From b1c428ad1170f327613cce686079041b13993b9e Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 17 Jun 2026 23:18:00 -0500 Subject: [PATCH 1/3] feat(usage): budget-aware coloring for the Extra Usage widgets Adds an optional Widget.getDynamicColor(item, context) hook so a widget can override its foreground color from live data. The renderer prefers it over the configured color at both per-widget color sites (normal and powerline); the global overrideForegroundColor still wins, and widgets that do not implement the hook are unaffected. The Extra Usage widgets (used, remaining, utilization) opt in via a (b) toggle and the budgetColors metadata flag and shift green to yellow to red as the monthly budget nears exhaustion (warn at 75 percent, critical at 90 percent utilization). The three tiers default to plain green/yellow/red and are overridable via budgetColorOk/Warn/Crit hexes. Default behavior is unchanged. Refs #467. --- src/types/Widget.ts | 6 ++ src/utils/renderer.ts | 6 +- src/widgets/ExtraUsageRemaining.ts | 24 +++++++- src/widgets/ExtraUsageUsed.ts | 24 +++++++- src/widgets/ExtraUsageUtilization.ts | 19 ++++++- src/widgets/shared/budget-color.ts | 82 ++++++++++++++++++++++++++++ 6 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 src/widgets/shared/budget-color.ts 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/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/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; +} From 081957387ad2c36f8a49d262ef53e9d01e9f5b16 Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 17 Jun 2026 23:18:06 -0500 Subject: [PATCH 2/3] test(usage): cover budget-aware coloring A budget-color suite (threshold escalation, custom hex overrides, the opt-in toggle, the (b) keybind and editor modifier, and each Extra Usage widget getDynamicColor) plus the three keybind-assertion updates for the new bind. --- .../__tests__/ExtraUsageRemaining.test.ts | 3 + src/widgets/__tests__/ExtraUsageUsed.test.ts | 4 + .../__tests__/ExtraUsageUtilization.test.ts | 9 ++- .../shared/__tests__/budget-color.test.ts | 76 +++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 src/widgets/shared/__tests__/budget-color.test.ts 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(); + } + }); +}); From 9a7ead82864547001d175a7c502ba203979e00dd Mon Sep 17 00:00:00 2001 From: Zach Date: Fri, 18 Sep 2026 12:58:31 -0500 Subject: [PATCH 3/3] test(renderer): cover the dynamic color hook at both render paths The budget-color tests called getDynamicColor on the widget classes, so removing the renderer's call to it left the suite green. Both color resolution sites are now asserted through renderStatusLine: the normal path and the powerline path. Cases cover the opt-in flag off, unknown utilization, each threshold tier, and the global overrideForegroundColor taking precedence. --- .../__tests__/renderer-dynamic-color.test.ts | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 src/utils/__tests__/renderer-dynamic-color.test.ts 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); + }); + }); +});