feat(tui): add unread tab glow (#39428)
This commit is contained in:
parent
a7b2ea94e5
commit
f64b50d71b
3 changed files with 63 additions and 10 deletions
|
|
@ -170,7 +170,9 @@ export function SessionTabs() {
|
||||||
enabled={config.animations ?? true}
|
enabled={config.animations ?? true}
|
||||||
active={status().busy}
|
active={status().busy}
|
||||||
complete={status().complete}
|
complete={status().complete}
|
||||||
|
glow={status().unread === "activity" && !status().busy && !selected() && !status().attention}
|
||||||
color={pulseColor()}
|
color={pulseColor()}
|
||||||
|
glowColor={accent()}
|
||||||
completionColor={accent()}
|
completionColor={accent()}
|
||||||
backgroundColor={pulseBackground()}
|
backgroundColor={pulseBackground()}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
active?: boolean
|
active?: boolean
|
||||||
complete?: boolean
|
complete?: boolean
|
||||||
|
glow?: boolean
|
||||||
color?: RGBA
|
color?: RGBA
|
||||||
|
glowColor?: RGBA
|
||||||
completionColor?: RGBA
|
completionColor?: RGBA
|
||||||
backgroundColor?: RGBA
|
backgroundColor?: RGBA
|
||||||
}
|
}
|
||||||
|
|
@ -19,6 +21,8 @@ const RUN_TAIL = 18
|
||||||
const RUN_FADE_OUT = 500
|
const RUN_FADE_OUT = 500
|
||||||
const COMPLETION_DURATION = 900
|
const COMPLETION_DURATION = 900
|
||||||
const COMPLETION_ATTACK = 0.16
|
const COMPLETION_ATTACK = 0.16
|
||||||
|
const GLOW_TAIL = 12
|
||||||
|
const GLOW_OPACITY = 0.16
|
||||||
const intensityAt = (index: number, front: number, head: number, tail: number) => {
|
const intensityAt = (index: number, front: number, head: number, tail: number) => {
|
||||||
const distance = front - index
|
const distance = front - index
|
||||||
return distance < 0 ? smootherstep(clamp(1 + distance / head)) : smootherstep(clamp(1 - distance / tail))
|
return distance < 0 ? smootherstep(clamp(1 + distance / head)) : smootherstep(clamp(1 - distance / tail))
|
||||||
|
|
@ -33,11 +37,17 @@ export const completionPulseOpacity = (progress: number) =>
|
||||||
progress < COMPLETION_ATTACK
|
progress < COMPLETION_ATTACK
|
||||||
? smootherstep(clamp(progress / COMPLETION_ATTACK))
|
? smootherstep(clamp(progress / COMPLETION_ATTACK))
|
||||||
: 1 - smootherstep(clamp((progress - COMPLETION_ATTACK) / (1 - COMPLETION_ATTACK)))
|
: 1 - smootherstep(clamp((progress - COMPLETION_ATTACK) / (1 - COMPLETION_ATTACK)))
|
||||||
|
export const unreadGlowIntensity = (index: number, width: number) => {
|
||||||
|
const tail = Math.min(GLOW_TAIL, Math.max(1, width - 2))
|
||||||
|
return smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
|
||||||
|
}
|
||||||
class TabPulseRenderable extends Renderable {
|
class TabPulseRenderable extends Renderable {
|
||||||
private _enabled: boolean
|
private _enabled: boolean
|
||||||
private _active: boolean
|
private _active: boolean
|
||||||
private _complete: boolean
|
private _complete: boolean
|
||||||
|
private _glow: boolean
|
||||||
private _color: RGBA
|
private _color: RGBA
|
||||||
|
private _glowColor: RGBA
|
||||||
private _completionColor: RGBA
|
private _completionColor: RGBA
|
||||||
private _backgroundColor: RGBA
|
private _backgroundColor: RGBA
|
||||||
private clock = 0
|
private clock = 0
|
||||||
|
|
@ -52,7 +62,9 @@ class TabPulseRenderable extends Renderable {
|
||||||
this._enabled = enabled
|
this._enabled = enabled
|
||||||
this._active = active
|
this._active = active
|
||||||
this._complete = options.complete ?? false
|
this._complete = options.complete ?? false
|
||||||
|
this._glow = options.glow ?? false
|
||||||
this._color = options.color ?? RGBA.defaultForeground()
|
this._color = options.color ?? RGBA.defaultForeground()
|
||||||
|
this._glowColor = options.glowColor ?? this._color
|
||||||
this._completionColor = options.completionColor ?? this._color
|
this._completionColor = options.completionColor ?? this._color
|
||||||
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
|
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
|
||||||
}
|
}
|
||||||
|
|
@ -103,12 +115,24 @@ class TabPulseRenderable extends Renderable {
|
||||||
this.requestRender()
|
this.requestRender()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set glow(value: boolean) {
|
||||||
|
if (value === this._glow) return
|
||||||
|
this._glow = value
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
set color(value: RGBA) {
|
set color(value: RGBA) {
|
||||||
if (value.equals(this._color)) return
|
if (value.equals(this._color)) return
|
||||||
this._color = value
|
this._color = value
|
||||||
this.requestRender()
|
this.requestRender()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set glowColor(value: RGBA) {
|
||||||
|
if (value.equals(this._glowColor)) return
|
||||||
|
this._glowColor = value
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
set completionColor(value: RGBA) {
|
set completionColor(value: RGBA) {
|
||||||
if (value.equals(this._completionColor)) return
|
if (value.equals(this._completionColor)) return
|
||||||
this._completionColor = value
|
this._completionColor = value
|
||||||
|
|
@ -144,15 +168,19 @@ class TabPulseRenderable extends Renderable {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override renderSelf(buffer: OptimizedBuffer): void {
|
protected override renderSelf(buffer: OptimizedBuffer): void {
|
||||||
if (!this.visible || this.isDestroyed || !this._enabled || this.width <= 0) return
|
if (!this.visible || this.isDestroyed || this.width <= 0) return
|
||||||
const runningOpacity = this._active
|
const runningOpacity = !this._enabled
|
||||||
? 1
|
? 0
|
||||||
: this.fadeClock === undefined
|
: this._active
|
||||||
? 0
|
? 1
|
||||||
: 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT))
|
: this.fadeClock === undefined
|
||||||
|
? 0
|
||||||
|
: 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT))
|
||||||
const completionOpacity =
|
const completionOpacity =
|
||||||
this.completionClock === undefined ? 0 : completionPulseOpacity(this.completionClock / COMPLETION_DURATION)
|
!this._enabled || this.completionClock === undefined
|
||||||
if (runningOpacity === 0 && completionOpacity === 0) return
|
? 0
|
||||||
|
: completionPulseOpacity(this.completionClock / COMPLETION_DURATION)
|
||||||
|
if (!this._glow && runningOpacity === 0 && completionOpacity === 0) return
|
||||||
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
|
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
|
||||||
const start = -RUN_HEAD
|
const start = -RUN_HEAD
|
||||||
const end = this.width - 1 + RUN_TAIL
|
const end = this.width - 1 + RUN_TAIL
|
||||||
|
|
@ -163,7 +191,10 @@ class TabPulseRenderable extends Renderable {
|
||||||
intensityAt(index, front, RUN_HEAD, RUN_TAIL),
|
intensityAt(index, front, RUN_HEAD, RUN_TAIL),
|
||||||
intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
|
intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
|
||||||
)
|
)
|
||||||
const running = tint(this._backgroundColor, this._color, intensity * 0.14 * runningOpacity)
|
const background = this._glow
|
||||||
|
? tint(this._backgroundColor, this._glowColor, unreadGlowIntensity(index, this.width) * GLOW_OPACITY)
|
||||||
|
: this._backgroundColor
|
||||||
|
const running = tint(background, this._color, intensity * 0.14 * runningOpacity)
|
||||||
buffer.setCell(
|
buffer.setCell(
|
||||||
this.screenX + index,
|
this.screenX + index,
|
||||||
this.screenY,
|
this.screenY,
|
||||||
|
|
@ -187,7 +218,9 @@ export function TabPulse(props: {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
active: boolean
|
active: boolean
|
||||||
complete?: boolean
|
complete?: boolean
|
||||||
|
glow?: boolean
|
||||||
color: RGBA
|
color: RGBA
|
||||||
|
glowColor?: RGBA
|
||||||
completionColor?: RGBA
|
completionColor?: RGBA
|
||||||
backgroundColor: RGBA
|
backgroundColor: RGBA
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -199,7 +232,9 @@ export function TabPulse(props: {
|
||||||
enabled={props.enabled ?? true}
|
enabled={props.enabled ?? true}
|
||||||
active={props.active}
|
active={props.active}
|
||||||
complete={props.complete ?? false}
|
complete={props.complete ?? false}
|
||||||
|
glow={props.glow ?? false}
|
||||||
color={props.color}
|
color={props.color}
|
||||||
|
glowColor={props.glowColor ?? props.color}
|
||||||
completionColor={props.completionColor ?? props.color}
|
completionColor={props.completionColor ?? props.color}
|
||||||
backgroundColor={props.backgroundColor}
|
backgroundColor={props.backgroundColor}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { completionPulseOpacity } from "../../src/component/tab-pulse"
|
import { completionPulseOpacity, unreadGlowIntensity } from "../../src/component/tab-pulse"
|
||||||
|
|
||||||
test("completion pulse rises quickly and fades over the remaining duration", () => {
|
test("completion pulse rises quickly and fades over the remaining duration", () => {
|
||||||
expect(completionPulseOpacity(0)).toBe(0)
|
expect(completionPulseOpacity(0)).toBe(0)
|
||||||
|
|
@ -8,3 +8,19 @@ test("completion pulse rises quickly and fades over the remaining duration", ()
|
||||||
expect(completionPulseOpacity(0.58)).toBeCloseTo(0.5)
|
expect(completionPulseOpacity(0.58)).toBeCloseTo(0.5)
|
||||||
expect(completionPulseOpacity(1)).toBe(0)
|
expect(completionPulseOpacity(1)).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("unread glow peaks behind the tab number and fades to the normal background", () => {
|
||||||
|
const intensities = Array.from({ length: 22 }, (_, index) => unreadGlowIntensity(index, 22))
|
||||||
|
|
||||||
|
expect(intensities[0]).toBe(1)
|
||||||
|
expect(intensities[1]).toBe(1)
|
||||||
|
expect(intensities[2]).toBeLessThan(1)
|
||||||
|
expect(intensities.slice(1)).toEqual(intensities.slice(1).sort((a, b) => b - a))
|
||||||
|
expect(intensities[13]).toBe(0)
|
||||||
|
expect(intensities.at(-1)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unread glow reaches the normal background on compact tabs", () => {
|
||||||
|
expect(unreadGlowIntensity(0, 8)).toBe(1)
|
||||||
|
expect(unreadGlowIntensity(7, 8)).toBe(0)
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue