cat / 012
clone

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

cat/module-path
012 / internal / ui / rules.go
rules.go 12.7K
1package ui23import (4 "errors"5 "fmt"6 "image/color"78 tea "charm.land/bubbletea/v2"910 "src.rest/cat/012/internal/macro"11 "src.rest/cat/012/internal/sheet"12 "src.rest/cat/012/internal/ui/picker"13 "src.rest/cat/012/internal/ui/rules"14)1516// Rules follow Sheets: Format > Conditional formatting and Data > Data17// validation open a panel of the sheet's rules at the right of the grid18// (package rules), Insert > Checkbox and Insert > Dropdown add the two19// rules people reach for most, Space toggles a checkbox and Alt+Down20// opens a dropdown, and an entry a rule doesn't accept is refused or21// kept with a warning on the context line. The panel's rules are saved22// as the commands that add a rule from a line of the file, so a macro23// recording keeps the rules added.2425func init() {26 hasFormats := func(m *Model) bool { return len(m.sheet.CondFormats()) > 0 }27 hasValidations := func(m *Model) bool { return len(m.sheet.Validations()) > 0 }28 register(29 &command{id: "format.conditional", macro: macroView, title: "Conditional formatting",30 desc: "Color cells by their values: rules on ranges, and color scales",31 run: func(m *Model) tea.Cmd {32 m.openOverlay(rules.Formats(m.host()))33 return nil34 }},35 &command{id: "format.conditional_add", title: "Add conditional format rule",36 desc: `Add a rule written as a line of the file, e.g. {"ranges":"B2:B9","condition":"gt","values":["100"],"fill":"green"}`,37 run: func(m *Model) tea.Cmd {38 m.openText("Rule:", "", func(m *Model, text string) tea.Cmd {39 f, err := sheet.ParseCondFormat(text)40 if err == nil {41 err = m.host().SaveFormat(-1, f)42 }43 m.failOn(err)44 return nil45 })46 return nil47 }},48 &command{id: "format.conditional_clear", title: "Clear conditional formats", enabled: hasFormats,49 desc: "Take the selected cells out of every conditional format rule",50 run: func(m *Model) tea.Cmd {51 m.sheet.ClearCondFormats(m.selection())52 m.changed = true53 return nil54 }},55 &command{id: "data.validation", macro: macroView, title: "Data validation",56 desc: "Rules on what cells may hold: dropdowns, checkboxes, numbers, dates, formulas",57 run: func(m *Model) tea.Cmd {58 m.openOverlay(rules.Validations(m.host()))59 return nil60 }},61 &command{id: "data.validation_add", title: "Add data validation rule",62 desc: `Add a rule written as a line of the file, e.g. {"ranges":"D2:D9","criteria":"list","items":["Yes","No"]}`,63 run: func(m *Model) tea.Cmd {64 m.openText("Rule:", "", func(m *Model, text string) tea.Cmd {65 v, err := sheet.ParseValidation(text)66 if err == nil {67 err = m.host().SaveValidation(-1, v)68 }69 m.failOn(err)70 return nil71 })72 return nil73 }},74 &command{id: "data.validation_clear", title: "Remove data validation", enabled: hasValidations,75 desc: "Take the selected cells out of every data validation rule",76 run: func(m *Model) tea.Cmd {77 m.sheet.ClearValidations(m.selection())78 m.changed = true79 return nil80 }},81 &command{id: "insert.checkbox", title: "Checkbox", edits: (*Model).selection, changes: noCells,82 desc: "Make the selected cells checkboxes: TRUE or FALSE, toggled with Space or a click",83 run: func(m *Model) tea.Cmd {84 m.failOn(m.host().SaveValidation(-1, sheet.Validation{Ranges: []sheet.Rect{m.selection()}, Kind: sheet.ValidCheckbox}))85 return nil86 }},87 &command{id: "insert.dropdown", macro: macroView, title: "Dropdown", edits: (*Model).selection, changes: noCells,88 desc: "Give the selected cells a list of items to pick from",89 run: func(m *Model) tea.Cmd {90 m.openOverlay(rules.Validations(m.host()).Add(true))91 return nil92 }},93 &command{id: "data.dropdown", macro: macroView, title: "Open dropdown",94 desc: "Pick the active cell's value from its dropdown list, or filter its column",95 enabled: func(m *Model) bool { return m.onDropdown() || commands["data.filter_column"].available(m) },96 run: func(m *Model) tea.Cmd {97 if !m.onDropdown() {98 return m.runCommand("data.filter_column")99 }100 if m.refuseEdit(sheet.Rect{From: m.cur, To: m.cur}, false) {101 return nil // a pivot's result or a spilled value102 }103 m.openDropdown()104 return nil105 }},106 &command{id: "data.checkbox_toggle", title: "Toggle checkbox", typed: true, edits: (*Model).selection,107 desc: "Check or uncheck the selected checkboxes",108 enabled: func(m *Model) bool { return m.sheet.HasRules() && m.sheet.Look(m.cur).Checkbox },109 run: (*Model).toggleCheckboxes},110 )111 keymap["alt+down"] = "data.dropdown"112 keymap["space"] = "data.checkbox_toggle"113}114115// failOn shows err, if any, as ERROR mode does.116func (m *Model) failOn(err error) {117 if err != nil {118 m.fail(err.Error())119 }120}121122// onDropdown reports whether the active cell has a dropdown list.123func (m *Model) onDropdown() bool {124 return m.sheet.HasRules() && m.sheet.Look(m.cur).Dropdown125}126127// openDropdown opens the active cell's list in a picker under the cell;128// picking an item enters it, as typing it would.129func (m *Model) openDropdown() {130 a := m.cur131 items := m.sheet.DropdownItems(a)132 current := m.sheet.ShownText(a)133 var list []picker.Item134 sel := 0135 for _, it := range items {136 pi := picker.Item{Title: it, Name: len(it), Desc: "Enter " + it + " in " + a.String()}137 pi.Pick = func() tea.Cmd {138 m.pickItem(a, it)139 return nil140 }141 if it == current {142 sel = len(list)143 pi.Detail = "✓"144 }145 list = append(list, pi)146 }147 if len(list) == 0 {148 m.note = "The dropdown of " + a.String() + " has no items"149 return150 }151 p := m.newPicker(a.String(), "Search the items", 40, list)152 p.Action = "choose"153 p.Sel = sel154 if y, ok := m.rowY(a.Row); ok {155 p.At = &[2]int{m.colStart(a.Col), y + 1}156 }157 m.openOverlay(p)158}159160// pickItem enters a dropdown's item in the cell at a.161func (m *Model) pickItem(a sheet.Addr, item string) {162 m.closeOverlay()163 if m.askProtected(sheet.Rect{From: a, To: a}, func(m *Model) tea.Cmd {164 m.pickItem(a, item)165 return nil166 }) {167 return168 }169 if err := m.sheet.Set(a, item); err != nil {170 m.note = err.Error()171 return172 }173 m.changed = true174 m.recordEntry(item, false)175}176177// toggleCheckboxes checks the selected checkboxes, or unchecks them when178// the active cell is checked, as Sheets' Space does. Blank checkboxes179// are unchecked, so unchecking leaves them blank. Whole columns or rows180// selected are cut to the data, so a million blank checkboxes aren't181// written.182func (m *Model) toggleCheckboxes() tea.Cmd {183 s := m.sheet184 on := !s.Look(m.cur).Checked185 targets := []sheet.Addr{m.cur}186 r := m.selection()187 if used, ok := s.UsedRange(); r.AllRows() || r.AllCols() {188 if !ok {189 used = sheet.Rect{From: m.cur, To: m.cur}190 }191 r = intersect(r, used)192 }193 if m.hasRange() {194 for _, v := range s.Validations() {195 if v.Kind != sheet.ValidCheckbox {196 continue197 }198 for _, vr := range v.Ranges {199 targets = appendCells(targets, intersect(vr, r), m.cur, func(a sheet.Addr) bool { return on || s.Filled(a) })200 }201 }202 }203 label := "uncheck " + r.String()204 if on {205 label = "check " + r.String()206 }207 err := s.Batch(sheet.Change{Label: label, Focus: r, Sheet: s}, func() error {208 for _, a := range targets {209 if err := s.Set(a, s.CheckboxInput(a, on)); err != nil {210 return err211 }212 }213 return nil214 })215 if err != nil {216 m.note = err.Error()217 }218 m.changed = true219 return nil220}221222// appendCells appends the cells of r that keep keeps to out, but for223// skip; an empty r (From past To) adds nothing.224func appendCells(out []sheet.Addr, r sheet.Rect, skip sheet.Addr, keep func(sheet.Addr) bool) []sheet.Addr {225 for row := r.From.Row; row <= r.To.Row; row++ {226 for col := r.From.Col; col <= r.To.Col; col++ {227 if a := (sheet.Addr{Col: col, Row: row}); a != skip && keep(a) {228 out = append(out, a)229 }230 }231 }232 return out233}234235// intersect is the cells a and b share, From past To when none.236func intersect(a, b sheet.Rect) sheet.Rect {237 return sheet.Rect{238 From: sheet.Addr{Col: max(a.From.Col, b.From.Col), Row: max(a.From.Row, b.From.Row)},239 To: sheet.Addr{Col: min(a.To.Col, b.To.Col), Row: min(a.To.Row, b.To.Row)},240 }241}242243// checkEntry checks an entry against the cell's validation: a rule that244// rejects it keeps the entry open with its help text, and one that245// warns lets it in, saying so. It reports whether the entry may go in.246func (m *Model) checkEntry(input string) (warn string, ok bool) {247 bad := m.entrySheet().CheckEntry(m.cur, input)248 switch {249 case bad == nil:250 return "", true251 case bad.Reject:252 m.entryError(bad, input)253 return "", false254 }255 return bad.Error(), true256}257258// writeChecked runs a paste or fill (what), which returns the range it259// wrote, and checks what it wrote against the cells' validation, as260// Sheets does: when a rule that rejects fails, the whole change is261// taken back and ERROR mode says why; when only rules that warn fail,262// it stays, the cells are marked, and the context line says how many.263// Inside a macro's run, taking it back leaves the rest of the run as it264// is (sheet.Workbook.Try). It reports the range and whether the change265// stayed.266func (m *Model) writeChecked(what string, write func() (sheet.Rect, error)) (sheet.Rect, bool) {267 s := m.sheet268 var r sheet.Rect269 var bad []*sheet.InvalidEntry270 var refused *sheet.InvalidEntry271 kept, err := m.book().Try(func() error {272 var err error273 r, err = write()274 return err275 }, func() bool {276 if !s.HasRules() {277 return false278 }279 bad = s.InvalidIn(r)280 for _, b := range bad {281 if b.Reject {282 refused = b283 return true284 }285 }286 return false287 })288 switch {289 case err != nil:290 m.fail(err.Error())291 return r, false292 case !kept:293 m.fail(what + " undone: " + refused.Error())294 return r, false295 case len(bad) > 0:296 m.warn = invalidNote(bad)297 }298 return r, true299}300301// checkEntryFill handles the cells an entry filled (Ctrl+Enter) that fail302// their validation: a rule that rejects takes the fill back and keeps303// the entry open with the rule's help, as for one cell; rules that warn304// let it in, saying so. It reports whether the entry stays open.305func (m *Model) checkEntryFill(bad []*sheet.InvalidEntry, input string) bool {306 for _, b := range bad {307 if b.Reject {308 m.entrySheet().Discard()309 m.entryError(b, input)310 return true311 }312 }313 if len(bad) > 0 {314 m.warn = invalidNote(bad)315 }316 return false317}318319// invalidNote says how many cells a paste or fill left invalid, and why320// the first is.321func invalidNote(bad []*sheet.InvalidEntry) string {322 return fmt.Sprintf("%s invalid, first %s: %s", cellCount(len(bad)), bad[0].Addr, bad[0].Help)323}324325// The rules panel's host.326327func (h host) Sheet() *sheet.Sheet { return h.m.sheet }328func (h host) Selection() sheet.Rect { return h.m.selection() }329func (h host) Slot(i int) color.Color { return h.m.slotColor(i) }330func (h host) refused(rs []sheet.Rect) bool {331 for _, r := range rs {332 if h.m.sheet.InPivot(r) {333 return true334 }335 }336 return false337}338339// SaveFormat adds or replaces a conditional format; an added one is340// recorded as the command that adds it.341func (h host) SaveFormat(i int, f sheet.CondFormat) error {342 m := h.m343 var err error344 if i < 0 {345 err = m.sheet.AddCondFormat(f)346 } else {347 err = m.sheet.SetCondFormat(i, f)348 }349 if err == nil {350 m.changed = true351 if i < 0 {352 m.recordRule("format.conditional_add", f.JSON())353 } else {354 m.recordRule("format.conditional_set", numbered(i, f.JSON()))355 }356 }357 return err358}359360// Reworked follows a rule removed or moved in the panel, recorded as the361// command id that does the same, answered with answer.362func (h host) Reworked(id, answer string) {363 h.m.changed = true364 h.m.recordRule(id, answer)365}366367// noteUnrecorded notes in a recording the last undo step, a change no368// command replays, as the named ranges picker makes.369func (m *Model) noteUnrecorded() {370 if r := m.rec; r != nil && r.depth == 0 && r.pending == "" {371 r.flush(m)372 r.actions = append(r.actions, macro.Note("Not recorded: "+m.book().UndoLabel()))373 r.acted = true374 }375}376377// SaveValidation adds or replaces a validation rule, refusing one over a378// pivot table's results, whose cells take no entries; an added one is379// recorded as the command that adds it.380func (h host) SaveValidation(i int, v sheet.Validation) error {381 m := h.m382 if h.refused(v.Ranges) {383 return errors.New("Data validation can't go on a pivot table's results")384 }385 var err error386 if i < 0 {387 err = m.sheet.AddValidation(v)388 } else {389 err = m.sheet.SetValidation(i, v)390 }391 if err == nil {392 m.changed = true393 if i < 0 {394 m.recordRule("data.validation_add", v.JSON())395 } else {396 m.recordRule("data.validation_set", numbered(i, v.JSON()))397 }398 }399 return err400}401402// recordRule records a change the panel made to rules as the command403// that makes it, answered with a rule's line or number (rulemacro.go). A404// command that changes one itself is recorded where it runs.405func (m *Model) recordRule(id, answer string) {406 if m.rec == nil || m.rec.depth > 0 || m.rec.pending != "" {407 return408 }409 m.rec.flush(m)410 m.rec.add(m, macro.Call("run", id).With("answer", macro.JSON(answer)))411}