diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 98ce51408..5eeda2590 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -512,7 +512,9 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { x += w; } crate::document_drag::finish_tabs(ui.ctx(), hovering_art); - // The » button: every open document, the active one checked. + // The » button: every open document, the active one checked. The list scrolls once it + // reaches the bottom of the window, so every document of a large session stays reachable + // (#4017). if overflow { let more = egui::Rect::from_min_max(egui::pos2(strip.right() - MORE, strip.top()), egui::pos2(strip.right(), strip.bottom() - 1.0)); let resp = ui.interact(more, ui.scope_id().with("tabs-more"), Sense::click()); @@ -520,12 +522,14 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { icons::paint(ui, "chevrons-right", egui::Rect::from_center_size(more.center(), vec2(14.0, 14.0)), t.text); let resp = resp.on_hover_text(tl!("Show all open documents")); egui::Popup::menu(&resp).show(|ui| { - for (i, (title, _)) in tabs.iter().enumerate() { - if widgets::menu_item_name(ui, title, true, Some(i) == active) { - activate = Some(i); - ui.close(); + widgets::menu_scroll(ui, |ui| { + for (i, (title, _)) in tabs.iter().enumerate() { + if widgets::menu_item_name(ui, title, true, Some(i) == active) { + activate = Some(i); + ui.close(); + } } - } + }); }); } if let Some(i) = close { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4bc2358e4..1ca37dcf0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -173,6 +173,8 @@ mod tests_sysclip_emf; #[cfg(test)] mod tests_sysclip_probe; #[cfg(test)] +mod tests_tab_overflow; +#[cfg(test)] mod tests_taskbar; #[cfg(test)] mod tests_transparencygrid; diff --git a/crates/ui-egui/src/tests_tab_overflow.rs b/crates/ui-egui/src/tests_tab_overflow.rs new file mode 100644 index 000000000..2ed4f866c --- /dev/null +++ b/crates/ui-egui/src/tests_tab_overflow.rs @@ -0,0 +1,87 @@ +//! The » list of the document tab strip with more documents than fit in the window (#4017). + +use egui::{Event, PointerButton, Pos2}; +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::{VectorcraftApp, chrome}; + +const SIZE: egui::Vec2 = egui::vec2(1000.0, 700.0); + +/// Run a tab-strip frame and return the visible texts with their screen rects. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec) -> Vec<(String, egui::Rect)> { + let mut out = ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, SIZE)), time: Some(time), events, ..Default::default() }, + |ui| { + egui::CentralPanel::default().show(ui, |ui| chrome::doc_tabs(app, ui)); + }, + ); + out.textures_delta.clear(); + let screen = egui::Rect::from_min_size(Pos2::ZERO, SIZE); + // Texts nested in shape lists too, kept only where their clip rect shows them whole. + fn walk(s: &egui::Shape, clip: egui::Rect, v: &mut Vec<(String, egui::Rect)>) { + match s { + egui::Shape::Text(t) => { + let r = egui::Rect::from_min_size(t.pos, t.galley.size()); + if clip.contains_rect(r) { + v.push((t.galley.text().to_owned(), r)); + } + } + egui::Shape::Vec(list) => list.iter().for_each(|s| walk(s, clip, v)), + _ => {} + } + } + let mut texts = vec![]; + for c in &out.shapes { + walk(&c.shape, c.clip_rect.intersect(screen), &mut texts); + } + texts +} + +fn click(at: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() } +} + +/// Every document of a large session can be picked from the » list: it scrolls instead of running +/// off the bottom of the window. +#[test] +fn the_tab_overflow_list_scrolls_to_its_last_document() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + for i in 0..60 { + app.run("file.new", json!({"width": 100, "height": 100, "name": format!("Doc {i:02}")})).unwrap(); + } + app.session.set_active(0); + app.sync_views(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = frame(&mut app, &ctx, 0.0, vec![]); + let tab = texts.iter().find(|(t, _)| t.starts_with("Doc 00")).map(|(_, r)| *r).unwrap(); + // The » button sits at the right end of the strip, level with the tabs. + let more = egui::pos2(SIZE.x - 8.0 - 15.0, tab.center().y); + frame(&mut app, &ctx, 0.1, vec![Event::PointerMoved(more)]); + frame(&mut app, &ctx, 0.15, vec![click(more, true)]); + frame(&mut app, &ctx, 0.2, vec![click(more, false)]); + frame(&mut app, &ctx, 0.25, vec![]); + let texts = frame(&mut app, &ctx, 0.3, vec![]); + // The list's rows put a check column before each title; the tabs don't. + let row = |texts: &[(String, egui::Rect)], name: &str| texts.iter().find(|(t, _)| !t.starts_with("Doc") && t.contains(name)).map(|(_, r)| *r); + let first = row(&texts, "Doc 00").unwrap_or_else(|| panic!("the » list is open: {texts:?}")); + assert!(row(&texts, "Doc 59").is_none(), "the last document starts below the bottom of the window"); + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -20000.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + frame(&mut app, &ctx, 0.35, vec![Event::PointerMoved(first.center()), wheel]); + let mut texts = vec![]; + for i in 4..40 { + texts = frame(&mut app, &ctx, f64::from(i) * 0.1, vec![]); + } + let last = row(&texts, "Doc 59").unwrap_or_else(|| panic!("the wheel scrolls the list to its last document: {texts:?}")).center(); + frame(&mut app, &ctx, 4.0, vec![Event::PointerMoved(last)]); + frame(&mut app, &ctx, 4.1, vec![click(last, true)]); + frame(&mut app, &ctx, 4.2, vec![click(last, false)]); + assert_eq!(app.session.active_index(), Some(59), "the last document is picked from the list"); +}