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
16 changes: 16 additions & 0 deletions src/utils/__tests__/renderer-dim.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
afterAll,
beforeAll,
describe,
expect,
it
Expand All @@ -18,12 +20,26 @@ import {
applyColors,
applyParensDim
} from '../colors';
import {
resetPowerlineFontCache,
setCachedPowerlineFontStatus
} from '../powerline';
import {
calculateMaxWidthsFromPreRendered,
preRenderAllWidgets,
renderStatusLine
} from '../renderer';

// The powerline tests below expect real separator glyphs in the output; pin
// the font detection so the render does not depend on this machine's fonts.
beforeAll(() => {
setCachedPowerlineFontStatus({ installed: true, checkedSymbol: '' });
});

afterAll(() => {
resetPowerlineFontCache();
});

const DIM = '\x1b[2m';
const BOLD = '\x1b[1m';
const INTENSITY_RESET = '\x1b[22m';
Expand Down
16 changes: 16 additions & 0 deletions src/utils/__tests__/renderer-flex-width.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
afterAll,
beforeAll,
describe,
expect,
it
Expand All @@ -15,6 +17,10 @@ import {
stripSgrCodes
} from '../ansi';
import { getColorAnsiCode } from '../colors';
import {
resetPowerlineFontCache,
setCachedPowerlineFontStatus
} from '../powerline';
import {
calculateMaxWidthsFromPreRendered,
countPowerlineStartCapSlots,
Expand All @@ -23,6 +29,16 @@ import {
} from '../renderer';
import { advanceGlobalSeparatorIndex } from '../separator-index';

// The powerline tests below expect real separator/cap glyphs in the output;
// pin the font detection so the render does not depend on this machine's fonts.
beforeAll(() => {
setCachedPowerlineFontStatus({ installed: true, checkedSymbol: '' });
});

afterAll(() => {
resetPowerlineFontCache();
});

function createSettings(overrides: Partial<Settings> = {}): Settings {
return {
...DEFAULT_SETTINGS,
Expand Down
100 changes: 100 additions & 0 deletions src/utils/__tests__/separator-font-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {
afterEach,
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 {
containsPowerlineGlyph,
fontSafeSeparator,
resetPowerlineFontCache
} from '../powerline';
import {
calculateMaxWidthsFromPreRendered,
preRenderAllWidgets,
renderStatusLine
} from '../renderer';

const fontAbsent = { installed: false, checkedSymbol: '' };
const fontPresent = { installed: true, checkedSymbol: '' };

function renderLine(settings: Settings, widgets: WidgetItem[]): string {
const context: RenderContext = { isPreview: false };
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);

return renderStatusLine(widgets, settings, context, preRenderedLines[0] ?? [], preCalculatedMaxWidths);
}

describe('font-safe separator fallback', () => {
afterEach(() => {
resetPowerlineFontCache();
delete process.env.DEBUG_FONT_INSTALL;
});

it('keeps plain separators untouched regardless of fonts', () => {
expect(fontSafeSeparator('|', fontAbsent)).toBe('|');
expect(fontSafeSeparator(' | ', fontAbsent)).toBe(' | ');
expect(fontSafeSeparator('', fontAbsent)).toBe('');
});

it('degrades powerline glyphs to | when no font is detected', () => {
expect(fontSafeSeparator('', fontAbsent)).toBe('|');
expect(fontSafeSeparator('', fontAbsent)).toBe('|');
expect(fontSafeSeparator('\u{F1000}', fontAbsent)).toBe('|'); // astral Nerd Font range
});

it('keeps glyphs when a powerline font is detected', () => {
expect(fontSafeSeparator('', fontPresent)).toBe('');
});

it('detects glyphs across PUA and astral ranges', () => {
expect(containsPowerlineGlyph('')).toBe(true);
expect(containsPowerlineGlyph('/')).toBe(false);
expect(containsPowerlineGlyph('\u{F1000}')).toBe(true);
});

it('renders | instead of mojibake in powerline mode without fonts', () => {
process.env.DEBUG_FONT_INSTALL = '1';
resetPowerlineFontCache();
const settings: Settings = {
...DEFAULT_SETTINGS,
colorLevel: 3,
defaultPadding: '',
powerline: { ...DEFAULT_SETTINGS.powerline, enabled: true }
};
const widgets: WidgetItem[] = [
{ id: '1', type: 'custom-text', customText: 'A' },
{ id: '2', type: 'custom-text', customText: 'B' }
];

const line = renderLine(settings, widgets);

expect(line).toContain('|');
expect(line).not.toContain('');
});

it('falls back for regular-mode separators typed as PUA glyphs', () => {
process.env.DEBUG_FONT_INSTALL = '1';
resetPowerlineFontCache();
const settings: Settings = {
...DEFAULT_SETTINGS,
colorLevel: 3,
defaultPadding: ''
};
const widgets: WidgetItem[] = [
{ id: '1', type: 'custom-text', customText: 'A' },
{ id: '2', type: 'separator', character: '' },
{ id: '3', type: 'custom-text', customText: 'B' }
];

const line = renderLine(settings, widgets);

expect(line).toContain('|');
expect(line).not.toContain('');
});
});
43 changes: 43 additions & 0 deletions src/utils/powerline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,46 @@ export async function checkPowerlineFontsAsync(): Promise<PowerlineFontStatus> {
}
}

// Private-use-area glyphs (the Powerline/Nerd Font ranges) render as mojibake
// without a matching font. The detection is a directory scan, so cache the
// result for the process lifetime; installPowerlineFonts() resets it.
let cachedFontStatus: PowerlineFontStatus | null = null;

export function resetPowerlineFontCache(): void {
cachedFontStatus = null;
}

// Test seam: pin the detection result so renderer output stays deterministic
// regardless of the machine's installed fonts.
export function setCachedPowerlineFontStatus(status: PowerlineFontStatus): void {
cachedFontStatus = status;
}

export function getCachedPowerlineFontStatus(): PowerlineFontStatus {
if (cachedFontStatus === null) {
cachedFontStatus = checkPowerlineFonts();
}
return cachedFontStatus;
}

// True when the text contains a glyph from the private-use-area ranges that
// Powerline/Nerd Fonts remap (BMP PUA plus the astral Nerd Font planes).
export function containsPowerlineGlyph(text: string): boolean {
return /[\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/u.test(text);
}

// Replace a PUA separator/cap glyph with a plain '|' when no Powerline font is
// detected, so the status line never renders mojibake. Non-glyph text and
// font-present environments pass through untouched. The fontStatus parameter
// exists for tests; production callers use the cached detection.
export function fontSafeSeparator(separator: string, fontStatus?: PowerlineFontStatus): string {
if (!separator || !containsPowerlineGlyph(separator)) {
return separator;
}
const status = fontStatus ?? getCachedPowerlineFontStatus();
return status.installed ? separator : '|';
}

/**
* Install Powerline fonts on the system
*/
Expand Down Expand Up @@ -243,6 +283,9 @@ export async function installPowerlineFonts(): Promise<{ success: boolean; messa
fontsInstalledThisSession = true;
}

// Newly installed fonts must stop the separator fallback
resetPowerlineFontCache();

return {
success: true,
message: 'Powerline fonts installed successfully! Please restart your terminal and select a Powerline font (e.g., "Source Code Pro for Powerline", "Meslo LG S for Powerline", etc.)'
Expand Down
14 changes: 8 additions & 6 deletions src/utils/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
parseGradientSpec
} from './gradient';
import { getTerminalWidth } from './terminal';
import { fontSafeSeparator } from './powerline';
import {
getWidget,
widgetPreservesColors
Expand Down Expand Up @@ -160,13 +161,14 @@ function renderPowerlineStatusLine(
const config = powerlineConfig ?? {};
const continueThemeAcrossLines = Boolean(config.continueThemeAcrossLines);

// Get separator configuration
const separators = (config.separators as string[] | undefined) ?? ['\uE0B0'];
// Get separator configuration; PUA glyphs degrade to '|' without a
// Powerline/Nerd Font instead of rendering as mojibake
const separators = ((config.separators as string[] | undefined) ?? ['\uE0B0']).map((separator) => fontSafeSeparator(separator));
const invertBgs = (config.separatorInvertBackground as boolean[] | undefined) ?? separators.map(() => false);

// Get caps arrays or fallback to empty arrays
const startCaps = (config.startCaps as string[] | undefined) ?? [];
const endCaps = (config.endCaps as string[] | undefined) ?? [];
const startCaps = ((config.startCaps as string[] | undefined) ?? []).map((cap) => fontSafeSeparator(cap));
const endCaps = ((config.endCaps as string[] | undefined) ?? []).map((cap) => fontSafeSeparator(cap));

// Get the cap for this line (cycle through if more lines than caps)
const capLineIndex = context.lineIndex ?? lineIndex;
Expand Down Expand Up @@ -1142,7 +1144,7 @@ export function renderStatusLine(
}
}

const sepChar = widget.character ?? (settings.defaultSeparator ?? '|');
const sepChar = fontSafeSeparator(widget.character ?? (settings.defaultSeparator ?? '|'));
const formattedSep = formatSeparator(sepChar);

// Check if we should inherit colors from the previous widget
Expand Down Expand Up @@ -1266,7 +1268,7 @@ export function renderStatusLine(
const finalElements: string[] = [];
const padding = settings.defaultPadding ?? '';
const { leading: sideLeadingPadding, trailing: sideTrailingPadding } = resolvePaddingSides(padding, settings.defaultPaddingSide);
const defaultSep = settings.defaultSeparator ? formatSeparator(settings.defaultSeparator) : '';
const defaultSep = settings.defaultSeparator ? formatSeparator(fontSafeSeparator(settings.defaultSeparator)) : '';

elements.forEach((elem, index) => {
// Add default separator between any two items (but not before first item, and not around flex separators)
Expand Down
Loading