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
5 changes: 5 additions & 0 deletions src/types/Widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ export interface Widget {
supportsColors(item: WidgetItem): boolean;
handleEditorAction?(action: string, item: WidgetItem): WidgetItem | null;
getNumericValue?(context: RenderContext, item: WidgetItem): number | null;
// Lets a widget override its foreground color from live data (e.g. budget
// severity). Returns a color to use instead of the configured one, or
// undefined to keep the static color. Resolved at the renderer's per-widget
// color sites; the global overrideForegroundColor still takes precedence.
getDynamicColor?(item: WidgetItem, context: RenderContext): string | undefined;
}

export interface WidgetEditorProps {
Expand Down
6 changes: 3 additions & 3 deletions src/utils/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ function renderPowerlineStatusLine(
const paddedText = `${leadingPadding}${widgetText}${trailingPadding}`;

// Determine colors
let fgColor = widget.color ?? defaultColor;
let fgColor = widgetImpl?.getDynamicColor?.(widget, context) ?? widget.color ?? defaultColor;
let bgColor = widget.backgroundColor;

// Apply theme colors if a theme is set (and not 'custom')
Expand Down Expand Up @@ -1036,13 +1036,13 @@ export function renderStatusLine(
try {
let widgetText: string | undefined;
let defaultColor = 'white';
const widgetImpl = getWidget(widget.type);

// Use pre-rendered content
const preRendered = preRenderedWidgets[i];
if (preRendered?.content) {
widgetText = preRendered.content;
// Get default color from widget impl for consistency
const widgetImpl = getWidget(widget.type);
if (widgetImpl) {
defaultColor = widgetImpl.getDefaultColor();
}
Expand All @@ -1064,7 +1064,7 @@ export function renderStatusLine(
} else {
// Normal widget rendering with colors
elements.push({
content: applyColorsWithOverride(widgetText, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, widget.dim),
content: applyColorsWithOverride(widgetText, widgetImpl?.getDynamicColor?.(widget, context) ?? widget.color ?? defaultColor, widget.backgroundColor, widget.bold, widget.dim),
type: widget.type,
widget
});
Expand Down
17 changes: 14 additions & 3 deletions src/widgets/ExtraUsageRemaining.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import type {
} from '../types/Widget';
import { getUsageErrorMessage } from '../utils/usage';

import {
appendBudgetColorsModifier,
getBudgetColorsKeybind,
handleToggleBudgetColorsAction,
resolveBudgetColor
} from './shared/budget-color';
import { formatUsageCurrency } from './shared/currency';
import {
appendHideDisabledModifier,
Expand All @@ -26,12 +32,13 @@ export class ExtraUsageRemainingWidget implements Widget {
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
return {
displayText: this.getDisplayName(),
modifierText: appendHideDisabledModifier(undefined, item)
modifierText: appendBudgetColorsModifier(appendHideDisabledModifier(undefined, item), item)
};
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
return handleToggleExtraUsageDisabledAction(action, item);
return handleToggleExtraUsageDisabledAction(action, item)
?? handleToggleBudgetColorsAction(action, item);
}

render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
Expand Down Expand Up @@ -61,7 +68,11 @@ export class ExtraUsageRemainingWidget implements Widget {
}

getCustomKeybinds(): CustomKeybind[] {
return [getHideExtraUsageDisabledKeybind()];
return [getHideExtraUsageDisabledKeybind(), getBudgetColorsKeybind()];
}

getDynamicColor(item: WidgetItem, context: RenderContext): string | undefined {
return resolveBudgetColor(item, context.usageData?.extraUsageUtilization);
}

supportsRawValue(): boolean { return true; }
Expand Down
17 changes: 14 additions & 3 deletions src/widgets/ExtraUsageUsed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import type {
} from '../types/Widget';
import { getUsageErrorMessage } from '../utils/usage';

import {
appendBudgetColorsModifier,
getBudgetColorsKeybind,
handleToggleBudgetColorsAction,
resolveBudgetColor
} from './shared/budget-color';
import { formatUsageCurrency } from './shared/currency';
import {
appendHideDisabledModifier,
Expand All @@ -26,12 +32,13 @@ export class ExtraUsageUsedWidget implements Widget {
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
return {
displayText: this.getDisplayName(),
modifierText: appendHideDisabledModifier(undefined, item)
modifierText: appendBudgetColorsModifier(appendHideDisabledModifier(undefined, item), item)
};
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
return handleToggleExtraUsageDisabledAction(action, item);
return handleToggleExtraUsageDisabledAction(action, item)
?? handleToggleBudgetColorsAction(action, item);
}

render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
Expand Down Expand Up @@ -59,7 +66,11 @@ export class ExtraUsageUsedWidget implements Widget {
}

getCustomKeybinds(): CustomKeybind[] {
return [getHideExtraUsageDisabledKeybind()];
return [getHideExtraUsageDisabledKeybind(), getBudgetColorsKeybind()];
}

getDynamicColor(item: WidgetItem, context: RenderContext): string | undefined {
return resolveBudgetColor(item, context.usageData?.extraUsageUtilization);
}

supportsRawValue(): boolean { return true; }
Expand Down
16 changes: 13 additions & 3 deletions src/widgets/ExtraUsageUtilization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import type {
} from '../types/Widget';
import { getUsageErrorMessage } from '../utils/usage';

import {
appendBudgetColorsModifier,
getBudgetColorsKeybind,
handleToggleBudgetColorsAction,
resolveBudgetColor
} from './shared/budget-color';
import {
appendHideDisabledModifier,
getHideExtraUsageDisabledKeybind,
Expand Down Expand Up @@ -38,7 +44,7 @@ export class ExtraUsageUtilizationWidget implements Widget {
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
return {
displayText: this.getDisplayName(),
modifierText: appendHideDisabledModifier(getUsageDisplayModifierText(item), item)
modifierText: appendBudgetColorsModifier(appendHideDisabledModifier(getUsageDisplayModifierText(item), item), item)
};
}

Expand All @@ -56,7 +62,7 @@ export class ExtraUsageUtilizationWidget implements Widget {
return toggleUsageInverted(item);
}

return null;
return handleToggleBudgetColorsAction(action, item);
}

render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
Expand Down Expand Up @@ -114,7 +120,11 @@ export class ExtraUsageUtilizationWidget implements Widget {
}

getCustomKeybinds(item?: WidgetItem): CustomKeybind[] {
return [...getUsagePercentCustomKeybinds(item), getHideExtraUsageDisabledKeybind()];
return [...getUsagePercentCustomKeybinds(item), getHideExtraUsageDisabledKeybind(), getBudgetColorsKeybind()];
}

getDynamicColor(item: WidgetItem, context: RenderContext): string | undefined {
return resolveBudgetColor(item, context.usageData?.extraUsageUtilization);
}

supportsRawValue(): boolean { return true; }
Expand Down
3 changes: 2 additions & 1 deletion src/widgets/__tests__/ExtraUsageRemaining.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ describe('ExtraUsageRemainingWidget', () => {
const baseItem: WidgetItem = { id: 'extra', type: 'extra-usage-remaining' };

expect(widget.getCustomKeybinds()).toEqual([
{ key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' }
{ key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' },
{ key: 'b', label: '(b)udget colors', action: 'toggle-budget-colors' }
]);
expect(widget.getEditorDisplay(baseItem).modifierText).toBeUndefined();

Expand Down
3 changes: 2 additions & 1 deletion src/widgets/__tests__/ExtraUsageUsed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ describe('ExtraUsageUsedWidget', () => {
const baseItem: WidgetItem = { id: 'extra', type: 'extra-usage-used' };

expect(widget.getCustomKeybinds()).toEqual([
{ key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' }
{ key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' },
{ key: 'b', label: '(b)udget colors', action: 'toggle-budget-colors' }
]);
expect(widget.getEditorDisplay(baseItem).modifierText).toBeUndefined();

Expand Down
6 changes: 4 additions & 2 deletions src/widgets/__tests__/ExtraUsageUtilization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ describe('ExtraUsageUtilizationWidget', () => {

expect(widget.getCustomKeybinds(baseItem)).toEqual([
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
{ key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' }
{ key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' },
{ key: 'b', label: '(b)udget colors', action: 'toggle-budget-colors' }
]);
expect(widget.getCustomKeybinds({
...baseItem,
Expand All @@ -83,7 +84,8 @@ describe('ExtraUsageUtilizationWidget', () => {
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' },
{ key: 't', label: '(t)ime cursor', action: 'toggle-cursor' },
{ key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' }
{ key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' },
{ key: 'b', label: '(b)udget colors', action: 'toggle-budget-colors' }
]);
expect(widget.getEditorDisplay(baseItem).modifierText).toBeUndefined();

Expand Down
76 changes: 76 additions & 0 deletions src/widgets/shared/__tests__/budget-color.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
describe,
expect,
it
} from 'vitest';

import type { RenderContext } from '../../../types/RenderContext';
import type { WidgetItem } from '../../../types/Widget';
import { ExtraUsageRemainingWidget } from '../../ExtraUsageRemaining';
import { ExtraUsageUsedWidget } from '../../ExtraUsageUsed';
import { ExtraUsageUtilizationWidget } from '../../ExtraUsageUtilization';
import {
appendBudgetColorsModifier,
getBudgetColorsKeybind,
handleToggleBudgetColorsAction,
isBudgetColorsEnabled,
resolveBudgetColor
} from '../budget-color';

const item = (metadata: Record<string, string> = {}): WidgetItem => ({ id: 'x', type: 'extra-usage-used', metadata });
const on = (extra: Record<string, string> = {}): WidgetItem => item({ budgetColors: 'true', ...extra });
const ctx = (utilization?: number): RenderContext => ({ usageData: { extraUsageUtilization: utilization } });

describe('budget-color', () => {
it('returns undefined when the opt-in flag is off', () => {
expect(isBudgetColorsEnabled(item())).toBe(false);
expect(resolveBudgetColor(item(), 95)).toBeUndefined();
});

it('returns undefined when utilization is unknown', () => {
expect(resolveBudgetColor(on(), undefined)).toBeUndefined();
});

it('escalates green -> yellow -> red across the thresholds', () => {
expect(resolveBudgetColor(on(), 0)).toBe('green');
expect(resolveBudgetColor(on(), 74)).toBe('green');
expect(resolveBudgetColor(on(), 75)).toBe('yellow');
expect(resolveBudgetColor(on(), 89)).toBe('yellow');
expect(resolveBudgetColor(on(), 90)).toBe('red');
expect(resolveBudgetColor(on(), 100)).toBe('red');
});

it('honors per-tier color overrides', () => {
const colors = { budgetColorOk: 'hex:00FF00', budgetColorWarn: 'hex:FFAA00', budgetColorCrit: 'hex:FF0000' };
expect(resolveBudgetColor(on(colors), 10)).toBe('hex:00FF00');
expect(resolveBudgetColor(on(colors), 80)).toBe('hex:FFAA00');
expect(resolveBudgetColor(on(colors), 99)).toBe('hex:FF0000');
});

it('toggles the opt-in flag via the editor action', () => {
const enabled = handleToggleBudgetColorsAction('toggle-budget-colors', item());
expect(enabled?.metadata?.budgetColors).toBe('true');
expect(handleToggleBudgetColorsAction('toggle-budget-colors', enabled ?? item())?.metadata?.budgetColors).toBe('false');
expect(handleToggleBudgetColorsAction('something-else', item())).toBeNull();
});

it('exposes a (b)udget colors keybind', () => {
expect(getBudgetColorsKeybind()).toEqual({ key: 'b', label: '(b)udget colors', action: 'toggle-budget-colors' });
});

it('appends the modifier only when enabled', () => {
expect(appendBudgetColorsModifier(undefined, item())).toBeUndefined();
expect(appendBudgetColorsModifier(undefined, on())).toBe('(budget colors)');
expect(appendBudgetColorsModifier('(short bar)', on())).toBe('(short bar, budget colors)');
});

it('each Extra Usage widget colors by utilization through getDynamicColor', () => {
const widgets = [new ExtraUsageUsedWidget(), new ExtraUsageRemainingWidget(), new ExtraUsageUtilizationWidget()];
for (const widget of widgets) {
expect(widget.getDynamicColor(item(), ctx(95))).toBeUndefined();
expect(widget.getDynamicColor(on(), ctx(50))).toBe('green');
expect(widget.getDynamicColor(on(), ctx(95))).toBe('red');
expect(widget.getDynamicColor(on(), ctx(undefined))).toBeUndefined();
}
});
});
82 changes: 82 additions & 0 deletions src/widgets/shared/budget-color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type {
CustomKeybind,
WidgetItem
} from '../../types/Widget';

import {
isMetadataFlagEnabled,
toggleMetadataFlag
} from './metadata';

const BUDGET_COLORS_KEY = 'budgetColors';
const TOGGLE_BUDGET_COLORS_ACTION = 'toggle-budget-colors';

const OK_COLOR_KEY = 'budgetColorOk';
const WARN_COLOR_KEY = 'budgetColorWarn';
const CRIT_COLOR_KEY = 'budgetColorCrit';

// Utilization (used/limit, 0-100) at which the color escalates.
const WARN_AT_PERCENT = 75;
const CRIT_AT_PERCENT = 90;

const DEFAULT_OK_COLOR = 'green';
const DEFAULT_WARN_COLOR = 'yellow';
const DEFAULT_CRIT_COLOR = 'red';

const BUDGET_COLORS_KEYBIND: CustomKeybind = {
key: 'b',
label: '(b)udget colors',
action: TOGGLE_BUDGET_COLORS_ACTION
};

export function isBudgetColorsEnabled(item: WidgetItem): boolean {
return isMetadataFlagEnabled(item, BUDGET_COLORS_KEY);
}

export function handleToggleBudgetColorsAction(action: string, item: WidgetItem): WidgetItem | null {
if (action !== TOGGLE_BUDGET_COLORS_ACTION) {
return null;
}

return toggleMetadataFlag(item, BUDGET_COLORS_KEY);
}

export function getBudgetColorsKeybind(): CustomKeybind {
return BUDGET_COLORS_KEYBIND;
}

export function appendBudgetColorsModifier(modifierText: string | undefined, item: WidgetItem): string | undefined {
if (!isBudgetColorsEnabled(item)) {
return modifierText;
}

if (!modifierText) {
return '(budget colors)';
}

return `${modifierText.slice(0, -1)}, budget colors)`;
}

/**
* The severity color for a budget widget at the given utilization percentage
* (used / limit, 0-100). Returns undefined when the opt-in flag is off or the
* utilization is unknown, so the widget keeps its statically configured color.
* Higher utilization is more problematic: the configured ok color below the warn
* threshold, the warn color up to critical, the critical color at or beyond it.
* Each tier defaults to a plain ANSI color and is overridable via metadata.
*/
export function resolveBudgetColor(item: WidgetItem, utilizationPercent: number | undefined): string | undefined {
if (!isBudgetColorsEnabled(item) || utilizationPercent === undefined) {
return undefined;
}

if (utilizationPercent >= CRIT_AT_PERCENT) {
return item.metadata?.[CRIT_COLOR_KEY] ?? DEFAULT_CRIT_COLOR;
}

if (utilizationPercent >= WARN_AT_PERCENT) {
return item.metadata?.[WARN_COLOR_KEY] ?? DEFAULT_WARN_COLOR;
}

return item.metadata?.[OK_COLOR_KEY] ?? DEFAULT_OK_COLOR;
}