diff --git a/AGENTS.md b/AGENTS.md index 60c9d3f..1c84277 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ per `.so`. | `deft_palette` | `[deft:util] palette` | `src/palette/` | `palette`, `deftcommands_lib` | | `deft_switch` | `[deft:util] switch` | `src/switch/` | `palette`, `project_lib` | | `deft_radial` | `[deft:util] radial` | `src/radial/` | `radial` | +| `deft_sloppyfocus` | `[deft:util] sloppyfocus` | `src/sloppyfocus/` | self-contained | | `deft_acme` | `[deft:nineify] acme` | `src/acme/` | self-contained | | `deft_sam` | `[deft:nineify] sam` | `src/sam/` | `sam_lib` | | `deft_plumb` | `[deft:nineify] plumb`| `src/plumb/` | `plumb_lib` | diff --git a/README.md b/README.md index 1df8afc..46a22ea 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ ctest --test-dir build # or via ctest ## Install the plugins into Kate -Deft is **one `.so` per feature**, not a monolith. The build produces six +Deft is **one `.so` per feature**, not a monolith. The build produces seven KTextEditor plugins under `build/bin/kf6/ktexteditor/`: | Plugin file | Display name | Feature | @@ -72,6 +72,7 @@ KTextEditor plugins under `build/bin/kf6/ktexteditor/`: | `deft_palette.so`| `[deft:util] palette` | Command palette (`Alt+X` / M-x) over every window action + the `:`-verbs + the switchers, frecency-ranked | | `deft_switch.so` | `[deft:util] switch` | Project **Go to File** (`Alt+P`) and **Go to Symbol** (`Alt+G`) | | `deft_radial.so` | `[deft:util] radial` | Radial caret menu (`Alt+R` / right-mouse) | +| `deft_sloppyfocus.so` | `[deft:util] sloppyfocus` | Focus follows the mouse into the view/pane under the pointer | | `deft_acme.so` | `[deft:nineify] acme` | Acme editing gestures (line keys, pair selection, mouse chords) | | `deft_sam.so` | `[deft:nineify] sam` | `sam`-style structural regular expressions | | `deft_plumb.so` | `[deft:nineify] plumb`| Plan 9 plumber (open under cursor) | @@ -233,6 +234,21 @@ through a list. default is used. See [docs/radials.example.json](docs/radials.example.json) for the format. +### `[deft:util] sloppyfocus` — focus follows mouse + +Keyboard focus follows the pointer: move the mouse into an editor view or tool +pane and, after a short hover delay, that pane gets keyboard focus — no click. +The acme way of working, where the mouse and the keyboard target the same place. + +- Works for editor views and focusable tool panes (terminal, file tree, etc.). +- **Focus-stealing prevention:** a configurable hover **delay** and a **minimum + stay time** stop rapid bouncing; focus is suppressed while a modal dialog is + open or when a floating widget in another window holds focus. +- Configure it in Settings → Configure Kate → Plugins → **Sloppy Focus** + (delay, minimum stay time, raise-on-focus, dialog/floating guards). +- Pairs naturally with `radial`'s pointer-follows-focus warping — one moves the + pointer to the focused pane, the other moves focus to the pointed-at pane. + ### `[deft:nineify] acme` — acme editing gestures ![Acme-style bracket/quote pair selection in Kate](docs/img/deft-acme-select.png) @@ -303,6 +319,8 @@ src/ radialplugin.{h,cpp} [deft:util] radial — caret menu plugin radialplugin.json plugin metadata test_*.cpp QTest suites + sloppyfocus/ + sloppyfocusplugin.{h,cpp,json} [deft:util] sloppyfocus — focus follows mouse commands/ texttransforms.{h,cpp} pure text transforms (headless) deftcommands.{h,cpp} ":"-verb KTextEditor::Command pack (used by palette) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0899773..936c774 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -8,4 +8,5 @@ if(KF6TextEditor_FOUND) add_subdirectory(acme) add_subdirectory(commands) add_subdirectory(switch) + add_subdirectory(sloppyfocus) endif() diff --git a/src/sloppyfocus/CMakeLists.txt b/src/sloppyfocus/CMakeLists.txt new file mode 100644 index 0000000..6fc25de --- /dev/null +++ b/src/sloppyfocus/CMakeLists.txt @@ -0,0 +1,21 @@ +# sloppyfocus — focus-follows-mouse ([deft:util] sloppyfocus). +# +# Self-contained plugin (no separate lib). Needs KF6::TextEditor; the config +# page persists settings via KF6::ConfigCore (KSharedConfig/KConfigGroup). +if(NOT KF6TextEditor_FOUND) + return() +endif() + +find_package(KF6Config QUIET CONFIG) + +kcoreaddons_add_plugin(deft_sloppyfocus + SOURCES sloppyfocusplugin.cpp sloppyfocusplugin.h + INSTALL_NAMESPACE "kf6/ktexteditor" +) +target_link_libraries(deft_sloppyfocus PRIVATE + KF6::TextEditor + KF6::CoreAddons + KF6::I18n + KF6::ConfigCore + Qt6::Widgets +) diff --git a/src/sloppyfocus/sloppyfocusplugin.cpp b/src/sloppyfocus/sloppyfocusplugin.cpp new file mode 100644 index 0000000..c17b2df --- /dev/null +++ b/src/sloppyfocus/sloppyfocusplugin.cpp @@ -0,0 +1,387 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "sloppyfocusplugin.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace deft +{ + +namespace +{ +const QString configGroup() { return QStringLiteral("deft_sloppyfocus"); } +const char *KEY_FOCUS_DELAY = "FocusDelay"; +const char *KEY_MIN_STAY = "MinimumStayTime"; +const char *KEY_RAISE = "RaiseOnFocus"; +const char *KEY_IGNORE_DIALOGS = "IgnoreDialogs"; +const char *KEY_IGNORE_FLOATING = "IgnoreFloatingWidgets"; +} // namespace + +// ============================================================ +// SloppyFocusPlugin +// ============================================================ + +SloppyFocusPlugin::SloppyFocusPlugin(QObject *parent, const QVariantList &args) + : KTextEditor::Plugin(parent) +{ + Q_UNUSED(args) + readConfig(); +} + +SloppyFocusPlugin::~SloppyFocusPlugin() = default; + +QObject *SloppyFocusPlugin::createView(KTextEditor::MainWindow *mainWindow) +{ + return new SloppyFocusView(this, mainWindow); +} + +int SloppyFocusPlugin::configPages() const +{ + return 1; +} + +KTextEditor::ConfigPage *SloppyFocusPlugin::configPage(int number, QWidget *parent) +{ + if (number == 0) { + return new SloppyFocusConfigPage(this, parent); + } + return nullptr; +} + +void SloppyFocusPlugin::setFocusDelay(int ms) +{ + m_focusDelay = qBound(0, ms, 5000); +} + +void SloppyFocusPlugin::setMinimumStayTime(int ms) +{ + m_minimumStayTime = qBound(0, ms, 5000); +} + +void SloppyFocusPlugin::setRaiseOnFocus(bool raise) +{ + m_raiseOnFocus = raise; +} + +void SloppyFocusPlugin::setIgnoreDialogs(bool ignore) +{ + m_ignoreDialogs = ignore; +} + +void SloppyFocusPlugin::setIgnoreFloatingWidgets(bool ignore) +{ + m_ignoreFloatingWidgets = ignore; +} + +void SloppyFocusPlugin::readConfig() +{ + KConfigGroup cg(KSharedConfig::openConfig(), configGroup()); + m_focusDelay = cg.readEntry(KEY_FOCUS_DELAY, 150); + m_minimumStayTime = cg.readEntry(KEY_MIN_STAY, 50); + m_raiseOnFocus = cg.readEntry(KEY_RAISE, false); + m_ignoreDialogs = cg.readEntry(KEY_IGNORE_DIALOGS, true); + m_ignoreFloatingWidgets = cg.readEntry(KEY_IGNORE_FLOATING, true); +} + +void SloppyFocusPlugin::writeConfig() +{ + KConfigGroup cg(KSharedConfig::openConfig(), configGroup()); + cg.writeEntry(KEY_FOCUS_DELAY, m_focusDelay); + cg.writeEntry(KEY_MIN_STAY, m_minimumStayTime); + cg.writeEntry(KEY_RAISE, m_raiseOnFocus); + cg.writeEntry(KEY_IGNORE_DIALOGS, m_ignoreDialogs); + cg.writeEntry(KEY_IGNORE_FLOATING, m_ignoreFloatingWidgets); + cg.sync(); + Q_EMIT configChanged(); +} + +// ============================================================ +// SloppyFocusView — event filter and focus switching logic +// ============================================================ + +SloppyFocusView::SloppyFocusView(SloppyFocusPlugin *plugin, KTextEditor::MainWindow *mainWindow) + : QObject(mainWindow->window()) + , m_plugin(plugin) + , m_mainWindow(mainWindow) +{ + m_focusTimer.setSingleShot(true); + connect(&m_focusTimer, &QTimer::timeout, this, &SloppyFocusView::activatePendingPane); + connect(m_plugin, &SloppyFocusPlugin::configChanged, this, &SloppyFocusView::onConfigChanged); + + m_lastFocusChange.start(); + + // Install event filter on QApplication to see Enter events for ALL widgets. + // A filter on just the main window won't propagate to children. + qApp->installEventFilter(this); +} + +SloppyFocusView::~SloppyFocusView() +{ + m_focusTimer.stop(); + qApp->removeEventFilter(this); +} + +void SloppyFocusView::onConfigChanged() +{ + // Timer interval is updated on next trigger; nothing else needed +} + +bool SloppyFocusView::eventFilter(QObject *obj, QEvent *event) +{ + if (event->type() != QEvent::Enter) { + return false; + } + + auto *widget = qobject_cast(obj); + if (!widget) { + return false; + } + + // Only handle widgets belonging to our main window + if (widget->window() != m_mainWindow->window()) { + return false; + } + + // Find the focusable pane (editor view, tool view, terminal, etc.) + QWidget *pane = paneForWidget(widget); + if (!pane) { + return false; + } + + // Don't re-trigger for the pane that already has focus + if (pane == m_currentPane) { + m_focusTimer.stop(); + m_pendingPane = nullptr; + return false; + } + + // Focus-stealing prevention: check minimum stay time + if (m_plugin->minimumStayTime() > 0 && + m_lastFocusChange.elapsed() < m_plugin->minimumStayTime()) { + return false; + } + + // Check if we should ignore due to active dialog + if (shouldIgnoreFocus()) { + return false; + } + + // Schedule focus change after delay + m_pendingPane = pane; + m_focusTimer.start(m_plugin->focusDelay()); + + return false; +} + +void SloppyFocusView::activatePendingPane() +{ + if (!m_pendingPane) { + return; + } + + QWidget *pane = m_pendingPane; + m_pendingPane = nullptr; + + if (!pane->isVisible()) { + return; + } + + // Verify mouse is still within the pane's geometry + QPoint globalPos = QCursor::pos(); + QPoint paneTopLeft = pane->mapToGlobal(QPoint(0, 0)); + QRect globalRect(paneTopLeft, pane->size()); + if (!globalRect.contains(globalPos)) { + return; + } + + // Move keyboard focus to the pane + pane->setFocus(); + m_currentPane = pane; + m_lastFocusChange.restart(); + + if (m_plugin->raiseOnFocus()) { + pane->raise(); + } +} + +QWidget *SloppyFocusView::paneForWidget(QWidget *widget) const +{ + // Walk up the widget hierarchy to find the nearest focusable pane. + // Priority: KTextEditor::View first, then any widget with a keyboard + // focus policy (tool views, terminals, file browsers, etc.). + QWidget *w = widget; + QWidget *focusCandidate = nullptr; + + while (w && w != m_mainWindow->window()) { + // KTextEditor::View is always a valid pane + if (qobject_cast(w)) { + return w; + } + + // Track the deepest widget that accepts keyboard focus as a + // fallback. This catches tool views, terminals, tree views, etc. + if (!focusCandidate) { + auto policy = w->focusPolicy(); + if (policy == Qt::StrongFocus || policy == Qt::WheelFocus) { + focusCandidate = w; + } + } + + w = w->parentWidget(); + } + + return focusCandidate; +} + +bool SloppyFocusView::shouldIgnoreFocus() const +{ + // Ignore if a modal dialog is active + if (m_plugin->ignoreDialogs()) { + if (QApplication::activeModalWidget()) { + return true; + } + } + + // Ignore if a floating/popup widget has focus + if (m_plugin->ignoreFloatingWidgets()) { + QWidget *focused = QApplication::focusWidget(); + if (focused && focused->window() != m_mainWindow->window()) { + return true; + } + } + + return false; +} + +// ============================================================ +// SloppyFocusConfigPage +// ============================================================ + +SloppyFocusConfigPage::SloppyFocusConfigPage(SloppyFocusPlugin *plugin, QWidget *parent) + : KTextEditor::ConfigPage(parent) + , m_plugin(plugin) +{ + auto *layout = new QVBoxLayout(this); + + // Focus timing group + auto *timingGroup = new QGroupBox(i18n("Focus Timing"), this); + auto *timingLayout = new QFormLayout(timingGroup); + + m_focusDelaySpin = new QSpinBox(this); + m_focusDelaySpin->setRange(0, 5000); + m_focusDelaySpin->setSuffix(i18n(" ms")); + m_focusDelaySpin->setToolTip(i18n("Time the mouse must hover over a view before focus switches. " + "Set to 0 for immediate focus change.")); + timingLayout->addRow(i18n("Focus delay:"), m_focusDelaySpin); + + m_minimumStaySpin = new QSpinBox(this); + m_minimumStaySpin->setRange(0, 5000); + m_minimumStaySpin->setSuffix(i18n(" ms")); + m_minimumStaySpin->setToolTip(i18n("Minimum time focus must remain on a view before another " + "switch is allowed. Prevents rapid bouncing.")); + timingLayout->addRow(i18n("Minimum stay time:"), m_minimumStaySpin); + + layout->addWidget(timingGroup); + + // Behavior group + auto *behaviorGroup = new QGroupBox(i18n("Focus-Stealing Prevention"), this); + auto *behaviorLayout = new QVBoxLayout(behaviorGroup); + + m_raiseOnFocusCheck = new QCheckBox(i18n("Raise view on focus"), this); + m_raiseOnFocusCheck->setToolTip(i18n("Raise the view widget when it receives sloppy focus")); + behaviorLayout->addWidget(m_raiseOnFocusCheck); + + m_ignoreDialogsCheck = new QCheckBox(i18n("Ignore when dialogs are open"), this); + m_ignoreDialogsCheck->setToolTip(i18n("Disable sloppy focus while a modal dialog is active")); + behaviorLayout->addWidget(m_ignoreDialogsCheck); + + m_ignoreFloatingCheck = new QCheckBox(i18n("Ignore when floating widgets have focus"), this); + m_ignoreFloatingCheck->setToolTip(i18n("Disable sloppy focus when focus is in a different " + "window (e.g., completion popups, tooltips)")); + behaviorLayout->addWidget(m_ignoreFloatingCheck); + + layout->addWidget(behaviorGroup); + layout->addStretch(); + + // Connect change signals + connect(m_focusDelaySpin, QOverload::of(&QSpinBox::valueChanged), + this, &SloppyFocusConfigPage::changed); + connect(m_minimumStaySpin, QOverload::of(&QSpinBox::valueChanged), + this, &SloppyFocusConfigPage::changed); + connect(m_raiseOnFocusCheck, &QCheckBox::toggled, + this, &SloppyFocusConfigPage::changed); + connect(m_ignoreDialogsCheck, &QCheckBox::toggled, + this, &SloppyFocusConfigPage::changed); + connect(m_ignoreFloatingCheck, &QCheckBox::toggled, + this, &SloppyFocusConfigPage::changed); + + reset(); +} + +SloppyFocusConfigPage::~SloppyFocusConfigPage() = default; + +QString SloppyFocusConfigPage::name() const +{ + return i18n("Sloppy Focus"); +} + +QString SloppyFocusConfigPage::fullName() const +{ + return i18n("Sloppy Focus (Focus Follows Mouse)"); +} + +QIcon SloppyFocusConfigPage::icon() const +{ + return QIcon::fromTheme(QStringLiteral("input-mouse")); +} + +void SloppyFocusConfigPage::apply() +{ + m_plugin->setFocusDelay(m_focusDelaySpin->value()); + m_plugin->setMinimumStayTime(m_minimumStaySpin->value()); + m_plugin->setRaiseOnFocus(m_raiseOnFocusCheck->isChecked()); + m_plugin->setIgnoreDialogs(m_ignoreDialogsCheck->isChecked()); + m_plugin->setIgnoreFloatingWidgets(m_ignoreFloatingCheck->isChecked()); + m_plugin->writeConfig(); +} + +void SloppyFocusConfigPage::defaults() +{ + m_focusDelaySpin->setValue(150); + m_minimumStaySpin->setValue(50); + m_raiseOnFocusCheck->setChecked(false); + m_ignoreDialogsCheck->setChecked(true); + m_ignoreFloatingCheck->setChecked(true); +} + +void SloppyFocusConfigPage::reset() +{ + m_focusDelaySpin->setValue(m_plugin->focusDelay()); + m_minimumStaySpin->setValue(m_plugin->minimumStayTime()); + m_raiseOnFocusCheck->setChecked(m_plugin->raiseOnFocus()); + m_ignoreDialogsCheck->setChecked(m_plugin->ignoreDialogs()); + m_ignoreFloatingCheck->setChecked(m_plugin->ignoreFloatingWidgets()); +} + +} // namespace deft + +K_PLUGIN_FACTORY_WITH_JSON(SloppyFocusPluginFactory, "sloppyfocusplugin.json", + registerPlugin();) + +#include "sloppyfocusplugin.moc" diff --git a/src/sloppyfocus/sloppyfocusplugin.h b/src/sloppyfocus/sloppyfocusplugin.h new file mode 100644 index 0000000..5db6945 --- /dev/null +++ b/src/sloppyfocus/sloppyfocusplugin.h @@ -0,0 +1,125 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * [deft:util] sloppyfocus — focus-follows-mouse for Kate. + * + * SloppyFocusPlugin installs an application event filter that moves keyboard + * focus to whatever editor view / tool pane the mouse enters, after a + * configurable hover delay, with focus-stealing prevention (minimum stay time, + * dialog / floating-widget guards). This is the acme-style sloppy focus that + * complements the radial's pointer-follows-focus warping: together the mouse + * and keyboard focus stay where your attention is. + */ +#ifndef DEFT_SLOPPYFOCUSPLUGIN_H +#define DEFT_SLOPPYFOCUSPLUGIN_H + +#include +#include +#include +#include + +#include +#include +#include +#include + +class QCheckBox; +class QSpinBox; + +namespace deft +{ + +class SloppyFocusPlugin : public KTextEditor::Plugin +{ + Q_OBJECT + +public: + explicit SloppyFocusPlugin(QObject *parent, const QVariantList &args = {}); + ~SloppyFocusPlugin() override; + + QObject *createView(KTextEditor::MainWindow *mainWindow) override; + int configPages() const override; + KTextEditor::ConfigPage *configPage(int number, QWidget *parent) override; + + // Configuration + int focusDelay() const { return m_focusDelay; } + int minimumStayTime() const { return m_minimumStayTime; } + bool raiseOnFocus() const { return m_raiseOnFocus; } + bool ignoreDialogs() const { return m_ignoreDialogs; } + bool ignoreFloatingWidgets() const { return m_ignoreFloatingWidgets; } + + void setFocusDelay(int ms); + void setMinimumStayTime(int ms); + void setRaiseOnFocus(bool raise); + void setIgnoreDialogs(bool ignore); + void setIgnoreFloatingWidgets(bool ignore); + + void readConfig(); + void writeConfig(); + +Q_SIGNALS: + void configChanged(); + +private: + int m_focusDelay = 150; + int m_minimumStayTime = 50; + bool m_raiseOnFocus = false; + bool m_ignoreDialogs = true; + bool m_ignoreFloatingWidgets = true; +}; + +class SloppyFocusView : public QObject +{ + Q_OBJECT + +public: + explicit SloppyFocusView(SloppyFocusPlugin *plugin, KTextEditor::MainWindow *mainWindow); + ~SloppyFocusView() override; + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + +private Q_SLOTS: + void activatePendingPane(); + void onConfigChanged(); + +private: + QWidget *paneForWidget(QWidget *widget) const; + bool shouldIgnoreFocus() const; + + SloppyFocusPlugin *m_plugin; + KTextEditor::MainWindow *m_mainWindow; + QTimer m_focusTimer; + QElapsedTimer m_lastFocusChange; + QPointer m_pendingPane; + QPointer m_currentPane; +}; + +class SloppyFocusConfigPage : public KTextEditor::ConfigPage +{ + Q_OBJECT + +public: + explicit SloppyFocusConfigPage(SloppyFocusPlugin *plugin, QWidget *parent); + ~SloppyFocusConfigPage() override; + + QString name() const override; + QString fullName() const override; + QIcon icon() const override; + + void apply() override; + void defaults() override; + void reset() override; + +private: + SloppyFocusPlugin *m_plugin; + QSpinBox *m_focusDelaySpin; + QSpinBox *m_minimumStaySpin; + QCheckBox *m_raiseOnFocusCheck; + QCheckBox *m_ignoreDialogsCheck; + QCheckBox *m_ignoreFloatingCheck; +}; + +} // namespace deft + +#endif // DEFT_SLOPPYFOCUSPLUGIN_H diff --git a/src/sloppyfocus/sloppyfocusplugin.json b/src/sloppyfocus/sloppyfocusplugin.json new file mode 100644 index 0000000..77acc1f --- /dev/null +++ b/src/sloppyfocus/sloppyfocusplugin.json @@ -0,0 +1,14 @@ +{ + "KPlugin": { + "Authors": [ + { + "Name": "Levi Neely" + } + ], + "Description": "Focus follows mouse: keyboard focus moves to the editor view or tool pane under the pointer, with a configurable hover delay and focus-stealing prevention.", + "Icon": "input-mouse", + "License": "LGPL-2.0-or-later", + "Name": "[deft:util] sloppyfocus", + "Version": "0.1.0" + } +}