mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
M7: keep tab strip usable with many files
This commit is contained in:
+333
-53
@@ -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(¤t) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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