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>
| M | .gitignore | 1 | + |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 25 | +- |
| M | internal/ | 38 | ++- |
| A | internal/ | 406 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 152 | +++++++++++ |
| M | internal/ | 124 | +--------- |
| M | internal/ | 4 | + |
| M | internal/ | 30 | ++- |
@@ -129,7 +129,7 @@ func (m *Model) save() tea.Cmd {
129129 // quit exits, confirming first when there are unsaved changes.130130 func (m *Model) quit() tea.Cmd {131131 if !m.changed {132- return tea.Quit132+ return exit()133133 }134134 m.openMenu()135135 m.menu = append(m.menu, menuLevel{items: quitConfirm})@@ -57,7 +57,7 @@ var quitConfirm = []menuItem{
5757 }5858 5959 func init() {60- register(&command{id: "quit.force", title: "Quit without saving", desc: "Close one23, discarding changes", run: func(*Model) tea.Cmd { return tea.Quit }})60+ register(&command{id: "quit.force", title: "Quit without saving", desc: "Close one23, discarding changes", run: func(*Model) tea.Cmd { return exit() }})6161 }6262 6363 func unsavedWarning(desc string) func(m *Model) string {@@ -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:@@ -2,6 +2,7 @@ package ui
22 33 import (44 "os"5+ "reflect"56 "strings"67 "testing"78 "time"@@ -65,18 +66,39 @@ func press(t *testing.T, m *Model, keys ...string) tea.Msg {
6566 return last6667 }6768 68-// send delivers msg and runs resulting commands, returning a QuitMsg if69-// one is produced.69+// send delivers msg and runs resulting commands (including batches and70+// sequences), returning a QuitMsg if one is produced. Timer commands such71+// as autoscroll ticks are not run; tests send those messages directly.7072 func send(m *Model, msg tea.Msg) tea.Msg {7173 _, cmd := m.Update(msg)72- for cmd != nil {73- out := cmd()74- if _, ok := out.(tea.QuitMsg); ok {75- return out74+ return run(m, cmd)75+}76+77+func run(m *Model, cmd tea.Cmd) tea.Msg {78+ if cmd == nil {79+ return nil80+ }81+ out := cmd()82+ if _, ok := out.(tea.QuitMsg); ok {83+ return out84+ }85+ if _, ok := out.(autoscrollMsg); ok {86+ return nil87+ }88+ // Batches and sequences are slices of commands.89+ cmdType := reflect.TypeFor[tea.Cmd]()90+ if v := reflect.ValueOf(out); v.Kind() == reflect.Slice && v.Type().Elem() == cmdType {91+ for i := range v.Len() {92+ if c, ok := v.Index(i).Interface().(tea.Cmd); ok {93+ if q := run(m, c); q != nil {94+ return q95+ }96+ }7697 }77- _, cmd = m.Update(out)98+ return nil7899 }79- return nil100+ _, next := m.Update(out)101+ return run(m, next)80102 }81103 82104 func newModel() *Model {@@ -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+}@@ -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+}@@ -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-}@@ -16,6 +16,8 @@ type theme struct {
1616 header lipgloss.Style // column letters and row numbers1717 headerActive lipgloss.Style // header of the focused row and column1818 headerSel lipgloss.Style // headers of selected rows and columns19+ headerHover lipgloss.Style // a header under the mouse20+ handle lipgloss.Style // a column resize handle being hovered or dragged1921 pointer lipgloss.Style // the cell pointer2022 selection lipgloss.Style // a range being pointed at2123 menuSelected lipgloss.Style // highlighted menu item@@ -42,6 +44,8 @@ func newTheme(dark bool) theme {
4244 header: lipgloss.NewStyle().Background(headerBg).Foreground(headerFg),4345 headerActive: accent.Bold(true),4446 headerSel: lipgloss.NewStyle().Background(lipgloss.Blue).Foreground(selFg),47+ headerHover: lipgloss.NewStyle().Background(headerBg).Foreground(lipgloss.Cyan).Bold(true),48+ handle: lipgloss.NewStyle().Background(headerBg).Foreground(lipgloss.Cyan).Bold(true),4549 pointer: accent,4650 selection: lipgloss.NewStyle().Background(lipgloss.Blue).Foreground(selFg),4751 menuSelected: accent.Bold(true),@@ -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) + " "))