Dock: flush title bars and a vertical title bar for floating panels

Every docked panel now shows its title bar flush along the top, like the
Properties panel: dock::frame takes the title bar separately and pads
only the content below it.

A floating panel drops its horizontal title bar for a vertical one on
the side facing the nearer workspace edge. Close and the auto-hide pin
sit at the top; the panel's icon sits at the bottom with the title
reading upward above it. The free part of the bar drags the panel. With
auto-hide on, a floating panel shrinks to its title bar until hovered.

Panel views take a dock::Chrome (auto-collapse plus floating) in place
of the bare auto-collapse flag, so the shared title bar knows when to
step aside.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt
This commit is contained in:
Claude committed 2026-10-09 10:11:02 +00:00
1 parent 089f572373
commit 03763ff197
10 files changed
+271 -81

No files matched your search

+77 -7
View File
@@ -80,6 +80,20 @@ impl PanelId {
}
}
/// The panel's icon: the one on the ribbon tool that opens it.
pub fn icon(self) -> &'static [u8] {
match self {
PanelId::Properties => include_bytes!("../../assets/icons/properties.svg"),
PanelId::BlockPalette => include_bytes!("../../assets/icons/blocks/insert.svg"),
PanelId::ExternalReferences => crate::ui::icons::FOLDER_OPEN,
PanelId::Browser => include_bytes!("../../assets/icons/content_browser.svg"),
PanelId::NodeGraph => crate::ui::icons::NODE_GRAPH,
PanelId::PointCloudManager => include_bytes!("../../assets/icons/pc_attach.svg"),
PanelId::Count => include_bytes!("../../assets/icons/data_extract.svg"),
PanelId::SheetSetManager => include_bytes!("../../assets/icons/sheetset.svg"),
}
}
/// Default dock width for a freshly-created panel instance.
fn default_width(self) -> f32 {
match self {
@@ -559,9 +573,25 @@ use crate::app::Message;
use iced::widget::{button, container, mouse_area, row, text, tooltip, Space};
use iced::{Background, Border, Color, Element, Length, Theme};
/// How a panel is framed where it is shown, passed to its view so the shared
/// chrome can adapt.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Chrome {
/// The panel auto-collapses (its pin is on).
pub auto_collapse: bool,
/// The panel floats; its title lives in the vertical side bar drawn by
/// the floating frame instead of the horizontal title bar.
pub floating: bool,
}
/// The row every docked panel starts with: its title, the auto-collapse pin
/// and close. Pressing the row starts re-docking the panel.
pub fn title_bar<'a>(id: PanelId, title: String, auto_collapse: bool) -> Element<'a, Message> {
/// and close. Pressing the row starts re-docking the panel. A floating panel
/// gets nothing here: its frame draws a vertical title bar beside it.
pub fn title_bar<'a>(id: PanelId, title: String, chrome: Chrome) -> Element<'a, Message> {
if chrome.floating {
return Space::new().width(0).height(0).into();
}
let auto_collapse = chrome.auto_collapse;
let pin_icon = if auto_collapse {
crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0)
} else {
@@ -639,11 +669,18 @@ pub fn tool_button<'a>(
.into()
}
/// A docked panel's body: fixed width, full height, padded, on the base
/// background with a neutral edge.
pub fn frame<'a>(content: impl Into<Element<'a, Message>>, width: f32) -> Element<'a, Message> {
container(content)
.padding(6)
/// A docked panel's body: its title bar flush along the top (as on the
/// Properties panel), then the padded content; fixed width, full height, on
/// the base background with a neutral edge.
pub fn frame<'a>(
title_bar: Element<'a, Message>,
content: impl Into<Element<'a, Message>>,
width: f32,
) -> Element<'a, Message> {
container(iced::widget::column![
title_bar,
container(content).padding(6).height(Length::Fill)
])
.width(Length::Fixed(width))
.height(Length::Fill)
.style(|theme: &Theme| container::Style {
@@ -910,6 +947,39 @@ mod tests {
assert_eq!(state.right.len(), 1);
}
#[test]
fn every_panel_has_its_own_icon() {
let ids = [
PanelId::Properties,
PanelId::BlockPalette,
PanelId::ExternalReferences,
PanelId::Browser,
PanelId::NodeGraph,
PanelId::PointCloudManager,
PanelId::Count,
PanelId::SheetSetManager,
];
for (i, a) in ids.iter().enumerate() {
assert!(a.icon().starts_with(b"<svg"), "{a:?} icon is not an SVG");
for b in &ids[i + 1..] {
assert_ne!(a.icon(), b.icon(), "{a:?} and {b:?} share an icon");
}
}
}
#[test]
fn floating_title_bar_replaces_the_horizontal_one() {
let floating = Chrome {
auto_collapse: false,
floating: true,
};
let bar: Element<'_, Message> = title_bar(PanelId::Count, "Count".into(), floating);
assert_eq!(
bar.as_widget().size(),
iced::Size::new(Length::Fixed(0.0), Length::Fixed(0.0))
);
}
#[test]
fn slot_zone_splits_quarters() {
assert_eq!(slot_zone(10.0, 0.0, 400.0), SlotZone::Above);
+3 -7
View File
@@ -584,7 +584,7 @@ impl Graph {
/// The node library as a docked panel: title bar, a search box with the
/// graph's New / Open / Save beside it, then the categories. Entries are
/// clicked or dragged onto the canvas.
pub fn panel(&self, width: f32, auto_collapse: bool) -> Element<'_, Message> {
pub fn panel(&self, width: f32, chrome: crate::ui::dock::Chrome) -> Element<'_, Message> {
use crate::ui::dock::{self, PanelId};
let tool = |icon: &'static [u8], tip: &str, message: GraphMsg| {
dock::tool_button(
@@ -663,12 +663,8 @@ impl Graph {
.height(Length::Fill);
dock::frame(
column![
dock::title_bar(PanelId::NodeGraph, t!("Node Graph").into_owned(), auto_collapse),
toolbar,
body
]
.spacing(6),
dock::title_bar(PanelId::NodeGraph, t!("Node Graph").into_owned(), chrome),
column![toolbar, body].spacing(6),
width,
)
}
+2 -2
View File
@@ -542,11 +542,11 @@ impl PropertiesPanel {
.unwrap_or_default()
}
pub fn view(&self, width: f32, auto_collapse: bool) -> Element<'_, Message> {
pub fn view(&self, width: f32, chrome: crate::ui::dock::Chrome) -> Element<'_, Message> {
use crate::ui::dock::PanelId;
// ── Header ──────────────────────────────────────────────────────────
let header =
crate::ui::dock::title_bar(PanelId::Properties, t!("Properties").into_owned(), auto_collapse);
crate::ui::dock::title_bar(PanelId::Properties, t!("Properties").into_owned(), chrome);
// ── Title bar (entity type / "No Selection") ─────────────────────
let title_content: Element<'_, Message> = if self.selection_groups.is_empty() {
+3 -3
View File
@@ -659,8 +659,8 @@ fn options_card<'a>(palette: &'a BlockPalette) -> Element<'a, Message> {
}
/// Build the docked panel element from the palette state.
pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element<'_, Message> {
let title_bar = crate::ui::dock::title_bar(PanelId::BlockPalette, crate::t!("Blocks").into_owned(), auto_collapse);
pub fn view(palette: &BlockPalette, width: f32, chrome: crate::ui::dock::Chrome) -> Element<'_, Message> {
let title_bar = crate::ui::dock::title_bar(PanelId::BlockPalette, crate::t!("Blocks").into_owned(), chrome);
let tabs = {
let buttons = [Tab::Current, Tab::Recent, Tab::Favorites, Tab::Libraries].map(|t| {
@@ -801,7 +801,7 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element<
.into()
};
crate::ui::dock::frame(column![title_bar, top, body, options_card(palette)].spacing(6), width)
crate::ui::dock::frame(title_bar, column![top, body, options_card(palette)].spacing(6), width)
}
/// Width of the ribbon's block gallery.
+3 -3
View File
@@ -97,10 +97,10 @@ pub fn view<'a>(
document: &'a CadDocument,
open_sketch: Option<&'a str>,
width: f32,
auto_collapse: bool,
chrome: crate::ui::dock::Chrome,
) -> Element<'a, Message> {
let title_bar =
crate::ui::dock::title_bar(PanelId::Browser, crate::t!("Browser").into_owned(), auto_collapse);
crate::ui::dock::title_bar(PanelId::Browser, crate::t!("Browser").into_owned(), chrome);
let mut tree = column![].spacing(1);
@@ -143,5 +143,5 @@ pub fn view<'a>(
.width(Fill)
.height(Fill);
crate::ui::dock::frame(column![title_bar, body].spacing(6), width)
crate::ui::dock::frame(title_bar, column![body].spacing(6), width)
}
+3 -3
View File
@@ -530,9 +530,9 @@ pub fn view<'a>(
doc_id: u64,
epoch: u64,
width: f32,
auto_collapse: bool,
chrome: crate::ui::dock::Chrome,
) -> Element<'a, Message> {
let title_bar = crate::ui::dock::title_bar(PanelId::Count, crate::t!("Count").into_owned(), auto_collapse);
let title_bar = crate::ui::dock::title_bar(PanelId::Count, crate::t!("Count").into_owned(), chrome);
let body = match mode {
Some(mode) if mode.target.is_some() => mode_view(palette, mode, doc),
_ => {
@@ -550,7 +550,7 @@ pub fn view<'a>(
list_view(palette, &instances)
}
};
crate::ui::dock::frame(column![title_bar, body].spacing(6), width)
crate::ui::dock::frame(title_bar, column![body].spacing(6), width)
}
fn bar_button<'a>(icon: Element<'a, Message>, tip: String, m: Option<CountMsg>) -> Element<'a, Message> {
+3 -3
View File
@@ -235,10 +235,10 @@ pub fn view<'a>(
document: &'a CadDocument,
selected: &[Handle],
width: f32,
auto_collapse: bool,
chrome: crate::ui::dock::Chrome,
) -> Element<'a, Message> {
let title_bar =
crate::ui::dock::title_bar(PanelId::PointCloudManager, crate::t!("Point Cloud Manager").into_owned(), auto_collapse);
crate::ui::dock::title_bar(PanelId::PointCloudManager, crate::t!("Point Cloud Manager").into_owned(), chrome);
let query = state.search.trim().to_lowercase();
let matches = |label: &str| query.is_empty() || label.to_lowercase().contains(&query);
// Searching opens every node so the matches show.
@@ -354,5 +354,5 @@ pub fn view<'a>(
.padding([4, 8])
.size(12);
crate::ui::dock::frame(column![title_bar, card, search].spacing(6), width)
crate::ui::dock::frame(title_bar, column![card, search].spacing(6), width)
}
+3 -3
View File
@@ -936,9 +936,9 @@ fn heading(label: &str) -> Element<'static, Message> {
.into()
}
pub fn view<'a>(state: &'a SheetSetManager, width: f32, auto_collapse: bool) -> Element<'a, Message> {
pub fn view<'a>(state: &'a SheetSetManager, width: f32, chrome: crate::ui::dock::Chrome) -> Element<'a, Message> {
let title_bar =
crate::ui::dock::title_bar(PanelId::SheetSetManager, t!("Sheet Set Manager").into_owned(), auto_collapse);
crate::ui::dock::title_bar(PanelId::SheetSetManager, t!("Sheet Set Manager").into_owned(), chrome);
let mut picks: Vec<Pick> =
state.sets.iter().enumerate().map(|(i, db)| Pick(Some(i), db.name().to_string())).collect();
picks.push(Pick(None, t!("Open...").into_owned()));
@@ -1040,7 +1040,7 @@ pub fn view<'a>(state: &'a SheetSetManager, width: f32, auto_collapse: bool) ->
let hint = text(t!("Double-click opens a sheet. Right-click for more commands.").into_owned())
.size(10)
.style(muted_style);
crate::ui::dock::frame(column![title_bar, combo, tabs, card, hint].spacing(6), width)
crate::ui::dock::frame(title_bar, column![combo, tabs, card, hint].spacing(6), width)
}
// ── dialogs ──────────────────────────────────────────────────────────────────
+2 -2
View File
@@ -542,7 +542,7 @@ impl XrefManagerPanel {
pub fn view<'a>(
&'a self,
width: f32,
auto_collapse: bool,
chrome: crate::ui::dock::Chrome,
missing: usize,
doc: &'a CadDocument,
) -> Element<'a, Message> {
@@ -550,7 +550,7 @@ impl XrefManagerPanel {
let title_bar = crate::ui::dock::title_bar(
PanelId::ExternalReferences,
format!("{} ({})", crate::t!("External References").as_ref(), self.entries.len()),
auto_collapse,
chrome,
);
// Table content width: column widths plus gutters, stretched to the
// dock when wider so rows fill the panel; narrower docks sidescroll.