From 611a974b33e8b3786059fdec619426c07ecafbb4 Mon Sep 17 00:00:00 2001 From: Akshat Khandelwal Date: Tue, 3 Nov 2026 23:01:23 +0530 Subject: [PATCH] Give the search field a real caret and selection The query was a Text with a hand-drawn fake caret: every key was appended or removed at the end of the string, so Shift/Ctrl+arrows, selection, word motions, IME input and paste-at-caret had nothing to act on. Use a TextInput for the field and let Qt own the caret, selection and keyboard editing. omacast's own keys (list navigation, popups, Enter, Ctrl+U) are still handled first. Add core/TextCursor.js for the Ctrl+Backspace / Ctrl+Delete word bounds, make paste insert at the caret and replace the selection, and extend the launcher tests. --- CLAUDE.md | 1 + core/TextCursor.js | 51 +++++++++++ tests/launcher-paste.test.js | 78 ++++++++++++++++- tests/textcursor.test.js | 51 +++++++++++ ui/LauncherWindow.qml | 159 ++++++++++++++++++++++++++--------- 5 files changed, 296 insertions(+), 44 deletions(-) create mode 100644 core/TextCursor.js create mode 100644 tests/textcursor.test.js diff --git a/CLAUDE.md b/CLAUDE.md index b9d6019..3c0a3de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,7 @@ Works: - **Ranking state:** frecency (+100 per launch, 10-day half-life) and learned search terms in `db/frecency.json`. Aliases (exact → top; duplicates rejected), favourites (Ctrl+Shift+F, reorder Ctrl+Alt+↑↓) and disabled commands live in `db/settings.json` `commands`. - **Navigation:** pushed Omarchy submenus (search within them), the alias editor, back with Esc / Ctrl+[ / Backspace on an empty query / the header arrow, and Shift+Esc to pop to root. - **Keyboard:** Ctrl+K action panel (filterable, per-row shortcuts), ↵ / Ctrl+↵ / Ctrl+Shift+↵ for the first three actions, Ctrl+1–9, Ctrl+↑↓ to jump sections, PageUp/PageDown, ↑ on an empty root for search history, Ctrl+Shift+C copy, Alt+Shift+D disable, Ctrl + / − / 0 zoom. + - **Search field:** a real TextInput, so the caret and selection behave like any text field: ←/→, Ctrl+←/→ words, Home/End, Shift and Ctrl+Shift selection, mouse drag or double-click selection, Ctrl+A/C/X/V, Ctrl+Backspace / Ctrl+Delete word deletion (`core/TextCursor.js`), Ctrl+U clear, and IME input. Paste inserts at the caret and replaces the selection. - Navigation bindings (`general.navigationBindings`): `emacs` moves down with Ctrl+N; `vim-motions` moves with Ctrl+J/K and puts the action panel on **Alt+K**. Set it with `omacast call settings.updateGeneralSettings '{"navigationBindings":"vim-motions"}'`. - **Ctrl+P** opens the root dropdown (All / Applications / Commands); emacs Ctrl+P = up only works inside popups. - **Ctrl+T** opens the toast's actions (Copy Error Message on failures, Undo after Disable Command). **Ctrl+Shift+K** opens the About panel for the selected row. diff --git a/core/TextCursor.js b/core/TextCursor.js new file mode 100644 index 0000000..b8ceb49 --- /dev/null +++ b/core/TextCursor.js @@ -0,0 +1,51 @@ +// Word bounds for the launcher's search field. The field itself is a QML +// TextInput, so Qt owns the caret, the selection and the standard editing +// keys; this module only supplies the word ranges used by Ctrl+Backspace and +// Ctrl+Delete [D]. A word is a run of non-whitespace characters; a move in +// either direction skips whitespace first, then the word. + +function clampPosition(text, position) { + text = String(text == null ? "" : text) + position = Number(position) + if (!isFinite(position)) return 0 + return Math.max(0, Math.min(text.length, Math.round(position))) +} + +function isSpace(ch) { + return /\s/.test(ch) +} + +// Start of the word before `position`: "alpha beta " at 11 -> 6, at 6 -> 0. +function wordStart(text, position) { + text = String(text == null ? "" : text) + var i = clampPosition(text, position) + while (i > 0 && isSpace(text.charAt(i - 1))) i-- + while (i > 0 && !isSpace(text.charAt(i - 1))) i-- + return i +} + +// End of the word after `position`: "alpha beta" at 0 -> 5, at 5 -> 10. +function wordEnd(text, position) { + text = String(text == null ? "" : text) + var i = clampPosition(text, position) + var n = text.length + while (i < n && isSpace(text.charAt(i))) i++ + while (i < n && !isSpace(text.charAt(i))) i++ + return i +} + +// Range removed by Ctrl+Backspace (forward false) / Ctrl+Delete (forward +// true): the selection when there is one, otherwise one word next to the +// caret. start === end means there is nothing to delete. +function deletionRange(text, anchor, caret, forward) { + text = String(text == null ? "" : text) + var start = clampPosition(text, Math.min(anchor, caret)) + var end = clampPosition(text, Math.max(anchor, caret)) + if (start !== end) return { start: start, end: end } + var at = clampPosition(text, caret) + return forward + ? { start: at, end: wordEnd(text, at) } + : { start: wordStart(text, at), end: at } +} + +if (typeof module !== "undefined") module.exports = { clampPosition, wordStart, wordEnd, deletionRange } diff --git a/tests/launcher-paste.test.js b/tests/launcher-paste.test.js index 68e8645..e052e72 100644 --- a/tests/launcher-paste.test.js +++ b/tests/launcher-paste.test.js @@ -3,6 +3,7 @@ const assert = require("node:assert/strict") const fs = require("node:fs") const path = require("node:path") const vm = require("node:vm") +const TextCursor = require("../core/TextCursor.js") const source = fs.readFileSync(path.join(__dirname, "../ui/LauncherWindow.qml"), "utf8") @@ -22,19 +23,53 @@ function harness(text) { const changes = [] const window = { query: "prefix ", actionPanelOpen: false, view: { kind: "root" }, historyIndex: 0, argFocus: -1, argDefs: [], + queryInputSyncing: false, rebuild: reset => changes.push(reset), isTyping: () => false, isActionPanelKey: () => false, direction: () => 0 } + // The subset of TextInput the launcher touches, with the same caret and + // selection rules the widget has (selectionStart === selectionEnd === caret + // when nothing is selected). + const queryInput = { + text: window.query, + selectionStart: window.query.length, + selectionEnd: window.query.length, + cursorPosition: window.query.length, + deselect() { this.selectionStart = this.selectionEnd = this.cursorPosition }, + remove(start, end) { + this.text = this.text.slice(0, start) + this.text.slice(end) + this.onTextChanged() + }, + insert(position, value) { + this.text = this.text.slice(0, position) + value + this.text.slice(position) + this.onTextChanged() + }, + onTextChanged() { window.queryEdited(this.text) } + } const actionPanel = { item: { filter: "" } } const clipboardInput = { text: "", clear() { this.text = "" }, paste() { this.text = text } } - const context = vm.createContext({ Qt, window, actionPanel, clipboardInput, Util: { editsFilter: () => false } }) + const context = vm.createContext({ Qt, window, queryInput, actionPanel, clipboardInput, TextCursor, Util: { editsFilter: () => false } }) + window.queryEdited = value => { + context.value = value + vm.runInContext(body("function queryEdited(value)"), context) + } + window.syncQueryInput = () => vm.runInContext(body("function syncQueryInput()"), context) window.setQuery = next => { context.next = next vm.runInContext(body("function setQuery(next)"), context) + window.syncQueryInput() + } + window.deleteQueryWord = forward => { + context.forward = forward + vm.runInContext(body("function deleteQueryWord(forward)"), context) + } + window.insertQueryText = text => { + context.text = text + vm.runInContext(body("function insertQueryText(text)"), context) } window.pasteClipboard = () => vm.runInContext(body("function pasteClipboard()"), context) return { - Qt, window, actionPanel, clipboardInput, changes, + Qt, window, queryInput, actionPanel, clipboardInput, changes, press(key, modifiers) { context.event = { key: Qt["Key_" + key], modifiers } return vm.runInContext(body("function handleKey(event)"), context) @@ -89,11 +124,48 @@ test("clipboard paste also works in submenu queries and alias editing", () => { h.press("V", h.Qt.ControlModifier) assert.equal(h.window.query, "prefix some text with\tspaces") h.window.view.kind = "alias" - h.window.query = "" + h.window.setQuery("") h.press("Insert", h.Qt.ShiftModifier) assert.equal(h.window.query, "sometextwithspaces") }) +test("paste replaces the selection at the caret instead of appending", () => { + const h = harness("12+34") + h.queryInput.text = "alpha beta" + h.window.query = "alpha beta" + h.queryInput.cursorPosition = 11 + h.queryInput.deselect() + h.queryInput.selectionStart = 6 + h.queryInput.selectionEnd = 10 + h.queryInput.cursorPosition = 10 + h.press("V", h.Qt.ControlModifier) + assert.equal(h.window.query, "alpha 12+34") + assert.equal(h.queryInput.cursorPosition, 11) + assert.equal(h.queryInput.selectionStart, 11) + assert.equal(h.queryInput.selectionEnd, 11) +}) + +test("Ctrl+Backspace deletes the word before the caret, Ctrl+Delete the next one", () => { + const back = harness("x") + back.queryInput.text = "alpha beta" + back.window.query = "alpha beta" + back.queryInput.cursorPosition = 10 + back.queryInput.deselect() + back.press("Backspace", back.Qt.ControlModifier) + assert.equal(back.window.query, "alpha ") + assert.equal(back.queryInput.text, "alpha ") + assert.equal(back.queryInput.cursorPosition, 6) + + const forward = harness("x") + forward.queryInput.text = "alpha beta" + forward.window.query = "alpha beta" + forward.queryInput.cursorPosition = 6 + forward.queryInput.deselect() + forward.press("Delete", forward.Qt.ControlModifier) + assert.equal(forward.window.query, "alpha ") + assert.equal(forward.queryInput.cursorPosition, 6) +}) + test("action-panel paste edits its filter without changing the query", () => { const h = harness("Copy\nResult") h.window.actionPanelOpen = true diff --git a/tests/textcursor.test.js b/tests/textcursor.test.js new file mode 100644 index 0000000..fe0b3f7 --- /dev/null +++ b/tests/textcursor.test.js @@ -0,0 +1,51 @@ +const test = require("node:test") +const assert = require("node:assert/strict") +const { clampPosition, wordStart, wordEnd, deletionRange } = require("../core/TextCursor.js") + +test("wordStart walks back over whitespace and then the word", () => { + const text = "alpha beta gamma" + assert.equal(wordStart(text, text.length), 12) + assert.equal(wordStart(text, 12), 6) + assert.equal(wordStart(text, 11), 6) + assert.equal(wordStart(text, 6), 0) + assert.equal(wordStart(text, 5), 0) + assert.equal(wordStart(text, 0), 0) +}) + +test("wordEnd walks forward over whitespace and then the word", () => { + const text = "alpha beta gamma" + assert.equal(wordEnd(text, 0), 5) + assert.equal(wordEnd(text, 5), 10) + assert.equal(wordEnd(text, 10), 17) + assert.equal(wordEnd(text, text.length), text.length) +}) + +test("word bounds use characters, not bytes", () => { + assert.equal(wordStart("héllo wörld", 11), 6) + assert.equal(wordStart("héllo wörld", 6), 0) + assert.equal(wordEnd("héllo wörld", 0), 5) + assert.equal(wordEnd("你好 世界", 3), 5) + assert.equal(wordStart("你好 世界", 5), 3) +}) + +test("deletionRange deletes the word next to a collapsed caret", () => { + assert.deepEqual(deletionRange("alpha beta", 10, 10, false), { start: 6, end: 10 }) + assert.deepEqual(deletionRange("alpha beta ", 11, 11, false), { start: 6, end: 11 }) + assert.deepEqual(deletionRange("alpha beta", 0, 0, true), { start: 0, end: 5 }) + assert.deepEqual(deletionRange("alpha beta", 3, 3, true), { start: 3, end: 5 }) + assert.deepEqual(deletionRange("", 0, 0, false), { start: 0, end: 0 }) + assert.deepEqual(deletionRange("", 0, 0, true), { start: 0, end: 0 }) +}) + +test("deletionRange deletes the selection whatever the caret side", () => { + assert.deepEqual(deletionRange("alpha beta", 8, 2, false), { start: 2, end: 8 }) + assert.deepEqual(deletionRange("alpha beta", 2, 8, true), { start: 2, end: 8 }) +}) + +test("clampPosition keeps positions inside the text", () => { + assert.equal(clampPosition("abc", -4), 0) + assert.equal(clampPosition("abc", 99), 3) + assert.equal(clampPosition("abc", 1.6), 2) + assert.equal(clampPosition("abc", "x"), 0) + assert.equal(clampPosition(null, 2), 0) +}) diff --git a/ui/LauncherWindow.qml b/ui/LauncherWindow.qml index 618a8ad..c437bd0 100644 --- a/ui/LauncherWindow.qml +++ b/ui/LauncherWindow.qml @@ -6,6 +6,7 @@ import qs.Commons as Commons import qs.Ui import "../core/RootSearch.js" as RootSearch import "../core/Rows.js" as Rows +import "../core/TextCursor.js" as TextCursor // Omarchy 4.0.x dropped qs.Ui.OverlayWindow; this is the same full-screen // overlay layer the shell's own emoji picker uses. @@ -88,8 +89,8 @@ PanelWindow { // Navigation bindings (ref 04 §2.8): emacs moves with Ctrl+N (Ctrl+P is the // dropdown); vim-motions with Ctrl+J/K, which moves the action panel to - // Alt+K. Ctrl+B/F and Ctrl+H/L move a text cursor in Raycast; our search - // field has none, so they do nothing here. [D] + // Alt+K. Ctrl+B/F and Ctrl+H/L are left to Qt's text editing: the search + // field is a real TextInput, like Raycast's. [D] readonly property bool vim: window.general.navigationBindings === "vim-motions" readonly property string actionPanelKeys: window.vim ? "Alt K" : "Ctrl K" @@ -126,8 +127,9 @@ PanelWindow { if (typeof payload.query === "string") window.query = payload.query window.closePopups() window.historyIndex = -1 + window.syncQueryInput() window.rebuild(true) - Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + Qt.callLater(function() { queryInput.forceActiveFocus() }) } onShownChanged: { @@ -461,8 +463,52 @@ PanelWindow { } + // The search field is a TextInput, so Qt owns the caret, the selection, IME + // input and the standard editing keys (Ctrl+A, Ctrl+C/X/V, Ctrl+Left/Right, + // Shift+Ctrl+Left/Right, Home/End, …). window.query stays the plain string + // the backend searches with; these keep the two in step without resetting + // the caret on every keystroke. + property bool queryInputSyncing: false + + function queryEdited(value) { + if (window.query === value) return + window.queryInputSyncing = true + window.query = value + window.queryInputSyncing = false + window.historyIndex = -1 + window.rebuild(true) + } + + // Programmatic query changes (history, push/pop, Esc) put the caret at the + // end and drop any selection. + function syncQueryInput() { + if (queryInput.text !== window.query) queryInput.text = window.query + queryInput.deselect() + queryInput.cursorPosition = queryInput.text.length + } + + // Ctrl+Backspace / Ctrl+Delete: Qt Quick's TextInput does not delete a word + // on every platform, so the word range comes from core/TextCursor.js. + function deleteQueryWord(forward) { + var range = TextCursor.deletionRange(queryInput.text, queryInput.selectionStart, queryInput.selectionEnd, forward) + if (range.start === range.end) return + queryInput.remove(range.start, range.end) + queryInput.deselect() + queryInput.cursorPosition = range.start + } + + // Paste at the caret (or over the selection) instead of appending. + function insertQueryText(text) { + var start = Math.min(queryInput.selectionStart, queryInput.selectionEnd) + var end = Math.max(queryInput.selectionStart, queryInput.selectionEnd) + if (start !== end) queryInput.remove(start, end) + queryInput.insert(start, text) + queryInput.cursorPosition = start + text.length + queryInput.deselect() + } + function setQuery(next) { - if (window.query === next) return + if (window.query === next) { window.syncQueryInput(); return } window.query = next window.historyIndex = -1 window.rebuild(true) @@ -482,7 +528,7 @@ PanelWindow { window.argValues = values } else { if (window.view.kind === "alias") text = text.replace(/\s/g, "") - window.setQuery(window.query + text) + window.insertQueryText(text) } } @@ -636,9 +682,15 @@ PanelWindow { } } - if (Util.editsFilter(event, window.query)) { window.setQuery(Util.editedFilter(event, window.query)); return true } + // Text editing (characters, Backspace/Delete, arrows, Home/End, Ctrl+A, + // Ctrl+C/X/V and Shift / Ctrl+Shift selection) is Qt's: return false and + // the focused TextInput applies it. Only the keys it would get wrong live + // here. + if (ctrl && !shift && !alt && key === Qt.Key_U) { window.setQuery(""); return true } + if (ctrl && !shift && !alt && key === Qt.Key_Backspace) { window.deleteQueryWord(false); return true } + if (ctrl && !shift && !alt && key === Qt.Key_Delete) { window.deleteQueryWord(true); return true } if (key === Qt.Key_Backspace && !window.query && !window.atRoot) { window.pop(); return true } - if (window.isTyping(event)) { window.setQuery(window.query + event.text); return true } + if (key === Qt.Key_Tab || key === Qt.Key_Backtab) return true return false } @@ -647,6 +699,11 @@ PanelWindow { && (event.modifiers === Qt.NoModifier || event.modifiers === Qt.ShiftModifier) } + Connections { + target: window + function onQueryChanged() { if (!window.queryInputSyncing) window.syncQueryInput() } + } + Connections { target: window.backend function onCandidatesRevisionChanged() { if (window.shown && !window.actionPanelOpen && !window.alert) window.rebuild(false) } @@ -671,15 +728,7 @@ PanelWindow { borderSpec: window.borderSpec clip: true - MouseArea { anchors.fill: parent; onClicked: { window.closePopups(); keyCatcher.forceActiveFocus() } } - - Item { - id: keyCatcher - anchors.fill: parent - focus: true - Keys.priority: Keys.BeforeItem - Keys.onPressed: function(event) { if (window.handleKey(event)) event.accepted = true } - } + MouseArea { anchors.fill: parent; onClicked: { window.closePopups(); queryInput.forceActiveFocus() } } Item { id: header @@ -719,44 +768,72 @@ PanelWindow { // Room left of the filter button; argument pills sit right after the // typed text (ref 04 §2.2 WidthSizer). - readonly property real queryRoom: (filterButton.visible ? filterButton.x - window.px(12) : header.width - window.px(20)) - queryText.x + readonly property real queryX: lead.x + lead.width + window.px(12) + readonly property real queryRoom: (filterButton.visible ? filterButton.x - window.px(12) : header.width - window.px(20)) - header.queryX + readonly property real queryTextWidth: Math.min(queryInput.contentWidth, queryInput.width) Text { - id: queryText - textFormat: Text.PlainText - x: lead.x + lead.width + window.px(12) - width: Math.min(implicitWidth, header.queryRoom - (pills.visible ? pills.width + window.px(10) : 0)) + id: queryPlaceholder + x: queryInput.x + width: queryInput.width anchors.verticalCenter: parent.verticalCenter - text: window.query || (pills.visible ? "" : window.placeholder) + textFormat: Text.PlainText + text: window.placeholder + elide: Text.ElideRight color: window.look.foreground - opacity: window.query ? 1 : 0.45 + opacity: 0.45 + visible: queryInput.text === "" && !pills.visible font.family: window.look.fontFamily font.pixelSize: Math.round(Style.font.heading * 1.15 * window.zoom) - elide: Text.ElideLeft } - Rectangle { - width: Math.max(1, Style.space(2)) - height: queryText.font.pixelSize + Style.space(4) + TextInput { + id: queryInput + x: header.queryX + width: Math.max(1, header.queryRoom - (pills.visible ? pills.width + window.px(10) : 0)) + height: header.height anchors.verticalCenter: parent.verticalCenter - x: queryText.x + (window.query ? Math.min(queryText.contentWidth, queryText.width) + Style.space(1) : 0) - color: window.look.accent - visible: window.shown && !window.actionPanelOpen && window.argFocus < 0 - - SequentialAnimation on opacity { - running: window.shown - loops: Animation.Infinite - NumberAnimation { to: 1; duration: 0 } - PauseAnimation { duration: 530 } - NumberAnimation { to: 0; duration: 0 } - PauseAnimation { duration: 530 } + selectByMouse: true + clip: true + color: window.look.foreground + selectionColor: Style.selectionFillFor(window.look.foreground, window.look.accent) + selectedTextColor: window.look.foreground + font.family: window.look.fontFamily + font.pixelSize: Math.round(Style.font.heading * 1.15 * window.zoom) + verticalAlignment: TextInput.AlignVCenter + cursorDelegate: Item {} + onTextChanged: window.queryEdited(text) + + Keys.priority: Keys.BeforeItem + Keys.onPressed: function(event) { if (window.handleKey(event)) event.accepted = true } + + // Qt's caret is replaced (cursorDelegate above) so it keeps the + // launcher's accent colour and blink. + Rectangle { + readonly property rect caret: queryInput.cursorRectangle + readonly property real caretWidth: Math.max(1, Style.space(2)) + x: Math.max(0, Math.min(caret.x, queryInput.width - caretWidth)) + y: caret.y + width: caretWidth + height: caret.height + color: window.look.accent + visible: queryInput.activeFocus && window.shown && !window.actionPanelOpen && window.argFocus < 0 + + SequentialAnimation on opacity { + running: window.shown + loops: Animation.Infinite + NumberAnimation { to: 1; duration: 0 } + PauseAnimation { duration: 530 } + NumberAnimation { to: 0; duration: 0 } + PauseAnimation { duration: 530 } + } } } Row { id: pills visible: window.argDefs.length > 0 && window.view.kind !== "alias" - x: queryText.x + (window.query ? queryText.width : 0) + window.px(10) + x: queryInput.x + (window.query ? header.queryTextWidth : 0) + window.px(10) anchors.verticalCenter: parent.verticalCenter spacing: window.px(6) @@ -804,7 +881,7 @@ PanelWindow { MouseArea { anchors.fill: parent cursorShape: Qt.IBeamCursor - onClicked: { window.argFocus = pill.index; keyCatcher.forceActiveFocus() } + onClicked: { window.argFocus = pill.index; queryInput.forceActiveFocus() } } } } @@ -850,7 +927,7 @@ PanelWindow { MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: { window.toggleDropdown(); keyCatcher.forceActiveFocus() } + onClicked: { window.toggleDropdown(); queryInput.forceActiveFocus() } } }