Skip to content
Draft
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
87 changes: 87 additions & 0 deletions src/tui/components/GlobalOverridesMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import {
import React, { useState } from 'react';

import { getColorLevelString } from '../../types/ColorLevel';
import {
NUMBER_KINDS,
type GlobalNumberFormat,
type NumberFormat,
type NumberKind,
type NumberStyle
} from '../../types/NumberFormat';
import {
DefaultPaddingSideSchema,
type Settings
Expand All @@ -21,6 +28,34 @@ import { shouldInsertInput } from '../../utils/input-guards';

import { ConfirmDialog } from './ConfirmDialog';

const NUMBER_FORMAT_STYLES: (NumberStyle | undefined)[] = [undefined, 'compact', 'whole'];

// Cycle a number kind's global style: default (precise) -> compact -> whole -> default.
// A global style forces that kind across all widgets (see resolveNumberFormat).
function cycleGlobalNumberStyle(settings: Settings, kind: NumberKind): Settings {
const current = settings.numberFormat?.[kind]?.style;
const currentIndex = NUMBER_FORMAT_STYLES.indexOf(current);
const nextStyle = NUMBER_FORMAT_STYLES[(currentIndex + 1) % NUMBER_FORMAT_STYLES.length];

const kindFormat: NumberFormat = { ...settings.numberFormat?.[kind] };
if (nextStyle === undefined) {
delete kindFormat.style;
} else {
kindFormat.style = nextStyle;
}

const { [kind]: removedKind, ...restGlobal } = settings.numberFormat ?? {};
void removedKind; // Intentionally unused
const nextGlobal: GlobalNumberFormat = Object.keys(kindFormat).length > 0
? { ...restGlobal, [kind]: kindFormat }
: restGlobal;

return {
...settings,
numberFormat: Object.keys(nextGlobal).length > 0 ? nextGlobal : undefined
};
}

export interface GlobalOverridesMenuProps {
settings: Settings;
onUpdate: (settings: Settings) => void;
Expand All @@ -36,6 +71,8 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
const [inheritColors, setInheritColors] = useState(settings.inheritSeparatorColors);
const [globalBold, setGlobalBold] = useState(settings.globalBold);
const [minimalistMode, setMinimalistMode] = useState(settings.minimalistMode);
const [numberFormatMode, setNumberFormatMode] = useState(false);
const [numberFormatKindIndex, setNumberFormatKindIndex] = useState(0);
const [gradientMode, setGradientMode] = useState(false);
const [gradientIndex, setGradientIndex] = useState(0);
const [gradientCustomStep, setGradientCustomStep] = useState<'start' | 'end' | null>(null);
Expand Down Expand Up @@ -162,6 +199,19 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
setGradientCustomStep('start');
}
}
} else if (numberFormatMode) {
if (key.escape) {
setNumberFormatMode(false);
} else if (key.upArrow) {
setNumberFormatKindIndex((numberFormatKindIndex - 1 + NUMBER_KINDS.length) % NUMBER_KINDS.length);
} else if (key.downArrow) {
setNumberFormatKindIndex((numberFormatKindIndex + 1) % NUMBER_KINDS.length);
} else if (key.leftArrow || key.rightArrow) {
const kind = NUMBER_KINDS[numberFormatKindIndex];
if (kind) {
onUpdate(cycleGlobalNumberStyle(settings, kind));
}
}
} else {
if (key.escape) {
onBack();
Expand Down Expand Up @@ -211,6 +261,9 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
minimalistMode: newMinimalistMode
};
onUpdate(updatedSettings);
} else if (input === 'n' || input === 'N') {
setNumberFormatMode(true);
setNumberFormatKindIndex(0);
} else if (input === 'f' || input === 'F') {
// Cycle through foreground colors
const nextIndex = (currentFgIndex + 1) % fgColors.length;
Expand Down Expand Up @@ -248,6 +301,34 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
}
});

if (numberFormatMode) {
return (
<Box flexDirection='column'>
<Text bold>Global Number Formatting</Text>
<Box marginTop={1}>
<Text dimColor>↑↓ to select a number type, ←→ to cycle its style, ESC to go back</Text>
</Box>
<Box marginTop={1} flexDirection='column'>
{NUMBER_KINDS.map((kind, idx) => {
const style = settings.numberFormat?.[kind]?.style ?? 'precise (default)';
return (
<Text key={kind} color={idx === numberFormatKindIndex ? 'cyan' : undefined}>
{idx === numberFormatKindIndex ? '▶ ' : ' '}
{kind}
{': '}
{style}
</Text>
);
})}
</Box>
<Box marginTop={1} flexDirection='column'>
<Text dimColor>precise = keep trailing zeros (1.0M), compact = trim them (1M / 1.1M), whole = no decimals (1M).</Text>
<Text dimColor>A global style forces that type across every widget. Decimal places are set per-widget or in settings.json.</Text>
</Box>
</Box>
);
}

if (gradientMode) {
const level = getColorLevelString(settings.colorLevel);

Expand Down Expand Up @@ -372,6 +453,12 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
<Text dimColor> - Press (m) to toggle</Text>
</Box>

<Box>
<Text>Number Formatting: </Text>
<Text color='cyan'>{settings.numberFormat ? 'customized' : '(defaults)'}</Text>
<Text dimColor> - Press (n) to configure per-type</Text>
</Box>

<Box>
<Text> Default Padding: </Text>
<Text color='cyan'>{settings.defaultPadding ? `"${settings.defaultPadding}"` : '(none)'}</Text>
Expand Down
10 changes: 6 additions & 4 deletions src/tui/components/ItemsEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
} from '../../types/Widget';
import { getBackgroundColorsForPowerline } from '../../utils/colors';
import { generateGuid } from '../../utils/guid';
import { getNumberFormatKeybind } from '../../utils/number-format';
import { canDetectTerminalWidth } from '../../utils/terminal';
import {
filterWidgetCatalog,
Expand Down Expand Up @@ -104,11 +105,12 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
};

const getCustomKeybindsForWidget = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => {
if (!widgetImpl.getCustomKeybinds) {
return [];
}
const keybinds = widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [];

return widgetImpl.getCustomKeybinds(widget);
// Numeric widgets get the precision cycle here rather than in the color
// menu, so every non-color override stays on this screen and stays
// reachable while a powerline theme is active.
return widgetImpl.supportsNumberFormat?.() ? [...keybinds, getNumberFormatKeybind()] : keybinds;
};

const openWidgetPicker = (action: WidgetPickerAction) => {
Expand Down
7 changes: 4 additions & 3 deletions src/tui/components/color-menu/__tests__/mutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,16 @@ describe('color-menu mutations', () => {
expect(whole[1]?.dim).toBeUndefined();
});

it('resetWidgetStyling removes color, backgroundColor, bold, and dim from one widget', () => {
it('resetWidgetStyling removes color, backgroundColor, bold, dim, and numberFormat from one widget', () => {
const widgets: WidgetItem[] = [
{
id: '1',
type: 'tokens-input',
color: 'red',
backgroundColor: 'blue',
bold: true,
dim: 'parens'
dim: 'parens',
numberFormat: { style: 'compact' }
},
{ id: '2', type: 'tokens-output', color: 'white', bold: true }
];
Expand All @@ -87,7 +88,7 @@ describe('color-menu mutations', () => {
bold: true,
dim: true
},
{ id: '2', type: 'tokens-output', color: 'white', bold: true, dim: 'parens' }
{ id: '2', type: 'tokens-output', color: 'white', bold: true, dim: 'parens', numberFormat: { style: 'whole' } }
];

const updated = clearAllWidgetStyling(widgets);
Expand Down
4 changes: 4 additions & 0 deletions src/tui/components/color-menu/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,14 @@ export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): Wid
backgroundColor,
bold,
dim,
numberFormat,
...restWidget
} = widget;
void color; // Intentionally unused
void backgroundColor; // Intentionally unused
void bold; // Intentionally unused
void dim; // Intentionally unused
void numberFormat; // Intentionally unused
return restWidget;
});
}
Expand All @@ -84,12 +86,14 @@ export function clearAllWidgetStyling(widgets: WidgetItem[]): WidgetItem[] {
backgroundColor,
bold,
dim,
numberFormat,
...restWidget
} = widget;
void color; // Intentionally unused
void backgroundColor; // Intentionally unused
void bold; // Intentionally unused
void dim; // Intentionally unused
void numberFormat; // Intentionally unused
return restWidget;
});
}
Expand Down
50 changes: 49 additions & 1 deletion src/tui/components/items-editor/__tests__/input-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import {
vi
} from 'vitest';

import type { WidgetItem } from '../../../../types/Widget';
import type {
CustomKeybind,
Widget,
WidgetItem
} from '../../../../types/Widget';
import { getNumberFormatKeybind } from '../../../../utils/number-format';
import type { WidgetCatalogEntry } from '../../../../utils/widgets';
import {
handleMoveInputMode,
Expand Down Expand Up @@ -1003,4 +1008,47 @@ describe('items-editor input handlers', () => {
expect(setSelectedIndex).not.toHaveBeenCalled();
});
});

describe('precision keybind', () => {
// The items editor injects this bind for numeric widgets, so the handler
// applies it itself rather than delegating to handleEditorAction.
const injectPrecisionKeybind = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => {
const keybinds = widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [];
return widgetImpl.supportsNumberFormat?.() ? [...keybinds, getNumberFormatKeybind()] : keybinds;
};

const pressPrecision = (widgets: WidgetItem[], onUpdate: (widgets: WidgetItem[]) => void) => {
handleNormalInputMode({
input: '.',
key: {},
widgets,
selectedIndex: 0,
separatorChars: ['|'],
onBack: vi.fn(),
onUpdate,
setSelectedIndex: vi.fn(),
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: injectPrecisionKeybind,
setCustomEditorWidget: vi.fn()
});
};

it('cycles the number style of a numeric widget that has no keybinds of its own', () => {
const onUpdate = vi.fn();
pressPrecision([{ id: '1', type: 'tokens-input' }], onUpdate);

expect(onUpdate).toHaveBeenCalledWith([
{ id: '1', type: 'tokens-input', numberFormat: { style: 'compact' } }
]);
});

it('leaves a non-numeric widget untouched', () => {
const onUpdate = vi.fn();
pressPrecision([{ id: '1', type: 'custom-text' }], onUpdate);

expect(onUpdate).not.toHaveBeenCalled();
});
});
});
14 changes: 12 additions & 2 deletions src/tui/components/items-editor/input-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import type {
WidgetItemType
} from '../../../types/Widget';
import { generateGuid } from '../../../utils/guid';
import {
CYCLE_NUMBER_STYLE_ACTION,
cycleNumberStyle
} from '../../../utils/number-format';
import {
filterWidgetCatalog,
getWidget,
Expand Down Expand Up @@ -476,15 +480,21 @@ export function handleNormalInputMode({
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const widgetImpl = getWidget(currentWidget.type);
if (!widgetImpl?.getCustomKeybinds) {
if (!widgetImpl) {
return;
}

const customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
const matchedKeybind = customKeybinds.find(kb => kb.key === input);

if (matchedKeybind && !key.ctrl) {
if (widgetImpl.handleEditorAction) {
// The precision cycle is shared by every numeric widget, so it is
// applied here instead of in each widget's handleEditorAction.
if (matchedKeybind.action === CYCLE_NUMBER_STYLE_ACTION) {
const newWidgets = [...widgets];
newWidgets[selectedIndex] = cycleNumberStyle(currentWidget);
onUpdate(newWidgets);
} else if (widgetImpl.handleEditorAction) {
const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget);
if (updatedWidget) {
const newWidgets = [...widgets];
Expand Down
33 changes: 33 additions & 0 deletions src/types/NumberFormat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { z } from 'zod';

// Decimal-rendering styles for numeric widgets:
// precise - fixed decimal places, trailing zeros kept ("1.0M"); today's default
// compact - trailing zeros trimmed, real fractions kept ("1M", "1.1M")
// whole - no decimals ("1M")
export const NUMBER_STYLES = ['precise', 'compact', 'whole'] as const;
export type NumberStyle = (typeof NUMBER_STYLES)[number];

// The kind of number a widget renders. Each kind keeps its own baseline
// precision in its formatter, so a token-oriented change never drags money off
// its 2-decimal convention.
export const NUMBER_KINDS = ['token', 'speed', 'percent', 'memory', 'cost'] as const;
export type NumberKind = (typeof NUMBER_KINDS)[number];

// A precision override. Both fields optional; an empty format means "use the
// formatter's built-in baseline", i.e. current output.
export const NumberFormatSchema = z.object({
style: z.enum(NUMBER_STYLES).optional(),
decimals: z.number().int().min(0).max(6).optional()
});
export type NumberFormat = z.infer<typeof NumberFormatSchema>;

// Optional global precision, keyed by number kind. A kind set here wins over any
// per-widget value (same precedence as overrideForegroundColor / globalBold).
export const GlobalNumberFormatSchema = z.object({
token: NumberFormatSchema.optional(),
speed: NumberFormatSchema.optional(),
percent: NumberFormatSchema.optional(),
memory: NumberFormatSchema.optional(),
cost: NumberFormatSchema.optional()
});
export type GlobalNumberFormat = z.infer<typeof GlobalNumberFormatSchema>;
2 changes: 2 additions & 0 deletions src/types/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from 'zod';

import { ColorLevelSchema } from './ColorLevel';
import { FlexModeSchema } from './FlexMode';
import { GlobalNumberFormatSchema } from './NumberFormat';
import { PowerlineConfigSchema } from './PowerlineConfig';
import { WidgetItemSchema } from './Widget';

Expand Down Expand Up @@ -73,6 +74,7 @@ export const SettingsSchema = z.object({
overrideBackgroundColor: z.string().optional(),
overrideForegroundColor: z.string().optional(),
globalBold: z.boolean().default(false),
numberFormat: GlobalNumberFormatSchema.optional(),
gitCacheTtlSeconds: z.number().min(0).max(60).default(5),
minimalistMode: z.boolean().default(false),
powerline: PowerlineConfigSchema.default({
Expand Down
6 changes: 6 additions & 0 deletions src/types/Widget.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';

import { NumberFormatSchema } from './NumberFormat';
import type { RenderContext } from './RenderContext';
import type { Settings } from './Settings';

Expand All @@ -11,6 +12,7 @@ export const WidgetItemSchema = z.object({
backgroundColor: z.string().optional(),
bold: z.boolean().optional(),
dim: z.union([z.boolean(), z.literal('parens')]).optional(),
numberFormat: NumberFormatSchema.optional(),
character: z.string().optional(),
rawValue: z.boolean().optional(),
customText: z.string().optional(),
Expand Down Expand Up @@ -45,6 +47,10 @@ export interface Widget {
renderEditor?(props: WidgetEditorProps): React.ReactElement | null;
supportsRawValue(): boolean;
supportsColors(item: WidgetItem): boolean;
// Whether the widget renders a number whose precision can be overridden.
// Gates the items editor's precision keybind; widgets that omit it are
// treated as non-numeric.
supportsNumberFormat?(): boolean;
handleEditorAction?(action: string, item: WidgetItem): WidgetItem | null;
getNumericValue?(context: RenderContext, item: WidgetItem): number | null;
}
Expand Down
Loading