kate-sloppyfocus/sloppyfocus.cpp

367 lines
11 KiB
C++

#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"