ui: draw a floating panel's menu in its own window

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.
This commit is contained in:
marshiyar committed 2026-10-30 20:02:27 -07:00
1 parent 7a9572d8b9
commit 3d0e04dfd8
2 files changed
+169 -10

No files matched your search

+39 -6
View File
@@ -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::<panels::OpenPanelMenu>(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::<PanelKind>::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<DocAction>,
/// Viewer locks clicked.
locks: Vec<PanelKind>,
/// 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<panels::OpenPanelMenu>,
/// 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<egui::ViewportId>,
/// Windows that published this pass.
published: Vec<egui::ViewportId>,
}
@@ -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<egui::ViewportId> {
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::<panels::OpenPanelMenu>(id).is_some_and(|(_, _, owner)| owner != egui::ViewportId::ROOT && !windows.contains(&owner)) {
d.remove::<panels::OpenPanelMenu>(id);
}
});
}
/// The OS window craft-ui opens for the floating window `id`.
#[cfg(not(target_arch = "wasm32"))]
fn window_viewport(id: u64) -> Option<egui::ViewportId> {
@@ -790,6 +818,11 @@ impl DockContent<PanelKind> 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<PanelKind> 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()));
}
}
+130 -4
View File
@@ -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::<OpenPanelMenu>(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::<OpenPanelMenu>(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::<OpenPanelMenu>(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::<OpenPanelMenu>(menu_id(), (PanelKind::Timeline, egui::pos2(20.0, 20.0), owner)));
}
fn is_open(ctx: &egui::Context) -> bool {
ctx.data(|d| d.get_temp::<OpenPanelMenu>(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<egui::Event>) -> Option<egui::Rect> {
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<egui::Event> {
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");
}
}