From 7cfd472193f4f93831f55892f7a3c9617b0bc44d Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 8 Oct 2026 17:47:05 +0200 Subject: [PATCH] gui: shared TextSelection.js for bracket/quote pair selection Extract selectBracketPair, selectWordAt, and helpers into TextSelection.js and import it in both ChatBlockDelegate.qml and ChatPane.qml. Prompt textarea now has the same double-click behavior as chat blocks: delimiter adjacent to cursor -> select pair content, else -> word select. Quotes use parity to decide direction and scan document-wide, so multi-line content between the pair is selected in full. --- kde/gui/ChatBlockDelegate.qml | 194 +--------------------------------- kde/gui/ChatPane.qml | 22 +++- kde/gui/TextSelection.js | 167 +++++++++++++++++++++++++++++ kde/gui/qml.qrc | 1 + 4 files changed, 191 insertions(+), 193 deletions(-) create mode 100644 kde/gui/TextSelection.js diff --git a/kde/gui/ChatBlockDelegate.qml b/kde/gui/ChatBlockDelegate.qml index 5691533..d4f4676 100644 --- a/kde/gui/ChatBlockDelegate.qml +++ b/kde/gui/ChatBlockDelegate.qml @@ -6,6 +6,7 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import "TextSelection.js" as TextSelection Column { id: root @@ -224,8 +225,8 @@ Column { // Delimiter under cursor: select the pair. // Otherwise select the word ourselves (the overlay // swallows the event, so TextEdit won't do it). - if (!selectBracketPair(mainTextEdit, mouse.x, mouse.y)) { - selectWordAt(mainTextEdit, mouse.x, mouse.y) + if (!TextSelection.selectBracketPair(mainTextEdit, mouse.x, mouse.y)) { + TextSelection.selectWordAt(mainTextEdit, mouse.x, mouse.y) } mouse.accepted = true } @@ -383,191 +384,6 @@ Column { } } - // Select the plain word under the mouse (double-click word select). - // Uses a word charset, not acme's broader isfilec, so a double-click - // behaves like a normal editor word select. - function selectWordAt(textEdit, mouseX, mouseY) { - var cursorPos = textEdit.positionAt(mouseX, mouseY) - var text = textEdit.getText(0, textEdit.length) - if (cursorPos < 0 || cursorPos > text.length) return - - function isword(ch) { - return /[A-Za-z0-9_]/.test(ch) - } - - var start = cursorPos - while (start > 0 && isword(text.charAt(start - 1))) start-- - var end = cursorPos - while (end < text.length && isword(text.charAt(end))) end++ - - if (start === end) return - textEdit.select(start, end) - } - - // Bracket pairs for double-click selection - function matchingBracket(ch) { - switch (ch) { - case '(': return {match: ')', isOpener: true} - case '[': return {match: ']', isOpener: true} - case '{': return {match: '}', isOpener: true} - case ')': return {match: '(', isOpener: false} - case ']': return {match: '[', isOpener: false} - case '}': return {match: '{', isOpener: false} - default: return null - } - } - - function isQuote(ch) { - return ch === "'" || ch === '"' || ch === '`' - } - - // A quote at position qPos opens a pair when an even number of identical - // quote chars precede it; odd means it closes one. Quotes do not nest, so - // parity decides direction. - function quoteIsOpener(text, qPos, quote) { - var count = 0 - for (var c = 0; c < qPos; c++) { - if (text.charAt(c) === quote) count++ - } - return (count % 2) === 0 - } - - // Next identical quote char forward from qPos. -1 if none. - function findQuoteForward(text, qPos, quote) { - for (var c = qPos + 1; c < text.length; c++) { - if (text.charAt(c) === quote) return c - } - return -1 - } - - // Previous identical quote char backward from qPos. -1 if none. - function findQuoteBackward(text, qPos, quote) { - for (var c = qPos - 1; c >= 0; c--) { - if (text.charAt(c) === quote) return c - } - return -1 - } - - // Find matching closer, handling nesting - function findCloser(text, startPos, opener, closer) { - var depth = 1 - var pos = startPos - while (pos < text.length) { - var ch = text.charAt(pos) - if (ch === opener) depth++ - else if (ch === closer) { - depth-- - if (depth === 0) return pos - } - pos++ - } - return -1 // unbalanced - } - - // Find matching opener, handling nesting (scan backwards) - function findOpener(text, startPos, opener, closer) { - var depth = 1 - var pos = startPos - while (pos >= 0) { - var ch = text.charAt(pos) - if (ch === closer) depth++ - else if (ch === opener) { - depth-- - if (depth === 0) return pos - } - pos-- - } - return -1 // unbalanced - } - - // Select text between a matching delimiter pair on double-click. - // Quotes ('' "" ``) are resolved first (symmetric, non-nesting, line-scoped, - // direction by parity); then brackets (nesting, document-wide). The adjacent - // delimiter can be on either side of the cursor and determines the search - // direction and the matching end delimiter. - // Returns true if a pair was found and the inner content selected. - function selectBracketPair(textEdit, mouseX, mouseY) { - var cursorPos = textEdit.positionAt(mouseX, mouseY) - var text = textEdit.getText(0, textEdit.length) - - if (cursorPos < 0 || cursorPos > text.length) return false - - // Char to the right and left of the click position. - var rightCh = cursorPos < text.length ? text.charAt(cursorPos) : '' - var leftCh = cursorPos > 0 ? text.charAt(cursorPos - 1) : '' - - // Quote pairs first. Pick the adjacent quote (right preferred), decide - // opener/closer by parity, then scan that direction for the matching - // quote. Content between may span multiple lines; all of it is selected. - var q = '' - var qPos = -1 - if (isQuote(rightCh)) { - q = rightCh - qPos = cursorPos - } else if (isQuote(leftCh)) { - q = leftCh - qPos = cursorPos - 1 - } - if (q !== '') { - var qOpen = -1 - var qClose = -1 - if (quoteIsOpener(text, qPos, q)) { - qOpen = qPos - qClose = findQuoteForward(text, qPos, q) - } else { - qClose = qPos - qOpen = findQuoteBackward(text, qPos, q) - } - if (qOpen < 0 || qClose < 0) return false // unbalanced - textEdit.select(qOpen + 1, qClose) - return true - } - - var rightMatch = matchingBracket(rightCh) - var leftMatch = matchingBracket(leftCh) - - var innerStart = -1 - var innerEnd = -1 - - // Prefer opener to the right, then closer to the left, then the - // remaining adjacency cases. Inner range excludes the delimiters. - if (rightMatch && rightMatch.isOpener) { - // Opener to right: find closer forward. - var closerPos = findCloser(text, cursorPos + 1, rightCh, rightMatch.match) - if (closerPos >= 0) { - innerStart = cursorPos + 1 - innerEnd = closerPos - } - } else if (leftMatch && !leftMatch.isOpener) { - // Closer to left: find opener backward. - var openerPos = findOpener(text, cursorPos - 2, leftMatch.match, leftCh) - if (openerPos >= 0) { - innerStart = openerPos + 1 - innerEnd = cursorPos - 1 - } - } else if (rightMatch && !rightMatch.isOpener) { - // Closer to right: find opener backward from just before it. - var openerPos2 = findOpener(text, cursorPos - 1, rightMatch.match, rightCh) - if (openerPos2 >= 0) { - innerStart = openerPos2 + 1 - innerEnd = cursorPos - } - } else if (leftMatch && leftMatch.isOpener) { - // Opener to left: find closer forward from the click position. - var closerPos2 = findCloser(text, cursorPos, leftCh, leftMatch.match) - if (closerPos2 >= 0) { - innerStart = cursorPos - innerEnd = closerPos2 - } - } - - if (innerStart >= 0 && innerEnd >= innerStart) { - textEdit.select(innerStart, innerEnd) - return true - } - return false - } - // Text segment component Component { id: textComponent @@ -595,8 +411,8 @@ Column { onDoubleClicked: function(mouse) { if (mouse.button === Qt.LeftButton) { - if (!selectBracketPair(textEdit, mouse.x, mouse.y)) { - selectWordAt(textEdit, mouse.x, mouse.y) + if (!TextSelection.selectBracketPair(textEdit, mouse.x, mouse.y)) { + TextSelection.selectWordAt(textEdit, mouse.x, mouse.y) } mouse.accepted = true } diff --git a/kde/gui/ChatPane.qml b/kde/gui/ChatPane.qml index d0d64a2..f2df0f8 100644 --- a/kde/gui/ChatPane.qml +++ b/kde/gui/ChatPane.qml @@ -1,6 +1,7 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import "TextSelection.js" as TextSelection Item { id: chatPane @@ -470,7 +471,7 @@ Item { } // Acme-style mouse chording overlay - // B1 = select (handled by TextArea) + // B1 double-click = bracket/quote pair or word select // B2 = execute selection // B3 = plumb selection / context menu // B1+B2 = cut (snarf) @@ -478,16 +479,29 @@ Item { MouseArea { id: chordArea anchors.fill: parent - acceptedButtons: Qt.MiddleButton | Qt.RightButton + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton propagateComposedEvents: true cursorShape: Qt.IBeamCursor // Track if B1 (left) is currently held for chord detection property bool leftHeld: false - // We can't directly detect B1 here since TextArea handles it, - // but we can check mouse.buttons in the handlers + onDoubleClicked: function(mouse) { + if (mouse.button === Qt.LeftButton) { + if (!TextSelection.selectBracketPair(promptInput, mouse.x, mouse.y)) { + TextSelection.selectWordAt(promptInput, mouse.x, mouse.y) + } + mouse.accepted = true + } + } + onPressed: function(mouse) { + // Left button single clicks pass through to TextArea for selection + if (mouse.button === Qt.LeftButton) { + mouse.accepted = false + return + } + // Check if this is a chord (B1 already held) if (mouse.buttons & Qt.LeftButton) { leftHeld = true diff --git a/kde/gui/TextSelection.js b/kde/gui/TextSelection.js new file mode 100644 index 0000000..4e8ae64 --- /dev/null +++ b/kde/gui/TextSelection.js @@ -0,0 +1,167 @@ +// Shared text selection helpers for acme-style double-click behavior. +// Brackets select their nested content; quotes select by parity. + +function isQuote(ch) { + return ch === "'" || ch === '"' || ch === '`' +} + +// A quote at position qPos opens a pair when an even number of identical +// quote chars precede it; odd means it closes one. +function quoteIsOpener(text, qPos, quote) { + var count = 0 + for (var c = 0; c < qPos; c++) { + if (text.charAt(c) === quote) count++ + } + return (count % 2) === 0 +} + +function findQuoteForward(text, qPos, quote) { + for (var c = qPos + 1; c < text.length; c++) { + if (text.charAt(c) === quote) return c + } + return -1 +} + +function findQuoteBackward(text, qPos, quote) { + for (var c = qPos - 1; c >= 0; c--) { + if (text.charAt(c) === quote) return c + } + return -1 +} + +function matchingBracket(ch) { + switch (ch) { + case '(': return {match: ')', isOpener: true} + case '[': return {match: ']', isOpener: true} + case '{': return {match: '}', isOpener: true} + case ')': return {match: '(', isOpener: false} + case ']': return {match: '[', isOpener: false} + case '}': return {match: '{', isOpener: false} + default: return null + } +} + +function findCloser(text, startPos, opener, closer) { + var depth = 1 + var pos = startPos + while (pos < text.length) { + var ch = text.charAt(pos) + if (ch === opener) depth++ + else if (ch === closer) { + depth-- + if (depth === 0) return pos + } + pos++ + } + return -1 +} + +function findOpener(text, startPos, opener, closer) { + var depth = 1 + var pos = startPos + while (pos >= 0) { + var ch = text.charAt(pos) + if (ch === closer) depth++ + else if (ch === opener) { + depth-- + if (depth === 0) return pos + } + pos-- + } + return -1 +} + +// Select text between a matching delimiter pair on double-click. +// Returns true if a pair was found and the inner content selected. +function selectBracketPair(textEdit, mouseX, mouseY) { + var cursorPos = textEdit.positionAt(mouseX, mouseY) + var text = textEdit.getText(0, textEdit.length) + + if (cursorPos < 0 || cursorPos > text.length) return false + + var rightCh = cursorPos < text.length ? text.charAt(cursorPos) : '' + var leftCh = cursorPos > 0 ? text.charAt(cursorPos - 1) : '' + + // Quote pairs first. + var q = '' + var qPos = -1 + if (isQuote(rightCh)) { + q = rightCh + qPos = cursorPos + } else if (isQuote(leftCh)) { + q = leftCh + qPos = cursorPos - 1 + } + if (q !== '') { + var qOpen = -1 + var qClose = -1 + if (quoteIsOpener(text, qPos, q)) { + qOpen = qPos + qClose = findQuoteForward(text, qPos, q) + } else { + qClose = qPos + qOpen = findQuoteBackward(text, qPos, q) + } + if (qOpen < 0 || qClose < 0) return false + textEdit.select(qOpen + 1, qClose) + return true + } + + // Bracket pairs. + var rightMatch = matchingBracket(rightCh) + var leftMatch = matchingBracket(leftCh) + + var innerStart = -1 + var innerEnd = -1 + + if (rightMatch && rightMatch.isOpener) { + var closerPos = findCloser(text, cursorPos + 1, rightCh, rightMatch.match) + if (closerPos >= 0) { + innerStart = cursorPos + 1 + innerEnd = closerPos + } + } else if (leftMatch && !leftMatch.isOpener) { + var openerPos = findOpener(text, cursorPos - 2, leftMatch.match, leftCh) + if (openerPos >= 0) { + innerStart = openerPos + 1 + innerEnd = cursorPos - 1 + } + } else if (rightMatch && !rightMatch.isOpener) { + var openerPos2 = findOpener(text, cursorPos - 1, rightMatch.match, rightCh) + if (openerPos2 >= 0) { + innerStart = openerPos2 + 1 + innerEnd = cursorPos + } + } else if (leftMatch && leftMatch.isOpener) { + var closerPos2 = findCloser(text, cursorPos, leftCh, leftMatch.match) + if (closerPos2 >= 0) { + innerStart = cursorPos + innerEnd = closerPos2 + } + } + + if (innerStart >= 0 && innerEnd >= innerStart) { + textEdit.select(innerStart, innerEnd) + return true + } + return false +} + +// Select the plain word under the mouse (double-click word select). +function selectWordAt(textEdit, mouseX, mouseY) { + var cursorPos = textEdit.positionAt(mouseX, mouseY) + var text = textEdit.getText(0, textEdit.length) + if (cursorPos < 0 || cursorPos > text.length) return + + function isword(ch) { + return /[A-Za-z0-9_]/.test(ch) + } + + var start = cursorPos + while (start > 0 && isword(text.charAt(start - 1))) start-- + var end = cursorPos + while (end < text.length && isword(text.charAt(end))) end++ + + if (start === end) return + textEdit.select(start, end) +} diff --git a/kde/gui/qml.qrc b/kde/gui/qml.qrc index 10cc1a6..804498d 100644 --- a/kde/gui/qml.qrc +++ b/kde/gui/qml.qrc @@ -9,6 +9,7 @@ ChatBlockDelegate.qml SessionTree.qml ChatPane.qml + TextSelection.js ollie.svg