1package ui23import (4 tea "charm.land/bubbletea/v2"56 "src.rest/cat/012/internal/sheet"7 "src.rest/cat/012/internal/ui/picker"8 "src.rest/cat/012/internal/ui/tabstrip"9)1011// Sheets work as in Google Sheets: Ctrl+PgDn and Ctrl+PgUp (or Alt+Right12// and Alt+Left, for terminals that keep Ctrl+PgUp/PgDn for their own13// tabs) move between sheets, Shift+F11 adds one, and each sheet remembers14// its cursor and scroll position. The tabs sit at the left of the status15// line (tabstrip.go).16//17// While a formula is being typed, switching sheets points into the other18// sheet, as clicking a tab does in Sheets: the entry stays with its cell19// (entry.home), and the pointer inserts references such as Sheet2!A1.20// Enter stores the formula and returns to its sheet.2122func init() {23 register(24 &command{id: "sheet.new", title: "New sheet", desc: "Add an empty sheet after the others", run: func(m *Model) tea.Cmd {25 s, err := m.book().AddSheet("", m.book().Len())26 if err != nil {27 m.fail(err.Error())28 return nil29 }30 m.showSheet(s)31 m.note = "Added " + s.Name()32 return nil33 }},34 &command{id: "sheet.next", macro: macroView, title: "Next sheet", desc: "Show the sheet to the right", enabled: func(m *Model) bool { return m.sheetAt(1) != nil },35 run: func(m *Model) tea.Cmd { return m.stepSheet(1) }},36 &command{id: "sheet.prev", macro: macroView, title: "Previous sheet", desc: "Show the sheet to the left", enabled: func(m *Model) bool { return m.sheetAt(-1) != nil },37 run: func(m *Model) tea.Cmd { return m.stepSheet(-1) }},38 &command{id: "sheet.goto", macro: macroView, title: "Go to sheet", desc: "Pick a sheet by name", run: func(m *Model) tea.Cmd {39 m.openSheetPicker()40 return nil41 }},42 &command{id: "sheet.rename", title: "Rename sheet", desc: "Give the sheet a new name; formulas that use it follow", run: func(m *Model) tea.Cmd {43 m.openRename()44 return nil45 }},46 &command{id: "sheet.duplicate", title: "Duplicate sheet", desc: "Copy the sheet, with its formatting and charts, to a new sheet after it", run: func(m *Model) tea.Cmd {47 cp, err := m.book().DuplicateSheet(m.sheet)48 if err != nil {49 m.fail(err.Error())50 return nil51 }52 m.showSheet(cp)53 m.note = "Duplicated as " + cp.Name()54 return nil55 }},56 &command{id: "sheet.delete", title: "Delete sheet", desc: "Delete the sheet; formulas that use it show #REF!",57 enabled: func(m *Model) bool { return len(m.book().Visible()) > 1 }, run: (*Model).confirmDeleteSheet},58 &command{id: "sheet.move_left", title: "Move sheet left", desc: "Move the sheet's tab one place left", enabled: func(m *Model) bool { return m.sheetAt(-1) != nil },59 run: func(m *Model) tea.Cmd { return m.moveSheet(-1) }},60 &command{id: "sheet.move_right", title: "Move sheet right", desc: "Move the sheet's tab one place right", enabled: func(m *Model) bool { return m.sheetAt(1) != nil },61 run: func(m *Model) tea.Cmd { return m.moveSheet(1) }},62 )63 keymap["shift+f11"] = "sheet.new"64 keymap["ctrl+pgdown"] = "sheet.next"65 keymap["alt+right"] = "sheet.next"66 keymap["ctrl+pgup"] = "sheet.prev"67 keymap["alt+left"] = "sheet.prev"68 keymap["alt+shift+k"] = "sheet.goto"69 keyAliases["alt+K"] = "alt+shift+k"70}7172// tabMenu is the menu of a sheet's tab, as in Sheets.73var tabMenu = []menuItem{74 {cmd: "sheet.rename", title: "Rename"}, {cmd: "sheet.duplicate", title: "Duplicate"}, {cmd: "sheet.delete", title: "Delete"}, {cmd: "sheet.hide"}, sep,75 {cmd: "sheet.move_left", title: "Move left"}, {cmd: "sheet.move_right", title: "Move right"}, sep,76 {cmd: "sheet.new"}, {cmd: "sheet.goto"}, {cmd: "sheet.unhide"},77}7879// sheetAt returns the sheet d tabs from the one shown, skipping hidden80// sheets, or nil past the ends: moving between sheets doesn't wrap81// around, as in Sheets.82func (g *grid) sheetAt(d int) *sheet.Sheet {83 book, step, n := g.book(), 1, d84 if d < 0 {85 step, n = -1, -d86 }87 for i := book.Index(g.sheet) + step; i >= 0 && i < book.Len() && n > 0; i += step {88 if s := book.Sheet(i); !s.Hidden() {89 if n--; n == 0 {90 return s91 }92 }93 }94 return nil95}9697func (m *Model) stepSheet(d int) tea.Cmd {98 if s := m.sheetAt(d); s != nil {99 m.switchTo(s)100 }101 return nil102}103104// moveSheet moves the sheet shown past its visible neighbour d tabs105// away (hidden sheets between keep their order).106func (m *Model) moveSheet(d int) tea.Cmd {107 if s := m.sheetAt(d); s != nil {108 m.book().MoveSheet(m.sheet, m.book().Index(s))109 }110 return nil111}112113// switchTo shows s: plainly in READY, or pointing into it while a formula114// is typed.115func (m *Model) switchTo(s *sheet.Sheet) {116 if m.editing() {117 m.pointInto(s)118 return119 }120 m.showSheet(s)121}122123// showSheet shows s where it was left, remembering where the sheet shown124// now is. The selection doesn't carry over; the copy marker and a trace125// stay with their own sheets.126func (m *Model) showSheet(s *sheet.Sheet) {127 if s == m.sheet || s == nil {128 return129 }130 m.leave()131 m.leftGrid()132 if v := m.nbView(); v != nil {133 v.StopEdit()134 }135 m.sheet = s136 p := m.tabs.Places[s]137 m.cur, m.top, m.left = p.Cur, p.Top, p.Left138 m.clearSelection()139 m.charts.last = -1140 m.book().SetActive(s)141}142143// leave remembers where the sheet shown is, before another is shown.144func (m *Model) leave() {145 if m.tabs.Places == nil {146 m.tabs.Places = map[*sheet.Sheet]tabstrip.Place{}147 }148 cur := m.cur149 if m.away() {150 cur = m.point.at // the entry's cell is on another sheet151 }152 m.tabs.Places[m.sheet] = tabstrip.Place{Cur: cur, Top: m.top, Left: m.left}153}154155// away reports whether an entry is being typed for a cell on another156// sheet than the one shown.157func (m *Model) away() bool { return m.entry.home != nil && m.entry.home != m.sheet }158159// entrySheet is the sheet the entry being typed goes to.160func (m *Model) entrySheet() *sheet.Sheet {161 if m.entry.home != nil {162 return m.entry.home163 }164 return m.sheet165}166167// pointInto shows s while a formula is being typed, pointing at the cell168// last active there so arrows go on from it. An entry that isn't a169// formula ready for a reference is stored first, as a click elsewhere170// would.171func (m *Model) pointInto(s *sheet.Sheet) {172 if s == m.sheet {173 return174 }175 if m.mode != modePoint {176 if !m.line.IsFormula() || !m.canPoint() {177 if m.commit() {178 m.showSheet(s)179 }180 return181 }182 m.entry.prefix = m.line.Head()183 m.entry.suffix = m.line.Tail()184 m.mode = modePoint185 }186 if m.entry.home == nil {187 m.entry.home = m.sheet188 }189 cur := m.cur190 m.leave()191 m.sheet = s192 p := m.tabs.Places[s]193 if s == m.entry.home {194 p.Cur = cur // back home, pointing starts at the entry's cell195 }196 m.top, m.left = p.Top, p.Left197 m.cur = cur198 m.point = pointer{at: p.Cur}199 m.book().SetActive(s)200}201202// returnHome shows the entry's sheet again once the entry is stored or203// cancelled.204func (m *Model) returnHome() {205 home := m.entry.home206 m.entry.home = nil207 if home == nil || home == m.sheet || !home.Live() {208 return209 }210 cur := m.cur211 m.tabs.Places[m.sheet] = tabstrip.Place{Cur: m.point.at, Top: m.top, Left: m.left}212 m.sheet = home213 p := m.tabs.Places[home]214 m.cur, m.top, m.left = cur, p.Top, p.Left215 m.book().SetActive(home)216}217218// pointRef is the reference being pointed at as it goes into the formula:219// with the sheet's name when it's on another sheet than the entry's.220func (m *Model) pointRef() string {221 if m.away() {222 return sheet.Qualified(m.sheet.Name(), m.point.rect())223 }224 return m.point.text()225}226227// afterSheetsChange keeps a sheet on screen after undo, redo or a delete228// took the shown one away, and forgets deleted sheets' places.229func (m *Model) afterSheetsChange(prefer *sheet.Sheet, index int) {230 book := m.book()231 switch {232 case prefer != nil && prefer.Live() && !prefer.Hidden():233 m.showSheet(prefer)234 case !m.sheet.Live():235 s := m.nearVisible(clamp(index, 0, book.Len()-1))236 m.sheet = s // the old sheet is gone: nothing to remember of it237 p := m.tabs.Places[s]238 m.cur, m.top, m.left = p.Cur, p.Top, p.Left239 m.clearSelection()240 m.charts.last = -1241 book.SetActive(s)242 case m.sheet.Hidden():243 m.showSheet(m.nearVisible(book.Index(m.sheet)))244 }245}246247// confirmDeleteSheet deletes the sheet shown, asking first when it has248// contents. Undo brings it back either way.249func (m *Model) confirmDeleteSheet() tea.Cmd {250 s := m.sheet251 del := func(m *Model) tea.Cmd {252 i := m.book().Index(s)253 if err := m.book().DeleteSheet(s); err != nil {254 m.fail(err.Error())255 return nil256 }257 m.afterSheetsChange(nil, i)258 m.note = "Deleted " + s.Name() + "; Ctrl+Z brings it back"259 return nil260 }261 n := s.Len()262 if n == 0 && len(s.Charts()) == 0 {263 return del(m)264 }265 what := "Delete " + s.Name() + " and its " + cellCount(n) + "?"266 if n == 0 {267 what = "Delete " + s.Name() + " and its charts?"268 }269 m.ask(question{msg: what, warn: true, choices: []choice{270 {key: "enter", label: "Delete", run: del},271 {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},272 }})273 return nil274}275276// openRename asks for the sheet's new name on the context line.277func (m *Model) openRename() {278 s := m.sheet279 m.openText("Rename sheet:", s.Name(), func(m *Model, text string) tea.Cmd {280 old := s.Name()281 if err := m.book().RenameSheet(s, text); err != nil {282 m.fail(err.Error())283 return nil284 }285 if old != s.Name() {286 m.note = "Renamed " + old + " to " + s.Name()287 }288 return nil289 })290}291292// openSheetPicker lists the sheets, fzf style, with the one shown293// highlighted.294func (m *Model) openSheetPicker() {295 var items []picker.Item296 sel := 0297 for i, s := range m.visibleSheets() {298 if s == m.sheet {299 sel = i300 }301 detail := "empty"302 if used, ok := s.UsedRange(); ok {303 detail = used.String() + ", " + cellCount(s.Len())304 }305 items = append(items, picker.Item{306 Title: s.Name(), Name: len(s.Name()), Detail: detail, Desc: "Show " + s.Name(),307 Pick: func() tea.Cmd {308 m.closeOverlay()309 m.showSheet(s)310 return nil311 },312 })313 }314 p := m.newPicker("Go to sheet", "Type a sheet name", 60, items)315 p.Action = "show"316 p.Sel = sel317 m.openOverlay(p)318}319320// sheetKey runs the commands that switch sheets while an entry is typed,321// which point into the other sheet (see switchTo).322func (m *Model) sheetKey(key string) bool {323 switch id := keymap[key]; id {324 case "sheet.next", "sheet.prev":325 m.runCommand(id)326 return true327 }328 return false329}