mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
Merge pull request #3149 from texjer/fix/home-recent-hover
Home: Recent Files rows highlight and click as one; Home screen scrolls
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user