Files
hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs
T
phantom b09795ccff 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.
2026-07-16 22:10:22 +03:00

674 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Dock view — renders the PaneGrid with panel content.
//!
//! Each pane type maps to its corresponding panel view function.
//! The canvas pane renders the main document viewport with a document tab bar.
//! Utility panes render their respective panel content.
//! All panels receive ThemeColors for consistent styling.
use crate::app::Message;
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, Controls, TitleBar};
use iced::widget::svg;
use iced::widget::{button, column, container, pane_grid, row, text};
use iced::{Element, Length};
/// Build the dock view element.
///
/// Renders a PaneGrid where each pane displays the appropriate panel content
/// based on its PaneType. Title bars show the pane name with close/maximize buttons.
/// The canvas pane includes a document tab bar for multi-document support.
pub fn dock_view<'a>(
dock: &'a DockState,
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();
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));
}
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];
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,
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)
}
}
}
/// Build the document tab bar.
///
/// Shows a horizontal tab for each open document. The active document tab
/// is highlighted. Each tab shows the document name and a close button.
/// Modified documents show an asterisk (*). Tabs have hover feedback.
fn document_tab_bar<'a>(
app: &'a crate::app::HcieIcedApp,
colors: ThemeColors,
) -> Element<'a, Message> {
let mut tabs = row![].spacing(0);
for (idx, doc) in app.documents.iter().enumerate() {
let is_active = idx == app.active_doc;
let modified_indicator = if doc.modified { " *" } else { "" };
let label = format!("{}{}", doc.name, modified_indicator);
let tab_text = text(label).size(11);
let tab_text = if is_active {
tab_text.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.accent),
})
} else {
tab_text.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_primary),
})
};
let active = is_active;
let c = colors;
let tab = button(
row![
tab_text,
iced::widget::tooltip(
button(text("×").size(10))
.on_press(Message::DocClose(idx))
.padding([0, 4])
.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(c.bg_active)),
text_color: c.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: c.text_secondary,
border: iced::Border::default(),
..Default::default()
},
}
}
),
text(format!("Close {}", doc.name)).size(11),
iced::widget::tooltip::Position::Bottom,
),
]
.spacing(4)
.align_y(iced::Alignment::Center),
)
.on_press(Message::DocSwitch(idx))
.padding([4, 8])
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
iced::widget::button::Status::Active => {
if active {
iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_active)),
border: iced::Border::default().color(c.accent).width(1),
text_color: c.accent,
..Default::default()
}
} else {
iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_panel)),
border: iced::Border::default().color(c.border_low).width(1),
text_color: c.text_primary,
..Default::default()
}
}
}
iced::widget::button::Status::Hovered => {
if active {
iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_active)),
border: iced::Border::default().color(c.accent).width(1),
text_color: c.accent,
..Default::default()
}
} else {
iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_hover)),
border: iced::Border::default().color(c.border_high).width(1),
text_color: c.text_primary,
..Default::default()
}
}
}
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_active)),
border: iced::Border::default().color(c.accent).width(1),
text_color: c.accent,
..Default::default()
},
},
);
tabs = tabs.push(tab);
}
// Add new document button with hover
let new_btn = button(text("+").size(12))
.on_press(Message::DialogOpen(crate::app::ActiveDialog::NewImage))
.padding([4, 8])
.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::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()
},
},
);
tabs = tabs.push(new_btn);
container(tabs)
.width(Length::Fill)
.style(move |_theme| styles::tabbar_background(colors))
.into()
}
/// Map pane type to its SVG icon path.
fn pane_type_icon(pane_type: PaneType) -> Option<&'static str> {
match pane_type {
PaneType::Canvas => None,
PaneType::Layers => Some("icons/panel-layers.svg"),
PaneType::History => Some("icons/panel-history.svg"),
PaneType::Brushes => Some("icons/panel-brush.svg"),
PaneType::Filters => Some("icons/panel-filters.svg"),
PaneType::ColorPicker => Some("icons/panel-colorbox.svg"),
PaneType::Properties => Some("icons/panel-settings.svg"),
PaneType::Script => Some("icons/panel-pen.svg"),
PaneType::AiChat => Some("icons/panel-ai.svg"),
PaneType::LayerDetails => Some("icons/panel-layers.svg"),
PaneType::Geometry => Some("icons/vector_rect.svg"),
PaneType::ToolSettings => Some("icons/panel-settings.svg"),
PaneType::AiScript => Some("icons/panel-ai.svg"),
}
}
/// 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> {
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(|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(if focused {
colors.text_primary
} else {
colors.text_secondary
})
]
.spacing(6)
.align_y(iced::Alignment::Center)
.into()
} else {
text(pane_type.label())
.size(TITLE)
.color(colors.text_secondary)
.into()
}
} else {
text(pane_type.label())
.size(TITLE)
.color(colors.text_secondary)
.into()
};
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_drag]
.spacing(1)
.align_y(iced::Alignment::Center)
.height(28),
)
.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)"));
}
}