1mod add;
8#[cfg(test)]
9mod add_tests;
10#[cfg(test)]
11mod badge_tests;
12mod paint;
13mod strip;
14#[cfg(test)]
15mod tests;
16
17use std::time::Duration;
18
19use crate::event::{Event, MouseButton, MouseKind};
20use crate::geometry::{Rect, Size, clamp_u16};
21use crate::keymap::{Key, Modifiers};
22use crate::theme::State;
23use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
24
25use super::close_mark;
26use super::context_item::ContextItem;
27use super::context_menu;
28use super::edge_scroll::{Edge, Zone};
29use super::popup_menu::{PopupAction, PopupMenu};
30use super::tab_model::{self, Direction, TabModel, drop_target, preview_order};
31use strip::Strip;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum TabWidth {
36 #[default]
38 Fit,
39 Fixed(u16),
41 Fill,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum Overflow {
49 #[default]
55 Arrows,
56 Menu,
59}
60
61const GAP: u16 = 1;
63const PAD: u16 = 2;
65const CLOSE: u16 = 1 + close_mark::WIDTH - 1;
68const FILL_MIN: u16 = 12;
70const ARROW: u16 = 3;
72const ADD: u16 = 3;
74
75pub struct Tabs<Msg> {
113 labels: Vec<String>,
114 badges: Vec<u32>,
115 numbered: bool,
116 width: TabWidth,
117 overflow: Overflow,
118 on_add: Option<Box<dyn Fn() -> Msg>>,
119 model: TabModel<Msg>,
120}
121
122#[derive(Debug, Default)]
123struct TabsMemory {
124 offset: usize,
125 followed: Option<usize>,
126 hidden: Vec<usize>,
127 pressed: Option<(Arrow, Duration)>,
129 on_add: bool,
131 add_pressed: Option<Duration>,
133 add_hovered_since: Option<Duration>,
135 add_hint_since: Option<Duration>,
136 add_hint: Option<Rect>,
138}
139
140use Edge as Arrow;
142
143impl<Msg: 'static> Tabs<Msg> {
144 #[must_use]
146 pub fn new(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
147 let labels: Vec<String> = labels.into_iter().map(Into::into).collect();
148 let model = TabModel::new(labels.len());
149 let badges = vec![0; labels.len()];
150 Self { labels, badges, numbered: false, width: TabWidth::Fit, overflow: Overflow::Arrows, on_add: None, model }
151 }
152
153 #[must_use]
155 pub fn active(mut self, index: usize) -> Self {
156 self.model.active = index;
157 self
158 }
159
160 #[must_use]
162 pub fn numbered(mut self, numbered: bool) -> Self {
163 self.numbered = numbered;
164 self
165 }
166
167 #[must_use]
173 pub fn badge(mut self, index: usize, count: u32) -> Self {
174 if let Some(badge) = self.badges.get_mut(index) {
175 *badge = count;
176 }
177 self
178 }
179
180 #[must_use]
182 pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
183 self.model.on_select = Some(Box::new(message));
184 self
185 }
186
187 #[must_use]
190 pub fn closable(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
191 self.model.set_on_close(message);
192 self
193 }
194
195 #[must_use]
197 pub fn pinned(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
198 self.model.pinned = indices.into_iter().collect();
199 self
200 }
201
202 #[must_use]
204 pub fn tab_width(mut self, width: TabWidth) -> Self {
205 self.width = width;
206 self
207 }
208
209 #[must_use]
211 pub fn overflow(mut self, overflow: Overflow) -> Self {
212 self.overflow = overflow;
213 self
214 }
215
216 #[must_use]
219 pub fn reorderable(mut self, message: impl Fn(usize, usize) -> Msg + 'static) -> Self {
220 self.model.set_on_move(message);
221 self
222 }
223
224 #[must_use]
228 pub fn on_drag_scroll(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
229 self.model.on_drag_scroll = Some(Box::new(message));
230 self
231 }
232
233 #[must_use]
238 pub fn on_add(mut self, message: impl Fn() -> Msg + 'static) -> Self {
239 self.on_add = Some(Box::new(message));
240 self
241 }
242
243 #[must_use]
248 pub fn context_menu(mut self, items: impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static) -> Self {
249 self.model.set_context_menu(items);
250 self
251 }
252}
253
254impl<Msg: 'static> Widget<Msg> for Tabs<Msg> {
255 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
256 let width = match self.width {
257 TabWidth::Fill => available.width,
258 TabWidth::Fit | TabWidth::Fixed(_) => {
259 Self::total_width(&self.widths(available.width)).saturating_add(self.add_room())
260 }
261 };
262 Size::new(width, 1).min(available)
263 }
264
265 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
266 cx.register_hit(area);
267 let on_add = self.add_focus_paint(cx);
268 if self.labels.is_empty() {
269 let strip = self.strip(area, &[], 0, &[]);
270 let pointer = cx.pointer();
271 self.paint_add(cx, area, &strip, pointer, on_add);
272 return;
273 }
274 let focused = cx.is_focus_visible() && !on_add;
275 let pointer = cx.pointer();
276 let widths = self.widths(area.width);
277 let active = self.model.active();
278 let drag = self.model.drag(cx);
279 let identity = self.identity();
280
281 let offset = {
284 let memory = cx.memory::<TabsMemory>();
285 let (offset, followed) = (memory.offset, memory.followed);
286 let follows = drag.is_none() && (self.overflow == Overflow::Menu || followed != Some(active));
287 let offset = if follows {
288 self.follow(area, &identity, offset, active, &widths)
289 } else {
290 offset.min(self.labels.len() - 1)
291 };
292 let offset = if drag.is_none() { self.settle(area, &identity, offset, &widths) } else { offset };
293 let memory = cx.memory::<TabsMemory>();
294 memory.offset = offset;
295 memory.followed = Some(active);
296 offset
297 };
298
299 let resting = self.strip(area, &identity, offset, &widths);
300 let slots = self.drop_slots(&resting);
301 let target = drag.map(|d| (d.index, drop_target(&slots, d.index, d.pointer, Direction::Across)));
302 let order = preview_order(self.labels.len(), target);
303 let strip = if target.is_some() { self.strip(area, &order, offset, &widths) } else { resting };
304
305 let menu_tab = self.model.menu_tab(cx);
307 if menu_tab.is_some() {
308 cx.request_overlay(area);
309 }
310 let pointer = pointer.filter(|_| menu_tab.is_none());
311
312 for (position, (index, rect)) in strip.tabs.iter().enumerate() {
313 if drag.is_some_and(|d| d.index == *index) {
314 tab_model::paint_drop_slot(cx, *rect);
315 continue;
316 }
317 let mut states = Vec::new();
318 if menu_tab == Some(*index) || (drag.is_none() && pointer.is_some_and(|(x, y)| rect.contains(x, y))) {
319 states.push(State::Hover);
320 }
321 if *index == active {
322 states.push(State::Selected);
323 if focused {
324 states.push(State::Focus);
325 }
326 }
327 self.paint_tab(cx, *rect, *index, offset + position, &states);
328 }
329
330 let control_pointer = drag.is_none().then_some(pointer).flatten();
333 let held = drag.and_then(|drag| self.drag_zone(area, &strip, drag.pointer.0)).map(|zone| zone.edge);
334 match self.overflow {
335 Overflow::Arrows => self.paint_arrows(cx, &strip, offset, control_pointer, held),
336 Overflow::Menu => self.paint_menu_control(cx, &strip, control_pointer),
337 }
338 self.paint_add(cx, area, &strip, control_pointer, on_add);
339
340 if let Some(drag) = drag {
341 self.paint_ghost(cx, area, &strip, drag, widths[drag.index], &order);
342 }
343 }
344
345 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
346 if self.model.paint_menu(cx, anchor) {
347 return;
348 }
349 if !PopupMenu::is_open_paint(cx) {
350 self.paint_add_hint(cx);
351 return;
352 }
353 let hidden = cx.memory::<TabsMemory>().hidden.clone();
354 let labels = self.menu_labels(&hidden);
355 let current = hidden.iter().position(|i| *i == self.model.active());
358 PopupMenu::paint(cx, anchor, &labels, current);
359 }
360
361 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
362 let area = cx.area();
363 if self.labels.is_empty() {
364 return self.add_event(cx, event, &self.strip(area, &[], 0, &[]));
365 }
366 let widths = self.widths(area.width);
367 let identity = self.identity();
368 let mut offset = cx.memory::<TabsMemory>().offset;
369 if let Event::Key(key) = event
372 && self.model.has_menu()
373 && context_menu::is_menu_key(key)
374 && !context_menu::is_open_in(cx)
375 {
376 offset = self.follow(area, &identity, offset, self.model.active(), &widths);
377 cx.memory::<TabsMemory>().offset = offset;
378 }
379 let strip = self.strip(area, &identity, offset, &widths);
380 if self.overflow == Overflow::Menu && PopupMenu::is_open(cx) {
381 let hidden = cx.memory::<TabsMemory>().hidden.clone();
382 let labels = self.menu_labels(&hidden);
383 match PopupMenu::event(cx, event, &labels) {
384 PopupAction::Chosen(row) => {
385 self.model.open(cx, hidden[row]);
386 return true;
387 }
388 PopupAction::Closed if Self::presses_beside(event, strip.menu) => {}
392 PopupAction::Used | PopupAction::Closed => return true,
393 PopupAction::Ignored => {}
394 }
395 }
396 let hit = match event {
397 Event::Mouse(mouse) => self.hit(&strip, mouse.x, mouse.y),
398 _ => None,
399 };
400 let open_tab = strip.tabs.iter().find(|(index, _)| *index == self.model.active()).map(|(_, rect)| *rect);
401 if self.model.menu_event(cx, event, hit, open_tab) {
402 return true;
403 }
404 if self.add_event(cx, event, &strip) {
405 return true;
406 }
407 match event {
408 Event::Key(key) => {
409 if self.model.key(cx, key, Direction::Across) {
410 return true;
411 }
412 if self.numbered
413 && let Key::Char(c @ '1'..='9') = key.chord.key
414 && key.chord.mods == Modifiers::default()
415 {
416 let index = usize::try_from(u32::from(c) - u32::from('1')).unwrap_or(usize::MAX);
417 return self.model.open(cx, index);
418 }
419 let ctrl = Modifiers { ctrl: true, ..Modifiers::default() };
420 if self.scrolls(&strip) && key.chord.mods == ctrl {
423 let arrow = match key.chord.key {
424 Key::PageUp => Arrow::Back,
425 Key::PageDown => Arrow::Forward,
426 _ => return false,
427 };
428 self.scroll(cx, &strip, arrow, true);
429 return true;
430 }
431 if strip.menu.is_some() && key.is_plain(Key::Down) {
432 self.open_menu(cx, &strip);
433 return true;
434 }
435 false
436 }
437 Event::Mouse(mouse) => {
438 let left_down = mouse.kind == MouseKind::Down(MouseButton::Left);
439 let down = matches!(mouse.kind, MouseKind::Down(_));
442 if down && let Some(arrow) = strip.arrow_at(mouse.x, mouse.y) {
443 if left_down {
444 self.scroll(cx, &strip, arrow, true);
445 }
446 return true;
447 }
448 if self.scrolls(&strip) && matches!(mouse.kind, MouseKind::ScrollUp | MouseKind::ScrollDown) {
449 let arrow = if mouse.kind == MouseKind::ScrollUp { Arrow::Back } else { Arrow::Forward };
450 self.scroll(cx, &strip, arrow, false);
451 return true;
452 }
453 if down && strip.menu.is_some_and(|menu| menu.contains(mouse.x, mouse.y)) {
454 if left_down {
455 self.open_menu(cx, &strip);
456 }
457 return true;
458 }
459 let used = self.model.pointer(cx, mouse, hit, &self.drop_slots(&strip), Direction::Across);
460 if mouse.kind == MouseKind::Drag(MouseButton::Left) {
461 let zone = self.drag_zone(area, &strip, mouse.x);
462 self.model.edge_scroll(cx, zone, |cx, arrow| {
463 self.scroll(cx, &strip, arrow, true).then(|| cx.memory::<TabsMemory>().offset)
464 });
465 }
466 used
467 }
468 _ => false,
469 }
470 }
471
472 fn focusable(&self) -> bool {
473 !self.labels.is_empty() || self.on_add.is_some()
474 }
475}
476
477impl<Msg: 'static> Tabs<Msg> {
478 fn presses_beside(event: &Event, menu: Option<Rect>) -> bool {
480 matches!(event, Event::Mouse(mouse)
481 if matches!(mouse.kind, MouseKind::Down(_)) && !menu.is_some_and(|rect| rect.contains(mouse.x, mouse.y)))
482 }
483
484 fn menu_labels(&self, hidden: &[usize]) -> Vec<String> {
487 hidden
488 .iter()
489 .map(|i| match self.badge_text(*i) {
490 Some(count) => format!("{} {count}", self.labels[*i]),
491 None => self.labels[*i].clone(),
492 })
493 .collect()
494 }
495
496 fn scrolls(&self, strip: &Strip) -> bool {
499 self.overflow == Overflow::Arrows && strip.tabs.len() < self.labels.len()
500 }
501
502 fn open_menu(&self, cx: &mut EventCx<'_, Msg>, strip: &Strip) {
505 let hidden = Self::hidden(self.labels.len(), strip);
506 PopupMenu::open(cx, hidden.iter().position(|i| *i == self.model.active()).unwrap_or(0));
507 }
508
509 fn hidden(count: usize, strip: &Strip) -> Vec<usize> {
511 (0..count).filter(|i| !strip.tabs.iter().any(|(visible, _)| visible == i)).collect()
512 }
513
514 fn can_scroll(&self, strip: &Strip, offset: usize, arrow: Arrow) -> bool {
516 match arrow {
517 Arrow::Back => offset > 0,
518 Arrow::Forward => offset + strip.tabs.len() < self.labels.len(),
519 }
520 }
521
522 fn drag_zone(&self, area: Rect, strip: &Strip, x: i32) -> Option<Zone> {
526 if !self.scrolls(strip) || strip.add.is_some_and(|add| x >= add.x) {
527 return None;
528 }
529 let (back_end, forward_start) =
530 strip.arrows.map_or((area.x, area.right()), |(back, forward)| (back.right(), forward.x));
531 if x < back_end {
532 Some(Zone { edge: Edge::Back, beyond: clamp_u16(area.x - x) })
533 } else if x >= forward_start {
534 Some(Zone { edge: Edge::Forward, beyond: clamp_u16(x + 1 - area.right()) })
535 } else {
536 None
537 }
538 }
539
540 fn scroll(&self, cx: &mut EventCx<'_, Msg>, strip: &Strip, arrow: Arrow, flash: bool) -> bool {
543 let now = cx.now();
544 let memory = cx.memory::<TabsMemory>();
545 if !self.can_scroll(strip, memory.offset, arrow) {
546 return false;
547 }
548 memory.offset = match arrow {
549 Arrow::Back => memory.offset - 1,
550 Arrow::Forward => memory.offset + 1,
551 };
552 if flash {
553 memory.pressed = Some((arrow, now));
554 }
555 true
556 }
557}