commit a641cbfc007bbd9f2b39f5a10c2569244ff47bad Author: Levi Neely Date: Mon Jul 20 14:59:05 2026 +0200 Sloppy focus KTextEditor plugin for Kate split panes Focus follows mouse across Kate split views. Configurable focus-stealing prevention parameters: - Focus delay (default 150ms) - Minimum stay time (default 50ms) - Ignore modal dialogs - Ignore floating widgets - Optional raise on focus Uses qApp event filter to catch Enter events on all widgets, walks up to find KTextEditor::View, and calls setFocus() to activate the view in its current split pane without opening new tabs. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..6286bbe --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,57 @@ +cmake_minimum_required(VERSION 3.16) +project(sloppyfocus VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + +find_package(ECM 5.80 REQUIRED NO_MODULE) +set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH}) + +# --- Detect Qt6/KF6 or fall back to Qt5/KF5 --- +find_package(KF6TextEditor QUIET CONFIG) +if(KF6TextEditor_FOUND) + set(QT_MAJOR_VERSION 6) +else() + set(QT_MAJOR_VERSION 5) +endif() + +include(KDEInstallDirs) +include(KDECMakeSettings) +include(KDECompilerSettings NO_POLICY_SCOPE) + +if(QT_MAJOR_VERSION EQUAL 6) + find_package(Qt6 REQUIRED COMPONENTS Core Widgets) + find_package(KF6I18n REQUIRED) + find_package(KF6CoreAddons REQUIRED) + set(KF_LINK_LIBS + KF6::TextEditor + KF6::I18n + Qt6::Core + Qt6::Widgets + ) + set(PLUGIN_INSTALL_NS "kf6/ktexteditor") + message(STATUS "Building Sloppy Focus with KF6/Qt6") +else() + find_package(Qt5 REQUIRED COMPONENTS Core Widgets) + find_package(KF5TextEditor REQUIRED) + find_package(KF5I18n REQUIRED) + find_package(KF5CoreAddons REQUIRED) + set(KF_LINK_LIBS + KF5::TextEditor + KF5::I18n + Qt5::Core + Qt5::Widgets + ) + set(PLUGIN_INSTALL_NS "ktexteditor") + message(STATUS "Building Sloppy Focus with KF5/Qt5") +endif() + +kcoreaddons_add_plugin(sloppyfocus + SOURCES + sloppyfocus.cpp + INSTALL_NAMESPACE "${PLUGIN_INSTALL_NS}" +) + +target_link_libraries(sloppyfocus ${KF_LINK_LIBS}) +target_include_directories(sloppyfocus PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..005c98d --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: all clean install + +BUILD_DIR := build + +all: $(BUILD_DIR)/Makefile + @cd $(BUILD_DIR) && make -j$$(nproc) + +$(BUILD_DIR)/Makefile: CMakeLists.txt + @mkdir -p $(BUILD_DIR) + @cd $(BUILD_DIR) && cmake .. -DCMAKE_INSTALL_PREFIX=/usr -Wno-dev + +install: all + cd $(BUILD_DIR) && sudo make install + +clean: + rm -rf $(BUILD_DIR) diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..da35264 --- /dev/null +++ b/metadata.json @@ -0,0 +1,14 @@ +{ + "KPlugin": { + "Id": "sloppyfocus", + "Name": "Sloppy Focus", + "Description": "Focus follows mouse for Kate editor views with configurable focus-stealing prevention", + "Icon": "input-mouse", + "Authors": [ + { + "Name": "lkn" + } + ] + }, + "X-KDE-PluginInfo-EnabledByDefault": true +} diff --git a/sloppyfocus.cpp b/sloppyfocus.cpp new file mode 100644 index 0000000..0a6b80c --- /dev/null +++ b/sloppyfocus.cpp @@ -0,0 +1,366 @@ +#include "sloppyfocus.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +K_PLUGIN_FACTORY_WITH_JSON(SloppyFocusPluginFactory, + "metadata.json", + registerPlugin();) + +static const char *CONFIG_GROUP = "SloppyFocus"; +static const char *KEY_FOCUS_DELAY = "FocusDelay"; +static const char *KEY_MIN_STAY = "MinimumStayTime"; +static const char *KEY_RAISE = "RaiseOnFocus"; +static const char *KEY_IGNORE_DIALOGS = "IgnoreDialogs"; +static const char *KEY_IGNORE_FLOATING = "IgnoreFloatingWidgets"; + +// ============================================================ +// 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(), CONFIG_GROUP); + 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(), CONFIG_GROUP); + 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::activatePendingView); + 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 KTextEditor::View ancestor of the entered widget + KTextEditor::View *view = viewForWidget(widget); + if (!view) { + return false; + } + + // Don't re-trigger for the view that already has focus + if (view == m_currentView) { + m_focusTimer.stop(); + m_pendingView = 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_pendingView = view; + m_focusTimer.start(m_plugin->focusDelay()); + + return false; +} + +void SloppyFocusView::activatePendingView() +{ + if (!m_pendingView) { + return; + } + + // Final check: is the mouse still over this view? + // (The view might have been destroyed or mouse moved away) + KTextEditor::View *view = m_pendingView; + m_pendingView = nullptr; + + if (!view->isVisible()) { + return; + } + + // Verify mouse is still within the view's geometry + QPoint globalPos = QCursor::pos(); + QRect viewRect = view->rect(); + QPoint viewTopLeft = view->mapToGlobal(viewRect.topLeft()); + QRect globalRect(viewTopLeft, viewRect.size()); + if (!globalRect.contains(globalPos)) { + return; + } + + // Perform the focus switch — just move keyboard focus to the view + // in its current split pane. Do NOT use activateView(document) as that + // can open the document in a different view space. + view->setFocus(); + m_currentView = view; + m_lastFocusChange.restart(); + + if (m_plugin->raiseOnFocus()) { + view->raise(); + } +} + +KTextEditor::View *SloppyFocusView::viewForWidget(QWidget *widget) const +{ + // Walk up the widget hierarchy looking for a KTextEditor::View + QWidget *w = widget; + while (w) { + if (auto *view = qobject_cast(w)) { + return view; + } + w = w->parentWidget(); + } + return nullptr; +} + +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()); +} + +#include "sloppyfocus.moc" diff --git a/sloppyfocus.h b/sloppyfocus.h new file mode 100644 index 0000000..b0f1718 --- /dev/null +++ b/sloppyfocus.h @@ -0,0 +1,105 @@ +#ifndef SLOPPYFOCUS_H +#define SLOPPYFOCUS_H + +#include +#include +#include +#include + +#include +#include +#include +#include + +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 activatePendingView(); + void onConfigChanged(); + +private: + KTextEditor::View *viewForWidget(QWidget *widget) const; + bool shouldIgnoreFocus() const; + + SloppyFocusPlugin *m_plugin; + KTextEditor::MainWindow *m_mainWindow; + QTimer m_focusTimer; + QElapsedTimer m_lastFocusChange; + QPointer m_pendingView; + QPointer m_currentView; +}; + +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; + class QSpinBox *m_focusDelaySpin; + class QSpinBox *m_minimumStaySpin; + class QCheckBox *m_raiseOnFocusCheck; + class QCheckBox *m_ignoreDialogsCheck; + class QCheckBox *m_ignoreFloatingCheck; +}; + +#endif // SLOPPYFOCUS_H