diff --git a/ROADMAP.md b/ROADMAP.md index 90b72832..82b6c4c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -315,6 +315,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-16 | minor | The full presenter now scrolls content beyond a responsive phone or tablet screen in both directions. A drag that starts inside the screen continues when the pointer leaves it; the device body stays fixed. Computer presets keep their ordinary scrolling rules. The unsaved-changes dialog appears over presentation when closing a window. The saved design is unchanged. Numbers unchanged. | diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index 824defd4..5fc098a4 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -942,10 +942,11 @@ pub fn menu_item(resp: &Response, label: &str, trail: &str, checked: Option Self { + let app = FigApp::new(Session::new(), Box::new(NoServices)); + let mut u = Self { ctx: egui::Context::default(), app, time: 0.0 }; + for _ in 0..3 { + u.frame(vec![]); + } + u + } + + fn frame(&mut self, events: Vec) { + self.time += 1.0 / 60.0; + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SIZE)), time: Some(self.time), events, ..Default::default() }; + let app = &mut self.app; + let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + } + + fn hover(&mut self, p: Pos2) { + self.frame(vec![Event::PointerMoved(p)]); + } + + /// Hover `p` and stay there until a submenu could open. + fn settle(&mut self, p: Pos2) { + self.hover(p); + self.wait(HOVER_DELAY + 0.05); + } + + /// Frames without input for `secs`. + fn wait(&mut self, secs: f64) { + let until = self.time + secs; + while self.time < until { + self.frame(vec![]); + } + } + + fn open_main_menu(&mut self) { + self.app.ui.main_menu = true; + // Somewhere off the menu: nothing hovered. + self.hover(pos2(900.0, 700.0)); + self.frame(vec![]); + } + + fn shown(&self, area: Id) -> bool { + self.ctx.memory(|m| m.areas().visible_layer_ids().contains(&LayerId::new(Order::Foreground, area))) + } + + fn rect(&self, area: Id) -> Rect { + self.ctx.memory(|m| m.area_rect(area)).expect("area drawn") + } + + /// The main menu's row for top-level menu `i` (File 0, Edit 1, View 2, …). + fn top_row(&self, i: usize) -> Rect { + let m = self.rect(main_menu_id()); + // Frame margin, the Actions row and the space under it. + let y = m.min.y + 8.0 + 30.0 + 6.0 + ROW * i as f32; + Rect::from_min_max(pos2(m.min.x + 8.0, y), pos2(m.max.x - 8.0, y + ROW)) + } + + /// Which top-level menus' panels show. + fn tops_shown(&self) -> Vec<&'static str> { + TOPS.iter().copied().filter(|t| self.shown(top(t))).collect() + } +} + +const TOPS: [&str; 6] = ["File", "Edit", "View", "Object", "Text", "Arrange"]; + +fn top(name: &str) -> Id { + Id::new(("menu", name)) +} + +/// The submenus of top-level menu `menu` (their keys), from the menu catalog. +fn submenu_keys(menu: &str) -> Vec { + let mut keys: Vec = figcraft_engine::catalog::MENU + .iter() + .filter_map(|m| m.menu.strip_prefix(menu).and_then(|r| r.strip_prefix('/')).map(|r| format!("{menu}/{}", r.split('/').next().unwrap_or(r)))) + .collect(); + keys.extend(figcraft_engine::catalog::MENU.iter().filter(|m| m.menu == menu && m.id == "file.openRecent").map(|m| format!("{menu}/{}", m.label))); + keys.dedup(); + keys +} + +#[test] +fn the_triangle_takes_the_pointer_from_the_row_toward_the_panel() { + let panel = Rect::from_min_max(pos2(300.0, 50.0), pos2(500.0, 650.0)); + let from = pos2(250.0, 100.0); + assert!(in_triangle(pos2(1.0, 1.0), pos2(0.0, 0.0), pos2(4.0, 0.0), pos2(0.0, 4.0))); + assert!(!in_triangle(pos2(3.0, 3.0), pos2(0.0, 0.0), pos2(4.0, 0.0), pos2(0.0, 4.0))); + // Diagonally down toward the panel: heading for it. + assert!(heading_for(from, pos2(260.0, 130.0), panel)); + // Straight down, straight up, back left, or far below the panel's reach: not. + assert!(!heading_for(from, pos2(250.0, 130.0), panel)); + assert!(!heading_for(from, pos2(250.0, 80.0), panel)); + assert!(!heading_for(from, pos2(240.0, 105.0), panel)); + assert!(!heading_for(from, pos2(260.0, 400.0), Rect::from_min_max(pos2(300.0, 50.0), pos2(500.0, 120.0)))); + // A panel on the left works the same way, mirrored. + let left = Rect::from_min_max(pos2(0.0, 50.0), pos2(200.0, 650.0)); + assert!(heading_for(from, pos2(240.0, 130.0), left)); + assert!(!heading_for(from, pos2(260.0, 130.0), left)); + // Junk positions never count. + assert!(!heading_for(pos2(f32::NAN, 0.0), from, panel)); +} + +#[test] +fn a_reopened_main_menu_shows_no_stale_submenu() { + let mut u = Ui::new(); + u.open_main_menu(); + assert!(u.tops_shown().is_empty(), "nothing open before hovering"); + u.settle(u.top_row(0).center()); + assert_eq!(u.tops_shown(), ["File"]); + // Escape closes it; it reopens without File open. + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); + u.frame(vec![]); + assert!(!u.app.ui.main_menu); + u.open_main_menu(); + assert!(u.tops_shown().is_empty(), "reopened with {:?} open", u.tops_shown()); + // Closed from outside (the menu button toggling it) it forgets too. + u.settle(u.top_row(1).center()); + assert_eq!(u.tops_shown(), ["Edit"]); + u.app.ui.main_menu = false; + u.frame(vec![]); + u.open_main_menu(); + assert!(u.tops_shown().is_empty(), "reopened with {:?} open", u.tops_shown()); +} + +#[test] +fn a_submenu_one_level_down_reopens_closed() { + let mut u = Ui::new(); + u.open_main_menu(); + u.settle(u.top_row(0).center()); + let keys = submenu_keys("File"); + assert!(!keys.is_empty(), "File has submenus"); + let sub_shown = |u: &Ui| keys.iter().find(|k| u.shown(Id::new(("submenu", k.as_str())))).cloned(); + // Down the middle of File's panel until one of its submenus opens. + let panel = u.rect(top("File")); + let mut y = panel.min.y + 10.0; + while sub_shown(&u).is_none() && y < panel.max.y { + u.settle(pos2(panel.center().x, y)); + y += 7.0; + } + let Some(open) = sub_shown(&u) else { panic!("no File submenu opened (looked for {keys:?})") }; + // Over to Edit and back to File: File shows, without its submenu. + u.hover(u.top_row(1).center()); + u.wait(AIM_REST + 0.1); + assert_eq!(u.tops_shown(), ["Edit"]); + u.hover(u.top_row(0).center()); + u.wait(AIM_REST + 0.1); + assert_eq!(u.tops_shown(), ["File"]); + assert_eq!(sub_shown(&u), None, "{open} came back open"); +} + +#[test] +fn crossing_rows_toward_the_open_submenu_keeps_it_open() { + let mut u = Ui::new(); + u.open_main_menu(); + let file = u.top_row(0); + let start = pos2(file.max.x - 30.0, file.center().y); + u.settle(start); + assert_eq!(u.tops_shown(), ["File"]); + // Diagonally across Edit and View into File's panel, a few rows down. + let panel = u.rect(top("File")); + let end = pos2(panel.min.x + 20.0, u.top_row(3).center().y); + for k in 1..=8 { + let p = start + (end - start) * (k as f32 / 8.0); + u.hover(p); + assert_eq!(u.tops_shown(), ["File"], "at {p:?}"); + } + assert!(panel.contains(end)); + u.wait(AIM_REST + 0.2); + assert_eq!(u.tops_shown(), ["File"]); +} + +#[test] +fn resting_on_a_crossed_row_hands_over_to_it() { + let mut u = Ui::new(); + u.open_main_menu(); + let file = u.top_row(0); + let start = pos2(file.max.x - 30.0, file.center().y); + u.settle(start); + // Toward the panel, stopping on View. + let view = u.top_row(2); + let stop = pos2(view.max.x - 4.0, view.center().y); + for k in 1..=4 { + u.hover(start + (stop - start) * (k as f32 / 4.0)); + } + assert_eq!(u.tops_shown(), ["File"], "still on the way"); + u.wait(AIM_REST + 0.1); + assert_eq!(u.tops_shown(), ["View"]); +} + +#[test] +fn moving_straight_down_switches_after_a_short_hover() { + let mut u = Ui::new(); + u.open_main_menu(); + let file = u.top_row(0); + u.settle(file.center()); + assert_eq!(u.tops_shown(), ["File"]); + // Down to Edit in small steps: no progress toward File's panel, so no triangle. + let edit = u.top_row(1).center(); + let mut p = file.center(); + while p.y < edit.y { + p.y = (p.y + 4.0).min(edit.y); + u.hover(p); + } + assert!(u.tops_shown().is_empty(), "File closes at once, Edit waits: {:?}", u.tops_shown()); + u.wait(HOVER_DELAY + 0.05); + assert_eq!(u.tops_shown(), ["Edit"]); +} + +#[test] +fn the_first_submenu_opens_after_a_short_hover() { + let mut u = Ui::new(); + u.open_main_menu(); + u.hover(u.top_row(0).center()); + u.frame(vec![]); + assert!(u.tops_shown().is_empty(), "opened at once"); + u.wait(HOVER_DELAY + 0.05); + assert_eq!(u.tops_shown(), ["File"]); +} + +#[test] +fn sweeping_across_rows_opens_only_where_the_pointer_stops() { + let mut u = Ui::new(); + u.open_main_menu(); + u.settle(u.top_row(0).center()); + // Quickly down the left of the rows, a row a frame, to Arrange. + for i in 1..=5 { + let r = u.top_row(i); + u.hover(pos2(r.min.x + 20.0, r.center().y)); + // (A panel drawn above the row the pointer is on goes the next frame.) + u.frame(vec![]); + assert!(u.tops_shown().is_empty(), "{:?} open on row {i}", u.tops_shown()); + } + u.wait(HOVER_DELAY + 0.05); + assert_eq!(u.tops_shown(), ["Arrange"]); +} + +#[test] +fn hovering_a_plain_row_closes_the_open_submenu_at_once() { + let mut u = Ui::new(); + u.open_main_menu(); + u.settle(u.top_row(0).center()); + assert_eq!(u.tops_shown(), ["File"]); + // Up onto the Actions row. + let m = u.rect(main_menu_id()); + u.hover(pos2(u.top_row(0).center().x, m.min.y + 8.0 + 15.0)); + assert!(u.tops_shown().is_empty(), "{:?} still open", u.tops_shown()); +} + +#[test] +fn leaving_the_menu_keeps_the_last_submenu() { + let mut u = Ui::new(); + u.open_main_menu(); + u.settle(u.top_row(1).center()); + // Off the menus onto the canvas. + u.hover(pos2(900.0, 700.0)); + u.wait(AIM_REST + 0.1); + assert_eq!(u.tops_shown(), ["Edit"]); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2a8a4e00..f7119470 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -284,7 +284,7 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool { subs.push(sub.clone()); last_sep = false; let full = format!("{prefix}/{sub}"); - if submenu(ui, prefix, &full, tr(&sub), &t, |ui| menu_level(app, ui, &full).then_some(())).is_some() { + if submenu(ui, &full, tr(&sub), &t, |ui| menu_level(app, ui, &full).then_some(())).is_some() { return true; } } @@ -300,7 +300,7 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool { /// File ▸ Open recent: the recent files (greyed when the file is gone) and Clear recent. `key` is /// the menu's English label, `label` the one shown. Returns whether an item ran. fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &str, t: &Tokens) -> bool { - let picked = submenu(ui, prefix, &format!("{prefix}/{key}"), label, t, |ui| { + let picked = submenu(ui, &format!("{prefix}/{key}"), label, t, |ui| { let recents = &app.session.prefs.recent_files; let mut picked = None; for p in recents.iter() { @@ -327,20 +327,285 @@ fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &s true } -/// A submenu row `label` in the menu at `prefix`; while it's open (hovered last), `add` draws its -/// items beside it. `key` is unique among the menu's submenus. Returns what `add` returned. -pub(crate) fn submenu(ui: &mut Ui, prefix: &str, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option) -> Option { - let resp = submenu_item(ui, label, t); - let slot = Id::new(("submenu_open", prefix)); - if resp.hovered() || resp.has_focus() { - ui.data_mut(|d| d.insert_temp(slot, key.to_string())); +/// A submenu row `label` in the menu being drawn; while it's open, `add` draws its items beside +/// it. `key` names the submenu (its menu path, unique in its menu tree). Returns what `add` +/// returned. +pub(crate) fn submenu(ui: &mut Ui, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option) -> Option { + submenu_at(ui, Id::new(("submenu", key)), key, label, 4.0, false, t, add) +} + +// --------------------------------------------------------------------------------------------- +// Which submenu is open: hover intent with a safe triangle. +// +// Every menu tree (the main menu, the context menu) keeps, per level, which of its submenus is +// open. Hovering another row closes the open submenu at once; resting on a submenu row for +// `HOVER_DELAY` opens it (sweeping across rows doesn't flash every submenu on the way). Except while the +// pointer is on its way from the open submenu's row to the submenu: while each move lands inside +// the triangle between where it was and the submenu's near edge (it keeps getting nearer), rows +// it crosses don't take over and the submenu's row stays highlighted. Resting (`AIM_REST`) or +// turning away ends that, and the row under the pointer takes over. Leaving the menus keeps the +// last submenu open. +// Closing a menu tree forgets all of it, so it reopens with no submenu open. + +/// How long the pointer stays on a submenu row before its submenu opens. +pub(crate) const HOVER_DELAY: f64 = 0.15; +/// How long the pointer may rest inside the safe triangle before the row under it takes over. +pub(crate) const AIM_REST: f64 = 0.3; +/// A pointer that moved less than this is resting. +const AIM_STILL: f32 = 1.0; +/// The triangle reaches this far above and below the submenu. +const AIM_EDGE_SLOP: f32 = 8.0; + +/// The hover state of one menu level. +#[derive(Clone, Default, Debug)] +struct Level { + /// The open submenu's key. + open: Option, + /// The open submenu's row and where its panel was drawn (last frame). + row: Option, + panel: Option, + /// The pointer's last position counted as heading for the panel, and when it got there. + apex: Option, + moved_at: f64, + /// This pass's verdict: the pointer is aiming at the panel. + pass: Option, + aiming: bool, + /// The row the pointer is on, waiting to take over (a submenu's key, or `None`: a plain row), + /// and since when; whether a row asked this pass (unasked, it lapses). + pending: Option<(Option, f64)>, + asked: bool, + /// When the pointer came to rest in the triangle, if that's what ended aiming this pass. + rested_at: Option, +} + +/// One menu tree's levels, by menu path ("" for the top). +#[derive(Clone, Default, Debug)] +struct Tree { + levels: std::collections::HashMap, +} + +/// The menu level a menu frame's ui draws: its tree's root and its path. +#[derive(Clone, Debug)] +struct At { + root: Id, + path: String, +} + +fn at_key(ui: &Ui) -> Id { + ui.id().with("menu_level") +} + +fn level_at(ui: &Ui) -> At { + ui.data(|d| d.get_temp::(at_key(ui))).unwrap_or(At { root: Id::new("menus"), path: String::new() }) +} + +fn with_level(ctx: &egui::Context, at: &At, f: impl FnOnce(&mut Level) -> R) -> R { + ctx.data_mut(|d| f(d.get_temp_mut_or_default::(at.root).levels.entry(at.path.clone()).or_default())) +} + +/// Forget everything a menu tree remembers (it closed or opened anew). +pub(crate) fn forget_tree(ctx: &egui::Context, root: Id) { + ctx.data_mut(|d| d.remove::(root)); +} + +/// Open (`Some(key)`) or close (`None`) a submenu of `root`'s level `path`; the levels below the +/// one it replaces are forgotten (they reopen closed). +fn set_open(ctx: &egui::Context, root: Id, path: &str, key: Option<&str>) { + ctx.data_mut(|d| { + let tree = d.get_temp_mut_or_default::(root); + let level = tree.levels.entry(path.to_string()).or_default(); + if level.open.as_deref() == key { + return; + } + let old = std::mem::replace(&mut level.open, key.map(str::to_string)); + level.row = None; + level.panel = None; + level.apex = None; + level.pending = None; + if let Some(old) = old { + let below = format!("{old}/"); + tree.levels.retain(|p, _| *p != old && !p.starts_with(&below)); + } + }); +} + +/// Whether `p` is in the triangle `a`, `b`, `c` (either winding, edges included). +pub(crate) fn in_triangle(p: egui::Pos2, a: egui::Pos2, b: egui::Pos2, c: egui::Pos2) -> bool { + let side = |u: egui::Pos2, v: egui::Pos2| (v.x - u.x) * (p.y - u.y) - (v.y - u.y) * (p.x - u.x); + let (d1, d2, d3) = (side(a, b), side(b, c), side(c, a)); + let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0; + let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0; + !(neg && pos) +} + +/// Whether a pointer that moved from `from` to `p` is heading for `panel`: `p` is inside the +/// triangle from `from` to the panel's near edge, so it got nearer the panel without leaving the +/// rows' way to it. Straight up or down (or away) isn't. +pub(crate) fn heading_for(from: egui::Pos2, p: egui::Pos2, panel: Rect) -> bool { + if ![from.x, from.y, p.x, p.y].iter().all(|v| v.is_finite()) { + return false; } - let open: Option = ui.data(|d| d.get_temp(slot)); - if open.as_deref() != Some(key) { + let edge = if panel.min.x >= from.x { + panel.min.x + } else if panel.max.x <= from.x { + panel.max.x + } else { + return false; + }; + in_triangle(p, from, pos2(edge, panel.min.y - AIM_EDGE_SLOP), pos2(edge, panel.max.y + AIM_EDGE_SLOP)) +} + +/// Whether the pointer is on its way to this level's open submenu (rows it crosses don't take +/// over). Worked out once per pass per level. +fn aiming(ui: &Ui) -> bool { + let at = level_at(ui); + let ctx = ui.ctx(); + let pass = ctx.cumulative_pass_nr(); + let (p, now) = ctx.input(|i| (i.pointer.hover_pos(), i.time)); + let (aiming, wait) = with_level(ctx, &at, |l| { + if l.pass == Some(pass) { + return (l.aiming, None); + } + l.pass = Some(pass); + l.aiming = false; + l.rested_at = None; + if !l.asked { + l.pending = None; + } + l.asked = false; + let (Some(p), Some(_), Some(panel)) = (p, l.open.as_ref(), l.panel) else { + l.apex = None; + return (false, None); + }; + if panel.contains(p) { + l.apex = None; + return (false, None); + } + // On the row (its bottom edge is the next row's). + if l.row.is_some_and(|r| (r.min.x..r.max.x).contains(&p.x) && (r.min.y..r.max.y).contains(&p.y)) { + l.apex = Some(p); + l.moved_at = now; + return (false, None); + } + let Some(from) = l.apex else { return (false, None) }; + if (p - from).length() <= AIM_STILL { + // Resting: the submenu holds a moment longer. + if !(0.0..AIM_REST).contains(&(now - l.moved_at)) { + l.apex = None; + // It has rested long enough: the row under it needn't wait again. + l.rested_at = Some(l.moved_at); + return (false, None); + } + } else if heading_for(from, p, panel) { + l.apex = Some(p); + l.moved_at = now; + } else { + l.apex = None; + return (false, None); + } + l.aiming = true; + (true, Some(AIM_REST - (now - l.moved_at))) + }); + // Wake up when the rest runs out, so the row under a still pointer takes over. + if let Some(w) = wait.filter(|w| w.is_finite() && *w > 0.0) { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(w)); + } + aiming +} + +/// The pointer is on a row of level `at` (`target`: the row's submenu, or `None` for a plain +/// row): another open submenu closes at once, and `target` opens once the pointer has stayed +/// there `HOVER_DELAY`. +fn hover_request(ctx: &egui::Context, at: &At, target: Option<&str>) { + let now = ctx.input(|i| i.time); + if with_level(ctx, at, |l| l.open.is_some() && l.open.as_deref() != target) { + set_open(ctx, at.root, &at.path, None); + } + let left = with_level(ctx, at, |l| { + l.asked = true; + if l.open.as_deref() == target { + l.pending = None; + return None; + } + let since = match &l.pending { + Some((t, since)) if t.as_deref() == target => *since, + _ => { + let since = l.rested_at.unwrap_or(now); + l.pending = Some((target.map(str::to_string), since)); + since + } + }; + Some(HOVER_DELAY - (now - since)) + }); + match left { + Some(left) if left <= 0.0 => set_open(ctx, at.root, &at.path, target), + // Wake up when it's due, so a still pointer gets there. + Some(left) => ctx.request_repaint_after(std::time::Duration::from_secs_f64(left.min(HOVER_DELAY))), + None => {} + } +} + +/// Whether the pointer is on a row of level `at` other than the open submenu's (it shows as +/// hovered instead). +fn pending_elsewhere(ctx: &egui::Context, at: &At) -> bool { + with_level(ctx, at, |l| l.pending.as_ref().is_some_and(|(t, _)| *t != l.open)) +} + +/// A plain row of the level being drawn: hovering it (not while aiming at a submenu) closes the +/// level's open submenu. Returns whether it shows as hovered. +fn plain_row_hover(ui: &Ui, resp: &egui::Response) -> bool { + let hovered = resp.hovered() && !aiming(ui); + if hovered { + hover_request(ui.ctx(), &level_at(ui), None); + } + hovered || resp.has_focus() +} + +/// A submenu row whose panel (area `area`, `gap` right of the row) shows while it's open. +#[allow(clippy::too_many_arguments)] +fn submenu_at( + ui: &mut Ui, + area: Id, + key: &str, + label: &str, + gap: f32, + constrain: bool, + t: &Tokens, + add: impl FnOnce(&mut Ui) -> Option, +) -> Option { + let at = level_at(ui); + let aim = aiming(ui); + let w = ui.available_width(); + let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click()); + // The keyboard and clicks open it at once; hovering after a moment. + if resp.has_focus() || resp.clicked() { + set_open(ui.ctx(), at.root, &at.path, Some(key)); + } else if resp.hovered() && !aim { + hover_request(ui.ctx(), &at, Some(key)); + } + let open = with_level(ui.ctx(), &at, |l| { + let open = l.open.as_deref() == Some(key); + if open { + l.row = Some(r); + } + open + }); + a11y::submenu(&resp, label, open); + a11y::own_focus(&resp); + // The open submenu's row stays highlighted while the pointer is in the submenu (not while it + // rests on another row of this menu). + let keep = open && !pending_elsewhere(ui.ctx(), &at); + paint_submenu_row(ui, r, label, keep || (resp.hovered() && !aim) || resp.has_focus(), t); + if !open { return None; } - let pos = pos2(resp.rect.max.x + 4.0, resp.rect.min.y - 8.0); - egui::Area::new(Id::new(("submenu", key))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| menu_frame(ui, t, add)).inner + let pos = pos2(r.max.x + gap, r.min.y - 8.0); + let child = At { root: at.root, path: key.to_string() }; + let shown = + egui::Area::new(area).fixed_pos(pos).order(egui::Order::Foreground).constrain(constrain).show(ui.ctx(), |ui| menu_frame(ui, t, child, add)); + let panel = shown.response.rect; + with_level(ui.ctx(), &at, |l| l.panel = Some(panel)); + shown.inner } /// A thin line between groups of menu items. @@ -351,7 +616,8 @@ pub(crate) fn separator(ui: &mut Ui) { ui.add_space(4.0); } -fn menu_frame(ui: &mut Ui, t: &Tokens, add: impl FnOnce(&mut Ui) -> R) -> R { +/// A menu panel drawing menu level `at`. +fn menu_frame(ui: &mut Ui, t: &Tokens, at: At, add: impl FnOnce(&mut Ui) -> R) -> R { egui::Frame::NONE .fill(t.menu) .corner_radius(CornerRadius::same(13)) @@ -362,6 +628,8 @@ fn menu_frame(ui: &mut Ui, t: &Tokens, add: impl FnOnce(&mut Ui) -> R) -> R { let w = ui.data(|d| d.get_temp::(width_key(ui))).unwrap_or(0.0).max(MENU_W); ui.set_width(w); ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + let key = at_key(ui); + ui.data_mut(|d| d.insert_temp(key, at)); add(ui) }) .inner @@ -398,7 +666,7 @@ fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checke } // The keyboard's item is highlighted like the hovered one. a11y::own_focus(&resp); - let hov = (resp.hovered() || resp.has_focus()) && enabled; + let hov = plain_row_hover(ui, &resp) && enabled; if hov { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } @@ -418,24 +686,24 @@ fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checke resp.clicked() } -fn submenu_item(ui: &mut Ui, label: &str, t: &Tokens) -> egui::Response { - let w = ui.available_width(); - let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click()); - a11y::submenu(&resp, label); - a11y::own_focus(&resp); - if resp.hovered() || resp.has_focus() { +fn paint_submenu_row(ui: &mut Ui, r: Rect, label: &str, highlight: bool, t: &Tokens) { + if highlight { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } let galley = ui.painter().layout_no_wrap(label.to_string(), regular(12.0), t.menu_text); need_width(ui, 28.0 + galley.size().x + 28.0); ui.painter().galley(pos2(r.min.x + 28.0, r.center().y - galley.size().y / 2.0), galley, t.menu_text); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 12.0, r.center().y), vec2(12.0, 12.0)), Icon::ChevronRight, t.menu_text); - resp } /// Open the main menu at top-level menu `top` (control channel). pub fn open_main_menu(ctx: &egui::Context, top: &str) { - ctx.data_mut(|d| d.insert_temp(Id::new("main_open"), top.to_string())); + set_open(ctx, main_menu_id(), "", Some(top)); +} + +/// The context menu's area (and menu tree). +fn ctx_menu_id() -> Id { + Id::new("ctx_menu") } /// The main menu's area. @@ -464,13 +732,29 @@ const TOP_MENUS: &[&str] = &[ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); + // A closed menu forgets its open submenus (and a context menu opened somewhere else starts + // afresh), so neither reopens with a stale submenu showing. + if !app.ui.main_menu { + forget_tree(ctx, main_menu_id()); + } + let ctx_at = app.ui.context_menu.as_ref().map(|(p, _)| *p); + let ctx_seen = Id::new("ctx_menu_at"); + if ctx.data(|d| d.get_temp::>(ctx_seen)) != Some(ctx_at) { + ctx.data_mut(|d| d.insert_temp(ctx_seen, ctx_at)); + forget_tree(ctx, ctx_menu_id()); + } + // Select layer opened or closed from the control channel. + if let Some(open) = ctx.data_mut(|d| d.remove_temp::(select_layer_ask_id())) { + set_open(ctx, ctx_menu_id(), "", open.then_some(SELECT_LAYER)); + } // Main menu. if app.ui.main_menu { let mut close = false; let area = egui::Area::new(main_menu_id()).fixed_pos(pos2(8.0, crate::tabs::HEIGHT + 48.0)).order(egui::Order::Foreground).show(ctx, |ui| { - menu_frame(ui, &t, |ui| { + menu_frame(ui, &t, At { root: main_menu_id(), path: String::new() }, |ui| { // Quick actions search row. let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); + plain_row_hover(ui, &resp); a11y::menu_item(&resp, tr("Actions…"), crate::shortcuts::shortcut("app.actions").unwrap_or(""), None); ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(5), Color32::from_white_alpha(18)); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(14.0, 14.0)), Icon::Search, t.menu_dim); @@ -488,20 +772,8 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { } ui.add_space(6.0); for top in TOP_MENUS { - let resp = submenu_item(ui, tr(top), &t); - if resp.hovered() || resp.has_focus() { - ui.data_mut(|d| d.insert_temp(Id::new("main_open"), top.to_string())); - } - let open: Option = ui.data(|d| d.get_temp(Id::new("main_open"))); - if open.as_deref() == Some(*top) { - let pos = pos2(resp.rect.max.x + 12.0, resp.rect.min.y - 8.0); - egui::Area::new(Id::new(("menu", *top))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| { - menu_frame(ui, &t, |ui| { - if menu_level(app, ui, top) { - close = true; - } - }); - }); + if submenu_at(ui, Id::new(("menu", *top)), top, tr(top), 12.0, false, &t, |ui| menu_level(app, ui, top).then_some(())).is_some() { + close = true; } } }); @@ -509,6 +781,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { let clicked_outside = ctx.input(|i| i.pointer.any_pressed()) && !over_menu(ctx) || ctx.input(|i| i.key_pressed(egui::Key::Escape)); if close || (clicked_outside && !area.response.hovered()) { app.ui.main_menu = false; + forget_tree(ctx, main_menu_id()); } } // Context menu (from the keyboard: at the focused layer row or the canvas selection, taking the @@ -521,8 +794,8 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { a11y::popup(&mut app.ui.nav, f, Id::new("ctx_menu")); } let mut close = false; - let area = egui::Area::new(Id::new("ctx_menu")).fixed_pos(pos).order(egui::Order::Foreground).constrain(true).show(ctx, |ui| { - menu_frame(ui, &t, |ui| { + let area = egui::Area::new(ctx_menu_id()).fixed_pos(pos).order(egui::Order::Foreground).constrain(true).show(ctx, |ui| { + menu_frame(ui, &t, At { root: ctx_menu_id(), path: String::new() }, |ui| { if select_layer_menu(app, ui, pos, &t) { close = true; } @@ -635,20 +908,23 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { let clicked_outside = ctx.input(|i| i.pointer.any_pressed()) && !area.response.hovered() && !over_menu(ctx); if close || clicked_outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.context_menu = None; - ctx.data_mut(|d| d.remove::(select_layer_id())); + forget_tree(ctx, ctx_menu_id()); } } crate::colorpicker::show(app, ctx); palette(app, ctx); } -fn select_layer_id() -> Id { - Id::new("ctx_select_layer_open") +/// The context menu's Select layer submenu (its key in the context menu's tree). +const SELECT_LAYER: &str = "Select layer"; + +fn select_layer_ask_id() -> Id { + Id::new("ctx_select_layer_ask") } -/// Open (or close) the context menu's Select layer submenu. +/// Open (or close) the context menu's Select layer submenu (applied when the menu next draws). pub fn open_select_layer(ctx: &egui::Context, open: bool) { - ctx.data_mut(|d| d.insert_temp(select_layer_id(), open)); + ctx.data_mut(|d| d.insert_temp(select_layer_ask_id(), open)); } /// The context menu's "Select layer" submenu (right-click on the canvas): every layer under the @@ -672,23 +948,15 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) return false; } let sel = ds.selection.clone(); - let resp = submenu_item(ui, tr("Select layer"), t); - if resp.hovered() { - ui.data_mut(|d| d.insert_temp(select_layer_id(), true)); - } - let mut picked = None; - if ui.data(|d| d.get_temp::(select_layer_id())).unwrap_or(false) { - let at = pos2(resp.rect.max.x + 4.0, resp.rect.min.y - 8.0); - egui::Area::new(Id::new("ctx_select_layer")).fixed_pos(at).order(egui::Order::Foreground).constrain(true).show(ui.ctx(), |ui| { - menu_frame(ui, t, |ui| { - for (id, depth, name) in &layers { - if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) { - picked = Some(*id); - } - } - }); - }); - } + let picked = submenu_at(ui, Id::new("ctx_select_layer"), SELECT_LAYER, tr("Select layer"), 4.0, true, t, |ui| { + let mut picked = None; + for (id, depth, name) in &layers { + if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) { + picked = Some(*id); + } + } + picked + }); separator(ui); match picked { Some(id) => { diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 481b446c..745cde88 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -151,7 +151,7 @@ pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool { } } commands => { - let picked = crate::menus::submenu(ui, "Plugins", &format!("Plugins/plugin:{}", m.id), &m.name, t, |ui| { + let picked = crate::menus::submenu(ui, &format!("Plugins/plugin:{}", m.id), &m.name, t, |ui| { commands.iter().find(|c| crate::menus::item(ui, &c.label, "", true, false, t)).map(|c| c.id.clone()) }); if let Some(c) = picked { @@ -164,7 +164,7 @@ pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool { app.session.prefs.plugins.iter().filter(|p| p.manifest.widget).map(|p| (p.manifest.id.as_str(), p.manifest.name.as_str())).collect(); let widget = (!widgets.is_empty()) .then(|| { - crate::menus::submenu(ui, "Plugins", "Plugins/widgets", tr("Widgets"), t, |ui| { + crate::menus::submenu(ui, "Plugins/widgets", tr("Widgets"), t, |ui| { widgets.iter().find(|(_, name)| crate::menus::item(ui, name, "", true, false, t)).map(|(id, _)| id.to_string()) }) }) diff --git a/docs/roadmap.md b/docs/roadmap.md index 9e9eaec9..cf285464 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |