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.
This commit is contained in:
parent
794cecfa6c
commit
7cfd472193
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
<file>ChatBlockDelegate.qml</file>
|
||||
<file>SessionTree.qml</file>
|
||||
<file>ChatPane.qml</file>
|
||||
<file>TextSelection.js</file>
|
||||
<file>ollie.svg</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
|
|
|
|||
Loading…
Reference in New Issue