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;