feat: expressive lock input (#1536)

* feat: shapes in lock input

* fix: better lock input placeholder

* feat: scale lock center properly + style improvements

* fix: use non anim placeholder width

* fix: not mono lock state text

* fix: use shape queue instead of random

* fix: use scale for enter button instead of state layer

* fix: no slant for lock state message

* fix: enter button pressable when no buffer
This commit is contained in:
2 * r + 2 * t 2026-06-09 19:07:18 +10:00 committed by GitHub
parent 63b522648e
commit a1124c8228
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 229 additions and 104 deletions

View file

@ -41,6 +41,7 @@ ColumnLayout {
PasswordInput { PasswordInput {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
centerScale: Math.max(0.8, root.centerScale)
centerWidth: root.centerWidth centerWidth: root.centerWidth
lock: root.lock lock: root.lock
} }

View file

@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Quickshell import Quickshell
import M3Shapes
import Caelestia.Config import Caelestia.Config
import qs.components import qs.components
import qs.services import qs.services
@ -10,9 +11,19 @@ import qs.modules.lock
Item { Item {
id: root id: root
required property real centerScale
required property Pam pam required property Pam pam
readonly property alias placeholder: placeholder readonly property alias placeholder: placeholder
readonly property alias placeholderWidth: nonAnimPlaceholder.width
property string buffer property string buffer
readonly property list<int> shapeQueue: {
const shapes = [MaterialShape.Slanted, MaterialShape.Arch, MaterialShape.Fan, MaterialShape.Arrow, MaterialShape.SemiCircle, MaterialShape.Triangle, MaterialShape.Diamond, MaterialShape.ClamShell, MaterialShape.Pentagon, MaterialShape.Gem, MaterialShape.Sunny, MaterialShape.VerySunny, MaterialShape.Cookie4Sided, MaterialShape.Ghostish, MaterialShape.SoftBurst];
for (let i = shapes.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shapes[i], shapes[j]] = [shapes[j], shapes[i]];
}
return shapes;
}
clip: true clip: true
@ -31,22 +42,30 @@ Item {
target: root.pam target: root.pam
} }
StyledText { TextMetrics {
id: placeholder id: nonAnimPlaceholder
anchors.centerIn: parent
text: { text: {
if (root.pam.passwd.active) if (root.pam.passwd.active)
return qsTr("Loading..."); return qsTr("Loading...");
if (root.pam.state === "max") if (root.pam.state === "max")
return qsTr("You have reached the maximum number of tries"); return qsTr("Max tries reached");
return qsTr("Enter your password"); return qsTr("Enter your password");
} }
font: placeholder.font
}
StyledText {
id: placeholder
anchors.centerIn: parent
anchors.verticalCenterOffset: 1
text: nonAnimPlaceholder.text
animate: true animate: true
color: root.pam.passwd.active ? Colours.palette.m3secondary : Colours.palette.m3outline color: root.pam.passwd.active ? Colours.palette.m3secondary : Colours.palette.m3outline
font: Tokens.font.mono.medium font: Tokens.font.body.builders.medium.scale(root.centerScale).width(110).build()
opacity: root.buffer ? 0 : 1 opacity: root.buffer ? 0 : 1
@ -60,7 +79,12 @@ Item {
ListView { ListView {
id: charList id: charList
readonly property int fullWidth: count * (implicitHeight + spacing) - spacing readonly property int fullWidth: {
let w = (count - 1) * spacing;
for (let i = 0; i < count; i++)
w += ((itemAtIndex(i) as CharItem)?.nonAnimWidthScale ?? 1) * implicitHeight;
return w + implicitHeight; // Extra padding at ends
}
function bindImWidth(): void { function bindImWidth(): void {
imWidthBehavior.enabled = false; imWidthBehavior.enabled = false;
@ -82,63 +106,7 @@ Item {
values: root.buffer.split("") values: root.buffer.split("")
} }
delegate: StyledRect { delegate: CharItem {}
id: ch
implicitWidth: implicitHeight
implicitHeight: charList.implicitHeight
color: Colours.palette.m3onSurface
radius: Tokens.rounding.medium / 2
opacity: 0
scale: 0
Component.onCompleted: {
opacity = 1;
scale = 1;
}
ListView.onRemove: removeAnim.start()
SequentialAnimation {
id: removeAnim
PropertyAction {
target: ch
property: "ListView.delayRemove"
value: true
}
ParallelAnimation {
Anim {
type: Anim.DefaultEffects
target: ch
property: "opacity"
to: 0
}
Anim {
target: ch
property: "scale"
to: 0.5
}
}
PropertyAction {
target: ch
property: "ListView.delayRemove"
value: false
}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
Behavior on scale {
Anim {
type: Anim.FastSpatial
}
}
}
Behavior on implicitWidth { Behavior on implicitWidth {
id: imWidthBehavior id: imWidthBehavior
@ -146,4 +114,121 @@ Item {
Anim {} Anim {}
} }
} }
component CharItem: Item {
id: char
required property int index
property real nonAnimWidthScale: 1
implicitHeight: charList.implicitHeight
ListView.onRemove: {
initAnim.stop();
removeAnim.start();
}
MaterialShape {
id: charShape
anchors.centerIn: parent
implicitSize: charList.implicitHeight * 1.5
shape: root.shapeQueue[char.index % root.shapeQueue.length] ?? MaterialShape.Circle
color: Colours.palette.m3onSurface
Behavior on color {
CAnim {}
}
SequentialAnimation {
id: initAnim
running: true
ParallelAnimation {
Anim {
target: charShape
property: "opacity"
from: 0
to: 1
type: Anim.DefaultEffects
}
Anim {
target: charShape
property: "scale"
from: 0
to: 1
type: Anim.FastSpatial
}
Anim {
target: char
property: "implicitWidth"
from: charList.implicitHeight
to: charList.implicitHeight * 1.3
type: Anim.DefaultEffects
}
PropertyAction {
target: char
property: "nonAnimWidthScale"
value: 1.5
}
}
PauseAnimation {
duration: 180 * Tokens.anim.durations.scale
}
PropertyAction {
target: charShape
property: "shape"
value: MaterialShape.Circle
}
ParallelAnimation {
Anim {
target: charShape
property: "scale"
to: 2 / 3
type: Anim.FastSpatial
}
Anim {
target: char
property: "implicitWidth"
to: charList.implicitHeight
type: Anim.DefaultEffects
}
PropertyAction {
target: char
property: "nonAnimWidthScale"
value: 1
}
}
}
SequentialAnimation {
id: removeAnim
PropertyAction {
target: char
property: "ListView.delayRemove"
value: true
}
ParallelAnimation {
Anim {
type: Anim.DefaultEffects
target: charShape
property: "opacity"
to: 0
}
Anim {
target: charShape
property: "scale"
to: 0.5
}
}
PropertyAction {
target: char
property: "ListView.delayRemove"
value: false
}
}
}
}
} }

View file

@ -1,5 +1,8 @@
pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import M3Shapes
import Caelestia.Config import Caelestia.Config
import qs.components import qs.components
import qs.components.controls import qs.components.controls
@ -8,10 +11,14 @@ import qs.services
StyledRect { StyledRect {
id: root id: root
required property real centerScale
required property int centerWidth required property int centerWidth
required property var lock required property var lock
implicitWidth: centerWidth * 0.8 implicitWidth: {
const w = centerWidth * 0.8;
return lock.pam.buffer ? w : Math.min(w, inputField.placeholderWidth + iconWrapper.implicitWidth + enterButton.implicitWidth + input.spacing * 2 + Tokens.padding.medium * 2);
}
implicitHeight: input.implicitHeight + Tokens.padding.small implicitHeight: input.implicitHeight + Tokens.padding.small
color: Colours.tPalette.m3surfaceContainer color: Colours.tPalette.m3surfaceContainer
@ -33,6 +40,10 @@ StyledRect {
root.lock.pam.handleKey(event); root.lock.pam.handleKey(event);
} }
Behavior on implicitWidth {
Anim {}
}
StateLayer { StateLayer {
hoverEnabled: false hoverEnabled: false
cursorShape: Qt.IBeamCursor cursorShape: Qt.IBeamCursor
@ -47,44 +58,39 @@ StyledRect {
spacing: Tokens.spacing.medium spacing: Tokens.spacing.medium
Item { Item {
id: iconWrapper
Layout.fillHeight: true Layout.fillHeight: true
implicitWidth: height implicitWidth: height
MaterialIcon { AnimLoader {
id: fprintIcon
anchors.centerIn: parent anchors.centerIn: parent
animate: true anchors.verticalCenterOffset: sourceComponent === iconComp ? 1 : 0
text: { sourceComp: root.lock.pam.passwd.active ? loadingComp : iconComp
if (root.lock.pam.fprint.tries >= GlobalConfig.lock.maxFprintTries) }
return "fingerprint_off";
if (root.lock.pam.fprint.active)
return "fingerprint";
return "lock";
}
color: root.lock.pam.fprint.tries >= GlobalConfig.lock.maxFprintTries ? Colours.palette.m3error : Colours.palette.m3onSurface
opacity: root.lock.pam.passwd.active ? 0 : 1
Behavior on opacity { Component {
Anim { id: iconComp
type: Anim.DefaultEffects
MaterialIcon {
animate: true
text: {
if (root.lock.pam.fprint.tries >= GlobalConfig.lock.maxFprintTries)
return "fingerprint_off";
if (root.lock.pam.fprint.active)
return "fingerprint";
return "lock";
} }
color: root.lock.pam.fprint.tries >= GlobalConfig.lock.maxFprintTries ? Colours.palette.m3error : Colours.palette.m3onSurfaceVariant
fontStyle: Tokens.font.icon.builders.medium.scale(root.centerScale).build()
} }
} }
Loader { Component {
anchors.fill: parent id: loadingComp
anchors.margins: Tokens.padding.small
active: opacity > 0 LoadingIndicator {
opacity: root.lock.pam.passwd.active ? 1 : 0 implicitSize: iconWrapper.height - Tokens.padding.small * 2
sourceComponent: LoadingIndicator {}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
} }
} }
} }
@ -95,19 +101,45 @@ StyledRect {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
centerScale: root.centerScale
pam: root.lock.pam pam: root.lock.pam
} }
StyledRect { Item {
id: enterButton
implicitWidth: implicitHeight implicitWidth: implicitHeight
implicitHeight: enterIcon.implicitHeight + Tokens.padding.small implicitHeight: {
const h = enterIcon.implicitHeight + Tokens.padding.extraSmall * 2;
return h % 2 === 0 ? h : h + 1;
}
color: root.lock.pam.buffer ? Colours.palette.m3primary : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) MaterialShape {
radius: Tokens.rounding.full anchors.fill: parent
StateLayer { color: root.lock.pam.buffer ? Colours.palette.m3primary : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
color: root.lock.pam.buffer ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface shape: root.lock.pam.buffer ? MaterialShape.Arrow : MaterialShape.Circle
onClicked: root.lock.pam.passwd.start() scale: !root.lock.pam.buffer ? 1 : mouse.pressed ? 0.6 : mouse.containsMouse ? 0.8 : 0.7
rotation: 90
Behavior on scale {
Anim {
type: Anim.FastSpatial
}
}
Behavior on color {
CAnim {}
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: root.lock.pam.buffer ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.lock.pam.buffer && root.lock.pam.passwd.start()
}
} }
MaterialIcon { MaterialIcon {
@ -115,8 +147,15 @@ StyledRect {
anchors.centerIn: parent anchors.centerIn: parent
text: "arrow_forward" text: "arrow_forward"
color: root.lock.pam.buffer ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface color: Colours.palette.m3onSurfaceVariant
fontStyle: Tokens.font.icon.size(Tokens.font.icon.large.pointSize).weight(Font.Medium).build() fontStyle: Tokens.font.icon.builders.medium.scale(root.centerScale * 1.2).build()
opacity: root.lock.pam.buffer ? 0 : 1
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
} }
} }
} }

View file

@ -119,7 +119,7 @@ Item {
opacity: root.stateMsgShouldBeVisible && !root.msg ? 1 : 0 opacity: root.stateMsgShouldBeVisible && !root.msg ? 1 : 0
color: Colours.palette.m3onSurfaceVariant color: Colours.palette.m3onSurfaceVariant
font: Tokens.font.mono.small font: Tokens.font.body.small
horizontalAlignment: Qt.AlignHCenter horizontalAlignment: Qt.AlignHCenter
wrapMode: Text.WrapAtWordBoundaryOrAnywhere wrapMode: Text.WrapAtWordBoundaryOrAnywhere
lineHeight: 1.2 lineHeight: 1.2
@ -145,7 +145,7 @@ Item {
opacity: 0 opacity: 0
color: Colours.palette.m3error color: Colours.palette.m3error
font: Tokens.font.mono.small font: Tokens.font.body.small
horizontalAlignment: Qt.AlignHCenter horizontalAlignment: Qt.AlignHCenter
wrapMode: Text.WrapAtWordBoundaryOrAnywhere wrapMode: Text.WrapAtWordBoundaryOrAnywhere