cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Number formats show fractions; ROUND and number literals as Sheets has them

Dami committed 5f4d97f
TEXT(0.75, "# ?/?") is 3/4: fraction patterns, with a whole part or
without, a denominator of up to so many digits or a fixed one.
ROUND adds half a unit and drops the rest on 15 digits, so rounding at
the 15th digit carries as in Sheets. A number written in a formula keeps
15 significant digits.
commit
5f4d97f9438ac4614d5d1048be03af7d96c66498
parent
434643b1ab0e0adb9ff5f7f8d6751b61f2cc7f45
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.0 of 3, Jev's estimate
6 files changed, +273 -2
Minternal/formula/parse.go4+-
Ainternal/numfmt/entered.go35++++++
Ainternal/numfmt/fraction.go166++++++++++++++++++++++++++++++
Minternal/numfmt/numfmt_test.go55++++++++++
Minternal/numfmt/pattern.go3+
Minternal/numfmt/round.go12++-
M internal/formula/parse.go +3 -1
A internal/numfmt/entered.go +35 -0
A internal/numfmt/fraction.go +166 -0
@@ -0,0 +1,166 @@
1+package numfmt2+3+import (4+ "math"5+ "strconv"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/locale"9+)10+11+// Fraction patterns: "# ?/?" shows 1.75 as 1 3/4, "# ??/??" allows12+// denominators up to 99, "# ?/8" shows eighths, and "?/?" with no whole13+// part shows 7/4. Placeholders pad as in numbers: ? with a space, 0 with14+// a zero, # with nothing.15+16+// fracLayout is where a fraction section puts each part: the tokens of17+// the whole number (with anything before it), those between it and the18+// numerator, the numerator's and denominator's placeholders, and the19+// rest.20+type fracLayout struct {21+ whole, gap, num, den, rest []ptok22+ fixed int // the denominator written in the pattern, or 023+}24+25+// layoutFraction finds a fraction in a number section: digit26+// placeholders, a /, and placeholders or digits for the denominator.27+func layoutFraction(toks []ptok) (fracLayout, bool) {28+ slash := -129+ for i := 1; i+1 < len(toks); i++ {30+ if toks[i].kind == ptLit && toks[i].s == "/" && toks[i-1].kind == ptDigit && denToken(toks[i+1]) {31+ slash = i32+ break33+ }34+ }35+ if slash < 0 {36+ return fracLayout{}, false37+ }38+ start := slash39+ for start > 0 && toks[start-1].kind == ptDigit {40+ start--41+ }42+ end := slash + 143+ for end < len(toks) && denToken(toks[end]) {44+ end++45+ }46+ l := fracLayout{num: toks[start:slash], den: toks[slash+1 : end], rest: toks[end:]}47+ last := start - 148+ for last >= 0 && toks[last].kind != ptDigit {49+ last--50+ }51+ l.whole, l.gap = toks[:last+1], toks[last+1:start]52+ var d strings.Builder53+ for _, t := range l.den {54+ d.WriteString(t.s)55+ }56+ if n, err := strconv.Atoi(d.String()); err == nil && n > 0 {57+ l.fixed = n58+ }59+ return l, true60+}61+62+// denToken reports whether t can be part of a denominator: a digit63+// placeholder or a digit written out.64+func denToken(t ptok) bool {65+ return t.kind == ptDigit || t.kind == ptLit && len(t.s) == 1 && t.s[0] >= '0' && t.s[0] <= '9'66+}67+68+// hasWhole reports whether the section shows a whole number before the69+// fraction.70+func (l fracLayout) hasWhole() bool { return hasKind(l.whole, ptDigit) }71+72+// formatFraction renders |v| with a fraction section as shown in loc;73+// neg adds a minus sign.74+func formatFraction(v float64, neg bool, l fracLayout, loc *locale.Locale) string {75+ whole := math.Floor(v)76+ n, d := l.approximate(v - whole)77+ if n == d {78+ whole, n = whole+1, 079+ }80+ if !l.hasWhole() {81+ n += int(whole) * d82+ whole = 083+ }84+ var b strings.Builder85+ if neg && (whole != 0 || n != 0) {86+ b.WriteByte('-')87+ }88+ w := ""89+ if l.hasWhole() {90+ w = formatNumber(whole, false, l.whole, loc)91+ } else {92+ w = literals(l.whole)93+ }94+ switch {95+ case n == 0 && l.hasWhole():96+ if strings.TrimSpace(w) == "" {97+ w = "0"98+ }99+ b.WriteString(w)100+ case n == 0 && !l.hasWhole() && l.fixed == 0:101+ b.WriteString(w + "0/1")102+ default:103+ b.WriteString(w)104+ if strings.TrimSpace(w) != "" {105+ b.WriteString(literals(l.gap))106+ }107+ b.WriteString(padDigits(strconv.Itoa(n), l.num, true))108+ b.WriteByte('/')109+ if l.fixed > 0 {110+ b.WriteString(strconv.Itoa(l.fixed))111+ } else {112+ b.WriteString(padDigits(strconv.Itoa(d), l.den, false))113+ }114+ }115+ b.WriteString(literals(l.rest))116+ return b.String()117+}118+119+// approximate is the fraction n/d nearest f (0 <= f < 1): with the120+// pattern's denominator, or the nearest with a denominator of as many121+// digits as its placeholders, the smallest such denominator on a tie.122+func (l fracLayout) approximate(f float64) (n, d int) {123+ if l.fixed > 0 {124+ return int(math.Round(f * float64(l.fixed))), l.fixed125+ }126+ maxD := int(math.Pow10(min(len(l.den), 5))) - 1127+ n, d = int(math.Round(f)), 1128+ best := math.Abs(f - float64(n))129+ for q := 2; q <= maxD && best > 0; q++ {130+ p := math.Round(f * float64(q))131+ if e := math.Abs(f - p/float64(q)); e < best {132+ n, d, best = int(p), q, e133+ }134+ }135+ return n, d136+}137+138+// padDigits fills the placeholders phs with digits: those it has room139+// for are padded, a ? with a space and a 0 with a zero, on the left of a140+// numerator (right) or the right of a denominator (left).141+func padDigits(digits string, phs []ptok, right bool) string {142+ var pad strings.Builder143+ for i := len(digits); i < len(phs); i++ {144+ switch phs[i].s {145+ case "?":146+ pad.WriteByte(' ')147+ case "0":148+ pad.WriteByte('0')149+ }150+ }151+ if right {152+ return pad.String() + digits153+ }154+ return digits + pad.String()155+}156+157+// literals is the text of the literal tokens among toks.158+func literals(toks []ptok) string {159+ var b strings.Builder160+ for _, t := range toks {161+ if t.kind == ptLit {162+ b.WriteString(t.s)163+ }164+ }165+ return b.String()166+}
M internal/numfmt/numfmt_test.go +55 -0
@@ -41,6 +41,24 @@ func TestFormat(t *testing.T) {
4141 {12.5, "General", "12.5"},4242 {5551234, "000-0000", "555-1234"},4343 {1.5, "General.00", "1.5.00"}, // placeholders after General get zeros44+ // Fractions, as Sheets shows them.45+ {0.75, "# ?/?", "3/4"},46+ {1.75, "# ?/?", "1 3/4"},47+ {-1.75, "# ?/?", "-1 3/4"},48+ {0.75, "0 ?/?", "0 3/4"},49+ {2, "# ?/?", "2"},50+ {0, "# ?/?", "0"},51+ {1.99, "# ?/?", "2"},52+ {0.3333, "# ?/?", "1/3"},53+ {3.14159, "# ??/??", "3 14/99"},54+ {1.5, "# ??/??", "1 1/2 "},55+ {0.1234, "# ??/??", "10/81"},56+ {1.3, "# ?/4", "1 1/4"},57+ {0.5, "# ?/8", "4/8"},58+ {0.37, "# ??/100", "37/100"},59+ {1.75, "?/?", "7/4"},60+ {1.5, `# ?/? "cups"`, "1 1/2 cups"},61+ {1234.5, "#,##0 ?/?", "1,234 1/2"},4462 // Dates and times: 46291 is 2026-09-26, a Saturday.4563 {46291, "m/d/yyyy", "9/26/2026"},4664 {46291, "yyyy-mm-dd", "2026-09-26"},
@@ -122,3 +140,40 @@ func TestFormatLCID(t *testing.T) {
122140 }123141 }124142 }143+144+func TestRound(t *testing.T) {145+ tests := []struct {146+ x float64147+ places int148+ mode Rounding149+ want float64150+ }{151+ {1.005, 2, HalfUp, 1.01},152+ {2.675, 2, HalfUp, 2.68},153+ {-2.5, 0, HalfUp, -3},154+ {1234.5, -2, HalfUp, 1200},155+ {1.23456789012345, 14, HalfUp, 1.23456789012346}, // the half added carries, as in Sheets156+ {1.23456789012345, 15, HalfUp, 1.23456789012345},157+ {2.301, 1, Up, 2.4},158+ {-2.39, 1, Down, -2.3},159+ }160+ for _, tt := range tests {161+ if got := Round(tt.x, tt.places, tt.mode); got != tt.want {162+ t.Errorf("Round(%v, %d, %v) = %v, want %v", tt.x, tt.places, tt.mode, got, tt.want)163+ }164+ }165+}166+167+func TestEntered(t *testing.T) {168+ for in, want := range map[string]string{169+ "123456789012345678": "123456789012345000",170+ "123456789012345": "123456789012345",171+ "0.00012345678901234567": "0.00012345678901234500",172+ "1.2345678901234567e10": "1.2345678901234500e10",173+ "12.5": "12.5",174+ } {175+ if got := Entered(in); got != want {176+ t.Errorf("Entered(%s) = %s, want %s", in, got, want)177+ }178+ }179+}
M internal/numfmt/pattern.go +3 -0
M internal/numfmt/round.go +11 -1