Skip to content

Commit c20e111

Browse files
✨ feat(renderer): let widgets opt out of powerline auto-align (#489)
* feat(renderer): let widgets opt out of powerline auto-align In powerline auto-align mode every widget is padded so its column lines up with the widget above and below it. A single wide widget, such as a long git branch, therefore stretches that column on every other line and wastes horizontal space. Add an `excludeFromAutoAlign` flag. A flagged widget, and everything after it on the same line, stops contributing to the shared column widths and stops receiving alignment padding, so it keeps its natural width while the columns before it stay aligned. The items editor exposes an `e(x)clude align` toggle and a `(no-align)` marker, shown only when powerline auto-align is on. * fix(renderer): constrain no-align to alignable widgets Only allow excludeFromAutoAlign to be toggled from the items editor when powerline auto-align is active and the selected widget is not merged into a previous widget. This keeps the shortcut behavior aligned with the visible help text and prevents hidden exclusions from being persisted while the feature is unavailable. Treat no-align as a merge-chain-head option in the UI, so widgets merged into a previous item no longer show an effective no-align marker. The renderer keeps merged-in widgets participating in their parent group width while still honoring exclusions on the first widget in the chain. Add regression coverage for merged no-align width calculation and for the editor shortcut gate. --------- Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
1 parent cea4d3c commit c20e111

6 files changed

Lines changed: 222 additions & 22 deletions

File tree

src/tui/components/ItemsEditor.tsx

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ export interface ItemsEditorProps {
4242
settings: Settings;
4343
}
4444

45+
function isMergedIntoPreviousWidget(widgets: WidgetItem[], index: number): boolean {
46+
if (index <= 0) {
47+
return false;
48+
}
49+
50+
return Boolean(widgets[index - 1]?.merge);
51+
}
52+
4553
export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onBack, lineNumber, settings }) => {
4654
const [selectedIndex, setSelectedIndex] = useState(0);
4755
const [moveMode, setMoveMode] = useState(false);
@@ -152,6 +160,30 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
152160
setWidgetPicker(null);
153161
};
154162

163+
const currentWidget = widgets[selectedIndex];
164+
const isSeparator = currentWidget?.type === 'separator';
165+
const isFlexSeparator = currentWidget?.type === 'flex-separator';
166+
167+
// Check if widget supports raw value using registry
168+
let canToggleRaw = false;
169+
let customKeybinds: CustomKeybind[] = [];
170+
if (currentWidget && !isSeparator && !isFlexSeparator) {
171+
const widgetImpl = getWidget(currentWidget.type);
172+
if (widgetImpl) {
173+
canToggleRaw = widgetImpl.supportsRawValue();
174+
// Get custom keybinds from the widget
175+
customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
176+
} else {
177+
canToggleRaw = false;
178+
}
179+
}
180+
181+
const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator;
182+
const canExcludeAlign = Boolean(currentWidget) && !isSeparator && !isFlexSeparator
183+
&& settings.powerline.enabled && settings.powerline.autoAlign
184+
&& !isMergedIntoPreviousWidget(widgets, selectedIndex);
185+
const hasWidgets = widgets.length > 0;
186+
155187
useInput((input, key) => {
156188
// Skip input if custom editor is active
157189
if (customEditorWidget) {
@@ -193,6 +225,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
193225
key,
194226
widgets,
195227
selectedIndex,
228+
canExcludeAlign,
196229
separatorChars,
197230
onBack,
198231
onUpdate,
@@ -251,28 +284,6 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
251284
? (pickerEntries.find(entry => entry.type === widgetPicker.selectedType) ?? pickerEntries[0])
252285
: null;
253286

254-
// Build dynamic help text based on selected item
255-
const currentWidget = widgets[selectedIndex];
256-
const isSeparator = currentWidget?.type === 'separator';
257-
const isFlexSeparator = currentWidget?.type === 'flex-separator';
258-
259-
// Check if widget supports raw value using registry
260-
let canToggleRaw = false;
261-
let customKeybinds: CustomKeybind[] = [];
262-
if (currentWidget && !isSeparator && !isFlexSeparator) {
263-
const widgetImpl = getWidget(currentWidget.type);
264-
if (widgetImpl) {
265-
canToggleRaw = widgetImpl.supportsRawValue();
266-
// Get custom keybinds from the widget
267-
customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
268-
} else {
269-
canToggleRaw = false;
270-
}
271-
}
272-
273-
const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator;
274-
const hasWidgets = widgets.length > 0;
275-
276287
// Build main help text (without custom keybinds)
277288
let helpText = hasWidgets
278289
? '↑↓ select, ←→ open type picker'
@@ -289,6 +300,9 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
289300
if (canMerge) {
290301
helpText += ', (m)erge';
291302
}
303+
if (canExcludeAlign) {
304+
helpText += ', e(x)clude align';
305+
}
292306
helpText += ', ESC back';
293307

294308
// Build custom keybinds text
@@ -547,6 +561,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
547561
{supportsRawValue && widget.rawValue && <Text dimColor> (raw value)</Text>}
548562
{widget.merge === true && <Text dimColor> (merged→)</Text>}
549563
{widget.merge === 'no-padding' && <Text dimColor> (merged-no-pad→)</Text>}
564+
{widget.excludeFromAutoAlign && settings.powerline.enabled && settings.powerline.autoAlign && !isMergedIntoPreviousWidget(widgets, index) && <Text dimColor> (no-align)</Text>}
550565
</Box>
551566
);
552567
})}

src/tui/components/items-editor/__tests__/input-handlers.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,59 @@ describe('items-editor input handlers', () => {
532532
expect(updated?.[0]?.character).toBe('-');
533533
});
534534

535+
it('toggles auto-align exclusion when the editor marks it available', () => {
536+
const widgets: WidgetItem[] = [
537+
{ id: '1', type: 'tokens-input' }
538+
];
539+
const onUpdate = vi.fn();
540+
541+
handleNormalInputMode({
542+
input: 'x',
543+
key: {},
544+
widgets,
545+
selectedIndex: 0,
546+
canExcludeAlign: true,
547+
separatorChars: ['|', '-'],
548+
onBack: vi.fn(),
549+
onUpdate,
550+
setSelectedIndex: vi.fn(),
551+
setMoveMode: vi.fn(),
552+
setShowClearConfirm: vi.fn(),
553+
openWidgetPicker: vi.fn(),
554+
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
555+
setCustomEditorWidget: vi.fn()
556+
});
557+
558+
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
559+
expect(updated?.[0]?.excludeFromAutoAlign).toBe(true);
560+
});
561+
562+
it('ignores the auto-align exclusion shortcut when the editor marks it unavailable', () => {
563+
const widgets: WidgetItem[] = [
564+
{ id: '1', type: 'tokens-input' }
565+
];
566+
const onUpdate = vi.fn();
567+
568+
handleNormalInputMode({
569+
input: 'x',
570+
key: {},
571+
widgets,
572+
selectedIndex: 0,
573+
canExcludeAlign: false,
574+
separatorChars: ['|', '-'],
575+
onBack: vi.fn(),
576+
onUpdate,
577+
setSelectedIndex: vi.fn(),
578+
setMoveMode: vi.fn(),
579+
setShowClearConfirm: vi.fn(),
580+
openWidgetPicker: vi.fn(),
581+
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
582+
setCustomEditorWidget: vi.fn()
583+
});
584+
585+
expect(onUpdate).not.toHaveBeenCalled();
586+
});
587+
535588
it('applies custom widget keybind actions in normal mode', () => {
536589
const widgets: WidgetItem[] = [
537590
{ id: '1', type: 'session-usage' }

src/tui/components/items-editor/input-handlers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ export interface HandleNormalInputModeArgs {
338338
key: InputKey;
339339
widgets: WidgetItem[];
340340
selectedIndex: number;
341+
canExcludeAlign?: boolean;
341342
separatorChars: string[];
342343
onBack: () => void;
343344
onUpdate: (widgets: WidgetItem[]) => void;
@@ -355,6 +356,7 @@ export function handleNormalInputMode({
355356
key,
356357
widgets,
357358
selectedIndex,
359+
canExcludeAlign = false,
358360
separatorChars,
359361
onBack,
360362
onUpdate,
@@ -455,6 +457,19 @@ export function handleNormalInputMode({
455457
}
456458
onUpdate(newWidgets);
457459
}
460+
} else if (input === 'x' && widgets.length > 0) {
461+
const currentWidget = widgets[selectedIndex];
462+
if (canExcludeAlign && currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
463+
const newWidgets = [...widgets];
464+
if (currentWidget.excludeFromAutoAlign) {
465+
const { excludeFromAutoAlign, ...rest } = currentWidget;
466+
void excludeFromAutoAlign; // Intentionally unused
467+
newWidgets[selectedIndex] = rest;
468+
} else {
469+
newWidgets[selectedIndex] = { ...currentWidget, excludeFromAutoAlign: true };
470+
}
471+
onUpdate(newWidgets);
472+
}
458473
} else if (key.escape) {
459474
onBack();
460475
} else if (widgets.length > 0) {

src/types/Widget.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const WidgetItemSchema = z.object({
2121
timeout: z.number().optional(),
2222
merge: z.union([z.boolean(), z.literal('no-padding')]).optional(),
2323
hide: z.boolean().optional(),
24+
excludeFromAutoAlign: z.boolean().optional(),
2425
metadata: z.record(z.string(), z.string()).optional()
2526
});
2627

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import {
2+
describe,
3+
expect,
4+
it
5+
} from 'vitest';
6+
7+
import type { RenderContext } from '../../types/RenderContext';
8+
import {
9+
DEFAULT_SETTINGS,
10+
type Settings
11+
} from '../../types/Settings';
12+
import type { WidgetItem } from '../../types/Widget';
13+
import { getVisibleWidth } from '../ansi';
14+
import {
15+
calculateMaxWidthsFromPreRendered,
16+
preRenderAllWidgets,
17+
renderStatusLine,
18+
type PreRenderedWidget
19+
} from '../renderer';
20+
21+
function createSettings(overrides: Partial<Settings> = {}): Settings {
22+
return {
23+
...DEFAULT_SETTINGS,
24+
defaultPadding: '',
25+
flexMode: 'full',
26+
...overrides,
27+
powerline: {
28+
...DEFAULT_SETTINGS.powerline,
29+
...(overrides.powerline ?? {})
30+
}
31+
};
32+
}
33+
34+
function pre(content: string, extra: Partial<WidgetItem> = {}): PreRenderedWidget {
35+
return { content, plainLength: content.length, widget: { id: content, type: 'custom-text', ...extra } };
36+
}
37+
38+
function text(content: string, extra: Partial<WidgetItem> = {}): WidgetItem {
39+
return { id: content, type: 'custom-text', customText: content, ...extra };
40+
}
41+
42+
describe('calculateMaxWidthsFromPreRendered with excludeFromAutoAlign', () => {
43+
it.each([
44+
{ name: 'lets a wide widget inflate the shared column by default', exclude: false, expected: [5, 14] },
45+
{ name: 'drops an excluded widget and the rest of its line', exclude: true, expected: [5, 1] }
46+
])('$name', ({ exclude, expected }) => {
47+
const lines = [
48+
[pre('short'), pre('VERYLONGWIDGET', exclude ? { excludeFromAutoAlign: true } : {})],
49+
[pre('x'), pre('y')]
50+
];
51+
52+
expect(calculateMaxWidthsFromPreRendered(lines, createSettings())).toEqual(expected);
53+
});
54+
55+
it('keeps columns before the excluded widget aligned', () => {
56+
const lines = [
57+
[pre('a'), pre('wide', { excludeFromAutoAlign: true }), pre('tail')],
58+
[pre('AAAAA'), pre('BBBBB'), pre('CCCCC')]
59+
];
60+
61+
expect(calculateMaxWidthsFromPreRendered(lines, createSettings())).toEqual([5, 5, 5]);
62+
});
63+
64+
it('ignores exclusions on widgets merged into a previous widget', () => {
65+
const linesWithoutExclude = [
66+
[pre('a', { merge: true }), pre('VERYLONGWIDGET')],
67+
[pre('x'), pre('y')]
68+
];
69+
const linesWithMergedExclude = [
70+
[pre('a', { merge: true }), pre('VERYLONGWIDGET', { excludeFromAutoAlign: true })],
71+
[pre('x'), pre('y')]
72+
];
73+
74+
expect(calculateMaxWidthsFromPreRendered(linesWithMergedExclude, createSettings()))
75+
.toEqual(calculateMaxWidthsFromPreRendered(linesWithoutExclude, createSettings()));
76+
});
77+
78+
it('honors exclusions on the first widget in a merged chain', () => {
79+
const lines = [
80+
[pre('a', { merge: true, excludeFromAutoAlign: true }), pre('VERYLONGWIDGET')],
81+
[pre('x'), pre('y')]
82+
];
83+
84+
expect(calculateMaxWidthsFromPreRendered(lines, createSettings())).toEqual([1, 1]);
85+
});
86+
});
87+
88+
describe('renderStatusLine auto-align exemption', () => {
89+
const settings = createSettings({ powerline: { ...DEFAULT_SETTINGS.powerline, enabled: true, autoAlign: true } });
90+
91+
function renderFirstLine(exclude: boolean): string {
92+
const lines = [
93+
[text('a'), text('y', exclude ? { excludeFromAutoAlign: true } : {}), text('z')],
94+
[text('AAAAA'), text('BBBBB'), text('CCCCC')]
95+
];
96+
const context: RenderContext = { isPreview: false, terminalWidth: 200, lineIndex: 0 };
97+
const preRendered = preRenderAllWidgets(lines, settings, context);
98+
const maxWidths = calculateMaxWidthsFromPreRendered(preRendered, settings);
99+
return renderStatusLine(lines[0] ?? [], settings, context, preRendered[0] ?? [], maxWidths);
100+
}
101+
102+
it('exempts the excluded widget and the rest of its line from alignment padding', () => {
103+
expect(getVisibleWidth(renderFirstLine(false))).toBeGreaterThan(getVisibleWidth(renderFirstLine(true)));
104+
});
105+
});

src/utils/renderer.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,11 @@ function renderPowerlineStatusLine(
376376
if (!element)
377377
continue;
378378

379+
// An excluded widget, and everything after it on this line, keeps its
380+
// natural width (mirrors the skip in calculateMaxWidthsFromPreRendered).
381+
if (element.widget.excludeFromAutoAlign)
382+
break;
383+
379384
// Check if previous widget was merged with this one
380385
const prevWidget = i > 0 ? widgetElements[i - 1] : null;
381386
const isPreviousMerged = prevWidget?.mergesWithNext;
@@ -854,6 +859,12 @@ export function calculateMaxWidthsFromPreRendered(
854859
if (!widget)
855860
continue;
856861

862+
// An excluded widget opts itself and the rest of the line out of the
863+
// shared column widths. This only applies to merge-group heads;
864+
// widgets merged into a previous widget keep the group's width.
865+
if (widget.widget.excludeFromAutoAlign)
866+
break;
867+
857868
// Calculate the total width for this alignment position
858869
// If this widget is merged with the next, accumulate their widths
859870
let totalWidth = widget.plainLength + (paddingLength * 2);

0 commit comments

Comments
 (0)