1197 lines
40 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|