mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 22:02:38 +00:00
Merge pull request #831 from YarilaFox/fix/tab-strip-overflow
M7: Keep tab strip usable with many files
This commit is contained in:
2 files changed
+349
-59
No files matched your search
+274
-59
@@ -12,22 +12,93 @@ 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;
|
||||
|
||||
/// Which tabs were open last frame and when the newly opened ones appeared. It is updated in place
|
||||
/// in egui's memory; it only allocates when the set of tabs changes.
|
||||
#[derive(Clone, Default)]
|
||||
struct TabMotion {
|
||||
initialized: bool,
|
||||
known: Vec<u64>,
|
||||
opening: Vec<(u64, f64)>,
|
||||
}
|
||||
|
||||
impl TabMotion {
|
||||
/// Track tabs added since the previous frame and expire their animations.
|
||||
fn begin_frame(&mut self, current: impl Iterator<Item = u64> + Clone, now: f64, reduce_motion: bool) {
|
||||
if !self.known.iter().copied().eq(current.clone()) {
|
||||
if self.initialized && !reduce_motion {
|
||||
for uid in current.clone().filter(|uid| !self.known.contains(uid)) {
|
||||
self.opening.push((uid, now));
|
||||
}
|
||||
}
|
||||
self.known = current.collect();
|
||||
}
|
||||
self.initialized = true;
|
||||
if reduce_motion {
|
||||
self.opening.clear();
|
||||
} else {
|
||||
let known = &self.known;
|
||||
self.opening.retain(|(uid, started)| known.contains(uid) && now - *started < OPEN_TIME);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The entering tab's progress among `opening` (from [`TabMotion`]), or 1 when it is not animating.
|
||||
fn open_progress(opening: &[(u64, f64)], uid: u64, now: f64) -> f32 {
|
||||
opening.iter().find(|(u, _)| *u == uid).map_or(1.0, |(_, started)| ((now - started) / OPEN_TIME).clamp(0.0, 1.0) as f32)
|
||||
}
|
||||
|
||||
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 +120,70 @@ 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 current_uids = document_tabs.iter().filter_map(|&i| app.session.docs.get(i).map(|d| d.uid));
|
||||
let reduce_motion = app.reduce_motion();
|
||||
// Cloning `opening` is free while nothing animates (an empty Vec).
|
||||
let opening = ui.ctx().data_mut(|data| {
|
||||
let motion = data.get_temp_mut_or_default::<TabMotion>(motion_id);
|
||||
motion.begin_frame(current_uids, now, reduce_motion);
|
||||
motion.opening.clone()
|
||||
});
|
||||
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();
|
||||
let name = &d.doc.name;
|
||||
// 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| {
|
||||
@@ -82,49 +195,52 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
let label = match live {
|
||||
Some(true) => trf("{name}, in a live session", &[("name", &name)]),
|
||||
Some(false) => trf("{name}, online, open only to you", &[("name", &name)]),
|
||||
Some(true) => trf("{name}, in a live session", &[("name", name)]),
|
||||
Some(false) => trf("{name}, online, open only to you", &[("name", name)]),
|
||||
None => name.clone(),
|
||||
};
|
||||
let resp = if live.is_some() { resp.on_hover_text(&label) } else { resp };
|
||||
crate::a11y::tab(&resp, &label, active);
|
||||
let progress = open_progress(&opening, 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),
|
||||
&name,
|
||||
&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));
|
||||
}
|
||||
if cresp.clicked() {
|
||||
close = Some(i);
|
||||
}
|
||||
if resp.clicked() && close.is_none() {
|
||||
switch = Some(i);
|
||||
@@ -135,35 +251,66 @@ 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);
|
||||
crate::right::menu_popup(&resp).show(|ui| {
|
||||
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
|
||||
for &i in &document_tabs {
|
||||
let Some(d) = app.session.docs.get(i) else { continue };
|
||||
let on = i == app.session.active && !app.ui.home && app.ui.present.is_none();
|
||||
let label = if d.is_dirty() { format!("{} •", d.doc.name) } else { d.doc.name.clone() };
|
||||
if crate::right::menu_check(ui, on, &label, "") {
|
||||
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 +342,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 +368,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,8 +394,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
||||
app.ui.present_uid = None;
|
||||
}
|
||||
}
|
||||
let _ = Color32::TRANSPARENT;
|
||||
let _ = Align2::LEFT_TOP;
|
||||
if !opening.is_empty() {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -325,3 +490,53 @@ pub fn resize_edges(ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod layout_tests {
|
||||
use super::{MIN_TAB_WIDTH, OPEN_TIME, OVERFLOW_WIDTH, TabMotion, open_progress, tab_drop_position, tab_layout};
|
||||
use egui::{Rect, pos2};
|
||||
|
||||
#[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_unless_motion_is_reduced() {
|
||||
let mut motion = TabMotion::default();
|
||||
motion.begin_frame([1].into_iter(), 0.0, false);
|
||||
assert!(motion.opening.is_empty(), "the tabs open at start-up don't animate");
|
||||
motion.begin_frame([1, 2].into_iter(), 1.0, false);
|
||||
assert_eq!(open_progress(&motion.opening, 2, 1.0), 0.0);
|
||||
assert_eq!(open_progress(&motion.opening, 1, 1.0), 1.0);
|
||||
motion.begin_frame([1, 2].into_iter(), 1.0 + 2.0 * OPEN_TIME, false);
|
||||
assert!(motion.opening.is_empty(), "the animation ends");
|
||||
motion.begin_frame([1, 2, 3].into_iter(), 2.0, true);
|
||||
assert!(motion.opening.is_empty(), "Reduce motion opens tabs at once");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user