cat / 012
clone

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

Notes and protected ranges in the UI

Dami committed 87f47fe
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>
21 files changed, +641 -15
Minternal/ui/clipboard.go17++
Minternal/ui/commands.go9+
Minternal/ui/context.go4+-
Minternal/ui/context_test.go2+-
Minternal/ui/edit.go12+-
Minternal/ui/entry.go12++
Minternal/ui/fill.go9+
Minternal/ui/menubar.go5+-
Minternal/ui/model.go9+-
Ainternal/ui/notes.go139++++++++++++++++++
Ainternal/ui/notes_test.go62++++++++
Minternal/ui/overlay.go3+
Minternal/ui/panel.go3+
Minternal/ui/pivot.go16++
Minternal/ui/prompt.go6+
Ainternal/ui/protect.go226++++++++++++++++++++++++++++++
Ainternal/ui/protect_test.go108++++++++++++++
Minternal/ui/sort.go3+
Minternal/ui/theme/scheme.go2+-
Minternal/ui/theme/theme.go6+
Minternal/ui/view.go3+
M internal/ui/clipboard.go +17 -0
M internal/ui/commands.go +9 -0
M internal/ui/context.go +3 -1
M internal/ui/context_test.go +1 -1
M internal/ui/edit.go +6 -6
@@ -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
M internal/ui/entry.go +12 -0
M internal/ui/fill.go +9 -0
M internal/ui/menubar.go +3 -2
M internal/ui/model.go +5 -4
A internal/ui/notes.go +139 -0
@@ -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"
A internal/ui/notes_test.go +62 -0
@@ -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+}
M internal/ui/overlay.go +3 -0
M internal/ui/panel.go +3 -0
M internal/ui/pivot.go +16 -0
M internal/ui/prompt.go +6 -0
A internal/ui/protect.go +226 -0
@@ -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+}
A internal/ui/protect_test.go +108 -0
@@ -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+}
M internal/ui/sort.go +3 -0
M internal/ui/theme/scheme.go +1 -1
M internal/ui/theme/theme.go +6 -0
M internal/ui/view.go +3 -0