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