Skip to main content

max / alloy

Let the bar say whether the machine is charging The status line was `while date; do sleep 20; done` on a distro whose one validated machine is a laptop, so the only way to answer "am I charging" was to read /sys/class/power_supply by hand. `alloy status --bar` replaces it: battery with charge state, the connected network, the default output's volume, and the same clock. A console verb rather than a script in usr/bin/ because `alloy audio` and `alloy net` already parse pactl and nmcli into types, and a script would have been a second parse of the same two contracts. Volume comes off a `pactl subscribe` event rather than a poll, so the Fn keys move the bar immediately without spending two spawns a second all day to learn nothing. Battery is read from sysfs, which is what upower reads and costs no subprocess. Colors resolve through makeover to hex, since swaybar's protocol takes nothing else, and re-resolve every ten seconds so the bar follows a day/night switch. The verb runs before the theme load: a theme directory that will not load leaves the blocks uncolored rather than taking the bar down with it.
Author: Max Johnson <me@maxj.phd> · 2026-07-30 12:47 UTC
Signed with PGP, not checked
Commit: a95a198bed154cdcc9ca1faeff2d89425f152300
Parent: ccf4663
7 files changed, +607 insertions, -6 deletions
M docs/CONSOLE.md +15 -1
@@ -26,12 +26,26 @@
26 26 alloy setup # the first-boot offer: mesh and sync [shipped]
27 27 alloy settings # system settings and app configs: two tabs over one form
28 28 alloy config <path> # one config file, opened directly, without the tab chrome
29 + alloy status --bar # the swaybar status line, in JSON, not a view [shipped]
29 30 alloy theme apply # put the chosen day/night skeleton in place [shipped]
30 31 alloy theme <name> # swap the runtime theme; reads makeover's themes/*.toml
31 32 # or ~/.config/alloy/themes/*.toml via makeover
32 33 ```
33 34
34 - `alloy theme apply` is the one verb here that draws nothing. It reads
35 + `alloy status --bar` is the other verb that draws nothing, and it is the only
36 + one whose output is read by another program rather than by a person. sway's
37 + `bar { status_command }` runs it and reads swaybar's JSON protocol off its
38 + stdout: battery, network, volume, clock, one block each, colored from the theme's
39 + intent tokens because that protocol takes hex and nothing else. It reuses the
40 + `audio` and `net` backends rather than shelling out a second time, which is the
41 + whole argument for it being a console verb instead of a script in `usr/bin/`
42 + alongside `alloy-shot` and `alloy-menu`. The `--bar` flag is required: a bare
43 + `alloy status` should not fill a terminal with JSON because someone was guessing
44 + at verbs, and it leaves room for a human-readable one-shot later. Both it and
45 + `theme apply` are handled before the theme load in `main`, since neither needs a
46 + ratatui palette and the bar must survive a theme directory that will not load.
47 +
48 + `alloy theme apply` is the one view-less verb that also writes. It reads
35 49 `~/.config/alloy/mode`, which the settings theme row writes, and copies the
36 50 matching render of every themed config out of the image: `/etc/skel` for `day`,
37 51 `/usr/share/alloy/skel-night` for `night`. `usr/bin/alloy-session` runs it before
M docs/STACK.md +7 -1
@@ -26,7 +26,13 @@
26 26
27 27 ## Bar
28 28
29 - **swaybar** (sway's built-in bar). Configured in the sway config's `bar {}` block with a `status_command`; no extra package or daemon. The v0 scaffold ships a minimal clock placeholder; a real status line (or the future `alloy` console status view) is a shaping task, not a toolkit decision.
29 + **swaybar** (sway's built-in bar). Configured in the sway config's `bar {}` block with a `status_command`; no extra package or daemon.
30 +
31 + The status line is **`alloy status --bar`**, a console verb rather than a script. It emits swaybar's JSON protocol and shows four blocks: battery percentage and charge state, the connected network, the default output's volume, and the clock. Until 2026-07-30 the line was a clock placeholder, which on a laptop-first distro left no way to answer "am I charging" short of reading `/sys/class/power_supply`.
32 +
33 + A verb and not a shell script because the readers already existed: `alloy audio` fronts `pactl -f json` and `alloy net` fronts `nmcli`, both parsed into types, so a script would have been a second parse of the same two contracts. Battery is read from sysfs rather than through upower, which is in the image and would have worked; the kernel files are what upower itself reads and cost no subprocess on a loop that runs all day. Volume updates on a `pactl subscribe` event rather than on a poll, so the Fn keys move the bar immediately without spending a spawn a second to learn nothing.
34 +
35 + Colors come through the theme's intent tokens, resolved to hex because swaybar's protocol takes nothing else, and are re-read every ten seconds so the bar follows a day/night switch instead of holding the palette it launched in. A theme that will not load leaves the blocks uncolored rather than taking the bar down.
30 36
31 37 Rejected: Ironbar (the prior pick, dropped with the GTK stack in the pivot), waybar (C++, and swaybar already covers the need), eww (Lisp/yuck config clashes with the stack), yambar (YAML, ruled out). Re-adopt a standalone bar only if swaybar's status protocol proves too limiting.
32 38
@@ -202,6 +202,20 @@
202 202 fn list_devices(&self, log: &mut CommandLog) -> Result<Vec<Device>>;
203 203 fn list_streams(&self, log: &mut CommandLog) -> Result<Vec<Stream>>;
204 204
205 + /// The default output device on its own.
206 + ///
207 + /// For a caller that wants one number rather than the table: `alloy status
208 + /// --bar` shows the volume of whatever is playing and nothing else, and
209 + /// [`list_devices`](Self::list_devices) spends four `pactl` spawns to build
210 + /// a table it would throw away. A backend that can answer more cheaply
211 + /// overrides this; one that cannot pays the same as before.
212 + fn default_output(&self, log: &mut CommandLog) -> Result<Option<Device>> {
213 + Ok(self
214 + .list_devices(log)?
215 + .into_iter()
216 + .find(|device| device.is_default && device.direction == Direction::Output))
217 + }
218 +
205 219 /// Set a target's volume, as a percentage of unity.
206 220 fn set_volume(&self, target: Target<'_>, percent: u8, log: &mut CommandLog) -> Result<()>;
207 221
@@ -252,6 +266,22 @@
252 266 Ok(devices)
253 267 }
254 268
269 + /// Two spawns rather than the four [`Backend::list_devices`] makes: the
270 + /// sources and the default source say nothing about an output device, and
271 + /// the bar asks this question on every volume event.
272 + fn default_output(&self, log: &mut CommandLog) -> Result<Option<Device>> {
273 + let sinks = Invocation::new("pactl")
274 + .args(["-f", "json", "list", "sinks"])
275 + .run(log)?;
276 + let default_sink = Invocation::new("pactl").arg("get-default-sink").run(log)?;
277 + Ok(
278 + parse_devices(&sinks, Direction::Output, default_sink.trim())
279 + .context("parsing sinks")?
280 + .into_iter()
281 + .find(|device| device.is_default),
282 + )
283 + }
284 +
255 285 fn list_streams(&self, log: &mut CommandLog) -> Result<Vec<Stream>> {
256 286 let playback = Invocation::new("pactl")
257 287 .args(["-f", "json", "list", "sink-inputs"])
@@ -19,6 +19,7 @@
19 19 mod settings;
20 20 mod setup;
21 21 mod shell;
22 + mod status;
22 23 mod store;
23 24 mod sync;
24 25 mod system;
@@ -88,6 +89,18 @@
88 89 #[arg(long)]
89 90 if_first_boot: bool,
90 91 },
92 + /// The status line: battery, network, volume and the clock
93 + ///
94 + /// Not a view. This is what sway's `bar { status_command }` runs, and it
95 + /// writes swaybar's JSON protocol to stdout until the bar goes away.
96 + Status {
97 + /// Emit swaybar's JSON protocol. The only mode there is, and required
98 + /// rather than implied: a bare `alloy status` should not fill a
99 + /// terminal with JSON forever because someone was guessing at verbs,
100 + /// and the flag leaves room for a human-readable one-shot later.
101 + #[arg(long)]
102 + bar: bool,
103 + },
91 104 /// The day/night skeleton for this session
92 105 Theme {
93 106 #[command(subcommand)]
@@ -171,6 +184,18 @@
171 184 return Ok(());
172 185 }
173 186
187 + // Also before the theme load, and for a sharper version of the same reason.
188 + // `theme::load` is a hard error when nothing is on the search path; the bar
189 + // runs from sway's config on every login and degrades to uncolored blocks
190 + // rather than to no bar at all, so a damaged theme directory must not be
191 + // able to take the status line down with it.
192 + if let Command::Status { bar } = cli.command {
193 + if !bar {
194 + anyhow::bail!("`alloy status` needs --bar: this verb emits swaybar's protocol");
195 + }
196 + return status::run_bar();
197 + }
198 +
174 199 let theme = theme::load(cli.theme.as_deref())?;
175 200 let mut log = CommandLog::new();
176 201
@@ -247,5 +272,7 @@
247 272 Command::Theme { action } => match action {
248 273 ThemeAction::Apply { .. } => Ok(()),
249 274 },
275 + // Likewise handled above, for the same reason.
276 + Command::Status { .. } => Ok(()),
250 277 }
251 278 }
@@ -31,7 +31,7 @@
31 31 /// and a user's own `akari-dawn` was silently ignored in favour of the packaged
32 32 /// one; on a dev box makeover's bundled copies beat both. Naming the tiers means
33 33 /// the order is no longer this file's to get backwards.
34 - fn search_path() -> Vec<(PathBuf, bool)> {
34 + pub(crate) fn search_path() -> Vec<(PathBuf, bool)> {
35 35 ThemeDirs::new()
36 36 .bundled(makeover::bundled_themes_dir())
37 37 .system(Some(PathBuf::from("/usr/share/alloy/themes")))
@@ -175,6 +175,21 @@
175 175 })
176 176 }
177 177
178 + /// The same theme [`load`] resolves, as concrete hex rather than as a ratatui
179 + /// palette.
180 + ///
181 + /// For the one console surface that is not a ratatui view. `alloy status --bar`
182 + /// hands its colors to swaybar, whose protocol takes `#rrggbb` and nothing
183 + /// else, so it needs the intent layer before [`Theme`] quantizes it down to
184 + /// what a terminal can draw. Resolved through the same search path and the same
185 + /// id, so the bar and the console are never in different palettes.
186 + pub(crate) fn semantic(id: Option<&str>) -> Result<makeover::SemanticTokens> {
187 + let id = id.map_or_else(current_id, str::to_string);
188 + makeover::load_semantic(&search_path(), &id)
189 + .map_err(anyhow::Error::msg)
190 + .with_context(|| format!("resolving theme `{id}` to hex"))
191 + }
192 +
178 193 /// Load a theme by id, or the console's current one when `id` is `None`.
179 194 ///
180 195 /// `--theme` wins, then what the user chose, then the guess from the terminal
@@ -286,13 +286,22 @@
286 286 bindsym --release Caps_Lock exec swayosd-client --caps-lock
287 287
288 288 # -------------------------------------------------------------------
289 - # Bar — PLACEHOLDER. Minimal swaybar with a clock; replace with a real
290 - # status_command, waybar, or the future alloy console status view.
289 + # Bar — swaybar, with `alloy status --bar` as the status line.
291 290 # -------------------------------------------------------------------
291 + # This was `while date; do sleep 20; done` until 2026-07-30: a clock and
292 + # nothing else, on a distro whose one validated machine is a laptop. Asking
293 + # whether the machine was charging meant reading /sys by hand.
294 + #
295 + # The status line is a console verb rather than a script in usr/bin/ because
296 + # `alloy audio` and `alloy net` already parse pactl and nmcli into types, and a
297 + # script would have been a second parse of the same two contracts. It emits
298 + # swaybar's JSON protocol, so each block carries its own color from the theme;
299 + # the `colors` block below still sets the bar's own surfaces and the workspace
300 + # buttons, which are swaybar's to draw and not the status line's.
292 301 bar {
293 302 position top
294 303 font pango:IosevkaTerm Nerd Font 10
295 - status_command while date +'%Y-%m-%d %H:%M'; do sleep 20; done
304 + status_command alloy status --bar
296 305 colors {
297 306 background @{surface.page}
298 307 statusline @{content.primary}
@@ -1,0 +1,715 @@
1 + //! `alloy status --bar` — the swaybar status line.
2 + //!
3 + //! The one verb in the console that draws nothing. swaybar runs a
4 + //! `status_command` and reads its stdout; everything else here is a ratatui
5 + //! view, and this emits swaybar's JSON protocol instead. It is still a console
6 + //! verb rather than a shell script in `usr/bin/` because the readers it needs
7 + //! already exist: [`crate::audio`] fronts `pactl -f json` and [`crate::net`]
8 + //! fronts `nmcli`, both already parsed into types, and a script would have been
9 + //! a second parse of the same two contracts.
10 + //!
11 + //! What the bar shows, and why those four. The machine Alloy is validated on is
12 + //! a laptop, and until this verb the bar was a clock, so the only way to learn
13 + //! whether the machine was charging was to read
14 + //! `/sys/class/power_supply/BAT1/status` by hand. Battery is the reason this
15 + //! exists; network and volume are here because the same tick and the same
16 + //! readers cover them for nearly nothing.
17 + //!
18 + //! Colors come from the theme, resolved through `makeover` to concrete hex,
19 + //! because swaybar's protocol takes `#rrggbb` and nothing else. docs/TOKENS.md's
20 + //! rule that no hex is hard-coded in Rust holds: the hex is looked up, never
21 + //! written here.
22 + //!
23 + //! <!-- wiki: alloy-console -->
24 +
25 + use std::fmt::Write as _;
26 + use std::io::{BufRead, BufReader, Write};
27 + use std::process::{Command, Stdio};
28 + use std::sync::Arc;
29 + use std::sync::atomic::{AtomicBool, Ordering};
30 + use std::time::Duration;
31 +
32 + use anyhow::Result;
33 + use makeover::SemanticTokens;
34 +
35 + use crate::audio::{self, Device};
36 + use crate::cli::CommandLog;
37 + use crate::net::{self, Interface, Kind, State};
38 + use crate::theme;
39 +
40 + /// How long the loop sleeps between updates.
41 + ///
42 + /// The same second [`crate::shell::TICK`] uses, for the same reason: fast
43 + /// enough that the clock is never visibly wrong, slow enough that a tick which
44 + /// polls nothing costs nothing.
45 + const TICK: Duration = Duration::from_secs(1);
46 +
47 + /// Ticks between `nmcli` reads. An interface coming up or a network changing is
48 + /// a human-scale event, and five seconds of staleness on it is not noticeable;
49 + /// one spawn a second forever would be.
50 + const NET_POLL_TICKS: u64 = 5;
51 +
52 + /// Ticks between power reads. Cheaper than the others — sysfs, no subprocess —
53 + /// but a battery percentage that moves faster than this would be lying about
54 + /// its own resolution.
55 + const POWER_POLL_TICKS: u64 = 5;
56 +
57 + /// Ticks between re-resolving the theme.
58 + ///
59 + /// The bar outlives a day/night switch, which every other console verb does not:
60 + /// they are opened, used and closed inside one mode. Re-reading means the bar
61 + /// follows `alloy theme apply` rather than staying in the palette it was
62 + /// launched in until the session ends.
63 + const THEME_POLL_TICKS: u64 = 10;
64 +
65 + /// Ticks between `pactl` reads when `pactl subscribe` is not available.
66 + ///
67 + /// The fallback cadence, not the normal one. With the subscription running,
68 + /// volume is read on the event and this interval is a safety net for a
69 + /// subscription that died quietly.
70 + const AUDIO_POLL_TICKS: u64 = 10;
71 +
72 + /// Where the kernel publishes battery and adapter state.
73 + ///
74 + /// Read directly rather than through `upower`, which is in the image and would
75 + /// have worked. The kernel files are what upower itself reads, they need no
76 + /// subprocess on a loop that runs all day, and they are readable on a machine
77 + /// where the daemon has not come up yet.
78 + const POWER_SUPPLY: &str = "/sys/class/power_supply";
79 +
80 + /// Run the bar until stdout closes.
81 + ///
82 + /// Returns `Ok(())` when swaybar goes away, which is how this verb ends: sway
83 + /// restarting, the session exiting, or the bar being reconfigured. A write
84 + /// error on stdout is that, not a failure worth an error message nobody is
85 + /// positioned to read.
86 + pub(crate) fn run_bar() -> Result<()> {
87 + let mut log = CommandLog::new();
88 + let audio_backend = audio::detect();
89 + let net_backend = net::detect();
90 + let audio_changed = subscribe_audio();
91 +
92 + let mut stdout = std::io::stdout().lock();
93 +
94 + // The protocol's preamble: a header object, then an array that is never
95 + // closed. Every update after this is one element of it.
96 + writeln!(stdout, "{{\"version\":1}}")?;
97 + writeln!(stdout, "[")?;
98 +
99 + let mut tokens = theme::semantic(None).ok();
100 + let mut power = read_power();
101 + let mut network = read_network(net_backend.as_ref(), &mut log);
102 + let mut volume = read_volume(audio_backend.as_ref(), &mut log);
103 +
104 + for tick in 0u64.. {
105 + if tick > 0 {
106 + if tick % THEME_POLL_TICKS == 0 {
107 + // Kept only when it loads. A theme deleted out from under a
108 + // running bar leaves the last good palette in place, which is
109 + // better than a bar that goes monochrome mid-session.
110 + if let Ok(fresh) = theme::semantic(None) {
111 + tokens = Some(fresh);
112 + }
113 + }
114 + if tick % POWER_POLL_TICKS == 0 {
115 + power = read_power();
116 + }
117 + if tick % NET_POLL_TICKS == 0 {
118 + network = read_network(net_backend.as_ref(), &mut log);
119 + }
120 + let audio_due = audio_changed
121 + .as_ref()
122 + .is_some_and(|flag| flag.swap(false, Ordering::Relaxed))
123 + || tick % AUDIO_POLL_TICKS == 0;
124 + if audio_due {
125 + volume = read_volume(audio_backend.as_ref(), &mut log);
126 + }
127 + }
128 +
129 + let line = render(
130 + tokens.as_ref(),
131 + power.as_ref(),
132 + network.as_ref(),
133 + volume.as_ref(),
134 + );
135 + if writeln!(stdout, "{line},").is_err() || stdout.flush().is_err() {
136 + return Ok(());
137 + }
138 +
139 + std::thread::sleep(TICK);
140 + }
141 +
142 + Ok(())
143 + }
144 +
145 + /// Watch `pactl subscribe` for sink events, so volume updates on the keypress.
146 + ///
147 + /// The alternative was reading `pactl` every second, which is what the bar
148 + /// would have to do to keep up with the Fn keys, and which spends two spawns a
149 + /// second all day to learn nothing almost every time. A subscription is one
150 + /// long-lived process that says something only when there is something to say.
151 + ///
152 + /// Returns `None` when `pactl subscribe` will not start, which is the mock
153 + /// backend's case and also a PipeWire that has not come up; the caller falls
154 + /// back to [`AUDIO_POLL_TICKS`]. The thread is deliberately never joined: it
155 + /// ends when the child's stdout closes, which is when the process this belongs
156 + /// to is ending anyway.
157 + fn subscribe_audio() -> Option<Arc<AtomicBool>> {
158 + let mut child = Command::new("pactl")
159 + .arg("subscribe")
160 + .stdin(Stdio::null())
161 + .stdout(Stdio::piped())
162 + .stderr(Stdio::null())
163 + .spawn()
164 + .ok()?;
165 + let stdout = child.stdout.take()?;
166 +
167 + let changed = Arc::new(AtomicBool::new(false));
168 + let flag = Arc::clone(&changed);
169 + std::thread::spawn(move || {
170 + for line in BufReader::new(stdout).lines().map_while(Result::ok) {
171 + // `pactl subscribe` narrates every object PipeWire touches: cards,
172 + // clients, streams, modules. Only sink events can change what this
173 + // bar shows, and matching on the noun keeps a busy playback session
174 + // from re-reading `pactl` on every buffer.
175 + if line.contains("on sink ") || line.contains("on server ") {
176 + flag.store(true, Ordering::Relaxed);
177 + }
178 + }
179 + // The child is reaped here rather than leaked: `pactl subscribe` exits
180 + // when PipeWire restarts, and a bar that runs for a week would
181 + // otherwise collect a zombie for each restart.
182 + let _ = child.wait();
183 + });
184 +
185 + Some(changed)
186 + }
187 +
188 + /// A battery reading, as the bar states it.
189 + struct Power {
190 + percent: u8,
191 + charging: bool,
192 + /// Nothing is discharging and the battery is not below full: the machine is
193 + /// on mains and topped up. Distinguished from `charging` because "100% AC"
194 + /// and "94% charging" are different sentences.
195 + full: bool,
196 + }
197 +
198 + /// Read the first battery the kernel lists, and whether it is charging.
199 + ///
200 + /// The first, not a sum across all of them. Both validated machines have one
201 + /// battery; a machine with two would read low here, which is the safe direction
202 + /// to be wrong in and is worth revisiting on the first machine that has two.
203 + ///
204 + /// `None` on a desktop, which is not an error: the bar drops the block rather
205 + /// than showing a battery that does not exist. That is also what makes this
206 + /// verb usable on the server profile.
207 + fn read_power() -> Option<Power> {
208 + let mut entries: Vec<_> = std::fs::read_dir(POWER_SUPPLY)
209 + .ok()?
210 + .filter_map(Result::ok)
211 + .map(|entry| entry.path())
212 + .collect();
213 + // Directory order is not sorted, so BAT0 and BAT1 could arrive either way.
214 + // Sorting makes "the first battery" mean the same thing on every boot.
215 + entries.sort();
216 +
217 + for path in entries {
218 + let kind = std::fs::read_to_string(path.join("type")).ok()?;
219 + if kind.trim() != "Battery" {
220 + continue;
221 + }
222 + let percent = std::fs::read_to_string(path.join("capacity"))
223 + .ok()
224 + .and_then(|raw| raw.trim().parse::<u8>().ok())?;
225 + // The kernel's own vocabulary, kept verbatim rather than mapped to a
226 + // bool: "Not charging" is a real state (a plugged-in machine holding a
227 + // charge limit) and it is neither charging nor discharging.
228 + let status = std::fs::read_to_string(path.join("status")).unwrap_or_default();
229 + let status = status.trim();
230 + return Some(Power {
231 + percent,
232 + charging: status == "Charging",
233 + full: status == "Full" || status == "Not charging",
234 + });
235 + }
236 + None
237 + }
238 +
239 + /// The interface the bar names: the connected one, wireless first.
240 + ///
241 + /// Wireless first because a laptop that is on both is on wired for a reason the
242 + /// user already knows about, and it is the wireless network whose name they
243 + /// cannot otherwise see. Loopback is never a candidate — it is always up and
244 + /// says nothing.
245 + fn read_network(backend: &dyn net::Backend, log: &mut CommandLog) -> Option<Interface> {
246 + let interfaces = log.quiet(|log| backend.list(log)).ok()?;
247 + let candidates = interfaces
248 + .into_iter()
249 + .filter(|iface| iface.kind != Kind::Loopback && iface.state == State::Connected);
250 + let mut best: Option<Interface> = None;
251 + for iface in candidates {
252 + let wins = best
253 + .as_ref()
254 + .is_none_or(|held| held.kind != Kind::Wireless && iface.kind == Kind::Wireless);
255 + if wins {
256 + best = Some(iface);
257 + }
258 + }
259 + best
260 + }
261 +
262 + /// The default output device's volume and mute state.
263 + ///
264 + /// `None` covers both "no PipeWire" and "no default sink", which the bar treats
265 + /// alike: it drops the block. A machine with no audio should not carry a block
266 + /// reading `--`.
267 + fn read_volume(backend: &dyn audio::Backend, log: &mut CommandLog) -> Option<Device> {
268 + log.quiet(|log| backend.default_output(log)).ok().flatten()
269 + }
270 +
271 + /// One update: the JSON array swaybar reads as a full redraw of the line.
272 + ///
273 + /// Right to left in bar order, which is the reverse of urgency: the clock is
274 + /// the thing the eye goes to by habit and sits at the end, and battery, the
275 + /// block this verb was written for, sits furthest from it.
276 + fn render(
277 + tokens: Option<&SemanticTokens>,
278 + power: Option<&Power>,
279 + network: Option<&Interface>,
280 + volume: Option<&Device>,
281 + ) -> String {
282 + let mut blocks: Vec<String> = Vec::new();
283 +
284 + if let Some(power) = power {
285 + let text = if power.charging {
286 + format!("{}% charging", power.percent)
287 + } else if power.full {
288 + format!("{}% AC", power.percent)
289 + } else {
290 + format!("{}%", power.percent)
291 + };
292 + // The one place the bar uses a status color on its own text rather than
293 + // on a marker beside it. docs/TOKENS.md's accent-on-glyph rule reserves
294 + // `warning` and `danger` for glyphs and edge markers because Akari's
295 + // amber only clears AA-UI on body text; a battery percentage is read as
296 + // an indicator rather than as copy, which is the case that rule carves
297 + // out. Worth revisiting if the bar ever grows a real glyph to hang it on.
298 + let color = match () {
299 + () if power.charging || power.full => "content",
300 + () if power.percent <= 10 => "danger",
301 + () if power.percent <= 20 => "warning",
302 + () => "content",
303 + };
304 + blocks.push(block("battery", &text, color, tokens));
305 + }
306 +
307 + if let Some(iface) = network {
308 + // The connection name, which for wifi is the SSID and for wired is
309 + // whatever NetworkManager called the profile. The device name is the
310 + // fallback rather than the first choice: `wlp166s0` identifies the card
311 + // and answers no question anyone asks a bar.
312 + let text = iface.connection.as_deref().unwrap_or(&iface.name);
313 + blocks.push(block("network", text, "content", tokens));
314 + }
315 +
316 + if let Some(device) = volume {
317 + let (text, color) = if device.muted {
318 + (format!("vol {}% muted", device.volume), "content-muted")
319 + } else {
320 + (format!("vol {}%", device.volume), "content")
321 + };
322 + blocks.push(block("volume", &text, color, tokens));
323 + }
324 +
325 + blocks.push(block("clock", &clock(), "content", tokens));
326 +
327 + format!("[{}]", blocks.join(","))
328 + }
329 +
330 + /// The date and time, formatted the way the placeholder did.
331 + ///
332 + /// `date +'%Y-%m-%d %H:%M'` is what the bar said before this verb, and the
333 + /// format is kept rather than improved: the bar changing shape underneath
334 + /// someone is the kind of churn a daily driver does not need, and ISO order is
335 + /// already the right answer.
336 + ///
337 + /// Formatted by hand from the system clock. `chrono` and `time` are both real
338 + /// crates and neither is a dependency of this one; a `date` spawn every second
339 + /// is exactly the cost this module avoids everywhere else.
340 + fn clock() -> String {
341 + let now = std::time::SystemTime::now()
342 + .duration_since(std::time::UNIX_EPOCH)
343 + .map_or(0, |since| since.as_secs()) as i64;
344 + let (year, month, day, hour, minute) = civil_from_unix(now + local_offset());
345 + format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
346 + }
347 +
348 + /// Seconds since the epoch to a civil date and time, in whatever zone the
349 + /// caller already shifted them into.
350 + ///
351 + /// Takes shifted seconds rather than doing the shifting, so the calendar math
352 + /// is testable against fixed instants without a test depending on the machine's
353 + /// `TZ`. [`clock`] adds [`local_offset`] on the way in.
354 + ///
355 + /// The date algorithm is Howard Hinnant's `civil_from_days`, which is the
356 + /// standard one and is where the constants come from: 719_468 shifts the epoch
357 + /// to 0000-03-01 so leap days land at the end of the year and the month
358 + /// arithmetic needs no special case for February.
359 + fn civil_from_unix(seconds: i64) -> (i64, u32, u32, u32, u32) {
360 + let days = seconds.div_euclid(86_400);
361 + let secs = seconds.rem_euclid(86_400);
362 +
363 + let z = days + 719_468;
364 + let era = z.div_euclid(146_097);
365 + let doe = z.rem_euclid(146_097);
366 + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
367 + let y = yoe + era * 400;
368 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
369 + let mp = (5 * doy + 2) / 153;
370 + let d = doy - (153 * mp + 2) / 5 + 1;
371 + let m = if mp < 10 { mp + 3 } else { mp - 9 };
372 + let y = if m <= 2 { y + 1 } else { y };
373 +
374 + (
375 + y,
376 + m as u32,
377 + d as u32,
378 + (secs / 3600) as u32,
379 + (secs / 60 % 60) as u32,
380 + )
381 + }
382 +
383 + /// Seconds east of UTC for this machine, right now.
384 + ///
385 + /// Read from `/etc/localtime`'s TZif data rather than from libc, which this
386 + /// crate does not link, and cached for the life of the process would be wrong:
387 + /// a bar that runs across a DST boundary has to notice. So it is recomputed on
388 + /// each call, which is once a second against a file the page cache has held
389 + /// since the first read.
390 + ///
391 + /// Zero on anything unreadable, which shows UTC. A clock an hour off is a
392 + /// visible, correctable wrong answer; a bar with no clock is not.
393 + fn local_offset() -> i64 {
394 + tzif_offset().unwrap_or(0)
395 + }
396 +
397 + /// Parse the current UTC offset out of `/etc/localtime`.
398 + ///
399 + /// TZif version 1 layout, which every version of the format still carries at
400 + /// the front for exactly this reason: a v2 or v3 file repeats the data in
401 + /// 64-bit form afterwards, and the v1 block stays valid through 2038. Reading
402 + /// only the first block keeps this to one pass and no allocation past the file
403 + /// itself.
404 + fn tzif_offset() -> Option<i64> {
405 + let data = std::fs::read("/etc/localtime").ok()?;
406 + if data.len() < 44 || &data[..4] != b"TZif" {
407 + return None;
408 + }
409 + let counts: Vec<u32> = (0..6)
410 + .map(|i| {
411 + let at = 20 + i * 4;
412 + u32::from_be_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]])
413 + })
414 + .collect();
415 + let (timecnt, typecnt) = (counts[3] as usize, counts[4] as usize);
416 + if typecnt == 0 {
417 + return None;
418 + }
419 +
420 + let times = 44;
421 + let indices = times + timecnt * 4;
422 + let types = indices + timecnt;
423 +
424 + let now = std::time::SystemTime::now()
425 + .duration_since(std::time::UNIX_EPOCH)
426 + .ok()?
427 + .as_secs() as i64;
428 +
429 + // The last transition at or before now. Transitions are sorted, so walking
430 + // forward and keeping the last match is enough; there are a few hundred of
431 + // them in a zone with DST.
432 + let mut chosen = 0usize;
433 + for i in 0..timecnt {
434 + let at = times + i * 4;
435 + let when = i32::from_be_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]) as i64;
436 + if when > now {
437 + break;
438 + }
439 + chosen = *data.get(indices + i)? as usize;
440 + }
441 + if chosen >= typecnt {
442 + return None;
443 + }
444 +
445 + let at = types + chosen * 6;
446 + let raw = data.get(at..at + 4)?;
447 + Some(i32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]) as i64)
448 + }
449 +
450 + /// One block of the status line.
451 + ///
452 + /// `separator_block_width` is set narrower than swaybar's default so four
453 + /// blocks fit a 12-inch panel without the line reading as four separate things.
454 + /// The separator itself stays on: without it the blocks run together into one
455 + /// string of numbers.
456 + fn block(name: &str, text: &str, token: &str, tokens: Option<&SemanticTokens>) -> String {
457 + let mut out = String::from("{\"name\":\"");
458 + out.push_str(name);
459 + out.push_str("\",\"full_text\":\"");
460 + escape_into(&mut out, text);
461 + out.push('"');
462 + if let Some(hex) = tokens.and_then(|tokens| tokens.hex(token)) {
463 + let _ = write!(out, ",\"color\":\"{hex}\"");
464 + }
465 + out.push_str(",\"separator_block_width\":14}");
466 + out
467 + }
468 +
469 + /// JSON-escape into an existing buffer.
470 + ///
471 + /// Hand-rolled against `serde_json` because a block is four fixed keys and one
472 + /// value, and building a map to serialize it would be more code than this. The
473 + /// value is the one thing here that is not this module's own literal: an SSID
474 + /// is user-controlled and can hold a quote or a backslash, which unescaped
475 + /// would break the array and take the whole bar down with it.
476 + fn escape_into(out: &mut String, text: &str) {
477 + for ch in text.chars() {
478 + match ch {
479 + '"' => out.push_str("\\\""),
480 + '\\' => out.push_str("\\\\"),
481 + // Control characters have no business in a bar and no escape worth
482 + // spelling out; dropped rather than emitted as \u00xx.
483 + c if (c as u32) < 0x20 => {}
484 + c => out.push(c),
485 + }
486 + }
487 + }
488 +
489 + #[cfg(test)]
490 + mod tests {
491 + use super::*;
492 + use crate::audio::Direction;
493 +
494 + fn power(percent: u8, charging: bool, full: bool) -> Power {
495 + Power {
496 + percent,
497 + charging,
498 + full,
499 + }
500 + }
Lines truncated