diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index bf30ba9bc..567e260a6 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -41,6 +41,42 @@ pub enum FontPick { Wheel(Vec), } +/// The UI that contains a font family field. +#[derive(Clone, Copy, Debug)] +pub enum WheelOwner { + Character, + Properties, + Control, + /// The Character popover attached to this Control bar link. + ControlPopover(egui::Id), +} + +impl WheelOwner { + fn visible(self, app: &VectorcraftApp, ctx: &egui::Context) -> bool { + if crate::panels::character::text_style(app).is_none() { + return false; + } + let panel = match self { + Self::Control => return app.ui.control_bar && app.ui.screen_mode < 2, + Self::ControlPopover(anchor) => { + return app.ui.control_bar && app.ui.screen_mode < 2 && widgets::popover_is_open(ctx, anchor); + } + Self::Character => "character", + Self::Properties => { + let selected = app.session.active().and_then(|document| document.selection.objects.first().and_then(|id| document.doc.node(*id))); + if !selected.is_some_and(|n| matches!(&n.kind, vectorcraft_doc::NodeKind::Text(_))) { + return false; + } + "properties" + } + }; + app.ui.dock + && app.ui.screen_mode < 3 + && (crate::panel_docking::showing(&app.ui, panel) + || (app.ui.transient_panel.as_deref() == Some(panel) && crate::panel_docking::showing(&app.ui, "appearance"))) + } +} + /// How the menu looks (from the preferences and the favourites) and whether its closed field /// takes the mouse wheel. #[derive(Clone, Copy, Debug)] @@ -58,12 +94,14 @@ pub struct MenuLook<'a> { /// The mouse wheel turned over the closed field steps through the families in the menu's /// order ([`FontPick::Wheel`]): the Character and Properties panels and the Control bar. pub wheel: bool, + /// Its owner, so a field hidden before the next layout cannot capture wheel input. + pub wheel_owner: Option, } impl Default for MenuLook<'static> { /// Samples on, English names, medium rows, no favourites, no wheel. fn default() -> Self { - Self { samples: true, english_names: true, row: ROW, favorites: &[], recent: &[], wheel: false } + Self { samples: true, english_names: true, row: ROW, favorites: &[], recent: &[], wheel: false, wheel_owner: None } } } @@ -81,6 +119,7 @@ impl<'a> MenuLook<'a> { favorites: &app.ui.favorite_fonts, recent: app.recent_fonts(), wheel: false, + wheel_owner: None, } } } @@ -250,7 +289,7 @@ struct MenuState { /// kept while the pointer stays on it: each step makes its family a recent one, which would /// otherwise move it in the order. wheel_order: Option>, - wheel_target: Option, + wheel_target: Option, } /// The font family menu showing `current`, `width` points wide, rows sampling `sample` (the @@ -293,7 +332,7 @@ pub fn font_menu( // Closed (Escape, a click outside) with a preview showing. out = Some(FontPick::EndPreview); } - let turns = remember_wheel_field(ui, &field, look.wheel && !drawn && field.enabled()); + let turns = remember_owned_wheel_field(ui, &field, look.wheel && !drawn && field.enabled(), state_id, look.wheel_owner); if !look.wheel || !field.enabled() { st.wheel_order = None; ui.data_mut(|d| d.remove::(field.id.with("wheel"))); @@ -390,6 +429,8 @@ enum WheelInput { #[derive(Clone, Copy)] struct WheelField { id: egui::Id, + menu: egui::Id, + owner: Option, rect: egui::Rect, layer: egui::LayerId, } @@ -409,20 +450,58 @@ fn wheel_fields_id(viewport: egui::ViewportId) -> egui::Id { egui::Id::unique("font-wheel-fields").with(viewport) } -/// Pending turns belong to the document, dialog and focus that received their input batch. +#[derive(Clone, Copy, PartialEq)] +struct WheelTarget { + input: crate::state::InputGeneration, + workspace: u64, +} + +/// Pending turns and field rectangles belong to their input target and visible workspace. pub(crate) fn sync_wheel_target(app: &VectorcraftApp, ctx: &egui::Context, viewport: egui::ViewportId) { - let target = app.input_generation(); + let target = WheelTarget { input: app.input_generation(), workspace: app.ui.docking_generation }; let key = wheel_fields_id(viewport); + let (previous, mut fields) = ctx.data(|d| (d.get_temp::(key.with("target")), d.get_temp::(key))); + if previous.is_some_and(|old| old != target) { + fields = None; + } + let mut hidden = Vec::new(); + if let Some(fields) = &mut fields { + fields.fields.retain(|field| { + if field.owner.is_none_or(|owner| owner.visible(app, ctx)) { + true + } else { + hidden.push(*field); + false + } + }); + for field in &hidden { + fields.pending.remove(&field.id); + } + } ctx.data_mut(|d| { - if d.get_temp::(key.with("target")).is_some_and(|old| old != target) { + if let Some(fields) = fields { + d.insert_temp(key, fields); + } else { d.remove::(key); } + for field in hidden { + d.remove::(field.id.with("wheel")); + if let Some(mut state) = d.get_temp::(field.menu) { + state.wheel_order = None; + d.insert_temp(field.menu, state); + } + } d.insert_temp(key.with("target"), target); }); } /// Remember the visible closed fields for the next input batch and take this field's turns. +#[cfg(test)] fn remember_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool) -> Vec { + remember_owned_wheel_field(ui, resp, enabled, resp.id, None) +} + +fn remember_owned_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool, menu: egui::Id, owner: Option) -> Vec { let key = wheel_fields_id(ui.ctx().viewport_id()); let pass = ui.ctx().cumulative_pass_nr(); ui.data_mut(|d| { @@ -433,7 +512,7 @@ fn remember_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool) -> Vec Some(crate::font_menu::WheelOwner::Character), + "font" => Some(crate::font_menu::WheelOwner::Properties), + "cb-font" => Some(crate::font_menu::WheelOwner::Control), + _ => None, + }; + font_pickers_owned(app, ui, s, ids, widths, wheel_owner); +} + +fn font_pickers_owned( + app: &mut VectorcraftApp, + ui: &mut Ui, + s: &CharStyle, + ids: (&str, &str), + widths: (f32, f32), + wheel_owner: Option, +) { crate::font_menu::sync_wheel_target(app, ui.ctx(), ui.ctx().viewport_id()); let sample = crate::font_menu::sample_text(app); - let look = crate::font_menu::MenuLook { wheel: true, ..crate::font_menu::MenuLook::of(app) }; + let look = crate::font_menu::MenuLook { wheel: true, wheel_owner, ..crate::font_menu::MenuLook::of(app) }; if let Some(pick) = crate::font_menu::font_menu(ui, ids.0, &s.font_family, widths.0, sample.as_deref(), look) { crate::font_menu::apply(app, ui.ctx(), pick); } @@ -895,7 +912,7 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { .on_hover_text(tl!("Character options")); widgets::popover(&link, link.clicked(), |ui| { ui.set_width(260.0); - show(app, ui); + show_owned(app, ui, crate::font_menu::WheelOwner::ControlPopover(link.id)); }); font_pickers(app, ui, &s, ("cb-font", "cb-font-style"), (150.0, 96.0)); size_field(app, ui, "cb-font-size", 96.0); @@ -907,7 +924,11 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { const SNAP_TO_GLYPH_OPTIONS: &str = "ch-snap-to-glyph-options"; pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { - attributes(app, ui); + show_owned(app, ui, crate::font_menu::WheelOwner::Character); +} + +fn show_owned(app: &mut VectorcraftApp, ui: &mut Ui, owner: crate::font_menu::WheelOwner) { + attributes(app, ui, owner); // Drawing over type needs no type selected, so the options show without one too. if pstate::(ui.ctx(), SNAP_TO_GLYPH_OPTIONS) { snap_to_glyph_options(app, ui); @@ -945,7 +966,7 @@ fn snap_to_glyph_options(app: &mut VectorcraftApp, ui: &mut Ui) { } /// The character attributes of the type selected (an empty state without any). -fn attributes(app: &mut VectorcraftApp, ui: &mut Ui) { +fn attributes(app: &mut VectorcraftApp, ui: &mut Ui, owner: crate::font_menu::WheelOwner) { let Some((s, _)) = text_style(app) else { app.character_focus = None; super::empty_state(ui, "type", tl!("No text selected"), tl!("Select a text object to edit its character attributes.")); @@ -957,7 +978,7 @@ fn attributes(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(f) = app.character_focus { widgets::arm_field_focus(ui.ctx(), f.salt()); } - font_pickers(app, ui, &s, ("ch-font", "ch-style"), (w - 4.0, w - 4.0)); + font_pickers_owned(app, ui, &s, ("ch-font", "ch-style"), (w - 4.0, w - 4.0), Some(owner)); variable_font(app, ui, &s, w - 4.0); ui.add_space(2.0); let hidden = pstate::(ui.ctx(), "ch-hide-options"); diff --git a/crates/ui-egui/src/tests/font_menu.rs b/crates/ui-egui/src/tests/font_menu.rs index 078286133..624c32c7e 100644 --- a/crates/ui-egui/src/tests/font_menu.rs +++ b/crates/ui-egui/src/tests/font_menu.rs @@ -208,6 +208,9 @@ fn selected_inter(app: &mut VectorcraftApp) -> u64 { app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Gagaku", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + if !crate::panel_docking::showing(&app.ui, "character") { + app.run("window.panel", json!({"panel": "character"})).unwrap(); + } id } @@ -450,6 +453,34 @@ fn a_wheel_queued_for_another_document_is_discarded() { assert_eq!(app.session.active().unwrap().history.undo.len(), before); } +/// Presentation mode hides an Appearance row's Character popup even if its state is still open. +#[test] +fn presentation_mode_leaves_a_transient_font_fields_wheel_available() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + selected_inter(&mut app); + app.run("window.panel.close", json!({"panel": "character"})).unwrap(); + app.run("window.panel.close", json!({"panel": "properties"})).unwrap(); + app.run("window.panel", json!({"panel": "appearance"})).unwrap(); + assert!(!crate::panel_docking::showing(&app.ui, "character")); + assert!(!crate::panel_docking::showing(&app.ui, "properties")); + assert!(crate::panel_docking::showing(&app.ui, "appearance")); + app.ui.transient_panel = Some("character".into()); + let draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| crate::panels::character::show(app, ui); + let mut field = None; + for _ in 0..3 { + field = panel_frame(&ctx, &mut app, vec![], "Inter", &draw).0; + } + app.ui.screen_mode = 3; + let mut raw = egui::RawInput { + events: vec![egui::Event::PointerMoved(field.unwrap()), wheel(egui::MouseWheelUnit::Line, -1.0, Default::default())], + ..Default::default() + }; + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. }))); +} + /// #5253: the Properties panel and the Control bar have the same family field, and the wheel steps /// the families over it too. #[test] @@ -465,6 +496,7 @@ fn the_wheel_steps_the_families_in_properties_and_the_control_bar() { crate::theme::install_fonts(&ctx); let mut app = VectorcraftApp::new(Session::new(), Default::default()); let id = selected_inter(&mut app); + app.ui.control_bar = true; let mut field = None; for _ in 0..3 { field = panel_frame(&ctx, &mut app, vec![], "Inter", &draw).0; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0e6bb21a7..b6b4130b8 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -43,7 +43,7 @@ pub use drag::{ }; #[cfg(test)] use dropdowns::combo_button_id; -pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field, combo_named}; +pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field, combo_named, popover_is_open}; pub use dropdowns::{ dropdown, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll, panel_scroll, popover, set_menu_floor, strip_scroll, diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs index 781fd7b78..a9b56e091 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -217,7 +217,7 @@ pub fn popover(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) - let ctx = &resp.ctx; let id = resp.id.with("popover"); let frame = ctx.cumulative_frame_nr(); - let was_open = ctx.data(|d| d.get_temp::(id)).is_some_and(|f| f + 1 >= frame); + let was_open = popover_is_open(ctx, resp.id); let mut open = if toggle { !was_open } else { @@ -246,6 +246,12 @@ pub fn popover(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) - inner } +/// Whether the popover for this anchor was open in the latest completed frame. +pub(crate) fn popover_is_open(ctx: &egui::Context, anchor: egui::Id) -> bool { + let frame = ctx.cumulative_frame_nr(); + ctx.data(|d| d.get_temp::(anchor.with("popover"))).is_some_and(|f| f + 1 >= frame) +} + /// Records where the bottom bars (status and hint bars) start this frame, so menus can stop above /// them (#2086). Without bars it is the bottom of the window. pub fn set_menu_floor(ctx: &egui::Context, y: f32) { diff --git a/crates/ui-egui/tests/font_wheel.rs b/crates/ui-egui/tests/font_wheel.rs new file mode 100644 index 000000000..7f8e0353c --- /dev/null +++ b/crates/ui-egui/tests/font_wheel.rs @@ -0,0 +1,107 @@ +//! Wheel routing when a font field disappears before the next layout pass. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use egui::{Event, MouseWheelSource, MouseWheelUnit, Pos2, RawInput, Rect, Shape, TouchPhase, vec2}; +use serde_json::{Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_ui_egui::VectorcraftApp; + +#[test] +fn hiding_control_between_passes_leaves_its_old_fields_wheel_available() { + assert!(control_wheel_available_after("window.control", json!({}))); +} + +#[test] +fn hiding_properties_preserves_the_visible_control_fields_wheel() { + assert!(!control_wheel_available_after("window.panel.close", json!({"panel": "properties"}))); +} + +#[test] +fn deselecting_type_leaves_the_missing_control_fields_wheel_available() { + assert!(control_wheel_available_after("select.set", json!({"ids": []}))); +} + +#[test] +fn the_control_character_popover_keeps_its_visible_font_wheel() { + let (ctx, mut app) = selected_type_with_control(); + app.run("window.panel.close", json!({"panel": "character"})).unwrap(); + let mut texts = Vec::new(); + for _ in 0..8 { + texts = frame(&mut app, &ctx, vec![]); + } + let link = texts.iter().find(|(t, p)| t == "Character:" && p.y < 100.0).unwrap().1; + let button = |pressed| Event::PointerButton { pos: link, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(link), button(true)]); + frame(&mut app, &ctx, vec![button(false)]); + for _ in 0..3 { + texts = frame(&mut app, &ctx, vec![]); + } + let point = texts + .iter() + .find(|(t, p)| t == "Inter" && p.y > link.y + 10.0 && p.y < link.y + 100.0 && (p.x - link.x).abs() < 100.0) + .expect("the Character popover shows its family field") + .1; + let mut raw = wheel_input(point); + app.raw_input_hook(&ctx, &mut raw); + assert!(!raw.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })), "the visible popover keeps font wheel input"); + app.run("window.control", json!({})).unwrap(); + let mut raw = wheel_input(point); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })), "hiding Control also hides its popover"); +} + +fn selected_type_with_control() -> (egui::Context, VectorcraftApp) { + let ctx = egui::Context::default(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 250})).unwrap(); + let text = app.run("text.create", json!({"x": 30, "y": 70, "text": "Control wheel", "font": "Inter"})).unwrap(); + app.run("select.set", json!({"ids": [text["id"]]})).unwrap(); + app.run("window.control", json!({})).unwrap(); + (ctx, app) +} + +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Pos2)> { + let mut raw = RawInput { events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), ..Default::default() }; + app.raw_input_hook(ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| app.ui(ui)); + let texts = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::Text(t) => Some((t.galley.text().to_string(), t.pos + vec2(4.0, 6.0))), + _ => None, + }) + .collect(); + out.textures_delta.clear(); + texts +} + +fn control_wheel_available_after(command: &str, params: Value) -> bool { + let (ctx, mut app) = selected_type_with_control(); + let mut texts = Vec::new(); + for _ in 0..8 { + texts = frame(&mut app, &ctx, vec![]); + } + let point = texts.iter().find(|(t, p)| t == "Inter" && p.y < 150.0).expect("the Control bar's family field is rendered").1; + app.run(command, params).unwrap(); + let mut raw = wheel_input(point); + app.raw_input_hook(&ctx, &mut raw); + raw.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })) +} + +fn wheel_input(point: Pos2) -> RawInput { + RawInput { + events: vec![ + Event::PointerMoved(point), + Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: TouchPhase::Move, + modifiers: Default::default(), + source: MouseWheelSource::Unknown, + }, + ], + ..Default::default() + } +}