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{} }