Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions core/TextCursor.js
Original file line number Diff line number Diff line change
@@ -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 }
78 changes: 75 additions & 3 deletions tests/launcher-paste.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions tests/textcursor.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
Loading