3 Commits
Author SHA1 Message Date
Lixen df94556101 v0.6.1 flush: memcmp finds a row's first and last changed cells
The whole-row memcmp settled an unchanged row, but on a changed row the cell
loop then read again the prefix memcmp had already compared, and the trailing
trim walked unchanged cells one by one. Chunks of eight cells now find the
first and last changed cell, so no cell is compared twice and only the span
between them is scanned cell by cell. Output is byte-identical.

Measured on frames captured from vif at 60 fps (median of 15, one core):
vanguard 120x40 73.7 -> 66.6 us a frame (-10%), vanguard 240x66
158.8 -> 131.8 (-17%), main with the gun firing 40.0 -> 34.1 (-15%), main
idle 20.3 -> 14.0 (-31%). Dropping the row compare instead costs +15% to
+120% on the same frames.
2026-10-28 22:03:41 -04:00
Lixen bc22a9bf00 v0.6.0 mouse: every wheel direction and side button named; tui: a scrollbar the pointer drives
* mouse: the wheel's four directions and the side buttons

parseSGRMouse read a report's low two bits alone, so a wheel tilted left or
right (66, 67) reported as WheelDown, and a mouse's back and forward buttons
(128, 129) as a left or middle press. It now decodes by bank: bits 0-1 number
a button within the main three and none, the wheel's four directions (bit 6),
or buttons 8 to 11 (bit 7). WheelLeft and WheelRight join MouseButton after
Forward, so every existing value keeps its number; Back and Forward are
reported for buttons 8 and 9; buttons 10 and 11, and a report with both bank
bits set, are dropped as KeyNone. A wheel step is a press with no release in
every direction.

* tui: ScrollThumb and ScrollDrag

ScrollThumb is the thumb ScrollBarStyled draws, its first row and height,
exported so an application hit-tests the bar it drew. ScrollDrag drives a bar
by 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 and
takes hold, and each drag keeps the held row under the pointer, clamped at the
track's ends, at the nearest offset when there are fewer offsets than rows.
Each call returns the offset to show.

A table of SGR reports covers each bank and drops the buttons past 9; a
property test over every track of 3 to 10 rows and content up to 30 checks
each press and drag against the thumb ScrollThumb draws.
2026-10-26 20:42:04 -04:00
Lixen d4cc9f2407 v0.5.2 tui: titles and labels drawn a column a rune; an unchanged frame writes nothing
* tui: titles, labels and buttons drawn a column a rune

Ten places drew each rune of a title or label at its byte offset, so a
string holding runes of more than one byte drew with holes and ran past the
width it was measured and truncated to: the Pane title, the Modal title and
hint, the Divider label, TabBar titles, Table cells, Form labels, the
TextField placeholder, the ConfirmDialog and AlertDialog buttons, and the
ProgressOverlay title. Each now draws through Region.Text, which counts
columns by rune, within the bound its loop kept; a Form label stays within
its label column, as the byte count meant it to.

* an unchanged frame writes nothing

flush closed every frame with SGR 0, so a frame that changed no cell still
sent four bytes, and an app drawing an idle screen kept writing to the
terminal. The reset now follows only a frame that wrote cells: the next
cell written restates its whole style, and Fini resets on its own.

Each widget, given a title such as "● é · ñ", draws it whole where it drew
it with holes before; a frame flushed twice writes nothing the second time,
where it wrote ESC [ 0 m.
2026-10-24 19:00:37 -04:00
17 changed files with 332 additions and 134 deletions

No files matched your search

+8 -4
View File
@@ -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 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: 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. - Cursor moves are emitted only when the write position is non-contiguous.
- SGR state (fg, bg, attributes) is coalesced across cells; redundant sequences - SGR state (fg, bg, attributes) is coalesced across cells; redundant sequences
are suppressed. 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 `Key == KeyRune` with `Rune` set for printable input, `Modifiers` bitmask
(`ModShift`, `ModAlt`, `ModCtrl`), `Action` (`KeyPress`, and `KeyRepeat` and (`ModShift`, `ModAlt`, `ModCtrl`), `Action` (`KeyPress`, and `KeyRepeat` and
`KeyRelease` while releases are reported; see below) `KeyRelease` while releases are reported; see below)
- `EventMouse` — 0-indexed `MouseX/Y`, `MouseBtn` (buttons, wheel), - `EventMouse` — 0-indexed `MouseX/Y`, `MouseBtn` (the three buttons, the
`MouseAction` (press/release/move/drag), modifiers. Enable via wheel's four directions, back and forward), `MouseAction`
`SetMouseMode(MouseModeClick | MouseModeDrag)`; SGR protocol only. (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` - `EventResize` — new `Width`/`Height`
- `EventPaste` — `Text` holds a whole paste, as sent, control characters - `EventPaste` — `Text` holds a whole paste, as sent, control characters
included. Enable via `SetPasteMode(true)` (bracketed paste); off, a paste included. Enable via `SetPasteMode(true)` (bracketed paste); off, a paste
+26 -39
View File
@@ -623,7 +623,8 @@ func (r *inputReader) parseControl(b byte) Event {
} }
// parseSGRMouse parses mouse SGR sequences, ESC [ < Btn ; X ; Y M/m, from // 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 { func parseSGRMouse(params []byte, final byte) Event {
btn, x, y, ok := parseSGRParams(params) btn, x, y, ok := parseSGRParams(params)
if !ok { 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 ev := Event{Type: EventMouse, MouseX: x - 1, MouseY: y - 1} // Convert to 0-indexed
// Decode button and action // Bits 0-1 number a button within its bank: the main three and none, the
// Bits 0-1: button (0=left, 1=middle, 2=right, 3=release) // wheel's four directions (bit 6), or buttons 8 to 11 (bit 7); bit 5 is motion
// Bit 5 (32): motion id := btn & 0x03
// Bit 6 (64): scroll switch btn & (64 | 128) {
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 {
case 0: case 0:
ev.MouseBtn = MouseBtnLeft ev.MouseBtn = [...]MouseButton{MouseBtnLeft, MouseBtnMiddle, MouseBtnRight, MouseBtnNone}[id]
case 1: case 64:
ev.MouseBtn = MouseBtnMiddle ev.MouseBtn = [...]MouseButton{MouseBtnWheelUp, MouseBtnWheelDown, MouseBtnWheelLeft, MouseBtnWheelRight}[id]
case 2: case 128:
ev.MouseBtn = MouseBtnRight if id > 1 {
case 3: return Event{Type: EventKey, Key: KeyNone}
ev.MouseBtn = MouseBtnNone // Release with no specific button }
ev.MouseBtn = MouseBtnBack + MouseButton(id)
default:
return Event{Type: EventKey, Key: KeyNone}
} }
if final == 'M' { switch {
if isMotion { case btn&64 != 0:
if ev.MouseBtn != MouseBtnNone { ev.MouseAction = MouseActionPress // A wheel step is its press
ev.MouseAction = MouseActionDrag case final == 'm':
} else {
ev.MouseAction = MouseActionMove
}
} else {
ev.MouseAction = MouseActionPress
}
} else {
ev.MouseAction = MouseActionRelease 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 // Extract modifiers from button byte
+8 -2
View File
@@ -10,8 +10,10 @@ const (
MouseBtnRight MouseBtnRight
MouseBtnWheelUp MouseBtnWheelUp
MouseBtnWheelDown MouseBtnWheelDown
MouseBtnBack // Button 4 (if supported) MouseBtnBack // Button 8, a mouse's back side button
MouseBtnForward // Button 5 (if supported) 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 // MouseAction represents the type of mouse event
@@ -52,6 +54,10 @@ func (b MouseButton) String() string {
return "Back" return "Back"
case MouseBtnForward: case MouseBtnForward:
return "Forward" return "Forward"
case MouseBtnWheelLeft:
return "WheelLeft"
case MouseBtnWheelRight:
return "WheelRight"
default: default:
return "None" return "None"
} }
+40 -13
View File
@@ -76,8 +76,8 @@ func cellEqual(a, b Cell) bool {
// fails to compile should a field change leave padding behind // 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{}{} 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 // cellsEqual reports whether two runs of cells hold the same cells, as one memcmp
func rowEqual(a, b []Cell) bool { func cellsEqual(a, b []Cell) bool {
if len(a) != len(b) { if len(a) != len(b) {
return false 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)) 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 // flush renders the back buffer into buf, diffing against front buffer
func (o *outputBuffer) flush(cells []Cell, width, height int) { func (o *outputBuffer) flush(cells []Cell, width, height int) {
if width != o.width || height != o.height { if width != o.width || height != o.height {
@@ -100,23 +128,19 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
} }
w := &o.buf w := &o.buf
start := w.Len()
for y := 0; y < height; y++ { for y := 0; y < height; y++ {
rowStart := y * width rowStart := y * width
// Most rows of a frame are unchanged; one compare settles each of those // Memcmp finds the row's first and last changed cells, so an unchanged row is
if rowEqual(cells[rowStart:rowStart+width], o.front[rowStart:rowStart+width]) { // 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 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 { for x < rowEnd {
idx := rowStart + x 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) w.Write(csiSGR0)
}
o.lastValid = false o.lastValid = false
} }
+61
View File
@@ -3,6 +3,7 @@ package terminal
import ( import (
"bytes" "bytes"
"cmp" "cmp"
"math/rand/v2"
"slices" "slices"
"strings" "strings"
"sync" "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 // 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 // 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 // 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 // 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 // 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 // nothing once four DEL, which end any sequence or character, and a paste
+25 -2
View File
@@ -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) 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 ### Modal dialog
```go ```go
@@ -265,5 +287,6 @@ Pure logic, no rendering — usable independently:
(widget default or row background) — check specific widget docs. A zero (widget default or row background) — check specific widget docs. A zero
background with no Bg color bits is transparent: the cell keeps the background with no Bg color bits is transparent: the cell keeps the
background beneath it, bits and all. background beneath it, bits and all.
- Mouse hit testing: `TabBar` returns `[]TabBounds`; other widgets require - Mouse hit testing: `TabBar` returns `[]TabBounds`, and `ScrollDrag` maps a
application-side geometry from the regions used. scrollbar's rows to offsets; other widgets require application-side
geometry from the regions used.
+1 -4
View File
@@ -149,10 +149,7 @@ func (r Region) Divider(y int, label string, line LineType, fg color.RGB) {
text = Truncate(text, r.W-2) text = Truncate(text, r.W-2)
textLen = RuneLen(text) textLen = RuneLen(text)
} }
startX := (r.W - textLen) / 2 r.Text((r.W-textLen)/2, y, text, fg, color.RGB{}, terminal.AttrBold)
for i, ch := range text {
r.Cell(startX+i, y, ch, fg, color.RGB{}, terminal.AttrBold)
}
} }
} }
+3 -15
View File
@@ -225,11 +225,7 @@ func (r Region) ConfirmDialog(state *ConfirmState, opts ConfirmOpts) Region {
yesBg = style.ButtonFocusBg yesBg = style.ButtonFocusBg
} }
} }
for i, ch := range yesLabel { content.Text(buttonX, buttonY, yesLabel, yesFg, yesBg, terminal.AttrNone)
if buttonX+i < content.W {
content.Cell(buttonX+i, buttonY, ch, yesFg, yesBg, terminal.AttrNone)
}
}
// No button // No button
noX := buttonX + yesW + buttonGap noX := buttonX + yesW + buttonGap
@@ -239,11 +235,7 @@ func (r Region) ConfirmDialog(state *ConfirmState, opts ConfirmOpts) Region {
noFg = style.ButtonFocusFg noFg = style.ButtonFocusFg
noBg = style.ButtonFocusBg noBg = style.ButtonFocusBg
} }
for i, ch := range noLabel { content.Text(noX, buttonY, noLabel, noFg, noBg, terminal.AttrNone)
if noX+i < content.W {
content.Cell(noX+i, buttonY, ch, noFg, noBg, terminal.AttrNone)
}
}
return content.Sub(0, 0, content.W, buttonY-1) return content.Sub(0, 0, content.W, buttonY-1)
} }
@@ -317,11 +309,7 @@ func (r Region) AlertDialog(opts AlertOpts) Region {
buttonW := RuneLen(buttonLabel) buttonW := RuneLen(buttonLabel)
buttonX := (content.W - buttonW) / 2 buttonX := (content.W - buttonW) / 2
for i, ch := range buttonLabel { content.Text(buttonX, buttonY, buttonLabel, style.ButtonFocusFg, style.ButtonFocusBg, terminal.AttrNone)
if buttonX+i < content.W {
content.Cell(buttonX+i, buttonY, ch, style.ButtonFocusFg, style.ButtonFocusBg, terminal.AttrNone)
}
}
return content.Sub(0, 0, content.W, buttonY-1) return content.Sub(0, 0, content.W, buttonY-1)
} }
+1 -6
View File
@@ -164,12 +164,7 @@ func (r Region) Form(state *FormState, opts FormOpts) int {
// Label // Label
label := field.Label + ":" label := field.Label + ":"
for j, ch := range label { r.Sub(0, y, labelW, 1).Text(0, 0, label, style.LabelFg, style.Bg, terminal.AttrNone)
if j >= labelW || j >= r.W {
break
}
r.Cell(j, y, ch, style.LabelFg, style.Bg, terminal.AttrNone)
}
// Field area // Field area
fieldX := labelW fieldX := labelW
+2 -10
View File
@@ -36,10 +36,7 @@ func (r Region) Modal(opts ModalOpts) Region {
title = Truncate(title, r.W-4) title = Truncate(title, r.W-4)
titleLen = RuneLen(title) titleLen = RuneLen(title)
} }
x := (r.W - titleLen) / 2 r.Text((r.W-titleLen)/2, 0, title, opts.TitleFg, opts.Bg, terminal.AttrBold)
for i, ch := range title {
r.Cell(x+i, 0, ch, opts.TitleFg, opts.Bg, terminal.AttrBold)
}
} }
// Hint right-aligned on top edge // Hint right-aligned on top edge
@@ -54,12 +51,7 @@ func (r Region) Modal(opts ModalOpts) Region {
if x < r.W/2 { if x < r.W/2 {
x = r.W / 2 x = r.W / 2
} }
for i, ch := range hint { r.Sub(0, 0, r.W-1, 1).Text(x, 0, hint, opts.HintFg, opts.Bg, terminal.AttrNone)
if x+i >= r.W-1 {
break
}
r.Cell(x+i, 0, ch, opts.HintFg, opts.Bg, terminal.AttrNone)
}
} }
// Return content region // Return content region
+1 -7
View File
@@ -33,13 +33,7 @@ func (r Region) Pane(opts PaneOpts) Region {
if RuneLen(title) > r.W-4 { if RuneLen(title) > r.W-4 {
title = Truncate(title, r.W-4) title = Truncate(title, r.W-4)
} }
x := 2 r.Text(2, 0, title, opts.TitleFg, opts.Bg, terminal.AttrBold)
for i, ch := range title {
if x+i >= r.W-1 {
break
}
r.Cell(x+i, 0, ch, opts.TitleFg, opts.Bg, terminal.AttrBold)
}
} }
// Content region is everything inside the border. The title sits ON the border row. // Content region is everything inside the border. The title sits ON the border row.
+1 -4
View File
@@ -194,10 +194,7 @@ func (r Region) ProgressOverlay(opts ProgressOverlayOpts) Region {
title = Truncate(title, overlayW-4) title = Truncate(title, overlayW-4)
titleLen = RuneLen(title) titleLen = RuneLen(title)
} }
titleX := (overlayW - titleLen) / 2 overlay.Text((overlayW-titleLen)/2, 0, title, opts.AccentFg, opts.Bg, terminal.AttrBold)
for i, ch := range title {
overlay.Cell(titleX+i, 0, ch, opts.AccentFg, opts.Bg, terminal.AttrBold)
}
} }
// Content area // Content area
+64 -10
View File
@@ -35,8 +35,8 @@ func (r Region) ScrollBarStyled(x int, offset, visible, total int, opts ScrollBa
} }
trackH := r.H trackH := r.H
if total <= visible || trackH < 3 { thumbY, thumbH := ScrollThumb(trackH, offset, visible, total)
// No scrolling needed or track too small if thumbH == 0 {
if opts.HideIdle { if opts.HideIdle {
return return
} }
@@ -46,14 +46,6 @@ func (r Region) ScrollBarStyled(x int, offset, visible, total int, opts ScrollBa
return 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 { for y := range trackH {
if y >= thumbY && y < thumbY+thumbH { if y >= thumbY && y < thumbY+thumbH {
r.Cell(x, y, thumbCh, opts.ThumbFg, opts.Bg, terminal.AttrNone) 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%) // ScrollIndicator draws compact indicator text (Top/Bot/XX%)
func (r Region) ScrollIndicator(y int, offset, visible, total int, fg color.RGB) { func (r Region) ScrollIndicator(y int, offset, visible, total int, fg color.RGB) {
if y < 0 || y >= r.H { if y < 0 || y >= r.H {
+1 -7
View File
@@ -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) r.Cell(x+j, y, ' ', style.Fg, style.Bg, style.Attr)
} }
titleStart := x + opts.Padding r.TextStyled(x+opts.Padding, y, title, style)
for j, ch := range title {
if titleStart+j >= r.W {
break
}
r.Cell(titleStart+j, y, ch, style.Fg, style.Bg, style.Attr)
}
for j := 0; j < opts.Padding; j++ { for j := 0; j < opts.Padding; j++ {
pos := x + opts.Padding + RuneLen(title) + j pos := x + opts.Padding + RuneLen(title) + j
+1 -5
View File
@@ -171,11 +171,7 @@ func (r Region) renderTableRow(y int, cells []string, widths []int, aligns []Ali
startX = x startX = x
} }
for j, ch := range text { r.TextStyled(startX, y, text, style)
if startX+j < r.W {
r.Cell(startX+j, y, ch, style.Fg, style.Bg, style.Attr)
}
}
x += w x += w
+1 -6
View File
@@ -108,12 +108,7 @@ func (r Region) TextField(state *TextFieldState, opts TextFieldOpts) int {
if RuneLen(placeholder) > viewportW { if RuneLen(placeholder) > viewportW {
placeholder = Truncate(placeholder, viewportW) placeholder = Truncate(placeholder, viewportW)
} }
for i, ch := range placeholder { r.Text(x, contentY, placeholder, style.PlaceholderFg, style.TextBg, terminal.AttrDim)
if x+i >= contentX+contentW {
break
}
r.Cell(x+i, contentY, ch, style.PlaceholderFg, style.TextBg, terminal.AttrDim)
}
} else { } else {
// Scroll indicators // Scroll indicators
if state.Scroll > 0 && x > contentX { if state.Scroll > 0 && x > contentX {
+88
View File
@@ -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 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)
}
}
}