Skip to main content

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}