1use std::collections::HashSet;
78use std::hash::Hash;
79
80use egui::epaint::text::{LayoutJob, TextFormat};
81use egui::{
82 Align2, Color32, CornerRadius, Event, FontFamily, FontId, Id, Key, Modifiers, Pos2, Rect,
83 Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetInfo, WidgetType,
84};
85
86use crate::theme::{Palette, Theme, Typography};
87
88#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
90pub enum TerminalStatus {
91 Connected,
93 Reconnecting,
96 Offline,
98}
99
100impl TerminalStatus {
101 pub fn indicator_state(self) -> crate::IndicatorState {
104 match self {
105 Self::Connected => crate::IndicatorState::On,
106 Self::Reconnecting => crate::IndicatorState::Connecting,
107 Self::Offline => crate::IndicatorState::Off,
108 }
109 }
110}
111
112#[derive(Clone, Debug, PartialEq, Eq)]
114pub enum LineKind {
115 Out,
117 Info,
119 Ok,
121 Warn,
123 Err,
125 Dim,
127 Command {
132 user: String,
134 host: String,
136 cwd: String,
138 cmd: String,
140 },
141}
142
143#[derive(Clone, Debug)]
145pub struct TerminalLine {
146 pub kind: LineKind,
148 pub text: String,
150}
151
152impl TerminalLine {
153 pub fn new(kind: LineKind, text: impl Into<String>) -> Self {
155 Self {
156 kind,
157 text: text.into(),
158 }
159 }
160
161 pub fn out(text: impl Into<String>) -> Self {
163 Self::new(LineKind::Out, text)
164 }
165 pub fn info(text: impl Into<String>) -> Self {
167 Self::new(LineKind::Info, text)
168 }
169 pub fn ok(text: impl Into<String>) -> Self {
171 Self::new(LineKind::Ok, text)
172 }
173 pub fn warn(text: impl Into<String>) -> Self {
175 Self::new(LineKind::Warn, text)
176 }
177 pub fn err(text: impl Into<String>) -> Self {
179 Self::new(LineKind::Err, text)
180 }
181 pub fn dim(text: impl Into<String>) -> Self {
183 Self::new(LineKind::Dim, text)
184 }
185
186 pub fn command(
189 user: impl Into<String>,
190 host: impl Into<String>,
191 cwd: impl Into<String>,
192 cmd: impl Into<String>,
193 ) -> Self {
194 Self {
195 kind: LineKind::Command {
196 user: user.into(),
197 host: host.into(),
198 cwd: cwd.into(),
199 cmd: cmd.into(),
200 },
201 text: String::new(),
202 }
203 }
204}
205
206#[derive(Clone, Debug)]
208pub struct TerminalPane {
209 pub id: String,
212 pub host: String,
214 pub user: String,
216 pub cwd: String,
218 pub status: TerminalStatus,
220 pub lines: Vec<TerminalLine>,
222}
223
224impl TerminalPane {
225 pub fn new(id: impl Into<String>, host: impl Into<String>) -> Self {
228 Self {
229 id: id.into(),
230 host: host.into(),
231 user: "user".into(),
232 cwd: "~".into(),
233 status: TerminalStatus::Connected,
234 lines: Vec::new(),
235 }
236 }
237
238 #[inline]
240 pub fn user(mut self, user: impl Into<String>) -> Self {
241 self.user = user.into();
242 self
243 }
244
245 #[inline]
247 pub fn cwd(mut self, cwd: impl Into<String>) -> Self {
248 self.cwd = cwd.into();
249 self
250 }
251
252 #[inline]
254 pub fn status(mut self, status: TerminalStatus) -> Self {
255 self.status = status;
256 self
257 }
258
259 #[inline]
261 pub fn push(mut self, line: TerminalLine) -> Self {
262 self.lines.push(line);
263 self
264 }
265
266 pub fn push_line(&mut self, line: TerminalLine) {
268 self.lines.push(line);
269 }
270
271 pub fn set_status(&mut self, status: TerminalStatus) {
273 self.status = status;
274 }
275
276 pub fn command_line(&self, cmd: impl Into<String>) -> TerminalLine {
279 TerminalLine::command(self.user.clone(), self.host.clone(), self.cwd.clone(), cmd)
280 }
281}
282
283#[derive(Clone, Debug)]
285pub enum TerminalEvent {
286 Command {
294 targets: Vec<String>,
296 command: String,
298 },
299}
300
301#[must_use = "Call `.show(ui)` to render the widget."]
305pub struct MultiTerminal {
306 id_salt: Id,
307 panes: Vec<TerminalPane>,
308 broadcast: HashSet<String>,
309 collapsed: HashSet<String>,
310 stashed: Option<HashSet<String>>,
311 focused_id: Option<String>,
312 pending: String,
313 columns_mode: ColumnsMode,
314 pane_min_height: f32,
315 scrollback_cap: usize,
316 events: Vec<TerminalEvent>,
317}
318
319#[derive(Clone, Copy, Debug, PartialEq)]
321pub enum ColumnsMode {
322 Fixed(usize),
324 Auto {
328 min_col_width: f32,
330 },
331}
332
333impl std::fmt::Debug for MultiTerminal {
334 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335 f.debug_struct("MultiTerminal")
336 .field("id_salt", &self.id_salt)
337 .field("panes", &self.panes.len())
338 .field("broadcast", &self.broadcast)
339 .field("collapsed", &self.collapsed)
340 .field("focused_id", &self.focused_id)
341 .field("pending", &self.pending)
342 .field("columns_mode", &self.columns_mode)
343 .field("events", &self.events.len())
344 .finish()
345 }
346}
347
348impl MultiTerminal {
349 pub fn new(id_salt: impl Hash) -> Self {
352 Self {
353 id_salt: Id::new(("elegance_multi_terminal", id_salt)),
354 panes: Vec::new(),
355 broadcast: HashSet::new(),
356 collapsed: HashSet::new(),
357 stashed: None,
358 focused_id: None,
359 pending: String::new(),
360 columns_mode: ColumnsMode::Fixed(2),
361 pane_min_height: 220.0,
362 scrollback_cap: 500,
363 events: Vec::new(),
364 }
365 }
366
367 #[inline]
369 pub fn with_pane(mut self, pane: TerminalPane) -> Self {
370 self.add_pane(pane);
371 self
372 }
373
374 #[inline]
380 pub fn columns(mut self, columns: usize) -> Self {
381 self.columns_mode = ColumnsMode::Fixed(columns.max(1));
382 self
383 }
384
385 #[inline]
395 pub fn columns_auto(mut self, min_col_width: f32) -> Self {
396 self.columns_mode = ColumnsMode::Auto {
397 min_col_width: min_col_width.max(240.0),
398 };
399 self
400 }
401
402 #[inline]
404 pub fn pane_min_height(mut self, h: f32) -> Self {
405 self.pane_min_height = h.max(80.0);
406 self
407 }
408
409 #[inline]
412 pub fn scrollback_cap(mut self, n: usize) -> Self {
413 self.scrollback_cap = n.max(1);
414 self
415 }
416
417 pub fn add_pane(&mut self, pane: TerminalPane) {
419 if self.focused_id.is_none() {
421 self.focused_id = Some(pane.id.clone());
422 }
423 if pane.status == TerminalStatus::Connected {
426 self.broadcast.insert(pane.id.clone());
427 }
428 self.panes.push(pane);
429 }
430
431 pub fn remove_pane(&mut self, id: &str) {
433 self.panes.retain(|p| p.id != id);
434 self.broadcast.remove(id);
435 if let Some(stash) = self.stashed.as_mut() {
436 stash.remove(id);
437 }
438 if self.focused_id.as_deref() == Some(id) {
439 self.focused_id = self.panes.first().map(|p| p.id.clone());
440 }
441 }
442
443 pub fn pane(&self, id: &str) -> Option<&TerminalPane> {
445 self.panes.iter().find(|p| p.id == id)
446 }
447
448 pub fn pane_mut(&mut self, id: &str) -> Option<&mut TerminalPane> {
450 self.panes.iter_mut().find(|p| p.id == id)
451 }
452
453 pub fn panes(&self) -> &[TerminalPane] {
455 &self.panes
456 }
457
458 pub fn push_line(&mut self, id: &str, line: TerminalLine) {
461 let cap = self.scrollback_cap;
462 if let Some(p) = self.panes.iter_mut().find(|p| p.id == id) {
463 p.lines.push(line);
464 if p.lines.len() > cap {
465 let drop = p.lines.len() - cap;
466 p.lines.drain(0..drop);
467 }
468 }
469 }
470
471 pub fn set_status(&mut self, id: &str, status: TerminalStatus) {
474 if let Some(p) = self.pane_mut(id) {
475 p.status = status;
476 }
477 if status != TerminalStatus::Connected {
478 self.broadcast.remove(id);
479 }
480 }
481
482 pub fn focused(&self) -> Option<&str> {
484 self.focused_id.as_deref()
485 }
486
487 pub fn set_focused(&mut self, id: Option<String>) {
489 self.focused_id = id;
490 }
491
492 pub fn broadcast(&self) -> &HashSet<String> {
495 &self.broadcast
496 }
497
498 pub fn set_broadcast(&mut self, set: HashSet<String>) {
501 self.broadcast = set;
502 self.stashed = None;
503 }
504
505 pub fn is_collapsed(&self, id: &str) -> bool {
508 self.collapsed.contains(id)
509 }
510
511 pub fn set_collapsed(&mut self, id: &str, collapsed: bool) {
513 if collapsed {
514 self.collapsed.insert(id.to_string());
515 } else {
516 self.collapsed.remove(id);
517 }
518 }
519
520 pub fn toggle_collapsed(&mut self, id: &str) {
522 if self.collapsed.contains(id) {
523 self.collapsed.remove(id);
524 } else {
525 self.collapsed.insert(id.to_string());
526 }
527 }
528
529 pub fn collapse_all(&mut self) {
531 for p in &self.panes {
532 self.collapsed.insert(p.id.clone());
533 }
534 }
535
536 pub fn expand_all(&mut self) {
538 self.collapsed.clear();
539 }
540
541 pub fn toggle_broadcast(&mut self, id: &str) {
543 if self
544 .pane(id)
545 .is_some_and(|p| p.status == TerminalStatus::Connected)
546 {
547 self.stashed = None;
548 if self.broadcast.contains(id) {
549 self.broadcast.remove(id);
550 } else {
551 self.broadcast.insert(id.to_string());
552 }
553 }
554 }
555
556 pub fn solo(&mut self, id: &str) {
562 if !self
563 .panes
564 .iter()
565 .any(|p| p.id == id && p.status == TerminalStatus::Connected)
566 {
567 return;
568 }
569 let is_solo = self.broadcast.len() == 1 && self.broadcast.contains(id);
570 if is_solo {
571 self.restore_or_fallback();
572 } else {
573 self.stashed = Some(self.broadcast.clone());
574 self.broadcast.clear();
575 self.broadcast.insert(id.to_string());
576 }
577 self.focused_id = Some(id.to_string());
578 }
579
580 pub fn solo_focused(&mut self) {
583 if let Some(fid) = self.focused_id.clone() {
584 self.solo(&fid);
585 }
586 }
587
588 pub fn broadcast_all(&mut self) {
595 let connected: Vec<String> = self
596 .panes
597 .iter()
598 .filter(|p| p.status == TerminalStatus::Connected)
599 .map(|p| p.id.clone())
600 .collect();
601 let all_on =
602 !connected.is_empty() && connected.iter().all(|id| self.broadcast.contains(id));
603 self.stashed = None;
607 if all_on {
608 self.broadcast.clear();
609 } else {
610 self.broadcast = connected.into_iter().collect();
611 }
612 }
613
614 pub fn invert_broadcast(&mut self) {
617 self.stashed = None;
618 let mut next = HashSet::new();
619 for p in &self.panes {
620 if p.status != TerminalStatus::Connected {
621 continue;
622 }
623 if !self.broadcast.contains(&p.id) {
624 next.insert(p.id.clone());
625 }
626 }
627 self.broadcast = next;
628 }
629
630 pub fn pending(&self) -> &str {
632 &self.pending
633 }
634
635 pub fn clear_pending(&mut self) {
637 self.pending.clear();
638 }
639
640 pub fn take_events(&mut self) -> Vec<TerminalEvent> {
644 std::mem::take(&mut self.events)
645 }
646
647 pub fn show(&mut self, ui: &mut Ui) -> Response {
650 let theme = Theme::current(ui.ctx());
651 let focus_id = self.id_salt;
652
653 let inner = ui
656 .vertical(|ui| {
657 self.ui_gridbar(ui, &theme);
658 ui.add_space(0.0);
659 self.ui_grid(ui, &theme);
660 })
661 .response;
662
663 let bg = ui.interact(inner.rect, focus_id, Sense::focusable_noninteractive());
669
670 let someone_else_has_focus = ui
676 .ctx()
677 .memory(|m| m.focused().is_some_and(|f| f != focus_id));
678 if !someone_else_has_focus {
679 ui.ctx().memory_mut(|m| m.request_focus(focus_id));
680 }
681
682 if ui.ctx().memory(|m| m.has_focus(focus_id)) {
683 self.handle_keys(ui);
684 }
685
686 bg.widget_info(|| {
687 WidgetInfo::labeled(
688 WidgetType::Other,
689 true,
690 format!(
691 "Multi-terminal, {} pane{}, {} receiving",
692 self.panes.len(),
693 if self.panes.len() == 1 { "" } else { "s" },
694 self.target_ids().len()
695 ),
696 )
697 });
698 bg
699 }
700
701 fn restore_or_fallback(&mut self) {
706 if let Some(stash) = self.stashed.take() {
707 self.broadcast = stash
708 .into_iter()
709 .filter(|id| {
710 self.panes
711 .iter()
712 .any(|p| p.id == *id && p.status == TerminalStatus::Connected)
713 })
714 .collect();
715 }
716 if self.broadcast.is_empty() {
717 if let Some(fid) = self.focused_id.clone() {
718 self.broadcast.insert(fid);
719 }
720 }
721 }
722
723 fn target_ids(&self) -> Vec<String> {
726 let alive: Vec<String> = self
727 .panes
728 .iter()
729 .filter(|p| self.broadcast.contains(&p.id) && p.status == TerminalStatus::Connected)
730 .map(|p| p.id.clone())
731 .collect();
732 if !alive.is_empty() {
733 return alive;
734 }
735 if let Some(fid) = &self.focused_id {
736 if self
737 .panes
738 .iter()
739 .any(|p| p.id == *fid && p.status == TerminalStatus::Connected)
740 {
741 return vec![fid.clone()];
742 }
743 }
744 Vec::new()
745 }
746
747 fn connected_count(&self) -> usize {
748 self.panes
749 .iter()
750 .filter(|p| p.status == TerminalStatus::Connected)
751 .count()
752 }
753
754 fn run_pending(&mut self) {
755 let cmd = self.pending.trim().to_string();
756 if cmd.is_empty() {
757 return;
758 }
759 let targets = self.target_ids();
760 if targets.is_empty() {
761 return;
762 }
763 let cap = self.scrollback_cap;
766 for id in &targets {
767 if let Some(pane) = self.panes.iter_mut().find(|p| p.id == *id) {
768 let line = pane.command_line(&cmd);
769 pane.lines.push(line);
770 if pane.lines.len() > cap {
771 let drop = pane.lines.len() - cap;
772 pane.lines.drain(0..drop);
773 }
774 }
775 }
776 self.events.push(TerminalEvent::Command {
777 targets,
778 command: cmd,
779 });
780 self.pending.clear();
781 }
782
783 fn handle_keys(&mut self, ui: &mut Ui) {
784 let events: Vec<Event> = ui.ctx().input(|i| i.events.clone());
787 for event in events {
788 match event {
789 Event::Key {
790 key,
791 pressed: true,
792 modifiers,
793 ..
794 } => {
795 if modifiers.matches_exact(Modifiers::COMMAND)
796 || modifiers.matches_exact(Modifiers::CTRL)
797 {
798 match key {
799 Key::A => self.broadcast_all(),
800 Key::D => self.solo_focused(),
801 _ => {}
802 }
803 continue;
804 }
805 if modifiers.any() {
806 continue;
808 }
809 match key {
810 Key::Enter => self.run_pending(),
811 Key::Escape => self.pending.clear(),
812 Key::Backspace => {
813 self.pending.pop();
814 }
815 _ => {}
816 }
817 }
818 Event::Text(text) => {
819 for ch in text.chars() {
820 if !ch.is_control() {
821 self.pending.push(ch);
822 }
823 }
824 }
825 _ => {}
826 }
827 }
828 }
829
830 fn ui_gridbar(&mut self, ui: &mut Ui, theme: &Theme) {
833 let palette = &theme.palette;
834 let typo = &theme.typography;
835 let connected = self.connected_count();
836 let targets = self.target_ids();
837 let targets_len = targets.len();
838
839 let height = 36.0;
840 let (rect, _resp) =
841 ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
842 let painter = ui.painter_at(rect);
843
844 painter.rect(
846 rect,
847 CornerRadius {
848 nw: theme.card_radius as u8,
849 ne: theme.card_radius as u8,
850 sw: 0,
851 se: 0,
852 },
853 palette.card,
854 Stroke::new(1.0, palette.border),
855 StrokeKind::Inside,
856 );
857
858 if connected > 0 {
862 let frac = (targets_len as f32 / connected as f32).clamp(0.0, 1.0);
863 let bar_top = rect.bottom() - 1.5;
864 let bar_rect = Rect::from_min_max(
865 Pos2::new(rect.left(), bar_top),
866 Pos2::new(rect.left() + rect.width() * frac, rect.bottom()),
867 );
868 painter.rect_filled(bar_rect, CornerRadius::ZERO, palette.sky);
869 }
870
871 let (mode_label, mode_style) = self.derive_mode(targets_len, connected);
873 let mut cursor_x = rect.left() + 14.0;
874 let y_mid = rect.center().y;
875
876 cursor_x += self.paint_mode_pill(
877 &painter,
878 Pos2::new(cursor_x, y_mid),
879 mode_label,
880 mode_style,
881 palette,
882 typo,
883 );
884 cursor_x += 10.0;
885
886 let summary = self.target_summary(&targets, targets_len, connected);
888 let summary_color = if targets_len == 0 {
889 palette.warning
890 } else {
891 palette.text_muted
892 };
893 let right_reserve = 280.0;
896 let max_text_right = (rect.right() - right_reserve).max(cursor_x + 40.0);
897 let summary_job = summary_layout(
898 &summary,
899 palette,
900 typo.label,
901 summary_color,
902 max_text_right - cursor_x,
903 );
904 let galley = painter.layout_job(summary_job);
905 painter.galley(
906 Pos2::new(cursor_x, y_mid - galley.size().y * 0.5),
907 galley,
908 palette.text_muted,
909 );
910
911 let mut x = rect.right() - 10.0;
914 let all_on = connected > 0 && targets_len == connected;
915
916 let all_w = qa_button(
917 ui,
918 rect,
919 &mut x,
920 self.id_salt.with("qa-all"),
921 "All on",
922 Some("\u{2318}A"),
923 all_on,
924 theme,
925 );
926 if all_w.clicked {
927 self.broadcast_all();
928 ui.ctx().memory_mut(|m| m.request_focus(self.id_salt));
931 }
932 }
933
934 fn target_summary(&self, targets: &[String], n: usize, connected: usize) -> String {
935 if n == 0 {
936 return "No reachable terminals".into();
937 }
938 let phrase = if n == 1 {
939 "Sending to"
940 } else if n == connected {
941 "Broadcasting to ALL"
942 } else {
943 "Broadcasting to"
944 };
945 let hosts: Vec<&str> = targets
946 .iter()
947 .filter_map(|id| self.pane(id).map(|p| p.host.as_str()))
948 .collect();
949 let shown = if hosts.len() <= 3 {
950 hosts.join(", ")
951 } else {
952 format!("{}, +{} more", hosts[..2].join(", "), hosts.len() - 2)
953 };
954 format!("{phrase} {n} \u{00b7} {shown}")
955 }
956
957 fn paint_mode_pill(
958 &self,
959 painter: &egui::Painter,
960 left_center: Pos2,
961 label: &str,
962 style: ModePillStyle,
963 palette: &Palette,
964 typo: &Typography,
965 ) -> f32 {
966 let text_color = match style {
967 ModePillStyle::Single => palette.text_muted,
968 ModePillStyle::Selected => palette.sky,
969 ModePillStyle::All => Color32::from_rgb(0x0f, 0x17, 0x2a),
970 };
971 let (fill, border) = match style {
972 ModePillStyle::Single => (palette.input_bg, palette.border),
973 ModePillStyle::Selected => (with_alpha(palette.sky, 22), with_alpha(palette.sky, 90)),
974 ModePillStyle::All => (palette.sky, palette.sky),
975 };
976
977 let galley = painter.layout_no_wrap(
978 label.to_string(),
979 FontId::new(typo.small - 1.5, FontFamily::Proportional),
980 text_color,
981 );
982 let pad_x = 7.0;
983 let pill_h = galley.size().y + 4.0;
984 let pill_w = galley.size().x + pad_x * 2.0;
985 let pill_rect = Rect::from_center_size(
986 Pos2::new(left_center.x + pill_w * 0.5, left_center.y),
987 Vec2::new(pill_w, pill_h),
988 );
989 painter.rect(
990 pill_rect,
991 CornerRadius::same((pill_h * 0.5) as u8),
992 fill,
993 Stroke::new(1.0, border),
994 StrokeKind::Inside,
995 );
996 painter.galley(
997 Pos2::new(
998 pill_rect.left() + pad_x,
999 pill_rect.center().y - galley.size().y * 0.5,
1000 ),
1001 galley,
1002 text_color,
1003 );
1004 pill_w
1005 }
1006
1007 fn derive_mode(&self, targets: usize, connected: usize) -> (&'static str, ModePillStyle) {
1008 if targets == 0 {
1009 ("NO TARGET", ModePillStyle::Single)
1010 } else if targets == 1 {
1011 ("SINGLE", ModePillStyle::Single)
1012 } else if targets == connected {
1013 ("ALL", ModePillStyle::All)
1014 } else {
1015 ("SELECTED", ModePillStyle::Selected)
1016 }
1017 }
1018
1019 fn ui_grid(&mut self, ui: &mut Ui, theme: &Theme) {
1020 let palette = &theme.palette;
1021 let full_w = ui.available_width();
1022 ui.spacing_mut().item_spacing.y = 0.0;
1023
1024 let inner_pad = 12.0;
1025 let gap = 12.0;
1026
1027 let inner_w_for_cols = (full_w - inner_pad * 2.0).max(0.0);
1031 let max_cols_from_width = |min_col_width: f32| -> usize {
1036 ((inner_w_for_cols + gap) / (min_col_width + gap))
1037 .floor()
1038 .max(1.0) as usize
1039 };
1040 let pane_count = self.panes.len().max(1);
1041 let cols_raw = match self.columns_mode {
1042 ColumnsMode::Fixed(n) => n,
1043 ColumnsMode::Auto { min_col_width } => {
1044 let max_cols = max_cols_from_width(min_col_width).min(pane_count);
1045 let rows = pane_count.div_ceil(max_cols);
1046 pane_count.div_ceil(rows)
1047 }
1048 };
1049 let cols = cols_raw.max(1).min(pane_count);
1050 let n_rows = self.panes.len().div_ceil(cols);
1051
1052 let header_only_h = PANE_HEADER_HEIGHT;
1055 let row_heights: Vec<f32> = (0..n_rows)
1056 .map(|row| {
1057 let any_expanded = (0..cols).any(|col| {
1058 let idx = row * cols + col;
1059 idx < self.panes.len() && !self.collapsed.contains(&self.panes[idx].id)
1060 });
1061 if any_expanded {
1062 self.pane_min_height
1063 } else {
1064 header_only_h
1065 }
1066 })
1067 .collect();
1068 let total_h = if self.panes.is_empty() {
1069 60.0
1070 } else {
1071 inner_pad * 2.0
1072 + row_heights.iter().sum::<f32>()
1073 + (n_rows.saturating_sub(1)) as f32 * gap
1074 };
1075
1076 let (outer_rect, _resp) =
1077 ui.allocate_exact_size(Vec2::new(full_w, total_h), Sense::hover());
1078
1079 ui.painter().rect(
1080 outer_rect,
1081 CornerRadius {
1082 nw: 0,
1083 ne: 0,
1084 sw: theme.card_radius as u8,
1085 se: theme.card_radius as u8,
1086 },
1087 palette.card,
1088 Stroke::new(1.0, palette.border),
1089 StrokeKind::Inside,
1090 );
1091
1092 if self.panes.is_empty() {
1093 ui.painter().text(
1094 outer_rect.center(),
1095 Align2::CENTER_CENTER,
1096 "No terminals",
1097 FontId::proportional(theme.typography.body),
1098 palette.text_faint,
1099 );
1100 return;
1101 }
1102
1103 let inner = outer_rect.shrink(inner_pad);
1104 let cell_w = (inner.width() - gap * (cols as f32 - 1.0)) / cols as f32;
1105
1106 let mut intent_focus: Option<String> = None;
1109 let mut intent_toggle: Option<String> = None;
1110 let mut intent_solo: Option<String> = None;
1111 let mut intent_collapse: Option<String> = None;
1112
1113 let mut y_cursor = inner.top();
1115 let mut row_top_for = vec![0.0_f32; n_rows];
1116 for (row, h) in row_heights.iter().enumerate() {
1117 row_top_for[row] = y_cursor;
1118 y_cursor += h + gap;
1119 }
1120
1121 for (idx, pane) in self.panes.iter().enumerate() {
1122 let row = idx / cols;
1123 let col = idx % cols;
1124 let cell_top = row_top_for[row];
1125 let cell_left = inner.left() + col as f32 * (cell_w + gap);
1126 let is_collapsed = self.collapsed.contains(&pane.id);
1130 let cell_h = if is_collapsed {
1131 header_only_h
1132 } else {
1133 row_heights[row]
1134 };
1135 let cell_rect =
1136 Rect::from_min_size(Pos2::new(cell_left, cell_top), Vec2::new(cell_w, cell_h));
1137
1138 let is_focused = self.focused_id.as_deref() == Some(pane.id.as_str());
1139 let is_receiving =
1140 self.broadcast.contains(&pane.id) && pane.status == TerminalStatus::Connected;
1141 let is_solo = self.broadcast.len() == 1 && self.broadcast.contains(&pane.id);
1142
1143 let ctx = PaneCtx {
1144 rect: cell_rect,
1145 pane,
1146 is_focused,
1147 is_receiving,
1148 is_solo,
1149 is_collapsed,
1150 pending: if is_receiving { &self.pending } else { "" },
1151 theme,
1152 id_salt: self.id_salt.with(("pane", idx)),
1153 };
1154 let actions = draw_pane(ui, &ctx);
1155
1156 if actions.header_clicked || actions.body_clicked {
1157 intent_focus = Some(pane.id.clone());
1158 }
1159 if actions.toggle_clicked {
1160 intent_toggle = Some(pane.id.clone());
1161 }
1162 if actions.solo_clicked {
1163 intent_solo = Some(pane.id.clone());
1164 }
1165 if actions.collapse_clicked {
1166 intent_collapse = Some(pane.id.clone());
1167 }
1168 }
1169
1170 if let Some(id) = intent_focus {
1171 self.focused_id = Some(id);
1172 ui.ctx().memory_mut(|m| m.request_focus(self.id_salt));
1173 }
1174 if let Some(id) = intent_toggle {
1175 self.toggle_broadcast(&id);
1176 ui.ctx().memory_mut(|m| m.request_focus(self.id_salt));
1177 }
1178 if let Some(id) = intent_solo {
1179 self.solo(&id);
1180 ui.ctx().memory_mut(|m| m.request_focus(self.id_salt));
1181 }
1182 if let Some(id) = intent_collapse {
1183 self.toggle_collapsed(&id);
1184 ui.ctx().memory_mut(|m| m.request_focus(self.id_salt));
1185 }
1186 }
1187}
1188
1189const PANE_HEADER_HEIGHT: f32 = 34.0;
1191
1192struct PaneCtx<'a> {
1198 rect: Rect,
1199 pane: &'a TerminalPane,
1200 is_focused: bool,
1201 is_receiving: bool,
1202 is_solo: bool,
1204 is_collapsed: bool,
1206 pending: &'a str,
1207 theme: &'a Theme,
1208 id_salt: Id,
1209}
1210
1211struct PaneActions {
1212 header_clicked: bool,
1213 body_clicked: bool,
1214 toggle_clicked: bool,
1215 solo_clicked: bool,
1216 collapse_clicked: bool,
1217}
1218
1219fn draw_pane(ui: &mut Ui, ctx: &PaneCtx<'_>) -> PaneActions {
1220 let palette = &ctx.theme.palette;
1221 let p = ctx.rect;
1222
1223 let border_color = if ctx.is_focused {
1225 palette.sky
1226 } else if ctx.is_receiving {
1227 with_alpha(palette.sky, 115)
1228 } else {
1229 palette.border
1230 };
1231 let border_stroke = Stroke::new(if ctx.is_focused { 1.5 } else { 1.0 }, border_color);
1232 ui.painter().rect(
1233 p,
1234 CornerRadius::same((ctx.theme.control_radius + 2.0) as u8),
1235 palette.card,
1236 border_stroke,
1237 StrokeKind::Inside,
1238 );
1239
1240 if ctx.is_focused {
1242 ui.painter().rect_stroke(
1243 p.expand(2.0),
1244 CornerRadius::same((ctx.theme.control_radius + 4.0) as u8),
1245 Stroke::new(1.0, with_alpha(palette.sky, 50)),
1246 StrokeKind::Outside,
1247 );
1248 }
1249
1250 let header_rect = Rect::from_min_size(p.min, Vec2::new(p.width(), PANE_HEADER_HEIGHT));
1253 let (header_clicked, toggle_clicked, solo_clicked, collapse_clicked) =
1254 draw_pane_header(ui, header_rect, ctx);
1255
1256 let body_clicked = if ctx.is_collapsed {
1257 false
1258 } else {
1259 let body_rect = Rect::from_min_max(Pos2::new(p.left(), header_rect.bottom()), p.max);
1260 draw_pane_body(ui, body_rect, ctx)
1261 };
1262
1263 PaneActions {
1264 header_clicked,
1265 body_clicked,
1266 toggle_clicked,
1267 solo_clicked,
1268 collapse_clicked,
1269 }
1270}
1271
1272fn draw_pane_header(ui: &mut Ui, rect: Rect, ctx: &PaneCtx<'_>) -> (bool, bool, bool, bool) {
1273 let palette = &ctx.theme.palette;
1274 let typo = &ctx.theme.typography;
1275
1276 if !ctx.is_collapsed {
1279 ui.painter().line_segment(
1280 [
1281 Pos2::new(rect.left() + 1.0, rect.bottom() - 0.5),
1282 Pos2::new(rect.right() - 1.0, rect.bottom() - 0.5),
1283 ],
1284 Stroke::new(1.0, palette.border),
1285 );
1286 }
1287
1288 let header_resp = ui.interact(rect, ctx.id_salt.with("header"), Sense::click());
1291
1292 let edge_pad = 6.0;
1294 let (collapse_clicked, chev_w) = draw_chevron_button(ui, ctx, rect, edge_pad);
1295
1296 let pad_x = 13.0;
1298 let host_x = rect.left() + edge_pad + chev_w + 6.0;
1299 let mut job = LayoutJob::default();
1300 job.append(
1301 &ctx.pane.host,
1302 0.0,
1303 TextFormat {
1304 font_id: FontId::monospace(typo.small + 0.5),
1305 color: palette.text,
1306 ..Default::default()
1307 },
1308 );
1309 job.append(
1310 &format!("@{}", ctx.pane.user),
1311 0.0,
1312 TextFormat {
1313 font_id: FontId::monospace(typo.small + 0.5),
1314 color: palette.text_faint,
1315 ..Default::default()
1316 },
1317 );
1318 let galley = ui.painter().layout_job(job);
1319 ui.painter().galley(
1320 Pos2::new(host_x, rect.center().y - galley.size().y * 0.5),
1321 galley,
1322 palette.text,
1323 );
1324
1325 let ind_size = 10.0;
1328 let ind_center = Pos2::new(rect.right() - pad_x - ind_size * 0.5, rect.center().y);
1329 paint_status_indicator(ui.painter(), ind_center, ctx.pane.status, palette, ind_size);
1330
1331 let bc_rect_right = ind_center.x - ind_size * 0.5 - 8.0;
1333 let (toggle_clicked, bc_w) = draw_broadcast_pill(ui, ctx, bc_rect_right, rect.center().y);
1334
1335 let solo_right = bc_rect_right - bc_w - 6.0;
1337 let (solo_clicked, _solo_w) = draw_solo_button(ui, ctx, solo_right, rect.center().y);
1338
1339 (
1340 header_resp.clicked(),
1341 toggle_clicked,
1342 solo_clicked,
1343 collapse_clicked,
1344 )
1345}
1346
1347fn draw_chevron_button(ui: &mut Ui, ctx: &PaneCtx<'_>, header: Rect, edge_pad: f32) -> (bool, f32) {
1352 let palette = &ctx.theme.palette;
1353 let size = 18.0;
1354 let rect = Rect::from_center_size(
1355 Pos2::new(header.left() + edge_pad + size * 0.5, header.center().y),
1356 Vec2::splat(size),
1357 );
1358 let resp = ui.interact(rect, ctx.id_salt.with("chev"), Sense::click());
1359 let color = if resp.hovered() {
1360 palette.text
1361 } else {
1362 palette.text_muted
1363 };
1364
1365 let c = rect.center();
1367 let h = 3.5; let pts = if ctx.is_collapsed {
1369 vec![
1371 Pos2::new(c.x - h * 0.7, c.y - h),
1372 Pos2::new(c.x - h * 0.7, c.y + h),
1373 Pos2::new(c.x + h, c.y),
1374 ]
1375 } else {
1376 vec![
1378 Pos2::new(c.x - h, c.y - h * 0.7),
1379 Pos2::new(c.x + h, c.y - h * 0.7),
1380 Pos2::new(c.x, c.y + h),
1381 ]
1382 };
1383 ui.painter()
1384 .add(egui::Shape::convex_polygon(pts, color, Stroke::NONE));
1385
1386 (resp.clicked(), size)
1387}
1388
1389fn paint_status_indicator(
1393 painter: &egui::Painter,
1394 center: Pos2,
1395 status: TerminalStatus,
1396 palette: &Palette,
1397 size: f32,
1398) {
1399 let r = size * 0.5;
1400 match status {
1401 TerminalStatus::Connected => {
1402 painter.circle_filled(center, r + 1.5, with_alpha(palette.success, 70));
1403 painter.circle_filled(center, r, palette.success);
1404 }
1405 TerminalStatus::Reconnecting => {
1406 painter.circle_stroke(center, r - 0.5, Stroke::new(1.8, palette.warning));
1407 }
1408 TerminalStatus::Offline => {
1409 painter.circle_stroke(center, r - 0.5, Stroke::new(1.0, palette.danger));
1410 let bar_w = size * 0.7;
1411 let bar_h = 2.0;
1412 let bar = Rect::from_center_size(center, Vec2::new(bar_w, bar_h));
1413 painter.rect_filled(bar, CornerRadius::same(1), palette.danger);
1414 }
1415 }
1416}
1417
1418fn draw_broadcast_pill(ui: &mut Ui, ctx: &PaneCtx<'_>, right_edge: f32, y_mid: f32) -> (bool, f32) {
1419 let palette = &ctx.theme.palette;
1420 let dim = ctx.pane.status != TerminalStatus::Connected;
1421
1422 let pill_w = 34.0;
1425 let pill_h = 22.0;
1426 let rect = Rect::from_min_size(
1427 Pos2::new(right_edge - pill_w, y_mid - pill_h * 0.5),
1428 Vec2::new(pill_w, pill_h),
1429 );
1430
1431 let resp = ui.interact(rect, ctx.id_salt.with("bcast"), Sense::click());
1432 let hovered = resp.hovered() && !dim;
1433
1434 let (fill, border, icon_color) = if ctx.is_receiving {
1435 let fill = if hovered {
1437 palette.depth_tint(palette.sky, 0.12)
1438 } else {
1439 palette.sky
1440 };
1441 (fill, palette.sky, Color32::from_rgb(0x0f, 0x17, 0x2a))
1442 } else if hovered {
1443 (
1446 with_alpha(palette.sky, 26),
1447 with_alpha(palette.sky, 130),
1448 palette.sky,
1449 )
1450 } else {
1451 (Color32::TRANSPARENT, palette.border, palette.text_faint)
1452 };
1453
1454 ui.painter().rect(
1455 rect,
1456 CornerRadius::same((pill_h * 0.5) as u8),
1457 fill,
1458 Stroke::new(1.0, border),
1459 StrokeKind::Inside,
1460 );
1461
1462 let center = rect.center();
1464 if ctx.is_receiving {
1465 let t = ui.input(|i| i.time);
1466 let phase = (t.rem_euclid(1.2) / 1.2) as f32;
1467 let halo_r = 2.0 + phase.min(1.0) * 4.5;
1468 let halo_a = (70.0 * (1.0 - phase)).clamp(0.0, 255.0) as u8;
1469 ui.painter()
1470 .circle_filled(center, halo_r, with_alpha(icon_color, halo_a));
1471 }
1472
1473 paint_broadcast_glyph(ui.painter(), center, icon_color);
1474
1475 (if dim { false } else { resp.clicked() }, pill_w)
1476}
1477
1478fn paint_broadcast_glyph(painter: &egui::Painter, center: Pos2, color: Color32) {
1482 painter.circle_filled(center, 1.8, color);
1484
1485 let stroke = Stroke::new(1.2, color);
1486 use std::f32::consts::PI;
1490 paint_arc(painter, center, 4.5, -0.45, 0.45, stroke);
1491 paint_arc(painter, center, 4.5, PI - 0.45, PI + 0.45, stroke);
1492 paint_arc(painter, center, 7.5, -0.32, 0.32, stroke);
1493 paint_arc(painter, center, 7.5, PI - 0.32, PI + 0.32, stroke);
1494}
1495
1496fn paint_arc(
1498 painter: &egui::Painter,
1499 center: Pos2,
1500 radius: f32,
1501 start: f32,
1502 end: f32,
1503 stroke: Stroke,
1504) {
1505 const STEPS: usize = 8;
1506 let mut pts = Vec::with_capacity(STEPS + 1);
1507 for i in 0..=STEPS {
1508 let t = i as f32 / STEPS as f32;
1509 let a = start + (end - start) * t;
1510 pts.push(Pos2::new(
1511 center.x + radius * a.cos(),
1512 center.y + radius * a.sin(),
1513 ));
1514 }
1515 painter.add(egui::Shape::line(pts, stroke));
1516}
1517
1518fn draw_solo_button(ui: &mut Ui, ctx: &PaneCtx<'_>, right_edge: f32, y_mid: f32) -> (bool, f32) {
1524 let palette = &ctx.theme.palette;
1525 let dim = ctx.pane.status != TerminalStatus::Connected;
1526
1527 let size = 22.0;
1528 let rect = Rect::from_min_size(
1529 Pos2::new(right_edge - size, y_mid - size * 0.5),
1530 Vec2::splat(size),
1531 );
1532
1533 let resp = ui.interact(rect, ctx.id_salt.with("solo"), Sense::click());
1534 let hovered = resp.hovered() && !dim;
1535
1536 let (fill, border, icon_color) = if ctx.is_solo {
1537 (with_alpha(palette.sky, 28), palette.sky, palette.sky)
1538 } else if hovered {
1539 (Color32::TRANSPARENT, palette.text_muted, palette.text)
1540 } else {
1541 (Color32::TRANSPARENT, palette.border, palette.text_faint)
1542 };
1543
1544 ui.painter().rect(
1545 rect,
1546 CornerRadius::same((size * 0.5) as u8),
1547 fill,
1548 Stroke::new(1.0, border),
1549 StrokeKind::Inside,
1550 );
1551
1552 paint_solo_icon(ui.painter(), rect.center(), icon_color);
1556
1557 (if dim { false } else { resp.clicked() }, size)
1558}
1559
1560fn paint_solo_icon(painter: &egui::Painter, center: Pos2, color: Color32) {
1561 let pad = 1.0;
1562 let cell = 5.5;
1563 let cells = [
1564 (-cell - pad, -cell - pad, true),
1565 (pad, -cell - pad, false),
1566 (-cell - pad, pad, false),
1567 (pad, pad, false),
1568 ];
1569 for (dx, dy, filled) in cells {
1570 let r = Rect::from_min_size(Pos2::new(center.x + dx, center.y + dy), Vec2::splat(cell));
1571 if filled {
1572 painter.rect_filled(r, CornerRadius::same(1), color);
1573 } else {
1574 painter.rect_stroke(
1575 r,
1576 CornerRadius::same(1),
1577 Stroke::new(1.2, color),
1578 StrokeKind::Inside,
1579 );
1580 }
1581 }
1582}
1583
1584fn draw_pane_body(ui: &mut Ui, rect: Rect, ctx: &PaneCtx<'_>) -> bool {
1586 let palette = &ctx.theme.palette;
1587 let typo = &ctx.theme.typography;
1588
1589 let term_bg = palette.depth_tint(palette.input_bg, 0.015);
1591 ui.painter().rect_filled(
1592 rect.shrink2(Vec2::new(1.0, 1.0)),
1593 CornerRadius {
1594 nw: 0,
1595 ne: 0,
1596 sw: (ctx.theme.control_radius + 1.0) as u8,
1597 se: (ctx.theme.control_radius + 1.0) as u8,
1598 },
1599 term_bg,
1600 );
1601
1602 let body_resp = ui.interact(rect, ctx.id_salt.with("body"), Sense::click());
1603
1604 let mut child = ui.new_child(
1606 egui::UiBuilder::new()
1607 .max_rect(rect.shrink(8.0))
1608 .layout(egui::Layout::top_down(egui::Align::Min)),
1609 );
1610 child.spacing_mut().item_spacing.y = 2.0;
1611
1612 egui::ScrollArea::vertical()
1613 .id_salt(ctx.id_salt.with("scroll"))
1614 .auto_shrink([false, false])
1615 .stick_to_bottom(true)
1616 .show(&mut child, |ui| {
1617 for line in &ctx.pane.lines {
1618 paint_line(ui, line, palette, typo);
1619 }
1620 paint_live_prompt(ui, ctx, palette, typo);
1621 });
1622
1623 body_resp.clicked()
1624}
1625
1626fn paint_line(ui: &mut Ui, line: &TerminalLine, palette: &Palette, typo: &Typography) {
1627 let size = typo.small + 0.5;
1628 let font = FontId::monospace(size);
1629 let wrap_width = ui.available_width();
1630
1631 match &line.kind {
1632 LineKind::Command {
1633 user,
1634 host,
1635 cwd,
1636 cmd,
1637 } => {
1638 let mut job = LayoutJob::default();
1639 job.wrap.max_width = wrap_width;
1644 job.wrap.break_anywhere = true;
1645 job.append(
1646 &format!("{user}@{host}"),
1647 0.0,
1648 TextFormat {
1649 font_id: font.clone(),
1650 color: palette.success,
1651 ..Default::default()
1652 },
1653 );
1654 job.append(
1655 ":",
1656 0.0,
1657 TextFormat {
1658 font_id: font.clone(),
1659 color: palette.text_muted,
1660 ..Default::default()
1661 },
1662 );
1663 job.append(
1664 cwd,
1665 0.0,
1666 TextFormat {
1667 font_id: font.clone(),
1668 color: palette.purple,
1669 ..Default::default()
1670 },
1671 );
1672 job.append(
1673 "$ ",
1674 0.0,
1675 TextFormat {
1676 font_id: font.clone(),
1677 color: palette.text_muted,
1678 ..Default::default()
1679 },
1680 );
1681 job.append(
1682 cmd,
1683 0.0,
1684 TextFormat {
1685 font_id: font,
1686 color: palette.text,
1687 ..Default::default()
1688 },
1689 );
1690 ui.label(job);
1691 }
1692 other => {
1693 let color = color_for_kind(other, palette);
1694 let italic = matches!(other, LineKind::Info);
1695 let rich = egui::RichText::new(&line.text).font(font).color(color);
1696 let rich = if italic { rich.italics() } else { rich };
1697 ui.add(egui::Label::new(rich).wrap());
1698 }
1699 }
1700}
1701
1702fn paint_live_prompt(ui: &mut Ui, ctx: &PaneCtx<'_>, palette: &Palette, typo: &Typography) {
1703 let size = typo.small + 0.5;
1704 let font = FontId::monospace(size);
1705 let pane = ctx.pane;
1706
1707 let mut job = LayoutJob::default();
1708 job.wrap.max_width = (ui.available_width() - 10.0).max(40.0);
1711 job.wrap.break_anywhere = true;
1715 job.append(
1716 &format!("{}@{}", pane.user, pane.host),
1717 0.0,
1718 TextFormat {
1719 font_id: font.clone(),
1720 color: palette.success,
1721 ..Default::default()
1722 },
1723 );
1724 job.append(
1725 ":",
1726 0.0,
1727 TextFormat {
1728 font_id: font.clone(),
1729 color: palette.text_muted,
1730 ..Default::default()
1731 },
1732 );
1733 job.append(
1734 &pane.cwd,
1735 0.0,
1736 TextFormat {
1737 font_id: font.clone(),
1738 color: palette.purple,
1739 ..Default::default()
1740 },
1741 );
1742 job.append(
1743 "$ ",
1744 0.0,
1745 TextFormat {
1746 font_id: font.clone(),
1747 color: palette.text_muted,
1748 ..Default::default()
1749 },
1750 );
1751 if !ctx.pending.is_empty() {
1752 job.append(
1753 ctx.pending,
1754 0.0,
1755 TextFormat {
1756 font_id: font.clone(),
1757 color: palette.sky,
1758 ..Default::default()
1759 },
1760 );
1761 }
1762
1763 let galley = ui.painter().layout_job(job);
1767 let caret_h = size + 2.0;
1768 let caret_w = 7.0;
1769 let total_size = Vec2::new(
1770 galley.size().x + caret_w + 2.0,
1771 galley.size().y.max(caret_h),
1772 );
1773 let (rect, _resp) = ui.allocate_exact_size(total_size, Sense::hover());
1774 let galley_origin = rect.min;
1775
1776 let last_row = galley.rows.last();
1778 let caret_x = galley_origin.x + last_row.map(|r| r.rect().right()).unwrap_or(0.0) + 1.0;
1779 let caret_y = galley_origin.y
1780 + last_row
1781 .map(|r| r.rect().center().y)
1782 .unwrap_or(galley.size().y * 0.5);
1783
1784 ui.painter().galley(galley_origin, galley, palette.text);
1785
1786 let caret_rect = Rect::from_min_size(
1787 Pos2::new(caret_x, caret_y - caret_h * 0.5),
1788 Vec2::new(caret_w, caret_h),
1789 );
1790 let caret_color = if ctx.is_receiving {
1791 palette.sky
1792 } else {
1793 with_alpha(palette.text_faint, 80)
1794 };
1795 ui.painter()
1796 .rect_filled(caret_rect, CornerRadius::ZERO, caret_color);
1797}
1798
1799fn color_for_kind(kind: &LineKind, palette: &Palette) -> Color32 {
1800 match kind {
1801 LineKind::Out => palette.text,
1802 LineKind::Info => palette.text_faint,
1803 LineKind::Ok => palette.success,
1804 LineKind::Warn => palette.warning,
1805 LineKind::Err => palette.danger,
1806 LineKind::Dim => palette.text_muted,
1807 LineKind::Command { .. } => palette.text,
1808 }
1809}
1810
1811fn summary_layout(
1812 text: &str,
1813 palette: &Palette,
1814 size: f32,
1815 color: Color32,
1816 max_width: f32,
1817) -> LayoutJob {
1818 let mut job = LayoutJob::default();
1819 job.wrap.max_width = max_width;
1820 job.wrap.max_rows = 1;
1821 job.wrap.break_anywhere = true;
1822 job.wrap.overflow_character = Some('\u{2026}');
1823 job.append(
1824 text,
1825 0.0,
1826 TextFormat {
1827 font_id: FontId::new(size, FontFamily::Proportional),
1828 color,
1829 ..Default::default()
1830 },
1831 );
1832 let _ = palette;
1833 job
1834}
1835
1836struct QaResult {
1841 clicked: bool,
1842}
1843
1844#[allow(clippy::too_many_arguments)]
1845fn qa_button(
1846 ui: &mut Ui,
1847 bar_rect: Rect,
1848 x_right: &mut f32,
1849 id: Id,
1850 label: &str,
1851 shortcut: Option<&str>,
1852 active: bool,
1853 theme: &Theme,
1854) -> QaResult {
1855 let palette = &theme.palette;
1856 let typo = &theme.typography;
1857 let font = FontId::new(typo.small, FontFamily::Proportional);
1858 let label_galley = ui
1859 .painter()
1860 .layout_no_wrap(label.to_string(), font.clone(), palette.text);
1861
1862 let kbd_font = FontId::monospace(typo.small - 1.5);
1863 let kbd_galley = shortcut.map(|s| {
1864 ui.painter()
1865 .layout_no_wrap(s.to_string(), kbd_font.clone(), palette.text_faint)
1866 });
1867
1868 let icon_w = 16.0;
1869 let pad_x = 8.0;
1870 let label_w = label_galley.size().x;
1871 let kbd_w = kbd_galley.as_ref().map(|g| g.size().x + 8.0).unwrap_or(0.0);
1872 let btn_w = icon_w + 6.0 + label_w + kbd_w + pad_x * 2.0;
1873 let btn_h = bar_rect.height() - 10.0;
1874 let btn_rect = Rect::from_min_size(
1875 Pos2::new(*x_right - btn_w, bar_rect.center().y - btn_h * 0.5),
1876 Vec2::new(btn_w, btn_h),
1877 );
1878 *x_right = btn_rect.left() - 4.0;
1879
1880 let resp = ui.interact(btn_rect, id, Sense::click());
1881 let hover = resp.hovered();
1882
1883 let (fg, border, fill) = if active {
1884 (
1885 palette.sky,
1886 with_alpha(palette.sky, 110),
1887 with_alpha(palette.sky, 22),
1888 )
1889 } else if hover {
1890 (palette.text, palette.text_muted, Color32::TRANSPARENT)
1891 } else {
1892 (palette.text_muted, palette.border, Color32::TRANSPARENT)
1893 };
1894
1895 ui.painter().rect(
1896 btn_rect,
1897 CornerRadius::same(theme.control_radius as u8),
1898 fill,
1899 Stroke::new(1.0, border),
1900 StrokeKind::Inside,
1901 );
1902
1903 let icon_center = Pos2::new(btn_rect.left() + pad_x + icon_w * 0.5, btn_rect.center().y);
1905 paint_grid_icon(ui.painter(), icon_center, fg);
1906
1907 let label_x = btn_rect.left() + pad_x + icon_w + 6.0;
1909 let label_galley2 = ui
1910 .painter()
1911 .layout_no_wrap(label.to_string(), font.clone(), fg);
1912 ui.painter().galley(
1913 Pos2::new(label_x, btn_rect.center().y - label_galley2.size().y * 0.5),
1914 label_galley2,
1915 fg,
1916 );
1917
1918 if let Some(kbd) = shortcut {
1920 let kbd_galley2 =
1921 ui.painter()
1922 .layout_no_wrap(kbd.to_string(), kbd_font.clone(), palette.text_faint);
1923 let kbd_rect = Rect::from_min_size(
1924 Pos2::new(
1925 btn_rect.right() - pad_x - kbd_galley2.size().x - 8.0,
1926 btn_rect.center().y - (kbd_galley2.size().y + 2.0) * 0.5,
1927 ),
1928 Vec2::new(kbd_galley2.size().x + 8.0, kbd_galley2.size().y + 2.0),
1929 );
1930 ui.painter().rect(
1931 kbd_rect,
1932 CornerRadius::same(3),
1933 palette.input_bg,
1934 Stroke::new(1.0, palette.border),
1935 StrokeKind::Inside,
1936 );
1937 ui.painter().galley(
1938 Pos2::new(
1939 kbd_rect.left() + 4.0,
1940 kbd_rect.center().y - kbd_galley2.size().y * 0.5,
1941 ),
1942 kbd_galley2,
1943 palette.text_faint,
1944 );
1945 }
1946
1947 QaResult {
1948 clicked: resp.clicked(),
1949 }
1950}
1951
1952fn paint_grid_icon(painter: &egui::Painter, center: Pos2, color: Color32) {
1954 let pad = 1.0;
1955 let size = 5.5;
1956 for (dx, dy) in &[
1957 (-size - pad, -size - pad),
1958 (pad, -size - pad),
1959 (-size - pad, pad),
1960 (pad, pad),
1961 ] {
1962 let r = Rect::from_min_size(Pos2::new(center.x + dx, center.y + dy), Vec2::splat(size));
1963 painter.rect_stroke(
1964 r,
1965 CornerRadius::same(1),
1966 Stroke::new(1.2, color),
1967 StrokeKind::Inside,
1968 );
1969 }
1970}
1971
1972#[derive(Clone, Copy)]
1973enum ModePillStyle {
1974 Single,
1975 Selected,
1976 All,
1977}
1978
1979fn with_alpha(c: Color32, a: u8) -> Color32 {
1980 Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a)
1981}