Home: Recent Files rows light up and click as one; Home scrolls

Each Recent Files row (history icon, name, folder) is now a single
button that paints the hover token behind it, shows a pointing hand and
keeps the full path as its tooltip; a click anywhere on it opens the
file. Before, only the frameless file name reacted, with no hover
feedback.

The Home screen's column now sits in a vertical scroll area, so a small
window or a long Recent Files list no longer hides the presets and
Community links below the fold. Widgets scrolled out of view are
clipped and take no pointer.
This commit is contained in:
texjer
2026-10-20 02:37:04 +07:00
committed by bflatastic
parent 361ebd29bf
commit 30646c83bd
2 changed files with 174 additions and 15 deletions
+66 -15
View File
@@ -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);
}
+108
View File
@@ -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<egui::Event>, fill: egui::Color32) -> (Vec<egui::Rect>, egui::CursorIcon) {
fn walk(s: &egui::Shape, fill: egui::Color32, out: &mut Vec<egui::Rect>) {
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<egui::Event>,
) -> 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");
}