mod add;
#[cfg(test)]
mod add_tests;
#[cfg(test)]
mod badge_tests;
mod paint;
mod strip;
#[cfg(test)]
mod tests;
use std::time::Duration;
use crate::event::{Event, MouseButton, MouseKind};
use crate::geometry::{Rect, Size, clamp_u16};
use crate::keymap::{Key, Modifiers};
use crate::theme::State;
use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
use super::close_mark;
use super::context_item::ContextItem;
use super::context_menu;
use super::edge_scroll::{Edge, Zone};
use super::popup_menu::{PopupAction, PopupMenu};
use super::tab_model::{self, Direction, TabModel, drop_target, preview_order};
use strip::Strip;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TabWidth {
#[default]
Fit,
Fixed(u16),
Fill,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Overflow {
#[default]
Arrows,
Menu,
}
const GAP: u16 = 1;
const PAD: u16 = 2;
const CLOSE: u16 = 1 + close_mark::WIDTH - 1;
const FILL_MIN: u16 = 12;
const ARROW: u16 = 3;
const ADD: u16 = 3;
pub struct Tabs<Msg> {
labels: Vec<String>,
badges: Vec<u32>,
numbered: bool,
width: TabWidth,
overflow: Overflow,
on_add: Option<Box<dyn Fn() -> Msg>>,
model: TabModel<Msg>,
}
#[derive(Debug, Default)]
struct TabsMemory {
offset: usize,
followed: Option<usize>,
hidden: Vec<usize>,
pressed: Option<(Arrow, Duration)>,
on_add: bool,
add_pressed: Option<Duration>,
add_hovered_since: Option<Duration>,
add_hint_since: Option<Duration>,
add_hint: Option<Rect>,
}
use Edge as Arrow;
impl<Msg: 'static> Tabs<Msg> {
#[must_use]
pub fn new(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
let labels: Vec<String> = labels.into_iter().map(Into::into).collect();
let model = TabModel::new(labels.len());
let badges = vec![0; labels.len()];
Self { labels, badges, numbered: false, width: TabWidth::Fit, overflow: Overflow::Arrows, on_add: None, model }
}
#[must_use]
pub fn active(mut self, index: usize) -> Self {
self.model.active = index;
self
}
#[must_use]
pub fn numbered(mut self, numbered: bool) -> Self {
self.numbered = numbered;
self
}
#[must_use]
pub fn badge(mut self, index: usize, count: u32) -> Self {
if let Some(badge) = self.badges.get_mut(index) {
*badge = count;
}
self
}
#[must_use]
pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
self.model.on_select = Some(Box::new(message));
self
}
#[must_use]
pub fn closable(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
self.model.set_on_close(message);
self
}
#[must_use]
pub fn pinned(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
self.model.pinned = indices.into_iter().collect();
self
}
#[must_use]
pub fn tab_width(mut self, width: TabWidth) -> Self {
self.width = width;
self
}
#[must_use]
pub fn overflow(mut self, overflow: Overflow) -> Self {
self.overflow = overflow;
self
}
#[must_use]
pub fn reorderable(mut self, message: impl Fn(usize, usize) -> Msg + 'static) -> Self {
self.model.set_on_move(message);
self
}
#[must_use]
pub fn on_drag_scroll(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
self.model.on_drag_scroll = Some(Box::new(message));
self
}
#[must_use]
pub fn on_add(mut self, message: impl Fn() -> Msg + 'static) -> Self {
self.on_add = Some(Box::new(message));
self
}
#[must_use]
pub fn context_menu(mut self, items: impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static) -> Self {
self.model.set_context_menu(items);
self
}
}
impl<Msg: 'static> Widget<Msg> for Tabs<Msg> {
fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
let width = match self.width {
TabWidth::Fill => available.width,
TabWidth::Fit | TabWidth::Fixed(_) => {
Self::total_width(&self.widths(available.width)).saturating_add(self.add_room())
}
};
Size::new(width, 1).min(available)
}
fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
cx.register_hit(area);
let on_add = self.add_focus_paint(cx);
if self.labels.is_empty() {
let strip = self.strip(area, &[], 0, &[]);
let pointer = cx.pointer();
self.paint_add(cx, area, &strip, pointer, on_add);
return;
}
let focused = cx.is_focus_visible() && !on_add;
let pointer = cx.pointer();
let widths = self.widths(area.width);
let active = self.model.active();
let drag = self.model.drag(cx);
let identity = self.identity();
let offset = {
let memory = cx.memory::<TabsMemory>();
let (offset, followed) = (memory.offset, memory.followed);
let follows = drag.is_none() && (self.overflow == Overflow::Menu || followed != Some(active));
let offset = if follows {
self.follow(area, &identity, offset, active, &widths)
} else {
offset.min(self.labels.len() - 1)
};
let offset = if drag.is_none() { self.settle(area, &identity, offset, &widths) } else { offset };
let memory = cx.memory::<TabsMemory>();
memory.offset = offset;
memory.followed = Some(active);
offset
};
let resting = self.strip(area, &identity, offset, &widths);
let slots = self.drop_slots(&resting);
let target = drag.map(|d| (d.index, drop_target(&slots, d.index, d.pointer, Direction::Across)));
let order = preview_order(self.labels.len(), target);
let strip = if target.is_some() { self.strip(area, &order, offset, &widths) } else { resting };
let menu_tab = self.model.menu_tab(cx);
if menu_tab.is_some() {
cx.request_overlay(area);
}
let pointer = pointer.filter(|_| menu_tab.is_none());
for (position, (index, rect)) in strip.tabs.iter().enumerate() {
if drag.is_some_and(|d| d.index == *index) {
tab_model::paint_drop_slot(cx, *rect);
continue;
}
let mut states = Vec::new();
if menu_tab == Some(*index) || (drag.is_none() && pointer.is_some_and(|(x, y)| rect.contains(x, y))) {
states.push(State::Hover);
}
if *index == active {
states.push(State::Selected);
if focused {
states.push(State::Focus);
}
}
self.paint_tab(cx, *rect, *index, offset + position, &states);
}
let control_pointer = drag.is_none().then_some(pointer).flatten();
let held = drag.and_then(|drag| self.drag_zone(area, &strip, drag.pointer.0)).map(|zone| zone.edge);
match self.overflow {
Overflow::Arrows => self.paint_arrows(cx, &strip, offset, control_pointer, held),
Overflow::Menu => self.paint_menu_control(cx, &strip, control_pointer),
}
self.paint_add(cx, area, &strip, control_pointer, on_add);
if let Some(drag) = drag {
self.paint_ghost(cx, area, &strip, drag, widths[drag.index], &order);
}
}
fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
if self.model.paint_menu(cx, anchor) {
return;
}
if !PopupMenu::is_open_paint(cx) {
self.paint_add_hint(cx);
return;
}
let hidden = cx.memory::<TabsMemory>().hidden.clone();
let labels = self.menu_labels(&hidden);
let current = hidden.iter().position(|i| *i == self.model.active());
PopupMenu::paint(cx, anchor, &labels, current);
}
fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
let area = cx.area();
if self.labels.is_empty() {
return self.add_event(cx, event, &self.strip(area, &[], 0, &[]));
}
let widths = self.widths(area.width);
let identity = self.identity();
let mut offset = cx.memory::<TabsMemory>().offset;
if let Event::Key(key) = event
&& self.model.has_menu()
&& context_menu::is_menu_key(key)
&& !context_menu::is_open_in(cx)
{
offset = self.follow(area, &identity, offset, self.model.active(), &widths);
cx.memory::<TabsMemory>().offset = offset;
}
let strip = self.strip(area, &identity, offset, &widths);
if self.overflow == Overflow::Menu && PopupMenu::is_open(cx) {
let hidden = cx.memory::<TabsMemory>().hidden.clone();
let labels = self.menu_labels(&hidden);
match PopupMenu::event(cx, event, &labels) {
PopupAction::Chosen(row) => {
self.model.open(cx, hidden[row]);
return true;
}
PopupAction::Closed if Self::presses_beside(event, strip.menu) => {}
PopupAction::Used | PopupAction::Closed => return true,
PopupAction::Ignored => {}
}
}
let hit = match event {
Event::Mouse(mouse) => self.hit(&strip, mouse.x, mouse.y),
_ => None,
};
let open_tab = strip.tabs.iter().find(|(index, _)| *index == self.model.active()).map(|(_, rect)| *rect);
if self.model.menu_event(cx, event, hit, open_tab) {
return true;
}
if self.add_event(cx, event, &strip) {
return true;
}
match event {
Event::Key(key) => {
if self.model.key(cx, key, Direction::Across) {
return true;
}
if self.numbered
&& let Key::Char(c @ '1'..='9') = key.chord.key
&& key.chord.mods == Modifiers::default()
{
let index = usize::try_from(u32::from(c) - u32::from('1')).unwrap_or(usize::MAX);
return self.model.open(cx, index);
}
let ctrl = Modifiers { ctrl: true, ..Modifiers::default() };
if self.scrolls(&strip) && key.chord.mods == ctrl {
let arrow = match key.chord.key {
Key::PageUp => Arrow::Back,
Key::PageDown => Arrow::Forward,
_ => return false,
};
self.scroll(cx, &strip, arrow, true);
return true;
}
if strip.menu.is_some() && key.is_plain(Key::Down) {
self.open_menu(cx, &strip);
return true;
}
false
}
Event::Mouse(mouse) => {
let left_down = mouse.kind == MouseKind::Down(MouseButton::Left);
let down = matches!(mouse.kind, MouseKind::Down(_));
if down && let Some(arrow) = strip.arrow_at(mouse.x, mouse.y) {
if left_down {
self.scroll(cx, &strip, arrow, true);
}
return true;
}
if self.scrolls(&strip) && matches!(mouse.kind, MouseKind::ScrollUp | MouseKind::ScrollDown) {
let arrow = if mouse.kind == MouseKind::ScrollUp { Arrow::Back } else { Arrow::Forward };
self.scroll(cx, &strip, arrow, false);
return true;
}
if down && strip.menu.is_some_and(|menu| menu.contains(mouse.x, mouse.y)) {
if left_down {
self.open_menu(cx, &strip);
}
return true;
}
let used = self.model.pointer(cx, mouse, hit, &self.drop_slots(&strip), Direction::Across);
if mouse.kind == MouseKind::Drag(MouseButton::Left) {
let zone = self.drag_zone(area, &strip, mouse.x);
self.model.edge_scroll(cx, zone, |cx, arrow| {
self.scroll(cx, &strip, arrow, true).then(|| cx.memory::<TabsMemory>().offset)
});
}
used
}
_ => false,
}
}
fn focusable(&self) -> bool {
!self.labels.is_empty() || self.on_add.is_some()
}
}
impl<Msg: 'static> Tabs<Msg> {
fn presses_beside(event: &Event, menu: Option<Rect>) -> bool {
matches!(event, Event::Mouse(mouse)
if matches!(mouse.kind, MouseKind::Down(_)) && !menu.is_some_and(|rect| rect.contains(mouse.x, mouse.y)))
}
fn menu_labels(&self, hidden: &[usize]) -> Vec<String> {
hidden
.iter()
.map(|i| match self.badge_text(*i) {
Some(count) => format!("{} {count}", self.labels[*i]),
None => self.labels[*i].clone(),
})
.collect()
}
fn scrolls(&self, strip: &Strip) -> bool {
self.overflow == Overflow::Arrows && strip.tabs.len() < self.labels.len()
}
fn open_menu(&self, cx: &mut EventCx<'_, Msg>, strip: &Strip) {
let hidden = Self::hidden(self.labels.len(), strip);
PopupMenu::open(cx, hidden.iter().position(|i| *i == self.model.active()).unwrap_or(0));
}
fn hidden(count: usize, strip: &Strip) -> Vec<usize> {
(0..count).filter(|i| !strip.tabs.iter().any(|(visible, _)| visible == i)).collect()
}
fn can_scroll(&self, strip: &Strip, offset: usize, arrow: Arrow) -> bool {
match arrow {
Arrow::Back => offset > 0,
Arrow::Forward => offset + strip.tabs.len() < self.labels.len(),
}
}
fn drag_zone(&self, area: Rect, strip: &Strip, x: i32) -> Option<Zone> {
if !self.scrolls(strip) || strip.add.is_some_and(|add| x >= add.x) {
return None;
}
let (back_end, forward_start) =
strip.arrows.map_or((area.x, area.right()), |(back, forward)| (back.right(), forward.x));
if x < back_end {
Some(Zone { edge: Edge::Back, beyond: clamp_u16(area.x - x) })
} else if x >= forward_start {
Some(Zone { edge: Edge::Forward, beyond: clamp_u16(x + 1 - area.right()) })
} else {
None
}
}
fn scroll(&self, cx: &mut EventCx<'_, Msg>, strip: &Strip, arrow: Arrow, flash: bool) -> bool {
let now = cx.now();
let memory = cx.memory::<TabsMemory>();
if !self.can_scroll(strip, memory.offset, arrow) {
return false;
}
memory.offset = match arrow {
Arrow::Back => memory.offset - 1,
Arrow::Forward => memory.offset + 1,
};
if flash {
memory.pressed = Some((arrow, now));
}
true
}
}