Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc22a9bf00 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+25
-2
@@ -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.
|
||||
+64
-10
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user