1package ui23import (4 "strconv"56 tea "charm.land/bubbletea/v2"78 "src.rest/cat/012/internal/sheet"9 "src.rest/cat/012/internal/ui/picker"10)1112// 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.2021func 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}5051// 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.54func (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}7273// protectedTarget is what a command changes, for protected ranges, and74// false for a command that changes no cells.75func (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}8485// 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.88func (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.ask(question{105 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}117118// 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.121func (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}136137// protectionsPicker lists the sheet's protections, with keys to remove138// them.139type 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}145146func (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}151152// protectionItems lists adding a range and the sheet, then every153// protection.154func 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}181182// current is the index of the protection highlighted, if one is.183func (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}195196func (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}214215func (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}