mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
Merge branch 'main' into feat/figcraft-canvas-fonts-presenter
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -942,10 +942,11 @@ pub fn menu_item(resp: &Response, label: &str, trail: &str, checked: Option<bool
|
||||
}
|
||||
|
||||
/// A menu item opening a submenu.
|
||||
pub fn submenu(resp: &Response, label: &str) {
|
||||
pub fn submenu(resp: &Response, label: &str, open: bool) {
|
||||
node(resp, label, |n| {
|
||||
n.set_role(Role::MenuItem);
|
||||
n.set_has_popup(accesskit::HasPopup::Menu);
|
||||
n.set_expanded(open);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,8 @@ mod live_tests;
|
||||
#[cfg(test)]
|
||||
mod live_text_tests;
|
||||
#[cfg(test)]
|
||||
mod menu_aim_tests;
|
||||
#[cfg(test)]
|
||||
mod menu_input_tests;
|
||||
#[cfg(test)]
|
||||
mod online_tests;
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Which submenu the main and context menus show, driven through egui headless: a closed menu
|
||||
//! reopens with no submenu open (also one level down), hovering another row closes the open
|
||||
//! submenu at once while a submenu opens only once the pointer stays on its row a moment
|
||||
//! (sweeping across rows doesn't flash each one), and on the way from a
|
||||
//! submenu's row to its submenu the rows the pointer crosses don't take over (the safe triangle),
|
||||
//! unless it rests on one or turns away.
|
||||
|
||||
use egui::{Event, Id, LayerId, Order, Pos2, RawInput, Rect, pos2, vec2};
|
||||
use figcraft_engine::Session;
|
||||
|
||||
use crate::menus::{AIM_REST, HOVER_DELAY, heading_for, in_triangle, main_menu_id};
|
||||
use crate::{FigApp, NoServices};
|
||||
|
||||
const SIZE: egui::Vec2 = vec2(1440.0, 900.0);
|
||||
const ROW: f32 = 28.0;
|
||||
|
||||
struct Ui {
|
||||
ctx: egui::Context,
|
||||
app: FigApp,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Ui {
|
||||
fn new() -> 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<Event>) {
|
||||
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<String> {
|
||||
let mut keys: Vec<String> = 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"]);
|
||||
}
|
||||
+330
-62
@@ -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<R>(ui: &mut Ui, prefix: &str, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option<R>) -> Option<R> {
|
||||
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<R>(ui: &mut Ui, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option<R>) -> Option<R> {
|
||||
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<String>,
|
||||
/// The open submenu's row and where its panel was drawn (last frame).
|
||||
row: Option<Rect>,
|
||||
panel: Option<Rect>,
|
||||
/// The pointer's last position counted as heading for the panel, and when it got there.
|
||||
apex: Option<egui::Pos2>,
|
||||
moved_at: f64,
|
||||
/// This pass's verdict: the pointer is aiming at the panel.
|
||||
pass: Option<u64>,
|
||||
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<String>, f64)>,
|
||||
asked: bool,
|
||||
/// When the pointer came to rest in the triangle, if that's what ended aiming this pass.
|
||||
rested_at: Option<f64>,
|
||||
}
|
||||
|
||||
/// One menu tree's levels, by menu path ("" for the top).
|
||||
#[derive(Clone, Default, Debug)]
|
||||
struct Tree {
|
||||
levels: std::collections::HashMap<String, Level>,
|
||||
}
|
||||
|
||||
/// 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>(at_key(ui))).unwrap_or(At { root: Id::new("menus"), path: String::new() })
|
||||
}
|
||||
|
||||
fn with_level<R>(ctx: &egui::Context, at: &At, f: impl FnOnce(&mut Level) -> R) -> R {
|
||||
ctx.data_mut(|d| f(d.get_temp_mut_or_default::<Tree>(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::<Tree>(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::<Tree>(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<String> = 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<R>(
|
||||
ui: &mut Ui,
|
||||
area: Id,
|
||||
key: &str,
|
||||
label: &str,
|
||||
gap: f32,
|
||||
constrain: bool,
|
||||
t: &Tokens,
|
||||
add: impl FnOnce(&mut Ui) -> Option<R>,
|
||||
) -> Option<R> {
|
||||
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<R>(ui: &mut Ui, t: &Tokens, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
/// A menu panel drawing menu level `at`.
|
||||
fn menu_frame<R>(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<R>(ui: &mut Ui, t: &Tokens, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
let w = ui.data(|d| d.get_temp::<f32>(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::<Option<egui::Pos2>>(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::<bool>(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<String> = 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::<bool>(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::<bool>(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) => {
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
+2
-1
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user