refactor(tui): compose tab pulse layers

This commit is contained in:
Kit Langton 2026-08-01 18:12:52 +00:00 committed by OpenCode Agent
commit ced6747737
3 changed files with 321 additions and 318 deletions

View file

@ -1,5 +1,5 @@
import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js" import { For, Show, createComputed, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config" import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs" import { useSessionTabs } from "../context/session-tabs"
@ -18,7 +18,7 @@ import {
import { createAnimatable, spring, tween } from "../ui/animation" import { createAnimatable, spring, tween } from "../ui/animation"
import { Locale } from "../util/locale" import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width" import { stringWidth } from "../util/string-width"
import { TabPulse, unreadGlowIntensity } from "./tab-pulse" import { TabPulse, TabPulseTimeline, unreadGlowIntensity } from "./tab-pulse"
import { tint } from "../theme/color" import { tint } from "../theme/color"
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout" import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { projectName } from "../util/project" import { projectName } from "../util/project"
@ -97,6 +97,14 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
), ),
) )
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)! const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
const pulseTimelines = new Map<string, TabPulseTimeline>()
const pulseTimeline = (sessionID: string) => {
const existing = pulseTimelines.get(sessionID)
if (existing) return existing
const created = new TabPulseTimeline()
pulseTimelines.set(sessionID, created)
return created
}
let rail: { screenY: number } | undefined let rail: { screenY: number } | undefined
let scroll: ScrollBoxRenderable | undefined let scroll: ScrollBoxRenderable | undefined
@ -132,6 +140,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
<box flexShrink={0} flexDirection="column" gap={1}> <box flexShrink={0} flexDirection="column" gap={1}>
<For each={items()}> <For each={items()}>
{(tab, index) => { {(tab, index) => {
const timeline = pulseTimeline(tab.sessionID)
onCleanup(() => pulseTimelines.delete(tab.sessionID))
const selected = () => activeID() === tab.sessionID const selected = () => activeID() === tab.sessionID
const status = createMemo(() => itemStatus(tab)) const status = createMemo(() => itemStatus(tab))
const [sweepLevel, setSweepLevel] = createSignal(0) const [sweepLevel, setSweepLevel] = createSignal(0)
@ -185,6 +195,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35)) const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
const glows = () => status().glows const glows = () => status().glows
const previous = createMemo(() => items()[index() - 1]) const previous = createMemo(() => items()[index() - 1])
const previousTimeline = () => {
const tab = previous()
return tab ? pulseTimeline(tab.sessionID) : undefined
}
const previousStatus = createMemo(() => { const previousStatus = createMemo(() => {
const tab = previous() const tab = previous()
return tab return tab
@ -244,24 +258,30 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
top={-1} top={-1}
edge="above" edge="above"
enabled={animations()} enabled={animations()}
active={runs()} layer={{
outerActive={previousRuns()} timeline,
promptPulse={status().promptPulse} active: runs(),
outerPromptPulse={previousStatus().promptPulse} promptPulse: status().promptPulse,
complete={complete() && !status().attention} complete: complete() && !status().attention,
outerComplete={previousStatus().complete && !previousStatus().attention} glow: glows(),
glow={glows()} breathe: status().attention,
outerGlow={previousGlows()} color: separatorLowerPulseColor(),
breathe={status().attention} glowColor: separatorLowerColor(),
outerBreathe={previousStatus().attention} glowTail: 8,
color={separatorLowerPulseColor()} completionColor: separatorLowerColor(),
outerColor={separatorUpperPulseColor()} }}
glowColor={separatorLowerColor()} edgeLayer={{
outerGlowColor={separatorUpperColor()} timeline: previousTimeline(),
glowTail={8} active: previousRuns(),
outerGlowTail={5} promptPulse: previousStatus().promptPulse,
completionColor={separatorLowerColor()} complete: previousStatus().complete && !previousStatus().attention,
outerCompletionColor={separatorUpperColor()} glow: previousGlows(),
breathe: previousStatus().attention,
color: separatorUpperPulseColor(),
glowColor: separatorUpperColor(),
glowTail: 5,
completionColor: separatorUpperColor(),
}}
backgroundColor={theme.background.default} backgroundColor={theme.background.default}
/> />
<Show when={index() === items().length - 1}> <Show when={index() === items().length - 1}>
@ -269,38 +289,41 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
top={2} top={2}
edge="below" edge="below"
enabled={animations()} enabled={animations()}
active={runs()} layer={{
outerActive={false} timeline,
promptPulse={status().promptPulse} active: runs(),
outerPromptPulse={0} promptPulse: status().promptPulse,
complete={complete() && !status().attention} complete: complete() && !status().attention,
outerComplete={false} glow: glows(),
glow={glows()} breathe: status().attention,
outerGlow={false} color: tint(theme.background.default, theme.text.default, 0.04),
breathe={status().attention} glowColor: tint(theme.background.default, glowHue(), 0.1),
outerBreathe={false} glowTail: 8,
color={tint(theme.background.default, theme.text.default, 0.04)} completionColor: tint(theme.background.default, glowHue(), 0.1),
outerColor={tint(theme.background.default, theme.text.default, 0.006)} }}
glowColor={tint(theme.background.default, glowHue(), 0.1)} edgeLayer={{
outerGlowColor={theme.background.default} color: tint(theme.background.default, theme.text.default, 0.006),
glowTail={8} glowColor: theme.background.default,
outerGlowTail={5} glowTail: 5,
completionColor={tint(theme.background.default, glowHue(), 0.1)} completionColor: theme.background.default,
outerCompletionColor={theme.background.default} }}
backgroundColor={theme.background.default} backgroundColor={theme.background.default}
/> />
</Show> </Show>
<box height={1} width="100%" flexDirection="row" position="relative"> <box height={1} width="100%" flexDirection="row" position="relative">
<TabPulse <TabPulse
enabled={animations()} enabled={animations()}
active={runs()} layer={{
promptPulse={status().promptPulse} timeline,
complete={complete() && !status().attention} active: runs(),
glow={glows()} promptPulse: status().promptPulse,
breathe={status().attention} complete: complete() && !status().attention,
color={pulseColor()} glow: glows(),
glowColor={glowColor()} breathe: status().attention,
completionColor={glowColor()} color: pulseColor(),
glowColor: glowColor(),
completionColor: glowColor(),
}}
backgroundColor={pulseBackground()} backgroundColor={pulseBackground()}
onLevel={setSweepLevel} onLevel={setSweepLevel}
/> />
@ -346,15 +369,18 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
<box height={1} width="100%" position="relative" flexDirection="row"> <box height={1} width="100%" position="relative" flexDirection="row">
<TabPulse <TabPulse
enabled={animations()} enabled={animations()}
active={runs()} layer={{
promptPulse={status().promptPulse} timeline,
complete={complete() && !status().attention} active: runs(),
glow={glows()} promptPulse: status().promptPulse,
breathe={status().attention} complete: complete() && !status().attention,
color={detailPulseColor()} glow: glows(),
glowColor={detailGlowColor()} breathe: status().attention,
glowTail={10} color: detailPulseColor(),
completionColor={detailGlowColor()} glowColor: detailGlowColor(),
glowTail: 10,
completionColor: detailGlowColor(),
}}
backgroundColor={pulseBackground()} backgroundColor={pulseBackground()}
/> />
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}> <box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
@ -651,15 +677,17 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
> >
<TabPulse <TabPulse
enabled={animations()} enabled={animations()}
active={status().busy && !status().attention} layer={{
promptPulse={status().promptPulse} active: status().busy && !status().attention,
complete={status().complete && !status().attention} promptPulse: status().promptPulse,
glow={glows()} complete: status().complete && !status().attention,
breathe={status().attention} glow: glows(),
color={pulseColor()} breathe: status().attention,
glowColor={glowColor()} color: pulseColor(),
flashColor={flashColor()} glowColor: glowColor(),
completionColor={accent()} flashColor: flashColor(),
completionColor: accent(),
}}
backgroundColor={background()} backgroundColor={background()}
onLevel={setSweepLevel} onLevel={setSweepLevel}
/> />

View file

@ -1,29 +1,28 @@
import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderContext } from "@opentui/core" import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderContext } from "@opentui/core"
import { extend } from "@opentui/solid" import { extend } from "@opentui/solid"
export type TabPulseState = {
active?: boolean
promptPulse?: number
complete?: boolean
glow?: boolean
breathe?: boolean
}
export type TabPulseLayer = TabPulseState & {
timeline?: TabPulseTimeline
color: RGBA
glowColor?: RGBA
glowTail?: number
flashColor?: RGBA
completionColor?: RGBA
}
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & { type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
edge?: "above" | "below" edge?: "above" | "below"
enabled?: boolean enabled?: boolean
active?: boolean layer?: TabPulseLayer
outerActive?: boolean edgeLayer?: TabPulseLayer
promptPulse?: number
outerPromptPulse?: number
complete?: boolean
outerComplete?: boolean
glow?: boolean
outerGlow?: boolean
breathe?: boolean
outerBreathe?: boolean
color?: RGBA
outerColor?: RGBA
glowColor?: RGBA
outerGlowColor?: RGBA
glowTail?: number
outerGlowTail?: number
flashColor?: RGBA
outerFlashColor?: RGBA
completionColor?: RGBA
outerCompletionColor?: RGBA
backgroundColor?: RGBA backgroundColor?: RGBA
/** Reports the running sweep's intensity at the tab number's cell, quantized; 0 when idle. */ /** Reports the running sweep's intensity at the tab number's cell, quantized; 0 when idle. */
onLevel?: (level: number) => void onLevel?: (level: number) => void
@ -310,71 +309,163 @@ class PulseState {
} }
} }
export class TabPulseTimeline {
private state: PulseState
private frame = -1
constructor(options: TabPulseState = {}, enabled = true) {
this.state = new PulseState({
enabled,
active: options.active ?? false,
promptPulse: options.promptPulse ?? 0,
complete: options.complete ?? false,
glow: options.glow ?? false,
breathe: options.breathe ?? false,
})
}
set(options: TabPulseState, enabled: boolean) {
return [
this.state.setEnabled(enabled),
// Stopping a run arms completion; apply complete afterward so an atomic idle update can consume it.
this.state.setActive(options.active ?? false),
this.state.setPromptPulse(options.promptPulse ?? 0),
this.state.setComplete(options.complete ?? false),
this.state.setGlow(options.glow ?? false),
this.state.setBreathe(options.breathe ?? false),
].some(Boolean)
}
setEnabled(value: boolean) {
return this.state.setEnabled(value)
}
advance(deltaTime: number, frame: number) {
if (frame === this.frame) return
this.frame = frame
this.state.advance(deltaTime)
}
get live() {
return this.state.live
}
get running() {
return this.state.running
}
get completion() {
return this.state.completion
}
get flash() {
return this.state.flash
}
get glowLevel() {
return this.state.glowLevel
}
fronts(width: number) {
return this.state.fronts(width)
}
}
class PulseLayer {
timeline: TabPulseTimeline
private source: TabPulseTimeline | undefined
private enabled: boolean
color: RGBA
glowColor: RGBA
glowTail: number
flashColor: RGBA
completionColor: RGBA
constructor(options: TabPulseLayer, enabled: boolean) {
this.enabled = enabled
this.source = options.timeline
this.timeline = options.timeline ?? new TabPulseTimeline(options, enabled)
if (options.timeline) options.timeline.set(options, enabled)
this.color = options.color
this.glowColor = options.glowColor ?? options.color
this.glowTail = options.glowTail ?? GLOW_TAIL
this.flashColor = options.flashColor ?? options.color
this.completionColor = options.completionColor ?? options.color
}
set(options: TabPulseLayer) {
const sourceChanged = options.timeline !== this.source
if (sourceChanged) {
this.source = options.timeline
this.timeline = options.timeline ?? new TabPulseTimeline(options, this.enabled)
}
const stateChanged = sourceChanged
? (options.timeline?.set(options, this.enabled) ?? false)
: this.timeline.set(options, this.enabled)
const color = options.color
const glowColor = options.glowColor ?? color
const glowTail = options.glowTail ?? GLOW_TAIL
const flashColor = options.flashColor ?? color
const completionColor = options.completionColor ?? color
const changed = [
sourceChanged,
stateChanged,
!color.equals(this.color),
!glowColor.equals(this.glowColor),
glowTail !== this.glowTail,
!flashColor.equals(this.flashColor),
!completionColor.equals(this.completionColor),
].some(Boolean)
this.color = color
this.glowColor = glowColor
this.glowTail = glowTail
this.flashColor = flashColor
this.completionColor = completionColor
return changed
}
setEnabled(value: boolean) {
this.enabled = value
return this.timeline.setEnabled(value)
}
}
const DEFAULT_LAYER: TabPulseLayer = { color: RGBA.defaultForeground() }
class TabPulseRenderable extends Renderable { class TabPulseRenderable extends Renderable {
private _enabled: boolean private _enabled: boolean
private inner: PulseState private primary: PulseLayer
private outer: PulseState private adjacent: PulseLayer
private _color: RGBA private primaryAssigned: boolean
private _outerColor: RGBA private adjacentAssigned: boolean
private _glowColor: RGBA
private _outerGlowColor: RGBA
private _glowTail: number
private _outerGlowTail: number
private _edge: "above" | "below" | undefined private _edge: "above" | "below" | undefined
private _flashColor: RGBA
private _outerFlashColor: RGBA
private _completionColor: RGBA
private _outerCompletionColor: RGBA
private _backgroundColor: RGBA private _backgroundColor: RGBA
private renderColor = RGBA.fromInts(0, 0, 0) private primaryRenderColor = RGBA.fromInts(0, 0, 0)
private outerRenderColor = RGBA.fromInts(0, 0, 0) private adjacentRenderColor = RGBA.fromInts(0, 0, 0)
private _onLevel: ((level: number) => void) | undefined private _onLevel: ((level: number) => void) | undefined
private lastLevel = 0 private lastLevel = 0
constructor(ctx: RenderContext, options: TabPulseOptions = {}) { constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
const enabled = options.enabled ?? true const enabled = options.enabled ?? true
const active = options.active ?? false const primary = options.layer ?? DEFAULT_LAYER
const glow = options.glow ?? false const adjacent = options.edgeLayer ?? DEFAULT_LAYER
const breathe = options.breathe ?? false
const edge = options.edge const edge = options.edge
const outerActive = options.outerActive ?? active
const outerGlow = options.outerGlow ?? glow
const outerBreathe = options.outerBreathe ?? breathe
super(ctx, { super(ctx, {
...options, ...options,
height: 1, height: 1,
live: live:
enabled && enabled &&
(active || (glow && breathe) || (edge !== undefined && (outerActive || (outerGlow && outerBreathe)))), ((primary.active ?? false) ||
((primary.glow ?? false) && (primary.breathe ?? false)) ||
(edge !== undefined &&
((adjacent.active ?? false) || ((adjacent.glow ?? false) && (adjacent.breathe ?? false))))),
}) })
this._enabled = enabled this._enabled = enabled
this.inner = new PulseState({ this.primary = new PulseLayer(primary, enabled)
enabled, this.adjacent = new PulseLayer(adjacent, enabled && edge !== undefined)
active, this.primaryAssigned = options.layer !== undefined
promptPulse: options.promptPulse ?? 0, this.adjacentAssigned = options.edgeLayer !== undefined
complete: options.complete ?? false,
glow,
breathe,
})
this.outer = new PulseState({
enabled: enabled && edge !== undefined,
active: outerActive,
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
this._glowColor = options.glowColor ?? this._color
this._outerGlowColor = options.outerGlowColor ?? this._glowColor
this._glowTail = options.glowTail ?? GLOW_TAIL
this._outerGlowTail = options.outerGlowTail ?? this._glowTail
this._edge = edge this._edge = edge
this._flashColor = options.flashColor ?? this._color
this._outerFlashColor = options.outerFlashColor ?? options.flashColor ?? this._outerColor
this._completionColor = options.completionColor ?? this._color
this._outerCompletionColor = options.outerCompletionColor ?? options.completionColor ?? this._outerColor
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground() this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
this._onLevel = options.onLevel this._onLevel = options.onLevel
} }
@ -388,129 +479,42 @@ class TabPulseRenderable extends Renderable {
const quantized = Math.round(value * 32) / 32 const quantized = Math.round(value * 32) / 32
if (quantized === this.lastLevel) return if (quantized === this.lastLevel) return
this.lastLevel = quantized this.lastLevel = quantized
this._onLevel?.(quantized) this._onLevel(quantized)
} }
set enabled(value: boolean) { set enabled(value: boolean) {
if (value === this._enabled) return if (value === this._enabled) return
this._enabled = value this._enabled = value
this.inner.setEnabled(value) this.primary.setEnabled(value)
this.outer.setEnabled(value && this._edge !== undefined) this.adjacent.setEnabled(value && this._edge !== undefined)
this.live = this.inner.live || this.outer.live this.changed()
this.requestRender()
} }
set active(value: boolean) { set layer(value: TabPulseLayer) {
if (this.inner.setActive(value)) this.changed() if (!this.primaryAssigned) {
this.primaryAssigned = true
this.primary = new PulseLayer(value, this._enabled)
this.changed()
return
}
if (this.primary.set(value)) this.changed()
} }
set outerActive(value: boolean) { set edgeLayer(value: TabPulseLayer) {
if (this.outer.setActive(value)) this.changed() if (!this.adjacentAssigned) {
} this.adjacentAssigned = true
this.adjacent = new PulseLayer(value, this._enabled && this._edge !== undefined)
set promptPulse(value: number) { this.changed()
if (this.inner.setPromptPulse(value)) this.changed() return
} }
if (this.adjacent.set(value)) this.changed()
set outerPromptPulse(value: number) {
if (this.outer.setPromptPulse(value)) this.changed()
}
set complete(value: boolean) {
if (this.inner.setComplete(value)) this.changed()
}
set outerComplete(value: boolean) {
if (this.outer.setComplete(value)) this.changed()
}
set glow(value: boolean) {
if (this.inner.setGlow(value)) this.changed()
}
set outerGlow(value: boolean) {
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()
}
set color(value: RGBA) {
if (value.equals(this._color)) return
this._color = value
this.requestRender()
}
set glowColor(value: RGBA) {
if (value.equals(this._glowColor)) return
this._glowColor = value
this.requestRender()
}
set outerColor(value: RGBA) {
if (value.equals(this._outerColor)) return
this._outerColor = value
this.requestRender()
}
set outerGlowColor(value: RGBA) {
if (value.equals(this._outerGlowColor)) return
this._outerGlowColor = value
this.requestRender()
}
set glowTail(value: number) {
if (value === this._glowTail) return
this._glowTail = value
this.requestRender()
}
set outerGlowTail(value: number) {
if (value === this._outerGlowTail) return
this._outerGlowTail = value
this.requestRender()
} }
set edge(value: "above" | "below" | undefined) { set edge(value: "above" | "below" | undefined) {
if (value === this._edge) return if (value === this._edge) return
this._edge = value this._edge = value
this.outer.setEnabled(this._enabled && value !== undefined) this.adjacent.setEnabled(this._enabled && value !== undefined)
this.live = this.inner.live || this.outer.live this.changed()
this.requestRender()
}
set flashColor(value: RGBA) {
if (value.equals(this._flashColor)) return
this._flashColor = value
this.requestRender()
}
set outerFlashColor(value: RGBA) {
if (value.equals(this._outerFlashColor)) return
this._outerFlashColor = value
this.requestRender()
}
set completionColor(value: RGBA) {
if (value.equals(this._completionColor)) return
this._completionColor = value
this.requestRender()
}
set outerCompletionColor(value: RGBA) {
if (value.equals(this._outerCompletionColor)) return
this._outerCompletionColor = value
this.requestRender()
} }
set backgroundColor(value: RGBA) { set backgroundColor(value: RGBA) {
@ -519,38 +523,43 @@ class TabPulseRenderable extends Renderable {
this.requestRender() this.requestRender()
} }
private changed() {
this.live = this.primary.timeline.live || this.adjacent.timeline.live
this.requestRender()
}
protected override onUpdate(deltaTime: number): void { protected override onUpdate(deltaTime: number): void {
if (!this._enabled) return if (!this._enabled) return
this.inner.advance(deltaTime) this.primary.timeline.advance(deltaTime, this.ctx.frameId)
this.outer.advance(deltaTime) this.adjacent.timeline.advance(deltaTime, this.ctx.frameId)
this.live = this.inner.live || this.outer.live this.live = this.primary.timeline.live || this.adjacent.timeline.live
} }
protected override renderSelf(buffer: OptimizedBuffer): void { protected override renderSelf(buffer: OptimizedBuffer): void {
if (!this.visible || this.isDestroyed || this.width <= 0) return if (!this.visible || this.isDestroyed || this.width <= 0) return
const running = this.inner.running const running = this.primary.timeline.running
const completion = this.inner.completion const completion = this.primary.timeline.completion
const flash = this.inner.flash const flash = this.primary.timeline.flash
const glowLevel = this.inner.glowLevel const glowLevel = this.primary.timeline.glowLevel
const outerRunning = this.outer.running const adjacentRunning = this.adjacent.timeline.running
const outerCompletion = this.outer.completion const adjacentCompletion = this.adjacent.timeline.completion
const outerFlash = this.outer.flash const adjacentFlash = this.adjacent.timeline.flash
const outerGlowLevel = this.outer.glowLevel const adjacentGlowLevel = this.adjacent.timeline.glowLevel
if ( if (
glowLevel === 0 && glowLevel === 0 &&
running === 0 && running === 0 &&
completion === 0 && completion === 0 &&
flash === 0 && flash === 0 &&
outerGlowLevel === 0 && adjacentGlowLevel === 0 &&
outerRunning === 0 && adjacentRunning === 0 &&
outerCompletion === 0 && adjacentCompletion === 0 &&
outerFlash === 0 adjacentFlash === 0
) { ) {
this.emitLevel(0) this.emitLevel(0)
return return
} }
const [front, secondFront] = this.inner.fronts(this.width) const [front, secondFront] = this.primary.timeline.fronts(this.width)
const [outerFront, outerSecondFront] = this.outer.fronts(this.width) const [adjacentFront, adjacentSecondFront] = this.adjacent.timeline.fronts(this.width)
if (this._onLevel) if (this._onLevel)
this.emitLevel( this.emitLevel(
running === 0 running === 0
@ -558,8 +567,8 @@ class TabPulseRenderable extends Renderable {
: Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) * : Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
running, running,
) )
const glowTail = Math.min(this._glowTail, Math.max(1, this.width - 2)) const glowTail = Math.min(this.primary.glowTail, Math.max(1, this.width - 2))
const outerGlowTail = Math.min(this._outerGlowTail, Math.max(1, this.width - 2)) const adjacentGlowTail = Math.min(this.adjacent.glowTail, Math.max(1, this.width - 2))
for (let index = 0; index < this.width; index++) { for (let index = 0; index < this.width; index++) {
// Skip per-cell sweep and glow math when that stage is idle, e.g. a steady breathing glow. // Skip per-cell sweep and glow math when that stage is idle, e.g. a steady breathing glow.
const sweep = const sweep =
@ -571,49 +580,49 @@ class TabPulseRenderable extends Renderable {
) * ) *
0.14 * 0.14 *
running running
const outerSweep = const adjacentSweep =
outerRunning === 0 adjacentRunning === 0
? 0 ? 0
: Math.max( : Math.max(
intensityAt(index, outerFront, RUN_HEAD, RUN_TAIL), intensityAt(index, adjacentFront, RUN_HEAD, RUN_TAIL),
intensityAt(index, outerSecondFront, RUN_HEAD, RUN_TAIL), intensityAt(index, adjacentSecondFront, RUN_HEAD, RUN_TAIL),
) * ) *
0.14 * 0.14 *
outerRunning adjacentRunning
blendTabPulseColor( blendTabPulseColor(
this.renderColor, this.primaryRenderColor,
this._backgroundColor, this._backgroundColor,
this._glowColor, this.primary.glowColor,
this._color, this.primary.color,
this._flashColor, this.primary.flashColor,
this._completionColor, this.primary.completionColor,
glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel, glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel,
sweep, sweep,
flash, flash,
completion, completion,
) )
if (!this._edge) { if (!this._edge) {
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor) buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.primaryRenderColor)
continue continue
} }
blendTabPulseColor( blendTabPulseColor(
this.outerRenderColor, this.adjacentRenderColor,
this._backgroundColor, this._backgroundColor,
this._outerGlowColor, this.adjacent.glowColor,
this._outerColor, this.adjacent.color,
this._outerFlashColor, this.adjacent.flashColor,
this._outerCompletionColor, this.adjacent.completionColor,
outerGlowLevel === 0 ? 0 : glowIntensityAt(index, outerGlowTail) * GLOW_OPACITY * outerGlowLevel, adjacentGlowLevel === 0 ? 0 : glowIntensityAt(index, adjacentGlowTail) * GLOW_OPACITY * adjacentGlowLevel,
outerSweep, adjacentSweep,
outerFlash, adjacentFlash,
outerCompletion, adjacentCompletion,
) )
buffer.setCell( buffer.setCell(
this.screenX + index, this.screenX + index,
this.screenY, this.screenY,
this._edge === "above" ? "▄" : "▀", this._edge === "above" ? "▄" : "▀",
this.renderColor, this.primaryRenderColor,
this.outerRenderColor, this.adjacentRenderColor,
) )
} }
} }
@ -632,26 +641,8 @@ export function TabPulse(props: {
width?: number width?: number
edge?: "above" | "below" edge?: "above" | "below"
enabled?: boolean enabled?: boolean
active: boolean layer: TabPulseLayer
outerActive?: boolean edgeLayer?: TabPulseLayer
promptPulse?: number
outerPromptPulse?: number
complete?: boolean
outerComplete?: boolean
glow?: boolean
outerGlow?: boolean
breathe?: boolean
outerBreathe?: boolean
color: RGBA
outerColor?: RGBA
glowColor?: RGBA
outerGlowColor?: RGBA
glowTail?: number
outerGlowTail?: number
flashColor?: RGBA
outerFlashColor?: RGBA
completionColor?: RGBA
outerCompletionColor?: RGBA
backgroundColor: RGBA backgroundColor: RGBA
onLevel?: (level: number) => void onLevel?: (level: number) => void
}) { }) {
@ -663,26 +654,8 @@ export function TabPulse(props: {
zIndex={0} zIndex={0}
width={props.width ?? "100%"} width={props.width ?? "100%"}
enabled={props.enabled ?? true} enabled={props.enabled ?? true}
active={props.active} layer={props.layer}
outerActive={props.outerActive ?? props.active} edgeLayer={props.edge === undefined ? undefined : (props.edgeLayer ?? props.layer)}
promptPulse={props.promptPulse ?? 0}
outerPromptPulse={props.outerPromptPulse ?? props.promptPulse ?? 0}
complete={props.complete ?? false}
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}
outerGlowColor={props.outerGlowColor ?? props.glowColor ?? props.color}
glowTail={props.glowTail ?? GLOW_TAIL}
outerGlowTail={props.outerGlowTail ?? props.glowTail ?? GLOW_TAIL}
flashColor={props.flashColor ?? props.color}
outerFlashColor={props.outerFlashColor ?? props.flashColor ?? props.outerColor ?? props.color}
completionColor={props.completionColor ?? props.color}
outerCompletionColor={props.outerCompletionColor ?? props.completionColor ?? props.outerColor ?? props.color}
backgroundColor={props.backgroundColor} backgroundColor={props.backgroundColor}
onLevel={props.onLevel} onLevel={props.onLevel}
/> />

View file

@ -20,10 +20,12 @@ test("a prompt pulse restarts the neutral edge flash while the tab remains busy"
() => ( () => (
<box width={8} height={1} backgroundColor={background}> <box width={8} height={1} backgroundColor={background}>
<TabPulse <TabPulse
active={true} layer={{
promptPulse={promptPulse()} active: true,
color={background} promptPulse: promptPulse(),
flashColor={flash} color: background,
flashColor: flash,
}}
backgroundColor={background} backgroundColor={background}
/> />
</box> </box>