Fix double-space period, shift double-tap; settings; letters from system keymap

- Double-space replaced the space via a Backspace keysym, which clients
  apply after the commit: use delete_surrounding_text with the commit
- Tests use a shared FakeEngine that applies ops to a simulated field
- Double-tap shift locks at a sentence start; typing between taps doesn't
- Organization name set: config lives in ~/.config/sloposk/
- Settings (auto-capitalization, double-space period) with a ⚙ panel
- Letter rows come from the compositor's XKB keymap; other letters of the
  layout become long-press alternates; globe switches the system layout
  over org.kde.keyboard
- One JSON template for non-letter layers; stretch keys even out rows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 20:28:39 +02:00
co-authored by Claude Opus 5.5
parent c6c977eb4c
commit 5277ab16ff
49 changed files with 1162 additions and 359 deletions
+10 -1
View File
@@ -10,15 +10,18 @@ qt_add_library(sloposk_core STATIC
input/AlternatesPicker.h input/AlternatesPicker.cpp
input/KeyRepeater.h input/KeyRepeater.cpp
input/TextRules.h input/TextRules.cpp
input/TypingOptions.h
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/KeymapLetters.h layout/KeymapLetters.cpp
layout/LayoutBuilder.h layout/LayoutBuilder.cpp
layout/KeyGeometry.h layout/KeyGeometry.cpp
layout/LayoutModel.h layout/LayoutModel.cpp
window/VisibilityController.h window/VisibilityController.cpp
window/WindowSettings.h window/WindowSettings.cpp
settings/Settings.h settings/Settings.cpp
)
# QML lives in the top-level qml/ dir; alias each file so the module sees it at its root.
@@ -29,6 +32,9 @@ set(SLOPOSK_QML_FILES
AlternatesPopup.qml
DragHandle.qml
ResizeHandle.qml
SettingsButton.qml
SettingsPanel.qml
ToggleRow.qml
Theme.qml
)
list(TRANSFORM SLOPOSK_QML_FILES PREPEND ${PROJECT_SOURCE_DIR}/qml/ OUTPUT_VARIABLE sloposk_qml_paths)
@@ -55,6 +61,7 @@ target_include_directories(sloposk_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_include_directories(sloposk_core PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/input
${CMAKE_CURRENT_SOURCE_DIR}/layout
${CMAKE_CURRENT_SOURCE_DIR}/settings
)
target_link_libraries(sloposk_core PUBLIC Qt6::Quick PkgConfig::XKB)
@@ -64,9 +71,11 @@ qt_add_executable(sloposk
main.cpp
app/DevHooks.h app/DevHooks.cpp
app/DBusControl.h app/DBusControl.cpp
app/SystemLayouts.h app/SystemLayouts.cpp
backend/InputMethodProtocol.h
backend/KWinInputMethod.h backend/KWinInputMethod.cpp
backend/KWinInputContext.h backend/KWinInputContext.cpp
backend/WaylandKeymap.h backend/WaylandKeymap.cpp
window/FloatingWindow.h window/FloatingWindow.cpp
)
+49
View File
@@ -0,0 +1,49 @@
#include "SystemLayouts.h"
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusReply>
namespace {
const QString Service = QStringLiteral("org.kde.keyboard");
const QString Path = QStringLiteral("/Layouts");
const QString Interface = QStringLiteral("org.kde.KeyboardLayouts");
QDBusMessage call(const QString &method, const QVariantList &args = {})
{
QDBusMessage message = QDBusMessage::createMethodCall(Service, Path, Interface, method);
message.setArguments(args);
return message;
}
} // namespace
SystemLayouts::SystemLayouts(QObject *parent)
: QObject(parent)
{
QDBusConnection bus = QDBusConnection::sessionBus();
const QDBusReply<uint> reply = bus.call(call(QStringLiteral("getLayout")));
m_system = reply.isValid();
if (!m_system)
return;
m_current = reply.value();
bus.connect(Service, Path, Interface, QStringLiteral("layoutChanged"), this, SLOT(setCurrent(uint)));
}
void SystemLayouts::next(quint32 count)
{
if (count < 2)
return;
const quint32 index = (m_current + 1) % count;
if (m_system)
QDBusConnection::sessionBus().asyncCall(call(QStringLiteral("setLayout"), {index})); // echoes layoutChanged
else
setCurrent(index);
}
void SystemLayouts::setCurrent(uint index)
{
if (index == m_current)
return;
m_current = index;
Q_EMIT currentChanged();
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <QObject>
// Which of the system's keyboard layouts is active, followed and switched
// over KWin's org.kde.keyboard D-Bus interface, so the on-screen and the
// physical keyboard always agree. Without that service it switches locally.
class SystemLayouts : public QObject
{
Q_OBJECT
public:
explicit SystemLayouts(QObject *parent = nullptr);
quint32 current() const { return m_current; }
// Switches to the next of `count` layouts.
void next(quint32 count);
Q_SIGNALS:
void currentChanged();
private Q_SLOTS:
void setCurrent(uint index);
private:
bool m_system = false;
quint32 m_current = 0;
};
+8
View File
@@ -53,6 +53,14 @@ void KWinInputContext::commit(const QString &text)
commit_string(m_serial, text);
}
void KWinInputContext::replaceBeforeCursor(const QString &before, const QString &text)
{
// Offsets are UTF-8 bytes relative to the cursor; applied with the commit.
const auto bytes = uint32_t(before.toUtf8().size());
delete_surrounding_text(-int32_t(bytes), bytes);
commit_string(m_serial, text);
}
void KWinInputContext::preedit(const QString &text)
{
preedit_string(m_serial, text, text);
+1
View File
@@ -19,6 +19,7 @@ public:
TextContext context() const { return m_context; }
void commit(const QString &text);
void replaceBeforeCursor(const QString &before, const QString &text);
void preedit(const QString &text);
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers);
+6
View File
@@ -48,6 +48,12 @@ void KWinInputMethod::commitText(const QString &text)
m_context->commit(text);
}
void KWinInputMethod::replaceBeforeCursor(const QString &before, const QString &text)
{
if (m_context)
m_context->replaceBeforeCursor(before, text);
}
void KWinInputMethod::setPreedit(const QString &text)
{
if (m_context)
+1
View File
@@ -43,6 +43,7 @@ public:
TextContext context() const override;
void commitText(const QString &text) override;
void replaceBeforeCursor(const QString &before, const QString &text) override;
void setPreedit(const QString &text) override;
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) override;
+4
View File
@@ -23,6 +23,10 @@ public:
virtual TextContext context() const { return {}; }
Q_INVOKABLE virtual void commitText(const QString &text) = 0;
// Replaces `before`, the text right before the cursor, with `text` in one
// step. Don't emulate this with a Backspace keysym: clients may apply key
// events after commits, so the deletion would hit the new text.
virtual void replaceBeforeCursor(const QString &before, 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;
+125
View File
@@ -0,0 +1,125 @@
#include "WaylandKeymap.h"
#include <QGuiApplication>
#include <wayland-client.h>
#include <xkbcommon/xkbcommon.h>
#include <cstring>
#include <sys/mman.h>
#include <unistd.h>
// Plain libwayland on Qt's display: Qt's own seat wrappers are private API.
// Our proxies live on the default queue, which Qt dispatches.
class WaylandKeymap::Seat
{
public:
Seat(wl_display *display, WaylandKeymap &owner)
: m_owner(owner)
, m_registry(wl_display_get_registry(display))
{
wl_registry_add_listener(m_registry, &RegistryListener, this);
}
~Seat()
{
if (m_keyboard)
wl_keyboard_release(m_keyboard);
if (m_seat)
wl_seat_release(m_seat);
wl_registry_destroy(m_registry);
}
private:
static constexpr uint32_t SeatVersion = 5; // wl_keyboard.release, wl_seat.release
static void global(void *data, wl_registry *registry, uint32_t name, const char *interface, uint32_t version)
{
auto *self = static_cast<Seat *>(data);
if (self->m_seat || std::strcmp(interface, wl_seat_interface.name) != 0 || version < SeatVersion)
return;
self->m_seat = static_cast<wl_seat *>(wl_registry_bind(registry, name, &wl_seat_interface, SeatVersion));
wl_seat_add_listener(self->m_seat, &SeatListener, self);
}
static void capabilities(void *data, wl_seat *seat, uint32_t caps)
{
auto *self = static_cast<Seat *>(data);
const bool hasKeyboard = caps & WL_SEAT_CAPABILITY_KEYBOARD;
if (hasKeyboard && !self->m_keyboard) {
self->m_keyboard = wl_seat_get_keyboard(seat);
wl_keyboard_add_listener(self->m_keyboard, &KeyboardListener, self);
} else if (!hasKeyboard && self->m_keyboard) {
wl_keyboard_release(std::exchange(self->m_keyboard, nullptr));
}
}
static void keymap(void *data, wl_keyboard *, uint32_t format, int32_t fd, uint32_t size)
{
if (format == WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1)
static_cast<Seat *>(data)->m_owner.loadKeymap(fd, size);
close(fd);
}
static constexpr wl_registry_listener RegistryListener = {
.global = global,
.global_remove = [](void *, wl_registry *, uint32_t) {},
};
static constexpr wl_seat_listener SeatListener = {
.capabilities = capabilities,
.name = [](void *, wl_seat *, const char *) {},
};
// The window never has keyboard focus; only the keymap matters.
static constexpr wl_keyboard_listener KeyboardListener = {
.keymap = keymap,
.enter = [](void *, wl_keyboard *, uint32_t, wl_surface *, wl_array *) {},
.leave = [](void *, wl_keyboard *, uint32_t, wl_surface *) {},
.key = [](void *, wl_keyboard *, uint32_t, uint32_t, uint32_t, uint32_t) {},
.modifiers = [](void *, wl_keyboard *, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) {},
.repeat_info = [](void *, wl_keyboard *, int32_t, int32_t) {},
};
WaylandKeymap &m_owner;
wl_registry *m_registry;
wl_seat *m_seat = nullptr;
wl_keyboard *m_keyboard = nullptr;
};
WaylandKeymap::WaylandKeymap(QObject *parent)
: QObject(parent)
, m_context(xkb_context_new(XKB_CONTEXT_NO_FLAGS), xkb_context_unref)
, m_keymap(nullptr, xkb_keymap_unref)
{
setKeymap(xkb_keymap_new_from_names(m_context.get(), nullptr, XKB_KEYMAP_COMPILE_NO_FLAGS));
if (auto *wayland = qGuiApp->nativeInterface<QNativeInterface::QWaylandApplication>())
m_seat = std::make_unique<Seat>(wayland->display(), *this);
}
WaylandKeymap::~WaylandKeymap() = default;
quint32 WaylandKeymap::layoutCount() const
{
return m_keymap ? xkb_keymap_num_layouts(m_keymap.get()) : 0;
}
void WaylandKeymap::loadKeymap(int fd, uint32_t size)
{
void *data = mmap(nullptr, size, PROT_READ, MAP_PRIVATE, fd, 0);
if (data == MAP_FAILED)
return;
const auto *text = static_cast<const char *>(data);
// `size` counts the terminating NUL; the buffer API wants the text length.
setKeymap(xkb_keymap_new_from_buffer(m_context.get(), text, strnlen(text, size), XKB_KEYMAP_FORMAT_TEXT_V1,
XKB_KEYMAP_COMPILE_NO_FLAGS));
munmap(data, size);
}
void WaylandKeymap::setKeymap(xkb_keymap *keymap)
{
if (!keymap) {
qWarning("Failed to compile the keyboard keymap");
return;
}
m_keymap.reset(keymap);
Q_EMIT changed();
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <QObject>
#include <memory>
struct xkb_context;
struct xkb_keymap;
// The compositor's keymap, received on a wl_keyboard of our own (the keyboard
// window never has focus, but the keymap is sent regardless). Until it
// arrives, or without a keyboard on the seat, XKB's default is used
// (XKB_DEFAULT_LAYOUT etc., else "us").
class WaylandKeymap : public QObject
{
Q_OBJECT
public:
explicit WaylandKeymap(QObject *parent = nullptr);
~WaylandKeymap() override;
// Valid until the next changed().
xkb_keymap *keymap() const { return m_keymap.get(); }
quint32 layoutCount() const;
Q_SIGNALS:
void changed();
private:
class Seat;
void loadKeymap(int fd, uint32_t size);
void setKeymap(xkb_keymap *keymap);
std::unique_ptr<xkb_context, void (*)(xkb_context *)> m_context;
std::unique_ptr<xkb_keymap, void (*)(xkb_keymap *)> m_keymap;
std::unique_ptr<Seat> m_seat;
};
+5
View File
@@ -12,6 +12,11 @@ void DirectEngine::commitText(const QString &text)
m_backend.commitText(text);
}
void DirectEngine::replaceBeforeCursor(const QString &before, const QString &text)
{
m_backend.replaceBeforeCursor(before, text);
}
void DirectEngine::sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers)
{
m_backend.sendKeysym(keysym, modifiers);
+1
View File
@@ -10,6 +10,7 @@ public:
explicit DirectEngine(TextBackend &backend);
void commitText(const QString &text) override;
void replaceBeforeCursor(const QString &before, const QString &text) override;
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) override;
private:
+2
View File
@@ -12,5 +12,7 @@ public:
virtual ~InputEngine() = default;
virtual void commitText(const QString &text) = 0;
// See TextBackend::replaceBeforeCursor.
virtual void replaceBeforeCursor(const QString &before, const QString &text) = 0;
virtual void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers = Qt::NoModifier) = 0;
};
+16 -21
View File
@@ -3,7 +3,6 @@
#include "engine/InputEngine.h"
#include "layout/LayoutModel.h"
#include "layout/LayoutRepository.h"
#include <xkbcommon/xkbcommon-keysyms.h>
@@ -17,15 +16,11 @@ namespace {
constexpr qsizetype MaxTextBefore = 256;
}
KeyboardController::KeyboardController(InputEngine &engine, LayoutModel &model,
const LayoutRepository &layouts, QObject *parent)
KeyboardController::KeyboardController(InputEngine &engine, LayoutModel &model, 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());
connect(&m_backspaceRepeater, &KeyRepeater::repeat, this, &KeyboardController::deleteBackward);
}
@@ -79,7 +74,7 @@ void KeyboardController::keyUp(const KeyDef &key, KeyOutput output, const QStrin
autoCapitalize();
break;
case KeyType::Globe:
nextLayout();
Q_EMIT nextLayoutRequested();
break;
case KeyType::Dismiss:
Q_EMIT dismissRequested();
@@ -130,26 +125,25 @@ void KeyboardController::setContext(const TextContext &context)
autoCapitalize();
}
void KeyboardController::nextLayout()
void KeyboardController::setOptions(const TypingOptions &options)
{
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]);
m_options = options;
autoCapitalize();
}
void KeyboardController::typeText(const QString &text)
{
if (text == u" " && m_context.isProse() && TextRules::wantsDoubleSpacePeriod(m_textBefore)) {
m_engine.sendKeysym(XKB_KEY_BackSpace);
if (text == u" " && m_options.doubleSpacePeriod && m_context.isProse()
&& TextRules::wantsDoubleSpacePeriod(m_textBefore)) {
// "word " + space → "word. ", replacing the first space in place.
m_engine.replaceBeforeCursor(QStringLiteral(" "), QStringLiteral(". "));
m_textBefore.chop(1);
typeText(QStringLiteral(". "));
return;
m_textBefore += QStringLiteral(". ");
} else {
m_engine.commitText(text);
m_textBefore += text;
}
m_engine.commitText(text);
m_textBefore = (m_textBefore + text).right(MaxTextBefore);
m_textBefore = m_textBefore.right(MaxTextBefore);
changeShift([&] { m_shift.characterTyped(); });
autoCapitalize();
}
@@ -164,7 +158,8 @@ void KeyboardController::deleteBackward(bool wholeWord)
void KeyboardController::autoCapitalize()
{
const bool capitalize = m_context.allowsAutoCapitalization() && TextRules::startsSentence(m_textBefore);
const bool capitalize = m_options.autoCapitalize && m_context.allowsAutoCapitalization()
&& TextRules::startsSentence(m_textBefore);
changeShift([&] { m_shift.setAutomatic(capitalize); });
}
+6 -8
View File
@@ -2,6 +2,7 @@
#include "KeyRepeater.h"
#include "ShiftState.h"
#include "TypingOptions.h"
#include "backend/TextContext.h"
#include "layout/Layout.h"
@@ -11,12 +12,11 @@
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, layout switching and the
// Keyboard rules: what each key does, shift, layers and the
// iOS conveniences (auto-capitalization, double-space period, backspace
// repeat). Knows nothing about touch or Wayland: it receives key presses and
// text-field context, and talks to an InputEngine.
@@ -31,8 +31,7 @@ public:
enum class ShiftMode { Off, Once, Locked };
Q_ENUM(ShiftMode)
KeyboardController(InputEngine &engine, LayoutModel &model, const LayoutRepository &layouts,
QObject *parent = nullptr);
KeyboardController(InputEngine &engine, LayoutModel &model, QObject *parent = nullptr);
ShiftMode shiftMode() const;
@@ -48,12 +47,12 @@ public:
void resetContext();
// The focused field reported new text/content type.
void setContext(const TextContext &context);
Q_INVOKABLE void nextLayout();
void setOptions(const TypingOptions &options);
Q_SIGNALS:
void shiftChanged();
void dismissRequested();
void nextLayoutRequested(); // the globe key: switching is the system's job
private:
void typeText(const QString &text);
@@ -64,10 +63,9 @@ private:
InputEngine &m_engine;
LayoutModel &m_model;
const LayoutRepository &m_layouts;
TypingOptions m_options;
ShiftState m_shift;
KeyRepeater m_backspaceRepeater;
int m_layoutIndex = 0;
QString m_layerBeforeSwitch;
TextContext m_context;
+6 -2
View File
@@ -2,13 +2,16 @@
void ShiftState::press(qint64 timeMs)
{
const bool doubleTap = timeMs - m_lastPress <= DoubleTapMs;
// Locks whatever the first tap did: at a sentence start that tap turns
// auto-shift off, and the second must still lock.
const bool doubleTap = timeMs - m_lastPress <= DoubleTapMs && !m_lastPressUnlocked;
m_lastPress = timeMs;
m_lastPressUnlocked = m_mode == Mode::Locked;
m_held = true;
m_typedWhileHeld = false;
switch (m_mode) {
case Mode::Off: m_mode = Mode::Once; break;
case Mode::Off: m_mode = doubleTap ? Mode::Locked : Mode::Once; break;
case Mode::Once: m_mode = doubleTap ? Mode::Locked : Mode::Off; break;
case Mode::Locked: m_mode = Mode::Off; break;
}
@@ -23,6 +26,7 @@ void ShiftState::release()
void ShiftState::characterTyped()
{
m_lastPress = NoPress; // shift, type, shift is not a double tap
if (m_held)
m_typedWhileHeld = true;
else if (m_mode == Mode::Once)
+3 -1
View File
@@ -29,5 +29,7 @@ private:
Mode m_mode = Mode::Off;
bool m_held = false;
bool m_typedWhileHeld = false;
qint64 m_lastPress = std::numeric_limits<qint64>::min() / 2;
bool m_lastPressUnlocked = false; // a tap leaving caps lock doesn't start a double tap
static constexpr qint64 NoPress = std::numeric_limits<qint64>::min() / 2;
qint64 m_lastPress = NoPress;
};
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// User choices for the typing conveniences. Plain data: the controller reads
// it, Settings persists it.
struct TypingOptions {
bool autoCapitalize = true;
bool doubleSpacePeriod = true;
bool operator==(const TypingOptions &) const = default;
};
-4
View File
@@ -2,8 +2,6 @@
#include <algorithm>
namespace {
qreal rowUnits(const Row &row)
{
qreal units = 0;
@@ -12,8 +10,6 @@ qreal rowUnits(const Row &row)
return units;
}
} // namespace
QList<QRectF> computeKeyRects(const Layer &layer, QSizeF size, KeyboardMetrics metrics)
{
QList<QRectF> rects;
+3
View File
@@ -14,3 +14,6 @@ struct KeyboardMetrics {
// 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 = {});
// Total width of a row in key units.
qreal rowUnits(const Row &row);
+66
View File
@@ -0,0 +1,66 @@
#include "KeymapLetters.h"
#include <xkbcommon/xkbcommon.h>
#include <optional>
#include <ranges>
namespace {
// XKB keycodes are evdev codes + 8.
constexpr xkb_keycode_t fromEvdev(int code) { return xkb_keycode_t(code + 8); }
struct KeyRange {
int first;
int last;
};
// AD01–AD12, AC01–AC11, AB01–AB10 in evdev codes.
constexpr KeyRange LetterRows[] = {{16, 27}, {30, 40}, {44, 53}};
// AE01–AE12 (number row), BKSL, LSGT: letters here become extras.
constexpr KeyRange ExtraKeys[] = {{2, 13}, {43, 43}, {86, 86}};
QString symbolText(xkb_keymap *keymap, xkb_keycode_t key, xkb_layout_index_t layout, xkb_level_index_t level)
{
const xkb_keysym_t *syms = nullptr;
if (xkb_keymap_key_get_syms_by_level(keymap, key, layout, level, &syms) != 1)
return {};
const char32_t ucs = xkb_keysym_to_utf32(syms[0]); // 0 for dead keys and functions
return ucs ? QString::fromUcs4(&ucs, 1) : QString();
}
bool isLetter(const QString &text)
{
return !text.isEmpty() && QChar::isLetter(text.toUcs4().first());
}
std::optional<Letter> letterAt(xkb_keymap *keymap, xkb_keycode_t key, xkb_layout_index_t layout)
{
const QString lower = symbolText(keymap, key, layout, 0);
if (!isLetter(lower))
return std::nullopt;
const QString shifted = symbolText(keymap, key, layout, 1);
return Letter{lower, isLetter(shifted) ? shifted : lower.toUpper()};
}
} // namespace
KeymapLetters readKeymapLetters(xkb_keymap *keymap, quint32 layoutIndex)
{
KeymapLetters result;
if (!keymap || layoutIndex >= xkb_keymap_num_layouts(keymap))
return result;
result.name = QString::fromUtf8(xkb_keymap_layout_get_name(keymap, layoutIndex));
const auto collect = [&](KeyRange range, QList<Letter> &into) {
for (int code : std::views::iota(range.first, range.last + 1)) {
if (const auto letter = letterAt(keymap, fromEvdev(code), layoutIndex))
into.append(*letter);
}
};
for (KeyRange range : LetterRows)
collect(range, result.rows.emplace_back());
for (KeyRange range : ExtraKeys)
collect(range, result.extras);
return result;
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <QList>
#include <QString>
struct xkb_keymap;
struct Letter {
QString lower;
QString upper;
};
// The letters one layout of an XKB keymap puts where a physical keyboard has
// its three letter rows (Q…, A…, Z… positions). Letters on other keys, e.g.
// Slovak ľščťž on the number row, are collected as extras.
struct KeymapLetters {
QString name; // e.g. "Slovak"
QList<QList<Letter>> rows;
QList<Letter> extras;
};
KeymapLetters readKeymapLetters(xkb_keymap *keymap, quint32 layoutIndex);
+11 -4
View File
@@ -1,5 +1,6 @@
#pragma once
#include <QHash>
#include <QList>
#include <QString>
#include <QStringList>
@@ -14,6 +15,7 @@ enum class KeyType {
Globe,
Dismiss,
Spacer, // empty room, e.g. the half-key indent of the home row
Letters, // template only: a row of letters from the system keymap
};
struct KeyDef {
@@ -24,6 +26,11 @@ struct KeyDef {
QStringList alternates; // long-press choices
QString target; // layer to switch to (LayerSwitch only)
qreal width = 1.0; // in key units
bool stretch = false; // grows so the row is as wide as the layer's widest
// Letters placeholders only.
int lettersRow = -1; // which keymap row (0 = top)
QStringList flicks; // flick symbol per letter, left to right
// Text this key shows/types; only character keys change case.
QString text(bool uppercase) const
@@ -42,11 +49,11 @@ struct Layer {
};
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
QString id;
QString name; // human readable, e.g. "Slovak"
QList<Layer> layers; // the first one is shown by default
// Template only: long-press choices for keymap letters, by lowercase letter.
QHash<QString, QStringList> alternates;
const Layer *layer(const QString &layerName) const
{
+110
View File
@@ -0,0 +1,110 @@
#include "LayoutBuilder.h"
#include "KeyGeometry.h"
#include <QSet>
#include <algorithm>
namespace {
// Letters without a Unicode decomposition, and the key they belong under.
const QHash<QString, QString> &baseOverrides()
{
static const QHash<QString, QString> bases = {
{QStringLiteral("ß"), QStringLiteral("s")}, {QStringLiteral("ł"), QStringLiteral("l")},
{QStringLiteral("ø"), QStringLiteral("o")}, {QStringLiteral("æ"), QStringLiteral("a")},
{QStringLiteral("œ"), QStringLiteral("o")}, {QStringLiteral("đ"), QStringLiteral("d")},
{QStringLiteral("ð"), QStringLiteral("d")}, {QStringLiteral("þ"), QStringLiteral("t")},
{QStringLiteral("ħ"), QStringLiteral("h")}, {QStringLiteral("ı"), QStringLiteral("i")},
};
return bases;
}
// "š" → "s": the letter an accented letter is a variant of.
QString baseLetter(const QString &letter)
{
if (const auto it = baseOverrides().constFind(letter); it != baseOverrides().cend())
return *it;
return letter.normalized(QString::NormalizationForm_D).left(1);
}
// Long-press choices per key letter: the keymap's extras first (they're what
// this language types), then the template's, minus letters that have a key.
QHash<QString, QStringList> alternatesByLetter(const Layout &tmpl, const KeymapLetters &letters)
{
QSet<QString> onKeys;
for (const auto &row : letters.rows) {
for (const Letter &letter : row)
onKeys.insert(letter.lower);
}
QHash<QString, QStringList> result;
const auto add = [&](const QString &base, const QString &choice) {
QStringList &list = result[base];
if (!onKeys.contains(choice) && !list.contains(choice))
list.append(choice);
};
for (const Letter &extra : letters.extras)
add(baseLetter(extra.lower), extra.lower);
for (auto it = tmpl.alternates.cbegin(); it != tmpl.alternates.cend(); ++it) {
for (const QString &choice : *it)
add(it.key(), choice);
}
return result;
}
Row expandLetters(const Row &row, const KeymapLetters &letters, const QHash<QString, QStringList> &alternates)
{
Row result;
for (const KeyDef &key : row) {
if (key.type != KeyType::Letters) {
result.append(key);
continue;
}
const QList<Letter> source = letters.rows.value(key.lettersRow);
for (qsizetype i = 0; i < source.size(); ++i) {
KeyDef letter;
letter.label = source[i].lower;
letter.shifted = source[i].upper;
letter.flick = key.flicks.value(i);
letter.alternates = alternates.value(source[i].lower);
result.append(letter);
}
}
return result;
}
void stretchRows(Layer &layer)
{
qreal widest = 0;
for (const Row &row : layer.rows)
widest = std::max(widest, rowUnits(row));
for (Row &row : layer.rows) {
const auto stretchy = std::ranges::count_if(row, &KeyDef::stretch);
if (stretchy == 0)
continue;
const qreal extra = (widest - rowUnits(row)) / qreal(stretchy);
for (KeyDef &key : row) {
if (key.stretch)
key.width += extra;
}
}
}
} // namespace
Layout buildLayout(const Layout &tmpl, const KeymapLetters &letters)
{
Layout layout = tmpl;
layout.name = letters.name.isEmpty() ? tmpl.name : letters.name;
layout.alternates.clear();
const QHash<QString, QStringList> alternates = alternatesByLetter(tmpl, letters);
for (Layer &layer : layout.layers) {
for (Row &row : layer.rows)
row = expandLetters(row, letters, alternates);
stretchRows(layer);
}
return layout;
}
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include "KeymapLetters.h"
#include "Layout.h"
// Turns the template into the layout shown: fills its letters placeholders
// from the keymap, gives each letter its flick and long-press choices, and
// stretches the marked keys so every row of a layer is equally wide.
Layout buildLayout(const Layout &tmpl, const KeymapLetters &letters);
+24 -6
View File
@@ -9,6 +9,16 @@ namespace LayoutLoader {
namespace {
constexpr int LetterRows = 3;
QStringList toStringList(const QJsonValue &value)
{
QStringList list;
for (const QJsonValue &item : value.toArray())
list.append(item.toString());
return list;
}
const QHash<QString, KeyType> &keyTypesByName()
{
static const QHash<QString, KeyType> types = {
@@ -21,6 +31,7 @@ const QHash<QString, KeyType> &keyTypesByName()
{QStringLiteral("globe"), KeyType::Globe},
{QStringLiteral("dismiss"), KeyType::Dismiss},
{QStringLiteral("spacer"), KeyType::Spacer},
{QStringLiteral("letters"), KeyType::Letters},
};
return types;
}
@@ -51,13 +62,19 @@ std::optional<KeyDef> parseKey(const QJsonValue &value, QString *error)
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());
key.stretch = obj.value(u"stretch").toBool();
key.alternates = toStringList(obj.value(u"alternates"));
key.lettersRow = obj.value(u"row").toInt(-1);
key.flicks = toStringList(obj.value(u"flicks"));
if (key.type == KeyType::Character && key.label.isEmpty()) {
*error = QStringLiteral("character key without a label");
return std::nullopt;
}
if (key.type == KeyType::Letters && (key.lettersRow < 0 || key.lettersRow >= LetterRows)) {
*error = QStringLiteral("letters need a row from 0 to %1").arg(LetterRows - 1);
return std::nullopt;
}
if (key.width <= 0) {
*error = QStringLiteral("key \"%1\" has a non-positive width").arg(key.label);
return std::nullopt;
@@ -110,8 +127,9 @@ std::optional<Layout> parse(const QByteArray &json, QString *error)
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();
const QJsonObject alternates = root.value(u"alternates").toObject();
for (auto it = alternates.begin(); it != alternates.end(); ++it)
layout.alternates.insert(it.key(), toStringList(it.value()));
if (layout.id.isEmpty()) {
*error = QStringLiteral("layout without an id");
return std::nullopt;
@@ -124,8 +142,8 @@ std::optional<Layout> parse(const QByteArray &json, QString *error)
layout.layers.append(*layer);
}
if (layout.layers.isEmpty() && layout.base.isEmpty()) {
*error = QStringLiteral("layout has no layers and no base");
if (layout.layers.isEmpty()) {
*error = QStringLiteral("layout has no layers");
return std::nullopt;
}
return layout;
+5 -4
View File
@@ -6,16 +6,17 @@
class QJsonValue;
// Parses the JSON layout format (see layouts/en-US.json).
// Parses the JSON template format (see layouts/template.json).
// A key is either a plain string (a character key) or an object:
// {"type": "backspace", "width": 1.5}
// {"label": "e", "flick": "3", "alternates": ["é", "è"]}
// {"type": "backspace", "width": 1.5, "stretch": true}
// {"label": ",", "flick": "!", "alternates": ["…"]}
// {"type": "letters", "row": 0, "flicks": ["1", "2"]} (filled from the keymap)
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.
// Checks references between layers.
bool validate(const Layout &layout, QString *error);
} // namespace LayoutLoader
+1
View File
@@ -16,6 +16,7 @@ QString keyTypeName(KeyType type)
case KeyType::Globe: return QStringLiteral("globe");
case KeyType::Dismiss: return QStringLiteral("dismiss");
case KeyType::Spacer: return QStringLiteral("spacer");
case KeyType::Letters: break; // expanded before it reaches the model
}
return {};
}
-75
View File
@@ -1,75 +0,0 @@
#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
@@ -1,24 +0,0 @@
#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;
};
+48 -15
View File
@@ -1,16 +1,19 @@
#include "app/DBusControl.h"
#include "app/DevHooks.h"
#include "app/SystemLayouts.h"
#include "backend/KWinInputMethod.h"
#include "backend/WaylandKeymap.h"
#include "engine/DirectEngine.h"
#include "input/KeyboardController.h"
#include "layout/LayoutBuilder.h"
#include "layout/LayoutLoader.h"
#include "layout/LayoutModel.h"
#include "layout/LayoutRepository.h"
#include "settings/Settings.h"
#include "window/FloatingWindow.h"
#include "window/VisibilityController.h"
#include "window/WindowSettings.h"
#include <QGuiApplication>
#include <QLocale>
#include <QQmlApplicationEngine>
#include <QQmlExtensionPlugin>
#include <QQuickWindow>
@@ -20,13 +23,33 @@ Q_IMPORT_QML_PLUGIN(SloposkPlugin)
namespace {
void loadLayouts(LayoutRepository &layouts)
std::optional<Layout> loadTemplate()
{
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());
// ~/.local/share/sloposk/template.json replaces the built-in one.
const QString user = QStandardPaths::locate(QStandardPaths::AppDataLocation, QStringLiteral("template.json"));
for (const QString &path : {user, QStringLiteral(":/layouts/template.json")}) {
if (path.isEmpty())
continue;
QString error;
if (auto layout = LayoutLoader::load(path, &error); layout && LayoutLoader::validate(*layout, &error))
return layout;
qWarning("Skipping layout template %s: %s", qPrintable(path), qPrintable(error));
}
return std::nullopt;
}
// The shown layout is the template filled with the active system layout's letters.
void connectLayouts(const Layout &tmpl, WaylandKeymap &keymap, SystemLayouts &systemLayouts,
KeyboardController &controller, LayoutModel &model)
{
const auto rebuild = [&] {
model.setLayout(buildLayout(tmpl, readKeymapLetters(keymap.keymap(), systemLayouts.current())));
};
QObject::connect(&keymap, &WaylandKeymap::changed, &model, rebuild);
QObject::connect(&systemLayouts, &SystemLayouts::currentChanged, &model, rebuild);
QObject::connect(&controller, &KeyboardController::nextLayoutRequested, &systemLayouts,
[&] { systemLayouts.next(keymap.layoutCount()); });
rebuild();
}
void restoreAndPersistGeometry(FloatingWindow &placement, WindowSettings &settings)
@@ -65,25 +88,35 @@ int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
QGuiApplication::setApplicationName(QStringLiteral("sloposk"));
QGuiApplication::setOrganizationName(QStringLiteral("sloposk")); // ~/.config/sloposk/sloposk.conf
QGuiApplication::setDesktopFileName(QStringLiteral("org.sloposk.keyboard"));
LayoutRepository layouts;
loadLayouts(layouts);
if (layouts.layouts().isEmpty()) {
qCritical("No usable keyboard layouts found");
const std::optional<Layout> layoutTemplate = loadTemplate();
if (!layoutTemplate) {
qCritical("No usable layout template found");
return 1;
}
KWinInputMethod backend;
DirectEngine engine(backend);
LayoutModel model;
KeyboardController controller(engine, model, layouts);
KeyboardController controller(engine, model);
connectTextContext(backend, controller);
WaylandKeymap keymap;
SystemLayouts systemLayouts;
connectLayouts(*layoutTemplate, keymap, systemLayouts, controller, model);
Settings settings;
controller.setOptions(settings.typingOptions());
QObject::connect(&settings, &Settings::typingOptionsChanged, &controller,
[&] { controller.setOptions(settings.typingOptions()); });
QQmlApplicationEngine qml;
qml.setInitialProperties({
{QStringLiteral("layoutModel"), QVariant::fromValue(&model)},
{QStringLiteral("controller"), QVariant::fromValue(&controller)},
{QStringLiteral("settings"), QVariant::fromValue(&settings)},
});
qml.loadFromModule("Sloposk", "Main");
auto *window = qml.rootObjects().isEmpty() ? nullptr : qobject_cast<QQuickWindow *>(qml.rootObjects().first());
@@ -94,8 +127,8 @@ int main(int argc, char *argv[])
FloatingWindow placement(window);
window->setProperty("placement", QVariant::fromValue(&placement));
WindowSettings settings;
restoreAndPersistGeometry(placement, settings);
WindowSettings windowSettings;
restoreAndPersistGeometry(placement, windowSettings);
VisibilityController visibility;
connectVisibility(visibility, backend, controller, placement);
+42
View File
@@ -0,0 +1,42 @@
#include "Settings.h"
#include <QSettings>
namespace {
constexpr auto AutoCapitalizeKey = "typing/autoCapitalize";
constexpr auto DoubleSpacePeriodKey = "typing/doubleSpacePeriod";
}
Settings::Settings(QObject *parent)
: QObject(parent)
{
const QSettings store;
const TypingOptions defaults;
m_typing.autoCapitalize = store.value(AutoCapitalizeKey, defaults.autoCapitalize).toBool();
m_typing.doubleSpacePeriod = store.value(DoubleSpacePeriodKey, defaults.doubleSpacePeriod).toBool();
}
void Settings::setAutoCapitalize(bool enabled)
{
TypingOptions options = m_typing;
options.autoCapitalize = enabled;
setTypingOptions(options);
}
void Settings::setDoubleSpacePeriod(bool enabled)
{
TypingOptions options = m_typing;
options.doubleSpacePeriod = enabled;
setTypingOptions(options);
}
void Settings::setTypingOptions(const TypingOptions &options)
{
if (options == m_typing)
return;
m_typing = options;
QSettings store;
store.setValue(AutoCapitalizeKey, m_typing.autoCapitalize);
store.setValue(DoubleSpacePeriodKey, m_typing.doubleSpacePeriod);
Q_EMIT typingOptionsChanged();
}
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include "input/TypingOptions.h"
#include <QObject>
#include <QtQml/qqmlregistration.h>
// User preferences, persisted with QSettings (~/.config/sloposk/sloposk.conf)
// and editable from the keyboard's settings panel.
class Settings : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Created in C++")
Q_PROPERTY(bool autoCapitalize READ autoCapitalize WRITE setAutoCapitalize NOTIFY typingOptionsChanged)
Q_PROPERTY(bool doubleSpacePeriod READ doubleSpacePeriod WRITE setDoubleSpacePeriod NOTIFY typingOptionsChanged)
public:
explicit Settings(QObject *parent = nullptr);
TypingOptions typingOptions() const { return m_typing; }
bool autoCapitalize() const { return m_typing.autoCapitalize; }
void setAutoCapitalize(bool enabled);
bool doubleSpacePeriod() const { return m_typing.doubleSpacePeriod; }
void setDoubleSpacePeriod(bool enabled);
Q_SIGNALS:
void typingOptionsChanged();
private:
void setTypingOptions(const TypingOptions &options);
TypingOptions m_typing;
};