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