From c657af606d3462f060d57511de3c0ba608e09ea7 Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Sat, 10 Oct 2026 18:04:30 -0400 Subject: [PATCH] v0.2.2 tui update, reverse row bar --- README.md | 31 ++++++----- ansi.go | 10 ++-- output.go | 130 +++++++++++++++++++++++++++++++++------------ terminal_test.go | 36 ++++++++++--- tui/README.md | 12 +++-- tui/style.go | 8 ++- tui/text.go | 20 +------ tui/widget_test.go | 22 +++++++- 8 files changed, 183 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index 73eab46..131cd2e 100644 --- a/README.md +++ b/README.md @@ -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 is dropped to prevent resize-race corruption. The next frame (built at the new 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 redraw — required after any external process writes to the terminal. -Auto-wrap is disabled during the session, making the bottom-right cell writable -without scroll side effects. +Auto-wrap and, where the terminal follows ECMA-48 mode 8 (VTE), its own bidi +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 @@ -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 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 | -| `Screen(dst, src, alpha)` | `1-(1-d)(1-s)` | lightens, never clips | -| `Overlay(dst, src, alpha)` | multiply/screen split at 0.5 | contrast, keeps dst structure | -| `SoftLight(dst, src, intensity)` | Perez soft light | gentle tint/glow | -| `Max(dst, src, alpha)` | per-channel max | non-additive highlight | -| `Scale(c, factor)` | channel multiply | dim/brighten | -| `Grayscale(c)` | Rec. 601 luma | desaturation | -| `c.Lerp(other, t)` | method on `RGB` | gradients, animation | +- `Blend(dst, src, alpha)`: linear interpolation, standard transparency +- `Add(dst, src, alpha)`: saturating add, bright accumulation, clips +- `Screen(dst, src, alpha)`: `1-(1-d)(1-s)`, lightens, never clips +- `Overlay(dst, src, alpha)`: multiply/screen split at 0.5, contrast, keeps dst structure +- `SoftLight(dst, src, intensity)`: Perez soft light, gentle tint/glow +- `Max(dst, src, alpha)`: per-channel max, non-additive highlight +- `Scale(c, factor)`: channel multiply, dim/brighten +- `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 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). 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. -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. On Unix, a redirected stdin or stdout is replaced by `/dev/tty`, so diff --git a/ansi.go b/ansi.go index 022aa6f..35130c4 100644 --- a/ansi.go +++ b/ansi.go @@ -9,7 +9,7 @@ var ( // CSI sequences csi = []byte("\x1b[") 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") // Cursor control @@ -17,9 +17,10 @@ var ( csiCursorShow = []byte("\x1b[?25h") csiCursorPos = []byte("\x1b[") // followed by row;colH - // Screen modes - csiAltScreenEnter = []byte("\x1b[?1049h") - csiAltScreenExit = []byte("\x1b[?1049l") + // Screen modes; the alternate screen also turns off the terminal's own bidi + // reordering (ECMA-48 BDSM, mode 8), so right-to-left text keeps its cells + csiAltScreenEnter = []byte("\x1b[?1049h\x1b[8l") + csiAltScreenExit = []byte("\x1b[?1049l\x1b[8h") // DECAWM: Auto-Wrap Mode // ?7l disables wrapping (cursor sticks at right edge), preventing scroll when writing to bottom-right corner csiAutoWrapOn = []byte("\x1b[?7h") @@ -67,7 +68,6 @@ func writeInt(w *bufio.Writer, n int) { w.WriteByte(byte(n%10) + '0') return } - // Fallback for >999 (rare) var buf [5]byte i := 4 for n > 0 { diff --git a/output.go b/output.go index 4a38d19..c61f61b 100644 --- a/output.go +++ b/output.go @@ -40,7 +40,6 @@ func (wa writerAdapter) Write(p []byte) (int, error) { return len(p), nil } -// newOutputBuffer creates a new output buffer func newOutputBuffer(backend Backend, colorMode ColorMode) *outputBuffer { // Use 128KB buffer for minimal calls to 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) { size := width * height if cap(o.front) < size { @@ -68,9 +66,8 @@ func (o *outputBuffer) resize(width, height int) { 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 { - // A cell is only equal if every visual component matches, checking most likely changed fields first (Rune/Bg) return a.Rune == b.Rune && a.Bg == b.Bg && a.Fg == b.Fg && @@ -93,13 +90,12 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) { for y := 0; y < height; y++ { rowStart := y * width - // Early termination: find last dirty cell in row (scan backward) rowEnd := width for rowEnd > 0 && cellEqual(cells[rowStart+rowEnd-1], o.front[rowStart+rowEnd-1]) { rowEnd-- } if rowEnd == 0 { - continue // Entire row unchanged + continue } x := 0 @@ -111,13 +107,11 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) { continue } - // Found dirty cell - check for small gaps ahead to potentially merge segments segStart := x 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 { - // Find gap size gapStart := segEnd for gapStart < rowEnd && cellEqual(cells[rowStart+gapStart], o.front[rowStart+gapStart]) { gapStart++ @@ -125,7 +119,6 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) { gapSize := gapStart - segEnd if gapSize == 0 { - // No gap, extend to next unchanged for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) { segEnd++ } @@ -133,16 +126,15 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) { } 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 - refCell := cells[rowStart+segEnd-1] // The last dirty cell of the current segment + refCell := cells[rowStart+segEnd-1] for k := 0; k < gapSize; 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 { gapCompatible = false break @@ -150,41 +142,30 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) { } if !gapCompatible { - break // Gap has different style, cheaper to jump + break } - // Check if there's more dirty content after gap if gapStart >= rowEnd { - break // Gap extends to row end + break } - // Small gap with content after - include gap in segment segEnd = gapStart - // Continue to find more dirty cells for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) { segEnd++ } } - // Positions cursor to segment start o.moveCursorTo(w, segStart, y) - // Write segment [segStart, segEnd) for sx := segStart; sx < segEnd; sx++ { cidx := rowStart + sx c := cells[cidx] o.writeStyleCoalesced(w, c.Fg, c.Bg, c.Attrs) - - // 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 { + if r := c.Rune; r >= ' ' && r < 0x7f { w.WriteByte(byte(r)) } else { - w.WriteRune(r) + w.WriteRune(oneColumn(r)) } o.front[cidx] = c @@ -200,7 +181,90 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) { 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 { if n == 1 { return 3 // \x1b[C @@ -208,13 +272,12 @@ func cursorForwardCost(n int) int { return 3 + digitCount(n) // \x1b[nC } -// cursorAbsoluteCost returns byte cost of absolute cursor position func cursorAbsoluteCost(x, y int) int { // \x1b[row;colH = 2 + digits(row) + 1 + digits(col) + 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 { if n < 10 { return 1 @@ -260,7 +323,6 @@ func (o *outputBuffer) writeStyleCoalesced(w *bufio.Writer, fg, bg color.RGB, at if o.colorMode == ColorModeNone { fg, bg, attr = color.RGB{}, color.RGB{}, attr&AttrStyle } - // Check what changed fgChanged := !o.lastValid || fg != o.lastFg || (attr&AttrFgColor) != (o.lastAttr&AttrFgColor) bgChanged := !o.lastValid || bg != o.lastBg || (attr&AttrBgColor) != (o.lastAttr&AttrBgColor) 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() { for i := range o.front { 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() { o.cursorValid = false } diff --git a/terminal_test.go b/terminal_test.go index 29d0ff6..b8dcea8 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -89,14 +89,32 @@ func TestColorModesWriteWhatTheTerminalReads(t *testing.T) { } } -// A cell is drawn as one glyph: a control character, C0 or C1, that a -// pasted value carried in, or no rune at all, is drawn as a space, never -// sent to the terminal +// A cell is drawn as one glyph: a control character, C0 or C1, a format one +// (a bidi control, a zero-width space) or a line separator that a pasted +// 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) { f := &fakeBackend{} o := newOutputBuffer(f, ColorMode256) - o.flush([]Cell{{Rune: 0x1b}, {Rune: 0x9b}, {Rune: 0x7f}, {Rune: -229}, {Rune: 'é'}}, 5, 1) - if got, want := f.written(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m é\x1b[0m"; got != want { + 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 { + 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) } } @@ -187,8 +205,9 @@ func TestPasteModeReachesTheReader(t *testing.T) { } } -// Paste mode ends with the session, and Init clears to the terminal's own -// background rather than painting it black +// The terminal's own bidi reordering is off while the session runs, paste +// mode ends with it, and Init clears to the terminal's own background rather +// than painting it black func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) { f := &fakeBackend{} 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") { 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) + } } diff --git a/tui/README.md b/tui/README.md index 56d2360..5100e20 100644 --- a/tui/README.md +++ b/tui/README.md @@ -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 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 -without color. `GlyphsUnicode`, `GlyphsCP437` (text consoles) and -`GlyphsASCII` (outside UTF-8) are the glyph sets; `Glyphs.Line` is the line -type of frames, rules and wires. +without color. A reverse-video `Selected` with no color of its own +(`Theme16`, `MonoTheme`) keeps the terminal's own foreground in every cell +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 th := tui.DefaultTheme @@ -257,8 +259,8 @@ Pure logic, no rendering — usable independently: ## Notes -- Width calculations count runes, not terminal columns; East Asian wide - characters and combining marks are not width-aware. +- Width calculations count runes, one column each, as `Flush` draws a wide or + joining rune as U+FFFD. - Zero-value `color.RGB` in style fields generally means "inherit" (widget default or row background) — check specific widget docs. A zero background with no Bg color bits is transparent: the cell keeps the diff --git a/tui/style.go b/tui/style.go index 268708d..e813e91 100644 --- a/tui/style.go +++ b/tui/style.go @@ -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 -// 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 { 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 { 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 } diff --git a/tui/text.go b/tui/text.go index 0b41389..054a1d8 100644 --- a/tui/text.go +++ b/tui/text.go @@ -5,16 +5,12 @@ import ( "unicode/utf8" ) -// --- Length calculation --- - -// 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 +// 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 func RuneLen(s string) int { return utf8.RuneCountInString(s) } -// --- Truncation --- - // Truncate truncates string with … suffix if exceeds maxLen func Truncate(s string, maxLen int) string { if maxLen <= 0 { @@ -63,16 +59,12 @@ func TruncateMiddle(s string, maxLen int) string { 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 endLen := maxLen - 1 - startLen return string(runes[:startLen]) + "…" + string(runes[len(runes)-endLen:]) } -// --- Padding --- - // PadRight pads string with spaces to width func PadRight(s string, width int) string { runes := []rune(s) @@ -117,8 +109,6 @@ func PadCenter(s string, width int) string { return string(result) } -// --- Text wrapping --- - // WrapText wraps text to fit width: at each line break, at spaces, or, in a // 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. @@ -146,17 +136,14 @@ func wrapLine(s string, width int) []string { lastSpace, lastMark := -1, -1 for i := 0; i <= len(runes); i++ { - // Check if we need to wrap if i-lineStart >= width || i == len(runes) { if i == len(runes) { - // End of string if lineStart < len(runes) { lines = append(lines, string(runes[lineStart:])) } break } - // Need to wrap wrapAt := i switch { case lastSpace > lineStart: @@ -167,7 +154,6 @@ func wrapLine(s string, width int) []string { lines = append(lines, string(runes[lineStart:wrapAt])) - // Skip space at wrap point if wrapAt < len(runes) && runes[wrapAt] == ' ' { lineStart = wrapAt + 1 } else { @@ -192,8 +178,6 @@ func wrapLine(s string, width int) []string { return lines } -// --- Repetition --- - // RepeatRune returns a string of n repeated runes, for string repeat use strings.Repeat func RepeatRune(r rune, n int) string { if n <= 0 { diff --git a/tui/widget_test.go b/tui/widget_test.go index 7b1530f..386d9e5 100644 --- a/tui/widget_test.go +++ b/tui/widget_test.go @@ -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 { 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) } } } +// 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 func TestSelectionChangesAGlyph(t *testing.T) { for _, th := range []Theme{DefaultTheme, Theme16, MonoTheme} {