cat / 012
clone

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

Draw rule shades from cached escape codes, and find percentiles by selection

Dami committed c1ab15f
A color scale's shades and rule colors keep the escape codes that open
and close them, so a plain cell on one is written without rendering a
style. Scale points need only the lowest and highest values, and a
percentile finds its values by quickselect rather than sorting the
range. A stress case puts a 3-point scale over 8192 x 26 numbers.
commit
c1ab15fa2e7c81c58a1f613e70b970da4a1ed46d
parent
9a7705faef068dd970cdc3e2401c5fb6a42d5842
author
Dami <dami.etoile@gmail.com>
10 files changed, +206 -39
Minternal/sheet/condfmt.go59+++++++++++++++++++++++++++---
Minternal/sheet/looks.go19+++++++---
Minternal/sheet/rules_test.go25+++++++++++++
Minternal/ui/looks.go19+++++-----
Minternal/ui/rules/condfmt.go11++++--
Minternal/ui/stress_test.go14+++++++
Minternal/ui/theme/rules.go47+++++++++++++++++++++++-
Minternal/ui/theme/rules_test.go14+++++--
Minternal/ui/theme/theme.go9+++--
Minternal/ui/view.go28+++++++++++---
M internal/sheet/condfmt.go +53 -6
@@ -318,9 +318,10 @@ func subtract(r, cut Rect) []Rect {
318318 return out319319 }320320 321-// scaleValue is where a point sits among values, sorted ascending.322-func (p ScalePoint) at(vals []float64) float64 {323- lo, hi := vals[0], vals[len(vals)-1]321+// at is where a point sits among the values of a scale's ranges, lo322+// the lowest and hi the highest; vals, in any order and reordered here,323+// are needed only for a percentile.324+func (p ScalePoint) at(vals []float64, lo, hi float64) float64 {324325 n, _, _ := ParseValue(strings.TrimSpace(p.Value))325326 switch p.Kind {326327 case PointMin:
@@ -335,15 +336,61 @@ func (p ScalePoint) at(vals []float64) float64 {
335336 return n336337 }337338 338-// percentile interpolates between sorted values, as PERCENTILE.INC.339+// percentile interpolates between the values around rank p, as340+// PERCENTILE.INC, finding them by selection rather than sorting.339341 func percentile(vals []float64, p float64) float64 {340342 p = min(max(p, 0), 1)341343 x := p * float64(len(vals)-1)342344 i := int(x)345+ v := nth(vals, i)343346 if i >= len(vals)-1 {344- return vals[len(vals)-1]347+ return v345348 }346- return vals[i] + (x-float64(i))*(vals[i+1]-vals[i])349+ next := slices.Min(vals[i+1:]) // nth leaves the larger values after i350+ return v + (x-float64(i))*(next-v)351+}352+353+// nth reorders vals so that vals[k] is the value a sort would put there,354+// with no larger value before it and no smaller after, and returns it355+// (quickselect, with the median of three as the pivot).356+func nth(vals []float64, k int) float64 {357+ lo, hi := 0, len(vals)-1358+ for lo < hi {359+ mid := lo + (hi-lo)/2360+ if vals[mid] < vals[lo] {361+ vals[mid], vals[lo] = vals[lo], vals[mid]362+ }363+ if vals[hi] < vals[lo] {364+ vals[hi], vals[lo] = vals[lo], vals[hi]365+ }366+ if vals[hi] < vals[mid] {367+ vals[hi], vals[mid] = vals[mid], vals[hi]368+ }369+ pivot := vals[mid]370+ i, j := lo, hi371+ for i <= j {372+ for vals[i] < pivot {373+ i++374+ }375+ for vals[j] > pivot {376+ j--377+ }378+ if i <= j {379+ vals[i], vals[j] = vals[j], vals[i]380+ i++381+ j--382+ }383+ }384+ switch {385+ case k <= j:386+ hi = j387+ case k >= i:388+ lo = i389+ default:390+ return vals[k]391+ }392+ }393+ return vals[k]347394 }348395 349396 // pointText writes a point as the rules editor shows it, e.g. "Min" or
M internal/sheet/looks.go +14 -5
M internal/sheet/rules_test.go +25 -0
M internal/ui/looks.go +9 -10
@@ -9,6 +9,7 @@ import (
99 1010 "github.com/FelineStateMachine/012/internal/sheet"1111 "github.com/FelineStateMachine/012/internal/ui/rowtext"12+ "github.com/FelineStateMachine/012/internal/ui/theme"1213 )1314 1415 // Drawing the sheet's rules (see sheet.Look): a conditional format's
@@ -24,10 +25,10 @@ import (
2425 var checkGlyphs = [2]string{"[ ]", "[✓]"}2526 2627 // ruleSpan applies the looks of cell a and of the owner of its text to27-// a span: its base role where the cell has no other, its text styles,28-// a checkbox's glyph. It reports the base and whether it's colored.29-func (m *Model) ruleSpan(a sheet.Addr, sp *rowtext.Span, base lipgloss.Style, colored bool, rgb func(int) color.Color) (lipgloss.Style, bool, sheet.Look) {30- look := m.sheet.Look(a)28+// a span: its text styles and a checkbox's glyph, and, where the cell29+// isn't colored already, the shade it's drawn on, if any (shaded).30+func (m *Model) ruleSpan(a sheet.Addr, sp *rowtext.Span, colored bool, rgb func(int) color.Color) (look sheet.Look, shade theme.Shade, shaded bool) {31+ look = m.sheet.Look(a)3132 own := look3233 if sp.Owner != a.Col && sp.Text != "" {3334 own = m.sheet.Look(sheet.Addr{Col: sp.Owner, Row: a.Row})
@@ -44,12 +45,10 @@ func (m *Model) ruleSpan(a sheet.Addr, sp *rowtext.Span, base lipgloss.Style, co
4445 sp.Style.Strikethrough = sp.Style.Strikethrough || st.Strikethrough4546 }4647 sp.Invalid = own.Invalid && sp.Text != ""47- if colored {48- return base, true, look49- }5048 switch {49+ case colored:5150 case look.Scaled:52- return m.th.ScaleFill(look.From, look.To, look.Pos, rgb), true, look51+ return look, m.th.ScaleFill(look.From, look.To, look.Pos, rgb), true5352 case look.Styled || own.Styled:5453 st := sheet.RuleStyle{}5554 if look.Styled {
@@ -59,10 +58,10 @@ func (m *Model) ruleSpan(a sheet.Addr, sp *rowtext.Span, base lipgloss.Style, co
5958 st.Text = own.Style.Text6059 }6160 if st.Fill != sheet.ColorNone || st.Text != sheet.ColorNone {62- return m.th.Rule(st), true, look61+ return look, m.th.RuleShade(st), true6362 }6463 }65- return base, false, look64+ return look, theme.Shade{}, false6665 }6766 6867 // checkSpan is a checkbox drawn in a column w wide.
M internal/ui/rules/condfmt.go +8 -3
M internal/ui/stress_test.go +14 -0
@@ -47,9 +47,21 @@ func uiShapes() []uiShape {
4747 return s4848 }},4949 {"sparse-1M", func() *sheet.Sheet { return stress.Sparse(10000, 100, 1000) }},50+ {"scale-8192x26", scaled},5051 }5152 }5253 54+// scaled is dense-8192x26 under a 3-point color scale over all of it,55+// every visible cell a shade: what conditional formatting costs a frame.56+func scaled() *sheet.Sheet {57+ s := stress.Dense(stress.Rows, 26)58+ f, err := sheet.ParseCondFormat(`{"ranges":"A1:Z8192","scale":[{"type":"min","color":"red"},{"type":"percentile","value":"50","color":"yellow"},{"type":"max","color":"green"}]}`)59+ if err != nil || s.LoadCondFormats([]sheet.CondFormat{f}) > 0 {60+ panic(err)61+ }62+ return s63+}64+5365 // fakeTerm stands in for Bubble Tea's renderer: a cell buffer the frame5466 // is drawn into and a terminal renderer diffing it to io.Discard.5567 type fakeTerm struct {
@@ -178,6 +190,8 @@ func BenchmarkKeystroke(b *testing.B) {
178190 {"type/fanin-1000", uiShape{"fanin", func() *sheet.Sheet { return stress.FanIn(stress.Rows, 1000) }},179191 nil, []string{"7", "enter", "up"}},180192 {"type/dense-8192x26", uiShapes()[1], nil, []string{"7", "enter", "up"}},193+ {"arrow/scale-8192x26", uiShapes()[7], nil, []string{"down", "up"}},194+ {"type/scale-8192x26", uiShapes()[7], nil, []string{"7", "enter", "up"}},181195 {"jump/sparse-1M", uiShapes()[6], nil, []string{"ctrl+down", "ctrl+down", "ctrl+up", "ctrl+up"}},182196 {"select-sheet/sparse-1M", uiShapes()[6], []string{"ctrl+a", "ctrl+a"}, nil},183197 {"extend-column/sparse-1M", uiShapes()[6], []string{"ctrl+space"}, []string{"shift+right", "shift+left"}},
M internal/ui/theme/rules.go +45 -2
@@ -3,6 +3,7 @@ package theme
33 import (44 "image/color"55 "math"6+ "strings"67 78 "charm.land/lipgloss/v2"89 "github.com/charmbracelet/x/ansi"
@@ -101,11 +102,53 @@ type scaleKey struct {
101102 step int102103 }103104 105+// Shade is a style drawn often, with the escape codes that open and106+// close it, so a cell of plain text in it is written without rendering107+// the style again.108+type Shade struct {109+ Style lipgloss.Style110+ Open, Close string111+}112+113+func shadeOf(s lipgloss.Style) Shade {114+ r := s.Render(shadeMark)115+ i := strings.Index(r, shadeMark)116+ if i < 0 {117+ return Shade{Style: s}118+ }119+ return Shade{Style: s, Open: r[:i], Close: r[i+len(shadeMark):]}120+}121+122+// shadeMark stands for the text while a shade's codes are worked out: a123+// private use character, which no style changes.124+const shadeMark = ""125+126+// Wrap draws text in the shade.127+func (s Shade) Wrap(text string) string {128+ if s.Open == "" && s.Close == "" {129+ return s.Style.Render(text)130+ }131+ return s.Open + text + s.Close132+}133+134+// RuleShade is Rule's style with its codes, kept per theme.135+func (t *Theme) RuleShade(st sheet.RuleStyle) Shade {136+ k := sheet.RuleStyle{Text: st.Text, Fill: st.Fill}137+ if s, ok := t.shades[k]; ok {138+ return s139+ }140+ s := shadeOf(t.Rule(k))141+ if t.shades != nil {142+ t.shades[k] = s143+ }144+ return s145+}146+104147 // ScaleFill is the fill of a color scale's cell, pos (0 to 1) of the way105148 // from one point's color to the next's, with text readable on it. The106149 // colors are the scheme's, or for the terminal theme rgb's (the107150 // terminal's palette as it reports it). Shades are kept per theme.108-func (t *Theme) ScaleFill(from, to sheet.Color, pos float64, rgb func(slot int) color.Color) lipgloss.Style {151+func (t *Theme) ScaleFill(from, to sheet.Color, pos float64, rgb func(slot int) color.Color) Shade {109152 slotRGB := func(c sheet.Color) color.RGBA {110153 var col color.Color = color.Black111154 if s := RuleSlot(c); s >= 0 {
@@ -124,7 +167,7 @@ func (t *Theme) ScaleFill(from, to sheet.Color, pos float64, rgb func(slot int)
124167 return s125168 }126169 bg := blend(k.from, k.to, float64(step)/scaleSteps)127- s := lipgloss.NewStyle().Background(bg).Foreground(t.scaleInk(bg, rgb))170+ s := shadeOf(lipgloss.NewStyle().Background(bg).Foreground(t.scaleInk(bg, rgb)))128171 if t.scales != nil && len(t.scales) < 4096 {129172 t.scales[k] = s130173 }
M internal/ui/theme/rules_test.go +10 -4
@@ -44,7 +44,7 @@ func TestColorScalesReadable(t *testing.T) {
4444 for _, from := range sheet.Colors()[1:] {4545 for _, to := range sheet.Colors()[1:] {4646 for step := 0; step <= scaleSteps; step += 4 {47- s := th.ScaleFill(from, to, float64(step)/scaleSteps, xterm)47+ s := th.ScaleFill(from, to, float64(step)/scaleSteps, xterm).Style4848 requireContrast(t, p.Name, "color scale", s.GetForeground(), s.GetBackground(), minText)4949 }5050 }
@@ -56,9 +56,9 @@ func TestScaleFillBlends(t *testing.T) {
5656 p, _ := Lookup("Dracula", "")5757 th := FromPalette(p)5858 none := func(int) color.Color { return color.Black }59- lo := th.ScaleFill(sheet.ColorRed, sheet.ColorGreen, 0, none).GetBackground()60- hi := th.ScaleFill(sheet.ColorRed, sheet.ColorGreen, 1, none).GetBackground()61- mid := th.ScaleFill(sheet.ColorRed, sheet.ColorGreen, 0.5, none).GetBackground()59+ lo := th.ScaleFill(sheet.ColorRed, sheet.ColorGreen, 0, none).Style.GetBackground()60+ hi := th.ScaleFill(sheet.ColorRed, sheet.ColorGreen, 1, none).Style.GetBackground()61+ mid := th.ScaleFill(sheet.ColorRed, sheet.ColorGreen, 0.5, none).Style.GetBackground()6262 same := func(a, b color.Color) bool {6363 ar, ag, ab, _ := a.RGBA()6464 br, bg, bb, _ := b.RGBA()
@@ -67,6 +67,12 @@ func TestScaleFillBlends(t *testing.T) {
6767 if !same(lo, p.ANSI[1]) || !same(hi, p.ANSI[2]) || same(mid, lo) || same(mid, hi) {6868 t.Errorf("scale %v %v %v, scheme's red %v green %v", lo, mid, hi, p.ANSI[1], p.ANSI[2])6969 }70+ // A shade's codes draw what its style does.71+ for _, s := range []Shade{th.ScaleFill(sheet.ColorRed, sheet.ColorGreen, 0.5, none), th.RuleShade(sheet.RuleStyle{Fill: sheet.ColorBlue, Text: sheet.ColorYellow})} {72+ if s.Open == "" || s.Wrap(" 12 ") != s.Style.Render(" 12 ") {73+ t.Errorf("wrap %q, render %q", s.Wrap(" 12 "), s.Style.Render(" 12 "))74+ }75+ }7076 if len(th.scales) != 3 {7177 t.Errorf("%d shades kept", len(th.scales))7278 }
M internal/ui/theme/theme.go +6 -3
M internal/ui/view.go +22 -6
@@ -173,19 +173,29 @@ func (m *Model) cellsText(row, first int, spans []rowtext.Span, focus sheet.Addr
173173 colored = false174174 }175175 var look sheet.Look176+ var shade theme.Shade177+ shaded := false176178 if a == m.cur && (m.mode == modeEnter || m.mode == modeEdit) && !m.away() {177179 sp = rowtext.Span{Text: m.inCellText(m.sheet.ColWidth(a.Col))}178180 } else {179181 m.decorate(&sp, row) // links and error marks, see links.go180182 if rules {181- base, colored, look = m.ruleSpan(a, &sp, base, colored, rgb) // looks.go183+ look, shade, shaded = m.ruleSpan(a, &sp, colored, rgb) // looks.go182184 }183185 }186+ if shaded {187+ base, colored = shade.Style, true188+ }184189 // The copy marker is layered on the cell's own colors.185190 if m.copied.marks(m.sheet, a) {186- base, colored = base.Inherit(m.th.Copied), true191+ base, colored, shaded = base.Inherit(m.th.Copied), true, false192+ }193+ var text string194+ if shaded && plainSpan(sp) {195+ text = shade.Wrap(strings.Repeat(" ", sp.Lead) + sp.Text + strings.Repeat(" ", sp.Trail))196+ } else {197+ text = renderSpan(&m.th, sp, base, colored)187198 }188- text := renderSpan(&m.th, sp, base, colored)189199 w := m.sheet.ColWidth(a.Col)190200 switch {191201 case m.showFillHandle(a):
@@ -215,13 +225,19 @@ func (m *Model) dividerRow() string {
215225 return m.th.FrozenLine.Render(ansi.Truncate(b.String(), m.width, ""))216226 }217227 228+// plainSpan reports whether a span's text has no style of its own: no229+// text style, link or mark.230+func plainSpan(sp rowtext.Span) bool {231+ st := sp.Style232+ st.Align = sheet.AlignAuto233+ return st.IsZero() && sp.Link == "" && !sp.Error && !sp.Invalid234+}235+218236 // renderSpan draws a span on base, one of the cell roles. Plain cells219237 // with no text style are written without escape codes.220238 func renderSpan(th *theme.Theme, sp rowtext.Span, base lipgloss.Style, colored bool) string {221239 lead, trail := strings.Repeat(" ", sp.Lead), strings.Repeat(" ", sp.Trail)222- st := sp.Style223- st.Align = sheet.AlignAuto224- plain := st.IsZero() && sp.Link == "" && !sp.Error && !sp.Invalid240+ plain := plainSpan(sp)225241 switch {226242 case plain && !colored:227243 return lead + sp.Text + trail