1use std::hash::Hash;
47
48use egui::{
49 pos2, vec2, Color32, CornerRadius, FontId, FontSelection, Id, Rect, Response, RichText, Sense,
50 Stroke, TextWrapMode, Ui, Vec2, WidgetInfo, WidgetText, WidgetType,
51};
52
53use crate::theme::{with_alpha, Theme};
54
55#[derive(Clone, Debug)]
57pub struct BrowserTab {
58 pub id: String,
60 pub label: String,
62 pub icon: Option<String>,
65 pub dirty: bool,
67}
68
69impl BrowserTab {
70 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
72 Self {
73 id: id.into(),
74 label: label.into(),
75 icon: None,
76 dirty: false,
77 }
78 }
79
80 #[inline]
82 pub fn icon(mut self, icon: impl Into<String>) -> Self {
83 self.icon = Some(icon.into());
84 self
85 }
86
87 #[inline]
89 pub fn dirty(mut self, dirty: bool) -> Self {
90 self.dirty = dirty;
91 self
92 }
93}
94
95#[derive(Clone, Debug, PartialEq, Eq)]
99pub enum BrowserTabsEvent {
100 Activated(String),
102 Closed(String),
106 NewRequested,
110}
111
112const STRIP_PAD_X: f32 = 8.0;
113const STRIP_PAD_Y: f32 = 8.0;
114const TAB_PAD_X: f32 = 10.0;
115const TAB_PAD_Y: f32 = 7.0;
116const TAB_GAP: f32 = 2.0;
117const TAB_RADIUS: f32 = 7.0;
118const ICON_SIZE: f32 = 12.0;
119const INNER_GAP: f32 = 8.0;
120const DIRTY_SIZE: f32 = 7.0;
121const DIRTY_TO_CLOSE_GAP: f32 = 5.0;
122const CLOSE_SIZE: f32 = 16.0;
123const CLOSE_INNER: f32 = 9.0;
124const CLOSE_RADIUS: u8 = 4;
125const NEW_BTN_SIZE: f32 = 28.0;
126const NEW_BTN_INNER: f32 = 14.0;
127const NEW_BTN_RADIUS: u8 = 5;
128const NEW_BTN_GAP: f32 = 4.0;
129
130const DEFAULT_MIN_TAB_WIDTH: f32 = 120.0;
131const DEFAULT_MAX_TAB_WIDTH: f32 = 220.0;
132
133#[must_use = "Call `.show(ui)` to render the widget."]
137pub struct BrowserTabs {
138 id_salt: Id,
139 tabs: Vec<BrowserTab>,
140 selected: Option<String>,
141 show_new_button: bool,
142 min_tab_width: f32,
143 max_tab_width: f32,
144 events: Vec<BrowserTabsEvent>,
145}
146
147impl Default for BrowserTabs {
148 fn default() -> Self {
149 Self::new("elegance::browser_tabs")
150 }
151}
152
153impl std::fmt::Debug for BrowserTabs {
154 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155 f.debug_struct("BrowserTabs")
156 .field("id_salt", &self.id_salt)
157 .field("tabs", &self.tabs.len())
158 .field("selected", &self.selected)
159 .field("show_new_button", &self.show_new_button)
160 .field("min_tab_width", &self.min_tab_width)
161 .field("max_tab_width", &self.max_tab_width)
162 .field("events", &self.events.len())
163 .finish()
164 }
165}
166
167impl BrowserTabs {
168 pub fn new(id_salt: impl Hash) -> Self {
172 Self {
173 id_salt: Id::new(("elegance::browser_tabs", id_salt)),
174 tabs: Vec::new(),
175 selected: None,
176 show_new_button: true,
177 min_tab_width: DEFAULT_MIN_TAB_WIDTH,
178 max_tab_width: DEFAULT_MAX_TAB_WIDTH,
179 events: Vec::new(),
180 }
181 }
182
183 #[inline]
185 pub fn with_tab(mut self, tab: BrowserTab) -> Self {
186 self.add_tab(tab);
187 self
188 }
189
190 #[inline]
192 pub fn show_new_button(mut self, show: bool) -> Self {
193 self.show_new_button = show;
194 self
195 }
196
197 #[inline]
199 pub fn min_tab_width(mut self, w: f32) -> Self {
200 self.min_tab_width = w.max(60.0);
201 if self.max_tab_width < self.min_tab_width {
202 self.max_tab_width = self.min_tab_width;
203 }
204 self
205 }
206
207 #[inline]
209 pub fn max_tab_width(mut self, w: f32) -> Self {
210 self.max_tab_width = w.max(self.min_tab_width);
211 self
212 }
213
214 pub fn add_tab(&mut self, tab: BrowserTab) {
218 if self.selected.is_none() {
219 self.selected = Some(tab.id.clone());
220 }
221 self.tabs.push(tab);
222 }
223
224 pub fn remove_tab(&mut self, id: &str) -> bool {
228 let Some(pos) = self.tabs.iter().position(|t| t.id == id) else {
229 return false;
230 };
231 let was_selected = self.selected.as_deref() == Some(id);
232 self.tabs.remove(pos);
233 if was_selected {
234 self.selected = self
235 .tabs
236 .get(pos)
237 .or_else(|| self.tabs.get(pos.saturating_sub(1)))
238 .map(|t| t.id.clone());
239 }
240 true
241 }
242
243 #[inline]
245 pub fn selected(&self) -> Option<&str> {
246 self.selected.as_deref()
247 }
248
249 pub fn set_selected(&mut self, id: impl Into<String>) {
251 let id = id.into();
252 if self.tabs.iter().any(|t| t.id == id) {
253 self.selected = Some(id);
254 }
255 }
256
257 #[inline]
259 pub fn tabs(&self) -> &[BrowserTab] {
260 &self.tabs
261 }
262
263 pub fn tab(&self, id: &str) -> Option<&BrowserTab> {
265 self.tabs.iter().find(|t| t.id == id)
266 }
267
268 pub fn tab_mut(&mut self, id: &str) -> Option<&mut BrowserTab> {
270 self.tabs.iter_mut().find(|t| t.id == id)
271 }
272
273 pub fn take_events(&mut self) -> Vec<BrowserTabsEvent> {
276 std::mem::take(&mut self.events)
277 }
278
279 pub fn show(&mut self, ui: &mut Ui) -> Response {
281 let theme = Theme::current(ui.ctx());
282 let p = &theme.palette;
283 let t = &theme.typography;
284
285 let label_size = t.small + 0.5;
286 let tab_height = TAB_PAD_Y * 2.0 + label_size.max(ICON_SIZE);
287 let strip_height = STRIP_PAD_Y + tab_height;
288
289 let avail_w = ui.available_width();
290 let (strip_rect, response) =
291 ui.allocate_exact_size(Vec2::new(avail_w, strip_height), Sense::hover());
292
293 if !ui.is_rect_visible(strip_rect) {
294 response.widget_info(|| WidgetInfo::labeled(WidgetType::Other, true, "browser tabs"));
295 return response;
296 }
297
298 let strip_bg = p.input_bg;
299 ui.painter()
300 .rect_filled(strip_rect, CornerRadius::ZERO, strip_bg);
301
302 let active_id = self.selected.clone();
303 let mut active_rect: Option<Rect> = None;
304 let mut activate_target: Option<String> = None;
305 let mut close_target: Option<String> = None;
306 let mut new_clicked = false;
307
308 let tabs_top = strip_rect.min.y + STRIP_PAD_Y;
309 let mut x = strip_rect.min.x + STRIP_PAD_X;
310
311 for tab in self.tabs.iter() {
312 let icon_w = if tab.icon.is_some() {
313 ICON_SIZE + INNER_GAP
314 } else {
315 0.0
316 };
317 let dirty_block_w = if tab.dirty {
318 INNER_GAP + DIRTY_SIZE + DIRTY_TO_CLOSE_GAP
319 } else {
320 INNER_GAP
321 };
322 let close_w = CLOSE_SIZE;
323 let max_label_w =
324 (self.max_tab_width - 2.0 * TAB_PAD_X - icon_w - dirty_block_w - close_w).max(0.0);
325
326 let label_galley = WidgetText::from(
327 RichText::new(&tab.label)
328 .size(label_size)
329 .color(Color32::PLACEHOLDER),
330 )
331 .into_galley(
332 ui,
333 Some(TextWrapMode::Truncate),
334 max_label_w,
335 FontSelection::FontId(FontId::proportional(label_size)),
336 );
337 let label_w = label_galley.size().x.min(max_label_w);
338
339 let mut tab_w = 2.0 * TAB_PAD_X + icon_w + label_w + dirty_block_w + close_w;
340 tab_w = tab_w.clamp(self.min_tab_width, self.max_tab_width);
341 let tab_rect = Rect::from_min_size(pos2(x, tabs_top), vec2(tab_w, tab_height));
342
343 let tab_id = self.id_salt.with(("tab", tab.id.as_str()));
344 let resp = ui.interact(tab_rect, tab_id, Sense::click());
345
346 let close_center = pos2(
347 tab_rect.max.x - TAB_PAD_X - CLOSE_SIZE * 0.5,
348 tab_rect.center().y,
349 );
350 let close_rect = Rect::from_center_size(close_center, Vec2::splat(CLOSE_SIZE));
351 let close_id = self.id_salt.with(("close", tab.id.as_str()));
352 let close_resp = ui.interact(close_rect, close_id, Sense::click());
353
354 let is_active = active_id.as_deref() == Some(tab.id.as_str());
355 let any_hover = resp.hovered() || close_resp.hovered();
356
357 let radius = CornerRadius {
358 nw: TAB_RADIUS as u8,
359 ne: TAB_RADIUS as u8,
360 sw: 0,
361 se: 0,
362 };
363 let painter = ui.painter();
364
365 let (fill, label_color, icon_color) = if is_active {
366 (p.card, p.text, p.sky)
367 } else if any_hover {
368 (p.depth_tint(strip_bg, 0.06), p.text, p.text_muted)
369 } else {
370 (p.depth_tint(strip_bg, 0.02), p.text_muted, p.text_faint)
371 };
372 painter.rect_filled(tab_rect, radius, fill);
373
374 let (top_color, side_color) = if is_active {
379 (p.border, p.depth_tint(p.card, 0.04))
380 } else {
381 let outline = with_alpha(p.border, 110);
382 (outline, outline)
383 };
384 painter.line_segment(
385 [
386 pos2(tab_rect.min.x + TAB_RADIUS, tab_rect.min.y + 0.5),
387 pos2(tab_rect.max.x - TAB_RADIUS, tab_rect.min.y + 0.5),
388 ],
389 Stroke::new(1.0, top_color),
390 );
391 painter.line_segment(
392 [
393 pos2(tab_rect.min.x + 0.5, tab_rect.min.y + TAB_RADIUS),
394 pos2(tab_rect.min.x + 0.5, tab_rect.max.y),
395 ],
396 Stroke::new(1.0, side_color),
397 );
398 painter.line_segment(
399 [
400 pos2(tab_rect.max.x - 0.5, tab_rect.min.y + TAB_RADIUS),
401 pos2(tab_rect.max.x - 0.5, tab_rect.max.y),
402 ],
403 Stroke::new(1.0, side_color),
404 );
405
406 let mut cursor_x = tab_rect.min.x + TAB_PAD_X;
407 let cy = tab_rect.center().y;
408
409 if let Some(icon) = &tab.icon {
410 let icon_galley = WidgetText::from(
411 RichText::new(icon)
412 .size(ICON_SIZE)
413 .color(Color32::PLACEHOLDER),
414 )
415 .into_galley(
416 ui,
417 Some(TextWrapMode::Extend),
418 f32::INFINITY,
419 FontSelection::FontId(FontId::proportional(ICON_SIZE)),
420 );
421 let painter = ui.painter();
422 painter.galley(
423 pos2(cursor_x, cy - icon_galley.size().y * 0.5),
424 icon_galley,
425 icon_color,
426 );
427 cursor_x += ICON_SIZE + INNER_GAP;
428 }
429
430 let painter = ui.painter();
431 let label_pos = pos2(cursor_x, cy - label_galley.size().y * 0.5);
432 painter.galley(label_pos, label_galley, label_color);
433
434 if tab.dirty {
435 let dot_x = close_rect.min.x - DIRTY_TO_CLOSE_GAP - DIRTY_SIZE * 0.5;
436 painter.circle_filled(pos2(dot_x, cy), DIRTY_SIZE * 0.5, p.sky);
437 }
438
439 let close_visible = is_active || any_hover;
440 if close_visible {
441 if close_resp.hovered() {
442 let close_bg = p.depth_tint(if is_active { p.card } else { strip_bg }, 0.10);
443 painter.rect_filled(close_rect, CornerRadius::same(CLOSE_RADIUS), close_bg);
444 }
445 let cross_color = if close_resp.hovered() {
446 p.text
447 } else if is_active {
448 p.text_muted
449 } else {
450 p.text_faint
451 };
452 let half = CLOSE_INNER * 0.5;
453 let stroke = Stroke::new(1.5, cross_color);
454 painter.line_segment(
455 [
456 pos2(close_center.x - half, close_center.y - half),
457 pos2(close_center.x + half, close_center.y + half),
458 ],
459 stroke,
460 );
461 painter.line_segment(
462 [
463 pos2(close_center.x + half, close_center.y - half),
464 pos2(close_center.x - half, close_center.y + half),
465 ],
466 stroke,
467 );
468 }
469
470 if is_active {
471 active_rect = Some(tab_rect);
472 }
473
474 let info_active = is_active;
475 let info_label = tab.label.clone();
476 resp.widget_info(move || {
477 WidgetInfo::selected(WidgetType::Button, true, info_active, &info_label)
478 });
479 let close_label = format!("Close {}", tab.label);
480 close_resp
481 .widget_info(move || WidgetInfo::labeled(WidgetType::Button, true, &close_label));
482
483 if close_resp.clicked() {
484 close_target = Some(tab.id.clone());
485 } else if resp.clicked() {
486 activate_target = Some(tab.id.clone());
487 }
488
489 x += tab_w + TAB_GAP;
490 }
491
492 if self.show_new_button {
493 x += NEW_BTN_GAP;
494 let btn_y = tabs_top + tab_height - NEW_BTN_SIZE - 2.0;
495 let new_rect = Rect::from_min_size(pos2(x, btn_y), Vec2::splat(NEW_BTN_SIZE));
496 let new_id = self.id_salt.with("new_tab");
497 let new_resp = ui.interact(new_rect, new_id, Sense::click());
498
499 let painter = ui.painter();
500 let hovered = new_resp.hovered();
501 if hovered {
502 painter.rect_filled(
503 new_rect,
504 CornerRadius::same(NEW_BTN_RADIUS),
505 p.depth_tint(strip_bg, 0.06),
506 );
507 }
508 let cross_color = if hovered { p.text } else { p.text_faint };
509 let center = new_rect.center();
510 let half = NEW_BTN_INNER * 0.5;
511 let stroke = Stroke::new(2.0, cross_color);
512 painter.line_segment(
513 [
514 pos2(center.x, center.y - half),
515 pos2(center.x, center.y + half),
516 ],
517 stroke,
518 );
519 painter.line_segment(
520 [
521 pos2(center.x - half, center.y),
522 pos2(center.x + half, center.y),
523 ],
524 stroke,
525 );
526 new_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, "New tab"));
527
528 if new_resp.clicked() {
529 new_clicked = true;
530 }
531 }
532
533 let border_y = strip_rect.bottom() - 0.5;
534 let stroke = Stroke::new(1.0, p.border);
535 let painter = ui.painter();
536 if let Some(active) = active_rect {
537 painter.line_segment(
538 [
539 pos2(strip_rect.min.x, border_y),
540 pos2(active.min.x, border_y),
541 ],
542 stroke,
543 );
544 painter.line_segment(
545 [
546 pos2(active.max.x, border_y),
547 pos2(strip_rect.max.x, border_y),
548 ],
549 stroke,
550 );
551 } else {
552 painter.line_segment(
553 [
554 pos2(strip_rect.min.x, border_y),
555 pos2(strip_rect.max.x, border_y),
556 ],
557 stroke,
558 );
559 }
560
561 if let Some(id) = activate_target {
562 if self.selected.as_deref() != Some(id.as_str()) {
563 self.selected = Some(id.clone());
564 self.events.push(BrowserTabsEvent::Activated(id));
565 }
566 }
567 if let Some(id) = close_target {
568 if self.remove_tab(&id) {
569 self.events.push(BrowserTabsEvent::Closed(id));
570 if let Some(new_active) = self.selected.clone() {
571 self.events.push(BrowserTabsEvent::Activated(new_active));
572 }
573 }
574 }
575 if new_clicked {
576 self.events.push(BrowserTabsEvent::NewRequested);
577 }
578
579 response.widget_info(|| WidgetInfo::labeled(WidgetType::Other, true, "browser tabs"));
580 response
581 }
582}