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.
This commit is contained in:
Levi Neely 2026-10-08 17:23:16 +02:00
parent 36e8b0abd3
commit 1cfa424220
1 changed files with 175 additions and 31 deletions

View File

@ -192,35 +192,55 @@ Column {
// Fast path: single TextEdit for the concatenated HTML. // Fast path: single TextEdit for the concatenated HTML.
// Diagrams are handled separately via the segments list. // Diagrams are handled separately via the segments list.
// Editable for scratchpad use - changes are local only, not persisted. // Editable for scratchpad use - changes are local only, not persisted.
TextEdit { Item {
id: mainTextEdit
width: root.width width: root.width
height: mainTextEdit.height
visible: !hasDiagrams() 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) // B1+B2=cut, B1+B3=paste (chord detection)
// Double-click on bracket: select text between matching pair
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
acceptedButtons: Qt.MiddleButton | Qt.RightButton acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
cursorShape: Qt.IBeamCursor 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) { 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.buttons & Qt.LeftButton) {
if (mouse.button === Qt.MiddleButton) { if (mouse.button === Qt.MiddleButton) {
// B1+B2 = cut (snarf)
mainTextEdit.cut() mainTextEdit.cut()
} else if (mouse.button === Qt.RightButton) { } else if (mouse.button === Qt.RightButton) {
// B1+B3 = paste
mainTextEdit.paste() mainTextEdit.paste()
} }
mouse.accepted = true mouse.accepted = true
@ -228,23 +248,19 @@ Column {
} }
if (mouse.button === Qt.MiddleButton) { if (mouse.button === Qt.MiddleButton) {
// B2: Execute selection as prompt
var sel = mainTextEdit.selectedText var sel = mainTextEdit.selectedText
if (sel && sel.trim() !== "") { if (sel && sel.trim() !== "") {
executeText(sel) executeText(sel)
} }
mouse.accepted = true mouse.accepted = true
} else if (mouse.button === Qt.RightButton) { } else if (mouse.button === Qt.RightButton) {
// B3: Plumb selection or word under cursor
if (plumber.available()) { if (plumber.available()) {
var sel = mainTextEdit.selectedText var sel = mainTextEdit.selectedText
if (sel && sel.trim() !== "") { if (sel && sel.trim() !== "") {
plumber.plumb(sel.trim(), sessionCwd()) plumber.plumb(sel.trim(), sessionCwd())
} else { } else {
// No selection - expand word at click position (acme-style) // No selection - expand word and highlight it
var pos = mainTextEdit.positionAt(mouse.x, mouse.y) selectAndPlumb(mainTextEdit, mouse.x, mouse.y)
var word = wordAtCursor(mainTextEdit, pos)
if (word) plumber.plumb(word, sessionCwd())
} }
} }
mouse.accepted = true mouse.accepted = true
@ -329,16 +345,17 @@ Column {
// Expand cursor position to surrounding word/path for plumbing // Expand cursor position to surrounding word/path for plumbing
// Matches acme's isfilec: alphanumeric + . - + / : @ // Matches acme's isfilec: alphanumeric + . - + / : @
// Returns {start, end, word} or null if no word found
function wordAtCursor(textEdit, cursorPos) { function wordAtCursor(textEdit, cursorPos) {
var text = textEdit.getText(0, textEdit.length) 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 + . - + / : @ // isfilec from acme: alphanumeric + . - + / : @
function isfilec(ch) { function isfilec(ch) {
return /[a-zA-Z0-9.\-+\/:@]/.test(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 var start = cursorPos
while (start > 0 && isfilec(text.charAt(start - 1))) { while (start > 0 && isfilec(text.charAt(start - 1))) {
start-- start--
@ -350,8 +367,121 @@ Column {
end++ end++
} }
if (start === end) return "" if (start === end) return null
return text.substring(start, end) 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 // Text segment component
@ -373,12 +503,28 @@ Column {
// Acme-style mouse handling with chord support // Acme-style mouse handling with chord support
// B1+B2=cut, B1+B3=paste (chord detection) // B1+B2=cut, B1+B3=paste (chord detection)
// Double-click on bracket: select text between matching pair
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
acceptedButtons: Qt.MiddleButton | Qt.RightButton acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
cursorShape: Qt.IBeamCursor 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) { onPressed: function(mouse) {
if (mouse.button === Qt.LeftButton) {
mouse.accepted = false
return
}
if (mouse.buttons & Qt.LeftButton) { if (mouse.buttons & Qt.LeftButton) {
if (mouse.button === Qt.MiddleButton) { if (mouse.button === Qt.MiddleButton) {
textEdit.cut() textEdit.cut()
@ -402,9 +548,7 @@ Column {
if (sel && sel.trim() !== "") { if (sel && sel.trim() !== "") {
plumber.plumb(sel.trim(), sessionCwd()) plumber.plumb(sel.trim(), sessionCwd())
} else { } else {
var pos = textEdit.positionAt(mouse.x, mouse.y) selectAndPlumb(textEdit, mouse.x, mouse.y)
var word = wordAtCursor(textEdit, pos)
if (word) plumber.plumb(word, sessionCwd())
} }
} }
mouse.accepted = true mouse.accepted = true