commit cdef9b4ad06d1cdbe99ac54bc7ab547450f87b3a Author: ericek111 Date: Wed Oct 14 14:43:44 2026 +0200 Spike: KWin input-method backend with floating layer-shell window Registers as KWin's virtual keyboard (input-method-unstable-v1), shows a non-focusable layer-shell overlay, and commits Unicode text. Verified in a nested headless KWin with the textprobe tool. Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17b7a4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/ +.cache/ +*.user diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..6cb7dee --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,18 @@ +cmake_minimum_required(VERSION 3.21) +project(sloposk VERSION 0.1 LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +find_package(Qt6 6.8 REQUIRED COMPONENTS Gui Quick Widgets WaylandClient) +find_package(LayerShellQt REQUIRED) +find_package(PkgConfig REQUIRED) +pkg_check_modules(XKB REQUIRED IMPORTED_TARGET xkbcommon) +pkg_check_modules(WAYLAND REQUIRED IMPORTED_TARGET wayland-client) +pkg_get_variable(WAYLAND_PROTOCOLS_DIR wayland-protocols pkgdatadir) + +qt_standard_project_setup(REQUIRES 6.8) + +add_subdirectory(src) +add_subdirectory(tools) diff --git a/qml/Main.qml b/qml/Main.qml new file mode 100644 index 0000000..801c45b --- /dev/null +++ b/qml/Main.qml @@ -0,0 +1,45 @@ +import QtQuick +import QtQuick.Controls + +// Spike UI: proves text commits, keysyms and dragging. Replaced by the real +// keyboard in milestone 2. +Window { + id: root + + required property var backend + property var placement: null + + width: 720 + height: 120 + visible: false + color: "#d1d3d9" + + Rectangle { + id: handle + anchors { horizontalCenter: parent.horizontalCenter; bottom: parent.bottom; bottomMargin: 6 } + width: 120; height: 6; radius: 3 + color: "#8e8e93" + + DragHandler { + target: null + margin: 12 + property point last + onActiveChanged: last = centroid.scenePressPosition + onCentroidChanged: { + if (!active || !root.placement) + return + const p = centroid.scenePosition + root.placement.moveBy(p.x - last.x, p.y - last.y) + } + } + } + + Row { + anchors.centerIn: parent + spacing: 8 + Button { text: "héllo👍"; focusPolicy: Qt.NoFocus; onClicked: root.backend.commitText(text) } + Button { text: "⌫"; focusPolicy: Qt.NoFocus; onClicked: root.backend.sendKeysym(0xff08) } + Button { text: "⏎"; focusPolicy: Qt.NoFocus; onClicked: root.backend.sendKeysym(0xff0d) } + Label { anchors.verticalCenter: parent.verticalCenter; text: root.backend.active ? "active" : "inactive" } + } +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..95e8a9b --- /dev/null +++ b/src/CMakeLists.txt @@ -0,0 +1,42 @@ +qt_add_executable(sloposk + main.cpp + backend/TextBackend.h + backend/InputMethodProtocol.h + backend/KWinInputMethod.h backend/KWinInputMethod.cpp + backend/KWinInputContext.h backend/KWinInputContext.cpp + window/FloatingWindow.h window/FloatingWindow.cpp +) + +qt6_generate_wayland_protocol_client_sources(sloposk PRIVATE_CODE FILES + ${WAYLAND_PROTOCOLS_DIR}/unstable/input-method/input-method-unstable-v1.xml +) +# Generated code lacks the core-protocol include (see backend/InputMethodProtocol.h). +set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/qwayland-input-method-unstable-v1.cpp + PROPERTIES COMPILE_OPTIONS "-include;wayland-client-protocol.h") + +# QML lives in the top-level qml/ dir; alias each file so the module sees it at its root. +set(SLOPOSK_QML_FILES + Main.qml +) +list(TRANSFORM SLOPOSK_QML_FILES PREPEND ${PROJECT_SOURCE_DIR}/qml/ OUTPUT_VARIABLE sloposk_qml_paths) +foreach(file IN LISTS SLOPOSK_QML_FILES) + set_source_files_properties(${PROJECT_SOURCE_DIR}/qml/${file} PROPERTIES QT_RESOURCE_ALIAS ${file}) +endforeach() + +qt_add_qml_module(sloposk + URI Sloposk + QML_FILES ${sloposk_qml_paths} +) + +target_include_directories(sloposk PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +target_link_libraries(sloposk PRIVATE + Qt6::Gui + Qt6::Quick + Qt6::WaylandClient + LayerShellQt::Interface + PkgConfig::XKB + PkgConfig::WAYLAND +) + +install(TARGETS sloposk) diff --git a/src/backend/InputMethodProtocol.h b/src/backend/InputMethodProtocol.h new file mode 100644 index 0000000..64f4482 --- /dev/null +++ b/src/backend/InputMethodProtocol.h @@ -0,0 +1,7 @@ +#pragma once + +// The generated protocol header references core interfaces (wl_keyboard, +// wl_surface) without including their declarations. Include this instead. +#include + +#include "qwayland-input-method-unstable-v1.h" diff --git a/src/backend/KWinInputContext.cpp b/src/backend/KWinInputContext.cpp new file mode 100644 index 0000000..cad4683 --- /dev/null +++ b/src/backend/KWinInputContext.cpp @@ -0,0 +1,93 @@ +#include "KWinInputContext.h" + +#include + +namespace { + +// Order defines the bit positions used in keysym()'s modifier mask. +constexpr const char *ModifierNames[] = {"Shift", "Control", "Mod1", "Mod4"}; +constexpr Qt::KeyboardModifier ModifierFlags[] = { + Qt::ShiftModifier, Qt::ControlModifier, Qt::AltModifier, Qt::MetaModifier}; + +uint32_t toModifierMask(Qt::KeyboardModifiers modifiers) +{ + uint32_t mask = 0; + for (size_t bit = 0; bit < std::size(ModifierFlags); ++bit) { + if (modifiers & ModifierFlags[bit]) + mask |= 1u << bit; + } + return mask; +} + +uint32_t timestampMs() +{ + static QElapsedTimer clock = [] { QElapsedTimer t; t.start(); return t; }(); + return uint32_t(clock.elapsed()); +} + +// The protocol reports cursor offsets in UTF-8 bytes. +int utf8OffsetToIndex(const QString &text, uint32_t byteOffset) +{ + return QString::fromUtf8(text.toUtf8().left(int(byteOffset))).size(); +} + +constexpr uint32_t KeyReleased = 0; +constexpr uint32_t KeyPressed = 1; + +} // namespace + +KWinInputContext::KWinInputContext(::zwp_input_method_context_v1 *object, QObject *parent) + : QObject(parent) + , QtWayland::zwp_input_method_context_v1(object) +{ + announceModifiers(); +} + +KWinInputContext::~KWinInputContext() +{ + destroy(); +} + +void KWinInputContext::commit(const QString &text) +{ + commit_string(m_serial, text); +} + +void KWinInputContext::preedit(const QString &text) +{ + preedit_string(m_serial, text, text); +} + +void KWinInputContext::sendKeysym(quint32 sym, Qt::KeyboardModifiers modifiers) +{ + const uint32_t mask = toModifierMask(modifiers); + keysym(m_serial, timestampMs(), sym, KeyPressed, mask); + keysym(m_serial, timestampMs(), sym, KeyReleased, mask); +} + +void KWinInputContext::zwp_input_method_context_v1_surrounding_text(const QString &text, uint32_t cursor, uint32_t) +{ + m_surroundingText = text; + m_cursorPosition = utf8OffsetToIndex(text, cursor); + Q_EMIT surroundingChanged(); +} + +void KWinInputContext::zwp_input_method_context_v1_commit_state(uint32_t serial) +{ + m_serial = serial; +} + +void KWinInputContext::zwp_input_method_context_v1_reset() +{ + m_surroundingText.clear(); + m_cursorPosition = -1; + Q_EMIT surroundingChanged(); +} + +void KWinInputContext::announceModifiers() +{ + QByteArray map; + for (const char *name : ModifierNames) + map.append(name).append('\0'); + modifiers_map(map); +} diff --git a/src/backend/KWinInputContext.h b/src/backend/KWinInputContext.h new file mode 100644 index 0000000..95f6ed6 --- /dev/null +++ b/src/backend/KWinInputContext.h @@ -0,0 +1,38 @@ +#pragma once + +#include "InputMethodProtocol.h" + +#include + +// One input-method context: exists while a text field is focused. +// Owns the serial bookkeeping and the UTF-8 <-> QString offset conversion. +class KWinInputContext : public QObject, public QtWayland::zwp_input_method_context_v1 +{ + Q_OBJECT + +public: + explicit KWinInputContext(::zwp_input_method_context_v1 *object, QObject *parent = nullptr); + ~KWinInputContext() override; + + QString surroundingText() const { return m_surroundingText; } + int cursorPosition() const { return m_cursorPosition; } + + void commit(const QString &text); + void preedit(const QString &text); + void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers); + +Q_SIGNALS: + void surroundingChanged(); + +protected: + void zwp_input_method_context_v1_surrounding_text(const QString &text, uint32_t cursor, uint32_t anchor) override; + void zwp_input_method_context_v1_commit_state(uint32_t serial) override; + void zwp_input_method_context_v1_reset() override; + +private: + void announceModifiers(); + + uint32_t m_serial = 0; + QString m_surroundingText; + int m_cursorPosition = -1; +}; diff --git a/src/backend/KWinInputMethod.cpp b/src/backend/KWinInputMethod.cpp new file mode 100644 index 0000000..0ca24e1 --- /dev/null +++ b/src/backend/KWinInputMethod.cpp @@ -0,0 +1,80 @@ +#include "KWinInputMethod.h" +#include "KWinInputContext.h" + +KWinInputMethodGlobal::KWinInputMethodGlobal() + : QWaylandClientExtensionTemplate(1) +{ + initialize(); +} + +void KWinInputMethodGlobal::zwp_input_method_v1_activate(::zwp_input_method_context_v1 *context) +{ + Q_EMIT activated(context); +} + +void KWinInputMethodGlobal::zwp_input_method_v1_deactivate(::zwp_input_method_context_v1 *) +{ + Q_EMIT deactivated(); +} + +KWinInputMethod::KWinInputMethod(QObject *parent) + : TextBackend(parent) +{ + connect(&m_global, &KWinInputMethodGlobal::activated, this, &KWinInputMethod::activate); + connect(&m_global, &KWinInputMethodGlobal::deactivated, this, &KWinInputMethod::deactivate); + connect(&m_global, &QWaylandClientExtension::activeChanged, this, &KWinInputMethod::availableChanged); +} + +KWinInputMethod::~KWinInputMethod() = default; + +bool KWinInputMethod::isAvailable() const +{ + return m_global.isActive(); +} + +bool KWinInputMethod::isActive() const +{ + return m_context != nullptr; +} + +QString KWinInputMethod::surroundingText() const +{ + return m_context ? m_context->surroundingText() : QString(); +} + +int KWinInputMethod::cursorPosition() const +{ + return m_context ? m_context->cursorPosition() : -1; +} + +void KWinInputMethod::commitText(const QString &text) +{ + if (m_context) + m_context->commit(text); +} + +void KWinInputMethod::setPreedit(const QString &text) +{ + if (m_context) + m_context->preedit(text); +} + +void KWinInputMethod::sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) +{ + if (m_context) + m_context->sendKeysym(keysym, modifiers); +} + +void KWinInputMethod::activate(::zwp_input_method_context_v1 *context) +{ + m_context = std::make_unique(context); + connect(m_context.get(), &KWinInputContext::surroundingChanged, this, &TextBackend::surroundingChanged); + Q_EMIT activeChanged(); +} + +void KWinInputMethod::deactivate() +{ + m_context.reset(); + Q_EMIT activeChanged(); + Q_EMIT surroundingChanged(); +} diff --git a/src/backend/KWinInputMethod.h b/src/backend/KWinInputMethod.h new file mode 100644 index 0000000..9af8717 --- /dev/null +++ b/src/backend/KWinInputMethod.h @@ -0,0 +1,59 @@ +#pragma once + +#include "TextBackend.h" +#include "InputMethodProtocol.h" + +#include +#include + +class KWinInputContext; + +// Binds zwp_input_method_v1, which KWin only offers to the process it launched +// as the virtual keyboard (see --inputmethod / System Settings → Virtual Keyboard). +class KWinInputMethodGlobal + : public QWaylandClientExtensionTemplate + , public QtWayland::zwp_input_method_v1 +{ + Q_OBJECT + +public: + KWinInputMethodGlobal(); + +Q_SIGNALS: + void activated(::zwp_input_method_context_v1 *context); + void deactivated(); + +protected: + void zwp_input_method_v1_activate(::zwp_input_method_context_v1 *context) override; + void zwp_input_method_v1_deactivate(::zwp_input_method_context_v1 *context) override; +}; + +class KWinInputMethod : public TextBackend +{ + Q_OBJECT + +public: + explicit KWinInputMethod(QObject *parent = nullptr); + ~KWinInputMethod() override; + + // False when the compositor didn't hand us the input-method global. + bool isAvailable() const; + + bool isActive() const override; + QString surroundingText() const override; + int cursorPosition() const override; + + void commitText(const QString &text) override; + void setPreedit(const QString &text) override; + void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) override; + +Q_SIGNALS: + void availableChanged(); + +private: + void activate(::zwp_input_method_context_v1 *context); + void deactivate(); + + KWinInputMethodGlobal m_global; + std::unique_ptr m_context; +}; diff --git a/src/backend/TextBackend.h b/src/backend/TextBackend.h new file mode 100644 index 0000000..aabf1d5 --- /dev/null +++ b/src/backend/TextBackend.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +// Delivers text produced by the keyboard to the focused application. +// Implementations hide the transport (KWin input method, uinput, XTest, ...). +// Keys are identified by XKB keysyms so every backend speaks the same language. +class TextBackend : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool active READ isActive NOTIFY activeChanged) + Q_PROPERTY(QString surroundingText READ surroundingText NOTIFY surroundingChanged) + Q_PROPERTY(int cursorPosition READ cursorPosition NOTIFY surroundingChanged) + +public: + using QObject::QObject; + + // True while a text field is focused and accepts input. + virtual bool isActive() const = 0; + + // Text around the cursor, if the backend knows it (empty otherwise). + virtual QString surroundingText() const { return {}; } + virtual int cursorPosition() const { return -1; } + + Q_INVOKABLE virtual void commitText(const QString &text) = 0; + Q_INVOKABLE virtual void setPreedit(const QString &text) = 0; + Q_INVOKABLE virtual void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers = Qt::NoModifier) = 0; + +Q_SIGNALS: + void activeChanged(); + void surroundingChanged(); +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..183880f --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,49 @@ +#include "backend/KWinInputMethod.h" +#include "window/FloatingWindow.h" + +#include +#include +#include + +int main(int argc, char *argv[]) +{ + QGuiApplication app(argc, argv); + QGuiApplication::setApplicationName(QStringLiteral("sloposk")); + QGuiApplication::setDesktopFileName(QStringLiteral("org.sloposk.keyboard")); + + KWinInputMethod backend; + + QQmlApplicationEngine engine; + engine.setInitialProperties({{QStringLiteral("backend"), QVariant::fromValue(&backend)}}); + engine.loadFromModule("Sloposk", "Main"); + auto *window = engine.rootObjects().isEmpty() ? nullptr : qobject_cast(engine.rootObjects().first()); + if (!window) { + qCritical("Failed to load the keyboard UI"); + return 1; + } + + FloatingWindow placement(window); + window->setProperty("placement", QVariant::fromValue(&placement)); + + // Show while a text field is focused; with no input-method global (e.g. run + // standalone for UI work) stay visible so the keyboard can still be tried out. + auto updateVisibility = [&] { + if (backend.isActive() || !backend.isAvailable()) + placement.show(); + else + placement.hide(); + }; + QObject::connect(&backend, &TextBackend::activeChanged, &app, updateVisibility); + QObject::connect(&backend, &KWinInputMethod::availableChanged, &app, updateVisibility); + updateVisibility(); + + // Dev aid for headless testing: type a string as soon as a field is focused. + if (const QString autotype = qEnvironmentVariable("SLOPOSK_AUTOTYPE"); !autotype.isEmpty()) { + QObject::connect(&backend, &TextBackend::activeChanged, &app, [&backend, autotype] { + if (backend.isActive()) + backend.commitText(autotype); + }); + } + + return app.exec(); +} diff --git a/src/window/FloatingWindow.cpp b/src/window/FloatingWindow.cpp new file mode 100644 index 0000000..0313322 --- /dev/null +++ b/src/window/FloatingWindow.cpp @@ -0,0 +1,85 @@ +#include "FloatingWindow.h" + +#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(); +} + +void FloatingWindow::setPosition(QPoint position) +{ + position = clamped(position); + if (position == m_position) + return; + m_position = position; + apply(); + Q_EMIT positionChanged(); +} + +void FloatingWindow::moveBy(qreal dx, qreal dy) +{ + setPosition(m_position + QPoint(qRound(dx), qRound(dy))); +} + +void FloatingWindow::show() +{ + m_window->setVisible(true); +} + +void FloatingWindow::hide() +{ + m_window->setVisible(false); +} + +QPoint FloatingWindow::clamped(QPoint position) const +{ + const QRect area = m_window->screen()->geometry(); + const int maxX = qMax(0, area.width() - m_window->width()); + const int maxY = qMax(0, area.height() - m_window->height()); + return {qBound(0, position.x(), maxX), qBound(0, position.y(), maxY)}; +} + +QPoint FloatingWindow::defaultPosition() const +{ + const QRect area = m_window->screen()->geometry(); + return clamped({(area.width() - m_window->width()) / 2, + area.height() - m_window->height() - BottomGap}); +} + +void FloatingWindow::apply() +{ + if (m_layerSurface) + m_layerSurface->setMargins({m_position.x(), m_position.y(), 0, 0}); + else + m_window->setPosition(m_window->screen()->geometry().topLeft() + m_position); +} diff --git a/src/window/FloatingWindow.h b/src/window/FloatingWindow.h new file mode 100644 index 0000000..f8427e5 --- /dev/null +++ b/src/window/FloatingWindow.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +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. +class FloatingWindow : public QObject +{ + Q_OBJECT + Q_PROPERTY(QPoint position READ position WRITE setPosition NOTIFY positionChanged) + +public: + // Must be called before the window is shown for the first time. + explicit FloatingWindow(QWindow *window, QObject *parent = nullptr); + + QPoint position() const { return m_position; } + void setPosition(QPoint position); + + Q_INVOKABLE void moveBy(qreal dx, qreal dy); + Q_INVOKABLE void show(); + Q_INVOKABLE void hide(); + +Q_SIGNALS: + void positionChanged(); + +private: + QPoint clamped(QPoint position) const; + QPoint defaultPosition() const; + void apply(); + + QWindow *m_window; + LayerShellQt::Window *m_layerSurface = nullptr; + QPoint m_position; +}; diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt new file mode 100644 index 0000000..8e8d12b --- /dev/null +++ b/tools/CMakeLists.txt @@ -0,0 +1,4 @@ +# Test client: a focused line edit that prints every text change to stdout. +# Lets us verify the input-method path headlessly under a nested KWin. +qt_add_executable(sloposk-textprobe textprobe.cpp) +target_link_libraries(sloposk-textprobe PRIVATE Qt6::Widgets) diff --git a/tools/textprobe.cpp b/tools/textprobe.cpp new file mode 100644 index 0000000..09975c9 --- /dev/null +++ b/tools/textprobe.cpp @@ -0,0 +1,17 @@ +#include +#include +#include + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + QLineEdit edit; + QObject::connect(&edit, &QLineEdit::textChanged, [](const QString &text) { + std::printf("TEXT:%s\n", qPrintable(text)); + std::fflush(stdout); + }); + edit.resize(400, 40); + edit.show(); + edit.setFocus(); + return app.exec(); +}