M7: keep tab strip usable with many files

This commit is contained in:
YarilaFox
2026-10-19 14:23:04 +05:00
parent d2dfe2ef20
commit 2af54a26fb
2 changed files with 408 additions and 53 deletions
+333 -53
View File
@@ -2,6 +2,8 @@
//! the title bar: its empty space drags the window (double-click maximises), and it carries the
//! window buttons; the window's edges resize it.
use std::collections::{HashMap, HashSet};
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
@@ -12,22 +14,151 @@ use crate::theme::{Tokens, regular};
use crate::{FigApp, TitleBar};
pub const HEIGHT: f32 = 40.0;
const MIN_TAB_WIDTH: f32 = 96.0;
const OVERFLOW_WIDTH: f32 = 32.0;
const OPEN_TIME: f64 = 0.18;
const CLOSE_TIME: f64 = 0.24;
#[derive(Clone)]
struct TabSnapshot {
name: String,
rect: Rect,
active: bool,
}
#[derive(Clone)]
struct ClosingTab {
snapshot: TabSnapshot,
started: f64,
}
#[derive(Clone, Default)]
struct TabMotion {
initialized: bool,
known: HashSet<u64>,
opening: HashMap<u64, f64>,
last_visible: HashMap<u64, TabSnapshot>,
closing: Vec<ClosingTab>,
}
impl TabMotion {
/// Track tabs added or removed since the previous frame and expire their animations.
fn begin_frame(&mut self, current: HashSet<u64>, now: f64, reduce_motion: bool) {
if self.initialized && !reduce_motion {
for uid in current.difference(&self.known) {
self.opening.insert(*uid, now);
}
for uid in self.known.difference(&current) {
if let Some(snapshot) = self.last_visible.get(uid) {
self.closing.push(ClosingTab { snapshot: snapshot.clone(), started: now });
}
}
}
self.initialized = true;
self.known = current;
self.last_visible.clear();
if reduce_motion {
self.opening.clear();
self.closing.clear();
} else {
self.opening.retain(|uid, started| self.known.contains(uid) && now - *started < OPEN_TIME);
self.closing.retain(|tab| now - tab.started < CLOSE_TIME);
}
}
/// Return the entering tab's progress, or full width when it is not animating.
fn open_progress(&self, uid: u64, now: f64) -> f32 {
self.opening.get(&uid).map_or(1.0, |started| ((now - started) / OPEN_TIME).clamp(0.0, 1.0) as f32)
}
/// Keep requesting frames while an opening or closing tab remains visible.
fn is_animating(&self) -> bool {
!self.opening.is_empty() || !self.closing.is_empty()
}
}
/// Paint removed tabs as fading snapshots below the strip until their exit motion ends.
fn paint_closing_tabs(ui: &Ui, t: &Tokens, motion: &TabMotion, now: f64) {
if motion.closing.is_empty() {
return;
}
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, ui.id().with("closing_tabs")));
for tab in &motion.closing {
let progress = ((now - tab.started) / CLOSE_TIME).clamp(0.0, 1.0) as f32;
let opacity = (1.0 - progress).powi(2);
let r = tab.snapshot.rect.translate(vec2(0.0, 40.0 * progress * progress));
painter.rect_filled(r, CornerRadius::same(4), if tab.snapshot.active { t.tab_active } else { t.hover }.linear_multiply(opacity));
icons::paint(
&painter,
Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0)),
Icon::Logo,
crate::theme::ACCENT.linear_multiply(opacity),
);
crate::bidi::paint_name(
&painter,
pos2(r.min.x + 32.0, r.center().y),
&tab.snapshot.name,
regular(12.0),
t.text.linear_multiply(opacity),
(r.width() - 64.0).max(0.0),
);
icons::paint(
&painter,
Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(10.0, 10.0)),
Icon::Close,
t.icon_secondary.linear_multiply(opacity),
);
}
}
struct TabLayout {
first: usize,
widths: Vec<f32>,
overflow: bool,
}
/// Shrink open tabs before moving the excess into the overflow menu.
fn tab_layout(preferred: &[f32], available: f32, active: Option<usize>) -> TabLayout {
let count = preferred.len();
let overflow = count as f32 * MIN_TAB_WIDTH > available;
let visible = if overflow { ((available - OVERFLOW_WIDTH).max(0.0) / MIN_TAB_WIDTH).floor() as usize } else { count }.min(count);
let first = active.unwrap_or(0).saturating_sub(visible / 2).min(count - visible);
let mut widths = preferred[first..first + visible].to_vec();
let budget = (available - if overflow { OVERFLOW_WIDTH } else { 0.0 }).max(0.0);
let mut excess = (widths.iter().sum::<f32>() - budget).max(0.0);
while excess > 0.01 {
let flexible = widths.iter().filter(|w| **w > MIN_TAB_WIDTH + 0.01).count();
if flexible == 0 {
break;
}
let share = excess / flexible as f32;
let mut removed = 0.0;
for width in &mut widths {
let shrink = (*width - MIN_TAB_WIDTH).max(0.0).min(share);
*width -= shrink;
removed += shrink;
}
excess -= removed;
}
TabLayout { first, widths, overflow }
}
/// Final document index and insertion marker for a tab dropped among the visible tabs.
fn tab_drop_position(visible: &[(usize, Rect)], from: usize, pointer_x: f32) -> Option<(usize, f32)> {
let others: Vec<_> = visible.iter().copied().filter(|(index, _)| *index != from).collect();
let slot = others.partition_point(|(_, rect)| rect.center().x < pointer_x);
if let Some(&(index, rect)) = others.get(slot) {
Some((index - usize::from(from < index), rect.min.x))
} else {
let &(index, rect) = others.last()?;
Some((index + usize::from(from > index), rect.max.x))
}
}
pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::top("tab_bar").exact_size(HEIGHT).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| {
let rect = ui.max_rect();
// Empty space moves the window (first, so the tabs and buttons on top take their clicks).
if app.ui.title_bar != TitleBar::Native {
let drag = ui.interact(rect, ui.id().with("titlebar_drag"), Sense::click_and_drag());
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if drag.double_clicked() {
let max = ui.input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
}
}
let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(app, ui, rect) } else { 0.0 };
let mut x = rect.min.x + if app.ui.title_bar == TitleBar::Inset { 76.0 } else { 8.0 };
// Home.
@@ -49,28 +180,65 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
}
x += 44.0;
ui.painter().line_segment([pos2(x, rect.min.y), pos2(x, rect.max.y)], Stroke::new(1.0, t.border));
let document_tabs: Vec<usize> = app.session.docs.iter().enumerate().filter_map(|(i, d)| app.shows(d.uid).then_some(i)).collect();
let now = ui.input(|input| input.time);
let motion_id = ui.id().with("tab_motion");
let mut motion = ui.ctx().data_mut(|data| data.get_temp::<TabMotion>(motion_id).unwrap_or_default());
let current_uids = document_tabs.iter().filter_map(|&i| app.session.docs.get(i).map(|d| d.uid)).collect();
motion.begin_frame(current_uids, now, app.reduce_motion());
let preferred: Vec<f32> = document_tabs
.iter()
.map(|&i| {
let d = &app.session.docs[i];
let mark_w = if live_mark(app, d.uid).is_some() { 18.0 } else { 0.0 };
let dot_w = if d.is_dirty() && mark_w > 0.0 { 12.0 } else { 0.0 };
(regular(12.0).size * 0.6 * d.doc.name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0)
})
.collect();
// Leave the new-tab button, theme toggle, window controls and a draggable gap free.
let present_w = if app.ui.present.is_some() || app.ui.paused_present.is_some() { 128.0 } else { 0.0 };
let available = (rect.max.x - buttons_w - 70.0 - x - 34.0 - present_w).max(0.0);
let active_tab = document_tabs.iter().position(|&i| i == app.session.active);
let layout = tab_layout(&preferred, available, active_tab);
let mut tab_rects = Vec::with_capacity(layout.widths.len());
let mut tab_x = x;
for &width in &layout.widths {
tab_rects.push(Rect::from_min_size(pos2(tab_x, rect.min.y), vec2(width, HEIGHT)));
tab_x += width;
}
let mut switch = None;
let mut close = None;
// A tab dragged off the strip, or "Open in new window", moves to a window of its own.
let mut detach = None;
let mut dragging = None;
let mut dropped = None;
let several = !ui.ctx().embed_viewports();
for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| app.shows(d.uid)) {
for (slot, &w) in layout.widths.iter().enumerate() {
let i = document_tabs[layout.first + slot];
let d = &app.session.docs[i];
let active = i == app.session.active && !app.ui.home && app.ui.present.is_none();
let name = d.doc.name.clone();
// In a live session: a mark after the name says whether others get in.
let live = live_mark(app, d.uid);
let mark_w = if live.is_some() { 18.0 } else { 0.0 };
// (A file in a session is rarely saved here: its unsaved dot gets room beside the mark.)
let dirty = d.is_dirty();
let dot_w = if dirty && live.is_some() { 12.0 } else { 0.0 };
let w = (regular(12.0).size * 0.6 * name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0);
let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT));
let resp = ui.interact(r, ui.id().with(("tab", d.uid)), if several { Sense::click_and_drag() } else { Sense::click() });
if resp.drag_stopped()
let r = tab_rects[slot];
let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y));
let close_r = Rect::from_min_max(body_r.right_top(), r.max);
let resp = ui.interact(body_r, ui.id().with(("tab", d.uid)), Sense::click_and_drag());
let cresp = ui.interact(close_r, ui.id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab"));
if (resp.dragged() || resp.drag_stopped())
&& let Some(p) = ui.input(|inp| inp.pointer.latest_pos())
&& (p.y - rect.center().y).abs() > HEIGHT * 2.0
{
detach = Some((i, Some(p)));
if resp.drag_stopped() {
if several && (p.y - rect.center().y).abs() > HEIGHT * 2.0 {
detach = Some((i, Some(p)));
} else if (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) {
dropped = Some((i, p.x));
}
} else {
dragging = Some((i, p));
}
}
if several {
resp.context_menu(|ui| {
@@ -88,43 +256,47 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
};
let resp = if live.is_some() { resp.on_hover_text(&label) } else { resp };
crate::a11y::tab(&resp, &label, active);
let progress = motion.open_progress(d.uid, now);
let paint_r = r.translate(vec2(-28.0 * (1.0 - progress).powi(2), 0.0));
let painter = ui.painter().with_clip_rect(r);
if active {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active);
} else if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
painter.rect_filled(paint_r, CornerRadius::ZERO, t.tab_active.linear_multiply(progress));
} else if resp.hovered() || cresp.hovered() {
painter.rect_filled(paint_r, CornerRadius::ZERO, t.hover.linear_multiply(progress));
}
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0)),
&painter,
Rect::from_center_size(pos2(paint_r.min.x + 18.0, paint_r.center().y), vec2(14.0, 14.0)),
Icon::Logo,
crate::theme::ACCENT,
crate::theme::ACCENT.linear_multiply(progress),
);
// The unsaved-changes dot follows the name on screen, whichever way the name reads.
let shown = crate::bidi::paint_name(
ui.painter(),
pos2(r.min.x + 32.0, r.center().y),
&painter,
pos2(paint_r.min.x + 32.0, paint_r.center().y),
&name,
regular(12.0),
t.text,
w - mark_w - if dirty { 72.0 } else { 60.0 },
t.text.linear_multiply(progress),
(w - mark_w - if dirty { 72.0 } else { 60.0 }).max(0.0),
);
if let Some(shared) = live {
let at = Rect::from_center_size(pos2(r.max.x - 38.0, r.center().y), vec2(14.0, 14.0));
icons::paint(ui.painter(), at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary });
let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0));
icons::paint(&painter, at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }.linear_multiply(progress));
}
if dirty {
ui.painter().text(pos2(shown.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text);
painter.text(pos2(shown.max.x + 4.0, paint_r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text.linear_multiply(progress));
}
let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0));
if resp.hovered() || active {
let cresp = ui.interact(cr, ui.id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab"));
let cr = Rect::from_center_size(close_r.center() + vec2(paint_r.min.x - r.min.x, 0.0), vec2(18.0, 18.0));
// The close target never overlaps the draggable body, including on inactive tabs.
if resp.hovered() || cresp.hovered() || active {
if cresp.hovered() {
ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed);
}
icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary);
if cresp.clicked() {
close = Some(i);
painter.rect_filled(cr, CornerRadius::same(4), t.pressed.linear_multiply(progress));
}
icons::paint(&painter, cr.shrink(4.0), Icon::Close, t.icon_secondary.linear_multiply(progress));
}
motion.last_visible.insert(d.uid, TabSnapshot { name, rect: r, active });
if cresp.clicked() {
close = Some(i);
}
if resp.clicked() && close.is_none() {
switch = Some(i);
@@ -135,35 +307,68 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
x += w;
ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border));
}
let visible: Vec<(usize, Rect)> = tab_rects.iter().copied().enumerate().map(|(slot, r)| (document_tabs[layout.first + slot], r)).collect();
if let Some((from, p)) = dragging
&& (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y)
&& let Some((_, at)) = tab_drop_position(&visible, from, p.x)
{
ui.painter().line_segment([pos2(at, rect.min.y + 5.0), pos2(at, rect.max.y - 5.0)], Stroke::new(3.0, crate::theme::ACCENT));
}
let reorder = dropped.and_then(|(from, at)| tab_drop_position(&visible, from, at).map(|(to, _)| (from, to)));
if layout.overflow {
let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(OVERFLOW_WIDTH, HEIGHT));
let resp = ui.interact(r, ui.id().with("tab_overflow"), Sense::click()).tip(tr("Open files"));
crate::a11y::menu_button(&resp, tr("Open files"), crate::a11y::menu_open(&resp));
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon);
egui::Popup::menu(&resp).style(crate::right::menu_style).show(|ui| {
ui.set_min_width(180.0);
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
for (slot, &i) in document_tabs.iter().enumerate() {
if (layout.first..layout.first + layout.widths.len()).contains(&slot) {
continue;
}
let d = &app.session.docs[i];
if ui.button(&d.doc.name).clicked() {
switch = Some(i);
ui.close();
}
}
});
});
x += OVERFLOW_WIDTH;
}
// Presentations live in their own tab. Switching back to the design keeps the player
// state (current screen, navigation history and scroll position) for the return trip.
if app.ui.present.is_some() || app.ui.paused_present.is_some() {
let w = 128.0;
let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT));
let resp = ui.interact(r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present"));
let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y));
let close_r = Rect::from_min_max(body_r.right_top(), r.max);
let resp = ui.interact(body_r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present"));
let close_resp = ui.interact(close_r, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview"));
let active = app.ui.present.is_some();
crate::a11y::tab(&resp, tr("Present"), active);
if active {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active);
} else if resp.hovered() {
} else if resp.hovered() || close_resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon);
ui.painter().text(pos2(x + 34.0, r.center().y), Align2::LEFT_CENTER, tr("Present"), regular(12.0), t.text);
let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0));
if resp.hovered() || active {
let close_resp = ui.interact(cr, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview"));
let cr = Rect::from_center_size(close_r.center(), vec2(18.0, 18.0));
if resp.hovered() || close_resp.hovered() || active {
if close_resp.hovered() {
ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed);
}
icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary);
if close_resp.clicked() {
app.ui.present = None;
app.ui.paused_present = None;
app.ui.present_uid = None;
} else if resp.clicked() && !active {
resume_presentation(app);
}
}
if close_resp.clicked() {
app.ui.present = None;
app.ui.paused_present = None;
app.ui.present_uid = None;
} else if resp.clicked() && !active {
resume_presentation(app);
}
@@ -195,6 +400,18 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
let cmd = if app.ui.dark { "view.themeLight" } else { "view.themeDark" };
app.run(cmd, json!({}));
}
// Only the free space drags the window. A full-bar drag target steals tab drags.
if app.ui.title_bar != TitleBar::Native && x + 34.0 < theme_r.min.x {
let free = Rect::from_min_max(pos2(x + 34.0, rect.min.y), pos2(theme_r.min.x, rect.max.y));
let drag = ui.interact(free, ui.id().with("titlebar_drag"), Sense::click_and_drag());
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if drag.double_clicked() {
let max = ui.input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
}
}
if let Some(i) = switch {
if let Some(p) = app.ui.present.take() {
app.ui.paused_present = Some(p);
@@ -209,6 +426,11 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
app.toast(e);
}
}
if let Some((from, to)) = reorder
&& from != to
{
app.run("file.moveTab", json!({"from": from, "to": to}));
}
if let Some(i) = close {
// A branch tab's edits live on in its main file: closing it loses nothing.
let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none());
@@ -230,6 +452,11 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
app.ui.present_uid = None;
}
}
paint_closing_tabs(ui, &t, &motion, now);
if motion.is_animating() {
ui.ctx().request_repaint();
}
ui.ctx().data_mut(|data| data.insert_temp(motion_id, motion));
let _ = Color32::TRANSPARENT;
let _ = Align2::LEFT_TOP;
});
@@ -325,3 +552,56 @@ pub fn resize_edges(ctx: &egui::Context) {
}
});
}
#[cfg(test)]
mod layout_tests {
use super::{MIN_TAB_WIDTH, OVERFLOW_WIDTH, TabMotion, TabSnapshot, tab_drop_position, tab_layout};
use egui::{Rect, pos2};
use std::collections::HashSet;
#[test]
fn tabs_shrink_to_fit_before_overflowing() {
let preferred = vec![120.0; 10];
let layout = tab_layout(&preferred, 1100.0, Some(5));
assert!(!layout.overflow);
assert_eq!(layout.first, 0);
assert_eq!(layout.widths.len(), preferred.len());
assert!(layout.widths.iter().all(|&width| (width - 110.0).abs() < 0.01));
}
#[test]
fn excess_tabs_leave_room_for_overflow_and_keep_the_active_tab_visible() {
let preferred = vec![120.0; 24];
let available = 1100.0;
let layout = tab_layout(&preferred, available, Some(23));
assert!(layout.overflow);
assert!(layout.first <= 23 && 23 < layout.first + layout.widths.len());
assert!(layout.widths.iter().all(|&width| width >= MIN_TAB_WIDTH));
assert!(layout.widths.iter().sum::<f32>() + OVERFLOW_WIDTH <= available + 0.01);
}
#[test]
fn drop_position_places_a_tab_between_its_neighbors() {
let visible = (0..4)
.map(|index| (index, Rect::from_min_max(pos2(index as f32 * 120.0, 0.0), pos2((index + 1) as f32 * 120.0, 40.0))))
.collect::<Vec<_>>();
assert_eq!(tab_drop_position(&visible, 0, 330.0).map(|(to, _)| to), Some(2));
assert_eq!(tab_drop_position(&visible, 3, 30.0).map(|(to, _)| to), Some(0));
}
#[test]
fn new_tabs_animate_and_closed_tabs_fall_unless_motion_is_reduced() {
let mut motion = TabMotion::default();
motion.begin_frame(HashSet::from([1]), 0.0, false);
assert!(!motion.is_animating());
let snapshot = TabSnapshot { name: "First".into(), rect: Rect::from_min_max(pos2(0.0, 0.0), pos2(120.0, 40.0)), active: false };
motion.last_visible.insert(1, snapshot.clone());
motion.begin_frame(HashSet::from([1, 2]), 1.0, false);
assert_eq!(motion.open_progress(2, 1.0), 0.0);
motion.last_visible.insert(1, snapshot);
motion.begin_frame(HashSet::from([2]), 1.05, false);
assert_eq!(motion.closing.len(), 1);
motion.begin_frame(HashSet::from([2]), 1.06, true);
assert!(!motion.is_animating());
}
}
+75
View File
@@ -158,6 +158,81 @@ fn tabs_and_new_tab_keep_their_clicks() {
assert!(opened, "the new-tab button took no click");
}
#[test]
fn many_tabs_do_not_cover_the_window_buttons() {
let mut u = Ui::new(TitleBar::Custom);
for _ in 0..24 {
u.app.session.execute("file.new", &json!({})).unwrap();
}
u.frame(vec![]);
let before = u.app.session.docs.len();
u.click(pos2(SIZE.x - 222.0, 20.0));
assert_eq!(u.app.session.docs.len(), before + 1, "the new-tab button must stay reachable");
assert!(u.click(window_button(0)).contains(&ViewportCommand::Minimized(true)));
assert!(u.click(window_button(1)).contains(&ViewportCommand::Maximized(true)));
u.click(window_button(2));
assert!(u.frame(vec![]).contains(&ViewportCommand::Close));
}
#[test]
fn close_button_on_inactive_tab_closes_it_without_switching() {
let mut u = Ui::new(TitleBar::Custom);
u.app.session.execute("file.new", &json!({})).unwrap();
u.app.session.execute("file.new", &json!({})).unwrap();
let active_uid = u.app.session.docs[u.app.session.active].uid;
let inactive_uid = u.app.session.docs[1].uid;
let close_button = pos2(270.0, 20.0);
u.frame(vec![Event::PointerMoved(close_button)]);
u.click(close_button);
assert!(
!u.app.session.docs.iter().any(|doc| doc.uid == inactive_uid),
"docs={}, active={}, last_error={:?}, target_dirty={}",
u.app.session.docs.len(),
u.app.session.active,
u.app.ui.last_error,
u.app.session.docs.iter().find(|doc| doc.uid == inactive_uid).is_some_and(|doc| doc.is_dirty())
);
assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid);
}
#[test]
fn closing_a_tab_before_the_active_one_keeps_the_active_file() {
let mut u = Ui::new(TitleBar::Custom);
for _ in 0..3 {
u.app.session.execute("file.new", &json!({})).unwrap();
}
u.app.session.execute("doc.switch", &json!({"index": 2})).unwrap();
let active_uid = u.app.session.docs[u.app.session.active].uid;
let closed_uid = u.app.session.docs[0].uid;
let close_button = pos2(156.0, 20.0);
u.frame(vec![Event::PointerMoved(close_button)]);
u.click(close_button);
assert!(!u.app.session.docs.iter().any(|doc| doc.uid == closed_uid));
assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid);
}
#[test]
fn dragging_a_tab_along_the_bar_reorders_it_without_detaching() {
let mut u = Ui::new(TitleBar::Custom);
for name in ["Two", "Three", "Four"] {
u.app.session.execute("file.new", &json!({"name": name})).unwrap();
}
let moved_uid = u.app.session.docs[0].uid;
let active_uid = u.app.session.docs[u.app.session.active].uid;
u.frame(vec![]);
let commands = u.drag(pos2(100.0, 20.0), vec2(310.0, 0.0));
assert!(!commands.contains(&ViewportCommand::StartDrag), "{commands:?}");
assert_eq!(
u.app.session.docs[2].uid,
moved_uid,
"order={:?}, active={}, error={:?}",
u.app.session.docs.iter().map(|d| d.uid).collect::<Vec<_>>(),
u.app.session.active,
u.app.ui.last_error
);
assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid);
}
#[test]
fn a_system_title_bar_leaves_the_window_alone() {
let mut u = Ui::new(TitleBar::Native);