mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
+1
-1
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user