Implement stable serialization and restoration for dock layouts, including auto-hidden and floating panels

- Add `persistence.rs` for stable serialization of dock layouts without runtime identifiers.
- Introduce `preview.rs` for geometry handling of dock drop previews.
- Create `sizing.rs` to manage content-aware sizing policies and constraint solving for dock panels.
- Implement `welcome.rs` to provide a welcome surface when no documents are open, featuring New and Open actions.
This commit is contained in:
2026-07-16 22:10:22 +03:00
parent 1240d25011
commit b09795ccff
72 changed files with 11898 additions and 3115 deletions
+472 -33
View File
@@ -14,6 +14,7 @@
use crate::ai_chat::{self, AiChatState, ChatMessage, SYSTEM_PROMPT};
use crate::dialogs;
use crate::dock::manager::DockDragState;
use crate::dock::state::{DockState, PaneType};
use crate::i18n;
use crate::io::tablet::TabletState;
@@ -117,6 +118,8 @@ pub enum ActiveDialog {
pub struct HcieIcedApp {
/// All open documents.
pub documents: Vec<IcedDocument>,
/// Whether the center document host shows the post-close welcome surface.
pub show_welcome: bool,
/// Index of the currently active document.
pub active_doc: usize,
/// Current tool state.
@@ -284,6 +287,8 @@ pub struct HcieIcedApp {
pub vector_shapes_snapshot: Option<crate::vector_edit::VectorEditSnapshot>,
/// Last drag position during vector shape editing (canvas coordinates).
pub vector_drag_last: Option<(f32, f32)>,
/// Timestamp of the last expensive vector rerasterization during a drag.
pub vector_drag_last_render: Option<std::time::Instant>,
/// Immutable basis for the active vector drag.
pub vector_drag_session: Option<crate::vector_edit::VectorDragSession>,
/// Boolean operation shape A selector index (geometry panel).
@@ -838,9 +843,39 @@ pub enum Message {
// ── Dock Layout ─────────────────────────────────────
PaneClicked(iced::widget::pane_grid::Pane),
PaneDragged(iced::widget::pane_grid::DragEvent),
/// Begins a reliable custom drag from the visible dock title content.
DockHeaderDragStart(iced::widget::pane_grid::Pane),
PaneResized(iced::widget::pane_grid::ResizeEvent),
PaneClose(iced::widget::pane_grid::Pane),
PaneMaximize(iced::widget::pane_grid::Pane),
/// Moves a tool pane to the fixed right auto-hide rail.
PaneAutoHide(iced::widget::pane_grid::Pane),
/// Moves a docked tool into an in-viewport floating card.
PaneFloat(iced::widget::pane_grid::Pane),
/// Begins dragging a floating card header.
FloatingDragStart(PaneType),
/// Raises a clicked floating card without beginning a drag.
FloatingFocus(PaneType),
/// Redocks a floating tool beside Canvas.
FloatingRedock(PaneType),
/// Minimizes a floating tool to the fixed right auto-hide rail.
FloatingAutoHide(PaneType),
/// Closes a floating tool placement.
FloatingClose(PaneType),
/// Shared full-window logical cursor feed for dock and dialog drags.
DockPointerMoved(f32, f32),
/// Ends active full-window floating and dialog drags.
DockPointerReleased,
/// Clears click-only or outside-release dock state after PaneGrid handles the release event.
DockReleaseCleanup,
/// Tracks the current logical viewport for geometry and clamping.
DockViewportChanged(iced::Size),
/// Opens one temporary auto-hide overlay.
AutoHideActivate(PaneType),
/// Closes the temporary overlay without changing placement.
AutoHideDismiss,
/// Pins an auto-hidden panel back into PaneGrid.
AutoHideRestore(PaneType),
// ── File I/O ────────────────────────────────────────
NewDocument(u32, u32),
@@ -1178,10 +1213,11 @@ impl HcieIcedApp {
};
// Load saved settings
let mut settings = crate::settings::AppSettings::load();
let settings = crate::settings::AppSettings::load();
let mut app = Self {
documents: vec![doc],
show_welcome: false,
active_doc: 0,
tool_state: ToolState::default(),
fg_color: [220, 50, 50, 255],
@@ -1225,7 +1261,7 @@ impl HcieIcedApp {
layer_style_baseline: None,
pending_file_operation: None,
pending_document_close: None,
dock: DockState::new(),
dock: settings.panel_layout.restore_dock(),
tablet_state: Arc::new(Mutex::new(TabletState::new())),
initialized: false,
theme_state: ThemeState::new(settings.theme_preset),
@@ -1274,6 +1310,7 @@ impl HcieIcedApp {
colors_dirty: false,
vector_shapes_snapshot: None,
vector_drag_last: None,
vector_drag_last_render: None,
vector_drag_session: None,
bool_shape_a: None,
bool_shape_b: None,
@@ -1288,6 +1325,38 @@ impl HcieIcedApp {
{
app.dock.reopen_pane(panel);
}
if let Some(panel) = app
.screenshot_request
.as_ref()
.and_then(|request| request.floating_panel.as_deref())
.and_then(crate::dock::state::PaneType::from_label)
{
app.dock.reopen_pane(panel);
if let Some(pane) = app.dock.pane_for_type(panel) {
app.dock.float_pane(pane, (settings.window.width, settings.window.height));
}
}
if let Some(panel) = app
.screenshot_request
.as_ref()
.and_then(|request| request.auto_hide_panel.as_deref())
.and_then(crate::dock::state::PaneType::from_label)
{
app.dock.reopen_pane(panel);
if let Some(pane) = app.dock.pane_for_type(panel) {
app.dock.auto_hide_pane(
pane,
Some(crate::dock::manager::DockEdge::Right),
);
}
}
if app
.screenshot_request
.as_ref()
.is_some_and(|request| request.welcome)
{
app.show_welcome = true;
}
// Apply saved settings to tool state
settings.apply_to_tool_state(&mut app.tool_state);
@@ -1555,13 +1624,26 @@ impl HcieIcedApp {
}
}
/// Removes one non-final document tab and keeps the active index valid.
/// Removes one document tab and keeps the editor shell alive for the final tab.
///
/// Arguments: `document_index` is the tab to remove. Returns: Nothing. Logic & Workflow:
/// indices before the active tab shift it left; closing the active tab selects the tab now at
/// that position or the preceding final tab. Side Effects: Drops the document and edit state.
fn close_document_tab(&mut self, document_index: usize) {
if self.documents.len() <= 1 || document_index >= self.documents.len() {
if document_index >= self.documents.len() {
return;
}
if self.documents.len() == 1 {
let _ = self.update(Message::NewDocument(800, 600));
self.documents.remove(document_index);
self.active_doc = 0;
self.active_dialog = ActiveDialog::None;
self.show_welcome = true;
self.pending_document_close = None;
self.vector_shapes_snapshot = None;
self.vector_drag_session = None;
self.vector_drag_last = None;
self.vector_drag_last_render = None;
return;
}
self.documents.remove(document_index);
@@ -1745,6 +1827,9 @@ impl HcieIcedApp {
}
}
Message::OverlayOutsideClick => {
if self.dock.active_auto_hide.take().is_some() {
return Task::none();
}
if self.sub_tools_open {
self.sub_tools_open = false;
} else {
@@ -3595,7 +3680,89 @@ impl HcieIcedApp {
// The actual offset computation uses delta from the last position.
self.layer_style_dragging = true;
}
Message::LayerStyleDialogDragMove(x, y) => {
Message::LayerStyleDialogDragMove(x, y) | Message::DockPointerMoved(x, y) => {
self.last_cursor_pos = Some((x, y));
let pointer = iced::Point::new(x, y);
if self.dock.drag.is_none() {
if let Some(header) = self.dock.header_drag {
if pointer.distance(header.origin) >= 4.0 {
self.dock.drag = Some(DockDragState {
source_pane: header.source_pane,
source_type: header.source_type,
pointer: Some(pointer),
candidate_target: None,
accepted_preview: None,
});
}
}
}
if self.dock.drag.is_some() {
let geometry = crate::dock::preview::DockGeometry {
viewport: iced::Size::new(
self.settings.window.width,
self.settings.window.height,
),
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
};
let regions = self.dock.pane_grid.layout().pane_regions(
crate::dock::preview::PANE_SPACING,
geometry.grid_bounds().size(),
);
let source_type = self.dock.drag.map(|drag| drag.source_type);
let approved = crate::dock::preview::approved_target(
pointer, geometry, &regions,
)
.filter(|target| {
source_type.is_some_and(|source| {
crate::dock::manager::DockDropPolicy::accepts(source, target.zone)
})
});
if let Some(drag) = &mut self.dock.drag {
drag.pointer = Some(pointer);
drag.candidate_target = approved.map(|target| target.target);
drag.accepted_preview = approved.map(|target| target.preview);
}
}
if let Some(floating_drag) = self.dock.floating_drag {
let geometry = crate::dock::preview::DockGeometry {
viewport: iced::Size::new(
self.settings.window.width,
self.settings.window.height,
),
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
};
let regions = self.dock.pane_grid.layout().pane_regions(
crate::dock::preview::PANE_SPACING,
geometry.grid_bounds().size(),
);
self.dock.floating_target = crate::dock::preview::approved_target(
pointer, geometry, &regions,
)
.filter(|target| {
crate::dock::manager::DockDropPolicy::accepts(
floating_drag.panel,
target.zone,
)
});
if let Some(current) = self
.dock
.floating
.iter()
.find(|item| item.panel == floating_drag.panel)
.map(|item| item.rect)
{
let rect = crate::dock::manager::DockRect {
x: x - floating_drag.pointer_offset.x,
y: y - floating_drag.pointer_offset.y,
..current
};
self.dock.move_floating(
floating_drag.panel,
rect,
(self.settings.window.width, self.settings.window.height),
);
}
}
if self.layer_style_dragging {
if let Some((lx, ly)) = self.layer_style_drag_start {
// Compute delta from last position and add to offset
@@ -3607,9 +3774,41 @@ impl HcieIcedApp {
self.layer_style_drag_start = Some((x, y));
}
}
Message::LayerStyleDialogDragEnd => {
Message::LayerStyleDialogDragEnd | Message::DockPointerReleased => {
self.layer_style_dragging = false;
self.layer_style_drag_start = None;
let mut persist_dock = self.dock.resize_persistence_dirty;
self.dock.resize_persistence_dirty = false;
if let Some(header) = self.dock.header_drag.take() {
if let Some(drag) = self
.dock
.drag
.take()
.filter(|drag| drag.source_pane == header.source_pane)
{
if let Some(target) = drag.candidate_target {
if self.dock.apply_drop(drag.source_pane, target) {
persist_dock = true;
}
}
}
}
if let Some(drag) = self.dock.floating_drag.take() {
if let Some(target) = self.dock.floating_target.take() {
self.dock.redock_floating_at(drag.panel, target.target);
}
persist_dock = true;
}
if persist_dock {
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
return Task::perform(async {}, |_| Message::DockReleaseCleanup);
}
Message::DockReleaseCleanup => {
self.dock.cancel_transient_drags();
}
// ── Brushes ───────────────────────────────────
@@ -4879,28 +5078,68 @@ impl HcieIcedApp {
);
let (x1, y1, x2, y2) = shape.normalized_bounds();
let angle = shape.angle();
let engine = &mut self.documents[self.active_doc].engine;
engine.set_vector_shape_bounds(
session.layer_id,
session.shape_index,
x1,
y1,
x2,
y2,
);
engine.set_vector_shape_angle(session.layer_id, session.shape_index, angle);
if let Some(shapes) = self.documents[self.active_doc]
.engine
.get_layer_shapes_direct(session.layer_id)
{
if let Some(current) = shapes.get_mut(session.shape_index) {
*current = shape;
}
}
self.documents[self.active_doc].modified = true;
self.vector_drag_last = Some((x, y));
self.refresh_composite_if_needed();
let should_render = self
.vector_drag_last_render
.is_none_or(|last| last.elapsed() >= std::time::Duration::from_millis(16));
if should_render {
let engine = &mut self.documents[self.active_doc].engine;
engine.set_vector_shape_bounds(
session.layer_id,
session.shape_index,
x1,
y1,
x2,
y2,
);
engine.set_vector_shape_angle(session.layer_id, session.shape_index, angle);
self.vector_drag_last_render = Some(std::time::Instant::now());
self.refresh_composite_if_needed();
}
}
}
Message::VectorSelectDragEnd => {
if self.vector_drag_last.is_some() {
if let Some(session) = self.vector_drag_session.as_ref() {
if let Some(shape) = self.documents[self.active_doc]
.engine
.active_vector_shapes()
.and_then(|shapes| shapes.get(session.shape_index).cloned())
{
let (x1, y1, x2, y2) = shape.normalized_bounds();
let angle = shape.angle();
let engine = &mut self.documents[self.active_doc].engine;
engine.set_vector_shape_bounds(
session.layer_id,
session.shape_index,
x1,
y1,
x2,
y2,
);
engine.set_vector_shape_angle(
session.layer_id,
session.shape_index,
angle,
);
}
}
// Commit the move/resize/rotate: if shapes changed, push history snapshot.
self.vector_drag_last = None;
self.vector_drag_last_render = None;
self.vector_drag_session = None;
self.documents[self.active_doc].vector_edit_handle =
hcie_engine_api::VectorEditHandle::None;
self.refresh_composite_if_needed();
}
}
Message::VectorShapeDelete => {
@@ -5973,6 +6212,7 @@ impl HcieIcedApp {
};
match result {
Ok(()) => {
self.show_welcome = false;
self.documents[self.active_doc].engine.pre_tile_all_layers();
self.documents[self.active_doc].name = path
.file_name()
@@ -6010,28 +6250,193 @@ impl HcieIcedApp {
// ── Dock Layout ───────────────────────────────
Message::PaneClicked(pane) => {
self.dock.active_auto_hide = None;
self.dock.focus(pane);
}
Message::DockHeaderDragStart(pane) => {
if let Some(source_type) = self
.dock
.pane_type(pane)
.filter(|panel| *panel != PaneType::Canvas)
{
let origin = self
.last_cursor_pos
.map(|(x, y)| iced::Point::new(x, y))
.unwrap_or_default();
self.dock.header_drag = Some(
crate::dock::manager::DockHeaderDragState {
source_pane: pane,
source_type,
origin,
},
);
self.dock.focus(pane);
}
}
Message::PaneDragged(drag_event) => {
if let iced::widget::pane_grid::DragEvent::Dropped { pane, target } = drag_event {
self.dock.pane_grid.drop(pane, target);
use iced::widget::pane_grid::DragEvent;
match drag_event {
DragEvent::Picked { pane } => {
if let Some(source_type) = self
.dock
.pane_type(pane)
.filter(|panel| *panel != PaneType::Canvas)
{
self.dock.drag = Some(DockDragState {
source_pane: pane,
source_type,
pointer: self.last_cursor_pos.map(|(x, y)| iced::Point::new(x, y)),
candidate_target: None,
accepted_preview: None,
});
}
}
DragEvent::Dropped { pane, target } => {
let approved = self.dock.drag.as_ref().is_some_and(|drag| {
drag.source_pane == pane
&& drag.candidate_target.is_some_and(|candidate| {
crate::dock::preview::target_matches(candidate, target)
&& crate::dock::manager::DockDropPolicy::accepts(
drag.source_type,
crate::dock::manager::DockDropZone::from_target(target),
)
})
});
self.dock.drag = None;
if approved && self.dock.apply_drop(pane, target) {
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
}
DragEvent::Canceled { .. } => self.dock.drag = None,
}
}
Message::PaneResized(resize_event) => {
self.dock
.pane_grid
.resize(resize_event.split, resize_event.ratio);
let grid_size = self.dock.last_grid_size.unwrap_or_else(|| {
crate::dock::preview::DockGeometry {
viewport: iced::Size::new(
self.settings.window.width,
self.settings.window.height,
),
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
}
.grid_bounds()
.size()
});
let ratio = crate::dock::sizing::constrain_user_resize_ratio(
&self.dock.pane_grid,
resize_event.split,
resize_event.ratio,
grid_size,
);
self.dock.pane_grid.resize(resize_event.split, ratio);
self.dock.resize_persistence_dirty = true;
}
Message::PaneClose(pane) => {
// Don't close canvas panes
if let Some(PaneType::Canvas) = self.dock.pane_type(pane) {
return Task::none();
}
self.dock.close_pane(pane);
if self.dock.close_pane(pane) {
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
}
Message::PaneMaximize(pane) => {
self.dock.toggle_maximize(pane);
}
Message::PaneAutoHide(pane) => {
if self
.dock
.auto_hide_pane(pane, Some(crate::dock::manager::DockEdge::Right))
{
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
}
Message::PaneFloat(pane) => {
let viewport = (self.settings.window.width, self.settings.window.height);
if self.dock.float_pane(pane, viewport) {
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
}
Message::FloatingFocus(panel) => {
self.dock.focus_floating(panel);
}
Message::FloatingDragStart(panel) => {
if let Some(rect) = self.dock.focus_floating(panel) {
let pointer = self.last_cursor_pos.unwrap_or((rect.x, rect.y));
self.dock.floating_drag = Some(crate::dock::floating::FloatingDragState {
panel,
pointer_offset: iced::Point::new(pointer.0 - rect.x, pointer.1 - rect.y),
});
self.dock.floating_target = None;
}
}
Message::FloatingRedock(panel) => {
if self.dock.redock_floating(panel) {
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
}
Message::FloatingClose(panel) => {
if self.dock.close_floating(panel) {
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
}
Message::FloatingAutoHide(panel) => {
if self
.dock
.auto_hide_floating(panel, crate::dock::manager::DockEdge::Right)
{
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
}
Message::DockViewportChanged(size) => {
self.settings.window.width = size.width;
self.settings.window.height = size.height;
self.dock.clamp_floating((size.width, size.height));
let grid_size = crate::dock::preview::DockGeometry {
viewport: size,
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
}
.grid_bounds()
.size();
self.dock.set_grid_size(grid_size);
}
Message::AutoHideActivate(panel) => {
self.dock.active_auto_hide = if self.dock.active_auto_hide == Some(panel) {
None
} else {
Some(panel)
};
}
Message::AutoHideDismiss => self.dock.active_auto_hide = None,
Message::AutoHideRestore(panel) => {
if self.dock.restore_auto_hidden(panel) {
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
}
// ── Dock Profiles ─────────────────────────────
Message::DockProfileSave(name) => {
@@ -6164,6 +6569,7 @@ impl HcieIcedApp {
// ── File I/O ─────────────────────────────────
Message::NewDocument(w, h) => {
self.show_welcome = false;
let mut engine = Engine::new(w, h);
engine.pre_tile_all_layers();
let composite_raw = engine.get_composite_pixels();
@@ -6236,8 +6642,9 @@ impl HcieIcedApp {
.is_some_and(|document| document.modified);
match document_close_disposition(self.documents.len(), idx, modified) {
DocumentCloseDisposition::Invalid => return Task::none(),
DocumentCloseDisposition::Window => return self.update(Message::WindowClose),
DocumentCloseDisposition::Confirm | DocumentCloseDisposition::Close => {
DocumentCloseDisposition::Welcome
| DocumentCloseDisposition::Confirm
| DocumentCloseDisposition::Close => {
if self.preview_baseline.is_some() {
self.restore_preview();
self.filter_preview_active = false;
@@ -6544,6 +6951,9 @@ impl HcieIcedApp {
.map_or(0, |duration| duration.as_secs());
self.screenshot_request = Some(crate::cli::ScreenshotRequest {
panel: None,
floating_panel: None,
auto_hide_panel: None,
welcome: false,
output: std::path::PathBuf::from(format!(
"target/screenshots/iced_{timestamp}.png"
)),
@@ -6715,6 +7125,16 @@ impl HcieIcedApp {
self.settings.panel_layout.sidebar_expanded = self.sidebar_expanded;
self.settings.panel_layout.sidebar_width =
if self.sidebar_expanded { 72.0 } else { 36.0 };
let grid_size = crate::dock::preview::DockGeometry {
viewport: iced::Size::new(
self.settings.window.width,
self.settings.window.height,
),
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
}
.grid_bounds()
.size();
self.dock.set_grid_size(grid_size);
let _ = self.settings.save();
}
@@ -6732,10 +7152,16 @@ impl HcieIcedApp {
ts.reset_pressure();
}
}
if !pressed {
return Task::perform(async {}, |_| Message::DockReleaseCleanup);
}
}
// ── Contextual key actions ───────────────────────
Message::EscapePressed => {
if self.dock.active_auto_hide.take().is_some() {
return Task::none();
}
// Mirrors egui's Escape handling. Priority: viewer > crop >
// transform > vector selection > selection > dialog > viewer fallback.
match overlay_escape_target(self.sub_tools_open, self.active_menu.is_some()) {
@@ -6971,10 +7397,19 @@ impl HcieIcedApp {
let has_dock_profile_dialog = self.show_dock_profile_dialog;
let has_context_menu = self.canvas_context_menu.is_some();
let has_color_picker = self.color_picker_target.is_some();
let has_floating = !self.dock.floating.is_empty();
if has_dialog || has_menu || has_dock_profile_dialog || has_context_menu || has_color_picker
if has_dialog
|| has_menu
|| has_dock_profile_dialog
|| has_context_menu
|| has_color_picker
|| has_floating
{
let mut stack = iced::widget::Stack::new().push(content);
for item in &self.dock.floating {
stack = stack.push(crate::dock::floating::card(item, self, colors));
}
if has_dialog {
stack = stack.push(dialog_overlay);
}
@@ -7266,13 +7701,17 @@ impl HcieIcedApp {
== iced::event::Status::Ignored)
.then_some(Message::OverlayOutsideClick),
iced::mouse::Event::CursorMoved { position } => {
Some(Message::LayerStyleDialogDragMove(position.x, position.y))
Some(Message::DockPointerMoved(position.x, position.y))
}
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) => {
Some(Message::LayerStyleDialogDragEnd)
Some(Message::DockPointerReleased)
}
_ => None,
},
iced::Event::Window(iced::window::Event::Opened { size, .. })
| iced::Event::Window(iced::window::Event::Resized(size)) => {
Some(Message::DockViewportChanged(size))
}
iced::Event::Touch(touch_event) => {
// iced 0.13 touch events do not carry a force/pressure field,
// so we can only forward position. A touch press/move marks
@@ -7360,7 +7799,7 @@ fn overlay_escape_target(subtools_open: bool, menu_open: bool) -> Option<Overlay
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DocumentCloseDisposition {
Invalid,
Window,
Welcome,
Confirm,
Close,
}
@@ -7368,7 +7807,7 @@ enum DocumentCloseDisposition {
/// Resolves tab-close behavior without mutating application state.
///
/// Arguments: open document count, requested index, and modified state. Returns: whether to ignore,
/// delegate to window close, confirm, or remove immediately. Side Effects: None.
/// show the welcome state, confirm, or remove immediately. Side Effects: None.
fn document_close_disposition(
document_count: usize,
document_index: usize,
@@ -7376,10 +7815,10 @@ fn document_close_disposition(
) -> DocumentCloseDisposition {
if document_index >= document_count {
DocumentCloseDisposition::Invalid
} else if document_count == 1 {
DocumentCloseDisposition::Window
} else if modified {
DocumentCloseDisposition::Confirm
} else if document_count == 1 {
DocumentCloseDisposition::Welcome
} else {
DocumentCloseDisposition::Close
}
@@ -7440,7 +7879,7 @@ mod cycle_one_ux_tests {
);
assert_eq!(
document_close_disposition(1, 0, false),
DocumentCloseDisposition::Window
DocumentCloseDisposition::Welcome
);
assert_eq!(
document_close_disposition(2, 2, false),
+57 -1
View File
@@ -13,6 +13,12 @@ use std::path::PathBuf;
pub struct ScreenshotRequest {
/// Optional exact dock-pane label. `None` captures the complete viewport.
pub panel: Option<String>,
/// Optional tool panel floated before an automated full-viewport capture.
pub floating_panel: Option<String>,
/// Optional tool panel moved to the auto-hide rail before capture.
pub auto_hide_panel: Option<String>,
/// Whether the post-close welcome surface is activated before capture.
pub welcome: bool,
/// PNG output path supplied by the caller.
pub output: PathBuf,
}
@@ -64,17 +70,64 @@ where
let output = required_operand(&tokens, index + 1, "--screenshot")?;
parsed.screenshot = Some(ScreenshotRequest {
panel: None,
floating_panel: None,
auto_hide_panel: None,
welcome: false,
output: PathBuf::from(output),
});
index += 2;
continue;
}
"--screenshot-floating" => {
ensure_no_screenshot(&parsed)?;
let panel = required_operand(&tokens, index + 1, "--screenshot-floating")?;
let output = required_operand(&tokens, index + 2, "--screenshot-floating")?;
parsed.screenshot = Some(ScreenshotRequest {
panel: None,
floating_panel: Some(panel.to_owned()),
auto_hide_panel: None,
welcome: false,
output: PathBuf::from(output),
});
index += 3;
continue;
}
"--screenshot-panel" => {
ensure_no_screenshot(&parsed)?;
let panel = required_operand(&tokens, index + 1, "--screenshot-panel")?;
let output = required_operand(&tokens, index + 2, "--screenshot-panel")?;
parsed.screenshot = Some(ScreenshotRequest {
panel: Some(panel.to_owned()),
floating_panel: None,
auto_hide_panel: None,
welcome: false,
output: PathBuf::from(output),
});
index += 3;
continue;
}
"--screenshot-welcome" => {
ensure_no_screenshot(&parsed)?;
let output = required_operand(&tokens, index + 1, "--screenshot-welcome")?;
parsed.screenshot = Some(ScreenshotRequest {
panel: None,
floating_panel: None,
auto_hide_panel: None,
welcome: true,
output: PathBuf::from(output),
});
index += 2;
continue;
}
"--screenshot-auto-hide" => {
ensure_no_screenshot(&parsed)?;
let panel = required_operand(&tokens, index + 1, "--screenshot-auto-hide")?;
let output = required_operand(&tokens, index + 2, "--screenshot-auto-hide")?;
parsed.screenshot = Some(ScreenshotRequest {
panel: None,
floating_panel: None,
auto_hide_panel: Some(panel.to_owned()),
welcome: false,
output: PathBuf::from(output),
});
index += 3;
@@ -120,7 +173,7 @@ fn ensure_no_screenshot(parsed: &CliArgs) -> Result<(), String> {
/// Returns the command-line help shown by the binary.
pub fn help_text() -> &'static str {
"Usage: hcie-iced [OPTIONS] [FILE]\n\nHCIE - Iced Edition\n\nOptions:\n -h, --help Print this help message\n --screenshot OUTPUT Capture the full viewport as PNG\n --screenshot-panel PANEL OUTPUT\n Capture a named panel as PNG\n\nArguments:\n FILE Image file to open on startup"
"Usage: hcie-iced [OPTIONS] [FILE]\n\nHCIE - Iced Edition\n\nOptions:\n -h, --help Print this help message\n --screenshot OUTPUT Capture the full viewport as PNG\n --screenshot-panel PANEL OUTPUT\n Capture a named panel as PNG\n --screenshot-welcome OUTPUT Capture the post-close welcome surface\n\nArguments:\n FILE Image file to open on startup"
}
#[cfg(test)]
@@ -136,6 +189,9 @@ mod tests {
args.screenshot,
Some(ScreenshotRequest {
panel: None,
floating_panel: None,
auto_hide_panel: None,
welcome: false,
output: PathBuf::from("full.png"),
})
);
@@ -271,7 +271,10 @@ pub fn popup_view(
let body: Element<'static, Message> = match tab {
0 => rgb_popup_section(color),
1 => Canvas::new(ColorWheel { color })
1 => Canvas::new(ColorWheel {
color,
output: WheelOutput::Popup,
})
.width(Length::Fixed(220.0))
.height(Length::Fixed(220.0))
.into(),
@@ -425,6 +428,16 @@ fn popup_slider_row<'a>(
#[derive(Debug, Clone, Copy)]
struct ColorWheel {
color: [u8; 4],
output: WheelOutput,
}
/// Selects the application message emitted by a shared interactive wheel.
#[derive(Debug, Clone, Copy)]
enum WheelOutput {
/// Updates the docked foreground color.
Foreground,
/// Updates the currently targeted popup color.
Popup,
}
/// Tracks whether a wheel drag began over an editable region.
@@ -434,6 +447,14 @@ struct ColorWheelState {
}
impl ColorWheel {
/// Wraps a selected color in the message expected by this wheel placement.
fn message(self, color: [u8; 4]) -> Message {
match self.output {
WheelOutput::Foreground => Message::FgColorChanged(color),
WheelOutput::Popup => Message::PopupColorChanged(color),
}
}
/// Maps a local wheel position to HSL, preserving the current component when
/// the pointer lies outside both the ring and center square.
fn color_at(self, bounds: Rectangle, position: Point) -> Option<[u8; 4]> {
@@ -565,7 +586,7 @@ impl canvas::Program<Message> for ColorWheel {
state.dragging = true;
return (
canvas::event::Status::Captured,
Some(Message::PopupColorChanged(color)),
Some(self.message(color)),
);
}
}
@@ -573,7 +594,7 @@ impl canvas::Program<Message> for ColorWheel {
if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) {
return (
canvas::event::Status::Captured,
Some(Message::PopupColorChanged(color)),
Some(self.message(color)),
);
}
}
@@ -729,7 +750,7 @@ pub fn view<'a>(
// ── 4. Tab content ──
let tab_content: Element<'_, Message> = match color_tab {
0 => build_wheel_view(h, s, l, fg_color),
1 => build_slider_view(h, s, l),
1 => build_slider_view(h, s, l, fg_color[3]),
_ => build_grid_view(fg_color),
};
@@ -775,6 +796,7 @@ pub fn view<'a>(
});
let clickable = iced::widget::mouse_area(swatch)
.on_press(Message::FgColorChanged(c))
.on_right_press(Message::BgColorChanged(c))
.interaction(iced::mouse::Interaction::Pointer);
recent_items.push(clickable.into());
}
@@ -784,7 +806,7 @@ pub fn view<'a>(
row(recent_items).spacing(2).into()
};
column![
let content = column![
text("Color").size(11).font(iced::Font::MONOSPACE),
swatch_row,
hex_row,
@@ -796,118 +818,21 @@ pub fn view<'a>(
recent_row,
]
.width(Length::Fill)
.height(Length::Shrink)
.spacing(3)
.padding(Padding::from([4, 4]))
.into()
.padding(Padding::from([4, 4]));
scrollable(content)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
/// Build the HSL color wheel view (tab W).
/// Builds a responsive Canvas-based HSL wheel for the docked panel.
///
/// Uses a hue ring made of colored cells and an SL picker grid.
fn build_wheel_view<'a>(h: f32, s: f32, l: f32, _fg_color: &'a [u8; 4]) -> Element<'a, Message> {
// Hue ring: 36 cells, each 10 degrees
let cell_size = 10;
let mut hue_ring = row![].spacing(0);
for i in 0..36 {
let hue = i as f32 * 10.0;
let (r, g, b) = hsl_to_rgb(hue, 1.0, 0.5);
let cell_color =
iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
let is_current_h = (h - hue).abs() < 5.0 || (h - hue).abs() > 355.0;
let is_selected = is_current_h && (s - 1.0).abs() < 0.1 && (l - 0.5).abs() < 0.1;
let brightness = r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114;
let dot_color = if brightness > 128.0 {
iced::Color::BLACK
} else {
iced::Color::WHITE
};
let dot = text(if is_selected { "\u{2022}" } else { "" })
.size(8)
.style(move |_theme| iced::widget::text::Style {
color: Some(dot_color),
});
let cell = container(dot)
.width(cell_size)
.height(cell_size)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(cell_color)),
border: if is_current_h {
iced::Border::default().color(iced::Color::WHITE).width(1)
} else {
iced::Border::default()
},
..Default::default()
});
let h_val = hue;
let s_val = s;
let l_val = l;
let clickable = iced::widget::mouse_area(cell)
.on_press(Message::FgColorChanged({
let (r, g, b) = hsl_to_rgb(h_val, s_val, l_val);
[r, g, b, 255]
}))
.interaction(iced::mouse::Interaction::Pointer);
hue_ring = hue_ring.push(clickable);
}
// SL picker grid: 12 columns (saturation) x 12 rows (lightness)
let sl_size = 10;
let mut sl_grid = column![].spacing(0);
for row_idx in 0..12 {
let lightness = row_idx as f32 / 11.0;
let mut sl_row = row![].spacing(0);
for col_idx in 0..12 {
let sat = col_idx as f32 / 11.0;
let (r, g, b) = hsl_to_rgb(h, sat, lightness);
let is_current = (s - sat).abs() < 0.05 && (l - lightness).abs() < 0.05;
let cell_color =
iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
let brightness = r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114;
let dot_color = if brightness > 128.0 {
iced::Color::BLACK
} else {
iced::Color::WHITE
};
let dot = text(if is_current { "\u{2022}" } else { "" })
.size(8)
.style(move |_theme| iced::widget::text::Style {
color: Some(dot_color),
});
let cell = container(dot)
.width(sl_size)
.height(sl_size)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(cell_color)),
border: if is_current {
iced::Border::default().color(iced::Color::WHITE).width(1)
} else {
iced::Border::default()
},
..Default::default()
});
let h_val = h;
let s_val = sat;
let l_val = lightness;
let clickable = iced::widget::mouse_area(cell)
.on_press(Message::FgColorChanged({
let (r, g, b) = hsl_to_rgb(h_val, s_val, l_val);
[r, g, b, 255]
}))
.interaction(iced::mouse::Interaction::Pointer);
sl_row = sl_row.push(clickable);
}
sl_grid = sl_grid.push(sl_row);
}
// HSL readout
/// The wheel fills narrow pane widths while retaining intrinsic vertical height, avoiding the
/// former fixed 360-pixel hue strip and allowing the outer panel scrollable to handle short panes.
fn build_wheel_view<'a>(h: f32, s: f32, l: f32, fg_color: &'a [u8; 4]) -> Element<'a, Message> {
let readout = text(format!(
"H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%",
h,
@@ -917,32 +842,33 @@ fn build_wheel_view<'a>(h: f32, s: f32, l: f32, _fg_color: &'a [u8; 4]) -> Eleme
.size(9)
.font(iced::Font::MONOSPACE);
column![
text("Hue").size(9),
hue_ring,
text("Saturation / Lightness").size(9),
sl_grid,
readout,
]
let wheel = Canvas::new(ColorWheel {
color: *fg_color,
output: WheelOutput::Foreground,
})
.width(Length::Fill)
.height(Length::Fixed(220.0));
column![wheel, readout]
.spacing(3)
.into()
}
/// Build the HSL sliders view (tab H).
fn build_slider_view<'a>(h: f32, s: f32, l: f32) -> Element<'a, Message> {
fn build_slider_view<'a>(h: f32, s: f32, l: f32, alpha: u8) -> Element<'a, Message> {
let h_slider = slider(0.0..=360.0, h, move |v: f32| {
let (r, g, b) = hsl_to_rgb(v, s, l);
Message::FgColorChanged([r, g, b, 255])
Message::FgColorChanged([r, g, b, alpha])
})
.step(1.0);
let s_slider = slider(0.0..=100.0, s * 100.0, move |v: f32| {
let (r, g, b) = hsl_to_rgb(h, v / 100.0, l);
Message::FgColorChanged([r, g, b, 255])
Message::FgColorChanged([r, g, b, alpha])
})
.step(1.0);
let l_slider = slider(0.0..=100.0, l * 100.0, move |v: f32| {
let (r, g, b) = hsl_to_rgb(h, s, v / 100.0);
Message::FgColorChanged([r, g, b, 255])
Message::FgColorChanged([r, g, b, alpha])
})
.step(1.0);
@@ -1013,7 +939,8 @@ fn build_grid_view<'a>(fg_color: &'a [u8; 4]) -> Element<'a, Message> {
..Default::default()
});
let clickable = iced::widget::mouse_area(swatch)
.on_press(Message::FgColorChanged([c[0], c[1], c[2], 255]))
.on_press(Message::FgColorChanged([c[0], c[1], c[2], fg_color[3]]))
.on_right_press(Message::BgColorChanged([c[0], c[1], c[2], fg_color[3]]))
.interaction(iced::mouse::Interaction::Pointer);
row_widgets = row_widgets.push(clickable);
}
@@ -0,0 +1,87 @@
//! Auto-hide rail and temporary overlay presentation.
//!
//! Rail presses activate one opaque overlay. Panel content is supplied by `dock::view::panel_body`,
//! so auto-hidden and docked panels use the same implementation.
use crate::app::Message;
use crate::dock::state::PaneType;
use crate::theme::ThemeColors;
use iced::widget::{button, column, container, row, text, tooltip};
use iced::{Element, Length};
/// Renders the fixed right auto-hide rail outside PaneGrid.
pub fn right_rail<'a>(
panels: impl Iterator<Item = PaneType>,
colors: ThemeColors,
) -> Element<'a, Message> {
let mut tabs = column![].spacing(3).padding([4, 2]).width(36);
for panel in panels {
let tab = button(text(panel.icon_glyph()).size(16))
.on_press(Message::AutoHideActivate(panel))
.width(32)
.height(32)
.padding(6)
.style(move |_theme, status| crate::dock::view::chrome_button_style(colors, status));
tabs = tabs.push(tooltip(
tab,
text(panel.label()).size(11),
tooltip::Position::Left,
));
}
container(tabs)
.height(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)),
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
})
.into()
}
/// Builds an opaque temporary auto-hide overlay aligned to the right edge.
pub fn overlay<'a>(
panel: PaneType,
body: Element<'a, Message>,
colors: ThemeColors,
) -> Element<'a, Message> {
let header = row![
text(panel.icon_glyph()).size(14).color(colors.accent),
text(panel.label()).size(12).color(colors.text_primary),
iced::widget::Space::with_width(Length::Fill),
tooltip(
button(text("").size(10))
.on_press(Message::AutoHideRestore(panel))
.padding([3, 6])
.style(
move |_theme, status| crate::dock::view::chrome_button_style(colors, status)
),
text("Pin panel open").size(11),
tooltip::Position::Left
),
button(text("×").size(14))
.on_press(Message::AutoHideDismiss)
.padding([2, 7])
.style(move |_theme, status| crate::dock::view::chrome_button_style(colors, status)),
]
.align_y(iced::Alignment::Center)
.height(28)
.padding([0, 6]);
let card = container(column![header, body].height(Length::Fill))
.width(320)
.height(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default().color(colors.accent).width(1),
shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.45),
offset: iced::Vector::new(-6.0, 4.0),
blur_radius: 16.0,
},
..Default::default()
});
container(card)
.width(Length::Fill)
.height(Length::Fill)
.align_x(iced::alignment::Horizontal::Right)
.into()
}
@@ -0,0 +1,119 @@
//! In-viewport floating tool panel geometry and card rendering.
//!
//! Cards reuse dock panel bodies and are positioned with ordinary padding and `Space` widgets.
//! Only visible card bounds receive events; surrounding viewport space remains inert.
use crate::app::Message;
use crate::dock::manager::DockRect;
use crate::dock::persistence::PersistedFloatingPanel;
use crate::dock::state::PaneType;
use crate::theme::ThemeColors;
use iced::widget::{button, column, container, mouse_area, row, text, tooltip, Space};
use iced::{Element, Length, Point};
pub const DEFAULT_WIDTH: f32 = 320.0;
pub const DEFAULT_HEIGHT: f32 = 420.0;
/// Active header drag retaining the pointer-to-card offset.
#[derive(Debug, Clone, Copy)]
pub struct FloatingDragState {
pub panel: PaneType,
pub pointer_offset: Point,
}
/// Creates a recoverable default rectangle inside the current viewport.
pub fn default_rect(viewport: (f32, f32), ordinal: usize) -> DockRect {
DockRect {
x: 120.0 + ordinal as f32 * 24.0,
y: 96.0 + ordinal as f32 * 24.0,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
}
.clamped(viewport)
}
/// Positions one opaque floating card in full-viewport logical coordinates.
pub fn card<'a>(
item: &PersistedFloatingPanel,
app: &'a crate::app::HcieIcedApp,
colors: ThemeColors,
) -> Element<'a, Message> {
let panel = item.panel;
let title = mouse_area(
row![
text(panel.icon_glyph()).size(14).color(colors.accent),
text(panel.label()).size(12).color(colors.text_primary),
Space::with_width(Length::Fill),
]
.spacing(7)
.align_y(iced::Alignment::Center)
.padding([0, 8])
.height(30),
)
.on_press(Message::FloatingDragStart(panel));
let control = |label, message| {
button(text(label).size(12))
.on_press(message)
.padding([3, 7])
.style(move |_theme, status| crate::dock::view::chrome_button_style(colors, status))
};
let header = row![
title,
tooltip(
control("", Message::FloatingAutoHide(panel)),
text("Minimize to side rail").size(11),
tooltip::Position::Bottom
),
tooltip(
control("", Message::FloatingRedock(panel)),
text("Redock panel").size(11),
tooltip::Position::Bottom
),
tooltip(
control("×", Message::FloatingClose(panel)),
text("Close panel").size(11),
tooltip::Position::Bottom
),
]
.align_y(iced::Alignment::Center)
.height(30);
let body = crate::dock::view::panel_body(panel, app, colors);
let card = container(column![header, body].height(Length::Fill))
.width(item.rect.width)
.height(item.rect.height)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default().color(colors.accent).width(1),
shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5),
offset: iced::Vector::new(5.0, 7.0),
blur_radius: 18.0,
},
..Default::default()
});
let card = mouse_area(card).on_press(Message::FloatingFocus(panel));
column![
Space::with_height(item.rect.y.max(0.0)),
row![
Space::with_width(item.rect.x.max(0.0)),
card,
Space::with_width(Length::Fill)
],
Space::with_height(Length::Fill),
]
.width(Length::Fill)
.height(Length::Fill)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_rect_clamps_to_small_viewports() {
let rect = default_rect((240.0, 180.0), 4);
assert!(rect.x <= 192.0 && rect.y <= 152.0);
assert_eq!((rect.width, rect.height), (240.0, 180.0));
}
}
@@ -0,0 +1,182 @@
//! Policy and runtime types for the VS-style dock manager.
//!
//! Raw `PaneGrid` targets are classified and validated before `DockState` mutates Iced state.
use crate::dock::state::PaneType;
use iced::widget::pane_grid::{Edge, Pane, Region, Target};
use iced::{Point, Rectangle};
use serde::{Deserialize, Serialize};
/// Semantic ownership of content participating in the application layout.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DockRole {
/// The unique center document host.
Document,
/// A movable utility panel.
Tool,
/// Shell UI that never enters the dock manager.
Fixed,
}
/// Stable edge names used by auto-hide and persisted placements.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DockEdge {
Left,
Right,
Top,
Bottom,
}
impl DockEdge {
/// Converts an Iced edge to its stable dock equivalent.
pub fn from_iced(edge: Edge) -> Self {
match edge {
Edge::Left => Self::Left,
Edge::Right => Self::Right,
Edge::Top => Self::Top,
Edge::Bottom => Self::Bottom,
}
}
}
/// Logical rectangle persisted for floating panels without runtime window IDs.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct DockRect {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
impl DockRect {
/// Clamps dimensions and enough header area into the supplied viewport.
pub fn clamped(self, viewport: (f32, f32)) -> Self {
Self {
x: self.x.clamp(0.0, (viewport.0 - 48.0).max(0.0)),
y: self.y.clamp(0.0, (viewport.1 - 28.0).max(0.0)),
width: self.width.clamp(180.0, viewport.0.max(180.0)),
height: self.height.clamp(120.0, viewport.1.max(120.0)),
}
}
}
/// Stable placement of a document or tool panel.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
pub enum DockPlacement {
Docked,
AutoHidden(DockEdge),
Floating(DockRect),
Closed,
}
/// Policy-level class of an Iced drop target.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DockDropZone {
OuterEdge(DockEdge),
PaneEdge(DockEdge),
Center,
}
impl DockDropZone {
/// Classifies a raw Iced target without retaining runtime pane identifiers.
pub fn from_target(target: Target) -> Self {
match target {
Target::Edge(edge) => Self::OuterEdge(DockEdge::from_iced(edge)),
Target::Pane(_, Region::Edge(edge)) => Self::PaneEdge(DockEdge::from_iced(edge)),
Target::Pane(_, Region::Center) => Self::Center,
}
}
}
/// Current interaction state for one PaneGrid drag.
#[derive(Debug, Clone, Copy)]
pub struct DockDragState {
pub source_pane: Pane,
pub source_type: PaneType,
pub pointer: Option<Point>,
pub candidate_target: Option<Target>,
pub accepted_preview: Option<Rectangle>,
}
/// Pointer-down state for the visible dock title before its drag deadband is crossed.
#[derive(Debug, Clone, Copy)]
pub struct DockHeaderDragState {
/// Runtime pane that owns the pressed title.
pub source_pane: Pane,
/// Stable panel payload used by drop policy.
pub source_type: PaneType,
/// Full-window logical pointer position at press time.
pub origin: Point,
}
/// Stateless gate for all dock placement mutations.
pub struct DockDropPolicy;
impl DockDropPolicy {
/// Returns the semantic role of a pane type.
pub fn role(panel: PaneType) -> DockRole {
if panel == PaneType::Canvas {
DockRole::Document
} else {
DockRole::Tool
}
}
/// Returns true only for tool-to-edge drops.
pub fn accepts(source: PaneType, zone: DockDropZone) -> bool {
Self::role(source) == DockRole::Tool
&& matches!(zone, DockDropZone::OuterEdge(_) | DockDropZone::PaneEdge(_))
}
/// Validates actions that remove content from the grid.
pub fn accepts_placement(source: PaneType, placement: DockPlacement) -> bool {
Self::role(source) == DockRole::Tool && !matches!(placement, DockPlacement::Docked)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn policy_matrix_allows_only_tool_edges() {
let zones = [
DockDropZone::OuterEdge(DockEdge::Left),
DockDropZone::PaneEdge(DockEdge::Right),
DockDropZone::Center,
];
for panel in PaneType::ALL {
for zone in zones {
assert_eq!(
DockDropPolicy::accepts(panel, zone),
panel != PaneType::Canvas && zone != DockDropZone::Center
);
}
}
}
#[test]
fn document_cannot_close_auto_hide_or_float() {
for placement in [
DockPlacement::Closed,
DockPlacement::AutoHidden(DockEdge::Right),
DockPlacement::Floating(DockRect {
x: 0.0,
y: 0.0,
width: 320.0,
height: 400.0,
}),
] {
assert!(!DockDropPolicy::accepts_placement(
PaneType::Canvas,
placement
));
assert!(DockDropPolicy::accepts_placement(
PaneType::Layers,
placement
));
}
}
}
@@ -3,5 +3,12 @@
//! Provides a resizable, draggable pane grid where each pane
//! can host a different panel (layers, history, brushes, etc.).
pub mod auto_hide;
pub mod floating;
pub mod manager;
pub mod persistence;
pub mod preview;
pub mod sizing;
pub mod state;
pub mod view;
pub mod welcome;
@@ -0,0 +1,242 @@
//! Stable serialization for dock layouts without runtime `Pane` identifiers.
//!
//! Live Iced nodes are mapped to panel payloads; restore removes duplicates, clamps ratios, and
//! injects the unique Canvas when malformed or legacy data omits it.
use crate::dock::manager::{DockEdge, DockRect};
use crate::dock::state::{DockState, PaneType};
use iced::widget::pane_grid::{Axis, Configuration, Node};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
/// Stable split axis independent of Iced serialization.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DockAxis {
Horizontal,
Vertical,
}
/// Serializable dock tree whose leaves contain stable panel types.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum DockLayoutNode {
Split {
axis: DockAxis,
ratio: f32,
first: Box<DockLayoutNode>,
second: Box<DockLayoutNode>,
},
Panel {
panel: PaneType,
},
}
/// Persisted panel and rail edge for one auto-hidden tool.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersistedAutoHiddenPanel {
pub panel: PaneType,
pub edge: DockEdge,
}
/// Persisted panel and logical viewport rectangle for one floating tool.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PersistedFloatingPanel {
pub panel: PaneType,
pub rect: DockRect,
}
/// Complete stable dock payload stored under `PanelLayout`.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PersistedDockLayout {
#[serde(default)]
pub tree: Option<DockLayoutNode>,
#[serde(default)]
pub auto_hidden: Vec<PersistedAutoHiddenPanel>,
#[serde(default)]
pub floating: Vec<PersistedFloatingPanel>,
#[serde(default)]
pub focused: Option<PaneType>,
}
impl PersistedDockLayout {
/// Captures topology and detached placements from a runtime dock.
pub fn capture(dock: &DockState) -> Self {
Self {
tree: snapshot_node(dock.pane_grid.layout(), &dock.pane_grid),
auto_hidden: dock.persisted_auto_hidden(),
floating: dock.floating.clone(),
focused: dock.focused_type(),
}
}
/// Restores a sanitized runtime dock and guarantees a unique Canvas.
pub fn restore(&self) -> DockState {
DockState::from_persisted(self)
}
}
/// Recursively replaces runtime leaves with stable panel payloads.
fn snapshot_node(
node: &Node,
state: &iced::widget::pane_grid::State<PaneType>,
) -> Option<DockLayoutNode> {
match node {
Node::Pane(pane) => state
.get(*pane)
.copied()
.map(|panel| DockLayoutNode::Panel { panel }),
Node::Split {
axis, ratio, a, b, ..
} => Some(DockLayoutNode::Split {
axis: match axis {
Axis::Horizontal => DockAxis::Horizontal,
Axis::Vertical => DockAxis::Vertical,
},
ratio: sanitized_ratio(*ratio),
first: Box::new(snapshot_node(a, state)?),
second: Box::new(snapshot_node(b, state)?),
}),
}
}
/// Converts a persisted tree to Iced configuration while removing duplicate leaves.
pub(crate) fn sanitized_configuration(tree: Option<&DockLayoutNode>) -> Configuration<PaneType> {
fn sanitize(
node: &DockLayoutNode,
seen: &mut HashSet<PaneType>,
) -> Option<Configuration<PaneType>> {
match node {
DockLayoutNode::Panel { panel } => {
seen.insert(*panel).then_some(Configuration::Pane(*panel))
}
DockLayoutNode::Split {
axis,
ratio,
first,
second,
} => {
let first = sanitize(first, seen);
let second = sanitize(second, seen);
match (first, second) {
(Some(a), Some(b)) => Some(Configuration::Split {
axis: match axis {
DockAxis::Horizontal => Axis::Horizontal,
DockAxis::Vertical => Axis::Vertical,
},
ratio: sanitized_ratio(*ratio),
a: Box::new(a),
b: Box::new(b),
}),
(Some(node), None) | (None, Some(node)) => Some(node),
(None, None) => None,
}
}
}
}
let mut seen = HashSet::new();
let sanitized = tree.and_then(|node| sanitize(node, &mut seen));
match (sanitized, seen.contains(&PaneType::Canvas)) {
(Some(config), true) => config,
(Some(config), false) => Configuration::Split {
axis: Axis::Vertical,
ratio: 0.75,
a: Box::new(Configuration::Pane(PaneType::Canvas)),
b: Box::new(config),
},
(None, _) => Configuration::Pane(PaneType::Canvas),
}
}
/// Validates persisted split ratios without imposing a legacy blanket layout clamp.
///
/// **Arguments:** `ratio` is a serialized or live split fraction. **Returns:** A finite ratio in
/// `0.01..=0.99`, using an even split for NaN or infinity. **Logic & Workflow:** Content policy
/// performs pixel-aware clamping once the viewport is known; persistence only prevents malformed
/// geometry. **Side Effects / Dependencies:** None.
fn sanitized_ratio(ratio: f32) -> f32 {
if ratio.is_finite() {
ratio.clamp(0.01, 0.99)
} else {
0.5
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn persisted_layout_round_trips_without_runtime_ids() {
let dock = DockState::new();
let json = serde_json::to_string(&PersistedDockLayout::capture(&dock)).unwrap();
assert!(!json.contains("Pane("));
let restored: PersistedDockLayout = serde_json::from_str(&json).unwrap();
assert!(restored.restore().invariants_hold());
}
#[test]
fn malformed_tree_deduplicates_and_injects_canvas() {
let tree = DockLayoutNode::Split {
axis: DockAxis::Vertical,
ratio: 4.0,
first: Box::new(DockLayoutNode::Panel {
panel: PaneType::Layers,
}),
second: Box::new(DockLayoutNode::Panel {
panel: PaneType::Layers,
}),
};
let restored = PersistedDockLayout {
tree: Some(tree),
..Default::default()
}
.restore();
assert!(restored.invariants_hold());
assert!(restored.is_pane_open(PaneType::Layers));
}
#[test]
fn missing_new_fields_are_serde_compatible() {
let layout: PersistedDockLayout = serde_json::from_str(r#"{"tree":null}"#).unwrap();
assert!(layout.auto_hidden.is_empty());
assert!(layout.floating.is_empty());
}
#[test]
fn ratio_sanitization_accepts_extreme_finite_values_only() {
assert_eq!(sanitized_ratio(0.02), 0.02);
assert_eq!(sanitized_ratio(0.999), 0.99);
assert_eq!(sanitized_ratio(f32::NAN), 0.5);
}
}
#[cfg(test)]
mod floating_roundtrip_tests {
use super::*;
/// Proves floating logical rectangles survive serialization, clamp, and unique redocking.
#[test]
fn floating_round_trip_clamps_and_redocks_uniquely() {
let mut dock = DockState::new();
let layers = dock.pane_for_type(PaneType::Layers).unwrap();
assert!(dock.float_pane(layers, (1280.0, 800.0)));
let json = serde_json::to_string(&PersistedDockLayout::capture(&dock)).unwrap();
let layout: PersistedDockLayout = serde_json::from_str(&json).unwrap();
let mut restored = layout.restore();
restored.clamp_floating((220.0, 160.0));
let rect = restored.floating[0].rect;
assert!(rect.x <= 172.0 && rect.y <= 132.0);
assert!(restored.redock_floating(PaneType::Layers));
assert!(restored.floating.is_empty() && restored.invariants_hold());
assert_eq!(
restored
.pane_grid
.iter()
.filter(|(_, panel)| **panel == PaneType::Layers)
.count(),
1
);
}
}
@@ -0,0 +1,267 @@
//! Pure geometry for policy-approved dock drop previews.
//!
//! Global logical pointer coordinates are converted into PaneGrid-local edge targets. Boundaries
//! and edge priority mirror Iced's own hit testing; pane centers never produce mutations.
use crate::dock::manager::{DockDropZone, DockEdge};
use iced::widget::pane_grid::{Edge, Pane, Region, Target};
use iced::{Point, Rectangle, Size};
use std::collections::BTreeMap;
pub const TITLE_HEIGHT: f32 = 28.0;
pub const TOOLBAR_HEIGHT: f32 = 28.0;
pub const STATUS_HEIGHT: f32 = 20.0;
pub const TOOLBOX_GAP: f32 = 2.0;
pub const AUTO_HIDE_RAIL_WIDTH: f32 = 36.0;
pub const PANE_SPACING: f32 = 4.0;
const OUTER_THICKNESS_RATIO: f32 = 25.0;
/// Full shell metrics used to locate PaneGrid independently of renderer scale.
#[derive(Debug, Clone, Copy)]
pub struct DockGeometry {
pub viewport: Size,
pub toolbox_width: f32,
}
impl DockGeometry {
/// Returns PaneGrid's global logical rectangle after fixed shell regions are removed.
pub fn grid_bounds(self) -> Rectangle {
let x = self.toolbox_width + TOOLBOX_GAP;
let y = TITLE_HEIGHT + TOOLBAR_HEIGHT;
Rectangle {
x,
y,
width: (self.viewport.width - x - AUTO_HIDE_RAIL_WIDTH).max(0.0),
height: (self.viewport.height - y - STATUS_HEIGHT).max(0.0),
}
}
}
/// Approved raw target and PaneGrid-local visual geometry.
#[derive(Debug, Clone, Copy)]
pub struct ApprovedTarget {
pub target: Target,
pub zone: DockDropZone,
pub preview: Rectangle,
}
/// Resolves one global logical pointer to an approved edge-only target.
pub fn approved_target(
pointer: Point,
geometry: DockGeometry,
pane_regions: &BTreeMap<Pane, Rectangle>,
) -> Option<ApprovedTarget> {
let grid = geometry.grid_bounds();
if !contains(grid, pointer) || grid.width <= 0.0 || grid.height <= 0.0 {
return None;
}
let local = Point::new(pointer.x - grid.x, pointer.y - grid.y);
let grid_local = Rectangle::new(Point::ORIGIN, grid.size());
if let Some(edge) = outer_edge(local, grid_local) {
return Some(approved(Target::Edge(to_iced(edge)), edge, grid_local));
}
pane_regions.iter().find_map(|(pane, rect)| {
contains(*rect, local)
.then(|| pane_edge(local, *rect))
.flatten()
.map(|edge| {
approved(
Target::Pane(*pane, Region::Edge(to_iced(edge))),
edge,
*rect,
)
})
})
}
/// Checks exact raw-target consistency without requiring `Target: PartialEq`.
pub fn target_matches(approved: Target, raw: Target) -> bool {
match (approved, raw) {
(Target::Edge(a), Target::Edge(b)) => edge_eq(a, b),
(Target::Pane(ap, Region::Edge(a)), Target::Pane(bp, Region::Edge(b))) => {
ap == bp && edge_eq(a, b)
}
_ => false,
}
}
fn approved(target: Target, edge: DockEdge, bounds: Rectangle) -> ApprovedTarget {
ApprovedTarget {
target,
zone: match target {
Target::Edge(_) => DockDropZone::OuterEdge(edge),
_ => DockDropZone::PaneEdge(edge),
},
preview: edge_rect(bounds, edge),
}
}
fn outer_edge(point: Point, bounds: Rectangle) -> Option<DockEdge> {
let thickness = bounds.width.min(bounds.height) / OUTER_THICKNESS_RATIO;
if point.x > bounds.x && point.x < bounds.x + thickness {
Some(DockEdge::Left)
} else if point.x > bounds.x + bounds.width - thickness
&& point.x < bounds.x + bounds.width
{
Some(DockEdge::Right)
} else if point.y > bounds.y && point.y < bounds.y + thickness {
Some(DockEdge::Top)
} else if point.y > bounds.y + bounds.height - thickness
&& point.y < bounds.y + bounds.height
{
Some(DockEdge::Bottom)
} else {
None
}
}
fn pane_edge(point: Point, bounds: Rectangle) -> Option<DockEdge> {
if point.x < bounds.x + bounds.width / 3.0 {
Some(DockEdge::Left)
} else if point.x > bounds.x + 2.0 * bounds.width / 3.0 {
Some(DockEdge::Right)
} else if point.y < bounds.y + bounds.height / 3.0 {
Some(DockEdge::Top)
} else if point.y > bounds.y + 2.0 * bounds.height / 3.0 {
Some(DockEdge::Bottom)
} else {
None
}
}
fn edge_rect(bounds: Rectangle, edge: DockEdge) -> Rectangle {
match edge {
DockEdge::Left => Rectangle {
width: bounds.width * 0.5,
..bounds
},
DockEdge::Right => Rectangle {
x: bounds.x + bounds.width * 0.5,
width: bounds.width * 0.5,
..bounds
},
DockEdge::Top => Rectangle {
height: bounds.height * 0.5,
..bounds
},
DockEdge::Bottom => Rectangle {
y: bounds.y + bounds.height * 0.5,
height: bounds.height * 0.5,
..bounds
},
}
}
fn contains(bounds: Rectangle, point: Point) -> bool {
point.x >= bounds.x
&& point.y >= bounds.y
&& point.x <= bounds.x + bounds.width
&& point.y <= bounds.y + bounds.height
}
fn to_iced(edge: DockEdge) -> Edge {
match edge {
DockEdge::Left => Edge::Left,
DockEdge::Right => Edge::Right,
DockEdge::Top => Edge::Top,
DockEdge::Bottom => Edge::Bottom,
}
}
fn edge_eq(a: Edge, b: Edge) -> bool {
matches!(
(a, b),
(Edge::Left, Edge::Left)
| (Edge::Right, Edge::Right)
| (Edge::Top, Edge::Top)
| (Edge::Bottom, Edge::Bottom)
)
}
#[cfg(test)]
mod tests {
use super::*;
use iced::widget::pane_grid::State;
fn setup() -> (DockGeometry, BTreeMap<Pane, Rectangle>) {
let (state, _) = State::new(());
let pane = *state.iter().next().unwrap().0;
let geometry = DockGeometry {
viewport: Size::new(800.0, 600.0),
toolbox_width: 36.0,
};
let mut regions = BTreeMap::new();
regions.insert(
pane,
Rectangle::new(Point::ORIGIN, geometry.grid_bounds().size()),
);
(geometry, regions)
}
#[test]
fn centers_are_never_approved() {
let (geometry, regions) = setup();
let grid = geometry.grid_bounds();
assert!(approved_target(
Point::new(grid.center_x(), grid.center_y()),
geometry,
&regions
)
.is_none());
}
#[test]
fn outer_and_pane_edges_are_approved() {
let (geometry, regions) = setup();
let grid = geometry.grid_bounds();
let outer = approved_target(
Point::new(grid.x + 2.0, grid.center_y()),
geometry,
&regions,
)
.unwrap();
assert_eq!(outer.zone, DockDropZone::OuterEdge(DockEdge::Left));
let pane = approved_target(
Point::new(grid.center_x(), grid.y + grid.height * 0.2),
geometry,
&regions,
)
.unwrap();
assert_eq!(pane.zone, DockDropZone::PaneEdge(DockEdge::Top));
}
#[test]
fn narrow_viewport_clamps_to_empty_bounds() {
let geometry = DockGeometry {
viewport: Size::new(60.0, 50.0),
toolbox_width: 72.0,
};
assert_eq!(geometry.grid_bounds().size(), Size::new(0.0, 0.0));
}
#[test]
fn edge_classification_matches_iced_thirds_and_priority() {
let bounds = Rectangle::new(Point::new(10.0, 20.0), Size::new(300.0, 180.0));
assert_eq!(
pane_edge(Point::new(109.9, 21.0), bounds),
Some(DockEdge::Left)
);
assert_eq!(pane_edge(Point::new(110.0, 110.0), bounds), None);
assert_eq!(
pane_edge(Point::new(210.0, 79.9), bounds),
Some(DockEdge::Top)
);
assert_eq!(
pane_edge(Point::new(11.0, 21.0), bounds),
Some(DockEdge::Left)
);
}
#[test]
fn outer_edge_uses_iced_dynamic_thickness_and_priority() {
let bounds = Rectangle::new(Point::ORIGIN, Size::new(500.0, 250.0));
assert_eq!(outer_edge(Point::new(9.9, 1.0), bounds), Some(DockEdge::Left));
assert_eq!(outer_edge(Point::new(10.0, 125.0), bounds), None);
assert_eq!(outer_edge(Point::new(250.0, 9.9), bounds), Some(DockEdge::Top));
}
}
@@ -0,0 +1,522 @@
//! Content-aware sizing policy and constraint solver for the Iced dock tree.
//!
//! **Purpose:** Keeps utility panels legible while assigning adaptable space to the canvas and
//! other flexible content. **Logic & Workflow:** Leaf policies are aggregated through the
//! `PaneGrid` tree, legal pixel intervals are converted to split ratios, and ratio updates are
//! collected before mutating Iced state. **Side Effects / Dependencies:** Public rebalance
//! functions mutate only `pane_grid::State` split ratios and perform no I/O.
use crate::dock::state::PaneType;
use iced::widget::pane_grid::{Axis, Node, Split, State};
use iced::Size;
/// Space between adjacent dock panes, matching `dock::view`.
pub const PANE_SPACING: f32 = 4.0;
/// Minimum, preferred, and maximum extent for one axis of a panel or subtree.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PaneAxisPolicy {
/// Smallest usable logical-pixel extent.
pub min: f32,
/// Comfortable logical-pixel extent before surplus growth.
pub preferred: f32,
/// Largest useful extent, or `f32::INFINITY` for flexible content.
pub max: f32,
/// Relative priority for receiving constrained and surplus space.
pub grow_priority: u16,
}
/// Independent horizontal and vertical sizing policies for one panel.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PaneSizePolicy {
/// Horizontal sizing policy.
pub width: PaneAxisPolicy,
/// Vertical sizing policy.
pub height: PaneAxisPolicy,
}
/// Reason for solving the tree, used to preserve the correct existing dimensions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RebalanceMode {
/// Computes content-aware ratios for the built-in layout once its viewport is known.
InitialDefault,
/// Corrects new Iced 50/50 splits while retaining other valid ratios.
StructureChanged,
/// Preserves lower-growth utility pixels and sends viewport deltas to flexible content.
WindowResize,
/// Preserves existing ratios except where pixel constraints require a clamp.
UserResize,
}
/// Returns the content policy for a dock panel.
///
/// **Arguments:** `panel` identifies the leaf being measured. **Returns:** Width and height
/// constraints in logical pixels. **Logic & Workflow:** Canvas is unbounded and receives the
/// highest priority; Color Palette is deliberately bounded and low-growth; editors remain
/// flexible; list-like tools use compact but readable defaults. **Side Effects / Dependencies:**
/// None.
pub fn panel_size_policy(panel: PaneType) -> PaneSizePolicy {
let axis = |min, preferred, max, grow_priority| PaneAxisPolicy {
min,
preferred,
max,
grow_priority,
};
match panel {
PaneType::Canvas => PaneSizePolicy {
width: axis(320.0, 800.0, f32::INFINITY, 100),
height: axis(240.0, 600.0, f32::INFINITY, 100),
},
PaneType::ColorPicker => PaneSizePolicy {
width: axis(180.0, 280.0, 380.0, 1),
height: axis(180.0, 330.0, 380.0, 1),
},
PaneType::Script | PaneType::AiChat | PaneType::AiScript => PaneSizePolicy {
width: axis(220.0, 360.0, f32::INFINITY, 60),
height: axis(180.0, 420.0, f32::INFINITY, 60),
},
PaneType::Layers | PaneType::History | PaneType::LayerDetails => PaneSizePolicy {
width: axis(160.0, 300.0, f32::INFINITY, 35),
height: axis(120.0, 300.0, f32::INFINITY, 35),
},
PaneType::Brushes | PaneType::Filters => PaneSizePolicy {
width: axis(160.0, 300.0, f32::INFINITY, 30),
height: axis(120.0, 280.0, f32::INFINITY, 30),
},
PaneType::Properties | PaneType::Geometry | PaneType::ToolSettings => PaneSizePolicy {
width: axis(160.0, 280.0, f32::INFINITY, 25),
height: axis(120.0, 280.0, f32::INFINITY, 25),
},
}
}
/// Aggregates one axis of a subtree into a single policy.
///
/// **Arguments:** `node` is the subtree root, `state` resolves runtime pane IDs to stable panel
/// types, and `axis` selects width or height. **Returns:** Combined minimum, preferred, maximum,
/// and highest growth priority. **Logic & Workflow:** Children split on the queried axis add their
/// extents and spacing; children split across it share an extent, so minima/preferences use the
/// larger child and maxima use the tighter child. **Side Effects / Dependencies:** None.
pub fn aggregate_subtree_policy(
node: &Node,
state: &State<PaneType>,
axis: Axis,
) -> PaneAxisPolicy {
match node {
Node::Pane(pane) => state
.get(*pane)
.map(|panel| policy_axis(panel_size_policy(*panel), axis))
.unwrap_or(PaneAxisPolicy {
min: 0.0,
preferred: 0.0,
max: f32::INFINITY,
grow_priority: 0,
}),
Node::Split {
axis: split_axis,
a,
b,
..
} => {
let a = aggregate_subtree_policy(a, state, axis);
let b = aggregate_subtree_policy(b, state, axis);
if same_axis(*split_axis, axis) {
PaneAxisPolicy {
min: a.min + PANE_SPACING + b.min,
preferred: a.preferred + PANE_SPACING + b.preferred,
max: add_max(a.max, b.max, PANE_SPACING),
grow_priority: a.grow_priority.max(b.grow_priority),
}
} else {
PaneAxisPolicy {
min: a.min.max(b.min),
preferred: a.preferred.max(b.preferred),
max: a.max.min(b.max),
grow_priority: a.grow_priority.max(b.grow_priority),
}
}
}
}
}
/// Rebalances every split for a known grid size.
///
/// **Arguments:** `state` is the live PaneGrid state, `grid_size` excludes shell/sidebar/rail,
/// `previous_grid_size` is required for pixel-preserving window resize, and `mode` selects the
/// preservation strategy. **Returns:** Nothing. **Logic & Workflow:** The immutable tree is
/// traversed first to collect legal ratios, then updates are applied to avoid aliasing state while
/// policies are calculated. **Side Effects / Dependencies:** Mutates split ratios in `state`.
pub fn rebalance_state(
state: &mut State<PaneType>,
grid_size: Size,
previous_grid_size: Option<Size>,
mode: RebalanceMode,
) {
if grid_size.width <= 0.0 || grid_size.height <= 0.0 {
return;
}
let mut updates = Vec::new();
collect_updates(
state.layout(),
state,
grid_size,
previous_grid_size,
mode,
&mut updates,
);
for (split, ratio) in updates {
state.resize(split, ratio);
}
}
/// Clamps one user-requested split ratio against subtree pixel policies.
///
/// **Arguments:** `state` supplies the split tree and panel types, `split` identifies the divider,
/// `requested_ratio` is the pointer-derived ratio, and `grid_size` is the actual PaneGrid size.
/// **Returns:** The nearest finite legal ratio, bounded only by content constraints and the
/// persistence safety range. **Side Effects / Dependencies:** None.
pub fn constrain_user_resize_ratio(
state: &State<PaneType>,
split: Split,
requested_ratio: f32,
grid_size: Size,
) -> f32 {
let Some((node, extent)) = find_split(state.layout(), split, grid_size) else {
return sanitize_ratio(requested_ratio);
};
let Node::Split { axis, a, b, .. } = node else {
return sanitize_ratio(requested_ratio);
};
clamp_ratio(
requested_ratio,
extent_for_axis(extent, *axis),
aggregate_subtree_policy(a, state, *axis),
aggregate_subtree_policy(b, state, *axis),
)
}
/// Recursively collects split updates using original ratios for all geometry calculations.
fn collect_updates(
node: &Node,
state: &State<PaneType>,
extent: Size,
previous_extent: Option<Size>,
mode: RebalanceMode,
updates: &mut Vec<(Split, f32)>,
) {
let Node::Split {
id,
axis,
ratio,
a,
b,
} = node
else {
return;
};
let total = extent_for_axis(extent, *axis);
let a_policy = aggregate_subtree_policy(a, state, *axis);
let b_policy = aggregate_subtree_policy(b, state, *axis);
let policy_ratio = allocation_ratio(total, a_policy, b_policy);
let current = sanitize_ratio(*ratio);
let requested = match mode {
RebalanceMode::InitialDefault => policy_ratio,
RebalanceMode::StructureChanged if (current - 0.5).abs() <= 0.0001 => policy_ratio,
RebalanceMode::StructureChanged | RebalanceMode::UserResize => current,
RebalanceMode::WindowResize => {
let old_total = previous_extent
.map(|old| extent_for_axis(old, *axis))
.unwrap_or(total);
window_resize_ratio(total, old_total, current, a_policy, b_policy)
}
};
let next = clamp_ratio(requested, total, a_policy, b_policy);
if (next - *ratio).abs() > 0.0001 {
updates.push((*id, next));
}
let (next_a, next_b) = split_extent(extent, *axis, next);
let (old_a, old_b) = previous_extent
.map(|old| split_extent(old, *axis, current))
.map_or((None, None), |(a, b)| (Some(a), Some(b)));
collect_updates(a, state, next_a, old_a, mode, updates);
collect_updates(b, state, next_b, old_b, mode, updates);
}
/// Computes an allocation ratio from minima, preferred extents, maxima, and growth priority.
fn allocation_ratio(total: f32, a: PaneAxisPolicy, b: PaneAxisPolicy) -> f32 {
let available = (total - PANE_SPACING).max(1.0);
let mut sizes = [a.min, b.min];
let policies = [a, b];
let mut remaining = (available - sizes[0] - sizes[1]).max(0.0);
let preferences_fit = a.preferred + b.preferred <= available;
let mut order = [0usize, 1usize];
order.sort_by_key(|index| std::cmp::Reverse(policies[*index].grow_priority));
if preferences_fit {
for index in 0..2 {
let target = policies[index].preferred.min(policies[index].max);
let addition = (target - sizes[index]).max(0.0).min(remaining);
sizes[index] += addition;
remaining -= addition;
}
} else {
for index in order {
let target = policies[index].preferred.min(policies[index].max);
let addition = (target - sizes[index]).max(0.0).min(remaining);
sizes[index] += addition;
remaining -= addition;
}
}
for index in order {
let capacity = (policies[index].max - sizes[index]).max(0.0);
let addition = capacity.min(remaining);
sizes[index] += addition;
remaining -= addition;
}
if remaining > 0.0 {
sizes[order[0]] += remaining;
}
(sizes[0] + PANE_SPACING / 2.0) / total.max(1.0)
}
/// Preserves the lower-growth child's previous pixels while assigning the delta to its sibling.
fn window_resize_ratio(
total: f32,
old_total: f32,
current: f32,
a: PaneAxisPolicy,
b: PaneAxisPolicy,
) -> f32 {
let total = total.max(1.0);
let old_total = old_total.max(1.0);
let old_a = (old_total * current - PANE_SPACING / 2.0).max(0.0);
let old_b = (old_total * (1.0 - current) - PANE_SPACING / 2.0).max(0.0);
if a.grow_priority > b.grow_priority {
1.0 - (old_b + PANE_SPACING / 2.0) / total
} else if b.grow_priority > a.grow_priority {
(old_a + PANE_SPACING / 2.0) / total
} else {
current
}
}
/// Converts child constraints to a legal split interval and clamps a proposed ratio.
fn clamp_ratio(
requested: f32,
total: f32,
a: PaneAxisPolicy,
b: PaneAxisPolicy,
) -> f32 {
let total = total.max(1.0);
let half_spacing = PANE_SPACING / 2.0;
let mut low = ((a.min + half_spacing) / total)
.max(1.0 - (b.max + half_spacing) / total);
let mut high = (1.0 - (b.min + half_spacing) / total)
.min((a.max + half_spacing) / total);
low = low.clamp(0.01, 0.99);
high = high.clamp(0.01, 0.99);
if low > high {
let minimum_total = a.min + b.min;
let fallback = if minimum_total > 0.0 {
a.min / minimum_total
} else {
0.5
};
return sanitize_ratio(fallback);
}
sanitize_ratio(requested).clamp(low, high)
}
/// Finds a split and its actual region without mutating the tree.
fn find_split(node: &Node, split: Split, extent: Size) -> Option<(&Node, Size)> {
match node {
Node::Pane(_) => None,
Node::Split {
id,
axis,
ratio,
a,
b,
} => {
if *id == split {
return Some((node, extent));
}
let (a_extent, b_extent) = split_extent(extent, *axis, sanitize_ratio(*ratio));
find_split(a, split, a_extent).or_else(|| find_split(b, split, b_extent))
}
}
}
/// Splits a logical region using Iced's spacing semantics.
fn split_extent(extent: Size, axis: Axis, ratio: f32) -> (Size, Size) {
match axis {
Axis::Horizontal => {
let a = (extent.height * ratio - PANE_SPACING / 2.0)
.max(0.0)
.round();
(
Size::new(extent.width, a),
Size::new(extent.width, (extent.height - a - PANE_SPACING).max(0.0)),
)
}
Axis::Vertical => {
let a = (extent.width * ratio - PANE_SPACING / 2.0)
.max(0.0)
.round();
(
Size::new(a, extent.height),
Size::new((extent.width - a - PANE_SPACING).max(0.0), extent.height),
)
}
}
}
/// Selects one axis from a complete panel policy.
fn policy_axis(policy: PaneSizePolicy, axis: Axis) -> PaneAxisPolicy {
match axis {
Axis::Horizontal => policy.height,
Axis::Vertical => policy.width,
}
}
/// Returns the extent corresponding to an Iced split axis.
fn extent_for_axis(size: Size, axis: Axis) -> f32 {
match axis {
Axis::Horizontal => size.height,
Axis::Vertical => size.width,
}
}
/// Compares Iced axes without requiring assumptions about their debug representation.
fn same_axis(a: Axis, b: Axis) -> bool {
matches!((a, b), (Axis::Horizontal, Axis::Horizontal) | (Axis::Vertical, Axis::Vertical))
}
/// Adds maximum extents while preserving an unbounded result.
fn add_max(a: f32, b: f32, spacing: f32) -> f32 {
if a.is_infinite() || b.is_infinite() {
f32::INFINITY
} else {
a + spacing + b
}
}
/// Converts malformed ratios to a safe value and applies the persistence bounds.
fn sanitize_ratio(ratio: f32) -> f32 {
if ratio.is_finite() {
ratio.clamp(0.01, 0.99)
} else {
0.5
}
}
#[cfg(test)]
mod tests {
use super::*;
use iced::widget::pane_grid::{Configuration, State};
/// Creates a simple two-pane state and returns its only split.
fn pair(a: PaneType, b: PaneType, axis: Axis) -> (State<PaneType>, Split) {
let state = State::with_configuration(Configuration::Split {
axis,
ratio: 0.5,
a: Box::new(Configuration::Pane(a)),
b: Box::new(Configuration::Pane(b)),
});
let split = *state.layout().splits().next().unwrap();
(state, split)
}
/// Verifies leaf policy values encode the required growth hierarchy and Color bounds.
#[test]
fn policy_values_prioritize_canvas_and_bound_color_picker() {
let canvas = panel_size_policy(PaneType::Canvas);
let color = panel_size_policy(PaneType::ColorPicker);
assert_eq!((canvas.width.min, canvas.height.min), (320.0, 240.0));
assert!(canvas.width.max.is_infinite());
assert_eq!((color.width.preferred, color.height.preferred), (280.0, 330.0));
assert_eq!((color.width.max, color.height.max), (380.0, 380.0));
assert!(canvas.width.grow_priority > color.width.grow_priority);
}
/// Verifies additive and shared subtree aggregation includes spacing and child constraints.
#[test]
fn subtree_constraints_follow_split_axis() {
let (state, _) = pair(PaneType::Canvas, PaneType::ColorPicker, Axis::Vertical);
let width = aggregate_subtree_policy(state.layout(), &state, Axis::Vertical);
let height = aggregate_subtree_policy(state.layout(), &state, Axis::Horizontal);
assert_eq!(width.min, 320.0 + PANE_SPACING + 180.0);
assert_eq!(height.min, 240.0);
assert_eq!(height.max, 380.0);
}
/// Ensures both requested audit viewports can satisfy every leaf minimum in the default tree.
#[test]
fn default_and_compact_viewports_have_feasible_bounds() {
for size in [Size::new(1206.0, 724.0), Size::new(950.0, 524.0)] {
let mut dock = crate::dock::state::DockState::new();
rebalance_state(&mut dock.pane_grid, size, None, RebalanceMode::InitialDefault);
let regions = dock.pane_grid.layout().pane_regions(PANE_SPACING, size);
for (pane, panel) in dock.pane_grid.iter() {
let region = regions[pane];
let policy = panel_size_policy(*panel);
assert!(region.width + 0.1 >= policy.width.min, "{panel:?} width");
assert!(region.height + 0.1 >= policy.height.min, "{panel:?} height");
}
}
}
/// Ensures a bounded Color Palette does not absorb vertical surplus before a list sibling.
#[test]
fn color_picker_does_not_receive_vertical_surplus_with_list_sibling() {
let (mut state, _) = pair(PaneType::ColorPicker, PaneType::Layers, Axis::Horizontal);
let size = Size::new(300.0, 800.0);
rebalance_state(&mut state, size, None, RebalanceMode::InitialDefault);
let regions = state.layout().pane_regions(PANE_SPACING, size);
let color = state.iter().find(|(_, panel)| **panel == PaneType::ColorPicker).unwrap().0;
let layers = state.iter().find(|(_, panel)| **panel == PaneType::Layers).unwrap().0;
assert!(regions[color].height <= 380.1);
assert!(regions[layers].height > regions[color].height);
}
/// Ensures a newly created Iced 50/50 split is corrected using panel content policy.
#[test]
fn structure_rebalance_avoids_oversized_color_picker() {
let (mut state, split) = pair(PaneType::Canvas, PaneType::ColorPicker, Axis::Vertical);
rebalance_state(
&mut state,
Size::new(1000.0, 700.0),
None,
RebalanceMode::StructureChanged,
);
let ratio = state.layout().split_regions(PANE_SPACING, Size::new(1000.0, 700.0))[&split].2;
assert!(ratio > 0.6);
}
/// Ensures valid user ratios survive while requests outside pixel constraints are clamped.
#[test]
fn user_resize_preserves_valid_and_clamps_invalid_ratios() {
let (state, split) = pair(PaneType::Canvas, PaneType::ColorPicker, Axis::Vertical);
let size = Size::new(1000.0, 700.0);
assert_eq!(constrain_user_resize_ratio(&state, split, 0.7, size), 0.7);
let clamped = constrain_user_resize_ratio(&state, split, 0.95, size);
assert!(clamped < 0.83 && clamped > 0.6);
}
/// Ensures added window width is assigned to Canvas while utility pixels remain stable.
#[test]
fn window_growth_gives_surplus_to_canvas() {
let (mut state, _) = pair(PaneType::Canvas, PaneType::Layers, Axis::Vertical);
let old = Size::new(900.0, 700.0);
rebalance_state(&mut state, old, None, RebalanceMode::InitialDefault);
let old_regions = state.layout().pane_regions(PANE_SPACING, old);
let layers = *state.iter().find(|(_, panel)| **panel == PaneType::Layers).unwrap().0;
let old_utility = old_regions[&layers].width;
let new = Size::new(1200.0, 700.0);
rebalance_state(&mut state, new, Some(old), RebalanceMode::WindowResize);
let new_regions = state.layout().pane_regions(PANE_SPACING, new);
assert!((new_regions[&layers].width - old_utility).abs() < 0.2);
}
}
@@ -1,23 +1,34 @@
//! Dock state — manages pane grid layout and panel assignments.
//!
//! Provides a resizable, draggable pane grid where each pane
//! can host a different panel (layers, history, brushes, etc.).
//! The default layout mirrors the egui GUI arrangement with Canvas centered:
//!
//! ```text
//! +--------+------------+-----------+---------+
//! | | | | Layers |
//! | Brushes| | |---------|
//! |--------| Canvas | ColorBox |Properties|
//! | Filters| |-----------|---------|
//! | | | History | |
//! +--------+------------+-----------+---------+
//! ```
use iced::widget::pane_grid::{Configuration, Pane, State};
///! Dock state — manages pane grid layout and panel assignments.
///!
///! Provides a resizable, draggable pane grid where each pane
///! can host a different panel (layers, history, brushes, etc.).
///! The default layout mirrors the egui GUI arrangement with Canvas centered:
///!
///! ```text
///! +--------+------------+-----------+---------+
///! | | | | Layers |
///! | Brushes| | |---------|
///! |--------| Canvas | ColorBox |Properties|
///! | Filters| |-----------|---------|
///! | | | History | |
///! +--------+------------+-----------+---------+
///! ```
use crate::dock::floating::FloatingDragState;
use crate::dock::manager::{
DockDragState, DockDropPolicy, DockDropZone, DockEdge, DockHeaderDragState, DockPlacement,
};
use crate::dock::persistence::{
PersistedAutoHiddenPanel, PersistedDockLayout, PersistedFloatingPanel,
};
use crate::dock::preview::ApprovedTarget;
use iced::widget::pane_grid::{Configuration, Pane, State, Target};
use iced::Size;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
/// Identifies which panel content a pane displays.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub enum PaneType {
/// The main canvas viewport.
@@ -49,6 +60,23 @@ pub enum PaneType {
}
impl PaneType {
/// All panel payloads in stable declaration order.
pub const ALL: [Self; 13] = [
Self::Canvas,
Self::Layers,
Self::History,
Self::Brushes,
Self::Filters,
Self::ColorPicker,
Self::Properties,
Self::Script,
Self::AiChat,
Self::LayerDetails,
Self::Geometry,
Self::ToolSettings,
Self::AiScript,
];
/// Display name for the pane type.
pub fn label(self) -> &'static str {
match self {
@@ -76,29 +104,45 @@ impl PaneType {
/// command-line capture and dock UI naming cannot drift independently.
/// **Side Effects / Dependencies:** None.
pub fn from_label(label: &str) -> Option<Self> {
[
Self::Canvas,
Self::Layers,
Self::History,
Self::Brushes,
Self::Filters,
Self::ColorPicker,
Self::Properties,
Self::Script,
Self::AiChat,
Self::LayerDetails,
Self::Geometry,
Self::ToolSettings,
Self::AiScript,
]
.into_iter()
.find(|pane| pane.label() == label)
Self::ALL.into_iter().find(|pane| pane.label() == label)
}
/// Compact readable label used on narrow auto-hide rails.
pub fn rail_label(self) -> &'static str {
match self {
Self::ColorPicker => "Color",
Self::LayerDetails => "Details",
Self::ToolSettings => "Tools",
Self::AiScript => "AI Script",
_ => self.label(),
}
}
/// Clear fallback glyph used where an SVG is not available inline.
pub fn icon_glyph(self) -> &'static str {
match self {
Self::Layers => "L",
Self::History => "H",
Self::Brushes => "B",
Self::Filters => "F",
Self::ColorPicker => "C",
Self::Properties => "P",
Self::Script => "S",
Self::AiChat | Self::AiScript => "AI",
Self::Geometry => "G",
Self::LayerDetails => "D",
Self::ToolSettings => "T",
Self::Canvas => "",
}
}
}
#[cfg(test)]
mod tests {
use super::{DockState, PaneType};
use crate::dock::floating::FloatingDragState;
use crate::dock::manager::DockDragState;
use crate::dock::manager::DockEdge;
use iced::widget::pane_grid::{Edge, Region, Target};
#[test]
@@ -123,18 +167,82 @@ mod tests {
let mut dock = DockState::new();
let source = pane(&dock, PaneType::Brushes);
let target = pane(&dock, PaneType::Layers);
dock.pane_grid.drop(source, Target::Pane(target, region));
let accepted = dock.apply_drop(source, Target::Pane(target, region));
assert_eq!(accepted, !matches!(region, Region::Center));
assert_canvas_invariant(&dock);
}
for edge in [Edge::Top, Edge::Right, Edge::Bottom, Edge::Left] {
let mut dock = DockState::new();
let source = pane(&dock, PaneType::Brushes);
dock.pane_grid.drop(source, Target::Edge(edge));
assert!(dock.apply_drop(source, Target::Edge(edge)));
assert_canvas_invariant(&dock);
}
}
#[test]
fn canvas_drop_is_rejected_and_auto_hide_restores_uniquely() {
let mut dock = DockState::new();
let canvas = pane(&dock, PaneType::Canvas);
assert!(!dock.apply_drop(canvas, Target::Edge(Edge::Right)));
let layers = pane(&dock, PaneType::Layers);
assert!(dock.auto_hide_pane(layers, None));
assert!(!dock.is_pane_open(PaneType::Layers));
assert!(dock.restore_auto_hidden(PaneType::Layers));
assert!(dock.invariants_hold());
assert_eq!(
dock.pane_grid
.iter()
.filter(|(_, panel)| **panel == PaneType::Layers)
.count(),
1
);
}
#[test]
fn floating_panel_redocks_at_preview_target_and_can_minimize() {
let mut dock = DockState::new();
let brushes = pane(&dock, PaneType::Brushes);
assert!(dock.float_pane(brushes, (1280.0, 800.0)));
let layers = pane(&dock, PaneType::Layers);
assert!(dock.redock_floating_at(
PaneType::Brushes,
Target::Pane(layers, Region::Edge(Edge::Left)),
));
assert!(dock.is_pane_open(PaneType::Brushes));
assert!(dock.invariants_hold());
let properties = pane(&dock, PaneType::Properties);
assert!(dock.float_pane(properties, (1280.0, 800.0)));
assert!(dock.auto_hide_floating(PaneType::Properties, DockEdge::Right));
assert!(dock.auto_hidden[1].contains(&PaneType::Properties));
assert!(dock.invariants_hold());
}
#[test]
fn global_release_clears_click_only_and_floating_drag_state() {
let mut dock = DockState::new();
let layers = pane(&dock, PaneType::Layers);
dock.drag = Some(DockDragState {
source_pane: layers,
source_type: PaneType::Layers,
pointer: Some(iced::Point::new(120.0, 80.0)),
candidate_target: None,
accepted_preview: None,
});
dock.floating_drag = Some(FloatingDragState {
panel: PaneType::Properties,
pointer_offset: iced::Point::new(10.0, 8.0),
});
dock.cancel_transient_drags();
assert!(dock.drag.is_none());
assert!(dock.floating_drag.is_none());
assert!(dock.floating_target.is_none());
assert!(dock.invariants_hold());
}
/// Finds the pane identifier containing `pane_type` in a test dock.
fn pane(dock: &DockState, pane_type: PaneType) -> iced::widget::pane_grid::Pane {
dock.pane_grid
@@ -165,6 +273,26 @@ pub struct DockState {
pub root_pane: Pane,
/// Currently focused pane.
pub focused_pane: Option<Pane>,
/// Active PaneGrid drag and approved candidate.
pub drag: Option<DockDragState>,
/// Visible-title press that becomes a custom dock drag after the pointer deadband.
pub header_drag: Option<DockHeaderDragState>,
/// Auto-hidden panels ordered left, right, top, bottom.
pub auto_hidden: [Vec<PaneType>; 4],
/// Temporary auto-hide overlay currently displayed.
pub active_auto_hide: Option<PaneType>,
/// Stable floating placements in back-to-front order.
pub floating: Vec<PersistedFloatingPanel>,
/// Active floating header drag.
pub floating_drag: Option<FloatingDragState>,
/// Approved edge target currently previewed by a floating-panel drag.
pub floating_target: Option<ApprovedTarget>,
/// Last measured PaneGrid size after fixed shell, sidebar, and auto-hide rail subtraction.
pub last_grid_size: Option<Size>,
/// Whether an interactive split change must be persisted on pointer release.
pub resize_persistence_dirty: bool,
/// Whether the first measured viewport should replace built-in placeholder ratios.
apply_initial_defaults: bool,
}
impl DockState {
@@ -251,15 +379,67 @@ impl DockState {
pane_grid,
root_pane,
focused_pane: Some(root_pane),
drag: None,
header_drag: None,
auto_hidden: std::array::from_fn(|_| Vec::new()),
active_auto_hide: None,
floating: Vec::new(),
floating_drag: None,
floating_target: None,
last_grid_size: None,
resize_persistence_dirty: false,
apply_initial_defaults: true,
}
}
/// Restores stable topology and placements while ignoring invalid detachments.
pub fn from_persisted(layout: &PersistedDockLayout) -> Self {
let pane_grid = State::with_configuration(
crate::dock::persistence::sanitized_configuration(layout.tree.as_ref()),
);
let root_pane = pane_grid
.iter()
.find(|(_, t)| **t == PaneType::Canvas)
.map(|(p, _)| *p)
.expect("sanitized dock layout always has Canvas");
let mut dock = Self {
pane_grid,
root_pane,
focused_pane: Some(root_pane),
drag: None,
header_drag: None,
auto_hidden: std::array::from_fn(|_| Vec::new()),
active_auto_hide: None,
floating: Vec::new(),
floating_drag: None,
floating_target: None,
last_grid_size: None,
resize_persistence_dirty: false,
apply_initial_defaults: false,
};
for item in &layout.auto_hidden {
if item.panel != PaneType::Canvas && !dock.contains_anywhere(item.panel) {
dock.auto_hidden[edge_index(item.edge)].push(item.panel);
}
}
for item in &layout.floating {
if item.panel != PaneType::Canvas && !dock.contains_anywhere(item.panel) {
dock.floating.push(item.clone());
}
}
if let Some(panel) = layout.focused {
dock.focused_pane = dock.pane_for_type(panel).or(Some(root_pane));
}
dock
}
/// Split a pane horizontally with a new panel type.
#[allow(dead_code)]
pub fn split_horizontal(&mut self, target: Pane, pane_type: PaneType) -> Option<Pane> {
let (new_pane, _) =
self.pane_grid
.split(iced::widget::pane_grid::Axis::Horizontal, target, pane_type)?;
self.rebalance_structure();
Some(new_pane)
}
@@ -269,12 +449,20 @@ impl DockState {
let (new_pane, _) =
self.pane_grid
.split(iced::widget::pane_grid::Axis::Vertical, target, pane_type)?;
self.rebalance_structure();
Some(new_pane)
}
/// Close a pane, redistributing its content to neighbors.
pub fn close_pane(&mut self, pane: Pane) -> bool {
self.pane_grid.close(pane).is_some()
if self.pane_type(pane) == Some(PaneType::Canvas) {
return false;
}
let changed = self.pane_grid.close(pane).is_some();
if changed {
self.rebalance_structure();
}
changed
}
/// Toggle maximize on a pane.
@@ -291,19 +479,340 @@ impl DockState {
self.focused_pane = Some(pane);
}
/// Clears custom drag state after a global pointer release.
///
/// **Purpose:** Handles click-only PaneGrid picks that never emit `Dropped` or `Canceled`
/// because Iced's drag deadband was not crossed. **Side Effects:** Removes only transient drag
/// and preview state; dock topology, floating placements, and persisted layout are unchanged.
pub fn cancel_transient_drags(&mut self) {
self.drag = None;
self.header_drag = None;
self.floating_drag = None;
self.floating_target = None;
}
/// Get the pane type at a given pane ID.
pub fn pane_type(&self, pane: Pane) -> Option<PaneType> {
self.pane_grid.get(pane).copied()
}
/// Returns the runtime ID currently carrying a stable panel payload.
pub fn pane_for_type(&self, panel: PaneType) -> Option<Pane> {
self.pane_grid
.iter()
.find(|(_, candidate)| **candidate == panel)
.map(|(pane, _)| *pane)
}
/// Applies one raw PaneGrid target only after policy and invariant validation.
pub fn apply_drop(&mut self, pane: Pane, target: Target) -> bool {
let Some(source) = self.pane_type(pane) else {
return false;
};
if !self.invariants_hold()
|| !DockDropPolicy::accepts(source, DockDropZone::from_target(target))
{
return false;
}
if matches!(target, Target::Pane(target, _) if target == pane || self.pane_type(target).is_none())
{
return false;
}
let previous = self.pane_grid.clone();
self.pane_grid.drop(pane, target);
if !self.invariants_hold() {
self.pane_grid = previous;
return false;
}
self.focused_pane = self.pane_for_type(source);
self.rebalance_structure();
true
}
/// Verifies exactly one Canvas and one instance at most of every tool payload.
pub fn invariants_hold(&self) -> bool {
let mut seen = HashSet::new();
self.pane_grid.iter().all(|(_, panel)| seen.insert(*panel))
&& seen.contains(&PaneType::Canvas)
&& self
.auto_hidden
.iter()
.flatten()
.all(|panel| *panel != PaneType::Canvas && seen.insert(*panel))
&& self
.floating
.iter()
.all(|item| item.panel != PaneType::Canvas && seen.insert(item.panel))
}
/// Get the currently focused pane type.
#[allow(dead_code)]
pub fn focused_type(&self) -> Option<PaneType> {
self.focused_pane.and_then(|p| self.pane_type(p))
}
/// Removes a tool from PaneGrid and adds it to an auto-hide rail.
pub fn auto_hide_pane(&mut self, pane: Pane, requested: Option<DockEdge>) -> bool {
let Some(panel) = self.pane_type(pane) else {
return false;
};
let edge = requested.unwrap_or_else(|| self.infer_edge(pane));
if !DockDropPolicy::accepts_placement(panel, DockPlacement::AutoHidden(edge)) {
return false;
}
let previous = self.pane_grid.clone();
if self.pane_grid.close(pane).is_none() {
return false;
}
self.auto_hidden[edge_index(edge)].push(panel);
self.focused_pane = self.pane_for_type(PaneType::Canvas);
if !self.invariants_hold() {
self.pane_grid = previous;
self.auto_hidden[edge_index(edge)].retain(|candidate| *candidate != panel);
return false;
}
self.rebalance_structure();
true
}
/// Restores an auto-hidden panel next to Canvas on its remembered edge.
pub fn restore_auto_hidden(&mut self, panel: PaneType) -> bool {
let Some(edge) = self
.auto_hidden
.iter()
.enumerate()
.find_map(|(index, panels)| panels.contains(&panel).then_some(index_edge(index)))
else {
return false;
};
for panels in &mut self.auto_hidden {
panels.retain(|candidate| *candidate != panel);
}
self.active_auto_hide = None;
if self.contains_anywhere(panel) {
return false;
}
let Some(canvas) = self.pane_for_type(PaneType::Canvas) else {
return false;
};
let axis = match edge {
DockEdge::Top | DockEdge::Bottom => iced::widget::pane_grid::Axis::Horizontal,
_ => iced::widget::pane_grid::Axis::Vertical,
};
let Some((new_pane, _)) = self.pane_grid.split(axis, canvas, panel) else {
return false;
};
if matches!(edge, DockEdge::Left | DockEdge::Top) {
self.pane_grid.swap(canvas, new_pane);
}
self.focus(new_pane);
self.rebalance_structure();
self.invariants_hold()
}
/// Removes a tool pane from PaneGrid and creates a clamped floating placement.
pub fn float_pane(&mut self, pane: Pane, viewport: (f32, f32)) -> bool {
let Some(panel) = self.pane_type(pane) else {
return false;
};
let rect = crate::dock::floating::default_rect(viewport, self.floating.len());
if !DockDropPolicy::accepts_placement(panel, DockPlacement::Floating(rect)) {
return false;
}
let previous = self.pane_grid.clone();
if self.pane_grid.close(pane).is_none() {
return false;
}
self.floating.push(PersistedFloatingPanel { panel, rect });
self.focused_pane = self.pane_for_type(PaneType::Canvas);
if !self.invariants_hold() {
self.floating.retain(|item| item.panel != panel);
self.pane_grid = previous;
return false;
}
self.rebalance_structure();
true
}
/// Moves a floating panel to the top of z-order and returns its current rectangle.
pub fn focus_floating(&mut self, panel: PaneType) -> Option<crate::dock::manager::DockRect> {
let index = self.floating.iter().position(|item| item.panel == panel)?;
let item = self.floating.remove(index);
let rect = item.rect;
self.floating.push(item);
Some(rect)
}
/// Updates a floating rectangle after clamping it to the current viewport.
pub fn move_floating(
&mut self,
panel: PaneType,
rect: crate::dock::manager::DockRect,
viewport: (f32, f32),
) -> bool {
let Some(item) = self.floating.iter_mut().find(|item| item.panel == panel) else {
return false;
};
item.rect = rect.clamped(viewport);
true
}
/// Removes a floating placement without reopening it.
pub fn close_floating(&mut self, panel: PaneType) -> bool {
let previous = self.floating.len();
self.floating.retain(|item| item.panel != panel);
self.floating_drag = self.floating_drag.filter(|drag| drag.panel != panel);
self.floating_target = None;
self.floating.len() != previous
}
/// Moves a floating panel into an auto-hide rail without reopening PaneGrid content.
pub fn auto_hide_floating(&mut self, panel: PaneType, edge: DockEdge) -> bool {
let Some(index) = self.floating.iter().position(|item| item.panel == panel) else {
return false;
};
if !DockDropPolicy::accepts_placement(panel, DockPlacement::AutoHidden(edge)) {
return false;
}
let item = self.floating.remove(index);
self.auto_hidden[edge_index(edge)].push(panel);
self.floating_drag = None;
self.floating_target = None;
if !self.invariants_hold() {
self.auto_hidden[edge_index(edge)].retain(|candidate| *candidate != panel);
self.floating.insert(index, item);
return false;
}
true
}
/// Restores a unique floating tool beside Canvas on its right edge.
pub fn redock_floating(&mut self, panel: PaneType) -> bool {
let Some(index) = self.floating.iter().position(|item| item.panel == panel) else {
return false;
};
let item = self.floating.remove(index);
let Some(canvas) = self.pane_for_type(PaneType::Canvas) else {
self.floating.insert(index, item);
return false;
};
let Some((new_pane, _)) =
self.pane_grid
.split(iced::widget::pane_grid::Axis::Vertical, canvas, panel)
else {
self.floating.insert(index, item);
return false;
};
if !self.invariants_hold() {
self.pane_grid.close(new_pane);
self.floating.insert(index, item);
return false;
}
self.focus(new_pane);
self.floating_drag = None;
self.floating_target = None;
self.rebalance_structure();
true
}
/// Redocks a floating panel at the exact policy-approved preview target.
pub fn redock_floating_at(&mut self, panel: PaneType, target: Target) -> bool {
if !DockDropPolicy::accepts(panel, DockDropZone::from_target(target)) {
return false;
}
let Some(index) = self.floating.iter().position(|item| item.panel == panel) else {
return false;
};
let item = self.floating.remove(index);
let previous = self.pane_grid.clone();
let Some(canvas) = self.pane_for_type(PaneType::Canvas) else {
self.floating.insert(index, item);
return false;
};
let Some((new_pane, _)) = self.pane_grid.split(
iced::widget::pane_grid::Axis::Vertical,
canvas,
panel,
) else {
self.floating.insert(index, item);
return false;
};
self.pane_grid.drop(new_pane, target);
if !self.invariants_hold() {
self.pane_grid = previous;
self.floating.insert(index, item);
return false;
}
self.focused_pane = self.pane_for_type(panel);
self.floating_drag = None;
self.floating_target = None;
self.rebalance_structure();
true
}
/// Clamps all restored floating rectangles to the current logical viewport.
pub fn clamp_floating(&mut self, viewport: (f32, f32)) {
for item in &mut self.floating {
item.rect = item.rect.clamped(viewport);
}
}
/// Returns stable auto-hide placements for settings serialization.
pub fn persisted_auto_hidden(&self) -> Vec<PersistedAutoHiddenPanel> {
self.auto_hidden
.iter()
.enumerate()
.flat_map(|(index, panels)| {
panels.iter().map(move |panel| PersistedAutoHiddenPanel {
panel: *panel,
edge: index_edge(index),
})
})
.collect()
}
/// Returns whether a panel exists in any managed placement.
fn contains_anywhere(&self, panel: PaneType) -> bool {
self.is_pane_open(panel)
|| self
.auto_hidden
.iter()
.flatten()
.any(|candidate| *candidate == panel)
|| self.floating.iter().any(|item| item.panel == panel)
}
/// Infers the nearest workspace edge from normalized PaneGrid geometry.
fn infer_edge(&self, pane: Pane) -> DockEdge {
let regions = self
.pane_grid
.layout()
.pane_regions(4.0, iced::Size::new(1000.0, 1000.0));
let Some(rect) = regions.get(&pane) else {
return DockEdge::Right;
};
[
(rect.x, DockEdge::Left),
(1000.0 - rect.x - rect.width, DockEdge::Right),
(rect.y, DockEdge::Top),
(1000.0 - rect.y - rect.height, DockEdge::Bottom),
]
.into_iter()
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, edge)| edge)
.unwrap_or(DockEdge::Right)
}
/// Reopen a closed pane or focus it if it is already open.
pub fn reopen_pane(&mut self, pane_type: PaneType) {
if self
.auto_hidden
.iter()
.flatten()
.any(|panel| *panel == pane_type)
{
self.restore_auto_hidden(pane_type);
return;
}
let already_open = self.pane_grid.iter().find(|(_, t)| **t == pane_type);
if let Some((pane, _)) = already_open {
self.focus(*pane);
@@ -329,10 +838,53 @@ impl DockState {
};
if let Some((new_pane, _)) = self.pane_grid.split(axis, target, pane_type) {
self.focus(new_pane);
self.rebalance_structure();
}
}
}
/// Applies initial or window-resize policy for a newly measured PaneGrid extent.
///
/// **Arguments:** `grid_size` is the logical size after fixed application chrome is removed.
/// **Returns:** Nothing. **Logic & Workflow:** Built-in layouts receive InitialDefault once;
/// persisted layouts retain valid ratios and are only clamped. Later changes preserve utility
/// pixel extents and route growth toward Canvas. **Side Effects / Dependencies:** Updates split
/// ratios and records the size for future structure and resize operations.
pub fn set_grid_size(&mut self, grid_size: Size) {
if grid_size.width <= 0.0 || grid_size.height <= 0.0 {
return;
}
let previous = self.last_grid_size;
let mode = if previous.is_none() {
if self.apply_initial_defaults {
crate::dock::sizing::RebalanceMode::InitialDefault
} else {
crate::dock::sizing::RebalanceMode::UserResize
}
} else {
crate::dock::sizing::RebalanceMode::WindowResize
};
crate::dock::sizing::rebalance_state(&mut self.pane_grid, grid_size, previous, mode);
self.last_grid_size = Some(grid_size);
self.apply_initial_defaults = false;
}
/// Rebalances a changed topology when a measured grid extent is available.
///
/// **Arguments:** None. **Returns:** Nothing. **Logic & Workflow:** Newly created Iced splits
/// begin at 50/50 and are replaced with policy allocation; valid established ratios remain.
/// **Side Effects / Dependencies:** Mutates PaneGrid split ratios but performs no persistence.
fn rebalance_structure(&mut self) {
if let Some(size) = self.last_grid_size {
crate::dock::sizing::rebalance_state(
&mut self.pane_grid,
size,
None,
crate::dock::sizing::RebalanceMode::StructureChanged,
);
}
}
/// Check if a pane type is currently open in the dock.
pub fn is_pane_open(&self, pane_type: PaneType) -> bool {
self.pane_grid.iter().any(|(_, t)| *t == pane_type)
@@ -359,3 +911,23 @@ impl DockState {
}
}
}
/// Converts a stable edge into the auto-hide array index.
fn edge_index(edge: DockEdge) -> usize {
match edge {
DockEdge::Left => 0,
DockEdge::Right => 1,
DockEdge::Top => 2,
DockEdge::Bottom => 3,
}
}
/// Converts an auto-hide array index into its stable edge.
fn index_edge(index: usize) -> DockEdge {
match index {
0 => DockEdge::Left,
2 => DockEdge::Top,
3 => DockEdge::Bottom,
_ => DockEdge::Right,
}
}
@@ -10,7 +10,7 @@ use crate::dock::state::{DockState, PaneType};
use crate::panels::styles;
use crate::panels::typography::TITLE;
use crate::theme::ThemeColors;
use iced::widget::pane_grid::{Content, TitleBar};
use iced::widget::pane_grid::{Content, Controls, TitleBar};
use iced::widget::svg;
use iced::widget::{button, column, container, pane_grid, row, text};
use iced::{Element, Length};
@@ -25,209 +25,260 @@ pub fn dock_view<'a>(
app: &'a crate::app::HcieIcedApp,
) -> Element<'a, Message> {
let colors = app.theme_state.colors();
let custom_drag = dock.drag.is_some() || dock.floating_drag.is_some();
pane_grid::PaneGrid::new(&dock.pane_grid, |pane, pane_type, _is_maximized| {
// Canvas pane has no title bar (the document tab bar serves as its header).
// All other panes get a Photoshop-style title bar with close/maximize buttons.
let title: Option<TitleBar<'a, Message>> = if *pane_type == PaneType::Canvas {
None
} else {
Some(make_title_bar(pane, *pane_type, colors))
};
let body: Element<'a, Message> = match pane_type {
PaneType::Canvas => {
// Canvas pane includes document tab bar as its header
let doc_tab_bar = document_tab_bar(app, colors);
let canvas = {
let doc = &app.documents[app.active_doc];
let pressure = app
.tablet_state
.lock()
.map(|s| s.current_pressure())
.unwrap_or(1.0);
let ts = &app.settings.tool_settings;
crate::canvas::view(
doc,
&app.tool_state,
app.marching_ants_offset,
pressure,
ts.vector_points,
ts.vector_sides,
ts.vector_radius,
)
};
column![doc_tab_bar, canvas]
.width(Length::Fill)
.height(Length::Fill)
.into()
}
// Tools is no longer in the dock — toolbox is a fixed strip on the left
PaneType::Layers => {
let doc = &app.documents[app.active_doc];
crate::panels::layers::view(
&doc.cached_layers,
doc.engine.active_layer_id(),
&doc.engine,
let grid: Element<'a, Message> =
pane_grid::PaneGrid::new(&dock.pane_grid, |pane, pane_type, _is_maximized| {
// Canvas pane has no title bar (the document tab bar serves as its header).
// All other panes get a Photoshop-style title bar with close/maximize buttons.
let title: Option<TitleBar<'a, Message>> = if *pane_type == PaneType::Canvas {
None
} else {
Some(make_title_bar(
pane,
*pane_type,
dock.focused_pane == Some(pane),
colors,
)
))
};
let body = panel_body(*pane_type, app, colors);
let mut content = Content::new(body);
// Apply per-pane background style. Canvas pane gets a dark workspace
// background (no border); all other panes get panel background + border
// so they are visually separated from neighbors.
if *pane_type == PaneType::Canvas {
content = content.style(move |_theme| styles::canvas_pane_background(colors));
} else {
content = content.style(move |_theme| styles::pane_background(colors));
}
PaneType::History => {
if let Some(title_bar) = title {
content = content.title_bar(title_bar);
}
content
})
.width(Length::Fill)
.height(Length::Fill)
.spacing(4)
.on_click(Message::PaneClicked)
.on_drag(Message::PaneDragged)
.on_resize(4, Message::PaneResized)
.style(move |_theme| pane_grid::Style {
hovered_region: pane_grid::Highlight {
background: iced::Background::Color(iced::Color::from_rgba(
colors.accent.r,
colors.accent.g,
colors.accent.b,
if custom_drag { 0.0 } else { 0.15 },
)),
border: iced::Border::default()
.color(colors.accent)
.width(if custom_drag { 0 } else { 1 }),
},
picked_split: pane_grid::Line {
color: colors.accent,
width: 2.0,
},
hovered_split: pane_grid::Line {
color: colors.border_high,
width: 1.0,
},
})
.into();
let rail = crate::dock::auto_hide::right_rail(dock.auto_hidden[1].iter().copied(), colors);
let base: Element<'a, Message> = row![grid, rail]
.width(Length::Fill)
.height(Length::Fill)
.into();
let base: Element<'a, Message> =
if let Some(preview) = dock
.drag
.as_ref()
.and_then(|drag| drag.accepted_preview)
.or_else(|| dock.floating_target.map(|target| target.preview))
{
iced::widget::Stack::new()
.push(base)
.push(preview_overlay(preview, colors))
.width(Length::Fill)
.height(Length::Fill)
.into()
} else {
base
};
if let Some(panel) = dock.active_auto_hide {
let overlay =
crate::dock::auto_hide::overlay(panel, panel_body(panel, app, colors), colors);
iced::widget::Stack::new()
.push(base)
.push(overlay)
.width(Length::Fill)
.height(Length::Fill)
.into()
} else {
base
}
}
/// Builds reusable panel content for docked and temporary auto-hide placements.
///
/// Canvas remains exclusive to PaneGrid; callers must never request it for detached placement.
pub fn panel_body<'a>(
pane_type: PaneType,
app: &'a crate::app::HcieIcedApp,
colors: ThemeColors,
) -> Element<'a, Message> {
match &pane_type {
PaneType::Canvas => {
if app.show_welcome {
return crate::dock::welcome::view(colors);
}
// Canvas pane includes document tab bar as its header
let doc_tab_bar = document_tab_bar(app, colors);
let canvas = {
let doc = &app.documents[app.active_doc];
crate::panels::history::view(&doc.cached_history, doc.history_current, colors)
}
PaneType::Brushes => crate::panels::brushes::view(
app.tool_state.brush_style,
let pressure = app
.tablet_state
.lock()
.map(|s| s.current_pressure())
.unwrap_or(1.0);
let ts = &app.settings.tool_settings;
crate::canvas::view(
doc,
&app.tool_state,
app.marching_ants_offset,
pressure,
ts.vector_points,
ts.vector_sides,
ts.vector_radius,
)
};
column![doc_tab_bar, canvas]
.width(Length::Fill)
.height(Length::Fill)
.into()
}
// Tools is no longer in the dock — toolbox is a fixed strip on the left
PaneType::Layers => {
let doc = &app.documents[app.active_doc];
crate::panels::layers::view(
&doc.cached_layers,
doc.engine.active_layer_id(),
&doc.engine,
colors,
)
}
PaneType::History => {
let doc = &app.documents[app.active_doc];
crate::panels::history::view(&doc.cached_history, doc.history_current, colors)
}
PaneType::Brushes => crate::panels::brushes::view(
app.tool_state.brush_style,
app.tool_state.brush_size,
app.tool_state.brush_opacity,
app.tool_state.brush_hardness,
colors,
),
PaneType::Filters => crate::panels::filters::view(
app.selected_filter,
&app.filter_params,
app.filter_preview_active,
app.documents[app.active_doc]
.engine
.active_layer_is_editable(),
colors,
),
PaneType::ColorPicker => crate::color_picker::view(
&app.fg_color,
&app.bg_color,
&app.recent_colors,
app.color_tab,
),
PaneType::Properties => {
let doc = &app.documents[app.active_doc];
let active_id = doc.engine.active_layer_id();
let layer_info = doc.cached_layers.iter().find(|l| l.id == active_id);
let layer_name = layer_info.map(|l| l.name.as_str()).unwrap_or("");
let layer_opacity = layer_info.map(|l| l.opacity).unwrap_or(1.0);
let layer_visible = layer_info.map(|l| l.visible).unwrap_or(true);
let layer_blend_mode = layer_info
.map(|l| l.blend_mode)
.unwrap_or(hcie_engine_api::BlendMode::Normal);
let layer_type = layer_info
.map(|l| l.layer_type)
.unwrap_or(hcie_engine_api::LayerType::Raster);
let layer_width = layer_info.map(|l| l.width).unwrap_or(0);
let layer_height = layer_info.map(|l| l.height).unwrap_or(0);
let vector_shapes = doc.engine.active_vector_shapes().unwrap_or_default();
let ts2 = app.settings.tool_settings.clone();
crate::panels::properties::view(
&app.tool_state.active_tool,
app.tool_state.brush_size,
app.tool_state.brush_opacity,
app.tool_state.brush_hardness,
doc.selection_rect,
doc.vector_draw,
colors,
),
PaneType::Filters => crate::panels::filters::view(
app.selected_filter,
&app.filter_params,
app.filter_preview_active,
app.documents[app.active_doc]
.engine
.active_layer_is_editable(),
colors,
),
PaneType::ColorPicker => crate::color_picker::view(
&app.fg_color,
&app.bg_color,
&app.recent_colors,
app.color_tab,
),
PaneType::Properties => {
let doc = &app.documents[app.active_doc];
let active_id = doc.engine.active_layer_id();
let layer_info = doc.cached_layers.iter().find(|l| l.id == active_id);
let layer_name = layer_info.map(|l| l.name.as_str()).unwrap_or("");
let layer_opacity = layer_info.map(|l| l.opacity).unwrap_or(1.0);
let layer_visible = layer_info.map(|l| l.visible).unwrap_or(true);
let layer_blend_mode = layer_info
.map(|l| l.blend_mode)
.unwrap_or(hcie_engine_api::BlendMode::Normal);
let layer_type = layer_info
.map(|l| l.layer_type)
.unwrap_or(hcie_engine_api::LayerType::Raster);
let layer_width = layer_info.map(|l| l.width).unwrap_or(0);
let layer_height = layer_info.map(|l| l.height).unwrap_or(0);
let vector_shapes = doc.engine.active_vector_shapes().unwrap_or_default();
let ts2 = app.settings.tool_settings.clone();
crate::panels::properties::view(
&app.tool_state.active_tool,
app.tool_state.brush_size,
app.tool_state.brush_opacity,
app.tool_state.brush_hardness,
doc.selection_rect,
doc.vector_draw,
colors,
layer_name,
layer_opacity,
layer_visible,
layer_blend_mode,
layer_type,
layer_width,
layer_height,
doc.selected_vector_shape,
&vector_shapes,
ts2.vector_stroke_width,
ts2.vector_opacity,
ts2.vector_fill,
ts2.vector_radius,
ts2.vector_points,
ts2.vector_sides,
active_id,
app.fg_color,
app.bg_color,
)
}
PaneType::Script => crate::panels::script_panel::view(
&app.script_content,
app.script_error.as_deref(),
app.script_error_line,
app.script_is_running,
colors,
),
PaneType::AiChat => crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors),
PaneType::AiScript => crate::panels::ai_script_panel::view(app),
PaneType::LayerDetails => {
let doc = &app.documents[app.active_doc];
crate::panels::layer_details::view(
&doc.cached_layers,
doc.engine.active_layer_id(),
colors,
)
}
PaneType::Geometry => {
let shapes = app.documents[app.active_doc]
.engine
.active_vector_shapes()
.unwrap_or_default();
crate::panels::geometry::view(
shapes,
colors,
app.documents[app.active_doc].selected_vector_shape,
app.bool_shape_a,
app.bool_shape_b,
)
}
PaneType::ToolSettings => {
let fg = app.fg_color;
let draft = app.documents[app.active_doc].text_draft.as_ref();
crate::panels::tool_settings::view(
&app.tool_state,
&app.settings,
fg,
draft,
colors,
)
}
};
let mut content = Content::new(body);
// Apply per-pane background style. Canvas pane gets a dark workspace
// background (no border); all other panes get panel background + border
// so they are visually separated from neighbors.
if *pane_type == PaneType::Canvas {
content = content.style(move |_theme| styles::canvas_pane_background(colors));
} else {
content = content.style(move |_theme| styles::pane_background(colors));
layer_name,
layer_opacity,
layer_visible,
layer_blend_mode,
layer_type,
layer_width,
layer_height,
doc.selected_vector_shape,
&vector_shapes,
ts2.vector_stroke_width,
ts2.vector_opacity,
ts2.vector_fill,
ts2.vector_radius,
ts2.vector_points,
ts2.vector_sides,
active_id,
app.fg_color,
app.bg_color,
)
}
if let Some(title_bar) = title {
content = content.title_bar(title_bar);
PaneType::Script => crate::panels::script_panel::view(
&app.script_content,
app.script_error.as_deref(),
app.script_error_line,
app.script_is_running,
colors,
),
PaneType::AiChat => crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors),
PaneType::AiScript => crate::panels::ai_script_panel::view(app),
PaneType::LayerDetails => {
let doc = &app.documents[app.active_doc];
crate::panels::layer_details::view(
&doc.cached_layers,
doc.engine.active_layer_id(),
colors,
)
}
content
})
.width(Length::Fill)
.height(Length::Fill)
.spacing(4)
.on_click(Message::PaneClicked)
.on_drag(Message::PaneDragged)
.on_resize(4, Message::PaneResized)
.style(move |_theme| pane_grid::Style {
hovered_region: pane_grid::Highlight {
background: iced::Background::Color(iced::Color::from_rgba(
colors.accent.r,
colors.accent.g,
colors.accent.b,
0.15,
)),
border: iced::Border::default().color(colors.accent).width(1),
},
picked_split: pane_grid::Line {
color: colors.accent,
width: 2.0,
},
hovered_split: pane_grid::Line {
color: colors.border_high,
width: 1.0,
},
})
.into()
PaneType::Geometry => {
let shapes = app.documents[app.active_doc]
.engine
.active_vector_shapes()
.unwrap_or_default();
crate::panels::geometry::view(
shapes,
colors,
app.documents[app.active_doc].selected_vector_shape,
app.bool_shape_a,
app.bool_shape_b,
)
}
PaneType::ToolSettings => {
let fg = app.fg_color;
let draft = app.documents[app.active_doc].text_draft.as_ref();
crate::panels::tool_settings::view(&app.tool_state, &app.settings, fg, draft, colors)
}
}
}
/// Build the document tab bar.
@@ -396,37 +447,38 @@ fn pane_type_icon(pane_type: PaneType) -> Option<&'static str> {
}
}
/// Create a Photoshop-style title bar for a pane.
///
/// Shows a panel type icon + label with close and maximize buttons.
/// Uses `panel_header` style from the theme for consistent header appearance.
/// Buttons have hover feedback: transparent by default, bg_hover on hover.
/// Creates a compact VS-style title bar whose controls do not initiate pane dragging.
fn make_title_bar<'a>(
pane: pane_grid::Pane,
pane_type: PaneType,
focused: bool,
colors: ThemeColors,
) -> TitleBar<'a, Message> {
// Panel type icon
let icon_paths = [
std::path::PathBuf::from("hcie-iced-app/assets"),
std::path::PathBuf::from("/mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/assets"),
];
let title_content: Element<'_, Message> = if let Some(icon_file) = pane_type_icon(pane_type) {
if let Some(base) = icon_paths.iter().find(|p| p.join(icon_file).exists()) {
let handle = svg::Handle::from_path(base.join(icon_file));
let icon = svg(handle).width(14).height(14).style(
move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
color: Some(colors.text_secondary),
},
);
if let Some(base) = icon_paths.iter().find(|path| path.join(icon_file).exists()) {
let icon = svg(svg::Handle::from_path(base.join(icon_file)))
.width(14)
.height(14)
.style(move |_theme, _status| svg::Style {
color: Some(if focused {
colors.accent
} else {
colors.text_secondary
}),
});
row![
icon,
text(pane_type.label())
.size(TITLE)
.color(colors.text_secondary)
text(pane_type.label()).size(TITLE).color(if focused {
colors.text_primary
} else {
colors.text_secondary
})
]
.spacing(4)
.spacing(6)
.align_y(iced::Alignment::Center)
.into()
} else {
@@ -442,73 +494,180 @@ fn make_title_bar<'a>(
.into()
};
// Close button with hover state
let close_btn = button(text("×").size(11))
.on_press(Message::PaneClose(pane))
.padding([2, 6])
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
iced::widget::button::Status::Hovered => iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_hover)),
text_color: colors.text_primary,
border: iced::Border::default(),
..Default::default()
},
iced::widget::button::Status::Pressed => iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_active)),
text_color: colors.text_primary,
border: iced::Border::default(),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
text_color: colors.text_secondary,
border: iced::Border::default(),
..Default::default()
},
},
);
// Maximize button with hover state
let maximize_btn = button(text("").size(11))
.on_press(Message::PaneMaximize(pane))
.padding([2, 6])
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
iced::widget::button::Status::Hovered => iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_hover)),
text_color: colors.text_primary,
border: iced::Border::default(),
..Default::default()
},
iced::widget::button::Status::Pressed => iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_active)),
text_color: colors.text_primary,
border: iced::Border::default(),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
text_color: colors.text_secondary,
border: iced::Border::default(),
..Default::default()
},
},
);
let control = |label, message| {
button(text(label).size(12))
.on_press(message)
.padding([3, 6])
.style(move |_theme, status| chrome_button_style(colors, status))
};
let pin = iced::widget::tooltip(
control("", Message::PaneAutoHide(pane)),
text("Minimize to side rail").size(11),
iced::widget::tooltip::Position::Bottom,
);
let float_control = iced::widget::tooltip(
control("", Message::PaneFloat(pane)),
text("Float panel").size(11),
iced::widget::tooltip::Position::Bottom,
);
let maximize = iced::widget::tooltip(
control("", Message::PaneMaximize(pane)),
text("Maximize / restore").size(11),
iced::widget::tooltip::Position::Bottom,
);
let close = iced::widget::tooltip(
control("×", Message::PaneClose(pane)),
text("Close panel").size(11),
iced::widget::tooltip::Position::Bottom,
);
let compact_control = |label, message| {
button(text(label).size(11))
.on_press(message)
.padding([3, 3])
.style(move |_theme, status| chrome_button_style(colors, status))
};
let compact_pin = iced::widget::tooltip(
compact_control("", Message::PaneAutoHide(pane)),
text("Minimize to side rail").size(11),
iced::widget::tooltip::Position::Bottom,
);
let compact_float = iced::widget::tooltip(
compact_control("", Message::PaneFloat(pane)),
text("Float panel").size(11),
iced::widget::tooltip::Position::Bottom,
);
let compact_maximize = iced::widget::tooltip(
compact_control("", Message::PaneMaximize(pane)),
text("Maximize / restore").size(11),
iced::widget::tooltip::Position::Bottom,
);
let compact_close = iced::widget::tooltip(
compact_control("×", Message::PaneClose(pane)),
text("Close panel").size(11),
iced::widget::tooltip::Position::Bottom,
);
let full_controls = row![pin, float_control, maximize, close]
.spacing(1)
.align_y(iced::Alignment::Center)
.height(28);
let compact_controls = row![compact_pin, compact_float, compact_maximize, compact_close]
.spacing(1)
.align_y(iced::Alignment::Center)
.height(28);
let title_drag = iced::widget::mouse_area(title_content)
.on_press(Message::DockHeaderDragStart(pane))
.interaction(iced::mouse::Interaction::Grab);
let header_colors = colors;
TitleBar::new(
row![
title_content,
iced::widget::Space::with_width(Length::Fill),
maximize_btn,
close_btn
]
.spacing(2)
.align_y(iced::Alignment::Center),
row![title_drag]
.spacing(1)
.align_y(iced::Alignment::Center)
.height(28),
)
.style(move |_theme| styles::panel_header(colors))
.controls(Controls::dynamic(full_controls, compact_controls))
.always_show_controls()
.padding([0, 6])
.style(move |_theme| styles::modern_panel_header(header_colors, focused))
}
/// Shared hover and pressed style for dock chrome and auto-hide controls.
pub fn chrome_button_style(
colors: ThemeColors,
status: iced::widget::button::Status,
) -> iced::widget::button::Style {
let background = match status {
iced::widget::button::Status::Hovered => Some(iced::Background::Color(colors.bg_hover)),
iced::widget::button::Status::Pressed => Some(iced::Background::Color(colors.bg_active)),
_ => None,
};
iced::widget::button::Style {
background,
text_color: colors.text_secondary,
border: iced::Border {
radius: 2.0.into(),
..Default::default()
},
..Default::default()
}
}
/// Renders a translucent accepted rectangle and compact edge compass without capturing events.
fn preview_overlay<'a>(rect: iced::Rectangle, colors: ThemeColors) -> Element<'a, Message> {
let fill = container(text(""))
.width(rect.width)
.height(rect.height)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
colors.accent.r,
colors.accent.g,
colors.accent.b,
0.22,
))),
border: iced::Border::default().color(colors.accent).width(2),
..Default::default()
});
let marker = |glyph, active| {
container(text(glyph).size(12).color(if active {
colors.text_primary
} else {
colors.text_disabled
}))
.center_x(24)
.center_y(24)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(if active {
colors.accent
} else {
colors.bg_app
})),
border: iced::Border::default().color(colors.border_high).width(1),
..Default::default()
})
};
let compass = column![
row![
iced::widget::Space::with_width(24),
marker("", true),
iced::widget::Space::with_width(24)
],
row![marker("", true), marker("×", false), marker("", true)],
row![
iced::widget::Space::with_width(24),
marker("", true),
iced::widget::Space::with_width(24)
],
]
.spacing(1);
let compass_x = (rect.center_x() - 36.0).max(0.0);
let compass_y = (rect.center_y() - 36.0).max(0.0);
iced::widget::Stack::new()
.push(column![
iced::widget::Space::with_height(rect.y),
row![iced::widget::Space::with_width(rect.x), fill]
])
.push(column![
iced::widget::Space::with_height(compass_y),
row![iced::widget::Space::with_width(compass_x), compass]
])
.width(Length::Fill)
.height(Length::Fill)
.into()
}
#[cfg(test)]
mod tests {
/// Guards the Iced drag contract: title and controls must be distinct widget regions.
#[test]
fn title_bar_uses_explicit_drag_handle_and_separate_controls() {
let source = include_str!("view.rs");
let start = source.find("fn make_title_bar").unwrap();
let end = source[start..].find("pub fn chrome_button_style").unwrap() + start;
let title_source = &source[start..end];
assert!(title_source.contains("Controls::dynamic"));
assert!(title_source.contains(".controls("));
assert!(title_source.contains(".always_show_controls()"));
assert!(title_source.contains("mouse_area(title_content)"));
assert!(title_source.contains("DockHeaderDragStart(pane)"));
assert!(!title_source.contains("Space::with_width(Length::Fill)"));
}
}
@@ -0,0 +1,63 @@
//! Center-document welcome surface shown when no user document remains open.
//!
//! **Purpose:** Keeps the editor shell and dock tools available after the last document closes.
//! **Logic & Workflow:** Presents primary New and Open actions inside the unique Canvas host while
//! a private placeholder engine keeps existing rendering assumptions safe.
//! **Side Effects / Dependencies:** Buttons emit ordinary application messages; no I/O occurs here.
use crate::app::{ActiveDialog, Message};
use crate::theme::ThemeColors;
use iced::widget::{button, column, container, row, text};
use iced::{Element, Length};
/// Builds the responsive welcome card for the center document host.
///
/// **Arguments:** `colors` supplies the active theme palette.
/// **Returns:** A centered full-size element with New Image and Open Image actions.
/// **Side Effects / Dependencies:** None until a button emits its message.
pub fn view(colors: ThemeColors) -> Element<'static, Message> {
let action = move |label: &'static str, message| {
button(text(label).size(13))
.on_press(message)
.padding([9, 18])
.style(move |_theme, status| crate::dock::view::chrome_button_style(colors, status))
};
let card = container(
column![
text("HCIE").size(30).color(colors.text_primary),
text("Create a new image or open an existing project")
.size(13)
.color(colors.text_secondary),
row![
action(
"New Image",
Message::DialogOpen(ActiveDialog::NewImage)
),
action("Open Image", Message::OpenFileRfd),
]
.spacing(10),
]
.spacing(16)
.align_x(iced::Alignment::Center),
)
.padding(28)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default()
.color(colors.border_high)
.width(1)
.rounded(8),
shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.28),
offset: iced::Vector::new(0.0, 6.0),
blur_radius: 20.0,
},
..Default::default()
});
container(card)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
}
@@ -449,44 +449,48 @@ pub fn view<'a>(
}
// ── Bottom toolbar ─────────────────────────────────────
let add_btn = button(text("+Lay").size(10))
let add_btn = button(text("+Layer").size(BODY))
.on_press(Message::LayerAdd)
.padding([3, 6])
.padding([5, 8])
.style(move |_theme, _status| flat_btn_style(colors));
let group_btn = button(text("+Grp").size(10))
let group_btn = button(text("+Group").size(BODY))
.on_press(Message::LayerAddGroup)
.padding([3, 6])
.padding([5, 8])
.style(move |_theme, _status| flat_btn_style(colors));
let duplicate_btn = button(text("Dup").size(9)).padding([3, 6]);
let mask_btn = button(text("Mask").size(9)).padding([3, 6]);
let rasterize_btn = button(text("Raster").size(9)).padding([3, 6]);
let flatten_btn = button(text("Flatten").size(10))
let duplicate_btn = button(text("Duplicate").size(BODY)).padding([5, 8]);
let mask_btn = button(text("Mask").size(BODY)).padding([5, 8]);
let rasterize_btn = button(text("Rasterize").size(BODY)).padding([5, 8]);
let flatten_btn = button(text("Flatten").size(BODY))
.on_press(Message::LayerFlatten)
.padding([3, 6])
.padding([5, 8])
.style(move |_theme, _status| flat_btn_style(colors));
let del_btn = button(text("\u{2716}").size(10))
let del_btn = button(text("\u{2716}").size(BODY))
.on_press(Message::LayerDelete(active_layer_id))
.padding([3, 6])
.style(move |_theme, _status| flat_btn_style(colors));
let move_up_btn = button(text("\u{25B2}").size(10))
let move_up_btn = button(text("\u{25B2}").size(BODY))
.on_press(Message::LayerMoveUp(active_layer_id))
.padding([3, 4])
.style(move |_theme, _status| flat_btn_style(colors));
let move_down_btn = button(text("\u{25BC}").size(10))
let move_down_btn = button(text("\u{25BC}").size(BODY))
.on_press(Message::LayerMoveDown(active_layer_id))
.padding([3, 4])
.style(move |_theme, _status| flat_btn_style(colors));
let fx_btn = button(text("fx").size(10))
let fx_btn = button(text("fx").size(BODY))
.on_press(Message::OpenLayerStyleDialog)
.padding([3, 6])
.style(move |_theme, _status| flat_btn_style(colors));
let toolbar = column![
text("Disabled (engine API): duplicate / mask / rasterize").size(8),
row![duplicate_btn, mask_btn, rasterize_btn]
text("Unavailable: duplicate, mask, rasterize").size(BODY),
row![duplicate_btn, mask_btn]
.spacing(2)
.align_y(iced::Alignment::Center),
row![add_btn, group_btn, flatten_btn, fx_btn]
row![rasterize_btn].align_y(iced::Alignment::Center),
row![add_btn, group_btn]
.spacing(2)
.align_y(iced::Alignment::Center),
row![flatten_btn, fx_btn]
.spacing(2)
.align_y(iced::Alignment::Center),
row![move_up_btn, move_down_btn, del_btn]
@@ -154,3 +154,24 @@ pub fn inactive_tab_background(colors: ThemeColors) -> iced::widget::container::
..Default::default()
}
}
/// Modern compact pane header with a focused accent separator.
pub fn modern_panel_header(colors: ThemeColors, focused: bool) -> iced::widget::container::Style {
iced::widget::container::Style {
background: Some(iced::Background::Color(if focused {
colors.bg_hover
} else {
colors.bg_active
})),
border: iced::Border {
color: if focused {
colors.accent
} else {
colors.border_low
},
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
}
}
@@ -3,6 +3,7 @@
//! **Purpose:** Persists tool settings, panel layout, and preferences between sessions.
//! Settings are saved to `~/.hcie/settings.json` and loaded on startup.
use crate::dock::persistence::PersistedDockLayout;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
@@ -143,6 +144,9 @@ pub struct PanelLayout {
pub visible_panels: Vec<String>,
/// Panel order in the dock.
pub panel_order: Vec<String>,
/// Stable dock tree plus auto-hidden and floating placements.
#[serde(default)]
pub dock_layout: PersistedDockLayout,
}
/// Window settings.
@@ -223,6 +227,7 @@ impl Default for PanelLayout {
"History".to_string(),
"Filters".to_string(),
],
dock_layout: PersistedDockLayout::default(),
}
}
}
@@ -237,6 +242,30 @@ impl Default for WindowSettings {
}
}
impl PanelLayout {
/// Captures current dock placement without runtime Pane IDs.
pub fn persist_current_placement(&mut self, dock: &crate::dock::state::DockState) {
self.dock_layout = PersistedDockLayout::capture(dock);
self.visible_panels = dock
.pane_grid
.iter()
.map(|(_, panel)| panel.label().to_string())
.collect();
}
/// Restores current stable dock placement, preserving legacy defaults when absent.
pub fn restore_dock(&self) -> crate::dock::state::DockState {
if self.dock_layout.tree.is_none()
&& self.dock_layout.auto_hidden.is_empty()
&& self.dock_layout.floating.is_empty()
{
crate::dock::state::DockState::new()
} else {
self.dock_layout.restore()
}
}
}
impl AppSettings {
/// Get the settings file path (~/.hcie/settings.json).
pub fn file_path() -> PathBuf {
@@ -322,6 +351,11 @@ mod tests {
.and_then(serde_json::Value::as_object_mut)
.expect("panel layout object")
.remove("sidebar_expanded");
object
.get_mut("panel_layout")
.and_then(serde_json::Value::as_object_mut)
.expect("panel layout object")
.remove("dock_layout");
let settings: AppSettings =
serde_json::from_value(legacy).expect("deserialize legacy settings");
@@ -166,9 +166,14 @@ fn screenshot_cli_uses_native_capture_and_panel_crop() {
fn cycle_one_state_paths_are_complete_and_persisted() {
let app = source("src/app.rs");
let settings = source("src/settings.rs");
let manager = source("src/dock/manager.rs");
assert!(
app.contains("self.dock.pane_grid.drop(pane, target)"),
app.contains("self.dock.apply_drop(pane, target)")
&& manager.contains("DockDropZone::Center")
&& manager.contains("DockDropZone::OuterEdge(_)")
&& manager.contains("DockDropZone::PaneEdge(_)")
&& manager.contains("Self::role(source) == DockRole::Tool"),
"{PANE_FULL_DROP_TARGETS}"
);
assert!(
@@ -257,10 +262,10 @@ fn cycle_four_panel_and_document_paths_are_connected() {
);
assert!(
layers.contains("Fill opacity: unavailable in hcie-engine-api")
&& layers.contains("Disabled (engine API): duplicate / mask / rasterize")
&& layers.contains("button(text(\"Dup\")")
&& layers.contains("Unavailable: duplicate, mask, rasterize")
&& layers.contains("button(text(\"Duplicate\")")
&& layers.contains("button(text(\"Mask\")")
&& layers.contains("button(text(\"Raster\")"),
&& layers.contains("button(text(\"Rasterize\")"),
"{LAYERS_HIERARCHY_CONTROLS}: unsupported operations must be visibly disabled"
);
assert!(
@@ -133,16 +133,14 @@ impl AiChatPanel {
});
let bubble = container(
column![
text(label).size(10).font(iced::Font::MONOSPACE),
msg_text,
]
.spacing(2)
column![text(label).size(10).font(iced::Font::MONOSPACE), msg_text,].spacing(2),
)
.width(Length::Fill)
.padding(8)
.style(|_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb(0.25, 0.25, 0.25))),
background: Some(iced::Background::Color(iced::Color::from_rgb(
0.25, 0.25, 0.25,
))),
border: iced::Border::default().rounded(6),
..Default::default()
});
@@ -183,8 +181,12 @@ impl AiChatPanel {
.width(Length::Fill)
.height(Length::Fill)
.style(|_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb(0.18, 0.18, 0.18))),
border: iced::Border::default().color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1),
background: Some(iced::Background::Color(iced::Color::from_rgb(
0.18, 0.18, 0.18,
))),
border: iced::Border::default()
.color(iced::Color::from_rgb(0.3, 0.3, 0.3))
.width(1),
..Default::default()
})
.into()
@@ -115,8 +115,12 @@ impl ScriptPanel {
.width(Length::Fill)
.height(Length::Fill)
.style(|_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb(0.18, 0.18, 0.18))),
border: iced::Border::default().color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1),
background: Some(iced::Background::Color(iced::Color::from_rgb(
0.18, 0.18, 0.18,
))),
border: iced::Border::default()
.color(iced::Color::from_rgb(0.3, 0.3, 0.3))
.width(1),
..Default::default()
})
.into()
@@ -139,7 +143,11 @@ fn execute_dsl_script(engine: &mut Engine, script: &str) -> Result<String, Strin
match cmd.as_str() {
"layer" => {
let name = if args.is_empty() { "Script Layer" } else { args };
let name = if args.is_empty() {
"Script Layer"
} else {
args
};
engine.add_layer(name);
lines_executed += 1;
}
@@ -264,7 +272,10 @@ fn parse_color(s: &str) -> Option<[u8; 4]> {
parts[1].trim().parse::<u8>(),
parts[2].trim().parse::<u8>(),
) {
let a = parts.get(3).and_then(|a| a.trim().parse::<u8>().ok()).unwrap_or(255);
let a = parts
.get(3)
.and_then(|a| a.trim().parse::<u8>().ok())
.unwrap_or(255);
return Some([r, g, b, a]);
}
}