A workbook names its locale in its file ("locale": "de-DE", no
version bump) or follows the new locale option, which falls back on
LC_ALL, LC_NUMERIC and LANG when nothing sets it. Setting it is one undo
step that redraws the sheet and changes no value.
Cells keep en-US's form. The UI translates at its edges: the formula bar
and editing show entries in the locale (sheet.LocalEntry), what's typed
or pasted is stored canonically (sheet.CanonicalEntry, with a ' for text
that would read as something else stored), function hints show ; between
arguments, and rule and filter arguments follow suit. The grid, chart
labels, the clipboard and the status line's Sum and Avg show numbers in
the locale.
Dates show day and month without leading zeros, as en-US's do, so they
fit a default column.- commit
- 4ce697c42f461345dec1afc6dcececf72a601c4d
- parent
- 6cefe51acdeb1b886503d96662be919e5057adfb
- author
- Dami <dami.etoile@gmail.com>
@@ -0,0 +1,32 @@
1+# Locale: a small budget typed in English (United States), then File >2+# Settings > Locale, here from the palette, shows it as Germany writes3+# it: decimal commas, day-first dates, euros after the number and ; in4+# formulas. The values don't change; the file stores the same thing.5+Output out/locale.gif6+Source lib/setup.tape7+8+Hide9+Type "O12_LOCALE=en-US ../bin/012" Enter10+Sleep 1.5s11+Show12+13+Sleep 600ms14+Type "Item" Tab Type "Amount" Tab Type "Due" Enter15+Type "Rent" Tab Type "$950.00" Tab Type "9/26/2026" Enter16+Type "Power" Tab Type "$84.20" Tab Type "10/3/2026" Enter17+Type "Total" Tab Type "=SUM(B2:B3)" Enter18+Type "VAT" Tab Type "=ROUND(B4*0.19, 2)" Enter19+Sleep 1.5s20+Ctrl+K21+Type@120ms "locale"22+Sleep 1s23+Enter24+Sleep 1s25+Type@120ms "germany"26+Sleep 1.5s27+Enter28+Sleep 1s29+Up Right30+Sleep 2.5s31+Screenshot out/stills/locale-de.png32+Sleep 500ms@@ -63,6 +63,7 @@ save), so they raise no version:
6363 | `protected` | a sheet | [Protected ranges](../sheets/notes-protection.md#protected-sheets-and-ranges): `{"range":"B2:C9","description":"Totals"}`, or `{"sheet":true}` |6464 | `conditionalFormats`, `validations` | a sheet | [Rules](#conditional-formats-and-data-validation), one per line |6565 | `arithmetic` | the workbook | `decimal` for [decimal arithmetic](../formulas/decimal.md) |66+| `locale` | the workbook | The [locale](../sheets/locale.md) it's typed and shown in (`"de-DE"`), when File > Settings > Locale chose one; without it, the file follows the reader's `locale` setting. Cells are stored the same way in every locale: `input` is always as typed in en-US (`1,234.5`, `9/26/2026`, `=ROUND(A1,2)`) |6667 | `macros`, `macroOrigin` | the workbook | Macros as Starlark scripts, and the computer they were made or trusted on: see [Macro scripting API](../reference/macro-api.md#in-the-file). Opening a file never runs them |6768 6869 ## Column and row formats@@ -79,7 +80,7 @@ formatting together; `A:XFD` is the whole sheet's format:
7980 8081 In version 4 the sheets are a list, each with its name and the fields a8182 version 3 file has at the top, and named ranges say their sheet. The82-workbook's settings (named ranges, decimal arithmetic, the sheet shown83+workbook's settings (named ranges, decimal arithmetic, the locale, the sheet shown8384 when saved as `active`) stay at the top:8485 8586 ```jsonBinary file.
@@ -33,7 +33,8 @@ config-file = ?local.conf
3333 line (and in `012 config`).3434 3535 Settings are process-wide. Settings that belong to a workbook, such as36-decimal arithmetic, are saved in the workbook's `.012` file instead.36+decimal arithmetic and its locale, are saved in the workbook's `.012` file37+instead; `locale` here is the default for workbooks without one.3738 3839 **Precedence:** command-line flags win over environment variables, which3940 win over the config file, which wins over the defaults. `012 config` shows@@ -71,6 +72,7 @@ start.
7172 | [`chart-images`](#chart-images) | `true` | `O12_CHART_IMAGES` |7273 | [`notifications`](#notifications) | `true` | `O12_NOTIFICATIONS` |7374 | [`keymap`](#keymap) | `default` | `O12_KEYMAP` |75+| [`locale`](#locale) | `en-US` | `O12_LOCALE` |7476 | [`max-cells`](#max-cells) | `2000000` | `O12_MAX_CELLS` |7577 | [`jev-api-key-command`](#jev-api-key-command) | | |7678 | [`jev-credential-store`](#jev-credential-store) | `true` | `O12_JEV_CREDENTIAL_STORE` |@@ -135,6 +137,18 @@ Keys in the grid. `default` works like Google Sheets; `vim` adds hjkl, counts, o
135137 136138 ### Data137139 140+#### `locale`141+142+How new sheets and files without a locale of their own are typed and shown: decimal and thousands separators, date order, the currency symbol and the formula argument separator (`;` where the decimal separator is a comma), as in Sheets' File > Settings > Locale, which sets a file's own. Files store the same thing in every locale. When unset, the POSIX locale (`LC_ALL`, `LC_NUMERIC`, then `LANG`, e.g. `de_DE.UTF-8`) picks it if it's one of these.143+144+| | |145+|---|---|146+| Type | one of `en-US`, `en-GB`, `en-CA`, `en-AU`, `de-DE`, `de-CH`, `fr-FR`, `fr-CA`, `es-ES`, `es-MX`, `it-IT`, `pt-BR`, `pt-PT`, `nl-NL`, `sv-SE`, `da-DK`, `nb-NO`, `fi-FI`, `pl-PL`, `cs-CZ`, `ru-RU`, `tr-TR`, `ja-JP`, `zh-CN` |147+| Default | `en-US` |148+| Environment | `O12_LOCALE` |149+| When unset | `LC_ALL`, `LC_NUMERIC`, `LANG` |150+| Applies | File > Settings > Reload config |151+138152 #### `max-cells`139153 140154 The most cells an import keeps, and a paste or fill writes at once. A sheet takes about 300 bytes a cell, so the default of two million is about 600 MB. Imports keep whole rows up to the budget and say how many they left out; larger pastes and fills are refused. The grid itself is 1,048,576 rows by 16,384 columns (A to XFD) whatever this is.@@ -22,3 +22,4 @@ working with its data.
2222 | [Notes and protection](notes-protection.md) | Notes on cells; ranges that warn before an edit |2323 | [Charts](charts.md) | Charts that float over the grid |2424 | [Macros](macros.md) | Recording and running what you do |25+| [Locale](locale.md) | Decimal commas, date order, currency and `;` in formulas, as a country writes them |@@ -0,0 +1,95 @@
1+---2+title: "Locale"3+sidebar_position: 124+---5+6+# Locale7+8+A file's locale says how its numbers, dates, currency and formulas are9+typed and shown, as File > Settings > Locale does in Google Sheets. In10+German (Germany) a sheet shows `1.234,56 €` and `26.9.2026`, takes11+`1,5` as a number, and formulas separate their arguments with `;`:12+`=ROUND(A1*1,19; 2)`.13+14+15+16+## Choosing one17+18+File > Settings > Locale lists the locales with a sample of each; type19+a language, country or tag to narrow it, Enter picks. The first entry,20+Default, follows the [`locale` setting](../reference/config.md#locale),21+which new sheets use and which reads `LC_ALL`, `LC_NUMERIC` or `LANG`22+(`de_DE.UTF-8`) when nothing sets it. A locale picked here is saved in23+the file, marks it modified and undoes with Ctrl+Z.24+25+| Tag | Locale | Number | Date | Currency |26+|---|---|---|---|---|27+| `en-US` | English (United States) | 1,234.56 | 9/26/2026 | $1,234.56 |28+| `en-GB` | English (United Kingdom) | 1,234.56 | 26/9/2026 | £1,234.56 |29+| `en-CA` | English (Canada) | 1,234.56 | 2026-09-26 | $1,234.56 |30+| `en-AU` | English (Australia) | 1,234.56 | 26/9/2026 | $1,234.56 |31+| `de-DE` | German (Germany) | 1.234,56 | 26.9.2026 | 1.234,56 € |32+| `de-CH` | German (Switzerland) | 1’234.56 | 26.9.2026 | CHF 1’234.56 |33+| `fr-FR` | French (France) | 1 234,56 | 26/9/2026 | 1 234,56 € |34+| `fr-CA` | French (Canada) | 1 234,56 | 2026-09-26 | 1 234,56 $ |35+| `es-ES` | Spanish (Spain) | 1.234,56 | 26/9/2026 | 1.234,56 € |36+| `es-MX` | Spanish (Mexico) | 1,234.56 | 26/9/2026 | $1,234.56 |37+| `it-IT` | Italian (Italy) | 1.234,56 | 26/9/2026 | 1.234,56 € |38+| `pt-BR` | Portuguese (Brazil) | 1.234,56 | 26/9/2026 | R$ 1.234,56 |39+| `pt-PT` | Portuguese (Portugal) | 1 234,56 | 26/9/2026 | 1 234,56 € |40+| `nl-NL` | Dutch (Netherlands) | 1.234,56 | 26-9-2026 | € 1.234,56 |41+| `sv-SE` | Swedish (Sweden) | 1 234,56 | 2026-09-26 | 1 234,56 kr |42+| `da-DK` | Danish (Denmark) | 1.234,56 | 26.9.2026 | 1.234,56 kr. |43+| `nb-NO` | Norwegian (Norway) | 1 234,56 | 26.9.2026 | kr 1 234,56 |44+| `fi-FI` | Finnish (Finland) | 1 234,56 | 26.9.2026 | 1 234,56 € |45+| `pl-PL` | Polish (Poland) | 1 234,56 | 26.9.2026 | 1 234,56 zł |46+| `cs-CZ` | Czech (Czechia) | 1 234,56 | 26.9.2026 | 1 234,56 Kč |47+| `ru-RU` | Russian (Russia) | 1 234,56 | 26.9.2026 | 1 234,56 ₽ |48+| `tr-TR` | Turkish (Türkiye) | 1.234,56 | 26.9.2026 | ₺1.234,56 |49+| `ja-JP` | Japanese (Japan) | 1,234.56 | 2026/9/26 | ¥1,234.56 |50+| `zh-CN` | Chinese (China) | 1,234.56 | 2026/9/26 | ¥1,234.56 |51+52+Dates show day and month without leading zeros, as en-US's do, so most53+fit a default column; ISO dates keep theirs. Spaces in numbers are54+no-break spaces, so a number never breaks across lines; when typing55+one, an ordinary space does.56+57+## What follows the locale58+59+- **Typing.** Numbers, percentages, currency and dates are read the60+ locale's way: `1.234,5`, `12,5 %`, `12,50 €` and `26.09.2026` in61+ German. A date of numbers follows the locale's order (day first in62+ most of Europe, year first in Japan and Sweden); `2026-09-26`, month63+ names in English (`26 Sep 2026`) and times (`14:30`) read the same64+ everywhere. What isn't a value in the locale stays text: `$5` is text65+ in German, `9/26/2026` in British English.66+- **Showing.** Automatic and every number format use the locale's67+ decimal and thousands separators. Currency, Date, Time and Date time68+ (Format > Number) show the locale's symbol and order; a69+ [custom format](formatting.md) keeps its own symbols and order, only70+ its separators follow the locale, as in Sheets.71+- **Formulas.** Where the decimal separator is a comma, arguments are72+ separated by `;` and the values of an array's row by `\`: `=IF(A1>1,5;73+ "yes"; "no")`, `={1\2;3\4}`. Function names stay in English, as in74+ Sheets. The formula bar, editing a cell and the function hints75+ (`ROUND(value; [places])`) all use the locale's form, as do formulas76+ in [conditional formatting, data validation](rules.md) and filter77+ conditions.78+- **The clipboard and CSV files.** Copying writes numbers with the79+ locale's decimal separator and pasting reads text the locale's way.80+ CSV files are [read and written](../files/README.md) in the locale81+ too.82+- **The status line.** Sum and Avg of the selection.83+84+## What doesn't85+86+The file stores everything the same way in every locale, so a sheet87+opens with the same values wherever it's opened: entries as typed in88+en-US (`1,234.5`, `09/26/2026`, `=ROUND(A1*1.19, 2)`; see89+[the file format](../files/format.md)). Switching the locale redraws90+the sheet and changes no value. Functions compute the same everywhere91+too: `VALUE("1,5")` and `TEXT(A1, "0.00")` read and write en-US's form92+in any locale, and macros and their [API](../reference/macro-api.md)93+set and read entries in en-US's form. [Excel files](../files/excel.md)94+are written with en-US's Currency (`$`) and Date (`m/d/yyyy`) formats95+whatever the locale, so their formats read back as the same formats.@@ -136,6 +136,7 @@ func Load(path string, getenv func(string) string, flags map[string]string) *Con
136136 break137137 }138138 }139+ c.fallBack(o, getenv)139140 }140141 }141142 for name, v := range flags {@@ -146,6 +147,20 @@ func Load(path string, getenv func(string) string, flags map[string]string) *Con
146147 return c147148 }148149 150+// fallBack sets an option nothing else set from the first of its151+// Fallback variables that Adopt makes a value of.152+func (c *Config) fallBack(o *Option, getenv func(string) string) {153+ if len(c.vals[o.Name]) > 0 {154+ return155+ }156+ for _, env := range o.Fallback {157+ if v := o.Adopt(strings.TrimSpace(getenv(env))); v != "" {158+ c.set(o, v, Source{Kind: FromEnv, Name: env})159+ return160+ }161+ }162+}163+149164 // readFile reads one file and then the files it includes. seen holds the150165 // files being read, to stop include cycles.151166 func (c *Config) readFile(path string, optional bool, seen map[string]bool) {@@ -331,3 +331,30 @@ func TestConfigDoc(t *testing.T) {
331331 t.Errorf("%s is stale: run go test ./internal/config -update-docs", path)332332 }333333 }334+335+// TestLocaleFallsBackOnPOSIX reads the locale from LC_ALL, LC_NUMERIC or336+// LANG only when nothing else sets it, and passes over POSIX locales it337+// doesn't have without a warning.338+func TestLocaleFallsBackOnPOSIX(t *testing.T) {339+ dir := t.TempDir()340+ set := write(t, filepath.Join(dir, "set"), "locale = fr-FR\n")341+ unset := filepath.Join(dir, "none")342+ cases := []struct {343+ path string344+ env map[string]string345+ want string346+ }{347+ {unset, map[string]string{"LANG": "de_DE.UTF-8"}, "de-DE"},348+ {unset, map[string]string{"LANG": "de_DE.UTF-8", "LC_ALL": "pt_BR.UTF-8"}, "pt-BR"},349+ {unset, map[string]string{"LANG": "C.UTF-8"}, "en-US"},350+ {unset, map[string]string{"LANG": "xx_YY.UTF-8", "LC_NUMERIC": "sv_SE"}, "sv-SE"},351+ {set, map[string]string{"LANG": "de_DE.UTF-8"}, "fr-FR"},352+ {unset, map[string]string{"LANG": "de_DE.UTF-8", "O12_LOCALE": "ja-JP"}, "ja-JP"},353+ }354+ for _, c := range cases {355+ cfg := Load(c.path, func(k string) string { return c.env[k] }, nil)356+ if got := cfg.String("locale"); got != c.want || len(cfg.Warnings) > 0 {357+ t.Errorf("%v: locale %q, want %q; warnings %s", c.env, got, c.want, warnings(cfg))358+ }359+ }360+}@@ -30,6 +30,9 @@ func Reference() string {
3030 if len(o.Env) > 0 {3131 fmt.Fprintf(&b, "| Environment | `%s` |\n", strings.Join(o.Env, "`, `"))3232 }33+ if len(o.Fallback) > 0 {34+ fmt.Fprintf(&b, "| When unset | `%s` |\n", strings.Join(o.Fallback, "`, `"))35+ }3336 if o.Flag != "" {3437 fmt.Fprintf(&b, "| Flag | `%s` |\n", o.Flag)3538 }@@ -6,7 +6,8 @@
66 // from it.77 //88 // Settings are process-wide. Settings that belong to a workbook (decimal9-// arithmetic) stay in the workbook's file. Secrets never go here: the JEV9+// arithmetic, its locale) stay in the workbook's file; the locale option is10+// only the default. Secrets never go here: the JEV1011 // API key lives in the OS credential store (see internal/keyring).1112 package config1213 @@ -18,6 +19,8 @@ import (
1819 "strconv"1920 "strings"2021 "time"22+23+ "github.com/FelineStateMachine/012/internal/locale"2124 )2225 2326 // Kind is an option's type.@@ -47,10 +50,16 @@ type Option struct {
4750 Default string // as it would be written in the file4851 Values []string // for Enum4952 Env []string // environment variables that set it, first set wins50- Flag string // command-line flag, e.g. "--log"51- Repeat bool // may be given more than once; each adds a value52- Live bool // Reload config applies it without restarting53- Desc string // what it does, one or more sentences53+ // Fallback are environment variables read, first set wins, when54+ // nothing else sets the option, through Adopt, which turns their55+ // value into one of the option's or "" to pass over it quietly (LANG56+ // may name a locale 012 lacks).57+ Fallback []string58+ Adopt func(string) string59+ Flag string // command-line flag, e.g. "--log"60+ Repeat bool // may be given more than once; each adds a value61+ Live bool // Reload config applies it without restarting62+ Desc string // what it does, one or more sentences5463 // Check validates a value beyond its kind. Invalid values are5564 // reported as warnings and the option keeps its previous value.5665 Check func(string) error@@ -92,6 +101,14 @@ var Options = []Option{
92101 Desc: "Keys in the grid. `default` works like Google Sheets; `vim` adds hjkl, counts, operators, " +93102 "visual selection and a : command line (File > Settings > Vim keys)."},94103 104+ {Name: "locale", Kind: Enum, Group: GroupData, Default: "en-US", Values: locale.Tags(), Env: []string{"O12_LOCALE"},105+ Fallback: []string{"LC_ALL", "LC_NUMERIC", "LANG"}, Adopt: locale.FromPOSIX, Live: true,106+ Desc: "How new sheets and files without a locale of their own are typed and shown: decimal and " +107+ "thousands separators, date order, the currency symbol and the formula argument separator " +108+ "(`;` where the decimal separator is a comma), as in Sheets' File > Settings > Locale, " +109+ "which sets a file's own. Files store the same thing in every locale. " +110+ "When unset, the POSIX locale (`LC_ALL`, `LC_NUMERIC`, then `LANG`, e.g. `de_DE.UTF-8`) picks it if it's one of these."},111+95112 {Name: "max-cells", Kind: Int, Group: GroupData, Default: "2000000", Env: []string{"O12_MAX_CELLS"}, Live: true,96113 Desc: "The most cells an import keeps, and a paste or fill writes at once. A sheet takes about " +97114 "300 bytes a cell, so the default of two million is about 600 MB. Imports keep whole rows " +@@ -51,33 +51,34 @@ const (
5151 nnbsp = " "5252 )5353 54-// table lists the locales, en-US first. Each sticks to what Sheets shows55-// by default: Date, Time and Currency are the formats of Format > Number56-// in that locale.54+// table lists the locales, en-US first. Date, Time and Currency are the55+// formats of Format > Number in each. Dates show day and month without56+// leading zeros, as en-US's m/d/yyyy does, so most fit a default column57+// (26.9.2026); where a country writes ISO dates they keep theirs.5758 var table = []Locale{5859 {Tag: "en-US", Name: "English (United States)", Decimal: '.', Group: ",", Order: MDY, DateSep: '/', Date: "m/d/yyyy", Time: "h:mm:ss am/pm", Currency: "$"},59- {Tag: "en-GB", Name: "English (United Kingdom)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "dd/mm/yyyy", Time: "hh:mm:ss", Currency: "£"},60+ {Tag: "en-GB", Name: "English (United Kingdom)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "£"},6061 {Tag: "en-CA", Name: "English (Canada)", Decimal: '.', Group: ",", Order: YMD, DateSep: '-', Date: "yyyy-mm-dd", Time: "h:mm:ss am/pm", Currency: "$"},61- {Tag: "en-AU", Name: "English (Australia)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "d/mm/yyyy", Time: "h:mm:ss am/pm", Currency: "$"},62- {Tag: "de-DE", Name: "German (Germany)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "dd.mm.yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},63- {Tag: "de-CH", Name: "German (Switzerland)", Decimal: '.', Group: "’", Order: DMY, DateSep: '.', Date: "dd.mm.yyyy", Time: "hh:mm:ss", Currency: "CHF", Space: true},64- {Tag: "fr-FR", Name: "French (France)", Decimal: ',', Group: nnbsp, Order: DMY, DateSep: '/', Date: "dd/mm/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},62+ {Tag: "en-AU", Name: "English (Australia)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "h:mm:ss am/pm", Currency: "$"},63+ {Tag: "de-DE", Name: "German (Germany)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},64+ {Tag: "de-CH", Name: "German (Switzerland)", Decimal: '.', Group: "’", Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "CHF", Space: true},65+ {Tag: "fr-FR", Name: "French (France)", Decimal: ',', Group: nnbsp, Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},6566 {Tag: "fr-CA", Name: "French (Canada)", Decimal: ',', Group: nbsp, Order: YMD, DateSep: '-', Date: "yyyy-mm-dd", Time: "hh:mm:ss", Currency: "$", After: true, Space: true},66- {Tag: "es-ES", Name: "Spanish (Spain)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "dd/mm/yyyy", Time: "h:mm:ss", Currency: "€", After: true, Space: true},67- {Tag: "es-MX", Name: "Spanish (Mexico)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "dd/mm/yyyy", Time: "hh:mm:ss", Currency: "$"},68- {Tag: "it-IT", Name: "Italian (Italy)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "dd/mm/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},69- {Tag: "pt-BR", Name: "Portuguese (Brazil)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "dd/mm/yyyy", Time: "hh:mm:ss", Currency: "R$", Space: true},70- {Tag: "pt-PT", Name: "Portuguese (Portugal)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '/', Date: "dd/mm/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},67+ {Tag: "es-ES", Name: "Spanish (Spain)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "h:mm:ss", Currency: "€", After: true, Space: true},68+ {Tag: "es-MX", Name: "Spanish (Mexico)", Decimal: '.', Group: ",", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "$"},69+ {Tag: "it-IT", Name: "Italian (Italy)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},70+ {Tag: "pt-BR", Name: "Portuguese (Brazil)", Decimal: ',', Group: ".", Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "R$", Space: true},71+ {Tag: "pt-PT", Name: "Portuguese (Portugal)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '/', Date: "d/m/yyyy", Time: "hh:mm:ss", Currency: "€", After: true, Space: true},7172 {Tag: "nl-NL", Name: "Dutch (Netherlands)", Decimal: ',', Group: ".", Order: DMY, DateSep: '-', Date: "d-m-yyyy", Time: "hh:mm:ss", Currency: "€", Space: true},7273 {Tag: "sv-SE", Name: "Swedish (Sweden)", Decimal: ',', Group: nbsp, Order: YMD, DateSep: '-', Date: "yyyy-mm-dd", Time: "hh:mm:ss", Currency: "kr", After: true, Space: true},73- {Tag: "da-DK", Name: "Danish (Denmark)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "dd.mm.yyyy", Time: "hh:mm:ss", Currency: "kr.", After: true, Space: true},74- {Tag: "nb-NO", Name: "Norwegian (Norway)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "dd.mm.yyyy", Time: "hh:mm:ss", Currency: "kr", Space: true},74+ {Tag: "da-DK", Name: "Danish (Denmark)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "kr.", After: true, Space: true},75+ {Tag: "nb-NO", Name: "Norwegian (Norway)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "kr", Space: true},7576 {Tag: "fi-FI", Name: "Finnish (Finland)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "h:mm:ss", Currency: "€", After: true, Space: true},76- {Tag: "pl-PL", Name: "Polish (Poland)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "dd.mm.yyyy", Time: "hh:mm:ss", Currency: "zł", After: true, Space: true},77- {Tag: "cs-CZ", Name: "Czech (Czechia)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "dd.mm.yyyy", Time: "h:mm:ss", Currency: "Kč", After: true, Space: true},78- {Tag: "ru-RU", Name: "Russian (Russia)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "dd.mm.yyyy", Time: "h:mm:ss", Currency: "₽", After: true, Space: true},79- {Tag: "tr-TR", Name: "Turkish (Türkiye)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "dd.mm.yyyy", Time: "hh:mm:ss", Currency: "₺"},80- {Tag: "ja-JP", Name: "Japanese (Japan)", Decimal: '.', Group: ",", Order: YMD, DateSep: '/', Date: "yyyy/mm/dd", Time: "h:mm:ss", Currency: "¥"},77+ {Tag: "pl-PL", Name: "Polish (Poland)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "zł", After: true, Space: true},78+ {Tag: "cs-CZ", Name: "Czech (Czechia)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "h:mm:ss", Currency: "Kč", After: true, Space: true},79+ {Tag: "ru-RU", Name: "Russian (Russia)", Decimal: ',', Group: nbsp, Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "h:mm:ss", Currency: "₽", After: true, Space: true},80+ {Tag: "tr-TR", Name: "Turkish (Türkiye)", Decimal: ',', Group: ".", Order: DMY, DateSep: '.', Date: "d.m.yyyy", Time: "hh:mm:ss", Currency: "₺"},81+ {Tag: "ja-JP", Name: "Japanese (Japan)", Decimal: '.', Group: ",", Order: YMD, DateSep: '/', Date: "yyyy/m/d", Time: "h:mm:ss", Currency: "¥"},8182 {Tag: "zh-CN", Name: "Chinese (China)", Decimal: '.', Group: ",", Order: YMD, DateSep: '/', Date: "yyyy/m/d", Time: "h:mm:ss", Currency: "¥"},8283 }8384 @@ -4,6 +4,7 @@ import (
44 "strings"55 "unicode/utf8"66 7+ "github.com/FelineStateMachine/012/internal/locale"78 "github.com/FelineStateMachine/012/internal/numfmt"89 )910 @@ -14,6 +15,12 @@ import (
1415 // Automatic first drops decimals and falls back to scientific notation.1516 // Accounting returns AlignFill: exactly width columns, $ at the left.1617 func Display(v Value, f Format, width int) (string, Align) {18+ return DisplayIn(v, f, width, locale.Canonical)19+}20+21+// DisplayIn is Display as shown in loc: its separators, currency and22+// date order (see Format.CodeIn).23+func DisplayIn(v Value, f Format, width int, loc *locale.Locale) (string, Align) {1724 inner := max(width-1, 0)1825 switch v.Kind {1926 case Empty:@@ -25,16 +32,16 @@ func Display(v Value, f Format, width int) (string, Align) {
2532 }2633 switch f.Kind {2734 case FmtAuto:28- return numfmt.GeneralFit(v.Num, inner), AlignRight35+ return numfmt.GeneralFitIn(v.Num, inner, loc), AlignRight2936 case FmtText:30- return numfmt.General(v.Num), AlignLeft37+ return numfmt.GeneralIn(v.Num, loc), AlignLeft3138 case FmtAccounting:32- if s, ok := numfmt.Accounting(v.Num, f.Decimals, width); ok {39+ if s, ok := numfmt.AccountingIn(v.Num, f.Decimals, width, loc); ok {3340 return s, AlignFill3441 }3542 return strings.Repeat("#", inner), AlignRight3643 }37- s := numfmt.Format(v.Num, f.Code())44+ s := numfmt.FormatIn(v.Num, f.CodeIn(loc), loc)3845 if utf8.RuneCountInString(s) > inner {3946 s = strings.Repeat("#", inner)4047 }@@ -46,15 +53,19 @@ func FormatPattern(v float64, pat string) string { return numfmt.Format(v, pat)
4653 4754 // FormatText renders v under f with no width limit, e.g. for TEXT() or4855 // copying out of the grid.49-func FormatText(v Value, f Format) string {56+func FormatText(v Value, f Format) string { return FormatTextIn(v, f, locale.Canonical) }57+58+// FormatTextIn is FormatText as shown in loc, e.g. for a CSV file59+// written in loc.60+func FormatTextIn(v Value, f Format, loc *locale.Locale) string {5061 if v.Kind != Number {5162 return text(v)5263 }5364 switch f.Kind {5465 case FmtAuto, FmtText:55- return numfmt.General(v.Num)66+ return numfmt.GeneralIn(v.Num, loc)5667 }57- return numfmt.Format(v.Num, f.Code())68+ return numfmt.FormatIn(v.Num, f.CodeIn(loc), loc)5869 }5970 6071 // FormatValue renders v in width columns with one column of padding, the@@ -8,6 +8,8 @@ import (
88 "maps"99 "slices"1010 "strings"11+12+ "github.com/FelineStateMachine/012/internal/locale"1113 )1214 1315 // FileExt is the extension of the native worksheet format.@@ -49,6 +51,10 @@ type fileFormat struct {
4951 // version bump: earlier builds ignore it and compute in binary, as5052 // Sheets would. It is for the whole workbook, so it stays at the top.5153 Arithmetic string `json:"arithmetic,omitempty"`54+ // Locale is the tag of the workbook's locale ("de-DE"), when it55+ // names one. It only changes how entries are typed and shown (cells56+ // are stored in en-US's form), so it needs no version bump either.57+ Locale string `json:"locale,omitempty"`5258 // Macros and where they were made need no version bump either:5359 // earlier builds ignore them, and the sheets read the same.5460 MacroOrigin string `json:"macroOrigin,omitempty"`@@ -138,7 +144,7 @@ func jsonString(s string) string {
138144 }139145 140146 // headLines are the workbook's fields before the sheets: the named ranges141-// and the arithmetic setting, separated as the lines of the file.147+// and the settings, separated as the lines of the file.142148 func (w *Workbook) headLines() string {143149 var lines []string144150 if names := w.namesLine(); names != "" {@@ -147,6 +153,9 @@ func (w *Workbook) headLines() string {
147153 if w.decimal {148154 lines = append(lines, `"arithmetic": "decimal"`)149155 }156+ if w.locale != nil {157+ lines = append(lines, `"locale": `+jsonString(w.locale.Tag))158+ }150159 if len(w.macros) > 0 {151160 if w.macroOrigin != "" {152161 lines = append(lines, `"macroOrigin": `+jsonString(w.macroOrigin))@@ -310,6 +319,10 @@ func readBook(r io.Reader, trace any) (*Workbook, error) {
310319 // Anything but "decimal" (say, a mode from a later build) computes in311320 // binary, as the file would in a build without the setting.312321 w.decimal = f.Arithmetic == "decimal"322+ // A locale this build doesn't know follows the default.323+ if l, ok := locale.Lookup(f.Locale); ok {324+ w.locale = l325+ }313326 if err := w.readMacros(f.Macros); err != nil {314327 return nil, err315328 }@@ -53,8 +53,9 @@ type step struct {
5353 rules map[*Sheet]rulesState5454 // sheets is the sheet list before the step, when it changed.5555 sheets *sheetList56- // decimal is the arithmetic setting before the step, when it changed.57- decimal *bool56+ // settings are the workbook's settings before the step, when they57+ // changed.58+ settings *settings5859 // macros is the macro list before the step, when it changed.5960 macros *[]Macro6061 }@@ -76,7 +77,7 @@ func (st *step) empty() bool {
7677 7778 // widthOnly reports whether the step changed nothing but column widths.7879 func (st *step) widthOnly() bool {79- return len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && st.sheets == nil && st.decimal == nil &&80+ return len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && st.sheets == nil && st.settings == nil &&8081 st.macros == nil8182 }8283 @@ -211,12 +212,12 @@ func (w *Workbook) recordSheets() {
211212 }212213 }213214 214-// recordDecimal saves the arithmetic setting before its first change in215-// the open step.216-func (w *Workbook) recordDecimal() {217- if st := w.hist.open; st != nil && st.decimal == nil {218- d := w.decimal219- st.decimal = &d215+// recordSettings saves the workbook's settings before their first change216+// in the open step.217+func (w *Workbook) recordSettings() {218+ if st := w.hist.open; st != nil && st.settings == nil {219+ cur := w.settings220+ st.settings = &cur220221 }221222 }222223 @@ -273,8 +274,8 @@ func (w *Workbook) dropUnchanged(st *step) {
273274 if st.sheets != nil && st.sheets.equal(w.sheetList()) {274275 st.sheets = nil275276 }276- if st.decimal != nil && *st.decimal == w.decimal {277- st.decimal = nil277+ if st.settings != nil && *st.settings == w.settings {278+ st.settings = nil278279 }279280 if st.macros != nil && slices.Equal(*st.macros, w.macros) {280281 st.macros = nil@@ -451,10 +452,11 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
451452 s.rules = r452453 s.looks.reset()453454 }454- if st.decimal != nil {455- cur := w.decimal456- inv.decimal, w.decimal = &cur, *st.decimal457- w.structural = true // every formula computes differently455+ if st.settings != nil {456+ cur := w.settings457+ inv.settings, w.settings = &cur, *st.settings458+ // Every formula computes differently in the other arithmetic.459+ w.structural = w.structural || cur.decimal != w.decimal458460 }459461 if st.macros != nil {460462 cur := w.macros@@ -0,0 +1,185 @@
1+package sheet2+3+import (4+ "strings"5+ "sync/atomic"6+7+ "github.com/FelineStateMachine/012/internal/formula"8+ "github.com/FelineStateMachine/012/internal/locale"9+ "github.com/FelineStateMachine/012/internal/numfmt"10+ "github.com/FelineStateMachine/012/internal/value"11+)12+13+// A workbook's locale is how its entries are typed and its numbers,14+// dates and formulas shown, as Sheets' File > Settings > Locale. Cells15+// keep their entries in en-US's form whatever the locale (see16+// value.Canonicalize and formula.Delocalize), so values never depend on17+// it: switching the locale redraws the sheet and changes nothing else,18+// and a file reads the same everywhere. Functions compute in en-US's19+// form too (VALUE("1,5") is an error in every locale), so formulas give20+// the same results everywhere.21+22+// settings are the workbook's own settings, saved in its file: Sheets'23+// File > Settings.24+type settings struct {25+ decimal bool // decimal arithmetic, see decimal.go26+ locale *locale.Locale // nil follows the default27+}28+29+// defaultLocale is the locale of workbooks that don't name one: the30+// config's locale, set with SetDefaultLocale.31+var defaultLocale atomic.Pointer[locale.Locale]32+33+// SetDefaultLocale sets the locale of workbooks that don't name one of34+// their own; nil is en-US.35+func SetDefaultLocale(l *locale.Locale) { defaultLocale.Store(l) }36+37+// DefaultLocale is the locale of workbooks that don't name one.38+func DefaultLocale() *locale.Locale { return defaultLocale.Load().Or() }39+40+// Locale is the workbook's locale: its own, or the default.41+func (w *Workbook) Locale() *locale.Locale {42+ if w.locale != nil {43+ return w.locale44+ }45+ return DefaultLocale()46+}47+48+// LocaleTag is the tag of the locale the workbook names, or "" when it49+// follows the default.50+func (w *Workbook) LocaleTag() string {51+ if w.locale == nil {52+ return ""53+ }54+ return w.locale.Tag55+}56+57+// SetLocale sets the workbook's locale by its tag, "" to follow the58+// default, as one undo step. Values don't change, only how they're59+// typed and shown. It reports false for a tag it doesn't know.60+func (w *Workbook) SetLocale(tag string) bool {61+ var l *locale.Locale62+ if tag != "" {63+ var ok bool64+ if l, ok = locale.Lookup(tag); !ok {65+ return false66+ }67+ }68+ if l == w.locale {69+ return true70+ }71+ label := "follow the default locale"72+ if l != nil {73+ label = "set the locale to " + l.Tag74+ }75+ w.change(w.sheets[w.Active()], label, Rect{}, func() {76+ w.recordSettings()77+ w.locale = l78+ })79+ return true80+}81+82+// Locale is the sheet's workbook's.83+func (s *Sheet) Locale() *locale.Locale { return s.wb.Locale() }84+85+// CanonicalEntry is an entry typed in loc as the cell stores it: numbers,86+// dates and times in en-US's form, formulas with its separators, text as87+// typed, behind a ' when it would read as something else stored (1.5,88+// typed as text in fr-FR).89+func CanonicalEntry(typed string, loc *locale.Locale) string {90+ if loc.IsCanonical() || typed == "" || strings.HasPrefix(typed, "'") {91+ return typed92+ }93+ if c, ok := value.Canonicalize(typed, loc); ok {94+ return c95+ }96+ switch strings.ToUpper(typed) {97+ case "TRUE", "FALSE":98+ return typed99+ }100+ if strings.HasPrefix(typed, "=") {101+ return formula.Delocalize(typed, loc)102+ }103+ if strings.HasPrefix(typed, "+") || strings.HasPrefix(typed, "-") {104+ if f := formula.Delocalize(typed, loc); IsFormulaEntry(f) {105+ return f106+ }107+ }108+ if storedAsOther(typed) {109+ return "'" + typed110+ }111+ return typed112+}113+114+// LocalEntry is the inverse of CanonicalEntry: a cell's entry as typed115+// in loc, as the formula bar shows it and editing starts from.116+func LocalEntry(input string, loc *locale.Locale) string {117+ if loc.IsCanonical() || input == "" {118+ return input119+ }120+ if rest, ok := strings.CutPrefix(input, "'"); ok {121+ // The ' CanonicalEntry adds, when typing rest in loc is text.122+ if storedAsOther(rest) && CanonicalEntry(rest, loc) == input {123+ return rest124+ }125+ return input126+ }127+ if _, _, ok := ParseValue(input); ok {128+ return value.Localize(input, loc)129+ }130+ if IsFormulaEntry(input) {131+ return formula.Localize(input, loc)132+ }133+ return input134+}135+136+// CanonicalArg is the argument of a rule or filter condition typed in137+// loc as it's stored: a number or date in en-US's form, a formula (after138+// =) in its syntax, text as typed.139+func CanonicalArg(arg string, loc *locale.Locale) string {140+ if loc.IsCanonical() {141+ return arg142+ }143+ if c, ok := value.Canonicalize(arg, loc); ok {144+ return c145+ }146+ if strings.HasPrefix(arg, "=") {147+ return formula.Delocalize(arg, loc)148+ }149+ return arg150+}151+152+// LocalArg is the inverse of CanonicalArg, for editing an argument again.153+func LocalArg(arg string, loc *locale.Locale) string {154+ if loc.IsCanonical() {155+ return arg156+ }157+ if _, _, ok := ParseValue(arg); ok {158+ return value.Localize(arg, loc)159+ }160+ if strings.HasPrefix(arg, "=") {161+ return formula.Localize(arg, loc)162+ }163+ return arg164+}165+166+// textIn is v as General shows it in loc: 1,5 in de-DE.167+func textIn(v Value, loc *locale.Locale) string {168+ if v.Kind == Number {169+ return numfmt.GeneralIn(v.Num, loc)170+ }171+ return v.String()172+}173+174+// storedAsOther reports whether an entry stored as s isn't text: a175+// number, date, formula or boolean.176+func storedAsOther(s string) bool {177+ if _, _, ok := ParseValue(s); ok {178+ return true179+ }180+ switch strings.ToUpper(s) {181+ case "TRUE", "FALSE":182+ return true183+ }184+ return IsFormulaEntry(s)185+}@@ -0,0 +1,133 @@
1+package sheet2+3+import (4+ "bytes"5+ "strings"6+ "testing"7+8+ "github.com/FelineStateMachine/012/internal/locale"9+)10+11+func mustLocale(t *testing.T, tag string) *locale.Locale {12+ t.Helper()13+ l, ok := locale.Lookup(tag)14+ if !ok {15+ t.Fatalf("no locale %s", tag)16+ }17+ return l18+}19+20+// TestEntriesInLocales types entries in a locale, stores them, and checks21+// the value, the format and what editing the cell shows again.22+func TestEntriesInLocales(t *testing.T) {23+ cases := []struct {24+ tag, typed, stored, shown string25+ kind FormatKind26+ }{27+ {"de-DE", "1.234,5", "1,234.5", "1.234,5", FmtNumber},28+ {"de-DE", "12,50 €", "$12.50", "12,50 €", FmtCurrency},29+ {"de-DE", "26.09.2026", "09/26/2026", "26.09.2026", FmtDate},30+ {"de-DE", "=SUM(1;2)", "=SUM(1,2)", "=SUM(1;2)", FmtAuto},31+ {"de-DE", "=ROUND(A1*1,19; 2)", "=ROUND(A1*1.19, 2)", "=ROUND(A1*1,19; 2)", FmtAuto},32+ {"de-DE", "-A1*0,5", "-A1*0.5", "-A1*0,5", FmtAuto},33+ {"de-DE", "Hallo, Welt", "Hallo, Welt", "Hallo, Welt", FmtAuto},34+ {"fr-FR", "1.5", "'1.5", "1.5", FmtAuto},35+ {"de-DE", "1,234.5", "'1,234.5", "1,234.5", FmtAuto},36+ {"de-DE", "'1,5", "'1,5", "'1,5", FmtAuto},37+ {"de-DE", "TRUE", "TRUE", "TRUE", FmtAuto},38+ {"en-GB", "26/09/2026", "09/26/2026", "26/09/2026", FmtDate},39+ {"en-GB", "9/26/2026", "'9/26/2026", "9/26/2026", FmtAuto},40+ {"en-US", "1,234.5", "1,234.5", "1,234.5", FmtNumber},41+ }42+ for _, c := range cases {43+ l := mustLocale(t, c.tag)44+ stored := CanonicalEntry(c.typed, l)45+ if stored != c.stored {46+ t.Errorf("%s %q stored as %q, want %q", c.tag, c.typed, stored, c.stored)47+ continue48+ }49+ if shown := LocalEntry(stored, l); shown != c.shown {50+ t.Errorf("%s %q shown as %q, want %q", c.tag, stored, shown, c.shown)51+ }52+ s := New()53+ if err := s.Set(at("A1"), stored); err != nil {54+ if !strings.Contains(c.typed, "SUM") {55+ t.Errorf("%s %q: %v", c.tag, c.typed, err)56+ }57+ continue58+ }59+ if k := s.Cell(at("A1")).Format.Kind; k != c.kind {60+ t.Errorf("%s %q: format %v, want %v", c.tag, c.typed, k, c.kind)61+ }62+ }63+}64+65+// TestSetLocale switches a workbook's locale: values stay, the display66+// follows, undo puts it back, and the file keeps it.67+func TestSetLocale(t *testing.T) {68+ s := New()69+ for a, in := range map[string]string{"A1": "1234.5", "A2": "$12.50", "A3": "9/26/2026", "A4": "=A1/4"} {70+ if err := s.Set(at(a), in); err != nil {71+ t.Fatal(err)72+ }73+ }74+ s.SetFormat(NewRect(at("A1"), at("A1")), Preset(FmtNumber))75+ shown := func(a string) string {76+ text, _ := DisplayIn(s.Cell(at(a)).Value, s.DisplayFormat(at(a)), 20, s.Locale())77+ return text78+ }79+ before := s.Cell(at("A4")).Value80+ if !s.Book().SetLocale("de-DE") {81+ t.Fatal("de-DE unknown")82+ }83+ if got := s.Cell(at("A4")).Value; got != before {84+ t.Errorf("A4 changed to %v", got)85+ }86+ for a, want := range map[string]string{"A1": "1.234,50", "A2": "12,50 €", "A3": "26.9.2026", "A4": "308,63"} {87+ if got := shown(a); got != want {88+ t.Errorf("de-DE %s = %q, want %q", a, got, want)89+ }90+ }91+ var buf bytes.Buffer92+ if err := s.Write(&buf); err != nil {93+ t.Fatal(err)94+ }95+ if !strings.Contains(buf.String(), `"locale": "de-DE"`) || !strings.Contains(buf.String(), `"A4": "=A1/4"`) {96+ t.Errorf("file:\n%s", buf.String())97+ }98+ back, err := Read(&buf)99+ if err != nil {100+ t.Fatal(err)101+ }102+ if back.Book().LocaleTag() != "de-DE" || back.Cell(at("A4")).Value != before {103+ t.Errorf("read back %q, %v", back.Book().LocaleTag(), back.Cell(at("A4")).Value)104+ }105+ if _, ok := s.Undo(); !ok {106+ t.Fatal("nothing to undo")107+ }108+ if s.Book().LocaleTag() != "" || shown("A1") != "1,234.50" {109+ t.Errorf("after undo: %q, %q", s.Book().LocaleTag(), shown("A1"))110+ }111+ if s.Book().SetLocale("xx-YY") {112+ t.Error("xx-YY accepted")113+ }114+}115+116+// TestDefaultLocale follows the config's locale unless the file names117+// its own.118+func TestDefaultLocale(t *testing.T) {119+ defer SetDefaultLocale(nil)120+ SetDefaultLocale(mustLocale(t, "fr-FR"))121+ w := NewBook()122+ if w.Locale().Tag != "fr-FR" {123+ t.Errorf("new workbook in %s", w.Locale().Tag)124+ }125+ w.SetLocale("ja-JP")126+ if w.Locale().Tag != "ja-JP" {127+ t.Errorf("workbook in %s", w.Locale().Tag)128+ }129+ SetDefaultLocale(nil)130+ if NewBook().Locale().Tag != "en-US" {131+ t.Error("the default isn't en-US")132+ }133+}@@ -7,6 +7,7 @@ import (
77 "sync/atomic"88 99 "github.com/FelineStateMachine/012/internal/formula"10+ "github.com/FelineStateMachine/012/internal/locale"1011 )1112 1213 // Errors returned by operations that would lose data or don't fit.@@ -215,12 +216,12 @@ func (c *Clip) Size() (cols, rows int) {
215216 return c.Src.To.Col - c.Src.From.Col + 1, c.Src.To.Row - c.Src.From.Row + 1216217 }217218 218-// Text returns the clip's values as displayed in General format, row by219+// TextIn returns the clip's values as General shows them in loc, row by219220 // row, for the system clipboard. Blank rows and columns past the last220221 // value are left off, so copying whole columns gives their data; a block221222 // of more than MaxCells cells, blanks between values included, is too222-// big for text, and Text returns nil.223-func (c *Clip) Text() [][]string {223+// big for text, and TextIn returns nil.224+func (c *Clip) TextIn(loc *locale.Locale) [][]string {224225 cols, rows := 0, 0225226 for off, cell := range c.cells {226227 if !cell.Blank() {@@ -235,7 +236,7 @@ func (c *Clip) Text() [][]string {
235236 out[r] = make([]string, cols)236237 for col := range out[r] {237238 if cell := c.cells[Addr{Col: col, Row: r}]; cell != nil {238- out[r][col] = cell.Value.String()239+ out[r][col] = textIn(cell.Value, loc)239240 }240241 }241242 }@@ -49,10 +49,10 @@ type Workbook struct {
4949 evaluating loc5050 // depth counts the cells and operators being evaluated, nested; see5151 // evaluate.go.52- depth int53- hist history // undo and redo, see history.go54- active int // the sheet last shown, saved in the file55- decimal bool // decimal arithmetic, see decimal.go52+ depth int53+ hist history // undo and redo, see history.go54+ active int // the sheet last shown, saved in the file55+ settings5656 5757 macros []Macro // see macros.go5858 macroOrigin string@@ -10,7 +10,6 @@ import (
1010 1111 "github.com/FelineStateMachine/012/internal/sheet"1212 "github.com/FelineStateMachine/012/internal/ui/formula"13- "github.com/FelineStateMachine/012/internal/ui/lineedit"1413 "github.com/FelineStateMachine/012/internal/ui/overlay"1514 "github.com/FelineStateMachine/012/internal/ui/theme"1615 )@@ -88,7 +87,7 @@ func (a *assist) shown(m *Model) ([]suggestion, int) {
8887 if (m.mode != modeEnter && m.mode != modeEdit) || !m.line.IsFormula() || !a.active || m.overlay != nil {8988 return nil, 09089 }91- c := formula.ScanCaret(m.line.Buf, m.line.Pos)90+ c := formula.ScanCaret(m.storedFormula(m.line.Buf), m.line.Pos)9291 if c.Word == "" {9392 return nil, 09493 }@@ -237,16 +236,16 @@ func (a *assist) status(m *Model) (desc, keys string, ok bool) {
237236 238237 // inFunction reports whether the caret is inside a known function's239238 // parentheses.240-func inFunction(l *lineedit.Line) bool {241- _, ok := sheet.LookupFunc(formula.ScanCaret(l.Buf, l.Pos).Fn)239+func (m *Model) inFunction() bool {240+ _, ok := sheet.LookupFunc(formula.ScanCaret(m.storedFormula(m.line.Buf), m.line.Pos).Fn)242241 return ok243242 }244243 245244 // signatureLine puts the signature of the function around the caret on246245 // the context line, with what the function does and the keys that apply247246 // (hints) as room allows. It reports false outside a function.248-func signatureLine(th *theme.Theme, width int, buf []rune, pos int, hints string) (left, right string, ok bool) {249- sig, desc := signature(th, buf, pos)247+func signatureLine(th *theme.Theme, width int, buf []rune, pos int, sep byte, hints string) (left, right string, ok bool) {248+ sig, desc := signature(th, buf, pos, sep)250249 if sig == "" {251250 return "", "", false252251 }@@ -261,8 +260,10 @@ func signatureLine(th *theme.Theme, width int, buf []rune, pos int, hints string
261260 262261 // signature renders the signature of the function around the caret, e.g.263262 // SUM(value1, [value2, ...]), with the current argument marked, and what264-// the function does. It is "" outside a function.265-func signature(th *theme.Theme, buf []rune, pos int) (sig, desc string) {263+// the function does. It is "" outside a function. buf is the formula in264+// the syntax it's parsed in (see Model.storedFormula); sep separates the265+// arguments shown, ; in a locale with a decimal comma.266+func signature(th *theme.Theme, buf []rune, pos int, sep byte) (sig, desc string) {266267 c := formula.ScanCaret(buf, pos)267268 f, ok := sheet.LookupFunc(c.Fn)268269 if c.Fn == "" || !ok {@@ -274,7 +275,10 @@ func signature(th *theme.Theme, buf []rune, pos int) (sig, desc string) {
274275 b.WriteString(th.Key.Render(f.Name) + "(")275276 for i, p := range parts {276277 if i > 0 {277- b.WriteString(", ")278+ b.WriteString(string(sep) + " ")279+ }280+ if sep != ',' {281+ p = strings.ReplaceAll(p, ",", string(sep)) // [value2; ...]278282 }279283 if i == cur {280284 b.WriteString(th.Argument.Render(p))@@ -38,7 +38,7 @@ func TestAutocompleteFunctions(t *testing.T) {
3838 t.Errorf("signature %q", ctx)3939 }4040 press(t, m, "1,")41- sig, _ := signature(&m.th, m.line.Buf, m.line.Pos)41+ sig, _ := signature(&m.th, m.line.Buf, m.line.Pos, ',')4242 if !strings.Contains(sig, m.th.Argument.Render("[value2, ...]")) {4343 t.Errorf("second argument not marked: %q", sig)4444 }@@ -44,7 +44,7 @@ func init() {
4444 func (m *Model) copy(cut bool) tea.Cmd {4545 clip := m.sheet.Copy(m.selection())4646 m.copied = clipboard{clip: clip, sheet: m.sheet, cut: cut, marked: true}47- text := clip.Text()47+ text := clip.TextIn(m.locale())4848 if text == nil {4949 m.note = "Copied; too many cells for the system clipboard, but Ctrl+V pastes them here"5050 return nil@@ -167,7 +167,7 @@ func (m *Model) pasteText(content string) bool {
167167 v = row[j]168168 }169169 a := sheet.Addr{Col: r.From.Col + j, Row: r.From.Row + i}170- if m.sheet.Set(a, v) != nil {170+ if m.sheet.Set(a, m.storedEntry(v)) != nil {171171 m.sheet.Set(a, "'"+v) // a broken formula stays as text172172 }173173 }@@ -151,7 +151,7 @@ func (m *Model) structural(err error) tea.Cmd {
151151 // fillEntry stores the entry being typed in every selected cell, adjusting152152 // references as if it were copied from the active cell (Ctrl+Enter).153153 func (m *Model) fillEntry() bool {154- input := m.line.Text()154+ input := m.storedEntry(m.line.Text())155155 if err := m.entrySheet().FillEntry(m.selection(), m.cur, input); err != nil {156156 m.entryError(err, input)157157 return false@@ -59,7 +59,7 @@ func (m *Model) startEntry(md mode, text string) {
5959 func (m *Model) startEdit() tea.Cmd {6060 m.startEntry(modeEdit, "")6161 if c := m.sheet.Cell(m.cur); c != nil && m.mode == modeEdit {62- m.line.Set(c.Input)62+ m.line.Set(m.shownEntry(c.Input))6363 }6464 return nil6565 }@@ -79,7 +79,7 @@ func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {
7979 m.toggleAbsolute()8080 return nil8181 }82- if m.line.IsFormula() && (m.pointAfterSheet(key) || m.line.CanPoint() && m.startPoint(key)) {82+ if m.line.IsFormula() && (m.pointAfterSheet(key) || m.canPoint() && m.startPoint(key)) {8383 return nil8484 }8585 if m.line.IsFormula() && (key == "left" || key == "right") {@@ -102,7 +102,7 @@ func (m *Model) editKey(k tea.KeyPressMsg) tea.Cmd {
102102 if m.entry.assist.key(m, key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {103103 return nil104104 }105- if m.line.IsFormula() && strings.HasPrefix(key, "shift+") && (m.pointAfterSheet(key) || m.line.CanPoint() && m.startPoint(key)) {105+ if m.line.IsFormula() && strings.HasPrefix(key, "shift+") && (m.pointAfterSheet(key) || m.canPoint() && m.startPoint(key)) {106106 return nil107107 }108108 switch key {@@ -228,7 +228,7 @@ func (m *Model) resumeEntry(ref string) {
228228 // commit stores the edit line in the active cell. An invalid formula stays229229 // in EDIT mode with the caret at the problem and the reason on line 3.230230 func (m *Model) commit() bool {231- input := m.line.Text()231+ input := m.storedEntry(m.line.Text())232232 warn, ok := m.checkEntry(input) // data validation, rules.go233233 if !ok {234234 return false@@ -141,7 +141,7 @@ func (m *Model) openValuesPicker(title string, x int, values []sheet.FilterValue
141141 for _, v := range p.values {142142 p.checked[v.Text] = v.Shown143143 }144- p.fields[1] = p.cond.Arg144+ p.fields[1] = sheet.LocalArg(p.cond.Arg, m.locale())145145 m.openOverlay(p)146146 m.line.Clear()147147 p.search()@@ -239,7 +239,7 @@ func (p *filterPicker) apply(m *Model) {
239239 cr.Hidden = append(cr.Hidden, v.Text)240240 }241241 }242- cr.Cond = sheet.Condition{Op: p.cond.Op, Arg: strings.TrimSpace(p.fields[1])}242+ cr.Cond = sheet.Condition{Op: p.cond.Op, Arg: sheet.CanonicalArg(strings.TrimSpace(p.fields[1]), m.locale())}243243 if !cr.Cond.Op.TakesArg() {244244 cr.Cond.Arg = ""245245 } else if cr.Cond.Arg == "" {@@ -0,0 +1,110 @@
1+package ui2+3+import (4+ tea "charm.land/bubbletea/v2"5+6+ "github.com/FelineStateMachine/012/internal/formula"7+ "github.com/FelineStateMachine/012/internal/locale"8+ "github.com/FelineStateMachine/012/internal/numfmt"9+ "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/ui/picker"11+)12+13+// File > Settings > Locale: how the workbook's entries are typed and its14+// numbers, dates and formulas shown (see sheet/locale.go). Cells keep15+// their entries in en-US's form, so the UI translates at its edges: what16+// the formula bar and editing show (shownEntry), and what's typed or17+// pasted before it's stored (storedEntry).18+19+func init() {20+ register(&command{id: "settings.locale", title: "Locale",21+ desc: "Type and show numbers, dates, currency and formulas as a country does: 1.234,56 and =ROUND(A1; 2) in Germany",22+ run: func(m *Model) tea.Cmd { m.openLocalePicker(); return nil }})23+}24+25+// locale is the workbook's locale.26+func (m *Model) locale() *locale.Locale { return m.sheet.Locale() }27+28+// shownEntry is a cell's entry as typed in the workbook's locale.29+func (m *Model) shownEntry(input string) string { return sheet.LocalEntry(input, m.locale()) }30+31+// storedEntry is text typed in the workbook's locale as the cell stores32+// it.33+func (m *Model) storedEntry(typed string) string { return sheet.CanonicalEntry(typed, m.locale()) }34+35+// storedFormula is a formula being typed, in the syntax it's parsed in:36+// what the caret's function and argument are read from. It has the same37+// runes at the same places.38+func (m *Model) storedFormula(buf []rune) []rune {39+ if formula.SameSyntax(m.locale()) {40+ return buf41+ }42+ return []rune(formula.Delocalize(string(buf), m.locale()))43+}44+45+// canPoint reports whether the caret follows an operator a reference may46+// come after; not a decimal comma.47+func (m *Model) canPoint() bool {48+ if !m.line.CanPoint() {49+ return false50+ }51+ return m.line.Buf[m.line.Pos-1] != ',' || m.locale().Decimal != ','52+}53+54+// openLocalePicker lists the locales, the workbook's own highlighted;55+// the first follows the config's locale.56+func (m *Model) openLocalePicker() {57+ cur := m.book().LocaleTag()58+ def := sheet.DefaultLocale()59+ items := []picker.Item{m.localeItem("Default: "+def.Name, "", def)}60+ sel := 061+ for i, l := range locale.All() {62+ if l.Tag == cur {63+ sel = i + 164+ }65+ items = append(items, m.localeItem(l.Name, l.Tag, l))66+ }67+ p := m.newPicker("Locale", "Type a language, country or tag", 72, items)68+ p.Action = "set"69+ p.Answers = true70+ p.Sel = sel71+ m.openOverlay(p)72+}73+74+// localeItem is the picker's row for l, which picking sets as tag.75+func (m *Model) localeItem(title, tag string, l *locale.Locale) picker.Item {76+ return picker.Item{77+ Title: title, Name: len(title), Detail: l.Tag + " " + localeSample(l), Desc: localeDesc(l),78+ Pick: func() tea.Cmd {79+ m.closeOverlay()80+ m.setLocale(tag)81+ return nil82+ },83+ }84+}85+86+// localeSample shows a number, a date and the argument separator in l.87+func localeSample(l *locale.Locale) string {88+ return numfmt.FormatIn(1234.56, "#,##0.00", l) + " " + numfmt.FormatIn(sampleDate, l.Date, l) + " " + string(l.ArgSep())89+}90+91+// localeDesc says how l writes currency, dates and formulas.92+func localeDesc(l *locale.Locale) string {93+ return "Numbers " + numfmt.FormatIn(1234.56, sheet.Preset(sheet.FmtCurrency).CodeIn(l), l) +94+ ", dates " + numfmt.FormatIn(sampleDate, l.Date, l) + ", formulas =ROUND(A1" + string(l.ArgSep()) + " 2)"95+}96+97+// sampleDate is the date locales are shown with.98+var sampleDate = numfmt.DateSerial(2026, 9, 26)99+100+// setLocale sets the workbook's locale, "" to follow the default.101+func (m *Model) setLocale(tag string) {102+ m.book().SetLocale(tag)103+ m.changed = m.sheet.StateID() != m.saved104+ l := m.locale()105+ name := l.Name106+ if tag == "" {107+ name = "the default, " + l.Name108+ }109+ m.note = "Locale " + name + ". " + localeDesc(l)110+}@@ -0,0 +1,73 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// File > Settings > Locale picks German (Germany): the sheet shows11+// decimal commas without changing a value, entries and formulas are12+// typed the German way and stored the en-US way, the formula bar and13+// editing show them German again, and undo goes back to en-US.14+func TestLocaleSetting(t *testing.T) {15+ m := newModel()16+ press(t, m, "1234.5", "<enter>", "=A1/4", "<enter>")17+ press(t, m, "<alt+f>", "<up>", "<up>", "<right>", "<down>", "<enter>")18+ if !strings.Contains(screen(m), "German (Germany)") {19+ t.Fatalf("no locale picker:\n%s", screen(m))20+ }21+ press(t, m, "Germany", "<enter>")22+ if tag := m.book().LocaleTag(); tag != "de-DE" || !strings.Contains(line(m, contextLine), "1.234,56") {23+ t.Fatalf("locale %q, context %q", tag, line(m, contextLine))24+ }25+ if v := m.sheet.Value(addr("A2")).Num; v != 308.625 || !strings.Contains(screen(m), "308,625") {26+ t.Errorf("A2 = %v:\n%s", v, screen(m))27+ }28+ press(t, m, "1,5", "<enter>")29+ if input(m, "A3") != "1.5" || m.sheet.Value(addr("A3")).Num != 1.5 {30+ t.Errorf("A3 %q = %v", input(m, "A3"), m.sheet.Value(addr("A3")))31+ }32+ press(t, m, "=ROUND(A1;")33+ if ctx := line(m, contextLine); !strings.HasPrefix(ctx, "ROUND(value; [places])") {34+ t.Errorf("signature %q", ctx)35+ }36+ press(t, m, "<backspace>", "*1,5; 1)", "<enter>")37+ if input(m, "A4") != "=ROUND(A1*1.5, 1)" || m.sheet.Value(addr("A4")).Num != 1851.8 {38+ t.Errorf("A4 %q = %v", input(m, "A4"), m.sheet.Value(addr("A4")))39+ }40+ m.cur = addr("A4")41+ if bar := line(m, formulaLine); !strings.Contains(bar, "=ROUND(A1*1,5; 1)") {42+ t.Errorf("formula bar %q", bar)43+ }44+ press(t, m, "<f2>")45+ if m.line.Text() != "=ROUND(A1*1,5; 1)" {46+ t.Errorf("editing %q", m.line.Text())47+ }48+ press(t, m, "<esc>", "<ctrl+z>", "<ctrl+z>", "<ctrl+z>")49+ if tag := m.book().LocaleTag(); tag != "" || !strings.Contains(screen(m), "308.625") {50+ t.Errorf("after undo: locale %q\n%s", tag, screen(m))51+ }52+}53+54+// Pasted text is read in the workbook's locale, and copied values are55+// written in it.56+func TestLocalePasteAndCopy(t *testing.T) {57+ m := newModel()58+ m.book().SetLocale("de-DE")59+ m.handlePaste("1,5\t26.09.2026\n2.000\tText")60+ if v := m.sheet.Value(addr("A1")).Num; v != 1.5 {61+ t.Errorf("A1 = %v", v)62+ }63+ if f := m.sheet.Cell(addr("B1")).Format; f.Kind != sheet.FmtDate {64+ t.Errorf("B1 format %v", f)65+ }66+ if v := m.sheet.Value(addr("A2")).Num; v != 2000 {67+ t.Errorf("A2 = %v", v)68+ }69+ clip := m.sheet.Copy(sheet.NewRect(addr("A1"), addr("A2")))70+ if got := formatTSV(clip.TextIn(m.locale())); got != "1,5\n2000" {71+ t.Errorf("copied %q", got)72+ }73+}@@ -35,7 +35,7 @@ var menuBar = []menuDef{
3535 {cmd: "file.new"}, {cmd: "file.open"}, {cmd: "file.import"}, sep,3636 {cmd: "file.save"}, {cmd: "file.saveas"}, {title: "Download", items: downloadItems()}, sep,3737 {title: "Settings", items: []menuItem{38- {cmd: "settings.decimal"}, {cmd: "settings.vim"}, sep,38+ {cmd: "settings.decimal"}, {cmd: "settings.locale"}, {cmd: "settings.vim"}, sep,3939 {cmd: "settings.theme"}, {cmd: "settings.jev_key"}, sep,4040 {cmd: "settings.config_edit"}, {cmd: "settings.config_reload"},4141 }}, sep,@@ -181,7 +181,7 @@ func (m *Model) leftPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {
181181 case h.kind == hitEditLine:182182 m.line.SetCaret(h.x)183183 return nil184- case h.kind == hitCell && m.line.IsFormula() && m.line.CanPoint():184+ case h.kind == hitCell && m.line.IsFormula() && m.canPoint():185185 m.entry.prefix = m.line.Head()186186 m.entry.suffix = m.line.Tail()187187 m.point = pointer{at: h.addr, anchor: h.addr}@@ -12,6 +12,7 @@ import (
1212 "charm.land/lipgloss/v2"1313 "github.com/charmbracelet/x/ansi"1414 15+ "github.com/FelineStateMachine/012/internal/numfmt"1516 "github.com/FelineStateMachine/012/internal/sheet"1617 "github.com/FelineStateMachine/012/internal/telemetry"1718 "github.com/FelineStateMachine/012/internal/ui/overlay"@@ -188,10 +189,10 @@ func (m *Model) formulaBar() string {
188189 if anchor, ok := m.sheet.SpillAnchor(m.cur); ok && m.sheet.HasSpills() {189190 // A spilled cell shows the formula it spills from, dimmed, as190191 // Sheets does.191- return box + m.th.Muted.Render(m.sheet.Cell(anchor).Input)192+ return box + m.th.Muted.Render(m.shownEntry(m.sheet.Cell(anchor).Input))192193 }193194 if c := m.sheet.Cell(m.cur); c != nil {194- return box + c.Input195+ return box + m.shownEntry(c.Input)195196 }196197 return box197198 }@@ -244,11 +245,11 @@ func (m *Model) contextLineText() string {
244245 case m.mode == modePoint:245246 prefix := []rune(m.entry.prefix)246247 var ok bool247- if left, right, ok = signatureLine(&m.th, m.width, prefix, len(prefix), m.th.KeyHints("Shift+arrows", "range", "Esc", "back")); !ok {248+ if left, right, ok = signatureLine(&m.th, m.width, m.storedFormula(prefix), len(prefix), m.locale().ArgSep(), m.th.KeyHints("Shift+arrows", "range", "Esc", "back")); !ok {248249 left = m.th.KeyHints("Arrows", "pick a cell", "Shift+arrows", "pick a range", "Enter", "accept", "Esc", "back")249250 }250- case (m.mode == modeEnter || m.mode == modeEdit) && m.line.IsFormula() && inFunction(&m.line):251- left, right, _ = signatureLine(&m.th, m.width, m.line.Buf, m.line.Pos, m.th.KeyHints("Enter", "accept", "Esc", "cancel"))251+ case (m.mode == modeEnter || m.mode == modeEdit) && m.line.IsFormula() && m.inFunction():252+ left, right, _ = signatureLine(&m.th, m.width, m.storedFormula(m.line.Buf), m.line.Pos, m.locale().ArgSep(), m.th.KeyHints("Enter", "accept", "Esc", "cancel"))252253 case m.mode == modeEnter && m.line.IsFormula():253254 left = m.th.KeyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "pick cells after an operator", "Esc", "cancel")254255 case m.mode == modeEnter:@@ -401,8 +402,8 @@ func (m *Model) statusRights() []string {
401402 rng := m.th.Key.Render(r.String())402403 sum, avg := "", ""403404 if st.Nums > 0 {404- sum = m.th.Muted.Render("Sum ") + fmtStat(st.Sum)405- avg = m.th.Muted.Render("Avg ") + fmtStat(st.Sum/float64(st.Nums))405+ sum = m.th.Muted.Render("Sum ") + m.fmtStat(st.Sum)406+ avg = m.th.Muted.Render("Avg ") + m.fmtStat(st.Sum/float64(st.Nums))406407 }407408 count := m.th.Muted.Render("Count ") + strconv.Itoa(st.Count)408409 // As many stats as fit: Avg goes first, then Sum, then Count.@@ -423,6 +424,6 @@ func (m *Model) statusRights() []string {
423424 424425 // fmtStat formats a status line statistic with at most two decimals, as425426 // Sheets does.426-func fmtStat(v float64) string {427- return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64)427+func (m *Model) fmtStat(v float64) string {428+ return numfmt.Localize(strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64), m.locale())428429 }@@ -13,6 +13,7 @@ import (
1313 "github.com/FelineStateMachine/012/internal/config"1414 "github.com/FelineStateMachine/012/internal/jev"1515 "github.com/FelineStateMachine/012/internal/keyring"16+ "github.com/FelineStateMachine/012/internal/locale"1617 "github.com/FelineStateMachine/012/internal/sheet"1718 "github.com/FelineStateMachine/012/internal/ui/theme"1819 "github.com/FelineStateMachine/012/internal/ui/themepicker"@@ -62,6 +63,9 @@ func (m *Model) applyConfig() string {
6263 m.term.noNotify = !c.Bool("notifications")6364 m.SetVimKeys(c.String("keymap") == "vim")6465 sheet.SetMaxCells(c.Int("max-cells"))66+ if l, ok := locale.Lookup(c.String("locale")); ok {67+ sheet.SetDefaultLocale(l)68+ }6569 return m.applyTheme()6670 }6771 @@ -50,7 +50,7 @@ func (m *Model) copyRows(r sheet.Rect) tea.Cmd {
5050 clip := m.sheet.Copy(r)5151 m.copied = clipboard{clip: clip, sheet: m.sheet}5252 m.vim.rows = clip53- return tea.SetClipboard(formatTSV(clip.Text()))53+ return tea.SetClipboard(formatTSV(clip.TextIn(m.locale())))5454 }5555 5656 // rowsCopied reports whether the clipboard holds whole rows.@@ -10,6 +10,7 @@ import (
1010 1111 "github.com/charmbracelet/x/ansi"1212 13+ "github.com/FelineStateMachine/012/internal/locale"1314 "github.com/FelineStateMachine/012/internal/sheet"1415 )1516 @@ -41,7 +42,7 @@ type Span struct {
4142 // It can come from cells outside the viewport, so the scan starts at the4243 // nearest filled cell on each side.4344 func Layout(s *sheet.Sheet, row, lo, ncols, minCol, maxCol int) []Span {44- l := rowLayout{s: s, row: row, lo: lo, hi: lo + ncols - 1, out: make([]Span, ncols)}45+ l := rowLayout{s: s, loc: s.Locale(), row: row, lo: lo, hi: lo + ncols - 1, out: make([]Span, ncols)}4546 for i := range l.out {4647 l.out[i] = Span{Trail: s.ColWidth(lo + i)}4748 }@@ -63,6 +64,7 @@ func Layout(s *sheet.Sheet, row, lo, ncols, minCol, maxCol int) []Span {
6364 // rowLayout is Layout's work in progress on one row.6465 type rowLayout struct {6566 s *sheet.Sheet67+ loc *locale.Locale // what numbers are shown in6668 row int6769 lo, hi int // the columns laid out6870 first, last int // the columns whose text may reach them@@ -137,13 +139,13 @@ func (l *rowLayout) place(c int, cell *sheet.Cell) {
137139 // the text, its alignment and the padding on its aligned side.138140 func (l *rowLayout) display(c int, cell *sheet.Cell, w int) (string, sheet.Align, int) {139141 f := l.s.DisplayFormat(sheet.Addr{Col: c, Row: l.row})140- text, align := sheet.Display(cell.Value, f, w)142+ text, align := sheet.DisplayIn(cell.Value, f, w, l.loc)141143 pad := 1142144 // A number one character too wide (12/31/2026 in a default column)143145 // may use the padding when nothing is to its right, rather than144146 // turning into #s.145147 if cell.Value.Kind == sheet.Number && strings.Trim(text, "#") == "" && l.content(c+1) == nil {146- if wider, _ := sheet.Display(cell.Value, f, w+1); strings.Trim(wider, "#") != "" {148+ if wider, _ := sheet.DisplayIn(cell.Value, f, w+1, l.loc); strings.Trim(wider, "#") != "" {147149 text, pad = wider, 0148150 }149151 }@@ -3,6 +3,7 @@ package rules
33 import (44 "strings"55 6+ "github.com/FelineStateMachine/012/internal/locale"67 "github.com/FelineStateMachine/012/internal/sheet"78 "github.com/FelineStateMachine/012/internal/ui/theme"89 )@@ -81,6 +82,7 @@ type cfForm struct {
8182 under bool8283 strike bool8384 pts [3]cfPoint85+ loc *locale.Locale // what args are typed in8486 }8587 8688 // cfPoint is a color scale point being edited: kind is an index in its@@ -101,7 +103,7 @@ var pointKinds = [3][]sheet.PointKind{
101103 }102104 103105 func (cfKind) form(h Host, i int, sel sheet.Rect) form {104- f := &cfForm{i: i, ranges: sel.String(), fill: int(sheet.ColorGreen),106+ f := &cfForm{i: i, ranges: sel.String(), fill: int(sheet.ColorGreen), loc: h.Sheet().Locale(),105107 op: indexOf(sheet.CondFormatOps(), sheet.RuleNotEmpty),106108 pts: [3]cfPoint{{color: int(sheet.ColorRed) - 1}, {value: "50", color: int(sheet.ColorYellow) - 1}, {color: int(sheet.ColorGreen) - 1}}}107109 if i < 0 {@@ -116,11 +118,11 @@ func (cfKind) form(h Host, i int, sel sheet.Rect) form {
116118 if k == len(r.Scale)-1 {117119 slot = 2118120 }119- f.pts[slot] = cfPoint{kind: max(indexOf(pointKinds[slot], p.Kind), 0), value: p.Value, color: int(p.Color) - 1}121+ f.pts[slot] = cfPoint{kind: max(indexOf(pointKinds[slot], p.Kind), 0), value: sheet.LocalArg(p.Value, f.loc), color: int(p.Color) - 1}120122 }121123 return f122124 }123- f.op, f.args = max(indexOf(sheet.CondFormatOps(), r.Op), 0), r.Args125+ f.op, f.args = max(indexOf(sheet.CondFormatOps(), r.Op), 0), localArgs(r.Args, f.loc)124126 st := r.Style125127 f.text, f.fill = int(st.Text), int(st.Fill)126128 f.bold, f.italic, f.under, f.strike = st.Bold, st.Italic, st.Underline, st.Strikethrough@@ -155,12 +157,12 @@ func (f *cfForm) rule() (sheet.CondFormat, error) {
155157 if k == 1 && p.kind == 0 {156158 continue // no midpoint157159 }158- r.Scale = append(r.Scale, sheet.ScalePoint{Kind: pointKinds[k][p.kind], Value: strings.TrimSpace(p.value), Color: sheet.Color(p.color + 1)})160+ r.Scale = append(r.Scale, sheet.ScalePoint{Kind: pointKinds[k][p.kind], Value: sheet.CanonicalArg(strings.TrimSpace(p.value), f.loc), Color: sheet.Color(p.color + 1)})159161 }160162 return r, nil161163 }162164 r.Op = sheet.CondFormatOps()[f.op]163- r.Args = [2]string{strings.TrimSpace(f.args[0]), strings.TrimSpace(f.args[1])}165+ r.Args = canonicalArgs(f.args, f.loc)164166 r.Style = sheet.RuleStyle{Text: sheet.Color(f.text), Fill: sheet.Color(f.fill), Bold: f.bold,165167 Italic: f.italic, Underline: f.under, Strikethrough: f.strike}166168 return r, nil@@ -6,6 +6,8 @@ import (
66 "charm.land/lipgloss/v2"77 "github.com/charmbracelet/x/ansi"88 9+ "github.com/FelineStateMachine/012/internal/locale"10+ "github.com/FelineStateMachine/012/internal/sheet"911 "github.com/FelineStateMachine/012/internal/ui/theme"1012 )1113 @@ -109,3 +111,14 @@ func (r row) cycle(d int) {
109111 110112 // textX is where a text row's value starts, from the box's inner left.111113 func textX() int { return labelW + 1 }114+115+// canonicalArgs are a rule's two arguments typed in loc, trimmed, as the116+// rule stores them (see sheet.CanonicalArg).117+func canonicalArgs(args [2]string, loc *locale.Locale) [2]string {118+ return [2]string{sheet.CanonicalArg(strings.TrimSpace(args[0]), loc), sheet.CanonicalArg(strings.TrimSpace(args[1]), loc)}119+}120+121+// localArgs are a rule's two arguments as typed in loc.122+func localArgs(args [2]string, loc *locale.Locale) [2]string {123+ return [2]string{sheet.LocalArg(args[0], loc), sheet.LocalArg(args[1], loc)}124+}@@ -4,6 +4,7 @@ import (
44 "errors"55 "strings"66 7+ "github.com/FelineStateMachine/012/internal/locale"78 "github.com/FelineStateMachine/012/internal/sheet"89 "github.com/FelineStateMachine/012/internal/ui/theme"910 )@@ -55,10 +56,11 @@ type dvForm struct {
5556 formula string5657 reject int // 0 warns, 1 rejects5758 help string59+ loc *locale.Locale // what args are typed in5860 }5961 6062 func (dvKind) form(h Host, i int, sel sheet.Rect) form {61- f := &dvForm{i: i, ranges: sel.String(), kind: int(sheet.ValidList)}63+ f := &dvForm{i: i, ranges: sel.String(), kind: int(sheet.ValidList), loc: h.Sheet().Locale()}6264 if i < 0 {6365 return f6466 }@@ -70,9 +72,9 @@ func (dvKind) form(h Host, i int, sel sheet.Rect) form {
7072 }7173 switch {7274 case v.Kind == sheet.ValidFormula:73- f.formula = v.Args[0]75+ f.formula = sheet.LocalArg(v.Args[0], f.loc)7476 case v.Kind.Compares():75- f.op, f.args = max(indexOf(f.ops(), v.Op), 0), v.Args77+ f.op, f.args = max(indexOf(f.ops(), v.Op), 0), localArgs(v.Args, f.loc)7678 }7779 return f7880 }@@ -124,11 +126,11 @@ func (f *dvForm) rule() (sheet.Validation, error) {
124126 case k == sheet.ValidRange:125127 v.Source = strings.TrimSpace(f.source)126128 case k == sheet.ValidFormula:127- v.Args[0] = strings.TrimSpace(f.formula)129+ v.Args[0] = sheet.CanonicalArg(strings.TrimSpace(f.formula), f.loc)128130 case k.Compares():129131 ops := f.ops()130132 v.Op = ops[min(f.op, len(ops)-1)]131- v.Args = [2]string{strings.TrimSpace(f.args[0]), strings.TrimSpace(f.args[1])}133+ v.Args = canonicalArgs(f.args, f.loc)132134 }133135 return v, nil134136 }@@ -169,7 +169,7 @@ func (m *Model) pointInto(s *sheet.Sheet) {
169169 return170170 }171171 if m.mode != modePoint {172- if !m.line.IsFormula() || !m.line.CanPoint() {172+ if !m.line.IsFormula() || !m.canPoint() {173173 if m.commit() {174174 m.showSheet(s)175175 }@@ -168,6 +168,9 @@ func (m *Model) startImport(name string, opt fileio.Options, place transfer.Plac
168168 if !ok {169169 return nil170170 }171+ if opt.Locale == nil {172+ opt.Locale = m.locale()173+ }171174 return m.xfer.Start(name, path, opt, place, m.spans.Parent())172175 }173176 @@ -41,7 +41,7 @@ func TestVimSetting(t *testing.T) {
4141 if s := screen(m); !strings.Contains(s, "Vim keys") {4242 t.Fatalf("settings submenu:\n%s", s)4343 }44- press(t, m, "<down>", "<enter>")44+ press(t, m, "<down>", "<down>", "<enter>")4545 if !m.prefs.vim || indicator(m) != "NORMAL" || !strings.Contains(line(m, contextLine), "Vim keys on") {4646 t.Fatalf("vim %v, indicator %q, context %q", m.prefs.vim, indicator(m), line(m, contextLine))4747 }@@ -1,6 +1,9 @@
11 package value_test22 33 import (4+ "fmt"5+ "os"6+ "strings"47 "testing"58 69 "github.com/FelineStateMachine/012/internal/locale"@@ -150,17 +153,17 @@ func TestDisplayInLocales(t *testing.T) {
150153 {"de-DE", value.Preset(value.FmtPercent), 0.125, "12,50%"},151154 {"de-DE", value.Preset(value.FmtScientific), v, "1,23E+03"},152155 {"de-DE", value.Preset(value.FmtCurrency), -v, "-1.234,50 €"},153- {"de-DE", value.Preset(value.FmtDate), date, "26.09.2026"},156+ {"de-DE", value.Preset(value.FmtDate), date, "26.9.2026"},154157 {"de-DE", value.Preset(value.FmtTime), date, "14:05:00"},155- {"de-DE", value.Preset(value.FmtDateTime), date, "26.09.2026 14:05:00"},158+ {"de-DE", value.Preset(value.FmtDateTime), date, "26.9.2026 14:05:00"},156159 {"de-DE", value.Format{Kind: value.FmtDate, Pattern: "yyyy-mm-dd"}, date, "2026-09-26"},157160 {"de-DE", value.Format{Kind: value.FmtCustom, Pattern: `"$"#,##0.0`}, v, "$1.234,5"},158161 {"fr-FR", value.Preset(value.FmtNumber), v, "1 234,50"},159162 {"en-GB", value.Preset(value.FmtCurrency), v, "£1,234.50"},160- {"en-GB", value.Preset(value.FmtDate), date, "26/09/2026"},163+ {"en-GB", value.Preset(value.FmtDate), date, "26/9/2026"},161164 {"pt-BR", value.Preset(value.FmtCurrency), v, "R$ 1.234,50"},162165 {"sv-SE", value.Preset(value.FmtDate), date, "2026-09-26"},163- {"ja-JP", value.Preset(value.FmtDate), date, "2026/09/26"},166+ {"ja-JP", value.Preset(value.FmtDate), date, "2026/9/26"},164167 {"de-CH", value.Preset(value.FmtNumber), v, "1’234.50"},165168 }166169 for _, c := range cases {@@ -190,3 +193,23 @@ func TestAccountingInLocales(t *testing.T) {
190193 }191194 }192195 }196+197+// TestLocaleDocTable checks the table of locales in docs/sheets/locale.md198+// shows each as it renders.199+func TestLocaleDocTable(t *testing.T) {200+ doc, err := os.ReadFile("../../docs/sheets/locale.md")201+ if err != nil {202+ t.Fatal(err)203+ }204+ var want strings.Builder205+ date := numfmt.DateSerial(2026, 9, 26)206+ for _, l := range locale.All() {207+ fmt.Fprintf(&want, "| `%s` | %s | %s | %s | %s |\n", l.Tag, l.Name,208+ numfmt.FormatIn(1234.56, "#,##0.00", l),209+ numfmt.FormatIn(date, value.Preset(value.FmtDate).CodeIn(l), l),210+ numfmt.FormatIn(1234.56, value.Preset(value.FmtCurrency).CodeIn(l), l))211+ }212+ if !strings.Contains(string(doc), want.String()) {213+ t.Errorf("docs/sheets/locale.md's table isn't:\n%s", want.String())214+ }215+}