mod layout;
mod paint;
#[cfg(test)]
mod tests;
use crate::event::{Event, MouseButton, MouseEvent, MouseKind};
use crate::geometry::{Rect, Size, clamp_u16};
use crate::keymap::Key;
use crate::style::CellStyle;
use crate::text;
use crate::theme::State;
use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
use super::cells;
use super::close_mark;
use super::context_item::ContextItem;
use super::context_menu;
use super::edge_scroll::Edge;
use super::row::LEAD;
use super::scrollbar::{self, ScrollMetrics};
use super::tab_model::{self, Direction, TabModel, drop_target, preview_order};
const COLLAPSED: u16 = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CollapsedMarker {
#[default]
Icon,
Initial,
Number,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RailTab {
name: String,
icon: Option<String>,
badge: Option<String>,
status: Option<String>,
marker: Option<CollapsedMarker>,
}
impl RailTab {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self { name: name.into(), icon: None, badge: None, status: None, marker: None }
}
#[must_use]
pub fn marker(mut self, marker: CollapsedMarker) -> Self {
self.marker = Some(marker);
self
}
#[must_use]
pub fn icon(mut self, key: impl Into<String>) -> Self {
self.icon = Some(key.into());
self
}
#[must_use]
pub fn badge(mut self, text: impl Into<String>) -> Self {
self.badge = Some(text.into());
self
}
#[must_use]
pub fn status(mut self, token: impl Into<String>) -> Self {
self.status = Some(token.into());
self
}
}
pub struct TabRail<Msg> {
tabs: Vec<RailTab>,
collapsed: bool,
marker: CollapsedMarker,
row_height: u16,
gap: Option<u16>,
on_add: Option<Box<dyn Fn() -> Msg>>,
model: TabModel<Msg>,
}
#[derive(Debug, Default)]
struct RailMemory {
offset: usize,
followed: Option<usize>,
hint: Option<RailRow>,
card: Option<(RailRow, Rect)>,
dragging_scrollbar: bool,
}
impl RailMemory {
fn reveal(&mut self, index: usize, visible: usize) {
if index < self.offset {
self.offset = index;
} else if index >= self.offset + visible {
self.offset = index + 1 - visible;
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RailRow {
Tab(usize),
Add,
}
impl<Msg: 'static> TabRail<Msg> {
#[must_use]
pub fn new(tabs: impl IntoIterator<Item = RailTab>) -> Self {
let tabs: Vec<RailTab> = tabs.into_iter().collect();
let model = TabModel::new(tabs.len());
Self { tabs, collapsed: false, marker: CollapsedMarker::Icon, row_height: 1, gap: None, on_add: None, model }
}
#[must_use]
pub fn active(mut self, index: usize) -> Self {
self.model.active = index;
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 collapsed(mut self, collapsed: bool) -> Self {
self.collapsed = collapsed;
self
}
#[must_use]
pub fn collapsed_marker(mut self, marker: CollapsedMarker) -> Self {
self.marker = marker;
self
}
#[must_use]
pub fn row_height(mut self, lines: u16) -> Self {
self.row_height = lines.max(1);
self
}
#[must_use]
pub fn gap(mut self, lines: u16) -> Self {
self.gap = Some(lines);
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 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 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 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 TabRail<Msg> {
fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
let height = self.natural_height(self.rows());
if self.collapsed {
return Size::new(COLLAPSED, height).min(available);
}
let widest = (0..self.tabs.len())
.map(|i| {
let icon = if self.tabs[i].icon.is_some() { 2 } else { 0 };
cells::sum([LEAD, icon, text::width(&self.tabs[i].name), self.trailing_width(i, self.row_height), 3])
})
.max()
.unwrap_or(0);
Size::new(widest, height).min(available)
}
fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
cx.register_hit(area);
let card = {
let memory = cx.memory::<RailMemory>();
memory.hint = None;
memory.card.take()
};
if self.rows() == 0 || area.is_empty() {
return;
}
let focused = cx.is_focus_visible();
let pointer = cx.pointer();
let active = self.model.active();
let drag = self.model.drag(cx);
let visible = self.visible_blocks(area.height);
let offset = {
let memory = cx.memory::<RailMemory>();
if drag.is_none() && memory.followed != Some(active) {
memory.reveal(active, visible);
memory.followed = Some(active);
}
memory.offset = memory.offset.min(self.rows().saturating_sub(visible));
memory.offset
};
let (content, metrics) = self.content(area, offset);
let resting = self.slots(&self.identity(), offset, content);
let target = drag.map(|d| (d.index, drop_target(&resting, d.index, d.pointer, Direction::Down)));
let order = preview_order(self.tabs.len(), target);
let carded = card
.filter(|(_, rect)| drag.is_none() && pointer.is_some_and(|(x, y)| rect.contains(x, y)))
.map(|(row, _)| row);
let named = (self.collapsed && focused && pointer.is_none() && drag.is_none()).then_some(RailRow::Tab(active));
let menu_tab = self.model.menu_tab(cx);
let menu = menu_tab.is_some();
if menu {
cx.request_overlay(area);
}
let carded = carded.filter(|_| !menu);
let named = named.filter(|_| !menu);
let pointer = pointer.filter(|_| !menu);
for (index, rect) in self.slots(&order, offset, content) {
if drag.is_some_and(|d| d.index == index) {
tab_model::paint_drop_slot(cx, rect);
continue;
}
let mut states = Vec::new();
let hovered = drag.is_none() && pointer.is_some_and(|(x, y)| rect.contains(x, y));
if hovered || carded == Some(RailRow::Tab(index)) || menu_tab == Some(index) {
states.push(State::Hover);
}
if self.collapsed && !menu && (hovered || carded.or(named) == Some(RailRow::Tab(index))) {
cx.memory::<RailMemory>().hint = Some(RailRow::Tab(index));
cx.request_overlay(rect);
}
if index == active {
states.push(State::Selected);
if focused {
states.push(State::Focus);
}
}
self.paint_row(cx, rect, index, &states, None);
}
if let Some(rect) = self.add_rect(content, offset) {
let hovered =
drag.is_none() && (pointer.is_some_and(|(x, y)| rect.contains(x, y)) || carded == Some(RailRow::Add));
if hovered && self.collapsed && !menu {
cx.memory::<RailMemory>().hint = Some(RailRow::Add);
cx.request_overlay(rect);
}
self.paint_add(cx, rect, hovered);
}
if let Some(drag) = drag {
let height = resting.first().map_or(1, |(_, rect)| rect.height);
let top = content.y;
let bottom = (content.bottom() - i32::from(height)).max(top);
let y = (drag.pointer.1 - drag.grab.1).clamp(top, bottom);
let rect = Rect::new(content.x, y, content.width, height);
tab_model::paint_ghost_surface(cx, rect);
self.paint_row(cx, rect, drag.index, &[], Some("ghost"));
}
if metrics.overflows() {
let bar = Rect::new(area.right() - 1, area.y, 1, area.height);
let held =
drag.and_then(|drag| self.drag_zone(area, content, metrics, drag.pointer.1)).is_some_and(|zone| {
match zone.edge {
Edge::Back => offset > 0,
Edge::Forward => offset < metrics.max_offset(),
}
});
let active = held
|| cx.memory::<RailMemory>().dragging_scrollbar
|| (carded.is_none() && pointer.is_some_and(|(x, _)| x == bar.x));
scrollbar::paint(cx, bar, metrics, active, None);
}
}
fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
if self.model.paint_menu(cx, anchor) {
return;
}
let Some(row) = cx.memory::<RailMemory>().hint else {
return;
};
let (name, badge, closable) = match row {
RailRow::Tab(index) => {
(self.tabs[index].name.clone(), self.tabs[index].badge.clone(), self.model.closable(index))
}
RailRow::Add => (cx.env().i18n().translate("quvyta.tab-rail.add", &[]), None, false),
};
let card = Self::card(cx.clip(), anchor, &name, badge.as_deref(), closable);
let line = Self::middle(card);
let close = closable.then(|| close_mark::rect(card.right() - i32::from(close_mark::WIDTH), line));
let on_card = cx.pointer_anywhere().is_some_and(|(x, y)| card.contains(x, y));
let on_close = cx.pointer_anywhere().is_some_and(|(x, y)| close.is_some_and(|close| close.contains(x, y)));
let states = if on_card && !on_close { vec![State::Hover] } else { Vec::new() };
let style = cx.style("rail-hint", None, &states).text();
let background = style.bg.unwrap_or_else(|| cx.color("overlay"));
let grounds = cx.grounds_around(card);
cx.clear(card, background);
cx.register_hit(card);
let mut right = card.right() - i32::from(close.map_or(1, |close| close.width));
if let Some(badge) = &badge {
let badge_style = cx.style("rail-badge", None, &states).text();
let width = text::width(badge);
right -= i32::from(width);
cx.text(right, line, badge, CellStyle { bg: None, ..badge_style }, width);
right -= 2;
}
let budget = clamp_u16(right - (card.x + 1));
let shown = text::truncate(&name, budget).into_owned();
cx.text(card.x + 1, line, &shown, CellStyle { bg: None, ..style }, budget);
if let Some(close) = close {
close_mark::paint(cx, close.x, close.y, true);
}
cx.stand_apart(card, &grounds, Some(background));
cx.memory::<RailMemory>().card = Some((row, card));
}
fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
if self.rows() == 0 {
return false;
}
let area = cx.area();
if self.menu_event(cx, event) {
return true;
}
match event {
Event::Key(_) if self.tabs.is_empty() => false,
Event::Key(key) => {
if key.is_plain(Key::Home) {
return self.model.open(cx, 0);
}
if key.is_plain(Key::End) {
return self.model.open(cx, self.tabs.len() - 1);
}
self.model.key(cx, key, Direction::Down)
}
Event::Mouse(mouse) => {
let offset = cx.memory::<RailMemory>().offset;
let (content, metrics) = self.content(area, offset);
if matches!(mouse.kind, MouseKind::ScrollUp | MouseKind::ScrollDown) {
let memory = cx.memory::<RailMemory>();
memory.offset = if mouse.kind == MouseKind::ScrollUp {
memory.offset.saturating_sub(1)
} else {
(memory.offset + 1).min(metrics.max_offset())
};
return true;
}
let card = cx.memory::<RailMemory>().card.filter(|(_, rect)| rect.contains(mouse.x, mouse.y));
if (card.is_none() || cx.memory::<RailMemory>().dragging_scrollbar)
&& self.scrollbar(cx, mouse, metrics)
{
return true;
}
let on_add = match card {
Some((row, _)) => row == RailRow::Add,
None => self.add_rect(content, offset).is_some_and(|add| add.contains(mouse.x, mouse.y)),
};
if on_add && let MouseKind::Down(button) = mouse.kind {
if button == MouseButton::Left
&& let Some(message) = &self.on_add
{
cx.emit(message());
}
return true;
}
if self.tabs.is_empty() {
return false;
}
let slots = self.slots(&self.identity(), offset, content);
let hit = match card {
Some((RailRow::Tab(index), rect)) if index < self.tabs.len() => {
Some(Self::card_hit(rect, index, self.model.closable(index), mouse.x, mouse.y))
}
_ => self.hit(&slots, mouse.x, mouse.y),
};
let used = self.model.pointer(cx, mouse, hit, &slots, Direction::Down);
if mouse.kind == MouseKind::Drag(MouseButton::Left) {
let zone = self.drag_zone(area, content, metrics, mouse.y);
self.model.edge_scroll(cx, zone, |cx, edge| {
let memory = cx.memory::<RailMemory>();
memory.offset = match edge {
Edge::Back => memory.offset.checked_sub(1)?,
Edge::Forward => Some(memory.offset + 1).filter(|next| *next <= metrics.max_offset())?,
};
Some(memory.offset)
});
}
used
}
_ => false,
}
}
fn focusable(&self) -> bool {
!self.tabs.is_empty()
}
}
impl<Msg: 'static> TabRail<Msg> {
fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
if let Event::Key(key) = event
&& self.model.has_menu()
&& context_menu::is_menu_key(key)
&& !context_menu::is_open_in(cx)
{
let visible = self.visible_blocks(cx.area().height);
cx.memory::<RailMemory>().reveal(self.model.active(), visible);
}
let offset = cx.memory::<RailMemory>().offset;
let (content, _) = self.content(cx.area(), offset);
let slots = self.slots(&self.identity(), offset, content);
let hit = match event {
Event::Mouse(mouse) => match cx.memory::<RailMemory>().card {
Some((RailRow::Tab(index), rect)) if rect.contains(mouse.x, mouse.y) && index < self.tabs.len() => {
Some(Self::card_hit(rect, index, self.model.closable(index), mouse.x, mouse.y))
}
Some((RailRow::Add, rect)) if rect.contains(mouse.x, mouse.y) => None,
_ => self.hit(&slots, mouse.x, mouse.y),
},
_ => None,
};
let open_tab = slots.iter().find(|(index, _)| *index == self.model.active()).map(|(_, rect)| *rect);
self.model.menu_event(cx, event, hit, open_tab)
}
fn scrollbar(&self, cx: &mut EventCx<'_, Msg>, mouse: &MouseEvent, metrics: ScrollMetrics) -> bool {
let area = cx.area();
let row = clamp_u16(mouse.y - area.y);
match mouse.kind {
MouseKind::Down(MouseButton::Left) if metrics.overflows() && mouse.x == area.right() - 1 => {
cx.capture_pointer();
let memory = cx.memory::<RailMemory>();
memory.dragging_scrollbar = true;
memory.offset = metrics.offset_at(row, area.height);
true
}
MouseKind::Drag(MouseButton::Left) if cx.memory::<RailMemory>().dragging_scrollbar => {
cx.memory::<RailMemory>().offset = metrics.offset_at(row, area.height);
true
}
MouseKind::Up(MouseButton::Left) if cx.memory::<RailMemory>().dragging_scrollbar => {
cx.memory::<RailMemory>().dragging_scrollbar = false;
true
}
_ => false,
}
}
}