diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 338e92800..0550f7f51 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -69,6 +69,12 @@ export interface Widget { * still take precedence (see custom-command's preserve-colors mode). */ preservesRenderedColors?(item: WidgetItem): boolean; + + // 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/__tests__/renderer-dynamic-color.test.ts b/src/utils/__tests__/renderer-dynamic-color.test.ts new file mode 100644 index 000000000..629d248ce --- /dev/null +++ b/src/utils/__tests__/renderer-dynamic-color.test.ts @@ -0,0 +1,137 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { + DEFAULT_SETTINGS, + type Settings +} from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { renderStatusLine } from '../renderer'; + +interface PreRenderedWidget { + content: string; + plainLength: number; + widget: WidgetItem; +} + +// Truecolor so every expected code is an unambiguous literal rather than a +// palette lookup. +const CONFIGURED = 'hex:0000FF'; +const TIER_OK = 'hex:00FF00'; +const TIER_WARN = 'hex:FFAA00'; +const TIER_CRIT = 'hex:FF0000'; + +const ANSI_CONFIGURED = '\x1b[38;2;0;0;255m'; +const ANSI_OK = '\x1b[38;2;0;255;0m'; +const ANSI_WARN = '\x1b[38;2;255;170;0m'; +const ANSI_CRIT = '\x1b[38;2;255;0;0m'; + +function createSettings(overrides: Partial = {}): Settings { + return { + ...DEFAULT_SETTINGS, + ...overrides, + colorLevel: 3, + powerline: { + ...DEFAULT_SETTINGS.powerline, + ...(overrides.powerline ?? {}) + } + }; +} + +function budgetItem(metadata: Record): WidgetItem { + return { + id: 'usage', + type: 'extra-usage-used', + color: CONFIGURED, + metadata: { + budgetColorOk: TIER_OK, + budgetColorWarn: TIER_WARN, + budgetColorCrit: TIER_CRIT, + ...metadata + } + }; +} + +function render( + item: WidgetItem, + utilization: number | undefined, + settingsOverrides: Partial = {} +): string { + const settings = createSettings(settingsOverrides); + const context: RenderContext = { + isPreview: false, + terminalWidth: 200, + usageData: { extraUsageUtilization: utilization } + }; + const preRendered: PreRenderedWidget[] = [{ content: '$106', plainLength: 4, widget: item }]; + return renderStatusLine([item], settings, context, preRendered, []); +} + +const powerline: Partial = { powerline: { ...DEFAULT_SETTINGS.powerline, enabled: true } }; + +describe('renderer resolves a widget dynamic color', () => { + const cases = [ + { + name: 'keeps the configured color when budget colors are off', + metadata: {}, + utilization: 95, + expected: ANSI_CONFIGURED + }, + { + name: 'keeps the configured color when utilization is unknown', + metadata: { budgetColors: 'true' }, + utilization: undefined, + expected: ANSI_CONFIGURED + }, + { + name: 'uses the ok tier below the warn threshold', + metadata: { budgetColors: 'true' }, + utilization: 50, + expected: ANSI_OK + }, + { + name: 'uses the warn tier at the warn threshold', + metadata: { budgetColors: 'true' }, + utilization: 75, + expected: ANSI_WARN + }, + { + name: 'uses the crit tier at the crit threshold', + metadata: { budgetColors: 'true' }, + utilization: 90, + expected: ANSI_CRIT + } + ] as const; + + const paths: { path: string; settings: Partial }[] = [ + { path: 'normal', settings: {} }, + { path: 'powerline', settings: powerline } + ]; + + describe.each(paths)('$path path', ({ settings }) => { + it.each(cases)('$name', ({ metadata, utilization, expected }) => { + const out = render(budgetItem(metadata), utilization, settings); + + expect(out).toContain(expected); + for (const other of [ANSI_CONFIGURED, ANSI_OK, ANSI_WARN, ANSI_CRIT]) { + if (other !== expected) + expect(out).not.toContain(other); + } + }); + + it('lets the global foreground override win over the dynamic color', () => { + const out = render( + budgetItem({ budgetColors: 'true' }), + 95, + { ...settings, overrideForegroundColor: 'hex:ABCDEF' } + ); + + expect(out).toContain('\x1b[38;2;171;205;239m'); + expect(out).not.toContain(ANSI_CRIT); + }); + }); +}); diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index a3ca51ec3..0eff99136 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -306,7 +306,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') @@ -1162,13 +1162,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(); } @@ -1199,7 +1199,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 838ab59cd..c99939890 100644 --- a/src/widgets/ExtraUsageRemaining.ts +++ b/src/widgets/ExtraUsageRemaining.ts @@ -1,6 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + CustomKeybind, HideableState, Widget, WidgetEditorDisplay, @@ -9,6 +10,12 @@ import type { import { resolveNumberFormat } from '../utils/number-format'; import { getUsageErrorMessage } from '../utils/usage'; +import { + appendBudgetColorsModifier, + getBudgetColorsKeybind, + handleToggleBudgetColorsAction, + resolveBudgetColor +} from './shared/budget-color'; import { formatUsageCurrency } from './shared/currency'; import { EXTRA_USAGE_DISABLED_HIDEABLE_STATE } from './shared/extra-usage-disabled'; import { isHidden } from './shared/hideable'; @@ -22,13 +29,20 @@ export class ExtraUsageRemainingWidget implements Widget { getCategory(): string { return 'Usage'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { displayText: this.getDisplayName() }; + return { + displayText: this.getDisplayName(), + modifierText: appendBudgetColorsModifier(undefined, item) + }; } getHideableStates(): HideableState[] { return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE]; } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleBudgetColorsAction(action, item); + } + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const format = resolveNumberFormat('cost', item, settings); if (context.isPreview) { @@ -59,6 +73,14 @@ export class ExtraUsageRemainingWidget implements Widget { return formatRawOrLabeledValue(item, 'Overage Left: ', formatted); } + getCustomKeybinds(): CustomKeybind[] { + return [getBudgetColorsKeybind()]; + } + + getDynamicColor(item: WidgetItem, context: RenderContext): string | undefined { + return resolveBudgetColor(item, context.usageData?.extraUsageUtilization); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } supportsNumberFormat(): boolean { return true; } diff --git a/src/widgets/ExtraUsageUsed.ts b/src/widgets/ExtraUsageUsed.ts index 8faaba7fd..babedc293 100644 --- a/src/widgets/ExtraUsageUsed.ts +++ b/src/widgets/ExtraUsageUsed.ts @@ -1,6 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + CustomKeybind, HideableState, Widget, WidgetEditorDisplay, @@ -9,6 +10,12 @@ import type { import { resolveNumberFormat } from '../utils/number-format'; import { getUsageErrorMessage } from '../utils/usage'; +import { + appendBudgetColorsModifier, + getBudgetColorsKeybind, + handleToggleBudgetColorsAction, + resolveBudgetColor +} from './shared/budget-color'; import { formatUsageCurrency } from './shared/currency'; import { EXTRA_USAGE_DISABLED_HIDEABLE_STATE } from './shared/extra-usage-disabled'; import { isHidden } from './shared/hideable'; @@ -22,13 +29,20 @@ export class ExtraUsageUsedWidget implements Widget { getCategory(): string { return 'Usage'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { displayText: this.getDisplayName() }; + return { + displayText: this.getDisplayName(), + modifierText: appendBudgetColorsModifier(undefined, item) + }; } getHideableStates(): HideableState[] { return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE]; } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleBudgetColorsAction(action, item); + } + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const format = resolveNumberFormat('cost', item, settings); if (context.isPreview) { @@ -57,6 +71,14 @@ export class ExtraUsageUsedWidget implements Widget { return formatRawOrLabeledValue(item, 'Overage Used: ', formatted); } + getCustomKeybinds(): CustomKeybind[] { + return [getBudgetColorsKeybind()]; + } + + getDynamicColor(item: WidgetItem, context: RenderContext): string | undefined { + return resolveBudgetColor(item, context.usageData?.extraUsageUtilization); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } supportsNumberFormat(): boolean { return true; } diff --git a/src/widgets/ExtraUsageUtilization.ts b/src/widgets/ExtraUsageUtilization.ts index 851b7c65f..3985389b9 100644 --- a/src/widgets/ExtraUsageUtilization.ts +++ b/src/widgets/ExtraUsageUtilization.ts @@ -13,6 +13,12 @@ import { } from '../utils/number-format'; import { getUsageErrorMessage } from '../utils/usage'; +import { + appendBudgetColorsModifier, + getBudgetColorsKeybind, + handleToggleBudgetColorsAction, + resolveBudgetColor +} from './shared/budget-color'; import { EXTRA_USAGE_DISABLED_HIDEABLE_STATE } from './shared/extra-usage-disabled'; import { isHidden } from './shared/hideable'; import { makeTimerProgressBar } from './shared/progress-bar'; @@ -40,7 +46,10 @@ export class ExtraUsageUtilizationWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), - modifierText: getUsageDisplayModifierText(item, { showUsageDirection: true }) + modifierText: appendBudgetColorsModifier( + getUsageDisplayModifierText(item, { showUsageDirection: true }), + item + ) }; } @@ -57,7 +66,7 @@ export class ExtraUsageUtilizationWidget implements Widget { return toggleUsageInverted(item); } - return null; + return handleToggleBudgetColorsAction(action, item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { @@ -119,7 +128,11 @@ export class ExtraUsageUtilizationWidget implements Widget { } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { - return getUsagePercentCustomKeybinds(item, false); + return [...getUsagePercentCustomKeybinds(item, false), 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 aa11d36a4..a0257cd39 100644 --- a/src/widgets/__tests__/ExtraUsageRemaining.test.ts +++ b/src/widgets/__tests__/ExtraUsageRemaining.test.ts @@ -103,6 +103,9 @@ describe('ExtraUsageRemainingWidget', () => { const baseItem: WidgetItem = { id: 'extra', type: 'extra-usage-remaining' }; expect(widget.getHideableStates().map(state => state.key)).toEqual(['disabled', 'no-data']); + expect(widget.getCustomKeybinds()).toEqual([ + { 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 70be2b521..1cb2582b7 100644 --- a/src/widgets/__tests__/ExtraUsageUsed.test.ts +++ b/src/widgets/__tests__/ExtraUsageUsed.test.ts @@ -93,6 +93,10 @@ describe('ExtraUsageUsedWidget', () => { type: 'extra-usage-used' }, { isPreview: true }, settings)).toBe('Overage Used: $106'); expect(widget.supportsNumberFormat()).toBe(true); + expect(widget.getCustomKeybinds()).toEqual([ + { key: 'b', label: '(b)udget colors', action: 'toggle-budget-colors' } + ]); + expect(widget.getEditorDisplay({ id: 'extra', type: 'extra-usage-used' }).modifierText).toBeUndefined(); }); it('declares the disabled and no-data hideable states', () => { diff --git a/src/widgets/__tests__/ExtraUsageUtilization.test.ts b/src/widgets/__tests__/ExtraUsageUtilization.test.ts index b4f8fe54f..df66e79f1 100644 --- a/src/widgets/__tests__/ExtraUsageUtilization.test.ts +++ b/src/widgets/__tests__/ExtraUsageUtilization.test.ts @@ -74,21 +74,24 @@ describe('ExtraUsageUtilizationWidget', () => { expect(widget.getCustomKeybinds(baseItem)).toEqual([ { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, - { key: 'u', label: '(u) show remaining', action: 'toggle-invert' } + { key: 'u', label: '(u) show remaining', action: 'toggle-invert' }, + { key: 'b', label: '(b)udget colors', action: 'toggle-budget-colors' } ]); expect(widget.getCustomKeybinds({ ...baseItem, metadata: { display: 'progress' } })).toEqual([ { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, - { key: 'u', label: '(u) show remaining', action: 'toggle-invert' } + { key: 'u', label: '(u) show remaining', action: 'toggle-invert' }, + { key: 'b', label: '(b)udget colors', action: 'toggle-budget-colors' } ]); expect(widget.getCustomKeybinds({ ...baseItem, metadata: { invert: 'true' } })).toEqual([ { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, - { key: 'u', label: '(u) show used', action: 'toggle-invert' } + { key: 'u', label: '(u) show used', action: 'toggle-invert' }, + { key: 'b', label: '(b)udget colors', action: 'toggle-budget-colors' } ]); expect(widget.getEditorDisplay(baseItem).modifierText).toBe('(used)'); expect(widget.getEditorDisplay({ 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 000000000..3b33e7e12 --- /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 000000000..cd5ef61b5 --- /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; +}