cat / 012
clone

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

Draw and edit conditional formats and data validation

Dami committed 4358f56
Format > Conditional formatting and Data > Data validation open a rules
panel at the right of the grid (package rules, with a narrow host).
Rules draw in theme roles on the ANSI slots: text colors, fills with
readable ink, and color scales blended from the scheme's or terminal's
colors with readable text. Checkboxes toggle with Space or a click,
dropdowns open with Alt+Down or a click on their mark, and entries a
rule rejects stay open with Sheets' help text. Rules added from the
panel record as the commands that add them from a line of the file.
commit
4358f56c94a3fb767db810e413bc36e3da49085e
parent
e458001ee4e19cbe82de678972fe77857501ca7e
author
Dami <dami.etoile@gmail.com>
28 files changed, +2213 -18
Minternal/sheet/looks.go4+-
Minternal/sheet/validation.go2+-
Minternal/ui/commands.go5+
Minternal/ui/context.go2+-
Minternal/ui/entry.go7+
Minternal/ui/filter.go1-
Minternal/ui/links.go2+
Ainternal/ui/looks.go157++++++++++++
Minternal/ui/macrohostnav.go3+
Minternal/ui/menubar.go5+-
Minternal/ui/model.go2+-
Minternal/ui/mouse.go13+-
Minternal/ui/overlay/overlay.go2+
Minternal/ui/panel.go5+-
Minternal/ui/picker/picker.go12+
Minternal/ui/rowtext/rowtext.go3+
Ainternal/ui/rules.go317+++++++++++++++++++++++++
Ainternal/ui/rules/condfmt.go267+++++++++++++++++++++
Ainternal/ui/rules/editor.go382++++++++++++++++++++++++++++++
Ainternal/ui/rules/editor_test.go249++++++++++++++++++++
Ainternal/ui/rules/form.go111+++++++++
Ainternal/ui/rules/validation.go192+++++++++++++++
Ainternal/ui/rules_test.go213+++++++++++++++++
Ainternal/ui/theme/rules.go152++++++++++++
Ainternal/ui/theme/rules_test.go73++++++
Minternal/ui/theme/scheme.go2+-
Minternal/ui/theme/theme.go21++
Minternal/ui/view.go27++-
M internal/sheet/looks.go +2 -2
M internal/sheet/validation.go +1 -1
M internal/ui/commands.go +5 -0
M internal/ui/context.go +1 -1
M internal/ui/entry.go +7 -0
M internal/ui/filter.go +0 -1
M internal/ui/links.go +2 -0
A internal/ui/looks.go +157 -0
@@ -0,0 +1,157 @@
1+package ui2+3+import (4+ "image/color"5+ "strings"6+7+ "charm.land/lipgloss/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/rowtext"12+)13+14+// Drawing the sheet's rules (see sheet.Look): a conditional format's15+// fill, text color and text styles, a color scale's shade, checkboxes,16+// the ▾ of a dropdown and the mark of an invalid entry. Only the visible17+// cells are asked for their looks, and the sheet keeps them until the18+// next recalculation, so a rule over thousands of rows costs a frame19+// what the screen shows.20+21+// checkGlyphs are a checkbox's text, unchecked and checked, drawn22+// centered like a boolean. ASCII brackets keep one width in every23+// terminal, where ballot box glyphs don't.24+var checkGlyphs = [2]string{"[ ]", "[✓]"}25+26+// 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)31+ own := look32+ if sp.Owner != a.Col && sp.Text != "" {33+ own = m.sheet.Look(sheet.Addr{Col: sp.Owner, Row: a.Row})34+ }35+ if look.Checkbox {36+ *sp = checkSpan(look.Checked, m.sheet.ColWidth(a.Col), m.sheet.CellStyle(a))37+ own = look38+ }39+ if own.Styled {40+ st := own.Style41+ sp.Style.Bold = sp.Style.Bold || st.Bold42+ sp.Style.Italic = sp.Style.Italic || st.Italic43+ sp.Style.Underline = sp.Style.Underline || st.Underline44+ sp.Style.Strikethrough = sp.Style.Strikethrough || st.Strikethrough45+ }46+ sp.Invalid = own.Invalid && sp.Text != ""47+ if colored {48+ return base, true, look49+ }50+ switch {51+ case look.Scaled:52+ return m.th.ScaleFill(look.From, look.To, look.Pos, rgb), true, look53+ case look.Styled || own.Styled:54+ st := sheet.RuleStyle{}55+ if look.Styled {56+ st.Fill = look.Style.Fill57+ }58+ if own.Styled {59+ st.Text = own.Style.Text60+ }61+ if st.Fill != sheet.ColorNone || st.Text != sheet.ColorNone {62+ return m.th.Rule(st), true, look63+ }64+ }65+ return base, false, look66+}67+68+// checkSpan is a checkbox drawn in a column w wide.69+func checkSpan(on bool, w int, st sheet.Style) rowtext.Span {70+ g := checkGlyphs[0]71+ if on {72+ g = checkGlyphs[1]73+ }74+ if w < 3 {75+ g = ansi.Truncate(g[1:], w, "")76+ }77+ gw := ansi.StringWidth(g)78+ lead := (w - gw) / 279+ st.Underline = false // an underline would run under the brackets80+ return rowtext.Span{Lead: lead, Text: g, Trail: w - gw - lead, Style: st}81+}82+83+// dropdownMark puts the ▾ of a dropdown cell in its last column.84+func (m *Model) dropdownMark(text string, w int, base lipgloss.Style, colored bool) string {85+ if w < 3 {86+ return text87+ }88+ mark := m.th.Dropdown.Render("▾")89+ if colored {90+ mark = base.Render("▾")91+ }92+ return ansi.Truncate(text, w-1, "") + mark93+}94+95+// slotColor is the terminal's color for an ANSI slot, as color scales96+// blend it.97+func (m *Model) slotColor(i int) color.Color { return m.term.color(i) }98+99+// checkboxAt reports where the glyph of the checkbox at a is on screen,100+// as columns from..to of the row, or false if a isn't a checkbox.101+func (m *Model) checkboxAt(a sheet.Addr, start int) (from, to int, ok bool) {102+ if !m.sheet.HasRules() || !m.sheet.Look(a).Checkbox {103+ return 0, 0, false104+ }105+ sp := checkSpan(false, m.sheet.ColWidth(a.Col), sheet.Style{})106+ return start + sp.Lead, start + sp.Lead + ansi.StringWidth(sp.Text) - 1, true107+}108+109+// ruleHit is what a click at x on the cell at a starting at start hits110+// among its rules: a checkbox's glyph or a dropdown's ▾.111+func (m *Model) ruleHit(a sheet.Addr, x, start int) hitKind {112+ if m.mode != modeReady || !m.sheet.HasRules() {113+ return hitCell114+ }115+ if from, to, ok := m.checkboxAt(a, start); ok && x >= from && x <= to {116+ return hitCheckbox117+ }118+ if w := m.sheet.ColWidth(a.Col); w >= 3 && x == start+w-1 && m.sheet.Look(a).Dropdown {119+ return hitDropdown120+ }121+ return hitCell122+}123+124+// validationLine is the context line for the active cell's validation:125+// why its entry is invalid, the rule's help text, or the keys of a126+// checkbox or dropdown.127+func (m *Model) validationLine() string {128+ if !m.sheet.HasRules() {129+ return ""130+ }131+ v, ok := m.sheet.Validation(m.cur)132+ if !ok {133+ return ""134+ }135+ look := m.sheet.Look(m.cur)136+ var keys string137+ switch {138+ case look.Checkbox:139+ keys = m.th.KeyHints("Space", "check or uncheck")140+ case look.Dropdown:141+ keys = m.th.KeyHints(m.shortcut("data.dropdown"), "choose from the list")142+ }143+ var text string144+ switch {145+ case look.Invalid:146+ text = m.th.Warning.Render("Invalid: " + v.HelpText())147+ case strings.TrimSpace(v.Help) != "":148+ text = m.th.Muted.Render(v.Help)149+ }150+ switch {151+ case text == "":152+ return keys153+ case keys == "" || ansi.StringWidth(text)+3+ansi.StringWidth(keys) > m.width:154+ return text155+ }156+ return text + " " + keys157+}
M internal/ui/macrohostnav.go +3 -0
M internal/ui/menubar.go +4 -1
@@ -69,7 +69,8 @@ var menuBar = []menuDef{
6969 {cmd: "insert.col_left", title: "Column left"}, {cmd: "insert.col_right", title: "Column right"}, sep,7070 {cmd: "insert.selection"}, sep,7171 {cmd: "sheet.new", title: "Sheet"}, sep,72- {cmd: "insert.chart"},72+ {cmd: "insert.chart"}, sep,73+ {cmd: "insert.checkbox"}, {cmd: "insert.dropdown"},7374 }},7475 {title: "Format", accel: 'o', items: []menuItem{7576 {title: "Number", items: []menuItem{
@@ -83,6 +84,7 @@ var menuBar = []menuDef{
8384 {cmd: "format.bold", title: "Bold"}, {cmd: "format.italic", title: "Italic"}, {cmd: "format.underline", title: "Underline"}, {cmd: "format.strikethrough", title: "Strikethrough"}, sep,8485 {cmd: "format.align_left", title: "Align left"}, {cmd: "format.align_center", title: "Align center"}, {cmd: "format.align_right", title: "Align right"}, sep,8586 {cmd: "column.width"}, {cmd: "column.reset"}, sep,87+ {cmd: "format.conditional"}, sep,8688 {cmd: "format.clear", title: "Clear formatting"},8789 }},8890 {title: "Data", accel: 'd', items: []menuItem{
@@ -94,6 +96,7 @@ var menuBar = []menuDef{
9496 {cmd: "data.sort_range", title: "Advanced range sorting options"},9597 }}, sep,9698 {cmd: "data.filter"}, {cmd: "data.filter_column"}, {cmd: "data.filter_remove"}, sep,99+ {cmd: "data.validation"}, {cmd: "data.validation_clear"}, sep,97100 {cmd: "data.pivot"}, {cmd: "data.pivot_edit"}, {cmd: "data.frequency"}, sep,98101 {cmd: "data.named_ranges"}, {cmd: "data.define_name"}, sep,99102 {cmd: "data.precedents"}, {cmd: "data.dependents"}, sep,
M internal/ui/model.go +1 -1
M internal/ui/mouse.go +11 -2
@@ -49,6 +49,8 @@ const (
4949 hitStatus5050 hitFilterButton // the filter mark in a column header5151 hitFillHandle // the corner of the selection that drags out a fill52+ hitCheckbox // a checkbox's glyph, which a click toggles53+ hitDropdown // a dropdown's ▾, which a click opens5254 hitTab // a sheet's tab on the status line; addr.Col is its index5355 hitTabAdd // the + after the tabs5456 hitTabPrev // the ‹ before tabs scrolled off to the left
@@ -111,7 +113,7 @@ func (m *Model) hitTest(x, y int) hit {
111113 if m.mode == modeReady && a == m.fillCorner() && x == start+m.sheet.ColWidth(col)-1 {112114 return hit{kind: hitFillHandle, addr: a}113115 }114- return hit{kind: hitCell, addr: a}116+ return hit{kind: m.ruleHit(a, x, start), addr: a}115117 case y == m.height-1:116118 if h, ok := m.tabAt(x); ok {117119 return h
@@ -213,6 +215,13 @@ func (m *Model) readyPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {
213215 m.startFill()214216 case hitFilterButton:215217 m.openFilterPicker(h.addr.Col)218+ case hitCheckbox, hitDropdown:219+ m.cur = h.addr220+ m.clearSelection()221+ if h.kind == hitCheckbox {222+ return m.runCommand("data.checkbox_toggle")223+ }224+ return m.runCommand("data.dropdown")216225 case hitColBorder:217226 if double {218227 m.autofit(h.addr.Col)
@@ -360,7 +369,7 @@ func (m *Model) pointerShape() tea.Cmd {
360369 case m.mouse.hover.kind == hitFormulaBar, m.mouse.hover.kind == hitEditLine:361370 shape = "text"362371 case m.mouse.hover.kind == hitColHeader, m.mouse.hover.kind == hitRowHeader, m.mouse.hover.kind == hitCorner, m.mouse.hover.kind == hitFilterButton,363- isTabHit(m.mouse.hover.kind):372+ m.mouse.hover.kind == hitCheckbox, m.mouse.hover.kind == hitDropdown, isTabHit(m.mouse.hover.kind):364373 shape = "pointer"365374 }366375 if shape == m.mouse.shape {
M internal/ui/overlay/overlay.go +2 -0
M internal/ui/panel.go +4 -1
M internal/ui/picker/picker.go +12 -0
M internal/ui/rowtext/rowtext.go +3 -0
A internal/ui/rules.go +317 -0
@@ -0,0 +1,317 @@
1+package ui2+3+import (4+ "errors"5+ "image/color"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/macro"10+ "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/picker"12+ "github.com/FelineStateMachine/012/internal/ui/rules"13+)14+15+// Rules follow Sheets: Format > Conditional formatting and Data > Data16+// validation open a panel of the sheet's rules at the right of the grid17+// (package rules), Insert > Checkbox and Insert > Dropdown add the two18+// rules people reach for most, Space toggles a checkbox and Alt+Down19+// opens a dropdown, and an entry a rule doesn't accept is refused or20+// kept with a warning on the context line. The panel's rules are saved21+// as the commands that add a rule from a line of the file, so a macro22+// recording keeps the rules added.23+24+func init() {25+ hasFormats := func(m *Model) bool { return len(m.sheet.CondFormats()) > 0 }26+ hasValidations := func(m *Model) bool { return len(m.sheet.Validations()) > 0 }27+ register(28+ &command{id: "format.conditional", macro: macroView, title: "Conditional formatting",29+ desc: "Color cells by their values: rules on ranges, and color scales",30+ run: func(m *Model) tea.Cmd {31+ m.openOverlay(rules.Formats(m.host()))32+ return nil33+ }},34+ &command{id: "format.conditional_add", title: "Add conditional format rule",35+ desc: `Add a rule written as a line of the file, e.g. {"ranges":"B2:B9","condition":"gt","values":["100"],"fill":"green"}`,36+ run: func(m *Model) tea.Cmd {37+ m.openText("Rule:", "", func(m *Model, text string) tea.Cmd {38+ f, err := sheet.ParseCondFormat(text)39+ if err == nil {40+ err = m.host().SaveFormat(-1, f)41+ }42+ m.failOn(err)43+ return nil44+ })45+ return nil46+ }},47+ &command{id: "format.conditional_clear", title: "Clear conditional formats", enabled: hasFormats,48+ desc: "Take the selected cells out of every conditional format rule",49+ run: func(m *Model) tea.Cmd {50+ m.sheet.ClearCondFormats(m.selection())51+ m.changed = true52+ return nil53+ }},54+ &command{id: "data.validation", macro: macroView, title: "Data validation",55+ desc: "Rules on what cells may hold: dropdowns, checkboxes, numbers, dates, formulas",56+ run: func(m *Model) tea.Cmd {57+ m.openOverlay(rules.Validations(m.host()))58+ return nil59+ }},60+ &command{id: "data.validation_add", title: "Add data validation rule",61+ desc: `Add a rule written as a line of the file, e.g. {"ranges":"D2:D9","criteria":"list","items":["Yes","No"]}`,62+ run: func(m *Model) tea.Cmd {63+ m.openText("Rule:", "", func(m *Model, text string) tea.Cmd {64+ v, err := sheet.ParseValidation(text)65+ if err == nil {66+ err = m.host().SaveValidation(-1, v)67+ }68+ m.failOn(err)69+ return nil70+ })71+ return nil72+ }},73+ &command{id: "data.validation_clear", title: "Remove data validation", enabled: hasValidations,74+ desc: "Take the selected cells out of every data validation rule",75+ run: func(m *Model) tea.Cmd {76+ m.sheet.ClearValidations(m.selection())77+ m.changed = true78+ return nil79+ }},80+ &command{id: "insert.checkbox", title: "Checkbox", edits: (*Model).selection,81+ desc: "Make the selected cells checkboxes: TRUE or FALSE, toggled with Space or a click",82+ run: func(m *Model) tea.Cmd {83+ m.failOn(m.host().SaveValidation(-1, sheet.Validation{Ranges: []sheet.Rect{m.selection()}, Kind: sheet.ValidCheckbox}))84+ return nil85+ }},86+ &command{id: "insert.dropdown", macro: macroView, title: "Dropdown", edits: (*Model).selection,87+ desc: "Give the selected cells a list of items to pick from",88+ run: func(m *Model) tea.Cmd {89+ m.openOverlay(rules.Validations(m.host()).Add(true))90+ return nil91+ }},92+ &command{id: "data.dropdown", macro: macroView, title: "Open dropdown",93+ desc: "Pick the active cell's value from its dropdown list, or filter its column",94+ enabled: func(m *Model) bool { return m.onDropdown() || commands["data.filter_column"].available(m) },95+ run: func(m *Model) tea.Cmd {96+ if !m.onDropdown() {97+ return m.runCommand("data.filter_column")98+ }99+ m.openDropdown()100+ return nil101+ }},102+ &command{id: "data.checkbox_toggle", title: "Toggle checkbox", typed: true, edits: (*Model).selection,103+ desc: "Check or uncheck the selected checkboxes",104+ enabled: func(m *Model) bool { return m.sheet.HasRules() && m.sheet.Look(m.cur).Checkbox },105+ run: (*Model).toggleCheckboxes},106+ )107+ keymap["alt+down"] = "data.dropdown"108+ keymap["space"] = "data.checkbox_toggle"109+}110+111+// failOn shows err, if any, as ERROR mode does.112+func (m *Model) failOn(err error) {113+ if err != nil {114+ m.fail(err.Error())115+ }116+}117+118+// onDropdown reports whether the active cell has a dropdown list.119+func (m *Model) onDropdown() bool {120+ return m.sheet.HasRules() && m.sheet.Look(m.cur).Dropdown121+}122+123+// openDropdown opens the active cell's list in a picker under the cell;124+// picking an item enters it, as typing it would.125+func (m *Model) openDropdown() {126+ a := m.cur127+ items := m.sheet.DropdownItems(a)128+ current := m.sheet.ShownText(a)129+ var list []picker.Item130+ sel := 0131+ for _, it := range items {132+ pi := picker.Item{Title: it, Name: len(it), Desc: "Enter " + it + " in " + a.String()}133+ pi.Pick = func() tea.Cmd {134+ m.pickItem(a, it)135+ return nil136+ }137+ if it == current {138+ sel = len(list)139+ pi.Detail = "✓"140+ }141+ list = append(list, pi)142+ }143+ if len(list) == 0 {144+ m.note = "The dropdown of " + a.String() + " has no items"145+ return146+ }147+ p := m.newPicker(a.String(), "Search the items", 40, list)148+ p.Action = "choose"149+ p.Sel = sel150+ if y, ok := m.rowY(a.Row); ok {151+ p.At = &[2]int{m.colStart(a.Col), y + 1}152+ }153+ m.openOverlay(p)154+}155+156+// pickItem enters a dropdown's item in the cell at a.157+func (m *Model) pickItem(a sheet.Addr, item string) {158+ m.closeOverlay()159+ if err := m.sheet.Set(a, item); err != nil {160+ m.note = err.Error()161+ return162+ }163+ m.changed = true164+ m.recordEntry(item, false)165+}166+167+// toggleCheckboxes checks the selected checkboxes, or unchecks them when168+// the active cell is checked, as Sheets' Space does. Blank checkboxes169+// are unchecked, so unchecking leaves them blank. Whole columns or rows170+// selected are cut to the data, so a million blank checkboxes aren't171+// written.172+func (m *Model) toggleCheckboxes() tea.Cmd {173+ s := m.sheet174+ on := !s.Look(m.cur).Checked175+ input := "FALSE"176+ if on {177+ input = "TRUE"178+ }179+ targets := []sheet.Addr{m.cur}180+ r := m.selection()181+ if used, ok := s.UsedRange(); r.AllRows() || r.AllCols() {182+ if !ok {183+ used = sheet.Rect{From: m.cur, To: m.cur}184+ }185+ r = intersect(r, used)186+ }187+ if m.hasRange() {188+ for _, v := range s.Validations() {189+ if v.Kind != sheet.ValidCheckbox {190+ continue191+ }192+ for _, vr := range v.Ranges {193+ targets = appendCells(targets, intersect(vr, r), m.cur, func(a sheet.Addr) bool { return on || !s.Cell(a).Blank() })194+ }195+ }196+ }197+ label := "uncheck " + r.String()198+ if on {199+ label = "check " + r.String()200+ }201+ err := s.Batch(sheet.Change{Label: label, Focus: r, Sheet: s}, func() error {202+ for _, a := range targets {203+ if err := s.Set(a, input); err != nil {204+ return err205+ }206+ }207+ return nil208+ })209+ if err != nil {210+ m.note = err.Error()211+ }212+ m.changed = true213+ return nil214+}215+216+// appendCells appends the cells of r that keep keeps to out, but for217+// skip; an empty r (From past To) adds nothing.218+func appendCells(out []sheet.Addr, r sheet.Rect, skip sheet.Addr, keep func(sheet.Addr) bool) []sheet.Addr {219+ for row := r.From.Row; row <= r.To.Row; row++ {220+ for col := r.From.Col; col <= r.To.Col; col++ {221+ if a := (sheet.Addr{Col: col, Row: row}); a != skip && keep(a) {222+ out = append(out, a)223+ }224+ }225+ }226+ return out227+}228+229+// intersect is the cells a and b share, From past To when none.230+func intersect(a, b sheet.Rect) sheet.Rect {231+ return sheet.Rect{232+ From: sheet.Addr{Col: max(a.From.Col, b.From.Col), Row: max(a.From.Row, b.From.Row)},233+ To: sheet.Addr{Col: min(a.To.Col, b.To.Col), Row: min(a.To.Row, b.To.Row)},234+ }235+}236+237+// checkEntry checks an entry against the cell's validation: a rule that238+// rejects it keeps the entry open with its help text, and one that239+// warns lets it in, saying so. It reports whether the entry may go in.240+func (m *Model) checkEntry(input string) (warn string, ok bool) {241+ bad := m.entrySheet().CheckEntry(m.cur, input)242+ switch {243+ case bad == nil:244+ return "", true245+ case bad.Reject:246+ m.entryError(bad, input)247+ return "", false248+ }249+ return bad.Error(), true250+}251+252+// The rules panel's host.253+254+func (h host) Sheet() *sheet.Sheet { return h.m.sheet }255+func (h host) Selection() sheet.Rect { return h.m.selection() }256+func (h host) Slot(i int) color.Color { return h.m.slotColor(i) }257+func (h host) refused(rs []sheet.Rect) bool {258+ for _, r := range rs {259+ if h.m.sheet.InPivot(r) {260+ return true261+ }262+ }263+ return false264+}265+266+// SaveFormat adds or replaces a conditional format; an added one is267+// recorded as the command that adds it.268+func (h host) SaveFormat(i int, f sheet.CondFormat) error {269+ m := h.m270+ var err error271+ if i < 0 {272+ err = m.sheet.AddCondFormat(f)273+ } else {274+ err = m.sheet.SetCondFormat(i, f)275+ }276+ if err == nil {277+ m.changed = true278+ if i < 0 {279+ m.recordRule("format.conditional_add", f.JSON())280+ }281+ }282+ return err283+}284+285+// SaveValidation adds or replaces a validation rule, refusing one over a286+// pivot table's results, whose cells take no entries; an added one is287+// recorded as the command that adds it.288+func (h host) SaveValidation(i int, v sheet.Validation) error {289+ m := h.m290+ if h.refused(v.Ranges) {291+ return errors.New("Data validation can't go on a pivot table's results")292+ }293+ var err error294+ if i < 0 {295+ err = m.sheet.AddValidation(v)296+ } else {297+ err = m.sheet.SetValidation(i, v)298+ }299+ if err == nil {300+ m.changed = true301+ if i < 0 {302+ m.recordRule("data.validation_add", v.JSON())303+ }304+ }305+ return err306+}307+308+// recordRule records a rule added from the panel as the command that309+// adds it, answered with its line. A command that adds one itself is310+// recorded where it runs.311+func (m *Model) recordRule(id, line string) {312+ if m.rec == nil || m.rec.depth > 0 || m.rec.pending != "" {313+ return314+ }315+ m.rec.flush(m)316+ m.rec.add(m, macro.Call("run", id).With("answer", line))317+}
A internal/ui/rules/condfmt.go +267 -0
@@ -0,0 +1,267 @@
1+package rules2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/012/internal/sheet"7+ "github.com/FelineStateMachine/012/internal/ui/theme"8+)9+10+// cfKind lists conditional formats.11+type cfKind struct{}12+13+func (cfKind) title() string { return "Conditional format rules" }14+func (cfKind) count(s *sheet.Sheet) int { return len(s.CondFormats()) }15+func (cfKind) remove(s *sheet.Sheet, i int) { s.DeleteCondFormat(i) }16+func (cfKind) addHint() string { return "A" }17+18+func (cfKind) move(s *sheet.Sheet, i, to int) bool {19+ s.MoveCondFormat(i, to)20+ return true21+}22+23+// item is a rule's sample, its ranges and what it tests.24+func (cfKind) item(th *theme.Theme, h Host, i, w int, sel bool) string {25+ f := h.Sheet().CondFormats()[i]26+ base := th.MenuBar27+ if sel {28+ base = th.MenuSelected29+ }30+ text := base.Render(" ") + sample(th, h, f, 4) + base.Render(" "+sheet.RangesText(f.Ranges)+" ")31+ return spread(base, text+base.Render(f.Summary()), "", w)32+}33+34+// sample draws w columns of a rule's look: its style on "123", or its35+// color scale.36+func sample(th *theme.Theme, h Host, f sheet.CondFormat, w int) string {37+ if f.IsScale() {38+ return scaleBar(th, h, f.Scale, w)39+ }40+ s := th.Text(th.Rule(f.Style), sheet.Style{Bold: f.Style.Bold, Italic: f.Style.Italic,41+ Underline: f.Style.Underline, Strikethrough: f.Style.Strikethrough})42+ return s.Render(theme.Center("123", w))43+}44+45+// scaleBar draws a color scale's shades across w columns.46+func scaleBar(th *theme.Theme, h Host, ps []sheet.ScalePoint, w int) string {47+ var b strings.Builder48+ for x := range w {49+ pos := float64(x) / float64(max(w-1, 1))50+ from, to := ps[0].Color, ps[len(ps)-1].Color51+ if len(ps) == 3 {52+ if pos <= 0.5 {53+ from, to, pos = ps[0].Color, ps[1].Color, pos*254+ } else {55+ from, to, pos = ps[1].Color, ps[2].Color, (pos-0.5)*256+ }57+ }58+ b.WriteString(th.ScaleFill(from, to, pos, h.Slot).Render(" "))59+ }60+ return b.String()61+}62+63+// cfForm is a conditional format rule being edited.64+type cfForm struct {65+ i int66+ ranges string67+ scale int // 0 single color, 1 color scale68+ op int // index in sheet.CondFormatOps69+ args [2]string70+ text int // index in sheet.Colors71+ fill int72+ bold bool73+ italic bool74+ under bool75+ strike bool76+ pts [3]cfPoint77+}78+79+// cfPoint is a color scale point being edited: kind is an index in its80+// row's kinds, where the midpoint's first is none, and color a named81+// color less one, as points have no None.82+type cfPoint struct {83+ kind int84+ value string85+ color int86+}87+88+// pointKinds are the kinds each point offers: the minimum, the89+// midpoint (none first) and the maximum.90+var pointKinds = [3][]sheet.PointKind{91+ {sheet.PointMin, sheet.PointNumber, sheet.PointPercent, sheet.PointPercentile},92+ {sheet.PointMin, sheet.PointNumber, sheet.PointPercent, sheet.PointPercentile}, // PointMin stands for none93+ {sheet.PointMax, sheet.PointNumber, sheet.PointPercent, sheet.PointPercentile},94+}95+96+func (cfKind) form(h Host, i int, sel sheet.Rect) form {97+ f := &cfForm{i: i, ranges: sel.String(), fill: int(sheet.ColorGreen),98+ op: indexOf(sheet.CondFormatOps(), sheet.RuleNotEmpty),99+ pts: [3]cfPoint{{color: int(sheet.ColorRed) - 1}, {value: "50", color: int(sheet.ColorYellow) - 1}, {color: int(sheet.ColorGreen) - 1}}}100+ if i < 0 {101+ return f102+ }103+ r := h.Sheet().CondFormats()[i]104+ f.ranges = sheet.RangesText(r.Ranges)105+ if r.IsScale() {106+ f.scale = 1107+ for k, p := range r.Scale {108+ slot := k109+ if k == len(r.Scale)-1 {110+ slot = 2111+ }112+ f.pts[slot] = cfPoint{kind: max(indexOf(pointKinds[slot], p.Kind), 0), value: p.Value, color: int(p.Color) - 1}113+ }114+ return f115+ }116+ f.op, f.args = max(indexOf(sheet.CondFormatOps(), r.Op), 0), r.Args117+ st := r.Style118+ f.text, f.fill = int(st.Text), int(st.Fill)119+ f.bold, f.italic, f.under, f.strike = st.Bold, st.Italic, st.Underline, st.Strikethrough120+ return f121+}122+123+func indexOf[T comparable](xs []T, x T) int {124+ for i, y := range xs {125+ if y == x {126+ return i127+ }128+ }129+ return -1130+}131+132+func (f *cfForm) title() string {133+ if f.i < 0 {134+ return "Add conditional format"135+ }136+ return "Edit conditional format"137+}138+139+// rule is the rule the form describes.140+func (f *cfForm) rule() (sheet.CondFormat, error) {141+ rs, ok := sheet.ParseRanges(f.ranges)142+ if !ok && strings.TrimSpace(f.ranges) != "" {143+ return sheet.CondFormat{}, errRange144+ }145+ r := sheet.CondFormat{Ranges: rs}146+ if f.scale == 1 {147+ for k, p := range f.pts {148+ if k == 1 && p.kind == 0 {149+ continue // no midpoint150+ }151+ r.Scale = append(r.Scale, sheet.ScalePoint{Kind: pointKinds[k][p.kind], Value: strings.TrimSpace(p.value), Color: sheet.Color(p.color + 1)})152+ }153+ return r, nil154+ }155+ r.Op = sheet.CondFormatOps()[f.op]156+ r.Args = [2]string{strings.TrimSpace(f.args[0]), strings.TrimSpace(f.args[1])}157+ r.Style = sheet.RuleStyle{Text: sheet.Color(f.text), Fill: sheet.Color(f.fill), Bold: f.bold,158+ Italic: f.italic, Underline: f.under, Strikethrough: f.strike}159+ return r, nil160+}161+162+func (f *cfForm) save(h Host) error {163+ r, err := f.rule()164+ if err != nil {165+ return err166+ }167+ return h.SaveFormat(f.i, r)168+}169+170+func (f *cfForm) rows(th *theme.Theme, h Host) []row {171+ rs := []row{172+ {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"},173+ {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"},174+ {kind: rowSep},175+ }176+ if f.scale == 1 {177+ return append(rs, f.scaleRows(th, h)...)178+ }179+ op := sheet.CondFormatOps()[f.op]180+ rs = append(rs, row{kind: rowChoice, label: "Format if", choices: opTitles(), at: &f.op, hint: "The condition cells must meet"})181+ switch op.Args() {182+ case 1:183+ hint, holder := "The value to compare with, or a formula starting with =", "value or =formula"184+ if op == sheet.RuleFormula {185+ hint, holder = "TRUE for cells to format, written for the first cell: =$C2>100", "=$C2>100"186+ } else if op == sheet.RuleDateIs || op == sheet.RuleDateBefore || op == sheet.RuleDateAfter {187+ hint, holder = "A date, or today, tomorrow or yesterday", "2026-09-30 or today"188+ }189+ rs = append(rs, row{kind: rowText, label: "Value", text: &f.args[0], placeholder: holder, hint: hint})190+ case 2:191+ rs = append(rs, row{kind: rowText, label: "Between", text: &f.args[0], placeholder: "lowest", hint: "The lowest value"},192+ row{kind: rowText, label: "And", text: &f.args[1], placeholder: "highest", hint: "The highest value"})193+ }194+ swatch := func(fill bool) func(int) string {195+ return func(i int) string {196+ if i == 0 {197+ return th.MenuBar.Render(" ")198+ }199+ if fill {200+ return th.RuleFill[i].Render(" ")201+ }202+ return th.RuleText[i].Render("Aa")203+ }204+ }205+ rs = append(rs, row{kind: rowSep},206+ row{kind: rowChoice, label: "Text color", choices: colorTitles(), at: &f.text, swatch: swatch(false), hint: "The text's color, from the terminal's palette"},207+ row{kind: rowChoice, label: "Fill", choices: colorTitles(), at: &f.fill, swatch: swatch(true), hint: "The cell's background, from the terminal's palette"},208+ row{kind: rowCheck, label: "Bold", on: &f.bold, hint: "Bold text"},209+ row{kind: rowCheck, label: "Italic", on: &f.italic, hint: "Italic text"},210+ row{kind: rowCheck, label: "Underline", on: &f.under, hint: "Underlined text"},211+ row{kind: rowCheck, label: "Strikethrough", on: &f.strike, hint: "Struck through text"},212+ row{kind: rowSep},213+ row{kind: rowPreview, label: "Preview", preview: func(int) string {214+ r, _ := f.rule()215+ return sample(th, h, r, 12)216+ }})217+ return rs218+}219+220+// scaleRows are the rows of a color scale: each point's kind, value and221+// color, and a preview.222+func (f *cfForm) scaleRows(th *theme.Theme, h Host) []row {223+ var rs []row224+ names := [3]string{"Minpoint", "Midpoint", "Maxpoint"}225+ for k := range f.pts {226+ p := &f.pts[k]227+ kinds := make([]string, len(pointKinds[k]))228+ for j, pk := range pointKinds[k] {229+ kinds[j] = pk.Title()230+ }231+ if k == 1 {232+ kinds[0] = "None"233+ }234+ rs = append(rs, row{kind: rowChoice, label: names[k], choices: kinds, at: &p.kind, hint: "Where the point's color goes among the values"})235+ if k == 1 && p.kind == 0 {236+ continue237+ }238+ if pointKinds[k][p.kind].TakesValue() {239+ rs = append(rs, row{kind: rowText, label: " Value", text: &p.value, placeholder: "50", hint: "The point's number, percent or percentile"})240+ }241+ rs = append(rs, row{kind: rowChoice, label: " Color", choices: colorTitles()[1:], at: &p.color, swatch: func(i int) string {242+ return th.RuleFill[i+1].Render(" ")243+ }, hint: "The point's color, from the terminal's palette"})244+ }245+ return append(rs, row{kind: rowSep}, row{kind: rowPreview, label: "Preview", preview: func(w int) string {246+ r, _ := f.rule()247+ return scaleBar(th, h, r.Scale, min(w, 24))248+ }})249+}250+251+func opTitles() []string {252+ ops := sheet.CondFormatOps()253+ out := make([]string, len(ops))254+ for i, op := range ops {255+ out[i] = op.Title()256+ }257+ return out258+}259+260+func colorTitles() []string {261+ cs := sheet.Colors()262+ out := make([]string, len(cs))263+ for i, c := range cs {264+ out[i] = c.Title()265+ }266+ return out267+}
A internal/ui/rules/editor.go +382 -0
@@ -0,0 +1,382 @@
1+// Package rules is the side panel for a sheet's rules, as Sheets'2+// Conditional formatting and Data validation sidebars: a list of the3+// rules, and a form to add or edit one. It is docked at the right of4+// the grid in the manner of lazygit's panels, and knows the UI only5+// through Host.6+package rules7+8+import (9+ "image/color"10+ "strconv"11+12+ tea "charm.land/bubbletea/v2"13+ "github.com/charmbracelet/x/ansi"14+15+ "github.com/FelineStateMachine/012/internal/sheet"16+ "github.com/FelineStateMachine/012/internal/ui/lineedit"17+ "github.com/FelineStateMachine/012/internal/ui/overlay"18+ "github.com/FelineStateMachine/012/internal/ui/theme"19+)20+21+// Host is what the rules panel needs of the UI.22+type Host interface {23+ Theme() *theme.Theme24+ Size() (width, height int)25+ // Line is the shared edit line text rows are typed in.26+ Line() *lineedit.Line27+ // Close closes the open overlay.28+ Close()29+ // Sheet is the sheet whose rules are edited.30+ Sheet() *sheet.Sheet31+ // Selection is the range a new rule applies to.32+ Selection() sheet.Rect33+ // SaveFormat and SaveValidation store rule i (-1 adds one), as the34+ // commands macros record do, reporting why they couldn't.35+ SaveFormat(i int, f sheet.CondFormat) error36+ SaveValidation(i int, v sheet.Validation) error37+ // Edited marks the file modified after a rule was removed or moved.38+ Edited()39+ // Slot is the terminal's color for an ANSI slot, for color scales.40+ Slot(i int) color.Color41+}42+43+// kind is what the panel lists: conditional formats or validation.44+type kind interface {45+ title() string46+ count(s *sheet.Sheet) int47+ // item draws rule i of the list in w columns on base.48+ item(th *theme.Theme, h Host, i, w int, sel bool) string49+ // form opens rule i for editing, or a new rule over sel for -1.50+ form(h Host, i int, sel sheet.Rect) form51+ remove(s *sheet.Sheet, i int)52+ // move moves rule i to to, and reports whether rules have an order.53+ move(s *sheet.Sheet, i, to int) bool54+ addHint() string55+}56+57+// form is a rule being added or edited.58+type form interface {59+ rows(th *theme.Theme, h Host) []row60+ save(h Host) error61+ title() string62+}63+64+// Editor is the rules panel.65+type Editor struct {66+ h Host67+ k kind68+ f form // the form open, or nil for the list69+ sel int // the picked row of the form70+ msg string71+ list overlay.List // over "Add rule", then the rules72+ top int // the form's scroll73+}74+75+// ID identifies the panel's box in mouse events.76+const ID = "rules"77+78+// Formats opens the panel on the sheet's conditional formats.79+func Formats(h Host) *Editor { return &Editor{h: h, k: cfKind{}} }80+81+// Validations opens the panel on the sheet's data validation.82+func Validations(h Host) *Editor { return &Editor{h: h, k: dvKind{}} }83+84+// Add opens the form for a new rule over the selection, as Insert >85+// Dropdown and a new rule do; with dropdown, a dropdown list.86+func (e *Editor) Add(dropdown bool) *Editor {87+ e.openForm(-1)88+ if f, ok := e.f.(*dvForm); ok && dropdown {89+ f.kind = int(sheet.ValidList)90+ }91+ return e92+}93+94+func (e *Editor) Indicator() string { return "RULES" }95+96+func (e *Editor) openForm(i int) {97+ e.f, e.msg, e.top = e.k.form(e.h, i, e.h.Selection()), "", 098+ e.sel = -199+ e.step(e.rows(), 1)100+ e.enter()101+}102+103+// rows is the open form's rows.104+func (e *Editor) rows() []row { return e.f.rows(e.h.Theme(), e.h) }105+106+// enter puts the picked text row's text in the edit line.107+func (e *Editor) enter() {108+ if rs := e.rows(); e.sel < len(rs) && rs[e.sel].kind == rowText {109+ e.h.Line().Set(*rs[e.sel].text)110+ }111+}112+113+// leave keeps what was typed in the picked text row.114+func (e *Editor) leave() {115+ if rs := e.rows(); e.sel >= 0 && e.sel < len(rs) && rs[e.sel].kind == rowText {116+ *rs[e.sel].text = e.h.Line().Text()117+ }118+}119+120+// step picks the next row d away that can be picked.121+func (e *Editor) step(rs []row, d int) {122+ for i := e.sel + d; i >= 0 && i < len(rs); i += d {123+ if rs[i].picks() {124+ e.sel = i125+ return126+ }127+ }128+}129+130+// pickRow moves the form to row i.131+func (e *Editor) pickRow(i int) {132+ e.leave()133+ e.sel = i134+ e.enter()135+}136+137+func (e *Editor) Key(k tea.KeyPressMsg) tea.Cmd {138+ if e.f != nil {139+ e.formKey(k)140+ return nil141+ }142+ n := e.k.count(e.h.Sheet()) + 1143+ switch key := k.String(); key {144+ case "esc":145+ e.h.Close()146+ case "up", "ctrl+p", "shift+tab":147+ e.list.Move(-1, n)148+ case "down", "ctrl+n", "tab":149+ e.list.Move(1, n)150+ case "enter", "space":151+ e.openForm(e.list.Sel - 1)152+ case "a", "+":153+ e.openForm(-1)154+ case "delete", "backspace", "x", "-":155+ if e.list.Sel > 0 {156+ e.k.remove(e.h.Sheet(), e.list.Sel-1)157+ e.h.Edited()158+ e.list.Sel = min(e.list.Sel, e.k.count(e.h.Sheet()))159+ }160+ case "shift+up", "shift+down":161+ e.reorder(key == "shift+down")162+ }163+ return nil164+}165+166+// reorder moves the highlighted rule up or down the list.167+func (e *Editor) reorder(down bool) {168+ i := e.list.Sel - 1169+ to := i - 1170+ if down {171+ to = i + 1172+ }173+ if i < 0 || to < 0 || to >= e.k.count(e.h.Sheet()) {174+ return175+ }176+ if e.k.move(e.h.Sheet(), i, to) {177+ e.list.Sel = to + 1178+ e.h.Edited()179+ }180+}181+182+func (e *Editor) formKey(k tea.KeyPressMsg) {183+ rs := e.rows()184+ r := rs[e.sel]185+ switch key := k.String(); {186+ case key == "esc":187+ e.f, e.msg = nil, ""188+ case key == "enter":189+ e.leave()190+ if err := e.f.save(e.h); err != nil {191+ e.msg = err.Error()192+ return193+ }194+ e.f, e.msg = nil, ""195+ case key == "up" || key == "shift+tab" || key == "ctrl+p":196+ e.leave()197+ e.step(rs, -1)198+ e.enter()199+ case key == "down" || key == "tab" || key == "ctrl+n":200+ e.leave()201+ e.step(rs, 1)202+ e.enter()203+ case r.kind == rowChoice && (key == "left" || key == "right" || key == "space"):204+ r.cycle(map[bool]int{true: -1, false: 1}[key == "left"])205+ case r.kind == rowCheck && key == "space":206+ *r.on = !*r.on207+ case r.kind == rowText:208+ e.h.Line().Key(k)209+ *r.text = e.h.Line().Text()210+ }211+}212+213+func (e *Editor) Mouse(ev overlay.MouseEvent) tea.Cmd {214+ if ev.Box != ID {215+ if ev.Kind == overlay.MousePress {216+ e.h.Close()217+ }218+ return nil219+ }220+ up := ev.Kind == overlay.MouseWheel && ev.Button == tea.MouseWheelUp221+ down := ev.Kind == overlay.MouseWheel && ev.Button == tea.MouseWheelDown222+ press := ev.Kind == overlay.MousePress && ev.Button == tea.MouseLeft223+ if e.f == nil {224+ n := e.k.count(e.h.Sheet()) + 1225+ i := e.list.Top + ev.Row - 1226+ switch {227+ case up:228+ e.list.Move(-1, n)229+ case down:230+ e.list.Move(1, n)231+ case press && i >= 0 && i < n && i == e.list.Sel:232+ e.openForm(i - 1)233+ case press && i >= 0 && i < n:234+ e.list.Sel = i235+ }236+ return nil237+ }238+ rs := e.rows()239+ i := e.top + ev.Row - 1240+ switch {241+ case up:242+ e.formKey(tea.KeyPressMsg{Code: tea.KeyUp})243+ case down:244+ e.formKey(tea.KeyPressMsg{Code: tea.KeyDown})245+ case !press || i < 0 || i >= len(rs) || !rs[i].picks():246+ case i != e.sel:247+ e.pickRow(i)248+ case rs[i].kind == rowChoice:249+ rs[i].cycle(1)250+ case rs[i].kind == rowCheck:251+ *rs[i].on = !*rs[i].on252+ }253+ return nil254+}255+256+// box places the panel at the right of the grid.257+func (e *Editor) box() (x, y, inner int) {258+ width, _ := e.h.Size()259+ inner = min(46, width-2)260+ return max(width-inner-2, 0), GridTop, inner261+}262+263+// GridTop is the first screen row of the grid, under the control panel264+// and the column headers.265+const GridTop = 4266+267+// visible is how many rows of n fit above the status line.268+func (e *Editor) visible(n int) int {269+ _, height := e.h.Size()270+ return max(min(n, height-1-GridTop-2), 1)271+}272+273+func (e *Editor) Layout() []overlay.Box {274+ th := e.h.Theme()275+ x, y, inner := e.box()276+ if e.f == nil {277+ return []overlay.Box{{ID: ID, X: x, Y: y, Lines: th.Frame(inner, e.k.title(), e.footer(), e.listLines(inner))}}278+ }279+ rs := e.rows()280+ n := e.visible(len(rs))281+ e.top = max(e.sel-n+1, min(e.top, e.sel), 0)282+ var lines []string283+ for i := e.top; i < min(e.top+n, len(rs)); i++ {284+ lines = append(lines, rs[i].draw(th, inner, i == e.sel, e.h.Line().Text()))285+ }286+ return []overlay.Box{{ID: ID, X: x, Y: y, Lines: th.Frame(inner, e.f.title(), "", lines)}}287+}288+289+func (e *Editor) footer() string {290+ n := e.k.count(e.h.Sheet())291+ if n == 1 {292+ return "1 rule"293+ }294+ return strconv.Itoa(n) + " rules"295+}296+297+func (e *Editor) listLines(inner int) []string {298+ th := e.h.Theme()299+ n := e.k.count(e.h.Sheet()) + 1300+ e.list.Sel = min(e.list.Sel, n-1)301+ rows := e.visible(n)302+ e.list.Show(rows)303+ var lines []string304+ for r := range rows {305+ i := e.list.Top + r306+ if i >= n {307+ break308+ }309+ base := th.MenuBar310+ if i == e.list.Sel {311+ base = th.MenuSelected312+ }313+ if i == 0 {314+ lines = append(lines, spread(base, base.Render(" + Add rule"), th.Muted.Inherit(base).Render(e.k.addHint()+" "), inner))315+ continue316+ }317+ lines = append(lines, e.k.item(th, e.h, i-1, inner, i == e.list.Sel))318+ }319+ return lines320+}321+322+// Cursor is the caret in a text row being typed into.323+func (e *Editor) Cursor() (int, int) {324+ if e.f == nil {325+ return -1, -1326+ }327+ rs := e.rows()328+ if e.sel >= len(rs) || rs[e.sel].kind != rowText {329+ return -1, -1330+ }331+ x, y, _ := e.box()332+ return x + 1 + textX() + ansi.StringWidth(e.h.Line().Head()), y + 1 + e.sel - e.top333+}334+335+// Changed keeps what's typed in the picked text row.336+func (e *Editor) Changed() {337+ if e.f != nil {338+ e.leave()339+ }340+}341+342+func (e *Editor) Status() (string, string) {343+ th := e.h.Theme()344+ width, _ := e.h.Size()345+ var desc string346+ var pairs []string347+ switch {348+ case e.f == nil && e.list.Sel == 0:349+ desc, pairs = "Add a rule over the selection", []string{"Enter", "add", "Esc", "close"}350+ case e.f == nil:351+ desc, pairs = "Rules apply in order: the first that matches a cell formats it",352+ []string{"Enter", "edit", "Del", "remove", "Shift+↑/↓", "move", "Esc", "close"}353+ default:354+ r := e.rows()[e.sel]355+ desc = r.hint356+ pairs = []string{"Enter", "save", "Esc", "back"}357+ switch r.kind {358+ case rowChoice:359+ pairs = append([]string{"←/→", "change"}, pairs...)360+ case rowCheck:361+ pairs = append([]string{"Space", "on/off"}, pairs...)362+ }363+ }364+ if e.msg != "" {365+ desc = th.Warning.Render(e.msg)366+ } else {367+ desc = th.Muted.Render(desc)368+ }369+ for {370+ keys := th.KeyHints(pairs...)371+ switch {372+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= width:373+ return desc, keys374+ case e.msg == "" && desc != "":375+ desc = ""376+ case len(pairs) > 4:377+ pairs = pairs[2:]378+ default:379+ return desc, keys380+ }381+ }382+}
A internal/ui/rules/editor_test.go +249 -0
@@ -0,0 +1,249 @@
1+package rules2+3+import (4+ "image/color"5+ "strings"6+ "testing"7+8+ tea "charm.land/bubbletea/v2"9+ "github.com/charmbracelet/x/ansi"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/ui/lineedit"13+ "github.com/FelineStateMachine/012/internal/ui/overlay"14+ "github.com/FelineStateMachine/012/internal/ui/theme"15+)16+17+// fakeHost is a sheet and an edit line, recording what the panel asks.18+type fakeHost struct {19+ th theme.Theme20+ line lineedit.Line21+ s *sheet.Sheet22+ sel sheet.Rect23+ closed bool24+ edits int25+ saves []string26+}27+28+func newHost() *fakeHost {29+ s := sheet.New()30+ for i, v := range []string{"5", "150", "20"} {31+ s.Set(sheet.Addr{Row: i}, v)32+ }33+ return &fakeHost{th: theme.New(true), s: s, sel: sheet.Rect{To: sheet.Addr{Row: 2}}}34+}35+36+func (h *fakeHost) Theme() *theme.Theme { return &h.th }37+func (h *fakeHost) Size() (int, int) { return 100, 30 }38+func (h *fakeHost) Line() *lineedit.Line { return &h.line }39+func (h *fakeHost) Close() { h.closed = true }40+func (h *fakeHost) Sheet() *sheet.Sheet { return h.s }41+func (h *fakeHost) Selection() sheet.Rect { return h.sel }42+func (h *fakeHost) Edited() { h.edits++ }43+func (h *fakeHost) Slot(i int) color.Color { return color.Gray{Y: uint8(i * 16)} }44+func (h *fakeHost) SaveFormat(i int, f sheet.CondFormat) error {45+ h.saves = append(h.saves, f.JSON())46+ if i < 0 {47+ return h.s.AddCondFormat(f)48+ }49+ return h.s.SetCondFormat(i, f)50+}51+func (h *fakeHost) SaveValidation(i int, v sheet.Validation) error {52+ h.saves = append(h.saves, v.JSON())53+ if i < 0 {54+ return h.s.AddValidation(v)55+ }56+ return h.s.SetValidation(i, v)57+}58+59+var keys = map[string]tea.Key{"enter": {Code: tea.KeyEnter}, "esc": {Code: tea.KeyEscape},60+ "up": {Code: tea.KeyUp}, "down": {Code: tea.KeyDown}, "left": {Code: tea.KeyLeft},61+ "right": {Code: tea.KeyRight}, "space": {Code: tea.KeySpace}, "delete": {Code: tea.KeyDelete},62+ "backspace": {Code: tea.KeyBackspace}}63+64+// press sends named keys in angle brackets, and types other text.65+func press(e *Editor, ks ...string) {66+ for _, k := range ks {67+ if name, ok := strings.CutPrefix(k, "<"); ok {68+ name = strings.TrimSuffix(name, ">")69+ key := keys[strings.TrimPrefix(name, "shift+")]70+ if strings.HasPrefix(name, "shift+") {71+ key.Mod = tea.ModShift72+ }73+ e.Key(tea.KeyPressMsg(key))74+ continue75+ }76+ for _, r := range k {77+ e.Key(tea.KeyPressMsg{Code: r, Text: string(r)})78+ }79+ }80+}81+82+func text(e *Editor) string {83+ var b strings.Builder84+ for _, box := range e.Layout() {85+ for _, l := range box.Lines {86+ b.WriteString(ansi.Strip(l) + "\n")87+ }88+ }89+ return b.String()90+}91+92+func TestAddAndEditFormat(t *testing.T) {93+ h := newHost()94+ e := Formats(h)95+ if out := text(e); !strings.Contains(out, "Conditional format rules") || !strings.Contains(out, "+ Add rule") || !strings.Contains(out, "0 rules") {96+ t.Fatalf("list:\n%s", out)97+ }98+ press(e, "<enter>") // add99+ out := text(e)100+ for _, want := range []string{"Add conditional format", "Apply to A1:A3", "‹ Single color ›", "‹ Is not empty ›", "‹ Green ›", "[ ] Bold"} {101+ if !strings.Contains(out, want) {102+ t.Errorf("form lacks %q:\n%s", want, out)103+ }104+ }105+ // Condition: Greater than is ten steps on; a value row appears.106+ press(e, "<down>", "<down>")107+ for range 9 {108+ press(e, "<right>")109+ }110+ if !strings.Contains(text(e), "‹ Greater than ›") {111+ t.Fatalf("condition:\n%s", text(e))112+ }113+ press(e, "<down>", "100", "<down>", "<right>") // text color red114+ press(e, "<down>", "<down>", "<space>") // bold115+ press(e, "<enter>")116+ fs := h.s.CondFormats()117+ if len(fs) != 1 || fs[0].Op != sheet.RuleGreater || fs[0].Args[0] != "100" || fs[0].Style.Text != sheet.ColorRed || !fs[0].Style.Bold {118+ t.Fatalf("saved %+v", fs)119+ }120+ if !h.s.Look(sheet.Addr{Row: 1}).Styled || h.s.Look(sheet.Addr{}).Styled {121+ t.Error("the rule doesn't apply")122+ }123+ if out := text(e); !strings.Contains(out, "A1:A3 Greater than 100") || !strings.Contains(out, "1 rule") {124+ t.Errorf("list after save:\n%s", out)125+ }126+ // Editing it: a bad value keeps the form open with why.127+ press(e, "<down>", "<enter>", "<down>", "<down>", "<down>")128+ h.line.Set("")129+ e.Changed()130+ press(e, "<enter>")131+ if e.msg != "Enter a value" || e.f == nil {132+ t.Errorf("msg %q", e.msg)133+ }134+ desc, _ := e.Status()135+ if !strings.Contains(desc, "Enter a value") {136+ t.Errorf("status %q", desc)137+ }138+ press(e, "<esc>") // back to the list, unchanged139+ if e.f != nil || h.s.CondFormats()[0].Args[0] != "100" {140+ t.Error("Esc didn't discard the form")141+ }142+ press(e, "<esc>")143+ if !h.closed {144+ t.Error("Esc on the list didn't close")145+ }146+}147+148+func TestColorScaleForm(t *testing.T) {149+ h := newHost()150+ e := Formats(h)151+ press(e, "<enter>", "<down>", "<right>") // Color scale152+ out := text(e)153+ for _, want := range []string{"Minpoint ‹ Min value ›", "Midpoint ‹ None ›", "Maxpoint ‹ Max value ›", "‹ Red ›", "‹ Green ›", "Preview"} {154+ if !strings.Contains(out, want) {155+ t.Errorf("scale form lacks %q:\n%s", want, out)156+ }157+ }158+ // A percentile midpoint in yellow.159+ press(e, "<down>", "<down>", "<down>", "<left>")160+ if !strings.Contains(text(e), "‹ Percentile ›") {161+ t.Fatalf("midpoint:\n%s", text(e))162+ }163+ press(e, "<enter>")164+ f := h.s.CondFormats()[0]165+ if len(f.Scale) != 3 || f.Scale[1].Kind != sheet.PointPercentile || f.Scale[1].Value != "50" || f.Scale[1].Color != sheet.ColorYellow {166+ t.Fatalf("scale %+v", f.Scale)167+ }168+ if l := h.s.Look(sheet.Addr{Row: 2}); !l.Scaled || l.To != sheet.ColorYellow || l.Pos != 1 {169+ t.Errorf("look %+v", l)170+ }171+}172+173+func TestRemoveAndReorder(t *testing.T) {174+ h := newHost()175+ for _, v := range []string{"1", "2"} {176+ h.s.AddCondFormat(sheet.CondFormat{Ranges: []sheet.Rect{h.sel}, Op: sheet.RuleEqual, Args: [2]string{v}, Style: sheet.RuleStyle{Bold: true}})177+ }178+ e := Formats(h)179+ press(e, "<down>", "<shift+down>")180+ if fs := h.s.CondFormats(); fs[1].Args[0] != "1" || e.list.Sel != 2 || h.edits != 1 {181+ t.Errorf("reorder: %+v sel %d", fs, e.list.Sel)182+ }183+ press(e, "<delete>")184+ if fs := h.s.CondFormats(); len(fs) != 1 || fs[0].Args[0] != "2" || e.list.Sel != 1 {185+ t.Errorf("remove: %+v sel %d", fs, e.list.Sel)186+ }187+}188+189+func TestValidationForm(t *testing.T) {190+ h := newHost()191+ e := Validations(h).Add(true)192+ out := text(e)193+ for _, want := range []string{"Add data validation", "‹ Dropdown ›", "Items", "‹ Show a warning ›", "Help text"} {194+ if !strings.Contains(out, want) {195+ t.Errorf("form lacks %q:\n%s", want, out)196+ }197+ }198+ press(e, "<down>", "<down>", "Yes, No,, Maybe ", "<down>", "<right>", "<enter>")199+ vs := h.s.Validations()200+ if len(vs) != 1 || strings.Join(vs[0].Items, "|") != "Yes|No|Maybe" || !vs[0].Reject {201+ t.Fatalf("saved %+v", vs)202+ }203+ // A number between, rejecting.204+ press(e, "<down>", "<enter>", "<down>")205+ for range 3 {206+ press(e, "<right>")207+ }208+ press(e, "<down>", "<down>", "1", "<down>", "10", "<enter>")209+ v := h.s.Validations()[0]210+ if v.Kind != sheet.ValidNumber || v.Op != sheet.RuleBetween || v.Args != [2]string{"1", "10"} {211+ t.Fatalf("number rule %+v", v)212+ }213+ if out := text(e); !strings.Contains(out, "Number between 1 and 10") || !strings.Contains(out, "rejects") {214+ t.Errorf("list:\n%s", out)215+ }216+ // Dates offer any date first.217+ press(e, "<enter>", "<down>", "<right>")218+ if !strings.Contains(text(e), "‹ Is a valid date ›") {219+ t.Errorf("date:\n%s", text(e))220+ }221+}222+223+func TestMouseAndCursor(t *testing.T) {224+ h := newHost()225+ e := Formats(h)226+ x, y, _ := e.box()227+ // Clicking the highlighted "Add rule" opens the form.228+ e.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, Button: tea.MouseLeft, Box: ID, Row: 1})229+ if e.f == nil {230+ t.Fatal("no form")231+ }232+ cx, cy := e.Cursor()233+ if cx != x+1+textX()+len("A1:A3") || cy != y+1 {234+ t.Errorf("cursor %d,%d box %d,%d", cx, cy, x, y)235+ }236+ // Clicking a choice row picks it; clicking again changes it.237+ e.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, Button: tea.MouseLeft, Box: ID, Row: 2})238+ e.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, Button: tea.MouseLeft, Box: ID, Row: 2})239+ if !strings.Contains(text(e), "‹ Color scale ›") {240+ t.Errorf("click:\n%s", text(e))241+ }242+ if cx, _ := e.Cursor(); cx >= 0 {243+ t.Error("a caret on a choice row")244+ }245+ e.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, Button: tea.MouseLeft})246+ if !h.closed {247+ t.Error("a click outside didn't close")248+ }249+}
A internal/ui/rules/form.go +111 -0
@@ -0,0 +1,111 @@
1+package rules2+3+import (4+ "strings"5+6+ "charm.land/lipgloss/v2"7+ "github.com/charmbracelet/x/ansi"8+9+ "github.com/FelineStateMachine/012/internal/ui/theme"10+)11+12+// A form is a list of rows in the manner of lazygit's panels: Up and13+// Down pick a row, Left and Right change a choice, Space flips a check,14+// and a text row is typed into (in the host's edit line) while it's15+// picked.16+17+type rowKind uint818+19+const (20+ rowText rowKind = iota21+ rowChoice22+ rowCheck23+ rowSep24+ rowPreview25+)26+27+// row is one line of a form, pointing into the form's state.28+type row struct {29+ kind rowKind30+ label string31+ hint string // what the row sets, for the status line32+ text *string33+ placeholder string34+ choices []string35+ at *int36+ // swatch, when set, draws a sample before each choice's name.37+ swatch func(i int) string38+ on *bool39+ preview func(w int) string40+}41+42+// picks reports whether the row can be picked.43+func (r row) picks() bool { return r.kind != rowSep && r.kind != rowPreview }44+45+// labelW is the width of the label column.46+const labelW = 1247+48+// draw lays out the row in w columns; sel marks it as picked, and49+// editing is the text being typed into it.50+func (r row) draw(th *theme.Theme, w int, sel bool, editing string) string {51+ base, dim := th.MenuBar, th.Muted52+ if sel {53+ base, dim = th.MenuSelected, th.MenuSelected54+ }55+ lead := base.Render(" " + theme.PadRight(r.label, labelW-1))56+ var val string57+ switch r.kind {58+ case rowSep:59+ return theme.SepRow60+ case rowCheck:61+ box := "[ ] "62+ if *r.on {63+ box = "[x] "64+ }65+ return theme.Cells(base, " "+box+r.label, w)66+ case rowPreview:67+ return spread(base, lead+r.preview(w-labelW-1), "", w)68+ case rowChoice:69+ chip := th.KeyChip70+ if sel {71+ chip = th.Indicator72+ }73+ i := min(max(*r.at, 0), len(r.choices)-1)74+ val = chip.Render("‹ " + r.choices[i] + " ›")75+ if r.swatch != nil {76+ val = r.swatch(i) + base.Render(" ") + val77+ }78+ case rowText:79+ text := *r.text80+ if sel {81+ text = editing82+ }83+ switch {84+ case text == "" && !sel:85+ val = dim.Render(r.placeholder)86+ case sel:87+ val = base.Render(text)88+ default:89+ val = th.Key.Render(text)90+ }91+ }92+ return spread(base, lead+base.Render(" ")+val, "", w)93+}94+95+// spread lays left and right out in w columns on base, cutting left to96+// make room for right.97+func spread(base lipgloss.Style, left, right string, w int) string {98+ rw := ansi.StringWidth(right)99+ left = ansi.Truncate(left, max(w-rw-1, 1), "…")100+ gap := max(w-ansi.StringWidth(left)-rw, 0)101+ return ansi.Truncate(left+base.Render(strings.Repeat(" ", gap))+right, w, "")102+}103+104+// cycle moves a choice row d steps, wrapping around.105+func (r row) cycle(d int) {106+ n := len(r.choices)107+ *r.at = ((*r.at+d)%n + n) % n108+}109+110+// textX is where a text row's value starts, from the box's inner left.111+func textX() int { return labelW + 1 }
A internal/ui/rules/validation.go +192 -0
@@ -0,0 +1,192 @@
1+package rules2+3+import (4+ "errors"5+ "strings"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+ "github.com/FelineStateMachine/012/internal/ui/theme"9+)10+11+var errRange = errors.New("Enter the range the rule applies to, e.g. B2:B100")12+13+// dvKind lists data validation rules.14+type dvKind struct{}15+16+func (dvKind) title() string { return "Data validation rules" }17+func (dvKind) count(s *sheet.Sheet) int { return len(s.Validations()) }18+func (dvKind) remove(s *sheet.Sheet, i int) { s.DeleteValidation(i) }19+func (dvKind) move(*sheet.Sheet, int, int) bool {20+ return false // a cell has one rule, so they have no order21+}22+func (dvKind) addHint() string { return "A" }23+24+// marks are how the list shows each kind of rule, as the cells do.25+var marks = map[sheet.ValidKind]string{sheet.ValidList: " ▾ ", sheet.ValidRange: " ▾ ", sheet.ValidCheckbox: "[✓]"}26+27+func (dvKind) item(th *theme.Theme, h Host, i, w int, sel bool) string {28+ v := h.Sheet().Validations()[i]29+ base := th.MenuBar30+ if sel {31+ base = th.MenuSelected32+ }33+ mark := marks[v.Kind]34+ if mark == "" {35+ mark = " "36+ }37+ text := base.Render(" "+mark+" "+sheet.RangesText(v.Ranges)+" ") + base.Render(v.Summary())38+ right := ""39+ if v.Reject {40+ right = th.Muted.Inherit(base).Render("rejects ")41+ }42+ return spread(base, text, right, w)43+}44+45+// dvForm is a data validation rule being edited.46+type dvForm struct {47+ i int48+ ranges string49+ kind int // a sheet.ValidKind50+ items string51+ source string52+ op int // index in the comparisons of the kind; see ops53+ args [2]string54+ formula string55+ reject int // 0 warns, 1 rejects56+ help string57+}58+59+func (dvKind) form(h Host, i int, sel sheet.Rect) form {60+ f := &dvForm{i: i, ranges: sel.String(), kind: int(sheet.ValidList)}61+ if i < 0 {62+ return f63+ }64+ v := h.Sheet().Validations()[i]65+ f.ranges, f.kind, f.source, f.help = sheet.RangesText(v.Ranges), int(v.Kind), v.Source, v.Help66+ f.items = strings.Join(v.Items, ", ")67+ if v.Reject {68+ f.reject = 169+ }70+ switch {71+ case v.Kind == sheet.ValidFormula:72+ f.formula = v.Args[0]73+ case v.Kind.Compares():74+ f.op, f.args = max(indexOf(f.ops(), v.Op), 0), v.Args75+ }76+ return f77+}78+79+func (f *dvForm) title() string {80+ if f.i < 0 {81+ return "Add data validation"82+ }83+ return "Edit data validation"84+}85+86+// ops are the comparisons the kind offers: dates may be any date.87+func (f *dvForm) ops() []sheet.RuleOp {88+ if sheet.ValidKind(f.kind) == sheet.ValidDate {89+ return append([]sheet.RuleOp{sheet.RuleNone}, sheet.CompareOps()...)90+ }91+ return sheet.CompareOps()92+}93+94+// opTitle names a comparison as the kind reads it.95+func opTitle(k sheet.ValidKind, op sheet.RuleOp) string {96+ titles := map[sheet.RuleOp]string{sheet.RuleNone: "Is a valid date", sheet.RuleBetween: "Between",97+ sheet.RuleNotBetween: "Not between", sheet.RuleEqual: "Equal to", sheet.RuleNotEqual: "Not equal to",98+ sheet.RuleGreater: "Greater than", sheet.RuleGreaterEq: "Greater than or equal to",99+ sheet.RuleLess: "Less than", sheet.RuleLessEq: "Less than or equal to"}100+ if k == sheet.ValidDate {101+ titles[sheet.RuleEqual], titles[sheet.RuleNotEqual] = "On", "Not on"102+ titles[sheet.RuleGreater], titles[sheet.RuleGreaterEq] = "After", "On or after"103+ titles[sheet.RuleLess], titles[sheet.RuleLessEq] = "Before", "On or before"104+ }105+ return titles[op]106+}107+108+// rule is the rule the form describes.109+func (f *dvForm) rule() (sheet.Validation, error) {110+ rs, ok := sheet.ParseRanges(f.ranges)111+ if !ok && strings.TrimSpace(f.ranges) != "" {112+ return sheet.Validation{}, errRange113+ }114+ k := sheet.ValidKind(f.kind)115+ v := sheet.Validation{Ranges: rs, Kind: k, Reject: f.reject == 1, Help: strings.TrimSpace(f.help)}116+ switch {117+ case k == sheet.ValidList:118+ for _, it := range strings.Split(f.items, ",") {119+ if it = strings.TrimSpace(it); it != "" {120+ v.Items = append(v.Items, it)121+ }122+ }123+ case k == sheet.ValidRange:124+ v.Source = strings.TrimSpace(f.source)125+ case k == sheet.ValidFormula:126+ v.Args[0] = strings.TrimSpace(f.formula)127+ case k.Compares():128+ ops := f.ops()129+ v.Op = ops[min(f.op, len(ops)-1)]130+ v.Args = [2]string{strings.TrimSpace(f.args[0]), strings.TrimSpace(f.args[1])}131+ }132+ return v, nil133+}134+135+func (f *dvForm) save(h Host) error {136+ v, err := f.rule()137+ if err != nil {138+ return err139+ }140+ return h.SaveValidation(f.i, v)141+}142+143+func (f *dvForm) rows(*theme.Theme, Host) []row {144+ kinds := sheet.ValidKinds()145+ titles := make([]string, len(kinds))146+ for i, k := range kinds {147+ titles[i] = k.Title()148+ }149+ rs := []row{150+ {kind: rowText, label: "Apply to", text: &f.ranges, placeholder: "B2:B100", hint: "The ranges the rule applies to, e.g. B2:B100 or B2:B9,D2:D9"},151+ {kind: rowChoice, label: "Criteria", choices: titles, at: &f.kind, hint: "What the cells may hold"},152+ }153+ rs = append(rs, f.criteriaRows()...)154+ return append(rs, row{kind: rowSep},155+ row{kind: rowChoice, label: "If invalid", choices: []string{"Show a warning", "Reject the input"}, at: &f.reject,156+ hint: "Keep an invalid entry and mark it, or refuse it"},157+ row{kind: rowText, label: "Help text", text: &f.help, placeholder: "the rule's own", hint: "What the context line says about the cells; empty for the rule's own words"})158+}159+160+// criteriaRows are the rows of what the criteria need.161+func (f *dvForm) criteriaRows() []row {162+ k := sheet.ValidKind(f.kind)163+ switch {164+ case k == sheet.ValidList:165+ return []row{{kind: rowText, label: "Items", text: &f.items, placeholder: "Yes, No, Maybe", hint: "The dropdown's items, separated by commas"}}166+ case k == sheet.ValidRange:167+ return []row{{kind: rowText, label: "From range", text: &f.source, placeholder: "Lists!A1:A20", hint: "The range whose values the dropdown lists"}}168+ case k == sheet.ValidFormula:169+ return []row{{kind: rowText, label: "Formula", text: &f.formula, placeholder: "=B2<=C2", hint: "TRUE for valid entries, written for the first cell"}}170+ case !k.Compares():171+ return nil172+ }173+ ops := f.ops()174+ f.op = min(f.op, len(ops)-1)175+ titles := make([]string, len(ops))176+ for i, op := range ops {177+ titles[i] = opTitle(k, op)178+ }179+ rs := []row{{kind: rowChoice, label: "Condition", choices: titles, at: &f.op, hint: "How entries compare"}}180+ holder := "number"181+ if k == sheet.ValidDate {182+ holder = "2026-09-30"183+ }184+ switch ops[f.op].Args() {185+ case 1:186+ rs = append(rs, row{kind: rowText, label: "Value", text: &f.args[0], placeholder: holder, hint: "The value to compare with, or a formula starting with ="})187+ case 2:188+ rs = append(rs, row{kind: rowText, label: "Between", text: &f.args[0], placeholder: holder, hint: "The lowest value"},189+ row{kind: rowText, label: "And", text: &f.args[1], placeholder: holder, hint: "The highest value"})190+ }191+ return rs192+}
A internal/ui/rules_test.go +213 -0
@@ -0,0 +1,213 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+ "github.com/FelineStateMachine/012/internal/ui/picker"9+ "github.com/FelineStateMachine/012/internal/ui/rowtext"10+ "github.com/FelineStateMachine/012/internal/ui/rules"11+)12+13+func mustValidate(t *testing.T, m *Model, line string) {14+ t.Helper()15+ v, err := sheet.ParseValidation(line)16+ if err != nil {17+ t.Fatal(err)18+ }19+ if err := m.sheet.AddValidation(v); err != nil {20+ t.Fatal(err)21+ }22+}23+24+func TestConditionalFormattingPanel(t *testing.T) {25+ m := newModel()26+ press(t, m, "5", "<enter>", "150", "<enter>", "<up>", "<up>", "<shift+down>")27+ run(m, m.runCommand("format.conditional"))28+ if _, ok := m.overlay.(*rules.Editor); !ok || !strings.Contains(screen(m), "Conditional format rules") {29+ t.Fatalf("overlay %T:\n%s", m.overlay, screen(m))30+ }31+ if !strings.Contains(line(m, menuLine), "RULES") {32+ t.Errorf("indicator: %q", line(m, menuLine))33+ }34+ press(t, m, "<enter>", "<down>", "<down>")35+ for range 9 {36+ press(t, m, "<right>") // Greater than37+ }38+ press(t, m, "<down>", "100", "<enter>")39+ fs := m.sheet.CondFormats()40+ if len(fs) != 1 || fs[0].Summary() != "Greater than 100" || sheet.RangesText(fs[0].Ranges) != "A1:A2" || !m.changed {41+ t.Fatalf("rules %+v", fs)42+ }43+ press(t, m, "<esc>")44+ if m.overlay != nil || m.mode != modeReady {45+ t.Fatalf("still open: %T", m.overlay)46+ }47+ // A2 is drawn on the green fill, A1 isn't.48+ sp := rowtext.Span{Text: "150", Owner: 0}49+ base, colored, _ := m.ruleSpan(addr("A2"), &sp, m.th.Cell, false, m.slotColor)50+ if !colored || base.GetBackground() != m.th.RuleFill[sheet.ColorGreen].GetBackground() {51+ t.Errorf("A2 base %v colored %v", base.GetBackground(), colored)52+ }53+ if _, colored, _ := m.ruleSpan(addr("A1"), &sp, m.th.Cell, false, m.slotColor); colored {54+ t.Error("A1 formatted")55+ }56+ press(t, m, "<ctrl+z>")57+ if len(m.sheet.CondFormats()) != 0 {58+ t.Error("undo kept the rule")59+ }60+}61+62+func TestCheckboxes(t *testing.T) {63+ m := newModel()64+ press(t, m, "<right>", "<shift+down>", "<shift+down>")65+ run(m, m.runCommand("insert.checkbox"))66+ if vs := m.sheet.Validations(); len(vs) != 1 || vs[0].Kind != sheet.ValidCheckbox {67+ t.Fatalf("validations %+v", vs)68+ }69+ press(t, m, "<esc>", "<space>")70+ if input(m, "B1") != "TRUE" || m.mode != modeReady {71+ t.Fatalf("B1 %q mode %v", input(m, "B1"), m.mode)72+ }73+ out := screen(m)74+ if strings.Count(out, "[✓]") != 1 || strings.Count(out, "[ ]") != 2 {75+ t.Errorf("glyphs:\n%s", out)76+ }77+ if !strings.Contains(line(m, contextLine), "Space") {78+ t.Errorf("context line %q", line(m, contextLine))79+ }80+ // A selection toggles together, as the active cell goes.81+ press(t, m, "<shift+down>", "<shift+down>", "<space>")82+ if input(m, "B1") != "FALSE" || input(m, "B3") != "" { // blank is unchecked already83+ t.Errorf("B1 %q B3 %q", input(m, "B1"), input(m, "B3"))84+ }85+ press(t, m, "<space>")86+ if input(m, "B1") != "TRUE" || input(m, "B3") != "TRUE" || m.sheet.Book().UndoLabel() != "check B1:B3" {87+ t.Errorf("check all: B1 %q B3 %q %q", input(m, "B1"), input(m, "B3"), m.sheet.Book().UndoLabel())88+ }89+ press(t, m, "<space>")90+ if input(m, "B2") != "FALSE" {91+ t.Errorf("uncheck all: B2 %q", input(m, "B2"))92+ }93+ press(t, m, "<ctrl+z>", "<ctrl+z>")94+ // A click on the glyph toggles; Space elsewhere starts an entry.95+ press(t, m, "<esc>")96+ click(m, minRowHdrW+sheet.DefaultWidth+4, gridTop+1, 0)97+ if m.cur != addr("B2") || input(m, "B2") != "TRUE" {98+ t.Errorf("click: cur %v B2 %q", m.cur, input(m, "B2"))99+ }100+ press(t, m, "<left>", " ")101+ if m.mode != modeEnter {102+ t.Errorf("space on a plain cell: mode %v", m.mode)103+ }104+}105+106+func TestDropdown(t *testing.T) {107+ m := newModel()108+ mustValidate(t, m, `{"ranges":"A1:A5","criteria":"list","items":["Yes","No","Maybe"]}`)109+ if !strings.Contains(line(m, gridTop), "▾") {110+ t.Errorf("no dropdown mark: %q", line(m, gridTop))111+ }112+ press(t, m, "<alt+down>")113+ p, ok := m.overlay.(*picker.Picker)114+ if !ok || len(p.Items) != 3 || p.At == nil {115+ t.Fatalf("overlay %T", m.overlay)116+ }117+ press(t, m, "no", "<enter>")118+ if input(m, "A1") != "No" || m.overlay != nil {119+ t.Fatalf("A1 %q overlay %T", input(m, "A1"), m.overlay)120+ }121+ // Clicking the mark of another cell opens its list there.122+ click(m, minRowHdrW+sheet.DefaultWidth-1, gridTop+2, 0)123+ if _, ok := m.overlay.(*picker.Picker); !ok || m.cur != addr("A3") {124+ t.Fatalf("click: cur %v overlay %T", m.cur, m.overlay)125+ }126+ press(t, m, "<esc>")127+ if input(m, "A3") != "" {128+ t.Error("Esc entered something")129+ }130+}131+132+func TestValidationRejectsAndWarns(t *testing.T) {133+ m := newModel()134+ mustValidate(t, m, `{"ranges":"A1:A9","criteria":"number","condition":"between","values":["1","10"],"reject":true}`)135+ mustValidate(t, m, `{"ranges":"B1:B9","criteria":"number","condition":"between","values":["1","10"]}`)136+ press(t, m, "42", "<enter>")137+ if m.mode != modeEdit || input(m, "A1") != "" || !strings.Contains(line(m, contextLine), "Invalid entry in A1: Input must be a number between 1 and 10") {138+ t.Fatalf("reject: mode %v A1 %q %q", m.mode, input(m, "A1"), line(m, contextLine))139+ }140+ press(t, m, "<esc>", "<right>", "42", "<enter>")141+ if input(m, "B1") != "42" || !strings.Contains(line(m, contextLine), "Invalid entry in B1") {142+ t.Fatalf("warn: B1 %q %q", input(m, "B1"), line(m, contextLine))143+ }144+ press(t, m, "<up>")145+ if !strings.Contains(line(m, contextLine), "Invalid: Input must be a number between 1 and 10") || !m.sheet.Look(addr("B1")).Invalid {146+ t.Errorf("on the invalid cell: %q", line(m, contextLine))147+ }148+ // Scripts are refused too.149+ script(t, m, `select("A2")150+enter("0")`)151+ if input(m, "A2") != "" || !strings.Contains(m.warn, "Input must be a number between 1 and 10") {152+ t.Errorf("script: A2 %q warn %q", input(m, "A2"), m.warn)153+ }154+}155+156+func TestRuleMacros(t *testing.T) {157+ m := newModel()158+ press(t, m, "<shift+down>")159+ src := recordRules(t, m)160+ for _, want := range []string{`run("format.conditional_add", answer="{\"ranges\":\"A1:A2\",\"condition\":\"not_empty\",\"fill\":\"green\"}")`,161+ `run("insert.checkbox")`, `run("data.validation_add", answer="{\"ranges\":\"C1:C2\",\"criteria\":\"list\",\"items\":[\"a\",\"b\"]}")`} {162+ if !strings.Contains(src, want) {163+ t.Errorf("script lacks %s:\n%s", want, src)164+ }165+ }166+ fresh := newModel()167+ script(t, fresh, src)168+ if len(fresh.sheet.CondFormats()) != 1 || len(fresh.sheet.Validations()) != 2 {169+ t.Errorf("replay: %+v %+v %q", fresh.sheet.CondFormats(), fresh.sheet.Validations(), fresh.warn)170+ }171+}172+173+// recordRules records adding a conditional format from the panel, a174+// checkbox and a dropdown from the Add command.175+func recordRules(t *testing.T, m *Model) string {176+ t.Helper()177+ send(m, nil)178+ run(m, m.runCommand("macro.record"))179+ run(m, m.runCommand("format.conditional"))180+ press(t, m, "<enter>", "<enter>", "<esc>", "<right>")181+ run(m, m.runCommand("insert.checkbox"))182+ press(t, m, "<right>")183+ run(m, m.runCommand("data.validation_add"))184+ m.line.Set(`{"ranges":"C1:C2","criteria":"list","items":["a","b"]}`)185+ press(t, m, "<enter>")186+ run(m, m.runCommand("macro.stop"))187+ m.line.Set("Rules")188+ press(t, m, "<enter>", "<enter>")189+ mc, ok := m.book().Macro("Rules")190+ if !ok {191+ t.Fatalf("no macro: %q", m.errMsg)192+ }193+ return mc.Source194+}195+196+func TestRulesRespectPivots(t *testing.T) {197+ m := wideModel()198+ salesTable(t, m)199+ m.runCommand("data.pivot")200+ press(t, m, "<space>", "reg", "<enter>", "<enter>")201+ press(t, m, "<down>")202+ run(m, m.runCommand("insert.checkbox"))203+ if len(m.sheet.Validations()) != 0 || !strings.Contains(line(m, contextLine), "Pivot table results can't be edited") {204+ t.Errorf("checkbox on a pivot: %+v %q", m.sheet.Validations(), line(m, contextLine))205+ }206+ if err := m.host().SaveValidation(-1, sheet.Validation{Ranges: []sheet.Rect{rectOf("A1:B3")}, Kind: sheet.ValidCheckbox}); err == nil {207+ t.Error("validation saved over a pivot")208+ }209+ // Conditional formats only draw, so they may color a pivot.210+ if err := m.host().SaveFormat(-1, sheet.CondFormat{Ranges: []sheet.Rect{rectOf("B2:B3")}, Op: sheet.RuleGreater, Args: [2]string{"5"}, Style: sheet.RuleStyle{Bold: true}}); err != nil {211+ t.Error(err)212+ }213+}
A internal/ui/theme/rules.go +152 -0
@@ -0,0 +1,152 @@
1+package theme2+3+import (4+ "image/color"5+ "math"6+7+ "charm.land/lipgloss/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// Conditional formats draw in the named colors of sheet.Color, each an14+// ANSI slot, so a rule looks right in the terminal's palette and in every15+// color scheme: a text color is the slot as a foreground, a fill the slot16+// as a background with text readable on it, and a color scale blends17+// the slots of its points.18+19+// ruleSlots are the ANSI colors of sheet.Color, by index.20+var ruleSlots = [sheet.NumColors]ansi.BasicColor{0, lipgloss.Red, lipgloss.Yellow, lipgloss.Green,21+ lipgloss.Cyan, lipgloss.Blue, lipgloss.Magenta}22+23+// RuleSlot is the ANSI color index of a rule color, or -1 for none.24+func RuleSlot(c sheet.Color) int {25+ if c == sheet.ColorNone || int(c) >= sheet.NumColors {26+ return -127+ }28+ return int(ruleSlots[c])29+}30+31+// referenceANSI are the palettes the terminal theme's rule colors are32+// checked against, the reference palettes of the e2e screens (common33+// Ghostty defaults), dark and light.34+var referenceANSI = [2][16]uint32{35+ {0x1d1f21, 0xcc6666, 0xb5bd68, 0xf0c674, 0x81a2be, 0xb294bb, 0x8abeb7, 0xc5c8c6,36+ 0x666666, 0xd54e53, 0xb9ca4a, 0xe7c547, 0x7aa6da, 0xc397d8, 0x70c0b1, 0xeaeaea},37+ {0x1d1f21, 0xc82829, 0x718c00, 0xb58900, 0x4271ae, 0x8959a8, 0x3e999f, 0xd6d6d6,38+ 0x8e908c, 0xc82829, 0x718c00, 0xb58900, 0x4271ae, 0x8959a8, 0x3e999f, 0xffffff},39+}40+41+func reference(dark bool, slot ansi.BasicColor) color.Color {42+ p := referenceANSI[1]43+ if dark {44+ p = referenceANSI[0]45+ }46+ v := p[slot%16]47+ return color.RGBA{R: uint8(v >> 16), G: uint8(v >> 8), B: uint8(v), A: 0xff}48+}49+50+// ruleInk is the text color on a fill: black or bright white, whichever51+// reads better on the reference palette.52+func ruleInk(dark bool, fill ansi.BasicColor) ansi.BasicColor {53+ bg := reference(dark, fill)54+ if contrast(reference(dark, lipgloss.Black), bg) >= contrast(reference(dark, lipgloss.BrightWhite), bg) {55+ return lipgloss.Black56+ }57+ return lipgloss.BrightWhite58+}59+60+// ruleRoles sets the roles of rule colors for the terminal theme.61+func ruleRoles(t *Theme, dark bool) {62+ n := sheet.NumColors63+ for c := 1; c < n; c++ {64+ slot := ruleSlots[c]65+ t.RuleText[c] = lipgloss.NewStyle().Foreground(slot)66+ t.RuleFill[c] = lipgloss.NewStyle().Background(slot).Foreground(ruleInk(dark, slot))67+ }68+ for f := 1; f < n; f++ {69+ for c := 1; c < n; c++ {70+ // A text color on a fill where it reads; the fill's own ink71+ // where it wouldn't (red on red, yellow on green).72+ fg := ruleSlots[c]73+ if c == f || contrast(reference(dark, fg), reference(dark, ruleSlots[f])) < minText {74+ fg = ruleInk(dark, ruleSlots[f])75+ }76+ t.RuleOn[f*n+c] = lipgloss.NewStyle().Background(ruleSlots[f]).Foreground(fg)77+ }78+ }79+}80+81+// Rule is the base style of a cell a single-color rule formats: its82+// fill and text color, with text readable on the fill. Text styles are83+// added by Text.84+func (t *Theme) Rule(st sheet.RuleStyle) lipgloss.Style {85+ switch {86+ case st.Fill == sheet.ColorNone && st.Text == sheet.ColorNone:87+ return t.Cell88+ case st.Fill == sheet.ColorNone:89+ return t.RuleText[st.Text]90+ case st.Text == sheet.ColorNone:91+ return t.RuleFill[st.Fill]92+ }93+ return t.RuleOn[int(st.Fill)*sheet.NumColors+int(st.Text)]94+}95+96+// scaleSteps is how many shades a color scale has between two points.97+const scaleSteps = 3298+99+type scaleKey struct {100+ from, to color.RGBA101+ step int102+}103+104+// ScaleFill is the fill of a color scale's cell, pos (0 to 1) of the way105+// from one point's color to the next's, with text readable on it. The106+// colors are the scheme's, or for the terminal theme rgb's (the107+// 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 {109+ slotRGB := func(c sheet.Color) color.RGBA {110+ var col color.Color = color.Black111+ if s := RuleSlot(c); s >= 0 {112+ if t.Palette != nil {113+ col = t.Palette.ANSI[s]114+ } else {115+ col = rgb(s)116+ }117+ }118+ r, g, b, _ := col.RGBA()119+ return color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 0xff}120+ }121+ step := int(math.Round(min(max(pos, 0), 1) * scaleSteps))122+ k := scaleKey{slotRGB(from), slotRGB(to), step}123+ if s, ok := t.scales[k]; ok {124+ return s125+ }126+ bg := blend(k.from, k.to, float64(step)/scaleSteps)127+ s := lipgloss.NewStyle().Background(bg).Foreground(t.scaleInk(bg, rgb))128+ if t.scales != nil && len(t.scales) < 4096 {129+ t.scales[k] = s130+ }131+ return s132+}133+134+// scaleInk is the text color on a scale's shade: the screen's text or135+// background color, or failing both black or white.136+func (t *Theme) scaleInk(bg color.Color, rgb func(int) color.Color) color.Color {137+ fg, back := rgb(15), rgb(0)138+ if t.Palette != nil {139+ fg, back = t.Palette.Foreground, t.Palette.Background140+ }141+ best := fg142+ if contrast(back, bg) > contrast(fg, bg) {143+ best = back144+ }145+ if contrast(best, bg) >= minText {146+ return best147+ }148+ if contrast(color.Black, bg) > contrast(color.White, bg) {149+ return color.Black150+ }151+ return color.White152+}
A internal/ui/theme/rules_test.go +73 -0
@@ -0,0 +1,73 @@
1+package theme2+3+import (4+ "image/color"5+ "testing"6+7+ "github.com/charmbracelet/x/ansi"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// In the terminal theme, rule fills and text colors on them read on13+// both reference palettes: the fill's ink stands in where a text color14+// wouldn't.15+func TestRuleColorsReadOnReferencePalettes(t *testing.T) {16+ for _, dark := range []bool{true, false} {17+ th := New(dark)18+ for _, c := range sheet.Colors()[1:] {19+ for _, text := range sheet.Colors() {20+ s := th.Rule(sheet.RuleStyle{Fill: c, Text: text})21+ fg := reference(dark, s.GetForeground().(ansi.BasicColor))22+ bg := reference(dark, s.GetBackground().(ansi.BasicColor))23+ // The reference palettes' own colors fall a little short24+ // of 4.5 in places: black on the light green is 4.29, as25+ // for Traced.26+ if r := contrast(fg, bg); r < 4.25 {27+ t.Errorf("dark %v: %s text on %s fill: %.2f", dark, text.Title(), c.Title(), r)28+ }29+ }30+ }31+ }32+}33+34+// Every scheme's rule colors read: text colors on the screen and fills35+// with their text (TestEveryThemeReadable checks the roles), and every36+// shade of every color scale.37+func TestColorScalesReadable(t *testing.T) {38+ xterm := func(i int) color.Color { return reference(true, ansi.BasicColor(i)) }39+ for _, p := range append(Builtins(), Palette{}) {40+ th := New(true)41+ if p.Background != nil {42+ th = FromPalette(p)43+ }44+ for _, from := range sheet.Colors()[1:] {45+ for _, to := range sheet.Colors()[1:] {46+ for step := 0; step <= scaleSteps; step += 4 {47+ s := th.ScaleFill(from, to, float64(step)/scaleSteps, xterm)48+ requireContrast(t, p.Name, "color scale", s.GetForeground(), s.GetBackground(), minText)49+ }50+ }51+ }52+ }53+}54+55+func TestScaleFillBlends(t *testing.T) {56+ p, _ := Lookup("Dracula", "")57+ th := FromPalette(p)58+ 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()62+ same := func(a, b color.Color) bool {63+ ar, ag, ab, _ := a.RGBA()64+ br, bg, bb, _ := b.RGBA()65+ return ar>>8 == br>>8 && ag>>8 == bg>>8 && ab>>8 == bb>>866+ }67+ if !same(lo, p.ANSI[1]) || !same(hi, p.ANSI[2]) || same(mid, lo) || same(mid, hi) {68+ t.Errorf("scale %v %v %v, scheme's red %v green %v", lo, mid, hi, p.ANSI[1], p.ANSI[2])69+ }70+ if len(th.scales) != 3 {71+ t.Errorf("%d shades kept", len(th.scales))72+ }73+}
M internal/ui/theme/scheme.go +1 -1
M internal/ui/theme/theme.go +21 -0
@@ -66,6 +66,22 @@ type Theme struct {
6666 // hides something (the mark itself also changes, from ▾ to ▼).6767 FilterOn lipgloss.Style6868 69+ // Conditional formats (rules.go), indexed by sheet.Color: a rule's70+ // text color on the cell, its fill with text readable on it, and71+ // RuleOn[fill*sheet.NumColors+text] a text color on a fill, in the72+ // fill's own ink where the text color wouldn't read on it.73+ RuleText [sheet.NumColors]lipgloss.Style74+ RuleFill [sheet.NumColors]lipgloss.Style75+ RuleOn [sheet.NumColors * sheet.NumColors]lipgloss.Style76+ // Invalid is layered on the text of a cell that fails its data77+ // validation, as Sheets' red corner: a dotted underline, in the78+ // warning color where the terminal colors underlines.79+ Invalid lipgloss.Style80+ // Dropdown is the ▾ at the right of a cell with a dropdown list.81+ Dropdown lipgloss.Style82+ // scales keeps the shades of color scales drawn so far.83+ scales map[scaleKey]lipgloss.Style84+6985 // Chrome: the menu bar, dropdowns, the palette and dialogs.7086 MenuBar lipgloss.Style // menu bar titles7187 MenuAccel lipgloss.Style // a title's accelerator letter
@@ -212,7 +228,12 @@ func New(dark bool) Theme {
212228 ChartSelected: lipgloss.NewStyle().Foreground(lipgloss.Cyan).Bold(true),213229 ChartAxis: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),214230 ChartLabel: lipgloss.NewStyle().Foreground(muted),231+232+ Invalid: lipgloss.NewStyle().UnderlineStyle(lipgloss.UnderlineDotted).UnderlineColor(lipgloss.Yellow),233+ Dropdown: lipgloss.NewStyle().Foreground(muted),234+ scales: map[scaleKey]lipgloss.Style{},215235 }236+ ruleRoles(&t, dark)216237 for i, c := range series {217238 t.Series[i] = lipgloss.NewStyle().Foreground(c)218239 t.SeriesBg[i] = lipgloss.NewStyle().Background(c)
M internal/ui/view.go +20 -7
@@ -1,6 +1,7 @@
11 package ui22 33 import (4+ "image/color"45 "strconv"56 "strings"67
@@ -147,6 +148,11 @@ func (m *Model) gridRow(row int) string {
147148 // roles for the pointer, the selection, search matches and errors.148149 func (m *Model) cellsText(row, first int, spans []rowtext.Span, focus sheet.Addr, sel sheet.Rect, selecting bool) string {149150 var b strings.Builder151+ rules := m.sheet.HasRules()152+ var rgb func(int) color.Color153+ if rules {154+ rgb = m.slotColor155+ }150156 for i, sp := range spans {151157 a := sheet.Addr{Col: first + i, Row: row}152158 base, colored := m.th.Cell, true
@@ -166,19 +172,26 @@ func (m *Model) cellsText(row, first int, spans []rowtext.Span, focus sheet.Addr
166172 default:167173 colored = false168174 }169- // The copy marker is layered on the cell's own colors.170- if m.copied.marks(m.sheet, a) {171- base, colored = base.Inherit(m.th.Copied), true172- }175+ var look sheet.Look173176 if a == m.cur && (m.mode == modeEnter || m.mode == modeEdit) && !m.away() {174177 sp = rowtext.Span{Text: m.inCellText(m.sheet.ColWidth(a.Col))}175178 } else {176179 m.decorate(&sp, row) // links and error marks, see links.go180+ if rules {181+ base, colored, look = m.ruleSpan(a, &sp, base, colored, rgb) // looks.go182+ }183+ }184+ // The copy marker is layered on the cell's own colors.185+ if m.copied.marks(m.sheet, a) {186+ base, colored = base.Inherit(m.th.Copied), true177187 }178188 text := renderSpan(&m.th, sp, base, colored)179- if m.showFillHandle(a) {180- w := m.sheet.ColWidth(a.Col)189+ w := m.sheet.ColWidth(a.Col)190+ switch {191+ case m.showFillHandle(a):181192 text = ansi.Truncate(text, w-1, "") + base.Render("▟")193+ case look.Dropdown:194+ text = m.dropdownMark(text, w, base, colored)182195 }183196 b.WriteString(text)184197 }
@@ -206,7 +219,7 @@ func renderSpan(th *theme.Theme, sp rowtext.Span, base lipgloss.Style, colored b
206219 lead, trail := strings.Repeat(" ", sp.Lead), strings.Repeat(" ", sp.Trail)207220 st := sp.Style208221 st.Align = sheet.AlignAuto209- plain := st.IsZero() && sp.Link == "" && !sp.Error222+ plain := st.IsZero() && sp.Link == "" && !sp.Error && !sp.Invalid210223 switch {211224 case plain && !colored:212225 return lead + sp.Text + trail