|
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 |
+ |
}
|