fix(tui): breathe running glyph symmetrically instead of blanking at the trough
The running status glyph clipped its cosine throb to a blank column below STATUS_FADE_MIN_OPACITY, so each breath read as bold->dim->disappear rather than bold->dim->bold. Remove the cutoff and the constant; the trough now renders as the dimmest gray, giving a symmetric breathe. Turn-boundary appear/disappear is unchanged (fade envelope and non-truecolor visible threshold).
This commit is contained in:
@@ -29,19 +29,12 @@ export const STATUS_PULSE_PERIOD_MS = 1400
|
||||
|
||||
/**
|
||||
* Brightness floor of the running throb, as a fraction of the settled gray. At
|
||||
* 0 the pulse swells from fully invisible (a blank glyph column, see
|
||||
* {@link STATUS_FADE_MIN_OPACITY}) up to full and back, so the dimmest point of
|
||||
* each breath truly disappears rather than lingering as a faint mark.
|
||||
* 0 the pulse swells from the near-background trough up to full and back. The
|
||||
* trough is still rendered as the dimmest gray, not clipped to a blank, so the
|
||||
* cosine breathes symmetrically bold→dim→bold.
|
||||
*/
|
||||
export const STATUS_PULSE_FLOOR = 0
|
||||
|
||||
/**
|
||||
* Opacity below which the truecolor running glyph is hidden entirely (a blank
|
||||
* column) instead of painted as a near-background gray, so the trough of the
|
||||
* pulse reads as invisible. The fixed glyph width is preserved by the blank.
|
||||
*/
|
||||
export const STATUS_FADE_MIN_OPACITY = 0.12
|
||||
|
||||
/**
|
||||
* Muted-gray foreground the truecolor running glyph fades through, from the
|
||||
* near-background trough (opacity 0) to the settled dim gray (opacity 1). Same
|
||||
@@ -243,8 +236,8 @@ export function runningPhaseGlyph(events: readonly SessionEvent[], running: bool
|
||||
/**
|
||||
* The running throb's brightness at continuous clock `nowMs`: a cosine between
|
||||
* {@link STATUS_PULSE_FLOOR} and 1 over {@link STATUS_PULSE_PERIOD_MS}, so the
|
||||
* dim glyph breathes without ever blinking off. Multiplied by the fade envelope
|
||||
* to gate appear/disappear.
|
||||
* dim glyph breathes bold→dim→bold without ever blinking off. Multiplied by the
|
||||
* fade envelope, which alone drives appear/disappear at turn boundaries.
|
||||
*
|
||||
* @param nowMs - Monotonic render clock in milliseconds.
|
||||
* @returns Brightness fraction in [{@link STATUS_PULSE_FLOOR}, 1].
|
||||
@@ -256,16 +249,16 @@ export function pulseLevel(nowMs: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* One frame of the running glyph at fade `opacity` (0 = invisible trough,
|
||||
* 1 = settled dim gray). The character and its width never change — only the
|
||||
* gray fades — so the prompt caret column stays fixed and the glyph reads as
|
||||
* the caret dimly appearing and disappearing, never a colored indicator.
|
||||
* One frame of the running glyph at fade `opacity` (0 = near-background trough
|
||||
* gray, 1 = settled dim gray). The character and its width never change — only
|
||||
* the gray fades — so the prompt caret column stays fixed and the glyph reads as
|
||||
* the caret dimly breathing, never a colored indicator.
|
||||
*
|
||||
* With truecolor the glyph's 24-bit gray foreground interpolates between
|
||||
* {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade and the
|
||||
* running throb render as brightness; below {@link STATUS_FADE_MIN_OPACITY} it
|
||||
* is hidden entirely so the pulse trough disappears. Without truecolor there is
|
||||
* no per-frame gray, so `visible` (driven by the fade envelope, not the opacity)
|
||||
* With truecolor the glyph's 24-bit gray foreground interpolates continuously
|
||||
* between {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade
|
||||
* and the running throb render as a smooth, symmetric brightness swing with no
|
||||
* hard cutoff to clip the trough into a blank. Without truecolor there is no
|
||||
* per-frame gray, so `visible` (driven by the fade envelope, not the opacity)
|
||||
* shows the glyph in the palette's muted role or leaves a blank column — a
|
||||
* single dim appear/disappear at fixed width, still dim rather than accent, and
|
||||
* no throb-driven blink. With color off entirely a visible glyph is bare,
|
||||
@@ -277,7 +270,7 @@ export function pulseLevel(nowMs: number): number {
|
||||
* @param truecolor - Whether the terminal accepts 24-bit foreground codes.
|
||||
* @param opacity - Brightness fraction in [0, 1] for the truecolor gray.
|
||||
* @param visible - Whether the non-truecolor fallback shows the glyph at all.
|
||||
* @returns The dim-gray glyph at this opacity, or a single space when hidden.
|
||||
* @returns The gray glyph at this opacity, or a single space when hidden.
|
||||
*/
|
||||
export function fadeGlyph(
|
||||
glyph: string,
|
||||
@@ -289,9 +282,6 @@ export function fadeGlyph(
|
||||
): string {
|
||||
if (truecolor && colorEnabled) {
|
||||
const o = Math.min(Math.max(opacity, 0), 1)
|
||||
// Below the visibility threshold the glyph is fully hidden, so the pulse
|
||||
// trough disappears rather than lingering as a near-background gray.
|
||||
if (o < STATUS_FADE_MIN_OPACITY) return ' '
|
||||
const [tr, tg, tb] = STATUS_FADE_GRAY.trough
|
||||
const [sr, sg, sb] = STATUS_FADE_GRAY.settled
|
||||
const r = Math.round(tr + (sr - tr) * o)
|
||||
|
||||
@@ -1616,7 +1616,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
return r
|
||||
}
|
||||
|
||||
it('throbs the running glyph in dim gray, swelling from invisible to full, never accent', async () => {
|
||||
it('throbs the running glyph in dim gray, breathing between trough and full without blanking, never accent', async () => {
|
||||
let clock = 0
|
||||
let chunkIndex = 0
|
||||
const result = await setup({ status: 'running', config: { theme: { color: true, truecolor: true } }, now: () => clock })
|
||||
@@ -1631,11 +1631,13 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
return result.terminal.output
|
||||
}
|
||||
|
||||
// The pulse swells from fully invisible at its trough to the settled peak
|
||||
// and back. At phase 0 (t=1400, pulse level 0, past fade-in) the glyph is
|
||||
// hidden — the slot carries no ● — so the dimmest breath truly disappears.
|
||||
// The pulse breathes between the dimmest trough gray and the settled peak
|
||||
// and back, never blanking. At phase 0 (t=1400, pulse level 0, past fade-in)
|
||||
// the glyph still paints the trough gray, so the breath dims but never
|
||||
// disappears — a symmetric bold→dim→bold throb.
|
||||
const trough = await frameAt(1_400)
|
||||
expect(trough).not.toMatch(/●/u)
|
||||
expect(trough).toMatch(/●/u)
|
||||
expect(glyphGray(trough)).toBe(43)
|
||||
// Half a period later (t=2100, pulse peak) it paints the brightest gray.
|
||||
const peak = await frameAt(2_100)
|
||||
expect(glyphGray(peak)).toBe(136)
|
||||
|
||||
Reference in New Issue
Block a user