//! Docked "Insert Block" panel: a searchable grid of block thumbnails that //! inserts a clicked block, mirrors the existing `InsertBlockCommand` flow. use crate::app::Message; use crate::modules::IconKind; use crate::scene::model::wire_model::WireModel; use crate::ui::dock::{DockMsg, PanelId}; use iced::widget::canvas::{Frame, Path, Program, Stroke}; use iced::widget::{button, canvas, column, container, mouse_area, row, scrollable, text, text_input, tooltip}; use iced::{Background, Border, Color, Element, Fill, Length, Theme}; const TOOL_H: f32 = 22.0; /// Character budget for a block card's label so all rows stay one line. /// Fits the narrowest card (Small = 3 per row) at size 11 without wrapping. const MAX_LABEL_CHARS: usize = 12; /// Fixed single-line height of a card's label. Clips any text that still /// won't fit so every cell in a grid row shares the same height. const LABEL_LINE_H: f32 = 16.0; /// Every message the block palette emits. Wrapped in `Message::BlockPalette`. #[derive(Debug, Clone)] pub enum BlockPaletteMsg { Search(String), CyclePreviewSize, PickFile, Insert(String), Refresh, FilePicked(Result), } /// Thumbnail box sizes for the three preview-size settings. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum PreviewSize { #[default] Small, Medium, Large, } impl PreviewSize { /// Number of cards per grid row. fn columns(self) -> usize { match self { PreviewSize::Small => 3, PreviewSize::Medium => 2, PreviewSize::Large => 1, } } /// Height (px) of the thumbnail box inside each card. fn box_height(self) -> f32 { match self { PreviewSize::Small => 56.0, PreviewSize::Medium => 84.0, PreviewSize::Large => 120.0, } } } /// Advance to the next preview size (Small → Medium → Large → Small). pub fn cycle_preview_size(s: PreviewSize) -> PreviewSize { match s { PreviewSize::Small => PreviewSize::Medium, PreviewSize::Medium => PreviewSize::Large, PreviewSize::Large => PreviewSize::Small, } } /// One block's cached preview wires. pub struct BlockEntry { pub name: String, pub wires: Vec, } /// Panel state held on the app. #[derive(Default)] pub struct BlockPalette { pub search: String, pub preview_size: PreviewSize, /// Cached (name, wires) pairs, rebuilt on refresh. pub blocks: Vec, /// Block name currently being placed (INSERT active), for the highlight. pub placing: Option, /// Last known block-name list, for the cheap stale check. pub cached_names: Vec, /// Document tab and block-definition revision that produced `blocks`. /// Names alone are not enough: separate drawings can share block names, /// and a block definition can change without being renamed. pub source_tab_id: Option, pub source_block_epoch: u64, } /// Canvas program that draws a block's wires fit-to-box inside its bounds. struct BlockPreviewCanvas<'a> { wires: &'a [WireModel], } impl<'a> Program for BlockPreviewCanvas<'a> { type State = (); fn draw( &self, _state: &(), renderer: &iced::Renderer, _theme: &Theme, bounds: iced::Rectangle, _cursor: iced::mouse::Cursor, ) -> Vec { let mut frame = Frame::new(renderer, bounds.size()); let pad = 6.0; let inner_w = (bounds.width - 2.0 * pad).max(1.0); let inner_h = (bounds.height - 2.0 * pad).max(1.0); let (mut minx, mut miny, mut maxx, mut maxy) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); let mut any = false; for w in self.wires { for p in &w.points { if p[0].is_finite() && p[1].is_finite() { minx = minx.min(p[0]); miny = miny.min(p[1]); maxx = maxx.max(p[0]); maxy = maxy.max(p[1]); any = true; } } } if !any { // Placeholder box so an empty block still reads as a card. let rect = Path::rectangle(iced::Point::new(pad, pad), iced::Size::new(inner_w, inner_h)); frame.fill(&rect, Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 }); return vec![frame.into_geometry()]; } let span_x = (maxx - minx).max(1e-6); let span_y = (maxy - miny).max(1e-6); let scale = (inner_w / span_x).min(inner_h / span_y); let ox = (bounds.width - span_x * scale) * 0.5; let oy = (bounds.height - span_y * scale) * 0.5; // World up → screen down; centre the fitted AABB. let map = |x: f32, y: f32| iced::Point::new(ox + (x - minx) * scale, oy + (maxy - y) * scale); // Solid fills first so strokes sit on top. for w in self.wires { let col = Color { r: w.color[0], g: w.color[1], b: w.color[2], a: 0.55 }; for tri in w.fill_tris.chunks(3) { if tri.len() == 3 { let path = Path::new(|p| { p.move_to(map(tri[0][0], tri[0][1])); p.line_to(map(tri[1][0], tri[1][1])); p.line_to(map(tri[2][0], tri[2][1])); p.close(); }); frame.fill(&path, col); } } } // Polylines, split into runs on NaN separators (NaN = polyline break). for w in self.wires { let col = Color { r: w.color[0], g: w.color[1], b: w.color[2], a: 1.0 }; let width = if w.line_weight_px > 1.5 { 2.0 } else { 1.0 }; let mut run: Vec = Vec::new(); let flush = |frame: &mut Frame, run: &mut Vec, col: Color, width: f32| { if run.len() >= 2 { let path = Path::new(|p| { p.move_to(run[0]); for &pt in &run[1..] { p.line_to(pt); } }); frame.stroke(&path, Stroke::default().with_color(col).with_width(width)); } run.clear(); }; for p in &w.points { if p[0].is_finite() && p[1].is_finite() { run.push(map(p[0], p[1])); } else { flush(&mut frame, &mut run, col, width); } } flush(&mut frame, &mut run, col, width); } vec![frame.into_geometry()] } } /// Build the docked panel element from the palette state. pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element<'_, Message> { let query = palette.search.trim().to_lowercase(); let filtered: Vec<&BlockEntry> = palette .blocks .iter() .filter(|block| query.is_empty() || block.name.to_lowercase().contains(&query)) .collect(); // ── Dock chrome (title, pin, close) — matches the Properties panel ──── let pin_icon = if auto_collapse { crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) } else { crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0) }; let pin = button(pin_icon) .on_press(Message::Dock(DockMsg::AutoCollapseToggle(PanelId::BlockPalette))) .style(move |theme: &Theme, status| { let mut style = button::subtle(theme, status); if auto_collapse { let palette = theme.palette(); style.background = Some(Background::Color(palette.primary.weak.color)); style.text_color = palette.primary.weak.text; style.border.color = palette.primary.base.color; style.border.width = 1.0; } style }) .padding([3, 5]); let pin = tooltip(pin, text(crate::t!("Auto")).size(10), tooltip::Position::Bottom).gap(4); let close = button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0)) .on_press(Message::Dock(DockMsg::Close(PanelId::BlockPalette))) .style(button::subtle) .padding([3, 5]); let close = tooltip(close, text(crate::t!("Close")).size(10), tooltip::Position::Bottom).gap(4); let title_bar = mouse_area( container( row![ text(crate::t!("Block Palette")).size(12), iced::widget::Space::new().width(Fill), pin, close, ] .spacing(3) .align_y(iced::Center), ) .style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.weak.color)), ..Default::default() }) .width(Fill) .padding([3, 6]), ) .on_press(Message::Dock(DockMsg::DockGrab(PanelId::BlockPalette))) .interaction(iced::mouse::Interaction::Grab); let search_input = text_input(&crate::t!("Search blocks…"), &palette.search) .on_input(|v| Message::BlockPalette(BlockPaletteMsg::Search(v))) .padding([4, 8]) .size(12); let header = row![ search_input, icon_button( IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg")), BlockPaletteMsg::PickFile, ), icon_button( IconKind::Svg(include_bytes!("../../../assets/icons/blocks/preview_size.svg")), BlockPaletteMsg::CyclePreviewSize, ), ] .spacing(4) .align_y(iced::Center); let body: Element<'_, Message> = if filtered.is_empty() { let msg = if palette.cached_names.is_empty() { "No blocks in this drawing" } else { "No matches" }; container(text(crate::t!(msg)).size(12).color(iced::Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 })) .center_x(Fill) .center_y(Fill) .width(Fill) .height(Fill) .into() } else { let mut col = column![].spacing(6); for chunk in filtered.chunks(palette.preview_size.columns()) { let mut r = row![].spacing(6).width(Fill); for block in chunk { r = r.push(block_card(palette, block)); } col = col.push(r); } scrollable( container(col).padding(iced::Padding { top: 0.0, right: 8.0, bottom: 6.0, left: 0.0, }), ) .width(Fill) .height(Fill) .into() }; container(column![title_bar, header, body].spacing(6).padding(6)) .width(Length::Fixed(width)) .height(Fill) .style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.base.color)), border: Border { color: theme.palette().background.neutral.color, width: 1.0, radius: 0.0.into(), }, ..Default::default() }) .into() } fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'a, Message> { let is_placing = palette.placing.as_deref() == Some(block.name.as_str()); let preview = canvas(BlockPreviewCanvas { wires: &block.wires }) .width(Fill) .height(Length::Fixed(palette.preview_size.box_height())); // Fixed height guarantees the label occupies exactly one line, so every // cell in a row keeps the same height even for long names (the elide budget // handles overflow, and this clips anything that still won't fit). let label = text(crate::ui::text_util::elide(&block.name, MAX_LABEL_CHARS)) .size(11) .color(Color::WHITE) .width(Fill) .height(Length::Fixed(LABEL_LINE_H)) .center(); let content = column![preview, label].spacing(4).padding(6); button(content) .on_press(Message::BlockPalette(BlockPaletteMsg::Insert(block.name.clone()))) .width(Fill) .style(move |theme: &Theme, status| { let palette = theme.palette(); let neutral = palette.background.neutral.color; let base = palette.background.base.color; let strong = palette.background.strong.color; let (bg, border) = if is_placing { (palette.primary.base.color, palette.primary.base.color) } else { match status { button::Status::Hovered | button::Status::Pressed => (strong, neutral), _ => (base, neutral), } }; button::Style { background: Some(Background::Color(bg)), border: Border { color: border, width: 1.0, radius: 3.0.into(), }, text_color: Color::WHITE, ..Default::default() } }) .into() } fn icon_button<'a>(icon: IconKind, msg: BlockPaletteMsg) -> Element<'a, Message> { let icon_el: Element<'_, Message> = match icon { IconKind::Glyph(s) => text(s).size(15).color(Color::WHITE).into(), IconKind::Svg(bytes) => crate::ui::icons::semantic(bytes, TOOL_H), }; button(icon_el) .on_press(Message::BlockPalette(msg)) .width(Length::Fixed(TOOL_H + 8.0)) .height(Length::Fixed(TOOL_H + 8.0)) .style(|theme: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered | button::Status::Pressed => { theme.palette().background.strong.color } _ => Color::TRANSPARENT, })), border: Border { radius: 3.0.into(), ..Default::default() }, text_color: Color::WHITE, ..Default::default() }) .into() } #[cfg(test)] mod tests { use super::*; #[test] fn preview_size_cycles() { assert_eq!(cycle_preview_size(PreviewSize::Small), PreviewSize::Medium); assert_eq!(cycle_preview_size(PreviewSize::Medium), PreviewSize::Large); assert_eq!(cycle_preview_size(PreviewSize::Large), PreviewSize::Small); } }