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