diff --git a/AGENTS.md b/AGENTS.md index a0cf0a8..9578908 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,47 +7,47 @@ Handoff for agents continuing work on the Magic Island shell (Hermes, Claude Cod - **This repo:** `/root/amane-config` on the dev host; remote `git@server1:fabolous005/amane-config.git`. - **Amane library:** `/root/amane` (docs in `docs/src/*.md`). The desktop builds against its own, different copy at `~/git/amane`; type-check against a copy of that one (see Develop). - **Hermes skill `amane`** (`~/.hermes/skills/software-development/amane/`): distilled Amane docs plus lessons. `references/recipe-media-island.md` and `references/remote-visual-testing.md` cover this project; `scripts/` holds copies of `tools/` (keep them in sync). -- **Desktop:** `ssh -o BatchMode=yes fabian@192.168.2.169`. Hyprland 0.56.2 with a **Lua** config (`~/.config/hypr/*.lua`; API stubs in `/usr/share/hypr/stubs/hl.meta.lua`). Intel Xe iGPU (Raptor Lake). Monitors: eDP-1 1920×1200 at 1920,620; HDMI-A-1 1920×1080 at 0,620; DP-2 1920×1080 at 3840,0; HDMI-A-4 at 5760,0 only when connected. `~/.config/amane` on the desktop is a git checkout of this repo. -- **Desktop daemons the control centre uses:** OpenRC services `iwd` (Wi-Fi), `bluetooth` (bluez), `openvpn.` (VPN); doas rule `permit nopass fabian` for `rc-service`, `sh` and `cat`; `wl-copy`, `ip`, `lspci`. iwd and bluetoothd are often stopped; the shell then greys their tiles. +- **Desktop:** `ssh -o BatchMode=yes fabian@192.168.2.169`. Hyprland 0.56.2 with a **Lua** config (`~/.config/hypr/*.lua`; API stubs in `/usr/share/hypr/stubs/hl.meta.lua`). Intel Xe iGPU (Raptor Lake) plus an eGPU (RX 6600). Monitors: eDP-1 1920×1200 at 1920,620; HDMI-A-1 1920×1080 at 0,620; DP-2 1920×1080 at 3840,0; HDMI-A-4 at 5760,0 only when connected. `~/.config/amane` on the desktop is a git checkout of this repo. +- **Desktop daemons the shell uses:** OpenRC services `iwd`, `bluetooth`, `openvpn.`, `ollama` (stopped by default; `gemma4:e4b`, listens on 0.0.0.0:11434, runs on the eGPU); doas rule `permit nopass fabian` for `rc-service`, `sh` and `cat`; gnome-keyring as the Secret Service (`secret-tool`); `wl-copy`, `ip`, `lspci`, `ghostty`, `btm`. iwd and bluetoothd are often stopped; the shell then greys their tiles. ## Ground rules from the user -- **Branch per big change, ask before merging.** New branch for each big change; merge into `master` only after explicit approval. "In main branch" or "merge directly to main" means commit straight to master. Small follow-ups in the same request can share a branch. **Push only when the user asks.** +- **Branch per big change, ask before merging.** Merge into `master` only after explicit approval. "In main branch" or "merge directly to main" means commit straight to master. Small follow-ups in the same request can share a branch. **Push only when the user asks.** - **Never touch the user's real players' sound** (Cider, Firefox). Before any `island preset|output` call or volume change, check `amane ipc call island status` names a fake test player. Test audio goes to the inaudible null sinks. -- **The user's own amane:** they sometimes run it themselves. Don't kill it without asking; `tools/amane-remote.sh start` refuses when it finds a shell it didn't start. -- **Desktop access is SSH only.** Don't probe the network, SSH keys or other hosts. If SSH fails, report it and wait. +- **The user's own amane:** their session starts one at login. Don't kill it without asking; `tools/amane-remote.sh start` refuses when it finds a shell it didn't start. +- **Desktop access is SSH only.** Don't probe the network, SSH keys or other hosts. If SSH fails, report it and wait. The user may be at the desktop while you test: if workspaces change or cards close by themselves, stop the on-screen tests. - **Leave the desktop clean** after testing: fake players and null sinks stopped, shell running without the frame log (see "After testing"). - **Edit files with the patch/write tools, not scripts that rewrite files.** The user installs missing tools themselves. Scripts take instance values (hosts, names) as arguments. -- **No hardcoded tuning values:** every size, timing, text size, colour or interval is a setting in `config.rs` (see Settings). The user asked for this explicitly. -- **Blur strength is Hyprland's only** (`decoration.blur.*`); the shell never sets it. A shell blur setting was added once and reverted at the user's request. -- **D-Bus only for reading status.** Actions go through the CLIs: `iwctl` to join, scan and disconnect, `bluetoothctl` to pair and connect (the user had a D-Bus agent version reverted, `afe963d`). The one exception, chosen by the user: the shell serves `org.kde.StatusNotifierWatcher` and calls tray apps' dbusmenus. Ask before serving anything else on the bus. -- **Never wake iwd by accident:** any D-Bus call to `net.connman.iwd` starts it (it then joins Wi-Fi and adds a route). Check `org.freedesktop.DBus.NameHasOwner` first; only talk to it while it has an owner. -- **Root only through `doas -n` with fixed command lines** (`rc-service start|stop`, `cat /var/lib/iwd/.psk`). Strings from other programs (SSIDs, device names, D-Bus metadata) go to `Command` args, never into `amane::spawn`/`output` (`sh -c`). Passwords go to `wl-copy` on stdin, never as an argument; iwctl's `--passphrase` is visible in `ps` for about a second, which the user accepted. -- Edits to AGENTS.md prompt for approval (`security.protected_instruction_files`); the user approves them at the screen, and a timed-out prompt is a no: don't retry by another path. Keep this file near 20k characters, Hermes's load limit. +- **No hardcoded tuning values:** every size, timing, text size, colour or interval is a setting in `config.rs` (see Settings). +- **Blur strength is Hyprland's only** (`decoration.blur.*`; the user keeps size 1 and the layer rule's `ignore_alpha = 0.5`). The shell never sets it. +- **D-Bus mostly for reading status.** Actions go through the CLIs: `iwctl`, `bluetoothctl` (a D-Bus agent version was reverted, `afe963d`). The shell serves only what the user chose: `org.kde.StatusNotifierWatcher` (tray) and `org.freedesktop.Notifications` (Amane's own server; dunst is commented out in `~/.config/hypr/autocmds.lua`, backup `autocmds.lua.before-amane-notifications`). Ask before serving anything else. +- **Never wake iwd by accident:** any D-Bus call to `net.connman.iwd` starts it. Check `NameHasOwner` first. +- **Root only through `doas -n` with fixed command lines.** Strings from other programs go to `Command` args, never into `amane::spawn`/`output` (`sh -c`). Secrets go on stdin: `wl-copy`, `secret-tool store`, curl's `-K -` for the calendar. Never put a password in a file, an argument or a log. +- **Local AI only:** the AI card talks to Ollama on the desktop, never a cloud service. Phone stats are out for now. +- Edits to AGENTS.md prompt for approval (`security.protected_instruction_files`); a timed-out prompt is a no: don't retry by another path. Keep this file near 20k characters, Hermes's load limit. - Report honestly what was and wasn't checked on the real desktop. ## Current state -**master** (pushed): the island (pill, card, CD ring, EQ, output and player switching, hover animations, instant transport buttons), an idle pill when no player is open, one island per monitor (card on one monitor, live bars only on the focused one), a workspace pill per monitor, wallpaper colours through matugen, and: +**master** (pushed): the island (pill, card, CD ring, EQ, output and player switching, hover animations), an idle pill without players, one island per monitor (live bars only on the focused one), a workspace pill per monitor, wallpaper colours through matugen, and: -- **Control centre** (top right): a status pill (Wi-Fi or hotspot, VPN, Bluetooth, Ethernet, battery) that opens a panel: tiles for Wi-Fi, Bluetooth, VPN, Hotspot, Ethernet; volume with the output, brightness, keyboard light, performance mode, external-GPU programs; a Settings button (gear) at its foot. The panel morphs into its menus as one shape: Wi-Fi and Bluetooth maps (click to join/pair, right-click a saved network for its password, click the centre to search again), VPN list, sound outputs, Ethernet details with copyable addresses. Popups (`magic-prompt`) ask for passwords, PINs and passkeys and say why something failed. +- **Control centre** (top right): status pill opening a panel with tiles (Wi-Fi, Bluetooth, VPN, Hotspot, Ethernet), volume, brightness, keyboard light, performance mode, eGPU programs, Settings (gear). The panel morphs into its menus as one shape: Wi-Fi and Bluetooth maps, VPN list, sound outputs, Ethernet details. Popups (`magic-prompt`) ask for passwords, PINs and passkeys. - **System tray** right of the workspace pill. -- **Bar hiding:** only on monitors showing a fullscreen window; the pointer on the top edge brings it back. Peeks: the workspace pill alone on a workspace change, the island on a song change or a new player. A peek starts from where the part is (no dip). -- **Workspace switch:** Hyprland deletes an empty workspace the moment it's left; its slot stays for the slide, shrinking and fading, so the row closes up with the disc's move instead of jumping. +- **Bar hiding** only on monitors showing a fullscreen window; the top edge brings it back. Peeks: the workspace pill on a workspace change; the island on a song change or new player, but not when the focused window's class is the playing player (`parse::same_app`). +- **Workspaces:** a removed empty workspace's slot shrinks and fades during the slide; a created one (`createworkspacev2`) grows in. - **Reserved space:** windows that aren't fullscreen stay below the bar (44 px). -- **Settings** (branch `config-window`, merged 2026-10-15): `~/.config/amane/config.toml` holds all 184 shell settings with their help; the config window (`Magic Island settings`, a normal window; Esc closes it) edits them plus every Hyprland option. See Settings. +- **Settings:** `~/.config/amane/config.toml` holds every shell setting with its help; the config window edits them plus every Hyprland option. Saves are serialised by `config::WRITTEN`, and the file watch skips the shell's own saves (an earlier race reset settings to defaults). +- **Glass designs:** `appearance.surface.design` = solid / frosted / liquid, drawn by `surface::shell()` on every surface. The user keeps all three and may want more. The old branches `glass-base`, `design-frosted`, `design-liquid` (also on origin) are superseded; deleting them was asked, no answer. -On 2026-10-13 the user tried the mouse-only parts (tray clicks, top-edge reveal, Copy buttons, PIN pairing): "All works". The agent itself only drives them over IPC. +**Branch `side-pills`** (pushed, not merged; the user tries it first): two one-icon pills left of the island (AI, System) and two right (Home, Notifications); see Architecture and README "Side pills". -**Glass designs** (`glass-base`, `design-frosted`, `design-liquid`, pushed, not merged): `glass-base` adds `src/surface.rs` (`shell()` draws the island, idle pill and workspace pill; with glass below 1 the open/close fade uses `.opacity()`, as a veil can't hide content on see-through glass) and a `glass` opacity; `design-frosted` (0.62, thin tint, hairline white rim, top sheen, soft black shadow) and `design-liquid` (0.4, specular gradient, light rim warming to `flame` on hover, accent glow) build on it. They branched from `036b326`, before the control centre and the settings, and read `glass` from `shell.conf`, which master no longer has: port them as settings (`appearance.surface.*`), not as a revived `shell.conf`. Measured 2026-10-09 (pill / open card GPU ms per frame): solid 1.6 / 3.3, liquid 2.2 / 3.3, frosted 2.9 / 3.3. `~/.config/hypr/windowrules.lua` has the glass layer rule (`^magic-.*$`, blur, `ignore_alpha = 0.5`). +**On the desktop:** the agent-started shell (pid in `~/.cache/amane-remote-test/shell.pid`, stderr in `shell.err`) runs the `side-pills` build; the user's login shell was stopped with their approval. `config.toml` has `pills.ai.model = gemma4:e4b`, design frosted at 0.7. `~/.config/hypr/hyprland.lua` did not `require("amane")` (checked 2026-10-15), so Hyprland options set in the window are lost on a reload; the user was told. -**On the desktop:** the agent-started shell runs the `config-window` build (pid in `~/.cache/amane-remote-test/shell.pid`, no frame log). `config.toml` was made from the old `shell.conf` (still there, unread). `~/.config/hypr/hyprland.lua` does **not** `require("amane")` yet (checked 2026-10-15), so Hyprland options set in the window are lost on a Hyprland reload; the user was told to add it. amane is not autostarted: after a reboot the user starts it. - -**Theme pipeline:** Skwd-wall v2's matugen integration `amane` renders `~/.config/amane/matugen/amane.colors` into `~/.config/amane/theme` (scheme `scheme-fidelity`). +**Theme pipeline:** Skwd-wall v2's matugen integration renders `~/.config/amane/matugen/amane.colors` into `~/.config/amane/theme`. ## Constraints of Amane itself -- Only `amane` and the Rust standard library: `amane compile` generates `Cargo.toml`, so no crates. External tools run as processes (`std::process::Command`, `amane::output`). +- Only `amane` and the Rust standard library: `amane compile` generates `Cargo.toml`, so no crates. External tools run as processes (`Command`, `amane::output`); HTTP is curl. - Amane redraws the whole window every frame, so every effect is paid at the frame rate. - Amane can only blur what the shell draws itself; frosting the desktop behind it needs a Hyprland layer rule. - `amane compile` saves `~/.cache/amane/amane-shell`; `amane run` starts it. @@ -56,68 +56,64 @@ On 2026-10-13 the user tried the mouse-only parts (tray clicks, top-edge reveal, | Piece | Kind | Job | |---|---|---| -| `config.rs` | settings | Every setting declared once in `settings! { part { group { name: type = default, range; "help" } } }`: a typed accessor (`config::island::pill::width()`) plus an `OPTIONS` entry (path, help, kind, range, default) for the window. Values live in a `RwLock`; reading per draw costs nothing measurable. `load()` reads `config.toml` (first start: writes it, moving `shell.conf`'s 4 values over); `save()` rewrites it with every setting and its help. `[hyprland.*]` tables hold Hyprland overrides. | -| `settings.rs` | view, normal `Window` | The config window: a page per part and per Hyprland category, search over everything, a row per setting (switch, slider + field, text with colour swatch, choice chips), reset per row, `config.toml` errors at the top. Esc closes. The `config` IPC handler (`command`). | -| `hypr.rs` `Hypr` | Service, worker thread | Hyprland's options from Hyprland itself (`hyprctl descriptions -j`, one `--batch` of `j/getoption`), so no option is named in code. `set`/`reset` queue jobs (superseded ones dropped); the worker applies with `hl.config`, rewrites `~/.config/hypr/amane.lua` and `config.toml`; a reset reloads Hyprland's config so the user's value returns. | -| `island.rs` `Island` | input-only Service | `open: Animation` (0 pill, 1 card), `expanded`, `selected` player (`None` follows the playing one), open `menu`, time-bar `scrub`, `hover` (per-`Spot`), `opened_on` / `pointer_on` (monitors), `playing_now`. Also the view and the `island` IPC handler. | -| `sound.rs` `Sound` | Service, poll | `pactl list sinks` + `sink-inputs` → each player's streams (by process ancestry, then app name), volume, mute, output, equalizer; `presets()` from settings. Curves saved by player identity to `~/.config/amane/island-eq`. | -| `spectrum.rs` `Spectrum` | Service | While the shown player plays: `pw-record --target \| cava -p $XDG_RUNTIME_DIR/magic-island/cava.conf` → levels (`sound.spectrum.bars`). On start it `pkill -KILL`s cava left by an earlier shell (cava ignores SIGTERM). | -| `art.rs` `Art` | Service, poll | Cover URL → local file, copied to `palette-cover`, which `Palette` (opened in `main.rs`) watches. Only used for `cover_colors`. | -| `theme.rs` `Theme` | Service, `amane::watch_file` | 10 colours from `~/.config/amane/theme`, faded together on one `Animation`; also watches `config.toml` (own thread) and reloads it. Views start with `theme::begin()` and draw with `theme::now()`; `theme::touch()` redraws every view. No theme file: `appearance.fallback.*`. `watch_file` watches the folder, so matugen's rename-over saves count. | -| `workspaces.rs` | view + input-only `Hover` | Workspace pill per monitor (`magic-workspaces`, top left), slots placed by x in one `Stack` under one sliding accent disc. Click runs `hyprctl dispatch 'hl.dsp.focus({ workspace = N })'` on a thread. | -| `parse.rs` | plain functions | pactl/cava/proc parsing, filter-chain config, EQ store, `key = value`, colours, iwctl/bluetoothctl output, Wi-Fi bars, `hyprctl` JSON, `ip`, tray icons; the TOML reader/writer (`Value`, `toml`), a JSON reader (`json`, skips non-JSON lines between values), Hyprland option values (`hypr_option`, `hypr_lua`). Unit-tested on its own. | -| `control.rs` `Control` | view + input-only Service | The control centre (`magic-control`) and its menu window (`magic-control-menu`: the panel morphs to the menu's size, then its content fades in). `menu_content(menu, map_w, ..)`, `map_width(monitor_w)`, the `control` IPC handler. | -| `system.rs` `Links`, `Power`, `Gpu` | Services, threads | iwd (D-Bus reads, only while it has an owner), openvpn, bluez state, wired cards from sysfs, battery, brightness, keyboard light, power profile, eGPU programs from fdinfo; actions via CLIs. Intervals in `control.polling.*`. | -| `prompt.rs` `Prompt` | view + input-only Service | The popup (`magic-prompt`): password, PIN, passkey, confirm, code, saved password, failure reason. Runs the `bluetoothctl` pairing session. `prompt::copy(text, done)` is the one wl-copy. | -| `map.rs` | widget functions | The Wi-Fi/Bluetooth maps: nodes on an ellipse round the centre bubble (nearer = stronger), floating, lines, search rings. Sizes in `control.maps.*` (8 nodes at most: a 9th makes labels meet at 822 px; picked by simulating the drift). | -| `glyph.rs` | shapes | Every icon as `Shape`s; maps draw all of theirs into ONE canvas (`glyph_shapes`), as each `Canvas` has a large fixed GPU cost. | -| `edge.rs` `Edge` | Service, Hyprland sockets | `.socket2.sock` events and `.socket.sock` queries, no polling. Hides the bar on fullscreen monitors, the 1 px `magic-edge` strip, peeks, the island's pop, the workspace slide (`switched`), `hide_h()`, and `spacer` (`magic-reserve`, 44 px). | -| `tray.rs` `Tray`, `TrayUi` | Services, bus threads | Owns `org.kde.StatusNotifierWatcher` (only if no other watcher runs) and is its host; icons from themes or `IconPixmap`; dbusmenu menus in `magic-tray-menu`; the `tray` IPC handler. | -| `png.rs` | plain functions | Stored-deflate PNG writer for pixmap tray icons. | +| `config.rs` | settings | Every setting declared once in `settings! { part { group { name: type = default, range [choices]; "help" } } }`: a typed accessor plus an `OPTIONS` entry for the window. `load()`/`save()` of `config.toml`, `set`/`reset`; `[hyprland.*]` tables hold Hyprland overrides. | +| `settings.rs` | view, normal `Window` | The config window: a page per part and per Hyprland category, search, a row per setting (switch, slider + field, text with swatch, chips), reset per row. Esc closes. `config` IPC. | +| `hypr.rs` `Hypr` | Service, worker | Hyprland's options from `hyprctl descriptions -j` and one `--batch` of `j/getoption`. `set`/`reset` apply with `hl.config`, rewrite `~/.config/hypr/amane.lua` and `config.toml`. | +| `surface.rs` | widget function | `shell(w, h, radius, colors, lift, content)`: the chosen glass design for every surface. | +| `island.rs` `Island` | input-only Service | The island: `open: Animation`, player selection, menus, scrub, hover spots, `width_on(monitor)` (for the pods), `island` IPC. `expand()` closes a pod card. | +| `pods.rs` `Pods` | view + input-only Service | Side pills: windows `magic-pods-left|right` per monitor beside the island, moving out as it opens; a pill morphs into its card (one timeline, `pills.layout.open_ms`). One card open across the shell. `header`, `chip`, `button`, `rule` helpers for the cards. `pods` IPC (`open|toggle [monitor]`, `close`, `ask`, `processes`, `notices …`, `status`). | +| `ai.rs` `Ai` | Service | Ollama `/api/chat` via curl, streamed line by line (`parse::Chunk`); starts `ollama` via doas when it doesn't answer. Stop and New chat bump `round`, so a late line from an old answer is dropped. | +| `stats.rs` `Stats` | Service, thread | CPU (`/proc/stat`), GPU clock (xe/i915 sysfs), memory, network, disk sectors of physical disks, hottest sensor per hwmon. All graphs in one canvas. Processes runs `ghostty --class=amane.monitor -e btm` via `hl.exec_cmd` on `special:monitor`, then toggles it. | +| `home.rs` `Clock`, `Forecast`, `Agenda` | Services, threads | Avatar (`~/.face`, else a glyph), clock, open-meteo weather (Munich by default), month grid, CalDAV (iCloud by default): discovery, `REPORT` with `expand`, `parse::ics_events`. Sign-in stores an app-specific password with `secret-tool` (`service amane-calendar account `). | +| `notices.rs` `Notices` | Service, poll 250 ms | On top of Amane's `Notifications` server: popups (`magic-notifications`, top right of the focused monitor, timeouts by urgency, paused on hover), pictures found once per notification (`tray::icon_file`), unread count, the history card (cross = `dismiss`, Clear all, Mute = `notifications.popups.enabled`). A popup click without a default action only hides it; the server's `click` would delete it. The server itself never expires notifications. | +| `sound.rs` `Sound` | Service, poll | `pactl` sinks and sink-inputs → each player's streams, volume, mute, output, equalizer. Curves in `~/.config/amane/island-eq`. | +| `spectrum.rs` `Spectrum` | Service | `pw-record --target \| cava` → levels while the shown player plays; kills stale cava with `-KILL`. | +| `art.rs` `Art` | Service, poll | Cover → `palette-cover` for `cover_colors`. | +| `theme.rs` `Theme` | Service, `watch_file` | 10 colours from `~/.config/amane/theme` faded on one `Animation`; also watches `config.toml`. Views start with `theme::begin()`, draw with `theme::now()`; `theme::touch()` redraws all. | +| `workspaces.rs` | view + `Hover` | Workspace pill per monitor; slots by x in one `Stack` under one sliding disc; ghost and growing slots. | +| `parse.rs` | plain functions | All parsing, unit-tested: pactl/cava/proc, EQ store, colours, iwctl/bluetoothctl, `hyprctl` JSON, TOML (`Value`), JSON, Hyprland values, `same_app`, Ollama chunks, /proc stats, weather, WebDAV XML, iCalendar, civil dates, notification body markup. | +| `control.rs`, `map.rs`, `prompt.rs`, `system.rs` | views, Services | Control centre and its menus, the maps (one canvas each), the popups, the network/power/GPU services. | +| `glyph.rs` | shapes | Every icon as `Shape`s; draw many into ONE canvas (`glyph_shapes`), as each `Canvas` has a large fixed GPU cost. | +| `edge.rs` `Edge` | Service, Hyprland sockets | socket2 events, no polling: bar hiding, `magic-edge`, peeks, workspace slide (`switched`, `created`), `hide_h()`, `magic-reserve`. | +| `tray.rs`, `png.rs` | Services | StatusNotifierWatcher and host, icons from themes or pixmaps, dbusmenu menus. | -The equalizer: one `setsid -f pipewire -c eq-.conf` per player with `libpipewire-module-filter-chain`, 10 biquads at `sound.equalizer.bands`. The player's streams move into sink `magic_island_eq.`; gains change live with `pw-cli set-param`. A player only gets one when its curve or output first changes, or it has a saved non-flat curve. +### The island view -### The view (island.rs `view`) - -- One `LayerWindow` per monitor (`magic-island`, `Layer::Overlay` because the user's windows are fullscreen), sized for the open card; an `InputArea` lets clicks outside through. No player: `idle()`, a click-through pill. -- `tap(monitor)` opens the card where it was clicked, `expand()` (IPC) on the focused monitor. Only the focused window and the one with the card read `Spectrum` (reading subscribes), so the rest stay idle. -- `phases(open)` splits one timeline (`island.animation.open_ms`): pill fades out, the empty shape grows until `shape_done`, then the card fades in. At most one face per frame; `veiled()` fades with a backdrop-coloured veil instead of `.opacity()`. -- Background: one 150° three-stop gradient (`backdrop()`); with `cover_colors` the card blends to the cover's colours as it opens. White-alpha overlays (`appearance.overlays.*`, `CLEAR`) stay unthemed. -- Hover: `hovering(spot)` eases a `Spot` over `appearance.motion.hover_ms`. Amane matches hover targets by index in the last frame, so a vanished button never hears the pointer leave: entering a spot fades all others, and `collapse()`/`set_menu()` drop stale highlights. -- Transport: play/pause flips at once through `playing_now`; `refresh_soon()` re-reads Media at 150 and 600 ms. +- One `LayerWindow` per monitor (`magic-island`, `Layer::Overlay`), sized for the open card; an `InputArea` passes clicks outside through. +- `phases(open)` splits one timeline: pill fades out, the empty shape grows, then the card fades in. At most one face per frame; `veiled()` fades with a backdrop-coloured veil. +- Hover: Amane matches hover targets by index in the last frame, so a vanished button never hears the pointer leave: entering a spot fades all others, and `collapse()`/`set_menu()` drop stale highlights. ## Settings -- **Add a setting** by declaring it in `config.rs`'s `settings!` block in the right part and group, with a range and a one-line help; use the accessor in code. Derived values (`inner_w()` and the like) are functions over settings, not constants. Text sizes are roles (`appearance.text.tiny|caption|small|label|body|heading|section|title|popup`), not numbers. Remaining `const`s are names and identities only (D-Bus names, `CLEAR`, `TAU`). +- **Add a setting** in `config.rs`'s `settings!` block with a range and a one-line help; use the accessor in code. Derived values are functions over settings. Text sizes are roles (`appearance.text.*`). Remaining `const`s are names and identities only. - The font and cava's bar count apply on restart; everything else on save. -- `amane ipc call config get|set|reset [value]` drives everything without the mouse (`hyprland.` for Hyprland, e.g. `hyprland.decoration.blur.size`); `status` says `open=… page=… errors=… search=…`. -- `hl.config` value forms: numbers and bools plain, gradients `{ colors = { "rgba(…)" }, angle = 45.0 }`, vec2 `{ x, y }`, css gaps a number or `{ top, right, bottom, left }`. `hyprctl --batch` of all `j/getoption` prints plain "invalid type (internal error)" lines between objects for a few options. +- `amane ipc call config get|set|reset [value]` (`hyprland.` for Hyprland); `status` says `open=… page=… errors=… search=…`. +- `hl.config` value forms: numbers and bools plain, gradients `{ colors = { "rgba(…)" }, angle = 45.0 }`, vec2 `{ x, y }`, css gaps a number or `{ top, right, bottom, left }`. ## Hyprland 0.56 (Lua config) pitfalls -- **Never return `visible(false)` from a layer view**: a hidden-at-start layer window stays blank forever ("has no child to draw"). Return `window.click_through().child(Rectangle::new().width(0.0).height(0.0))`. -- **`Workspaces::focus` does nothing** (sends `dispatch workspace N`). Use `hl.dsp.focus({ workspace = N })`. -- `hyprctl eval` / `repl` share one Lua state; globals (a rule kept in a variable) vanish on a config reload. -- Layer-rule `match.namespace` is a full match: `^magic-.*$`. Layer rules can only switch blur on; strength is global. -- `$XDG_RUNTIME_DIR/hypr/` keeps folders of dead instances; the live one has `.socket.sock`. A shell started with a dead signature gets an empty `Workspaces` list. +- **Never return `visible(false)` from a layer view**: a hidden-at-start layer window stays blank forever. Return `window.click_through().child(Rectangle::new().width(0.0).height(0.0))`. +- **`Workspaces::focus` does nothing.** Use `hl.dsp.focus({ workspace = N })`. Special workspaces: `hl.exec_cmd(cmd, { workspace = "special:x silent" })`, `hl.dsp.workspace.toggle_special("x")`. +- `hyprctl eval` / `repl` share one Lua state; globals vanish on a config reload. +- Layer-rule `match.namespace` is a full match: `^magic-.*$`. +- `$XDG_RUNTIME_DIR/hypr/` keeps folders of dead instances; the live one has `.socket.sock`. - `hyprctl layers` lists hidden layer windows too (alpha 0). -- **Reserved space:** `Zone::Reserve` reserves the whole window height and only on a window anchored to one edge. Tiled and maximised windows stay out of the strip; fullscreen 2/3 cover it. Bar windows keep `Zone::Ignore`. Check with `hyprctl monitors` (`reserved: 0 44 0 0`). -- A peek only shows on the **focused** monitor; test with a workspace hop there, then restore the focus. -- `hl.dsp.window.float(...)` acts on the focused window. To screenshot the config window floating, add a temporary rule: `hyprctl eval 'r = hl.window_rule({ name = "amane-settings", match = { title = "^Magic Island settings$" }, float = true, center = true, size = { 1100, 760 } })'`. +- **Reserved space:** `Zone::Reserve` reserves the whole window height, only on a window anchored to one edge. Check with `hyprctl monitors` (`reserved: 0 44 0 0`). +- A peek only shows on the **focused** monitor. A monitor whose workspace has a fullscreen window hides the bar, pods included (an open pod card stays). ## Performance rules -- **Measure before and after every visual change**, baseline first, same player and monitor: Amane's per-frame `gpu` time and `top` CPU (the iGPU busy counter reads 100% even with the shell paused). -- Don't resize a shape with the full card inside it; grow the empty shape, then fade the content in. Don't resize a layer window every frame: give it the largest size for the animation (the workspace ghost does). -- `.clip()` and `.opacity(<1)` each add a GPU layer (a clip over the whole card once took the iGPU from 47 to 9 fps); a shadow is two. Halos only while hovered. -- One backdrop gradient is affordable, many aren't. A veil over a gradient must be the same gradient at the same size. -- Draw repeated marks (candles, bars) as one `Path` per look. -- Streaming data needs `amane::request_frame()` in the view while levels are live. +- **Measure before and after every visual change**, same player and monitor: Amane's per-frame `gpu` time and `top` CPU. +- Don't resize a shape with the full card inside it; grow the empty shape, then fade the content in. Don't resize a layer window every frame: size it for the whole animation. +- `.clip()` and `.opacity(<1)` each add a GPU layer; a shadow is two. Halos only while hovered. +- One backdrop gradient is affordable, many aren't. +- Draw repeated marks as one `Path` per look; many icons or graphs in one `Canvas`. +- Streaming data needs `amane::request_frame()` while it's live. Slow work (icon lookups, curl, sysfs walks) belongs on Service threads, never in a view. ## Develop -1. Type-check in a scratch crate outside the repo (don't commit a `Cargo.toml`), at `/root/.hermes/cache/scratch/amane-check/` (pruned after 24 h idle): `[package] name = "amane-shell"`, edition 2024, `[[bin]] path = '/root/amane-config/src/main.rs'`, `amane = { path = '' }`, an empty `[workspace]`. Then `cargo clippy` (first build takes minutes). -2. Unit tests: `rustc --edition 2024 --test src/parse.rs -o /tmp/parse_test && /tmp/parse_test` (12 tests). +1. Type-check in a scratch crate outside the repo (don't commit a `Cargo.toml`), at `/root/.hermes/cache/scratch/amane-check/` (pruned after 24 h idle): `[package] name = "amane-shell"`, edition 2024, `[[bin]] path = '/root/amane-config/src/main.rs'`, `amane = { path = '' }`, an empty `[workspace]`. Then `cargo clippy`. Fetch the copy with `ssh … 'tar cf - --exclude=target --exclude=.git -C ~/git amane'`. +2. Unit tests: `rustc --edition 2024 --test src/parse.rs -o /tmp/parse_test && /tmp/parse_test` (21 tests). ## Testing on the desktop @@ -127,23 +123,21 @@ The equalizer: one `setsid -f pipewire -c eq-.conf` per player with `libpip H=fabian@192.168.2.169 tools/amane-remote.sh $H deploy src # back up the remote src once, then replace it tools/amane-remote.sh $H build # amane compile -tools/amane-remote.sh $H start # stop the shell it started earlier, start one WITH frame logging +tools/amane-remote.sh $H start # start a shell WITH frame logging tools/amane-remote.sh $H fps 3 # frames, average gap, gpu wait tools/amane-remote.sh $H anim island # open/close 3x; stutter = 20-500 ms gaps tools/amane-remote.sh $H sinks start # null sinks island_test_a/_b -tools/amane-remote.sh $H players start 4 # fake MPRIS players; "Synth Test" plays into island_test_a +tools/amane-remote.sh $H players start 4 # fake MPRIS players tools/amane-remote.sh $H sh 'COMMAND' # anything inside the graphical session's environment ``` - Pick a fake player with `ipc island next` until `status` names it, before anything that changes sound. -- `ipc island open` opens on the **focused** monitor. Read geometry from `hyprctl layers -j` right before each `grim` capture. With one window per monitor the frame log mixes windows. -- **Animations:** capture a strip in a loop (`grim -g "3840,0 300x60"` takes ~20 ms) while dispatching the change, then tile the frames with ffmpeg (`tile=2x12`) and look at one sheet. -- Keys to a focused window: `wtype -k Escape`. Hover can't be faked with `movecursor`; real motion moves the user's mouse, so ask first. -- Control centre and tray without a mouse: `ipc control open|close|menu |ethernet|status` (`status` ends with `prompt=…`), `ipc tray list|menu|open N|activate N|choose ID|close`. Fake tray app: `python3 tools/fake_tray.py name|path`; kill it by pid. -- Maps with many nodes: deploy a scratch `src/` whose `map::map` injects fake nodes (never commit it), then redeploy the real one. -- Measure gaps and alignment from pixels (`ffmpeg … crop=1920:1:0:Y -f rawvideo`), not by asking a vision model. -- To tune a visualiser, sample real levels read-only first (`pw-record --target | cava`, `timeout -s KILL` on each stage); typical music sits at 0.15–0.35. -- Check `uptime` before trusting timings. In `pkill -f` patterns over SSH use `[p]ython3 ...`. A fresh shell once died with "lost the gpu"; starting again worked. +- `ipc island open` and `ipc pods open X` act on the **focused** monitor (pods take a monitor name too). Read geometry from `hyprctl layers -j` right before each `grim` capture, on a monitor without a fullscreen window. +- **Animations:** capture a strip in a loop (`grim -g "3840,0 300x60"`) while dispatching the change, then tile the frames with ffmpeg. +- Keys: `wtype -k Escape`. Hover can't be faked with `movecursor`; real motion moves the user's mouse, so ask first. +- Without a mouse: `ipc control open|close|menu …|status`, `ipc tray list|menu|open N|…`, `ipc pods …`. Notifications: `notify-send -a App -i icon -u critical -A key=Label …`, then `ipc pods notices list`. +- Measure gaps and alignment from pixels (`ffmpeg … crop=… -f rawvideo`), not by asking a vision model. +- Check `uptime` before trusting timings. In `pkill -f` patterns over SSH use `[p]ython3 ...`. ### After testing @@ -151,17 +145,18 @@ tools/amane-remote.sh $H sh 'COMMAND' # anything inside the graphical sessi tools/amane-remote.sh $H players stop tools/amane-remote.sh $H sinks stop tools/amane-remote.sh $H sh 'kill $(cat shell.pid); for i in $(seq 50); do pgrep -F shell.pid > /dev/null || break; sleep 0.1; done - rm -f shell.log; setsid -f sh -c "echo \$\$ > shell.pid; exec ~/.cache/amane/amane-shell" > /dev/null 2>&1' + rm -f shell.log; setsid -f sh -c "echo \$\$ > shell.pid; exec ~/.cache/amane/amane-shell 2> shell.err" > /dev/null 2>&1' ``` ## Open threads -- The user reviews the glass designs, ported onto master as settings; then merge the chosen one. +- **Side pills, untested by the agent:** the calendar (the user signs in later; CalDAV discovery and the iCloud answer have never run), clicks and typing in the cards (only IPC was used), the AI answer in the card (the API answered in 0.8 s; the card capture came after the user closed it), the popups' new right alignment, Processes (ghostty + btm), the avatar (`~/.face` doesn't exist yet). +- During the notification test all three test notifications left the history within 6 s and the Firefox one's Open action fired, apparently from the user's clicks at the desk; not confirmed. - The user adds `require("amane")` to `hyprland.lua` (or asks the agent to). -- The config window was only driven over IPC and Esc; clicking, dragging and typing in it are untested by the agent. +- The config window was only driven over IPC and Esc. - The candle ring is still mirrored (`mirrored()`); the pill's bars aren't. -- Upstream Amane fixes for the two Hyprland 0.56 issues (hidden layer at start, `Workspaces::focus`): not started. -- `island-eq` is tracked but rewritten live; untracking it was proposed, no answer yet. Leftovers offered for deletion, no answer: `src.before-test/` and old screenshots in `~/.cache/amane-remote-test/` on the desktop, merged local branches on the dev host. -- On a monitor narrower than about 1500 px the maps stop at 600 px and run under the island. -- The open/close stutter rise (25–40 slow frames per 3 opens and closes on every build) is unexplained. +- Upstream Amane fixes for the two Hyprland 0.56 issues: not started. +- `island-eq` is tracked but rewritten live; untracking it was proposed, no answer. Leftovers offered for deletion, no answer: `src.before-test/` and old screenshots on the desktop, merged and superseded local branches. +- On a monitor narrower than about 1500 px the maps run under the island. +- The open/close stutter rise (25–40 slow frames per 3 opens and closes) is unexplained. - See README "Known limits". diff --git a/README.md b/README.md index 11c6702..cc45cab 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,19 @@ The Material You scheme type is Skwd-wall's setting (`matugen.schemeType`). Whether the open card takes the song cover's colours instead (`appearance.colors.cover_colors`), the fade time, and the colours used until there is a theme file are [settings](#settings). +## Side pills + +Two small one-icon pills sit left of the island and two right of it, on every monitor, each in its own colour (`pills.colors.*`; Home's follows the sky). A click grows a pill into a big card in the middle of the top of the screen; meanwhile the row becomes a carousel: the other pills and the island's pill line up two left and two right of the card in their usual order (around Home: AI and System left, the island and the bell right), and clicking one switches straight to its card (the island's pill closes the card, then opens the island). A closing card shrinks back into its own pill's place. Only one card is open across the shell. Esc, the cross or a click elsewhere closes it. + +- **AI** (sparkle): a chat with an Ollama model on your own servers, `gemma4:e4b` at `http://localhost:11434` by default. `pills.ai.servers` takes several, comma-separated, in order: the model chip in the header lists every server's models (each once, with its server when there are several) and switches at once, and a question goes to the first server that has the chosen model. Answers stream in through curl. When Ollama doesn't answer, the shell starts the OpenRC service with `doas rc-service ollama start` (`pills.ai.start_service`). Stop ends an answer and New chat starts over. Nothing goes to a cloud service, only to the servers listed. +- **System** (pulse): CPU, the integrated GPU's clock, the external GPU's load (with its video memory and the programs on it, formerly in the control centre), memory, swap, network and disk read/write as graphs over the last minute, plus the hottest sensors with icons, in cool/warm/hot colours (`pills.monitor.warm|hot`). Everything is read from `/proc` and `/sys`, so there is no process list. Processes opens `ghostty --class=amane.monitor -e btm` on the special workspace `monitor`; a second press shows or hides it. +- **Home** (house): `~/.face` as the avatar (a person glyph until the file exists), a greeting, the date, a big clock, and Munich's weather now and for four days from open-meteo (no key), over a photo of the current weather (`~/.config/amane/weather/-.jpg`, downloaded once from Wikimedia Commons, credits in `weather/CREDITS.md`; swap any file, `pills.home.photo_opacity` tones it down). A big month grid marks events (dots in their calendars' colours), birthdays from your iCloud contacts (a cake) and the public holidays of `pills.calendar.holidays` (BY by default); click a day for its list. Events can be created, edited and deleted there (a series: title, place and calendar only). For iCloud, set `pills.calendar.account` to your Apple ID and store an [app-specific password](https://account.apple.com) in the keyring: `secret-tool store --label='Magic Island calendar' service amane-calendar account ` (gnome-keyring or another Secret Service has to run). The password never goes into `config.toml`. Other CalDAV/CardDAV servers work through `pills.calendar.server` and `contacts_server`. +- **Notifications** (bell, with the unread count): Amane is the notification daemon (`org.freedesktop.Notifications`), so dunst must not run; it is commented out in `~/.config/hypr/autocmds.lua` (the old file is beside it as `autocmds.lua.before-amane-notifications`). Popups appear at the top right of the focused monitor with the app's icon or image, summary, body (simple markup) and action buttons. They time out by urgency (critical ones stay) and wait while the pointer is over them. A click runs the default action. The card lists the history, newest first; each entry's cross deletes it, and Clear all deletes everything. Mute app keeps an app's notifications in the history without popups; Block app drops them (`notifications.apps.muted|blocked`, undone from chips at the top of the card). Do not disturb turns all popups off. + +`amane ipc call pods open|toggle ai|monitor|home|notifications [monitor]`, `close`, `ask `, `processes`, `notices list|dismiss |clear`, `status`. Sizes, colours, poll intervals, the city and its coordinates, the model and the popup timeouts are under Side pills and Notifications in the [settings](#settings). + +Left out on purpose: phone stats (iOS shares nothing Linux can read without extra daemons). + ## Settings Every setting lives in `~/.config/amane/config.toml`: sizes, timings, text sizes, colours, poll intervals, equalizer presets and bands, the hotspot profile, the extra Sound output. The shell writes the file with every setting and its explanation the first time it starts (moving `cover_colors`, `transition_ms`, `hotspot` and `extra_output` over from an old `shell.conf`, which it no longer reads). Saving the file applies it at once; a line left out takes its default, and a line the shell can't use is listed at the top of the config window. The font and the cava bar count take effect on restart. diff --git a/src/ai.rs b/src/ai.rs new file mode 100644 index 0000000..d477fa0 --- /dev/null +++ b/src/ai.rs @@ -0,0 +1,519 @@ +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Mutex, PoisonError}; +use std::thread; +use std::time::{Duration, Instant}; + +use amane::{Center, Column, End, Full, Rectangle, Row, Scroll, Service, Stack, Start, Text, TextInput, Widget}; + +use crate::config::appearance::{overlays, text}; +use crate::config::pills::ai as settings; +use crate::glyph::Glyph; +use crate::island::faded; +use crate::parse::{self, Chunk}; +use crate::{pods, theme}; + +/* + * the AI card: a chat with a model on your own ollama servers (pills.ai.servers) through + * their http api (curl, streamed line by line), never a cloud service. a question goes to + * the first server that has the chosen model. a local ollama isn't kept running: it's + * started (doas, the same fixed rc-service rule the control centre uses) when it doesn't + * answer and no other server has the model + */ +#[derive(Clone, Copy, PartialEq)] +pub enum Who { + You, + Model, +} + +pub struct Ai { + pub said: Vec<(Who, String)>, + pub busy: bool, + // what's going on or what went wrong, under the chat + pub note: Option, + // how far the chat is scrolled back from its newest line, in px + back: f32, + // the question being typed, for the send button + typed: String, + // bumped by Stop and New chat, so an answer still streaming stops writing + round: u32, + // (server, model) for every model the servers have, while the model list is open (empty while it loads) + models: Option>, +} + +impl Service for Ai { + fn new() -> Self { + Self { said: Vec::new(), busy: false, note: None, back: 0.0, typed: String::new(), round: 0, models: None } + } + + // changes only through input and the answering thread + fn listen() {} +} + +const INPUT: &str = "ai-input"; + +// the curl streaming the answer now, so Stop can end it +static ASKING: Mutex> = Mutex::new(None); + +// a question from outside (ipc), as if typed +pub fn ask(words: String) { + send(words); +} + +fn send(words: String) { + let words = words.trim().to_string(); + + if words.is_empty() || Ai::read().busy { + return; + } + + TextInput::set_text(INPUT, ""); + + let round = { + let mut ai = Ai::write(); + + ai.said.push((Who::You, words)); + ai.said.push((Who::Model, String::new())); + ai.busy = true; + ai.note = None; + ai.back = 0.0; + ai.typed.clear(); + ai.round + }; + + thread::spawn(move || { + if let Err(why) = answer(round) { + finish(round, Some(why)); + } + }); +} + +fn stop() { + if let Some(mut child) = ASKING.lock().unwrap_or_else(PoisonError::into_inner).take() { + let _ = child.kill(); + let _ = child.wait(); + } + + let mut ai = Ai::write(); + + ai.round += 1; + ai.busy = false; + + // an answer cut off before its first word leaves nothing to show + if ai.said.last().is_some_and(|(who, words)| *who == Who::Model && words.is_empty()) { + ai.said.pop(); + } +} + +fn new_chat() { + stop(); + + let mut ai = Ai::write(); + + ai.said.clear(); + ai.note = None; + ai.back = 0.0; +} + +// on the answering thread: find the model's server (starting a local ollama if needed), then stream the reply into the chat +fn answer(round: u32) -> Result<(), String> { + let model = settings::model(); + + let server = models(Some(&model), |words| note(round, words))? + .into_iter() + .find(|(_, name)| *name == model) + .map(|(server, _)| server) + .ok_or_else(|| format!("None of pills.ai.servers has {model}"))?; + + let body = request(); + + let mut child = Command::new("curl") + .args(["-sN", "-X", "POST", "-H", "Content-Type: application/json", "--data-binary", "@-", &format!("{server}/api/chat")]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("curl: {error}"))?; + + if let Some(mut input) = child.stdin.take() { + let _ = input.write_all(body.as_bytes()); + } + + let output = child.stdout.take().ok_or("curl gave no output")?; + + *ASKING.lock().unwrap_or_else(PoisonError::into_inner) = Some(child); + + for line in BufReader::new(output).lines() { + let Ok(line) = line else { break }; + + match parse::ollama_chunk(&line) { + Some(Chunk::Text(piece)) => { + let mut ai = Ai::write(); + + if ai.round != round { + return Ok(()); + } + + if let Some((Who::Model, words)) = ai.said.last_mut() { + words.push_str(&piece); + } + } + Some(Chunk::Done) => break, + Some(Chunk::Failed(why)) => return Err(why), + None => {} + } + } + + if let Some(mut child) = ASKING.lock().unwrap_or_else(PoisonError::into_inner).take() { + let _ = child.wait(); + } + + let empty = Ai::read().said.last().is_some_and(|(who, words)| *who == Who::Model && words.is_empty()); + + finish(round, empty.then(|| String::from("The model gave no answer"))); + + Ok(()) +} + +// pills.ai.servers, in order, without trailing slashes +fn servers() -> Vec { + settings::servers().split(',').map(|server| server.trim().trim_end_matches('/').to_string()).filter(|server| !server.is_empty()).collect() +} + +fn local(server: &str) -> bool { + server.contains("://localhost") || server.contains("://127.0.0.1") +} + +// the models a server has; none when it doesn't answer +fn models_on(server: &str) -> Option> { + let done = Command::new("curl").args(["-s", "--connect-timeout", "1", "-m", "5", &format!("{server}/api/tags")]).output().ok()?; + + if !done.status.success() { + return None; + } + + let tags = parse::json(&String::from_utf8_lossy(&done.stdout)); + + let names = tags.first().and_then(|tags| tags.get("models")).map(|models| match models { + parse::Value::List(models) => models.iter().filter_map(|model| model.get("name").and_then(parse::Value::text).map(str::to_string)).collect(), + _ => Vec::new(), + }); + + Some(names.unwrap_or_default()) +} + +/* + * (server, model) for the servers' models, in the servers' order, each model once with the + * first server that has it; with `wanted`, only until a server has that model. a local + * server that doesn't answer is started (pills.ai.start_service) when the others don't have + * `wanted`, or for the whole list; `say` tells what's going on + */ +fn models(wanted: Option<&str>, say: impl Fn(&str)) -> Result, String> { + let servers = servers(); + + if servers.is_empty() { + return Err(String::from("pills.ai.servers names no server")); + } + + let mut started = false; + + loop { + let mut found: Vec<(String, String)> = Vec::new(); + let mut silent_local = None; + let mut answered = false; + + for server in &servers { + let Some(names) = models_on(server) else { + if local(server) && silent_local.is_none() { + silent_local = Some(server.clone()); + } + + continue; + }; + + answered = true; + + for name in names { + if !found.iter().any(|(_, known)| *known == name) { + found.push((server.clone(), name)); + } + } + + if wanted.is_some_and(|wanted| found.iter().any(|(_, name)| name == wanted)) { + return Ok(found); + } + } + + if let Some(server) = silent_local.filter(|_| !started && settings::start_service()) { + wake(&server, &say)?; + + started = true; + + continue; + } + + return match (answered, found.is_empty()) { + (false, _) => Err(format!("No Ollama server answers ({})", servers.join(", "))), + (true, true) => Err(String::from("The servers have no models (ollama pull )")), + (true, false) => Ok(found), + }; + } +} + +// starts the local ollama service, telling `say` what's going on, and waits until it answers +fn wake(server: &str, say: impl Fn(&str)) -> Result<(), String> { + if answers(server) { + return Ok(()); + } + + say("Starting ollama…"); + + let _ = Command::new("doas").args(["-n", "rc-service", "ollama", "start"]).output(); + let until = Instant::now() + Duration::from_secs(30); + + while !answers(server) { + if Instant::now() > until { + return Err(String::from("Ollama didn't start (doas rc-service ollama start)")); + } + + thread::sleep(Duration::from_millis(500)); + } + + say(""); + + Ok(()) +} + +// the model list opens (asking ollama which models it has) or closes +fn pick() { + let mut ai = Ai::write(); + + if ai.models.take().is_some() { + return; + } + + ai.models = Some(Vec::new()); + + drop(ai); + + thread::spawn(|| { + let names = models(None, |words| Ai::write().note = (!words.is_empty()).then(|| words.to_string())); + + let mut ai = Ai::write(); + + match names { + // only if the list wasn't closed meanwhile + Ok(names) => { + if ai.models.is_some() { + ai.models = Some(names); + } + } + Err(why) => { + ai.models = None; + ai.note = Some(why); + } + } + }); +} + +// another model from the list; kept in config.toml, used from the next question on +fn choose(name: String) { + Ai::write().models = None; + + let _ = crate::config::set("pills.ai.model", parse::Value::Text(name)); +} + +// whether ollama answers at all, within a second +fn answers(server: &str) -> bool { + Command::new("curl") + .args(["-s", "-m", "1", "-o", "/dev/null", "-w", "%{http_code}", &format!("{server}/api/version")]) + .output() + .is_ok_and(|done| done.stdout == b"200") +} + +// the chat so far as ollama's /api/chat request +fn request() -> String { + let ai = Ai::read(); + + let mut messages = vec![format!("{{\"role\":\"system\",\"content\":{}}}", parse::json_string(&settings::system()))]; + + for (who, words) in &ai.said { + if words.is_empty() { + continue; + } + + let role = if *who == Who::You { "user" } else { "assistant" }; + + messages.push(format!("{{\"role\":\"{role}\",\"content\":{}}}", parse::json_string(words))); + } + + format!("{{\"model\":{},\"stream\":true,\"messages\":[{}]}}", parse::json_string(&settings::model()), messages.join(",")) +} + +fn note(round: u32, words: &str) { + let mut ai = Ai::write(); + + if ai.round == round { + ai.note = (!words.is_empty()).then(|| words.to_string()); + } +} + +fn finish(round: u32, problem: Option) { + let mut ai = Ai::write(); + + if ai.round != round { + return; + } + + ai.busy = false; + ai.note = problem; + + if ai.said.last().is_some_and(|(who, words)| *who == Who::Model && words.is_empty()) { + ai.said.pop(); + } +} + +fn scrolled(step: Scroll) { + let mut ai = Ai::write(); + + ai.back = (ai.back - step.y * 40.0).max(0.0); +} + +/* + * the card's inside: the header, the chat (newest at the bottom, the wheel scrolls back), + * a note line, the question field + */ +pub fn card(width: f32, height: f32) -> Rectangle { + let ai = Ai::read(); + let theme = theme::now(); + + // the model, as a chip that opens the list of the others + // the model, as a chip that opens the list of the others; its arrow points up while the list is open + let arrow = if ai.models.is_some() { Glyph::ChevronUp } else { Glyph::Chevron }; + let mut buttons: Vec> = vec![Box::new(pods::chip(&settings::model(), Some(arrow), pick))]; + + if ai.busy { + buttons.push(Box::new(pods::chip("Stop", None, stop))); + } else if !ai.said.is_empty() { + buttons.push(Box::new(pods::chip("New chat", None, new_chat))); + } + + let header = pods::header("AI", buttons, width); + + let field_h = 40.0; + let note_h = text::small() + 8.0; + let chat_h = height - pods::header_height() - field_h - note_h - 3.0 * 8.0; + + // the bubbles, measured so the newest sit at the bottom + let bubble_w = width * 0.86; + let mut bubbles: Vec> = Vec::new(); + let mut total = 0.0; + + for (who, words) in &ai.said { + let shown = if words.is_empty() { String::from("…") } else { words.clone() }; + let words = Text::new(shown).size(text::body()).color(theme.text).wrap(); + let words_h = words.height_in(bubble_w - 24.0); + let mine = *who == Who::You; + + let bubble = Rectangle::new() + .width(bubble_w) + .height(words_h + 16.0) + .radius(14.0) + .fill(if mine { faded(theme.accent, 70) } else { overlays::chip() }) + .padding(8.0) + .child(Rectangle::new().width(bubble_w - 24.0).height(words_h).child(words)); + + total += words_h + 16.0 + 8.0; + // yours on the right, the model's on the left + let row = Rectangle::new().width(width).height(words_h + 16.0); + let row = if mine { row.align_child(End, Start) } else { row.align_child(Start, Start) }; + + bubbles.push(Box::new(row.child(bubble))); + } + + let chat: Box = if let Some(models) = &ai.models { + let current = settings::model(); + let several = servers().len() > 1; + let mut rows: Vec> = vec![Box::new(Text::new(if models.is_empty() { "Asking the servers for their models…" } else { "Chat with" }).size(text::small()).color(theme.dim))]; + + for (server, name) in models { + let chosen = *name == current; + let picked = name.clone(); + + // with more than one server, where the model is, without the scheme + let host = if several { server.split("://").last().unwrap_or(server) } else { "" }; + let host_w = host.chars().count() as f32 * text::small() * crate::config::appearance::font::letter_width(); + + rows.push(Box::new( + Rectangle::new() + .width(width) + .height(34.0) + .radius(12.0) + .fill(if chosen { faded(theme.accent, 70) } else { overlays::chip() }) + .padding(amane::Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 12.0 }) + .align_child(Start, Center) + .cursor(amane::Pointer) + .on_click(move |_| choose(picked.clone())) + .child( + Row::new(vec![ + Box::new(Rectangle::new().width(width - 24.0 - host_w - 12.0).height(34.0).align_child(Start, Center).child(Text::new(name.as_str()).size(text::body()).color(theme.text).elide())) as Box, + Box::new(Text::new(host).size(text::small()).color(theme.dim)), + ]) + .gap(12.0) + .align(Center), + ), + )); + } + + Box::new(Rectangle::new().width(width).height(chat_h).clip().child(Column::new(rows).gap(6.0))) + } else if bubbles.is_empty() { + Box::new(Rectangle::new().width(width).height(chat_h).align_child(Center, Center).child(Text::new(if servers().iter().all(|server| local(server)) { format!("Ask {} anything; it runs on this computer", settings::model()) } else { format!("Ask {} anything; it runs on your own servers", settings::model()) }).size(text::small()).color(theme.dim))) + } else { + // the column's bottom on the area's bottom, moved down by the scroll back; what's above is cut + let back = ai.back.min((total - chat_h).max(0.0)); + let column = Column::new(bubbles).gap(8.0); + + Box::new( + Rectangle::new() + .width(width) + .height(chat_h) + .clip() + .on_scroll(scrolled) + .child(Stack::new(vec![Box::new(Rectangle::new().width(width).height(total).translate(0.0, chat_h - total + back).child(column)) as Box]).width(width).height(chat_h)), + ) + }; + + let note = Text::new(ai.note.clone().unwrap_or_else(|| if ai.busy { String::from("Thinking…") } else { String::new() })).size(text::small()).color(theme.dim).elide(); + + let input = TextInput::new(INPUT).width(width - 2.0 * 14.0 - 34.0).size(text::body()).color(theme.text).placeholder(format!("Ask {}…", settings::model())).focused().on_change(|words| Ai::write().typed = words).on_submit(send); + + let field = Rectangle::new() + .width(width) + .height(field_h) + .radius(Full) + .fill(overlays::chip()) + .padding(amane::Padding { top: 0.0, right: 6.0, bottom: 0.0, left: 14.0 }) + .align_child(Start, Center) + .child( + Row::new(vec![ + Box::new(input) as Box, + Box::new(pods::button(Glyph::Send, || { + let typed = Ai::read().typed.clone(); + + send(typed); + })), + ]) + .gap(6.0) + .align(Center), + ); + + Rectangle::new().width(width).height(height).child( + Column::new(vec![ + Box::new(header) as Box, + chat, + Box::new(Rectangle::new().width(width).height(note_h).align_child(Start, Center).child(note)), + Box::new(field), + ]) + .gap(8.0), + ) +} diff --git a/src/config.rs b/src/config.rs index adceb8c..855b583 100644 --- a/src/config.rs +++ b/src/config.rs @@ -425,9 +425,6 @@ settings! { slider_height: f32 = 28.0, 8..=100; "Height of a slider" keys_height: f32 = 26.0, 8..=100; "Height of the keyboard light's buttons" profile_height: f32 = 48.0, 16..=200; "Height of the performance profile buttons" - vram_height: f32 = 14.0, 4..=60; "Height of the external GPU's memory line" - row_height: f32 = 22.0, 8..=100; "Height of a program row under the external GPU" - max_rows: usize = 5, 1..=50; "Programs listed under the external GPU before \"and N more\"" } menus { pad: f32 = 12.0, 0..=100; "Padding inside a menu" @@ -502,6 +499,120 @@ settings! { media_check_ms: u64 = 300, 50..=5000; "How often the song is checked for a change, for the island's peek" } } + + pills { + layout { + gap: f32 = 10.0, 0..=200; "Space between the island's pill and the small pills beside it" + spacing: f32 = 8.0, 0..=100; "Space between the small pills on one side" + icon: f32 = 18.0, 6..=40; "Size of the small pills' icons" + open_ms: u64 = 420, 0..=5000; "How long a small pill takes to open into its card, and to close" + radius: f32 = 28.0, 0..=60; "Corner radius of the opened cards" + pad: f32 = 24.0, 0..=60; "Padding inside the opened cards" + } + colors { + ai: Color = Color::rgb(160, 128, 255); "The AI pill's own colour, mixed into its pill and card" + monitor: Color = Color::rgb(64, 208, 190); "The system monitor pill's own colour" + notifications: Color = Color::rgb(255, 128, 104); "The notification pill's own colour" + home_clear_day: Color = Color::rgb(255, 190, 92); "The home pill's colour under a clear or partly cloudy day sky" + home_clear_night: Color = Color::rgb(104, 118, 230); "The home pill's colour on a clear night" + home_cloudy: Color = Color::rgb(150, 170, 200); "The home pill's colour under clouds (and before the weather is known)" + home_fog: Color = Color::rgb(176, 176, 190); "The home pill's colour in fog" + home_rain: Color = Color::rgb(84, 156, 244); "The home pill's colour in rain" + home_snow: Color = Color::rgb(206, 232, 255); "The home pill's colour in snow" + home_storm: Color = Color::rgb(160, 104, 230); "The home pill's colour in a storm" + pill_mix: f32 = 0.55, 0..=1; "How much of its own colour a small pill takes (0 = the wallpaper's only)" + card_mix: f32 = 0.28, 0..=1; "How much of its pill's colour an open card takes" + } + ai { + width: f32 = 760.0, 280..=1400; "Width of the AI card" + height: f32 = 680.0, 200..=1200; "Height of the AI card" + servers: String = String::from("http://localhost:11434"); "The Ollama servers the AI card talks to, comma-separated, in order: the model list shows all their models, a question goes to the first that has the chosen one" + model: String = String::from("gemma4:e4b"); "The Ollama model to chat with, with its tag (ollama list shows them)" + start_service: bool = true; "Start the local ollama service (doas rc-service ollama start) when it isn't answering" + system: String = String::from("You are a helpful assistant inside a Linux desktop shell. Answer briefly and plainly."); "What the model is told before every chat" + } + monitor { + width: f32 = 620.0, 280..=1400; "Width of the system monitor card" + interval_ms: u64 = 1000, 200..=10000; "How often the system monitor reads the system" + history: usize = 60, 10..=600; "How many readings the graphs show" + graph_height: f32 = 46.0, 10..=200; "Height of each graph" + temperatures: usize = 6, 0..=20; "How many temperature sensors to show, always in the same order: CPU, iGPU, eGPU, NVMe, memory, board, then the rest" + warm: f32 = 65.0, 20..=120; "From this temperature (°C) a sensor shows in the warm colour" + hot: f32 = 85.0, 20..=130; "From this temperature (°C) a sensor shows in the hot colour" + cool_color: Color = Color::rgb(120, 200, 255); "Colour of a sensor below the warm temperature" + warm_color: Color = Color::rgb(255, 196, 92); "Colour of a warm sensor" + hot_color: Color = Color::rgb(255, 105, 97); "Colour of a hot sensor" + programs: usize = 5, 1..=50; "Programs on the external GPU listed before the rest are counted" + terminal: String = String::from("ghostty"); "The terminal that runs the process list" + processes: String = String::from("btm --default_widget_type proc --expanded"); "The process list command, opened on a special workspace" + workspace: String = String::from("processes"); "The special workspace the process list opens on" + } + home { + width: f32 = 880.0, 300..=1600; "Width of the home card. It opens in the middle with the AI, system and island pills on its left, which on a 1920 px wide monitor leaves room for about 900 beside the workspace pill" + clock_size: f32 = 64.0, 20..=200; "Size of the big clock" + clock_font: String = String::new(); "The big clock's font family, any installed one (fc-list : family lists them); empty takes the shell's font" + clock_weight: u64 = 700, 100..=900; "The big clock's weight, 100 (thin) to 900 (black); a font without that weight shows its nearest one" + clock_letter_width: f32 = 0.0, 0..=2; "Width of one of the clock's digits as a share of its size, so the weather beside it makes room; 0 takes the shell font's" + seconds: bool = false; "Show seconds on the big clock" + avatar: String = String::from("~/.face"); "The picture shown as the user's avatar; a drawn person without one" + avatar_size: f32 = 84.0, 24..=200; "Size of the avatar; the greeting row is as tall" + gap: f32 = 12.0, 0..=60; "Space between the home card's parts" + city: String = String::from("Munich"); "The weather's place, as shown" + latitude: f32 = 48.137, -90.0..=90.0; "The weather's latitude" + longitude: f32 = 11.575, -180.0..=180.0; "The weather's longitude" + weather_minutes: u64 = 20, 5..=360; "How often the weather is fetched, in minutes" + weather_height: f32 = 60.0, 30..=200; "Height of the weather now" + weather_icon: f32 = 44.0, 16..=120; "Size of the icon of the weather now" + temperature_size: f32 = 28.0, 10..=100; "Size of the temperature now" + forecast_height: f32 = 64.0, 30..=200; "Height of the coming days' weather" + forecast_icon: f32 = 22.0, 10..=80; "Size of the coming days' weather icons" + photo: bool = true; "Show a photo of the weather now behind the home card" + photos: String = String::from("~/.config/amane/weather"); "Folder of the weather photos: -.jpg for clear, partly-cloudy, cloudy, fog, rain, snow and storm. A missing one is downloaded once from Wikimedia Commons (credits in the repo's weather/CREDITS.md); a file that's there is never replaced, so put your own in its place (the shell shows a swapped file after a restart)" + photo_opacity: f32 = 0.45, 0..=1; "How much the weather photo shows through behind the home card: the card's colour veils the rest, 0 hides it" + } + calendar { + server: String = String::from("https://caldav.icloud.com/"); "The CalDAV server; iCloud's by default" + contacts_server: String = String::from("https://contacts.icloud.com/"); "The CardDAV server the birthdays come from, with the same account; iCloud's by default" + account: String = String::new(); "Your Apple ID (or CalDAV user). Store its app-specific password (account.apple.com > Sign-In and Security > App-Specific Passwords) in the keyring with: secret-tool store --label='Magic Island calendar' service amane-calendar account " + sync_minutes: u64 = 15, 1..=360; "How often the calendar and the birthdays are synced, in minutes" + holidays: String = String::from("BY"); "The German state whose public holidays the calendar marks: BW, BY, BE, BB, HB, HH, HE, MV, NI, NW, RP, SL, SN, ST, SH or TH; empty marks none" + birthdays: bool = true; "Show the birthdays of your contacts" + cell: f32 = 52.0, 32..=96; "Size of a day in the month grid; the card grows with it" + disc: f32 = 0.72, 0.3..=1.0; "Size of today's disc and the selected day's ring, as a share of a day" + ring: f32 = 2.0, 0.5..=6.0; "Thickness of the selected day's ring" + dot: f32 = 5.0, 2..=16; "Size of the dots under a day with events, one per calendar" + dots: usize = 3, 1..=6; "Most dots under one day" + mark: f32 = 13.0, 6..=30; "Size of the cake on a day with a birthday" + list_gap: f32 = 24.0, 0..=100; "Space between the month grid and the selected day's list" + item_gap: f32 = 8.0, 0..=40; "Space between the selected day's items" + field_height: f32 = 32.0, 20..=60; "Height of the event form's text fields" + length_minutes: u64 = 60, 5..=1440; "How long a new event lasts until you change its end" + holiday_color: Color = Color::rgb(255, 122, 104); "Colour of public holidays: the day's number, its tint and flag" + holiday_tint: f32 = 0.2, 0..=1; "How strongly a public holiday is tinted in its colour" + birthday_color: Color = Color::rgb(255, 138, 196); "Colour of birthdays: the cake on the day and in its list" + selected_color: Color = Color::rgba(255, 255, 255, 220); "Colour of the selected day's ring" + } + } + + notifications { + popups { + enabled: bool = true; "Show notifications as popups; off is do not disturb (they still go to the history)" + timeout_ms: u64 = 6000, 1000..=60000; "How long a normal notification's popup stays" + low_timeout_ms: u64 = 4000, 1000..=60000; "How long a low-urgency popup stays" + critical_stays: bool = true; "Critical notifications stay until dismissed" + width: f32 = 380.0, 240..=900; "Width of the popups" + count: usize = 4, 1..=10; "How many popups show at once" + icon: f32 = 40.0, 16..=128; "Size of a notification's icon or picture" + } + apps { + muted: String = String::new(); "Apps whose notifications don't pop up but stay in the history, comma-separated (Mute app on a notification adds it)" + blocked: String = String::new(); "Apps whose notifications are dropped at once, comma-separated (Block app on a notification adds it)" + } + history { + width: f32 = 620.0, 280..=1400; "Width of the notification card" + height: f32 = 700.0, 200..=1400; "Height of the notification card" + } + } } // what config.toml set, by path; a missing or bad setting takes its default @@ -869,6 +980,7 @@ pub fn modules() -> Vec<&'static str> { pub fn title(id: &str) -> String { match id { "control" => String::from("Control centre"), + "pills" => String::from("Side pills"), "bar" => String::from("Bar hiding"), "input-capture" => String::from("Input capture"), "opengl" => String::from("OpenGL"), diff --git a/src/control.rs b/src/control.rs index 311ac2a..ddf5ef2 100644 --- a/src/control.rs +++ b/src/control.rs @@ -2,7 +2,7 @@ * the control centre: a status pill in the top right corner of every monitor (wi-fi, * vpn, bluetooth, battery) that opens, on the monitor it was clicked on, into a panel * like macOS's: connection tiles, sound, the screen and keyboard light, the performance - * mode with the battery, and the external gpu with the programs using it. it opens on + * mode with the battery (the external gpu moved to the system monitor pill). it opens on * the island's timeline (island::phases): pill out, the empty shape grows, panel in */ use std::collections::HashMap; @@ -26,7 +26,7 @@ use crate::parse; use crate::prompt; use crate::settings; use crate::sound::Sound; -use crate::system::{self, Charge, Gpu, Links, Power, VpnState}; +use crate::system::{self, Charge, Links, Power, VpnState}; use crate::{surface, theme}; // every size and timing is a setting (config.rs: control.*); these follow from them @@ -266,12 +266,11 @@ struct Layout { keyboard: bool, profiles: bool, battery: bool, - gpu_rows: Option, } impl Layout { fn largest() -> Layout { - Layout { ethernet: true, extra_output: true, screen: true, keyboard: true, profiles: true, battery: true, gpu_rows: Some(c::modules::max_rows()) } + Layout { ethernet: true, extra_output: true, screen: true, keyboard: true, profiles: true, battery: true } } fn sound(&self) -> f32 { @@ -292,12 +291,8 @@ impl Layout { module_height(&parts) } - fn gpu(&self) -> Option { - self.gpu_rows.map(|rows| 2.0 * c::modules::pad() + c::modules::title_height() + c::modules::vram_height() + rows.max(1) as f32 * c::modules::row_height() + 2.0 * c::modules::line_gap()) - } - fn height(&self) -> f32 { - let modules: f32 = [Some(self.sound()), self.display(), self.performance(), self.gpu()].into_iter().flatten().map(|height| c::panel::gap() + height).sum(); + let modules: f32 = [Some(self.sound()), self.display(), self.performance()].into_iter().flatten().map(|height| c::panel::gap() + height).sum(); let tile_rows = if self.ethernet { 3.0 } else { 2.0 }; // the Settings button closes the panel @@ -321,7 +316,6 @@ struct Scene<'a> { bluetooth: &'a Bluetooth, audio: &'a Audio, screen: &'a Brightness, - gpu: &'a Gpu, hotspot: &'a str, extra_output: Option<&'a parse::Sink>, layout: Layout, @@ -375,7 +369,6 @@ pub fn view(monitor: &Monitor) -> LayerWindow { let audio = open.then(Audio::read); let screen = open.then(Brightness::read); - let gpu = open.then(Gpu::read); let settings = open.then(|| (c::services::hotspot(), c::services::extra_output())); let sound = open.then(Sound::read); @@ -391,7 +384,6 @@ pub fn view(monitor: &Monitor) -> LayerWindow { keyboard: power.keyboard.is_some(), profiles: !power.profiles.is_empty(), battery: power.battery.is_some(), - gpu_rows: gpu.as_deref().filter(|gpu| !gpu.name.is_empty()).map(|gpu| gpu.programs.len().min(c::modules::max_rows())), }; /* @@ -427,8 +419,8 @@ pub fn view(monitor: &Monitor) -> LayerWindow { let colors = theme::now().surfaces(); // at most one face at a time, each faded by a veil instead of a gpu layer - let face = match (&audio, &screen, &gpu, &settings) { - (Some(audio), Some(screen), Some(gpu), Some((hotspot, _))) if reveal > 0.0 && fade > 0.0 => { + let face = match (&audio, &screen, &settings) { + (Some(audio), Some(screen), Some((hotspot, _))) if reveal > 0.0 && fade > 0.0 => { let scene = Scene { control: &control, links: &links, @@ -436,7 +428,6 @@ pub fn view(monitor: &Monitor) -> LayerWindow { bluetooth: &bluetooth, audio, screen, - gpu, hotspot, extra_output, layout, @@ -880,10 +871,6 @@ fn panel(scene: &Scene) -> Rectangle { blocks.push(Box::new(performance(scene, height))); } - if let Some(height) = scene.layout.gpu() { - blocks.push(Box::new(gpu(scene, height))); - } - blocks.push(Box::new(settings_button(scene.control))); let height = scene.layout.height(); @@ -1480,84 +1467,6 @@ fn battery_words(charge: &Charge) -> String { } } -// the external gpu: its load, memory and temperature, and who uses it - -fn gpu(scene: &Scene, height: f32) -> Rectangle { - let theme = theme::now(); - let gpu = scene.gpu; - - let mut facts = vec![format!("{}%", gpu.busy)]; - - if let Some(celsius) = gpu.celsius { - facts.push(format!("{celsius}°C")); - } - - if let Some(watts) = gpu.watts { - facts.push(format!("{watts:.0} W")); - } - - let title = Row::new(children![icon(Glyph::Chip, 15.0, theme.accent), Text::new(gpu.name.as_str()).size(text::body()).weight(Weight::Bold).color(theme.text).elide()]).gap(8.0).align(Center); - - let top = Row::new(children![title, note(facts.join(" · "))]).justify(SpaceBetween).align(Center).width(module_w()).height(c::modules::title_height()); - - let share = if gpu.vram_total > 0 { gpu.vram_used as f32 / gpu.vram_total as f32 } else { 0.0 }; - - let memory_label = format!("{} of {}", size(gpu.vram_used / 1024), size(gpu.vram_total / 1024)); - let memory_w = label_width(&memory_label, 10.5, 4.0); - let bar_w = module_w() - memory_w - 10.0; - - let memory = Row::new(children![ - Rectangle::new().width(bar_w).height(6.0).radius(Full).fill(overlays::track()).child(Rectangle::new().width((bar_w * share).max(6.0)).height(6.0).radius(Full).fill(theme.accent)), - Text::new(memory_label).size(text::caption()).color(theme.dim), - ]) - .gap(10.0) - .align(Center) - .width(module_w()) - .height(c::modules::vram_height()); - - let mut rows: Vec> = Vec::new(); - - let shown = if gpu.programs.len() > c::modules::max_rows() { c::modules::max_rows() - 1 } else { c::modules::max_rows() }; - - for program in gpu.programs.iter().take(shown) { - let mut amount = size(program.vram_kib); - - if let Some(busy) = program.busy { - amount = format!("{amount} · {busy}%"); - } - - rows.push(Box::new( - Row::new(children![ - Text::new(program.name.as_str()).size(text::body()).color(theme.text).elide(), - note(amount), - ]) - .justify(SpaceBetween) - .align(Center) - .width(module_w()) - .height(c::modules::row_height()), - )); - } - - if gpu.programs.len() > c::modules::max_rows() { - rows.push(Box::new(Rectangle::new().width(module_w()).height(c::modules::row_height()).align_child(Start, Center).child(note(format!("and {} more", gpu.programs.len() - shown))))); - } - - if gpu.programs.is_empty() { - rows.push(Box::new(Rectangle::new().width(module_w()).height(c::modules::row_height()).align_child(Start, Center).child(note("No programs on it")))); - } - - module(height, Column::new(children![top, memory, Column::new(rows)]).gap(c::modules::line_gap())) -} - -// video memory in KiB, the way a person reads it -fn size(kib: u64) -> String { - match kib { - 0..1024 => String::from("<1 MB"), - 1024..1_048_576 => format!("{} MB", kib / 1024), - _ => format!("{:.1} GB", kib as f64 / 1_048_576.0), - } -} - /* * the menus: a window of their own per monitor, over the panel, so the panel never * re-lays out. a menu grows out of the tile or block that opened it (the island's diff --git a/src/glyph.rs b/src/glyph.rs index 4d8a916..44497a4 100644 --- a/src/glyph.rs +++ b/src/glyph.rs @@ -12,6 +12,7 @@ pub enum Glyph { Muted, Bluetooth, Chevron, + ChevronUp, // pointing left, for a menu's way back Back, Note, @@ -32,6 +33,35 @@ pub enum Glyph { Ethernet, // a cog, for the config window Gear, + + // the side pills: a four-pointed spark for the AI, a pulse line for the system monitor, + // a house for home, a bell for notifications + Sparkle, + Pulse, + House, + Bell, + // a cross to close or delete, an arrow to send, a prompt for a terminal, a head and shoulders + Cross, + Send, + Terminal, + Person, + // the sky + Moon, + Cloud, + PartCloud, + Rain, + Snow, + Storm, + Fog, + Thermometer, + Drive, + Memory, + Gpu, + Plus, + Pencil, + Trash, + Cake, + Flag, } pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas { @@ -109,6 +139,7 @@ pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin: .cap(Cap::Round) ], Glyph::Chevron => shapes![Path::new().move_to(x(0.12), y(0.32)).line_to(x(0.5), y(0.7)).line_to(x(0.88), y(0.32)).stroke(1.5, color).cap(Cap::Round)], + Glyph::ChevronUp => shapes![Path::new().move_to(x(0.12), y(0.68)).line_to(x(0.5), y(0.3)).line_to(x(0.88), y(0.68)).stroke(1.5, color).cap(Cap::Round)], Glyph::Back => shapes![Path::new().move_to(x(0.64), y(0.14)).line_to(x(0.3), y(0.5)).line_to(x(0.64), y(0.86)).stroke(thin.max(1.6), color).cap(Cap::Round)], // an eighth note: round head, stem, a curved flag Glyph::Note => shapes![ @@ -234,5 +265,160 @@ pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin: parts } + Glyph::Sparkle => shapes![ + Path::new() + .move_to(x(0.5), y(0.06)) + .quad_to(x(0.54), y(0.46), x(0.94), y(0.5)) + .quad_to(x(0.54), y(0.54), x(0.5), y(0.94)) + .quad_to(x(0.46), y(0.54), x(0.06), y(0.5)) + .quad_to(x(0.46), y(0.46), x(0.5), y(0.06)) + .close() + .fill(color) + ], + Glyph::Pulse => shapes![ + Path::new() + .move_to(x(0.04), y(0.54)) + .line_to(x(0.28), y(0.54)) + .line_to(x(0.38), y(0.24)) + .line_to(x(0.54), y(0.8)) + .line_to(x(0.66), y(0.42)) + .line_to(x(0.74), y(0.54)) + .line_to(x(0.96), y(0.54)) + .stroke(thin, color) + .cap(Cap::Round) + ], + Glyph::House => shapes![ + Path::new() + .move_to(x(0.1), y(0.48)) + .line_to(x(0.5), y(0.12)) + .line_to(x(0.9), y(0.48)) + .stroke(thin, color) + .cap(Cap::Round), + Path::new() + .move_to(x(0.22), y(0.4)) + .line_to(x(0.22), y(0.88)) + .line_to(x(0.42), y(0.88)) + .line_to(x(0.42), y(0.64)) + .line_to(x(0.58), y(0.64)) + .line_to(x(0.58), y(0.88)) + .line_to(x(0.78), y(0.88)) + .line_to(x(0.78), y(0.4)) + .stroke(thin, color), + ], + Glyph::Bell => shapes![ + Path::new() + .move_to(x(0.16), y(0.74)) + .quad_to(x(0.26), y(0.62), x(0.26), y(0.46)) + .quad_to(x(0.26), y(0.16), x(0.5), y(0.16)) + .quad_to(x(0.74), y(0.16), x(0.74), y(0.46)) + .quad_to(x(0.74), y(0.62), x(0.84), y(0.74)) + .close() + .fill(color), + Circle::new().center(x(0.5), y(0.84)).radius(0.08 * s).fill(color), + line(0.5, 0.08, 0.5, 0.16), + ], + Glyph::Cross => shapes![line(0.24, 0.24, 0.76, 0.76), line(0.76, 0.24, 0.24, 0.76)], + // a tube with its bulb + Glyph::Thermometer => shapes![ + Path::new().move_to(x(0.4), y(0.62)).line_to(x(0.4), y(0.16)).quad_to(x(0.4), y(0.06), x(0.5), y(0.06)).quad_to(x(0.6), y(0.06), x(0.6), y(0.16)).line_to(x(0.6), y(0.62)).stroke(thin, color), + Circle::new().center(x(0.5), y(0.76)).radius(0.17 * s).fill(color), + line(0.5, 0.3, 0.5, 0.7), + ], + // a flat drive with its connector + Glyph::Drive => shapes![frame(0.08, 0.3, 0.92, 0.7).stroke(thin, color), line(0.2, 0.5, 0.2, 0.5), line(0.66, 0.5, 0.8, 0.5)], + // a memory stick: a board with chips and pins + Glyph::Memory => shapes![frame(0.06, 0.28, 0.94, 0.62).stroke(thin, color), frame(0.18, 0.38, 0.34, 0.52).fill(color), frame(0.42, 0.38, 0.58, 0.52).fill(color), frame(0.66, 0.38, 0.82, 0.52).fill(color), line(0.2, 0.62, 0.2, 0.76), line(0.4, 0.62, 0.4, 0.76), line(0.6, 0.62, 0.6, 0.76), line(0.8, 0.62, 0.8, 0.76)], + // a graphics card: a board with a fan + Glyph::Gpu => shapes![frame(0.06, 0.24, 0.94, 0.72).stroke(thin, color), Circle::new().center(x(0.4), y(0.48)).radius(0.14 * s).stroke(thin, color), line(0.66, 0.4, 0.8, 0.4), line(0.66, 0.56, 0.8, 0.56), line(0.16, 0.72, 0.16, 0.86)], + Glyph::Plus => shapes![line(0.5, 0.2, 0.5, 0.8), line(0.2, 0.5, 0.8, 0.5)], + Glyph::Pencil => shapes![ + Path::new().move_to(x(0.2), y(0.8)).line_to(x(0.24), y(0.62)).line_to(x(0.66), y(0.2)).line_to(x(0.8), y(0.34)).line_to(x(0.38), y(0.76)).close().stroke(thin, color), + line(0.58, 0.28, 0.72, 0.42), + ], + Glyph::Trash => shapes![ + Path::new().move_to(x(0.24), y(0.3)).line_to(x(0.3), y(0.88)).line_to(x(0.7), y(0.88)).line_to(x(0.76), y(0.3)).stroke(thin, color), + line(0.14, 0.3, 0.86, 0.3), + line(0.4, 0.14, 0.6, 0.14), + line(0.44, 0.44, 0.44, 0.74), + line(0.56, 0.44, 0.56, 0.74), + ], + // a cake with a candle, for birthdays + Glyph::Cake => shapes![frame(0.14, 0.5, 0.86, 0.86).fill(color), line(0.5, 0.26, 0.5, 0.44), Circle::new().center(x(0.5), y(0.16)).radius(0.07 * s).fill(color)], + // a flag on a pole, for holidays + Glyph::Flag => shapes![line(0.22, 0.1, 0.22, 0.9), Path::new().move_to(x(0.22), y(0.12)).line_to(x(0.82), y(0.12)).line_to(x(0.68), y(0.3)).line_to(x(0.82), y(0.48)).line_to(x(0.22), y(0.48)).close().fill(color)], + Glyph::Send => shapes![ + Path::new().move_to(x(0.1), y(0.14)).line_to(x(0.92), y(0.5)).line_to(x(0.1), y(0.86)).line_to(x(0.24), y(0.5)).close().fill(color) + ], + Glyph::Terminal => shapes![ + frame(0.06, 0.14, 0.94, 0.86).stroke(thin, color), + Path::new().move_to(x(0.22), y(0.36)).line_to(x(0.38), y(0.5)).line_to(x(0.22), y(0.64)).stroke(thin, color).cap(Cap::Round), + line(0.46, 0.66, 0.7, 0.66), + ], + Glyph::Person => shapes![ + Circle::new().center(x(0.5), y(0.34)).radius(0.18 * s).fill(color), + Path::new().move_to(x(0.14), y(0.92)).quad_to(x(0.18), y(0.58), x(0.5), y(0.58)).quad_to(x(0.82), y(0.58), x(0.86), y(0.92)).close().fill(color), + ], + Glyph::Moon => shapes![ + Path::new() + .move_to(x(0.62), y(0.1)) + .quad_to(x(0.12), y(0.16), x(0.16), y(0.56)) + .quad_to(x(0.3), y(0.94), x(0.86), y(0.72)) + .quad_to(x(0.4), y(0.64), x(0.62), y(0.1)) + .close() + .fill(color) + ], + Glyph::Cloud => shapes![cloud(&x, &y, 0.0, color)], + Glyph::PartCloud => { + let mut parts: Vec> = shapes![Circle::new().center(x(0.34), y(0.36)).radius(0.16 * s).fill(color)]; + + for ray in 0..6 { + let (sin, cos) = (ray as f32 * std::f32::consts::FRAC_PI_3).sin_cos(); + + parts.push(Box::new(line(0.34 + 0.23 * sin, 0.36 - 0.23 * cos, 0.34 + 0.31 * sin, 0.36 - 0.31 * cos))); + } + + parts.push(Box::new(cloud(&x, &y, 0.12, color))); + + parts + } + Glyph::Rain => { + let mut parts: Vec> = shapes![cloud(&x, &y, -0.14, color)]; + + for drop in [0.32, 0.5, 0.68] { + parts.push(Box::new(line(drop, 0.7, drop - 0.06, 0.88))); + } + + parts + } + Glyph::Snow => { + let mut parts: Vec> = shapes![cloud(&x, &y, -0.14, color)]; + + for flake in [0.32, 0.5, 0.68] { + parts.push(Box::new(Circle::new().center(x(flake), y(0.8)).radius(0.05 * s).fill(color))); + } + + parts + } + Glyph::Storm => shapes![ + cloud(&x, &y, -0.14, color), + Path::new().move_to(x(0.56), y(0.6)).line_to(x(0.42), y(0.78)).line_to(x(0.54), y(0.78)).line_to(x(0.46), y(0.96)).stroke(thin, color).cap(Cap::Round), + ], + Glyph::Fog => shapes![line(0.14, 0.34, 0.86, 0.34), line(0.22, 0.5, 0.78, 0.5), line(0.14, 0.66, 0.86, 0.66)], } } + +// a cloud, `drop` of the box lower (or higher, negative) than its middle +fn cloud(x: &impl Fn(f32) -> f32, y: &impl Fn(f32) -> f32, drop: f32, color: Color) -> Path { + let y = |k: f32| y(k + drop); + + Path::new() + .move_to(x(0.2), y(0.74)) + .quad_to(x(0.04), y(0.72), x(0.08), y(0.56)) + .quad_to(x(0.14), y(0.44), x(0.28), y(0.48)) + .quad_to(x(0.34), y(0.26), x(0.54), y(0.28)) + .quad_to(x(0.74), y(0.3), x(0.76), y(0.48)) + .quad_to(x(0.96), y(0.5), x(0.92), y(0.66)) + .quad_to(x(0.88), y(0.74), x(0.78), y(0.74)) + .close() + .fill(color) +} diff --git a/src/home.rs b/src/home.rs new file mode 100644 index 0000000..d73c279 --- /dev/null +++ b/src/home.rs @@ -0,0 +1,1882 @@ +use std::collections::HashMap; +use std::env; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{Mutex, PoisonError}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use amane::{Canvas, Center, Circle, Color, Column, Full, Image, Padding, Pointer, Rectangle, Row, Service, Shape, SpaceBetween, Stack, Start, Text, TextInput, Weight, Widget}; + +use crate::config::appearance::{font, overlays, text}; +use crate::config::pills::{calendar as agenda_settings, home as settings, layout}; +use crate::config; +use crate::glyph::{Glyph, glyph_shapes}; +use crate::island::{CLEAR, faded}; +use crate::parse::{self, Birthday, Change, Sky, Weather, When}; +use crate::{pods, theme}; + +/* + * the home card: who's here, a big clock and the date, the weather (open-meteo, no key) over a + * photo of it, and a month of the calendar: the events of every caldav calendar (icloud's by + * default) to read, add, change and delete, the contacts' birthdays (carddav) and german public + * holidays worked out here. the app-specific password is read from the keyring (secret-tool) + * and goes to curl on stdin, never into a file, an argument or a log + */ + +// local time, refreshed by the minute (by the second with seconds shown) +#[derive(Default)] +pub struct Clock { + // seconds since 1970 in local time, and the local offset from utc + now: i64, + offset: i64, + avatar: Option, +} + +impl Service for Clock { + fn new() -> Self { + Self::default() + } + + fn listen() { + let mut ticks = 0; + + loop { + // the offset (summer time) and the avatar file can change; look again each minute + if ticks % 60 == 0 { + let offset = utc_offset(); + let avatar = avatar_file(); + + let mut clock = Self::write(); + + clock.offset = offset; + clock.avatar = avatar; + } + + let unix = now_unix(); + + { + let mut clock = Self::write(); + + clock.now = unix + clock.offset; + } + + let wait = if settings::seconds() { 1 } else { 60 - (unix % 60) }; + + ticks += wait; + thread::sleep(Duration::from_secs(wait as u64)); + } + } +} + +fn now_unix() -> i64 { + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_secs() as i64) +} + +// the local offset from utc in seconds now, from `date +%z` (+0200) +fn utc_offset() -> i64 { + let printed = Command::new("date").arg("+%z").output().map(|done| String::from_utf8_lossy(&done.stdout).into_owned()).unwrap_or_default(); + + parse::zone_offset(&printed).unwrap_or(0) +} + +// today as days since 1970, in local time; on a service thread +fn local_today() -> i64 { + (now_unix() + utc_offset()).div_euclid(86_400) +} + +fn home_dir() -> PathBuf { + env::var_os("HOME").map(PathBuf::from).unwrap_or_default() +} + +// a path from the settings, ~/ meaning the home folder +fn expanded(path: &str) -> PathBuf { + match path.strip_prefix("~/") { + Some(rest) => home_dir().join(rest), + None => PathBuf::from(path), + } +} + +// the avatar picture, if the file is there +fn avatar_file() -> Option { + let path = expanded(&settings::avatar()); + + path.is_file().then(|| path.to_string_lossy().into_owned()) +} + +#[derive(Default)] +pub struct Forecast { + weather: Option, + problem: Option, + // the photo of the sky now, once its file is there + photo: Option, +} + +impl Service for Forecast { + fn new() -> Self { + Self::default() + } + + fn listen() { + loop { + let url = format!( + "https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}¤t=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m,is_day\ + &daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_probability_max&timezone=auto&forecast_days=5", + settings::latitude(), + settings::longitude() + ); + + let printed = Command::new("curl").args(["-s", "-m", "15", &url]).output().map(|done| String::from_utf8_lossy(&done.stdout).into_owned()).unwrap_or_default(); + + let sky = { + let mut forecast = Self::write(); + + match parse::weather(&printed) { + Some(weather) => { + forecast.weather = Some(weather); + forecast.problem = None; + } + None => forecast.problem = Some(String::from("The weather couldn't be fetched")), + } + + forecast.weather.as_ref().map(|weather| (weather.code, weather.is_day)) + }; + + // the photo for the sky now, fetched first if its file is missing; after the weather shows + let photo = sky.and_then(|(code, day)| photo(code, day)); + + if Self::read().photo != photo { + Self::write().photo = photo; + } + + thread::sleep(Duration::from_secs(settings::weather_minutes() * 60)); + } + } +} + +const USER_AGENT: &str = "MagicIsland/1.0 (amane-config desktop shell)"; + +// a photo per kind of sky by day and by night, 1280 px wide, from wikimedia commons; their authors and licences are in weather/CREDITS.md +const PHOTOS: [(&str, &str); 14] = [ + ( + "clear-day", + "https://upload.wikimedia.org/wikipedia/commons/thumb/1/11/Zugspitze%2C_Alpspitze_und_Jubil%C3%A4umsgrat_von_Kinderburg%2C_Allmannshausen%2C_Starnberger_See.jpg/1280px-Zugspitze%2C_Alpspitze_und_Jubil%C3%A4umsgrat_von_Kinderburg%2C_Allmannshausen%2C_Starnberger_See.jpg", + ), + ("clear-night", "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Starry_night_at_Old_Uppsala%2C_Sweden.jpg/1280px-Starry_night_at_Old_Uppsala%2C_Sweden.jpg"), + ( + "partly-cloudy-day", + "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7b/Cumulus_humilis_Sch%C3%B6nwald_im_Schwarzwald_20180810.jpg/1280px-Cumulus_humilis_Sch%C3%B6nwald_im_Schwarzwald_20180810.jpg", + ), + ( + "partly-cloudy-night", + "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bf/Full_moon_over_Gullmarn_fjord_at_Holma_Marina_1.jpg/1280px-Full_moon_over_Gullmarn_fjord_at_Holma_Marina_1.jpg", + ), + ( + "cloudy-day", + "https://upload.wikimedia.org/wikipedia/commons/thumb/0/01/Vragender_%28NL%29%2C_Vragenderveen_--_2014_--_3102.jpg/1280px-Vragender_%28NL%29%2C_Vragenderveen_--_2014_--_3102.jpg", + ), + ("cloudy-night", "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/Stora_Born%C3%B6_on_a_cloudy_night_2.jpg/1280px-Stora_Born%C3%B6_on_a_cloudy_night_2.jpg"), + ("fog-day", "https://upload.wikimedia.org/wikipedia/commons/thumb/7/76/D%C3%BClmen%2C_Umland_--_2014_--_7056.jpg/1280px-D%C3%BClmen%2C_Umland_--_2014_--_7056.jpg"), + ( + "fog-night", + "https://upload.wikimedia.org/wikipedia/commons/thumb/0/04/Foggy_Autumn_Evening_on_Pawia_Street%2C_Warsaw.jpg/1280px-Foggy_Autumn_Evening_on_Pawia_Street%2C_Warsaw.jpg", + ), + ("rain-day", "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Rain_drops_on_window_01_ies.jpg/1280px-Rain_drops_on_window_01_ies.jpg"), + ( + "rain-night", + "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c0/City_streets%3B_Reflections_on_the_street_surface_after_rain.jpg/1280px-City_streets%3B_Reflections_on_the_street_surface_after_rain.jpg", + ), + ("snow-day", "https://upload.wikimedia.org/wikipedia/commons/thumb/1/14/Sninsk%C3%BD_kame%C5%88_%28v_zime%29_001.jpg/1280px-Sninsk%C3%BD_kame%C5%88_%28v_zime%29_001.jpg"), + ("snow-night", "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7c/TwinLamp%40snowyNight%28byHideObara%29.jpg/1280px-TwinLamp%40snowyNight%28byHideObara%29.jpg"), + ("storm-day", "https://upload.wikimedia.org/wikipedia/commons/thumb/1/16/2011-07_Unwetter_Schw%C3%A4bisch_Gm%C3%BCnd.jpg/1280px-2011-07_Unwetter_Schw%C3%A4bisch_Gm%C3%BCnd.jpg"), + ( + "storm-night", + "https://upload.wikimedia.org/wikipedia/commons/thumb/5/57/Lightning%2C_Friedrichshafen_%28OW1A0402%29.jpg/1280px-Lightning%2C_Friedrichshafen_%28OW1A0402%29.jpg", + ), +]; + +fn sky_word(sky: Sky) -> &'static str { + match sky { + Sky::Clear => "clear", + Sky::PartlyCloudy => "partly-cloudy", + Sky::Cloudy => "cloudy", + Sky::Fog => "fog", + Sky::Rain => "rain", + Sky::Snow => "snow", + Sky::Storm => "storm", + } +} + +// the photo file for a weather code by day or night, downloaded first when it's missing; on the forecast's thread +fn photo(code: u32, day: bool) -> Option { + if !settings::photo() { + return None; + } + + let kind = format!("{}-{}", sky_word(parse::sky(code)), if day { "day" } else { "night" }); + let folder = expanded(&settings::photos()); + let path = folder.join(format!("{kind}.jpg")); + + if !path.exists() + && let Some((_, url)) = PHOTOS.iter().find(|(name, _)| *name == kind) + { + fetch_photo(url, &folder, &path); + } + + path.is_file().then(|| path.to_string_lossy().into_owned()) +} + +// a photo downloaded beside its place, then linked into it only while nothing is there: a file the user put there is never replaced +fn fetch_photo(url: &str, folder: &Path, path: &Path) { + if fs::create_dir_all(folder).is_err() { + return; + } + + let part = folder.join(format!(".download-{}", std::process::id())); + + let fetched = Command::new("curl") + .args(["-s", "-f", "-L", "-m", "60", "-A", USER_AGENT, "-o"]) + .arg(&part) + .arg(url) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + + // the picture decoder only reads png and jpeg; an error page must not land there + if fetched && fs::read(&part).is_ok_and(|bytes| bytes.starts_with(&[0xFF, 0xD8, 0xFF])) { + // a link never replaces a file; where links can't be made, a rename while nothing is there + if fs::hard_link(&part, path).is_err() && !path.exists() { + let _ = fs::rename(&part, path); + } + } + + let _ = fs::remove_file(&part); +} + +// one event as the card shows it, and where it lives on the server +#[derive(Clone)] +struct Entry { + // local seconds (unix plus the offset at that moment); all-day ones run from local midnight + start: i64, + end: i64, + all_day: bool, + summary: String, + location: String, + color: Option, + // its calendar's and its own whole url, and the version the server gave + calendar: String, + href: String, + etag: String, + // part of a series: changes go to the whole series + recurring: bool, +} + +// an event calendar of the account +#[derive(Clone)] +struct Calendar { + url: String, + name: String, + color: Option, + writable: bool, +} + +// the event form, beside the month +#[derive(Clone)] +struct Form { + // the event changed, none for a new one + editing: Option, + title: String, + location: String, + day: i64, + all_day: bool, + // all day: how many days; at times: how many days after its start day it ends + span: i64, + start: String, + end: String, + calendar: String, + // a new event's title takes the keyboard until something is typed + fresh: bool, + // Delete was pressed once + confirm: bool, + saving: bool, + problem: Option, +} + +#[derive(Default)] +pub struct Agenda { + // the events of `span` (local days, end excluded), soonest first + events: Vec, + span: (i64, i64), + calendars: Vec, + birthdays: Vec, + synced: bool, + problem: Option, + contacts_problem: Option, + // no account or no password in the keyring: the card says how to set them + missing: bool, + + // the month shown as (year, month), none for today's; the day picked, none for today + month: Option<(i64, u32)>, + selected: Option, + form: Option
, +} + +impl Service for Agenda { + fn new() -> Self { + Self::default() + } + + // everything every few minutes, at once when the account or the servers change + fn listen() { + loop { + sync(Scope::Everything); + + let synced_for = sync_settings(); + let mut waited = 0; + + while waited < agenda_settings::sync_minutes() * 60 && sync_settings() == synced_for { + thread::sleep(Duration::from_secs(1)); + waited += 1; + } + } + } +} + +fn sync_settings() -> (String, String, String, bool) { + (agenda_settings::account(), agenda_settings::server(), agenda_settings::contacts_server(), agenda_settings::birthdays()) +} + +// the sky now and whether it's day, once the weather is known (the home pill takes its colour from it) +pub fn sky() -> Option<(parse::Sky, bool)> { + Forecast::read().weather.as_ref().map(|weather| (parse::sky(weather.code), weather.is_day)) +} + +// the event form closes without saving +fn cancel() { + Agenda::write().form = None; +} + +// Esc in the card: closes the event form if it's open (then true), else leaves it to the card +pub fn escape() -> bool { + let open = Agenda::read().form.is_some(); + + if open { + cancel(); + } + + open +} + +// whether the card has a form to type into: then it keeps the keyboard and stays open +pub fn typing() -> bool { + Agenda::read().form.is_some() +} + +const KEYRING: [&str; 2] = ["service", "amane-calendar"]; + +fn password(account: &str) -> Option { + let done = Command::new("secret-tool").args(["lookup", KEYRING[0], KEYRING[1], "account", account]).output().ok()?; + let password = String::from_utf8_lossy(&done.stdout).trim_end_matches('\n').to_string(); + + (done.status.success() && !password.is_empty()).then_some(password) +} + +// what a sync fetches: everything with discovery and birthdays, the events again after a change, or the shown month if it isn't there yet +#[derive(Clone, Copy, PartialEq)] +enum Scope { + Everything, + Events, + Month, +} + +// one sync at a time +static SYNCING: Mutex<()> = Mutex::new(()); + +// the largest offset from utc there is, so a utc window holds every local day asked for +const ZONE_SLACK: i64 = 14 * 3_600; + +fn shifted((year, month): (i64, u32), by: i64) -> (i64, u32) { + let index = year * 12 + i64::from(month) - 1 + by; + + (index.div_euclid(12), (index.rem_euclid(12) + 1) as u32) +} + +fn month_start(month: (i64, u32), by: i64) -> i64 { + let (year, month) = shifted(month, by); + + parse::days_from_civil(year, month, 1) +} + +fn month_of(day: i64) -> (i64, u32) { + let (year, month, _) = parse::civil_from_days(day); + + (year, month) +} + +// the days a month's grid shows: six weeks from the monday before its first +fn grid_span(month: (i64, u32)) -> (i64, i64) { + let first = month_start(month, 0); + let start = first - i64::from(parse::weekday(first)); + + (start, start + 42) +} + +fn covers(span: (i64, i64), wanted: (i64, i64)) -> bool { + span.0 <= wanted.0 && span.1 >= wanted.1 +} + +// the calendars, the events from the month before the shown one to the one after, and with Everything the birthdays; on this thread +fn sync(scope: Scope) { + let _one = SYNCING.lock().unwrap_or_else(PoisonError::into_inner); + + let account = agenda_settings::account().trim().to_string(); + let password = if account.is_empty() { None } else { password(&account) }; + + let Some(password) = password else { + let mut agenda = Agenda::write(); + + agenda.missing = true; + agenda.synced = false; + agenda.problem = None; + agenda.contacts_problem = None; + agenda.events.clear(); + agenda.calendars.clear(); + agenda.birthdays.clear(); + + return; + }; + + let (month, known, have, synced) = { + let agenda = Agenda::read(); + + (agenda.month, agenda.calendars.clone(), agenda.span, agenda.synced) + }; + + let month = month.unwrap_or_else(|| month_of(local_today())); + + if scope == Scope::Month && synced && covers(have, grid_span(month)) { + return; + } + + let span = (month_start(month, -1), month_start(month, 2)); + let known = if scope == Scope::Everything { Vec::new() } else { known }; + + let found = events(&agenda_settings::server(), &account, &password, span, known); + let birthdays = (scope == Scope::Everything && agenda_settings::birthdays()).then(|| birthdays(&agenda_settings::contacts_server(), &account, &password)); + + let mut agenda = Agenda::write(); + + agenda.missing = false; + + match found { + Ok((calendars, events)) => { + agenda.calendars = calendars; + agenda.events = events; + agenda.span = span; + agenda.synced = true; + agenda.problem = None; + } + Err(why) => agenda.problem = Some(why), + } + + match birthdays { + Some(Ok(found)) => { + agenda.birthdays = found; + agenda.contacts_problem = None; + } + Some(Err(why)) => agenda.contacts_problem = Some(why), + None if !agenda_settings::birthdays() => agenda.birthdays.clear(), + None => {} + } +} + +/* + * one webdav request with curl. the account, the password and any body go in on stdin as curl + * config (-K -), never as arguments, so no other program sees them; gives (status, body, etag) + */ +fn request(method: &str, url: &str, headers: &[String], body: &str, account: &str, password: &str) -> Result<(String, String, String), String> { + let mut command = Command::new("curl"); + + command.args(["-s", "-m", "30", "-X", method, "-w", "\n%{http_code}\n%header{etag}"]); + + for header in headers { + command.arg("-H").arg(header); + } + + let mut child = command.args(["-K", "-", url]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().map_err(|error| format!("curl: {error}"))?; + + if let Some(mut input) = child.stdin.take() { + let mut config = format!("user = {}\n", parse::curl_quoted(&format!("{account}:{password}"))); + + if !body.is_empty() { + config.push_str(&format!("data-binary = {}\n", parse::curl_quoted(body))); + } + + let _ = input.write_all(config.as_bytes()); + } + + let done = child.wait_with_output().map_err(|error| format!("curl: {error}"))?; + let printed = String::from_utf8_lossy(&done.stdout); + let (answer, status, etag) = parse::curl_answer(&printed); + + Ok((status.to_string(), answer.to_string(), etag.to_string())) +} + +// a webdav question (PROPFIND, REPORT) and its answer +fn dav(method: &str, url: &str, depth: &str, body: &str, account: &str, password: &str) -> Result { + let headers = [format!("Depth: {depth}"), String::from("Content-Type: application/xml; charset=utf-8")]; + let (status, answer, _) = request(method, url, &headers, body, account, password)?; + let host = url.split('/').nth(2).unwrap_or(url); + + match status.as_str() { + code if code.starts_with('2') => Ok(answer), + "401" => Err(format!("{host} didn't accept the Apple ID or the app-specific password")), + "000" | "" => Err(format!("{host} can't be reached")), + code => Err(format!("{host} answered {code}")), + } +} + +const CHANGED_ELSEWHERE: &str = "The event changed on another device meanwhile; it's synced again, try once more"; + +// what a change's answer means +fn changed(status: &str) -> Result<(), String> { + match status { + code if code.starts_with('2') => Ok(()), + "412" => Err(String::from(CHANGED_ELSEWHERE)), + "401" => Err(String::from("The calendar didn't accept the Apple ID or the app-specific password")), + "403" => Err(String::from("The calendar doesn't let you change this; it may be read-only")), + "404" => Err(String::from("The event isn't on the server anymore")), + "000" | "" => Err(String::from("The calendar server can't be reached")), + code => Err(format!("The calendar server answered {code}")), + } +} + +// an href from an answer as a whole url, on the server it came from +fn join(base: &str, href: &str) -> String { + if href.starts_with("http") { + return href.to_string(); + } + + let origin_end = base.find("://").map_or(base.len(), |scheme| base[scheme + 3..].find('/').map_or(base.len(), |path| scheme + 3 + path)); + + format!("{}{href}", &base[..origin_end]) +} + +const PRINCIPAL: &str = r#""#; + +// caldav discovery: the account's principal, its calendar home, the event calendars in it +fn calendars(server: &str, account: &str, password: &str) -> Result, String> { + let ask = |method: &str, url: &str, depth: &str, body: &str| dav(method, url, depth, body, account, password); + + let found = ask("PROPFIND", server, "0", PRINCIPAL)?; + let principal = join(server, &parse::dav_href(&found, "current-user-principal").ok_or("The calendar server named no account")?); + + let found = ask("PROPFIND", &principal, "0", r#""#)?; + let home = join(&principal, &parse::dav_href(&found, "calendar-home-set").ok_or("The calendar server named no calendars")?); + + let found = ask( + "PROPFIND", + &home, + "1", + r#""#, + )?; + + let read_only = parse::dav_read_only(&found); + + Ok(parse::dav_calendars(&found) + .into_iter() + .map(|calendar| Calendar { + url: join(&home, &calendar.href), + writable: !read_only.contains(&calendar.href), + color: calendar.color.as_deref().and_then(config::color), + name: calendar.name, + }) + .collect()) +} + +/* + * every calendar's events between two local days, series expanded by the server (in utc), with + * their hrefs and etags. `known` calendars skip the discovery. times turn local with the offset + * in force at each moment, so an event across a change to or from summer time shows right + */ +fn events(server: &str, account: &str, password: &str, span: (i64, i64), known: Vec) -> Result<(Vec, Vec), String> { + let calendars = if known.is_empty() { calendars(server, account, password)? } else { known }; + + let offset = utc_offset(); + let (from, to) = (parse::dav_time(span.0 * 86_400 - ZONE_SLACK), parse::dav_time(span.1 * 86_400 + ZONE_SLACK)); + + let query = format!( + r#""# + ); + + let mut found = Vec::new(); + let mut failure = None; + let mut answered = 0; + + for (index, calendar) in calendars.iter().enumerate() { + match dav("REPORT", &calendar.url, "1", &query, account, password) { + Ok(answer) => { + answered += 1; + + for resource in parse::dav_resources(&answer, "calendar-data") { + let href = join(&calendar.url, &resource.href); + + for occurrence in parse::ics_occurrences(&resource.data, offset) { + found.push((index, href.clone(), resource.etag.clone(), occurrence)); + } + } + } + Err(why) => { + failure.get_or_insert(why); + } + } + } + + if answered == 0 + && let Some(why) = failure + { + return Err(why); + } + + let instants: Vec = found.iter().filter(|(.., occurrence)| !occurrence.event.all_day).flat_map(|(.., occurrence)| [occurrence.event.start, occurrence.event.end]).collect(); + let offsets = offsets_at(&instants); + let local = |unix: i64| unix + offsets.get(&unix).copied().unwrap_or(offset); + + let mut entries: Vec = found + .into_iter() + .map(|(index, href, etag, occurrence)| { + let calendar = &calendars[index]; + let event = occurrence.event; + + // a date was read as local midnight at today's offset; adding it back gives the day itself + let (start, end) = if event.all_day { (event.start + offset, event.end + offset) } else { (local(event.start), local(event.end)) }; + + Entry { start, end, all_day: event.all_day, summary: event.summary, location: event.location, color: calendar.color, calendar: calendar.url.clone(), href, etag, recurring: occurrence.recurring } + }) + .filter(|entry| entry.end > span.0 * 86_400 && entry.start < span.1 * 86_400) + .collect(); + + entries.sort_by_key(|entry| (entry.start, entry.end)); + + Ok((calendars, entries)) +} + +// the local offset from utc at each moment, from one `date -f -` for all of them; empty when date can't say +fn offsets_at(instants: &[i64]) -> HashMap { + let mut unique = instants.to_vec(); + + unique.sort_unstable(); + unique.dedup(); + + if unique.is_empty() { + return HashMap::new(); + } + + let Ok(mut child) = Command::new("date").args(["-f", "-", "+%z"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { + return HashMap::new(); + }; + + let asked: String = unique.iter().map(|unix| format!("@{unix}\n")).collect(); + + // written from its own thread, so a long list can't fill both pipes at once + if let Some(mut input) = child.stdin.take() { + thread::spawn(move || { + let _ = input.write_all(asked.as_bytes()); + }); + } + + let Ok(done) = child.wait_with_output() else { + return HashMap::new(); + }; + + let offsets: Vec = String::from_utf8_lossy(&done.stdout).lines().filter_map(parse::zone_offset).collect(); + + if offsets.len() != unique.len() { + return HashMap::new(); + } + + unique.into_iter().zip(offsets).collect() +} + +/* + * carddav: the principal, its address book home, the address books, and from each the cards + * that have a BDAY (only their names and birthdays asked for); a server that can't filter is + * asked for every card + */ +fn birthdays(server: &str, account: &str, password: &str) -> Result, String> { + let ask = |method: &str, url: &str, depth: &str, body: &str| dav(method, url, depth, body, account, password); + + let found = ask("PROPFIND", server, "0", PRINCIPAL)?; + let principal = join(server, &parse::dav_href(&found, "current-user-principal").ok_or("The contacts server named no account")?); + + let found = ask("PROPFIND", &principal, "0", r#""#)?; + let home = join(&principal, &parse::dav_href(&found, "addressbook-home-set").ok_or("The contacts server named no address books")?); + + let found = ask("PROPFIND", &home, "1", r#""#)?; + + let wanted = r#""#; + let with_birthday = format!(r#"{wanted}"#); + let every_card = format!(r#"{wanted}"#); + + let mut all = Vec::new(); + + for book in parse::dav_addressbooks(&found) { + let url = join(&home, &book); + let answer = ask("REPORT", &url, "1", &with_birthday).or_else(|_| ask("REPORT", &url, "1", &every_card))?; + + for card in parse::dav_resources(&answer, "address-data") { + all.extend(parse::vcard_birthdays(&card.data)); + } + } + + all.sort_by(|one, other| (one.month, one.day, &one.name).cmp(&(other.month, other.day, &other.name))); + all.dedup_by(|one, other| one.month == other.month && one.day == other.day && one.name == other.name); + + Ok(all) +} + +// a new event's uid, from the kernel's random uuids +fn new_uid() -> String { + let random = fs::read_to_string("/proc/sys/kernel/random/uuid").map(|uuid| uuid.trim().to_ascii_uppercase()).unwrap_or_default(); + + if random.is_empty() { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_nanos()); + + return format!("{nanos:X}-{:X}-MAGIC-ISLAND", std::process::id()); + } + + random +} + +// a local date and time as unix seconds, asking `date` for the zone's rules on that day +fn local_unix(day: i64, minutes: u32) -> Result { + let (year, month, date) = parse::civil_from_days(day); + let words = format!("{year:04}-{month:02}-{date:02} {:02}:{:02}", minutes / 60, minutes % 60); + + let done = Command::new("date").args(["-d", &words, "+%s"]).output().map_err(|error| format!("date: {error}"))?; + + if !done.status.success() { + return Err(format!("{words} doesn't exist here: the clocks change then")); + } + + String::from_utf8_lossy(&done.stdout).trim().parse().map_err(|_| format!("date can't read {words}")) +} + +// when the form says the event happens, in utc for timed events +fn form_when(form: &Form) -> Result { + if form.all_day { + return Ok(When::AllDay { first: form.day, days: form.span.max(1) }); + } + + let typed = |text: &str| parse::clock_minutes(text).ok_or_else(|| String::from("Type the times like 9:30 or 14:00")); + let (from, to) = (typed(&form.start)?, typed(&form.end)?); + + let start = local_unix(form.day, from)?; + let mut end = local_unix(form.day + form.span.max(0), to)?; + + // an end before the start on the same day is after midnight + if end <= start && form.span <= 0 { + end = local_unix(form.day + 1, to)?; + } + + if end <= start { + return Err(String::from("The end comes before the start")); + } + + Ok(When::Timed { start, end }) +} + +fn with_slash(url: &str) -> String { + format!("{}/", url.trim_end_matches('/')) +} + +// the form's event written to the server: PUT a new resource, or GET, change and PUT the old one (moved: PUT there, DELETE here) +fn write_event(form: &Form) -> Result<(), String> { + let account = agenda_settings::account().trim().to_string(); + let password = password(&account).ok_or("The calendar password isn't in the keyring")?; + + let ics_header = String::from("Content-Type: text/calendar; charset=utf-8"); + let put = |url: &str, condition: String, ics: &str| -> Result<(), String> { + let headers = if condition.is_empty() { vec![ics_header.clone()] } else { vec![ics_header.clone(), condition] }; + + changed(&request("PUT", url, &headers, ics, &account, &password)?.0) + }; + + let stamp = now_unix(); + + let Some(entry) = &form.editing else { + let uid = new_uid(); + let ics = parse::ics_new(&uid, stamp, &form.title, &form.location, form_when(form)?); + + return put(&format!("{}{uid}.ics", with_slash(&form.calendar)), String::from("If-None-Match: *"), &ics); + }; + + let (status, stored, etag) = request("GET", &entry.href, &[], "", &account, &password)?; + + changed(&status)?; + + let etag = if etag.is_empty() { entry.etag.clone() } else { etag }; + let matching = |etag: &str| if etag.is_empty() { String::new() } else { format!("If-Match: {etag}") }; + + // a series keeps its days and times: they can only be changed in a full calendar app + let when = if entry.recurring { None } else { Some(form_when(form)?) }; + let change = Change { summary: form.title.clone(), location: form.location.clone(), when }; + let ics = parse::ics_change(&stored, stamp, &change).ok_or("The server's copy of the event holds no event")?; + + if form.calendar == entry.calendar { + return put(&entry.href, matching(&etag), &ics); + } + + // to another calendar: written there first, so nothing is lost when the delete fails + let name = entry.href.rsplit('/').find(|part| !part.is_empty()).unwrap_or("event.ics"); + + put(&format!("{}{name}", with_slash(&form.calendar)), String::from("If-None-Match: *"), &ics)?; + + changed(&request("DELETE", &entry.href, &[matching(&etag)].into_iter().filter(|header| !header.is_empty()).collect::>(), "", &account, &password)?.0) +} + +fn delete_event(entry: &Entry) -> Result<(), String> { + let account = agenda_settings::account().trim().to_string(); + let password = password(&account).ok_or("The calendar password isn't in the keyring")?; + let headers: Vec = if entry.etag.is_empty() { Vec::new() } else { vec![format!("If-Match: {}", entry.etag)] }; + + changed(&request("DELETE", &entry.href, &headers, "", &account, &password)?.0) +} + +// a change to the open form; any typing ends a new title's hold on the keyboard +fn edit_form(change: impl FnOnce(&mut Form)) { + if let Some(form) = Agenda::write().form.as_mut() { + form.fresh = false; + change(form); + } +} + +// after a change on the server, or a failed one, the events are fetched again +fn finish(result: Result<(), String>) { + match result { + Ok(()) => { + Agenda::write().form = None; + + sync(Scope::Events); + } + Err(why) => { + let conflict = why == CHANGED_ELSEWHERE; + + edit_form(|form| { + form.saving = false; + form.confirm = false; + form.problem = Some(why); + }); + + if conflict { + sync(Scope::Events); + } + } + } +} + +fn save() { + let Some(form) = Agenda::read().form.clone() else { return }; + + if form.saving { + return; + } + + let times = form.all_day || form.editing.as_ref().is_some_and(|entry| entry.recurring) || (parse::clock_minutes(&form.start).is_some() && parse::clock_minutes(&form.end).is_some()); + + let problem = if form.title.trim().is_empty() { + Some("Give the event a title") + } else if form.calendar.is_empty() { + Some("There's no calendar to write to yet") + } else if !times { + Some("Type the times like 9:30 or 14:00") + } else { + None + }; + + if let Some(problem) = problem { + edit_form(|form| form.problem = Some(String::from(problem))); + + return; + } + + edit_form(|form| { + form.saving = true; + form.problem = None; + }); + + thread::spawn(move || finish(write_event(&form))); +} + +fn remove() { + let Some(form) = Agenda::read().form.clone() else { return }; + let Some(entry) = form.editing.clone() else { return }; + + if form.saving { + return; + } + + if !form.confirm { + edit_form(|form| form.confirm = true); + + return; + } + + edit_form(|form| form.saving = true); + + thread::spawn(move || finish(delete_event(&entry))); +} + +fn hhmm(local: i64) -> String { + format!("{:02}:{:02}", local.rem_euclid(86_400) / 3_600, local.rem_euclid(3_600) / 60) +} + +// the form's fields filled for an event; the inputs keep their text under their ids +fn fill_inputs(form: &Form) { + TextInput::set_text("home-title", &form.title); + TextInput::set_text("home-location", &form.location); + TextInput::set_text("home-start", &form.start); + TextInput::set_text("home-end", &form.end); +} + +// a new event's times: from the next full hour, for the length the settings give +fn default_times() -> (String, String) { + let now = Clock::read().now; + let start = (now.div_euclid(3_600) + 1) * 3_600; + let end = start + agenda_settings::length_minutes() as i64 * 60; + + (hhmm(start), hhmm(end)) +} + +fn new_event(day: i64) { + let (start, end) = default_times(); + + let mut agenda = Agenda::write(); + let calendar = agenda.calendars.iter().find(|calendar| calendar.writable).map(|calendar| calendar.url.clone()).unwrap_or_default(); + + let form = Form { editing: None, title: String::new(), location: String::new(), day, all_day: false, span: 0, start, end, calendar, fresh: true, confirm: false, saving: false, problem: None }; + + fill_inputs(&form); + + agenda.selected = Some(day); + agenda.form = Some(form); +} + +fn edit_event(entry: Entry) { + let day = entry.start.div_euclid(86_400); + + let (span, start, end) = if entry.all_day { + let (start, end) = default_times(); + + ((entry.end - entry.start + 43_200).div_euclid(86_400).max(1), start, end) + } else { + (entry.end.div_euclid(86_400) - day, hhmm(entry.start), hhmm(entry.end)) + }; + + let form = Form { + title: entry.summary.clone(), + location: entry.location.clone(), + day, + all_day: entry.all_day, + span, + start, + end, + calendar: entry.calendar.clone(), + editing: Some(entry), + fresh: false, + confirm: false, + saving: false, + problem: None, + }; + + fill_inputs(&form); + + let mut agenda = Agenda::write(); + + agenda.selected = Some(day); + agenda.form = Some(form); +} + +fn toggle_all_day() { + edit_form(|form| { + form.all_day = !form.all_day; + form.span = if form.all_day { form.span + 1 } else { (form.span - 1).max(0) }; + }); +} + +// another month in the grid (`today` from the view), with the day picked in it; fetched when it isn't there yet +fn show_month(month: (i64, u32), today: i64, selected: Option) { + let resync = { + let mut agenda = Agenda::write(); + + agenda.month = if month == month_of(today) { None } else { Some(month) }; + agenda.selected = selected; + + agenda.synced && !covers(agenda.span, grid_span(month)) + }; + + if resync { + thread::spawn(|| sync(Scope::Month)); + } +} + +// a day clicked in the grid: picked, the form's day when one is open, and its month shown +fn pick(day: i64, shown: (i64, u32), today: i64) { + if month_of(day) != shown { + show_month(month_of(day), today, Some(day)); + } + + let mut agenda = Agenda::write(); + + agenda.selected = Some(day); + + if let Some(form) = agenda.form.as_mut() { + form.day = day; + } +} + +const WEEKDAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +fn weekday_name(day: i64) -> &'static str { + WEEKDAYS[parse::weekday(day) as usize % 7] +} + +fn month_name(month: u32) -> &'static str { + MONTHS[(month as usize + 11) % 12] +} + +fn sky_glyph(code: u32, day: bool) -> Glyph { + match parse::sky(code) { + Sky::Clear if day => Glyph::Sun, + Sky::Clear => Glyph::Moon, + Sky::PartlyCloudy => Glyph::PartCloud, + Sky::Cloudy => Glyph::Cloud, + Sky::Fog => Glyph::Fog, + Sky::Rain => Glyph::Rain, + Sky::Snow => Glyph::Snow, + Sky::Storm => Glyph::Storm, + } +} + +// the parts' sizes, shared by the card, height() and the canvas the card's icons are drawn in +fn button_size() -> f32 { + pods::header_height() - 4.0 +} + +fn clock_h() -> f32 { + settings::clock_size() * 1.1 +} + +fn weather_h() -> f32 { + settings::weather_height() + settings::gap() / 2.0 + settings::forecast_height() +} + +fn middle_h() -> f32 { + clock_h().max(weather_h()) +} + +fn letters_h() -> f32 { + text::tiny() + 8.0 +} + +fn calendar_h() -> f32 { + pods::header_height() + letters_h() + 6.0 * agenda_settings::cell() +} + +pub fn height() -> f32 { + let gap = settings::gap(); + + 2.0 * layout::pad() + settings::avatar_size() + gap + middle_h() + gap + 1.0 + gap + calendar_h() +} + +fn name() -> String { + let user = env::var("USER").unwrap_or_default(); + let mut letters = user.chars(); + + letters.next().map(|first| first.to_uppercase().chain(letters).collect()).unwrap_or_default() +} + +/* + * the card: the greeting, the clock beside the weather, the month beside the picked day (or the + * event form). every icon is drawn into one canvas over it all, as each canvas has a large gpu + * cost of its own; the weather's photo lies under it all, veiled in the surface's colour + */ +pub fn card(width: f32, height: f32) -> Rectangle { + let clock = Clock::read(); + let forecast = Forecast::read(); + let agenda = Agenda::read(); + let gap = settings::gap(); + + let today = clock.now.div_euclid(86_400); + let mut shapes: Vec> = Vec::new(); + + let middle_y = settings::avatar_size() + gap; + let calendar_y = middle_y + middle_h() + gap + 1.0 + gap; + + let content = Column::new(vec![ + top_row(&clock, width, &mut shapes), + middle_row(&clock, &forecast, width, today, middle_y, &mut shapes), + Box::new(pods::rule(width)), + calendar(&agenda, width, today, calendar_y, &mut shapes), + ]) + .gap(gap); + + let mut layers: Vec> = Vec::new(); + + if let Some(photo) = backdrop(width, height, forecast.photo.as_deref()) { + layers.push(photo); + } + + layers.push(Box::new(content)); + + if !shapes.is_empty() { + layers.push(Box::new(Canvas::new().width(width).height(height).shapes(shapes))); + } + + Rectangle::new().width(width).height(height).child(Stack::new(layers).width(width).height(height)) +} + +// the weather's photo over the whole card just inside its rim, under a veil of the surface's colour +fn backdrop(width: f32, height: f32, photo: Option<&str>) -> Option> { + let shown = settings::photo_opacity().clamp(0.0, 1.0); + let path = photo.filter(|_| settings::photo() && shown > 0.0)?; + + let pad = layout::pad(); + let (w, h) = (width + 2.0 * pad - 2.0, height + 2.0 * pad - 2.0); + let radius = (layout::radius() - 1.0).max(0.0); + let veil = ((1.0 - shown) * 255.0).round() as u8; + + let picture = Rectangle::new().width(w).height(h).radius(radius).fill(Image::cover(path).thumbnail(w.ceil() as u32, h.ceil() as u32)).translate(1.0 - pad, 1.0 - pad); + let cover = Rectangle::new().width(w).height(h).radius(radius).fill(faded(theme::now().surface, veil)).translate(1.0 - pad, 1.0 - pad); + + Some(Box::new(Stack::new(vec![Box::new(picture) as Box, Box::new(cover)]).width(width).height(height))) +} + +// who's here: the avatar, a greeting, the date, and the cross that closes the card +fn top_row(clock: &Clock, width: f32, shapes: &mut Vec>) -> Box { + let theme = theme::now(); + let size = settings::avatar_size(); + + let avatar = match &clock.avatar { + Some(path) => Rectangle::new().width(size).height(size).radius(Full).fill(Image::cover(path.clone())), + None => { + let glyph = size * 0.54; + + shapes.extend(glyph_shapes(Glyph::Person, glyph, theme.accent, 0.0, ((size - glyph) / 2.0, (size - glyph) / 2.0))); + + Rectangle::new().width(size).height(size).radius(Full).fill(faded(theme.accent, 60)) + } + }; + + let seconds_today = clock.now.rem_euclid(86_400); + + let greeting = match seconds_today / 3_600 { + 5..12 => "Good morning", + 12..18 => "Good afternoon", + 18..23 => "Good evening", + _ => "Good night", + }; + + let days = clock.now.div_euclid(86_400); + let (year, month, day) = parse::civil_from_days(days); + let date = if clock.now == 0 { String::new() } else { format!("{}, {day} {} {year}", weekday_name(days), month_name(month)) }; + + let button = button_size(); + let cross = button * 0.54; + + shapes.extend(glyph_shapes(Glyph::Cross, cross, theme.text, 0.0, (width - button + (button - cross) / 2.0, (size - cross) / 2.0))); + + let close = Rectangle::new().width(button).height(button).radius(Full).fill(overlays::chip()).cursor(Pointer).on_click(|_| pods::close()); + + let row = Row::new(vec![ + Box::new( + Row::new(vec![ + Box::new(avatar) as Box, + Box::new( + Column::new(vec![ + Box::new(Text::new(format!("{greeting}, {}", name())).size(text::heading()).weight(Weight::Bold).color(theme.text)) as Box, + Box::new(Text::new(date).size(text::small()).color(theme.dim)), + ]) + .gap(4.0), + ), + ]) + .gap(settings::gap()) + .align(Center), + ) as Box, + Box::new(close), + ]) + .width(width) + .justify(SpaceBetween) + .align(Center); + + Box::new(Rectangle::new().width(width).height(size).child(row)) +} + +// the big clock, and the weather beside it; `y` is where the row starts in the card +fn middle_row(clock: &Clock, forecast: &Forecast, width: f32, today: i64, y: f32, shapes: &mut Vec>) -> Box { + let gap = settings::gap(); + + let seconds_today = clock.now.rem_euclid(86_400); + let (hour, minute, second) = (seconds_today / 3_600, seconds_today % 3_600 / 60, seconds_today % 60); + let time = if settings::seconds() { format!("{hour:02}:{minute:02}:{second:02}") } else { format!("{hour:02}:{minute:02}") }; + + let letter = if settings::clock_letter_width() > 0.0 { settings::clock_letter_width() } else { font::letter_width() }; + let clock_w = (settings::clock_size() * letter * time.chars().count() as f32).min(width / 2.0); + let weather_w = (width - clock_w - gap).max(0.0); + let height = middle_h(); + + let big = Rectangle::new().width(clock_w).height(height).align_child(Start, Center).child(clock_text(time)); + let weather = weather(forecast, weather_w, today, (clock_w + gap, y + (height - weather_h()) / 2.0), shapes); + + Box::new( + Rectangle::new() + .width(width) + .height(height) + .child(Row::new(vec![Box::new(big) as Box, Box::new(Rectangle::new().width(weather_w).height(height).align_child(Start, Center).child(weather))]).gap(gap)), + ) +} + +// the big clock's digits in its own font and weight +fn clock_text(time: String) -> Text { + let digits = Text::new(time).size(settings::clock_size()).weight(settings::clock_weight().min(1000) as u16).color(theme::now().text).tight(); + let family = settings::clock_font(); + + if family.trim().is_empty() { digits } else { digits.font(family.trim()) } +} + +// the weather now over the coming days, at `origin` in the card +fn weather(forecast: &Forecast, width: f32, today: i64, origin: (f32, f32), shapes: &mut Vec>) -> Rectangle { + let theme = theme::now(); + let (now_h, days_h, inner_gap) = (settings::weather_height(), settings::forecast_height(), settings::gap() / 2.0); + + let Some(weather) = &forecast.weather else { + let words = forecast.problem.clone().unwrap_or_else(|| format!("Fetching the weather for {}…", settings::city())); + + return Rectangle::new().width(width).height(weather_h()).align_child(Start, Center).child(Text::new(words).size(text::small()).color(theme.dim)); + }; + + let big = settings::weather_icon(); + + shapes.extend(glyph_shapes(sky_glyph(weather.code, weather.is_day), big, theme.accent, 0.0, (origin.0, origin.1 + (now_h - big) / 2.0))); + + let (high, low, sunrise, sunset) = weather.days.first().map_or((0.0, 0.0, "", ""), |day| (day.max, day.min, day.sunrise.as_str(), day.sunset.as_str())); + + let now = Row::new(vec![ + Box::new( + Row::new(vec![ + Box::new(Rectangle::new().width(big).height(big)) as Box, + Box::new( + Column::new(vec![ + Box::new( + Row::new(vec![ + Box::new(Text::new(format!("{:.0}°", weather.temperature)).size(settings::temperature_size()).weight(Weight::Bold).color(theme.text)) as Box, + Box::new(Text::new(format!("{} · feels {:.0}°", parse::weather_words(weather.code), weather.apparent)).size(text::small()).color(theme.text)), + ]) + .gap(inner_gap) + .align(Center), + ) as Box, + Box::new(Text::new(format!("{} · wind {:.0} km/h · humidity {:.0}%", settings::city(), weather.wind, weather.humidity)).size(text::tiny()).color(theme.dim)), + ]) + .gap(2.0), + ), + ]) + .gap(settings::gap()) + .align(Center), + ) as Box, + Box::new( + Column::new(vec![ + Box::new(Text::new(format!("↑ {high:.0}° ↓ {low:.0}°")).size(text::small()).color(theme.text)) as Box, + Box::new(Text::new(format!("rise {sunrise} · set {sunset}")).size(text::tiny()).color(theme.dim)), + ]) + .gap(4.0), + ), + ]) + .width(width) + .justify(SpaceBetween) + .align(Center); + + // the coming days after today, their icons in the card's canvas + let column_w = width / 4.0; + let icon = settings::forecast_icon(); + let label_h = text::tiny() + 4.0; + let icon_y = origin.1 + now_h + inner_gap + label_h + 2.0 + 4.0; + + let mut columns: Vec> = Vec::new(); + + for (index, day) in weather.days.iter().skip(1).take(4).enumerate() { + shapes.extend(glyph_shapes(sky_glyph(day.code, true), icon, theme.text, 0.0, (origin.0 + index as f32 * column_w + (column_w - icon) / 2.0, icon_y))); + + columns.push(Box::new( + Rectangle::new().width(column_w).height(days_h).child( + Column::new(vec![ + Box::new(Rectangle::new().width(column_w).height(label_h).align_child(Center, Center).child(Text::new(&weekday_name(today + 1 + index as i64)[..3]).size(text::tiny()).color(theme.dim))) as Box, + Box::new(Rectangle::new().width(column_w).height(icon + 8.0)), + Box::new(Rectangle::new().width(column_w).height(text::small() + 4.0).align_child(Center, Center).child(Text::new(format!("{:.0}° / {:.0}°", day.max, day.min)).size(text::small()).color(theme.text))), + ]) + .gap(2.0), + ), + )); + } + + Rectangle::new().width(width).height(weather_h()).child( + Column::new(vec![Box::new(Rectangle::new().width(width).height(now_h).align_child(Start, Center).child(now)) as Box, Box::new(Rectangle::new().width(width).height(days_h).child(Row::new(columns)))]) + .gap(inner_gap), + ) +} + +// the public holidays of the settings' state between two days +fn holidays_between(from: i64, to: i64) -> Vec<(i64, &'static str)> { + let state = agenda_settings::holidays(); + let (first, _, _) = parse::civil_from_days(from); + let (last, _, _) = parse::civil_from_days(to - 1); + + (first..=last).flat_map(|year| parse::holidays(year, &state)).filter(|(day, _)| (from..to).contains(day)).collect() +} + +// the birthdays on a day; a 29 february one shows on the 28th in other years +fn birthdays_on(birthdays: &[Birthday], day: i64) -> impl Iterator { + let (year, month, date) = parse::civil_from_days(day); + let leap = parse::days_from_civil(year, 3, 1) - parse::days_from_civil(year, 2, 28) == 2; + + birthdays.iter().filter(move |birthday| (birthday.month, birthday.day) == (month, date) || (!leap && (month, date) == (2, 28) && (birthday.month, birthday.day) == (2, 29))) +} + +// the local days an event touches, its end excluded +fn days_of(entry: &Entry) -> (i64, i64) { + let first = entry.start.div_euclid(86_400); + let last = if entry.end > entry.start { (entry.end - 1).div_euclid(86_400) } else { first }; + + (first, last) +} + +// the month beside the picked day's list or the form; `y` is where it starts in the card +fn calendar(agenda: &Agenda, width: f32, today: i64, y: f32, shapes: &mut Vec>) -> Box { + let grid_w = 7.0 * agenda_settings::cell(); + let list_gap = agenda_settings::list_gap(); + let list_w = (width - grid_w - list_gap).max(0.0); + + let selected = agenda.selected.unwrap_or(today); + let month = agenda.month.unwrap_or_else(|| month_of(today)); + + let grid = month_grid(agenda, month, today, selected, (0.0, y), shapes); + + let side = match &agenda.form { + Some(form) => event_form(agenda, form, list_w, (grid_w + list_gap, y), shapes), + None => day_list(agenda, selected, today, list_w, (grid_w + list_gap, y), shapes), + }; + + Box::new(Rectangle::new().width(width).height(calendar_h()).child(Row::new(vec![grid, side]).gap(list_gap))) +} + +/* + * the month: its name with ‹ Today ›, the weekdays, six weeks of days. today has the accent's + * disc, the picked day a ring, a public holiday its colour, a birthday a cake at the corner and + * events dots in their calendars' colours (all in the card's canvas) + */ +fn month_grid(agenda: &Agenda, (year, month): (i64, u32), today: i64, selected: i64, origin: (f32, f32), shapes: &mut Vec>) -> Box { + let theme = theme::now(); + let cell = agenda_settings::cell(); + let grid_w = 7.0 * cell; + let shown = (year, month); + + let first = parse::days_from_civil(year, month, 1); + let next = month_start(shown, 1); + let (start, end) = grid_span(shown); + + let previous = shifted(shown, -1); + let following = shifted(shown, 1); + let first_of = move |month: (i64, u32)| if month == month_of(today) { None } else { Some(month_start(month, 0)) }; + + let mut nav: Vec> = vec![Box::new(pods::chip("‹", None, move || show_month(previous, today, first_of(previous))))]; + + if shown != month_of(today) || selected != today { + nav.push(Box::new(pods::chip("Today", None, move || show_month(month_of(today), today, None)))); + } + + nav.push(Box::new(pods::chip("›", None, move || show_month(following, today, first_of(following))))); + + let header = Rectangle::new().width(grid_w).height(pods::header_height()).child( + Row::new(vec![ + Box::new(Text::new(format!("{} {year}", month_name(month))).size(text::heading()).weight(Weight::Bold).color(theme.text)) as Box, + Box::new(Row::new(nav).gap(6.0).align(Center)), + ]) + .width(grid_w) + .justify(SpaceBetween) + .align(Center), + ); + + let letters = Row::new( + ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"].iter().map(|letters| Box::new(Rectangle::new().width(cell).height(letters_h()).align_child(Center, Center).child(Text::new(*letters).size(text::tiny()).color(theme.dim))) as Box).collect(), + ); + + // what the six weeks hold + let holidays: Vec = holidays_between(start, end).into_iter().map(|(day, _)| day).collect(); + let mut busy: HashMap> = HashMap::new(); + + for entry in &agenda.events { + let (first_day, last_day) = days_of(entry); + let color = entry.color.unwrap_or(theme.accent); + + for day in first_day.max(start)..=last_day.min(end - 1) { + let colors = busy.entry(day).or_default(); + + if !colors.contains(&color) { + colors.push(color); + } + } + } + + let (disc, ring, dot, dots, mark) = (cell * agenda_settings::disc(), agenda_settings::ring(), agenda_settings::dot(), agenda_settings::dots(), agenda_settings::mark()); + let (holiday_color, birthday_color) = (agenda_settings::holiday_color(), agenda_settings::birthday_color()); + let tint = (agenda_settings::holiday_tint().clamp(0.0, 1.0) * 255.0).round() as u8; + let cells_y = origin.1 + pods::header_height() + letters_h(); + + let mut rows: Vec> = vec![Box::new(header), Box::new(letters)]; + + for week in 0..6 { + let mut cells: Vec> = Vec::new(); + + for weekday in 0..7 { + let date = start + week * 7 + weekday; + let (x, y) = (origin.0 + weekday as f32 * cell, cells_y + week as f32 * cell); + + let inside = date >= first && date < next; + let is_today = date == today; + let holiday = holidays.contains(&date); + + // days of the months around are faded + let soft = |color: Color| if inside { color } else { faded(color, color.alpha() / 2) }; + + let number = if is_today { + theme.on_accent + } else if holiday { + soft(holiday_color) + } else { + soft(theme.text) + }; + + let fill = if is_today { + theme.accent + } else if holiday { + faded(holiday_color, tint) + } else { + CLEAR + }; + + let face = Rectangle::new() + .width(disc) + .height(disc) + .radius(Full) + .fill(fill) + .align_child(Center, Center) + .child(Text::new(parse::civil_from_days(date).2.to_string()).size(text::heading()).weight(if is_today || holiday { Weight::Bold } else { Weight::Regular }).color(number)); + + let face = if date == selected { face.border(ring, agenda_settings::selected_color()) } else { face }; + + cells.push(Box::new(Rectangle::new().width(cell).height(cell).align_child(Center, Center).cursor(Pointer).on_click(move |_| pick(date, shown, today)).child(face))); + + if let Some(colors) = busy.get(&date) { + let count = colors.len().min(dots); + let step = dot * 1.6; + let left = x + cell / 2.0 - (count as f32 - 1.0) * step / 2.0; + let dots_y = y + cell - (cell - disc) / 4.0; + + for (index, color) in colors.iter().take(count).enumerate() { + shapes.push(Box::new(Circle::new().center(left + index as f32 * step, dots_y).radius(dot / 2.0).fill(soft(*color)))); + } + } + + if birthdays_on(&agenda.birthdays, date).next().is_some() { + shapes.extend(glyph_shapes(Glyph::Cake, mark, soft(birthday_color), 0.0, (x + cell - mark - 2.0, y + 2.0))); + } + } + + rows.push(Box::new(Row::new(cells))); + } + + Box::new(Rectangle::new().width(grid_w).height(calendar_h()).child(Column::new(rows))) +} + +// what the picked day's list shows, line by line +enum Line<'a> { + Holiday(&'static str), + Birthday(String), + Event(&'a Entry), + Note(String, Color), +} + +// when an event happens on a day of its, as the list says it +fn event_time(entry: &Entry, day: i64) -> String { + let (first, last) = days_of(entry); + + match (entry.all_day, first == day, last == day) { + (true, ..) => String::from("All day"), + (false, true, true) => format!("{} – {}", hhmm(entry.start), hhmm(entry.end)), + (false, true, false) => format!("from {}", hhmm(entry.start)), + (false, false, true) => format!("until {}", hhmm(entry.end)), + (false, false, false) => String::from("All day"), + } +} + +// the account and keyring set-up, as the setting's help says it +fn setup_words() -> String { + let help = config::option("pills.calendar.account").map_or("", |option| option.help); + let account = agenda_settings::account(); + + if account.trim().is_empty() { + format!("Set pills.calendar.account in Settings. {help}") + } else { + format!("The keyring has no password for {}. {}", account.trim(), help.replace("", account.trim())) + } +} + +/* + * the picked day: its holidays, birthdays and events (a click on one of a writable calendar + * opens it in the form), or how to set the calendar up; what doesn't fit is counted. `origin` + * places the flags and cakes in the card's canvas + */ +fn day_list(agenda: &Agenda, day: i64, today: i64, width: f32, origin: (f32, f32), shapes: &mut Vec>) -> Box { + let theme = theme::now(); + let gap = agenda_settings::item_gap(); + let mark = agenda_settings::mark(); + let height = calendar_h(); + let (year, month, date) = parse::civil_from_days(day); + + let title = format!("{}{}, {date} {}", if day == today { "Today · " } else { "" }, weekday_name(day), month_name(month)); + let can_add = !agenda.missing && agenda.calendars.iter().any(|calendar| calendar.writable); + let button = button_size(); + + let mut header: Vec> = vec![Box::new(Text::new(title).size(text::heading()).weight(Weight::Bold).color(theme.text))]; + + if can_add { + let words = "New event"; + let at = (origin.0 + width - choice_width(words, button / 2.0 + 6.0), origin.1 + (pods::header_height() - button) / 2.0); + + header.push(Box::new(icon_chip(words, false, Glyph::Plus, at, shapes, move || new_event(day)))); + } + + let mut lines: Vec = Vec::new(); + + lines.extend(parse::holidays(year, &agenda_settings::holidays()).into_iter().filter(|(holiday, _)| *holiday == day).map(|(_, name)| Line::Holiday(name))); + + if !agenda.missing { + lines.extend(birthdays_on(&agenda.birthdays, day).map(|birthday| match birthday.year.map(|born| year - born).filter(|age| *age > 0) { + Some(age) => Line::Birthday(format!("{}'s birthday (turns {age})", birthday.name)), + None => Line::Birthday(format!("{}'s birthday", birthday.name)), + })); + } + + let mut events: Vec<&Entry> = agenda.events.iter().filter(|entry| (days_of(entry).0..=days_of(entry).1).contains(&day)).collect(); + + events.sort_by_key(|entry| (!entry.all_day, entry.start)); + + let warning = overlays::low_battery(); + + if agenda.missing { + lines.push(Line::Note(setup_words(), theme.dim)); + } else if let Some(problem) = &agenda.problem { + lines.push(Line::Note(problem.clone(), warning)); + } else if !agenda.synced { + lines.push(Line::Note(String::from("Syncing…"), theme.dim)); + } + + let quiet = events.is_empty() && lines.is_empty(); + + lines.extend(events.into_iter().map(Line::Event)); + + if quiet { + lines.push(Line::Note(String::from("Nothing planned"), theme.dim)); + } + + if let Some(problem) = agenda.contacts_problem.as_ref().filter(|_| !agenda.missing) { + lines.push(Line::Note(format!("Birthdays: {problem}"), theme.dim)); + } + + // laid out from the top while they fit, keeping room to count the rest + let row_h = text::small() + 12.0; + let event_h = text::small() + text::tiny() + 16.0; + let more_h = text::small() + 4.0; + let radius = layout::radius() / 2.0; + let time_w = text::small() * font::letter_width() * 14.0; + let total = lines.len(); + + let mut items: Vec> = vec![Box::new(Rectangle::new().width(width).height(pods::header_height()).child(Row::new(header).width(width).justify(SpaceBetween).align(Center)))]; + let mut used = pods::header_height(); + + for (index, line) in lines.into_iter().enumerate() { + let line_h = match &line { + Line::Holiday(_) | Line::Birthday(_) => row_h, + Line::Event(_) => event_h, + Line::Note(words, _) => Text::new(words.as_str()).size(text::small()).wrap().height_in(width), + }; + + let room = if index + 1 < total { height - more_h - gap } else { height }; + + if used + gap + line_h > room { + items.push(Box::new(Rectangle::new().width(width).height(more_h).align_child(Start, Center).child(Text::new(format!("and {} more", total - index)).size(text::small()).color(theme.dim)))); + + break; + } + + let y = origin.1 + used + gap; + + used += gap + line_h; + + let item: Box = match line { + Line::Holiday(name) => { + shapes.extend(glyph_shapes(Glyph::Flag, mark, agenda_settings::holiday_color(), 0.0, (origin.0, y + (row_h - mark) / 2.0))); + + Box::new( + Rectangle::new() + .width(width) + .height(row_h) + .padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: mark + gap }) + .align_child(Start, Center) + .child(Text::new(format!("{name} · public holiday")).size(text::small()).color(agenda_settings::holiday_color()).elide()), + ) + } + Line::Birthday(words) => { + shapes.extend(glyph_shapes(Glyph::Cake, mark, agenda_settings::birthday_color(), 0.0, (origin.0, y + (row_h - mark) / 2.0))); + + Box::new(Rectangle::new().width(width).height(row_h).padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: mark + gap }).align_child(Start, Center).child(Text::new(words).size(text::small()).color(theme.text).elide())) + } + Line::Note(words, color) => Box::new(wrapped(words, color, width)), + Line::Event(entry) => { + let writable = agenda.calendars.iter().any(|calendar| calendar.url == entry.calendar && calendar.writable); + + // a pencil at the right end of events that can be changed + let pencil = if writable { mark + gap } else { 0.0 }; + + if writable { + shapes.extend(glyph_shapes(Glyph::Pencil, mark, theme.dim, 0.0, (origin.0 + width - gap - mark, y + (event_h - mark) / 2.0))); + } + + let bar = Rectangle::new().width(4.0).height(event_h - 2.0 * gap).radius(Full).fill(entry.color.unwrap_or(theme.accent)); + let words_w = (width - 4.0 - time_w - 4.0 * gap - pencil).max(0.0); + let summary = if entry.summary.trim().is_empty() { String::from("Untitled") } else { entry.summary.clone() }; + + let mut words: Vec> = vec![Box::new(Text::new(summary).size(text::body()).weight(Weight::Bold).color(theme.text).elide())]; + + if !entry.location.trim().is_empty() { + words.push(Box::new(Text::new(entry.location.replace('\n', ", ")).size(text::tiny()).color(theme.dim).elide())); + } + + let row = Row::new(vec![ + Box::new(bar) as Box, + Box::new(Rectangle::new().width(time_w).height(event_h).align_child(Start, Center).child(Text::new(event_time(entry, day)).size(text::small()).color(theme.dim))), + Box::new(Rectangle::new().width(words_w).height(event_h).align_child(Start, Center).child(Column::new(words).gap(2.0))), + ]) + .gap(gap) + .align(Center); + + let block = Rectangle::new().width(width).height(event_h).radius(radius).fill(overlays::chip()).padding(Padding { top: 0.0, right: gap, bottom: 0.0, left: gap }).align_child(Start, Center).child(row); + + if writable { + let entry = entry.clone(); + + Box::new(block.cursor(Pointer).on_click(move |_| edit_event(entry.clone()))) + } else { + Box::new(block) + } + } + }; + + items.push(item); + } + + Box::new(Rectangle::new().width(width).height(height).child(Column::new(items).gap(gap))) +} + +// a chip's width for its words, with `lead` px before them (a colour dot or an icon) +fn choice_width(words: &str, lead: f32) -> f32 { + words.chars().count() as f32 * text::small() * font::letter_width() + 22.0 + lead +} + +fn dot_size() -> f32 { + text::small() * 0.7 +} + +// a chip that can be on (filled with the accent), with a colour dot before its words or not +fn choice(words: &str, on: bool, dot: Option, pressed: impl Fn() + 'static) -> Rectangle { + let theme = theme::now(); + let size = text::small(); + + let mut parts: Vec> = Vec::new(); + + if let Some(color) = dot { + parts.push(Box::new(Rectangle::new().width(dot_size()).height(dot_size()).radius(Full).fill(color))); + } + + parts.push(Box::new(Text::new(words).size(size).color(if on { theme.on_accent } else { theme.text }))); + + Rectangle::new() + .width(choice_width(words, if dot.is_some() { dot_size() + 6.0 } else { 0.0 })) + .height(button_size()) + .radius(Full) + .fill(if on { theme.accent } else { overlays::chip() }) + .align_child(Center, Center) + .cursor(Pointer) + .on_click(move |_| pressed()) + .child(Row::new(parts).gap(6.0).align(Center)) +} + +// a chip with an icon before its words, the icon drawn into the card's canvas; `at` is the chip's top left in the card +fn icon_chip(words: &str, on: bool, glyph: Glyph, at: (f32, f32), shapes: &mut Vec>, pressed: impl Fn() + 'static) -> Rectangle { + let theme = theme::now(); + let (button, side) = (button_size(), 11.0); + let icon = button / 2.0; + let color = if on { theme.on_accent } else { theme.text }; + + shapes.extend(glyph_shapes(glyph, icon, color, 0.0, (at.0 + side, at.1 + (button - icon) / 2.0))); + + Rectangle::new() + .width(choice_width(words, icon + 6.0)) + .height(button) + .radius(Full) + .fill(if on { theme.accent } else { overlays::chip() }) + .padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: side }) + .align_child(Start, Center) + .cursor(Pointer) + .on_click(move |_| pressed()) + .child(Row::new(vec![Box::new(Rectangle::new().width(icon).height(icon)) as Box, Box::new(Text::new(words).size(text::small()).color(color))]).gap(6.0).align(Center)) +} + +/* + * the event form: title, day (picked in the month), all day or from and to, place, calendar, + * Save and Cancel, and Delete at the top (pressed twice). a series keeps its days and times: + * its title, place and calendar change for every time it happens, and Delete removes them all. + * `origin` places the bin's icon in the card's canvas + */ +fn event_form(agenda: &Agenda, form: &Form, width: f32, origin: (f32, f32), shapes: &mut Vec>) -> Box { + let theme = theme::now(); + let field_h = agenda_settings::field_height(); + let gap = agenda_settings::item_gap(); + let series = form.editing.as_ref().is_some_and(|entry| entry.recurring); + + let field = |input: TextInput, field_w: f32| { + Rectangle::new().width(field_w).height(field_h).radius(field_h / 3.0).fill(overlays::chip()).padding(Padding { top: 0.0, right: gap, bottom: 0.0, left: gap }).align_child(Start, Center).child(input) + }; + + let input = |id: &'static str, field_w: f32, hint: &str| TextInput::new(id).width((field_w - 2.0 * gap).max(0.0)).size(text::body()).color(theme.text).placeholder(hint).on_submit(|_| save()); + + let title = input("home-title", width, "Title").on_change(|typed| edit_form(move |form| form.title = typed)); + let title = if form.fresh { title.focused() } else { title }; + + let heading = match (&form.editing, series) { + (None, _) => "New event", + (Some(_), false) => "Change event", + (Some(_), true) => "Change repeating event", + }; + + let mut top: Vec> = vec![Box::new(Text::new(heading).size(text::heading()).weight(Weight::Bold).color(theme.text))]; + let mut actions: Vec> = Vec::new(); + + if form.editing.is_some() { + let words = match (form.confirm, series) { + (false, _) => "Delete", + (true, false) => "Really delete?", + (true, true) => "Delete every time?", + }; + + let button = button_size(); + let cancel_w = choice_width("Cancel", 0.0); + let at = (origin.0 + width - cancel_w - gap - choice_width(words, button / 2.0 + 6.0), origin.1 + (pods::header_height() - button) / 2.0); + + actions.push(Box::new(icon_chip(words, form.confirm, Glyph::Trash, at, shapes, remove))); + } + + // Cancel at the top, where it's always in sight (Esc does the same) + actions.push(Box::new(pods::chip("Cancel", None, cancel))); + top.push(Box::new(Row::new(actions).gap(gap).align(Center))); + + let mut parts: Vec> = vec![ + Box::new(Rectangle::new().width(width).height(pods::header_height()).child(Row::new(top).width(width).justify(SpaceBetween).align(Center))), + Box::new(field(title, width)), + ]; + + if series { + let entry = form.editing.as_ref(); + let when = entry.map_or(String::new(), |entry| event_time(entry, entry.start.div_euclid(86_400))); + + parts.push(Box::new(wrapped( + format!("{when}, repeating. A new title, place or calendar goes to every time it happens and Delete removes them all; change its days and times in a full calendar app."), + theme.dim, + width, + ))); + } else { + let (year, month, date) = parse::civil_from_days(form.day); + + parts.push(Box::new(Text::new(format!("{} {date} {} {year} · pick another day in the month", weekday_name(form.day), month_name(month))).size(text::small()).color(theme.dim))); + + let mut when: Vec> = vec![Box::new(choice("All day", form.all_day, None, toggle_all_day))]; + + if form.all_day { + let days = form.span.max(1); + + when.push(Box::new(pods::chip("−", None, || edit_form(|form| form.span = (form.span - 1).max(1))))); + when.push(Box::new(Text::new(if days == 1 { String::from("1 day") } else { format!("{days} days") }).size(text::small()).color(theme.text))); + when.push(Box::new(pods::chip("+", None, || edit_form(|form| form.span += 1)))); + } else { + let time_w = text::body() * font::letter_width() * 5.0 + 2.0 * gap + gap; + + when.push(Box::new(Text::new("from").size(text::small()).color(theme.dim))); + when.push(Box::new(field(input("home-start", time_w, "9:00").on_change(|typed| edit_form(move |form| form.start = typed)), time_w))); + when.push(Box::new(Text::new("to").size(text::small()).color(theme.dim))); + when.push(Box::new(field(input("home-end", time_w, "10:00").on_change(|typed| edit_form(move |form| form.end = typed)), time_w))); + } + + parts.push(Box::new(Row::new(when).gap(gap).align(Center))); + } + + parts.push(Box::new(field(input("home-location", width, "Place (optional)").on_change(|typed| edit_form(move |form| form.location = typed)), width))); + + // the writable calendars as chips, in as many rows as they need + let mut rows: Vec> = Vec::new(); + let mut row: Vec> = Vec::new(); + let mut row_w = 0.0; + + for calendar in agenda.calendars.iter().filter(|calendar| calendar.writable) { + let chip_w = choice_width(&calendar.name, dot_size() + 6.0); + + if !row.is_empty() && row_w + gap + chip_w > width { + rows.push(Box::new(Row::new(std::mem::take(&mut row)).gap(gap))); + row_w = 0.0; + } + + let url = calendar.url.clone(); + + row_w += if row.is_empty() { chip_w } else { gap + chip_w }; + row.push(Box::new(choice(&calendar.name, calendar.url == form.calendar, Some(calendar.color.unwrap_or(theme.accent)), move || { + let url = url.clone(); + + edit_form(move |form| form.calendar = url); + }))); + } + + if !row.is_empty() { + rows.push(Box::new(Row::new(row).gap(gap))); + } + + if !rows.is_empty() { + parts.push(Box::new(Column::new(rows).gap(gap))); + } + + if let Some(problem) = &form.problem { + parts.push(Box::new(wrapped(problem.clone(), overlays::low_battery(), width))); + } + + parts.push(Box::new(choice(if form.saving { "Saving…" } else { "Save" }, true, None, save))); + + Box::new(Rectangle::new().width(width).height(calendar_h()).child(Column::new(parts).gap(gap))) +} + +// small text wrapped to a width, in a box as tall as it comes out +fn wrapped(words: String, color: Color, width: f32) -> Rectangle { + let words = Text::new(words).size(text::small()).color(color).wrap(); + + Rectangle::new().width(width).height(words.height_in(width)).child(words) +} diff --git a/src/island.rs b/src/island.rs index 0a4f0ee..9985e3d 100644 --- a/src/island.rs +++ b/src/island.rs @@ -225,8 +225,12 @@ pub fn view(monitor: &Monitor) -> LayerWindow { .space(Zone::Ignore) .namespace("magic-island"); - // out of sight: empty and click-through (never hidden, see below), and nothing else read, so a playing player doesn't redraw it - if slide <= -edge::hide_h() { + /* + * out of sight: empty and click-through (never hidden, see below), and nothing else + * read, so a playing player doesn't redraw it. also while a side pill's card is open in + * the middle: the pods' window draws this pill in the row beside it meanwhile + */ + if slide <= -edge::hide_h() || crate::pods::holds_island(here) { return window.click_through().child(Rectangle::new().width(0.0).height(0.0)); } @@ -297,9 +301,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow { veiled(card(&scene), width, height, radius, reveal, &colors) } - Some(player) if pill > 0.0 => veiled(compact(player, playing(player, &island), &art.path, levels), width, height, radius, pill, &colors), + Some(player) if pill > 0.0 => veiled(compact(player, playing(player, &island), &art.path, levels, c::pill::width()), width, height, radius, pill, &colors), None if reveal > 0.0 => veiled(crate::last::card(), width, height, radius, reveal, &colors), - None if pill > 0.0 => veiled(idle(), width, height, radius, pill, &colors), + None if pill > 0.0 => veiled(idle_face(c::pill::width()), width, height, radius, pill, &colors), // mid-grow: the empty shape alone, the cheapest frames of the animation _ => Rectangle::new().width(0.0).height(0.0), }; @@ -356,8 +360,74 @@ pub fn view(monitor: &Monitor) -> LayerWindow { ) } +/* + * the island's pill as the side pills' carousel draws it while one of their cards is + * open in the middle (the island's own window leaves it to them meanwhile, see + * pods::holds_island). `small` shrinks it from the pill (0) to its least (1): the song's + * cover, or its live bars when there's no cover, or a note with nothing playing. the + * pill's face fades out over the first half, the small one in over the second + */ +pub fn pill_face(monitor: &str, small: f32) -> Rectangle { + let media = Media::read(); + let island = Island::read(); + let colors = theme::now().surfaces(); + let art = Art::read(); + let h = c::pill::height(); + + let player = shown(&media, island.selected.as_deref()); + let small_w = small_width_for(player.is_some(), &art.path); + let width = mix(c::pill::width(), small_w, small); + + let live = player.is_some() && focused_monitor().as_deref().is_none_or(|name| name == monitor); + let spectrum = live.then(Spectrum::read); + let levels: &[f32] = spectrum.as_deref().map_or(&[], |spectrum| spectrum.levels.as_slice()); + + if !levels.is_empty() { + amane::request_frame(); + } + + /* + * the pill's face fades out, then the small one in: they swap halfway, or with nothing + * playing as soon as the shape gets too narrow for the words, so these never vanish while seen + */ + let turn = if player.is_some() { 0.5 } else { ((c::pill::width() - idle_room()) / (c::pill::width() - small_w).max(1.0)).clamp(0.05, 0.5) }; + + let face = if small < turn { + let big = match player { + Some(player) => compact(player, playing(player, &island), &art.path, levels, width), + None => idle_face(width), + }; + + veiled(big, width, h, h / 2.0, 1.0 - small / turn, &colors) + } else { + let little: Box = match player { + Some(_) if !art.path.is_empty() => Box::new(cover(&art.path, h - 10.0, 1.5, false)), + Some(player) => Box::new(pill_levels(playing(player, &island), levels)), + None => Box::new(icon(Glyph::Note, IDLE_NOTE, theme::now().accent)), + }; + + veiled(Rectangle::new().width(width).height(h).align_child(Center, Center).child(Stack::new(vec![little])), width, h, h / 2.0, (small - turn) / (1.0 - turn), &colors) + }; + + surface::shell(width, h, h / 2.0, &colors, 0.0, face) +} + +// the island's pill at its least in the carousel: as wide as tall with a cover or nothing playing, else room for its bars +pub fn small_width() -> f32 { + let media = Media::read(); + let island = Island::read(); + + small_width_for(shown(&media, island.selected.as_deref()).is_some(), &Art::read().path) +} + +fn small_width_for(playing_something: bool, art: &str) -> f32 { + let h = c::pill::height(); + + if !playing_something || !art.is_empty() { h } else { (c::pill::bars_width() + 24.0).max(h) } +} + // a click on the island of `monitor`: closes an open dropdown, else opens the card there -fn tap(monitor: &str) { +pub fn tap(monitor: &str) { let mut island = Island::write(); if island.menu.take().is_none() { @@ -381,6 +451,9 @@ fn open_on(island: &mut Island, monitor: Option) { island.expanded = true; island.open.to(1.0); + + // one card at a time: a side pill's card closes + crate::pods::close(); } // the open timeline, at rest on the pill @@ -501,6 +574,14 @@ fn next_player() { Island::write().selected = next; } +// how wide this monitor's island is this frame, hover aside: the side pills keep beside it +pub fn width_on(monitor: &str) -> f32 { + let island = Island::read(); + let open = if island.opened_on.as_deref().is_none_or(|name| name == monitor) { island.open.value() } else { 0.0 }; + + mix(c::pill::width(), c::card::width(), phases(open).0) +} + pub fn mix(from: f32, to: f32, amount: f32) -> f32 { from + (to - from) * amount } @@ -576,21 +657,36 @@ pub fn faded(color: Color, alpha: u8) -> Color { Color::rgba(color.red(), color.green(), color.blue(), alpha) } -// the pill while no player is open: a music note and "Nothing playing"; the words only -// where they fit with half the pill's height to spare at each end, else the note alone, so it never nears the rim -fn idle() -> Rectangle { - let (width, h, note) = (c::pill::width(), c::pill::height(), 15.0); - let words = Text::new("Nothing playing").size(text::body()).color(theme::now().dim); - let fits = matches!(words.width(), amane::Size::Fixed(words_w) if note + 8.0 + words_w + h <= width); +/* + * a note and "Nothing playing", centred. the words only where they fit with half the + * pill's height to spare at each end, else the note alone: an elided text takes the whole + * width, which put the note against the rim, and a pill narrower than the words did too + */ +fn idle_face(width: f32) -> Rectangle { + let note = icon(Glyph::Note, IDLE_NOTE, theme::now().accent); + let row = if idle_room() <= width { Row::new(children![note, idle_words()]) } else { Row::new(children![note]) }; - let row = if fits { Row::new(children![icon(Glyph::Note, note, theme::now().accent), words]) } else { Row::new(children![icon(Glyph::Note, note, theme::now().accent)]) }; - Rectangle::new().width(width).height(h).align_child(Center, Center).child(row.gap(8.0).align(Center)) + Rectangle::new().width(width).height(c::pill::height()).align_child(Center, Center).child(row.gap(8.0).align(Center)) } -// the pill: cover, title, the player's live levels -fn compact(player: &MediaPlayer, playing: bool, art: &str, levels: &[f32]) -> Rectangle { +const IDLE_NOTE: f32 = 15.0; + +fn idle_words() -> Text { + Text::new("Nothing playing").size(text::body()).color(theme::now().dim) +} + +// the width the note and the words need, with half the pill's height to spare at each end +fn idle_room() -> f32 { + match idle_words().width() { + amane::Size::Fixed(words) => IDLE_NOTE + 8.0 + words + c::pill::height(), + amane::Size::Parent => f32::MAX, + } +} + +// the pill, `width` wide: cover, title, the player's live levels +fn compact(player: &MediaPlayer, playing: bool, art: &str, levels: &[f32], width: f32) -> Rectangle { Rectangle::new() - .width(c::pill::width()) + .width(width) .height(c::pill::height()) .padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 6.0 }) .child( diff --git a/src/main.rs b/src/main.rs index 57a2eac..6e9306c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,22 @@ +mod ai; mod art; mod config; mod control; mod edge; mod glyph; +mod home; mod hypr; mod island; mod last; mod map; +mod notices; mod parse; +mod pods; mod prompt; mod settings; mod sound; mod spectrum; +mod stats; mod surface; mod system; mod png; @@ -38,14 +43,19 @@ fn main() { .ipc("control", control::command) .ipc("tray", tray::command) .ipc("config", settings::command) + .ipc("pods", pods::command) .window_per_monitor(edge::spacer) .window_per_monitor(edge::view) .window_per_monitor(island::view) + // the small pills beside the island, and their cards in the middle + .window_per_monitor(pods::row_view) + .window_per_monitor(pods::card_view) .window_per_monitor(workspaces::view) .window_per_monitor(control::view) .window_per_monitor(control::menu_view) .window_per_monitor(tray::menu_view) - // last, so the popup lies over the control centre + // last, so the popups lie over the control centre + .window_per_monitor(notices::popups) .window_per_monitor(prompt::view) .run(); } diff --git a/src/notices.rs b/src/notices.rs new file mode 100644 index 0000000..71c4678 --- /dev/null +++ b/src/notices.rs @@ -0,0 +1,537 @@ +use std::collections::HashMap; +use std::path::Path; +use std::thread; +use std::time::{Duration, Instant, SystemTime}; + +use amane::{ + Center, Column, Full, Horizontal, Image, LayerWindow, Layer, Margin, Monitor, Notification, Notifications, Pointer, Rectangle, Row, ScrollArea, Service, SpaceBetween, + Start, Text, Urgency, Vertical, Weight, Widget, Zone, +}; + +use crate::config::appearance::{overlays, text}; +use crate::config::control::pill as control_pill; +use crate::config::island::pill as island_pill; +use crate::config::notifications::{apps, popups as settings}; +use crate::glyph::Glyph; +use crate::island::{self, faded}; +use crate::parse::{self, Value}; +use crate::{config, pods, surface, theme, tray}; + +/* + * notifications: amane's own server (org.freedesktop.Notifications, which dunst held + * before) keeps the list; this adds the popups, the unread count on the bell and the + * history card. a watcher reads the list four times a second (memory, not d-bus), finds + * each new one's picture once and times the popups out by urgency + */ +#[derive(Default)] +pub struct Notices { + // shown as popups, newest first, with when each (re)arrived + popups: Vec<(u32, Instant)>, + // each notification's arrival time as last seen, to tell new and replaced ones apart + known: HashMap, + // a picture file per notification: its image, else its icon found in the themes + pictures: HashMap, + unread: usize, + // the pointer over the popups: they don't time out meanwhile + hovered: bool, +} + +impl Service for Notices { + fn new() -> Self { + Self::default() + } + + fn listen() { + loop { + watch(); + + thread::sleep(Duration::from_millis(250)); + } + } +} + +pub fn unread() -> usize { + Notices::read().unread +} + +// the history was looked at +pub fn mark_read() { + let mut notices = Notices::write(); + + if notices.unread > 0 { + notices.unread = 0; + } +} + +fn timeout(urgency: Urgency) -> Option { + match urgency { + Urgency::Low => Some(Duration::from_millis(settings::low_timeout_ms())), + Urgency::Critical if settings::critical_stays() => None, + _ => Some(Duration::from_millis(settings::timeout_ms())), + } +} + +fn watch() { + let current: Vec<(u32, SystemTime, Urgency, String, String, String)> = Notifications::read() + .list() + .iter() + .map(|notice| (notice.id(), notice.received(), notice.urgency(), notice.icon().to_string(), notice.image().to_string(), notice.app_name().to_string())) + .collect(); + + let (arrived, gone, expired) = { + let notices = Notices::read(); + + let arrived: Vec<_> = current.iter().filter(|(id, received, ..)| notices.known.get(id) != Some(received)).cloned().collect(); + let gone: Vec = notices.known.keys().filter(|id| !current.iter().any(|(known, ..)| known == *id)).copied().collect(); + + let expired: Vec = if notices.hovered { + Vec::new() + } else { + notices + .popups + .iter() + .filter(|(id, since)| current.iter().find(|(known, ..)| known == id).and_then(|(_, _, urgency, ..)| timeout(*urgency)).is_some_and(|stay| since.elapsed() >= stay)) + .map(|(id, _)| *id) + .collect() + }; + + (arrived, gone, expired) + }; + + if arrived.is_empty() && gone.is_empty() && expired.is_empty() { + return; + } + + // a blocked app's notifications are dropped at once; a muted one's stay in the history without popping up + let (blocked, muted) = (apps::blocked(), apps::muted()); + let (dropped, arrived): (Vec<_>, Vec<_>) = arrived.into_iter().partition(|(.., app)| listed(&blocked, app)); + + for (id, ..) in &dropped { + Notifications::dismiss(*id); + } + + // icon lookups walk the themes: here, not under the lock and never in a view + let pictures: Vec<(u32, Option)> = arrived.iter().map(|(id, _, _, icon, image, _)| (*id, picture(icon, image))).collect(); + + let looking = pods::open_card() == Some(pods::Pod::Notifications); + let popping = settings::enabled(); + + let mut notices = Notices::write(); + + for (id, received, .., app) in &arrived { + let fresh = !notices.known.contains_key(id); + let quiet = listed(&muted, app); + + notices.known.insert(*id, *received); + notices.popups.retain(|(shown, _)| shown != id); + + if popping && !quiet { + notices.popups.insert(0, (*id, Instant::now())); + } + + if fresh && !looking && !quiet { + notices.unread += 1; + } + } + + for (id, file) in pictures { + match file { + Some(file) => notices.pictures.insert(id, file), + None => notices.pictures.remove(&id), + }; + } + + for id in gone { + notices.known.remove(&id); + notices.pictures.remove(&id); + notices.popups.retain(|(shown, _)| *shown != id); + } + + notices.popups.retain(|(shown, _)| !expired.contains(shown)); + notices.unread = notices.unread.min(notices.known.len()); +} + +// whether `app` is in a comma-separated list of app names, in any case +fn listed(list: &str, app: &str) -> bool { + !app.is_empty() && list.split(',').any(|name| name.trim().eq_ignore_ascii_case(app)) +} + +// puts an app into the list at `path` in config.toml, or takes it out +fn relist(path: &str, list: String, app: &str, on: bool) { + let mut names: Vec<&str> = list.split(',').map(str::trim).filter(|name| !name.is_empty() && !name.eq_ignore_ascii_case(app)).collect(); + + if on { + names.push(app); + } + + let _ = config::set(path, Value::Text(names.join(", "))); +} + +// Mute app: its notifications stop popping up (its popups now go too) but stay in the history +fn mute(app: &str, on: bool) { + relist("notifications.apps.muted", apps::muted(), app, on); + + if on { + hide_app(app); + } +} + +// Block app: its notifications are dropped from now on +fn block(app: &str, on: bool) { + relist("notifications.apps.blocked", apps::blocked(), app, on); + + if on { + hide_app(app); + } +} + +fn hide_app(app: &str) { + let ids: Vec = Notifications::read().list().iter().filter(|notice| notice.app_name().eq_ignore_ascii_case(app)).map(Notification::id).collect(); + + Notices::write().popups.retain(|(shown, _)| !ids.contains(shown)); +} + +// a notification's picture as a file: its image (a path or file:// url), else its icon (a path or a theme name) +fn picture(icon: &str, image: &str) -> Option { + for wanted in [image, icon] { + let path = wanted.strip_prefix("file://").unwrap_or(wanted); + + if path.is_empty() { + continue; + } + + if path.starts_with('/') { + if Path::new(path).is_file() { + return Some(path.to_string()); + } + + continue; + } + + if let Some(file) = tray::icon_file(path, "") { + return Some(file.to_string_lossy().into_owned()); + } + } + + None +} + +fn hide(id: u32) { + Notices::write().popups.retain(|(shown, _)| *shown != id); +} + +fn hovering(inside: bool) { + let mut notices = Notices::write(); + + notices.hovered = inside; + + // leaving starts every popup's time anew + if !inside { + for (_, since) in &mut notices.popups { + *since = Instant::now(); + } + } +} + +fn ago(received: SystemTime) -> String { + let seconds = received.elapsed().map_or(0, |since| since.as_secs()); + + match seconds { + 0..60 => String::from("now"), + 60..3600 => format!("{} min", seconds / 60), + 3600..86400 => format!("{} h", seconds / 3600), + _ => format!("{} d", seconds / 86400), + } +} + +// the picture, or the app's first letter on a disc +fn picture_widget(file: Option<&String>, app: &str, size: f32) -> Box { + let theme = theme::now(); + + match file { + Some(file) => Box::new(Rectangle::new().width(size).height(size).radius(size * 0.22).fill(Image::contain(file.clone()))), + None => { + let letter: String = app.chars().next().map(|letter| letter.to_uppercase().collect()).unwrap_or_else(|| String::from("•")); + + Box::new(Rectangle::new().width(size).height(size).radius(Full).fill(faded(theme.accent, 70)).align_child(Center, Center).child(Text::new(letter).size(size * 0.42).weight(Weight::Bold).color(theme.accent))) + } + } +} + +// the buttons a notification asks for +fn actions(notice: &Notification) -> Option> { + if notice.actions().is_empty() { + return None; + } + + let id = notice.id(); + let chips: Vec> = notice + .actions() + .iter() + .map(|action| { + let key = action.key().to_string(); + + Box::new(pods::chip(action.label(), None, move || Notifications::invoke(id, &key))) as Box + }) + .collect(); + + Some(Box::new(Row::new(chips).gap(6.0))) +} + +/* + * one notification's face, for a popup and for the history: picture, app and time, + * summary, body; `lines` caps the body; `close` is the cross at its top right + */ +fn face(notice: &Notification, file: Option<&String>, width: f32, lines: usize, close: Box) -> (Rectangle, f32) { + let theme = theme::now(); + let picture_size = settings::icon(); + let text_w = width - picture_size - 12.0 - 30.0; + + let body = parse::plain_body(notice.body()); + let body = Text::new(body).size(text::small()).color(theme.dim).wrap().max_lines(lines); + let body_h = if notice.body().is_empty() { 0.0 } else { body.height_in(text_w) }; + + let line_h = text::tiny() + 6.0; + let summary_h = text::body() + 6.0; + let words_h = line_h + summary_h + body_h; + + let actions = actions(notice); + let actions_h = if actions.is_some() { 26.0 + 8.0 } else { 0.0 }; + + let height = words_h.max(picture_size) + actions_h; + + let app = if notice.app_name().is_empty() { String::from("Notification") } else { notice.app_name().to_string() }; + + let words = Column::new(vec![ + Box::new(Rectangle::new().width(text_w).height(line_h).align_child(Start, Center).child(Text::new(format!("{app} · {}", ago(notice.received()))).size(text::tiny()).color(theme.dim).elide())) as Box, + Box::new(Rectangle::new().width(text_w).height(summary_h).align_child(Start, Center).child(Text::new(notice.summary()).size(text::body()).weight(Weight::Bold).color(theme.text).elide())), + Box::new(Rectangle::new().width(text_w).height(body_h).child(body)), + ]); + + let top = Row::new(vec![ + Box::new(Row::new(vec![picture_widget(file, &app, picture_size), Box::new(words)]).gap(12.0)) as Box, + Box::new(pods::button(Glyph::Cross, close)), + ]) + .width(width) + .justify(SpaceBetween); + + let mut parts: Vec> = vec![Box::new(Rectangle::new().width(width).height(words_h.max(picture_size)).child(top))]; + + if let Some(actions) = actions { + parts.push(Box::new(Rectangle::new().width(width).height(26.0).padding(amane::Padding { top: 0.0, right: 0.0, bottom: 0.0, left: picture_size + 12.0 }).child(Column::new(vec![actions])))); + } + + (Rectangle::new().width(width).height(height).child(Column::new(parts).gap(8.0)), height) +} + +/* + * the popups, top right under the bar on the focused monitor, newest on top; a click + * runs the notification's default action (or just hides the popup), the cross hides + * it, both leave it in the history + */ +pub fn popups(monitor: &Monitor) -> LayerWindow { + theme::begin(); + + let notices = Notices::read(); + let list = Notifications::read(); + let shadow = island_pill::shadow(); + let width = settings::width(); + let pad = 14.0; + + // the right edge lines up with the control centre's pill above; the shadow there runs off the screen + let right = control_pill::right(); + + let window = |height: f32| { + LayerWindow::new() + .width(width + shadow + right) + .height(height) + .anchor_vertical(Vertical::Top) + .anchor_horizontal(Horizontal::Right) + .margin(Margin { top: (island_pill::top_gap() + island_pill::height() + 8.0 - shadow).max(0.0) as i32, right: 0, bottom: 0, left: 0 }) + .layer(Layer::Overlay) + .space(Zone::Ignore) + .namespace("magic-notifications") + }; + + let focused = island::focused_monitor().is_none_or(|name| name == monitor.name); + let shown: Vec<(&Notification, Instant)> = + notices.popups.iter().filter_map(|(id, since)| list.list().iter().find(|notice| notice.id() == *id).map(|notice| (notice, *since))).take(settings::count()).collect(); + + if !focused || shown.is_empty() { + return window(1.0).click_through().child(Rectangle::new().width(0.0).height(0.0)); + } + + let colors = theme::now().surfaces(); + let mut cards: Vec> = Vec::new(); + let mut total = 0.0; + let mut arriving = false; + + for (notice, since) in shown { + let id = notice.id(); + let (content, height) = face(notice, notices.pictures.get(&id), width - 2.0 * pad, 3, Box::new(move || hide(id))); + let height = height + 2.0 * pad; + + // slides in from the right over its first quarter second + let t = (since.elapsed().as_secs_f32() / 0.25).min(1.0); + arriving |= t < 1.0; + + let content = Rectangle::new().width(width).height(height).padding(pad).child(content); + let card = surface::shell(width, height, 18.0, &colors, if notice.urgency() == Urgency::Critical { 1.0 } else { 0.0 }, content); + let card = if notice.urgency() == Urgency::Critical { card.border(1.5, crate::config::appearance::overlays::low_battery()) } else { card }; + + let has_default = notice.has_default_action(); + + cards.push(Box::new(card.cursor(Pointer).on_click(move |_| if has_default { Notifications::click(id) } else { hide(id) }).translate((1.0 - t * t * (3.0 - 2.0 * t)) * 40.0, 0.0))); + + total += height + 10.0; + } + + if arriving { + amane::request_frame(); + } + + window(total + 2.0 * shadow).child( + Rectangle::new() + .width(width + shadow + right) + .height(total + 2.0 * shadow) + .padding(amane::Padding { top: shadow, right, bottom: shadow, left: shadow }) + .on_hover(hovering) + .child(Column::new(cards).gap(10.0)), + ) +} + +// `pods notices list|dismiss |clear`: the history without the mouse +pub fn command(words: &[&str]) -> String { + match words { + ["dismiss", id] => match id.parse() { + Ok(id) => { + Notifications::dismiss(id); + + String::from("ok") + } + Err(_) => String::from("dismiss takes a notification's number"), + }, + ["clear"] => { + Notifications::clear(); + + String::from("ok") + } + _ => { + let notifications = Notifications::read(); + let notices = Notices::read(); + let lines: Vec = notifications + .list() + .iter() + .map(|notice| { + let popup = if notices.popups.iter().any(|(id, _)| *id == notice.id()) { " popup" } else { "" }; + let picture = notices.pictures.get(¬ice.id()).map_or(String::from("-"), Clone::clone); + + format!("{} [{:?}{popup}] {}: {} | {} | picture {picture} | actions {}", notice.id(), notice.urgency(), notice.app_name(), notice.summary(), parse::plain_body(notice.body()), notice.actions().len()) + }) + .collect(); + + format!("running={} unread={}\n{}", notifications.running(), notices.unread, lines.join("\n")) + } + } +} + +// do not disturb: the popups off (or on again), kept in config.toml +fn toggle_quiet() { + let _ = config::set("notifications.popups.enabled", Value::Bool(!settings::enabled())); +} + +// the history card: every notification still open, newest first, each with its cross to delete it +pub fn card(width: f32, height: f32) -> Rectangle { + let notices = Notices::read(); + let list = Notifications::read(); + let theme = theme::now(); + + let mut buttons: Vec> = vec![Box::new(pods::chip(if settings::enabled() { "Do not disturb" } else { "Popups on" }, None, toggle_quiet))]; + + if !list.list().is_empty() { + buttons.push(Box::new(pods::chip("Clear all", None, Notifications::clear))); + } + + let header = pods::header("Notifications", buttons, width); + + // the muted and blocked apps, each a chip that takes it off its list again + let (muted, blocked) = (apps::muted(), apps::blocked()); + let names = |list: &str| list.split(',').map(str::trim).filter(|name| !name.is_empty()).map(str::to_string).collect::>(); + let mut undo: Vec> = Vec::new(); + + for app in names(&muted) { + undo.push(Box::new(pods::chip(&format!("Unmute {app}"), Some(Glyph::Muted), move || mute(&app, false)))); + } + + for app in names(&blocked) { + undo.push(Box::new(pods::chip(&format!("Unblock {app}"), Some(Glyph::Cross), move || block(&app, false)))); + } + + let undo_h = if undo.is_empty() { 0.0 } else { 26.0 + 10.0 }; + let list_h = height - pods::header_height() - 10.0 - undo_h; + + let body: Box = if !list.running() { + Box::new( + Rectangle::new() + .width(width) + .height(list_h) + .align_child(Center, Center) + .child(Text::new("Another notification daemon (dunst?) holds the bus name: stop it and restart the shell").size(text::small()).color(theme.dim).wrap()), + ) + } else if list.list().is_empty() { + Box::new( + Rectangle::new().width(width).height(list_h).align_child(Center, Center).child( + Column::new(vec![ + Box::new(Rectangle::new().width(width).height(40.0).align_child(Center, Center).child(crate::glyph::icon(Glyph::Bell, 32.0, faded(theme.dim, 120)))) as Box, + Box::new(Rectangle::new().width(width).height(text::small() + 8.0).align_child(Center, Center).child(Text::new("No notifications").size(text::small()).color(theme.dim))), + ]) + .gap(8.0), + ), + ) + } else { + let item_w = width - 8.0; + let mut items: Vec> = Vec::new(); + + for notice in list.list().iter().rev() { + let id = notice.id(); + let (content, inner_h) = face(notice, notices.pictures.get(&id), item_w - 24.0, 4, Box::new(move || Notifications::dismiss(id))); + let has_default = notice.has_default_action(); + + // Mute app keeps its notifications quiet in the history, Block app drops them + let app = notice.app_name().to_string(); + let content: Box = if app.is_empty() { + Box::new(content) + } else { + let quiet = listed(&muted, &app); + let (for_mute, for_block) = (app.clone(), app.clone()); + + let choices = Row::new(vec![ + Box::new(pods::chip(if quiet { "Unmute app" } else { "Mute app" }, Some(Glyph::Muted), move || mute(&for_mute, !quiet))) as Box, + Box::new(pods::chip("Block app", Some(Glyph::Cross), move || block(&for_block, true))), + ]) + .gap(6.0); + + Box::new(Column::new(vec![Box::new(content) as Box, Box::new(Rectangle::new().width(item_w - 24.0).height(26.0).align_child(amane::End, Center).child(choices))]).gap(8.0)) + }; + let inner_h = if app.is_empty() { inner_h } else { inner_h + 26.0 + 8.0 }; + + let item = Rectangle::new().width(item_w).height(inner_h + 24.0).radius(16.0).fill(overlays::chip()).padding(12.0).child(Column::new(vec![content])); + let item = if has_default { item.cursor(Pointer).on_click(move |_| Notifications::click(id)) } else { item }; + + items.push(Box::new(item)); + } + + Box::new(ScrollArea::new("notices-history", Column::new(items).gap(8.0)).width(width).height(list_h)) + }; + + let mut parts: Vec> = vec![Box::new(header)]; + + if !undo.is_empty() { + // ponytail: one row, cut at the card's edge; the config window has the whole lists + parts.push(Box::new(Rectangle::new().width(width).height(26.0).clip().child(Row::new(undo).gap(6.0)))); + } + + parts.push(body); + + Rectangle::new().width(width).height(height).child(Column::new(parts).gap(10.0)) +} diff --git a/src/parse.rs b/src/parse.rs index 0973cad..6d8335d 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -1205,6 +1205,1207 @@ pub fn hypr_lua(kind: HyprKind, text: &str) -> Result { } } +// ---- side pills: ai, system monitor, weather, calendar, notifications ---- + +// text as a json string literal, quotes included, for request bodies +pub fn json_string(text: &str) -> String { + let mut out = String::with_capacity(text.len() + 2); + + out.push('"'); + + for letter in text.chars() { + match letter { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ if u32::from(letter) < 0x20 => out.push_str(&format!("\\u{:04x}", u32::from(letter))), + _ => out.push(letter), + } + } + + out.push('"'); + + out +} + +// what one line of ollama's streaming /api/chat answer says +#[derive(Debug, PartialEq)] +pub enum Chunk { + Text(String), + Done, + Failed(String), +} + +// one line of the stream: a piece of the reply, its end or an error; none for blank, non-json or empty pieces +pub fn ollama_chunk(line: &str) -> Option { + let answer = json(line).into_iter().next()?; + + if let Some(error) = answer.get("error").and_then(Value::text) { + return Some(Chunk::Failed(error.to_string())); + } + + if answer.get("done") == Some(&Value::Bool(true)) { + return Some(Chunk::Done); + } + + let content = answer.get("message")?.get("content")?.text()?; + + (!content.is_empty()).then(|| Chunk::Text(content.to_string())) +} + +// /proc/stat's all-cpu line as (busy, total) jiffies since boot; idle and iowait are the time not busy +pub fn cpu_jiffies(stat: &str) -> Option<(u64, u64)> { + let line = stat.lines().find_map(|line| line.strip_prefix("cpu "))?; + let numbers: Vec = line.split_whitespace().take(8).map_while(|word| word.parse().ok()).collect(); + + let idle = numbers.get(3)?.saturating_add(numbers.get(4).copied().unwrap_or(0)); + let total = numbers.iter().fold(0u64, |sum, number| sum.saturating_add(*number)); + + Some((total.saturating_sub(idle), total)) +} + +// memory and swap from /proc/meminfo, in kB +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Memory { + pub total_kb: u64, + pub available_kb: u64, + pub swap_total_kb: u64, + pub swap_free_kb: u64, +} + +// /proc/meminfo as Memory; none without MemTotal or MemAvailable, swap 0 when missing +pub fn memory(meminfo: &str) -> Option { + let field = |name: &str| meminfo.lines().find_map(|line| line.strip_prefix(name)?.strip_prefix(':')?.split_whitespace().next()?.parse::().ok()); + + Some(Memory { total_kb: field("MemTotal")?, available_kb: field("MemAvailable")?, swap_total_kb: field("SwapTotal").unwrap_or(0), swap_free_kb: field("SwapFree").unwrap_or(0) }) +} + +// /proc/net/dev as (received, sent) bytes, summed over every interface but lo +pub fn network_bytes(dev: &str) -> (u64, u64) { + dev.lines().filter_map(|line| line.split_once(':')).filter(|(name, _)| name.trim() != "lo").fold((0, 0), |(received, sent), (_, counters)| { + let mut numbers = counters.split_whitespace().map(|number| number.parse::().unwrap_or(0)); + + (received.saturating_add(numbers.next().unwrap_or(0)), sent.saturating_add(numbers.nth(7).unwrap_or(0))) + }) +} + +// a /sys/block//stat line as (sectors read, sectors written), its 3rd and 7th numbers +pub fn block_sectors(stat: &str) -> Option<(u64, u64)> { + let mut fields = stat.split_whitespace(); + + Some((fields.nth(2)?.parse().ok()?, fields.nth(3)?.parse().ok()?)) +} + +// one day of open-meteo's forecast: date as "YYYY-MM-DD", sunrise and sunset as "HH:MM", rain chance in percent +#[derive(Debug, Clone, PartialEq)] +pub struct Day { + pub date: String, + pub code: u32, + pub max: f64, + pub min: f64, + pub sunrise: String, + pub sunset: String, + pub rain_chance: f64, +} + +// open-meteo's current conditions (°C, %, km/h, wmo code) and the days ahead +#[derive(Debug, Clone, PartialEq)] +pub struct Weather { + pub temperature: f64, + pub apparent: f64, + pub humidity: f64, + pub code: u32, + pub wind: f64, + pub is_day: bool, + pub days: Vec, +} + +// open-meteo's forecast json as Weather; none without its current object +pub fn weather(text: &str) -> Option { + let answer = json(text).into_iter().next()?; + let current = answer.get("current").filter(|current| matches!(current, Value::Table(_)))?; + let now = |key: &str| current.get(key).and_then(Value::number).unwrap_or(0.0); + + // the day at `at` in one of the daily lists + let daily = |key: &str, at: usize| match answer.get("daily")?.get(key)? { + Value::List(items) => items.get(at), + _ => None, + }; + let number = |key: &str, at: usize| daily(key, at).and_then(Value::number).unwrap_or(0.0); + let clock = |key: &str, at: usize| { + let time = daily(key, at).and_then(Value::text).and_then(|stamp| stamp.split_once('T')).map_or("", |(_, time)| time); + + time.get(..5).unwrap_or(time).to_string() + }; + + let days = (0..) + .map_while(|at| { + Some(Day { + date: daily("time", at)?.text()?.to_string(), + code: number("weather_code", at) as u32, + max: number("temperature_2m_max", at), + min: number("temperature_2m_min", at), + sunrise: clock("sunrise", at), + sunset: clock("sunset", at), + rain_chance: number("precipitation_probability_max", at), + }) + }) + .collect(); + + Some(Weather { + temperature: now("temperature_2m"), + apparent: now("apparent_temperature"), + humidity: now("relative_humidity_2m"), + code: now("weather_code") as u32, + wind: now("wind_speed_10m"), + is_day: now("is_day") > 0.0, + days, + }) +} + +// a wmo weather code as a short phrase +pub fn weather_words(code: u32) -> &'static str { + match code { + 0 => "Clear", + 1 => "Mainly clear", + 2 => "Partly cloudy", + 3 => "Overcast", + 45 | 48 => "Fog", + 51 | 53 | 55 => "Drizzle", + 56 | 57 => "Freezing drizzle", + 61 | 63 => "Rain", + 65 => "Heavy rain", + 66 | 67 => "Freezing rain", + 71 | 73 => "Snow", + 75 => "Heavy snow", + 77 => "Snow grains", + 80..=82 => "Showers", + 85 | 86 => "Snow showers", + 95 => "Thunderstorm", + 96 | 99 => "Thunderstorm with hail", + _ => "Unknown", + } +} + +// the kinds of sky a weather icon draws +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Sky { + Clear, + PartlyCloudy, + Cloudy, + Fog, + Rain, + Snow, + Storm, +} + +// a wmo weather code as the sky to draw; unknown codes are cloudy +pub fn sky(code: u32) -> Sky { + match code { + 0 | 1 => Sky::Clear, + 2 => Sky::PartlyCloudy, + 45 | 48 => Sky::Fog, + 51 | 53 | 55 | 56 | 57 | 61 | 63 | 65 | 66 | 67 | 80..=82 => Sky::Rain, + 71 | 73 | 75 | 77 | 85 | 86 => Sky::Snow, + 95..=99 => Sky::Storm, + _ => Sky::Cloudy, + } +} + +// a notification body (freedesktop's markup subset) as plain text:
breaks the line, other tags go, entities are decoded +pub fn plain_body(body: &str) -> String { + unmarkup(body) +} + +// a markup tag: its name with any prefix, whether it closes or closes itself, and its length in bytes +struct Tag<'a> { + name: &'a str, + closing: bool, + empty: bool, + length: usize, +} + +// the tag at the start of the text; none when its '<' starts no tag, as in "a < b" +fn tag(text: &str) -> Option> { + let inside = text.strip_prefix('<')?; + let closing = inside.starts_with('/'); + let named = inside.strip_prefix('/').unwrap_or(inside); + let name = named.split(|letter: char| letter.is_whitespace() || letter == '/' || letter == '>').next()?; + + if !name.starts_with(|letter: char| letter.is_ascii_alphabetic() || letter == '_') { + return None; + } + + // a '>' in a quoted attribute doesn't end the tag + let mut quote = None; + + for (at, letter) in inside.char_indices() { + match (quote, letter) { + (None, '"' | '\'') => quote = Some(letter), + (Some(open), _) if open == letter => quote = None, + (None, '>') => return Some(Tag { name, closing, empty: inside.get(..at).is_some_and(|head| head.ends_with('/')), length: at + 2 }), + _ => {} + } + } + + None +} + +// the entity at the start of the text (& ' —) as its character and length; none for unknown ones +fn entity(text: &str) -> Option<(char, usize)> { + let (end, _) = text.char_indices().take(12).find(|(_, letter)| *letter == ';')?; + + let letter = match text.get(1..end)? { + "amp" => '&', + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + name => { + let number = name.strip_prefix('#')?; + let code = match number.strip_prefix(['x', 'X']) { + Some(hex) => u32::from_str_radix(hex, 16).ok()?, + None => number.parse().ok()?, + }; + + char::from_u32(code)? + } + }; + + Some((letter, end + 1)) +} + +// markup as trimmed plain text:
is a line break, other tags and comments go, cdata stays as written, entities are decoded +fn unmarkup(markup: &str) -> String { + let mut plain = String::with_capacity(markup.len()); + let mut rest = markup; + + while let Some(at) = rest.find(['<', '&']) { + let (before, from) = rest.split_at(at); + + plain.push_str(before); + + let skip = if let Some(inner) = from.strip_prefix("").unwrap_or(inner.len()); + + plain.push_str(inner.get(..end).unwrap_or("")); + + Some("".len()) + } else if from.starts_with("").map_or(from.len(), |end| end + "-->".len())) + } else if from.starts_with('&') { + entity(from).map(|(letter, length)| { + plain.push(letter); + length + }) + } else { + tag(from).map(|tag| { + if tag.name.eq_ignore_ascii_case("br") { + plain.push('\n'); + } + tag.length + }) + }; + + // a lone '<' or '&' stays as it is + let length = skip.unwrap_or(1); + + if skip.is_none() { + plain.push_str(from.get(..1).unwrap_or("")); + } + + rest = from.get(length..).unwrap_or(""); + } + + plain.push_str(rest); + + plain.trim().to_string() +} + +// the raw inner xml of every element with this local name, whatever its namespace prefix; "" for a self-closing one +pub fn xml_blocks(xml: &str, name: &str) -> Vec { + let mut blocks = Vec::new(); + let mut open = None; + let mut at = 0; + + while let Some(found) = xml.get(at..).and_then(|rest| rest.find('<')) { + let start = at + found; + let rest = xml.get(start..).unwrap_or(""); + + // cdata and comments may hold any '<' + if let Some((_, end)) = [(""), ("")].into_iter().find(|(begin, _)| rest.starts_with(begin)) { + at = start + rest.find(end).map_or(rest.len(), |close| close + end.len()); + + continue; + } + + let Some(tag) = tag(rest) else { + at = start + 1; + + continue; + }; + + if tag.name.rsplit(':').next() == Some(name) { + match (tag.closing, open) { + (false, None) if tag.empty => blocks.push(String::new()), + (false, None) => open = Some(start + tag.length), + (true, Some(inner)) => { + blocks.push(xml.get(inner..start).unwrap_or("").to_string()); + open = None; + } + _ => {} + } + } + + at = start + tag.length; + } + + blocks +} + +// the text of every element with this local name: tags dropped, entities decoded, cdata unwrapped, trimmed +pub fn xml_texts(xml: &str, name: &str) -> Vec { + xml_blocks(xml, name).iter().map(|block| unmarkup(block)).collect() +} + +// one calendar collection on a caldav server; color as "#RRGGBB" +#[derive(Debug, Clone, PartialEq)] +pub struct Calendar { + pub href: String, + pub name: String, + pub color: Option, +} + +/* + * the event calendars in a depth 1 propfind answer: responses whose resourcetype holds a + * calendar and whose component set, if listed, has VEVENT; the calendar home itself and + * reminders lists (VTODO only) are left out. a calendar without a name is named after its path + */ +pub fn dav_calendars(xml: &str) -> Vec { + xml_blocks(xml, "response") + .iter() + .filter_map(|response| { + // only the properties the server found, not the ones in a 404 propstat + let found: String = xml_blocks(response, "propstat").into_iter().filter(|propstat| xml_texts(propstat, "status").iter().all(|status| status.contains(" 200"))).collect(); + + let calendar = xml_blocks(&found, "resourcetype").iter().any(|kinds| !xml_blocks(kinds, "calendar").is_empty()); + let events = xml_blocks(&found, "supported-calendar-component-set").iter().all(|set| set.contains("VEVENT")); + + if !calendar || !events { + return None; + } + + let href = xml_texts(response, "href").into_iter().next()?; + let name = xml_texts(&found, "displayname").into_iter().find(|name| !name.is_empty()); + let name = name.unwrap_or_else(|| href.trim_end_matches('/').rsplit('/').next().unwrap_or("").to_string()); + + // apple's "#FF2968FF" carries an alpha pair + let color = xml_texts(&found, "calendar-color").into_iter().find_map(|color| { + let digits = color.strip_prefix('#').filter(|digits| matches!(digits.len(), 6 | 8) && digits.chars().all(|digit| digit.is_ascii_hexdigit()))?; + + Some(format!("#{}", digits.get(..6)?)) + }); + + Some(Calendar { href, name, color }) + }) + .collect() +} + +// one calendar event, start and end as unix seconds; an all-day event runs from local midnight +#[derive(Debug, Clone, PartialEq)] +pub struct Event { + pub start: i64, + pub end: i64, + pub all_day: bool, + pub summary: String, + pub location: String, +} + +// a VEVENT while its lines are read +#[derive(Default)] +struct Draft { + start: Option<(i64, bool)>, + end: Option, + duration: Option, + summary: String, + location: String, + cancelled: bool, + uid: String, + recurring: bool, +} + +impl Draft { + // the finished event, none without a start or when cancelled; with no end it lasts its duration, else a day or an hour + fn occurrence(self) -> Option { + let (start, all_day) = self.start.filter(|_| !self.cancelled)?; + let end = self.end.or_else(|| self.duration.map(|duration| start.saturating_add(duration))).unwrap_or(start + if all_day { 86_400 } else { 3_600 }); + + Some(Occurrence { event: Event { start, end, all_day, summary: self.summary, location: self.location }, uid: self.uid, recurring: self.recurring }) + } +} + +/* + * the events in an icalendar text (a caldav report's calendar-data): utc times as they are; + * dates, TZID and floating times as local time `offset_s` seconds east of utc. cancelled events + * are skipped, alarms can't overwrite their event, and recurrences are left to the server. + * the shell reads ics_occurrences; this is the events alone, as the tests read them + */ +#[cfg(test)] +pub fn ics_events(ics: &str, offset_s: i64) -> Vec { + ics_occurrences(ics, offset_s).into_iter().map(|occurrence| occurrence.event).collect() +} + +// one event of an icalendar text with what changing it takes: its UID, and whether it's part of a series +#[derive(Debug, Clone, PartialEq)] +pub struct Occurrence { + pub event: Event, + pub uid: String, + // an RRULE or RDATE, or one instance the server expanded from a series (it has a RECURRENCE-ID) + pub recurring: bool, +} + +// the events of an icalendar text like ics_events, each with its UID and whether it repeats +pub fn ics_occurrences(ics: &str, offset_s: i64) -> Vec { + let mut occurrences = Vec::new(); + let mut draft: Option = None; + + // how deep inside a VEVENT's own blocks, like VALARM, a line is + let mut nested = 0; + + for line in &unfolded(ics) { + let Some((head, value)) = content_line(line) else { + continue; + }; + + let key = head.split(';').next().unwrap_or(head).to_ascii_uppercase(); + + match (key.as_str(), draft.as_mut()) { + ("BEGIN", None) if value.eq_ignore_ascii_case("VEVENT") => draft = Some(Draft::default()), + ("BEGIN", Some(_)) => nested += 1, + ("END", Some(_)) if nested > 0 => nested -= 1, + ("END", Some(_)) if value.eq_ignore_ascii_case("VEVENT") => occurrences.extend(draft.take().and_then(Draft::occurrence)), + (_, Some(_)) if nested > 0 => {} + ("SUMMARY", Some(event)) => event.summary = ics_text(value), + ("LOCATION", Some(event)) => event.location = ics_text(value), + ("STATUS", Some(event)) => event.cancelled = value.trim().eq_ignore_ascii_case("CANCELLED"), + ("DTSTART", Some(event)) => event.start = ics_time(value, offset_s), + ("DTEND", Some(event)) => event.end = ics_time(value, offset_s).map(|(time, _)| time), + ("DURATION", Some(event)) => event.duration = ics_duration(value), + ("UID", Some(event)) => event.uid = ics_text(value.trim()), + ("RRULE" | "RDATE" | "RECURRENCE-ID", Some(event)) => event.recurring = true, + _ => {} + } + } + + occurrences +} + +// an icalendar or vcard text as whole content lines: a line starting with a space or tab continues the one before +fn unfolded(text: &str) -> Vec { + let mut lines: Vec = Vec::new(); + + for line in text.lines().map(|line| line.trim_end_matches('\r')) { + match (line.strip_prefix([' ', '\t']), lines.last_mut()) { + (Some(more), Some(last)) => last.push_str(more), + _ => lines.push(line.to_string()), + } + } + + lines +} + +// an ics line as (name with its parameters, value), split at the first ':' outside quotes +fn content_line(line: &str) -> Option<(&str, &str)> { + let mut quoted = false; + + let colon = line.find(|letter: char| { + if letter == '"' { + quoted = !quoted; + } + + letter == ':' && !quoted + })?; + + Some((line.get(..colon)?, line.get(colon + 1..)?)) +} + +// an ics DATE (20261016, a whole day) or DATE-TIME (20261016T090000, with Z for utc) as unix seconds and whether it's a whole day +fn ics_time(value: &str, offset_s: i64) -> Option<(i64, bool)> { + let value = value.trim(); + let number = |from: usize, to: usize| value.get(from..to)?.parse::().ok(); + + let month = number(4, 6).filter(|month| (1..=12).contains(month))?; + let day = number(6, 8).filter(|day| (1..=31).contains(day))?; + let midnight = days_from_civil(i64::from(number(0, 4)?), month, day) * 86_400; + + if value.len() == 8 { + return Some((midnight - offset_s, true)); + } + + value.get(8..9).filter(|letter| letter.eq_ignore_ascii_case("T"))?; + + let time = i64::from(number(9, 11)?) * 3_600 + i64::from(number(11, 13)?) * 60 + i64::from(number(13, 15).unwrap_or(0)); + let local = if value.ends_with(['Z', 'z']) { 0 } else { offset_s }; + + Some((midnight + time - local, false)) +} + +// an ics duration (PT1H30M, P1D, P1W, -PT15M) in seconds +fn ics_duration(value: &str) -> Option { + let value = value.trim(); + let (sign, rest) = match value.strip_prefix('-') { + Some(rest) => (-1, rest), + None => (1, value.strip_prefix('+').unwrap_or(value)), + }; + + let mut total: i64 = 0; + let mut number: i64 = 0; + + for letter in rest.strip_prefix('P')?.chars() { + let unit = match letter { + 'W' => 604_800, + 'D' => 86_400, + 'H' => 3_600, + 'M' => 60, + 'S' => 1, + 'T' => continue, + _ => { + number = number.checked_mul(10)?.checked_add(i64::from(letter.to_digit(10)?))?; + + continue; + } + }; + + total = number.checked_mul(unit)?.checked_add(total)?; + number = 0; + } + + Some(sign * total) +} + +// an ics text value without its escapes: \, \; \n \N and \\ +fn ics_text(value: &str) -> String { + let mut text = String::with_capacity(value.len()); + let mut letters = value.chars(); + + while let Some(letter) = letters.next() { + if letter != '\\' { + text.push(letter); + + continue; + } + + match letters.next() { + Some('n' | 'N') => text.push('\n'), + Some(other) => text.push(other), + None => text.push('\\'), + } + } + + text +} + +// days since 1970-01-01 for a date in the proleptic gregorian calendar (howard hinnant's days_from_civil) +pub fn days_from_civil(year: i64, month: u32, day: u32) -> i64 { + let (month, day) = (i64::from(month), i64::from(day)); + let year = if month <= 2 { year - 1 } else { year }; + + // 400-year eras, each 146097 days; march is the first month so leap days come last + let era = year.div_euclid(400); + let year_of_era = year - era * 400; + let day_of_year = (153 * ((month + 9) % 12) + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + + era * 146_097 + day_of_era - 719_468 +} + +// days since 1970-01-01 as (year, month, day) +pub fn civil_from_days(days: i64) -> (i64, u32, u32) { + let days = days + 719_468; + let era = days.div_euclid(146_097); + let day_of_era = days - era * 146_097; + let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let march_month = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * march_month + 2) / 5 + 1; + let month = if march_month < 10 { march_month + 3 } else { march_month - 9 }; + + (year_of_era + era * 400 + i64::from(month <= 2), month as u32, day as u32) +} + +// days since 1970-01-01 as a weekday, 0 monday to 6 sunday +pub fn weekday(days: i64) -> u32 { + // 1970-01-01 was a thursday + ((days.rem_euclid(7) + 3) % 7) as u32 +} + +// unix seconds as a utc time for caldav's time-range queries, like "20261015T000000Z" +pub fn dav_time(unix: i64) -> String { + let (year, month, day) = civil_from_days(unix.div_euclid(86_400)); + let seconds = unix.rem_euclid(86_400); + + format!("{year:04}{month:02}{day:02}T{:02}{:02}{:02}Z", seconds / 3_600, seconds / 60 % 60, seconds % 60) +} + +/* + * webdav answers beyond calendars' own: the href inside a property (current-user-principal, + * calendar-home-set, addressbook-home-set), a report's resources, address books, and the + * calendars the user may only read + */ +pub fn dav_href(xml: &str, property: &str) -> Option { + xml_blocks(xml, property).iter().find_map(|block| xml_texts(block, "href").into_iter().find(|href| !href.is_empty())) +} + +// one resource in a calendar-query or addressbook-query answer: its href, etag (quotes kept) and data +#[derive(Debug, Clone, PartialEq)] +pub struct Resource { + pub href: String, + pub etag: String, + pub data: String, +} + +// the resources in a report answer, `data` naming the element that holds each one's text (calendar-data, address-data) +pub fn dav_resources(xml: &str, data: &str) -> Vec { + xml_blocks(xml, "response") + .iter() + .filter_map(|response| { + let found: String = xml_blocks(response, "propstat").into_iter().filter(|propstat| xml_texts(propstat, "status").iter().all(|status| status.contains(" 200"))).collect(); + let href = xml_texts(response, "href").into_iter().next()?; + let text = xml_texts(&found, data).into_iter().next().filter(|text| !text.is_empty())?; + let etag = xml_texts(&found, "getetag").into_iter().next().unwrap_or_default(); + + Some(Resource { href, etag, data: text }) + }) + .collect() +} + +// the address books in a depth 1 propfind answer, by href +pub fn dav_addressbooks(xml: &str) -> Vec { + xml_blocks(xml, "response") + .iter() + .filter(|response| xml_blocks(response, "resourcetype").iter().any(|kinds| !xml_blocks(kinds, "addressbook").is_empty())) + .filter_map(|response| xml_texts(response, "href").into_iter().next()) + .collect() +} + +// the calendars in a propfind answer whose current-user-privilege-set grants no writing; one without the property counts as writable +pub fn dav_read_only(xml: &str) -> Vec { + xml_blocks(xml, "response") + .iter() + .filter(|response| { + let found: String = xml_blocks(response, "propstat").into_iter().filter(|propstat| xml_texts(propstat, "status").iter().all(|status| status.contains(" 200"))).collect(); + let sets = xml_blocks(&found, "current-user-privilege-set"); + + !sets.is_empty() && sets.iter().all(|set| ["write", "write-content", "all"].iter().all(|right| xml_blocks(set, right).is_empty())) + }) + .filter_map(|response| xml_texts(response, "href").into_iter().next()) + .collect() +} + +/* + * curl's output with `-w "\n%{http_code}\n%header{etag}"` after the body, as + * (body, status code, etag); the etag is empty when the server sent none, or when + * a curl older than 7.84 printed the variable as it was + */ +pub fn curl_answer(printed: &str) -> (&str, &str, &str) { + let mut parts = printed.rsplitn(3, '\n'); + let etag = parts.next().unwrap_or("").trim(); + let status = parts.next().unwrap_or(""); + let body = parts.next().unwrap_or(""); + + (body, status.trim(), if etag.starts_with("%header") { "" } else { etag }) +} + +/* + * text as a quoted value in a curl config file (read with -K - from stdin, which is how the + * password and the bodies reach curl): backslashes, quotes and line breaks escaped + */ +pub fn curl_quoted(text: &str) -> String { + let mut quoted = String::with_capacity(text.len() + 2); + + quoted.push('"'); + + for letter in text.chars() { + match letter { + '\\' => quoted.push_str("\\\\"), + '"' => quoted.push_str("\\\""), + '\n' => quoted.push_str("\\n"), + '\r' => quoted.push_str("\\r"), + '\t' => quoted.push_str("\\t"), + other => quoted.push(other), + } + } + + quoted.push('"'); + + quoted +} + +// a utc offset as `date +%z` prints it (+0200, -0930) in seconds east of utc +pub fn zone_offset(printed: &str) -> Option { + let printed = printed.trim(); + let (sign, digits) = match printed.strip_prefix('-') { + Some(digits) => (-1, digits), + None => (1, printed.strip_prefix('+').unwrap_or(printed)), + }; + + if digits.len() != 4 || !digits.chars().all(|digit| digit.is_ascii_digit()) { + return None; + } + + let hours: i64 = digits.get(..2)?.parse().ok()?; + let minutes: i64 = digits.get(2..)?.parse().ok()?; + + Some(sign * (hours * 3_600 + minutes * 60)) +} + +// a time of day as typed (9, 930, 0930, 9:30, 09.30, 9h30) in minutes after midnight +pub fn clock_minutes(typed: &str) -> Option { + let typed = typed.trim(); + let (hours, minutes) = match typed.find([':', '.', 'h', 'H']) { + Some(at) => (typed.get(..at)?, typed.get(at + 1..)?), + None if typed.len() <= 2 => (typed, "0"), + None if typed.len() <= 4 => typed.split_at(typed.len() - 2), + None => return None, + }; + + if hours.is_empty() || minutes.is_empty() || !hours.chars().chain(minutes.chars()).all(|digit| digit.is_ascii_digit()) || minutes.len() > 2 { + return None; + } + + let (hours, minutes): (u32, u32) = (hours.parse().ok()?, minutes.parse().ok()?); + + (hours < 24 && minutes < 60).then_some(hours * 60 + minutes) +} + +// days since 1970 as an icalendar DATE, like "20261016" +pub fn ics_date(days: i64) -> String { + let (year, month, day) = civil_from_days(days); + + format!("{year:04}{month:02}{day:02}") +} + +// text for an icalendar TEXT value (RFC 5545 3.3.11): backslashes, commas, semicolons and line breaks escaped +pub fn ics_escape(text: &str) -> String { + let mut escaped = String::with_capacity(text.len() + 8); + let mut letters = text.chars().peekable(); + + while let Some(letter) = letters.next() { + match letter { + '\\' => escaped.push_str("\\\\"), + ',' => escaped.push_str("\\,"), + ';' => escaped.push_str("\\;"), + '\n' => escaped.push_str("\\n"), + '\r' => { + letters.next_if_eq(&'\n'); + escaped.push_str("\\n"); + } + other => escaped.push(other), + } + } + + escaped +} + +// one content line folded so no line is longer than 75 octets (RFC 5545 3.1), never inside a utf-8 character, ending in CRLF +pub fn ics_fold(line: &str) -> String { + let mut folded = String::with_capacity(line.len() + line.len() / 64 * 3 + 2); + let mut used = 0; + + for letter in line.chars() { + if used + letter.len_utf8() > 75 { + folded.push_str("\r\n "); + used = 1; + } + + folded.push(letter); + used += letter.len_utf8(); + } + + folded.push_str("\r\n"); + + folded +} + +// when an event happens, as written: whole days from `first` (days since 1970) for `days` days, or utc unix seconds +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum When { + AllDay { first: i64, days: i64 }, + Timed { start: i64, end: i64 }, +} + +impl When { + fn lines(self) -> [String; 2] { + match self { + Self::AllDay { first, days } => [format!("DTSTART;VALUE=DATE:{}", ics_date(first)), format!("DTEND;VALUE=DATE:{}", ics_date(first + days.max(1)))], + Self::Timed { start, end } => [format!("DTSTART:{}", dav_time(start)), format!("DTEND:{}", dav_time(end.max(start)))], + } + } +} + +const PRODID: &str = "PRODID:-//Magic Island//amane-config//EN"; + +// a new calendar resource holding one event, `stamp` (unix seconds) as its creation time +pub fn ics_new(uid: &str, stamp: i64, summary: &str, location: &str, when: When) -> String { + let [start, end] = when.lines(); + let stamp = dav_time(stamp); + + let mut lines = vec![ + String::from("BEGIN:VCALENDAR"), + String::from("VERSION:2.0"), + String::from(PRODID), + String::from("CALSCALE:GREGORIAN"), + String::from("BEGIN:VEVENT"), + format!("UID:{}", ics_escape(uid)), + format!("DTSTAMP:{stamp}"), + format!("CREATED:{stamp}"), + format!("LAST-MODIFIED:{stamp}"), + start, + end, + format!("SUMMARY:{}", ics_escape(summary.trim())), + ]; + + if !location.trim().is_empty() { + lines.push(format!("LOCATION:{}", ics_escape(location.trim()))); + } + + lines.extend(["SEQUENCE:0", "END:VEVENT", "END:VCALENDAR"].map(String::from)); + + lines.iter().map(|line| ics_fold(line)).collect() +} + +// what an edit sets in an existing event; `when` none keeps its days and times +#[derive(Debug, Clone, PartialEq)] +pub struct Change { + pub summary: String, + pub location: String, + pub when: Option, +} + +/* + * an existing calendar resource with the change made, `stamp` the time of the edit. every + * VEVENT in it (a series and its moved instances) gets the summary and location; the days + * and times go only to VEVENTs without a RECURRENCE-ID, replacing DTSTART, DTEND and + * DURATION. SEQUENCE goes up, DTSTAMP and LAST-MODIFIED are the stamp, Apple's structured + * location goes when the location changes. alarms and everything else stay as they were. + * none when the text holds no VEVENT + */ +pub fn ics_change(ics: &str, stamp: i64, change: &Change) -> Option { + let stamp = dav_time(stamp); + let summary = format!("SUMMARY:{}", ics_escape(change.summary.trim())); + let location = (!change.location.trim().is_empty()).then(|| format!("LOCATION:{}", ics_escape(change.location.trim()))); + + let mut out: Vec = Vec::new(); + let mut events = 0; + + // the VEVENT being copied: its lines so far, how deep in its own blocks (VALARM) a line is + let mut event: Option> = None; + let mut nested = 0; + + for line in unfolded(ics) { + if line.trim().is_empty() { + continue; + } + + let Some((head, value)) = content_line(&line) else { + match event.as_mut() { + Some(lines) => lines.push(line), + None => out.push(line), + } + + continue; + }; + + let key = head.split(';').next().unwrap_or(head).to_ascii_uppercase(); + + let Some(lines) = event.as_mut() else { + if key == "BEGIN" && value.trim().eq_ignore_ascii_case("VEVENT") { + event = Some(vec![line]); + nested = 0; + } else { + out.push(line); + } + + continue; + }; + + match key.as_str() { + "BEGIN" => nested += 1, + "END" if nested > 0 => nested -= 1, + "END" => { + lines.push(line); + out.extend(changed_event(lines, &stamp, &summary, location.as_deref(), change)); + event = None; + events += 1; + + continue; + } + _ => {} + } + + lines.push(line); + } + + (events > 0).then(|| out.iter().map(|line| ics_fold(line)).collect()) +} + +// one VEVENT's lines (BEGIN to END) with the change made, see ics_change +fn changed_event(lines: &[String], stamp: &str, summary: &str, location: Option<&str>, change: &Change) -> Vec { + let key_of = |line: &str| content_line(line).map(|(head, _)| head.split(';').next().unwrap_or(head).to_ascii_uppercase()).unwrap_or_default(); + + // its own properties come before its first nested block (or its END) + let body_end = lines.iter().skip(1).position(|line| matches!(key_of(line).as_str(), "BEGIN" | "END")).map_or(lines.len(), |at| at + 1); + let (own, rest) = lines.split_at(body_end); + + let instance = own.iter().any(|line| key_of(line) == "RECURRENCE-ID"); + let when = change.when.filter(|_| !instance); + + let old_location = own.iter().find(|line| key_of(line) == "LOCATION").and_then(|line| content_line(line)).map(|(_, value)| ics_text(value)).unwrap_or_default(); + let moved = old_location.trim() != change.location.trim(); + + let sequence = own.iter().find(|line| key_of(line) == "SEQUENCE").and_then(|line| content_line(line)?.1.trim().parse::().ok()).map_or(1, |number| number + 1); + + let mut kept: Vec = Vec::new(); + + for line in own { + let key = key_of(line); + + let dropped = match key.as_str() { + "SUMMARY" | "LOCATION" | "SEQUENCE" | "DTSTAMP" | "LAST-MODIFIED" => true, + "X-APPLE-STRUCTURED-LOCATION" => moved, + "DTSTART" | "DTEND" | "DURATION" => when.is_some(), + _ => false, + }; + + if !dropped { + kept.push(line.clone()); + } + } + + kept.push(format!("DTSTAMP:{stamp}")); + kept.push(format!("LAST-MODIFIED:{stamp}")); + kept.push(format!("SEQUENCE:{sequence}")); + + if let Some(when) = when { + kept.extend(when.lines()); + } + + kept.push(summary.to_string()); + kept.extend(location.map(String::from)); + kept.extend(rest.iter().cloned()); + + kept +} + +// a contact's birthday; `year` none when the card leaves it out (Apple writes 1604 with X-APPLE-OMIT-YEAR) +#[derive(Debug, Clone, PartialEq)] +pub struct Birthday { + pub name: String, + pub month: u32, + pub day: u32, + pub year: Option, +} + +/* + * the birthdays in vcards (one or many in a text, 3.0 or 4.0): BDAY as 19900315, 1990-03-15, + * --0315, --03-15, with VALUE=date or a time after it, and Apple's year-less 1604-03-15 with + * X-APPLE-OMIT-YEAR=1604. the name is FN, else N's given and family names, else ORG + */ +pub fn vcard_birthdays(text: &str) -> Vec { + #[derive(Default)] + struct Card { + full: String, + parts: String, + org: String, + date: Option<(Option, u32, u32)>, + } + + let mut birthdays = Vec::new(); + let mut card: Option = None; + + for line in unfolded(text) { + let Some((head, value)) = content_line(&line) else { + continue; + }; + + let mut params = head.split(';'); + + // grouped properties like item1.X-ABDATE carry their group before a dot + let name = params.next().unwrap_or("").rsplit('.').next().unwrap_or("").to_ascii_uppercase(); + + match (name.as_str(), card.as_mut()) { + ("BEGIN", _) if value.trim().eq_ignore_ascii_case("VCARD") => card = Some(Card::default()), + ("END", Some(_)) if value.trim().eq_ignore_ascii_case("VCARD") => { + let Some(done) = card.take() else { continue }; + let Some((year, month, day)) = done.date else { continue }; + + let given_family = { + let fields: Vec = done.parts.split(';').map(ics_text).collect(); + let given = fields.get(1).map_or("", |given| given.trim()); + let family = fields.first().map_or("", |family| family.trim()); + + format!("{given} {family}").trim().to_string() + }; + + let name = [done.full.trim().to_string(), given_family, done.org.split(';').next().map(ics_text).unwrap_or_default().trim().to_string()].into_iter().find(|name| !name.is_empty()); + + if let Some(name) = name { + birthdays.push(Birthday { name, month, day, year }); + } + } + ("FN", Some(card)) => card.full = ics_text(value), + ("N", Some(card)) => card.parts = value.to_string(), + ("ORG", Some(card)) => card.org = value.to_string(), + ("BDAY", Some(card)) => { + let omitted = params.find_map(|param| { + let (key, number) = param.split_once('=')?; + + key.trim().eq_ignore_ascii_case("X-APPLE-OMIT-YEAR").then(|| number.trim().trim_matches('"').parse::().ok())? + }); + + card.date = birthday_date(value, omitted); + } + _ => {} + } + } + + birthdays +} + +// a BDAY value as (year if known, month, day) +fn birthday_date(value: &str, omitted_year: Option) -> Option<(Option, u32, u32)> { + let date = value.trim().split(['T', 't']).next().unwrap_or(""); + + let (year, digits) = match date.strip_prefix("--") { + Some(rest) => (None, rest.replace('-', "")), + None => { + let digits = date.replace('-', ""); + let year: i64 = digits.get(..4)?.parse().ok()?; + + (Some(year), digits.get(4..)?.to_string()) + } + }; + + if digits.len() != 4 || !digits.chars().all(|digit| digit.is_ascii_digit()) { + return None; + } + + let month: u32 = digits.get(..2)?.parse().ok()?; + let day: u32 = digits.get(2..)?.parse().ok()?; + + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + + Some((year.filter(|year| Some(*year) != omitted_year), month, day)) +} + +// easter sunday of a year in the gregorian calendar (the anonymous gregorian computus), as days since 1970 +pub fn easter(year: i64) -> i64 { + let golden = year.rem_euclid(19); + let (century, rest) = (year.div_euclid(100), year.rem_euclid(100)); + let (leap_centuries, century_rest) = (century / 4, century % 4); + let correction = (century + 8) / 25; + let moon = (century - correction + 1) / 3; + let epact = (19 * golden + century - leap_centuries - moon + 15) % 30; + let (leap_years, year_rest) = (rest / 4, rest % 4); + let weekday = (32 + 2 * century_rest + 2 * leap_years - epact - year_rest) % 7; + let shift = (golden + 11 * epact + 22 * weekday) / 451; + let month = (epact + weekday - 7 * shift + 114) / 31; + let day = (epact + weekday - 7 * shift + 114) % 31 + 1; + + days_from_civil(year, month as u32, day as u32) +} + +const STATES: [&str; 16] = ["BW", "BY", "BE", "BB", "HB", "HH", "HE", "MV", "NI", "NW", "RP", "SL", "SN", "ST", "SH", "TH"]; + +/* + * the public holidays (gesetzliche Feiertage) of a German state in a year, as (days since + * 1970, German name), in date order: the nationwide ones and the state's own. `state` is + * its code (BW, BY, BE, BB, HB, HH, HE, MV, NI, NW, RP, SL, SN, ST, SH, TH); empty gives + * none, an unknown code only the nationwide ones. school holidays are not public holidays. + * Mariä Himmelfahrt counts for all of BY although it's only official in its mostly catholic + * places, the majority; regional ones (Augsburg's Friedensfest, Fronleichnam in parts of + * SN and TH) are left out + */ +pub fn holidays(year: i64, state: &str) -> Vec<(i64, &'static str)> { + let state = state.trim().to_ascii_uppercase(); + + if state.is_empty() { + return Vec::new(); + } + + let known = STATES.contains(&state.as_str()); + let here = |states: &[&str]| known && states.contains(&state.as_str()); + let date = |month: u32, day: u32| days_from_civil(year, month, day); + let easter = easter(year); + + let mut days: Vec<(i64, &'static str)> = vec![ + (date(1, 1), "Neujahr"), + (easter - 2, "Karfreitag"), + (easter + 1, "Ostermontag"), + (date(5, 1), "Tag der Arbeit"), + (easter + 39, "Christi Himmelfahrt"), + (easter + 50, "Pfingstmontag"), + (date(12, 25), "1. Weihnachtstag"), + (date(12, 26), "2. Weihnachtstag"), + ]; + + if year >= 1990 { + days.push((date(10, 3), "Tag der Deutschen Einheit")); + } + + if here(&["BW", "BY", "ST"]) { + days.push((date(1, 6), "Heilige Drei Könige")); + } + + if (here(&["BE"]) && year >= 2019) || (here(&["MV"]) && year >= 2023) { + days.push((date(3, 8), "Internationaler Frauentag")); + } + + if here(&["BB", "HE"]) { + days.push((easter, "Ostersonntag")); + days.push((easter + 49, "Pfingstsonntag")); + } + + if here(&["BE"]) && (year == 2020 || year == 2025) { + days.push((date(5, 8), "Tag der Befreiung")); + } + + if here(&["BW", "BY", "HE", "NW", "RP", "SL"]) { + days.push((easter + 60, "Fronleichnam")); + } + + if here(&["BY", "SL"]) { + days.push((date(8, 15), "Mariä Himmelfahrt")); + } + + if here(&["TH"]) && year >= 2019 { + days.push((date(9, 20), "Weltkindertag")); + } + + if year == 2017 || here(&["BB", "MV", "SN", "ST", "TH"]) || (here(&["HB", "HH", "NI", "SH"]) && year >= 2018) { + days.push((date(10, 31), "Reformationstag")); + } + + if here(&["BW", "BY", "NW", "RP", "SL"]) { + days.push((date(11, 1), "Allerheiligen")); + } + + // the wednesday before 23 november; everywhere until 1994 + if here(&["SN"]) || year <= 1994 { + let before = date(11, 22); + + days.push((before - (i64::from(weekday(before)) + 5) % 7, "Buß- und Bettag")); + } + + days.sort_by_key(|(day, _)| *day); + + days +} + #[cfg(test)] mod tests { use super::*; @@ -1539,4 +2740,441 @@ Sink #80 assert_eq!(hypr_lua(HyprKind::Float, "3").unwrap(), "3.0"); assert_eq!(hypr_lua(HyprKind::Text, "say \"hi\"").unwrap(), "\"say \\\"hi\\\"\""); } + + #[test] + fn writes_json_strings() { + let text = "say \"hi\" \\ to\n\tthem\r\u{1} — ok"; + + assert_eq!(json_string(text), "\"say \\\"hi\\\" \\\\ to\\n\\tthem\\r\\u0001 — ok\""); + assert_eq!(json(&json_string(text)), [Value::Text(String::from(text))]); + assert_eq!(json_string(""), "\"\""); + } + + #[test] + fn reads_ollama_streams() { + let stream = r#"{"model":"gemma4","created_at":"2026-10-15T10:12:01.123Z","message":{"role":"assistant","content":"Hi"},"done":false} +{"model":"gemma4","created_at":"2026-10-15T10:12:01.180Z","message":{"role":"assistant","content":" there, \"friend\"\n"},"done":false} +{"model":"gemma4","created_at":"2026-10-15T10:12:01.230Z","message":{"role":"assistant","content":""},"done_reason":"stop","done":true,"total_duration":1234567890,"eval_count":12} +"#; + + let chunks: Vec = stream.lines().filter_map(ollama_chunk).collect(); + + assert_eq!(chunks, [Chunk::Text(String::from("Hi")), Chunk::Text(String::from(" there, \"friend\"\n")), Chunk::Done]); + assert_eq!(ollama_chunk(r#"{"error":"model 'x' not found"}"#), Some(Chunk::Failed(String::from("model 'x' not found")))); + assert_eq!(ollama_chunk(""), None); + assert_eq!(ollama_chunk("Internal Server Error"), None); + assert_eq!(ollama_chunk(r#"{"model":"gemma4","message":{"role":"assistant","content":""},"done":false}"#), None); + } + + #[test] + fn reads_proc_and_sys() { + let stat = "cpu 10132153 290696 3084719 46828483 16683 0 25195 0 175628 0\ncpu0 1393280 32966 572056 13343292 6130 0 17875 0 23933 0\ncpu1 1335736 37133 425613 13347651 2152 0 3051 0 23498 0\nintr 1462898 0 9 0\nctxt 2633617\nbtime 1792022400\nprocesses 10423\n"; + + // busy is user + nice + system + irq + softirq + steal; guest columns are already in user + assert_eq!(cpu_jiffies(stat), Some((13_532_763, 60_377_929))); + assert_eq!(cpu_jiffies("cpu0 1 2 3 4\n"), None); + + let meminfo = "MemTotal: 32718416 kB\nMemFree: 2034560 kB\nMemAvailable: 20513204 kB\nBuffers: 612340 kB\nCached: 17012344 kB\nSwapCached: 128 kB\nActive: 9216000 kB\nSwapTotal: 8388604 kB\nSwapFree: 8126460 kB\nDirty: 420 kB\n"; + + assert_eq!(memory(meminfo), Some(Memory { total_kb: 32_718_416, available_kb: 20_513_204, swap_total_kb: 8_388_604, swap_free_kb: 8_126_460 })); + assert_eq!(memory("MemTotal: 1000 kB\nMemAvailable: 600 kB\n"), Some(Memory { total_kb: 1000, available_kb: 600, ..Memory::default() })); + assert_eq!(memory("MemTotal: 1000 kB\nMemFree: 600 kB\n"), None); + + let dev = "Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + lo: 2776770 11307 0 0 0 0 0 0 2776770 11307 0 0 0 0 0 0 + wlan0: 1215645 2751 0 0 0 0 0 0 176304 1576 0 0 0 0 0 0 + eth0:4294967295123 3500000 0 0 0 0 0 1200 98765432 600000 0 0 0 0 0 0 +"; + + assert_eq!(network_bytes(dev), (1_215_645 + 4_294_967_295_123, 176_304 + 98_765_432)); + assert_eq!(network_bytes(""), (0, 0)); + + let sda = " 6431 2209 451390 2563 9036 9837 627608 22412 0 14812 25000 0 0 0 0 389 123\n"; + + assert_eq!(block_sectors(sda), Some((451_390, 627_608))); + assert_eq!(block_sectors("1 2 3"), None); + } + + #[test] + fn reads_open_meteo() { + let answer = r#"{"latitude":52.52,"longitude":13.419998,"generationtime_ms":0.0581741333,"utc_offset_seconds":7200,"timezone":"Europe/Berlin","timezone_abbreviation":"GMT+2","elevation":38.0, +"current_units":{"time":"iso8601","interval":"seconds","temperature_2m":"°C","apparent_temperature":"°C","relative_humidity_2m":"%","weather_code":"wmo code","wind_speed_10m":"km/h","is_day":""}, +"current":{"time":"2026-10-15T12:15","interval":900,"temperature_2m":14.2,"apparent_temperature":12.9,"relative_humidity_2m":71,"weather_code":3,"wind_speed_10m":9.4,"is_day":1}, +"daily_units":{"time":"iso8601","weather_code":"wmo code","temperature_2m_max":"°C","temperature_2m_min":"°C","sunrise":"iso8601","sunset":"iso8601","precipitation_probability_max":"%"}, +"daily":{"time":["2026-10-15","2026-10-16"],"weather_code":[3,61],"temperature_2m_max":[15.1,12],"temperature_2m_min":[8.4,7.9],"sunrise":["2026-10-15T07:34","2026-10-16T07:36"],"sunset":["2026-10-15T18:31","2026-10-16T18:29"],"precipitation_probability_max":[20,null]}}"#; + + let expected = Weather { + temperature: 14.2, + apparent: 12.9, + humidity: 71.0, + code: 3, + wind: 9.4, + is_day: true, + days: vec![ + Day { date: String::from("2026-10-15"), code: 3, max: 15.1, min: 8.4, sunrise: String::from("07:34"), sunset: String::from("18:31"), rain_chance: 20.0 }, + Day { date: String::from("2026-10-16"), code: 61, max: 12.0, min: 7.9, sunrise: String::from("07:36"), sunset: String::from("18:29"), rain_chance: 0.0 }, + ], + }; + + assert_eq!(weather(answer), Some(expected)); + assert_eq!(weather(r#"{"error":true,"reason":"Latitude must be in range of -90 to 90°. Given: 91.0."}"#), None); + assert_eq!(weather(""), None); + assert_eq!(weather(r#"{"current":{"temperature_2m":-3,"is_day":0}}"#).map(|now| (now.temperature, now.is_day, now.days.len())), Some((-3.0, false, 0))); + + assert_eq!( + [0, 2, 3, 48, 53, 57, 63, 65, 67, 73, 75, 77, 81, 86, 95, 99, 42].map(weather_words), + ["Clear", "Partly cloudy", "Overcast", "Fog", "Drizzle", "Freezing drizzle", "Rain", "Heavy rain", "Freezing rain", "Snow", "Heavy snow", "Snow grains", "Showers", "Snow showers", "Thunderstorm", "Thunderstorm with hail", "Unknown"] + ); + assert_eq!( + [1, 2, 3, 45, 55, 66, 82, 77, 85, 96, 42].map(sky), + [Sky::Clear, Sky::PartlyCloudy, Sky::Cloudy, Sky::Fog, Sky::Rain, Sky::Rain, Sky::Rain, Sky::Snow, Sky::Snow, Sky::Storm, Sky::Cloudy] + ); + } + + #[test] + fn reads_notification_markup() { + let body = r#" Ann & Bob sent a link
3 < 4 > 2, it's "fine" 'ok'
— — &bogus; AT&T a < b
xend "#; + + assert_eq!(plain_body(body), "Ann & Bob sent a link\n3 < 4 > 2, it's \"fine\" 'ok'\n— — &bogus; AT&T a < b\nend"); + assert_eq!(plain_body("x <3"), "x <3"); + assert_eq!(plain_body("<b>literal</b>"), "literal"); + } + + const PROPFIND: &str = r##" +/123456789/calendars/CalendarsHTTP/1.1 200 OKHTTP/1.1 404 Not Found/123456789/calendars/home/Home & Family#FF2968FFHTTP/1.1 200 OK/123456789/calendars/tasks/Reminders#1BADF8FFHTTP/1.1 200 OK"##; + + #[test] + fn reads_caldav_xml() { + let mixed = r#"/a//b/#fff"#; + + assert_eq!(xml_blocks(mixed, "response"), ["/a/", "/b/"]); + assert_eq!(xml_blocks(mixed, "href"), ["/a/", "/b/", ""]); + assert!(xml_blocks(mixed, "calendar").is_empty()); + assert_eq!( + xml_texts(r#" & more]]> Tom & Jerry's"#, "displayname"), + ["Work & more", "Tom & Jerry's"] + ); + + // the home collection and the reminders list are left out + assert_eq!( + dav_calendars(PROPFIND), + [Calendar { href: String::from("/123456789/calendars/home/"), name: String::from("Home & Family"), color: Some(String::from("#FF2968")) }] + ); + + // nextcloud's prefixes, an empty name and no colour or component set + let nextcloud = r#"/remote.php/dav/calendars/ann/work/HTTP/1.1 200 OK"#; + + assert_eq!(dav_calendars(nextcloud), [Calendar { href: String::from("/remote.php/dav/calendars/ann/work/"), name: String::from("work"), color: None }]); + } + + #[test] + fn reads_icalendar_events() { + let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Apple Inc.//iCloud 2.0//EN\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nDTSTART:19810329T020000\r\nTZOFFSETTO:+0200\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\n\ + BEGIN:VEVENT\r\nUID:a1\r\nDTSTART;VALUE=DATE:20261016\r\nDTEND;VALUE=DATE:20261017\r\nSUMMARY:Mum's birthday\r\nRRULE:FREQ=YEARLY\r\nEND:VEVENT\r\n\ + BEGIN:VEVENT\r\nUID:a2\r\nDTSTART:20261016T090000Z\r\nDURATION:PT1H30M\r\nSUMMARY:Stand-up\\, team A\\; B\r\nLOCATION:Room 4\\nFloor 2 \\\\ east\r\nEND:VEVENT\r\n\ + BEGIN:VEVENT\r\nUID:a3\r\nDTSTART;TZID=Europe/Berlin:20261016T110000\r\nDTEND;TZID=Europe/Berlin:20261016T120000\r\nSUMMARY:A very long meeting title that the serv\r\n er folded onto a second line\r\nLOCATION:Caf\r\n\té Einstein\r\n\ + BEGIN:VALARM\r\nTRIGGER:-PT15M\r\nACTION:DISPLAY\r\nSUMMARY:Alarm\r\nDTSTART:19760401T005545Z\r\nEND:VALARM\r\nEND:VEVENT\r\n\ + BEGIN:VEVENT\r\nUID:a4\r\nDTSTART:20261017T100000Z\r\nSUMMARY:Called off\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\n\ + BEGIN:VEVENT\nUID:a5\nDTSTART:20261018T080000\nSUMMARY:Floating\\, LF only\nEND:VEVENT\nEND:VCALENDAR\r\n"; + + // CEST, two hours east of utc; 2026-10-16T00:00Z is 1792108800 + let event = |start, end, all_day, summary: &str, location: &str| Event { start, end, all_day, summary: String::from(summary), location: String::from(location) }; + + assert_eq!( + ics_events(ics, 7_200), + [ + event(1_792_101_600, 1_792_188_000, true, "Mum's birthday", ""), + event(1_792_141_200, 1_792_146_600, false, "Stand-up, team A; B", "Room 4\nFloor 2 \\ east"), + event(1_792_141_200, 1_792_144_800, false, "A very long meeting title that the server folded onto a second line", "Café Einstein"), + event(1_792_303_200, 1_792_306_800, false, "Floating, LF only", ""), + ] + ); + + // no end: an all-day event lasts a day; an event without a start is dropped + let short = "BEGIN:VEVENT\nDTSTART;VALUE=DATE:20261020\nSUMMARY:Holiday\nEND:VEVENT\nBEGIN:VEVENT\nSUMMARY:No start\nEND:VEVENT\n"; + + assert_eq!(ics_events(short, 0), [event(1_792_454_400, 1_792_540_800, true, "Holiday", "")]); + assert!(ics_events("", 0).is_empty()); + } + + #[test] + fn counts_days() { + assert_eq!(days_from_civil(1970, 1, 1), 0); + assert_eq!(days_from_civil(2024, 2, 29), 19_782); + assert_eq!(days_from_civil(2026, 10, 15) * 86_400, 1_792_022_400); + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(19_782), (2024, 2, 29)); + assert_eq!(civil_from_days(19_783), (2024, 3, 1)); + assert_eq!(civil_from_days(-1), (1969, 12, 31)); + + // every day from 1422 to 2517 reads back, one day after the other + let mut previous = civil_from_days(-200_001); + + for days in -200_000..200_000 { + let (year, month, day) = civil_from_days(days); + + assert_eq!(days_from_civil(year, month, day), days); + assert!((year, month, day) > previous && (1..=12).contains(&month) && (1..=31).contains(&day)); + + previous = (year, month, day); + } + + // thursday 1970-01-01, thursday 2024-02-29, thursday 2026-10-15, sunday 2026-10-18 + assert_eq!([0, -1, 19_782, days_from_civil(2026, 10, 15), days_from_civil(2026, 10, 18)].map(weekday), [3, 2, 3, 3, 6]); + + assert_eq!(dav_time(1_792_022_400), "20261015T000000Z"); + assert_eq!(dav_time(1_792_146_607), "20261016T103007Z"); + assert_eq!(dav_time(-1), "19691231T235959Z"); + } + + #[test] + fn reads_dav_discovery_and_reports() { + // icloud's principal, its calendar home on another host, and a carddav home + let principal = r#"//123456789/principal/HTTP/1.1 200 OK"#; + let home = r#"/123456789/principal/https://p42-caldav.icloud.com:443/123456789/calendars/HTTP/1.1 200 OK"#; + let cards = r#"/123456789/principal/https://p42-contacts.icloud.com:443/123456789/carddavhome/HTTP/1.1 200 OK"#; + + assert_eq!(dav_href(principal, "current-user-principal").as_deref(), Some("/123456789/principal/")); + assert_eq!(dav_href(home, "calendar-home-set").as_deref(), Some("https://p42-caldav.icloud.com:443/123456789/calendars/")); + assert_eq!(dav_href(cards, "addressbook-home-set").as_deref(), Some("https://p42-contacts.icloud.com:443/123456789/carddavhome/")); + assert_eq!(dav_href(principal, "calendar-home-set"), None); + + let books = r#"/123456789/carddavhome/HTTP/1.1 200 OK/123456789/carddavhome/card/HTTP/1.1 200 OK"#; + + assert_eq!(dav_addressbooks(books), ["/123456789/carddavhome/card/"]); + + // a calendar shared read-only, the user's own with every right, one that doesn't say + let rights = r#"/1/calendars/shared/HTTP/1.1 200 OK/1/calendars/home/HTTP/1.1 200 OK/1/calendars/work/HTTP/1.1 404 Not Found"#; + + assert_eq!(dav_read_only(rights), ["/1/calendars/shared/"]); + + // a calendar-query answer: icloud's cdata, entity-escaped data with , a 404 one left out + let report = "/123456789/calendars/home/6B1C9E6A.ics\"mfe0k8mi\"\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n]]>HTTP/1.1 200 OK\ + /123456789/calendars/home/x2.ics"abc"BEGIN:VCALENDAR \nSUMMARY:Tom & Jerry \nEND:VCALENDAR \nHTTP/1.1 200 OK\ + /123456789/calendars/home/gone.icsHTTP/1.1 404 Not Found"; + + let found = dav_resources(report, "calendar-data"); + + assert_eq!(found.len(), 2); + assert_eq!((found[0].href.as_str(), found[0].etag.as_str()), ("/123456789/calendars/home/6B1C9E6A.ics", "\"mfe0k8mi\"")); + assert!(found[0].data.starts_with("BEGIN:VCALENDAR\r\n") && found[0].data.contains("SUMMARY:A & B \r\n")); + assert_eq!((found[1].etag.as_str(), found[1].data.as_str()), ("\"abc\"", "BEGIN:VCALENDAR\r\nSUMMARY:Tom & Jerry\r\nEND:VCALENDAR")); + + assert_eq!(curl_answer("\nline\n207\n\"etag-1\""), ("\nline", "207", "\"etag-1\"")); + assert_eq!(curl_answer("\n000\n"), ("", "000", "")); + assert_eq!(curl_answer(""), ("", "", "")); + assert_eq!(curl_answer("ok\n204\n%header{etag}"), ("ok", "204", "")); + assert_eq!(curl_quoted("ann@icloud.com:ab\"c\\d"), "\"ann@icloud.com:ab\\\"c\\\\d\""); + assert_eq!(curl_quoted("BEGIN:VCALENDAR\r\n\tX"), "\"BEGIN:VCALENDAR\\r\\n\\tX\""); + } + + #[test] + fn reads_offsets_and_typed_times() { + assert_eq!(["+0200\n", "-0930", "+0000", "0100", "+02", "bogus", ""].map(zone_offset), [Some(7_200), Some(-34_200), Some(0), Some(3_600), None, None, None]); + + assert_eq!( + ["9", "930", "0930", "9:30", "09.30", "9h30", "23:59", "0:00", "1:5", " 18:45 "].map(clock_minutes), + [Some(540), Some(570), Some(570), Some(570), Some(570), Some(570), Some(1_439), Some(0), Some(65), Some(1_125)] + ); + assert_eq!(["24:00", "12:60", "", "ab", "12345", ":30", "9:", "9:300", "-1"].map(clock_minutes), [None; 9]); + } + + #[test] + fn reads_series_and_instances() { + // icloud's answer to an expanded report: an instance of a series in utc, and an all-day event + let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN\r\n\ + BEGIN:VEVENT\r\nUID:series-1\r\nDTSTART:20261019T080000Z\r\nDTEND:20261019T083000Z\r\nRECURRENCE-ID:20261019T070000Z\r\nSUMMARY:Stand-up\r\nEND:VEVENT\r\n\ + BEGIN:VEVENT\r\nUID:one-off\r\nDTSTART;VALUE=DATE:20261020\r\nDTEND;VALUE=DATE:20261021\r\nSUMMARY:Day off\r\nEND:VEVENT\r\n\ + BEGIN:VEVENT\r\nUID:weekly\r\nDTSTART:20261021T100000Z\r\nRRULE:FREQ=WEEKLY\r\nSUMMARY:Unexpanded\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + + let day = |month, day| days_from_civil(2026, month, day) * 86_400; + let found = ics_occurrences(ics, 7_200); + + assert_eq!(found.iter().map(|occurrence| (occurrence.uid.as_str(), occurrence.recurring)).collect::>(), [("series-1", true), ("one-off", false), ("weekly", true)]); + assert_eq!((found[0].event.start, found[0].event.end, found[0].event.all_day), (day(10, 19) + 8 * 3_600, day(10, 19) + 8 * 3_600 + 1_800, false)); + assert_eq!((found[1].event.start, found[1].event.end, found[1].event.all_day), (day(10, 20) - 7_200, day(10, 21) - 7_200, true)); + assert_eq!(ics_events(ics, 7_200), found.iter().map(|occurrence| occurrence.event.clone()).collect::>()); + } + + #[test] + fn writes_icalendar() { + assert_eq!(ics_escape("a, b; c\\d\ne\r\nf"), "a\\, b\\; c\\\\d\\ne\\nf"); + assert_eq!(ics_text(&ics_escape("Tom, Jerry; \\ and\nmore")), "Tom, Jerry; \\ and\nmore"); + assert_eq!(ics_date(days_from_civil(2026, 10, 16)), "20261016"); + + // 100 letters: 75 on the first line, a space and the other 25 on the next + let long = "X".repeat(100); + + assert_eq!(ics_fold(&long), format!("{}\r\n {}\r\n", "X".repeat(75), "X".repeat(25))); + assert_eq!(ics_fold("SHORT:yes"), "SHORT:yes\r\n"); + + // two-byte letters are never cut, and no line passes 75 octets + let wide = format!("SUMMARY:{}", "ä".repeat(80)); + let folded = ics_fold(&wide); + + assert!(folded.split("\r\n").all(|line| line.len() <= 75)); + assert_eq!(folded.split("\r\n").next().map(str::len), Some(74)); + assert_eq!(unfolded(&folded), [wide]); + + let stamp = 1_792_022_400; + let start = days_from_civil(2026, 10, 16) * 86_400 + 9 * 3_600; + + assert_eq!( + ics_new("ABC-1", stamp, "Lunch, with Ann; Bob ", "Café Einstein", When::Timed { start, end: start + 5_400 }), + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Magic Island//amane-config//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:ABC-1\r\nDTSTAMP:20261015T000000Z\r\nCREATED:20261015T000000Z\r\n\ + LAST-MODIFIED:20261015T000000Z\r\nDTSTART:20261016T090000Z\r\nDTEND:20261016T103000Z\r\nSUMMARY:Lunch\\, with Ann\\; Bob\r\nLOCATION:Café Einstein\r\nSEQUENCE:0\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + ); + + // an all-day event over two days reads back; no location, no LOCATION line + let trip = ics_new("T-2", stamp, "Trip", " ", When::AllDay { first: days_from_civil(2026, 10, 16), days: 2 }); + + assert!(trip.contains("\r\nDTSTART;VALUE=DATE:20261016\r\nDTEND;VALUE=DATE:20261018\r\n") && !trip.contains("LOCATION")); + assert_eq!( + ics_occurrences(&trip, 0), + [Occurrence { + event: Event { start: days_from_civil(2026, 10, 16) * 86_400, end: days_from_civil(2026, 10, 18) * 86_400, all_day: true, summary: String::from("Trip"), location: String::new() }, + uid: String::from("T-2"), + recurring: false + }] + ); + } + + #[test] + fn changes_icalendar_events() { + // an event as apple calendar stores it on icloud: a zone, a structured location, an alarm + let stored = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Apple Inc.//macOS 15.0//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nTZOFFSETFROM:+0100\r\n\ + RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nDTSTART:19810329T020000\r\nTZNAME:CEST\r\nTZOFFSETTO:+0200\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\n\ + BEGIN:VEVENT\r\nCREATED:20261001T080000Z\r\nDTEND;TZID=Europe/Berlin:20261016T120000\r\nDTSTAMP:20261001T080000Z\r\nDTSTART;TZID=Europe/Berlin:20261016T110000\r\n\ + LAST-MODIFIED:20261001T080000Z\r\nLOCATION:Café Einstein\\nUnter den Linden 42\r\nSEQUENCE:2\r\nSUMMARY:Coffee\r\nUID:6B1C9E6A-1F7A-4C8E-9C51-0E3F2D8A7B11\r\n\ + X-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-ADDRESS=Unter den Linden 42;X-APPLE-RADIUS=70;X-TITLE=\"Café Einst\r\n ein\":geo:52.517,13.389\r\n\ + BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT15M\r\nUID:ALARM-1\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + + let stamp = 1_792_022_400; + let start = days_from_civil(2026, 10, 16) * 86_400 + 11 * 3_600; + let change = Change { summary: String::from("Coffee with Ann"), location: String::from("Home"), when: Some(When::Timed { start, end: start + 3_600 }) }; + let changed = ics_change(stored, stamp, &change).unwrap_or_default(); + + // the zone stays as it was; the event keeps its uid, creation and alarm + assert!(changed.starts_with("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Apple Inc.//macOS 15.0//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\n")); + assert!(changed.ends_with( + "BEGIN:VEVENT\r\nCREATED:20261001T080000Z\r\nUID:6B1C9E6A-1F7A-4C8E-9C51-0E3F2D8A7B11\r\nDTSTAMP:20261015T000000Z\r\nLAST-MODIFIED:20261015T000000Z\r\nSEQUENCE:3\r\n\ + DTSTART:20261016T110000Z\r\nDTEND:20261016T120000Z\r\nSUMMARY:Coffee with Ann\r\nLOCATION:Home\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT15M\r\n\ + UID:ALARM-1\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + )); + assert_eq!( + ics_events(&changed, 7_200), + [Event { start, end: start + 3_600, all_day: false, summary: String::from("Coffee with Ann"), location: String::from("Home") }] + ); + + // the same location keeps apple's structured one; no times given keeps them; an empty location drops it + let renamed = ics_change(stored, stamp, &Change { summary: String::from("Coffee"), location: String::from("Café Einstein\nUnter den Linden 42"), when: None }).unwrap_or_default(); + + assert!(renamed.contains("X-APPLE-STRUCTURED-LOCATION") && renamed.contains("DTSTART;TZID=Europe/Berlin:20261016T110000\r\n")); + + let cleared = ics_change(stored, stamp, &Change { summary: String::from("Coffee"), location: String::new(), when: None }).unwrap_or_default(); + + assert!(!cleared.contains("LOCATION")); + + // a series with a moved instance: both renamed, neither's days touched, each SEQUENCE up by one + let series = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Apple Inc.//iOS 18.0//EN\n\ + BEGIN:VEVENT\nUID:series-1\nDTSTART;TZID=Europe/Berlin:20261005T090000\nDTEND;TZID=Europe/Berlin:20261005T093000\nRRULE:FREQ=WEEKLY;BYDAY=MO\nEXDATE;TZID=Europe/Berlin:20261012T090000\nSUMMARY:Stand-up\nDTSTAMP:20261001T080000Z\nEND:VEVENT\n\ + BEGIN:VEVENT\nUID:series-1\nRECURRENCE-ID;TZID=Europe/Berlin:20261019T090000\nDTSTART;TZID=Europe/Berlin:20261019T100000\nDTEND;TZID=Europe/Berlin:20261019T103000\nSUMMARY:Stand-up\nSEQUENCE:1\nDTSTAMP:20261001T080000Z\nEND:VEVENT\n\ + END:VCALENDAR\n"; + + let renamed = ics_change(series, stamp, &Change { summary: String::from("Daily, sync"), location: String::new(), when: None }).unwrap_or_default(); + let read = ics_occurrences(&renamed, 7_200); + + assert_eq!(read.iter().map(|occurrence| (occurrence.event.summary.as_str(), occurrence.recurring)).collect::>(), [("Daily, sync", true), ("Daily, sync", true)]); + assert_eq!(read.iter().map(|occurrence| occurrence.event.start).collect::>(), ics_occurrences(series, 7_200).iter().map(|occurrence| occurrence.event.start).collect::>()); + assert!(renamed.contains("RRULE:FREQ=WEEKLY;BYDAY=MO\r\n") && renamed.contains("EXDATE;TZID=Europe/Berlin:20261012T090000\r\n")); + assert_eq!(renamed.matches("SEQUENCE:1\r\n").count() + renamed.matches("SEQUENCE:2\r\n").count(), 2); + + // times given to a series go to its first VEVENT only, never to a moved instance + let moved = ics_change(series, stamp, &Change { summary: String::from("x"), location: String::new(), when: Some(When::AllDay { first: days_from_civil(2026, 10, 6), days: 1 }) }).unwrap_or_default(); + + assert_eq!(moved.matches("DTSTART;VALUE=DATE:20261006").count(), 1); + assert!(moved.contains("DTSTART;TZID=Europe/Berlin:20261019T100000")); + + assert_eq!(ics_change("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", stamp, &change), None); + } + + #[test] + fn reads_birthdays_from_vcards() { + // icloud's vcard 3.0 with a folded name, apple's year-less date, vcard 4.0 forms, fallbacks + let cards = "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:-//Apple Inc.//iOS 18.0//EN\r\nN:Müller;Anna;;;\r\nFN:Anna Mül\r\n ler\r\nBDAY;VALUE=date:1990-03-15\r\nEND:VCARD\r\n\ + BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;;;\r\nFN:Smith\\, John\r\nBDAY;X-APPLE-OMIT-YEAR=1604:1604-07-02\r\nEND:VCARD\r\n\ + BEGIN:VCARD\nVERSION:4.0\nFN:Lena\nBDAY:--0704\nEND:VCARD\n\ + BEGIN:VCARD\nVERSION:4.0\nFN:Max\nBDAY:--07-05\nEND:VCARD\n\ + BEGIN:VCARD\nVERSION:3.0\nN:Doe;Jane;;;\nBDAY:19851224\nEND:VCARD\n\ + BEGIN:VCARD\nVERSION:3.0\nORG:ACME GmbH;Sales\nBDAY:2001-10-16T00:00:00Z\nEND:VCARD\n\ + BEGIN:VCARD\nVERSION:3.0\nFN:No Birthday\nTEL:+49 89 1234\nEND:VCARD\n\ + BEGIN:VCARD\nVERSION:3.0\nFN:Broken\nBDAY:1990-13-40\nEND:VCARD\n\ + BEGIN:VCARD\nVERSION:3.0\nFN:Leap\nbday;value=date:2000-02-29\nEND:VCARD\n"; + + let birthday = |name: &str, month, day, year| Birthday { name: String::from(name), month, day, year }; + + assert_eq!( + vcard_birthdays(cards), + [ + birthday("Anna Müller", 3, 15, Some(1990)), + birthday("Smith, John", 7, 2, None), + birthday("Lena", 7, 4, None), + birthday("Max", 7, 5, None), + birthday("Jane Doe", 12, 24, Some(1985)), + birthday("ACME GmbH", 10, 16, Some(2001)), + birthday("Leap", 2, 29, Some(2000)), + ] + ); + assert!(vcard_birthdays("").is_empty()); + } + + #[test] + fn computes_german_holidays() { + let date = |year, month, day| days_from_civil(year, month, day); + + // known easter sundays, the earliest and latest possible among them + for (year, month, day) in [(2024, 3, 31), (2025, 4, 20), (2026, 4, 5), (2027, 3, 28), (2000, 4, 23), (2008, 3, 23), (2011, 4, 24), (1818, 3, 22), (2038, 4, 25), (2285, 3, 22)] { + assert_eq!(easter(year), date(year, month, day), "easter {year}"); + } + + let bavaria = holidays(2026, "BY"); + let named = |name: &str| bavaria.iter().find(|(_, holiday)| *holiday == name).map(|(day, _)| *day); + + assert_eq!(bavaria.len(), 13); + assert_eq!(named("Karfreitag"), Some(date(2026, 4, 3))); + assert_eq!(named("Ostermontag"), Some(date(2026, 4, 6))); + assert_eq!(named("Christi Himmelfahrt"), Some(date(2026, 5, 14))); + assert_eq!(named("Pfingstmontag"), Some(date(2026, 5, 25))); + assert_eq!(named("Fronleichnam"), Some(date(2026, 6, 4))); + assert_eq!(named("Heilige Drei Könige"), Some(date(2026, 1, 6))); + assert_eq!(named("Mariä Himmelfahrt"), Some(date(2026, 8, 15))); + assert_eq!(named("Allerheiligen"), Some(date(2026, 11, 1))); + assert_eq!(named("Reformationstag"), None); + assert!(bavaria.windows(2).all(|pair| pair[0].0 < pair[1].0)); + + // berlin in 2025: women's day and the one-off day of liberation + let berlin: Vec<&str> = holidays(2025, "be").iter().map(|(_, name)| *name).collect(); + + assert_eq!( + berlin, + ["Neujahr", "Internationaler Frauentag", "Karfreitag", "Ostermontag", "Tag der Arbeit", "Tag der Befreiung", "Christi Himmelfahrt", "Pfingstmontag", "Tag der Deutschen Einheit", "1. Weihnachtstag", "2. Weihnachtstag"] + ); + + // saxony's day of repentance is the wednesday before 23 november, 22 november itself in 2023 + let repentance = |year| holidays(year, "SN").into_iter().find(|(_, name)| *name == "Buß- und Bettag").map(|(day, _)| day); + + assert_eq!([2023, 2024, 2025, 2026].map(repentance), [Some(date(2023, 11, 22)), Some(date(2024, 11, 20)), Some(date(2025, 11, 19)), Some(date(2026, 11, 18))]); + assert_eq!(holidays(2026, "SN").iter().filter(|(_, name)| *name == "Reformationstag").count(), 1); + + // brandenburg and hesse keep easter and whit sunday; thuringia children's day; the north the reformation since 2018 + assert!(holidays(2026, "BB").contains(&(date(2026, 4, 5), "Ostersonntag")) && holidays(2026, "HE").contains(&(date(2026, 5, 24), "Pfingstsonntag"))); + assert!(holidays(2026, "TH").contains(&(date(2026, 9, 20), "Weltkindertag")) && !holidays(2018, "TH").iter().any(|(_, name)| *name == "Weltkindertag")); + assert!(holidays(2018, "HH").contains(&(date(2018, 10, 31), "Reformationstag")) && !holidays(2016, "HH").iter().any(|(_, name)| *name == "Reformationstag")); + assert!(holidays(2017, "BY").contains(&(date(2017, 10, 31), "Reformationstag"))); + + // none without a state, the nationwide nine for an unknown code + assert!(holidays(2026, "").is_empty()); + assert_eq!(holidays(2026, "XX").len(), 9); + } } diff --git a/src/pods.rs b/src/pods.rs new file mode 100644 index 0000000..3baa91e --- /dev/null +++ b/src/pods.rs @@ -0,0 +1,774 @@ +use std::collections::HashMap; +use std::thread; +use std::time::Duration; + +use amane::{ + Animation, Blend, Center, Color, Easing, Full, Horizontal, InputArea, Key, Keyboard, Layer, LayerWindow, Margin, Monitor, Parent, Pointer, Rectangle, Row, + Service, SpaceBetween, Stack, Start, Text, Vertical, Weight, Widget, Zone, +}; + +use crate::config::appearance::{motion, overlays, text}; +use crate::config::island::pill as island_pill; +use crate::config::notifications::history; +use crate::config::pills::{self as settings, colors as hues, layout}; +use crate::edge::{self, Edge}; +use crate::glyph::{Glyph, icon}; +use crate::island::{self, CLEAR, mix, phases, veiled}; +use crate::parse::Sky; +use crate::{ai, home, notices, stats, surface, theme}; + +/* + * four small one-icon pills beside the island, each opening into a big card in the + * middle of the top of the screen. while a card is open the row becomes a carousel: + * every other pill and the island's pill line up left and right of the card in their + * usual order ([AI][System] island [Home][Bell]), and clicking one switches to its card. + * a closing card shrinks back into its own pill's place. + * + * one window per monitor (magic-pods), as wide as the monitor and anchored to its left + * edge, so it never moves: only its height changes, at the top edge. while a card is + * open it also draws the island's pill (island::pill_face), which the island's own + * window leaves out meanwhile (holds_island); the two overlap for a moment at each + * hand-over, at the same place, so the pill never blinks + */ +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Pod { + Ai, + Monitor, + Home, + Notifications, +} + +// the row, left to right +#[derive(Clone, Copy, PartialEq, Eq)] +enum Item { + Pod(Pod), + Island, +} + +const ROW: [Item; 5] = [Item::Pod(Pod::Ai), Item::Pod(Pod::Monitor), Item::Island, Item::Pod(Pod::Home), Item::Pod(Pod::Notifications)]; + +const PODS: [Pod; 4] = [Pod::Ai, Pod::Monitor, Pod::Home, Pod::Notifications]; + +impl Pod { + fn glyph(self) -> Glyph { + match self { + Self::Ai => Glyph::Sparkle, + Self::Monitor => Glyph::Pulse, + Self::Home => Glyph::House, + Self::Notifications => Glyph::Bell, + } + } + + // the open card's size + fn size(self) -> (f32, f32) { + match self { + Self::Ai => (settings::ai::width(), settings::ai::height()), + Self::Monitor => (settings::monitor::width(), stats::height()), + Self::Home => (settings::home::width(), home::height()), + Self::Notifications => (history::width(), history::height()), + } + } + + // its own colour; home's follows the sky + fn hue(self) -> Color { + match self { + Self::Ai => hues::ai(), + Self::Monitor => hues::monitor(), + Self::Notifications => hues::notifications(), + Self::Home => match home::sky() { + Some((Sky::Clear | Sky::PartlyCloudy, true)) => hues::home_clear_day(), + Some((Sky::Clear | Sky::PartlyCloudy, false)) => hues::home_clear_night(), + Some((Sky::Cloudy, _)) | None => hues::home_cloudy(), + Some((Sky::Fog, _)) => hues::home_fog(), + Some((Sky::Rain, _)) => hues::home_rain(), + Some((Sky::Snow, _)) => hues::home_snow(), + Some((Sky::Storm, _)) => hues::home_storm(), + }, + } + } + + // whether its card takes typing: then it has the keyboard and stays open when the pointer leaves + fn types(self) -> bool { + self == Self::Ai || (self == Self::Home && home::typing()) + } + + fn word(self) -> &'static str { + match self { + Self::Ai => "ai", + Self::Monitor => "monitor", + Self::Home => "home", + Self::Notifications => "notifications", + } + } + + fn from_word(word: &str) -> Option { + PODS.into_iter().find(|pod| pod.word() == word) + } +} + +// the wallpaper's surfaces with some of a colour mixed in, keeping their see-through +fn tinted(colors: &[Color; 3], hue: Color, amount: f32) -> [Color; 3] { + colors.map(|color| Color::blend(color, Color::rgba(hue.red(), hue.green(), hue.blue(), color.alpha()), amount)) +} + +pub struct Pods { + // the card open or closing, and the monitor it's on; `shown` whether it's opening or open + card: Option<(Pod, String)>, + shown: bool, + timeline: Animation, + + // a switch from one open card straight to another: the one before, and the switch's progress + previous: Option, + switch: Animation, + + hover: HashMap, + + // the pointer over the card, and a count every arrival and departure bumps, so a late close does nothing + inside: bool, + moves: u32, +} + +impl Service for Pods { + fn new() -> Self { + Self { card: None, shown: false, timeline: timeline(), previous: None, switch: switching(1.0), hover: HashMap::new(), inside: false, moves: 0 } + } + + // changes only through input + fn listen() {} +} + +fn timeline() -> Animation { + Animation::new(0.0).duration(Duration::from_millis(layout::open_ms())).easing(Easing::Linear) +} + +fn switching(from: f32) -> Animation { + Animation::new(from).duration(Duration::from_millis(layout::open_ms())).easing(Easing::InOut) +} + +pub fn open(pod: Pod, monitor: &str) { + island::collapse(); + + let mut pods = Pods::write(); + + let open_here = pods.shown && pods.timeline.value() > 0.5; + let current = pods.card.as_ref().filter(|(_, on)| on == monitor).map(|(open, _)| *open); + + match current { + Some(open) if open == pod => {} + // another card while one is open here: the open one turns into it + Some(open) if open_here => { + pods.previous = Some(open); + pods.switch = switching(0.0); + pods.switch.to(1.0); + pods.card = Some((pod, monitor.to_string())); + } + // another card, or one on another monitor: it grows from its own pill + _ => { + pods.card = Some((pod, monitor.to_string())); + pods.timeline = timeline(); + pods.previous = None; + pods.switch = switching(1.0); + } + } + + pods.shown = true; + pods.inside = true; + pods.timeline.to(1.0); + + drop(pods); + + if pod == Pod::Notifications { + notices::mark_read(); + } +} + +pub fn close() { + let mut pods = Pods::write(); + + if pods.shown { + pods.shown = false; + pods.timeline.to(0.0); + } +} + +fn toggle(pod: Pod, monitor: &str) { + let open_here = { + let pods = Pods::read(); + + pods.shown && pods.card.as_ref().is_some_and(|(open, on)| *open == pod && on == monitor) + }; + + if open_here { close() } else { open(pod, monitor) } +} + +// the open card's pod, if any +pub fn open_card() -> Option { + let pods = Pods::read(); + + pods.card.as_ref().filter(|_| pods.shown).map(|(pod, _)| *pod) +} + +/* + * whether this window draws the island's pill on `monitor` now: a little past the start + * of opening and until a little before the end of closing. this window draws it from + * the first frame to the last, so the two overlap at each hand-over, at the same place + */ +pub fn holds_island(monitor: &str) -> bool { + let pods = Pods::read(); + + pods.card.as_ref().is_some_and(|(_, on)| on == monitor) && pods.timeline.value() > HAND_OVER +} + +// a share of the open timeline (420 ms by default: 0.08 is about two frames) +const HAND_OVER: f32 = 0.08; + +fn hovering(pod: Pod) -> impl Fn(bool) + 'static { + move |inside| { + edge::over_bar(inside); + + Pods::write() + .hover + .entry(pod) + .or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(motion::hover_ms()))) + .to(if inside { 1.0 } else { 0.0 }); + } +} + +// the pointer on or off a card; leaving one that takes no typing closes it a moment later +fn over_card(inside: bool) { + edge::over_bar(inside); + + let moves = { + let mut pods = Pods::write(); + + pods.inside = inside; + pods.moves += 1; + pods.moves + }; + + if inside { + return; + } + + thread::spawn(move || { + thread::sleep(Duration::from_millis(400)); + + let leave = { + let pods = Pods::read(); + + pods.moves == moves && !pods.inside && pods.card.as_ref().is_some_and(|(pod, _)| !pod.types()) + }; + + if leave { + close(); + } + }); +} + +// the island's pill in the carousel: the card closes back into place, then the island opens +fn island_tapped(monitor: String) { + close(); + + thread::spawn(move || { + thread::sleep(Duration::from_millis(layout::open_ms())); + + island::tap(&monitor); + }); +} + +// an item's width in the carousel +fn item_width(item: Item) -> f32 { + match item { + Item::Pod(_) => island_pill::height(), + // beside an open card the island's pill shrinks to its cover or its bars + Item::Island => island::small_width(), + } +} + +// an item's left edge in the row with nothing open, around the island as wide as it is now +fn closed_x(item: Item, middle: f32, island_w: f32) -> f32 { + let (h, spacing, gap) = (island_pill::height(), layout::spacing(), layout::gap()); + + match item { + Item::Island => middle - island_pill::width() / 2.0, + Item::Pod(Pod::Monitor) => middle - island_w / 2.0 - gap - h, + Item::Pod(Pod::Ai) => middle - island_w / 2.0 - gap - 2.0 * h - spacing, + Item::Pod(Pod::Home) => middle + island_w / 2.0 + gap, + Item::Pod(Pod::Notifications) => middle + island_w / 2.0 + gap + h + spacing, + } +} + +fn index(item: Item) -> usize { + ROW.iter().position(|other| *other == item).unwrap_or(0) +} + +/* + * the two items on each side of `open`'s card: the other four in the row's order, the + * first two on its left and the last two on its right, so the left-most stays left-most + * (around Home: AI and System on its left, the island and the bell on its right; around + * AI: System and the island on its left, Home and the bell on its right) + */ +fn beside(open: Pod) -> ([Item; 2], [Item; 2]) { + let others: Vec = ROW.into_iter().filter(|item| *item != Item::Pod(open)).collect(); + + ([others[0], others[1]], [others[2], others[3]]) +} + +// an item's left edge in the carousel around `open`'s card, `card_w` wide, in the middle, and its side of the card (-1, 0 for the card's own, 1) +fn carousel(item: Item, open: Pod, card_w: f32, middle: f32) -> (f32, i8) { + // the card's own pill, while it fades into the card or out of it, sits in the card's middle + if item == Item::Pod(open) { + return (middle - item_width(item) / 2.0, 0); + } + + let (spacing, gap) = (layout::spacing(), layout::gap()); + let (left, right) = beside(open); + + match left.iter().position(|other| *other == item) { + // leftwards from the card, past the one between + Some(k) => { + let between: f32 = left[k + 1..].iter().map(|other| item_width(*other) + spacing).sum(); + + (middle - card_w / 2.0 - gap - between - item_width(item), -1) + } + None => { + let k = right.iter().position(|other| *other == item).unwrap_or(0); + let between: f32 = right[..k].iter().map(|other| item_width(*other) + spacing).sum(); + + (middle + card_w / 2.0 + gap + between, 1) + } + } +} + +/* + * an item's side of the middle in the closed row, where every card opens: AI and System + * left, the island in it (0, so it slides out to whichever side it goes), Home and the + * bell right. no item changes sides on opening, so none crosses the card + */ +fn closed_side(item: Item) -> i8 { + (index(item) as i8 - index(Item::Island) as i8).signum() +} + +/* + * from one place to another by `k`, with how much it shows: sliding when it stays on its + * side of the card; changing sides it fades out where it is over the first half and in at + * its new place over the second, so it never crosses the card + */ +fn travel(from: (f32, i8), to: (f32, i8), k: f32) -> (f32, f32) { + if from.1 == to.1 || from.1 == 0 || to.1 == 0 { + (mix(from.0, to.0, k), 1.0) + } else if k < 0.5 { + (from.0, 1.0 - 2.0 * k) + } else { + (to.0, 2.0 * k - 1.0) + } +} + +// what both windows need to know about this monitor's card this frame +struct Scene { + // the pod and its timeline, while it's at all open here + card: Option<(Pod, f32)>, + grow: f32, + pill_shown: f32, + reveal: f32, + switch: f32, + previous: Option, + middle: f32, + island_w: f32, + // the row's move into the carousel: it starts only after the hand-over (see holds_island), so the island's pill never shows twice + row_grow: f32, +} + +fn scene(state: &Pods, monitor: &Monitor) -> Scene { + let here = monitor.name.as_str(); + let card = state.card.as_ref().filter(|(_, on)| on == here).map(|(pod, _)| (*pod, state.timeline.value())).filter(|(_, t)| *t > 0.0); + let (grow, pill_shown, reveal) = card.map_or((0.0, 1.0, 0.0), |(_, t)| phases(t)); + + // read only here: a running animation redraws every window that reads it + let switch = if card.is_some() { state.switch.value() } else { 1.0 }; + let previous = state.previous.filter(|_| switch < 1.0 && card.is_some()); + + let row_grow = card.map_or(0.0, |(_, t)| phases(((t - 2.0 * HAND_OVER) / (1.0 - 2.0 * HAND_OVER)).max(0.0)).0); + + Scene { card, grow, pill_shown, reveal, switch, previous, middle: monitor.width as f32 / 2.0, island_w: island::width_on(here), row_grow } +} + +impl Scene { + // an item's left edge on the monitor and how much it shows: between the closed row and the carousel by `row_grow`, between two carousels by `switch` + fn place(&self, item: Item) -> (f32, f32) { + let closed = closed_x(item, self.middle, self.island_w); + + let Some((open, _)) = self.card else { return (closed, 1.0) }; + + let now = carousel(item, open, open.size().0, self.middle); + + let (open_x, open_shown, open_side) = match self.previous { + Some(before) => { + let from = carousel(item, before, before.size().0, self.middle); + let (x, shown) = travel(from, now, self.switch); + + (x, shown, if self.switch < 0.5 { from.1 } else { now.1 }) + } + None => (now.0, 1.0, now.1), + }; + + let (x, shown) = travel((closed, closed_side(item)), (open_x, open_side), self.row_grow); + + (x, shown * open_shown) + } +} + +/* + * the row of pills: as wide as the monitor and as tall as a pill, never changing. amane + * repaints a whole window every frame and hyprland then recomposites (and re-blurs) + * everything under it, so a window as tall as the cards ran the animations at 15 fps; + * the cards have their own window (card_view) + */ +pub fn row_view(monitor: &Monitor) -> LayerWindow { + theme::begin(); + + let here = monitor.name.as_str(); + let wallpaper = theme::now().surfaces(); + let state = Pods::read(); + let scene = scene(&state, monitor); + let (h, top, shadow) = (island_pill::height(), island_pill::top_gap(), island_pill::shadow()); + + // with the bar on a fullscreen monitor, unless a card is open + let slide = if scene.card.is_some() { 0.0 } else { edge::hidden_by(Edge::read().shown(here), 0.0, edge::hide_h()) }; + + let window = LayerWindow::new() + .width(monitor.width as f32) + .height(top + h + shadow) + .anchor_vertical(Vertical::Top) + .anchor_horizontal(Horizontal::Left) + .margin(Margin { top: 0, right: 0, bottom: 0, left: 0 }) + .layer(Layer::Overlay) + .space(Zone::Ignore) + .namespace("magic-pods"); + + // empty and click-through rather than hidden: see the island's view for why + if slide <= -edge::hide_h() { + return window.click_through().child(Rectangle::new().width(0.0).height(0.0)); + } + + let (card, previous, switch) = (scene.card, scene.previous, scene.switch); + + let mut layers: Vec> = Vec::new(); + let mut regions: Vec = Vec::new(); + + for item in ROW { + let (x, seen) = scene.place(item); + + if seen <= 0.0 { + continue; + } + + match item { + Item::Island => { + // the island's own window draws its pill while nothing is open here + if card.is_none() { + continue; + } + + // it shrinks to its cover (or its bars) as it moves aside + let small = scene.row_grow; + let width = mix(island_pill::width(), island::small_width(), small); + let on = here.to_string(); + let pill = island::pill_face(here, small).cursor(Pointer).on_hover(edge::over_bar).on_click(move |_| island_tapped(on.clone())); + let pill = if seen < 1.0 { pill.opacity(seen) } else { pill }; + + regions.push(InputArea { x: x as i32, y: top as i32, width: width.ceil() as i32, height: h.ceil() as i32 }); + layers.push(Box::new(pill.translate(x, top))); + } + Item::Pod(pod) => { + // the open one is the card, drawn last. in a switch, the one switched to slides into + // the card and fades over the first half; the one switched away from fades back in as a pill + let shown = match previous { + Some(_) if card.is_some_and(|(open, _)| open == pod) => 1.0 - 2.0 * switch, + // its card is in the card window, which starts drawing at HAND_OVER; the pill stays a little longer under it + _ if card.is_some_and(|(open, t)| open == pod && t > 2.0 * HAND_OVER) => continue, + Some(before) if before == pod => switch, + _ => 1.0, + } * seen; + + if shown <= 0.0 { + continue; + } + + let lit = state.hover.get(&pod).map_or(0.0, Animation::value); + let pill = pill(pod, lit, &wallpaper, here); + let pill = if shown < 1.0 { pill.opacity(shown) } else { pill }; + + regions.push(InputArea { x: x as i32, y: (top + slide) as i32, width: h.ceil() as i32, height: h.ceil() as i32 }); + layers.push(Box::new(pill.translate(x, top + slide))); + } + } + } + + window.input_region(regions).child(Rectangle::new().width(Parent).height(Parent).child(Stack::new(layers).width(Parent).height(Parent))) +} + +/* + * the open card, in the middle: a window as big as the biggest card while one is open + * here, and an empty pixel otherwise. it changes size only while it shows nothing (the + * card starts and ends as its pill, which the row window draws under it meanwhile), so + * hyprland's squashed first frame at a new size is never seen + */ +pub fn card_view(monitor: &Monitor) -> LayerWindow { + theme::begin(); + + let wallpaper = theme::now().surfaces(); + let state = Pods::read(); + let scene = scene(&state, monitor); + let (h, top, shadow) = (island_pill::height(), island_pill::top_gap(), island_pill::shadow()); + + let window = |width: f32, height: f32| LayerWindow::new().width(width).height(height).anchor_vertical(Vertical::Top).layer(Layer::Overlay).space(Zone::Ignore).namespace("magic-pods-card"); + + let Some((pod, t)) = scene.card else { + return window(1.0, 1.0).click_through().child(Rectangle::new().width(0.0).height(0.0)); + }; + + let widest = PODS.iter().map(|pod| pod.size().0).fold(h, f32::max); + let tallest = [settings::ai::height(), stats::tallest(), home::height(), history::height()].into_iter().fold(h, f32::max); + let window_w = (widest + 2.0 * shadow).round(); + + // centred by hyprland: its left edge on the monitor + let left = ((monitor.width as f32 - window_w) / 2.0).floor(); + let window = window(window_w, top + tallest + shadow); + + // the first frames at the new size show at the old place, so they stay empty (the row draws the pill meanwhile) + if t <= HAND_OVER { + return window.click_through().child(Rectangle::new().width(0.0).height(0.0)); + } + + let (grow, pill_shown, reveal, switch, previous, middle) = (scene.grow, scene.pill_shown, scene.reveal, scene.switch, scene.previous, scene.middle); + + let mut layers: Vec> = Vec::new(); + let mut regions: Vec = Vec::new(); + + let typing = pod.types() && state.shown; + + { + let (card_w, card_h) = pod.size(); + + // a switch morphs the card from the one before: its size, its colour, and its face crossing over + let (full_w, full_h, hue) = match previous { + Some(before) => { + let (before_w, before_h) = before.size(); + + (mix(before_w, card_w, switch), mix(before_h, card_h, switch), Color::blend(before.hue(), pod.hue(), switch)) + } + None => (card_w, card_h, pod.hue()), + }; + + // from its pill's place in the closed row to the middle, and back into that place; in the window + let from_x = closed_x(Item::Pod(pod), middle, scene.island_w) - left; + let to_x = middle - full_w / 2.0 - left; + + let width = mix(h, full_w, grow); + let height = mix(h, full_h, grow); + let x = mix(from_x, to_x, grow); + let radius = mix(h / 2.0, layout::radius(), grow); + let lit = state.hover.get(&pod).map_or(0.0, Animation::value); + let colors = tinted(&wallpaper, hue, mix(hues::pill_mix(), hues::card_mix(), grow)); + + let face: Rectangle = match previous { + // the card before fades out over the first half of the switch, this one in over the second + Some(before) if switch < 0.5 => { + let (before_w, before_h) = before.size(); + + veiled(content(before, before_w, before_h), width, height, radius, 1.0 - 2.0 * switch, &colors) + } + Some(_) => veiled(content(pod, card_w, card_h), width, height, radius, 2.0 * switch - 1.0, &colors), + None if reveal > 0.0 => veiled(content(pod, card_w, card_h), width, height, radius, reveal, &colors), + None => { + // the pill's icon fading as the shape grows + let tint = Color::blend(CLEAR, pod.hue(), pill_shown); + + Rectangle::new().width(h).height(h).align_child(Center, Center).child(icon(pod.glyph(), layout::icon(), tint)) + } + }; + + let face = Rectangle::new().width(width).height(height).align_child(Center, Start).child(face); + let shape = surface::shell(width, height, radius, &colors, lit * pill_shown, face).on_hover(over_card).translate(x, top); + + regions.push(InputArea { x: x as i32, y: top as i32, width: width.ceil() as i32, height: height.ceil() as i32 }); + layers.push(Box::new(shape)); + } + + let window = window.input_region(regions); + + let window = if typing { + window.keyboard(Keyboard::OnDemand).on_key(|key| { + // Esc closes the home card's event form first, then the card + if key == Key::Escape && !home::escape() { + close(); + } + }) + } else { + window + }; + + window.child(Rectangle::new().width(Parent).height(Parent).child(Stack::new(layers).width(Parent).height(Parent))) +} + +// a small pill in its own colour: the surface with one icon; the bell carries the unread count +fn pill(pod: Pod, lit: f32, wallpaper: &[Color; 3], monitor: &str) -> Rectangle { + let h = island_pill::height(); + let theme = theme::now(); + let hue = pod.hue(); + let colors = tinted(wallpaper, hue, hues::pill_mix()); + + let glyph = icon(pod.glyph(), layout::icon() * (1.0 + 0.08 * lit), Color::blend(hue, theme.text, 0.25 + 0.5 * lit)); + let mut inside: Vec> = vec![Box::new(Rectangle::new().width(h).height(h).align_child(Center, Center).child(glyph))]; + + let unread = if pod == Pod::Notifications { notices::unread() } else { 0 }; + + if unread > 0 { + let badge = text::tiny() + 6.0; + let words = if unread > 9 { String::from("9+") } else { unread.to_string() }; + + inside.push(Box::new( + Rectangle::new() + .width(badge) + .height(badge) + .radius(Full) + .fill(hue) + .align_child(Center, Center) + .translate(h - badge - 3.0, 3.0) + .child(Text::new(words).size(text::tiny() - 1.0).weight(Weight::Bold).color(theme.on_accent)), + )); + } + + let content = Rectangle::new().width(h).height(h).child(Stack::new(inside).width(h).height(h)); + let on = monitor.to_string(); + + surface::shell(h, h, h / 2.0, &colors, lit, content).cursor(Pointer).on_hover(hovering(pod)).on_click(move |_| toggle(pod, &on)) +} + +fn content(pod: Pod, width: f32, height: f32) -> Rectangle { + let pad = layout::pad(); + let (inner_w, inner_h) = (width - 2.0 * pad, height - 2.0 * pad); + + let body = match pod { + Pod::Ai => ai::card(inner_w, inner_h), + Pod::Monitor => stats::card(inner_w, inner_h), + Pod::Home => home::card(inner_w, inner_h), + Pod::Notifications => notices::card(inner_w, inner_h), + }; + + Rectangle::new().width(width).height(height).padding(pad).child(body) +} + +/* + * a card's first row: its title, the card's own buttons, then a cross that closes it. + * the cards share it so they read as one family + */ +pub fn header(title: &str, buttons: Vec>, width: f32) -> Rectangle { + let theme = theme::now(); + + let mut right: Vec> = buttons; + right.push(Box::new(button(Glyph::Cross, close))); + + Rectangle::new().width(width).height(header_height()).child( + Row::new(vec![ + Box::new(Text::new(title).size(text::heading()).weight(Weight::Bold).color(theme.text)) as Box, + Box::new(Row::new(right).gap(6.0).align(Center)), + ]) + .width(width) + .justify(SpaceBetween) + .align(Center), + ) +} + +pub fn header_height() -> f32 { + 30.0 +} + +// a small round icon button +pub fn button(glyph: Glyph, pressed: impl Fn() + 'static) -> Rectangle { + let theme = theme::now(); + + Rectangle::new() + .width(26.0) + .height(26.0) + .radius(Full) + .fill(overlays::chip()) + .align_child(Center, Center) + .cursor(Pointer) + .on_click(move |_| pressed()) + .child(icon(glyph, 14.0, theme.text)) +} + +// a chip with words on it, for actions like "Clear all" or "Processes" +pub fn chip(words: &str, glyph: Option, pressed: impl Fn() + 'static) -> Rectangle { + let theme = theme::now(); + let letters = words.chars().count() as f32; + let width = letters * text::small() * crate::config::appearance::font::letter_width() + if glyph.is_some() { 40.0 } else { 22.0 }; + + let mut parts: Vec> = Vec::new(); + + if let Some(glyph) = glyph { + parts.push(Box::new(icon(glyph, 13.0, theme.text))); + } + + parts.push(Box::new(Text::new(words).size(text::small()).color(theme.text))); + + Rectangle::new() + .width(width) + .height(26.0) + .radius(Full) + .fill(overlays::chip()) + .align_child(Center, Center) + .cursor(Pointer) + .on_click(move |_| pressed()) + .child(Row::new(parts).gap(6.0).align(Center)) +} + +// `amane ipc call pods open|toggle ai|monitor|home|notifications [monitor]`, `close`, `status`; the focused monitor by default +pub fn command(arguments: &[String]) -> String { + let words: Vec<&str> = arguments.iter().map(String::as_str).collect(); + let focused = island::focused_monitor().unwrap_or_default(); + + match words.as_slice() { + [verb @ ("open" | "toggle"), name, rest @ ..] => match Pod::from_word(name) { + Some(pod) => { + let on = rest.first().map_or(focused, |monitor| monitor.to_string()); + + if *verb == "open" { open(pod, &on) } else { toggle(pod, &on) } + + String::from("ok") + } + None => String::from("pods: ai, monitor, home or notifications"), + }, + ["close"] => { + close(); + + String::from("ok") + } + ["ask", question @ ..] if !question.is_empty() => { + ai::ask(question.join(" ")); + + String::from("ok") + } + ["notices", rest @ ..] => notices::command(rest), + ["processes"] => { + stats::processes(); + + String::from("ok") + } + ["status"] | [] => { + let pods = Pods::read(); + let card = pods.card.as_ref().filter(|_| pods.shown).map_or("none", |(pod, _)| pod.word()); + let on = pods.card.as_ref().map_or("", |(_, on)| on.as_str()); + + format!("card={card} monitor={on} timeline={:.2} switch={:.2} unread={}", pods.timeline.value(), pods.switch.value(), notices::unread()) + } + _ => String::from("usage: pods open|toggle [monitor] | close | ask | processes | notices list|dismiss |clear | status"), + } +} + +// a thin divider across a card +pub fn rule(width: f32) -> Rectangle { + Rectangle::new().width(width).height(1.0).fill(overlays::track()) +} diff --git a/src/stats.rs b/src/stats.rs new file mode 100644 index 0000000..6f22a8b --- /dev/null +++ b/src/stats.rs @@ -0,0 +1,564 @@ +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::thread; +use std::time::{Duration, Instant}; + +use amane::{Blend, Canvas, Center, Color, Column, Full, Path, Rectangle, Row, Service, Shape, SpaceBetween, Stack, Start, Text, Weight, Widget}; + +use crate::config::appearance::{overlays, text}; +use crate::config::pills::{layout, monitor as settings}; +use crate::glyph::{Glyph, glyph_shapes}; +use crate::island::faded; +use crate::parse; +use crate::system::Gpu; +use crate::{pods, theme}; + +/* + * the system monitor card: cpu, the integrated gpu, the external gpu, memory, swap, + * network, disk reads and writes as graphs over the last readings; the external gpu's + * memory and the programs on it; the hottest temperature sensors in their colours. + * everything comes from /proc and /sys on this Service's thread (the external gpu from + * system::Gpu, which reads only while this card is open); no process list here, its + * button opens btm's in a terminal on a special workspace instead + */ +#[derive(Default)] +pub struct Stats { + // 0 to 1 + cpu: Vec, + // the integrated gpu's clock as a share of its highest, and in words + gpu: Vec, + gpu_words: String, + // the external gpu's busy share + egpu: Vec, + ram: Vec, + ram_words: String, + swap: Vec, + swap_words: String, + // bytes a second + down: Vec, + up: Vec, + read: Vec, + write: Vec, + // (sensor, its icon, °C), hottest first + temps: Vec<(String, Glyph, f32)>, +} + +struct Sample { + at: Instant, + cpu: (u64, u64), + net: (u64, u64), + disk: (u64, u64), +} + +impl Service for Stats { + fn new() -> Self { + Self::default() + } + + fn listen() { + let mut before = sample(); + + loop { + thread::sleep(Duration::from_millis(settings::interval_ms())); + + let now = sample(); + let seconds = now.at.duration_since(before.at).as_secs_f32().max(0.001); + let rate = |new: u64, old: u64| new.saturating_sub(old) as f32 / seconds; + + let busy = now.cpu.0.saturating_sub(before.cpu.0) as f32 / now.cpu.1.saturating_sub(before.cpu.1).max(1) as f32; + let memory = parse::memory(&read("/proc/meminfo")).unwrap_or_default(); + let used = memory.total_kb.saturating_sub(memory.available_kb); + let swapped = memory.swap_total_kb.saturating_sub(memory.swap_free_kb); + let (clock, clock_words) = gpu_clock(); + let egpu = Gpu::read().busy as f32 / 100.0; + let temps = temperatures(); + + { + let mut stats = Self::write(); + let keep = settings::history(); + + push(&mut stats.cpu, busy, keep); + push(&mut stats.gpu, clock, keep); + push(&mut stats.egpu, egpu, keep); + push(&mut stats.ram, used as f32 / memory.total_kb.max(1) as f32, keep); + push(&mut stats.swap, swapped as f32 / memory.swap_total_kb.max(1) as f32, keep); + push(&mut stats.down, rate(now.net.0, before.net.0), keep); + push(&mut stats.up, rate(now.net.1, before.net.1), keep); + push(&mut stats.read, rate(now.disk.0, before.disk.0) * 512.0, keep); + push(&mut stats.write, rate(now.disk.1, before.disk.1) * 512.0, keep); + + stats.gpu_words = clock_words; + stats.ram_words = format!("{} of {}", gigabytes(used), gigabytes(memory.total_kb)); + stats.swap_words = if memory.swap_total_kb == 0 { String::from("no swap") } else { format!("{} of {}", gigabytes(swapped), gigabytes(memory.swap_total_kb)) }; + stats.temps = temps; + } + + before = now; + } + } +} + +fn gigabytes(kb: u64) -> String { + format!("{:.1} GB", kb as f32 / 1048576.0) +} + +fn push(history: &mut Vec, value: f32, keep: usize) { + history.push(value); + + let extra = history.len().saturating_sub(keep); + + history.drain(..extra); +} + +fn read(path: impl Into) -> String { + fs::read_to_string(path.into()).unwrap_or_default() +} + +fn sample() -> Sample { + Sample { at: Instant::now(), cpu: parse::cpu_jiffies(&read("/proc/stat")).unwrap_or((0, 0)), net: parse::network_bytes(&read("/proc/net/dev")), disk: disk_sectors() } +} + +// sectors read and written by the whole physical disks: ones with a device behind them, not loop, ram or zram +fn disk_sectors() -> (u64, u64) { + let mut sum = (0, 0); + + for entry in fs::read_dir("/sys/block").into_iter().flatten().flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + + if ["loop", "ram", "zram"].iter().any(|virtual_disk| name.starts_with(virtual_disk)) || !entry.path().join("device").exists() { + continue; + } + + if let Some((reads, writes)) = parse::block_sectors(&read(entry.path().join("stat"))) { + sum.0 += reads; + sum.1 += writes; + } + } + + sum +} + +// the first gpu that tells its clock (xe or i915): its share of the highest clock, and the clock in words +fn gpu_clock() -> (f32, String) { + for entry in fs::read_dir("/sys/class/drm").into_iter().flatten().flatten() { + let card = entry.path(); + + for (now, top) in [("device/tile0/gt0/freq0/act_freq", "device/tile0/gt0/freq0/max_freq"), ("gt_act_freq_mhz", "gt_max_freq_mhz")] { + let mhz = |file: &str| read(card.join(file)).trim().parse::().ok(); + + if let (Some(now), Some(top)) = (mhz(now), mhz(top)) { + return ((now / top.max(1.0)).clamp(0.0, 1.0), format!("{now:.0} of {top:.0} MHz")); + } + } + } + + (0.0, String::from("no clock to read")) +} + +// what the temperatures are shown in, always: a sensor keeps its place whatever its reading +const ORDER: [&str; 8] = ["CPU", "iGPU", "eGPU", "NVMe", "Memory", "Board", "ThinkPad", "Wi-Fi"]; + +// every hwmon device's hottest sensor, named for what it is, in ORDER (then by name, then by device) +fn temperatures() -> Vec<(String, Glyph, f32)> { + let mut found: Vec<(String, Glyph, f32)> = Vec::new(); + + // the devices in their numbers' order (hwmon2 before hwmon10), so two of a kind keep theirs too + let mut devices: Vec = fs::read_dir("/sys/class/hwmon").into_iter().flatten().flatten().map(|entry| entry.path()).collect(); + + devices.sort_by_key(|path| path.file_name().and_then(|name| name.to_str()).and_then(|name| name.trim_start_matches("hwmon").parse::().ok()).unwrap_or(u32::MAX)); + + for device in devices { + let kind = read(device.join("name")).trim().to_string(); + + let hottest = fs::read_dir(&device) + .into_iter() + .flatten() + .flatten() + .filter(|file| file.file_name().to_string_lossy().starts_with("temp") && file.file_name().to_string_lossy().ends_with("_input")) + .filter_map(|file| read(file.path()).trim().parse::().ok()) + .map(|milli| milli / 1000.0) + .filter(|celsius| *celsius > 0.0 && *celsius < 150.0) + .fold(None, |hottest: Option, celsius| Some(hottest.map_or(celsius, |hot| hot.max(celsius)))); + + let Some(celsius) = hottest else { continue }; + + let (name, glyph) = match kind.as_str() { + "coretemp" | "k10temp" | "zenpower" => ("CPU", Glyph::Chip), + "nvme" => ("NVMe", Glyph::Drive), + "amdgpu" | "nouveau" => ("eGPU", Glyph::Gpu), + "xe" | "i915" => ("iGPU", Glyph::Gpu), + "acpitz" => ("Board", Glyph::Thermometer), + "thinkpad" => ("ThinkPad", Glyph::Laptop), + "spd5118" | "jc42" => ("Memory", Glyph::Memory), + other if other.starts_with("iwlwifi") => ("Wi-Fi", Glyph::Thermometer), + other => (other, Glyph::Thermometer), + }; + + found.push((name.to_string(), glyph, celsius)); + } + + // a stable sort: same kinds keep the devices' order + found.sort_by_key(|(name, ..)| (ORDER.iter().position(|kind| kind == name).unwrap_or(ORDER.len()), name.clone())); + found.truncate(settings::temperatures()); + + // a kind shown more than once is numbered, in the order shown + let names: Vec = found.iter().map(|(name, ..)| name.clone()).collect(); + + for (index, (name, ..)) in found.iter_mut().enumerate() { + if names.iter().filter(|other| *other == name).count() > 1 { + let number = names[..index].iter().filter(|other| *other == name).count() + 1; + + *name = format!("{name} {number}"); + } + } + + found +} + +// a temperature's colour: cool below `warm`, then warm turning hot up to `hot` +fn temperature_color(celsius: f32) -> Color { + let (warm, hot) = (settings::warm(), settings::hot()); + + if celsius < warm { + settings::cool_color() + } else if celsius >= hot { + settings::hot_color() + } else { + Color::blend(settings::warm_color(), settings::hot_color(), (celsius - warm) / (hot - warm).max(0.1)) + } +} + +/* + * opens btm's process list in a terminal on a special workspace, or shows and hides that + * workspace when it runs already. the window is told apart by its class + */ +pub fn processes() { + pods::close(); + + let (terminal, command, space) = (settings::terminal(), settings::processes(), settings::workspace()); + + thread::spawn(move || { + let class = format!("amane.{space}"); + let running = Command::new("pgrep").args(["-f", "--", &format!("--class={class}")]).output().is_ok_and(|done| done.status.success()); + + if !running { + let line = format!("{terminal} --class={class} -e {command}"); + let lua = format!("hl.exec_cmd({}, {{ workspace = {} }})", parse::quoted(&line), parse::quoted(&format!("special:{space} silent"))); + + let _ = Command::new("hyprctl").args(["eval", &lua]).output(); + } + + let _ = Command::new("hyprctl").args(["dispatch", &format!("hl.dsp.workspace.toggle_special({})", parse::quoted(&space))]).output(); + }); +} + +fn label_height() -> f32 { + text::small() + 8.0 +} + +fn temp_row_height() -> f32 { + text::small() + 16.0 +} + +fn temp_rows() -> usize { + settings::temperatures().div_ceil(2) +} + +fn program_row_height() -> f32 { + text::small() + 8.0 +} + +// the external gpu's rows under its graph: its memory, then the programs on it (at least one line) +fn egpu_rows(gpu: &Gpu) -> usize { + 1 + gpu.programs.len().clamp(1, settings::programs()) +} + +fn graph_count(gpu: &Gpu) -> usize { + if gpu.name.is_empty() { 6 } else { 7 } +} + +/* + * the card's height, from settings and from whether there is an external gpu and how + * many programs use it, so the window's size doesn't follow the readings + */ +pub fn height() -> f32 { + let gpu = Gpu::read(); + let count = graph_count(&gpu) as f32; + let graphs = count * (label_height() + settings::graph_height()) + (count - 1.0) * 10.0; + + // header, graphs, the external gpu's rows, rule, the temperatures' label and rows, with the column's gaps + let (egpu, parts) = if gpu.name.is_empty() { (0.0, 5.0) } else { (egpu_rows(&gpu) as f32 * program_row_height(), 6.0) }; + + 2.0 * layout::pad() + pods::header_height() + graphs + egpu + 1.0 + label_height() + temp_rows() as f32 * temp_row_height() + (parts - 1.0) * 10.0 +} + +// the card's height at its tallest: an external gpu with its whole list of programs +pub fn tallest() -> f32 { + let graphs = 7.0 * (label_height() + settings::graph_height()) + 6.0 * 10.0; + let egpu = (1 + settings::programs()) as f32 * program_row_height(); + + 2.0 * layout::pad() + pods::header_height() + graphs + egpu + 1.0 + label_height() + temp_rows() as f32 * temp_row_height() + 5.0 * 10.0 +} + +// bytes a second in words +fn rate(bytes: f32) -> String { + match bytes { + b if b >= 1e9 => format!("{:.1} GB/s", b / 1e9), + b if b >= 1e6 => format!("{:.1} MB/s", b / 1e6), + b if b >= 1e3 => format!("{:.0} kB/s", b / 1e3), + b => format!("{b:.0} B/s"), + } +} + +// video memory in KiB, the way a person reads it +fn size(kib: u64) -> String { + match kib { + 0..1024 => String::from("<1 MB"), + 1024..1_048_576 => format!("{} MB", kib / 1024), + _ => format!("{:.1} GB", kib as f64 / 1_048_576.0), + } +} + +// one graph's readings, the value at its top, its colour +type Series<'a> = (&'a [f32], f32, Color); + +// a graph's line and the faint area under it, scaled so `top` reaches the top +fn graph(history: &[f32], top: f32, origin: (f32, f32), size: (f32, f32), color: Color) -> Vec> { + let (x0, y0) = origin; + let (width, height) = size; + let keep = settings::history().max(2) as f32; + + if history.len() < 2 { + return Vec::new(); + } + + // newest on the right; older readings fill in leftwards + let point = |index: usize, value: f32| { + let age = (history.len() - 1 - index) as f32; + + (x0 + width - age / (keep - 1.0) * width, y0 + height - (value / top.max(f32::EPSILON)).clamp(0.0, 1.0) * height) + }; + + let mut line = Path::new(); + let mut area = Path::new(); + + for (index, value) in history.iter().enumerate() { + let (x, y) = point(index, *value); + + if index == 0 { + line = line.move_to(x, y); + area = area.move_to(x, y0 + height).line_to(x, y); + } else { + line = line.line_to(x, y); + area = area.line_to(x, y); + } + } + + let (last_x, _) = point(history.len() - 1, 0.0); + + vec![Box::new(area.line_to(last_x, y0 + height).close().fill(faded(color, 40))), Box::new(line.stroke(1.5, color))] +} + +// a label on the left and its value on the right, one line +fn line_of(name: impl Into, value: impl Into, width: f32, height: f32, name_color: Color) -> Rectangle { + let theme = theme::now(); + + Rectangle::new().width(width).height(height).align_child(Start, Center).child( + Row::new(vec![ + Box::new(Text::new(name).size(text::small()).color(name_color).elide()) as Box, + Box::new(Text::new(value).size(text::small()).color(theme.dim)), + ]) + .width(width) + .justify(SpaceBetween) + .align(Center), + ) +} + +// the external gpu's memory as a bar, and the programs on it, the most memory first +fn egpu_detail(gpu: &Gpu, width: f32) -> Column { + let theme = theme::now(); + let row_h = program_row_height(); + + let share = if gpu.vram_total > 0 { gpu.vram_used as f32 / gpu.vram_total as f32 } else { 0.0 }; + let words = format!("{} of {}", size(gpu.vram_used / 1024), size(gpu.vram_total / 1024)); + let bar_w = width * 0.32; + + let memory = Row::new(vec![ + Box::new(Text::new("Video memory").size(text::small()).color(theme.text)) as Box, + Box::new( + Row::new(vec![ + Box::new(Rectangle::new().width(bar_w).height(6.0).radius(Full).fill(overlays::track()).child(Rectangle::new().width((bar_w * share).max(6.0)).height(6.0).radius(Full).fill(theme.accent))) + as Box, + Box::new(Text::new(words).size(text::small()).color(theme.dim)), + ]) + .gap(10.0) + .align(Center), + ), + ]) + .width(width) + .height(row_h) + .justify(SpaceBetween) + .align(Center); + + let mut rows: Vec> = vec![Box::new(memory)]; + let most = settings::programs(); + let shown = if gpu.programs.len() > most { most - 1 } else { most }; + + for program in gpu.programs.iter().take(shown) { + let amount = match program.busy { + Some(busy) => format!("{} · {busy}%", size(program.vram_kib)), + None => size(program.vram_kib), + }; + + rows.push(Box::new(line_of(program.name.as_str(), amount, width, row_h, theme.text))); + } + + if gpu.programs.len() > most { + rows.push(Box::new(line_of(format!("and {} more", gpu.programs.len() - shown), "", width, row_h, theme.dim))); + } + + if gpu.programs.is_empty() { + rows.push(Box::new(line_of("No programs on it", "", width, row_h, theme.dim))); + } + + Column::new(rows) +} + +// the temperatures in two columns: icon, name, value in its colour, a bar up to `hot` +fn temperature_grid(temps: &[(String, Glyph, f32)], width: f32) -> Stack { + let theme = theme::now(); + let cell_w = width / 2.0 - 8.0; + let (row_h, line_h, icon) = (temp_row_height(), text::small() + 6.0, 14.0); + let bar_w = cell_w - icon - 8.0; + + // every icon into one canvas behind the rows + let mut shapes: Vec> = Vec::new(); + let mut rows: Vec> = Vec::new(); + + for (row, pair) in temps.chunks(2).enumerate() { + let mut cells: Vec> = Vec::new(); + + for (column, (name, glyph, celsius)) in pair.iter().enumerate() { + let color = temperature_color(*celsius); + let share = (celsius / settings::hot().max(1.0)).clamp(0.0, 1.0); + + shapes.extend(glyph_shapes(*glyph, icon, color, 0.0, (column as f32 * (cell_w + 16.0), row as f32 * row_h + (line_h - icon) / 2.0))); + + cells.push(Box::new( + Rectangle::new().width(cell_w).height(row_h).padding(amane::Padding { top: 0.0, right: 0.0, bottom: 0.0, left: icon + 8.0 }).child( + Column::new(vec![ + Box::new( + Row::new(vec![ + Box::new(Text::new(name.as_str()).size(text::small()).color(theme.text)) as Box, + Box::new(Text::new(format!("{celsius:.0} °C")).size(text::small()).weight(Weight::Bold).color(color)), + ]) + .width(bar_w) + .height(line_h) + .justify(SpaceBetween) + .align(Center), + ) as Box, + Box::new(Rectangle::new().width(bar_w).height(3.0).radius(Full).fill(overlays::track()).child(Rectangle::new().width((bar_w * share).max(3.0)).height(3.0).radius(Full).fill(color))), + ]) + .gap(2.0), + ), + )); + } + + rows.push(Box::new(Row::new(cells).gap(16.0))); + } + + let height = temp_rows() as f32 * row_h; + + Stack::new(vec![Box::new(Canvas::new().width(width).height(height).shapes(shapes)) as Box, Box::new(Column::new(rows))]).width(width).height(height) +} + +pub fn card(width: f32, height: f32) -> Rectangle { + let stats = Stats::read(); + let gpu = Gpu::read(); + let theme = theme::now(); + + let header = pods::header("System", vec![Box::new(pods::chip("Processes", Some(Glyph::Terminal), processes))], width); + + let last = |history: &[f32]| history.last().copied().unwrap_or(0.0); + let peak = |a: &[f32], b: &[f32]| a.iter().chain(b).copied().fold(1024.0_f32, f32::max); + + let net_top = peak(&stats.down, &stats.up); + let disk_top = peak(&stats.read, &stats.write); + + // (name, value in words, graphs as (history, top, colour)) + let mut blocks: Vec<(String, String, Vec)> = vec![ + (String::from("CPU"), format!("{:.0} %", last(&stats.cpu) * 100.0), vec![(&stats.cpu, 1.0, theme.accent)]), + (String::from("iGPU"), stats.gpu_words.clone(), vec![(&stats.gpu, 1.0, theme.accent)]), + ]; + + if !gpu.name.is_empty() { + let mut facts = vec![format!("{}%", gpu.busy)]; + + if let Some(celsius) = gpu.celsius { + facts.push(format!("{celsius} °C")); + } + + if let Some(watts) = gpu.watts { + facts.push(format!("{watts:.0} W")); + } + + blocks.push((gpu.name.clone(), facts.join(" · "), vec![(&stats.egpu, 1.0, theme.flame)])); + } + + blocks.extend([ + (String::from("Memory"), stats.ram_words.clone(), vec![(stats.ram.as_slice(), 1.0, theme.accent)]), + (String::from("Swap"), stats.swap_words.clone(), vec![(stats.swap.as_slice(), 1.0, theme.flame)]), + (String::from("Network"), format!("↓ {} ↑ {}", rate(last(&stats.down)), rate(last(&stats.up))), vec![(stats.down.as_slice(), net_top, theme.accent), (stats.up.as_slice(), net_top, theme.flame)]), + (String::from("Disk"), format!("read {} write {}", rate(last(&stats.read)), rate(last(&stats.write))), vec![(stats.read.as_slice(), disk_top, theme.accent), (stats.write.as_slice(), disk_top, theme.flame)]), + ]); + + let (label_h, graph_h) = (label_height(), settings::graph_height()); + + // every graph into one canvas under the labels: each canvas costs the gpu on its own + let mut shapes: Vec> = Vec::new(); + let mut rows: Vec> = Vec::new(); + + for (index, (name, words, series)) in blocks.into_iter().enumerate() { + let top = index as f32 * (label_h + graph_h + 10.0); + + for (history, peak, color) in series { + shapes.extend(graph(history, peak, (0.0, top + label_h), (width, graph_h), color)); + } + + rows.push(Box::new( + Rectangle::new().width(width).height(label_h + graph_h).child( + Row::new(vec![ + Box::new(Text::new(name).size(text::small()).weight(Weight::Bold).color(theme.text)) as Box, + Box::new(Text::new(words).size(text::small()).color(theme.dim)), + ]) + .width(width) + .justify(SpaceBetween), + ), + )); + } + + let count = graph_count(&gpu) as f32; + let graphs_h = count * (label_h + graph_h) + (count - 1.0) * 10.0; + + let graphs = Stack::new(vec![Box::new(Canvas::new().width(width).height(graphs_h).shapes(shapes)) as Box, Box::new(Column::new(rows).gap(10.0))]).width(width).height(graphs_h); + + let mut parts: Vec> = vec![Box::new(header), Box::new(graphs)]; + + if !gpu.name.is_empty() { + parts.push(Box::new(egpu_detail(&gpu, width))); + } + + parts.push(Box::new(pods::rule(width))); + parts.push(Box::new(Rectangle::new().width(width).height(label_h).child(Text::new("Temperatures").size(text::small()).weight(Weight::Bold).color(theme.text)))); + + if stats.temps.is_empty() { + let words = if stats.cpu.is_empty() { "Reading…" } else { "No temperature sensors" }; + + parts.push(Box::new(Rectangle::new().width(width).height(temp_rows() as f32 * temp_row_height()).child(Text::new(words).size(text::small()).color(theme.dim)))); + } else { + parts.push(Box::new(temperature_grid(&stats.temps, width))); + } + + Rectangle::new().width(width).height(height).child(Column::new(parts).gap(10.0)) +} diff --git a/src/system.rs b/src/system.rs index 24c2a65..d3572f5 100644 --- a/src/system.rs +++ b/src/system.rs @@ -14,7 +14,6 @@ use std::{fs, thread}; use amane::{Argument, Bluetooth, Bus, Service, Value}; -use crate::control::Control; use crate::parse; use crate::config::control::{polling, services}; @@ -800,13 +799,13 @@ impl Service for Gpu { Self::default() } - // ponytail: scans every process's open files every 2 s, so only while the control centre is open + // ponytail: scans every process's open files every 2 s, so only while the system monitor's card is open fn listen() { let mut names: HashMap = HashMap::new(); let mut engines: HashMap = HashMap::new(); loop { - if !Control::read().expanded() { + if crate::pods::open_card() != Some(crate::pods::Pod::Monitor) { thread::sleep(Duration::from_millis(polling::gpu_wait_ms())); continue; diff --git a/src/tray.rs b/src/tray.rs index f146016..d2a70a6 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -306,7 +306,7 @@ fn forget(name: &str) { * (gtk's choice, hicolor, Adwaita), then /usr/share/pixmaps. themes are walked once * per name, the answer kept */ -fn icon_file(name: &str, theme_path: &str) -> Option { +pub fn icon_file(name: &str, theme_path: &str) -> Option { static FOUND: Mutex>>> = Mutex::new(None); if name.is_empty() { diff --git a/weather/CREDITS.md b/weather/CREDITS.md new file mode 100644 index 0000000..c8613cc --- /dev/null +++ b/weather/CREDITS.md @@ -0,0 +1,33 @@ +# Weather photos + +The home card shows a photo of the weather now behind it. On first use the shell downloads the +one it needs from Wikimedia Commons (1280 px wide thumbnails) into `pills.home.photos` +(`~/.config/amane/weather` by default) as `-.jpg`. It never replaces a file +that's already there, so any of them can be swapped for your own picture (a swapped file shows +after the shell restarts). + +All of them are free to use under the licence given, with credit to their authors: + +| File | Photo | Author | Licence | +|---|---|---|---| +| clear-day.jpg | [Zugspitze, Alpspitze und Jubiläumsgrat von Kinderburg, Allmannshausen, Starnberger See](https://commons.wikimedia.org/wiki/File:Zugspitze,_Alpspitze_und_Jubil%C3%A4umsgrat_von_Kinderburg,_Allmannshausen,_Starnberger_See.jpg) | TGoeller | CC0 1.0 | +| clear-night.jpg | [Starry night at Old Uppsala, Sweden](https://commons.wikimedia.org/wiki/File:Starry_night_at_Old_Uppsala,_Sweden.jpg) | Kevin Cho | CC BY-SA 4.0 | +| partly-cloudy-day.jpg | [Cumulus humilis Schönwald im Schwarzwald 20180810](https://commons.wikimedia.org/wiki/File:Cumulus_humilis_Sch%C3%B6nwald_im_Schwarzwald_20180810.jpg) | Uoaei1 | CC BY-SA 4.0 | +| partly-cloudy-night.jpg | [Full moon over Gullmarn fjord at Holma Marina 1](https://commons.wikimedia.org/wiki/File:Full_moon_over_Gullmarn_fjord_at_Holma_Marina_1.jpg) | W.carter | CC BY-SA 4.0 | +| cloudy-day.jpg | [Vragender (NL), Vragenderveen -- 2014 -- 3102](https://commons.wikimedia.org/wiki/File:Vragender_(NL),_Vragenderveen_--_2014_--_3102.jpg) | Dietmar Rabich | CC BY-SA 4.0 | +| cloudy-night.jpg | [Stora Bornö on a cloudy night 2](https://commons.wikimedia.org/wiki/File:Stora_Born%C3%B6_on_a_cloudy_night_2.jpg) | W.carter | CC BY-SA 4.0 | +| fog-day.jpg | [Dülmen, Umland -- 2014 -- 7056](https://commons.wikimedia.org/wiki/File:D%C3%BClmen,_Umland_--_2014_--_7056.jpg) | Dietmar Rabich | CC BY-SA 4.0 | +| fog-night.jpg | [Foggy Autumn Evening on Pawia Street, Warsaw](https://commons.wikimedia.org/wiki/File:Foggy_Autumn_Evening_on_Pawia_Street,_Warsaw.jpg) | Oleslawlama | CC BY-SA 4.0 | +| rain-day.jpg | [Rain drops on window 01 ies](https://commons.wikimedia.org/wiki/File:Rain_drops_on_window_01_ies.jpg) | Frank Vincentz | CC BY-SA 3.0 | +| rain-night.jpg | [City streets; Reflections on the street surface after rain](https://commons.wikimedia.org/wiki/File:City_streets;_Reflections_on_the_street_surface_after_rain.jpg) | Tarasna0922 | CC BY-SA 4.0 | +| snow-day.jpg | [Sninský kameň (v zime) 001](https://commons.wikimedia.org/wiki/File:Sninsk%C3%BD_kame%C5%88_(v_zime)_001.jpg) | Milan Bališin | CC BY-SA 4.0 | +| snow-night.jpg | [TwinLamp@snowyNight(byHideObara)](https://commons.wikimedia.org/wiki/File:TwinLamp@snowyNight(byHideObara).jpg) | Hide Obara | CC0 1.0 | +| storm-day.jpg | [2011-07 Unwetter Schwäbisch Gmünd](https://commons.wikimedia.org/wiki/File:2011-07_Unwetter_Schw%C3%A4bisch_Gm%C3%BCnd.jpg) | Kreuzschnabel | CC BY-SA 3.0 | +| storm-night.jpg | [Lightning, Friedrichshafen (OW1A0402)](https://commons.wikimedia.org/wiki/File:Lightning,_Friedrichshafen_(OW1A0402).jpg) | Matti Blume | CC BY-SA 4.0 | + +Licences: [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/), +[CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/), +[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/). The shell shows the photos +scaled, cropped to the card and veiled in the card's colour; the files themselves are unchanged +Wikimedia thumbnails. Authors and licences were read from each file's Commons page +(`extmetadata`) on 2026-10-15.