1package ui23import (4 "strings"56 tea "charm.land/bubbletea/v2"78 "src.rest/cat/012/internal/sheet"9)1011// Selection follows Google Sheets: the active cell (cur) stays put while12// an extension corner (ext) moves with Shift+arrows, Shift+click or a13// mouse drag. Clicking a column or row header selects whole columns or14// rows, and the corner selects everything.1516type wholeKind int1718const (19 wholeNone wholeKind = iota20 wholeCols21 wholeRows22 wholeAll23)2425type dragKind int2627const (28 dragNone dragKind = iota29 dragCells30 dragCols31 dragRows32 dragPoint // dragging out a range in POINT mode or a range prompt33 dragResize // dragging a column header border34 dragRowResize // dragging a row number's bottom border35 dragFill // dragging the fill handle36 dragTab // dragging a sheet's tab to move it37)3839// selection returns the selected range; just the active cell when nothing40// is selected. A range takes in the merged cells it touches, as in Sheets.41func (g *grid) selection() sheet.Rect {42 return g.sheet.Grow(g.corners())43}4445// corners is the range between the active cell and the selection's46// moving corner, before merged cells grow it: what a macro records.47func (g *grid) corners() sheet.Rect {48 if !g.selecting {49 return sheet.Rect{From: g.cur, To: g.cur}50 }51 r := sheet.NewRect(g.cur, g.ext)52 switch g.whole {53 case wholeCols:54 r.From.Row, r.To.Row = 0, sheet.MaxRows-155 case wholeRows:56 r.From.Col, r.To.Col = 0, sheet.MaxCols-157 case wholeAll:58 r = sheet.Rect{To: sheet.Addr{Col: sheet.MaxCols - 1, Row: sheet.MaxRows - 1}}59 }60 return r61}6263// snap returns the cell a stands for: the top-left cell of the merge64// holding it, where a merged cell's contents are.65func (g *grid) snap(a sheet.Addr) sheet.Addr {66 if m, ok := g.sheet.MergeAt(a); ok {67 return m.From68 }69 return a70}7172// hasRange reports whether more than one cell is selected: a merged73// cell alone counts as one.74func (g *grid) hasRange() bool {75 r := g.selection()76 if m, ok := g.sheet.MergeAt(g.cur); ok && r == m {77 return false78 }79 return r.From != r.To80}8182func (g *grid) clearSelection() {83 g.selecting, g.whole = false, wholeNone84 g.ext = g.cur85}8687// extendKey reports whether key is a Shift-modified movement key and88// returns the movement without Shift. Shift+Tab is its own movement.89func extendKey(key string) (string, bool) {90 if key == "shift+tab" || !strings.Contains(key, "shift+") {91 return "", false92 }93 base := strings.Replace(key, "shift+", "", 1)94 return base, isMoveKey(base)95}9697// moveKey handles a movement key in READY mode: plain keys move the active98// cell and drop the selection, Shift+keys extend it.99func (g *grid) moveKey(key string) bool {100 if base, ok := extendKey(key); ok {101 if !g.selecting {102 g.selecting, g.ext = true, g.cur103 }104 g.whole = wholeNone105 return g.navigate(base, &g.ext)106 }107 if g.navigate(key, &g.cur) {108 g.entered, g.cur = g.cur, g.snap(g.cur)109 g.clearSelection()110 return true111 }112 return false113}114115// leaveFrom is where a step off the merge m leaves from: the cell the116// active cell entered it by, when a is the active cell and that's in m,117// otherwise a.118func (g *grid) leaveFrom(a *sheet.Addr, m sheet.Rect) sheet.Addr {119 if a == &g.cur && m.Contains(g.entered) {120 return g.entered121 }122 return *a123}124125// pointMoveKey handles movement in POINT mode and range prompts: arrows126// move the pointer and Shift+arrows stretch it into a range, as when127// selecting a range while typing a formula in Sheets.128func (m *Model) pointMoveKey(key string) bool {129 if key == "tab" || key == "shift+tab" {130 return false131 }132 if base, ok := extendKey(key); ok {133 if !m.point.anchored {134 m.point.anchor, m.point.anchored = m.point.at, true135 }136 return m.navigate(base, &m.point.at)137 }138 if m.navigate(key, &m.point.at) {139 m.point.at = m.snap(m.point.at)140 m.point.anchored = false141 return true142 }143 return false144}145146// selectColumns selects the columns spanned by the selection (Ctrl+Space).147func (g *grid) selectColumns() tea.Cmd {148 if !g.selecting {149 g.ext = g.cur150 }151 g.selecting, g.whole = true, wholeCols152 return nil153}154155// selectRows selects the rows spanned by the selection (Shift+Space).156func (g *grid) selectRows() tea.Cmd {157 if !g.selecting {158 g.ext = g.cur159 }160 g.selecting, g.whole = true, wholeRows161 return nil162}163164func (g *grid) selectAll() tea.Cmd {165 used, ok := g.sheet.UsedRange()166 if ok && !(g.selecting && g.whole == wholeNone && g.selection() == used) {167 g.cur, g.ext = used.From, used.To168 g.selecting, g.whole = true, wholeNone169 return nil170 }171 g.cur = sheet.Addr{Col: g.left, Row: g.top}172 g.selecting, g.whole, g.ext = true, wholeAll, g.cur173 return nil174}175176// navigate applies a movement key to a, returning false if it isn't one.177// Keys follow Google Sheets.178func (g *grid) navigate(key string, a *sheet.Addr) bool {179 rows, cols := g.scrollRows(), g.visibleCols(g.left)180 if m, ok := g.sheet.MergeAt(*a); ok { // steps leave a merged cell from its edge181 from := g.leaveFrom(a, m)182 switch key {183 case "up":184 a.Row, a.Col = m.From.Row, from.Col185 case "down":186 a.Row, a.Col = m.To.Row, from.Col187 case "left", "shift+tab":188 a.Col, a.Row = m.From.Col, from.Row189 case "right", "tab":190 a.Col, a.Row = m.To.Col, from.Row191 }192 }193 switch key {194 case "up":195 a.Row = g.stepRow(a.Row, -1)196 case "down":197 a.Row = g.stepRow(a.Row, 1)198 case "left", "shift+tab":199 a.Col--200 case "right", "tab":201 a.Col++202 case "ctrl+up":203 *a = g.sheet.Edge(*a, 0, -1)204 case "ctrl+down":205 *a = g.sheet.Edge(*a, 0, 1)206 case "ctrl+left":207 *a = g.sheet.Edge(*a, -1, 0)208 case "ctrl+right", "end":209 *a = g.sheet.Edge(*a, 1, 0)210 case "pgup":211 a.Row = g.stepRow(a.Row, -rows)212 g.top = g.stepRow(g.top, -rows)213 case "pgdown":214 a.Row = g.stepRow(a.Row, rows)215 g.top = g.stepRow(g.top, rows)216 case "alt+pgup":217 a.Col -= cols218 g.left -= cols219 case "alt+pgdown":220 a.Col += cols221 g.left += cols222 case "home":223 a.Col = 0224 case "ctrl+home":225 *a = sheet.Addr{}226 case "ctrl+end":227 used, _ := g.sheet.UsedRange()228 *a = used.To229 default:230 return false231 }232 *a = clampAddr(*a)233 a.Row = g.visibleRow(a.Row)234 // Moving into the frozen panes by keyboard scrolls the rest back to235 // the start, as in Sheets (Ctrl+Home shows A1 with row 2 under it).236 if fr, fc := g.frozen(); a.Row < fr || a.Col < fc {237 if a.Row < fr {238 g.top = 0239 }240 if a.Col < fc {241 g.left = 0242 }243 }244 g.clampView()245 return true246}247248func isMoveKey(key string) bool {249 switch key {250 case "up", "down", "left", "right", "tab", "shift+tab", "pgup", "pgdown",251 "alt+pgup", "alt+pgdown", "home", "end", "ctrl+home", "ctrl+end",252 "ctrl+up", "ctrl+down", "ctrl+left", "ctrl+right":253 return true254 }255 return false256}257258func (m *Model) openGoto() {259 m.openText("Go to:", m.cur.String(), func(m *Model, text string) tea.Cmd {260 if !m.gotoText(strings.TrimSpace(text)) {261 m.fail("Not a cell, range, named range, table or region: " + text)262 }263 return nil264 })265 m.prompt.indicator = "POINT"266}267268// gotoText goes to a cell, range, named range, table (a region's too,269// by its name or as formulas name it, nu.r1), on any sheet, and reports whether text named270// one. A sheet may lead: Sheet2!A1, 'Q3 plan'!B2:C9, or just Sheet2!.271func (m *Model) gotoText(text string) bool {272 target := m.sheet273 if name, rest := sheet.SplitSheet(text); name != "" {274 if target = m.book().Lookup(name); target == nil {275 m.fail("There's no sheet named " + name)276 return true277 }278 if text = rest; text == "" {279 if !m.refuseHidden(target) {280 m.showSheet(target)281 }282 return true283 }284 }285 r, ok := sheet.ParseRange(text)286 if n, named := m.sheet.LookupName(text); named && !n.Gone() && target == m.sheet {287 r, ok, target = n.Range, true, n.Sheet288 }289 if t, found := m.book().LookupTable(text); !ok && found && t.Shown() && target == m.sheet {290 r, ok, target = t.Range, true, t.Sheet291 }292 if !ok && target == m.sheet {293 r, target, ok = m.regionTable(text)294 }295 if !ok {296 return false297 }298 if m.refuseHidden(target) {299 return true300 }301 m.showSheet(target)302 if r.From == r.To {303 m.clearSelection()304 m.cur = m.snap(r.From)305 return true306 }307 m.selectRect(r)308 return true309}310311// regionTable finds the region formulas name as text (nu.r1), ignoring312// case, and returns its table, header row included, and its sheet. A313// region with no rows yet has no table: its first cell stands for it.314func (m *Model) regionTable(text string) (sheet.Rect, *sheet.Sheet, bool) {315 const prefix = "nu."316 if len(text) <= len(prefix) || !strings.EqualFold(text[:len(prefix)], prefix) {317 return sheet.Rect{}, nil, false318 }319 s, reg, ok := m.book().Region(text[len(prefix):])320 if !ok {321 return sheet.Rect{}, nil, false322 }323 if t, ok := s.RegionTable(reg.Name); ok {324 return t, s, true325 }326 return sheet.Rect{From: reg.At, To: reg.At}, s, true327}