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 ]