mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
feat(tui): diffuse unread tab glow on resolve
This commit is contained in:
@@ -71,8 +71,8 @@ export type SessionTabsController = Pick<ContextController, "tabs" | "current" |
|
||||
status(sessionID: string): SessionTabsStatus
|
||||
}
|
||||
const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_TITLE }
|
||||
const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number) =>
|
||||
tint(base, glow, 0.12 * unreadGlowIntensity(index, width))
|
||||
const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number, level = 1) =>
|
||||
tint(base, glow, 0.12 * unreadGlowIntensity(index, width) * level)
|
||||
|
||||
function createNumberIgnition(runs: () => boolean, prompt: () => number, animations: () => boolean) {
|
||||
const ignition = createAnimatable({ level: 0 }, { enabled: animations, transition: tween({ duration: 0.7 }) })
|
||||
@@ -430,11 +430,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
hovered() === tab.sessionID && !selected()
|
||||
? foreground()
|
||||
: tint(idleNumber(), tint(theme.text.default, pulseBackground(), 0.25), Number(selected()))
|
||||
const color = status().attention
|
||||
? theme.text.feedback.warning.default
|
||||
: status().unread === "error"
|
||||
? theme.text.feedback.error.default
|
||||
: tint(base, accent(), Number(complete()))
|
||||
const color = tint(base, glowHue(), numberGlow.value().level)
|
||||
const runningColor = runs() ? activeNumber() : color
|
||||
return sweepLevel() === 0
|
||||
? tint(runningColor, theme.text.default, numberIgnition.value().level)
|
||||
@@ -445,10 +441,13 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
return selected() ? theme.text.default : theme.text.subdued
|
||||
}
|
||||
const complete = () => status().complete
|
||||
// Latched so a resolving glow fades out in the hue it lit with instead of snapping to accent.
|
||||
let lastGlowHue: RGBA | undefined
|
||||
const glowHue = () => {
|
||||
if (status().attention) return theme.text.feedback.warning.default
|
||||
if (status().unread === "error") return theme.text.feedback.error.default
|
||||
return accent()
|
||||
if (status().attention) return (lastGlowHue = theme.text.feedback.warning.default)
|
||||
if (status().unread === "error") return (lastGlowHue = theme.text.feedback.error.default)
|
||||
if (status().unread !== undefined) return (lastGlowHue = accent())
|
||||
return lastGlowHue ?? accent()
|
||||
}
|
||||
const pulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.25))
|
||||
const flashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.7))
|
||||
@@ -462,6 +461,21 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
? fadeTitleColor(detailColor(), pulseBackground(), index, visibleDetailParts().length, 0)
|
||||
: detailColor()
|
||||
const glows = () => status().glows
|
||||
// Text tints ride an eased level so they diffuse away with the background glow instead of snapping.
|
||||
const titleGlow = createAnimatable(
|
||||
{ level: 0 },
|
||||
{ enabled: animations, transition: tween({ duration: 0.4 }) },
|
||||
)
|
||||
createEffect(() => titleGlow.animate({ level: glows() ? 1 : 0 }))
|
||||
const numberGlow = createAnimatable(
|
||||
{ level: 0 },
|
||||
{ enabled: animations, transition: tween({ duration: 0.4 }) },
|
||||
)
|
||||
createEffect(() =>
|
||||
numberGlow.animate({
|
||||
level: status().attention || status().unread === "error" || complete() ? 1 : 0,
|
||||
}),
|
||||
)
|
||||
const previous = createMemo(() => items()[index() - 1])
|
||||
const previousStatus = createMemo(() => {
|
||||
const tab = previous()
|
||||
@@ -472,17 +486,22 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const previousGlows = () => previousStatus().glows
|
||||
const previousRuns = () => previousStatus().runs
|
||||
const indicatorWidth = 10
|
||||
let lastPreviousGlowHue: RGBA | undefined
|
||||
const previousGlowHue = () => {
|
||||
if (previousStatus().attention) return theme.text.feedback.warning.default
|
||||
if (previousStatus().unread === "error") return theme.text.feedback.error.default
|
||||
return accent()
|
||||
if (previousStatus().attention) return (lastPreviousGlowHue = theme.text.feedback.warning.default)
|
||||
if (previousStatus().unread === "error")
|
||||
return (lastPreviousGlowHue = theme.text.feedback.error.default)
|
||||
if (previousStatus().unread !== undefined) return (lastPreviousGlowHue = accent())
|
||||
return lastPreviousGlowHue ?? accent()
|
||||
}
|
||||
const separatorUpperColor = createMemo(() => tint(theme.background.default, previousGlowHue(), 0.1))
|
||||
const separatorLowerColor = createMemo(() => tint(theme.background.default, glowHue(), 0.12))
|
||||
const titleColor = (index: number) => {
|
||||
const color = glows()
|
||||
? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width())
|
||||
: foreground()
|
||||
const level = titleGlow.value().level
|
||||
const color =
|
||||
level === 0
|
||||
? foreground()
|
||||
: glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width(), level)
|
||||
return titleFades()
|
||||
? fadeTitleColor(
|
||||
color,
|
||||
@@ -628,7 +647,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
selectable={false}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
<Show when={glows() || titleFades()} fallback={visibleTitle()}>
|
||||
<Show when={titleGlow.value().level > 0 || titleFades()} fallback={visibleTitle()}>
|
||||
<For each={visibleTitleParts()}>
|
||||
{(character, index) => <span style={{ fg: titleColor(index()) }}>{character}</span>}
|
||||
</For>
|
||||
|
||||
@@ -47,6 +47,9 @@ const GLOW_IGNITION_DURATION = 600
|
||||
const GLOW_IGNITION_PEAK = 1.5
|
||||
const GLOW_IGNITION_ATTACK = 0.3
|
||||
const GLOW_FADE_OUT = 200
|
||||
const GLOW_RELEASE_DURATION = 900
|
||||
const GLOW_RELEASE_ATTACK = 0.12
|
||||
const GLOW_RELEASE_PEAK = 1.25
|
||||
const GLOW_TAIL = 12
|
||||
const GLOW_OPACITY = 0.16
|
||||
const DEFAULT_FOREGROUND = RGBA.defaultForeground()
|
||||
@@ -75,6 +78,8 @@ export const unreadGlowIntensity = (index: number, width: number, maximumTail =
|
||||
const tail = Math.min(maximumTail, Math.max(1, width - 2))
|
||||
return glowIntensityAt(index, tail)
|
||||
}
|
||||
/** How far a resolving glow has diffused: the resting tail spreads across the full width as it thins away. */
|
||||
const glowReleaseSpread = (progress: number, tail: number, width: number) => tail + smootherstep(progress) * width
|
||||
export function blendTabPulseColor(
|
||||
output: RGBA,
|
||||
background: RGBA,
|
||||
@@ -148,6 +153,10 @@ class Envelope {
|
||||
return this.clock !== undefined
|
||||
}
|
||||
|
||||
get progress() {
|
||||
return this.clock === undefined ? undefined : this.clock / this.duration
|
||||
}
|
||||
|
||||
level() {
|
||||
return this.clock === undefined ? 0 : this.scale * this.shape(this.clock / this.duration)
|
||||
}
|
||||
@@ -178,7 +187,19 @@ class PulseState {
|
||||
private edgeFlash = new Envelope(EDGE_FLASH_DURATION, (progress) => attackDecay(progress, EDGE_FLASH_ATTACK, 1, 0))
|
||||
private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel)
|
||||
private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut)
|
||||
private envelopes = [this.runAttack, this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
|
||||
// A resolving glow's send-off: a gentle swell that decays while the spatial profile diffuses outward.
|
||||
private release = new Envelope(GLOW_RELEASE_DURATION, (progress) =>
|
||||
attackDecay(progress, GLOW_RELEASE_ATTACK, GLOW_RELEASE_PEAK, 0),
|
||||
)
|
||||
private envelopes = [
|
||||
this.runAttack,
|
||||
this.runFade,
|
||||
this.completionPulse,
|
||||
this.edgeFlash,
|
||||
this.ignition,
|
||||
this.glowOff,
|
||||
this.release,
|
||||
]
|
||||
|
||||
constructor(options: PulseStateOptions) {
|
||||
this.enabled = options.enabled
|
||||
@@ -211,6 +232,14 @@ class PulseState {
|
||||
return this.ignition.active ? this.ignition.level() : 1
|
||||
}
|
||||
|
||||
get releaseLevel() {
|
||||
return this.release.level()
|
||||
}
|
||||
|
||||
get releaseProgress() {
|
||||
return this.release.progress
|
||||
}
|
||||
|
||||
setEnabled(value: boolean) {
|
||||
if (value === this.enabled) return false
|
||||
this.enabled = value
|
||||
@@ -266,11 +295,16 @@ class PulseState {
|
||||
|
||||
setGlow(value: boolean) {
|
||||
if (value === this.glow) return false
|
||||
if (this.enabled && !value) this.glowOff.start(this.glowLevel)
|
||||
if (this.enabled && !value) {
|
||||
// Resolving the glow sends it off: residual glow drains while the release diffuses outward.
|
||||
this.glowOff.start(this.glowLevel)
|
||||
this.release.restart(Math.max(1, this.glowLevel))
|
||||
}
|
||||
this.glow = value
|
||||
this.ignition.stop()
|
||||
if (this.enabled && value) {
|
||||
this.glowOff.stop()
|
||||
this.release.stop()
|
||||
this.ignition.start()
|
||||
}
|
||||
return true
|
||||
@@ -523,16 +557,20 @@ class TabPulseRenderable extends Renderable {
|
||||
const completion = this.inner.completion
|
||||
const flash = this.inner.flash
|
||||
const glowLevel = this.inner.glowLevel
|
||||
const releaseLevel = this.inner.releaseLevel
|
||||
const outerRunning = this.outer.running
|
||||
const outerCompletion = this.outer.completion
|
||||
const outerFlash = this.outer.flash
|
||||
const outerGlowLevel = this.outer.glowLevel
|
||||
const outerReleaseLevel = this.outer.releaseLevel
|
||||
if (
|
||||
glowLevel === 0 &&
|
||||
releaseLevel === 0 &&
|
||||
running === 0 &&
|
||||
completion === 0 &&
|
||||
flash === 0 &&
|
||||
outerGlowLevel === 0 &&
|
||||
outerReleaseLevel === 0 &&
|
||||
outerRunning === 0 &&
|
||||
outerCompletion === 0 &&
|
||||
outerFlash === 0
|
||||
@@ -551,6 +589,8 @@ class TabPulseRenderable extends Renderable {
|
||||
)
|
||||
const glowTail = Math.min(this._glowTail, Math.max(1, this.width - 2))
|
||||
const outerGlowTail = Math.min(this._outerGlowTail, Math.max(1, this.width - 2))
|
||||
const releaseSpread = glowReleaseSpread(this.inner.releaseProgress ?? 0, glowTail, this.width)
|
||||
const outerReleaseSpread = glowReleaseSpread(this.outer.releaseProgress ?? 0, outerGlowTail, this.width)
|
||||
const flashTail = this._flashTail === undefined ? undefined : Math.min(this._flashTail, Math.max(1, this.width - 2))
|
||||
const outerFlashTail =
|
||||
this._outerFlashTail === undefined ? undefined : Math.min(this._outerFlashTail, Math.max(1, this.width - 2))
|
||||
@@ -581,7 +621,10 @@ class TabPulseRenderable extends Renderable {
|
||||
this._color,
|
||||
this._flashColor,
|
||||
this._completionColor,
|
||||
glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel,
|
||||
Math.max(
|
||||
glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel,
|
||||
releaseLevel === 0 ? 0 : glowIntensityAt(index, releaseSpread) * GLOW_OPACITY * releaseLevel,
|
||||
),
|
||||
sweep,
|
||||
flashTail === undefined ? flash : flash * tabFlashIntensity(index, flashTail),
|
||||
completion,
|
||||
@@ -597,7 +640,10 @@ class TabPulseRenderable extends Renderable {
|
||||
this._outerColor,
|
||||
this._outerFlashColor,
|
||||
this._outerCompletionColor,
|
||||
outerGlowLevel === 0 ? 0 : glowIntensityAt(index, outerGlowTail) * GLOW_OPACITY * outerGlowLevel,
|
||||
Math.max(
|
||||
outerGlowLevel === 0 ? 0 : glowIntensityAt(index, outerGlowTail) * GLOW_OPACITY * outerGlowLevel,
|
||||
outerReleaseLevel === 0 ? 0 : glowIntensityAt(index, outerReleaseSpread) * GLOW_OPACITY * outerReleaseLevel,
|
||||
),
|
||||
outerSweep,
|
||||
outerFlashTail === undefined ? outerFlash : outerFlash * tabFlashIntensity(index, outerFlashTail),
|
||||
outerCompletion,
|
||||
|
||||
Reference in New Issue
Block a user