Layouts, iPad-style rendering, taps, shift and layers

- JSON layouts (en-US, sv-SE inheriting number/symbol layers via base)
- LayoutModel + KeyGeometry: proportional rows, nearest-key hit testing
- KeyboardSurface: multi-pointer touch/mouse routing to the controller
- KeyboardController + ShiftState: iOS shift/caps-lock, layers, globe, dismiss
- QML view with light/dark iPad palette and drag handle
- DevHooks: SLOPOSK_AUTOTYPE, SLOPOSK_SCREENSHOT for headless checks
- Unit tests for shift, loader, geometry and controller

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 14:50:21 +02:00
co-authored by Claude Opus 5.5
parent cdef9b4ad0
commit be14bbcc62
35 changed files with 1970 additions and 65 deletions
+56 -20
View File
@@ -1,6 +1,59 @@
# --- sloposk_core: everything that runs without a compositor; also the "Sloposk" QML module.
qt_add_library(sloposk_core STATIC
backend/TextBackend.h
engine/InputEngine.h
engine/DirectEngine.h engine/DirectEngine.cpp
input/ShiftState.h input/ShiftState.cpp
input/KeyboardController.h input/KeyboardController.cpp
input/KeyboardSurface.h input/KeyboardSurface.cpp
layout/Layout.h
layout/LayoutLoader.h layout/LayoutLoader.cpp
layout/LayoutRepository.h layout/LayoutRepository.cpp
layout/KeyGeometry.h layout/KeyGeometry.cpp
layout/LayoutModel.h layout/LayoutModel.cpp
)
# 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
Keyboard.qml
Key.qml
DragHandle.qml
Theme.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()
set_source_files_properties(${PROJECT_SOURCE_DIR}/qml/Theme.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
qt_add_qml_module(sloposk_core
URI Sloposk
OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/qml/Sloposk
QML_FILES ${sloposk_qml_paths}
)
file(GLOB layout_files CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/layouts/*.json)
qt_add_resources(sloposk_core "layouts"
PREFIX /layouts
BASE ${PROJECT_SOURCE_DIR}/layouts
FILES ${layout_files}
)
target_include_directories(sloposk_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
# The generated QML type registration includes QML_ELEMENT headers by bare name.
target_include_directories(sloposk_core PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/input
${CMAKE_CURRENT_SOURCE_DIR}/layout
)
target_link_libraries(sloposk_core PUBLIC Qt6::Quick PkgConfig::XKB)
# --- sloposk: the app, with the compositor-specific parts.
qt_add_executable(sloposk
main.cpp
backend/TextBackend.h
DevHooks.h DevHooks.cpp
backend/InputMethodProtocol.h
backend/KWinInputMethod.h backend/KWinInputMethod.cpp
backend/KWinInputContext.h backend/KWinInputContext.cpp
@@ -14,28 +67,11 @@ qt6_generate_wayland_protocol_client_sources(sloposk PRIVATE_CODE FILES
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
sloposk_core
sloposk_coreplugin
Qt6::WaylandClient
LayerShellQt::Interface
PkgConfig::XKB
PkgConfig::WAYLAND
)
+38
View File
@@ -0,0 +1,38 @@
#include "DevHooks.h"
#include "backend/TextBackend.h"
#include <QCoreApplication>
#include <QQuickWindow>
#include <QTimer>
namespace {
void installAutotype(TextBackend &backend, const QString &text)
{
QObject::connect(&backend, &TextBackend::activeChanged, &backend, [&backend, text] {
if (backend.isActive())
backend.commitText(text);
});
}
void installScreenshot(QQuickWindow &window, const QString &path)
{
constexpr int SettleMs = 500;
QTimer::singleShot(SettleMs, &window, [&window, path] {
const bool saved = window.grabWindow().save(path);
if (!saved)
qWarning("Failed to save screenshot to %s", qPrintable(path));
QCoreApplication::exit(saved ? 0 : 1);
});
}
} // namespace
void installDevHooks(TextBackend &backend, QQuickWindow &window)
{
if (const QString text = qEnvironmentVariable("SLOPOSK_AUTOTYPE"); !text.isEmpty())
installAutotype(backend, text);
if (const QString path = qEnvironmentVariable("SLOPOSK_SCREENSHOT"); !path.isEmpty())
installScreenshot(window, path);
}
+9
View File
@@ -0,0 +1,9 @@
#pragma once
class QQuickWindow;
class TextBackend;
// Environment-driven helpers for headless testing; no effect when unset.
// SLOPOSK_AUTOTYPE=text commit `text` whenever a text field gets focus
// SLOPOSK_SCREENSHOT=path save the keyboard window to `path`, then quit
void installDevHooks(TextBackend &backend, QQuickWindow &window);
+18
View File
@@ -0,0 +1,18 @@
#include "DirectEngine.h"
#include "backend/TextBackend.h"
DirectEngine::DirectEngine(TextBackend &backend)
: m_backend(backend)
{
}
void DirectEngine::commitText(const QString &text)
{
m_backend.commitText(text);
}
void DirectEngine::sendKeysym(quint32 keysym)
{
m_backend.sendKeysym(keysym);
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include "InputEngine.h"
class TextBackend;
class DirectEngine : public InputEngine
{
public:
explicit DirectEngine(TextBackend &backend);
void commitText(const QString &text) override;
void sendKeysym(quint32 keysym) override;
private:
TextBackend &m_backend;
};
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <QString>
// Turns what the keyboard produces into text operations.
// DirectEngine passes them straight through; prediction and swipe engines
// will sit here too (preedit, candidates, auto-correction).
class InputEngine
{
public:
virtual ~InputEngine() = default;
virtual void commitText(const QString &text) = 0;
virtual void sendKeysym(quint32 keysym) = 0;
};
+108
View File
@@ -0,0 +1,108 @@
#include "KeyboardController.h"
#include "engine/InputEngine.h"
#include "layout/LayoutModel.h"
#include "layout/LayoutRepository.h"
#include <xkbcommon/xkbcommon-keysyms.h>
// ShiftMode mirrors ShiftState::Mode for QML.
static_assert(int(KeyboardController::ShiftMode::Off) == int(ShiftState::Mode::Off));
static_assert(int(KeyboardController::ShiftMode::Once) == int(ShiftState::Mode::Once));
static_assert(int(KeyboardController::ShiftMode::Locked) == int(ShiftState::Mode::Locked));
KeyboardController::KeyboardController(InputEngine &engine, LayoutModel &model,
const LayoutRepository &layouts, QObject *parent)
: QObject(parent)
, m_engine(engine)
, m_model(model)
, m_layouts(layouts)
{
if (!m_layouts.layouts().isEmpty())
m_model.setLayout(m_layouts.layouts().first());
}
KeyboardController::ShiftMode KeyboardController::shiftMode() const
{
return ShiftMode(m_shift.mode());
}
void KeyboardController::keyDown(const KeyDef &key, qint64 timeMs)
{
switch (key.type) {
case KeyType::Shift:
changeShift([&] { m_shift.press(timeMs); });
break;
case KeyType::Backspace:
m_engine.sendKeysym(XKB_KEY_BackSpace);
break;
case KeyType::LayerSwitch:
m_model.setLayer(key.target);
break;
default:
break; // everything else acts on release
}
}
void KeyboardController::keyUp(const KeyDef &key, KeyOutput output, const QString &alternate, qint64)
{
switch (key.type) {
case KeyType::Shift:
changeShift([&] { m_shift.release(); });
break;
case KeyType::Character:
if (output == KeyOutput::Alternate && !alternate.isEmpty())
typeText(alternate);
else if (output == KeyOutput::Flick && !key.flick.isEmpty())
typeText(key.flick);
else
typeText(key.text(m_shift.isUppercase()));
break;
case KeyType::Space:
typeText(QStringLiteral(" "));
break;
case KeyType::Enter:
m_engine.sendKeysym(XKB_KEY_Return);
break;
case KeyType::Globe:
nextLayout();
break;
case KeyType::Dismiss:
Q_EMIT dismissRequested();
break;
default:
break;
}
}
void KeyboardController::keyCancelled(const KeyDef &key)
{
if (key.type == KeyType::Shift)
changeShift([&] { m_shift.release(); });
}
void KeyboardController::nextLayout()
{
const auto &layouts = m_layouts.layouts();
if (layouts.size() < 2)
return;
m_layoutIndex = int((m_layoutIndex + 1) % layouts.size());
m_model.setLayout(layouts[m_layoutIndex]);
}
void KeyboardController::typeText(const QString &text)
{
m_engine.commitText(text);
changeShift([&] { m_shift.characterTyped(); });
}
template<typename Change>
void KeyboardController::changeShift(Change change)
{
const ShiftState::Mode before = m_shift.mode();
change();
if (m_shift.mode() == before)
return;
m_model.setUppercase(m_shift.isUppercase());
Q_EMIT shiftChanged();
}
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include "ShiftState.h"
#include "layout/Layout.h"
#include <QObject>
#include <QtQml/qqmlregistration.h>
class InputEngine;
class LayoutModel;
class LayoutRepository;
// Which of a key's outputs the gesture selected.
enum class KeyOutput { Primary, Flick, Alternate };
// Keyboard rules: what each key does, shift, layers and layout switching.
// Knows nothing about touch or Wayland: it receives key presses and talks to
// an InputEngine.
class KeyboardController : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Created in C++")
Q_PROPERTY(ShiftMode shiftMode READ shiftMode NOTIFY shiftChanged)
public:
enum class ShiftMode { Off, Once, Locked };
Q_ENUM(ShiftMode)
KeyboardController(InputEngine &engine, LayoutModel &model, const LayoutRepository &layouts,
QObject *parent = nullptr);
ShiftMode shiftMode() const;
void keyDown(const KeyDef &key, qint64 timeMs);
void keyUp(const KeyDef &key, KeyOutput output, const QString &alternate, qint64 timeMs);
void keyCancelled(const KeyDef &key);
Q_INVOKABLE void nextLayout();
Q_SIGNALS:
void shiftChanged();
void dismissRequested();
private:
void typeText(const QString &text);
template<typename Change>
void changeShift(Change change);
InputEngine &m_engine;
LayoutModel &m_model;
const LayoutRepository &m_layouts;
ShiftState m_shift;
int m_layoutIndex = 0;
};
+139
View File
@@ -0,0 +1,139 @@
#include "KeyboardSurface.h"
#include "KeyboardController.h"
#include "layout/LayoutModel.h"
namespace {
constexpr int MousePointerId = -1;
}
KeyboardSurface::KeyboardSurface(QQuickItem *parent)
: QQuickItem(parent)
{
setAcceptTouchEvents(true);
setAcceptedMouseButtons(Qt::LeftButton);
}
void KeyboardSurface::setModel(LayoutModel *model)
{
if (model == m_model)
return;
cancelAll();
if (m_model)
disconnect(m_model, nullptr, this, nullptr);
m_model = model;
if (m_model) {
connect(m_model, &QAbstractItemModel::modelAboutToBeReset, this, &KeyboardSurface::forgetIndices);
m_model->setSize(size());
}
Q_EMIT modelChanged();
}
void KeyboardSurface::setController(KeyboardController *controller)
{
if (controller == m_controller)
return;
cancelAll();
m_controller = controller;
Q_EMIT controllerChanged();
}
void KeyboardSurface::touchEvent(QTouchEvent *event)
{
if (event->type() == QEvent::TouchCancel) {
cancelAll();
return;
}
const qint64 time = qint64(event->timestamp());
for (const QEventPoint &point : event->points()) {
switch (point.state()) {
case QEventPoint::Pressed: pointerDown(point.id(), point.position(), time); break;
case QEventPoint::Updated: pointerMove(point.id(), point.position()); break;
case QEventPoint::Released: pointerUp(point.id(), time); break;
default: break;
}
}
event->accept();
}
void KeyboardSurface::touchUngrabEvent()
{
cancelAll();
}
void KeyboardSurface::mousePressEvent(QMouseEvent *event)
{
pointerDown(MousePointerId, event->position(), qint64(event->timestamp()));
}
void KeyboardSurface::mouseMoveEvent(QMouseEvent *event)
{
pointerMove(MousePointerId, event->position());
}
void KeyboardSurface::mouseReleaseEvent(QMouseEvent *event)
{
pointerUp(MousePointerId, qint64(event->timestamp()));
}
void KeyboardSurface::mouseUngrabEvent()
{
cancelAll();
}
void KeyboardSurface::geometryChange(const QRectF &newGeometry, const QRectF &oldGeometry)
{
QQuickItem::geometryChange(newGeometry, oldGeometry);
if (m_model)
m_model->setSize(newGeometry.size());
}
void KeyboardSurface::pointerDown(int id, QPointF pos, qint64 timeMs)
{
if (!m_model || !m_controller)
return;
const int index = m_model->keyAt(pos);
if (index < 0)
return;
const Pointer pointer{m_model->key(index), index, pos, pos};
m_pointers.insert(id, pointer);
m_model->setPressed(index, true);
m_controller->keyDown(pointer.key, timeMs);
}
void KeyboardSurface::pointerMove(int id, QPointF pos)
{
auto it = m_pointers.find(id);
if (it != m_pointers.end())
it->position = pos;
}
void KeyboardSurface::pointerUp(int id, qint64 timeMs)
{
const auto it = m_pointers.constFind(id);
if (it == m_pointers.cend())
return;
const Pointer pointer = *it;
m_pointers.erase(it);
m_model->setPressed(pointer.index, false);
m_controller->keyUp(pointer.key, KeyOutput::Primary, {}, timeMs);
}
void KeyboardSurface::cancelAll()
{
const auto pointers = std::exchange(m_pointers, {});
for (const Pointer &pointer : pointers) {
if (m_model)
m_model->setPressed(pointer.index, false);
if (m_controller)
m_controller->keyCancelled(pointer.key);
}
}
void KeyboardSurface::forgetIndices()
{
for (Pointer &pointer : m_pointers)
pointer.index = -1;
}
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include "KeyboardController.h"
#include "layout/Layout.h"
#include "layout/LayoutModel.h"
#include <QHash>
#include <QPointer>
#include <QQuickItem>
// Receives all touch points (and the mouse) over the keyboard, maps them to
// keys and forwards key presses to the controller. Tracks each pointer
// separately so several fingers can type at once.
class KeyboardSurface : public QQuickItem
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(LayoutModel *model READ model WRITE setModel NOTIFY modelChanged)
Q_PROPERTY(KeyboardController *controller READ controller WRITE setController NOTIFY controllerChanged)
public:
explicit KeyboardSurface(QQuickItem *parent = nullptr);
LayoutModel *model() const { return m_model; }
void setModel(LayoutModel *model);
KeyboardController *controller() const { return m_controller; }
void setController(KeyboardController *controller);
Q_SIGNALS:
void modelChanged();
void controllerChanged();
protected:
void touchEvent(QTouchEvent *event) override;
void touchUngrabEvent() override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void mouseUngrabEvent() override;
void geometryChange(const QRectF &newGeometry, const QRectF &oldGeometry) override;
private:
struct Pointer {
KeyDef key; // copied: the model may switch layers mid-press
int index; // into the model, -1 once the layer changed
QPointF start;
QPointF position;
};
void pointerDown(int id, QPointF pos, qint64 timeMs);
void pointerMove(int id, QPointF pos);
void pointerUp(int id, qint64 timeMs);
void cancelAll();
void forgetIndices();
QPointer<LayoutModel> m_model;
QPointer<KeyboardController> m_controller;
QHash<int, Pointer> m_pointers;
};
+37
View File
@@ -0,0 +1,37 @@
#include "ShiftState.h"
void ShiftState::press(qint64 timeMs)
{
const bool doubleTap = timeMs - m_lastPress <= DoubleTapMs;
m_lastPress = timeMs;
m_held = true;
m_typedWhileHeld = false;
switch (m_mode) {
case Mode::Off: m_mode = Mode::Once; break;
case Mode::Once: m_mode = doubleTap ? Mode::Locked : Mode::Off; break;
case Mode::Locked: m_mode = Mode::Off; break;
}
}
void ShiftState::release()
{
m_held = false;
if (m_typedWhileHeld && m_mode == Mode::Once)
m_mode = Mode::Off;
}
void ShiftState::characterTyped()
{
if (m_held)
m_typedWhileHeld = true;
else if (m_mode == Mode::Once)
m_mode = Mode::Off;
}
void ShiftState::setAutomatic(bool capitalize)
{
if (m_held || m_mode == Mode::Locked)
return;
m_mode = capitalize ? Mode::Once : Mode::Off;
}
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <QtGlobal>
#include <limits>
// iOS shift semantics:
// - tap: shift the next character only
// - double-tap: caps lock; tap again to unlock
// - hold while typing: every character shifted, off on release
// Times are in milliseconds and passed in, so this stays deterministic.
class ShiftState
{
public:
enum class Mode { Off, Once, Locked };
static constexpr qint64 DoubleTapMs = 300;
Mode mode() const { return m_mode; }
bool isUppercase() const { return m_mode != Mode::Off; }
void press(qint64 timeMs);
void release();
void characterTyped();
// Auto-capitalization hint; ignored while shift is held or locked.
void setAutomatic(bool capitalize);
private:
Mode m_mode = Mode::Off;
bool m_held = false;
bool m_typedWhileHeld = false;
qint64 m_lastPress = std::numeric_limits<qint64>::min() / 2;
};
+46
View File
@@ -0,0 +1,46 @@
#include "KeyGeometry.h"
#include <algorithm>
namespace {
qreal rowUnits(const Row &row)
{
qreal units = 0;
for (const KeyDef &key : row)
units += key.width;
return units;
}
} // namespace
QList<QRectF> computeKeyRects(const Layer &layer, QSizeF size, KeyboardMetrics metrics)
{
QList<QRectF> rects;
if (layer.rows.isEmpty())
return rects;
qreal maxUnits = 0;
for (const Row &row : layer.rows)
maxUnits = std::max(maxUnits, rowUnits(row));
const qreal innerWidth = size.width() - 2 * metrics.padding;
const qreal innerHeight = size.height() - 2 * metrics.padding;
const int rowCount = int(layer.rows.size());
// Each cell includes one trailing gap; the last gap of a row is dropped.
const qreal unitWidth = (innerWidth + metrics.gap) / maxUnits;
const qreal rowHeight = (innerHeight - (rowCount - 1) * metrics.gap) / rowCount;
qreal y = metrics.padding;
for (const Row &row : layer.rows) {
qreal x = metrics.padding + (maxUnits - rowUnits(row)) * unitWidth / 2;
for (const KeyDef &key : row) {
const qreal cellWidth = key.width * unitWidth;
rects.append(QRectF(x, y, std::max<qreal>(0, cellWidth - metrics.gap), rowHeight));
x += cellWidth;
}
y += rowHeight + metrics.gap;
}
return rects;
}
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include "Layout.h"
#include <QRectF>
#include <QSizeF>
struct KeyboardMetrics {
qreal padding = 6; // around the whole keyboard
qreal gap = 8; // between keys, horizontally and vertically
};
// Lays out a layer's keys to fill `size`. Returns one rect per key, in row
// order (spacers included). One key unit has the same width in every row, so
// rows with equal unit totals line up; shorter rows are centered.
QList<QRectF> computeKeyRects(const Layer &layer, QSizeF size, KeyboardMetrics metrics = {});
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <QList>
#include <QString>
#include <QStringList>
enum class KeyType {
Character,
Space,
Backspace,
Enter,
Shift,
LayerSwitch,
Globe,
Dismiss,
Spacer, // empty room, e.g. the half-key indent of the home row
};
struct KeyDef {
KeyType type = KeyType::Character;
QString label; // shown and typed (for character keys)
QString shifted; // explicit shifted form; empty means label.toUpper()
QString flick; // secondary symbol, typed by flicking down
QStringList alternates; // long-press choices
QString target; // layer to switch to (LayerSwitch only)
qreal width = 1.0; // in key units
// Text this key shows/types; only character keys change case.
QString text(bool uppercase) const
{
if (!uppercase || type != KeyType::Character)
return label;
return shifted.isEmpty() ? label.toUpper() : shifted;
}
};
using Row = QList<KeyDef>;
struct Layer {
QString name;
QList<Row> rows;
};
struct Layout {
QString id; // e.g. "en-US"
QString name; // human readable
QString locale; // e.g. "en_US"
QString base; // id of a layout to take missing layers from
QList<Layer> layers; // the first one is shown by default
const Layer *layer(const QString &layerName) const
{
for (const Layer &l : layers) {
if (l.name == layerName)
return &l;
}
return nullptr;
}
};
+160
View File
@@ -0,0 +1,160 @@
#include "LayoutLoader.h"
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
namespace LayoutLoader {
namespace {
const QHash<QString, KeyType> &keyTypesByName()
{
static const QHash<QString, KeyType> types = {
{QStringLiteral("char"), KeyType::Character},
{QStringLiteral("space"), KeyType::Space},
{QStringLiteral("backspace"), KeyType::Backspace},
{QStringLiteral("enter"), KeyType::Enter},
{QStringLiteral("shift"), KeyType::Shift},
{QStringLiteral("layer"), KeyType::LayerSwitch},
{QStringLiteral("globe"), KeyType::Globe},
{QStringLiteral("dismiss"), KeyType::Dismiss},
{QStringLiteral("spacer"), KeyType::Spacer},
};
return types;
}
std::optional<KeyDef> parseKey(const QJsonValue &value, QString *error)
{
KeyDef key;
if (value.isString()) {
key.label = value.toString();
return key;
}
if (!value.isObject()) {
*error = QStringLiteral("key must be a string or an object");
return std::nullopt;
}
const QJsonObject obj = value.toObject();
const QString typeName = obj.value(u"type").toString(QStringLiteral("char"));
const auto type = keyTypesByName().constFind(typeName);
if (type == keyTypesByName().cend()) {
*error = QStringLiteral("unknown key type \"%1\"").arg(typeName);
return std::nullopt;
}
key.type = *type;
key.label = obj.value(u"label").toString();
key.shifted = obj.value(u"shifted").toString();
key.flick = obj.value(u"flick").toString();
key.target = obj.value(u"target").toString();
key.width = obj.value(u"width").toDouble(1.0);
for (const QJsonValue &alt : obj.value(u"alternates").toArray())
key.alternates.append(alt.toString());
if (key.type == KeyType::Character && key.label.isEmpty()) {
*error = QStringLiteral("character key without a label");
return std::nullopt;
}
if (key.width <= 0) {
*error = QStringLiteral("key \"%1\" has a non-positive width").arg(key.label);
return std::nullopt;
}
return key;
}
std::optional<Layer> parseLayer(const QJsonObject &obj, QString *error)
{
Layer layer;
layer.name = obj.value(u"name").toString();
if (layer.name.isEmpty()) {
*error = QStringLiteral("layer without a name");
return std::nullopt;
}
for (const QJsonValue &rowValue : obj.value(u"rows").toArray()) {
Row row;
for (const QJsonValue &keyValue : rowValue.toArray()) {
auto key = parseKey(keyValue, error);
if (!key) {
*error = QStringLiteral("layer \"%1\": %2").arg(layer.name, *error);
return std::nullopt;
}
row.append(*key);
}
layer.rows.append(row);
}
if (layer.rows.isEmpty()) {
*error = QStringLiteral("layer \"%1\" has no rows").arg(layer.name);
return std::nullopt;
}
return layer;
}
} // namespace
std::optional<Layout> parse(const QByteArray &json, QString *error)
{
QJsonParseError parseError;
const QJsonDocument doc = QJsonDocument::fromJson(json, &parseError);
if (!doc.isObject()) {
*error = parseError.error != QJsonParseError::NoError ? parseError.errorString()
: QStringLiteral("top level must be an object");
return std::nullopt;
}
const QJsonObject root = doc.object();
Layout layout;
layout.id = root.value(u"id").toString();
layout.name = root.value(u"name").toString(layout.id);
layout.locale = root.value(u"locale").toString();
layout.base = root.value(u"base").toString();
if (layout.id.isEmpty()) {
*error = QStringLiteral("layout without an id");
return std::nullopt;
}
for (const QJsonValue &layerValue : root.value(u"layers").toArray()) {
auto layer = parseLayer(layerValue.toObject(), error);
if (!layer)
return std::nullopt;
layout.layers.append(*layer);
}
if (layout.layers.isEmpty() && layout.base.isEmpty()) {
*error = QStringLiteral("layout has no layers and no base");
return std::nullopt;
}
return layout;
}
std::optional<Layout> load(const QString &path, QString *error)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
*error = file.errorString();
return std::nullopt;
}
return parse(file.readAll(), error);
}
bool validate(const Layout &layout, QString *error)
{
for (const Layer &layer : layout.layers) {
for (const Row &row : layer.rows) {
for (const KeyDef &key : row) {
if (key.type == KeyType::LayerSwitch && !layout.layer(key.target)) {
*error = QStringLiteral("layer \"%1\" switches to unknown layer \"%2\"")
.arg(layer.name, key.target);
return false;
}
}
}
}
return true;
}
} // namespace LayoutLoader
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include "Layout.h"
#include <optional>
class QJsonValue;
// Parses the JSON layout format (see layouts/en-US.json).
// A key is either a plain string (a character key) or an object:
// {"type": "backspace", "width": 1.5}
// {"label": "e", "flick": "3", "alternates": ["é", "è"]}
namespace LayoutLoader {
std::optional<Layout> parse(const QByteArray &json, QString *error);
std::optional<Layout> load(const QString &path, QString *error);
// Checks references that can only be resolved once bases are merged in.
bool validate(const Layout &layout, QString *error);
} // namespace LayoutLoader
+165
View File
@@ -0,0 +1,165 @@
#include "LayoutModel.h"
#include <limits>
namespace {
QString keyTypeName(KeyType type)
{
switch (type) {
case KeyType::Character: return QStringLiteral("char");
case KeyType::Space: return QStringLiteral("space");
case KeyType::Backspace: return QStringLiteral("backspace");
case KeyType::Enter: return QStringLiteral("enter");
case KeyType::Shift: return QStringLiteral("shift");
case KeyType::LayerSwitch: return QStringLiteral("layer");
case KeyType::Globe: return QStringLiteral("globe");
case KeyType::Dismiss: return QStringLiteral("dismiss");
case KeyType::Spacer: return QStringLiteral("spacer");
}
return {};
}
qreal distanceSquared(const QRectF &rect, QPointF p)
{
const qreal dx = std::max({rect.left() - p.x(), 0.0, p.x() - rect.right()});
const qreal dy = std::max({rect.top() - p.y(), 0.0, p.y() - rect.bottom()});
return dx * dx + dy * dy;
}
} // namespace
int LayoutModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : int(m_keys.size());
}
QVariant LayoutModel::data(const QModelIndex &index, int role) const
{
if (!checkIndex(index, CheckIndexOption::IndexIsValid))
return {};
const KeyDef &key = m_keys.at(index.row());
const QRectF rect = m_rects.value(index.row());
switch (role) {
case LabelRole: return key.text(m_uppercase);
case FlickLabelRole: return key.flick;
case KeyTypeRole: return keyTypeName(key.type);
case KeyXRole: return rect.x();
case KeyYRole: return rect.y();
case KeyWidthRole: return rect.width();
case KeyHeightRole: return rect.height();
case PressedRole: return m_pressCount.at(index.row()) > 0;
}
return {};
}
QHash<int, QByteArray> LayoutModel::roleNames() const
{
return {
{LabelRole, "label"},
{FlickLabelRole, "flickLabel"},
{KeyTypeRole, "keyType"},
{KeyXRole, "keyX"},
{KeyYRole, "keyY"},
{KeyWidthRole, "keyWidth"},
{KeyHeightRole, "keyHeight"},
{PressedRole, "pressed"},
};
}
void LayoutModel::setLayout(const Layout &layout)
{
m_layout = layout;
m_layerName = layout.layers.isEmpty() ? QString() : layout.layers.first().name;
rebuild();
Q_EMIT layoutChanged();
Q_EMIT layerChanged();
}
void LayoutModel::setLayer(const QString &layerName)
{
if (layerName == m_layerName || !m_layout.layer(layerName))
return;
m_layerName = layerName;
rebuild();
Q_EMIT layerChanged();
}
void LayoutModel::setSize(QSizeF size)
{
if (size == m_size)
return;
m_size = size;
relayout();
if (!m_keys.isEmpty())
Q_EMIT dataChanged(index(0), index(rowCount() - 1), {KeyXRole, KeyYRole, KeyWidthRole, KeyHeightRole});
}
void LayoutModel::setUppercase(bool uppercase)
{
if (uppercase == m_uppercase)
return;
m_uppercase = uppercase;
if (!m_keys.isEmpty())
Q_EMIT dataChanged(index(0), index(rowCount() - 1), {LabelRole});
}
void LayoutModel::setPressed(int row, bool pressed)
{
if (row < 0 || row >= m_pressCount.size())
return;
const bool wasPressed = m_pressCount[row] > 0;
m_pressCount[row] = std::max(0, m_pressCount[row] + (pressed ? 1 : -1));
if (wasPressed != (m_pressCount[row] > 0))
Q_EMIT dataChanged(index(row), index(row), {PressedRole});
}
int LayoutModel::keyAt(QPointF pos) const
{
int nearest = -1;
qreal best = std::numeric_limits<qreal>::max();
for (qsizetype i = 0; i < m_rects.size(); ++i) {
const qreal d = distanceSquared(m_rects[i], pos);
if (d < best) {
best = d;
nearest = int(i);
}
}
return nearest;
}
void LayoutModel::rebuild()
{
beginResetModel();
m_keys.clear();
if (const Layer *layer = m_layout.layer(m_layerName)) {
for (const Row &row : layer->rows) {
for (const KeyDef &key : row) {
if (key.type != KeyType::Spacer)
m_keys.append(key);
}
}
}
m_pressCount = QList<int>(m_keys.size(), 0);
relayout();
endResetModel();
}
void LayoutModel::relayout()
{
m_rects.clear();
const Layer *layer = m_layout.layer(m_layerName);
if (!layer || m_size.isEmpty())
return;
const QList<QRectF> all = computeKeyRects(*layer, m_size);
qsizetype i = 0;
for (const Row &row : layer->rows) {
for (const KeyDef &key : row) {
if (key.type != KeyType::Spacer)
m_rects.append(all[i]);
++i;
}
}
}
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include "KeyGeometry.h"
#include "Layout.h"
#include <QAbstractListModel>
#include <QtQml/qqmlregistration.h>
// The keys of the current layer, positioned for the current size.
// Spacers take room but are not exposed as items.
class LayoutModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Created in C++")
Q_PROPERTY(QString layoutName READ layoutName NOTIFY layoutChanged)
Q_PROPERTY(QString layerName READ layerName NOTIFY layerChanged)
public:
enum Role {
LabelRole = Qt::UserRole + 1,
FlickLabelRole,
KeyTypeRole,
KeyXRole,
KeyYRole,
KeyWidthRole,
KeyHeightRole,
PressedRole,
};
using QAbstractListModel::QAbstractListModel;
int rowCount(const QModelIndex &parent = {}) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
QString layoutName() const { return m_layout.name; }
QString layerName() const { return m_layerName; }
void setLayout(const Layout &layout);
void setLayer(const QString &layerName);
void setSize(QSizeF size);
void setUppercase(bool uppercase);
void setPressed(int index, bool pressed);
// Key under `pos`, or the nearest one; -1 if there are no keys.
int keyAt(QPointF pos) const;
const KeyDef &key(int index) const { return m_keys.at(index); }
Q_SIGNALS:
void layoutChanged();
void layerChanged();
private:
void rebuild();
void relayout();
Layout m_layout;
QString m_layerName;
QSizeF m_size;
bool m_uppercase = false;
QList<KeyDef> m_keys;
QList<QRectF> m_rects;
QList<int> m_pressCount; // several fingers may hold one key
};
+75
View File
@@ -0,0 +1,75 @@
#include "LayoutRepository.h"
#include "LayoutLoader.h"
#include <QDir>
#include <QLoggingCategory>
#include <algorithm>
Q_LOGGING_CATEGORY(lcLayouts, "sloposk.layouts")
void LayoutRepository::loadDirectory(const QString &dir)
{
const QDir directory(dir);
for (const QString &file : directory.entryList({QStringLiteral("*.json")}, QDir::Files, QDir::Name)) {
QString error;
const auto layout = LayoutLoader::load(directory.filePath(file), &error);
if (layout)
add(*layout);
else
qCWarning(lcLayouts) << "Skipping" << directory.filePath(file) << ":" << error;
}
}
void LayoutRepository::finalize(const QString &preferredLocale)
{
resolveBases();
m_layouts.removeIf([](const Layout &layout) {
QString error;
if (LayoutLoader::validate(layout, &error))
return false;
qCWarning(lcLayouts) << "Dropping layout" << layout.id << ":" << error;
return true;
});
std::stable_partition(m_layouts.begin(), m_layouts.end(), [&](const Layout &layout) {
return layout.locale == preferredLocale;
});
}
int LayoutRepository::indexOf(const QString &id) const
{
for (qsizetype i = 0; i < m_layouts.size(); ++i) {
if (m_layouts[i].id == id)
return int(i);
}
return -1;
}
void LayoutRepository::add(const Layout &layout)
{
const int existing = indexOf(layout.id);
if (existing >= 0)
m_layouts[existing] = layout;
else
m_layouts.append(layout);
}
void LayoutRepository::resolveBases()
{
// One level of inheritance keeps this simple and cycle-free.
for (Layout &layout : m_layouts) {
if (layout.base.isEmpty())
continue;
const int baseIndex = indexOf(layout.base);
if (baseIndex < 0) {
qCWarning(lcLayouts) << "Layout" << layout.id << "has unknown base" << layout.base;
continue;
}
for (const Layer &layer : m_layouts[baseIndex].layers) {
if (!layout.layer(layer.name))
layout.layers.append(layer);
}
}
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include "Layout.h"
// All usable layouts: built-ins from resources plus user overrides on disk.
// A later layout with the same id replaces an earlier one.
class LayoutRepository
{
public:
// Loads every *.json in dir. Invalid files are skipped with a warning.
void loadDirectory(const QString &dir);
// Merges base layers in, drops invalid layouts, puts `locale` first.
void finalize(const QString &preferredLocale);
const QList<Layout> &layouts() const { return m_layouts; }
int indexOf(const QString &id) const;
private:
void add(const Layout &layout);
void resolveBases();
QList<Layout> m_layouts;
};
+43 -12
View File
@@ -1,9 +1,32 @@
#include "DevHooks.h"
#include "backend/KWinInputMethod.h"
#include "engine/DirectEngine.h"
#include "input/KeyboardController.h"
#include "layout/LayoutModel.h"
#include "layout/LayoutRepository.h"
#include "window/FloatingWindow.h"
#include <QGuiApplication>
#include <QLocale>
#include <QQmlApplicationEngine>
#include <QQmlExtensionPlugin>
#include <QQuickWindow>
#include <QStandardPaths>
Q_IMPORT_QML_PLUGIN(SloposkPlugin)
namespace {
void loadLayouts(LayoutRepository &layouts)
{
layouts.loadDirectory(QStringLiteral(":/layouts"));
// User layouts (~/.local/share/sloposk/layouts) override built-ins by id.
layouts.loadDirectory(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)
+ QStringLiteral("/layouts"));
layouts.finalize(QLocale::system().name());
}
} // namespace
int main(int argc, char *argv[])
{
@@ -11,12 +34,25 @@ int main(int argc, char *argv[])
QGuiApplication::setApplicationName(QStringLiteral("sloposk"));
QGuiApplication::setDesktopFileName(QStringLiteral("org.sloposk.keyboard"));
KWinInputMethod backend;
LayoutRepository layouts;
loadLayouts(layouts);
if (layouts.layouts().isEmpty()) {
qCritical("No usable keyboard layouts found");
return 1;
}
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());
KWinInputMethod backend;
DirectEngine engine(backend);
LayoutModel model;
KeyboardController controller(engine, model, layouts);
QQmlApplicationEngine qml;
qml.setInitialProperties({
{QStringLiteral("layoutModel"), QVariant::fromValue(&model)},
{QStringLiteral("controller"), QVariant::fromValue(&controller)},
});
qml.loadFromModule("Sloposk", "Main");
auto *window = qml.rootObjects().isEmpty() ? nullptr : qobject_cast<QQuickWindow *>(qml.rootObjects().first());
if (!window) {
qCritical("Failed to load the keyboard UI");
return 1;
@@ -35,15 +71,10 @@ int main(int argc, char *argv[])
};
QObject::connect(&backend, &TextBackend::activeChanged, &app, updateVisibility);
QObject::connect(&backend, &KWinInputMethod::availableChanged, &app, updateVisibility);
QObject::connect(&controller, &KeyboardController::dismissRequested, &placement, &FloatingWindow::hide);
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);
});
}
installDevHooks(backend, *window);
return app.exec();
}