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.
This commit is contained in:
Levi Neely 2026-07-20 14:59:05 +02:00
commit a641cbfc00
6 changed files with 559 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
build/

57
CMakeLists.txt Normal file
View File

@ -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})

16
Makefile Normal file
View File

@ -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)

14
metadata.json Normal file
View File

@ -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
}

366
sloppyfocus.cpp Normal file
View File

@ -0,0 +1,366 @@
#include "sloppyfocus.h"
#include <KLocalizedString>
#include <KPluginFactory>
#include <KSharedConfig>
#include <KConfigGroup>
#include <QApplication>
#include <QEvent>
#include <QWidget>
#include <QDialog>
#include <QVBoxLayout>
#include <QFormLayout>
#include <QGroupBox>
#include <QSpinBox>
#include <QCheckBox>
#include <QLabel>
#include <QIcon>
K_PLUGIN_FACTORY_WITH_JSON(SloppyFocusPluginFactory,
"metadata.json",
registerPlugin<SloppyFocusPlugin>();)
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<QWidget *>(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<KTextEditor::View *>(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<int>::of(&QSpinBox::valueChanged),
this, &SloppyFocusConfigPage::changed);
connect(m_minimumStaySpin, QOverload<int>::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"

105
sloppyfocus.h Normal file
View File

@ -0,0 +1,105 @@
#ifndef SLOPPYFOCUS_H
#define SLOPPYFOCUS_H
#include <KTextEditor/Plugin>
#include <KTextEditor/MainWindow>
#include <KTextEditor/ConfigPage>
#include <KTextEditor/View>
#include <QObject>
#include <QTimer>
#include <QPointer>
#include <QElapsedTimer>
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<KTextEditor::View> m_pendingView;
QPointer<KTextEditor::View> 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