plugin: extend double-click selection to '' "" `` quote pairs

This commit is contained in:
Levi Neely 2026-10-08 17:33:29 +02:00
parent 376a190b52
commit 03f209bfd7
2 changed files with 90 additions and 2 deletions

View File

@ -185,11 +185,16 @@ Therefore the palette is replaced, not extended, and the replacement is built on
- Double-clicking next to a bracket (`()[]{}`) selects the text strictly between - Double-clicking next to a bracket (`()[]{}`) selects the text strictly between
the matching pair, **multi-line aware** and nesting-aware. Adjacency is checked the matching pair, **multi-line aware** and nesting-aware. Adjacency is checked
on both sides of the click; openers select forward, closers select backward. on both sides of the click; openers select forward, closers select backward.
- Double-clicking next to a quote (`''`, `""`, `` `` ``) selects the text strictly
between the enclosing pair. Quotes are symmetric and non-nesting, so matching is
**line-local**: direction is decided by the parity of same-kind quotes preceding
the clicked one (even → opener, scan forward; odd → closer, scan backward), via
`isQuote` / `quoteIsOpener` / `findQuoteForward` / `findQuoteBackward`.
- Implemented in the event filter via `selectBracketPairAt`: maps the click to a - Implemented in the event filter via `selectBracketPairAt`: maps the click to a
document cursor (`coordinatesToCursor`), reads neighbouring characters document cursor (`coordinatesToCursor`), reads neighbouring characters
(`characterAt`), scans with depth counting across line ends, and sets the (`characterAt`), scans with depth counting across line ends, and sets the
selection. When no bracket is adjacent it falls through to Kate's default selection. When no bracket or quote is adjacent it falls through to Kate's
double-click (word select). default double-click (word select).
- A left double-click leaves the button held, so it also arms the chord anchor: - A left double-click leaves the button held, so it also arms the chord anchor:
the user can go straight from a double-click (bracket- or word-select) into a the user can go straight from a double-click (bracket- or word-select) into a
left+middle / left+right chord without releasing. left+middle / left+right chord without releasing.

View File

@ -89,6 +89,16 @@ QChar matchingBracket(QChar c, bool &isOpener)
} }
} }
// Quote delimiters understood by the double-click selection feature. Unlike
// brackets these are symmetric (same opening and closing char) and do not nest,
// so matching is handled separately: scan the click's line for the enclosing
// pair of identical quote chars.
bool isQuote(QChar c)
{
const ushort u = c.unicode();
return u == '\'' || u == '"' || u == '`';
}
// Advance a cursor by one character, wrapping across line ends. Returns false // Advance a cursor by one character, wrapping across line ends. Returns false
// at end of document. // at end of document.
bool stepForward(KTextEditor::Document *doc, KTextEditor::Cursor &c) bool stepForward(KTextEditor::Document *doc, KTextEditor::Cursor &c)
@ -164,6 +174,45 @@ KTextEditor::Cursor findOpener(KTextEditor::Document *doc, KTextEditor::Cursor p
return KTextEditor::Cursor::invalid(); return KTextEditor::Cursor::invalid();
} }
// Count occurrences of \a q on \a line in columns [0, col). An even count means
// the quote at \a col opens a pair; odd means it closes one. Quotes do not nest,
// so parity of preceding quotes decides direction.
bool quoteIsOpener(KTextEditor::Document *doc, int line, int col, QChar q)
{
int count = 0;
for (int c = 0; c < col; ++c) {
if (doc->characterAt(KTextEditor::Cursor(line, c)) == q) {
++count;
}
}
return (count % 2) == 0;
}
// Find the matching quote char of the same kind on \a line, searching columns
// (col, lineLength) forward. Returns the closer's cursor, or invalid if none.
KTextEditor::Cursor findQuoteForward(KTextEditor::Document *doc, int line, int col, QChar q)
{
const int len = doc->lineLength(line);
for (int c = col + 1; c < len; ++c) {
if (doc->characterAt(KTextEditor::Cursor(line, c)) == q) {
return KTextEditor::Cursor(line, c);
}
}
return KTextEditor::Cursor::invalid();
}
// Find the matching quote char of the same kind on \a line, searching columns
// [0, col) backward. Returns the opener's cursor, or invalid if none.
KTextEditor::Cursor findQuoteBackward(KTextEditor::Document *doc, int line, int col, QChar q)
{
for (int c = col - 1; c >= 0; --c) {
if (doc->characterAt(KTextEditor::Cursor(line, c)) == q) {
return KTextEditor::Cursor(line, c);
}
}
return KTextEditor::Cursor::invalid();
}
// Prefix marking a palette id that is a ":" command string to execute rather // Prefix marking a palette id that is a ":" command string to execute rather
// than a QAction to trigger. // than a QAction to trigger.
const QLatin1String kCmdPrefix("ollie:cmd:"); const QLatin1String kCmdPrefix("ollie:cmd:");
@ -1167,6 +1216,40 @@ bool OllieView::selectBracketPairAt(KTextEditor::View *view, const QPoint &globa
const bool haveLeft = stepBackward(doc, leftCur); const bool haveLeft = stepBackward(doc, leftCur);
const QChar leftCh = haveLeft ? doc->characterAt(leftCur) : QChar(); const QChar leftCh = haveLeft ? doc->characterAt(leftCur) : QChar();
// Quote pairs ('' "" ``) are symmetric and non-nesting: resolve them on the
// click's line and select the content between the enclosing pair. Handled
// before brackets since the two delimiter sets are disjoint.
{
QChar q;
int qCol = -1;
if (isQuote(rightCh)) {
q = rightCh;
qCol = at.column();
} else if (haveLeft && isQuote(leftCh)) {
q = leftCh;
qCol = leftCur.column();
}
if (!q.isNull()) {
const int line = at.line();
KTextEditor::Cursor qOpen = KTextEditor::Cursor::invalid();
KTextEditor::Cursor qClose = KTextEditor::Cursor::invalid();
if (quoteIsOpener(doc, line, qCol, q)) {
qOpen = KTextEditor::Cursor(line, qCol);
qClose = findQuoteForward(doc, line, qCol, q);
} else {
qClose = KTextEditor::Cursor(line, qCol);
qOpen = findQuoteBackward(doc, line, qCol, q);
}
if (!qOpen.isValid() || !qClose.isValid()) {
return false; // unbalanced quote on this line
}
const KTextEditor::Cursor innerStart(line, qOpen.column() + 1);
view->setSelection(KTextEditor::Range(innerStart, qClose));
view->setCursorPosition(qClose);
return true;
}
}
bool rOpener = false; bool rOpener = false;
const QChar rMatch = matchingBracket(rightCh, rOpener); const QChar rMatch = matchingBracket(rightCh, rOpener);
bool lOpener = false; bool lOpener = false;