diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 807ac7280..265eb9a4c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2851,9 +2851,27 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, l fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { let t = Tokens::get(ui.ctx()); ui.painter().rect_filled(rect, 0.0, t.panel_darker); - let inner = rect.shrink2(vec2((rect.width() - 820.0).max(40.0) / 2.0, 60.0)); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min))); - let ui = &mut child; + // Taller than the window (a small window, many recent files): it scrolls. Widgets scrolled out + // of view are clipped, so they take no pointer. + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min))); + egui::ScrollArea::vertical().id_salt("home-scroll").auto_shrink([false, false]).show(&mut child, |ui| { + let side = (ui.available_width() - 820.0).max(40.0) / 2.0; + let width = (ui.available_width() - 2.0 * side).max(0.0); + ui.add_space(60.0); + ui.horizontal_top(|ui| { + ui.add_space(side); + ui.allocate_ui_with_layout(vec2(width, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_width(width); + home_content(app, ui); + }); + }); + ui.add_space(60.0); + }); +} + +/// The Home screen's column: welcome, New / Open, Recent Files, presets and community links. +fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(tl!("Welcome to VectorCraft")).font(theme::semibold(26.0)).color(t.text)); ui.add_space(4.0); ui.label(egui::RichText::new(tl!("Vector illustration — fast, open, scriptable.")).size(14.0).color(t.text_dim)); @@ -2901,21 +2919,54 @@ fn recent(app: &mut VectorcraftApp, ui: &mut Ui) { ui.label(egui::RichText::new(tl!("Recent Files")).font(theme::semibold(14.0)).color(t.text)); ui.add_space(8.0); for path in files { - let p = std::path::Path::new(&path); - let name = p.file_name().map_or_else(|| path.clone(), |n| n.to_string_lossy().into_owned()); - let folder = p.parent().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(); - ui.horizontal(|ui| { - crate::icons::icon(ui, "history", 16.0, t.icon); - let r = ui.add(egui::Button::new(egui::RichText::new(&name).color(t.accent).size(13.0)).frame(false)).on_hover_text(&path); - ui.add(egui::Label::new(egui::RichText::new(&folder).size(12.0).color(t.text_dim)).truncate()); - if r.clicked() { - // A failure is reported to the user there. - let _ = crate::io::open_reporting(app, &path); - } - }); + if recent_row(ui, &path).clicked() { + // A failure is reported to the user there. + let _ = crate::io::open_reporting(app, &path); + } } } +/// One Recent Files row across the Home screen: history icon, name (accent) and folder (dim, +/// truncated). The whole row is one button that lights up under the pointer; its tooltip is the +/// full path. +fn recent_row(ui: &mut Ui, path: &str) -> egui::Response { + const PAD: f32 = 6.0; + const ICON: f32 = 16.0; + let t = Tokens::get(ui.ctx()); + let p = std::path::Path::new(path); + let name = p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()); + let folder = p.parent().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), egui::Sense::click()); + if ui.is_rect_visible(rect) { + if resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover); + } + let icon = egui::Rect::from_center_size(egui::pos2(rect.left() + PAD + ICON / 2.0, rect.center().y), vec2(ICON, ICON)); + crate::icons::paint(ui, "history", icon, t.icon); + let x = icon.right() + 8.0; + let name_galley = egui::WidgetText::from(egui::RichText::new(&name).size(13.0).color(t.accent)).into_galley( + ui, + Some(egui::TextWrapMode::Truncate), + (rect.right() - PAD - x).max(0.0), + egui::TextStyle::Body, + ); + let folder_x = x + name_galley.size().x + 10.0; + ui.painter().galley(egui::pos2(x, rect.center().y - name_galley.size().y / 2.0), name_galley, t.accent); + let room = rect.right() - PAD - folder_x; + if room > 0.0 && !folder.is_empty() { + let folder_galley = egui::WidgetText::from(egui::RichText::new(&folder).size(12.0).color(t.text_dim)).into_galley( + ui, + Some(egui::TextWrapMode::Truncate), + room, + egui::TextStyle::Body, + ); + ui.painter().galley(egui::pos2(folder_x, rect.center().y - folder_galley.size().y / 2.0), folder_galley, t.text_dim); + } + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); + resp.on_hover_text(path).on_hover_cursor(egui::CursorIcon::PointingHand) +} + fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) { vectorcraft_geom::kurbo::flatten(p.elements().iter().copied(), tol, f); } diff --git a/crates/ui-egui/src/tests_home.rs b/crates/ui-egui/src/tests_home.rs index d998573f2..6e4aa2991 100644 --- a/crates/ui-egui/src/tests_home.rs +++ b/crates/ui-egui/src/tests_home.rs @@ -133,3 +133,111 @@ fn home_lists_recent_files_and_a_click_opens_one() { assert!(app.session.documents().is_empty()); assert!(!app.ui.status.is_empty(), "a file that can't be opened says so"); } + +/// One Home screen frame with `events`: the filled rectangles drawn in `fill`, and the cursor. +fn home_fills(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, fill: egui::Color32) -> (Vec, egui::CursorIcon) { + fn walk(s: &egui::Shape, fill: egui::Color32, out: &mut Vec) { + match s { + egui::Shape::Rect(r) if r.fill == fill => out.push(r.rect), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, fill, out)), + _ => {} + } + } + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 900.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui)); + out.textures_delta.clear(); + let mut rects = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, fill, &mut rects)); + (rects, out.platform_output.cursor_icon) +} + +/// A Recent Files row is one button: the pointer over its folder lights the whole row (icon, name +/// and folder) with a pointing hand, and a click there opens the file like a click on its name. +#[test] +fn a_recent_file_row_lights_up_and_opens_from_anywhere_on_it() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let dir = std::env::temp_dir().join("vectorcraft-home-recent-row"); + app.ui.recent_files = vec![dir.join("Gone.svg").to_string_lossy().into_owned()]; + let texts = home_texts(&mut app, &ctx, vec![]); + let rect_of = |s: &str| texts.iter().find(|(t, _)| t == s).map(|(_, r)| *r).unwrap(); + let (name, folder) = (rect_of("Gone.svg"), rect_of(&dir.to_string_lossy())); + let hover = crate::theme::Tokens::get(&ctx).hover; + let lit = |rects: &[egui::Rect]| rects.iter().any(|r| r.contains(name.center()) && r.contains(folder.center())); + let (rects, cursor) = home_fills(&mut app, &ctx, vec![], hover); + assert!(!lit(&rects) && cursor != egui::CursorIcon::PointingHand, "no highlight without the pointer"); + let at = folder.center(); + home_fills(&mut app, &ctx, vec![egui::Event::PointerMoved(at)], hover); + let (rects, cursor) = home_fills(&mut app, &ctx, vec![], hover); + assert!(lit(&rects), "the pointer over the folder lights the whole row"); + assert_eq!(cursor, egui::CursorIcon::PointingHand); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + home_texts(&mut app, &ctx, vec![press(true)]); + home_texts(&mut app, &ctx, vec![press(false)]); + assert!(app.session.documents().is_empty()); + assert!(!app.ui.status.is_empty(), "the click on the folder tried to open the file"); +} + +/// One Home screen frame in a `w`×`h` window at `time` with `events`: the texts visible (inside +/// their clip rect and the window) and where. +fn visible_home_texts( + app: &mut VectorcraftApp, + ctx: &egui::Context, + (w, h): (f32, f32), + time: f64, + events: Vec, +) -> Vec<(String, egui::Rect)> { + fn walk(s: &egui::Shape, clip: egui::Rect, out: &mut Vec<(String, egui::Rect)>) { + match s { + egui::Shape::Text(t) if clip.contains_rect(t.visual_bounding_rect()) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, clip, out)), + _ => {} + } + } + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, h)); + let raw = egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui)); + out.textures_delta.clear(); + let mut texts = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, c.clip_rect.intersect(screen), &mut texts)); + texts +} + +/// Home scrolls when it is taller than the window: in a short window the Community section and +/// the last preset card are below the fold, the wheel brings them into view, and a card scrolled +/// into view still opens its preset. +#[test] +fn home_scrolls_in_a_short_window() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let dir = std::env::temp_dir().join("vectorcraft-home-scroll"); + app.ui.recent_files = (0..6).map(|i| dir.join(format!("Drawing {i}.svg")).to_string_lossy().into_owned()).collect(); + let size = (800.0, 300.0); + let has = |texts: &[(String, egui::Rect)], s: &str| texts.iter().any(|(t, _)| t == s); + let texts = visible_home_texts(&mut app, &ctx, size, 0.0, vec![]); + assert!(has(&texts, "Welcome to VectorCraft"), "{texts:?}"); + assert!(!has(&texts, "Community"), "below the fold at first"); + let at = egui::pos2(400.0, 150.0); + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -5000.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }; + visible_home_texts(&mut app, &ctx, size, 0.1, vec![egui::Event::PointerMoved(at), wheel]); + let mut texts = vec![]; + for i in 2..40 { + texts = visible_home_texts(&mut app, &ctx, size, f64::from(i) * 0.1, vec![]); + } + assert!(has(&texts, "Community"), "the wheel reaches the bottom: {texts:?}"); + assert!(!has(&texts, "Welcome to VectorCraft"), "the top scrolled away"); + // The last preset card, scrolled into view, still takes a click. + let card = texts.iter().find(|(t, _)| t.starts_with("Social Square Post")).map(|(_, r)| r.center()).unwrap(); + let press = |pressed| egui::Event::PointerButton { pos: card, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + visible_home_texts(&mut app, &ctx, size, 4.0, vec![egui::Event::PointerMoved(card), press(true)]); + visible_home_texts(&mut app, &ctx, size, 4.1, vec![press(false)]); + assert_eq!(app.session.documents().len(), 1, "the card opened its preset"); +}