Skip to content
Merged
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
59 changes: 37 additions & 22 deletions src/tui/components/ItemsEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ItemsEditorProps> = ({ widgets, onUpdate, onBack, lineNumber, settings }) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const [moveMode, setMoveMode] = useState(false);
Expand Down Expand Up @@ -152,6 +160,30 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ 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) {
Expand Down Expand Up @@ -193,6 +225,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
key,
widgets,
selectedIndex,
canExcludeAlign,
separatorChars,
onBack,
onUpdate,
Expand Down Expand Up @@ -251,28 +284,6 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ 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'
Expand All @@ -289,6 +300,9 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
if (canMerge) {
helpText += ', (m)erge';
}
if (canExcludeAlign) {
helpText += ', e(x)clude align';
}
helpText += ', ESC back';

// Build custom keybinds text
Expand Down Expand Up @@ -547,6 +561,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
{supportsRawValue && widget.rawValue && <Text dimColor> (raw value)</Text>}
{widget.merge === true && <Text dimColor> (merged→)</Text>}
{widget.merge === 'no-padding' && <Text dimColor> (merged-no-pad→)</Text>}
{widget.excludeFromAutoAlign && settings.powerline.enabled && settings.powerline.autoAlign && !isMergedIntoPreviousWidget(widgets, index) && <Text dimColor> (no-align)</Text>}
</Box>
);
})}
Expand Down
53 changes: 53 additions & 0 deletions src/tui/components/items-editor/__tests__/input-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down
15 changes: 15 additions & 0 deletions src/tui/components/items-editor/input-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ export interface HandleNormalInputModeArgs {
key: InputKey;
widgets: WidgetItem[];
selectedIndex: number;
canExcludeAlign?: boolean;
separatorChars: string[];
onBack: () => void;
onUpdate: (widgets: WidgetItem[]) => void;
Expand All @@ -355,6 +356,7 @@ export function handleNormalInputMode({
key,
widgets,
selectedIndex,
canExcludeAlign = false,
separatorChars,
onBack,
onUpdate,
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/types/Widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
});

Expand Down
105 changes: 105 additions & 0 deletions src/utils/__tests__/renderer-exclude-auto-align.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): Settings {
return {
...DEFAULT_SETTINGS,
defaultPadding: '',
flexMode: 'full',
...overrides,
powerline: {
...DEFAULT_SETTINGS.powerline,
...(overrides.powerline ?? {})
}
};
}

function pre(content: string, extra: Partial<WidgetItem> = {}): PreRenderedWidget {
return { content, plainLength: content.length, widget: { id: content, type: 'custom-text', ...extra } };
}

function text(content: string, extra: Partial<WidgetItem> = {}): 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)));
});
});
11 changes: 11 additions & 0 deletions src/utils/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down