//! 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> = 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)")); } }