From 1cfa424220c676e1179d96ed1b602122210edb05 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 8 Oct 2026 17:23:16 +0200 Subject: [PATCH] gui: bracket-pair selection on double-click Double-clicking next to a bracket (()[]{}) selects the text between the matching pair, including nested pairs. Falls through to the default word-select when no bracket is adjacent. Also: B3-click on word now highlights it before plumbing. --- kde/gui/ChatBlockDelegate.qml | 206 +++++++++++++++++++++++++++++----- 1 file changed, 175 insertions(+), 31 deletions(-) diff --git a/kde/gui/ChatBlockDelegate.qml b/kde/gui/ChatBlockDelegate.qml index 28482d5..eb2befd 100644 --- a/kde/gui/ChatBlockDelegate.qml +++ b/kde/gui/ChatBlockDelegate.qml @@ -192,35 +192,55 @@ Column { // Fast path: single TextEdit for the concatenated HTML. // Diagrams are handled separately via the segments list. // Editable for scratchpad use - changes are local only, not persisted. - TextEdit { - id: mainTextEdit + Item { width: root.width + height: mainTextEdit.height visible: !hasDiagrams() - text: root.blockRenderedHtml - textFormat: Text.RichText - selectByMouse: true - persistentSelection: true - wrapMode: TextEdit.Wrap - font.family: theme.fontFamily || Qt.application.font.family - font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 - selectionColor: theme.highlightColor - selectedTextColor: theme.highlightedTextColor - // Acme-style mouse handling: B2=execute, B3=plumb/menu + TextEdit { + id: mainTextEdit + anchors.fill: parent + text: root.blockRenderedHtml + textFormat: Text.RichText + selectByMouse: true + persistentSelection: true + wrapMode: TextEdit.Wrap + font.family: theme.fontFamily || Qt.application.font.family + font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 + selectionColor: theme.highlightColor + selectedTextColor: theme.highlightedTextColor + } + + // Acme-style mouse handling: B2=execute, B3=plumb // B1+B2=cut, B1+B3=paste (chord detection) + // Double-click on bracket: select text between matching pair MouseArea { anchors.fill: parent - acceptedButtons: Qt.MiddleButton | Qt.RightButton - cursorShape: Qt.IBeamCursor + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton + z: 1 + + onDoubleClicked: function(mouse) { + if (mouse.button === Qt.LeftButton) { + // Try bracket-pair selection; fall through to word select if no bracket + if (selectBracketPair(mainTextEdit, mouse.x, mouse.y)) { + mouse.accepted = true + } else { + mouse.accepted = false // let TextEdit do word select + } + } + } onPressed: function(mouse) { - // Check if this is a chord (B1 already held) + // Left button single clicks pass through to TextEdit for selection + if (mouse.button === Qt.LeftButton) { + mouse.accepted = false + return + } + if (mouse.buttons & Qt.LeftButton) { if (mouse.button === Qt.MiddleButton) { - // B1+B2 = cut (snarf) mainTextEdit.cut() } else if (mouse.button === Qt.RightButton) { - // B1+B3 = paste mainTextEdit.paste() } mouse.accepted = true @@ -228,23 +248,19 @@ Column { } if (mouse.button === Qt.MiddleButton) { - // B2: Execute selection as prompt var sel = mainTextEdit.selectedText if (sel && sel.trim() !== "") { executeText(sel) } mouse.accepted = true } else if (mouse.button === Qt.RightButton) { - // B3: Plumb selection or word under cursor if (plumber.available()) { var sel = mainTextEdit.selectedText if (sel && sel.trim() !== "") { plumber.plumb(sel.trim(), sessionCwd()) } else { - // No selection - expand word at click position (acme-style) - var pos = mainTextEdit.positionAt(mouse.x, mouse.y) - var word = wordAtCursor(mainTextEdit, pos) - if (word) plumber.plumb(word, sessionCwd()) + // No selection - expand word and highlight it + selectAndPlumb(mainTextEdit, mouse.x, mouse.y) } } mouse.accepted = true @@ -329,16 +345,17 @@ Column { // Expand cursor position to surrounding word/path for plumbing // Matches acme's isfilec: alphanumeric + . - + / : @ + // Returns {start, end, word} or null if no word found function wordAtCursor(textEdit, cursorPos) { var text = textEdit.getText(0, textEdit.length) - if (cursorPos < 0 || cursorPos > text.length) return "" + if (cursorPos < 0 || cursorPos > text.length) return null // isfilec from acme: alphanumeric + . - + / : @ function isfilec(ch) { return /[a-zA-Z0-9.\-+\/:@]/.test(ch) } - // Find start of word (acme also allows isaddrc chars going left) + // Find start of word var start = cursorPos while (start > 0 && isfilec(text.charAt(start - 1))) { start-- @@ -350,8 +367,121 @@ Column { end++ } - if (start === end) return "" - return text.substring(start, end) + if (start === end) return null + return {start: start, end: end, word: text.substring(start, end)} + } + + // Select word at cursor and plumb it (with visual highlight) + function selectAndPlumb(textEdit, mouseX, mouseY) { + var pos = textEdit.positionAt(mouseX, mouseY) + var result = wordAtCursor(textEdit, pos) + if (result) { + // Select the word to show what's being plumbed + textEdit.select(result.start, result.end) + plumber.plumb(result.word, sessionCwd()) + } + } + + // 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 + } + } + + // 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 matching brackets on double-click + // Returns true if a bracket pair was found and 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 + + // Check char to the right of cursor + var rightCh = cursorPos < text.length ? text.charAt(cursorPos) : '' + var rightMatch = matchingBracket(rightCh) + + // Check char to the left of cursor + var leftCh = cursorPos > 0 ? text.charAt(cursorPos - 1) : '' + var leftMatch = matchingBracket(leftCh) + + var innerStart = -1 + var innerEnd = -1 + + // Prefer opener to the right, then closer to the left + if (rightMatch && rightMatch.isOpener) { + // Opener to right: find closer + 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 + 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 backwards + var openerPos = findOpener(text, cursorPos - 1, rightMatch.match, rightCh) + if (openerPos >= 0) { + innerStart = openerPos + 1 + innerEnd = cursorPos + } + } else if (leftMatch && leftMatch.isOpener) { + // Opener to left: find closer + var closerPos = findCloser(text, cursorPos, leftCh, leftMatch.match) + if (closerPos >= 0) { + innerStart = cursorPos + innerEnd = closerPos + } + } + + if (innerStart >= 0 && innerEnd >= innerStart) { + textEdit.select(innerStart, innerEnd) + return true + } + return false } // Text segment component @@ -373,12 +503,28 @@ Column { // Acme-style mouse handling with chord support // B1+B2=cut, B1+B3=paste (chord detection) + // Double-click on bracket: select text between matching pair MouseArea { anchors.fill: parent - acceptedButtons: Qt.MiddleButton | Qt.RightButton + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton cursorShape: Qt.IBeamCursor + onDoubleClicked: function(mouse) { + if (mouse.button === Qt.LeftButton) { + if (selectBracketPair(textEdit, mouse.x, mouse.y)) { + mouse.accepted = true + } else { + mouse.accepted = false + } + } + } + onPressed: function(mouse) { + if (mouse.button === Qt.LeftButton) { + mouse.accepted = false + return + } + if (mouse.buttons & Qt.LeftButton) { if (mouse.button === Qt.MiddleButton) { textEdit.cut() @@ -402,9 +548,7 @@ Column { if (sel && sel.trim() !== "") { plumber.plumb(sel.trim(), sessionCwd()) } else { - var pos = textEdit.positionAt(mouse.x, mouse.y) - var word = wordAtCursor(textEdit, pos) - if (word) plumber.plumb(word, sessionCwd()) + selectAndPlumb(textEdit, mouse.x, mouse.y) } } mouse.accepted = true