Blacksite/crates/editor_ui/src/content_browser/body.rs

1197 lines
40 KiB
Rust

//! 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<R>(
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<egui::Color32> {
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<egui::Response> {
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<egui::Response> {
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<egui::Response> {
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<ContentBrowserDetailsAction> {
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<String>,
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<String>,
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<String>,
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);
}
}
}