436 lines
19 KiB
Rust
436 lines
19 KiB
Rust
//! 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 iced::widget::svg;
|
||
use crate::theme::ThemeColors;
|
||
use iced::widget::pane_grid::{Content, TitleBar};
|
||
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();
|
||
|
||
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, 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, 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,
|
||
)
|
||
}
|
||
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));
|
||
}
|
||
|
||
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, 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()
|
||
}
|
||
|
||
/// 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,
|
||
button(text("×").size(10))
|
||
.on_press(Message::NoOp)
|
||
.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()
|
||
},
|
||
}
|
||
}),
|
||
]
|
||
.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"),
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
fn make_title_bar<'a>(pane: pane_grid::Pane, pane_type: PaneType, 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),
|
||
});
|
||
row![icon, text(pane_type.label()).size(11).color(colors.text_secondary)]
|
||
.spacing(4)
|
||
.align_y(iced::Alignment::Center)
|
||
.into()
|
||
} else {
|
||
text(pane_type.label()).size(11).color(colors.text_secondary).into()
|
||
}
|
||
} else {
|
||
text(pane_type.label()).size(11).color(colors.text_secondary).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()
|
||
},
|
||
}
|
||
});
|
||
|
||
TitleBar::new(
|
||
row![title_content, iced::widget::Space::with_width(Length::Fill), maximize_btn, close_btn]
|
||
.spacing(2)
|
||
.align_y(iced::Alignment::Center)
|
||
)
|
||
.style(move |_theme| styles::panel_header(colors))
|
||
}
|