Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions src/widgets/SessionUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,21 @@ 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,
getUsageDisplayMode,
getUsageDisplayModifierText,
getUsagePercentCustomKeybinds,
getUsageProgressBarWidth,
isUsageCursorEnabled,
isUsageInverted,
isUsageProgressMode,
toggleUsageCursor,
toggleUsageInverted
} from './shared/usage-display';

Expand All @@ -45,20 +48,26 @@ 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;
const renderedPercent = inverted ? 100 - previewPercent : previewPercent;

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);
}

Expand All @@ -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);
}

Expand Down
25 changes: 22 additions & 3 deletions src/widgets/WeeklyUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,21 @@ 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,
getUsageDisplayMode,
getUsageDisplayModifierText,
getUsagePercentCustomKeybinds,
getUsageProgressBarWidth,
isUsageCursorEnabled,
isUsageInverted,
isUsageProgressMode,
toggleUsageCursor,
toggleUsageInverted
} from './shared/usage-display';

Expand All @@ -45,20 +48,26 @@ 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;
const renderedPercent = inverted ? 100 - previewPercent : previewPercent;

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);
}

Expand All @@ -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);
}

Expand Down
6 changes: 3 additions & 3 deletions src/widgets/__tests__/SessionUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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: {
Expand Down
6 changes: 3 additions & 3 deletions src/widgets/__tests__/WeeklyUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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: {
Expand Down
9 changes: 6 additions & 3 deletions src/widgets/__tests__/helpers/usage-widget-suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -120,18 +121,20 @@ export function runUsagePercentWidgetSuite<TWidget extends UsageWidgetLike>(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', () => {
Expand Down
77 changes: 77 additions & 0 deletions src/widgets/__tests__/shared/progress-bar.test.ts
Original file line number Diff line number Diff line change
@@ -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('████████░░░░░░░░');
});
});
});
27 changes: 27 additions & 0 deletions src/widgets/shared/progress-bar.ts
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 15 additions & 1 deletion src/widgets/shared/usage-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
}
Expand All @@ -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');
}
Expand All @@ -79,7 +92,7 @@ export function cycleUsageDisplayMode(item: WidgetItem, disabledInProgressKeys:
: 'time';

const nextItem = removeMetadataKeys(item, nextMode === 'time'
? ['invert']
? ['invert', 'cursor']
: disabledInProgressKeys);
const nextMetadata: Record<string, string> = {
...(nextItem.metadata ?? {}),
Expand All @@ -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;
Expand Down