cat / 012
clone

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

Make mouse interaction work like Google Sheets

Dami committed 1aedf0d
The view asks for every motion event (MouseModeAllMotion) so headers
highlight under the mouse, and the terminal pointer shape follows what's
underneath via OSC 22: cell, col-resize, text or pointer. XTSHIFTESCAPE
at startup lets Shift+click reach the app in Ghostty and xterm instead
of starting a terminal text selection, and both are restored on quit.

Clicks work in every mode: clicking a cell while typing accepts the
entry, or inserts the cell's reference (drag for a range) when a formula
is waiting for one; clicking the formula bar edits with the caret where
you clicked; clicks dismiss help and errors. Dragging a column header
border resizes it live with the width on the context line, double-click
fits the content, dragging a selection past the grid edge autoscrolls,
and right-click outside the selection moves there first.
commit
1aedf0d9bb52392e650b1e1527c6ffd2aea43d8a
parent
efa91386b904287f935abe23648b5aeebc8264ab
author
Dami <dami.etoile@gmail.com>
10 files changed, +642 -142
M.gitignore1+
Minternal/ui/commands.go2+-
Minternal/ui/menu.go2+-
Minternal/ui/model.go25+-
Minternal/ui/model_test.go38++-
Ainternal/ui/mouse.go406++++++++++++++++++++++++++++++
Ainternal/ui/mouse_test.go152+++++++++++
Minternal/ui/selection.go124+---------
Minternal/ui/theme.go4+
Minternal/ui/view.go30++-
M .gitignore +1 -0
M internal/ui/commands.go +1 -1
M internal/ui/menu.go +1 -1
M internal/ui/model.go +18 -7
@@ -55,9 +55,16 @@ type Model struct {
5555 selecting bool5656 ext sheet.Addr // the moving corner of the selection5757 whole wholeKind58- drag dragKind59- lastClick time.Time60- lastAddr sheet.Addr58+59+ // Mouse: see mouse.go.60+ drag dragKind61+ lastClick time.Time62+ lastHit hit63+ hover hit // what's under the mouse, for hover styling64+ mouseX, mouseY int // last mouse position, for autoscroll65+ autoscrolling bool // an autoscroll tick is pending66+ resizeCol int // column being resized by its header border67+ shape string // pointer shape last sent to the terminal6168 6269 // tabStart remembers where a run of Tab-committed entries began, so6370 // Enter returns to that column on the next row, as in Sheets.
@@ -90,7 +97,9 @@ func New(s *sheet.Sheet, filename string) *Model {
9097 9198 // Init implements tea.Model. It asks the terminal for its background color9299 // so the theme can adapt to light terminals.93-func (m *Model) Init() tea.Cmd { return tea.RequestBackgroundColor }100+func (m *Model) Init() tea.Cmd {101+ return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn))102+}94103 95104 // Update implements tea.Model.96105 func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -107,11 +116,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
107116 case tea.PasteMsg:108117 m.handlePaste(msg.Content)109118 case tea.MouseClickMsg:110- m.handleClick(msg.Mouse())119+ cmd = m.handlePress(msg.Mouse())111120 case tea.MouseMotionMsg:112- m.handleMotion(msg.Mouse())121+ cmd = m.handleMotion(msg.Mouse())113122 case tea.MouseReleaseMsg:114- m.handleRelease()123+ cmd = m.handleRelease()124+ case autoscrollMsg:125+ cmd = m.handleAutoscroll()115126 case tea.MouseWheelMsg:116127 m.handleWheel(msg.Mouse())117128 case savedMsg:
M internal/ui/model_test.go +30 -8
A internal/ui/mouse.go +406 -0
@@ -0,0 +1,406 @@
1+package ui2+3+import (4+ "time"5+6+ tea "charm.land/bubbletea/v2"7+ "github.com/charmbracelet/x/ansi"8+9+ "one23/internal/sheet"10+)11+12+// 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.17+18+// hitKind is what's under the mouse.19+type hitKind int20+21+const (22+ hitNone hitKind = iota23+ hitCorner24+ hitColHeader25+ hitColBorder // the resize handle at the right edge of a column header26+ hitRowHeader27+ hitCell28+ hitFormulaBar // the active cell's contents in READY mode29+ hitEditLine // the entry being typed30+ hitPanel31+ hitStatus32+)33+34+type hit struct {35+ kind hitKind36+ addr sheet.Addr // the cell; for headers, the column or row37+ x int // x within the formula bar or edit line text38+}39+40+// formulaBarAt returns where the active cell's contents are drawn in READY41+// mode: the screen line and the x of the first character.42+func (m *Model) formulaBarAt() (line, x int) { return 0, rowHdrW }43+44+// editLineAt returns where the entry being typed is drawn.45+func (m *Model) editLineAt() (line, x int) { return 1, 0 }46+47+// hitTest maps a screen position to what's there.48+func (m *Model) hitTest(x, y int) hit {49+ rows := m.visibleRows()50+ switch {51+ case y < panelLines:52+ if line, tx := m.editLineAt(); y == line && m.editing() {53+ return hit{kind: hitEditLine, x: x - tx}54+ }55+ if line, tx := m.formulaBarAt(); y == line && x >= tx && m.mode == modeReady {56+ return hit{kind: hitFormulaBar, x: x - tx}57+ }58+ return hit{kind: hitPanel}59+ case y == headerLine && x < rowHdrW:60+ return hit{kind: hitCorner}61+ case y == headerLine:62+ col, start, ok := m.colSpan(x)63+ if !ok {64+ return hit{}65+ }66+ kind := hitColHeader67+ if x == start+m.sheet.ColWidth(col)-1 {68+ kind = hitColBorder69+ }70+ return hit{kind: kind, addr: sheet.Addr{Col: col, Row: m.top}}71+ case y >= gridTop && y < gridTop+rows:72+ row := m.top + y - gridTop73+ if x < rowHdrW {74+ return hit{kind: hitRowHeader, addr: sheet.Addr{Col: m.left, Row: row}}75+ }76+ col, _, ok := m.colSpan(x)77+ if !ok {78+ return hit{}79+ }80+ return hit{kind: hitCell, addr: sheet.Addr{Col: col, Row: row}}81+ case y == m.height-1:82+ return hit{kind: hitStatus}83+ }84+ return hit{}85+}86+87+// colSpan returns the visible column under x and the x where it starts.88+func (m *Model) colSpan(x int) (col, start int, ok bool) {89+ cx := rowHdrW90+ for c := m.left; c < sheet.MaxCols && cx < m.width; c++ {91+ w := m.sheet.ColWidth(c)92+ if x >= cx && x < cx+w {93+ return c, cx, true94+ }95+ cx += w96+ }97+ return 0, 0, false98+}99+100+// colStart returns the screen x where column c starts, which may be off101+// screen.102+func (m *Model) colStart(c int) int {103+ x := rowHdrW104+ if c >= m.left {105+ for k := m.left; k < c; k++ {106+ x += m.sheet.ColWidth(k)107+ }108+ return x109+ }110+ for k := c; k < m.left; k++ {111+ x -= m.sheet.ColWidth(k)112+ }113+ return x114+}115+116+// dragTarget maps a position during a drag to a cell, clamping to the117+// visible grid, and reports which way to autoscroll when it's outside.118+func (m *Model) dragTarget(x, y int) (a sheet.Addr, dc, dr int) {119+ rows, cols := m.visibleRows(), m.visibleCols(m.left)120+ switch {121+ case y < gridTop:122+ dr = -1123+ case y >= gridTop+rows:124+ dr = 1125+ }126+ right := m.colStart(m.left + cols)127+ switch {128+ case x < rowHdrW:129+ dc = -1130+ case x >= right:131+ dc = 1132+ }133+ a.Row = clamp(m.top+y-gridTop, m.top, m.top+rows-1)134+ if col, _, ok := m.colSpan(x); ok && dc == 0 {135+ a.Col = col136+ } else if dc < 0 {137+ a.Col = m.left138+ } else {139+ a.Col = m.left + cols - 1140+ }141+ return clampAddr(a), dc, dr142+}143+144+// editing reports whether an entry is being typed.145+func (m *Model) editing() bool {146+ return m.mode == modeEnter || m.mode == modeEdit || m.mode == modePoint147+}148+149+func (m *Model) handlePress(mouse tea.Mouse) tea.Cmd {150+ h := m.hitTest(mouse.X, mouse.Y)151+ double := h.kind == m.lastHit.kind && h.addr == m.lastHit.addr && time.Since(m.lastClick) < doubleClick152+ m.lastClick, m.lastHit = time.Now(), h153+ switch mouse.Button {154+ case tea.MouseLeft:155+ return m.leftPress(h, mouse, double)156+ case tea.MouseRight:157+ // Right-clicking outside the selection moves there first, as in158+ // Sheets, so a context menu acts on what was clicked.159+ if h.kind == hitCell && m.mode == modeReady && !m.selection().Contains(h.addr) {160+ m.cur = h.addr161+ m.clearSelection()162+ }163+ }164+ return nil165+}166+167+func (m *Model) leftPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {168+ switch m.mode {169+ case modeHelp, modeError:170+ m.errMsg, m.mode = "", modeReady171+ return nil172+ case modeMenu:173+ if h.kind != hitPanel {174+ m.menu, m.mode = nil, modeReady175+ }176+ return nil177+ case modePrompt:178+ if m.pointing() && h.kind == hitCell {179+ m.point = pointer{at: h.addr, anchor: h.addr}180+ m.drag = dragPoint181+ }182+ return nil183+ case modePoint:184+ switch h.kind {185+ case hitCell:186+ m.point = pointer{at: h.addr, anchor: h.addr}187+ m.drag = dragPoint188+ case hitEditLine:189+ m.resumeEntry(m.point.text())190+ m.setCaret(h.x)191+ }192+ return nil193+ case modeEnter, modeEdit:194+ switch {195+ case h.kind == hitEditLine:196+ m.setCaret(h.x)197+ return nil198+ case h.kind == hitCell && m.isFormula() && m.canPoint():199+ m.pointPrefix = string(m.buf[:m.bufPos])200+ m.pointSuffix = string(m.buf[m.bufPos:])201+ m.point = pointer{at: h.addr, anchor: h.addr}202+ m.mode, m.drag = modePoint, dragPoint203+ return nil204+ case h.kind == hitNone, h.kind == hitPanel, h.kind == hitStatus:205+ return nil206+ }207+ // Clicking elsewhere accepts the entry, then acts as in READY.208+ if !m.commit() {209+ return nil210+ }211+ m.tabbing = false212+ double = false213+ }214+ return m.readyPress(h, mouse, double)215+}216+217+func (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.startEdit()222+ m.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 hitColBorder:227+ if double {228+ m.autofit(h.addr.Col)229+ return nil230+ }231+ m.resizeCol, m.drag = h.addr.Col, dragResize232+ case hitCell:233+ switch {234+ case shift:235+ m.selecting, m.whole, m.ext = true, wholeNone, h.addr236+ case double && h.addr == m.cur:237+ m.clearSelection()238+ return m.startEdit()239+ default:240+ m.cur = h.addr241+ m.clearSelection()242+ }243+ m.drag = dragCells244+ case hitColHeader, hitRowHeader:245+ whole, drag := wholeCols, dragCols246+ if h.kind == hitRowHeader {247+ whole, drag = wholeRows, dragRows248+ }249+ if !shift || m.whole != whole {250+ m.cur = h.addr251+ }252+ m.selecting, m.whole, m.ext = true, whole, h.addr253+ m.drag = drag254+ }255+ return nil256+}257+258+// setCaret puts the edit caret at display column x of the entry.259+func (m *Model) setCaret(x int) {260+ w := 0261+ for i, r := range m.buf {262+ if w >= x {263+ m.bufPos = i264+ return265+ }266+ w += ansi.StringWidth(string(r))267+ }268+ m.bufPos = len(m.buf)269+}270+271+func (m *Model) handleMotion(mouse tea.Mouse) tea.Cmd {272+ m.hover = m.hitTest(mouse.X, mouse.Y)273+ m.mouseX, m.mouseY = mouse.X, mouse.Y274+ var cmd tea.Cmd275+ if m.drag != dragNone && mouse.Button == tea.MouseLeft {276+ cmd = m.dragTo(mouse.X, mouse.Y)277+ }278+ return tea.Batch(cmd, m.pointerShape())279+}280+281+// dragTo extends the drag in progress to (x, y), starting autoscroll when282+// the mouse is past the edge of the grid.283+func (m *Model) dragTo(x, y int) tea.Cmd {284+ if m.drag == dragResize {285+ m.sheet.SetColWidth(m.resizeCol, clamp(x-m.colStart(m.resizeCol)+1, 1, 240))286+ m.changed = true287+ return nil288+ }289+ a, dc, dr := m.dragTarget(x, y)290+ switch m.drag {291+ case dragPoint:292+ m.point.at, m.point.anchored = a, a != m.point.anchor || m.point.anchored293+ case dragCells:294+ m.selecting, m.ext = true, a295+ case dragCols:296+ m.ext.Col = a.Col297+ case dragRows:298+ m.ext.Row = a.Row299+ }300+ if (dc != 0 || dr != 0) && !m.autoscrolling {301+ m.autoscrolling = true302+ return autoscrollTick()303+ }304+ return nil305+}306+307+type autoscrollMsg struct{}308+309+func autoscrollTick() tea.Cmd {310+ return tea.Tick(60*time.Millisecond, func(time.Time) tea.Msg { return autoscrollMsg{} })311+}312+313+// handleAutoscroll moves a drag that's past the edge of the grid one more314+// step and keeps ticking until the mouse comes back or is released.315+func (m *Model) handleAutoscroll() tea.Cmd {316+ if m.drag == dragNone || m.drag == dragResize {317+ m.autoscrolling = false318+ return nil319+ }320+ _, dc, dr := m.dragTarget(m.mouseX, m.mouseY)321+ if dc == 0 && dr == 0 {322+ m.autoscrolling = false323+ return nil324+ }325+ step := func(a *sheet.Addr) {326+ *a = clampAddr(sheet.Addr{Col: a.Col + dc, Row: a.Row + dr})327+ }328+ switch m.drag {329+ case dragPoint:330+ step(&m.point.at)331+ m.point.anchored = true332+ case dragCells, dragCols, dragRows:333+ m.selecting = true334+ step(&m.ext)335+ }336+ m.scrollTo(*m.focus())337+ return autoscrollTick()338+}339+340+func (m *Model) handleRelease() tea.Cmd {341+ m.drag, m.autoscrolling = dragNone, false342+ if m.selecting && m.whole == wholeNone && m.ext == m.cur {343+ m.clearSelection()344+ }345+ return nil346+}347+348+// autofit sizes column c to its widest content, as double-clicking a349+// header border does in Sheets. Selected columns fit together.350+func (m *Model) autofit(c int) {351+ cols := []int{c}352+ if r := m.selection(); m.whole == wholeCols && c >= r.From.Col && c <= r.To.Col {353+ cols = cols[:0]354+ for k := r.From.Col; k <= r.To.Col; k++ {355+ cols = append(cols, k)356+ }357+ }358+ widest := map[int]int{}359+ for _, a := range m.sheet.Addrs() {360+ if cell := m.sheet.Cell(a); cell != nil {361+ widest[a.Col] = max(widest[a.Col], ansi.StringWidth(cell.Value.String()))362+ }363+ }364+ for _, k := range cols {365+ // One column of padding each side, and never narrower than the366+ // header letters.367+ m.sheet.SetColWidth(k, clamp(widest[k]+2, len(sheet.ColName(k))+2, 240))368+ }369+ m.changed = true370+}371+372+// pointerShape asks the terminal for the pointer shape that fits what's373+// under the mouse (OSC 22, supported by Ghostty, kitty, foot and xterm;374+// others ignore it).375+func (m *Model) pointerShape() tea.Cmd {376+ shape := "default"377+ switch {378+ case m.drag == dragResize, m.hover.kind == hitColBorder:379+ shape = "col-resize"380+ case m.hover.kind == hitCell:381+ shape = "cell"382+ case m.hover.kind == hitFormulaBar, m.hover.kind == hitEditLine:383+ shape = "text"384+ case m.hover.kind == hitColHeader, m.hover.kind == hitRowHeader, m.hover.kind == hitCorner:385+ shape = "pointer"386+ }387+ if shape == m.shape {388+ return nil389+ }390+ m.shape = shape391+ return tea.Raw(ansi.SetPointerShape(shape))392+}393+394+// Terminal modes set at startup and undone on exit.395+const (396+ // shiftEscapeOn asks the terminal to report Shift+click to the program397+ // instead of starting its own text selection (XTSHIFTESCAPE), so398+ // Shift+click can extend a selection. Ghostty and xterm honor it.399+ shiftEscapeOn = "\x1b[>1s"400+ shiftEscapeOff = "\x1b[>0s"401+)402+403+// exit restores the pointer shape and Shift+click handling, then quits.404+func exit() tea.Cmd {405+ return tea.Sequence(tea.Raw(ansi.SetPointerShape("default")+shiftEscapeOff), tea.Quit)406+}
A internal/ui/mouse_test.go +152 -0
@@ -0,0 +1,152 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+9+ "one23/internal/sheet"10+)11+12+13+func mouseAt(x, y int) tea.Mouse { return tea.Mouse{X: x, Y: y, Button: tea.MouseLeft} }14+15+func TestClickWhileTypingAccepts(t *testing.T) {16+ m := newModel()17+ press(t, m, "hello")18+ click(m, cellX(2), gridTop+3, 0)19+ if input(m, "A1") != "hello" || m.cur != addr("C4") || m.mode != modeReady {20+ t.Errorf("A1 %q cur %v mode %v", input(m, "A1"), m.cur, m.mode)21+ }22+}23+24+func TestClickInsertsReferenceIntoFormula(t *testing.T) {25+ m := newModel()26+ press(t, m, "5", "<enter>", "7", "<enter>", "=")27+ click(m, cellX(0), gridTop, 0)28+ if m.mode != modePoint || line(m, 1) != "=A1" {29+ t.Fatalf("mode %v line %q", m.mode, line(m, 1))30+ }31+ // Dragging makes it a range.32+ send(m, tea.MouseClickMsg(mouseAt(cellX(0), gridTop)))33+ send(m, tea.MouseMotionMsg(mouseAt(cellX(0), gridTop+1)))34+ send(m, tea.MouseReleaseMsg(mouseAt(cellX(0), gridTop+1)))35+ if line(m, 1) != "=A1:A2" {36+ t.Fatalf("line %q", line(m, 1))37+ }38+ press(t, m, "*2", "<enter>")39+ if got := m.sheet.Cell(addr("A3")); got == nil || got.Input != "=A1:A2*2" {40+ t.Errorf("A3 = %+v", got)41+ }42+}43+44+func TestClickFormulaBarEdits(t *testing.T) {45+ m := newModel()46+ press(t, m, "=1+2", "<enter>", "<up>")47+ line0, x0 := m.formulaBarAt()48+ click(m, x0+2, line0, 0)49+ if m.mode != modeEdit || m.bufPos != 2 {50+ t.Fatalf("mode %v caret %d", m.mode, m.bufPos)51+ }52+ // Clicking in the edit line moves the caret.53+ line1, x1 := m.editLineAt()54+ click(m, x1+4, line1, 0)55+ if m.bufPos != 4 {56+ t.Errorf("caret %d", m.bufPos)57+ }58+}59+60+func TestResizeColumnByDragging(t *testing.T) {61+ m := newModel()62+ border := rowHdrW + sheet.DefaultWidth - 163+ send(m, tea.MouseMotionMsg{X: border, Y: headerLine})64+ if m.hover.kind != hitColBorder || !strings.Contains(line(m, headerLine), "▐") {65+ t.Fatalf("hover %v header %q", m.hover.kind, line(m, headerLine))66+ }67+ send(m, tea.MouseClickMsg(mouseAt(border, headerLine)))68+ send(m, tea.MouseMotionMsg(mouseAt(border+5, headerLine)))69+ if w := m.sheet.ColWidth(0); w != sheet.DefaultWidth+5 {70+ t.Errorf("width while dragging %d", w)71+ }72+ if !strings.Contains(line(m, 2), "Column A width 15") {73+ t.Errorf("context line %q", line(m, 2))74+ }75+ send(m, tea.MouseReleaseMsg(mouseAt(border+5, headerLine)))76+ if m.drag != dragNone || !m.changed {77+ t.Error("resize did not finish")78+ }79+}80+81+func TestDoubleClickBorderAutofits(t *testing.T) {82+ m := newModel()83+ press(t, m, "A much longer label", "<enter>")84+ border := rowHdrW + sheet.DefaultWidth - 185+ click(m, border, headerLine, 0)86+ click(m, border, headerLine, 0)87+ if w := m.sheet.ColWidth(0); w != len("A much longer label")+2 {88+ t.Errorf("autofit width %d", w)89+ }90+}91+92+func TestDragAutoscrolls(t *testing.T) {93+ m := newModel()94+ send(m, tea.MouseClickMsg(mouseAt(cellX(0), gridTop)))95+ _, cmd := m.Update(tea.MouseMotionMsg(mouseAt(cellX(0), m.height-1)))96+ if cmd == nil || !m.autoscrolling {97+ t.Fatal("dragging below the grid did not start autoscroll")98+ }99+ bottom := m.ext.Row100+ for range 5 {101+ send(m, autoscrollMsg{})102+ }103+ if m.ext.Row != bottom+5 || m.top == 0 {104+ t.Errorf("ext %v top %d", m.ext, m.top)105+ }106+ send(m, tea.MouseReleaseMsg(mouseAt(cellX(0), m.height-1)))107+ send(m, autoscrollMsg{})108+ if m.autoscrolling {109+ t.Error("autoscroll kept going after release")110+ }111+}112+113+func TestPointerShapes(t *testing.T) {114+ m := newModel()115+ for _, tt := range []struct {116+ x, y int117+ want string118+ }{119+ {cellX(1), gridTop + 1, "cell"},120+ {rowHdrW + sheet.DefaultWidth - 1, headerLine, "col-resize"},121+ {cellX(1), headerLine, "pointer"},122+ {rowHdrW + 1, 0, "text"},123+ {1, m.height - 1, "default"},124+ } {125+ send(m, tea.MouseMotionMsg{X: tt.x, Y: tt.y})126+ if m.shape != tt.want {127+ t.Errorf("at %d,%d shape %q, want %q", tt.x, tt.y, m.shape, tt.want)128+ }129+ }130+}131+132+func TestRightClickMovesOutsideSelection(t *testing.T) {133+ m := newModel()134+ press(t, m, "<shift+down>")135+ send(m, tea.MouseClickMsg{X: cellX(0), Y: gridTop + 1, Button: tea.MouseRight})136+ if !m.hasRange() {137+ t.Error("right-click inside the selection dropped it")138+ }139+ send(m, tea.MouseClickMsg{X: cellX(3), Y: gridTop + 5, Button: tea.MouseRight})140+ if m.cur != addr("D6") || m.hasRange() {141+ t.Errorf("cur %v", m.cur)142+ }143+}144+145+func TestClickDismissesHelp(t *testing.T) {146+ m := newModel()147+ press(t, m, "<f1>")148+ click(m, cellX(1), gridTop, 0)149+ if m.mode != modeReady {150+ t.Errorf("mode %v", m.mode)151+ }152+}
M internal/ui/selection.go +2 -122
@@ -2,7 +2,6 @@ package ui
22 33 import (44 "strings"5- "time"65 76 tea "charm.land/bubbletea/v2"87
@@ -30,7 +29,8 @@ const (
3029 dragCells3130 dragCols3231 dragRows33- dragPoint // dragging out a range in POINT mode or a range prompt32+ dragPoint // dragging out a range in POINT mode or a range prompt33+ dragResize // dragging a column header border3434 )3535 3636 // selection returns the selected range; just the active cell when nothing
@@ -138,123 +138,3 @@ func (m *Model) selectAll() tea.Cmd {
138138 m.selecting, m.whole, m.ext = true, wholeAll, m.cur139139 return nil140140 }141-142-// hit is what's under the mouse.143-type hit struct {144- kind dragKind // dragCells, dragCols or dragRows; dragNone for the corner145- addr sheet.Addr146- ok bool147-}148-149-// hitTest maps a screen position to a cell, header or the corner. Rows and150-// columns past the edge of the grid clamp one step beyond it, so dragging151-// off the grid scrolls.152-func (m *Model) hitTest(x, y int) hit {153- rows := m.visibleRows()154- row := clamp(m.top+y-gridTop, max(m.top-1, 0), min(m.top+rows, sheet.MaxRows-1))155- col := m.colAt(x)156- switch {157- case y == headerLine && x < rowHdrW:158- return hit{kind: dragNone, ok: true}159- case y == headerLine:160- return hit{kind: dragCols, addr: sheet.Addr{Col: col, Row: m.top}, ok: true}161- case y > headerLine && x < rowHdrW:162- return hit{kind: dragRows, addr: sheet.Addr{Col: m.left, Row: row}, ok: true}163- case y > headerLine:164- return hit{kind: dragCells, addr: sheet.Addr{Col: col, Row: row}, ok: true}165- }166- return hit{}167-}168-169-// colAt returns the column under x, clamped one past the visible columns.170-func (m *Model) colAt(x int) int {171- if x < rowHdrW {172- return max(m.left-1, 0)173- }174- cx := rowHdrW175- for c := m.left; c < sheet.MaxCols; c++ {176- cx += m.sheet.ColWidth(c)177- if x < cx {178- return c179- }180- }181- return sheet.MaxCols - 1182-}183-184-func (m *Model) handleClick(mouse tea.Mouse) {185- if mouse.Button != tea.MouseLeft {186- return187- }188- h := m.hitTest(mouse.X, mouse.Y)189- if !h.ok {190- return191- }192- if m.mode == modePoint || m.pointing() {193- if h.kind == dragCells {194- m.point.at, m.point.anchor, m.point.anchored = h.addr, h.addr, false195- m.drag = dragPoint196- }197- return198- }199- if m.mode != modeReady {200- return201- }202- shift := mouse.Mod.Contains(tea.ModShift)203- switch h.kind {204- case dragNone:205- m.cur = sheet.Addr{Col: m.left, Row: m.top}206- m.selecting, m.whole, m.ext = true, wholeAll, m.cur207- return208- case dragCells:209- switch {210- case shift:211- m.selecting, m.whole, m.ext = true, wholeNone, h.addr212- case h.addr == m.lastAddr && time.Since(m.lastClick) < doubleClick:213- m.cur = h.addr214- m.clearSelection()215- m.startEdit()216- return217- default:218- m.cur = h.addr219- m.clearSelection()220- }221- m.lastClick, m.lastAddr = time.Now(), h.addr222- case dragCols, dragRows:223- whole := wholeCols224- if h.kind == dragRows {225- whole = wholeRows226- }227- if !shift || m.whole != whole {228- m.cur = h.addr229- }230- m.selecting, m.whole, m.ext = true, whole, h.addr231- }232- m.drag = h.kind233-}234-235-func (m *Model) handleMotion(mouse tea.Mouse) {236- if m.drag == dragNone || mouse.Button != tea.MouseLeft {237- return238- }239- h := m.hitTest(mouse.X, mouse.Y)240- switch m.drag {241- case dragPoint:242- if h.addr != m.point.anchor {243- m.point.anchored = true244- }245- m.point.at = h.addr246- case dragCells:247- m.selecting, m.ext = true, h.addr248- case dragCols:249- m.ext.Col = m.colAt(mouse.X)250- case dragRows:251- m.ext.Row = h.addr.Row252- }253-}254-255-func (m *Model) handleRelease() {256- m.drag = dragNone257- if m.selecting && m.whole == wholeNone && m.ext == m.cur {258- m.clearSelection()259- }260-}
M internal/ui/theme.go +4 -0
M internal/ui/view.go +27 -3
@@ -32,13 +32,14 @@ func (m *Model) View() tea.View {
3232 3333 v := tea.NewView(strings.Join(lines, "\n"))3434 v.AltScreen = true35- v.MouseMode = tea.MouseModeCellMotion35+ v.MouseMode = tea.MouseModeAllMotion // hover feedback; see mouse.go3636 v.WindowTitle = "one23 - " + m.displayName()3737 if m.changed {3838 v.WindowTitle += " (modified)"3939 }4040 if x, ok := m.cursorX(); ok {41- v.Cursor = tea.NewCursor(x, 1)41+ line, lx := m.editLineAt()42+ v.Cursor = tea.NewCursor(lx+x, line)4243 v.Cursor.Shape = tea.CursorBar4344 }4445 return v
@@ -105,6 +106,9 @@ func (m *Model) promptPrefix() string {
105106 // formula error, or which keys do what.106107 func (m *Model) panelLine3() string {107108 switch {109+ case m.drag == dragResize:110+ return m.th.key.Render("Column "+sheet.ColName(m.resizeCol)) + m.th.muted.Render(" width ") +111+ strconv.Itoa(m.sheet.ColWidth(m.resizeCol)) + m.th.muted.Render(" double-click the border to fit")108112 case m.hint != "":109113 return m.th.warning.Render(m.hint)110114 case m.mode == modeMenu:
@@ -176,18 +180,36 @@ func (m *Model) headerRow() string {
176180 sel, selecting := m.highlight()177181 for i := range m.visibleCols(m.left) {178182 c := m.left + i183+ w := m.sheet.ColWidth(c)179184 style := m.th.header180185 switch {181186 case c == focus.Col:182187 style = m.th.headerActive183188 case selecting && c >= sel.From.Col && c <= sel.To.Col:184189 style = m.th.headerSel190+ case m.hover.addr.Col == c && (m.hover.kind == hitColHeader || m.hover.kind == hitColBorder):191+ style = m.th.headerHover192+ }193+ label := center(sheet.ColName(c), w)194+ if m.showHandle(c) && w > 1 {195+ // Draw the resize handle in the header's last cell.196+ b.WriteString(style.Render(label[:len(label)-1]) + m.th.handle.Render("▐"))197+ continue185198 }186- b.WriteString(style.Render(center(sheet.ColName(c), m.sheet.ColWidth(c))))199+ b.WriteString(style.Render(label))187200 }188201 return b.String()189202 }190203 204+// showHandle reports whether column c's resize handle is visible: while205+// hovering it or dragging it.206+func (m *Model) showHandle(c int) bool {207+ if m.drag == dragResize {208+ return m.resizeCol == c209+ }210+ return m.hover.kind == hitColBorder && m.hover.addr.Col == c211+}212+191213 func (m *Model) gridRow(row int) string {192214 if row >= sheet.MaxRows {193215 return ""
@@ -200,6 +222,8 @@ func (m *Model) gridRow(row int) string {
200222 hdr = m.th.headerActive201223 case selecting && row >= sel.From.Row && row <= sel.To.Row:202224 hdr = m.th.headerSel225+ case m.hover.kind == hitRowHeader && m.hover.addr.Row == row:226+ hdr = m.th.headerHover203227 }204228 var b strings.Builder205229 b.WriteString(hdr.Render(padLeft(strconv.Itoa(row+1), rowHdrW-1) + " "))