diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index cf705467..4318bb63 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -185,7 +185,7 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option { ui.add_space(16.0); ui.spacing_mut().item_spacing.x = 4.0; let resp = widgets::dropdown(ui, MODELS.get(model).copied().unwrap_or("Hex"), 52.0, None); - egui::Popup::menu(&resp).show(|ui| { + crate::right::menu_popup(&resp).show(|ui| { for (i, m) in MODELS.iter().enumerate() { if crate::right::menu_item(ui, m, "") { ui.data_mut(|d| d.insert_temp(model_id, i)); @@ -542,7 +542,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ui.horizontal(|ui| { ui.add_space(16.0); let resp = widgets::dropdown(ui, &format!("{:?}", img.scale_mode), 100.0, None); - egui::Popup::menu(&resp).show(|ui| { + crate::right::menu_popup(&resp).show(|ui| { for (l, m) in [ ("Fill", ImageScaleMode::Fill), ("Fit", ImageScaleMode::Fit), @@ -759,7 +759,7 @@ fn image_presets(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &m ui.horizontal(|ui| { ui.add_space(16.0); let resp = widgets::dropdown(ui, ¤t, 152.0, None).on_hover_text("Adjustment presets"); - egui::Popup::menu(&resp).show(|ui| { + crate::right::menu_popup(&resp).show(|ui| { for (id, label, _) in built_in { if crate::right::menu_item(ui, label, "") { action = Some(PresetAction::Apply(id.to_string())); diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 8a4c57d8..633107fe 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -684,7 +684,7 @@ fn message( let menu = Id::new(("comment_msg_more_menu", id, i)); let more = hover_button(ui, Id::new(("comment_msg_more", id, i)), Icon::Dots, hovered || egui::Popup::is_id_open(&ctx, menu), "More"); - egui::Popup::menu(&more).id(menu).show(|ui| { + crate::right::menu_popup(&more).id(menu).show(|ui| { if crate::right::menu_item(ui, "Edit", "") { *act = Some(Act::Edit(i, m.text.clone())); ui.close(); @@ -703,7 +703,7 @@ fn message( hovered || egui::Popup::is_id_open(&ctx, menu), "Add reaction", ); - egui::Popup::menu(&react).id(menu).show(|ui| { + crate::right::menu_popup(&react).id(menu).show(|ui| { ui.horizontal(|ui| { for e in REACTIONS { let on = m.reacted(e, cx.me); @@ -901,7 +901,7 @@ pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, boun act = Some(Act::Close); } let more = icons::button(ui, Icon::Dots, 24.0, false, "More"); - egui::Popup::menu(&more).show(|ui| { + crate::right::menu_popup(&more).show(|ui| { if crate::right::menu_item(ui, "Mark as unread", "") { act = Some(Act::MarkUnread); ui.close(); @@ -1033,7 +1033,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); let menu = icons::button(ui, Icon::Adjust, 24.0, app.ui.comments.filter != Filter::Open, "Filter and sort"); - egui::Popup::menu(&menu).width(200.0).show(|ui| { + crate::right::menu_popup(&menu).width(200.0).show(|ui| { for f in Filter::ALL { if crate::right::menu_check(ui, app.ui.comments.filter == f, f.label(), "") { app.ui.comments.filter = f; diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 499b6d33..7b8ce8a5 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -18,7 +18,7 @@ use serde_json::{Value, json}; use crate::FigApp; use crate::icons::{self, Icon}; -use crate::right::{menu_item, row}; +use crate::right::{menu_item, menu_popup, row}; use crate::theme::{COMPONENT, Tokens, semibold}; use crate::widgets; @@ -136,7 +136,7 @@ fn slot_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, prefer (true, k) => format!("{k} layers"), }; let resp = widgets::dropdown(ui, &label, w, Some(Icon::Slot)).on_hover_text("Add content to this slot"); - egui::Popup::menu(&resp).width(w.max(200.0)).show(|ui| { + menu_popup(&resp).width(w.max(200.0)).show(|ui| { let insert = |app: &mut FigApp, c: NodeId| app.run("instance.slotInsert", json!({"id": slot, "component": c.0})); let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary)); if !preferred.is_empty() { @@ -194,7 +194,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { let existing: Vec = defs.keys().cloned().chain(variants.iter().map(|(k, _)| k.clone())).collect(); let fresh = (1..).map(|k| format!("Property {k}")).find(|n| !existing.contains(n)).unwrap_or_else(|| "Property".into()); let plus = header_with_plus(ui, "Properties", "Create component property"); - egui::Popup::menu(&plus).width(180.0).show(|ui| { + menu_popup(&plus).width(180.0).show(|ui| { let swap_default = comps.iter().map(|(c, _)| *c).find(|c| *c != owner && first != Some(*c)); let items: [(&str, &str, Value); 4] = [ ("Variant", "variant", json!("Default")), @@ -226,7 +226,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { } let def = vals.first().cloned().unwrap_or_default(); let resp = widgets::dropdown(ui, &def, 70.0, None).on_hover_text("Default value (the first variant)"); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for v in vals { if menu_item(ui, v, "") { app.run("component.editProperty", json!({"id": owner, "name": k, "default": v})); @@ -285,7 +285,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { PropDef::InstanceSwap { default } => { let label = doc.node(*default).map(|n| n.name.clone()).unwrap_or_else(|| "Missing".into()); let resp = widgets::dropdown(ui, &label, 84.0, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for (c, name) in &comps { if menu_item(ui, name, "") { app.run("component.editProperty", json!({"id": owner, "name": k, "default": c.0})); @@ -313,7 +313,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { }; let w = (ui.available_width() - 8.0).max(60.0); let resp = widgets::dropdown(ui, &label, w, None).on_hover_text("Offered first when filling the slot (any content is allowed)"); - egui::Popup::menu(&resp).width(w.max(200.0)).show(|ui| { + menu_popup(&resp).width(w.max(200.0)).show(|ui| { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for (c, name) in comps.iter().filter(|(c, _)| *c != owner && first != Some(*c)) { let on = preferred.contains(c); @@ -382,7 +382,7 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { widgets::label(ui, label, t.text_secondary); let shown = bound.clone().unwrap_or_else(|| "Not bound".into()); let resp = widgets::dropdown(ui, &shown, 120.0, None).on_hover_text("Apply a component property"); - egui::Popup::menu(&resp).width(180.0).show(|ui| { + menu_popup(&resp).width(180.0).show(|ui| { for k in &matching { if menu_item(ui, k, "") { app.run("component.bindProperty", json!({"layer": n.id, "property": k, "field": field})); @@ -482,7 +482,7 @@ fn instance_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { ui.label(egui::RichText::new(if nested { "Nested instance" } else { "Instance" }).font(semibold(11.0)).color(t.text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let more = icons::button(ui, Icon::Dots, 24.0, false, "More instance actions"); - egui::Popup::menu(&more).width(220.0).show(|ui| { + menu_popup(&more).width(220.0).show(|ui| { if !missing && menu_item(ui, "Go to main component", "") { app.run("component.goToMain", json!({"id": id})); ui.close(); @@ -516,7 +516,7 @@ fn instance_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { row(ui, 32.0, |ui| { let w = (ui.available_width() - 32.0).max(80.0); let resp = widgets::dropdown(ui, &comp_name, w, Some(Icon::Instance)).on_hover_text("Swap instance"); - egui::Popup::menu(&resp).width(w.max(200.0)).show(|ui| { + menu_popup(&resp).width(w.max(200.0)).show(|ui| { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { let mut page = None; for (c, name) in &comps { @@ -601,7 +601,7 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps prop_label(ui, &k, Icon::ComponentSet); let w = ui.available_width().max(60.0); let resp = widgets::dropdown(ui, &cur, w, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for v in &vals { if menu_item(ui, v, "") { app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): v}})); @@ -643,7 +643,7 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps let cur = figcraft_engine::params::id_of(&v); let label = cur.and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| "None".into()); let resp = widgets::dropdown(ui, &label, w, Some(Icon::Instance)); - egui::Popup::menu(&resp).width(200.0).show(|ui| { + menu_popup(&resp).width(200.0).show(|ui| { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for (c, name) in comps { if menu_item(ui, name, "") { diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index 8121b283..4fc965e1 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -10,7 +10,7 @@ use figcraft_engine::devinspect::{self, DevItem, Prop}; use serde_json::json; use crate::icons::{self, Icon}; -use crate::right::{menu_check, prop_row, row}; +use crate::right::{menu_check, menu_popup, prop_row, row}; use crate::theme::{ACCENT, Tokens, regular, semibold}; use crate::widgets::{self, PAD}; use crate::{FigApp, RightTab}; @@ -230,7 +230,7 @@ fn pin_menu(ui: &mut Ui, id: Id, sections: &[devinspect::Section], pinned: &[Str let w = ui.available_width() - PAD * 2.0; row(ui, 30.0, |ui| { let resp = ui.push_id(id, |ui| widgets::dropdown(ui, "Pin a property", w, Some(Icon::Plus))).inner; - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for p in PINNABLE { let on = pinned.iter().any(|q| q == p.id); if (on || annotations::applies(sections, p)) && menu_check(ui, on, p.label, "") { @@ -260,7 +260,7 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { let w = ui.available_width() - PAD * 2.0; row(ui, 32.0, |ui| { let resp = widgets::dropdown(ui, FILTERS.get(filter).map_or("", |f| f.1), w, Some(Icon::Code)); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for (i, (_, label)) in FILTERS.iter().enumerate() { if menu_check(ui, i == filter, label, "") { app.ui.dev.filter = i; diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 100a4ff7..e390880b 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -11,7 +11,7 @@ use serde_json::json; use crate::FigApp; use crate::icons::{self, Icon}; -use crate::right::{common, menu_item, row}; +use crate::right::{common, menu_item, menu_popup, row}; use crate::theme::{Tokens, regular}; use crate::widgets::{self, FIELD_H}; @@ -76,7 +76,7 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo app.run("export.update", json!({"ids": ids, "index": i, "suffix": sfx})); } let resp = widgets::dropdown(ui, e.format.label(), format_w, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for f in ExportFormat::ALL { if menu_item(ui, f.label(), "") { app.run("export.update", json!({"ids": ids, "index": i, "format": f.ext()})); diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index aab23d73..9959fbd0 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -227,7 +227,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { app.ui.rename_page = Some((id, name.clone())); ui.memory_mut(|m| m.request_focus(Id::new(("pagename", id)).with("te"))); } - resp.context_menu(|ui| { + crate::right::context_popup(&resp).show(|ui| { if ui.button("Rename page").clicked() { app.ui.rename_page = Some((id, name.clone())); ui.close(); diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 5514cc3c..d5c69bd0 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -9,7 +9,7 @@ use serde_json::json; use crate::FigApp; use crate::icons::{self, Icon}; -use crate::right::{block, common, menu_item, row}; +use crate::right::{block, common, menu_item, menu_popup, row}; use crate::theme::{ACCENT, Tokens, regular}; use crate::widgets::{self, FIELD_H, Lead}; @@ -155,7 +155,7 @@ fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has } let current = first.map(|n| if horizontal { n.width } else { n.height }).unwrap_or(0.0); let ids2 = ids.clone(); - egui::Popup::menu(&resp).width(200.0).show(|ui| { + menu_popup(&resp).width(200.0).show(|ui| { let mut opts = vec![("Fixed", "fixed")]; if has_al || is_text { opts.push(("Hug contents", "hug")); @@ -312,7 +312,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { app.field(ids, "gap", e, |v| json!(v)); ui.add_space(8.0); let resp = widgets::dropdown(ui, if auto { "Auto gap" } else { "Fixed gap" }, 96.0, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { if menu_item(ui, "Fixed", "") { app.set_props(ids, json!({"mainAlign": "min"})); ui.close(); @@ -434,7 +434,7 @@ fn tracks(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId]) { Sizing::Fill => ("Fill", Icon::FillContainer), }; let resp = widgets::dropdown(ui, label, col, Some(icon)); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for (l, v) in [("Fixed", "fixed"), ("Hug contents", "hug"), ("Fill (fr)", "fill")] { if menu_item(ui, l, "") { app.run("layout.setTrack", with(v, None)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c88d68e4..46845c0e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -58,6 +58,8 @@ mod panel_reorder_tests; #[cfg(test)] mod perf_tests; #[cfg(test)] +mod popup_width_tests; +#[cfg(test)] mod present_tests; #[cfg(test)] mod present_view_tests; diff --git a/crates/ui-egui/src/popup_width_tests.rs b/crates/ui-egui/src/popup_width_tests.rs new file mode 100644 index 00000000..740222d6 --- /dev/null +++ b/crates/ui-egui/src/popup_width_tests.rs @@ -0,0 +1,103 @@ +//! Popup menus are as wide as their widest row (at least as wide as what opened them), not egui's +//! default area width, and stay inside the window: drawn headless. + +use egui::{Color32, Event, Order, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use figcraft_engine::Session; + +use crate::right::{menu_item, menu_popup, menu_row_width}; +use crate::theme::regular; +use crate::{FigApp, NoServices, RightTab}; + +const SCREEN: egui::Vec2 = vec2(1440.0, 900.0); + +fn input(t: &mut f64, events: Vec) -> RawInput { + *t += 1.0 / 60.0; + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), time: Some(*t), events, ..Default::default() } +} + +fn press(p: Pos2, pressed: bool) -> Vec { + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }] +} + +/// The rects of the foreground areas (menus) showing. +fn menus(ctx: &egui::Context) -> Vec { + ctx.memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| l.order == Order::Foreground).filter_map(|l| m.area_rect(l.id)).collect()) +} + +/// The popup frame's margins, left plus right. +fn frame_margins(ctx: &egui::Context) -> f32 { + egui::Frame::popup(&ctx.global_style()).total_margin().sum().x +} + +#[test] +fn a_menu_is_as_wide_as_its_widest_row_and_does_not_wrap() { + let ctx = egui::Context::default(); + let mut t = 0.0; + let items = [("Copy as code", "Cmd+Shift+C"), ("Paste", "Cmd+V"), ("A much longer menu item label", "")]; + let mut frame = |events: Vec| { + let mut out = ctx.run_ui(input(&mut t, events), |ui| { + // A narrow icon button opens it. + let (_, resp) = ui.allocate_exact_size(vec2(24.0, 24.0), egui::Sense::click()); + menu_popup(&resp).show(|ui| { + for (label, keys) in items { + menu_item(ui, label, keys); + } + }); + }); + out.textures_delta.clear(); + }; + let at = pos2(12.0, 12.0); + frame(vec![]); + frame(press(at, true)); + frame(press(at, false)); + for _ in 0..3 { + frame(vec![]); + } + let shown = menus(&ctx); + let [menu] = shown.as_slice() else { panic!("one menu open: {shown:?}") }; + let width = |s: &str, size: f32| ctx.fonts_mut(|f| f.layout_no_wrap(crate::toolbar::pretty_shortcut(s), regular(size), Color32::WHITE).size().x); + let content = + items.iter().map(|(l, k)| menu_row_width(width(l, 11.5), if k.is_empty() { 0.0 } else { width(k, 11.0) + 8.0 }).ceil()).fold(0.0, f32::max); + let expected = content + frame_margins(&ctx); + assert!((menu.width() - expected).abs() <= 2.0, "menu {} wide, content needs {expected}", menu.width()); + // One line per row. + let rows = 26.0 * items.len() as f32; + assert!(menu.height() < rows + 40.0, "menu {} tall for {rows} of rows", menu.height()); +} + +#[test] +fn right_panel_dropdown_menus_fit_the_panel() { + let mut app = FigApp::new(Session::new(), Box::new(NoServices)); + app.ui.right_tab = RightTab::Inspect; + let ctx = egui::Context::default(); + let mut t = 0.0; + let mut frame = |app: &mut FigApp, events: Vec| { + let mut out = ctx.run_ui(input(&mut t, events), |ui| app.ui(ui)); + out.textures_delta.clear(); + }; + for _ in 0..3 { + frame(&mut app, vec![]); + } + // With nothing selected, Inspect lists the frames ready for dev under a filter dropdown, the + // only control on that panel: click down the panel until it opens. + let panel_left = SCREEN.x - app.ui.right_width; + let x = panel_left + app.ui.right_width / 2.0; + let mut menu = None; + for y in (140..600).step_by(6) { + let at = pos2(x, y as f32); + frame(&mut app, press(at, true)); + frame(&mut app, press(at, false)); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + if let [m] = menus(&ctx).as_slice() { + menu = Some(*m); + break; + } + } + let menu = menu.expect("the Ready for dev filter menu opens"); + // The dropdown spans the panel less its padding; its three short options fit in that. + let dropdown = app.ui.right_width - 2.0 * crate::widgets::PAD; + assert!(menu.width() >= dropdown - 16.0, "menu {menu:?} at least as wide as its dropdown ({dropdown})"); + assert!(menu.width() <= app.ui.right_width, "menu {menu:?} fits the {} px panel", app.ui.right_width); + assert!(menu.max.x <= SCREEN.x && menu.min.x >= panel_left - 8.0, "menu {menu:?} inside the window, under the panel"); +} diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index fce88687..d9f27967 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -1912,7 +1912,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) ui.painter().text(br.center(), Align2::CENTER_CENTER, *label, regular(11.5), text); } if *act == BarAction::Options { - let menu = egui::Popup::menu(&resp).width(220.0).show(|ui| options_menu(p, ui, device, settings.responsive, now)); + let menu = crate::right::menu_popup(&resp).width(220.0).show(|ui| options_menu(p, ui, device, settings.responsive, now)); action = action.or(menu.and_then(|m| m.inner)); } else if resp.clicked() { action = Some(*act); @@ -1934,7 +1934,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) left.rect_filled(fr, CornerRadius::same(5), hover); } left.galley(pos2(fr.min.x + 8.0, fr.center().y - galley.size().y / 2.0), galley, text); - egui::Popup::menu(&resp).width(240.0).show(|ui| { + crate::right::menu_popup(&resp).width(240.0).show(|ui| { let t = Tokens::get(ui.ctx()); for f in &flows { if crate::right::menu_check(ui, f.start == p.start, &f.name, "") { diff --git a/crates/ui-egui/src/proto_editor.rs b/crates/ui-egui/src/proto_editor.rs index c4148e59..6168d14e 100644 --- a/crates/ui-egui/src/proto_editor.rs +++ b/crates/ui-egui/src/proto_editor.rs @@ -13,7 +13,7 @@ use serde_json::{Value, json}; use crate::FigApp; use crate::icons::{self, Icon}; use crate::proto_panel::{action_fields, action_of, action_summary, targets_for, value_json}; -use crate::right::{menu_check, menu_item, row}; +use crate::right::{menu_check, menu_item, menu_popup, row}; use crate::theme::{Tokens, regular, semibold}; use crate::widgets::{self, BindEvent, FIELD_H, FieldEdit, Lead, PAD}; @@ -177,7 +177,7 @@ fn link_button(ui: &mut Ui, icon: Icon, label: &str) -> egui::Response { fn add_action_row(app: &mut FigApp, ui: &mut Ui, cx: &Cx, path: Vec) { row(ui, 28.0, |ui| { let resp = link_button(ui, Icon::Plus, "Add action"); - egui::Popup::menu(&resp).width(200.0).show(|ui| { + menu_popup(&resp).width(200.0).show(|ui| { if let Some(l) = action_menu(ui) { match action_of(l, cx.doc, cx.page, cx.node, None) { Some(a) => { @@ -220,7 +220,7 @@ fn action_item(app: &mut FigApp, ui: &mut Ui, cx: &Cx, a: &Action, path: Vec set_action(app, &slot, new), @@ -244,7 +244,7 @@ fn action_item(app: &mut FigApp, ui: &mut Ui, cx: &Cx, a: &Action, path: Vec 0 && menu_item(ui, "Move up", "") { moved = Some(i - 1); @@ -445,7 +445,7 @@ fn builder_row(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, b: usize, s: crate::variable_ui::open_proto(ui, VarSlot { slot: slot.clone(), part: VarPart::Lhs(b) }, None, resp.rect.left_bottom()); } let resp = widgets::dropdown(ui, s.op.symbol(), op_w, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for op in ops_for(kind) { if menu_check(ui, *op == s.op, op.symbol(), "") { set_condition(app, slot, b, &with_op(s, *op, vars).expr()); @@ -492,7 +492,7 @@ fn literal_field(ui: &mut Ui, id: Id, cur: &VariableValue, w: f32) -> Option { let resp = widgets::dropdown(ui, if *on { "true" } else { "false" }, w, None); let mut out = None; - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for v in [true, false] { if menu_check(ui, v == *on, if v { "true" } else { "false" }, "") { out = (v != *on).then_some(VariableValue::Boolean(v)); diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index a9c40ce6..f8d8d052 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -11,7 +11,7 @@ use serde_json::{Value, json}; use crate::FigApp; use crate::icons::{self, Icon}; use crate::proto_editor::{Cx, Slot, set_action}; -use crate::right::{menu_item, row}; +use crate::right::{menu_item, menu_popup, row}; use crate::theme::{ACCENT, Tokens, regular, semibold}; use crate::widgets::{self, FIELD_H, FieldEdit, Lead, PAD}; @@ -380,7 +380,7 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { row(ui, 32.0, |ui| { let w = ui.available_width() - if rotates { 32.0 } else { 0.0 }; let resp = widgets::dropdown(ui, &settings.device, w, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { let mut pick = None; if crate::right::menu_check(ui, settings.device == "None", "None", "") { pick = Some("None"); @@ -448,12 +448,12 @@ fn device_colors(app: &mut FigApp, ui: &mut Ui, colors: &[proto::DeviceColor], c } let w = ui.available_width(); let resp = widgets::dropdown(ui, current.map_or("", |c| c.name), w, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for c in colors { let on = current.is_some_and(|x| x.name == c.name); let label = format!("{}{}", if on { "✓ " } else { " " }, c.name); let paint = color_paint(*c); - let picked = crate::right::menu_item_with(ui, &label, "", |p, r, _| { + let picked = crate::right::menu_item_with(ui, &label, "", 22.0, |p, r, _| { widgets::paint_swatch(p, Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(12.0, 12.0)), &paint); }); if picked { @@ -645,7 +645,7 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte widgets::sublabels(ui, "Trigger", None); row(ui, 32.0, |ui| { let resp = widgets::dropdown(ui, trigger_kind(&it.trigger), ui.available_width(), None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for (i, l) in TRIGGERS.iter().enumerate() { if i == 4 || i == 5 || i == 9 { ui.separator(); @@ -741,7 +741,7 @@ fn dest_dropdown( row(ui, 32.0, |ui| { let text = cur.map(|d| name_of(doc, d)).unwrap_or_else(|| "None".into()); let resp = widgets::dropdown(ui, &text, ui.available_width(), Some(Icon::Frame)); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for d in options { let n = name_of(doc, *d); @@ -798,7 +798,7 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: widgets::sublabels(ui, "Overlay position", None); row(ui, 32.0, |ui| { let resp = widgets::dropdown(ui, position.label(), ui.available_width(), None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for p in OverlayPosition::ALL { if menu_item(ui, p.label(), "") { set_action(app, slot, Action::OpenOverlay { dest, position: p, close_on_outside, background }); @@ -845,7 +845,7 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: widgets::sublabels(ui, "Collection", None); row(ui, 32.0, |ui| { let resp = widgets::dropdown(ui, current.map_or("Choose collection", |c| c.name.as_str()), ui.available_width(), None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for c in colls { if menu_item(ui, &c.name, "") && let Some(m) = c.modes.first() @@ -861,7 +861,7 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: let modes = current.map(|c| c.modes.as_slice()).unwrap_or_default(); let name = modes.iter().find(|m| &m.id == mode).map_or("Choose mode", |m| m.name.as_str()); let resp = widgets::dropdown(ui, name, ui.available_width(), None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for m in modes { if menu_item(ui, &m.name, "") { set_action(app, slot, Action::SetVariableMode { collection: collection.clone(), mode: m.id.clone() }); @@ -881,7 +881,7 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In widgets::sublabels(ui, "Animation", None); row(ui, 32.0, |ui| { let resp = widgets::dropdown(ui, it.transition.label(), ui.available_width(), None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for (i, l) in TRANSITIONS.iter().enumerate() { if i == 3 { ui.separator(); @@ -908,7 +908,7 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In if !matches!(it.transition, Transition::Instant) { row(ui, 32.0, |ui| { let resp = widgets::dropdown(ui, it.easing.label(), ui.available_width() - 80.0, None); - egui::Popup::menu(&resp).width(220.0).show(|ui| { + menu_popup(&resp).width(220.0).show(|ui| { let custom = [Easing::CUSTOM_BEZIER, Easing::CUSTOM_SPRING]; for (i, e) in Easing::ALL.iter().chain(custom.iter()).enumerate() { if i == 7 || i == Easing::ALL.len() { @@ -1050,7 +1050,7 @@ fn scroll_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], ScrollPosition::Sticky => "Sticky", }; let resp = widgets::dropdown(ui, cur, ui.available_width(), None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for (l, v) in [("Scroll with parent", "scrolls"), ("Fixed", "fixed"), ("Sticky", "sticky")] { if menu_item(ui, l, "") { app.set_props(sel, json!({"scrollPosition": v})); @@ -1070,7 +1070,7 @@ fn scroll_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], Overflow::Both => "Both directions", }; let resp = widgets::dropdown(ui, cur, ui.available_width(), None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for (l, v) in [("No scrolling", "none"), ("Horizontal", "horizontal"), ("Vertical", "vertical"), ("Both directions", "both")] { if menu_item(ui, l, "") { app.set_props(sel, json!({"overflow": v})); diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 2ae7b8b5..8b15f6ae 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -87,7 +87,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { if presp.clicked() { app.run("proto.present", json!({})); } - egui::Popup::menu(&mresp).width(200.0).show(|ui| { + menu_popup(&mresp).width(200.0).show(|ui| { if menu_item(ui, "Present", &present_keys) { app.run("proto.present", json!({})); ui.close(); @@ -125,7 +125,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { } ui.painter().text(pos2(zr.min.x + 6.0, zr.center().y), Align2::LEFT_CENTER, format!("{}%", (zoom * 100.0).round()), regular(11.0), t.text); icons::paint(ui.painter(), Rect::from_center_size(pos2(zr.max.x - 9.0, zr.center().y), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); - egui::Popup::menu(&zresp).width(200.0).show(|ui| { + menu_popup(&zresp).width(200.0).show(|ui| { for (label, cmd) in [ ("Zoom in", "view.zoomIn"), ("Zoom out", "view.zoomOut"), @@ -182,33 +182,67 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { }); } +/// The smallest width a popup menu is measured in (see [`menu_popup`]). +const MENU_MIN_W: f32 = 48.0; +/// A menu row's inset either side, and the gap between its label and its shortcut or art. +const MENU_PAD: f32 = 8.0; +const MENU_GAP: f32 = 24.0; + +/// The style of popup menus: egui's, with text kept on one line (a menu grows to its widest item). +fn menu_style(s: &mut egui::Style) { + egui::containers::menu::menu_style(s); + s.wrap_mode = Some(egui::TextWrapMode::Extend); +} + +/// The menu a dropdown or button opens: as wide as its widest row and at least as wide as +/// `anchor`. egui measures a popup in a first, invisible pass as wide as the width it is given +/// (else its 600 px default area width) and keeps the size measured; rows ([`menu_row`]) take the +/// larger of that width and their content, so the menu fits its content. Chain `.width(w)` for a +/// menu at least `w` wide. +pub fn menu_popup(anchor: &egui::Response) -> egui::Popup<'static> { + egui::Popup::menu(anchor).width(anchor.rect.width().max(MENU_MIN_W)).style(menu_style) +} + +/// A context menu (secondary click on `resp`) sized like [`menu_popup`]. +pub fn context_popup(resp: &egui::Response) -> egui::Popup<'static> { + egui::Popup::context_menu(resp).width(MENU_MIN_W).style(menu_style) +} + pub fn menu_item(ui: &mut Ui, label: &str, shortcut: &str) -> bool { - menu_row(ui, label, shortcut, |_, _, _| {}).clicked() + menu_row(ui, label, shortcut, 0.0, |_, _, _| {}).clicked() } -/// A menu row with art drawn into it after the label (`art(painter, row rect, text colour)`). -pub fn menu_item_with(ui: &mut Ui, label: &str, shortcut: &str, art: impl FnOnce(&egui::Painter, Rect, Color32)) -> bool { - menu_row(ui, label, shortcut, art).clicked() +/// A menu row with art drawn into it after the label (`art(painter, row rect, text colour)`), +/// taking `art_w` from the row's right edge. +pub fn menu_item_with(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnce(&egui::Painter, Rect, Color32)) -> bool { + menu_row(ui, label, shortcut, art_w, art).clicked() } -/// A menu row (see [`menu_item_with`]); its response tells hovering too. -pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art: impl FnOnce(&egui::Painter, Rect, Color32)) -> egui::Response { +/// The width a menu row needs for a label `label_w` wide and, at its right end, a shortcut or +/// art `trail` wide (0 for neither). +pub(crate) fn menu_row_width(label_w: f32, trail: f32) -> f32 { + MENU_PAD + label_w + if trail > 0.0 { MENU_GAP + trail } else { MENU_PAD } +} + +/// A menu row (see [`menu_item_with`]); its response tells hovering too. It spans the menu and +/// is at least as wide as its label, shortcut and art. +pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnce(&egui::Painter, Rect, Color32)) -> egui::Response { let t = Tokens::get(ui.ctx()); - let w = ui.available_width().max(180.0); + let text = ui.painter().layout_no_wrap(label.to_owned(), regular(11.5), Color32::PLACEHOLDER); + let keys = + (!shortcut.is_empty()).then(|| ui.painter().layout_no_wrap(crate::toolbar::pretty_shortcut(shortcut), regular(11.0), Color32::PLACEHOLDER)); + let trail = keys.as_ref().map_or(0.0, |k| k.size().x + MENU_PAD).max(art_w); + let w = ui.available_width().max(menu_row_width(text.size().x, trail).ceil()); let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); - if resp.hovered() { + let hovered = resp.hovered(); + if hovered { ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT); } - let col = if resp.hovered() { Color32::WHITE } else { t.text }; - ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, label, regular(11.5), col); - if !shortcut.is_empty() { - ui.painter().text( - pos2(r.max.x - 8.0, r.center().y), - Align2::RIGHT_CENTER, - crate::toolbar::pretty_shortcut(shortcut), - regular(11.0), - if resp.hovered() { Color32::WHITE } else { t.text_secondary }, - ); + let col = if hovered { Color32::WHITE } else { t.text }; + ui.painter().galley(pos2(r.min.x + MENU_PAD, r.center().y - text.size().y / 2.0), text, col); + if let Some(k) = keys { + let at = pos2(r.max.x - MENU_PAD - k.size().x, r.center().y - k.size().y / 2.0); + ui.painter().galley(at, k, if hovered { Color32::WHITE } else { t.text_secondary }); } art(ui.painter(), r, col); resp @@ -325,7 +359,7 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { widgets::section_header(ui, "Color profile", &[], false); row(ui, 32.0, |ui| { let resp = widgets::dropdown(ui, doc.color_profile.label(), 140.0, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for p in figcraft_color::ColorProfile::ALL { if menu_item(ui, p.label(), "") { if p != doc.color_profile { @@ -402,7 +436,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { app.run("frame.swapOrientation", json!({"ids": sel})); } let ids: Vec = sel.to_vec(); - egui::Popup::menu(&resp).width(260.0).show(|ui| { + menu_popup(&resp).width(260.0).show(|ui| { egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { let mut cat = ""; for (c, name, pw, ph) in figcraft_engine::cmd::create::FRAME_PRESETS { @@ -433,7 +467,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } icons::paint(ui.painter(), br.shrink(5.0), icon, t.icon); if cmd == "bool.union" { - egui::Popup::menu(&bresp).width(180.0).show(|ui| { + menu_popup(&bresp).width(180.0).show(|ui| { for (l, c) in [ ("Union", "bool.union"), ("Subtract", "bool.subtract"), @@ -479,7 +513,7 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } if sel.len() > 2 { let more = icons::button(ui, Icon::DistributeH, 24.0, false, "Distribute and tidy"); - egui::Popup::menu(&more).show(|ui| { + menu_popup(&more).show(|ui| { for (l, c) in [ ("Distribute horizontal spacing", "distribute.horizontal"), ("Distribute vertical spacing", "distribute.vertical"), @@ -535,7 +569,7 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { row(ui, 32.0, |ui| { let h = c.map(|c| format!("{:?}", c.horizontal)).unwrap_or_else(|| "Mixed".into()); let resp = widgets::dropdown(ui, &pretty(&h), col, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for (l, v) in [("Left", "left"), ("Right", "right"), ("Left & right", "leftRight"), ("Center", "center"), ("Scale", "scale")] { if menu_item(ui, l, "") { app.set_props(&ids, json!({"constraints": {"horizontal": v}})); @@ -545,7 +579,7 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { }); let v = c.map(|c| format!("{:?}", c.vertical)).unwrap_or_else(|| "Mixed".into()); let resp = widgets::dropdown(ui, &pretty(&v), col, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for (l, val) in [("Top", "top"), ("Bottom", "bottom"), ("Top & bottom", "topBottom"), ("Center", "center"), ("Scale", "scale")] { if menu_item(ui, l, "") { app.set_props(&ids, json!({"constraints": {"vertical": val}})); @@ -596,7 +630,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { widgets::sublabels(ui, "Mask", None); row(ui, 32.0, |ui| { let resp = widgets::dropdown(ui, mt.map(MaskType::label).unwrap_or("Mixed"), col * 2.0 + 8.0, Some(Icon::Mask)); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for m in MaskType::ALL { if menu_item(ui, m.label(), "") { app.set_props(&ids, json!({"maskType": m.id()})); @@ -683,7 +717,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { row(ui, 32.0, |ui| { let label = blend.map(|b| b.label()).unwrap_or("Mixed"); let resp = widgets::dropdown(ui, label, col * 2.0 + 8.0, Some(Icon::Blend)); - egui::Popup::menu(&resp).width(200.0).show(|ui| { + menu_popup(&resp).width(200.0).show(|ui| { for b in BlendMode::ALL { if menu_item(ui, b.label(), "") { app.set_props(&ids, json!({"blend": b.css()})); @@ -754,7 +788,7 @@ fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) ui.interact(rect, Id::new(("var_mode_name", &c.id)), Sense::hover()).on_hover_text(&hover); } let resp = widgets::elided_dropdown(ui, &label, pick_w); - egui::Popup::menu(&resp).width(180.0).show(|ui| { + menu_popup(&resp).width(180.0).show(|ui| { let mut pick = None; if menu_check(ui, own == Some(None), &auto, "") { pick = Some(Value::Null); @@ -900,7 +934,7 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: half, None, ); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for (l, v) in [("Inside", "inside"), ("Center", "center"), ("Outside", "outside")] { if menu_item(ui, l, "") { app.set_props(&ids, json!({"strokeAlign": v})); @@ -915,7 +949,7 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: app.field(&ids, "strokeWeight", e, |v| json!(v.max(0.0))); if sided { let sides_btn = icons::button(ui, Icon::IndependentCorners, 24.0, false, "Individual strokes"); - egui::Popup::menu(&sides_btn).show(|ui| { + menu_popup(&sides_btn).show(|ui| { let w = weight.unwrap_or(1.0); for (l, v) in [ ("All", Value::Null), @@ -1008,14 +1042,14 @@ fn width_profile_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], profile: Opt let art = Rect::from_min_max(pos2(r.max.x - 22.0 - 56.0, r.center().y - 5.0), pos2(r.max.x - 22.0, r.center().y + 5.0)); profile_art(ui.painter(), art, p.as_ref(), t.icon_secondary); } - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for preset in WidthPreset::ALL { let prof = preset.profile(); let art = |pt: &egui::Painter, r: Rect, col: Color32| { let a = Rect::from_min_max(pos2(r.max.x - 8.0 - 64.0, r.center().y - 5.0), pos2(r.max.x - 8.0, r.center().y + 5.0)); profile_art(pt, a, prof.as_ref(), col); }; - if menu_item_with(ui, preset.label(), "", art) { + if menu_item_with(ui, preset.label(), "", 8.0 + 64.0, art) { app.run("stroke.widthProfile", json!({"ids": ids, "preset": preset.id()})); ui.close(); } @@ -1044,7 +1078,7 @@ fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] row(ui, 32.0, |ui| { let dashed = dashes.as_ref().is_some_and(|d| !d.is_empty()); let resp = widgets::dropdown(ui, if dashed { "Dash" } else { "Solid" }, half, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { if menu_item(ui, "Solid", "") { app.set_props(&ids, json!({"dashes": []})); ui.close(); @@ -1076,7 +1110,7 @@ fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] None => "Mixed", }; let resp = widgets::dropdown(ui, label, half, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for (l, v) in [("Miter", "miter"), ("Bevel", "bevel"), ("Round", "round")] { if menu_item(ui, l, "") { app.set_props(&ids, json!({"strokeJoin": v})); @@ -1097,9 +1131,9 @@ fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] row(ui, 32.0, |ui| { for (key, cap) in [("startCap", st.as_ref().map(|s| s.cap_start)), ("endCap", st.as_ref().map(|s| s.cap_end))] { let resp = widgets::dropdown(ui, cap.map(StrokeCap::label).unwrap_or("Mixed"), half, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for c in StrokeCap::ALL { - if menu_item_with(ui, c.label(), "", |p, r, col| cap_art(p, r, col, c, key == "startCap")) { + if menu_item_with(ui, c.label(), "", CAP_ART_W, |p, r, col| cap_art(p, r, col, c, key == "startCap")) { app.set_props(&ids, json!({key: format!("{c:?}")})); ui.close(); } @@ -1111,6 +1145,9 @@ fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] ui.add_space(4.0); } +/// The width [`cap_art`] takes at the right of a menu row (its arrows reach past the line's ends). +const CAP_ART_W: f32 = 64.0; + /// A short stroke ending in `cap` (on its left end for a start point), drawn at the right of a menu /// row. fn cap_art(p: &egui::Painter, r: Rect, col: Color32, cap: StrokeCap, start: bool) { @@ -1270,7 +1307,7 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } let w = ui.available_width() - 56.0; let resp = widgets::dropdown(ui, e.label(), w, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for (l, ty) in Effect::TYPES { if menu_item(ui, l, "") && let Some(ne) = Effect::of_type(ty) @@ -1389,7 +1426,7 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: & }; row(ui, 32.0, |ui| { let resp = widgets::dropdown(ui, nz.mode.label(), half * 2.0 + 8.0, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for m in NoiseMode::ALL { if menu_item(ui, m.label(), "") { set(app, with(&|n| n.mode = m), None); @@ -1538,7 +1575,7 @@ fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } let w = ui.available_width() - 56.0; let resp = widgets::dropdown(ui, &grid_label(g), w, None); - egui::Popup::menu(&resp).width(220.0).show(|ui| { + menu_popup(&resp).width(220.0).show(|ui| { for (l, kind) in [("Grid", GridKind::Grid), ("Columns", GridKind::Columns), ("Rows", GridKind::Rows)] { if menu_item(ui, l, "") { let fresh = match kind { @@ -1614,7 +1651,7 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr let aligns = [GridAlign::Stretch, GridAlign::Min, GridAlign::Center, GridAlign::Max]; let cur = aligns.iter().position(|a| *a == g.align).unwrap_or(0); let resp = widgets::dropdown(ui, names.get(cur).copied().unwrap_or_default(), col, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for (k, (name, a)) in names.iter().zip(aligns).enumerate() { if menu_check(ui, k == cur, name, "") { // Fixed-size columns need a size of their own. @@ -1792,7 +1829,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) { let copy = widgets::section_header(ui, "Code", &[(Icon::Export, "Copy")], false).is_some(); row(ui, 32.0, |ui| { let resp = widgets::dropdown(ui, lang.label(), 120.0, Some(Icon::Code)); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for (i, l) in CodeLang::ALL.iter().enumerate() { if menu_item(ui, l.label(), "") { ui.data_mut(|d| d.insert_temp(lang_id, i)); diff --git a/crates/ui-egui/src/section_panel.rs b/crates/ui-egui/src/section_panel.rs index 6bd6315e..13d89eac 100644 --- a/crates/ui-egui/src/section_panel.rs +++ b/crates/ui-egui/src/section_panel.rs @@ -7,7 +7,7 @@ use serde_json::json; use crate::FigApp; use crate::icons::Icon; -use crate::right::{common, menu_item, row}; +use crate::right::{common, menu_item, menu_popup, row}; use crate::widgets; /// Section fill presets: light tints that keep dark text readable. @@ -45,7 +45,7 @@ pub fn status_dropdown(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[Nod None => "Mixed", }; let resp = widgets::dropdown(ui, label, width, Some(Icon::Code)); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for d in DevStatus::ALL { if menu_item(ui, d.label(), "") { app.set_props(ids, json!({"devStatus": d.id()})); diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 2d0433ec..9c4ee143 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -695,7 +695,7 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { }); (family_resp, style_resp) }); - egui::Popup::menu(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()) { set(out, &|d| { d.font_family = f.clone(); @@ -708,7 +708,7 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { } }); if let Some(sr) = style_resp { - egui::Popup::menu(&sr).width(200.0).show(|ui| { + crate::right::menu_popup(&sr).width(200.0).show(|ui| { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for s in figcraft_text::family_styles(&def.font_family) { let mark = if def.font_style == s { "✓ " } else { " " }; diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 5ae5f741..a0fc06a2 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -148,7 +148,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { ui.painter().circle_filled(hr.center() + vec2(0.0, 1.0), 17.0, Color32::from_black_alpha(20)); ui.painter().circle_filled(hr.center(), 16.0, t.panel); ui.painter().text(hr.center(), Align2::CENTER_CENTER, "?", crate::theme::regular(15.0), t.text); - egui::Popup::menu(&hresp).show(|ui| { + crate::right::menu_popup(&hresp).show(|ui| { for (l, c) in [("Keyboard shortcuts", "help.shortcuts"), ("Help page", "help.page"), ("About FigCraft", "help.about")] { if crate::right::menu_item(ui, l, figcraft_engine::catalog::shortcut(c).unwrap_or("")) { app.run(c, json!({})); diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 0261ce2a..94603998 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -13,7 +13,7 @@ use serde_json::{Value, json}; use crate::FigApp; use crate::icons::{self, Icon}; -use crate::right::{common, menu_item, row}; +use crate::right::{common, menu_item, menu_popup, row}; use crate::theme::{Tokens, regular, semibold}; use crate::widgets::{self, FIELD_H, FieldEdit, Lead, PAD}; @@ -137,7 +137,7 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle] row(ui, 32.0, |ui| { let w = ui.available_width(); let resp = widgets::elided_dropdown(ui, family.as_deref().unwrap_or(MIXED), w); - egui::Popup::menu(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + menu_popup(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { if let Some(f) = font_picker(ui, family.as_deref(), resp.clicked()) { remember_family(ui.ctx(), &f); apply_style(app, "fontFamily", json!(f)); @@ -151,7 +151,7 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle] row(ui, 32.0, |ui| { let resp = widgets::elided_dropdown(ui, fstyle.as_deref().unwrap_or(MIXED), half); let fam = family.clone(); - egui::Popup::menu(&resp).width(200.0).show(|ui| match &fam { + menu_popup(&resp).width(200.0).show(|ui| match &fam { Some(fam) => { let list = figcraft_text::family_styles(fam); if list.is_empty() { @@ -191,7 +191,7 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle] None => {} } let more = icons::button(ui, Icon::Chevron, 20.0, false, "Font size"); - egui::Popup::menu(&more).width(90.0).show(|ui| { + menu_popup(&more).width(90.0).show(|ui| { egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { for v in SIZES { let mark = if size == Some(v) { "✓ " } else { " " }; @@ -300,7 +300,7 @@ fn paragraph_rows( /// A menu item that also reports the pointer being over it: (clicked, hovered). fn menu_item_hover(ui: &mut Ui, label: &str) -> (bool, bool) { - let r = crate::right::menu_row(ui, label, "", |_, _, _| {}); + let r = crate::right::menu_row(ui, label, "", 0.0, |_, _, _| {}); (r.clicked(), r.hovered()) } diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 0f5fb88e..fc2ee4b8 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -286,7 +286,7 @@ fn collection_row(app: &mut FigApp, ui: &mut Ui, view: &View, c: &Collection, st if resp.double_clicked() { start_rename(ui, st, target.clone()); } - resp.context_menu(|ui| { + crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, "Rename", "") { start_rename(ui, st, target.clone()); ui.close(); @@ -368,7 +368,7 @@ fn group_rows(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: if resp.double_clicked() { start_rename(ui, st, target.clone()); } - resp.context_menu(|ui| { + crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, "Rename group", "") { start_rename(ui, st, target.clone()); ui.close(); @@ -532,7 +532,7 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: if resp.double_clicked() { start_rename(ui, st, target.clone()); } - resp.context_menu(|ui| { + crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, "Rename mode", "") { start_rename(ui, st, target.clone()); ui.close(); @@ -584,7 +584,7 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va if resp.double_clicked() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x < name_r.max.x) { start_rename(ui, st, target.clone()); } - resp.context_menu(|ui| { + crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, "Edit variable", "") { st.details = Some(v.id.clone()); ui.close(); @@ -766,7 +766,7 @@ fn create_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: ui.horizontal(|ui| { ui.add_space(12.0); let resp = widgets::button(ui, "+ Create variable", false, true); - egui::Popup::menu(&resp).show(|ui| { + crate::right::menu_popup(&resp).show(|ui| { for kind in TYPES { let label = match kind { VariableType::Color => "Color", diff --git a/crates/ui-egui/src/vector_panel.rs b/crates/ui-egui/src/vector_panel.rs index 2614f86b..3c9ee0e6 100644 --- a/crates/ui-egui/src/vector_panel.rs +++ b/crates/ui-egui/src/vector_panel.rs @@ -9,7 +9,7 @@ use serde_json::{Value, json}; use crate::FigApp; use crate::icons::Icon; -use crate::right::{menu_item, row}; +use crate::right::{menu_item, menu_popup, row}; use crate::widgets::{self, FieldEdit, Lead, PAD}; /// Run a point command from a field: typed values are one step, scrubs one step when released. @@ -116,7 +116,7 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { HandleMirroring::AngleAndLength => "Mirror angle and length", }; let resp = widgets::dropdown(ui, mirror.map(label).unwrap_or("Mixed"), col * 2.0 + 8.0, None); - egui::Popup::menu(&resp).show(|ui| { + menu_popup(&resp).show(|ui| { for m in [HandleMirroring::None, HandleMirroring::Angle, HandleMirroring::AngleAndLength] { if menu_item(ui, label(m), "") { app.run("vector.edit.mirroring", json!({"mode": m.id()})); diff --git a/crates/ui-egui/src/versions.rs b/crates/ui-egui/src/versions.rs index adcd609a..5cb52129 100644 --- a/crates/ui-egui/src/versions.rs +++ b/crates/ui-egui/src/versions.rs @@ -375,8 +375,8 @@ fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option