D2: floating panels open in OS windows of their own on desktop; dialogs block them

craft-ui's native-windows feature is on for every non-wasm build. Keys and
dropped files from a floating window reach the main window's next frame, and
while a modal dialog is open the dock area blocks input in floating windows.
Move Panel to Main Window docks the panel again. The web, Wayland and embedded
viewports keep floating windows in the main window.
This commit is contained in:
aiongg
2026-10-25 05:32:15 +00:00
parent 3014f03a81
commit 38be76ddc4
10 changed files with 163 additions and 27 deletions
+2
View File
@@ -33,6 +33,8 @@ kurbo = { workspace = true }
image = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Floating panels open in OS windows of their own on desktop; the web keeps them in the page.
craft-ui = { workspace = true, features = ["native-windows"] }
sys-locale = { workspace = true }
[dev-dependencies]
+1 -1
View File
@@ -409,7 +409,7 @@ pub trait WorkspaceExt {
fn panels(&self) -> Vec<PanelKind>;
}
fn node_panels(node: &Node<PanelKind>, out: &mut Vec<PanelKind>) {
pub(crate) fn node_panels(node: &Node<PanelKind>, out: &mut Vec<PanelKind>) {
match node {
Node::Split { first, second, .. } => {
node_panels(first, out);
+44 -10
View File
@@ -226,17 +226,18 @@ impl EffectcraftApp {
pos.and_then(|p| self.dock_rects.iter().rev().find(|(_, r)| r.contains(p)).map(|(k, _)| *k)).unwrap_or(self.ui.focused)
}
/// Whether panel `p`'s floating window is recorded as its own native window.
/// Whether panel `p` is in a floating window with a desktop place: its own OS window on
/// desktop (craft-ui `native-windows`); on the web and in embedded viewports the window
/// stays in the page and the place is kept for a desktop session.
pub fn in_panel_window(&self, p: PanelKind) -> bool {
self.ui.dock.layout.floating.iter().any(|w| w.contains(&p) && w.native.is_some())
}
/// Move panel `p`'s floating window into its own native window, which can be placed on
/// another monitor, or back into the main window (`window` false). `None` toggles. A docked
/// panel is undocked first. The window's place is over the panel's place in the main window.
/// Returns whether `p` is now in a window of its own.
// D2: craft-ui's `native-windows` feature draws these windows; until then the place is
// recorded and the window is drawn in the main window.
/// Move panel `p` into a floating window of its own, which on desktop is an OS window that
/// can go on another monitor, or back into the main window (`window` false), where it joins
/// the first docked group. `None` toggles. A docked panel is undocked first, and its window
/// opens over the panel's place in the main window. Returns whether `p` is now in a window
/// of its own.
pub fn set_panel_window(&mut self, ctx: &egui::Context, p: PanelKind, window: Option<bool>) -> Result<bool, String> {
let current = self.in_panel_window(p);
let window = window.unwrap_or(!current);
@@ -244,7 +245,21 @@ impl EffectcraftApp {
return Ok(current);
}
if !window {
self.apply_dock(Action::SetNative { panel: p, rect: None })?;
// On desktop every floating window is an OS window, so the way back is docking.
let anchor = self.ui.dock.layout.root.as_ref().and_then(|root| {
let mut panels = Vec::new();
dock::node_panels(root, &mut panels);
panels.into_iter().find(|panel| *panel != p)
});
let actions = match anchor {
Some(anchor) => vec![Action::Move { panel: p, anchor, placement: craft_ui::docking::Placement::Tab { before: None } }],
// Nothing is docked: the panel becomes the docked layout.
None => vec![Action::Close { panel: p }, Action::Open { panel: p, anchor: None }],
};
let mut workspace = self.ui.dock.clone();
// One transaction, so the panel is never reported closed and keeps its state.
let closed = workspace.apply_all(actions, dock::permissions).map_err(|_| format!("can't dock {}", p.title()))?;
self.commit_workspace(workspace, &closed, true);
return Ok(false);
}
if !self.ui.dock.is_floating(p) {
@@ -375,7 +390,11 @@ impl EffectcraftApp {
}
let style = dock_style(ui, &t);
let maximized = self.ui.maximized.filter(|m| self.ui.dock.is_docked(*m));
let mut area = DockArea::new(area_id()).float_bounds(body).maximized(maximized).locked(self.ui.dock.locked);
let mut area = DockArea::new(area_id())
.float_bounds(body)
.maximized(maximized)
.locked(self.ui.dock.locked)
.input_blocked(self.dialog.is_some() || panels::dialogs::modal_open(&ctx));
if let Some(panel) = self.pending_panel_raise.take() {
area = area.raise_panel(panel);
}
@@ -396,9 +415,10 @@ impl EffectcraftApp {
menu: None,
bodies: Vec::new(),
};
let output = area.show_content(&mut child, &layout, &style, &mut content);
let mut output = area.show_content(&mut child, &layout, &style, &mut content);
let EffectContent { doc_actions, locks, menu, bodies, .. } = content;
self.dock_rects = bodies;
self.forward_window_input(&ctx, std::mem::take(&mut output.forwarded_events), std::mem::take(&mut output.window_files));
if let Some(panel) = output.focused {
self.focus_panel(&ctx, panel);
}
@@ -446,6 +466,20 @@ impl EffectcraftApp {
panels::panel_menu_popup(self, ui);
}
/// Keys and dropped files that reach a floating panel's own OS window go to the main
/// window's next frame ([`crate::EffectcraftApp::raw_input_hook`]), so its shortcuts and
/// file import handle them. Text is not forwarded, and nothing is while a text field in
/// the main window has the keyboard.
fn forward_window_input(&mut self, ctx: &egui::Context, events: Vec<egui::Event>, files: Vec<craft_ui::docking::WindowFiles<PanelKind>>) {
let typing = ctx.egui_wants_keyboard_input();
let events = events.into_iter().filter(|event| !typing && !matches!(event, egui::Event::Text(_) | egui::Event::Ime(_)));
self.forwarded.events.extend(events);
self.forwarded.files.extend(files.into_iter().flat_map(|window| window.dropped));
if !self.forwarded.events.is_empty() || !self.forwarded.files.is_empty() {
ctx.request_repaint();
}
}
/// Automation ids for craft-ui's gesture sources: `panel.tab.<Panel>`, `panel.drag.<Panel>`
/// (the group's drag bar), `panel.tabs.more.<Panel>`, `panel.header.<Panel>` (a stacked
/// panel's open/close button), `panel.float.<n>.move`, `.close`, `.resize` (bottom-right
+12
View File
@@ -278,6 +278,8 @@ pub struct EffectcraftApp {
pub(crate) control_rx: Option<Receiver<ControlRequest>>,
pub(crate) deferred: Vec<(ControlRequest, f64)>,
pub(crate) synthetic: Vec<egui::Event>,
/// Input from floating panels' own OS windows for the main window's next frame.
pub(crate) forwarded: ForwardedInput,
pub(crate) input_waiters: Vec<Sender<serde_json::Value>>,
/// Control requests waiting for an offloaded job (`wait: true` in the browser).
pub(crate) job_waiters: Vec<(control::JobWaiter, Sender<serde_json::Value>)>,
@@ -381,6 +383,7 @@ impl EffectcraftApp {
control_rx: None,
deferred: vec![],
synthetic: vec![],
forwarded: ForwardedInput::default(),
input_waiters: vec![],
job_waiters: vec![],
queued_screenshots: vec![],
@@ -1874,6 +1877,13 @@ impl EffectcraftApp {
}
}
/// Keys and dropped files from floating panels' OS windows (craft-ui `DockOutput`).
#[derive(Default)]
pub(crate) struct ForwardedInput {
pub(crate) events: Vec<egui::Event>,
pub(crate) files: Vec<egui::DroppedFileHandle>,
}
impl eframe::App for EffectcraftApp {
fn on_exit(&mut self) {
self.save_workspace_state();
@@ -1885,6 +1895,8 @@ impl eframe::App for EffectcraftApp {
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
raw_input.events.append(&mut self.forwarded.events);
raw_input.dropped_files.append(&mut self.forwarded.files);
let injected = !self.synthetic.is_empty();
self.feed_synthetic(raw_input);
if pointer_wrap::prepare_input(ctx, raw_input, injected) {
+37
View File
@@ -170,6 +170,18 @@ pub(crate) fn modal_space(ctx: &egui::Context) -> egui::Vec2 {
(ctx.content_rect().size() - vec2(48.0, 48.0)).max(vec2(120.0, 80.0))
}
fn modal_pass_id() -> egui::Id {
egui::Id::new("effectcraft-modal-pass")
}
/// Whether a modal dialog showed in this pass or the one before: egui's `Modal` blocks input
/// only in its own window, so floating panels in OS windows of their own are blocked by the
/// dock area while this holds. Dialogs draw after the dock, so the previous pass counts.
pub(crate) fn modal_open(ctx: &egui::Context) -> bool {
let shown = ctx.data(|d| d.get_temp::<u64>(modal_pass_id()));
shown.is_some_and(|pass| pass.saturating_add(1) >= ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT))
}
pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) {
modal_with_scroll(ctx, title, size, t, false, body);
}
@@ -190,6 +202,8 @@ fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tok
/// only its content: `body` gets the height left under the title, with `size` kept within the
/// window.
pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) {
let pass = ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT);
ctx.data_mut(|d| d.insert_temp(modal_pass_id(), pass));
let size = size.min(modal_space(ctx));
let id = egui::Id::new(("modal", title));
// Dragging the title row moves the dialog off what it edits. The offset is kept within the
@@ -687,6 +701,29 @@ fn palette(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
mod tests {
use super::*;
#[test]
fn modal_open_holds_from_a_shown_dialog_until_the_pass_after_it_closes() {
let (app, ctx) = app();
let t = app.tokens;
// Asked as the dock asks it: during the pass, before the dialogs draw.
let pass = |show: bool| {
let mut open = false;
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
open = modal_open(ui.ctx());
if show {
modal(ui.ctx(), "Test", vec2(200.0, 100.0), &t, |_| {});
}
});
output.textures_delta.clear();
open
};
assert!(!pass(false));
assert!(!pass(true), "the first pass of a dialog finds none shown yet");
assert!(pass(true));
assert!(pass(false), "the pass after the last one shown still counts");
assert!(!pass(false));
}
fn app() -> (EffectcraftApp, egui::Context) {
let ctx = egui::Context::default();
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
+2 -3
View File
@@ -234,9 +234,8 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
let _ = app.dock_command(ui.ctx(), craft_ui::docking::Action::Float { panel, rect: r });
close = true;
}
// Multiple monitors: a native window of its own, which can go on another screen.
// D2: craft-ui's native windows draw it; until then the place is recorded and the
// panel floats in the main window.
// Multiple monitors: an OS window of its own, which can go on another screen. Where
// egui embeds viewports (the web), floating windows stay in the page.
if !ui.ctx().embed_viewports() {
let windowed = app.in_panel_window(panel);
if ui.button(if windowed { tr("Move Panel to Main Window") } else { tr("Move Panel to New Window") }).clicked() {
+45 -6
View File
@@ -1,7 +1,7 @@
//! Multiple monitors (#486): Move Panel to New Window records a floating panel's window as its
//! own native window, which can be placed on another screen, and back.
// D2: craft-ui's `native-windows` feature draws these windows; until then they are drawn in the
// main window and only their place is recorded and saved.
//! Multiple monitors (#486): Move Panel to New Window puts a panel in a floating window of its
//! own, which on desktop is an OS window that can go on another screen, and Move Panel to Main
//! Window docks it again. The test harness embeds viewports, so these windows draw in the main
//! window here, as on the web.
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::{PanelKind, WorkspaceExt as _};
@@ -31,9 +31,10 @@ fn panel_window_command_moves_a_docked_panel_into_its_own_window_and_back() {
// Asking again changes nothing; no `window` toggles.
assert_eq!(invoke(&mut app, &ctx, json!({"panel": "Project", "window": true})).unwrap(), json!({"window": true}));
assert_eq!(invoke(&mut app, &ctx, json!({"panel": "Project"})).unwrap(), json!({"window": false}));
// Back in the main window, still floating where it was.
// Back in the main window: docked in the first docked group.
assert!(!app.in_panel_window(PanelKind::Project));
assert!(app.ui.dock.is_floating(PanelKind::Project));
assert!(!app.ui.dock.is_floating(PanelKind::Project));
assert!(app.ui.dock.is_docked(PanelKind::Project));
// The serialized panel form works too.
assert_eq!(invoke(&mut app, &ctx, json!({"panel": PanelKind::Project})).unwrap(), json!({"window": true}));
// Bad requests are errors and change nothing.
@@ -65,3 +66,41 @@ fn panel_windows_survive_restart_with_hostile_places_made_safe() {
assert!(x <= 100_000.0 && y >= -100_000.0 && (160.0..=16_384.0).contains(&w) && (100.0..=16_384.0).contains(&h));
assert!(sanitized_window_rect([f32::NAN, f32::INFINITY, f32::NAN, f32::NEG_INFINITY]).iter().all(|v| v.is_finite()));
}
#[test]
fn a_click_on_a_floating_panel_does_nothing_while_a_dialog_is_open() {
use egui::{Event, Modifiers, PointerButton, pos2, vec2};
use egui_kittest::Harness;
let mut app = EffectcraftApp::new(Session::default());
app.ui.start_screen = false;
app.apply_dock(craft_ui::docking::Action::Float { panel: PanelKind::Project, rect: [80.0, 120.0, 320.0, 240.0] }).unwrap();
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_| app);
h.run_steps(3);
let close = |h: &Harness<'_, EffectcraftApp>| {
let e = h.state().auto.elements.iter().find(|e| e.id.starts_with("panel.float.") && e.id.ends_with(".close")).expect("the floating window's ×");
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
};
let click = |h: &mut Harness<'_, EffectcraftApp>, pos| {
h.event(Event::PointerMoved(pos));
h.step();
for pressed in [true, false] {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
};
let at = close(&h);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "general"})).unwrap();
h.run_steps(3);
let before = h.state().ui.dock.clone();
click(&mut h, at);
assert_eq!(h.state().ui.dock, before, "the floating panel takes no click while Settings is open");
assert!(h.state().ui.dock.is_floating(PanelKind::Project));
// The same click closes the window once the dialog is gone.
h.state_mut().dialog = None;
h.run_steps(3);
let at = close(&h);
click(&mut h, at);
assert!(!h.state().ui.dock.is_open(PanelKind::Project));
}
+18 -5
View File
@@ -216,9 +216,16 @@ takes a name, or as `{"Viewer":42}` and `{"ScriptPanel":77}` for viewers and Scr
side zone docks along that edge of the whole workspace. `window.floatPanel {panel, rect?}`
requires exactly four finite numbers when `rect` is given and otherwise floats a 420x320
window in the middle of the app window. `window.closePanel {panel}` closes it.
- `window.panelWindow {panel, window?}` records a floating panel's window as its own native
window (`window` false moves it back; without `window` it toggles). A docked panel floats
first. The window is drawn in the main window in this version.
- `window.panelWindow {panel, window?}` puts a panel in a floating window of its own with a
desktop place over the panel's place in the main window, and returns `{"window": bool}`. A
docked panel floats first. `window` false docks it again, as a tab of the first docked group;
without `window` it toggles.
On desktop every floating window is an OS window of its own (craft-ui's `native-windows`), which
can go on another monitor; on Wayland, on the web and where egui embeds viewports, floating
windows stay in the main window. Keys pressed in a floating window that none of its widgets uses
run the main window's shortcuts, and files dropped on one import as on the main window. While a
modal dialog is open, floating windows take no input.
The automation ids of the docking chrome are `panel.tab.<Panel>`, `panel.drag.<Panel>` (a
group's drag bar), `panel.tabs.more.<Panel>`, `panel.menu.<Panel>` (≡), `panel.header.<Panel>`
@@ -229,5 +236,11 @@ group's drag bar), `panel.tabs.more.<Panel>`, `panel.menu.<Panel>` (≡), `panel
`s<i>`) and `dock.corner.<outer>.<inner>` where two dividers meet.
Saved workspaces (`workspaces.json`, version 2) hold craft-ui `Workspace`s: the layout, the
closed panels with where each goes back, and the lock. A file from an earlier version loads as
the default layout and is left unchanged.
closed panels with where each goes back, the lock, and each floating window's desktop place. A
version 1 file (EffectCraft 0.10 and earlier) and the older unversioned record of
named workspaces load with every panel in place: the current layout, the named workspaces,
floating windows and their native window places. A stacked frame with several tabs becomes one
stacked panel per tab, the shown one keeping the frame's open state and height. Panels that were
closed stay closed and reopen at their default place. The first save writes version 2 and keeps
the earlier file as `workspaces.old.json`. A damaged, unknown or newer file loads as the default
layout and is left unchanged.
+1 -1
View File
@@ -76,7 +76,7 @@ are grouped by area; the issue tracker has the rest.
| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) |
| 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) |
| 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) |
| 20 | **Multi-monitor and video output** | #486: any panel can move into a native window of its own (panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so onto another screen, restored with the workspace; still missing: dragging tabs between those windows, app shortcuts while one has the focus, named workspaces remembering them, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |
| 20 | **Multi-monitor and video output** | #486: on desktop every floating panel is an OS window of its own (Undock Panel, a drag out of the main window, panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so can go onto another screen; tabs and groups drag between windows, shortcuts work in them, and the current and named workspaces restore their places; still missing: Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |
| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), pasting Illustrator's own clipboard data as shapes (26.3; SVG markup on the clipboard pastes as a shape layer, #738), variable-font filter (26.3), the rebuilt Effect Controls (26.5). Percentage guides (View ▸ Add Percentage Guide, Convert Guides to Percentage / Pixels) and effect colour labels (Effect Controls header bar and right-click Label menu, `effect.setLabel`) are in (#1095), built from the release notes' one-line descriptions and never compared with After Effects: the Timeline's effect rows wear the label as a colour bar on the left edge (#1509), but After Effects' exact UI there is unknown | Users of the current version expect them | 10–20 h left | — · [ui-parity.md](ui-parity.md) |
| 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372 closed by #778: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) |
| 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 and #611 closed | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) |
+1 -1
View File
@@ -16,7 +16,7 @@ feel and platform differences, and the 26.x interface changes are not in yet.
| Area | Status | % | Evidence and gaps |
|---|---|---:|---|
| Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) |
| Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; panels move into their own native windows on any screen (#486 open: no dragging tabs between those windows); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed |
| Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; on desktop floating panels are OS windows of their own on any screen, with tabs and groups dragging between windows and shortcuts working in them (#486); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed |
| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a hidden null, camera or light has no box or handles in the viewer and isn't picked there; other selected hidden layers keep their layer controls and paths, #1769); a row control (a switch, the label swatch, a Modes cell) clicked on one of several selected layers acts on the whole selection, with the layers the switch doesn't apply to left out (#1849); a shape group's inner Contents (After Effects' elided Vectors group) draws no row — the group's path, fill/stroke and Transform list directly under it |
| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); anchor point grab on Linux (#492); #352 (snapping) is closed; no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu |
| Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed |