Skip to main content

max / alloy

install: blink an activity light while a step runs Some install steps print nothing for a second or more — udevadm settle, importing 86 layers, trimming the root — and a screen that has not changed reads as a hang. The run screen now shows an activity light on the status line that blinks while a command runs, so a silent step still visibly works. The shell drew once a second, which is too slow for the light to read as blinking and is itself part of the frozen feel. Views now name their own tick interval; the run screen shortens it to 400ms while a command is in flight and returns to the default second when the install finishes or fails, freezing the light so the state is unmistakable. Every other view keeps the default and costs nothing while idle. The light is a CP437 block, bright to dim rather than glyph to blank, so it blinks in place instead of flickering away. It draws on the Linux console that the installer runs on.
Author: Max Johnson <me@maxj.phd> · 2026-07-21 22:07 UTC
Signed with PGP, not checked
Commit: 52a9f01ab0b1cb7109379de91899884f118a79c4
Parent: 8be8993
2 files changed, +106 insertions, -11 deletions
@@ -53,6 +53,8 @@
53 53 //!
54 54 //! <!-- wiki: alloy-console -->
55 55
56 + use std::time::Duration;
57 +
56 58 use alloy_tui::{AlloyBlock, AlloyList, Hint, Severity, Theme, hint, text};
57 59 use anyhow::{Context, Result};
58 60 use ratatui::Frame;
@@ -69,7 +71,7 @@
69 71 use crate::cli::{CommandLog, Invocation, Secret};
70 72 use crate::field::TextField;
71 73 use crate::run::{Sequence, Stage};
72 - use crate::shell::{Confirm, Flow, View, block_title};
74 + use crate::shell::{Confirm, Flow, TICK, View, block_title};
73 75 use crate::wizard::Steps;
74 76
75 77 /// The questions, in the order they are asked.
@@ -244,6 +246,28 @@
244 246 /// mountpoint used throughout.
245 247 const TARGET_MOUNT: &str = "/mnt/alloy-target";
246 248
249 + /// The activity light's lit and dim glyphs.
250 + ///
251 + /// Block elements from CP437, which the Linux console's default font carries —
252 + /// the same family as the box-drawing the rest of the UI already uses. A
253 + /// brighter and a fainter block rather than a glyph and a blank, so the light
254 + /// blinks in place instead of flickering away.
255 + const LIGHT_ON: char = '█';
256 + const LIGHT_OFF: char = '░';
257 +
258 + /// How often the run screen redraws, which is how fast the light blinks.
259 + ///
260 + /// Short enough that the light reads as blinking rather than as a screen that
261 + /// changed once, long enough that polling the running command a few times a
262 + /// second stays cheap. In effect only while the install runs; every other
263 + /// step ticks at the shell's default second.
264 + const BLINK_INTERVAL: Duration = Duration::from_millis(400);
265 +
266 + /// The activity light's glyph for a given blink count: lit on even, dim on odd.
267 + fn blink_glyph(blink: usize) -> char {
268 + if blink % 2 == 0 { LIGHT_ON } else { LIGHT_OFF }
269 + }
270 +
247 271 /// Find the ostree deployment directory beneath a mounted target root.
248 272 ///
249 273 /// **This is why the earlier design was wrong, not merely unverified.** An
@@ -1145,6 +1169,12 @@
1145 1169 /// happens after the last answer. While it is `Some` it owns the screen:
1146 1170 /// there is nothing to ask and nothing to go back to.
1147 1171 running: Option<Sequence>,
1172 + /// Counts up while the install runs, to blink the activity light.
1173 + ///
1174 + /// Some steps — settling udev, importing 86 layers — print nothing for a
1175 + /// second or more, and a screen that has not changed reads as a hang. The
1176 + /// light blinks off this counter so a silent step still visibly works.
1177 + blink: usize,
1148 1178 }
1149 1179
1150 1180 impl InstallView {
@@ -1167,6 +1197,7 @@
1167 1197 answers: Answers::default(),
1168 1198 error: None,
1169 1199 running: None,
1200 + blink: 0,
1170 1201 };
1171 1202 view.refresh(log);
1172 1203 view
@@ -1438,10 +1469,10 @@
1438 1469 let (done, total) = sequence.progress();
1439 1470
1440 1471 let status = match sequence.outcome() {
1441 - None => Line::from(text::muted(
1442 - theme,
1443 - format!("Installing. Step {} of {total}.", done + 1),
1444 - )),
1472 + None => Line::from(vec![
1473 + text::muted(theme, format!("Installing. Step {} of {total}. ", done + 1)),
1474 + self.activity_light(theme),
1475 + ]),
1445 1476 Some(Ok(())) => Line::from(Span::styled(
1446 1477 "Installation finished. Reboot to start Alloy.".to_string(),
1447 1478 Severity::Healthy.style(theme),
@@ -1467,6 +1498,17 @@
1467 1498 frame.render_widget(Paragraph::new(lines), area);
1468 1499 }
1469 1500
1501 + /// The activity light, lit or dim by the blink counter.
1502 + ///
1503 + /// A solid block that blinks against a faint one, so the cell keeps its
1504 + /// place rather than flickering in and out. On, then off, one tick each.
1505 + fn activity_light(&self, theme: &Theme) -> Span<'static> {
1506 + match blink_glyph(self.blink) {
1507 + LIGHT_ON => text::primary(theme, LIGHT_ON.to_string()),
1508 + glyph => text::muted(theme, glyph.to_string()),
1509 + }
1510 + }
1511 +
1470 1512 /// Raise the wipe confirmation.
1471 1513 ///
1472 1514 /// Two gates of different kinds, on purpose. The summary is the one you
@@ -1667,13 +1709,28 @@
1667 1709 self.running = Some(Sequence::new(self.plan()));
1668 1710 }
1669 1711
1670 - /// Drive the running install.
1712 + /// Drive the running install and advance the activity light.
1671 1713 ///
1672 - /// This is the tick the shell already calls once a second, so the run
1673 - /// screen costs nothing on any other step and needs no timer of its own.
1714 + /// The shell calls this every [`tick_interval`](Self::tick_interval), which
1715 + /// the run screen shortens so the light blinks. On any other step there is
1716 + /// no sequence, the interval is the default second, and this does nothing.
1674 1717 fn tick(&mut self, log: &mut CommandLog) {
1675 - if let Some(sequence) = &mut self.running {
1676 - sequence.poll(log);
1718 + let Some(sequence) = &mut self.running else {
1719 + return;
1720 + };
1721 + sequence.poll(log);
1722 + // Freeze the light once the run is over: a finished or failed install
1723 + // is not still working, and its status line says which.
1724 + if !sequence.is_done() {
1725 + self.blink = self.blink.wrapping_add(1);
1726 + }
1727 + }
1728 +
1729 + /// Blink fast while installing, idle at the shell's default otherwise.
1730 + fn tick_interval(&self) -> Duration {
1731 + match &self.running {
1732 + Some(sequence) if !sequence.is_done() => BLINK_INTERVAL,
1733 + _ => TICK,
1677 1734 }
1678 1735 }
1679 1736
@@ -1900,6 +1957,7 @@
1900 1957 answers: Answers::default(),
1901 1958 error: None,
1902 1959 running: None,
1960 + blink: 0,
1903 1961 };
1904 1962 view.cursor.resize(view.disks.len());
1905 1963 (view, CommandLog::new())
@@ -2812,6 +2870,32 @@
2812 2870 assert_eq!(view.step(), Step::Summary, "a key moved the wizard");
2813 2871 }
2814 2872
2873 + // The light alternates every tick, and off is a different glyph, not a
2874 + // gap: the cell keeps its place so the light blinks rather than flickers.
2875 + #[test]
2876 + fn the_activity_light_blinks() {
2877 + assert_eq!(blink_glyph(0), LIGHT_ON);
2878 + assert_eq!(blink_glyph(1), LIGHT_OFF);
2879 + assert_eq!(blink_glyph(2), LIGHT_ON);
2880 + assert_ne!(LIGHT_ON, LIGHT_OFF);
2881 + }
2882 +
2883 + // A queued install redraws fast enough to blink; every other step waits
2884 + // the shell's default second, so an idle wizard costs no busy-looping.
2885 + #[test]
2886 + fn the_run_screen_ticks_faster_than_an_idle_step() {
2887 + let (mut view, mut log) = at_summary();
2888 + assert_eq!(view.tick_interval(), TICK, "an idle step should not spin");
2889 +
2890 + view.confirmed(&mut log);
2891 + assert_eq!(
2892 + view.tick_interval(),
2893 + BLINK_INTERVAL,
2894 + "a running install should redraw to blink"
2895 + );
2896 + assert!(BLINK_INTERVAL < TICK);
2897 + }
2898 +
2815 2899 #[test]
2816 2900 fn the_title_names_the_backend_and_the_position() {
2817 2901 let (view, _log) = view();
@@ -133,6 +133,17 @@
133 133 /// Anything run from here is console bookkeeping, not a user action, so it
134 134 /// belongs inside [`CommandLog::quiet`].
135 135 fn tick(&mut self, _log: &mut CommandLog) {}
136 +
137 + /// How long the loop should wait for input before ticking this view.
138 + ///
139 + /// Default is [`TICK`], right for a view that only changes when acted on:
140 + /// it waits a second, ticks, redraws, and costs nothing while idle. A view
141 + /// animating something — an activity light while a command runs — returns
142 + /// a shorter interval so it redraws often enough to blink. Bounds tick
143 + /// latency, never input latency: a keypress wakes the poll immediately.
144 + fn tick_interval(&self) -> Duration {
145 + TICK
146 + }
136 147 }
137 148
138 149 /// How long the loop waits for a key before ticking.
@@ -171,7 +182,7 @@
171 182 // Poll rather than block, so a view can refresh state that changes
172 183 // without the user. `poll` returns as soon as an event arrives, so
173 184 // this costs nothing in input responsiveness.
174 - if !event::poll(TICK)? {
185 + if !event::poll(view.tick_interval())? {
175 186 view.tick(log);
176 187 continue;
177 188 }