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>
| M | internal/ | 59 | +++++++++++++++++++++++++++--- |
| M | internal/ | 19 | +++++++--- |
| M | internal/ | 25 | +++++++++++++ |
| M | internal/ | 19 | +++++----- |
| M | internal/ | 11 | ++++-- |
| M | internal/ | 14 | +++++++ |
| M | internal/ | 47 | +++++++++++++++++++++++- |
| M | internal/ | 14 | +++++-- |
| M | internal/ | 9 | +++-- |
| M | internal/ | 28 | +++++++++++--- |
@@ -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@@ -1,6 +1,7 @@
11 package sheet22 33 import (4+ "math"45 "slices"56 "strings"67 "unicode/utf8"@@ -257,20 +258,28 @@ func (c *looksCache) scale(s *Sheet, i int) *scaleStats {
257258 return st258259 }259260 f := s.rules.formats[i]261+ // Only a percentile needs the values themselves; the rest need the262+ // lowest and highest.263+ keep := slices.ContainsFunc(f.Scale, func(p ScalePoint) bool { return p.Kind == PointPercentile })264+ lo, hi, n := math.Inf(1), math.Inf(-1), 0260265 var vals []float64261266 for _, r := range f.Ranges {262267 for _, cell := range s.cells.inRange(r) {263- if cell.Value.Kind == Number {264- vals = append(vals, cell.Value.Num)268+ if cell.Value.Kind != Number {269+ continue270+ }271+ v := cell.Value.Num272+ lo, hi, n = min(lo, v), max(hi, v), n+1273+ if keep {274+ vals = append(vals, v)265275 }266276 }267277 }268278 st := &scaleStats{}269- if len(vals) > 0 {270- slices.Sort(vals)279+ if n > 0 {271280 st.ok = true272281 for _, p := range f.Scale {273- st.at = append(st.at, p.at(vals))282+ st.at = append(st.at, p.at(vals, lo, hi))274283 st.colors = append(st.colors, p.Color)275284 }276285 }@@ -3,6 +3,8 @@ package sheet
33 import (44 "bytes"55 "math"6+ "math/rand/v2"7+ "slices"68 "strings"79 "testing"810 )@@ -298,6 +300,29 @@ func TestRuleJSON(t *testing.T) {
298300 }299301 }300302 303+// Percentiles by selection agree with sorting, duplicates and all.304+func TestPercentileBySelection(t *testing.T) {305+ r := rand.New(rand.NewPCG(3, 4))306+ for _, n := range []int{1, 2, 3, 10, 101, 1000} {307+ vals := make([]float64, n)308+ for i := range vals {309+ vals[i] = float64(r.IntN(20))310+ }311+ sorted := slices.Sorted(slices.Values(vals))312+ for _, p := range []float64{0, 0.1, 0.25, 0.5, 0.9, 1} {313+ x := p * float64(n-1)314+ i := int(x)315+ want := sorted[i]316+ if i < n-1 {317+ want += (x - float64(i)) * (sorted[i+1] - sorted[i])318+ }319+ if got := percentile(slices.Clone(vals), p); math.Abs(got-want) > 1e-9 {320+ t.Errorf("n %d p %v: %v, want %v", n, p, got, want)321+ }322+ }323+ }324+}325+301326 func TestSubtract(t *testing.T) {302327 got := RangesText(subtract(rng("A1:E5"), rng("B2:C3")))303328 if got != "A1:E1,A4:E5,A2:A3,D2:E3" {@@ -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.@@ -39,9 +39,14 @@ func sample(th *theme.Theme, h Host, f sheet.CondFormat, w int) string {
3939 if f.IsScale() {4040 return scaleBar(th, h, f.Scale, w)4141 }42- s := th.Text(th.Rule(f.Style), sheet.Style{Bold: f.Style.Bold, Italic: f.Style.Italic,42+ // The fill spans the sample; text styles only its text, as in a cell.43+ base := th.Rule(f.Style)44+ s := th.Text(base, sheet.Style{Bold: f.Style.Bold, Italic: f.Style.Italic,4345 Underline: f.Style.Underline, Strikethrough: f.Style.Strikethrough})44- return s.Render(theme.Center("123", w))46+ text := theme.Center("123", w)47+ lead := len(text) - len(strings.TrimLeft(text, " "))48+ body := strings.TrimSpace(text)49+ return base.Render(text[:lead]) + s.Render(body) + base.Render(text[lead+len(body):])4550 }4651 4752 // scaleBar draws a color scale's shades across w columns.@@ -57,7 +62,7 @@ func scaleBar(th *theme.Theme, h Host, ps []sheet.ScalePoint, w int) string {
5762 from, to, pos = ps[1].Color, ps[2].Color, (pos-0.5)*25863 }5964 }60- b.WriteString(th.ScaleFill(from, to, pos, h.Slot).Render(" "))65+ b.WriteString(th.ScaleFill(from, to, pos, h.Slot).Wrap(" "))6166 }6267 return b.String()6368 }@@ -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"}},@@ -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 }@@ -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 }@@ -82,8 +82,10 @@ type Theme struct {
8282 Invalid lipgloss.Style8383 // Dropdown is the ▾ at the right of a cell with a dropdown list.8484 Dropdown lipgloss.Style85- // scales keeps the shades of color scales drawn so far.86- scales map[scaleKey]lipgloss.Style85+ // scales and shades keep the shades of color scales and of rule86+ // colors drawn so far.87+ scales map[scaleKey]Shade88+ shades map[sheet.RuleStyle]Shade8789 8890 // Chrome: the menu bar, dropdowns, the palette and dialogs.8991 MenuBar lipgloss.Style // menu bar titles@@ -237,7 +239,8 @@ func New(dark bool) Theme {
237239 238240 Invalid: lipgloss.NewStyle().UnderlineStyle(lipgloss.UnderlineDotted).UnderlineColor(lipgloss.Yellow),239241 Dropdown: lipgloss.NewStyle().Foreground(muted),240- scales: map[scaleKey]lipgloss.Style{},242+ scales: map[scaleKey]Shade{},243+ shades: map[sheet.RuleStyle]Shade{},241244 }242245 ruleRoles(&t, dark)243246 for i, c := range series {@@ -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