diff --git a/AUDIT.md b/AUDIT.md index c55e020..8a82736 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -5,11 +5,10 @@ Open items only; fixed ones are in the history (`3576dd8`..`6ff86fb`). ## Medium 1. **Fake input can't type characters missing from the keymap.** `KWinFakeInput::typeText` (`src/backend/KWinFakeInput.cpp`) sends keysyms, and KWin has to find each one in the active keymap. Probably all of these do nothing when no text field is focused: long-press alternates (ť on a US keymap) and template symbols missing from the layout. Nothing tells the user it failed. A fix likely needs a temporary keymap (e.g. the virtual-keyboard protocol). -2. **The layer surface can't follow focus to another output.** `FloatingWindow` (`src/window/FloatingWindow.cpp`) now refits on screen changes, but the layer surface stays on `window->screen()`. Moving it needs a remap. ## Low -3. **Hold-keys caps lock is tracked locally.** `HardwareKeys::m_capsLock` toggles on its own, so a hardware Caps Lock (or a missed event) puts the shown state out of sync. The window never has keyboard focus, so it gets no modifier events to read the real state from. +2. **Hold-keys caps lock is tracked locally.** `HardwareKeys::m_capsLock` toggles on its own, so a hardware Caps Lock (or a missed event) puts the shown state out of sync. The window never has keyboard focus, so it gets no modifier events to read the real state from. ## Untested @@ -17,5 +16,4 @@ Fixed without automated tests; check on a live KWin session: - Focus moving from field to field and deactivate ordering (`KWinInputMethod`) - `org.kde.keyboard` appearing after startup or KWin restarting (`SystemLayouts`) -- Refit after a resolution change or monitor unplug (`FloatingWindow`) - Fake-input version checks on a KWin older than fake-input v6 (`KWinFakeInput`) diff --git a/CMakeLists.txt b/CMakeLists.txt index 1c18dde..68ccfac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,8 +6,8 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) find_package(Qt6 6.8 REQUIRED COMPONENTS Gui Quick Widgets Test DBus WaylandClient) -find_package(LayerShellQt REQUIRED) find_package(KF6WindowSystem REQUIRED) +find_package(KF6Config REQUIRED) find_package(PkgConfig REQUIRED) pkg_check_modules(XKB REQUIRED IMPORTED_TARGET xkbcommon) pkg_check_modules(WAYLAND REQUIRED IMPORTED_TARGET wayland-client) diff --git a/README.md b/README.md index 36b6804..7e67249 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ cmake -B build -G Ninja && cmake --build build ctest --test-dir build ``` -Needs Qt ≥ 6.8 (Quick, WaylandClient, DBus, Test), LayerShellQt, KF6 WindowSystem, xkbcommon, wayland-protocols. +Needs Qt ≥ 6.8 (Quick, WaylandClient, DBus, Test), KF6 WindowSystem, KF6 Config, xkbcommon, wayland-protocols. ## Run @@ -73,7 +73,7 @@ input/ touch → PointerTracker gestures → KeyboardController rules engine/ key output → text ops (DirectEngine; prediction/swipe later) backend/ text ops → compositor (KWin input-method-v1, or fake input with no text field); the compositor's keymap layout/ template JSON + keymap letters → Layout → LayoutModel (geometry, hit testing) -window/ floating layer-shell placement, visibility, saved geometry +window/ floating window (KWin rule: on top, no focus), visibility, saved geometry app/ D-Bus control and system layouts, dev hooks settings/ persisted user preferences ``` diff --git a/qml/DragHandle.qml b/qml/DragHandle.qml index 01fdd69..99c6871 100644 --- a/qml/DragHandle.qml +++ b/qml/DragHandle.qml @@ -1,11 +1,11 @@ import QtQuick -// The grab bar under the floating keyboard. Emits how far it was dragged; -// moving the window is up to the owner. +// The grab bar under the floating keyboard. Moving the window is up to the +// owner (the window manager's move, so it works for mouse and touch alike). Item { id: handle - signal moved(real dx, real dy) + signal dragStarted() implicitHeight: 22 @@ -19,12 +19,6 @@ Item { DragHandler { target: null - // The window follows the pointer, so the pointer stays near its press - // position in window coordinates: measure each step from there. - onCentroidChanged: { - if (active) - handle.moved(centroid.scenePosition.x - centroid.scenePressPosition.x, - centroid.scenePosition.y - centroid.scenePressPosition.y) - } + onActiveChanged: if (active) handle.dragStarted() } } diff --git a/qml/Main.qml b/qml/Main.qml index 696cf50..a7c0c32 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -63,7 +63,7 @@ Window { DragHandle { id: handle anchors { left: parent.left; right: parent.right; bottom: parent.bottom } - onMoved: (dx, dy) => root.placement?.moveBy(dx, dy) + onDragStarted: root.startSystemMove() } SettingsButton { @@ -74,7 +74,7 @@ Window { ResizeHandle { anchors { right: parent.right; bottom: parent.bottom } - onResized: (dw, dh) => root.placement?.resizeBy(dw, dh) + onDragStarted: root.startSystemResize(Qt.RightEdge | Qt.BottomEdge) } } } diff --git a/qml/ResizeHandle.qml b/qml/ResizeHandle.qml index 6ea7082..34b4e32 100644 --- a/qml/ResizeHandle.qml +++ b/qml/ResizeHandle.qml @@ -1,11 +1,10 @@ import QtQuick -// Corner grip. The window grows from its top-left corner, so window -// coordinates stay put while resizing: report each step's delta. +// Corner grip. Resizing is up to the owner (the window manager's resize). Item { id: grip - signal resized(real dw, real dh) + signal dragStarted() implicitWidth: 28 implicitHeight: 28 @@ -28,17 +27,10 @@ Item { } DragHandler { - property point last - target: null + // The grab bar lies underneath and would take the drag over. + grabPermissions: PointerHandler.TakeOverForbidden cursorShape: Qt.SizeFDiagCursor - onActiveChanged: last = centroid.scenePressPosition - onCentroidChanged: { - if (!active) - return - const p = centroid.scenePosition - grip.resized(p.x - last.x, p.y - last.y) - last = p - } + onActiveChanged: if (active) grip.dragStarted() } } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index fc15cf8..4bef203 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -85,6 +85,7 @@ qt_add_executable(sloposk backend/KWinFakeInput.h backend/KWinFakeInput.cpp backend/WaylandKeymap.h backend/WaylandKeymap.cpp window/FloatingWindow.h window/FloatingWindow.cpp + window/KWinRule.h window/KWinRule.cpp ) qt6_generate_wayland_protocol_client_sources(sloposk PRIVATE_CODE FILES @@ -100,7 +101,7 @@ target_link_libraries(sloposk PRIVATE sloposk_coreplugin Qt6::DBus Qt6::WaylandClient - LayerShellQt::Interface + KF6::ConfigCore KF6::WindowSystem PkgConfig::WAYLAND ) diff --git a/src/main.cpp b/src/main.cpp index 21a6db0..ea00996 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -11,6 +11,7 @@ #include "layout/LayoutModel.h" #include "settings/Settings.h" #include "window/FloatingWindow.h" +#include "window/KWinRule.h" #include "window/VisibilityController.h" #include "window/WindowSettings.h" @@ -158,6 +159,7 @@ int main(int argc, char *argv[]) return 1; } + installKWinRule(QGuiApplication::desktopFileName()); FloatingWindow placement(window); window->setProperty("placement", QVariant::fromValue(&placement)); WindowSettings windowSettings; diff --git a/src/window/FloatingWindow.cpp b/src/window/FloatingWindow.cpp index 8c881fd..751047f 100644 --- a/src/window/FloatingWindow.cpp +++ b/src/window/FloatingWindow.cpp @@ -1,68 +1,28 @@ #include "FloatingWindow.h" #include -#include #include -#include #include #include -namespace { - -bool isWayland() -{ - return QGuiApplication::platformName().startsWith(QLatin1String("wayland")); -} - -constexpr int BottomGap = 24; - -} // namespace - FloatingWindow::FloatingWindow(QWindow *window, QObject *parent) : QObject(parent) , m_window(window) { - if (isWayland()) { - m_layerSurface = LayerShellQt::Window::get(window); - m_layerSurface->setLayer(LayerShellQt::Window::LayerOverlay); - m_layerSurface->setKeyboardInteractivity(LayerShellQt::Window::KeyboardInteractivityNone); - m_layerSurface->setAnchors({LayerShellQt::Window::AnchorTop | LayerShellQt::Window::AnchorLeft}); - m_layerSurface->setExclusiveZone(-1); - m_layerSurface->setScope(QStringLiteral("sloposk-keyboard")); - m_layerSurface->setActivateOnShow(false); - } else { - window->setFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint - | Qt::WindowDoesNotAcceptFocus); - } - m_position = defaultPosition(); - apply(); - followScreen(window->screen()); - connect(window, &QWindow::screenChanged, this, &FloatingWindow::followScreen); -} - -// Re-fits the window (not saved: the stored geometry is the user's choice). -void FloatingWindow::followScreen(QScreen *screen) -{ - if (m_screen) - disconnect(m_screen, nullptr, this, nullptr); - m_screen = screen; - if (!m_screen) - return; - connect(m_screen, &QScreen::geometryChanged, this, [this] { setGeometry(geometry()); }); - setGeometry(geometry()); + window->setFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::WindowDoesNotAcceptFocus); + for (const auto changed : {&QWindow::xChanged, &QWindow::yChanged, &QWindow::widthChanged, &QWindow::heightChanged}) + connect(window, changed, this, &FloatingWindow::geometryChanged); } QRect FloatingWindow::geometry() const { - return {m_position, m_window->size()}; + return m_window->geometry().translated(-screenArea().topLeft()); } void FloatingWindow::setGeometry(const QRect &geometry) { const QSize size = geometry.size().expandedTo(m_window->minimumSize()).boundedTo(screenArea().size()); - m_window->resize(size); - m_position = clamped(geometry.topLeft(), size); - apply(); + m_window->setGeometry({screenArea().topLeft() + geometry.topLeft(), size}); } QString FloatingWindow::screenName() const @@ -70,26 +30,12 @@ QString FloatingWindow::screenName() const return m_window->screen()->name(); } -void FloatingWindow::moveBy(qreal dx, qreal dy) -{ - const QPoint position = clamped(m_position + QPoint(qRound(dx), qRound(dy)), m_window->size()); - if (position == m_position) - return; - m_position = position; - apply(); - Q_EMIT geometryChanged(); -} - void FloatingWindow::resizeBy(qreal dw, qreal dh) { - const QSize maximum = screenArea().size() - QSize(m_position.x(), m_position.y()); const QSize size = (m_window->size() + QSize(qRound(dw), qRound(dh))) .expandedTo(m_window->minimumSize()) - .boundedTo(maximum); - if (size == m_window->size()) - return; + .boundedTo(screenArea().size()); m_window->resize(size); - Q_EMIT geometryChanged(); } void FloatingWindow::setInputRegion(const QRectF ®ion) @@ -122,26 +68,3 @@ QRect FloatingWindow::screenArea() const { return m_window->screen()->geometry(); } - -QPoint FloatingWindow::clamped(QPoint position, QSize size) const -{ - const QRect area = screenArea(); - const int maxX = qMax(0, area.width() - size.width()); - const int maxY = qMax(0, area.height() - size.height()); - return {qBound(0, position.x(), maxX), qBound(0, position.y(), maxY)}; -} - -QPoint FloatingWindow::defaultPosition() const -{ - const QRect area = screenArea(); - return clamped({(area.width() - m_window->width()) / 2, area.height() - m_window->height() - BottomGap}, - m_window->size()); -} - -void FloatingWindow::apply() -{ - if (m_layerSurface) - m_layerSurface->setMargins({m_position.x(), m_position.y(), 0, 0}); - else - m_window->setPosition(screenArea().topLeft() + m_position); -} diff --git a/src/window/FloatingWindow.h b/src/window/FloatingWindow.h index 0a11d8e..fa46fe8 100644 --- a/src/window/FloatingWindow.h +++ b/src/window/FloatingWindow.h @@ -4,15 +4,12 @@ #include #include -class QScreen; class QWindow; -namespace LayerShellQt { class Window; } -// Places a keyboard window as a free-floating overlay that never takes focus. -// On Wayland: a layer-shell surface anchored top-left, positioned via margins. -// On X11: a frameless, non-focusable, always-on-top tool window. -// Geometry is relative to the window's screen and always kept on it, also -// when the screen's resolution changes or the window moves to another one. +// Makes a keyboard window a frameless, always-on-top window that never takes +// focus. The window manager places it and moves it (Super+drag included); +// on KWin Wayland that relies on the rule from installKWinRule(). +// Geometry is relative to the window's screen. class FloatingWindow : public QObject { Q_OBJECT @@ -22,10 +19,10 @@ public: explicit FloatingWindow(QWindow *window, QObject *parent = nullptr); QRect geometry() const; + // Wayland clients can't place themselves: only the size applies there. void setGeometry(const QRect &geometry); QString screenName() const; - Q_INVOKABLE void moveBy(qreal dx, qreal dy); // Grows from the top-left corner; respects the window's minimum size. Q_INVOKABLE void resizeBy(qreal dw, qreal dh); // Only this part of the window takes input; the rest passes through. @@ -36,18 +33,10 @@ public: Q_INVOKABLE void hide(); Q_SIGNALS: - // Only for user-initiated changes (move/resize), not for restores. void geometryChanged(); private: QRect screenArea() const; - QPoint clamped(QPoint position, QSize size) const; - QPoint defaultPosition() const; - void apply(); - void followScreen(QScreen *screen); QWindow *m_window; - LayerShellQt::Window *m_layerSurface = nullptr; - QPoint m_position; - QScreen *m_screen = nullptr; }; diff --git a/src/window/KWinRule.cpp b/src/window/KWinRule.cpp new file mode 100644 index 0000000..97a0af6 --- /dev/null +++ b/src/window/KWinRule.cpp @@ -0,0 +1,66 @@ +#include "KWinRule.h" + +#include +#include +#include +#include +#include + +namespace { + +const QString RuleName = QStringLiteral("sloposk"); + +// KWin rule policies. +constexpr auto Force = "2"; +constexpr auto Remember = "4"; + +bool writeEntry(KConfigGroup &group, const QString &key, const QString &value) +{ + if (group.readEntry(key, QString()) == value) + return false; + group.writeEntry(key, value); + return true; +} + +} // namespace + +void installKWinRule(const QString &appId) +{ + KConfig config(QStringLiteral("kwinrulesrc"), KConfig::NoGlobals); + KConfigGroup rule = config.group(RuleName); + const QList> entries = { + {QStringLiteral("Description"), QStringLiteral("sloposk keyboard: on top, never focused, remembered position")}, + {QStringLiteral("wmclass"), appId}, + {QStringLiteral("wmclassmatch"), QStringLiteral("1")}, // exact + {QStringLiteral("wmclasscomplete"), QStringLiteral("false")}, + {QStringLiteral("above"), QStringLiteral("true")}, + {QStringLiteral("aboverule"), QString::fromLatin1(Force)}, + {QStringLiteral("acceptfocus"), QStringLiteral("false")}, + {QStringLiteral("acceptfocusrule"), QString::fromLatin1(Force)}, + {QStringLiteral("skiptaskbar"), QStringLiteral("true")}, + {QStringLiteral("skiptaskbarrule"), QString::fromLatin1(Force)}, + {QStringLiteral("skippager"), QStringLiteral("true")}, + {QStringLiteral("skippagerrule"), QString::fromLatin1(Force)}, + {QStringLiteral("skipswitcher"), QStringLiteral("true")}, + {QStringLiteral("skipswitcherrule"), QString::fromLatin1(Force)}, + {QStringLiteral("positionrule"), QString::fromLatin1(Remember)}, + }; + bool changed = false; + for (const auto &[key, value] : entries) + changed |= writeEntry(rule, key, value); + + KConfigGroup general = config.group(QStringLiteral("General")); + QStringList rules = general.readEntry("rules", QStringList()); + if (!rules.contains(RuleName)) { + rules.append(RuleName); + general.writeEntry("rules", rules); + general.writeEntry("count", rules.size()); + changed = true; + } + if (!changed) + return; + config.sync(); + QDBusConnection::sessionBus().asyncCall(QDBusMessage::createMethodCall( + QStringLiteral("org.kde.KWin"), QStringLiteral("/KWin"), QStringLiteral("org.kde.KWin"), + QStringLiteral("reconfigure"))); +} diff --git a/src/window/KWinRule.h b/src/window/KWinRule.h new file mode 100644 index 0000000..680c0d9 --- /dev/null +++ b/src/window/KWinRule.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +// Adds (or repairs) a KWin window rule for the keyboard's app id in +// kwinrulesrc: keep above, never take focus, skip taskbar/pager/switcher, +// remember the position. Wayland gives clients no other way to stay +// unfocused. Reloads KWin's rules when anything changed. +void installKWinRule(const QString &appId);