Skip to content
Open
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
4 changes: 3 additions & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,11 @@ Configure global formatting preferences that apply to all widgets:

### Default Padding & Separators

- **Default Padding** - Add consistent padding to the left and right of each widget
- **Default Padding** - Add consistent padding around each widget
- **Padding Side** - Choose whether default padding applies to **Both** sides (default), **Left only**, or **Right only**
- **Default Separator** - Automatically insert a separator between all widgets
- Press **(p)** to edit padding
- Press **(d)** to cycle padding side
- Press **(s)** to edit separator
- Manual separators collapse around widgets that render empty, so hide-when-empty widgets do not leave dangling dividers.

Expand Down
26 changes: 24 additions & 2 deletions src/tui/components/GlobalOverridesMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {
import React, { useState } from 'react';

import { getColorLevelString } from '../../types/ColorLevel';
import type { Settings } from '../../types/Settings';
import {
DefaultPaddingSideSchema,
type Settings
} from '../../types/Settings';
import {
COLOR_MAP,
applyColors,
Expand Down Expand Up @@ -231,6 +234,16 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
overrideForegroundColor: undefined
};
onUpdate(updatedSettings);
} else if (input === 'd' || input === 'D') {
// Cycle through padding sides: both -> left -> right -> both
const paddingSides = DefaultPaddingSideSchema.options;
const currentIndex = paddingSides.indexOf(settings.defaultPaddingSide);
const nextSide = paddingSides[(currentIndex + 1) % paddingSides.length] ?? 'both';
const updatedSettings = {
...settings,
defaultPaddingSide: nextSide
};
onUpdate(updatedSettings);
}
}
});
Expand Down Expand Up @@ -298,7 +311,7 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
{editingPadding ? (
<Box flexDirection='column'>
<Box>
<Text>Enter default padding (applied to left and right of each widget): </Text>
<Text>Enter default padding (applied per the Padding Side setting): </Text>
<Text color='cyan'>{paddingInput ? `"${paddingInput}"` : '(empty)'}</Text>
</Box>
<Text dimColor>Press Enter to save, ESC to cancel</Text>
Expand Down Expand Up @@ -365,6 +378,12 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
<Text dimColor> - Press (p) to edit</Text>
</Box>

<Box>
<Text> Padding Side: </Text>
<Text color='cyan'>{settings.defaultPaddingSide === 'left' ? 'Left only' : settings.defaultPaddingSide === 'right' ? 'Right only' : 'Both'}</Text>
<Text dimColor> - Press (d) to cycle</Text>
</Box>

<Box>
<Text>Override FG Color: </Text>
{(() => {
Expand Down Expand Up @@ -442,6 +461,9 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
<Text dimColor wrap='wrap'>
Note: These settings are applied during rendering and don't add widgets to your widget list.
</Text>
<Text dimColor wrap='wrap'>
• Padding Side: Choose whether default padding applies to both sides, left only, or right only
</Text>
<Text dimColor wrap='wrap'>
• Inherit colors: Separators will use colors from the preceding widget
</Text>
Expand Down
78 changes: 78 additions & 0 deletions src/tui/components/__tests__/GlobalOverridesMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,84 @@ describe('GlobalOverridesMenu', () => {
}
});

it('displays padding side as "Both" by default', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn();
const onBack = vi.fn();

const instance = render(
React.createElement(GlobalOverridesMenu, {
settings: DEFAULT_SETTINGS,
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);

try {
await flushInk();
expect(stdout.getOutput()).toContain('Padding Side:');
expect(stdout.getOutput()).toContain('Both');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});

it.each([
{ starting: 'both' as const, expected: 'left' as const },
{ starting: 'left' as const, expected: 'right' as const },
{ starting: 'right' as const, expected: 'both' as const }
])('cycles padding side from "$starting" to "$expected" when (d) is pressed', async ({ starting, expected }) => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn();
const onBack = vi.fn();

const instance = render(
React.createElement(GlobalOverridesMenu, {
settings: { ...DEFAULT_SETTINGS, defaultPaddingSide: starting },
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);

try {
await flushInk();
stdin.write('d');
await flushInk();

expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ defaultPaddingSide: expected }));
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});

it('shows foreground override gradient and clear controls on the same line', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
Expand Down
5 changes: 5 additions & 0 deletions src/types/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { WidgetItemSchema } from './Widget';
// Current version - bump this when making breaking changes to the schema
export const CURRENT_VERSION = 3;

// Which side(s) of a widget the default padding is applied to
export const DefaultPaddingSideSchema = z.enum(['both', 'left', 'right']);
export type DefaultPaddingSide = z.infer<typeof DefaultPaddingSideSchema>;

export const InstallationMetadataSchema = z.discriminatedUnion('method', [
z.object({
method: z.literal('auto-update'),
Expand Down Expand Up @@ -64,6 +68,7 @@ export const SettingsSchema = z.object({
colorLevel: ColorLevelSchema.default(2),
defaultSeparator: z.string().optional(),
defaultPadding: z.string().optional(),
defaultPaddingSide: DefaultPaddingSideSchema.default('both'),
inheritSeparatorColors: z.boolean().default(false),
overrideBackgroundColor: z.string().optional(),
overrideForegroundColor: z.string().optional(),
Expand Down
178 changes: 178 additions & 0 deletions src/utils/__tests__/renderer-padding-side.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
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 { stripSgrCodes } from '../ansi';
import {
calculateMaxWidthsFromPreRendered,
renderStatusLine,
type PreRenderedWidget
} from '../renderer';

function createSettings(overrides: Partial<Settings> = {}): Settings {
return {
...DEFAULT_SETTINGS,
colorLevel: 0,
defaultPadding: '.',
...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 };
}

function powerlineSettings(overrides: Partial<Settings> = {}): Settings {
return createSettings({
...overrides,
powerline: { ...DEFAULT_SETTINGS.powerline, enabled: true, separators: ['|'] }
});
}

// Assertions below only care about visible content and spacing, never
// styling, so every render() call returns SGR-stripped output. This matches
// the repo convention (see stripSgrCodes usage in renderer-flex-width.test.ts
// and the separator-collapse tests) and keeps assertions stable regardless of
// chalk's ambient color-support detection (e.g. under FORCE_COLOR/NO_COLOR),
// which is independent of the `colorLevel` set on Settings.
function render(widgets: WidgetItem[], contentByIndex: Record<number, string>, settings: Settings): string {
const context: RenderContext = { isPreview: false, terminalWidth: 200 };
const preRenderedWidgets = widgets.map((widget, i) => {
const content = contentByIndex[i] ?? '';
return { content, plainLength: content.length, widget };
});
return stripSgrCodes(renderStatusLine(widgets, settings, context, preRenderedWidgets, []));
}

describe('defaultPaddingSide', () => {
it('defaults to "both", preserving existing behavior', () => {
expect(DEFAULT_SETTINGS.defaultPaddingSide).toBe('both');
});

describe('standard (non-powerline) rendering', () => {
it('applies padding to both sides by default', () => {
const settings = createSettings();
const out = render([text('a')], { 0: 'A' }, settings);
expect(out).toBe('.A.');
});

it('applies padding to the left only when side is "left"', () => {
const settings = createSettings({ defaultPaddingSide: 'left' });
const out = render([text('a')], { 0: 'A' }, settings);
expect(out).toBe('.A');
});

it('applies padding to the right only when side is "right"', () => {
const settings = createSettings({ defaultPaddingSide: 'right' });
const out = render([text('a')], { 0: 'A' }, settings);
expect(out).toBe('A.');
});
});

describe('powerline rendering', () => {
it('applies padding to both sides by default', () => {
const out = render([text('a')], { 0: 'A' }, powerlineSettings());
expect(out).toContain('.A.');
});

it('applies padding to the left only when side is "left"', () => {
const settings = powerlineSettings({ defaultPaddingSide: 'left' });
const widgets = [text('a'), text('b')];
const out = render(widgets, { 0: 'A', 1: 'B' }, settings);
expect(out).toContain('.A');
expect(out).not.toContain('A.');
});

it('applies padding to the right only when side is "right"', () => {
const settings = powerlineSettings({ defaultPaddingSide: 'right' });
const widgets = [text('a'), text('b')];
const out = render(widgets, { 0: 'A', 1: 'B' }, settings);
expect(out).toContain('A.');
expect(out).not.toContain('.A');
});
});

describe('merge: "no-padding" takes precedence over the padding-side setting', () => {
it.each([
{
side: 'left' as const,
// Side 'left' means B's own leading pad would already be '.', so
// the no-padding merge must be what suppresses it (glue "AB").
withoutMerge: '.A.B',
withMerge: '.AB'
},
{
side: 'right' as const,
// Side 'right' means A's own trailing pad would already be '.', so
// the no-padding merge must be what suppresses it (glue "AB").
withoutMerge: 'A.B.',
withMerge: 'AB.'
}
])('standard mode, side "$side": no double-pad and correct glue across a no-padding merge boundary', ({ side, withoutMerge, withMerge }) => {
const settings = createSettings({ defaultPaddingSide: side });

const baseline = render([text('a'), text('b')], { 0: 'A', 1: 'B' }, settings);
expect(baseline).toBe(withoutMerge);

const merged = render([text('a', { merge: 'no-padding' }), text('b')], { 0: 'A', 1: 'B' }, settings);
expect(merged).toBe(withMerge);
});

it.each([
{ side: 'left' as const },
{ side: 'right' as const }
])('powerline mode, side "$side": no double-pad, no separator, and correct glue across a no-padding merge boundary', ({ side }) => {
const settings = powerlineSettings({ defaultPaddingSide: side });

const baseline = render([text('a'), text('b')], { 0: 'A', 1: 'B' }, settings);
// Without the merge, A and B are separate segments joined by the
// powerline separator.
expect(baseline).toContain('|');

const merged = render([text('a', { merge: 'no-padding' }), text('b')], { 0: 'A', 1: 'B' }, settings);
// With the no-padding merge, A and B glue directly together: no
// separator, and no padding character sits between them.
expect(merged).not.toContain('|');
expect(merged).toContain('AB');
});
});

describe('calculateMaxWidthsFromPreRendered width accounting', () => {
it('counts padding on both sides by default', () => {
const lines = [[pre('AB')]];
const settings = createSettings({ defaultPadding: '..' });
// 'AB' (2) + 2 leading + 2 trailing = 6
expect(calculateMaxWidthsFromPreRendered(lines, settings)).toEqual([6]);
});

it('counts padding only once when side is "left"', () => {
const lines = [[pre('AB')]];
const settings = createSettings({ defaultPadding: '..', defaultPaddingSide: 'left' });
// 'AB' (2) + 2 leading + 0 trailing = 4
expect(calculateMaxWidthsFromPreRendered(lines, settings)).toEqual([4]);
});

it('counts padding only once when side is "right"', () => {
const lines = [[pre('AB')]];
const settings = createSettings({ defaultPadding: '..', defaultPaddingSide: 'right' });
// 'AB' (2) + 0 leading + 2 trailing = 4
expect(calculateMaxWidthsFromPreRendered(lines, settings)).toEqual([4]);
});
});
});
Loading