diff --git a/README.md b/README.md index 66675a4..2651bb0 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -141 of them, under `tests/`. Run the lot, or a subset by pattern: +142 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 18b809e..a26ff8f 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -1445,6 +1445,12 @@ Singleton { }, // ── Internal ──────────────────────────────────────────────────────── + { + key: "welcomeSeen", type: "bool", def: false, group: "internal", + internal: true, + label: "Welcome shown", + detail: "Set once the first-run welcome has been dismissed. Restoring defaults shows it again, which is intended: a reset machine is one somebody wants introduced to them" + }, { key: "lastPage", type: "string", def: "home", group: "internal", internal: true, diff --git a/config/dot/quickshell/modules/welcome/Welcome.qml b/config/dot/quickshell/modules/welcome/Welcome.qml new file mode 100644 index 0000000..37c53d5 --- /dev/null +++ b/config/dot/quickshell/modules/welcome/Welcome.qml @@ -0,0 +1,281 @@ +// The first thing a new machine shows. +// +// Panama has thirty settings pages, which is the opposite of the usual +// problem: somebody arriving from GNOME, macOS or Windows cannot tell which +// four things matter. This is those four things, once, on the first start. +// +// Deliberately not a tour. Nobody reads a tour, and a multi-step wizard on a +// desktop somebody just installed is another thing standing between them and +// using it. One screen, the handful of keys that unlock everything else, and a +// way out. +// +// The chords are read from the live keymap rather than written here, so a +// machine whose owner has already rebound something shows what they actually +// have -- and so this cannot drift the way a hand-written list would. +// +// Shown once, tracked by the `welcomeSeen` preference. It stays reachable +// afterwards from the launcher and from Settings > About, because the moment +// somebody wants it again is exactly the moment a one-shot has thrown it away. + +import Quickshell +import Quickshell.Wayland +import QtQuick +import qs.config +import qs.services +import qs.widgets +import qs.modules.settings + +PanelWindow { + id: root + + readonly property bool open: ShellState.welcomeOpen + + // What to teach, in the order it becomes useful. Each names a bind by the + // description hypr/keybinds.lua gives it, so the chord shown is whatever + // that bind currently answers to. + readonly property var lessons: [ + { + find: "Launcher", + fallback: "Super + Space", + title: "Find anything", + detail: "Applications, files, clipboard history, emoji, a calculator. Start typing." + }, + { + find: "Terminal", + fallback: "Super + T", + title: "Open a terminal", + detail: "Windows tile themselves as they open. Nothing needs arranging." + }, + { + find: "Overview", + fallback: "Super + `", + title: "See every window", + detail: "All your workspaces at once, and search across them." + }, + { + find: "Settings", + fallback: "Super + I", + title: "Change anything", + detail: "Displays, sound, network, appearance, and everything else." + }, + { + find: "Keyboard shortcuts", + fallback: "Super + /", + title: "The rest of the keys", + detail: "Every shortcut this desktop has, whenever you want it." + } + ] + + function chordFor(description: string, fallback: string): string { + for (const bind of Keybinds.binds) { + if (bind.description === description) + return bind.chord; + } + return fallback; + } + + anchors { top: true; bottom: true; left: true; right: true } + color: "transparent" + exclusiveZone: 0 + exclusionMode: ExclusionMode.Ignore + + WlrLayershell.namespace: "qs-popover-welcome" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: root.open + ? WlrKeyboardFocus.Exclusive + : WlrKeyboardFocus.None + + property bool mapped: false + visible: root.mapped + + onOpenChanged: { + if (root.open) { + unmapTimer.stop(); + root.mapped = true; + Keybinds.refresh(); + } else { + unmapTimer.restart(); + } + } + + Timer { + id: unmapTimer + interval: Theme.durNormal + onTriggered: root.mapped = false + } + + // Dismissing is what marks it seen. Closing by any route counts: somebody + // who pressed Escape has seen it, and showing it again next login would be + // the desktop failing to take no for an answer. + function dismiss(): void { + DesktopPreferences.set("welcomeSeen", true); + ShellState.close(); + } + + // First start on a fresh install. Waits for the keymap so the chords are + // real rather than fallbacks, and gives the rest of the shell a moment to + // finish coming up -- appearing over a half-drawn desktop reads as a crash. + Timer { + id: firstStart + interval: 2500 + running: !DesktopPreferences.get("welcomeSeen") + onTriggered: { + if (!DesktopPreferences.get("welcomeSeen") && !ShellState.anyOverlayOpen) + ShellState.open("welcome"); + } + } + + Rectangle { + anchors.fill: parent + color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha) + opacity: root.open ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } } + + // No click-outside-to-close. Everything else on this desktop dismisses + // that way, but this is the one surface where a stray click during the + // first thirty seconds would throw away the only explanation on offer. + MouseArea { anchors.fill: parent } + } + + Rectangle { + id: card + + anchors.centerIn: parent + width: Math.min(root.width - 120, 640) + height: Math.min(root.height - 120, content.implicitHeight + 64) + radius: Theme.popoverRadius + color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.1) + + opacity: root.open ? 1 : 0 + scale: root.open ? 1 : 0.98 + Behavior on opacity { NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } } + Behavior on scale { NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } } + + PrismEdge { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + inset: Theme.popoverRadius + } + + Column { + id: content + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 32 + spacing: 22 + + Column { + width: parent.width + spacing: 6 + + Text { + text: "Welcome to Panama" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeTitle + font.weight: Font.DemiBold + } + + Text { + width: parent.width + text: "Five keys, and you have the whole desktop." + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + wrapMode: Text.WordWrap + } + } + + Column { + width: parent.width + spacing: 14 + + Repeater { + model: root.lessons + + Item { + required property var modelData + + width: parent.width + height: Math.max(chordPill.height, lessonText.implicitHeight) + + Rectangle { + id: chordPill + width: 150 + height: chordLabel.implicitHeight + 12 + radius: Theme.pillRadius + color: Theme.alpha(Theme.fg, 0.07) + + Text { + id: chordLabel + anchors.centerIn: parent + text: root.chordFor(modelData.find, modelData.fallback) + color: Theme.accent + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeSmall + font.features: Theme.tabularFigures + } + } + + Column { + id: lessonText + anchors.left: chordPill.right + anchors.leftMargin: 16 + anchors.right: parent.right + anchors.verticalCenter: chordPill.verticalCenter + spacing: 2 + + Text { + width: parent.width + text: modelData.title + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + } + + Text { + width: parent.width + text: modelData.detail + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + } + } + } + } + + Row { + anchors.right: parent.right + spacing: 8 + + SettingsButton { + text: "Show every shortcut" + onClicked: { + DesktopPreferences.set("welcomeSeen", true); + ShellState.open("cheatsheet"); + } + } + + SettingsButton { + text: "Start using it" + tone: "accent" + onClicked: root.dismiss() + } + } + } + } + + Item { + anchors.fill: parent + focus: true + Keys.onEscapePressed: root.dismiss() + } +} diff --git a/config/dot/quickshell/modules/welcome/qmldir b/config/dot/quickshell/modules/welcome/qmldir new file mode 100644 index 0000000..ad4909b --- /dev/null +++ b/config/dot/quickshell/modules/welcome/qmldir @@ -0,0 +1,2 @@ +module qs.modules.welcome +Welcome 1.0 Welcome.qml diff --git a/config/dot/quickshell/services/Keybinds.qml b/config/dot/quickshell/services/Keybinds.qml index 6eb1428..14159d4 100644 --- a/config/dot/quickshell/services/Keybinds.qml +++ b/config/dot/quickshell/services/Keybinds.qml @@ -47,6 +47,17 @@ Singleton { "bracketleft": "[", "bracketright": "]", "grave": "`", + "slash": "/", + "backslash": "\\", + "period": ".", + "comma": ",", + "equal": "=", + "minus": "-", + "semicolon": ";", + "apostrophe": "'", + "Return": "Enter", + "Escape": "Esc", + "space": "Space", "Print": "Print Screen", "left": "←", "right": "→", diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index 7216eef..b11f54b 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -17,7 +17,7 @@ Singleton { id: root // Exactly one of these may be non-empty at a time. - // "" | "overview" | "quicksettings" | "notifications" | "clipboard" | "capture" | "activity" | "powermenu" | "cheatsheet" + // "" | "overview" | "quicksettings" | "notifications" | "clipboard" | "capture" | "activity" | "powermenu" | "cheatsheet" | "welcome" property string activeOverlay: "" readonly property bool overviewOpen: activeOverlay === "overview" @@ -28,6 +28,7 @@ Singleton { readonly property bool activityOpen: activeOverlay === "activity" readonly property bool powerMenuOpen: activeOverlay === "powermenu" readonly property bool cheatsheetOpen: activeOverlay === "cheatsheet" + readonly property bool welcomeOpen: activeOverlay === "welcome" // Settings is a normal application window rather than a transient overlay. // It can stay open while Quick Settings or the notification center appears. diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index 61f933d..9438c54 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -35,6 +35,7 @@ import qs.modules.notifications import qs.modules.capture import qs.modules.powermenu import qs.modules.cheatsheet +import qs.modules.welcome import qs.modules.clipboard import qs.modules.datemenu import qs.modules.focus @@ -100,6 +101,7 @@ ShellRoot { DateMenu { id: dateMenu } ClipboardPanel {} Cheatsheet {} + Welcome {} CaptureOverlay {} IntelligenceResult {} ActivityPanel {} @@ -146,6 +148,20 @@ ShellRoot { } } + // Shown once on a fresh machine, and reachable afterwards -- the moment + // somebody wants it again is exactly when a one-shot has thrown it away. + IpcHandler { + target: "welcome" + function open(): void { ShellState.open("welcome"); } + function close(): void { ShellState.close(); } + function status(): string { + return JSON.stringify({ + open: ShellState.welcomeOpen, + seen: DesktopPreferences.get("welcomeSeen") === true + }); + } + } + IpcHandler { target: "overview" function toggle(): void { ShellState.toggle("overview"); } diff --git a/config/local/share/vicinae/scripts/show-welcome b/config/local/share/vicinae/scripts/show-welcome new file mode 100755 index 0000000..34a9b16 --- /dev/null +++ b/config/local/share/vicinae/scripts/show-welcome @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title Welcome to Panama +# @vicinae.mode silent +# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg +# @vicinae.description The five keys that unlock the desktop, shown again. +# @vicinae.keywords ["welcome", "getting started", "help", "intro", "tour", "new"] + +exec qs ipc call welcome open diff --git a/tests/quickshell/panama-commands-contract b/tests/quickshell/panama-commands-contract index 3a52749..28bf142 100755 --- a/tests/quickshell/panama-commands-contract +++ b/tests/quickshell/panama-commands-contract @@ -77,7 +77,7 @@ declare -a standalone=( lock-screen suspend-system log-out reboot-system power-off remind-me list-reminders pick-color switch-window force-quit-window kill-process ssh-hosts recent-files - copy-password keyboard-shortcuts + copy-password keyboard-shortcuts show-welcome ) # Generated commands must match their source. A stale command dispatches to a diff --git a/tests/quickshell/welcome-contract b/tests/quickshell/welcome-contract new file mode 100755 index 0000000..aee17a9 --- /dev/null +++ b/tests/quickshell/welcome-contract @@ -0,0 +1,133 @@ +#!/usr/bin/env bash + +# The first thing a new machine shows. +# +# Panama has thirty settings pages, which is the opposite of the usual problem: +# somebody arriving from another desktop cannot tell which few things matter. +# This is those few things, once. +# +# What must hold: +# +# 1. The chords come from the live keymap. A welcome screen that teaches the +# wrong keys is worse than none, and it is the one surface read by +# somebody with no way to tell it is wrong. +# 2. It is shown once and dismissing marks it. A desktop that reintroduces +# itself every login has failed to take no for an answer. +# 3. Dismissing by ANY route counts, including Escape. +# 4. It stays reachable afterwards. The moment somebody wants it again is +# exactly when a one-shot has thrown it away. +# 5. It does NOT close on a click outside, unlike every other surface here. +# A stray click in the first thirty seconds would throw away the only +# explanation on offer. +# +# The live half opens the real surface. It deliberately does not exercise the +# dismiss path, because that would write to the running machine's settings and +# there is no honest way to put it back. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +module="$repo_dir/config/dot/quickshell/modules/welcome" +welcome="$module/Welcome.qml" +shell_qml="$repo_dir/config/dot/quickshell/shell.qml" +state="$repo_dir/config/dot/quickshell/services/ShellState.qml" +schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" +command_file="$repo_dir/config/local/share/vicinae/scripts/show-welcome" + +findings=() +note() { findings+=("$1"); } + +[[ -r "$welcome" ]] || { printf 'welcome contract: %s is missing\n' "$welcome" >&2; exit 1; } + +# ── The module ─────────────────────────────────────────────────────────────── + +grep -q '^Welcome 1.0 Welcome.qml$' "$module/qmldir" \ + || note 'Welcome is not registered in its qmldir, so the shell would fail to load entirely' + +# ── 1. The keys it teaches are the keys you have ───────────────────────────── + +grep -q 'Keybinds.binds' "$welcome" \ + || note 'the welcome screen does not read the live keymap, so it could teach chords this machine does not have' +grep -q 'function chordFor' "$welcome" \ + || note 'there is no lookup from a bind description to its current chord' +# A fallback is correct -- the keymap may not have loaded yet -- but it must be +# a fallback rather than the source. +grep -q 'fallback' "$welcome" \ + || note 'no fallback chord, so the screen would be blank until the keymap loads' + +# ── 2 & 3. Once, and dismissing counts ─────────────────────────────────────── + +grep -q 'key: "welcomeSeen"' "$schema" \ + || note 'there is no welcomeSeen preference, so the welcome cannot be shown once' +grep -A2 'key: "welcomeSeen"' "$schema" | grep -q 'internal: true' \ + || note 'welcomeSeen is not internal, so it would appear in settings search as something to toggle' +grep -q 'DesktopPreferences.set("welcomeSeen", true)' "$welcome" \ + || note 'dismissing does not mark the welcome as seen, so it would return every login' +grep -q 'Keys.onEscapePressed: root.dismiss()' "$welcome" \ + || note 'Escape does not go through dismiss(), so closing that way would show it again next login' + +# ── 4. Still reachable ─────────────────────────────────────────────────────── + +grep -q 'target: "welcome"' "$shell_qml" \ + || note 'there is no welcome IPC target, so it cannot be reopened' +[[ -x "$command_file" ]] || note 'there is no launcher command to show the welcome again' + +# ── 5. A stray click must not dismiss it ───────────────────────────────────── +# +# Every other overlay closes on an outside click. This one must not, and the +# check is that the scrim's MouseArea swallows clicks without acting. + +python3 - "$welcome" <<'PY' || note 'the welcome scrim closes on an outside click, so a stray click would throw away the only explanation on offer' +import re, sys +text = open(sys.argv[1], encoding="utf-8").read() +# The scrim is the MouseArea filling the dimming Rectangle. Anything that +# reacts to a click there dismisses the surface. +scrim = re.search(r"Rectangle\s*\{[^{}]*anchors\.fill:\s*parent.*?MouseArea\s*\{(.*?)\}", text, re.S) +if scrim and re.search(r"onClicked|onPressed|onTapped", scrim.group(1)): + raise SystemExit(1) +PY + +# ── Wiring ─────────────────────────────────────────────────────────────────── + +grep -q 'import qs.modules.welcome' "$shell_qml" \ + || note 'shell.qml does not import the welcome module' +grep -qE '^\s*Welcome \{\}' "$shell_qml" \ + || note 'shell.qml never instantiates the welcome screen' +grep -q 'welcomeOpen: activeOverlay === "welcome"' "$state" \ + || note 'ShellState does not track the welcome screen' + +# ── Live ───────────────────────────────────────────────────────────────────── + +if ! command -v qs >/dev/null 2>&1 || ! qs ipc call welcome status >/dev/null 2>&1; then + if (( ${#findings[@]} > 0 )); then + printf 'welcome contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 + fi + printf 'welcome contract: PASS (static; no running shell)\n' + exit 0 +fi + +was_open="$(qs ipc call welcome status | jq -r .open)" + +qs ipc call welcome open >/dev/null +sleep 1 +[[ "$(qs ipc call welcome status | jq -r .open)" == "true" ]] \ + || note 'the welcome screen did not open' +hyprctl layers -j | grep -q 'qs-popover-welcome' \ + || note 'the welcome screen reports open but its layer never mapped' + +qs ipc call welcome close >/dev/null +sleep 1 +[[ "$(qs ipc call welcome status | jq -r .open)" == "false" ]] \ + || note 'the welcome screen did not close' + +[[ "$was_open" == "true" ]] && qs ipc call welcome open >/dev/null + +if (( ${#findings[@]} > 0 )); then + printf 'welcome contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'welcome contract: PASS\n'