1use crate::config::{Config, TabBarMode, TabBarPosition};
6use crate::tab::{TabId, TabManager};
7
8const CHEVRON_RESERVED: f32 = 28.0;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14struct StyledSegment {
15 text: String,
16 bold: bool,
17 italic: bool,
18 underline: bool,
19 color: Option<[u8; 3]>,
20}
21
22#[derive(Clone, Copy, Debug)]
23struct TitleStyle {
24 bold: bool,
25 italic: bool,
26 underline: bool,
27 color: Option<[u8; 3]>,
28}
29
30#[derive(Debug, Clone, PartialEq)]
32pub enum TabBarAction {
33 None,
35 SwitchTo(TabId),
37 Close(TabId),
39 NewTab,
41 NewTabWithProfile(crate::profile::ProfileId),
43 Reorder(TabId, usize),
45 SetColor(TabId, [u8; 3]),
47 ClearColor(TabId),
49 Duplicate(TabId),
51}
52
53pub struct TabBarUI {
55 pub hovered_tab: Option<TabId>,
57 pub close_hovered: Option<TabId>,
59 drag_in_progress: bool,
61 dragging_tab: Option<TabId>,
63 dragging_title: String,
65 dragging_color: Option<[u8; 3]>,
67 dragging_tab_width: f32,
69 drop_target_index: Option<usize>,
71 tab_rects: Vec<(TabId, egui::Rect)>,
73 context_menu_tab: Option<TabId>,
75 context_menu_pos: egui::Pos2,
77 context_menu_opened_frame: u64,
79 editing_color: [u8; 3],
81 scroll_offset: f32,
83 pub show_new_tab_profile_menu: bool,
85}
86
87impl TabBarUI {
88 pub fn new() -> Self {
90 Self {
91 hovered_tab: None,
92 close_hovered: None,
93 drag_in_progress: false,
94 dragging_tab: None,
95 dragging_title: String::new(),
96 dragging_color: None,
97 dragging_tab_width: 0.0,
98 drop_target_index: None,
99 tab_rects: Vec::new(),
100 context_menu_tab: None,
101 context_menu_pos: egui::Pos2::ZERO,
102 context_menu_opened_frame: 0,
103 editing_color: [100, 100, 100],
104 scroll_offset: 0.0,
105 show_new_tab_profile_menu: false,
106 }
107 }
108
109 pub fn should_show(&self, tab_count: usize, mode: TabBarMode) -> bool {
111 match mode {
112 TabBarMode::Always => true,
113 TabBarMode::WhenMultiple => tab_count > 1,
114 TabBarMode::Never => false,
115 }
116 }
117
118 pub fn is_dragging(&self) -> bool {
120 self.drag_in_progress
121 }
122
123 pub fn render(
125 &mut self,
126 ctx: &egui::Context,
127 tabs: &TabManager,
128 config: &Config,
129 profiles: &crate::profile::ProfileManager,
130 ) -> TabBarAction {
131 let tab_count = tabs.tab_count();
132
133 if !self.should_show(tab_count, config.tab_bar_mode) {
135 return TabBarAction::None;
136 }
137
138 match config.tab_bar_position {
139 TabBarPosition::Left => self.render_vertical(ctx, tabs, config, profiles),
140 _ => self.render_horizontal(ctx, tabs, config, profiles),
141 }
142 }
143
144 fn render_horizontal(
146 &mut self,
147 ctx: &egui::Context,
148 tabs: &TabManager,
149 config: &Config,
150 profiles: &crate::profile::ProfileManager,
151 ) -> TabBarAction {
152 let tab_count = tabs.tab_count();
153
154 self.tab_rects.clear();
156
157 let mut action = TabBarAction::None;
158 let active_tab_id = tabs.active_tab_id();
159
160 let tab_spacing = 4.0;
162 let new_tab_btn_width = 28.0
163 + if profiles.is_empty() {
164 0.0
165 } else {
166 CHEVRON_RESERVED
167 };
168 let scroll_btn_width = 24.0;
169
170 let bar_bg = config.tab_bar_background;
171 let frame =
172 egui::Frame::NONE.fill(egui::Color32::from_rgb(bar_bg[0], bar_bg[1], bar_bg[2]));
173
174 let panel = if config.tab_bar_position == TabBarPosition::Bottom {
175 egui::TopBottomPanel::bottom("tab_bar").exact_height(config.tab_bar_height)
176 } else {
177 egui::TopBottomPanel::top("tab_bar").exact_height(config.tab_bar_height)
178 };
179
180 panel.frame(frame).show(ctx, |ui| {
181 let total_bar_width = ui.available_width();
182
183 let min_total_tabs_width = if tab_count > 0 {
185 tab_count as f32 * config.tab_min_width + (tab_count - 1) as f32 * tab_spacing
186 } else {
187 0.0
188 };
189
190 let base_tabs_area_width = total_bar_width - new_tab_btn_width - tab_spacing;
192
193 let needs_scroll = tab_count > 0 && min_total_tabs_width > base_tabs_area_width;
195
196 let tabs_area_width = if needs_scroll {
198 base_tabs_area_width - 2.0 * scroll_btn_width - 2.0 * tab_spacing
199 } else {
200 base_tabs_area_width
201 };
202
203 let tab_width = if tab_count == 0 || needs_scroll {
205 config.tab_min_width
206 } else if config.tab_stretch_to_fill {
207 let total_spacing = (tab_count - 1) as f32 * tab_spacing;
208 let stretched = (tabs_area_width - total_spacing) / tab_count as f32;
209 stretched.max(config.tab_min_width)
210 } else {
211 config.tab_min_width
212 };
213
214 let max_scroll = if needs_scroll {
216 (min_total_tabs_width - tabs_area_width).max(0.0)
217 } else {
218 0.0
219 };
220
221 self.scroll_offset = self.scroll_offset.clamp(0.0, max_scroll);
223
224 ui.horizontal(|ui| {
225 ui.spacing_mut().item_spacing = egui::vec2(tab_spacing, 0.0);
226
227 if needs_scroll {
228 let can_scroll_left = self.scroll_offset > 0.0;
230 let left_btn = ui.add_enabled(
231 can_scroll_left,
232 egui::Button::new("◀")
233 .min_size(egui::vec2(scroll_btn_width, config.tab_bar_height - 4.0))
234 .fill(egui::Color32::TRANSPARENT),
235 );
236 if left_btn.clicked() {
237 self.scroll_offset =
238 (self.scroll_offset - tab_width - tab_spacing).max(0.0);
239 }
240
241 let scroll_area_response = egui::ScrollArea::horizontal()
243 .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
244 .max_width(tabs_area_width)
245 .horizontal_scroll_offset(self.scroll_offset)
246 .show(ui, |ui| {
247 ui.horizontal(|ui| {
248 ui.spacing_mut().item_spacing = egui::vec2(tab_spacing, 0.0);
249
250 for (index, tab) in tabs.tabs().iter().enumerate() {
251 let is_active = Some(tab.id) == active_tab_id;
252 let is_bell_active = tab.is_bell_active();
253 let (tab_action, tab_rect) = self.render_tab_with_width(
254 ui,
255 tab.id,
256 index,
257 &tab.title,
258 tab.profile_icon.as_deref(),
259 is_active,
260 tab.has_activity,
261 is_bell_active,
262 tab.custom_color,
263 config,
264 tab_width,
265 tab_count,
266 );
267 self.tab_rects.push((tab.id, tab_rect));
268
269 if tab_action != TabBarAction::None {
270 action = tab_action;
271 }
272 }
273 });
274 });
275
276 self.scroll_offset = scroll_area_response.state.offset.x;
278
279 let can_scroll_right = self.scroll_offset < max_scroll;
281 let right_btn = ui.add_enabled(
282 can_scroll_right,
283 egui::Button::new("▶")
284 .min_size(egui::vec2(scroll_btn_width, config.tab_bar_height - 4.0))
285 .fill(egui::Color32::TRANSPARENT),
286 );
287 if right_btn.clicked() {
288 self.scroll_offset =
289 (self.scroll_offset + tab_width + tab_spacing).min(max_scroll);
290 }
291 } else {
292 for (index, tab) in tabs.tabs().iter().enumerate() {
294 let is_active = Some(tab.id) == active_tab_id;
295 let is_bell_active = tab.is_bell_active();
296 let (tab_action, tab_rect) = self.render_tab_with_width(
297 ui,
298 tab.id,
299 index,
300 &tab.title,
301 tab.profile_icon.as_deref(),
302 is_active,
303 tab.has_activity,
304 is_bell_active,
305 tab.custom_color,
306 config,
307 tab_width,
308 tab_count,
309 );
310 self.tab_rects.push((tab.id, tab_rect));
311
312 if tab_action != TabBarAction::None {
313 action = tab_action;
314 }
315 }
316 }
317
318 ui.add_space(tab_spacing);
320
321 let prev_spacing = ui.spacing().item_spacing.x;
323 ui.spacing_mut().item_spacing.x = 0.0;
324
325 let plus_btn = ui.add(
327 egui::Button::new("+")
328 .min_size(egui::vec2(28.0, config.tab_bar_height - 4.0))
329 .fill(egui::Color32::TRANSPARENT),
330 );
331 if plus_btn.clicked_by(egui::PointerButton::Primary) {
332 action = TabBarAction::NewTab;
333 }
334 if plus_btn.hovered() {
335 #[cfg(target_os = "macos")]
336 plus_btn.on_hover_text("New Tab (Cmd+T)");
337 #[cfg(not(target_os = "macos"))]
338 plus_btn.on_hover_text("New Tab (Ctrl+Shift+T)");
339 }
340
341 if !profiles.is_empty() {
343 let chevron_btn = ui.add(
344 egui::Button::new("⏷")
345 .min_size(egui::vec2(14.0, config.tab_bar_height - 4.0))
346 .fill(egui::Color32::TRANSPARENT),
347 );
348 if chevron_btn.clicked_by(egui::PointerButton::Primary) {
349 self.show_new_tab_profile_menu = !self.show_new_tab_profile_menu;
350 }
351 if chevron_btn.hovered() {
352 chevron_btn.on_hover_text("New tab from profile");
353 }
354 }
355
356 ui.spacing_mut().item_spacing.x = prev_spacing;
358 });
359
360 if self.drag_in_progress {
363 let drag_action = self.render_drag_feedback(ui, config);
364 if drag_action != TabBarAction::None {
365 action = drag_action;
366 }
367 }
368 });
369
370 if self.drag_in_progress && self.dragging_tab.is_some() {
372 self.render_ghost_tab(ctx, config);
373 }
374
375 if let Some(context_tab_id) = self.context_menu_tab {
377 let menu_action = self.render_context_menu(ctx, context_tab_id);
378 if menu_action != TabBarAction::None {
379 action = menu_action;
380 }
381 }
382
383 let menu_action = self.render_new_tab_profile_menu(ctx, profiles);
385 if menu_action != TabBarAction::None {
386 action = menu_action;
387 }
388
389 action
390 }
391
392 fn render_vertical(
394 &mut self,
395 ctx: &egui::Context,
396 tabs: &TabManager,
397 config: &Config,
398 profiles: &crate::profile::ProfileManager,
399 ) -> TabBarAction {
400 let tab_count = tabs.tab_count();
401
402 self.tab_rects.clear();
403
404 let mut action = TabBarAction::None;
405 let active_tab_id = tabs.active_tab_id();
406
407 let bar_bg = config.tab_bar_background;
408 let tab_spacing = 4.0;
409 let tab_height = config.tab_bar_height; egui::SidePanel::left("tab_bar")
412 .exact_width(config.tab_bar_width)
413 .frame(egui::Frame::NONE.fill(egui::Color32::from_rgb(bar_bg[0], bar_bg[1], bar_bg[2])))
414 .show(ctx, |ui| {
415 egui::ScrollArea::vertical()
416 .scroll_bar_visibility(
417 egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded,
418 )
419 .show(ui, |ui| {
420 ui.vertical(|ui| {
421 ui.spacing_mut().item_spacing = egui::vec2(0.0, tab_spacing);
422
423 for (index, tab) in tabs.tabs().iter().enumerate() {
424 let is_active = Some(tab.id) == active_tab_id;
425 let is_bell_active = tab.is_bell_active();
426 let (tab_action, tab_rect) = self.render_vertical_tab(
427 ui,
428 tab.id,
429 index,
430 &tab.title,
431 tab.profile_icon.as_deref(),
432 is_active,
433 tab.has_activity,
434 is_bell_active,
435 tab.custom_color,
436 config,
437 tab_height,
438 tab_count,
439 );
440 self.tab_rects.push((tab.id, tab_rect));
441
442 if tab_action != TabBarAction::None {
443 action = tab_action;
444 }
445 }
446
447 ui.add_space(tab_spacing);
449 ui.horizontal(|ui| {
450 ui.spacing_mut().item_spacing.x = 0.0;
452
453 let chevron_space = if profiles.is_empty() {
454 0.0
455 } else {
456 CHEVRON_RESERVED
457 };
458 let plus_btn = ui.add(
459 egui::Button::new("+")
460 .min_size(egui::vec2(
461 ui.available_width() - chevron_space,
462 tab_height - 4.0,
463 ))
464 .fill(egui::Color32::TRANSPARENT),
465 );
466 if plus_btn.clicked_by(egui::PointerButton::Primary) {
467 action = TabBarAction::NewTab;
468 }
469 if plus_btn.hovered() {
470 #[cfg(target_os = "macos")]
471 plus_btn.on_hover_text("New Tab (Cmd+T)");
472 #[cfg(not(target_os = "macos"))]
473 plus_btn.on_hover_text("New Tab (Ctrl+Shift+T)");
474 }
475
476 if !profiles.is_empty() {
477 let chevron_btn = ui.add(
478 egui::Button::new("⏷")
479 .min_size(egui::vec2(14.0, tab_height - 4.0))
480 .fill(egui::Color32::TRANSPARENT),
481 );
482 if chevron_btn.clicked_by(egui::PointerButton::Primary) {
483 self.show_new_tab_profile_menu =
484 !self.show_new_tab_profile_menu;
485 }
486 if chevron_btn.hovered() {
487 chevron_btn.on_hover_text("New tab from profile");
488 }
489 }
490 });
491 });
492 });
493
494 if self.drag_in_progress {
496 let drag_action = self.render_vertical_drag_feedback(ui, config);
497 if drag_action != TabBarAction::None {
498 action = drag_action;
499 }
500 }
501 });
502
503 if self.drag_in_progress && self.dragging_tab.is_some() {
505 self.render_ghost_tab(ctx, config);
506 }
507
508 if let Some(context_tab_id) = self.context_menu_tab {
510 let menu_action = self.render_context_menu(ctx, context_tab_id);
511 if menu_action != TabBarAction::None {
512 action = menu_action;
513 }
514 }
515
516 let menu_action = self.render_new_tab_profile_menu(ctx, profiles);
518 if menu_action != TabBarAction::None {
519 action = menu_action;
520 }
521
522 action
523 }
524
525 #[allow(clippy::too_many_arguments)]
527 fn render_vertical_tab(
528 &mut self,
529 ui: &mut egui::Ui,
530 id: TabId,
531 _index: usize,
532 title: &str,
533 profile_icon: Option<&str>,
534 is_active: bool,
535 has_activity: bool,
536 is_bell_active: bool,
537 custom_color: Option<[u8; 3]>,
538 config: &Config,
539 tab_height: f32,
540 tab_count: usize,
541 ) -> (TabBarAction, egui::Rect) {
542 let mut action = TabBarAction::None;
543
544 let is_hovered = self.hovered_tab == Some(id);
545 let is_being_dragged = self.dragging_tab == Some(id) && self.drag_in_progress;
546 let should_dim =
547 is_being_dragged || (config.dim_inactive_tabs && !is_active && !is_hovered);
548 let opacity = if is_being_dragged {
549 100
550 } else if should_dim {
551 (config.inactive_tab_opacity * 255.0) as u8
552 } else {
553 255
554 };
555
556 let bg_color = if let Some(custom) = custom_color {
557 if is_active {
558 egui::Color32::from_rgba_unmultiplied(custom[0], custom[1], custom[2], 255)
559 } else if is_hovered {
560 let lighten = |c: u8| c.saturating_add(20);
561 egui::Color32::from_rgba_unmultiplied(
562 lighten(custom[0]),
563 lighten(custom[1]),
564 lighten(custom[2]),
565 255,
566 )
567 } else {
568 let darken = |c: u8| c.saturating_sub(30);
569 egui::Color32::from_rgba_unmultiplied(
570 darken(custom[0]),
571 darken(custom[1]),
572 darken(custom[2]),
573 opacity,
574 )
575 }
576 } else if is_active {
577 let c = config.tab_active_background;
578 egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], 255)
579 } else if is_hovered {
580 let c = config.tab_hover_background;
581 egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], 255)
582 } else {
583 let c = config.tab_inactive_background;
584 egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], opacity)
585 };
586
587 let full_width = ui.available_width();
588 let (tab_rect, _) =
589 ui.allocate_exact_size(egui::vec2(full_width, tab_height), egui::Sense::hover());
590
591 let tab_draw_rect = tab_rect.shrink2(egui::vec2(2.0, 1.0));
592 let tab_rounding = 4.0;
593 if ui.is_rect_visible(tab_rect) {
594 ui.painter()
595 .rect_filled(tab_draw_rect, tab_rounding, bg_color);
596
597 if is_active {
599 let c = if let Some(custom) = custom_color {
600 let lighten = |v: u8| v.saturating_add(50);
601 [lighten(custom[0]), lighten(custom[1]), lighten(custom[2])]
602 } else {
603 config.tab_active_indicator
604 };
605 let indicator_rect = egui::Rect::from_min_size(
606 tab_draw_rect.left_top(),
607 egui::vec2(3.0, tab_draw_rect.height()),
608 );
609 ui.painter().rect_filled(
610 indicator_rect,
611 egui::CornerRadius {
612 nw: tab_rounding as u8,
613 sw: tab_rounding as u8,
614 ne: 0,
615 se: 0,
616 },
617 egui::Color32::from_rgb(c[0], c[1], c[2]),
618 );
619 }
620
621 let content_rect = tab_rect.shrink2(egui::vec2(8.0, 2.0));
623 let mut content_ui = ui.new_child(
624 egui::UiBuilder::new()
625 .max_rect(content_rect)
626 .layout(egui::Layout::left_to_right(egui::Align::Center)),
627 );
628
629 content_ui.horizontal(|ui| {
630 if is_bell_active {
631 let c = config.tab_bell_indicator;
632 ui.colored_label(egui::Color32::from_rgb(c[0], c[1], c[2]), "🔔");
633 ui.add_space(2.0);
634 } else if has_activity && !is_active {
635 let c = config.tab_activity_indicator;
636 ui.colored_label(egui::Color32::from_rgb(c[0], c[1], c[2]), "•");
637 ui.add_space(2.0);
638 }
639
640 let icon_width = if let Some(icon) = profile_icon {
642 ui.label(icon);
643 ui.add_space(2.0);
644 18.0
645 } else {
646 0.0
647 };
648
649 let text_color = if is_active {
650 let c = config.tab_active_text;
651 egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], 255)
652 } else {
653 let c = config.tab_inactive_text;
654 egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], opacity)
655 };
656
657 let close_width = if config.tab_show_close_button {
659 20.0
660 } else {
661 0.0
662 };
663 let available = (full_width - 16.0 - icon_width - close_width).max(20.0);
664 let base_font_id = ui.style().text_styles[&egui::TextStyle::Button].clone();
665 let max_chars = estimate_max_chars(ui, &base_font_id, available);
666 let display_title = truncate_plain(title, max_chars);
667 ui.label(egui::RichText::new(display_title).color(text_color));
668 });
669
670 if config.tab_show_close_button {
672 let close_size = 16.0;
673 let close_rect = egui::Rect::from_min_size(
674 egui::pos2(
675 tab_rect.right() - close_size - 4.0,
676 tab_rect.center().y - close_size / 2.0,
677 ),
678 egui::vec2(close_size, close_size),
679 );
680 let pointer_pos = ui.ctx().input(|i| i.pointer.hover_pos());
681 let close_hovered = pointer_pos.is_some_and(|pos| close_rect.contains(pos));
682
683 if close_hovered {
684 self.close_hovered = Some(id);
685 } else if self.close_hovered == Some(id) {
686 self.close_hovered = None;
687 }
688
689 let close_color = if self.close_hovered == Some(id) {
690 let c = config.tab_close_button_hover;
691 egui::Color32::from_rgb(c[0], c[1], c[2])
692 } else {
693 let c = config.tab_close_button;
694 egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], opacity)
695 };
696
697 ui.painter().text(
698 close_rect.center(),
699 egui::Align2::CENTER_CENTER,
700 "×",
701 egui::FontId::proportional(12.0),
702 close_color,
703 );
704 }
705 }
706
707 let tab_response = ui.interact(
709 tab_rect,
710 egui::Id::new(("tab_click", id)),
711 egui::Sense::click_and_drag(),
712 );
713
714 let pointer_in_tab = tab_response.hovered();
715 let clicked = tab_response.clicked_by(egui::PointerButton::Primary);
716
717 if tab_count > 1
718 && !self.drag_in_progress
719 && self.close_hovered != Some(id)
720 && tab_response.drag_started_by(egui::PointerButton::Primary)
721 {
722 self.drag_in_progress = true;
723 self.dragging_tab = Some(id);
724 self.dragging_title = title.to_string();
725 self.dragging_color = custom_color;
726 self.dragging_tab_width = full_width;
727 }
728
729 let is_dragging_this = self.dragging_tab == Some(id) && self.drag_in_progress;
730
731 if clicked
732 && !is_dragging_this
733 && action == TabBarAction::None
734 && self.close_hovered != Some(id)
735 {
736 action = TabBarAction::SwitchTo(id);
737 }
738
739 if clicked && self.close_hovered == Some(id) {
740 action = TabBarAction::Close(id);
741 }
742
743 if tab_response.secondary_clicked() {
744 self.editing_color = custom_color.unwrap_or([100, 100, 100]);
745 self.context_menu_tab = Some(id);
746 if let Some(pos) = ui.ctx().input(|i| i.pointer.interact_pos()) {
747 self.context_menu_pos = pos;
748 }
749 self.context_menu_opened_frame = ui.ctx().cumulative_frame_nr();
750 }
751
752 if pointer_in_tab {
753 self.hovered_tab = Some(id);
754 } else if self.hovered_tab == Some(id) {
755 self.hovered_tab = None;
756 }
757
758 (action, tab_rect)
759 }
760
761 fn render_vertical_drag_feedback(
763 &mut self,
764 ui: &mut egui::Ui,
765 config: &Config,
766 ) -> TabBarAction {
767 let mut action = TabBarAction::None;
768
769 let dragging_id = match self.dragging_tab {
770 Some(id) => id,
771 None => {
772 self.drag_in_progress = false;
773 return action;
774 }
775 };
776
777 if ui.ctx().input(|i| i.key_pressed(egui::Key::Escape)) {
778 self.drag_in_progress = false;
779 self.dragging_tab = None;
780 self.drop_target_index = None;
781 return action;
782 }
783
784 ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
785
786 let drag_source_index = self.tab_rects.iter().position(|(id, _)| *id == dragging_id);
787
788 if let Some(pointer_pos) = ui.ctx().input(|i| i.pointer.hover_pos()) {
789 let mut insert_index = self.tab_rects.len();
790 for (i, (_id, rect)) in self.tab_rects.iter().enumerate() {
791 if pointer_pos.y < rect.center().y {
792 insert_index = i;
793 break;
794 }
795 }
796
797 let is_noop =
798 drag_source_index.is_some_and(|src| insert_index == src || insert_index == src + 1);
799
800 if is_noop {
801 self.drop_target_index = None;
802 } else {
803 self.drop_target_index = Some(insert_index);
804
805 let indicator_y = if insert_index < self.tab_rects.len() {
807 self.tab_rects[insert_index].1.top() - 2.0
808 } else if let Some(last) = self.tab_rects.last() {
809 last.1.bottom() + 2.0
810 } else {
811 0.0
812 };
813
814 let indicator_color = egui::Color32::from_rgb(80, 160, 255);
815 let left = config.tab_bar_width * 0.05;
816 let right = config.tab_bar_width * 0.95;
817
818 ui.painter().line_segment(
819 [
820 egui::pos2(left, indicator_y),
821 egui::pos2(right, indicator_y),
822 ],
823 egui::Stroke::new(3.0, indicator_color),
824 );
825 }
826 }
827
828 if ui.ctx().input(|i| i.pointer.any_released()) {
829 if let Some(insert_idx) = self.drop_target_index {
830 let effective_target = if let Some(src) = drag_source_index {
831 if insert_idx > src {
832 insert_idx - 1
833 } else {
834 insert_idx
835 }
836 } else {
837 insert_idx
838 };
839 action = TabBarAction::Reorder(dragging_id, effective_target);
840 }
841 self.drag_in_progress = false;
842 self.dragging_tab = None;
843 self.drop_target_index = None;
844 }
845
846 action
847 }
848
849 #[allow(clippy::too_many_arguments)]
851 fn render_tab_with_width(
852 &mut self,
853 ui: &mut egui::Ui,
854 id: TabId,
855 index: usize,
856 title: &str,
857 profile_icon: Option<&str>,
858 is_active: bool,
859 has_activity: bool,
860 is_bell_active: bool,
861 custom_color: Option<[u8; 3]>,
862 config: &Config,
863 tab_width: f32,
864 tab_count: usize,
865 ) -> (TabBarAction, egui::Rect) {
866 let mut action = TabBarAction::None;
867
868 let is_hovered = self.hovered_tab == Some(id);
872 let is_being_dragged = self.dragging_tab == Some(id) && self.drag_in_progress;
873 let should_dim =
874 is_being_dragged || (config.dim_inactive_tabs && !is_active && !is_hovered);
875 let opacity = if is_being_dragged {
876 100
877 } else if should_dim {
878 (config.inactive_tab_opacity * 255.0) as u8
879 } else {
880 255
881 };
882
883 let bg_color = if let Some(custom) = custom_color {
886 if is_active {
888 egui::Color32::from_rgba_unmultiplied(custom[0], custom[1], custom[2], 255)
889 } else if is_hovered {
890 let lighten = |c: u8| c.saturating_add(20);
892 egui::Color32::from_rgba_unmultiplied(
893 lighten(custom[0]),
894 lighten(custom[1]),
895 lighten(custom[2]),
896 255,
897 )
898 } else {
899 let darken = |c: u8| c.saturating_sub(30);
901 egui::Color32::from_rgba_unmultiplied(
902 darken(custom[0]),
903 darken(custom[1]),
904 darken(custom[2]),
905 opacity,
906 )
907 }
908 } else if is_active {
909 let c = config.tab_active_background;
910 egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], 255)
911 } else if is_hovered {
912 let c = config.tab_hover_background;
913 egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], 255)
914 } else {
915 let c = config.tab_inactive_background;
916 egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], opacity)
917 };
918
919 let (tab_rect, _) = ui.allocate_exact_size(
921 egui::vec2(tab_width, config.tab_bar_height),
922 egui::Sense::hover(),
923 );
924
925 let tab_draw_rect = tab_rect.shrink2(egui::vec2(0.0, 2.0));
929 let tab_rounding = tab_draw_rect.height() / 2.0;
930 if ui.is_rect_visible(tab_rect) {
931 ui.painter()
932 .rect_filled(tab_draw_rect, tab_rounding, bg_color);
933
934 if config.tab_border_width > 0.0 || is_active {
937 let (border_color, border_width) = if is_active {
938 let c = if let Some(custom) = custom_color {
940 let lighten = |v: u8| v.saturating_add(50);
942 [lighten(custom[0]), lighten(custom[1]), lighten(custom[2])]
943 } else {
944 config.tab_active_indicator
945 };
946 (c, config.tab_border_width.max(1.5))
947 } else {
948 (config.tab_border_color, config.tab_border_width)
950 };
951
952 if border_width > 0.0 {
953 ui.painter().rect_stroke(
954 tab_draw_rect,
955 tab_rounding,
956 egui::Stroke::new(
957 border_width,
958 egui::Color32::from_rgb(
959 border_color[0],
960 border_color[1],
961 border_color[2],
962 ),
963 ),
964 egui::StrokeKind::Middle,
965 );
966 }
967 }
968
969 let mut content_ui = ui.new_child(
971 egui::UiBuilder::new()
972 .max_rect(tab_rect.shrink2(egui::vec2(8.0, 4.0)))
973 .layout(egui::Layout::left_to_right(egui::Align::Center)),
974 );
975
976 content_ui.horizontal(|ui| {
977 if is_bell_active {
979 let c = config.tab_bell_indicator;
980 ui.colored_label(egui::Color32::from_rgb(c[0], c[1], c[2]), "🔔");
981 ui.add_space(4.0);
982 } else if has_activity && !is_active {
983 let c = config.tab_activity_indicator;
985 ui.colored_label(egui::Color32::from_rgb(c[0], c[1], c[2]), "•");
986 ui.add_space(4.0);
987 }
988
989 if config.tab_show_index {
991 }
993
994 let icon_width = if let Some(icon) = profile_icon {
996 ui.label(icon);
997 ui.add_space(2.0);
998 18.0
999 } else {
1000 0.0
1001 };
1002
1003 let base_font_id = ui.style().text_styles[&egui::TextStyle::Button].clone();
1005 let indicator_width = if is_bell_active {
1006 18.0
1007 } else if has_activity && !is_active {
1008 14.0
1009 } else {
1010 0.0
1011 };
1012 let hotkey_width = if index < 9 { 26.0 } else { 0.0 };
1013 let close_width = if config.tab_show_close_button {
1014 24.0
1015 } else {
1016 0.0
1017 };
1018 let padding = 12.0;
1019 let title_available_width = (tab_width
1020 - indicator_width
1021 - icon_width
1022 - hotkey_width
1023 - close_width
1024 - padding)
1025 .max(24.0);
1026
1027 let max_chars = estimate_max_chars(ui, &base_font_id, title_available_width);
1028
1029 let text_color = if is_active {
1030 let c = config.tab_active_text;
1031 egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], 255)
1032 } else {
1033 let c = config.tab_inactive_text;
1034 egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], opacity)
1035 };
1036
1037 if config.tab_html_titles {
1038 let segments = parse_html_title(title);
1039 let truncated = truncate_segments(&segments, max_chars);
1040 render_segments(ui, &truncated, text_color);
1041 } else {
1042 let display_title = truncate_plain(title, max_chars);
1043 ui.label(egui::RichText::new(display_title).color(text_color));
1044 }
1045
1046 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
1048 if config.tab_show_close_button {
1050 ui.add_space(24.0);
1051 }
1052 if index < 9 {
1053 let modifier_symbol = if cfg!(target_os = "macos") {
1055 "⌘"
1056 } else {
1057 "^"
1058 };
1059 let hotkey_text = format!("{}{}", modifier_symbol, index + 1);
1060 let hotkey_color =
1061 egui::Color32::from_rgba_unmultiplied(180, 180, 180, opacity);
1062 ui.label(
1063 egui::RichText::new(hotkey_text)
1064 .color(hotkey_color)
1065 .size(11.0),
1066 );
1067 }
1068 });
1069 });
1070 }
1071
1072 let close_btn_size = 20.0;
1075 let close_btn_rect = if config.tab_show_close_button {
1076 Some(egui::Rect::from_min_size(
1077 egui::pos2(
1078 tab_rect.right() - close_btn_size - 4.0,
1079 tab_rect.center().y - close_btn_size / 2.0,
1080 ),
1081 egui::vec2(close_btn_size, close_btn_size),
1082 ))
1083 } else {
1084 None
1085 };
1086
1087 let pointer_pos = ui.ctx().input(|i| i.pointer.hover_pos());
1089 let close_hovered = close_btn_rect
1090 .zip(pointer_pos)
1091 .is_some_and(|(rect, pos)| rect.contains(pos));
1092
1093 if close_hovered {
1094 self.close_hovered = Some(id);
1095 } else if self.close_hovered == Some(id) {
1096 self.close_hovered = None;
1097 }
1098
1099 if let Some(close_rect) = close_btn_rect {
1101 let close_color = if self.close_hovered == Some(id) {
1102 let c = config.tab_close_button_hover;
1103 egui::Color32::from_rgb(c[0], c[1], c[2])
1104 } else {
1105 let c = config.tab_close_button;
1106 egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], opacity)
1107 };
1108
1109 ui.painter().text(
1111 close_rect.center(),
1112 egui::Align2::CENTER_CENTER,
1113 "×",
1114 egui::FontId::proportional(14.0),
1115 close_color,
1116 );
1117 }
1118
1119 let tab_response = ui.interact(
1122 tab_rect,
1123 egui::Id::new(("tab_click", id)),
1124 egui::Sense::click_and_drag(),
1125 );
1126
1127 let pointer_in_tab = tab_response.hovered();
1129 let clicked = tab_response.clicked_by(egui::PointerButton::Primary);
1130
1131 if tab_count > 1
1134 && !self.drag_in_progress
1135 && self.close_hovered != Some(id)
1136 && tab_response.drag_started_by(egui::PointerButton::Primary)
1137 {
1138 self.drag_in_progress = true;
1139 self.dragging_tab = Some(id);
1140 self.dragging_title = title.to_string();
1141 self.dragging_color = custom_color;
1142 self.dragging_tab_width = tab_width;
1143 }
1144
1145 let is_dragging_this = self.dragging_tab == Some(id) && self.drag_in_progress;
1147
1148 if clicked
1152 && !is_dragging_this
1153 && action == TabBarAction::None
1154 && self.close_hovered != Some(id)
1155 {
1156 action = TabBarAction::SwitchTo(id);
1157 }
1158
1159 if clicked && self.close_hovered == Some(id) {
1161 action = TabBarAction::Close(id);
1162 }
1163
1164 if tab_response.secondary_clicked() {
1166 self.editing_color = custom_color.unwrap_or([100, 100, 100]);
1168 self.context_menu_tab = Some(id);
1169 if let Some(pos) = ui.ctx().input(|i| i.pointer.interact_pos()) {
1171 self.context_menu_pos = pos;
1172 }
1173 self.context_menu_opened_frame = ui.ctx().cumulative_frame_nr();
1175 }
1176
1177 if pointer_in_tab {
1179 self.hovered_tab = Some(id);
1180 } else if self.hovered_tab == Some(id) {
1181 self.hovered_tab = None;
1182 }
1183
1184 (action, tab_rect)
1185 }
1186
1187 fn render_drag_feedback(&mut self, ui: &mut egui::Ui, config: &Config) -> TabBarAction {
1189 let mut action = TabBarAction::None;
1190
1191 let dragging_id = match self.dragging_tab {
1192 Some(id) => id,
1193 None => {
1194 self.drag_in_progress = false;
1195 return action;
1196 }
1197 };
1198
1199 if ui.ctx().input(|i| i.key_pressed(egui::Key::Escape)) {
1201 self.drag_in_progress = false;
1202 self.dragging_tab = None;
1203 self.drop_target_index = None;
1204 return action;
1205 }
1206
1207 ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
1209
1210 let drag_source_index = self.tab_rects.iter().position(|(id, _)| *id == dragging_id);
1212
1213 if let Some(pointer_pos) = ui.ctx().input(|i| i.pointer.hover_pos()) {
1215 let mut insert_index = self.tab_rects.len(); for (i, (_id, rect)) in self.tab_rects.iter().enumerate() {
1217 if pointer_pos.x < rect.center().x {
1218 insert_index = i;
1219 break;
1220 }
1221 }
1222
1223 let is_noop =
1225 drag_source_index.is_some_and(|src| insert_index == src || insert_index == src + 1);
1226
1227 if is_noop {
1228 self.drop_target_index = None;
1229 } else {
1230 self.drop_target_index = Some(insert_index);
1231
1232 let indicator_x = if insert_index < self.tab_rects.len() {
1234 self.tab_rects[insert_index].1.left() - 2.0
1235 } else if let Some(last) = self.tab_rects.last() {
1236 last.1.right() + 2.0
1237 } else {
1238 0.0
1239 };
1240
1241 let indicator_color = egui::Color32::from_rgb(80, 160, 255);
1242 let glow_color = egui::Color32::from_rgba_unmultiplied(80, 160, 255, 50);
1243 let top = config.tab_bar_height * 0.1;
1244 let bottom = config.tab_bar_height * 0.9;
1245
1246 ui.painter().rect_filled(
1248 egui::Rect::from_min_max(
1249 egui::pos2(indicator_x - 4.0, top),
1250 egui::pos2(indicator_x + 4.0, bottom),
1251 ),
1252 2.0,
1253 glow_color,
1254 );
1255
1256 ui.painter().line_segment(
1258 [
1259 egui::pos2(indicator_x, top),
1260 egui::pos2(indicator_x, bottom),
1261 ],
1262 egui::Stroke::new(3.0, indicator_color),
1263 );
1264
1265 let diamond_size = 4.0;
1267 for y in [top, bottom] {
1268 ui.painter().circle_filled(
1269 egui::pos2(indicator_x, y),
1270 diamond_size,
1271 indicator_color,
1272 );
1273 }
1274 }
1275 }
1276
1277 if ui.ctx().input(|i| i.pointer.any_released()) {
1279 if let Some(insert_idx) = self.drop_target_index {
1280 let effective_target = if let Some(src) = drag_source_index {
1282 if insert_idx > src {
1283 insert_idx - 1
1284 } else {
1285 insert_idx
1286 }
1287 } else {
1288 insert_idx
1289 };
1290 action = TabBarAction::Reorder(dragging_id, effective_target);
1291 }
1292 self.drag_in_progress = false;
1293 self.dragging_tab = None;
1294 self.drop_target_index = None;
1295 }
1296
1297 action
1298 }
1299
1300 fn render_ghost_tab(&self, ctx: &egui::Context, config: &Config) {
1302 let Some(pointer_pos) = ctx.input(|i| i.pointer.hover_pos()) else {
1303 return;
1304 };
1305
1306 let ghost_width = self.dragging_tab_width;
1307 let ghost_height = config.tab_bar_height - 4.0;
1308
1309 let ghost_pos = egui::pos2(
1311 pointer_pos.x - ghost_width / 2.0,
1312 pointer_pos.y - ghost_height / 2.0,
1313 );
1314
1315 let bg_color = if let Some(custom) = self.dragging_color {
1317 egui::Color32::from_rgba_unmultiplied(custom[0], custom[1], custom[2], 200)
1318 } else {
1319 let c = config.tab_active_background;
1320 egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], 200)
1321 };
1322
1323 let border_color = egui::Color32::from_rgba_unmultiplied(120, 180, 255, 200);
1324
1325 egui::Area::new(egui::Id::new("tab_drag_ghost"))
1326 .fixed_pos(ghost_pos)
1327 .order(egui::Order::Tooltip)
1328 .interactable(false)
1329 .show(ctx, |ui| {
1330 let (rect, _) = ui.allocate_exact_size(
1331 egui::vec2(ghost_width, ghost_height),
1332 egui::Sense::hover(),
1333 );
1334
1335 let rounding = ghost_height / 2.0;
1336
1337 let shadow_rect = rect.translate(egui::vec2(2.0, 2.0));
1339 ui.painter().rect_filled(
1340 shadow_rect,
1341 rounding,
1342 egui::Color32::from_rgba_unmultiplied(0, 0, 0, 80),
1343 );
1344
1345 ui.painter().rect_filled(rect, rounding, bg_color);
1347
1348 ui.painter().rect_stroke(
1350 rect,
1351 rounding,
1352 egui::Stroke::new(1.5, border_color),
1353 egui::StrokeKind::Middle,
1354 );
1355
1356 let text_color = egui::Color32::from_rgba_unmultiplied(255, 255, 255, 220);
1358 let font_id = egui::FontId::proportional(13.0);
1359 let max_text_width = ghost_width - 16.0;
1360 let galley = ui.painter().layout(
1361 self.dragging_title.clone(),
1362 font_id,
1363 text_color,
1364 max_text_width,
1365 );
1366 let text_pos =
1367 egui::pos2(rect.left() + 8.0, rect.center().y - galley.size().y / 2.0);
1368 ui.painter().galley(text_pos, galley, text_color);
1369 });
1370 }
1371
1372 fn render_new_tab_profile_menu(
1374 &mut self,
1375 ctx: &egui::Context,
1376 profiles: &crate::profile::ProfileManager,
1377 ) -> TabBarAction {
1378 let mut action = TabBarAction::None;
1379
1380 if !self.show_new_tab_profile_menu {
1381 return action;
1382 }
1383
1384 if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
1385 self.show_new_tab_profile_menu = false;
1386 return action;
1387 }
1388
1389 let mut open = true;
1390 egui::Window::new("New Tab")
1391 .collapsible(false)
1392 .resizable(false)
1393 .order(egui::Order::Foreground)
1394 .fixed_size(egui::vec2(200.0, 0.0))
1395 .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
1396 .open(&mut open)
1397 .show(ctx, |ui| {
1398 if ui
1400 .selectable_label(false, " Default")
1401 .on_hover_text("Open a new tab with default settings")
1402 .clicked()
1403 {
1404 action = TabBarAction::NewTab;
1405 self.show_new_tab_profile_menu = false;
1406 }
1407 ui.separator();
1408
1409 for profile in profiles.profiles_ordered() {
1411 let icon = profile.icon.as_deref().unwrap_or(" ");
1412 let label = format!("{} {}", icon, profile.name);
1413 if ui.selectable_label(false, &label).clicked() {
1414 action = TabBarAction::NewTabWithProfile(profile.id);
1415 self.show_new_tab_profile_menu = false;
1416 }
1417 }
1418 });
1419
1420 if !open {
1421 self.show_new_tab_profile_menu = false;
1422 }
1423
1424 action
1425 }
1426
1427 fn render_context_menu(&mut self, ctx: &egui::Context, tab_id: TabId) -> TabBarAction {
1429 let mut action = TabBarAction::None;
1430 let mut close_menu = false;
1431
1432 if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
1434 close_menu = true;
1435 }
1436
1437 let area_response = egui::Area::new(egui::Id::new("tab_context_menu"))
1438 .fixed_pos(self.context_menu_pos)
1439 .constrain(true)
1440 .order(egui::Order::Foreground)
1441 .show(ctx, |ui| {
1442 egui::Frame::popup(ui.style())
1443 .inner_margin(egui::Margin::symmetric(1, 4))
1444 .show(ui, |ui| {
1445 ui.set_min_width(160.0);
1446 ui.style_mut().spacing.item_spacing = egui::vec2(0.0, 0.0);
1447
1448 let menu_item = |ui: &mut egui::Ui, label: &str| -> bool {
1450 let response = ui.add_sized(
1451 [ui.available_width(), 24.0],
1452 egui::Button::new(label)
1453 .frame(false)
1454 .fill(egui::Color32::TRANSPARENT),
1455 );
1456 response.clicked()
1457 };
1458
1459 if menu_item(ui, "Duplicate Tab") {
1461 action = TabBarAction::Duplicate(tab_id);
1462 close_menu = true;
1463 }
1464
1465 if menu_item(ui, "Close Tab") {
1467 action = TabBarAction::Close(tab_id);
1468 close_menu = true;
1469 }
1470
1471 ui.add_space(4.0);
1472 ui.separator();
1473 ui.add_space(4.0);
1474
1475 ui.horizontal(|ui| {
1477 ui.add_space(8.0);
1478 ui.label("Tab Color:");
1479 });
1480
1481 ui.add_space(4.0);
1482
1483 ui.horizontal(|ui| {
1485 ui.add_space(8.0);
1486
1487 let presets: &[([u8; 3], &str)] = &[
1488 ([220, 50, 50], "Red"),
1489 ([220, 130, 50], "Orange"),
1490 ([220, 180, 50], "Yellow"),
1491 ([50, 180, 50], "Green"),
1492 ([50, 180, 180], "Cyan"),
1493 ([50, 100, 220], "Blue"),
1494 ([180, 50, 180], "Purple"),
1495 ];
1496
1497 for (color, name) in presets {
1498 let btn = ui.add(
1499 egui::Button::new("")
1500 .fill(egui::Color32::from_rgb(color[0], color[1], color[2]))
1501 .min_size(egui::vec2(18.0, 18.0))
1502 .corner_radius(2.0),
1503 );
1504 if btn.clicked() {
1505 action = TabBarAction::SetColor(tab_id, *color);
1506 close_menu = true;
1507 }
1508 if btn.hovered() {
1509 btn.on_hover_text(*name);
1510 }
1511 }
1512
1513 ui.add_space(4.0);
1514
1515 if ui.color_edit_button_srgb(&mut self.editing_color).changed() {
1517 action = TabBarAction::SetColor(tab_id, self.editing_color);
1518 }
1519 });
1520
1521 ui.add_space(4.0);
1522
1523 if menu_item(ui, "Clear Color") {
1525 action = TabBarAction::ClearColor(tab_id);
1526 close_menu = true;
1527 }
1528 });
1529 });
1530
1531 let current_frame = ctx.cumulative_frame_nr();
1533 if current_frame > self.context_menu_opened_frame
1534 && ctx.input(|i| i.pointer.any_click())
1535 && !area_response.response.hovered()
1536 && !close_menu
1538 && action == TabBarAction::None
1539 {
1540 close_menu = true;
1541 }
1542
1543 if close_menu {
1545 self.context_menu_tab = None;
1546 }
1547
1548 action
1549 }
1550
1551 pub fn get_height(&self, tab_count: usize, config: &Config) -> f32 {
1553 if self.should_show(tab_count, config.tab_bar_mode)
1554 && config.tab_bar_position.is_horizontal()
1555 {
1556 config.tab_bar_height
1557 } else {
1558 0.0
1559 }
1560 }
1561
1562 pub fn get_width(&self, tab_count: usize, config: &Config) -> f32 {
1564 if self.should_show(tab_count, config.tab_bar_mode)
1565 && config.tab_bar_position == TabBarPosition::Left
1566 {
1567 config.tab_bar_width
1568 } else {
1569 0.0
1570 }
1571 }
1572
1573 pub fn is_context_menu_open(&self) -> bool {
1575 self.context_menu_tab.is_some()
1576 }
1577}
1578
1579fn truncate_plain(title: &str, max_len: usize) -> String {
1580 if max_len == 0 {
1581 return "…".to_string();
1582 }
1583 let mut chars = title.chars();
1584 let mut taken = String::new();
1585 for _ in 0..max_len {
1586 if let Some(c) = chars.next() {
1587 taken.push(c);
1588 } else {
1589 return taken;
1590 }
1591 }
1592 if chars.next().is_some() {
1593 if max_len > 0 {
1594 taken.pop();
1595 }
1596 taken.push('…');
1597 }
1598 taken
1599}
1600
1601fn truncate_segments(segments: &[StyledSegment], max_len: usize) -> Vec<StyledSegment> {
1602 if max_len == 0 {
1603 return vec![StyledSegment {
1604 text: "…".to_string(),
1605 bold: false,
1606 italic: false,
1607 underline: false,
1608 color: None,
1609 }];
1610 }
1611 let mut remaining = max_len;
1612 let mut out: Vec<StyledSegment> = Vec::new();
1613 for seg in segments {
1614 if remaining == 0 {
1615 break;
1616 }
1617 let seg_len = seg.text.chars().count();
1618 if seg_len == 0 {
1619 continue;
1620 }
1621 if seg_len <= remaining {
1622 out.push(seg.clone());
1623 remaining -= seg_len;
1624 } else {
1625 let truncated_text: String =
1626 seg.text.chars().take(remaining.saturating_sub(1)).collect();
1627 let mut truncated = seg.clone();
1628 truncated.text = truncated_text;
1629 truncated.text.push('…');
1630 out.push(truncated);
1631 remaining = 0;
1632 }
1633 }
1634 out
1635}
1636
1637fn render_segments(ui: &mut egui::Ui, segments: &[StyledSegment], fallback_color: egui::Color32) {
1638 ui.horizontal(|ui| {
1639 ui.spacing_mut().item_spacing.x = 0.0;
1640 for segment in segments {
1641 let mut rich = egui::RichText::new(&segment.text);
1642 if segment.bold {
1643 rich = rich.strong();
1644 }
1645 if segment.italic {
1646 rich = rich.italics();
1647 }
1648 if segment.underline {
1649 rich = rich.underline();
1650 }
1651 if let Some(color) = segment.color {
1652 rich = rich.color(egui::Color32::from_rgb(color[0], color[1], color[2]));
1653 } else {
1654 rich = rich.color(fallback_color);
1655 }
1656 ui.label(rich);
1657 }
1658 });
1659}
1660
1661fn estimate_max_chars(_ui: &egui::Ui, font_id: &egui::FontId, available_width: f32) -> usize {
1662 let char_width = (font_id.size * 0.55).max(4.0); ((available_width / char_width).floor() as usize).max(4)
1664}
1665
1666fn parse_html_title(input: &str) -> Vec<StyledSegment> {
1667 let mut segments: Vec<StyledSegment> = Vec::new();
1668 let mut style_stack: Vec<TitleStyle> = vec![TitleStyle {
1669 bold: false,
1670 italic: false,
1671 underline: false,
1672 color: None,
1673 }];
1674 let mut buffer = String::new();
1675 let mut chars = input.chars().peekable();
1676
1677 while let Some(ch) = chars.next() {
1678 if ch == '<' {
1679 if !buffer.is_empty() {
1681 let style = *style_stack.last().unwrap_or(&TitleStyle {
1682 bold: false,
1683 italic: false,
1684 underline: false,
1685 color: None,
1686 });
1687 segments.push(StyledSegment {
1688 text: buffer.clone(),
1689 bold: style.bold,
1690 italic: style.italic,
1691 underline: style.underline,
1692 color: style.color,
1693 });
1694 buffer.clear();
1695 }
1696
1697 let mut tag = String::new();
1699 while let Some(&c) = chars.peek() {
1700 chars.next();
1701 if c == '>' {
1702 break;
1703 }
1704 tag.push(c);
1705 }
1706
1707 let tag_trimmed = tag.trim().to_lowercase();
1708 match tag_trimmed.as_str() {
1709 "b" => {
1710 let mut style = *style_stack.last().unwrap();
1711 style.bold = true;
1712 style_stack.push(style);
1713 }
1714 "/b" => {
1715 pop_style(&mut style_stack, |s| s.bold);
1716 }
1717 "i" => {
1718 let mut style = *style_stack.last().unwrap();
1719 style.italic = true;
1720 style_stack.push(style);
1721 }
1722 "/i" => {
1723 pop_style(&mut style_stack, |s| s.italic);
1724 }
1725 "u" => {
1726 let mut style = *style_stack.last().unwrap();
1727 style.underline = true;
1728 style_stack.push(style);
1729 }
1730 "/u" => {
1731 pop_style(&mut style_stack, |s| s.underline);
1732 }
1733 t if t.starts_with("span") => {
1734 if let Some(color) = parse_span_color(&tag_trimmed) {
1735 let mut style = *style_stack.last().unwrap();
1736 style.color = Some(color);
1737 style_stack.push(style);
1738 } else {
1739 }
1741 }
1742 "/span" => {
1743 pop_style(&mut style_stack, |s| s.color.is_some());
1744 }
1745 _ => {
1746 }
1748 }
1749 } else {
1750 buffer.push(ch);
1751 }
1752 }
1753
1754 if !buffer.is_empty() {
1755 let style = *style_stack.last().unwrap_or(&TitleStyle {
1756 bold: false,
1757 italic: false,
1758 underline: false,
1759 color: None,
1760 });
1761 segments.push(StyledSegment {
1762 text: buffer,
1763 bold: style.bold,
1764 italic: style.italic,
1765 underline: style.underline,
1766 color: style.color,
1767 });
1768 }
1769
1770 segments
1771}
1772
1773fn pop_style<F>(stack: &mut Vec<TitleStyle>, predicate: F)
1774where
1775 F: Fn(&TitleStyle) -> bool,
1776{
1777 if stack.len() <= 1 {
1778 return;
1779 }
1780 for idx in (1..stack.len()).rev() {
1781 let style = stack[idx];
1782 if predicate(&style) {
1783 stack.remove(idx);
1784 return;
1785 }
1786 }
1787}
1788
1789fn parse_span_color(tag: &str) -> Option<[u8; 3]> {
1790 let style_attr = tag.split("style=").nth(1)?;
1792 let style_val = style_attr
1793 .trim_start_matches(['\"', '\''])
1794 .trim_end_matches(['\"', '\'']);
1795 let mut color_part = None;
1796 for decl in style_val.split(';') {
1797 let mut kv = decl.splitn(2, ':');
1798 let key = kv.next()?.trim();
1799 let val = kv.next()?.trim();
1800 if key == "color" {
1801 color_part = Some(val);
1802 break;
1803 }
1804 }
1805 let color_str = color_part?;
1806 if let Some(hex) = color_str.strip_prefix('#') {
1807 if hex.len() == 6 {
1808 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
1809 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
1810 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
1811 return Some([r, g, b]);
1812 }
1813 } else if let Some(rgb) = color_str
1814 .strip_prefix("rgb(")
1815 .and_then(|s| s.strip_suffix(')'))
1816 {
1817 let parts: Vec<&str> = rgb.split(',').map(|p| p.trim()).collect();
1818 if parts.len() == 3 {
1819 let r = parts[0].parse::<u8>().ok()?;
1820 let g = parts[1].parse::<u8>().ok()?;
1821 let b = parts[2].parse::<u8>().ok()?;
1822 return Some([r, g, b]);
1823 }
1824 }
1825 None
1826}
1827
1828impl Default for TabBarUI {
1829 fn default() -> Self {
1830 Self::new()
1831 }
1832}
1833
1834#[cfg(test)]
1835mod tests {
1836 use super::*;
1837
1838 #[test]
1839 fn parse_html_title_basic_tags() {
1840 let segments = parse_html_title("<b>Hello</b> <i>world</i>");
1841 assert_eq!(
1842 segments,
1843 vec![
1844 StyledSegment {
1845 text: "Hello".to_string(),
1846 bold: true,
1847 italic: false,
1848 underline: false,
1849 color: None
1850 },
1851 StyledSegment {
1852 text: " ".to_string(),
1853 bold: false,
1854 italic: false,
1855 underline: false,
1856 color: None
1857 },
1858 StyledSegment {
1859 text: "world".to_string(),
1860 bold: false,
1861 italic: true,
1862 underline: false,
1863 color: None
1864 }
1865 ]
1866 );
1867 }
1868
1869 #[test]
1870 fn parse_html_title_span_color() {
1871 let segments = parse_html_title("<span style=\"color:#ff0000\">Red</span> text");
1872 assert_eq!(segments.len(), 2);
1873 assert_eq!(
1874 segments[0],
1875 StyledSegment {
1876 text: "Red".to_string(),
1877 bold: false,
1878 italic: false,
1879 underline: false,
1880 color: Some([255, 0, 0])
1881 }
1882 );
1883 }
1884
1885 #[test]
1886 fn truncate_segments_adds_ellipsis() {
1887 let segs = vec![StyledSegment {
1888 text: "HelloWorld".to_string(),
1889 bold: false,
1890 italic: false,
1891 underline: false,
1892 color: None,
1893 }];
1894 let truncated = truncate_segments(&segs, 6);
1895 assert_eq!(truncated[0].text, "Hello…");
1896 }
1897
1898 #[test]
1899 fn truncate_plain_handles_short_text() {
1900 assert_eq!(truncate_plain("abc", 5), "abc");
1901 assert_eq!(truncate_plain("abcdef", 5), "abcd…");
1902 }
1903}