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.
This commit is contained in:
1 parent
d4cc9f2407
commit
bc22a9bf00
7 files changed
+215
-60
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
|
`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
|
||||||
|
|||||||
@@ -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
|
case 0:
|
||||||
isMotion := btn&32 != 0
|
ev.MouseBtn = [...]MouseButton{MouseBtnLeft, MouseBtnMiddle, MouseBtnRight, MouseBtnNone}[id]
|
||||||
isScroll := btn&64 != 0
|
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 {
|
switch {
|
||||||
// Scroll: buttonID 0=up, 1=down
|
case btn&64 != 0:
|
||||||
if buttonID == 0 {
|
ev.MouseAction = MouseActionPress // A wheel step is its press
|
||||||
ev.MouseBtn = MouseBtnWheelUp
|
case final == 'm':
|
||||||
} else {
|
ev.MouseAction = MouseActionRelease
|
||||||
ev.MouseBtn = MouseBtnWheelDown
|
case btn&32 == 0:
|
||||||
}
|
ev.MouseAction = MouseActionPress
|
||||||
ev.MouseAction = MouseActionPress // Scroll is instantaneous
|
case ev.MouseBtn != MouseBtnNone:
|
||||||
} else {
|
ev.MouseAction = MouseActionDrag
|
||||||
// Regular button
|
default:
|
||||||
switch buttonID {
|
ev.MouseAction = MouseActionMove
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract modifiers from button byte
|
// Extract modifiers from button byte
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -71,4 +77,4 @@ func (a MouseAction) String() string {
|
|||||||
default:
|
default:
|
||||||
return "None"
|
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
|
// 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
@@ -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.
|
||||||
+64
-10
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
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
|
// 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
|
// keeps them together: each widget draws s as many times as it is given it
|
||||||
func TestLabelsKeepTheirRunesTogether(t *testing.T) {
|
func TestLabelsKeepTheirRunesTogether(t *testing.T) {
|
||||||
|
|||||||
Reference in new issue
Block a user