Browse Source

fix(tui): stop attention glow breathing (#42404)

Kit Langton 2 days ago
parent
commit
3a41ee8817

+ 0 - 7
packages/tui/src/component/session-tabs.tsx

@@ -562,8 +562,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
                     outerComplete={previousStatus().complete && !previousStatus().attention}
                     glow={glows()}
                     outerGlow={previousGlows()}
-                    breathe={status().attention}
-                    outerBreathe={previousStatus().attention}
                     color={separatorLowerPulseColor()}
                     width={indicatorWidth}
                     outerColor={separatorUpperPulseColor()}
@@ -591,8 +589,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
                       outerComplete={false}
                       glow={glows()}
                       outerGlow={false}
-                      breathe={status().attention}
-                      outerBreathe={false}
                       color={tint(theme.background.default, theme.text.default, 0.04)}
                       width={indicatorWidth}
                       outerColor={tint(theme.background.default, theme.text.default, 0.006)}
@@ -614,7 +610,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
                       promptPulse={status().promptPulse}
                       complete={complete() && !status().attention}
                       glow={glows()}
-                      breathe={status().attention}
                       color={pulseColor()}
                       width={indicatorWidth}
                       glowColor={glowColor()}
@@ -673,7 +668,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
                       promptPulse={status().promptPulse}
                       complete={complete() && !status().attention}
                       glow={glows()}
-                      breathe={status().attention}
                       color={detailPulseColor()}
                       width={indicatorWidth}
                       glowColor={detailGlowColor()}
@@ -1073,7 +1067,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
                 promptPulse={status().promptPulse}
                 complete={status().complete && !status().attention}
                 glow={glows()}
-                breathe={status().attention}
                 color={pulseColor()}
                 glowColor={glowColor()}
                 flashColor={flashColor()}

+ 36 - 68
packages/tui/src/component/tab-pulse.tsx

@@ -12,8 +12,6 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
   outerComplete?: boolean
   glow?: boolean
   outerGlow?: boolean
-  breathe?: boolean
-  outerBreathe?: boolean
   color?: RGBA
   outerColor?: RGBA
   glowColor?: RGBA
@@ -49,8 +47,6 @@ const GLOW_IGNITION_DURATION = 600
 const GLOW_IGNITION_PEAK = 1.5
 const GLOW_IGNITION_ATTACK = 0.3
 const GLOW_FADE_OUT = 200
-const GLOW_BREATHE_PERIOD = 3_600
-const GLOW_BREATHE_RISE = 0.25
 const GLOW_TAIL = 12
 const GLOW_OPACITY = 0.16
 const DEFAULT_FOREGROUND = RGBA.defaultForeground()
@@ -91,18 +87,30 @@ export function blendTabPulseColor(
   flash: number,
   completion: number,
 ) {
-  output.r = background.r + (glowColor.r - background.r) * glow
-  output.g = background.g + (glowColor.g - background.g) * glow
-  output.b = background.b + (glowColor.b - background.b) * glow
-  output.r += (runningColor.r - output.r) * running
-  output.g += (runningColor.g - output.g) * running
-  output.b += (runningColor.b - output.b) * running
-  output.r += (flashColor.r - output.r) * flash
-  output.g += (flashColor.g - output.g) * flash
-  output.b += (flashColor.b - output.b) * flash
-  output.r += (completionColor.r - output.r) * completion
-  output.g += (completionColor.g - output.g) * completion
-  output.b += (completionColor.b - output.b) * completion
+  if (glow === 0) {
+    output.r = background.r
+    output.g = background.g
+    output.b = background.b
+  } else {
+    output.r = background.r + (glowColor.r - background.r) * glow
+    output.g = background.g + (glowColor.g - background.g) * glow
+    output.b = background.b + (glowColor.b - background.b) * glow
+  }
+  if (running !== 0) {
+    output.r += (runningColor.r - output.r) * running
+    output.g += (runningColor.g - output.g) * running
+    output.b += (runningColor.b - output.b) * running
+  }
+  if (flash !== 0) {
+    output.r += (flashColor.r - output.r) * flash
+    output.g += (flashColor.g - output.g) * flash
+    output.b += (flashColor.b - output.b) * flash
+  }
+  if (completion !== 0) {
+    output.r += (completionColor.r - output.r) * completion
+    output.g += (completionColor.g - output.g) * completion
+    output.b += (completionColor.b - output.b) * completion
+  }
 }
 
 /** A one-shot animation clock: level() follows shape over duration, scaled by the value passed to start. */
@@ -154,7 +162,6 @@ type PulseStateOptions = {
   promptPulse: number
   complete: boolean
   glow: boolean
-  breathe: boolean
 }
 
 class PulseState {
@@ -163,9 +170,7 @@ class PulseState {
   private promptPulse: number
   private complete: boolean
   private glow: boolean
-  private breathe: boolean
   private clock = 0
-  private breatheClock = 0
   private completionPending = false
   private runAttack = new Envelope(RUN_ATTACK, smootherstep)
   private runFade = new Envelope(RUN_FADE_OUT, fadeOut)
@@ -181,16 +186,11 @@ class PulseState {
     this.promptPulse = options.promptPulse
     this.complete = options.complete
     this.glow = options.glow
-    this.breathe = options.breathe
     if (this.enabled && this.active) this.runAttack.start()
   }
 
-  private get breathing() {
-    return this.enabled && this.glow && this.breathe
-  }
-
   get live() {
-    return this.enabled && (this.active || this.breathing || this.envelopes.some(envelopeActive))
+    return this.enabled && (this.active || this.envelopes.some(envelopeActive))
   }
 
   get running() {
@@ -208,11 +208,7 @@ class PulseState {
 
   get glowLevel() {
     if (!this.glow) return this.glowOff.level()
-    const base = this.ignition.active ? this.ignition.level() : 1
-    if (!this.breathing) return base
-    return (
-      base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD)))
-    )
+    return this.ignition.active ? this.ignition.level() : 1
   }
 
   setEnabled(value: boolean) {
@@ -221,7 +217,6 @@ class PulseState {
     if (!value) {
       for (const envelope of this.envelopes) envelope.stop()
       this.completionPending = false
-      this.breatheClock = 0
     } else if (this.active) {
       this.runAttack.restart()
     }
@@ -274,7 +269,6 @@ class PulseState {
     if (this.enabled && !value) this.glowOff.start(this.glowLevel)
     this.glow = value
     this.ignition.stop()
-    this.breatheClock = 0
     if (this.enabled && value) {
       this.glowOff.stop()
       this.ignition.start()
@@ -282,17 +276,9 @@ class PulseState {
     return true
   }
 
-  setBreathe(value: boolean) {
-    if (value === this.breathe) return false
-    this.breathe = value
-    this.breatheClock = 0
-    return true
-  }
-
   advance(deltaTime: number) {
-    if (!this.enabled) return
+    if (!this.live) return
     if (this.active || this.runFade.active) this.clock += deltaTime
-    if (this.breathing) this.breatheClock += deltaTime
     for (const envelope of this.envelopes) envelope.advance(deltaTime)
     if (!this.completionPending) return
     if (this.complete) {
@@ -340,17 +326,13 @@ class TabPulseRenderable extends Renderable {
     const enabled = options.enabled ?? true
     const active = options.active ?? false
     const glow = options.glow ?? false
-    const breathe = options.breathe ?? false
     const edge = options.edge
     const outerActive = options.outerActive ?? active
     const outerGlow = options.outerGlow ?? glow
-    const outerBreathe = options.outerBreathe ?? breathe
     super(ctx, {
       ...options,
       height: 1,
-      live:
-        enabled &&
-        (active || (glow && breathe) || (edge !== undefined && (outerActive || (outerGlow && outerBreathe)))),
+      live: enabled && (active || (edge !== undefined && outerActive)),
     })
     this._enabled = enabled
     this.inner = new PulseState({
@@ -359,7 +341,6 @@ class TabPulseRenderable extends Renderable {
       promptPulse: options.promptPulse ?? 0,
       complete: options.complete ?? false,
       glow,
-      breathe,
     })
     this.outer = new PulseState({
       enabled: enabled && edge !== undefined,
@@ -367,7 +348,6 @@ class TabPulseRenderable extends Renderable {
       promptPulse: options.outerPromptPulse ?? options.promptPulse ?? 0,
       complete: options.outerComplete ?? options.complete ?? false,
       glow: outerGlow,
-      breathe: outerBreathe,
     })
     this._color = options.color ?? RGBA.defaultForeground()
     this._outerColor = options.outerColor ?? this._color
@@ -439,14 +419,6 @@ class TabPulseRenderable extends Renderable {
     if (this.outer.setGlow(value)) this.changed()
   }
 
-  set breathe(value: boolean) {
-    if (this.inner.setBreathe(value)) this.changed()
-  }
-
-  set outerBreathe(value: boolean) {
-    if (this.outer.setBreathe(value)) this.changed()
-  }
-
   private changed() {
     this.live = this.inner.live || this.outer.live
     this.requestRender()
@@ -539,7 +511,7 @@ class TabPulseRenderable extends Renderable {
   }
 
   protected override onUpdate(deltaTime: number): void {
-    if (!this._enabled) return
+    if (!this.live) return
     this.inner.advance(deltaTime)
     this.outer.advance(deltaTime)
     this.live = this.inner.live || this.outer.live
@@ -568,13 +540,13 @@ class TabPulseRenderable extends Renderable {
       this.emitLevel(0)
       return
     }
-    const [front, secondFront] = this.inner.fronts(this.width)
-    const [outerFront, outerSecondFront] = this.outer.fronts(this.width)
+    const fronts = running === 0 ? undefined : this.inner.fronts(this.width)
+    const outerFronts = outerRunning === 0 ? undefined : this.outer.fronts(this.width)
     if (this._onLevel)
       this.emitLevel(
         running === 0
           ? 0
-          : Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
+          : Math.max(intensityAt(1, fronts![0], RUN_HEAD, RUN_TAIL), intensityAt(1, fronts![1], RUN_HEAD, RUN_TAIL)) *
               running,
       )
     const glowTail = Math.min(this._glowTail, Math.max(1, this.width - 2))
@@ -588,8 +560,8 @@ class TabPulseRenderable extends Renderable {
         running === 0
           ? 0
           : Math.max(
-              intensityAt(index, front, RUN_HEAD, RUN_TAIL),
-              intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
+              intensityAt(index, fronts![0], RUN_HEAD, RUN_TAIL),
+              intensityAt(index, fronts![1], RUN_HEAD, RUN_TAIL),
             ) *
             0.14 *
             running
@@ -597,8 +569,8 @@ class TabPulseRenderable extends Renderable {
         outerRunning === 0
           ? 0
           : Math.max(
-              intensityAt(index, outerFront, RUN_HEAD, RUN_TAIL),
-              intensityAt(index, outerSecondFront, RUN_HEAD, RUN_TAIL),
+              intensityAt(index, outerFronts![0], RUN_HEAD, RUN_TAIL),
+              intensityAt(index, outerFronts![1], RUN_HEAD, RUN_TAIL),
             ) *
             0.14 *
             outerRunning
@@ -662,8 +634,6 @@ export function TabPulse(props: {
   outerComplete?: boolean
   glow?: boolean
   outerGlow?: boolean
-  breathe?: boolean
-  outerBreathe?: boolean
   color: RGBA
   outerColor?: RGBA
   glowColor?: RGBA
@@ -695,8 +665,6 @@ export function TabPulse(props: {
       outerComplete={props.outerComplete ?? props.complete ?? false}
       glow={props.glow ?? false}
       outerGlow={props.outerGlow ?? props.glow ?? false}
-      breathe={props.breathe ?? false}
-      outerBreathe={props.outerBreathe ?? props.breathe ?? false}
       color={props.color}
       outerColor={props.outerColor ?? props.color}
       glowColor={props.glowColor ?? props.color}

+ 18 - 0
packages/tui/test/component/tab-pulse.test.tsx

@@ -33,6 +33,24 @@ test("a disabled pulse stays idle when it becomes active", async () => {
   }
 })
 
+test("an attention glow becomes idle after ignition", async () => {
+  const background = RGBA.fromHex("#101010")
+  const app = await testRender(
+    () => <TabPulse active={false} glow color={RGBA.fromHex("#ffcc00")} backgroundColor={background} />,
+    { width: 8, height: 1 },
+  )
+
+  try {
+    await app.renderOnce()
+    expect(app.renderer.root.liveCount).toBe(1)
+    await Bun.sleep(650)
+    await app.renderOnce()
+    expect(app.renderer.root.liveCount).toBe(0)
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
 test("completion pulse rises quickly and fades over the remaining duration", () => {
   expect(completionPulseOpacity(0)).toBe(0)
   expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5)