1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
99pub enum TerminalStatus {
100 Connected,
102 Reconnecting,
105 Offline,
107}
108
109impl TerminalStatus {
110 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#[derive(Clone, Debug, PartialEq, Eq)]
123pub enum LineKind {
124 Out,
126 Info,
128 Ok,
130 Warn,
132 Err,
134 Dim,
136 Command {
141 user: String,
143 host: String,
145 cwd: String,
147 cmd: String,
149 },
150}
151
152#[derive(Clone, Debug)]
154pub struct TerminalLine {
155 pub kind: LineKind,
157 pub text: String,
159}
160
161impl TerminalLine {
162 pub fn new(kind: LineKind, text: impl Into<String>) -> Self {
164 Self {
165 kind,
166 text: text.into(),
167 }
168 }
169
170 pub fn out(text: impl Into<String>) -> Self {
172 Self::new(LineKind::Out, text)
173 }
174 pub fn info(text: impl Into<String>) -> Self {
176 Self::new(LineKind::Info, text)
177 }
178 pub fn ok(text: impl Into<String>) -> Self {
180 Self::new(LineKind::Ok, text)
181 }
182 pub fn warn(text: impl Into<String>) -> Self {
184 Self::new(LineKind::Warn, text)
185 }
186 pub fn err(text: impl Into<String>) -> Self {
188 Self::new(LineKind::Err, text)
189 }
190 pub fn dim(text: impl Into<String>) -> Self {
192 Self::new(LineKind::Dim, text)
193 }
194
195 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#[derive(Clone, Debug)]
217pub struct TerminalPane {
218 pub id: String,
221 pub host: String,
223 pub user: String,
225 pub cwd: String,
227 pub status: TerminalStatus,
229 pub lines: Vec<TerminalLine>,
231}
232
233impl TerminalPane {
234 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 #[inline]
249 pub fn user(mut self, user: impl Into<String>) -> Self {
250 self.user = user.into();
251 self
252 }
253
254 #[inline]
256 pub fn cwd(mut self, cwd: impl Into<String>) -> Self {
257 self.cwd = cwd.into();
258 self
259 }
260
261 #[inline]
263 pub fn status(mut self, status: TerminalStatus) -> Self {
264 self.status = status;
265 self
266 }
267
268 #[inline]
270 pub fn push(mut self, line: TerminalLine) -> Self {
271 self.lines.push(line);
272 self
273 }
274
275 pub fn push_line(&mut self, line: TerminalLine) {
277 self.lines.push(line);
278 }
279
280 pub fn set_status(&mut self, status: TerminalStatus) {
282 self.status = status;
283 }
284
285 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#[derive(Clone, Debug)]
294pub enum TerminalEvent {
295 Command {
303 targets: Vec<String>,
305 command: String,
307 },
308}
309
310#[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 pending_cursor: usize,
326 history: Vec<String>,
329 history_cursor: Option<usize>,
332 history_cap: usize,
333 columns_mode: ColumnsMode,
334 pane_min_height: f32,
335 scrollback_cap: usize,
336 auto_focus: bool,
340 events: Vec<TerminalEvent>,
341}
342
343#[derive(Clone, Copy, Debug, PartialEq)]
345pub enum ColumnsMode {
346 Fixed(usize),
348 Auto {
352 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 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 #[inline]
399 pub fn with_pane(mut self, pane: TerminalPane) -> Self {
400 self.add_pane(pane);
401 self
402 }
403
404 #[inline]
410 pub fn columns(mut self, columns: usize) -> Self {
411 self.columns_mode = ColumnsMode::Fixed(columns.max(1));
412 self
413 }
414
415 #[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 #[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 #[inline]
442 pub fn scrollback_cap(mut self, n: usize) -> Self {
443 self.scrollback_cap = n.max(1);
444 self
445 }
446
447 #[inline]
450 pub fn history_cap(mut self, n: usize) -> Self {
451 self.history_cap = n.max(1);
452 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 #[inline]
464 pub fn auto_focus(mut self, enabled: bool) -> Self {
465 self.auto_focus = enabled;
466 self
467 }
468
469 pub fn add_pane(&mut self, pane: TerminalPane) {
471 if self.focused_id.is_none() {
473 self.focused_id = Some(pane.id.clone());
474 }
475 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 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 pub fn pane(&self, id: &str) -> Option<&TerminalPane> {
499 self.panes.iter().find(|p| p.id == id)
500 }
501
502 pub fn pane_mut(&mut self, id: &str) -> Option<&mut TerminalPane> {
504 self.panes.iter_mut().find(|p| p.id == id)
505 }
506
507 pub fn panes(&self) -> &[TerminalPane] {
509 &self.panes
510 }
511
512 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 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 pub fn focused(&self) -> Option<&str> {
538 self.focused_id.as_deref()
539 }
540
541 pub fn set_focused(&mut self, id: Option<String>) {
543 self.focused_id = id;
544 }
545
546 pub fn broadcast(&self) -> &HashSet<String> {
549 &self.broadcast
550 }
551
552 pub fn set_broadcast(&mut self, set: HashSet<String>) {
555 self.broadcast = set;
556 self.stashed = None;
557 }
558
559 pub fn is_collapsed(&self, id: &str) -> bool {
562 self.collapsed.contains(id)
563 }
564
565 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 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 pub fn collapse_all(&mut self) {
585 for p in &self.panes {
586 self.collapsed.insert(p.id.clone());
587 }
588 }
589
590 pub fn expand_all(&mut self) {
592 self.collapsed.clear();
593 }
594
595 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 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 pub fn solo_focused(&mut self) {
637 if let Some(fid) = self.focused_id.clone() {
638 self.solo(&fid);
639 }
640 }
641
642 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 self.stashed = None;
659 if all_on {
660 self.broadcast.clear();
661 } else {
662 self.broadcast = connected.into_iter().collect();
663 }
664 }
665
666 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 pub fn pending(&self) -> &str {
684 &self.pending
685 }
686
687 pub fn clear_pending(&mut self) {
689 self.pending.clear();
690 self.pending_cursor = 0;
691 }
692
693 pub fn take_events(&mut self) -> Vec<TerminalEvent> {
697 std::mem::take(&mut self.events)
698 }
699
700 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 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 let bg = ui.interact(inner.rect, focus_id, Sense::focusable_noninteractive());
722
723 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 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 fn restore_or_fallback(&mut self) {
778 if let Some(stash) = self.stashed.take() {
779 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 self.broadcast.clear();
799 }
800 }
801
802 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 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 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 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 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 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 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 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 if modifiers.matches_exact(Modifiers::CTRL) {
1014 match key {
1015 Key::C => {
1016 self.clear_pending();
1018 self.history_cursor = None;
1019 continue;
1020 }
1021 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 Key::L | Key::K => self.clear_targets(),
1041 _ => {}
1042 }
1043 continue;
1044 }
1045 if modifiers.any() {
1046 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 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 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 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 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 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 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 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 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 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 let inner_pad = 1.0;
1287 let gap = 1.0;
1288
1289 let inner_w_for_cols = (full_w - inner_pad * 2.0).max(0.0);
1293 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 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 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 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 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 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 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 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 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
1505const PANE_HEADER_HEIGHT: f32 = 34.0;
1507
1508struct PaneCtx<'a> {
1514 rect: Rect,
1515 pane: &'a TerminalPane,
1516 is_focused: bool,
1517 is_receiving: bool,
1518 is_solo: bool,
1520 is_collapsed: bool,
1522 corner_radius: CornerRadius,
1526 pending: &'a str,
1527 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 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 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 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 let header_resp = ui.interact(rect, ctx.id_salt.with("header"), Sense::click());
1607
1608 let edge_pad = 6.0;
1610 let (collapse_clicked, chev_w) = draw_chevron_button(ui, ctx, rect, edge_pad);
1611
1612 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 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
1669fn 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 let c = rect.center();
1689 let h = 3.5; let pts = if ctx.is_collapsed {
1691 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 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
1711fn 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 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 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 (
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 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
1800fn paint_broadcast_glyph(painter: &egui::Painter, center: Pos2, color: Color32) {
1804 painter.circle_filled(center, 1.8, color);
1806
1807 let stroke = Stroke::new(1.2, color);
1808 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
1818fn 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
1840fn 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 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
1906fn 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 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 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 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
1957fn 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 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
2038fn 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 job.wrap.max_width = (ui.available_width() - 10.0).max(40.0);
2050 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 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 let prefix_chars = pane.user.chars().count()
2117 + 1 + pane.host.chars().count()
2119 + 1 + pane.cwd.chars().count()
2121 + 2; 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 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
2196struct 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 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 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 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
2312fn 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}