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.6package rules78import (9 "image/color"10 "strconv"1112 tea "charm.land/bubbletea/v2"13 "github.com/charmbracelet/x/ansi"1415 "src.rest/cat/012/internal/sheet"16 "src.rest/cat/012/internal/ui/lineedit"17 "src.rest/cat/012/internal/ui/overlay"18 "src.rest/cat/012/internal/ui/theme"19)2021// Host is what the rules panel needs of the UI.22type 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 // Reworked follows a rule removed or moved: the file is modified,38 // and a macro being recorded records the command id that does the39 // same, answered with answer.40 Reworked(id, answer string)41 // Slot is the terminal's color for an ANSI slot, for color scales.42 Slot(i int) color.Color43}4445// kind is what the panel lists: conditional formats or validation.46type kind interface {47 title() string48 count(s *sheet.Sheet) int49 // item draws rule i of the list in w columns on base.50 item(th *theme.Theme, h Host, i, w int, sel bool) string51 // form opens rule i for editing, or a new rule over sel for -1.52 form(h Host, i int, sel sheet.Rect) form53 remove(s *sheet.Sheet, i int)54 // move moves rule i to to, and reports whether rules have an order.55 move(s *sheet.Sheet, i, to int) bool56 // ordered reports whether the rules' order matters, and listHint57 // says how rules combine, for the status line.58 ordered() bool59 listHint() string60 // command is the id of the command that does action ("remove",61 // "move") to a rule, as a macro records it.62 command(action string) string63}6465// form is a rule being added or edited.66type form interface {67 rows(th *theme.Theme, h Host) []row68 save(h Host) error69 title() string70}7172// Editor is the rules panel.73type Editor struct {74 h Host75 k kind76 f form // the form open, or nil for the list77 sel int // the picked row of the form78 msg string79 list overlay.List // over "Add rule", then the rules80 top int // the form's scroll81}8283// ID identifies the panel's box in mouse events.84const ID = "rules"8586// Formats opens the panel on the sheet's conditional formats.87func Formats(h Host) *Editor { return &Editor{h: h, k: cfKind{}} }8889// Validations opens the panel on the sheet's data validation.90func Validations(h Host) *Editor { return &Editor{h: h, k: dvKind{}} }9192// Add opens the form for a new rule over the selection, as Insert >93// Dropdown and a new rule do; with dropdown, a dropdown list.94func (e *Editor) Add(dropdown bool) *Editor {95 e.openForm(-1)96 if f, ok := e.f.(*dvForm); ok && dropdown {97 f.kind = int(sheet.ValidList)98 }99 return e100}101102func (e *Editor) Indicator() string { return "RULES" }103104func (e *Editor) openForm(i int) {105 e.f, e.msg, e.top = e.k.form(e.h, i, e.h.Selection()), "", 0106 e.sel = -1107 e.step(e.rows(), 1)108 e.enter()109}110111// rows is the open form's rows.112func (e *Editor) rows() []row { return e.f.rows(e.h.Theme(), e.h) }113114// enter puts the picked text row's text in the edit line.115func (e *Editor) enter() {116 if rs := e.rows(); e.sel < len(rs) && rs[e.sel].kind == rowText {117 e.h.Line().Set(*rs[e.sel].text)118 }119}120121// leave keeps what was typed in the picked text row.122func (e *Editor) leave() {123 if rs := e.rows(); e.sel >= 0 && e.sel < len(rs) && rs[e.sel].kind == rowText {124 *rs[e.sel].text = e.h.Line().Text()125 }126}127128// step picks the next row d away that can be picked.129func (e *Editor) step(rs []row, d int) {130 for i := e.sel + d; i >= 0 && i < len(rs); i += d {131 if rs[i].picks() {132 e.sel = i133 return134 }135 }136}137138// pickRow moves the form to row i.139func (e *Editor) pickRow(i int) {140 e.leave()141 e.sel = i142 e.enter()143}144145func (e *Editor) Key(k tea.KeyPressMsg) tea.Cmd {146 if e.f != nil {147 e.formKey(k)148 return nil149 }150 n := e.k.count(e.h.Sheet()) + 1151 switch key := k.String(); key {152 case "esc":153 e.h.Close()154 case "up", "ctrl+p", "shift+tab":155 e.list.Move(-1, n)156 case "down", "ctrl+n", "tab":157 e.list.Move(1, n)158 case "enter", "space":159 e.openForm(e.list.Sel - 1)160 case "a", "+":161 e.openForm(-1)162 case "delete", "backspace", "x", "-":163 if i := e.list.Sel - 1; i >= 0 {164 e.k.remove(e.h.Sheet(), i)165 e.h.Reworked(e.k.command("remove"), strconv.Itoa(i+1))166 e.list.Sel = min(e.list.Sel, e.k.count(e.h.Sheet()))167 }168 case "shift+up", "shift+down":169 e.reorder(key == "shift+down")170 }171 return nil172}173174// reorder moves the highlighted rule up or down the list.175func (e *Editor) reorder(down bool) {176 i := e.list.Sel - 1177 to := i - 1178 if down {179 to = i + 1180 }181 if i < 0 || to < 0 || to >= e.k.count(e.h.Sheet()) {182 return183 }184 if e.k.move(e.h.Sheet(), i, to) {185 e.list.Sel = to + 1186 e.h.Reworked(e.k.command("move"), `{"rule":`+strconv.Itoa(i+1)+`,"to":`+strconv.Itoa(to+1)+`}`)187 }188}189190func (e *Editor) formKey(k tea.KeyPressMsg) {191 rs := e.rows()192 r := rs[e.sel]193 switch key := k.String(); {194 case key == "esc":195 e.f, e.msg = nil, ""196 case key == "enter":197 e.leave()198 if err := e.f.save(e.h); err != nil {199 e.msg = sheet.LocalizeError(err, e.h.Sheet().Locale()).Error()200 return201 }202 e.f, e.msg = nil, ""203 case key == "up" || key == "shift+tab" || key == "ctrl+p":204 e.leave()205 e.step(rs, -1)206 e.enter()207 case key == "down" || key == "tab" || key == "ctrl+n":208 e.leave()209 e.step(rs, 1)210 e.enter()211 case r.kind == rowChoice && (key == "left" || key == "right" || key == "space"):212 r.cycle(map[bool]int{true: -1, false: 1}[key == "left"])213 case r.kind == rowCheck && key == "space":214 *r.on = !*r.on215 case r.kind == rowText:216 e.h.Line().Key(k)217 *r.text = e.h.Line().Text()218 }219}220221func (e *Editor) Mouse(ev overlay.MouseEvent) tea.Cmd {222 if ev.Box != ID {223 if ev.Kind == overlay.MousePress {224 e.h.Close()225 }226 return nil227 }228 up := ev.Kind == overlay.MouseWheel && ev.Button == tea.MouseWheelUp229 down := ev.Kind == overlay.MouseWheel && ev.Button == tea.MouseWheelDown230 press := ev.Kind == overlay.MousePress && ev.Button == tea.MouseLeft231 if e.f == nil {232 n := e.k.count(e.h.Sheet()) + 1233 i := e.list.Top + ev.Row - 1234 switch {235 case up:236 e.list.Move(-1, n)237 case down:238 e.list.Move(1, n)239 case press && i >= 0 && i < n && i == e.list.Sel:240 e.openForm(i - 1)241 case press && i >= 0 && i < n:242 e.list.Sel = i243 }244 return nil245 }246 rs := e.rows()247 i := e.top + ev.Row - 1248 switch {249 case up:250 e.formKey(tea.KeyPressMsg{Code: tea.KeyUp})251 case down:252 e.formKey(tea.KeyPressMsg{Code: tea.KeyDown})253 case !press || i < 0 || i >= len(rs) || !rs[i].picks():254 case i != e.sel:255 e.pickRow(i)256 case rs[i].kind == rowChoice:257 rs[i].cycle(1)258 case rs[i].kind == rowCheck:259 *rs[i].on = !*rs[i].on260 }261 return nil262}263264// box places the panel at the right of the grid.265func (e *Editor) box() (x, y, inner int) {266 width, _ := e.h.Size()267 inner = min(46, width-2)268 return max(width-inner-2, 0), GridTop, inner269}270271// GridTop is the first screen row of the grid, under the control panel272// and the column headers.273const GridTop = 4274275// visible is how many rows of n fit above the status line.276func (e *Editor) visible(n int) int {277 _, height := e.h.Size()278 return max(min(n, height-1-GridTop-2), 1)279}280281func (e *Editor) Layout() []overlay.Box {282 th := e.h.Theme()283 x, y, inner := e.box()284 if e.f == nil {285 return []overlay.Box{{ID: ID, X: x, Y: y, Lines: th.Frame(inner, e.k.title(), e.footer(), e.listLines(inner))}}286 }287 rs := e.rows()288 n := e.visible(len(rs))289 e.top = max(e.sel-n+1, min(e.top, e.sel), 0)290 var lines []string291 for i := e.top; i < min(e.top+n, len(rs)); i++ {292 lines = append(lines, rs[i].draw(th, inner, i == e.sel, e.h.Line().Text()))293 }294 return []overlay.Box{{ID: ID, X: x, Y: y, Lines: th.Frame(inner, e.f.title(), "", lines)}}295}296297func (e *Editor) footer() string {298 n := e.k.count(e.h.Sheet())299 if n == 1 {300 return "1 rule"301 }302 return strconv.Itoa(n) + " rules"303}304305func (e *Editor) listLines(inner int) []string {306 th := e.h.Theme()307 n := e.k.count(e.h.Sheet()) + 1308 e.list.Sel = min(e.list.Sel, n-1)309 rows := e.visible(n)310 e.list.Show(rows)311 var lines []string312 for r := range rows {313 i := e.list.Top + r314 if i >= n {315 break316 }317 base := th.MenuBar318 if i == e.list.Sel {319 base = th.MenuSelected320 }321 if i == 0 {322 lines = append(lines, theme.Cells(base, " + Add rule", inner))323 continue324 }325 lines = append(lines, e.k.item(th, e.h, i-1, inner, i == e.list.Sel))326 }327 return lines328}329330// Cursor is the caret in a text row being typed into.331func (e *Editor) Cursor() (int, int) {332 if e.f == nil {333 return -1, -1334 }335 rs := e.rows()336 if e.sel >= len(rs) || rs[e.sel].kind != rowText {337 return -1, -1338 }339 x, y, _ := e.box()340 return x + 1 + textX() + ansi.StringWidth(e.h.Line().Head()), y + 1 + e.sel - e.top341}342343// Changed keeps what's typed in the picked text row.344func (e *Editor) Changed() {345 if e.f != nil {346 e.leave()347 }348}349350func (e *Editor) Status() (string, string) {351 th := e.h.Theme()352 width, _ := e.h.Size()353 var desc string354 var pairs []string355 switch {356 case e.f == nil && e.list.Sel == 0:357 desc, pairs = "Add a rule over the selection", []string{"Enter", "add", "Esc", "close"}358 case e.f == nil:359 desc, pairs = e.k.listHint(), []string{"Enter", "edit", "Del", "remove", "Esc", "close"}360 if e.k.ordered() {361 pairs = []string{"Enter", "edit", "Del", "remove", "Shift+↑/↓", "move", "Esc", "close"}362 }363 default:364 r := e.rows()[e.sel]365 desc = r.hint366 pairs = []string{"Enter", "save", "Esc", "back"}367 switch r.kind {368 case rowChoice:369 pairs = append([]string{"←/→", "change"}, pairs...)370 case rowCheck:371 pairs = append([]string{"Space", "on/off"}, pairs...)372 }373 }374 if e.msg != "" {375 desc = th.Warning.Render(e.msg)376 } else {377 desc = th.Muted.Render(desc)378 }379 for {380 keys := th.KeyHints(pairs...)381 switch {382 case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= width:383 return desc, keys384 case e.msg == "" && desc != "":385 desc = ""386 case len(pairs) > 4:387 pairs = pairs[2:]388 default:389 return desc, keys390 }391 }392}