use crate::event::{Event, KeyEvent, MouseButton, MouseEvent, MouseKind};
use crate::geometry::Rect;
use crate::keymap::Key;
use crate::style::CellStyle;
use crate::widget::{EventCx, PaintCx, Widget};
use super::IndexMessage;
use super::context_item::{self, ContextItem};
use super::context_menu::{self, ContextMenu};
use super::edge_scroll::{Edge, EdgeScroll, Zone};
type MoveMessage<Msg> = Box<dyn Fn(usize, usize) -> Msg>;
type MenuItems<Msg> = Box<dyn Fn(usize) -> Vec<ContextItem<Msg>>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabEdit {
Close(usize),
Move {
from: usize,
to: usize,
},
}
impl TabEdit {
pub fn apply<T>(self, tabs: &mut Vec<T>, active: &mut usize) {
match self {
Self::Close(index) => {
if index >= tabs.len() {
return;
}
tabs.remove(index);
if index < *active {
*active -= 1;
}
*active = (*active).min(tabs.len().saturating_sub(1));
}
Self::Move { from, to } => {
if from >= tabs.len() || to >= tabs.len() || from == to {
return;
}
let tab = tabs.remove(from);
tabs.insert(to, tab);
*active = if *active == from {
to
} else if from < *active && to >= *active {
*active - 1
} else if from > *active && to <= *active {
*active + 1
} else {
*active
};
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Direction {
Across,
Down,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TabHit {
Tab(usize, Rect),
Close(usize),
}
#[derive(Debug, Clone, Copy)]
struct Pressed {
index: usize,
start: (i32, i32),
pointer: (i32, i32),
grab: (i32, i32),
dragging: bool,
close: bool,
}
#[derive(Debug, Default)]
struct TabPointer {
pressed: Option<Pressed>,
edge: EdgeScroll,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Drag {
pub(crate) index: usize,
pub(crate) pointer: (i32, i32),
pub(crate) grab: (i32, i32),
}
pub(crate) struct TabModel<Msg> {
pub(crate) count: usize,
pub(crate) active: usize,
pub(crate) pinned: Vec<usize>,
pub(crate) on_select: Option<IndexMessage<Msg>>,
pub(crate) on_close: Option<IndexMessage<Msg>>,
pub(crate) on_move: Option<MoveMessage<Msg>>,
pub(crate) on_drag_scroll: Option<IndexMessage<Msg>>,
menu: Option<MenuItems<Msg>>,
}
#[derive(Debug, Default)]
struct MenuTab(usize);
impl<Msg> TabModel<Msg> {
pub(crate) fn new(count: usize) -> Self {
Self {
count,
active: 0,
pinned: Vec::new(),
on_select: None,
on_close: None,
on_move: None,
on_drag_scroll: None,
menu: None,
}
}
pub(crate) fn set_context_menu(&mut self, items: impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static) {
self.menu = Some(Box::new(items));
}
fn menu_for(&self, index: usize) -> Option<(ContextMenu<usize>, Vec<Msg>)> {
let items = self.menu.as_ref()?;
let (items, messages) = context_item::keyed(items(index));
Some((ContextMenu::new(items), messages))
}
pub(crate) fn has_menu(&self) -> bool {
self.menu.is_some()
}
fn open_menu(&self, cx: &mut EventCx<'_, Msg>, index: usize, anchor: Rect, keyboard: bool) {
let Some((menu, _)) = self.menu_for(index) else {
return;
};
cx.memory::<MenuTab>().0 = index;
cx.with_messages(|cx: &mut EventCx<'_, usize>| menu.open(cx, anchor, keyboard));
}
pub(crate) fn menu_event(
&self,
cx: &mut EventCx<'_, Msg>,
event: &Event,
hit: Option<TabHit>,
open_tab: Option<Rect>,
) -> bool {
if self.menu.is_none() || self.count == 0 {
return false;
}
let right_press = match event {
Event::Mouse(mouse) if mouse.kind == MouseKind::Down(MouseButton::Right) => Some((mouse.x, mouse.y)),
_ => None,
};
if context_menu::is_open_in(cx) {
let tab = cx.memory::<MenuTab>().0;
let elsewhere = right_press.is_some_and(|(x, y)| !context_menu::contains(cx, x, y));
if elsewhere || tab >= self.count {
cx.with_messages(|cx: &mut EventCx<'_, usize>| ContextMenu::<usize>::close(cx));
} else if let Some((menu, messages)) = self.menu_for(tab) {
let (used, chosen) = cx.with_messages(|cx| menu.event(cx, event));
if let Some(message) = chosen.last().and_then(|chosen| messages.into_iter().nth(*chosen)) {
cx.emit(message);
}
return used;
}
}
match event {
Event::Mouse(_) => {
let Some((x, y)) = right_press else {
return false;
};
if let Some(TabHit::Tab(index, _) | TabHit::Close(index)) = hit {
self.open_menu(cx, index, Rect::new(x, y, 1, 1), false);
}
true
}
Event::Key(key) if context_menu::is_menu_key(key) => {
let area = cx.area();
let anchor = open_tab.unwrap_or(Rect::new(area.x, area.y, 1, 1));
self.open_menu(cx, self.active(), anchor, true);
true
}
_ => false,
}
}
pub(crate) fn paint_menu(&self, cx: &mut PaintCx<'_>, anchor: Rect) -> bool {
match self.menu_tab(cx).and_then(|tab| self.menu_for(tab)) {
Some((menu, _)) => {
menu.paint_overlay(cx, anchor);
true
}
None => false,
}
}
pub(crate) fn menu_tab(&self, cx: &mut PaintCx<'_>) -> Option<usize> {
if self.menu.is_none() || !context_menu::is_open(cx) {
return None;
}
Some(cx.memory::<MenuTab>().0).filter(|tab| *tab < self.count)
}
pub(crate) fn set_on_close(&mut self, message: impl Fn(usize) -> Msg + 'static) {
self.on_close = Some(Box::new(message));
}
pub(crate) fn set_on_move(&mut self, message: impl Fn(usize, usize) -> Msg + 'static) {
self.on_move = Some(Box::new(message));
}
pub(crate) fn active(&self) -> usize {
self.active.min(self.count.saturating_sub(1))
}
pub(crate) fn closable(&self, index: usize) -> bool {
self.on_close.is_some() && !self.pinned.contains(&index)
}
pub(crate) fn reorderable(&self) -> bool {
self.on_move.is_some()
}
pub(crate) fn open(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
if index >= self.count {
return false;
}
if index != self.active
&& let Some(message) = &self.on_select
{
cx.emit(message(index));
}
true
}
pub(crate) fn close(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
if index >= self.count || !self.closable(index) {
return false;
}
if let Some(message) = &self.on_close {
cx.emit(message(index));
}
true
}
fn move_tab(&self, cx: &mut EventCx<'_, Msg>, from: usize, to: usize) {
if from != to
&& to < self.count
&& let Some(message) = &self.on_move
{
cx.emit(message(from, to));
}
}
pub(crate) fn key(&self, cx: &mut EventCx<'_, Msg>, key: &KeyEvent, direction: Direction) -> bool {
let (back, forward, back_letter, forward_letter) = match direction {
Direction::Across => (Key::Left, Key::Right, 'h', 'l'),
Direction::Down => (Key::Up, Key::Down, 'k', 'j'),
};
let active = self.active();
if key.is_plain(back) || key.is_plain(Key::Char(back_letter)) {
return active > 0 && self.open(cx, active - 1);
}
if key.is_plain(forward) || key.is_plain(Key::Char(forward_letter)) {
return self.open(cx, active + 1);
}
let mods = key.chord.mods;
if self.on_close.is_some() && mods.ctrl && !mods.shift && !mods.alt && key.chord.key == Key::Char('w') {
self.close(cx, active);
return true;
}
if self.reorderable() && mods.ctrl && mods.shift && !mods.alt {
if key.chord.key == back {
if active > 0 {
self.move_tab(cx, active, active - 1);
}
return true;
}
if key.chord.key == forward {
self.move_tab(cx, active, active + 1);
return true;
}
}
false
}
pub(crate) fn pointer(
&self,
cx: &mut EventCx<'_, Msg>,
mouse: &MouseEvent,
hit: Option<TabHit>,
slots: &[(usize, Rect)],
direction: Direction,
) -> bool {
let at = (mouse.x, mouse.y);
match mouse.kind {
MouseKind::Down(MouseButton::Middle) => match hit {
Some(TabHit::Tab(index, _) | TabHit::Close(index)) if self.on_close.is_some() => {
self.close(cx, index);
true
}
_ => false,
},
MouseKind::Down(MouseButton::Left) => match hit {
Some(TabHit::Close(index)) => {
cx.capture_pointer();
let press = Pressed { index, start: at, pointer: at, grab: (0, 0), dragging: false, close: true };
*cx.memory::<TabPointer>() = TabPointer { pressed: Some(press), edge: EdgeScroll::default() };
true
}
Some(TabHit::Tab(index, rect)) => {
self.open(cx, index);
if self.reorderable() {
cx.capture_pointer();
let grab = (at.0 - rect.x, at.1 - rect.y);
let press = Pressed { index, start: at, pointer: at, grab, dragging: false, close: false };
*cx.memory::<TabPointer>() = TabPointer { pressed: Some(press), edge: EdgeScroll::default() };
}
true
}
None => false,
},
MouseKind::Drag(MouseButton::Left) => {
let memory = cx.memory::<TabPointer>();
let Some(press) = &mut memory.pressed else {
return false;
};
press.pointer = at;
let travel = match direction {
Direction::Across => (at.0 - press.start.0).abs() >= 2,
Direction::Down => (at.1 - press.start.1).abs() >= 1,
};
if !press.close && travel {
press.dragging = true;
}
true
}
MouseKind::Up(MouseButton::Left) => {
let memory = cx.memory::<TabPointer>();
memory.edge = EdgeScroll::default();
let Some(press) = memory.pressed.take() else {
return false;
};
if press.close {
if hit == Some(TabHit::Close(press.index)) {
self.close(cx, press.index);
}
} else if press.dragging {
let to = drop_target(slots, press.index, at, direction);
self.move_tab(cx, press.index, to);
}
true
}
_ => false,
}
}
pub(crate) fn edge_scroll(
&self,
cx: &mut EventCx<'_, Msg>,
zone: Option<Zone>,
step: impl FnOnce(&mut EventCx<'_, Msg>, Edge) -> Option<usize>,
) {
let memory = cx.memory::<TabPointer>();
let dragging = memory.pressed.is_some_and(|press| press.dragging);
let mut edge = memory.edge;
let first = edge.drive(cx, zone.filter(|_| dragging), step);
cx.memory::<TabPointer>().edge = edge;
if let (Some(first), Some(message)) = (first, &self.on_drag_scroll) {
cx.emit(message(first));
}
}
pub(crate) fn drag(&self, cx: &mut PaintCx<'_>) -> Option<Drag> {
let press = cx.memory::<TabPointer>().pressed?;
(press.dragging && press.index < self.count).then_some(Drag {
index: press.index,
pointer: press.pointer,
grab: press.grab,
})
}
}
pub(crate) fn paint_drop_slot(cx: &mut PaintCx<'_>, rect: Rect) {
let drop = cx.style("tab-drop", None, &[]).text();
cx.clear(rect, drop.bg.unwrap_or_else(|| cx.color("raised")));
}
pub(crate) fn paint_ghost_surface(cx: &mut PaintCx<'_>, rect: Rect) -> CellStyle {
let ghost = cx.style("tab-ghost", None, &[]).text();
cx.clear(rect, ghost.bg.unwrap_or_else(|| cx.color("active")));
ghost
}
pub(crate) fn drop_target(slots: &[(usize, Rect)], from: usize, pointer: (i32, i32), direction: Direction) -> usize {
let along = |rect: &Rect| match direction {
Direction::Across => rect.x,
Direction::Down => rect.y,
};
let position = match direction {
Direction::Across => pointer.0,
Direction::Down => pointer.1,
};
let Some((first, _)) = slots.first() else {
return from;
};
slots.iter().take_while(|(_, rect)| along(rect) <= position).last().map_or(*first, |(index, _)| *index)
}
pub(crate) fn preview_order(count: usize, drag: Option<(usize, usize)>) -> Vec<usize> {
let mut order: Vec<usize> = (0..count).collect();
if let Some((from, to)) = drag
&& from < count
&& to < count
{
let tab = order.remove(from);
order.insert(to, tab);
}
order
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edits_keep_the_open_tab() {
let mut tabs = vec!["a", "b", "c", "d"];
let mut active = 2;
TabEdit::Close(0).apply(&mut tabs, &mut active);
assert_eq!((tabs.as_slice(), active), (&["b", "c", "d"][..], 1));
TabEdit::Close(1).apply(&mut tabs, &mut active);
assert_eq!((tabs.as_slice(), active), (&["b", "d"][..], 1));
TabEdit::Close(1).apply(&mut tabs, &mut active);
assert_eq!((tabs.as_slice(), active), (&["b"][..], 0));
let mut tabs = vec!["a", "b", "c", "d"];
let mut active = 1;
TabEdit::Move { from: 1, to: 3 }.apply(&mut tabs, &mut active);
assert_eq!((tabs.as_slice(), active), (&["a", "c", "d", "b"][..], 3));
TabEdit::Move { from: 0, to: 3 }.apply(&mut tabs, &mut active);
assert_eq!((tabs.as_slice(), active), (&["c", "d", "b", "a"][..], 2));
TabEdit::Move { from: 3, to: 0 }.apply(&mut tabs, &mut active);
assert_eq!((tabs.as_slice(), active), (&["a", "c", "d", "b"][..], 3));
TabEdit::Move { from: 9, to: 0 }.apply(&mut tabs, &mut active);
assert_eq!(active, 3);
}
#[test]
fn drop_target_is_the_resting_tab_under_the_pointer() {
let slots = [(2, Rect::new(4, 0, 6, 1)), (3, Rect::new(11, 0, 9, 1)), (4, Rect::new(21, 0, 5, 1))];
assert_eq!(drop_target(&slots, 3, (0, 0), Direction::Across), 2);
assert_eq!(drop_target(&slots, 2, (12, 0), Direction::Across), 3);
assert_eq!(drop_target(&slots, 2, (40, 0), Direction::Across), 4);
assert_eq!(preview_order(4, Some((0, 2))), vec![1, 2, 0, 3]);
}
}