Compare commits
2
Commits
6399844731
...
e8741b1cb3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8741b1cb3 | ||
|
|
bbe184ab97 |
@@ -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`.
|
- **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).
|
- **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).
|
- **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:** `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 control centre uses:** OpenRC services `iwd` (Wi-Fi), `bluetooth` (bluez), `openvpn.<name>` (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 daemons the shell uses:** OpenRC services `iwd`, `bluetooth`, `openvpn.<name>`, `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
|
## 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.
|
- **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.
|
- **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.
|
- **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").
|
- **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.
|
- **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.
|
- **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 shell never sets it. A shell blur setting was added once and reverted at the user's request.
|
- **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 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.
|
- **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 (it then joins Wi-Fi and adds a route). Check `org.freedesktop.DBus.NameHasOwner` first; only talk to it while it has an owner.
|
- **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** (`rc-service <svc> start|stop`, `cat /var/lib/iwd/<profile>.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.
|
- **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.
|
||||||
- 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.
|
- **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.
|
- Report honestly what was and wasn't checked on the real desktop.
|
||||||
|
|
||||||
## Current state
|
## 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.
|
- **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).
|
- **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`).
|
||||||
- **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.
|
- **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).
|
- **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 renders `~/.config/amane/matugen/amane.colors` into `~/.config/amane/theme`.
|
||||||
|
|
||||||
**Theme pipeline:** Skwd-wall v2's matugen integration `amane` renders `~/.config/amane/matugen/amane.colors` into `~/.config/amane/theme` (scheme `scheme-fidelity`).
|
|
||||||
|
|
||||||
## Constraints of Amane itself
|
## 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 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 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.
|
- `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 |
|
| 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. |
|
| `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 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`). |
|
| `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 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. |
|
| `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`. |
|
||||||
| `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. |
|
| `surface.rs` | widget function | `shell(w, h, radius, colors, lift, content)`: the chosen glass design for every surface. |
|
||||||
| `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`. |
|
| `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. |
|
||||||
| `spectrum.rs` `Spectrum` | Service | While the shown player plays: `pw-record --target <stream> \| 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). |
|
| `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 <pod> [monitor]`, `close`, `ask`, `processes`, `notices …`, `status`). |
|
||||||
| `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`. |
|
| `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. |
|
||||||
| `theme.rs` `Theme` | Service, `amane::watch_file` | 10 colours from `~/.config/amane/theme`, faded together on one `Animation<Colors>`; 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. |
|
| `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. |
|
||||||
| `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. |
|
| `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 <id>`). |
|
||||||
| `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. |
|
| `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. |
|
||||||
| `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. |
|
| `sound.rs` `Sound` | Service, poll | `pactl` sinks and sink-inputs → each player's streams, volume, mute, output, equalizer. Curves in `~/.config/amane/island-eq`. |
|
||||||
| `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.*`. |
|
| `spectrum.rs` `Spectrum` | Service | `pw-record --target <stream> \| cava` → levels while the shown player plays; kills stale cava with `-KILL`. |
|
||||||
| `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. |
|
| `art.rs` `Art` | Service, poll | Cover → `palette-cover` for `cover_colors`. |
|
||||||
| `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). |
|
| `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. |
|
||||||
| `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. |
|
| `workspaces.rs` | view + `Hover` | Workspace pill per monitor; slots by x in one `Stack` under one sliding disc; ghost and growing slots. |
|
||||||
| `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). |
|
| `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. |
|
||||||
| `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. |
|
| `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. |
|
||||||
| `png.rs` | plain functions | Stored-deflate PNG writer for pixmap tray icons. |
|
| `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-<key>.conf` per player with `libpipewire-module-filter-chain`, 10 biquads at `sound.equalizer.bands`. The player's streams move into sink `magic_island_eq.<key>`; 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`), 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.
|
||||||
- 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.
|
- 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.
|
||||||
- `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.
|
|
||||||
|
|
||||||
## Settings
|
## 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.
|
- The font and cava's bar count apply on restart; everything else on save.
|
||||||
- `amane ipc call config get|set|reset <path> [value]` drives everything without the mouse (`hyprland.<option.path>` for Hyprland, e.g. `hyprland.decoration.blur.size`); `status` says `open=… page=… errors=… search=…`.
|
- `amane ipc call config get|set|reset <path> [value]` (`hyprland.<option.path>` 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 }`. `hyprctl --batch` of all `j/getoption` prints plain "invalid type (internal error)" lines between objects for a few options.
|
- `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
|
## 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))`.
|
- **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** (sends `dispatch workspace N`). Use `hl.dsp.focus({ workspace = N })`.
|
- **`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 (a rule kept in a variable) vanish on a config reload.
|
- `hyprctl eval` / `repl` share one Lua state; globals vanish on a config reload.
|
||||||
- Layer-rule `match.namespace` is a full match: `^magic-.*$`. Layer rules can only switch blur on; strength is global.
|
- Layer-rule `match.namespace` is a full match: `^magic-.*$`.
|
||||||
- `$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.
|
- `$XDG_RUNTIME_DIR/hypr/` keeps folders of dead instances; the live one has `.socket.sock`.
|
||||||
- `hyprctl layers` lists hidden layer windows too (alpha 0).
|
- `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`).
|
- **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; test with a workspace hop there, then restore the focus.
|
- 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).
|
||||||
- `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 } })'`.
|
|
||||||
|
|
||||||
## Performance rules
|
## 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).
|
- **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: give it the largest size for the animation (the workspace ghost does).
|
- 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 clip over the whole card once took the iGPU from 47 to 9 fps); a shadow is two. Halos only while hovered.
|
- `.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. A veil over a gradient must be the same gradient at the same size.
|
- One backdrop gradient is affordable, many aren't.
|
||||||
- Draw repeated marks (candles, bars) as one `Path` per look.
|
- Draw repeated marks as one `Path` per look; many icons or graphs in one `Canvas`.
|
||||||
- Streaming data needs `amane::request_frame()` in the view while levels are live.
|
- 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
|
## 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 = '<copy of the desktop's ~/git/amane>' }`, an empty `[workspace]`. Then `cargo clippy` (first build takes minutes).
|
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 = '<copy of the desktop's ~/git/amane>' }`, 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` (12 tests).
|
2. Unit tests: `rustc --edition 2024 --test src/parse.rs -o /tmp/parse_test && /tmp/parse_test` (21 tests).
|
||||||
|
|
||||||
## Testing on the desktop
|
## Testing on the desktop
|
||||||
|
|
||||||
@@ -127,23 +123,21 @@ The equalizer: one `setsid -f pipewire -c eq-<key>.conf` per player with `libpip
|
|||||||
H=fabian@192.168.2.169
|
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 deploy src # back up the remote src once, then replace it
|
||||||
tools/amane-remote.sh $H build # amane compile
|
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 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 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 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
|
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.
|
- 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.
|
- `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"` takes ~20 ms) while dispatching the change, then tile the frames with ffmpeg (`tile=2x12`) and look at one sheet.
|
- **Animations:** capture a strip in a loop (`grim -g "3840,0 300x60"`) while dispatching the change, then tile the frames with ffmpeg.
|
||||||
- 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.
|
- Keys: `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 <wifi|bluetooth|vpn|sound>|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.
|
- 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`.
|
||||||
- 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=… -f rawvideo`), not by asking a vision model.
|
||||||
- Measure gaps and alignment from pixels (`ffmpeg … crop=1920:1:0:Y -f rawvideo`), not by asking a vision model.
|
- Check `uptime` before trusting timings. In `pkill -f` patterns over SSH use `[p]ython3 ...`.
|
||||||
- To tune a visualiser, sample real levels read-only first (`pw-record --target <stream> | 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.
|
|
||||||
|
|
||||||
### After testing
|
### 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 players stop
|
||||||
tools/amane-remote.sh $H sinks 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
|
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
|
## 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 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.
|
- 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.
|
- 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 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.
|
- `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 stop at 600 px and run under the island.
|
- 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 on every build) is unexplained.
|
- The open/close stutter rise (25–40 slow frames per 3 opens and closes) is unexplained.
|
||||||
- See README "Known limits".
|
- See README "Known limits".
|
||||||
|
|||||||
@@ -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).
|
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; they move outward while the island opens. A click morphs a pill into its card. Only one card is open across the shell: opening the island closes a card, and opening a card closes the island. Esc, the cross or a click elsewhere closes it.
|
||||||
|
|
||||||
|
- **AI** (sparkle): a chat with a local Ollama model, `gemma4:e4b` at `http://localhost:11434` by default. 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 leaves the machine.
|
||||||
|
- **System** (pulse): CPU, GPU clock, memory, network and disk read/write as graphs over the last minute, plus the hottest sensors. 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). The month grid dots the days with events. The iCloud calendar shows the coming events once you sign in from the card with your Apple ID and an [app-specific password](https://account.apple.com). The password goes into the keyring through `secret-tool` (libsecret, so a Secret Service like gnome-keyring or KeePassXC has to run), never into `config.toml`. The calendar is read over CalDAV from `pills.calendar.server`, so other CalDAV servers work too.
|
||||||
|
- **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 turns popups off.
|
||||||
|
|
||||||
|
`amane ipc call pods open|toggle ai|monitor|home|notifications [monitor]`, `close`, `ask <question>`, `processes`, `notices list|dismiss <id>|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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
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 local model through ollama's http api (curl, streamed line
|
||||||
|
* by line), never a cloud service. ollama isn't kept running: the first question starts
|
||||||
|
* its openrc service when it doesn't answer (doas, the same fixed rc-service rule the
|
||||||
|
* control centre uses)
|
||||||
|
*/
|
||||||
|
#[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<String>,
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service for Ai {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self { said: Vec::new(), busy: false, note: None, back: 0.0, typed: String::new(), round: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Option<Child>> = 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: start ollama if needed, then stream the reply into the chat
|
||||||
|
fn answer(round: u32) -> Result<(), String> {
|
||||||
|
let server = settings::server().trim_end_matches('/').to_string();
|
||||||
|
|
||||||
|
if !answers(&server) {
|
||||||
|
let local = server.contains("://localhost") || server.contains("://127.0.0.1");
|
||||||
|
|
||||||
|
if !(settings::start_service() && local) {
|
||||||
|
return Err(format!("Ollama doesn't answer at {server}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
note(round, "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));
|
||||||
|
}
|
||||||
|
|
||||||
|
note(round, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<String>) {
|
||||||
|
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();
|
||||||
|
|
||||||
|
let mut buttons: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
|
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(&format!("AI · {}", settings::model()), 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<Box<dyn Widget>> = 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<dyn Widget> = if bubbles.is_empty() {
|
||||||
|
Box::new(Rectangle::new().width(width).height(chat_h).align_child(Center, Center).child(Text::new(format!("Ask {} anything; it runs on this computer", 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<dyn Widget>]).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<dyn Widget>,
|
||||||
|
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<dyn Widget>,
|
||||||
|
chat,
|
||||||
|
Box::new(Rectangle::new().width(width).height(note_h).align_child(Start, Center).child(note)),
|
||||||
|
Box::new(field),
|
||||||
|
])
|
||||||
|
.gap(8.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -497,6 +497,68 @@ settings! {
|
|||||||
media_check_ms: u64 = 300, 50..=5000; "How often the song is checked for a change, for the island's peek"
|
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 = 16.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 = 22.0, 0..=60; "Corner radius of the opened cards"
|
||||||
|
pad: f32 = 18.0, 0..=60; "Padding inside the opened cards"
|
||||||
|
}
|
||||||
|
ai {
|
||||||
|
width: f32 = 440.0, 280..=1200; "Width of the AI card"
|
||||||
|
height: f32 = 520.0, 200..=1200; "Height of the AI card"
|
||||||
|
server: String = String::from("http://localhost:11434"); "The Ollama server the AI card talks to"
|
||||||
|
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 = 420.0, 280..=1200; "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 = 34.0, 10..=200; "Height of each graph"
|
||||||
|
temperatures: usize = 6, 0..=20; "How many temperature sensors to show, hottest first"
|
||||||
|
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 = 460.0, 300..=1200; "Width of the home card"
|
||||||
|
clock_size: f32 = 64.0, 20..=200; "Size of the big clock"
|
||||||
|
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"
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
calendar {
|
||||||
|
server: String = String::from("https://caldav.icloud.com/"); "The CalDAV server; iCloud's by default"
|
||||||
|
account: String = String::new(); "The Apple ID (or CalDAV user). Sign in from the home card; the app-specific password goes to the keyring, never here"
|
||||||
|
days: u64 = 14, 1..=90; "How many days ahead the calendar shows events"
|
||||||
|
sync_minutes: u64 = 15, 1..=360; "How often the calendar is synced, in minutes"
|
||||||
|
events: usize = 6, 1..=30; "How many upcoming events the home card lists"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
history {
|
||||||
|
width: f32 = 420.0, 280..=1200; "Width of the notification card"
|
||||||
|
height: f32 = 560.0, 200..=1400; "Height of the notification card"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// what config.toml set, by path; a missing or bad setting takes its default
|
// what config.toml set, by path; a missing or bad setting takes its default
|
||||||
@@ -864,6 +926,7 @@ pub fn modules() -> Vec<&'static str> {
|
|||||||
pub fn title(id: &str) -> String {
|
pub fn title(id: &str) -> String {
|
||||||
match id {
|
match id {
|
||||||
"control" => String::from("Control centre"),
|
"control" => String::from("Control centre"),
|
||||||
|
"pills" => String::from("Side pills"),
|
||||||
"bar" => String::from("Bar hiding"),
|
"bar" => String::from("Bar hiding"),
|
||||||
"input-capture" => String::from("Input capture"),
|
"input-capture" => String::from("Input capture"),
|
||||||
"opengl" => String::from("OpenGL"),
|
"opengl" => String::from("OpenGL"),
|
||||||
|
|||||||
+147
@@ -32,6 +32,26 @@ pub enum Glyph {
|
|||||||
Ethernet,
|
Ethernet,
|
||||||
// a cog, for the config window
|
// a cog, for the config window
|
||||||
Gear,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas {
|
pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas {
|
||||||
@@ -234,5 +254,132 @@ pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin:
|
|||||||
|
|
||||||
parts
|
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)],
|
||||||
|
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<Box<dyn Shape>> = 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<Box<dyn Shape>> = 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<Box<dyn Shape>> = 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)
|
||||||
|
}
|
||||||
|
|||||||
+678
@@ -0,0 +1,678 @@
|
|||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use amane::{Canvas, Center, Color, Column, Full, Image, Rectangle, Row, Service, Shape, SpaceBetween, Stack, Start, Text, TextInput, Weight, Widget};
|
||||||
|
|
||||||
|
use crate::config::appearance::{overlays, text};
|
||||||
|
use crate::config::pills::{calendar as agenda_settings, home as settings, layout};
|
||||||
|
use crate::config;
|
||||||
|
use crate::glyph::{Glyph, glyph_shapes, icon};
|
||||||
|
use crate::island::faded;
|
||||||
|
use crate::parse::{self, Event, Sky, Value, Weather};
|
||||||
|
use crate::{pods, theme};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the home card: who's here, a big clock and the date, the weather (open-meteo, no key),
|
||||||
|
* and the calendar (any caldav server, icloud's by default). the calendar password is an
|
||||||
|
* app-specific one in the keyring (secret-tool), handed to curl on stdin, never written
|
||||||
|
* to a file or a command line
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_secs() as i64);
|
||||||
|
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the local offset from utc in seconds, from `date +%z` (+0200)
|
||||||
|
fn utc_offset() -> i64 {
|
||||||
|
let printed = Command::new("date").arg("+%z").output().map(|done| String::from_utf8_lossy(&done.stdout).trim().to_string()).unwrap_or_default();
|
||||||
|
|
||||||
|
let sign = if printed.starts_with('-') { -1 } else { 1 };
|
||||||
|
let digits = printed.trim_start_matches(['+', '-']);
|
||||||
|
let hours: i64 = digits.get(..2).and_then(|hours| hours.parse().ok()).unwrap_or(0);
|
||||||
|
let minutes: i64 = digits.get(2..4).and_then(|minutes| minutes.parse().ok()).unwrap_or(0);
|
||||||
|
|
||||||
|
sign * (hours * 3600 + minutes * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn home_dir() -> PathBuf {
|
||||||
|
env::var_os("HOME").map(PathBuf::from).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// the avatar picture, if the file is there
|
||||||
|
fn avatar_file() -> Option<String> {
|
||||||
|
let wanted = settings::avatar();
|
||||||
|
let path = match wanted.strip_prefix("~/") {
|
||||||
|
Some(rest) => home_dir().join(rest),
|
||||||
|
None => PathBuf::from(&wanted),
|
||||||
|
};
|
||||||
|
|
||||||
|
path.is_file().then(|| path.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Forecast {
|
||||||
|
weather: Option<Weather>,
|
||||||
|
problem: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_secs(settings::weather_minutes() * 60));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Agenda {
|
||||||
|
// upcoming events with their calendar's colour, soonest first
|
||||||
|
events: Vec<(Event, Option<Color>)>,
|
||||||
|
synced: bool,
|
||||||
|
problem: Option<String>,
|
||||||
|
// the sign-in form is open, and the apple id typed into it
|
||||||
|
form: bool,
|
||||||
|
account: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service for Agenda {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listen() {
|
||||||
|
loop {
|
||||||
|
sync();
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_secs(agenda_settings::sync_minutes() * 60));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// whether the card has a field to type into (then it keeps the keyboard and stays open)
|
||||||
|
pub fn signing_in() -> bool {
|
||||||
|
Agenda::read().form
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEYRING: [&str; 2] = ["service", "amane-calendar"];
|
||||||
|
|
||||||
|
fn password(account: &str) -> Option<String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_in(account: String, password: String) {
|
||||||
|
let account = account.trim().to_string();
|
||||||
|
|
||||||
|
if account.is_empty() || password.is_empty() {
|
||||||
|
Agenda::write().problem = Some(String::from("Type the Apple ID and an app-specific password"));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TextInput::set_text("home-password", "");
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let stored = Command::new("secret-tool")
|
||||||
|
.args(["store", "--label=Magic Island calendar", KEYRING[0], KEYRING[1], "account", &account])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.and_then(|mut child| {
|
||||||
|
if let Some(mut input) = child.stdin.take() {
|
||||||
|
input.write_all(password.as_bytes())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
child.wait()
|
||||||
|
})
|
||||||
|
.is_ok_and(|status| status.success());
|
||||||
|
|
||||||
|
if !stored {
|
||||||
|
Agenda::write().problem = Some(String::from("The keyring didn't take the password (secret-tool)"));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = config::set("pills.calendar.account", Value::Text(account));
|
||||||
|
|
||||||
|
Agenda::write().form = false;
|
||||||
|
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_out() {
|
||||||
|
let account = agenda_settings::account();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let _ = Command::new("secret-tool").args(["clear", KEYRING[0], KEYRING[1], "account", &account]).output();
|
||||||
|
let _ = config::reset("pills.calendar.account");
|
||||||
|
|
||||||
|
let mut agenda = Agenda::write();
|
||||||
|
|
||||||
|
agenda.events.clear();
|
||||||
|
agenda.synced = false;
|
||||||
|
agenda.problem = None;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// the calendar's events from now on, on this thread
|
||||||
|
fn sync() {
|
||||||
|
let account = agenda_settings::account();
|
||||||
|
|
||||||
|
if account.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(password) = password(&account) else {
|
||||||
|
Agenda::write().problem = Some(String::from("The calendar password isn't in the keyring: sign in again"));
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let found = events(&agenda_settings::server(), &account, &password);
|
||||||
|
|
||||||
|
let mut agenda = Agenda::write();
|
||||||
|
|
||||||
|
match found {
|
||||||
|
Ok(events) => {
|
||||||
|
agenda.events = events;
|
||||||
|
agenda.synced = true;
|
||||||
|
agenda.problem = None;
|
||||||
|
}
|
||||||
|
Err(why) => agenda.problem = Some(why),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// one webdav request with curl; the account and password go in on stdin as curl config
|
||||||
|
fn dav(method: &str, url: &str, depth: &str, body: &str, account: &str, password: &str) -> Result<String, String> {
|
||||||
|
let mut child = Command::new("curl")
|
||||||
|
.args(["-s", "-m", "20", "-X", method, "-H", &format!("Depth: {depth}"), "-H", "Content-Type: application/xml; charset=utf-8", "--data-binary", body, "-w", "\n%{http_code}", "-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 quote = |text: &str| text.replace('\\', "\\\\").replace('"', "\\\"");
|
||||||
|
let _ = writeln!(input, "user = \"{}:{}\"", quote(account), quote(password));
|
||||||
|
}
|
||||||
|
|
||||||
|
let done = child.wait_with_output().map_err(|error| format!("curl: {error}"))?;
|
||||||
|
let printed = String::from_utf8_lossy(&done.stdout);
|
||||||
|
let (answer, status) = printed.rsplit_once('\n').unwrap_or(("", &printed));
|
||||||
|
|
||||||
|
match status.trim() {
|
||||||
|
"401" | "403" => Err(String::from("The calendar didn't accept the Apple ID or the app-specific password")),
|
||||||
|
code if code.starts_with('2') => Ok(answer.to_string()),
|
||||||
|
"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])
|
||||||
|
}
|
||||||
|
|
||||||
|
// caldav discovery (principal, calendar home, calendars), then every calendar's events in the coming days
|
||||||
|
fn events(server: &str, account: &str, password: &str) -> Result<Vec<(Event, Option<Color>)>, String> {
|
||||||
|
let ask = |method: &str, url: &str, depth: &str, body: &str| dav(method, url, depth, body, account, password);
|
||||||
|
|
||||||
|
let found = ask("PROPFIND", server, "0", r#"<d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>"#)?;
|
||||||
|
let principal = parse::xml_blocks(&found, "current-user-principal").first().and_then(|block| parse::xml_texts(block, "href").into_iter().next()).ok_or("The calendar server named no account")?;
|
||||||
|
let principal = join(server, &principal);
|
||||||
|
|
||||||
|
let found = ask("PROPFIND", &principal, "0", r#"<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><c:calendar-home-set/></d:prop></d:propfind>"#)?;
|
||||||
|
let home = parse::xml_blocks(&found, "calendar-home-set").first().and_then(|block| parse::xml_texts(block, "href").into_iter().next()).ok_or("The calendar server named no calendars")?;
|
||||||
|
let home = join(&principal, &home);
|
||||||
|
|
||||||
|
let found = ask(
|
||||||
|
"PROPFIND",
|
||||||
|
&home,
|
||||||
|
"1",
|
||||||
|
r#"<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/"><d:prop><d:displayname/><d:resourcetype/><a:calendar-color/><c:supported-calendar-component-set/></d:prop></d:propfind>"#,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let offset = utc_offset();
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_secs() as i64);
|
||||||
|
let today = (now + offset).div_euclid(86400) * 86400 - offset;
|
||||||
|
let (from, to) = (parse::dav_time(today), parse::dav_time(today + agenda_settings::days() as i64 * 86400));
|
||||||
|
|
||||||
|
let query = format!(
|
||||||
|
r#"<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><c:calendar-data><c:expand start="{from}" end="{to}"/></c:calendar-data></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"><c:time-range start="{from}" end="{to}"/></c:comp-filter></c:comp-filter></c:filter></c:calendar-query>"#
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut all: Vec<(Event, Option<Color>)> = Vec::new();
|
||||||
|
|
||||||
|
for calendar in parse::dav_calendars(&found) {
|
||||||
|
let color = calendar.color.as_deref().and_then(config::color);
|
||||||
|
let Ok(answer) = ask("REPORT", &join(&home, &calendar.href), "1", &query) else { continue };
|
||||||
|
|
||||||
|
for ics in parse::xml_texts(&answer, "calendar-data") {
|
||||||
|
all.extend(parse::ics_events(&ics, offset).into_iter().filter(|event| event.end > now).map(|event| (event, color)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
all.sort_by_key(|(event, _)| event.start);
|
||||||
|
|
||||||
|
Ok(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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' heights, shared by the card and height()
|
||||||
|
const TOP_H: f32 = 48.0;
|
||||||
|
const WEATHER_H: f32 = 60.0;
|
||||||
|
const DAYS_H: f32 = 62.0;
|
||||||
|
const CELL: f32 = 24.0;
|
||||||
|
|
||||||
|
fn clock_h() -> f32 {
|
||||||
|
settings::clock_size() * 1.1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calendar_h() -> f32 {
|
||||||
|
let grid = text::tiny() + 8.0 + 6.0 * CELL;
|
||||||
|
let list = text::small() + 8.0 + agenda_settings::events() as f32 * (text::small() + 10.0);
|
||||||
|
|
||||||
|
grid.max(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn height() -> f32 {
|
||||||
|
2.0 * layout::pad() + TOP_H + clock_h() + WEATHER_H + DAYS_H + 1.0 + calendar_h() + 5.0 * 12.0
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn card(width: f32, height: f32) -> Rectangle {
|
||||||
|
let clock = Clock::read();
|
||||||
|
let forecast = Forecast::read();
|
||||||
|
let agenda = Agenda::read();
|
||||||
|
let theme = theme::now();
|
||||||
|
|
||||||
|
let days = clock.now.div_euclid(86400);
|
||||||
|
let (year, month, day) = parse::civil_from_days(days);
|
||||||
|
|
||||||
|
let weekday = parse::weekday(days) as usize;
|
||||||
|
let seconds_today = clock.now.rem_euclid(86400);
|
||||||
|
let (hour, minute, second) = (seconds_today / 3600, seconds_today % 3600 / 60, seconds_today % 60);
|
||||||
|
|
||||||
|
// who's here: the avatar, a greeting, the date
|
||||||
|
let avatar: Box<dyn Widget> = match &clock.avatar {
|
||||||
|
Some(path) => Box::new(Rectangle::new().width(TOP_H).height(TOP_H).radius(Full).fill(Image::cover(path.clone()))),
|
||||||
|
None => Box::new(Rectangle::new().width(TOP_H).height(TOP_H).radius(Full).fill(faded(theme.accent, 60)).align_child(Center, Center).child(icon(Glyph::Person, 26.0, theme.accent))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let greeting = match hour {
|
||||||
|
5..12 => "Good morning",
|
||||||
|
12..18 => "Good afternoon",
|
||||||
|
18..23 => "Good evening",
|
||||||
|
_ => "Good night",
|
||||||
|
};
|
||||||
|
|
||||||
|
let date = if clock.now == 0 { String::new() } else { format!("{}, {day} {} {year}", WEEKDAYS[weekday % 7], MONTHS[(month as usize + 11) % 12]) };
|
||||||
|
|
||||||
|
let top = Row::new(vec![
|
||||||
|
Box::new(
|
||||||
|
Row::new(vec![
|
||||||
|
avatar,
|
||||||
|
Box::new(
|
||||||
|
Column::new(vec![
|
||||||
|
Box::new(Text::new(format!("{greeting}, {}", name())).size(text::heading()).weight(Weight::Bold).color(theme.text)) as Box<dyn Widget>,
|
||||||
|
Box::new(Text::new(date).size(text::small()).color(theme.dim)),
|
||||||
|
])
|
||||||
|
.gap(4.0),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.gap(12.0)
|
||||||
|
.align(Center),
|
||||||
|
) as Box<dyn Widget>,
|
||||||
|
Box::new(pods::button(Glyph::Cross, pods::close)),
|
||||||
|
])
|
||||||
|
.width(width)
|
||||||
|
.justify(SpaceBetween)
|
||||||
|
.align(Center);
|
||||||
|
|
||||||
|
// the big clock
|
||||||
|
let time = if settings::seconds() { format!("{hour:02}:{minute:02}:{second:02}") } else { format!("{hour:02}:{minute:02}") };
|
||||||
|
let big = Rectangle::new().width(width).height(clock_h()).align_child(Start, Center).child(Text::new(time).size(settings::clock_size()).weight(Weight::Bold).color(theme.text).tight());
|
||||||
|
|
||||||
|
let (now_weather, days_row) = weather(&forecast, width, days);
|
||||||
|
|
||||||
|
Rectangle::new().width(width).height(height).child(
|
||||||
|
Column::new(vec![
|
||||||
|
Box::new(Rectangle::new().width(width).height(TOP_H).child(top)) as Box<dyn Widget>,
|
||||||
|
Box::new(big),
|
||||||
|
now_weather,
|
||||||
|
days_row,
|
||||||
|
Box::new(pods::rule(width)),
|
||||||
|
calendar(&agenda, width, (year, month), days, clock.offset),
|
||||||
|
])
|
||||||
|
.gap(12.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the weather now, and the next days in one row (their icons in one canvas)
|
||||||
|
fn weather(forecast: &Forecast, width: f32, today: i64) -> (Box<dyn Widget>, Box<dyn Widget>) {
|
||||||
|
let theme = theme::now();
|
||||||
|
|
||||||
|
let Some(weather) = &forecast.weather else {
|
||||||
|
let words = forecast.problem.clone().unwrap_or_else(|| format!("Fetching the weather for {}…", settings::city()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
Box::new(Rectangle::new().width(width).height(WEATHER_H).align_child(Start, Center).child(Text::new(words).size(text::small()).color(theme.dim))),
|
||||||
|
Box::new(Rectangle::new().width(width).height(DAYS_H)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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(icon(sky_glyph(weather.code, weather.is_day), 44.0, theme.accent)) as Box<dyn Widget>,
|
||||||
|
Box::new(
|
||||||
|
Column::new(vec![
|
||||||
|
Box::new(
|
||||||
|
Row::new(vec![
|
||||||
|
Box::new(Text::new(format!("{:.0}°", weather.temperature)).size(28.0).weight(Weight::Bold).color(theme.text)) as Box<dyn Widget>,
|
||||||
|
Box::new(Text::new(format!("{} · feels {:.0}°", parse::weather_words(weather.code), weather.apparent)).size(text::small()).color(theme.text)),
|
||||||
|
])
|
||||||
|
.gap(10.0)
|
||||||
|
.align(Center),
|
||||||
|
) as Box<dyn Widget>,
|
||||||
|
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(12.0)
|
||||||
|
.align(Center),
|
||||||
|
) as Box<dyn Widget>,
|
||||||
|
Box::new(
|
||||||
|
Column::new(vec![
|
||||||
|
Box::new(Text::new(format!("↑ {high:.0}° ↓ {low:.0}°")).size(text::small()).color(theme.text)) as Box<dyn Widget>,
|
||||||
|
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
|
||||||
|
let coming: Vec<_> = weather.days.iter().skip(1).take(4).collect();
|
||||||
|
let column_w = width / 4.0;
|
||||||
|
let icon_size = 22.0;
|
||||||
|
|
||||||
|
let mut shapes: Vec<Box<dyn Shape>> = Vec::new();
|
||||||
|
let mut columns: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
|
for (index, day) in coming.iter().enumerate() {
|
||||||
|
let weekday = parse::weekday(today + 1 + index as i64) as usize;
|
||||||
|
|
||||||
|
shapes.extend(glyph_shapes(sky_glyph(day.code, true), icon_size, theme.text, 0.0, (index as f32 * column_w + (column_w - icon_size) / 2.0, text::tiny() + 8.0)));
|
||||||
|
|
||||||
|
columns.push(Box::new(
|
||||||
|
Rectangle::new().width(column_w).height(DAYS_H).child(
|
||||||
|
Column::new(vec![
|
||||||
|
Box::new(Rectangle::new().width(column_w).height(text::tiny() + 4.0).align_child(Center, Center).child(Text::new(&WEEKDAYS[weekday % 7][..3]).size(text::tiny()).color(theme.dim))) as Box<dyn Widget>,
|
||||||
|
Box::new(Rectangle::new().width(column_w).height(icon_size + 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),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let days = Stack::new(vec![Box::new(Canvas::new().width(width).height(DAYS_H).shapes(shapes)) as Box<dyn Widget>, Box::new(Row::new(columns))]).width(width).height(DAYS_H);
|
||||||
|
|
||||||
|
(Box::new(Rectangle::new().width(width).height(WEATHER_H).child(now)), Box::new(days))
|
||||||
|
}
|
||||||
|
|
||||||
|
// this month's grid on the left, the coming events (or the sign-in) on the right; `offset` is local time's from utc
|
||||||
|
fn calendar(agenda: &Agenda, width: f32, (year, month): (i64, u32), today: i64, offset: i64) -> Box<dyn Widget> {
|
||||||
|
let theme = theme::now();
|
||||||
|
let grid_w = 7.0 * CELL;
|
||||||
|
let list_w = width - grid_w - 20.0;
|
||||||
|
|
||||||
|
// the month: weekday letters, then its days under the right ones; today in the accent, days with events dotted
|
||||||
|
let first = parse::days_from_civil(year, month, 1);
|
||||||
|
let next = if month == 12 { parse::days_from_civil(year + 1, 1, 1) } else { parse::days_from_civil(year, month + 1, 1) };
|
||||||
|
let lead = parse::weekday(first) as i64;
|
||||||
|
|
||||||
|
let busy: Vec<i64> = agenda.events.iter().map(|(event, _)| (event.start + offset).div_euclid(86400)).collect();
|
||||||
|
|
||||||
|
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(Row::new(
|
||||||
|
["M", "T", "W", "T", "F", "S", "S"].iter().map(|letter| Box::new(Rectangle::new().width(CELL).height(text::tiny() + 8.0).align_child(Center, Center).child(Text::new(*letter).size(text::tiny()).color(theme.dim))) as Box<dyn Widget>).collect(),
|
||||||
|
))];
|
||||||
|
|
||||||
|
for week in 0..6 {
|
||||||
|
let mut cells: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
|
for weekday in 0..7 {
|
||||||
|
let date = first - lead + week * 7 + weekday;
|
||||||
|
let inside = date >= first && date < next;
|
||||||
|
let number = parse::civil_from_days(date).2;
|
||||||
|
let is_today = date == today;
|
||||||
|
let dotted = inside && busy.contains(&date);
|
||||||
|
|
||||||
|
let color = if is_today { theme.on_accent } else if inside { theme.text } else { faded(theme.dim, 60) };
|
||||||
|
|
||||||
|
let mut layers: Vec<Box<dyn Widget>> = vec![Box::new(Text::new(number.to_string()).size(text::tiny()).weight(if is_today { Weight::Bold } else { Weight::Regular }).color(color))];
|
||||||
|
|
||||||
|
if dotted && !is_today {
|
||||||
|
layers.push(Box::new(Rectangle::new().width(3.0).height(3.0).radius(Full).fill(theme.accent).translate(0.0, 8.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
cells.push(Box::new(
|
||||||
|
Rectangle::new()
|
||||||
|
.width(CELL)
|
||||||
|
.height(CELL)
|
||||||
|
.radius(Full)
|
||||||
|
.fill(if is_today { theme.accent } else { amane::Color::rgba(0, 0, 0, 0) })
|
||||||
|
.align_child(Center, Center)
|
||||||
|
.child(Stack::new(layers)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(Box::new(Row::new(cells)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let grid = Column::new(rows);
|
||||||
|
|
||||||
|
let account = agenda_settings::account();
|
||||||
|
|
||||||
|
let list: Box<dyn Widget> = if agenda.form {
|
||||||
|
sign_in_form(agenda, list_w)
|
||||||
|
} else if account.is_empty() {
|
||||||
|
Box::new(
|
||||||
|
Column::new(vec![
|
||||||
|
Box::new(Text::new("Calendar").size(text::small()).weight(Weight::Bold).color(theme.text)) as Box<dyn Widget>,
|
||||||
|
Box::new(Text::new("Sign in with your Apple ID and an app-specific password to see your iCloud calendar here.").size(text::small()).color(theme.dim).wrap()),
|
||||||
|
Box::new(pods::chip("Sign in to iCloud", None, || Agenda::write().form = true)),
|
||||||
|
])
|
||||||
|
.gap(8.0),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let mut lines: Vec<Box<dyn Widget>> = vec![Box::new(
|
||||||
|
Row::new(vec![
|
||||||
|
Box::new(Text::new("Coming up").size(text::small()).weight(Weight::Bold).color(theme.text)) as Box<dyn Widget>,
|
||||||
|
Box::new(pods::chip("Sign out", None, sign_out)),
|
||||||
|
])
|
||||||
|
.width(list_w)
|
||||||
|
.justify(SpaceBetween)
|
||||||
|
.align(Center),
|
||||||
|
)];
|
||||||
|
|
||||||
|
if let Some(problem) = &agenda.problem {
|
||||||
|
lines.push(Box::new(Text::new(problem.as_str()).size(text::small()).color(crate::config::appearance::overlays::low_battery()).wrap()));
|
||||||
|
} else if !agenda.synced {
|
||||||
|
lines.push(Box::new(Text::new("Syncing…").size(text::small()).color(theme.dim)));
|
||||||
|
} else if agenda.events.is_empty() {
|
||||||
|
lines.push(Box::new(Text::new(format!("Nothing in the next {} days", agenda_settings::days())).size(text::small()).color(theme.dim)));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (event, color) in agenda.events.iter().take(agenda_settings::events()) {
|
||||||
|
let start = event.start + offset;
|
||||||
|
let start_day = start.div_euclid(86400);
|
||||||
|
let clock = format!("{:02}:{:02}", start.rem_euclid(86400) / 3600, start.rem_euclid(3600) / 60);
|
||||||
|
|
||||||
|
let when = match (start_day - today, event.all_day) {
|
||||||
|
(..=0, true) => String::from("Today"),
|
||||||
|
(..=0, false) => clock,
|
||||||
|
(1, true) => String::from("Tomorrow"),
|
||||||
|
(1, false) => format!("Tmrw {clock}"),
|
||||||
|
(_, true) => WEEKDAYS[parse::weekday(start_day) as usize % 7][..3].to_string(),
|
||||||
|
(_, false) => format!("{} {clock}", &WEEKDAYS[parse::weekday(start_day) as usize % 7][..3]),
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(Box::new(
|
||||||
|
Row::new(vec![
|
||||||
|
Box::new(Rectangle::new().width(6.0).height(6.0).radius(Full).fill(color.unwrap_or(theme.accent))) as Box<dyn Widget>,
|
||||||
|
Box::new(Rectangle::new().width(64.0).height(text::small() + 6.0).align_child(Start, Center).child(Text::new(when).size(text::small()).color(theme.dim))),
|
||||||
|
Box::new(Rectangle::new().width(list_w - 84.0).height(text::small() + 6.0).align_child(Start, Center).child(Text::new(event.summary.as_str()).size(text::small()).color(theme.text).elide())),
|
||||||
|
])
|
||||||
|
.gap(7.0)
|
||||||
|
.align(Center),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Box::new(Column::new(lines).gap(4.0))
|
||||||
|
};
|
||||||
|
|
||||||
|
Box::new(
|
||||||
|
Rectangle::new()
|
||||||
|
.width(width)
|
||||||
|
.height(calendar_h())
|
||||||
|
.child(Row::new(vec![Box::new(grid) as Box<dyn Widget>, Box::new(Rectangle::new().width(list_w).height(calendar_h()).child(Column::new(vec![list])))]).gap(20.0)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_in_form(agenda: &Agenda, width: f32) -> Box<dyn Widget> {
|
||||||
|
let theme = theme::now();
|
||||||
|
|
||||||
|
let field = |input: TextInput| Rectangle::new().width(width).height(32.0).radius(10.0).fill(overlays::chip()).padding(amane::Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 }).align_child(Start, Center).child(input);
|
||||||
|
|
||||||
|
let account = TextInput::new("home-account").width(width - 20.0).size(text::small()).color(theme.text).placeholder("Apple ID (e-mail)").focused().on_change(|typed| Agenda::write().account = typed);
|
||||||
|
|
||||||
|
let password = TextInput::new("home-password").width(width - 20.0).size(text::small()).color(theme.text).placeholder("App-specific password, then Enter").password().on_submit(|typed| {
|
||||||
|
let account = Agenda::read().account.clone();
|
||||||
|
|
||||||
|
sign_in(account, typed);
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut parts: Vec<Box<dyn Widget>> = vec![
|
||||||
|
Box::new(Text::new("iCloud calendar").size(text::small()).weight(Weight::Bold).color(theme.text)),
|
||||||
|
Box::new(field(account)),
|
||||||
|
Box::new(field(password)),
|
||||||
|
Box::new(Text::new("Make the password at account.apple.com › Sign-In and Security › App-Specific Passwords.").size(text::tiny()).color(theme.dim).wrap()),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(problem) = &agenda.problem {
|
||||||
|
parts.push(Box::new(Text::new(problem.as_str()).size(text::tiny()).color(crate::config::appearance::overlays::low_battery()).wrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(Box::new(pods::chip("Cancel", None, || Agenda::write().form = false)));
|
||||||
|
|
||||||
|
Box::new(Column::new(parts).gap(6.0))
|
||||||
|
}
|
||||||
+12
-1
@@ -394,6 +394,9 @@ fn open_on(island: &mut Island, monitor: Option<String>) {
|
|||||||
|
|
||||||
island.expanded = true;
|
island.expanded = true;
|
||||||
island.open.to(1.0);
|
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
|
// the open timeline, at rest on the pill
|
||||||
@@ -408,7 +411,7 @@ pub fn focused_monitor() -> Option<String> {
|
|||||||
workspaces.list().iter().find(|workspace| workspace.focused()).and_then(|workspace| workspace.output()).map(String::from)
|
workspaces.list().iter().find(|workspace| workspace.focused()).and_then(|workspace| workspace.output()).map(String::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collapse() {
|
pub fn collapse() {
|
||||||
let mut island = Island::write();
|
let mut island = Island::write();
|
||||||
|
|
||||||
island.expanded = false;
|
island.expanded = false;
|
||||||
@@ -514,6 +517,14 @@ fn next_player() {
|
|||||||
Island::write().selected = next;
|
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 {
|
pub fn mix(from: f32, to: f32, amount: f32) -> f32 {
|
||||||
from + (to - from) * amount
|
from + (to - from) * amount
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -1,16 +1,21 @@
|
|||||||
|
mod ai;
|
||||||
mod art;
|
mod art;
|
||||||
mod config;
|
mod config;
|
||||||
mod control;
|
mod control;
|
||||||
mod edge;
|
mod edge;
|
||||||
mod glyph;
|
mod glyph;
|
||||||
|
mod home;
|
||||||
mod hypr;
|
mod hypr;
|
||||||
mod island;
|
mod island;
|
||||||
mod map;
|
mod map;
|
||||||
|
mod notices;
|
||||||
mod parse;
|
mod parse;
|
||||||
|
mod pods;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod sound;
|
mod sound;
|
||||||
mod spectrum;
|
mod spectrum;
|
||||||
|
mod stats;
|
||||||
mod surface;
|
mod surface;
|
||||||
mod system;
|
mod system;
|
||||||
mod png;
|
mod png;
|
||||||
@@ -37,14 +42,19 @@ fn main() {
|
|||||||
.ipc("control", control::command)
|
.ipc("control", control::command)
|
||||||
.ipc("tray", tray::command)
|
.ipc("tray", tray::command)
|
||||||
.ipc("config", settings::command)
|
.ipc("config", settings::command)
|
||||||
|
.ipc("pods", pods::command)
|
||||||
.window_per_monitor(edge::spacer)
|
.window_per_monitor(edge::spacer)
|
||||||
.window_per_monitor(edge::view)
|
.window_per_monitor(edge::view)
|
||||||
.window_per_monitor(island::view)
|
.window_per_monitor(island::view)
|
||||||
|
// the small pills beside the island, and their cards
|
||||||
|
.window_per_monitor(pods::left)
|
||||||
|
.window_per_monitor(pods::right)
|
||||||
.window_per_monitor(workspaces::view)
|
.window_per_monitor(workspaces::view)
|
||||||
.window_per_monitor(control::view)
|
.window_per_monitor(control::view)
|
||||||
.window_per_monitor(control::menu_view)
|
.window_per_monitor(control::menu_view)
|
||||||
.window_per_monitor(tray::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)
|
.window_per_monitor(prompt::view)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
+443
@@ -0,0 +1,443 @@
|
|||||||
|
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::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<u32, SystemTime>,
|
||||||
|
// a picture file per notification: its image, else its icon found in the themes
|
||||||
|
pictures: HashMap<u32, String>,
|
||||||
|
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<Duration> {
|
||||||
|
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)> =
|
||||||
|
Notifications::read().list().iter().map(|notice| (notice.id(), notice.received(), notice.urgency(), notice.icon().to_string(), notice.image().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<u32> = notices.known.keys().filter(|id| !current.iter().any(|(known, ..)| known == *id)).copied().collect();
|
||||||
|
|
||||||
|
let expired: Vec<u32> = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// icon lookups walk the themes: here, not under the lock and never in a view
|
||||||
|
let pictures: Vec<(u32, Option<String>)> = 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, ..) in &arrived {
|
||||||
|
let fresh = !notices.known.contains_key(id);
|
||||||
|
|
||||||
|
notices.known.insert(*id, *received);
|
||||||
|
notices.popups.retain(|(shown, _)| shown != id);
|
||||||
|
|
||||||
|
if popping {
|
||||||
|
notices.popups.insert(0, (*id, Instant::now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if fresh && !looking {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<String> {
|
||||||
|
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<dyn Widget> {
|
||||||
|
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<Box<dyn Widget>> {
|
||||||
|
if notice.actions().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = notice.id();
|
||||||
|
let chips: Vec<Box<dyn Widget>> = 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<dyn Widget>
|
||||||
|
})
|
||||||
|
.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<dyn Fn()>) -> (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<dyn Widget>,
|
||||||
|
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<dyn Widget>,
|
||||||
|
Box::new(pods::button(Glyph::Cross, close)),
|
||||||
|
])
|
||||||
|
.width(width)
|
||||||
|
.justify(SpaceBetween);
|
||||||
|
|
||||||
|
let mut parts: Vec<Box<dyn Widget>> = 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<Box<dyn Widget>> = 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 <id>|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<String> = 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<Box<dyn Widget>> = vec![Box::new(pods::chip(if settings::enabled() { "Mute" } else { "Unmute" }, 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);
|
||||||
|
let list_h = height - pods::header_height() - 10.0;
|
||||||
|
|
||||||
|
let body: Box<dyn Widget> = 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<dyn Widget>,
|
||||||
|
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<Box<dyn Widget>> = 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();
|
||||||
|
|
||||||
|
let item = Rectangle::new().width(item_w).height(inner_h + 24.0).radius(16.0).fill(overlays::chip()).padding(12.0).child(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))
|
||||||
|
};
|
||||||
|
|
||||||
|
Rectangle::new().width(width).height(height).child(Column::new(vec![Box::new(header) as Box<dyn Widget>, body]).gap(10.0))
|
||||||
|
}
|
||||||
+807
@@ -1205,6 +1205,626 @@ pub fn hypr_lua(kind: HyprKind, text: &str) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 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<Chunk> {
|
||||||
|
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<u64> = 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<Memory> {
|
||||||
|
let field = |name: &str| meminfo.lines().find_map(|line| line.strip_prefix(name)?.strip_prefix(':')?.split_whitespace().next()?.parse::<u64>().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::<u64>().unwrap_or(0));
|
||||||
|
|
||||||
|
(received.saturating_add(numbers.next().unwrap_or(0)), sent.saturating_add(numbers.nth(7).unwrap_or(0)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// a /sys/block/<dev>/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<Day>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// open-meteo's forecast json as Weather; none without its current object
|
||||||
|
pub fn weather(text: &str) -> Option<Weather> {
|
||||||
|
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: <br> 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<Tag<'_>> {
|
||||||
|
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: <br> 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("<![CDATA[") {
|
||||||
|
let end = inner.find("]]>").unwrap_or(inner.len());
|
||||||
|
|
||||||
|
plain.push_str(inner.get(..end).unwrap_or(""));
|
||||||
|
|
||||||
|
Some("<![CDATA[".len() + end + "]]>".len())
|
||||||
|
} else if from.starts_with("<!--") {
|
||||||
|
Some(from.find("-->").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<String> {
|
||||||
|
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)) = [("<![CDATA[", "]]>"), ("<!--", "-->")].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<String> {
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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<Calendar> {
|
||||||
|
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<i64>,
|
||||||
|
duration: Option<i64>,
|
||||||
|
summary: String,
|
||||||
|
location: String,
|
||||||
|
cancelled: 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 event(self) -> Option<Event> {
|
||||||
|
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(Event { start, end, all_day, summary: self.summary, location: self.location })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
pub fn ics_events(ics: &str, offset_s: i64) -> Vec<Event> {
|
||||||
|
// a line starting with a space or tab continues the one before
|
||||||
|
let mut lines: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for line in ics.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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let mut draft: Option<Draft> = None;
|
||||||
|
|
||||||
|
// how deep inside a VEVENT's own blocks, like VALARM, a line is
|
||||||
|
let mut nested = 0;
|
||||||
|
|
||||||
|
for line in &lines {
|
||||||
|
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") => events.extend(draft.take().and_then(Draft::event)),
|
||||||
|
(_, 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),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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::<u32>().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<i64> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1539,4 +2159,191 @@ Sink #80
|
|||||||
assert_eq!(hypr_lua(HyprKind::Float, "3").unwrap(), "3.0");
|
assert_eq!(hypr_lua(HyprKind::Float, "3").unwrap(), "3.0");
|
||||||
assert_eq!(hypr_lua(HyprKind::Text, "say \"hi\"").unwrap(), "\"say \\\"hi\\\"\"");
|
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<Chunk> = 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#" <b>Ann & Bob</b> sent <a href="https://example.org/?a=1&b=2>3">a link</a><br/>3 < 4 > 2, it's "fine" 'ok'<br>— — &bogus; AT&T a < b<br /><img src="file:///tmp/x.png" alt="x"/><u>end</u> "#;
|
||||||
|
|
||||||
|
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("<i>x</i> <3"), "x <3");
|
||||||
|
assert_eq!(plain_body("<b>literal</b>"), "<b>literal</b>");
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROPFIND: &str = r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<multistatus xmlns="DAV:"><response><href>/123456789/calendars/</href><propstat><prop><displayname>Calendars</displayname><resourcetype><collection/></resourcetype></prop><status>HTTP/1.1 200 OK</status></propstat><propstat><prop><calendar-color xmlns="http://apple.com/ns/ical/"/></prop><status>HTTP/1.1 404 Not Found</status></propstat></response><response><href>/123456789/calendars/home/</href><propstat><prop><displayname>Home & Family</displayname><resourcetype><collection/><calendar xmlns="urn:ietf:params:xml:ns:caldav"/></resourcetype><calendar-color xmlns="http://apple.com/ns/ical/" symbolic-color="red">#FF2968FF</calendar-color><supported-calendar-component-set xmlns="urn:ietf:params:xml:ns:caldav"><comp name="VEVENT"/></supported-calendar-component-set></prop><status>HTTP/1.1 200 OK</status></propstat></response><response><href>/123456789/calendars/tasks/</href><propstat><prop><displayname>Reminders</displayname><resourcetype><collection/><calendar xmlns="urn:ietf:params:xml:ns:caldav"/></resourcetype><calendar-color xmlns="http://apple.com/ns/ical/">#1BADF8FF</calendar-color><supported-calendar-component-set xmlns="urn:ietf:params:xml:ns:caldav"><comp name="VTODO"/></supported-calendar-component-set></prop><status>HTTP/1.1 200 OK</status></propstat></response></multistatus>"##;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_caldav_xml() {
|
||||||
|
let mixed = r#"<D:response><D:href>/a/</D:href></D:response><response xmlns="DAV:"><href>/b/</href></response><d:href/><calendar-color>#fff</calendar-color>"#;
|
||||||
|
|
||||||
|
assert_eq!(xml_blocks(mixed, "response"), ["<D:href>/a/</D:href>", "<href>/b/</href>"]);
|
||||||
|
assert_eq!(xml_blocks(mixed, "href"), ["/a/", "/b/", ""]);
|
||||||
|
assert!(xml_blocks(mixed, "calendar").is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
xml_texts(r#"<d:displayname> <![CDATA[Work <stuff> & more]]> </d:displayname><displayname>Tom & Jerry's</displayname>"#, "displayname"),
|
||||||
|
["Work <stuff> & 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#"<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav"><d:response><d:href>/remote.php/dav/calendars/ann/work/</d:href><d:propstat><d:prop><d:displayname/><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#;
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+484
@@ -0,0 +1,484 @@
|
|||||||
|
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::{card as island_card, pill as island_pill};
|
||||||
|
use crate::config::notifications::history;
|
||||||
|
use crate::config::pills::{self as settings, layout};
|
||||||
|
use crate::edge::{self, Edge};
|
||||||
|
use crate::glyph::{Glyph, icon};
|
||||||
|
use crate::island::{self, CLEAR, mix, phases, veiled};
|
||||||
|
use crate::{ai, home, notices, stats, surface, theme};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* four small one-icon pills beside the island, each opening into a card the way the
|
||||||
|
* island does: left of it the AI chat and the system monitor, right of it home and
|
||||||
|
* notifications. one window per side and monitor (magic-pods-left|right) placed beside
|
||||||
|
* the island's pill; while the island opens they move out of its way. one card at a
|
||||||
|
* time across the shell: opening one collapses the island and the other way round
|
||||||
|
*/
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||||
|
pub enum Pod {
|
||||||
|
Ai,
|
||||||
|
Monitor,
|
||||||
|
Home,
|
||||||
|
Notifications,
|
||||||
|
}
|
||||||
|
|
||||||
|
// left of the island, outermost first; right of it, innermost first
|
||||||
|
const LEFT: [Pod; 2] = [Pod::Ai, Pod::Monitor];
|
||||||
|
const RIGHT: [Pod; 2] = [Pod::Home, Pod::Notifications];
|
||||||
|
|
||||||
|
impl Pod {
|
||||||
|
fn left(self) -> bool {
|
||||||
|
matches!(self, Self::Ai | Self::Monitor)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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::signing_in())
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Self> {
|
||||||
|
[Self::Ai, Self::Monitor, Self::Home, Self::Notifications].into_iter().find(|pod| pod.word() == word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
|
||||||
|
hover: HashMap<Pod, Animation>,
|
||||||
|
|
||||||
|
// 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(), 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open(pod: Pod, monitor: &str) {
|
||||||
|
island::collapse();
|
||||||
|
|
||||||
|
let mut pods = Pods::write();
|
||||||
|
|
||||||
|
// another card, or this one on another monitor: it grows from its own pill
|
||||||
|
if pods.card.as_ref().is_none_or(|(open, on)| *open != pod || on != monitor) {
|
||||||
|
pods.card = Some((pod, monitor.to_string()));
|
||||||
|
pods.timeline = timeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Pod> {
|
||||||
|
let pods = Pods::read();
|
||||||
|
|
||||||
|
pods.card.as_ref().filter(|_| pods.shown).map(|(pod, _)| *pod)
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn left(monitor: &Monitor) -> LayerWindow {
|
||||||
|
side(monitor, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn right(monitor: &Monitor) -> LayerWindow {
|
||||||
|
side(monitor, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn side(monitor: &Monitor, left: bool) -> LayerWindow {
|
||||||
|
theme::begin();
|
||||||
|
|
||||||
|
let here = monitor.name.as_str();
|
||||||
|
let pods = if left { LEFT } else { RIGHT };
|
||||||
|
let colors = theme::now().surfaces();
|
||||||
|
|
||||||
|
let state = Pods::read();
|
||||||
|
|
||||||
|
// this side's card on this monitor, while it's at all open
|
||||||
|
let card = state.card.as_ref().filter(|(pod, on)| pod.left() == left && on == here).map(|(pod, _)| (*pod, state.timeline.value())).filter(|(_, t)| *t > 0.0);
|
||||||
|
|
||||||
|
// with the bar on a fullscreen monitor, unless its card is open; out of the island's way while it opens
|
||||||
|
let slide = if card.is_some() { 0.0 } else { edge::hidden_by(Edge::read().shown(here), 0.0, edge::hide_h()) };
|
||||||
|
let shift = (island::width_on(here) - island_pill::width()) / 2.0;
|
||||||
|
|
||||||
|
let (h, top, shadow, spacing) = (island_pill::height(), island_pill::top_gap(), island_pill::shadow(), layout::spacing());
|
||||||
|
let count = pods.len() as f32;
|
||||||
|
let group_w = count * h + (count - 1.0) * spacing;
|
||||||
|
|
||||||
|
let (card_w, card_h) = card.map_or((0.0, 0.0), |(pod, _)| pod.size());
|
||||||
|
|
||||||
|
// room for the pills to move away from the open island, or for the card
|
||||||
|
let room = (island_card::width() - island_pill::width()) / 2.0;
|
||||||
|
let inner_w = (group_w + room).max(card_w);
|
||||||
|
let window_h = top + h.max(card_h) + shadow;
|
||||||
|
|
||||||
|
// the group's edge nearest the island, in the window, and where the window starts on the monitor
|
||||||
|
let near = if left { shadow + inner_w } else { shadow };
|
||||||
|
let middle = monitor.width as f32 / 2.0;
|
||||||
|
let beside = island_pill::width() / 2.0 + layout::gap();
|
||||||
|
let start = if left { middle - beside - near } else { middle + beside - near };
|
||||||
|
|
||||||
|
let window = LayerWindow::new()
|
||||||
|
.width(inner_w + 2.0 * shadow)
|
||||||
|
.height(window_h)
|
||||||
|
.anchor_vertical(Vertical::Top)
|
||||||
|
.anchor_horizontal(Horizontal::Left)
|
||||||
|
.margin(Margin { top: 0, right: 0, bottom: 0, left: start.round() as i32 })
|
||||||
|
.layer(Layer::Overlay)
|
||||||
|
.space(Zone::Ignore)
|
||||||
|
.namespace(if left { "magic-pods-left" } else { "magic-pods-right" });
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// a pill's left edge in the window; `k` counts from the island outwards
|
||||||
|
let pill_x = |pod: Pod| {
|
||||||
|
let at = pods.iter().position(|other| *other == pod).unwrap_or(0) as f32;
|
||||||
|
let k = if left { count - 1.0 - at } else { at };
|
||||||
|
|
||||||
|
if left { near - shift - (k + 1.0) * h - k * spacing } else { near + shift + k * (h + spacing) }
|
||||||
|
};
|
||||||
|
|
||||||
|
let (grow, pill_shown, reveal) = card.map_or((0.0, 1.0, 0.0), |(_, t)| phases(t));
|
||||||
|
|
||||||
|
let mut layers: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
let mut regions: Vec<InputArea> = Vec::new();
|
||||||
|
|
||||||
|
for pod in pods {
|
||||||
|
// the open one becomes the card; the others fade while it grows over them
|
||||||
|
if card.is_some_and(|(open, _)| open == pod) || grow >= 1.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let x = pill_x(pod);
|
||||||
|
let lit = state.hover.get(&pod).map_or(0.0, Animation::value);
|
||||||
|
let pill = pill(pod, lit, &colors, here);
|
||||||
|
let pill = if grow > 0.0 { pill.opacity(1.0 - grow) } 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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let typing = card.is_some_and(|(pod, _)| pod.types() && state.shown);
|
||||||
|
|
||||||
|
if let Some((pod, _)) = card {
|
||||||
|
// from the pill to the card, its edge nearest the island staying put
|
||||||
|
let from_x = pill_x(pod);
|
||||||
|
let to_x = if left { near - shift - card_w } else { near + shift };
|
||||||
|
|
||||||
|
let width = mix(h, card_w, grow);
|
||||||
|
let height = mix(h, card_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 face: Rectangle = if reveal > 0.0 {
|
||||||
|
veiled(content(pod, card_w, card_h), width, height, radius, reveal, &colors)
|
||||||
|
} else {
|
||||||
|
// the pill's icon fading as the shape grows
|
||||||
|
let tint = Color::blend(CLEAR, theme::now().text, 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(Start, Start).child(face);
|
||||||
|
let shape = surface::shell(width, height, radius, &colors, lit * pill_shown, face).on_hover(over_card).translate(x, top + slide);
|
||||||
|
|
||||||
|
regions.push(InputArea { x: x as i32, y: (top + slide) 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| {
|
||||||
|
if key == Key::Escape {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
window
|
||||||
|
};
|
||||||
|
|
||||||
|
window.child(Rectangle::new().width(Parent).height(Parent).child(Stack::new(layers).width(Parent).height(Parent)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// a small pill: the surface with one icon; the bell carries the unread count
|
||||||
|
fn pill(pod: Pod, lit: f32, colors: &[Color; 3], monitor: &str) -> Rectangle {
|
||||||
|
let h = island_pill::height();
|
||||||
|
let theme = theme::now();
|
||||||
|
|
||||||
|
let glyph = icon(pod.glyph(), layout::icon() * (1.0 + 0.08 * lit), Color::blend(theme.dim, theme.text, 0.6 + 0.4 * lit));
|
||||||
|
let mut inside: Vec<Box<dyn Widget>> = 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(theme.accent)
|
||||||
|
.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<Box<dyn Widget>>, width: f32) -> Rectangle {
|
||||||
|
let theme = theme::now();
|
||||||
|
|
||||||
|
let mut right: Vec<Box<dyn Widget>> = 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<dyn Widget>,
|
||||||
|
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<Glyph>, 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<Box<dyn Widget>> = 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} unread={}", pods.timeline.value(), notices::unread())
|
||||||
|
}
|
||||||
|
_ => String::from("usage: pods open|toggle <ai|monitor|home|notifications> [monitor] | close | ask <question> | notices list|dismiss <id>|clear | status"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a thin divider across a card
|
||||||
|
pub fn rule(width: f32) -> Rectangle {
|
||||||
|
Rectangle::new().width(width).height(1.0).fill(overlays::track())
|
||||||
|
}
|
||||||
|
|
||||||
+382
@@ -0,0 +1,382 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use amane::{Canvas, Color, Column, Path, Rectangle, Row, Service, Shape, SpaceBetween, Stack, Text, Weight, Widget};
|
||||||
|
|
||||||
|
use crate::config::appearance::text;
|
||||||
|
use crate::config::pills::{layout, monitor as settings};
|
||||||
|
use crate::glyph::Glyph;
|
||||||
|
use crate::island::faded;
|
||||||
|
use crate::parse;
|
||||||
|
use crate::system::Gpu;
|
||||||
|
use crate::{pods, theme};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the system monitor card: cpu, gpu, memory, network, disk reads and writes as graphs
|
||||||
|
* over the last readings, and the hottest temperature sensors. everything comes from
|
||||||
|
* /proc and /sys on this Service's thread; 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<f32>,
|
||||||
|
// the integrated gpu's clock as a share of its highest, and in words
|
||||||
|
gpu: Vec<f32>,
|
||||||
|
gpu_words: String,
|
||||||
|
ram: Vec<f32>,
|
||||||
|
ram_words: String,
|
||||||
|
// bytes a second
|
||||||
|
down: Vec<f32>,
|
||||||
|
up: Vec<f32>,
|
||||||
|
read: Vec<f32>,
|
||||||
|
write: Vec<f32>,
|
||||||
|
// (sensor, °C), hottest first
|
||||||
|
temps: Vec<(String, 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 (clock, clock_words) = gpu_clock();
|
||||||
|
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.ram, used as f32 / memory.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!("{:.1} of {:.1} GB", used as f32 / 1048576.0, memory.total_kb as f32 / 1048576.0);
|
||||||
|
stats.temps = temps;
|
||||||
|
}
|
||||||
|
|
||||||
|
before = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(history: &mut Vec<f32>, value: f32, keep: usize) {
|
||||||
|
history.push(value);
|
||||||
|
|
||||||
|
let extra = history.len().saturating_sub(keep);
|
||||||
|
|
||||||
|
history.drain(..extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(path: impl Into<PathBuf>) -> 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::<f32>().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"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// every hwmon device's hottest sensor, named for what it is, hottest first
|
||||||
|
fn temperatures() -> Vec<(String, f32)> {
|
||||||
|
let mut found: Vec<(String, f32)> = Vec::new();
|
||||||
|
|
||||||
|
for entry in fs::read_dir("/sys/class/hwmon").into_iter().flatten().flatten() {
|
||||||
|
let device = entry.path();
|
||||||
|
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::<f32>().ok())
|
||||||
|
.map(|milli| milli / 1000.0)
|
||||||
|
.filter(|celsius| *celsius > 0.0 && *celsius < 150.0)
|
||||||
|
.fold(None, |hottest: Option<f32>, celsius| Some(hottest.map_or(celsius, |hot| hot.max(celsius))));
|
||||||
|
|
||||||
|
let Some(celsius) = hottest else { continue };
|
||||||
|
|
||||||
|
let name = match kind.as_str() {
|
||||||
|
"coretemp" | "k10temp" | "zenpower" => "CPU",
|
||||||
|
"nvme" => "NVMe",
|
||||||
|
"amdgpu" | "nouveau" => "eGPU",
|
||||||
|
"xe" | "i915" => "GPU",
|
||||||
|
"acpitz" => "Board",
|
||||||
|
"thinkpad" => "ThinkPad",
|
||||||
|
"spd5118" | "jc42" => "Memory",
|
||||||
|
other if other.starts_with("iwlwifi") => "Wi-Fi",
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
|
||||||
|
found.push((name.to_string(), celsius));
|
||||||
|
}
|
||||||
|
|
||||||
|
found.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||||
|
found.truncate(settings::temperatures());
|
||||||
|
|
||||||
|
// a kind shown more than once is numbered, in the order shown
|
||||||
|
let names: Vec<String> = 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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() + 8.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_rows() -> usize {
|
||||||
|
settings::temperatures().div_ceil(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the card's height, from settings only, so the window's size doesn't follow the readings
|
||||||
|
pub fn height() -> f32 {
|
||||||
|
let graphs = 5.0 * (label_height() + settings::graph_height()) + 4.0 * 10.0;
|
||||||
|
|
||||||
|
// header, graphs, rule, the temperatures' label and rows, with the column's four gaps
|
||||||
|
2.0 * layout::pad() + pods::header_height() + graphs + 1.0 + label_height() + temp_rows() as f32 * temp_row_height() + 4.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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Box<dyn Shape>> {
|
||||||
|
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))]
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
let egpu = if gpu.name.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
let celsius = gpu.celsius.map_or(String::new(), |celsius| format!(" · {celsius} °C"));
|
||||||
|
|
||||||
|
format!(" · {} {}%{celsius}", gpu.name, gpu.busy)
|
||||||
|
};
|
||||||
|
|
||||||
|
// (name, value in words, graphs as (history, top, colour))
|
||||||
|
let blocks: Vec<(&str, String, Vec<Series>)> = vec![
|
||||||
|
("CPU", format!("{:.0} %", last(&stats.cpu) * 100.0), vec![(&stats.cpu, 1.0, theme.accent)]),
|
||||||
|
("GPU", format!("{}{egpu}", stats.gpu_words), vec![(&stats.gpu, 1.0, theme.accent)]),
|
||||||
|
("Memory", stats.ram_words.clone(), vec![(&stats.ram, 1.0, theme.accent)]),
|
||||||
|
("Network", format!("↓ {} ↑ {}", rate(last(&stats.down)), rate(last(&stats.up))), vec![(&stats.down, net_top, theme.accent), (&stats.up, net_top, theme.flame)]),
|
||||||
|
("Disk", format!("read {} write {}", rate(last(&stats.read)), rate(last(&stats.write))), vec![(&stats.read, disk_top, theme.accent), (&stats.write, 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<Box<dyn Shape>> = Vec::new();
|
||||||
|
let mut rows: Vec<Box<dyn Widget>> = 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<dyn Widget>,
|
||||||
|
Box::new(Text::new(words).size(text::small()).color(theme.dim)),
|
||||||
|
])
|
||||||
|
.width(width)
|
||||||
|
.justify(SpaceBetween),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let graphs_h = 5.0 * (label_h + graph_h) + 4.0 * 10.0;
|
||||||
|
|
||||||
|
let graphs = Stack::new(vec![Box::new(Canvas::new().width(width).height(graphs_h).shapes(shapes)) as Box<dyn Widget>, Box::new(Column::new(rows).gap(10.0))]).width(width).height(graphs_h);
|
||||||
|
|
||||||
|
// the temperatures in two columns
|
||||||
|
let mut temp_rows: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
|
for pair in stats.temps.chunks(2) {
|
||||||
|
let cell = |(name, celsius): &(String, f32)| -> Box<dyn Widget> {
|
||||||
|
let hot = if *celsius >= 85.0 { crate::config::appearance::overlays::low_battery() } else { theme.text };
|
||||||
|
|
||||||
|
Box::new(
|
||||||
|
Rectangle::new().width(width / 2.0 - 8.0).height(temp_row_height()).child(
|
||||||
|
Row::new(vec![Box::new(Text::new(name.as_str()).size(text::small()).color(theme.dim)) as Box<dyn Widget>, Box::new(Text::new(format!("{celsius:.0} °C")).size(text::small()).color(hot))])
|
||||||
|
.width(width / 2.0 - 8.0)
|
||||||
|
.justify(SpaceBetween),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
temp_rows.push(Box::new(Row::new(pair.iter().map(cell).collect()).gap(16.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats.temps.is_empty() {
|
||||||
|
temp_rows.push(Box::new(Text::new(if stats.cpu.is_empty() { "Reading…" } else { "No temperature sensors" }).size(text::small()).color(theme.dim)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle::new().width(width).height(height).child(
|
||||||
|
Column::new(vec![
|
||||||
|
Box::new(header) as Box<dyn Widget>,
|
||||||
|
Box::new(graphs),
|
||||||
|
Box::new(pods::rule(width)),
|
||||||
|
Box::new(Rectangle::new().width(width).height(label_h).child(Text::new("Temperatures").size(text::small()).weight(Weight::Bold).color(theme.text))),
|
||||||
|
Box::new(Column::new(temp_rows)),
|
||||||
|
])
|
||||||
|
.gap(10.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
-1
@@ -306,7 +306,7 @@ fn forget(name: &str) {
|
|||||||
* (gtk's choice, hicolor, Adwaita), then /usr/share/pixmaps. themes are walked once
|
* (gtk's choice, hicolor, Adwaita), then /usr/share/pixmaps. themes are walked once
|
||||||
* per name, the answer kept
|
* per name, the answer kept
|
||||||
*/
|
*/
|
||||||
fn icon_file(name: &str, theme_path: &str) -> Option<PathBuf> {
|
pub fn icon_file(name: &str, theme_path: &str) -> Option<PathBuf> {
|
||||||
static FOUND: Mutex<Option<HashMap<String, Option<PathBuf>>>> = Mutex::new(None);
|
static FOUND: Mutex<Option<HashMap<String, Option<PathBuf>>>> = Mutex::new(None);
|
||||||
|
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
|
|||||||
Reference in New Issue
Block a user