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:
@@ -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, ®ions,
|
||||
)
|
||||
.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, ®ions,
|
||||
)
|
||||
.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),
|
||||
|
||||
@@ -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,
|
||||
®ions
|
||||
)
|
||||
.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,
|
||||
®ions,
|
||||
)
|
||||
.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,
|
||||
®ions,
|
||||
)
|
||||
.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]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user