Acme keys: resolve shortcut ambiguity by stripping conflicting bindings
Adding our Ctrl+A/E/H/U/W actions alongside Kate's own (Select All, Close, lowercase, …) left both claiming the same sequence, so Qt reported ambiguous shortcuts. clearConflictingShortcuts() now walks every GUI-client action collection and removes our sequences from any foreign action, also clearing their KActionCollection default shortcuts so they are not restored. The sweep runs at setup, once more deferred to the event loop (after the GUI factory merges all clients), and on every viewCreated so new documents don't re-introduce the conflict. Also implements the five Acme motions against the View/Document API with semantics ported from plan9port acme text.c. All 5 ctest suites green.
This commit is contained in:
parent
9bcbece09a
commit
1b56cca1a2
14
docs/PLAN.md
14
docs/PLAN.md
|
|
@ -156,6 +156,20 @@ Therefore the palette is replaced, not extended, and the replacement is built on
|
||||||
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.
|
||||||
|
|
||||||
|
### Acme line-editing keys — DONE
|
||||||
|
- Keys bound (overriding Kate/KDE defaults, `ApplicationShortcut`):
|
||||||
|
**C-a** beginning of line, **C-e** end of line, **C-h** erase char back,
|
||||||
|
**C-u** erase to line start, **C-w** erase word back. Semantics ported from
|
||||||
|
plan9port acme `text.c` (`^A`/`^U` stop at the line's newline; `^W` skips
|
||||||
|
non-alphanumerics then eats the alphanumeric run; isalnum excludes `_`).
|
||||||
|
- Implemented directly against the View/Document API (`cursorPosition`,
|
||||||
|
`setCursorPosition`, `removeText`, `characterAt`, `lineLength`).
|
||||||
|
- Shortcut conflicts (e.g. `Ctrl+A` = Select All, `Ctrl+W` = Close) are resolved
|
||||||
|
by stripping our sequences from every other GUI-client action
|
||||||
|
(`clearConflictingShortcuts`), which also clears their `KActionCollection`
|
||||||
|
defaults. The sweep runs at setup, deferred once via the event loop (after the
|
||||||
|
GUI factory merges all clients), and again on every `viewCreated`.
|
||||||
|
|
||||||
### M5 — Command vocabulary + project model + switchers — NEXT
|
### M5 — Command vocabulary + project model + switchers — NEXT
|
||||||
- `:`-verb pack via `KTextEditor::Command`: `sort`, `align`, `json`, `b64`,
|
- `:`-verb pack via `KTextEditor::Command`: `sort`, `align`, `json`, `b64`,
|
||||||
`uuid`, `case`, `pipe <shell>`, etc. These populate M-x and the radial.
|
`uuid`, `case`, `pipe <shell>`, etc. These populate M-x and the radial.
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow)
|
||||||
}
|
}
|
||||||
|
|
||||||
setupRadials();
|
setupRadials();
|
||||||
|
setupAcmeKeys();
|
||||||
|
|
||||||
// One application-wide filter serves both the mouse-button radial gesture
|
// One application-wide filter serves both the mouse-button radial gesture
|
||||||
// and the Acme-style left+middle chord, catching presses no matter which
|
// and the Acme-style left+middle chord, catching presses no matter which
|
||||||
|
|
@ -666,6 +667,194 @@ void OllieView::onRadialActivated(const QString &actionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Acme-style line-editing keys
|
||||||
|
//
|
||||||
|
// Semantics ported from plan9port acme text.c (textbswidth / Ktype):
|
||||||
|
// ^A move to beginning of line (stop at the preceding newline)
|
||||||
|
// ^E move to end of line (stop at the next newline)
|
||||||
|
// ^H erase one character back
|
||||||
|
// ^U erase back to the beginning of line
|
||||||
|
// ^W erase back one word: skip trailing non-alphanumerics, then the
|
||||||
|
// alphanumeric run (Acme uses C isalnum, which excludes '_')
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void OllieView::setupAcmeKeys()
|
||||||
|
{
|
||||||
|
QWidget *w = m_mainWindow->window();
|
||||||
|
|
||||||
|
struct Binding {
|
||||||
|
const char *name;
|
||||||
|
QKeySequence seq;
|
||||||
|
void (OllieView::*slot)();
|
||||||
|
};
|
||||||
|
const Binding bindings[] = {
|
||||||
|
{"ollie_acme_bol", QKeySequence(Qt::CTRL | Qt::Key_A), &OllieView::acmeBeginningOfLine},
|
||||||
|
{"ollie_acme_eol", QKeySequence(Qt::CTRL | Qt::Key_E), &OllieView::acmeEndOfLine},
|
||||||
|
{"ollie_acme_erase_char", QKeySequence(Qt::CTRL | Qt::Key_H), &OllieView::acmeEraseChar},
|
||||||
|
{"ollie_acme_erase_line", QKeySequence(Qt::CTRL | Qt::Key_U), &OllieView::acmeEraseLine},
|
||||||
|
{"ollie_acme_erase_word", QKeySequence(Qt::CTRL | Qt::Key_W), &OllieView::acmeEraseWord},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const Binding &b : bindings) {
|
||||||
|
auto *action = new QAction(this);
|
||||||
|
action->setObjectName(QString::fromLatin1(b.name));
|
||||||
|
action->setShortcut(b.seq);
|
||||||
|
// Override the Kate/KDE defaults (Select All, Close, …) inside the app.
|
||||||
|
action->setShortcutContext(Qt::ApplicationShortcut);
|
||||||
|
connect(action, &QAction::triggered, this, b.slot);
|
||||||
|
if (w) {
|
||||||
|
w->addAction(action);
|
||||||
|
}
|
||||||
|
m_acmeSequences.push_back(b.seq);
|
||||||
|
m_acmeActions.push_back(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove our sequences from any pre-existing Kate/KDE action so Qt does not
|
||||||
|
// report an ambiguous shortcut. New views bring their own copies of these
|
||||||
|
// actions, so re-run the sweep whenever a view is created. Also defer one
|
||||||
|
// sweep to the event loop, after the GUI factory has merged all clients.
|
||||||
|
clearConflictingShortcuts();
|
||||||
|
QMetaObject::invokeMethod(this, &OllieView::clearConflictingShortcuts,
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
connect(m_mainWindow, &KTextEditor::MainWindow::viewCreated, this,
|
||||||
|
[this](KTextEditor::View *) { clearConflictingShortcuts(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void OllieView::clearConflictingShortcuts()
|
||||||
|
{
|
||||||
|
KXMLGUIFactory *factory = m_mainWindow->guiFactory();
|
||||||
|
if (!factory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the quick-lookup set of sequences we own.
|
||||||
|
const QList<KXMLGUIClient *> clients = factory->clients();
|
||||||
|
for (KXMLGUIClient *client : clients) {
|
||||||
|
KActionCollection *coll = client->actionCollection();
|
||||||
|
if (!coll) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QList<QAction *> actions = coll->actions();
|
||||||
|
for (QAction *a : actions) {
|
||||||
|
if (!a || m_acmeActions.contains(a)) {
|
||||||
|
continue; // never strip our own actions
|
||||||
|
}
|
||||||
|
const QList<QKeySequence> scs = a->shortcuts();
|
||||||
|
if (scs.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
QList<QKeySequence> kept;
|
||||||
|
kept.reserve(scs.size());
|
||||||
|
for (const QKeySequence &s : scs) {
|
||||||
|
if (!m_acmeSequences.contains(s)) {
|
||||||
|
kept.push_back(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (kept.size() != scs.size()) {
|
||||||
|
a->setShortcuts(kept);
|
||||||
|
// Also clear any KActionCollection default so it is not restored.
|
||||||
|
KActionCollection::setDefaultShortcuts(a, kept);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void OllieView::acmeBeginningOfLine()
|
||||||
|
{
|
||||||
|
KTextEditor::View *view = m_mainWindow->activeView();
|
||||||
|
if (!view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const KTextEditor::Cursor c = view->cursorPosition();
|
||||||
|
view->setCursorPosition(KTextEditor::Cursor(c.line(), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void OllieView::acmeEndOfLine()
|
||||||
|
{
|
||||||
|
KTextEditor::View *view = m_mainWindow->activeView();
|
||||||
|
if (!view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KTextEditor::Document *doc = view->document();
|
||||||
|
const KTextEditor::Cursor c = view->cursorPosition();
|
||||||
|
view->setCursorPosition(KTextEditor::Cursor(c.line(), doc->lineLength(c.line())));
|
||||||
|
}
|
||||||
|
|
||||||
|
void OllieView::acmeEraseChar()
|
||||||
|
{
|
||||||
|
KTextEditor::View *view = m_mainWindow->activeView();
|
||||||
|
if (!view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KTextEditor::Document *doc = view->document();
|
||||||
|
KTextEditor::Cursor q0 = view->cursorPosition();
|
||||||
|
KTextEditor::Cursor from = q0;
|
||||||
|
if (!stepBackward(doc, from)) {
|
||||||
|
return; // at start of document
|
||||||
|
}
|
||||||
|
doc->removeText(KTextEditor::Range(from, q0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void OllieView::acmeEraseLine()
|
||||||
|
{
|
||||||
|
KTextEditor::View *view = m_mainWindow->activeView();
|
||||||
|
if (!view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KTextEditor::Document *doc = view->document();
|
||||||
|
const KTextEditor::Cursor q0 = view->cursorPosition();
|
||||||
|
// Erase back to the beginning of the line; if already at column 0, eat the
|
||||||
|
// preceding newline (join with the previous line), matching Acme's ^U.
|
||||||
|
if (q0.column() > 0) {
|
||||||
|
doc->removeText(KTextEditor::Range(KTextEditor::Cursor(q0.line(), 0), q0));
|
||||||
|
} else if (q0.line() > 0) {
|
||||||
|
const int prevLen = doc->lineLength(q0.line() - 1);
|
||||||
|
doc->removeText(KTextEditor::Range(KTextEditor::Cursor(q0.line() - 1, prevLen), q0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void OllieView::acmeEraseWord()
|
||||||
|
{
|
||||||
|
KTextEditor::View *view = m_mainWindow->activeView();
|
||||||
|
if (!view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KTextEditor::Document *doc = view->document();
|
||||||
|
const KTextEditor::Cursor q0 = view->cursorPosition();
|
||||||
|
|
||||||
|
// Replicate textbswidth(^W): walk back, stopping at a newline (eating it
|
||||||
|
// only if we are standing on it); otherwise skip non-alphanumerics, then
|
||||||
|
// consume the following alphanumeric run.
|
||||||
|
KTextEditor::Cursor q = q0;
|
||||||
|
bool skipping = true;
|
||||||
|
while (true) {
|
||||||
|
KTextEditor::Cursor prev = q;
|
||||||
|
if (!stepBackward(doc, prev)) {
|
||||||
|
break; // start of document
|
||||||
|
}
|
||||||
|
// Crossing to a previous line is Acme's newline stop. Eat the newline
|
||||||
|
// only if we are standing on it (nothing erased yet on this line).
|
||||||
|
if (prev.line() != q.line()) {
|
||||||
|
if (q == q0) {
|
||||||
|
q = prev;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const QChar r = doc->characterAt(prev);
|
||||||
|
const bool alnum = r.isLetterOrNumber();
|
||||||
|
if (alnum && skipping) {
|
||||||
|
skipping = false;
|
||||||
|
} else if (!alnum && !skipping) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
q = prev;
|
||||||
|
}
|
||||||
|
if (q != q0) {
|
||||||
|
doc->removeText(KTextEditor::Range(q, q0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace katecustom
|
} // namespace katecustom
|
||||||
|
|
||||||
// K_PLUGIN_FACTORY generates the factory and the moc-level registration. The
|
// K_PLUGIN_FACTORY generates the factory and the moc-level registration. The
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,17 @@ private:
|
||||||
void showRadialAt(int defIndex, const QPoint &globalPos); // centre on a point
|
void showRadialAt(int defIndex, const QPoint &globalPos); // centre on a point
|
||||||
void onRadialActivated(const QString &actionId);
|
void onRadialActivated(const QString &actionId);
|
||||||
|
|
||||||
|
// Acme-style line-editing keys, bound to override the Kate/KDE defaults.
|
||||||
|
void setupAcmeKeys();
|
||||||
|
// Strip our Acme shortcuts from any other action that also claims them, to
|
||||||
|
// avoid Qt "ambiguous shortcut" warnings. Safe to call repeatedly.
|
||||||
|
void clearConflictingShortcuts();
|
||||||
|
void acmeBeginningOfLine(); // C-a
|
||||||
|
void acmeEndOfLine(); // C-e
|
||||||
|
void acmeEraseChar(); // C-h
|
||||||
|
void acmeEraseLine(); // C-u
|
||||||
|
void acmeEraseWord(); // C-w
|
||||||
|
|
||||||
// Catches configured mouse-button gestures inside the active view.
|
// Catches configured mouse-button gestures inside the active view.
|
||||||
bool eventFilter(QObject *obj, QEvent *event) override;
|
bool eventFilter(QObject *obj, QEvent *event) override;
|
||||||
|
|
||||||
|
|
@ -97,6 +108,8 @@ private:
|
||||||
bool m_leftDownInView = false; // LMB held inside the view (Acme chord anchor)
|
bool m_leftDownInView = false; // LMB held inside the view (Acme chord anchor)
|
||||||
bool m_swallowMiddleRelease = false; // swallow the release paired with a consumed middle press
|
bool m_swallowMiddleRelease = false; // swallow the release paired with a consumed middle press
|
||||||
bool m_swallowRightRelease = false; // swallow the release paired with a left+right paste chord
|
bool m_swallowRightRelease = false; // swallow the release paired with a left+right paste chord
|
||||||
|
QList<QKeySequence> m_acmeSequences; // shortcuts we own (for conflict clearing)
|
||||||
|
QList<QPointer<QAction>> m_acmeActions; // our Acme actions (never cleared)
|
||||||
QHash<QString, QPointer<QAction>> m_actionsById;
|
QHash<QString, QPointer<QAction>> m_actionsById;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ QList<RadialDef> RadialConfig::builtinDefault()
|
||||||
// Multi-cursor on every selected line: a quintessential in-place action
|
// Multi-cursor on every selected line: a quintessential in-place action
|
||||||
// (select lines → gesture → keep typing). Referenced by visible text so
|
// (select lines → gesture → keep typing). Referenced by visible text so
|
||||||
// it resolves regardless of the Kate action's objectName.
|
// it resolves regardless of the Kate action's objectName.
|
||||||
leaf(QStringLiteral("Cursors to Line Ends"),
|
leaf(QStringLiteral("Multi-Cursor"),
|
||||||
QStringLiteral("Add Cursors to Line Ends")),
|
QStringLiteral("Add Cursors to Line Ends")),
|
||||||
more,
|
more,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue