Files
face-unlock/src/agent/qml/Bubble.qml
T

255 lines
9.2 KiB
QML

// SPDX-License-Identifier: GPL-3.0-or-later
//
// The bubble: a black island at the top of the screen that slides down,
// opens up, shows the face while it looks, and closes again.
//
// The choreography is Glance's: entering, the island slides in first and grows
// a moment later; leaving, it shrinks first and slides away as it finishes.
// Growing overshoots a little, shrinking does not, and both end on time rather
// than creeping in. The content grows and shrinks with the island. While it
// scans the content breathes, so it reads as looking rather than stuck.
//
// "full" is the open island with the face in it. "minimal" is a small pill
// with a lock on one side and the face on the other.
import QtQuick
import QtQuick.Effects
Item {
id: root
required property var bubble
width: 420
height: 300
readonly property string phase: bubble ? bubble.phase : "hidden"
readonly property bool minimal: bubble && bubble.style === "minimal"
readonly property bool wantOpen: phase !== "hidden"
property bool positioned: false
property bool expanded: false
// Which way it is going. Set before the change it is about, so the
// animations below have the matching curve by the time they start.
property bool opening: false
function choreograph() {
if (wantOpen) {
opening = true
slideOut.stop()
closeDone.stop()
positioned = true
if (!expanded) {
expandLater.restart()
}
} else {
opening = false
expandLater.stop()
expanded = false
slideOut.restart()
}
}
onWantOpenChanged: choreograph()
Component.onCompleted: choreograph()
Timer {
id: expandLater
interval: Theme.expandDelay
onTriggered: root.expanded = true
}
Timer {
id: slideOut
interval: Theme.slideOutDelay
onTriggered: {
root.positioned = false
closeDone.restart()
}
}
Timer {
id: closeDone
interval: Theme.slideOutDuration + 60
onTriggered: if (root.bubble) root.bubble.closed()
}
// The phase on screen. Closing keeps the last one, so the tick does not
// turn back into a face on its way out.
property string shownPhase: "idle"
onPhaseChanged: {
if (phase !== "hidden") {
shownPhase = phase
}
if (phase === "failure" && minimal) {
pillShake.restart()
}
}
// What the face shows.
readonly property string glyphMode: shownPhase === "scanning" ? (bubble.faceSeen ? "tracking" : "scanning") : shownPhase
// -- the breathing while it scans
property real pulse: 0
SequentialAnimation on pulse {
id: pulseAnimation
running: root.phase === "scanning" && root.expanded
loops: Animation.Infinite
PauseAnimation { duration: 600 }
NumberAnimation { from: 0; to: 1; duration: 400; easing.type: Easing.InOutQuad }
PauseAnimation { duration: 50 }
NumberAnimation { from: 1; to: 0; duration: 400; easing.type: Easing.InOutQuad }
PauseAnimation { duration: 50 }
onRunningChanged: if (!running) settle.restart()
}
NumberAnimation {
id: settle
target: root
property: "pulse"
to: 0
duration: 200
easing.type: Easing.OutQuad
}
// -- the minimal pill shakes as a whole; the full island shakes its face
property real shake: 0
SequentialAnimation {
id: pillShake
NumberAnimation { target: root; property: "shake"; to: -10; duration: 55; easing.type: Easing.OutQuad }
NumberAnimation { target: root; property: "shake"; to: 9; duration: 70 }
NumberAnimation { target: root; property: "shake"; to: -6; duration: 65 }
NumberAnimation { target: root; property: "shake"; to: 4; duration: 60 }
NumberAnimation { target: root; property: "shake"; to: 0; duration: 55; easing.type: Easing.OutQuad }
}
Item {
id: island
readonly property real targetWidth: root.expanded ? (root.minimal ? Theme.minimalWidth : Theme.openWidth) : Theme.closedWidth
readonly property real targetHeight: root.expanded ? (root.minimal ? Theme.minimalHeight : Theme.openHeight) : Theme.closedHeight
// How far open it is, for the content that grows with it.
readonly property real fit: Math.min(width / Theme.openWidth, height / Theme.openHeight)
width: targetWidth
height: targetHeight
// Growing overshoots sideways more than down, so the bottom edge does
// not sag.
Behavior on width {
NumberAnimation {
duration: root.opening ? Theme.growDuration : Theme.shrinkDuration
easing.type: root.opening ? Easing.OutBack : Easing.OutCubic
easing.overshoot: 1.6
}
}
Behavior on height {
NumberAnimation {
duration: root.opening ? Theme.growDuration : Theme.shrinkDuration
easing.type: root.opening ? Easing.OutBack : Easing.OutCubic
easing.overshoot: 0.9
}
}
x: (root.width - width) / 2 + root.shake
// Off screen by a fixed amount. A target that followed the shrinking
// height would restart the slide every frame and hold it back.
y: root.positioned ? Theme.topGap : -Theme.closedHeight - 20
Behavior on y {
NumberAnimation {
duration: root.opening ? Theme.slideInDuration : Theme.slideOutDuration
easing.type: root.opening ? Easing.OutCubic : Easing.InCubic
}
}
Rectangle {
id: shape
anchors.fill: parent
color: Theme.panel
// From size alone, so it cannot jump when the island turns.
radius: root.minimal ? height / 2 : Math.min(Theme.openRadius, height / 2)
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: "#000000"
shadowOpacity: root.expanded ? 0.35 : 0
shadowBlur: 0.7
shadowVerticalOffset: 3
Behavior on shadowOpacity { NumberAnimation { duration: 200 } }
}
}
// -- full: the face, and a line of text under it. Laid out at the
// open size and scaled with the island, so it never pokes out.
Item {
id: full
width: Theme.openWidth
height: Theme.openHeight
anchors.centerIn: parent
visible: !root.minimal
scale: island.fit
opacity: root.expanded ? 1 : 0
Behavior on opacity { NumberAnimation { duration: root.opening ? 260 : 120 } }
readonly property bool hasMessage: root.bubble && root.bubble.message.length > 0
Item {
anchors.fill: parent
opacity: 1 - 0.35 * root.pulse
scale: 1 - 0.03 * root.pulse
FaceGlyph {
id: glyph
width: 100
height: 100
anchors.horizontalCenter: parent.horizontalCenter
y: full.hasMessage ? 28 : 40
Behavior on y { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
mode: root.glyphMode
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 20
width: parent.width - 32
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
text: root.bubble ? root.bubble.message : ""
color: root.shownPhase === "failure" || root.shownPhase === "lockout" ? Theme.textDetail : Theme.textSecondary
font.pixelSize: 13
font.weight: Font.Medium
opacity: full.hasMessage ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 200 } }
}
}
}
// -- minimal: [ lock ] ... [ face ]
Item {
id: small
anchors.fill: parent
visible: root.minimal
opacity: root.expanded ? 1 : 0
Behavior on opacity { NumberAnimation { duration: root.opening ? 200 : 120 } }
LockGlyph {
width: 18
height: 18
anchors.verticalCenter: parent.verticalCenter
x: 16
open: root.shownPhase === "success"
color: root.shownPhase === "failure" || root.shownPhase === "lockout" ? Theme.failure : Theme.textPrimary
}
FaceGlyph {
width: 24
height: 24
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
anchors.rightMargin: 14
lineWidth: 2.4
mode: root.glyphMode === "lockout" ? "failure" : root.glyphMode
opacity: 1 - 0.35 * root.pulse
scale: 1 - 0.03 * root.pulse
}
}
}
}