From f2a4555b03f8db0ee0138658c1415404ccda7cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Pierre=20Bouchard?= Date: Sat, 21 Mar 2026 11:06:50 -0400 Subject: [PATCH] feat: add time cursor to usage progress bars The old progress bar showed only how much of the usage limit had been consumed. The new makeTimerProgressBar adds an optional cursor marker that shows the elapsed time position within the current usage window. Users can toggle the cursor on and off with the t keybind when in progress display mode. --- src/widgets/SessionUsage.ts | 25 +++++- src/widgets/WeeklyUsage.ts | 25 +++++- src/widgets/__tests__/SessionUsage.test.ts | 6 +- src/widgets/__tests__/WeeklyUsage.test.ts | 6 +- .../__tests__/helpers/usage-widget-suites.ts | 9 ++- .../__tests__/shared/progress-bar.test.ts | 77 +++++++++++++++++++ src/widgets/shared/progress-bar.ts | 27 +++++++ src/widgets/shared/usage-display.ts | 16 +++- 8 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 src/widgets/__tests__/shared/progress-bar.test.ts create mode 100644 src/widgets/shared/progress-bar.ts diff --git a/src/widgets/SessionUsage.ts b/src/widgets/SessionUsage.ts index 1805e918..d3b61592 100644 --- a/src/widgets/SessionUsage.ts +++ b/src/widgets/SessionUsage.ts @@ -8,9 +8,10 @@ import type { } from '../types/Widget'; import { getUsageErrorMessage, - makeUsageProgressBar + resolveUsageWindowWithFallback } from '../utils/usage'; +import { makeTimerProgressBar } from './shared/progress-bar'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { cycleUsageDisplayMode, @@ -18,8 +19,10 @@ import { getUsageDisplayModifierText, getUsagePercentCustomKeybinds, getUsageProgressBarWidth, + isUsageCursorEnabled, isUsageInverted, isUsageProgressMode, + toggleUsageCursor, toggleUsageInverted } from './shared/usage-display'; @@ -45,12 +48,17 @@ export class SessionUsageWidget implements Widget { return toggleUsageInverted(item); } + if (action === 'toggle-cursor') { + return toggleUsageCursor(item); + } + return null; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); + const showCursor = isUsageCursorEnabled(item); if (context.isPreview) { const previewPercent = 20; @@ -58,7 +66,8 @@ export class SessionUsageWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); - const progressDisplay = `${makeUsageProgressBar(renderedPercent, width)} ${renderedPercent.toFixed(1)}%`; + const progressBar = makeTimerProgressBar(renderedPercent, width, showCursor ? { cursorPercent: 50 } : undefined); + const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; return formatRawOrLabeledValue(item, 'Session: ', progressDisplay); } @@ -75,7 +84,17 @@ export class SessionUsageWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const renderedPercent = inverted ? 100 - percent : percent; - const progressDisplay = `${makeUsageProgressBar(renderedPercent, width)} ${renderedPercent.toFixed(1)}%`; + + let cursorOpts; + if (showCursor) { + const window = resolveUsageWindowWithFallback(data, context.blockMetrics); + if (window) { + cursorOpts = { cursorPercent: window.elapsedPercent }; + } + } + + const progressBar = makeTimerProgressBar(renderedPercent, width, cursorOpts); + const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; return formatRawOrLabeledValue(item, 'Session: ', progressDisplay); } diff --git a/src/widgets/WeeklyUsage.ts b/src/widgets/WeeklyUsage.ts index c4858046..ef38fdf3 100644 --- a/src/widgets/WeeklyUsage.ts +++ b/src/widgets/WeeklyUsage.ts @@ -8,9 +8,10 @@ import type { } from '../types/Widget'; import { getUsageErrorMessage, - makeUsageProgressBar + resolveWeeklyUsageWindow } from '../utils/usage'; +import { makeTimerProgressBar } from './shared/progress-bar'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { cycleUsageDisplayMode, @@ -18,8 +19,10 @@ import { getUsageDisplayModifierText, getUsagePercentCustomKeybinds, getUsageProgressBarWidth, + isUsageCursorEnabled, isUsageInverted, isUsageProgressMode, + toggleUsageCursor, toggleUsageInverted } from './shared/usage-display'; @@ -45,12 +48,17 @@ export class WeeklyUsageWidget implements Widget { return toggleUsageInverted(item); } + if (action === 'toggle-cursor') { + return toggleUsageCursor(item); + } + return null; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); + const showCursor = isUsageCursorEnabled(item); if (context.isPreview) { const previewPercent = 12; @@ -58,7 +66,8 @@ export class WeeklyUsageWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); - const progressDisplay = `${makeUsageProgressBar(renderedPercent, width)} ${renderedPercent.toFixed(1)}%`; + const progressBar = makeTimerProgressBar(renderedPercent, width, showCursor ? { cursorPercent: 50 } : undefined); + const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; return formatRawOrLabeledValue(item, 'Weekly: ', progressDisplay); } @@ -75,7 +84,17 @@ export class WeeklyUsageWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const renderedPercent = inverted ? 100 - percent : percent; - const progressDisplay = `${makeUsageProgressBar(renderedPercent, width)} ${renderedPercent.toFixed(1)}%`; + + let cursorOpts; + if (showCursor) { + const window = resolveWeeklyUsageWindow(data); + if (window) { + cursorOpts = { cursorPercent: window.elapsedPercent }; + } + } + + const progressBar = makeTimerProgressBar(renderedPercent, width, cursorOpts); + const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; return formatRawOrLabeledValue(item, 'Weekly: ', progressDisplay); } diff --git a/src/widgets/__tests__/SessionUsage.test.ts b/src/widgets/__tests__/SessionUsage.test.ts index 777dea7e..af6b2064 100644 --- a/src/widgets/__tests__/SessionUsage.test.ts +++ b/src/widgets/__tests__/SessionUsage.test.ts @@ -28,7 +28,7 @@ describe('SessionUsageWidget', () => { beforeEach(() => { vi.restoreAllMocks(); mockGetUsageErrorMessage = vi.spyOn(usage, 'getUsageErrorMessage'); - vi.spyOn(usage, 'makeUsageProgressBar').mockImplementation((percent: number, width = 15) => `[bar:${percent.toFixed(1)}:${width}]`); + // makeUsageProgressBar no longer used; SessionUsage uses makeTimerProgressBar directly }); afterEach(() => { @@ -40,8 +40,8 @@ describe('SessionUsageWidget', () => { createWidget: () => new SessionUsageWidget(), errorMessageMock: usageErrorMessageMock, expectedModifierText: '(short bar, inverted)', - expectedProgress: 'Session: [bar:76.5:16] 76.5%', - expectedRawProgress: '[bar:23.4:32] 23.4%', + expectedProgress: 'Session: [████████████░░░░] 76.5%', + expectedRawProgress: '[████████░░░░░░░░░░░░░░░░░░░░░░░░] 23.4%', expectedRawTime: '23.4%', expectedTime: 'Session: 23.4%', modifierItem: { diff --git a/src/widgets/__tests__/WeeklyUsage.test.ts b/src/widgets/__tests__/WeeklyUsage.test.ts index fa97a944..2d9ee55b 100644 --- a/src/widgets/__tests__/WeeklyUsage.test.ts +++ b/src/widgets/__tests__/WeeklyUsage.test.ts @@ -28,7 +28,7 @@ describe('WeeklyUsageWidget', () => { beforeEach(() => { vi.restoreAllMocks(); mockGetUsageErrorMessage = vi.spyOn(usage, 'getUsageErrorMessage'); - vi.spyOn(usage, 'makeUsageProgressBar').mockImplementation((percent: number, width = 15) => `[bar:${percent.toFixed(1)}:${width}]`); + // makeUsageProgressBar no longer used; WeeklyUsage uses makeTimerProgressBar directly }); afterEach(() => { @@ -40,8 +40,8 @@ describe('WeeklyUsageWidget', () => { createWidget: () => new WeeklyUsageWidget(), errorMessageMock: usageErrorMessageMock, expectedModifierText: '(progress bar, inverted)', - expectedProgress: 'Weekly: [bar:57.9:32] 57.9%', - expectedRawProgress: '[bar:42.1:16] 42.1%', + expectedProgress: 'Weekly: [███████████████████░░░░░░░░░░░░░] 57.9%', + expectedRawProgress: '[███████░░░░░░░░░] 42.1%', expectedRawTime: '42.1%', expectedTime: 'Weekly: 42.1%', modifierItem: { diff --git a/src/widgets/__tests__/helpers/usage-widget-suites.ts b/src/widgets/__tests__/helpers/usage-widget-suites.ts index a73cca3f..4f4d09c3 100644 --- a/src/widgets/__tests__/helpers/usage-widget-suites.ts +++ b/src/widgets/__tests__/helpers/usage-widget-suites.ts @@ -53,7 +53,8 @@ const EXPECTED_USAGE_KEYBINDS: CustomKeybind[] = [ const EXPECTED_USAGE_PROGRESS_KEYBINDS: CustomKeybind[] = [ { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, - { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' } + { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }, + { key: 't', label: '(t)ime cursor', action: 'toggle-cursor' } ]; const EXPECTED_TIMER_TIME_KEYBINDS: CustomKeybind[] = [ @@ -120,18 +121,20 @@ export function runUsagePercentWidgetSuite(conf expect(config.render(widget, config.baseItem, { usageData: { error: 'timeout' } })).toBe('[Timeout]'); }); - it('clears invert metadata when cycling back to time mode', () => { + it('clears invert and cursor metadata when cycling back to time mode', () => { const widget = config.createWidget(); const updated = widget.handleEditorAction('toggle-progress', { ...config.baseItem, metadata: { display: 'progress-short', - invert: 'true' + invert: 'true', + cursor: 'true' } }); expect(updated?.metadata?.display).toBe('time'); expect(updated?.metadata?.invert).toBeUndefined(); + expect(updated?.metadata?.cursor).toBeUndefined(); }); it('cycles display modes in the expected order', () => { diff --git a/src/widgets/__tests__/shared/progress-bar.test.ts b/src/widgets/__tests__/shared/progress-bar.test.ts new file mode 100644 index 00000000..c3b5f2f7 --- /dev/null +++ b/src/widgets/__tests__/shared/progress-bar.test.ts @@ -0,0 +1,77 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { makeTimerProgressBar } from '../../shared/progress-bar'; + +describe('makeTimerProgressBar', () => { + it('renders a fully empty bar at 0%', () => { + expect(makeTimerProgressBar(0, 16)).toBe('░░░░░░░░░░░░░░░░'); + }); + + it('renders a fully filled bar at 100%', () => { + expect(makeTimerProgressBar(100, 16)).toBe('████████████████'); + }); + + it('renders a partially filled bar at 50%', () => { + expect(makeTimerProgressBar(50, 16)).toBe('████████░░░░░░░░'); + }); + + it('clamps percent below 0', () => { + expect(makeTimerProgressBar(-10, 16)).toBe('░░░░░░░░░░░░░░░░'); + }); + + it('clamps percent above 100', () => { + expect(makeTimerProgressBar(150, 16)).toBe('████████████████'); + }); + + it('renders with a 32-char width', () => { + const bar = makeTimerProgressBar(25, 32); + expect(bar).toHaveLength(32); + expect(bar).toBe('████████░░░░░░░░░░░░░░░░░░░░░░░░'); + }); + + describe('time cursor', () => { + it('places cursor at the correct position', () => { + const bar = makeTimerProgressBar(50, 16, { cursorPercent: 50 }); + expect(bar).toBe('████████│░░░░░░░'); + }); + + it('places cursor within the filled region', () => { + const bar = makeTimerProgressBar(75, 16, { cursorPercent: 25 }); + expect(bar).toBe('████│███████░░░░'); + }); + + it('places cursor within the empty region', () => { + const bar = makeTimerProgressBar(25, 16, { cursorPercent: 75 }); + expect(bar).toBe('████░░░░░░░░│░░░'); + }); + + it('places cursor at position 0', () => { + const bar = makeTimerProgressBar(50, 16, { cursorPercent: 0 }); + expect(bar).toBe('│███████░░░░░░░░'); + }); + + it('places cursor at the last position for 100%', () => { + const bar = makeTimerProgressBar(50, 16, { cursorPercent: 100 }); + expect(bar).toBe('████████░░░░░░░│'); + }); + + it('clamps negative cursor percent', () => { + const bar = makeTimerProgressBar(50, 16, { cursorPercent: -10 }); + expect(bar).toBe('│███████░░░░░░░░'); + }); + + it('clamps cursor percent above 100', () => { + const bar = makeTimerProgressBar(50, 16, { cursorPercent: 150 }); + expect(bar).toBe('████████░░░░░░░│'); + }); + + it('does not render cursor when cursorPercent is undefined', () => { + const bar = makeTimerProgressBar(50, 16, {}); + expect(bar).toBe('████████░░░░░░░░'); + }); + }); +}); \ No newline at end of file diff --git a/src/widgets/shared/progress-bar.ts b/src/widgets/shared/progress-bar.ts new file mode 100644 index 00000000..5bcf5ab3 --- /dev/null +++ b/src/widgets/shared/progress-bar.ts @@ -0,0 +1,27 @@ +export interface TimerProgressBarOptions { cursorPercent?: number } + +export function makeTimerProgressBar( + percent: number, + width: number, + options?: TimerProgressBarOptions +): string { + const clampedPercent = Math.max(0, Math.min(100, percent)); + const filledWidth = Math.round((clampedPercent / 100) * width); + + const cursorPos = options?.cursorPercent !== undefined + ? Math.min(Math.floor((Math.max(0, Math.min(100, options.cursorPercent)) / 100) * width), width - 1) + : -1; + + let bar = ''; + for (let i = 0; i < width; i++) { + if (i === cursorPos) { + bar += '│'; + } else if (i < filledWidth) { + bar += '█'; + } else { + bar += '░'; + } + } + + return bar; +} \ No newline at end of file diff --git a/src/widgets/shared/usage-display.ts b/src/widgets/shared/usage-display.ts index e42e99ee..1a7b865c 100644 --- a/src/widgets/shared/usage-display.ts +++ b/src/widgets/shared/usage-display.ts @@ -15,6 +15,7 @@ export type UsageDisplayMode = 'time' | 'progress' | 'progress-short'; const PROGRESS_TOGGLE_KEYBIND: CustomKeybind = { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }; const INVERT_TOGGLE_KEYBIND: CustomKeybind = { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }; const COMPACT_TOGGLE_KEYBIND: CustomKeybind = { key: 's', label: '(s)hort time', action: 'toggle-compact' }; +const CURSOR_TOGGLE_KEYBIND: CustomKeybind = { key: 't', label: '(t)ime cursor', action: 'toggle-cursor' }; export function getUsageDisplayMode(item: WidgetItem): UsageDisplayMode { const mode = item.metadata?.display; @@ -40,6 +41,14 @@ export function isUsageCompact(item: WidgetItem): boolean { return isMetadataFlagEnabled(item, 'compact'); } +export function isUsageCursorEnabled(item: WidgetItem): boolean { + return isMetadataFlagEnabled(item, 'cursor'); +} + +export function toggleUsageCursor(item: WidgetItem): WidgetItem { + return toggleMetadataFlag(item, 'cursor'); +} + export function toggleUsageCompact(item: WidgetItem): WidgetItem { return toggleMetadataFlag(item, 'compact'); } @@ -63,6 +72,10 @@ export function getUsageDisplayModifierText( modifiers.push('inverted'); } + if (isUsageCursorEnabled(item) && isUsageProgressMode(mode)) { + modifiers.push('time cursor'); + } + if (options.includeCompact && !isUsageProgressMode(mode) && isUsageCompact(item)) { modifiers.push('compact'); } @@ -79,7 +92,7 @@ export function cycleUsageDisplayMode(item: WidgetItem, disabledInProgressKeys: : 'time'; const nextItem = removeMetadataKeys(item, nextMode === 'time' - ? ['invert'] + ? ['invert', 'cursor'] : disabledInProgressKeys); const nextMetadata: Record = { ...(nextItem.metadata ?? {}), @@ -101,6 +114,7 @@ export function getUsagePercentCustomKeybinds(item?: WidgetItem): CustomKeybind[ if (item && isUsageProgressMode(getUsageDisplayMode(item))) { keybinds.push(INVERT_TOGGLE_KEYBIND); + keybinds.push(CURSOR_TOGGLE_KEYBIND); } return keybinds;