1use crate::common::Size;
7use egui::{
8 pos2, vec2, Color32, FontId, Rect, Response, Sense, Stroke, Ui, Vec2, Widget, WidgetText,
9};
10use egui_components_theme::{mix, Theme};
11
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13pub enum TabVariant {
14 #[default]
15 Underline,
16 Pill,
17 Segmented,
18}
19
20pub struct Tabs<'a> {
21 selected: &'a mut usize,
22 tabs: Vec<WidgetText>,
23 disabled: Vec<bool>,
24 variant: TabVariant,
25 size: Size,
26}
27
28impl<'a> Tabs<'a> {
29 pub fn new(selected: &'a mut usize) -> Self {
30 Self {
31 selected,
32 tabs: Vec::new(),
33 disabled: Vec::new(),
34 variant: TabVariant::default(),
35 size: Size::Medium,
36 }
37 }
38
39 pub fn tab(mut self, label: impl Into<WidgetText>) -> Self {
41 self.tabs.push(label.into());
42 self.disabled.push(false);
43 self
44 }
45
46 pub fn disabled_tab(mut self, label: impl Into<WidgetText>) -> Self {
48 self.tabs.push(label.into());
49 self.disabled.push(true);
50 self
51 }
52
53 pub fn tabs<I, T>(mut self, items: I) -> Self
55 where
56 I: IntoIterator<Item = T>,
57 T: Into<WidgetText>,
58 {
59 for it in items {
60 self.tabs.push(it.into());
61 self.disabled.push(false);
62 }
63 self
64 }
65
66 pub fn variant(mut self, v: TabVariant) -> Self {
67 self.variant = v;
68 self
69 }
70 pub fn underline(self) -> Self {
71 self.variant(TabVariant::Underline)
72 }
73 pub fn pill(self) -> Self {
74 self.variant(TabVariant::Pill)
75 }
76 pub fn segmented(self) -> Self {
77 self.variant(TabVariant::Segmented)
78 }
79
80 pub fn size(mut self, s: Size) -> Self {
81 self.size = s;
82 self
83 }
84 pub fn small(self) -> Self {
85 self.size(Size::Small)
86 }
87 pub fn large(self) -> Self {
88 self.size(Size::Large)
89 }
90
91 pub fn show(self, ui: &mut Ui) -> Response {
92 let theme = Theme::get(ui.ctx());
93 let m = theme.metrics;
94 let c = &theme.colors;
95 let font = FontId::proportional(self.size.font_size(&m));
96 let height = self.size.button_height(&m);
97 let pad_x = match self.size {
98 Size::Small => 10.0,
99 Size::Medium => 14.0,
100 Size::Large => 18.0,
101 };
102 let gap = match self.variant {
103 TabVariant::Underline => 4.0,
104 TabVariant::Pill => 4.0,
105 TabVariant::Segmented => 2.0,
106 };
107
108 if self.tabs.is_empty() {
109 return ui.allocate_response(Vec2::ZERO, Sense::hover());
110 }
111 if *self.selected >= self.tabs.len() {
112 *self.selected = 0;
113 }
114
115 let galleys: Vec<_> = self
116 .tabs
117 .iter()
118 .map(|t| {
119 t.clone().into_galley(
120 ui,
121 Some(egui::TextWrapMode::Extend),
122 f32::INFINITY,
123 font.clone(),
124 )
125 })
126 .collect();
127
128 let widths: Vec<f32> = galleys
129 .iter()
130 .map(|g| g.size().x + pad_x * 2.0)
131 .collect();
132
133 let outer_pad = match self.variant {
134 TabVariant::Segmented => 3.0,
135 _ => 0.0,
136 };
137 let row_h = height + outer_pad * 2.0;
138 let row_gap_y = 4.0;
139
140 let visible_w =
152 (ui.clip_rect().right() - ui.cursor().min.x).max(0.0);
153 let available_w = ui.available_width().min(visible_w);
154 let mut rows: Vec<std::ops::Range<usize>> = Vec::new();
155 {
156 let mut start = 0usize;
157 let mut row_w = outer_pad * 2.0 + widths[0];
158 for i in 1..widths.len() {
159 let next_w = row_w + gap + widths[i];
160 if next_w > available_w {
161 rows.push(start..i);
162 start = i;
163 row_w = outer_pad * 2.0 + widths[i];
164 } else {
165 row_w = next_w;
166 }
167 }
168 rows.push(start..widths.len());
169 }
170
171 let row_count = rows.len();
172 let total_h = row_h * row_count as f32
173 + row_gap_y * row_count.saturating_sub(1) as f32;
174 let (rect, response) =
175 ui.allocate_exact_size(vec2(available_w, total_h), Sense::hover());
176
177 if !ui.is_rect_visible(rect) {
178 return response;
179 }
180
181 let mut clicked_idx: Option<usize> = None;
182
183 for (row_idx, row) in rows.iter().enumerate() {
184 let row_top = rect.top() + (row_h + row_gap_y) * row_idx as f32;
185
186 let mut row_tab_total = 0.0;
189 for (j, i) in row.clone().enumerate() {
190 if j > 0 {
191 row_tab_total += gap;
192 }
193 row_tab_total += widths[i];
194 }
195 let row_total_w = row_tab_total + outer_pad * 2.0;
196
197 let row_rect = Rect::from_min_size(
198 pos2(rect.left(), row_top),
199 vec2(row_total_w, row_h),
200 );
201
202 if matches!(self.variant, TabVariant::Segmented) {
203 ui.painter()
204 .rect_filled(row_rect, theme.corner(), c.muted_background);
205 }
206 if matches!(self.variant, TabVariant::Underline) {
207 ui.painter().line_segment(
208 [
209 pos2(row_rect.left(), row_rect.bottom() - 1.0),
210 pos2(row_rect.right(), row_rect.bottom() - 1.0),
211 ],
212 Stroke::new(1.0, c.border),
213 );
214 }
215
216 let mut x = rect.left() + outer_pad;
217 let tab_y = row_top + outer_pad;
218 for i in row.clone() {
219 let w = widths[i];
220 let tab_rect = Rect::from_min_size(pos2(x, tab_y), vec2(w, height));
221 let disabled = self.disabled[i];
222 let id = response.id.with(("tab", i));
223 let sense = if disabled { Sense::hover() } else { Sense::click() };
224 let tab_resp = ui.interact(tab_rect, id, sense);
225 let is_selected = *self.selected == i;
226
227 paint_tab(
228 ui,
229 tab_rect,
230 &tab_resp,
231 &theme,
232 self.variant,
233 is_selected,
234 disabled,
235 &galleys[i],
236 );
237
238 if tab_resp.clicked() && !disabled {
239 clicked_idx = Some(i);
240 }
241 if !disabled && tab_resp.hovered() {
242 ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
243 }
244
245 x += w + gap;
246 }
247 }
248
249 if let Some(i) = clicked_idx {
250 *self.selected = i;
251 }
252
253 response
254 }
255}
256
257impl<'a> Widget for Tabs<'a> {
258 fn ui(self, ui: &mut Ui) -> Response {
259 self.show(ui)
260 }
261}
262
263fn paint_tab(
264 ui: &mut Ui,
265 rect: Rect,
266 response: &Response,
267 theme: &Theme,
268 variant: TabVariant,
269 selected: bool,
270 disabled: bool,
271 galley: &std::sync::Arc<egui::Galley>,
272) {
273 let c = &theme.colors;
274 let painter = ui.painter();
275
276 let text_color = if disabled {
277 mix(c.muted_foreground, Color32::TRANSPARENT, 0.3)
278 } else if selected {
279 match variant {
280 TabVariant::Pill => c.primary_foreground,
281 TabVariant::Underline => c.foreground,
282 TabVariant::Segmented => c.foreground,
283 }
284 } else {
285 c.muted_foreground
286 };
287
288 match variant {
289 TabVariant::Underline => {
290 if !disabled && !selected && response.hovered() {
292 painter.rect_filled(
293 rect.shrink(2.0),
294 theme.corner_sm(),
295 c.accent_background,
296 );
297 }
298 if selected {
300 let y = rect.bottom() - 1.0;
301 let pad = 4.0;
302 painter.line_segment(
303 [
304 pos2(rect.left() + pad, y),
305 pos2(rect.right() - pad, y),
306 ],
307 Stroke::new(2.0, c.primary_background),
308 );
309 }
310 }
311 TabVariant::Pill => {
312 let radius = egui::CornerRadius::same((rect.height() * 0.5) as u8);
313 let bg = if selected {
314 if disabled {
315 mix(c.primary_background, Color32::TRANSPARENT, 0.5)
316 } else if response.is_pointer_button_down_on() {
317 c.primary_active_background
318 } else if response.hovered() {
319 c.primary_hover_background
320 } else {
321 c.primary_background
322 }
323 } else if !disabled && response.hovered() {
324 c.secondary_hover_background
325 } else {
326 Color32::TRANSPARENT
327 };
328 if bg != Color32::TRANSPARENT {
329 painter.rect_filled(rect, radius, bg);
330 }
331 }
332 TabVariant::Segmented => {
333 if selected {
334 painter.rect_filled(rect, theme.corner_sm(), c.popover_background);
335 painter.rect_stroke(
336 rect,
337 theme.corner_sm(),
338 Stroke::new(1.0, c.border),
339 egui::StrokeKind::Inside,
340 );
341 } else if !disabled && response.hovered() {
342 painter.rect_filled(rect, theme.corner_sm(), c.accent_background);
343 }
344 }
345 }
346
347 if response.has_focus() {
348 painter.rect_stroke(
349 rect.expand(1.0),
350 theme.corner(),
351 theme.focus_ring(),
352 egui::StrokeKind::Outside,
353 );
354 }
355
356 let text_pos = rect.center() - galley.size() * 0.5;
357 painter.galley_with_override_text_color(text_pos, galley.clone(), text_color);
358}