diff --git a/README.md b/README.md index 3453a53..4935eae 100644 --- a/README.md +++ b/README.md @@ -226,9 +226,11 @@ division approximation; `SoftLight` uses init-time LUTs replacing `math.Sqrt`. `Key == KeyRune` with `Rune` set for printable input, `Modifiers` bitmask (`ModShift`, `ModAlt`, `ModCtrl`), `Action` (`KeyPress`, and `KeyRepeat` and `KeyRelease` while releases are reported; see below) -- `EventMouse` — 0-indexed `MouseX/Y`, `MouseBtn` (buttons, wheel), - `MouseAction` (press/release/move/drag), modifiers. Enable via - `SetMouseMode(MouseModeClick | MouseModeDrag)`; SGR protocol only. +- `EventMouse` — 0-indexed `MouseX/Y`, `MouseBtn` (the three buttons, the + wheel's four directions, back and forward), `MouseAction` + (press/release/move/drag; a wheel step is a press with no release), + modifiers. Enable via `SetMouseMode(MouseModeClick | MouseModeDrag)`; SGR + protocol only. - `EventResize` — new `Width`/`Height` - `EventPaste` — `Text` holds a whole paste, as sent, control characters included. Enable via `SetPasteMode(true)` (bracketed paste); off, a paste diff --git a/input.go b/input.go index ab110d5..98c3fe1 100644 --- a/input.go +++ b/input.go @@ -623,7 +623,8 @@ func (r *inputReader) parseControl(b byte) Event { } // parseSGRMouse parses mouse SGR sequences, ESC [ < Btn ; X ; Y M/m, from -// the parameters and final byte; a malformed one is dropped as KeyNone +// the parameters and final byte; a malformed one, or a button past 9, is +// dropped as KeyNone func parseSGRMouse(params []byte, final byte) Event { btn, x, y, ok := parseSGRParams(params) if !ok { @@ -632,48 +633,34 @@ func parseSGRMouse(params []byte, final byte) Event { ev := Event{Type: EventMouse, MouseX: x - 1, MouseY: y - 1} // Convert to 0-indexed - // Decode button and action - // Bits 0-1: button (0=left, 1=middle, 2=right, 3=release) - // Bit 5 (32): motion - // Bit 6 (64): scroll - buttonID := btn & 0x03 - isMotion := btn&32 != 0 - isScroll := btn&64 != 0 + // Bits 0-1 number a button within its bank: the main three and none, the + // wheel's four directions (bit 6), or buttons 8 to 11 (bit 7); bit 5 is motion + id := btn & 0x03 + switch btn & (64 | 128) { + case 0: + ev.MouseBtn = [...]MouseButton{MouseBtnLeft, MouseBtnMiddle, MouseBtnRight, MouseBtnNone}[id] + case 64: + ev.MouseBtn = [...]MouseButton{MouseBtnWheelUp, MouseBtnWheelDown, MouseBtnWheelLeft, MouseBtnWheelRight}[id] + case 128: + if id > 1 { + return Event{Type: EventKey, Key: KeyNone} + } + ev.MouseBtn = MouseBtnBack + MouseButton(id) + default: + return Event{Type: EventKey, Key: KeyNone} + } - if isScroll { - // Scroll: buttonID 0=up, 1=down - if buttonID == 0 { - ev.MouseBtn = MouseBtnWheelUp - } else { - ev.MouseBtn = MouseBtnWheelDown - } - ev.MouseAction = MouseActionPress // Scroll is instantaneous - } else { - // Regular button - switch buttonID { - case 0: - ev.MouseBtn = MouseBtnLeft - case 1: - ev.MouseBtn = MouseBtnMiddle - case 2: - ev.MouseBtn = MouseBtnRight - case 3: - ev.MouseBtn = MouseBtnNone // Release with no specific button - } - - if final == 'M' { - if isMotion { - if ev.MouseBtn != MouseBtnNone { - ev.MouseAction = MouseActionDrag - } else { - ev.MouseAction = MouseActionMove - } - } else { - ev.MouseAction = MouseActionPress - } - } else { - ev.MouseAction = MouseActionRelease - } + switch { + case btn&64 != 0: + ev.MouseAction = MouseActionPress // A wheel step is its press + case final == 'm': + ev.MouseAction = MouseActionRelease + case btn&32 == 0: + ev.MouseAction = MouseActionPress + case ev.MouseBtn != MouseBtnNone: + ev.MouseAction = MouseActionDrag + default: + ev.MouseAction = MouseActionMove } // Extract modifiers from button byte diff --git a/mouse.go b/mouse.go index 98c6d42..fd9c16b 100644 --- a/mouse.go +++ b/mouse.go @@ -10,8 +10,10 @@ const ( MouseBtnRight MouseBtnWheelUp MouseBtnWheelDown - MouseBtnBack // Button 4 (if supported) - MouseBtnForward // Button 5 (if supported) + MouseBtnBack // Button 8, a mouse's back side button + MouseBtnForward // Button 9, its forward side button + MouseBtnWheelLeft // A wheel tilted left, or a touchpad swiped so + MouseBtnWheelRight // A wheel tilted right, or a touchpad swiped so ) // MouseAction represents the type of mouse event @@ -52,6 +54,10 @@ func (b MouseButton) String() string { return "Back" case MouseBtnForward: return "Forward" + case MouseBtnWheelLeft: + return "WheelLeft" + case MouseBtnWheelRight: + return "WheelRight" default: return "None" } @@ -71,4 +77,4 @@ func (a MouseAction) String() string { default: return "None" } -} \ No newline at end of file +} diff --git a/terminal_test.go b/terminal_test.go index f85298e..be4edfb 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -228,6 +228,35 @@ func TestNoInputStopsTheReader(t *testing.T) { } } +// A mouse report names its button in each bank: the main three, the wheel's four +// directions, and back and forward among buttons 8 to 11, whose others, which no +// application could tell apart from the main three before, are dropped +func TestMouseReportsNameTheirButtons(t *testing.T) { + at := func(btn MouseButton, action MouseAction, mods Modifier) Event { + return Event{Type: EventMouse, MouseX: 4, MouseY: 2, MouseBtn: btn, MouseAction: action, Modifiers: mods} + } + for _, c := range []struct { + report string + want []Event + }{ + {"\x1b[<20;5;3M", []Event{at(MouseBtnLeft, MouseActionPress, ModShift|ModCtrl)}}, + {"\x1b[<34;5;3M", []Event{at(MouseBtnRight, MouseActionDrag, 0)}}, + {"\x1b[<35;5;3M", []Event{at(MouseBtnNone, MouseActionMove, 0)}}, + {"\x1b[<64;5;3M", []Event{at(MouseBtnWheelUp, MouseActionPress, 0)}}, + {"\x1b[<65;5;3M", []Event{at(MouseBtnWheelDown, MouseActionPress, 0)}}, + {"\x1b[<66;5;3M", []Event{at(MouseBtnWheelLeft, MouseActionPress, 0)}}, + {"\x1b[<75;5;3M", []Event{at(MouseBtnWheelRight, MouseActionPress, ModAlt)}}, + {"\x1b[<128;5;3M\x1b[<128;5;3m", []Event{at(MouseBtnBack, MouseActionPress, 0), at(MouseBtnBack, MouseActionRelease, 0)}}, + {"\x1b[<129;5;3M", []Event{at(MouseBtnForward, MouseActionPress, 0)}}, + {"\x1b[<130;5;3Mx", []Event{key('x')}}, + {"\x1b[<192;5;3Mx", []Event{key('x')}}, + } { + if got, _ := parsed(c.report); !slices.Equal(got, c.want) { + t.Errorf("%q: got %+v, want %+v", c.report, got, c.want) + } + } +} + // Whatever bytes arrive, however reads split them, the reader sends the same // events, holds back no more than ESC and the longest sequence, and holds // nothing once four DEL, which end any sequence or character, and a paste diff --git a/tui/README.md b/tui/README.md index 5100e20..b44ca17 100644 --- a/tui/README.md +++ b/tui/README.md @@ -187,6 +187,28 @@ r.List(items, state.Selection, state.Offset, tui.ListOpts{CursorBg: color.DarkSl r.ScrollBar(r.W-1, state.Offset, state.Visible, state.Total, color.IronGray) ``` +`ScrollThumb` is the thumb a bar draws. `ScrollDrag` drives one by the +pointer, rows counted from the track's top: a press on the thumb takes hold +of it, a press on the track moves the thumb's middle there and takes hold, and +a drag keeps the held row under the pointer, each returning the offset to show. + +```go +var drag tui.ScrollDrag // kept across events +bar := r.Sub(r.W-1, 0, 1, r.H) // the column ScrollBar draws, in screen cells at bar.X, bar.Y + +// per left button event: +switch ev.MouseAction { +case terminal.MouseActionPress: + if ev.MouseX == bar.X { + state.ScrollTo(drag.Press(ev.MouseY-bar.Y, bar.H, state.Offset, state.Visible, state.Total)) + } +case terminal.MouseActionDrag: + state.ScrollTo(drag.Drag(ev.MouseY-bar.Y, bar.H, state.Offset, state.Visible, state.Total)) +case terminal.MouseActionRelease: + drag.Release() +} +``` + ### Modal dialog ```go @@ -265,5 +287,6 @@ Pure logic, no rendering — usable independently: (widget default or row background) — check specific widget docs. A zero background with no Bg color bits is transparent: the cell keeps the background beneath it, bits and all. -- Mouse hit testing: `TabBar` returns `[]TabBounds`; other widgets require - application-side geometry from the regions used. +- Mouse hit testing: `TabBar` returns `[]TabBounds`, and `ScrollDrag` maps a + scrollbar's rows to offsets; other widgets require application-side + geometry from the regions used. diff --git a/tui/scrollbar.go b/tui/scrollbar.go index 762f06f..c257ecf 100644 --- a/tui/scrollbar.go +++ b/tui/scrollbar.go @@ -35,8 +35,8 @@ func (r Region) ScrollBarStyled(x int, offset, visible, total int, opts ScrollBa } trackH := r.H - if total <= visible || trackH < 3 { - // No scrolling needed or track too small + thumbY, thumbH := ScrollThumb(trackH, offset, visible, total) + if thumbH == 0 { if opts.HideIdle { return } @@ -46,14 +46,6 @@ func (r Region) ScrollBarStyled(x int, offset, visible, total int, opts ScrollBa return } - thumbH := min(max((visible*trackH)/total, 1), trackH) - maxScroll := total - visible - thumbY := 0 - if maxScroll > 0 { - thumbY = (offset * (trackH - thumbH)) / maxScroll - } - thumbY = min(max(thumbY, 0), trackH-thumbH) - for y := range trackH { if y >= thumbY && y < thumbY+thumbH { r.Cell(x, y, thumbCh, opts.ThumbFg, opts.Bg, terminal.AttrNone) @@ -63,6 +55,68 @@ func (r Region) ScrollBarStyled(x int, offset, visible, total int, opts ScrollBa } } +// ScrollThumb is the thumb a scrollbar trackH rows tall draws for offset: its +// first row and its height, 0 when content fits or the track is under 3 rows +func ScrollThumb(trackH, offset, visible, total int) (y, h int) { + if total <= visible || trackH < 3 { + return 0, 0 + } + h = min(max((visible*trackH)/total, 1), trackH) + y = (offset * (trackH - h)) / (total - visible) + return min(max(y, 0), trackH-h), h +} + +// ScrollDrag holds a scrollbar's thumb under the pointer, rows counted from the +// track's top: a press on the thumb takes hold where it lands, a press on the +// track moves the thumb's middle there first, and each drag keeps the row held +// under the pointer. Each call returns the offset to show. +type ScrollDrag struct { + Active bool // The thumb is held + Grab int // The thumb's row the pointer holds +} + +// Press takes hold of the thumb at track row y; off the track it holds nothing +func (d *ScrollDrag) Press(y, trackH, offset, visible, total int) int { + top, h := ScrollThumb(trackH, offset, visible, total) + d.Active = h > 0 && y >= 0 && y < trackH + if !d.Active { + return offset + } + if y < top || y >= top+h { + offset = scrollOffsetAt(y-h/2, trackH, h, visible, total) + top, _ = ScrollThumb(trackH, offset, visible, total) + } + d.Grab = min(max(y-top, 0), h-1) + return offset +} + +// Drag moves the held row to track row y, clamped at the track's ends +func (d *ScrollDrag) Drag(y, trackH, offset, visible, total int) int { + _, h := ScrollThumb(trackH, offset, visible, total) + if !d.Active || h == 0 { + return offset + } + return scrollOffsetAt(y-d.Grab, trackH, h, visible, total) +} + +// Release lets go of the thumb +func (d *ScrollDrag) Release() { *d = ScrollDrag{} } + +// scrollOffsetAt is the offset whose thumb, h rows tall, starts nearest row top +func scrollOffsetAt(top, trackH, h, visible, total int) int { + span, maxScroll := trackH-h, total-visible + top = min(max(top, 0), span) + o := (top*maxScroll + span - 1) / span // The first offset whose thumb starts at or past top + if o > 0 { + at, _ := ScrollThumb(trackH, o, visible, total) + before, _ := ScrollThumb(trackH, o-1, visible, total) + if at-top > top-before { + return o - 1 + } + } + return o +} + // ScrollIndicator draws compact indicator text (Top/Bot/XX%) func (r Region) ScrollIndicator(y int, offset, visible, total int, fg color.RGB) { if y < 0 || y >= r.H { diff --git a/tui/widget_test.go b/tui/widget_test.go index d42ea42..ec987f1 100644 --- a/tui/widget_test.go +++ b/tui/widget_test.go @@ -468,6 +468,60 @@ func TestWindowShowsTheRowsInView(t *testing.T) { r.Sub(8, 0, 4, 3).Window(10, &v, MonoTheme.Text, content) // no columns, past the right edge } +// A press on a scrollbar's track moves the thumb's middle to it and a press on +// the thumb leaves it, either taking hold; a drag keeps the held row under the +// pointer, clamped at the ends, or as near as an offset can put it when there are +// fewer offsets than rows. A bar with no thumb, or a row off it, holds nothing. +func TestScrollDragHoldsTheThumbUnderThePointer(t *testing.T) { + near := func(tops []int, want, got int) bool { + for _, top := range tops { + if max(top-want, want-top) < max(got-want, want-got) { + return false + } + } + return true + } + for trackH := 3; trackH <= 10; trackH++ { + for total := 1; total <= 30; total++ { + for visible := 1; visible <= total+1; visible++ { + var tops []int + for o := 0; o <= total-visible; o++ { + top, _ := ScrollThumb(trackH, o, visible, total) + tops = append(tops, top) + } + for y := -1; y <= trackH; y++ { + var d ScrollDrag + from := (total - visible) / 3 + fromTop, h := ScrollThumb(trackH, from, visible, total) + off := d.Press(y, trackH, from, visible, total) + top, _ := ScrollThumb(trackH, off, visible, total) + onThumb := func(top int) bool { return y >= top && y < top+h } + switch { + case h == 0 || y < 0 || y >= trackH: + if d.Active || off != from { + t.Fatalf("%d/%d/%d: a press at %d off the bar holds %v at %d", trackH, visible, total, y, d.Active, off) + } + continue + case onThumb(fromTop): + if off != from { + t.Fatalf("%d/%d/%d: a press on the thumb moved it %d to %d", trackH, visible, total, from, off) + } + case !near(tops, min(max(y-h/2, 0), trackH-h), top) || !onThumb(top) && slices.ContainsFunc(tops, onThumb): + t.Fatalf("%d/%d/%d: a press at %d starts the thumb at %d", trackH, visible, total, y, top) + } + for to := -2; to <= trackH+1; to++ { + got, _ := ScrollThumb(trackH, d.Drag(to, trackH, off, visible, total), visible, total) + if want := min(max(to-d.Grab, 0), trackH-h); !near(tops, want, got) { + t.Fatalf("%d/%d/%d: held at %d and dragged to %d, the thumb starts at %d, want near %d", + trackH, visible, total, d.Grab, to, got, want) + } + } + } + } + } + } +} + // A label is drawn a column a rune, so one holding runes of more than a byte // keeps them together: each widget draws s as many times as it is given it func TestLabelsKeepTheirRunesTogether(t *testing.T) {