feat: show the bubble on gnome
This commit is contained in:
1 parent
f232f796fc
commit
2a9b0d3f91
28 files changed
+2278
-1077
No files matched your search
@@ -0,0 +1,926 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// face-unlock's bubble on GNOME.
|
||||
//
|
||||
// GNOME lets no program draw above its windows, and nothing at all above its
|
||||
// lock screen. An extension runs inside GNOME Shell and may. This one draws
|
||||
// the bubble the agent draws everywhere else, with the same shapes and the
|
||||
// same timing: a port of src/agent/qml (Bubble, FaceGlyph, LockGlyph,
|
||||
// Checkmark). What to show comes from the agent over the session bus.
|
||||
|
||||
import Clutter from 'gi://Clutter';
|
||||
import Gio from 'gi://Gio';
|
||||
import GLib from 'gi://GLib';
|
||||
import Pango from 'gi://Pango';
|
||||
import PangoCairo from 'gi://PangoCairo';
|
||||
import St from 'gi://St';
|
||||
|
||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
|
||||
|
||||
const BUS_NAME = 'io.github.loonixtools.FaceUnlock';
|
||||
const OBJECT_PATH = '/io/github/loonixtools/FaceUnlock';
|
||||
const BubbleProxy = Gio.DBusProxy.makeProxyWrapper(`
|
||||
<node>
|
||||
<interface name="io.github.loonixtools.FaceUnlock.Bubble">
|
||||
<method name="GetState"><arg type="a{sv}" direction="out"/></method>
|
||||
<signal name="StateChanged"><arg type="a{sv}"/></signal>
|
||||
</interface>
|
||||
</node>`);
|
||||
|
||||
// Theme.qml
|
||||
const Theme = {
|
||||
success: [0x30, 0xd1, 0x58],
|
||||
failure: [0xff, 0x45, 0x3a],
|
||||
panel: [0, 0, 0],
|
||||
textPrimary: [0xff, 0xff, 0xff],
|
||||
textSecondary: [0x94, 0x94, 0x94],
|
||||
textDetail: [0xbd, 0xbd, 0xbd],
|
||||
closedWidth: 80,
|
||||
closedHeight: 24,
|
||||
openWidth: 180,
|
||||
openHeight: 180,
|
||||
openRadius: 48,
|
||||
minimalWidth: 150,
|
||||
minimalHeight: 40,
|
||||
topGap: 6,
|
||||
slideInDuration: 280,
|
||||
expandDelay: 150,
|
||||
growDuration: 460,
|
||||
shrinkDuration: 280,
|
||||
slideOutDelay: 150,
|
||||
slideOutDuration: 240,
|
||||
};
|
||||
const WIDTH = 420;
|
||||
const HEIGHT = 300;
|
||||
|
||||
// Qt's easing curves, as the QML uses them.
|
||||
const Ease = {
|
||||
linear: p => p,
|
||||
outQuad: p => 1 - (1 - p) * (1 - p),
|
||||
inOutQuad: p => p < 0.5 ? 2 * p * p : 1 - (-2 * p + 2) ** 2 / 2,
|
||||
outCubic: p => 1 - (1 - p) ** 3,
|
||||
inCubic: p => p ** 3,
|
||||
inOutSine: p => -(Math.cos(Math.PI * p) - 1) / 2,
|
||||
outBack: s => p => {
|
||||
const q = p - 1;
|
||||
return q * q * ((s + 1) * q + s) + 1;
|
||||
},
|
||||
};
|
||||
const OUT_BACK = Ease.outBack(1.70158);
|
||||
|
||||
function now() {
|
||||
return GLib.get_monotonic_time() / 1000;
|
||||
}
|
||||
|
||||
function smooth(from, to, p) {
|
||||
const x = Math.max(0, Math.min(1, (p - from) / (to - from)));
|
||||
return x * x * (3 - 2 * x);
|
||||
}
|
||||
|
||||
// A value that eases to a new target from wherever it is, like a QML
|
||||
// Behavior. Colours are arrays and ease per channel.
|
||||
class Tween {
|
||||
constructor(value) {
|
||||
this._from = value;
|
||||
this._to = value;
|
||||
this._start = 0;
|
||||
this._duration = 0;
|
||||
this._ease = Ease.linear;
|
||||
}
|
||||
|
||||
set(to, duration, ease, at) {
|
||||
if (this._same(to, this._to))
|
||||
return;
|
||||
this._from = this.get(at);
|
||||
this._to = to;
|
||||
this._start = at;
|
||||
this._duration = duration;
|
||||
this._ease = ease;
|
||||
}
|
||||
|
||||
jump(value) {
|
||||
this._from = this._to = value;
|
||||
this._duration = 0;
|
||||
}
|
||||
|
||||
get(at) {
|
||||
const p = this._duration > 0 ? Math.min(1, (at - this._start) / this._duration) : 1;
|
||||
const e = this._ease(p);
|
||||
if (Array.isArray(this._to))
|
||||
return this._to.map((t, i) => this._from[i] + (t - this._from[i]) * e);
|
||||
return this._from + (this._to - this._from) * e;
|
||||
}
|
||||
|
||||
busy(at) {
|
||||
return this._duration > 0 && at - this._start < this._duration;
|
||||
}
|
||||
|
||||
_same(a, b) {
|
||||
return Array.isArray(a) ? a.every((v, i) => v === b[i]) : a === b;
|
||||
}
|
||||
}
|
||||
|
||||
// A run of steps from 0, like a QML SequentialAnimation of NumberAnimations:
|
||||
// [target, duration, easing]...
|
||||
function sequence(steps, start, at, pace) {
|
||||
if (start === null)
|
||||
return [0, false];
|
||||
let t = at - start;
|
||||
let from = 0;
|
||||
for (const [to, duration, ease] of steps) {
|
||||
const d = duration * pace;
|
||||
if (t < d)
|
||||
return [from + (to - from) * ease(t / d), true];
|
||||
t -= d;
|
||||
from = to;
|
||||
}
|
||||
return [from, false];
|
||||
}
|
||||
|
||||
function rgba(cr, color, alpha = 1) {
|
||||
cr.setSourceRGBA(color[0] / 255, color[1] / 255, color[2] / 255, alpha);
|
||||
}
|
||||
|
||||
// A quadratic curve in cairo, which only knows cubic ones.
|
||||
function quadTo(cr, x0, y0, cx, cy, x, y) {
|
||||
cr.curveTo(x0 + 2 / 3 * (cx - x0), y0 + 2 / 3 * (cy - y0),
|
||||
x + 2 / 3 * (cx - x), y + 2 / 3 * (cy - y), x, y);
|
||||
}
|
||||
|
||||
function polyline(cr, points) {
|
||||
if (points.length < 2)
|
||||
return;
|
||||
cr.moveTo(points[0][0], points[0][1]);
|
||||
for (let i = 1; i < points.length; ++i)
|
||||
cr.lineTo(points[i][0], points[i][1]);
|
||||
}
|
||||
|
||||
function roundedRect(cr, x, y, w, h, r) {
|
||||
r = Math.max(0, Math.min(r, w / 2, h / 2));
|
||||
cr.newSubPath();
|
||||
cr.arc(x + w - r, y + r, r, -Math.PI / 2, 0);
|
||||
cr.arc(x + w - r, y + h - r, r, 0, Math.PI / 2);
|
||||
cr.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI);
|
||||
cr.arc(x + r, y + r, r, Math.PI, 3 * Math.PI / 2);
|
||||
cr.closePath();
|
||||
}
|
||||
|
||||
// Draws what draw() draws into its own layer, at the given opacity and
|
||||
// scaled about the middle of the square it sits in.
|
||||
function layer(cr, opacity, scale, cx, cy, draw) {
|
||||
if (opacity <= 0.001)
|
||||
return;
|
||||
cr.save();
|
||||
cr.pushGroup();
|
||||
cr.translate(cx, cy);
|
||||
cr.scale(scale, scale);
|
||||
cr.translate(-cx, -cy);
|
||||
draw();
|
||||
cr.popGroupToSource();
|
||||
cr.paintWithAlpha(Math.min(1, opacity));
|
||||
cr.restore();
|
||||
}
|
||||
|
||||
// LockGlyph.qml: a padlock that opens.
|
||||
class LockGlyph {
|
||||
constructor() {
|
||||
this._openness = new Tween(0);
|
||||
this.open = false;
|
||||
this.pace = 1;
|
||||
}
|
||||
|
||||
setOpen(open, at) {
|
||||
if (open === this.open)
|
||||
return;
|
||||
this.open = open;
|
||||
if (open) {
|
||||
this._openness.jump(0);
|
||||
this._openness.set(1, 560 * this.pace, Ease.linear, at);
|
||||
} else {
|
||||
this._openness.jump(0);
|
||||
}
|
||||
}
|
||||
|
||||
busy(at) {
|
||||
return this._openness.busy(at);
|
||||
}
|
||||
|
||||
draw(cr, x, y, size, color, at) {
|
||||
const u = size / 100;
|
||||
const openness = this._openness.get(at);
|
||||
const lift = 12 * smooth(0, 0.3, openness);
|
||||
const p = Math.max(0, Math.min(1, (openness - 0.22) / 0.78));
|
||||
const s = 1.4;
|
||||
const swing = 180 * (1 + (s + 1) * (p - 1) ** 3 + s * (p - 1) ** 2);
|
||||
const beat = 1 + 0.14 * Math.sin(Math.PI * smooth(0.55, 1, openness));
|
||||
|
||||
cr.save();
|
||||
cr.translate(x + size / 2, y + size / 2);
|
||||
cr.scale(beat, beat);
|
||||
cr.translate(-size / 2, -size / 2);
|
||||
rgba(cr, color);
|
||||
roundedRect(cr, 14 * u, 46 * u, 72 * u, 50 * u, 12 * u);
|
||||
cr.fill();
|
||||
|
||||
const right = 68.5;
|
||||
const radius = 18.5;
|
||||
const top = 28 - lift;
|
||||
const narrow = Math.cos(swing * Math.PI / 180);
|
||||
const points = [];
|
||||
const add = (px, py) => points.push([(right + (px - right) * narrow) * u, py * u]);
|
||||
add(31.5, 56 - 18 * smooth(0, 0.3, openness));
|
||||
add(31.5, top);
|
||||
for (let i = 1; i < 24; ++i) {
|
||||
const a = Math.PI + Math.PI * i / 24;
|
||||
add(50 + radius * Math.cos(a), top + radius * Math.sin(a));
|
||||
}
|
||||
add(right, top);
|
||||
add(right, 56);
|
||||
cr.setLineWidth(11 * u);
|
||||
cr.setLineCap(1 /* round */);
|
||||
cr.setLineJoin(1 /* round */);
|
||||
polyline(cr, points);
|
||||
cr.stroke();
|
||||
cr.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// FaceGlyph.qml: brackets round a face that looks around, then the rings and
|
||||
// a tick, or a red shake of the head.
|
||||
class FaceGlyph {
|
||||
constructor(lineWidth = null) {
|
||||
this.lineWidth = lineWidth;
|
||||
this.color = Theme.textPrimary;
|
||||
this.pace = 1;
|
||||
this.mode = 'idle';
|
||||
this.onSettled = null;
|
||||
this._lookStart = 0;
|
||||
this._lookFrozen = 0;
|
||||
this._lookAmount = new Tween(0);
|
||||
this._smile = new Tween(1);
|
||||
this._bracket = new Tween(1);
|
||||
this._bracketColor = new Tween(Theme.textPrimary);
|
||||
this._shown = new Tween(1);
|
||||
this._lockOpacity = new Tween(0);
|
||||
this._lockScale = new Tween(0.7);
|
||||
this._lock = new LockGlyph();
|
||||
this._successStart = null;
|
||||
this._shakeStart = null;
|
||||
this._settle = 0;
|
||||
}
|
||||
|
||||
_looking() {
|
||||
return this.mode === 'scanning';
|
||||
}
|
||||
|
||||
_lookAngle(at) {
|
||||
if (!this._looking())
|
||||
return this._lookFrozen;
|
||||
const period = 2000 * this.pace;
|
||||
return 2 * Math.PI * (((at - this._lookStart) % period) / period);
|
||||
}
|
||||
|
||||
_breathe(at) {
|
||||
if (!this._looking())
|
||||
return 0;
|
||||
const half = 700 * this.pace;
|
||||
const p = ((at - this._lookStart) % (2 * half)) / half;
|
||||
return p < 1 ? Ease.inOutSine(p) : 1 - Ease.inOutSine(p - 1);
|
||||
}
|
||||
|
||||
setMode(mode, at) {
|
||||
if (mode === this.mode)
|
||||
return;
|
||||
const wasLooking = this._looking();
|
||||
if (wasLooking)
|
||||
this._lookFrozen = this._lookAngle(at);
|
||||
this.mode = mode;
|
||||
const pace = this.pace;
|
||||
if (this._looking() && !wasLooking)
|
||||
this._lookStart = at;
|
||||
|
||||
this._lookAmount.set(this._looking() ? 1 : 0, 350 * pace, Ease.inOutQuad, at);
|
||||
this._smile.set(mode === 'failure' ? 0 : 1, 220 * pace, Ease.outQuad, at);
|
||||
this._bracket.set(mode === 'tracking' ? 0.9 : 1, 260 * pace, OUT_BACK, at);
|
||||
this._bracketColor.set(this._colorFor(mode), 220 * pace, Ease.linear, at);
|
||||
this._shown.set(mode === 'lockout' ? 0 : 1, 180 * pace, Ease.linear, at);
|
||||
this._lockOpacity.set(mode === 'lockout' ? 1 : 0, 200 * pace, Ease.linear, at);
|
||||
this._lockScale.set(mode === 'lockout' ? 1 : 0.7, 260 * pace, OUT_BACK, at);
|
||||
|
||||
this._successStart = null;
|
||||
if (this._settle) {
|
||||
GLib.source_remove(this._settle);
|
||||
this._settle = 0;
|
||||
}
|
||||
if (mode === 'failure') {
|
||||
this._shakeStart = at;
|
||||
this.onSettled?.(false);
|
||||
} else if (mode === 'success') {
|
||||
this._successStart = at;
|
||||
this._settle = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 850 * pace, () => {
|
||||
this._settle = 0;
|
||||
this.onSettled?.(true);
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_colorFor(mode) {
|
||||
if (mode === 'success')
|
||||
return Theme.success;
|
||||
if (mode === 'failure')
|
||||
return Theme.failure;
|
||||
return this.color;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._settle)
|
||||
GLib.source_remove(this._settle);
|
||||
this._settle = 0;
|
||||
}
|
||||
|
||||
busy(at) {
|
||||
return this._looking() || this._lookAmount.busy(at) || this._smile.busy(at) ||
|
||||
this._bracket.busy(at) || this._bracketColor.busy(at) || this._shown.busy(at) ||
|
||||
this._lockOpacity.busy(at) || this._lockScale.busy(at) ||
|
||||
(this._successStart !== null && at - this._successStart < 900 * this.pace) ||
|
||||
sequence(FaceGlyph.SHAKE, this._shakeStart, at, this.pace)[1];
|
||||
}
|
||||
|
||||
draw(cr, x, y, size, at) {
|
||||
const u = size / 100;
|
||||
const lw = this.lineWidth ?? size * 0.055;
|
||||
const pace = this.pace;
|
||||
const color = this._bracketColor.get(at);
|
||||
|
||||
const lookAmount = this._lookAmount.get(at);
|
||||
const angle = this._lookAngle(at);
|
||||
const yaw = 0.34 * Math.cos(angle) * lookAmount;
|
||||
const pitch = 0.2 * Math.sin(angle) * lookAmount;
|
||||
const fx = (px, py, pz) => (50 + px * Math.cos(yaw) + pz * Math.sin(yaw)) * u;
|
||||
const fy = (px, py, pz) => {
|
||||
const depth = pz * Math.cos(yaw) - px * Math.sin(yaw);
|
||||
return (50 + py * Math.cos(pitch) - depth * Math.sin(pitch)) * u;
|
||||
};
|
||||
|
||||
const t = this._successStart === null ? 0 : Math.min(1, (at - this._successStart) / (850 * pace));
|
||||
const tickStart = this._successStart === null ? null : this._successStart + 470 * pace;
|
||||
const tick = tickStart === null || at < tickStart ? 0 : Ease.outQuad(Math.min(1, (at - tickStart) / (260 * pace)));
|
||||
const [shake] = sequence(FaceGlyph.SHAKE, this._shakeStart, at, pace);
|
||||
|
||||
const faceGone = smooth(0, 0.22, t);
|
||||
const ringsIn = smooth(0.04, 0.26, t);
|
||||
const turn = FaceGlyph.turned(Math.min(1, t / 0.7));
|
||||
const ringSize = 40 * (0.7 + 0.3 * ringsIn);
|
||||
const ringA = FaceGlyph.ring(0, -360 * turn, 90 * turn, ringSize);
|
||||
const ringB = FaceGlyph.ring(70 + 360 * turn, 30 + 360 * turn, -90 * turn, ringSize);
|
||||
const ringBShown = 1 - smooth(0.36, 0.58, t);
|
||||
const ringABack = 1 - 0.6 * FaceGlyph.tilt(ringA);
|
||||
const ringBBack = 1 - 0.6 * FaceGlyph.tilt(ringB);
|
||||
const glow = 1 - smooth(0.42, 0.75, t);
|
||||
const onScreen = p => [(50 + p[0]) * u, (50 + p[1]) * u];
|
||||
|
||||
const bracketScale = this._bracket.get(at) - 0.04 * this._breathe(at);
|
||||
const c = 50 * u;
|
||||
|
||||
cr.save();
|
||||
cr.translate(x + shake * u, y);
|
||||
cr.setLineCap(1);
|
||||
cr.setLineJoin(1);
|
||||
|
||||
// Brackets, giving way to the rings
|
||||
layer(cr, 1 - faceGone, 1 - 0.15 * faceGone, c, c, () => {
|
||||
cr.translate(c, c);
|
||||
cr.scale(bracketScale, bracketScale);
|
||||
cr.translate(-c, -c);
|
||||
rgba(cr, color);
|
||||
cr.setLineWidth(lw);
|
||||
const corner = (sx, sy, lx, ly, qx, qy, cx, cy, ex, ey) => {
|
||||
cr.moveTo(sx * u, sy * u);
|
||||
cr.lineTo(lx * u, ly * u);
|
||||
quadTo(cr, lx * u, ly * u, cx * u, cy * u, qx * u, qy * u);
|
||||
cr.lineTo(ex * u, ey * u);
|
||||
};
|
||||
corner(6, 30, 6, 19, 19, 6, 6, 6, 30, 6);
|
||||
corner(70, 6, 81, 6, 94, 19, 94, 6, 94, 30);
|
||||
corner(94, 70, 94, 81, 81, 94, 94, 94, 70, 94);
|
||||
corner(30, 94, 19, 94, 6, 81, 6, 94, 6, 70);
|
||||
cr.stroke();
|
||||
});
|
||||
|
||||
// The rings: a soft glow under them, their far halves dimmed, the
|
||||
// near halves on top.
|
||||
if (t > 0) {
|
||||
layer(cr, ringsIn, 1, c, c, () => {
|
||||
if (glow > 0) {
|
||||
// MultiEffect's blur, drawn as wider and fainter strokes.
|
||||
for (let i = 0; i < 6; ++i) {
|
||||
cr.setLineWidth(lw * 1.6 + i * lw * 0.9);
|
||||
const a = 0.8 * glow * 0.16;
|
||||
rgba(cr, color, a);
|
||||
polyline(cr, [...ringA.map(onScreen), onScreen(ringA[0])]);
|
||||
cr.stroke();
|
||||
rgba(cr, color, a * ringBShown);
|
||||
polyline(cr, [...ringB.map(onScreen), onScreen(ringB[0])]);
|
||||
cr.stroke();
|
||||
}
|
||||
}
|
||||
cr.setLineWidth(lw);
|
||||
const half = (points, front) => FaceGlyph.half(points, front).map(onScreen);
|
||||
rgba(cr, color, ringABack);
|
||||
polyline(cr, half(ringA, false));
|
||||
cr.stroke();
|
||||
rgba(cr, color, ringBBack * ringBShown);
|
||||
polyline(cr, half(ringB, false));
|
||||
cr.stroke();
|
||||
rgba(cr, color, ringBShown);
|
||||
polyline(cr, half(ringB, true));
|
||||
cr.stroke();
|
||||
rgba(cr, color);
|
||||
polyline(cr, half(ringA, true));
|
||||
cr.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
// The face itself
|
||||
layer(cr, this._shown.get(at) * (1 - faceGone), 1 - 0.2 * faceGone, c, c, () => {
|
||||
rgba(cr, color);
|
||||
cr.setLineWidth(lw);
|
||||
const smile = this._smile.get(at);
|
||||
cr.moveTo(fx(-16, -14, 26), fy(-16, -14, 26));
|
||||
cr.lineTo(fx(-16, -5, 26), fy(-16, -5, 26));
|
||||
cr.moveTo(fx(16, -14, 26), fy(16, -14, 26));
|
||||
cr.lineTo(fx(16, -5, 26), fy(16, -5, 26));
|
||||
cr.moveTo(fx(0, -13, 29), fy(0, -13, 29));
|
||||
cr.lineTo(fx(0, 6, 37), fy(0, 6, 37));
|
||||
quadTo(cr, fx(0, 6, 37), fy(0, 6, 37), fx(0, 11, 35), fy(0, 11, 35), fx(-5, 11, 31), fy(-5, 11, 31));
|
||||
cr.moveTo(fx(-15, 20, 21), fy(-15, 20, 21));
|
||||
quadTo(cr, fx(-15, 20, 21), fy(-15, 20, 21), fx(0, 20 + 11 * smile, 24), fy(0, 20 + 11 * smile, 24),
|
||||
fx(15, 20, 21), fy(15, 20, 21));
|
||||
cr.stroke();
|
||||
});
|
||||
|
||||
// The tick, drawn inside the ring as it comes to rest
|
||||
if (tick > 0.001) {
|
||||
layer(cr, Math.min(1, tick * 8), 0.75, c, c, () => {
|
||||
cr.translate(1.5 * u, 0);
|
||||
rgba(cr, Theme.success);
|
||||
cr.setLineWidth(lw / 0.75);
|
||||
const a = [28 * u, 52 * u];
|
||||
const b = [43 * u, 67 * u];
|
||||
const e = [73 * u, 35 * u];
|
||||
const lerp = (p, q, k) => [p[0] + (q[0] - p[0]) * k, p[1] + (q[1] - p[1]) * k];
|
||||
const split = 0.33;
|
||||
polyline(cr, tick <= split ? [a, lerp(a, b, tick / split)]
|
||||
: [a, b, lerp(b, e, (tick - split) / (1 - split))]);
|
||||
cr.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
// A lock instead of the face, after too many tries
|
||||
const lockSize = size * 0.42;
|
||||
layer(cr, this._lockOpacity.get(at), this._lockScale.get(at), c, c, () => {
|
||||
this._lock.draw(cr, c - lockSize / 2, c - lockSize / 2, lockSize, this.color, at);
|
||||
});
|
||||
|
||||
cr.restore();
|
||||
}
|
||||
|
||||
// How far the rings have turned at p: speeding up from rest, fastest a
|
||||
// quarter of the way in, then settling softly, facing forward.
|
||||
static turned(p) {
|
||||
return p * p * (10 - p * (20 - p * (15 - 4 * p)));
|
||||
}
|
||||
|
||||
// A ring turned in 3D (degrees about x, then y, then z).
|
||||
static ring(ax, ay, az, radius) {
|
||||
const d = Math.PI / 180;
|
||||
const ca = Math.cos(ax * d), sa = Math.sin(ax * d);
|
||||
const cb = Math.cos(ay * d), sb = Math.sin(ay * d);
|
||||
const cg = Math.cos(az * d), sg = Math.sin(az * d);
|
||||
const points = [];
|
||||
for (let i = 0; i < 64; ++i) {
|
||||
const phi = 2 * Math.PI * i / 64;
|
||||
let px = radius * Math.cos(phi);
|
||||
let py = radius * Math.sin(phi) * ca;
|
||||
let pz = radius * Math.sin(phi) * sa;
|
||||
const x1 = px * cb + pz * sb;
|
||||
pz = pz * cb - px * sb;
|
||||
px = x1;
|
||||
const x2 = px * cg - py * sg;
|
||||
py = px * sg + py * cg;
|
||||
px = x2;
|
||||
points.push([px, py, pz]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
static tilt(points) {
|
||||
let most = 0;
|
||||
for (const p of points)
|
||||
most = Math.max(most, Math.abs(p[2]));
|
||||
return most / Math.max(1, Math.hypot(points[0][0], points[0][1], points[0][2]));
|
||||
}
|
||||
|
||||
// The half of a ring in front of the middle, or behind it, in one piece
|
||||
// and reaching one point into the other half so the two meet.
|
||||
static half(points, front) {
|
||||
const n = points.length;
|
||||
const inFront = i => points[(i + n) % n][2] >= -1e-6;
|
||||
let start = -1;
|
||||
for (let i = 0; i < n; ++i) {
|
||||
if (inFront(i) === front && inFront(i - 1) !== front) {
|
||||
start = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
if (start < 0) {
|
||||
if (inFront(0) === front) {
|
||||
for (let i = 0; i <= n; ++i)
|
||||
out.push(points[i % n]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
out.push(points[(start + n - 1) % n]);
|
||||
let i = start;
|
||||
while (inFront(i) === front && i < start + n) {
|
||||
out.push(points[i % n]);
|
||||
++i;
|
||||
}
|
||||
out.push(points[i % n]);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
FaceGlyph.SHAKE = [
|
||||
[-9, 55, Ease.outQuad], [8, 70, Ease.inOutQuad], [-6, 65, Ease.inOutQuad],
|
||||
[4, 60, Ease.inOutQuad], [-2, 55, Ease.inOutQuad], [0, 50, Ease.outQuad],
|
||||
];
|
||||
|
||||
const PILL_SHAKE = [
|
||||
[-10, 55, Ease.outQuad], [9, 70, Ease.linear], [-6, 65, Ease.linear],
|
||||
[4, 60, Ease.linear], [0, 55, Ease.outQuad],
|
||||
];
|
||||
|
||||
// Bubble.qml: the island that slides down, opens up, shows the face and
|
||||
// closes again.
|
||||
class Bubble {
|
||||
constructor() {
|
||||
this.actor = new St.DrawingArea({reactive: false, visible: false});
|
||||
this.actor.connect('repaint', area => this._repaint(area));
|
||||
this._timeline = new Clutter.Timeline({actor: this.actor, duration: 1000, repeat_count: -1});
|
||||
this._timeline.connect('new-frame', () => this._frame());
|
||||
|
||||
this.phase = 'hidden';
|
||||
this.message = '';
|
||||
this.faceSeen = false;
|
||||
this.style = 'full';
|
||||
this.pace = 1;
|
||||
this._shownPhase = 'idle';
|
||||
this._positioned = false;
|
||||
this._expanded = false;
|
||||
this._opening = false;
|
||||
this._timers = {};
|
||||
|
||||
this._width = new Tween(Theme.closedWidth);
|
||||
this._height = new Tween(Theme.closedHeight);
|
||||
this._y = new Tween(-Theme.closedHeight - 20);
|
||||
this._shadow = new Tween(0);
|
||||
this._fullOpacity = new Tween(0);
|
||||
this._smallOpacity = new Tween(0);
|
||||
this._glyphY = new Tween(40);
|
||||
this._messageOpacity = new Tween(0);
|
||||
this._pulseStart = null;
|
||||
this._settle = new Tween(0);
|
||||
this._pillShakeStart = null;
|
||||
|
||||
this._face = new FaceGlyph();
|
||||
this._smallFace = new FaceGlyph(2.4);
|
||||
this._smallFace.onSettled = ok => {
|
||||
if (!ok)
|
||||
this._pillShakeStart = now();
|
||||
};
|
||||
this._lock = new LockGlyph();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const id of Object.values(this._timers))
|
||||
GLib.source_remove(id);
|
||||
this._timers = {};
|
||||
this._face.destroy();
|
||||
this._smallFace.destroy();
|
||||
this._timeline.stop();
|
||||
this.actor.destroy();
|
||||
}
|
||||
|
||||
setState(state) {
|
||||
const at = now();
|
||||
const wasOpen = this.phase !== 'hidden';
|
||||
this.phase = state.phase ?? 'hidden';
|
||||
this.message = state.message ?? '';
|
||||
this.faceSeen = !!state.faceSeen;
|
||||
this.style = state.style === 'minimal' ? 'minimal' : 'full';
|
||||
const pace = state.pace ?? 1;
|
||||
if (pace !== this.pace) {
|
||||
this.pace = pace;
|
||||
this._face.pace = this._smallFace.pace = this._lock.pace = pace;
|
||||
}
|
||||
|
||||
if (this.phase !== 'hidden')
|
||||
this._shownPhase = this.phase;
|
||||
const glyphMode = this._shownPhase === 'scanning'
|
||||
? this.faceSeen ? 'tracking' : 'scanning'
|
||||
: this._shownPhase;
|
||||
this._face.setMode(glyphMode, at);
|
||||
this._smallFace.setMode(glyphMode === 'lockout' ? 'failure' : glyphMode, at);
|
||||
this._lock.setOpen(this._shownPhase === 'success', at);
|
||||
|
||||
const hasMessage = this.message.length > 0;
|
||||
this._glyphY.set(hasMessage ? 28 : 40, 220 * this.pace, Ease.outCubic, at);
|
||||
this._messageOpacity.set(hasMessage ? 1 : 0, 200 * this.pace, Ease.linear, at);
|
||||
this._updatePulse(at);
|
||||
|
||||
const wantOpen = this.phase !== 'hidden';
|
||||
if (wantOpen !== wasOpen)
|
||||
this._choreograph(wantOpen, at);
|
||||
this._wake();
|
||||
}
|
||||
|
||||
_timer(name, ms, callback) {
|
||||
if (this._timers[name])
|
||||
GLib.source_remove(this._timers[name]);
|
||||
this._timers[name] = GLib.timeout_add(GLib.PRIORITY_DEFAULT, Math.max(0, Math.round(ms)), () => {
|
||||
delete this._timers[name];
|
||||
callback(now());
|
||||
this._wake();
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
|
||||
_cancel(name) {
|
||||
if (this._timers[name]) {
|
||||
GLib.source_remove(this._timers[name]);
|
||||
delete this._timers[name];
|
||||
}
|
||||
}
|
||||
|
||||
_choreograph(wantOpen, at) {
|
||||
if (wantOpen) {
|
||||
this._opening = true;
|
||||
this._cancel('slideOut');
|
||||
this._cancel('closeDone');
|
||||
this._setPositioned(true, at);
|
||||
if (!this._expanded)
|
||||
this._timer('expand', Theme.expandDelay * this.pace, t => this._setExpanded(true, t));
|
||||
this.actor.show();
|
||||
} else {
|
||||
this._opening = false;
|
||||
this._cancel('expand');
|
||||
this._setExpanded(false, at);
|
||||
this._timer('slideOut', Theme.slideOutDelay * this.pace, t => {
|
||||
this._setPositioned(false, t);
|
||||
this._timer('closeDone', Theme.slideOutDuration * this.pace + 60, () => {
|
||||
this.actor.hide();
|
||||
this._timeline.stop();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_setPositioned(positioned, at) {
|
||||
this._positioned = positioned;
|
||||
this._y.set(positioned ? Theme.topGap : -Theme.closedHeight - 20,
|
||||
(this._opening ? Theme.slideInDuration : Theme.slideOutDuration) * this.pace,
|
||||
this._opening ? Ease.outCubic : Ease.inCubic, at);
|
||||
}
|
||||
|
||||
_setExpanded(expanded, at) {
|
||||
this._expanded = expanded;
|
||||
const minimal = this.style === 'minimal';
|
||||
const w = expanded ? minimal ? Theme.minimalWidth : Theme.openWidth : Theme.closedWidth;
|
||||
const h = expanded ? minimal ? Theme.minimalHeight : Theme.openHeight : Theme.closedHeight;
|
||||
const duration = (this._opening ? Theme.growDuration : Theme.shrinkDuration) * this.pace;
|
||||
// Growing overshoots sideways more than down, so the bottom edge does
|
||||
// not sag.
|
||||
this._width.set(w, duration, this._opening ? Ease.outBack(1.6) : Ease.outCubic, at);
|
||||
this._height.set(h, duration, this._opening ? Ease.outBack(0.9) : Ease.outCubic, at);
|
||||
this._shadow.set(expanded ? 0.35 : 0, (this._opening ? 200 : 120) * this.pace, Ease.linear, at);
|
||||
this._fullOpacity.set(expanded ? 1 : 0, (this._opening ? 260 : 120) * this.pace, Ease.linear, at);
|
||||
this._smallOpacity.set(expanded ? 1 : 0, (this._opening ? 200 : 120) * this.pace, Ease.linear, at);
|
||||
this._updatePulse(at);
|
||||
}
|
||||
|
||||
// The breathing while it scans: 1500 ms a breath, settling when it stops.
|
||||
_updatePulse(at) {
|
||||
const running = this.phase === 'scanning' && this._expanded;
|
||||
if (running && this._pulseStart === null) {
|
||||
this._pulseStart = at;
|
||||
} else if (!running && this._pulseStart !== null) {
|
||||
this._settle.jump(this._pulse(at));
|
||||
this._settle.set(0, 200 * this.pace, Ease.outQuad, at);
|
||||
this._pulseStart = null;
|
||||
}
|
||||
}
|
||||
|
||||
_pulse(at) {
|
||||
if (this._pulseStart === null)
|
||||
return this._settle.get(at);
|
||||
const p = this.pace;
|
||||
let t = (at - this._pulseStart) % (1500 * p);
|
||||
if (t < 600 * p)
|
||||
return 0;
|
||||
t -= 600 * p;
|
||||
if (t < 400 * p)
|
||||
return Ease.inOutQuad(t / (400 * p));
|
||||
t -= 400 * p;
|
||||
if (t < 50 * p)
|
||||
return 1;
|
||||
t -= 50 * p;
|
||||
if (t < 400 * p)
|
||||
return 1 - Ease.inOutQuad(t / (400 * p));
|
||||
return 0;
|
||||
}
|
||||
|
||||
_wake() {
|
||||
if (!this.actor.visible)
|
||||
return;
|
||||
if (!this._timeline.is_playing())
|
||||
this._timeline.start();
|
||||
this.actor.queue_repaint();
|
||||
}
|
||||
|
||||
_frame() {
|
||||
const at = now();
|
||||
this.actor.queue_repaint();
|
||||
const busy = this.phase !== 'hidden' || Object.keys(this._timers).length > 0 ||
|
||||
this._width.busy(at) || this._y.busy(at) || this._face.busy(at) || this._smallFace.busy(at);
|
||||
if (!busy)
|
||||
this._timeline.stop();
|
||||
}
|
||||
|
||||
_repaint(area) {
|
||||
const cr = area.get_context();
|
||||
const scale = St.ThemeContext.get_for_stage(global.stage).scale_factor;
|
||||
const at = now();
|
||||
cr.save();
|
||||
cr.setOperator(0 /* clear */);
|
||||
cr.paint();
|
||||
cr.restore();
|
||||
cr.scale(scale, scale);
|
||||
try {
|
||||
this._draw(cr, at);
|
||||
} catch (e) {
|
||||
logError(e, 'face-unlock bubble');
|
||||
}
|
||||
cr.$dispose();
|
||||
}
|
||||
|
||||
_draw(cr, at) {
|
||||
const minimal = this.style === 'minimal';
|
||||
const w = this._width.get(at);
|
||||
const h = this._height.get(at);
|
||||
const [pillShake] = sequence(PILL_SHAKE, this._pillShakeStart, at, this.pace);
|
||||
const x = (WIDTH - w) / 2 + pillShake;
|
||||
const y = this._y.get(at);
|
||||
const radius = minimal ? h / 2 : Math.min(Theme.openRadius, h / 2);
|
||||
|
||||
// The shadow: MultiEffect's soft one, as rings that fade outwards.
|
||||
const shadow = this._shadow.get(at);
|
||||
if (shadow > 0.001) {
|
||||
for (let i = 8; i >= 1; --i) {
|
||||
const grow = i * 2;
|
||||
rgba(cr, [0, 0, 0], shadow * 0.18 * (1 - i / 9));
|
||||
roundedRect(cr, x - grow, y + 3 - grow, w + 2 * grow, h + 2 * grow, radius + grow);
|
||||
cr.fill();
|
||||
}
|
||||
}
|
||||
|
||||
rgba(cr, Theme.panel);
|
||||
roundedRect(cr, x, y, w, h, radius);
|
||||
cr.fill();
|
||||
|
||||
const pulse = this._pulse(at);
|
||||
if (!minimal) {
|
||||
// The face and a line of text, laid out at the open size and
|
||||
// scaled with the island.
|
||||
const fit = Math.min(w / Theme.openWidth, h / Theme.openHeight);
|
||||
const ox = x + w / 2 - Theme.openWidth / 2;
|
||||
const oy = y + h / 2 - Theme.openHeight / 2;
|
||||
const cx = ox + Theme.openWidth / 2;
|
||||
const cy = oy + Theme.openHeight / 2;
|
||||
layer(cr, this._fullOpacity.get(at), fit, cx, cy, () => {
|
||||
layer(cr, 1 - 0.35 * pulse, 1 - 0.03 * pulse, cx, cy, () => {
|
||||
this._face.draw(cr, ox + 40, oy + this._glyphY.get(at), 100, at);
|
||||
this._drawMessage(cr, ox, oy, at);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
layer(cr, this._smallOpacity.get(at), 1, x + w / 2, y + h / 2, () => {
|
||||
const failed = this._shownPhase === 'failure' || this._shownPhase === 'lockout';
|
||||
this._lock.draw(cr, x + 16, y + (h - 18) / 2, 18, failed ? Theme.failure : Theme.textPrimary, at);
|
||||
const fx = x + w - 14 - 24;
|
||||
const fy = y + (h - 24) / 2;
|
||||
layer(cr, 1 - 0.35 * pulse, 1 - 0.03 * pulse, fx + 12, fy + 12, () => {
|
||||
this._smallFace.draw(cr, fx, fy, 24, at);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_drawMessage(cr, ox, oy, at) {
|
||||
const opacity = this._messageOpacity.get(at);
|
||||
if (opacity <= 0.001 || !this.message)
|
||||
return;
|
||||
const layout = PangoCairo.create_layout(cr);
|
||||
const font = Pango.FontDescription.from_string(Bubble._fontName());
|
||||
font.set_absolute_size(13 * Pango.SCALE);
|
||||
font.set_weight(Pango.Weight.MEDIUM);
|
||||
layout.set_font_description(font);
|
||||
layout.set_width((Theme.openWidth - 32) * Pango.SCALE);
|
||||
layout.set_ellipsize(Pango.EllipsizeMode.END);
|
||||
layout.set_alignment(Pango.Alignment.CENTER);
|
||||
layout.set_text(this.message, -1);
|
||||
const [, logical] = layout.get_pixel_extents();
|
||||
const failed = this._shownPhase === 'failure' || this._shownPhase === 'lockout';
|
||||
rgba(cr, failed ? Theme.textDetail : Theme.textSecondary, opacity);
|
||||
cr.moveTo(ox + 16, oy + Theme.openHeight - 20 - logical.height);
|
||||
PangoCairo.show_layout(cr, layout);
|
||||
}
|
||||
|
||||
static _fontName() {
|
||||
if (!Bubble._font) {
|
||||
const name = new Gio.Settings({schema_id: 'org.gnome.desktop.interface'}).get_string('font-name');
|
||||
Bubble._font = name.replace(/\s+[\d.]+$/, '');
|
||||
}
|
||||
return Bubble._font;
|
||||
}
|
||||
}
|
||||
|
||||
export default class FaceUnlockExtension extends Extension {
|
||||
enable() {
|
||||
this._bubble = new Bubble();
|
||||
const ui = Main.layoutManager.uiGroup;
|
||||
ui.add_child(this._bubble.actor);
|
||||
// Above everything, the lock screen and its dialogs included.
|
||||
this._raise = ui.connect('child-added', () => ui.set_child_above_sibling(this._bubble.actor, null));
|
||||
this._monitors = Main.layoutManager.connect('monitors-changed', () => this._place());
|
||||
this._scale = St.ThemeContext.get_for_stage(global.stage).connect('notify::scale-factor', () => this._place());
|
||||
this._place();
|
||||
|
||||
this._watch = Gio.bus_watch_name(Gio.BusType.SESSION, BUS_NAME, Gio.BusNameWatcherFlags.NONE,
|
||||
() => this._connect(), () => this._disconnect());
|
||||
}
|
||||
|
||||
disable() {
|
||||
Gio.bus_unwatch_name(this._watch);
|
||||
this._disconnect();
|
||||
Main.layoutManager.uiGroup.disconnect(this._raise);
|
||||
Main.layoutManager.disconnect(this._monitors);
|
||||
St.ThemeContext.get_for_stage(global.stage).disconnect(this._scale);
|
||||
this._bubble.destroy();
|
||||
this._bubble = null;
|
||||
}
|
||||
|
||||
_place() {
|
||||
const monitor = Main.layoutManager.primaryMonitor;
|
||||
if (!monitor)
|
||||
return;
|
||||
const scale = St.ThemeContext.get_for_stage(global.stage).scale_factor;
|
||||
this._bubble.actor.set_size(WIDTH * scale, HEIGHT * scale);
|
||||
this._bubble.actor.set_position(monitor.x + Math.round((monitor.width - WIDTH * scale) / 2), monitor.y);
|
||||
Main.layoutManager.uiGroup.set_child_above_sibling(this._bubble.actor, null);
|
||||
}
|
||||
|
||||
_connect() {
|
||||
this._disconnect();
|
||||
const unpack = state => {
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(state))
|
||||
out[key] = value instanceof GLib.Variant ? value.recursiveUnpack() : value;
|
||||
return out;
|
||||
};
|
||||
this._proxy = new BubbleProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH, (proxy, error) => {
|
||||
if (error) {
|
||||
logError(error, 'face-unlock');
|
||||
return;
|
||||
}
|
||||
this._signal = proxy.connectSignal('StateChanged', (p, sender, [state]) => {
|
||||
this._place();
|
||||
this._bubble?.setState(unpack(state));
|
||||
});
|
||||
proxy.GetStateRemote(([state], getError) => {
|
||||
if (!getError)
|
||||
this._bubble?.setState(unpack(state));
|
||||
});
|
||||
}, null, Gio.DBusProxyFlags.DO_NOT_AUTO_START);
|
||||
}
|
||||
|
||||
_disconnect() {
|
||||
if (this._proxy && this._signal)
|
||||
this._proxy.disconnectSignal(this._signal);
|
||||
this._proxy = null;
|
||||
this._signal = null;
|
||||
this._bubble?.setState({phase: 'hidden'});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user