v0.2.2 tui update, reverse row bar

This commit is contained in:
lixen committed 2026-10-10 18:04:30 -04:00
1 parent 7d1c3b24a0
commit c657af606d
8 files changed
+182 -85

No files matched your search

+17 -14
View File
@@ -46,12 +46,18 @@ passes it to `Flush`. The output buffer diffs against the previously flushed fra
- If the backend size changed between buffer preparation and `Flush`, the frame - If the backend size changed between buffer preparation and `Flush`, the frame
is dropped to prevent resize-race corruption. The next frame (built at the new is dropped to prevent resize-race corruption. The next frame (built at the new
size) renders normally. size) renders normally.
- A cell is one glyph in one column, on a terminal that draws ambiguous-width
runes (box drawing, U+FFFD) narrow: a control or format character in `Rune`
(a pasted escape, a bidi override, a line separator) is drawn as a space,
and a rune a terminal draws in no column, in two or in its neighbour's
cluster (a combining or spacing mark, a wide or emoji rune) as U+FFFD.
`Sync()` clears the screen and invalidates the front buffer, forcing a full `Sync()` clears the screen and invalidates the front buffer, forcing a full
redraw — required after any external process writes to the terminal. redraw — required after any external process writes to the terminal.
Auto-wrap is disabled during the session, making the bottom-right cell writable Auto-wrap and, where the terminal follows ECMA-48 mode 8 (VTE), its own bidi
without scroll side effects. reordering are off during the session: the bottom-right cell is writable
without scroll side effects, and right-to-left text stays in its cells.
## Quick start ## Quick start
@@ -170,17 +176,15 @@ Palette helpers: `Cube256(r,g,b)` / `CubeRGB256(idx)` for 6×6×6 cube math,
destination first and are branch-free in the hot path or LUT-backed; suitable destination first and are branch-free in the hot path or LUT-backed; suitable
for per-cell use at frame rate. for per-cell use at frame rate.
| Function | Operation | Character | - `Blend(dst, src, alpha)`: linear interpolation, standard transparency
|---|---|---| - `Add(dst, src, alpha)`: saturating add, bright accumulation, clips
| `Blend(dst, src, alpha)` | linear interpolation | standard transparency | - `Screen(dst, src, alpha)`: `1-(1-d)(1-s)`, lightens, never clips
| `Add(dst, src, alpha)` | saturating add | bright accumulation, clips | - `Overlay(dst, src, alpha)`: multiply/screen split at 0.5, contrast, keeps dst structure
| `Screen(dst, src, alpha)` | `1-(1-d)(1-s)` | lightens, never clips | - `SoftLight(dst, src, intensity)`: Perez soft light, gentle tint/glow
| `Overlay(dst, src, alpha)` | multiply/screen split at 0.5 | contrast, keeps dst structure | - `Max(dst, src, alpha)`: per-channel max, non-additive highlight
| `SoftLight(dst, src, intensity)` | Perez soft light | gentle tint/glow | - `Scale(c, factor)`: channel multiply, dim/brighten
| `Max(dst, src, alpha)` | per-channel max | non-additive highlight | - `Grayscale(c)`: Rec. 601 luma, desaturation
| `Scale(c, factor)` | channel multiply | dim/brighten | - `c.Lerp(other, t)`: method on `RGB`, gradients, animation
| `Grayscale(c)` | Rec. 601 luma | desaturation |
| `c.Lerp(other, t)` | method on `RGB` | gradients, animation |
`alpha`/`intensity`/`t` are `[0,1]`; out-of-range values clamp. `alpha` of 0 or 1 `alpha`/`intensity`/`t` are `[0,1]`; out-of-range values clamp. `alpha` of 0 or 1
short-circuits without float math. All float→channel conversions round half-up, short-circuits without float math. All float→channel conversions round half-up,
@@ -218,7 +222,6 @@ division approximation; `SoftLight` uses init-time LUTs replacing `math.Sqrt`.
A standalone ESC press is disambiguated from escape sequences by a short input-idle timeout (one ~10ms poll cycle). A standalone ESC press is disambiguated from escape sequences by a short input-idle timeout (one ~10ms poll cycle).
ESC ESC is Alt+Escape alone, Escape before a paste, and Alt with the key that follows otherwise (rxvt's Alt+arrows). ESC ESC is Alt+Escape alone, Escape before a paste, and Alt with the key that follows otherwise (rxvt's Alt+arrows).
Partial UTF-8 and escape sequences at read boundaries are reassembled in a persistent buffer. Partial UTF-8 and escape sequences at read boundaries are reassembled in a persistent buffer.
A cell is drawn as one glyph: a control character in `Rune`, such as a pasted escape, is drawn as a space.
With paste mode off, a paste marker is not a paste. A paste whose end marker never arrives ends once input pauses for a second, so what follows a paste that stalls that long arrives as keys. With paste mode off, a paste marker is not a paste. A paste whose end marker never arrives ends once input pauses for a second, so what follows a paste that stalls that long arrives as keys.
On Unix, a redirected stdin or stdout is replaced by `/dev/tty`, so On Unix, a redirected stdin or stdout is replaced by `/dev/tty`, so
+5 -5
View File
@@ -9,7 +9,7 @@ var (
// CSI sequences // CSI sequences
csi = []byte("\x1b[") csi = []byte("\x1b[")
csiClear = []byte("\x1b[2J\x1b[H") csiClear = []byte("\x1b[2J\x1b[H")
csiRIS = []byte("\x1bc") // Reset to Initial GameState (emergency) csiRIS = []byte("\x1bc") // Reset to Initial State (emergency)
csiSGR0 = []byte("\x1b[0m") csiSGR0 = []byte("\x1b[0m")
// Cursor control // Cursor control
@@ -17,9 +17,10 @@ var (
csiCursorShow = []byte("\x1b[?25h") csiCursorShow = []byte("\x1b[?25h")
csiCursorPos = []byte("\x1b[") // followed by row;colH csiCursorPos = []byte("\x1b[") // followed by row;colH
// Screen modes // Screen modes; the alternate screen also turns off the terminal's own bidi
csiAltScreenEnter = []byte("\x1b[?1049h") // reordering (ECMA-48 BDSM, mode 8), so right-to-left text keeps its cells
csiAltScreenExit = []byte("\x1b[?1049l") csiAltScreenEnter = []byte("\x1b[?1049h\x1b[8l")
csiAltScreenExit = []byte("\x1b[?1049l\x1b[8h")
// DECAWM: Auto-Wrap Mode // DECAWM: Auto-Wrap Mode
// ?7l disables wrapping (cursor sticks at right edge), preventing scroll when writing to bottom-right corner // ?7l disables wrapping (cursor sticks at right edge), preventing scroll when writing to bottom-right corner
csiAutoWrapOn = []byte("\x1b[?7h") csiAutoWrapOn = []byte("\x1b[?7h")
@@ -67,7 +68,6 @@ func writeInt(w *bufio.Writer, n int) {
w.WriteByte(byte(n%10) + '0') w.WriteByte(byte(n%10) + '0')
return return
} }
// Fallback for >999 (rare)
var buf [5]byte var buf [5]byte
i := 4 i := 4
for n > 0 { for n > 0 {
+95 -35
View File
@@ -40,7 +40,6 @@ func (wa writerAdapter) Write(p []byte) (int, error) {
return len(p), nil return len(p), nil
} }
// newOutputBuffer creates a new output buffer
func newOutputBuffer(backend Backend, colorMode ColorMode) *outputBuffer { func newOutputBuffer(backend Backend, colorMode ColorMode) *outputBuffer {
// Use 128KB buffer for minimal calls to backend // Use 128KB buffer for minimal calls to backend
adapter := writerAdapter{b: backend} adapter := writerAdapter{b: backend}
@@ -50,7 +49,6 @@ func newOutputBuffer(backend Backend, colorMode ColorMode) *outputBuffer {
} }
} }
// resize updates buffer dimensions
func (o *outputBuffer) resize(width, height int) { func (o *outputBuffer) resize(width, height int) {
size := width * height size := width * height
if cap(o.front) < size { if cap(o.front) < size {
@@ -68,9 +66,8 @@ func (o *outputBuffer) resize(width, height int) {
o.cursorValid = false o.cursorValid = false
} }
// cellEqual compares two cells for equality (standalone for inlining) // cellEqual compares the fields most likely to change first
func cellEqual(a, b Cell) bool { func cellEqual(a, b Cell) bool {
// A cell is only equal if every visual component matches, checking most likely changed fields first (Rune/Bg)
return a.Rune == b.Rune && return a.Rune == b.Rune &&
a.Bg == b.Bg && a.Bg == b.Bg &&
a.Fg == b.Fg && a.Fg == b.Fg &&
@@ -93,13 +90,12 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
for y := 0; y < height; y++ { for y := 0; y < height; y++ {
rowStart := y * width rowStart := y * width
// Early termination: find last dirty cell in row (scan backward)
rowEnd := width rowEnd := width
for rowEnd > 0 && cellEqual(cells[rowStart+rowEnd-1], o.front[rowStart+rowEnd-1]) { for rowEnd > 0 && cellEqual(cells[rowStart+rowEnd-1], o.front[rowStart+rowEnd-1]) {
rowEnd-- rowEnd--
} }
if rowEnd == 0 { if rowEnd == 0 {
continue // Entire row unchanged continue
} }
x := 0 x := 0
@@ -111,13 +107,11 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
continue continue
} }
// Found dirty cell - check for small gaps ahead to potentially merge segments
segStart := x segStart := x
segEnd := x + 1 segEnd := x + 1
// Extend segment through small gaps (≤3 unchanged cells) // A gap of up to 3 unchanged cells is rewritten, about the bytes of a cursor move
for segEnd < rowEnd { for segEnd < rowEnd {
// Find gap size
gapStart := segEnd gapStart := segEnd
for gapStart < rowEnd && cellEqual(cells[rowStart+gapStart], o.front[rowStart+gapStart]) { for gapStart < rowEnd && cellEqual(cells[rowStart+gapStart], o.front[rowStart+gapStart]) {
gapStart++ gapStart++
@@ -125,7 +119,6 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
gapSize := gapStart - segEnd gapSize := gapStart - segEnd
if gapSize == 0 { if gapSize == 0 {
// No gap, extend to next unchanged
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) { for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
segEnd++ segEnd++
} }
@@ -133,16 +126,15 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
} }
if gapSize > 3 { if gapSize > 3 {
break // Gap too large, end segment here break
} }
// Gap logic check: only bridge the gap if the gap cells have the same attributes as the current segment, otherwise, emit SGR codes inside the gap, making it more expensive than a cursor move // Only a gap in the segment's style: an SGR inside it costs more than the move
gapCompatible := true gapCompatible := true
refCell := cells[rowStart+segEnd-1] // The last dirty cell of the current segment refCell := cells[rowStart+segEnd-1]
for k := 0; k < gapSize; k++ { for k := 0; k < gapSize; k++ {
gCell := cells[rowStart+segEnd+k] gCell := cells[rowStart+segEnd+k]
// Strict equality on style/color to ensure no SGR emission
if gCell.Fg != refCell.Fg || gCell.Bg != refCell.Bg || gCell.Attrs != refCell.Attrs { if gCell.Fg != refCell.Fg || gCell.Bg != refCell.Bg || gCell.Attrs != refCell.Attrs {
gapCompatible = false gapCompatible = false
break break
@@ -150,41 +142,30 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
} }
if !gapCompatible { if !gapCompatible {
break // Gap has different style, cheaper to jump break
} }
// Check if there's more dirty content after gap
if gapStart >= rowEnd { if gapStart >= rowEnd {
break // Gap extends to row end break
} }
// Small gap with content after - include gap in segment
segEnd = gapStart segEnd = gapStart
// Continue to find more dirty cells
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) { for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
segEnd++ segEnd++
} }
} }
// Positions cursor to segment start
o.moveCursorTo(w, segStart, y) o.moveCursorTo(w, segStart, y)
// Write segment [segStart, segEnd)
for sx := segStart; sx < segEnd; sx++ { for sx := segStart; sx < segEnd; sx++ {
cidx := rowStart + sx cidx := rowStart + sx
c := cells[cidx] c := cells[cidx]
o.writeStyleCoalesced(w, c.Fg, c.Bg, c.Attrs) o.writeStyleCoalesced(w, c.Fg, c.Bg, c.Attrs)
if r := c.Rune; r >= ' ' && r < 0x7f {
// A cell holds one glyph, never a control the terminal would act on
r := c.Rune
if unicode.IsControl(r) || !utf8.ValidRune(r) {
r = ' '
}
if r < 0x80 {
w.WriteByte(byte(r)) w.WriteByte(byte(r))
} else { } else {
w.WriteRune(r) w.WriteRune(oneColumn(r))
} }
o.front[cidx] = c o.front[cidx] = c
@@ -200,7 +181,90 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
w.Flush() w.Flush()
} }
// cursorForwardCost returns byte cost of cursor forward sequence // oneColumn is r as a cell draws it: one glyph in one column, where the
// terminal draws ambiguous-width runes (box drawing, U+FFFD) narrow
func oneColumn(r rune) rune {
if uint32(r) < 0x10000 && plain[r>>6]&(1<<(r&63)) != 0 {
return r
}
if !utf8.ValidRune(r) {
return ' '
}
for _, k := range drawnAs {
if unicode.In(r, k.runes...) {
return k.as
}
}
return r
}
// drawnAs is the glyph a cell draws for each kind of rune that would not take
// one column: a space for one a terminal acts on, hides or reorders text by
// (a control, a format character, a line separator), U+FFFD for one it draws
// in none or two or joins to a neighbour (a mark, a wide or emoji rune)
var drawnAs = [...]struct {
as rune
runes []*unicode.RangeTable
}{
{' ', []*unicode.RangeTable{unicode.Cc, unicode.Cf, unicode.Zl, unicode.Zp}},
{utf8.RuneError, []*unicode.RangeTable{wideOrJoining, unicode.Mn, unicode.Me, unicode.Mc}},
}
// plain has a bit set for each BMP rune a cell draws as itself, so most text
// skips drawnAs's searches
var plain = func() (p [0x10000 >> 6]uint64) {
for i := range p {
if i < 0xD800>>6 || i >= 0xE000>>6 {
p[i] = ^uint64(0)
}
}
for _, k := range drawnAs {
for _, t := range k.runes {
for _, r := range t.R16 {
for c := int(r.Lo); c <= int(r.Hi); c += int(r.Stride) {
p[c>>6] &^= 1 << (c & 63)
}
}
}
}
return p
}()
// wideOrJoining is what a terminal draws in two columns, East Asian wide and
// fullwidth and emoji presentation as of Unicode 17, and the letters a
// grapheme cluster joins to a neighbour (Hangul jamo, Prepend, SpacingMark and
// Extend ones); a range spans the unassigned code points between its runes
var wideOrJoining = &unicode.RangeTable{
R16: []unicode.Range16{
{0x0D4E, 0x0D4E, 1}, {0x0E33, 0x0E33, 1}, {0x0EB3, 0x0EB3, 1},
{0x1100, 0x11FF, 1}, {0x231A, 0x231B, 1}, {0x2329, 0x232A, 1}, {0x23E9, 0x23EC, 1},
{0x23F0, 0x23F0, 1}, {0x23F3, 0x23F3, 1}, {0x25FD, 0x25FE, 1}, {0x2614, 0x2615, 1},
{0x2630, 0x2637, 1}, {0x2648, 0x2653, 1}, {0x267F, 0x267F, 1}, {0x268A, 0x268F, 1},
{0x2693, 0x2693, 1}, {0x26A1, 0x26A1, 1}, {0x26AA, 0x26AB, 1}, {0x26BD, 0x26BE, 1},
{0x26C4, 0x26C5, 1}, {0x26CE, 0x26CE, 1}, {0x26D4, 0x26D4, 1}, {0x26EA, 0x26EA, 1},
{0x26F2, 0x26F3, 1}, {0x26F5, 0x26F5, 1}, {0x26FA, 0x26FA, 1}, {0x26FD, 0x26FD, 1},
{0x2705, 0x2705, 1}, {0x270A, 0x270B, 1}, {0x2728, 0x2728, 1}, {0x274C, 0x274C, 1},
{0x274E, 0x274E, 1}, {0x2753, 0x2755, 1}, {0x2757, 0x2757, 1}, {0x2795, 0x2797, 1},
{0x27B0, 0x27B0, 1}, {0x27BF, 0x27BF, 1}, {0x2B1B, 0x2B1C, 1}, {0x2B50, 0x2B50, 1},
{0x2B55, 0x2B55, 1}, {0x2E80, 0x303E, 1}, {0x3041, 0x3247, 1}, {0x3250, 0xA4C6, 1},
{0xA960, 0xA97C, 1}, {0xAC00, 0xD7FF, 1}, {0xF900, 0xFAFF, 1}, {0xFE10, 0xFE19, 1},
{0xFE30, 0xFE6B, 1}, {0xFF01, 0xFF60, 1}, {0xFF9E, 0xFF9F, 1}, {0xFFE0, 0xFFE6, 1},
},
R32: []unicode.Range32{
{0x111C2, 0x111C3, 1}, {0x113D1, 0x113D1, 1}, {0x1193F, 0x1193F, 1}, {0x11941, 0x11941, 1},
{0x11A84, 0x11A89, 1}, {0x11D46, 0x11D46, 1}, {0x11F02, 0x11F02, 1},
{0x16FE0, 0x1B2FB, 1}, {0x1D300, 0x1D376, 1}, {0x1F004, 0x1F004, 1}, {0x1F0CF, 0x1F0CF, 1},
{0x1F18E, 0x1F18E, 1}, {0x1F191, 0x1F19A, 1}, {0x1F1E6, 0x1F320, 1}, {0x1F32D, 0x1F335, 1},
{0x1F337, 0x1F37C, 1}, {0x1F37E, 0x1F393, 1}, {0x1F3A0, 0x1F3CA, 1}, {0x1F3CF, 0x1F3D3, 1},
{0x1F3E0, 0x1F3F0, 1}, {0x1F3F4, 0x1F3F4, 1}, {0x1F3F8, 0x1F43E, 1}, {0x1F440, 0x1F440, 1},
{0x1F442, 0x1F4FC, 1}, {0x1F4FF, 0x1F53D, 1}, {0x1F54B, 0x1F54E, 1}, {0x1F550, 0x1F567, 1},
{0x1F57A, 0x1F57A, 1}, {0x1F595, 0x1F596, 1}, {0x1F5A4, 0x1F5A4, 1}, {0x1F5FB, 0x1F64F, 1},
{0x1F680, 0x1F6C5, 1}, {0x1F6CC, 0x1F6CC, 1}, {0x1F6D0, 0x1F6D2, 1}, {0x1F6D5, 0x1F6DF, 1},
{0x1F6EB, 0x1F6EC, 1}, {0x1F6F4, 0x1F6FC, 1}, {0x1F7E0, 0x1F7F0, 1}, {0x1F90C, 0x1F93A, 1},
{0x1F93C, 0x1F945, 1}, {0x1F947, 0x1F9FF, 1}, {0x1FA70, 0x1FAF8, 1}, {0x20000, 0x3FFFF, 1},
},
}
func cursorForwardCost(n int) int { func cursorForwardCost(n int) int {
if n == 1 { if n == 1 {
return 3 // \x1b[C return 3 // \x1b[C
@@ -208,13 +272,12 @@ func cursorForwardCost(n int) int {
return 3 + digitCount(n) // \x1b[nC return 3 + digitCount(n) // \x1b[nC
} }
// cursorAbsoluteCost returns byte cost of absolute cursor position
func cursorAbsoluteCost(x, y int) int { func cursorAbsoluteCost(x, y int) int {
// \x1b[row;colH = 2 + digits(row) + 1 + digits(col) + 1 // \x1b[row;colH = 2 + digits(row) + 1 + digits(col) + 1
return 4 + digitCount(y+1) + digitCount(x+1) return 4 + digitCount(y+1) + digitCount(x+1)
} }
// digitCount returns number of decimal digits in n // digitCount caps at 4: no terminal is 10000 cells across
func digitCount(n int) int { func digitCount(n int) int {
if n < 10 { if n < 10 {
return 1 return 1
@@ -260,7 +323,6 @@ func (o *outputBuffer) writeStyleCoalesced(w *bufio.Writer, fg, bg color.RGB, at
if o.colorMode == ColorModeNone { if o.colorMode == ColorModeNone {
fg, bg, attr = color.RGB{}, color.RGB{}, attr&AttrStyle fg, bg, attr = color.RGB{}, color.RGB{}, attr&AttrStyle
} }
// Check what changed
fgChanged := !o.lastValid || fg != o.lastFg || (attr&AttrFgColor) != (o.lastAttr&AttrFgColor) fgChanged := !o.lastValid || fg != o.lastFg || (attr&AttrFgColor) != (o.lastAttr&AttrFgColor)
bgChanged := !o.lastValid || bg != o.lastBg || (attr&AttrBgColor) != (o.lastAttr&AttrBgColor) bgChanged := !o.lastValid || bg != o.lastBg || (attr&AttrBgColor) != (o.lastAttr&AttrBgColor)
styleAttr := attr & AttrStyle styleAttr := attr & AttrStyle
@@ -348,7 +410,6 @@ func (o *outputBuffer) writeColor(w *bufio.Writer, c color.RGB, palette, deflt b
} }
} }
// forceFullRedraw clears front buffer to force complete redraw
func (o *outputBuffer) forceFullRedraw() { func (o *outputBuffer) forceFullRedraw() {
for i := range o.front { for i := range o.front {
o.front[i] = Cell{Rune: 0} o.front[i] = Cell{Rune: 0}
@@ -376,7 +437,6 @@ func (o *outputBuffer) clear(bg color.RGB, attr Attr) {
} }
} }
// invalidateCursor marks cursor position as unknown
func (o *outputBuffer) invalidateCursor() { func (o *outputBuffer) invalidateCursor() {
o.cursorValid = false o.cursorValid = false
} }
+28 -6
View File
@@ -89,18 +89,36 @@ func TestColorModesWriteWhatTheTerminalReads(t *testing.T) {
} }
} }
// A cell is drawn as one glyph: a control character, C0 or C1, that a // A cell is drawn as one glyph: a control character, C0 or C1, a format one
// pasted value carried in, or no rune at all, is drawn as a space, never // (a bidi control, a zero-width space) or a line separator that a pasted
// sent to the terminal // value or a log line carried in, or no rune at all, is drawn as a space,
// never sent to the terminal
func TestCellsSendNoControlCharacters(t *testing.T) { func TestCellsSendNoControlCharacters(t *testing.T) {
f := &fakeBackend{} f := &fakeBackend{}
o := newOutputBuffer(f, ColorMode256) o := newOutputBuffer(f, ColorMode256)
o.flush([]Cell{{Rune: 0x1b}, {Rune: 0x9b}, {Rune: 0x7f}, {Rune: -229}, {Rune: 'é'}}, 5, 1) o.flush([]Cell{{Rune: 0x1b}, {Rune: 0x9b}, {Rune: 0x7f}, {Rune: -229}, {Rune: 0xd800}, {Rune: 0x202e}, {Rune: 0x2067}, {Rune: 0x061c}, {Rune: 0x200b}, {Rune: 0x2028}, {Rune: 'é'}}, 11, 1)
if got, want := f.written(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m é\x1b[0m"; got != want { if got, want := f.written(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m é\x1b[0m"; got != want {
t.Fatalf("%q, want %q", got, want) t.Fatalf("%q, want %q", got, want)
} }
} }
// A cell is one column: a rune a terminal draws in none (a combining mark, a
// joining jamo), in two (wide, fullwidth, emoji) or in its neighbour's cluster
// (a spacing mark, a letter such as Thai's sara am) is drawn as U+FFFD, any
// other as itself
func TestEachCellIsOneColumn(t *testing.T) {
f := &fakeBackend{}
o := newOutputBuffer(f, ColorMode256)
var row []Cell
for _, r := range "\u0301\u20dd\u1161漢A😀✅\U0001f1fa\u093e\u0e33é─❤" {
row = append(row, Cell{Rune: r})
}
o.flush(row, len(row), 1)
if got, want := f.written(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m"+strings.Repeat("\ufffd", 10)+"é─❤\x1b[0m"; got != want {
t.Fatalf("%q, want %q", got, want)
}
}
// 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
@@ -187,8 +205,9 @@ func TestPasteModeReachesTheReader(t *testing.T) {
} }
} }
// Paste mode ends with the session, and Init clears to the terminal's own // The terminal's own bidi reordering is off while the session runs, paste
// background rather than painting it black // mode ends with it, and Init clears to the terminal's own background rather
// than painting it black
func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) { func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
f := &fakeBackend{} f := &fakeBackend{}
term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)} term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)}
@@ -205,4 +224,7 @@ func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
!strings.Contains(got[strings.Index(got, "\x1b[?2004h"):], "\x1b[?2004l") { !strings.Contains(got[strings.Index(got, "\x1b[?2004h"):], "\x1b[?2004l") {
t.Fatalf("%q", got) t.Fatalf("%q", got)
} }
if i := strings.Index(got, "\x1b[8l"); i < 0 || !strings.Contains(got[i:], "\x1b[8h") {
t.Fatalf("bidi reordering not off for the session: %q", got)
}
} }
+7 -5
View File
@@ -125,9 +125,11 @@ draws alike in each: `DefaultTheme` is dark true color, `Theme16` uses the
ANSI 16 on the terminal's own background, `MonoTheme` uses attributes ANSI 16 on the terminal's own background, `MonoTheme` uses attributes
alone, and `ThemeFor(term.ColorMode())` picks among them. A selection alone, and `ThemeFor(term.ColorMode())` picks among them. A selection
always changes a glyph (a radio mark, a pointer, the focus mark), so it reads always changes a glyph (a radio mark, a pointer, the focus mark), so it reads
without color. `GlyphsUnicode`, `GlyphsCP437` (text consoles) and without color. A reverse-video `Selected` with no color of its own
`GlyphsASCII` (outside UTF-8) are the glyph sets; `Glyphs.Line` is the line (`Theme16`, `MonoTheme`) keeps the terminal's own foreground in every cell
type of frames, rules and wires. drawn on it, so the cursor row is one bar. `GlyphsUnicode`, `GlyphsCP437`
(text consoles) and `GlyphsASCII` (outside UTF-8) are the glyph sets;
`Glyphs.Line` is the line type of frames, rules and wires.
```go ```go
th := tui.DefaultTheme th := tui.DefaultTheme
@@ -257,8 +259,8 @@ Pure logic, no rendering — usable independently:
## Notes ## Notes
- Width calculations count runes, not terminal columns; East Asian wide - Width calculations count runes, one column each, as `Flush` draws a wide or
characters and combining marks are not width-aware. joining rune as U+FFFD.
- Zero-value `color.RGB` in style fields generally means "inherit" - Zero-value `color.RGB` in style fields generally means "inherit"
(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
+7 -1
View File
@@ -25,11 +25,17 @@ func (s Style) IsZero() bool {
} }
// On returns s drawn on bg: s's foreground, bg's background (s's own where // On returns s drawn on bg: s's foreground, bg's background (s's own where
// bg's is transparent), and the attributes of both // bg's is transparent), and the attributes of both. On a reverse bg with no
// color of its own, s keeps only its background, with the terminal's own
// foreground and bg's styles: a reversed cell's background is its foreground
// (a console's keeps its intensity), so mixed styles would break the bar.
func (s Style) On(bg Style) Style { func (s Style) On(bg Style) Style {
out := Style{Fg: s.Fg, Bg: s.Bg, Attr: s.Attr | bg.Attr&terminal.AttrStyle} out := Style{Fg: s.Fg, Bg: s.Bg, Attr: s.Attr | bg.Attr&terminal.AttrStyle}
if bg.Bg != (color.RGB{}) || bg.Attr&terminal.AttrBgColor != 0 { if bg.Bg != (color.RGB{}) || bg.Attr&terminal.AttrBgColor != 0 {
out.Bg, out.Attr = bg.Bg, out.Attr&^terminal.AttrBgColor|bg.Attr&terminal.AttrBgColor out.Bg, out.Attr = bg.Bg, out.Attr&^terminal.AttrBgColor|bg.Attr&terminal.AttrBgColor
} }
if bg.Attr&terminal.AttrReverse != 0 && bg.Bg == (color.RGB{}) && bg.Attr&terminal.AttrBg256 == 0 {
out.Fg, out.Attr = color.RGB{}, terminal.AttrFgDefault|out.Attr&terminal.AttrBgColor|bg.Attr&terminal.AttrStyle
}
return out return out
} }
+2 -18
View File
@@ -5,16 +5,12 @@ import (
"unicode/utf8" "unicode/utf8"
) )
// --- Length calculation --- // RuneLen returns s's width in cells, a rune each: Flush draws every rune in
// one column, a wide or joining one as U+FFFD
// RuneLen returns rune count, used as display width under the package-wide assumption of 1 cell per rune
// Wide (CJK), emoji, and combining characters are not handled
func RuneLen(s string) int { func RuneLen(s string) int {
return utf8.RuneCountInString(s) return utf8.RuneCountInString(s)
} }
// --- Truncation ---
// Truncate truncates string with … suffix if exceeds maxLen // Truncate truncates string with … suffix if exceeds maxLen
func Truncate(s string, maxLen int) string { func Truncate(s string, maxLen int) string {
if maxLen <= 0 { if maxLen <= 0 {
@@ -63,16 +59,12 @@ func TruncateMiddle(s string, maxLen int) string {
return Truncate(s, maxLen) return Truncate(s, maxLen)
} }
// Split remaining space between start and end
// Favor start slightly: (maxLen-1)/2 for start, rest for end
startLen := (maxLen - 1) / 2 startLen := (maxLen - 1) / 2
endLen := maxLen - 1 - startLen endLen := maxLen - 1 - startLen
return string(runes[:startLen]) + "…" + string(runes[len(runes)-endLen:]) return string(runes[:startLen]) + "…" + string(runes[len(runes)-endLen:])
} }
// --- Padding ---
// PadRight pads string with spaces to width // PadRight pads string with spaces to width
func PadRight(s string, width int) string { func PadRight(s string, width int) string {
runes := []rune(s) runes := []rune(s)
@@ -117,8 +109,6 @@ func PadCenter(s string, width int) string {
return string(result) return string(result)
} }
// --- Text wrapping ---
// WrapText wraps text to fit width: at each line break, at spaces, or, in a // WrapText wraps text to fit width: at each line break, at spaces, or, in a
// word longer than a line, after its last '.', ',', ':', '/', '[' or ']' // word longer than a line, after its last '.', ',', ':', '/', '[' or ']'
// that fits (a path or a key such as a[0].b); a word without one is cut. // that fits (a path or a key such as a[0].b); a word without one is cut.
@@ -146,17 +136,14 @@ func wrapLine(s string, width int) []string {
lastSpace, lastMark := -1, -1 lastSpace, lastMark := -1, -1
for i := 0; i <= len(runes); i++ { for i := 0; i <= len(runes); i++ {
// Check if we need to wrap
if i-lineStart >= width || i == len(runes) { if i-lineStart >= width || i == len(runes) {
if i == len(runes) { if i == len(runes) {
// End of string
if lineStart < len(runes) { if lineStart < len(runes) {
lines = append(lines, string(runes[lineStart:])) lines = append(lines, string(runes[lineStart:]))
} }
break break
} }
// Need to wrap
wrapAt := i wrapAt := i
switch { switch {
case lastSpace > lineStart: case lastSpace > lineStart:
@@ -167,7 +154,6 @@ func wrapLine(s string, width int) []string {
lines = append(lines, string(runes[lineStart:wrapAt])) lines = append(lines, string(runes[lineStart:wrapAt]))
// Skip space at wrap point
if wrapAt < len(runes) && runes[wrapAt] == ' ' { if wrapAt < len(runes) && runes[wrapAt] == ' ' {
lineStart = wrapAt + 1 lineStart = wrapAt + 1
} else { } else {
@@ -192,8 +178,6 @@ func wrapLine(s string, width int) []string {
return lines return lines
} }
// --- Repetition ---
// RepeatRune returns a string of n repeated runes, for string repeat use strings.Repeat // RepeatRune returns a string of n repeated runes, for string repeat use strings.Repeat
func RepeatRune(r rune, n int) string { func RepeatRune(r rune, n int) string {
if n <= 0 { if n <= 0 {
+21 -1
View File
@@ -37,12 +37,32 @@ func TestTransparentBackgroundKeepsItsKind(t *testing.T) {
if c := r.Cells[0]; c.Bg != panel.Bg || c.Attrs != terminal.AttrBold|panel.Attr&terminal.AttrBgColor { if c := r.Cells[0]; c.Bg != panel.Bg || c.Attrs != terminal.AttrBold|panel.Attr&terminal.AttrBgColor {
t.Errorf("on %+v: %+v", panel, c) t.Errorf("on %+v: %+v", panel, c)
} }
if on := panel.On(Style{Attr: terminal.AttrReverse}); on.Bg != panel.Bg || on.Attr != panel.Attr|terminal.AttrReverse { if on := panel.On(Style{Attr: terminal.AttrReverse}); on.Bg != panel.Bg || on.Attr != panel.Attr&terminal.AttrBgColor|terminal.AttrFgDefault|terminal.AttrReverse {
t.Errorf("%+v on reverse: %+v", panel, on) t.Errorf("%+v on reverse: %+v", panel, on)
} }
} }
} }
// A reverse cursor row is one bar: its pointer, key, name, hint and blanks
// take one style, whatever each draws in off the row, where they keep it
func TestReverseCursorRowIsOneStyle(t *testing.T) {
s := NewOptionListState([]Option{{Name: "file", Hint: "rotating files", Key: 'f'}, {Name: "tcp", Key: 't'}})
s.Menu = true
for name, th := range map[string]Theme{"16 colors": Theme16, "mono": MonoTheme} {
r := canvas(40, 2, th)
r.OptionList(s, th)
for x, c := range r.Cells[:r.W] {
if first := r.Cells[0]; c.Fg != first.Fg || c.Bg != first.Bg || c.Attrs != first.Attrs {
t.Errorf("%s %q: column %d %+v, column 0 %+v", name, row(r, 0), x, c, first)
break
}
}
if c, a := r.Cells[r.W+2], th.Accent; c.Rune != 't' || c.Fg != a.Fg || c.Attrs&^terminal.AttrBgColor != a.Attr {
t.Errorf("%s %q: the key off the cursor row %+v, want %+v", name, row(r, 1), c, a)
}
}
}
// Every widget marks its selection with a glyph, so it reads with no color // Every widget marks its selection with a glyph, so it reads with no color
func TestSelectionChangesAGlyph(t *testing.T) { func TestSelectionChangesAGlyph(t *testing.T) {
for _, th := range []Theme{DefaultTheme, Theme16, MonoTheme} { for _, th := range []Theme{DefaultTheme, Theme16, MonoTheme} {