tear_types/statusbar.rs
1//! Typed status-bar model.
2//!
3//! tmux's status bar is configured via format strings full of
4//! `#{...}` expressions. Tear models it as a typed list of
5//! [`Segment`]s — each segment is one composable widget. The
6//! tear-tmux-backend reduces the typed list to a tmux format string;
7//! the in-process backend (which mado embeds) renders the segments
8//! directly. Operators get autocomplete + type-checked status bars.
9
10use ishou_tokens::{Signal, SignalMode, TearSignals};
11use serde::{Deserialize, Serialize};
12
13/// How a signal segment renders into glyphs — the typed mirror of
14/// [`ishou_tokens::SignalMode`]. A roomy status bar picks `Emoji`; a
15/// tight, fixed-width bar picks `Glyph` (single-cell, never misaligns);
16/// a no-emoji terminal / log picks `Label`. The *vocabulary* (which mark
17/// means "zoomed") is fleet-uniform regardless of mode — it comes from
18/// [`TearSignals`].
19#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum SignalRenderMode {
22 /// Emoji-first (may be two cells wide). Roomy bars, notifications.
23 Emoji,
24 /// Single-width geometric glyph. Tight / dense status columns.
25 Glyph,
26 /// Plain-text label. No-emoji terminals, logs, a11y.
27 Label,
28}
29
30impl SignalRenderMode {
31 /// Map to the underlying [`ishou_tokens::SignalMode`].
32 #[must_use]
33 pub fn to_ishou(self) -> SignalMode {
34 match self {
35 Self::Emoji => SignalMode::Emoji,
36 Self::Glyph => SignalMode::Glyph,
37 Self::Label => SignalMode::Label,
38 }
39 }
40}
41
42impl Default for SignalRenderMode {
43 /// Emoji is the fleet default — the directive asks for emoji-based
44 /// communication; tight bars opt down to `Glyph` explicitly.
45 fn default() -> Self {
46 Self::Emoji
47 }
48}
49
50/// A semantic name for one fleet/tear status signal. Each variant maps
51/// to exactly one field of [`TearSignals`] (which composes the shared
52/// [`ishou_tokens::FleetSignals`] vocabulary) — so an operator writes
53/// `Segment::Signal { kind: TearSignalKind::SessionActive, .. }` and gets
54/// the fleet-uniform `🌊` / `≈` / `active` triple instead of hand-typing
55/// an emoji into a [`Segment::Text`]. Touching the ishou atlas moves the
56/// glyph fleet-wide on the next compile; tear never hardcodes the mark.
57#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
58#[serde(rename_all = "kebab-case")]
59pub enum TearSignalKind {
60 // ── shared fleet session/multiplexer vocabulary ──────────────
61 /// An attached, live session — the fleet `🌊` tide mark.
62 SessionActive,
63 /// A detached / sleeping session.
64 SessionDetached,
65 /// A zoomed / maximized pane.
66 PaneZoomed,
67 /// The multiplexer prefix key is armed (awaiting a command key).
68 PrefixArmed,
69 // ── shared connectivity ──────────────────────────────────────
70 /// Connected / healthy.
71 Online,
72 /// Disconnected / down.
73 Offline,
74 /// Degraded / partial.
75 Degraded,
76 /// Terminal bell / attention.
77 Bell,
78 // ── shared identity ──────────────────────────────────────────
79 /// The pleme-io fleet mark — Nord frost `❄`.
80 FleetMark,
81 // ── tear-specific vocabulary ─────────────────────────────────
82 /// A window (a tab / group of panes).
83 Window,
84 /// A horizontal split.
85 SplitHorizontal,
86 /// A vertical split.
87 SplitVertical,
88 /// Input synchronized across panes (send-to-all).
89 SyncPanes,
90 /// Copy-mode / scrollback navigation active.
91 CopyMode,
92}
93
94impl TearSignalKind {
95 /// The [`Signal`] triple this kind names, drawn from the prescribed
96 /// [`TearSignals`] atlas. The single source of truth for tear's
97 /// status glyphs — no literal emoji lives in tear's source.
98 #[must_use]
99 pub fn signal(self) -> Signal {
100 let s = TearSignals::prescribed();
101 match self {
102 Self::SessionActive => s.fleet.session_active,
103 Self::SessionDetached => s.fleet.session_detached,
104 Self::PaneZoomed => s.fleet.pane_zoomed,
105 Self::PrefixArmed => s.fleet.prefix_armed,
106 Self::Online => s.fleet.online,
107 Self::Offline => s.fleet.offline,
108 Self::Degraded => s.fleet.degraded,
109 Self::Bell => s.fleet.bell,
110 Self::FleetMark => s.fleet.fleet_mark,
111 Self::Window => s.window,
112 Self::SplitHorizontal => s.split_horizontal,
113 Self::SplitVertical => s.split_vertical,
114 Self::SyncPanes => s.sync_panes,
115 Self::CopyMode => s.copy_mode,
116 }
117 }
118
119 /// Render this kind's glyph at `mode`, falling back so a chosen field
120 /// that is empty (e.g. a bare-tier emoji) still yields a non-empty
121 /// mark rather than vanishing from the bar.
122 #[must_use]
123 pub fn render(self, mode: SignalRenderMode) -> &'static str {
124 self.signal().render_or_fallback(mode.to_ishou())
125 }
126}
127
128/// Where a segment sits relative to its bar.
129#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
130#[serde(rename_all = "lowercase")]
131pub enum SegmentAlignment {
132 Left,
133 Center,
134 Right,
135}
136
137/// One status-bar widget. Each variant evaluates to a string at
138/// render time; the bar concatenates segments aligned per
139/// `alignment`. Colors and attrs come from the active
140/// [`crate::TearTheme`].
141#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
142#[serde(tag = "kind", rename_all = "kebab-case")]
143pub enum Segment {
144 /// Literal text — common for separators / icons.
145 Text { value: String },
146 /// The current session's name.
147 SessionName,
148 /// The current window's name.
149 WindowName,
150 /// The active pane's command (e.g. `"zsh"`, `"nvim"`).
151 PaneCommand,
152 /// Current working directory (basename).
153 PaneCwdBasename,
154 /// Current time, formatted via strftime.
155 Time { format: String },
156 /// Hostname (long or short).
157 Hostname { short: bool },
158 /// User-defined shell-command output, refreshed every `interval`
159 /// seconds. The backend caches the most recent value.
160 Shell {
161 cmd: String,
162 interval_seconds: u32,
163 },
164 /// Conditional: render `then` if `cond` evaluates non-empty,
165 /// otherwise `else`. `cond` is a `#{...}`-style condition for
166 /// tmux compatibility.
167 If {
168 cond: String,
169 then: Box<Segment>,
170 otherwise: Box<Segment>,
171 },
172 /// A typed fleet status signal — a semantic glyph from the
173 /// [`TearSignals`] / [`ishou_tokens::FleetSignals`] atlas (session
174 /// active `🌊`, pane zoomed `🔍`, prefix armed `⌨️`, sync on `🔗`,
175 /// copy-mode `📜`, …). Replaces hand-typed emoji in [`Segment::Text`]
176 /// with the fleet-uniform vocabulary; the glyph moves fleet-wide when
177 /// the ishou atlas changes. `mode` selects emoji / single-width glyph
178 /// / plain label for the bar's width budget. Pair inside a
179 /// [`Segment::If`] (tmux conditional) to show the signal only when the
180 /// state is active.
181 ///
182 /// Field is named `signal` (not `kind`) to avoid colliding with the
183 /// enum's `#[serde(tag = "kind")]` discriminant key.
184 Signal {
185 signal: TearSignalKind,
186 #[serde(default)]
187 mode: SignalRenderMode,
188 },
189}
190
191/// One side (left | center | right) of a status bar. Held as an
192/// ordered Vec — segments render in order with theme separators in
193/// between.
194#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
195pub struct StatusBar {
196 /// Segments rendered on the left.
197 #[serde(default)]
198 pub left: Vec<Segment>,
199 /// Segments rendered in the centre.
200 #[serde(default)]
201 pub center: Vec<Segment>,
202 /// Segments rendered on the right.
203 #[serde(default)]
204 pub right: Vec<Segment>,
205 /// Refresh interval for any time-varying segments (clock, shell
206 /// segments). Seconds. tmux's `status-interval`.
207 #[serde(default = "default_interval")]
208 pub refresh_interval_seconds: u32,
209 /// Whether the bar is rendered at all. tmux's `status off`.
210 #[serde(default = "default_visible")]
211 pub visible: bool,
212}
213
214fn default_interval() -> u32 {
215 5
216}
217fn default_visible() -> bool {
218 true
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 /// Tear's status glyphs are PINNED to the ishou atlas — no literal
226 /// emoji lives in tear's source. If the atlas changes a mark, this
227 /// test fails BEFORE the operator-visible bar silently drifts, and
228 /// the fix is to update the atlas (the canonical vocabulary), not
229 /// tear. The 🌊 tide / 🔍 zoom / ⌨️ prefix / 🔗 sync / 📜 copy-mode
230 /// marks are exactly the fleet convention.
231 #[test]
232 fn signal_kinds_resolve_from_the_fleet_atlas() {
233 let atlas = TearSignals::prescribed();
234 // shared fleet session vocabulary
235 assert_eq!(
236 TearSignalKind::SessionActive.signal(),
237 atlas.fleet.session_active
238 );
239 assert_eq!(TearSignalKind::PaneZoomed.signal(), atlas.fleet.pane_zoomed);
240 assert_eq!(
241 TearSignalKind::PrefixArmed.signal(),
242 atlas.fleet.prefix_armed
243 );
244 // tear-specific vocabulary
245 assert_eq!(TearSignalKind::Window.signal(), atlas.window);
246 assert_eq!(TearSignalKind::SyncPanes.signal(), atlas.sync_panes);
247 assert_eq!(TearSignalKind::CopyMode.signal(), atlas.copy_mode);
248 }
249
250 /// The active-session mark is the established fleet 🌊 tide (emoji)
251 /// and `≈` (single-width glyph) — adoption is drift-free vs
252 /// mado/tear/praça.
253 #[test]
254 fn active_session_is_the_fleet_tide_mark() {
255 assert_eq!(
256 TearSignalKind::SessionActive.render(SignalRenderMode::Emoji),
257 "🌊"
258 );
259 assert_eq!(
260 TearSignalKind::SessionActive.render(SignalRenderMode::Glyph),
261 "≈"
262 );
263 }
264
265 /// Mode selection picks the right column of the triple, and a tight
266 /// bar gets a single-cell glyph for every signal.
267 #[test]
268 fn glyph_mode_is_single_width_for_every_kind() {
269 for kind in [
270 TearSignalKind::SessionActive,
271 TearSignalKind::SessionDetached,
272 TearSignalKind::PaneZoomed,
273 TearSignalKind::PrefixArmed,
274 TearSignalKind::Online,
275 TearSignalKind::Offline,
276 TearSignalKind::Degraded,
277 TearSignalKind::Bell,
278 TearSignalKind::FleetMark,
279 TearSignalKind::Window,
280 TearSignalKind::SplitHorizontal,
281 TearSignalKind::SplitVertical,
282 TearSignalKind::SyncPanes,
283 TearSignalKind::CopyMode,
284 ] {
285 let glyph = kind.render(SignalRenderMode::Glyph);
286 assert_eq!(
287 glyph.chars().count(),
288 1,
289 "{kind:?} glyph must be single-width, got {glyph:?}"
290 );
291 }
292 }
293
294 /// The signal segment round-trips through serde: the enum tag is the
295 /// `kind` key (`"signal"`), the signal name is the `signal` key
296 /// (kebab-case), and `mode` defaults to emoji when omitted.
297 #[test]
298 fn signal_segment_serde_round_trips_with_default_mode() {
299 let seg = Segment::Signal {
300 signal: TearSignalKind::PaneZoomed,
301 mode: SignalRenderMode::default(),
302 };
303 let json = serde_json::to_string(&seg).unwrap();
304 assert!(json.contains("\"kind\":\"signal\""), "tag: {json}");
305 assert!(json.contains("\"signal\":\"pane-zoomed\""), "signal: {json}");
306 let back: Segment = serde_json::from_str(&json).unwrap();
307 assert_eq!(back, seg);
308
309 // `mode` omitted → defaults to emoji (the fleet default).
310 let no_mode: Segment =
311 serde_json::from_str(r#"{"kind":"signal","signal":"sync-panes"}"#).unwrap();
312 assert_eq!(
313 no_mode,
314 Segment::Signal {
315 signal: TearSignalKind::SyncPanes,
316 mode: SignalRenderMode::Emoji,
317 }
318 );
319 }
320}