Skip to main content

elegance/
multi_terminal.rs

1//! Multi-pane terminal widget with per-pane broadcast toggles.
2//!
3//! [`MultiTerminal`] renders a responsive grid of [`TerminalPane`]s with a
4//! shared keyboard input surface: whatever the user types is broadcast to
5//! every pane whose "broadcast" pill is on. Think tmux's synchronized panes
6//! or MobaXterm's multi-exec, rendered in the elegance design language.
7//!
8//! The widget is purely presentational: it captures keystrokes, maintains a
9//! pending input buffer, and emits [`TerminalEvent::Command`] when the user
10//! presses Enter. The caller is responsible for running the command on each
11//! target and pushing response lines back into the corresponding pane via
12//! [`MultiTerminal::push_line`].
13//!
14//! # Interaction
15//!
16//! * Click a pane header or body to move keyboard focus onto that pane.
17//! * Click a pane's broadcast pill to toggle it in or out of the broadcast
18//!   set. Every pane with broadcast on will receive input; offline panes
19//!   are skipped. If the set is empty, the focused pane receives input as
20//!   a fallback, so the buffer always has somewhere to go.
21//! * Each pane has a **Solo** target button next to its broadcast pill:
22//!   clicking solos that pane (broadcast = `{this}`); clicking again
23//!   restores the previously stashed set.
24//! * The gridbar has an **All on** toggle: clicking turns broadcast on
25//!   for every connected pane, and clicking again turns all of them off.
26//! * Keyboard: `Enter` sends, `Backspace` edits, `Esc` clears; `Cmd`/
27//!   `Ctrl` + `A` toggles All on/off, `Cmd`/`Ctrl` + `D` solos the focused
28//!   pane.
29//!
30//! # Example
31//!
32//! ```no_run
33//! use elegance::{LineKind, MultiTerminal, TerminalEvent, TerminalLine,
34//!                TerminalPane, TerminalStatus};
35//!
36//! struct App {
37//!     terms: MultiTerminal,
38//! }
39//!
40//! impl Default for App {
41//!     fn default() -> Self {
42//!         let terms = MultiTerminal::new("ssh-multi")
43//!             .with_pane(
44//!                 TerminalPane::new("api-east", "api-east-01")
45//!                     .user("root")
46//!                     .cwd("/var/log")
47//!                     .status(TerminalStatus::Connected),
48//!             )
49//!             .with_pane(
50//!                 TerminalPane::new("edge", "edge-proxy-01")
51//!                     .user("root")
52//!                     .status(TerminalStatus::Connected),
53//!             );
54//!         Self { terms }
55//!     }
56//! }
57//!
58//! # impl App {
59//! fn ui(&mut self, ui: &mut egui::Ui) {
60//!     self.terms.show(ui);
61//!     for ev in self.terms.take_events() {
62//!         match ev {
63//!             TerminalEvent::Command { targets, command } => {
64//!                 for id in targets {
65//!                     self.terms.push_line(
66//!                         &id,
67//!                         TerminalLine::new(LineKind::Out, format!("ran: {command}")),
68//!                     );
69//!                 }
70//!             }
71//!         }
72//!     }
73//! }
74//! # }
75//! ```
76
77use std::collections::HashSet;
78use std::hash::Hash;
79
80use egui::epaint::text::{LayoutJob, TextFormat};
81use egui::{
82    Align2, Color32, CornerRadius, Event, FontFamily, FontId, Id, Key, Modifiers, Pos2, Rect,
83    Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetInfo, WidgetType,
84};
85
86use crate::theme::{Palette, Theme, Typography};
87
88/// Connection status for a [`TerminalPane`].
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
90pub enum TerminalStatus {
91    /// The pane is live and will receive broadcast input.
92    Connected,
93    /// The pane is temporarily unavailable; shown in amber and excluded
94    /// from broadcasts.
95    Reconnecting,
96    /// The pane is offline; shown in red and excluded from broadcasts.
97    Offline,
98}
99
100impl TerminalStatus {
101    /// Map to the corresponding [`IndicatorState`](crate::IndicatorState) so
102    /// the pane header can reuse the library's status-light glyph.
103    pub fn indicator_state(self) -> crate::IndicatorState {
104        match self {
105            Self::Connected => crate::IndicatorState::On,
106            Self::Reconnecting => crate::IndicatorState::Connecting,
107            Self::Offline => crate::IndicatorState::Off,
108        }
109    }
110}
111
112/// How a [`TerminalLine`] is coloured when rendered.
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub enum LineKind {
115    /// Plain output, rendered in the primary text colour.
116    Out,
117    /// Informational text, rendered faint and italic.
118    Info,
119    /// Successful output, rendered in the success green.
120    Ok,
121    /// Warning, rendered in amber.
122    Warn,
123    /// Error, rendered in danger red.
124    Err,
125    /// Dimmed secondary output, rendered in muted grey.
126    Dim,
127    /// A command echo with a full prompt prefix (`user@host:cwd$ cmd`).
128    ///
129    /// When this variant is used, the `text` field of [`TerminalLine`] is
130    /// ignored; the command text is stored inline.
131    Command {
132        /// Username shown in the prompt.
133        user: String,
134        /// Hostname shown in the prompt.
135        host: String,
136        /// Working directory shown in the prompt.
137        cwd: String,
138        /// The command text the user typed.
139        cmd: String,
140    },
141}
142
143/// A single line in a [`TerminalPane`]'s scrollback buffer.
144#[derive(Clone, Debug)]
145pub struct TerminalLine {
146    /// Colour/style of the line.
147    pub kind: LineKind,
148    /// The text content. Unused when `kind` is [`LineKind::Command`].
149    pub text: String,
150}
151
152impl TerminalLine {
153    /// Create a line with the given kind and text.
154    pub fn new(kind: LineKind, text: impl Into<String>) -> Self {
155        Self {
156            kind,
157            text: text.into(),
158        }
159    }
160
161    /// Plain-output shortcut.
162    pub fn out(text: impl Into<String>) -> Self {
163        Self::new(LineKind::Out, text)
164    }
165    /// Informational shortcut.
166    pub fn info(text: impl Into<String>) -> Self {
167        Self::new(LineKind::Info, text)
168    }
169    /// Success shortcut.
170    pub fn ok(text: impl Into<String>) -> Self {
171        Self::new(LineKind::Ok, text)
172    }
173    /// Warning shortcut.
174    pub fn warn(text: impl Into<String>) -> Self {
175        Self::new(LineKind::Warn, text)
176    }
177    /// Error shortcut.
178    pub fn err(text: impl Into<String>) -> Self {
179        Self::new(LineKind::Err, text)
180    }
181    /// Dimmed shortcut.
182    pub fn dim(text: impl Into<String>) -> Self {
183        Self::new(LineKind::Dim, text)
184    }
185
186    /// Build a command echo line. Rendered as `user@host:cwd$ cmd` with
187    /// elegance's prompt colouring.
188    pub fn command(
189        user: impl Into<String>,
190        host: impl Into<String>,
191        cwd: impl Into<String>,
192        cmd: impl Into<String>,
193    ) -> Self {
194        Self {
195            kind: LineKind::Command {
196                user: user.into(),
197                host: host.into(),
198                cwd: cwd.into(),
199                cmd: cmd.into(),
200            },
201            text: String::new(),
202        }
203    }
204}
205
206/// A single pane rendered by [`MultiTerminal`].
207#[derive(Clone, Debug)]
208pub struct TerminalPane {
209    /// Stable identifier used as the key in the broadcast set and event
210    /// target list. Must be unique across panes in a single `MultiTerminal`.
211    pub id: String,
212    /// Hostname shown in the header and prompt.
213    pub host: String,
214    /// Username shown in the prompt. Default: `"user"`.
215    pub user: String,
216    /// Working directory shown in the prompt. Default: `"~"`.
217    pub cwd: String,
218    /// Connection status. Default: [`TerminalStatus::Connected`].
219    pub status: TerminalStatus,
220    /// Scrollback buffer. Oldest line at index 0, newest at the end.
221    pub lines: Vec<TerminalLine>,
222}
223
224impl TerminalPane {
225    /// Create a pane with the given id and hostname. Defaults: user `"user"`,
226    /// cwd `"~"`, status [`TerminalStatus::Connected`], no lines.
227    pub fn new(id: impl Into<String>, host: impl Into<String>) -> Self {
228        Self {
229            id: id.into(),
230            host: host.into(),
231            user: "user".into(),
232            cwd: "~".into(),
233            status: TerminalStatus::Connected,
234            lines: Vec::new(),
235        }
236    }
237
238    /// Set the username shown in the prompt.
239    #[inline]
240    pub fn user(mut self, user: impl Into<String>) -> Self {
241        self.user = user.into();
242        self
243    }
244
245    /// Set the working directory shown in the prompt.
246    #[inline]
247    pub fn cwd(mut self, cwd: impl Into<String>) -> Self {
248        self.cwd = cwd.into();
249        self
250    }
251
252    /// Set the connection status.
253    #[inline]
254    pub fn status(mut self, status: TerminalStatus) -> Self {
255        self.status = status;
256        self
257    }
258
259    /// Append a line to the scrollback buffer (builder form).
260    #[inline]
261    pub fn push(mut self, line: TerminalLine) -> Self {
262        self.lines.push(line);
263        self
264    }
265
266    /// Append a line at runtime.
267    pub fn push_line(&mut self, line: TerminalLine) {
268        self.lines.push(line);
269    }
270
271    /// Replace the connection status at runtime.
272    pub fn set_status(&mut self, status: TerminalStatus) {
273        self.status = status;
274    }
275
276    /// Build a command echo line targeting this pane. Convenience helper:
277    /// the prompt pieces are filled from the pane's own user/host/cwd.
278    pub fn command_line(&self, cmd: impl Into<String>) -> TerminalLine {
279        TerminalLine::command(self.user.clone(), self.host.clone(), self.cwd.clone(), cmd)
280    }
281}
282
283/// Events emitted by [`MultiTerminal`] that the caller must react to.
284#[derive(Clone, Debug)]
285pub enum TerminalEvent {
286    /// The user pressed Enter with a non-empty buffer. Run `command` on
287    /// each pane whose id is in `targets` and push the response lines back
288    /// via [`MultiTerminal::push_line`].
289    ///
290    /// The widget has already echoed the command into each target pane
291    /// (as a [`LineKind::Command`] line) before this event is emitted, so
292    /// the caller only needs to append the reply.
293    Command {
294        /// Pane ids that should run this command, in grid order.
295        targets: Vec<String>,
296        /// The command text as typed by the user.
297        command: String,
298    },
299}
300
301/// Multi-pane terminal with per-pane broadcast toggles.
302///
303/// See the module-level documentation for the full interaction model.
304#[must_use = "Call `.show(ui)` to render the widget."]
305pub struct MultiTerminal {
306    id_salt: Id,
307    panes: Vec<TerminalPane>,
308    broadcast: HashSet<String>,
309    collapsed: HashSet<String>,
310    stashed: Option<HashSet<String>>,
311    focused_id: Option<String>,
312    pending: String,
313    columns_mode: ColumnsMode,
314    pane_min_height: f32,
315    scrollback_cap: usize,
316    events: Vec<TerminalEvent>,
317}
318
319/// How [`MultiTerminal`] decides the grid's column count.
320#[derive(Clone, Copy, Debug, PartialEq)]
321pub enum ColumnsMode {
322    /// Always render exactly `n` columns, regardless of available width.
323    Fixed(usize),
324    /// Pick the column count each frame from the available width, ensuring
325    /// every column is at least `min_col_width` points wide. Scales well
326    /// from a narrow sidebar (1 column) up to a wide monitor (3-4+ columns).
327    Auto {
328        /// Minimum column width before the grid drops a column.
329        min_col_width: f32,
330    },
331}
332
333impl std::fmt::Debug for MultiTerminal {
334    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335        f.debug_struct("MultiTerminal")
336            .field("id_salt", &self.id_salt)
337            .field("panes", &self.panes.len())
338            .field("broadcast", &self.broadcast)
339            .field("collapsed", &self.collapsed)
340            .field("focused_id", &self.focused_id)
341            .field("pending", &self.pending)
342            .field("columns_mode", &self.columns_mode)
343            .field("events", &self.events.len())
344            .finish()
345    }
346}
347
348impl MultiTerminal {
349    /// Create an empty widget. `id_salt` scopes the widget's memory state;
350    /// two `MultiTerminal`s on the same page need distinct salts.
351    pub fn new(id_salt: impl Hash) -> Self {
352        Self {
353            id_salt: Id::new(("elegance_multi_terminal", id_salt)),
354            panes: Vec::new(),
355            broadcast: HashSet::new(),
356            collapsed: HashSet::new(),
357            stashed: None,
358            focused_id: None,
359            pending: String::new(),
360            columns_mode: ColumnsMode::Fixed(2),
361            pane_min_height: 220.0,
362            scrollback_cap: 500,
363            events: Vec::new(),
364        }
365    }
366
367    /// Add a pane at construction time (builder form).
368    #[inline]
369    pub fn with_pane(mut self, pane: TerminalPane) -> Self {
370        self.add_pane(pane);
371        self
372    }
373
374    /// Render with a fixed number of columns in the pane grid. Panes
375    /// wrap after `columns` per row. Default: 2.
376    ///
377    /// See also [`columns_auto`](Self::columns_auto) for a width-responsive
378    /// mode that's better suited to large pane counts.
379    #[inline]
380    pub fn columns(mut self, columns: usize) -> Self {
381        self.columns_mode = ColumnsMode::Fixed(columns.max(1));
382        self
383    }
384
385    /// Render with a width-responsive column count. Each frame the grid
386    /// picks the largest column count such that every column is at least
387    /// `min_col_width` points wide, clamped between 1 and the number of
388    /// panes. With 16 panes, this naturally produces 3–4 columns on a
389    /// wide monitor and 1–2 on a narrow sidebar.
390    ///
391    /// `min_col_width` is clamped to a minimum of 240 pt so the pane
392    /// header always has room for the chevron, hostname, solo button,
393    /// broadcast pill and status indicator.
394    #[inline]
395    pub fn columns_auto(mut self, min_col_width: f32) -> Self {
396        self.columns_mode = ColumnsMode::Auto {
397            min_col_width: min_col_width.max(240.0),
398        };
399        self
400    }
401
402    /// Minimum height of a single pane, in points. Default: `220.0`.
403    #[inline]
404    pub fn pane_min_height(mut self, h: f32) -> Self {
405        self.pane_min_height = h.max(80.0);
406        self
407    }
408
409    /// Cap on the number of lines retained per pane. Older lines are
410    /// dropped when the buffer exceeds this count. Default: 500.
411    #[inline]
412    pub fn scrollback_cap(mut self, n: usize) -> Self {
413        self.scrollback_cap = n.max(1);
414        self
415    }
416
417    /// Append a pane at runtime.
418    pub fn add_pane(&mut self, pane: TerminalPane) {
419        // If this is the first pane, focus it by default.
420        if self.focused_id.is_none() {
421            self.focused_id = Some(pane.id.clone());
422        }
423        // Connected panes start in the broadcast set so the widget has a
424        // sensible initial target.
425        if pane.status == TerminalStatus::Connected {
426            self.broadcast.insert(pane.id.clone());
427        }
428        self.panes.push(pane);
429    }
430
431    /// Remove a pane by id.
432    pub fn remove_pane(&mut self, id: &str) {
433        self.panes.retain(|p| p.id != id);
434        self.broadcast.remove(id);
435        if let Some(stash) = self.stashed.as_mut() {
436            stash.remove(id);
437        }
438        if self.focused_id.as_deref() == Some(id) {
439            self.focused_id = self.panes.first().map(|p| p.id.clone());
440        }
441    }
442
443    /// Borrow a pane by id.
444    pub fn pane(&self, id: &str) -> Option<&TerminalPane> {
445        self.panes.iter().find(|p| p.id == id)
446    }
447
448    /// Borrow a pane mutably by id.
449    pub fn pane_mut(&mut self, id: &str) -> Option<&mut TerminalPane> {
450        self.panes.iter_mut().find(|p| p.id == id)
451    }
452
453    /// All panes, in grid order.
454    pub fn panes(&self) -> &[TerminalPane] {
455        &self.panes
456    }
457
458    /// Append a line to the pane with the given id. No-op if not found.
459    /// Applies the scrollback cap.
460    pub fn push_line(&mut self, id: &str, line: TerminalLine) {
461        let cap = self.scrollback_cap;
462        if let Some(p) = self.panes.iter_mut().find(|p| p.id == id) {
463            p.lines.push(line);
464            if p.lines.len() > cap {
465                let drop = p.lines.len() - cap;
466                p.lines.drain(0..drop);
467            }
468        }
469    }
470
471    /// Change a pane's status at runtime. If the pane leaves the connected
472    /// state, it's removed from the broadcast set.
473    pub fn set_status(&mut self, id: &str, status: TerminalStatus) {
474        if let Some(p) = self.pane_mut(id) {
475            p.status = status;
476        }
477        if status != TerminalStatus::Connected {
478            self.broadcast.remove(id);
479        }
480    }
481
482    /// Id of the currently focused pane, if any.
483    pub fn focused(&self) -> Option<&str> {
484        self.focused_id.as_deref()
485    }
486
487    /// Programmatically set the focused pane.
488    pub fn set_focused(&mut self, id: Option<String>) {
489        self.focused_id = id;
490    }
491
492    /// Current broadcast set (pane ids that will receive input). Does not
493    /// include offline panes.
494    pub fn broadcast(&self) -> &HashSet<String> {
495        &self.broadcast
496    }
497
498    /// Replace the broadcast set wholesale. Invalidates the stash used by
499    /// the Solo / All-on toggles.
500    pub fn set_broadcast(&mut self, set: HashSet<String>) {
501        self.broadcast = set;
502        self.stashed = None;
503    }
504
505    /// Whether a pane is currently collapsed (rendered as a header-only
506    /// strip with its scrollback hidden).
507    pub fn is_collapsed(&self, id: &str) -> bool {
508        self.collapsed.contains(id)
509    }
510
511    /// Collapse or expand a pane by id.
512    pub fn set_collapsed(&mut self, id: &str, collapsed: bool) {
513        if collapsed {
514            self.collapsed.insert(id.to_string());
515        } else {
516            self.collapsed.remove(id);
517        }
518    }
519
520    /// Flip the collapsed state of a pane.
521    pub fn toggle_collapsed(&mut self, id: &str) {
522        if self.collapsed.contains(id) {
523            self.collapsed.remove(id);
524        } else {
525            self.collapsed.insert(id.to_string());
526        }
527    }
528
529    /// Collapse every pane to its header strip.
530    pub fn collapse_all(&mut self) {
531        for p in &self.panes {
532            self.collapsed.insert(p.id.clone());
533        }
534    }
535
536    /// Expand every pane back to full height.
537    pub fn expand_all(&mut self) {
538        self.collapsed.clear();
539    }
540
541    /// Toggle whether `id` is in the broadcast set. Connected panes only.
542    pub fn toggle_broadcast(&mut self, id: &str) {
543        if self
544            .pane(id)
545            .is_some_and(|p| p.status == TerminalStatus::Connected)
546        {
547            self.stashed = None;
548            if self.broadcast.contains(id) {
549                self.broadcast.remove(id);
550            } else {
551                self.broadcast.insert(id.to_string());
552            }
553        }
554    }
555
556    /// Collapse the broadcast set to just the pane with the given id, and
557    /// focus that pane. Calling solo on a pane that's already the sole
558    /// receiver restores the previously-stashed set (so the button toggles).
559    ///
560    /// No-op if the id doesn't match a connected pane.
561    pub fn solo(&mut self, id: &str) {
562        if !self
563            .panes
564            .iter()
565            .any(|p| p.id == id && p.status == TerminalStatus::Connected)
566        {
567            return;
568        }
569        let is_solo = self.broadcast.len() == 1 && self.broadcast.contains(id);
570        if is_solo {
571            self.restore_or_fallback();
572        } else {
573            self.stashed = Some(self.broadcast.clone());
574            self.broadcast.clear();
575            self.broadcast.insert(id.to_string());
576        }
577        self.focused_id = Some(id.to_string());
578    }
579
580    /// Solo the currently-focused pane. See [`solo`](Self::solo) for the
581    /// toggle semantics. Bound to the `Cmd/Ctrl+D` shortcut.
582    pub fn solo_focused(&mut self) {
583        if let Some(fid) = self.focused_id.clone() {
584            self.solo(&fid);
585        }
586    }
587
588    /// Toggle broadcast on every connected pane. If every connected pane
589    /// is already in the broadcast set, clears it; otherwise fills it with
590    /// every connected pane.
591    ///
592    /// Note: when the set ends up empty, the focused pane still receives
593    /// input as a fallback so the buffer always has somewhere to go.
594    pub fn broadcast_all(&mut self) {
595        let connected: Vec<String> = self
596            .panes
597            .iter()
598            .filter(|p| p.status == TerminalStatus::Connected)
599            .map(|p| p.id.clone())
600            .collect();
601        let all_on =
602            !connected.is_empty() && connected.iter().all(|id| self.broadcast.contains(id));
603        // All-on is now a plain on/off toggle rather than a stash-and-restore
604        // mechanism: an explicit "turn everything off" is cleaner for users
605        // than having the button sometimes restore a prior set.
606        self.stashed = None;
607        if all_on {
608            self.broadcast.clear();
609        } else {
610            self.broadcast = connected.into_iter().collect();
611        }
612    }
613
614    /// Flip the broadcast state on every connected pane (off becomes on
615    /// and vice versa). Clears the stash.
616    pub fn invert_broadcast(&mut self) {
617        self.stashed = None;
618        let mut next = HashSet::new();
619        for p in &self.panes {
620            if p.status != TerminalStatus::Connected {
621                continue;
622            }
623            if !self.broadcast.contains(&p.id) {
624                next.insert(p.id.clone());
625            }
626        }
627        self.broadcast = next;
628    }
629
630    /// Current pending input (what the user is typing).
631    pub fn pending(&self) -> &str {
632        &self.pending
633    }
634
635    /// Clear the pending input buffer.
636    pub fn clear_pending(&mut self) {
637        self.pending.clear();
638    }
639
640    /// Drain and return the events accumulated since the previous call.
641    /// Call this once per frame after [`show`](Self::show) to react to
642    /// user-submitted commands.
643    pub fn take_events(&mut self) -> Vec<TerminalEvent> {
644        std::mem::take(&mut self.events)
645    }
646
647    /// Render the widget. Call once per frame inside a `CentralPanel` or
648    /// similar container.
649    pub fn show(&mut self, ui: &mut Ui) -> Response {
650        let theme = Theme::current(ui.ctx());
651        let focus_id = self.id_salt;
652
653        // Reserve the whole widget region first so we have a rect to make
654        // keyboard-focusable. The closure renders the actual content.
655        let inner = ui
656            .vertical(|ui| {
657                self.ui_gridbar(ui, &theme);
658                ui.add_space(0.0);
659                self.ui_grid(ui, &theme);
660            })
661            .response;
662
663        // Register the full region as keyboard-focusable *without* claiming
664        // pointer clicks. An interactive `Sense::click()` here would sit on
665        // top of the children in egui's z-order and swallow their clicks
666        // (broadcast pill, quick actions, pane headers). Children call
667        // `request_focus(focus_id)` explicitly when clicked.
668        let bg = ui.interact(inner.rect, focus_id, Sense::focusable_noninteractive());
669
670        // Auto-claim focus whenever nothing else has it — the widget is a
671        // REPL-style typing surface, so keystrokes should land in the panes
672        // as soon as the widget is visible, without requiring an initial
673        // click. We only take focus when the app isn't focused on something
674        // else (a TextEdit elsewhere, for instance).
675        let someone_else_has_focus = ui
676            .ctx()
677            .memory(|m| m.focused().is_some_and(|f| f != focus_id));
678        if !someone_else_has_focus {
679            ui.ctx().memory_mut(|m| m.request_focus(focus_id));
680        }
681
682        if ui.ctx().memory(|m| m.has_focus(focus_id)) {
683            self.handle_keys(ui);
684        }
685
686        bg.widget_info(|| {
687            WidgetInfo::labeled(
688                WidgetType::Other,
689                true,
690                format!(
691                    "Multi-terminal, {} pane{}, {} receiving",
692                    self.panes.len(),
693                    if self.panes.len() == 1 { "" } else { "s" },
694                    self.target_ids().len()
695                ),
696            )
697        });
698        bg
699    }
700
701    // ---- Internal helpers ------------------------------------------------
702
703    /// Restore from the stashed broadcast set or fall back to the focused
704    /// pane if nothing is stashed.
705    fn restore_or_fallback(&mut self) {
706        if let Some(stash) = self.stashed.take() {
707            self.broadcast = stash
708                .into_iter()
709                .filter(|id| {
710                    self.panes
711                        .iter()
712                        .any(|p| p.id == *id && p.status == TerminalStatus::Connected)
713                })
714                .collect();
715        }
716        if self.broadcast.is_empty() {
717            if let Some(fid) = self.focused_id.clone() {
718                self.broadcast.insert(fid);
719            }
720        }
721    }
722
723    /// The set of pane ids that should actually receive input right now.
724    /// Falls back to the focused pane when the user-chosen set is empty.
725    fn target_ids(&self) -> Vec<String> {
726        let alive: Vec<String> = self
727            .panes
728            .iter()
729            .filter(|p| self.broadcast.contains(&p.id) && p.status == TerminalStatus::Connected)
730            .map(|p| p.id.clone())
731            .collect();
732        if !alive.is_empty() {
733            return alive;
734        }
735        if let Some(fid) = &self.focused_id {
736            if self
737                .panes
738                .iter()
739                .any(|p| p.id == *fid && p.status == TerminalStatus::Connected)
740            {
741                return vec![fid.clone()];
742            }
743        }
744        Vec::new()
745    }
746
747    fn connected_count(&self) -> usize {
748        self.panes
749            .iter()
750            .filter(|p| p.status == TerminalStatus::Connected)
751            .count()
752    }
753
754    fn run_pending(&mut self) {
755        let cmd = self.pending.trim().to_string();
756        if cmd.is_empty() {
757            return;
758        }
759        let targets = self.target_ids();
760        if targets.is_empty() {
761            return;
762        }
763        // Echo the command into each target pane before emitting the event,
764        // so the caller just appends the response.
765        let cap = self.scrollback_cap;
766        for id in &targets {
767            if let Some(pane) = self.panes.iter_mut().find(|p| p.id == *id) {
768                let line = pane.command_line(&cmd);
769                pane.lines.push(line);
770                if pane.lines.len() > cap {
771                    let drop = pane.lines.len() - cap;
772                    pane.lines.drain(0..drop);
773                }
774            }
775        }
776        self.events.push(TerminalEvent::Command {
777            targets,
778            command: cmd,
779        });
780        self.pending.clear();
781    }
782
783    fn handle_keys(&mut self, ui: &mut Ui) {
784        // Collect events first to release the input borrow; many handlers
785        // want `&mut self` which the input closure can't hold.
786        let events: Vec<Event> = ui.ctx().input(|i| i.events.clone());
787        for event in events {
788            match event {
789                Event::Key {
790                    key,
791                    pressed: true,
792                    modifiers,
793                    ..
794                } => {
795                    if modifiers.matches_exact(Modifiers::COMMAND)
796                        || modifiers.matches_exact(Modifiers::CTRL)
797                    {
798                        match key {
799                            Key::A => self.broadcast_all(),
800                            Key::D => self.solo_focused(),
801                            _ => {}
802                        }
803                        continue;
804                    }
805                    if modifiers.any() {
806                        // Let other shortcuts fall through untouched.
807                        continue;
808                    }
809                    match key {
810                        Key::Enter => self.run_pending(),
811                        Key::Escape => self.pending.clear(),
812                        Key::Backspace => {
813                            self.pending.pop();
814                        }
815                        _ => {}
816                    }
817                }
818                Event::Text(text) => {
819                    for ch in text.chars() {
820                        if !ch.is_control() {
821                            self.pending.push(ch);
822                        }
823                    }
824                }
825                _ => {}
826            }
827        }
828    }
829
830    // ---- Painting ------------------------------------------------------
831
832    fn ui_gridbar(&mut self, ui: &mut Ui, theme: &Theme) {
833        let palette = &theme.palette;
834        let typo = &theme.typography;
835        let connected = self.connected_count();
836        let targets = self.target_ids();
837        let targets_len = targets.len();
838
839        let height = 36.0;
840        let (rect, _resp) =
841            ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
842        let painter = ui.painter_at(rect);
843
844        // Fill + top-of-grid rounded corners.
845        painter.rect(
846            rect,
847            CornerRadius {
848                nw: theme.card_radius as u8,
849                ne: theme.card_radius as u8,
850                sw: 0,
851                se: 0,
852            },
853            palette.card,
854            Stroke::new(1.0, palette.border),
855            StrokeKind::Inside,
856        );
857
858        // Broadcast-fraction underline on the bottom edge of the gridbar.
859        // Widens with how many panes are receiving; gives a felt sense of
860        // reach at a glance.
861        if connected > 0 {
862            let frac = (targets_len as f32 / connected as f32).clamp(0.0, 1.0);
863            let bar_top = rect.bottom() - 1.5;
864            let bar_rect = Rect::from_min_max(
865                Pos2::new(rect.left(), bar_top),
866                Pos2::new(rect.left() + rect.width() * frac, rect.bottom()),
867            );
868            painter.rect_filled(bar_rect, CornerRadius::ZERO, palette.sky);
869        }
870
871        // Mode pill.
872        let (mode_label, mode_style) = self.derive_mode(targets_len, connected);
873        let mut cursor_x = rect.left() + 14.0;
874        let y_mid = rect.center().y;
875
876        cursor_x += self.paint_mode_pill(
877            &painter,
878            Pos2::new(cursor_x, y_mid),
879            mode_label,
880            mode_style,
881            palette,
882            typo,
883        );
884        cursor_x += 10.0;
885
886        // Target summary (truncated if too many hosts).
887        let summary = self.target_summary(&targets, targets_len, connected);
888        let summary_color = if targets_len == 0 {
889            palette.warning
890        } else {
891            palette.text_muted
892        };
893        // Reserve space on the right for buttons so the summary can be
894        // clipped without overlapping them.
895        let right_reserve = 280.0;
896        let max_text_right = (rect.right() - right_reserve).max(cursor_x + 40.0);
897        let summary_job = summary_layout(
898            &summary,
899            palette,
900            typo.label,
901            summary_color,
902            max_text_right - cursor_x,
903        );
904        let galley = painter.layout_job(summary_job);
905        painter.galley(
906            Pos2::new(cursor_x, y_mid - galley.size().y * 0.5),
907            galley,
908            palette.text_muted,
909        );
910
911        // Right-aligned "All on" toggle. Solo lives on each pane's header;
912        // manual per-pane broadcast toggles cover every other case.
913        let mut x = rect.right() - 10.0;
914        let all_on = connected > 0 && targets_len == connected;
915
916        let all_w = qa_button(
917            ui,
918            rect,
919            &mut x,
920            self.id_salt.with("qa-all"),
921            "All on",
922            Some("\u{2318}A"),
923            all_on,
924            theme,
925        );
926        if all_w.clicked {
927            self.broadcast_all();
928            // Clicking the button grabs egui's focus; hand it back to the
929            // widget so the next keystroke still lands in the panes.
930            ui.ctx().memory_mut(|m| m.request_focus(self.id_salt));
931        }
932    }
933
934    fn target_summary(&self, targets: &[String], n: usize, connected: usize) -> String {
935        if n == 0 {
936            return "No reachable terminals".into();
937        }
938        let phrase = if n == 1 {
939            "Sending to"
940        } else if n == connected {
941            "Broadcasting to ALL"
942        } else {
943            "Broadcasting to"
944        };
945        let hosts: Vec<&str> = targets
946            .iter()
947            .filter_map(|id| self.pane(id).map(|p| p.host.as_str()))
948            .collect();
949        let shown = if hosts.len() <= 3 {
950            hosts.join(", ")
951        } else {
952            format!("{}, +{} more", hosts[..2].join(", "), hosts.len() - 2)
953        };
954        format!("{phrase} {n} \u{00b7} {shown}")
955    }
956
957    fn paint_mode_pill(
958        &self,
959        painter: &egui::Painter,
960        left_center: Pos2,
961        label: &str,
962        style: ModePillStyle,
963        palette: &Palette,
964        typo: &Typography,
965    ) -> f32 {
966        let text_color = match style {
967            ModePillStyle::Single => palette.text_muted,
968            ModePillStyle::Selected => palette.sky,
969            ModePillStyle::All => Color32::from_rgb(0x0f, 0x17, 0x2a),
970        };
971        let (fill, border) = match style {
972            ModePillStyle::Single => (palette.input_bg, palette.border),
973            ModePillStyle::Selected => (with_alpha(palette.sky, 22), with_alpha(palette.sky, 90)),
974            ModePillStyle::All => (palette.sky, palette.sky),
975        };
976
977        let galley = painter.layout_no_wrap(
978            label.to_string(),
979            FontId::new(typo.small - 1.5, FontFamily::Proportional),
980            text_color,
981        );
982        let pad_x = 7.0;
983        let pill_h = galley.size().y + 4.0;
984        let pill_w = galley.size().x + pad_x * 2.0;
985        let pill_rect = Rect::from_center_size(
986            Pos2::new(left_center.x + pill_w * 0.5, left_center.y),
987            Vec2::new(pill_w, pill_h),
988        );
989        painter.rect(
990            pill_rect,
991            CornerRadius::same((pill_h * 0.5) as u8),
992            fill,
993            Stroke::new(1.0, border),
994            StrokeKind::Inside,
995        );
996        painter.galley(
997            Pos2::new(
998                pill_rect.left() + pad_x,
999                pill_rect.center().y - galley.size().y * 0.5,
1000            ),
1001            galley,
1002            text_color,
1003        );
1004        pill_w
1005    }
1006
1007    fn derive_mode(&self, targets: usize, connected: usize) -> (&'static str, ModePillStyle) {
1008        if targets == 0 {
1009            ("NO TARGET", ModePillStyle::Single)
1010        } else if targets == 1 {
1011            ("SINGLE", ModePillStyle::Single)
1012        } else if targets == connected {
1013            ("ALL", ModePillStyle::All)
1014        } else {
1015            ("SELECTED", ModePillStyle::Selected)
1016        }
1017    }
1018
1019    fn ui_grid(&mut self, ui: &mut Ui, theme: &Theme) {
1020        let palette = &theme.palette;
1021        let full_w = ui.available_width();
1022        ui.spacing_mut().item_spacing.y = 0.0;
1023
1024        let inner_pad = 12.0;
1025        let gap = 12.0;
1026
1027        // Resolve the column count from the configured mode. Auto picks
1028        // the largest column count that keeps every column at least
1029        // `min_col_width` wide.
1030        let inner_w_for_cols = (full_w - inner_pad * 2.0).max(0.0);
1031        // Column count: Fixed modes use the caller's number; Auto first
1032        // finds the cap allowed by the available width, then balances rows
1033        // by using the *smallest* column count that still fits in that cap.
1034        // For 4 panes on a 3-col-capable screen this gives 2+2, not 3+1.
1035        let max_cols_from_width = |min_col_width: f32| -> usize {
1036            ((inner_w_for_cols + gap) / (min_col_width + gap))
1037                .floor()
1038                .max(1.0) as usize
1039        };
1040        let pane_count = self.panes.len().max(1);
1041        let cols_raw = match self.columns_mode {
1042            ColumnsMode::Fixed(n) => n,
1043            ColumnsMode::Auto { min_col_width } => {
1044                let max_cols = max_cols_from_width(min_col_width).min(pane_count);
1045                let rows = pane_count.div_ceil(max_cols);
1046                pane_count.div_ceil(rows)
1047            }
1048        };
1049        let cols = cols_raw.max(1).min(pane_count);
1050        let n_rows = self.panes.len().div_ceil(cols);
1051
1052        // Per-row heights: a row where every pane is collapsed shrinks
1053        // to the header height so 16 idle panes don't hog the viewport.
1054        let header_only_h = PANE_HEADER_HEIGHT;
1055        let row_heights: Vec<f32> = (0..n_rows)
1056            .map(|row| {
1057                let any_expanded = (0..cols).any(|col| {
1058                    let idx = row * cols + col;
1059                    idx < self.panes.len() && !self.collapsed.contains(&self.panes[idx].id)
1060                });
1061                if any_expanded {
1062                    self.pane_min_height
1063                } else {
1064                    header_only_h
1065                }
1066            })
1067            .collect();
1068        let total_h = if self.panes.is_empty() {
1069            60.0
1070        } else {
1071            inner_pad * 2.0
1072                + row_heights.iter().sum::<f32>()
1073                + (n_rows.saturating_sub(1)) as f32 * gap
1074        };
1075
1076        let (outer_rect, _resp) =
1077            ui.allocate_exact_size(Vec2::new(full_w, total_h), Sense::hover());
1078
1079        ui.painter().rect(
1080            outer_rect,
1081            CornerRadius {
1082                nw: 0,
1083                ne: 0,
1084                sw: theme.card_radius as u8,
1085                se: theme.card_radius as u8,
1086            },
1087            palette.card,
1088            Stroke::new(1.0, palette.border),
1089            StrokeKind::Inside,
1090        );
1091
1092        if self.panes.is_empty() {
1093            ui.painter().text(
1094                outer_rect.center(),
1095                Align2::CENTER_CENTER,
1096                "No terminals",
1097                FontId::proportional(theme.typography.body),
1098                palette.text_faint,
1099            );
1100            return;
1101        }
1102
1103        let inner = outer_rect.shrink(inner_pad);
1104        let cell_w = (inner.width() - gap * (cols as f32 - 1.0)) / cols as f32;
1105
1106        // Collect click intents across panes so we can apply mutations
1107        // after the read-only iteration.
1108        let mut intent_focus: Option<String> = None;
1109        let mut intent_toggle: Option<String> = None;
1110        let mut intent_solo: Option<String> = None;
1111        let mut intent_collapse: Option<String> = None;
1112
1113        // Rolling vertical cursor so variable-height rows stack tidily.
1114        let mut y_cursor = inner.top();
1115        let mut row_top_for = vec![0.0_f32; n_rows];
1116        for (row, h) in row_heights.iter().enumerate() {
1117            row_top_for[row] = y_cursor;
1118            y_cursor += h + gap;
1119        }
1120
1121        for (idx, pane) in self.panes.iter().enumerate() {
1122            let row = idx / cols;
1123            let col = idx % cols;
1124            let cell_top = row_top_for[row];
1125            let cell_left = inner.left() + col as f32 * (cell_w + gap);
1126            // Collapsed panes render as just the header row at the top of
1127            // their row-slot — the space below stays empty (and stays the
1128            // same colour as the grid container so it's invisible).
1129            let is_collapsed = self.collapsed.contains(&pane.id);
1130            let cell_h = if is_collapsed {
1131                header_only_h
1132            } else {
1133                row_heights[row]
1134            };
1135            let cell_rect =
1136                Rect::from_min_size(Pos2::new(cell_left, cell_top), Vec2::new(cell_w, cell_h));
1137
1138            let is_focused = self.focused_id.as_deref() == Some(pane.id.as_str());
1139            let is_receiving =
1140                self.broadcast.contains(&pane.id) && pane.status == TerminalStatus::Connected;
1141            let is_solo = self.broadcast.len() == 1 && self.broadcast.contains(&pane.id);
1142
1143            let ctx = PaneCtx {
1144                rect: cell_rect,
1145                pane,
1146                is_focused,
1147                is_receiving,
1148                is_solo,
1149                is_collapsed,
1150                pending: if is_receiving { &self.pending } else { "" },
1151                theme,
1152                id_salt: self.id_salt.with(("pane", idx)),
1153            };
1154            let actions = draw_pane(ui, &ctx);
1155
1156            if actions.header_clicked || actions.body_clicked {
1157                intent_focus = Some(pane.id.clone());
1158            }
1159            if actions.toggle_clicked {
1160                intent_toggle = Some(pane.id.clone());
1161            }
1162            if actions.solo_clicked {
1163                intent_solo = Some(pane.id.clone());
1164            }
1165            if actions.collapse_clicked {
1166                intent_collapse = Some(pane.id.clone());
1167            }
1168        }
1169
1170        if let Some(id) = intent_focus {
1171            self.focused_id = Some(id);
1172            ui.ctx().memory_mut(|m| m.request_focus(self.id_salt));
1173        }
1174        if let Some(id) = intent_toggle {
1175            self.toggle_broadcast(&id);
1176            ui.ctx().memory_mut(|m| m.request_focus(self.id_salt));
1177        }
1178        if let Some(id) = intent_solo {
1179            self.solo(&id);
1180            ui.ctx().memory_mut(|m| m.request_focus(self.id_salt));
1181        }
1182        if let Some(id) = intent_collapse {
1183            self.toggle_collapsed(&id);
1184            ui.ctx().memory_mut(|m| m.request_focus(self.id_salt));
1185        }
1186    }
1187}
1188
1189/// Fixed header height used by pane rendering and by collapsed-row layout.
1190const PANE_HEADER_HEIGHT: f32 = 34.0;
1191
1192// ---------------------------------------------------------------------------
1193// Rendering helpers (free functions, not methods, so the borrow checker
1194// doesn't get tangled with `&self.panes`).
1195// ---------------------------------------------------------------------------
1196
1197struct PaneCtx<'a> {
1198    rect: Rect,
1199    pane: &'a TerminalPane,
1200    is_focused: bool,
1201    is_receiving: bool,
1202    /// This pane is the only member of the broadcast set.
1203    is_solo: bool,
1204    /// This pane is collapsed to a header-only strip.
1205    is_collapsed: bool,
1206    pending: &'a str,
1207    theme: &'a Theme,
1208    id_salt: Id,
1209}
1210
1211struct PaneActions {
1212    header_clicked: bool,
1213    body_clicked: bool,
1214    toggle_clicked: bool,
1215    solo_clicked: bool,
1216    collapse_clicked: bool,
1217}
1218
1219fn draw_pane(ui: &mut Ui, ctx: &PaneCtx<'_>) -> PaneActions {
1220    let palette = &ctx.theme.palette;
1221    let p = ctx.rect;
1222
1223    // Background and border.
1224    let border_color = if ctx.is_focused {
1225        palette.sky
1226    } else if ctx.is_receiving {
1227        with_alpha(palette.sky, 115)
1228    } else {
1229        palette.border
1230    };
1231    let border_stroke = Stroke::new(if ctx.is_focused { 1.5 } else { 1.0 }, border_color);
1232    ui.painter().rect(
1233        p,
1234        CornerRadius::same((ctx.theme.control_radius + 2.0) as u8),
1235        palette.card,
1236        border_stroke,
1237        StrokeKind::Inside,
1238    );
1239
1240    // Focus glow.
1241    if ctx.is_focused {
1242        ui.painter().rect_stroke(
1243            p.expand(2.0),
1244            CornerRadius::same((ctx.theme.control_radius + 4.0) as u8),
1245            Stroke::new(1.0, with_alpha(palette.sky, 50)),
1246            StrokeKind::Outside,
1247        );
1248    }
1249
1250    // Header + (optional) body layout. Collapsed panes don't render a
1251    // body — the rect is sized to just the header height by the caller.
1252    let header_rect = Rect::from_min_size(p.min, Vec2::new(p.width(), PANE_HEADER_HEIGHT));
1253    let (header_clicked, toggle_clicked, solo_clicked, collapse_clicked) =
1254        draw_pane_header(ui, header_rect, ctx);
1255
1256    let body_clicked = if ctx.is_collapsed {
1257        false
1258    } else {
1259        let body_rect = Rect::from_min_max(Pos2::new(p.left(), header_rect.bottom()), p.max);
1260        draw_pane_body(ui, body_rect, ctx)
1261    };
1262
1263    PaneActions {
1264        header_clicked,
1265        body_clicked,
1266        toggle_clicked,
1267        solo_clicked,
1268        collapse_clicked,
1269    }
1270}
1271
1272fn draw_pane_header(ui: &mut Ui, rect: Rect, ctx: &PaneCtx<'_>) -> (bool, bool, bool, bool) {
1273    let palette = &ctx.theme.palette;
1274    let typo = &ctx.theme.typography;
1275
1276    // Bottom separator under the header — only drawn when the pane is
1277    // expanded (and therefore has a body below the separator).
1278    if !ctx.is_collapsed {
1279        ui.painter().line_segment(
1280            [
1281                Pos2::new(rect.left() + 1.0, rect.bottom() - 0.5),
1282                Pos2::new(rect.right() - 1.0, rect.bottom() - 0.5),
1283            ],
1284            Stroke::new(1.0, palette.border),
1285        );
1286    }
1287
1288    // Background click area. Child widgets (chevron, solo, broadcast pill)
1289    // are drawn afterwards so their clicks take priority via egui's z-order.
1290    let header_resp = ui.interact(rect, ctx.id_salt.with("header"), Sense::click());
1291
1292    // Chevron at the far left. Click to collapse / expand this pane.
1293    let edge_pad = 6.0;
1294    let (collapse_clicked, chev_w) = draw_chevron_button(ui, ctx, rect, edge_pad);
1295
1296    // Hostname, offset to the right of the chevron.
1297    let pad_x = 13.0;
1298    let host_x = rect.left() + edge_pad + chev_w + 6.0;
1299    let mut job = LayoutJob::default();
1300    job.append(
1301        &ctx.pane.host,
1302        0.0,
1303        TextFormat {
1304            font_id: FontId::monospace(typo.small + 0.5),
1305            color: palette.text,
1306            ..Default::default()
1307        },
1308    );
1309    job.append(
1310        &format!("@{}", ctx.pane.user),
1311        0.0,
1312        TextFormat {
1313            font_id: FontId::monospace(typo.small + 0.5),
1314            color: palette.text_faint,
1315            ..Default::default()
1316        },
1317    );
1318    let galley = ui.painter().layout_job(job);
1319    ui.painter().galley(
1320        Pos2::new(host_x, rect.center().y - galley.size().y * 0.5),
1321        galley,
1322        palette.text,
1323    );
1324
1325    // Status indicator on the right — same glyph set as the library's
1326    // `Indicator` widget (On / Connecting / Off).
1327    let ind_size = 10.0;
1328    let ind_center = Pos2::new(rect.right() - pad_x - ind_size * 0.5, rect.center().y);
1329    paint_status_indicator(ui.painter(), ind_center, ctx.pane.status, palette, ind_size);
1330
1331    // Broadcast toggle pill, sitting between the hostname and the indicator.
1332    let bc_rect_right = ind_center.x - ind_size * 0.5 - 8.0;
1333    let (toggle_clicked, bc_w) = draw_broadcast_pill(ui, ctx, bc_rect_right, rect.center().y);
1334
1335    // Solo button sits to the left of the broadcast pill.
1336    let solo_right = bc_rect_right - bc_w - 6.0;
1337    let (solo_clicked, _solo_w) = draw_solo_button(ui, ctx, solo_right, rect.center().y);
1338
1339    (
1340        header_resp.clicked(),
1341        toggle_clicked,
1342        solo_clicked,
1343        collapse_clicked,
1344    )
1345}
1346
1347/// Chevron button at the left edge of a pane header. Triangle pointing
1348/// down when the pane is expanded, right when it's collapsed.
1349///
1350/// Returns `(clicked, width)`.
1351fn draw_chevron_button(ui: &mut Ui, ctx: &PaneCtx<'_>, header: Rect, edge_pad: f32) -> (bool, f32) {
1352    let palette = &ctx.theme.palette;
1353    let size = 18.0;
1354    let rect = Rect::from_center_size(
1355        Pos2::new(header.left() + edge_pad + size * 0.5, header.center().y),
1356        Vec2::splat(size),
1357    );
1358    let resp = ui.interact(rect, ctx.id_salt.with("chev"), Sense::click());
1359    let color = if resp.hovered() {
1360        palette.text
1361    } else {
1362        palette.text_muted
1363    };
1364
1365    // Small triangle centred in the button.
1366    let c = rect.center();
1367    let h = 3.5; // half-size of the triangle
1368    let pts = if ctx.is_collapsed {
1369        // Pointing right: ▸
1370        vec![
1371            Pos2::new(c.x - h * 0.7, c.y - h),
1372            Pos2::new(c.x - h * 0.7, c.y + h),
1373            Pos2::new(c.x + h, c.y),
1374        ]
1375    } else {
1376        // Pointing down: ▾
1377        vec![
1378            Pos2::new(c.x - h, c.y - h * 0.7),
1379            Pos2::new(c.x + h, c.y - h * 0.7),
1380            Pos2::new(c.x, c.y + h),
1381        ]
1382    };
1383    ui.painter()
1384        .add(egui::Shape::convex_polygon(pts, color, Stroke::NONE));
1385
1386    (resp.clicked(), size)
1387}
1388
1389/// Paint the connection indicator glyph at `center`. Mirrors the library's
1390/// [`Indicator`](crate::Indicator) widget so the pane header shares the
1391/// same visual vocabulary.
1392fn paint_status_indicator(
1393    painter: &egui::Painter,
1394    center: Pos2,
1395    status: TerminalStatus,
1396    palette: &Palette,
1397    size: f32,
1398) {
1399    let r = size * 0.5;
1400    match status {
1401        TerminalStatus::Connected => {
1402            painter.circle_filled(center, r + 1.5, with_alpha(palette.success, 70));
1403            painter.circle_filled(center, r, palette.success);
1404        }
1405        TerminalStatus::Reconnecting => {
1406            painter.circle_stroke(center, r - 0.5, Stroke::new(1.8, palette.warning));
1407        }
1408        TerminalStatus::Offline => {
1409            painter.circle_stroke(center, r - 0.5, Stroke::new(1.0, palette.danger));
1410            let bar_w = size * 0.7;
1411            let bar_h = 2.0;
1412            let bar = Rect::from_center_size(center, Vec2::new(bar_w, bar_h));
1413            painter.rect_filled(bar, CornerRadius::same(1), palette.danger);
1414        }
1415    }
1416}
1417
1418fn draw_broadcast_pill(ui: &mut Ui, ctx: &PaneCtx<'_>, right_edge: f32, y_mid: f32) -> (bool, f32) {
1419    let palette = &ctx.theme.palette;
1420    let dim = ctx.pane.status != TerminalStatus::Connected;
1421
1422    // Compact icon-only toggle: a broadcast-waves glyph (dot with arcs
1423    // flanking it on both sides) inside a rounded pill.
1424    let pill_w = 34.0;
1425    let pill_h = 22.0;
1426    let rect = Rect::from_min_size(
1427        Pos2::new(right_edge - pill_w, y_mid - pill_h * 0.5),
1428        Vec2::new(pill_w, pill_h),
1429    );
1430
1431    let resp = ui.interact(rect, ctx.id_salt.with("bcast"), Sense::click());
1432    let hovered = resp.hovered() && !dim;
1433
1434    let (fill, border, icon_color) = if ctx.is_receiving {
1435        // On: sky fill; hover slightly lifts it so the press is felt.
1436        let fill = if hovered {
1437            palette.depth_tint(palette.sky, 0.12)
1438        } else {
1439            palette.sky
1440        };
1441        (fill, palette.sky, Color32::from_rgb(0x0f, 0x17, 0x2a))
1442    } else if hovered {
1443        // Off + hovered: preview the "on" state with a faint sky tint so
1444        // the affordance is obvious — clicking will turn it sky.
1445        (
1446            with_alpha(palette.sky, 26),
1447            with_alpha(palette.sky, 130),
1448            palette.sky,
1449        )
1450    } else {
1451        (Color32::TRANSPARENT, palette.border, palette.text_faint)
1452    };
1453
1454    ui.painter().rect(
1455        rect,
1456        CornerRadius::same((pill_h * 0.5) as u8),
1457        fill,
1458        Stroke::new(1.0, border),
1459        StrokeKind::Inside,
1460    );
1461
1462    // Pulse halo behind the centre dot while receiving.
1463    let center = rect.center();
1464    if ctx.is_receiving {
1465        let t = ui.input(|i| i.time);
1466        let phase = (t.rem_euclid(1.2) / 1.2) as f32;
1467        let halo_r = 2.0 + phase.min(1.0) * 4.5;
1468        let halo_a = (70.0 * (1.0 - phase)).clamp(0.0, 255.0) as u8;
1469        ui.painter()
1470            .circle_filled(center, halo_r, with_alpha(icon_color, halo_a));
1471    }
1472
1473    paint_broadcast_glyph(ui.painter(), center, icon_color);
1474
1475    (if dim { false } else { resp.clicked() }, pill_w)
1476}
1477
1478/// Broadcast-waves glyph: centre dot with two symmetric arcs emanating
1479/// outward on both sides. Rendered at `center`, roughly 18 pt wide and
1480/// 8 pt tall so it fits comfortably inside a pill.
1481fn paint_broadcast_glyph(painter: &egui::Painter, center: Pos2, color: Color32) {
1482    // Centre source dot.
1483    painter.circle_filled(center, 1.8, color);
1484
1485    let stroke = Stroke::new(1.2, color);
1486    // Inner arcs (radius ~4.5) and outer arcs (radius ~7.5) on each side.
1487    // Angles are measured in radians; 0 points right, so "right arc" spans
1488    // roughly [-span, +span] around 0 and "left arc" spans [PI - span, PI + span].
1489    use std::f32::consts::PI;
1490    paint_arc(painter, center, 4.5, -0.45, 0.45, stroke);
1491    paint_arc(painter, center, 4.5, PI - 0.45, PI + 0.45, stroke);
1492    paint_arc(painter, center, 7.5, -0.32, 0.32, stroke);
1493    paint_arc(painter, center, 7.5, PI - 0.32, PI + 0.32, stroke);
1494}
1495
1496/// Approximate an arc with a short line-segment polyline.
1497fn paint_arc(
1498    painter: &egui::Painter,
1499    center: Pos2,
1500    radius: f32,
1501    start: f32,
1502    end: f32,
1503    stroke: Stroke,
1504) {
1505    const STEPS: usize = 8;
1506    let mut pts = Vec::with_capacity(STEPS + 1);
1507    for i in 0..=STEPS {
1508        let t = i as f32 / STEPS as f32;
1509        let a = start + (end - start) * t;
1510        pts.push(Pos2::new(
1511            center.x + radius * a.cos(),
1512            center.y + radius * a.sin(),
1513        ));
1514    }
1515    painter.add(egui::Shape::line(pts, stroke));
1516}
1517
1518/// Per-pane solo button: a small round target-icon button. Clicking makes
1519/// this pane the only member of the broadcast set; clicking again (while
1520/// already solo) restores the prior set.
1521///
1522/// Returns `(clicked, width)`.
1523fn draw_solo_button(ui: &mut Ui, ctx: &PaneCtx<'_>, right_edge: f32, y_mid: f32) -> (bool, f32) {
1524    let palette = &ctx.theme.palette;
1525    let dim = ctx.pane.status != TerminalStatus::Connected;
1526
1527    let size = 22.0;
1528    let rect = Rect::from_min_size(
1529        Pos2::new(right_edge - size, y_mid - size * 0.5),
1530        Vec2::splat(size),
1531    );
1532
1533    let resp = ui.interact(rect, ctx.id_salt.with("solo"), Sense::click());
1534    let hovered = resp.hovered() && !dim;
1535
1536    let (fill, border, icon_color) = if ctx.is_solo {
1537        (with_alpha(palette.sky, 28), palette.sky, palette.sky)
1538    } else if hovered {
1539        (Color32::TRANSPARENT, palette.text_muted, palette.text)
1540    } else {
1541        (Color32::TRANSPARENT, palette.border, palette.text_faint)
1542    };
1543
1544    ui.painter().rect(
1545        rect,
1546        CornerRadius::same((size * 0.5) as u8),
1547        fill,
1548        Stroke::new(1.0, border),
1549        StrokeKind::Inside,
1550    );
1551
1552    // Solo glyph: 2x2 grid with only the top-left cell filled. Pairs
1553    // visually with the All-on button's four-cell grid and reads as
1554    // "just this one of the many".
1555    paint_solo_icon(ui.painter(), rect.center(), icon_color);
1556
1557    (if dim { false } else { resp.clicked() }, size)
1558}
1559
1560fn paint_solo_icon(painter: &egui::Painter, center: Pos2, color: Color32) {
1561    let pad = 1.0;
1562    let cell = 5.5;
1563    let cells = [
1564        (-cell - pad, -cell - pad, true),
1565        (pad, -cell - pad, false),
1566        (-cell - pad, pad, false),
1567        (pad, pad, false),
1568    ];
1569    for (dx, dy, filled) in cells {
1570        let r = Rect::from_min_size(Pos2::new(center.x + dx, center.y + dy), Vec2::splat(cell));
1571        if filled {
1572            painter.rect_filled(r, CornerRadius::same(1), color);
1573        } else {
1574            painter.rect_stroke(
1575                r,
1576                CornerRadius::same(1),
1577                Stroke::new(1.2, color),
1578                StrokeKind::Inside,
1579            );
1580        }
1581    }
1582}
1583
1584/// Returns true if the body area was clicked.
1585fn draw_pane_body(ui: &mut Ui, rect: Rect, ctx: &PaneCtx<'_>) -> bool {
1586    let palette = &ctx.theme.palette;
1587    let typo = &ctx.theme.typography;
1588
1589    // Terminal-bg fill (darker than the card, like a screen).
1590    let term_bg = palette.depth_tint(palette.input_bg, 0.015);
1591    ui.painter().rect_filled(
1592        rect.shrink2(Vec2::new(1.0, 1.0)),
1593        CornerRadius {
1594            nw: 0,
1595            ne: 0,
1596            sw: (ctx.theme.control_radius + 1.0) as u8,
1597            se: (ctx.theme.control_radius + 1.0) as u8,
1598        },
1599        term_bg,
1600    );
1601
1602    let body_resp = ui.interact(rect, ctx.id_salt.with("body"), Sense::click());
1603
1604    // Render the lines inside a child UI so we can use ScrollArea.
1605    let mut child = ui.new_child(
1606        egui::UiBuilder::new()
1607            .max_rect(rect.shrink(8.0))
1608            .layout(egui::Layout::top_down(egui::Align::Min)),
1609    );
1610    child.spacing_mut().item_spacing.y = 2.0;
1611
1612    egui::ScrollArea::vertical()
1613        .id_salt(ctx.id_salt.with("scroll"))
1614        .auto_shrink([false, false])
1615        .stick_to_bottom(true)
1616        .show(&mut child, |ui| {
1617            for line in &ctx.pane.lines {
1618                paint_line(ui, line, palette, typo);
1619            }
1620            paint_live_prompt(ui, ctx, palette, typo);
1621        });
1622
1623    body_resp.clicked()
1624}
1625
1626fn paint_line(ui: &mut Ui, line: &TerminalLine, palette: &Palette, typo: &Typography) {
1627    let size = typo.small + 0.5;
1628    let font = FontId::monospace(size);
1629    let wrap_width = ui.available_width();
1630
1631    match &line.kind {
1632        LineKind::Command {
1633            user,
1634            host,
1635            cwd,
1636            cmd,
1637        } => {
1638            let mut job = LayoutJob::default();
1639            // `LayoutJob`s don't wrap by default; constrain them to the
1640            // pane's current content width so long commands wrap like
1641            // output lines do. `break_anywhere` lets unbroken tokens
1642            // (URLs, paths, pasted blobs) wrap mid-character.
1643            job.wrap.max_width = wrap_width;
1644            job.wrap.break_anywhere = true;
1645            job.append(
1646                &format!("{user}@{host}"),
1647                0.0,
1648                TextFormat {
1649                    font_id: font.clone(),
1650                    color: palette.success,
1651                    ..Default::default()
1652                },
1653            );
1654            job.append(
1655                ":",
1656                0.0,
1657                TextFormat {
1658                    font_id: font.clone(),
1659                    color: palette.text_muted,
1660                    ..Default::default()
1661                },
1662            );
1663            job.append(
1664                cwd,
1665                0.0,
1666                TextFormat {
1667                    font_id: font.clone(),
1668                    color: palette.purple,
1669                    ..Default::default()
1670                },
1671            );
1672            job.append(
1673                "$ ",
1674                0.0,
1675                TextFormat {
1676                    font_id: font.clone(),
1677                    color: palette.text_muted,
1678                    ..Default::default()
1679                },
1680            );
1681            job.append(
1682                cmd,
1683                0.0,
1684                TextFormat {
1685                    font_id: font,
1686                    color: palette.text,
1687                    ..Default::default()
1688                },
1689            );
1690            ui.label(job);
1691        }
1692        other => {
1693            let color = color_for_kind(other, palette);
1694            let italic = matches!(other, LineKind::Info);
1695            let rich = egui::RichText::new(&line.text).font(font).color(color);
1696            let rich = if italic { rich.italics() } else { rich };
1697            ui.add(egui::Label::new(rich).wrap());
1698        }
1699    }
1700}
1701
1702fn paint_live_prompt(ui: &mut Ui, ctx: &PaneCtx<'_>, palette: &Palette, typo: &Typography) {
1703    let size = typo.small + 0.5;
1704    let font = FontId::monospace(size);
1705    let pane = ctx.pane;
1706
1707    let mut job = LayoutJob::default();
1708    // Reserve space for the caret block at the end so the prompt wraps
1709    // before the caret falls off the right edge.
1710    job.wrap.max_width = (ui.available_width() - 10.0).max(40.0);
1711    // Typed text in a terminal is usually one unbroken token (no spaces),
1712    // so `break_anywhere` is required to wrap it mid-character. Without
1713    // this the pending buffer overflows past the pane's right edge.
1714    job.wrap.break_anywhere = true;
1715    job.append(
1716        &format!("{}@{}", pane.user, pane.host),
1717        0.0,
1718        TextFormat {
1719            font_id: font.clone(),
1720            color: palette.success,
1721            ..Default::default()
1722        },
1723    );
1724    job.append(
1725        ":",
1726        0.0,
1727        TextFormat {
1728            font_id: font.clone(),
1729            color: palette.text_muted,
1730            ..Default::default()
1731        },
1732    );
1733    job.append(
1734        &pane.cwd,
1735        0.0,
1736        TextFormat {
1737            font_id: font.clone(),
1738            color: palette.purple,
1739            ..Default::default()
1740        },
1741    );
1742    job.append(
1743        "$ ",
1744        0.0,
1745        TextFormat {
1746            font_id: font.clone(),
1747            color: palette.text_muted,
1748            ..Default::default()
1749        },
1750    );
1751    if !ctx.pending.is_empty() {
1752        job.append(
1753            ctx.pending,
1754            0.0,
1755            TextFormat {
1756                font_id: font.clone(),
1757                color: palette.sky,
1758                ..Default::default()
1759            },
1760        );
1761    }
1762
1763    // Lay out the wrapped prompt ourselves (without a horizontal wrapper,
1764    // whose effectively-unbounded available width can override the job's
1765    // wrap cap) and paint the caret at the end of the last wrapped row.
1766    let galley = ui.painter().layout_job(job);
1767    let caret_h = size + 2.0;
1768    let caret_w = 7.0;
1769    let total_size = Vec2::new(
1770        galley.size().x + caret_w + 2.0,
1771        galley.size().y.max(caret_h),
1772    );
1773    let (rect, _resp) = ui.allocate_exact_size(total_size, Sense::hover());
1774    let galley_origin = rect.min;
1775
1776    // Remember where the last row ends before we move the Arc into painter.galley.
1777    let last_row = galley.rows.last();
1778    let caret_x = galley_origin.x + last_row.map(|r| r.rect().right()).unwrap_or(0.0) + 1.0;
1779    let caret_y = galley_origin.y
1780        + last_row
1781            .map(|r| r.rect().center().y)
1782            .unwrap_or(galley.size().y * 0.5);
1783
1784    ui.painter().galley(galley_origin, galley, palette.text);
1785
1786    let caret_rect = Rect::from_min_size(
1787        Pos2::new(caret_x, caret_y - caret_h * 0.5),
1788        Vec2::new(caret_w, caret_h),
1789    );
1790    let caret_color = if ctx.is_receiving {
1791        palette.sky
1792    } else {
1793        with_alpha(palette.text_faint, 80)
1794    };
1795    ui.painter()
1796        .rect_filled(caret_rect, CornerRadius::ZERO, caret_color);
1797}
1798
1799fn color_for_kind(kind: &LineKind, palette: &Palette) -> Color32 {
1800    match kind {
1801        LineKind::Out => palette.text,
1802        LineKind::Info => palette.text_faint,
1803        LineKind::Ok => palette.success,
1804        LineKind::Warn => palette.warning,
1805        LineKind::Err => palette.danger,
1806        LineKind::Dim => palette.text_muted,
1807        LineKind::Command { .. } => palette.text,
1808    }
1809}
1810
1811fn summary_layout(
1812    text: &str,
1813    palette: &Palette,
1814    size: f32,
1815    color: Color32,
1816    max_width: f32,
1817) -> LayoutJob {
1818    let mut job = LayoutJob::default();
1819    job.wrap.max_width = max_width;
1820    job.wrap.max_rows = 1;
1821    job.wrap.break_anywhere = true;
1822    job.wrap.overflow_character = Some('\u{2026}');
1823    job.append(
1824        text,
1825        0.0,
1826        TextFormat {
1827            font_id: FontId::new(size, FontFamily::Proportional),
1828            color,
1829            ..Default::default()
1830        },
1831    );
1832    let _ = palette;
1833    job
1834}
1835
1836// ---------------------------------------------------------------------------
1837// "All on" toggle button in the gridbar.
1838// ---------------------------------------------------------------------------
1839
1840struct QaResult {
1841    clicked: bool,
1842}
1843
1844#[allow(clippy::too_many_arguments)]
1845fn qa_button(
1846    ui: &mut Ui,
1847    bar_rect: Rect,
1848    x_right: &mut f32,
1849    id: Id,
1850    label: &str,
1851    shortcut: Option<&str>,
1852    active: bool,
1853    theme: &Theme,
1854) -> QaResult {
1855    let palette = &theme.palette;
1856    let typo = &theme.typography;
1857    let font = FontId::new(typo.small, FontFamily::Proportional);
1858    let label_galley = ui
1859        .painter()
1860        .layout_no_wrap(label.to_string(), font.clone(), palette.text);
1861
1862    let kbd_font = FontId::monospace(typo.small - 1.5);
1863    let kbd_galley = shortcut.map(|s| {
1864        ui.painter()
1865            .layout_no_wrap(s.to_string(), kbd_font.clone(), palette.text_faint)
1866    });
1867
1868    let icon_w = 16.0;
1869    let pad_x = 8.0;
1870    let label_w = label_galley.size().x;
1871    let kbd_w = kbd_galley.as_ref().map(|g| g.size().x + 8.0).unwrap_or(0.0);
1872    let btn_w = icon_w + 6.0 + label_w + kbd_w + pad_x * 2.0;
1873    let btn_h = bar_rect.height() - 10.0;
1874    let btn_rect = Rect::from_min_size(
1875        Pos2::new(*x_right - btn_w, bar_rect.center().y - btn_h * 0.5),
1876        Vec2::new(btn_w, btn_h),
1877    );
1878    *x_right = btn_rect.left() - 4.0;
1879
1880    let resp = ui.interact(btn_rect, id, Sense::click());
1881    let hover = resp.hovered();
1882
1883    let (fg, border, fill) = if active {
1884        (
1885            palette.sky,
1886            with_alpha(palette.sky, 110),
1887            with_alpha(palette.sky, 22),
1888        )
1889    } else if hover {
1890        (palette.text, palette.text_muted, Color32::TRANSPARENT)
1891    } else {
1892        (palette.text_muted, palette.border, Color32::TRANSPARENT)
1893    };
1894
1895    ui.painter().rect(
1896        btn_rect,
1897        CornerRadius::same(theme.control_radius as u8),
1898        fill,
1899        Stroke::new(1.0, border),
1900        StrokeKind::Inside,
1901    );
1902
1903    // Icon: 2x2 grid of small squares matching the pane-grid metaphor.
1904    let icon_center = Pos2::new(btn_rect.left() + pad_x + icon_w * 0.5, btn_rect.center().y);
1905    paint_grid_icon(ui.painter(), icon_center, fg);
1906
1907    // Label.
1908    let label_x = btn_rect.left() + pad_x + icon_w + 6.0;
1909    let label_galley2 = ui
1910        .painter()
1911        .layout_no_wrap(label.to_string(), font.clone(), fg);
1912    ui.painter().galley(
1913        Pos2::new(label_x, btn_rect.center().y - label_galley2.size().y * 0.5),
1914        label_galley2,
1915        fg,
1916    );
1917
1918    // Shortcut pill (right-aligned).
1919    if let Some(kbd) = shortcut {
1920        let kbd_galley2 =
1921            ui.painter()
1922                .layout_no_wrap(kbd.to_string(), kbd_font.clone(), palette.text_faint);
1923        let kbd_rect = Rect::from_min_size(
1924            Pos2::new(
1925                btn_rect.right() - pad_x - kbd_galley2.size().x - 8.0,
1926                btn_rect.center().y - (kbd_galley2.size().y + 2.0) * 0.5,
1927            ),
1928            Vec2::new(kbd_galley2.size().x + 8.0, kbd_galley2.size().y + 2.0),
1929        );
1930        ui.painter().rect(
1931            kbd_rect,
1932            CornerRadius::same(3),
1933            palette.input_bg,
1934            Stroke::new(1.0, palette.border),
1935            StrokeKind::Inside,
1936        );
1937        ui.painter().galley(
1938            Pos2::new(
1939                kbd_rect.left() + 4.0,
1940                kbd_rect.center().y - kbd_galley2.size().y * 0.5,
1941            ),
1942            kbd_galley2,
1943            palette.text_faint,
1944        );
1945    }
1946
1947    QaResult {
1948        clicked: resp.clicked(),
1949    }
1950}
1951
1952/// 2x2 grid glyph drawn at `center`. Used as the "All on" button's icon.
1953fn paint_grid_icon(painter: &egui::Painter, center: Pos2, color: Color32) {
1954    let pad = 1.0;
1955    let size = 5.5;
1956    for (dx, dy) in &[
1957        (-size - pad, -size - pad),
1958        (pad, -size - pad),
1959        (-size - pad, pad),
1960        (pad, pad),
1961    ] {
1962        let r = Rect::from_min_size(Pos2::new(center.x + dx, center.y + dy), Vec2::splat(size));
1963        painter.rect_stroke(
1964            r,
1965            CornerRadius::same(1),
1966            Stroke::new(1.2, color),
1967            StrokeKind::Inside,
1968        );
1969    }
1970}
1971
1972#[derive(Clone, Copy)]
1973enum ModePillStyle {
1974    Single,
1975    Selected,
1976    All,
1977}
1978
1979fn with_alpha(c: Color32, a: u8) -> Color32 {
1980    Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a)
1981}