1mod paint;
8mod strip;
9#[cfg(test)]
10mod tests;
11
12use std::time::Duration;
13
14use crate::event::{Event, MouseButton, MouseKind};
15use crate::geometry::{Rect, Size, clamp_u16};
16use crate::keymap::{Key, Modifiers};
17use crate::theme::State;
18use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
19
20use super::close_mark;
21use super::context_item::ContextItem;
22use super::context_menu;
23use super::edge_scroll::{Edge, Zone};
24use super::popup_menu::{PopupAction, PopupMenu};
25use super::tab_model::{self, Direction, TabModel, drop_target, preview_order};
26use strip::Strip;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum TabWidth {
31 #[default]
33 Fit,
34 Fixed(u16),
36 Fill,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum Overflow {
44 #[default]
50 Arrows,
51 Menu,
54}
55
56const GAP: u16 = 1;
58const PAD: u16 = 2;
60const CLOSE: u16 = 1 + close_mark::WIDTH - 1;
63const FILL_MIN: u16 = 12;
65const ARROW: u16 = 3;
67
68pub struct Tabs<Msg> {
98 labels: Vec<String>,
99 numbered: bool,
100 width: TabWidth,
101 overflow: Overflow,
102 model: TabModel<Msg>,
103}
104
105#[derive(Debug, Default)]
106struct TabsMemory {
107 offset: usize,
108 followed: Option<usize>,
109 hidden: Vec<usize>,
110 pressed: Option<(Arrow, Duration)>,
112}
113
114use Edge as Arrow;
116
117impl<Msg: 'static> Tabs<Msg> {
118 #[must_use]
120 pub fn new(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
121 let labels: Vec<String> = labels.into_iter().map(Into::into).collect();
122 let model = TabModel::new(labels.len());
123 Self { labels, numbered: false, width: TabWidth::Fit, overflow: Overflow::Arrows, model }
124 }
125
126 #[must_use]
128 pub fn active(mut self, index: usize) -> Self {
129 self.model.active = index;
130 self
131 }
132
133 #[must_use]
135 pub fn numbered(mut self, numbered: bool) -> Self {
136 self.numbered = numbered;
137 self
138 }
139
140 #[must_use]
142 pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
143 self.model.on_select = Some(Box::new(message));
144 self
145 }
146
147 #[must_use]
150 pub fn closable(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
151 self.model.set_on_close(message);
152 self
153 }
154
155 #[must_use]
157 pub fn pinned(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
158 self.model.pinned = indices.into_iter().collect();
159 self
160 }
161
162 #[must_use]
164 pub fn tab_width(mut self, width: TabWidth) -> Self {
165 self.width = width;
166 self
167 }
168
169 #[must_use]
171 pub fn overflow(mut self, overflow: Overflow) -> Self {
172 self.overflow = overflow;
173 self
174 }
175
176 #[must_use]
179 pub fn reorderable(mut self, message: impl Fn(usize, usize) -> Msg + 'static) -> Self {
180 self.model.set_on_move(message);
181 self
182 }
183
184 #[must_use]
188 pub fn on_drag_scroll(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
189 self.model.on_drag_scroll = Some(Box::new(message));
190 self
191 }
192
193 #[must_use]
198 pub fn context_menu(mut self, items: impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static) -> Self {
199 self.model.set_context_menu(items);
200 self
201 }
202}
203
204impl<Msg: 'static> Widget<Msg> for Tabs<Msg> {
205 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
206 let width = match self.width {
207 TabWidth::Fill => available.width,
208 TabWidth::Fit | TabWidth::Fixed(_) => Self::total_width(&self.widths(available.width)),
209 };
210 Size::new(width, 1).min(available)
211 }
212
213 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
214 cx.register_hit(area);
215 if self.labels.is_empty() {
216 return;
217 }
218 let focused = cx.is_focus_visible();
219 let pointer = cx.pointer();
220 let widths = self.widths(area.width);
221 let active = self.model.active();
222 let drag = self.model.drag(cx);
223 let identity = self.identity();
224
225 let offset = {
228 let memory = cx.memory::<TabsMemory>();
229 let (offset, followed) = (memory.offset, memory.followed);
230 let follows = drag.is_none() && (self.overflow == Overflow::Menu || followed != Some(active));
231 let offset = if follows {
232 self.follow(area, &identity, offset, active, &widths)
233 } else {
234 offset.min(self.labels.len() - 1)
235 };
236 let offset = if drag.is_none() { self.settle(area, &identity, offset, &widths) } else { offset };
237 let memory = cx.memory::<TabsMemory>();
238 memory.offset = offset;
239 memory.followed = Some(active);
240 offset
241 };
242
243 let resting = self.strip(area, &identity, offset, &widths);
244 let target = drag.map(|d| (d.index, drop_target(&resting.tabs, d.index, d.pointer, Direction::Across)));
245 let order = preview_order(self.labels.len(), target);
246 let strip = if target.is_some() { self.strip(area, &order, offset, &widths) } else { resting };
247
248 let menu_tab = self.model.menu_tab(cx);
250 if menu_tab.is_some() {
251 cx.request_overlay(area);
252 }
253 let pointer = pointer.filter(|_| menu_tab.is_none());
254
255 for (position, (index, rect)) in strip.tabs.iter().enumerate() {
256 if drag.is_some_and(|d| d.index == *index) {
257 tab_model::paint_drop_slot(cx, *rect);
258 continue;
259 }
260 let mut states = Vec::new();
261 if menu_tab == Some(*index) || (drag.is_none() && pointer.is_some_and(|(x, y)| rect.contains(x, y))) {
262 states.push(State::Hover);
263 }
264 if *index == active {
265 states.push(State::Selected);
266 if focused {
267 states.push(State::Focus);
268 }
269 }
270 self.paint_tab(cx, *rect, *index, offset + position, &states);
271 }
272
273 let control_pointer = drag.is_none().then_some(pointer).flatten();
276 let held = drag.and_then(|drag| self.drag_zone(area, &strip, drag.pointer.0)).map(|zone| zone.edge);
277 match self.overflow {
278 Overflow::Arrows => self.paint_arrows(cx, &strip, offset, control_pointer, held),
279 Overflow::Menu => self.paint_menu_control(cx, &strip, control_pointer),
280 }
281
282 if let Some(drag) = drag {
283 self.paint_ghost(cx, area, &strip, drag, widths[drag.index], &order);
284 }
285 }
286
287 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
288 if self.model.paint_menu(cx, anchor) {
289 return;
290 }
291 let hidden = cx.memory::<TabsMemory>().hidden.clone();
292 let labels: Vec<String> = hidden.iter().map(|i| self.labels[*i].clone()).collect();
293 let current = hidden.iter().position(|i| *i == self.model.active());
296 PopupMenu::paint(cx, anchor, &labels, current);
297 }
298
299 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
300 if self.labels.is_empty() {
301 return false;
302 }
303 let area = cx.area();
304 let widths = self.widths(area.width);
305 let identity = self.identity();
306 let mut offset = cx.memory::<TabsMemory>().offset;
307 if let Event::Key(key) = event
310 && self.model.has_menu()
311 && context_menu::is_menu_key(key)
312 && !context_menu::is_open_in(cx)
313 {
314 offset = self.follow(area, &identity, offset, self.model.active(), &widths);
315 cx.memory::<TabsMemory>().offset = offset;
316 }
317 let strip = self.strip(area, &identity, offset, &widths);
318 if self.overflow == Overflow::Menu && PopupMenu::is_open(cx) {
319 let hidden = cx.memory::<TabsMemory>().hidden.clone();
320 let labels: Vec<String> = hidden.iter().map(|i| self.labels[*i].clone()).collect();
321 match PopupMenu::event(cx, event, &labels) {
322 PopupAction::Chosen(row) => {
323 self.model.open(cx, hidden[row]);
324 return true;
325 }
326 PopupAction::Closed if Self::presses_beside(event, strip.menu) => {}
330 PopupAction::Used | PopupAction::Closed => return true,
331 PopupAction::Ignored => {}
332 }
333 }
334 let hit = match event {
335 Event::Mouse(mouse) => self.hit(&strip, mouse.x, mouse.y),
336 _ => None,
337 };
338 let open_tab = strip.tabs.iter().find(|(index, _)| *index == self.model.active()).map(|(_, rect)| *rect);
339 if self.model.menu_event(cx, event, hit, open_tab) {
340 return true;
341 }
342 match event {
343 Event::Key(key) => {
344 if self.model.key(cx, key, Direction::Across) {
345 return true;
346 }
347 if self.numbered
348 && let Key::Char(c @ '1'..='9') = key.chord.key
349 && key.chord.mods == Modifiers::default()
350 {
351 let index = usize::try_from(u32::from(c) - u32::from('1')).unwrap_or(usize::MAX);
352 return self.model.open(cx, index);
353 }
354 let ctrl = Modifiers { ctrl: true, ..Modifiers::default() };
355 if self.scrolls(&strip) && key.chord.mods == ctrl {
358 let arrow = match key.chord.key {
359 Key::PageUp => Arrow::Back,
360 Key::PageDown => Arrow::Forward,
361 _ => return false,
362 };
363 self.scroll(cx, &strip, arrow, true);
364 return true;
365 }
366 if strip.menu.is_some() && key.is_plain(Key::Down) {
367 self.open_menu(cx, &strip);
368 return true;
369 }
370 false
371 }
372 Event::Mouse(mouse) => {
373 let left_down = mouse.kind == MouseKind::Down(MouseButton::Left);
374 let down = matches!(mouse.kind, MouseKind::Down(_));
377 if down && let Some(arrow) = strip.arrow_at(mouse.x, mouse.y) {
378 if left_down {
379 self.scroll(cx, &strip, arrow, true);
380 }
381 return true;
382 }
383 if self.scrolls(&strip) && matches!(mouse.kind, MouseKind::ScrollUp | MouseKind::ScrollDown) {
384 let arrow = if mouse.kind == MouseKind::ScrollUp { Arrow::Back } else { Arrow::Forward };
385 self.scroll(cx, &strip, arrow, false);
386 return true;
387 }
388 if down && strip.menu.is_some_and(|menu| menu.contains(mouse.x, mouse.y)) {
389 if left_down {
390 self.open_menu(cx, &strip);
391 }
392 return true;
393 }
394 let used = self.model.pointer(cx, mouse, hit, &strip.tabs, Direction::Across);
395 if mouse.kind == MouseKind::Drag(MouseButton::Left) {
396 let zone = self.drag_zone(area, &strip, mouse.x);
397 self.model.edge_scroll(cx, zone, |cx, arrow| {
398 self.scroll(cx, &strip, arrow, true).then(|| cx.memory::<TabsMemory>().offset)
399 });
400 }
401 used
402 }
403 _ => false,
404 }
405 }
406
407 fn focusable(&self) -> bool {
408 !self.labels.is_empty()
409 }
410}
411
412impl<Msg: 'static> Tabs<Msg> {
413 fn presses_beside(event: &Event, menu: Option<Rect>) -> bool {
415 matches!(event, Event::Mouse(mouse)
416 if matches!(mouse.kind, MouseKind::Down(_)) && !menu.is_some_and(|rect| rect.contains(mouse.x, mouse.y)))
417 }
418
419 fn scrolls(&self, strip: &Strip) -> bool {
422 self.overflow == Overflow::Arrows && strip.tabs.len() < self.labels.len()
423 }
424
425 fn open_menu(&self, cx: &mut EventCx<'_, Msg>, strip: &Strip) {
428 let hidden = Self::hidden(self.labels.len(), strip);
429 PopupMenu::open(cx, hidden.iter().position(|i| *i == self.model.active()).unwrap_or(0));
430 }
431
432 fn hidden(count: usize, strip: &Strip) -> Vec<usize> {
434 (0..count).filter(|i| !strip.tabs.iter().any(|(visible, _)| visible == i)).collect()
435 }
436
437 fn can_scroll(&self, strip: &Strip, offset: usize, arrow: Arrow) -> bool {
439 match arrow {
440 Arrow::Back => offset > 0,
441 Arrow::Forward => offset + strip.tabs.len() < self.labels.len(),
442 }
443 }
444
445 fn drag_zone(&self, area: Rect, strip: &Strip, x: i32) -> Option<Zone> {
449 if !self.scrolls(strip) {
450 return None;
451 }
452 let (back_end, forward_start) =
453 strip.arrows.map_or((area.x, area.right()), |(back, forward)| (back.right(), forward.x));
454 if x < back_end {
455 Some(Zone { edge: Edge::Back, beyond: clamp_u16(area.x - x) })
456 } else if x >= forward_start {
457 Some(Zone { edge: Edge::Forward, beyond: clamp_u16(x + 1 - area.right()) })
458 } else {
459 None
460 }
461 }
462
463 fn scroll(&self, cx: &mut EventCx<'_, Msg>, strip: &Strip, arrow: Arrow, flash: bool) -> bool {
466 let now = cx.now();
467 let memory = cx.memory::<TabsMemory>();
468 if !self.can_scroll(strip, memory.offset, arrow) {
469 return false;
470 }
471 memory.offset = match arrow {
472 Arrow::Back => memory.offset - 1,
473 Arrow::Forward => memory.offset + 1,
474 };
475 if flash {
476 memory.pressed = Some((arrow, now));
477 }
478 true
479 }
480}