1use super::content::ContentContext;
7use crate::ext::ArmasContextExt;
8use egui::{Pos2, Ui, Vec2};
9
10const DEFAULT_HEIGHT: f32 = 28.0;
12const DEFAULT_PADDING: f32 = 2.0;
13const DEFAULT_LIST_RADIUS: f32 = 6.0;
14const DEFAULT_TRIGGER_RADIUS: f32 = 4.0;
15const DEFAULT_TRIGGER_PADDING_X: f32 = 8.0;
16const DEFAULT_GAP: f32 = 4.0;
17const DEFAULT_FONT_SIZE: f32 = 13.5;
18
19#[derive(Debug, Clone)]
21pub struct TabsResponse {
22 pub response: egui::Response,
24 pub selected: Option<usize>,
26 pub changed: bool,
28}
29
30pub struct Tabs {
49 labels: Vec<String>,
50 active_index: usize,
51 animate: bool,
52 indicator_pos: f32,
53 persist_state: bool,
54 width: f32,
56 height: f32,
57 padding: f32,
58 list_radius: f32,
59 trigger_radius: f32,
60 trigger_padding_x: f32,
61 gap: f32,
62 font_size: f32,
63}
64
65impl Tabs {
66 #[must_use]
68 pub fn new(labels: Vec<impl Into<String>>) -> Self {
69 Self {
70 labels: labels.into_iter().map(std::convert::Into::into).collect(),
71 active_index: 0,
72 animate: true,
73 indicator_pos: 0.0,
74 persist_state: true,
75 width: 0.0,
76 height: DEFAULT_HEIGHT,
77 padding: DEFAULT_PADDING,
78 list_radius: DEFAULT_LIST_RADIUS,
79 trigger_radius: DEFAULT_TRIGGER_RADIUS,
80 trigger_padding_x: DEFAULT_TRIGGER_PADDING_X,
81 gap: DEFAULT_GAP,
82 font_size: DEFAULT_FONT_SIZE,
83 }
84 }
85
86 #[must_use]
88 pub fn active(mut self, index: usize) -> Self {
89 let max = if self.labels.is_empty() {
90 usize::MAX
91 } else {
92 self.labels.len().saturating_sub(1)
93 };
94 self.active_index = index.min(max);
95 self.indicator_pos = self.active_index as f32;
96 self.persist_state = false;
97 self
98 }
99
100 #[must_use]
102 pub const fn animate(mut self, animate: bool) -> Self {
103 self.animate = animate;
104 self
105 }
106
107 #[must_use]
109 pub const fn width(mut self, width: f32) -> Self {
110 self.width = width;
111 self
112 }
113
114 #[must_use]
116 pub const fn height(mut self, height: f32) -> Self {
117 self.height = height;
118 self
119 }
120
121 #[must_use]
123 pub const fn padding(mut self, padding: f32) -> Self {
124 self.padding = padding;
125 self
126 }
127
128 #[must_use]
130 pub const fn list_radius(mut self, radius: f32) -> Self {
131 self.list_radius = radius;
132 self
133 }
134
135 #[must_use]
137 pub const fn trigger_radius(mut self, radius: f32) -> Self {
138 self.trigger_radius = radius;
139 self
140 }
141
142 #[must_use]
144 pub const fn trigger_padding_x(mut self, padding: f32) -> Self {
145 self.trigger_padding_x = padding;
146 self
147 }
148
149 #[must_use]
151 pub const fn gap(mut self, gap: f32) -> Self {
152 self.gap = gap;
153 self
154 }
155
156 #[must_use]
158 pub const fn font_size(mut self, size: f32) -> Self {
159 self.font_size = size;
160 self
161 }
162
163 fn load_and_animate(&mut self, ui: &Ui, count: usize) {
165 if self.persist_state {
166 let tabs_id = ui.id().with("tabs_state");
167 let (stored_active, stored_indicator): (usize, f32) = ui.ctx().data_mut(|d| {
168 d.get_persisted(tabs_id)
169 .unwrap_or((self.active_index, self.active_index as f32))
170 });
171
172 if self.active_index == 0 && stored_active > 0 && stored_active < count {
173 self.active_index = stored_active;
174 }
175 self.indicator_pos = stored_indicator;
176 }
177
178 let dt = ui.input(|i| i.stable_dt);
179 if self.animate {
180 let target = self.active_index as f32;
181 let speed = 12.0;
182 self.indicator_pos += (target - self.indicator_pos) * speed * dt;
183 if (self.indicator_pos - target).abs() > 0.01 {
184 ui.ctx().request_repaint();
185 }
186 } else {
187 self.indicator_pos = self.active_index as f32;
188 }
189 }
190
191 fn compute_widths(&self, ui: &Ui, count: usize) -> (Vec<f32>, f32) {
193 let total_gap = self.gap * count.saturating_sub(1) as f32;
194 let explicit_width = if self.width > 0.0 { self.width } else { 0.0 };
195
196 let tab_widths: Vec<f32> = if explicit_width > 0.0 {
197 let inner = explicit_width - self.padding * 2.0 - total_gap;
198 let per_tab = inner / count as f32;
199 vec![per_tab; count]
200 } else if !self.labels.is_empty() {
201 let char_width = self.font_size * 0.6;
203 self.labels
204 .iter()
205 .map(|label| {
206 let text_width = char_width * label.len() as f32;
207 text_width + self.trigger_padding_x * 2.0
208 })
209 .collect()
210 } else {
211 let avail = ui.available_width();
213 let inner = avail - self.padding * 2.0 - total_gap;
214 let per_tab = inner / count as f32;
215 vec![per_tab; count]
216 };
217
218 let total_width = if explicit_width > 0.0 {
219 explicit_width
220 } else if !self.labels.is_empty() {
221 tab_widths.iter().sum::<f32>() + total_gap + self.padding * 2.0
222 } else {
223 ui.available_width()
224 };
225
226 (tab_widths, total_width)
227 }
228
229 fn draw_background(
231 &self,
232 ui: &mut Ui,
233 tab_widths: &[f32],
234 total_width: f32,
235 count: usize,
236 ) -> (egui::Rect, Vec<f32>, f32, egui::Response) {
237 let theme = ui.ctx().armas_theme();
238
239 let (list_rect, list_response) =
240 ui.allocate_exact_size(Vec2::new(total_width, self.height), egui::Sense::hover());
241
242 ui.painter()
243 .rect_filled(list_rect, self.list_radius, theme.muted());
244
245 let inner_height = self.height - self.padding * 2.0;
246
247 let mut x_positions: Vec<f32> = Vec::with_capacity(count);
249 let mut current_x = list_rect.min.x + self.padding;
250 for (i, width) in tab_widths.iter().enumerate() {
251 x_positions.push(current_x);
252 current_x += width;
253 if i < count - 1 {
254 current_x += self.gap;
255 }
256 }
257
258 if !tab_widths.is_empty() {
260 let floor_idx = (self.indicator_pos.floor() as usize).min(tab_widths.len() - 1);
261 let ceil_idx = (self.indicator_pos.ceil() as usize).min(tab_widths.len() - 1);
262 let t = self.indicator_pos.fract();
263
264 let start_x =
265 x_positions[floor_idx] + (x_positions[ceil_idx] - x_positions[floor_idx]) * t;
266 let width = tab_widths[floor_idx] + (tab_widths[ceil_idx] - tab_widths[floor_idx]) * t;
267
268 let active_rect = egui::Rect::from_min_size(
269 Pos2::new(start_x, list_rect.min.y + self.padding),
270 Vec2::new(width, inner_height),
271 );
272
273 ui.painter()
274 .rect_filled(active_rect, self.trigger_radius, theme.background());
275 }
276
277 (list_rect, x_positions, inner_height, list_response)
278 }
279
280 fn persist(&self, ui: &Ui) {
282 if self.persist_state {
283 let tabs_id = ui.id().with("tabs_state");
284 ui.ctx().data_mut(|d| {
285 d.insert_persisted(tabs_id, (self.active_index, self.indicator_pos));
286 });
287 }
288 }
289
290 pub fn show(&mut self, ui: &mut Ui) -> TabsResponse {
292 let n = self.labels.len();
293 if n == 0 {
294 let (_, empty_response) =
295 ui.allocate_exact_size(egui::Vec2::new(0.0, self.height), egui::Sense::hover());
296 return TabsResponse {
297 response: empty_response,
298 selected: None,
299 changed: false,
300 };
301 }
302
303 let theme = ui.ctx().armas_theme();
304 self.load_and_animate(ui, n);
305
306 let (tab_widths, total_width) = self.compute_widths(ui, n);
307 let (list_rect, x_positions, inner_height, list_response) =
308 self.draw_background(ui, &tab_widths, total_width, n);
309
310 let font_id = egui::FontId::proportional(self.font_size);
311 let mut selected = None;
312
313 for (index, label) in self.labels.iter().enumerate() {
314 let tab_rect = egui::Rect::from_min_size(
315 Pos2::new(x_positions[index], list_rect.min.y + self.padding),
316 Vec2::new(tab_widths[index], inner_height),
317 );
318
319 let is_active = index == self.active_index;
320 let is_hovered = ui.rect_contains_pointer(tab_rect);
321
322 let text_color = if is_active {
323 theme.foreground()
324 } else {
325 theme.muted_foreground()
326 };
327
328 ui.painter().text(
329 tab_rect.center(),
330 egui::Align2::CENTER_CENTER,
331 label,
332 font_id.clone(),
333 text_color,
334 );
335
336 if is_hovered && ui.input(|i| i.pointer.primary_clicked()) {
337 selected = Some(index);
338 self.active_index = index;
339 }
340 }
341
342 let changed = selected.is_some();
343 if let Some(new_index) = selected {
344 self.active_index = new_index;
345 }
346
347 self.persist(ui);
348
349 TabsResponse {
350 response: list_response,
351 selected,
352 changed,
353 }
354 }
355
356 pub fn show_ui(
373 &mut self,
374 ui: &mut Ui,
375 count: usize,
376 render_tab: impl Fn(usize, &mut Ui, &ContentContext),
377 ) -> TabsResponse {
378 if count == 0 {
379 let (_, empty_response) =
380 ui.allocate_exact_size(egui::Vec2::new(0.0, self.height), egui::Sense::hover());
381 return TabsResponse {
382 response: empty_response,
383 selected: None,
384 changed: false,
385 };
386 }
387
388 let theme = ui.ctx().armas_theme();
389 self.load_and_animate(ui, count);
390
391 let (tab_widths, total_width) = self.compute_widths(ui, count);
392 let (list_rect, x_positions, inner_height, list_response) =
393 self.draw_background(ui, &tab_widths, total_width, count);
394
395 let mut selected = None;
396
397 for index in 0..count {
398 let tab_rect = egui::Rect::from_min_size(
399 Pos2::new(x_positions[index], list_rect.min.y + self.padding),
400 Vec2::new(tab_widths[index], inner_height),
401 );
402
403 let is_active = index == self.active_index;
404 let is_hovered = ui.rect_contains_pointer(tab_rect);
405
406 let text_color = if is_active {
407 theme.foreground()
408 } else {
409 theme.muted_foreground()
410 };
411
412 let mut child_ui = ui.new_child(
414 egui::UiBuilder::new()
415 .max_rect(tab_rect)
416 .layout(egui::Layout::left_to_right(egui::Align::Center)),
417 );
418 child_ui.style_mut().visuals.override_text_color = Some(text_color);
419
420 let ctx = ContentContext {
421 color: text_color,
422 font_size: self.font_size,
423 is_active,
424 };
425 render_tab(index, &mut child_ui, &ctx);
426
427 if is_hovered && ui.input(|i| i.pointer.primary_clicked()) {
428 selected = Some(index);
429 self.active_index = index;
430 }
431 }
432
433 let changed = selected.is_some();
434 if let Some(new_index) = selected {
435 self.active_index = new_index;
436 }
437
438 self.persist(ui);
439
440 TabsResponse {
441 response: list_response,
442 selected,
443 changed,
444 }
445 }
446}