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.
124 lines
3.1 KiB
Go
124 lines
3.1 KiB
Go
package app
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Phase ordering of the marquee state machine: hold the head, scroll
|
|
// one cell per marqueeStep until the tail is visible, hold the tail,
|
|
// snap back to the head.
|
|
const (
|
|
phaseHoldStart = iota
|
|
phaseScroll
|
|
phaseHoldEnd
|
|
)
|
|
|
|
const (
|
|
marqueeHoldStart = time.Second
|
|
marqueeStep = 150 * time.Millisecond
|
|
marqueeHoldEnd = time.Second
|
|
)
|
|
|
|
// marqueeState drives the focused sidebar row's pause-scroll-pause
|
|
// animation. State is wall-clock anchored (since), not tick-count
|
|
// anchored, so a missed tick yields a slightly later frame rather
|
|
// than a skipped one.
|
|
type marqueeState struct {
|
|
mu sync.Mutex
|
|
id string
|
|
nameLen int
|
|
budget int
|
|
state int
|
|
offset int
|
|
since time.Time
|
|
}
|
|
|
|
// step advances the state machine for the row identified by id with
|
|
// the given visible name length (in runes) and column budget. It
|
|
// returns the current scroll offset, whether the row is animating
|
|
// (i.e. nameLen > budget), and how long until the next visual change.
|
|
//
|
|
// When id changes, or nameLen <= budget, the state machine resets to
|
|
// phaseHoldStart with offset 0 anchored at now.
|
|
func (m *marqueeState) step(id string, nameLen, budget int, now time.Time) (offset int, animating bool, nextWake time.Duration) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if id != m.id || nameLen != m.nameLen || budget != m.budget {
|
|
m.id = id
|
|
m.nameLen = nameLen
|
|
m.budget = budget
|
|
m.state = phaseHoldStart
|
|
m.offset = 0
|
|
m.since = now
|
|
}
|
|
|
|
if nameLen <= budget || budget <= 0 {
|
|
return 0, false, 0
|
|
}
|
|
|
|
maxOffset := nameLen - budget
|
|
|
|
for {
|
|
elapsed := now.Sub(m.since)
|
|
switch m.state {
|
|
case phaseHoldStart:
|
|
if elapsed < marqueeHoldStart {
|
|
return 0, true, marqueeHoldStart - elapsed
|
|
}
|
|
m.state = phaseScroll
|
|
m.since = m.since.Add(marqueeHoldStart)
|
|
continue
|
|
case phaseScroll:
|
|
steps := int(elapsed / marqueeStep)
|
|
if steps >= maxOffset {
|
|
m.offset = maxOffset
|
|
m.state = phaseHoldEnd
|
|
m.since = m.since.Add(time.Duration(maxOffset) * marqueeStep)
|
|
continue
|
|
}
|
|
m.offset = steps
|
|
rem := marqueeStep - (elapsed % marqueeStep)
|
|
return m.offset, true, rem
|
|
case phaseHoldEnd:
|
|
if elapsed < marqueeHoldEnd {
|
|
return maxOffset, true, marqueeHoldEnd - elapsed
|
|
}
|
|
m.state = phaseHoldStart
|
|
m.offset = 0
|
|
m.since = m.since.Add(marqueeHoldEnd)
|
|
continue
|
|
default:
|
|
m.state = phaseHoldStart
|
|
m.offset = 0
|
|
m.since = now
|
|
return 0, true, marqueeHoldStart
|
|
}
|
|
}
|
|
}
|
|
|
|
// active reports whether the marquee currently has an overflowing row
|
|
// to animate. The marquee ticker goroutine uses this to gate dirty
|
|
// flag flips so an idle sidebar costs nothing.
|
|
func (m *marqueeState) active() bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.id != "" && m.nameLen > m.budget && m.budget > 0
|
|
}
|
|
|
|
// reset clears all state, forcing the next step() call to start a
|
|
// fresh phaseHoldStart. Call this when focus changes so the newly
|
|
// focused row begins with a full head-hold instead of inheriting
|
|
// whatever phase the previous focus was in.
|
|
func (m *marqueeState) reset() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.id = ""
|
|
m.nameLen = 0
|
|
m.budget = 0
|
|
m.state = phaseHoldStart
|
|
m.offset = 0
|
|
m.since = time.Time{}
|
|
}
|