From 106d947c989992cf53ae9df08030f4bb9ba46c8d Mon Sep 17 00:00:00 2001 From: Rbanh Date: Wed, 22 Jul 2026 17:14:07 -0400 Subject: [PATCH] feat(editor): finalize M2 content workspace UX --- .../content-browser-card-layout.yaml | 20 +- .../scenarios/ui-gallery-framework.yaml | 7 +- ...ce_and_import_authoring_2026-07-13.plan.md | 24 +- .gitignore | 1 + Cargo.lock | 1 + Cargo.toml | 2 +- README.md | 22 +- crates/editor/Cargo.toml | 1 + crates/editor/src/assets/thumbnails/cache.rs | 392 +++- .../src/assets/thumbnails/disk_cache.rs | 650 +++++++ crates/editor/src/assets/thumbnails/job.rs | 9 + crates/editor/src/assets/thumbnails/mod.rs | 1 + .../src/assets/thumbnails/sources/gltf.rs | 31 +- crates/editor/src/assets/thumbnails/studio.rs | 102 +- .../asset_documents/document_store/service.rs | 7 + crates/editor/src/ui/asset_browser/panel.rs | 262 ++- .../src/ui/asset_browser/panel/details.rs | 211 ++- .../src/ui/asset_browser/panel/embedded.rs | 4 + .../editor/src/ui/asset_browser/panel/grid.rs | 101 +- .../src/ui/asset_browser/panel/layout.rs | 94 + .../editor/src/ui/asset_browser/panel/list.rs | 50 +- .../src/ui/asset_browser/panel/navigation.rs | 90 +- .../src/ui/asset_browser/panel/tests/a.rs | 20 +- .../src/ui/asset_browser/panel/tests/b.rs | 7 +- .../ui/asset_browser/panel/toolbar_import.rs | 47 +- .../src/ui/asset_browser/panel/utilities.rs | 13 - crates/editor/src/ui/asset_browser/state.rs | 4 + crates/editor/src/ui/asset_card.rs | 168 +- crates/editor/src/ui/dock_tabs.rs | 2 +- crates/editor/src/ui/helpers.rs | 18 - .../src/ui/materials/advanced_preview.rs | 281 --- crates/editor/src/ui/materials/inputs.rs | 657 ------- crates/editor/src/ui/materials/layout.rs | 343 ---- crates/editor/src/ui/materials/panel.rs | 686 ------- .../src/ui/materials/panel/header_actions.rs | 216 --- crates/editor/src/ui/materials/pickers.rs | 411 ----- .../editor/src/ui/materials/texture_inputs.rs | 408 ----- .../editor/src/ui/materials/uv_transform.rs | 131 -- crates/editor/src/ui/mod.rs | 109 +- crates/editor/src/ui/theme.rs | 19 +- crates/editor_ui/src/content_browser.rs | 1578 ++++++++++++----- crates/editor_ui/src/content_browser/body.rs | 1196 +++++++++++++ crates/editor_ui/src/content_browser/types.rs | 234 +++ .../src/design_system/color_picker.rs | 61 +- .../editor_ui/src/design_system/controls.rs | 40 +- crates/editor_ui/src/design_system/mod.rs | 5 +- crates/editor_ui/src/materials/panel.rs | 48 +- .../src/materials/panel/drop_feedback.rs | 412 +++++ crates/editor_ui/src/theme.rs | 35 + crates/ui_gallery/fixtures/theme.json | 15 +- crates/ui_gallery/src/app.rs | 41 +- crates/ui_gallery/src/content_browser.rs | 326 ++++ crates/ui_gallery/src/main.rs | 1 + crates/ui_gallery/src/reference.rs | 29 +- crates/ui_gallery/src/reload.rs | 52 + ...orkspace-and-material-fallback-contract.md | 13 +- .../0049-penpot-led-editor-visual-system.md | 25 + docs/editor/README.md | 4 +- docs/editor/architecture.md | 11 +- docs/editor/content-workspace.md | 62 +- docs/editor/design-system.md | 47 +- .../content-workspace-m2/penpot/README.md | 9 + .../penpot/content-browser-compact-620.png | 3 + .../penpot/content-browser-expanded-1600.png | 3 + .../penpot/content-browser-wide-1240.png | 3 + docs/editor/project-launcher.md | 12 +- scripts/editor/blacksite-editor | 93 +- scripts/editor/build_progress.py | 164 +- scripts/editor/test_build_progress.py | 92 +- 69 files changed, 5893 insertions(+), 4343 deletions(-) create mode 100644 crates/editor/src/assets/thumbnails/disk_cache.rs create mode 100644 crates/editor/src/ui/asset_browser/panel/layout.rs delete mode 100644 crates/editor/src/ui/materials/advanced_preview.rs delete mode 100644 crates/editor/src/ui/materials/inputs.rs delete mode 100644 crates/editor/src/ui/materials/layout.rs delete mode 100644 crates/editor/src/ui/materials/panel.rs delete mode 100644 crates/editor/src/ui/materials/panel/header_actions.rs delete mode 100644 crates/editor/src/ui/materials/pickers.rs delete mode 100644 crates/editor/src/ui/materials/texture_inputs.rs delete mode 100644 crates/editor/src/ui/materials/uv_transform.rs create mode 100644 crates/editor_ui/src/content_browser/body.rs create mode 100644 crates/editor_ui/src/content_browser/types.rs create mode 100644 crates/editor_ui/src/materials/panel/drop_feedback.rs create mode 100644 crates/ui_gallery/src/content_browser.rs create mode 100644 docs/editor/evaluations/content-workspace-m2/penpot/content-browser-compact-620.png create mode 100644 docs/editor/evaluations/content-workspace-m2/penpot/content-browser-expanded-1600.png create mode 100644 docs/editor/evaluations/content-workspace-m2/penpot/content-browser-wide-1240.png diff --git a/.agents/skills/blacksite-native-qa/references/scenarios/content-browser-card-layout.yaml b/.agents/skills/blacksite-native-qa/references/scenarios/content-browser-card-layout.yaml index 9e15714..4f62318 100644 --- a/.agents/skills/blacksite-native-qa/references/scenarios/content-browser-card-layout.yaml +++ b/.agents/skills/blacksite-native-qa/references/scenarios/content-browser-card-layout.yaml @@ -1,20 +1,30 @@ name: content-browser-card-layout -purpose: Verify production-quality grid/list card layout and model-thumbnail identity at wide and narrow browser sizes. -preconditions: The running editor has the project Content Browser available with long asset names and the Poly Haven desk imported. +purpose: Verify that the production Content Browser uses the shared Penpot body presentation at wide and compact sizes. +preconditions: The focused shared-UI checks pass and the project Content Browser contains folders, long asset names, and the Poly Haven desk. build_lane: dev fixture: steps: - - Open the Content Browser grid and navigate to the Office model folder. + - Open the Content Browser grid at the project assets root with Sources and Details visible. + - Confirm folders and assets form one continuous row-major stream under the 56 px content heading. + - Navigate to the Office model folder. - Inspect long Material, Texture, and model names with clean and dirty status states. + - At an expanded width, confirm Name sort and the thumbnail-size slider are visible in the toolbar. + - Sweep the thumbnail-size slider through its minimum, default, and maximum positions. - Resize the Content Browser and Details splitter through narrow and wide layouts. - Switch to list view and compare status markers and column alignment. - Inspect the Poly Haven metal office desk card and one mesh-subasset preview. assertions: - - Grid labels stay inside clipped two-line footers with ellipsis and no status overlap. + - Toolbar, body, and footer form one continuous stack with no unowned horizontal gutter. + - Folders and assets use the same bounded card footprint and assets fill the remaining cells of a folder row. + - Grid cards show one clipped and ellipsized name plus a separate kind line with no status overlap. + - Every thumbnail scale preserves the complete name/kind footer and square image aspect; the minimum card never paints its preview through the footer. + - Sort and thumbnail size are first-class controls at expanded width, remain in overflow below the expanded breakpoint, and never appear twice. + - Every asset category keeps its distinct bottom-card keyline across placeholder, pending, ready, failed, hovered, and selected states; folders remain neutral. - Dirty, processing, and error state use the fixed top-right status rail rather than text over the label. + - Sources labels and Details identity, paths, dependencies, and status remain clipped to their owning panes. - List status markers occupy fixed space and do not shift or truncate neighboring columns unpredictably. - Toolbar, Details splitter, and cards remain usable without clipped controls at narrow sizes. - The office desk and mesh-subasset thumbnails show rendered geometry, never an albedo texture substitute. -evidence: Target-window captures for grid-wide, grid-narrow, list, and desk geometry states plus hashes. +evidence: Target-window captures for root-grid wide, Office-grid wide, compact grid, list, and desk geometry states plus hashes. cleanup: Close only a runner-spawned editor; this scenario does not mutate project fixtures. invalidates: Asset-card, Content Browser layout, status model, or thumbnail-routing changes. diff --git a/.agents/skills/blacksite-native-qa/references/scenarios/ui-gallery-framework.yaml b/.agents/skills/blacksite-native-qa/references/scenarios/ui-gallery-framework.yaml index d700eee..83adec2 100644 --- a/.agents/skills/blacksite-native-qa/references/scenarios/ui-gallery-framework.yaml +++ b/.agents/skills/blacksite-native-qa/references/scenarios/ui-gallery-framework.yaml @@ -7,13 +7,14 @@ binary: ui_gallery fixture: steps: - Launch the standalone Blacksite UI Gallery without a project, scene, renderer, or gameplay runtime. - - Inspect the default Materials 620 side-by-side reference and production component canvases. - - Switch through Materials 420, Renderer 760, and Renderer 480 presets. + - Inspect the default Content Browser 1240 side-by-side reference and production component canvases. + - Switch through Content Browser 620, Materials 620, Materials 420, Renderer 760, and Renderer 480 presets. - Exercise Live, Overlay, and Difference modes and inspect the reported difference statistics. - Switch through direct, inherited, imported, dirty, processing, failed, read-only, and built-in fixtures. assertions: - The gallery opens as one responsive native window without launching the Blacksite editor or game runtime. - - All four exact reference presets render the direct Penpot PNG and shared production component without clipping the gallery chrome. + - All six exact reference presets render the direct Penpot PNG and shared production component without clipping the gallery chrome. + - Content Browser 1240 and Content Browser 620 each report fewer than 15 percent changed pixels in Difference mode. - Side-by-side, overlay, and difference modes remain interactive and Difference reports numeric delta statistics. - Representative material ownership and health fixtures preserve the same shared panel geometry. - Theme and material fixture JSON changes appear without rebuilding or restarting the gallery. diff --git a/.cursor/plans/content_workspace_and_import_authoring_2026-07-13.plan.md b/.cursor/plans/content_workspace_and_import_authoring_2026-07-13.plan.md index 1c02454..4e6a5c8 100644 --- a/.cursor/plans/content_workspace_and_import_authoring_2026-07-13.plan.md +++ b/.cursor/plans/content_workspace_and_import_authoring_2026-07-13.plan.md @@ -100,9 +100,10 @@ loader. - [Implemented 2026-07-14] Typed thumbnail keys separate textures, models, mesh subassets, and source materials. Models always queue geometry rendering and static draw previews request their exact mesh subasset; the Poly Haven metal office desk regression is unit-covered. -- [Implemented 2026-07-15] Thumbnail cache replacement, retry, and catalog invalidation release the - last Egui image registration for each key. Repeated studio renders no longer retain hidden GPU - render targets after the visible cache entry is gone. +- [Implemented 2026-07-19] Typed texture/model/mesh/material previews persist as content-addressed + 256 px artifacts under the managed `.thumbnails` cache. Visual source/dependency hashes and a + renderer version invalidate only affected entries; catalog refresh retains last-good GPU images, + corrupt artifacts self-heal, studio retries are bounded, and manual regeneration evicts one key. - [Implemented 2026-07-14] Asset-keyed authored documents separate live Material/Instance and import-setting edits from explicit source publication. Context Save, Save All, recovery, conflict state, separate Git/processing badges, and revisioned background processing remove @@ -172,6 +173,23 @@ loader. overlay, and Difference modes, all material ownership/health fixtures, runtime token/fixture reload, and a managed last-good rebuild/restart loop with bounded dependency progress. Focused tests, strict lint, and architecture audit pass; paired full-editor evidence remains required. +- [Acceptance-in-progress 2026-07-18] The current Page 03 Content Browser boards supersede the earlier + Asset Browser chrome. Production maps the standalone 36 px title row to the existing dock tab, + owns an 88 px deterministic toolbar, uses 216/290 px wide Sources/Details panes, and exposes + compact Sources/Details drawers alongside preserved Search, Grid/List, Import, New, and overflow. + Engineering verification, paired wide/compact native captures, Sources/Details interaction, and + repeated 1200×760 through 1800×1100 resize stress pass. Exact candidate publication remains + pending. +- [Corrective implementation 2026-07-19; production acceptance pending] Content Browser Gallery + evidence exposed a split renderer: only toolbar/status and item ordering were shared, while the + fixture hand-painted Sources, cards, content heading, and Details. Those body presentations now + live in `editor_ui::content_browser` and are invoked by both Gallery fixtures and production state. + Production also removes the unowned toolbar/body/footer gutter, clips every exact panel region, + uses one bounded name plus kind per card, and ellipsizes Details values. The shared implementation + now also owns the Penpot panel fill/border, 16+6 px grid surface/card inset, card preview and status + rail coordinates, and the fixed Details heading/identity stack; production no longer inserts a + private spacer before Details. The prior Gallery-only pixel score is invalid as production + evidence until a refreshed full-editor capture passes. ## Target Contracts diff --git a/.gitignore b/.gitignore index a869d60..c7a349d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ # Editor/runtime generated session state /assets/levels/.pie_session.scn.ron /assets/.trash/ +/assets/.thumbnails/ /.blacksite/backups/ # Backup, temporary, and log files diff --git a/Cargo.lock b/Cargo.lock index bcdf080..847936c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3242,6 +3242,7 @@ dependencies = [ "game_hot", "gltf", "hot-lib-reloader", + "image", "notify", "polyanya", "protocol", diff --git a/Cargo.toml b/Cargo.toml index 8457eae..d6f1db8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ egui = "0.34.3" eframe = { version = "0.34.1", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] } bevy-inspector-egui = "0.37" egui_dock = { version = "0.19.1", features = ["serde"] } -egui_phosphor_icons = { version = "0.3.1", default-features = false, features = ["bold"] } +egui_phosphor_icons = { version = "0.3.1", default-features = false, features = ["bold", "fill"] } editor_ui = { path = "crates/editor_ui" } material_schema = { path = "crates/material_schema" } transform-gizmo-bevy = "0.9" diff --git a/README.md b/README.md index 6c4c3fd..5653f8e 100644 --- a/README.md +++ b/README.md @@ -89,10 +89,15 @@ cargo run -p editor --bin project_launcher --features dev The installed **Blacksite Editor** desktop entry also exposes **Open Project Browser** from its desktop action menu. In the editor, **File > Switch Project...** uses the guarded Save All / Discard / Cancel shutdown path before opening the same browser; choosing a project starts a fresh editor -process with that root. A normal desktop click launches the current managed development binary -without invoking Cargo. This keeps editor startup deterministic and prevents build-planning work -from consuming memory in the desktop session. When an explicit rebuild is required, run -`~/.local/bin/blacksite-editor --build-only`; that command uses Blacksite's managed Cargo lane. +process with that root. A normal desktop click incrementally builds the editor and project browser +through Blacksite's persistent managed development lane before launching, so saved Rust changes are +always represented by the opened binary. The splash reports exact resolved-crate progress while +Cargo reuses warm artifacts; a build or storage-preflight failure leaves the splash open with an +actionable log. Run `~/.local/bin/blacksite-editor --build-only` to perform the same managed update +without opening the editor. Clicking the shortcut while an editor is open still performs the update +check. If the executable changed, Blacksite protects unsaved work by focusing the running process +and showing an explicit close-and-reopen notification instead of terminating it or pretending that +it has hot-swapped to the new binary. ### Hot reload (gameplay iteration) @@ -211,9 +216,9 @@ and remove only an entire verified disposable lane. See the | Viewport selection/orientation HUD | Identify the primary selection, multi-selection count, overlapping-pick position, camera axes, shading mode, and active render path | | Click Player visualizer in Edit mode | Select or create the authored `PlayerSpawn` (`Player Start`) | | Select Project Sun | Inspect project default lighting; create a scene sun override | -| Asset Browser project/file views | Browse `assets/`, search/filter/sort models, textures, materials, audio clips, levels, and prefabs; switch grid/list; expand model subassets; inspect file details; resize the Details pane by dragging its divider (double-click resets it); use click/Ctrl/Shift multi-selection and item/empty-space context menus; audition audio; drag supported assets/submeshes into the viewport | +| Content Browser project/file views | Browse `assets/` through a dense file-manager workspace: wide layouts keep navigation, breadcrumbs, import/new, search/filter, and view controls in one 55 px row; expanded widths also expose sort and thumbnail scale directly, while compact layouts use an 88 px two-row toolbar with Sources/Details drawers; folders and assets share one continuous card flow; search/filter/sort models, textures, materials, audio clips, levels, and prefabs; switch grid/list; expand model subassets; inspect file details; resize the wide Details pane by dragging its divider (double-click resets it); use click/Ctrl/Shift multi-selection and item/empty-space context menus; audition audio; drag supported assets/submeshes into the viewport | | Drag audio clip into viewport | Create an authored audio source; when an audio source is selected, assign the clip instead | -| Asset Browser context/details actions | Preview affected IDs/reference rewrites, then create folders or Materials in the current folder; rename, duplicate, cut/copy/paste or drag-to-folder; undo an unchanged content move; use **Import Here** or **Import To...** and review every source dependency/final target before committing; review/group loose PBR textures into editable Materials; assign/locate/clear per-slot model materials and resolve preserved reimport orphans; extract editable glTF/GLB/FBX PBR materials, explicitly diff/apply or create-new on re-extraction, and atomically map their source slots; regenerate thumbnails; reimport; place assets/submeshes; or trash and collision-guarded restore complete deletion batches | +| Content Browser context/details actions | Preview affected IDs/reference rewrites, then create folders or Materials in the current folder; rename, duplicate, cut/copy/paste or drag-to-folder; undo an unchanged content move; use **Import Here** or **Import To...** and review every source dependency/final target before committing; review/group loose PBR textures into editable Materials; assign/locate/clear per-slot model materials and resolve preserved reimport orphans; extract editable glTF/GLB/FBX PBR materials, explicitly diff/apply or create-new on re-extraction, and atomically map their source slots; regenerate thumbnails; reimport; place assets/submeshes; or trash and collision-guarded restore complete deletion batches | | Texture Details | Set semantic and sRGB/Linear intent, mip policy, Basis UASTC or uncompressed KTX2 output, size limit, filtering, wrapping, anisotropy, and OpenGL/DirectX normal convention; **Apply & Reprocess** publishes derived runtime data without altering the source image | | Window → Material Library | Search/filter project Materials and Material Instances, inspect scene usage and dependency health, create/edit shared assets, and drag them into the viewport | | Drag Material/Instance onto viewport surface | Preview and assign the exact primitive/static/skinned slot or brush face under the pointer; release commits one undo step, while Escape/right-click/outside restores the preview | @@ -534,6 +539,7 @@ crates/ - [x] Asset-keyed dirty documents use one overlay-first live Material authority and conditional derived processing without pointer-release writes; contextual Save/Save All, recovery, background processing, and live-handle persistence passed native acceptance ([ADR 0047](docs/adr/0047-editor-authored-asset-documents.md)) - [x] Registry-only Inspector dispatch, shared material/asset-card UI, and the architecture debt ratchet are implemented; both Inspector and Content Browser shells are <=500 nonblank lines and pass the selective architecture gate plus native resize/deep-scroll acceptance ([ADR 0048](docs/adr/0048-modular-editor-composition-and-debt-ratchet.md)) - [ ] Penpot-led tokens, Source Sans Pro typography, responsive Inspector/material components, asset/color pickers, and exact current 620/420 px geometry are undergoing final M2 native acceptance ([ADR 0049](docs/adr/0049-penpot-led-editor-visual-system.md)) +- [ ] Penpot-led Content Browser wide/compact composition, dock-header ownership, responsive Sources/Details access, and catalog-processing status pass current Page 03 native comparison and resize stress; exact candidate publication remains pending ([content workspace guide](docs/editor/content-workspace.md), [ADR 0049](docs/adr/0049-penpot-led-editor-visual-system.md)) - [x] File-manager Content Browser selection for assets and folders, Ctrl/Cmd toggle and Shift ranges, batch clipboard/duplicate/drag/trash operations, stable-ID moves, fresh-ID copies with internal-only reference remapping, concrete destructive-reference previews, exact cancel-state restoration, restart-safe explicit ambiguous external-move identity repair, fingerprint-guarded move/create/copy/trash undo, modal-safe content-scoped keyboard shortcuts, exact-name Cut/Paste collision review, safe navigation/filter selection clearing, consistent right-click targeting for files/folders/subassets, current-folder Material creation, and right-click menus on items and empty workspace space ([content workspace guide](docs/editor/content-workspace.md)) - [x] Transactional Content Trash with versioned batch manifests, stable-ID restore, generated model-manifest preservation, collision guards, legacy-batch discovery, and registry/runtime-catalog rollback ([content workspace guide](docs/editor/content-workspace.md)) - [x] Transactional loose-texture PBR grouping with suffix/confidence detection, manual role correction and target merge/split, packed ORM/ARM mapping, and no-overwrite Material publication ([material-system guide](docs/editor/material-system.md)) @@ -557,7 +563,9 @@ crates/ - Crouch lowers the camera and movement speed; the collider stays full-height for stability. - The editor asset browser is filesystem-backed with folder/tree navigation, grid/list views, direct texture thumbnails, offscreen geometry renders for all models and mesh subassets, - material sphere thumbnails, search/filter/sort controls, expandable model + material sphere thumbnails, and a managed content-addressed 256 px disk cache that reuses valid + previews across editor sessions while restoring the last-good image after a restart during + dependency-driven regeneration or source failure. It also provides search/filter/sort controls, expandable model subasset shelves, and a staged details pane. **File -> Import Assets** accepts glTF/GLB and binary **FBX**. FBX import parses and transactionally preserves every safe referenced sibling `textures/` or `.fbm/` file while rejecting traversal and external absolute diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 5432f8f..c4cc414 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -54,3 +54,4 @@ uuid = { version = "1", features = ["v4", "serde"] } walkdir = "2.5" gltf = { version = "1.4", features = ["extras"] } blake3 = "1" +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } diff --git a/crates/editor/src/assets/thumbnails/cache.rs b/crates/editor/src/assets/thumbnails/cache.rs index 836aed6..3634536 100644 --- a/crates/editor/src/assets/thumbnails/cache.rs +++ b/crates/editor/src/assets/thumbnails/cache.rs @@ -8,6 +8,7 @@ use bevy::prelude::*; use bevy_egui::{egui, EguiPrimaryContextPass, EguiTextureHandle, EguiUserTextures}; use egui_phosphor_icons::icons; +use super::disk_cache::{ThumbnailArtifact, ThumbnailDiskCache}; use super::sources::gltf::validate_gltf_dependencies; use super::studio::{model_file_exists, ThumbnailStudio}; use super::ThumbnailJobSource; @@ -28,21 +29,66 @@ struct ThumbnailFailure { retryable: bool, } -#[derive(Resource, Default)] +struct DecodedThumbnail { + image: Image, + artifact: ThumbnailArtifact, + is_current: bool, +} + +#[derive(Resource)] pub struct AssetThumbnailCache { pub texture_ids: HashMap, registered_images: HashMap>, pending: HashMap>, + decoded_pending: HashMap, pub(crate) studio_pending: HashSet, failed: HashMap, + requested_signatures: HashMap, + visual_signatures: HashMap, + validated_epochs: HashMap, + catalog_epoch: u64, prefetched_folder: Option, + disk: ThumbnailDiskCache, +} + +impl Default for AssetThumbnailCache { + fn default() -> Self { + Self::new(std::env::current_dir().unwrap_or_else(|_| ".".into())) + } +} + +impl AssetThumbnailCache { + fn new(project_root: impl Into) -> Self { + Self { + texture_ids: HashMap::new(), + registered_images: HashMap::new(), + pending: HashMap::new(), + decoded_pending: HashMap::new(), + studio_pending: HashSet::new(), + failed: HashMap::new(), + requested_signatures: HashMap::new(), + visual_signatures: HashMap::new(), + validated_epochs: HashMap::new(), + catalog_epoch: 0, + prefetched_folder: None, + disk: ThumbnailDiskCache::new(project_root), + } + } } pub struct AssetThumbnailsPlugin; impl Plugin for AssetThumbnailsPlugin { fn build(&self, app: &mut App) { - app.init_resource::() + let project_root = app + .world() + .get_resource::() + .map(|workspace| std::path::PathBuf::from(&workspace.root)) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let cache = AssetThumbnailCache::new(project_root); + cache.disk.spawn_maintenance(); + app.insert_resource(cache) .add_systems(EguiPrimaryContextPass, register_loaded_thumbnails); } } @@ -59,7 +105,10 @@ impl AssetThumbnailCache { retryable: failure.retryable, }); } - if self.pending.contains_key(key) || self.studio_pending.contains(key) { + if self.pending.contains_key(key) + || self.decoded_pending.contains_key(key) + || self.studio_pending.contains(key) + { return Some(ThumbnailState::Pending); } None @@ -69,46 +118,48 @@ impl AssetThumbnailCache { /// The caller must remove that registration from `EguiUserTextures` after releasing the cache /// borrow. pub fn retry(&mut self, key: &str) -> Option> { + self.disk.evict_key(key); self.failed.remove(key); self.pending.remove(key); + self.decoded_pending.remove(key); self.studio_pending.remove(key); + self.requested_signatures.remove(key); + self.visual_signatures.remove(key); + self.validated_epochs.remove(key); self.texture_ids.remove(key); self.release_registered_image(key) } pub fn request_texture(&mut self, key: String, path: String, asset_server: &AssetServer) { - if matches!( - self.state(&key), - Some(ThumbnailState::Ready | ThumbnailState::Pending) - ) { - return; - } - if self.failed.contains_key(&key) { + if self.request_is_current(&key) { return; } if !self.texture_source_ready(&key, &path) { return; } + let source = ThumbnailJobSource::Texture { + texture_path: path.clone(), + }; + let Some(artifact) = self.prepare_request(&key, &source) else { + return; + }; let handle: Handle = asset_server.load(asset_server_path(&path)); self.pending.insert(key, handle); + self.disk.spawn_texture_persist(artifact, &path); } pub fn request_model(&mut self, key: String, model_path: String, studio: &mut ThumbnailStudio) { - if matches!( - self.state(&key), - Some(ThumbnailState::Ready | ThumbnailState::Pending) - ) { + if self.request_is_current(&key) { return; } - if self.failed.contains_key(&key) { - return; - } - if !self.model_source_ready(&key, &model_path) { return; } - - if studio.enqueue(key.clone(), model_path) { + let source = ThumbnailJobSource::Model { model_path }; + let Some(artifact) = self.prepare_request(&key, &source) else { + return; + }; + if studio.enqueue_source(key.clone(), source, artifact) { self.studio_pending.insert(key); } } @@ -122,11 +173,7 @@ impl AssetThumbnailCache { requires_scene_instance: bool, studio: &mut ThumbnailStudio, ) { - if matches!( - self.state(&key), - Some(ThumbnailState::Ready | ThumbnailState::Pending) - ) || self.failed.contains_key(&key) - { + if self.request_is_current(&key) { return; } if !self.model_source_ready(&key, &model_path) { @@ -141,7 +188,10 @@ impl AssetThumbnailCache { material_label, } }; - if studio.enqueue_source(key.clone(), source) { + let Some(artifact) = self.prepare_request(&key, &source) else { + return; + }; + if studio.enqueue_source(key.clone(), source, artifact) { self.studio_pending.insert(key); } } @@ -153,23 +203,20 @@ impl AssetThumbnailCache { material_label: String, studio: &mut ThumbnailStudio, ) { - if matches!( - self.state(&key), - Some(ThumbnailState::Ready | ThumbnailState::Pending) - ) || self.failed.contains_key(&key) - { + if self.request_is_current(&key) { return; } if !self.source_material_ready(&key, &model_path) { return; } - if studio.enqueue_source( - key.clone(), - ThumbnailJobSource::SourceMaterial { - model_path, - material_label, - }, - ) { + let source = ThumbnailJobSource::SourceMaterial { + model_path, + material_label, + }; + let Some(artifact) = self.prepare_request(&key, &source) else { + return; + }; + if studio.enqueue_source(key.clone(), source, artifact) { self.studio_pending.insert(key); } } @@ -180,11 +227,7 @@ impl AssetThumbnailCache { path: String, studio: &mut ThumbnailStudio, ) { - if matches!( - self.state(&key), - Some(ThumbnailState::Ready | ThumbnailState::Pending) - ) || self.failed.contains_key(&key) - { + if self.request_is_current(&key) { return; } let material = match shared::load_resolved_material_from_path(&path) { @@ -200,24 +243,118 @@ impl AssetThumbnailCache { shared::MaterialInstanceAsset::load_from_path(&path).map(|asset| asset.label) }) .unwrap_or_else(|_| "Material".into()); - if studio.enqueue_source( - key.clone(), - ThumbnailJobSource::MaterialAsset { - label, - material: Box::new(material), - }, - ) { + let source = ThumbnailJobSource::MaterialAsset { + source_path: path, + label, + material: Box::new(material), + }; + let Some(artifact) = self.prepare_request(&key, &source) else { + return; + }; + if studio.enqueue_source(key.clone(), source, artifact) { self.studio_pending.insert(key); } } + fn request_is_current(&self, key: &str) -> bool { + self.validated_epochs.get(key).copied() == Some(self.catalog_epoch) + && (self.texture_ids.contains_key(key) + || self.pending.contains_key(key) + || self.decoded_pending.contains_key(key) + || self.studio_pending.contains(key) + || self.failed.contains_key(key)) + } + + /// Returns an artifact only when generation is required. A valid disk hit is queued for + /// publication on the egui pass; an existing last-good texture remains registered meanwhile. + fn prepare_request( + &mut self, + key: &str, + source: &ThumbnailJobSource, + ) -> Option { + let artifact = match self.disk.artifact_for(key, source) { + Ok(artifact) => artifact, + Err(error) => { + self.mark_studio_failed(key, &error, true); + self.queue_last_good(key, None); + self.validated_epochs + .insert(key.to_string(), self.catalog_epoch); + return None; + } + }; + self.validated_epochs + .insert(key.to_string(), self.catalog_epoch); + + if self.visual_signatures.get(key) == Some(&artifact.signature) + && self.texture_ids.contains_key(key) + { + self.failed.remove(key); + return None; + } + if self.requested_signatures.get(key) == Some(&artifact.signature) + && (self.pending.contains_key(key) + || self.decoded_pending.contains_key(key) + || self.studio_pending.contains(key)) + { + return None; + } + + self.failed.remove(key); + self.requested_signatures + .insert(key.to_string(), artifact.signature.clone()); + match self.disk.load(&artifact) { + Ok(Some(image)) => { + self.decoded_pending.insert( + key.to_string(), + DecodedThumbnail { + image, + artifact, + is_current: true, + }, + ); + None + } + Ok(None) => { + self.queue_last_good(key, Some(&artifact.path)); + Some(artifact) + } + Err(error) => { + warn!("{error}; regenerating {}", source.label()); + self.queue_last_good(key, Some(&artifact.path)); + Some(artifact) + } + } + } + + fn queue_last_good(&mut self, key: &str, excluded_path: Option<&Path>) { + if self.texture_ids.contains_key(key) || self.decoded_pending.contains_key(key) { + return; + } + match self.disk.load_last_good(key, excluded_path) { + Ok(Some((image, artifact))) => { + self.decoded_pending.insert( + key.to_string(), + DecodedThumbnail { + image, + artifact, + is_current: false, + }, + ); + } + Ok(None) => {} + Err(error) => warn!("Could not restore last-good thumbnail for {key}: {error}"), + } + } + fn model_source_ready(&mut self, key: &str, model_path: &str) -> bool { if !model_file_exists(model_path) { self.mark_studio_failed(key, "file not found", false); + self.queue_last_good(key, None); return false; } if let Err(reason) = validate_gltf_dependencies(model_path) { self.mark_studio_failed(key, &reason, false); + self.queue_last_good(key, None); return false; } true @@ -228,6 +365,7 @@ impl AssetThumbnailCache { return true; } self.mark_studio_failed(key, &format!("missing texture: {texture_path}"), false); + self.queue_last_good(key, None); false } @@ -243,6 +381,7 @@ impl AssetThumbnailCache { crate::assets::import::validate_fbx_dependencies(std::path::Path::new(model_path)) { self.mark_studio_failed(key, &reason, false); + self.queue_last_good(key, None); return false; } } @@ -253,16 +392,23 @@ impl AssetThumbnailCache { &mut self, key: &str, image: Handle, + artifact: &ThumbnailArtifact, textures: &mut EguiUserTextures, ) { self.studio_pending.remove(key); self.failed.remove(key); + self.requested_signatures.remove(key); + self.visual_signatures + .insert(key.to_string(), artifact.signature.clone()); self.register_thumbnail(key, image, textures); } pub(crate) fn mark_studio_failed(&mut self, key: &str, reason: &str, retryable: bool) { self.studio_pending.remove(key); self.pending.remove(key); + self.decoded_pending.remove(key); + self.validated_epochs + .insert(key.to_string(), self.catalog_epoch); self.failed.insert( key.to_string(), ThumbnailFailure { @@ -277,8 +423,12 @@ impl AssetThumbnailCache { pub fn invalidate_all(&mut self) -> Vec> { self.texture_ids.clear(); self.pending.clear(); + self.decoded_pending.clear(); self.studio_pending.clear(); self.failed.clear(); + self.requested_signatures.clear(); + self.visual_signatures.clear(); + self.validated_epochs.clear(); self.prefetched_folder = None; self.registered_images .drain() @@ -322,6 +472,7 @@ impl AssetThumbnailCache { pending_keys: self .pending .keys() + .chain(self.decoded_pending.keys()) .chain(self.studio_pending.iter()) .cloned() .collect(), @@ -333,13 +484,40 @@ impl AssetThumbnailCache { .collect(), } } + + /// Catalog refreshes invalidate fingerprints, not usable pixels. This preserves last-good + /// previews while the next visible request checks whether its visual dependencies changed. + fn invalidate_catalog_epoch(&mut self) { + self.catalog_epoch = self.catalog_epoch.wrapping_add(1); + self.pending.clear(); + self.decoded_pending.clear(); + self.studio_pending.clear(); + self.failed.clear(); + self.requested_signatures.clear(); + self.validated_epochs.clear(); + self.prefetched_folder = None; + } } fn register_loaded_thumbnails( asset_server: Res, mut cache: ResMut, mut textures: ResMut, + mut images: ResMut>, ) { + let decoded = cache.decoded_pending.drain().collect::>(); + for (key, decoded) in decoded { + let handle = images.add(decoded.image); + if decoded.is_current { + cache.failed.remove(&key); + cache.requested_signatures.remove(&key); + } + cache + .visual_signatures + .insert(key.clone(), decoded.artifact.signature); + cache.register_thumbnail(&key, handle, &mut textures); + } + let failed: Vec<(String, String)> = cache .pending .iter() @@ -362,6 +540,9 @@ fn register_loaded_thumbnails( for (key, handle) in ready { cache.pending.remove(&key); cache.failed.remove(&key); + if let Some(signature) = cache.requested_signatures.remove(&key) { + cache.visual_signatures.insert(key.clone(), signature); + } cache.register_thumbnail(&key, handle, &mut textures); } } @@ -518,12 +699,9 @@ pub fn retry_thumbnail(world: &mut World, key: &str) { } pub fn invalidate_on_catalog_refresh(world: &mut World) { - let registrations = world.resource_mut::().invalidate_all(); - if let Some(mut textures) = world.get_resource_mut::() { - for registration in registrations { - textures.remove_image(registration); - } - } + world + .resource_mut::() + .invalidate_catalog_epoch(); if !world.contains_resource::() { return; } @@ -542,6 +720,14 @@ pub fn invalidate_on_catalog_refresh(world: &mut World) { mod tests { use super::*; + fn test_artifact(key: &str, signature: impl Into) -> ThumbnailArtifact { + ThumbnailArtifact { + cache_key: key.into(), + signature: signature.into(), + path: std::env::temp_dir().join("blacksite-test-thumbnail.png"), + } + } + #[test] fn missing_fbx_material_dependencies_are_one_stable_non_retryable_state() { let model_path = Path::new(env!("CARGO_MANIFEST_DIR")) @@ -593,7 +779,11 @@ mod tests { .join("../../assets/Furniture/Office/metal_office_desk_2k.gltf") .to_string_lossy() .into_owned(); - let mut cache = AssetThumbnailCache::default(); + let cache_root = std::env::temp_dir().join(format!( + "blacksite-thumbnail-queue-test-{}", + std::process::id() + )); + let mut cache = AssetThumbnailCache::new(cache_root); let mut studio = ThumbnailStudio::for_test(); cache.request_model("model:desk".into(), model_path.clone(), &mut studio); @@ -617,7 +807,12 @@ mod tests { for revision in 0..32 { let image = images.add(Image::default()); let image_id = image.id(); - cache.complete_studio_thumbnail("material:shared", image, &mut textures); + cache.complete_studio_thumbnail( + "material:shared", + image, + &test_artifact("material:shared", revision.to_string()), + &mut textures, + ); assert!(textures.image_id(image_id).is_some(), "revision {revision}"); assert_eq!(cache.registered_images.len(), 1); } @@ -639,8 +834,18 @@ mod tests { let image = images.add(Image::default()); let image_id = image.id(); - cache.complete_studio_thumbnail("first", image.clone(), &mut textures); - cache.complete_studio_thumbnail("second", image, &mut textures); + cache.complete_studio_thumbnail( + "first", + image.clone(), + &test_artifact("first", "one"), + &mut textures, + ); + cache.complete_studio_thumbnail( + "second", + image, + &test_artifact("second", "one"), + &mut textures, + ); assert!(cache.retry("first").is_none()); assert!(textures.image_id(image_id).is_some()); let registration = cache.retry("second").expect("last owner releases image"); @@ -648,4 +853,71 @@ mod tests { textures.remove_image(registration); assert!(textures.image_id(image_id).is_none()); } + + #[test] + fn catalog_refresh_keeps_last_good_registration_and_revalidates_requests() { + let root = std::env::temp_dir().join(format!( + "blacksite-thumbnail-last-good-{}", + std::process::id() + )); + let mut cache = AssetThumbnailCache::new(&root); + let mut textures = EguiUserTextures::default(); + let mut images = Assets::::default(); + let image = images.add(Image::default()); + let image_id = image.id(); + cache.complete_studio_thumbnail( + "material:last-good", + image, + &test_artifact("material:last-good", "old"), + &mut textures, + ); + cache + .validated_epochs + .insert("material:last-good".into(), cache.catalog_epoch); + + cache.invalidate_catalog_epoch(); + + assert!(cache.texture_ids.contains_key("material:last-good")); + assert_eq!(cache.registered_images.len(), 1); + assert_eq!(cache.registered_images["material:last-good"], image_id); + assert!(!cache.request_is_current("material:last-good")); + } + + #[test] + fn changed_revision_queues_generation_while_restoring_disk_last_good() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "blacksite-thumbnail-restart-fallback-{}-{nonce}", + std::process::id(), + )); + let source_path = root.join("assets/source.png"); + std::fs::create_dir_all(source_path.parent().unwrap()).unwrap(); + std::fs::write(&source_path, b"first revision").unwrap(); + let mut cache = AssetThumbnailCache::new(&root); + let source = ThumbnailJobSource::Texture { + texture_path: "assets/source.png".into(), + }; + let previous = cache.disk.artifact_for("texture:source", &source).unwrap(); + super::super::disk_cache::persist_rgba8(&previous, 4, 4, &[32_u8; 4 * 4 * 4]).unwrap(); + + std::fs::write(&source_path, b"changed revision").unwrap(); + let replacement = cache + .prepare_request("texture:source", &source) + .expect("changed revision still needs generation"); + assert_ne!(replacement.signature, previous.signature); + let fallback = cache + .decoded_pending + .get("texture:source") + .expect("last-good preview is queued immediately"); + assert!(!fallback.is_current); + assert_eq!(fallback.artifact, previous); + assert_eq!( + cache.requested_signatures.get("texture:source"), + Some(&replacement.signature) + ); + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/crates/editor/src/assets/thumbnails/disk_cache.rs b/crates/editor/src/assets/thumbnails/disk_cache.rs new file mode 100644 index 0000000..0cf8212 --- /dev/null +++ b/crates/editor/src/assets/thumbnails/disk_cache.rs @@ -0,0 +1,650 @@ +//! Persistent, content-addressed thumbnail artifacts. + +use std::collections::{BTreeSet, HashMap}; +use std::fs; +use std::io::{BufReader, Read}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use bevy::asset::RenderAssetUsages; +use bevy::prelude::Image; +use image::{DynamicImage, ImageFormat}; + +use super::sources::gltf::gltf_dependency_paths; +use super::ThumbnailJobSource; + +pub(crate) const THUMBNAIL_EDGE: u32 = 256; +const THUMBNAIL_CACHE_VERSION: &str = "blacksite-thumbnail-v3"; +const CACHE_DIRECTORY: &str = "assets/.thumbnails/v3"; +const RETAINED_PREVIOUS_GENERATIONS: usize = 1; +const CACHE_SOFT_LIMIT_BYTES: u64 = 512 * 1024 * 1024; +const CACHE_PRUNE_TARGET_BYTES: u64 = 410 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ThumbnailArtifact { + pub(crate) cache_key: String, + pub(crate) signature: String, + pub(crate) path: PathBuf, +} + +#[derive(Debug, Clone)] +pub(crate) struct ThumbnailDiskCache { + project_root: PathBuf, + fingerprints: Arc>>, +} + +#[derive(Debug, Clone)] +struct MemoizedFingerprint { + byte_length: u64, + modified: Option, + digest: blake3::Hash, +} + +impl Default for ThumbnailDiskCache { + fn default() -> Self { + Self::new(std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) + } +} + +impl ThumbnailDiskCache { + pub(crate) fn new(project_root: impl Into) -> Self { + Self { + project_root: project_root.into(), + fingerprints: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub(crate) fn artifact_for( + &self, + cache_key: &str, + source: &ThumbnailJobSource, + ) -> Result { + let mut hasher = blake3::Hasher::new(); + hasher.update(THUMBNAIL_CACHE_VERSION.as_bytes()); + hash_source(self, source, &mut hasher)?; + let signature = hasher.finalize().to_hex().to_string(); + let path = self + .key_directory(cache_key) + .join(format!("{signature}.png")); + Ok(ThumbnailArtifact { + cache_key: cache_key.to_string(), + signature, + path, + }) + } + + /// Fully decodes the small artifact before publication so truncated PNGs never enter Bevy's + /// asset cache as a permanent failed load. + pub(crate) fn load(&self, artifact: &ThumbnailArtifact) -> Result, String> { + if !artifact.path.is_file() { + return Ok(None); + } + let image = match image::open(&artifact.path) { + Ok(image) => image, + Err(error) => { + let _ = fs::remove_file(&artifact.path); + return Err(format!( + "discarded corrupt thumbnail {}: {error}", + artifact.path.display() + )); + } + }; + if image.width() == 0 + || image.height() == 0 + || image.width() > THUMBNAIL_EDGE + || image.height() > THUMBNAIL_EDGE + { + let _ = fs::remove_file(&artifact.path); + return Err(format!( + "discarded invalid thumbnail dimensions {}x{} at {}", + image.width(), + image.height(), + artifact.path.display() + )); + } + Ok(Some(Image::from_dynamic( + image, + true, + RenderAssetUsages::all(), + ))) + } + + /// Loads the newest still-valid generation for this typed key. This lets a restarted editor + /// present the previous preview while a changed revision regenerates, or alongside a broken + /// source diagnostic. Corrupt candidates are removed and the next retained generation is + /// tried before giving up. + pub(crate) fn load_last_good( + &self, + cache_key: &str, + excluded_path: Option<&Path>, + ) -> Result, String> { + let Ok(entries) = fs::read_dir(self.key_directory(cache_key)) else { + return Ok(None); + }; + let mut candidates = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + if excluded_path.is_some_and(|excluded| path.as_path() == excluded) + || path.extension().and_then(|value| value.to_str()) != Some("png") + { + return None; + } + let signature = path.file_stem()?.to_str()?.to_string(); + let modified = entry.metadata().ok()?.modified().ok()?; + Some((modified, signature, path)) + }) + .collect::>(); + candidates.sort_by_key(|(modified, _, _)| std::cmp::Reverse(*modified)); + + let mut last_error = None; + for (_, signature, path) in candidates { + let artifact = ThumbnailArtifact { + cache_key: cache_key.to_string(), + signature, + path, + }; + match self.load(&artifact) { + Ok(Some(image)) => return Ok(Some((image, artifact))), + Ok(None) => {} + Err(error) => last_error = Some(error), + } + } + match last_error { + Some(error) => Err(error), + None => Ok(None), + } + } + + pub(crate) fn spawn_texture_persist(&self, artifact: ThumbnailArtifact, source_path: &str) { + let source = resolve_project_path(&self.project_root, source_path); + std::thread::spawn(move || { + let result = image::open(&source) + .map(|image| image.thumbnail(THUMBNAIL_EDGE, THUMBNAIL_EDGE)) + .map_err(|error| format!("could not decode {}: {error}", source.display())) + .and_then(|image| persist_dynamic_image(&artifact, image)); + if let Err(error) = result { + bevy::log::warn!("Texture thumbnail persistence failed: {error}"); + } + }); + } + + /// Removes abandoned temporary files and trims only the oldest derived artifacts after the + /// cache crosses its soft limit. Maintenance is deliberately off the editor frame path. + pub(crate) fn spawn_maintenance(&self) { + let root = self.project_root.join(CACHE_DIRECTORY); + std::thread::spawn(move || maintain_cache(&root)); + } + + pub(crate) fn evict_key(&self, cache_key: &str) { + let directory = self.key_directory(cache_key); + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + for entry in entries.flatten() { + if entry.path().extension().and_then(|value| value.to_str()) == Some("png") { + let _ = fs::remove_file(entry.path()); + } + } + } + + fn key_directory(&self, cache_key: &str) -> PathBuf { + let key_hash = blake3::hash(cache_key.as_bytes()).to_hex().to_string(); + self.project_root + .join(CACHE_DIRECTORY) + .join(&key_hash[..24]) + } +} + +fn maintain_cache(root: &Path) { + let mut stack = vec![root.to_path_buf()]; + let mut artifacts = Vec::new(); + let mut total = 0_u64; + while let Some(directory) = stack.pop() { + let Ok(entries) = fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(metadata) = entry.metadata() else { + continue; + }; + if metadata.is_dir() { + stack.push(path); + } else if path.extension().and_then(|value| value.to_str()) == Some("tmp") { + let _ = fs::remove_file(path); + } else if path.extension().and_then(|value| value.to_str()) == Some("png") { + total = total.saturating_add(metadata.len()); + artifacts.push((metadata.modified().ok(), metadata.len(), path)); + } + } + } + if total <= CACHE_SOFT_LIMIT_BYTES { + return; + } + artifacts.sort_by_key(|(modified, _, _)| *modified); + for (_, bytes, path) in artifacts { + if total <= CACHE_PRUNE_TARGET_BYTES { + break; + } + if fs::remove_file(path).is_ok() { + total = total.saturating_sub(bytes); + } + } +} + +pub(crate) fn persist_rgba8( + artifact: &ThumbnailArtifact, + width: u32, + height: u32, + rgba: &[u8], +) -> Result<(), String> { + if width == 0 || height == 0 || rgba.len() != width as usize * height as usize * 4 { + return Err(format!( + "invalid thumbnail readback: {width}x{height} with {} bytes", + rgba.len() + )); + } + persist_with(artifact, |temporary| { + image::save_buffer_with_format( + temporary, + rgba, + width, + height, + image::ColorType::Rgba8, + ImageFormat::Png, + ) + .map_err(|error| error.to_string()) + }) +} + +fn persist_dynamic_image(artifact: &ThumbnailArtifact, image: DynamicImage) -> Result<(), String> { + persist_with(artifact, |temporary| { + image + .save_with_format(temporary, ImageFormat::Png) + .map_err(|error| error.to_string()) + }) +} + +fn persist_with( + artifact: &ThumbnailArtifact, + write: impl FnOnce(&Path) -> Result<(), String>, +) -> Result<(), String> { + let parent = artifact + .path + .parent() + .ok_or_else(|| "thumbnail artifact has no parent directory".to_string())?; + fs::create_dir_all(parent) + .map_err(|error| format!("could not create {}: {error}", parent.display()))?; + if artifact.path.is_file() { + return Ok(()); + } + let temporary = parent.join(format!( + ".{}.{}.tmp", + artifact.signature, + std::process::id() + )); + let result = write(&temporary).and_then(|()| { + fs::rename(&temporary, &artifact.path).map_err(|error| { + format!( + "could not publish thumbnail {}: {error}", + artifact.path.display() + ) + }) + }); + if result.is_err() { + let _ = fs::remove_file(&temporary); + return result; + } + prune_generations(parent, &artifact.path); + Ok(()) +} + +fn prune_generations(directory: &Path, current: &Path) { + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + let mut previous = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + if path == current || path.extension().and_then(|value| value.to_str()) != Some("png") { + return None; + } + let modified = entry.metadata().ok()?.modified().ok()?; + Some((modified, path)) + }) + .collect::>(); + previous.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified)); + for (_, path) in previous.into_iter().skip(RETAINED_PREVIOUS_GENERATIONS) { + let _ = fs::remove_file(path); + } +} + +fn hash_source( + cache: &ThumbnailDiskCache, + source: &ThumbnailJobSource, + hasher: &mut blake3::Hasher, +) -> Result<(), String> { + match source { + ThumbnailJobSource::Texture { texture_path } => { + hasher.update(b"texture\0"); + hash_file(cache, texture_path, hasher)?; + } + ThumbnailJobSource::Model { model_path } => { + hasher.update(b"model\0"); + hash_model_bundle(cache, model_path, hasher)?; + } + ThumbnailJobSource::MeshSubAsset { + model_path, + mesh_label, + material_label, + } => { + hasher.update(b"mesh-subasset\0"); + hasher.update(mesh_label.as_bytes()); + hasher.update(b"\0"); + if let Some(material_label) = material_label { + hasher.update(material_label.as_bytes()); + } + hash_model_bundle(cache, model_path, hasher)?; + } + ThumbnailJobSource::SourceMaterial { + model_path, + material_label, + } => { + hasher.update(b"source-material\0"); + hasher.update(material_label.as_bytes()); + hash_model_bundle(cache, model_path, hasher)?; + } + ThumbnailJobSource::MaterialAsset { + source_path, + label: _, + material, + } => { + hasher.update(b"project-material\0"); + hash_file(cache, source_path, hasher)?; + let mut canonical = (**material).clone(); + canonical.material_asset_path = None; + let serialized = ron::ser::to_string(&canonical) + .map_err(|error| format!("could not fingerprint material: {error}"))?; + hasher.update(serialized.as_bytes()); + for dependency in material_dependency_paths(&cache.project_root, &canonical) { + hash_file(cache, &dependency, hasher)?; + } + } + } + Ok(()) +} + +fn hash_model_bundle( + cache: &ThumbnailDiskCache, + model_path: &str, + hasher: &mut blake3::Hasher, +) -> Result<(), String> { + hash_file(cache, model_path, hasher)?; + let resolved_model = resolve_project_path(&cache.project_root, model_path); + let extension = resolved_model + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let dependencies = match extension.as_str() { + "gltf" | "glb" => gltf_dependency_paths(&resolved_model)?, + "fbx" => content_pipeline::inspect_fbx_dependencies(&resolved_model) + .map(|inspection| inspection.resolved_paths)?, + _ => Vec::new(), + }; + for dependency in dependencies { + if dependency.is_file() { + let display = dependency.to_string_lossy(); + hash_file(cache, &display, hasher)?; + } else { + hasher.update(b"missing-dependency\0"); + hash_logical_path(&cache.project_root, &dependency, hasher); + } + } + Ok(()) +} + +fn hash_file( + cache: &ThumbnailDiskCache, + path: &str, + hasher: &mut blake3::Hasher, +) -> Result<(), String> { + let resolved = resolve_project_path(&cache.project_root, path); + hasher.update(b"file\0"); + hash_logical_path(&cache.project_root, &resolved, hasher); + hasher.update(b"\0"); + let metadata = fs::metadata(&resolved) + .map_err(|error| format!("could not fingerprint {}: {error}", resolved.display()))?; + let modified = metadata.modified().ok(); + if let Ok(fingerprints) = cache.fingerprints.lock() { + if let Some(cached) = fingerprints.get(&resolved) { + if cached.byte_length == metadata.len() && cached.modified == modified { + hasher.update(cached.digest.as_bytes()); + return Ok(()); + } + } + } + let file = fs::File::open(&resolved) + .map_err(|error| format!("could not fingerprint {}: {error}", resolved.display()))?; + let mut reader = BufReader::new(file); + let mut file_hasher = blake3::Hasher::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = reader + .read(&mut buffer) + .map_err(|error| format!("could not read {}: {error}", resolved.display()))?; + if count == 0 { + break; + } + file_hasher.update(&buffer[..count]); + } + let digest = file_hasher.finalize(); + hasher.update(digest.as_bytes()); + if let Ok(mut fingerprints) = cache.fingerprints.lock() { + fingerprints.insert( + resolved, + MemoizedFingerprint { + byte_length: metadata.len(), + modified, + digest, + }, + ); + } + Ok(()) +} + +fn hash_logical_path(project_root: &Path, path: &Path, hasher: &mut blake3::Hasher) { + let logical = path.strip_prefix(project_root).unwrap_or(path); + hasher.update(logical.to_string_lossy().replace('\\', "/").as_bytes()); +} + +fn material_dependency_paths( + project_root: &Path, + material: &shared::MaterialDesc, +) -> BTreeSet { + let mut paths = [ + material.base_color_texture.clone(), + material.emissive_texture.clone(), + material.normal_map_texture.clone(), + material.metallic_roughness_texture.clone(), + material.occlusion_texture.clone(), + ] + .into_iter() + .flatten() + .collect::>(); + paths.extend(material.textures.iter().filter_map(|binding| { + binding + .texture + .as_ref() + .and_then(|reference| reference.source_path.clone()) + })); + if let Some(path) = material.shader.shader_path.clone() { + paths.insert(path); + } + if let Some(schema_path) = material.shader.schema_path.clone() { + let resolved = resolve_project_path(project_root, &schema_path); + if let Ok(schema) = shared::ShaderSchemaAsset::load_from_path(&resolved.to_string_lossy()) { + if let Some(wgsl_path) = schema.wgsl_path { + paths.insert(wgsl_path); + } + } + paths.insert(schema_path); + } + paths +} + +fn resolve_project_path(project_root: &Path, path: &str) -> PathBuf { + let path = Path::new(path); + if path.is_absolute() { + path.to_path_buf() + } else { + project_root.join(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn fixture_root(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!( + "blacksite-thumbnail-cache-{name}-{}-{nonce}", + std::process::id() + )) + } + + #[test] + fn unchanged_source_reuses_artifact_and_changed_bytes_invalidate_it() { + let root = fixture_root("fingerprint"); + let source = root.join("assets/textures/source.png"); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + fs::write(&source, b"first").unwrap(); + let cache = ThumbnailDiskCache::new(&root); + let request = ThumbnailJobSource::Texture { + texture_path: "assets/textures/source.png".into(), + }; + let first = cache.artifact_for("texture:source", &request).unwrap(); + let unchanged = cache.artifact_for("texture:source", &request).unwrap(); + assert_eq!(first, unchanged); + + fs::write(&source, b"second").unwrap(); + let changed = cache.artifact_for("texture:source", &request).unwrap(); + assert_ne!(first.signature, changed.signature); + assert_ne!(first.path, changed.path); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn typed_cache_keys_cannot_share_an_artifact_directory() { + let root = fixture_root("typed"); + let source = root.join("assets/shared.bin"); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + fs::write(&source, b"shared").unwrap(); + let cache = ThumbnailDiskCache::new(&root); + let request = ThumbnailJobSource::Texture { + texture_path: "assets/shared.bin".into(), + }; + let texture = cache + .artifact_for("texture:assets/shared.bin", &request) + .unwrap(); + let model = cache + .artifact_for("model:assets/shared.bin", &request) + .unwrap(); + assert_ne!(texture.path.parent(), model.path.parent()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn corrupt_artifact_is_removed_and_reported_as_a_cache_miss() { + let root = fixture_root("corrupt"); + let source = root.join("assets/source.png"); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + fs::write(&source, b"source").unwrap(); + let cache = ThumbnailDiskCache::new(&root); + let request = ThumbnailJobSource::Texture { + texture_path: "assets/source.png".into(), + }; + let artifact = cache.artifact_for("texture:source", &request).unwrap(); + fs::create_dir_all(artifact.path.parent().unwrap()).unwrap(); + fs::write(&artifact.path, b"not a png").unwrap(); + assert!(cache.load(&artifact).is_err()); + assert!(!artifact.path.exists()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn published_artifact_roundtrips_as_a_small_bevy_image() { + let root = fixture_root("roundtrip"); + let source = root.join("assets/source.png"); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + fs::write(&source, b"source").unwrap(); + let cache = ThumbnailDiskCache::new(&root); + let request = ThumbnailJobSource::Texture { + texture_path: "assets/source.png".into(), + }; + let artifact = cache.artifact_for("texture:source", &request).unwrap(); + let pixels = vec![128_u8; 4 * 4 * 4]; + persist_rgba8(&artifact, 4, 4, &pixels).unwrap(); + + let image = cache.load(&artifact).unwrap().unwrap(); + assert_eq!(image.width(), 4); + assert_eq!(image.height(), 4); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn changed_revision_can_restore_the_previous_generation_after_restart() { + let root = fixture_root("last-good"); + let source = root.join("assets/source.png"); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + fs::write(&source, b"first revision").unwrap(); + let cache = ThumbnailDiskCache::new(&root); + let request = ThumbnailJobSource::Texture { + texture_path: "assets/source.png".into(), + }; + let previous = cache.artifact_for("texture:source", &request).unwrap(); + persist_rgba8(&previous, 4, 4, &[64_u8; 4 * 4 * 4]).unwrap(); + + fs::write(&source, b"changed revision").unwrap(); + let changed = cache.artifact_for("texture:source", &request).unwrap(); + assert_ne!(previous.path, changed.path); + let (_, restored) = cache + .load_last_good("texture:source", Some(&changed.path)) + .unwrap() + .expect("previous generation remains available"); + assert_eq!(restored, previous); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn material_texture_bytes_participate_in_visual_signature() { + let root = fixture_root("material-dependency"); + let material_path = root.join("assets/materials/test.material.ron"); + let texture_path = root.join("assets/textures/albedo.png"); + fs::create_dir_all(material_path.parent().unwrap()).unwrap(); + fs::create_dir_all(texture_path.parent().unwrap()).unwrap(); + fs::write(&material_path, b"material").unwrap(); + fs::write(&texture_path, b"first pixels").unwrap(); + let mut material = shared::MaterialDesc::default(); + material.base_color_texture = Some("assets/textures/albedo.png".into()); + let source = ThumbnailJobSource::MaterialAsset { + source_path: "assets/materials/test.material.ron".into(), + label: "Test".into(), + material: Box::new(material), + }; + let cache = ThumbnailDiskCache::new(&root); + let first = cache.artifact_for("material:test", &source).unwrap(); + fs::write(&texture_path, b"changed pixels").unwrap(); + let second = cache.artifact_for("material:test", &source).unwrap(); + assert_ne!(first.signature, second.signature); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/editor/src/assets/thumbnails/job.rs b/crates/editor/src/assets/thumbnails/job.rs index 40196a8..90d8a1d 100644 --- a/crates/editor/src/assets/thumbnails/job.rs +++ b/crates/editor/src/assets/thumbnails/job.rs @@ -2,14 +2,21 @@ use shared::MaterialDesc; +use super::disk_cache::ThumbnailArtifact; + #[derive(Debug, Clone)] pub struct ThumbnailJob { pub cache_key: String, pub source: ThumbnailJobSource, + pub(crate) artifact: ThumbnailArtifact, + pub(crate) attempt: u8, } #[derive(Debug, Clone)] pub enum ThumbnailJobSource { + Texture { + texture_path: String, + }, Model { model_path: String, }, @@ -23,6 +30,7 @@ pub enum ThumbnailJobSource { material_label: String, }, MaterialAsset { + source_path: String, label: String, material: Box, }, @@ -31,6 +39,7 @@ pub enum ThumbnailJobSource { impl ThumbnailJobSource { pub fn label(&self) -> &str { match self { + Self::Texture { texture_path } => texture_path, Self::Model { model_path } | Self::MeshSubAsset { model_path, .. } | Self::SourceMaterial { model_path, .. } => model_path, diff --git a/crates/editor/src/assets/thumbnails/mod.rs b/crates/editor/src/assets/thumbnails/mod.rs index 5ab3ee0..a4f9c62 100644 --- a/crates/editor/src/assets/thumbnails/mod.rs +++ b/crates/editor/src/assets/thumbnails/mod.rs @@ -3,6 +3,7 @@ use bevy::prelude::*; mod cache; +mod disk_cache; mod job; mod sources; mod studio; diff --git a/crates/editor/src/assets/thumbnails/sources/gltf.rs b/crates/editor/src/assets/thumbnails/sources/gltf.rs index 603f5c1..cc06c1b 100644 --- a/crates/editor/src/assets/thumbnails/sources/gltf.rs +++ b/crates/editor/src/assets/thumbnails/sources/gltf.rs @@ -1,7 +1,7 @@ //! glTF/GLB dependency validation and thumbnail source inspection. use std::collections::HashSet; -use std::path::Path; +use std::path::{Path, PathBuf}; /// Verifies local external buffers and images before Bevy starts an async glTF load. pub fn validate_gltf_dependencies(model_asset_path: &str) -> Result<(), String> { @@ -10,6 +10,23 @@ pub fn validate_gltf_dependencies(model_asset_path: &str) -> Result<(), String> return Ok(()); } + for dependency in gltf_dependency_paths(model_path)? { + if !dependency.is_file() { + let display = normalize_asset_path(&dependency) + .unwrap_or_else(|| dependency.to_string_lossy().replace('\\', "/")); + return Err(format!("missing dependency: {display}")); + } + } + Ok(()) +} + +/// Resolves every local external buffer and image used by a glTF. Data URIs and remote URLs are +/// self-contained/non-project dependencies and intentionally do not participate in invalidation. +pub(crate) fn gltf_dependency_paths(model_path: &Path) -> Result, String> { + if !is_gltf_path(model_path) { + return Ok(Vec::new()); + } + let gltf = gltf::Gltf::open(model_path).map_err(|error| format!("invalid glTF: {error}"))?; let mut uris = Vec::new(); for buffer in gltf.buffers() { @@ -24,18 +41,16 @@ pub fn validate_gltf_dependencies(model_asset_path: &str) -> Result<(), String> } let model_dir = model_path.parent().unwrap_or_else(|| Path::new("")); + let mut dependencies = Vec::new(); for uri in uris { if uri.starts_with("data:") || uri.contains("://") { continue; } - let dependency = model_dir.join(uri); - if !dependency.is_file() { - let display = normalize_asset_path(&dependency) - .unwrap_or_else(|| dependency.to_string_lossy().replace('\\', "/")); - return Err(format!("missing dependency: {display}")); - } + dependencies.push(model_dir.join(uri)); } - Ok(()) + dependencies.sort(); + dependencies.dedup(); + Ok(dependencies) } /// Returns primitive labels that cannot be rendered without their source skin hierarchy. diff --git a/crates/editor/src/assets/thumbnails/studio.rs b/crates/editor/src/assets/thumbnails/studio.rs index 684514b..d5580af 100644 --- a/crates/editor/src/assets/thumbnails/studio.rs +++ b/crates/editor/src/assets/thumbnails/studio.rs @@ -9,17 +9,19 @@ use bevy::gltf::GltfAssetLabel; use bevy::math::bounding::Aabb3d; use bevy::mesh::VertexAttributeValues; use bevy::prelude::*; -use bevy::render::render_resource::TextureFormat; +use bevy::render::gpu_readback::{Readback, ReadbackComplete}; +use bevy::render::render_resource::{TextureFormat, TextureUsages}; use bevy_egui::EguiUserTextures; use shared::{material_from_desc, standard_material_asset_path, ModelRef}; use super::cache::AssetThumbnailCache; +use super::disk_cache::{persist_rgba8, ThumbnailArtifact, THUMBNAIL_EDGE}; use super::job::{ThumbnailJob, ThumbnailJobSource}; use super::sources::{source_for_extension, spawn_fbx_mesh_subasset_preview, uses_scene_root}; use crate::assets::asset_server_path; use crate::infra::EditorOnly; -pub(crate) const THUMB_SIZE: u32 = 128; +pub(crate) const THUMB_SIZE: u32 = THUMBNAIL_EDGE; pub(crate) const THUMBNAIL_LAYER: usize = 31; const THUMBNAIL_VERTICAL_FOV: f32 = 40.0_f32.to_radians(); const THUMBNAIL_HALF_VERTICAL_FOV: f32 = THUMBNAIL_VERTICAL_FOV * 0.5; @@ -31,6 +33,7 @@ const FIRST_RENDER_WARMUP_FRAMES: u8 = 120; const RENDER_FRAMES: u8 = 3; const COOLDOWN_FRAMES: u8 = 2; const LOAD_TIMEOUT_FRAMES: u32 = 240; +const MAX_AUTO_RETRIES: u8 = 1; #[derive(Component)] struct ThumbnailStudioCamera; @@ -44,6 +47,11 @@ struct ThumbnailStudioLayer; #[derive(Component)] struct ThumbnailSceneRoot; +#[derive(Component)] +struct ThumbnailReadback { + artifact: ThumbnailArtifact, +} + #[derive(Resource)] pub struct ThumbnailStudio { camera: Entity, @@ -58,6 +66,9 @@ pub struct ThumbnailStudio { struct ActiveModelThumbnail { cache_key: String, label: String, + source: ThumbnailJobSource, + artifact: ThumbnailArtifact, + attempt: u8, render_image: Handle, root: Entity, meshes_ready: bool, @@ -79,6 +90,7 @@ enum ThumbnailFraming { impl ThumbnailFraming { fn for_source(source: &ThumbnailJobSource) -> Self { match source { + ThumbnailJobSource::Texture { .. } => Self::SceneBounds, ThumbnailJobSource::SourceMaterial { .. } | ThumbnailJobSource::MaterialAsset { .. } => Self::MaterialSphere, ThumbnailJobSource::Model { .. } | ThumbnailJobSource::MeshSubAsset { .. } => { @@ -99,11 +111,12 @@ impl Plugin for ThumbnailStudioPlugin { } impl ThumbnailStudio { - pub fn enqueue(&mut self, cache_key: String, model_path: String) -> bool { - self.enqueue_source(cache_key, ThumbnailJobSource::Model { model_path }) - } - - pub fn enqueue_source(&mut self, cache_key: String, source: ThumbnailJobSource) -> bool { + pub(crate) fn enqueue_source( + &mut self, + cache_key: String, + source: ThumbnailJobSource, + artifact: ThumbnailArtifact, + ) -> bool { if self .active .as_ref() @@ -114,7 +127,12 @@ impl ThumbnailStudio { if self.queue.iter().any(|job| job.cache_key == cache_key) { return false; } - self.queue.push_back(ThumbnailJob { cache_key, source }); + self.queue.push_back(ThumbnailJob { + cache_key, + source, + artifact, + attempt: 0, + }); true } @@ -324,8 +342,14 @@ fn process_thumbnail_studio( cache.complete_studio_thumbnail( &active.cache_key, active.render_image.clone(), + &active.artifact, &mut textures, ); + spawn_thumbnail_readback( + &mut commands, + active.render_image.clone(), + active.artifact.clone(), + ); finish_active_thumbnail( &mut commands, &mut studio, @@ -388,6 +412,9 @@ fn process_thumbnail_studio( ActiveModelThumbnail { cache_key: job.cache_key, label, + source: job.source, + artifact: job.artifact, + attempt: job.attempt, render_image, root, meshes_ready: false, @@ -408,6 +435,9 @@ fn process_thumbnail_studio( studio.active = Some(ActiveModelThumbnail { cache_key: job.cache_key, label, + source: job.source, + artifact: job.artifact, + attempt: job.attempt, render_image, root, meshes_ready: false, @@ -430,6 +460,7 @@ fn spawn_thumbnail_job_content( source: &ThumbnailJobSource, ) -> bool { match source { + ThumbnailJobSource::Texture { .. } => false, ThumbnailJobSource::Model { model_path } => { if uses_scene_root(model_path) { commands.entity(root).insert(WorldAssetRoot( @@ -551,7 +582,45 @@ fn labeled_asset_path(path: &str, label: &str) -> String { } fn studio_render_image() -> Image { - Image::new_target_texture(THUMB_SIZE, THUMB_SIZE, TextureFormat::Rgba16Float, None) + let mut image = + Image::new_target_texture(THUMB_SIZE, THUMB_SIZE, TextureFormat::Rgba8UnormSrgb, None); + image.texture_descriptor.usage |= TextureUsages::COPY_SRC; + image +} + +fn spawn_thumbnail_readback( + commands: &mut Commands, + image: Handle, + artifact: ThumbnailArtifact, +) { + commands + .spawn(( + EditorOnly, + ThumbnailReadback { artifact }, + Readback::texture(image), + )) + .observe(persist_thumbnail_readback); +} + +fn persist_thumbnail_readback( + event: On, + readbacks: Query<&ThumbnailReadback>, + mut commands: Commands, +) { + let Ok(readback) = readbacks.get(event.entity) else { + return; + }; + let artifact = readback.artifact.clone(); + let data = event.data.clone(); + commands.entity(event.entity).despawn(); + std::thread::spawn(move || { + if let Err(error) = persist_rgba8(&artifact, THUMB_SIZE, THUMB_SIZE, &data) { + warn!( + "Could not persist thumbnail {}: {error}", + artifact.cache_key + ); + } + }); } fn capture_frames(render_pipeline_warmed: bool) -> u8 { @@ -591,6 +660,21 @@ fn fail_active_thumbnail( reason: &str, retryable: bool, ) { + if retryable && active.attempt < MAX_AUTO_RETRIES { + let next_attempt = active.attempt + 1; + warn!( + "Retrying thumbnail {} after {reason} (attempt {next_attempt})", + active.label + ); + studio.queue.push_back(ThumbnailJob { + cache_key: active.cache_key.clone(), + source: active.source.clone(), + artifact: active.artifact.clone(), + attempt: next_attempt, + }); + finish_active_thumbnail(commands, studio, cameras, camera_targets, active); + return; + } cache.mark_studio_failed(&active.cache_key, reason, retryable); finish_active_thumbnail(commands, studio, cameras, camera_targets, active); } diff --git a/crates/editor/src/project/asset_documents/document_store/service.rs b/crates/editor/src/project/asset_documents/document_store/service.rs index 74179ce..6bbb9b5 100644 --- a/crates/editor/src/project/asset_documents/document_store/service.rs +++ b/crates/editor/src/project/asset_documents/document_store/service.rs @@ -6,6 +6,13 @@ pub struct ContentProcessingQueue { pub(super) active: Option, } +impl ContentProcessingQueue { + /// Lightweight UI snapshot that does not expose processing request internals. + pub fn job_count(&self) -> usize { + self.pending.len() + usize::from(self.active.is_some()) + } +} + #[derive(Debug, Clone)] pub(super) enum ProcessingRequest { Material { diff --git a/crates/editor/src/ui/asset_browser/panel.rs b/crates/editor/src/ui/asset_browser/panel.rs index 5a73b83..b1953b8 100644 --- a/crates/editor/src/ui/asset_browser/panel.rs +++ b/crates/editor/src/ui/asset_browser/panel.rs @@ -20,7 +20,7 @@ use crate::assets::{ AssetSelection, AssetSubAssetKind, EditorAsset, EditorAssetKind, EditorAssets, ExternalMoveRepairChoice, PendingExternalMoveRepair, ASSETS_ROOT, BUILTINS_FOLDER, }; -use crate::project::collaboration::{file_status_indicator_ui, CollaborationState}; +use crate::project::collaboration::{CollaborationState, FileStatusTone}; use crate::scene_io::{SceneIo, SceneIoRequest}; use super::state::{ @@ -50,18 +50,16 @@ use crate::assets::{ use crate::ui::asset_card::{draw_asset_card, draw_asset_status_marker, AssetCardStatus}; use crate::ui::document_status::authored_document_status_ui; use crate::ui::theme::{ - panel_heading, ACCENT, BORDER, ELEVATED_BG, ERROR, SUCCESS, TEXT, TEXT_DIM, TEXT_MUTED, - WARNING, WIDGET_BG, + panel_heading, ACCENT, BORDER, ELEVATED_BG, ERROR, TEXT, TEXT_DIM, WARNING, WIDGET_BG, }; const FOOTER_HEIGHT: f32 = editor_ui::content_browser::STATUS_BAR_HEIGHT; -const TOOLBAR_HEIGHT: f32 = editor_ui::content_browser::TOOLBAR_HEIGHT; const DETAILS_MIN_PANEL_WIDTH: f32 = 980.0; const DETAILS_MIN_WIDTH: f32 = 200.0; const DETAILS_RESIZE_HANDLE_WIDTH: f32 = 10.0; const TREE_MIN_PANEL_WIDTH: f32 = editor_ui::content_browser::COMPACT_BREAKPOINT; -const TREE_MIN_WIDTH: f32 = 180.0; -const TREE_MAX_WIDTH: f32 = 216.0; +const TREE_MIN_WIDTH: f32 = editor_ui::content_browser::SOURCES_WIDTH; +const TREE_MAX_WIDTH: f32 = editor_ui::content_browser::SOURCES_WIDTH; const MIN_CONTENT_WIDTH: f32 = 160.0; const COMPACT_LIST_WIDTH: f32 = 560.0; const PHOSPHOR: &str = "phosphor-regular"; @@ -100,88 +98,6 @@ struct ExtractedMaterialBinding { path: String, } -fn fit_width(ui: &egui::Ui, min: f32, max: f32) -> f32 { - let available = ui.available_width().max(1.0); - available.min(max).max(min.min(available)) -} - -fn icon_text(icon: Icon, size: f32) -> egui::RichText { - egui::RichText::new(icon.as_str()).font(egui::FontId::new( - size, - egui::FontFamily::Name(PHOSPHOR.into()), - )) -} - -fn exact_region( - ui: &mut egui::Ui, - size: egui::Vec2, - layout: egui::Layout, - add_contents: impl FnOnce(&mut egui::Ui) -> R, -) -> R { - let (rect, _response) = ui.allocate_exact_size(size, egui::Sense::hover()); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(layout)); - add_contents(&mut child) -} - -fn exact_region_with_pointer( - ui: &mut egui::Ui, - size: egui::Vec2, - layout: egui::Layout, - add_contents: impl FnOnce(&mut egui::Ui, bool) -> R, -) -> (R, egui::Response) { - let (rect, _response) = ui.allocate_exact_size(size, egui::Sense::hover()); - let contains_pointer = ui - .ctx() - .pointer_hover_pos() - .is_some_and(|position| rect.contains(position)); - let mut child = ui.new_child( - egui::UiBuilder::new() - .max_rect(rect) - .layout(layout) - .sense(egui::Sense::click()), - ); - let result = add_contents(&mut child, contains_pointer); - let response = child.response(); - (result, response) -} - -fn details_width_for_layout(available_width: f32, tree_width: f32, requested: f32) -> f32 { - let separator_budget = if tree_width > 0.0 { 10.0 } else { 0.0 }; - let max_width = (available_width - - tree_width - - separator_budget - - DETAILS_RESIZE_HANDLE_WIDTH - - MIN_CONTENT_WIDTH) - .max(DETAILS_MIN_WIDTH); - requested.clamp(DETAILS_MIN_WIDTH, max_width) -} - -fn details_resize_handle(ui: &mut egui::Ui, height: f32) -> egui::Response { - let rect = egui::Rect::from_min_size( - ui.next_widget_position(), - egui::vec2(DETAILS_RESIZE_HANDLE_WIDTH, height), - ); - let response = ui.interact( - rect, - egui::Id::new("asset_details_resize_handle"), - egui::Sense::drag(), - ); - ui.advance_cursor_after_rect(rect); - let stroke_color = if response.hovered() || response.dragged() { - ACCENT - } else { - BORDER - }; - ui.painter().vline( - rect.center().x, - rect.y_range().shrink(4.0), - egui::Stroke::new(2.0_f32, stroke_color), - ); - response - .on_hover_cursor(egui::CursorIcon::ResizeHorizontal) - .on_hover_text("Drag to resize asset details; double-click to reset") -} - pub fn asset_browser_ui( world: &mut World, ui: &mut egui::Ui, @@ -189,7 +105,9 @@ pub fn asset_browser_ui( ) { let available_width = ui.available_width().max(1.0); let available_height = ui.available_height().max(1.0); - let body_height = (available_height - TOOLBAR_HEIGHT - FOOTER_HEIGHT - 4.0).max(40.0); + let toolbar_height = + editor_ui::content_browser::ContentBrowserChrome::Docked.height(available_width); + let body_height = (available_height - toolbar_height - FOOTER_HEIGHT).max(40.0); let browser_interactions_enabled = !content_browser_modal_active(world); exact_region( @@ -197,12 +115,15 @@ pub fn asset_browser_ui( egui::vec2(available_width, available_height), egui::Layout::top_down(egui::Align::Min), |ui| { + // The Content Browser owns a continuous toolbar/body/footer stack. Default egui item + // spacing creates an unowned horizontal gutter between those exact regions. + ui.spacing_mut().item_spacing = egui::Vec2::ZERO; if !browser_interactions_enabled { ui.disable(); } exact_region( ui, - egui::vec2(available_width, TOOLBAR_HEIGHT), + egui::vec2(available_width, toolbar_height), egui::Layout::left_to_right(egui::Align::Center), |ui| { asset_toolbar(world, ui); @@ -254,23 +175,40 @@ pub fn asset_browser_ui( prefetch_folder_thumbnails(world, ¤t_folder); - let show_tree = available_width >= TREE_MIN_PANEL_WIDTH; - let show_details = - state_snapshot.show_details && available_width >= DETAILS_MIN_PANEL_WIDTH; + let compact = available_width < TREE_MIN_PANEL_WIDTH; + let show_tree = if compact { + state_snapshot.show_sources + } else { + true + }; + let show_details = if compact { + state_snapshot.show_compact_details + } else { + state_snapshot.show_details && available_width >= DETAILS_MIN_PANEL_WIDTH + }; let tree_width = if show_tree { - (available_width * 0.18).clamp(TREE_MIN_WIDTH, TREE_MAX_WIDTH) + TREE_MAX_WIDTH.min((available_width - MIN_CONTENT_WIDTH).max(TREE_MIN_WIDTH)) } else { 0.0 }; let details_width = if show_details { - details_width_for_layout(available_width, tree_width, state_snapshot.details_width) + if compact { + editor_ui::content_browser::DETAILS_WIDTH + .min((available_width - MIN_CONTENT_WIDTH).max(DETAILS_MIN_WIDTH)) + } else { + details_width_for_layout( + available_width, + tree_width, + state_snapshot.details_width, + ) + } } else { 0.0 }; - let separator_budget = (show_tree as u8 as f32) * 10.0 - + (show_details as u8 as f32) * DETAILS_RESIZE_HANDLE_WIDTH; - let content_width = (available_width - tree_width - details_width - separator_budget) - .max(MIN_CONTENT_WIDTH.min(available_width)); + // Penpot separators and the Details resize target overlay their owning panes. They do + // not consume layout width: 216 + 734 + 290 must close exactly at the 1240 px reference. + let content_width = + content_width_for_layout(available_width, tree_width, details_width); exact_region( ui, @@ -283,11 +221,9 @@ pub fn asset_browser_ui( egui::vec2(tree_width, body_height), egui::Layout::top_down(egui::Align::Min), |ui| { - ui.label( - egui::RichText::new("SOURCES") - .small() - .strong() - .color(TEXT_MUTED), + editor_ui::content_browser::content_browser_pane_background(ui); + editor_ui::content_browser::content_browser_pane_heading( + ui, "SOURCES", false, ); egui::ScrollArea::vertical() .id_salt("asset_folder_tree") @@ -307,7 +243,6 @@ pub fn asset_browser_ui( }); }, ); - ui.separator(); } let (visible_order, content_response) = exact_region_with_pointer( @@ -315,7 +250,16 @@ pub fn asset_browser_ui( egui::vec2(content_width, body_height), egui::Layout::top_down(egui::Align::Min), |ui, content_contains_pointer| { - content_header(world, ui, ¤t_folder, &folders, &assets); + editor_ui::content_browser::content_browser_pane_background(ui); + content_header( + world, + ui, + ¤t_folder, + &folders, + &assets, + selection_count, + ); + editor_ui::content_browser::content_browser_grid_surface_background(ui); egui::ScrollArea::vertical() .id_salt("asset_content_area") .max_height(ui.available_height()) @@ -344,16 +288,21 @@ pub fn asset_browser_ui( let thumbnail_size = state_snapshot .thumbnail_size .min((content_width - 26.0).max(48.0)); - asset_grid( - world, + editor_ui::content_browser::content_browser_grid_inset( ui, - selected_entities, - &selected, - &child_folders, - &visible_assets, - &cache_snapshot, - thumbnail_size, - &visible_order, + |ui| { + asset_grid( + world, + ui, + selected_entities, + &selected, + &child_folders, + &visible_assets, + &cache_snapshot, + thumbnail_size, + &visible_order, + ); + }, ); } else { asset_list( @@ -375,27 +324,16 @@ pub fn asset_browser_ui( content_empty_space(world, content_response, ¤t_folder, &visible_order); if show_details { - let resize_response = details_resize_handle(ui, body_height); - let requested_width = if resize_response.double_clicked() { - Some(ASSET_DETAILS_DEFAULT_WIDTH) - } else if resize_response.dragged() { - Some(details_width - resize_response.drag_delta().x) - } else { - None - }; - if let Some(requested_width) = requested_width { - world.resource_mut::().details_width = - details_width_for_layout( - available_width, - tree_width, - requested_width, - ); - } + let details_boundary = ui.next_widget_position(); exact_region( ui, egui::vec2(details_width, body_height), egui::Layout::top_down(egui::Align::Min), |ui| { + editor_ui::content_browser::content_browser_pane_background(ui); + editor_ui::content_browser::content_browser_details_pane_heading( + ui, "DETAILS", true, + ); egui::ScrollArea::vertical() .id_salt("asset_details_area") .max_height(ui.available_height()) @@ -405,6 +343,27 @@ pub fn asset_browser_ui( }); }, ); + if !compact { + // Paint and interact after the Details lane so its background cannot + // cover the overlaid resize affordance. + let resize_response = + details_resize_handle(ui, details_boundary, body_height); + let requested_width = if resize_response.double_clicked() { + Some(ASSET_DETAILS_DEFAULT_WIDTH) + } else if resize_response.dragged() { + Some(details_width - resize_response.drag_delta().x) + } else { + None + }; + if let Some(requested_width) = requested_width { + world.resource_mut::().details_width = + details_width_for_layout( + available_width, + tree_width, + requested_width, + ); + } + } } }, ); @@ -422,17 +381,45 @@ pub fn asset_browser_ui( .iter() .filter(|folder| folder.parent.as_deref() == Some(current_folder.as_str())) .count(); - let summary = if selection_count > 0 { - format!("{selection_count} selected") + let selection_summary = if selection_count == 1 { + selected.as_ref().map_or_else( + || "1 selected".to_string(), + |item| format!("{} selected", item.display_label()), + ) + } else if selection_count > 1 { + format!("{selection_count} assets selected") } else { format!("{direct_folders} folders · {direct_assets} assets") }; + let thumbnail_jobs = world + .resource::() + .snapshot() + .pending_keys + .len(); + let processing_jobs = world + .get_resource::() + .map_or(0, |queue| queue.job_count()); + let processing = if thumbnail_jobs > 0 { + format!("{thumbnail_jobs} thumbnails processing") + } else if processing_jobs > 0 { + format!("{processing_jobs} content jobs active") + } else { + "Background processing idle".to_string() + }; + let healthy = !browser_status.to_ascii_lowercase().contains("failed") + && !browser_status.to_ascii_lowercase().contains("error"); + let catalog = if healthy { + "Catalog ready" + } else { + browser_status.as_str() + }; editor_ui::content_browser::content_browser_status_bar( ui, editor_ui::content_browser::ContentBrowserStatusViewModel { - summary: &summary, - location: ¤t_folder, - status: &browser_status, + catalog, + processing: &processing, + selection: &selection_summary, + healthy, }, ); }, @@ -460,6 +447,8 @@ mod file_operations; mod grid; #[path = "panel/import_settings.rs"] mod import_settings; +#[path = "panel/layout.rs"] +mod layout; #[path = "panel/list.rs"] mod list; #[path = "panel/material_extraction.rs"] @@ -486,6 +475,7 @@ use embedded::*; use file_operations::*; use grid::*; use import_settings::*; +use layout::*; use list::*; pub(crate) use material_extraction::begin_actor_material_extraction; use material_extraction::*; diff --git a/crates/editor/src/ui/asset_browser/panel/details.rs b/crates/editor/src/ui/asset_browser/panel/details.rs index 154aa0e..308303d 100644 --- a/crates/editor/src/ui/asset_browser/panel/details.rs +++ b/crates/editor/src/ui/asset_browser/panel/details.rs @@ -1,10 +1,11 @@ use super::*; pub(super) fn detail_row(ui: &mut egui::Ui, label: &str, value: &str) { - ui.horizontal_wrapped(|ui| { - ui.label(egui::RichText::new(label).color(TEXT_DIM)); - ui.add(egui::Label::new(value).wrap().selectable(false)); - }); + editor_ui::content_browser::content_browser_detail_row(ui, label, value); +} + +pub(super) fn detail_path_row(ui: &mut egui::Ui, label: &str, value: &str) { + editor_ui::content_browser::content_browser_detail_path_row(ui, label, value); } pub(super) fn details_panel( @@ -12,8 +13,6 @@ pub(super) fn details_panel( ui: &mut egui::Ui, selected_entities: &SelectedEntities, ) { - ui.label(panel_heading("Details")); - ui.separator(); let selections = world.resource::().selections.clone(); if selections.len() > 1 { let has_authored_content = !selected_content_paths(world).is_empty(); @@ -61,7 +60,7 @@ pub(super) fn details_panel( .and_then(|name| name.to_str()) .unwrap_or(&path), )); - detail_row(ui, "Path", &path); + detail_path_row(ui, "Path", &path); ui.separator(); if ui.button("Open Folder").clicked() { navigate_content_folder(world, path); @@ -71,7 +70,7 @@ pub(super) fn details_panel( } else if let Some(asset) = world.resource::().selected_asset().cloned() { top_level_asset_details_panel(world, ui, selected_entities, &asset); } else { - ui.label(egui::RichText::new("No asset selected").color(TEXT_DIM)); + editor_ui::content_browser::content_browser_details_empty_state(ui, "No asset selected"); } } @@ -82,9 +81,9 @@ pub(crate) fn top_level_asset_details_panel( asset: &EditorAsset, ) { asset_details_header(world, ui, asset); - ui.add_space(8.0); - detail_row(ui, "Path", asset.path.as_deref().unwrap_or("Built-in")); - detail_row(ui, "Folder", asset.folder_path.as_str()); + editor_ui::content_browser::content_browser_details_section(ui, "IDENTITY"); + detail_path_row(ui, "Path", asset.path.as_deref().unwrap_or("Built-in")); + detail_path_row(ui, "Folder", asset.folder_path.as_str()); if asset.path.is_none() && asset.label == blacksite_surface::DEFAULT_GRID_LABEL { ui.separator(); @@ -101,66 +100,43 @@ pub(crate) fn top_level_asset_details_panel( detail_row(ui, "Asset ID", &record.id.as_string()); if matches!(asset.kind, EditorAssetKind::Model) { let settings = record.model_import(); - ui.separator(); - ui.label(panel_heading("Import Settings")); + editor_ui::content_browser::content_browser_details_section(ui, "IMPORT SETTINGS"); import_settings_editor(world, ui, asset, path, settings); if let Some(manifest) = settings.static_mesh_manifest_path.as_deref() { - detail_row(ui, "Static mesh artifact", manifest); + detail_path_row(ui, "Artifact", manifest); } } if matches!(asset.kind, EditorAssetKind::Texture) { if let Some(settings) = record.texture_import() { - ui.separator(); - ui.label(panel_heading("Texture Properties")); + editor_ui::content_browser::content_browser_details_section( + ui, + "TEXTURE PROPERTIES", + ); texture_import_settings_editor(world, ui, path, &record, settings); } } if !record.dependencies.is_empty() { - ui.separator(); + editor_ui::content_browser::content_browser_details_section(ui, "DEPENDENCIES"); let missing_count = record .dependencies .iter() .filter(|dependency| !dependency_path(dependency).is_file()) .count(); - ui.horizontal_wrapped(|ui| { - ui.label(panel_heading("Dependencies")); - if missing_count > 0 { - let optional = !model_uses_source_materials(&record); - ui.small( - egui::RichText::new(if optional { - format!("{missing_count} missing | override") - } else { - format!("{missing_count} missing") - }) - .color(if optional { - WARNING - } else { - ERROR - }), - ); - } - }); + if missing_count > 0 { + let optional = !model_uses_source_materials(&record); + detail_row( + ui, + "Status", + &if optional { + format!("{missing_count} missing | override") + } else { + format!("{missing_count} missing") + }, + ); + } for dep in &record.dependencies { let exists = dependency_path(dep).is_file(); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(if exists { - icons::CHECK_CIRCLE.as_str() - } else { - icons::WARNING_CIRCLE.as_str() - }) - .font(egui::FontId::new( - 12.0, - egui::FontFamily::Name(PHOSPHOR.into()), - )) - .color(if exists { - SUCCESS - } else { - WARNING - }), - ); - ui.add(egui::Label::new(dep).truncate()).on_hover_text(dep); - }); + detail_path_row(ui, if exists { "Ready" } else { "Missing" }, dep); } } } @@ -178,8 +154,7 @@ pub(crate) fn top_level_asset_details_panel( .unwrap_or("-"), ); if matches!(asset.kind, EditorAssetKind::Material) { - ui.separator(); - ui.label(panel_heading("Material")); + editor_ui::content_browser::content_browser_details_section(ui, "MATERIAL"); crate::ui::design_system::scope(ui, |ui| { if MaterialInstanceAsset::load_from_path(path).is_ok() { material_instance_asset_editor(world, ui, asset, path, true); @@ -227,59 +202,81 @@ pub(crate) fn top_level_asset_details_panel( asset_action_buttons(world, ui, selected_entities, asset); } -pub(super) fn asset_details_header(world: &World, ui: &mut egui::Ui, asset: &EditorAsset) { - let thumbnail = world +pub(super) fn asset_details_header(world: &mut World, ui: &mut egui::Ui, asset: &EditorAsset) { + let cache = world .get_resource::() - .and_then(|cache| cache.snapshot().texture_for(asset)); - ui.horizontal_top(|ui| { - let (preview, _) = ui.allocate_exact_size(egui::vec2(96.0, 96.0), egui::Sense::hover()); - ui.painter().rect( - preview, - 5.0, - WIDGET_BG, - egui::Stroke::new(1.0_f32, BORDER), - egui::StrokeKind::Inside, - ); - if let Some(texture) = thumbnail { - ui.painter().image( - texture, - preview.shrink(4.0), - egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } else { - ui.painter().text( - preview.center(), - egui::Align2::CENTER_CENTER, - kind_icon(&asset.kind).as_str(), - egui::FontId::new(30.0, egui::FontFamily::Name(PHOSPHOR.into())), - TEXT_DIM, - ); - } - ui.vertical(|ui| { - ui.add(egui::Label::new(egui::RichText::new(&asset.label).strong()).truncate()) - .on_hover_text(&asset.label); - ui.small(egui::RichText::new(kind_label(&asset.kind)).color(TEXT_DIM)); - if let (Some(state), Some(path)) = ( - world.get_resource::(), - asset.path.as_deref(), - ) { - let status = state.file_status(Path::new(path)); - let _ = file_status_indicator_ui(ui, &status, Path::new(path)); - } - if let Some(snapshot) = asset.path.as_deref().and_then(|path| { - world - .get_resource::() - .and_then(|store| store.snapshot_for_path(path)) - }) { - if snapshot.dirty { - ui.small(egui::RichText::new("UNSAVED").color(WARNING).strong()); - } - } - ui.add_space(4.0); - ui.small(egui::RichText::new("Asset identity and import settings").color(TEXT_MUTED)); - }); + .map(AssetThumbnailCache::snapshot); + let thumbnail = if let Some(texture) = cache.as_ref().and_then(|cache| cache.texture_for(asset)) + { + editor_ui::content_browser::ContentBrowserThumbnail::Ready(texture) + } else if cache.as_ref().is_some_and(|cache| cache.is_pending(asset)) { + editor_ui::content_browser::ContentBrowserThumbnail::Pending + } else if cache + .as_ref() + .and_then(|cache| cache.failure_reason(asset)) + .is_some() + { + editor_ui::content_browser::ContentBrowserThumbnail::Failed + } else { + editor_ui::content_browser::ContentBrowserThumbnail::Placeholder + }; + let dirty = asset.path.as_deref().is_some_and(|path| { + world + .get_resource::() + .and_then(|store| store.snapshot_for_path(path)) + .is_some_and(|snapshot| snapshot.dirty) }); + let collaboration = asset.path.as_deref().and_then(|path| { + world + .get_resource::() + .map(|state| state.file_status(Path::new(path))) + }); + let collaboration_tooltip = asset.path.as_deref().and_then(|path| { + collaboration + .as_ref() + .map(|status| status.tooltip(Path::new(path))) + }); + let collaboration_indicator = collaboration.as_ref().and_then(|status| status.indicator()); + let badge = if dirty { + Some(( + "UNSAVED", + editor_ui::content_browser::ContentBrowserBadgeTone::Warning, + )) + } else { + collaboration_indicator.as_ref().map(|indicator| { + let tone = match indicator.tone { + FileStatusTone::Neutral => { + editor_ui::content_browser::ContentBrowserBadgeTone::Neutral + } + FileStatusTone::Success => { + editor_ui::content_browser::ContentBrowserBadgeTone::Healthy + } + FileStatusTone::Warning => { + editor_ui::content_browser::ContentBrowserBadgeTone::Warning + } + FileStatusTone::Error => editor_ui::content_browser::ContentBrowserBadgeTone::Error, + }; + (indicator.label, tone) + }) + }; + let action = editor_ui::content_browser::content_browser_details_header( + ui, + editor_ui::content_browser::ContentBrowserDetailsHeaderViewModel { + label: &asset.label, + kind: kind_label(&asset.kind), + icon: kind_icon(&asset.kind), + thumbnail, + badge, + badge_tooltip: collaboration_tooltip.as_deref(), + unsaved: false, + can_locate: asset.path.is_some(), + }, + ); + if action == Some(editor_ui::content_browser::ContentBrowserDetailsAction::Locate) { + if let Some(path) = asset.path.as_deref() { + locate_content_asset(world, path); + } + } } pub(super) fn subasset_details_panel( diff --git a/crates/editor/src/ui/asset_browser/panel/embedded.rs b/crates/editor/src/ui/asset_browser/panel/embedded.rs index d139826..64ac63a 100644 --- a/crates/editor/src/ui/asset_browser/panel/embedded.rs +++ b/crates/editor/src/ui/asset_browser/panel/embedded.rs @@ -8,7 +8,9 @@ pub(super) struct AssetBrowserStateSnapshot { pub(super) kind_filter: AssetKindFilter, pub(super) thumbnail_size: f32, pub(super) recursive: bool, + pub(super) show_sources: bool, pub(super) show_details: bool, + pub(super) show_compact_details: bool, pub(super) details_width: f32, } @@ -20,7 +22,9 @@ pub(super) fn state_snapshot(state: &AssetBrowserUiState) -> AssetBrowserStateSn kind_filter: state.kind_filter, thumbnail_size: state.thumbnail_size, recursive: state.recursive, + show_sources: state.show_sources, show_details: state.show_details, + show_compact_details: state.show_compact_details, details_width: state.details_width, } } diff --git a/crates/editor/src/ui/asset_browser/panel/grid.rs b/crates/editor/src/ui/asset_browser/panel/grid.rs index 7dce05e..fd4c3d4 100644 --- a/crates/editor/src/ui/asset_browser/panel/grid.rs +++ b/crates/editor/src/ui/asset_browser/panel/grid.rs @@ -1,4 +1,5 @@ use super::*; +use editor_ui::content_browser::{continuous_grid_entries, ContentBrowserGridEntry}; pub(super) fn content_empty_space( world: &mut World, @@ -29,57 +30,60 @@ pub(super) fn asset_grid( thumbnail_size: f32, visible_order: &[AssetSelection], ) { - let thumb_size = thumbnail_size.clamp(48.0, 112.0); - let cell_width = thumb_size + 20.0; - let gap = 6.0; + let cell_width = editor_ui::content_browser::content_browser_asset_card_size(thumbnail_size).x; + let gap = editor_ui::content_browser::ASSET_CARD_GAP; let columns = ((ui.available_width() + gap) / (cell_width + gap)) .floor() .max(1.0) as usize; - for folder_row in folders.chunks(columns) { + let entries = continuous_grid_entries(folders.len(), assets.len()); + for entry_row in entries.chunks(columns) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing = egui::vec2(gap, gap); - for folder in folder_row { - let selection = AssetSelection::Folder(folder.path.clone()); - let is_selected = world.resource::().is_selected(&selection); - let response = draw_folder_cell(ui, folder, thumbnail_size, is_selected); - handle_asset_drop_to_folder(world, ui, &response, &folder.path); - if response.secondary_clicked() { - prepare_context_menu_selection(world, selection.clone()); + for entry in entry_row { + if let ContentBrowserGridEntry::Folder(folder_index) = *entry { + let folder = &folders[folder_index]; + let selection = AssetSelection::Folder(folder.path.clone()); + let is_selected = world.resource::().is_selected(&selection); + let response = draw_folder_cell(ui, folder, thumbnail_size, is_selected); + handle_asset_drop_to_folder(world, ui, &response, &folder.path); + if response.secondary_clicked() { + prepare_context_menu_selection(world, selection.clone()); + } + if response.double_clicked() { + navigate_content_folder(world, folder.path.clone()); + } else if response.clicked() { + apply_content_click_selection(world, ui, selection.clone(), visible_order); + } + if response.drag_started() { + world + .resource_mut::() + .start_drag(selection.clone()); + } + response.context_menu(|ui| folder_context_menu(world, ui, folder)); + } else { + let ContentBrowserGridEntry::Asset(asset_index) = *entry else { + unreachable!("folder entries are handled above") + }; + draw_asset_grid_item( + world, + ui, + selected_entities, + selected, + &assets[asset_index], + cache_snapshot, + thumbnail_size, + visible_order, + ); } - if response.double_clicked() { - navigate_content_folder(world, folder.path.clone()); - } else if response.clicked() { - apply_content_click_selection(world, ui, selection.clone(), visible_order); - } - if response.drag_started() { - world - .resource_mut::() - .start_drag(selection.clone()); - } - response.context_menu(|ui| folder_context_menu(world, ui, folder)); - } - }); - } - - for asset_row in assets.chunks(columns) { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing = egui::vec2(gap, gap); - for row in asset_row { - draw_asset_grid_item( - world, - ui, - selected_entities, - selected, - row, - cache_snapshot, - thumbnail_size, - visible_order, - ); } }); - for row in asset_row { + for entry in entry_row { + let ContentBrowserGridEntry::Asset(asset_index) = *entry else { + continue; + }; + let row = &assets[asset_index]; if row.asset.path.as_deref().is_some_and(|path| { world .resource::() @@ -166,19 +170,22 @@ pub(super) fn draw_asset_grid_item( .get_resource::() .and_then(|store| store.snapshot_for_path(path)) }); + let thumbnail_pending = cache_snapshot.is_pending(asset); + let mut card_status = AssetCardStatus::for_document( + document_status.as_ref(), + document_status.as_ref().is_some_and(|status| status.dirty), + failure.is_some(), + ); + card_status.processing |= thumbnail_pending; let response = draw_asset_card( ui, asset, cache_snapshot.texture_for(asset), - cache_snapshot.is_pending(asset), + thumbnail_pending, failure, is_selected, thumbnail_size, - AssetCardStatus::for_document( - document_status.as_ref(), - document_status.as_ref().is_some_and(|status| status.dirty), - failure.is_some(), - ), + card_status, ); if has_children { diff --git a/crates/editor/src/ui/asset_browser/panel/layout.rs b/crates/editor/src/ui/asset_browser/panel/layout.rs new file mode 100644 index 0000000..60e7a03 --- /dev/null +++ b/crates/editor/src/ui/asset_browser/panel/layout.rs @@ -0,0 +1,94 @@ +use super::*; + +pub(super) fn fit_width(ui: &egui::Ui, min: f32, max: f32) -> f32 { + let available = ui.available_width().max(1.0); + available.min(max).max(min.min(available)) +} + +pub(super) fn icon_text(icon: Icon, size: f32) -> egui::RichText { + egui::RichText::new(icon.as_str()).font(egui::FontId::new( + size, + egui::FontFamily::Name(PHOSPHOR.into()), + )) +} + +pub(super) fn exact_region( + ui: &mut egui::Ui, + size: egui::Vec2, + layout: egui::Layout, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + let (rect, _response) = ui.allocate_exact_size(size, egui::Sense::hover()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(layout)); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + add_contents(&mut child) +} + +pub(super) fn exact_region_with_pointer( + ui: &mut egui::Ui, + size: egui::Vec2, + layout: egui::Layout, + add_contents: impl FnOnce(&mut egui::Ui, bool) -> R, +) -> (R, egui::Response) { + let (rect, _response) = ui.allocate_exact_size(size, egui::Sense::hover()); + let contains_pointer = ui + .ctx() + .pointer_hover_pos() + .is_some_and(|position| rect.contains(position)); + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(rect) + .layout(layout) + .sense(egui::Sense::click()), + ); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + let result = add_contents(&mut child, contains_pointer); + let response = child.response(); + (result, response) +} + +pub(super) fn details_width_for_layout( + available_width: f32, + tree_width: f32, + requested: f32, +) -> f32 { + let max_width = (available_width - tree_width - MIN_CONTENT_WIDTH).max(DETAILS_MIN_WIDTH); + requested.clamp(DETAILS_MIN_WIDTH, max_width) +} + +pub(super) fn content_width_for_layout( + available_width: f32, + tree_width: f32, + details_width: f32, +) -> f32 { + (available_width - tree_width - details_width).max(MIN_CONTENT_WIDTH.min(available_width)) +} + +pub(super) fn details_resize_handle( + ui: &mut egui::Ui, + boundary: egui::Pos2, + height: f32, +) -> egui::Response { + let rect = egui::Rect::from_min_size( + egui::pos2(boundary.x - DETAILS_RESIZE_HANDLE_WIDTH * 0.5, boundary.y), + egui::vec2(DETAILS_RESIZE_HANDLE_WIDTH, height), + ); + let response = ui.interact( + rect, + egui::Id::new("asset_details_resize_handle"), + egui::Sense::drag(), + ); + let stroke_color = if response.hovered() || response.dragged() { + ACCENT + } else { + BORDER + }; + ui.painter().vline( + boundary.x, + rect.y_range().shrink(4.0), + egui::Stroke::new(2.0_f32, stroke_color), + ); + response + .on_hover_cursor(egui::CursorIcon::ResizeHorizontal) + .on_hover_text("Drag to resize asset details; double-click to reset") +} diff --git a/crates/editor/src/ui/asset_browser/panel/list.rs b/crates/editor/src/ui/asset_browser/panel/list.rs index 36040f8..f1a40ab 100644 --- a/crates/editor/src/ui/asset_browser/panel/list.rs +++ b/crates/editor/src/ui/asset_browser/panel/list.rs @@ -368,39 +368,19 @@ pub(super) fn draw_folder_cell( thumbnail_size: f32, is_selected: bool, ) -> egui::Response { - let thumb_size = thumbnail_size.clamp(48.0, 112.0); - let cell_size = egui::vec2(thumb_size + 20.0, thumb_size + 30.0); - let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag()); - let fill = if is_selected { - crate::ui::theme::SELECTION_BG_MUTED - } else if response.hovered() { - ELEVATED_BG - } else { - WIDGET_BG - }; - ui.painter().rect( - rect, - 4.0, - fill, - egui::Stroke::new(1.0_f32, if is_selected { ACCENT } else { BORDER }), - egui::StrokeKind::Inside, - ); - ui.painter().text( - rect.center_top() + egui::vec2(0.0, 10.0 + thumb_size * 0.45), - egui::Align2::CENTER_CENTER, - icons::FOLDER_SIMPLE.as_str(), - egui::FontId::new( - (thumb_size * 0.48).max(24.0), - egui::FontFamily::Name("phosphor-regular".into()), - ), - TEXT, - ); - ui.painter().text( - egui::pos2(rect.center().x, rect.max.y - 6.0), - egui::Align2::CENTER_BOTTOM, - folder.name.as_str(), - egui::FontId::new(11.0, egui::FontFamily::Proportional), - TEXT, - ); - response + editor_ui::content_browser::content_browser_asset_card( + ui, + editor_ui::content_browser::ContentBrowserCardViewModel { + label: &folder.name, + kind: "Folder", + icon: icons::FOLDER_OPEN, + icon_style: editor_ui::design_system::controls::PhosphorIconStyle::Fill, + thumbnail: editor_ui::content_browser::ContentBrowserThumbnail::Placeholder, + asset_tone: editor_ui::content_browser::ContentBrowserAssetTone::None, + selected: is_selected, + status: editor_ui::content_browser::ContentBrowserCardStatus::default(), + failure_tooltip: None, + }, + thumbnail_size, + ) } diff --git a/crates/editor/src/ui/asset_browser/panel/navigation.rs b/crates/editor/src/ui/asset_browser/panel/navigation.rs index f3c4226..1f7cf6f 100644 --- a/crates/editor/src/ui/asset_browser/panel/navigation.rs +++ b/crates/editor/src/ui/asset_browser/panel/navigation.rs @@ -6,6 +6,7 @@ pub(super) fn content_header( current_folder: &str, folders: &[FolderSnapshot], assets: &[AssetRow], + selection_count: usize, ) { let folder_label = if current_folder == BUILTINS_FOLDER { "Built-ins" @@ -15,7 +16,6 @@ pub(super) fn content_header( .and_then(|name| name.to_str()) .unwrap_or(current_folder) }; - ui.label(panel_heading(folder_label)); let direct_assets = assets .iter() .filter(|row| row.asset.folder_path == current_folder) @@ -25,13 +25,14 @@ pub(super) fn content_header( .filter(|folder| folder.parent.as_deref() == Some(current_folder)) .count(); let sort = world.resource::().sort; - ui.small( - egui::RichText::new(format!( - "{} items · sorted by {}", - direct_folders + direct_assets, - sort_label(sort) - )) - .color(TEXT_DIM), + editor_ui::content_browser::content_browser_content_header( + ui, + editor_ui::content_browser::ContentBrowserContentHeaderViewModel { + folder: folder_label, + item_count: direct_folders + direct_assets, + sort: sort_label(sort), + selection_count, + }, ); } @@ -100,48 +101,41 @@ pub(super) fn folder_tree_branch( .contains(path); let selected = current_folder == *path; - ui.horizontal(|ui| { - ui.add_space(depth as f32 * 12.0); - if children.is_empty() { - ui.add_sized( - [18.0, 20.0], - egui::Label::new(icon_text(icons::DOT_OUTLINE, 12.0).color(TEXT_DIM)), - ); + let folder_count = world + .resource::() + .assets + .iter() + .filter(|asset| asset.folder_path == *path) + .count(); + let row = editor_ui::content_browser::content_browser_source_row( + ui, + editor_ui::content_browser::ContentBrowserSourceRowViewModel { + id: path, + label: name, + count: folder_count, + depth, + has_children: !children.is_empty(), + expanded, + selected, + }, + ); + if row.disclosure_clicked { + let mut state = world.resource_mut::(); + if expanded { + state.expanded_folders.remove(path); } else { - let icon = if expanded { - icons::CARET_DOWN - } else { - icons::CARET_RIGHT - }; - if ui - .add_sized( - [18.0, 18.0], - egui::Button::new(icon_text(icon, 12.0)).frame(false), - ) - .clicked() - { - let mut state = world.resource_mut::(); - if expanded { - state.expanded_folders.remove(path); - } else { - state.expanded_folders.insert(path.clone()); - } - } + state.expanded_folders.insert(path.clone()); } - ui.label(icon_text(icons::FOLDER, 14.0).color(TEXT_DIM)); - let folder_response = ui.add_sized( - [fit_width(ui, 72.0, f32::INFINITY), 20.0], - egui::Button::selectable(selected, name.as_str()), - ); - if folder_response.clicked() { - navigate_content_folder(world, path.clone()); - } - if folder_response.secondary_clicked() { - prepare_context_menu_selection(world, AssetSelection::Folder(path.clone())); - } - folder_response.context_menu(|ui| folder_context_menu(world, ui, folder)); - handle_asset_drop_to_folder(world, ui, &folder_response, path); - }); + } + let folder_response = row.row; + if folder_response.clicked() { + navigate_content_folder(world, path.clone()); + } + if folder_response.secondary_clicked() { + prepare_context_menu_selection(world, AssetSelection::Folder(path.clone())); + } + folder_response.context_menu(|ui| folder_context_menu(world, ui, folder)); + handle_asset_drop_to_folder(world, ui, &folder_response, path); if expanded { for child in children { diff --git a/crates/editor/src/ui/asset_browser/panel/tests/a.rs b/crates/editor/src/ui/asset_browser/panel/tests/a.rs index 6098b93..2629457 100644 --- a/crates/editor/src/ui/asset_browser/panel/tests/a.rs +++ b/crates/editor/src/ui/asset_browser/panel/tests/a.rs @@ -394,15 +394,27 @@ } #[test] - fn details_width_respects_content_and_pane_minimums() { + fn content_browser_details_width_respects_content_and_pane_minimums() { assert_eq!(details_width_for_layout(1_000.0, 220.0, 100.0), 200.0); - assert_eq!(details_width_for_layout(1_000.0, 220.0, 900.0), 600.0); + assert_eq!(details_width_for_layout(1_000.0, 220.0, 900.0), 620.0); assert_eq!(details_width_for_layout(1_000.0, 220.0, 420.0), 420.0); } #[test] - fn details_width_uses_reclaimed_tree_space() { - assert_eq!(details_width_for_layout(1_000.0, 0.0, 900.0), 830.0); + fn content_browser_details_width_uses_reclaimed_tree_space() { + assert_eq!(details_width_for_layout(1_000.0, 0.0, 900.0), 840.0); + } + + #[test] + fn content_browser_wide_penpot_panes_close_without_separator_budget() { + assert_eq!( + content_width_for_layout( + 1_240.0, + editor_ui::content_browser::SOURCES_WIDTH, + editor_ui::content_browser::DETAILS_WIDTH, + ), + 734.0 + ); } #[test] diff --git a/crates/editor/src/ui/asset_browser/panel/tests/b.rs b/crates/editor/src/ui/asset_browser/panel/tests/b.rs index 49540c7..1c52a57 100644 --- a/crates/editor/src/ui/asset_browser/panel/tests/b.rs +++ b/crates/editor/src/ui/asset_browser/panel/tests/b.rs @@ -129,8 +129,10 @@ #[test] fn content_navigation_history_round_trips_like_a_file_manager() { let mut world = World::new(); - let mut assets = EditorAssets::default(); - assets.current_folder = "assets".into(); + let assets = EditorAssets { + current_folder: "assets".into(), + ..Default::default() + }; world.insert_resource(assets); world.insert_resource(AssetBrowserUiState::default()); @@ -178,7 +180,6 @@ assert!(asset_matches_kind_filter(&audio, AssetKindFilter::Audio)); assert!(!asset_matches_kind_filter(&texture, AssetKindFilter::Audio)); - assert_eq!(kind_filter_label(AssetKindFilter::Audio), "Audio"); } #[test] diff --git a/crates/editor/src/ui/asset_browser/panel/toolbar_import.rs b/crates/editor/src/ui/asset_browser/panel/toolbar_import.rs index 227b497..4bbb224 100644 --- a/crates/editor/src/ui/asset_browser/panel/toolbar_import.rs +++ b/crates/editor/src/ui/asset_browser/panel/toolbar_import.rs @@ -16,7 +16,9 @@ pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) { kind_filter, thumbnail_size, recursive, + show_sources, show_details, + show_compact_details, can_undo, can_paste, can_go_back, @@ -30,21 +32,45 @@ pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) { state.kind_filter, state.thumbnail_size, state.recursive, + state.show_sources, state.show_details, + state.show_compact_details, !state.content_undo.is_empty(), state.clipboard.is_some() && current_folder != BUILTINS_FOLDER, !state.navigation_back.is_empty(), !state.navigation_forward.is_empty(), ) }; + let (direct_folder_count, direct_asset_count) = { + let assets = world.resource::(); + ( + assets + .folders + .iter() + .filter(|folder| folder.parent.as_deref() == Some(current_folder.as_str())) + .count(), + assets + .assets + .iter() + .filter(|asset| asset.folder_path == current_folder) + .count(), + ) + }; + let compact = ui.available_width() < editor_ui::content_browser::COMPACT_BREAKPOINT; let model = editor_ui::content_browser::ContentBrowserToolbarViewModel { + chrome: editor_ui::content_browser::ContentBrowserChrome::Docked, path: content_path_segments(¤t_folder), search, kind_filter: kind_filter.into(), sort: sort.into(), view: view.into(), recursive, - show_details, + show_sources, + show_details: if compact { + show_compact_details + } else { + show_details + }, thumbnail_size, can_go_parent: current_folder.contains('/'), can_go_back, @@ -55,6 +81,8 @@ pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) { can_paste, can_undo, pending_repairs, + direct_folder_count, + direct_asset_count, }; let actions = editor_ui::content_browser::content_browser_toolbar(ui, &model); let mut visible_results_changed = false; @@ -99,8 +127,23 @@ pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) { world.resource_mut::().recursive = recursive; visible_results_changed = true; } + Action::SetShowSources(show) => { + let mut state = world.resource_mut::(); + state.show_sources = show; + if show { + state.show_compact_details = false; + } + } Action::SetShowDetails(show) => { - world.resource_mut::().show_details = show; + let mut state = world.resource_mut::(); + if compact { + state.show_compact_details = show; + if show { + state.show_sources = false; + } + } else { + state.show_details = show; + } } Action::SetThumbnailSize(size) => { world.resource_mut::().thumbnail_size = size; diff --git a/crates/editor/src/ui/asset_browser/panel/utilities.rs b/crates/editor/src/ui/asset_browser/panel/utilities.rs index 5b766f3..cb81738 100644 --- a/crates/editor/src/ui/asset_browser/panel/utilities.rs +++ b/crates/editor/src/ui/asset_browser/panel/utilities.rs @@ -114,19 +114,6 @@ pub(super) fn asset_matches_kind_filter(asset: &EditorAsset, filter: AssetKindFi } } -pub(super) fn kind_filter_label(filter: AssetKindFilter) -> &'static str { - match filter { - AssetKindFilter::All => "All", - AssetKindFilter::Model => "Models", - AssetKindFilter::Texture => "Textures", - AssetKindFilter::Material => "Materials", - AssetKindFilter::Audio => "Audio", - AssetKindFilter::Level => "Levels", - AssetKindFilter::Prefab => "Prefabs", - AssetKindFilter::Builtin => "Built-ins", - } -} - pub(super) fn sort_label(sort: AssetSort) -> &'static str { match sort { AssetSort::Name => "Name", diff --git a/crates/editor/src/ui/asset_browser/state.rs b/crates/editor/src/ui/asset_browser/state.rs index 6ebbdcc..e086950 100644 --- a/crates/editor/src/ui/asset_browser/state.rs +++ b/crates/editor/src/ui/asset_browser/state.rs @@ -43,7 +43,9 @@ pub struct AssetBrowserUiState { pub(crate) kind_filter: AssetKindFilter, pub(crate) thumbnail_size: f32, pub(crate) recursive: bool, + pub(crate) show_sources: bool, pub(crate) show_details: bool, + pub(crate) show_compact_details: bool, /// File-manager navigation history. Content topology operations do not rewrite these stacks. pub(crate) navigation_back: Vec, pub(crate) navigation_forward: Vec, @@ -188,7 +190,9 @@ impl Default for AssetBrowserUiState { kind_filter: AssetKindFilter::All, thumbnail_size: 96.0, recursive: false, + show_sources: false, show_details: true, + show_compact_details: false, navigation_back: Vec::new(), navigation_forward: Vec::new(), details_width: ASSET_DETAILS_DEFAULT_WIDTH, diff --git a/crates/editor/src/ui/asset_card.rs b/crates/editor/src/ui/asset_card.rs index 24d7ef5..66ef228 100644 --- a/crates/editor/src/ui/asset_card.rs +++ b/crates/editor/src/ui/asset_card.rs @@ -3,7 +3,7 @@ use bevy_egui::egui; use egui_phosphor_icons::icons; -use crate::assets::{kind_icon, EditorAsset}; +use crate::assets::{kind_icon, EditorAsset, EditorAssetKind}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct AssetCardStatus { @@ -50,17 +50,54 @@ pub fn draw_asset_card( thumbnail_size: f32, status: AssetCardStatus, ) -> egui::Response { - let response = draw_asset_cell( + let thumbnail = if let Some(texture) = texture_id { + editor_ui::content_browser::ContentBrowserThumbnail::Ready(texture) + } else if pending { + editor_ui::content_browser::ContentBrowserThumbnail::Pending + } else if failure.is_some() { + editor_ui::content_browser::ContentBrowserThumbnail::Failed + } else { + editor_ui::content_browser::ContentBrowserThumbnail::Placeholder + }; + editor_ui::content_browser::content_browser_asset_card( ui, - asset, - texture_id, - pending, - failure, - selected, + editor_ui::content_browser::ContentBrowserCardViewModel { + label: &asset.label, + kind: asset_kind_label(&asset.kind), + icon: kind_icon(&asset.kind), + icon_style: editor_ui::design_system::controls::PhosphorIconStyle::Regular, + thumbnail, + asset_tone: asset_kind_tone(&asset.kind), + selected, + status: editor_ui::content_browser::ContentBrowserCardStatus { + dirty: status.dirty, + processing: status.processing, + error: status.error, + }, + failure_tooltip: failure, + }, thumbnail_size, - ); - draw_asset_status_rail(ui, response.rect, status); - response + ) +} + +fn asset_kind_tone(kind: &EditorAssetKind) -> editor_ui::content_browser::ContentBrowserAssetTone { + use editor_ui::content_browser::ContentBrowserAssetTone; + + match kind { + EditorAssetKind::Primitive(_) => ContentBrowserAssetTone::Primitive, + EditorAssetKind::Light(_) => ContentBrowserAssetTone::Light, + EditorAssetKind::Model => ContentBrowserAssetTone::Model, + EditorAssetKind::Texture => ContentBrowserAssetTone::Texture, + EditorAssetKind::Material => ContentBrowserAssetTone::Material, + EditorAssetKind::AudioClip => ContentBrowserAssetTone::Audio, + EditorAssetKind::Level => ContentBrowserAssetTone::Level, + EditorAssetKind::Prefab => ContentBrowserAssetTone::Prefab, + EditorAssetKind::PostProcessVolume | EditorAssetKind::PostProcessEffect => { + ContentBrowserAssetTone::PostProcess + } + EditorAssetKind::RenderingProfile => ContentBrowserAssetTone::RenderingProfile, + EditorAssetKind::ShaderSchema => ContentBrowserAssetTone::Shader, + } } pub fn draw_asset_status_rail(ui: &egui::Ui, rect: egui::Rect, status: AssetCardStatus) { @@ -124,102 +161,19 @@ pub fn draw_asset_status_marker(ui: &mut egui::Ui, status: AssetCardStatus) { }); } -#[allow(clippy::too_many_arguments)] -fn draw_asset_cell( - ui: &mut egui::Ui, - asset: &EditorAsset, - texture_id: Option, - pending: bool, - failed: Option<&str>, - selected: bool, - thumbnail_size: f32, -) -> egui::Response { - let thumb_size = thumbnail_size.clamp(48.0, 112.0); - let cell_size = egui::vec2(thumb_size + 20.0, thumb_size + 42.0); - let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag()); - let fill = if selected { - super::theme::SELECTION_BG_MUTED - } else if response.hovered() { - super::theme::ELEVATED_BG - } else { - super::theme::WIDGET_BG - }; - ui.painter().rect( - rect, - 4.0, - fill, - egui::Stroke::new( - 1.0_f32, - if selected { - super::theme::ACCENT_HOVER - } else { - super::theme::BORDER - }, - ), - egui::StrokeKind::Inside, - ); - - let thumbnail_rect = egui::Rect::from_min_size( - rect.min + egui::vec2(10.0, 8.0), - egui::vec2(thumb_size, thumb_size), - ); - let painter = ui.painter().with_clip_rect(thumbnail_rect); - if let Some(texture_id) = texture_id { - painter.image( - texture_id, - thumbnail_rect, - egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } else { - let (icon, color) = if pending { - (icons::CIRCLE_NOTCH, super::theme::TEXT_DIM) - } else if failed.is_some() { - (icons::WARNING_CIRCLE, super::theme::TEXT_DIM) - } else { - (kind_icon(&asset.kind), super::theme::TEXT) - }; - painter.text( - thumbnail_rect.center(), - egui::Align2::CENTER_CENTER, - icon.as_str(), - egui::FontId::new(26.0, egui::FontFamily::Name("phosphor-regular".into())), - color, - ); - } - - let label_rect = egui::Rect::from_min_max( - egui::pos2(rect.min.x + 6.0, thumbnail_rect.max.y + 4.0), - egui::pos2(rect.max.x - 6.0, rect.max.y - 4.0), - ); - let label_color = if selected { - super::theme::TEXT_SELECTED - } else { - super::theme::TEXT - }; - let font = egui::FontId::proportional(11.0); - let mut job = - egui::text::LayoutJob::simple(asset.label.clone(), font, label_color, label_rect.width()); - job.halign = egui::Align::Center; - job.wrap.max_rows = 2; - job.wrap.break_anywhere = true; - job.wrap.overflow_character = Some('…'); - let galley = ui.painter().layout_job(job); - let label_painter = ui.painter().with_clip_rect(label_rect); - label_painter.galley( - egui::pos2( - label_rect.center().x - galley.rect.width() * 0.5, - label_rect.center().y - galley.rect.height() * 0.5, - ), - galley.clone(), - label_color, - ); - - if let Some(reason) = failed { - response.on_hover_text(reason) - } else if galley.elided { - response.on_hover_text(&asset.label) - } else { - response +fn asset_kind_label(kind: &EditorAssetKind) -> &'static str { + match kind { + EditorAssetKind::Primitive(_) => "Primitive", + EditorAssetKind::Light(_) => "Light", + EditorAssetKind::Model => "Model", + EditorAssetKind::Texture => "Texture", + EditorAssetKind::Material => "Material", + EditorAssetKind::AudioClip => "Audio Clip", + EditorAssetKind::Level => "Level", + EditorAssetKind::Prefab => "Prefab", + EditorAssetKind::PostProcessVolume => "Post Process Volume", + EditorAssetKind::PostProcessEffect => "Post FX", + EditorAssetKind::RenderingProfile => "Rendering Profile", + EditorAssetKind::ShaderSchema => "Shader Schema", } } diff --git a/crates/editor/src/ui/dock_tabs.rs b/crates/editor/src/ui/dock_tabs.rs index 90225b7..20b0939 100644 --- a/crates/editor/src/ui/dock_tabs.rs +++ b/crates/editor/src/ui/dock_tabs.rs @@ -78,7 +78,7 @@ pub fn tab_label(tab: &EditorTab) -> &'static str { EditorTab::Viewport | EditorTab::GameView => "Viewport", EditorTab::Hierarchy => "Hierarchy", EditorTab::Inspector => "Inspector", - EditorTab::AssetBrowser => "Asset Browser", + EditorTab::AssetBrowser => "Content Browser", EditorTab::MaterialLibrary => "Material Library", EditorTab::Build => "Build", EditorTab::Toolbar => "Toolbar (legacy)", diff --git a/crates/editor/src/ui/helpers.rs b/crates/editor/src/ui/helpers.rs index 5033305..63d2ab9 100644 --- a/crates/editor/src/ui/helpers.rs +++ b/crates/editor/src/ui/helpers.rs @@ -280,24 +280,6 @@ pub fn create_scene_sun_override_from_project_settings(world: &mut World) -> Ent ) } -pub fn asset_label(asset: &EditorAsset) -> String { - let prefix = match asset.kind { - EditorAssetKind::Primitive(_) => "Primitive", - EditorAssetKind::Light(_) => "Light", - EditorAssetKind::Model => "Model", - EditorAssetKind::Texture => "Texture", - EditorAssetKind::Material => "Material", - EditorAssetKind::AudioClip => "Audio Clip", - EditorAssetKind::Level => "Level", - EditorAssetKind::Prefab => "Prefab", - EditorAssetKind::PostProcessVolume => "Post Process Volume", - EditorAssetKind::PostProcessEffect => "Post FX", - EditorAssetKind::RenderingProfile => "Rendering Profile", - EditorAssetKind::ShaderSchema => "Shader Schema", - }; - format!("{prefix}: {}", asset.label) -} - pub fn apply_texture_to_selection( world: &mut World, asset: &EditorAsset, diff --git a/crates/editor/src/ui/materials/advanced_preview.rs b/crates/editor/src/ui/materials/advanced_preview.rs deleted file mode 100644 index 08a5889..0000000 --- a/crates/editor/src/ui/materials/advanced_preview.rs +++ /dev/null @@ -1,281 +0,0 @@ -//! Non-interactive Penpot v2.3.1 renderer-control preview. - -use bevy_egui::egui; -use egui_phosphor_icons::icons; - -use crate::ui::design_system::typography::TypeRole; -use crate::ui::design_system::{self, ADVANCED_COLLAPSED_HEIGHT, ADVANCED_EXPANDED_HEIGHT}; - -const PLANNED_TOOLTIP: &str = "Planned — renderer support pending"; - -pub(super) fn unsupported_advanced_preview(ui: &mut egui::Ui) { - let open_id = ui.make_persistent_id("material_unsupported_advanced_open"); - let reveal_id = open_id.with("reveal_complete_body"); - let mut open = ui - .ctx() - .data_mut(|data| data.get_persisted::(open_id)) - .unwrap_or(false); - let width = ui.available_width().max(1.0); - let mode = super::layout::ResponsiveMaterialLayout::for_width(width); - let height = if open { - if mode == super::layout::ResponsiveMaterialLayout::Transient { - 254.0 - } else { - ADVANCED_EXPANDED_HEIGHT - } - } else { - ADVANCED_COLLAPSED_HEIGHT - }; - let palette = design_system::palette(ui); - let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); - ui.painter().rect( - rect, - 5.0, - palette.section, - egui::Stroke::new(1.0_f32, palette.border), - egui::StrokeKind::Inside, - ); - let header = egui::Rect::from_min_size(rect.min, egui::vec2(rect.width(), 30.0)); - ui.painter().rect_filled(header, 4.0, palette.elevated); - let header_response = ui.interact(header, open_id.with("header"), egui::Sense::click()); - if header_response.clicked() { - open = !open; - ui.ctx().data_mut(|data| data.insert_temp(reveal_id, open)); - } - header_response.on_hover_text(if open { - "Collapse Advanced preview" - } else { - "Expand Advanced preview" - }); - ui.painter().text( - header.left_center() + egui::vec2(12.0, 0.0), - egui::Align2::LEFT_CENTER, - if open { - icons::CARET_DOWN.as_str() - } else { - icons::CARET_RIGHT.as_str() - }, - egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-bold".into())), - palette.text_secondary, - ); - ui.painter().text( - header.left_center() + egui::vec2(30.0, 0.0), - egui::Align2::LEFT_CENTER, - "Advanced", - TypeRole::Section.font(), - palette.text_primary, - ); - if !open && rect.width() >= 300.0 { - ui.painter().text( - header.right_center() - egui::vec2(12.0, 0.0), - egui::Align2::RIGHT_CENTER, - PLANNED_TOOLTIP, - TypeRole::Small.font(), - palette.text_muted, - ); - } - if open { - match mode { - super::layout::ResponsiveMaterialLayout::Reference => reference_controls(ui, rect), - super::layout::ResponsiveMaterialLayout::Compact => { - compact_reference_controls(ui, rect) - } - super::layout::ResponsiveMaterialLayout::Transient => compact_controls(ui, rect), - } - } - let reveal_complete_body = ui - .ctx() - .data_mut(|data| data.get_temp::(reveal_id)) - .unwrap_or(false); - if open && reveal_complete_body && rect.height() > ADVANCED_COLLAPSED_HEIGHT { - ui.scroll_to_rect(rect, Some(egui::Align::BOTTOM)); - ui.ctx().data_mut(|data| data.remove::(reveal_id)); - } - ui.ctx() - .data_mut(|data| data.insert_persisted(open_id, open)); -} - -fn reference_controls(ui: &mut egui::Ui, rect: egui::Rect) { - disabled_field(ui, rect, 28.0, 42.0, "Culling", "Back"); - disabled_field(ui, rect, 28.0, 76.0, "Render Queue", "From Shader"); - disabled_toggle(ui, rect, 328.0, 42.0, "Depth Write", true); - disabled_toggle(ui, rect, 328.0, 76.0, "Receive Shadows", true); - planned_note(ui, rect, 28.0, 124.0); -} - -fn compact_reference_controls(ui: &mut egui::Ui, rect: egui::Rect) { - disabled_field_at(ui, rect, 20.0, 90.0, 90.0, 42.0, "Culling", "Back"); - disabled_field_at( - ui, - rect, - 20.0, - 90.0, - 90.0, - 76.0, - "Render Queue", - "From Shader", - ); - disabled_toggle_at( - ui, - rect, - 196.0, - rect.width() - 38.0, - 42.0, - "Depth Write", - true, - ); - disabled_toggle_at( - ui, - rect, - 196.0, - rect.width() - 38.0, - 76.0, - "Receive Shadows", - true, - ); - planned_note(ui, rect, 20.0, 124.0); -} - -fn compact_controls(ui: &mut egui::Ui, rect: egui::Rect) { - disabled_field(ui, rect, 20.0, 42.0, "Culling", "Back"); - disabled_field(ui, rect, 20.0, 78.0, "Render Queue", "From Shader"); - disabled_toggle(ui, rect, 20.0, 124.0, "Depth Write", true); - disabled_toggle(ui, rect, 20.0, 160.0, "Receive Shadows", true); - planned_note(ui, rect, 20.0, 210.0); -} - -fn disabled_field(ui: &mut egui::Ui, rect: egui::Rect, x: f32, y: f32, label: &str, value: &str) { - disabled_field_at(ui, rect, x, x + 106.0, 150.0, y, label, value); -} - -#[expect( - clippy::too_many_arguments, - reason = "explicit Penpot preview geometry" -)] -fn disabled_field_at( - ui: &mut egui::Ui, - rect: egui::Rect, - label_x: f32, - field_x: f32, - field_width: f32, - y: f32, - label: &str, - value: &str, -) { - let palette = design_system::palette(ui); - ui.painter().text( - rect.min + egui::vec2(label_x, y), - egui::Align2::LEFT_CENTER, - label, - TypeRole::Body.font(), - palette.text_muted, - ); - let field = egui::Rect::from_min_size( - egui::pos2(rect.left() + field_x, rect.top() + y - 7.0), - egui::vec2(field_width.min(rect.width() - field_x - 8.0).max(1.0), 24.0), - ); - paint_disabled_field(ui, field, value); -} - -fn disabled_toggle( - ui: &mut egui::Ui, - rect: egui::Rect, - x: f32, - y: f32, - label: &str, - enabled: bool, -) { - disabled_toggle_at(ui, rect, x, x + 106.0, y, label, enabled); -} - -fn disabled_toggle_at( - ui: &mut egui::Ui, - rect: egui::Rect, - label_x: f32, - toggle_x: f32, - y: f32, - label: &str, - enabled: bool, -) { - let palette = design_system::palette(ui); - ui.painter().text( - rect.min + egui::vec2(label_x, y), - egui::Align2::LEFT_CENTER, - label, - TypeRole::Body.font(), - palette.text_muted, - ); - let toggle = egui::Rect::from_min_size( - egui::pos2(rect.left() + toggle_x, rect.top() + y - 8.0), - egui::vec2(30.0, 16.0), - ); - ui.painter().rect( - toggle, - 8.0, - palette.control, - egui::Stroke::new(1.0_f32, palette.border), - egui::StrokeKind::Inside, - ); - let thumb_x = if enabled { - toggle.right() - 8.0 - } else { - toggle.left() + 8.0 - }; - ui.painter().circle_filled( - egui::pos2(thumb_x, toggle.center().y), - 5.0, - palette.text_muted, - ); - ui.interact( - toggle, - ui.make_persistent_id(("advanced_toggle", label)), - egui::Sense::hover(), - ) - .on_hover_text(PLANNED_TOOLTIP); -} - -fn paint_disabled_field(ui: &mut egui::Ui, rect: egui::Rect, value: &str) { - let palette = design_system::palette(ui); - ui.painter().rect( - rect, - 4.0, - palette.control, - egui::Stroke::new(1.0_f32, palette.border), - egui::StrokeKind::Inside, - ); - ui.painter().text( - rect.left_center() + egui::vec2(10.0, 0.0), - egui::Align2::LEFT_CENTER, - value, - TypeRole::Body.font(), - palette.text_muted, - ); - ui.interact( - rect, - ui.make_persistent_id(("advanced_field", value)), - egui::Sense::hover(), - ) - .on_hover_text(PLANNED_TOOLTIP); -} - -fn planned_note(ui: &egui::Ui, rect: egui::Rect, x: f32, y: f32) { - let palette = design_system::palette(ui); - ui.painter().text( - rect.min + egui::vec2(x, y), - egui::Align2::LEFT_CENTER, - PLANNED_TOOLTIP, - TypeRole::Small.font(), - palette.text_muted, - ); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn penpot_advanced_preview_matches_v231_heights() { - assert_eq!(ADVANCED_COLLAPSED_HEIGHT, 30.0); - assert_eq!(ADVANCED_EXPANDED_HEIGHT, 178.0); - } -} diff --git a/crates/editor/src/ui/materials/inputs.rs b/crates/editor/src/ui/materials/inputs.rs deleted file mode 100644 index 8e8c3ab..0000000 --- a/crates/editor/src/ui/materials/inputs.rs +++ /dev/null @@ -1,657 +0,0 @@ -use super::uv_transform::{material_vec2_value, set_material_vec2_value, uv_transform_section}; -use super::*; - -use crate::ui::design_system; -use crate::ui::design_system::color_picker::{color_control, color_control_with_intensity}; -use crate::ui::design_system::controls::{ - scalar_control, section_frame, section_header, section_header_with_summary, switch, -}; -use crate::ui::design_system::property_grid::PropertyGridGeometry; -use crate::ui::design_system::typography::TypeRole; - -pub(crate) fn material_input_schema_editor( - ui: &mut egui::Ui, - schema: &MaterialInputSchema, - inputs: &mut MaterialInputSet, - texture_assets: &[MaterialTextureCandidate], - drop_candidate: Option<&MaterialTextureCandidate>, -) -> MaterialInputEditorResponse { - material_input_schema_editor_with_inheritance( - ui, - schema, - inputs, - None, - texture_assets, - drop_candidate, - ) -} - -pub(super) fn material_input_schema_editor_with_inheritance( - ui: &mut egui::Ui, - schema: &MaterialInputSchema, - inputs: &mut MaterialInputSet, - inherited: Option<&MaterialInputSet>, - texture_assets: &[MaterialTextureCandidate], - drop_candidate: Option<&MaterialTextureCandidate>, -) -> MaterialInputEditorResponse { - let mut response = MaterialInputEditorResponse::default(); - let has_standard_packed_inputs = ["occlusion", "roughness", "metallic"] - .iter() - .all(|name| schema.inputs.iter().any(|input| input.name == *name)); - let mut groups = schema.groups.clone(); - if groups.is_empty() { - groups.push(shared::MaterialInputGroupDesc { - id: String::new(), - display_name: "Properties".into(), - order: 0, - advanced: false, - }); - } - groups.sort_by_key(|group| group.order); - for group in groups { - let mut properties = schema - .inputs - .iter() - .filter(|property| { - property.group == group.id - && matches!( - property.presentation, - shared::MaterialInputPresentation::Row - ) - }) - .collect::>(); - let companions = schema - .inputs - .iter() - .filter(|property| property.group == group.id) - .filter_map(|property| match &property.presentation { - shared::MaterialInputPresentation::Companion { owner } => { - Some((owner.as_str(), property)) - } - shared::MaterialInputPresentation::Row => None, - }) - .collect::>(); - properties.sort_by_key(|property| property.order); - if properties.is_empty() { - continue; - } - if group.id == "uv_transform" { - material_uv_transform_ui(ui, inputs, inherited); - ui.add_space(8.0); - continue; - } - let open_id = ui.make_persistent_id(("material_input_group_open", &group.id)); - let mut open = ui - .ctx() - .data_mut(|data| data.get_persisted::(open_id)) - .unwrap_or(!group.advanced); - section_frame(ui).show(ui, |ui| { - if group.id == "surface_inputs" { - let summary = format!("{} bindings", properties.len()); - section_header_with_summary(ui, "Inputs", Some(&summary), &mut open); - } else { - section_header(ui, &group.display_name, &mut open); - } - if open { - let rows = material_input_rows( - ui, - &properties, - &companions, - inputs, - texture_assets, - drop_candidate, - ); - response.accepted_drop |= rows.accepted_drop; - response.locate = response.locate.take().or(rows.locate); - response.browse_library |= rows.browse_library; - } - }); - ui.ctx() - .data_mut(|data| data.insert_persisted(open_id, open)); - ui.add_space(8.0); - } - if has_standard_packed_inputs { - super::advanced_preview::unsupported_advanced_preview(ui); - } - response -} - -fn material_uv_transform_ui( - ui: &mut egui::Ui, - inputs: &mut MaterialInputSet, - inherited: Option<&MaterialInputSet>, -) { - let mut offset = material_vec2_value(inputs, "uv_offset", Vec2::ZERO); - let mut tiling = material_vec2_value(inputs, "uv_tiling", Vec2::ONE); - let reset_offset = inherited.map_or(Vec2::ZERO, |inputs| { - material_vec2_value(inputs, "uv_offset", Vec2::ZERO) - }); - let reset_tiling = inherited.map_or(Vec2::ONE, |inputs| { - material_vec2_value(inputs, "uv_tiling", Vec2::ONE) - }); - let original = (offset, tiling); - uv_transform_section(ui, &mut offset, &mut tiling, reset_offset, reset_tiling); - if (offset, tiling) != original { - set_material_vec2_value(inputs, "uv_offset", offset); - set_material_vec2_value(inputs, "uv_tiling", tiling); - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum PackedMapMode { - Arm, - Separate, -} - -pub(super) fn packed_map_mode(inputs: &MaterialInputSet) -> PackedMapMode { - let channels = [ - ("occlusion", TextureChannel::R), - ("roughness", TextureChannel::G), - ("metallic", TextureChannel::B), - ]; - if channels.iter().all(|(name, channel)| { - inputs - .texture(name) - .is_some_and(|binding| binding.channel == *channel) - }) { - PackedMapMode::Arm - } else { - PackedMapMode::Separate - } -} - -pub(super) fn apply_arm_texture_preset(inputs: &mut MaterialInputSet) { - let shared_texture = ["occlusion", "roughness", "metallic"] - .iter() - .find_map(|name| { - inputs - .texture(name) - .and_then(|binding| binding.texture.clone()) - }); - for (name, channel) in [ - ("occlusion", TextureChannel::R), - ("roughness", TextureChannel::G), - ("metallic", TextureChannel::B), - ] { - let binding = ensure_material_texture_binding(&mut inputs.textures, name); - binding.channel = channel; - if let Some(texture) = shared_texture.clone() { - binding.texture = Some(texture); - } - } -} - -pub(super) fn apply_separate_texture_preset(inputs: &mut MaterialInputSet) { - for name in ["occlusion", "roughness", "metallic"] { - ensure_material_texture_binding(&mut inputs.textures, name).channel = TextureChannel::R; - } -} - -pub(super) fn material_input_rows( - ui: &mut egui::Ui, - properties: &[&MaterialInputDesc], - companions: &[(&str, &MaterialInputDesc)], - inputs: &mut MaterialInputSet, - texture_assets: &[MaterialTextureCandidate], - drop_candidate: Option<&MaterialTextureCandidate>, -) -> MaterialInputEditorResponse { - let palette = design_system::palette(ui); - let mut response = MaterialInputEditorResponse::default(); - let geometry = PropertyGridGeometry::for_width(ui.available_width()); - // Property rows are a contiguous Penpot table. Default egui item spacing otherwise adds four - // pixels between every nominally 31 px row and breaks the reference geometry. - ui.spacing_mut().item_spacing.y = 0.0; - for (row, property) in properties.iter().enumerate() { - let companion = companions - .iter() - .find_map(|(owner, companion)| (*owner == property.name).then_some(*companion)); - ui.push_id(("material_input_row", &property.name), |ui| { - let width = ui.available_width().max(1.0); - let (row_rect, _) = ui - .allocate_exact_size(egui::vec2(width, geometry.row_height), egui::Sense::hover()); - ui.painter().rect_filled( - row_rect, - 0.0, - if row.is_multiple_of(2) { - palette.row_even - } else { - palette.row_odd - }, - ); - let rects = geometry.row_rects(row_rect); - rect_ui(ui, rects.label, |ui| material_input_label(ui, property)); - let is_color = matches!(property.property_type, ShaderPropertyType::Color); - rect_ui( - ui, - if is_color { - rects.color_value - } else { - rects.value - }, - |ui| material_input_value_ui(ui, property, companion, inputs), - ); - if !is_color { - rect_ui(ui, rects.channel, |ui| { - material_input_channel_ui(ui, property, inputs) - }); - } - if material_input_uses_texture(property) { - let texture = rect_ui(ui, rects.texture_group, |ui| { - material_input_texture_ui( - ui, - property, - inputs, - texture_assets, - drop_candidate, - rects.inline_texture_actions, - ) - }); - response.accepted_drop |= texture.accepted_drop; - response.locate = response.locate.take().or(texture.locate); - response.browse_library |= texture.browse_library; - } - }); - } - response -} - -fn rect_ui( - ui: &mut egui::Ui, - rect: egui::Rect, - add_contents: impl FnOnce(&mut egui::Ui) -> R, -) -> R { - ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { - ui.set_clip_rect(rect.intersect(ui.clip_rect())); - add_contents(ui) - }) - .inner -} - -pub(super) fn material_input_label(ui: &mut egui::Ui, property: &MaterialInputDesc) { - let palette = design_system::palette(ui); - ui.add( - egui::Label::new( - TypeRole::Body - .text(&property.display_name) - .color(palette.text_secondary), - ) - .truncate(), - ) - .on_hover_text(&property.tooltip); -} - -pub(super) fn material_input_uses_texture(property: &MaterialInputDesc) -> bool { - property.texture.is_some() || matches!(property.property_type, ShaderPropertyType::Texture) -} - -pub(super) fn material_input_value_ui( - ui: &mut egui::Ui, - property: &MaterialInputDesc, - companion: Option<&MaterialInputDesc>, - inputs: &mut MaterialInputSet, -) { - let MaterialInputSet { values, .. } = inputs; - ui.horizontal(|ui| { - if matches!(property.property_type, ShaderPropertyType::Texture) { - ui.label(egui::RichText::new("Texture").small().color(TEXT_DIM)); - } else { - let default = property - .default_value - .clone() - .unwrap_or_else(|| default_value_for_shader_property(&property.property_type)); - let mut value = values - .iter() - .find(|value| value.name == property.name) - .map(|parameter| parameter.value.clone()) - .unwrap_or(default); - if !parameter_value_matches_property(&value, &property.property_type) { - value = default_value_for_shader_property(&property.property_type); - } - let original = value.clone(); - if matches!(property.property_type, ShaderPropertyType::Color) - && companion.is_some_and(|input| input.name == "emissive_intensity") - { - let companion = companion.expect("checked companion"); - let companion_default = companion - .default_value - .clone() - .unwrap_or(MaterialParameterValue::Float(0.0)); - let mut companion_value = values - .iter() - .find(|value| value.name == companion.name) - .map(|parameter| parameter.value.clone()) - .unwrap_or(companion_default); - if !parameter_value_matches_property(&companion_value, &companion.property_type) { - companion_value = default_value_for_shader_property(&companion.property_type); - } - let original_companion = companion_value.clone(); - if let ( - MaterialParameterValue::Color(color), - MaterialParameterValue::Float(intensity), - ) = (&mut value, &mut companion_value) - { - let mut rgba = [color.r, color.g, color.b, color.a]; - let picker = color_control_with_intensity( - ui, - "material_emissive_color", - &property.display_name, - &mut rgba, - intensity, - ); - if picker.changed { - *color = ColorDesc { - r: rgba[0], - g: rgba[1], - b: rgba[2], - a: rgba[3], - }; - } - } - if value != original { - upsert_material_parameter(values, &property.name, value); - } - if companion_value != original_companion { - upsert_material_parameter(values, &companion.name, companion_value); - } - } else { - material_parameter_control_ui( - ui, - &property.display_name, - &property.property_type, - &mut value, - ); - if value != original { - upsert_material_parameter(values, &property.name, value); - } - } - } - }); -} - -fn upsert_material_parameter( - values: &mut Vec, - name: &str, - value: MaterialParameterValue, -) { - if let Some(parameter) = values.iter_mut().find(|parameter| parameter.name == name) { - parameter.value = value; - } else { - values.push(MaterialParameter { - name: name.to_string(), - value, - }); - } -} - -fn material_input_channel_ui( - ui: &mut egui::Ui, - property: &MaterialInputDesc, - inputs: &mut MaterialInputSet, -) { - let palette = design_system::palette(ui); - if property - .texture - .as_ref() - .is_some_and(|texture| texture.allow_channel_override) - { - let original = material_property_texture_binding(&inputs.textures, property); - let mut binding = original.clone(); - material_texture_channel_ui(ui, property, &mut binding); - if binding != original { - upsert_material_texture_binding(&mut inputs.textures, binding); - } - } else { - let channel = property.texture.as_ref().map(|texture| { - if matches!(texture.semantic, shared::TextureSemantic::Color) { - "RGB" - } else { - texture_channel_label(texture.default_channel) - } - }); - if let Some(channel) = channel { - readonly_channel(ui, channel); - } else { - ui.label(TypeRole::Body.text("—").color(palette.text_muted)); - } - } -} - -pub(super) fn material_input_texture_ui( - ui: &mut egui::Ui, - property: &MaterialInputDesc, - inputs: &mut MaterialInputSet, - texture_assets: &[MaterialTextureCandidate], - drop_candidate: Option<&MaterialTextureCandidate>, - inline_actions: bool, -) -> TextureSlotResponse { - if !material_input_uses_texture(property) { - return TextureSlotResponse::default(); - } - let original = material_property_texture_binding(&inputs.textures, property); - let mut binding = original.clone(); - let response = texture_binding_ui( - ui, - &mut binding, - texture_assets, - drop_candidate, - inline_actions, - ); - if binding != original { - upsert_material_texture_binding(&mut inputs.textures, binding); - } - response -} - -pub(super) fn material_parameter_control_ui( - ui: &mut egui::Ui, - label: &str, - property_type: &ShaderPropertyType, - value: &mut MaterialParameterValue, -) { - if !parameter_value_matches_property(value, property_type) { - *value = default_value_for_shader_property(property_type); - } - match value { - MaterialParameterValue::Bool(value) => { - switch(ui, value); - } - MaterialParameterValue::Float(value) => { - let (min, max) = match property_type { - ShaderPropertyType::Float { min, max } => (*min, *max), - _ => (None, None), - }; - match (min, max) { - (Some(min), Some(max)) => { - scalar_control(ui, label, value, min..=max, usize::from(max <= 1_000.0) * 2); - } - _ => { - ui.add(egui::DragValue::new(value).speed(0.01)); - } - } - } - MaterialParameterValue::Vec2(value) => { - ui.add(egui::DragValue::new(&mut value.x).speed(0.01).prefix("X ")); - ui.add(egui::DragValue::new(&mut value.y).speed(0.01).prefix("Y ")); - } - MaterialParameterValue::Vec3(value) => { - ui.add(egui::DragValue::new(&mut value.x).speed(0.01).prefix("X ")); - ui.add(egui::DragValue::new(&mut value.y).speed(0.01).prefix("Y ")); - ui.add(egui::DragValue::new(&mut value.z).speed(0.01).prefix("Z ")); - } - MaterialParameterValue::Color(value) => { - let mut rgba = [value.r, value.g, value.b, value.a]; - let response = color_control(ui, "material_color", label, &mut rgba); - if response.changed { - *value = ColorDesc { - r: rgba[0], - g: rgba[1], - b: rgba[2], - a: rgba[3], - }; - } - } - MaterialParameterValue::Enum(value) => { - let options = match property_type { - ShaderPropertyType::Enum { options } => options.as_slice(), - _ => &[], - }; - egui::ComboBox::from_id_salt(("material_enum", value.as_str())) - .selected_text(value.as_str()) - .show_ui(ui, |ui| { - for option in options { - ui.selectable_value(value, option.clone(), option); - } - }); - } - } -} - -fn readonly_channel(ui: &mut egui::Ui, channel: &str) { - let palette = design_system::palette(ui); - let (rect, response) = ui.allocate_exact_size(egui::vec2(48.0, 22.0), egui::Sense::hover()); - ui.painter().rect( - rect, - 4.0, - palette.control, - egui::Stroke::new(1.0_f32, palette.border), - egui::StrokeKind::Inside, - ); - ui.painter().text( - rect.left_center() + egui::vec2(9.0, 0.0), - egui::Align2::LEFT_CENTER, - channel, - TypeRole::Body.font(), - palette.text_secondary, - ); - ui.painter().text( - rect.right_center() - egui::vec2(8.0, 0.0), - egui::Align2::CENTER_CENTER, - egui_phosphor_icons::icons::CARET_DOWN.as_str(), - egui::FontId::new(9.0, egui::FontFamily::Name("phosphor-bold".into())), - palette.text_muted, - ); - response.on_hover_text("Channel is fixed by this material input"); -} - -pub(super) fn default_value_for_shader_property( - property_type: &ShaderPropertyType, -) -> MaterialParameterValue { - match property_type { - ShaderPropertyType::Bool => MaterialParameterValue::Bool(false), - ShaderPropertyType::Float { .. } => MaterialParameterValue::Float(0.0), - ShaderPropertyType::Vec2 => MaterialParameterValue::Vec2(Vec2::ZERO), - ShaderPropertyType::Vec3 => MaterialParameterValue::Vec3(Vec3::ZERO), - ShaderPropertyType::Color => MaterialParameterValue::Color(ColorDesc::default()), - ShaderPropertyType::Enum { options } => { - MaterialParameterValue::Enum(options.first().cloned().unwrap_or_default()) - } - ShaderPropertyType::Texture => MaterialParameterValue::Float(0.0), - } -} - -pub(super) fn parameter_value_matches_property( - value: &MaterialParameterValue, - property_type: &ShaderPropertyType, -) -> bool { - matches!( - (value, property_type), - (MaterialParameterValue::Bool(_), ShaderPropertyType::Bool) - | ( - MaterialParameterValue::Float(_), - ShaderPropertyType::Float { .. } - ) - | (MaterialParameterValue::Vec2(_), ShaderPropertyType::Vec2) - | (MaterialParameterValue::Vec3(_), ShaderPropertyType::Vec3) - | (MaterialParameterValue::Color(_), ShaderPropertyType::Color) - | ( - MaterialParameterValue::Enum(_), - ShaderPropertyType::Enum { .. } - ) - ) -} - -pub(super) fn compact_text_edit(ui: &mut egui::Ui, label: &str, value: &mut String) { - ui.horizontal(|ui| { - ui.add_sized([92.0, 20.0], egui::Label::new(label)); - ui.add_sized( - [fit_width(ui, 100.0, f32::INFINITY), 22.0], - egui::TextEdit::singleline(value), - ); - }); -} - -pub(super) fn shader_kind_picker(ui: &mut egui::Ui, kind: &mut MaterialShaderKind) { - ui.horizontal(|ui| { - ui.add_sized([92.0, 20.0], egui::Label::new("Shader")); - shader_kind_combo(ui, kind, "asset_material_shader_kind"); - }); -} - -pub(super) fn shader_kind_combo( - ui: &mut egui::Ui, - kind: &mut MaterialShaderKind, - salt: impl std::hash::Hash, -) { - egui::ComboBox::from_id_salt(salt) - .selected_text(match kind { - MaterialShaderKind::StandardLit => "Standard Lit", - MaterialShaderKind::Unlit => "Unlit", - MaterialShaderKind::Custom => "Custom Surface", - }) - .width(150.0_f32.min(ui.available_width().max(1.0))) - .show_ui(ui, |ui| { - ui.selectable_value(kind, MaterialShaderKind::StandardLit, "Standard Lit"); - ui.selectable_value(kind, MaterialShaderKind::Unlit, "Unlit"); - ui.selectable_value(kind, MaterialShaderKind::Custom, "Custom Surface"); - }); -} - -pub(super) fn optional_path_edit(ui: &mut egui::Ui, label: &str, value: &mut Option) { - ui.horizontal(|ui| { - ui.add_sized([92.0, 20.0], egui::Label::new(label)); - let mut text = value.clone().unwrap_or_default(); - if ui - .add_sized( - [fit_width(ui, 80.0, f32::INFINITY), 22.0], - egui::TextEdit::singleline(&mut text), - ) - .changed() - { - *value = if text.trim().is_empty() { - None - } else { - Some(text) - }; - } - if ui.button("Clear").clicked() { - *value = None; - } - }); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rendering_missing_standard_defaults_does_not_author_or_dirty_inputs() { - let context = egui::Context::default(); - context.set_fonts(crate::ui::fonts::editor_font_definitions()); - let mut inputs = MaterialInputSet::default(); - let original = inputs.clone(); - let schema = shared::standard_lit_input_schema(); - let _ = context.run_ui( - egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size( - egui::Pos2::ZERO, - egui::vec2(700.0, 900.0), - )), - ..Default::default() - }, - |ui| { - material_input_schema_editor(ui, &schema, &mut inputs, &[], None); - }, - ); - assert_eq!(inputs, original); - } -} diff --git a/crates/editor/src/ui/materials/layout.rs b/crates/editor/src/ui/materials/layout.rs deleted file mode 100644 index 3dfd3ab..0000000 --- a/crates/editor/src/ui/materials/layout.rs +++ /dev/null @@ -1,343 +0,0 @@ -//! Numeric Penpot v2.3.1 material-panel geometry shared by rendering and tests. - -use bevy_egui::egui; - -use crate::ui::design_system::{ - ADVANCED_COLLAPSED_HEIGHT, ADVANCED_EXPANDED_HEIGHT, COLLAPSED_HEADER_HEIGHT, HEADER_HEIGHT, - INPUT_SECTION_HEADER_HEIGHT, MATERIAL_IDENTITY_WIDTH, MATERIAL_PREVIEW_SIZE, OUTER_PADDING, - PARAMETER_ROW_HEIGHT, SECTION_GAP, SURFACE_SECTION_HEIGHT, UV_SECTION_HEIGHT, -}; - -/// Smallest canvas that can still contain the exported compact Surface controls. -/// -/// The Penpot board owns a 372 px parameter section. In the actor Inspector, component-card -/// chrome can temporarily reduce that canvas by a few pixels even while the Inspector itself is -/// at its supported 420 px floor. The fixed compact controls only require 362 px, so keep the -/// intended two-line layout until that real containment limit and reserve `Transient` for actual -/// sub-minimum docking frames. -pub(super) const COMPACT_SECTION_WIDTH: f32 = 357.0; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum ResponsiveMaterialLayout { - Reference, - Compact, - Transient, -} - -impl ResponsiveMaterialLayout { - pub(super) fn for_width(width: f32) -> Self { - if width + f32::EPSILON >= crate::ui::design_system::property_grid::WIDE_SECTION_WIDTH { - Self::Reference - } else if width + f32::EPSILON >= COMPACT_SECTION_WIDTH { - Self::Compact - } else { - Self::Transient - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub(super) struct SurfaceSectionGeometry { - pub height: f32, - pub title: egui::Rect, - pub blend: egui::Rect, - pub packed: egui::Rect, - pub double_sided: egui::Rect, -} - -impl SurfaceSectionGeometry { - pub(super) fn for_rect(rect: egui::Rect) -> Self { - let local = |x: f32, y: f32, width: f32, height: f32| { - let x = x.clamp(0.0, rect.width()); - let y = y.clamp(0.0, rect.height()); - egui::Rect::from_min_size( - rect.min + egui::vec2(x, y), - egui::vec2( - width.max(0.0).min(rect.width() - x), - height.max(0.0).min(rect.height() - y), - ), - ) - }; - match ResponsiveMaterialLayout::for_width(rect.width()) { - ResponsiveMaterialLayout::Reference => Self { - height: SURFACE_SECTION_HEIGHT, - title: local(10.0, 10.0, 48.0, 26.0), - blend: local(66.0, 10.0, 250.0, 26.0), - packed: local(324.0, 11.0, 128.0, 24.0), - double_sided: local(460.0, 12.0, 99.0, 22.0), - }, - ResponsiveMaterialLayout::Compact => Self { - height: crate::ui::design_system::COMPACT_SURFACE_SECTION_HEIGHT, - title: local(10.0, 10.0, 48.0, 26.0), - blend: local(66.0, 10.0, 50.0, 26.0), - packed: local(124.0, 11.0, 128.0, 24.0), - double_sided: local(260.0, 12.0, 99.0, 22.0), - }, - ResponsiveMaterialLayout::Transient => Self { - height: 100.0, - title: local(10.0, 10.0, 48.0, 26.0), - blend: local(66.0, 10.0, (rect.width() - 76.0).clamp(1.0, 250.0), 26.0), - packed: local(10.0, 42.0, (rect.width() - 20.0).clamp(0.0, 128.0), 24.0), - double_sided: local(146.0, 43.0, (rect.width() - 156.0).clamp(0.0, 99.0), 22.0), - }, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub(super) struct UvGroupGeometry { - pub label: egui::Rect, - pub x: egui::Rect, - pub y: egui::Rect, - pub reset: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub(super) struct UvSectionGeometry { - pub height: f32, - pub title: egui::Rect, - pub offset: UvGroupGeometry, - pub tiling: UvGroupGeometry, -} - -impl UvSectionGeometry { - pub(super) fn for_rect(rect: egui::Rect) -> Self { - let mode = ResponsiveMaterialLayout::for_width(rect.width()); - let title = egui::Rect::from_min_size( - rect.min + egui::vec2(12.0, 7.0), - egui::vec2(20.0_f32.min(rect.width()), 40.0_f32.min(rect.height())), - ); - if mode == ResponsiveMaterialLayout::Transient { - let group_width = (rect.width() - 20.0).max(1.0); - return Self { - height: 100.0, - title, - offset: uv_group(rect.min + egui::vec2(42.0, 7.0), group_width), - tiling: uv_group(rect.min + egui::vec2(42.0, 47.0), group_width), - }; - } - if mode == ResponsiveMaterialLayout::Reference { - Self { - height: UV_SECTION_HEIGHT, - title, - offset: uv_group(rect.min + egui::vec2(104.667, 7.0), 165.0), - tiling: uv_group(rect.min + egui::vec2(332.333, 7.0), 164.0), - } - } else { - Self { - height: crate::ui::design_system::COMPACT_UV_SECTION_HEIGHT, - title, - offset: uv_group(rect.min + egui::vec2(118.0, 7.0), 165.0), - tiling: uv_group(rect.min + egui::vec2(118.5, 47.0), 164.0), - } - } - } -} - -fn uv_group(origin: egui::Pos2, group_width: f32) -> UvGroupGeometry { - let local = |x: f32, y: f32, control_width: f32, height: f32| { - let x = x.clamp(0.0, group_width.max(0.0)); - egui::Rect::from_min_size( - origin + egui::vec2(x, y), - egui::vec2(control_width.max(0.0).min(group_width - x), height), - ) - }; - let reset = (group_width >= 164.0).then(|| local(group_width - 24.0, 8.0, 24.0, 24.0)); - UvGroupGeometry { - label: local(0.0, 10.0, 25.0, 20.0), - x: local(30.0, 8.0, 50.0, 24.0), - y: local(86.0, 8.0, 50.0, 24.0), - reset, - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub(super) struct MaterialPanelGeometry { - pub header: f32, - pub collapsed_header: f32, - pub asset_zone: f32, - pub surface: f32, - pub input_header: f32, - pub input_row: f32, - pub uv: f32, - pub advanced_collapsed: f32, - pub advanced_expanded: f32, - pub preview: f32, - pub identity: f32, - pub outer_padding: f32, - pub section_gap: f32, -} - -impl MaterialPanelGeometry { - pub(super) const REFERENCE: Self = Self { - header: HEADER_HEIGHT, - collapsed_header: COLLAPSED_HEADER_HEIGHT, - asset_zone: HEADER_HEIGHT, - surface: SURFACE_SECTION_HEIGHT, - input_header: INPUT_SECTION_HEADER_HEIGHT, - input_row: PARAMETER_ROW_HEIGHT, - uv: UV_SECTION_HEIGHT, - advanced_collapsed: ADVANCED_COLLAPSED_HEIGHT, - advanced_expanded: ADVANCED_EXPANDED_HEIGHT, - preview: MATERIAL_PREVIEW_SIZE, - identity: MATERIAL_IDENTITY_WIDTH, - outer_padding: OUTER_PADDING, - section_gap: SECTION_GAP, - }; - - #[cfg(test)] - pub(super) const fn slot_width(inspector_width: f32) -> f32 { - inspector_width - OUTER_PADDING * 2.0 - } - - #[cfg(test)] - pub(super) const fn parameter_width(slot_width: f32) -> f32 { - slot_width - 27.0 - } - - #[cfg(test)] - pub(super) const fn inputs_height(compact: bool, primary_rows: usize) -> f32 { - INPUT_SECTION_HEADER_HEIGHT - + primary_rows as f32 - * if compact { - crate::ui::design_system::COMPACT_PARAMETER_ROW_HEIGHT - } else { - PARAMETER_ROW_HEIGHT - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn final_penpot_reference_geometry_is_exact() { - let geometry = MaterialPanelGeometry::REFERENCE; - assert_eq!(geometry.header, 82.0); - assert_eq!(geometry.collapsed_header, 52.0); - assert_eq!(geometry.asset_zone, 82.0); - assert_eq!(geometry.surface, 46.0); - assert_eq!(geometry.input_header, 24.0); - assert_eq!(geometry.input_row, 32.0); - assert_eq!(geometry.uv, 54.0); - assert_eq!(geometry.advanced_collapsed, 30.0); - assert_eq!(geometry.advanced_expanded, 178.0); - assert_eq!(geometry.preview, 64.0); - assert_eq!(geometry.identity, 229.5); - assert_eq!(620.0 - OUTER_PADDING * 2.0, 596.0); - assert_eq!(420.0 - OUTER_PADDING * 2.0, 396.0); - assert_eq!(MaterialPanelGeometry::slot_width(620.0), 596.0); - assert_eq!(MaterialPanelGeometry::slot_width(420.0), 396.0); - assert_eq!(MaterialPanelGeometry::parameter_width(596.0), 569.0); - assert_eq!(MaterialPanelGeometry::parameter_width(396.0), 369.0); - assert_eq!(MaterialPanelGeometry::inputs_height(false, 6), 216.0); - assert_eq!(MaterialPanelGeometry::inputs_height(true, 6), 372.0); - } - - #[test] - fn surface_geometry_matches_both_exported_references() { - let wide = SurfaceSectionGeometry::for_rect(egui::Rect::from_min_size( - egui::Pos2::ZERO, - egui::vec2(569.0, 46.0), - )); - assert_eq!( - wide.title, - egui::Rect::from_min_size(egui::pos2(10.0, 10.0), egui::vec2(48.0, 26.0)) - ); - assert_eq!( - wide.blend, - egui::Rect::from_min_size(egui::pos2(66.0, 10.0), egui::vec2(250.0, 26.0)) - ); - assert_eq!( - wide.packed, - egui::Rect::from_min_size(egui::pos2(324.0, 11.0), egui::vec2(128.0, 24.0)) - ); - assert_eq!( - wide.double_sided, - egui::Rect::from_min_size(egui::pos2(460.0, 12.0), egui::vec2(99.0, 22.0)) - ); - - let narrow = SurfaceSectionGeometry::for_rect(egui::Rect::from_min_size( - egui::Pos2::ZERO, - egui::vec2(369.0, 46.0), - )); - assert_eq!( - narrow.blend, - egui::Rect::from_min_size(egui::pos2(66.0, 10.0), egui::vec2(50.0, 26.0)) - ); - assert_eq!( - narrow.packed, - egui::Rect::from_min_size(egui::pos2(124.0, 11.0), egui::vec2(128.0, 24.0)) - ); - assert_eq!( - narrow.double_sided, - egui::Rect::from_min_size(egui::pos2(260.0, 12.0), egui::vec2(99.0, 22.0)) - ); - } - - #[test] - fn uv_geometry_centers_title_and_keeps_independent_resets() { - for (width, height) in [(569.0, 54.0), (369.0, 94.0)] { - let section = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, height)); - let geometry = UvSectionGeometry::for_rect(section); - assert_eq!(geometry.title.min, egui::pos2(12.0, 7.0)); - assert_eq!(geometry.offset.x.size(), egui::vec2(50.0, 24.0)); - assert_eq!(geometry.offset.y.size(), egui::vec2(50.0, 24.0)); - assert!(geometry.offset.reset.is_some()); - assert!(geometry.tiling.reset.is_some()); - for rect in [ - geometry.title, - geometry.offset.label, - geometry.offset.x, - geometry.offset.y, - geometry.offset.reset.expect("reference reset"), - geometry.tiling.label, - geometry.tiling.x, - geometry.tiling.y, - geometry.tiling.reset.expect("reference reset"), - ] { - assert!(section.contains_rect(rect)); - assert!(rect.is_finite()); - } - } - } - - #[test] - fn transient_geometry_stays_finite_and_bounded() { - let section = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 100.0)); - let surface = SurfaceSectionGeometry::for_rect(section); - let uv = UvSectionGeometry::for_rect(section); - for rect in [ - surface.title, - surface.blend, - surface.packed, - surface.double_sided, - ] { - assert!(rect.is_finite()); - assert!(section.contains_rect(rect)); - } - for rect in [ - uv.title, - uv.offset.label, - uv.offset.x, - uv.offset.y, - uv.tiling.label, - uv.tiling.x, - uv.tiling.y, - ] { - assert!(rect.is_finite()); - assert!(section.contains_rect(rect)); - } - } - - #[test] - fn inspector_chrome_reduced_floor_keeps_the_penpot_compact_surface() { - let section = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(363.0, 46.0)); - let surface = SurfaceSectionGeometry::for_rect(section); - assert_eq!(surface.height, 46.0); - assert_eq!(surface.packed.min, egui::pos2(124.0, 11.0)); - assert_eq!(surface.packed.max, egui::pos2(252.0, 35.0)); - assert_eq!(surface.double_sided.min, egui::pos2(260.0, 12.0)); - } -} diff --git a/crates/editor/src/ui/materials/panel.rs b/crates/editor/src/ui/materials/panel.rs deleted file mode 100644 index 0b23167..0000000 --- a/crates/editor/src/ui/materials/panel.rs +++ /dev/null @@ -1,686 +0,0 @@ -//! Action-returning Penpot material-slot shell shared by primitive and mesh inspectors. - -use bevy_egui::egui; -use egui_phosphor_icons::icons; -use shared::{EditorAssetRef, MaterialShaderKind}; - -use super::layout::MaterialPanelGeometry; -use super::MaterialThumbnailPresentation; -use crate::ui::design_system::controls::status; -use crate::ui::design_system::typography::TypeRole; -use crate::ui::design_system::{self, DesignPalette, HEADER_HEIGHT}; - -mod header_actions; - -use header_actions::{expanded_status_actions, overflow_menu, SLOT_STATUS_ACTION_WIDTH}; - -const SLOT_ACCENT_X: f32 = 8.0; -const SLOT_CARET_CENTER_X: f32 = 23.0; -const SLOT_PREVIEW_X: f32 = 35.0; -const SHADER_CONTROL_WIDTH: f32 = 220.0; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum MaterialLayerBadge { - ModelSource, - ModelDefault, - ProjectDefault, - DefaultGrid, -} - -impl MaterialLayerBadge { - pub(crate) const fn label(self) -> &'static str { - match self { - Self::ModelSource => "Model Source", - Self::ModelDefault => "Model Default", - Self::ProjectDefault => "Project Default", - Self::DefaultGrid => "Default Grid", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum MaterialHealth { - Healthy, - Dirty, - Processing, - Broken, - ReadOnly, -} - -impl MaterialHealth { - fn presentation(self, palette: DesignPalette) -> (egui::Color32, &'static str, &'static str) { - match self { - Self::Healthy => ( - palette.healthy, - "Compiled", - "Material is resolved and up to date", - ), - Self::Dirty => ( - palette.warning, - "Unsaved", - "Material has unsaved in-memory changes", - ), - Self::Processing => ( - palette.accent, - "Processing", - "Derived material data is processing", - ), - Self::Broken => ( - palette.error, - "Failed", - "Material reference or processing failed", - ), - Self::ReadOnly => ( - palette.text_muted, - "Read only", - "This material cannot be edited in place", - ), - } - } - - fn icon(self) -> egui_phosphor_icons::Icon { - match self { - Self::Healthy => icons::CHECK_CIRCLE, - Self::Dirty => icons::PENCIL_SIMPLE, - Self::Processing => icons::CIRCLE_NOTCH, - Self::Broken => icons::WARNING_CIRCLE, - Self::ReadOnly => icons::LOCK_SIMPLE, - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct MaterialPickerCandidate { - pub reference: EditorAssetRef, - pub label: String, - pub detail: String, - pub thumbnail: MaterialThumbnailPresentation, -} - -#[derive(Debug, Clone)] -pub(crate) struct MaterialSlotPanelViewModel { - pub slot_id: String, - pub label: String, - pub path: Option, - pub shader: String, - pub shader_kind: Option, - pub can_edit_shader: bool, - pub thumbnail: MaterialThumbnailPresentation, - pub inherited: Option, - pub health: MaterialHealth, - pub assigned: Option, - pub effective: Option, - pub candidates: Vec, - pub drop_candidate: Option, - pub invalid_drop_reason: Option, - pub can_locate: bool, - pub can_clear: bool, - pub can_extract: bool, - pub can_create_instance: bool, -} - -#[derive(Debug, Clone, PartialEq)] -pub(crate) enum MaterialSlotAction { - Assign { - reference: EditorAssetRef, - from_drop: bool, - }, - BrowseLibrary, - Locate, - Clear, - ExtractEditable, - CreateInstance, - SetShader(MaterialShaderKind), -} - -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct MaterialSlotPanelAction { - pub slot_id: String, - pub action: MaterialSlotAction, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct MaterialsSectionViewModel { - pub slot_ids: Vec, -} - -pub(crate) struct MaterialSlotPanelResponse { - pub actions: Vec, -} - -pub(crate) fn materials_section( - ui: &mut egui::Ui, - _view: &MaterialsSectionViewModel, - add_slots: impl FnOnce(&mut egui::Ui) -> R, -) -> R { - let start = ui.next_widget_position(); - let expanded = actor_materials_rect(ui.max_rect(), start.y); - let mut child = ui.new_child( - egui::UiBuilder::new() - .max_rect(expanded) - .layout(egui::Layout::top_down(egui::Align::Min)), - ); - child.set_clip_rect(ui.clip_rect()); - let result = design_system::scope(&mut child, |ui| { - let palette = design_system::palette(ui); - ui.allocate_ui_with_layout( - egui::vec2( - ui.available_width().max(1.0), - design_system::MATERIALS_HEADING_HEIGHT, - ), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - ui.label( - TypeRole::Title - .text("Materials") - .color(palette.text_primary), - ); - }, - ); - ui.add_space(8.0); - add_slots(ui) - }); - // `min_rect` is not a safe content-height signal for a child hosted by a ScrollArea: nested - // frames may temporarily inherit the parent's virtual extent even though their layout cursor - // has only advanced through finite authored rows. Advancing from that rect creates a phantom - // tail after multi-slot renderers and lets the Inspector scrollbar enter an empty region. - // The cursor is the authoritative end of this top-down section. - let used_bottom = materials_used_bottom(child.next_widget_position().y, start.y); - ui.advance_cursor_after_rect(egui::Rect::from_min_max( - egui::pos2(ui.max_rect().left(), start.y), - egui::pos2(ui.max_rect().right(), used_bottom), - )); - result -} - -fn actor_materials_rect(body: egui::Rect, start_y: f32) -> egui::Rect { - // A ScrollArea content Ui has an effectively unbounded max-rect in its scrolling direction. - // Carrying that bottom edge into the child gives the Materials section a phantom multi-screen - // height even though its painted contents are finite. Seed only the exported width here; egui - // grows the zero-height child as headings and slots are actually allocated. - egui::Rect::from_min_size( - egui::pos2(body.left(), start_y), - egui::vec2(body.width(), 0.0), - ) -} - -fn materials_used_bottom(cursor_y: f32, start_y: f32) -> f32 { - if cursor_y.is_finite() { - cursor_y.max(start_y) - } else { - start_y - } -} - -pub(crate) fn material_slot_panel( - ui: &mut egui::Ui, - id: impl std::hash::Hash, - view: &MaterialSlotPanelViewModel, - content_ui: impl FnOnce(&mut egui::Ui), - diagnostics_ui: impl FnOnce(&mut egui::Ui), -) -> MaterialSlotPanelResponse { - design_system::scope(ui, |ui| { - let palette = design_system::palette(ui); - let id = ui.make_persistent_id(id); - let open_id = id.with("parameters_open"); - let mut parameters_open = ui - .ctx() - .data_mut(|data| data.get_persisted::(open_id)) - .unwrap_or(true); - let mut slot_actions = Vec::new(); - - egui::Frame::new() - .fill(palette.panel) - .stroke(egui::Stroke::new(1.0_f32, palette.border)) - .corner_radius(egui::CornerRadius::same(8)) - .show(ui, |ui| { - slot_header( - ui, - id, - view, - &mut parameters_open, - &mut slot_actions, - diagnostics_ui, - ); - if parameters_open { - parameter_body(ui, content_ui); - } - ui.ctx() - .data_mut(|data| data.insert_persisted(open_id, parameters_open)); - }); - MaterialSlotPanelResponse { - actions: slot_actions - .into_iter() - .map(|action| MaterialSlotPanelAction { - slot_id: view.slot_id.clone(), - action, - }) - .collect(), - } - }) -} - -fn slot_header( - ui: &mut egui::Ui, - id: egui::Id, - view: &MaterialSlotPanelViewModel, - open: &mut bool, - actions: &mut Vec, - diagnostics_ui: impl FnOnce(&mut egui::Ui), -) { - let palette = design_system::palette(ui); - let height = if *open { - HEADER_HEIGHT - } else { - MaterialPanelGeometry::REFERENCE.collapsed_header - }; - let width = ui.available_width().max(1.0); - let (rect, response) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::click()); - ui.painter().rect_filled(rect, 7.0, palette.recessed); - ui.painter().rect_filled( - egui::Rect::from_min_size( - rect.min + egui::vec2(SLOT_ACCENT_X, 8.0), - egui::vec2(3.0, height - 16.0), - ), - 2.0, - palette.accent, - ); - let caret_rect = egui::Rect::from_center_size( - egui::pos2(rect.left() + SLOT_CARET_CENTER_X, rect.center().y), - egui::vec2(18.0, 28.0), - ); - let caret_icon = if *open { - icons::CARET_DOWN - } else { - icons::CARET_RIGHT - }; - let caret = ui.put( - caret_rect, - egui::Button::new( - egui::RichText::new(caret_icon.as_str()).font(egui::FontId::new( - 12.0, - egui::FontFamily::Name("phosphor-bold".into()), - )), - ) - .frame(false), - ); - if caret.clicked() { - *open = !*open; - } - let preview_size = if *open { 64.0 } else { 32.0 }; - let preview_rect = egui::Rect::from_min_size( - egui::pos2( - rect.left() + SLOT_PREVIEW_X, - rect.center().y - preview_size * 0.5, - ), - egui::vec2(preview_size, preview_size), - ); - let preview = material_thumbnail_at(ui, view, preview_rect); - let action_width = SLOT_STATUS_ACTION_WIDTH; - let identity_left = preview_rect.right() + 6.0; - let actions_left = rect.right() - action_width - 8.0; - let identity_right = (identity_left + design_system::MATERIAL_IDENTITY_WIDTH) - .min(actions_left) - .max(identity_left + 1.0); - let identity_rect = egui::Rect::from_min_max( - egui::pos2(identity_left, rect.top() + if *open { 8.0 } else { 7.0 }), - egui::pos2( - identity_right, - rect.bottom() - if *open { 8.0 } else { 7.0 }, - ), - ); - let identity = ui - .scope_builder(egui::UiBuilder::new().max_rect(identity_rect), |ui| { - ui.set_clip_rect(identity_rect.intersect(ui.clip_rect())); - if *open { - material_identity(ui, view, actions) - } else { - compact_material_identity(ui, view) - } - }) - .inner; - let actions_rect = egui::Rect::from_min_size( - egui::pos2(actions_left, rect.top() + if *open { 9.0 } else { 7.0 }), - egui::vec2(action_width, if *open { 64.0 } else { height - 14.0 }), - ); - if *open { - expanded_status_actions(ui, actions_rect, view, actions, diagnostics_ui, palette); - } else { - ui.scope_builder(egui::UiBuilder::new().max_rect(actions_rect), |ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - overflow_menu(ui, view, actions, diagnostics_ui); - let (color, label, tooltip) = view.health.presentation(palette); - ui.horizontal(|ui| status(ui, view.health.icon(), color, label, tooltip)); - }); - }); - } - if response.clicked() { - if let Some(pointer) = ui.input(|input| input.pointer.interact_pos()) { - if !caret_rect.contains(pointer) - && !preview_rect.contains(pointer) - && !identity_rect.contains(pointer) - && !actions_rect.contains(pointer) - { - *open = !*open; - } - } - } - super::pickers::material_picker_popup( - ui, - id.with("material_picker"), - &preview.union(identity), - view, - actions, - ); - if response.hovered() - && view.drop_candidate.is_some() - && ui.input(|input| input.pointer.any_released()) - { - if let Some(candidate) = view.drop_candidate.as_ref() { - actions.push(MaterialSlotAction::Assign { - reference: candidate.reference.clone(), - from_drop: true, - }); - } - } - if response.hovered() { - if let Some(reason) = view.invalid_drop_reason.as_deref() { - response.on_hover_text(reason); - } - } -} - -fn compact_material_identity( - ui: &mut egui::Ui, - view: &MaterialSlotPanelViewModel, -) -> egui::Response { - let palette = design_system::palette(ui); - let name = ui - .add( - egui::Label::new( - TypeRole::Section - .text(&view.label) - .color(palette.text_primary), - ) - .truncate() - .sense(egui::Sense::click()), - ) - .on_hover_text(&view.label); - let detail_text = view - .inherited - .map(MaterialLayerBadge::label) - .unwrap_or(view.shader.as_str()); - let detail = ui - .add( - egui::Label::new(TypeRole::Small.text(detail_text).color(palette.text_muted)) - .truncate() - .sense(egui::Sense::click()), - ) - .on_hover_text(detail_text); - name.union(detail) -} - -fn parameter_body(ui: &mut egui::Ui, content_ui: impl FnOnce(&mut egui::Ui)) { - let palette = design_system::palette(ui); - egui::Frame::new() - .fill(palette.panel) - .inner_margin(egui::Margin { - left: 19, - right: 8, - top: 8, - bottom: 8, - }) - .show(ui, |ui| { - ui.label(TypeRole::Caption.text("PARAMETERS").color(palette.accent)); - let spine_x = ui.min_rect().left() - 7.0; - let spine_top = ui.cursor().top(); - content_ui(ui); - let spine_bottom = ui.min_rect().bottom().max(spine_top); - ui.painter().vline( - spine_x, - spine_top..=spine_bottom, - egui::Stroke::new(1.0_f32, palette.accent), - ); - }); -} - -fn material_identity( - ui: &mut egui::Ui, - view: &MaterialSlotPanelViewModel, - actions: &mut Vec, -) -> egui::Response { - let palette = design_system::palette(ui); - let name = ui - .add( - egui::Label::new( - TypeRole::Section - .text(&view.label) - .color(palette.text_primary), - ) - .truncate() - .sense(egui::Sense::click()), - ) - .on_hover_text(&view.label); - let path = view.path.as_deref().unwrap_or("Engine built-in material"); - let identity = view.inherited.map_or_else( - || path.to_string(), - |layer| format!("{path} · {}", layer.label()), - ); - let detail = ui - .add( - egui::Label::new(TypeRole::Small.text(&identity).color(palette.text_muted)) - .truncate() - .sense(egui::Sense::click()), - ) - .on_hover_text(&identity); - if view.can_edit_shader { - if let Some(mut kind) = view.shader_kind { - egui::ComboBox::from_id_salt("material_identity_shader_kind") - .selected_text(&view.shader) - .width(SHADER_CONTROL_WIDTH.min(ui.available_width().max(1.0))) - .show_ui(ui, |ui| { - ui.selectable_value(&mut kind, MaterialShaderKind::StandardLit, "Standard Lit"); - ui.selectable_value(&mut kind, MaterialShaderKind::Unlit, "Unlit"); - ui.selectable_value(&mut kind, MaterialShaderKind::Custom, "Custom Surface"); - }); - if Some(kind) != view.shader_kind { - actions.push(MaterialSlotAction::SetShader(kind)); - } - } - } else { - ui.add_sized( - [ - SHADER_CONTROL_WIDTH.min(ui.available_width().max(1.0)), - 22.0, - ], - egui::Label::new( - TypeRole::Body - .text(&view.shader) - .color(palette.text_secondary), - ), - ); - } - name.union(detail) -} - -fn material_thumbnail_at( - ui: &mut egui::Ui, - view: &MaterialSlotPanelViewModel, - rect: egui::Rect, -) -> egui::Response { - let palette = design_system::palette(ui); - let response = ui.interact( - rect, - ui.make_persistent_id(("material_preview", &view.slot_id)), - egui::Sense::click(), - ); - let size = rect.width(); - let cell = size / 4.0; - for row in 0..4 { - for column in 0..4 { - ui.painter().rect_filled( - egui::Rect::from_min_size( - rect.min + egui::vec2(column as f32 * cell, row as f32 * cell), - egui::vec2(cell, cell), - ) - .intersect(rect), - 0.0, - if (row + column) % 2 == 0 { - palette.checker - } else { - palette.recessed - }, - ); - } - } - match view.thumbnail { - MaterialThumbnailPresentation::Ready(texture) => { - ui.painter().image( - texture, - rect.shrink(2.0), - egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } - MaterialThumbnailPresentation::Pending => { - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - icons::CIRCLE_NOTCH.as_str(), - egui::FontId::new(22.0, egui::FontFamily::Name("phosphor-regular".into())), - palette.text_secondary, - ); - } - MaterialThumbnailPresentation::Failed => { - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - icons::WARNING.as_str(), - egui::FontId::new(22.0, egui::FontFamily::Name("phosphor-regular".into())), - palette.error, - ); - } - } - ui.painter().rect_stroke( - rect, - 4.0, - egui::Stroke::new(1.0_f32, palette.border), - egui::StrokeKind::Inside, - ); - response.on_hover_text(format!("Open {} material picker", view.label)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn view() -> MaterialSlotPanelViewModel { - MaterialSlotPanelViewModel { - slot_id: "slot:test".into(), - label: "M_Wood_Planks".into(), - path: Some("materials/wood/M_Wood_Planks.mat".into()), - shader: "Standard Lit".into(), - shader_kind: Some(MaterialShaderKind::StandardLit), - can_edit_shader: true, - thumbnail: MaterialThumbnailPresentation::Pending, - inherited: None, - health: MaterialHealth::Healthy, - assigned: None, - effective: None, - candidates: Vec::new(), - drop_candidate: None, - invalid_drop_reason: None, - can_locate: true, - can_clear: true, - can_extract: false, - can_create_instance: true, - } - } - - #[test] - fn slot_header_insets_match_the_current_penpot_export() { - assert_eq!(SLOT_ACCENT_X, 8.0); - assert_eq!(SLOT_CARET_CENTER_X, 23.0); - assert_eq!(SLOT_PREVIEW_X, 35.0); - assert_eq!(SLOT_PREVIEW_X - (SLOT_CARET_CENTER_X + 6.0), 6.0); - assert_eq!(SLOT_STATUS_ACTION_WIDTH, 140.0); - assert_eq!(SHADER_CONTROL_WIDTH, 220.0); - } - - #[test] - fn actor_component_body_preserves_penpot_slot_widths() { - let wide_body = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(596.0, 700.0)); - let compact_body = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(396.0, 700.0)); - - let wide = actor_materials_rect(wide_body, 32.0); - let compact = actor_materials_rect(compact_body, 32.0); - - assert_eq!(wide.width(), 596.0); - assert_eq!(compact.width(), 396.0); - assert_eq!(wide.left(), 0.0); - assert_eq!(compact.left(), 0.0); - assert_eq!(wide.top(), 32.0); - assert_eq!(compact.top(), 32.0); - assert_eq!(wide.height(), 0.0); - assert_eq!(compact.height(), 0.0); - } - - #[test] - fn materials_height_uses_the_finite_layout_cursor_not_a_virtual_scroll_extent() { - assert_eq!(materials_used_bottom(948.0, 320.0), 948.0); - assert_eq!(materials_used_bottom(240.0, 320.0), 320.0); - assert_eq!(materials_used_bottom(f32::INFINITY, 320.0), 320.0); - } - - #[test] - fn penpot_direct_assignment_has_no_redundant_explicit_badge() { - assert!(view().inherited.is_none()); - } - - #[test] - fn penpot_assignment_action_carries_exact_stable_reference() { - let reference = EditorAssetRef::new("asset-id", "material:source", "M_Wood"); - let action = MaterialSlotPanelAction { - slot_id: "slot:test".into(), - action: MaterialSlotAction::Assign { - reference: reference.clone(), - from_drop: false, - }, - }; - assert_eq!( - action, - MaterialSlotPanelAction { - slot_id: "slot:test".into(), - action: MaterialSlotAction::Assign { - reference, - from_drop: false, - }, - } - ); - } - - #[test] - fn penpot_supported_material_states_share_one_view_model() { - for health in [ - MaterialHealth::Healthy, - MaterialHealth::Dirty, - MaterialHealth::Processing, - MaterialHealth::Broken, - MaterialHealth::ReadOnly, - ] { - let mut view = view(); - view.health = health; - let palette = DesignPalette::from(crate::ui::theme::BLACKSITE_PALETTE); - assert!(!view.health.presentation(palette).1.is_empty()); - } - } - - #[test] - fn penpot_material_statuses_use_semantic_phosphor_icons() { - assert_eq!(MaterialHealth::Healthy.icon(), icons::CHECK_CIRCLE); - assert_eq!(MaterialHealth::Dirty.icon(), icons::PENCIL_SIMPLE); - assert_eq!(MaterialHealth::Processing.icon(), icons::CIRCLE_NOTCH); - assert_eq!(MaterialHealth::Broken.icon(), icons::WARNING_CIRCLE); - assert_eq!(MaterialHealth::ReadOnly.icon(), icons::LOCK_SIMPLE); - } -} diff --git a/crates/editor/src/ui/materials/panel/header_actions.rs b/crates/editor/src/ui/materials/panel/header_actions.rs deleted file mode 100644 index a130edd..0000000 --- a/crates/editor/src/ui/materials/panel/header_actions.rs +++ /dev/null @@ -1,216 +0,0 @@ -//! Exact Penpot status/action geometry for an expanded material-slot header. - -use bevy_egui::egui; -use egui_phosphor_icons::icons; - -use super::{MaterialSlotAction, MaterialSlotPanelViewModel}; -use crate::ui::design_system::controls::{icon_button, icon_label, status}; -use crate::ui::design_system::typography::TypeRole; -use crate::ui::design_system::{self, DesignPalette}; - -pub(super) const SLOT_STATUS_ACTION_WIDTH: f32 = 140.0; - -#[derive(Debug, Clone, Copy, PartialEq)] -struct ExpandedHeaderActionGeometry { - status: egui::Rect, - menu: egui::Rect, - browse: egui::Rect, - locate: egui::Rect, - clear: egui::Rect, -} - -fn geometry(rect: egui::Rect) -> ExpandedHeaderActionGeometry { - let status_row = egui::Rect::from_min_size( - rect.min + egui::vec2(0.0, 5.333), - egui::vec2(SLOT_STATUS_ACTION_WIDTH, 24.0), - ); - let menu = egui::Rect::from_min_size( - status_row.min + egui::vec2(116.0, 0.0), - egui::vec2(24.0, 24.0), - ); - let action_row = rect.min + egui::vec2(0.0, 34.667); - ExpandedHeaderActionGeometry { - status: egui::Rect::from_min_max( - status_row.min, - egui::pos2(menu.left() - 5.0, status_row.bottom()), - ), - menu, - browse: egui::Rect::from_min_size( - action_row + egui::vec2(56.0, 0.0), - egui::vec2(24.0, 24.0), - ), - locate: egui::Rect::from_min_size( - action_row + egui::vec2(86.0, 0.0), - egui::vec2(24.0, 24.0), - ), - clear: egui::Rect::from_min_size( - action_row + egui::vec2(116.0, 0.0), - egui::vec2(24.0, 24.0), - ), - } -} - -pub(super) fn expanded_status_actions( - ui: &mut egui::Ui, - rect: egui::Rect, - view: &MaterialSlotPanelViewModel, - actions: &mut Vec, - diagnostics_ui: impl FnOnce(&mut egui::Ui), - palette: DesignPalette, -) { - let geometry = geometry(rect); - rect_scope(ui, geometry.status, |ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let (color, label, tooltip) = view.health.presentation(palette); - status(ui, view.health.icon(), color, label, tooltip); - }); - }); - rect_scope(ui, geometry.menu, |ui| { - overflow_menu(ui, view, actions, diagnostics_ui); - }); - - for (button_rect, action, enabled, icon, tooltip) in [ - ( - geometry.browse, - MaterialSlotAction::BrowseLibrary, - true, - icons::FOLDER_OPEN, - "Browse project materials", - ), - ( - geometry.locate, - MaterialSlotAction::Locate, - view.can_locate, - icons::CROSSHAIR, - "Locate in Content Browser", - ), - ( - geometry.clear, - MaterialSlotAction::Clear, - view.can_clear, - icons::X, - "Clear actor assignment", - ), - ] { - if !enabled { - continue; - } - let clicked = rect_scope(ui, button_rect, |ui| { - icon_button(ui, icon, tooltip, true).clicked() - }); - if clicked { - actions.push(action); - } - } -} - -pub(super) fn overflow_menu( - ui: &mut egui::Ui, - view: &MaterialSlotPanelViewModel, - actions: &mut Vec, - diagnostics_ui: impl FnOnce(&mut egui::Ui), -) { - let palette = design_system::palette(ui); - ui.menu_button( - egui::RichText::new(icons::DOTS_THREE.as_str()).font(egui::FontId::new( - 16.0, - egui::FontFamily::Name("phosphor-bold".into()), - )), - |ui| { - if view.can_locate && menu_action(ui, icons::CROSSHAIR, "Locate", palette.text_primary) - { - actions.push(MaterialSlotAction::Locate); - ui.close(); - } - if menu_action( - ui, - icons::FOLDER_OPEN, - "Browse Asset Library", - palette.text_primary, - ) { - actions.push(MaterialSlotAction::BrowseLibrary); - ui.close(); - } - if view.can_clear && menu_action(ui, icons::X, "Clear material", palette.text_primary) { - actions.push(MaterialSlotAction::Clear); - ui.close(); - } - if view.can_create_instance - && menu_action( - ui, - icons::COPY, - "Create Instance and Assign", - palette.text_primary, - ) - { - actions.push(MaterialSlotAction::CreateInstance); - ui.close(); - } - if view.can_extract - && menu_action(ui, icons::EXPORT, "Extract Editable…", palette.text_primary) - { - actions.push(MaterialSlotAction::ExtractEditable); - ui.close(); - } - ui.separator(); - ui.label(TypeRole::Section.text("Source & Diagnostics")); - diagnostics_ui(ui); - }, - ) - .response - .on_hover_text("Material actions and diagnostics"); -} - -fn menu_action( - ui: &mut egui::Ui, - icon: egui_phosphor_icons::Icon, - label: &str, - color: egui::Color32, -) -> bool { - ui.add(egui::Button::new(icon_label( - icon, - label, - TypeRole::Control, - 14.0, - color, - ))) - .clicked() -} - -fn rect_scope( - ui: &mut egui::Ui, - rect: egui::Rect, - add_contents: impl FnOnce(&mut egui::Ui) -> R, -) -> R { - ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { - ui.set_clip_rect(rect.intersect(ui.clip_rect())); - add_contents(ui) - }) - .inner -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn expanded_status_and_actions_match_the_penpot_header_geometry() { - let rect = egui::Rect::from_min_size(egui::pos2(448.0, 9.0), egui::vec2(140.0, 64.0)); - let geometry = geometry(rect); - assert_eq!(geometry.menu.min, egui::pos2(564.0, 14.333)); - assert_eq!(geometry.browse.min, egui::pos2(504.0, 43.667)); - assert_eq!(geometry.locate.min, egui::pos2(534.0, 43.667)); - assert_eq!(geometry.clear.min, egui::pos2(564.0, 43.667)); - assert_eq!(geometry.menu.size(), egui::vec2(24.0, 24.0)); - assert_eq!(geometry.clear.size(), egui::vec2(24.0, 24.0)); - for child in [ - geometry.status, - geometry.menu, - geometry.browse, - geometry.locate, - geometry.clear, - ] { - assert!(rect.contains_rect(child)); - } - } -} diff --git a/crates/editor/src/ui/materials/pickers.rs b/crates/editor/src/ui/materials/pickers.rs deleted file mode 100644 index b587983..0000000 --- a/crates/editor/src/ui/materials/pickers.rs +++ /dev/null @@ -1,411 +0,0 @@ -//! Penpot-styled material and texture asset pickers. - -use bevy_egui::egui; -use egui_phosphor_icons::icons; -use shared::EditorAssetRef; - -use super::panel::{MaterialPickerCandidate, MaterialSlotAction, MaterialSlotPanelViewModel}; -use super::{MaterialTextureCandidate, MaterialThumbnailPresentation, TextureSlotResponse}; -use crate::ui::design_system; -use crate::ui::design_system::controls::icon_label; -use crate::ui::design_system::typography::TypeRole; - -const MATERIAL_PICKER_WIDTH: f32 = 244.0; -const TEXTURE_PICKER_WIDTH: f32 = 420.0; -const PICKER_ROW_HEIGHT: f32 = 44.0; -const PICKER_THUMBNAIL: f32 = 36.0; - -pub(super) fn material_picker_popup( - _ui: &mut egui::Ui, - id: egui::Id, - anchor: &egui::Response, - view: &MaterialSlotPanelViewModel, - actions: &mut Vec, -) { - egui::Popup::menu(anchor) - .id(id) - .width(MATERIAL_PICKER_WIDTH) - .show(|ui| { - ui.label( - TypeRole::Section - .text("Material Assets") - .color(design_system::palette(ui).text_primary), - ); - if let Some(current) = view.effective.as_ref() { - if let Some(candidate) = find_material_candidate(&view.candidates, current) { - material_menu_row(ui, candidate, true, actions); - } else { - unresolved_material_menu_row(ui, current); - } - } - - let recent_keys = recent_keys(ui, "material_picker_recents"); - let recent = candidates_for_recent_keys(&view.candidates, &recent_keys); - for candidate in recent.into_iter().take(4) { - let selected = view - .effective - .as_ref() - .is_some_and(|current| same_reference(current, &candidate.reference)); - if !selected { - material_menu_row(ui, candidate, false, actions); - } - } - picker_footer(ui, view.assigned.is_some(), actions); - }); -} - -pub(super) fn texture_picker_popup( - _ui: &mut egui::Ui, - id: egui::Id, - anchor: &egui::Response, - candidates: &[MaterialTextureCandidate], - selected: Option<&MaterialTextureCandidate>, - response: &mut TextureSlotResponse, -) { - egui::Popup::menu(anchor) - .id(id) - .width(TEXTURE_PICKER_WIDTH) - .show(|ui| { - picker_heading( - ui, - "Select Texture", - "Project textures and imported subassets", - ); - if let Some(current) = selected { - picker_section_label(ui, "CURRENT"); - texture_candidate_row(ui, current, true, response); - } - let recent_keys = recent_keys(ui, "texture_picker_recents"); - let recent = candidates_for_recent_texture_keys(candidates, &recent_keys); - if !recent.is_empty() { - picker_section_label(ui, "RECENT"); - for candidate in recent.into_iter().take(4) { - texture_candidate_row(ui, candidate, false, response); - } - } - picker_section_label(ui, "PROJECT TEXTURES"); - egui::ScrollArea::vertical() - .id_salt(id.with("project_textures")) - .max_height(240.0) - .auto_shrink([false, true]) - .show(ui, |ui| { - if candidates.is_empty() { - empty_picker_row(ui, "No project textures"); - } - for candidate in candidates { - let selected = selected.is_some_and(|current| { - same_reference(¤t.reference, &candidate.reference) - }); - texture_candidate_row(ui, candidate, selected, response); - } - }); - let palette = design_system::palette(ui); - ui.separator(); - if ui - .add(egui::Button::new(icon_label( - icons::FOLDER_OPEN, - "Browse Asset Library", - TypeRole::Control, - 14.0, - palette.text_primary, - ))) - .clicked() - { - response.browse_library = true; - ui.close(); - } - if selected.is_some() - && ui - .add(egui::Button::new(icon_label( - icons::X, - "Clear Texture", - TypeRole::Control, - 14.0, - palette.text_primary, - ))) - .clicked() - { - response.clear = true; - ui.close(); - } - }); -} - -fn picker_heading(ui: &mut egui::Ui, title: &str, subtitle: &str) { - let palette = design_system::palette(ui); - ui.label(TypeRole::Title.text(title).color(palette.text_primary)); - ui.label(TypeRole::Small.text(subtitle).color(palette.text_muted)); - ui.separator(); -} - -fn picker_section_label(ui: &mut egui::Ui, label: &str) { - let palette = design_system::palette(ui); - ui.add_space(4.0); - ui.label(TypeRole::Caption.text(label).color(palette.text_muted)); -} - -fn material_menu_row( - ui: &mut egui::Ui, - candidate: &MaterialPickerCandidate, - selected: bool, - actions: &mut Vec, -) { - let response = picker_asset_row( - ui, - &candidate.label, - &candidate.detail, - candidate.thumbnail, - selected, - ); - if response.clicked() { - remember_recent(ui, "material_picker_recents", &candidate.reference); - actions.push(MaterialSlotAction::Assign { - reference: candidate.reference.clone(), - from_drop: false, - }); - ui.close(); - } -} - -fn texture_candidate_row( - ui: &mut egui::Ui, - candidate: &MaterialTextureCandidate, - selected: bool, - response: &mut TextureSlotResponse, -) { - let row = picker_asset_row( - ui, - &candidate.label, - &candidate.detail, - candidate.thumbnail, - selected, - ); - if row.clicked() { - remember_recent(ui, "texture_picker_recents", &candidate.reference); - response.selected = Some(candidate.clone()); - ui.close(); - } -} - -fn picker_asset_row( - ui: &mut egui::Ui, - label: &str, - detail: &str, - thumbnail: MaterialThumbnailPresentation, - selected: bool, -) -> egui::Response { - let palette = design_system::palette(ui); - let (rect, response) = ui.allocate_exact_size( - egui::vec2(ui.available_width().max(1.0), PICKER_ROW_HEIGHT), - egui::Sense::click(), - ); - let fill = if selected { - palette.accent_dark - } else if response.hovered() { - palette.elevated - } else { - palette.recessed - }; - ui.painter().rect( - rect, - 5.0, - fill, - egui::Stroke::new( - 1.0_f32, - if selected { - palette.accent - } else { - palette.border - }, - ), - egui::StrokeKind::Inside, - ); - let thumb = egui::Rect::from_min_size( - rect.left_top() + egui::vec2(4.0, 4.0), - egui::vec2(PICKER_THUMBNAIL, PICKER_THUMBNAIL), - ); - paint_thumbnail(ui, thumb, thumbnail); - let text_rect = egui::Rect::from_min_max( - egui::pos2(thumb.right() + 8.0, rect.top() + 5.0), - egui::pos2(rect.right() - 8.0, rect.bottom() - 5.0), - ); - let mut child = ui.new_child( - egui::UiBuilder::new() - .max_rect(text_rect) - .layout(egui::Layout::top_down(egui::Align::Min)), - ); - child.set_clip_rect(rect.intersect(ui.clip_rect())); - child - .add(egui::Label::new(TypeRole::Body.text(label).color(palette.text_primary)).truncate()) - .on_hover_text(label); - child - .add(egui::Label::new(TypeRole::Small.text(detail).color(palette.text_muted)).truncate()) - .on_hover_text(detail); - response -} - -fn paint_thumbnail(ui: &egui::Ui, rect: egui::Rect, thumbnail: MaterialThumbnailPresentation) { - let palette = design_system::palette(ui); - ui.painter().rect_filled(rect, 4.0, palette.control); - match thumbnail { - MaterialThumbnailPresentation::Ready(texture) => { - ui.painter().image( - texture, - rect, - egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } - MaterialThumbnailPresentation::Pending => { - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - icons::CIRCLE_NOTCH.as_str(), - egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())), - palette.text_muted, - ); - } - MaterialThumbnailPresentation::Failed => { - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - icons::WARNING.as_str(), - egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())), - palette.error, - ); - } - } - ui.painter().rect_stroke( - rect, - 4.0, - egui::Stroke::new(1.0_f32, palette.border), - egui::StrokeKind::Inside, - ); -} - -fn picker_footer(ui: &mut egui::Ui, can_clear: bool, actions: &mut Vec) { - let palette = design_system::palette(ui); - ui.separator(); - if ui - .add(egui::Button::new(icon_label( - icons::FOLDER_OPEN, - "Browse Asset Library", - TypeRole::Control, - 14.0, - palette.text_primary, - ))) - .clicked() - { - actions.push(MaterialSlotAction::BrowseLibrary); - ui.close(); - } - if can_clear - && ui - .add(egui::Button::new(icon_label( - icons::X, - "Clear Material", - TypeRole::Control, - 14.0, - palette.text_primary, - ))) - .clicked() - { - actions.push(MaterialSlotAction::Clear); - ui.close(); - } -} - -fn unresolved_material_menu_row(ui: &mut egui::Ui, current: &EditorAssetRef) { - let palette = design_system::palette(ui); - ui.add_sized( - [ui.available_width().max(1.0), 28.0], - egui::Button::new(TypeRole::Body.text(¤t.label).color(palette.error)).selected(true), - ) - .on_hover_text("The currently assigned material could not be resolved"); -} - -fn empty_picker_row(ui: &mut egui::Ui, message: &str) { - let palette = design_system::palette(ui); - ui.add_sized( - [ui.available_width().max(1.0), 32.0], - egui::Label::new(TypeRole::Body.text(message).color(palette.text_muted)), - ); -} - -fn same_reference(left: &EditorAssetRef, right: &EditorAssetRef) -> bool { - left.asset_id == right.asset_id && left.sub_asset_id == right.sub_asset_id -} - -fn find_material_candidate<'a>( - candidates: &'a [MaterialPickerCandidate], - reference: &EditorAssetRef, -) -> Option<&'a MaterialPickerCandidate> { - candidates - .iter() - .find(|candidate| same_reference(&candidate.reference, reference)) -} - -fn recent_keys(ui: &egui::Ui, salt: &'static str) -> Vec<(String, String)> { - let id = ui.make_persistent_id(salt); - ui.ctx() - .data_mut(|data| data.get_persisted::>(id)) - .unwrap_or_default() -} - -fn remember_recent(ui: &egui::Ui, salt: &'static str, reference: &EditorAssetRef) { - let id = ui.make_persistent_id(salt); - let mut keys = recent_keys(ui, salt); - let key = (reference.asset_id.clone(), reference.sub_asset_id.clone()); - keys.retain(|current| current != &key); - keys.insert(0, key); - keys.truncate(8); - ui.ctx().data_mut(|data| data.insert_persisted(id, keys)); -} - -fn candidates_for_recent_keys<'a>( - candidates: &'a [MaterialPickerCandidate], - keys: &[(String, String)], -) -> Vec<&'a MaterialPickerCandidate> { - keys.iter() - .filter_map(|(asset_id, sub_asset_id)| { - candidates.iter().find(|candidate| { - candidate.reference.asset_id == *asset_id - && candidate.reference.sub_asset_id == *sub_asset_id - }) - }) - .collect() -} - -fn candidates_for_recent_texture_keys<'a>( - candidates: &'a [MaterialTextureCandidate], - keys: &[(String, String)], -) -> Vec<&'a MaterialTextureCandidate> { - keys.iter() - .filter_map(|(asset_id, sub_asset_id)| { - candidates.iter().find(|candidate| { - candidate.reference.asset_id == *asset_id - && candidate.reference.sub_asset_id == *sub_asset_id - }) - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn penpot_picker_reference_comparison_uses_stable_identity_not_label() { - let left = EditorAssetRef::new("asset", "material:source", "Left"); - let right = EditorAssetRef::new("asset", "material:source", "Renamed"); - assert!(same_reference(&left, &right)); - } - - #[test] - fn penpot_picker_geometry_matches_penpot_assets() { - assert_eq!(MATERIAL_PICKER_WIDTH, 244.0); - assert_eq!(TEXTURE_PICKER_WIDTH, 420.0); - assert_eq!(PICKER_ROW_HEIGHT, 44.0); - assert_eq!(PICKER_THUMBNAIL, 36.0); - } -} diff --git a/crates/editor/src/ui/materials/texture_inputs.rs b/crates/editor/src/ui/materials/texture_inputs.rs deleted file mode 100644 index 45a7c47..0000000 --- a/crates/editor/src/ui/materials/texture_inputs.rs +++ /dev/null @@ -1,408 +0,0 @@ -//! Texture/channel controls shared by Material and Material Instance input tables. - -use super::*; - -use crate::ui::design_system; -use crate::ui::design_system::typography::TypeRole; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TextureFieldAdornment { - TypedThumbnail, - BrokenReference, - Empty, -} - -fn texture_field_adornment( - presentation: Option, - current_present: bool, -) -> TextureFieldAdornment { - if presentation.is_some() { - TextureFieldAdornment::TypedThumbnail - } else if current_present { - TextureFieldAdornment::BrokenReference - } else { - TextureFieldAdornment::Empty - } -} - -pub(super) fn material_property_texture_binding( - textures: &[MaterialTextureBinding], - property: &MaterialInputDesc, -) -> MaterialTextureBinding { - textures - .iter() - .find(|texture| texture.name == property.name) - .cloned() - .unwrap_or_else(|| MaterialTextureBinding { - name: property.name.clone(), - texture: None, - channel: property - .texture - .as_ref() - .map_or(TextureChannel::Rgba, |desc| desc.default_channel), - }) -} - -pub(super) fn upsert_material_texture_binding( - textures: &mut Vec, - binding: MaterialTextureBinding, -) { - if let Some(existing) = textures - .iter_mut() - .find(|texture| texture.name == binding.name) - { - *existing = binding; - } else { - textures.push(binding); - } -} - -pub(super) fn material_texture_channel_ui( - ui: &mut egui::Ui, - property: &MaterialInputDesc, - binding: &mut MaterialTextureBinding, -) { - let allow_override = property - .texture - .as_ref() - .is_some_and(|texture| texture.allow_channel_override); - if !allow_override { - return; - } - egui::ComboBox::from_id_salt(("texture_channel", &property.name)) - .selected_text(texture_channel_label(binding.channel)) - .width(48.0) - .show_ui(ui, |ui| { - for channel in [ - TextureChannel::R, - TextureChannel::G, - TextureChannel::B, - TextureChannel::A, - ] { - ui.selectable_value( - &mut binding.channel, - channel, - texture_channel_label(channel), - ); - } - }); -} - -pub(super) fn texture_channel_label(channel: TextureChannel) -> &'static str { - match channel { - TextureChannel::R => "R", - TextureChannel::G => "G", - TextureChannel::B => "B", - TextureChannel::A => "A", - TextureChannel::Rgb => "RGB", - TextureChannel::Rgba => "RGBA", - } -} - -pub(super) fn ensure_material_texture_binding<'a>( - textures: &'a mut Vec, - name: &str, -) -> &'a mut MaterialTextureBinding { - let index = textures - .iter() - .position(|texture| texture.name == name) - .unwrap_or_else(|| { - textures.push(MaterialTextureBinding { - name: name.to_string(), - texture: None, - channel: TextureChannel::Rgba, - }); - textures.len() - 1 - }); - &mut textures[index] -} - -pub(super) fn texture_binding_ui( - ui: &mut egui::Ui, - binding: &mut MaterialTextureBinding, - texture_assets: &[MaterialTextureCandidate], - drop_candidate: Option<&MaterialTextureCandidate>, - inline_actions: bool, -) -> TextureSlotResponse { - let response = texture_slot_ui( - ui, - format!("binding_{}", binding.name), - TextureSlotSelection::Reference(binding.texture.as_ref()), - "(none)", - texture_assets, - drop_candidate, - inline_actions, - ); - if response.clear { - binding.texture = None; - } else if let Some(candidate) = response.selected.as_ref() { - binding.texture = Some(candidate.reference.clone()); - } - response -} - -pub(super) fn texture_slot_ui( - ui: &mut egui::Ui, - slot_id: impl std::hash::Hash, - current: TextureSlotSelection<'_>, - empty_label: &str, - candidates: &[MaterialTextureCandidate], - drop_candidate: Option<&MaterialTextureCandidate>, - inline_actions: bool, -) -> TextureSlotResponse { - let palette = design_system::palette(ui); - let mut result = TextureSlotResponse::default(); - ui.push_id(slot_id, |ui| { - let width = ui.available_width().max(1.0); - let (outer, _) = ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover()); - let group = outer; - let field_width = if inline_actions { - (group.width() - 60.0).max(1.0) - } else { - (group.width() - 30.0).max(1.0) - }; - let field_rect = egui::Rect::from_min_size(group.min, egui::vec2(field_width, 22.0)); - let locate_rect = egui::Rect::from_min_size( - group.min + egui::vec2(field_width + 6.0, 0.0), - egui::vec2(24.0, 22.0), - ); - let clear_rect = inline_actions.then(|| { - egui::Rect::from_min_size( - group.min + egui::vec2(field_width + 36.0, 0.0), - egui::vec2(24.0, 22.0), - ) - }); - let selected_candidate = candidates.iter().find(|candidate| match current { - TextureSlotSelection::Reference(reference) => reference.is_some_and(|reference| { - reference.asset_id == candidate.reference.asset_id - && reference.sub_asset_id == candidate.reference.sub_asset_id - }), - }); - let current_present = match current { - TextureSlotSelection::Reference(reference) => reference.is_some(), - }; - let display_label = selected_candidate - .map(|candidate| candidate.label.as_str()) - .or_else(|| match current { - TextureSlotSelection::Reference(reference) => { - reference.map(|reference| reference.label.as_str()) - } - }) - .unwrap_or(empty_label); - let presentation = selected_candidate.map(|candidate| candidate.thumbnail); - let adornment = texture_field_adornment(presentation, current_present); - let label_color = if current_present { - palette.text_primary - } else { - palette.text_muted - }; - ui.painter().rect( - field_rect, - 4.0, - palette.control, - egui::Stroke::new(1.0_f32, palette.border), - egui::StrokeKind::Inside, - ); - let field = ui.interact( - field_rect, - ui.make_persistent_id("texture_field"), - egui::Sense::click(), - ); - let image_rect = egui::Rect::from_center_size( - egui::pos2(field_rect.left() + 14.0, field_rect.center().y), - egui::vec2(16.0, 16.0), - ); - match selected_candidate.map(|candidate| candidate.thumbnail) { - Some(MaterialThumbnailPresentation::Ready(texture)) => { - ui.painter().image( - texture, - image_rect, - egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } - Some(MaterialThumbnailPresentation::Pending) => { - paint_field_icon(ui, image_rect, icons::CIRCLE_NOTCH, palette.text_muted); - } - Some(MaterialThumbnailPresentation::Failed) => { - paint_field_icon(ui, image_rect, icons::WARNING, palette.error); - } - None => paint_field_icon( - ui, - image_rect, - if adornment == TextureFieldAdornment::BrokenReference { - icons::WARNING - } else { - icons::IMAGE - }, - if adornment == TextureFieldAdornment::BrokenReference { - palette.error - } else { - palette.text_muted - }, - ), - } - let text_rect = egui::Rect::from_min_max( - egui::pos2(field_rect.left() + 27.0, field_rect.top()), - egui::pos2(field_rect.right() - 6.0, field_rect.bottom()), - ); - // `Label::truncate` centers its galley when forced into the full fixed field rectangle. - // Paint the truncated galley at the Penpot text origin instead: the typed thumbnail and - // the asset name then form one stable left-aligned identity regardless of field width. - let galley = egui::WidgetText::from(TypeRole::Body.text(display_label).color(label_color)) - .into_galley( - ui, - Some(egui::TextWrapMode::Truncate), - text_rect.width(), - egui::FontSelection::Default, - ); - let text_pos = egui::pos2( - text_rect.left(), - text_rect.center().y - galley.size().y * 0.5, - ); - let text_painter = ui - .painter() - .with_clip_rect(text_rect.intersect(ui.clip_rect())); - text_painter.galley(text_pos, galley, label_color); - field.clone().on_hover_text(display_label); - let drop_hovered = drop_candidate.is_some() && field.hovered(); - if drop_hovered { - ui.painter().rect_stroke( - field_rect, - 4.0, - egui::Stroke::new(2.0_f32, palette.accent), - egui::StrokeKind::Inside, - ); - } - super::pickers::texture_picker_popup( - ui, - field.id.with("texture_picker"), - &field, - candidates, - selected_candidate, - &mut result, - ); - if let Some(clear_rect) = clear_rect { - let locate = ui.put( - locate_rect, - egui::Button::new(icon_text(icons::CROSSHAIR, 14.0)) - .min_size(locate_rect.size()) - .fill(palette.control) - .stroke(egui::Stroke::new(1.0_f32, palette.border)), - ); - if selected_candidate.is_some() && locate.clicked() { - result.locate = selected_candidate.map(|candidate| candidate.selection.clone()); - } - locate.on_hover_text(if selected_candidate.is_some() { - "Locate texture in Content Browser" - } else { - "No texture to locate" - }); - let clear = ui.put( - clear_rect, - egui::Button::new(icon_text(icons::X, 14.0)) - .min_size(clear_rect.size()) - .fill(palette.control) - .stroke(egui::Stroke::new(1.0_f32, palette.border)), - ); - if current_present && clear.clicked() { - result.clear = true; - } - clear.on_hover_text(if current_present { - "Clear texture" - } else { - "No texture to clear" - }); - } else { - transient_texture_actions( - ui, - locate_rect, - selected_candidate, - current_present, - &mut result, - ); - } - if drop_hovered && ui.input(|input| input.pointer.any_released()) { - if let Some(candidate) = drop_candidate { - result.selected = Some(candidate.clone()); - result.accepted_drop = true; - } - } - }); - result -} - -fn transient_texture_actions( - ui: &mut egui::Ui, - rect: egui::Rect, - selected_candidate: Option<&MaterialTextureCandidate>, - current_present: bool, - result: &mut TextureSlotResponse, -) { - ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { - ui.set_clip_rect(rect.intersect(ui.clip_rect())); - ui.menu_button(icon_text(icons::DOTS_THREE, 14.0), |ui| { - if ui - .add_enabled(selected_candidate.is_some(), egui::Button::new("Locate")) - .clicked() - { - result.locate = selected_candidate.map(|candidate| candidate.selection.clone()); - ui.close(); - } - if ui - .add_enabled(current_present, egui::Button::new("Clear")) - .clicked() - { - result.clear = true; - ui.close(); - } - }) - .response - .on_hover_text("Texture actions"); - }); -} - -fn paint_field_icon( - ui: &egui::Ui, - rect: egui::Rect, - icon: egui_phosphor_icons::Icon, - color: egui::Color32, -) { - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - icon.as_str(), - egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-bold".into())), - color, - ); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ready_texture_uses_its_thumbnail_without_a_redundant_asset_glyph() { - assert_eq!( - texture_field_adornment( - Some(MaterialThumbnailPresentation::Ready( - egui::TextureId::Managed(7) - )), - true, - ), - TextureFieldAdornment::TypedThumbnail - ); - } - - #[test] - fn unresolved_and_empty_texture_fields_keep_distinct_fallbacks() { - assert_eq!( - texture_field_adornment(None, true), - TextureFieldAdornment::BrokenReference - ); - assert_eq!( - texture_field_adornment(None, false), - TextureFieldAdornment::Empty - ); - } -} diff --git a/crates/editor/src/ui/materials/uv_transform.rs b/crates/editor/src/ui/materials/uv_transform.rs deleted file mode 100644 index 1ac30ab..0000000 --- a/crates/editor/src/ui/materials/uv_transform.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! Shared material UV-transform authoring used by Materials and Material Instances. - -use bevy::prelude::Vec2; -use bevy_egui::egui; -use egui_phosphor_icons::icons; -use shared::{MaterialInputSet, MaterialParameter, MaterialParameterValue}; - -use crate::ui::design_system; -use crate::ui::design_system::typography::TypeRole; - -pub(super) fn uv_transform_section( - ui: &mut egui::Ui, - offset: &mut Vec2, - tiling: &mut Vec2, - reset_offset: Vec2, - reset_tiling: Vec2, -) { - let palette = design_system::palette(ui); - let width = ui.available_width().max(1.0); - let height = match super::layout::ResponsiveMaterialLayout::for_width(width) { - super::layout::ResponsiveMaterialLayout::Reference => { - crate::ui::design_system::UV_SECTION_HEIGHT - } - super::layout::ResponsiveMaterialLayout::Compact => { - crate::ui::design_system::COMPACT_UV_SECTION_HEIGHT - } - super::layout::ResponsiveMaterialLayout::Transient => 100.0, - }; - let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); - ui.painter().rect( - rect, - 5.0, - palette.section, - egui::Stroke::new(1.0_f32, palette.border), - egui::StrokeKind::Inside, - ); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect)); - child.set_clip_rect(rect.intersect(ui.clip_rect())); - let geometry = super::layout::UvSectionGeometry::for_rect(rect); - child.painter().text( - geometry.title.left_center(), - egui::Align2::LEFT_CENTER, - "UV", - TypeRole::Section.font(), - palette.text_primary, - ); - vec2_group( - &mut child, - geometry.offset, - "Offset", - offset, - reset_offset, - "Reset UV offset", - ); - vec2_group( - &mut child, - geometry.tiling, - "Tiling", - tiling, - reset_tiling, - "Reset UV tiling", - ); -} - -fn vec2_group( - ui: &mut egui::Ui, - geometry: super::layout::UvGroupGeometry, - label: &str, - value: &mut Vec2, - reset: Vec2, - tooltip: &str, -) { - let palette = design_system::palette(ui); - ui.painter().text( - geometry.label.left_center(), - egui::Align2::LEFT_CENTER, - label, - TypeRole::Small.font(), - palette.text_muted, - ); - ui.put( - geometry.x, - egui::DragValue::new(&mut value.x).speed(0.01).prefix("X "), - ); - ui.put( - geometry.y, - egui::DragValue::new(&mut value.y).speed(0.01).prefix("Y "), - ); - if let Some(reset_rect) = geometry.reset { - let enabled = *value != reset; - let response = ui.put( - reset_rect, - egui::Button::new( - egui::RichText::new(icons::ARROW_COUNTER_CLOCKWISE.as_str()).font( - egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())), - ), - ), - ); - if enabled && response.clicked() { - *value = reset; - } - response.on_hover_text(tooltip); - } -} - -pub(super) fn material_vec2_value(inputs: &MaterialInputSet, name: &str, default: Vec2) -> Vec2 { - inputs - .values - .iter() - .find(|parameter| parameter.name == name) - .and_then(|parameter| match parameter.value { - MaterialParameterValue::Vec2(value) => Some(value), - _ => None, - }) - .unwrap_or(default) -} - -pub(super) fn set_material_vec2_value(inputs: &mut MaterialInputSet, name: &str, value: Vec2) { - if let Some(parameter) = inputs - .values - .iter_mut() - .find(|parameter| parameter.name == name) - { - parameter.value = MaterialParameterValue::Vec2(value); - } else { - inputs.values.push(MaterialParameter { - name: name.into(), - value: MaterialParameterValue::Vec2(value), - }); - } -} diff --git a/crates/editor/src/ui/mod.rs b/crates/editor/src/ui/mod.rs index 7a3e680..24a0e9a 100644 --- a/crates/editor/src/ui/mod.rs +++ b/crates/editor/src/ui/mod.rs @@ -239,6 +239,7 @@ impl UiState { selected_entities: &mut self.selected_entities, renaming_entity: &mut self.renaming_entity, rename_buffer: &mut self.rename_buffer, + palette: editor_ui::theme::editor_palette(ctx), }; clamp_inspector_width( @@ -335,13 +336,33 @@ fn reconcile_operator_selection( } } +const CONTENT_BROWSER_TAB_BADGE_WIDTH: f32 = 74.0; +const CONTENT_BROWSER_TAB_BADGE_HEIGHT: f32 = 22.0; +const CONTENT_BROWSER_TAB_BADGE_GAP: f32 = 10.0; +const DOCK_TAB_CLOSE_RESERVE: f32 = 28.0; + fn tab_title(icon: egui_phosphor_icons::Icon, label: &str) -> egui::WidgetText { + tab_title_job( + icon, + label, + editor_ui::design_system::controls::PhosphorIconStyle::Regular, + egui::FontId::new(13.0, egui::FontFamily::Proportional), + ) + .into() +} + +fn tab_title_job( + icon: egui_phosphor_icons::Icon, + label: &str, + icon_style: editor_ui::design_system::controls::PhosphorIconStyle, + label_font: egui::FontId, +) -> egui::text::LayoutJob { let mut job = egui::text::LayoutJob::default(); job.append( icon.as_str(), 0.0, egui::TextFormat { - font_id: egui::FontId::new(13.0, egui::FontFamily::Name("phosphor-regular".into())), + font_id: editor_ui::design_system::controls::phosphor_icon_font(icon_style, 15.0), color: theme::TEXT, ..Default::default() }, @@ -350,14 +371,48 @@ fn tab_title(icon: egui_phosphor_icons::Icon, label: &str) -> egui::WidgetText { &format!(" {label}"), 0.0, egui::TextFormat { - font_id: egui::FontId::new(13.0, egui::FontFamily::Proportional), + font_id: label_font, color: theme::TEXT, ..Default::default() }, ); + job +} + +fn content_browser_tab_title() -> egui::WidgetText { + let mut job = tab_title_job( + icons::FOLDER_OPEN, + "Content Browser", + editor_ui::design_system::controls::PhosphorIconStyle::Fill, + editor_ui::design_system::typography::TypeRole::AssetTitle.font(), + ); + // The rounded PROJECT chip is painted by `on_tab_button`; reserve its exact width in the + // title galley so it never collides with the close button or neighboring tabs. + job.append( + "", + CONTENT_BROWSER_TAB_BADGE_GAP + CONTENT_BROWSER_TAB_BADGE_WIDTH, + egui::TextFormat { + font_id: editor_ui::design_system::typography::TypeRole::Eyebrow.font(), + color: theme::TEXT_DIM, + ..Default::default() + }, + ); job.into() } +fn content_browser_tab_badge_rect(tab_rect: egui::Rect) -> egui::Rect { + egui::Rect::from_center_size( + egui::pos2( + tab_rect.right() - DOCK_TAB_CLOSE_RESERVE - CONTENT_BROWSER_TAB_BADGE_WIDTH * 0.5, + tab_rect.center().y, + ), + egui::vec2( + CONTENT_BROWSER_TAB_BADGE_WIDTH, + CONTENT_BROWSER_TAB_BADGE_HEIGHT, + ), + ) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum EditorTab { Viewport, @@ -479,6 +534,7 @@ struct TabViewer<'a> { selected_entities: &'a mut SelectedEntities, renaming_entity: &'a mut Option, rename_buffer: &'a mut String, + palette: editor_ui::theme::EditorVisualPalette, } impl egui_dock::TabViewer for TabViewer<'_> { @@ -535,30 +591,77 @@ impl egui_dock::TabViewer for TabViewer<'_> { } fn title(&mut self, tab: &mut Self::Tab) -> egui::WidgetText { + if matches!(tab, EditorTab::AssetBrowser) { + return content_browser_tab_title(); + } let (icon, label) = match tab { EditorTab::Viewport | EditorTab::GameView => (icons::FILM_SLATE, "Viewport"), EditorTab::Hierarchy => (icons::LIST_BULLETS, "Hierarchy"), EditorTab::Inspector => (icons::SLIDERS, "Inspector"), EditorTab::Toolbar => (icons::WRENCH, "Toolbar"), - EditorTab::AssetBrowser => (icons::FOLDER, "Asset Browser"), + EditorTab::AssetBrowser => unreachable!("handled above"), EditorTab::MaterialLibrary => (icons::PALETTE, "Material Library"), EditorTab::Build => (icons::HAMMER, "Build"), }; tab_title(icon, label) } + fn on_tab_button(&mut self, tab: &mut Self::Tab, response: &egui::Response) { + if !matches!(tab, EditorTab::AssetBrowser) { + return; + } + let badge = content_browser_tab_badge_rect(response.rect); + let painter = response.ctx.layer_painter(response.layer_id); + painter.rect_filled( + badge, + CONTENT_BROWSER_TAB_BADGE_HEIGHT * 0.5, + self.palette.selection_bg, + ); + painter.text( + badge.center(), + egui::Align2::CENTER_CENTER, + "PROJECT", + editor_ui::design_system::typography::TypeRole::Eyebrow.font(), + self.palette.selection, + ); + } + fn clear_background(&self, tab: &Self::Tab) -> bool { match tab { EditorTab::Viewport | EditorTab::GameView => true, _ => true, } } + + fn tab_style_override( + &self, + tab: &Self::Tab, + global_style: &egui_dock::TabStyle, + ) -> Option { + if !matches!(tab, EditorTab::AssetBrowser) { + return None; + } + let mut style = global_style.clone(); + // The Content Browser owns exact edge-to-edge toolbar/body/footer rectangles. The generic + // four-pixel dock-body inset was the production-only gutter absent from Gallery renders. + style.tab_body.inner_margin = egui::Margin::ZERO; + Some(style) + } } #[cfg(test)] mod tests { use super::*; + #[test] + fn content_browser_project_badge_stays_inside_the_dock_tab() { + let tab = egui::Rect::from_min_size(egui::pos2(4.0, 2.0), egui::vec2(250.0, 32.0)); + let badge = content_browser_tab_badge_rect(tab); + assert_eq!(badge.size(), egui::vec2(74.0, 22.0)); + assert_eq!(badge.right(), tab.right() - DOCK_TAB_CLOSE_RESERVE); + assert!(tab.contains_rect(badge)); + } + #[test] fn operator_selection_wins_when_ui_selection_did_not_change() { let before = Entity::from_bits(1); diff --git a/crates/editor/src/ui/theme.rs b/crates/editor/src/ui/theme.rs index 4493c40..54a480f 100644 --- a/crates/editor/src/ui/theme.rs +++ b/crates/editor/src/ui/theme.rs @@ -196,7 +196,10 @@ pub fn editor_dock_style(ctx: &egui::Context) -> DockStyle { style.separator.color_dragged = palette.accent_hover; style.tab_bar.bg_fill = palette.panel_dark; - style.tab_bar.height = 30.0; + // Penpot's 36 px panel title row maps to the production dock tab. Keeping the dock row at the + // authored height lets docked and standalone Content Browser chrome share the same total + // 91/124 px geometry. + style.tab_bar.height = editor_ui::content_browser::DOCK_HEADER_HEIGHT; style.tab_bar.inner_margin = egui::Margin::symmetric(2, 1); style.tab_bar.hline_color = palette.accent; style.tab_bar.corner_radius = CornerRadius::ZERO; @@ -290,3 +293,17 @@ pub fn toolbar_group_frame() -> egui::Frame { .inner_margin(egui::Margin::symmetric(5, 3)) .stroke(Stroke::new(1.0_f32, BORDER)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn content_browser_dock_title_row_matches_the_penpot_header_height() { + let context = egui::Context::default(); + assert_eq!( + editor_dock_style(&context).tab_bar.height, + editor_ui::content_browser::DOCK_HEADER_HEIGHT + ); + } +} diff --git a/crates/editor_ui/src/content_browser.rs b/crates/editor_ui/src/content_browser.rs index 758dcfe..fb3b84e 100644 --- a/crates/editor_ui/src/content_browser.rs +++ b/crates/editor_ui/src/content_browser.rs @@ -4,133 +4,78 @@ //! transactions, selection, and authored-document state remain in the editor crate. use egui; -use egui_phosphor_icons::icons; +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 TOOLBAR_HEIGHT: f32 = 124.0; -pub const STATUS_BAR_HEIGHT: f32 = 32.0; -pub const COMPACT_BREAKPOINT: f32 = 720.0; +const SEARCH_ICON_INSET: f32 = 8.0; +const SEARCH_ICON_SIZE: f32 = 14.0; +const SEARCH_TEXT_INSET: f32 = 30.0; +const SEARCH_TRAILING_INSET: f32 = 6.0; +const SEARCH_TEXT_VERTICAL_INSET: f32 = 3.0; +const STANDARD_OPTIONS_TRAILING_SPAN: f32 = 432.0; +const EXPANDED_OPTIONS_TRAILING_SPAN: f32 = 658.0; +const THUMBNAIL_SLIDER_ICON_INSET: f32 = 13.0; +const THUMBNAIL_SLIDER_TRACK_LEFT_INSET: f32 = 34.0; +const THUMBNAIL_SLIDER_TRACK_RIGHT_INSET: f32 = 8.0; +const THUMBNAIL_SLIDER_TRACK_HEIGHT: f32 = 3.0; +const THUMBNAIL_SLIDER_INTERACTION_HEIGHT: f32 = 14.0; +const THUMBNAIL_SLIDER_THUMB_RADIUS: f32 = 5.0; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ContentBrowserViewMode { - Grid, - List, +#[derive(Debug, Clone, Copy, PartialEq)] +struct WideToolbarOptionsGeometry { + expanded: bool, + search: egui::Rect, + kind: egui::Rect, + sort: Option, + thumbnail_size: Option, + grid: egui::Rect, + list: egui::Rect, + details: egui::Rect, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ContentBrowserKindFilter { - All, - Model, - Texture, - Material, - Audio, - Level, - Prefab, - Builtin, -} - -impl ContentBrowserKindFilter { - pub const ALL: [Self; 8] = [ - Self::All, - Self::Model, - Self::Texture, - Self::Material, - Self::Audio, - Self::Level, - Self::Prefab, - Self::Builtin, - ]; - - pub const fn label(self) -> &'static str { - match self { - Self::All => "All assets", - Self::Model => "Models", - Self::Texture => "Textures", - Self::Material => "Materials", - Self::Audio => "Audio", - Self::Level => "Levels", - Self::Prefab => "Prefabs", - Self::Builtin => "Built-ins", +impl WideToolbarOptionsGeometry { + fn from_width(origin: egui::Pos2, width: f32, operations_reserve: f32) -> Self { + let expanded = width >= EXPANDED_OPTIONS_BREAKPOINT; + let right = width - operations_reserve; + let trailing_span = if expanded { + EXPANDED_OPTIONS_TRAILING_SPAN + } else { + STANDARD_OPTIONS_TRAILING_SPAN + }; + let search_x = right - trailing_span; + if expanded { + Self { + expanded, + search: at(origin, search_x, 11.0, 146.0, 28.0), + kind: at(origin, search_x + 156.0, 11.0, 104.0, 28.0), + sort: Some(at(origin, search_x + 268.0, 11.0, 88.0, 28.0)), + thumbnail_size: Some(at(origin, search_x + 364.0, 11.0, 122.0, 28.0)), + grid: at(origin, search_x + 494.0, 11.0, 28.0, 28.0), + list: at(origin, search_x + 528.0, 11.0, 28.0, 28.0), + details: at(origin, search_x + 562.0, 11.0, 88.0, 28.0), + } + } else { + Self { + expanded, + search: at(origin, search_x, 11.0, 146.0, 28.0), + kind: at(origin, search_x + 156.0, 11.0, 104.0, 28.0), + sort: None, + thumbnail_size: None, + grid: at(origin, search_x + 268.0, 11.0, 28.0, 28.0), + list: at(origin, search_x + 302.0, 11.0, 28.0, 28.0), + details: at(origin, search_x + 336.0, 11.0, 88.0, 28.0), + } } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ContentBrowserSort { - Name, - Kind, - Modified, - Size, -} - -impl ContentBrowserSort { - pub const ALL: [Self; 4] = [Self::Name, Self::Kind, Self::Modified, Self::Size]; - - pub const fn label(self) -> &'static str { - match self { - Self::Name => "Name", - Self::Kind => "Type", - Self::Modified => "Modified", - Self::Size => "Size", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ContentPathSegment { - pub path: String, - pub label: String, -} - -#[derive(Debug, Clone)] -pub struct ContentBrowserToolbarViewModel { - pub path: Vec, - pub search: String, - pub kind_filter: ContentBrowserKindFilter, - pub sort: ContentBrowserSort, - pub view: ContentBrowserViewMode, - pub recursive: bool, - pub show_details: bool, - pub thumbnail_size: f32, - pub can_go_parent: bool, - pub can_go_back: bool, - pub can_go_forward: bool, - pub can_edit_folder: bool, - pub can_rename: bool, - pub can_edit_selection: bool, - pub can_paste: bool, - pub can_undo: bool, - pub pending_repairs: usize, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum ContentBrowserToolbarAction { - Back, - Forward, - Parent, - Refresh, - Navigate(String), - ImportHere, - ImportTo, - NewFolder, - Rename, - Duplicate, - Undo, - Cut, - Copy, - Paste, - OpenTrash, - ReviewMoves, - SetSearch(String), - SetKindFilter(ContentBrowserKindFilter), - SetSort(ContentBrowserSort), - SetView(ContentBrowserViewMode), - SetRecursive(bool), - SetShowDetails(bool), - SetThumbnailSize(f32), -} +mod body; +mod types; +pub use body::*; +pub use types::*; pub fn content_browser_toolbar( ui: &mut egui::Ui, @@ -140,353 +85,848 @@ pub fn content_browser_toolbar( let mut actions = Vec::new(); let width = ui.available_width().max(1.0); let compact = width < COMPACT_BREAKPOINT; - let (rect, _) = ui.allocate_exact_size(egui::vec2(width, TOOLBAR_HEIGHT), egui::Sense::hover()); - ui.painter().rect_filled(rect, 0.0, palette.recessed); - ui.painter().line_segment( - [rect.left_bottom(), rect.right_bottom()], - egui::Stroke::new(1.0_f32, palette.border), + let height = model.chrome.height(width); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + let mut chrome_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + chrome_ui.set_clip_rect(rect.intersect(ui.clip_rect())); + chrome_ui.painter().rect( + rect, + 5.0, + palette.panel, + egui::Stroke::new(1.0_f32, palette.border_strong), + egui::StrokeKind::Inside, ); - let title_bar = egui::Rect::from_min_max( - rect.min + egui::vec2(8.0, 2.0), - egui::pos2(rect.right() - 8.0, rect.top() + 31.0), - ); - ui.scope_builder(egui::UiBuilder::new().max_rect(title_bar), |ui| { - ui.horizontal_centered(|ui| { - ui.label( - egui::RichText::new(icons::FOLDER_OPEN.as_str()) - .font(egui::FontId::new( - 14.0, - egui::FontFamily::Name("phosphor-bold".into()), - )) - .color(palette.text_secondary), - ); - ui.label(TypeRole::AssetTitle.text("Content Browser")); - let project = egui::Button::new(TypeRole::Eyebrow.text("PROJECT")) - .fill(palette.accent_dark) - .stroke(egui::Stroke::new(1.0_f32, palette.accent)) - .corner_radius(egui::CornerRadius::same(4)); - ui.add(project).on_hover_text("Managed project content"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - design_system::controls::icon_button( - ui, - icons::DOTS_THREE_VERTICAL, - "Content Browser options", - true, - ); - }); - }); - }); + if model.chrome == ContentBrowserChrome::Standalone { + draw_standalone_header(&mut chrome_ui, rect, model, &mut actions); + } - let primary = egui::Rect::from_min_max( - egui::pos2(rect.left() + 8.0, rect.top() + 38.0), - egui::pos2(rect.right() - 8.0, rect.top() + 76.0), - ); - ui.scope_builder(egui::UiBuilder::new().max_rect(primary), |ui| { - ui.horizontal_centered(|ui| { - if design_system::controls::icon_button( - ui, - icons::ARROW_LEFT, - "Back", - model.can_go_back, - ) - .clicked() - { - actions.push(ContentBrowserToolbarAction::Back); - } - if design_system::controls::icon_button( - ui, - icons::ARROW_RIGHT, - "Forward", - model.can_go_forward, - ) - .clicked() - { - actions.push(ContentBrowserToolbarAction::Forward); - } - if design_system::controls::icon_button( - ui, - icons::ARROW_UP, - "Parent folder", - model.can_go_parent, - ) - .clicked() - { - actions.push(ContentBrowserToolbarAction::Parent); - } - if design_system::controls::icon_button( - ui, - icons::ARROWS_CLOCKWISE, - "Refresh content", - true, - ) - .clicked() - { - actions.push(ContentBrowserToolbarAction::Refresh); - } - if ui - .add( - egui::Button::new(design_system::controls::icon_label( - icons::UPLOAD_SIMPLE, - "Import Here", - TypeRole::Control, - 13.0, - palette.text_primary, - )) - .fill(palette.accent_dark) - .stroke(egui::Stroke::new(1.0_f32, palette.accent)) - .corner_radius(egui::CornerRadius::same(4)), - ) - .clicked() - { - actions.push(ContentBrowserToolbarAction::ImportHere); - } - if !compact - && ui - .add_enabled( - model.can_edit_folder, - egui::Button::new(design_system::controls::icon_label( - icons::FOLDER_PLUS, - "New Folder", - TypeRole::Control, - 13.0, - palette.text_primary, - )), - ) - .clicked() - { - actions.push(ContentBrowserToolbarAction::NewFolder); - } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - content_operations_menu(ui, model, &mut actions); - if model.pending_repairs > 0 - && ui - .button(format!("Resolve {} moves", model.pending_repairs)) - .on_hover_text("Review ambiguous external move identities") - .clicked() - { - actions.push(ContentBrowserToolbarAction::ReviewMoves); - } - if !compact { - let mut details = model.show_details; - if ui - .add(egui::Button::new(design_system::controls::icon_label( - icons::INFO, - "Details", - TypeRole::Control, - 13.0, - palette.text_primary, - ))) - .clicked() - { - details = !details; - actions.push(ContentBrowserToolbarAction::SetShowDetails(details)); - } - } - for (mode, icon, tooltip) in [ - (ContentBrowserViewMode::List, icons::LIST, "List view"), - (ContentBrowserViewMode::Grid, icons::GRID_FOUR, "Grid view"), - ] { - let response = design_system::controls::icon_button(ui, icon, tooltip, true); - if model.view == mode { - ui.painter().rect_stroke( - response.rect, - 4.0, - egui::Stroke::new(1.0_f32, palette.accent), - egui::StrokeKind::Inside, - ); - } - if response.clicked() { - actions.push(ContentBrowserToolbarAction::SetView(mode)); - } - } - let mut filter = model.kind_filter; - egui::ComboBox::from_id_salt("content_browser_kind") - .width(if compact { 72.0 } else { 92.0 }) - .selected_text(filter.label()) - .show_ui(ui, |ui| { - for option in ContentBrowserKindFilter::ALL { - ui.selectable_value(&mut filter, option, option.label()); - } - }); - if filter != model.kind_filter { - actions.push(ContentBrowserToolbarAction::SetKindFilter(filter)); - } - let mut search = model.search.clone(); - if ui - .add_sized( - [if compact { 116.0 } else { 184.0 }, 24.0], - egui::TextEdit::singleline(&mut search).hint_text("Search project assets"), - ) - .changed() - { - actions.push(ContentBrowserToolbarAction::SetSearch(search)); - } - }); - }); - }); - - let secondary = egui::Rect::from_min_max( - egui::pos2(rect.left() + 8.0, rect.top() + 84.0), - egui::pos2(rect.right() - 8.0, rect.bottom() - 5.0), - ); - ui.scope_builder(egui::UiBuilder::new().max_rect(secondary), |ui| { - path_bar(ui, &model.path, &mut actions); - }); + let origin = rect.min + egui::vec2(0.0, model.chrome.y_offset()); + if compact { + draw_compact_toolbar(&mut chrome_ui, origin, width, model, &mut actions); + } else { + draw_wide_toolbar(&mut chrome_ui, origin, width, model, &mut actions); + } actions } -fn path_bar( +fn draw_standalone_header( ui: &mut egui::Ui, - path: &[ContentPathSegment], + rect: egui::Rect, + model: &ContentBrowserToolbarViewModel, actions: &mut Vec, ) { let palette = design_system::palette(ui); - let (rect, _) = ui.allocate_exact_size( - egui::vec2(ui.available_width().max(1.0), 24.0), - egui::Sense::hover(), + ui.painter().line_segment( + [ + egui::pos2(rect.left(), rect.top() + 35.0), + egui::pos2(rect.right(), rect.top() + 35.0), + ], + egui::Stroke::new(1.0_f32, palette.border), ); - ui.painter().rect( + ui.painter().text( + rect.min + egui::vec2(14.0, 18.0), + egui::Align2::CENTER_CENTER, + icons::FOLDER_OPEN.as_str(), + phosphor_icon_font(PhosphorIconStyle::Fill, 15.0), + palette.text_secondary, + ); + ui.painter().text( + rect.min + egui::vec2(38.0, 18.0), + egui::Align2::LEFT_CENTER, + "Content Browser", + TypeRole::AssetTitle.font(), + palette.text_primary, + ); + let chip = egui::Rect::from_min_size(rect.min + egui::vec2(150.0, 7.0), egui::vec2(74.0, 22.0)); + ui.painter().rect_filled(chip, 11.0, palette.accent_dark); + ui.painter().text( + chip.center(), + egui::Align2::CENTER_CENTER, + "PROJECT", + TypeRole::Eyebrow.font(), + palette.accent, + ); + if rect.width() >= COMPACT_BREAKPOINT { + ui.painter().text( + egui::pos2(rect.right() - 73.0, rect.top() + 18.0), + egui::Align2::RIGHT_CENTER, + format!( + "{} folders · {} assets", + model.direct_folder_count, model.direct_asset_count + ), + TypeRole::Small.font(), + palette.text_muted, + ); + } + let menu_right_inset = if rect.width() >= COMPACT_BREAKPOINT { + 80.0 + } else { + 40.0 + }; + operations_menu( + ui, + egui::Rect::from_min_size( + egui::pos2(rect.right() - menu_right_inset, rect.top() + 4.0), + egui::vec2(28.0, 28.0), + ), + model, + actions, + rect.width() < EXPANDED_OPTIONS_BREAKPOINT, + ); +} + +fn draw_wide_toolbar( + ui: &mut egui::Ui, + origin: egui::Pos2, + width: f32, + model: &ContentBrowserToolbarViewModel, + actions: &mut Vec, +) { + nav_button( + ui, + at(origin, 12.0, 11.0, 28.0, 28.0), + icons::CARET_LEFT, + "Back", + model.can_go_back, + ContentBrowserToolbarAction::Back, + actions, + ); + nav_button( + ui, + at(origin, 46.0, 11.0, 28.0, 28.0), + icons::CARET_RIGHT, + "Forward", + model.can_go_forward, + ContentBrowserToolbarAction::Forward, + actions, + ); + nav_button( + ui, + at(origin, 80.0, 11.0, 28.0, 28.0), + icons::CARET_UP, + "Parent folder", + model.can_go_parent, + ContentBrowserToolbarAction::Parent, + actions, + ); + nav_button( + ui, + at(origin, 114.0, 11.0, 28.0, 28.0), + icons::ARROW_COUNTER_CLOCKWISE, + "Refresh content", + true, + ContentBrowserToolbarAction::Refresh, + actions, + ); + + if placed_text_button( + ui, + at(origin, 156.0, 11.0, 108.0, 28.0), + icons::UPLOAD_SIMPLE, + "Import Here", + true, + true, + ) + .clicked() + { + actions.push(ContentBrowserToolbarAction::ImportHere); + } + if placed_text_button( + ui, + at(origin, 272.0, 11.0, 65.0, 28.0), + icons::PLUS, + "New", + model.can_edit_folder, + false, + ) + .clicked() + { + actions.push(ContentBrowserToolbarAction::NewFolder); + } + + // Penpot anchors the right control cluster while the space between breadcrumbs and search + // absorbs wider workspaces. Docked production keeps its operations menu in that flexible gap + // until the dock tab itself can host panel actions. + let operations_reserve = if model.chrome == ContentBrowserChrome::Docked { + 40.0 + } else { + 0.0 + }; + let geometry = WideToolbarOptionsGeometry::from_width(origin, width, operations_reserve); + let breadcrumb_width = (geometry.search.left() - origin.x - 51.0 - 351.0).max(122.0); + breadcrumb( + ui, + at(origin, 351.0, 11.0, breadcrumb_width, 28.0), + &model.path, + None, + actions, + ); + search_field(ui, geometry.search, model, actions); + kind_filter(ui, geometry.kind, model, actions); + if let Some(rect) = geometry.sort { + sort_control(ui, rect, model, actions); + } + if let Some(rect) = geometry.thumbnail_size { + thumbnail_size_control(ui, rect, model, actions); + } + view_button( + ui, + geometry.grid, + ContentBrowserViewMode::Grid, + icons::SQUARES_FOUR, + model, + actions, + ); + view_button( + ui, + geometry.list, + ContentBrowserViewMode::List, + icons::CLIPBOARD_TEXT, + model, + actions, + ); + if placed_text_button( + ui, + geometry.details, + icons::INFO, + "Details", + true, + model.show_details, + ) + .clicked() + { + actions.push(ContentBrowserToolbarAction::SetShowDetails( + !model.show_details, + )); + } + if model.chrome == ContentBrowserChrome::Docked { + operations_menu( + ui, + at(origin, width - 40.0, 11.0, 28.0, 28.0), + model, + actions, + !geometry.expanded, + ); + } +} + +fn draw_compact_toolbar( + ui: &mut egui::Ui, + origin: egui::Pos2, + width: f32, + model: &ContentBrowserToolbarViewModel, + actions: &mut Vec, +) { + nav_button( + ui, + at(origin, 12.0, 11.0, 28.0, 28.0), + icons::CARET_LEFT, + "Back", + model.can_go_back, + ContentBrowserToolbarAction::Back, + actions, + ); + nav_button( + ui, + at(origin, 46.0, 11.0, 28.0, 28.0), + icons::CARET_RIGHT, + "Forward", + model.can_go_forward, + ContentBrowserToolbarAction::Forward, + actions, + ); + nav_button( + ui, + at(origin, 80.0, 11.0, 28.0, 28.0), + icons::CARET_UP, + "Parent folder", + model.can_go_parent, + ContentBrowserToolbarAction::Parent, + actions, + ); + + let right = width - 12.0; + let search_right = (right - 200.0).max(184.0); + search_field( + ui, + at(origin, 116.0, 11.0, (search_right - 116.0).max(68.0), 28.0), + model, + actions, + ); + view_button( + ui, + at(origin, right - 192.0, 11.0, 28.0, 28.0), + ContentBrowserViewMode::Grid, + icons::SQUARES_FOUR, + model, + actions, + ); + view_button( + ui, + at(origin, right - 158.0, 11.0, 28.0, 28.0), + ContentBrowserViewMode::List, + icons::CLIPBOARD_TEXT, + model, + actions, + ); + if placed_icon_button_with_style( + ui, + at(origin, right - 124.0, 11.0, 28.0, 28.0), + icons::FOLDER_OPEN, + "Sources", + true, + model.show_sources, + PhosphorIconStyle::Fill, + ) + .clicked() + { + actions.push(ContentBrowserToolbarAction::SetShowSources( + !model.show_sources, + )); + } + if placed_icon_button( + ui, + at(origin, right - 90.0, 11.0, 28.0, 28.0), + icons::INFO, + "Details", + true, + model.show_details, + ) + .clicked() + { + actions.push(ContentBrowserToolbarAction::SetShowDetails( + !model.show_details, + )); + } + if model.chrome == ContentBrowserChrome::Docked { + operations_menu( + ui, + at(origin, right - 28.0, 11.0, 28.0, 28.0), + model, + actions, + true, + ); + } + + let new_width = 88.0_f32.min((width - 302.0).max(56.0)); + let new_x = (width - 42.0 - new_width).max(196.0); + let import_width = 98.0_f32.min((new_x - 94.0).max(80.0)); + let import_x = (new_x - 8.0 - import_width).max(104.0); + breadcrumb( + ui, + at(origin, 12.0, 50.0, (import_x - 84.0).max(120.0), 28.0), + &model.path, + None, + actions, + ); + if placed_text_button( + ui, + at(origin, import_x, 49.0, import_width, 28.0), + icons::UPLOAD_SIMPLE, + "Import", + true, + true, + ) + .clicked() + { + actions.push(ContentBrowserToolbarAction::ImportHere); + } + if placed_text_button( + ui, + at(origin, new_x, 50.0, new_width, 28.0), + icons::PLUS, + "New", + model.can_edit_folder, + false, + ) + .clicked() + { + actions.push(ContentBrowserToolbarAction::NewFolder); + } +} + +fn nav_button( + ui: &mut egui::Ui, + rect: egui::Rect, + icon: Icon, + tooltip: &str, + enabled: bool, + action: ContentBrowserToolbarAction, + actions: &mut Vec, +) { + if placed_icon_button(ui, rect, icon, tooltip, enabled, false).clicked() { + actions.push(action); + } +} + +fn view_button( + ui: &mut egui::Ui, + rect: egui::Rect, + mode: ContentBrowserViewMode, + icon: Icon, + model: &ContentBrowserToolbarViewModel, + actions: &mut Vec, +) { + if placed_icon_button_with_style( + ui, + rect, + icon, + if mode == ContentBrowserViewMode::Grid { + "Grid view" + } else { + "List view" + }, + true, + model.view == mode, + if mode == ContentBrowserViewMode::List { + PhosphorIconStyle::Regular + } else { + PhosphorIconStyle::Bold + }, + ) + .clicked() + { + actions.push(ContentBrowserToolbarAction::SetView(mode)); + } +} + +fn search_field( + ui: &mut egui::Ui, + rect: egui::Rect, + model: &ContentBrowserToolbarViewModel, + actions: &mut Vec, +) { + let palette = design_system::palette(ui); + let geometry = SearchFieldGeometry::from_rect(rect); + let clip = rect.intersect(ui.clip_rect()); + let painter = ui.painter().with_clip_rect(clip); + painter.rect( rect, 4.0, palette.recessed, egui::Stroke::new(1.0_f32, palette.border), egui::StrokeKind::Inside, ); - ui.scope_builder( - egui::UiBuilder::new().max_rect(rect.shrink2(egui::vec2(8.0, 2.0))), - |ui| { - ui.horizontal_centered(|ui| { - for (index, segment) in path.iter().enumerate() { - if index > 0 { - ui.label(TypeRole::Small.text("/").color(palette.text_muted)); - } - let response = ui - .add(egui::Button::new(TypeRole::Small.text(&segment.label)).frame(false)); - if response.clicked() { - actions.push(ContentBrowserToolbarAction::Navigate(segment.path.clone())); - } - response.on_hover_text(&segment.path); - } - }); - }, + painter.text( + geometry.icon.center(), + egui::Align2::CENTER_CENTER, + icons::MAGNIFYING_GLASS.as_str(), + phosphor_font(SEARCH_ICON_SIZE), + palette.text_muted, ); + + let mut search = model.search.clone(); + if ui + .put( + geometry.text, + egui::TextEdit::singleline(&mut search) + .id_salt("content_browser_search") + .font(TypeRole::Control.font()) + .text_color(palette.text_primary) + .hint_text("Search project assets") + .vertical_align(egui::Align::Center) + .margin(egui::Margin::ZERO) + .frame(egui::Frame::NONE), + ) + .changed() + { + actions.push(ContentBrowserToolbarAction::SetSearch(search)); + } } -fn content_operations_menu( +#[derive(Debug, Clone, Copy, PartialEq)] +struct SearchFieldGeometry { + icon: egui::Rect, + text: egui::Rect, +} + +impl SearchFieldGeometry { + fn from_rect(rect: egui::Rect) -> Self { + let icon = egui::Rect::from_min_size( + egui::pos2( + rect.left() + SEARCH_ICON_INSET, + rect.center().y - SEARCH_ICON_SIZE * 0.5, + ), + egui::Vec2::splat(SEARCH_ICON_SIZE), + ); + let text_left = (rect.left() + SEARCH_TEXT_INSET).min(rect.right() - 1.0); + let text_right = (rect.right() - SEARCH_TRAILING_INSET).max(text_left + 1.0); + let text = egui::Rect::from_min_max( + egui::pos2(text_left, rect.top() + SEARCH_TEXT_VERTICAL_INSET), + egui::pos2(text_right, rect.bottom() - SEARCH_TEXT_VERTICAL_INSET), + ); + Self { icon, text } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct ThumbnailSizeSliderGeometry { + icon: egui::Rect, + interaction: egui::Rect, + track: egui::Rect, +} + +impl ThumbnailSizeSliderGeometry { + fn from_rect(rect: egui::Rect) -> Self { + let icon = egui::Rect::from_center_size( + egui::pos2(rect.left() + THUMBNAIL_SLIDER_ICON_INSET, rect.center().y), + egui::Vec2::splat(14.0), + ); + let track_left = (rect.left() + THUMBNAIL_SLIDER_TRACK_LEFT_INSET).min(rect.right()); + let track_right = (rect.right() - THUMBNAIL_SLIDER_TRACK_RIGHT_INSET).max(track_left); + let interaction = egui::Rect::from_center_size( + egui::pos2((track_left + track_right) * 0.5, rect.center().y), + egui::vec2( + (track_right - track_left).max(1.0), + THUMBNAIL_SLIDER_INTERACTION_HEIGHT, + ), + ); + let track = egui::Rect::from_center_size( + interaction.center(), + egui::vec2(interaction.width(), THUMBNAIL_SLIDER_TRACK_HEIGHT), + ); + Self { + icon, + interaction, + track, + } + } +} + +fn kind_filter( ui: &mut egui::Ui, + rect: egui::Rect, model: &ContentBrowserToolbarViewModel, actions: &mut Vec, ) { - let response = ui.menu_button( - design_system::controls::icon_label( - icons::DOTS_THREE, - "Actions", - TypeRole::Control, - 13.0, - design_system::palette(ui).text_primary, - ), + let mut filter = model.kind_filter; + ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + egui::ComboBox::from_id_salt("content_browser_kind") + .width(rect.width()) + .selected_text(filter.label()) + .show_ui(ui, |ui| { + for option in ContentBrowserKindFilter::ALL { + ui.selectable_value(&mut filter, option, option.label()); + } + }); + }); + if filter != model.kind_filter { + actions.push(ContentBrowserToolbarAction::SetKindFilter(filter)); + } +} + +fn sort_control( + ui: &mut egui::Ui, + rect: egui::Rect, + model: &ContentBrowserToolbarViewModel, + actions: &mut Vec, +) { + let mut sort = model.sort; + ui.scope_builder( + egui::UiBuilder::new() + .max_rect(rect) + .id_salt("content_browser_sort_inline"), |ui| { - operation( - ui, - "Import To…", - model.can_edit_folder, - ContentBrowserToolbarAction::ImportTo, - actions, - ); - operation( - ui, - "New Folder", - model.can_edit_folder, - ContentBrowserToolbarAction::NewFolder, - actions, - ); - ui.separator(); - operation( - ui, - "Rename", - model.can_rename, - ContentBrowserToolbarAction::Rename, - actions, - ); - operation( - ui, - "Duplicate", - model.can_edit_selection, - ContentBrowserToolbarAction::Duplicate, - actions, - ); - operation( - ui, - "Cut", - model.can_edit_selection, - ContentBrowserToolbarAction::Cut, - actions, - ); - operation( - ui, - "Copy", - model.can_edit_selection, - ContentBrowserToolbarAction::Copy, - actions, - ); - operation( - ui, - "Paste", - model.can_paste, - ContentBrowserToolbarAction::Paste, - actions, - ); - ui.separator(); - operation( - ui, - "Undo Content Operation", - model.can_undo, - ContentBrowserToolbarAction::Undo, - actions, - ); - operation( - ui, - "Open Trash", - true, - ContentBrowserToolbarAction::OpenTrash, - actions, - ); - ui.separator(); - let mut recursive = model.recursive; - if ui.checkbox(&mut recursive, "Include subfolders").changed() { - actions.push(ContentBrowserToolbarAction::SetRecursive(recursive)); - } - let mut details = model.show_details; - if ui.checkbox(&mut details, "Show details pane").changed() { - actions.push(ContentBrowserToolbarAction::SetShowDetails(details)); - } - ui.label(TypeRole::Caption.text("Thumbnail size")); - let mut thumbnail_size = model.thumbnail_size; - if ui - .add(egui::Slider::new(&mut thumbnail_size, 48.0..=112.0).show_value(false)) - .changed() - { - actions.push(ContentBrowserToolbarAction::SetThumbnailSize( - thumbnail_size, - )); - } - ui.label(TypeRole::Caption.text("Sort by")); - let mut sort = model.sort; - egui::ComboBox::from_id_salt("content_browser_sort") + egui::ComboBox::from_id_salt("value") + .width(rect.width()) .selected_text(sort.label()) .show_ui(ui, |ui| { for option in ContentBrowserSort::ALL { ui.selectable_value(&mut sort, option, option.label()); } }); - if sort != model.sort { - actions.push(ContentBrowserToolbarAction::SetSort(sort)); - } }, ); - response.response.on_hover_text("Content operations"); + if sort != model.sort { + actions.push(ContentBrowserToolbarAction::SetSort(sort)); + } +} + +fn thumbnail_size_control( + ui: &mut egui::Ui, + rect: egui::Rect, + model: &ContentBrowserToolbarViewModel, + actions: &mut Vec, +) { + let palette = design_system::palette(ui); + let clip = rect.intersect(ui.clip_rect()); + let painter = ui.painter().with_clip_rect(clip); + painter.rect( + rect, + 4.0, + palette.recessed, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + let geometry = ThumbnailSizeSliderGeometry::from_rect(rect); + painter.text( + geometry.icon.center(), + egui::Align2::CENTER_CENTER, + icons::IMAGE.as_str(), + phosphor_font(13.0), + palette.text_secondary, + ); + let mut thumbnail_size = model.thumbnail_size; + let enabled = model.view == ContentBrowserViewMode::Grid; + let response = ui.interact( + geometry.interaction, + ui.make_persistent_id("content_browser_thumbnail_size_inline"), + if enabled { + egui::Sense::click_and_drag() + } else { + egui::Sense::hover() + }, + ); + if enabled && (response.clicked() || response.dragged()) { + if let Some(pointer) = response.interact_pointer_pos() { + let normalized = + ((pointer.x - geometry.track.left()) / geometry.track.width()).clamp(0.0, 1.0); + thumbnail_size = egui::lerp( + ASSET_CARD_MIN_THUMBNAIL_SIZE..=ASSET_CARD_MAX_THUMBNAIL_SIZE, + normalized, + ); + } + } + let normalized = ((thumbnail_size - ASSET_CARD_MIN_THUMBNAIL_SIZE) + / (ASSET_CARD_MAX_THUMBNAIL_SIZE - ASSET_CARD_MIN_THUMBNAIL_SIZE)) + .clamp(0.0, 1.0); + painter.rect_filled(geometry.track, 1.5, palette.border_strong); + let fill_right = egui::lerp(geometry.track.x_range(), normalized); + if fill_right > geometry.track.left() { + painter.rect_filled( + egui::Rect::from_min_max( + geometry.track.left_top(), + egui::pos2(fill_right, geometry.track.bottom()), + ), + 1.5, + palette.accent, + ); + } + painter.circle_filled( + egui::pos2(fill_right, geometry.track.center().y), + THUMBNAIL_SLIDER_THUMB_RADIUS, + if enabled { + palette.accent + } else { + palette.text_muted + }, + ); + response.on_hover_text(if enabled { + format!("Thumbnail size: {:.0} px", model.thumbnail_size) + } else { + "Thumbnail size applies to Grid view".to_owned() + }); + if enabled && thumbnail_size != model.thumbnail_size { + actions.push(ContentBrowserToolbarAction::SetThumbnailSize( + thumbnail_size, + )); + } +} + +fn breadcrumb( + ui: &mut egui::Ui, + rect: egui::Rect, + path: &[ContentPathSegment], + summary: Option, + actions: &mut Vec, +) { + let palette = design_system::palette(ui); + let mut x = rect.left() + 16.0; + let max_x = summary + .as_ref() + .map_or(rect.right() - 12.0, |_| rect.right() - 190.0); + for (index, segment) in path.iter().enumerate() { + if index > 0 { + ui.painter().text( + egui::pos2(x, rect.center().y), + egui::Align2::LEFT_CENTER, + "›", + TypeRole::Small.font(), + palette.text_muted, + ); + x += 16.0; + } + let label_width = (segment.label.chars().count() as f32 * 5.8 + 16.0).clamp(42.0, 104.0); + if x + label_width > max_x { + break; + } + let segment_rect = egui::Rect::from_min_size( + egui::pos2(x, rect.top() + 4.0), + egui::vec2(label_width, 22.0), + ); + let active = index + 1 == path.len(); + let response = ui.interact( + segment_rect, + ui.make_persistent_id(("content_path", &segment.path)), + egui::Sense::click(), + ); + if active { + ui.painter() + .rect_filled(segment_rect, 11.0, palette.accent_dark); + } + ui.painter().text( + segment_rect.center(), + egui::Align2::CENTER_CENTER, + &segment.label, + TypeRole::Small.font(), + if active { + palette.text_primary + } else { + palette.text_secondary + }, + ); + if response.clicked() { + actions.push(ContentBrowserToolbarAction::Navigate(segment.path.clone())); + } + response.on_hover_text(&segment.path); + x += label_width; + } + if let Some(summary) = summary { + ui.painter().text( + rect.right_center() - egui::vec2(14.0, 0.0), + egui::Align2::RIGHT_CENTER, + summary, + TypeRole::Small.font(), + palette.text_muted, + ); + } +} + +fn operations_menu( + ui: &mut egui::Ui, + rect: egui::Rect, + model: &ContentBrowserToolbarViewModel, + actions: &mut Vec, + include_layout_options: bool, +) { + ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + let response = ui.menu_button( + egui::RichText::new(icons::DOTS_THREE_VERTICAL.as_str()).font(phosphor_font(14.0)), + |ui| { + operation( + ui, + "Refresh", + true, + ContentBrowserToolbarAction::Refresh, + actions, + ); + operation( + ui, + "Import To…", + model.can_edit_folder, + ContentBrowserToolbarAction::ImportTo, + actions, + ); + operation( + ui, + "New Folder", + model.can_edit_folder, + ContentBrowserToolbarAction::NewFolder, + actions, + ); + ui.separator(); + operation( + ui, + "Rename", + model.can_rename, + ContentBrowserToolbarAction::Rename, + actions, + ); + operation( + ui, + "Duplicate", + model.can_edit_selection, + ContentBrowserToolbarAction::Duplicate, + actions, + ); + operation( + ui, + "Cut", + model.can_edit_selection, + ContentBrowserToolbarAction::Cut, + actions, + ); + operation( + ui, + "Copy", + model.can_edit_selection, + ContentBrowserToolbarAction::Copy, + actions, + ); + operation( + ui, + "Paste", + model.can_paste, + ContentBrowserToolbarAction::Paste, + actions, + ); + ui.separator(); + operation( + ui, + "Undo Content Operation", + model.can_undo, + ContentBrowserToolbarAction::Undo, + actions, + ); + operation( + ui, + "Open Trash", + true, + ContentBrowserToolbarAction::OpenTrash, + actions, + ); + if model.pending_repairs > 0 { + operation( + ui, + &format!("Resolve {} moves", model.pending_repairs), + true, + ContentBrowserToolbarAction::ReviewMoves, + actions, + ); + } + ui.separator(); + let mut recursive = model.recursive; + if ui.checkbox(&mut recursive, "Include subfolders").changed() { + actions.push(ContentBrowserToolbarAction::SetRecursive(recursive)); + } + if include_layout_options { + let mut thumbnail_size = model.thumbnail_size; + ui.label(TypeRole::Caption.text("Thumbnail size")); + if ui + .add( + egui::Slider::new( + &mut thumbnail_size, + ASSET_CARD_MIN_THUMBNAIL_SIZE..=ASSET_CARD_MAX_THUMBNAIL_SIZE, + ) + .show_value(false), + ) + .changed() + { + actions.push(ContentBrowserToolbarAction::SetThumbnailSize( + thumbnail_size, + )); + } + let mut sort = model.sort; + egui::ComboBox::from_id_salt("content_browser_sort") + .selected_text(sort.label()) + .show_ui(ui, |ui| { + for option in ContentBrowserSort::ALL { + ui.selectable_value(&mut sort, option, option.label()); + } + }); + if sort != model.sort { + actions.push(ContentBrowserToolbarAction::SetSort(sort)); + } + } + }, + ); + response.response.on_hover_text("Content operations"); + }); } fn operation( @@ -502,63 +942,261 @@ fn operation( } } +fn placed_icon_button( + ui: &mut egui::Ui, + rect: egui::Rect, + icon: Icon, + tooltip: &str, + enabled: bool, + selected: bool, +) -> egui::Response { + placed_icon_button_with_style( + ui, + rect, + icon, + tooltip, + enabled, + selected, + PhosphorIconStyle::Bold, + ) +} + +fn placed_icon_button_with_style( + ui: &mut egui::Ui, + rect: egui::Rect, + icon: Icon, + tooltip: &str, + enabled: bool, + selected: bool, + style: PhosphorIconStyle, +) -> egui::Response { + let palette = design_system::palette(ui); + ui.put( + rect, + egui::Button::new(egui::RichText::new(icon.as_str()).font(phosphor_icon_font(style, 14.0))) + .fill(if selected { + palette.accent_dark + } else { + palette.control + }) + .stroke(egui::Stroke::new( + 1.0_f32, + if selected { + palette.accent + } else { + palette.border + }, + )) + .corner_radius(egui::CornerRadius::same(4)) + .sense(if enabled { + egui::Sense::click() + } else { + egui::Sense::hover() + }), + ) + .on_hover_text(tooltip) +} + +fn placed_text_button( + ui: &mut egui::Ui, + rect: egui::Rect, + icon: Icon, + label: &str, + enabled: bool, + accented: bool, +) -> egui::Response { + let palette = design_system::palette(ui); + ui.put( + rect, + egui::Button::new(design_system::controls::icon_label( + icon, + label, + TypeRole::Control, + 13.0, + palette.text_primary, + )) + .fill(if accented { + palette.accent_dark + } else { + palette.control + }) + .stroke(egui::Stroke::new( + 1.0_f32, + if accented { + palette.accent + } else { + palette.border + }, + )) + .corner_radius(egui::CornerRadius::same(4)) + .sense(if enabled { + egui::Sense::click() + } else { + egui::Sense::hover() + }), + ) +} + +fn at(origin: egui::Pos2, x: f32, y: f32, width: f32, height: f32) -> egui::Rect { + egui::Rect::from_min_size( + origin + egui::vec2(x, y), + egui::vec2(width.max(1.0), height), + ) +} + +fn phosphor_font(size: f32) -> egui::FontId { + phosphor_icon_font(PhosphorIconStyle::Bold, size) +} + +#[cfg(test)] +mod toolbar_tests { + use super::*; + + #[test] + fn search_field_matches_penpot_icon_and_text_insets() { + let rect = egui::Rect::from_min_size(egui::pos2(100.0, 20.0), egui::vec2(146.0, 28.0)); + let geometry = SearchFieldGeometry::from_rect(rect); + + assert_eq!(geometry.icon.min, egui::pos2(108.0, 27.0)); + assert_eq!(geometry.icon.size(), egui::Vec2::splat(14.0)); + assert_eq!(geometry.text.left(), 130.0); + assert_eq!(geometry.text.right(), 240.0); + assert_eq!(geometry.text.top(), 23.0); + assert_eq!(geometry.text.bottom(), 45.0); + assert_eq!(geometry.text.center().y, rect.center().y); + assert!(rect.contains_rect(geometry.icon)); + assert!(rect.contains_rect(geometry.text)); + } + + #[test] + fn thumbnail_slider_is_centered_and_bounded() { + let rect = egui::Rect::from_min_size(egui::pos2(100.0, 20.0), egui::vec2(122.0, 28.0)); + let geometry = ThumbnailSizeSliderGeometry::from_rect(rect); + + assert_eq!(geometry.icon.center(), egui::pos2(113.0, 34.0)); + assert_eq!(geometry.interaction.center().y, rect.center().y); + assert_eq!(geometry.track.center().y, rect.center().y); + assert_eq!(geometry.track.left(), 134.0); + assert_eq!(geometry.track.right(), 214.0); + assert!(rect.contains_rect(geometry.icon)); + assert!(rect.contains_rect(geometry.interaction)); + assert!(rect.contains_rect(geometry.track)); + } + + #[test] + fn compact_search_geometry_stays_finite_and_bounded() { + for width in [1.0, 32.0, 68.0, 146.0] { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 28.0)); + let geometry = SearchFieldGeometry::from_rect(rect); + assert!(geometry.icon.is_finite()); + assert!(geometry.text.is_finite()); + assert!(geometry.text.width() >= 1.0); + assert!(geometry.text.left() >= rect.left()); + } + } + + #[test] + fn wide_toolbar_keeps_reference_controls_and_expands_only_at_the_breakpoint() { + let origin = egui::pos2(10.0, 20.0); + let standard = WideToolbarOptionsGeometry::from_width(origin, 1_240.0, 40.0); + assert!(!standard.expanded); + assert_eq!(standard.search.min, egui::pos2(778.0, 31.0)); + assert_eq!(standard.search.size(), egui::vec2(146.0, 28.0)); + assert_eq!(standard.kind.left(), 934.0); + assert_eq!(standard.kind.size(), egui::vec2(104.0, 28.0)); + assert_eq!(standard.grid.left(), 1_046.0); + assert_eq!(standard.list.left(), 1_080.0); + assert_eq!(standard.details.left(), 1_114.0); + assert_eq!(standard.details.right(), 1_202.0); + assert!(standard.sort.is_none()); + assert!(standard.thumbnail_size.is_none()); + + let expanded = WideToolbarOptionsGeometry::from_width(origin, 1_700.0, 40.0); + assert!(expanded.expanded); + assert_eq!(expanded.search.left(), 1_012.0); + assert_eq!(expanded.kind.left(), 1_168.0); + assert_eq!(expanded.kind.size(), egui::vec2(104.0, 28.0)); + assert_eq!(expanded.sort.expect("sort").left(), 1_280.0); + assert_eq!(expanded.sort.expect("sort").height(), 28.0); + assert_eq!( + expanded.thumbnail_size.expect("thumbnail size").left(), + 1_376.0 + ); + assert_eq!(expanded.grid.left(), 1_506.0); + assert_eq!(expanded.list.left(), 1_540.0); + assert_eq!(expanded.details.left(), 1_574.0); + assert_eq!(expanded.details.right(), origin.x + 1_700.0 - 40.0 - 8.0); + } + + #[test] + fn expanded_toolbar_controls_are_finite_ordered_and_bounded_during_resize() { + for width in [1_000.0, 1_399.0, 1_400.0, 1_700.0, 2_400.0] { + let geometry = WideToolbarOptionsGeometry::from_width(egui::Pos2::ZERO, width, 40.0); + let mut controls = vec![geometry.search, geometry.kind]; + controls.extend(geometry.sort); + controls.extend(geometry.thumbnail_size); + controls.extend([geometry.grid, geometry.list, geometry.details]); + for control in &controls { + assert!(control.is_finite()); + assert!(control.left() >= 0.0); + assert!(control.right() <= width - 40.0); + } + for pair in controls.windows(2) { + assert!(pair[0].right() <= pair[1].left()); + } + assert_eq!(geometry.expanded, width >= EXPANDED_OPTIONS_BREAKPOINT); + } + } +} + #[derive(Debug, Clone)] pub struct ContentBrowserStatusViewModel<'a> { - pub summary: &'a str, - pub location: &'a str, - pub status: &'a str, + pub catalog: &'a str, + pub processing: &'a str, + pub selection: &'a str, + pub healthy: bool, } pub fn content_browser_status_bar(ui: &mut egui::Ui, model: ContentBrowserStatusViewModel<'_>) { let palette = design_system::palette(ui); - let (rect, _) = ui.allocate_exact_size( - egui::vec2(ui.available_width().max(1.0), STATUS_BAR_HEIGHT), - egui::Sense::hover(), - ); - ui.painter().rect_filled(rect, 0.0, palette.recessed); + let width = ui.available_width().max(1.0); + let (rect, _) = + ui.allocate_exact_size(egui::vec2(width, STATUS_BAR_HEIGHT), egui::Sense::hover()); + ui.painter().rect_filled(rect, 0.0, palette.panel); ui.painter().line_segment( [rect.left_top(), rect.right_top()], - egui::Stroke::new(1.0_f32, palette.border), + egui::Stroke::new(1.0_f32, palette.border_strong), + ); + ui.painter().circle_filled( + rect.left_center() + egui::vec2(18.0, 0.0), + 3.5, + if model.healthy { + palette.healthy + } else { + palette.warning + }, ); ui.painter().text( - rect.left_center() + egui::vec2(10.0, 0.0), + rect.left_center() + egui::vec2(28.0, 0.0), egui::Align2::LEFT_CENTER, - model.summary, + model.catalog, TypeRole::Small.font(), palette.text_secondary, ); + if width >= COMPACT_BREAKPOINT { + ui.painter().text( + rect.left_center() + egui::vec2(184.0, 0.0), + egui::Align2::LEFT_CENTER, + model.processing, + TypeRole::Small.font(), + palette.text_muted, + ); + } ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - model.location, - TypeRole::Caption.font(), - palette.text_muted, - ); - ui.painter().text( - rect.right_center() - egui::vec2(10.0, 0.0), + rect.right_center() - egui::vec2(14.0, 0.0), egui::Align2::RIGHT_CENTER, - model.status, + model.selection, TypeRole::Small.font(), palette.text_muted, ); } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn penpot_content_browser_geometry_is_stable() { - assert_eq!(TOOLBAR_HEIGHT, 124.0); - assert_eq!(STATUS_BAR_HEIGHT, 32.0); - assert_eq!(COMPACT_BREAKPOINT, 720.0); - } - - #[test] - fn filters_and_sorts_have_stable_labels() { - assert_eq!(ContentBrowserKindFilter::ALL.len(), 8); - assert_eq!(ContentBrowserKindFilter::All.label(), "All assets"); - assert_eq!(ContentBrowserSort::ALL.len(), 4); - assert_eq!(ContentBrowserSort::Kind.label(), "Type"); - } -} diff --git a/crates/editor_ui/src/content_browser/body.rs b/crates/editor_ui/src/content_browser/body.rs new file mode 100644 index 0000000..880d0b6 --- /dev/null +++ b/crates/editor_ui/src/content_browser/body.rs @@ -0,0 +1,1196 @@ +//! 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); + } + } +} diff --git a/crates/editor_ui/src/content_browser/types.rs b/crates/editor_ui/src/content_browser/types.rs new file mode 100644 index 0000000..be20548 --- /dev/null +++ b/crates/editor_ui/src/content_browser/types.rs @@ -0,0 +1,234 @@ +pub const DOCK_HEADER_HEIGHT: f32 = 36.0; +pub const WIDE_FULL_TOOLBAR_HEIGHT: f32 = 91.0; +pub const WIDE_DOCKED_TOOLBAR_HEIGHT: f32 = WIDE_FULL_TOOLBAR_HEIGHT - DOCK_HEADER_HEIGHT; +pub const COMPACT_FULL_TOOLBAR_HEIGHT: f32 = 124.0; +pub const COMPACT_DOCKED_TOOLBAR_HEIGHT: f32 = COMPACT_FULL_TOOLBAR_HEIGHT - DOCK_HEADER_HEIGHT; +pub const STATUS_BAR_HEIGHT: f32 = 32.0; +/// Width below which the 124 px compact reference replaces the one-row 91 px layout. +/// +/// The wide row needs enough room for navigation, breadcrumbs, search, filtering, and view +/// controls without overlap. This is also the production breakpoint for Sources/Details panes. +pub const COMPACT_BREAKPOINT: f32 = 1000.0; +/// Width at which sort and thumbnail-scale controls move out of overflow and into the toolbar. +/// +/// The 1240 px Penpot wide reference remains unchanged. Expanded workspaces gain the controls +/// without compressing navigation, breadcrumbs, search, or the Details action. +pub const EXPANDED_OPTIONS_BREAKPOINT: f32 = 1400.0; +pub const SOURCES_WIDTH: f32 = 216.0; +pub const DETAILS_WIDTH: f32 = 290.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentBrowserChrome { + /// Includes the 36 px title row used by the standalone design reference and UI gallery. + Standalone, + /// The editor dock tab already owns the title row, leaving 55 px wide or 88 px compact chrome. + Docked, +} + +impl ContentBrowserChrome { + pub const fn height(self, width: f32) -> f32 { + match (self, width < COMPACT_BREAKPOINT) { + (Self::Standalone, false) => WIDE_FULL_TOOLBAR_HEIGHT, + (Self::Docked, false) => WIDE_DOCKED_TOOLBAR_HEIGHT, + (Self::Standalone, true) => COMPACT_FULL_TOOLBAR_HEIGHT, + (Self::Docked, true) => COMPACT_DOCKED_TOOLBAR_HEIGHT, + } + } + + pub(crate) const fn y_offset(self) -> f32 { + match self { + Self::Standalone => DOCK_HEADER_HEIGHT, + Self::Docked => 0.0, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentBrowserViewMode { + Grid, + List, +} + +/// Stable row-major presentation order shared by production and gallery fixtures. +/// +/// Folders keep their navigation semantics but do not own a separate layout pass: assets fill any +/// remaining cells in the final folder row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentBrowserGridEntry { + Folder(usize), + Asset(usize), +} + +pub fn continuous_grid_entries( + folder_count: usize, + asset_count: usize, +) -> Vec { + (0..folder_count) + .map(ContentBrowserGridEntry::Folder) + .chain((0..asset_count).map(ContentBrowserGridEntry::Asset)) + .collect() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentBrowserKindFilter { + All, + Model, + Texture, + Material, + Audio, + Level, + Prefab, + Builtin, +} + +impl ContentBrowserKindFilter { + pub const ALL: [Self; 8] = [ + Self::All, + Self::Model, + Self::Texture, + Self::Material, + Self::Audio, + Self::Level, + Self::Prefab, + Self::Builtin, + ]; + + pub const fn label(self) -> &'static str { + match self { + Self::All => "All assets", + Self::Model => "Models", + Self::Texture => "Textures", + Self::Material => "Materials", + Self::Audio => "Audio", + Self::Level => "Levels", + Self::Prefab => "Prefabs", + Self::Builtin => "Built-ins", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentBrowserSort { + Name, + Kind, + Modified, + Size, +} + +impl ContentBrowserSort { + pub const ALL: [Self; 4] = [Self::Name, Self::Kind, Self::Modified, Self::Size]; + + pub const fn label(self) -> &'static str { + match self { + Self::Name => "Name", + Self::Kind => "Type", + Self::Modified => "Modified", + Self::Size => "Size", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContentPathSegment { + pub path: String, + pub label: String, +} + +#[derive(Debug, Clone)] +pub struct ContentBrowserToolbarViewModel { + pub chrome: ContentBrowserChrome, + pub path: Vec, + pub search: String, + pub kind_filter: ContentBrowserKindFilter, + pub sort: ContentBrowserSort, + pub view: ContentBrowserViewMode, + pub recursive: bool, + pub show_sources: bool, + pub show_details: bool, + pub thumbnail_size: f32, + pub can_go_parent: bool, + pub can_go_back: bool, + pub can_go_forward: bool, + pub can_edit_folder: bool, + pub can_rename: bool, + pub can_edit_selection: bool, + pub can_paste: bool, + pub can_undo: bool, + pub pending_repairs: usize, + pub direct_folder_count: usize, + pub direct_asset_count: usize, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ContentBrowserToolbarAction { + Back, + Forward, + Parent, + Refresh, + Navigate(String), + ImportHere, + ImportTo, + NewFolder, + Rename, + Duplicate, + Undo, + Cut, + Copy, + Paste, + OpenTrash, + ReviewMoves, + SetSearch(String), + SetKindFilter(ContentBrowserKindFilter), + SetSort(ContentBrowserSort), + SetView(ContentBrowserViewMode), + SetRecursive(bool), + SetShowSources(bool), + SetShowDetails(bool), + SetThumbnailSize(f32), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn penpot_content_browser_geometry_is_stable() { + assert_eq!(DOCK_HEADER_HEIGHT, 36.0); + assert_eq!(WIDE_FULL_TOOLBAR_HEIGHT, 91.0); + assert_eq!(WIDE_DOCKED_TOOLBAR_HEIGHT, 55.0); + assert_eq!(COMPACT_FULL_TOOLBAR_HEIGHT, 124.0); + assert_eq!(COMPACT_DOCKED_TOOLBAR_HEIGHT, 88.0); + assert_eq!(STATUS_BAR_HEIGHT, 32.0); + assert_eq!(EXPANDED_OPTIONS_BREAKPOINT, 1400.0); + assert_eq!(SOURCES_WIDTH, 216.0); + assert_eq!(DETAILS_WIDTH, 290.0); + assert_eq!(ContentBrowserChrome::Standalone.height(1240.0), 91.0); + assert_eq!(ContentBrowserChrome::Docked.height(1240.0), 55.0); + assert_eq!(ContentBrowserChrome::Standalone.height(620.0), 124.0); + assert_eq!(ContentBrowserChrome::Docked.height(620.0), 88.0); + } + + #[test] + fn filters_and_sorts_have_stable_labels() { + assert_eq!(ContentBrowserKindFilter::ALL.len(), 8); + assert_eq!(ContentBrowserKindFilter::All.label(), "All assets"); + assert_eq!(ContentBrowserSort::ALL.len(), 4); + assert_eq!(ContentBrowserSort::Kind.label(), "Type"); + } + + #[test] + fn assets_fill_the_remaining_cells_in_a_folder_row() { + let entries = continuous_grid_entries(2, 3); + let rows = entries.chunks(4).collect::>(); + assert_eq!( + rows[0], + [ + ContentBrowserGridEntry::Folder(0), + ContentBrowserGridEntry::Folder(1), + ContentBrowserGridEntry::Asset(0), + ContentBrowserGridEntry::Asset(1), + ] + ); + assert_eq!(rows[1], [ContentBrowserGridEntry::Asset(2)]); + } +} diff --git a/crates/editor_ui/src/design_system/color_picker.rs b/crates/editor_ui/src/design_system/color_picker.rs index f90cbae..b3caa57 100644 --- a/crates/editor_ui/src/design_system/color_picker.rs +++ b/crates/editor_ui/src/design_system/color_picker.rs @@ -7,6 +7,7 @@ use egui; use super::typography::TypeRole; pub(super) const POPUP_SIZE: egui::Vec2 = egui::vec2(420.0, 350.0); +const COLOR_SWATCH_OUTER_RADIUS: u8 = 3; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum PickerMode { @@ -72,21 +73,27 @@ fn color_control_impl( egui::vec2((width - swatch_width - gap).max(0.0), 22.0), ); popup::checker(ui.painter(), swatch_rect, palette.control, palette.elevated); - let inner_swatch = swatch_rect.shrink(1.0); - let rgb_rect = egui::Rect::from_min_max( - inner_swatch.min, - egui::pos2(inner_swatch.center().x, inner_swatch.max.y), - ); - let alpha_rect = egui::Rect::from_min_max( - egui::pos2(inner_swatch.center().x, inner_swatch.min.y), - inner_swatch.max, - ); + let (rgb_rect, alpha_rect) = color_swatch_halves(swatch_rect.shrink(1.0)); ui.painter().rect_filled( rgb_rect, - 3.0, + egui::CornerRadius { + nw: COLOR_SWATCH_OUTER_RADIUS, + ne: 0, + sw: COLOR_SWATCH_OUTER_RADIUS, + se: 0, + }, egui::Color32::from_rgb(byte(rgba[0]), byte(rgba[1]), byte(rgba[2])), ); - ui.painter().rect_filled(alpha_rect, 3.0, color32(*rgba)); + ui.painter().rect_filled( + alpha_rect, + egui::CornerRadius { + nw: 0, + ne: COLOR_SWATCH_OUTER_RADIUS, + sw: 0, + se: COLOR_SWATCH_OUTER_RADIUS, + }, + color32(*rgba), + ); ui.painter().rect_stroke( swatch_rect, 4.0, @@ -194,6 +201,14 @@ fn color_control_impl( result } +fn color_swatch_halves(inner: egui::Rect) -> (egui::Rect, egui::Rect) { + let split = inner.center().x; + ( + egui::Rect::from_min_max(inner.min, egui::pos2(split, inner.max.y)), + egui::Rect::from_min_max(egui::pos2(split, inner.min.y), inner.max), + ) +} + fn centered_popup_rect(owner: egui::Rect, screen: egui::Rect) -> egui::Rect { const SCREEN_MARGIN: f32 = 12.0; let desired = owner.center() - POPUP_SIZE * 0.5; @@ -307,6 +322,30 @@ mod tests { } } + #[test] + fn inline_swatch_halves_share_one_unrounded_inner_edge() { + let inner = egui::Rect::from_min_size(egui::pos2(1.0, 1.0), egui::vec2(42.0, 20.0)); + let (rgb, alpha) = color_swatch_halves(inner); + assert_eq!(rgb.right(), alpha.left()); + assert_eq!(rgb.left(), inner.left()); + assert_eq!(alpha.right(), inner.right()); + assert_eq!(rgb.height(), alpha.height()); + + let left = egui::CornerRadius { + nw: COLOR_SWATCH_OUTER_RADIUS, + ne: 0, + sw: COLOR_SWATCH_OUTER_RADIUS, + se: 0, + }; + let right = egui::CornerRadius { + nw: 0, + ne: COLOR_SWATCH_OUTER_RADIUS, + sw: 0, + se: COLOR_SWATCH_OUTER_RADIUS, + }; + assert_eq!((left.ne, left.se, right.nw, right.sw), (0, 0, 0, 0)); + } + #[test] fn cancel_restores_opening_value_and_apply_keeps_live_preview() { let original = [0.1, 0.2, 0.3, 1.0]; diff --git a/crates/editor_ui/src/design_system/controls.rs b/crates/editor_ui/src/design_system/controls.rs index e9f52c9..b0bd932 100644 --- a/crates/editor_ui/src/design_system/controls.rs +++ b/crates/editor_ui/src/design_system/controls.rs @@ -7,6 +7,32 @@ use std::ops::RangeInclusive; use super::palette; use super::typography::TypeRole; +/// Phosphor font variant used by a design-system glyph. +/// +/// Penpot names the intended variant for each icon. Keeping that choice explicit prevents a +/// correct codepoint from silently rendering with the wrong visual weight in production. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum PhosphorIconStyle { + Regular, + #[default] + Bold, + Fill, +} + +impl PhosphorIconStyle { + const fn family(self) -> &'static str { + match self { + Self::Regular => "phosphor-regular", + Self::Bold => "phosphor-bold", + Self::Fill => "phosphor-fill", + } + } +} + +pub fn phosphor_icon_font(style: PhosphorIconStyle, size: f32) -> egui::FontId { + egui::FontId::new(size, egui::FontFamily::Name(style.family().into())) +} + // Penpot's scalar component keeps a six-pixel leading inset inside its value-group frame. The // exported child geometry is authoritative here; the group's transparent parent frame is six // pixels narrower than its visible children and must not be mistaken for the control width. @@ -85,13 +111,25 @@ pub fn icon_label( role: TypeRole, icon_size: f32, color: egui::Color32, +) -> egui::WidgetText { + icon_label_with_style(icon, label, role, icon_size, color, PhosphorIconStyle::Bold) +} + +/// Compose a Phosphor glyph in its authored visual weight with a Source Sans label. +pub fn icon_label_with_style( + icon: Icon, + label: &str, + role: TypeRole, + icon_size: f32, + color: egui::Color32, + style: PhosphorIconStyle, ) -> egui::WidgetText { let mut job = egui::text::LayoutJob::default(); job.append( icon.as_str(), 0.0, egui::TextFormat { - font_id: egui::FontId::new(icon_size, egui::FontFamily::Name("phosphor-bold".into())), + font_id: phosphor_icon_font(style, icon_size), color, ..Default::default() }, diff --git a/crates/editor_ui/src/design_system/mod.rs b/crates/editor_ui/src/design_system/mod.rs index 4ce7933..2dc1c96 100644 --- a/crates/editor_ui/src/design_system/mod.rs +++ b/crates/editor_ui/src/design_system/mod.rs @@ -10,7 +10,7 @@ pub mod typography; use egui::{self, Color32, CornerRadius, Stroke}; -use crate::theme::EditorVisualPalette; +use crate::theme::{AssetTypePalette, EditorVisualPalette}; pub const REFERENCE_WIDTH: f32 = 620.0; pub const INSPECTOR_MIN_WIDTH: f32 = 420.0; @@ -52,6 +52,7 @@ pub struct DesignPalette { pub healthy: Color32, pub warning: Color32, pub error: Color32, + pub asset_types: AssetTypePalette, } impl From for DesignPalette { @@ -75,6 +76,7 @@ impl From for DesignPalette { healthy: value.success, warning: value.warning, error: value.error, + asset_types: value.asset_types, } } } @@ -149,5 +151,6 @@ mod tests { assert_eq!(palette.accent, crate::theme::SELECTION); assert_eq!(palette.panel, crate::theme::PANEL_BG); assert_eq!(palette.text_primary, crate::theme::TEXT); + assert_eq!(palette.asset_types, crate::theme::ASSET_TYPES); } } diff --git a/crates/editor_ui/src/materials/panel.rs b/crates/editor_ui/src/materials/panel.rs index dc85bd0..2a748b2 100644 --- a/crates/editor_ui/src/materials/panel.rs +++ b/crates/editor_ui/src/materials/panel.rs @@ -10,8 +10,10 @@ use crate::design_system::controls::status; use crate::design_system::typography::TypeRole; use crate::design_system::{self, DesignPalette, HEADER_HEIGHT}; +mod drop_feedback; mod header_actions; +use drop_feedback::{accepted_drop_action, paint_material_drop_feedback, MaterialDropVisualState}; use header_actions::{expanded_status_actions, overflow_menu, SLOT_STATUS_ACTION_WIDTH}; // `Frame` reserves one pixel for the slot stroke before handing its child Ui to us. These local @@ -252,7 +254,7 @@ pub fn material_slot_panel( &mut slot_actions, diagnostics_ui, ); - if parameters_open { + if parameters_open && (view.assigned.is_some() || view.effective.is_some()) { parameter_body(ui, content_ui); } ui.ctx() @@ -287,6 +289,34 @@ fn slot_header( }; let width = ui.available_width().max(1.0); let (rect, response) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::click()); + let target_hovered = ui.rect_contains_pointer(rect); + if let Some(drop_state) = MaterialDropVisualState::from_view(view, target_hovered) { + paint_material_drop_feedback(ui, rect, drop_state, palette); + if drop_state == MaterialDropVisualState::Empty { + super::pickers::material_picker_popup( + ui, + id.with("material_picker"), + &response, + view, + actions, + ); + } + if let Some(action) = accepted_drop_action( + drop_state, + ui.input(|input| input.pointer.any_released()), + view.drop_candidate.as_ref(), + ) { + actions.push(action); + } + if drop_state == MaterialDropVisualState::Incompatible { + response.on_hover_text( + view.invalid_drop_reason + .as_deref() + .unwrap_or("Expected a Material asset"), + ); + } + return; + } ui.painter().rect_filled(rect, 7.0, palette.recessed); ui.painter().rect_filled( egui::Rect::from_min_size( @@ -392,22 +422,6 @@ fn slot_header( view, actions, ); - if response.hovered() - && view.drop_candidate.is_some() - && ui.input(|input| input.pointer.any_released()) - { - if let Some(candidate) = view.drop_candidate.as_ref() { - actions.push(MaterialSlotAction::Assign { - reference: candidate.reference.clone(), - from_drop: true, - }); - } - } - if response.hovered() { - if let Some(reason) = view.invalid_drop_reason.as_deref() { - response.on_hover_text(reason); - } - } } fn compact_material_identity( diff --git a/crates/editor_ui/src/materials/panel/drop_feedback.rs b/crates/editor_ui/src/materials/panel/drop_feedback.rs new file mode 100644 index 0000000..f1b11c7 --- /dev/null +++ b/crates/editor_ui/src/materials/panel/drop_feedback.rs @@ -0,0 +1,412 @@ +//! Transient assignment and drag feedback for the complete material-slot header. + +use egui; +use egui_phosphor_icons::icons; + +use super::{MaterialPickerCandidate, MaterialSlotAction, MaterialSlotPanelViewModel}; +use crate::design_system::typography::TypeRole; +use crate::design_system::DesignPalette; + +const ZONE_INSET: f32 = 1.0; +const CONTENT_INSET: f32 = 8.0; +const ICON_SIZE: f32 = 48.0; +const BUTTON_WIDTH: f32 = 130.0; +const COMPACT_BUTTON_WIDTH: f32 = 116.0; +const BUTTON_HEIGHT: f32 = 30.0; +const CONTENT_GAP: f32 = 10.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum MaterialDropVisualState { + Empty, + Compatible, + Incompatible, +} + +impl MaterialDropVisualState { + pub(super) fn from_view( + view: &MaterialSlotPanelViewModel, + target_hovered: bool, + ) -> Option { + if target_hovered && view.drop_candidate.is_some() { + Some(Self::Compatible) + } else if target_hovered && view.invalid_drop_reason.is_some() { + Some(Self::Incompatible) + } else if view.assigned.is_none() && view.effective.is_none() { + Some(Self::Empty) + } else { + None + } + } + + fn copy(self) -> (&'static str, &'static str, &'static str) { + match self { + Self::Empty => ( + "No material assigned", + "Drop a material here or choose from the browser", + "Choose material", + ), + Self::Compatible => ( + "Relevant project material", + "Compatible Material asset", + "Drop material", + ), + Self::Incompatible => ( + "Incompatible asset", + "Expected a Material asset", + "Cannot assign", + ), + } + } +} + +pub(super) fn accepted_drop_action( + state: MaterialDropVisualState, + pointer_released: bool, + candidate: Option<&MaterialPickerCandidate>, +) -> Option { + if state != MaterialDropVisualState::Compatible || !pointer_released { + return None; + } + candidate.map(|candidate| MaterialSlotAction::Assign { + reference: candidate.reference.clone(), + from_drop: true, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct MaterialDropFeedbackGeometry { + pub zone: egui::Rect, + pub icon: egui::Rect, + pub text: egui::Rect, + pub action: egui::Rect, +} + +impl MaterialDropFeedbackGeometry { + pub(super) fn from_header(header: egui::Rect) -> Self { + let zone = header.shrink(ZONE_INSET); + let icon = egui::Rect::from_center_size( + egui::pos2( + zone.left() + CONTENT_INSET + ICON_SIZE * 0.5, + zone.center().y, + ), + egui::vec2(ICON_SIZE, ICON_SIZE), + ); + let available_button_width = if zone.width() >= 430.0 { + BUTTON_WIDTH + } else { + COMPACT_BUTTON_WIDTH + }; + let action = egui::Rect::from_center_size( + egui::pos2( + zone.right() - CONTENT_INSET - available_button_width * 0.5, + zone.center().y, + ), + egui::vec2(available_button_width, BUTTON_HEIGHT), + ); + let text = egui::Rect::from_min_max( + egui::pos2(icon.right() + CONTENT_GAP, icon.top()), + egui::pos2( + (action.left() - CONTENT_GAP).max(icon.right() + CONTENT_GAP), + icon.bottom(), + ), + ); + Self { + zone, + icon, + text, + action, + } + } + + #[cfg(test)] + pub(super) fn is_finite_and_contained(self) -> bool { + [self.zone, self.icon, self.text, self.action] + .into_iter() + .all(|rect| rect.is_finite() && self.zone.contains_rect(rect)) + } +} + +pub(super) fn paint_material_drop_feedback( + ui: &egui::Ui, + header: egui::Rect, + state: MaterialDropVisualState, + palette: DesignPalette, +) { + let geometry = MaterialDropFeedbackGeometry::from_header(header); + let (title, detail, action_label) = state.copy(); + let (fill, stroke, detail_color, icon_fill) = match state { + MaterialDropVisualState::Empty => ( + palette.recessed, + palette.border_strong, + palette.text_muted, + palette.elevated, + ), + MaterialDropVisualState::Compatible => ( + palette.accent_dark, + palette.accent, + palette.accent, + palette.accent_dark, + ), + MaterialDropVisualState::Incompatible => ( + palette.elevated, + palette.error, + palette.error, + palette.control, + ), + }; + + ui.painter().rect_filled(geometry.zone, 7.0, fill); + if state == MaterialDropVisualState::Empty { + paint_dashed_rect(ui.painter(), geometry.zone, 7.0, stroke); + } else { + ui.painter().rect_stroke( + geometry.zone, + 7.0, + egui::Stroke::new(2.0_f32, stroke), + egui::StrokeKind::Inside, + ); + } + ui.painter().rect( + geometry.icon, + 4.0, + icon_fill, + egui::Stroke::new(1.0_f32, palette.border_strong), + egui::StrokeKind::Inside, + ); + ui.painter().text( + geometry.icon.center(), + egui::Align2::CENTER_CENTER, + icons::PALETTE.as_str(), + egui::FontId::new(18.0, egui::FontFamily::Name("phosphor-regular".into())), + if state == MaterialDropVisualState::Incompatible { + palette.text_muted + } else { + palette.accent + }, + ); + + let title_pos = egui::pos2(geometry.text.left(), geometry.text.center().y - 8.0); + let detail_pos = egui::pos2(geometry.text.left(), geometry.text.center().y + 8.0); + let text_painter = ui.painter().with_clip_rect(geometry.text); + text_painter.text( + title_pos, + egui::Align2::LEFT_CENTER, + title, + TypeRole::AssetTitle.font(), + palette.text_primary, + ); + text_painter.text( + detail_pos, + egui::Align2::LEFT_CENTER, + detail, + TypeRole::Small.font(), + detail_color, + ); + + ui.painter().rect( + geometry.action, + 5.0, + palette.control, + egui::Stroke::new( + 1.0_f32, + if state == MaterialDropVisualState::Incompatible { + palette.error + } else { + palette.accent + }, + ), + egui::StrokeKind::Inside, + ); + let label_width = ui + .painter() + .layout_no_wrap( + action_label.to_owned(), + TypeRole::Control.font(), + palette.text_primary, + ) + .size() + .x; + let group_width = 14.0 + 6.0 + label_width; + let group_left = geometry.action.center().x - group_width * 0.5; + ui.painter().text( + egui::pos2(group_left + 7.0, geometry.action.center().y), + egui::Align2::CENTER_CENTER, + icons::FOLDER_OPEN.as_str(), + egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-regular".into())), + if state == MaterialDropVisualState::Incompatible { + palette.error + } else { + palette.accent + }, + ); + ui.painter().text( + egui::pos2(group_left + 20.0, geometry.action.center().y), + egui::Align2::LEFT_CENTER, + action_label, + TypeRole::Control.font(), + palette.text_primary, + ); +} + +fn paint_dashed_rect(painter: &egui::Painter, rect: egui::Rect, radius: f32, color: egui::Color32) { + let stroke = egui::Stroke::new(1.0_f32, color); + let dash = 9.0; + let gap = 7.0; + let inset = radius.max(2.0); + for (from, to) in [ + ( + egui::pos2(rect.left() + inset, rect.top()), + egui::pos2(rect.right() - inset, rect.top()), + ), + ( + egui::pos2(rect.left() + inset, rect.bottom()), + egui::pos2(rect.right() - inset, rect.bottom()), + ), + ( + egui::pos2(rect.left(), rect.top() + inset), + egui::pos2(rect.left(), rect.bottom() - inset), + ), + ( + egui::pos2(rect.right(), rect.top() + inset), + egui::pos2(rect.right(), rect.bottom() - inset), + ), + ] { + let vector = to - from; + let length = vector.length(); + if length <= f32::EPSILON { + continue; + } + let direction = vector / length; + let mut offset = 0.0; + while offset < length { + let end = (offset + dash).min(length); + painter.line_segment([from + direction * offset, from + direction * end], stroke); + offset += dash + gap; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::materials::{MaterialHealth, MaterialThumbnailPresentation}; + + fn view() -> MaterialSlotPanelViewModel { + MaterialSlotPanelViewModel { + slot_id: "slot:test".into(), + label: "No material assigned".into(), + path: None, + shader: String::new(), + shader_kind: None, + can_edit_shader: false, + thumbnail: MaterialThumbnailPresentation::Empty, + inherited: None, + health: MaterialHealth::Healthy, + assigned: None, + effective: None, + candidates: Vec::new(), + drop_candidate: None, + invalid_drop_reason: None, + can_locate: false, + can_clear: false, + can_extract: false, + can_create_instance: false, + parameters_initially_open: false, + expanded_header_when_parameters_closed: false, + } + } + + #[test] + fn drop_state_matrix_prioritizes_targeted_feedback() { + let mut view = view(); + assert_eq!( + MaterialDropVisualState::from_view(&view, false), + Some(MaterialDropVisualState::Empty) + ); + view.drop_candidate = Some(super::super::MaterialPickerCandidate { + reference: material_schema::EditorAssetRef::new("mat", "material:source", "M_Test"), + label: "M_Test".into(), + detail: "assets/materials/M_Test.ron".into(), + thumbnail: MaterialThumbnailPresentation::Empty, + }); + assert_eq!( + MaterialDropVisualState::from_view(&view, true), + Some(MaterialDropVisualState::Compatible) + ); + view.drop_candidate = None; + view.invalid_drop_reason = Some("Expected a Material asset".into()); + assert_eq!( + MaterialDropVisualState::from_view(&view, true), + Some(MaterialDropVisualState::Incompatible) + ); + assert_eq!( + MaterialDropVisualState::from_view(&view, false), + Some(MaterialDropVisualState::Empty) + ); + } + + #[test] + fn assigned_slots_only_replace_the_header_while_targeted() { + let mut view = view(); + view.assigned = Some(material_schema::EditorAssetRef::new( + "mat", + "material:source", + "M_Test", + )); + view.effective = view.assigned.clone(); + assert_eq!(MaterialDropVisualState::from_view(&view, false), None); + view.invalid_drop_reason = Some("Expected a Material asset".into()); + assert_eq!( + MaterialDropVisualState::from_view(&view, true), + Some(MaterialDropVisualState::Incompatible) + ); + } + + #[test] + fn only_a_released_compatible_candidate_emits_an_assignment() { + let candidate = MaterialPickerCandidate { + reference: material_schema::EditorAssetRef::new("mat", "material:source", "M_Test"), + label: "M_Test".into(), + detail: "assets/materials/M_Test.ron".into(), + thumbnail: MaterialThumbnailPresentation::Empty, + }; + assert_eq!( + accepted_drop_action(MaterialDropVisualState::Compatible, true, Some(&candidate)), + Some(MaterialSlotAction::Assign { + reference: candidate.reference.clone(), + from_drop: true, + }) + ); + assert_eq!( + accepted_drop_action(MaterialDropVisualState::Compatible, false, Some(&candidate)), + None + ); + assert_eq!( + accepted_drop_action( + MaterialDropVisualState::Incompatible, + true, + Some(&candidate) + ), + None + ); + assert_eq!( + accepted_drop_action(MaterialDropVisualState::Compatible, true, None), + None + ); + } + + #[test] + fn feedback_geometry_is_finite_and_bounded_at_wide_and_narrow_widths() { + for width in [396.0, 596.0] { + let header = egui::Rect::from_min_size( + egui::pos2(12.0, 24.0), + egui::vec2(width, crate::design_system::HEADER_HEIGHT), + ); + let geometry = MaterialDropFeedbackGeometry::from_header(header); + assert!(geometry.is_finite_and_contained()); + assert_eq!(geometry.icon.size(), egui::vec2(48.0, 48.0)); + assert_eq!(geometry.action.height(), 30.0); + assert!(geometry.text.right() <= geometry.action.left()); + } + } +} diff --git a/crates/editor_ui/src/theme.rs b/crates/editor_ui/src/theme.rs index 7b2b181..03cbfe2 100644 --- a/crates/editor_ui/src/theme.rs +++ b/crates/editor_ui/src/theme.rs @@ -2,6 +2,26 @@ use egui::{self, Color32, CornerRadius, FontId, Id, Stroke, Visuals}; +/// Theme-owned colors for Content Browser asset categories. +/// +/// These are classification colors, not health or selection states. Keeping them in the palette +/// lets a future Editor Settings theme change the vocabulary without teaching cards about theme +/// preferences. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AssetTypePalette { + pub primitive: Color32, + pub light: Color32, + pub model: Color32, + pub texture: Color32, + pub material: Color32, + pub audio: Color32, + pub level: Color32, + pub prefab: Color32, + pub post_process: Color32, + pub rendering_profile: Color32, + pub shader: Color32, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct EditorVisualPalette { pub canvas: Color32, @@ -24,6 +44,7 @@ pub struct EditorVisualPalette { pub success: Color32, pub warning: Color32, pub error: Color32, + pub asset_types: AssetTypePalette, } pub const CANVAS_BG: Color32 = Color32::from_rgb(7, 8, 10); @@ -46,6 +67,19 @@ pub const BORDER_STRONG: Color32 = Color32::from_rgb(64, 71, 79); pub const SUCCESS: Color32 = Color32::from_rgb(84, 190, 127); pub const WARNING: Color32 = Color32::from_rgb(235, 180, 72); pub const ERROR: Color32 = Color32::from_rgb(226, 94, 94); +pub const ASSET_TYPES: AssetTypePalette = AssetTypePalette { + primitive: Color32::from_rgb(138, 155, 173), + light: Color32::from_rgb(244, 211, 94), + model: Color32::from_rgb(91, 143, 249), + texture: Color32::from_rgb(84, 190, 127), + material: Color32::from_rgb(235, 180, 72), + audio: Color32::from_rgb(208, 125, 232), + level: Color32::from_rgb(65, 198, 207), + prefab: Color32::from_rgb(79, 182, 166), + post_process: Color32::from_rgb(167, 139, 250), + rendering_profile: Color32::from_rgb(154, 167, 184), + shader: Color32::from_rgb(240, 124, 99), +}; pub const BLACKSITE_PALETTE: EditorVisualPalette = EditorVisualPalette { canvas: CANVAS_BG, @@ -68,6 +102,7 @@ pub const BLACKSITE_PALETTE: EditorVisualPalette = EditorVisualPalette { success: SUCCESS, warning: WARNING, error: ERROR, + asset_types: ASSET_TYPES, }; const EDITOR_PALETTE_ID: &str = "blacksite.editor.visual_palette"; diff --git a/crates/ui_gallery/fixtures/theme.json b/crates/ui_gallery/fixtures/theme.json index 19e92d2..1f335d8 100644 --- a/crates/ui_gallery/fixtures/theme.json +++ b/crates/ui_gallery/fixtures/theme.json @@ -12,5 +12,18 @@ "border_strong": "#40474F", "success": "#54BE7F", "warning": "#EBB448", - "error": "#E25E5E" + "error": "#E25E5E", + "asset_types": { + "primitive": "#8A9BAD", + "light": "#F4D35E", + "model": "#5B8FF9", + "texture": "#54BE7F", + "material": "#EBB448", + "audio": "#D07DE8", + "level": "#41C6CF", + "prefab": "#4FB6A6", + "post_process": "#A78BFA", + "rendering_profile": "#9AA7B8", + "shader": "#F07C63" + } } diff --git a/crates/ui_gallery/src/app.rs b/crates/ui_gallery/src/app.rs index fd87d2f..c7eb923 100644 --- a/crates/ui_gallery/src/app.rs +++ b/crates/ui_gallery/src/app.rs @@ -80,7 +80,7 @@ impl UiGalleryApp { } } Self { - preset: GalleryPreset::Materials620, + preset: GalleryPreset::ContentBrowser1240, comparison: ComparisonMode::SideBySide, overlay_opacity: 0.5, fixture_id, @@ -142,9 +142,17 @@ impl UiGalleryApp { fn handle_shortcuts(&mut self, ctx: &egui::Context) { let (function_key, shift, previous_fixture, next_fixture) = ctx.input(|input| { - let function_key = [egui::Key::F1, egui::Key::F2, egui::Key::F3, egui::Key::F4] - .into_iter() - .position(|key| input.key_pressed(key)); + let function_key = [ + egui::Key::F1, + egui::Key::F2, + egui::Key::F3, + egui::Key::F4, + egui::Key::F5, + egui::Key::F6, + egui::Key::F7, + ] + .into_iter() + .position(|key| input.key_pressed(key)); ( function_key, input.modifiers.shift, @@ -154,7 +162,9 @@ impl UiGalleryApp { }); if let Some(index) = function_key { if shift { - self.select_comparison(ComparisonMode::ALL[index]); + if let Some(comparison) = ComparisonMode::ALL.get(index).copied() { + self.select_comparison(comparison); + } } else { self.select_preset(ctx, GalleryPreset::ALL[index]); } @@ -270,7 +280,7 @@ impl UiGalleryApp { ui.separator(); ui.small("Theme and fixture JSON reload without rebuilding"); ui.separator(); - ui.small("F1–F4 presets · Shift+F1–F4 modes · PgUp/PgDn fixtures"); + ui.small("F1–F7 presets · Shift+F1–F4 modes · PgUp/PgDn fixtures"); if let Some(stats) = self.difference_stats { ui.separator(); ui.small(format!( @@ -351,11 +361,12 @@ impl UiGalleryApp { let size = egui::vec2(self.preset.width(), self.preset.target_height()); let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); let palette = editor_ui::design_system::palette(ui); - let (canvas_fill, canvas_radius) = if self.preset.is_renderer() { - (palette.recessed, 4.0) - } else { - (palette.panel, 7.0) - }; + let (canvas_fill, canvas_radius) = + if self.preset.is_content_browser() || self.preset.is_renderer() { + (palette.recessed, 4.0) + } else { + (palette.panel, 7.0) + }; ui.painter().rect( rect, canvas_radius, @@ -379,7 +390,13 @@ impl UiGalleryApp { .iter() .map(|fixture| fixture.inputs.clone()) .collect::>(); - if self.preset.is_renderer() { + if self.preset.is_content_browser() { + crate::content_browser::render_content_browser_fixture( + &mut child, + self.preset.width(), + self.preset.target_height(), + ); + } else if self.preset.is_renderer() { self.render_renderer_fixture(&mut child); } else { self.render_material_fixture(&mut child); diff --git a/crates/ui_gallery/src/content_browser.rs b/crates/ui_gallery/src/content_browser.rs new file mode 100644 index 0000000..aa86db7 --- /dev/null +++ b/crates/ui_gallery/src/content_browser.rs @@ -0,0 +1,326 @@ +use editor_ui::content_browser::{ + content_browser_asset_card, content_browser_content_header, content_browser_detail_path_row, + content_browser_detail_row, content_browser_details_header, + content_browser_details_pane_heading, content_browser_details_section, + content_browser_grid_inset, content_browser_grid_surface_background, + content_browser_pane_background, content_browser_pane_heading, content_browser_source_row, + content_browser_status_bar, content_browser_toolbar, ContentBrowserAssetTone, + ContentBrowserBadgeTone, ContentBrowserCardStatus, ContentBrowserCardViewModel, + ContentBrowserChrome, ContentBrowserContentHeaderViewModel, + ContentBrowserDetailsHeaderViewModel, ContentBrowserKindFilter, ContentBrowserSort, + ContentBrowserSourceRowViewModel, ContentBrowserStatusViewModel, ContentBrowserThumbnail, + ContentBrowserToolbarViewModel, ContentBrowserViewMode, ContentPathSegment, ASSET_CARD_GAP, + DETAILS_WIDTH, SOURCES_WIDTH, STATUS_BAR_HEIGHT, +}; +use egui_phosphor_icons::{icons, Icon}; + +pub fn render_content_browser_fixture(ui: &mut egui::Ui, width: f32, height: f32) { + ui.spacing_mut().item_spacing = egui::Vec2::ZERO; + let compact = width < editor_ui::content_browser::COMPACT_BREAKPOINT; + let model = toolbar_model(); + let _ = content_browser_toolbar(ui, &model); + let toolbar_height = model.chrome.height(width); + let body_height = (height - toolbar_height - STATUS_BAR_HEIGHT).max(1.0); + let (body, _) = ui.allocate_exact_size(egui::vec2(width, body_height), egui::Sense::hover()); + let mut body_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(body) + .layout(egui::Layout::left_to_right(egui::Align::Min)), + ); + body_ui.set_clip_rect(body.intersect(ui.clip_rect())); + body_ui.spacing_mut().item_spacing = egui::Vec2::ZERO; + if compact { + draw_grid(&mut body_ui, width, body_height); + } else { + draw_sources(&mut body_ui, body_height); + draw_grid( + &mut body_ui, + (width - SOURCES_WIDTH - DETAILS_WIDTH).max(1.0), + body_height, + ); + draw_details(&mut body_ui, body_height); + } + content_browser_status_bar( + ui, + ContentBrowserStatusViewModel { + catalog: "Catalog ready", + processing: "2 thumbnails processing", + selection: "metal_office_desk selected", + healthy: true, + }, + ); +} + +fn toolbar_model() -> ContentBrowserToolbarViewModel { + ContentBrowserToolbarViewModel { + chrome: ContentBrowserChrome::Standalone, + path: ["Project", "assets", "Props", "Office"] + .into_iter() + .scan(String::new(), |path, label| { + if !path.is_empty() { + path.push('/'); + } + path.push_str(label); + Some(ContentPathSegment { + path: path.clone(), + label: label.to_owned(), + }) + }) + .collect(), + search: String::new(), + kind_filter: ContentBrowserKindFilter::All, + sort: ContentBrowserSort::Name, + view: ContentBrowserViewMode::Grid, + recursive: false, + show_sources: false, + show_details: false, + thumbnail_size: 96.0, + can_go_parent: true, + can_go_back: true, + can_go_forward: true, + can_edit_folder: true, + can_rename: true, + can_edit_selection: true, + can_paste: true, + can_undo: true, + pending_repairs: 0, + direct_folder_count: 3, + direct_asset_count: 12, + } +} + +fn draw_sources(ui: &mut egui::Ui, height: f32) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(SOURCES_WIDTH, height), egui::Sense::hover()); + let mut sources = ui.new_child( + egui::UiBuilder::new() + .max_rect(rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + sources.set_clip_rect(rect.intersect(ui.clip_rect())); + sources.spacing_mut().item_spacing = egui::Vec2::ZERO; + content_browser_pane_background(&sources); + content_browser_pane_heading(&mut sources, "SOURCES", false); + for (index, (label, count, depth, children, expanded)) in [ + ("Built-ins", 14, 0, false, false), + ("assets", 42, 0, true, true), + ("Furniture", 8, 1, true, true), + ("Office", 12, 1, false, false), + ("Materials", 9, 1, false, false), + ("Textures", 16, 1, false, false), + ("Levels", 3, 1, false, false), + ] + .into_iter() + .enumerate() + { + let id = format!("gallery-source-{index}"); + let _ = content_browser_source_row( + &mut sources, + ContentBrowserSourceRowViewModel { + id: &id, + label, + count, + depth, + has_children: children, + expanded, + selected: label == "Office", + }, + ); + } +} + +fn draw_grid(ui: &mut egui::Ui, width: f32, height: f32) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + let mut grid = ui.new_child( + egui::UiBuilder::new() + .max_rect(rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + grid.set_clip_rect(rect.intersect(ui.clip_rect())); + grid.spacing_mut().item_spacing = egui::Vec2::ZERO; + content_browser_pane_background(&grid); + content_browser_content_header( + &mut grid, + ContentBrowserContentHeaderViewModel { + folder: "Office", + item_count: 12, + sort: "Name", + selection_count: 1, + }, + ); + content_browser_grid_surface_background(&grid); + content_browser_grid_inset(&mut grid, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(ASSET_CARD_GAP, ASSET_CARD_GAP); + ui.horizontal_wrapped(|ui| { + for card in fixture_cards() { + let _ = content_browser_asset_card(ui, card.view_model(), 96.0); + } + }); + }); +} + +#[derive(Clone, Copy)] +enum CardState { + Normal, + Dirty, + Failed, + Processing, + Selected, +} + +#[derive(Clone, Copy)] +struct FixtureCard { + label: &'static str, + kind: &'static str, + icon: Icon, + state: CardState, +} + +impl FixtureCard { + fn view_model(self) -> ContentBrowserCardViewModel<'static> { + ContentBrowserCardViewModel { + label: self.label, + kind: self.kind, + icon: self.icon, + icon_style: if self.kind == "Folder" { + editor_ui::design_system::controls::PhosphorIconStyle::Fill + } else { + editor_ui::design_system::controls::PhosphorIconStyle::Regular + }, + thumbnail: if matches!(self.state, CardState::Failed) { + ContentBrowserThumbnail::Failed + } else if matches!(self.state, CardState::Processing) { + ContentBrowserThumbnail::Pending + } else { + ContentBrowserThumbnail::Placeholder + }, + asset_tone: fixture_asset_tone(self.kind), + selected: matches!(self.state, CardState::Selected), + status: ContentBrowserCardStatus { + dirty: matches!(self.state, CardState::Dirty), + processing: matches!(self.state, CardState::Processing), + error: matches!(self.state, CardState::Failed), + }, + failure_tooltip: matches!(self.state, CardState::Failed) + .then_some("Thumbnail render failed"), + } + } +} + +fn fixture_asset_tone(kind: &str) -> ContentBrowserAssetTone { + match kind { + "Primitive" => ContentBrowserAssetTone::Primitive, + "Light" => ContentBrowserAssetTone::Light, + "Model" => ContentBrowserAssetTone::Model, + "Texture" => ContentBrowserAssetTone::Texture, + "Material" => ContentBrowserAssetTone::Material, + "Audio Clip" => ContentBrowserAssetTone::Audio, + "Level" => ContentBrowserAssetTone::Level, + "Prefab" => ContentBrowserAssetTone::Prefab, + "Post Process Volume" | "Post FX" => ContentBrowserAssetTone::PostProcess, + "Rendering Profile" => ContentBrowserAssetTone::RenderingProfile, + "Shader Schema" => ContentBrowserAssetTone::Shader, + _ => ContentBrowserAssetTone::None, + } +} + +fn fixture_cards() -> [FixtureCard; 8] { + [ + FixtureCard { + label: "office_lamp", + kind: "Model", + icon: icons::CUBE, + state: CardState::Normal, + }, + FixtureCard { + label: "pebble_bricks", + kind: "Material", + icon: icons::PALETTE, + state: CardState::Dirty, + }, + FixtureCard { + label: "office_lamp", + kind: "Model", + icon: icons::CUBE, + state: CardState::Normal, + }, + FixtureCard { + label: "damaged_fixture", + kind: "Model", + icon: icons::CUBE, + state: CardState::Failed, + }, + FixtureCard { + label: "metal_arm_2k", + kind: "Texture", + icon: icons::IMAGE, + state: CardState::Processing, + }, + FixtureCard { + label: "pebble_bricks", + kind: "Material", + icon: icons::PALETTE, + state: CardState::Dirty, + }, + FixtureCard { + label: "metal_office_desk", + kind: "Model", + icon: icons::CUBE, + state: CardState::Selected, + }, + FixtureCard { + label: "textures", + kind: "Folder", + icon: icons::FOLDER_OPEN, + state: CardState::Normal, + }, + ] +} + +fn draw_details(ui: &mut egui::Ui, height: f32) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(DETAILS_WIDTH, height), egui::Sense::hover()); + let mut details = ui.new_child( + egui::UiBuilder::new() + .max_rect(rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + details.set_clip_rect(rect.intersect(ui.clip_rect())); + details.spacing_mut().item_spacing = egui::Vec2::ZERO; + content_browser_pane_background(&details); + content_browser_details_pane_heading(&mut details, "DETAILS", true); + let _ = content_browser_details_header( + &mut details, + ContentBrowserDetailsHeaderViewModel { + label: "metal_office_desk_2k", + kind: "Model", + icon: icons::CUBE, + thumbnail: ContentBrowserThumbnail::Placeholder, + badge: Some(("UNTRACKED", ContentBrowserBadgeTone::Neutral)), + badge_tooltip: Some("Git: untracked"), + unsaved: false, + can_locate: true, + }, + ); + content_browser_details_section(&mut details, "IDENTITY"); + content_browser_detail_path_row( + &mut details, + "Path", + "assets/Props/Office/metal_office_desk_2k.gltf", + ); + content_browser_detail_path_row(&mut details, "Folder", "assets/Props/Office"); + content_browser_detail_row( + &mut details, + "Asset ID", + "c07a5b05-f27d-46c6-8b8c-9de9cdd14720", + ); + content_browser_details_section(&mut details, "DEPENDENCIES"); + content_browser_detail_path_row( + &mut details, + "Ready", + "textures/metal_office_desk_diff_2k.png", + ); + content_browser_detail_path_row( + &mut details, + "Ready", + "textures/metal_office_desk_arm_2k.png", + ); +} diff --git a/crates/ui_gallery/src/main.rs b/crates/ui_gallery/src/main.rs index c2aefa1..392f1a3 100644 --- a/crates/ui_gallery/src/main.rs +++ b/crates/ui_gallery/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod content_browser; mod fixtures; mod reference; mod reload; diff --git a/crates/ui_gallery/src/reference.rs b/crates/ui_gallery/src/reference.rs index 380823e..82f346e 100644 --- a/crates/ui_gallery/src/reference.rs +++ b/crates/ui_gallery/src/reference.rs @@ -2,6 +2,9 @@ use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GalleryPreset { + ContentBrowser1240, + ContentBrowser1600, + ContentBrowser620, Materials620, Materials420, Renderer760, @@ -9,15 +12,21 @@ pub enum GalleryPreset { } impl GalleryPreset { - pub const ALL: [Self; 4] = [ + pub const ALL: [Self; 7] = [ + Self::ContentBrowser1240, + Self::ContentBrowser620, Self::Materials620, Self::Materials420, Self::Renderer760, Self::Renderer480, + Self::ContentBrowser1600, ]; pub const fn label(self) -> &'static str { match self { + Self::ContentBrowser1240 => "Content Browser · 1240", + Self::ContentBrowser1600 => "Content Browser · 1600 expanded", + Self::ContentBrowser620 => "Content Browser · 620", Self::Materials620 => "Materials · 620", Self::Materials420 => "Materials · 420", Self::Renderer760 => "Renderer · 760", @@ -27,6 +36,9 @@ impl GalleryPreset { pub const fn width(self) -> f32 { match self { + Self::ContentBrowser1240 => 1240.0, + Self::ContentBrowser1600 => 1600.0, + Self::ContentBrowser620 => 620.0, Self::Materials620 => 620.0, Self::Materials420 => 420.0, Self::Renderer760 => 760.0, @@ -36,6 +48,7 @@ impl GalleryPreset { pub const fn target_height(self) -> f32 { match self { + Self::ContentBrowser1240 | Self::ContentBrowser1600 | Self::ContentBrowser620 => 650.0, Self::Materials620 => 539.0, Self::Materials420 => 735.0, Self::Renderer760 => 1233.0, @@ -45,6 +58,9 @@ impl GalleryPreset { pub const fn file_name(self) -> &'static str { match self { + Self::ContentBrowser1240 => "content-browser-wide-1240.png", + Self::ContentBrowser1600 => "content-browser-expanded-1600.png", + Self::ContentBrowser620 => "content-browser-compact-620.png", Self::Materials620 => "materials-wide-620.png", Self::Materials420 => "materials-narrow-420.png", Self::Renderer760 => "static-mesh-renderer-wide-760.png", @@ -56,12 +72,21 @@ impl GalleryPreset { matches!(self, Self::Renderer760 | Self::Renderer480) } + pub const fn is_content_browser(self) -> bool { + matches!( + self, + Self::ContentBrowser1240 | Self::ContentBrowser1600 | Self::ContentBrowser620 + ) + } + /// Production-content canvas inside the exported Penpot reference. /// /// Material boards include the Inspector border and its 12 px horizontal / 10 px top inset; /// renderer boards are already complete component canvases. pub fn content_rect(self, canvas: egui::Rect) -> egui::Rect { - if self.is_renderer() { + if self.is_content_browser() { + canvas + } else if self.is_renderer() { egui::Rect::from_min_max( canvas.min + egui::vec2(0.0, 12.0), egui::pos2(canvas.right(), canvas.bottom()), diff --git a/crates/ui_gallery/src/reload.rs b/crates/ui_gallery/src/reload.rs index 2744599..e0b06a9 100644 --- a/crates/ui_gallery/src/reload.rs +++ b/crates/ui_gallery/src/reload.rs @@ -27,6 +27,23 @@ pub struct ThemeOverrides { pub success: Option, pub warning: Option, pub error: Option, + pub asset_types: AssetTypeThemeOverrides, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct AssetTypeThemeOverrides { + pub primitive: Option, + pub light: Option, + pub model: Option, + pub texture: Option, + pub material: Option, + pub audio: Option, + pub level: Option, + pub prefab: Option, + pub post_process: Option, + pub rendering_profile: Option, + pub shader: Option, } impl ThemeOverrides { @@ -54,6 +71,24 @@ impl ThemeOverrides { apply!(success); apply!(warning); apply!(error); + macro_rules! apply_asset_type { + ($field:ident) => { + if let Some(value) = self.asset_types.$field.as_deref() { + palette.asset_types.$field = parse_color(value)?; + } + }; + } + apply_asset_type!(primitive); + apply_asset_type!(light); + apply_asset_type!(model); + apply_asset_type!(texture); + apply_asset_type!(material); + apply_asset_type!(audio); + apply_asset_type!(level); + apply_asset_type!(prefab); + apply_asset_type!(post_process); + apply_asset_type!(rendering_profile); + apply_asset_type!(shader); Ok(palette) } } @@ -240,4 +275,21 @@ mod tests { ); assert!(parse_color("#not-a-color").is_err()); } + + #[test] + fn asset_category_colors_are_runtime_theme_overrides() { + let overrides: ThemeOverrides = + serde_json::from_str(r##"{"asset_types":{"model":"#010203","shader":"#A0B0C0"}}"##) + .expect("asset type theme overrides"); + let palette = overrides.palette().expect("resolved theme palette"); + assert_eq!(palette.asset_types.model, egui::Color32::from_rgb(1, 2, 3)); + assert_eq!( + palette.asset_types.shader, + egui::Color32::from_rgb(160, 176, 192) + ); + assert_eq!( + palette.asset_types.material, + editor_ui::theme::ASSET_TYPES.material + ); + } } diff --git a/docs/adr/0045-content-workspace-and-material-fallback-contract.md b/docs/adr/0045-content-workspace-and-material-fallback-contract.md index 883282c..d2cbf03 100644 --- a/docs/adr/0045-content-workspace-and-material-fallback-contract.md +++ b/docs/adr/0045-content-workspace-and-material-fallback-contract.md @@ -65,6 +65,12 @@ result. base. Promotion requires a resolved project or imported Material base. - Ordinary editor/runtime loading is read-only. Registry, project-settings, and scene migrations run only through the explicit transactional project upgrader. +- Editor thumbnails use typed content-addressed artifacts under managed + `assets/.thumbnails//` storage. Their visual signatures cover source/dependency bytes, + resolved Material inputs and textures, custom shader files, subasset identity, and the studio + renderer version. Normal catalog invalidation revalidates the signature while retaining the + last-good GPU image; replacement publishes atomically after a valid render/readback. These + artifacts are editor-only, untracked, watcher-excluded, and absent from packages. ## Consequences @@ -79,9 +85,10 @@ result. imported source because failure uses the project/engine fallback deliberately. - One cached DefaultGrid/emergency handle covers repeated primitive and mesh hydration, so missing assignments do not grow the material asset stores. -- Thumbnail cache ownership includes the backing Egui image registration. Replacement, retry, and - catalog invalidation explicitly release the last registration before a new studio render owns - the key, preventing refresh-driven GPU resource growth. +- Thumbnail cache ownership includes the backing Egui image registration. Replacement releases the + superseded registration only after the new artifact is usable; manual retry/full teardown release + it immediately. Catalog refresh retains it as last-good while revalidating visual signatures, + preventing both refresh flicker and hidden GPU resource growth. - File operations and imports must be staged transactions that update authored references, registry state, derived manifests, and runtime catalogs together. - Ordinary asset-property edits use the explicit dirty-document and two-phase publication boundary diff --git a/docs/adr/0049-penpot-led-editor-visual-system.md b/docs/adr/0049-penpot-led-editor-visual-system.md index 9e22511..fa8f008 100644 --- a/docs/adr/0049-penpot-led-editor-visual-system.md +++ b/docs/adr/0049-penpot-led-editor-visual-system.md @@ -114,9 +114,34 @@ field, vector, selection, and overlay components across the Inspector. Built-in dispatch through the registry and share one clipped actor-body scroll owner; domain behavior stays outside the visual primitives. +The Content Browser is the next migrated workspace. Its Penpot reference deliberately includes a +36 px standalone title row, but the production dock tab already owns that identity; docked content +therefore renders the remaining 55 px single-row toolbar at wide widths rather than duplicating +chrome. Compact composition uses the exported 88 px two-row toolbar, starts with the content grid, +and exposes mutually exclusive Sources and Details drawers from explicit actions. Wide composition +uses fixed 216 px Sources and a 290 px default resizable Details pane around a flexible content +lane. The 56 px content heading precedes one continuous row-major card stream in which folders and +assets share the same bounded card footprint. Toolbar, breadcrumb, content header, cards, Details +identity, and the catalog/processing/selection footer consume the same semantic tokens and return +actions to the owning editor module. Long identity and dependency paths use the exported bounded +two-line Details geometry rather than unbounded egui wrapping or a single unreadable clipped line. +Those presentation functions live together in +`editor_ui::content_browser` and are invoked by both the production panel and the UI Gallery; +`editor::ui::asset_browser` remains the sole owner of project queries, selection, transactions, +thumbnail scheduling, and action resolution. A fixture-only Gallery body is not valid evidence for +production conformance. + +The 1240 px toolbar remains the standard-wide reference. A separate 1600 px expanded reference +exposes sort and thumbnail scale in the toolbar at the 1400 px production breakpoint; standard and +compact layouts retain those controls in overflow. Card scaling preserves a fixed 40 px identity +footer and changes only the preview footprint, with square cached artifacts aspect-fitted rather +than stretched. Sorting and scaling are presentation operations and never mutate authored content. + ## Consequences - New editor UI has one reviewed visual vocabulary rather than panel-local approximations. +- Standalone design/gallery boards and docked production panels share one component without + duplicating dock-owned title chrome. - Theme colors have one runtime owner and one design-side asset vocabulary; the material panel does not carry a private blue/teal palette. - The 620 px and 420 px geometries are deterministic and unit-testable. The Inspector has a 420 px diff --git a/docs/editor/README.md b/docs/editor/README.md index 4871ad7..393a17b 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -84,7 +84,7 @@ records below are dated evidence, not product specifications. | `ui/mod.rs` | Dock layout, unified viewport, menus | README controls | | `ui/scene_tabs.rs` | Main-toolbar scene tabs and composition controls | multi-scene-composition.md, ADR 0026 | | `ui/component_registry.rs` | Stable authoring component identities, lifecycle metadata, save policy, and inspector callbacks | extensibility.md, ADR 0034 | -| `ui/asset_browser/` | Asset Browser tree, grid/list, details | roadmap Phase 4b | +| `editor_ui::content_browser` / `ui/asset_browser/` | Shared responsive Content Browser chrome/body presentation plus production project state, list behavior, and content transactions | content-workspace.md, ADR 0045, ADR 0049 | | `history/` | Undo/redo command stack | README controls | | `crates/scene` | Schema stamp/migrate/validate (shared) | ADR 0006, ADR 0008 | | `ext/extensibility.rs` | Command registry, `ActorInspectorSection`, palette | Phase 6 | @@ -98,7 +98,7 @@ records below are dated evidence, not product specifications. - **Dedicated egui `Camera2d`** at full window — never attach `PrimaryEguiContext` to a viewport-cropped 3D camera (NaN layout panic). - **Unified viewport** uses one render-to-texture target for both the editor fly camera and the possessed player camera so HDR/atmosphere is not broken by sub-viewport cropping. - **PIE:** F8 possess/eject while sim runs; **F6** pauses/resumes simulation in Play; project settings drive shared rendering for the active viewport camera. -- **Asset browser** mirrors the on-disk `assets/` directory with project tree, breadcrumb, search/filter/sort, grid/list views, texture/model/material thumbnails, a details pane, and context-aware row/menu actions. It uses file-manager selection semantics across files and folders (click, Ctrl/Cmd toggle, Shift range, Ctrl/Cmd+A), batch clipboard/duplicate/trash/drag operations, keyboard shortcuts, and empty-space workspace menus. The details divider is draggable so asset and import settings can use more horizontal space, and double-clicking it restores the default width. Narrow docks prioritize content, keep the root panel fixed, switch list view to a compact single-column layout, and hide tree/details panes when they would crowd the content area. The footer stays pinned while only the project tree, asset content, and details panels scroll. **Built-ins** holds spawnable primitives and lights. Materials and shaders are classified by document schema anywhere under `assets/`; the browser renders material thumbnails on a sphere and exposes shader-schema-driven parameters/textures in Details. **Audio** accepts Bevy-supported Ogg/Vorbis and Speex (`.ogg`, `.oga`, `.spx`), WAV, MP3, and FLAC clips, with a dedicated filter, waveform icon, format/file details, and stable registry-backed references. glTF/GLB/FBX rows expand into normalized embedded mesh, material, and texture subassets. Model and Texture Details edit asset-keyed **UNSAVED** documents; `Ctrl+S` saves the active context and `Ctrl+Shift+S` saves all before affected-only background processing. FBX previews use the same sandboxed dependency resolver as import and validation; missing textures are required when any slot uses Source and informational when every slot uses Project/Default. Bundle import preserves referenced sibling or `.fbm/` layout transactionally. See [content-workspace.md](content-workspace.md) and [ADR 0044](../adr/0044-sandboxed-fbx-external-texture-dependencies.md). +- **Asset browser** mirrors the on-disk `assets/` directory with project tree, breadcrumb, search/filter/sort, grid/list views, texture/model/material thumbnails, a details pane, and context-aware row/menu actions. It uses file-manager selection semantics across files and folders (click, Ctrl/Cmd toggle, Shift range, Ctrl/Cmd+A), batch clipboard/duplicate/trash/drag operations, keyboard shortcuts, and empty-space workspace menus. The details divider is draggable so asset and import settings can use more horizontal space, and double-clicking it restores the default width. Narrow docks prioritize content, keep the root panel fixed, switch list view to a compact single-column layout, and hide tree/details panes when they would crowd the content area. The footer stays pinned while only the project tree, asset content, and details panels scroll. **Built-ins** holds spawnable primitives and lights. Materials and shaders are classified by document schema anywhere under `assets/`; the browser renders material thumbnails on a sphere and exposes shader-schema-driven parameters/textures in Details. Typed 256 px previews persist in the managed `.thumbnails` cache, revalidate against visual dependency hashes, retain the last-good image during regeneration, and self-heal corrupt artifacts across restarts. **Audio** accepts Bevy-supported Ogg/Vorbis and Speex (`.ogg`, `.oga`, `.spx`), WAV, MP3, and FLAC clips, with a dedicated filter, waveform icon, format/file details, and stable registry-backed references. glTF/GLB/FBX rows expand into normalized embedded mesh, material, and texture subassets. Model and Texture Details edit asset-keyed **UNSAVED** documents; `Ctrl+S` saves the active context and `Ctrl+Shift+S` saves all before affected-only background processing. FBX previews use the same sandboxed dependency resolver as import and validation; missing textures are required when any slot uses Source and informational when every slot uses Project/Default. Bundle import preserves referenced sibling or `.fbm/` layout transactionally. See [content-workspace.md](content-workspace.md) and [ADR 0044](../adr/0044-sandboxed-fbx-external-texture-dependencies.md). - **Imported-source fingerprints** use exact byte length plus lowercase BLAKE3 for model, texture, and audio registry records and for generated static-mesh/animation manifests. Filesystem timestamps are scan hints only; equivalent refresh preserves the exact committed RON bytes and registry publication order is normalized by project path. See [ADR 0043](../adr/0043-content-addressed-import-fingerprints.md) and the [acceptance record](evaluations/deterministic-asset-fingerprints/). - **Material Library** is a dockable bottom-panel catalog for cross-folder Material and direct-base Material Instance authoring. It provides search, type and scene-usage filters, grid/list thumbnails, dependency health, usage counts, creation, guarded details editing, and first-class drag sources. Viewport drops resolve an exact renderer slot, primitive, or brush face under the pointer, preview transiently, reject incompatible/read-only targets explicitly, restore on target change/cancel, and commit one typed undo step on release. - **Static/skinned renderer split** — model drag/drop uses normalized artifacts under `assets/meshes/generated/`. Unrigged, non-animated sources create `ActorKind::StaticMesh + StaticMeshRenderer`; skin-bound or animated sources create `ActorKind::SkinnedMesh + SkinnedMeshRenderer` and preserve the imported hierarchy. Static slots never contain marked skinned primitives or geometry from animated sources. `SceneInstance` placement keeps `ImportedModel + ModelRef` for generic full-source scenes. See [ADR 0033](../adr/0033-dedicated-skinned-mesh-renderer.md). diff --git a/docs/editor/architecture.md b/docs/editor/architecture.md index 098c1b1..7d6a349 100644 --- a/docs/editor/architecture.md +++ b/docs/editor/architecture.md @@ -254,9 +254,14 @@ the Edit-to-Play boundary restore the complete runtime snapshot. See poses. Material subassets expose source defaults, and texture dependencies can be applied to selected actors. The thumbnail studio renders each generated model/subasset/material preview into its own render target before registering it in the cache so later thumbnails cannot overwrite - earlier cache entries. FBX model and mesh previews use neutral direct geometry; source-material - previews preflight external textures and retain one stable hover-visible failure reason instead - of enqueueing a known-missing path. + earlier cache entries. Completed renders are asynchronously read back into typed, + content-addressed 256 px PNG artifacts under `assets/.thumbnails/v3/`. Visual signatures include + source/dependency bytes, resolved Material inputs, textures, shader files, and the studio version. + Catalog refresh increments a validation epoch without discarding the last-good GPU registration; + only a changed signature schedules replacement. Corrupt artifacts regenerate, publication is + atomic, and retryable studio failures receive one bounded retry. FBX model and mesh previews use + neutral direct geometry; source-material previews preflight external textures and retain one + stable hover-visible failure reason instead of enqueueing a known-missing path. - **Default placement:** **Renderable Asset (Auto)** creates `ActorKind::StaticMesh + StaticMeshRenderer` for unrigged, non-animated models and `ActorKind::SkinnedMesh + SkinnedMeshRenderer` when any part is skin-bound or the source contains diff --git a/docs/editor/content-workspace.md b/docs/editor/content-workspace.md index 274c8ca..2f6bf12 100644 --- a/docs/editor/content-workspace.md +++ b/docs/editor/content-workspace.md @@ -14,8 +14,32 @@ is mutable metadata that moves, validation, and packaging repair through the sha ## Content Browser workflow -The Content Browser toolbar provides **New Folder**, **Rename**, **Duplicate**, **Cut**, **Copy**, -and **Paste**. The selected folder is the destination for paste and **File > Import Assets**. These +The docked Content Browser maps the standalone Penpot title row to the editor dock tab instead of +rendering a second title inside the panel. At wide widths it uses the Penpot 55 px single-row +content toolbar: navigation, import, folder creation, breadcrumbs, search, type filtering, and view +controls share one dense row. At 1400 px and wider, sort order and the grid thumbnail-size slider +also become first-class controls in that row; narrower wide and compact layouts keep those +lower-frequency controls in overflow. Sorting preserves selection, while thumbnail scaling is a +presentation-only change and is disabled in list view. Below the 1000 px responsive breakpoint, +the compact 88 px two-row +toolbar keeps search in the flexible center lane, Sources and Details become +explicit mutually exclusive drawers, Import/New remain on the breadcrumb row, and infrequent +operations move to overflow. The default compact view is the content grid; no hidden pane silently +consumes its width. Wide mode uses a 216 px Sources pane and a 290 px default Details pane; the +Details divider remains resizable and double-click restores 290 px. + +Grid mode uses one continuous row-major stream for folders and assets. Folders retain navigation +and drop-target behavior but use the same bounded card contract as files; they never force +the first asset onto a new row. The 56 px content heading owns the folder name, sort summary, and +selection chip before that single card stream begins. Each card clips a single bounded name and a +separate asset-kind line; long or Unicode names expose their full value by tooltip instead of +wrapping across card bounds. Sources, that shared card presentation, and bounded Details rows live +in `editor_ui::content_browser`; production supplies real project state and handles the actions, +while the UI Gallery supplies fixtures to the same functions. Details paths and dependency +identities receive the exported 32 px two-line treatment; other scalar values stay one line. + +The Content Browser provides **New Folder**, **Rename**, **Duplicate**, **Cut**, **Copy**, and +**Paste**. The selected folder is the destination for paste and **File > Import Assets**. These operations use the shared content transaction engine: it previews collisions, rejects managed or symlink paths, stages filesystem and cached-reference changes together, preserves IDs on moves, assigns new IDs to copies, and rolls back on failure. Successful move, rename, create-folder, copy, @@ -80,7 +104,9 @@ unused content space clears any old selection and opens workspace actions for ** current folder; the Material Library's create button retains `assets/materials/` as its convenient default. Double-clicking a scene opens it, a model toggles its embedded contents, and other asset types reveal -their editable Details. The footer and Details pane summarize multi-selections; single-selection +their editable Details. The fixed footer reports catalog health, thumbnail/derived processing, and +the current selection without moving card labels. The content header reports folder/item count, +active sort, and a bounded selection chip. The footer and Details pane summarize multi-selections; single-selection Details remains the authoritative editor for import and material settings. Drag the Details divider to resize it for long slot/shader forms; double-click the divider to restore its default width. Texture Details uses the same pane for semantic/color-space intent, mip generation, UASTC or @@ -92,9 +118,23 @@ Thumbnail cache keys are typed by asset category. Texture cards may use direct i model cards always queue the offscreen geometry studio—even when a glTF has a base-color texture. Expanded mesh rows request their exact mesh-subasset render, and source materials use a separate material-sphere key. Missing dependencies produce an explicit failed thumbnail; a model or mesh -card never substitutes an albedo texture. Replacing, retrying, or invalidating a thumbnail also -unregisters its last Egui image handle, so repeated catalog refreshes release the prior GPU image -instead of accumulating hidden render targets. +card never substitutes an albedo texture. + +Successful 256 px previews are persisted beneath the managed, untracked +`assets/.thumbnails/v3/` cache. The artifact key hashes the typed thumbnail identity, renderer +version, source bytes, model bundle dependencies, resolved Material values, referenced textures, +and custom shader files. A restart decodes that small validated PNG instead of reloading a 4K +texture or re-rendering geometry. Catalog refreshes revalidate signatures, and retained prior +generations restore the last-good image even after a restart while a changed preview is rebuilt or +its broken source is diagnosed; corrupt/missing artifacts self-heal, retryable studio +failures receive one bounded automatic retry, and **Regenerate Thumbnail** explicitly evicts the +selected key. Publication is atomic, retains one prior generation until the replacement is valid, +and trims only old derived entries after the cache exceeds its 512 MiB soft limit. + +The grid slider scales cards from 68×88 through the 116×136 default to 132×152. Only the preview +region scales: every size reserves the same 40 px identity footer, keeps the name and kind bounded, +and paints the category keyline inside the card bottom. Canonical 256 px thumbnail artifacts are +aspect-fitted as squares rather than stretched to the card's changing preview rectangle. **Import Here** pins the current/selected folder before the native picker opens. **Import To...** first opens a project-folder chooser without navigating away from the current browser location. @@ -173,8 +213,14 @@ shown separately from Git state. `Ctrl+S` saves the last edited context; `Ctrl+S never discard a dirty asset. Source publication is guarded and atomic, then affected-only derived processing runs in the background while the previous valid artifact remains active. Material scalar/color/render-state saves do not show `PROCESSING`; only packed-map binding changes -do. Content Browser and Material Library cards share clipped two-line labels, a top-right grid-card -status rail, and fixed-width list markers, so state never paints over or shifts an asset name. +do. Content Browser cards use a clipped one-line identity plus a separate kind line, a top-right +grid-card status rail, and fixed-width list markers, so state never paints over or shifts an asset +name. Every registered asset category retains its theme-owned classification color as a narrow +keyline along the bottom of the complete card, including when a rendered thumbnail is ready. The +keyline never tints or otherwise alters preview content; folders remain neutral, while loading, +failure, dirty, and selection indicators remain independent states. The canonical category palette +is documented in the [editor design system](design-system.md#asset-category-colors). Material +Library cards consume the same authored-document status model. ## Headless processing diff --git a/docs/editor/design-system.md b/docs/editor/design-system.md index 98f1459..eea07e7 100644 --- a/docs/editor/design-system.md +++ b/docs/editor/design-system.md @@ -47,6 +47,48 @@ The material implementation is split as follows: - `ui/materials/inputs.rs` renders schema-declared material controls. - Inspector and asset editors provide state and apply returned actions through authored documents. +The Content Browser follows the same boundary. `editor_ui::content_browser` owns the standalone or +docked chrome mode plus the shared Sources rows, content heading, bounded folder/asset cards, +Details identity/rows, status presentation, and returned actions. The production +`ui/asset_browser` modules supply project state and own navigation history, selection, transactions, +thumbnail requests, and action handling; they do not carry a second body renderer. The UI Gallery +uses the same presentation functions with fixture view models, so a Gallery correction reaches the +production panel. The dock tab supplies the 36 px title row represented in the standalone Penpot +board. Production therefore renders the remaining 55 px single-row toolbar at wide widths or the +88 px two-row compact toolbar below 1000 px. An expanded composition at 1400 px and wider adds +foundation Combo Box and Slider instances for sort and thumbnail scale without changing the +standard-wide anchors; those controls return the same actions as their overflow counterparts. + +### Asset category colors + +Asset classification is a theme role, independent from thumbnail health, Git state, processing, +selection, or hover. Every `EditorAssetKind` maps exhaustively to the shared `AssetTypePalette` and +renders a 3 px keyline inside the bottom edge of its grid card. Primitive, Light, Model, Texture, +Material, Audio, Level, Prefab, Post Process, Rendering Profile, and Shader each have a distinct +default color; folders have no category keyline. The keyline remains stable for placeholder, +pending, ready, and failed thumbnails, so a rendered image never hides the asset type and a failure +never replaces it. The UI Gallery `theme.json` exposes the same roles for live iteration. + +At the 1240 px Penpot reference, Sources is 216 px, Details is 290 px, and the grid owns the flexible +middle lane. At the 620 px reference, Sources and Details are absent by default and are opened from +dedicated toolbar actions. The compact toolbar preserves Back/Forward/Up, a 292 px reference search +field, Grid/List, Sources, Details, Import, New, and overflow. Status geometry is invariant: catalog +health is left, active processing is shown when space permits, and selection is right-aligned. +The content heading is 56 px, and grid cards form one continuous row-major stream across folder and +asset kinds rather than separate folder and file rows. Each body lane uses the semantic panel fill, +5 px radius, and strong inside border from the Penpot board. The grid's recessed surface begins 16 +px inside that lane and cards begin another 6 px inside the surface; the resulting 22 px card +origin is shared by production and Gallery. The Details heading stays outside its scrolling content +body, and its identity stack owns the same fixed vertical geometry in both consumers. Ordinary +Details values use one clipped line; project-relative paths and dependency identities use the +Penpot 32 px two-line value region with ellipsis and a full-value tooltip, so a resized Details pane +never paints text across its boundary. + +At the 1600 px expanded reference, only the flexible toolbar gap and grid lane grow. Sort and +thumbnail scale appear between the type filter and view controls. Grid cards scale from 68×88 to +132×152 around a fixed 40 px identity footer, and ready 256 px thumbnails remain square and +aspect-fitted at every size. + ## Reference layout At the current 620 px wide reference, 12 px outer padding produces a 596 px slot and a 569 px parameter @@ -154,8 +196,9 @@ python scripts/editor/ui_gallery.py watch ``` The gallery links `editor_ui` and `material_schema` directly and does not open a project, scene, -renderer, or gameplay runtime. F1–F4 select Materials 620, Materials 420, Renderer 760, and Renderer -480; Shift+F1–F4 select Live, Side by side, Overlay, and Difference; Page Up/Page Down cycle the +renderer, or gameplay runtime. F1–F7 select Content Browser 1240/620, Materials 620/420, +Renderer 760/480, and Content Browser 1600 expanded; Shift+F1–F4 select Live, Side by side, +Overlay, and Difference; Page Up/Page Down cycle the ownership and health fixtures. `crates/ui_gallery/fixtures/theme.json` and `material.json` reload in the running process. Rust changes rebuild only the focused dependency closure, report a bounded `resolved/rebuilt` counter, launch the replacement, and then stop the last-good gallery; a failed diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/README.md b/docs/editor/evaluations/content-workspace-m2/penpot/README.md index 8d26b91..7322681 100644 --- a/docs/editor/evaluations/content-workspace-m2/penpot/README.md +++ b/docs/editor/evaluations/content-workspace-m2/penpot/README.md @@ -17,6 +17,14 @@ runtime invariant or substitutes for the paired full-editor acceptance capture. | [Static Mesh Renderer — wide](static-mesh-renderer-wide-760.svg) | Page 02, `6cb78075-fbd0-8045-8008-57f5315abf0a` | Full 760 px renderer composition, stable slot ordering, shared component chrome, and repeated material slots. | | [Static Mesh Renderer — narrow](static-mesh-renderer-narrow-480.svg) | Page 02, `6cb78075-fbd0-8045-8008-57f5419afad2` | Full 480 px renderer composition and bounded narrow reflow. | +## Content Browser targets + +| Export | Penpot page and shape | Required match | +|---|---|---| +| [Content Browser — wide](content-browser-wide-1240.png) | Page 03, `e755994a-6c1d-80bd-8008-58e2e8668f78` | 1240×650 standalone reference; 36 px dock/title row, 55 px single-row content toolbar, 216 px Sources, 56 px content heading, one mixed folder/asset card stream, flexible grid, 290 px Details, and 32 px status bar. Production maps the title row to its dock tab. | +| [Content Browser — expanded](content-browser-expanded-1600.png) | Page 18, `e4366b2b-77bd-8058-8008-5a57e6c9a02f` | 1600×650 expanded reference; the standard toolbar gains first-class Name sort and thumbnail-scale controls composed from the foundation Combo Box and Slider. Cards retain their identity footer while previews scale. | +| [Content Browser — compact](content-browser-compact-620.png) | Page 03, `e755994a-6c1d-80bd-8008-58e2e8dbf894` | 620×650 compact reference; 88 px two-row content toolbar, grid-only default body, explicit Sources/Details access, preserved search/navigation/grid-list controls, Import/New on the breadcrumb row, the same mixed card stream, and no clipped actions. | + ## Shared component targets | Export | Penpot page and shape | Required match | @@ -39,6 +47,7 @@ runtime invariant or substitutes for the paired full-editor acceptance capture. ## Issue ownership - **#68** owns exact Inspector/component/material/color-picker geometry and responsive behavior against these exports. +- **#68** also owns Content Browser visual-system conformance against the current Page 03 wide and compact boards. - **#67** owns the live authored-document and save-state behavior represented by the material-state and adjusted-color exports: interaction remains memory-only until explicit save, with distinct unsaved, processing, failed, and conflict state. - **#65** owns consolidated native comparison evidence across wide, narrow, short-height, multi-slot, overlay, save, restart, and failure scenarios. - **#59** indexes the design authority and requires the relevant child acceptance targets to be satisfied before epic closure. diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/content-browser-compact-620.png b/docs/editor/evaluations/content-workspace-m2/penpot/content-browser-compact-620.png new file mode 100644 index 0000000..8797a3f --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/content-browser-compact-620.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a60b81b28692217747da4f0ba5f969a44b4486ae07d002a4e1baaaabd8de510 +size 43337 diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/content-browser-expanded-1600.png b/docs/editor/evaluations/content-workspace-m2/penpot/content-browser-expanded-1600.png new file mode 100644 index 0000000..b97b7cc --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/content-browser-expanded-1600.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6832f2d7cd55aff212bbe815de73ce3a24b05dbf4536e5f0ac27290eaaf58721 +size 80591 diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/content-browser-wide-1240.png b/docs/editor/evaluations/content-workspace-m2/penpot/content-browser-wide-1240.png new file mode 100644 index 0000000..e7ef3d2 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/content-browser-wide-1240.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f32f2e3abbac4cef53689b9eb8136d87dd981e3a86633aa9ca73f3c4fbc9694 +size 82967 diff --git a/docs/editor/project-launcher.md b/docs/editor/project-launcher.md index 90ec8b8..b887f03 100644 --- a/docs/editor/project-launcher.md +++ b/docs/editor/project-launcher.md @@ -53,9 +53,15 @@ This keeps renderer, asset server, import registry, and project settings rooted On the supported Linux workstation, the **Blacksite Editor** desktop entry provides an **Open Project Browser** action. Its wrapper launches the current managed development binary directly, shows the normal startup splash, avoids duplicate processes, and preserves the Wayland, session-bus, -graphics, and XDG environment required by the detached process. A desktop click never invokes Cargo. -An explicit `~/.local/bin/blacksite-editor --build-only` rebuild uses the repository's managed Cargo -lane; a missing binary produces an actionable splash error instead of starting an implicit build. +graphics, and XDG environment required by the detached process. Before launch, the wrapper runs an +incremental build of both editor binaries through the persistent managed Cargo lane and its storage +pre/postflight. The splash reports exact resolved-crate progress, and any build or storage-preflight +failure remains visible with an actionable log instead of launching a stale executable. An explicit +`~/.local/bin/blacksite-editor --build-only` performs the same managed update without opening a GUI. +If an editor is already running, the wrapper still performs that update check before focusing it. +When Cargo replaced the executable, the running process is deliberately left untouched to protect +unsaved authored documents; the splash and desktop notification explicitly ask for a normal +close-and-reopen instead of implying that the old process already contains the rebuilt UI. The browser's **New Sandbox** workflow requires a missing or empty folder and a non-empty name. Creation failures remain in the browser as status text; a successful scaffold is validated and diff --git a/scripts/editor/blacksite-editor b/scripts/editor/blacksite-editor index 914383e..29ccd3d 100755 --- a/scripts/editor/blacksite-editor +++ b/scripts/editor/blacksite-editor @@ -10,7 +10,8 @@ readonly STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/blacksite-editor" readonly LOG_FILE="$STATE_DIR/launch.log" readonly STATUS_FILE="$STATE_DIR/splash.status" readonly SPLASH="$REPO/scripts/editor/splash.py" -readonly CARGO_LANE="$REPO/scripts/codex/cargo_lane.py" +readonly BUILD_PROGRESS="$REPO/scripts/editor/build_progress.py" +readonly BUILD_STORAGE="$REPO/scripts/codex/build_storage.py" build_only=false project_browser=false @@ -78,27 +79,55 @@ close_splash() { } trap close_splash EXIT -focus_existing() { +find_existing() { local pid executable while read -r pid; do [[ -n "$pid" ]] || continue executable="$(readlink -f "/proc/$pid/exe" 2>/dev/null || true)" if [[ "$executable" == "$binary" ]]; then - if command -v hyprctl >/dev/null 2>&1; then - hyprctl dispatch focuswindow "pid:$pid" >/dev/null 2>&1 || true - fi + printf '%s\n' "$pid" return 0 fi done < <(pgrep -x "$process_name" 2>/dev/null || true) return 1 } -if ! $build_only && focus_existing; then - exit 0 -fi +focus_process() { + local pid="$1" + if command -v hyprctl >/dev/null 2>&1; then + hyprctl dispatch focuswindow "pid:$pid" >/dev/null 2>&1 || true + fi +} + +running_binary_is_current() { + local pid="$1" + local running_identity current_identity + running_identity="$(stat -Lc '%d:%i' "/proc/$pid/exe" 2>/dev/null || true)" + current_identity="$(stat -Lc '%d:%i' "$binary" 2>/dev/null || true)" + [[ -n "$running_identity" && "$running_identity" == "$current_identity" ]] +} + +build_current_editor() { + write_status "Checking build storage" "Validating the managed development lane" 6 + if ! python3 "$BUILD_STORAGE" enforce --phase pre >>"$LOG_FILE" 2>&1; then + write_status "Build storage limit reached" \ + "Open the launch log for the checked storage report" 6 error + return 1 + fi + + python3 "$BUILD_PROGRESS" "$STATUS_FILE" "$LOG_FILE" + local build_result=$? + + if ! python3 "$BUILD_STORAGE" enforce --phase post >>"$LOG_FILE" 2>&1; then + printf '%s\n' \ + "warning: build-storage postflight failed; see the report above" \ + >>"$LOG_FILE" + fi + return "$build_result" +} mkdir -p "$STATE_DIR" -exec 9>"$STATE_DIR/launch.lock" +exec 9>"$STATE_DIR/build.lock" if ! flock -n 9; then exit 0 fi @@ -117,13 +146,50 @@ if ! cd "$REPO"; then exit 1 fi +# Capture the process before Cargo may atomically replace the on-disk executable. A launcher click +# always checks/builds source updates first; it never silently focuses a stale process and makes the +# user believe the running editor already contains the rebuilt UI. +existing_pid="" +if ! $build_only; then + existing_pid="$(find_existing || true)" +fi + +if ! build_current_editor; then + if $build_only; then + tail -n 120 "$LOG_FILE" >&2 + else + keep_splash=true + fi + exit 1 +fi + +if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid" 2>/dev/null; then + focus_process "$existing_pid" + if running_binary_is_current "$existing_pid"; then + write_status "Editor already open" "Focused the current development build" 100 close + else + write_status "Update ready" "Close and reopen the editor to activate it" 100 close + if command -v notify-send >/dev/null 2>&1; then + notify-send --app-name="Blacksite Editor" \ + "Blacksite update ready" \ + "Close the running editor normally, then reopen it to activate the rebuilt UI." \ + >/dev/null 2>&1 || true + fi + fi + # The status above already asked the splash process to close. Prevent the EXIT trap from + # replacing the meaningful "already open" or "update ready" result with a startup message. + splash_pid="" + exit 0 +fi + if $build_only; then - exec python3 "$CARGO_LANE" exec dev -- cargo build -p editor --bin editor + printf 'Blacksite editor and project browser are current.\n' + exit 0 fi if [[ ! -x "$binary" ]]; then write_status "Editor build unavailable" \ - "Run blacksite-editor --build-only from a terminal" 100 error + "The managed build completed without the requested binary" 100 error keep_splash=true exit 1 fi @@ -175,6 +241,11 @@ watch_editor_window() { "The process is still running; check the launch log" 100 error } +# The lock protects the managed build and startup handoff, not the editor runtime. Leaving this +# descriptor inherited across `exec` prevents later build-only updates for as long as the editor is +# open. +flock -u 9 +exec 9>&- watch_editor_window "$$" & exec "$binary" >>"$LOG_FILE" 2>&1 diff --git a/scripts/editor/build_progress.py b/scripts/editor/build_progress.py index 5b63786..f9f0068 100755 --- a/scripts/editor/build_progress.py +++ b/scripts/editor/build_progress.py @@ -8,9 +8,14 @@ import json import os from pathlib import Path import subprocess +import sys from typing import Iterable +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_CARGO_LANE = ROOT / "scripts/codex/cargo_lane.py" + + def write_status( path: Path, phase: str, @@ -27,10 +32,6 @@ def write_status( os.replace(temporary, path) -def unique_crates(tree_output: str) -> set[str]: - return {line.strip() for line in tree_output.splitlines() if line.strip()} - - def completed_crate( message: object, completed_package_ids: set[str] ) -> tuple[bool, str]: @@ -46,39 +47,111 @@ def completed_crate( return True, package_id.rsplit("#", 1)[-1] -def cargo_tree(cargo: str) -> list[str]: +def managed_cargo(cargo_lane: Path, lane: str, *arguments: str) -> list[str]: return [ - cargo, - "tree", - "-p", - "editor", - "--features", - "dev", - "--edges", - "normal,build", - "--prefix", - "none", - "--format", - "{p}", - "--no-dedupe", - "--color", - "never", + sys.executable, + str(cargo_lane), + "exec", + lane, + "--", + "cargo", + *arguments, ] -def cargo_build(cargo: str) -> list[str]: - return [ - cargo, +def host_target() -> str: + completed = subprocess.run( + ["rustc", "-vV"], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + ) + for line in completed.stdout.splitlines(): + if line.startswith("host:"): + return line.partition(":")[2].strip() + return "" + + +def cargo_metadata(cargo_lane: Path, lane: str, target: str) -> list[str]: + arguments = ["metadata", "--format-version", "1"] + if target: + arguments.extend(["--filter-platform", target]) + return managed_cargo( + cargo_lane, + lane, + *arguments, + ) + + +def cargo_build(cargo_lane: Path, lane: str) -> list[str]: + return managed_cargo( + cargo_lane, + lane, "build", "-p", "editor", "--bins", - "--features", - "dev", "--message-format=json-render-diagnostics", "--color", "never", - ] + ) + + +def editor_package_closure(metadata_output: str) -> set[str]: + document = json.loads(metadata_output) + packages = document.get("packages") + resolve = document.get("resolve") + if not isinstance(packages, list) or not isinstance(resolve, dict): + raise ValueError("Cargo metadata did not include packages and a resolve graph") + + editor_ids = { + package.get("id") + for package in packages + if isinstance(package, dict) + and package.get("name") == "editor" + and isinstance(package.get("id"), str) + } + if len(editor_ids) != 1: + raise ValueError("Cargo metadata did not identify exactly one editor package") + root_id = next(iter(editor_ids)) + + nodes = resolve.get("nodes") + if not isinstance(nodes, list): + raise ValueError("Cargo metadata resolve graph did not include nodes") + nodes_by_id = { + node["id"]: node + for node in nodes + if isinstance(node, dict) and isinstance(node.get("id"), str) + } + + closure: set[str] = set() + pending = [root_id] + while pending: + package_id = pending.pop() + if package_id in closure: + continue + closure.add(package_id) + node = nodes_by_id.get(package_id) + if not isinstance(node, dict): + continue + dependencies = node.get("deps") + if not isinstance(dependencies, list): + continue + for dependency in dependencies: + if not isinstance(dependency, dict): + continue + dependency_id = dependency.get("pkg") + kinds = dependency.get("dep_kinds") + if not isinstance(dependency_id, str) or not isinstance(kinds, list): + continue + if any( + isinstance(kind, dict) and kind.get("kind") in {None, "build"} + for kind in kinds + ): + pending.append(dependency_id) + return closure def rendered_lines(message: object, raw_line: str) -> Iterable[str]: @@ -93,25 +166,33 @@ def rendered_lines(message: object, raw_line: str) -> Iterable[str]: yield rendered.rstrip("\n") -def run(status_path: Path, log_path: Path, cargo: str) -> int: +def run(status_path: Path, log_path: Path, cargo_lane: Path, lane: str) -> int: write_status( status_path, "Preparing build", "Resolving editor crate graph", 8, ) - tree = subprocess.run( - cargo_tree(cargo), + metadata = subprocess.run( + cargo_metadata(cargo_lane, lane, host_target()), + cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) - crates = unique_crates(tree.stdout) if tree.returncode == 0 else set() + try: + crates = ( + editor_package_closure(metadata.stdout) + if metadata.returncode == 0 + else set() + ) + except (json.JSONDecodeError, ValueError): + crates = set() if not crates: with log_path.open("a", encoding="utf-8") as log: - log.write(tree.stdout) - log.write(tree.stderr) + log.write(metadata.stdout) + log.write(metadata.stderr) write_status( status_path, "Build graph failed", @@ -119,7 +200,7 @@ def run(status_path: Path, log_path: Path, cargo: str) -> int: 8, "error", ) - return tree.returncode or 1 + return metadata.returncode or 1 total = len(crates) write_status( @@ -131,7 +212,8 @@ def run(status_path: Path, log_path: Path, cargo: str) -> int: ) process = subprocess.Popen( - cargo_build(cargo), + cargo_build(cargo_lane, lane), + cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -154,7 +236,7 @@ def run(status_path: Path, log_path: Path, cargo: str) -> int: changed, crate_name = completed_crate(message, completed_package_ids) if not changed: continue - completed = min(len(completed_package_ids), total) + completed = len(completed_package_ids.intersection(crates)) progress = min(78, 12 + (66 * completed // total)) write_status( status_path, @@ -165,7 +247,7 @@ def run(status_path: Path, log_path: Path, cargo: str) -> int: ) return_code = process.wait() - completed = min(len(completed_package_ids), total) + completed = len(completed_package_ids.intersection(crates)) if return_code != 0: write_status( status_path, @@ -191,9 +273,15 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("status_path", type=Path) parser.add_argument("log_path", type=Path) - parser.add_argument("--cargo", default="/usr/bin/cargo") + parser.add_argument("--cargo-lane", type=Path, default=DEFAULT_CARGO_LANE) + parser.add_argument("--lane", default="dev") arguments = parser.parse_args() - return run(arguments.status_path, arguments.log_path, arguments.cargo) + return run( + arguments.status_path, + arguments.log_path, + arguments.cargo_lane, + arguments.lane, + ) if __name__ == "__main__": diff --git a/scripts/editor/test_build_progress.py b/scripts/editor/test_build_progress.py index 7e60286..a693d25 100755 --- a/scripts/editor/test_build_progress.py +++ b/scripts/editor/test_build_progress.py @@ -6,6 +6,7 @@ import unittest MODULE_PATH = Path(__file__).with_name("build_progress.py") +LAUNCHER_PATH = Path(__file__).with_name("blacksite-editor") SPEC = importlib.util.spec_from_file_location("build_progress", MODULE_PATH) assert SPEC is not None and SPEC.loader is not None BUILD_PROGRESS = importlib.util.module_from_spec(SPEC) @@ -13,11 +14,94 @@ SPEC.loader.exec_module(BUILD_PROGRESS) class BuildProgressTests(unittest.TestCase): - def test_unique_crates_deduplicates_repeated_tree_entries(self) -> None: - output = "editor v0.1.0\nserde v1.0.0\nserde v1.0.0\n" + def test_desktop_wrapper_releases_the_build_lock_before_editor_exec(self) -> None: + wrapper = LAUNCHER_PATH.read_text(encoding="utf-8") + + self.assertIn('exec 9>"$STATE_DIR/build.lock"', wrapper) + self.assertNotIn('exec 9>"$STATE_DIR/launch.lock"', wrapper) + unlock = wrapper.index("flock -u 9") + close = wrapper.index("exec 9>&-") + editor_exec = wrapper.index('exec "$binary"') + self.assertLess(unlock, close) + self.assertLess(close, editor_exec) + + def test_desktop_wrapper_builds_before_focusing_an_existing_editor(self) -> None: + wrapper = LAUNCHER_PATH.read_text(encoding="utf-8") + + capture_existing = wrapper.index('existing_pid="$(find_existing || true)"') + build = wrapper.index("if ! build_current_editor") + existing_handoff = wrapper.index( + 'if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid"' + ) + + self.assertLess(capture_existing, build) + self.assertLess(build, existing_handoff) + self.assertIn("running_binary_is_current", wrapper) + self.assertIn("Blacksite update ready", wrapper) + self.assertNotIn("if ! $build_only && focus_existing", wrapper) + + def test_build_commands_use_the_managed_default_feature_lane(self) -> None: + cargo_lane = Path("scripts/codex/cargo_lane.py") + metadata = BUILD_PROGRESS.cargo_metadata( + cargo_lane, "dev", "x86_64-unknown-linux-gnu" + ) + build = BUILD_PROGRESS.cargo_build(cargo_lane, "dev") + self.assertEqual( - BUILD_PROGRESS.unique_crates(output), - {"editor v0.1.0", "serde v1.0.0"}, + metadata[:7], + [ + BUILD_PROGRESS.sys.executable, + str(cargo_lane), + "exec", + "dev", + "--", + "cargo", + "metadata", + ], + ) + self.assertIn("--filter-platform", metadata) + self.assertIn("--bins", build) + self.assertNotIn("--features", metadata) + self.assertNotIn("--features", build) + + def test_editor_package_closure_includes_normal_and_build_edges(self) -> None: + metadata = { + "packages": [ + {"id": "editor-id", "name": "editor"}, + {"id": "normal-id", "name": "normal"}, + {"id": "build-id", "name": "build"}, + {"id": "dev-id", "name": "dev"}, + ], + "resolve": { + "nodes": [ + { + "id": "editor-id", + "deps": [ + { + "pkg": "normal-id", + "dep_kinds": [{"kind": None}], + }, + { + "pkg": "build-id", + "dep_kinds": [{"kind": "build"}], + }, + { + "pkg": "dev-id", + "dep_kinds": [{"kind": "dev"}], + }, + ], + }, + {"id": "normal-id", "deps": []}, + {"id": "build-id", "deps": []}, + {"id": "dev-id", "deps": []}, + ] + }, + } + self.assertEqual( + BUILD_PROGRESS.editor_package_closure( + BUILD_PROGRESS.json.dumps(metadata) + ), + {"editor-id", "normal-id", "build-id"}, ) def test_completed_crate_counts_each_package_once(self) -> None: