use eframe::egui;
use nord_usb::ObjectClass;
use crate::browser::{Act, Kind};
use crate::icon::{painted, Glyph};
use crate::panel::{GAP, GLYPH, PAD};
use crate::shell::new_button;
use crate::workspace::Workspace;
pub const SCROLL: &str = "tab_strip";
pub const HEIGHT: f32 = 26.0;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Spot {
Document(u64),
Library,
Keyboard,
}
pub struct Tabs {
open: Vec<Spot>,
active: Option<Spot>,
keyboard: Option<ObjectClass>,
}
impl Default for Tabs {
fn default() -> Tabs {
Tabs {
open: vec![Spot::Library],
active: Some(Spot::Library),
keyboard: None,
}
}
}
impl Tabs {
pub fn open(&mut self, id: u64) {
if !self.holds(id) {
self.open.push(Spot::Document(id));
}
self.active = Some(Spot::Document(id));
}
pub fn show(&mut self, spot: Spot) {
let held = self.open.contains(&spot);
match (held, spot) {
(true, _) => {}
(false, Spot::Keyboard) => self.open.push(Spot::Keyboard),
(false, Spot::Library | Spot::Document(_)) => return,
}
self.active = Some(spot);
}
pub fn keyboard_on(&mut self, class: ObjectClass) {
self.keyboard = Some(class);
}
pub fn keyboard_class(&self) -> Option<ObjectClass> {
self.keyboard
}
pub fn reorder(&mut self, from: usize, to: usize) {
let to = to.max(1);
if from == 0 || from == to || from >= self.open.len() || to >= self.open.len() {
return;
}
let tab = self.open.remove(from);
self.open.insert(to, tab);
}
pub fn close(&mut self, spot: Spot) {
if spot == Spot::Library {
return;
}
self.open.retain(|held| *held != spot);
if self.active == Some(spot) {
self.active = self.open.last().copied();
}
}
pub fn showing(&self) -> Option<Spot> {
self.active
}
pub fn active(&self) -> Option<u64> {
match self.active {
Some(Spot::Document(id)) => Some(id),
_ => None,
}
}
pub fn last_document(&self) -> Option<u64> {
self.active().or_else(|| {
self.open.iter().rev().find_map(|spot| match spot {
Spot::Document(id) => Some(*id),
Spot::Library | Spot::Keyboard => None,
})
})
}
pub fn holds(&self, id: u64) -> bool {
self.open.contains(&Spot::Document(id))
}
pub fn prune(&mut self, workspace: &Workspace) {
self.open.retain(|spot| match spot {
Spot::Document(id) => workspace.entities().iter().any(|e| e.id == *id),
Spot::Library | Spot::Keyboard => true,
});
if self.active.is_some_and(|spot| !self.open.contains(&spot)) {
self.active = self.open.last().copied();
}
}
pub fn ui(&mut self, ui: &mut egui::Ui, workspace: &Workspace, acts: &mut Vec<Act>) {
let rect = egui::Rect::from_min_size(
egui::pos2(ui.max_rect().left(), ui.cursor().top()),
egui::vec2(ui.available_width(), HEIGHT),
);
ui.painter()
.rect_filled(rect, 0.0, ui.visuals().window_fill);
let mut close = None;
let mut activate = None;
let mut dropped = None;
let mut painted: Vec<(usize, egui::Rect)> = Vec::new();
egui::ScrollArea::horizontal()
.id_salt(SCROLL)
.max_height(HEIGHT)
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
ui.horizontal(|ui| {
for (index, spot) in self.open.iter().copied().enumerate() {
let Some(face) = face(spot, workspace) else {
continue;
};
let drawn = paint(ui, &face, self.active == Some(spot));
painted.push((index, drawn.tab.rect));
let mut label = drawn.tab;
if label.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
if let Some(at) = label
.drag_stopped()
.then(|| ui.ctx().pointer_interact_pos())
.flatten()
{
dropped = Some((index, at.x));
}
if let Some(hint) = &face.hint {
label = label.on_hover_text(hint);
}
if label.clicked() {
activate = Some(spot);
}
if drawn.close.is_some_and(|shut| shut.clicked()) {
close = Some(spot);
}
}
let ink = crate::app::caption(ui.visuals());
new_button(ui, Glyph::Plus, ink, acts);
});
});
if let Some(spot) = activate {
self.active = Some(spot);
}
if let Some(spot) = close {
self.close(spot);
}
if let Some((from, x)) = dropped {
if let Some(to) = landing(&painted, x) {
self.reorder(from, to);
}
}
}
}
fn landing(painted: &[(usize, egui::Rect)], x: f32) -> Option<usize> {
let (first, left) = painted.first()?;
let (last, right) = painted.last()?;
if x < left.left() {
return Some(*first);
}
if x > right.right() {
return Some(*last);
}
painted
.iter()
.find(|(_, rect)| rect.x_range().contains(x))
.map(|(index, _)| *index)
}
struct Face {
glyph: Glyph,
name: String,
unsaved: bool,
hint: Option<String>,
shut: bool,
}
fn face(spot: Spot, workspace: &Workspace) -> Option<Face> {
match spot {
Spot::Library => Some(Face {
glyph: Glyph::LibraryBig,
name: "Library".into(),
unsaved: false,
hint: None,
shut: false,
}),
Spot::Keyboard => Some(Face {
glyph: Glyph::Keyboard,
name: "Keyboard".into(),
unsaved: false,
hint: None,
shut: true,
}),
Spot::Document(id) => {
let entity = workspace.get(id)?;
Some(Face {
glyph: Kind::of(entity.entity.as_ref()).glyph(),
name: entity.name.clone(),
unsaved: entity.is_unsaved(),
hint: Some(match workspace.is_view(id) {
true => format!("{} — the instrument's copy, viewed in place", entity.name),
false => entity.name.clone(),
}),
shut: true,
})
}
}
}
struct Drawn {
tab: egui::Response,
close: Option<egui::Response>,
}
const SHUT: f32 = 11.0;
fn paint(ui: &mut egui::Ui, face: &Face, active: bool) -> Drawn {
let visuals = ui.visuals().clone();
let ink = match active {
true => visuals.widgets.active.fg_stroke.color,
false => crate::app::caption(&visuals),
};
let mut text = egui::RichText::new(crate::browser::starred(&face.name, face.unsaved))
.text_style(crate::app::ui());
if face.unsaved {
text = text.italics();
}
let galley = egui::WidgetText::from(text.color(ink)).into_galley(
ui,
Some(egui::TextWrapMode::Extend),
f32::INFINITY,
crate::app::ui(),
);
let closes = match face.shut {
true => GAP + SHUT,
false => 0.0,
};
let width = PAD + GLYPH + GAP + galley.size().x + closes + PAD;
let (rect, tab) =
ui.allocate_exact_size(egui::vec2(width, HEIGHT), egui::Sense::click_and_drag());
let painter = ui.painter().clone();
if active {
painter.rect_filled(rect, 0.0, visuals.panel_fill);
painter.hline(
rect.x_range(),
rect.bottom() - 1.0,
egui::Stroke::new(2.0_f32, crate::app::accent(&visuals)),
);
}
painter.vline(
rect.right() - 0.5,
rect.y_range(),
egui::Stroke::new(1.0_f32, visuals.widgets.noninteractive.bg_stroke.color),
);
let mut x = rect.left() + PAD;
let box_ = |x: f32, size: f32| {
egui::Rect::from_min_size(
egui::pos2(x, rect.center().y - size / 2.0),
egui::vec2(size, size),
)
};
painted(ui, face.glyph, box_(x, GLYPH), ink);
x += GLYPH + GAP;
painter.galley(
egui::pos2(x, rect.center().y - galley.size().y / 2.0),
galley.clone(),
egui::Color32::PLACEHOLDER,
);
x += galley.size().x;
let close = face.shut.then(|| {
let shut = box_(x + GAP, SHUT);
painted(ui, Glyph::X, shut, ink.gamma_multiply(0.5));
ui.interact(shut, tab.id.with("close"), egui::Sense::click())
});
Drawn { tab, close }
}
#[cfg(test)]
pub(crate) fn words(output: &egui::FullOutput) -> Vec<String> {
fn walk(shape: &egui::Shape, into: &mut Vec<String>) {
match shape {
egui::Shape::Text(text) => into.push(text.galley.text().to_string()),
egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| walk(shape, into)),
_ => {}
}
}
let mut said = Vec::new();
for clipped in &output.shapes {
walk(&clipped.shape, &mut said);
}
said
}
#[cfg(test)]
mod tests {
use super::*;
fn workspace() -> Workspace {
Workspace::new(egui::Context::default())
}
#[test]
fn opening_an_asset_that_is_already_open_just_activates_it() {
let mut tabs = Tabs::default();
tabs.open(1);
tabs.open(2);
tabs.open(1);
assert_eq!(tabs.open.len(), 3, "the library, and the two documents");
assert_eq!(tabs.active(), Some(1));
}
#[test]
fn closing_the_active_tab_falls_back_to_another() {
let mut tabs = Tabs::default();
tabs.open(1);
tabs.open(2);
tabs.close(Spot::Document(2));
assert_eq!(tabs.active(), Some(1));
tabs.close(Spot::Document(1));
assert_eq!(tabs.active(), None);
assert_eq!(tabs.showing(), Some(Spot::Library));
}
#[test]
fn closing_the_library_does_nothing_and_leaves_the_strip_standing() {
let mut tabs = Tabs::default();
tabs.open(1);
tabs.show(Spot::Keyboard);
for spot in [
Spot::Library,
Spot::Document(1),
Spot::Keyboard,
Spot::Library,
] {
tabs.close(spot);
}
assert_eq!(tabs.open, vec![Spot::Library]);
assert_eq!(tabs.showing(), Some(Spot::Library));
}
#[test]
fn closing_a_background_tab_leaves_the_front_one_showing() {
let mut tabs = Tabs::default();
tabs.open(1);
tabs.open(2);
tabs.close(Spot::Document(1));
assert_eq!(tabs.active(), Some(2));
}
#[test]
fn a_tab_says_whether_it_is_open_whatever_it_holds() {
let mut tabs = Tabs::default();
assert!(!tabs.holds(1));
tabs.open(1);
tabs.open(2);
assert!(tabs.holds(1) && tabs.holds(2), "both are open");
assert!(!tabs.holds(3));
tabs.close(Spot::Document(2));
assert!(tabs.holds(1) && !tabs.holds(2));
tabs.close(Spot::Document(1));
assert!(!tabs.holds(1));
}
#[test]
fn a_tab_over_an_unsaved_document_wears_a_star() {
let ctx = egui::Context::default();
ctx.all_styles_mut(crate::app::metrics);
let mut ws = Workspace::new(ctx.clone());
let mut log = crate::log::Log::default();
let id = ws
.create(crate::workspace::Fresh::Program, &mut log)
.unwrap();
ws.rename(id, "Africa Split".into());
let mut tabs = Tabs::default();
tabs.open(id);
let strip = |tabs: &mut Tabs, ws: &Workspace| {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(600.0, 300.0),
)),
..Default::default()
};
let output = ctx.run(input, |ctx| {
egui::CentralPanel::default()
.frame(egui::Frame::new())
.show(ctx, |ui| {
tabs.ui(ui, ws, &mut Vec::new());
});
});
words(&output)
};
let said = strip(&mut tabs, &ws);
assert!(said.contains(&"Africa Split".to_string()), "{said:?}");
let bytes = ws.get(id).unwrap().bytes.clone();
let (_, edited) =
crate::fields::apply(&bytes, &[("center_panel.gain".into(), "96".into())]).unwrap();
ws.replace_bytes(id, edited, &mut log);
let said = strip(&mut tabs, &ws);
assert!(said.contains(&"Africa Split*".to_string()), "{said:?}");
}
#[test]
fn the_library_and_the_keyboard_are_each_one_tab() {
let mut tabs = Tabs::default();
tabs.open(1);
tabs.show(Spot::Keyboard);
tabs.show(Spot::Library);
assert_eq!(tabs.open.len(), 3);
assert_eq!(tabs.showing(), Some(Spot::Library));
assert_eq!(tabs.active(), None);
assert_eq!(tabs.last_document(), Some(1));
}
#[test]
fn showing_a_document_that_no_tab_holds_changes_nothing() {
let mut tabs = Tabs::default();
tabs.show(Spot::Library);
tabs.show(Spot::Document(7));
assert_eq!(tabs.showing(), Some(Spot::Library));
assert!(!tabs.holds(7));
}
#[test]
fn reordering_moves_one_tab_and_closes_the_strip_up_behind_it() {
let mut tabs = Tabs::default();
tabs.open(1);
tabs.open(2);
tabs.show(Spot::Keyboard);
let order = |tabs: &Tabs| tabs.open.clone();
tabs.reorder(1, 3);
assert_eq!(
order(&tabs),
vec![
Spot::Library,
Spot::Document(2),
Spot::Keyboard,
Spot::Document(1)
]
);
tabs.reorder(3, 1);
assert_eq!(
order(&tabs),
vec![
Spot::Library,
Spot::Document(1),
Spot::Document(2),
Spot::Keyboard
]
);
tabs.reorder(0, 2);
assert_eq!(
order(&tabs),
vec![
Spot::Library,
Spot::Document(1),
Spot::Document(2),
Spot::Keyboard
],
"the library itself never moves"
);
tabs.reorder(2, 0);
assert_eq!(
order(&tabs),
vec![
Spot::Library,
Spot::Document(2),
Spot::Document(1),
Spot::Keyboard
],
"a tab let go over the library lands after it"
);
assert_eq!(
tabs.showing(),
Some(Spot::Keyboard),
"moving is not showing"
);
}
#[test]
fn reordering_past_the_end_of_the_strip_moves_nothing() {
let mut tabs = Tabs::default();
tabs.open(1);
tabs.open(2);
let before = tabs.open.clone();
for (from, to) in [(0, 0), (0, 2), (5, 1), (9, 9)] {
tabs.reorder(from, to);
}
assert_eq!(tabs.open, before);
}
#[test]
fn a_drop_lands_on_the_tab_under_it_or_on_the_end_it_passed() {
let box_ = |left: f32, right: f32| {
egui::Rect::from_min_max(egui::pos2(left, 0.0), egui::pos2(right, HEIGHT))
};
let painted = [(0, box_(0.0, 60.0)), (1, box_(60.0, 130.0))];
assert_eq!(landing(&painted, 30.0), Some(0));
assert_eq!(landing(&painted, 100.0), Some(1));
assert_eq!(landing(&painted, -40.0), Some(0), "carried off the left");
assert_eq!(landing(&painted, 900.0), Some(1), "carried off the right");
assert_eq!(landing(&[], 30.0), None, "an empty strip takes no drop");
}
#[test]
fn dragging_a_tab_across_its_neighbour_swaps_them() {
let ctx = egui::Context::default();
ctx.all_styles_mut(crate::app::metrics);
let mut ws = Workspace::new(ctx.clone());
let mut log = crate::log::Log::default();
let first = ws
.create(crate::workspace::Fresh::Program, &mut log)
.unwrap();
let second = ws.create(crate::workspace::Fresh::Live, &mut log).unwrap();
ws.rename(
first,
"Africa Split, the one with the long tail".to_string(),
);
let mut tabs = Tabs::default();
tabs.open(first);
tabs.open(second);
let (from, to) = (
egui::pos2(200.0, HEIGHT / 2.0),
egui::pos2(4_000.0, HEIGHT / 2.0),
);
let button = |pos, pressed| egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
};
let frames: [Vec<egui::Event>; 5] = [
vec![egui::Event::PointerMoved(from)],
vec![button(from, true)],
vec![egui::Event::PointerMoved(to)],
vec![button(to, false)],
Vec::new(),
];
for events in frames {
let input = egui::RawInput {
events,
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(600.0, 300.0),
)),
..Default::default()
};
let _ = ctx.run(input, |ctx| {
egui::CentralPanel::default()
.frame(egui::Frame::new())
.show(ctx, |ui| {
tabs.ui(ui, &ws, &mut Vec::new());
});
});
}
assert_eq!(
tabs.open,
vec![Spot::Library, Spot::Document(second), Spot::Document(first)],
"the dragged tab landed past its neighbour"
);
assert_eq!(
tabs.showing(),
Some(Spot::Document(second)),
"a drop is not a click, so what was in front stayed in front"
);
}
#[test]
fn pruning_takes_documents_and_leaves_the_singletons() {
let (mut tabs, mut ws) = (Tabs::default(), workspace());
let mut log = crate::log::Log::default();
let id = ws
.create(crate::workspace::Fresh::Program, &mut log)
.unwrap();
tabs.open(id);
ws.remove(id, &mut log);
tabs.prune(&ws);
assert!(!tabs.holds(id));
assert_eq!(tabs.showing(), Some(Spot::Library));
}
}