From b90840f68c37e888d9f591d9241e1045e2c1f1de Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Tue, 3 Mar 2026 20:14:30 -0500 Subject: [PATCH] refactor: implement phase 4 tui component simplification with tests --- src/tui/components/ColorMenu.tsx | 113 +---- src/tui/components/ItemsEditor.tsx | 362 ++------------ .../color-menu/__tests__/mutations.test.ts | 136 ++++++ src/tui/components/color-menu/mutations.ts | 148 ++++++ .../__tests__/input-handlers.test.ts | 228 +++++++++ .../components/items-editor/input-handlers.ts | 458 ++++++++++++++++++ 6 files changed, 1035 insertions(+), 410 deletions(-) create mode 100644 src/tui/components/color-menu/__tests__/mutations.test.ts create mode 100644 src/tui/components/color-menu/mutations.ts create mode 100644 src/tui/components/items-editor/__tests__/input-handlers.test.ts create mode 100644 src/tui/components/items-editor/input-handlers.ts diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index 06b1080..9217857 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -19,6 +19,13 @@ import { shouldInsertInput } from '../../utils/input-guards'; import { getWidget } from '../../utils/widgets'; import { ConfirmDialog } from './ConfirmDialog'; +import { + clearAllWidgetStyling, + cycleWidgetColor, + resetWidgetStyling, + setWidgetColor, + toggleWidgetBold +} from './color-menu/mutations'; export interface ColorMenuProps { widgets: WidgetItem[]; @@ -80,17 +87,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const hexColor = `hex:${hexInput}`; const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { - // IMPORTANT: Update ALL items (not just colorableWidgets) to maintain proper indexing - const newItems = widgets.map((widget) => { - if (widget.id === highlightedItemId) { - if (editingBackground) { - return { ...widget, backgroundColor: hexColor }; - } else { - return { ...widget, color: hexColor }; - } - } - return widget; - }); + const newItems = setWidgetColor(widgets, selectedWidget.id, hexColor, editingBackground); onUpdate(newItems); } setHexInputMode(false); @@ -126,17 +123,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { - // IMPORTANT: Update ALL items (not just colorableWidgets) to maintain proper indexing - const newItems = widgets.map((widget) => { - if (widget.id === highlightedItemId) { - if (editingBackground) { - return { ...widget, backgroundColor: ansiColor }; - } else { - return { ...widget, color: ansiColor }; - } - } - return widget; - }); + const newItems = setWidgetColor(widgets, selectedWidget.id, ansiColor, editingBackground); onUpdate(newItems); setAnsi256InputMode(false); @@ -199,12 +186,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin // Toggle bold for the highlighted item const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { - const newItems = widgets.map((widget) => { - if (widget.id === selectedWidget.id) { - return { ...widget, bold: !widget.bold }; - } - return widget; - }); + const newItems = toggleWidgetBold(widgets, selectedWidget.id); onUpdate(newItems); } } @@ -213,17 +195,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin // Reset all styling (color, background, and bold) for the highlighted item const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { - const newItems = widgets.map((widget) => { - if (widget.id === selectedWidget.id) { - // Remove color, backgroundColor, and bold properties - const { color, backgroundColor, bold, ...restWidget } = widget; - void color; // Intentionally unused - void backgroundColor; // Intentionally unused - void bold; // Intentionally unused - return restWidget; - } - return widget; - }); + const newItems = resetWidgetStyling(widgets, selectedWidget.id); onUpdate(newItems); } } @@ -235,52 +207,13 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin if (highlightedItemId && highlightedItemId !== 'back') { const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { - const newItems = widgets.map((widget) => { - if (widget.id === selectedWidget.id) { - if (editingBackground) { - const currentBgColor = widget.backgroundColor ?? ''; // Empty string for 'none' - let currentBgColorIndex = bgColors.indexOf(currentBgColor); - // If color not found, start from beginning - if (currentBgColorIndex === -1) - currentBgColorIndex = 0; - - let nextBgColorIndex; - if (key.rightArrow) { - nextBgColorIndex = (currentBgColorIndex + 1) % bgColors.length; - } else { - nextBgColorIndex = currentBgColorIndex === 0 ? bgColors.length - 1 : currentBgColorIndex - 1; - } - const nextBgColor = bgColors[nextBgColorIndex]; - return { ...widget, backgroundColor: nextBgColor === '' ? undefined : nextBgColor }; - } else { - let defaultColor = 'white'; - if (widget.type !== 'separator' && widget.type !== 'flex-separator') { - const widgetImpl = getWidget(widget.type); - if (widgetImpl) { - defaultColor = widgetImpl.getDefaultColor(); - } - } - let currentColor = widget.color ?? defaultColor; - // If color is 'dim', treat as if no color was set - if (currentColor === 'dim') { - currentColor = defaultColor; - } - let currentColorIndex = colors.indexOf(currentColor); - // If color not found, start from beginning - if (currentColorIndex === -1) - currentColorIndex = 0; - - let nextColorIndex; - if (key.rightArrow) { - nextColorIndex = (currentColorIndex + 1) % colors.length; - } else { - nextColorIndex = currentColorIndex === 0 ? colors.length - 1 : currentColorIndex - 1; - } - const nextColor = colors[nextColorIndex]; - return { ...widget, color: nextColor }; - } - } - return widget; + const newItems = cycleWidgetColor({ + widgets, + widgetId: selectedWidget.id, + direction: key.rightArrow ? 'right' : 'left', + editingBackground, + colors, + backgroundColors: bgColors }); onUpdate(newItems); } @@ -430,15 +363,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin { - // Clear all colors from all widgets - const newItems = widgets.map((widget) => { - // Remove color, backgroundColor, and bold properties - const { color, backgroundColor, bold, ...restWidget } = widget; - void color; // Intentionally unused - void backgroundColor; // Intentionally unused - void bold; // Intentionally unused - return restWidget; - }); + const newItems = clearAllWidgetStyling(widgets); onUpdate(newItems); setShowClearConfirm(false); }} diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 2b8af64..997b92b 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -23,6 +23,15 @@ import { } from '../../utils/widgets'; import { ConfirmDialog } from './ConfirmDialog'; +import { + handleMoveInputMode, + handleNormalInputMode, + handlePickerInputMode, + normalizePickerState, + type CustomEditorWidgetState, + type WidgetPickerAction, + type WidgetPickerState +} from './items-editor/input-handlers'; export interface ItemsEditorProps { widgets: WidgetItem[]; @@ -32,22 +41,10 @@ export interface ItemsEditorProps { settings: Settings; } -type WidgetPickerAction = 'change' | 'add' | 'insert'; -type WidgetPickerLevel = 'category' | 'widget'; - -interface WidgetPickerState { - action: WidgetPickerAction; - level: WidgetPickerLevel; - selectedCategory: string | null; - categoryQuery: string; - widgetQuery: string; - selectedType: WidgetItemType | null; -} - export const ItemsEditor: React.FC = ({ widgets, onUpdate, onBack, lineNumber, settings }) => { const [selectedIndex, setSelectedIndex] = useState(0); const [moveMode, setMoveMode] = useState(false); - const [customEditorWidget, setCustomEditorWidget] = useState<{ widget: WidgetItem; impl: Widget; action?: string } | null>(null); + const [customEditorWidget, setCustomEditorWidget] = useState(null); const [widgetPicker, setWidgetPicker] = useState(null); const [showClearConfirm, setShowClearConfirm] = useState(false); const separatorChars = ['|', '-', ',', ' ']; @@ -97,32 +94,6 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB setCustomEditorWidget(null); }; - const getFilteredCategories = (query: string): string[] => { - void query; - return [...widgetCategories]; - }; - - const normalizePickerState = (state: WidgetPickerState): WidgetPickerState => { - const filteredCategories = getFilteredCategories(state.categoryQuery); - const selectedCategory = state.selectedCategory && filteredCategories.includes(state.selectedCategory) - ? state.selectedCategory - : (filteredCategories[0] ?? null); - - const hasTopLevelSearch = state.level === 'category' && state.categoryQuery.trim().length > 0; - const effectiveCategory = hasTopLevelSearch ? 'All' : (selectedCategory ?? 'All'); - const effectiveQuery = hasTopLevelSearch ? state.categoryQuery : state.widgetQuery; - const filteredWidgets = filterWidgetCatalog(widgetCatalog, effectiveCategory, effectiveQuery); - const hasSelectedType = state.selectedType - ? filteredWidgets.some(entry => entry.type === state.selectedType) - : false; - - return { - ...state, - selectedCategory, - selectedType: hasSelectedType ? state.selectedType : (filteredWidgets[0]?.type ?? null) - }; - }; - const shouldShowCustomKeybind = (widget: WidgetItem, keybind: CustomKeybind): boolean => { if (keybind.action !== 'toggle-invert') { return true; @@ -155,7 +126,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB categoryQuery: '', widgetQuery: '', selectedType - })); + }, widgetCatalog, widgetCategories)); }; const applyWidgetPickerSelection = (selectedType: WidgetItemType) => { @@ -201,286 +172,45 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB } if (widgetPicker) { - const filteredCategories = getFilteredCategories(widgetPicker.categoryQuery); - const selectedCategory = widgetPicker.selectedCategory && filteredCategories.includes(widgetPicker.selectedCategory) - ? widgetPicker.selectedCategory - : (filteredCategories[0] ?? null); - const hasTopLevelSearch = widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0; - const topLevelSearchEntries = hasTopLevelSearch - ? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery) - : []; - const topLevelSelectedEntry = topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? topLevelSearchEntries[0]; - const filteredWidgets = filterWidgetCatalog(widgetCatalog, selectedCategory ?? 'All', widgetPicker.widgetQuery); - const selectedEntry = filteredWidgets.find(entry => entry.type === widgetPicker.selectedType) ?? filteredWidgets[0]; - - if (widgetPicker.level === 'category') { - if (key.escape) { - if (widgetPicker.categoryQuery.length > 0) { - setWidgetPicker(prev => prev ? normalizePickerState({ - ...prev, - categoryQuery: '' - }) : prev); - } else { - setWidgetPicker(null); - } - } else if (key.return) { - if (hasTopLevelSearch) { - if (topLevelSelectedEntry) { - applyWidgetPickerSelection(topLevelSelectedEntry.type); - } - } else if (selectedCategory) { - setWidgetPicker(prev => prev ? normalizePickerState({ - ...prev, - level: 'widget', - selectedCategory - }) : prev); - } - } else if (key.upArrow || key.downArrow) { - if (hasTopLevelSearch) { - if (topLevelSearchEntries.length === 0) { - return; - } - - let currentIndex = topLevelSearchEntries.findIndex(entry => entry.type === widgetPicker.selectedType); - if (currentIndex === -1) { - currentIndex = 0; - } - - const nextIndex = key.downArrow - ? Math.min(topLevelSearchEntries.length - 1, currentIndex + 1) - : Math.max(0, currentIndex - 1); - const nextType = topLevelSearchEntries[nextIndex]?.type ?? null; - setWidgetPicker(prev => prev ? normalizePickerState({ - ...prev, - selectedType: nextType - }) : prev); - } else { - if (filteredCategories.length === 0) { - return; - } - - let currentIndex = filteredCategories.findIndex(category => category === selectedCategory); - if (currentIndex === -1) { - currentIndex = 0; - } - - const nextIndex = key.downArrow - ? Math.min(filteredCategories.length - 1, currentIndex + 1) - : Math.max(0, currentIndex - 1); - const nextCategory = filteredCategories[nextIndex] ?? null; - setWidgetPicker(prev => prev ? normalizePickerState({ - ...prev, - selectedCategory: nextCategory - }) : prev); - } - } else if (key.backspace || key.delete) { - setWidgetPicker(prev => prev ? normalizePickerState({ - ...prev, - categoryQuery: prev.categoryQuery.slice(0, -1) - }) : prev); - } else if ( - input - && !key.ctrl - && !key.meta - && !key.tab - ) { - setWidgetPicker(prev => prev ? normalizePickerState({ - ...prev, - categoryQuery: prev.categoryQuery + input - }) : prev); - } - } else { - if (key.escape) { - if (widgetPicker.widgetQuery.length > 0) { - setWidgetPicker(prev => prev ? normalizePickerState({ - ...prev, - widgetQuery: '' - }) : prev); - } else { - setWidgetPicker(prev => prev ? normalizePickerState({ - ...prev, - level: 'category' - }) : prev); - } - } else if (key.return) { - if (selectedEntry) { - applyWidgetPickerSelection(selectedEntry.type); - } - } else if (key.upArrow || key.downArrow) { - if (filteredWidgets.length === 0) { - return; - } - - let currentIndex = filteredWidgets.findIndex(entry => entry.type === widgetPicker.selectedType); - if (currentIndex === -1) { - currentIndex = 0; - } - - const nextIndex = key.downArrow - ? Math.min(filteredWidgets.length - 1, currentIndex + 1) - : Math.max(0, currentIndex - 1); - const nextType = filteredWidgets[nextIndex]?.type ?? null; - setWidgetPicker(prev => prev ? normalizePickerState({ - ...prev, - selectedType: nextType - }) : prev); - } else if (key.backspace || key.delete) { - setWidgetPicker(prev => prev ? normalizePickerState({ - ...prev, - widgetQuery: prev.widgetQuery.slice(0, -1) - }) : prev); - } else if ( - input - && !key.ctrl - && !key.meta - && !key.tab - ) { - setWidgetPicker(prev => prev ? normalizePickerState({ - ...prev, - widgetQuery: prev.widgetQuery + input - }) : prev); - } - } - + handlePickerInputMode({ + input, + key, + widgetPicker, + widgetCatalog, + widgetCategories, + setWidgetPicker, + applyWidgetPickerSelection + }); return; } if (moveMode) { - // In move mode, use up/down to move the selected item - if (key.upArrow && selectedIndex > 0) { - const newWidgets = [...widgets]; - const temp = newWidgets[selectedIndex]; - const prev = newWidgets[selectedIndex - 1]; - if (temp && prev) { - [newWidgets[selectedIndex], newWidgets[selectedIndex - 1]] = [prev, temp]; - } - onUpdate(newWidgets); - setSelectedIndex(selectedIndex - 1); - } else if (key.downArrow && selectedIndex < widgets.length - 1) { - const newWidgets = [...widgets]; - const temp = newWidgets[selectedIndex]; - const next = newWidgets[selectedIndex + 1]; - if (temp && next) { - [newWidgets[selectedIndex], newWidgets[selectedIndex + 1]] = [next, temp]; - } - onUpdate(newWidgets); - setSelectedIndex(selectedIndex + 1); - } else if (key.escape || key.return) { - // Exit move mode - setMoveMode(false); - } - } else { - // Normal mode - if (key.upArrow && widgets.length > 0) { - setSelectedIndex(Math.max(0, selectedIndex - 1)); - } else if (key.downArrow && widgets.length > 0) { - setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1)); - } else if (key.leftArrow && widgets.length > 0) { - openWidgetPicker('change'); - } else if (key.rightArrow && widgets.length > 0) { - openWidgetPicker('change'); - } else if (key.return && widgets.length > 0) { - // Enter move mode - setMoveMode(true); - } else if (input === 'a') { - openWidgetPicker('add'); - } else if (input === 'i') { - openWidgetPicker('insert'); - } else if (input === 'd' && widgets.length > 0) { - // Delete selected item - const newWidgets = widgets.filter((_, i) => i !== selectedIndex); - onUpdate(newWidgets); - if (selectedIndex >= newWidgets.length && selectedIndex > 0) { - setSelectedIndex(selectedIndex - 1); - } - } else if (input === 'c') { - if (widgets.length > 0) { - setShowClearConfirm(true); - } - } else if (input === ' ' && widgets.length > 0) { - // Space key - cycle separator character for separator types only (not flex) - const currentWidget = widgets[selectedIndex]; - if (currentWidget && currentWidget.type === 'separator') { - const currentChar = currentWidget.character ?? '|'; - const currentCharIndex = separatorChars.indexOf(currentChar); - const nextChar = separatorChars[(currentCharIndex + 1) % separatorChars.length]; - const newWidgets = [...widgets]; - newWidgets[selectedIndex] = { ...currentWidget, character: nextChar }; - onUpdate(newWidgets); - } - } else if (input === 'r' && widgets.length > 0) { - // Toggle raw value for widgets that support it - const currentWidget = widgets[selectedIndex]; - if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { - const widgetImpl = getWidget(currentWidget.type); - if (!widgetImpl?.supportsRawValue()) { - return; - } - const newWidgets = [...widgets]; - newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue }; - onUpdate(newWidgets); - } - } else if (input === 'm' && widgets.length > 0) { - // Cycle through merge states: undefined -> true -> 'no-padding' -> undefined - const currentWidget = widgets[selectedIndex]; - // Don't allow merge on the last item or on separators - if (currentWidget && selectedIndex < widgets.length - 1 - && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { - const newWidgets = [...widgets]; - let nextMergeState: boolean | 'no-padding' | undefined; - - if (currentWidget.merge === undefined) { - nextMergeState = true; - } else if (currentWidget.merge === true) { - nextMergeState = 'no-padding'; - } else { - nextMergeState = undefined; - } - - if (nextMergeState === undefined) { - const { merge, ...rest } = currentWidget; - void merge; // Intentionally unused - newWidgets[selectedIndex] = rest; - } else { - newWidgets[selectedIndex] = { ...currentWidget, merge: nextMergeState }; - } - onUpdate(newWidgets); - } - } else if (key.escape) { - onBack(); - } else if (widgets.length > 0) { - // Check for custom widget keybinds - const currentWidget = widgets[selectedIndex]; - if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { - const widgetImpl = getWidget(currentWidget.type); - if (widgetImpl) { - if (widgetImpl.getCustomKeybinds) { - const customKeybinds = getVisibleCustomKeybinds(widgetImpl, currentWidget); - const matchedKeybind = customKeybinds.find(kb => kb.key === input); - - if (matchedKeybind && !key.ctrl) { - // Check if widget handles the action directly - if (widgetImpl.handleEditorAction) { - // Let the widget handle the action directly - const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget); - if (updatedWidget) { - const newWidgets = [...widgets]; - newWidgets[selectedIndex] = updatedWidget; - onUpdate(newWidgets); - } else if (widgetImpl.renderEditor) { - // If handleEditorAction returned null, open the editor - setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action }); - } - } else if (widgetImpl.renderEditor) { - // Open the widget's custom editor with the action - setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action }); - } - } - } - } - } - } + handleMoveInputMode({ + key, + widgets, + selectedIndex, + onUpdate, + setSelectedIndex, + setMoveMode + }); + return; } + + handleNormalInputMode({ + input, + key, + widgets, + selectedIndex, + separatorChars, + onBack, + onUpdate, + setSelectedIndex, + setMoveMode, + setShowClearConfirm, + openWidgetPicker, + getVisibleCustomKeybinds, + setCustomEditorWidget + }); }); const getWidgetDisplay = (widget: WidgetItem) => { @@ -508,7 +238,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const hasFlexSeparator = widgets.some(widget => widget.type === 'flex-separator'); const widthDetectionAvailable = canDetectTerminalWidth(); const pickerCategories = widgetPicker - ? getFilteredCategories(widgetPicker.categoryQuery) + ? [...widgetCategories] : []; const selectedPickerCategory = widgetPicker ? (widgetPicker.selectedCategory && pickerCategories.includes(widgetPicker.selectedCategory) diff --git a/src/tui/components/color-menu/__tests__/mutations.test.ts b/src/tui/components/color-menu/__tests__/mutations.test.ts new file mode 100644 index 0000000..bbd68a1 --- /dev/null +++ b/src/tui/components/color-menu/__tests__/mutations.test.ts @@ -0,0 +1,136 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { WidgetItem } from '../../../../types/Widget'; +import { + clearAllWidgetStyling, + cycleWidgetColor, + resetWidgetStyling, + toggleWidgetBold, + updateWidgetById +} from '../mutations'; + +describe('color-menu mutations', () => { + it('updateWidgetById only updates the matching widget', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'tokens-input', color: 'blue' }, + { id: '2', type: 'tokens-output', color: 'white' } + ]; + + const updated = updateWidgetById(widgets, '1', widget => ({ + ...widget, + color: 'red' + })); + + expect(updated[0]?.color).toBe('red'); + expect(updated[1]?.color).toBe('white'); + }); + + it('toggleWidgetBold flips bold state for the selected widget only', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'tokens-input', bold: true }, + { id: '2', type: 'tokens-output', bold: false } + ]; + + const updated = toggleWidgetBold(widgets, '1'); + + expect(updated[0]?.bold).toBe(false); + expect(updated[1]?.bold).toBe(false); + }); + + it('resetWidgetStyling removes color, backgroundColor, and bold from one widget', () => { + const widgets: WidgetItem[] = [ + { + id: '1', + type: 'tokens-input', + color: 'red', + backgroundColor: 'blue', + bold: true + }, + { id: '2', type: 'tokens-output', color: 'white', bold: true } + ]; + + const updated = resetWidgetStyling(widgets, '1'); + + expect(updated[0]).toEqual({ id: '1', type: 'tokens-input' }); + expect(updated[1]).toEqual({ id: '2', type: 'tokens-output', color: 'white', bold: true }); + }); + + it('clearAllWidgetStyling strips styling fields from every widget', () => { + const widgets: WidgetItem[] = [ + { + id: '1', + type: 'tokens-input', + color: 'red', + backgroundColor: 'blue', + bold: true + }, + { id: '2', type: 'tokens-output', color: 'white', bold: true } + ]; + + const updated = clearAllWidgetStyling(widgets); + + expect(updated).toEqual([ + { id: '1', type: 'tokens-input' }, + { id: '2', type: 'tokens-output' } + ]); + }); + + it('cycles background colors and maps empty background to undefined', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'tokens-input', backgroundColor: 'bg:red' } + ]; + + const right = cycleWidgetColor({ + widgets, + widgetId: '1', + direction: 'right', + editingBackground: true, + colors: ['blue', 'red'], + backgroundColors: ['bg:red', ''] + }); + const left = cycleWidgetColor({ + widgets: right, + widgetId: '1', + direction: 'left', + editingBackground: true, + colors: ['blue', 'red'], + backgroundColors: ['bg:red', ''] + }); + + expect(right[0]?.backgroundColor).toBeUndefined(); + expect(left[0]?.backgroundColor).toBe('bg:red'); + }); + + it('cycles foreground colors from widget default and treats dim as default', () => { + const fromDefault: WidgetItem[] = [ + { id: '1', type: 'tokens-input' } + ]; + const fromDim: WidgetItem[] = [ + { id: '1', type: 'tokens-input', color: 'dim' } + ]; + + const defaultCycle = cycleWidgetColor({ + widgets: fromDefault, + widgetId: '1', + direction: 'right', + editingBackground: false, + colors: ['blue', 'red'], + backgroundColors: ['bg:red', ''] + }); + const dimCycle = cycleWidgetColor({ + widgets: fromDim, + widgetId: '1', + direction: 'right', + editingBackground: false, + colors: ['blue', 'red'], + backgroundColors: ['bg:red', ''] + }); + + expect(defaultCycle[0]?.color).toBe('red'); + expect(dimCycle[0]?.color).toBe('red'); + }); +}); \ No newline at end of file diff --git a/src/tui/components/color-menu/mutations.ts b/src/tui/components/color-menu/mutations.ts new file mode 100644 index 0000000..1556d4b --- /dev/null +++ b/src/tui/components/color-menu/mutations.ts @@ -0,0 +1,148 @@ +import type { WidgetItem } from '../../../types/Widget'; +import { getWidget } from '../../../utils/widgets'; + +export function updateWidgetById( + widgets: WidgetItem[], + widgetId: string, + updater: (widget: WidgetItem) => WidgetItem +): WidgetItem[] { + return widgets.map(widget => widget.id === widgetId ? updater(widget) : widget); +} + +export function setWidgetColor( + widgets: WidgetItem[], + widgetId: string, + color: string, + editingBackground: boolean +): WidgetItem[] { + return updateWidgetById(widgets, widgetId, (widget) => { + if (editingBackground) { + return { + ...widget, + backgroundColor: color + }; + } + + return { + ...widget, + color + }; + }); +} + +export function toggleWidgetBold(widgets: WidgetItem[], widgetId: string): WidgetItem[] { + return updateWidgetById(widgets, widgetId, widget => ({ + ...widget, + bold: !widget.bold + })); +} + +export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): WidgetItem[] { + return updateWidgetById(widgets, widgetId, (widget) => { + const { + color, + backgroundColor, + bold, + ...restWidget + } = widget; + void color; // Intentionally unused + void backgroundColor; // Intentionally unused + void bold; // Intentionally unused + return restWidget; + }); +} + +export function clearAllWidgetStyling(widgets: WidgetItem[]): WidgetItem[] { + return widgets.map((widget) => { + const { + color, + backgroundColor, + bold, + ...restWidget + } = widget; + void color; // Intentionally unused + void backgroundColor; // Intentionally unused + void bold; // Intentionally unused + return restWidget; + }); +} + +function getDefaultForegroundColor(widget: WidgetItem): string { + if (widget.type === 'separator' || widget.type === 'flex-separator') { + return 'white'; + } + + const widgetImpl = getWidget(widget.type); + return widgetImpl ? widgetImpl.getDefaultColor() : 'white'; +} + +function getNextIndex(currentIndex: number, length: number, direction: 'left' | 'right'): number { + if (direction === 'right') { + return (currentIndex + 1) % length; + } + + return currentIndex === 0 ? length - 1 : currentIndex - 1; +} + +export interface CycleWidgetColorOptions { + widgets: WidgetItem[]; + widgetId: string; + direction: 'left' | 'right'; + editingBackground: boolean; + colors: string[]; + backgroundColors: string[]; +} + +export function cycleWidgetColor({ + widgets, + widgetId, + direction, + editingBackground, + colors, + backgroundColors +}: CycleWidgetColorOptions): WidgetItem[] { + return updateWidgetById(widgets, widgetId, (widget) => { + if (editingBackground) { + if (backgroundColors.length === 0) { + return widget; + } + + const currentBgColor = widget.backgroundColor ?? ''; + let currentBgColorIndex = backgroundColors.indexOf(currentBgColor); + if (currentBgColorIndex === -1) { + currentBgColorIndex = 0; + } + + const nextBgColorIndex = getNextIndex(currentBgColorIndex, backgroundColors.length, direction); + const nextBgColor = backgroundColors[nextBgColorIndex]; + + return { + ...widget, + backgroundColor: nextBgColor === '' ? undefined : nextBgColor + }; + } + + if (colors.length === 0) { + return widget; + } + + const defaultColor = getDefaultForegroundColor(widget); + let currentColor = widget.color ?? defaultColor; + if (currentColor === 'dim') { + currentColor = defaultColor; + } + + let currentColorIndex = colors.indexOf(currentColor); + if (currentColorIndex === -1) { + currentColorIndex = 0; + } + + const nextColorIndex = getNextIndex(currentColorIndex, colors.length, direction); + const nextColor = colors[nextColorIndex]; + + return { + ...widget, + color: nextColor + }; + }); +} \ No newline at end of file diff --git a/src/tui/components/items-editor/__tests__/input-handlers.test.ts b/src/tui/components/items-editor/__tests__/input-handlers.test.ts new file mode 100644 index 0000000..e87734d --- /dev/null +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -0,0 +1,228 @@ +import { + describe, + expect, + it, + vi +} from 'vitest'; + +import type { WidgetItem } from '../../../../types/Widget'; +import type { WidgetCatalogEntry } from '../../../../utils/widgets'; +import { + handleMoveInputMode, + handleNormalInputMode, + handlePickerInputMode, + normalizePickerState, + type WidgetPickerState +} from '../input-handlers'; + +function createStateSetter(initial: T) { + let state = initial; + + return { + get: () => state, + set: (value: T | ((prev: T) => T)) => { + state = typeof value === 'function' + ? (value as (prev: T) => T)(state) + : value; + } + }; +} + +function requireState(value: T | null): T { + if (!value) { + throw new Error('Expected state value'); + } + + return value; +} + +function createCatalog(entries: (Partial & Pick)[]): WidgetCatalogEntry[] { + return entries.map(entry => ({ + type: entry.type, + displayName: entry.displayName ?? entry.type, + description: entry.description ?? entry.type, + category: entry.category ?? 'Other', + searchText: `${entry.displayName ?? entry.type} ${entry.description ?? entry.type} ${entry.type}`.toLowerCase() + })); +} + +describe('items-editor input handlers', () => { + it('normalizes picker state with valid fallback category and selected type', () => { + const widgetCatalog = createCatalog([ + { type: 'git-branch', displayName: 'Git Branch', category: 'Git' } + ]); + const widgetCategories = ['All', 'Git']; + const state: WidgetPickerState = { + action: 'change', + level: 'category', + selectedCategory: 'Missing', + categoryQuery: '', + widgetQuery: '', + selectedType: null + }; + + const normalized = normalizePickerState(state, widgetCatalog, widgetCategories); + + expect(normalized.selectedCategory).toBe('All'); + expect(normalized.selectedType).toBe('git-branch'); + }); + + it('applies top-level category search selection on Enter', () => { + const widgetCatalog = createCatalog([ + { type: 'git-branch', displayName: 'Git Branch', category: 'Git' } + ]); + const widgetCategories = ['All', 'Git']; + const pickerState = createStateSetter({ + action: 'change', + level: 'category', + selectedCategory: 'All', + categoryQuery: 'git', + widgetQuery: '', + selectedType: 'git-branch' + }); + const applySelection = vi.fn(); + + handlePickerInputMode({ + input: '', + key: { return: true }, + widgetPicker: requireState(pickerState.get()), + widgetCatalog, + widgetCategories, + setWidgetPicker: pickerState.set, + applyWidgetPickerSelection: applySelection + }); + + expect(applySelection).toHaveBeenCalledWith('git-branch'); + }); + + it('returns to category level from widget picker on escape when widget query is empty', () => { + const widgetCatalog = createCatalog([ + { type: 'git-branch', displayName: 'Git Branch', category: 'Git' } + ]); + const widgetCategories = ['All', 'Git']; + const pickerState = createStateSetter({ + action: 'change', + level: 'widget', + selectedCategory: 'Git', + categoryQuery: '', + widgetQuery: '', + selectedType: 'git-branch' + }); + + handlePickerInputMode({ + input: '', + key: { escape: true }, + widgetPicker: requireState(pickerState.get()), + widgetCatalog, + widgetCategories, + setWidgetPicker: pickerState.set, + applyWidgetPickerSelection: vi.fn() + }); + + expect(pickerState.get()?.level).toBe('category'); + }); + + it('moves selected widget up in move mode', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'tokens-input' }, + { id: '2', type: 'tokens-output' } + ]; + const onUpdate = vi.fn(); + const setSelectedIndex = vi.fn(); + const setMoveMode = vi.fn(); + + handleMoveInputMode({ + key: { upArrow: true }, + widgets, + selectedIndex: 1, + onUpdate, + setSelectedIndex, + setMoveMode + }); + + expect(onUpdate).toHaveBeenCalledWith([ + { id: '2', type: 'tokens-output' }, + { id: '1', type: 'tokens-input' } + ]); + expect(setSelectedIndex).toHaveBeenCalledWith(0); + expect(setMoveMode).not.toHaveBeenCalled(); + }); + + it('toggles raw value in normal mode for supported widgets', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'tokens-input' } + ]; + const onUpdate = vi.fn(); + + handleNormalInputMode({ + input: 'r', + key: {}, + widgets, + selectedIndex: 0, + separatorChars: ['|', '-'], + onBack: vi.fn(), + onUpdate, + setSelectedIndex: vi.fn(), + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [], + setCustomEditorWidget: vi.fn() + }); + + const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; + expect(updated?.[0]?.rawValue).toBe(true); + }); + + it('cycles separator character in normal mode', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'separator', character: '|' } + ]; + const onUpdate = vi.fn(); + + handleNormalInputMode({ + input: ' ', + key: {}, + widgets, + selectedIndex: 0, + separatorChars: ['|', '-'], + onBack: vi.fn(), + onUpdate, + setSelectedIndex: vi.fn(), + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [], + setCustomEditorWidget: vi.fn() + }); + + const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; + expect(updated?.[0]?.character).toBe('-'); + }); + + it('applies custom widget keybind actions in normal mode', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'session-usage' } + ]; + const onUpdate = vi.fn(); + + handleNormalInputMode({ + input: 'p', + key: {}, + widgets, + selectedIndex: 0, + separatorChars: ['|', '-'], + onBack: vi.fn(), + onUpdate, + setSelectedIndex: vi.fn(), + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [], + setCustomEditorWidget: vi.fn() + }); + + const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; + expect(updated?.[0]?.metadata?.display).toBe('progress'); + }); +}); \ No newline at end of file diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts new file mode 100644 index 0000000..2039f1c --- /dev/null +++ b/src/tui/components/items-editor/input-handlers.ts @@ -0,0 +1,458 @@ +import type { + CustomKeybind, + Widget, + WidgetItem, + WidgetItemType +} from '../../../types/Widget'; +import { + filterWidgetCatalog, + getWidget, + type WidgetCatalogEntry +} from '../../../utils/widgets'; + +export type WidgetPickerAction = 'change' | 'add' | 'insert'; +export type WidgetPickerLevel = 'category' | 'widget'; + +export interface WidgetPickerState { + action: WidgetPickerAction; + level: WidgetPickerLevel; + selectedCategory: string | null; + categoryQuery: string; + widgetQuery: string; + selectedType: WidgetItemType | null; +} + +export interface CustomEditorWidgetState { + widget: WidgetItem; + impl: Widget; + action?: string; +} + +export interface InputKey { + ctrl?: boolean; + meta?: boolean; + tab?: boolean; + shift?: boolean; + upArrow?: boolean; + downArrow?: boolean; + leftArrow?: boolean; + rightArrow?: boolean; + return?: boolean; + escape?: boolean; + backspace?: boolean; + delete?: boolean; +} + +type Setter = (value: T | ((prev: T) => T)) => void; + +function setPickerState( + setWidgetPicker: Setter, + normalizeState: (state: WidgetPickerState) => WidgetPickerState, + updater: (prev: WidgetPickerState) => WidgetPickerState +): void { + setWidgetPicker((prev) => { + if (!prev) { + return prev; + } + + return normalizeState(updater(prev)); + }); +} + +function getPickerCategories(widgetCategories: string[]): string[] { + return [...widgetCategories]; +} + +export function normalizePickerState( + state: WidgetPickerState, + widgetCatalog: WidgetCatalogEntry[], + widgetCategories: string[] +): WidgetPickerState { + const filteredCategories = getPickerCategories(widgetCategories); + const selectedCategory = state.selectedCategory && filteredCategories.includes(state.selectedCategory) + ? state.selectedCategory + : (filteredCategories[0] ?? null); + + const hasTopLevelSearch = state.level === 'category' && state.categoryQuery.trim().length > 0; + const effectiveCategory = hasTopLevelSearch ? 'All' : (selectedCategory ?? 'All'); + const effectiveQuery = hasTopLevelSearch ? state.categoryQuery : state.widgetQuery; + const filteredWidgets = filterWidgetCatalog(widgetCatalog, effectiveCategory, effectiveQuery); + const hasSelectedType = state.selectedType + ? filteredWidgets.some(entry => entry.type === state.selectedType) + : false; + + return { + ...state, + selectedCategory, + selectedType: hasSelectedType ? state.selectedType : (filteredWidgets[0]?.type ?? null) + }; +} + +interface PickerViewState { + filteredCategories: string[]; + selectedCategory: string | null; + hasTopLevelSearch: boolean; + topLevelSearchEntries: WidgetCatalogEntry[]; + topLevelSelectedEntry: WidgetCatalogEntry | undefined; + filteredWidgets: WidgetCatalogEntry[]; + selectedEntry: WidgetCatalogEntry | undefined; +} + +function getPickerViewState( + widgetPicker: WidgetPickerState, + widgetCatalog: WidgetCatalogEntry[], + widgetCategories: string[] +): PickerViewState { + const filteredCategories = getPickerCategories(widgetCategories); + const selectedCategory = widgetPicker.selectedCategory && filteredCategories.includes(widgetPicker.selectedCategory) + ? widgetPicker.selectedCategory + : (filteredCategories[0] ?? null); + const hasTopLevelSearch = widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0; + const topLevelSearchEntries = hasTopLevelSearch + ? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery) + : []; + const topLevelSelectedEntry = topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? topLevelSearchEntries[0]; + const filteredWidgets = filterWidgetCatalog(widgetCatalog, selectedCategory ?? 'All', widgetPicker.widgetQuery); + const selectedEntry = filteredWidgets.find(entry => entry.type === widgetPicker.selectedType) ?? filteredWidgets[0]; + + return { + filteredCategories, + selectedCategory, + hasTopLevelSearch, + topLevelSearchEntries, + topLevelSelectedEntry, + filteredWidgets, + selectedEntry + }; +} + +export interface HandlePickerInputModeArgs { + input: string; + key: InputKey; + widgetPicker: WidgetPickerState; + widgetCatalog: WidgetCatalogEntry[]; + widgetCategories: string[]; + setWidgetPicker: Setter; + applyWidgetPickerSelection: (selectedType: WidgetItemType) => void; +} + +export function handlePickerInputMode({ + input, + key, + widgetPicker, + widgetCatalog, + widgetCategories, + setWidgetPicker, + applyWidgetPickerSelection +}: HandlePickerInputModeArgs): void { + const normalizeState = (state: WidgetPickerState) => normalizePickerState(state, widgetCatalog, widgetCategories); + const { + filteredCategories, + selectedCategory, + hasTopLevelSearch, + topLevelSearchEntries, + topLevelSelectedEntry, + filteredWidgets, + selectedEntry + } = getPickerViewState(widgetPicker, widgetCatalog, widgetCategories); + + if (widgetPicker.level === 'category') { + if (key.escape) { + if (widgetPicker.categoryQuery.length > 0) { + setPickerState(setWidgetPicker, normalizeState, prev => ({ + ...prev, + categoryQuery: '' + })); + } else { + setWidgetPicker(null); + } + } else if (key.return) { + if (hasTopLevelSearch) { + if (topLevelSelectedEntry) { + applyWidgetPickerSelection(topLevelSelectedEntry.type); + } + } else if (selectedCategory) { + setPickerState(setWidgetPicker, normalizeState, prev => ({ + ...prev, + level: 'widget', + selectedCategory + })); + } + } else if (key.upArrow || key.downArrow) { + if (hasTopLevelSearch) { + if (topLevelSearchEntries.length === 0) { + return; + } + + let currentIndex = topLevelSearchEntries.findIndex(entry => entry.type === widgetPicker.selectedType); + if (currentIndex === -1) { + currentIndex = 0; + } + + const nextIndex = key.downArrow + ? Math.min(topLevelSearchEntries.length - 1, currentIndex + 1) + : Math.max(0, currentIndex - 1); + const nextType = topLevelSearchEntries[nextIndex]?.type ?? null; + setPickerState(setWidgetPicker, normalizeState, prev => ({ + ...prev, + selectedType: nextType + })); + } else { + if (filteredCategories.length === 0) { + return; + } + + let currentIndex = filteredCategories.findIndex(category => category === selectedCategory); + if (currentIndex === -1) { + currentIndex = 0; + } + + const nextIndex = key.downArrow + ? Math.min(filteredCategories.length - 1, currentIndex + 1) + : Math.max(0, currentIndex - 1); + const nextCategory = filteredCategories[nextIndex] ?? null; + setPickerState(setWidgetPicker, normalizeState, prev => ({ + ...prev, + selectedCategory: nextCategory + })); + } + } else if (key.backspace || key.delete) { + setPickerState(setWidgetPicker, normalizeState, prev => ({ + ...prev, + categoryQuery: prev.categoryQuery.slice(0, -1) + })); + } else if ( + input + && !key.ctrl + && !key.meta + && !key.tab + ) { + setPickerState(setWidgetPicker, normalizeState, prev => ({ + ...prev, + categoryQuery: prev.categoryQuery + input + })); + } + } else { + if (key.escape) { + if (widgetPicker.widgetQuery.length > 0) { + setPickerState(setWidgetPicker, normalizeState, prev => ({ + ...prev, + widgetQuery: '' + })); + } else { + setPickerState(setWidgetPicker, normalizeState, prev => ({ + ...prev, + level: 'category' + })); + } + } else if (key.return) { + if (selectedEntry) { + applyWidgetPickerSelection(selectedEntry.type); + } + } else if (key.upArrow || key.downArrow) { + if (filteredWidgets.length === 0) { + return; + } + + let currentIndex = filteredWidgets.findIndex(entry => entry.type === widgetPicker.selectedType); + if (currentIndex === -1) { + currentIndex = 0; + } + + const nextIndex = key.downArrow + ? Math.min(filteredWidgets.length - 1, currentIndex + 1) + : Math.max(0, currentIndex - 1); + const nextType = filteredWidgets[nextIndex]?.type ?? null; + setPickerState(setWidgetPicker, normalizeState, prev => ({ + ...prev, + selectedType: nextType + })); + } else if (key.backspace || key.delete) { + setPickerState(setWidgetPicker, normalizeState, prev => ({ + ...prev, + widgetQuery: prev.widgetQuery.slice(0, -1) + })); + } else if ( + input + && !key.ctrl + && !key.meta + && !key.tab + ) { + setPickerState(setWidgetPicker, normalizeState, prev => ({ + ...prev, + widgetQuery: prev.widgetQuery + input + })); + } + } +} + +export interface HandleMoveInputModeArgs { + key: InputKey; + widgets: WidgetItem[]; + selectedIndex: number; + onUpdate: (widgets: WidgetItem[]) => void; + setSelectedIndex: (index: number) => void; + setMoveMode: (moveMode: boolean) => void; +} + +export function handleMoveInputMode({ + key, + widgets, + selectedIndex, + onUpdate, + setSelectedIndex, + setMoveMode +}: HandleMoveInputModeArgs): void { + if (key.upArrow && selectedIndex > 0) { + const newWidgets = [...widgets]; + const temp = newWidgets[selectedIndex]; + const prev = newWidgets[selectedIndex - 1]; + if (temp && prev) { + [newWidgets[selectedIndex], newWidgets[selectedIndex - 1]] = [prev, temp]; + } + onUpdate(newWidgets); + setSelectedIndex(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < widgets.length - 1) { + const newWidgets = [...widgets]; + const temp = newWidgets[selectedIndex]; + const next = newWidgets[selectedIndex + 1]; + if (temp && next) { + [newWidgets[selectedIndex], newWidgets[selectedIndex + 1]] = [next, temp]; + } + onUpdate(newWidgets); + setSelectedIndex(selectedIndex + 1); + } else if (key.escape || key.return) { + setMoveMode(false); + } +} + +export interface HandleNormalInputModeArgs { + input: string; + key: InputKey; + widgets: WidgetItem[]; + selectedIndex: number; + separatorChars: string[]; + onBack: () => void; + onUpdate: (widgets: WidgetItem[]) => void; + setSelectedIndex: (index: number) => void; + setMoveMode: (moveMode: boolean) => void; + setShowClearConfirm: (show: boolean) => void; + openWidgetPicker: (action: WidgetPickerAction) => void; + getVisibleCustomKeybinds: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[]; + setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void; +} + +export function handleNormalInputMode({ + input, + key, + widgets, + selectedIndex, + separatorChars, + onBack, + onUpdate, + setSelectedIndex, + setMoveMode, + setShowClearConfirm, + openWidgetPicker, + getVisibleCustomKeybinds, + setCustomEditorWidget +}: HandleNormalInputModeArgs): void { + if (key.upArrow && widgets.length > 0) { + setSelectedIndex(Math.max(0, selectedIndex - 1)); + } else if (key.downArrow && widgets.length > 0) { + setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1)); + } else if (key.leftArrow && widgets.length > 0) { + openWidgetPicker('change'); + } else if (key.rightArrow && widgets.length > 0) { + openWidgetPicker('change'); + } else if (key.return && widgets.length > 0) { + setMoveMode(true); + } else if (input === 'a') { + openWidgetPicker('add'); + } else if (input === 'i') { + openWidgetPicker('insert'); + } else if (input === 'd' && widgets.length > 0) { + const newWidgets = widgets.filter((_, i) => i !== selectedIndex); + onUpdate(newWidgets); + if (selectedIndex >= newWidgets.length && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } + } else if (input === 'c') { + if (widgets.length > 0) { + setShowClearConfirm(true); + } + } else if (input === ' ' && widgets.length > 0) { + const currentWidget = widgets[selectedIndex]; + if (currentWidget && currentWidget.type === 'separator') { + const currentChar = currentWidget.character ?? '|'; + const currentCharIndex = separatorChars.indexOf(currentChar); + const nextChar = separatorChars[(currentCharIndex + 1) % separatorChars.length]; + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = { ...currentWidget, character: nextChar }; + onUpdate(newWidgets); + } + } else if (input === 'r' && widgets.length > 0) { + const currentWidget = widgets[selectedIndex]; + if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { + const widgetImpl = getWidget(currentWidget.type); + if (!widgetImpl?.supportsRawValue()) { + return; + } + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue }; + onUpdate(newWidgets); + } + } else if (input === 'm' && widgets.length > 0) { + const currentWidget = widgets[selectedIndex]; + if (currentWidget && selectedIndex < widgets.length - 1 + && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { + const newWidgets = [...widgets]; + let nextMergeState: boolean | 'no-padding' | undefined; + + if (currentWidget.merge === undefined) { + nextMergeState = true; + } else if (currentWidget.merge === true) { + nextMergeState = 'no-padding'; + } else { + nextMergeState = undefined; + } + + if (nextMergeState === undefined) { + const { merge, ...rest } = currentWidget; + void merge; // Intentionally unused + newWidgets[selectedIndex] = rest; + } else { + newWidgets[selectedIndex] = { ...currentWidget, merge: nextMergeState }; + } + onUpdate(newWidgets); + } + } else if (key.escape) { + onBack(); + } else if (widgets.length > 0) { + const currentWidget = widgets[selectedIndex]; + if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { + const widgetImpl = getWidget(currentWidget.type); + if (!widgetImpl?.getCustomKeybinds) { + return; + } + + const customKeybinds = getVisibleCustomKeybinds(widgetImpl, currentWidget); + const matchedKeybind = customKeybinds.find(kb => kb.key === input); + + if (matchedKeybind && !key.ctrl) { + if (widgetImpl.handleEditorAction) { + const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget); + if (updatedWidget) { + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = updatedWidget; + onUpdate(newWidgets); + } else if (widgetImpl.renderEditor) { + setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action }); + } + } else if (widgetImpl.renderEditor) { + setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action }); + } + } + } + } +} \ No newline at end of file