Compare commits
No commits in common. "634ec16c5e627d598aaccf130974224ba76b0e13" and "93e23ba194a581aebadce5f4b30c2bd48b19aa71" have entirely different histories.
634ec16c5e
...
93e23ba194
@ -1,30 +1,20 @@
|
||||
name: content-browser-card-layout
|
||||
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.
|
||||
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.
|
||||
build_lane: dev
|
||||
fixture:
|
||||
steps:
|
||||
- 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.
|
||||
- Open the Content Browser grid and 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:
|
||||
- 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.
|
||||
- Grid labels stay inside clipped two-line footers with ellipsis and no status overlap.
|
||||
- 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 root-grid wide, Office-grid wide, compact grid, list, and desk geometry states plus hashes.
|
||||
evidence: Target-window captures for grid-wide, grid-narrow, 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.
|
||||
|
||||
@ -7,14 +7,13 @@ binary: ui_gallery
|
||||
fixture:
|
||||
steps:
|
||||
- Launch the standalone Blacksite UI Gallery without a project, scene, renderer, or gameplay runtime.
|
||||
- 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.
|
||||
- Inspect the default Materials 620 side-by-side reference and production component canvases.
|
||||
- Switch through 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 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.
|
||||
- All four exact reference presets render the direct Penpot PNG and shared production component without clipping the gallery chrome.
|
||||
- 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.
|
||||
|
||||
@ -100,10 +100,9 @@ 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-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-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-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
|
||||
@ -173,23 +172,6 @@ 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
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -17,7 +17,6 @@
|
||||
# Editor/runtime generated session state
|
||||
/assets/levels/.pie_session.scn.ron
|
||||
/assets/.trash/
|
||||
/assets/.thumbnails/
|
||||
/.blacksite/backups/
|
||||
|
||||
# Backup, temporary, and log files
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -3242,7 +3242,6 @@ dependencies = [
|
||||
"game_hot",
|
||||
"gltf",
|
||||
"hot-lib-reloader",
|
||||
"image",
|
||||
"notify",
|
||||
"polyanya",
|
||||
"protocol",
|
||||
|
||||
@ -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", "fill"] }
|
||||
egui_phosphor_icons = { version = "0.3.1", default-features = false, features = ["bold"] }
|
||||
editor_ui = { path = "crates/editor_ui" }
|
||||
material_schema = { path = "crates/material_schema" }
|
||||
transform-gizmo-bevy = "0.9"
|
||||
|
||||
22
README.md
22
README.md
@ -89,15 +89,10 @@ 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 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.
|
||||
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.
|
||||
|
||||
### Hot reload (gameplay iteration)
|
||||
|
||||
@ -216,9 +211,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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Drag audio clip into viewport | Create an authored audio source; when an audio source is selected, assign the clip instead |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@ -539,7 +534,6 @@ 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))
|
||||
@ -563,9 +557,7 @@ 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, 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
|
||||
material sphere thumbnails, 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
|
||||
|
||||
@ -54,4 +54,3 @@ 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"] }
|
||||
|
||||
@ -8,7 +8,6 @@ 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;
|
||||
@ -29,66 +28,21 @@ struct ThumbnailFailure {
|
||||
retryable: bool,
|
||||
}
|
||||
|
||||
struct DecodedThumbnail {
|
||||
image: Image,
|
||||
artifact: ThumbnailArtifact,
|
||||
is_current: bool,
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
#[derive(Resource, Default)]
|
||||
pub struct AssetThumbnailCache {
|
||||
pub texture_ids: HashMap<String, egui::TextureId>,
|
||||
registered_images: HashMap<String, AssetId<Image>>,
|
||||
pending: HashMap<String, Handle<Image>>,
|
||||
decoded_pending: HashMap<String, DecodedThumbnail>,
|
||||
pub(crate) studio_pending: HashSet<String>,
|
||||
failed: HashMap<String, ThumbnailFailure>,
|
||||
requested_signatures: HashMap<String, String>,
|
||||
visual_signatures: HashMap<String, String>,
|
||||
validated_epochs: HashMap<String, u64>,
|
||||
catalog_epoch: u64,
|
||||
prefetched_folder: Option<String>,
|
||||
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<std::path::PathBuf>) -> 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) {
|
||||
let project_root = app
|
||||
.world()
|
||||
.get_resource::<crate::project_io::ProjectWorkspace>()
|
||||
.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)
|
||||
app.init_resource::<AssetThumbnailCache>()
|
||||
.add_systems(EguiPrimaryContextPass, register_loaded_thumbnails);
|
||||
}
|
||||
}
|
||||
@ -105,10 +59,7 @@ impl AssetThumbnailCache {
|
||||
retryable: failure.retryable,
|
||||
});
|
||||
}
|
||||
if self.pending.contains_key(key)
|
||||
|| self.decoded_pending.contains_key(key)
|
||||
|| self.studio_pending.contains(key)
|
||||
{
|
||||
if self.pending.contains_key(key) || self.studio_pending.contains(key) {
|
||||
return Some(ThumbnailState::Pending);
|
||||
}
|
||||
None
|
||||
@ -118,48 +69,46 @@ impl AssetThumbnailCache {
|
||||
/// The caller must remove that registration from `EguiUserTextures` after releasing the cache
|
||||
/// borrow.
|
||||
pub fn retry(&mut self, key: &str) -> Option<AssetId<Image>> {
|
||||
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 self.request_is_current(&key) {
|
||||
if matches!(
|
||||
self.state(&key),
|
||||
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if self.failed.contains_key(&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<Image> = 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 self.request_is_current(&key) {
|
||||
if matches!(
|
||||
self.state(&key),
|
||||
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if self.failed.contains_key(&key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.model_source_ready(&key, &model_path) {
|
||||
return;
|
||||
}
|
||||
let source = ThumbnailJobSource::Model { model_path };
|
||||
let Some(artifact) = self.prepare_request(&key, &source) else {
|
||||
return;
|
||||
};
|
||||
if studio.enqueue_source(key.clone(), source, artifact) {
|
||||
|
||||
if studio.enqueue(key.clone(), model_path) {
|
||||
self.studio_pending.insert(key);
|
||||
}
|
||||
}
|
||||
@ -173,7 +122,11 @@ impl AssetThumbnailCache {
|
||||
requires_scene_instance: bool,
|
||||
studio: &mut ThumbnailStudio,
|
||||
) {
|
||||
if self.request_is_current(&key) {
|
||||
if matches!(
|
||||
self.state(&key),
|
||||
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
||||
) || self.failed.contains_key(&key)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !self.model_source_ready(&key, &model_path) {
|
||||
@ -188,10 +141,7 @@ impl AssetThumbnailCache {
|
||||
material_label,
|
||||
}
|
||||
};
|
||||
let Some(artifact) = self.prepare_request(&key, &source) else {
|
||||
return;
|
||||
};
|
||||
if studio.enqueue_source(key.clone(), source, artifact) {
|
||||
if studio.enqueue_source(key.clone(), source) {
|
||||
self.studio_pending.insert(key);
|
||||
}
|
||||
}
|
||||
@ -203,20 +153,23 @@ impl AssetThumbnailCache {
|
||||
material_label: String,
|
||||
studio: &mut ThumbnailStudio,
|
||||
) {
|
||||
if self.request_is_current(&key) {
|
||||
if matches!(
|
||||
self.state(&key),
|
||||
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
||||
) || self.failed.contains_key(&key)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !self.source_material_ready(&key, &model_path) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
if studio.enqueue_source(
|
||||
key.clone(),
|
||||
ThumbnailJobSource::SourceMaterial {
|
||||
model_path,
|
||||
material_label,
|
||||
},
|
||||
) {
|
||||
self.studio_pending.insert(key);
|
||||
}
|
||||
}
|
||||
@ -227,7 +180,11 @@ impl AssetThumbnailCache {
|
||||
path: String,
|
||||
studio: &mut ThumbnailStudio,
|
||||
) {
|
||||
if self.request_is_current(&key) {
|
||||
if matches!(
|
||||
self.state(&key),
|
||||
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
||||
) || self.failed.contains_key(&key)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let material = match shared::load_resolved_material_from_path(&path) {
|
||||
@ -243,118 +200,24 @@ impl AssetThumbnailCache {
|
||||
shared::MaterialInstanceAsset::load_from_path(&path).map(|asset| asset.label)
|
||||
})
|
||||
.unwrap_or_else(|_| "Material".into());
|
||||
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) {
|
||||
if studio.enqueue_source(
|
||||
key.clone(),
|
||||
ThumbnailJobSource::MaterialAsset {
|
||||
label,
|
||||
material: Box::new(material),
|
||||
},
|
||||
) {
|
||||
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<ThumbnailArtifact> {
|
||||
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
|
||||
@ -365,7 +228,6 @@ impl AssetThumbnailCache {
|
||||
return true;
|
||||
}
|
||||
self.mark_studio_failed(key, &format!("missing texture: {texture_path}"), false);
|
||||
self.queue_last_good(key, None);
|
||||
false
|
||||
}
|
||||
|
||||
@ -381,7 +243,6 @@ 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;
|
||||
}
|
||||
}
|
||||
@ -392,23 +253,16 @@ impl AssetThumbnailCache {
|
||||
&mut self,
|
||||
key: &str,
|
||||
image: Handle<Image>,
|
||||
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 {
|
||||
@ -423,12 +277,8 @@ impl AssetThumbnailCache {
|
||||
pub fn invalidate_all(&mut self) -> Vec<AssetId<Image>> {
|
||||
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()
|
||||
@ -472,7 +322,6 @@ impl AssetThumbnailCache {
|
||||
pending_keys: self
|
||||
.pending
|
||||
.keys()
|
||||
.chain(self.decoded_pending.keys())
|
||||
.chain(self.studio_pending.iter())
|
||||
.cloned()
|
||||
.collect(),
|
||||
@ -484,40 +333,13 @@ 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<AssetServer>,
|
||||
mut cache: ResMut<AssetThumbnailCache>,
|
||||
mut textures: ResMut<EguiUserTextures>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
) {
|
||||
let decoded = cache.decoded_pending.drain().collect::<Vec<_>>();
|
||||
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()
|
||||
@ -540,9 +362,6 @@ 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);
|
||||
}
|
||||
}
|
||||
@ -699,9 +518,12 @@ pub fn retry_thumbnail(world: &mut World, key: &str) {
|
||||
}
|
||||
|
||||
pub fn invalidate_on_catalog_refresh(world: &mut World) {
|
||||
world
|
||||
.resource_mut::<AssetThumbnailCache>()
|
||||
.invalidate_catalog_epoch();
|
||||
let registrations = world.resource_mut::<AssetThumbnailCache>().invalidate_all();
|
||||
if let Some(mut textures) = world.get_resource_mut::<EguiUserTextures>() {
|
||||
for registration in registrations {
|
||||
textures.remove_image(registration);
|
||||
}
|
||||
}
|
||||
if !world.contains_resource::<ThumbnailStudio>() {
|
||||
return;
|
||||
}
|
||||
@ -720,14 +542,6 @@ pub fn invalidate_on_catalog_refresh(world: &mut World) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_artifact(key: &str, signature: impl Into<String>) -> 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"))
|
||||
@ -779,11 +593,7 @@ mod tests {
|
||||
.join("../../assets/Furniture/Office/metal_office_desk_2k.gltf")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
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 cache = AssetThumbnailCache::default();
|
||||
let mut studio = ThumbnailStudio::for_test();
|
||||
|
||||
cache.request_model("model:desk".into(), model_path.clone(), &mut studio);
|
||||
@ -807,12 +617,7 @@ 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,
|
||||
&test_artifact("material:shared", revision.to_string()),
|
||||
&mut textures,
|
||||
);
|
||||
cache.complete_studio_thumbnail("material:shared", image, &mut textures);
|
||||
assert!(textures.image_id(image_id).is_some(), "revision {revision}");
|
||||
assert_eq!(cache.registered_images.len(), 1);
|
||||
}
|
||||
@ -834,18 +639,8 @@ mod tests {
|
||||
let image = images.add(Image::default());
|
||||
let image_id = image.id();
|
||||
|
||||
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,
|
||||
);
|
||||
cache.complete_studio_thumbnail("first", image.clone(), &mut textures);
|
||||
cache.complete_studio_thumbnail("second", image, &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");
|
||||
@ -853,71 +648,4 @@ 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::<Image>::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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,652 +0,0 @@
|
||||
//! 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<Mutex<HashMap<PathBuf, MemoizedFingerprint>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct MemoizedFingerprint {
|
||||
byte_length: u64,
|
||||
modified: Option<SystemTime>,
|
||||
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<PathBuf>) -> 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<ThumbnailArtifact, String> {
|
||||
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<Option<Image>, 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<Option<(Image, ThumbnailArtifact)>, 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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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<String> {
|
||||
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::<BTreeSet<_>>();
|
||||
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 material = shared::MaterialDesc {
|
||||
base_color_texture: Some("assets/textures/albedo.png".into()),
|
||||
..Default::default()
|
||||
};
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -2,21 +2,14 @@
|
||||
|
||||
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,
|
||||
},
|
||||
@ -30,7 +23,6 @@ pub enum ThumbnailJobSource {
|
||||
material_label: String,
|
||||
},
|
||||
MaterialAsset {
|
||||
source_path: String,
|
||||
label: String,
|
||||
material: Box<MaterialDesc>,
|
||||
},
|
||||
@ -39,7 +31,6 @@ 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,
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
mod cache;
|
||||
mod disk_cache;
|
||||
mod job;
|
||||
mod sources;
|
||||
mod studio;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
//! glTF/GLB dependency validation and thumbnail source inspection.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
/// 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,23 +10,6 @@ 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<Vec<PathBuf>, 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() {
|
||||
@ -41,16 +24,18 @@ pub(crate) fn gltf_dependency_paths(model_path: &Path) -> Result<Vec<PathBuf>, S
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
dependencies.push(model_dir.join(uri));
|
||||
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.sort();
|
||||
dependencies.dedup();
|
||||
Ok(dependencies)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns primitive labels that cannot be rendered without their source skin hierarchy.
|
||||
|
||||
@ -9,19 +9,17 @@ use bevy::gltf::GltfAssetLabel;
|
||||
use bevy::math::bounding::Aabb3d;
|
||||
use bevy::mesh::VertexAttributeValues;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::gpu_readback::{Readback, ReadbackComplete};
|
||||
use bevy::render::render_resource::{TextureFormat, TextureUsages};
|
||||
use bevy::render::render_resource::TextureFormat;
|
||||
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 = THUMBNAIL_EDGE;
|
||||
pub(crate) const THUMB_SIZE: u32 = 128;
|
||||
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;
|
||||
@ -33,7 +31,6 @@ 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;
|
||||
@ -47,11 +44,6 @@ struct ThumbnailStudioLayer;
|
||||
#[derive(Component)]
|
||||
struct ThumbnailSceneRoot;
|
||||
|
||||
#[derive(Component)]
|
||||
struct ThumbnailReadback {
|
||||
artifact: ThumbnailArtifact,
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
pub struct ThumbnailStudio {
|
||||
camera: Entity,
|
||||
@ -66,9 +58,6 @@ pub struct ThumbnailStudio {
|
||||
struct ActiveModelThumbnail {
|
||||
cache_key: String,
|
||||
label: String,
|
||||
source: ThumbnailJobSource,
|
||||
artifact: ThumbnailArtifact,
|
||||
attempt: u8,
|
||||
render_image: Handle<Image>,
|
||||
root: Entity,
|
||||
meshes_ready: bool,
|
||||
@ -90,7 +79,6 @@ 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 { .. } => {
|
||||
@ -111,12 +99,11 @@ impl Plugin for ThumbnailStudioPlugin {
|
||||
}
|
||||
|
||||
impl ThumbnailStudio {
|
||||
pub(crate) fn enqueue_source(
|
||||
&mut self,
|
||||
cache_key: String,
|
||||
source: ThumbnailJobSource,
|
||||
artifact: ThumbnailArtifact,
|
||||
) -> bool {
|
||||
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 {
|
||||
if self
|
||||
.active
|
||||
.as_ref()
|
||||
@ -127,12 +114,7 @@ impl ThumbnailStudio {
|
||||
if self.queue.iter().any(|job| job.cache_key == cache_key) {
|
||||
return false;
|
||||
}
|
||||
self.queue.push_back(ThumbnailJob {
|
||||
cache_key,
|
||||
source,
|
||||
artifact,
|
||||
attempt: 0,
|
||||
});
|
||||
self.queue.push_back(ThumbnailJob { cache_key, source });
|
||||
true
|
||||
}
|
||||
|
||||
@ -342,14 +324,8 @@ 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,
|
||||
@ -412,9 +388,6 @@ 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,
|
||||
@ -435,9 +408,6 @@ 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,
|
||||
@ -460,7 +430,6 @@ 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(
|
||||
@ -582,45 +551,7 @@ fn labeled_asset_path(path: &str, label: &str) -> String {
|
||||
}
|
||||
|
||||
fn studio_render_image() -> Image {
|
||||
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<Image>,
|
||||
artifact: ThumbnailArtifact,
|
||||
) {
|
||||
commands
|
||||
.spawn((
|
||||
EditorOnly,
|
||||
ThumbnailReadback { artifact },
|
||||
Readback::texture(image),
|
||||
))
|
||||
.observe(persist_thumbnail_readback);
|
||||
}
|
||||
|
||||
fn persist_thumbnail_readback(
|
||||
event: On<ReadbackComplete>,
|
||||
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
|
||||
);
|
||||
}
|
||||
});
|
||||
Image::new_target_texture(THUMB_SIZE, THUMB_SIZE, TextureFormat::Rgba16Float, None)
|
||||
}
|
||||
|
||||
fn capture_frames(render_pipeline_warmed: bool) -> u8 {
|
||||
@ -660,21 +591,6 @@ 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);
|
||||
}
|
||||
|
||||
@ -6,13 +6,6 @@ pub struct ContentProcessingQueue {
|
||||
pub(super) active: Option<ActiveProcessingJob>,
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@ -20,7 +20,7 @@ use crate::assets::{
|
||||
AssetSelection, AssetSubAssetKind, EditorAsset, EditorAssetKind, EditorAssets,
|
||||
ExternalMoveRepairChoice, PendingExternalMoveRepair, ASSETS_ROOT, BUILTINS_FOLDER,
|
||||
};
|
||||
use crate::project::collaboration::{CollaborationState, FileStatusTone};
|
||||
use crate::project::collaboration::{file_status_indicator_ui, CollaborationState};
|
||||
use crate::scene_io::{SceneIo, SceneIoRequest};
|
||||
|
||||
use super::state::{
|
||||
@ -50,16 +50,18 @@ 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, TEXT, TEXT_DIM, WARNING, WIDGET_BG,
|
||||
panel_heading, ACCENT, BORDER, ELEVATED_BG, ERROR, SUCCESS, TEXT, TEXT_DIM, TEXT_MUTED,
|
||||
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 = editor_ui::content_browser::SOURCES_WIDTH;
|
||||
const TREE_MAX_WIDTH: f32 = editor_ui::content_browser::SOURCES_WIDTH;
|
||||
const TREE_MIN_WIDTH: f32 = 180.0;
|
||||
const TREE_MAX_WIDTH: f32 = 216.0;
|
||||
const MIN_CONTENT_WIDTH: f32 = 160.0;
|
||||
const COMPACT_LIST_WIDTH: f32 = 560.0;
|
||||
const PHOSPHOR: &str = "phosphor-regular";
|
||||
@ -98,6 +100,88 @@ 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<R>(
|
||||
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<R>(
|
||||
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,
|
||||
@ -105,9 +189,7 @@ pub fn asset_browser_ui(
|
||||
) {
|
||||
let available_width = ui.available_width().max(1.0);
|
||||
let available_height = ui.available_height().max(1.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 body_height = (available_height - TOOLBAR_HEIGHT - FOOTER_HEIGHT - 4.0).max(40.0);
|
||||
let browser_interactions_enabled = !content_browser_modal_active(world);
|
||||
|
||||
exact_region(
|
||||
@ -115,15 +197,12 @@ 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);
|
||||
@ -175,40 +254,23 @@ pub fn asset_browser_ui(
|
||||
|
||||
prefetch_folder_thumbnails(world, ¤t_folder);
|
||||
|
||||
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 show_tree = available_width >= TREE_MIN_PANEL_WIDTH;
|
||||
let show_details =
|
||||
state_snapshot.show_details && available_width >= DETAILS_MIN_PANEL_WIDTH;
|
||||
let tree_width = if show_tree {
|
||||
TREE_MAX_WIDTH.min((available_width - MIN_CONTENT_WIDTH).max(TREE_MIN_WIDTH))
|
||||
(available_width * 0.18).clamp(TREE_MIN_WIDTH, TREE_MAX_WIDTH)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let details_width = if show_details {
|
||||
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,
|
||||
)
|
||||
}
|
||||
details_width_for_layout(available_width, tree_width, state_snapshot.details_width)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// 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);
|
||||
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));
|
||||
|
||||
exact_region(
|
||||
ui,
|
||||
@ -221,9 +283,11 @@ pub fn asset_browser_ui(
|
||||
egui::vec2(tree_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_pane_heading(
|
||||
ui, "SOURCES", false,
|
||||
ui.label(
|
||||
egui::RichText::new("SOURCES")
|
||||
.small()
|
||||
.strong()
|
||||
.color(TEXT_MUTED),
|
||||
);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("asset_folder_tree")
|
||||
@ -243,6 +307,7 @@ pub fn asset_browser_ui(
|
||||
});
|
||||
},
|
||||
);
|
||||
ui.separator();
|
||||
}
|
||||
|
||||
let (visible_order, content_response) = exact_region_with_pointer(
|
||||
@ -250,16 +315,7 @@ pub fn asset_browser_ui(
|
||||
egui::vec2(content_width, body_height),
|
||||
egui::Layout::top_down(egui::Align::Min),
|
||||
|ui, content_contains_pointer| {
|
||||
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);
|
||||
content_header(world, ui, ¤t_folder, &folders, &assets);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("asset_content_area")
|
||||
.max_height(ui.available_height())
|
||||
@ -288,21 +344,16 @@ pub fn asset_browser_ui(
|
||||
let thumbnail_size = state_snapshot
|
||||
.thumbnail_size
|
||||
.min((content_width - 26.0).max(48.0));
|
||||
editor_ui::content_browser::content_browser_grid_inset(
|
||||
asset_grid(
|
||||
world,
|
||||
ui,
|
||||
|ui| {
|
||||
asset_grid(
|
||||
world,
|
||||
ui,
|
||||
selected_entities,
|
||||
&selected,
|
||||
&child_folders,
|
||||
&visible_assets,
|
||||
&cache_snapshot,
|
||||
thumbnail_size,
|
||||
&visible_order,
|
||||
);
|
||||
},
|
||||
selected_entities,
|
||||
&selected,
|
||||
&child_folders,
|
||||
&visible_assets,
|
||||
&cache_snapshot,
|
||||
thumbnail_size,
|
||||
&visible_order,
|
||||
);
|
||||
} else {
|
||||
asset_list(
|
||||
@ -324,16 +375,27 @@ pub fn asset_browser_ui(
|
||||
content_empty_space(world, content_response, ¤t_folder, &visible_order);
|
||||
|
||||
if show_details {
|
||||
let details_boundary = ui.next_widget_position();
|
||||
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::<AssetBrowserUiState>().details_width =
|
||||
details_width_for_layout(
|
||||
available_width,
|
||||
tree_width,
|
||||
requested_width,
|
||||
);
|
||||
}
|
||||
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())
|
||||
@ -343,27 +405,6 @@ 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::<AssetBrowserUiState>().details_width =
|
||||
details_width_for_layout(
|
||||
available_width,
|
||||
tree_width,
|
||||
requested_width,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -381,45 +422,17 @@ pub fn asset_browser_ui(
|
||||
.iter()
|
||||
.filter(|folder| folder.parent.as_deref() == Some(current_folder.as_str()))
|
||||
.count();
|
||||
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")
|
||||
let summary = if selection_count > 0 {
|
||||
format!("{selection_count} selected")
|
||||
} else {
|
||||
format!("{direct_folders} folders · {direct_assets} assets")
|
||||
};
|
||||
let thumbnail_jobs = world
|
||||
.resource::<AssetThumbnailCache>()
|
||||
.snapshot()
|
||||
.pending_keys
|
||||
.len();
|
||||
let processing_jobs = world
|
||||
.get_resource::<crate::asset_documents::ContentProcessingQueue>()
|
||||
.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 {
|
||||
catalog,
|
||||
processing: &processing,
|
||||
selection: &selection_summary,
|
||||
healthy,
|
||||
summary: &summary,
|
||||
location: ¤t_folder,
|
||||
status: &browser_status,
|
||||
},
|
||||
);
|
||||
},
|
||||
@ -447,8 +460,6 @@ 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"]
|
||||
@ -475,7 +486,6 @@ 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::*;
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn detail_row(ui: &mut egui::Ui, label: &str, value: &str) {
|
||||
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);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(egui::RichText::new(label).color(TEXT_DIM));
|
||||
ui.add(egui::Label::new(value).wrap().selectable(false));
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn details_panel(
|
||||
@ -13,6 +12,8 @@ pub(super) fn details_panel(
|
||||
ui: &mut egui::Ui,
|
||||
selected_entities: &SelectedEntities,
|
||||
) {
|
||||
ui.label(panel_heading("Details"));
|
||||
ui.separator();
|
||||
let selections = world.resource::<EditorAssets>().selections.clone();
|
||||
if selections.len() > 1 {
|
||||
let has_authored_content = !selected_content_paths(world).is_empty();
|
||||
@ -60,7 +61,7 @@ pub(super) fn details_panel(
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(&path),
|
||||
));
|
||||
detail_path_row(ui, "Path", &path);
|
||||
detail_row(ui, "Path", &path);
|
||||
ui.separator();
|
||||
if ui.button("Open Folder").clicked() {
|
||||
navigate_content_folder(world, path);
|
||||
@ -70,7 +71,7 @@ pub(super) fn details_panel(
|
||||
} else if let Some(asset) = world.resource::<EditorAssets>().selected_asset().cloned() {
|
||||
top_level_asset_details_panel(world, ui, selected_entities, &asset);
|
||||
} else {
|
||||
editor_ui::content_browser::content_browser_details_empty_state(ui, "No asset selected");
|
||||
ui.label(egui::RichText::new("No asset selected").color(TEXT_DIM));
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,9 +82,9 @@ pub(crate) fn top_level_asset_details_panel(
|
||||
asset: &EditorAsset,
|
||||
) {
|
||||
asset_details_header(world, ui, asset);
|
||||
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());
|
||||
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());
|
||||
|
||||
if asset.path.is_none() && asset.label == blacksite_surface::DEFAULT_GRID_LABEL {
|
||||
ui.separator();
|
||||
@ -100,43 +101,66 @@ 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();
|
||||
editor_ui::content_browser::content_browser_details_section(ui, "IMPORT SETTINGS");
|
||||
ui.separator();
|
||||
ui.label(panel_heading("Import Settings"));
|
||||
import_settings_editor(world, ui, asset, path, settings);
|
||||
if let Some(manifest) = settings.static_mesh_manifest_path.as_deref() {
|
||||
detail_path_row(ui, "Artifact", manifest);
|
||||
detail_row(ui, "Static mesh artifact", manifest);
|
||||
}
|
||||
}
|
||||
if matches!(asset.kind, EditorAssetKind::Texture) {
|
||||
if let Some(settings) = record.texture_import() {
|
||||
editor_ui::content_browser::content_browser_details_section(
|
||||
ui,
|
||||
"TEXTURE PROPERTIES",
|
||||
);
|
||||
ui.separator();
|
||||
ui.label(panel_heading("Texture Properties"));
|
||||
texture_import_settings_editor(world, ui, path, &record, settings);
|
||||
}
|
||||
}
|
||||
if !record.dependencies.is_empty() {
|
||||
editor_ui::content_browser::content_browser_details_section(ui, "DEPENDENCIES");
|
||||
ui.separator();
|
||||
let missing_count = record
|
||||
.dependencies
|
||||
.iter()
|
||||
.filter(|dependency| !dependency_path(dependency).is_file())
|
||||
.count();
|
||||
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")
|
||||
},
|
||||
);
|
||||
}
|
||||
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
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
for dep in &record.dependencies {
|
||||
let exists = dependency_path(dep).is_file();
|
||||
detail_path_row(ui, if exists { "Ready" } else { "Missing" }, dep);
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -154,7 +178,8 @@ pub(crate) fn top_level_asset_details_panel(
|
||||
.unwrap_or("-"),
|
||||
);
|
||||
if matches!(asset.kind, EditorAssetKind::Material) {
|
||||
editor_ui::content_browser::content_browser_details_section(ui, "MATERIAL");
|
||||
ui.separator();
|
||||
ui.label(panel_heading("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);
|
||||
@ -202,81 +227,59 @@ pub(crate) fn top_level_asset_details_panel(
|
||||
asset_action_buttons(world, ui, selected_entities, asset);
|
||||
}
|
||||
|
||||
pub(super) fn asset_details_header(world: &mut World, ui: &mut egui::Ui, asset: &EditorAsset) {
|
||||
let cache = world
|
||||
pub(super) fn asset_details_header(world: &World, ui: &mut egui::Ui, asset: &EditorAsset) {
|
||||
let thumbnail = world
|
||||
.get_resource::<AssetThumbnailCache>()
|
||||
.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::<crate::asset_documents::AuthoredAssetDocumentStore>()
|
||||
.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::<CollaborationState>()
|
||||
.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);
|
||||
.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::<CollaborationState>(),
|
||||
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::<crate::asset_documents::AuthoredAssetDocumentStore>()
|
||||
.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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn subasset_details_panel(
|
||||
|
||||
@ -8,9 +8,7 @@ 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,
|
||||
}
|
||||
|
||||
@ -22,9 +20,7 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
use super::*;
|
||||
use editor_ui::content_browser::{continuous_grid_entries, ContentBrowserGridEntry};
|
||||
|
||||
pub(super) fn content_empty_space(
|
||||
world: &mut World,
|
||||
@ -30,60 +29,57 @@ pub(super) fn asset_grid(
|
||||
thumbnail_size: f32,
|
||||
visible_order: &[AssetSelection],
|
||||
) {
|
||||
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 thumb_size = thumbnail_size.clamp(48.0, 112.0);
|
||||
let cell_width = thumb_size + 20.0;
|
||||
let gap = 6.0;
|
||||
let columns = ((ui.available_width() + gap) / (cell_width + gap))
|
||||
.floor()
|
||||
.max(1.0) as usize;
|
||||
|
||||
let entries = continuous_grid_entries(folders.len(), assets.len());
|
||||
for entry_row in entries.chunks(columns) {
|
||||
for folder_row in folders.chunks(columns) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(gap, gap);
|
||||
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::<EditorAssets>().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::<EditorAssets>()
|
||||
.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,
|
||||
);
|
||||
for folder in folder_row {
|
||||
let selection = AssetSelection::Folder(folder.path.clone());
|
||||
let is_selected = world.resource::<EditorAssets>().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::<EditorAssets>()
|
||||
.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 entry in entry_row {
|
||||
let ContentBrowserGridEntry::Asset(asset_index) = *entry else {
|
||||
continue;
|
||||
};
|
||||
let row = &assets[asset_index];
|
||||
for row in asset_row {
|
||||
if row.asset.path.as_deref().is_some_and(|path| {
|
||||
world
|
||||
.resource::<AssetBrowserUiState>()
|
||||
@ -170,22 +166,19 @@ pub(super) fn draw_asset_grid_item(
|
||||
.get_resource::<crate::asset_documents::AuthoredAssetDocumentStore>()
|
||||
.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),
|
||||
thumbnail_pending,
|
||||
cache_snapshot.is_pending(asset),
|
||||
failure,
|
||||
is_selected,
|
||||
thumbnail_size,
|
||||
card_status,
|
||||
AssetCardStatus::for_document(
|
||||
document_status.as_ref(),
|
||||
document_status.as_ref().is_some_and(|status| status.dirty),
|
||||
failure.is_some(),
|
||||
),
|
||||
);
|
||||
|
||||
if has_children {
|
||||
|
||||
@ -1,94 +0,0 @@
|
||||
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<R>(
|
||||
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<R>(
|
||||
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")
|
||||
}
|
||||
@ -368,19 +368,39 @@ pub(super) fn draw_folder_cell(
|
||||
thumbnail_size: f32,
|
||||
is_selected: bool,
|
||||
) -> egui::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,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@ 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"
|
||||
@ -16,6 +15,7 @@ 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,14 +25,13 @@ pub(super) fn content_header(
|
||||
.filter(|folder| folder.parent.as_deref() == Some(current_folder))
|
||||
.count();
|
||||
let sort = world.resource::<AssetBrowserUiState>().sort;
|
||||
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,
|
||||
},
|
||||
ui.small(
|
||||
egui::RichText::new(format!(
|
||||
"{} items · sorted by {}",
|
||||
direct_folders + direct_assets,
|
||||
sort_label(sort)
|
||||
))
|
||||
.color(TEXT_DIM),
|
||||
);
|
||||
}
|
||||
|
||||
@ -101,41 +100,48 @@ pub(super) fn folder_tree_branch(
|
||||
.contains(path);
|
||||
|
||||
let selected = current_folder == *path;
|
||||
let folder_count = world
|
||||
.resource::<EditorAssets>()
|
||||
.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::<AssetBrowserUiState>();
|
||||
if expanded {
|
||||
state.expanded_folders.remove(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)),
|
||||
);
|
||||
} else {
|
||||
state.expanded_folders.insert(path.clone());
|
||||
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::<AssetBrowserUiState>();
|
||||
if expanded {
|
||||
state.expanded_folders.remove(path);
|
||||
} else {
|
||||
state.expanded_folders.insert(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
if expanded {
|
||||
for child in children {
|
||||
|
||||
@ -28,7 +28,6 @@
|
||||
fn folder_navigation_clears_hidden_selection_and_drag_state() {
|
||||
let mut world = World::new();
|
||||
world.insert_resource(content_navigation_fixture());
|
||||
world.insert_resource(AssetBrowserUiState::default());
|
||||
|
||||
navigate_content_folder(&mut world, "assets/Props".into());
|
||||
|
||||
@ -275,7 +274,6 @@
|
||||
kind: EditorAssetKind::Material,
|
||||
});
|
||||
world.insert_resource(assets);
|
||||
world.insert_resource(AssetBrowserUiState::default());
|
||||
|
||||
locate_content_asset(&mut world, "assets/Props/desk.material.ron");
|
||||
|
||||
@ -396,27 +394,15 @@
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_browser_details_width_respects_content_and_pane_minimums() {
|
||||
fn 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), 620.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, 420.0), 420.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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
|
||||
);
|
||||
fn details_width_uses_reclaimed_tree_space() {
|
||||
assert_eq!(details_width_for_layout(1_000.0, 0.0, 900.0), 830.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -129,10 +129,8 @@
|
||||
#[test]
|
||||
fn content_navigation_history_round_trips_like_a_file_manager() {
|
||||
let mut world = World::new();
|
||||
let assets = EditorAssets {
|
||||
current_folder: "assets".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut assets = EditorAssets::default();
|
||||
assets.current_folder = "assets".into();
|
||||
world.insert_resource(assets);
|
||||
world.insert_resource(AssetBrowserUiState::default());
|
||||
|
||||
@ -180,6 +178,7 @@
|
||||
|
||||
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]
|
||||
|
||||
@ -16,9 +16,7 @@ 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,
|
||||
@ -32,45 +30,21 @@ 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::<EditorAssets>();
|
||||
(
|
||||
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_sources,
|
||||
show_details: if compact {
|
||||
show_compact_details
|
||||
} else {
|
||||
show_details
|
||||
},
|
||||
show_details,
|
||||
thumbnail_size,
|
||||
can_go_parent: current_folder.contains('/'),
|
||||
can_go_back,
|
||||
@ -81,8 +55,6 @@ 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;
|
||||
@ -127,23 +99,8 @@ pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) {
|
||||
world.resource_mut::<AssetBrowserUiState>().recursive = recursive;
|
||||
visible_results_changed = true;
|
||||
}
|
||||
Action::SetShowSources(show) => {
|
||||
let mut state = world.resource_mut::<AssetBrowserUiState>();
|
||||
state.show_sources = show;
|
||||
if show {
|
||||
state.show_compact_details = false;
|
||||
}
|
||||
}
|
||||
Action::SetShowDetails(show) => {
|
||||
let mut state = world.resource_mut::<AssetBrowserUiState>();
|
||||
if compact {
|
||||
state.show_compact_details = show;
|
||||
if show {
|
||||
state.show_sources = false;
|
||||
}
|
||||
} else {
|
||||
state.show_details = show;
|
||||
}
|
||||
world.resource_mut::<AssetBrowserUiState>().show_details = show;
|
||||
}
|
||||
Action::SetThumbnailSize(size) => {
|
||||
world.resource_mut::<AssetBrowserUiState>().thumbnail_size = size;
|
||||
|
||||
@ -114,6 +114,19 @@ 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",
|
||||
|
||||
@ -43,9 +43,7 @@ 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<String>,
|
||||
pub(crate) navigation_forward: Vec<String>,
|
||||
@ -190,9 +188,7 @@ 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,
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
use bevy_egui::egui;
|
||||
use egui_phosphor_icons::icons;
|
||||
|
||||
use crate::assets::{kind_icon, EditorAsset, EditorAssetKind};
|
||||
use crate::assets::{kind_icon, EditorAsset};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct AssetCardStatus {
|
||||
@ -50,54 +50,17 @@ pub fn draw_asset_card(
|
||||
thumbnail_size: f32,
|
||||
status: AssetCardStatus,
|
||||
) -> egui::Response {
|
||||
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(
|
||||
let response = draw_asset_cell(
|
||||
ui,
|
||||
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,
|
||||
},
|
||||
asset,
|
||||
texture_id,
|
||||
pending,
|
||||
failure,
|
||||
selected,
|
||||
thumbnail_size,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
);
|
||||
draw_asset_status_rail(ui, response.rect, status);
|
||||
response
|
||||
}
|
||||
|
||||
pub fn draw_asset_status_rail(ui: &egui::Ui, rect: egui::Rect, status: AssetCardStatus) {
|
||||
@ -161,19 +124,102 @@ pub fn draw_asset_status_marker(ui: &mut egui::Ui, status: AssetCardStatus) {
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_asset_cell(
|
||||
ui: &mut egui::Ui,
|
||||
asset: &EditorAsset,
|
||||
texture_id: Option<egui::TextureId>,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 => "Content Browser",
|
||||
EditorTab::AssetBrowser => "Asset Browser",
|
||||
EditorTab::MaterialLibrary => "Material Library",
|
||||
EditorTab::Build => "Build",
|
||||
EditorTab::Toolbar => "Toolbar (legacy)",
|
||||
|
||||
@ -280,6 +280,24 @@ 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,
|
||||
|
||||
281
crates/editor/src/ui/materials/advanced_preview.rs
Normal file
281
crates/editor/src/ui/materials/advanced_preview.rs
Normal file
@ -0,0 +1,281 @@
|
||||
//! 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::<bool>(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::<bool>(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::<bool>(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);
|
||||
}
|
||||
}
|
||||
657
crates/editor/src/ui/materials/inputs.rs
Normal file
657
crates/editor/src/ui/materials/inputs.rs
Normal file
@ -0,0 +1,657 @@
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<bool>(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<R>(
|
||||
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<MaterialParameter>,
|
||||
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<String>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
343
crates/editor/src/ui/materials/layout.rs
Normal file
343
crates/editor/src/ui/materials/layout.rs
Normal file
@ -0,0 +1,343 @@
|
||||
//! 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<egui::Rect>,
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
686
crates/editor/src/ui/materials/panel.rs
Normal file
686
crates/editor/src/ui/materials/panel.rs
Normal file
@ -0,0 +1,686 @@
|
||||
//! 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<String>,
|
||||
pub shader: String,
|
||||
pub shader_kind: Option<MaterialShaderKind>,
|
||||
pub can_edit_shader: bool,
|
||||
pub thumbnail: MaterialThumbnailPresentation,
|
||||
pub inherited: Option<MaterialLayerBadge>,
|
||||
pub health: MaterialHealth,
|
||||
pub assigned: Option<EditorAssetRef>,
|
||||
pub effective: Option<EditorAssetRef>,
|
||||
pub candidates: Vec<MaterialPickerCandidate>,
|
||||
pub drop_candidate: Option<MaterialPickerCandidate>,
|
||||
pub invalid_drop_reason: Option<String>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct MaterialSlotPanelResponse {
|
||||
pub actions: Vec<MaterialSlotPanelAction>,
|
||||
}
|
||||
|
||||
pub(crate) fn materials_section<R>(
|
||||
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::<bool>(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<MaterialSlotAction>,
|
||||
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<MaterialSlotAction>,
|
||||
) -> 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);
|
||||
}
|
||||
}
|
||||
216
crates/editor/src/ui/materials/panel/header_actions.rs
Normal file
216
crates/editor/src/ui/materials/panel/header_actions.rs
Normal file
@ -0,0 +1,216 @@
|
||||
//! 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<MaterialSlotAction>,
|
||||
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<MaterialSlotAction>,
|
||||
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<R>(
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
411
crates/editor/src/ui/materials/pickers.rs
Normal file
411
crates/editor/src/ui/materials/pickers.rs
Normal file
@ -0,0 +1,411 @@
|
||||
//! 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<MaterialSlotAction>,
|
||||
) {
|
||||
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<MaterialSlotAction>,
|
||||
) {
|
||||
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<MaterialSlotAction>) {
|
||||
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::<Vec<(String, String)>>(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);
|
||||
}
|
||||
}
|
||||
408
crates/editor/src/ui/materials/texture_inputs.rs
Normal file
408
crates/editor/src/ui/materials/texture_inputs.rs
Normal file
@ -0,0 +1,408 @@
|
||||
//! 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<MaterialThumbnailPresentation>,
|
||||
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<MaterialTextureBinding>,
|
||||
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<MaterialTextureBinding>,
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
131
crates/editor/src/ui/materials/uv_transform.rs
Normal file
131
crates/editor/src/ui/materials/uv_transform.rs
Normal file
@ -0,0 +1,131 @@
|
||||
//! 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -239,7 +239,6 @@ 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(
|
||||
@ -336,33 +335,13 @@ 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: editor_ui::design_system::controls::phosphor_icon_font(icon_style, 15.0),
|
||||
font_id: egui::FontId::new(13.0, egui::FontFamily::Name("phosphor-regular".into())),
|
||||
color: theme::TEXT,
|
||||
..Default::default()
|
||||
},
|
||||
@ -371,48 +350,14 @@ fn tab_title_job(
|
||||
&format!(" {label}"),
|
||||
0.0,
|
||||
egui::TextFormat {
|
||||
font_id: label_font,
|
||||
font_id: egui::FontId::new(13.0, egui::FontFamily::Proportional),
|
||||
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,
|
||||
@ -534,7 +479,6 @@ struct TabViewer<'a> {
|
||||
selected_entities: &'a mut SelectedEntities,
|
||||
renaming_entity: &'a mut Option<Entity>,
|
||||
rename_buffer: &'a mut String,
|
||||
palette: editor_ui::theme::EditorVisualPalette,
|
||||
}
|
||||
|
||||
impl egui_dock::TabViewer for TabViewer<'_> {
|
||||
@ -591,77 +535,30 @@ 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 => unreachable!("handled above"),
|
||||
EditorTab::AssetBrowser => (icons::FOLDER, "Asset Browser"),
|
||||
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<egui_dock::TabStyle> {
|
||||
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);
|
||||
|
||||
@ -196,10 +196,7 @@ pub fn editor_dock_style(ctx: &egui::Context) -> DockStyle {
|
||||
style.separator.color_dragged = palette.accent_hover;
|
||||
|
||||
style.tab_bar.bg_fill = palette.panel_dark;
|
||||
// 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.height = 30.0;
|
||||
style.tab_bar.inner_margin = egui::Margin::symmetric(2, 1);
|
||||
style.tab_bar.hline_color = palette.accent;
|
||||
style.tab_bar.corner_radius = CornerRadius::ZERO;
|
||||
@ -293,17 +290,3 @@ 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,234 +0,0 @@
|
||||
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<ContentBrowserGridEntry> {
|
||||
(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<ContentPathSegment>,
|
||||
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::<Vec<_>>();
|
||||
assert_eq!(
|
||||
rows[0],
|
||||
[
|
||||
ContentBrowserGridEntry::Folder(0),
|
||||
ContentBrowserGridEntry::Folder(1),
|
||||
ContentBrowserGridEntry::Asset(0),
|
||||
ContentBrowserGridEntry::Asset(1),
|
||||
]
|
||||
);
|
||||
assert_eq!(rows[1], [ContentBrowserGridEntry::Asset(2)]);
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,6 @@ 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 {
|
||||
@ -73,27 +72,21 @@ 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 (rgb_rect, alpha_rect) = color_swatch_halves(swatch_rect.shrink(1.0));
|
||||
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,
|
||||
);
|
||||
ui.painter().rect_filled(
|
||||
rgb_rect,
|
||||
egui::CornerRadius {
|
||||
nw: COLOR_SWATCH_OUTER_RADIUS,
|
||||
ne: 0,
|
||||
sw: COLOR_SWATCH_OUTER_RADIUS,
|
||||
se: 0,
|
||||
},
|
||||
3.0,
|
||||
egui::Color32::from_rgb(byte(rgba[0]), byte(rgba[1]), byte(rgba[2])),
|
||||
);
|
||||
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_filled(alpha_rect, 3.0, color32(*rgba));
|
||||
ui.painter().rect_stroke(
|
||||
swatch_rect,
|
||||
4.0,
|
||||
@ -201,14 +194,6 @@ 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;
|
||||
@ -322,30 +307,6 @@ 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];
|
||||
|
||||
@ -7,32 +7,6 @@ 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.
|
||||
@ -111,25 +85,13 @@ 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: phosphor_icon_font(style, icon_size),
|
||||
font_id: egui::FontId::new(icon_size, egui::FontFamily::Name("phosphor-bold".into())),
|
||||
color,
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@ -10,7 +10,7 @@ pub mod typography;
|
||||
|
||||
use egui::{self, Color32, CornerRadius, Stroke};
|
||||
|
||||
use crate::theme::{AssetTypePalette, EditorVisualPalette};
|
||||
use crate::theme::EditorVisualPalette;
|
||||
|
||||
pub const REFERENCE_WIDTH: f32 = 620.0;
|
||||
pub const INSPECTOR_MIN_WIDTH: f32 = 420.0;
|
||||
@ -52,7 +52,6 @@ pub struct DesignPalette {
|
||||
pub healthy: Color32,
|
||||
pub warning: Color32,
|
||||
pub error: Color32,
|
||||
pub asset_types: AssetTypePalette,
|
||||
}
|
||||
|
||||
impl From<EditorVisualPalette> for DesignPalette {
|
||||
@ -76,7 +75,6 @@ impl From<EditorVisualPalette> for DesignPalette {
|
||||
healthy: value.success,
|
||||
warning: value.warning,
|
||||
error: value.error,
|
||||
asset_types: value.asset_types,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -151,6 +149,5 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,10 +10,8 @@ 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
|
||||
@ -254,7 +252,7 @@ pub fn material_slot_panel(
|
||||
&mut slot_actions,
|
||||
diagnostics_ui,
|
||||
);
|
||||
if parameters_open && (view.assigned.is_some() || view.effective.is_some()) {
|
||||
if parameters_open {
|
||||
parameter_body(ui, content_ui);
|
||||
}
|
||||
ui.ctx()
|
||||
@ -289,34 +287,6 @@ 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(
|
||||
@ -422,6 +392,22 @@ 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(
|
||||
|
||||
@ -1,412 +0,0 @@
|
||||
//! 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<Self> {
|
||||
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<MaterialSlotAction> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,26 +2,6 @@
|
||||
|
||||
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,
|
||||
@ -44,7 +24,6 @@ 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);
|
||||
@ -67,19 +46,6 @@ 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,
|
||||
@ -102,7 +68,6 @@ 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";
|
||||
|
||||
@ -12,18 +12,5 @@
|
||||
"border_strong": "#40474F",
|
||||
"success": "#54BE7F",
|
||||
"warning": "#EBB448",
|
||||
"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"
|
||||
}
|
||||
"error": "#E25E5E"
|
||||
}
|
||||
|
||||
@ -80,7 +80,7 @@ impl UiGalleryApp {
|
||||
}
|
||||
}
|
||||
Self {
|
||||
preset: GalleryPreset::ContentBrowser1240,
|
||||
preset: GalleryPreset::Materials620,
|
||||
comparison: ComparisonMode::SideBySide,
|
||||
overlay_opacity: 0.5,
|
||||
fixture_id,
|
||||
@ -142,17 +142,9 @@ 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,
|
||||
egui::Key::F5,
|
||||
egui::Key::F6,
|
||||
egui::Key::F7,
|
||||
]
|
||||
.into_iter()
|
||||
.position(|key| input.key_pressed(key));
|
||||
let function_key = [egui::Key::F1, egui::Key::F2, egui::Key::F3, egui::Key::F4]
|
||||
.into_iter()
|
||||
.position(|key| input.key_pressed(key));
|
||||
(
|
||||
function_key,
|
||||
input.modifiers.shift,
|
||||
@ -162,9 +154,7 @@ impl UiGalleryApp {
|
||||
});
|
||||
if let Some(index) = function_key {
|
||||
if shift {
|
||||
if let Some(comparison) = ComparisonMode::ALL.get(index).copied() {
|
||||
self.select_comparison(comparison);
|
||||
}
|
||||
self.select_comparison(ComparisonMode::ALL[index]);
|
||||
} else {
|
||||
self.select_preset(ctx, GalleryPreset::ALL[index]);
|
||||
}
|
||||
@ -280,7 +270,7 @@ impl UiGalleryApp {
|
||||
ui.separator();
|
||||
ui.small("Theme and fixture JSON reload without rebuilding");
|
||||
ui.separator();
|
||||
ui.small("F1–F7 presets · Shift+F1–F4 modes · PgUp/PgDn fixtures");
|
||||
ui.small("F1–F4 presets · Shift+F1–F4 modes · PgUp/PgDn fixtures");
|
||||
if let Some(stats) = self.difference_stats {
|
||||
ui.separator();
|
||||
ui.small(format!(
|
||||
@ -361,12 +351,11 @@ 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_content_browser() || self.preset.is_renderer() {
|
||||
(palette.recessed, 4.0)
|
||||
} else {
|
||||
(palette.panel, 7.0)
|
||||
};
|
||||
let (canvas_fill, canvas_radius) = if self.preset.is_renderer() {
|
||||
(palette.recessed, 4.0)
|
||||
} else {
|
||||
(palette.panel, 7.0)
|
||||
};
|
||||
ui.painter().rect(
|
||||
rect,
|
||||
canvas_radius,
|
||||
@ -390,13 +379,7 @@ impl UiGalleryApp {
|
||||
.iter()
|
||||
.map(|fixture| fixture.inputs.clone())
|
||||
.collect::<Vec<_>>();
|
||||
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() {
|
||||
if self.preset.is_renderer() {
|
||||
self.render_renderer_fixture(&mut child);
|
||||
} else {
|
||||
self.render_material_fixture(&mut child);
|
||||
|
||||
@ -1,326 +0,0 @@
|
||||
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",
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
mod app;
|
||||
mod content_browser;
|
||||
mod fixtures;
|
||||
mod reference;
|
||||
mod reload;
|
||||
|
||||
@ -2,9 +2,6 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GalleryPreset {
|
||||
ContentBrowser1240,
|
||||
ContentBrowser1600,
|
||||
ContentBrowser620,
|
||||
Materials620,
|
||||
Materials420,
|
||||
Renderer760,
|
||||
@ -12,21 +9,15 @@ pub enum GalleryPreset {
|
||||
}
|
||||
|
||||
impl GalleryPreset {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::ContentBrowser1240,
|
||||
Self::ContentBrowser620,
|
||||
pub const ALL: [Self; 4] = [
|
||||
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",
|
||||
@ -36,9 +27,6 @@ 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,
|
||||
@ -48,7 +36,6 @@ 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,
|
||||
@ -58,9 +45,6 @@ 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",
|
||||
@ -72,21 +56,12 @@ 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_content_browser() {
|
||||
canvas
|
||||
} else if self.is_renderer() {
|
||||
if self.is_renderer() {
|
||||
egui::Rect::from_min_max(
|
||||
canvas.min + egui::vec2(0.0, 12.0),
|
||||
egui::pos2(canvas.right(), canvas.bottom()),
|
||||
|
||||
@ -27,23 +27,6 @@ pub struct ThemeOverrides {
|
||||
pub success: Option<String>,
|
||||
pub warning: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub asset_types: AssetTypeThemeOverrides,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AssetTypeThemeOverrides {
|
||||
pub primitive: Option<String>,
|
||||
pub light: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub texture: Option<String>,
|
||||
pub material: Option<String>,
|
||||
pub audio: Option<String>,
|
||||
pub level: Option<String>,
|
||||
pub prefab: Option<String>,
|
||||
pub post_process: Option<String>,
|
||||
pub rendering_profile: Option<String>,
|
||||
pub shader: Option<String>,
|
||||
}
|
||||
|
||||
impl ThemeOverrides {
|
||||
@ -71,24 +54,6 @@ 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)
|
||||
}
|
||||
}
|
||||
@ -275,21 +240,4 @@ 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,12 +65,6 @@ 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/<version>/` 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
|
||||
|
||||
@ -85,10 +79,9 @@ 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 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@ -114,34 +114,9 @@ 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
|
||||
|
||||
@ -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 |
|
||||
| `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 |
|
||||
| `ui/asset_browser/` | Asset Browser tree, grid/list, details | roadmap Phase 4b |
|
||||
| `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. 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).
|
||||
- **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).
|
||||
- **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).
|
||||
|
||||
@ -254,14 +254,9 @@ 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. 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.
|
||||
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.
|
||||
- **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
|
||||
|
||||
@ -14,32 +14,8 @@ is mutable metadata that moves, validation, and packaging repair through the sha
|
||||
|
||||
## Content Browser workflow
|
||||
|
||||
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
|
||||
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
|
||||
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,
|
||||
@ -104,9 +80,7 @@ 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 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
|
||||
their editable Details. 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
|
||||
@ -118,23 +92,9 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**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.
|
||||
@ -213,14 +173,8 @@ 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 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.
|
||||
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.
|
||||
|
||||
## Headless processing
|
||||
|
||||
|
||||
@ -47,48 +47,6 @@ 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
|
||||
@ -196,9 +154,8 @@ 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–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
|
||||
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
|
||||
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
|
||||
|
||||
@ -17,14 +17,6 @@ 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 |
|
||||
@ -47,7 +39,6 @@ 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.
|
||||
|
||||
BIN
docs/editor/evaluations/content-workspace-m2/penpot/content-browser-compact-620.png
(Stored with Git LFS)
BIN
docs/editor/evaluations/content-workspace-m2/penpot/content-browser-compact-620.png
(Stored with Git LFS)
Binary file not shown.
BIN
docs/editor/evaluations/content-workspace-m2/penpot/content-browser-expanded-1600.png
(Stored with Git LFS)
BIN
docs/editor/evaluations/content-workspace-m2/penpot/content-browser-expanded-1600.png
(Stored with Git LFS)
Binary file not shown.
BIN
docs/editor/evaluations/content-workspace-m2/penpot/content-browser-wide-1240.png
(Stored with Git LFS)
BIN
docs/editor/evaluations/content-workspace-m2/penpot/content-browser-wide-1240.png
(Stored with Git LFS)
Binary file not shown.
@ -53,15 +53,9 @@ 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. 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.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@ -10,8 +10,7 @@ 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 BUILD_PROGRESS="$REPO/scripts/editor/build_progress.py"
|
||||
readonly BUILD_STORAGE="$REPO/scripts/codex/build_storage.py"
|
||||
readonly CARGO_LANE="$REPO/scripts/codex/cargo_lane.py"
|
||||
|
||||
build_only=false
|
||||
project_browser=false
|
||||
@ -79,55 +78,27 @@ close_splash() {
|
||||
}
|
||||
trap close_splash EXIT
|
||||
|
||||
find_existing() {
|
||||
focus_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
|
||||
printf '%s\n' "$pid"
|
||||
if command -v hyprctl >/dev/null 2>&1; then
|
||||
hyprctl dispatch focuswindow "pid:$pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
done < <(pgrep -x "$process_name" 2>/dev/null || true)
|
||||
return 1
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
if ! $build_only && focus_existing; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
exec 9>"$STATE_DIR/build.lock"
|
||||
exec 9>"$STATE_DIR/launch.lock"
|
||||
if ! flock -n 9; then
|
||||
exit 0
|
||||
fi
|
||||
@ -146,50 +117,13 @@ 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
|
||||
printf 'Blacksite editor and project browser are current.\n'
|
||||
exit 0
|
||||
exec python3 "$CARGO_LANE" exec dev -- cargo build -p editor --bin editor
|
||||
fi
|
||||
|
||||
if [[ ! -x "$binary" ]]; then
|
||||
write_status "Editor build unavailable" \
|
||||
"The managed build completed without the requested binary" 100 error
|
||||
"Run blacksite-editor --build-only from a terminal" 100 error
|
||||
keep_splash=true
|
||||
exit 1
|
||||
fi
|
||||
@ -241,11 +175,6 @@ 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
|
||||
|
||||
|
||||
@ -8,14 +8,9 @@ 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,
|
||||
@ -32,6 +27,10 @@ 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]:
|
||||
@ -47,111 +46,39 @@ def completed_crate(
|
||||
return True, package_id.rsplit("#", 1)[-1]
|
||||
|
||||
|
||||
def managed_cargo(cargo_lane: Path, lane: str, *arguments: str) -> list[str]:
|
||||
def cargo_tree(cargo: str) -> list[str]:
|
||||
return [
|
||||
sys.executable,
|
||||
str(cargo_lane),
|
||||
"exec",
|
||||
lane,
|
||||
"--",
|
||||
"cargo",
|
||||
*arguments,
|
||||
cargo,
|
||||
"tree",
|
||||
"-p",
|
||||
"editor",
|
||||
"--features",
|
||||
"dev",
|
||||
"--edges",
|
||||
"normal,build",
|
||||
"--prefix",
|
||||
"none",
|
||||
"--format",
|
||||
"{p}",
|
||||
"--no-dedupe",
|
||||
"--color",
|
||||
"never",
|
||||
]
|
||||
|
||||
|
||||
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,
|
||||
def cargo_build(cargo: str) -> list[str]:
|
||||
return [
|
||||
cargo,
|
||||
"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]:
|
||||
@ -166,33 +93,25 @@ def rendered_lines(message: object, raw_line: str) -> Iterable[str]:
|
||||
yield rendered.rstrip("\n")
|
||||
|
||||
|
||||
def run(status_path: Path, log_path: Path, cargo_lane: Path, lane: str) -> int:
|
||||
def run(status_path: Path, log_path: Path, cargo: str) -> int:
|
||||
write_status(
|
||||
status_path,
|
||||
"Preparing build",
|
||||
"Resolving editor crate graph",
|
||||
8,
|
||||
)
|
||||
metadata = subprocess.run(
|
||||
cargo_metadata(cargo_lane, lane, host_target()),
|
||||
cwd=ROOT,
|
||||
tree = subprocess.run(
|
||||
cargo_tree(cargo),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
try:
|
||||
crates = (
|
||||
editor_package_closure(metadata.stdout)
|
||||
if metadata.returncode == 0
|
||||
else set()
|
||||
)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
crates = set()
|
||||
crates = unique_crates(tree.stdout) if tree.returncode == 0 else set()
|
||||
if not crates:
|
||||
with log_path.open("a", encoding="utf-8") as log:
|
||||
log.write(metadata.stdout)
|
||||
log.write(metadata.stderr)
|
||||
log.write(tree.stdout)
|
||||
log.write(tree.stderr)
|
||||
write_status(
|
||||
status_path,
|
||||
"Build graph failed",
|
||||
@ -200,7 +119,7 @@ def run(status_path: Path, log_path: Path, cargo_lane: Path, lane: str) -> int:
|
||||
8,
|
||||
"error",
|
||||
)
|
||||
return metadata.returncode or 1
|
||||
return tree.returncode or 1
|
||||
|
||||
total = len(crates)
|
||||
write_status(
|
||||
@ -212,8 +131,7 @@ def run(status_path: Path, log_path: Path, cargo_lane: Path, lane: str) -> int:
|
||||
)
|
||||
|
||||
process = subprocess.Popen(
|
||||
cargo_build(cargo_lane, lane),
|
||||
cwd=ROOT,
|
||||
cargo_build(cargo),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
@ -236,7 +154,7 @@ def run(status_path: Path, log_path: Path, cargo_lane: Path, lane: str) -> int:
|
||||
changed, crate_name = completed_crate(message, completed_package_ids)
|
||||
if not changed:
|
||||
continue
|
||||
completed = len(completed_package_ids.intersection(crates))
|
||||
completed = min(len(completed_package_ids), total)
|
||||
progress = min(78, 12 + (66 * completed // total))
|
||||
write_status(
|
||||
status_path,
|
||||
@ -247,7 +165,7 @@ def run(status_path: Path, log_path: Path, cargo_lane: Path, lane: str) -> int:
|
||||
)
|
||||
|
||||
return_code = process.wait()
|
||||
completed = len(completed_package_ids.intersection(crates))
|
||||
completed = min(len(completed_package_ids), total)
|
||||
if return_code != 0:
|
||||
write_status(
|
||||
status_path,
|
||||
@ -273,15 +191,9 @@ def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("status_path", type=Path)
|
||||
parser.add_argument("log_path", type=Path)
|
||||
parser.add_argument("--cargo-lane", type=Path, default=DEFAULT_CARGO_LANE)
|
||||
parser.add_argument("--lane", default="dev")
|
||||
parser.add_argument("--cargo", default="/usr/bin/cargo")
|
||||
arguments = parser.parse_args()
|
||||
return run(
|
||||
arguments.status_path,
|
||||
arguments.log_path,
|
||||
arguments.cargo_lane,
|
||||
arguments.lane,
|
||||
)
|
||||
return run(arguments.status_path, arguments.log_path, arguments.cargo)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -6,7 +6,6 @@ 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)
|
||||
@ -14,94 +13,11 @@ SPEC.loader.exec_module(BUILD_PROGRESS)
|
||||
|
||||
|
||||
class BuildProgressTests(unittest.TestCase):
|
||||
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")
|
||||
|
||||
def test_unique_crates_deduplicates_repeated_tree_entries(self) -> None:
|
||||
output = "editor v0.1.0\nserde v1.0.0\nserde v1.0.0\n"
|
||||
self.assertEqual(
|
||||
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"},
|
||||
BUILD_PROGRESS.unique_crates(output),
|
||||
{"editor v0.1.0", "serde v1.0.0"},
|
||||
)
|
||||
|
||||
def test_completed_crate_counts_each_package_once(self) -> None:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user