cat / 012
clone

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

Rules: data bars, icons and dropdown chips drawn; the panel makes every new rule

Dami committed 1fe2d7f
Data bars are eighth blocks in their color across the blank columns
they cover, a column short of the next cell, and reverse video under
the text they run beneath (BarOn); icons are a glyph at the cell's
left, moving text that would touch them; a dropdown shown as a chip has
its value and ▾ in reverse video between rounded ends (DropdownChip,
DropdownCap), a standout so it reads without color. Each reads in
characters, as the monochrome tests check.

The conditional format panel's Format row offers Data bar and Icon set
with their points, colors, sizes (thresholds spread evenly as Excel's)
and options, previews and list samples; conditions include top and
bottom values and percents, averages, duplicates and uniques, with
hints naming the periods date rules take. The validation panel sets a
dropdown's display (chip for new ones) and a checkbox's own values,
which Space toggles.
commit
1fe2d7ff7fcba10ac6cd3dc64854be2a88e16acf
parent
b602e6f5589b2c2d3ba82e7ed2ca08130175c067
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.1 of 3, Jev's estimate
12 files changed, +693 -61
Mdocs/sheets/rules.md71++++++++---
Minternal/sheet/condfmt.go1-
Ainternal/ui/bars.go143+++++++++++++++++++++
Ainternal/ui/bars_test.go70++++++++++
Ainternal/ui/rules/bars.go201++++++++++++++++++++++++++++++
Minternal/ui/rules/condfmt.go84++++++++++--
Minternal/ui/rules/editor_test.go39++++++-
Minternal/ui/rules/validation.go60+++++++--
Ainternal/ui/theme/bars.go30++++
Minternal/ui/theme/rules.go1+
Minternal/ui/theme/theme.go22++-
Minternal/ui/view.go32++++-
M docs/sheets/rules.md +54 -17
@@ -24,16 +24,42 @@ one of:
2424 and bold, italic, underline or strikethrough. The conditions are2525 Sheets': is empty, is not empty, text contains, does not contain,2626 starts with, ends with, is exactly (ignoring case), date is, is before,27- is after (a date, or today, tomorrow, yesterday), greater than, greater28- than or equal to, less than, less than or equal to, is equal to, is not29- equal to, is between, is not between, and custom formula is. A value30- may be a formula (`=B2`), and a custom formula (`=$C2>100`) is written31- for the first cell of the first range: its relative references move32- with each cell, as a copied formula's would.27+ is after, greater than, greater than or equal to, less than, less than28+ or equal to, is equal to, is not equal to, is between, is not between,29+ and custom formula is; and Excel's, which compare a cell with the rest30+ of the rule's cells: top values and bottom values (the highest or31+ lowest 1 to 1000, ties included), top and bottom percent, above and32+ below average, and duplicate and unique values (text ignoring case).33+ A value may be a formula (`=B2`), and a custom formula (`=$C2>100`) is34+ written for the first cell of the first range: its relative references35+ move with each cell, as a copied formula's would.3336 - **Color scale**: every number in the ranges is shaded along two or three3437 colors, from its minimum to its maximum; each point is the minimum or3538 maximum value, a number, a percent of the way between them, or a3639 percentile, and a midpoint is optional.40+- **Data bar**: every number gets a bar across its cell, as long as the41+ number is far from the shortest point to the longest. Automatic points42+ run from zero (or the lowest number, when that's below zero) to the43+ highest; either may be a number, a percent or a percentile. Bars are44+ drawn in eighth blocks (`██▍`) in one of the named colors, so a bar45+ ends within a column, and text they run under is drawn in reverse46+ video; Show bar only hides the numbers.47+- **Icon set**: every number gets an icon at its cell's left by the48+ thresholds it reaches, each a percent of the way from lowest to49+ highest, a number or a percentile: arrows (`↓ → ↑`, in 3, 4 or 5),50+ circles filling up (`○ ◑ ●`, in 3, 4 or 5), symbols (`✗ ! ✓`) or51+ rating bars (`▂ ▄ ▆ █`, in 4 or 5), the lowest red, the highest green52+ and those between yellow (rating bars blue). Reverse icons gives the53+ lowest values the highest icon, and Show icon only hides the numbers.54+55+Dates in "date is", "is before" and "is after" are a date, or a period56+as Sheets and Excel name them: today, tomorrow, yesterday, the past57+week, month or year, and this, last or next week, month or year (weeks58+run Sunday to Saturday). A date is in a period, before its first day or59+after its last.60+61+Data bars and icons are glyphs, so they read without color as they do62+in it; their colors are the terminal's, as the other rules' are.3763 3864 Rules are tried in order, and the first that applies to a cell formats3965 it, so a rule higher in the list wins. The pointer and the selection draw
@@ -61,14 +87,21 @@ Insert > Checkbox makes the selection checkboxes at once. The criteria:
6187 6288 | Criteria | Cells may hold | Shows |6389 |---|---|---|64-| Dropdown | one of the items typed (`Yes, No, Maybe`), ignoring case | ▾ at the right of the cell |65-| Dropdown (from a range) | one of the values in a range, on any sheet (`Lists!A1:A20`) | ▾ |66-| Checkbox | TRUE or FALSE; a blank cell is unchecked | `[✓]` or `[ ]`, centered |90+| Dropdown | one of the items typed (`Yes, No, Maybe`), ignoring case | the value on a chip, `▐Yes ▾▌`, or ▾ at the right of the cell |91+| Dropdown (from a range) | one of the values in a range, on any sheet (`Lists!A1:A20`) | a chip, or ▾ |92+| Checkbox | TRUE or FALSE, or values of its own (`Yes` and `No`); a blank cell is unchecked | `[✓]` or `[ ]`, centered |6793 | Number | a number between, not between, equal to, greater than ... values | |6894 | Date | any date, or one on, after, before, between ... dates | |6995 | Text length | text whose length is between, less than ... | |7096 | Custom formula is | anything for which the formula (`=B2<=C2`) is TRUE | |7197 98+A dropdown's Display is Sheets' too: Chip draws its value between99+rounded ends in reverse video, with the ▾ inside (new dropdowns), Arrow100+draws the ▾ at the cell's right, and Plain text neither. A checkbox's101+Checked and Unchecked values, when set, are what checking and102+unchecking enter instead of TRUE and FALSE; with only a checked value,103+unchecking leaves the cell blank.104+72105 Alt+Down, or a click on the ▾, opens a dropdown's list under the cell:73106 type to search, Enter enters the item as if typed. Space, or a click on74107 the box, checks or unchecks the selected checkboxes (all checked when
@@ -102,6 +135,10 @@ What validation checks:
102135 entry open instead. When only rules that warn fail, it stays, the103136 cells are marked, and the context line says how many ("2 cells104137 invalid, first B2: ...").138+- **Macros' pastes and fills** (`paste_text`, `fill`, `enter` with139+ `fill=True`, and the paste and fill commands), the same way: a refused140+ one is undone and stops the macro with the reason; what the macro did141+ before it stays, and undoes with the rest of the macro as one step.105142 - **Imports** aren't checked; what they leave invalid is marked.106143 107144 Adding a checkbox or a dropdown changes no cells, so on a protected range
@@ -110,12 +147,12 @@ picking an item asks as typing does ([protection](notes-protection.md#protected-
110147 111148 ## Moving cells112149 113-Conditional formats and validation rules move with cut and paste on a114-sheet, as in Sheets: the cut cells take their rules to where115-they land, and the cells they land on lose theirs. A rule whose cells116-partly move keeps both parts; with a custom formula it becomes two117-rules, each with the formula written for its own first cell, so the150+Conditional formats and validation rules move with cut and paste, as151+in Sheets: the cut cells take their rules to where they land, on their152+sheet or another, and the cells they land on lose theirs. A rule whose153+cells partly move keeps both parts; with a custom formula it becomes154+two rules, each with the formula written for its own first cell, so the118155 cells that stayed read what they read and the moved ones read the cells119-beside their new places. Formulas in rules follow cells that120-move. Cut on one sheet and pasted on another, the cells leave their121-rules behind and meet the rules where they land.156+beside their new places. Formulas in rules follow cells that move, and157+a rule taken to another sheet names the sheet it came from for the158+cells it still reads there (`=Sheet1!$B2>0`).
M internal/sheet/condfmt.go +0 -1
A internal/ui/bars.go +143 -0
@@ -0,0 +1,143 @@
1+package ui2+3+import (4+ "strings"5+6+ "charm.land/lipgloss/v2"7+ "github.com/charmbracelet/x/ansi"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/ui/rowtext"11+ "github.com/FelineStateMachine/012/internal/ui/theme"12+)13+14+// Drawing data bars, icon sets and dropdown chips (see sheet.Look), in15+// the terminal's own glyphs so each reads without color: a bar is16+// eighth blocks across the blank columns it covers and reverse video17+// under the text it runs beneath; an icon is a glyph at the cell's18+// left; a chip is the value in reverse video between rounded ends.19+20+// cellColumns splits a span into what each of its w columns shows: a21+// space, or a grapheme with "" after it for each further column it's22+// wide.23+func cellColumns(sp rowtext.Span, w int) []string {24+ cols := make([]string, 0, w)25+ for range sp.Lead {26+ cols = append(cols, " ")27+ }28+ for s := sp.Text; s != "" && len(cols) < w; {29+ g, gw := ansi.FirstGraphemeCluster(s, ansi.GraphemeWidth)30+ s = s[len(g):]31+ if gw == 0 {32+ continue33+ }34+ cols = append(cols, g)35+ for range gw - 1 {36+ cols = append(cols, "")37+ }38+ }39+ for len(cols) < w {40+ cols = append(cols, " ")41+ }42+ return cols[:w]43+}44+45+// iconColumn is where an icon goes in a column w wide.46+func iconColumn(w int) int { return min(1, w-1) }47+48+// withIcon makes room for an icon at the left of a cell's text: text49+// that would start within two columns of it moves right, cut at the50+// cell's edge; a value shown without its icon's value is dropped.51+func withIcon(sp rowtext.Span, w int, hidden bool) rowtext.Span {52+ if hidden {53+ return rowtext.Span{Trail: w, Owner: sp.Owner}54+ }55+ start := iconColumn(w) + 256+ if sp.Text == "" || sp.Lead >= start {57+ return sp58+ }59+ sp.Text = ansi.Truncate(sp.Text, max(w-start, 0), "")60+ sp.Lead = start61+ sp.Trail = max(w-start-ansi.StringWidth(sp.Text), 0)62+ return sp63+}64+65+// barText draws the cell at a, of w columns, with its data bar or icon:66+// sp is its text on this line, drawn on base (colored when that's a67+// highlight, which the bar's and icon's colors give way to), and value68+// whether this is the line its value is on.69+func (m *Model) barText(sp rowtext.Span, w int, look *sheet.Look, base *lipgloss.Style, colored, value bool) string {70+ icon := value && look.Icon != ""71+ if icon || look.ValueHidden {72+ sp = withIcon(sp, w, look.ValueHidden)73+ }74+ cols := cellColumns(sp, w)75+ var cover []int76+ if look.Bar {77+ cover = theme.BarCover(look.BarLen, w-1) // the last column keeps bars apart78+ }79+ text := spanStyle(&m.th, *base, sp)80+ var b strings.Builder81+ var run strings.Builder82+ var cur *lipgloss.Style83+ flush := func() {84+ if run.Len() > 0 {85+ b.WriteString(cur.Render(run.String()))86+ run.Reset()87+ }88+ }89+ put := func(role *lipgloss.Style, s string) {90+ if role != cur {91+ flush()92+ cur = role93+ }94+ run.WriteString(s)95+ }96+ for x, ch := range cols {97+ switch n := coverAt(cover, x); {98+ case ch == "":99+ case icon && x == iconColumn(w):100+ put(m.ruleRole(look.IconColor, base, colored), look.Icon)101+ case n > 0 && ch == " ":102+ put(m.ruleRole(look.BarColor, base, colored), theme.Block(n))103+ case n >= 4 && !colored:104+ put(&m.th.BarOn[look.BarColor], ch)105+ case ch == " ":106+ put(base, ch)107+ default:108+ put(&text, ch)109+ }110+ }111+ flush()112+ return b.String()113+}114+115+func coverAt(cover []int, x int) int {116+ if x < len(cover) {117+ return cover[x]118+ }119+ return 0120+}121+122+// ruleRole is the role a rule color draws a glyph in: the color, or the123+// highlight's own role on a highlighted cell.124+func (m *Model) ruleRole(c sheet.Color, base *lipgloss.Style, colored bool) *lipgloss.Style {125+ if colored || c == sheet.ColorNone || int(c) >= sheet.NumColors {126+ return base127+ }128+ return &m.th.RuleText[c]129+}130+131+// chipText draws a dropdown's value as a chip in a column w wide: its132+// rounded ends around the value and ▾, the value cut to fit; false when133+// the cell shows no value to put on one.134+func (m *Model) chipText(sp rowtext.Span, w int) (string, bool) {135+ value := strings.TrimSpace(sp.Text)136+ room := w - 5 // padding, two ends, a space and the ▾137+ if value == "" || room < 1 {138+ return "", false139+ }140+ value = ansi.Truncate(value, room, "…")141+ chip := m.paint(&m.th.DropdownCap, "▐") + m.th.DropdownChip.Render(value+" ▾") + m.paint(&m.th.DropdownCap, "▌")142+ return " " + chip + strings.Repeat(" ", w-4-ansi.StringWidth(value)-1), true143+}
A internal/ui/bars_test.go +70 -0
@@ -0,0 +1,70 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// mustFormat adds a conditional format written as a line of the file.11+func mustFormat(t *testing.T, m *Model, line string) {12+ t.Helper()13+ f, err := sheet.ParseCondFormat(line)14+ if err != nil {15+ t.Fatal(err)16+ }17+ if err := m.sheet.AddCondFormat(f); err != nil {18+ t.Fatal(err)19+ }20+}21+22+// Data bars draw in eighth blocks as long as their numbers, icons at23+// the cells' left, and a dropdown shown as a chip holds its value24+// between rounded ends; each reads in characters.25+func TestBarsIconsAndChips(t *testing.T) {26+ m := newModel()27+ for i, v := range []string{"25", "50", "100"} {28+ m.sheet.Set(sheet.Addr{Row: i}, v)29+ m.sheet.Set(sheet.Addr{Col: 1, Row: i}, v)30+ }31+ m.sheet.Set(addr("C1"), "Ann")32+ mustFormat(t, m, `{"ranges":"A1:A3","dataBar":{"color":"blue","min":{"type":"min"},"max":{"type":"max"}}}`)33+ mustFormat(t, m, `{"ranges":"B1:B3","iconSet":{"icons":"arrows","points":[{"type":"percent","value":"33"},{"type":"percent","value":"67"}]}}`)34+ mustValidate(t, m, `{"ranges":"C1:C3","criteria":"list","items":["Ann","Bo"],"display":"chip"}`)35+ m.cur = addr("E5")36+ g := gridLines(m)37+ colOf := func(line string, col int) string {38+ r := []rune(line)39+ from := m.hdrW() + col*sheet.DefaultWidth40+ if from >= len(r) {41+ return ""42+ }43+ return string(r[from:min(from+sheet.DefaultWidth, len(r))])44+ }45+ // A1 is a quarter of the way: two of its nine columns and a quarter.46+ if a1 := colOf(g[0], 0); !strings.HasPrefix(a1, "██▎") || !strings.HasSuffix(strings.TrimRight(a1, " "), "25") {47+ t.Errorf("A1's bar %q", a1)48+ }49+ if a3 := colOf(g[2], 0); strings.Count(a3, "█") < 5 || !strings.Contains(a3, "100") {50+ t.Errorf("A3's bar %q", a3)51+ }52+ for row, icon := range []string{"↓", "→", "↑"} {53+ if b := colOf(g[row], 1); !strings.HasPrefix(b, " "+icon) {54+ t.Errorf("B%d's icon %q", row+1, b)55+ }56+ }57+ if c1 := colOf(g[0], 2); !strings.HasPrefix(c1, " ▐Ann ▾▌") {58+ t.Errorf("C1's chip %q", c1)59+ }60+ if c2 := colOf(g[1], 2); !strings.Contains(c2, "▾") {61+ t.Errorf("a blank chip cell shows its ▾: %q", c2)62+ }63+ // Text under a bar is in reverse video, the bar's color as its64+ // background; without color that still reads.65+ m.sheet.SetColWidth(0, 4)66+ m.sheet.Set(addr("A3"), "999")67+ if cells := cellAt(m, addr("A3")); !cells[1].reverse {68+ t.Errorf("text under the bar: %+v", cells)69+ }70+}
A internal/ui/rules/bars.go +201 -0
@@ -0,0 +1,201 @@
1+package rules2+3+import (4+ "strconv"5+ "strings"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+ "github.com/FelineStateMachine/012/internal/ui/theme"9+)10+11+// The data bar's and icon set's parts of the conditional format form.12+13+// Kinds of the form's rule, the choices of its Format row.14+const (15+ formSingle = iota16+ formScale17+ formBar18+ formIcons19+)20+21+var formatTitles = []string{"Single color", "Color scale", "Data bar", "Icon set"}22+23+// barKinds are the kinds a bar's shortest and longest points offer, and24+// iconKinds an icon's threshold's.25+var (26+ barKinds = [2][]sheet.PointKind{27+ {sheet.PointMin, sheet.PointNumber, sheet.PointPercent, sheet.PointPercentile},28+ {sheet.PointMax, sheet.PointNumber, sheet.PointPercent, sheet.PointPercentile},29+ }30+ iconKinds = []sheet.PointKind{sheet.PointPercent, sheet.PointNumber, sheet.PointPercentile}31+)32+33+// barForm is a data bar or icon set being edited.34+type barForm struct {35+ color int // index in sheet.Colors()[1:]36+ pts [2]cfPoint // the bar's shortest and longest; color unused37+ barOnly bool38+39+ set int // index in sheet.IconSets()40+ size int // index in the set's sizes41+ thr [4]cfPoint // the icons' thresholds, the second icon's first42+ sized int // the size thr holds values for43+ reverse bool44+ iconOnly bool45+}46+47+func newBarForm() barForm {48+ b := barForm{color: int(sheet.ColorBlue) - 1}49+ b.fitThresholds(3)50+ return b51+}52+53+// fitThresholds gives n icons thresholds evenly apart in percent, as54+// Excel does, unless they already have values for n.55+func (b *barForm) fitThresholds(n int) {56+ if b.sized == n {57+ return58+ }59+ for k := range b.thr {60+ b.thr[k] = cfPoint{value: strconv.Itoa(100 * (k + 1) / n)}61+ }62+ b.sized = n63+}64+65+// sizes are the current set's sizes.66+func (b *barForm) sizes() []int { return sheet.IconSets()[b.set].Sizes() }67+68+// icons is how many icons the form's set has.69+func (b *barForm) icons() int {70+ ss := b.sizes()71+ b.size = min(b.size, len(ss)-1)72+ return ss[b.size]73+}74+75+// load reads rule r into the form.76+func (b *barForm) load(r sheet.CondFormat) {77+ switch {78+ case r.IsBar():79+ b.color, b.barOnly = int(r.Bar)-1, r.BarOnly80+ for k, p := range r.Scale {81+ b.pts[k] = cfPoint{kind: max(indexOf(barKinds[k], p.Kind), 0), value: p.Value}82+ }83+ case r.IsIcons():84+ b.set = max(indexOf(sheet.IconSets(), r.Icons), 0)85+ n := len(r.Scale) + 186+ b.size = max(indexOf(b.sizes(), n), 0)87+ for k, p := range r.Scale {88+ b.thr[k] = cfPoint{kind: max(indexOf(iconKinds, p.Kind), 0), value: p.Value}89+ }90+ b.sized, b.reverse, b.iconOnly = n, r.Reverse, r.BarOnly91+ }92+}93+94+// barRule fills r's data bar from the form.95+func (b *barForm) barRule(r *sheet.CondFormat, loc func(string) string) {96+ r.Bar, r.BarOnly = sheet.Color(b.color+1), b.barOnly97+ for k, p := range b.pts {98+ r.Scale = append(r.Scale, sheet.ScalePoint{Kind: barKinds[k][p.kind], Value: loc(strings.TrimSpace(p.value))})99+ }100+}101+102+// iconRule fills r's icon set from the form.103+func (b *barForm) iconRule(r *sheet.CondFormat, loc func(string) string) {104+ n := b.icons()105+ r.Icons, r.Reverse, r.BarOnly = sheet.IconSets()[b.set], b.reverse, b.iconOnly106+ for _, p := range b.thr[:n-1] {107+ r.Scale = append(r.Scale, sheet.ScalePoint{Kind: iconKinds[p.kind], Value: loc(strings.TrimSpace(p.value))})108+ }109+}110+111+// barRows are the rows of a data bar: its points, color, whether the112+// values show, and a preview.113+func (b *barForm) barRows(th *theme.Theme, preview func(w int) string) []row {114+ names := [2]string{"Shortest", "Longest"}115+ var rs []row116+ for k := range b.pts {117+ p := &b.pts[k]118+ kinds := make([]string, len(barKinds[k]))119+ for j, pk := range barKinds[k] {120+ kinds[j] = pk.Title()121+ }122+ kinds[0] = "Automatic"123+ rs = append(rs, row{kind: rowChoice, label: names[k], choices: kinds, at: &p.kind,124+ hint: "Where the bar is shortest or longest: automatic runs from zero, or the lowest below it, to the highest"})125+ if barKinds[k][p.kind].TakesValue() {126+ rs = append(rs, row{kind: rowText, label: " Value", text: &p.value, placeholder: "0", hint: "The point's number, percent or percentile"})127+ }128+ }129+ colors := colorTitles()[1:]130+ return append(rs,131+ row{kind: rowChoice, label: "Bar color", choices: colors, at: &b.color, swatch: func(i int) string {132+ return th.RuleText[i+1].Render("██")133+ }, hint: "The bar's color, from the terminal's palette"},134+ row{kind: rowCheck, label: "Show bar only", on: &b.barOnly, hint: "Hide the values, showing only their bars"},135+ row{kind: rowSep},136+ row{kind: rowPreview, label: "Preview", preview: preview})137+}138+139+// iconRows are the rows of an icon set: the set and its size, each140+// threshold, the order, whether the values show, and a preview.141+func (b *barForm) iconRows(preview func(w int) string) []row {142+ sets := sheet.IconSets()143+ titles := make([]string, len(sets))144+ for i, s := range sets {145+ titles[i] = s.Title()146+ }147+ n := b.icons()148+ b.fitThresholds(n)149+ sizes := make([]string, len(b.sizes()))150+ for i, s := range b.sizes() {151+ sizes[i] = strconv.Itoa(s) + " icons"152+ }153+ glyphs := sets[b.set].Glyphs(n)154+ rs := []row{155+ {kind: rowChoice, label: "Icons", choices: titles, at: &b.set, hint: "The set: arrows, circles filling up, symbols or rising bars"},156+ {kind: rowChoice, label: "How many", choices: sizes, at: &b.size, hint: "How many icons the values are sorted into"},157+ }158+ kinds := make([]string, len(iconKinds))159+ for j, k := range iconKinds {160+ kinds[j] = k.Title()161+ }162+ for k := range n - 1 {163+ p := &b.thr[k]164+ rs = append(rs,165+ row{kind: rowChoice, label: glyphs[k+1] + " from", choices: kinds, at: &p.kind, hint: "What the icon's threshold is: a percent of the way from lowest to highest, a number or a percentile"},166+ row{kind: rowText, label: " Value", text: &p.value, placeholder: "50", hint: "The lowest value the icon goes to"})167+ }168+ return append(rs,169+ row{kind: rowCheck, label: "Reverse icons", on: &b.reverse, hint: "Give the lowest values the highest icon"},170+ row{kind: rowCheck, label: "Show icon only", on: &b.iconOnly, hint: "Hide the values, showing only their icons"},171+ row{kind: rowSep},172+ row{kind: rowPreview, label: "Preview", preview: preview})173+}174+175+// barSample draws a data bar's look across w columns: bars a quarter,176+// half and all of the way, as a sheet's cells would show them.177+func barSample(th *theme.Theme, c sheet.Color, w int) string {178+ var b strings.Builder179+ each := max(w/3, 2)180+ for _, frac := range []float64{0.25, 0.6, 1} {181+ for _, n := range theme.BarCover(frac, each-1) {182+ b.WriteString(th.RuleText[c].Render(theme.Block(n)))183+ }184+ b.WriteString(" ")185+ }186+ return b.String()187+}188+189+// iconSample draws an icon set's icons, lowest first, in their colors.190+func iconSample(th *theme.Theme, set sheet.IconSet, n int, reverse bool) string {191+ var b strings.Builder192+ gs := set.Glyphs(n)193+ for k := range gs {194+ i := k195+ if reverse {196+ i = len(gs) - 1 - k197+ }198+ b.WriteString(th.RuleText[set.IconColor(i, n)].Render(gs[i]) + " ")199+ }200+ return b.String()201+}
M internal/ui/rules/condfmt.go +69 -15
@@ -34,10 +34,25 @@ func (cfKind) item(th *theme.Theme, h Host, i, w int, sel bool) string {
3434 return spread(base, text+base.Render(f.Summary()), "", w)3535 }3636 37-// sample draws w columns of a rule's look: its style on "123", or its38-// color scale.37+// sample draws w columns of a rule's look: its style on "123", its color38+// scale, its data bar or its icons.3939 func sample(th *theme.Theme, h Host, f sheet.CondFormat, w int) string {40- if f.IsScale() {40+ switch {41+ case f.IsBar():42+ var b strings.Builder43+ for _, n := range theme.BarCover(0.6, w) {44+ b.WriteString(theme.Block(n))45+ }46+ return th.RuleText[f.Bar].Render(b.String())47+ case f.IsIcons():48+ n := len(f.Scale) + 149+ gs := f.Icons.Glyphs(n)50+ var b strings.Builder51+ for k := range min(len(gs), w) {52+ b.WriteString(th.RuleText[f.Icons.IconColor(k, n)].Render(gs[k]))53+ }54+ return b.String() + strings.Repeat(" ", max(w-len(gs), 0))55+ case f.IsScale():4156 return scaleBar(th, h, f.Scale, w)4257 }4358 // The fill spans the sample; text styles only its text, as in a cell.
@@ -72,7 +87,8 @@ func scaleBar(th *theme.Theme, h Host, ps []sheet.ScalePoint, w int) string {
7287 type cfForm struct {7388 i int7489 ranges string75- scale int // 0 single color, 1 color scale90+ scale int // formSingle, formScale, formBar or formIcons91+ bar barForm7692 op int // index in sheet.CondFormatOps7793 args [2]string7894 text int // index in sheet.Colors
@@ -103,7 +119,7 @@ var pointKinds = [3][]sheet.PointKind{
103119 }104120 105121 func (cfKind) form(h Host, i int, sel sheet.Rect) form {106- f := &cfForm{i: i, ranges: sel.String(), fill: int(sheet.ColorGreen), loc: h.Sheet().Locale(),122+ f := &cfForm{i: i, ranges: sel.String(), fill: int(sheet.ColorGreen), loc: h.Sheet().Locale(), bar: newBarForm(),107123 op: indexOf(sheet.CondFormatOps(), sheet.RuleNotEmpty),108124 pts: [3]cfPoint{{color: int(sheet.ColorRed) - 1}, {value: "50", color: int(sheet.ColorYellow) - 1}, {color: int(sheet.ColorGreen) - 1}}}109125 if i < 0 {
@@ -111,8 +127,18 @@ func (cfKind) form(h Host, i int, sel sheet.Rect) form {
111127 }112128 r := h.Sheet().CondFormats()[i]113129 f.ranges = sheet.RangesText(r.Ranges)130+ switch {131+ case r.IsBar():132+ f.scale = formBar133+ f.bar.load(r)134+ return f135+ case r.IsIcons():136+ f.scale = formIcons137+ f.bar.load(r)138+ return f139+ }114140 if r.IsScale() {115- f.scale = 1141+ f.scale = formScale116142 for k, p := range r.Scale {117143 slot := k118144 if k == len(r.Scale)-1 {
@@ -155,7 +181,16 @@ func (f *cfForm) rule() (sheet.CondFormat, error) {
155181 return sheet.CondFormat{}, errRange156182 }157183 r := sheet.CondFormat{Ranges: rs}158- if f.scale == 1 {184+ loc := func(s string) string { return sheet.CanonicalArg(s, f.loc) }185+ switch f.scale {186+ case formBar:187+ f.bar.barRule(&r, loc)188+ return r, nil189+ case formIcons:190+ f.bar.iconRule(&r, loc)191+ return r, nil192+ }193+ if f.scale == formScale {159194 for k, p := range f.pts {160195 if k == 1 && p.kind == 0 {161196 continue // no midpoint
@@ -185,22 +220,26 @@ func (f *cfForm) save(h Host) error {
185220 func (f *cfForm) rows(th *theme.Theme, h Host) []row {186221 rs := []row{187222 {kind: rowText, label: "Apply to", text: &f.ranges, placeholder: "A2:A100", hint: "The ranges the rule applies to, e.g. A2:A100 or A2:A9,C2:C9"},188- {kind: rowChoice, label: "Format", choices: []string{"Single color", "Color scale"}, at: &f.scale, hint: "Color cells that meet a condition, or every number along a scale"},223+ {kind: rowChoice, label: "Format", choices: formatTitles, at: &f.scale, hint: "Color cells that meet a condition, every number along a scale, or draw bars or icons"},189224 {kind: rowSep},190225 }191- if f.scale == 1 {226+ switch f.scale {227+ case formScale:192228 return append(rs, f.scaleRows(th, h)...)229+ case formBar:230+ return append(rs, f.bar.barRows(th, func(w int) string {231+ return barSample(th, sheet.Color(f.bar.color+1), min(w, 24))232+ })...)233+ case formIcons:234+ return append(rs, f.bar.iconRows(func(int) string {235+ return iconSample(th, sheet.IconSets()[f.bar.set], f.bar.icons(), f.bar.reverse)236+ })...)193237 }194238 op := sheet.CondFormatOps()[f.op]195239 rs = append(rs, row{kind: rowChoice, label: "Format if", choices: opTitles(), at: &f.op, hint: "The condition cells must meet"})196240 switch op.Args() {197241 case 1:198- hint, holder := "The value to compare with, or a formula starting with =", "value or =formula"199- if op == sheet.RuleFormula {200- hint, holder = "TRUE for cells to format, written for the first cell: =$C2>100", "=$C2>100"201- } else if op == sheet.RuleDateIs || op == sheet.RuleDateBefore || op == sheet.RuleDateAfter {202- hint, holder = "A date, or today, tomorrow or yesterday", "2026-09-30 or today"203- }242+ hint, holder := argHint(op)204243 rs = append(rs, row{kind: rowText, label: "Value", text: &f.args[0], placeholder: holder, hint: hint})205244 case 2:206245 rs = append(rs, row{kind: rowText, label: "Between", text: &f.args[0], placeholder: "lowest", hint: "The lowest value"},
@@ -263,6 +302,21 @@ func (f *cfForm) scaleRows(th *theme.Theme, h Host) []row {
263302 }})264303 }265304 305+// argHint is the hint and placeholder of a condition's value.306+func argHint(op sheet.RuleOp) (hint, holder string) {307+ switch op {308+ case sheet.RuleFormula:309+ return "TRUE for cells to format, written for the first cell: =$C2>100", "=$C2>100"310+ case sheet.RuleDateIs, sheet.RuleDateBefore, sheet.RuleDateAfter:311+ return "A date, or a period: " + strings.Join(sheet.Periods(), ", "), "2026-09-30 or this week"312+ case sheet.RuleTop, sheet.RuleBottom:313+ return "How many of the highest or lowest values, 1 to 1000", "10"314+ case sheet.RuleTopPercent, sheet.RuleBottomPercent:315+ return "What percent of the highest or lowest values, 1 to 100", "10"316+ }317+ return "The value to compare with, or a formula starting with =", "value or =formula"318+}319+266320 func opTitles() []string {267321 ops := sheet.CondFormatOps()268322 out := make([]string, len(ops))
M internal/ui/rules/editor_test.go +37 -2
@@ -195,9 +195,9 @@ func TestValidationForm(t *testing.T) {
195195 t.Errorf("form lacks %q:\n%s", want, out)196196 }197197 }198- press(e, "<down>", "<down>", "Yes, No,, Maybe ", "<down>", "<right>", "<enter>")198+ press(e, "<down>", "<down>", "Yes, No,, Maybe ", "<down>", "<down>", "<right>", "<enter>")199199 vs := h.s.Validations()200- if len(vs) != 1 || strings.Join(vs[0].Items, "|") != "Yes|No|Maybe" || !vs[0].Reject {200+ if len(vs) != 1 || strings.Join(vs[0].Items, "|") != "Yes|No|Maybe" || !vs[0].Reject || vs[0].Display != sheet.DropChip {201201 t.Fatalf("saved %+v", vs)202202 }203203 // A number between, rejecting.
@@ -247,3 +247,38 @@ func TestMouseAndCursor(t *testing.T) {
247247 t.Error("a click outside didn't close")248248 }249249 }250+251+// The form makes data bars and icon sets: the Format row's third and252+// fourth choices, their rows, and a preview drawn as the cells are.253+func TestBarAndIconForms(t *testing.T) {254+ h := newHost()255+ e := Formats(h)256+ press(e, "<enter>", "<down>", "<right>", "<right>")257+ out := text(e)258+ for _, want := range []string{"‹ Data bar ›", "Shortest", "‹ Automatic ›", "Longest", "Bar color", "Show bar only", "Preview"} {259+ if !strings.Contains(out, want) {260+ t.Errorf("bar form lacks %q:\n%s", want, out)261+ }262+ }263+ press(e, "<enter>")264+ fs := h.s.CondFormats()265+ if len(fs) != 1 || !fs[0].IsBar() || fs[0].Bar != sheet.ColorBlue || fs[0].Scale[0].Kind != sheet.PointMin {266+ t.Fatalf("saved %+v", fs)267+ }268+ press(e, "<down>", "<enter>", "<down>", "<right>")269+ out = text(e)270+ for _, want := range []string{"‹ Icon set ›", "‹ Arrows ›", "‹ 3 icons ›", "→ from", "↑ from", "Reverse icons"} {271+ if !strings.Contains(out, want) {272+ t.Errorf("icon form lacks %q:\n%s", want, out)273+ }274+ }275+ // Four icons, evenly apart.276+ press(e, "<down>", "<down>", "<right>", "<enter>")277+ f := h.s.CondFormats()[0]278+ if !f.IsIcons() || len(f.Scale) != 3 || f.Scale[0].Value != "25" || f.Scale[2].Value != "75" {279+ t.Errorf("icons %+v", f)280+ }281+ if !strings.Contains(text(e), "↓↘↗↑") {282+ t.Errorf("list sample:\n%s", text(e))283+ }284+}
M internal/ui/rules/validation.go +46 -14
@@ -46,17 +46,20 @@ func (dvKind) item(th *theme.Theme, h Host, i, w int, sel bool) string {
4646 4747 // dvForm is a data validation rule being edited.4848 type dvForm struct {49- i int50- ranges string51- kind int // a sheet.ValidKind52- items string53- source string54- op int // index in the comparisons of the kind; see ops55- args [2]string56- formula string57- reject int // 0 warns, 1 rejects58- help string59- loc *locale.Locale // what args are typed in49+ i int50+ ranges string51+ kind int // a sheet.ValidKind52+ items string53+ source string54+ op int // index in the comparisons of the kind; see ops55+ args [2]string56+ formula string57+ reject int // 0 warns, 1 rejects58+ help string59+ display int // index in sheet.DropDisplays60+ checked string // a checkbox's own values, "" for TRUE and FALSE61+ unchecked string62+ loc *locale.Locale // what args are typed in6063 }6164 6265 func (dvKind) form(h Host, i int, sel sheet.Rect) form {
@@ -66,7 +69,12 @@ func (dvKind) form(h Host, i int, sel sheet.Rect) form {
6669 }6770 v := h.Sheet().Validations()[i]6871 f.ranges, f.kind, f.source, f.help = sheet.RangesText(v.Ranges), int(v.Kind), v.Source, v.Help69- f.items = strings.Join(v.Items, ", ")72+ f.display = max(indexOf(sheet.DropDisplays(), v.Display), 0)73+ if on, off, custom := v.CheckboxValues(); custom {74+ f.checked, f.unchecked = on, off75+ } else {76+ f.items = strings.Join(v.Items, ", ")77+ }7078 if v.Reject {7179 f.reject = 17280 }
@@ -94,6 +102,15 @@ func (f *dvForm) ops() []sheet.RuleOp {
94102 return sheet.CompareOps()95103 }96104 105+func displayTitles() []string {106+ ds := sheet.DropDisplays()107+ out := make([]string, len(ds))108+ for i, d := range ds {109+ out[i] = d.Title()110+ }111+ return out112+}113+97114 // opTitle names a comparison as the kind reads it.98115 func opTitle(k sheet.ValidKind, op sheet.RuleOp) string {99116 titles := map[sheet.RuleOp]string{sheet.RuleNone: "Is a valid date", sheet.RuleBetween: "Between",
@@ -116,7 +133,15 @@ func (f *dvForm) rule() (sheet.Validation, error) {
116133 }117134 k := sheet.ValidKind(f.kind)118135 v := sheet.Validation{Ranges: rs, Kind: k, Reject: f.reject == 1, Help: strings.TrimSpace(f.help)}136+ if k.Dropdown() {137+ v.Display = sheet.DropDisplays()[f.display]138+ }119139 switch {140+ case k == sheet.ValidCheckbox && strings.TrimSpace(f.checked+f.unchecked) != "":141+ v.Items = []string{strings.TrimSpace(f.checked)}142+ if off := strings.TrimSpace(f.unchecked); off != "" {143+ v.Items = append(v.Items, off)144+ }120145 case k == sheet.ValidList:121146 for _, it := range strings.Split(f.items, ",") {122147 if it = strings.TrimSpace(it); it != "" {
@@ -163,11 +188,18 @@ func (f *dvForm) rows(*theme.Theme, Host) []row {
163188 // criteriaRows are the rows of what the criteria need.164189 func (f *dvForm) criteriaRows() []row {165190 k := sheet.ValidKind(f.kind)191+ display := row{kind: rowChoice, label: "Display", choices: displayTitles(), at: &f.display,192+ hint: "How the cells show their dropdown: the value as a chip, an arrow at the right, or plain text"}166193 switch {167194 case k == sheet.ValidList:168- return []row{{kind: rowText, label: "Items", text: &f.items, placeholder: "Yes, No, Maybe", hint: "The dropdown's items, separated by commas"}}195+ return []row{{kind: rowText, label: "Items", text: &f.items, placeholder: "Yes, No, Maybe", hint: "The dropdown's items, separated by commas"}, display}169196 case k == sheet.ValidRange:170- return []row{{kind: rowText, label: "From range", text: &f.source, placeholder: "Lists!A1:A20", hint: "The range whose values the dropdown lists"}}197+ return []row{{kind: rowText, label: "From range", text: &f.source, placeholder: "Lists!A1:A20", hint: "The range whose values the dropdown lists"}, display}198+ case k == sheet.ValidCheckbox:199+ return []row{200+ {kind: rowText, label: "Checked", text: &f.checked, placeholder: "TRUE", hint: "What a checked box holds; empty for TRUE"},201+ {kind: rowText, label: "Unchecked", text: &f.unchecked, placeholder: "FALSE", hint: "What an unchecked box holds; empty for FALSE, or a blank with a value of its own for checked"},202+ }171203 case k == sheet.ValidFormula:172204 return []row{{kind: rowText, label: "Formula", text: &f.formula, placeholder: "=B2<=C2", hint: "TRUE for valid entries, written for the first cell"}}173205 case !k.Compares():
A internal/ui/theme/bars.go +30 -0
M internal/ui/theme/rules.go +1 -0
M internal/ui/theme/theme.go +16 -6
@@ -87,6 +87,14 @@ type Theme struct {
8787 Invalid lipgloss.Style8888 // Dropdown is the ▾ at the right of a cell with a dropdown list.8989 Dropdown lipgloss.Style90+ // DropdownChip is a dropdown's value drawn as a chip, as Sheets91+ // draws them: in reverse video, so it reads without color, with its92+ // rounded ends (▐ ▌) in DropdownCap, the chip's color.93+ DropdownChip lipgloss.Style94+ DropdownCap lipgloss.Style95+ // BarOn is text a data bar of a rule color runs under: reverse96+ // video in the bar's color (see bars.go).97+ BarOn [sheet.NumColors]lipgloss.Style9098 // scales and shades keep the shades of color scales and of rule9199 // colors drawn so far.92100 scales map[scaleKey]Shade
@@ -181,7 +189,7 @@ func New(dark bool) Theme {
181189 // without it. Call it last: the roles' colors read reversed afterwards.182190 func (t *Theme) standouts() {183191 for _, s := range []*lipgloss.Style{&t.Pointer, &t.Selection, &t.HeaderActive, &t.HeaderSel,184- &t.Found, &t.Traced, &t.MenuSelected, &t.MenuAccelSelected} {192+ &t.Found, &t.Traced, &t.MenuSelected, &t.MenuAccelSelected, &t.DropdownChip} {185193 fg, bg := s.GetForeground(), s.GetBackground()186194 *s = s.Foreground(bg).Background(fg).Reverse(true)187195 }
@@ -283,11 +291,13 @@ func roles(dark bool) Theme {
283291 ChartAxis: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),284292 ChartLabel: lipgloss.NewStyle().Foreground(muted),285293 286- Invalid: lipgloss.NewStyle().UnderlineStyle(lipgloss.UnderlineDotted).UnderlineColor(lipgloss.Yellow),287- Dropdown: lipgloss.NewStyle().Foreground(muted),288- scales: map[scaleKey]Shade{},289- shades: map[sheet.RuleStyle]Shade{},290- levels: aa,294+ Invalid: lipgloss.NewStyle().UnderlineStyle(lipgloss.UnderlineDotted).UnderlineColor(lipgloss.Yellow),295+ Dropdown: lipgloss.NewStyle().Foreground(muted),296+ DropdownChip: lipgloss.NewStyle().Background(headerBg).Foreground(headerFg),297+ DropdownCap: lipgloss.NewStyle().Foreground(headerBg),298+ scales: map[scaleKey]Shade{},299+ shades: map[sheet.RuleStyle]Shade{},300+ levels: aa,291301 }292302 ruleRoles(&t, dark)293303 for i, c := range series {
M internal/ui/view.go +26 -6
@@ -195,17 +195,37 @@ func (m *Model) cellsText(lc *lineCtx, first int, spans []rowtext.Span) string {
195195 marked := theme.Drawable(base.Inherit(m.th.Copied))196196 base, colored, shaded = &marked, true, false197197 }198- var text string199- if shaded && plainSpan(sp) {200- text = shade.Wrap(strings.Repeat(" ", sp.Lead) + sp.Text + strings.Repeat(" ", sp.Trail))201- } else {202- text = renderSpan(&m.th, sp, *base, colored)203- }198+ text := m.cellText(lc, a, sp, &look, base, colored, shade, shaded)204199 b.WriteString(m.cellMarks(lc, a, text, &look, base, colored))205200 }206201 return b.String()207202 }208203 204+// cellText draws a cell's span on its role: as a rule's shade, with its205+// data bar or icon, as a dropdown's chip (taking the look's ▾, which the206+// chip holds), or plainly.207+func (m *Model) cellText(lc *lineCtx, a sheet.Addr, sp rowtext.Span, look *sheet.Look, base *lipgloss.Style, colored bool, shade theme.Shade, shaded bool) string {208+ w := m.sheet.ColWidth(a.Col)209+ switch {210+ case look.Bar || look.Icon != "" || look.ValueHidden:211+ return m.barText(sp, w, look, base, colored, m.valueLine(lc, a))212+ case look.Dropdown && look.Display != sheet.DropArrow:213+ chip, ok := "", false214+ if look.Display == sheet.DropChip && !colored && sp.Owner == a.Col && m.valueLine(lc, a) {215+ chip, ok = m.chipText(sp, w)216+ }217+ if ok || look.Display == sheet.DropPlain {218+ look.Dropdown = false // no ▾ at the right219+ }220+ if ok {221+ return chip222+ }223+ case shaded && plainSpan(sp):224+ return shade.Wrap(strings.Repeat(" ", sp.Lead) + sp.Text + strings.Repeat(" ", sp.Trail))225+ }226+ return renderSpan(&m.th, sp, *base, colored)227+}228+209229 // cellRole is the role a cell is drawn in: the pointer, the selection,210230 // a search match, a trace, an error or a spill, or the plain cell role211231 // (colored false). Roles are large, so it points at the theme's.