From 17b2576cca85e7e0a43136b1cd8e56514f8bee7b Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Sun, 31 Aug 2025 02:47:02 -0400 Subject: [PATCH] Support emoji input for custom text --- src/widgets/CustomText.tsx | 108 +++++++++++++++++++++++++++++++------ 1 file changed, 93 insertions(+), 15 deletions(-) diff --git a/src/widgets/CustomText.tsx b/src/widgets/CustomText.tsx index 224f730..5843994 100644 --- a/src/widgets/CustomText.tsx +++ b/src/widgets/CustomText.tsx @@ -49,43 +49,121 @@ const CustomTextEditor: React.FC = ({ widget, onComplete, onC const [text, setText] = useState(widget.customText ?? ''); const [cursorPos, setCursorPos] = useState(text.length); + // Helper to get grapheme segments if Intl.Segmenter is available + const getGraphemes = (str: string): string[] => { + if ('Segmenter' in Intl) { + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + return Array.from(segmenter.segment(str), seg => seg.segment); + } + // Fallback to simple character array (won't handle complex emojis perfectly) + return Array.from(str); + }; + + // Convert between grapheme index and string index + const graphemeToStringIndex = (str: string, graphemeIndex: number): number => { + const graphemes = getGraphemes(str); + let stringIndex = 0; + for (let i = 0; i < Math.min(graphemeIndex, graphemes.length); i++) { + const grapheme = graphemes[i]; + if (grapheme) { + stringIndex += grapheme.length; + } + } + return stringIndex; + }; + + const stringToGraphemeIndex = (str: string, stringIndex: number): number => { + const graphemes = getGraphemes(str); + let currentStringIndex = 0; + for (let i = 0; i < graphemes.length; i++) { + if (currentStringIndex >= stringIndex) + return i; + const grapheme = graphemes[i]; + if (grapheme) { + currentStringIndex += grapheme.length; + } + } + return graphemes.length; + }; + useInput((input, key) => { if (key.return) { onComplete({ ...widget, customText: text }); } else if (key.escape) { onCancel(); } else if (key.leftArrow) { - setCursorPos(Math.max(0, cursorPos - 1)); + const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); + if (currentGraphemeIndex > 0) { + const newStringIndex = graphemeToStringIndex(text, currentGraphemeIndex - 1); + setCursorPos(newStringIndex); + } } else if (key.rightArrow) { - setCursorPos(Math.min(text.length, cursorPos + 1)); + const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); + const graphemeCount = getGraphemes(text).length; + if (currentGraphemeIndex < graphemeCount) { + const newStringIndex = graphemeToStringIndex(text, currentGraphemeIndex + 1); + setCursorPos(newStringIndex); + } } else if (key.ctrl && input === 'ArrowLeft') { setCursorPos(0); } else if (key.ctrl && input === 'ArrowRight') { setCursorPos(text.length); } else if (key.backspace) { if (cursorPos > 0) { - setText(text.slice(0, cursorPos - 1) + text.slice(cursorPos)); - setCursorPos(cursorPos - 1); + const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); + if (currentGraphemeIndex > 0) { + const deleteFromIndex = graphemeToStringIndex(text, currentGraphemeIndex - 1); + const deleteToIndex = graphemeToStringIndex(text, currentGraphemeIndex); + setText(text.slice(0, deleteFromIndex) + text.slice(deleteToIndex)); + setCursorPos(deleteFromIndex); + } } } else if (key.delete) { if (cursorPos < text.length) { - setText(text.slice(0, cursorPos) + text.slice(cursorPos + 1)); + const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); + const graphemeCount = getGraphemes(text).length; + if (currentGraphemeIndex < graphemeCount) { + const deleteFromIndex = graphemeToStringIndex(text, currentGraphemeIndex); + const deleteToIndex = graphemeToStringIndex(text, currentGraphemeIndex + 1); + setText(text.slice(0, deleteFromIndex) + text.slice(deleteToIndex)); + } } - } else if (input && input.length === 1) { - setText(text.slice(0, cursorPos) + input + text.slice(cursorPos)); - setCursorPos(cursorPos + 1); + } else if (input && !key.ctrl && !key.meta) { + // Insert the input at cursor position + const newText = text.slice(0, cursorPos) + input + text.slice(cursorPos); + setText(newText); + + // Move cursor by the actual string length of the input + // This handles multi-byte characters including emojis with modifiers + setCursorPos(cursorPos + input.length); } }); + // Get the grapheme at cursor position for display + const graphemes = getGraphemes(text); + const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); + + // Build display with ANSI codes for cursor highlighting + let display = 'Enter custom text: '; + for (let i = 0; i < graphemes.length; i++) { + const grapheme = graphemes[i]; + if (grapheme) { + if (i === currentGraphemeIndex) { + // Use inverse video for cursor position + display += `\x1b[7m${grapheme}\x1b[0m`; + } else { + display += grapheme; + } + } + } + if (currentGraphemeIndex >= graphemes.length) { + // Cursor at end + display += '\x1b[7m \x1b[0m'; + } + return ( - - Enter custom text: - {' '} - {text.slice(0, cursorPos)} - {text[cursorPos] ?? ' '} - {text.slice(cursorPos + 1)} - + {display} ←→ move cursor, Ctrl+←→ jump to start/end, Enter save, ESC cancel );