Sidebar rows that overflow the rail width used to spill characters into the main viewport. They now truncate with a trailing "…" when unfocused (or when the focused name still fits). The focused row whose name overflows runs a pause-scroll-pause marquee: 1 s hold on the head, ~150 ms per cell scroll, 1 s hold on the tail, snap back. The row's geometry never moves while it animates, so nothing below shifts. A dedicated 150 ms goroutine flips sidebarDirty only while a row is actively animating; the chrome ticker does the actual repaint. Idle is a single cheap wakeup. focus / spawn / exit / restart all reset the marquee state so the new focused row starts from frame zero. When the row's budget is tight, the trailing timer indicator drops before the name ellipses since the name is the only identifier the row carries. clampVisible() is a defensive net inside write(): even if a row's decoration size were mis-computed, it will not spill past the sidebar band into the PTY area.
162 lines
4.5 KiB
Go
162 lines
4.5 KiB
Go
package app
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestMarqueeStepFits(t *testing.T) {
|
|
var m marqueeState
|
|
now := time.Unix(0, 0)
|
|
off, animating, _ := m.step("a", 5, 10, now)
|
|
if animating {
|
|
t.Fatalf("expected no animation when name fits in budget")
|
|
}
|
|
if off != 0 {
|
|
t.Fatalf("expected offset 0, got %d", off)
|
|
}
|
|
}
|
|
|
|
func TestMarqueePhaseProgression(t *testing.T) {
|
|
var m marqueeState
|
|
// name 10 runes, budget 5 → maxOffset = 5.
|
|
const nameLen, budget = 10, 5
|
|
t0 := time.Unix(0, 0)
|
|
|
|
// At t0: phaseHoldStart, offset 0, animating.
|
|
off, anim, wake := m.step("row", nameLen, budget, t0)
|
|
if off != 0 || !anim || wake != marqueeHoldStart {
|
|
t.Fatalf("t0: off=%d anim=%v wake=%v", off, anim, wake)
|
|
}
|
|
|
|
// Just before hold expires: still offset 0.
|
|
off, anim, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart-time.Millisecond))
|
|
if off != 0 || !anim {
|
|
t.Fatalf("pre-expiry hold: off=%d anim=%v", off, anim)
|
|
}
|
|
|
|
// At hold expiry + 1 step: should have transitioned to scroll, offset 1.
|
|
off, anim, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+marqueeStep))
|
|
if !anim || off != 1 {
|
|
t.Fatalf("first scroll step: off=%d anim=%v", off, anim)
|
|
}
|
|
|
|
// Mid-scroll: offset == 3.
|
|
off, _, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+3*marqueeStep))
|
|
if off != 3 {
|
|
t.Fatalf("mid scroll: off=%d", off)
|
|
}
|
|
|
|
// Tail reached: offset == maxOffset == 5.
|
|
off, _, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+5*marqueeStep+time.Millisecond))
|
|
if off != 5 {
|
|
t.Fatalf("tail: off=%d", off)
|
|
}
|
|
|
|
// Hold-end window still pegged at maxOffset.
|
|
off, _, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+5*marqueeStep+marqueeHoldEnd/2))
|
|
if off != 5 {
|
|
t.Fatalf("hold-end mid: off=%d", off)
|
|
}
|
|
|
|
// After hold-end: snap back to offset 0.
|
|
off, _, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+5*marqueeStep+marqueeHoldEnd+time.Millisecond))
|
|
if off != 0 {
|
|
t.Fatalf("snap back: off=%d", off)
|
|
}
|
|
}
|
|
|
|
func TestMarqueeIDChangeResets(t *testing.T) {
|
|
var m marqueeState
|
|
t0 := time.Unix(0, 0)
|
|
_, _, _ = m.step("a", 10, 5, t0)
|
|
// Advance well into scroll for row "a".
|
|
_, _, _ = m.step("a", 10, 5, t0.Add(marqueeHoldStart+3*marqueeStep))
|
|
// Now focus moves to "b": offset must reset to 0 and phase to hold-start.
|
|
off, anim, wake := m.step("b", 10, 5, t0.Add(marqueeHoldStart+3*marqueeStep))
|
|
if off != 0 || !anim || wake != marqueeHoldStart {
|
|
t.Fatalf("id reset: off=%d anim=%v wake=%v", off, anim, wake)
|
|
}
|
|
}
|
|
|
|
func TestMarqueeActive(t *testing.T) {
|
|
var m marqueeState
|
|
if m.active() {
|
|
t.Fatalf("fresh marquee should not be active")
|
|
}
|
|
_, _, _ = m.step("row", 10, 5, time.Unix(0, 0))
|
|
if !m.active() {
|
|
t.Fatalf("expected active after overflow step")
|
|
}
|
|
_, _, _ = m.step("row", 4, 5, time.Unix(0, 0))
|
|
if m.active() {
|
|
t.Fatalf("should not be active when name fits")
|
|
}
|
|
}
|
|
|
|
func TestMarqueeReset(t *testing.T) {
|
|
var m marqueeState
|
|
_, _, _ = m.step("row", 10, 5, time.Unix(0, 0))
|
|
m.reset()
|
|
if m.active() {
|
|
t.Fatalf("expected inactive after reset")
|
|
}
|
|
// After reset, stepping the same id starts fresh.
|
|
off, _, wake := m.step("row", 10, 5, time.Unix(5, 0))
|
|
if off != 0 || wake != marqueeHoldStart {
|
|
t.Fatalf("post-reset start: off=%d wake=%v", off, wake)
|
|
}
|
|
}
|
|
|
|
func TestFitName(t *testing.T) {
|
|
cases := []struct {
|
|
name, in string
|
|
budget int
|
|
want string
|
|
}{
|
|
{"fits", "abc", 5, "abc"},
|
|
{"exact", "abcde", 5, "abcde"},
|
|
{"truncate", "abcdef", 5, "abcd…"},
|
|
{"budget1", "abcdef", 1, "…"},
|
|
{"budget0", "abc", 0, ""},
|
|
{"unicode", "αβγδεζη", 4, "αβγ…"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := fitName(c.in, c.budget)
|
|
if got != c.want {
|
|
t.Fatalf("fitName(%q, %d) = %q want %q", c.in, c.budget, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMarqueeWindow(t *testing.T) {
|
|
got := marqueeWindow("abcdefgh", 4, 2)
|
|
if got != "cdef" {
|
|
t.Fatalf("window = %q", got)
|
|
}
|
|
// Clamp end-of-string overflow.
|
|
got = marqueeWindow("abcdef", 4, 10)
|
|
if got != "cdef" {
|
|
t.Fatalf("clamped window = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestClampVisible(t *testing.T) {
|
|
// Plain string longer than width.
|
|
if got := clampVisible("abcdef", 3); visibleLen(got) != 3 {
|
|
t.Fatalf("plain clamp visible = %d (%q)", visibleLen(got), got)
|
|
}
|
|
// Already-fitting string is unchanged.
|
|
if got := clampVisible("abc", 5); got != "abc" {
|
|
t.Fatalf("unchanged = %q", got)
|
|
}
|
|
// SGR-wrapped string: visible portion must be <= width.
|
|
in := "\x1b[1mhello\x1b[0m world"
|
|
got := clampVisible(in, 5)
|
|
if visibleLen(got) != 5 {
|
|
t.Fatalf("sgr clamp visible = %d (%q)", visibleLen(got), got)
|
|
}
|
|
}
|