Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df94556101 | ||
|
|
bc22a9bf00 | ||
|
|
d4cc9f2407 |
No files matched your search
@@ -45,7 +45,9 @@ behaves as `New`'s Terminal does, the writer goroutine included; `New` is
|
||||
The application owns a flat `[]Cell` buffer (row-major, `cells[y*width+x]`) and
|
||||
passes it to `Flush`. The output buffer diffs against the previously flushed frame:
|
||||
|
||||
- Rows are scanned with early termination (trailing unchanged cells skipped).
|
||||
- A row's first and last changed cells are found by memcmp, eight cells a compare:
|
||||
an unchanged row costs those compares alone, and only the cells between are
|
||||
scanned one by one.
|
||||
- Cursor moves are emitted only when the write position is non-contiguous.
|
||||
- SGR state (fg, bg, attributes) is coalesced across cells; redundant sequences
|
||||
are suppressed.
|
||||
@@ -226,9 +228,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
|
||||
|
||||
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 {
|
||||
// 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 = MouseBtnLeft
|
||||
case 1:
|
||||
ev.MouseBtn = MouseBtnMiddle
|
||||
case 2:
|
||||
ev.MouseBtn = MouseBtnRight
|
||||
case 3:
|
||||
ev.MouseBtn = MouseBtnNone // Release with no specific button
|
||||
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 final == 'M' {
|
||||
if isMotion {
|
||||
if ev.MouseBtn != MouseBtnNone {
|
||||
ev.MouseAction = MouseActionDrag
|
||||
} else {
|
||||
ev.MouseAction = MouseActionMove
|
||||
}
|
||||
} else {
|
||||
ev.MouseAction = MouseActionPress
|
||||
}
|
||||
} else {
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -76,8 +76,8 @@ func cellEqual(a, b Cell) bool {
|
||||
// fails to compile should a field change leave padding behind
|
||||
var _ [0]struct{} = [unsafe.Sizeof(Cell{}) - unsafe.Sizeof(rune(0)) - 2*unsafe.Sizeof(color.RGB{}) - unsafe.Sizeof(Attr(0))]struct{}{}
|
||||
|
||||
// rowEqual reports whether two rows of cells hold the same cells, as one memcmp
|
||||
func rowEqual(a, b []Cell) bool {
|
||||
// cellsEqual reports whether two runs of cells hold the same cells, as one memcmp
|
||||
func cellsEqual(a, b []Cell) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
@@ -88,6 +88,34 @@ func rowEqual(a, b []Cell) bool {
|
||||
return bytes.Equal(unsafe.Slice((*byte)(unsafe.Pointer(&a[0])), n), unsafe.Slice((*byte)(unsafe.Pointer(&b[0])), n))
|
||||
}
|
||||
|
||||
// diffChunk is the cells one memcmp settles while a row's changes are sought: few
|
||||
// enough that a changed chunk costs little to search again cell by cell
|
||||
const diffChunk = 8
|
||||
|
||||
// firstChange is the first cell where a and b differ, len(a) when none does
|
||||
func firstChange(a, b []Cell) int {
|
||||
i := 0
|
||||
for i+diffChunk <= len(a) && cellsEqual(a[i:i+diffChunk], b[i:i+diffChunk]) {
|
||||
i += diffChunk
|
||||
}
|
||||
for i < len(a) && cellEqual(a[i], b[i]) {
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// lastChange is one past the last cell from on where a and b differ, from when none does
|
||||
func lastChange(a, b []Cell, from int) int {
|
||||
j := len(a)
|
||||
for j-diffChunk >= from && cellsEqual(a[j-diffChunk:j], b[j-diffChunk:j]) {
|
||||
j -= diffChunk
|
||||
}
|
||||
for j > from && cellEqual(a[j-1], b[j-1]) {
|
||||
j--
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
// flush renders the back buffer into buf, diffing against front buffer
|
||||
func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
||||
if width != o.width || height != o.height {
|
||||
@@ -100,23 +128,19 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
||||
}
|
||||
|
||||
w := &o.buf
|
||||
start := w.Len()
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
rowStart := y * width
|
||||
// Most rows of a frame are unchanged; one compare settles each of those
|
||||
if rowEqual(cells[rowStart:rowStart+width], o.front[rowStart:rowStart+width]) {
|
||||
// Memcmp finds the row's first and last changed cells, so an unchanged row is
|
||||
// one pass of compares and a changed one is scanned cell by cell between them
|
||||
row, front := cells[rowStart:rowStart+width], o.front[rowStart:rowStart+width]
|
||||
x := firstChange(row, front)
|
||||
if x == width {
|
||||
continue
|
||||
}
|
||||
rowEnd := lastChange(row, front, x+1)
|
||||
|
||||
rowEnd := width
|
||||
for rowEnd > 0 && cellEqual(cells[rowStart+rowEnd-1], o.front[rowStart+rowEnd-1]) {
|
||||
rowEnd--
|
||||
}
|
||||
if rowEnd == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
x := 0
|
||||
for x < rowEnd {
|
||||
idx := rowStart + x
|
||||
|
||||
@@ -194,7 +218,10 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
||||
}
|
||||
}
|
||||
|
||||
// A frame that changed no cell writes nothing, the reset included
|
||||
if w.Len() > start {
|
||||
w.Write(csiSGR0)
|
||||
}
|
||||
o.lastValid = false
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package terminal
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -121,6 +122,37 @@ func TestEachCellIsOneColumn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A frame that changes no cell writes nothing, so drawing an idle screen
|
||||
// again costs the terminal no bytes
|
||||
func TestUnchangedFrameWritesNothing(t *testing.T) {
|
||||
o := newOutputBuffer(ColorMode256)
|
||||
frame := []Cell{{Rune: 'a'}, {Rune: 'é'}}
|
||||
o.flush(frame, len(frame), 1)
|
||||
o.buf.Reset()
|
||||
if o.flush(frame, len(frame), 1); o.buf.Len() != 0 {
|
||||
t.Fatalf("unchanged frame wrote %q", o.buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Every cell a frame changes is written, wherever in its row it lies and however
|
||||
// wide the row: after a flush the screen the terminal holds is the frame
|
||||
func TestEveryChangedCellIsWritten(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
for _, width := range []int{1, 7, 8, 9, 17, 120} {
|
||||
o := newOutputBuffer(ColorModeTrueColor)
|
||||
frame := make([]Cell, width*4)
|
||||
for range 200 {
|
||||
for range rng.IntN(len(frame)/2 + 1) {
|
||||
frame[rng.IntN(len(frame))] = Cell{Rune: 'a' + rng.Int32N(4), Fg: color.RGB{G: uint8(rng.IntN(2))}}
|
||||
}
|
||||
o.flush(frame, width, 4)
|
||||
if !slices.Equal(o.front, frame) {
|
||||
t.Fatalf("width %d: a changed cell went unwritten", width)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A paste arrives as one event, whatever reads split it into: an escape
|
||||
// inside it is text even when a poll times out on it, and a paste the
|
||||
// terminal never ends ends when input pauses, whether reads time out or
|
||||
@@ -216,6 +248,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.
|
||||
+1
-4
@@ -149,10 +149,7 @@ func (r Region) Divider(y int, label string, line LineType, fg color.RGB) {
|
||||
text = Truncate(text, r.W-2)
|
||||
textLen = RuneLen(text)
|
||||
}
|
||||
startX := (r.W - textLen) / 2
|
||||
for i, ch := range text {
|
||||
r.Cell(startX+i, y, ch, fg, color.RGB{}, terminal.AttrBold)
|
||||
}
|
||||
r.Text((r.W-textLen)/2, y, text, fg, color.RGB{}, terminal.AttrBold)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-15
@@ -225,11 +225,7 @@ func (r Region) ConfirmDialog(state *ConfirmState, opts ConfirmOpts) Region {
|
||||
yesBg = style.ButtonFocusBg
|
||||
}
|
||||
}
|
||||
for i, ch := range yesLabel {
|
||||
if buttonX+i < content.W {
|
||||
content.Cell(buttonX+i, buttonY, ch, yesFg, yesBg, terminal.AttrNone)
|
||||
}
|
||||
}
|
||||
content.Text(buttonX, buttonY, yesLabel, yesFg, yesBg, terminal.AttrNone)
|
||||
|
||||
// No button
|
||||
noX := buttonX + yesW + buttonGap
|
||||
@@ -239,11 +235,7 @@ func (r Region) ConfirmDialog(state *ConfirmState, opts ConfirmOpts) Region {
|
||||
noFg = style.ButtonFocusFg
|
||||
noBg = style.ButtonFocusBg
|
||||
}
|
||||
for i, ch := range noLabel {
|
||||
if noX+i < content.W {
|
||||
content.Cell(noX+i, buttonY, ch, noFg, noBg, terminal.AttrNone)
|
||||
}
|
||||
}
|
||||
content.Text(noX, buttonY, noLabel, noFg, noBg, terminal.AttrNone)
|
||||
|
||||
return content.Sub(0, 0, content.W, buttonY-1)
|
||||
}
|
||||
@@ -317,11 +309,7 @@ func (r Region) AlertDialog(opts AlertOpts) Region {
|
||||
buttonW := RuneLen(buttonLabel)
|
||||
buttonX := (content.W - buttonW) / 2
|
||||
|
||||
for i, ch := range buttonLabel {
|
||||
if buttonX+i < content.W {
|
||||
content.Cell(buttonX+i, buttonY, ch, style.ButtonFocusFg, style.ButtonFocusBg, terminal.AttrNone)
|
||||
}
|
||||
}
|
||||
content.Text(buttonX, buttonY, buttonLabel, style.ButtonFocusFg, style.ButtonFocusBg, terminal.AttrNone)
|
||||
|
||||
return content.Sub(0, 0, content.W, buttonY-1)
|
||||
}
|
||||
|
||||
+1
-6
@@ -164,12 +164,7 @@ func (r Region) Form(state *FormState, opts FormOpts) int {
|
||||
|
||||
// Label
|
||||
label := field.Label + ":"
|
||||
for j, ch := range label {
|
||||
if j >= labelW || j >= r.W {
|
||||
break
|
||||
}
|
||||
r.Cell(j, y, ch, style.LabelFg, style.Bg, terminal.AttrNone)
|
||||
}
|
||||
r.Sub(0, y, labelW, 1).Text(0, 0, label, style.LabelFg, style.Bg, terminal.AttrNone)
|
||||
|
||||
// Field area
|
||||
fieldX := labelW
|
||||
|
||||
+2
-10
@@ -36,10 +36,7 @@ func (r Region) Modal(opts ModalOpts) Region {
|
||||
title = Truncate(title, r.W-4)
|
||||
titleLen = RuneLen(title)
|
||||
}
|
||||
x := (r.W - titleLen) / 2
|
||||
for i, ch := range title {
|
||||
r.Cell(x+i, 0, ch, opts.TitleFg, opts.Bg, terminal.AttrBold)
|
||||
}
|
||||
r.Text((r.W-titleLen)/2, 0, title, opts.TitleFg, opts.Bg, terminal.AttrBold)
|
||||
}
|
||||
|
||||
// Hint right-aligned on top edge
|
||||
@@ -54,12 +51,7 @@ func (r Region) Modal(opts ModalOpts) Region {
|
||||
if x < r.W/2 {
|
||||
x = r.W / 2
|
||||
}
|
||||
for i, ch := range hint {
|
||||
if x+i >= r.W-1 {
|
||||
break
|
||||
}
|
||||
r.Cell(x+i, 0, ch, opts.HintFg, opts.Bg, terminal.AttrNone)
|
||||
}
|
||||
r.Sub(0, 0, r.W-1, 1).Text(x, 0, hint, opts.HintFg, opts.Bg, terminal.AttrNone)
|
||||
}
|
||||
|
||||
// Return content region
|
||||
|
||||
+1
-7
@@ -33,13 +33,7 @@ func (r Region) Pane(opts PaneOpts) Region {
|
||||
if RuneLen(title) > r.W-4 {
|
||||
title = Truncate(title, r.W-4)
|
||||
}
|
||||
x := 2
|
||||
for i, ch := range title {
|
||||
if x+i >= r.W-1 {
|
||||
break
|
||||
}
|
||||
r.Cell(x+i, 0, ch, opts.TitleFg, opts.Bg, terminal.AttrBold)
|
||||
}
|
||||
r.Text(2, 0, title, opts.TitleFg, opts.Bg, terminal.AttrBold)
|
||||
}
|
||||
|
||||
// Content region is everything inside the border. The title sits ON the border row.
|
||||
|
||||
@@ -194,10 +194,7 @@ func (r Region) ProgressOverlay(opts ProgressOverlayOpts) Region {
|
||||
title = Truncate(title, overlayW-4)
|
||||
titleLen = RuneLen(title)
|
||||
}
|
||||
titleX := (overlayW - titleLen) / 2
|
||||
for i, ch := range title {
|
||||
overlay.Cell(titleX+i, 0, ch, opts.AccentFg, opts.Bg, terminal.AttrBold)
|
||||
}
|
||||
overlay.Text((overlayW-titleLen)/2, 0, title, opts.AccentFg, opts.Bg, terminal.AttrBold)
|
||||
}
|
||||
|
||||
// Content area
|
||||
|
||||
+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 {
|
||||
|
||||
+1
-7
@@ -64,13 +64,7 @@ func (r Region) TabBar(y int, titles []string, active int, opts TabBarOpts) []Ta
|
||||
r.Cell(x+j, y, ' ', style.Fg, style.Bg, style.Attr)
|
||||
}
|
||||
|
||||
titleStart := x + opts.Padding
|
||||
for j, ch := range title {
|
||||
if titleStart+j >= r.W {
|
||||
break
|
||||
}
|
||||
r.Cell(titleStart+j, y, ch, style.Fg, style.Bg, style.Attr)
|
||||
}
|
||||
r.TextStyled(x+opts.Padding, y, title, style)
|
||||
|
||||
for j := 0; j < opts.Padding; j++ {
|
||||
pos := x + opts.Padding + RuneLen(title) + j
|
||||
|
||||
+1
-5
@@ -171,11 +171,7 @@ func (r Region) renderTableRow(y int, cells []string, widths []int, aligns []Ali
|
||||
startX = x
|
||||
}
|
||||
|
||||
for j, ch := range text {
|
||||
if startX+j < r.W {
|
||||
r.Cell(startX+j, y, ch, style.Fg, style.Bg, style.Attr)
|
||||
}
|
||||
}
|
||||
r.TextStyled(startX, y, text, style)
|
||||
|
||||
x += w
|
||||
|
||||
|
||||
+1
-6
@@ -108,12 +108,7 @@ func (r Region) TextField(state *TextFieldState, opts TextFieldOpts) int {
|
||||
if RuneLen(placeholder) > viewportW {
|
||||
placeholder = Truncate(placeholder, viewportW)
|
||||
}
|
||||
for i, ch := range placeholder {
|
||||
if x+i >= contentX+contentW {
|
||||
break
|
||||
}
|
||||
r.Cell(x+i, contentY, ch, style.PlaceholderFg, style.TextBg, terminal.AttrDim)
|
||||
}
|
||||
r.Text(x, contentY, placeholder, style.PlaceholderFg, style.TextBg, terminal.AttrDim)
|
||||
} else {
|
||||
// Scroll indicators
|
||||
if state.Scroll > 0 && x > contentX {
|
||||
|
||||
@@ -467,3 +467,91 @@ 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) {
|
||||
const s = "● é · ñ"
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
n int
|
||||
draw func(r Region)
|
||||
}{
|
||||
{"pane", 1, func(r Region) { r.Pane(PaneOpts{Title: s}) }},
|
||||
{"modal", 2, func(r Region) { r.Modal(ModalOpts{Title: s, Hint: s}) }},
|
||||
{"divider", 1, func(r Region) { r.Divider(0, s, LineSingle, color.RGB{}) }},
|
||||
{"tabs", 2, func(r Region) { r.TabBar(0, []string{s, s}, 0, DefaultTabBarOpts()) }},
|
||||
{"table", 2, func(r Region) { r.Table([]string{s}, [][]string{{s}}, TableOpts{}) }},
|
||||
{"form", 1, func(r Region) { r.Form(NewFormState(s), FormOpts{}) }},
|
||||
{"field", 1, func(r Region) { r.TextField(NewTextFieldState(""), TextFieldOpts{Placeholder: s}) }},
|
||||
{"confirm", 4, func(r Region) {
|
||||
r.ConfirmDialog(NewConfirmState(true), ConfirmOpts{Title: s, Message: s, YesLabel: s, NoLabel: s})
|
||||
}},
|
||||
{"alert", 3, func(r Region) { r.AlertDialog(AlertOpts{Title: s, Message: s, Button: s}) }},
|
||||
{"progress", 1, func(r Region) { r.ProgressOverlay(DefaultProgressOpts(s, "", ProgressSpinner)) }},
|
||||
} {
|
||||
r := canvas(48, 14, MonoTheme)
|
||||
c.draw(r)
|
||||
rows := make([]string, r.H)
|
||||
for y := range rows {
|
||||
rows[y] = row(r, y)
|
||||
}
|
||||
if got := strings.Count(strings.Join(rows, "\n"), s); got != c.n {
|
||||
t.Errorf("%s: %d of %d, rows %q", c.name, got, c.n, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user