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 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 14:43:44 +02:00
co-authored by Claude Opus 5.5
commit cdef9b4ad0
15 changed files with 612 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
build/
.cache/
*.user
+18
View File
@@ -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)
+45
View File
@@ -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" }
}
}
+42
View File
@@ -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)
+7
View File
@@ -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 <wayland-client-protocol.h>
#include "qwayland-input-method-unstable-v1.h"
+93
View File
@@ -0,0 +1,93 @@
#include "KWinInputContext.h"
#include <QElapsedTimer>
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);
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include "InputMethodProtocol.h"
#include <QObject>
// 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;
};
+80
View File
@@ -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<KWinInputContext>(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();
}
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include "TextBackend.h"
#include "InputMethodProtocol.h"
#include <QWaylandClientExtensionTemplate>
#include <memory>
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<KWinInputMethodGlobal>
, 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<KWinInputContext> m_context;
};
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <QObject>
#include <QString>
// 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();
};
+49
View File
@@ -0,0 +1,49 @@
#include "backend/KWinInputMethod.h"
#include "window/FloatingWindow.h"
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQuickWindow>
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<TextBackend *>(&backend)}});
engine.loadFromModule("Sloposk", "Main");
auto *window = engine.rootObjects().isEmpty() ? nullptr : qobject_cast<QQuickWindow *>(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();
}
+85
View File
@@ -0,0 +1,85 @@
#include "FloatingWindow.h"
#include <LayerShellQt/Window>
#include <QGuiApplication>
#include <QScreen>
#include <QWindow>
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);
}
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <QObject>
#include <QPoint>
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;
};
+4
View File
@@ -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)
+17
View File
@@ -0,0 +1,17 @@
#include <QApplication>
#include <QLineEdit>
#include <cstdio>
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();
}