diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index a349b260..b9257ec3 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -42,6 +42,14 @@ export interface ItemsEditorProps { settings: Settings; } +function isMergedIntoPreviousWidget(widgets: WidgetItem[], index: number): boolean { + if (index <= 0) { + return false; + } + + return Boolean(widgets[index - 1]?.merge); +} + export const ItemsEditor: React.FC = ({ widgets, onUpdate, onBack, lineNumber, settings }) => { const [selectedIndex, setSelectedIndex] = useState(0); const [moveMode, setMoveMode] = useState(false); @@ -152,6 +160,30 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB setWidgetPicker(null); }; + const currentWidget = widgets[selectedIndex]; + const isSeparator = currentWidget?.type === 'separator'; + const isFlexSeparator = currentWidget?.type === 'flex-separator'; + + // Check if widget supports raw value using registry + let canToggleRaw = false; + let customKeybinds: CustomKeybind[] = []; + if (currentWidget && !isSeparator && !isFlexSeparator) { + const widgetImpl = getWidget(currentWidget.type); + if (widgetImpl) { + canToggleRaw = widgetImpl.supportsRawValue(); + // Get custom keybinds from the widget + customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget); + } else { + canToggleRaw = false; + } + } + + const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator; + const canExcludeAlign = Boolean(currentWidget) && !isSeparator && !isFlexSeparator + && settings.powerline.enabled && settings.powerline.autoAlign + && !isMergedIntoPreviousWidget(widgets, selectedIndex); + const hasWidgets = widgets.length > 0; + useInput((input, key) => { // Skip input if custom editor is active if (customEditorWidget) { @@ -193,6 +225,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB key, widgets, selectedIndex, + canExcludeAlign, separatorChars, onBack, onUpdate, @@ -251,28 +284,6 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ? (pickerEntries.find(entry => entry.type === widgetPicker.selectedType) ?? pickerEntries[0]) : null; - // Build dynamic help text based on selected item - const currentWidget = widgets[selectedIndex]; - const isSeparator = currentWidget?.type === 'separator'; - const isFlexSeparator = currentWidget?.type === 'flex-separator'; - - // Check if widget supports raw value using registry - let canToggleRaw = false; - let customKeybinds: CustomKeybind[] = []; - if (currentWidget && !isSeparator && !isFlexSeparator) { - const widgetImpl = getWidget(currentWidget.type); - if (widgetImpl) { - canToggleRaw = widgetImpl.supportsRawValue(); - // Get custom keybinds from the widget - customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget); - } else { - canToggleRaw = false; - } - } - - const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator; - const hasWidgets = widgets.length > 0; - // Build main help text (without custom keybinds) let helpText = hasWidgets ? '↑↓ select, ←→ open type picker' @@ -289,6 +300,9 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB if (canMerge) { helpText += ', (m)erge'; } + if (canExcludeAlign) { + helpText += ', e(x)clude align'; + } helpText += ', ESC back'; // Build custom keybinds text @@ -547,6 +561,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {supportsRawValue && widget.rawValue && (raw value)} {widget.merge === true && (merged→)} {widget.merge === 'no-padding' && (merged-no-pad→)} + {widget.excludeFromAutoAlign && settings.powerline.enabled && settings.powerline.autoAlign && !isMergedIntoPreviousWidget(widgets, index) && (no-align)} ); })} diff --git a/src/tui/components/items-editor/__tests__/input-handlers.test.ts b/src/tui/components/items-editor/__tests__/input-handlers.test.ts index 45dd555c..486d2f79 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -532,6 +532,59 @@ describe('items-editor input handlers', () => { expect(updated?.[0]?.character).toBe('-'); }); + it('toggles auto-align exclusion when the editor marks it available', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'tokens-input' } + ]; + const onUpdate = vi.fn(); + + handleNormalInputMode({ + input: 'x', + key: {}, + widgets, + selectedIndex: 0, + canExcludeAlign: true, + separatorChars: ['|', '-'], + onBack: vi.fn(), + onUpdate, + setSelectedIndex: vi.fn(), + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], + setCustomEditorWidget: vi.fn() + }); + + const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; + expect(updated?.[0]?.excludeFromAutoAlign).toBe(true); + }); + + it('ignores the auto-align exclusion shortcut when the editor marks it unavailable', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'tokens-input' } + ]; + const onUpdate = vi.fn(); + + handleNormalInputMode({ + input: 'x', + key: {}, + widgets, + selectedIndex: 0, + canExcludeAlign: false, + separatorChars: ['|', '-'], + onBack: vi.fn(), + onUpdate, + setSelectedIndex: vi.fn(), + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], + setCustomEditorWidget: vi.fn() + }); + + expect(onUpdate).not.toHaveBeenCalled(); + }); + it('applies custom widget keybind actions in normal mode', () => { const widgets: WidgetItem[] = [ { id: '1', type: 'session-usage' } diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 989b4c16..fab43416 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -338,6 +338,7 @@ export interface HandleNormalInputModeArgs { key: InputKey; widgets: WidgetItem[]; selectedIndex: number; + canExcludeAlign?: boolean; separatorChars: string[]; onBack: () => void; onUpdate: (widgets: WidgetItem[]) => void; @@ -355,6 +356,7 @@ export function handleNormalInputMode({ key, widgets, selectedIndex, + canExcludeAlign = false, separatorChars, onBack, onUpdate, @@ -455,6 +457,19 @@ export function handleNormalInputMode({ } onUpdate(newWidgets); } + } else if (input === 'x' && widgets.length > 0) { + const currentWidget = widgets[selectedIndex]; + if (canExcludeAlign && currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { + const newWidgets = [...widgets]; + if (currentWidget.excludeFromAutoAlign) { + const { excludeFromAutoAlign, ...rest } = currentWidget; + void excludeFromAutoAlign; // Intentionally unused + newWidgets[selectedIndex] = rest; + } else { + newWidgets[selectedIndex] = { ...currentWidget, excludeFromAutoAlign: true }; + } + onUpdate(newWidgets); + } } else if (key.escape) { onBack(); } else if (widgets.length > 0) { diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 121c2a9b..ad2bf571 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -21,6 +21,7 @@ export const WidgetItemSchema = z.object({ timeout: z.number().optional(), merge: z.union([z.boolean(), z.literal('no-padding')]).optional(), hide: z.boolean().optional(), + excludeFromAutoAlign: z.boolean().optional(), metadata: z.record(z.string(), z.string()).optional() }); diff --git a/src/utils/__tests__/renderer-exclude-auto-align.test.ts b/src/utils/__tests__/renderer-exclude-auto-align.test.ts new file mode 100644 index 00000000..098dcd8e --- /dev/null +++ b/src/utils/__tests__/renderer-exclude-auto-align.test.ts @@ -0,0 +1,105 @@ +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 { getVisibleWidth } from '../ansi'; +import { + calculateMaxWidthsFromPreRendered, + preRenderAllWidgets, + renderStatusLine, + type PreRenderedWidget +} from '../renderer'; + +function createSettings(overrides: Partial = {}): Settings { + return { + ...DEFAULT_SETTINGS, + defaultPadding: '', + flexMode: 'full', + ...overrides, + powerline: { + ...DEFAULT_SETTINGS.powerline, + ...(overrides.powerline ?? {}) + } + }; +} + +function pre(content: string, extra: Partial = {}): PreRenderedWidget { + return { content, plainLength: content.length, widget: { id: content, type: 'custom-text', ...extra } }; +} + +function text(content: string, extra: Partial = {}): WidgetItem { + return { id: content, type: 'custom-text', customText: content, ...extra }; +} + +describe('calculateMaxWidthsFromPreRendered with excludeFromAutoAlign', () => { + it.each([ + { name: 'lets a wide widget inflate the shared column by default', exclude: false, expected: [5, 14] }, + { name: 'drops an excluded widget and the rest of its line', exclude: true, expected: [5, 1] } + ])('$name', ({ exclude, expected }) => { + const lines = [ + [pre('short'), pre('VERYLONGWIDGET', exclude ? { excludeFromAutoAlign: true } : {})], + [pre('x'), pre('y')] + ]; + + expect(calculateMaxWidthsFromPreRendered(lines, createSettings())).toEqual(expected); + }); + + it('keeps columns before the excluded widget aligned', () => { + const lines = [ + [pre('a'), pre('wide', { excludeFromAutoAlign: true }), pre('tail')], + [pre('AAAAA'), pre('BBBBB'), pre('CCCCC')] + ]; + + expect(calculateMaxWidthsFromPreRendered(lines, createSettings())).toEqual([5, 5, 5]); + }); + + it('ignores exclusions on widgets merged into a previous widget', () => { + const linesWithoutExclude = [ + [pre('a', { merge: true }), pre('VERYLONGWIDGET')], + [pre('x'), pre('y')] + ]; + const linesWithMergedExclude = [ + [pre('a', { merge: true }), pre('VERYLONGWIDGET', { excludeFromAutoAlign: true })], + [pre('x'), pre('y')] + ]; + + expect(calculateMaxWidthsFromPreRendered(linesWithMergedExclude, createSettings())) + .toEqual(calculateMaxWidthsFromPreRendered(linesWithoutExclude, createSettings())); + }); + + it('honors exclusions on the first widget in a merged chain', () => { + const lines = [ + [pre('a', { merge: true, excludeFromAutoAlign: true }), pre('VERYLONGWIDGET')], + [pre('x'), pre('y')] + ]; + + expect(calculateMaxWidthsFromPreRendered(lines, createSettings())).toEqual([1, 1]); + }); +}); + +describe('renderStatusLine auto-align exemption', () => { + const settings = createSettings({ powerline: { ...DEFAULT_SETTINGS.powerline, enabled: true, autoAlign: true } }); + + function renderFirstLine(exclude: boolean): string { + const lines = [ + [text('a'), text('y', exclude ? { excludeFromAutoAlign: true } : {}), text('z')], + [text('AAAAA'), text('BBBBB'), text('CCCCC')] + ]; + const context: RenderContext = { isPreview: false, terminalWidth: 200, lineIndex: 0 }; + const preRendered = preRenderAllWidgets(lines, settings, context); + const maxWidths = calculateMaxWidthsFromPreRendered(preRendered, settings); + return renderStatusLine(lines[0] ?? [], settings, context, preRendered[0] ?? [], maxWidths); + } + + it('exempts the excluded widget and the rest of its line from alignment padding', () => { + expect(getVisibleWidth(renderFirstLine(false))).toBeGreaterThan(getVisibleWidth(renderFirstLine(true))); + }); +}); diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 47ac3914..fe022737 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -376,6 +376,11 @@ function renderPowerlineStatusLine( if (!element) continue; + // An excluded widget, and everything after it on this line, keeps its + // natural width (mirrors the skip in calculateMaxWidthsFromPreRendered). + if (element.widget.excludeFromAutoAlign) + break; + // Check if previous widget was merged with this one const prevWidget = i > 0 ? widgetElements[i - 1] : null; const isPreviousMerged = prevWidget?.mergesWithNext; @@ -854,6 +859,12 @@ export function calculateMaxWidthsFromPreRendered( if (!widget) continue; + // An excluded widget opts itself and the rest of the line out of the + // shared column widths. This only applies to merge-group heads; + // widgets merged into a previous widget keep the group's width. + if (widget.widget.excludeFromAutoAlign) + break; + // Calculate the total width for this alignment position // If this widget is merged with the next, accumulate their widths let totalWidth = widget.plainLength + (paddingLength * 2);