diff --git a/README.md b/README.md index 2493c20..78a659c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ iPad-style floating on-screen keyboard for Plasma Wayland. Qt 6, C++ core, QML U - Letters follow the system keyboard layout; the globe key switches it - Auto-capitalization, double-space → ". " (both optional, ⚙ on the bottom bar), backspace repeat → word delete - Long-press space = trackpad; slide from `.?123` onto a symbol and release +- Optional PC key blocks: function row, navigation (ins/home/pg up, del/end/pg dn, arrows), numeric keypad; any mix, typing keys included (⚙) - Floating: drag the bottom bar, resize from the corner; geometry is remembered per screen ## Build & test @@ -43,14 +44,16 @@ Everything else is `layouts/template.json`; `~/.local/share/sloposk/template.jso {"type": "backspace", "width": 1.5, "stretch": true} ``` -- Key: a string, or an object with `type` (`char` `space` `backspace` `enter` `shift` `layer` `globe` `dismiss` `spacer` `letters`), `label`, `shifted`, `flick`, `alternates`, `width` (key units), `target` (for `layer`), `stretch` +- Key: a string, or an object with `type` (`char` `space` `backspace` `enter` `shift` `layer` `globe` `dismiss` `spacer` `letters` `keysym`), `label`, `shifted`, `flick`, `alternates`, `width` (key units), `target` (for `layer`), `stretch` +- `keysym`: sends the XKB keysym named by `keysym` (`Home`, `F5`, `Left`...) on press; `repeat` auto-repeats while held - `letters`: the keymap row `row` (0–2), with `flicks` handed out left to right - `stretch`: the key grows so its row is as wide as the layer's widest (letter counts vary by layout) - Top-level `alternates`: long-press choices per letter, after the keymap's own +- Top-level `sections`: optional blocks, `{"id", "name", "placement": "above" | "right", "rows"}`; the user picks which show ## Settings -`~/.config/sloposk/sloposk.conf`, also editable from the ⚙ panel: `typing/autoCapitalize`, `typing/doubleSpacePeriod`. +`~/.config/sloposk/sloposk.conf`, also editable from the ⚙ panel: `typing/autoCapitalize`, `typing/doubleSpacePeriod`, `layout/visibleSections` (`main` plus section ids). ## Architecture diff --git a/layouts/template.json b/layouts/template.json index 737069d..8b932d8 100644 --- a/layouts/template.json +++ b/layouts/template.json @@ -16,6 +16,64 @@ "y": ["ý", "ÿ"], "z": ["ž", "ź", "ż"] }, + "sections": [ + { + "id": "function", + "name": "Function keys", + "placement": "above", + "rows": [[ + {"type": "keysym", "keysym": "Escape", "label": "esc", "stretch": true}, + {"type": "keysym", "keysym": "F1", "width": 0.75, "stretch": true}, + {"type": "keysym", "keysym": "F2", "width": 0.75, "stretch": true}, + {"type": "keysym", "keysym": "F3", "width": 0.75, "stretch": true}, + {"type": "keysym", "keysym": "F4", "width": 0.75, "stretch": true}, + {"type": "keysym", "keysym": "F5", "width": 0.75, "stretch": true}, + {"type": "keysym", "keysym": "F6", "width": 0.75, "stretch": true}, + {"type": "keysym", "keysym": "F7", "width": 0.75, "stretch": true}, + {"type": "keysym", "keysym": "F8", "width": 0.75, "stretch": true}, + {"type": "keysym", "keysym": "F9", "width": 0.75, "stretch": true}, + {"type": "keysym", "keysym": "F10", "width": 0.75, "stretch": true}, + {"type": "keysym", "keysym": "F11", "width": 0.75, "stretch": true}, + {"type": "keysym", "keysym": "F12", "width": 0.75, "stretch": true} + ]] + }, + { + "id": "navigation", + "name": "Navigation keys", + "rows": [ + [ + {"type": "keysym", "keysym": "Insert", "label": "ins"}, + {"type": "keysym", "keysym": "Home", "label": "home"}, + {"type": "keysym", "keysym": "Prior", "label": "pg up", "repeat": true} + ], + [ + {"type": "keysym", "keysym": "Delete", "label": "del", "repeat": true}, + {"type": "keysym", "keysym": "End", "label": "end"}, + {"type": "keysym", "keysym": "Next", "label": "pg dn", "repeat": true} + ], + [ + {"type": "spacer"}, + {"type": "keysym", "keysym": "Up", "label": "↑", "repeat": true}, + {"type": "spacer"} + ], + [ + {"type": "keysym", "keysym": "Left", "label": "←", "repeat": true}, + {"type": "keysym", "keysym": "Down", "label": "↓", "repeat": true}, + {"type": "keysym", "keysym": "Right", "label": "→", "repeat": true} + ] + ] + }, + { + "id": "numpad", + "name": "Numeric keypad", + "rows": [ + ["7", "8", "9", "/", {"type": "backspace"}], + ["4", "5", "6", "*", "-"], + ["1", "2", "3", "+", {"type": "enter"}], + [{"label": "0", "width": 2}, ".", ",", "="] + ] + } + ], "layers": [ { "name": "letters", diff --git a/qml/Main.qml b/qml/Main.qml index 03f3726..d969d39 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -46,6 +46,7 @@ Window { anchors.fill: keyboard visible: root.settingsOpen settings: root.settings + layoutModel: root.layoutModel } DragHandle { diff --git a/qml/SectionHeader.qml b/qml/SectionHeader.qml new file mode 100644 index 0000000..c479441 --- /dev/null +++ b/qml/SectionHeader.qml @@ -0,0 +1,9 @@ +import QtQuick + +// Small caps-style title above a group of settings. +Text { + color: Theme.secondaryText + font.pixelSize: 12 + font.capitalization: Font.AllUppercase + bottomPadding: 4 +} diff --git a/qml/SettingsPanel.qml b/qml/SettingsPanel.qml index 056ce91..5e0e9ea 100644 --- a/qml/SettingsPanel.qml +++ b/qml/SettingsPanel.qml @@ -6,28 +6,59 @@ Rectangle { id: panel required property Settings settings + required property LayoutModel layoutModel color: Theme.background radius: Theme.windowRadius - Column { - anchors { fill: parent; margins: 16 } - spacing: 4 + Row { + id: columns + readonly property real columnWidth: (width - spacing) / 2 - ToggleRow { - width: parent.width - text: qsTr("Auto-Capitalization") - detail: qsTr("Shift at the start of a sentence") - checked: panel.settings.autoCapitalize - onToggled: checked => panel.settings.autoCapitalize = checked + anchors { fill: parent; margins: 16 } + spacing: 32 + + Column { + width: columns.columnWidth + spacing: 4 + + SectionHeader { text: qsTr("Typing") } + + ToggleRow { + width: parent.width + text: qsTr("Auto-Capitalization") + detail: qsTr("Shift at the start of a sentence") + checked: panel.settings.autoCapitalize + onToggled: checked => panel.settings.autoCapitalize = checked + } + + ToggleRow { + width: parent.width + text: qsTr("“.” Shortcut") + detail: qsTr("Double-tap space for a period and a space") + checked: panel.settings.doubleSpacePeriod + onToggled: checked => panel.settings.doubleSpacePeriod = checked + } } - ToggleRow { - width: parent.width - text: qsTr("“.” Shortcut") - detail: qsTr("Double-tap space for a period and a space") - checked: panel.settings.doubleSpacePeriod - onToggled: checked => panel.settings.doubleSpacePeriod = checked + Column { + width: columns.columnWidth + spacing: 4 + + SectionHeader { text: qsTr("Keys") } + + Repeater { + // "main" is the typing keys; the rest come from the template. + model: [{ id: "main", name: qsTr("Typing keys") }].concat(panel.layoutModel.sections) + + ToggleRow { + required property var modelData + width: parent.width + text: modelData.name + checked: panel.settings.visibleSections.includes(modelData.id) + onToggled: checked => panel.settings.setSectionVisible(modelData.id, checked) + } + } } } } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c40fe8b..423ee76 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -32,6 +32,7 @@ set(SLOPOSK_QML_FILES AlternatesPopup.qml DragHandle.qml ResizeHandle.qml + SectionHeader.qml SettingsButton.qml SettingsPanel.qml ToggleRow.qml diff --git a/src/input/KeyboardController.cpp b/src/input/KeyboardController.cpp index e65f600..67dd4e4 100644 --- a/src/input/KeyboardController.cpp +++ b/src/input/KeyboardController.cpp @@ -14,14 +14,18 @@ static_assert(int(KeyboardController::ShiftMode::Locked) == int(ShiftState::Mode namespace { // Enough history for the text rules; fields may report megabytes. constexpr qsizetype MaxTextBefore = 256; +// Like a hardware key: no word mode, a faster rate than Backspace. +constexpr KeyRepeater::Timing KeysymRepeat{.intervalMs = 50, .wordModeAfterMs = std::numeric_limits::max()}; } KeyboardController::KeyboardController(InputEngine &engine, LayoutModel &model, QObject *parent) : QObject(parent) , m_engine(engine) , m_model(model) + , m_keysymRepeater(KeysymRepeat) { connect(&m_backspaceRepeater, &KeyRepeater::repeat, this, &KeyboardController::deleteBackward); + connect(&m_keysymRepeater, &KeyRepeater::repeat, this, &KeyboardController::sendRepeatedKeysym); } KeyboardController::ShiftMode KeyboardController::shiftMode() const @@ -43,6 +47,13 @@ void KeyboardController::keyDown(const KeyDef &key, qint64 timeMs) m_layerBeforeSwitch = m_model.layerName(); m_model.setLayer(key.target); break; + case KeyType::Keysym: + m_engine.sendKeysym(key.keysym); + if (key.repeat) { + m_repeatingKeysym = key.keysym; + m_keysymRepeater.start(); + } + break; default: break; // everything else acts on release } @@ -57,6 +68,9 @@ void KeyboardController::keyUp(const KeyDef &key, KeyOutput output, const QStrin case KeyType::Backspace: m_backspaceRepeater.stop(); break; + case KeyType::Keysym: + stopRepeating(key); + break; case KeyType::Character: if (output == KeyOutput::Alternate && !alternate.isEmpty()) typeText(alternate); @@ -90,6 +104,8 @@ void KeyboardController::keyCancelled(const KeyDef &key) changeShift([&] { m_shift.release(); }); else if (key.type == KeyType::Backspace) m_backspaceRepeater.stop(); + else if (key.type == KeyType::Keysym) + stopRepeating(key); } void KeyboardController::slideReleased(const KeyDef &target, qint64 timeMs) @@ -156,6 +172,18 @@ void KeyboardController::deleteBackward(bool wholeWord) autoCapitalize(); } +// Only the key that started the repeat stops it; another finger's key doesn't. +void KeyboardController::stopRepeating(const KeyDef &key) +{ + if (key.keysym == m_repeatingKeysym) + m_keysymRepeater.stop(); +} + +void KeyboardController::sendRepeatedKeysym() +{ + m_engine.sendKeysym(m_repeatingKeysym); +} + void KeyboardController::autoCapitalize() { const bool capitalize = m_options.autoCapitalize && m_context.allowsAutoCapitalization() diff --git a/src/input/KeyboardController.h b/src/input/KeyboardController.h index c82a6b6..463c195 100644 --- a/src/input/KeyboardController.h +++ b/src/input/KeyboardController.h @@ -57,6 +57,8 @@ Q_SIGNALS: private: void typeText(const QString &text); void deleteBackward(bool wholeWord); + void sendRepeatedKeysym(); + void stopRepeating(const KeyDef &key); void autoCapitalize(); template void changeShift(Change change); @@ -66,6 +68,8 @@ private: TypingOptions m_options; ShiftState m_shift; KeyRepeater m_backspaceRepeater; + KeyRepeater m_keysymRepeater; // arrows, Delete, ... + quint32 m_repeatingKeysym = 0; QString m_layerBeforeSwitch; TextContext m_context; diff --git a/src/layout/KeyGeometry.cpp b/src/layout/KeyGeometry.cpp index d9d7e79..346e385 100644 --- a/src/layout/KeyGeometry.cpp +++ b/src/layout/KeyGeometry.cpp @@ -1,6 +1,30 @@ #include "KeyGeometry.h" #include +#include + +namespace { + +qreal blockUnits(const Block &block) +{ + qreal units = 0; + for (const Row &row : block) + units = std::max(units, rowUnits(row)); + return units; +} + +// Key widths in units once stretch keys take up the row's slack. +QList stretchedWidths(const Row &row, qreal targetUnits) +{ + const auto stretchy = std::ranges::count_if(row, &KeyDef::stretch); + const qreal extra = stretchy ? (targetUnits - rowUnits(row)) / qreal(stretchy) : 0; + QList widths; + for (const KeyDef &key : row) + widths.append(key.width + (key.stretch ? extra : 0)); + return widths; +} + +} // namespace qreal rowUnits(const Row &row) { @@ -10,33 +34,49 @@ qreal rowUnits(const Row &row) return units; } -QList computeKeyRects(const Layer &layer, QSizeF size, KeyboardMetrics metrics) +qreal totalUnits(const QList &blocks) +{ + qreal units = 0; + for (const Block &block : blocks) + units += blockUnits(block); + return units; +} + +QList computeKeyRects(const QList &blocks, QSizeF size, KeyboardMetrics metrics) { QList rects; - if (layer.rows.isEmpty()) + const qreal units = totalUnits(blocks); + qsizetype maxRows = 0; + for (const Block &block : blocks) + maxRows = std::max(maxRows, block.size()); + if (units <= 0 || maxRows == 0) 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()); + const auto blockCount = qreal(blocks.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; + // Each cell includes one trailing gap; the last gap of a row is dropped, + // and blocks are separated by blockGap instead. + const qreal unitWidth = (innerWidth + blockCount * metrics.gap - (blockCount - 1) * metrics.blockGap) / units; + const qreal rowHeight = (innerHeight - qreal(maxRows - 1) * metrics.gap) / qreal(maxRows); - 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(0, cellWidth - metrics.gap), rowHeight)); - x += cellWidth; + qreal left = metrics.padding; + for (const Block &block : blocks) { + const qreal width = blockUnits(block); + qreal y = metrics.padding + qreal(maxRows - block.size()) * (rowHeight + metrics.gap); + for (const Row &row : block) { + const QList widths = stretchedWidths(row, width); + const qreal used = std::accumulate(widths.begin(), widths.end(), 0.0); + qreal x = left + (width - used) * unitWidth / 2; + for (qreal keyWidth : widths) { + const qreal cellWidth = keyWidth * unitWidth; + rects.append(QRectF(x, y, std::max(0, cellWidth - metrics.gap), rowHeight)); + x += cellWidth; + } + y += rowHeight + metrics.gap; } - y += rowHeight + metrics.gap; + left += width * unitWidth - metrics.gap + metrics.blockGap; } return rects; } diff --git a/src/layout/KeyGeometry.h b/src/layout/KeyGeometry.h index f00e25e..ecfb389 100644 --- a/src/layout/KeyGeometry.h +++ b/src/layout/KeyGeometry.h @@ -6,14 +6,26 @@ #include struct KeyboardMetrics { - qreal padding = 6; // around the whole keyboard - qreal gap = 8; // between keys, horizontally and vertically + qreal padding = 6; // around the whole keyboard + qreal gap = 8; // between keys, horizontally and vertically + qreal blockGap = 24; // between side-by-side blocks (main, navigation, numpad) }; -// 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 computeKeyRects(const Layer &layer, QSizeF size, KeyboardMetrics metrics = {}); +// The keys on screen: blocks of rows, side by side. +using Block = QList; -// Total width of a row in key units. +// Lays out blocks to fill `size`. Returns one rect per key, block by block in +// row order (spacers included). One key unit has the same width everywhere; +// all rows share one height, and shorter blocks sit at the bottom. In each +// block, stretch keys grow to make their row as wide as the widest one, and +// remaining shorter rows are centered. +QList computeKeyRects(const QList &blocks, QSizeF size, KeyboardMetrics metrics = {}); +inline QList computeKeyRects(const Layer &layer, QSizeF size, KeyboardMetrics metrics = {}) +{ + return computeKeyRects({layer.rows}, size, metrics); +} + +// Total width of a row in key units, before stretching. qreal rowUnits(const Row &row); +// Width of all blocks in key units, ignoring gaps. +qreal totalUnits(const QList &blocks); diff --git a/src/layout/Layout.h b/src/layout/Layout.h index bac5d62..0065b09 100644 --- a/src/layout/Layout.h +++ b/src/layout/Layout.h @@ -16,6 +16,7 @@ enum class KeyType { 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 + Keysym, // a non-text key (Home, F5, arrows...): sends `keysym` }; struct KeyDef { @@ -26,7 +27,9 @@ 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 + bool stretch = false; // grows so the row is as wide as its block's widest + quint32 keysym = 0; // Keysym keys only + bool repeat = false; // Keysym keys only: auto-repeat while held // Letters placeholders only. int lettersRow = -1; // which keymap row (0 = top) @@ -48,10 +51,21 @@ struct Layer { QList rows; }; +// An optional block of keys next to the main one, shown if the user wants it. +struct Section { + enum class Placement { Above, Right }; // above the main keys, or a column to their right + + QString id; // "main" is reserved for the layers + QString name; // shown in settings + Placement placement = Placement::Right; + QList rows; +}; + struct Layout { QString id; QString name; // human readable, e.g. "Slovak" - QList layers; // the first one is shown by default + QList layers; // the main keys; the first one is shown by default + QList
sections; // Template only: long-press choices for keymap letters, by lowercase letter. QHash alternates; @@ -64,3 +78,5 @@ struct Layout { return nullptr; } }; + +inline const QString MainSectionId = QStringLiteral("main"); diff --git a/src/layout/LayoutBuilder.cpp b/src/layout/LayoutBuilder.cpp index db4b91d..d8e62c2 100644 --- a/src/layout/LayoutBuilder.cpp +++ b/src/layout/LayoutBuilder.cpp @@ -1,9 +1,7 @@ #include "LayoutBuilder.h" -#include "KeyGeometry.h" #include -#include namespace { @@ -74,24 +72,6 @@ Row expandLetters(const Row &row, const KeymapLetters &letters, const QHash #include #include +#include namespace LayoutLoader { @@ -32,6 +33,7 @@ const QHash &keyTypesByName() {QStringLiteral("dismiss"), KeyType::Dismiss}, {QStringLiteral("spacer"), KeyType::Spacer}, {QStringLiteral("letters"), KeyType::Letters}, + {QStringLiteral("keysym"), KeyType::Keysym}, }; return types; } @@ -66,6 +68,18 @@ std::optional parseKey(const QJsonValue &value, QString *error) key.alternates = toStringList(obj.value(u"alternates")); key.lettersRow = obj.value(u"row").toInt(-1); key.flicks = toStringList(obj.value(u"flicks")); + key.repeat = obj.value(u"repeat").toBool(); + + if (key.type == KeyType::Keysym) { + const QString name = obj.value(u"keysym").toString(); + key.keysym = xkb_keysym_from_name(qPrintable(name), XKB_KEYSYM_NO_FLAGS); + if (key.keysym == XKB_KEY_NoSymbol) { + *error = QStringLiteral("unknown keysym \"%1\"").arg(name); + return std::nullopt; + } + if (key.label.isEmpty()) + key.label = name; + } if (key.type == KeyType::Character && key.label.isEmpty()) { *error = QStringLiteral("character key without a label"); @@ -82,6 +96,25 @@ std::optional parseKey(const QJsonValue &value, QString *error) return key; } +bool parseRows(const QJsonValue &value, QList *rows, QString *error) +{ + for (const QJsonValue &rowValue : value.toArray()) { + Row row; + for (const QJsonValue &keyValue : rowValue.toArray()) { + auto key = parseKey(keyValue, error); + if (!key) + return false; + row.append(*key); + } + rows->append(row); + } + if (rows->isEmpty()) { + *error = QStringLiteral("no rows"); + return false; + } + return true; +} + std::optional parseLayer(const QJsonObject &obj, QString *error) { Layer layer; @@ -90,27 +123,35 @@ std::optional parseLayer(const QJsonObject &obj, QString *error) *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); + if (!parseRows(obj.value(u"rows"), &layer.rows, error)) { + *error = QStringLiteral("layer \"%1\": %2").arg(layer.name, *error); return std::nullopt; } return layer; } +std::optional
parseSection(const QJsonObject &obj, QString *error) +{ + Section section; + section.id = obj.value(u"id").toString(); + section.name = obj.value(u"name").toString(section.id); + if (section.id.isEmpty() || section.id == MainSectionId) { + *error = QStringLiteral("section needs an id other than \"%1\"").arg(MainSectionId); + return std::nullopt; + } + const QString placement = obj.value(u"placement").toString(QStringLiteral("right")); + if (placement != u"right" && placement != u"above") { + *error = QStringLiteral("section \"%1\": placement must be \"above\" or \"right\"").arg(section.id); + return std::nullopt; + } + section.placement = placement == u"above" ? Section::Placement::Above : Section::Placement::Right; + if (!parseRows(obj.value(u"rows"), §ion.rows, error)) { + *error = QStringLiteral("section \"%1\": %2").arg(section.id, *error); + return std::nullopt; + } + return section; +} + } // namespace std::optional parse(const QByteArray &json, QString *error) @@ -142,6 +183,13 @@ std::optional parse(const QByteArray &json, QString *error) layout.layers.append(*layer); } + for (const QJsonValue §ionValue : root.value(u"sections").toArray()) { + auto section = parseSection(sectionValue.toObject(), error); + if (!section) + return std::nullopt; + layout.sections.append(*section); + } + if (layout.layers.isEmpty()) { *error = QStringLiteral("layout has no layers"); return std::nullopt; diff --git a/src/layout/LayoutModel.cpp b/src/layout/LayoutModel.cpp index b322676..3e0e6dd 100644 --- a/src/layout/LayoutModel.cpp +++ b/src/layout/LayoutModel.cpp @@ -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::Keysym: return QStringLiteral("keysym"); case KeyType::Letters: break; // expanded before it reaches the model } return {}; @@ -89,6 +90,27 @@ void LayoutModel::setLayer(const QString &layerName) Q_EMIT layerChanged(); } +void LayoutModel::setVisibleSections(const QStringList &ids) +{ + if (ids == m_visibleSections) + return; + m_visibleSections = ids; + rebuild(); +} + +QVariantList LayoutModel::sections() const +{ + QVariantList list; + for (const Section §ion : m_layout.sections) + list.append(QVariantMap{{QStringLiteral("id"), section.id}, {QStringLiteral("name"), section.name}}); + return list; +} + +qreal LayoutModel::totalUnits() const +{ + return ::totalUnits(m_blocks); +} + void LayoutModel::setSize(QSizeF size) { if (size == m_size) @@ -140,12 +162,39 @@ int LayoutModel::keyAt(QPointF pos) const return nearest; } +QList LayoutModel::composeBlocks() const +{ + const auto shown = [this](const QString &id) { return m_visibleSections.contains(id); }; + const Layer *layer = m_layout.layer(m_layerName); + + Block main; + for (const Section §ion : m_layout.sections) { + if (section.placement == Section::Placement::Above && shown(section.id)) + main += section.rows; + } + if (layer && shown(MainSectionId)) + main += layer->rows; + + QList blocks; + if (!main.isEmpty()) + blocks.append(main); + for (const Section §ion : m_layout.sections) { + if (section.placement == Section::Placement::Right && shown(section.id)) + blocks.append(section.rows); + } + // Never show nothing. + if (blocks.isEmpty() && layer) + blocks.append(layer->rows); + return blocks; +} + void LayoutModel::rebuild() { beginResetModel(); + m_blocks = composeBlocks(); m_keys.clear(); - if (const Layer *layer = m_layout.layer(m_layerName)) { - for (const Row &row : layer->rows) { + for (const Block &block : std::as_const(m_blocks)) { + for (const Row &row : block) { for (const KeyDef &key : row) { if (key.type != KeyType::Spacer) m_keys.append(key); @@ -161,17 +210,18 @@ void LayoutModel::rebuild() void LayoutModel::relayout() { m_rects.clear(); - const Layer *layer = m_layout.layer(m_layerName); - if (!layer || m_size.isEmpty()) + if (m_size.isEmpty()) return; - const QList all = computeKeyRects(*layer, m_size); + const QList all = computeKeyRects(m_blocks, 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; + for (const Block &block : std::as_const(m_blocks)) { + for (const Row &row : block) { + for (const KeyDef &key : row) { + if (key.type != KeyType::Spacer) + m_rects.append(all[i]); + ++i; + } } } } diff --git a/src/layout/LayoutModel.h b/src/layout/LayoutModel.h index fe37309..b4b237b 100644 --- a/src/layout/LayoutModel.h +++ b/src/layout/LayoutModel.h @@ -6,8 +6,9 @@ #include #include -// The keys of the current layer, positioned for the current size. -// Spacers take room but are not exposed as items. +// The keys on screen, positioned for the current size: the current layer +// plus the sections the user chose to show. Spacers take room but are not +// exposed as items. class LayoutModel : public QAbstractListModel { Q_OBJECT @@ -15,6 +16,8 @@ class LayoutModel : public QAbstractListModel QML_UNCREATABLE("Created in C++") Q_PROPERTY(QString layoutName READ layoutName NOTIFY layoutChanged) Q_PROPERTY(QString layerName READ layerName NOTIFY layerChanged) + // [{id, name}] of the optional sections, for the settings panel. + Q_PROPERTY(QVariantList sections READ sections NOTIFY layoutChanged) public: enum Role { @@ -37,9 +40,14 @@ public: QString layoutName() const { return m_layout.name; } QString layerName() const { return m_layerName; } + QVariantList sections() const; + // Width in key units of everything shown. + qreal totalUnits() const; void setLayout(const Layout &layout); void setLayer(const QString &layerName); + // Section ids to show; MainSectionId stands for the layers. + void setVisibleSections(const QStringList &ids); void setSize(QSizeF size); void setUppercase(bool uppercase); void setPressed(int index, bool pressed); @@ -56,11 +64,14 @@ Q_SIGNALS: void layerChanged(); private: + QList composeBlocks() const; void rebuild(); void relayout(); Layout m_layout; QString m_layerName; + QStringList m_visibleSections{MainSectionId}; + QList m_blocks; QSizeF m_size; bool m_uppercase = false; diff --git a/src/main.cpp b/src/main.cpp index b18c66d..54af9f2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -52,6 +52,17 @@ void connectLayouts(const Layout &tmpl, WaylandKeymap &keymap, SystemLayouts &sy rebuild(); } +// Showing or hiding a block resizes the window so keys keep their width. +void connectSections(Settings &settings, LayoutModel &model, FloatingWindow &placement) +{ + QObject::connect(&settings, &Settings::visibleSectionsChanged, &model, [&] { + const qreal before = model.totalUnits(); + model.setVisibleSections(settings.visibleSections()); + if (before > 0) + placement.resizeBy(placement.geometry().width() * (model.totalUnits() / before - 1), 0); + }); +} + void restoreAndPersistGeometry(FloatingWindow &placement, WindowSettings &settings) { if (const auto geometry = settings.load(placement.screenName())) @@ -109,6 +120,7 @@ int main(int argc, char *argv[]) Settings settings; controller.setOptions(settings.typingOptions()); + model.setVisibleSections(settings.visibleSections()); QObject::connect(&settings, &Settings::typingOptionsChanged, &controller, [&] { controller.setOptions(settings.typingOptions()); }); @@ -129,6 +141,7 @@ int main(int argc, char *argv[]) window->setProperty("placement", QVariant::fromValue(&placement)); WindowSettings windowSettings; restoreAndPersistGeometry(placement, windowSettings); + connectSections(settings, model, placement); VisibilityController visibility; connectVisibility(visibility, backend, controller, placement); diff --git a/src/settings/Settings.cpp b/src/settings/Settings.cpp index 9592986..506cc6a 100644 --- a/src/settings/Settings.cpp +++ b/src/settings/Settings.cpp @@ -1,10 +1,13 @@ #include "Settings.h" +#include "layout/Layout.h" + #include namespace { constexpr auto AutoCapitalizeKey = "typing/autoCapitalize"; constexpr auto DoubleSpacePeriodKey = "typing/doubleSpacePeriod"; +constexpr auto VisibleSectionsKey = "layout/visibleSections"; } Settings::Settings(QObject *parent) @@ -14,6 +17,23 @@ Settings::Settings(QObject *parent) const TypingOptions defaults; m_typing.autoCapitalize = store.value(AutoCapitalizeKey, defaults.autoCapitalize).toBool(); m_typing.doubleSpacePeriod = store.value(DoubleSpacePeriodKey, defaults.doubleSpacePeriod).toBool(); + m_visibleSections = store.value(VisibleSectionsKey, QStringList{MainSectionId}).toStringList(); + if (m_visibleSections.isEmpty()) + m_visibleSections = {MainSectionId}; +} + +void Settings::setSectionVisible(const QString &id, bool visible) +{ + QStringList sections = m_visibleSections; + if (visible && !sections.contains(id)) + sections.append(id); + else if (!visible) + sections.removeAll(id); + if (sections == m_visibleSections || sections.isEmpty()) + return; + m_visibleSections = sections; + QSettings().setValue(VisibleSectionsKey, m_visibleSections); + Q_EMIT visibleSectionsChanged(); } void Settings::setAutoCapitalize(bool enabled) diff --git a/src/settings/Settings.h b/src/settings/Settings.h index f0cb6bf..96b4bc0 100644 --- a/src/settings/Settings.h +++ b/src/settings/Settings.h @@ -3,6 +3,7 @@ #include "input/TypingOptions.h" #include +#include #include // User preferences, persisted with QSettings (~/.config/sloposk/sloposk.conf) @@ -14,6 +15,8 @@ class Settings : public QObject 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) + // Which key blocks are shown: "main" (the typing keys) and template section ids. + Q_PROPERTY(QStringList visibleSections READ visibleSections NOTIFY visibleSectionsChanged) public: explicit Settings(QObject *parent = nullptr); @@ -25,11 +28,17 @@ public: bool doubleSpacePeriod() const { return m_typing.doubleSpacePeriod; } void setDoubleSpacePeriod(bool enabled); + QStringList visibleSections() const { return m_visibleSections; } + // Hiding the last visible section is refused: something must stay on screen. + Q_INVOKABLE void setSectionVisible(const QString &id, bool visible); + Q_SIGNALS: void typingOptionsChanged(); + void visibleSectionsChanged(); private: void setTypingOptions(const TypingOptions &options); TypingOptions m_typing; + QStringList m_visibleSections; }; diff --git a/tests/tst_controller.cpp b/tests/tst_controller.cpp index e8e673b..abfc216 100644 --- a/tests/tst_controller.cpp +++ b/tests/tst_controller.cpp @@ -42,6 +42,7 @@ private Q_SLOTS: { engine = {}; model.setLayout(TestLayouts::build("us")); + model.setVisibleSections({MainSectionId}); controller = std::make_unique(engine, model); } @@ -199,6 +200,36 @@ private Q_SLOTS: QCOMPARE(requested, 1); } + void keysymKeyActsOnPressAndRepeats() + { + KeyDef left = makeKey(KeyType::Keysym, u"←"_s); + left.keysym = XKB_KEY_Left; + left.repeat = true; + controller->keyDown(left, 0); + QCOMPARE(engine.keysyms, QList{XKB_KEY_Left}); + QTRY_VERIFY_WITH_TIMEOUT(engine.keysyms.size() >= 3, 2000); // held: repeats + controller->keyUp(left, KeyOutput::Primary, {}, 0); + const auto sent = engine.keysyms.size(); + QTest::qWait(200); + QCOMPARE(engine.keysyms.size(), sent); + QVERIFY(engine.output.isEmpty()); // no text + } + + void sectionsAreComposedAroundTheMainKeys() + { + const qsizetype mainKeys = model.rowCount(); + const qreal mainUnits = model.totalUnits(); + model.setVisibleSections({MainSectionId, u"function"_s}); + QCOMPARE(model.rowCount(), mainKeys + 13); + QCOMPARE(model.totalUnits(), mainUnits); // above, not beside + model.setVisibleSections({MainSectionId, u"navigation"_s}); + QCOMPARE(model.totalUnits(), mainUnits + 3); + model.setVisibleSections({u"numpad"_s}); + QCOMPARE(model.rowCount(), 19); + model.setVisibleSections({}); + QCOMPARE(model.rowCount(), mainKeys); // never nothing + } + void dismissEmitsSignal() { int dismissed = 0; diff --git a/tests/tst_keygeometry.cpp b/tests/tst_keygeometry.cpp index b505e96..71d1a92 100644 --- a/tests/tst_keygeometry.cpp +++ b/tests/tst_keygeometry.cpp @@ -49,6 +49,26 @@ private Q_SLOTS: QCOMPARE(rects[4].left(), 100.0); QCOMPARE(rects[5].right(), 300.0); } + + void stretchKeysFillTheRow() + { + KeyDef wide = key(); + wide.stretch = true; + const Layer layer{u"l"_s, {{key(), key(), key(), key()}, {key(), wide}}}; + const auto rects = computeKeyRects(layer, {400, 100}, {.padding = 0, .gap = 0}); + QCOMPARE(rects[4].left(), 0.0); + QCOMPARE(rects[5].width(), 300.0); + } + + void blocksShareUnitAndSitAtTheBottom() + { + const Block tall{{key(), key()}, {key(), key()}}; + const Block shortBlock{{key()}}; + const auto rects = computeKeyRects({tall, shortBlock}, {400, 100}, {.padding = 0, .gap = 0, .blockGap = 100}); + // 3 units in 300 px: 100 each. + QCOMPARE(rects[1].right(), 200.0); + QCOMPARE(rects[4], QRectF(300, 50, 100, 50)); + } }; QTEST_GUILESS_MAIN(TestKeyGeometry) diff --git a/tests/tst_layoutloader.cpp b/tests/tst_layoutloader.cpp index 206120e..ec0da1a 100644 --- a/tests/tst_layoutloader.cpp +++ b/tests/tst_layoutloader.cpp @@ -3,6 +3,7 @@ #include "layout/LayoutLoader.h" #include +#include using namespace Qt::StringLiterals; @@ -67,24 +68,60 @@ private Q_SLOTS: &error)); } - // Stretching must even out rows whatever the keymap's letter counts. - void builtLayersHaveEqualRows_data() + // Stretch keys must even out rows whatever the keymap's letter counts. + void builtLayersHaveEvenRows_data() { QTest::addColumn("xkbLayout"); for (const char *layout : {"us", "sk", "se", "de", "fr", "ru", "gr"}) QTest::newRow(layout) << QString::fromLatin1(layout); } - void builtLayersHaveEqualRows() + void builtLayersHaveEvenRows() { QFETCH(QString, xkbLayout); const Layout layout = TestLayouts::build(qPrintable(xkbLayout)); for (const Layer &layer : layout.layers) { - const qreal expected = rowUnits(layer.rows.first()); - for (const Row &row : layer.rows) - QVERIFY2(qFuzzyCompare(rowUnits(row), expected), qPrintable(layer.name)); + const QList rects = computeKeyRects(layer, {1000, 300}); + qsizetype i = 0; + for (const Row &row : layer.rows) { + const QRectF first = rects[i]; + const QRectF last = rects[i + row.size() - 1]; + QVERIFY2(qAbs(first.left() - rects.first().left()) < 0.01, qPrintable(layer.name)); + QVERIFY2(qAbs(last.right() - rects[layer.rows.first().size() - 1].right()) < 0.01, + qPrintable(layer.name)); + i += row.size(); + } } } + + void templateHasKeySections() + { + const Layout layout = TestLayouts::builtinTemplate(); + QStringList ids; + for (const Section §ion : layout.sections) + ids.append(section.id); + QCOMPARE(ids, (QStringList{u"function"_s, u"navigation"_s, u"numpad"_s})); + } + + void parsesKeysymKeys() + { + QString error; + const auto layout = LayoutLoader::parse( + R"({"id": "t", "layers": [{"name": "l", "rows": [[{"type": "keysym", "keysym": "Home", "repeat": true}]]}]})", + &error); + QVERIFY2(layout, qPrintable(error)); + const KeyDef &key = layout->layers[0].rows[0][0]; + QCOMPARE(key.keysym, quint32(XKB_KEY_Home)); + QCOMPARE(key.label, u"Home"); // the keysym name unless labelled + QVERIFY(key.repeat); + } + + void rejectsUnknownKeysym() + { + QString error; + QVERIFY(!LayoutLoader::parse( + R"({"id": "t", "layers": [{"name": "l", "rows": [[{"type": "keysym", "keysym": "Nope"}]]}]})", &error)); + } }; QTEST_GUILESS_MAIN(TestLayoutLoader)