cat / 012
clone

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

cat/module-path
012 / internal / ui / mouse.go
mouse.go 12.7K
1package ui23import (4 "time"56 tea "charm.land/bubbletea/v2"7 "github.com/charmbracelet/x/ansi"89 "src.rest/cat/012/internal/sheet"10)1112// Mouse handling follows Google Sheets. The view asks Bubble Tea for every13// motion event (tea.MouseModeAllMotion), so headers react to hover and the14// terminal's pointer shape (OSC 22) changes over resize handles, cells and15// the formula bar. Clicks work in every mode: clicking a cell while typing16// accepts the entry, or inserts the cell's reference into a formula.1718// mouseState is what the mouse is doing: the drag in progress, what's19// under it, and the last click, to tell double clicks.20type mouseState struct {21 drag dragKind22 lastClick time.Time23 lastHit hit24 hover hit // what's under the mouse, for hover styling25 x, y int // last position, for autoscroll26 autoscrolling bool // an autoscroll tick is pending27 resizeCol int // the column being resized by its header border28 resizeRow int // the row being resized by its number's border29 shape string // the pointer shape last sent to the terminal3031 // A fill handle drag, see fill.go: where it points, and the range it32 // would fill.33 fillAt sheet.Addr34 fillTo sheet.Rect35}3637// hitKind is what's under the mouse.38type hitKind int3940const (41 hitNone hitKind = iota42 hitCorner43 hitColHeader44 hitColBorder // the resize handle at the right edge of a column header45 hitRowHeader46 hitRowBorder // the resize handle at the bottom right of a row number47 hitCell48 hitFormulaBar // the active cell's contents in READY mode49 hitEditLine // the entry being typed50 hitPanel51 hitStatus52 hitFilterButton // the filter mark in a column header53 hitFillHandle // the corner of the selection that drags out a fill54 hitCheckbox // a checkbox's glyph, which a click toggles55 hitDropdown // a dropdown's ▾, which a click opens56 hitTab // a sheet's tab on the status line; addr.Col is its index57 hitTabAdd // the + after the tabs58 hitTabPrev // the ‹ before tabs scrolled off to the left59 hitTabNext // the › after tabs scrolled off to the right60)6162type hit struct {63 kind hitKind64 addr sheet.Addr // the cell; for headers, the column or row65 x int // x within the formula bar or edit line text66}6768// formulaBarAt returns where the active cell's contents are drawn in READY69// mode: the screen line and the x of the first character.70func formulaBarAt() (line, x int) { return formulaLine, formulaBarTextX() }7172// editLineAt returns where the entry being typed is drawn: in place, in the73// formula bar.74func editLineAt() (line, x int) { return formulaLine, formulaBarTextX() }7576// hitTest maps a screen position to what's there.77func (m *Model) hitTest(x, y int) hit {78 switch {79 case y < panelLines:80 if line, tx := editLineAt(); y == line && m.editing() {81 return hit{kind: hitEditLine, x: x - tx}82 }83 if line, tx := formulaBarAt(); y == line && x >= tx && m.mode == modeReady {84 return hit{kind: hitFormulaBar, x: x - tx}85 }86 return hit{kind: hitPanel}87 case y == headerLine && x < m.hdrW():88 return hit{kind: hitCorner}89 case y == headerLine:90 col, start, ok := m.colSpan(x)91 if !ok {92 return hit{}93 }94 kind := hitColHeader95 switch {96 case x == start+m.sheet.ColWidth(col)-1:97 kind = hitColBorder98 case x == m.filterButtonX(col):99 kind = hitFilterButton100 }101 return hit{kind: kind, addr: sheet.Addr{Col: col, Row: m.top}}102 case y >= gridTop && y < gridTop+m.visibleRows():103 return m.gridHit(x, y)104 case y == m.height-1:105 if h, ok := m.tabAt(x); ok {106 return h107 }108 return hit{kind: hitStatus}109 }110 return hit{}111}112113// gridHit is hitTest over the grid's rows: a row's number, the resize114// handle at its corner, or a cell.115func (m *Model) gridHit(x, y int) hit {116 row, ok := m.rowAt(y)117 if !ok {118 return hit{}119 }120 if x < m.hdrW() {121 kind := hitRowHeader122 if b, _ := m.bandOf(row); m.out == nil && x == m.hdrW()-1 && y == b.end()-1 && b.shown == b.lines {123 kind = hitRowBorder124 }125 return hit{kind: kind, addr: sheet.Addr{Col: m.left, Row: row}}126 }127 col, start, ok := m.colSpan(x)128 if !ok {129 return hit{}130 }131 a := sheet.Addr{Col: col, Row: row}132 if m.mode == modeReady && m.out == nil && a == m.fillCorner() && x == start+m.sheet.ColWidth(col)-1 {133 return hit{kind: hitFillHandle, addr: a}134 }135 return hit{kind: m.ruleHit(a, x, start), addr: a}136}137138// dragAnchor is where the drag in progress started.139func (m *Model) dragAnchor() sheet.Addr {140 if m.mouse.drag == dragPoint {141 return m.point.anchor142 }143 return m.cur144}145146// editing reports whether an entry is being typed.147func (m *Model) editing() bool {148 return m.mode == modeEnter || m.mode == modeEdit || m.mode == modePoint149}150151func (m *Model) handlePress(mouse tea.Mouse) tea.Cmd {152 h := m.hitTest(mouse.X, mouse.Y)153 double := h.kind == m.mouse.lastHit.kind && h.addr == m.mouse.lastHit.addr && time.Since(m.mouse.lastClick) < doubleClick154 m.mouse.lastClick, m.mouse.lastHit = time.Now(), h155 // Right clicks open context menus (context.go) before reaching here.156 if mouse.Button == tea.MouseLeft {157 return m.leftPress(h, mouse, double)158 }159 return nil160}161162func (m *Model) leftPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {163 if isTabHit(h.kind) && m.mode != modeMenu && m.mode != modePrompt {164 if m.mode == modeError {165 m.errMsg, m.mode = "", modeReady166 }167 return m.tabPress(h, double)168 }169 switch m.mode {170 case modeError:171 m.errMsg, m.mode = "", modeReady172 return nil173 case modeMenu:174 return nil // overlays take their clicks in shellMouse first175 case modePrompt:176 if m.pointing() && h.kind == hitCell {177 m.point = pointer{at: h.addr, anchor: h.addr}178 m.mouse.drag = dragPoint179 }180 return nil181 case modePoint:182 switch h.kind {183 case hitCell:184 m.point = pointer{at: h.addr, anchor: h.addr}185 m.mouse.drag = dragPoint186 case hitEditLine:187 m.resumeEntry(m.pointRef())188 m.line.SetCaret(h.x)189 }190 return nil191 case modeEnter, modeEdit:192 switch {193 case h.kind == hitEditLine:194 m.line.SetCaret(h.x)195 return nil196 case h.kind == hitCell && m.line.IsFormula() && m.canPoint():197 m.entry.prefix = m.line.Head()198 m.entry.suffix = m.line.Tail()199 m.point = pointer{at: h.addr, anchor: h.addr}200 m.mode, m.mouse.drag = modePoint, dragPoint201 return nil202 case h.kind == hitNone, h.kind == hitPanel, h.kind == hitStatus:203 return nil204 }205 // Clicking elsewhere accepts the entry, then acts as in READY:206 // on the entry's sheet, unless the click was on another.207 away := m.away()208 if !m.commit() || away {209 return nil210 }211 m.entry.tabbing = false212 double = false213 }214 return m.readyPress(h, mouse, double)215}216217func (m *Model) readyPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {218 shift := mouse.Mod.Contains(tea.ModShift)219 switch h.kind {220 case hitFormulaBar:221 m.runCommand("edit")222 m.line.SetCaret(h.x)223 case hitCorner:224 m.cur = sheet.Addr{Col: m.left, Row: m.top}225 m.selecting, m.whole, m.ext = true, wholeAll, m.cur226 case hitFillHandle:227 m.startFill()228 case hitFilterButton:229 m.recordFlush() // opened without a command, which records the selection230 m.openFilterPicker(h.addr.Col)231 case hitCheckbox, hitDropdown:232 m.cur = h.addr233 m.clearSelection()234 if h.kind == hitCheckbox {235 return m.runCommand("data.checkbox_toggle")236 }237 return m.runCommand("data.dropdown")238 case hitColBorder:239 if double {240 m.autofit(h.addr.Col)241 return nil242 }243 m.mouse.resizeCol, m.mouse.drag = h.addr.Col, dragResize244 case hitRowBorder:245 if double {246 m.fitRows(h.addr.Row)247 return nil248 }249 m.mouse.resizeRow, m.mouse.drag = h.addr.Row, dragRowResize250 case hitCell:251 switch {252 case shift:253 m.selecting, m.whole, m.ext = true, wholeNone, h.addr254 case double && m.snap(h.addr) == m.cur:255 m.clearSelection()256 return m.runCommand("edit")257 default:258 m.entered, m.cur = h.addr, m.snap(h.addr)259 m.clearSelection()260 }261 m.mouse.drag = dragCells262 case hitColHeader, hitRowHeader:263 whole, drag := wholeCols, dragCols264 if h.kind == hitRowHeader {265 whole, drag = wholeRows, dragRows266 }267 if !shift || m.whole != whole {268 m.cur = h.addr269 }270 m.selecting, m.whole, m.ext = true, whole, h.addr271 m.mouse.drag = drag272 }273 return nil274}275276// isTabHit reports whether k is a part of the tab strip.277func isTabHit(k hitKind) bool {278 return k == hitTab || k == hitTabAdd || k == hitTabPrev || k == hitTabNext279}280281func (m *Model) handleMotion(mouse tea.Mouse) tea.Cmd {282 m.mouse.hover = m.hitTest(mouse.X, mouse.Y)283 m.mouse.x, m.mouse.y = mouse.X, mouse.Y284 var cmd tea.Cmd285 if m.mouse.drag != dragNone && mouse.Button == tea.MouseLeft {286 cmd = m.dragTo(mouse.X, mouse.Y)287 }288 return tea.Batch(cmd, m.pointerShape())289}290291// dragTo extends the drag in progress to (x, y), starting autoscroll when292// the mouse is past the edge of the grid.293func (m *Model) dragTo(x, y int) tea.Cmd {294 switch m.mouse.drag {295 case dragResize:296 m.sheet.SetColWidth(m.mouse.resizeCol, clamp(x-m.colStart(m.mouse.resizeCol)+1, 1, 240))297 m.changed = true298 return nil299 case dragRowResize:300 if b, ok := m.bandOf(m.mouse.resizeRow); ok {301 r := m.mouse.resizeRow302 m.sheet.SetRowHeight(r, r, clamp(y-b.top()+1, 1, sheet.MaxRowHeight))303 m.changed = true304 }305 return nil306 case dragTab:307 return nil // handleMotion tracks the tab under the mouse308 }309 a, dc, dr := m.dragTarget(x, y, m.dragAnchor())310 switch m.mouse.drag {311 case dragPoint:312 m.point.at, m.point.anchored = a, a != m.point.anchor || m.point.anchored313 case dragCells:314 m.selecting, m.ext = true, a315 case dragCols:316 m.ext.Col = a.Col317 case dragRows:318 m.ext.Row = a.Row319 case dragFill:320 m.dragFillTo(a)321 }322 if (dc != 0 || dr != 0) && !m.mouse.autoscrolling {323 m.mouse.autoscrolling = true324 return autoscrollTick()325 }326 return nil327}328329type autoscrollMsg struct{}330331func autoscrollTick() tea.Cmd {332 return tea.Tick(60*time.Millisecond, func(time.Time) tea.Msg { return autoscrollMsg{} })333}334335// handleAutoscroll moves a drag that's past the edge of the grid one more336// step and keeps ticking until the mouse comes back or is released.337func (m *Model) handleAutoscroll() tea.Cmd {338 if m.mouse.drag == dragNone || m.mouse.drag == dragResize || m.mouse.drag == dragRowResize || m.mouse.drag == dragTab {339 m.mouse.autoscrolling = false340 return nil341 }342 _, dc, dr := m.dragTarget(m.mouse.x, m.mouse.y, m.dragAnchor())343 if dc == 0 && dr == 0 {344 m.mouse.autoscrolling = false345 return nil346 }347 step := func(a *sheet.Addr) {348 *a = clampAddr(sheet.Addr{Col: a.Col + dc, Row: m.stepRow(a.Row, dr)})349 }350 switch m.mouse.drag {351 case dragPoint:352 step(&m.point.at)353 m.point.anchored = true354 case dragCells, dragCols, dragRows:355 m.selecting = true356 step(&m.ext)357 case dragFill:358 step(&m.mouse.fillAt)359 m.dragFillTo(m.mouse.fillAt)360 }361 m.scrollTo(*m.focus())362 return autoscrollTick()363}364365func (m *Model) handleRelease() tea.Cmd {366 switch m.mouse.drag {367 case dragFill:368 m.finishFill()369 case dragTab:370 m.dropTab()371 case dragResize:372 m.record(widthAction(m.mouse.resizeCol, m.mouse.resizeCol, m.sheet.ColWidth(m.mouse.resizeCol)))373 case dragRowResize:374 h, _ := m.sheet.RowHeight(m.mouse.resizeRow)375 m.record(heightAction(m.mouse.resizeRow, m.mouse.resizeRow, h))376 }377 m.mouse.drag, m.mouse.autoscrolling = dragNone, false378 if m.selecting && m.whole == wholeNone && m.ext == m.cur {379 m.clearSelection()380 }381 return nil382}383384// pointerShape asks the terminal for the pointer shape that fits what's385// under the mouse (OSC 22, supported by Ghostty, kitty, foot and xterm;386// others ignore it).387func (m *Model) pointerShape() tea.Cmd {388 shape := "default"389 switch {390 case m.mouse.drag == dragResize, m.mouse.hover.kind == hitColBorder:391 shape = "col-resize"392 case m.mouse.drag == dragRowResize, m.mouse.hover.kind == hitRowBorder:393 shape = "row-resize"394 case m.mouse.drag == dragFill, m.mouse.hover.kind == hitFillHandle:395 shape = "crosshair"396 case m.mode == modeReady && m.chartAt(m.mouse.x, m.mouse.y) >= 0:397 shape = "move"398 case m.mouse.hover.kind == hitCell:399 shape = "cell"400 case m.mouse.hover.kind == hitFormulaBar, m.mouse.hover.kind == hitEditLine:401 shape = "text"402 case m.mouse.hover.kind == hitColHeader, m.mouse.hover.kind == hitRowHeader, m.mouse.hover.kind == hitCorner, m.mouse.hover.kind == hitFilterButton,403 m.mouse.hover.kind == hitCheckbox, m.mouse.hover.kind == hitDropdown, isTabHit(m.mouse.hover.kind):404 shape = "pointer"405 }406 if shape == m.mouse.shape {407 return nil408 }409 m.mouse.shape = shape410 return tea.Raw(ansi.SetPointerShape(shape))411}412413// Terminal modes set at startup and undone on exit.414const (415 // shiftEscapeOn asks the terminal to report Shift+click to the program416 // instead of starting its own text selection (XTSHIFTESCAPE), so417 // Shift+click can extend a selection. Ghostty and xterm honor it.418 shiftEscapeOn = "\x1b[>1s"419 shiftEscapeOff = "\x1b[>0s"420)421422// exit restores the pointer shape, Shift+click handling and the other423// terminal modes 012 changed, frees chart images, then quits.424func (m *Model) exit() tea.Cmd {425 return tea.Sequence(tea.Raw(ansi.SetPointerShape("default")+shiftEscapeOff+m.term.release()), tea.Quit)426}