v0.2.2 tui update, reverse row bar
This commit is contained in:
8 files changed
+183
-86
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
+29
-7
@@ -89,14 +89,32 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
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
|
// 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
@@ -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
@@ -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
@@ -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
@@ -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} {
|
||||||
|
|||||||
Reference in new issue
Block a user