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 / review / review.go
review.go 8.4K
1// Package review is live mode's suggestions panel (docs/agents/live.md):2// a box at the right of the grid listing the agents' suggestions waiting3// for the person, each by agent and message with the cells it would4// set, to accept or reject whole or cell by cell, with keys or the5// mouse. The grid beside it shows the cell of the row highlighted. It6// knows the UI only through Host.7package review89import (10 "fmt"11 "strings"1213 tea "charm.land/bubbletea/v2"14 "github.com/charmbracelet/x/ansi"1516 "src.rest/cat/012/internal/sheet"17 "src.rest/cat/012/internal/ui/overlay"18 "src.rest/cat/012/internal/ui/theme"19)2021// Host is what the panel needs of the UI.22type Host interface {23 Theme() *theme.Theme24 Size() (width, height int)25 // Close closes the open overlay.26 Close()27 // Suggestions are those waiting, oldest first.28 Suggestions() []Item29 // Accept and Reject settle the cells of suggestion id picked, by30 // their index in it; nil is all it has waiting.31 Accept(id int, cells []int)32 Reject(id int, cells []int)33 // Reveal puts the pointer on a cell a suggestion sets.34 Reveal(sheet string, at sheet.Addr)35}3637// Item is a suggestion as the panel lists it.38type Item struct {39 ID int40 Agent string41 Color int42 Label string43 Message string44 Whole bool // it can only be accepted whole45 Other []string // what it does besides setting cells46 Cells []Cell // the cells still waiting47 Total int // the cells it sets, waiting or not48 Failed string // why accepting it failed49}5051// Cell is one cell a suggestion sets.52type Cell struct {53 Index int // in the suggestion54 Sheet string55 At sheet.Addr56 Was, Now string57 Look bool // its format, style or note change too58}5960// ID identifies the panel's box in mouse events.61const ID = "review"6263// row is a line of the list: a suggestion's heading (cell -1), one of64// its cells, or what else it does (other).65type row struct {66 item int67 cell int68 other string69}7071// Panel is the open suggestions panel.72type Panel struct {73 h Host74 sel, top int75 items []Item76 rows []row77 box overlay.Box78}7980// New opens the panel on the first suggestion.81func New(h Host) *Panel {82 p := &Panel{h: h}83 p.refresh()84 p.show()85 return p86}8788func (p *Panel) Indicator() string { return "REVIEW" }8990// refresh lists the suggestions as the host has them.91func (p *Panel) refresh() {92 p.items = p.h.Suggestions()93 p.rows = p.rows[:0]94 for i, it := range p.items {95 p.rows = append(p.rows, row{item: i, cell: -1})96 for _, o := range it.Other {97 p.rows = append(p.rows, row{item: i, cell: -1, other: o})98 }99 for j := range it.Cells {100 p.rows = append(p.rows, row{item: i, cell: j})101 }102 }103 p.sel = min(max(p.sel, 0), max(len(p.rows)-1, 0))104 for p.sel > 0 && p.rows[p.sel].other != "" {105 p.sel--106 }107}108109// show puts the grid's pointer on the highlighted row's cell.110func (p *Panel) show() {111 if len(p.rows) == 0 {112 return113 }114 r := p.rows[p.sel]115 it := p.items[r.item]116 c := r.cell117 if c < 0 {118 c = 0119 }120 if c < len(it.Cells) {121 p.h.Reveal(it.Cells[c].Sheet, it.Cells[c].At)122 }123}124125// visible is how many rows fit.126func (p *Panel) visible() int {127 _, height := p.h.Size()128 return max(min(len(p.rows), height-overlay.GridTop-3), 1)129}130131func (p *Panel) Layout() []overlay.Box {132 p.refresh()133 th := p.h.Theme()134 width, _ := p.h.Size()135 inner := max(min(58, width-4), 20)136 n := p.visible()137 p.top = min(max(p.top, p.sel-n+1, 0), p.sel)138 lines := make([]string, n)139 for i := range lines {140 j := p.top + i141 if j >= len(p.rows) {142 lines[i] = strings.Repeat(" ", inner)143 continue144 }145 lines[i] = p.line(th, p.rows[j], inner, j == p.sel)146 }147 if len(p.rows) == 0 {148 lines[0] = theme.Cells(th.Muted, " No suggestions waiting", inner)149 }150 footer := fmt.Sprintf("%d waiting", len(p.items))151 b := th.Frame(inner, "Suggestions", footer, lines)152 p.box = overlay.Box{ID: ID, X: max(width-ansi.StringWidth(b[0]), 0), Y: overlay.GridTop, Lines: b}153 return []overlay.Box{p.box}154}155156// line draws a row in inner columns: a suggestion's agent, message and157// size, or a cell's address and its value then and now, with the158// accept and reject chips at the right of the one highlighted.159func (p *Panel) line(th *theme.Theme, r row, inner int, sel bool) string {160 it := p.items[r.item]161 base := th.MenuBar162 if sel {163 base = th.MenuSelected164 }165 chips := ""166 if sel && r.other == "" {167 chips = " " + th.Chip("✓") + " " + th.Chip("✗")168 }169 room := inner - ansi.StringWidth(chips)170 var text string171 switch {172 case r.other != "":173 return theme.Cells(th.Muted, " also "+r.other, inner)174 case r.cell < 0:175 what := it.Message176 if what == "" {177 what = it.Label178 }179 size := fmt.Sprintf("%d cells", len(it.Cells))180 if it.Whole {181 size = "whole"182 } else if len(it.Cells) == 1 {183 size = "1 cell"184 }185 text = " " + agentMark(it.Agent) + ": " + what + " (" + size + ")"186 if it.Failed != "" {187 text += " ! " + it.Failed188 }189 if !sel {190 return th.Peer[it.Color%theme.Peers].Render(" ◆ ") + theme.Cells(base, strings.TrimPrefix(text, " ◆"), room-3) + chips191 }192 default:193 c := it.Cells[r.cell]194 text = " ◇ " + cellName(it, c) + " " + shown(c.Was) + " → " + shown(c.Now)195 if c.Look && c.Was == c.Now {196 text = " ◇ " + cellName(it, c) + " format"197 }198 }199 return theme.Cells(base, text, room) + chips200}201202// agentMark is one of the agents's name with the mark agents have.203func agentMark(name string) string { return "◆ " + name }204205// cellName is a cell's address, with its sheet when the suggestion sets206// cells on more than one.207func cellName(it Item, c Cell) string {208 for _, o := range it.Cells {209 if o.Sheet != c.Sheet {210 return sheet.Qualified(c.Sheet, sheet.Rect{From: c.At, To: c.At})211 }212 }213 return c.At.String()214}215216// shown is an input as the panel shows it: blank for none.217func shown(in string) string {218 if in == "" {219 return "blank"220 }221 return in222}223224// target is what the highlighted row settles: a suggestion and the225// cells picked, nil for all it has waiting.226func (p *Panel) target() (int, []int, bool) {227 if len(p.rows) == 0 {228 return 0, nil, false229 }230 r := p.rows[p.sel]231 it := p.items[r.item]232 if r.cell < 0 || it.Whole {233 return it.ID, nil, true234 }235 return it.ID, []int{it.Cells[r.cell].Index}, true236}237238func (p *Panel) Key(k tea.KeyPressMsg) tea.Cmd {239 switch k.String() {240 case "up", "k":241 p.move(-1)242 case "down", "j":243 p.move(1)244 case "enter", "a", "y":245 p.settle(true)246 case "r", "n", "delete", "backspace":247 p.settle(false)248 case "A":249 p.all(true)250 case "R":251 p.all(false)252 case "esc", "q":253 p.h.Close()254 }255 return nil256}257258// move highlights the row d away that settles something.259func (p *Panel) move(d int) {260 for i := p.sel + d; i >= 0 && i < len(p.rows); i += d {261 if p.rows[i].other == "" {262 p.sel = i263 break264 }265 }266 p.show()267}268269// settle accepts or rejects what the highlighted row stands for.270func (p *Panel) settle(accept bool) {271 id, cells, ok := p.target()272 if !ok {273 return274 }275 if accept {276 p.h.Accept(id, cells)277 } else {278 p.h.Reject(id, cells)279 }280 p.after()281}282283// all accepts or rejects every suggestion waiting.284func (p *Panel) all(accept bool) {285 for _, it := range p.items {286 if accept {287 p.h.Accept(it.ID, nil)288 } else {289 p.h.Reject(it.ID, nil)290 }291 }292 p.after()293}294295// after lists what's left, closing when nothing is.296func (p *Panel) after() {297 p.refresh()298 if len(p.rows) == 0 {299 p.h.Close()300 return301 }302 p.show()303}304305func (p *Panel) Mouse(e overlay.MouseEvent) tea.Cmd {306 switch {307 case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:308 p.move(-1)309 case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:310 p.move(1)311 case e.Kind != overlay.MousePress:312 case e.Box != ID:313 p.h.Close()314 default:315 p.click(e.Col, e.Row)316 }317 return nil318}319320// click highlights the row clicked, and on its chips accepts or rejects321// it: the chips are the last eight columns inside the frame.322func (p *Panel) click(col, line int) {323 i := p.top + line - 1 // the frame's top border324 if line < 1 || i >= len(p.rows) || p.rows[i].other != "" {325 return326 }327 was := p.sel328 p.sel = i329 inner := p.box.Width() - 2330 switch {331 case i != was:332 p.show()333 case col > inner-4 && col <= inner:334 p.settle(false)335 case col > inner-8 && col <= inner-4:336 p.settle(true)337 default:338 p.show()339 }340}341342func (p *Panel) Status() (string, string) {343 th := p.h.Theme()344 keys := th.KeyHints("Enter", "accept", "R", "reject", "Shift+A", "all", "Esc", "close")345 if len(p.rows) == 0 {346 return "", keys347 }348 it := p.items[p.rows[p.sel].item]349 desc := it.Agent + " suggests: " + it.Label350 if it.Message != "" {351 desc = it.Agent + ": " + it.Message352 }353 return desc, keys354}