diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 121c2a9b..1fa31997 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -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 { diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 47ac3914..54178cf1 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -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') @@ -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(); } @@ -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 }); diff --git a/src/widgets/ExtraUsageRemaining.ts b/src/widgets/ExtraUsageRemaining.ts index 6101de12..c5c8ef8c 100644 --- a/src/widgets/ExtraUsageRemaining.ts +++ b/src/widgets/ExtraUsageRemaining.ts @@ -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, @@ -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 { @@ -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; } diff --git a/src/widgets/ExtraUsageUsed.ts b/src/widgets/ExtraUsageUsed.ts index 07e05af0..197bf41a 100644 --- a/src/widgets/ExtraUsageUsed.ts +++ b/src/widgets/ExtraUsageUsed.ts @@ -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, @@ -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 { @@ -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; } diff --git a/src/widgets/ExtraUsageUtilization.ts b/src/widgets/ExtraUsageUtilization.ts index 71dc3780..8508ed95 100644 --- a/src/widgets/ExtraUsageUtilization.ts +++ b/src/widgets/ExtraUsageUtilization.ts @@ -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, @@ -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) }; } @@ -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 { @@ -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; } diff --git a/src/widgets/__tests__/ExtraUsageRemaining.test.ts b/src/widgets/__tests__/ExtraUsageRemaining.test.ts index 6cd3cb81..38ddef07 100644 --- a/src/widgets/__tests__/ExtraUsageRemaining.test.ts +++ b/src/widgets/__tests__/ExtraUsageRemaining.test.ts @@ -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(); diff --git a/src/widgets/__tests__/ExtraUsageUsed.test.ts b/src/widgets/__tests__/ExtraUsageUsed.test.ts index 05fbc0e5..8883b73d 100644 --- a/src/widgets/__tests__/ExtraUsageUsed.test.ts +++ b/src/widgets/__tests__/ExtraUsageUsed.test.ts @@ -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(); diff --git a/src/widgets/__tests__/ExtraUsageUtilization.test.ts b/src/widgets/__tests__/ExtraUsageUtilization.test.ts index 4c000255..584f7a41 100644 --- a/src/widgets/__tests__/ExtraUsageUtilization.test.ts +++ b/src/widgets/__tests__/ExtraUsageUtilization.test.ts @@ -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, @@ -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(); diff --git a/src/widgets/shared/__tests__/budget-color.test.ts b/src/widgets/shared/__tests__/budget-color.test.ts new file mode 100644 index 00000000..3b33e7e1 --- /dev/null +++ b/src/widgets/shared/__tests__/budget-color.test.ts @@ -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 = {}): WidgetItem => ({ id: 'x', type: 'extra-usage-used', metadata }); +const on = (extra: Record = {}): 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(); + } + }); +}); diff --git a/src/widgets/shared/budget-color.ts b/src/widgets/shared/budget-color.ts new file mode 100644 index 00000000..cd5ef61b --- /dev/null +++ b/src/widgets/shared/budget-color.ts @@ -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; +}