//! Shared Content Browser body presentation used by production and the UI Gallery. //! //! Project state and mutations stay in the editor crate. These components own Penpot geometry, //! clipping, typography, hover presentation, and action-returning interaction only. use egui; use egui_phosphor_icons::{icons, Icon}; use crate::design_system; use crate::design_system::controls::{phosphor_icon_font, PhosphorIconStyle}; use crate::design_system::typography::TypeRole; pub const CONTENT_HEADER_HEIGHT: f32 = 56.0; pub const PANE_HEADING_HEIGHT: f32 = 42.0; pub const DETAILS_PANE_HEADING_HEIGHT: f32 = 40.0; pub const DETAILS_EMPTY_STATE_HEIGHT: f32 = 30.0; pub const DETAILS_CONTENT_INSET: f32 = 14.0; pub const DETAILS_HEADER_HEIGHT: f32 = 108.0; pub const DETAILS_SECTION_HEIGHT: f32 = 40.0; pub const DETAILS_PATH_ROW_HEIGHT: f32 = 32.0; pub const ASSET_CARD_WIDTH: f32 = 116.0; pub const ASSET_CARD_HEIGHT: f32 = 136.0; pub const ASSET_CARD_MIN_THUMBNAIL_SIZE: f32 = 48.0; pub const ASSET_CARD_MAX_THUMBNAIL_SIZE: f32 = 112.0; pub const ASSET_CARD_FOOTER_HEIGHT: f32 = 40.0; pub const ASSET_CARD_GAP: f32 = 8.0; pub const ASSET_CARD_RADIUS: f32 = 5.0; pub const ASSET_CARD_PREVIEW_INSET: f32 = 7.0; pub const ASSET_CARD_PREVIEW_HEIGHT: f32 = 92.0; pub const ASSET_CARD_PREVIEW_HEIGHT_DELTA: f32 = 4.0; pub const ASSET_CARD_PREVIEW_RADIUS: f32 = 4.0; pub const ASSET_CARD_TYPE_KEYLINE_HEIGHT: f32 = 3.0; pub const ASSET_CARD_TYPE_KEYLINE_SIDE_INSET: f32 = 5.0; pub const ASSET_CARD_TYPE_KEYLINE_BOTTOM_INSET: f32 = 2.0; pub const ASSET_CARD_STATUS_RIGHT_INSET: f32 = 17.0; pub const ASSET_CARD_STATUS_TOP: f32 = 18.0; pub const GRID_INSET_X: i8 = 22; pub const GRID_INSET_Y: i8 = 6; pub const GRID_SURFACE_INSET_X: f32 = 16.0; pub const GRID_SURFACE_BOTTOM_INSET: f32 = 14.0; pub const SOURCE_ROW_HEIGHT: f32 = 32.0; pub const SOURCE_ROW_STRIDE: f32 = 36.0; /// Paints one complete Penpot Content Browser body lane without taking layout space. /// /// Production and the Gallery allocate identical deterministic lane rectangles. The full inside /// stroke can safely overlap its neighbor at a shared boundary without narrowing either lane. pub fn content_browser_pane_background(ui: &egui::Ui) { let palette = design_system::palette(ui); let rect = ui.max_rect().intersect(ui.clip_rect()); let painter = ui.painter().with_clip_rect(rect); painter.rect( rect, ASSET_CARD_RADIUS, palette.panel, egui::Stroke::new(1.0_f32, palette.border_strong), egui::StrokeKind::Inside, ); } /// Paints the recessed asset surface inside the outer grid pane. /// /// Penpot keeps a 16 px panel gutter around this surface. Cards begin another 6 px inside it, /// producing the canonical 22 px card origin without making the content header recessed. pub fn content_browser_grid_surface_background(ui: &egui::Ui) { let palette = design_system::palette(ui); let bounds = ui.max_rect(); let top = ui.next_widget_position().y; let rect = egui::Rect::from_min_max( egui::pos2(bounds.left() + GRID_SURFACE_INSET_X, top), egui::pos2( bounds.right() - GRID_SURFACE_INSET_X, bounds.bottom() - GRID_SURFACE_BOTTOM_INSET, ), ) .intersect(ui.clip_rect()); if rect.is_positive() { ui.painter().rect_filled(rect, 0.0, palette.recessed); } } /// Applies the exact Penpot card-grid inset while leaving card behavior to the caller. pub fn content_browser_grid_inset( ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui) -> R, ) -> R { egui::Frame::new() .inner_margin(egui::Margin::symmetric(GRID_INSET_X, GRID_INSET_Y)) .show(ui, add_contents) .inner } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct ContentBrowserCardStatus { pub dirty: bool, pub processing: bool, pub error: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ContentBrowserThumbnail { Ready(egui::TextureId), Pending, Failed, Placeholder, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum ContentBrowserAssetTone { #[default] None, Primitive, Light, Model, Texture, Material, Audio, Level, Prefab, PostProcess, RenderingProfile, Shader, } impl ContentBrowserAssetTone { fn background(self, palette: design_system::DesignPalette) -> egui::Color32 { match self { Self::None | Self::Primitive | Self::Light | Self::Model | Self::Audio | Self::Level | Self::Prefab | Self::PostProcess | Self::RenderingProfile | Self::Shader => palette.elevated, Self::Material => egui::Color32::from_rgb(62, 53, 39), Self::Texture => egui::Color32::from_rgb(20, 69, 52), } } fn accent(self, palette: design_system::DesignPalette) -> Option { match self { Self::None => None, Self::Primitive => Some(palette.asset_types.primitive), Self::Light => Some(palette.asset_types.light), Self::Model => Some(palette.asset_types.model), Self::Texture => Some(palette.asset_types.texture), Self::Material => Some(palette.asset_types.material), Self::Audio => Some(palette.asset_types.audio), Self::Level => Some(palette.asset_types.level), Self::Prefab => Some(palette.asset_types.prefab), Self::PostProcess => Some(palette.asset_types.post_process), Self::RenderingProfile => Some(palette.asset_types.rendering_profile), Self::Shader => Some(palette.asset_types.shader), } } } pub struct ContentBrowserCardViewModel<'a> { pub label: &'a str, pub kind: &'a str, pub icon: Icon, pub icon_style: PhosphorIconStyle, pub thumbnail: ContentBrowserThumbnail, pub asset_tone: ContentBrowserAssetTone, pub selected: bool, pub status: ContentBrowserCardStatus, pub failure_tooltip: Option<&'a str>, } /// Returns the complete card footprint for a user-selected thumbnail size. /// /// Thumbnail scaling changes the preview while the 40 px identity footer remains readable. This /// contract is shared by the renderer and the row-packing code so neither can silently reserve a /// different footprint. pub fn content_browser_asset_card_size(thumbnail_size: f32) -> egui::Vec2 { let thumb = thumbnail_size.clamp(ASSET_CARD_MIN_THUMBNAIL_SIZE, ASSET_CARD_MAX_THUMBNAIL_SIZE); egui::vec2(thumb + 20.0, thumb + ASSET_CARD_FOOTER_HEIGHT) } #[derive(Debug, Clone, Copy, PartialEq)] struct AssetCardGeometry { preview: egui::Rect, image: egui::Rect, text: egui::Rect, } impl AssetCardGeometry { fn from_card(card: egui::Rect, thumbnail_size: f32) -> Self { let thumb = thumbnail_size.clamp(ASSET_CARD_MIN_THUMBNAIL_SIZE, ASSET_CARD_MAX_THUMBNAIL_SIZE); let preview = egui::Rect::from_min_size( card.min + egui::Vec2::splat(ASSET_CARD_PREVIEW_INSET), egui::vec2( card.width() - ASSET_CARD_PREVIEW_INSET * 2.0, thumb - ASSET_CARD_PREVIEW_HEIGHT_DELTA, ), ); // Persistent thumbnails are canonical 256 px squares. Preserve that aspect ratio instead // of stretching their rendered geometry as the card crosses slider sizes. let image_size = preview.width().min(preview.height()); let image = egui::Rect::from_center_size(preview.center(), egui::Vec2::splat(image_size)); let text = egui::Rect::from_min_max( egui::pos2(card.left() + 8.0, preview.bottom() + 2.0), egui::pos2(card.right() - 8.0, card.bottom() - 2.0), ); Self { preview, image, text, } } } pub fn content_browser_asset_card( ui: &mut egui::Ui, model: ContentBrowserCardViewModel<'_>, thumbnail_size: f32, ) -> egui::Response { let palette = design_system::palette(ui); let size = content_browser_asset_card_size(thumbnail_size); let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click_and_drag()); let clip = rect.intersect(ui.clip_rect()); let painter = ui.painter().with_clip_rect(clip); let geometry = AssetCardGeometry::from_card(rect, thumbnail_size); let fill = if model.selected { palette.accent_dark } else if response.hovered() { palette.elevated } else { palette.control }; painter.rect( rect, 5.0, fill, egui::Stroke::new( if model.selected { 2.0_f32 } else { 1.0_f32 }, if model.selected { palette.accent } else { palette.border }, ), egui::StrokeKind::Inside, ); painter.rect_filled( geometry.preview, ASSET_CARD_PREVIEW_RADIUS, if matches!(model.thumbnail, ContentBrowserThumbnail::Failed) { egui::Color32::from_rgb(60, 32, 34) } else { model.asset_tone.background(palette) }, ); match model.thumbnail { ContentBrowserThumbnail::Ready(texture) => { painter.image( texture, geometry.image, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE, ); } ContentBrowserThumbnail::Pending => { painter.text( geometry.preview.center(), egui::Align2::CENTER_CENTER, icons::CIRCLE_NOTCH.as_str(), phosphor_font(20.0), palette.text_secondary, ); } ContentBrowserThumbnail::Failed => { painter.text( geometry.preview.center(), egui::Align2::CENTER_CENTER, icons::WARNING.as_str(), phosphor_font(20.0), palette.text_primary, ); } ContentBrowserThumbnail::Placeholder => { painter.text( geometry.preview.center(), egui::Align2::CENTER_CENTER, model.icon.as_str(), phosphor_icon_font(model.icon_style, 20.0), palette.text_primary, ); } }; let text_clip = geometry.text.intersect(clip); let label_elided = paint_ellipsized( ui, text_clip, egui::pos2(text_clip.left(), text_clip.top() + 10.0), model.label, TypeRole::Control.font(), palette.text_primary, ); paint_ellipsized( ui, text_clip, egui::pos2(text_clip.left(), text_clip.top() + 24.0), model.kind, TypeRole::Small.font(), palette.text_muted, ); // Classification is independent from thumbnail health. Paint it at the bottom of the complete // card so a ready image cannot hide it and an error/loading overlay cannot replace it. if let Some(color) = model.asset_tone.accent(palette) { painter.rect_filled(asset_card_type_keyline_rect(rect), 1.5, color); } draw_card_status(ui, rect, model.status); if let Some(reason) = model.failure_tooltip { response.on_hover_text(reason) } else if label_elided { response.on_hover_text(model.label) } else { response } } fn asset_card_type_keyline_rect(card: egui::Rect) -> egui::Rect { egui::Rect::from_min_max( egui::pos2( card.left() + ASSET_CARD_TYPE_KEYLINE_SIDE_INSET, card.bottom() - ASSET_CARD_TYPE_KEYLINE_BOTTOM_INSET - ASSET_CARD_TYPE_KEYLINE_HEIGHT, ), egui::pos2( card.right() - ASSET_CARD_TYPE_KEYLINE_SIDE_INSET, card.bottom() - ASSET_CARD_TYPE_KEYLINE_BOTTOM_INSET, ), ) } fn draw_card_status(ui: &egui::Ui, rect: egui::Rect, status: ContentBrowserCardStatus) { let palette = design_system::palette(ui); let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); let mut y = rect.top() + ASSET_CARD_STATUS_TOP; let x = rect.right() - ASSET_CARD_STATUS_RIGHT_INSET; if status.error { painter.text( egui::pos2(x, y), egui::Align2::CENTER_CENTER, icons::WARNING.as_str(), phosphor_font(14.0), palette.error, ); y += 14.0; } if status.processing { painter.text( egui::pos2(x, y), egui::Align2::CENTER_CENTER, icons::CIRCLE_NOTCH.as_str(), phosphor_font(12.0), palette.text_secondary, ); y += 14.0; } if status.dirty { painter.circle_filled(egui::pos2(x, y), 3.5, palette.warning); } } pub struct ContentBrowserContentHeaderViewModel<'a> { pub folder: &'a str, pub item_count: usize, pub sort: &'a str, pub selection_count: usize, } pub fn content_browser_content_header( ui: &mut egui::Ui, model: ContentBrowserContentHeaderViewModel<'_>, ) { let palette = design_system::palette(ui); let (rect, _) = ui.allocate_exact_size( egui::vec2(ui.available_width().max(1.0), CONTENT_HEADER_HEIGHT), egui::Sense::hover(), ); let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); painter.text( rect.min + egui::vec2(16.0, 23.0), egui::Align2::LEFT_CENTER, model.folder, TypeRole::AssetTitle.font(), palette.text_primary, ); painter.text( rect.min + egui::vec2(16.0, 44.0), egui::Align2::LEFT_CENTER, format!("{} items · sorted by {}", model.item_count, model.sort), TypeRole::Small.font(), palette.text_muted, ); if model.selection_count > 0 { let chip = egui::Rect::from_min_size(rect.min + egui::vec2(134.0, 36.0), egui::vec2(112.0, 16.0)); painter.rect_filled(chip, 8.0, palette.accent_dark); painter.text( chip.center(), egui::Align2::CENTER_CENTER, format!("{} SELECTED", model.selection_count), TypeRole::Eyebrow.font(), palette.accent, ); } } pub struct ContentBrowserSourceRowViewModel<'a> { pub id: &'a str, pub label: &'a str, pub count: usize, pub depth: usize, pub has_children: bool, pub expanded: bool, pub selected: bool, } pub struct ContentBrowserSourceRowResponse { pub row: egui::Response, pub disclosure_clicked: bool, } pub fn content_browser_source_row( ui: &mut egui::Ui, model: ContentBrowserSourceRowViewModel<'_>, ) -> ContentBrowserSourceRowResponse { let palette = design_system::palette(ui); let (slot, _) = ui.allocate_exact_size( egui::vec2(ui.available_width().max(1.0), SOURCE_ROW_STRIDE), egui::Sense::hover(), ); let row_rect = egui::Rect::from_min_size(slot.min, egui::vec2(slot.width(), SOURCE_ROW_HEIGHT)); let row = ui.interact( row_rect, ui.make_persistent_id(("content_source_row", model.id)), egui::Sense::click_and_drag(), ); let painter = ui .painter() .with_clip_rect(row_rect.intersect(ui.clip_rect())); painter.rect( row_rect, 4.0, if model.selected { palette.accent_dark } else if row.hovered() { palette.elevated } else if model.depth == 0 && model.has_children && model.expanded { palette.control } else { palette.panel }, egui::Stroke::new( 1.0_f32, if model.selected { palette.accent } else { palette.border }, ), egui::StrokeKind::Inside, ); // Penpot gives the expanded project root the leading icon position and uses one 18 px indent // for its direct children. Deeper project folders continue that same deterministic rhythm. let icon_left = row_rect.left() + if model.depth == 0 && model.has_children { 10.0 } else { 28.0 + model.depth.saturating_sub(1) as f32 * 18.0 }; let icon_rect = egui::Rect::from_min_size( egui::pos2(icon_left, row_rect.top() + 4.0), egui::vec2(20.0, 24.0), ); let disclosure_clicked = if model.has_children { let response = ui.interact( icon_rect, ui.make_persistent_id(("content_source_disclosure", model.id)), egui::Sense::click(), ); response.clicked() } else { false }; painter.text( egui::pos2(icon_left + 7.0, row_rect.center().y), egui::Align2::CENTER_CENTER, if model.expanded { icons::FOLDER_OPEN.as_str() } else { icons::FOLDER.as_str() }, phosphor_icon_font(PhosphorIconStyle::Fill, 14.0), if model.selected { palette.text_primary } else { palette.text_secondary }, ); let label_rect = egui::Rect::from_min_max( egui::pos2(icon_left + 24.0, row_rect.top()), egui::pos2(row_rect.right() - 52.0, row_rect.bottom()), ); paint_ellipsized( ui, label_rect, label_rect.left_center(), model.label, TypeRole::Body.font(), if model.selected { palette.text_primary } else { palette.text_secondary }, ); painter.text( row_rect.right_center() - egui::vec2(14.0, 0.0), egui::Align2::RIGHT_CENTER, model.count, TypeRole::Small.font(), palette.text_muted, ); ContentBrowserSourceRowResponse { row, disclosure_clicked, } } pub fn content_browser_pane_heading( ui: &mut egui::Ui, label: &str, show_menu: bool, ) -> Option { content_browser_pane_heading_with_height(ui, label, show_menu, PANE_HEADING_HEIGHT) } pub fn content_browser_details_pane_heading( ui: &mut egui::Ui, label: &str, show_menu: bool, ) -> Option { content_browser_pane_heading_with_height(ui, label, show_menu, DETAILS_PANE_HEADING_HEIGHT) } fn content_browser_pane_heading_with_height( ui: &mut egui::Ui, label: &str, show_menu: bool, height: f32, ) -> Option { let palette = design_system::palette(ui); let (rect, _) = ui.allocate_exact_size( egui::vec2(ui.available_width().max(1.0), height), egui::Sense::hover(), ); ui.painter().text( egui::pos2(rect.left() + 14.0, rect.top() + 20.0), egui::Align2::LEFT_CENTER, label, TypeRole::Eyebrow.font(), palette.text_muted, ); show_menu.then(|| { put_detached( ui, pane_heading_menu_rect(rect), egui::Button::new( egui::RichText::new(icons::DOTS_THREE_VERTICAL.as_str()).font(phosphor_font(13.0)), ), ) .on_hover_text("Panel options") }) } fn pane_heading_menu_rect(heading: egui::Rect) -> egui::Rect { egui::Rect::from_center_size( egui::pos2(heading.right() - 26.0, heading.center().y), egui::vec2(28.0, 28.0), ) } /// Draws the empty Details state with the same left gutter as populated Details content. pub fn content_browser_details_empty_state(ui: &mut egui::Ui, label: &str) -> egui::Response { let palette = design_system::palette(ui); let (rect, response) = ui.allocate_exact_size( egui::vec2(ui.available_width().max(1.0), DETAILS_EMPTY_STATE_HEIGHT), egui::Sense::hover(), ); ui.painter().text( rect.left_center() + egui::vec2(DETAILS_CONTENT_INSET, 0.0), egui::Align2::LEFT_CENTER, label, TypeRole::Body.font(), palette.text_secondary, ); response } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ContentBrowserBadgeTone { Neutral, Healthy, Warning, Error, } pub struct ContentBrowserDetailsHeaderViewModel<'a> { pub label: &'a str, pub kind: &'a str, pub icon: Icon, pub thumbnail: ContentBrowserThumbnail, pub badge: Option<(&'a str, ContentBrowserBadgeTone)>, pub badge_tooltip: Option<&'a str>, pub unsaved: bool, pub can_locate: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ContentBrowserDetailsAction { Locate, } pub fn content_browser_details_header( ui: &mut egui::Ui, model: ContentBrowserDetailsHeaderViewModel<'_>, ) -> Option { let palette = design_system::palette(ui); let width = ui.available_width().max(1.0); let (rect, _) = ui.allocate_exact_size( egui::vec2(width, DETAILS_HEADER_HEIGHT), egui::Sense::hover(), ); let clip = rect.intersect(ui.clip_rect()); let painter = ui.painter().with_clip_rect(clip); let preview_size = if width >= 250.0 { 96.0 } else { 72.0 }; let preview = egui::Rect::from_min_size( rect.min + egui::vec2(16.0, 0.0), egui::vec2(preview_size, preview_size), ); painter.rect( preview, 3.0, palette.elevated, egui::Stroke::new(1.0_f32, palette.border), egui::StrokeKind::Inside, ); match model.thumbnail { ContentBrowserThumbnail::Ready(texture) => { painter.image( texture, preview.shrink(4.0), egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE, ); } ContentBrowserThumbnail::Pending => { painter.text( preview.center(), egui::Align2::CENTER_CENTER, icons::CIRCLE_NOTCH.as_str(), phosphor_font(20.0), palette.text_secondary, ); } ContentBrowserThumbnail::Failed => { painter.text( preview.center(), egui::Align2::CENTER_CENTER, icons::WARNING.as_str(), phosphor_font(20.0), palette.error, ); } ContentBrowserThumbnail::Placeholder => { painter.text( preview.center(), egui::Align2::CENTER_CENTER, model.icon.as_str(), phosphor_font(20.0), palette.text_primary, ); } }; let identity_left = preview.right() + 14.0; let identity = egui::Rect::from_min_max( egui::pos2(identity_left, rect.top()), egui::pos2(rect.right() - 12.0, rect.bottom()), ); paint_ellipsized( ui, identity, egui::pos2(identity.left(), identity.top() + 18.0), model.label, TypeRole::AssetTitle.font(), palette.text_primary, ); painter.text( egui::pos2(identity.left(), identity.top() + 38.0), egui::Align2::LEFT_CENTER, model.kind, TypeRole::Small.font(), palette.text_muted, ); let mut next_y = identity.top() + 51.0; if let Some((badge, tone)) = model.badge { let color = match tone { ContentBrowserBadgeTone::Neutral => palette.accent, ContentBrowserBadgeTone::Healthy => palette.healthy, ContentBrowserBadgeTone::Warning => palette.warning, ContentBrowserBadgeTone::Error => palette.error, }; let badge_rect = egui::Rect::from_min_size( egui::pos2(identity.left(), next_y), egui::vec2(96.0_f32.min(identity.width()), 22.0), ); painter.rect( badge_rect, 3.0, color.linear_multiply(0.14), egui::Stroke::new(1.0_f32, color.linear_multiply(0.75)), egui::StrokeKind::Inside, ); painter.text( badge_rect.center(), egui::Align2::CENTER_CENTER, badge, TypeRole::Eyebrow.font(), color, ); if let Some(tooltip) = model.badge_tooltip { ui.interact( badge_rect, ui.make_persistent_id(("content_details_badge", model.label)), egui::Sense::hover(), ) .on_hover_text(tooltip); } next_y += 25.0; } if model.unsaved { painter.text( egui::pos2(identity.left(), next_y + 7.0), egui::Align2::LEFT_CENTER, "UNSAVED", TypeRole::Eyebrow.font(), palette.warning, ); next_y += 18.0; } let locate_rect = egui::Rect::from_min_size( egui::pos2(identity.left(), next_y), egui::vec2(96.0_f32.min(identity.width()), 28.0), ); let locate = put_detached( ui, locate_rect, egui::Button::new(crate::design_system::controls::icon_label_with_style( icons::TARGET, "Locate", TypeRole::Control, 12.0, palette.text_primary, PhosphorIconStyle::Fill, )) .sense(if model.can_locate { egui::Sense::click() } else { egui::Sense::hover() }), ); locate .clicked() .then_some(ContentBrowserDetailsAction::Locate) } pub fn content_browser_details_section(ui: &mut egui::Ui, label: &str) { let palette = design_system::palette(ui); let (rect, _) = ui.allocate_exact_size( egui::vec2(ui.available_width().max(1.0), DETAILS_SECTION_HEIGHT), egui::Sense::hover(), ); ui.painter().line_segment( [rect.left_top(), rect.right_top()], egui::Stroke::new(1.0_f32, palette.border), ); ui.painter().text( egui::pos2(rect.left() + 16.0, rect.top() + 22.0), egui::Align2::LEFT_CENTER, label, TypeRole::Eyebrow.font(), palette.text_muted, ); } pub fn content_browser_detail_row(ui: &mut egui::Ui, label: &str, value: &str) -> egui::Response { content_browser_detail_row_with_lines(ui, label, value, 28.0, 1) } /// Draws a bounded two-line identity or dependency path. /// /// Penpot gives project-relative paths two readable lines. Keeping that geometry in the shared /// component prevents long values from painting into the grid or beyond a resized Details lane. pub fn content_browser_detail_path_row( ui: &mut egui::Ui, label: &str, value: &str, ) -> egui::Response { content_browser_detail_row_with_lines(ui, label, value, DETAILS_PATH_ROW_HEIGHT, 2) } fn content_browser_detail_row_with_lines( ui: &mut egui::Ui, label: &str, value: &str, height: f32, max_rows: usize, ) -> egui::Response { let palette = design_system::palette(ui); let width = ui.available_width().max(1.0); let (rect, response) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); let clip = rect.intersect(ui.clip_rect()); let label_width = 56.0_f32.min((width * 0.3).max(36.0)); let content = rect.shrink2(egui::vec2(16.0, 0.0)); ui.painter().with_clip_rect(clip).text( content.left_center(), egui::Align2::LEFT_CENTER, label, TypeRole::Small.font(), palette.text_secondary, ); let value_rect = egui::Rect::from_min_max( egui::pos2(content.left() + label_width, content.top()), content.right_bottom(), ) .intersect(clip); let elided = paint_bounded_text( ui, value_rect, value, TypeRole::Small.font(), palette.text_secondary, max_rows, ); if elided { response.on_hover_text(value) } else { response } } fn paint_ellipsized( ui: &egui::Ui, clip: egui::Rect, anchor: egui::Pos2, text: impl Into, font: egui::FontId, color: egui::Color32, ) -> bool { paint_bounded_text_at(ui, clip, anchor, text, font, color, 1) } fn paint_bounded_text( ui: &egui::Ui, clip: egui::Rect, text: impl Into, font: egui::FontId, color: egui::Color32, max_rows: usize, ) -> bool { paint_bounded_text_at(ui, clip, clip.left_center(), text, font, color, max_rows) } fn paint_bounded_text_at( ui: &egui::Ui, clip: egui::Rect, anchor: egui::Pos2, text: impl Into, font: egui::FontId, color: egui::Color32, max_rows: usize, ) -> bool { if clip.width() <= 0.0 || clip.height() <= 0.0 { return true; } let mut job = egui::text::LayoutJob::simple(text.into(), font, color, clip.width()); job.wrap.max_rows = max_rows; job.wrap.break_anywhere = true; job.wrap.overflow_character = Some('…'); let galley = ui.painter().layout_job(job); ui.painter().with_clip_rect(clip).galley( egui::pos2(anchor.x, anchor.y - galley.rect.height() * 0.5), galley.clone(), color, ); galley.elided } fn put_detached(ui: &mut egui::Ui, rect: egui::Rect, widget: impl egui::Widget) -> egui::Response { let clip = rect.intersect(ui.clip_rect()); let mut overlay = ui.new_child( egui::UiBuilder::new() .max_rect(rect) .layout(egui::Layout::top_down(egui::Align::Min)), ); overlay.set_clip_rect(clip); overlay.put(rect, widget) } fn phosphor_font(size: f32) -> egui::FontId { egui::FontId::new(size, egui::FontFamily::Name("phosphor-bold".into())) } #[cfg(test)] mod tests { use super::*; #[test] fn body_geometry_matches_the_penpot_contract() { assert_eq!(CONTENT_HEADER_HEIGHT, 56.0); assert_eq!(PANE_HEADING_HEIGHT, 42.0); assert_eq!(DETAILS_PANE_HEADING_HEIGHT, 40.0); assert_eq!(DETAILS_EMPTY_STATE_HEIGHT, 30.0); assert_eq!(DETAILS_CONTENT_INSET, 14.0); assert_eq!(DETAILS_HEADER_HEIGHT, 108.0); assert_eq!(DETAILS_SECTION_HEIGHT, 40.0); assert_eq!(DETAILS_PATH_ROW_HEIGHT, 32.0); assert_eq!(ASSET_CARD_WIDTH, 116.0); assert_eq!(ASSET_CARD_HEIGHT, 136.0); assert_eq!(ASSET_CARD_MIN_THUMBNAIL_SIZE, 48.0); assert_eq!(ASSET_CARD_MAX_THUMBNAIL_SIZE, 112.0); assert_eq!(ASSET_CARD_FOOTER_HEIGHT, 40.0); assert_eq!(ASSET_CARD_GAP, 8.0); assert_eq!(ASSET_CARD_RADIUS, 5.0); assert_eq!(ASSET_CARD_PREVIEW_INSET, 7.0); assert_eq!(ASSET_CARD_PREVIEW_HEIGHT, 92.0); assert_eq!(ASSET_CARD_PREVIEW_HEIGHT_DELTA, 4.0); assert_eq!(ASSET_CARD_PREVIEW_RADIUS, 4.0); assert_eq!(ASSET_CARD_TYPE_KEYLINE_HEIGHT, 3.0); assert_eq!(ASSET_CARD_TYPE_KEYLINE_SIDE_INSET, 5.0); assert_eq!(ASSET_CARD_TYPE_KEYLINE_BOTTOM_INSET, 2.0); assert_eq!(ASSET_CARD_STATUS_RIGHT_INSET, 17.0); assert_eq!(ASSET_CARD_STATUS_TOP, 18.0); assert_eq!(GRID_INSET_X, 22); assert_eq!(GRID_INSET_Y, 6); assert_eq!(GRID_SURFACE_INSET_X, 16.0); assert_eq!(GRID_SURFACE_BOTTOM_INSET, 14.0); assert_eq!(SOURCE_ROW_HEIGHT, 32.0); assert_eq!(SOURCE_ROW_STRIDE, 36.0); } #[test] fn details_heading_and_empty_state_share_balanced_gutters() { let heading = egui::Rect::from_min_size( egui::pos2(100.0, 20.0), egui::vec2(290.0, DETAILS_PANE_HEADING_HEIGHT), ); let menu = pane_heading_menu_rect(heading); assert_eq!(menu.center().y, heading.center().y); assert_eq!(heading.right() - menu.right(), 12.0); assert!(heading.contains_rect(menu)); let context = egui::Context::default(); context.set_fonts(crate::fonts::font_definitions()); let mut response_rect = egui::Rect::NOTHING; let _ = context.run_ui( egui::RawInput { screen_rect: Some(egui::Rect::from_min_size( egui::Pos2::ZERO, egui::vec2(290.0, 80.0), )), ..Default::default() }, |ui| { response_rect = content_browser_details_empty_state(ui, "No asset selected").rect; }, ); assert_eq!(response_rect.height(), DETAILS_EMPTY_STATE_HEIGHT); assert_eq!(response_rect.width(), 290.0); } #[test] fn bottom_type_keyline_is_stable_and_bounded_for_every_card_scale() { for thumb in [48.0_f32, 64.0, 92.0, 112.0] { let card = egui::Rect::from_min_size(egui::Pos2::ZERO, content_browser_asset_card_size(thumb)); let keyline = asset_card_type_keyline_rect(card); assert!(keyline.is_finite()); assert!(card.contains_rect(keyline)); assert_eq!(keyline.height(), ASSET_CARD_TYPE_KEYLINE_HEIGHT); assert_eq!( keyline.width(), card.width() - ASSET_CARD_TYPE_KEYLINE_SIDE_INSET * 2.0 ); assert_eq!( card.bottom() - keyline.bottom(), ASSET_CARD_TYPE_KEYLINE_BOTTOM_INSET ); } } #[test] fn thumbnail_scaling_preserves_footer_and_square_image_geometry() { for thumb in [48.0_f32, 64.0, 96.0, 112.0] { let card = egui::Rect::from_min_size(egui::Pos2::ZERO, content_browser_asset_card_size(thumb)); let geometry = AssetCardGeometry::from_card(card, thumb); assert!(geometry.preview.is_finite()); assert!(geometry.image.is_finite()); assert!(geometry.text.is_finite()); assert!(card.contains_rect(geometry.preview)); assert!(geometry.preview.contains_rect(geometry.image)); assert_eq!(geometry.image.width(), geometry.image.height()); assert_eq!(geometry.preview.height(), thumb - 4.0); assert_eq!(geometry.text.height(), 33.0); assert!(geometry.text.top() >= geometry.preview.bottom()); assert!(asset_card_type_keyline_rect(card).top() >= geometry.text.top() + 30.0); } assert_eq!( content_browser_asset_card_size(96.0), egui::vec2(116.0, 136.0) ); assert_eq!(content_browser_asset_card_size(0.0), egui::vec2(68.0, 88.0)); assert_eq!( content_browser_asset_card_size(999.0), egui::vec2(132.0, 152.0) ); } #[test] fn every_asset_category_has_a_distinct_theme_owned_color() { let context = egui::Context::default(); let mut palette = None; let _ = context.run_ui(egui::RawInput::default(), |ui| { palette = Some(design_system::palette(ui)); }); let palette = palette.expect("design palette"); assert_eq!(ContentBrowserAssetTone::None.accent(palette), None); let colors = [ ContentBrowserAssetTone::Primitive, ContentBrowserAssetTone::Light, ContentBrowserAssetTone::Model, ContentBrowserAssetTone::Texture, ContentBrowserAssetTone::Material, ContentBrowserAssetTone::Audio, ContentBrowserAssetTone::Level, ContentBrowserAssetTone::Prefab, ContentBrowserAssetTone::PostProcess, ContentBrowserAssetTone::RenderingProfile, ContentBrowserAssetTone::Shader, ] .map(|tone| tone.accent(palette).expect("asset category color")); for (index, color) in colors.iter().enumerate() { for other in &colors[index + 1..] { assert_ne!(color, other, "asset category colors must remain distinct"); } } } #[test] fn shared_card_keeps_long_unicode_identity_inside_the_reference_rect() { let context = egui::Context::default(); context.set_fonts(crate::fonts::font_definitions()); let mut card_rect = egui::Rect::NOTHING; let _ = context.run_ui( egui::RawInput { screen_rect: Some(egui::Rect::from_min_size( egui::Pos2::ZERO, egui::vec2(400.0, 300.0), )), ..Default::default() }, |ui| { card_rect = content_browser_asset_card( ui, ContentBrowserCardViewModel { label: "metal_office_desk_with_a_very_long_名前", kind: "Model", icon: icons::CUBE, icon_style: PhosphorIconStyle::Regular, thumbnail: ContentBrowserThumbnail::Placeholder, asset_tone: ContentBrowserAssetTone::Model, selected: false, status: ContentBrowserCardStatus::default(), failure_tooltip: None, }, 96.0, ) .rect; }, ); assert_eq!( card_rect.size(), egui::vec2(ASSET_CARD_WIDTH, ASSET_CARD_HEIGHT) ); assert!(card_rect.is_finite()); } #[test] fn details_stack_matches_the_penpot_vertical_contract() { let context = egui::Context::default(); context.set_fonts(crate::fonts::font_definitions()); let mut advances = Vec::new(); let _ = context.run_ui( egui::RawInput { screen_rect: Some(egui::Rect::from_min_size( egui::Pos2::ZERO, egui::vec2(crate::content_browser::DETAILS_WIDTH, 500.0), )), ..Default::default() }, |ui| { ui.spacing_mut().item_spacing = egui::Vec2::ZERO; let mut previous = ui.next_widget_position().y; let _ = content_browser_details_pane_heading(ui, "DETAILS", true); advances.push(ui.next_widget_position().y - previous); previous = ui.next_widget_position().y; let _ = content_browser_details_header( ui, ContentBrowserDetailsHeaderViewModel { label: "metal_office_desk_2k", kind: "Model", icon: icons::CUBE, thumbnail: ContentBrowserThumbnail::Placeholder, badge: Some(("UNTRACKED", ContentBrowserBadgeTone::Neutral)), badge_tooltip: None, unsaved: false, can_locate: false, }, ); advances.push(ui.next_widget_position().y - previous); previous = ui.next_widget_position().y; content_browser_details_section(ui, "IDENTITY"); advances.push(ui.next_widget_position().y - previous); previous = ui.next_widget_position().y; let response = content_browser_detail_path_row( ui, "Path", "assets/Props/Office/metal_office_desk_2k.gltf", ); advances.push(ui.next_widget_position().y - previous); assert!(response.rect.is_finite()); assert!(ui.clip_rect().contains_rect(response.rect)); }, ); assert_eq!( advances, [ DETAILS_PANE_HEADING_HEIGHT, DETAILS_HEADER_HEIGHT, DETAILS_SECTION_HEIGHT, DETAILS_PATH_ROW_HEIGHT, ] ); } #[test] fn details_paths_remain_bounded_at_resized_lane_widths() { for width in [200.0, crate::content_browser::DETAILS_WIDTH, 420.0] { let context = egui::Context::default(); context.set_fonts(crate::fonts::font_definitions()); let mut response_rect = egui::Rect::NOTHING; let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 80.0)); let _ = context.run_ui( egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { response_rect = content_browser_detail_path_row( ui, "Ready", "assets/Furniture/Office/textures/metal_office_desk_arm_2k.ktx2", ) .rect; }, ); assert!(response_rect.is_finite()); assert!(screen.contains_rect(response_rect)); assert_eq!(response_rect.height(), DETAILS_PATH_ROW_HEIGHT); } } }