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
+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;
};