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
+30
View File
@@ -0,0 +1,30 @@
import QtQuick
// The grab bar under the floating keyboard. Emits how far it was dragged;
// moving the window is up to the owner.
Item {
id: handle
signal moved(real dx, real dy)
implicitHeight: 22
Rectangle {
anchors.centerIn: parent
width: 120
height: 5
radius: height / 2
color: Theme.handle
}
DragHandler {
target: null
// The window follows the pointer, so the pointer stays near its press
// position in window coordinates: measure each step from there.
onCentroidChanged: {
if (active)
handle.moved(centroid.scenePosition.x - centroid.scenePressPosition.x,
centroid.scenePosition.y - centroid.scenePressPosition.y)
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import QtQuick
import Sloposk
// One key, drawn from the LayoutModel roles. Purely visual.
Item {
id: key
required property string label
required property string flickLabel
required property string keyType
required property real keyX
required property real keyY
required property real keyWidth
required property real keyHeight
required property bool pressed
property int shiftMode: KeyboardController.Off
readonly property bool isCharacter: keyType === "char" || keyType === "space"
readonly property bool shiftActive: keyType === "shift" && shiftMode !== KeyboardController.Off
// Character keys darken when pressed; function keys lighten (as on iPad).
readonly property bool lit: (isCharacter !== pressed) || shiftActive
readonly property var defaultGlyphs: ({
backspace: "⌫",
globe: "🌐",
dismiss: "⌨",
enter: "return",
})
readonly property string displayLabel: {
if (keyType === "shift")
return ["⇧", "⬆", "⇪"][shiftMode]
return label !== "" ? label : (defaultGlyphs[keyType] ?? "")
}
// Count code points, not UTF-16 units, so emoji glyphs aren't treated as words.
readonly property bool isWord: [...displayLabel].length > 1 && !isCharacter
x: keyX
y: keyY
width: keyWidth
height: keyHeight
Rectangle {
anchors { fill: face; topMargin: 1; bottomMargin: -1 }
radius: Theme.keyRadius
color: Theme.keyShadow
}
Rectangle {
id: face
anchors.fill: parent
radius: Theme.keyRadius
color: key.lit ? Theme.characterKey : Theme.functionKey
}
Text {
id: flick
visible: text !== ""
text: key.flickLabel
anchors { horizontalCenter: parent.horizontalCenter; top: parent.top; topMargin: key.height * 0.1 }
font.pixelSize: key.height * 0.24
color: Theme.secondaryText
}
Text {
text: key.displayLabel
anchors {
horizontalCenter: parent.horizontalCenter
verticalCenter: parent.verticalCenter
verticalCenterOffset: flick.visible ? key.height * 0.14 : 0
}
font.pixelSize: key.height * (key.isWord ? 0.24 : flick.visible ? 0.34 : 0.4)
color: Theme.text
}
}
+13
View File
@@ -0,0 +1,13 @@
import QtQuick
import Sloposk
KeyboardSurface {
id: surface
Repeater {
model: surface.model
delegate: Key {
shiftMode: surface.controller ? surface.controller.shiftMode : KeyboardController.Off
}
}
}
+21 -32
View File
@@ -1,45 +1,34 @@
import QtQuick
import QtQuick.Controls
import Sloposk
// Spike UI: proves text commits, keysyms and dragging. Replaced by the real
// keyboard in milestone 2.
Window {
id: root
required property var backend
property var placement: null
required property LayoutModel layoutModel
required property KeyboardController controller
property var placement: null // FloatingWindow, set from C++
width: 720
height: 120
width: 1000
height: 340
visible: false
color: "#d1d3d9"
color: "transparent"
Rectangle {
id: handle
anchors { horizontalCenter: parent.horizontalCenter; bottom: parent.bottom; bottomMargin: 6 }
width: 120; height: 6; radius: 3
color: "#8e8e93"
anchors.fill: parent
radius: Theme.windowRadius
color: Theme.background
DragHandler {
target: null
margin: 12
property point last
onActiveChanged: last = centroid.scenePressPosition
onCentroidChanged: {
if (!active || !root.placement)
return
const p = centroid.scenePosition
root.placement.moveBy(p.x - last.x, p.y - last.y)
}
Keyboard {
anchors { left: parent.left; right: parent.right; top: parent.top; bottom: handle.top }
anchors.margins: 2
model: root.layoutModel
controller: root.controller
}
DragHandle {
id: handle
anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
onMoved: (dx, dy) => root.placement?.moveBy(dx, dy)
}
}
Row {
anchors.centerIn: parent
spacing: 8
Button { text: "héllo👍"; focusPolicy: Qt.NoFocus; onClicked: root.backend.commitText(text) }
Button { text: "⌫"; focusPolicy: Qt.NoFocus; onClicked: root.backend.sendKeysym(0xff08) }
Button { text: "⏎"; focusPolicy: Qt.NoFocus; onClicked: root.backend.sendKeysym(0xff0d) }
Label { anchors.verticalCenter: parent.verticalCenter; text: root.backend.active ? "active" : "inactive" }
}
}
+19
View File
@@ -0,0 +1,19 @@
pragma Singleton
import QtQuick
// iPad keyboard palette; follows the system light/dark preference.
QtObject {
readonly property bool dark: Qt.styleHints.colorScheme === Qt.ColorScheme.Dark
readonly property color background: dark ? "#2c2c2e" : "#d1d3d9"
readonly property color characterKey: dark ? "#6b6b6e" : "#ffffff"
readonly property color functionKey: dark ? "#464649" : "#abb0bc"
readonly property color keyShadow: dark ? "#141416" : "#88898d"
readonly property color text: dark ? "#ffffff" : "#000000"
readonly property color secondaryText: dark ? "#a5a5aa" : "#8a8a8e"
readonly property color handle: dark ? "#8e8e93" : "#7c7c80"
readonly property real windowRadius: 14
readonly property real keyRadius: 6
}