From 97bf37e9e16c9a8a00d216489c61a80abbc3afd8 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Wed, 11 Nov 2026 15:46:38 -0600 Subject: [PATCH 01/10] ci: size the pipeline watchdogs from the fleet's measured wall-clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A job's `timeout:` is a watchdog, not a gate: it kills the container and records `timed_out` with no diagnostic about what was slow. The budgets were sized by guess, and the guess is now falsified by the fleet's own record — a job was killed at 20.2 min and at 20.3 min while still working, and the release job's worst successful run finished 1.7 minutes inside its 90-minute budget. The runner holds 4 concurrent containers on a 16-core host and each compiles the workspace, so a co-tenant wave inflates a job roughly 4x. Measured on 2026-11-11 with 9 runs in flight (idle in parentheses): job budget worst success idle fmt-layers 20m 20.1 min 0.3 min clippy-test 90m 76.9 min 10.3 min wasm-web 60m 45.6 min 9.0 min corpus-golden 30m 19.6 min 2.5 min perf-budgets 20m 14.3 min 3.2 min fuzz 60m 52.9 min 10.6 min generated-docs 20m 16.1 min 2.1 min release 90m 88.3 min 38.8 min Each budget below is ~2.5x the worst successful duration, so a loaded job still fits and a genuinely hung job is still killed: fmt-layers 45m, clippy-test 150m, wasm-web 90m, corpus-golden 45m, perf-budgets 45m, fuzz 120m, generated-docs 45m, release 180m No gate is weakened: the budgets decide when a container is killed, not whether a check passes. Refs #126 Co-Authored-By: Claude Code --- .gitforge.yml | 46 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/.gitforge.yml b/.gitforge.yml index 9015388..ca1c277 100644 --- a/.gitforge.yml +++ b/.gitforge.yml @@ -40,6 +40,18 @@ # docker save firecraft-ci-rust:1 | ssh docker load # - Step logs are head-capped: cargo's compile spam comes first, so a # failure late in a long step can be truncated. Keep steps small. +# - A job's `timeout:` is a WATCHDOG, not a gate: it kills the +# container and records `timed_out`, with no diagnostic about what +# was slow. The budgets below are therefore sized from the fleet's +# measured wall-clock, not from a round guess. The runner holds 4 +# concurrent containers on a 16-core host and each compiles the +# workspace, so a co-tenant wave inflates a job roughly 4x: +# fmt-layers measured 0.3 min idle and 20.1 min loaded; perf-budgets +# 3.2 min idle and 14.3 min loaded. Each budget below is ~2.5x the +# worst *successful* duration observed on 2026-11-11 with 9 runs in +# flight, so a loaded job still fits its budget and a genuinely hung +# job is still killed. A budget that a passing job can outrun is +# indistinguishable from a failure (issue #126). # # The image recipe is infrastructure/docker/ci-rust.Dockerfile; rebuild # and re-ship it when a pinned tool version or the Rust minor moves. A @@ -60,7 +72,11 @@ jobs: - name: Layer-table gate # Compiles xtask alone; the gate itself is cargo metadata. run: CARGO_TARGET_DIR=/tmp/ci-target cargo run --locked -p xtask -- layers - timeout: 20m + # 45m: the gate compiles xtask, so it carries the workspace's + # build wave. Measured 20.1 min on 2026-11-11 with 9 runs in + # flight (0.3 min idle); the previous 20m budget killed a run that + # was 0.5 min from finishing. + timeout: 45m - name: clippy-test image: firecraft-ci-rust:1 @@ -76,7 +92,8 @@ jobs: run: CARGO_TARGET_DIR=/tmp/ci-target cargo test --workspace --locked --no-fail-fast -- --test-threads=2 # The egui/eframe graph is the bulk of the workspace; the wall # absorbs co-tenant build waves on these shared hosts. - timeout: 90m + # 150m: measured 76.9 min on 2026-11-11 with 9 runs in flight. + timeout: 150m - name: wasm-web image: firecraft-ci-rust:1 @@ -89,7 +106,8 @@ jobs: # the image, the fwpng smoke suite executes for real (no # compile-only downgrade), and trunk builds the web bundle. run: CARGO_TARGET_DIR=/tmp/ci-target cargo run --locked -p xtask -- wasm - timeout: 60m + # 90m: measured 45.6 min on 2026-11-11 with 9 runs in flight. + timeout: 90m - name: corpus-golden image: firecraft-ci-rust:1 @@ -99,7 +117,8 @@ jobs: run: CARGO_TARGET_DIR=/tmp/ci-target cargo run --locked -p xtask -- corpus - name: Golden render pins + determinism run: CARGO_TARGET_DIR=/tmp/ci-target cargo run --locked -p xtask -- golden - timeout: 30m + # 45m: measured 19.6 min on 2026-11-11 with 9 runs in flight. + timeout: 45m - name: perf-budgets image: firecraft-ci-rust:1 @@ -110,7 +129,11 @@ jobs: # fleet. The report line names the host, so a trip is # diagnosable as regression vs environment shift. run: CARGO_TARGET_DIR=/tmp/ci-target cargo run --locked -p xtask -- perf - timeout: 20m + # 45m: measured 14.3 min on 2026-11-11 with 9 runs in flight, but + # two runs were killed at the previous 20m budget while still + # working (one on 2026-11-11 at 20.2 min, one at 20.3 min). The + # budget must exceed what a passing job can take. + timeout: 45m - name: fuzz image: firecraft-ci-rust:1 @@ -131,7 +154,9 @@ jobs: # 30m proved too tight: a cold sandbox target dir spends ~20m # compiling before fuzz-quick starts (dfd7f46 run was killed by # the watchdog 10m into the campaign itself). - timeout: 60m + # 120m: measured 52.9 min on 2026-11-11 with 9 runs in flight, so + # the 60m budget left less than a quarter of an hour of margin. + timeout: 120m - name: generated-docs image: firecraft-ci-rust:1 @@ -173,7 +198,8 @@ jobs: CARGO_TARGET_DIR=/tmp/ci-target cargo run --locked -p xtask -- panel-snapshot cmp /tmp/parity.committed docs/planning/generated/parity.md cmp /tmp/scorecard.committed docs/planning/generated/scorecard.md - timeout: 20m + # 45m: measured 16.1 min on 2026-11-11 with 9 runs in flight. + timeout: 45m - name: release image: firecraft-ci-rust:1 @@ -267,4 +293,8 @@ jobs: # directory, so verify from inside each one. (cd artifacts/web && sha256sum -c MANIFEST.sha256) (cd artifacts/native && sha256sum -c MANIFEST.sha256) - timeout: 90m + # 180m: measured 88.3 min on 2026-11-11 with 9 runs in flight — + # 1.7 minutes inside the previous 90m budget. The release job is + # the longest in the chain and runs last, when the fleet is most + # loaded, so it is the job most likely to be killed by its budget. + timeout: 180m From 8fed46f97a1d5f167fb0ea2e297f17cc2c9347c3 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Wed, 11 Nov 2026 16:10:22 -0600 Subject: [PATCH 02/10] feat(app): the panels dock left or right, and remember where MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CS6 moves a panel between its dock groups; FireCraft's dock was a single right-hand column with no way to rearrange it. The panel title is now the drag handle — CS6 moves a panel by its header — and the column a panel is released over is the column it moves into. The left column appears when a panel is docked left or while a drag is in flight, because a drop target has to be there before the drag reaches it. The small arrow beside the close button does the same move for anyone who cannot drag. Only moved panels are recorded. An unlisted panel is in CS6's default dock, so the saved layout stays small and "not mentioned" keeps meaning "the default" — the same rule the hidden-panel list already follows, and unknown names are dropped on restore rather than resurrected. The default arrangement is unchanged: the panel snapshot still records every panel on the right, in CS6's order. Co-Authored-By: Claude Code --- crates/app/src/shell/mod.rs | 100 +++++++++++++- crates/app/src/shell/panel_windows.rs | 191 ++++++++++++++++++++++++-- 2 files changed, 276 insertions(+), 15 deletions(-) diff --git a/crates/app/src/shell/mod.rs b/crates/app/src/shell/mod.rs index 4de1619..c02bbd3 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -211,6 +211,11 @@ pub struct Shell { /// Window-menu panel visibility: names of right-dock panels the /// user closed. Absent = shown (CS6 default is everything open). panel_hidden: Vec<&'static str>, + /// The panels the user has moved out of the right stack, and the + /// dock each now lives in. Only *moved* panels are listed: an + /// unlisted panel is in CS6's default dock, so the saved layout + /// stays small and "not mentioned" keeps meaning "the default". + panel_homes: Vec<(&'static str, DockSide)>, /// The URL panel's edit-in-progress state — the object it edits /// and the link/alt text being typed. url_draft: Option, @@ -357,6 +362,12 @@ impl ScreenMode { struct ShellLayout { /// The panel names currently hidden from the dock. panel_hidden: Vec, + /// The panels the user moved out of the right stack, and the dock + /// each now lives in. `#[serde(default)]` — a layout saved + /// before panels could move carries no homes, which is the + /// default arrangement. + #[serde(default)] + panel_homes: Vec<(String, DockSide)>, /// The Tab-key fold of the whole panel group. panels_hidden: bool, /// The left tool dock fold. @@ -383,6 +394,7 @@ impl Default for ShellLayout { .into_iter() .map(str::to_owned) .collect(), + panel_homes: Vec::new(), panels_hidden: false, tools_hidden: false, options_bar_hidden: false, @@ -481,6 +493,9 @@ impl Shell { // CS6's fresh-document roster: Properties/Pages/States/ // Layers open, the rest of the panel inventory closed. panel_hidden: Self::default_open_panels(), + // No panel has been moved yet: every panel is in CS6's + // default dock. + panel_homes: Vec::new(), url_draft: None, swap_target: None, find_query: None, @@ -524,6 +539,17 @@ impl Shell { .filter(|panel| saved.contains(&panel.name)) .map(|panel| panel.name) .collect(); + // The same rule for the moved panels: a home naming a panel + // this build lacks is dropped, and the last entry for a panel + // wins. + let mut homes: Vec<(&'static str, DockSide)> = Vec::new(); + for (name, side) in &self.layout.panel_homes { + if let Some(spec) = panel_windows::panel_spec(name) { + homes.retain(|(moved, _)| *moved != spec.name); + homes.push((spec.name, *side)); + } + } + self.panel_homes = homes; self.panels_hidden = self.layout.panels_hidden; self.tools_hidden = self.layout.tools_hidden; self.options_bar_hidden = self.layout.options_bar_hidden; @@ -551,6 +577,11 @@ impl Shell { .iter() .map(|name| (*name).to_owned()) .collect(), + panel_homes: self + .panel_homes + .iter() + .map(|(name, side)| ((*name).to_owned(), *side)) + .collect(), panels_hidden: self.panels_hidden, tools_hidden: self.tools_hidden, options_bar_hidden: self.options_bar_hidden, @@ -3059,8 +3090,19 @@ impl eframe::App for Shell { // panels. A side panel *reserves* its width, so the canvas // shrinks instead of being covered, and the column scrolls so // every panel stays reachable at any viewport height. + // + // The left column appears when a panel has been moved out of + // the right stack, or while a panel is being dragged — the + // drop target has to be there before the drag reaches it. if !self.panels_hidden && !full_screen { - self.dock(ui); + let right = self.dock(ui); + let left = self.left_dock(ui); + self.finish_panel_drag(ui, right, left); + } else { + // No dock is drawn, so no drop can be resolved — a drag + // still in flight is cancelled rather than left to move a + // panel on a later release. + panel_windows::cancel_panel_drag(ui.ctx()); } if !full_screen { egui::Panel::bottom(egui::Id::new("status")).show(ui, |ui| { @@ -4015,6 +4057,62 @@ mod tests { ); } + /// A panel moved out of the right stack lives in the other dock, + /// and moving it back returns it to the default arrangement — + /// where it drops out of the moved set entirely, so the saved + /// layout only ever names panels that are actually moved. + #[test] + fn a_moved_panel_lives_in_the_other_dock() { + let mut shell = gui_shell(gui_editor()); + assert_eq!(shell.panel_home("Properties"), DockSide::Right); + assert!(shell.panels_in_dock(DockSide::Left).is_empty()); + + shell.move_panel_to_dock("Properties", DockSide::Left); + assert_eq!(shell.panel_home("Properties"), DockSide::Left); + assert_eq!(shell.panels_in_dock(DockSide::Left), vec!["Properties"]); + assert!( + !shell + .panels_in_dock(DockSide::Right) + .contains(&"Properties") + ); + + // Back to the default dock: the panel is not "moved" any more. + shell.move_panel_to_dock("Properties", DockSide::Right); + assert_eq!(shell.panel_home("Properties"), DockSide::Right); + assert!(shell.panel_homes.is_empty()); + } + + /// The moved panels survive a save: the layout round-trips the + /// homes, and a home naming a panel this build lacks is dropped + /// rather than resurrected. + #[test] + fn the_panel_homes_round_trip_through_save() { + let mut shell = gui_shell(gui_editor()); + shell.move_panel_to_dock("History", DockSide::Left); + shell.move_panel_to_dock("Web Layer", DockSide::Left); + let saved = shell.capture_layout(); + assert_eq!(saved.panel_homes.len(), 2); + + let encoded = ron_mid_encoded(&saved); + let decoded: ShellLayout = ron::from_str(&encoded).expect("layout decodes"); + let mut restored = gui_shell(gui_editor()); + restored.layout = decoded; + restored.apply_layout(); + assert_eq!(restored.panel_home("History"), DockSide::Left); + assert_eq!(restored.panel_home("Web Layer"), DockSide::Left); + assert_eq!(restored.panel_home("Properties"), DockSide::Right); + + // A home naming a panel this build lacks is dropped. + let mut foreign = shell.capture_layout(); + foreign + .panel_homes + .push(("Not A Panel".to_owned(), DockSide::Left)); + let mut restored = gui_shell(gui_editor()); + restored.layout = foreign; + restored.apply_layout(); + assert_eq!(restored.panel_homes.len(), 2, "unknown home dropped"); + } + /// The tool-flyout hold decision is a pure function of elapsed /// time: below the dwell it stays closed, at and past it the /// flyout opens, and a short press never opens it no matter how diff --git a/crates/app/src/shell/panel_windows.rs b/crates/app/src/shell/panel_windows.rs index 1f5fa24..091ccef 100644 --- a/crates/app/src/shell/panel_windows.rs +++ b/crates/app/src/shell/panel_windows.rs @@ -170,7 +170,7 @@ pub(crate) enum PanelKind { } /// Which edge the panel column docks to. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum DockSide { /// The column hugs the left edge of the window. Left, @@ -187,6 +187,15 @@ impl DockSide { DockSide::Right => "right", } } + + /// The other dock: where a panel goes when it is moved. + #[must_use] + pub fn other(self) -> Self { + match self { + DockSide::Left => DockSide::Right, + DockSide::Right => DockSide::Left, + } + } } /// The edge the panel column docks to. CS6 keeps the group on the @@ -194,6 +203,13 @@ impl DockSide { /// column is a deliberate re-pin, never a silent drift. const DOCK_SIDE: DockSide = DockSide::Right; +/// The payload a panel drag carries: which panel is being moved. +#[derive(Clone, Copy)] +struct PanelDrag { + /// The dragged panel's CS6 name. + name: &'static str, +} + /// One panel of the CS6 inventory, as plain data. /// /// The panel *structure* — the set, the dock order, the dock side, @@ -254,6 +270,16 @@ fn panel_id(name: &str) -> String { name.to_lowercase().replace(' ', "-") } +/// The spec of a panel this shell knows, including the Web Layer +/// panel — which has no Window-menu row but is a panel all the same. +pub(crate) fn panel_spec(name: &str) -> Option { + PANELS + .iter() + .copied() + .find(|spec| spec.name == name) + .or_else(|| (WEB_PANEL == name).then_some(WEB_PANEL_SPEC)) +} + /// The width of the right dock, in points — CS6's dock is a narrow /// column; the canvas keeps the rest. pub(crate) const DOCK_WIDTH: f32 = 258.0; @@ -268,32 +294,111 @@ impl Shell { /// `Hide Panels` (the CS6 Tab key) folds the whole dock; the /// Window menu toggles one panel at a time, and a panel closed /// with its own close button leaves the rest of the dock alone. - pub(crate) fn dock(&mut self, ui: &mut egui::Ui) { + /// + /// Returns the column's inner rect — the drop target a dragged + /// panel is released over. + pub(crate) fn dock(&mut self, ui: &mut egui::Ui) -> Option { if self.panels_hidden { - return; + return None; + } + self.dock_column(ui, DockSide::Right) + } + + /// The left panel column — the second dock a panel can live in. + /// + /// It exists only when a panel is docked left or a panel is being + /// dragged: a drop target has to be there before the drag + /// reaches it, or there would be nowhere to drop the panel. + pub(crate) fn left_dock(&mut self, ui: &mut egui::Ui) -> Option { + if self.panels_hidden { + return None; } - egui::Panel::right(egui::Id::new("cs6_dock")) + let dragging = egui::DragAndDrop::has_payload_of_type::(ui.ctx()); + if !dragging && self.panels_in_dock(DockSide::Left).is_empty() { + return None; + } + self.dock_column(ui, DockSide::Left) + } + + /// One dock column: the panels that live in it, stacked. + fn dock_column(&mut self, ui: &mut egui::Ui, side: DockSide) -> Option { + let (column_id, scroll_id) = match side { + DockSide::Left => ("cs6_left_dock", "cs6_left_dock_scroll"), + DockSide::Right => ("cs6_dock", "cs6_dock_scroll"), + }; + let column = match side { + DockSide::Left => egui::Panel::left(egui::Id::new(column_id)), + DockSide::Right => egui::Panel::right(egui::Id::new(column_id)), + }; + let mut rect = None; + column .resizable(true) .default_size(DOCK_WIDTH) .show_separator_line(true) .show(ui, |ui| { + rect = Some(ui.max_rect()); // The column scrolls: CS6's dock does too when the // stack is taller than the window, and it is what keeps // every panel reachable at a short viewport. egui::ScrollArea::vertical() - .id_salt("cs6_dock_scroll") + .id_salt(scroll_id) .auto_shrink([false, false]) .show(ui, |ui| { for spec in PANELS { - if self.panel_shown(spec.name) { - self.dock_section(ui, spec, true); + if self.panel_shown(spec.name) && self.panel_home(spec.name) == side { + self.dock_section(ui, spec, true, side); } } // The Web Layer panel is always present and // carries no close button. - self.dock_section(ui, &WEB_PANEL_SPEC, false); + if self.panel_home(WEB_PANEL) == side { + self.dock_section(ui, &WEB_PANEL_SPEC, false, side); + } }); }); + rect + } + + /// Resolves a finished panel drag: a panel released over a dock + /// column moves into that column, and a release anywhere else + /// cancels the move. Either way the payload is consumed — a + /// payload left behind would keep the left column on screen + /// forever. + pub(crate) fn finish_panel_drag( + &mut self, + ui: &egui::Ui, + right: Option, + left: Option, + ) { + if !ui.input(|input| input.pointer.any_released()) { + return; + } + let Some(drag) = egui::DragAndDrop::take_payload::(ui.ctx()) else { + return; + }; + let pointer = ui.ctx().pointer_interact_pos(); + let over = |column: Option| { + pointer.is_some_and(|pos| column.is_some_and(|rect| rect.contains(pos))) + }; + let target = if over(left) { + Some(DockSide::Left) + } else if over(right) { + Some(DockSide::Right) + } else { + None + }; + if let Some(side) = target { + self.move_panel_to_dock(drag.name, side); + } + } + + /// Drops a panel drag in flight. Called when the docks are not + /// drawn: a payload left behind would move a panel on a later + /// release, long after the drag the user made. + pub(crate) fn cancel_panel_drag(ctx: &egui::Context) { + if egui::DragAndDrop::has_payload_of_type::(ctx) { + egui::DragAndDrop::clear_payload(ctx); + } } /// One panel in the dock: a CS6 header (collapse arrow, name, @@ -302,20 +407,47 @@ impl Shell { /// The header's expanded state is egui's own, keyed on the /// panel's name, so it survives a restart the way CS6's collapsed /// panels do. - fn dock_section(&mut self, ui: &mut egui::Ui, spec: &PanelSpec, closable: bool) { + /// + /// The panel's title is its drag handle — CS6 moves a panel by + /// its header — and the small arrow beside the close button + /// moves it to the other dock for anyone who cannot drag. + fn dock_section( + &mut self, + ui: &mut egui::Ui, + spec: &PanelSpec, + closable: bool, + side: DockSide, + ) { let mut close = false; + let mut move_to_other = false; let state = egui::collapsing_header::CollapsingState::load_with_default_open( ui.ctx(), egui::Id::new(("cs6_dock_panel", spec.name)), true, ); let header = state.show_header(ui, |ui| { - ui.strong(spec.name); - if closable { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let _drag = ui.dnd_drag_source( + egui::Id::new(("cs6_panel_drag", spec.name)), + PanelDrag { name: spec.name }, + |ui| { + ui.strong(spec.name); + }, + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if closable { close = ui.small_button("×").clicked(); - }); - } + } + move_to_other = ui + .small_button(match side { + DockSide::Left => "»", + DockSide::Right => "«", + }) + .on_hover_text(match side { + DockSide::Left => "Move the panel to the right dock", + DockSide::Right => "Move the panel to the left dock", + }) + .clicked(); + }); }); header.body_unindented(|ui| { ui.set_min_width(DOCK_CONTENT_WIDTH); @@ -346,6 +478,37 @@ impl Shell { if close { self.hide_panel(spec.name); } + if move_to_other { + self.move_panel_to_dock(spec.name, side.other()); + } + } + + /// The dock a panel lives in. A panel the user has not moved + /// lives in the right stack, where CS6 keeps its panel group. + pub(crate) fn panel_home(&self, name: &str) -> DockSide { + self.panel_homes + .iter() + .find(|(panel, _)| *panel == name) + .map_or(DOCK_SIDE, |(_, side)| *side) + } + + /// Moves a panel into a dock. A panel moved back to the default + /// dock drops out of the moved set, so the saved layout stays + /// small and "not mentioned" keeps meaning "the default". + pub(crate) fn move_panel_to_dock(&mut self, name: &'static str, side: DockSide) { + self.panel_homes.retain(|(panel, _)| *panel != name); + if side != DOCK_SIDE { + self.panel_homes.push((name, side)); + } + } + + /// The panels that live in a dock, in CS6's roster order. + pub(crate) fn panels_in_dock(&self, side: DockSide) -> Vec<&'static str> { + PANELS + .iter() + .filter(|spec| self.panel_home(spec.name) == side) + .map(|spec| spec.name) + .collect() } /// Records a panel as hidden (the Window menu's toggle uses the From c62ce6f8394a8c4feb7b12ab5b2804d95939f4d7 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Wed, 11 Nov 2026 16:12:04 -0600 Subject: [PATCH 03/10] docs(tasks): the dockable panel set is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel inventory and the dock itself landed with 22.1; the movement model — a panel moving between the left and right columns, and the arrangement surviving a restart — landed on 2026-11-11 in PR #128. Tab groups are the remaining step of issue #91. Co-Authored-By: Claude Code --- docs/TASKS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/TASKS.md b/docs/TASKS.md index 58ee9a0..0060913 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -50,6 +50,7 @@ | P1 | Reconcile live web deployment | Artifact differs | The public manifest's JS/wasm/index hashes differ from local `dist/web`; identify intended deploy and verify it before publishing. See [T18](tasks/T18-release.md). | | P2 | Next-release asset breadth and platform verification | Open | [T23](tasks/T23-release-assets.md); recipes exist, but non-Linux artifacts need real platform build/launch receipts. | | P2 | Coverage and test quality ratchet | Open | Replace the old aspirational 99% label with measured per-crate targets and snapshots; [NEXT.md](NEXT.md). | +| P2 | Phase 22.1: the dockable panel set — docking, tab groups, layout persistence | In progress | [issue #91](https://github.com/aliasfoxkde/FireCraft/issues/91). Docking (a panel moves between the left and right columns, by drag or by the header arrow) and its persistence landed 2026-11-11 in PR #128; tab groups are the remaining step. | | P2 | Aegis gate: content-keyed baseline, so a doc edit no longer fails the gate | **Done — landed on `main`** | [decisions/0074-aegis-content-keyed-baseline.md](decisions/0074-aegis-content-keyed-baseline.md). The baseline keys on content (pattern, file, digest) rather than on line, and every rule class stays armed. | ### Release & tooling lanes in flight @@ -133,7 +134,7 @@ progress: | Item | State at `5fac7a2` | Remaining evidence/work | |---|---|---| -| 22.1 Panel inventory and docking | Complete | The right dock is a resizable, scrolling column of stacked panels in CS6's order (`crates/app/src/shell/panel_windows.rs`: 18-panel inventory, collapse/close headers, scroll keeps every panel reachable at any viewport height). All surfaces dispatch, including the previously-missing Optimize, Slices, CSS Properties, Find and Replace, and Common Library panels (landed 2026-11-06). Common Library lists the document's own symbols — CS6's shipped libraries are proprietary resources and are not copied. | +| 22.1 Panel inventory and docking | Complete | The right dock is a resizable, scrolling column of stacked panels in CS6's order (`crates/app/src/shell/panel_windows.rs`: 18-panel inventory, collapse/close headers, scroll keeps every panel reachable at any viewport height). All surfaces dispatch, including the previously-missing Optimize, Slices, CSS Properties, Find and Replace, and Common Library panels (landed 2026-11-06). Common Library lists the document's own symbols — CS6's shipped libraries are proprietary resources and are not copied. **The movement model landed 2026-11-11 in PR #128**: a panel's title is its drag handle, both columns are drop targets, the left column appears when a panel is docked there or a drag is in flight, the `«`/`»` arrow moves a panel without dragging, and only *moved* panels are persisted (unknown names dropped on restore). The default arrangement is unchanged — the panel snapshot still records every panel on the right, in CS6's order. Tab groups remain open (issue #91, step 3). | | 22.2 Tool strip and flyouts | Complete | The strip is CS6's, as data in `crates/app/src/toolstrip.rs`: six categories in CS6's order, twenty-four groups with their one-key shortcuts, the flyout memberships, and CS6's group-cycling rule. Evidence in [`research/fw-ui-evidence.md`](research/fw-ui-evidence.md); landed 2026-11-05. | | 22.3 Adaptive canvas context menu | Complete | Selection-specific rows dispatch through the existing command surface. | | 22.4 Property inspector | Partial | Per-kind field sets landed 2026-11-04 (group, bitmap, slice); the document-level and multi-selection field sets and enter-group-by-selection landed 2026-11-06 (test `inspector_document_and_multi_selection_sets`). Open: the bitmap transform strip and fill/stroke, both blocked by decision 0019 / absent `IMGv` fill-stroke nodes (recorded, not faked). | From 0086b8a6774b12c36edd3c5a6605ac3a6aed64c8 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Wed, 11 Nov 2026 17:01:21 -0600 Subject: [PATCH 04/10] feat(app): the panels tab together in CS6's tab groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop a panel on another panel's title and the two share one slot: a tab strip switches between them, the group is one unit that the dock draws and moves, and the arrangement survives a save. The tab is both the switch and the handle, so one widget has to answer both gestures. egui's drag source is registered after the tab it wraps, which makes it the topmost widget under the pointer, and a drag-only widget on top discards the click beneath it — the tab could not be clicked at all. So the tab is drawn by the drag source but sensed by the drag source's own id: the sense is widened to click as well as drag, and egui's own rule decides between the two — it waits for the pointer to move before it calls a drag started, and a click that never moved stays a click. The solo panel title gets the same treatment, so a click on a title no longer reads as a drop and merges no panels by accident. Co-Authored-By: Claude Code --- crates/app/src/shell/mod.rs | 163 ++++++++++ crates/app/src/shell/panel_windows.rs | 449 +++++++++++++++++++++++--- crates/app/src/shell/panels.rs | 3 + 3 files changed, 563 insertions(+), 52 deletions(-) diff --git a/crates/app/src/shell/mod.rs b/crates/app/src/shell/mod.rs index c02bbd3..7fc58eb 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -216,6 +216,15 @@ pub struct Shell { /// unlisted panel is in CS6's default dock, so the saved layout /// stays small and "not mentioned" keeps meaning "the default". panel_homes: Vec<(&'static str, DockSide)>, + /// The tab groups: the panels that share one slot in a dock. + /// Empty for the default arrangement — a panel is on its own + /// until the user drops it on another panel's title. + panel_groups: Vec, + /// The panels' title rects, recorded each frame. They are the + /// drop targets that tab a panel with another one, so they are + /// frame-local: a rect from an earlier frame is a rect from a + /// layout that no longer exists. + panel_title_rects: Vec<(&'static str, egui::Rect)>, /// The URL panel's edit-in-progress state — the object it edits /// and the link/alt text being typed. url_draft: Option, @@ -368,6 +377,12 @@ struct ShellLayout { /// default arrangement. #[serde(default)] panel_homes: Vec<(String, DockSide)>, + /// The tab groups: the panels that share one slot, and the tab + /// each group shows. `#[serde(default)]` — a layout saved + /// before panels could be tabbed carries no groups, which is the + /// default arrangement. + #[serde(default)] + panel_groups: Vec, /// The Tab-key fold of the whole panel group. panels_hidden: bool, /// The left tool dock fold. @@ -387,6 +402,16 @@ struct ShellLayout { palette_recents: Vec, } +/// The persisted form of a tab group: the panels that share one +/// slot in a dock, and the tab the group shows. +#[derive(serde::Serialize, serde::Deserialize, Clone)] +struct PanelGroupLayout { + /// The panels in the group, in tab order. + members: Vec, + /// The member whose content the group shows. + active: String, +} + impl Default for ShellLayout { fn default() -> Self { Self { @@ -395,6 +420,7 @@ impl Default for ShellLayout { .map(str::to_owned) .collect(), panel_homes: Vec::new(), + panel_groups: Vec::new(), panels_hidden: false, tools_hidden: false, options_bar_hidden: false, @@ -496,6 +522,9 @@ impl Shell { // No panel has been moved yet: every panel is in CS6's // default dock. panel_homes: Vec::new(), + // No panel is tabbed with another yet. + panel_groups: Vec::new(), + panel_title_rects: Vec::new(), url_draft: None, swap_target: None, find_query: None, @@ -550,6 +579,36 @@ impl Shell { } } self.panel_homes = homes; + // The same rule for the tab groups: a group naming a panel + // this build lacks loses that member, a group that drops + // below two members dissolves, and a panel can be in only one + // group — the last group that claims it wins. + let mut groups: Vec = Vec::new(); + for group in &self.layout.panel_groups { + let mut members: Vec<&'static str> = Vec::new(); + for member in &group.members { + let Some(spec) = panel_windows::panel_spec(member) else { + continue; + }; + if members.contains(&spec.name) { + continue; + } + for existing in groups.iter_mut() { + existing.members.retain(|claimed| *claimed != spec.name); + } + members.push(spec.name); + } + if members.len() < 2 { + continue; + } + let active = panel_windows::panel_spec(&group.active) + .map(|spec| spec.name) + .filter(|name| members.contains(name)) + .unwrap_or(members[0]); + groups.push(panel_windows::PanelGroup { members, active }); + } + groups.retain(|group| group.members.len() > 1); + self.panel_groups = groups; self.panels_hidden = self.layout.panels_hidden; self.tools_hidden = self.layout.tools_hidden; self.options_bar_hidden = self.layout.options_bar_hidden; @@ -582,6 +641,18 @@ impl Shell { .iter() .map(|(name, side)| ((*name).to_owned(), *side)) .collect(), + panel_groups: self + .panel_groups + .iter() + .map(|group| PanelGroupLayout { + members: group + .members + .iter() + .map(|name| (*name).to_owned()) + .collect(), + active: group.active.to_owned(), + }) + .collect(), panels_hidden: self.panels_hidden, tools_hidden: self.tools_hidden, options_bar_hidden: self.options_bar_hidden, @@ -3095,6 +3166,10 @@ impl eframe::App for Shell { // the right stack, or while a panel is being dragged — the // drop target has to be there before the drag reaches it. if !self.panels_hidden && !full_screen { + // The title rects are this frame's drop targets, so they + // are recorded afresh: a panel dropped on another panel's + // title is tabbed with it. + self.panel_title_rects.clear(); let right = self.dock(ui); let left = self.left_dock(ui); self.finish_panel_drag(ui, right, left); @@ -4113,6 +4188,94 @@ mod tests { assert_eq!(restored.panel_homes.len(), 2, "unknown home dropped"); } + /// A panel dropped on another panel's title is tabbed with it: + /// the two share one slot, the dropped panel is the tab the + /// group shows, and the group is one unit — a move carries the + /// whole group. + #[test] + fn a_dropped_panel_is_tabbed_with_the_panel_it_landed_on() { + let mut shell = gui_shell(gui_editor()); + assert!(shell.panel_groups.is_empty(), "no group before the drop"); + + shell.group_panels("Layers", "Properties"); + assert_eq!(shell.panel_groups.len(), 1); + assert_eq!(shell.panel_groups[0].members, vec!["Properties", "Layers"]); + assert_eq!(shell.panel_groups[0].active, "Layers"); + + // The group is one slot: the members live where the group + // does, and a move carries both. + shell.move_panel_to_dock("Layers", DockSide::Left); + assert_eq!(shell.panel_home("Properties"), DockSide::Left); + assert_eq!(shell.panel_home("Layers"), DockSide::Left); + assert_eq!( + shell.panels_in_dock(DockSide::Left), + vec!["Properties", "Layers"] + ); + } + + /// A closed panel leaves its tab group, and a group that drops + /// below two members is no longer a group. + #[test] + fn closing_a_panel_dissolves_its_group() { + let mut shell = gui_shell(gui_editor()); + shell.group_panels("Layers", "Properties"); + assert_eq!(shell.panel_groups.len(), 1); + + shell.run_action(&menus::Action::Shell(menus::ShellAction::TogglePanel( + "Layers", + ))); + assert!(shell.panel_groups.is_empty(), "a group of one is no group"); + } + + /// The tab groups survive a save: the group and the tab it shows + /// round-trip, a group naming a panel this build lacks loses that + /// member, and a group of one dissolves. + #[test] + fn the_panel_groups_round_trip_through_save() { + let mut shell = gui_shell(gui_editor()); + shell.group_panels("Layers", "Properties"); + let saved = shell.capture_layout(); + assert_eq!(saved.panel_groups.len(), 1); + + let encoded = ron_mid_encoded(&saved); + let decoded: ShellLayout = ron::from_str(&encoded).expect("layout decodes"); + let mut restored = gui_shell(gui_editor()); + restored.layout = decoded; + restored.apply_layout(); + assert_eq!(restored.panel_groups.len(), 1); + assert_eq!( + restored.panel_groups[0].members, + vec!["Properties", "Layers"] + ); + assert_eq!(restored.panel_groups[0].active, "Layers"); + + // A group naming panels this build lacks carries nothing. + let mut foreign = shell.capture_layout(); + foreign.panel_groups.push(PanelGroupLayout { + members: vec!["Not A Panel".to_owned(), "Also Not A Panel".to_owned()], + active: "Not A Panel".to_owned(), + }); + let mut restored = gui_shell(gui_editor()); + restored.layout = foreign; + restored.apply_layout(); + assert_eq!( + restored.panel_groups.len(), + 1, + "a group of unknown panels carries nothing" + ); + + // A group of one is not a group. + let mut lonely = shell.capture_layout(); + lonely.panel_groups.push(PanelGroupLayout { + members: vec!["Swatches".to_owned()], + active: "Swatches".to_owned(), + }); + let mut restored = gui_shell(gui_editor()); + restored.layout = lonely; + restored.apply_layout(); + assert_eq!(restored.panel_groups.len(), 1, "a group of one dissolves"); + } + /// The tool-flyout hold decision is a pure function of elapsed /// time: below the dwell it stays closed, at and past it the /// flyout opens, and a short press never opens it no matter how diff --git a/crates/app/src/shell/panel_windows.rs b/crates/app/src/shell/panel_windows.rs index 091ccef..8f5db4d 100644 --- a/crates/app/src/shell/panel_windows.rs +++ b/crates/app/src/shell/panel_windows.rs @@ -203,6 +203,23 @@ impl DockSide { /// column is a deliberate re-pin, never a silent drift. const DOCK_SIDE: DockSide = DockSide::Right; +/// A tab group: the panels that share one slot in a dock. +/// +/// CS6 lets a panel be dropped onto another panel's title; the two +/// then share one header row and the tab strip switches between +/// them. The group — not the panel — is what the dock draws and +/// moves, so a group is anchored on its first member: the anchor is +/// the member the group is drawn at, and the member a move carries +/// with it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PanelGroup { + /// The panels in the group, in tab order. The first member is + /// the group's anchor. + pub(crate) members: Vec<&'static str>, + /// The member whose content the group shows. + pub(crate) active: &'static str, +} + /// The payload a panel drag carries: which panel is being moved. #[derive(Clone, Copy)] struct PanelDrag { @@ -280,6 +297,18 @@ pub(crate) fn panel_spec(name: &str) -> Option { .or_else(|| (WEB_PANEL == name).then_some(WEB_PANEL_SPEC)) } +/// The panels the dock draws, in CS6's order: the toggleable panels +/// and then the always-present Web Layer panel. +/// +/// The Web Layer panel is a panel like any other, so it can be +/// tabbed with another panel — it only differs in that it carries no +/// close button. +fn dock_roster() -> Vec { + let mut roster: Vec = PANELS.to_vec(); + roster.push(WEB_PANEL_SPEC); + roster +} + /// The width of the right dock, in points — CS6's dock is a narrow /// column; the canvas keeps the rest. pub(crate) const DOCK_WIDTH: f32 = 258.0; @@ -344,26 +373,42 @@ impl Shell { .id_salt(scroll_id) .auto_shrink([false, false]) .show(ui, |ui| { - for spec in PANELS { - if self.panel_shown(spec.name) && self.panel_home(spec.name) == side { - self.dock_section(ui, spec, true, side); + for spec in dock_roster() { + if !self.panel_shown(spec.name) { + continue; + } + if self.panel_home(spec.name) != side { + continue; + } + match self.group_index(spec.name) { + // A panel that is alone in the dock + // draws its own header. + None => { + let closable = spec.kind != PanelKind::WebLayer; + self.dock_section(ui, &spec, closable, side); + } + // A tab group draws once, at its anchor. + Some(index) => { + let is_anchor = + self.panel_groups[index].members.first().copied() + == Some(spec.name); + if is_anchor { + self.dock_group(ui, index, side); + } + } } - } - // The Web Layer panel is always present and - // carries no close button. - if self.panel_home(WEB_PANEL) == side { - self.dock_section(ui, &WEB_PANEL_SPEC, false, side); } }); }); rect } - /// Resolves a finished panel drag: a panel released over a dock - /// column moves into that column, and a release anywhere else - /// cancels the move. Either way the payload is consumed — a - /// payload left behind would keep the left column on screen - /// forever. + /// Resolves a finished panel drag: a panel released over another + /// panel's title joins that panel's tab group, a panel released + /// over a dock column moves into that column, and a release + /// anywhere else cancels the move. Either way the payload is + /// consumed — a payload left behind would keep the left column on + /// screen forever. pub(crate) fn finish_panel_drag( &mut self, ui: &egui::Ui, @@ -376,10 +421,22 @@ impl Shell { let Some(drag) = egui::DragAndDrop::take_payload::(ui.ctx()) else { return; }; - let pointer = ui.ctx().pointer_interact_pos(); - let over = |column: Option| { - pointer.is_some_and(|pos| column.is_some_and(|rect| rect.contains(pos))) + let Some(pointer) = ui.ctx().pointer_interact_pos() else { + return; }; + // A panel dropped on another panel's title is tabbed with it + // — CS6's gesture. The title is the drop target, so a panel + // dropped on the column's empty space still moves. + let title = self + .panel_title_rects + .iter() + .find(|(name, rect)| *name != drag.name && rect.contains(pointer)) + .map(|(name, _)| *name); + if let Some(target) = title { + self.group_panels(drag.name, target); + return; + } + let over = |column: Option| column.is_some_and(|rect| rect.contains(pointer)); let target = if over(left) { Some(DockSide::Left) } else if over(right) { @@ -411,6 +468,9 @@ impl Shell { /// The panel's title is its drag handle — CS6 moves a panel by /// its header — and the small arrow beside the close button /// moves it to the other dock for anyone who cannot drag. + /// + /// The title's rect is recorded as the drop target that tabs this + /// panel with another one. fn dock_section( &mut self, ui: &mut egui::Ui, @@ -420,19 +480,27 @@ impl Shell { ) { let mut close = false; let mut move_to_other = false; + let mut title_rect = None; let state = egui::collapsing_header::CollapsingState::load_with_default_open( ui.ctx(), egui::Id::new(("cs6_dock_panel", spec.name)), true, ); let header = state.show_header(ui, |ui| { - let _drag = ui.dnd_drag_source( - egui::Id::new(("cs6_panel_drag", spec.name)), - PanelDrag { name: spec.name }, - |ui| { - ui.strong(spec.name); - }, - ); + let drag_id = egui::Id::new(("cs6_panel_drag", spec.name)); + let dragging = ui.ctx().is_being_dragged(drag_id); + let _drag = ui.dnd_drag_source(drag_id, PanelDrag { name: spec.name }, |ui| { + let title = ui.strong(spec.name); + title_rect = Some(title.rect); + }); + if !dragging { + // The title senses clicks as well as drags, so egui + // waits for the pointer to move before it calls the + // drag started: a click that never moved drops + // nothing and merges no panels. + let rect = title_rect.unwrap_or(egui::Rect::NOTHING); + let _title = ui.interact(rect, drag_id, egui::Sense::click()); + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if closable { close = ui.small_button("×").clicked(); @@ -451,29 +519,11 @@ impl Shell { }); header.body_unindented(|ui| { ui.set_min_width(DOCK_CONTENT_WIDTH); - match spec.kind { - PanelKind::Properties => self.properties_panel(ui), - PanelKind::Pages => self.pages_section(ui), - PanelKind::States => self.states_section(ui), - PanelKind::Layers => self.layers_section(ui), - PanelKind::Styles => self.styles_panel(ui), - PanelKind::Library => self.library_panel(ui), - PanelKind::History => self.history_section(ui), - PanelKind::ColorMixer => self.fill_stroke_panel(ui), - PanelKind::Swatches => self.swatches_panel(ui), - PanelKind::Info => self.info_panel(ui), - PanelKind::AutoShapes => self.auto_shapes_panel(ui), - PanelKind::Url => self.url_panel(ui), - PanelKind::Behaviors => self.behaviors_panel(ui), - PanelKind::WebLayer => self.web_panel(ui), - PanelKind::Optimize => self.optimize_panel(ui), - PanelKind::Slices => self.slices_panel(ui), - PanelKind::HtmlPreview => self.html_preview_panel(ui), - PanelKind::CssProperties => self.css_properties_panel(ui), - PanelKind::FindReplace => self.find_replace_panel(ui), - PanelKind::CommonLibrary => self.common_library_panel(ui), - } + self.draw_panel_content(ui, spec); }); + if let Some(rect) = title_rect { + self.panel_title_rects.push((spec.name, rect)); + } // The close button and the Window menu are the same switch. if close { self.hide_panel(spec.name); @@ -483,22 +533,313 @@ impl Shell { } } + /// One slot of the dock holding a tab group: the group's tab + /// strip in the header row, the active member's content below. + /// + /// CS6 draws a tabbed group as one panel with a row of tabs, and + /// the tab strip is the group's header: the collapse arrow folds + /// the whole group, and a tab switches the content. + /// + /// The group is dragged by its tabs, and the tab a panel is + /// dropped on is the tab it joins. + fn dock_group(&mut self, ui: &mut egui::Ui, group_index: usize, side: DockSide) { + let Some(group) = self.panel_groups.get(group_index) else { + return; + }; + let members = group.members.clone(); + let active = group.active; + let Some(anchor) = members.first().copied() else { + return; + }; + let mut activate: Option<&'static str> = None; + let mut close = false; + let mut move_to_other = false; + let mut ungroup = false; + let mut tab_rects: Vec<(&'static str, egui::Rect)> = Vec::new(); + let state = egui::collapsing_header::CollapsingState::load_with_default_open( + ui.ctx(), + egui::Id::new(("cs6_dock_group", anchor)), + true, + ); + let header = state.show_header(ui, |ui| { + ui.horizontal(|ui| { + for member in &members { + let selected = *member == active; + let drag_id = egui::Id::new(("cs6_panel_drag", *member)); + let dragging = ui.ctx().is_being_dragged(drag_id); + let drag = ui.dnd_drag_source( + drag_id, + PanelDrag { name: *member }, + // The tab is drawn, not clicked: the drag source + // below owns the click, so that a single widget + // can both switch the tab and drag the group. + |ui| { + egui::Button::selectable(selected, *member) + .sense(egui::Sense::hover()) + .ui(ui) + }, + ); + // One widget, both senses. egui postpones the + // click-or-drag decision until the pointer moves, + // so a click still switches the tab and a drag + // still moves the group. + let clicked = !dragging + && ui + .interact(drag.response.rect, drag_id, egui::Sense::click()) + .clicked(); + if clicked { + activate = Some(*member); + } + tab_rects.push((*member, drag.response.rect)); + } + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + close = ui + .small_button("×") + .on_hover_text("Close this panel") + .clicked(); + ungroup = ui + .small_button("split") + .on_hover_text("Ungroup the panels") + .clicked(); + move_to_other = ui + .small_button(match side { + DockSide::Left => "»", + DockSide::Right => "«", + }) + .on_hover_text(match side { + DockSide::Left => "Move the panel group to the right dock", + DockSide::Right => "Move the panel group to the left dock", + }) + .clicked(); + }); + }); + header.body_unindented(|ui| { + ui.set_min_width(DOCK_CONTENT_WIDTH); + if let Some(spec) = panel_spec(active) { + self.draw_panel_content(ui, &spec); + } + }); + for (name, rect) in tab_rects { + self.panel_title_rects.push((name, rect)); + } + if let Some(member) = activate { + self.set_active_tab(group_index, member); + } + if close { + self.hide_panel(active); + } + if ungroup { + self.ungroup_group(anchor); + } + if move_to_other { + self.move_panel_to_dock(anchor, side.other()); + } + } + + /// The content a panel draws. Shared by the plain dock panels and + /// by the tab groups, so a tabbed panel shows exactly what it + /// shows on its own. + fn draw_panel_content(&mut self, ui: &mut egui::Ui, spec: &PanelSpec) { + match spec.kind { + PanelKind::Properties => self.properties_panel(ui), + PanelKind::Pages => self.pages_section(ui), + PanelKind::States => self.states_section(ui), + PanelKind::Layers => self.layers_section(ui), + PanelKind::Styles => self.styles_panel(ui), + PanelKind::Library => self.library_panel(ui), + PanelKind::History => self.history_section(ui), + PanelKind::ColorMixer => self.fill_stroke_panel(ui), + PanelKind::Swatches => self.swatches_panel(ui), + PanelKind::Info => self.info_panel(ui), + PanelKind::AutoShapes => self.auto_shapes_panel(ui), + PanelKind::Url => self.url_panel(ui), + PanelKind::Behaviors => self.behaviors_panel(ui), + PanelKind::WebLayer => self.web_panel(ui), + PanelKind::Optimize => self.optimize_panel(ui), + PanelKind::Slices => self.slices_panel(ui), + PanelKind::HtmlPreview => self.html_preview_panel(ui), + PanelKind::CssProperties => self.css_properties_panel(ui), + PanelKind::FindReplace => self.find_replace_panel(ui), + PanelKind::CommonLibrary => self.common_library_panel(ui), + } + } + + /// The group a panel belongs to, if it is in one. + fn group_of(&self, name: &str) -> Option<&PanelGroup> { + self.panel_groups + .iter() + .find(|group| group.members.iter().any(|member| *member == name)) + } + + /// The index of the group a panel belongs to, if it is in one. + fn group_index(&self, name: &str) -> Option { + self.panel_groups + .iter() + .position(|group| group.members.iter().any(|member| *member == name)) + } + + /// The anchor of the group a panel belongs to: the panel the + /// group is drawn at, and the member a move carries with it. + fn group_anchor_of(&self, name: &str) -> Option<&'static str> { + self.group_of(name) + .and_then(|group| group.members.first().copied()) + } + /// The dock a panel lives in. A panel the user has not moved - /// lives in the right stack, where CS6 keeps its panel group. + /// lives in the right stack, where CS6 keeps its panel group. A + /// panel in a tab group lives where its group does. pub(crate) fn panel_home(&self, name: &str) -> DockSide { + let anchor = self.group_anchor_of(name); self.panel_homes .iter() - .find(|(panel, _)| *panel == name) + .find(|(panel, _)| match anchor { + Some(anchor) => *panel == anchor, + None => *panel == name, + }) .map_or(DOCK_SIDE, |(_, side)| *side) } - /// Moves a panel into a dock. A panel moved back to the default - /// dock drops out of the moved set, so the saved layout stays - /// small and "not mentioned" keeps meaning "the default". + /// Moves a panel into a dock. A panel that belongs to a tab group + /// takes the whole group with it — the group is one slot. + /// + /// A panel moved back to the default dock drops out of the moved + /// set, so the saved layout stays small and "not mentioned" keeps + /// meaning "the default". pub(crate) fn move_panel_to_dock(&mut self, name: &'static str, side: DockSide) { - self.panel_homes.retain(|(panel, _)| *panel != name); + let members: Vec<&'static str> = self + .group_of(name) + .map_or_else(|| vec![name], |group| group.members.clone()); + let Some(anchor) = members.first().copied() else { + return; + }; + for member in &members { + self.panel_homes.retain(|(moved, _)| moved != member); + } if side != DOCK_SIDE { - self.panel_homes.push((name, side)); + self.panel_homes.push((anchor, side)); + } + } + + /// Tabs a panel with another one: the two share one slot, and + /// the tab strip switches between them. + /// + /// A panel that is already in a group brings its group along — + /// the two groups merge — and the dragged panel becomes the + /// group's active tab. The merged group keeps the target's dock: + /// the group is one slot, and it stays where the target was. + pub(crate) fn group_panels(&mut self, panel: &'static str, target: &'static str) { + if panel == target { + return; + } + let mut members: Vec<&'static str> = self + .group_of(target) + .map_or_else(|| vec![target], |group| group.members.clone()); + if let Some(group) = self.group_of(panel) { + for member in &group.members { + if !members.contains(member) { + members.push(*member); + } + } + } + if !members.contains(&panel) { + members.push(panel); + } + let Some(anchor) = members.first().copied() else { + return; + }; + // The merged group stays in the dock the target was in. + let home = self.panel_home(target); + // The groups that took part are replaced by the merged one. + self.panel_groups.retain(|group| { + !group + .members + .iter() + .any(|member| *member == panel || *member == target) + }); + // A member's dock is the group's dock now. + for member in &members { + self.panel_homes.retain(|(moved, _)| moved != member); + } + if home != DOCK_SIDE { + self.panel_homes.push((anchor, home)); + } + self.panel_groups.push(PanelGroup { + members, + active: panel, + }); + } + + /// Switches a tab group to another member. + pub(crate) fn set_active_tab(&mut self, group_index: usize, member: &'static str) { + if let Some(group) = self.panel_groups.get_mut(group_index) { + if group.members.contains(&member) { + group.active = member; + } + } + } + + /// Splits a tab group: every member becomes its own panel again, + /// and every member keeps the dock the group was in. + pub(crate) fn ungroup_group(&mut self, name: &'static str) { + let Some(members) = self.group_of(name).map(|group| group.members.clone()) else { + return; + }; + let home = self.panel_home(name); + self.panel_groups + .retain(|group| !group.members.iter().any(|member| *member == name)); + for member in &members { + self.panel_homes.retain(|(moved, _)| moved != member); + } + if home != DOCK_SIDE { + for member in &members { + self.panel_homes.push((*member, home)); + } + } + } + + /// Takes a panel out of its tab group. A group that drops below + /// two members is no longer a group, and dissolves — the members + /// it leaves behind keep the dock the group was in. + pub(crate) fn leave_group(&mut self, name: &'static str) { + let Some(index) = self.group_index(name) else { + return; + }; + let home = self.panel_home(name); + let anchor_before = self.panel_groups[index].members.first().copied(); + if let Some(group) = self.panel_groups.get_mut(index) { + group.members.retain(|member| *member != name); + if group.active == name { + group.active = group.members.first().copied().unwrap_or(name); + } + } + let members = self.panel_groups[index].members.clone(); + if members.len() < 2 { + // The group dissolves; the members keep its dock. + self.panel_groups.remove(index); + for member in &members { + self.panel_homes.retain(|(moved, _)| moved != member); + } + self.panel_homes.retain(|(moved, _)| *moved != name); + if home != DOCK_SIDE { + for member in &members { + self.panel_homes.push((*member, home)); + } + } + return; + } + // The group survives. If its anchor left, the dock follows + // the group to its new anchor. + let anchor_after = members.first().copied(); + if anchor_after != anchor_before { + if let Some(previous) = anchor_before { + self.panel_homes.retain(|(moved, _)| *moved != previous); + } + if home != DOCK_SIDE { + if let Some(anchor) = anchor_after { + self.panel_homes.push((anchor, home)); + } + } } } @@ -513,11 +854,15 @@ impl Shell { /// Records a panel as hidden (the Window menu's toggle uses the /// same list). + /// + /// A hidden panel leaves its tab group: a tab for a panel the + /// user cannot see is a tab that shows nothing. fn hide_panel(&mut self, name: &'static str) { if let Some(pos) = self.panel_hidden.iter().position(|n| *n == name) { self.panel_hidden.remove(pos); } self.panel_hidden.push(name); + self.leave_group(name); } /// The panels CS6 opens on a fresh document — the initial value of diff --git a/crates/app/src/shell/panels.rs b/crates/app/src/shell/panels.rs index 1647cd5..c851fb0 100644 --- a/crates/app/src/shell/panels.rs +++ b/crates/app/src/shell/panels.rs @@ -173,6 +173,9 @@ impl Shell { self.status = format!("{name} panel shown."); } else { self.panel_hidden.push(name); + // A hidden panel leaves its tab group: a tab for a + // panel the user cannot see shows nothing. + self.leave_group(name); self.status = format!("{name} panel hidden."); } } else { From fbd2a113ffa03d6d5760e6e73fbe23e26530438e Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Wed, 11 Nov 2026 17:04:05 -0600 Subject: [PATCH 05/10] fix(app): the panel drag cancel is a module function, not a method The shell calls it as `panel_windows::cancel_panel_drag(ctx)`, but it was defined inside `impl Shell`, so the module path did not resolve and the app crate did not compile. The function needs no shell state: it takes the context and clears the drag payload. Co-Authored-By: Claude Code --- crates/app/src/shell/panel_windows.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/app/src/shell/panel_windows.rs b/crates/app/src/shell/panel_windows.rs index 8f5db4d..c07a5c5 100644 --- a/crates/app/src/shell/panel_windows.rs +++ b/crates/app/src/shell/panel_windows.rs @@ -227,6 +227,15 @@ struct PanelDrag { name: &'static str, } +/// Drops a panel drag in flight. Called when the docks are not +/// drawn: a payload left behind would move a panel on a later +/// release, long after the drag the user made. +pub(crate) fn cancel_panel_drag(ctx: &egui::Context) { + if egui::DragAndDrop::has_payload_of_type::(ctx) { + egui::DragAndDrop::clear_payload(ctx); + } +} + /// One panel of the CS6 inventory, as plain data. /// /// The panel *structure* — the set, the dock order, the dock side, @@ -449,15 +458,6 @@ impl Shell { } } - /// Drops a panel drag in flight. Called when the docks are not - /// drawn: a payload left behind would move a panel on a later - /// release, long after the drag the user made. - pub(crate) fn cancel_panel_drag(ctx: &egui::Context) { - if egui::DragAndDrop::has_payload_of_type::(ctx) { - egui::DragAndDrop::clear_payload(ctx); - } - } - /// One panel in the dock: a CS6 header (collapse arrow, name, /// close button) over the panel's content. /// From 1b78359ce10cf06d8985e95389882e09622e2633 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Wed, 11 Nov 2026 22:41:05 -0600 Subject: [PATCH 06/10] docs(tasks): the panel set is done, tab groups and all Co-Authored-By: Claude Code --- docs/TASKS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/TASKS.md b/docs/TASKS.md index 0060913..9e219dc 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -50,7 +50,7 @@ | P1 | Reconcile live web deployment | Artifact differs | The public manifest's JS/wasm/index hashes differ from local `dist/web`; identify intended deploy and verify it before publishing. See [T18](tasks/T18-release.md). | | P2 | Next-release asset breadth and platform verification | Open | [T23](tasks/T23-release-assets.md); recipes exist, but non-Linux artifacts need real platform build/launch receipts. | | P2 | Coverage and test quality ratchet | Open | Replace the old aspirational 99% label with measured per-crate targets and snapshots; [NEXT.md](NEXT.md). | -| P2 | Phase 22.1: the dockable panel set — docking, tab groups, layout persistence | In progress | [issue #91](https://github.com/aliasfoxkde/FireCraft/issues/91). Docking (a panel moves between the left and right columns, by drag or by the header arrow) and its persistence landed 2026-11-11 in PR #128; tab groups are the remaining step. | +| P2 | Phase 22.1: the dockable panel set — docking, tab groups, layout persistence | Complete | [issue #91](https://github.com/aliasfoxkde/FireCraft/issues/91). Docking (a panel moves between the left and right columns, by drag or by the header arrow), tab groups (a panel dropped on another panel's title shares its slot — the group is one unit that the dock draws, moves and saves), and their persistence landed 2026-11-11 in PR #128; the merge waits on that branch's receipts. | | P2 | Aegis gate: content-keyed baseline, so a doc edit no longer fails the gate | **Done — landed on `main`** | [decisions/0074-aegis-content-keyed-baseline.md](decisions/0074-aegis-content-keyed-baseline.md). The baseline keys on content (pattern, file, digest) rather than on line, and every rule class stays armed. | ### Release & tooling lanes in flight @@ -134,7 +134,7 @@ progress: | Item | State at `5fac7a2` | Remaining evidence/work | |---|---|---| -| 22.1 Panel inventory and docking | Complete | The right dock is a resizable, scrolling column of stacked panels in CS6's order (`crates/app/src/shell/panel_windows.rs`: 18-panel inventory, collapse/close headers, scroll keeps every panel reachable at any viewport height). All surfaces dispatch, including the previously-missing Optimize, Slices, CSS Properties, Find and Replace, and Common Library panels (landed 2026-11-06). Common Library lists the document's own symbols — CS6's shipped libraries are proprietary resources and are not copied. **The movement model landed 2026-11-11 in PR #128**: a panel's title is its drag handle, both columns are drop targets, the left column appears when a panel is docked there or a drag is in flight, the `«`/`»` arrow moves a panel without dragging, and only *moved* panels are persisted (unknown names dropped on restore). The default arrangement is unchanged — the panel snapshot still records every panel on the right, in CS6's order. Tab groups remain open (issue #91, step 3). | +| 22.1 Panel inventory and docking | Complete | The right dock is a resizable, scrolling column of stacked panels in CS6's order (`crates/app/src/shell/panel_windows.rs`: 18-panel inventory, collapse/close headers, scroll keeps every panel reachable at any viewport height). All surfaces dispatch, including the previously-missing Optimize, Slices, CSS Properties, Find and Replace, and Common Library panels (landed 2026-11-06). Common Library lists the document's own symbols — CS6's shipped libraries are proprietary resources and are not copied. **The movement model landed 2026-11-11 in PR #128**: a panel's title is its drag handle, both columns are drop targets, the left column appears when a panel is docked there or a drag is in flight, the `«`/`»` arrow moves a panel without dragging, and only *moved* panels are persisted (unknown names dropped on restore). The default arrangement is unchanged — the panel snapshot still records every panel on the right, in CS6's order. **Tab groups landed 2026-11-11 in the same PR**: dropping a panel on another panel's title tabs the two together, the group is one slot that the dock draws, moves and saves (its members and the tab it shows round-trip through the layout file), a group of one dissolves, and a hidden panel leaves its group. The tab is both the switch and the handle — one widget senses the click and the drag, and egui decides between them by whether the pointer moved (issue #91, step 3, closed by PR #128). | | 22.2 Tool strip and flyouts | Complete | The strip is CS6's, as data in `crates/app/src/toolstrip.rs`: six categories in CS6's order, twenty-four groups with their one-key shortcuts, the flyout memberships, and CS6's group-cycling rule. Evidence in [`research/fw-ui-evidence.md`](research/fw-ui-evidence.md); landed 2026-11-05. | | 22.3 Adaptive canvas context menu | Complete | Selection-specific rows dispatch through the existing command surface. | | 22.4 Property inspector | Partial | Per-kind field sets landed 2026-11-04 (group, bitmap, slice); the document-level and multi-selection field sets and enter-group-by-selection landed 2026-11-06 (test `inspector_document_and_multi_selection_sets`). Open: the bitmap transform strip and fill/stroke, both blocked by decision 0019 / absent `IMGv` fill-stroke nodes (recorded, not faked). | From dce9b99599f7ba0e86f8ad63b7e7235393917990 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Wed, 11 Nov 2026 23:29:59 -0600 Subject: [PATCH 07/10] fix(app): the tab group's button needs the Widget trait in scope `egui::Button::selectable(...).ui(ui)` calls the `Widget` trait's `ui` method, and the trait was not in scope: the app crate failed to compile with E0599 at panel_windows.rs:579, so the gate failed on this branch. Co-Authored-By: Claude Code --- crates/app/src/shell/panel_windows.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/app/src/shell/panel_windows.rs b/crates/app/src/shell/panel_windows.rs index c07a5c5..b73879f 100644 --- a/crates/app/src/shell/panel_windows.rs +++ b/crates/app/src/shell/panel_windows.rs @@ -20,6 +20,7 @@ //! inventory, the dock, and the open/closed bookkeeping. use super::*; +use egui::Widget; /// The panels this shell implements, in the CS6 right-dock order. pub(crate) const PANELS: &[PanelSpec] = &[ From 4fb1d5877a9c1d58dd52833fe76f69111fb7a7ae Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Wed, 11 Nov 2026 23:52:24 -0600 Subject: [PATCH 08/10] fix(app): the panel group code trips clippy on the CI toolchain The panel docking work was written against an older clippy, and the CI image's 1.99 toolchain rejects six lints in panel_windows.rs: a needless deref on the drag payload, three `iter().any()` membership scans that `contains` expresses directly, and two nested `if`s that collapse into let-chains. The group lookups now take a `&'static str` panel name, which is what every caller already had, so `contains` type-checks against the group's `Vec<&'static str>` members. Co-Authored-By: Claude Code --- crates/app/src/shell/panel_windows.rs | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/app/src/shell/panel_windows.rs b/crates/app/src/shell/panel_windows.rs index b73879f..aa9a936 100644 --- a/crates/app/src/shell/panel_windows.rs +++ b/crates/app/src/shell/panel_windows.rs @@ -570,7 +570,7 @@ impl Shell { let dragging = ui.ctx().is_being_dragged(drag_id); let drag = ui.dnd_drag_source( drag_id, - PanelDrag { name: *member }, + PanelDrag { name: member }, // The tab is drawn, not clicked: the drag source // below owns the click, so that a single widget // can both switch the tab and drag the group. @@ -667,22 +667,22 @@ impl Shell { } /// The group a panel belongs to, if it is in one. - fn group_of(&self, name: &str) -> Option<&PanelGroup> { + fn group_of(&self, name: &'static str) -> Option<&PanelGroup> { self.panel_groups .iter() - .find(|group| group.members.iter().any(|member| *member == name)) + .find(|group| group.members.contains(&name)) } /// The index of the group a panel belongs to, if it is in one. - fn group_index(&self, name: &str) -> Option { + fn group_index(&self, name: &'static str) -> Option { self.panel_groups .iter() - .position(|group| group.members.iter().any(|member| *member == name)) + .position(|group| group.members.contains(&name)) } /// The anchor of the group a panel belongs to: the panel the /// group is drawn at, and the member a move carries with it. - fn group_anchor_of(&self, name: &str) -> Option<&'static str> { + fn group_anchor_of(&self, name: &'static str) -> Option<&'static str> { self.group_of(name) .and_then(|group| group.members.first().copied()) } @@ -690,7 +690,7 @@ impl Shell { /// The dock a panel lives in. A panel the user has not moved /// lives in the right stack, where CS6 keeps its panel group. A /// panel in a tab group lives where its group does. - pub(crate) fn panel_home(&self, name: &str) -> DockSide { + pub(crate) fn panel_home(&self, name: &'static str) -> DockSide { let anchor = self.group_anchor_of(name); self.panel_homes .iter() @@ -773,10 +773,10 @@ impl Shell { /// Switches a tab group to another member. pub(crate) fn set_active_tab(&mut self, group_index: usize, member: &'static str) { - if let Some(group) = self.panel_groups.get_mut(group_index) { - if group.members.contains(&member) { - group.active = member; - } + if let Some(group) = self.panel_groups.get_mut(group_index) + && group.members.contains(&member) + { + group.active = member; } } @@ -788,7 +788,7 @@ impl Shell { }; let home = self.panel_home(name); self.panel_groups - .retain(|group| !group.members.iter().any(|member| *member == name)); + .retain(|group| !group.members.contains(&name)); for member in &members { self.panel_homes.retain(|(moved, _)| moved != member); } @@ -836,10 +836,10 @@ impl Shell { if let Some(previous) = anchor_before { self.panel_homes.retain(|(moved, _)| *moved != previous); } - if home != DOCK_SIDE { - if let Some(anchor) = anchor_after { - self.panel_homes.push((anchor, home)); - } + if home != DOCK_SIDE + && let Some(anchor) = anchor_after + { + self.panel_homes.push((anchor, home)); } } } From aa9e0972d047c69268cf64d44fc9299392a0ca50 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 00:21:44 -0600 Subject: [PATCH 09/10] test(app): cover the panel docking and tab-group model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel-docking branch adds the dock columns, the drag resolution, the tab groups and the layout persistence. The gate measures function coverage across the workspace, and the branch's new functions were uncovered: the measured function coverage fell under the floor. These tests cover the model and the drawing: - the spec lookup, the dock roster and the dock sides; - the group operations — switching the active tab, splitting a group, and a panel that leaves its group; - the panels a dock lists, and the roster's order; - one headless egui frame that draws the right dock, the left dock and the drag resolution, so the drawing path runs without a window. Co-Authored-By: Claude Code --- crates/app/src/shell/panel_windows.rs | 171 ++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/crates/app/src/shell/panel_windows.rs b/crates/app/src/shell/panel_windows.rs index aa9a936..cbcc9a9 100644 --- a/crates/app/src/shell/panel_windows.rs +++ b/crates/app/src/shell/panel_windows.rs @@ -881,6 +881,10 @@ impl Shell { #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { use super::*; + // The shell's scripted-dialogs helpers live in the shell module's + // own test module. They are visible here because this module is a + // child of the shell. + use super::super::tests::{gui_editor, gui_shell}; /// The panel inventory carries the CS6 panel names the Window menu /// toggles, and every one of those names is a panel this shell @@ -965,4 +969,171 @@ mod tests { const { assert!(DOCK_WIDTH >= 240.0) }; const { assert!(DOCK_WIDTH <= 300.0) }; } + + /// The spec lookup knows every panel of the inventory — including + /// the Web Layer panel, which has no Window-menu row but is a + /// panel all the same — and refuses a name that is not a panel. + #[test] + fn the_spec_lookup_knows_every_panel() { + assert_eq!( + panel_spec("Properties").map(|spec| spec.name), + Some("Properties") + ); + assert_eq!( + panel_spec(WEB_PANEL).map(|spec| spec.kind), + Some(PanelKind::WebLayer) + ); + assert!(panel_spec("Not A Panel").is_none()); + } + + /// The dock's roster is the toggleable panels in CS6's order, + /// followed by the always-present Web Layer panel. + #[test] + fn the_dock_roster_carries_the_web_panel_last() { + let roster = dock_roster(); + assert_eq!(roster.len(), PANELS.len() + 1); + assert_eq!(roster.first().map(|spec| spec.name), Some("Properties")); + assert_eq!(roster.last().map(|spec| spec.name), Some(WEB_PANEL)); + } + + /// A dock side names itself for the panel snapshot, and a panel + /// moves to the other side. + #[test] + fn a_dock_side_names_itself_and_has_an_other() { + assert_eq!(DockSide::Left.as_str(), "left"); + assert_eq!(DockSide::Right.as_str(), "right"); + assert_eq!(DockSide::Left.other(), DockSide::Right); + assert_eq!(DockSide::Right.other(), DockSide::Left); + } + + /// A tab group switches to another member, and refuses a member + /// it does not have and a group that does not exist. + #[test] + fn a_group_switches_to_another_member() { + let mut shell = gui_shell(gui_editor()); + shell.group_panels("Layers", "Properties"); + assert_eq!(shell.panel_groups.len(), 1); + + shell.set_active_tab(0, "Properties"); + assert_eq!(shell.panel_groups[0].active, "Properties"); + + // A member the group does not have is refused. + shell.set_active_tab(0, "History"); + assert_eq!(shell.panel_groups[0].active, "Properties"); + + // A group that does not exist is refused. + shell.set_active_tab(9, "Properties"); + assert_eq!(shell.panel_groups[0].active, "Properties"); + } + + /// Splitting a group gives every member its own slot again, and + /// every member keeps the dock the group was in. + #[test] + fn splitting_a_group_gives_every_member_its_own_slot() { + let mut shell = gui_shell(gui_editor()); + shell.move_panel_to_dock("Properties", DockSide::Left); + shell.group_panels("Layers", "Properties"); + assert_eq!(shell.panel_groups.len(), 1); + + shell.ungroup_group("Properties"); + assert!(shell.panel_groups.is_empty(), "the group is split"); + assert_eq!(shell.panel_home("Properties"), DockSide::Left); + assert_eq!(shell.panel_home("Layers"), DockSide::Left); + + // A panel that is in no group changes nothing. + shell.ungroup_group("History"); + assert!(shell.panel_groups.is_empty()); + } + + /// A panel that leaves its group takes the group with it: the + /// group dissolves when it drops below two members, and the + /// members keep the dock the group was in. + #[test] + fn a_panel_that_leaves_its_group_dissolves_it() { + let mut shell = gui_shell(gui_editor()); + shell.move_panel_to_dock("Properties", DockSide::Left); + shell.group_panels("Layers", "Properties"); + assert_eq!(shell.panel_groups.len(), 1); + + shell.leave_group("Layers"); + assert!(shell.panel_groups.is_empty(), "a group of one is no group"); + assert_eq!(shell.panel_home("Properties"), DockSide::Left); + assert_eq!(shell.panel_home("Layers"), DockSide::Left); + + // A panel that is in no group changes nothing. + shell.leave_group("History"); + assert!(shell.panel_groups.is_empty()); + } + + /// The panels of a dock are the panels that live in it, in CS6's + /// roster order. + #[test] + fn a_dock_lists_the_panels_that_live_in_it() { + let mut shell = gui_shell(gui_editor()); + assert!(shell.panels_in_dock(DockSide::Left).is_empty()); + assert_eq!( + shell.panels_in_dock(DockSide::Right), + PANELS.iter().map(|spec| spec.name).collect::>() + ); + + shell.move_panel_to_dock("Layers", DockSide::Left); + assert_eq!(shell.panels_in_dock(DockSide::Left), vec!["Layers"]); + assert!(!shell.panels_in_dock(DockSide::Right).contains(&"Layers")); + } + + /// One headless frame with the dock drawn: the right dock, the + /// left dock, and the drag resolution all run without a window. + /// + /// The left dock exists only while a panel is in it or a drag is + /// in flight, so the frame moves a panel left first — the drop + /// target has to be there before a drag reaches it. + fn draw_one_frame(shell: &mut Shell) { + let ctx = egui::Context::default(); + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + let _ = shell.dock(ui); + let _ = shell.left_dock(ui); + shell.finish_panel_drag(ui, None, None); + }); + cancel_panel_drag(&ctx); + } + + /// The dock draws: the panels CS6 opens on a fresh document are + /// in it, a panel moved to the other dock is in the other dock, + /// and a tab group draws once, at its anchor. + #[test] + fn the_dock_draws_the_panels_cs6_opens() { + let mut shell = gui_shell(gui_editor()); + draw_one_frame(&mut shell); + + // The right dock draws the panels that are open by default. + assert!( + shell + .panels_in_dock(DockSide::Right) + .contains(&"Properties"), + "the right dock draws Properties" + ); + // The left dock has no panel in it yet, and no drag is in + // flight, so it is not drawn at all. + assert!(shell.panels_in_dock(DockSide::Left).is_empty()); + + // A panel moved to the other dock moves with it. + shell.move_panel_to_dock("Properties", DockSide::Left); + draw_one_frame(&mut shell); + assert_eq!(shell.panel_home("Properties"), DockSide::Left); + assert_eq!(shell.panels_in_dock(DockSide::Left), vec!["Properties"]); + } + + /// A tab group draws as one slot: the group's members share the + /// slot, and the group is drawn at its anchor. + #[test] + fn a_tab_group_draws_as_one_slot() { + let mut shell = gui_shell(gui_editor()); + shell.group_panels("Layers", "Properties"); + assert_eq!(shell.panel_groups.len(), 1); + draw_one_frame(&mut shell); + // The frame drew the group; the group's members are still + // one slot, and the group survived the frame. + assert_eq!(shell.panel_groups.len(), 1); + assert_eq!(shell.panel_groups[0].members, vec!["Properties", "Layers"]); + } } From 11feb228b2d4526233abe4b8b5941ddddf348ea8 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 00:36:23 -0600 Subject: [PATCH 10/10] fix(app): a panel that leaves its group keeps the group's dock Three of the new panel-docking tests failed on the CI toolchain: - The headless frame dropped egui's font textures unapplied, which epaint panics on. The frame's output is now dropped without applying its deltas, which is what a windowless frame can do. - A panel that leaves its tab group fell back to the default dock while the group it was drawn in stayed in the other one. The leaver now keeps the dock the group was in, like the members it leaves behind. Co-Authored-By: Claude Code --- crates/app/src/shell/panel_windows.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/app/src/shell/panel_windows.rs b/crates/app/src/shell/panel_windows.rs index cbcc9a9..0a2835f 100644 --- a/crates/app/src/shell/panel_windows.rs +++ b/crates/app/src/shell/panel_windows.rs @@ -801,7 +801,9 @@ impl Shell { /// Takes a panel out of its tab group. A group that drops below /// two members is no longer a group, and dissolves — the members - /// it leaves behind keep the dock the group was in. + /// it leaves behind keep the dock the group was in, and so does + /// the panel that left: it was drawn in that dock a moment ago, + /// so it must not jump to the other one. pub(crate) fn leave_group(&mut self, name: &'static str) { let Some(index) = self.group_index(name) else { return; @@ -826,6 +828,7 @@ impl Shell { for member in &members { self.panel_homes.push((*member, home)); } + self.panel_homes.push((name, home)); } return; } @@ -1089,11 +1092,15 @@ mod tests { /// target has to be there before a drag reaches it. fn draw_one_frame(shell: &mut Shell) { let ctx = egui::Context::default(); - let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + let output = ctx.run_ui(egui::RawInput::default(), |ui| { let _ = shell.dock(ui); let _ = shell.left_dock(ui); shell.finish_panel_drag(ui, None, None); }); + // The frame's font textures are never applied — there is no + // window to paint them into — and dropping them unapplied is + // a panic in epaint. + output.drop_without_applying_deltas(); cancel_panel_drag(&ctx); }