diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index d2ae6606..41a941b4 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -23,6 +23,7 @@ import { ConfirmDialog } from './ConfirmDialog'; import { clearAllWidgetStyling, cycleWidgetColor, + cycleWidgetDim, resetWidgetStyling, setWidgetColor, toggleWidgetBold @@ -268,6 +269,15 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin onUpdate(newItems); } } + } else if (input === 'd' || input === 'D') { + if (highlightedItemId && highlightedItemId !== 'back') { + // Cycle dim for the highlighted item: off -> whole -> parens -> off + const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); + if (selectedWidget) { + const newItems = cycleWidgetDim(widgets, selectedWidget.id); + onUpdate(newItems); + } + } } else if (input === 'r' || input === 'R') { if (highlightedItemId && highlightedItemId !== 'back') { // Reset all styling (color, background, and bold) for the highlighted item @@ -347,7 +357,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin defaultColor = widgetImpl.getDefaultColor(); } } - const styledLabel = applyColors(label, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, level); + const styledLabel = applyColors(label, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, level, widget.dim); return { label: styledLabel, value: widget.id @@ -431,6 +441,11 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin colorDisplay = applyColors(displayName, currentColor, undefined, false, level); } } + const styleIndicators = [ + selectedWidget?.bold ? '[BOLD]' : null, + selectedWidget?.dim === true ? '[DIM]' : null, + selectedWidget?.dim === 'parens' ? '[DIM ()]' : null + ].filter(indicator => indicator !== null).join(' '); // Gradient selection mode takes over the whole view if (gradientMode) { @@ -573,7 +588,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin ↑↓ to select, ←→ to cycle {' '} {editingBackground ? 'background' : 'foreground'} - , (f) to toggle bg/fg, (b)old, + , (f) to toggle bg/fg, (b)old, (d)im, {settings.colorLevel === 3 ? ' (h)ex,' : settings.colorLevel === 2 ? ' (a)nsi256,' : ''} {!editingBackground && settings.colorLevel >= 2 ? ' (g)radient,' : ''} {' '} @@ -597,7 +612,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin ): {' '} {colorDisplay} - {selectedWidget.bold && chalk.bold(' [BOLD]')} + {styleIndicators && ` ${styleIndicators}`} ) : ( diff --git a/src/tui/components/__tests__/ColorMenu.test.tsx b/src/tui/components/__tests__/ColorMenu.test.tsx new file mode 100644 index 00000000..2bd37c01 --- /dev/null +++ b/src/tui/components/__tests__/ColorMenu.test.tsx @@ -0,0 +1,122 @@ +import { render } from 'ink'; +import { PassThrough } from 'node:stream'; +import React from 'react'; +import { + describe, + expect, + it, + vi +} from 'vitest'; + +import { DEFAULT_SETTINGS } from '../../../types/Settings'; +import type { WidgetItem } from '../../../types/Widget'; +import { ColorMenu } from '../ColorMenu'; + +class MockTtyStream extends PassThrough { + isTTY = true; + columns = 160; + rows = 40; + + setRawMode() { + return this; + } + + ref() { + return this; + } + + unref() { + return this; + } +} + +interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string } + +function createMockStdin(): NodeJS.ReadStream { + return new MockTtyStream() as unknown as NodeJS.ReadStream; +} + +function createMockStdout(): CapturedWriteStream { + const stream = new MockTtyStream(); + const chunks: string[] = []; + + stream.on('data', (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + + return Object.assign(stream as unknown as NodeJS.WriteStream, { + getOutput() { + return chunks.join(''); + } + }); +} + +function flushInk() { + return new Promise((resolve) => { + setTimeout(resolve, 25); + }); +} + +describe('ColorMenu', () => { + it('keeps bold and dim indicators on the current-style row', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const widgets: WidgetItem[] = [ + { id: '1', type: 'cache-hit-rate' }, + { + id: '2', + type: 'cache-read', + color: 'hex:ABB2BF', + backgroundColor: 'bgBrightBlack', + bold: true, + dim: 'parens' + }, + { id: '3', type: 'cache-write' }, + { id: '4', type: 'tokens-cached' } + ]; + + const instance = render( + React.createElement(ColorMenu, { + widgets, + settings: { + ...DEFAULT_SETTINGS, + colorLevel: 3, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true + } + }, + onUpdate: vi.fn(), + onBack: vi.fn() + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + stdin.write('\x1B[B'); + await flushInk(); + + const latestFrame = stdout.getOutput().split('Configure Colors').at(-1) ?? ''; + const currentStyleLine = latestFrame + .split('\n') + .find(line => line.includes('Current foreground')) ?? ''; + + expect(currentStyleLine).toContain('[BOLD] [DIM ()]'); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); +}); diff --git a/src/tui/components/__tests__/StatusLinePreview.test.ts b/src/tui/components/__tests__/StatusLinePreview.test.ts index 9cf7af32..c72dfa90 100644 --- a/src/tui/components/__tests__/StatusLinePreview.test.ts +++ b/src/tui/components/__tests__/StatusLinePreview.test.ts @@ -1,12 +1,68 @@ +import { render } from 'ink'; +import { PassThrough } from 'node:stream'; +import React from 'react'; import { describe, expect, it } from 'vitest'; +import { + DEFAULT_SETTINGS, + type Settings +} from '../../../types/Settings'; +import type { WidgetItem } from '../../../types/Widget'; import { getVisibleWidth } from '../../../utils/ansi'; import { renderOsc8Link } from '../../../utils/hyperlink'; -import { preparePreviewLineForTerminal } from '../StatusLinePreview'; +import { + StatusLinePreview, + preparePreviewLineForTerminal +} from '../StatusLinePreview'; + +class MockTtyStream extends PassThrough { + isTTY = true; + columns = 160; + rows = 40; + + setRawMode() { + return this; + } + + ref() { + return this; + } + + unref() { + return this; + } +} + +interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string } + +function createMockStdin(): NodeJS.ReadStream { + return new MockTtyStream() as unknown as NodeJS.ReadStream; +} + +function createMockStdout(): CapturedWriteStream { + const stream = new MockTtyStream(); + const chunks: string[] = []; + + stream.on('data', (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + + return Object.assign(stream as unknown as NodeJS.WriteStream, { + getOutput() { + return chunks.join(''); + } + }); +} + +function flushInk() { + return new Promise((resolve) => { + setTimeout(resolve, 25); + }); +} describe('StatusLinePreview helpers', () => { it('strips OSC links and clamps preview lines to the terminal width', () => { @@ -21,4 +77,80 @@ describe('StatusLinePreview helpers', () => { expect(prepared.endsWith('...')).toBe(true); expect(getVisibleWidth(` ${prepared}`)).toBeLessThanOrEqual(40); }); + + it('keeps parens dim scoped in the Ink preview when global bold is active', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const settings: Settings = { + ...DEFAULT_SETTINGS, + colorLevel: 3, + globalBold: true, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: 'custom', + separators: ['\uE0B0'], + separatorInvertBackground: [false] + } + }; + const lines: WidgetItem[][] = [[ + { + id: 'w1', + type: 'custom-text', + customText: 'Cache Hit: 87.0%', + color: 'hex:282C34', + backgroundColor: 'hex:61AFEF' + }, + { + id: 'w2', + type: 'custom-text', + customText: 'Cache Read: 12k (64.0%)', + color: 'hex:ABB2BF', + backgroundColor: 'hex:3E4452', + dim: 'parens' + }, + { + id: 'w3', + type: 'custom-text', + customText: 'Cache Write: 3k (16.0%)', + color: 'hex:282C34', + backgroundColor: 'hex:98C379' + } + ]]; + + const instance = render( + React.createElement(StatusLinePreview, { + lines, + terminalWidth: 160, + settings + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + const output = stdout.getOutput(); + const dimIndex = output.indexOf('\x1b[2m(64.0%)'); + const resetIndex = output.indexOf('\x1b[22;1m', dimIndex); + const nextWidgetIndex = output.indexOf('Cache Write'); + + expect(dimIndex).toBeGreaterThanOrEqual(0); + expect(resetIndex).toBeGreaterThan(dimIndex); + expect(resetIndex).toBeLessThan(nextWidgetIndex); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); }); diff --git a/src/tui/components/color-menu/__tests__/mutations.test.ts b/src/tui/components/color-menu/__tests__/mutations.test.ts index 00a5650f..d4b7e300 100644 --- a/src/tui/components/color-menu/__tests__/mutations.test.ts +++ b/src/tui/components/color-menu/__tests__/mutations.test.ts @@ -8,6 +8,7 @@ import type { WidgetItem } from '../../../../types/Widget'; import { clearAllWidgetStyling, cycleWidgetColor, + cycleWidgetDim, resetWidgetStyling, toggleWidgetBold, updateWidgetById @@ -41,14 +42,31 @@ describe('color-menu mutations', () => { expect(updated[1]?.bold).toBe(false); }); - it('resetWidgetStyling removes color, backgroundColor, and bold from one widget', () => { + it('cycleWidgetDim cycles off, whole widget, parens, then off for the selected widget only', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'tokens-input' }, + { id: '2', type: 'tokens-output' } + ]; + + const whole = cycleWidgetDim(widgets, '1'); + const parens = cycleWidgetDim(whole, '1'); + const off = cycleWidgetDim(parens, '1'); + + expect(whole[0]?.dim).toBe(true); + expect(parens[0]?.dim).toBe('parens'); + expect(off[0]).toEqual({ id: '1', type: 'tokens-input' }); + expect(whole[1]?.dim).toBeUndefined(); + }); + + it('resetWidgetStyling removes color, backgroundColor, bold, and dim from one widget', () => { const widgets: WidgetItem[] = [ { id: '1', type: 'tokens-input', color: 'red', backgroundColor: 'blue', - bold: true + bold: true, + dim: 'parens' }, { id: '2', type: 'tokens-output', color: 'white', bold: true } ]; @@ -66,9 +84,10 @@ describe('color-menu mutations', () => { type: 'tokens-input', color: 'red', backgroundColor: 'blue', - bold: true + bold: true, + dim: true }, - { id: '2', type: 'tokens-output', color: 'white', bold: true } + { id: '2', type: 'tokens-output', color: 'white', bold: true, dim: 'parens' } ]; const updated = clearAllWidgetStyling(widgets); diff --git a/src/tui/components/color-menu/mutations.ts b/src/tui/components/color-menu/mutations.ts index bfccc785..855b7976 100644 --- a/src/tui/components/color-menu/mutations.ts +++ b/src/tui/components/color-menu/mutations.ts @@ -37,17 +37,42 @@ export function toggleWidgetBold(widgets: WidgetItem[], widgetId: string): Widge })); } +export function cycleWidgetDim(widgets: WidgetItem[], widgetId: string): WidgetItem[] { + return updateWidgetById(widgets, widgetId, (widget) => { + // Cycle: off -> whole widget -> (...) spans only -> off + if (widget.dim === true) { + return { + ...widget, + dim: 'parens' as const + }; + } + + if (widget.dim === 'parens') { + const { dim, ...restWidget } = widget; + void dim; // Intentionally unused + return restWidget; + } + + return { + ...widget, + dim: true + }; + }); +} + export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): WidgetItem[] { return updateWidgetById(widgets, widgetId, (widget) => { const { color, backgroundColor, bold, + dim, ...restWidget } = widget; void color; // Intentionally unused void backgroundColor; // Intentionally unused void bold; // Intentionally unused + void dim; // Intentionally unused return restWidget; }); } @@ -58,11 +83,13 @@ export function clearAllWidgetStyling(widgets: WidgetItem[]): WidgetItem[] { color, backgroundColor, bold, + dim, ...restWidget } = widget; void color; // Intentionally unused void backgroundColor; // Intentionally unused void bold; // Intentionally unused + void dim; // Intentionally unused return restWidget; }); } diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 6415bcf4..121c2a9b 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -10,6 +10,7 @@ export const WidgetItemSchema = z.object({ color: z.string().optional(), backgroundColor: z.string().optional(), bold: z.boolean().optional(), + dim: z.union([z.boolean(), z.literal('parens')]).optional(), character: z.string().optional(), rawValue: z.boolean().optional(), customText: z.string().optional(), diff --git a/src/utils/__tests__/renderer-dim.test.ts b/src/utils/__tests__/renderer-dim.test.ts new file mode 100644 index 00000000..57293f5a --- /dev/null +++ b/src/utils/__tests__/renderer-dim.test.ts @@ -0,0 +1,322 @@ +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 { + getVisibleText, + getVisibleWidth +} from '../ansi'; +import { + applyColors, + applyParensDim +} from '../colors'; +import { + calculateMaxWidthsFromPreRendered, + preRenderAllWidgets, + renderStatusLine +} from '../renderer'; + +const DIM = '\x1b[2m'; +const BOLD = '\x1b[1m'; +const INTENSITY_RESET = '\x1b[22m'; +const INTENSITY_RESET_BOLD = '\x1b[22;1m'; +const TRUECOLOR_CODE = /\x1b\[38;2;\d+;\d+;\d+m/g; + +function createSettings(overrides: Partial = {}): Settings { + return { + ...DEFAULT_SETTINGS, + flexMode: 'full', + ...overrides, + powerline: { + ...DEFAULT_SETTINGS.powerline, + ...(overrides.powerline ?? {}) + } + }; +} + +function renderLine( + widgets: WidgetItem[], + options: { settings?: Partial; terminalWidth?: number } = {} +): string { + const settings = createSettings(options.settings); + const context: RenderContext = { + isPreview: false, + terminalWidth: options.terminalWidth + }; + + const preRenderedLines = preRenderAllWidgets([widgets], settings, context); + const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings); + const preRenderedWidgets = preRenderedLines[0] ?? []; + + return renderStatusLine(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths); +} + +describe('applyColors dim handling', () => { + it('dims the whole text with a single intensity reset', () => { + expect(applyColors('ctx', undefined, undefined, false, 'ansi16', true)).toBe(`${DIM}ctx${INTENSITY_RESET}`); + }); + + it('emits one intensity reset when bold and dim are combined', () => { + expect(applyColors('ctx', undefined, undefined, true, 'ansi16', true)).toBe(`${BOLD}${DIM}ctx${INTENSITY_RESET}`); + }); + + it('dims only parenthesized spans in parens mode', () => { + expect(applyColors('42k (21%)', undefined, undefined, false, 'ansi16', 'parens')).toBe(`42k ${DIM}(21%)${INTENSITY_RESET}`); + }); + + it('re-asserts bold after each parens span when bold is active', () => { + expect(applyColors('42k (21%)', undefined, undefined, true, 'ansi16', 'parens')).toBe(`${BOLD}42k ${DIM}(21%)${INTENSITY_RESET_BOLD}${INTENSITY_RESET}`); + }); + + it('leaves text without parens untouched', () => { + expect(applyParensDim('plain text')).toBe('plain text'); + }); + + it('dims multiple parens spans independently', () => { + expect(applyParensDim('(a) mid (b)')).toBe(`${DIM}(a)${INTENSITY_RESET} mid ${DIM}(b)${INTENSITY_RESET}`); + }); + + it('preserves parens dim when applying a foreground gradient', () => { + const line = applyColors('ctx (42%)', 'gradient:FF0000-0000FF', undefined, false, 'truecolor', 'parens'); + const dimIndex = line.indexOf(DIM); + const openParenIndex = line.indexOf('('); + const closeParenIndex = line.indexOf(')'); + const resetIndex = line.indexOf(INTENSITY_RESET, closeParenIndex); + + expect(getVisibleText(line)).toBe('ctx (42%)'); + expect(dimIndex).toBeGreaterThanOrEqual(0); + expect(dimIndex).toBeLessThan(openParenIndex); + expect(resetIndex).toBeGreaterThan(closeParenIndex); + expect(line.match(TRUECOLOR_CODE)).toHaveLength(8); + }); +}); + +describe('renderer dim styling', () => { + it('dims a whole widget in normal mode', () => { + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'hello', + dim: true + } + ]; + + const line = renderLine(widgets); + expect(line.indexOf(DIM)).toBeGreaterThanOrEqual(0); + expect(line.indexOf(DIM)).toBeLessThan(line.indexOf('hello')); + expect(line.indexOf(INTENSITY_RESET)).toBeGreaterThan(line.indexOf('hello')); + }); + + it('dims only the parens span in normal mode', () => { + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'ctx (42%)', + dim: 'parens' + } + ]; + + const line = renderLine(widgets); + expect(line).toContain(`${DIM}(42%)${INTENSITY_RESET}`); + expect(line.indexOf('ctx')).toBeLessThan(line.indexOf(DIM)); + }); + + it('keeps surrounding bold across a parens span', () => { + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'ctx (42%)', + bold: true, + dim: 'parens' + } + ]; + + const line = renderLine(widgets); + expect(line).toContain(`${DIM}(42%)${INTENSITY_RESET_BOLD}`); + }); + + it('does not change the visible text or width', () => { + const plain: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'ctx (42%)' + } + ]; + const dimmed: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'ctx (42%)', + bold: true, + dim: 'parens' + } + ]; + + const plainLine = renderLine(plain); + const dimmedLine = renderLine(dimmed); + expect(getVisibleText(dimmedLine)).toBe(getVisibleText(plainLine)); + expect(getVisibleWidth(dimmedLine)).toBe(getVisibleWidth(plainLine)); + }); + + it('applies dim in powerline mode and resets before the separator', () => { + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'head', + color: 'white', + backgroundColor: 'bgBlue', + dim: true + }, + { + id: 'w2', + type: 'custom-text', + customText: 'tail', + color: 'white', + backgroundColor: 'bgGreen' + } + ]; + + const line = renderLine(widgets, { + settings: { + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + separators: ['\uE0B0'], + separatorInvertBackground: [false] + } + } + }); + + expect(line.indexOf(DIM)).toBeGreaterThanOrEqual(0); + expect(line.indexOf(DIM)).toBeLessThan(line.indexOf('head')); + expect(line.indexOf(INTENSITY_RESET)).toBeGreaterThan(line.indexOf('head')); + expect(line.indexOf(INTENSITY_RESET)).toBeLessThan(line.indexOf('\uE0B0')); + expect(line.indexOf(INTENSITY_RESET)).toBeLessThan(line.indexOf('tail')); + }); + + it('does not leak dim past a middle powerline widget with customized colors', () => { + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'head', + color: 'hex:ECEFF4', + backgroundColor: 'hex:5E81AC' + }, + { + id: 'w2', + type: 'custom-text', + customText: 'mid', + color: 'hex:ECEFF4', + backgroundColor: 'hex:A3BE8C', + dim: true + }, + { + id: 'w3', + type: 'custom-text', + customText: 'tail', + color: 'hex:ECEFF4', + backgroundColor: 'hex:BF616A' + } + ]; + + const line = renderLine(widgets, { + settings: { + colorLevel: 3, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: 'custom', + separators: ['\uE0B0'], + separatorInvertBackground: [false] + } + } + }); + const dimIndex = line.indexOf(DIM); + const midIndex = line.indexOf('mid'); + const separatorAfterMidIndex = line.indexOf('\uE0B0', midIndex); + const tailIndex = line.indexOf('tail'); + const resetAfterMidIndex = line.indexOf(INTENSITY_RESET, midIndex); + + expect(dimIndex).toBeGreaterThanOrEqual(0); + expect(dimIndex).toBeLessThan(midIndex); + expect(resetAfterMidIndex).toBeGreaterThan(midIndex); + expect(resetAfterMidIndex).toBeLessThan(separatorAfterMidIndex); + expect(resetAfterMidIndex).toBeLessThan(tailIndex); + }); + + it('dims parens spans in powerline mode', () => { + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'ctx (42%)', + color: 'white', + backgroundColor: 'bgBlue', + dim: 'parens' + } + ]; + + const line = renderLine(widgets, { + settings: { + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + separators: ['\uE0B0'], + separatorInvertBackground: [false] + } + } + }); + + expect(line).toContain(`${DIM}(42%)${INTENSITY_RESET}`); + }); + + it('dims parens spans in powerline mode with a global foreground gradient', () => { + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'ctx (42%)', + color: 'white', + backgroundColor: 'bgBlue', + dim: 'parens' + } + ]; + + const line = renderLine(widgets, { + settings: { + colorLevel: 3, + overrideForegroundColor: 'gradient:FF0000-0000FF', + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + separators: ['\uE0B0'], + separatorInvertBackground: [false] + } + } + }); + const dimIndex = line.indexOf(DIM); + const openParenIndex = line.indexOf('('); + const closeParenIndex = line.indexOf(')'); + const resetIndex = line.indexOf(INTENSITY_RESET, closeParenIndex); + + expect(getVisibleText(line)).toContain('ctx (42%)'); + expect(dimIndex).toBeGreaterThanOrEqual(0); + expect(dimIndex).toBeLessThan(openParenIndex); + expect(resetIndex).toBeGreaterThan(closeParenIndex); + expect(line.match(TRUECOLOR_CODE)?.length ?? 0).toBeGreaterThan(1); + }); +}); diff --git a/src/utils/colors.ts b/src/utils/colors.ts index daf5c704..7ab843e9 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -128,15 +128,25 @@ export function getChalkColor(colorName: string | undefined, colorLevel: 'ansi16 } } +// Dim each (...) span within the text. \x1b[22m clears bold along with dim, +// so bold is re-asserted after each span when the surrounding text is bold. +export function applyParensDim(text: string, bold?: boolean): string { + const intensityReset = bold ? '\x1b[22;1m' : '\x1b[22m'; + return text.replace(/\([^()]*\)/g, span => `\x1b[2m${span}${intensityReset}`); +} + export function applyColors( text: string, foregroundColor?: string, backgroundColor?: string, bold?: boolean, - colorLevel: 'ansi16' | 'ansi256' | 'truecolor' = 'ansi16' + colorLevel: 'ansi16' | 'ansi256' | 'truecolor' = 'ansi16', + dim?: boolean | 'parens' ): string { - if (!foregroundColor && !backgroundColor && !bold) { - return text; + const styledText = dim === 'parens' ? applyParensDim(text, bold) : text; + + if (!foregroundColor && !backgroundColor && !bold && dim !== true) { + return styledText; } // Use raw ANSI codes for precise reset sequencing. @@ -144,9 +154,15 @@ export function applyColors( let prefix = ''; let suffix = ''; - // Apply bold first so it can be reset independently before color resets. + // Apply bold/dim first so they can be reset independently before color + // resets. A single \x1b[22m clears both attributes. if (bold) { prefix += '\x1b[1m'; + } + if (dim === true) { + prefix += '\x1b[2m'; + } + if (bold || dim === true) { suffix = '\x1b[22m' + suffix; } @@ -168,7 +184,7 @@ export function applyColors( // as the prefix guard. const gradientStops = parseGradientSpec(foregroundColor); if (gradientStops && colorLevel !== 'ansi16') { - return prefix + applyGradientToText(text, gradientStops, colorLevel) + '\x1b[39m' + suffix; + return prefix + applyGradientToText(styledText, gradientStops, colorLevel) + '\x1b[39m' + suffix; } const fgCode = getColorAnsiCode(foregroundColor, colorLevel, false); @@ -178,7 +194,7 @@ export function applyColors( } } - return prefix + text + suffix; + return prefix + styledText + suffix; } // Get raw ANSI codes for a color without the reset codes diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 63a933e4..fc18c39a 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -17,6 +17,7 @@ import { } from './ansi'; import { applyColors, + applyParensDim, bgToFg, getColorAnsiCode, getPowerlineTheme @@ -324,9 +325,10 @@ function renderPowerlineStatusLine( // Apply colors to widget content using raw ANSI codes for powerline mode // This avoids reset codes that interfere with separator rendering const shouldBold = (settings.globalBold) || widget.widget.bold; + const shouldDim = widget.widget.dim === true; // Check if we need a separator after this widget - const needsSeparator = i < widgetElements.length - 1 && separators.length > 0 && nextWidget && !widget.widget.merge; + const needsSeparator = i < widgetElements.length - 1 && separators.length > 0 && nextWidget !== undefined && !widget.widget.merge; let widgetContent = ''; @@ -336,9 +338,15 @@ function renderPowerlineStatusLine( if (shouldBold && !isPreserveColors) { widgetContent += '\x1b[1m'; } + if (shouldDim && !isPreserveColors) { + widgetContent += '\x1b[2m'; + } const textGradientStops = !isPreserveColors && powerlineGradientWidth > 1 ? overrideForegroundGradientStops : null; + const styledContent = widget.widget.dim === 'parens' && !isPreserveColors + ? applyParensDim(widget.content, shouldBold) + : widget.content; if (widget.fgColor && !isPreserveColors && !textGradientStops) { widgetContent += getColorAnsiCode(widget.fgColor, colorLevel, false); @@ -349,7 +357,7 @@ function renderPowerlineStatusLine( } if (textGradientStops) { const gradientResult = applyLineGradientSegment( - widget.content, + styledContent, textGradientStops, colorLevel, powerlineGradientColumn, @@ -358,7 +366,7 @@ function renderPowerlineStatusLine( widgetContent += gradientResult.text; powerlineGradientColumn = gradientResult.nextColumn; } else { - widgetContent += widget.content; + widgetContent += styledContent; } // Reset colors after content // For custom commands with preserveColors, also reset text attributes like dim @@ -367,10 +375,14 @@ function renderPowerlineStatusLine( widgetContent += '\x1b[0m'; } else { widgetContent += '\x1b[49m\x1b[39m'; - // Only reset bold if there's no separator following AND no end cap + // Dim should be scoped to the widget text only. Reset before + // separators/end caps so faint intensity cannot leak forward. const isLastWidget = i === widgetElements.length - 1; const hasEndCap = endCaps.length > 0 && endCaps[capLineIndex % endCaps.length]; - if (shouldBold && !needsSeparator && !(isLastWidget && hasEndCap)) { + const shouldRestoreBoldForBoundary = shouldDim && shouldBold && (needsSeparator ? true : isLastWidget && hasEndCap); + if (shouldRestoreBoldForBoundary) { + widgetContent += '\x1b[22;1m'; + } else if (shouldDim || (shouldBold && !needsSeparator && !(isLastWidget && hasEndCap))) { widgetContent += '\x1b[22m'; } } @@ -461,8 +473,8 @@ function renderPowerlineStatusLine( result += separatorOutput; - // Reset bold after separator if it was set - if (shouldBold) { + // Reset bold/dim after separator if either was set + if (shouldBold || shouldDim) { result += '\x1b[22m'; } } @@ -471,6 +483,8 @@ function renderPowerlineStatusLine( // Add end cap if specified if (endCap && widgetElements.length > 0) { const lastWidget = widgetElements[widgetElements.length - 1]; + const lastWidgetBold = (settings.globalBold) || lastWidget?.widget.bold; + const lastWidgetDim = lastWidget?.widget.dim === true; if (lastWidget?.bgColor) { // End cap uses last widget's background as foreground (converted) @@ -481,9 +495,8 @@ function renderPowerlineStatusLine( result += endCap; } - // Reset bold after end cap if needed - const lastWidgetBold = (settings.globalBold) || lastWidget?.widget.bold; - if (lastWidgetBold) { + // Reset bold/dim after end cap if needed + if (lastWidgetBold || lastWidgetDim) { result += '\x1b[22m'; } } @@ -675,8 +688,8 @@ export function renderStatusLine( preCalculatedMaxWidths ); - // Helper to apply colors with optional background and bold override - const applyColorsWithOverride = (text: string, foregroundColor?: string, backgroundColor?: string, bold?: boolean): string => { + // Helper to apply colors with optional background, bold, and dim + const applyColorsWithOverride = (text: string, foregroundColor?: string, backgroundColor?: string, bold?: boolean, dim?: boolean | 'parens'): string => { // Override foreground color takes precedence over EVERYTHING, including passed foreground // color — except a gradient: spec, which is not a solid color. The gradient is applied as a // whole-line pass after assembly, so when it will render (color levels above ansi16) we emit @@ -699,7 +712,7 @@ export function renderStatusLine( } const shouldBold = (settings.globalBold) || bold; - return applyColors(text, fgColor, bgColor, shouldBold, colorLevel); + return applyColors(text, fgColor, bgColor, shouldBold, colorLevel, dim); }; const detectedWidth = context.terminalWidth ?? getTerminalWidth(); @@ -744,6 +757,7 @@ export function renderStatusLine( let separatorColor = widget.color ?? 'gray'; let separatorBg = widget.backgroundColor; let separatorBold = widget.bold; + let separatorDim = widget.dim; if (settings.inheritSeparatorColors && i > 0 && !widget.color && !widget.backgroundColor) { // Only inherit if the separator doesn't have explicit colors set @@ -758,10 +772,11 @@ export function renderStatusLine( separatorColor = widgetColor; separatorBg = prevWidget.backgroundColor; separatorBold = prevWidget.bold; + separatorDim = prevWidget.dim; } } - elements.push({ content: applyColorsWithOverride(formattedSep, separatorColor, separatorBg, separatorBold), type: 'separator', widget }); + elements.push({ content: applyColorsWithOverride(formattedSep, separatorColor, separatorBg, separatorBold, separatorDim), type: 'separator', widget }); continue; } @@ -803,7 +818,7 @@ export function renderStatusLine( } else { // Normal widget rendering with colors elements.push({ - content: applyColorsWithOverride(widgetText, widget.color ?? defaultColor, widget.backgroundColor, widget.bold), + content: applyColorsWithOverride(widgetText, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, widget.dim), type: widget.type, widget }); @@ -848,7 +863,7 @@ export function renderStatusLine( const widgetImpl = getWidget(prevElem.widget.type); widgetColor = widgetImpl ? widgetImpl.getDefaultColor() : 'white'; } - const coloredSep = applyColorsWithOverride(defaultSep, widgetColor, prevElem.widget.backgroundColor, prevElem.widget.bold); + const coloredSep = applyColorsWithOverride(defaultSep, widgetColor, prevElem.widget.backgroundColor, prevElem.widget.bold, prevElem.widget.dim); finalElements.push(coloredSep); } else { finalElements.push(defaultSep);