collective

Browse / github.com/storytold/designcraft / patch

Window and panels: the app opens as it was left; floating panels keep their size

The app doesn't keep its window, dock or floating panel sizes **What happens:** - **Floating panels:** only a floating panel's position is kept. It comes back at a fixed 280×360 (300×400 when floated by command) after a restart, after docking and floating it again, when it is torn off the dock, and on the first change of the panel layout. - **The dock:** its width is not kept. - **The application window:** it always opens at 1440×900, even when it was left maximized, in full screen, or at another size. **Steps:** 1. Float a panel and drag it larger. Drag the dock wider and maximize the window. 2. Quit and start the app again. 3. The window opens at 1440

Status
open
Score
0.00
Adopters
0 other operators
Author
PiMinusMax (imported from pull request #1825, open)
Base
ed8aabd1dc5a88652a076631c2d46886ca60c031
Size
+304 −5 in 6 files
Review
pending
Shared
2026-10-28
Id
55b7f91650c7ab44686b1b70

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md apps/designcraft/src/main.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/panel_docking.rs crates/ui-egui/src/panel_docking_tests.rs

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -380,6 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m  Newest first. +- **2026-10-25** · The app opens as it was left: the window's size, maximized or full screen (Wayland too, which ignores a window created maximized), fitted to a smaller monitor than the one its size was saved on. A panel floats again at the size it last floated at, after docking and floating it, a restart or another workspace, instead of its fixed default. Older preference and workspace files still load. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. - **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change. - **2026-10-19** · Keyboard shortcuts run on the shared craft-keymap crate. Panels take shortcuts, with InDesign's defaults where the key is free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tool keys are editable in Edit › Keyboard Shortcuts and `window.setShortcut`, and tools sharing a key take turns (B). Saved shortcuts load unchanged. No readiness percentage change.diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs--- a/apps/designcraft/src/main.rs+++ b/apps/designcraft/src/main.rs@@ -199,10 +199,22 @@ fn load_prefs(app: &mut DesignApp) {     } } +/// The application window as the saved UI state left it, read before the window opens.+fn saved_window() -> Option<designcraft_ui_egui::WindowState> {+    if no_prefs() {+        return None;+    }+    let bytes = std::fs::read(prefs_path()?).ok()?;+    let window = designcraft_ui_egui::UiState::from_preferences_json(&bytes).ok()?.window;+    log::info!("window: saved state {window:?}");+    window+}+ fn save_prefs(app: &DesignApp) {     if no_prefs() {         return;     }+    log::info!("window: saving state {:?}", app.ui.window);     if let Some(p) = prefs_path() {         let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));         if !KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed)@@ -441,10 +453,16 @@ fn main() -> std::process::ExitCode {             Err(e) => log::warn!("no log file: {e}"),         }     }+    // The window as it was left: its size, maximized or full screen.+    let window = saved_window();+    let size =+        window.and_then(|w| w.size).filter(|s| s.iter().all(|v| v.is_finite())).map_or([1440.0, 900.0], |s| [s[0].max(900.0), s[1].max(560.0)]);     let mut options = eframe::NativeOptions {         viewport: egui::ViewportBuilder::default()             .with_title("DesignCraft")-            .with_inner_size([1440.0, 900.0])+            .with_inner_size(size)+            .with_maximized(window.is_some_and(|w| w.maximized))+            .with_fullscreen(window.is_some_and(|w| w.fullscreen))             .with_min_inner_size([900.0, 560.0])             .with_decorations(!cfg!(target_os = "windows"))             .with_drag_and_drop(true)diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -171,6 +171,10 @@ pub struct SavedWorkspace {     pub dock_expanded: bool,     pub open_panel: Option<String>,     pub floating: Vec<(String, [f32; 2])>,+    /// Floating panel sizes (see [`UiState::panel_sizes`]). `None` in workspace files from before+    /// sizes were kept: the panels keep the sizes they have.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub panel_sizes: Option<std::collections::BTreeMap<String, [f32; 2]>>,     /// Custom panel membership and placement; absent in older workspace files.     #[serde(default, skip_serializing_if = "Option::is_none")]     pub docking: Option<craft_ui::docking::Layout<String>>,@@ -246,6 +250,76 @@ pub(crate) fn second_window_viewport() -> egui::ViewportId {     egui::ViewportId::from_hash_of("designcraft_second_window") } +/// The application window as it was left (see [`UiState::window`]).+#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]+#[serde(default, rename_all = "camelCase")]+pub struct WindowState {+    /// Its inner size when neither maximized nor full screen, in the window system's logical+    /// points (what the window is created with), not egui's points, which UI Scaling enlarges.+    pub size: Option<[f32; 2]>,+    pub maximized: bool,+    pub fullscreen: bool,+}++impl WindowState {+    /// The window as egui reports it this frame, over what was known (`self`): the size only+    /// while it is an ordinary window, so restoring from maximized gets the size it had.+    /// `content` is the window's content size, for window systems that report no window rect+    /// (Wayland gives no position); `zoom` is UI Scaling's zoom factor, by which egui's points+    /// are larger than the window system's.+    pub fn observe(self, vp: &egui::ViewportInfo, content: egui::Vec2, zoom: f32) -> WindowState {+        let maximized = vp.maximized.unwrap_or(self.maximized);+        let fullscreen = vp.fullscreen.unwrap_or(self.fullscreen);+        let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 };+        let inner = vp.inner_rect.map_or(content, |r| r.size()) * zoom;+        let size = if !maximized && !fullscreen && inner.x.is_finite() && inner.y.is_finite() && inner.x > 0.0 && inner.y > 0.0 {+            Some([inner.x.round(), inner.y.round()])+        } else {+            self.size+        };+        WindowState { size, maximized, fullscreen }+    }++    /// Whether the window went from maximized or full screen (`self`) to an ordinary one (`now`).+    pub fn unmaximized_to(self, now: WindowState) -> bool {+        (self.maximized || self.fullscreen) && !(now.maximized || now.fullscreen)+    }++    /// What to tell the window that just opened (`vp`, `content` as in [`Self::observe`]) to be+    /// as it was left (`self`): maximized or full screen when it isn't yet; else a size that fits+    /// the monitor ([`Self::fit`]).+    pub fn restore(self, vp: &egui::ViewportInfo, content: egui::Vec2) -> Vec<egui::ViewportCommand> {+        if self.fullscreen && vp.fullscreen != Some(true) {+            return vec![egui::ViewportCommand::Fullscreen(true)];+        }+        if self.maximized && vp.maximized != Some(true) {+            return vec![egui::ViewportCommand::Maximized(true)];+        }+        if self.fullscreen || self.maximized {+            return Vec::new();+        }+        Self::fit(vp, content).into_iter().collect()+    }++    /// A window larger than the monitor it opened on (its size saved on a bigger screen): the size+    /// that fits, with a margin for the desktop's own bars. None when it fits, or is maximized or+    /// full screen (those follow the monitor).+    pub fn fit(vp: &egui::ViewportInfo, content: egui::Vec2) -> Option<egui::ViewportCommand> {+        let monitor = vp.monitor_size.filter(|m| m.x > 0.0 && m.y > 0.0)?;+        if vp.maximized == Some(true) || vp.fullscreen == Some(true) {+            return None;+        }+        let inner = vp.inner_rect.map_or(content, |r| r.size());+        let outer = vp.outer_rect.map_or(inner, |r| r.size());+        if !(inner.x > 0.0 && inner.y > 0.0) || (outer.x <= monitor.x && outer.y <= monitor.y) {+            return None;+        }+        let room = monitor * 0.92;+        let deco = outer - inner;+        Some(egui::ViewportCommand::InnerSize((room - deco).max(egui::vec2(400.0, 300.0)).min(inner)))+    }+}+ /// Persisted UI state. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")]@@ -342,6 +416,13 @@ pub struct UiState {     /// Its floating panels, and the float-only panels' windows (Type › Tabs): (panel id,     /// top-left position). A customized layout holds its own floating panels.     pub floating: Vec<(String, [f32; 2])>,+    /// The size (width, height) each panel last floated at: it floats at that size again, after a+    /// restart, docking and floating it, or a change of workspace.+    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]+    pub panel_sizes: std::collections::BTreeMap<String, [f32; 2]>,+    /// The application window as it was left: its size, maximized, full screen. It opens so again.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub window: Option<WindowState>,     /// The panel layout once the user customizes it; `None` is the workspace's default layout     /// (`panel_docking::default_layout`).     #[serde(default, skip_serializing_if = "Option::is_none")]@@ -503,6 +584,8 @@ impl Default for UiState {             dock_tab: "properties".into(),             open_panel: None,             floating: Vec::new(),+            panel_sizes: Default::default(),+            window: None,             docking: None,             docking_hidden: Default::default(),             docking_generation: 0,@@ -792,6 +875,9 @@ pub struct DesignApp {     fonts_system: bool,     pub restyle: bool,     fonts_ready: bool,+    /// The window was checked against its monitor after it opened (see [`WindowState::restore`]).+    window_fitted: bool,+    window_frames: u8,     /// Files the operating system asked to open (macOS Finder, Dock, Open With), opened once the     /// window is ready; see [`DesignApp::open_documents_later`].     os_documents: Vec<String>,@@ -929,6 +1015,8 @@ impl DesignApp {             fonts_system: false,             restyle: false,             fonts_ready: false,+            window_fitted: false,+            window_frames: 0,             os_documents: Vec::new(),             integrated_titlebar: false,             custom_titlebar: false,@@ -2096,6 +2184,37 @@ impl DesignApp {         self.canvas_ime = false;         self.ime_canvas = None;         self.ime_interrupt = false;+        // Once the window shows: maximized or full screen again if it was left so (some window+        // systems, Wayland among them, ignore that when the window is created), else made to fit+        // a smaller monitor than the one its size was saved on.+        // (Wayland may not say which monitor the window is on: after a few frames it goes ahead.)+        self.window_frames = self.window_frames.saturating_add(1);+        if !self.window_fitted && (ctx.input(|i| i.viewport().monitor_size.is_some()) || self.window_frames > 3) {+            let saved = self.ui.window.unwrap_or_default();+            let content = ctx.content_rect().size();+            let cmds = ctx.input(|i| saved.restore(i.viewport(), content));+            for cmd in cmds {+                log::info!("window: restoring {cmd:?}");+                ctx.send_viewport_cmd(cmd);+            }+            self.window_fitted = true;+        }+        // Only once the saved state is applied: before, the window shows as created (not yet+        // maximized), which must not replace what was saved.+        if self.window_fitted {+            let content = ctx.content_rect().size();+            let zoom = ctx.zoom_factor();+            let window = ctx.input(|i| self.ui.window.unwrap_or_default().observe(i.viewport(), content, zoom));+            // Leaving maximized or full screen brings back the ordinary size, which may have been+            // saved on a bigger monitor: fit it again on the next pass.+            if self.ui.window.is_some_and(|before| before.unmaximized_to(window)) {+                self.window_fitted = false;+                self.window_frames = u8::MAX;+            }+            if self.ui.window != Some(window) && window != WindowState::default() {+                self.ui.window = Some(window);+            }+        }         // Fonts/theme must be installed from the real UI pass, before the font gate below: doing         // this only from a hidden logic tick would make the first shown pass wait forever.         if ctx.current_pass_index() == 0 {@@ -5295,3 +5414,108 @@ mod load_styles_tests {         assert!(app.session.documents()[1].doc.styles.char_style("Lead").is_none());     } }++#[cfg(test)]+mod window_state_tests {+    use super::WindowState;++    fn vp(inner: [f32; 2], monitor: [f32; 2], maximized: bool) -> egui::ViewportInfo {+        egui::ViewportInfo {+            inner_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 30.0), egui::vec2(inner[0], inner[1]))),+            outer_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(inner[0], inner[1] + 30.0))),+            monitor_size: Some(egui::vec2(monitor[0], monitor[1])),+            maximized: Some(maximized),+            fullscreen: Some(false),+            ..Default::default()+        }+    }++    /// The window is remembered as left: the ordinary size, kept while it is maximized, so+    /// restoring it gets that size back.+    #[test]+    fn the_window_is_remembered_as_left() {+        let w = WindowState::default().observe(&vp([1200.0, 800.0], [1920.0, 1080.0], false), egui::Vec2::ZERO, 1.0);+        assert_eq!(w, WindowState { size: Some([1200.0, 800.0]), maximized: false, fullscreen: false });+        let max = w.observe(&vp([1920.0, 1050.0], [1920.0, 1080.0], true), egui::Vec2::ZERO, 1.0);+        assert_eq!(max, WindowState { size: Some([1200.0, 800.0]), maximized: true, fullscreen: false });+        // A file from before the window was kept loads without it.+        let ui: super::UiState = serde_json::from_str("{}").unwrap();+        assert!(ui.window.is_none() && ui.panel_sizes.is_empty());+    }++    /// A size saved on a bigger screen is made to fit a smaller one (or a lower resolution), so the+    /// window never reaches past it; one that fits, or a maximized one, is left as it is.+    #[test]+    fn a_window_saved_on_a_bigger_screen_fits_a_smaller_one() {+        let Some(egui::ViewportCommand::InnerSize(s)) = WindowState::fit(&vp([2400.0, 1400.0], [1366.0, 768.0], false), egui::Vec2::ZERO) else {+            panic!("a command to fit")+        };+        assert!(s.x <= 1366.0 && s.y + 30.0 <= 768.0, "{s:?}");+        assert!(WindowState::fit(&vp([1200.0, 700.0], [1366.0, 768.0], false), egui::Vec2::ZERO).is_none());+        assert!(WindowState::fit(&vp([2400.0, 1400.0], [1366.0, 768.0], true), egui::Vec2::ZERO).is_none());+    }++    /// Left maximized (or full screen), the window is told so again once it shows, as Wayland+    /// ignores it at creation; already so, nothing is sent.+    #[test]+    fn a_window_left_maximized_opens_maximized() {+        let max = WindowState { size: Some([1200.0, 800.0]), maximized: true, fullscreen: false };+        assert_eq!(max.restore(&vp([1200.0, 800.0], [1920.0, 1080.0], false), egui::Vec2::ZERO), vec![egui::ViewportCommand::Maximized(true)]);+        assert!(max.restore(&vp([1920.0, 1050.0], [1920.0, 1080.0], true), egui::Vec2::ZERO).is_empty());+        let full = WindowState { fullscreen: true, ..max };+        assert_eq!(full.restore(&vp([1200.0, 800.0], [1920.0, 1080.0], false), egui::Vec2::ZERO), vec![egui::ViewportCommand::Fullscreen(true)]);+        let plain = WindowState { size: Some([1200.0, 800.0]), ..Default::default() };+        assert!(plain.restore(&vp([1200.0, 800.0], [1920.0, 1080.0], false), egui::Vec2::ZERO).is_empty());+    }++    /// Wayland reports no window rect: the size comes from the window's content, so a resize is+    /// still remembered, and a window too big for the monitor still made to fit.+    #[test]+    fn without_a_window_rect_the_content_size_counts() {+        let wayland = |maximized| egui::ViewportInfo {+            monitor_size: Some(egui::vec2(1366.0, 768.0)),+            maximized: Some(maximized),+            fullscreen: Some(false),+            ..Default::default()+        };+        let w = WindowState::default().observe(&wayland(false), egui::vec2(1000.0, 700.0), 1.0);+        assert_eq!(w.size, Some([1000.0, 700.0]));+        let Some(egui::ViewportCommand::InnerSize(s)) = WindowState::fit(&wayland(false), egui::vec2(2400.0, 1400.0)) else {+            panic!("a command to fit")+        };+        assert!(s.x <= 1366.0 && s.y <= 768.0, "{s:?}");+        assert!(WindowState::fit(&wayland(false), egui::vec2(1000.0, 700.0)).is_none());+    }++    /// With UI Scaling at 150 %, egui's points are 1.5 times the window system's: the size is+    /// kept in the window system's, so the window doesn't shrink with every restart.+    #[test]+    fn ui_scaling_does_not_shrink_the_window_on_restart() {+        // A 1500 × 900 window shows 1000 × 600 egui points at 150 %.+        let w = WindowState::default().observe(&vp([1000.0, 600.0], [1920.0, 1080.0], false), egui::Vec2::ZERO, 1.5);+        assert_eq!(w.size, Some([1500.0, 900.0]));+    }++    /// Leaving maximized or full screen is noticed, so the ordinary size is fitted to the monitor+    /// again; other changes are not.+    #[test]+    fn leaving_maximized_is_noticed() {+        let max = WindowState { size: Some([3000.0, 2000.0]), maximized: true, fullscreen: false };+        let plain = WindowState { maximized: false, ..max };+        assert!(max.unmaximized_to(plain));+        assert!(WindowState { fullscreen: true, maximized: false, ..max }.unmaximized_to(plain));+        assert!(!plain.unmaximized_to(max) && !plain.unmaximized_to(plain) && !max.unmaximized_to(max));+    }++    /// A workspace saved with no panel sizes brings none (the current ones go); one from before+    /// sizes were kept leaves them.+    #[test]+    fn a_workspace_tells_none_from_absent_panel_sizes() {+        let new: crate::SavedWorkspace = serde_json::from_str(r#"{"name": "A", "panelSizes": {}}"#).unwrap();+        assert_eq!(new.panel_sizes, Some(Default::default()));+        let old: crate::SavedWorkspace = serde_json::from_str(r#"{"name": "B"}"#).unwrap();+        assert_eq!(old.panel_sizes, None);+        let saved = serde_json::to_value(&new).unwrap();+        assert_eq!(saved["panelSizes"], serde_json::json!({}));+    }+}diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -1983,6 +1983,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value                 dock_expanded: u.dock_expanded,                 open_panel: u.open_panel.clone(),                 floating: u.floating.clone(),+                panel_sizes: Some(u.panel_sizes.clone()),                 docking: u.docking.clone(),                 docking_hidden: u.docking_hidden.clone(),                 icon_groups: u.icon_groups.clone(),@@ -2030,6 +2031,11 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value                 app.ui.dock_expanded = w.dock_expanded;                 app.ui.open_panel = w.open_panel;                 app.ui.floating = w.floating;+                // An older workspace file has no sizes: the panels keep the ones they have. A newer+                // one brings its own, even none.+                if let Some(sizes) = w.panel_sizes {+                    app.ui.panel_sizes = sizes;+                }                 app.ui.docking = w.docking;                 app.ui.docking_hidden = w.docking_hidden;                 app.ui.icon_groups = w.icon_groups;diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs--- a/crates/ui-egui/src/panel_docking.rs+++ b/crates/ui-egui/src/panel_docking.rs@@ -177,7 +177,8 @@ pub(crate) fn default_layout(ui: &crate::UiState) -> Layout<String> {             // The layout is rebuilt every frame, so each window keeps an id of its panel's (a             // native window lives as long as its id).             let window = default_window_id(&id);-            floating.push(Floating::single(vec![id], 0, [at[0], at[1], 280.0, 360.0]).with_id(window));+            let size = remembered_size(ui, &id).unwrap_or(egui::vec2(280.0, 360.0));+            floating.push(Floating::single(vec![id], 0, [at[0], at[1], size.x, size.y]).with_id(window));         }     }     let mut columns = Vec::new();@@ -674,7 +675,7 @@ fn float_space(app: &DesignApp) -> FloatSpace<String> {     crate::dock::DOCK_TABS         .iter()         .chain(crate::dock::ICON_PANELS)-        .fold(space, |space, (id, _, _)| space.with_panel_size((*id).to_owned(), floating_size(id)))+        .fold(space, |space, (id, _, _)| space.with_panel_size((*id).to_owned(), floating_size_for(&app.ui, id))) }  /// A change to the layout in use, committed only when every step succeeded.@@ -820,6 +821,7 @@ impl Transaction {         if app.ui.docking_focused.as_deref().is_some_and(|panel| panel != CANVAS && place(&before, panel) != place(&layout, panel)) {             app.ui.docking_focused = None;         }+        remember_sizes(app, &layout);         app.ui.docking = Some(layout);         app.ui.docking_hidden = hidden;         release_closed(app, &closed);@@ -944,7 +946,8 @@ fn open_floating_group(app: &mut DesignApp, params: &Value) -> Option<Result<Val         return None;     }     let layout = layout_of(app);-    let (w, h) = (290.0, 340.0);+    let size = floating_size_for(&app.ui, panel);+    let (w, h) = (size.x, size.y);     let (x, y) = app.canvas_rect.map_or((400.0, 140.0), |r| ((r.right() - w - 24.0).max(r.left()), r.top() + 96.0));     let icon_group = dock_columns(&layout)         .iter()@@ -1251,6 +1254,33 @@ fn dock_panel_item(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) {     } } +/// The size a panel floats at: the size it last floated at, else its own ([`floating_size`]).+fn floating_size_for(ui: &crate::UiState, panel: &str) -> egui::Vec2 {+    remembered_size(ui, panel).unwrap_or_else(|| floating_size(panel))+}++/// The size `panel` last floated at, if it has floated (and the number is usable).+fn remembered_size(ui: &crate::UiState, panel: &str) -> Option<egui::Vec2> {+    ui.panel_sizes.get(panel).filter(|s| s.iter().all(|v| v.is_finite() && *v >= 40.0)).map(|s| egui::vec2(s[0], s[1]))+}++/// Remembers the size of every floating window's panels (its own OS window's size when it has+/// one), so a panel docked and floated again, or floated in another workspace, comes back at it.+/// A minimized window keeps the size it had.+fn remember_sizes(app: &mut DesignApp, layout: &Layout<String>) {+    for window in layout.floating.iter().filter(|w| !w.minimized) {+        let [_, _, w, h] = window.native.unwrap_or(window.rect);+        if !(w.is_finite() && h.is_finite() && w >= 40.0 && h >= 40.0) {+            continue;+        }+        for panel in window.panels() {+            if app.ui.panel_sizes.get(panel) != Some(&[w, h]) {+                app.ui.panel_sizes.insert(panel.clone(), [w, h]);+            }+        }+    }+}+ /// The size a panel floats at (tab strip included), whatever the size of the dock it left. fn floating_size(panel: &str) -> egui::Vec2 {     match panel {@@ -1302,7 +1332,7 @@ impl craft_ui::docking::DockContent<String> for PanelContent<'_> {         craft_ui::docking::PanelLimits { min, ..Default::default() }     }     fn floating_size(&self, id: &String) -> Option<egui::Vec2> {-        Some(floating_size(id))+        Some(floating_size_for(&self.app.ui, id))     }     fn paint_icon(&mut self, painter: &egui::Painter, rect: egui::Rect, id: &String, color: egui::Color32) -> bool {         let Some(icon) = panel_icon(id) else { return false };diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs--- a/crates/ui-egui/src/panel_docking_tests.rs+++ b/crates/ui-egui/src/panel_docking_tests.rs@@ -1279,3 +1279,23 @@ fn a_document_dropped_on_a_floating_panel_window_opens() {     open_window_drops(&mut app, &[files]);     assert_eq!(app.session.documents().len(), 1, "the dropped document opens"); }++/// A floating panel resized, then docked and floated again, comes back at the size it was left+/// at, not at its fixed default; the size goes with saved preferences and workspaces.+#[test]+fn a_floating_panel_comes_back_at_the_size_it_was_left_at() {+    let mut app = app();+    let rect = |app: &DesignApp, p: &str| app.ui.docking.as_ref().unwrap().floating.iter().find(|w| w.contains(p)).unwrap().rect;+    app.run("window.panel.float", json!({"panel":"properties"})).unwrap();+    app.run("window.panel.layout", json!({"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}})).unwrap();+    assert_eq!(app.ui.panel_sizes.get("properties"), Some(&[360.0, 450.0]));+    app.run("window.panel.dock", json!({"panel":"properties"})).unwrap();+    app.run("window.panel.float", json!({"panel":"properties"})).unwrap();+    let r = rect(&app, "properties");+    assert_eq!([r[2], r[3]], [360.0, 450.0], "floated again at the size it was left at: {r:?}");+    // Saved and loaded with the preferences, and kept by a saved workspace.+    let saved: crate::UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap();+    assert_eq!(saved.panel_sizes.get("properties"), Some(&[360.0, 450.0]));+    let old: crate::UiState = serde_json::from_str("{}").unwrap();+    assert!(old.panel_sizes.is_empty(), "a file from before loads");+}