diff --git a/README.md b/README.md index 6cdf779..a22cb90 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ changes at midnight and at no other moment. `typikon` first, since all three front ends shell out to it: ```console -$ pip install typikon +$ pipx install git+https://git.pumpin.online/funkpower/typikon $ typikon --set-default rocor # or antiochian, oca, greece, ... ``` @@ -40,14 +40,19 @@ $ quickshell -p ~/.config/quickshell/horologion/shell.qml It sits on the background layer, so it behaves like part of the wallpaper — always under your windows, never stealing a click. -To drop it into a shell you already have: +To drop it into a shell you already have, link this directory in beside your +own and import it by path: ```qml -import qs.horologion.modules +import "horologion/modules" Horologion {} ``` +The imports inside are relative, so the widget always resolves its own +`Config` and `Theme` rather than silently binding to yours and inheriting +whatever they happen to mean. + Three pieces, each usable on its own: | | | @@ -59,11 +64,16 @@ Three pieces, each usable on its own: Set a jurisdiction per-widget if you want one different from your default: ```qml +import "horologion/services" // for Typikon + Horologion { Component.onCompleted: Typikon.jurisdiction = "moscow" } ``` +`Typikon.forDate` takes an ISO date and shows a different day, which is how +you look at what Great Week will ask of you before it arrives. + ### Colours `Theme.qml` reads `~/.config/horologion/colors.json`, or whatever diff --git a/bin/horologion-install b/bin/horologion-install index 5b9a5fc..2e93f80 100755 --- a/bin/horologion-install +++ b/bin/horologion-install @@ -20,12 +20,17 @@ horologion-install [quickshell|waybar|eww|bin|all] USAGE } +# Set when something was skipped, so the run can report it in its exit status +# without `set -e` tearing the rest of the install down. +skipped=0 + link() { local from="$1" to="$2" mkdir -p "$(dirname "$to")" if [ -e "$to" ] && [ ! -L "$to" ]; then echo " ! $to exists and is not a link — leaving it alone" >&2 - return 1 + skipped=1 + return 0 fi ln -sfn "$from" "$to" echo " $to -> $from" @@ -74,13 +79,19 @@ do_eww() { NOTE } -command -v typikon >/dev/null 2>&1 || { +require_typikon() { + command -v typikon >/dev/null 2>&1 && return 0 echo "horologion: typikon is not on PATH." >&2 - echo " pip install typikon" >&2 - echo " https://git.pumpin.online/funkpower/typikon" >&2 + echo " pipx install git+https://git.pumpin.online/funkpower/typikon" >&2 exit 1 } +# --help must not be gated behind the dependency it tells you about. +case "${1:-all}" in + -h|--help|help) usage; exit 0 ;; +esac +require_typikon + case "${1:-all}" in quickshell) do_bin; do_quickshell ;; waybar) do_bin; do_waybar ;; @@ -88,10 +99,14 @@ case "${1:-all}" in bin) do_bin ;; all) do_bin - [ -d "$config/quickshell" ] && do_quickshell - [ -d "$config/waybar" ] && do_waybar - [ -d "$config/eww" ] && do_eww + # `[ -d x ] && f` as the last statement of the script would make a + # perfectly good run exit 1 under `set -e` whenever the directory is + # absent. Hence the `|| true`, and the explicit exit below. + [ -d "$config/quickshell" ] && do_quickshell || true + [ -d "$config/waybar" ] && do_waybar || true + [ -d "$config/eww" ] && do_eww || true ;; - -h|--help|help) usage ;; *) usage; exit 2 ;; esac + +exit "$skipped" diff --git a/bin/horologion-waybar b/bin/horologion-waybar index 7f5b454..45820b1 100755 --- a/bin/horologion-waybar +++ b/bin/horologion-waybar @@ -14,6 +14,8 @@ particular law actually binds. """ import argparse +import collections +import html import json import subprocess import sys @@ -41,26 +43,42 @@ def main(): try: days = run(args.jurisdiction, args.days) except FileNotFoundError: + # Exit 0 on purpose, here and below: waybar hides a custom module whose + # script exits non-zero, so a non-zero exit would make the module + # vanish instead of showing why it could not answer. print(json.dumps({"text": "typikon?", "tooltip": "typikon is not on PATH", "class": "error"})) - return 1 + return 0 except (subprocess.CalledProcessError, ValueError) as exc: print(json.dumps({"text": "⚠", "tooltip": str(exc), "class": "error"})) - return 1 + return 0 today = days[0] - fields = dict(today) + # `or ""` would blank a legitimate 0, so only None and missing become "". + fields = {k: ("" if v is None else v) for k, v in today.items()} if args.no_emoji: fields["emoji"] = "" - text = args.format.format(**{k: fields.get(k) or "" for k in fields}).strip() + try: + # format_map over a defaultdict: a field name that does not exist comes + # out empty rather than raising, because the --help text advertises + # eight names and someone will always try a ninth. + text = args.format.format_map(collections.defaultdict(str, fields)).strip() + except (IndexError, ValueError) as exc: + print(json.dumps({"text": "\u26a0", "class": "error", + "tooltip": f"bad --format: {exc}"})) + return 0 tip = [] head = today["title"] if today.get("churchDate") and today["churchDate"] not in head: head += f" ({today['churchDate']})" - tip.append(f"{head}") + # waybar renders the tooltip as Pango markup, so anything interpolated into + # it has to be escaped. Nothing in the shipped data contains an ampersand, + # but `typikon --update-menaion` fetches commemorations from a third-party + # page, and that is exactly where one arrives. + tip.append(f"{html.escape(head)}") if today.get("cycle"): - tip.append(f"{today['cycle']}") + tip.append(f"{html.escape(today['cycle'])}") tip.append("") line = today["fastLabel"] if today.get("obligation") == "obligatory": diff --git a/docs/great-week.png b/docs/great-week.png index 10506c0..c8d93b7 100644 Binary files a/docs/great-week.png and b/docs/great-week.png differ diff --git a/docs/nativity-fast.png b/docs/nativity-fast.png index 8cd9f2b..ffa138f 100644 Binary files a/docs/nativity-fast.png and b/docs/nativity-fast.png differ diff --git a/docs/old-calendar.png b/docs/old-calendar.png index 59bc60d..3ff7ed7 100644 Binary files a/docs/old-calendar.png and b/docs/old-calendar.png differ diff --git a/eww/horologion.yuck b/eww/horologion.yuck index 26d4ace..8cf7673 100644 --- a/eww/horologion.yuck +++ b/eww/horologion.yuck @@ -5,8 +5,9 @@ ;; (include "horologion/horologion.yuck") ;; ;; and put (horologion) in a window, or (horologion-bar) in a bar. Set a -;; jurisdiction by editing the two defpoll commands below; leaving them as -;; they are follows whatever `typikon --set-default` wrote. +;; jurisdiction by adding `--jurisdiction ` to both defpoll commands +;; below; leaving them as they are follows whatever `typikon --set-default` +;; wrote. (defpoll typikon-day :interval "30m" @@ -16,7 +17,9 @@ (defpoll typikon-week :interval "30m" :initial "{\"days\":[]}" - `typikon --json --days 7`) + ;; From tomorrow, not today: the head block above already shows today in + ;; full, and simplexpr has no way to drop the first element of a list. + `typikon --json --days 6 --date $(date -d tomorrow +%F)`) ;; ── the one-line version, for a bar (defwidget horologion-bar [] @@ -38,7 +41,7 @@ (box :class "head" :space-evenly false :spacing 11 (label :class "glyph-big" :text {typikon-day.emoji ?: ""}) (box :orientation "v" :space-evenly false :halign "start" :hexpand true - (label :class {typikon-day.feast != "" ? "title feast" : "title"} + (label :class {(typikon-day.feast ?: "") != "" ? "title feast" : "title"} :halign "start" :wrap true :lines 2 @@ -47,7 +50,7 @@ ? "sub obligatory" : "sub"} :halign "start" :text "${typikon-day.weekday ?: ""}${ - typikon-day.season != "" ? " · " : ""}${ + (typikon-day.season ?: "") != "" ? " · " : ""}${ typikon-day.season ?: ""}${ typikon-day.obligation == "obligatory" ? " · obligatory" : ""}") @@ -70,7 +73,7 @@ (box :space-evenly false :spacing 9 (label :class "glyph" :text {d.emoji}) (label :class "weekday" :width 34 :xalign 0 :text {d.weekday}) - (label :class {d.feast != "" ? "day feast" : "day"} + (label :class {(d.feast ?: "") != "" ? "day feast" : "day"} :hexpand true :xalign 0 :limit-width 40 :text {d.title})))) (box :class "rule") diff --git a/quickshell/modules/Horologion.qml b/quickshell/modules/Horologion.qml index 7053645..ba1d182 100644 --- a/quickshell/modules/Horologion.qml +++ b/quickshell/modules/Horologion.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Layouts -import qs -import qs.services +import ".." +import "../services" Panel { id: root @@ -198,7 +198,8 @@ Panel { } Text { - text: Typikon.pascha + // Labelled, because a bare ISO date in a corner is a riddle. + text: Typikon.pascha !== "" ? "Pascha " + Typikon.pascha : "" color: Theme.fgDim font.family: Config.font font.pixelSize: Config.fontSize - 4 diff --git a/quickshell/modules/Panel.qml b/quickshell/modules/Panel.qml index d3746a0..874cde4 100644 --- a/quickshell/modules/Panel.qml +++ b/quickshell/modules/Panel.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Layouts -import qs -import qs.services +import ".." +import "../services" // Chrome for the widget. Slightly translucent so a wallpaper reads through it, // opaque enough to stay legible over a busy picture. diff --git a/quickshell/modules/qmldir b/quickshell/modules/qmldir new file mode 100644 index 0000000..c8c99cb --- /dev/null +++ b/quickshell/modules/qmldir @@ -0,0 +1,3 @@ +module horologion.modules +Panel 1.0 Panel.qml +Horologion 1.0 Horologion.qml diff --git a/quickshell/qmldir b/quickshell/qmldir new file mode 100644 index 0000000..abde209 --- /dev/null +++ b/quickshell/qmldir @@ -0,0 +1,2 @@ +module horologion +singleton Config 1.0 Config.qml diff --git a/quickshell/services/Typikon.qml b/quickshell/services/Typikon.qml index 8b451c7..47dbce2 100644 --- a/quickshell/services/Typikon.qml +++ b/quickshell/services/Typikon.qml @@ -46,7 +46,15 @@ Singleton { readonly property bool obligatory: obligation === "obligatory" - function refresh() { if (!proc.running) proc.running = true; } + // A request made while a run is in flight is queued, not dropped. Dropping + // it meant a jurisdiction or date set a moment after startup never took + // effect at all -- the answer stayed yesterday's until midnight. + property bool _queued: false + + function refresh() { + if (proc.running) { root._queued = true; return; } + proc.running = true; + } SystemClock { id: clock; precision: SystemClock.Minutes } @@ -60,6 +68,7 @@ Singleton { Process { id: proc + onExited: if (root._queued) { root._queued = false; root.refresh(); } command: { const c = ["typikon", "--json", "--days", String(root.days)]; if (root.jurisdiction !== "") diff --git a/quickshell/services/qmldir b/quickshell/services/qmldir new file mode 100644 index 0000000..fdb7176 --- /dev/null +++ b/quickshell/services/qmldir @@ -0,0 +1,3 @@ +module horologion.services +singleton Typikon 1.0 Typikon.qml +singleton Theme 1.0 Theme.qml diff --git a/quickshell/shell.qml b/quickshell/shell.qml index b7da97d..8d5de81 100644 --- a/quickshell/shell.qml +++ b/quickshell/shell.qml @@ -4,9 +4,9 @@ import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Wayland -import qs -import qs.modules -import qs.services +import "." +import "modules" +import "services" // A runnable widget on its own — `quickshell -p quickshell/shell.qml`. // diff --git a/quickshell/shot.qml b/quickshell/shot.qml index c379ac2..3532df0 100644 --- a/quickshell/shot.qml +++ b/quickshell/shot.qml @@ -12,9 +12,9 @@ import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Wayland -import qs -import qs.modules -import qs.services +import "." +import "modules" +import "services" ShellRoot { PanelWindow { diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100755 index 0000000..2b4d8eb --- /dev/null +++ b/tests/smoke.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Smoke tests for the front ends. Run: tests/smoke.sh +# +# These exist because every bug found in the first review of this repo was in +# the shell and Python around the QML, not in the QML: a --format field that +# raised, an installer that aborted where it said it was continuing, an error +# path that exited non-zero so waybar hid the message it was trying to show. +# None of it was covered by anything. +set -uo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +waybar="$root/bin/horologion-waybar" +install="$root/bin/horologion-install" +pass=0 fail=0 +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +ok() { pass=$((pass+1)); printf ' ok %s\n' "$1"; } +bad() { fail=$((fail+1)); printf ' FAIL %s\n %s\n' "$1" "${2:-}"; } + +check() { # check + local name="$1" want="$2"; shift 2 + local out; out="$("$@" 2>&1)"; local got=$? + [ "$got" = "$want" ] || { bad "$name" "exit $got, wanted $want"; return; } + ok "$name" +} + +json() { # json -- stdout must parse + local name="$1"; shift + local out; out="$("$@" 2>/dev/null)" + if printf '%s' "$out" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null + then ok "$name"; else bad "$name" "not JSON: ${out:0:80}"; fi +} + +echo +echo " waybar module" +command -v typikon >/dev/null 2>&1 || { echo " SKIP: typikon not on PATH"; exit 77; } +json "plain run emits JSON" "$waybar" +json "an unknown --format field" "$waybar" --format '{nosuchfield}' +json "a positional --format field" "$waybar" --format '{0}' +json "an unclosed --format brace" "$waybar" --format '{' +json "--no-emoji" "$waybar" --no-emoji +json "--days 1" "$waybar" --days 1 +json "an unknown jurisdiction" "$waybar" -j atlantis +# waybar hides a module whose script exits non-zero, so every path must be 0 +# or the error JSON is never read. +# A PATH that still has an interpreter but no typikon -- typikon installs to +# ~/.local/bin, which this leaves out. Emptying PATH entirely would only prove +# that the shebang cannot find python3. +check "exit 0 when typikon is missing" 0 env PATH=/usr/bin:/bin "$waybar" +json "JSON when typikon is missing" env PATH=/usr/bin:/bin "$waybar" +check "exit 0 on a bad jurisdiction" 0 "$waybar" -j atlantis + +out="$("$waybar" --format '{daysToPascha}' 2>/dev/null)" +case "$out" in + *'"text": ""'*) bad "a zero field is not blanked" "daysToPascha rendered empty" ;; + *) ok "a zero field is not blanked" ;; +esac + +echo +echo " installer" +check "--help without typikon" 0 env PATH=/usr/bin:/bin "$install" --help +check "an unknown subcommand" 2 "$install" nonsense +mkdir -p "$tmp/a/.config/quickshell" "$tmp/a/.config/waybar" +check "a clean run with eww absent" 0 env HOME="$tmp/a" XDG_CONFIG_HOME="$tmp/a/.config" "$install" all +mkdir -p "$tmp/b/.local/bin" "$tmp/b/.config"; : > "$tmp/b/.local/bin/horologion-waybar" +check "a real file at the target" 1 env HOME="$tmp/b" XDG_CONFIG_HOME="$tmp/b/.config" "$install" all +[ -L "$tmp/a/.local/bin/horologion-waybar" ] \ + && ok "the clean run actually linked" || bad "the clean run actually linked" + +echo +echo " syntax" +check "installer parses" 0 bash -n "$install" +check "waybar module compiles" 0 python3 -m py_compile "$waybar" +rm -rf "$root/bin/__pycache__" +for f in "$root"/quickshell/*.qml "$root"/quickshell/*/*.qml; do + [ -f "$f" ] || continue + grep -q . "$f" || bad "empty QML: $f" +done +ok "QML files are non-empty" + +echo +printf ' %d passed, %d failed\n\n' "$pass" "$fail" +[ "$fail" -eq 0 ]