Insert > Note (Shift+F2) asks for the note on the context line, with Shift+Enter or Alt+Enter for a line break; the cell shows a corner mark, the context line the active cell's note and a box the hovered cell's. Data > Protect sheets and ranges lists protections in a picker; edits that touch one ask on the context line first, through what commands edit (or change, when narrower), typing, pasting text, the fill handle and sorting.
- commit
- 87f47fe1087e543e18bd6a1d21c589e9ee9028ad
- parent
- 1a8430ddee55ab50f4285c23aa4988d0718f0b46
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 17 | ++ |
| M | internal/ | 9 | + |
| M | internal/ | 4 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 12 | +- |
| M | internal/ | 12 | ++ |
| M | internal/ | 9 | + |
| M | internal/ | 5 | +- |
| M | internal/ | 9 | +- |
| A | internal/ | 139 | ++++++++++++++++++ |
| A | internal/ | 62 | ++++++++ |
| M | internal/ | 3 | + |
| M | internal/ | 3 | + |
| M | internal/ | 16 | ++ |
| M | internal/ | 6 | + |
| A | internal/ | 226 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 108 | ++++++++++++++ |
| M | internal/ | 3 | + |
| M | internal/ | 2 | +- |
| M | internal/ | 6 | + |
| M | internal/ | 3 | + |
@@ -117,6 +117,23 @@ func (c *clipboard) clearMark() {
117117 c.marked = false118118 }119119 120+// pasteTextRange is the block pasted text fills from a: as many rows and121+// columns as it has, or a alone for a single value.122+func pasteTextRange(a sheet.Addr, content string) sheet.Rect {123+ content = strings.TrimSuffix(strings.TrimSuffix(content, "\n"), "\r")124+ r := sheet.Rect{From: a, To: a}125+ if !strings.ContainsAny(content, "\t\n") {126+ return r127+ }128+ rows := parseTSV(content)129+ width := 0130+ for _, row := range rows {131+ width = max(width, len(row))132+ }133+ r.To = sheet.Addr{Col: min(a.Col+width-1, sheet.MaxCols-1), Row: min(a.Row+len(rows)-1, sheet.MaxRows-1)}134+ return r135+}136+120137 // pasteText fills a block of cells from pasted text with tabs or line121138 // breaks, such as cells copied from another spreadsheet, and reports122139 // whether it did. Each value is entered as if typed.@@ -34,6 +34,12 @@ type command struct {
3434 // refused, as Sheets refuses (see pivot.go).3535 edits func(m *Model) sheet.Rect3636 37+ // changes, when set, is the range whose cells the command changes,38+ // where that's narrower than edits, and false for none: inserting39+ // rows moves the cells below without changing them. Protected ranges40+ // ask before a change to them (see protect.go).41+ changes func(m *Model) (sheet.Rect, bool)42+3743 // macro says how recording and scripts treat the command; see3844 // macroUse.3945 macro macroUse@@ -171,6 +177,9 @@ func (m *Model) runCommand(id string) tea.Cmd {
171177 if c.edits != nil && m.refusePivot(c.edits(m)) {172178 return nil173179 }180+ if m.askCommand(c) {181+ return nil182+ }174183 if m.rec != nil {175184 return m.recordingCommand(c) // macrorec.go176185 }@@ -19,7 +19,9 @@ var (
1919 {cmd: "insert.row_above", title: "Insert row above"}, {cmd: "insert.row_below", title: "Insert row below"},2020 {cmd: "insert.col_left", title: "Insert column left"}, {cmd: "insert.col_right", title: "Insert column right"}, sep,2121 {cmd: "delete.row", title: "Delete row"}, {cmd: "delete.col", title: "Delete column"}, sep,22- {cmd: "clear"},22+ {cmd: "clear"}, sep,23+ {cmd: "note.edit", title: "Insert note"}, {cmd: "note.delete", title: "Delete notes"},24+ {cmd: "data.protect_range", title: "Protect range"},2325 })2426 columnMenu = concat(clipboardItems, []menuItem{sep,2527 {cmd: "insert.col_left", title: "Insert column left"}, {cmd: "insert.col_right", title: "Insert column right"}, sep,@@ -43,7 +43,7 @@ func TestRightClickCellMenu(t *testing.T) {
4343 if m.selection().String() != "A1:B2" {4444 t.Errorf("right-click inside moved the selection to %s", m.selection())4545 }46- if got := menuLabels(t, m); got != "Cut,Copy,Paste,Paste values only,-,Insert row above,Insert row below,Insert column left,Insert column right,-,Delete row,Delete column,-,Clear" {46+ if got := menuLabels(t, m); got != "Cut,Copy,Paste,Paste values only,-,Insert row above,Insert row below,Insert column left,Insert column right,-,Delete row,Delete column,-,Clear,-,Insert note,Delete notes,Protect range" {4747 t.Errorf("cell menu %s", got)4848 }4949 o := openMenu(t, m)@@ -26,34 +26,34 @@ func init() {
2626 &command{id: "edit.fill_right", title: "Fill right", desc: "Copy the left column of the selection into the columns right of it", edits: (*Model).selection, run: func(m *Model) tea.Cmd {2727 return m.fill(m.sheet.FillRight)2828 }},29- &command{id: "insert.row_above", title: "Insert rows above", desc: "Insert as many rows above the selection as it spans", edits: rowsTarget, run: func(m *Model) tea.Cmd {29+ &command{id: "insert.row_above", title: "Insert rows above", desc: "Insert as many rows above the selection as it spans", edits: rowsTarget, changes: noCells, run: func(m *Model) tea.Cmd {3030 r := m.selection()3131 return m.structural(m.sheet.InsertRows(r.From.Row, r.To.Row-r.From.Row+1))3232 }},33- &command{id: "insert.row_below", title: "Insert rows below", desc: "Insert as many rows below the selection as it spans", edits: rowsBelowTarget, run: func(m *Model) tea.Cmd {33+ &command{id: "insert.row_below", title: "Insert rows below", desc: "Insert as many rows below the selection as it spans", edits: rowsBelowTarget, changes: noCells, run: func(m *Model) tea.Cmd {3434 r := m.selection()3535 if r.To.Row == sheet.MaxRows-1 {3636 return m.structural(sheet.ErrPushedOff)3737 }3838 return m.structural(m.sheet.InsertRows(r.To.Row+1, r.To.Row-r.From.Row+1))3939 }},40- &command{id: "insert.col_left", title: "Insert columns left", desc: "Insert as many columns left of the selection as it spans", edits: colsTarget, run: func(m *Model) tea.Cmd {40+ &command{id: "insert.col_left", title: "Insert columns left", desc: "Insert as many columns left of the selection as it spans", edits: colsTarget, changes: noCells, run: func(m *Model) tea.Cmd {4141 r := m.selection()4242 return m.structural(m.sheet.InsertCols(r.From.Col, r.To.Col-r.From.Col+1))4343 }},44- &command{id: "insert.col_right", title: "Insert columns right", desc: "Insert as many columns right of the selection as it spans", edits: colsRightTarget, run: func(m *Model) tea.Cmd {44+ &command{id: "insert.col_right", title: "Insert columns right", desc: "Insert as many columns right of the selection as it spans", edits: colsRightTarget, changes: noCells, run: func(m *Model) tea.Cmd {4545 r := m.selection()4646 if r.To.Col == sheet.MaxCols-1 {4747 return m.structural(sheet.ErrPushedOff)4848 }4949 return m.structural(m.sheet.InsertCols(r.To.Col+1, r.To.Col-r.From.Col+1))5050 }},51- &command{id: "delete.row", title: "Delete rows", desc: "Delete the selected rows; references to them become #REF!", edits: rowsTarget, run: func(m *Model) tea.Cmd {51+ &command{id: "delete.row", title: "Delete rows", desc: "Delete the selected rows; references to them become #REF!", edits: rowsTarget, changes: selectedRowsTarget, run: func(m *Model) tea.Cmd {5252 r := m.selection()5353 m.sheet.DeleteRows(r.From.Row, r.To.Row-r.From.Row+1)5454 return nil5555 }},56- &command{id: "delete.col", title: "Delete columns", desc: "Delete the selected columns; references to them become #REF!", edits: colsTarget, run: func(m *Model) tea.Cmd {56+ &command{id: "delete.col", title: "Delete columns", desc: "Delete the selected columns; references to them become #REF!", edits: colsTarget, changes: selectedColsTarget, run: func(m *Model) tea.Cmd {5757 r := m.selection()5858 m.sheet.DeleteCols(r.From.Col, r.To.Col-r.From.Col+1)5959 return nil@@ -42,6 +42,12 @@ func (m *Model) startEntry(md mode, text string) {
4242 if m.refusePivot(sheet.Rect{From: m.cur, To: m.cur}) {4343 return4444 }45+ if m.askProtected(sheet.Rect{From: m.cur, To: m.cur}, func(m *Model) tea.Cmd {46+ m.startEntry(md, text)47+ return nil48+ }) {49+ return50+ }4551 m.mode = md4652 m.entry.home = nil4753 m.line.Clear()@@ -253,6 +259,12 @@ func (m *Model) cancelEntry() {
253259 func (m *Model) handlePaste(content string) {254260 switch m.mode {255261 case modeReady:262+ if m.askProtected(pasteTextRange(m.cur, content), func(m *Model) tea.Cmd {263+ m.handlePaste(content)264+ return nil265+ }) {266+ return267+ }256268 m.recordFlush()257269 if m.pasteText(content) {258270 m.record(macro.Call("paste_text", content))@@ -3,6 +3,8 @@ package ui
33 import (44 "log/slog"55 6+ tea "charm.land/bubbletea/v2"7+68 "github.com/FelineStateMachine/012/internal/macro"79 810 "github.com/FelineStateMachine/012/internal/sheet"@@ -76,6 +78,13 @@ func (m *Model) finishFill() {
7678 if dst == src || m.refusePivot(dst) {7779 return7880 }81+ if m.askProtected(dst, func(m *Model) tea.Cmd {82+ m.mouse.fillTo = dst83+ m.finishFill()84+ return nil85+ }) {86+ return87+ }7988 m.recordFlush()8089 span := m.spans.Start("fill", slog.Int("cells", (dst.To.Row-dst.From.Row+1)*(dst.To.Col-dst.From.Col+1)))8190 got, err := m.sheet.FillSeries(src, dst)@@ -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: "note.edit"},7374 }},7475 {title: "Format", accel: 'o', items: []menuItem{7576 {title: "Number", items: []menuItem{@@ -95,7 +96,7 @@ var menuBar = []menuDef{
9596 }}, sep,9697 {cmd: "data.filter"}, {cmd: "data.filter_column"}, {cmd: "data.filter_remove"}, sep,9798 {cmd: "data.pivot"}, {cmd: "data.pivot_edit"}, {cmd: "data.frequency"}, sep,98- {cmd: "data.named_ranges"}, {cmd: "data.define_name"}, sep,99+ {cmd: "data.named_ranges"}, {cmd: "data.define_name"}, {cmd: "data.protect"}, sep,99100 {cmd: "data.precedents"}, {cmd: "data.dependents"}, sep,100101 {title: "Macros", items: macroItems}, sep,101102 {cmd: "jev.refresh"},@@ -83,10 +83,11 @@ type Model struct {
8383 start *string // the file a served session opens first (OpenOnStart); nil once opened8484 recovered string // the recovery file restored into this book, removed once it's saved: recovery.go8585 86- mode mode87- note string // feedback on the last action, e.g. "Undid: clear B3"88- warn string // like note, for something that went wrong, e.g. a macro's error89- errMsg string // the message ERROR mode shows86+ mode mode87+ protectOK bool // an edit to a protected range was agreed to: protect.go88+ note string // feedback on the last action, e.g. "Undid: clear B3"89+ warn string // like note, for something that went wrong, e.g. a macro's error90+ errMsg string // the message ERROR mode shows9091 9192 // Components. Each owns its state and the handling of the input it9293 // takes; Model routes messages to them and composes what they draw.@@ -0,0 +1,139 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+ "charm.land/lipgloss/v2"9+ "github.com/charmbracelet/x/ansi"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/ui/overlay"13+ "github.com/FelineStateMachine/012/internal/ui/theme"14+)15+16+// Notes follow Sheets' Insert > Note (Shift+F2): a cell with a note shows17+// a mark in its top-right corner, the note is on the context line while18+// the cell is active, and in a small box beside the cell while the mouse19+// is over it. The note is typed on the context line, where Shift+Enter20+// or Alt+Enter starts a new line, shown as ↵.21+22+// noteBreak stands for a line break in a note being typed on the context23+// line, which holds one line.24+const noteBreak = "↵"25+26+func init() {27+ register(28+ &command{id: "note.edit", title: "Note", desc: "Add or edit the active cell's note, shown when the cell is active or hovered",29+ edits: cellTarget, run: (*Model).openNote},30+ &command{id: "note.delete", title: "Delete notes", desc: "Delete the notes of the selected cells",31+ edits: (*Model).selection, run: (*Model).deleteNotes},32+ )33+ keymap["shift+f2"] = "note.edit"34+}35+36+// openNote asks for the active cell's note on the context line, starting37+// from the note it has.38+func (m *Model) openNote() tea.Cmd {39+ a := m.cur40+ m.clearSelection()41+ initial := strings.ReplaceAll(m.sheet.Note(a), "\n", noteBreak)42+ m.openText("Note on "+a.String()+":", initial, func(m *Model, text string) tea.Cmd {43+ note := strings.ReplaceAll(text, noteBreak, "\n")44+ had := m.sheet.Note(a) != ""45+ if err := m.sheet.SetNote(a, note); err != nil {46+ m.fail(err.Error())47+ return nil48+ }49+ m.changed = true50+ if strings.TrimSpace(note) == "" && had {51+ m.note = "Deleted the note on " + a.String() + "; Ctrl+Z brings it back"52+ }53+ return nil54+ })55+ m.prompt.indicator = "NOTE"56+ m.prompt.breaks = true57+ m.prompt.fresh = false // typing adds to the note rather than replacing it58+ return nil59+}60+61+// deleteNotes deletes the notes in the selection.62+func (m *Model) deleteNotes() tea.Cmd {63+ r := m.selection()64+ switch n := m.sheet.ClearNotes(r); n {65+ case 0:66+ m.note = "No notes in " + r.String()67+ case 1:68+ m.changed = true69+ m.note = "Deleted 1 note"70+ default:71+ m.changed = true72+ m.note = "Deleted " + strconv.Itoa(n) + " notes"73+ }74+ return nil75+}76+77+// noteLine shows the active cell's note on the context line, its lines78+// run together.79+func (m *Model) noteLine() string {80+ note := m.sheet.Note(m.cur)81+ if note == "" {82+ return ""83+ }84+ return m.th.Key.Render("Note") + " " + strings.ReplaceAll(note, "\n", " "+m.th.Muted.Render(noteBreak)+" ")85+}86+87+// noteMark draws a cell's note mark over the last column of its text,88+// like Sheets' corner triangle: in the cell's own role where it has one89+// (the pointer, the selection), so it reads on any of them.90+func (m *Model) noteMark(text string, a sheet.Addr, base lipgloss.Style, colored bool) string {91+ w := m.sheet.ColWidth(a.Col)92+ if w < 2 {93+ return text94+ }95+ mark := m.th.NoteMark96+ if colored {97+ mark = base98+ }99+ return ansi.Truncate(text, w-1, "") + mark.Render("▝")100+}101+102+// noteMaxRows is how many lines of a note the hover box shows.103+const noteMaxRows = 8104+105+// noteBox is the note of the cell under the mouse, in a box beside the106+// cell, while nothing else is going on.107+func (m *Model) noteBox() (overlay.Box, bool) {108+ h := m.mouse.hover109+ if h.kind != hitCell || m.mode != modeReady || m.mouse.drag != dragNone {110+ return overlay.Box{}, false111+ }112+ note := m.sheet.Note(h.addr)113+ if note == "" {114+ return overlay.Box{}, false115+ }116+ inner := min(32, m.width-2)117+ if inner < 8 {118+ return overlay.Box{}, false119+ }120+ wrapped := strings.Split(ansi.Wrap(note, inner-2, ""), "\n")121+ if len(wrapped) > noteMaxRows {122+ wrapped = wrapped[:noteMaxRows]123+ wrapped[noteMaxRows-1] = ansi.Truncate(wrapped[noteMaxRows-1], inner-3, "") + "…"124+ }125+ rows := make([]string, len(wrapped))126+ for i, line := range wrapped {127+ rows[i] = m.th.MenuBar.Render(theme.PadRight(" "+strings.TrimRight(line, " "), inner))128+ }129+ lines := m.th.Frame(inner, "Note", "", rows)130+ x, y := m.cellPos(h.addr)131+ x += m.sheet.ColWidth(h.addr.Col)132+ if x+inner+2 > m.width { // no room on the right: the left side133+ x = max(x-m.sheet.ColWidth(h.addr.Col)-inner-2, 0)134+ }135+ x, y = m.clampBox(x, y, inner+2, len(lines))136+ return overlay.Box{ID: noteBoxID, X: x, Y: max(y, gridTop), Lines: lines}, true137+}138+139+const noteBoxID = "note"@@ -0,0 +1,62 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+)9+10+func TestNoteEdit(t *testing.T) {11+ m := newModel()12+ press(t, m, "<right>", "12", "<enter>", "<up>", "<shift+f2>")13+ if l := line(m, contextLine); !strings.HasPrefix(l, "Note on B1:") || m.indicator() != "NOTE" {14+ t.Fatalf("prompt %q, %s", l, m.indicator())15+ }16+ press(t, m, "Checked", "<alt+enter>", "twice", "<enter>")17+ if got := m.sheet.Note(addr("B1")); got != "Checked\ntwice" {18+ t.Fatalf("note %q", got)19+ }20+ // The active cell's note is on the context line, and its cell has21+ // the mark in its corner.22+ if l := line(m, contextLine); !strings.Contains(l, "Note Checked ↵ twice") {23+ t.Errorf("context line %q", l)24+ }25+ if row := line(m, gridTop); !strings.Contains(row, "12▝") {26+ t.Errorf("no mark: %q", row)27+ }28+ // Editing starts from the note, line breaks shown as ↵.29+ press(t, m, "<shift+f2>")30+ if l := line(m, contextLine); !strings.Contains(l, "Checked↵twice") {31+ t.Errorf("editing %q", l)32+ }33+ press(t, m, "<esc>")34+ // Clearing the cell keeps the note; undo takes it away.35+ press(t, m, "<delete>")36+ if m.sheet.Note(addr("B1")) == "" {37+ t.Error("Delete removed the note")38+ }39+ press(t, m, "<ctrl+z>", "<ctrl+z>")40+ if m.sheet.Note(addr("B1")) != "" {41+ t.Errorf("undo left %q", m.sheet.Note(addr("B1")))42+ }43+ press(t, m, "<ctrl+y>")44+ m.runCommand("note.delete")45+ if m.sheet.Note(addr("B1")) != "" || !strings.Contains(line(m, contextLine), "Deleted 1 note") {46+ t.Errorf("delete: %q, %q", m.sheet.Note(addr("B1")), line(m, contextLine))47+ }48+}49+50+func TestNoteHover(t *testing.T) {51+ m := newModel()52+ m.sheet.SetNote(addr("B2"), "A long note that wraps over more than one line in its box")53+ send(m, tea.MouseMotionMsg{X: cellX(1), Y: gridTop + 1})54+ s := screen(m)55+ if !strings.Contains(s, "┌─ Note ─") || !strings.Contains(s, "A long note that wraps over") || !strings.Contains(s, "more than one line in its box") {56+ t.Errorf("no hover box:\n%s", s)57+ }58+ send(m, tea.MouseMotionMsg{X: cellX(3), Y: gridTop + 1})59+ if strings.Contains(screen(m), "Note") {60+ t.Errorf("box stays after leaving the cell:\n%s", screen(m))61+ }62+}@@ -54,6 +54,9 @@ func (m *Model) floating() []overlay.Box {
5454 if b, ok := m.entry.assist.box(m); ok {5555 return []overlay.Box{b}5656 }57+ if b, ok := m.noteBox(); ok {58+ return []overlay.Box{b}59+ }5760 return nil5861 }5962 @@ -218,6 +218,9 @@ func (m *Model) contextLineText() string {
218218 if left == "" {219219 left = m.errorLine()220220 }221+ if left == "" {222+ left = m.noteLine()223+ }221224 if left == "" {222225 left = m.recordingLine()223226 }@@ -76,6 +76,22 @@ func colsRightTarget(m *Model) sheet.Rect {
7676 return r7777 }7878 79+// noCells is command.changes for commands that move cells without80+// changing any, such as inserting rows.81+func noCells(*Model) (sheet.Rect, bool) { return sheet.Rect{}, false }82+83+// selectedRowsTarget and selectedColsTarget are the whole rows or84+// columns of the selection, which deleting them changes.85+func selectedRowsTarget(m *Model) (sheet.Rect, bool) {86+ r := m.selection()87+ return sheet.Rect{From: sheet.Addr{Row: r.From.Row}, To: sheet.Addr{Col: sheet.MaxCols - 1, Row: r.To.Row}}, true88+}89+90+func selectedColsTarget(m *Model) (sheet.Rect, bool) {91+ r := m.selection()92+ return sheet.Rect{From: sheet.Addr{Col: r.From.Col}, To: sheet.Addr{Col: r.To.Col, Row: sheet.MaxRows - 1}}, true93+}94+7995 // pasteTarget is where Ctrl+V writes: the selection, and at least the8096 // clip's size from the active cell.8197 func (m *Model) pasteTarget() sheet.Rect {@@ -32,6 +32,7 @@ type prompt struct {
3232 indicator string3333 fresh bool // the default is still showing; typing replaces it3434 secret bool // the answer is shown masked, e.g. an API key35+ breaks bool // Shift+Enter and Alt+Enter type a line break, shown as noteBreak3536 typing bool // range prompts: the user is typing instead of pointing3637 onText func(m *Model, text string) tea.Cmd3738 onRange func(m *Model, r sheet.Rect) tea.Cmd@@ -76,6 +77,9 @@ func (p *prompt) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
7677 p.cancel(m)7778 case key == "enter":7879 return p.accept(m)80+ case p.breaks && (key == "shift+enter" || key == "alt+enter"):81+ p.fresh = false82+ m.line.Insert(noteBreak)7983 case m.pointing():8084 p.pointKey(m, k)8185 case p.kind == promptWidth && (key == "left" || key == "right"):@@ -185,6 +189,8 @@ func (p *prompt) line(m *Model) (left, right string) {
185189 return p.prefix() + m.line.Text(), m.th.KeyHints("Left/Right", "adjust", "Enter", "apply", "Esc", "cancel")186190 case p.secret:187191 return p.prefix() + mask(len(m.line.Buf)), m.th.KeyHints("Enter", "save", "Esc", "cancel")192+ case p.breaks:193+ return p.prefix() + m.line.Text(), m.th.KeyHints("Enter", "save", "Shift+Enter", "new line", "Esc", "cancel")188194 }189195 return p.prefix() + m.line.Text(), m.th.KeyHints("Enter", "apply", "Esc", "cancel")190196 }@@ -0,0 +1,226 @@
1+package ui2+3+import (4+ "strconv"5+6+ tea "charm.land/bubbletea/v2"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+ "github.com/FelineStateMachine/012/internal/ui/picker"10+)11+12+// Protected ranges and sheets follow Sheets' Data > Protect sheets and13+// ranges with "Show a warning when editing this range": an edit that14+// touches one asks first on the context line, Enter to go on and Esc to15+// back out. Commands are guarded through what they edit (command.edits,16+// or command.changes where that's narrower), and so are typing into a17+// cell, pasting text, the fill handle and sorting. Macros that run don't18+// ask, as Sheets' scripts don't. A picker lists the protections, like19+// Sheets' side panel.20+21+func init() {22+ register(23+ &command{id: "data.protect", macro: macroView, title: "Protect sheets and ranges", desc: "List the protected ranges: add, go to or remove them", run: func(m *Model) tea.Cmd {24+ m.openProtections(m.selection())25+ return nil26+ }},27+ &command{id: "data.protect_range", title: "Protect range", desc: "Warn before edits to the selected range",28+ run: func(m *Model) tea.Cmd { return m.protectAsk(m.selection(), false, false) }},29+ &command{id: "data.protect_sheet", title: "Protect sheet", desc: "Warn before any edit to this sheet",30+ run: func(m *Model) tea.Cmd { return m.protectAsk(m.selection(), true, false) }},31+ &command{id: "data.unprotect", title: "Remove protection", desc: "Stop warning before edits to the protected ranges the selection touches",32+ enabled: func(m *Model) bool { _, ok := m.sheet.Protecting(m.selection()); return ok },33+ run: func(m *Model) tea.Cmd {34+ n := m.sheet.UnprotectRange(m.selection())35+ if n > 0 {36+ m.changed = true37+ }38+ switch n {39+ case 0:40+ m.note = "Nothing here is protected"41+ case 1:42+ m.note = "Removed 1 protection; Ctrl+Z brings it back"43+ default:44+ m.note = "Removed " + strconv.Itoa(n) + " protections; Ctrl+Z brings them back"45+ }46+ return nil47+ }},48+ )49+}50+51+// protectAsk asks for a description of the protection on the context52+// line, then protects r, or the sheet; from the picker (back), the picker53+// opens again afterwards.54+func (m *Model) protectAsk(r sheet.Rect, whole, back bool) tea.Cmd {55+ what := r.String()56+ if whole {57+ what = m.sheet.Name()58+ }59+ m.openText("Describe "+what+" (optional):", "", func(m *Model, text string) tea.Cmd {60+ m.sheet.Protect(sheet.Protection{Range: r, Sheet: whole, Desc: text})61+ m.changed = true62+ if back {63+ m.openProtections(r)64+ return nil65+ }66+ m.note = what + " is protected: edits to it ask first"67+ return nil68+ })69+ m.prompt.indicator = "PROTECT"70+ return nil71+}72+73+// protectedTarget is what a command changes, for protected ranges, and74+// false for a command that changes no cells.75+func (m *Model) protectedTarget(c *command) (sheet.Rect, bool) {76+ if c.changes != nil {77+ return c.changes(m)78+ }79+ if c.edits != nil {80+ return c.edits(m), true81+ }82+ return sheet.Rect{}, false83+}84+85+// askProtected reports whether r touches a protected range, or the86+// sheet is protected, and if so asks on the context line whether to go87+// on: Enter runs retry, which then isn't asked again.88+func (m *Model) askProtected(r sheet.Rect, retry func(*Model) tea.Cmd) bool {89+ if m.protectOK || m.macros.run != nil {90+ return false91+ }92+ p, ok := m.sheet.Protecting(r)93+ if !ok {94+ return false95+ }96+ msg := p.Range.String() + " is protected."97+ if p.Sheet {98+ msg = m.sheet.Name() + " is protected."99+ }100+ desc := "This part of the sheet shouldn't be changed by accident"101+ if p.Desc != "" {102+ desc = p.Desc + ": shouldn't be changed by accident"103+ }104+ m.openOverlay(&choiceBar{105+ m: m, msg: msg, warn: true, desc: desc,106+ choices: []choice{107+ {key: "enter", label: "Edit anyway", run: func(m *Model) tea.Cmd {108+ m.protectOK = true109+ defer func() { m.protectOK = false }()110+ return retry(m)111+ }},112+ {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},113+ },114+ })115+ return true116+}117+118+// askCommand asks before running a command that changes a protected119+// range; a command that changes no cells asks only when the whole sheet120+// is protected, as inserting rows into it would.121+func (m *Model) askCommand(c *command) bool {122+ if c.edits == nil && c.changes == nil {123+ return false124+ }125+ retry := func(m *Model) tea.Cmd { return m.runCommand(c.id) }126+ if r, ok := m.protectedTarget(c); ok {127+ return m.askProtected(r, retry)128+ }129+ for _, p := range m.sheet.Protections() {130+ if p.Sheet {131+ return m.askProtected(p.Range, retry)132+ }133+ }134+ return false135+}136+137+// protectionsPicker lists the sheet's protections, with keys to remove138+// them.139+type protectionsPicker struct {140+ m *Model141+ *picker.Picker142+ sel sheet.Rect // the selection when the picker opened, to protect143+ msg string // feedback on the last removal144+}145+146+func (m *Model) openProtections(sel sheet.Rect) {147+ p := m.newPicker("Protected sheets and ranges", "Type to search", 60, protectionItems(m, sel))148+ p.Action = "go to"149+ m.openOverlay(&protectionsPicker{m: m, Picker: p, sel: sel})150+}151+152+// protectionItems lists adding a range and the sheet, then every153+// protection.154+func protectionItems(m *Model, sel sheet.Rect) []picker.Item {155+ addRange, addSheet := "+ Protect a range", "+ Protect the sheet"156+ items := []picker.Item{157+ {Title: addRange, Name: len(addRange), Detail: sel.String(), Desc: "Warn before edits to " + sel.String(),158+ Pick: func() tea.Cmd { m.closeOverlay(); return m.protectAsk(sel, false, true) }},159+ {Title: addSheet, Name: len(addSheet), Detail: m.sheet.Name(), Desc: "Warn before any edit to " + m.sheet.Name(),160+ Pick: func() tea.Cmd { m.closeOverlay(); return m.protectAsk(sel, true, true) }},161+ }162+ for _, p := range m.sheet.Protections() {163+ detail, desc := p.Range.String(), "Go to "+p.Range.String()164+ if p.Sheet {165+ detail, desc = "whole sheet", "Every edit to "+m.sheet.Name()+" asks first"166+ }167+ title := p.Desc168+ if title == "" {169+ title = detail170+ }171+ items = append(items, picker.Item{Title: title, Name: len(title), Detail: detail, Desc: desc, Pick: func() tea.Cmd {172+ m.closeOverlay()173+ if !p.Sheet {174+ m.selectRect(p.Range)175+ }176+ return nil177+ }})178+ }179+ return items180+}181+182+// current is the index of the protection highlighted, if one is.183+func (p *protectionsPicker) current() (int, bool) {184+ shown := p.Shown()185+ if p.Picker.Sel >= len(shown) {186+ return 0, false187+ }188+ for i := range p.Items[2:] {189+ if &p.Items[2+i] == shown[p.Picker.Sel].Item {190+ return i, true191+ }192+ }193+ return 0, false194+}195+196+func (p *protectionsPicker) Key(k tea.KeyPressMsg) tea.Cmd {197+ m := p.m198+ p.msg = ""199+ if k.String() != "ctrl+d" {200+ return p.Picker.Key(k)201+ }202+ if i, ok := p.current(); ok {203+ label := m.sheet.Protections()[i].Label()204+ m.sheet.Unprotect(i)205+ m.changed = true206+ p.msg = "Removed the protection of " + label + "; Ctrl+Z brings it back"207+ p.Items = protectionItems(m, p.sel)208+ sel := p.Picker.Sel209+ p.Changed()210+ p.Picker.Sel = max(min(sel, len(p.Shown())-1), 0)211+ }212+ return nil213+}214+215+func (p *protectionsPicker) Status() (string, string) {216+ m := p.m217+ keys := m.th.KeyHints("Enter", "go to", "Ctrl+D", "remove", "Esc", "close")218+ if _, ok := p.current(); !ok {219+ keys = m.th.KeyHints("Enter", "add", "Esc", "close")220+ }221+ if p.msg != "" {222+ return p.msg, keys223+ }224+ desc, _ := p.Picker.Status()225+ return desc, keys226+}@@ -0,0 +1,108 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+func TestProtectRangeWarns(t *testing.T) {13+ m := newModel()14+ press(t, m, "<shift+down>", "<shift+right>")15+ m.runCommand("data.protect_range")16+ if l := line(m, contextLine); !strings.HasPrefix(l, "Describe A1:B2 (optional):") {17+ t.Fatalf("prompt %q", l)18+ }19+ press(t, m, "Inputs", "<enter>")20+ if ps := m.sheet.Protections(); len(ps) != 1 || ps[0].Desc != "Inputs" || ps[0].Range != rectOf("A1:B2") {21+ t.Fatalf("protections %+v", ps)22+ }23+ // Typing into a protected cell asks first; Esc backs out.24+ press(t, m, "<esc>", "7")25+ if l := line(m, contextLine); !strings.Contains(l, "A1:B2 is protected.") || !strings.Contains(l, "Edit anyway") {26+ t.Fatalf("no warning: %q", l)27+ }28+ if !strings.Contains(line(m, m.height-1), "Inputs") {29+ t.Errorf("status %q", line(m, m.height-1))30+ }31+ press(t, m, "<esc>")32+ if m.mode != modeReady || m.sheet.Value(addr("A1")).Kind != sheet.Empty {33+ t.Fatalf("Esc went on: mode %v, A1 %v", m.mode, m.sheet.Value(addr("A1")))34+ }35+ // Enter goes on with the keystroke that asked.36+ press(t, m, "7", "<enter>")37+ if m.mode != modeEnter || m.line.Text() != "7" {38+ t.Fatalf("after Enter: mode %v, %q", m.mode, m.line.Text())39+ }40+ press(t, m, "<enter>")41+ if m.sheet.Value(addr("A1")).Num != 7 {42+ t.Fatalf("A1 = %v", m.sheet.Value(addr("A1")))43+ }44+ // Commands ask through what they edit; outside, nothing asks.45+ press(t, m, "<up>")46+ m.runCommand("clear")47+ if _, ok := m.overlay.(*choiceBar); !ok {48+ t.Fatal("clear didn't ask")49+ }50+ press(t, m, "<enter>")51+ if m.sheet.Value(addr("A1")).Kind != sheet.Empty {52+ t.Errorf("clear after Enter: %v", m.sheet.Value(addr("A1")))53+ }54+ press(t, m, "<right>", "<right>", "5", "<enter>")55+ if m.sheet.Value(addr("C1")).Num != 5 {56+ t.Errorf("C1 = %v", m.sheet.Value(addr("C1")))57+ }58+ // Inserting rows above moves the range without asking.59+ press(t, m, "<up>")60+ m.runCommand("insert.row_above")61+ if m.overlay != nil || m.sheet.Protections()[0].Range != rectOf("A2:B3") {62+ t.Errorf("insert: overlay %T, %+v", m.overlay, m.sheet.Protections())63+ }64+ // Pasted text into the range asks too.65+ press(t, m, "<left>", "<left>", "<down>")66+ send(m, tea.PasteMsg{Content: "1\t2"})67+ if _, ok := m.overlay.(*choiceBar); !ok {68+ t.Fatal("paste didn't ask")69+ }70+ press(t, m, "<enter>")71+ if m.sheet.Value(addr("B2")).Num != 2 {72+ t.Errorf("B2 = %v", m.sheet.Value(addr("B2")))73+ }74+ m.runCommand("data.unprotect")75+ if len(m.sheet.Protections()) != 0 {76+ t.Errorf("unprotect left %+v", m.sheet.Protections())77+ }78+}79+80+func TestProtectSheetAndPicker(t *testing.T) {81+ m := tallModel()82+ m.Update(teaSize(100, 30))83+ m.runCommand("data.protect")84+ s := screen(m)85+ if !strings.Contains(s, "Protected sheets and ranges") || !strings.Contains(s, "+ Protect the sheet") {86+ t.Fatalf("picker:\n%s", s)87+ }88+ press(t, m, "<down>", "<enter>", "<enter>")89+ if ps := m.sheet.Protections(); len(ps) != 1 || !ps[0].Sheet {90+ t.Fatalf("protections %+v", ps)91+ }92+ // Back in the picker; the sheet's protection is listed.93+ if !strings.Contains(screen(m), "whole sheet") {94+ t.Fatalf("picker after adding:\n%s", screen(m))95+ }96+ press(t, m, "<esc>")97+ // Anything on a protected sheet asks, inserting rows included.98+ m.runCommand("insert.row_above")99+ if _, ok := m.overlay.(*choiceBar); !ok || !strings.Contains(line(m, contextLine), "Sheet1 is protected.") {100+ t.Fatalf("insert didn't ask: %q", line(m, contextLine))101+ }102+ press(t, m, "<esc>")103+ m.runCommand("data.protect")104+ press(t, m, "<down>", "<down>", "<ctrl+d>")105+ if len(m.sheet.Protections()) != 0 || !strings.Contains(screen(m), "Removed the protection of the sheet") {106+ t.Errorf("remove: %+v\n%s", m.sheet.Protections(), screen(m))107+ }108+}@@ -65,6 +65,9 @@ func (m *Model) sort(r sheet.Rect, keys []sheet.SortKey) tea.Cmd {
6565 if m.refusePivot(r) {6666 return nil6767 }68+ if m.askProtected(r, func(m *Model) tea.Cmd { return m.sort(r, keys) }) {69+ return nil70+ }6871 span := m.spans.Start("sort", slog.Int("rows", r.To.Row-r.From.Row+1), slog.Int("cols", r.To.Col-r.From.Col+1), slog.Int("keys", len(keys)))6972 m.sheet.SortRange(r, keys)7073 span.End()@@ -98,7 +98,7 @@ func minContrast(role string) float64 {
9898 case "Disabled":9999 return minDisabled100100 case "Hint", "Muted", "Border", "FrozenLine", "ChartFrame", "ChartAxis", "ChartLabel",101- "Progress", "ProgressTodo", "Copied", "Series", "Match":101+ "Progress", "ProgressTodo", "Copied", "Series", "Match", "NoteMark":102102 return minSecondary103103 }104104 return minText@@ -65,6 +65,9 @@ type Theme struct {
6565 // FilterOn is the filter mark in the header of a column whose filter6666 // hides something (the mark itself also changes, from ▾ to ▼).6767 FilterOn lipgloss.Style68+ // NoteMark is the mark in the top-right corner of a cell with a note,69+ // like Sheets' small triangle.70+ NoteMark lipgloss.Style6871 6972 // Chrome: the menu bar, dropdowns, the palette and dialogs.7073 MenuBar lipgloss.Style // menu bar titles@@ -145,7 +148,9 @@ func New(dark bool) Theme {
145148 selFg, muted, match := lipgloss.Black, lipgloss.BrightBlack, lipgloss.Yellow146149 bar := lipgloss.Cyan147150 filterFg := lipgloss.Yellow151+ noteFg := lipgloss.Yellow148152 if !dark {153+ noteFg = lipgloss.Magenta149154 headerBg, headerFg = lipgloss.White, lipgloss.Black150155 selFg, muted, match = lipgloss.BrightWhite, lipgloss.Black, lipgloss.Blue151156 filterFg = lipgloss.Blue@@ -193,6 +198,7 @@ func New(dark bool) Theme {
193198 TabActive: accent.Bold(true),194199 TabHover: lipgloss.NewStyle().Foreground(bar).Bold(true).Underline(true),195200 FilterOn: lipgloss.NewStyle().Background(headerBg).Foreground(filterFg).Bold(true),201+ NoteMark: lipgloss.NewStyle().Foreground(noteFg),196202 197203 MenuBar: lipgloss.NewStyle(),198204 MenuAccel: lipgloss.NewStyle().Underline(true),@@ -176,6 +176,9 @@ func (m *Model) cellsText(row, first int, spans []rowtext.Span, focus sheet.Addr
176176 m.decorate(&sp, row) // links and error marks, see links.go177177 }178178 text := renderSpan(&m.th, sp, base, colored)179+ if m.sheet.Note(a) != "" {180+ text = m.noteMark(text, a, base, colored)181+ }179182 if m.showFillHandle(a) {180183 w := m.sheet.ColWidth(a.Col)181184 text = ansi.Truncate(text, w-1, "") + base.Render("▟")