refactor: implement phase 4 tui component simplification with tests

This commit is contained in:
Matthew Breedlove committed 2026-03-03 20:14:30 -05:00
1 parent b13ea14a8c
commit b90840f68c
6 files changed
+1035 -410

No files matched your search

+19 -94
View File
@@ -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<ColorMenuProps> = ({ 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<ColorMenuProps> = ({ 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<ColorMenuProps> = ({ 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<ColorMenuProps> = ({ 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<ColorMenuProps> = ({ 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<ColorMenuProps> = ({ widgets, lineIndex, settin
<ConfirmDialog
inline={true}
onConfirm={() => {
// 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);
}}
+46 -316
View File
@@ -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<ItemsEditorProps> = ({ 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<CustomEditorWidgetState | null>(null);
const [widgetPicker, setWidgetPicker] = useState<WidgetPickerState | null>(null);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const separatorChars = ['|', '-', ',', ' '];
@@ -97,32 +94,6 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ widgets, onUpdate, onB
categoryQuery: '',
widgetQuery: '',
selectedType
}));
}, widgetCatalog, widgetCategories));
};
const applyWidgetPickerSelection = (selectedType: WidgetItemType) => {
@@ -201,286 +172,45 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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)
@@ -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');
});
});
+148
View File
@@ -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
};
});
}
@@ -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<T>(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<T>(value: T | null): T {
if (!value) {
throw new Error('Expected state value');
}
return value;
}
function createCatalog(entries: (Partial<WidgetCatalogEntry> & Pick<WidgetCatalogEntry, 'type'>)[]): 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<WidgetPickerState | null>({
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<WidgetPickerState | null>({
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');
});
});
@@ -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<T> = (value: T | ((prev: T) => T)) => void;
function setPickerState(
setWidgetPicker: Setter<WidgetPickerState | null>,
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<WidgetPickerState | null>;
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 });
}
}
}
}
}