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