Merge pull request #668 from storytold/fix/663-recent-and-preset-dblclick

Part of #663: Home screen Recent Files; a double-clicked preset creates the document
This commit is contained in:
BFlat
2026-10-09 11:20:12 +07:00
committed by GitHub
4 changed files with 131 additions and 8 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+30
View File
@@ -1723,6 +1723,7 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
app.run("file.open", json!({})).ok();
}
});
recent(app, ui);
ui.add_space(28.0);
ui.label(egui::RichText::new(tl!("Quickly start a new file")).font(theme::semibold(14.0)).color(t.text));
ui.add_space(10.0);
@@ -1743,6 +1744,35 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
crate::community::links(app, ui);
}
/// The Home screen's Recent Files (#663): the first of File › Open Recent Files, each its name and
/// folder; a click opens it. Nothing when there are none.
fn recent(app: &mut VectorcraftApp, ui: &mut Ui) {
const SHOWN: usize = 6;
let files: Vec<String> = crate::io::recent_files(app).iter().take(SHOWN).cloned().collect();
if files.is_empty() {
return;
}
let t = Tokens::get(ui.ctx());
ui.add_space(28.0);
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()
&& let Err(e) = crate::io::open_path(app, &path)
{
app.status(e);
}
});
}
}
fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) {
vectorcraft_geom::kurbo::flatten(p.elements().iter().copied(), tol, f);
}
+57 -7
View File
@@ -190,7 +190,7 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
ui.vertical(|ui| {
ui.set_width(640.0);
ui.set_min_height(HEIGHT - 32.0);
presets(app, ui, &mut d);
b.create |= presets(app, ui, &mut d);
});
});
egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin::same(18)).show(ui, |ui| {
@@ -205,8 +205,9 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
b.finish(app, ctx, d);
}
/// The category tabs and the chosen category's preset cards.
fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
/// The category tabs and the chosen category's preset cards: a click chooses one, a double-click
/// also creates it (true then).
fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let names: Vec<&str> = newdoc::category_names().collect();
let cat = names.iter().position(|n| n.eq_ignore_ascii_case(&d.str("category"))).unwrap_or(0);
@@ -227,20 +228,24 @@ fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
if list.is_empty() {
let hint = if cat == 1 { tl!("Presets you save with the Save Preset button appear here.") } else { tl!("Documents you create appear here.") };
ui.label(egui::RichText::new(hint).color(t.text_dim));
return;
return false;
}
let mut create = false;
egui::ScrollArea::vertical().id_salt(("newdoc-presets", cat)).max_height(HEIGHT - 110.0).auto_shrink([false, false]).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(12.0, 12.0);
let (w, h, preset) = (d.f64("width", 0.0), d.f64("height", 0.0), d.str("preset"));
for s in &list {
let selected = d.fields.contains_key(APPLIED) && s.name == preset && s.width == w && s.height == h;
if preset_card(ui, s, selected).clicked() {
let card = preset_card(ui, s, selected);
if card.clicked() || card.double_clicked() {
apply(d, s);
}
create |= card.double_clicked();
}
});
});
create
}
/// Is `name` a built-in preset's? Those are ours (translated); Recent and Saved hold the names of
@@ -345,7 +350,7 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut
// Close and Create at the bottom right.
ui.add_space((HEIGHT - (ui.cursor().top() - top) - 28.0).max(12.0));
ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 28.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
b.create = widgets::primary_button(ui, tl!("Create")).clicked();
b.create |= widgets::primary_button(ui, tl!("Create")).clicked();
ui.add_space(8.0);
b.close = widgets::secondary_button(ui, tl!("Close")).clicked();
});
@@ -558,7 +563,7 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
ui.horizontal(|ui| {
templates = widgets::secondary_button(ui, tl!("Templates…")).on_hover_text(tl!("New from Template")).clicked();
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
b.create = widgets::primary_button(ui, tl!("Create Document")).clicked();
b.create |= widgets::primary_button(ui, tl!("Create Document")).clicked();
ui.add_space(8.0);
// Cancel goes back to New Document.
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
@@ -610,6 +615,51 @@ mod tests {
app.ui.dialog.as_ref().unwrap().fields.get(k).cloned().unwrap_or_default()
}
/// A double-click on a preset card creates the document from it (#663); a click only chooses it.
#[test]
fn a_double_clicked_preset_creates_the_document() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.newDialog", json!({})).unwrap();
set(&mut app, "category", json!("Mobile"));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
// Frames 50 ms apart, so two clicks a frame apart make a double-click.
let mut time = 0.0;
let mut run = |app: &mut VectorcraftApp, events| {
time += 0.05;
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 1000.0)));
let raw = egui::RawInput { screen_rect, events, time: Some(time), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
out
};
run(&mut app, vec![]);
// The first card of the category, found by its size line.
let card = |app: &mut VectorcraftApp, run: &mut dyn FnMut(&mut VectorcraftApp, Vec<egui::Event>) -> egui::FullOutput| {
let out = run(app, vec![]);
let mut texts = vec![];
fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts));
texts.into_iter().find(|(t, _)| t.starts_with("Phone 393")).map(|(_, r)| r.center()).unwrap()
};
let at = card(&mut app, &mut run);
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
run(&mut app, vec![egui::Event::PointerMoved(at), press(true)]);
run(&mut app, vec![press(false)]);
assert!(app.ui.dialog.is_some() && app.session.documents().is_empty(), "a click only chooses it");
assert!(field(&app, "preset").as_str().is_some_and(|p| p == "Phone 393×852"), "{:?}", field(&app, "preset"));
run(&mut app, vec![press(true)]);
run(&mut app, vec![press(false)]);
assert!(app.ui.dialog.is_none(), "the double-click created it: {:?}", app.ui.status);
assert_eq!(app.session.documents().len(), 1);
}
#[test]
fn a_preset_fills_the_details_and_create_makes_the_document() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+43
View File
@@ -90,3 +90,46 @@ fn cmd_n_opens_the_new_document_dialog() {
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("newDocument"));
assert_eq!(app.session.documents().len(), 1, "no document is made until the dialog's OK");
}
/// One Home screen frame (no document open) with `events`: the texts drawn and where.
fn home_texts(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<(String, egui::Rect)> {
fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, 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 texts = vec![];
out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts));
texts
}
/// The Home screen lists the first recent files (#663), each by name and folder; a click opens it,
/// and one that can't be opened says so. With none, there is no Recent Files section.
#[test]
fn home_lists_recent_files_and_a_click_opens_one() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let has = |texts: &[(String, egui::Rect)], s: &str| texts.iter().any(|(t, _)| t == s);
assert!(!has(&home_texts(&mut app, &ctx, vec![]), "Recent Files"), "none: no section");
let dir = std::env::temp_dir().join("vectorcraft-home-recent");
let missing = dir.join("Gone.svg").to_string_lossy().into_owned();
app.ui.recent_files = (0..9).map(|i| dir.join(format!("Drawing {i}.svg")).to_string_lossy().into_owned()).collect();
app.ui.recent_files.insert(0, missing);
let texts = home_texts(&mut app, &ctx, vec![]);
assert!(has(&texts, "Recent Files") && has(&texts, "Gone.svg") && has(&texts, "Drawing 4.svg"), "{texts:?}");
assert!(!has(&texts, "Drawing 5.svg"), "the first six only");
assert!(has(&texts, &dir.to_string_lossy()), "with its folder");
let at = texts.iter().find(|(t, _)| t == "Gone.svg").map(|(_, r)| r.center()).unwrap();
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
home_texts(&mut app, &ctx, vec![egui::Event::PointerMoved(at), press(true)]);
home_texts(&mut app, &ctx, vec![press(false)]);
assert!(app.session.documents().is_empty());
assert!(!app.ui.status.is_empty(), "a file that can't be opened says so");
}