From 50ab08af13bda8c6aadca8ee3b5358aaaf19f45c Mon Sep 17 00:00:00 2001 From: axisrow Date: Sun, 27 Sep 2026 00:22:28 +0800 Subject: [PATCH] fix(powerline): fall back to a plain | separator when no Powerline font is detected Powerline/Nerd Font glyphs (private-use area) render as mojibake when the terminal has no matching font; checkPowerlineFonts() could detect this, but only the TUI setup flow surfaced it. Now every powerline separator, cap, and user-typed separator character is checked at render time: PUA glyphs degrade to '|' when no font is detected and pass through untouched otherwise. Detection is a directory scan, cached for the process lifetime; installing fonts through the TUI resets the cache. Tests can pin the detection via setCachedPowerlineFontStatus() so renders stay deterministic regardless of the machine's fonts. Closes the bug half of sirmalloc/ccstatusline#604. Co-Authored-By: Claude Code --- src/utils/__tests__/renderer-dim.test.ts | 16 +++ .../__tests__/renderer-flex-width.test.ts | 16 +++ .../__tests__/separator-font-fallback.test.ts | 100 ++++++++++++++++++ src/utils/powerline.ts | 43 ++++++++ src/utils/renderer.ts | 14 +-- 5 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 src/utils/__tests__/separator-font-fallback.test.ts diff --git a/src/utils/__tests__/renderer-dim.test.ts b/src/utils/__tests__/renderer-dim.test.ts index 57293f5a9..99b34d298 100644 --- a/src/utils/__tests__/renderer-dim.test.ts +++ b/src/utils/__tests__/renderer-dim.test.ts @@ -1,4 +1,6 @@ import { + afterAll, + beforeAll, describe, expect, it @@ -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'; diff --git a/src/utils/__tests__/renderer-flex-width.test.ts b/src/utils/__tests__/renderer-flex-width.test.ts index 25d949151..7f6f047b5 100644 --- a/src/utils/__tests__/renderer-flex-width.test.ts +++ b/src/utils/__tests__/renderer-flex-width.test.ts @@ -1,4 +1,6 @@ import { + afterAll, + beforeAll, describe, expect, it @@ -15,6 +17,10 @@ import { stripSgrCodes } from '../ansi'; import { getColorAnsiCode } from '../colors'; +import { + resetPowerlineFontCache, + setCachedPowerlineFontStatus +} from '../powerline'; import { calculateMaxWidthsFromPreRendered, countPowerlineStartCapSlots, @@ -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 { return { ...DEFAULT_SETTINGS, diff --git a/src/utils/__tests__/separator-font-fallback.test.ts b/src/utils/__tests__/separator-font-fallback.test.ts new file mode 100644 index 000000000..5a2c34f4e --- /dev/null +++ b/src/utils/__tests__/separator-font-fallback.test.ts @@ -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(''); + }); +}); diff --git a/src/utils/powerline.ts b/src/utils/powerline.ts index 9602a79c6..1ef68ea1e 100644 --- a/src/utils/powerline.ts +++ b/src/utils/powerline.ts @@ -153,6 +153,46 @@ export async function checkPowerlineFontsAsync(): Promise { } } +// 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 */ @@ -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.)' diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 9d1edbd08..1f3ab86d4 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -39,6 +39,7 @@ import { parseGradientSpec } from './gradient'; import { getTerminalWidth } from './terminal'; +import { fontSafeSeparator } from './powerline'; import { getWidget, widgetPreservesColors @@ -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; @@ -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 @@ -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)