From 3d0e04dfd808ce90ca9b56badf68654067fefe11 Mon Sep 17 00:00:00 2001 From: marshiyar <57055687+marshiyar@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:02:27 -0700 Subject: [PATCH] ui: draw a floating panel's menu in its own window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ≡ menu of a panel in its own OS window ("Move Panel to New Window") was a root-layer Area drawn by the main window's pass at window-local coordinates, so it opened in the main window at the wrong place. The open menu now remembers the viewport that owns the click and is drawn by that viewport's pass (after the window's last panel body); the main window draws only its own panels' menus. The menu takes at most its window's width (a floated panel can be narrower than the menu), a press in another window dismisses it, and a menu left behind by a window that closed is dropped. Ports the FilmCraft fix (filmcraft#3633). Tested: new unit tests in panels.rs run passes of two viewports against one context: the menu is drawn in the owner's pass and not the main window's, a main-window menu stays there, a 160 pt window holds the menu, a press in another window closes it, and a closed window's menu is dropped. effectcraft-ui-egui lib tests (387), clippy -D warnings, fmt. --- crates/ui-egui/src/dock_ui.rs | 45 ++++++++++-- crates/ui-egui/src/panels.rs | 134 +++++++++++++++++++++++++++++++++- 2 files changed, 169 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index b66267d4d..7f6588a6a 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -514,9 +514,12 @@ impl EffectcraftApp { self.ui.status = error; } } - if let Some((panel, pos)) = menu { - ctx.data_mut(|d| d.insert_temp(egui::Id::unique("panel-menu"), (panel, pos))); + if let Some(open) = menu { + ctx.data_mut(|d| d.insert_temp::(egui::Id::unique("panel-menu"), open)); + // the owner window draws it on its next pass + ctx.request_repaint(); } + drop_menu_of_closed_window(&ctx, &self.native_viewports); let hits = DockArea::::hit_rects(&ctx, area_id()); self.register_dock_hits(&hits); panels::panel_menu_popup(self, ui); @@ -651,8 +654,8 @@ struct EffectContent<'a> { doc_actions: Vec, /// Viewer locks clicked. locks: Vec, - /// The ≡ button clicked: its panel and where its menu opens. - menu: Option<(PanelKind, egui::Pos2)>, + /// The ≡ button clicked: its panel, where its menu opens and the viewport it was clicked in. + menu: Option, /// Where each panel body was drawn, in drawing order (floating windows last). bodies: Vec<(PanelKind, Rect)>, /// Where each native window's IME descriptor is set up and published. @@ -672,6 +675,8 @@ pub(crate) struct NativeIme { expected: Vec<(egui::ViewportId, usize)>, /// The bodies each native window has drawn so far, in the order the windows began. drawn: Vec<(egui::ViewportId, usize)>, + /// Windows that drew their panel menu this pass. + menu_drawn: Vec, /// Windows that published this pass. published: Vec, } @@ -688,7 +693,7 @@ impl NativeIme { Some((viewport, count)) }) .collect(); - Self { expected, drawn: Vec::new(), published: Vec::new() } + Self { expected, drawn: Vec::new(), menu_drawn: Vec::new(), published: Vec::new() } } /// Count a body of `viewport` that starts drawing. True for the window's first body: its @@ -719,6 +724,18 @@ impl NativeIme { publishes } + /// Whether the body of `viewport` that just drew is the one that draws the window's panel + /// menu: after the window's last body, once per pass. + pub(crate) fn menu_turn(&mut self, viewport: egui::ViewportId) -> bool { + let drawn = self.drawn.iter().find(|(v, _)| *v == viewport).map_or(0, |(_, n)| *n); + let expected = self.expected.iter().find(|(v, _)| *v == viewport).map_or(0, |(_, n)| *n); + let turn = drawn >= expected && !self.menu_drawn.contains(&viewport); + if turn { + self.menu_drawn.push(viewport); + } + turn + } + /// The native windows whose pass began. pub(crate) fn begun(&self) -> Vec { self.drawn.iter().map(|(v, _)| *v).collect() @@ -730,6 +747,17 @@ impl NativeIme { } } +/// A panel menu left open by a panel window that has since closed has nowhere to draw: drop it. +/// `windows` are the panel windows that drew this pass. +pub(crate) fn drop_menu_of_closed_window(ctx: &egui::Context, windows: &[egui::ViewportId]) { + let id = egui::Id::unique("panel-menu"); + ctx.data_mut(|d| { + if d.get_temp::(id).is_some_and(|(_, _, owner)| owner != egui::ViewportId::ROOT && !windows.contains(&owner)) { + d.remove::(id); + } + }); +} + /// The OS window craft-ui opens for the floating window `id`. #[cfg(not(target_arch = "wasm32"))] fn window_viewport(id: u64) -> Option { @@ -790,6 +818,11 @@ impl DockContent for EffectContent<'_> { if native && self.ime.publishes(viewport, panels::viewer_text::ime_recorded(ui.ctx())) { panels::viewer_text::finish_ime(self.app, ui); } + // The ≡ menu of a panel in its own OS window is drawn by that window's pass, after its + // last body, at that window's coordinates. + if native && self.ime.menu_turn(viewport) { + panels::panel_menu_area(self.app, ui); + } // The body fills its frame; allocating it lets a floated panel keep its full size. ui.allocate_rect(rect, Sense::hover()); // Painted after the body so child backgrounds cannot hide the focus outline. @@ -843,7 +876,7 @@ impl DockContent for EffectContent<'_> { crate::widgets::focus_outline(ui, &response, &t); if response.clicked() { self.app.ui.focused = *active; - self.menu = Some((*active, rect.left_bottom())); + self.menu = Some((*active, rect.left_bottom(), ui.ctx().viewport_id())); } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 4cc544c7c..933f90c71 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -300,14 +300,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec } } -/// The ≡ panel menu (opened from a tab). +/// The open ≡ panel menu: the panel, where it opens and the viewport that owns the click. +pub(crate) type OpenPanelMenu = (PanelKind, egui::Pos2, egui::ViewportId); + +/// The main window's pass of the ≡ panel menu (opened from a tab). pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { + panel_menu_area(app, ui); +} + +/// The ≡ panel menu, drawn in the viewport that owns the click that opened it: a panel in its own +/// OS window opens its menu in that window, at that window's coordinates, instead of in the main +/// window. +pub(crate) fn panel_menu_area(app: &mut EffectcraftApp, ui: &mut egui::Ui) { let id = egui::Id::unique("panel-menu"); - let Some((panel, pos)) = ui.ctx().data(|d| d.get_temp::<(PanelKind, egui::Pos2)>(id)) else { return }; + let viewport = ui.ctx().viewport_id(); + let Some((panel, pos, owner)) = ui.ctx().data(|d| d.get_temp::(id)) else { return }; + if owner != viewport { + // a press in another window dismisses the menu, as a press elsewhere in its own does + if ui.ctx().input(|i| i.pointer.any_pressed()) { + ui.ctx().data_mut(|d| d.remove::(id)); + } + return; + } let mut close = false; let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(200.0); + // a small window (a floated panel can be narrower than the menu) gets the menu at its width + let width = (ui.ctx().content_rect().width() - 16.0).max(0.0); + ui.set_min_width(200.0_f32.min(width)); + ui.set_max_width(width); if ui.button(tr("Close Panel")).clicked() { let _ = app.dock_command(ui.ctx(), craft_ui::docking::Action::Close { panel }); close = true; @@ -371,7 +392,7 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { }); }); if close || crate::widgets::pressed_outside(ui.ctx(), &area.response) { - ui.ctx().data_mut(|d| d.remove::<(PanelKind, egui::Pos2)>(id)); + ui.ctx().data_mut(|d| d.remove::(id)); } } @@ -389,3 +410,108 @@ mod preset_drop_tests { assert_eq!(PresetRef::Text("typewriter".into()).command(), ("layer.applyTextPreset", json!({"preset": "typewriter"}))); } } + +#[cfg(test)] +mod panel_menu_viewport_tests { + use super::*; + use effectcraft_engine::Session; + + fn menu_id() -> egui::Id { + egui::Id::unique("panel-menu") + } + + fn open(ctx: &egui::Context, owner: egui::ViewportId) { + ctx.data_mut(|d| d.insert_temp::(menu_id(), (PanelKind::Timeline, egui::pos2(20.0, 20.0), owner))); + } + + fn is_open(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(menu_id())).is_some() + } + + /// One pass of `viewport` (a window `width` wide) that draws the menu as the app does: the + /// main window through `panel_menu_popup`, a panel's own window through `panel_menu_area`. + /// Returns where the menu was drawn in that pass. + fn pass(app: &mut EffectcraftApp, ctx: &egui::Context, viewport: egui::ViewportId, width: f32, events: Vec) -> Option { + let mut viewports = egui::ViewportIdMap::default(); + viewports.insert(viewport, egui::ViewportInfo::default()); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0)); + let raw = egui::RawInput { viewport_id: viewport, viewports, screen_rect: Some(screen), events, ..Default::default() }; + let mut drawn = None; + let mut out = ctx.run_ui(raw, |ui| { + if viewport == egui::ViewportId::ROOT { + panel_menu_popup(app, ui); + } else { + panel_menu_area(app, ui); + } + drawn = ui.ctx().memory(|m| m.area_rect(menu_id().with("area"))); + }); + out.textures_delta.clear(); + drawn + } + + fn window() -> egui::ViewportId { + egui::ViewportId::from_hash_of("panel-window") + } + + fn press(pos: egui::Pos2) -> Vec { + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }, + ] + } + + #[test] + fn a_floating_panels_menu_is_drawn_in_its_own_window_not_the_main_one() { + let mut app = EffectcraftApp::new(Session::default()); + let ctx = egui::Context::default(); + open(&ctx, window()); + assert_eq!(pass(&mut app, &ctx, egui::ViewportId::ROOT, 1000.0, vec![]), None, "the main window does not draw it"); + assert!(is_open(&ctx), "and does not drop it"); + let rect = pass(&mut app, &ctx, window(), 1000.0, vec![]).expect("the panel's window draws it"); + assert!((rect.min.x - 20.0).abs() < 1.0, "it opens at the window-local position: {rect:?}"); + } + + #[test] + fn a_main_window_panels_menu_stays_in_the_main_window() { + let mut app = EffectcraftApp::new(Session::default()); + let ctx = egui::Context::default(); + open(&ctx, egui::ViewportId::ROOT); + assert!(pass(&mut app, &ctx, window(), 1000.0, vec![]).is_none()); + assert!(pass(&mut app, &ctx, egui::ViewportId::ROOT, 1000.0, vec![]).is_some()); + } + + #[test] + fn a_floating_panels_menu_fits_a_window_narrower_than_the_menu() { + let mut app = EffectcraftApp::new(Session::default()); + let ctx = egui::Context::default(); + open(&ctx, window()); + let rect = pass(&mut app, &ctx, window(), 160.0, vec![]).unwrap(); + assert!(rect.max.x <= 160.5, "the menu fits the 160 pt window: {rect:?}"); + } + + #[test] + fn a_press_in_another_window_closes_a_floating_panels_menu() { + let mut app = EffectcraftApp::new(Session::default()); + let ctx = egui::Context::default(); + open(&ctx, window()); + pass(&mut app, &ctx, window(), 1000.0, vec![]); + assert!(is_open(&ctx)); + pass(&mut app, &ctx, egui::ViewportId::ROOT, 1000.0, press(egui::pos2(600.0, 300.0))); + assert!(!is_open(&ctx), "a click in the main window dismisses it"); + } + + #[test] + fn a_menu_left_in_a_closed_window_is_dropped() { + let ctx = egui::Context::default(); + let other = egui::ViewportId::from_hash_of("other-window"); + open(&ctx, window()); + crate::dock_ui::drop_menu_of_closed_window(&ctx, &[other]); + assert!(!is_open(&ctx), "its window is gone"); + open(&ctx, window()); + crate::dock_ui::drop_menu_of_closed_window(&ctx, &[window()]); + assert!(is_open(&ctx), "its window is still there"); + open(&ctx, egui::ViewportId::ROOT); + crate::dock_ui::drop_menu_of_closed_window(&ctx, &[]); + assert!(is_open(&ctx), "the main window is always there"); + } +}