feat: add production UI gallery and inspector system
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run

This commit is contained in:
Rbanh 2026-07-18 12:05:26 -04:00
parent cf6eaba534
commit 53dc1e44d8
95 changed files with 11003 additions and 1652 deletions

View File

@ -7,6 +7,8 @@ name:
purpose: purpose:
preconditions: preconditions:
build_lane: build_lane:
package:
binary:
fixture: fixture:
steps: steps:
assertions: assertions:
@ -15,6 +17,9 @@ cleanup:
invalidates: invalidates:
``` ```
`package` and `binary` are optional and default to `editor`. Focused tools such as the UI gallery
can therefore use the same exact-window evidence and cleanup workflow without launching a project.
Keep steps deterministic and phrased as user actions. Assertions describe visible behavior, state transitions, persistence, and failure recovery. Evidence names the target window/crop and expected files. Cleanup must identify every spawned process and fixture mutation. Keep steps deterministic and phrased as user actions. Assertions describe visible behavior, state transitions, persistence, and failure recovery. Evidence names the target window/crop and expected files. Cleanup must identify every spawned process and fixture mutation.
The runner exposes the scenario instead of pretending manual interactions are automated: The runner exposes the scenario instead of pretending manual interactions are automated:

View File

@ -1,10 +1,10 @@
name: penpot-material-inspector name: penpot-material-inspector
purpose: Verify the final Penpot Materials list and material-slot panel at its 640 px and 420 px Inspector reference widths. purpose: Verify the current Penpot Materials list and material-slot panel at its 620 px and 420 px Inspector reference widths with side-by-side design evidence.
preconditions: editor_scene contains primitive, static-mesh, and skinned-mesh material users; no editor process is running. preconditions: editor_scene contains primitive, static-mesh, and skinned-mesh material users; no editor process is running.
build_lane: dev build_lane: dev
fixture: assets/materials/pebble_bricks.ron fixture: assets/materials/pebble_bricks.ron
steps: steps:
- Launch the editor on the real desktop and size the Inspector to the 640 px reference width. - Launch the editor on the real desktop and size the Inspector to the 620 px reference width.
- Select a primitive, static mesh, and skinned mesh in turn and expand each material panel. - Select a primitive, static mesh, and skinned mesh in turn and expand each material panel.
- Compare Materials ownership, expanded slot header, PARAMETERS spine, Surface, six Inputs rows, UV, and Advanced geometry with the current Penpot reference. - Compare Materials ownership, expanded slot header, PARAMETERS spine, Surface, six Inputs rows, UV, and Advanced geometry with the current Penpot reference.
- Narrow the Inspector to the 420 px floor and verify every value, channel, texture, Locate, and Clear control reflows. - Narrow the Inspector to the 420 px floor and verify every value, channel, texture, Locate, and Clear control reflows.
@ -14,7 +14,7 @@ steps:
- Edit a scalar and color, release the controls, switch actors, then save and relaunch the editor. - Edit a scalar and color, release the controls, switch actors, then save and relaunch the editor.
- Inspect imported-source, DefaultGrid, and broken-reference presentations through the available fixture states. - Inspect imported-source, DefaultGrid, and broken-reference presentations through the available fixture states.
assertions: assertions:
- The 640 px Inspector matches the current Penpot palette, Source Sans Pro typography, 32 px Materials heading, 82 px expanded slot header, 592 px parameter sections, six 30 px input rows, spacing, borders, and fixed property geometry without visible drift. - The 620 px Inspector matches the current Penpot palette, Source Sans Pro typography, 32 px Materials heading, 82 px expanded slot header, 596 px slot, 569 px parameter body, six 30 px input rows, spacing, borders, and fixed property geometry without visible drift.
- Primitive, static-mesh, and skinned-mesh slots use the same panel geometry and have no redundant second slot presentation. - Primitive, static-mesh, and skinned-mesh slots use the same panel geometry and have no redundant second slot presentation.
- At the 420 px Inspector floor, six 56 px input rows put paired texture controls on the second line with no horizontal scrolling, clipping, compressed labels, or missing actions. - At the 420 px Inspector floor, six 56 px input rows put paired texture controls on the second line with no horizontal scrolling, clipping, compressed labels, or missing actions.
- Direct assignments have no Explicit badge; inherited, dirty, processing, failed, read-only, imported-source, and built-in states retain one header geometry. - Direct assignments have no Explicit badge; inherited, dirty, processing, failed, read-only, imported-source, and built-in states retain one header geometry.
@ -22,6 +22,6 @@ assertions:
- Wheel, Sliders, and Presets preview live; Apply retains the dirty in-memory value and Cancel, Escape, or outside dismissal restores the exact pre-open value. - Wheel, Sliders, and Presets preview live; Apply retains the dirty in-memory value and Cancel, Escape, or outside dismissal restores the exact pre-open value.
- Material controls perform no pointer-release write or scalar/color processing job and keep the existing live GPU handle visible across actor selection. - Material controls perform no pointer-release write or scalar/color processing job and keep the existing live GPU handle visible across actor selection.
- Save and relaunch preserve the authored value, while broken explicit references retain failed identity and visible DefaultGrid fallback. - Save and relaunch preserve the authored value, while broken explicit references retain failed identity and visible DefaultGrid fallback.
evidence: Target-window captures for 640 px primitive/static/skinned, narrow reflow, color modes, invalid drop, dirty state, and relaunch plus hashes. evidence: Font-faithful Penpot PNGs paired beside fresh target-window captures for 620 px primitive/static/skinned, 420 px reflow, component headers, renderer composition, color modes, invalid drop, dirty state, and relaunch plus hashes and a written delta matrix.
cleanup: Close the spawned editor and atomically restore pebble_bricks.ron from the runner snapshot. cleanup: Close the spawned editor and atomically restore pebble_bricks.ron from the runner snapshot.
invalidates: Design tokens, fonts, material panel geometry, material picker, color picker, authored documents, or material resolver changes. invalidates: Design tokens, fonts, material panel geometry, material picker, color picker, authored documents, or material resolver changes.

View File

@ -0,0 +1,22 @@
name: ui-gallery-framework
purpose: Prove the lightweight production-component gallery launches independently and exposes the Penpot comparison workflow.
preconditions: The focused ui_gallery build and workflow tests pass in the managed dev lane.
build_lane: dev
package: ui_gallery
binary: ui_gallery
fixture:
steps:
- Launch the standalone Blacksite UI Gallery without a project, scene, renderer, or gameplay runtime.
- Inspect the default Materials 620 side-by-side reference and production component canvases.
- Switch through Materials 420, Renderer 760, and Renderer 480 presets.
- Exercise Live, Overlay, and Difference modes and inspect the reported difference statistics.
- Switch through direct, inherited, imported, dirty, processing, failed, read-only, and built-in fixtures.
assertions:
- The gallery opens as one responsive native window without launching the Blacksite editor or game runtime.
- All four exact reference presets render the direct Penpot PNG and shared production component without clipping the gallery chrome.
- 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.
evidence: Exact ui_gallery window capture plus the hashed native-QA record.
cleanup: Close the runner-owned ui_gallery process; no project fixture is mutated.
invalidates: editor_ui, material_schema, ui_gallery, Penpot references, gallery fixtures, or gallery workflow scripts change.

View File

@ -38,7 +38,7 @@ design-system reference. Penpot owns geometry, typography, and interaction-state
Blacksite's editor-owned semantic palette owns color and is published into Penpot Assets / Colors. Blacksite's editor-owned semantic palette owns color and is published into Penpot Assets / Colors.
The current palette is the default, with preference selection deferred behind the same palette The current palette is the default, with preference selection deferred behind the same palette
seam. Shared semantic tokens, Source Sans Pro typography, action-returning seam. Shared semantic tokens, Source Sans Pro typography, action-returning
material panels, responsive property grids, and custom asset/color pickers must match the 640 px material panels, responsive property grids, and custom asset/color pickers must match the 620 px
and 420 px references. The Inspector has a hard 420 px floor and one actor-body scroll region. and 420 px references. The Inspector has a hard 420 px floor and one actor-body scroll region.
Runtime material authority and explicit-save behavior remain Runtime material authority and explicit-save behavior remain
owned by #67 and ADR 0047; wider editor-panel migration is not part of this slice. owned by #67 and ADR 0047; wider editor-panel migration is not part of this slice.
@ -166,6 +166,12 @@ loader.
Inspector. The new wide/narrow Material canvases are 596/396 px with 569/369 px parameter Inspector. The new wide/narrow Material canvases are 596/396 px with 569/369 px parameter
sections, 32/58 px rows, and 54/94 px UV sections. Candidate publication and issue closure remain sections, 32/58 px rows, and 54/94 px UV sections. Candidate publication and issue closure remain
blocked until refreshed engineering and Codex-owned native evidence pass on one exact tree. blocked until refreshed engineering and Codex-owned native evidence pass on one exact tree.
- [Engineering-complete; acceptance in progress 2026-07-18] A lightweight `ui_gallery` now renders
the same `editor_ui` production components without project/scene/renderer/gameplay startup. It
provides exact Materials 620/420 and Renderer 760/480 presets, direct Penpot side-by-side,
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.
## Target Contracts ## Target Contracts

241
Cargo.lock generated
View File

@ -2564,6 +2564,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "cgl"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "chiapos-chacha8" name = "chiapos-chacha8"
version = "0.1.0" version = "0.1.0"
@ -3226,6 +3235,7 @@ dependencies = [
"blacksite_surface", "blacksite_surface",
"blake3", "blake3",
"content_pipeline", "content_pipeline",
"editor_ui",
"egui_dock", "egui_dock",
"egui_phosphor_icons", "egui_phosphor_icons",
"game", "game",
@ -3249,6 +3259,51 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "editor_ui"
version = "0.1.0"
dependencies = [
"bevy_math",
"egui",
"egui_phosphor_icons",
"material_schema",
]
[[package]]
name = "eframe"
version = "0.34.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98fe83b2589105b69dd25ca1e0fa2135a6e864d502fd8e08978f937e128cfef"
dependencies = [
"ahash",
"bytemuck",
"document-features",
"egui",
"egui-wgpu",
"egui-winit",
"egui_glow",
"glow",
"glutin",
"glutin-winit",
"image",
"js-sys",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"profiling",
"raw-window-handle",
"static_assertions",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"web-time",
"windows-sys 0.61.2",
"winit",
]
[[package]] [[package]]
name = "egui" name = "egui"
version = "0.34.3" version = "0.34.3"
@ -3268,6 +3323,47 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "egui-wgpu"
version = "0.34.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f0c0559ac5598a1b887a6206dccbab7e3e6246c57cb00ae287262bd44776c9c"
dependencies = [
"ahash",
"bytemuck",
"document-features",
"egui",
"epaint",
"log",
"profiling",
"thiserror 2.0.18",
"type-map",
"web-time",
"wgpu",
"winit",
]
[[package]]
name = "egui-winit"
version = "0.34.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "967c5b323625d46d46a59b5daba3fef742248d27693cc18972458619858c4239"
dependencies = [
"arboard",
"bytemuck",
"egui",
"log",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
"profiling",
"raw-window-handle",
"smithay-clipboard",
"web-time",
"webbrowser",
"winit",
]
[[package]] [[package]]
name = "egui_dock" name = "egui_dock"
version = "0.19.1" version = "0.19.1"
@ -3281,6 +3377,23 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
[[package]]
name = "egui_glow"
version = "0.34.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b652957fa7e1ab01e8fecbfbf4e35f6e43a53fa98af8a562b50d5403cd44b9"
dependencies = [
"bytemuck",
"egui",
"glow",
"log",
"memoffset",
"profiling",
"wasm-bindgen",
"web-sys",
"winit",
]
[[package]] [[package]]
name = "egui_phosphor_icons" name = "egui_phosphor_icons"
version = "0.3.1" version = "0.3.1"
@ -4053,6 +4166,63 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "glutin"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325"
dependencies = [
"bitflags 2.13.0",
"cfg_aliases",
"cgl",
"dispatch2",
"glutin_egl_sys",
"glutin_glx_sys",
"glutin_wgl_sys",
"libloading",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"once_cell",
"raw-window-handle",
"wayland-sys",
"windows-sys 0.52.0",
"x11-dl",
]
[[package]]
name = "glutin-winit"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f"
dependencies = [
"cfg_aliases",
"glutin",
"raw-window-handle",
"winit",
]
[[package]]
name = "glutin_egl_sys"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2"
dependencies = [
"gl_generator",
"windows-sys 0.52.0",
]
[[package]]
name = "glutin_glx_sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185"
dependencies = [
"gl_generator",
"x11-dl",
]
[[package]] [[package]]
name = "glutin_wgl_sys" name = "glutin_wgl_sys"
version = "0.6.1" version = "0.6.1"
@ -5018,6 +5188,16 @@ dependencies = [
"regex-automata", "regex-automata",
] ]
[[package]]
name = "material_schema"
version = "0.1.0"
dependencies = [
"bevy_color",
"bevy_math",
"bevy_reflect 0.19.0",
"serde",
]
[[package]] [[package]]
name = "maybe-rayon" name = "maybe-rayon"
version = "0.1.1" version = "0.1.1"
@ -5043,6 +5223,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@ -5108,7 +5297,7 @@ dependencies = [
"num-traits", "num-traits",
"once_cell", "once_cell",
"pp-rs", "pp-rs",
"rustc-hash", "rustc-hash 1.1.0",
"spirv", "spirv",
"thiserror 2.0.18", "thiserror 2.0.18",
"unicode-ident", "unicode-ident",
@ -5125,7 +5314,7 @@ dependencies = [
"indexmap", "indexmap",
"naga", "naga",
"regex", "regex",
"rustc-hash", "rustc-hash 1.1.0",
"thiserror 2.0.18", "thiserror 2.0.18",
"tracing", "tracing",
"unicode-ident", "unicode-ident",
@ -5424,6 +5613,7 @@ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"block2 0.6.2", "block2 0.6.2",
"objc2 0.6.4", "objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics", "objc2-core-graphics",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
] ]
@ -5709,6 +5899,18 @@ dependencies = [
"objc2-user-notifications", "objc2-user-notifications",
] ]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.13.0",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
[[package]] [[package]]
name = "objc2-uniform-type-identifiers" name = "objc2-uniform-type-identifiers"
version = "0.2.2" version = "0.2.2"
@ -6756,6 +6958,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@ -6960,6 +7168,7 @@ dependencies = [
"avian3d", "avian3d",
"bevy", "bevy",
"blake3", "blake3",
"material_schema",
"ron 0.8.1", "ron 0.8.1",
"serde", "serde",
"settings", "settings",
@ -7699,6 +7908,15 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
[[package]]
name = "type-map"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90"
dependencies = [
"rustc-hash 2.1.3",
]
[[package]] [[package]]
name = "typeid" name = "typeid"
version = "1.0.3" version = "1.0.3"
@ -7720,6 +7938,20 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "ui_gallery"
version = "0.1.0"
dependencies = [
"editor_ui",
"eframe",
"egui",
"egui_phosphor_icons",
"image",
"material_schema",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@ -8152,6 +8384,7 @@ dependencies = [
"smallvec", "smallvec",
"static_assertions", "static_assertions",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures",
"web-sys", "web-sys",
"wgpu-core", "wgpu-core",
"wgpu-hal", "wgpu-hal",
@ -8180,7 +8413,7 @@ dependencies = [
"portable-atomic", "portable-atomic",
"profiling", "profiling",
"raw-window-handle", "raw-window-handle",
"rustc-hash", "rustc-hash 1.1.0",
"smallvec", "smallvec",
"thiserror 2.0.18", "thiserror 2.0.18",
"wgpu-core-deps-apple", "wgpu-core-deps-apple",
@ -8786,7 +9019,7 @@ dependencies = [
"objc2 0.5.2", "objc2 0.5.2",
"objc2-app-kit 0.2.2", "objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"objc2-ui-kit", "objc2-ui-kit 0.2.2",
"orbclient", "orbclient",
"percent-encoding", "percent-encoding",
"pin-project", "pin-project",

View File

@ -2,10 +2,13 @@
members = [ members = [
"crates/blacksite_surface", "crates/blacksite_surface",
"crates/content_pipeline", "crates/content_pipeline",
"crates/editor_ui",
"crates/game", "crates/game",
"crates/game_hot", "crates/game_hot",
"crates/material_schema",
"crates/shared", "crates/shared",
"crates/editor", "crates/editor",
"crates/ui_gallery",
"crates/protocol", "crates/protocol",
"crates/sim", "crates/sim",
"crates/settings", "crates/settings",
@ -44,9 +47,13 @@ blacksite_surface = { path = "crates/blacksite_surface" }
content_pipeline = { path = "crates/content_pipeline" } content_pipeline = { path = "crates/content_pipeline" }
bevy_ufbx = "0.18.1-rc.1" bevy_ufbx = "0.18.1-rc.1"
bevy_egui = "0.40" bevy_egui = "0.40"
egui = "0.34.3"
eframe = { version = "0.34.1", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] }
bevy-inspector-egui = "0.37" bevy-inspector-egui = "0.37"
egui_dock = { version = "0.19.1", features = ["serde"] } egui_dock = { version = "0.19.1", features = ["serde"] }
egui_phosphor_icons = { version = "0.3.1", default-features = false } 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" transform-gizmo-bevy = "0.9"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
nav_glam = { package = "glam", version = "=0.30.10", features = ["serde"] } nav_glam = { package = "glam", version = "=0.30.10", features = ["serde"] }

View File

@ -126,6 +126,23 @@ workflow never installs it implicitly.
> Note: the workspace uses the `mold` linker via `clang` (see `.cargo/config.toml`) for fast link times. > Note: the workspace uses the `mold` linker via `clang` (see `.cargo/config.toml`) for fast link times.
### Focused editor UI iteration
Use the lightweight gallery when changing shared Inspector/design-system components instead of
restarting the project editor for every visual edit:
```bash
python scripts/editor/ui_gallery.py watch
```
The gallery renders the production `editor_ui` components without loading a project, scene,
renderer, or gameplay runtime. It includes exact Materials 620/420 and Renderer 760/480 presets,
direct Penpot side-by-side/overlay/difference views, representative material states, and live
theme/material fixture reload. Rust edits rebuild the focused dependency closure with a bounded
`resolved/rebuilt` counter; the last-good gallery stays open unless its replacement launches
successfully. See the [Editor Design System](docs/editor/design-system.md#focused-ui-gallery-workflow)
for shortcuts, fixtures, and acceptance rules.
### Target Cache Cleanup ### Target Cache Cleanup
Cargo/Bevy artifacts are managed as whole, sentinel-marked build lanes. Inspect and plan cleanup Cargo/Bevy artifacts are managed as whole, sentinel-marked build lanes. Inspect and plan cleanup

View File

@ -26,7 +26,7 @@
r: 1.0, r: 1.0,
g: 1.0, g: 1.0,
b: 1.0, b: 1.0,
a: 1.0, a: 0.0,
)), )),
), ),
( (

View File

@ -2,7 +2,7 @@
"shared::components::SceneComposition": (scene_id:"legacy:assets/levels/editor_scene.scn.ron",subscenes:[]), "shared::components::SceneComposition": (scene_id:"legacy:assets/levels/editor_scene.scn.ron",subscenes:[]),
}, },
entities: { entities: {
4294966529: (components: { 4294966520: (components: {
"bevy_ecs::name::Name": "Brush Part 2", "bevy_ecs::name::Name": "Brush Part 2",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (25.75, 0.0, 9.5), translation: (25.75, 0.0, 9.5),
@ -170,7 +170,7 @@
uv_tiling: (1.0, 1.0), uv_tiling: (1.0, 1.0),
), ),
}), }),
4294966530: (components: { 4294966521: (components: {
"bevy_ecs::name::Name": "Brush Part 1", "bevy_ecs::name::Name": "Brush Part 1",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (24.5, 0.0, 7.75), translation: (24.5, 0.0, 7.75),
@ -338,7 +338,7 @@
uv_tiling: (1.0, 1.0), uv_tiling: (1.0, 1.0),
), ),
}), }),
4294966531: (components: { 4294966522: (components: {
"bevy_ecs::name::Name": "Crate Copy", "bevy_ecs::name::Name": "Crate Copy",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (15.624287, 0.75, 1.8058548), translation: (15.624287, 0.75, 1.8058548),
@ -506,7 +506,7 @@
uv_tiling: (1.0, 1.0), uv_tiling: (1.0, 1.0),
), ),
}), }),
4294966532: (components: { 4294966523: (components: {
"bevy_ecs::name::Name": "Post Process Volume", "bevy_ecs::name::Name": "Post Process Volume",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (28.0, 2.0, -1.0), translation: (28.0, 2.0, -1.0),
@ -661,7 +661,7 @@
label: None, label: None,
), ),
}), }),
4294966533: (components: { 4294966524: (components: {
"bevy_ecs::name::Name": "Pillar", "bevy_ecs::name::Name": "Pillar",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (-8.0, 3.5, -6.0), translation: (-8.0, 3.5, -6.0),
@ -697,7 +697,7 @@
), ),
), ),
}), }),
4294966534: (components: { 4294966525: (components: {
"bevy_ecs::name::Name": "Pillar", "bevy_ecs::name::Name": "Pillar",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (8.0, 3.5, -6.0), translation: (8.0, 3.5, -6.0),
@ -733,7 +733,7 @@
), ),
), ),
}), }),
4294966535: (components: { 4294966526: (components: {
"bevy_ecs::name::Name": "Pillar", "bevy_ecs::name::Name": "Pillar",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (-8.0, 3.5, 6.0), translation: (-8.0, 3.5, 6.0),
@ -769,7 +769,7 @@
), ),
), ),
}), }),
4294966536: (components: { 4294966527: (components: {
"bevy_ecs::name::Name": "Pillar", "bevy_ecs::name::Name": "Pillar",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (8.0, 3.5, 6.0), translation: (8.0, 3.5, 6.0),
@ -805,10 +805,10 @@
), ),
), ),
}), }),
4294966537: (components: { 4294966528: (components: {
"bevy_ecs::name::Name": "Crate", "bevy_ecs::name::Name": "Crate",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (-0.8005953, 0.3732614, 0.21393776), translation: (-2.8005953, 0.3732614, 0.21393776),
rotation: (0.0, 0.86742324, 0.0, -0.49757105), rotation: (0.0, 0.86742324, 0.0, -0.49757105),
scale: (1.0, 1.0, 1.0), scale: (1.0, 1.0, 1.0),
), ),
@ -841,10 +841,10 @@
), ),
), ),
}), }),
4294966538: (components: { 4294966529: (components: {
"bevy_ecs::name::Name": "Crate", "bevy_ecs::name::Name": "Crate",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (0.59940434, 0.3732614, 0.013936996), translation: (2.5994043, 0.3732614, 0.013936996),
rotation: (0.0, -0.53118616, 0.0, 0.8472551), rotation: (0.0, -0.53118616, 0.0, 0.8472551),
scale: (1.0, 1.0, 1.0), scale: (1.0, 1.0, 1.0),
), ),
@ -877,10 +877,10 @@
), ),
), ),
}), }),
4294966539: (components: { 4294966530: (components: {
"bevy_ecs::name::Name": "Crate", "bevy_ecs::name::Name": "Crate",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (-0.10059604, 1.3955705, 0.11393738), translation: (2.899404, 1.3955705, 0.11393738),
rotation: (0.0, 0.72083056, 0.0, -0.6931113), rotation: (0.0, 0.72083056, 0.0, -0.6931113),
scale: (1.0, 1.0, 1.0), scale: (1.0, 1.0, 1.0),
), ),
@ -913,7 +913,7 @@
), ),
), ),
}), }),
4294966540: (components: { 4294966531: (components: {
"bevy_ecs::name::Name": "Crate", "bevy_ecs::name::Name": "Crate",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (14.575503, 4.5, -15.5), translation: (14.575503, 4.5, -15.5),
@ -1080,7 +1080,7 @@
uv_tiling: (1.0, 1.0), uv_tiling: (1.0, 1.0),
), ),
}), }),
4294966541: (components: { 4294966532: (components: {
"bevy_ecs::name::Name": "Crate", "bevy_ecs::name::Name": "Crate",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (5.4, 0.5, 2.4), translation: (5.4, 0.5, 2.4),
@ -1116,7 +1116,7 @@
), ),
), ),
}), }),
4294966542: (components: { 4294966533: (components: {
"bevy_ecs::name::Name": "Ramp", "bevy_ecs::name::Name": "Ramp",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (-14.0, 1.6, 0.0), translation: (-14.0, 1.6, 0.0),
@ -1152,7 +1152,7 @@
), ),
), ),
}), }),
4294966543: (components: { 4294966534: (components: {
"bevy_ecs::name::Name": "Stair", "bevy_ecs::name::Name": "Stair",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (12.0, 0.09, 14.0), translation: (12.0, 0.09, 14.0),
@ -1188,7 +1188,7 @@
), ),
), ),
}), }),
4294966544: (components: { 4294966535: (components: {
"bevy_ecs::name::Name": "Stair", "bevy_ecs::name::Name": "Stair",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (12.0, 0.18, 13.5), translation: (12.0, 0.18, 13.5),
@ -1224,7 +1224,7 @@
), ),
), ),
}), }),
4294966545: (components: { 4294966536: (components: {
"bevy_ecs::name::Name": "Stair", "bevy_ecs::name::Name": "Stair",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (12.0, 0.27, 13.0), translation: (12.0, 0.27, 13.0),
@ -1260,7 +1260,7 @@
), ),
), ),
}), }),
4294966546: (components: { 4294966537: (components: {
"bevy_ecs::name::Name": "Stair", "bevy_ecs::name::Name": "Stair",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (12.0, 0.36, 12.5), translation: (12.0, 0.36, 12.5),
@ -1296,7 +1296,7 @@
), ),
), ),
}), }),
4294966547: (components: { 4294966538: (components: {
"bevy_ecs::name::Name": "Stair", "bevy_ecs::name::Name": "Stair",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (12.0, 0.45000002, 12.0), translation: (12.0, 0.45000002, 12.0),
@ -1332,7 +1332,7 @@
), ),
), ),
}), }),
4294966548: (components: { 4294966539: (components: {
"bevy_ecs::name::Name": "Stair", "bevy_ecs::name::Name": "Stair",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (12.0, 0.54, 11.5), translation: (12.0, 0.54, 11.5),
@ -1368,7 +1368,7 @@
), ),
), ),
}), }),
4294966549: (components: { 4294966540: (components: {
"bevy_ecs::name::Name": "Stair", "bevy_ecs::name::Name": "Stair",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (12.0, 0.63, 11.0), translation: (12.0, 0.63, 11.0),
@ -1404,7 +1404,7 @@
), ),
), ),
}), }),
4294966550: (components: { 4294966541: (components: {
"bevy_ecs::name::Name": "Stair", "bevy_ecs::name::Name": "Stair",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (12.0, 0.72, 10.5), translation: (12.0, 0.72, 10.5),
@ -1440,7 +1440,7 @@
), ),
), ),
}), }),
4294966551: (components: { 4294966542: (components: {
"bevy_ecs::name::Name": "Stair", "bevy_ecs::name::Name": "Stair",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (12.0, 0.81000006, 10.0), translation: (12.0, 0.81000006, 10.0),
@ -1476,7 +1476,7 @@
), ),
), ),
}), }),
4294966552: (components: { 4294966543: (components: {
"bevy_ecs::name::Name": "Stair", "bevy_ecs::name::Name": "Stair",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (12.0, 0.90000004, 9.5), translation: (12.0, 0.90000004, 9.5),
@ -1512,7 +1512,7 @@
), ),
), ),
}), }),
4294966553: (components: { 4294966544: (components: {
"bevy_ecs::name::Name": "ShowcaseSphere", "bevy_ecs::name::Name": "ShowcaseSphere",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (-8.0, 0.6, 4.0), translation: (-8.0, 0.6, 4.0),
@ -1546,7 +1546,7 @@
), ),
), ),
}), }),
4294966554: (components: { 4294966545: (components: {
"bevy_ecs::name::Name": "ShowcaseSphere", "bevy_ecs::name::Name": "ShowcaseSphere",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (-5.8, 0.6, 4.0), translation: (-5.8, 0.6, 4.0),
@ -1580,7 +1580,7 @@
), ),
), ),
}), }),
4294966555: (components: { 4294966546: (components: {
"bevy_ecs::name::Name": "ShowcaseSphere", "bevy_ecs::name::Name": "ShowcaseSphere",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (-3.6, 0.6, 4.0), translation: (-3.6, 0.6, 4.0),
@ -1614,7 +1614,7 @@
), ),
), ),
}), }),
4294966556: (components: { 4294966547: (components: {
"bevy_ecs::name::Name": "ShowcaseSphere", "bevy_ecs::name::Name": "ShowcaseSphere",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (-1.3999996, 0.6, 4.0), translation: (-1.3999996, 0.6, 4.0),
@ -1648,7 +1648,7 @@
), ),
), ),
}), }),
4294966557: (components: { 4294966548: (components: {
"bevy_ecs::name::Name": "Player Start", "bevy_ecs::name::Name": "Player Start",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (0.0, 0.9900649, 12.46175), translation: (0.0, 0.9900649, 12.46175),
@ -1663,7 +1663,7 @@
"shared::components::LevelObject": (), "shared::components::LevelObject": (),
"shared::components::PlayerSpawn": (), "shared::components::PlayerSpawn": (),
}), }),
4294966558: (components: { 4294966549: (components: {
"bevy_ecs::name::Name": "Ground", "bevy_ecs::name::Name": "Ground",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (0.0, -0.5, 0.0), translation: (0.0, -0.5, 0.0),
@ -1699,7 +1699,7 @@
), ),
), ),
}), }),
4294966559: (components: { 4294966550: (components: {
"bevy_ecs::name::Name": "Scene Sun", "bevy_ecs::name::Name": "Scene Sun",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (0.0, 5.316672, 0.0), translation: (0.0, 5.316672, 0.0),
@ -1727,11 +1727,11 @@
outer_angle_deg: 35.0, outer_angle_deg: 35.0,
), ),
}), }),
68719475876: (components: { 4294966551: (components: {
"bevy_ecs::hierarchy::ChildOf": (107374181688), "bevy_ecs::hierarchy::ChildOf": (4294966558),
"bevy_ecs::name::Name": "metal_office_desk / Primitive 0", "bevy_ecs::name::Name": "metal_office_desk / Primitive 0",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (0.0, 0.0, 0.0), translation: (1.0, 0.0, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0), rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0), scale: (1.0, 1.0, 1.0),
), ),
@ -1803,8 +1803,8 @@
), ),
), ),
}), }),
77309410416: (components: { 4294966552: (components: {
"bevy_ecs::hierarchy::ChildOf": (68719475876), "bevy_ecs::hierarchy::ChildOf": (4294966551),
"bevy_ecs::name::Name": "metal_office_desk_drawer_04 / Primitive 0", "bevy_ecs::name::Name": "metal_office_desk_drawer_04 / Primitive 0",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (0.0, 0.0, 0.0), translation: (0.0, 0.0, 0.0),
@ -1870,8 +1870,8 @@
), ),
), ),
}), }),
81604377613: (components: { 4294966553: (components: {
"bevy_ecs::hierarchy::ChildOf": (68719475876), "bevy_ecs::hierarchy::ChildOf": (4294966551),
"bevy_ecs::name::Name": "metal_office_desk_tray_01 / Primitive 0", "bevy_ecs::name::Name": "metal_office_desk_tray_01 / Primitive 0",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (0.0, 0.0, 0.0), translation: (0.0, 0.0, 0.0),
@ -1937,8 +1937,8 @@
), ),
), ),
}), }),
85899344880: (components: { 4294966554: (components: {
"bevy_ecs::hierarchy::ChildOf": (68719475876), "bevy_ecs::hierarchy::ChildOf": (4294966551),
"bevy_ecs::name::Name": "metal_office_desk_drawer_03 / Primitive 0", "bevy_ecs::name::Name": "metal_office_desk_drawer_03 / Primitive 0",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (0.0, 0.0, 0.0), translation: (0.0, 0.0, 0.0),
@ -2004,8 +2004,8 @@
), ),
), ),
}), }),
85899345209: (components: { 4294966555: (components: {
"bevy_ecs::hierarchy::ChildOf": (68719475876), "bevy_ecs::hierarchy::ChildOf": (4294966551),
"bevy_ecs::name::Name": "metal_office_desk_drawer_05 / Primitive 0", "bevy_ecs::name::Name": "metal_office_desk_drawer_05 / Primitive 0",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (0.0, 0.0, 0.0), translation: (0.0, 0.0, 0.0),
@ -2071,8 +2071,8 @@
), ),
), ),
}), }),
90194312192: (components: { 4294966556: (components: {
"bevy_ecs::hierarchy::ChildOf": (68719475876), "bevy_ecs::hierarchy::ChildOf": (4294966551),
"bevy_ecs::name::Name": "metal_office_desk_drawer_06 / Primitive 0", "bevy_ecs::name::Name": "metal_office_desk_drawer_06 / Primitive 0",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (0.0, 0.0, 0.0), translation: (0.0, 0.0, 0.0),
@ -2138,8 +2138,8 @@
), ),
), ),
}), }),
98784246793: (components: { 4294966557: (components: {
"bevy_ecs::hierarchy::ChildOf": (68719475876), "bevy_ecs::hierarchy::ChildOf": (4294966551),
"bevy_ecs::name::Name": "metal_office_desk_drawer_01 / Primitive 0", "bevy_ecs::name::Name": "metal_office_desk_drawer_01 / Primitive 0",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (0.0, 0.0, 0.0), translation: (0.0, 0.0, 0.0),
@ -2205,7 +2205,7 @@
), ),
), ),
}), }),
107374181688: (components: { 4294966558: (components: {
"bevy_ecs::name::Name": "metal_office_desk_2k", "bevy_ecs::name::Name": "metal_office_desk_2k",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (2.0, 0.1, 9.0), translation: (2.0, 0.1, 9.0),
@ -2220,8 +2220,8 @@
"shared::components::HierarchySiblingIndex": (30), "shared::components::HierarchySiblingIndex": (30),
"shared::components::LevelObject": (), "shared::components::LevelObject": (),
}), }),
120259083352: (components: { 4294966559: (components: {
"bevy_ecs::hierarchy::ChildOf": (68719475876), "bevy_ecs::hierarchy::ChildOf": (4294966551),
"bevy_ecs::name::Name": "metal_office_desk_drawer_02 / Primitive 0", "bevy_ecs::name::Name": "metal_office_desk_drawer_02 / Primitive 0",
"bevy_transform::components::transform::Transform": ( "bevy_transform::components::transform::Transform": (
translation: (0.0, 0.0, 0.0), translation: (0.0, 0.0, 0.0),

View File

@ -31,6 +31,7 @@ bevy_egui.workspace = true
bevy-inspector-egui.workspace = true bevy-inspector-egui.workspace = true
egui_dock.workspace = true egui_dock.workspace = true
egui_phosphor_icons.workspace = true egui_phosphor_icons.workspace = true
editor_ui.workspace = true
bevy_ufbx.workspace = true bevy_ufbx.workspace = true
content_pipeline.workspace = true content_pipeline.workspace = true
ufbx = "0.9" ufbx = "0.9"

View File

@ -62,19 +62,38 @@ fn color_control_impl(
) -> ColorPickerResponse { ) -> ColorPickerResponse {
let palette = super::palette(ui); let palette = super::palette(ui);
let id = ui.make_persistent_id(("penpot_color_picker", salt)); let id = ui.make_persistent_id(("penpot_color_picker", salt));
let (rect, response) = ui.allocate_exact_size(egui::vec2(130.0, 22.0), egui::Sense::click()); let width = ui.available_width().max(1.0);
let swatch_rect = egui::Rect::from_min_size(rect.min, egui::vec2(24.0, 22.0)); let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::click());
let hex_rect = let swatch_width = 44.0_f32.min(width);
egui::Rect::from_min_size(rect.min + egui::vec2(30.0, 0.0), egui::vec2(100.0, 22.0)); let swatch_rect = egui::Rect::from_min_size(rect.min, egui::vec2(swatch_width, 22.0));
let gap = if width > swatch_width + 6.0 { 6.0 } else { 0.0 };
let hex_rect = egui::Rect::from_min_size(
rect.min + egui::vec2(swatch_width + gap, 0.0),
egui::vec2((width - swatch_width - gap).max(0.0), 22.0),
);
popup::checker(ui.painter(), swatch_rect, palette.control, palette.elevated); popup::checker(ui.painter(), swatch_rect, palette.control, palette.elevated);
ui.painter() let inner_swatch = swatch_rect.shrink(1.0);
.rect_filled(swatch_rect.shrink(1.0), 4.0, color32(*rgba)); 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,
3.0,
egui::Color32::from_rgb(byte(rgba[0]), byte(rgba[1]), byte(rgba[2])),
);
ui.painter().rect_filled(alpha_rect, 3.0, color32(*rgba));
ui.painter().rect_stroke( ui.painter().rect_stroke(
swatch_rect, swatch_rect,
4.0, 4.0,
egui::Stroke::new(1.0_f32, palette.border_strong), egui::Stroke::new(1.0_f32, palette.border_strong),
egui::StrokeKind::Inside, egui::StrokeKind::Inside,
); );
if hex_rect.width() > 0.0 {
ui.painter().rect( ui.painter().rect(
hex_rect, hex_rect,
4.0, 4.0,
@ -89,6 +108,7 @@ fn color_control_impl(
TypeRole::Body.font(), TypeRole::Body.font(),
palette.text_primary, palette.text_primary,
); );
}
if response.clicked() { if response.clicked() {
let opened_at = ui.input(|input| input.time); let opened_at = ui.input(|input| input.time);

View File

@ -7,12 +7,10 @@ use std::ops::RangeInclusive;
use super::palette; use super::palette;
use super::typography::TypeRole; use super::typography::TypeRole;
const SCALAR_CONTROL_WIDTH: f32 = 132.0; const SCALAR_TRACK_X: f32 = 6.0;
const SCALAR_TRACK_X: f32 = 0.0; const SCALAR_NUMBER_GAP: f32 = 6.0;
const SCALAR_TRACK_WIDTH: f32 = 78.0;
const SCALAR_TRACK_INTERACTION_HEIGHT: f32 = 10.0; const SCALAR_TRACK_INTERACTION_HEIGHT: f32 = 10.0;
const SCALAR_NUMBER_X: f32 = 84.0; const SCALAR_NUMBER_WIDTH: f32 = 33.0;
const SCALAR_NUMBER_WIDTH: f32 = 48.0;
const SCALAR_THUMB_RADIUS: f32 = 4.0; const SCALAR_THUMB_RADIUS: f32 = 4.0;
pub(crate) fn section_frame(ui: &egui::Ui) -> egui::Frame { pub(crate) fn section_frame(ui: &egui::Ui) -> egui::Frame {
@ -50,7 +48,7 @@ pub(crate) fn section_header_with_summary(
} else { } else {
icons::CARET_RIGHT.as_str() icons::CARET_RIGHT.as_str()
}, },
egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-regular".into())), egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.text_secondary, palette.text_secondary,
); );
ui.painter().text( ui.painter().text(
@ -90,10 +88,7 @@ pub(crate) fn icon_label(
icon.as_str(), icon.as_str(),
0.0, 0.0,
egui::TextFormat { egui::TextFormat {
font_id: egui::FontId::new( font_id: egui::FontId::new(icon_size, egui::FontFamily::Name("phosphor-bold".into())),
icon_size,
egui::FontFamily::Name("phosphor-regular".into()),
),
color, color,
..Default::default() ..Default::default()
}, },
@ -123,7 +118,7 @@ pub(crate) fn icon_button(
enabled, enabled,
egui::Button::new(egui::RichText::new(icon.as_str()).font(egui::FontId::new( egui::Button::new(egui::RichText::new(icon.as_str()).font(egui::FontId::new(
14.0, 14.0,
egui::FontFamily::Name("phosphor-regular".into()), egui::FontFamily::Name("phosphor-bold".into()),
))) )))
.min_size(egui::vec2(24.0, 24.0)) .min_size(egui::vec2(24.0, 24.0))
.fill(palette.control) .fill(palette.control)
@ -133,12 +128,35 @@ pub(crate) fn icon_button(
response.on_hover_text(tooltip) response.on_hover_text(tooltip)
} }
pub(crate) fn status(ui: &mut egui::Ui, color: egui::Color32, label: &str, tooltip: &str) { pub(crate) fn status(
ui: &mut egui::Ui,
icon: Icon,
color: egui::Color32,
label: &str,
tooltip: &str,
) {
let palette = palette(ui); let palette = palette(ui);
let (dot, response) = ui.allocate_exact_size(egui::vec2(8.0, 14.0), egui::Sense::hover()); let mut job = egui::text::LayoutJob::default();
ui.painter().circle_filled(dot.center(), 4.0, color); job.append(
icon.as_str(),
0.0,
egui::TextFormat {
font_id: egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-bold".into())),
color,
..Default::default()
},
);
job.append(
label,
5.0,
egui::TextFormat {
font_id: TypeRole::Body.font(),
color: palette.text_secondary,
..Default::default()
},
);
let response = ui.add(egui::Label::new(job).sense(egui::Sense::hover()));
response.on_hover_text(tooltip); response.on_hover_text(tooltip);
ui.label(TypeRole::Body.text(label).color(palette.text_secondary));
} }
pub(crate) fn switch(ui: &mut egui::Ui, value: &mut bool) -> egui::Response { pub(crate) fn switch(ui: &mut egui::Ui, value: &mut bool) -> egui::Response {
@ -260,12 +278,15 @@ pub(crate) fn scalar_control(
) -> egui::Response { ) -> egui::Response {
let palette = palette(ui); let palette = palette(ui);
let before = *value; let before = *value;
let width = SCALAR_CONTROL_WIDTH.min(ui.available_width().max(1.0)); // The final Penpot component deliberately stretches only the track at the compact
// breakpoint: 93.8 px at the 569 px reference and 160.3 px at the 369 px reference.
// The numeric field remains a stable 33 px in both modes.
let width = ui.available_width().max(1.0);
let (rect, base_response) = let (rect, base_response) =
ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover()); ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover());
let number_width = SCALAR_NUMBER_WIDTH.min(width); let number_width = SCALAR_NUMBER_WIDTH.min(width);
let number_x = SCALAR_NUMBER_X.min((width - number_width).max(0.0)); let number_x = (width - number_width).max(0.0);
let track_width = SCALAR_TRACK_WIDTH.min((number_x - 6.0).max(1.0)); let track_width = (number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP).max(1.0);
let slider_rect = egui::Rect::from_min_size( let slider_rect = egui::Rect::from_min_size(
rect.min + egui::vec2(SCALAR_TRACK_X, 6.0), rect.min + egui::vec2(SCALAR_TRACK_X, 6.0),
egui::vec2(track_width, SCALAR_TRACK_INTERACTION_HEIGHT), egui::vec2(track_width, SCALAR_TRACK_INTERACTION_HEIGHT),
@ -337,8 +358,15 @@ mod scalar_tests {
#[test] #[test]
fn penpot_scalar_geometry_matches_the_current_export() { fn penpot_scalar_geometry_matches_the_current_export() {
assert!((SCALAR_NUMBER_X - (SCALAR_TRACK_X + SCALAR_TRACK_WIDTH) - 6.0).abs() < 0.001); let wide_width = 132.8;
assert_eq!(SCALAR_CONTROL_WIDTH, SCALAR_NUMBER_X + SCALAR_NUMBER_WIDTH); let wide_number_x = wide_width - SCALAR_NUMBER_WIDTH;
let wide_track = wide_number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP;
assert!((wide_track - 93.8).abs() < 0.001);
let compact_width = 199.3;
let compact_number_x = compact_width - SCALAR_NUMBER_WIDTH;
let compact_track = compact_number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP;
assert!((compact_track - 160.3).abs() < 0.001);
assert_eq!(SCALAR_TRACK_INTERACTION_HEIGHT, 10.0); assert_eq!(SCALAR_TRACK_INTERACTION_HEIGHT, 10.0);
assert_eq!(SCALAR_THUMB_RADIUS, 4.0); assert_eq!(SCALAR_THUMB_RADIUS, 4.0);
} }

View File

@ -3,7 +3,14 @@
use super::{COMPACT_PARAMETER_ROW_HEIGHT, PARAMETER_ROW_HEIGHT}; use super::{COMPACT_PARAMETER_ROW_HEIGHT, PARAMETER_ROW_HEIGHT};
use bevy_egui::egui; use bevy_egui::egui;
pub(crate) const WIDE_SECTION_WIDTH: f32 = 569.0; /// Minimum live section width that can safely retain the exported wide-row geometry.
///
/// The Penpot reference section itself is 569 px wide. In the native Inspector, dock chrome,
/// component-card borders, and the floating scrollbar consume up to ten pixels before the
/// material parameter body is allocated. Treat that 559 px contained canvas as wide so a 640 px
/// Inspector renders the authoritative wide reference instead of incorrectly switching to the
/// 420 px two-line rows.
pub(crate) const WIDE_SECTION_WIDTH: f32 = 559.0;
pub(crate) const RESPONSIVE_BREAKPOINT: f32 = WIDE_SECTION_WIDTH; pub(crate) const RESPONSIVE_BREAKPOINT: f32 = WIDE_SECTION_WIDTH;
/// The actor component chrome can consume up to twelve pixels of the exported 372 px compact /// The actor component chrome can consume up to twelve pixels of the exported 372 px compact
/// canvas. At 360 px the complete texture row still fits: 12 px inset + 336 px group + 12 px inset. /// canvas. At 360 px the complete texture row still fits: 12 px inset + 336 px group + 12 px inset.
@ -20,6 +27,7 @@ pub(crate) struct PropertyRowRects {
pub row: egui::Rect, pub row: egui::Rect,
pub label: egui::Rect, pub label: egui::Rect,
pub value: egui::Rect, pub value: egui::Rect,
pub color_value: egui::Rect,
pub channel: egui::Rect, pub channel: egui::Rect,
pub texture_group: egui::Rect, pub texture_group: egui::Rect,
pub texture_field: egui::Rect, pub texture_field: egui::Rect,
@ -60,8 +68,12 @@ impl PropertyGridGeometry {
egui::vec2(82.0, 22.0), egui::vec2(82.0, 22.0),
), ),
value: egui::Rect::from_min_size( value: egui::Rect::from_min_size(
row.min + egui::vec2(96.0, 5.0), row.min + egui::vec2(103.35, 5.0),
egui::vec2(132.0, 22.0), egui::vec2(132.8, 22.0),
),
color_value: egui::Rect::from_min_size(
row.min + egui::vec2(107.95, 5.0),
egui::vec2(182.55, 22.0),
), ),
channel: egui::Rect::from_min_size( channel: egui::Rect::from_min_size(
row.min + egui::vec2(234.0, 5.0), row.min + egui::vec2(234.0, 5.0),
@ -86,8 +98,10 @@ impl PropertyGridGeometry {
let channel_width = 48.0_f32.min(inner_width); let channel_width = 48.0_f32.min(inner_width);
let channel_x = inset + inner_width - channel_width; let channel_x = inset + inner_width - channel_width;
let value_x = (inset + 84.0).min(channel_x); let value_x = (inset + 91.35).min(channel_x);
let value_width = (channel_x - value_x - 6.35).max(1.0); let value_width = (channel_x - value_x - 6.35).max(1.0);
let color_value_x = (inset + 95.95).min(channel_x);
let color_value_width = (inset + inner_width - color_value_x).max(1.0);
let texture_group = egui::Rect::from_min_size( let texture_group = egui::Rect::from_min_size(
row.min + egui::vec2(inset, 31.0), row.min + egui::vec2(inset, 31.0),
egui::vec2(inner_width, 22.0), egui::vec2(inner_width, 22.0),
@ -109,6 +123,10 @@ impl PropertyGridGeometry {
row.min + egui::vec2(value_x, 5.0), row.min + egui::vec2(value_x, 5.0),
egui::vec2(value_width, 22.0), egui::vec2(value_width, 22.0),
), ),
color_value: egui::Rect::from_min_size(
row.min + egui::vec2(color_value_x, 5.0),
egui::vec2(color_value_width, 22.0),
),
channel: egui::Rect::from_min_size( channel: egui::Rect::from_min_size(
row.min + egui::vec2(channel_x, 5.0), row.min + egui::vec2(channel_x, 5.0),
egui::vec2(channel_width, 22.0), egui::vec2(channel_width, 22.0),
@ -151,8 +169,15 @@ mod tests {
} }
#[test] #[test]
fn penpot_below_reference_width_reflows_without_compression() { fn native_wide_containment_threshold_keeps_reference_rows() {
let geometry = PropertyGridGeometry::for_width(568.0); let geometry = PropertyGridGeometry::for_width(559.0);
assert!(!geometry.stacked);
assert_eq!(geometry.row_height, 32.0);
}
#[test]
fn below_native_wide_containment_threshold_reflows_without_compression() {
let geometry = PropertyGridGeometry::for_width(558.0);
assert!(geometry.stacked); assert!(geometry.stacked);
assert_eq!(geometry.row_height, 58.0); assert_eq!(geometry.row_height, 58.0);
} }
@ -162,9 +187,11 @@ mod tests {
let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(569.0, 32.0)); let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(569.0, 32.0));
let rects = PropertyGridGeometry::for_width(569.0).row_rects(row); let rects = PropertyGridGeometry::for_width(569.0).row_rects(row);
assert_eq!(rects.label.min, egui::pos2(12.0, 5.0)); assert_eq!(rects.label.min, egui::pos2(12.0, 5.0));
assert_eq!(rects.value.min, egui::pos2(96.0, 5.0)); assert_eq!(rects.value.min, egui::pos2(103.35, 5.0));
assert!((rects.value.width() - 132.0).abs() < 0.001); assert!((rects.value.width() - 132.8).abs() < 0.001);
assert_eq!(rects.value.height(), 22.0); assert_eq!(rects.value.height(), 22.0);
assert_eq!(rects.color_value.min, egui::pos2(107.95, 5.0));
assert!((rects.color_value.width() - 182.55).abs() < 0.001);
assert_eq!( assert_eq!(
rects.channel, rects.channel,
egui::Rect::from_min_size(egui::pos2(234.0, 5.0), egui::vec2(48.0, 22.0)) egui::Rect::from_min_size(egui::pos2(234.0, 5.0), egui::vec2(48.0, 22.0))
@ -184,7 +211,10 @@ mod tests {
let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(369.0, 58.0)); let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(369.0, 58.0));
let rects = PropertyGridGeometry::for_width(369.0).row_rects(row); let rects = PropertyGridGeometry::for_width(369.0).row_rects(row);
assert_eq!(rects.label.min, egui::pos2(12.0, 5.0)); assert_eq!(rects.label.min, egui::pos2(12.0, 5.0));
assert_eq!(rects.value.min, egui::pos2(96.0, 5.0)); assert_eq!(rects.value.min, egui::pos2(103.35, 5.0));
assert!((rects.value.width() - 199.3).abs() < 0.001);
assert_eq!(rects.color_value.min, egui::pos2(107.95, 5.0));
assert!((rects.color_value.width() - 249.05).abs() < 0.001);
assert_eq!( assert_eq!(
rects.channel, rects.channel,
egui::Rect::from_min_size(egui::pos2(309.0, 5.0), egui::vec2(48.0, 22.0)) egui::Rect::from_min_size(egui::pos2(309.0, 5.0), egui::vec2(48.0, 22.0))
@ -207,6 +237,7 @@ mod tests {
for rect in [ for rect in [
rects.label, rects.label,
rects.value, rects.value,
rects.color_value,
rects.channel, rects.channel,
rects.texture_group, rects.texture_group,
rects.texture_field, rects.texture_field,
@ -239,6 +270,7 @@ mod tests {
let wide = PropertyGridGeometry::for_width(669.0).row_rects(wide_row); let wide = PropertyGridGeometry::for_width(669.0).row_rects(wide_row);
assert_eq!(wide.label, reference.label); assert_eq!(wide.label, reference.label);
assert_eq!(wide.value, reference.value); assert_eq!(wide.value, reference.value);
assert_eq!(wide.color_value, reference.color_value);
assert_eq!(wide.channel, reference.channel); assert_eq!(wide.channel, reference.channel);
assert_eq!( assert_eq!(
wide.texture_field.width(), wide.texture_field.width(),

View File

@ -2,10 +2,6 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy_egui::{EguiContext, PrimaryEguiContext}; use bevy_egui::{EguiContext, PrimaryEguiContext};
use std::sync::Arc;
pub(crate) const SOURCE_SANS_REGULAR: &str = "blacksite-source-sans-regular";
pub(crate) const SOURCE_SANS_BOLD: &str = "blacksite-source-sans-bold";
pub struct EditorFontsPlugin; pub struct EditorFontsPlugin;
@ -23,55 +19,5 @@ fn configure_editor_fonts(mut contexts: Query<&mut EguiContext, Added<PrimaryEgu
} }
pub(crate) fn editor_font_definitions() -> bevy_egui::egui::FontDefinitions { pub(crate) fn editor_font_definitions() -> bevy_egui::egui::FontDefinitions {
let mut fonts = bevy_egui::egui::FontDefinitions::default(); editor_ui::fonts::font_definitions()
egui_phosphor_icons::add_fonts(&mut fonts);
fonts.font_data.insert(
SOURCE_SANS_REGULAR.to_owned(),
Arc::new(bevy_egui::egui::FontData::from_static(include_bytes!(
"../../assets/fonts/source-sans/SourceSansPro-Regular.ttf"
))),
);
fonts.font_data.insert(
SOURCE_SANS_BOLD.to_owned(),
Arc::new(bevy_egui::egui::FontData::from_static(include_bytes!(
"../../assets/fonts/source-sans/SourceSansPro-Bold.ttf"
))),
);
let proportional = fonts
.families
.entry(bevy_egui::egui::FontFamily::Proportional)
.or_default();
proportional.insert(0, SOURCE_SANS_REGULAR.to_owned());
fonts.families.insert(
bevy_egui::egui::FontFamily::Name(SOURCE_SANS_REGULAR.into()),
vec![SOURCE_SANS_REGULAR.to_owned()],
);
fonts.families.insert(
bevy_egui::egui::FontFamily::Name(SOURCE_SANS_BOLD.into()),
vec![SOURCE_SANS_BOLD.to_owned(), SOURCE_SANS_REGULAR.to_owned()],
);
// Phosphor families contain icon glyphs only. Text fallbacks give egui a
// replacement glyph for malformed/missing icons without changing icon lookup.
let text_fallbacks = fonts
.families
.get(&bevy_egui::egui::FontFamily::Proportional)
.cloned()
.unwrap_or_default();
for (family, font_names) in &mut fonts.families {
let bevy_egui::egui::FontFamily::Name(name) = family else {
continue;
};
if !name.starts_with("phosphor-") {
continue;
}
for fallback in &text_fallbacks {
if !font_names.contains(fallback) {
font_names.push(fallback.clone());
}
}
}
fonts
} }

View File

@ -1,12 +1,5 @@
use super::*; use super::*;
const COMPONENT_HEADER_HEIGHT: f32 = 50.0;
const COMPONENT_RAIL_X: f32 = 7.0;
const COMPONENT_CARET_CENTER_X: f32 = 26.0;
const COMPONENT_ICON_CENTER_X: f32 = 51.0;
const COMPONENT_IDENTITY_X: f32 = 69.0;
const COMPONENT_ACTIONS_WIDTH: f32 = 104.0;
pub(super) fn material_shader_ui(ui: &mut egui::Ui, material: &mut MaterialDesc) -> bool { pub(super) fn material_shader_ui(ui: &mut egui::Ui, material: &mut MaterialDesc) -> bool {
let mut changed = false; let mut changed = false;
property_row(ui, "Shader", |ui| { property_row(ui, "Shader", |ui| {
@ -137,201 +130,41 @@ pub(crate) fn component_card(
context: &ComponentCardContext, context: &ComponentCardContext,
add_contents: impl FnOnce(&mut egui::Ui), add_contents: impl FnOnce(&mut egui::Ui),
) -> ComponentCardResponse { ) -> ComponentCardResponse {
let mut response = ComponentCardResponse { let options = editor_ui::inspector::ComponentCardOptions {
type_name: context.options.type_name, type_name: context.options.type_name,
..Default::default() title: context.options.title,
icon: context.options.icon,
active_toggle: context.options.active_toggle,
removable: context.options.removable,
reorderable: context.options.reorderable,
resettable: context.options.resettable,
copyable: context.options.copyable,
summary: context.options.summary,
body_margin: context.options.body_margin,
}; };
crate::ui::design_system::scope(ui, |ui| { let shared = editor_ui::inspector::component_card(
egui::Frame::new() ui,
.fill(WIDGET_BG) &editor_ui::inspector::ComponentCardViewModel {
.stroke(egui::Stroke::new(1.0_f32, BORDER)) options,
.corner_radius(egui::CornerRadius::same(5)) active: context.active,
.show(ui, |ui| { collapsed: context.collapsed,
let width = ui.available_width().max(1.0); pasteable: context.pasteable,
let (header, header_response) = ui.allocate_exact_size( can_move_up: context.can_move_up,
egui::vec2(width, COMPONENT_HEADER_HEIGHT), can_move_down: context.can_move_down,
egui::Sense::click(),
);
ui.painter().rect_filled(
egui::Rect::from_min_size(
header.min + egui::vec2(COMPONENT_RAIL_X, 7.0),
egui::vec2(3.0, 36.0),
),
2.0,
crate::ui::theme::SELECTION,
);
let caret_rect = egui::Rect::from_center_size(
egui::pos2(header.left() + COMPONENT_CARET_CENTER_X, header.center().y),
egui::vec2(20.0, 28.0),
);
if ui
.put(
caret_rect,
egui::Button::new(phosphor_icon(
if context.collapsed {
icons::CARET_RIGHT
} else {
icons::CARET_DOWN
}, },
12.0, add_contents,
))
.frame(false),
)
.on_hover_text(if context.collapsed {
"Expand component"
} else {
"Collapse component"
})
.clicked()
{
response.collapsed = Some(!context.collapsed);
}
ui.painter().text(
egui::pos2(header.left() + COMPONENT_ICON_CENTER_X, header.center().y),
egui::Align2::CENTER_CENTER,
context.options.icon.as_str(),
egui::FontId::new(18.0, egui::FontFamily::Name("phosphor-regular".into())),
TEXT_DIM,
); );
ui.painter().text( ComponentCardResponse {
egui::pos2(header.left() + COMPONENT_IDENTITY_X, header.top() + 18.0), type_name: shared.type_name,
egui::Align2::LEFT_CENTER, collapsed: shared.collapsed,
context.options.title, active: shared.active,
crate::ui::design_system::typography::TypeRole::Title.font(), reset: shared.reset,
crate::ui::theme::TEXT, copy: shared.copy,
); paste: shared.paste,
ui.painter().text( move_up: shared.move_up,
egui::pos2(header.left() + COMPONENT_IDENTITY_X, header.top() + 34.0), move_down: shared.move_down,
egui::Align2::LEFT_CENTER, remove: shared.remove,
context.options.summary,
crate::ui::design_system::typography::TypeRole::Small.font(),
TEXT_MUTED,
);
let actions_rect = egui::Rect::from_min_size(
egui::pos2(
header.right() - COMPONENT_ACTIONS_WIDTH - 8.0,
header.top() + 11.0,
),
egui::vec2(COMPONENT_ACTIONS_WIDTH, 28.0),
);
ui.scope_builder(egui::UiBuilder::new().max_rect(actions_rect), |ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
component_actions_menu(ui, context, &mut response);
let label = if context.active { "Active" } else { "Off" };
let color = if context.active {
crate::ui::theme::SUCCESS
} else {
TEXT_MUTED
};
let active = ui
.horizontal(|ui| {
let dot = if context.options.active_toggle {
status_dot_button(ui, color, "Toggle component")
} else {
status_dot(ui, color, "Component active");
ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover())
};
ui.label(
crate::ui::design_system::typography::TypeRole::Small
.text(label)
.color(TEXT_DIM),
);
dot
})
.inner;
if context.options.active_toggle && active.clicked() {
response.active = Some(!context.active);
} }
});
});
if header_response.clicked()
&& ui
.input(|input| input.pointer.interact_pos())
.is_some_and(|pointer| {
!caret_rect.contains(pointer) && !actions_rect.contains(pointer)
})
{
response.collapsed = Some(!context.collapsed);
}
if !context.collapsed {
egui::Frame::new()
.inner_margin(egui::Margin {
left: context.options.body_margin,
right: context.options.body_margin,
top: 10,
bottom: context.options.body_margin,
})
.show(ui, |ui| {
ui.set_max_width(ui.available_width());
add_contents(ui);
});
}
});
});
ui.add_space(6.0);
response
}
fn component_actions_menu(
ui: &mut egui::Ui,
context: &ComponentCardContext,
response: &mut ComponentCardResponse,
) {
let menu = ui.menu_button(
phosphor_icon(icons::DOTS_THREE_VERTICAL, 16.0).color(TEXT_DIM),
|ui| {
ui.set_min_width(160.0);
if ui
.add_enabled(context.options.resettable, egui::Button::new("Reset"))
.clicked()
{
response.reset = true;
ui.close();
}
if ui
.add_enabled(context.options.copyable, egui::Button::new("Copy Values"))
.clicked()
{
response.copy = true;
ui.close();
}
if ui
.add_enabled(context.pasteable, egui::Button::new("Paste Values"))
.clicked()
{
response.paste = true;
ui.close();
}
ui.separator();
if ui
.add_enabled(context.can_move_up, egui::Button::new("Move Up"))
.clicked()
{
response.move_up = true;
ui.close();
}
if ui
.add_enabled(context.can_move_down, egui::Button::new("Move Down"))
.clicked()
{
response.move_down = true;
ui.close();
}
ui.separator();
ui.add_enabled(false, egui::Button::new("Open Documentation"));
if ui
.add_enabled(
context.options.removable,
egui::Button::new(egui::RichText::new("Remove").color(crate::ui::theme::ERROR)),
)
.clicked()
{
response.remove = true;
ui.close();
}
},
);
menu.response.on_hover_text("Component actions");
} }
pub(crate) fn property_row<R>( pub(crate) fn property_row<R>(
@ -501,18 +334,3 @@ pub fn material_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity)
set_material_with_history(world, entity, material); set_material_with_history(world, entity, material);
} }
} }
#[cfg(test)]
mod component_header_geometry_tests {
use super::*;
#[test]
fn current_penpot_inspector_header_geometry_is_exact() {
assert_eq!(COMPONENT_HEADER_HEIGHT, 50.0);
assert_eq!(COMPONENT_RAIL_X, 7.0);
assert_eq!(COMPONENT_CARET_CENTER_X, 26.0);
assert_eq!(COMPONENT_ICON_CENTER_X, 51.0);
assert_eq!(COMPONENT_IDENTITY_X, 69.0);
assert_eq!(COMPONENT_ACTIONS_WIDTH, 104.0);
}
}

View File

@ -10,27 +10,6 @@ pub(super) fn ensure_slot_id(entry: &mut StaticMeshRendererEntry, index: usize)
} }
} }
pub(super) fn status_dot(ui: &mut egui::Ui, color: egui::Color32, tooltip: &str) {
let (rect, response) = ui.allocate_exact_size(egui::vec2(18.0, 20.0), egui::Sense::hover());
ui.painter().circle_filled(rect.center(), 4.0, color);
response.on_hover_text(tooltip);
}
pub(super) fn status_dot_button(
ui: &mut egui::Ui,
color: egui::Color32,
tooltip: &str,
) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(egui::vec2(18.0, 20.0), egui::Sense::click());
let color = if response.hovered() {
color.linear_multiply(1.2)
} else {
color
};
ui.painter().circle_filled(rect.center(), 4.0, color);
response.on_hover_text(tooltip)
}
pub(super) fn material_drop_invalid_reason( pub(super) fn material_drop_invalid_reason(
world: &World, world: &World,
valid_material_candidate: bool, valid_material_candidate: bool,
@ -62,15 +41,26 @@ pub(super) struct MaterialSlotWidgetResponse {
pub(super) accepted_drop: bool, pub(super) accepted_drop: bool,
} }
pub(super) struct MaterialSlotWidgetContext<'a> {
pub(super) candidates: &'a [AssetRefCandidate],
pub(super) drop_candidate: Option<&'a AssetRefCandidate>,
pub(super) invalid_drop_reason: Option<&'static str>,
pub(super) expanded_header_when_parameters_closed: bool,
}
pub(super) fn material_slot_widget_ui( pub(super) fn material_slot_widget_ui(
world: &mut World, world: &mut World,
ui: &mut egui::Ui, ui: &mut egui::Ui,
entity: Entity, entity: Entity,
slot: &mut shared::MaterialSlot, slot: &mut shared::MaterialSlot,
candidates: &[AssetRefCandidate], context: MaterialSlotWidgetContext<'_>,
drop_candidate: Option<&AssetRefCandidate>,
invalid_drop_reason: Option<&'static str>,
) -> MaterialSlotWidgetResponse { ) -> MaterialSlotWidgetResponse {
let MaterialSlotWidgetContext {
candidates,
drop_candidate,
invalid_drop_reason,
expanded_header_when_parameters_closed,
} = context;
let mut response = MaterialSlotWidgetResponse::default(); let mut response = MaterialSlotWidgetResponse::default();
let actor_widget_id = stable_actor_widget_id(world, entity); let actor_widget_id = stable_actor_widget_id(world, entity);
let project_default = world let project_default = world
@ -165,6 +155,8 @@ pub(super) fn material_slot_widget_ui(
crate::ui::materials::MaterialThumbnailPresentation::Failed crate::ui::materials::MaterialThumbnailPresentation::Failed
} else if let Some(texture) = texture_id { } else if let Some(texture) = texture_id {
crate::ui::materials::MaterialThumbnailPresentation::Ready(texture) crate::ui::materials::MaterialThumbnailPresentation::Ready(texture)
} else if effective.is_none() {
crate::ui::materials::MaterialThumbnailPresentation::Empty
} else { } else {
crate::ui::materials::MaterialThumbnailPresentation::Pending crate::ui::materials::MaterialThumbnailPresentation::Pending
}; };
@ -227,6 +219,8 @@ pub(super) fn material_slot_widget_ui(
can_clear: slot.material.is_some(), can_clear: slot.material.is_some(),
can_extract: is_imported_source, can_extract: is_imported_source,
can_create_instance: is_project_material, can_create_instance: is_project_material,
parameters_initially_open: true,
expanded_header_when_parameters_closed,
}; };
let widget_slot_id = slot.id.0.clone(); let widget_slot_id = slot.id.0.clone();

View File

@ -81,9 +81,12 @@ pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, ent
ui, ui,
entity, entity,
slot, slot,
&material_candidates, MaterialSlotWidgetContext {
material_drop_candidate.as_ref(), candidates: &material_candidates,
invalid_material_drop, drop_candidate: material_drop_candidate.as_ref(),
invalid_drop_reason: invalid_material_drop,
expanded_header_when_parameters_closed: true,
},
); );
changed |= widget.changed; changed |= widget.changed;
accepted_drop |= widget.accepted_drop; accepted_drop |= widget.accepted_drop;
@ -172,7 +175,7 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
let card = component_card_context(world, entity, options); let card = component_card_context(world, entity, options);
let card_response = component_card(ui, &card, |ui| { let card_response = component_card(ui, &card, |ui| {
let add_slot = renderer_array_header(ui, renderer.slots.len()); let add_slot = renderer_array_header(ui, renderer.slots.len());
ui.add_space(10.0); ui.add_space(26.0);
if renderer.slots.is_empty() { if renderer.slots.is_empty() {
ui.label(egui::RichText::new("No renderer slots").color(TEXT_DIM)); ui.label(egui::RichText::new("No renderer slots").color(TEXT_DIM));
} }
@ -190,23 +193,57 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
renderer_panel(ui, |ui| { renderer_panel(ui, |ui| {
renderer_panel_header( renderer_panel_header(
ui, ui,
RendererPanelHeaderViewModel {
index, index,
&entry.name, label: &entry.name,
thumbnail, path: entry
.mesh
.source_path
.as_deref()
.unwrap_or(&entry.mesh.sub_asset_id),
slot_count: 1,
texture_id: thumbnail,
},
&mut renderer_open, &mut renderer_open,
|ui| { |ui| {
status_dot(ui, egui::Color32::from_rgb(74, 181, 104), "Slot active"); editor_ui::design_system::controls::icon_button(
if icon_button_small(ui, icons::TRASH, "Remove slot").clicked() { ui,
icons::DOTS_THREE_VERTICAL,
"Renderer actions",
true,
);
if editor_ui::design_system::controls::icon_button(
ui,
icons::TRASH,
"Remove slot",
true,
)
.clicked()
{
remove_entry = Some(index); remove_entry = Some(index);
} }
ui.label(phosphor_icon(icons::DOTS_THREE_VERTICAL, 16.0).color(TEXT_DIM)); editor_ui::design_system::controls::icon_button(
ui,
icons::CROSSHAIR,
"Locate mesh",
true,
);
editor_ui::design_system::controls::icon_button(
ui,
icons::FOLDER_OPEN,
"Browse mesh",
true,
);
}, },
); );
if !renderer_open { if !renderer_open {
return; return;
} }
let mut draw_fields = |ui: &mut egui::Ui, entry: &mut StaticMeshRendererEntry| { egui::Frame::new()
.inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| {
renderer_properties_header(ui); renderer_properties_header(ui);
ui.add_space(2.0);
property_row(ui, "Name", |ui| { property_row(ui, "Name", |ui| {
changed |= ui changed |= ui
.add_sized( .add_sized(
@ -215,6 +252,7 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
) )
.changed(); .changed();
}); });
ui.add_space(4.0);
let mesh_response = asset_selector_row( let mesh_response = asset_selector_row(
ui, ui,
"Mesh", "Mesh",
@ -234,7 +272,7 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
if mesh_response.locate { if mesh_response.locate {
locate_asset_ref(world, Some(&entry.mesh), &mesh_candidates); locate_asset_ref(world, Some(&entry.mesh), &mesh_candidates);
} }
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
changed |= ui.checkbox(&mut entry.visible, "Visible").changed(); changed |= ui.checkbox(&mut entry.visible, "Visible").changed();
changed |= ui changed |= ui
@ -244,11 +282,8 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
.checkbox(&mut entry.receive_shadows, "Receive shadows") .checkbox(&mut entry.receive_shadows, "Receive shadows")
.changed(); .changed();
}); });
}; ui.add_space(4.0);
});
egui::Frame::new()
.inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| draw_fields(ui, entry));
renderer_material_slots_header(ui, 1); renderer_material_slots_header(ui, 1);
if let Some(slot) = material_set.slot_mut(&entry.material_slot_id) { if let Some(slot) = material_set.slot_mut(&entry.material_slot_id) {
let widget = renderer_material_slot_row(ui, index, |ui| { let widget = renderer_material_slot_row(ui, index, |ui| {
@ -257,9 +292,12 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
ui, ui,
entity, entity,
slot, slot,
&material_candidates, MaterialSlotWidgetContext {
material_drop_candidate.as_ref(), candidates: &material_candidates,
invalid_material_drop, drop_candidate: material_drop_candidate.as_ref(),
invalid_drop_reason: invalid_material_drop,
expanded_header_when_parameters_closed: true,
},
) )
}); });
changed |= widget.changed; changed |= widget.changed;
@ -317,109 +355,3 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
set_static_mesh_renderer_with_history(world, entity, renderer); set_static_mesh_renderer_with_history(world, entity, renderer);
} }
} }
fn renderer_array_header(ui: &mut egui::Ui, count: usize) -> bool {
let palette = crate::ui::design_system::palette(ui);
let (rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width().max(1.0), 40.0),
egui::Sense::hover(),
);
ui.painter().rect_filled(rect, 4.0, palette.elevated);
ui.painter().text(
rect.left_center() + egui::vec2(12.0, 0.0),
egui::Align2::LEFT_CENTER,
icons::SQUARES_FOUR.as_str(),
egui::FontId::new(16.0, egui::FontFamily::Name("phosphor-regular".into())),
palette.text_secondary,
);
ui.painter().text(
rect.left_center() + egui::vec2(36.0, 0.0),
egui::Align2::LEFT_CENTER,
"Mesh renderers",
crate::ui::design_system::typography::TypeRole::Section.font(),
palette.text_primary,
);
ui.painter().text(
rect.left_center() + egui::vec2(132.0, 0.0),
egui::Align2::LEFT_CENTER,
format!("{count}"),
crate::ui::design_system::typography::TypeRole::Small.font(),
palette.text_muted,
);
let add_rect = egui::Rect::from_min_size(
egui::pos2(rect.right() - 116.0, rect.top() + 7.0),
egui::vec2(104.0, 26.0),
);
ui.put(
add_rect,
egui::Button::new(crate::ui::design_system::controls::icon_label(
icons::PLUS,
"Add renderer",
crate::ui::design_system::typography::TypeRole::Body,
12.0,
palette.text_primary,
)),
)
.clicked()
}
fn renderer_material_slots_header(ui: &mut egui::Ui, count: usize) {
let palette = crate::ui::design_system::palette(ui);
let (rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width().max(1.0), 38.0),
egui::Sense::hover(),
);
ui.painter().text(
rect.left_center() + egui::vec2(10.0, 0.0),
egui::Align2::LEFT_CENTER,
"Material slots",
crate::ui::design_system::typography::TypeRole::Section.font(),
palette.text_primary,
);
ui.painter().text(
rect.right_center() - egui::vec2(10.0, 0.0),
egui::Align2::RIGHT_CENTER,
format!("{count}"),
crate::ui::design_system::typography::TypeRole::Small.font(),
palette.text_muted,
);
}
fn renderer_properties_header(ui: &mut egui::Ui) {
let palette = crate::ui::design_system::palette(ui);
let (rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width().max(1.0), 18.0),
egui::Sense::hover(),
);
ui.painter().text(
rect.left_center(),
egui::Align2::LEFT_CENTER,
"Renderer properties",
crate::ui::design_system::typography::TypeRole::Caption.font(),
palette.text_muted,
);
}
fn renderer_material_slot_row<R>(
ui: &mut egui::Ui,
index: usize,
add_contents: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
let palette = crate::ui::design_system::palette(ui);
ui.horizontal_top(|ui| {
let (index_rect, _) = ui.allocate_exact_size(egui::vec2(31.0, 60.0), egui::Sense::hover());
ui.painter().text(
index_rect.center_top() + egui::vec2(0.0, 18.0),
egui::Align2::CENTER_CENTER,
index.to_string(),
crate::ui::design_system::typography::TypeRole::Body.font(),
palette.text_muted,
);
ui.vertical(|ui| {
ui.set_width(ui.available_width().max(1.0));
add_contents(ui)
})
.inner
})
.inner
}

View File

@ -65,9 +65,12 @@ pub(super) fn primitive_editor_ui(world: &mut World, ui: &mut egui::Ui, entity:
ui, ui,
entity, entity,
&mut primitive.surface, &mut primitive.surface,
&material_candidates, MaterialSlotWidgetContext {
material_drop_candidate.as_ref(), candidates: &material_candidates,
invalid_material_drop, drop_candidate: material_drop_candidate.as_ref(),
invalid_drop_reason: invalid_material_drop,
expanded_header_when_parameters_closed: false,
},
) )
}); });
changed |= widget.changed; changed |= widget.changed;

View File

@ -1,124 +1,5 @@
//! Penpot renderer-panel chrome shared by static renderer entries. pub(super) use editor_ui::renderer::{
renderer_array_header, renderer_material_slot_row, renderer_material_slots_header,
use super::*; renderer_panel, renderer_panel_header, renderer_properties_header,
RendererPanelHeaderViewModel,
const EXPANDED_RENDERER_HEADER_HEIGHT: f32 = 84.0;
const COLLAPSED_RENDERER_HEADER_HEIGHT: f32 = 58.0;
pub(super) fn renderer_panel(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui)) {
egui::Frame::new()
.fill(WIDGET_BG.linear_multiply(0.82))
.stroke(egui::Stroke::new(1.0_f32, BORDER))
.corner_radius(egui::CornerRadius::same(5))
.show(ui, |ui| {
ui.set_max_width(ui.available_width());
add_contents(ui);
});
ui.add_space(10.0);
}
pub(super) fn renderer_panel_header(
ui: &mut egui::Ui,
index: usize,
slot_name: &str,
texture_id: Option<egui::TextureId>,
open: &mut bool,
add_actions: impl FnOnce(&mut egui::Ui),
) {
let palette = crate::ui::design_system::palette(ui);
let height = if *open {
EXPANDED_RENDERER_HEADER_HEIGHT
} else {
COLLAPSED_RENDERER_HEADER_HEIGHT
}; };
let (rect, header_response) = ui.allocate_exact_size(
egui::vec2(ui.available_width().max(1.0), height),
egui::Sense::click(),
);
ui.painter().rect_filled(
egui::Rect::from_min_size(
rect.min + egui::vec2(6.0, 8.0),
egui::vec2(3.0, height - 16.0),
),
2.0,
palette.accent,
);
ui.painter().text(
rect.left_center() + egui::vec2(20.0, 0.0),
egui::Align2::CENTER_CENTER,
if *open {
icons::CARET_DOWN.as_str()
} else {
icons::CARET_RIGHT.as_str()
},
egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())),
palette.text_secondary,
);
let preview_size = if *open { 58.0 } else { 40.0 };
let preview = egui::Rect::from_center_size(
egui::pos2(rect.left() + 63.0, rect.center().y),
egui::vec2(preview_size, preview_size),
);
ui.painter().rect(
preview,
4.0,
PANEL_BG_DARK,
egui::Stroke::new(1.0_f32, BORDER),
egui::StrokeKind::Inside,
);
if let Some(texture_id) = texture_id {
ui.painter().image(
texture_id,
preview.shrink(3.0),
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
let label = if slot_name.trim().is_empty() {
"Mesh renderer"
} else {
slot_name.trim()
};
ui.painter().text(
egui::pos2(rect.left() + 104.0, rect.center().y - 8.0),
egui::Align2::LEFT_CENTER,
label,
crate::ui::design_system::typography::TypeRole::Title.font(),
palette.text_primary,
);
ui.painter().text(
egui::pos2(rect.left() + 104.0, rect.center().y + 10.0),
egui::Align2::LEFT_CENTER,
format!("Renderer {index}"),
crate::ui::design_system::typography::TypeRole::Small.font(),
palette.text_muted,
);
let action_rect = egui::Rect::from_min_size(
egui::pos2(rect.right() - 150.0, rect.center().y - 14.0),
egui::vec2(140.0, 28.0),
);
ui.scope_builder(egui::UiBuilder::new().max_rect(action_rect), |ui| {
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
add_actions,
);
});
if header_response.clicked()
&& ui
.input(|input| input.pointer.interact_pos())
.is_some_and(|pointer| !preview.contains(pointer) && !action_rect.contains(pointer))
{
*open = !*open;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renderer_headers_match_current_penpot_heights() {
assert_eq!(EXPANDED_RENDERER_HEADER_HEIGHT, 84.0);
assert_eq!(COLLAPSED_RENDERER_HEADER_HEIGHT, 58.0);
}
}

View File

@ -55,7 +55,7 @@ pub(super) fn unsupported_advanced_preview(ui: &mut egui::Ui) {
} else { } else {
icons::CARET_RIGHT.as_str() icons::CARET_RIGHT.as_str()
}, },
egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-regular".into())), egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.text_secondary, palette.text_secondary,
); );
ui.painter().text( ui.painter().text(

View File

@ -23,7 +23,7 @@ pub(crate) fn material_asset_editor(
.and_then(|selection| { .and_then(|selection| {
texture_assets texture_assets
.iter() .iter()
.find(|candidate| candidate.selection == *selection) .find(|candidate| material_locate_matches_selection(&candidate.locate, selection))
}) })
.cloned(); .cloned();
let original = draft_asset.clone(); let original = draft_asset.clone();
@ -53,7 +53,7 @@ pub(crate) fn material_asset_editor(
&mut draft_asset.render_state, &mut draft_asset.render_state,
Some(&mut draft_asset.inputs), Some(&mut draft_asset.inputs),
); );
ui.add_space(8.0); ui.add_space(4.0);
let input_response = material_input_schema_editor( let input_response = material_input_schema_editor(
ui, ui,
&input_schema, &input_schema,
@ -62,8 +62,10 @@ pub(crate) fn material_asset_editor(
texture_drop_candidate.as_ref(), texture_drop_candidate.as_ref(),
); );
accepted_texture_drop |= input_response.accepted_drop; accepted_texture_drop |= input_response.accepted_drop;
if let Some(selection) = input_response.locate { if let Some(target) = input_response.locate {
world.resource_mut::<EditorAssets>().select(selection); world
.resource_mut::<EditorAssets>()
.select(material_locate_selection(target));
crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser); crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser);
} }
if input_response.browse_library { if input_response.browse_library {
@ -112,117 +114,3 @@ fn material_source_diagnostics_ui(ui: &mut egui::Ui, provenance: &shared::Materi
}); });
ui.small("Reimport comparison never overwrites an edited project material automatically."); ui.small("Reimport comparison never overwrites an edited project material automatically.");
} }
pub(super) fn material_surface_options_ui(
ui: &mut egui::Ui,
state: &mut shared::MaterialRenderState,
packed_inputs: Option<&mut shared::MaterialInputSet>,
) {
let palette = crate::ui::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::SURFACE_SECTION_HEIGHT
}
super::layout::ResponsiveMaterialLayout::Compact => {
crate::ui::design_system::COMPACT_SURFACE_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 geometry = super::layout::SurfaceSectionGeometry::for_rect(rect);
ui.painter().text(
geometry.title.left_center(),
egui::Align2::LEFT_CENTER,
"Surface",
crate::ui::design_system::typography::TypeRole::Section.font(),
palette.text_primary,
);
clipped_rect_ui(ui, geometry.blend, |ui| {
surface_blend_controls(ui, state);
});
if let Some(inputs) = packed_inputs {
clipped_rect_ui(ui, geometry.packed, |ui| {
packed_mode_controls(ui, inputs);
});
}
ui.painter().text(
geometry.double_sided.left_center() + egui::vec2(7.0, 0.0),
egui::Align2::LEFT_CENTER,
"Double Sided",
crate::ui::design_system::typography::TypeRole::Body.font(),
palette.text_secondary,
);
let switch_rect = egui::Rect::from_min_size(
geometry.double_sided.min + egui::vec2(76.0, 3.0),
egui::vec2(31.0, 16.0),
);
clipped_rect_ui(ui, switch_rect, |ui| {
crate::ui::design_system::controls::switch(ui, &mut state.double_sided)
.on_hover_text("Render both sides of the surface");
});
}
fn packed_mode_controls(ui: &mut egui::Ui, inputs: &mut shared::MaterialInputSet) {
let arm = super::inputs::packed_map_mode(inputs) == super::inputs::PackedMapMode::Arm;
match crate::ui::design_system::controls::two_option_segmented(ui, "Separate", "ORM", arm) {
Some(false) => super::inputs::apply_separate_texture_preset(inputs),
Some(true) => super::inputs::apply_arm_texture_preset(inputs),
None => {}
}
}
fn surface_blend_controls(ui: &mut egui::Ui, state: &mut shared::MaterialRenderState) {
let width = ui.available_width().max(1.0);
let show_cutoff = state.alpha_mode == MaterialAlphaMode::Cutout;
let combo_width = if show_cutoff {
(width - 58.0).max(1.0)
} else {
width
};
let combo_rect = egui::Rect::from_min_size(ui.min_rect().min, egui::vec2(combo_width, 24.0));
clipped_rect_ui(ui, combo_rect, |ui| {
egui::ComboBox::from_id_salt("material_alpha_mode")
.selected_text(match state.alpha_mode {
MaterialAlphaMode::Opaque => "Opaque",
MaterialAlphaMode::Cutout => "Cutout",
})
.width(combo_width)
.show_ui(ui, |ui| {
ui.selectable_value(&mut state.alpha_mode, MaterialAlphaMode::Opaque, "Opaque");
ui.selectable_value(&mut state.alpha_mode, MaterialAlphaMode::Cutout, "Cutout");
});
});
if show_cutoff {
let cutoff_rect = egui::Rect::from_min_size(
ui.min_rect().min + egui::vec2(combo_width + 4.0, 1.0),
egui::vec2(54.0_f32.min(width - combo_width - 4.0), 22.0),
);
ui.put(
cutoff_rect,
egui::DragValue::new(&mut state.alpha_cutoff)
.range(0.0..=1.0)
.speed(0.01),
)
.on_hover_text("Alpha cutoff");
}
}
fn clipped_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
}

View File

@ -269,7 +269,7 @@ pub(super) fn material_texture_candidates(world: &mut World) -> Vec<MaterialText
label: asset.label.clone(), label: asset.label.clone(),
detail: path.to_string(), detail: path.to_string(),
reference, reference,
selection: AssetSelection::File(path.to_string()), locate: MaterialLocateTarget::File(path.to_string()),
thumbnail: thumbnails.as_ref().map_or( thumbnail: thumbnails.as_ref().map_or(
MaterialThumbnailPresentation::Pending, MaterialThumbnailPresentation::Pending,
|cache| { |cache| {
@ -325,11 +325,10 @@ pub(super) fn material_texture_candidates(world: &mut World) -> Vec<MaterialText
label: label.clone(), label: label.clone(),
detail: format!("{} | {source_path}", asset.label), detail: format!("{} | {source_path}", asset.label),
reference, reference,
selection: AssetSelection::SubAsset { locate: MaterialLocateTarget::SubAsset {
parent_path: parent_path.to_string(), parent_path: parent_path.to_string(),
sub_asset_id, sub_asset_id,
label, label,
kind: AssetSubAssetKind::Texture,
source_path: Some(source_path.clone()), source_path: Some(source_path.clone()),
}, },
thumbnail: MaterialThumbnailPresentation::Pending, thumbnail: MaterialThumbnailPresentation::Pending,

View File

@ -220,12 +220,21 @@ pub(super) fn material_input_rows(
); );
let rects = geometry.row_rects(row_rect); let rects = geometry.row_rects(row_rect);
rect_ui(ui, rects.label, |ui| material_input_label(ui, property)); rect_ui(ui, rects.label, |ui| material_input_label(ui, property));
rect_ui(ui, rects.value, |ui| { let is_color = matches!(property.property_type, ShaderPropertyType::Color);
material_input_value_ui(ui, property, companion, inputs) 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| { rect_ui(ui, rects.channel, |ui| {
material_input_channel_ui(ui, property, inputs) material_input_channel_ui(ui, property, inputs)
}); });
}
if material_input_uses_texture(property) { if material_input_uses_texture(property) {
let texture = rect_ui(ui, rects.texture_group, |ui| { let texture = rect_ui(ui, rects.texture_group, |ui| {
material_input_texture_ui( material_input_texture_ui(
@ -518,7 +527,7 @@ fn readonly_channel(ui: &mut egui::Ui, channel: &str) {
rect.right_center() - egui::vec2(8.0, 0.0), rect.right_center() - egui::vec2(8.0, 0.0),
egui::Align2::CENTER_CENTER, egui::Align2::CENTER_CENTER,
egui_phosphor_icons::icons::CARET_DOWN.as_str(), egui_phosphor_icons::icons::CARET_DOWN.as_str(),
egui::FontId::new(9.0, egui::FontFamily::Name("phosphor-regular".into())), egui::FontId::new(9.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.text_muted, palette.text_muted,
); );
response.on_hover_text("Channel is fixed by this material input"); response.on_hover_text("Channel is fixed by this material input");

View File

@ -29,7 +29,7 @@ pub(crate) fn material_instance_asset_editor(
.and_then(|selection| { .and_then(|selection| {
texture_assets texture_assets
.iter() .iter()
.find(|candidate| candidate.selection == *selection) .find(|candidate| material_locate_matches_selection(&candidate.locate, selection))
}) })
.cloned(); .cloned();
let original = draft.clone(); let original = draft.clone();
@ -58,13 +58,9 @@ pub(crate) fn material_instance_asset_editor(
} }
ui.add_enabled_ui(false, |ui| { ui.add_enabled_ui(false, |ui| {
let mut render_state = base_asset.render_state; let mut render_state = base_asset.render_state;
super::asset_editor::material_surface_options_ui( material_surface_options_ui(ui, &mut render_state, Some(&mut draft.overrides));
ui,
&mut render_state,
Some(&mut draft.overrides),
);
}); });
ui.add_space(8.0); ui.add_space(4.0);
let input_response = super::instance_inputs::material_instance_input_schema_editor( let input_response = super::instance_inputs::material_instance_input_schema_editor(
ui, ui,
&schema, &schema,
@ -73,8 +69,10 @@ pub(crate) fn material_instance_asset_editor(
&texture_assets, &texture_assets,
texture_drop_candidate.as_ref(), texture_drop_candidate.as_ref(),
); );
if let Some(selection) = input_response.locate.clone() { if let Some(target) = input_response.locate.clone() {
world.resource_mut::<EditorAssets>().select(selection); world
.resource_mut::<EditorAssets>()
.select(material_locate_selection(target));
crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser); crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser);
} }
if input_response.browse_library { if input_response.browse_library {

View File

@ -14,7 +14,7 @@ pub(super) fn material_instance_input_schema_editor(
let mut effective = normalized_base.clone(); let mut effective = normalized_base.clone();
overlay_inputs(&mut effective, overrides); overlay_inputs(&mut effective, overrides);
let response = super::inputs::material_input_schema_editor_with_inheritance( let response = material_input_schema_editor_with_inheritance(
ui, ui,
schema, schema,
&mut effective, &mut effective,
@ -110,6 +110,7 @@ fn upsert_texture(textures: &mut Vec<MaterialTextureBinding>, texture: MaterialT
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use shared::MaterialParameterValue;
#[test] #[test]
fn instance_editor_retains_only_values_different_from_base() { fn instance_editor_retains_only_values_different_from_base() {

View File

@ -7,14 +7,12 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::path::Path; use std::path::Path;
use bevy::prelude::{Vec2, Vec3, World}; use bevy::prelude::World;
use bevy_egui::egui; use bevy_egui::egui;
use egui_phosphor_icons::{icons, Icon};
use shared::{ use shared::{
standard_lit_input_schema, ColorDesc, EditorAssetRef, MaterialAlphaMode, MaterialAsset, standard_lit_input_schema, EditorAssetRef, MaterialAsset, MaterialInputSchema,
MaterialInputDesc, MaterialInputSchema, MaterialInputSet, MaterialInstanceAsset, MaterialInputSet, MaterialInstanceAsset, MaterialParameter, MaterialRef, MaterialShaderKind,
MaterialParameter, MaterialParameterValue, MaterialRef, MaterialShaderKind, MaterialTextureBinding, ShaderPropertyType, ShaderSchemaAsset,
MaterialTextureBinding, ShaderPropertyType, ShaderSchemaAsset, TextureChannel,
}; };
use crate::asset_db::{ensure_asset_record, find_asset_by_path, save_registry}; use crate::asset_db::{ensure_asset_record, find_asset_by_path, save_registry};
@ -29,17 +27,10 @@ use crate::ui::document_status::authored_document_status_ui;
use super::theme::{ERROR, TEXT_DIM, TEXT_MUTED, WARNING}; use super::theme::{ERROR, TEXT_DIM, TEXT_MUTED, WARNING};
mod advanced_preview;
mod asset_editor; mod asset_editor;
mod editor; mod editor;
mod inputs;
mod instance_editor; mod instance_editor;
mod instance_inputs; mod instance_inputs;
mod layout;
mod panel;
mod pickers;
mod texture_inputs;
mod uv_transform;
pub(crate) use asset_editor::material_asset_editor; pub(crate) use asset_editor::material_asset_editor;
pub(crate) use editor::{ pub(crate) use editor::{
@ -47,67 +38,57 @@ pub(crate) use editor::{
material_shader_label, set_material_shader_kind, material_shader_label, set_material_shader_kind,
}; };
use editor::{material_shader_settings, material_texture_candidates, shader_schema_assets}; use editor::{material_shader_settings, material_texture_candidates, shader_schema_assets};
pub(crate) use inputs::material_input_schema_editor; pub(crate) use editor_ui::materials::{
use inputs::*; compact_text_edit, default_value_for_shader_property, ensure_material_texture_binding,
pub(crate) use instance_editor::material_instance_asset_editor; material_input_schema_editor, material_input_schema_editor_with_inheritance,
pub(crate) use panel::{ material_slot_panel, material_surface_options_ui, materials_section, optional_path_edit,
material_slot_panel, materials_section, MaterialHealth, MaterialLayerBadge, parameter_value_matches_property, shader_kind_picker, MaterialHealth,
MaterialPickerCandidate, MaterialSlotAction, MaterialSlotPanelViewModel, MaterialInputEditorResponse, MaterialLayerBadge, MaterialLocateTarget, MaterialPickerCandidate,
MaterialsSectionViewModel, MaterialSlotAction, MaterialSlotPanelViewModel, MaterialTextureCandidate,
MaterialThumbnailPresentation, MaterialsSectionViewModel,
}; };
use texture_inputs::*; pub(crate) use instance_editor::material_instance_asset_editor;
const PHOSPHOR: &str = "phosphor-regular";
#[derive(Clone, Debug)]
pub(crate) struct MaterialTextureCandidate {
label: String,
detail: String,
reference: EditorAssetRef,
selection: AssetSelection,
thumbnail: MaterialThumbnailPresentation,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum MaterialThumbnailPresentation {
Ready(egui::TextureId),
Pending,
Failed,
}
#[derive(Clone, Copy)]
enum TextureSlotSelection<'a> {
Reference(Option<&'a EditorAssetRef>),
}
#[derive(Default)]
struct TextureSlotResponse {
selected: Option<MaterialTextureCandidate>,
locate: Option<AssetSelection>,
clear: bool,
accepted_drop: bool,
browse_library: bool,
}
#[derive(Default)]
pub(crate) struct MaterialInputEditorResponse {
pub accepted_drop: bool,
pub locate: Option<AssetSelection>,
pub browse_library: bool,
}
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 natural_cmp(left: &str, right: &str) -> std::cmp::Ordering { fn natural_cmp(left: &str, right: &str) -> std::cmp::Ordering {
left.to_ascii_lowercase().cmp(&right.to_ascii_lowercase()) left.to_ascii_lowercase().cmp(&right.to_ascii_lowercase())
} }
fn material_locate_selection(target: MaterialLocateTarget) -> AssetSelection {
match target {
MaterialLocateTarget::File(path) => AssetSelection::File(path),
MaterialLocateTarget::SubAsset {
parent_path,
sub_asset_id,
label,
source_path,
} => AssetSelection::SubAsset {
parent_path,
sub_asset_id,
label,
kind: AssetSubAssetKind::Texture,
source_path,
},
}
}
fn material_locate_matches_selection(
target: &MaterialLocateTarget,
selection: &AssetSelection,
) -> bool {
match (target, selection) {
(MaterialLocateTarget::File(left), AssetSelection::File(right)) => left == right,
(
MaterialLocateTarget::SubAsset {
parent_path: left_parent,
sub_asset_id: left_sub_asset,
..
},
AssetSelection::SubAsset {
parent_path: right_parent,
sub_asset_id: right_sub_asset,
..
},
) => left_parent == right_parent && left_sub_asset == right_sub_asset,
_ => false,
}
}

View File

@ -6,14 +6,18 @@ use shared::{EditorAssetRef, MaterialShaderKind};
use super::layout::MaterialPanelGeometry; use super::layout::MaterialPanelGeometry;
use super::MaterialThumbnailPresentation; use super::MaterialThumbnailPresentation;
use crate::ui::design_system::controls::{icon_button, icon_label, status}; use crate::ui::design_system::controls::status;
use crate::ui::design_system::typography::TypeRole; use crate::ui::design_system::typography::TypeRole;
use crate::ui::design_system::{self, DesignPalette, HEADER_HEIGHT}; use crate::ui::design_system::{self, DesignPalette, HEADER_HEIGHT};
use crate::ui::widgets::phosphor_icon;
mod header_actions;
use header_actions::{expanded_status_actions, overflow_menu, SLOT_STATUS_ACTION_WIDTH};
const SLOT_ACCENT_X: f32 = 8.0; const SLOT_ACCENT_X: f32 = 8.0;
const SLOT_CARET_CENTER_X: f32 = 23.0; const SLOT_CARET_CENTER_X: f32 = 23.0;
const SLOT_PREVIEW_X: f32 = 35.0; const SLOT_PREVIEW_X: f32 = 35.0;
const SHADER_CONTROL_WIDTH: f32 = 220.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MaterialLayerBadge { pub(crate) enum MaterialLayerBadge {
@ -73,6 +77,16 @@ impl MaterialHealth {
), ),
} }
} }
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)] #[derive(Debug, Clone)]
@ -275,16 +289,19 @@ fn slot_header(
egui::pos2(rect.left() + SLOT_CARET_CENTER_X, rect.center().y), egui::pos2(rect.left() + SLOT_CARET_CENTER_X, rect.center().y),
egui::vec2(18.0, 28.0), egui::vec2(18.0, 28.0),
); );
let caret = ui.put( let caret_icon = if *open {
caret_rect,
egui::Button::new(phosphor_icon(
if *open {
icons::CARET_DOWN icons::CARET_DOWN
} else { } else {
icons::CARET_RIGHT 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, 12.0,
)) egui::FontFamily::Name("phosphor-bold".into()),
)),
)
.frame(false), .frame(false),
); );
if caret.clicked() { if caret.clicked() {
@ -299,7 +316,7 @@ fn slot_header(
egui::vec2(preview_size, preview_size), egui::vec2(preview_size, preview_size),
); );
let preview = material_thumbnail_at(ui, view, preview_rect); let preview = material_thumbnail_at(ui, view, preview_rect);
let action_width = 140.0; let action_width = SLOT_STATUS_ACTION_WIDTH;
let identity_left = preview_rect.right() + 6.0; let identity_left = preview_rect.right() + 6.0;
let actions_left = rect.right() - action_width - 8.0; let actions_left = rect.right() - action_width - 8.0;
let identity_right = (identity_left + design_system::MATERIAL_IDENTITY_WIDTH) let identity_right = (identity_left + design_system::MATERIAL_IDENTITY_WIDTH)
@ -323,42 +340,20 @@ fn slot_header(
}) })
.inner; .inner;
let actions_rect = egui::Rect::from_min_size( let actions_rect = egui::Rect::from_min_size(
egui::pos2(actions_left, rect.top() + 7.0), egui::pos2(actions_left, rect.top() + if *open { 9.0 } else { 7.0 }),
egui::vec2(action_width, height - 14.0), egui::vec2(action_width, if *open { 64.0 } else { height - 14.0 }),
); );
ui.scope_builder(egui::UiBuilder::new().max_rect(actions_rect), |ui| {
if *open { if *open {
ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| { expanded_status_actions(ui, actions_rect, view, actions, diagnostics_ui, palette);
let (color, label, tooltip) = view.health.presentation(palette);
ui.horizontal(|ui| status(ui, color, label, tooltip));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
overflow_menu(ui, view, actions, diagnostics_ui);
if view.can_clear
&& icon_button(ui, icons::X, "Clear actor assignment", true).clicked()
{
actions.push(MaterialSlotAction::Clear);
}
if view.can_locate
&& icon_button(ui, icons::CROSSHAIR, "Locate in Content Browser", true)
.clicked()
{
actions.push(MaterialSlotAction::Locate);
}
if icon_button(ui, icons::FOLDER_OPEN, "Browse project materials", true)
.clicked()
{
actions.push(MaterialSlotAction::BrowseLibrary);
}
});
});
} else { } else {
ui.scope_builder(egui::UiBuilder::new().max_rect(actions_rect), |ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
overflow_menu(ui, view, actions, diagnostics_ui); overflow_menu(ui, view, actions, diagnostics_ui);
let (color, label, tooltip) = view.health.presentation(palette); let (color, label, tooltip) = view.health.presentation(palette);
ui.horizontal(|ui| status(ui, color, label, tooltip)); ui.horizontal(|ui| status(ui, view.health.icon(), color, label, tooltip));
});
}); });
} }
});
if response.clicked() { if response.clicked() {
if let Some(pointer) = ui.input(|input| input.pointer.interact_pos()) { if let Some(pointer) = ui.input(|input| input.pointer.interact_pos()) {
if !caret_rect.contains(pointer) if !caret_rect.contains(pointer)
@ -482,7 +477,7 @@ fn material_identity(
if let Some(mut kind) = view.shader_kind { if let Some(mut kind) = view.shader_kind {
egui::ComboBox::from_id_salt("material_identity_shader_kind") egui::ComboBox::from_id_salt("material_identity_shader_kind")
.selected_text(&view.shader) .selected_text(&view.shader)
.width(150.0_f32.min(ui.available_width().max(1.0))) .width(SHADER_CONTROL_WIDTH.min(ui.available_width().max(1.0)))
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
ui.selectable_value(&mut kind, MaterialShaderKind::StandardLit, "Standard Lit"); ui.selectable_value(&mut kind, MaterialShaderKind::StandardLit, "Standard Lit");
ui.selectable_value(&mut kind, MaterialShaderKind::Unlit, "Unlit"); ui.selectable_value(&mut kind, MaterialShaderKind::Unlit, "Unlit");
@ -494,7 +489,10 @@ fn material_identity(
} }
} else { } else {
ui.add_sized( ui.add_sized(
[150.0_f32.min(ui.available_width().max(1.0)), 22.0], [
SHADER_CONTROL_WIDTH.min(ui.available_width().max(1.0)),
22.0,
],
egui::Label::new( egui::Label::new(
TypeRole::Body TypeRole::Body
.text(&view.shader) .text(&view.shader)
@ -572,72 +570,6 @@ fn material_thumbnail_at(
response.on_hover_text(format!("Open {} material picker", view.label)) response.on_hover_text(format!("Open {} material picker", view.label))
} }
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(phosphor_icon(icons::DOTS_THREE, 16.0), |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()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -671,6 +603,8 @@ mod tests {
assert_eq!(SLOT_CARET_CENTER_X, 23.0); assert_eq!(SLOT_CARET_CENTER_X, 23.0);
assert_eq!(SLOT_PREVIEW_X, 35.0); assert_eq!(SLOT_PREVIEW_X, 35.0);
assert_eq!(SLOT_PREVIEW_X - (SLOT_CARET_CENTER_X + 6.0), 6.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] #[test]
@ -740,4 +674,13 @@ mod tests {
assert!(!view.health.presentation(palette).1.is_empty()); 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);
}
} }

View 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));
}
}
}

View File

@ -261,7 +261,7 @@ fn paint_thumbnail(ui: &egui::Ui, rect: egui::Rect, thumbnail: MaterialThumbnail
rect.center(), rect.center(),
egui::Align2::CENTER_CENTER, egui::Align2::CENTER_CENTER,
icons::CIRCLE_NOTCH.as_str(), icons::CIRCLE_NOTCH.as_str(),
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-regular".into())), egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.text_muted, palette.text_muted,
); );
} }
@ -270,7 +270,7 @@ fn paint_thumbnail(ui: &egui::Ui, rect: egui::Rect, thumbnail: MaterialThumbnail
rect.center(), rect.center(),
egui::Align2::CENTER_CENTER, egui::Align2::CENTER_CENTER,
icons::WARNING.as_str(), icons::WARNING.as_str(),
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-regular".into())), egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.error, palette.error,
); );
} }

View File

@ -372,7 +372,7 @@ fn paint_field_icon(
rect.center(), rect.center(),
egui::Align2::CENTER_CENTER, egui::Align2::CENTER_CENTER,
icon.as_str(), icon.as_str(),
egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())), egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-bold".into())),
color, color,
); );
} }

View File

@ -92,7 +92,7 @@ fn vec2_group(
reset_rect, reset_rect,
egui::Button::new( egui::Button::new(
egui::RichText::new(icons::ARROW_COUNTER_CLOCKWISE.as_str()).font( egui::RichText::new(icons::ARROW_COUNTER_CLOCKWISE.as_str()).font(
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-regular".into())), egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())),
), ),
), ),
); );

View File

@ -5,7 +5,7 @@ mod asset_card;
mod audio_inspector; mod audio_inspector;
mod build; mod build;
pub mod component_registry; pub mod component_registry;
pub(crate) mod design_system; pub(crate) use editor_ui::design_system;
mod diagnostics; mod diagnostics;
mod dock_tabs; mod dock_tabs;
pub(crate) mod document_status; pub(crate) mod document_status;

View File

@ -0,0 +1,12 @@
[package]
name = "editor_ui"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Lightweight production egui components and design system for Blacksite editor surfaces"
[dependencies]
bevy_math = "0.19"
egui.workspace = true
egui_phosphor_icons.workspace = true
material_schema.workspace = true

View File

@ -0,0 +1,93 @@
Copyright 2010-2024 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,328 @@
//! Penpot 420×350 material color popup with live preview and reversible dismissal.
mod popup;
use egui;
use super::typography::TypeRole;
pub(super) const POPUP_SIZE: egui::Vec2 = egui::vec2(420.0, 350.0);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PickerMode {
Wheel,
Sliders,
Presets,
}
#[derive(Debug, Clone)]
pub(super) struct PickerSession {
original: [f32; 4],
current: [f32; 4],
mode: PickerMode,
hex: String,
opened_at: f64,
owner_rect: egui::Rect,
original_intensity: Option<f32>,
current_intensity: Option<f32>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ColorPickerResponse {
pub changed: bool,
pub applied: bool,
pub cancelled: bool,
}
pub fn color_control(
ui: &mut egui::Ui,
salt: impl std::hash::Hash,
label: &str,
rgba: &mut [f32; 4],
) -> ColorPickerResponse {
color_control_impl(ui, salt, label, rgba, None)
}
pub fn color_control_with_intensity(
ui: &mut egui::Ui,
salt: impl std::hash::Hash,
label: &str,
rgba: &mut [f32; 4],
intensity: &mut f32,
) -> ColorPickerResponse {
color_control_impl(ui, salt, label, rgba, Some(intensity))
}
fn color_control_impl(
ui: &mut egui::Ui,
salt: impl std::hash::Hash,
label: &str,
rgba: &mut [f32; 4],
intensity: Option<&mut f32>,
) -> ColorPickerResponse {
let palette = super::palette(ui);
let id = ui.make_persistent_id(("penpot_color_picker", salt));
let width = ui.available_width().max(1.0);
let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::click());
let swatch_width = 44.0_f32.min(width);
let swatch_rect = egui::Rect::from_min_size(rect.min, egui::vec2(swatch_width, 22.0));
let gap = if width > swatch_width + 6.0 { 6.0 } else { 0.0 };
let hex_rect = egui::Rect::from_min_size(
rect.min + egui::vec2(swatch_width + gap, 0.0),
egui::vec2((width - swatch_width - gap).max(0.0), 22.0),
);
popup::checker(ui.painter(), swatch_rect, palette.control, palette.elevated);
let inner_swatch = swatch_rect.shrink(1.0);
let rgb_rect = egui::Rect::from_min_max(
inner_swatch.min,
egui::pos2(inner_swatch.center().x, inner_swatch.max.y),
);
let alpha_rect = egui::Rect::from_min_max(
egui::pos2(inner_swatch.center().x, inner_swatch.min.y),
inner_swatch.max,
);
ui.painter().rect_filled(
rgb_rect,
3.0,
egui::Color32::from_rgb(byte(rgba[0]), byte(rgba[1]), byte(rgba[2])),
);
ui.painter().rect_filled(alpha_rect, 3.0, color32(*rgba));
ui.painter().rect_stroke(
swatch_rect,
4.0,
egui::Stroke::new(1.0_f32, palette.border_strong),
egui::StrokeKind::Inside,
);
if hex_rect.width() > 0.0 {
ui.painter().rect(
hex_rect,
4.0,
palette.control,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
ui.painter().text(
hex_rect.left_center() + egui::vec2(9.0, 0.0),
egui::Align2::LEFT_CENTER,
hex(*rgba),
TypeRole::Body.font(),
palette.text_primary,
);
}
if response.clicked() {
let opened_at = ui.input(|input| input.time);
ui.ctx().data_mut(|data| {
data.insert_temp(
id,
PickerSession {
original: *rgba,
current: *rgba,
mode: PickerMode::Wheel,
hex: hex(*rgba),
opened_at,
owner_rect: ui.clip_rect(),
original_intensity: intensity.as_deref().copied(),
current_intensity: intensity.as_deref().copied(),
},
);
});
}
let before = *rgba;
let mut result = ColorPickerResponse::default();
let Some(mut session) = ui.ctx().data_mut(|data| data.get_temp::<PickerSession>(id)) else {
return result;
};
let screen_rect = ui.ctx().content_rect();
let popup_rect = centered_popup_rect(session.owner_rect, screen_rect);
let scrim = egui::Area::new(id.with("modal_scrim"))
.order(egui::Order::Foreground)
.fixed_pos(session.owner_rect.min)
.show(ui.ctx(), |ui| {
let (rect, response) =
ui.allocate_exact_size(session.owner_rect.size(), egui::Sense::click());
ui.painter()
.rect_filled(rect, 0.0, egui::Color32::from_black_alpha(132));
response
})
.inner;
let popup_response = egui::Area::new(id.with("popup"))
.order(egui::Order::Foreground)
.fixed_pos(popup_rect.min)
.show(ui.ctx(), |ui| {
ui.set_min_size(POPUP_SIZE);
ui.set_max_size(POPUP_SIZE);
popup::show(ui, label, &mut session, &mut result)
})
.response;
let escape = ui.input(|input| input.key_pressed(egui::Key::Escape));
let outside_click = scrim.clicked()
|| ui.input(|input| {
input.pointer.any_pressed()
&& input.time > session.opened_at
&& input
.pointer
.interact_pos()
.is_some_and(|position| !popup_response.rect.contains(position))
});
if escape || outside_click {
result.cancelled = true;
}
if result.cancelled {
*rgba = session.original;
if let (Some(target), Some(original)) = (intensity, session.original_intensity) {
*target = original;
}
ui.ctx().data_mut(|data| data.remove::<PickerSession>(id));
} else {
*rgba = session.current;
if let (Some(target), Some(current)) = (intensity, session.current_intensity) {
*target = current;
}
if result.applied {
ui.ctx().data_mut(|data| data.remove::<PickerSession>(id));
} else {
ui.ctx().data_mut(|data| data.insert_temp(id, session));
}
}
result.changed = *rgba != before;
result
}
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;
let minimum = screen.min + egui::vec2(SCREEN_MARGIN, SCREEN_MARGIN);
let maximum = screen.max - POPUP_SIZE - egui::vec2(SCREEN_MARGIN, SCREEN_MARGIN);
let x = if maximum.x >= minimum.x {
desired.x.clamp(minimum.x, maximum.x)
} else {
screen.center().x - POPUP_SIZE.x * 0.5
};
let y = if maximum.y >= minimum.y {
desired.y.clamp(minimum.y, maximum.y)
} else {
screen.center().y - POPUP_SIZE.y * 0.5
};
egui::Rect::from_min_size(egui::pos2(x, y), POPUP_SIZE)
}
pub(super) fn set_current(session: &mut PickerSession, value: [f32; 4]) {
session.current = value.map(|component| component.clamp(0.0, 1.0));
session.hex = hex(session.current);
}
pub(super) fn color32(value: [f32; 4]) -> egui::Color32 {
egui::Color32::from_rgba_unmultiplied(
byte(value[0]),
byte(value[1]),
byte(value[2]),
byte(value[3]),
)
}
pub(super) fn rgba(value: egui::Color32) -> [f32; 4] {
[
value.r() as f32 / 255.0,
value.g() as f32 / 255.0,
value.b() as f32 / 255.0,
value.a() as f32 / 255.0,
]
}
pub(super) fn byte(value: f32) -> u8 {
(value.clamp(0.0, 1.0) * 255.0).round() as u8
}
pub(super) fn hex(value: [f32; 4]) -> String {
if byte(value[3]) == 255 {
format!(
"#{:02X}{:02X}{:02X}",
byte(value[0]),
byte(value[1]),
byte(value[2])
)
} else {
format!(
"#{:02X}{:02X}{:02X}{:02X}",
byte(value[0]),
byte(value[1]),
byte(value[2]),
byte(value[3])
)
}
}
pub(super) fn parse_hex(value: &str) -> Option<[f32; 4]> {
let value = value.trim().trim_start_matches('#');
if !matches!(value.len(), 6 | 8) {
return None;
}
let rgb = u32::from_str_radix(&value[..6], 16).ok()?;
let alpha = if value.len() == 8 {
u8::from_str_radix(&value[6..], 16).ok()?
} else {
255
};
Some([
((rgb >> 16) & 0xFF) as f32 / 255.0,
((rgb >> 8) & 0xFF) as f32 / 255.0,
(rgb & 0xFF) as f32 / 255.0,
alpha as f32 / 255.0,
])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn popup_centers_on_inspector_owner_and_stays_on_screen() {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0));
let inspector =
egui::Rect::from_min_size(egui::pos2(800.0, 40.0), egui::vec2(640.0, 820.0));
let popup = centered_popup_rect(inspector, screen);
assert_eq!(popup.size(), POPUP_SIZE);
assert_eq!(popup.center(), inspector.center());
let edge = centered_popup_rect(
egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(200.0, 200.0)),
screen,
);
assert!(edge.left() >= 12.0);
assert!(edge.top() >= 12.0);
}
#[test]
fn penpot_hex_round_trip_preserves_rgba() {
let value = [0.2, 0.4, 0.6, 0.8];
let parsed = parse_hex(&hex(value)).expect("valid generated hex");
for (left, right) in value.into_iter().zip(parsed) {
assert!((left - right).abs() <= 1.0 / 255.0);
}
}
#[test]
fn cancel_restores_opening_value_and_apply_keeps_live_preview() {
let original = [0.1, 0.2, 0.3, 1.0];
let mut session = PickerSession {
original,
current: [0.8, 0.7, 0.6, 0.5],
mode: PickerMode::Wheel,
hex: String::new(),
opened_at: 0.0,
owner_rect: egui::Rect::ZERO,
original_intensity: None,
current_intensity: None,
};
let applied = session.current;
set_current(&mut session, applied);
assert_eq!(session.original, original);
assert_eq!(session.current, applied);
}
}

View File

@ -0,0 +1,765 @@
//! Fixed-coordinate renderer for the exported Penpot color overlay.
mod sliders;
use egui::{self, ecolor::Hsva};
use egui_phosphor_icons::{icons, Icon};
use super::{
byte, color32, parse_hex, rgba, set_current, ColorPickerResponse, PickerMode, PickerSession,
POPUP_SIZE,
};
use crate::design_system::typography::TypeRole;
const RECENTS: [egui::Color32; 8] = colors([
0xE7D4B4, 0x53D3E6, 0xCA6CE0, 0xF06B75, 0x55C58A, 0xF3C85B, 0x6F88EF, 0xF3F5F7,
]);
const PRESETS: [egui::Color32; 24] = colors([
0xE7D4B4, 0xF2EEE8, 0xD5B59C, 0xA8795B, 0x734C3C, 0x3B2D29, 0xE45D68, 0xF09A55, 0xE9D05B,
0x79C267, 0x45B7A6, 0x4FA9D8, 0x5A78DE, 0x8E68D8, 0xC26BC7, 0xE68DB5, 0xF5F7FA, 0xC4CCD5,
0x8995A3, 0x56616E, 0x303842, 0x1D242C, 0x101419, 0x07090C,
]);
const PLANNED_EYEDROPPER: &str = "Planned — screen sampling pending";
const fn colors<const N: usize>(values: [u32; N]) -> [egui::Color32; N] {
let mut result = [egui::Color32::BLACK; N];
let mut index = 0;
while index < N {
let value = values[index];
result[index] = egui::Color32::from_rgb(
((value >> 16) & 0xFF) as u8,
((value >> 8) & 0xFF) as u8,
(value & 0xFF) as u8,
);
index += 1;
}
result
}
struct Geometry;
impl Geometry {
const HEADER_HEIGHT: f32 = 38.0;
const CONTENT: egui::Rect =
egui::Rect::from_min_max(egui::pos2(12.0, 74.0), egui::pos2(408.0, 276.0));
const WHEEL_EDITOR: egui::Rect =
egui::Rect::from_min_max(egui::pos2(12.0, 74.0), egui::pos2(190.0, 276.0));
const VALUES: egui::Rect =
egui::Rect::from_min_max(egui::pos2(198.0, 82.0), egui::pos2(396.0, 268.0));
const RECENTS: egui::Rect =
egui::Rect::from_min_max(egui::pos2(12.0, 282.0), egui::pos2(408.0, 310.0));
const FOOTER: egui::Rect =
egui::Rect::from_min_max(egui::pos2(12.0, 316.0), egui::pos2(408.0, 342.0));
fn rect(root: egui::Rect, local: egui::Rect) -> egui::Rect {
local.translate(root.min.to_vec2())
}
fn at(root: egui::Rect, x: f32, y: f32, width: f32, height: f32) -> egui::Rect {
egui::Rect::from_min_size(root.min + egui::vec2(x, y), egui::vec2(width, height))
}
}
pub(super) fn show(
ui: &mut egui::Ui,
label: &str,
session: &mut PickerSession,
result: &mut ColorPickerResponse,
) -> egui::Response {
let palette = crate::design_system::palette(ui);
let (root, response) = ui.allocate_exact_size(POPUP_SIZE, egui::Sense::hover());
ui.painter().rect(
root,
7.0,
palette.panel,
egui::Stroke::new(1.0_f32, palette.border_strong),
egui::StrokeKind::Inside,
);
ui.painter().line_segment(
[
root.min + egui::vec2(0.0, Geometry::HEADER_HEIGHT),
root.min + egui::vec2(root.width(), Geometry::HEADER_HEIGHT),
],
egui::Stroke::new(1.0_f32, palette.border),
);
header(ui, root, label, session, result);
mode_tabs(ui, root, session);
intensity_control(ui, root, session);
match session.mode {
PickerMode::Wheel => wheel_mode(ui, root, session),
PickerMode::Sliders => sliders::show(ui, root, session),
PickerMode::Presets => presets_mode(ui, root, session),
}
recent_row(ui, root, session);
footer(ui, root, result);
response
}
fn intensity_control(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) {
let Some(intensity) = session.current_intensity.as_mut() else {
return;
};
let palette = crate::design_system::palette(ui);
text(
ui,
root.min + egui::vec2(272.0, 55.0),
egui::Align2::LEFT_CENTER,
"Intensity",
TypeRole::Small.font(),
palette.text_muted,
);
ui.put(
Geometry::at(root, 326.0, 43.0, 82.0, 24.0),
egui::DragValue::new(intensity)
.range(0.0..=50_000.0)
.speed(10.0),
)
.on_hover_text("Emissive luminance multiplier in nits");
}
fn header(
ui: &mut egui::Ui,
root: egui::Rect,
label: &str,
session: &PickerSession,
result: &mut ColorPickerResponse,
) {
let palette = crate::design_system::palette(ui);
text(
ui,
root.min + egui::vec2(12.0, 19.0),
egui::Align2::LEFT_CENTER,
label,
TypeRole::Section.font(),
palette.text_primary,
);
text(
ui,
root.min + egui::vec2(84.0, 19.0),
egui::Align2::LEFT_CENTER,
"sRGB · RGBA",
TypeRole::Small.font(),
palette.text_muted,
);
paint_swatch(
ui,
Geometry::at(root, 348.0, 7.0, 26.0, 24.0),
color32(session.current),
egui::Sense::hover(),
"Current color",
);
if icon_button(
ui,
Geometry::at(root, 384.0, 7.0, 24.0, 24.0),
icons::X,
"Cancel color changes",
true,
)
.clicked()
{
result.cancelled = true;
}
}
fn mode_tabs(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) {
let palette = crate::design_system::palette(ui);
for (index, (mode, icon, label, width)) in [
(PickerMode::Wheel, icons::CIRCLE, "Wheel", 76.0),
(PickerMode::Sliders, icons::SLIDERS, "Sliders", 76.0),
(PickerMode::Presets, icons::SQUARES_FOUR, "Presets", 78.0),
]
.into_iter()
.enumerate()
{
let x = [12.0, 92.0, 172.0][index];
let rect = Geometry::at(root, x, 42.0, width, 26.0);
let selected = session.mode == mode;
let response = ui.put(
rect,
egui::Button::new(crate::design_system::controls::icon_label(
icon,
label,
TypeRole::Body,
12.0,
if selected {
palette.text_primary
} else {
palette.text_secondary
},
))
.fill(if selected {
palette.accent_dark
} else {
palette.control
})
.stroke(egui::Stroke::new(
1.0_f32,
if selected {
palette.accent
} else {
palette.border
},
))
.corner_radius(4.0),
);
if selected {
ui.painter().rect_filled(
egui::Rect::from_center_size(
egui::pos2(rect.center().x, rect.bottom() - 1.0),
egui::vec2(60.0, 2.0),
),
1.0,
palette.accent,
);
}
if response.clicked() {
session.mode = mode;
}
}
}
fn wheel_mode(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) {
let palette = crate::design_system::palette(ui);
let ring = Geometry::at(root, 29.0, 88.0, 144.0, 144.0);
let sv = Geometry::at(root, 65.0, 124.0, 72.0, 72.0);
let editor = Geometry::rect(root, Geometry::WHEEL_EDITOR);
let response = ui.interact(
editor,
ui.make_persistent_id("penpot_color_wheel"),
egui::Sense::click_and_drag(),
);
let mut hsva = Hsva::from_rgba_unmultiplied(
session.current[0],
session.current[1],
session.current[2],
session.current[3],
);
paint_hue_wheel(ui.painter(), ring, hsva.h, palette.text_primary);
paint_sv(
ui.painter(),
sv,
hsva.h,
hsva.s,
hsva.v,
palette.text_primary,
);
if response.clicked() || response.dragged() {
if let Some(position) = response.interact_pointer_pos() {
let delta = position - ring.center();
if sv.contains(position) {
hsva.s = ((position.x - sv.left()) / sv.width()).clamp(0.0, 1.0);
hsva.v = (1.0 - (position.y - sv.top()) / sv.height()).clamp(0.0, 1.0);
} else if (52.0..=76.0).contains(&delta.length()) {
hsva.h = (delta.angle() / std::f32::consts::TAU + 0.25).rem_euclid(1.0);
}
let [r, g, b] = hsva.to_rgb();
set_current(session, [r, g, b, hsva.a]);
}
}
text(
ui,
root.min + egui::vec2(101.0, 252.0),
egui::Align2::CENTER_CENTER,
"Drag ring or field",
TypeRole::Small.font(),
palette.text_muted,
);
values(ui, root, session);
}
fn values(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) {
let palette = crate::design_system::palette(ui);
let _values_bounds = Geometry::rect(root, Geometry::VALUES);
caption(ui, root, 198.0, 82.0, "CURRENT");
caption(ui, root, 302.0, 82.0, "PREVIOUS");
paint_swatch(
ui,
Geometry::at(root, 198.0, 96.0, 94.0, 30.0),
color32(session.current),
egui::Sense::hover(),
"Current live value",
);
paint_swatch(
ui,
Geometry::at(root, 302.0, 96.0, 94.0, 30.0),
color32(session.original),
egui::Sense::hover(),
"Value when the picker opened",
);
caption(ui, root, 198.0, 132.0, "HEX");
let hex_field = Geometry::at(root, 198.0, 144.0, 166.0, 24.0);
field_background(ui, hex_field);
let edit_rect = egui::Rect::from_min_max(
hex_field.min,
egui::pos2(hex_field.right() - 24.0, hex_field.bottom()),
);
let edit = ui.put(
edit_rect.shrink2(egui::vec2(5.0, 2.0)),
egui::TextEdit::singleline(&mut session.hex)
.font(TypeRole::Body.font())
.frame(egui::Frame::NONE),
);
if edit.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)) {
if let Some(value) = parse_hex(&session.hex) {
set_current(session, value);
}
}
if bare_icon(
ui,
egui::Rect::from_min_size(
egui::pos2(hex_field.right() - 24.0, hex_field.top()),
egui::vec2(24.0, 24.0),
),
icons::COPY,
"Copy HEX",
palette.text_secondary,
)
.clicked()
{
ui.ctx().copy_text(session.hex.clone());
}
let eyedropper = Geometry::at(root, 370.0, 144.0, 26.0, 24.0);
field_background(ui, eyedropper);
bare_icon(
ui,
eyedropper,
icons::EYEDROPPER,
PLANNED_EYEDROPPER,
palette.text_muted,
);
caption(ui, root, 198.0, 174.0, "RGBA");
let mut bytes = session.current.map(byte);
let before_bytes = bytes;
for (index, prefix) in ["R ", "G ", "B ", "A "].into_iter().enumerate() {
let rect = Geometry::at(root, 198.0 + index as f32 * 49.0, 186.0, 45.0, 24.0);
ui.put(
rect,
egui::DragValue::new(&mut bytes[index])
.range(0..=255)
.prefix(prefix),
);
}
if bytes != before_bytes {
set_current(session, bytes.map(|component| component as f32 / 255.0));
}
caption(ui, root, 198.0, 216.0, "HSV");
let mut hsva = Hsva::from_rgba_unmultiplied(
session.current[0],
session.current[1],
session.current[2],
session.current[3],
);
let mut hsv = [hsva.h * 360.0, hsva.s * 100.0, hsva.v * 100.0];
let before_hsv = hsv;
for (index, suffix) in ["°", "%", "%"].into_iter().enumerate() {
let rect = Geometry::at(root, 198.0 + index as f32 * 66.0, 228.0, 61.0, 24.0);
ui.put(
rect,
egui::DragValue::new(&mut hsv[index])
.range(if index == 0 { 0.0..=360.0 } else { 0.0..=100.0 })
.suffix(suffix)
.max_decimals(0),
);
}
if hsv != before_hsv {
hsva.h = hsv[0] / 360.0;
hsva.s = hsv[1] / 100.0;
hsva.v = hsv[2] / 100.0;
let [r, g, b] = hsva.to_rgb();
set_current(session, [r, g, b, hsva.a]);
}
caption(ui, root, 198.0, 258.0, "ALPHA");
alpha_control(ui, Geometry::at(root, 240.0, 256.0, 156.0, 14.0), session);
}
fn presets_mode(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) {
let palette = crate::design_system::palette(ui);
let content = Geometry::rect(root, Geometry::CONTENT);
ui.painter().rect_filled(content, 4.0, palette.recessed);
caption(ui, root, 24.0, 86.0, "MATERIAL PALETTE");
for (index, color) in PRESETS.into_iter().enumerate() {
let column = index % 8;
let row = index / 8;
let rect = Geometry::at(
root,
24.0 + column as f32 * 47.0,
104.0 + row as f32 * 52.0,
34.0,
34.0,
);
if paint_swatch(ui, rect, color, egui::Sense::click(), "Use preset color").clicked() {
set_current(session, rgba(color));
}
}
}
fn recent_row(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) {
let palette = crate::design_system::palette(ui);
let row = Geometry::rect(root, Geometry::RECENTS);
ui.painter().rect_filled(row, 4.0, palette.section);
text(
ui,
root.min + egui::vec2(20.0, 296.0),
egui::Align2::LEFT_CENTER,
"RECENT",
TypeRole::Caption.font(),
palette.text_muted,
);
for (index, color) in RECENTS.into_iter().enumerate() {
let rect = Geometry::at(root, 74.0 + index as f32 * 36.0, 286.0, 28.0, 20.0);
if paint_swatch(ui, rect, color, egui::Sense::click(), "Use recent color").clicked() {
set_current(session, rgba(color));
}
}
}
fn footer(ui: &mut egui::Ui, root: egui::Rect, result: &mut ColorPickerResponse) {
let palette = crate::design_system::palette(ui);
let footer = Geometry::rect(root, Geometry::FOOTER);
text(
ui,
footer.left_center(),
egui::Align2::LEFT_CENTER,
"Enter apply · Esc cancel",
TypeRole::Small.font(),
palette.text_muted,
);
if labeled_button(
ui,
Geometry::at(root, 254.0, 316.0, 72.0, 26.0),
icons::X,
"Cancel",
false,
)
.clicked()
{
result.cancelled = true;
}
if labeled_button(
ui,
Geometry::at(root, 334.0, 316.0, 74.0, 26.0),
icons::CHECK,
"Apply",
true,
)
.clicked()
{
result.applied = true;
}
}
fn alpha_control(ui: &mut egui::Ui, rect: egui::Rect, session: &mut PickerSession) {
let palette = crate::design_system::palette(ui);
checker(ui.painter(), rect, palette.control, palette.elevated);
let response = ui.interact(
rect.expand2(egui::vec2(0.0, 4.0)),
ui.make_persistent_id("penpot_color_alpha"),
egui::Sense::click_and_drag(),
);
if response.clicked() || response.dragged() {
if let Some(position) = response.interact_pointer_pos() {
let mut value = session.current;
value[3] = ((position.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
set_current(session, value);
}
}
let x = egui::lerp(rect.left()..=rect.right(), session.current[3]);
ui.painter().rect_filled(
egui::Rect::from_min_max(rect.min, egui::pos2(x, rect.bottom())),
2.0,
palette.accent.gamma_multiply(0.65),
);
ui.painter().rect_filled(
egui::Rect::from_center_size(egui::pos2(x, rect.center().y), egui::vec2(10.0, 18.0)),
3.0,
palette.text_primary,
);
}
fn paint_hue_wheel(
painter: &egui::Painter,
rect: egui::Rect,
selected_hue: f32,
marker_color: egui::Color32,
) {
let center = rect.center();
let outer = 72.0;
let inner = 54.0;
let mut mesh = egui::Mesh::default();
const SEGMENTS: usize = 72;
for segment in 0..SEGMENTS {
let h0 = segment as f32 / SEGMENTS as f32;
let h1 = (segment + 1) as f32 / SEGMENTS as f32;
let a0 = (h0 - 0.25) * std::f32::consts::TAU;
let a1 = (h1 - 0.25) * std::f32::consts::TAU;
let start = mesh.vertices.len() as u32;
let c0 = egui::Color32::from(Hsva::new(h0, 1.0, 1.0, 1.0));
let c1 = egui::Color32::from(Hsva::new(h1, 1.0, 1.0, 1.0));
for (radius, angle, color) in [
(inner, a0, c0),
(outer, a0, c0),
(outer, a1, c1),
(inner, a1, c1),
] {
mesh.colored_vertex(
center + egui::vec2(angle.cos(), angle.sin()) * radius,
color,
);
}
mesh.add_triangle(start, start + 1, start + 2);
mesh.add_triangle(start, start + 2, start + 3);
}
painter.add(mesh);
let angle = (selected_hue - 0.25) * std::f32::consts::TAU;
let marker = center + egui::vec2(angle.cos(), angle.sin()) * 63.0;
painter.circle_stroke(marker, 5.0, egui::Stroke::new(2.0_f32, marker_color));
}
fn paint_sv(
painter: &egui::Painter,
rect: egui::Rect,
hue: f32,
saturation: f32,
value: f32,
marker_color: egui::Color32,
) {
let mut mesh = egui::Mesh::default();
for (position, color) in [
(rect.left_top(), egui::Color32::WHITE),
(
rect.right_top(),
egui::Color32::from(Hsva::new(hue, 1.0, 1.0, 1.0)),
),
(rect.right_bottom(), egui::Color32::BLACK),
(rect.left_bottom(), egui::Color32::BLACK),
] {
mesh.colored_vertex(position, color);
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
painter.add(mesh);
let marker = egui::pos2(
egui::lerp(rect.x_range(), saturation),
egui::lerp(rect.y_range(), 1.0 - value),
);
painter.circle_stroke(marker, 5.0, egui::Stroke::new(2.0_f32, marker_color));
}
pub(super) fn checker(
painter: &egui::Painter,
rect: egui::Rect,
dark: egui::Color32,
light: egui::Color32,
) {
let size = 6.0;
let columns = (rect.width() / size).ceil() as usize;
let rows = (rect.height() / size).ceil() as usize;
for row in 0..rows {
for column in 0..columns {
let cell = egui::Rect::from_min_size(
rect.min + egui::vec2(column as f32 * size, row as f32 * size),
egui::vec2(size, size),
)
.intersect(rect);
painter.rect_filled(
cell,
0.0,
if (row + column) % 2 == 0 { dark } else { light },
);
}
}
}
fn paint_swatch(
ui: &mut egui::Ui,
rect: egui::Rect,
color: egui::Color32,
sense: egui::Sense,
tooltip: &str,
) -> egui::Response {
let palette = crate::design_system::palette(ui);
checker(ui.painter(), rect, palette.control, palette.elevated);
ui.painter().rect_filled(rect.shrink(1.0), 3.0, color);
ui.painter().rect_stroke(
rect,
3.0,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
ui.interact(
rect,
ui.make_persistent_id(("color_swatch", rect.min.x.to_bits(), rect.min.y.to_bits())),
sense,
)
.on_hover_text(tooltip)
}
fn icon_button(
ui: &mut egui::Ui,
rect: egui::Rect,
icon: Icon,
tooltip: &str,
enabled: bool,
) -> egui::Response {
let palette = crate::design_system::palette(ui);
ui.put(
rect,
egui::Button::new(egui::RichText::new(icon.as_str()).font(egui::FontId::new(
13.0,
egui::FontFamily::Name("phosphor-regular".into()),
)))
.fill(palette.control)
.stroke(egui::Stroke::new(1.0_f32, palette.border))
.corner_radius(4.0)
.sense(if enabled {
egui::Sense::click()
} else {
egui::Sense::hover()
}),
)
.on_hover_text(tooltip)
}
fn bare_icon(
ui: &mut egui::Ui,
rect: egui::Rect,
icon: Icon,
tooltip: &str,
color: egui::Color32,
) -> egui::Response {
let response = ui.interact(
rect,
ui.make_persistent_id(("color_icon", tooltip)),
if tooltip == PLANNED_EYEDROPPER {
egui::Sense::hover()
} else {
egui::Sense::click()
},
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
icon.as_str(),
egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())),
color,
);
response.on_hover_text(tooltip)
}
fn labeled_button(
ui: &mut egui::Ui,
rect: egui::Rect,
icon: Icon,
label: &str,
accent: bool,
) -> egui::Response {
let palette = crate::design_system::palette(ui);
ui.push_id(("color_picker_footer_action", label), |ui| {
ui.put(
rect,
egui::Button::new(crate::design_system::controls::icon_label(
icon,
label,
TypeRole::Control,
12.0,
palette.text_primary,
))
.fill(if accent {
palette.accent
} else {
palette.control
})
.stroke(egui::Stroke::new(
1.0_f32,
if accent {
palette.accent
} else {
palette.border
},
))
.corner_radius(4.0),
)
})
.inner
}
fn field_background(ui: &egui::Ui, rect: egui::Rect) {
let palette = crate::design_system::palette(ui);
ui.painter().rect(
rect,
4.0,
palette.control,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
}
fn caption(ui: &egui::Ui, root: egui::Rect, x: f32, y: f32, value: &str) {
let palette = crate::design_system::palette(ui);
text(
ui,
root.min + egui::vec2(x, y),
egui::Align2::LEFT_TOP,
value,
TypeRole::Caption.font(),
palette.text_muted,
);
}
fn text(
ui: &egui::Ui,
position: egui::Pos2,
align: egui::Align2,
value: &str,
font: egui::FontId,
color: egui::Color32,
) {
ui.painter().text(position, align, value, font, color);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exported_popup_regions_match_penpot_geometry() {
assert_eq!(POPUP_SIZE, egui::vec2(420.0, 350.0));
assert_eq!(Geometry::HEADER_HEIGHT, 38.0);
assert_eq!(Geometry::CONTENT.size(), egui::vec2(396.0, 202.0));
assert_eq!(Geometry::WHEEL_EDITOR.size(), egui::vec2(178.0, 202.0));
assert_eq!(Geometry::VALUES.size(), egui::vec2(198.0, 186.0));
assert_eq!(Geometry::RECENTS.size(), egui::vec2(396.0, 28.0));
assert_eq!(Geometry::FOOTER.size(), egui::vec2(396.0, 26.0));
}
#[test]
fn exported_value_and_action_fields_retain_exact_sizes() {
let root = egui::Rect::from_min_size(egui::Pos2::ZERO, POPUP_SIZE);
assert_eq!(
Geometry::at(root, 198.0, 144.0, 166.0, 24.0).size(),
egui::vec2(166.0, 24.0)
);
assert_eq!(
Geometry::at(root, 370.0, 144.0, 26.0, 24.0).size(),
egui::vec2(26.0, 24.0)
);
assert_eq!(
Geometry::at(root, 240.0, 256.0, 156.0, 14.0).size(),
egui::vec2(156.0, 14.0)
);
assert_eq!(
Geometry::at(root, 334.0, 316.0, 74.0, 26.0).size(),
egui::vec2(74.0, 26.0)
);
}
#[test]
fn disabled_eyedropper_has_no_state_or_action_contract() {
assert_eq!(PLANNED_EYEDROPPER, "Planned — screen sampling pending");
}
}

View File

@ -0,0 +1,167 @@
//! Fixed Penpot geometry for the color picker's RGBA and HSV channel modes.
use egui::{self, ecolor::Hsva};
use super::super::{byte, set_current, PickerSession};
use super::{caption, text, Geometry};
use crate::design_system::typography::TypeRole;
/// Each 186 px channel group owns a 178 px row: a single-letter channel label,
/// a 104 px track, and a 44 px value field.
struct SliderGeometry;
impl SliderGeometry {
const RGBA_X: f32 = 12.0;
const HSV_X: f32 = 206.0;
const TITLE_Y: f32 = 74.0;
const FIRST_ROW_Y: f32 = 90.0;
const ROW_STRIDE: f32 = 30.0;
const LABEL_WIDTH: f32 = 18.0;
const TRACK_X: f32 = 24.0;
const TRACK_WIDTH: f32 = 104.0;
const VALUE_X: f32 = 134.0;
const VALUE_WIDTH: f32 = 44.0;
const ROW_HEIGHT: f32 = 24.0;
}
pub(super) fn show(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) {
let palette = crate::design_system::palette(ui);
let content = Geometry::rect(root, Geometry::CONTENT);
ui.painter().rect_filled(content, 4.0, palette.recessed);
caption(
ui,
root,
SliderGeometry::RGBA_X,
SliderGeometry::TITLE_Y,
"RGBA CHANNELS",
);
caption(
ui,
root,
SliderGeometry::HSV_X,
SliderGeometry::TITLE_Y,
"HSV CHANNELS",
);
let mut bytes = session.current.map(|component| f32::from(byte(component)));
let before_bytes = bytes;
for (index, label) in ["R", "G", "B", "A"].into_iter().enumerate() {
slider_channel(
ui,
root,
SliderGeometry::RGBA_X,
index,
label,
&mut bytes[index],
0.0..=255.0,
"",
);
}
if bytes != before_bytes {
set_current(session, bytes.map(|component| component / 255.0));
}
let mut hsva = Hsva::from_rgba_unmultiplied(
session.current[0],
session.current[1],
session.current[2],
session.current[3],
);
let mut values = [
hsva.h * 360.0,
hsva.s * 100.0,
hsva.v * 100.0,
hsva.a * 100.0,
];
let before = values;
for (index, label) in ["H", "S", "V", "A"].into_iter().enumerate() {
let range = if index == 0 { 0.0..=360.0 } else { 0.0..=100.0 };
slider_channel(
ui,
root,
SliderGeometry::HSV_X,
index,
label,
&mut values[index],
range,
if index == 0 { "°" } else { "%" },
);
}
if values != before {
hsva.h = values[0] / 360.0;
hsva.s = values[1] / 100.0;
hsva.v = values[2] / 100.0;
hsva.a = values[3] / 100.0;
let [r, g, b] = hsva.to_rgb();
set_current(session, [r, g, b, hsva.a]);
}
}
#[allow(clippy::too_many_arguments)]
fn slider_channel(
ui: &mut egui::Ui,
root: egui::Rect,
group_x: f32,
index: usize,
label: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
suffix: &str,
) {
let palette = crate::design_system::palette(ui);
let y = SliderGeometry::FIRST_ROW_Y + index as f32 * SliderGeometry::ROW_STRIDE;
let label_rect = Geometry::at(
root,
group_x,
y,
SliderGeometry::LABEL_WIDTH,
SliderGeometry::ROW_HEIGHT,
);
text(
ui,
label_rect.left_center(),
egui::Align2::LEFT_CENTER,
label,
TypeRole::Body.font(),
palette.text_secondary,
);
ui.put(
Geometry::at(
root,
group_x + SliderGeometry::TRACK_X,
y,
SliderGeometry::TRACK_WIDTH,
SliderGeometry::ROW_HEIGHT,
),
egui::Slider::new(value, range.clone()).show_value(false),
);
ui.put(
Geometry::at(
root,
group_x + SliderGeometry::VALUE_X,
y,
SliderGeometry::VALUE_WIDTH,
SliderGeometry::ROW_HEIGHT,
),
egui::DragValue::new(value)
.range(range)
.suffix(suffix)
.max_decimals(0),
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sliders_mode_matches_the_two_exported_channel_groups() {
assert_eq!(SliderGeometry::HSV_X - SliderGeometry::RGBA_X, 194.0);
assert_eq!(SliderGeometry::TRACK_WIDTH, 104.0);
assert_eq!(SliderGeometry::VALUE_WIDTH, 44.0);
assert_eq!(SliderGeometry::VALUE_X + SliderGeometry::VALUE_WIDTH, 178.0);
assert_eq!(SliderGeometry::ROW_STRIDE, 30.0);
assert_eq!(SliderGeometry::ROW_HEIGHT, 24.0);
}
}

View File

@ -0,0 +1,381 @@
//! Reusable Penpot-styled editor controls with no world or asset-store access.
use egui;
use egui_phosphor_icons::{icons, Icon};
use std::ops::RangeInclusive;
use super::palette;
use super::typography::TypeRole;
// 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.
const SCALAR_TRACK_X: f32 = 6.0;
const SCALAR_NUMBER_GAP: f32 = 6.0;
const SCALAR_TRACK_INTERACTION_HEIGHT: f32 = 10.0;
const SCALAR_NUMBER_WIDTH: f32 = 33.0;
const SCALAR_THUMB_RADIUS: f32 = 4.0;
pub fn section_frame(ui: &egui::Ui) -> egui::Frame {
let palette = palette(ui);
egui::Frame::new()
.fill(palette.section)
.stroke(egui::Stroke::new(1.0_f32, palette.border))
.corner_radius(egui::CornerRadius::same(5))
}
pub fn section_header(ui: &mut egui::Ui, title: &str, open: &mut bool) -> egui::Response {
section_header_with_summary(ui, title, None, open)
}
pub fn section_header_with_summary(
ui: &mut egui::Ui,
title: &str,
summary: Option<&str>,
open: &mut bool,
) -> egui::Response {
let palette = palette(ui);
let (rect, response) = ui.allocate_exact_size(
egui::vec2(
ui.available_width().max(1.0),
super::INPUT_SECTION_HEADER_HEIGHT,
),
egui::Sense::click(),
);
ui.painter().rect_filled(rect, 4.0, palette.elevated);
ui.painter().text(
rect.left_center() + egui::vec2(12.0, 0.0),
egui::Align2::CENTER_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(
rect.left_center() + egui::vec2(26.0, 0.0),
egui::Align2::LEFT_CENTER,
title,
TypeRole::Section.font(),
palette.text_primary,
);
if let Some(summary) = summary {
ui.painter().text(
rect.right_center() - egui::vec2(12.0, 0.0),
egui::Align2::RIGHT_CENTER,
summary,
TypeRole::Small.font(),
palette.text_muted,
);
}
if response.clicked() {
*open = !*open;
}
response
}
/// Compose a Phosphor glyph and Source Sans label without asking either font to render the
/// other's codepoints. This is the canonical path for icon-plus-text controls in a design-system
/// scope.
pub fn icon_label(
icon: Icon,
label: &str,
role: TypeRole,
icon_size: f32,
color: egui::Color32,
) -> egui::WidgetText {
let mut job = egui::text::LayoutJob::default();
job.append(
icon.as_str(),
0.0,
egui::TextFormat {
font_id: egui::FontId::new(icon_size, egui::FontFamily::Name("phosphor-bold".into())),
color,
..Default::default()
},
);
if !label.is_empty() {
job.append(
label,
6.0,
egui::TextFormat {
font_id: role.font(),
color,
..Default::default()
},
);
}
job.into()
}
pub fn icon_button(ui: &mut egui::Ui, icon: Icon, tooltip: &str, enabled: bool) -> egui::Response {
let palette = palette(ui);
let response = ui.add_enabled(
enabled,
egui::Button::new(
egui::RichText::new(icon.as_str())
.font(egui::FontId::new(
14.0,
egui::FontFamily::Name("phosphor-bold".into()),
))
.color(palette.text_secondary),
)
.min_size(egui::vec2(24.0, 24.0))
.fill(palette.control)
.stroke(egui::Stroke::new(1.0_f32, palette.border))
.corner_radius(egui::CornerRadius::same(4)),
);
response.on_hover_text(tooltip)
}
pub fn status(ui: &mut egui::Ui, icon: Icon, color: egui::Color32, label: &str, tooltip: &str) {
let palette = palette(ui);
let mut job = egui::text::LayoutJob::default();
job.append(
icon.as_str(),
0.0,
egui::TextFormat {
font_id: egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-bold".into())),
color,
..Default::default()
},
);
job.append(
label,
5.0,
egui::TextFormat {
font_id: TypeRole::Small.font(),
color: palette.text_secondary,
..Default::default()
},
);
let response = ui.add(egui::Label::new(job).sense(egui::Sense::hover()));
response.on_hover_text(tooltip);
}
pub fn switch(ui: &mut egui::Ui, value: &mut bool) -> egui::Response {
let palette = palette(ui);
let (rect, mut response) = ui.allocate_exact_size(egui::vec2(31.0, 16.0), egui::Sense::click());
if response.clicked() {
*value = !*value;
response.mark_changed();
}
let fill = if *value {
palette.accent_dark
} else {
palette.control
};
let stroke = if *value {
palette.accent
} else {
palette.border_strong
};
ui.painter().rect(
rect,
8.0,
fill,
egui::Stroke::new(1.0_f32, stroke),
egui::StrokeKind::Inside,
);
let x = if *value {
rect.right() - 8.0
} else {
rect.left() + 8.0
};
ui.painter()
.circle_filled(egui::pos2(x, rect.center().y), 5.0, palette.text_primary);
response
}
/// Fixed 128×24 two-option control used by the Penpot material Surface section.
///
/// Returning the selected side keeps the control independent from material state and prevents
/// stock `selectable_label` padding from changing its geometry.
pub fn two_option_segmented(
ui: &mut egui::Ui,
left_label: &str,
right_label: &str,
right_selected: bool,
) -> Option<bool> {
const WIDTH: f32 = 128.0;
const HEIGHT: f32 = 24.0;
const OPTION_WIDTH: f32 = WIDTH * 0.5;
let palette = palette(ui);
let (rect, _) = ui.allocate_exact_size(egui::vec2(WIDTH, HEIGHT), egui::Sense::hover());
ui.painter().rect(
rect,
4.0,
palette.control,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
let left = egui::Rect::from_min_size(rect.min, egui::vec2(OPTION_WIDTH, HEIGHT));
let right = egui::Rect::from_min_size(
rect.min + egui::vec2(OPTION_WIDTH, 0.0),
egui::vec2(OPTION_WIDTH, HEIGHT),
);
let mut selection = None;
for (option_rect, label, selected, value) in [
(left, left_label, !right_selected, false),
(right, right_label, right_selected, true),
] {
let response = ui.interact(
option_rect,
ui.make_persistent_id(("two_option_segment", label)),
egui::Sense::click(),
);
let fill = if selected {
palette.accent_dark
} else if response.hovered() {
palette.elevated
} else {
palette.control
};
ui.painter().rect_filled(option_rect.shrink(1.0), 3.0, fill);
if selected {
ui.painter().rect_stroke(
option_rect,
3.0,
egui::Stroke::new(1.0_f32, palette.accent),
egui::StrokeKind::Inside,
);
}
ui.painter().text(
option_rect.center(),
egui::Align2::CENTER_CENTER,
label,
TypeRole::Control.font(),
if selected {
palette.text_primary
} else {
palette.text_secondary
},
);
if response.clicked() && right_selected != value {
selection = Some(value);
}
}
selection
}
/// Exact Penpot scalar editor used by material inputs.
///
/// The track and numeric field are painted as independent controls so egui's stock slider
/// padding cannot move the thumb into the value field at the reference width.
pub fn scalar_control(
ui: &mut egui::Ui,
salt: impl std::hash::Hash,
value: &mut f32,
range: RangeInclusive<f32>,
max_decimals: usize,
) -> egui::Response {
let palette = palette(ui);
let before = *value;
// The final Penpot component deliberately stretches only the track at the compact
// breakpoint: 93.8 px at the 569 px reference and 160.3 px at the 369 px reference.
// The numeric field remains a stable 33 px in both modes, after a six-pixel leading inset
// and a six-pixel track-to-number gap.
let width = ui.available_width().max(1.0);
let (rect, base_response) =
ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover());
let number_width = SCALAR_NUMBER_WIDTH.min(width);
let number_x = (width - number_width).max(0.0);
let track_width = (number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP).max(1.0);
let slider_rect = egui::Rect::from_min_size(
rect.min + egui::vec2(SCALAR_TRACK_X, 6.0),
egui::vec2(track_width, SCALAR_TRACK_INTERACTION_HEIGHT),
);
let track_rect =
egui::Rect::from_center_size(slider_rect.center(), egui::vec2(track_width, 3.0));
let slider_response = ui.interact(
slider_rect,
ui.make_persistent_id(("penpot_scalar", salt)),
egui::Sense::click_and_drag(),
);
if slider_response.clicked() || slider_response.dragged() {
if let Some(pointer) = slider_response.interact_pointer_pos() {
let start = *range.start();
let end = *range.end();
let normalized = ((pointer.x - track_rect.left()) / track_rect.width()).clamp(0.0, 1.0);
*value = egui::lerp(start..=end, normalized);
}
}
let start = *range.start();
let end = *range.end();
let normalized = if end > start {
((*value - start) / (end - start)).clamp(0.0, 1.0)
} else {
0.0
};
ui.painter().rect_filled(track_rect, 2.0, palette.border);
let fill = egui::Rect::from_min_max(
track_rect.left_top(),
egui::pos2(
egui::lerp(track_rect.x_range(), normalized),
track_rect.bottom(),
),
);
ui.painter().rect_filled(fill, 2.0, palette.accent);
let thumb_center = egui::pos2(
egui::lerp(track_rect.x_range(), normalized),
track_rect.center().y,
);
ui.painter().circle(
thumb_center,
SCALAR_THUMB_RADIUS,
palette.text_primary,
egui::Stroke::new(1.0_f32, palette.accent),
);
let number_rect = egui::Rect::from_min_size(
rect.min + egui::vec2(number_x, 0.0),
egui::vec2(number_width, 22.0),
);
let number_response = ui.put(
number_rect,
egui::DragValue::new(value)
.speed(0.01)
.range(range)
.max_decimals(max_decimals),
);
let mut response = base_response.union(slider_response).union(number_response);
if *value != before {
response.mark_changed();
}
response
}
#[cfg(test)]
mod scalar_tests {
use super::*;
#[test]
fn penpot_scalar_geometry_matches_the_current_export() {
let wide_width = 138.8;
let wide_number_x = wide_width - SCALAR_NUMBER_WIDTH;
let wide_track = wide_number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP;
assert!((wide_track - 93.8).abs() < 0.001);
let compact_width = 205.3;
let compact_number_x = compact_width - SCALAR_NUMBER_WIDTH;
let compact_track = compact_number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP;
assert!((compact_track - 160.3).abs() < 0.001);
assert_eq!(SCALAR_TRACK_INTERACTION_HEIGHT, 10.0);
assert_eq!(SCALAR_THUMB_RADIUS, 4.0);
}
}
#[cfg(test)]
mod segmented_tests {
#[test]
fn penpot_segmented_control_has_two_fixed_halves() {
const WIDTH: f32 = 128.0;
const OPTION_WIDTH: f32 = WIDTH * 0.5;
assert_eq!(OPTION_WIDTH, 64.0);
assert_eq!(OPTION_WIDTH * 2.0, WIDTH);
}
}

View File

@ -0,0 +1,153 @@
//! Penpot-led editor design-system primitives.
//!
//! The first consumer is the Material Slot panel. These semantic tokens and controls are kept
//! independent from material/world state so later editor panels can migrate without copying style.
pub mod color_picker;
pub mod controls;
pub mod property_grid;
pub mod typography;
use egui::{self, Color32, CornerRadius, Stroke};
use crate::theme::EditorVisualPalette;
pub const REFERENCE_WIDTH: f32 = 620.0;
pub const INSPECTOR_MIN_WIDTH: f32 = 420.0;
pub const OUTER_PADDING: f32 = 12.0;
pub const SECTION_GAP: f32 = 8.0;
pub const MATERIALS_HEADING_HEIGHT: f32 = 32.0;
pub const HEADER_HEIGHT: f32 = 82.0;
pub const COLLAPSED_HEADER_HEIGHT: f32 = 52.0;
pub const MATERIAL_PREVIEW_SIZE: f32 = 64.0;
pub const MATERIAL_IDENTITY_WIDTH: f32 = 229.5;
pub const SURFACE_SECTION_HEIGHT: f32 = 46.0;
pub const COMPACT_SURFACE_SECTION_HEIGHT: f32 = 46.0;
pub const INPUT_SECTION_HEADER_HEIGHT: f32 = 24.0;
pub const PARAMETER_ROW_HEIGHT: f32 = 32.0;
pub const COMPACT_PARAMETER_ROW_HEIGHT: f32 = 58.0;
pub const UV_SECTION_HEIGHT: f32 = 54.0;
pub const COMPACT_UV_SECTION_HEIGHT: f32 = 94.0;
pub const ADVANCED_COLLAPSED_HEIGHT: f32 = 30.0;
pub const ADVANCED_EXPANDED_HEIGHT: f32 = 178.0;
pub const CONTROL_HEIGHT: f32 = 24.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DesignPalette {
pub accent: Color32,
pub accent_dark: Color32,
pub panel: Color32,
pub section: Color32,
pub recessed: Color32,
pub control: Color32,
pub elevated: Color32,
pub row_even: Color32,
pub row_odd: Color32,
pub checker: Color32,
pub border: Color32,
pub border_strong: Color32,
pub text_primary: Color32,
pub text_secondary: Color32,
pub text_muted: Color32,
pub healthy: Color32,
pub warning: Color32,
pub error: Color32,
}
impl From<EditorVisualPalette> for DesignPalette {
fn from(value: EditorVisualPalette) -> Self {
Self {
accent: value.selection,
accent_dark: value.selection_bg,
panel: value.panel,
section: value.widget,
recessed: value.panel_dark,
control: value.widget,
elevated: value.elevated,
row_even: value.panel,
row_odd: value.widget,
checker: value.elevated,
border: value.border,
border_strong: value.border_strong,
text_primary: value.text,
text_secondary: value.text_dim,
text_muted: value.text_muted,
healthy: value.success,
warning: value.warning,
error: value.error,
}
}
}
pub fn palette(ui: &egui::Ui) -> DesignPalette {
crate::theme::editor_palette(ui.ctx()).into()
}
/// Applies the Penpot material-panel visual language locally without restyling the whole editor.
pub fn scope<R>(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui) -> R) -> R {
let palette = palette(ui);
ui.scope(|ui| {
let style = ui.style_mut();
style.spacing.item_spacing = egui::vec2(8.0, 4.0);
style.spacing.button_padding = egui::vec2(8.0, 3.0);
style.spacing.interact_size = egui::vec2(31.0, CONTROL_HEIGHT);
style.visuals.widgets.noninteractive.bg_fill = palette.section;
style.visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0_f32, palette.border);
style.visuals.widgets.noninteractive.fg_stroke =
Stroke::new(1.0_f32, palette.text_secondary);
style.visuals.widgets.inactive.bg_fill = palette.control;
style.visuals.widgets.inactive.weak_bg_fill = palette.control;
style.visuals.widgets.inactive.bg_stroke = Stroke::new(1.0_f32, palette.border);
style.visuals.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, palette.text_primary);
style.visuals.widgets.hovered.bg_fill = palette.elevated;
style.visuals.widgets.hovered.weak_bg_fill = palette.elevated;
style.visuals.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, palette.border_strong);
style.visuals.widgets.hovered.fg_stroke = Stroke::new(1.0_f32, palette.text_primary);
style.visuals.widgets.active.bg_fill = palette.accent_dark;
style.visuals.widgets.active.weak_bg_fill = palette.accent_dark;
style.visuals.widgets.active.bg_stroke = Stroke::new(1.0_f32, palette.accent);
style.visuals.widgets.active.fg_stroke = Stroke::new(1.0_f32, palette.text_primary);
style.visuals.selection.bg_fill = palette.accent_dark;
style.visuals.selection.stroke = Stroke::new(1.0_f32, palette.accent);
style.visuals.window_fill = palette.panel;
style.visuals.window_stroke = Stroke::new(1.0_f32, palette.border_strong);
style.visuals.window_corner_radius = CornerRadius::same(7);
style.visuals.menu_corner_radius = CornerRadius::same(5);
style.visuals.override_text_color = Some(palette.text_primary);
typography::install_local_text_styles(style);
add_contents(ui)
})
.inner
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn penpot_geometry_tokens_are_exact() {
assert_eq!(REFERENCE_WIDTH, 620.0);
assert_eq!(MATERIALS_HEADING_HEIGHT, 32.0);
assert_eq!(HEADER_HEIGHT, 82.0);
assert_eq!(COLLAPSED_HEADER_HEIGHT, 52.0);
assert_eq!(MATERIAL_PREVIEW_SIZE, 64.0);
assert_eq!(MATERIAL_IDENTITY_WIDTH, 229.5);
assert_eq!(SURFACE_SECTION_HEIGHT, 46.0);
assert_eq!(COMPACT_SURFACE_SECTION_HEIGHT, 46.0);
assert_eq!(INPUT_SECTION_HEADER_HEIGHT, 24.0);
assert_eq!(PARAMETER_ROW_HEIGHT, 32.0);
assert_eq!(COMPACT_PARAMETER_ROW_HEIGHT, 58.0);
assert_eq!(UV_SECTION_HEIGHT, 54.0);
assert_eq!(COMPACT_UV_SECTION_HEIGHT, 94.0);
assert_eq!(ADVANCED_COLLAPSED_HEIGHT, 30.0);
assert_eq!(ADVANCED_EXPANDED_HEIGHT, 178.0);
}
#[test]
fn material_design_palette_uses_blacksite_semantics() {
let palette = DesignPalette::from(crate::theme::BLACKSITE_PALETTE);
assert_eq!(palette.accent, crate::theme::SELECTION);
assert_eq!(palette.panel, crate::theme::PANEL_BG);
assert_eq!(palette.text_primary, crate::theme::TEXT);
}
}

View File

@ -0,0 +1,294 @@
//! Testable geometry for dense Penpot material property rows.
use super::{COMPACT_PARAMETER_ROW_HEIGHT, PARAMETER_ROW_HEIGHT};
use egui;
/// Minimum live section width that can safely retain the exported wide-row geometry.
///
/// The Penpot reference section itself is 569 px wide. In the native Inspector, dock chrome,
/// component-card borders, and the floating scrollbar consume up to ten pixels before the
/// material parameter body is allocated. Treat that 559 px contained canvas as wide so a 640 px
/// Inspector renders the authoritative wide reference instead of incorrectly switching to the
/// 420 px two-line rows.
pub const WIDE_SECTION_WIDTH: f32 = 559.0;
pub const RESPONSIVE_BREAKPOINT: f32 = WIDE_SECTION_WIDTH;
/// The actor component chrome can consume up to twelve pixels of the exported 372 px compact
/// canvas. At 360 px the complete texture row still fits: 12 px inset + 336 px group + 12 px inset.
const COMPACT_INLINE_ACTION_FIT_WIDTH: f32 = 357.0;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PropertyGridGeometry {
pub stacked: bool,
pub row_height: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PropertyRowRects {
pub row: egui::Rect,
pub label: egui::Rect,
pub value: egui::Rect,
pub color_value: egui::Rect,
pub channel: egui::Rect,
pub texture_group: egui::Rect,
pub texture_field: egui::Rect,
pub locate: egui::Rect,
pub clear: egui::Rect,
pub inline_texture_actions: bool,
}
impl PropertyGridGeometry {
pub fn for_width(section_width: f32) -> Self {
if section_width + f32::EPSILON >= RESPONSIVE_BREAKPOINT {
Self {
stacked: false,
row_height: PARAMETER_ROW_HEIGHT,
}
} else {
Self {
stacked: true,
row_height: COMPACT_PARAMETER_ROW_HEIGHT,
}
}
}
pub fn row_rects(self, row: egui::Rect) -> PropertyRowRects {
let inset = 12.0_f32.min(row.width() * 0.5);
let inner_width = (row.width() - inset * 2.0).max(1.0);
if !self.stacked {
let texture_group_width = (inner_width - 286.5).max(60.0);
let texture_field_width = (texture_group_width - 60.0).max(1.0);
let texture_group = egui::Rect::from_min_size(
row.min + egui::vec2(298.5, 5.0),
egui::vec2(texture_group_width, 22.0),
);
return PropertyRowRects {
row,
label: egui::Rect::from_min_size(
row.min + egui::vec2(12.0, 5.0),
egui::vec2(82.0, 22.0),
),
value: egui::Rect::from_min_size(
row.min + egui::vec2(103.35, 5.0),
egui::vec2(138.8, 22.0),
),
color_value: egui::Rect::from_min_size(
row.min + egui::vec2(107.95, 5.0),
egui::vec2(182.55, 22.0),
),
channel: egui::Rect::from_min_size(
row.min + egui::vec2(242.5, 5.0),
egui::vec2(48.0, 22.0),
),
texture_group,
texture_field: egui::Rect::from_min_size(
texture_group.min,
egui::vec2(texture_field_width, 22.0),
),
locate: egui::Rect::from_min_size(
texture_group.min + egui::vec2(texture_field_width + 6.0, 0.0),
egui::vec2(24.0, 22.0),
),
clear: egui::Rect::from_min_size(
texture_group.min + egui::vec2(texture_field_width + 36.0, 0.0),
egui::vec2(24.0, 22.0),
),
inline_texture_actions: true,
};
}
let channel_width = 48.0_f32.min(inner_width);
let channel_x = inset + inner_width - channel_width;
let value_x = (inset + 91.35).min(channel_x);
// The scalar's visible children finish 0.35 px before the channel. Penpot's transparent
// parent frame is six pixels narrower and is not the actual clipping boundary.
let value_width = (channel_x - value_x - 0.35).max(1.0);
let color_value_x = (inset + 95.95).min(channel_x);
let color_value_width = (inset + inner_width - color_value_x).max(1.0);
let texture_group = egui::Rect::from_min_size(
row.min + egui::vec2(inset, 31.0),
egui::vec2(inner_width, 22.0),
);
let inline_texture_actions = row.width() + f32::EPSILON >= COMPACT_INLINE_ACTION_FIT_WIDTH;
let texture_field_width = if inline_texture_actions {
(inner_width - 60.0).max(1.0)
} else {
(inner_width - 30.0).max(1.0)
};
let overflow_or_locate_x = texture_field_width + 6.0;
PropertyRowRects {
row,
label: egui::Rect::from_min_size(
row.min + egui::vec2(inset, 5.0),
egui::vec2(78.0_f32.min(inner_width), 22.0),
),
value: egui::Rect::from_min_size(
row.min + egui::vec2(value_x, 5.0),
egui::vec2(value_width, 22.0),
),
color_value: egui::Rect::from_min_size(
row.min + egui::vec2(color_value_x, 5.0),
egui::vec2(color_value_width, 22.0),
),
channel: egui::Rect::from_min_size(
row.min + egui::vec2(channel_x, 5.0),
egui::vec2(channel_width, 22.0),
),
texture_group,
texture_field: egui::Rect::from_min_size(
texture_group.min,
egui::vec2(texture_field_width, 22.0),
),
locate: egui::Rect::from_min_size(
texture_group.min + egui::vec2(overflow_or_locate_x, 0.0),
egui::vec2(24.0, 22.0),
),
clear: egui::Rect::from_min_size(
texture_group.min
+ egui::vec2(
if inline_texture_actions {
texture_field_width + 36.0
} else {
inner_width
},
0.0,
),
egui::vec2(if inline_texture_actions { 24.0 } else { 0.0 }, 22.0),
),
inline_texture_actions,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn penpot_exact_reference_geometry_matches_columns() {
let geometry = PropertyGridGeometry::for_width(569.0);
assert!(!geometry.stacked);
assert_eq!(geometry.row_height, 32.0);
}
#[test]
fn native_wide_containment_threshold_keeps_reference_rows() {
let geometry = PropertyGridGeometry::for_width(559.0);
assert!(!geometry.stacked);
assert_eq!(geometry.row_height, 32.0);
}
#[test]
fn below_native_wide_containment_threshold_reflows_without_compression() {
let geometry = PropertyGridGeometry::for_width(558.0);
assert!(geometry.stacked);
assert_eq!(geometry.row_height, 58.0);
}
#[test]
fn row_rects_match_exported_wide_geometry() {
let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(569.0, 32.0));
let rects = PropertyGridGeometry::for_width(569.0).row_rects(row);
assert_eq!(rects.label.min, egui::pos2(12.0, 5.0));
assert_eq!(rects.value.min, egui::pos2(103.35, 5.0));
assert!((rects.value.width() - 138.8).abs() < 0.001);
assert_eq!(rects.value.height(), 22.0);
assert_eq!(rects.color_value.min, egui::pos2(107.95, 5.0));
assert!((rects.color_value.width() - 182.55).abs() < 0.001);
assert_eq!(
rects.channel,
egui::Rect::from_min_size(egui::pos2(242.5, 5.0), egui::vec2(48.0, 22.0))
);
assert_eq!(
rects.texture_group,
egui::Rect::from_min_size(egui::pos2(298.5, 5.0), egui::vec2(258.5, 22.0))
);
assert_eq!(rects.texture_field.size(), egui::vec2(198.5, 22.0));
assert_eq!(rects.locate.min, egui::pos2(503.0, 5.0));
assert_eq!(rects.clear.min, egui::pos2(533.0, 5.0));
assert!(rects.inline_texture_actions);
}
#[test]
fn row_rects_match_exported_compact_geometry() {
let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(369.0, 58.0));
let rects = PropertyGridGeometry::for_width(369.0).row_rects(row);
assert_eq!(rects.label.min, egui::pos2(12.0, 5.0));
assert_eq!(rects.value.min, egui::pos2(103.35, 5.0));
assert!((rects.value.width() - 205.3).abs() < 0.001);
assert_eq!(rects.color_value.min, egui::pos2(107.95, 5.0));
assert!((rects.color_value.width() - 249.05).abs() < 0.001);
assert_eq!(
rects.channel,
egui::Rect::from_min_size(egui::pos2(309.0, 5.0), egui::vec2(48.0, 22.0))
);
assert_eq!(
rects.texture_group,
egui::Rect::from_min_size(egui::pos2(12.0, 31.0), egui::vec2(345.0, 22.0))
);
assert_eq!(rects.texture_field.size(), egui::vec2(285.0, 22.0));
assert_eq!(rects.locate.min, egui::pos2(303.0, 31.0));
assert_eq!(rects.clear.min, egui::pos2(333.0, 31.0));
assert!(rects.inline_texture_actions);
}
#[test]
fn transient_row_rects_are_finite_and_contained() {
for width in [356.0, 300.0, 240.0] {
let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 58.0));
let rects = PropertyGridGeometry::for_width(width).row_rects(row);
for rect in [
rects.label,
rects.value,
rects.color_value,
rects.channel,
rects.texture_group,
rects.texture_field,
rects.locate,
rects.clear,
] {
assert!(rect.is_finite());
assert!(row.contains_rect(rect));
}
assert!(!rects.inline_texture_actions);
}
}
#[test]
fn inspector_chrome_reduced_floor_keeps_compact_texture_actions() {
let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(363.0, 58.0));
let rects = PropertyGridGeometry::for_width(363.0).row_rects(row);
assert!(rects.inline_texture_actions);
assert_eq!(rects.texture_group.min, egui::pos2(12.0, 31.0));
assert_eq!(rects.texture_group.max, egui::pos2(351.0, 53.0));
assert!(row.contains_rect(rects.locate));
assert!(row.contains_rect(rects.clear));
}
#[test]
fn penpot_wide_growth_only_expands_texture_field() {
let reference_row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(569.0, 32.0));
let wide_row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(669.0, 32.0));
let reference = PropertyGridGeometry::for_width(569.0).row_rects(reference_row);
let wide = PropertyGridGeometry::for_width(669.0).row_rects(wide_row);
assert_eq!(wide.label, reference.label);
assert_eq!(wide.value, reference.value);
assert_eq!(wide.color_value, reference.color_value);
assert_eq!(wide.channel, reference.channel);
assert_eq!(
wide.texture_field.width(),
reference.texture_field.width() + 100.0
);
}
#[test]
fn penpot_very_narrow_first_line_never_exceeds_inner_width() {
let geometry = PropertyGridGeometry::for_width(300.0);
assert!(geometry.stacked);
let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 58.0));
let rects = geometry.row_rects(row);
assert!(row.contains_rect(rects.label));
assert!(row.contains_rect(rects.value));
assert!(row.contains_rect(rects.channel));
assert_eq!(rects.texture_group.width(), 276.0);
}
}

View File

@ -0,0 +1,94 @@
//! Source Sans typography roles used by the Penpot-led editor visual system.
use egui::{self, FontFamily, FontId, RichText, Style, TextStyle};
use crate::fonts::{SOURCE_SANS_BOLD, SOURCE_SANS_REGULAR};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TypeRole {
Caption,
Eyebrow,
Small,
Body,
Control,
Section,
AssetLabel,
AssetTitle,
Title,
}
impl TypeRole {
pub const fn size(self) -> f32 {
match self {
Self::Caption | Self::Eyebrow => 9.0,
Self::Small => 10.0,
Self::Body => 11.0,
Self::Control | Self::AssetLabel => 10.0,
Self::Section => 11.0,
Self::AssetTitle => 13.0,
Self::Title => 14.0,
}
}
pub const fn bold(self) -> bool {
matches!(
self,
Self::Eyebrow | Self::Section | Self::AssetLabel | Self::AssetTitle | Self::Title
)
}
pub fn font(self) -> FontId {
let family = if self.bold() {
SOURCE_SANS_BOLD
} else {
SOURCE_SANS_REGULAR
};
FontId::new(self.size(), FontFamily::Name(family.into()))
}
pub fn text(self, value: impl Into<String>) -> RichText {
RichText::new(value).font(self.font())
}
}
pub fn install_local_text_styles(style: &mut Style) {
style
.text_styles
.insert(TextStyle::Body, TypeRole::Body.font());
style
.text_styles
.insert(TextStyle::Button, TypeRole::Control.font());
style
.text_styles
.insert(TextStyle::Small, TypeRole::Small.font());
style
.text_styles
.insert(TextStyle::Heading, TypeRole::Title.font());
style.text_styles.insert(
TextStyle::Monospace,
FontId::new(11.0, egui::FontFamily::Monospace),
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn penpot_type_scale_and_weights_are_stable() {
assert_eq!(TypeRole::Caption.size(), 9.0);
assert_eq!(TypeRole::Eyebrow.size(), 9.0);
assert_eq!(TypeRole::Small.size(), 10.0);
assert_eq!(TypeRole::Body.size(), 11.0);
assert_eq!(TypeRole::Control.size(), 10.0);
assert_eq!(TypeRole::Section.size(), 11.0);
assert_eq!(TypeRole::AssetLabel.size(), 10.0);
assert_eq!(TypeRole::AssetTitle.size(), 13.0);
assert_eq!(TypeRole::Title.size(), 14.0);
assert!(!TypeRole::Body.bold());
assert!(TypeRole::Eyebrow.bold());
assert!(TypeRole::Section.bold());
assert!(TypeRole::AssetLabel.bold());
assert!(TypeRole::AssetTitle.bold());
}
}

View File

@ -0,0 +1,54 @@
//! Source Sans Pro and Phosphor font registration for every Blacksite egui host.
use std::sync::Arc;
pub const SOURCE_SANS_REGULAR: &str = "blacksite-source-sans-regular";
pub const SOURCE_SANS_BOLD: &str = "blacksite-source-sans-bold";
pub fn font_definitions() -> egui::FontDefinitions {
let mut fonts = egui::FontDefinitions::default();
egui_phosphor_icons::add_fonts(&mut fonts);
fonts.font_data.insert(
SOURCE_SANS_REGULAR.to_owned(),
Arc::new(egui::FontData::from_static(include_bytes!(
"../assets/fonts/source-sans/SourceSansPro-Regular.ttf"
))),
);
fonts.font_data.insert(
SOURCE_SANS_BOLD.to_owned(),
Arc::new(egui::FontData::from_static(include_bytes!(
"../assets/fonts/source-sans/SourceSansPro-Bold.ttf"
))),
);
fonts
.families
.entry(egui::FontFamily::Proportional)
.or_default()
.insert(0, SOURCE_SANS_REGULAR.to_owned());
fonts.families.insert(
egui::FontFamily::Name(SOURCE_SANS_REGULAR.into()),
vec![SOURCE_SANS_REGULAR.to_owned()],
);
fonts.families.insert(
egui::FontFamily::Name(SOURCE_SANS_BOLD.into()),
vec![SOURCE_SANS_BOLD.to_owned(), SOURCE_SANS_REGULAR.to_owned()],
);
let fallbacks = fonts
.families
.get(&egui::FontFamily::Proportional)
.cloned()
.unwrap_or_default();
for (family, font_names) in &mut fonts.families {
let egui::FontFamily::Name(name) = family else {
continue;
};
if name.starts_with("phosphor-") {
for fallback in &fallbacks {
if !font_names.contains(fallback) {
font_names.push(fallback.clone());
}
}
}
}
fonts
}

View File

@ -0,0 +1,262 @@
//! Action-returning Inspector component-card chrome shared by production and gallery fixtures.
use egui;
use egui_phosphor_icons::{icons, Icon};
use crate::design_system;
use crate::design_system::typography::TypeRole;
use crate::theme::{BORDER, ERROR, SELECTION, SUCCESS, TEXT, TEXT_DIM, TEXT_MUTED, WIDGET_BG};
pub const COMPONENT_HEADER_HEIGHT: f32 = 50.0;
const COMPONENT_RAIL_X: f32 = 7.0;
const COMPONENT_CARET_CENTER_X: f32 = 26.0;
const COMPONENT_ICON_CENTER_X: f32 = 51.0;
const COMPONENT_IDENTITY_X: f32 = 69.0;
const COMPONENT_ACTIONS_WIDTH: f32 = 104.0;
#[derive(Debug, Clone, Copy)]
pub struct ComponentCardOptions {
pub type_name: &'static str,
pub title: &'static str,
pub icon: Icon,
pub active_toggle: bool,
pub removable: bool,
pub reorderable: bool,
pub resettable: bool,
pub copyable: bool,
pub summary: &'static str,
pub body_margin: i8,
}
impl ComponentCardOptions {
pub const fn removable(type_name: &'static str, title: &'static str, icon: Icon) -> Self {
Self {
type_name,
title,
icon,
active_toggle: true,
removable: true,
reorderable: true,
resettable: true,
copyable: true,
summary: "Authoring component",
body_margin: 12,
}
}
pub const fn fixed(type_name: &'static str, title: &'static str, icon: Icon) -> Self {
Self {
type_name,
title,
icon,
active_toggle: false,
removable: false,
reorderable: false,
resettable: true,
copyable: false,
summary: "Authoring component",
body_margin: 12,
}
}
}
#[derive(Debug, Clone)]
pub struct ComponentCardViewModel {
pub options: ComponentCardOptions,
pub active: bool,
pub collapsed: bool,
pub pasteable: bool,
pub can_move_up: bool,
pub can_move_down: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ComponentCardAction {
pub type_name: &'static str,
pub collapsed: Option<bool>,
pub active: Option<bool>,
pub reset: bool,
pub copy: bool,
pub paste: bool,
pub move_up: bool,
pub move_down: bool,
pub remove: bool,
}
pub fn component_card(
ui: &mut egui::Ui,
view: &ComponentCardViewModel,
add_contents: impl FnOnce(&mut egui::Ui),
) -> ComponentCardAction {
let mut action = ComponentCardAction {
type_name: view.options.type_name,
..Default::default()
};
design_system::scope(ui, |ui| {
egui::Frame::new()
.fill(WIDGET_BG)
.stroke(egui::Stroke::new(1.0_f32, BORDER))
.corner_radius(egui::CornerRadius::same(5))
.show(ui, |ui| {
let width = ui.available_width().max(1.0);
let (header, response) = ui.allocate_exact_size(
egui::vec2(width, COMPONENT_HEADER_HEIGHT),
egui::Sense::click(),
);
ui.painter().rect_filled(
egui::Rect::from_min_size(
header.min + egui::vec2(COMPONENT_RAIL_X, 7.0),
egui::vec2(3.0, 36.0),
),
2.0,
SELECTION,
);
let caret_rect = egui::Rect::from_center_size(
egui::pos2(header.left() + COMPONENT_CARET_CENTER_X, header.center().y),
egui::vec2(20.0, 28.0),
);
if ui
.put(
caret_rect,
egui::Button::new(icon_text(
if view.collapsed {
icons::CARET_RIGHT
} else {
icons::CARET_DOWN
},
12.0,
))
.frame(false),
)
.clicked()
{
action.collapsed = Some(!view.collapsed);
}
ui.painter().text(
egui::pos2(header.left() + COMPONENT_ICON_CENTER_X, header.center().y),
egui::Align2::CENTER_CENTER,
view.options.icon.as_str(),
egui::FontId::new(18.0, egui::FontFamily::Name("phosphor-bold".into())),
TEXT_DIM,
);
ui.painter().text(
egui::pos2(header.left() + COMPONENT_IDENTITY_X, header.top() + 18.0),
egui::Align2::LEFT_CENTER,
view.options.title,
TypeRole::Title.font(),
TEXT,
);
ui.painter().text(
egui::pos2(header.left() + COMPONENT_IDENTITY_X, header.top() + 34.0),
egui::Align2::LEFT_CENTER,
view.options.summary,
TypeRole::Small.font(),
TEXT_MUTED,
);
let actions_rect = egui::Rect::from_min_size(
egui::pos2(
header.right() - COMPONENT_ACTIONS_WIDTH - 8.0,
header.top() + 11.0,
),
egui::vec2(COMPONENT_ACTIONS_WIDTH, 28.0),
);
ui.scope_builder(egui::UiBuilder::new().max_rect(actions_rect), |ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
actions_menu(ui, view, &mut action);
let color = if view.active { SUCCESS } else { TEXT_MUTED };
let status_rect = ui
.allocate_exact_size(egui::vec2(8.0, 14.0), egui::Sense::hover())
.0;
ui.painter().circle_filled(status_rect.center(), 4.0, color);
ui.label(
TypeRole::Small
.text(if view.active { "Active" } else { "Off" })
.color(TEXT_DIM),
);
});
});
if response.clicked()
&& ui
.input(|input| input.pointer.interact_pos())
.is_some_and(|point| {
!caret_rect.contains(point) && !actions_rect.contains(point)
})
{
action.collapsed = Some(!view.collapsed);
}
if !view.collapsed {
egui::Frame::new()
.inner_margin(egui::Margin {
left: view.options.body_margin,
right: view.options.body_margin,
top: 10,
bottom: view.options.body_margin,
})
.show(ui, add_contents);
}
});
});
ui.add_space(6.0);
action
}
fn actions_menu(
ui: &mut egui::Ui,
view: &ComponentCardViewModel,
action: &mut ComponentCardAction,
) {
ui.menu_button(
icon_text(icons::DOTS_THREE_VERTICAL, 16.0).color(TEXT_DIM),
|ui| {
ui.set_min_width(160.0);
action.reset |= action_button(ui, view.options.resettable, "Reset");
action.copy |= action_button(ui, view.options.copyable, "Copy Values");
action.paste |= action_button(ui, view.pasteable, "Paste Values");
ui.separator();
action.move_up |= action_button(ui, view.can_move_up, "Move Up");
action.move_down |= action_button(ui, view.can_move_down, "Move Down");
ui.separator();
ui.add_enabled(false, egui::Button::new("Open Documentation"));
if ui
.add_enabled(
view.options.removable,
egui::Button::new(egui::RichText::new("Remove").color(ERROR)),
)
.clicked()
{
action.remove = true;
ui.close();
}
},
);
}
fn action_button(ui: &mut egui::Ui, enabled: bool, label: &str) -> bool {
let clicked = ui.add_enabled(enabled, egui::Button::new(label)).clicked();
if clicked {
ui.close();
}
clicked
}
fn icon_text(icon: Icon, size: f32) -> egui::RichText {
egui::RichText::new(icon.as_str()).font(egui::FontId::new(
size,
egui::FontFamily::Name("phosphor-bold".into()),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn penpot_component_header_geometry_is_exact() {
assert_eq!(COMPONENT_HEADER_HEIGHT, 50.0);
assert_eq!(COMPONENT_RAIL_X, 7.0);
assert_eq!(COMPONENT_CARET_CENTER_X, 26.0);
assert_eq!(COMPONENT_ICON_CENTER_X, 51.0);
assert_eq!(COMPONENT_IDENTITY_X, 69.0);
assert_eq!(COMPONENT_ACTIONS_WIDTH, 104.0);
}
}

View File

@ -0,0 +1,12 @@
//! Production egui presentation shared by the Blacksite editor and lightweight UI gallery.
//!
//! This crate owns rendering, deterministic geometry, and action-returning view contracts. It does
//! not own a Bevy world, project state, authored-document publication, scene mutations, or content
//! processing.
pub mod design_system;
pub mod fonts;
pub mod inspector;
pub mod materials;
pub mod renderer;
pub mod theme;

View File

@ -0,0 +1,286 @@
//! Non-interactive Penpot v2.3.1 renderer-control preview.
use egui;
use egui_phosphor_icons::icons;
use crate::design_system::typography::TypeRole;
use crate::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.panel,
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.panel);
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(26.0, 0.0),
egui::Align2::LEFT_CENTER,
"Advanced",
TypeRole::Section.font(),
palette.text_secondary,
);
if !open && rect.width() >= 300.0 {
ui.painter().hline(
(header.left() + 82.0)..=(header.right() - 158.0),
header.center().y,
egui::Stroke::new(1.0_f32, palette.border),
);
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);
}
}

View File

@ -0,0 +1,724 @@
use super::texture_inputs::{
ensure_material_texture_binding, material_property_texture_binding,
material_texture_channel_ui, texture_binding_ui, texture_channel_label,
upsert_material_texture_binding,
};
use super::uv_transform::{material_vec2_value, set_material_vec2_value, uv_transform_section};
use super::*;
use crate::design_system;
use crate::design_system::color_picker::{color_control, color_control_with_intensity};
use crate::design_system::controls::{
scalar_control, section_header, section_header_with_summary, switch,
};
use crate::design_system::property_grid::PropertyGridGeometry;
use crate::design_system::typography::TypeRole;
use material_schema::MaterialShaderKind;
pub 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 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(material_schema::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,
material_schema::MaterialInputPresentation::Row
)
})
.collect::<Vec<_>>();
let companions = schema
.inputs
.iter()
.filter(|property| property.group == group.id)
.filter_map(|property| match &property.presentation {
material_schema::MaterialInputPresentation::Companion { owner } => {
Some((owner.as_str(), property))
}
material_schema::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);
// The design-system scope contributes four pixels between vertical items; add the
// remaining four to reach the authored eight-pixel section gap.
ui.add_space(4.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);
let context = MaterialInputGroupContext {
properties: &properties,
companions: &companions,
texture_assets,
drop_candidate,
};
let rows = material_input_group_ui(ui, context, &mut open, inputs, |ui, open| {
if group.id == "surface_inputs" {
let summary = format!("{} bindings", properties.len());
section_header_with_summary(ui, "Inputs", Some(&summary), open);
} else {
section_header(ui, &group.display_name, open);
}
});
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(4.0);
}
if has_standard_packed_inputs {
super::advanced_preview::unsupported_advanced_preview(ui);
}
response
}
#[derive(Clone, Copy)]
struct MaterialInputGroupContext<'schema, 'view> {
properties: &'view [&'schema MaterialInputDesc],
companions: &'view [(&'schema str, &'schema MaterialInputDesc)],
texture_assets: &'view [MaterialTextureCandidate],
drop_candidate: Option<&'view MaterialTextureCandidate>,
}
fn material_input_group_ui(
ui: &mut egui::Ui,
context: MaterialInputGroupContext<'_, '_>,
open: &mut bool,
inputs: &mut MaterialInputSet,
header_ui: impl FnOnce(&mut egui::Ui, &mut bool),
) -> MaterialInputEditorResponse {
let width = ui.available_width().max(1.0);
let geometry = PropertyGridGeometry::for_width(width);
let body_open = *open;
let height = material_input_group_height(width, body_open, context.properties.len());
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
let palette = design_system::palette(ui);
ui.painter().rect(
rect,
5.0,
palette.section,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
// The parent owns the complete section rect. Child controls may manage local layout state,
// but they cannot alter where the next material section begins.
let mut section = ui.new_child(egui::UiBuilder::new().max_rect(rect));
section.set_clip_rect(rect.intersect(ui.clip_rect()));
let header_rect = egui::Rect::from_min_size(
rect.min,
egui::vec2(width, design_system::INPUT_SECTION_HEADER_HEIGHT),
);
let mut header = section.new_child(egui::UiBuilder::new().max_rect(header_rect));
header.set_clip_rect(header_rect.intersect(section.clip_rect()));
header_ui(&mut header, open);
if body_open {
let rows_rect =
egui::Rect::from_min_max(egui::pos2(rect.left(), header_rect.bottom()), rect.max);
material_input_rows(&mut section, rows_rect, geometry, context, inputs)
} else {
MaterialInputEditorResponse::default()
}
}
fn material_input_group_height(width: f32, open: bool, rows: usize) -> f32 {
let body = if open {
PropertyGridGeometry::for_width(width).row_height * rows as f32
} else {
0.0
};
design_system::INPUT_SECTION_HEADER_HEIGHT + body
}
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 enum PackedMapMode {
Arm,
Separate,
}
pub 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 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 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;
}
}
fn material_input_rows(
ui: &mut egui::Ui,
rows_rect: egui::Rect,
geometry: PropertyGridGeometry,
context: MaterialInputGroupContext<'_, '_>,
inputs: &mut MaterialInputSet,
) -> MaterialInputEditorResponse {
let palette = design_system::palette(ui);
let mut response = MaterialInputEditorResponse::default();
for (row, property) in context.properties.iter().enumerate() {
let companion = context
.companions
.iter()
.find_map(|(owner, companion)| (*owner == property.name).then_some(*companion));
ui.push_id(("material_input_row", &property.name), |ui| {
let row_rect = egui::Rect::from_min_size(
rows_rect.min + egui::vec2(0.0, row as f32 * geometry.row_height),
egui::vec2(rows_rect.width(), geometry.row_height),
);
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,
context.texture_assets,
context.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, material_schema::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 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 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 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 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 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 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 input_group_parent_owns_exact_reference_and_compact_heights() {
assert_eq!(material_input_group_height(569.0, true, 6), 216.0);
assert_eq!(material_input_group_height(369.0, true, 6), 372.0);
assert_eq!(material_input_group_height(569.0, false, 6), 24.0);
}
#[test]
fn rendering_missing_standard_defaults_does_not_author_or_dirty_inputs() {
let context = egui::Context::default();
context.set_fonts(crate::fonts::font_definitions());
let mut inputs = MaterialInputSet::default();
let original = inputs.clone();
let schema = material_schema::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);
}
}

View File

@ -0,0 +1,344 @@
//! Numeric Penpot v2.3.1 material-panel geometry shared by rendering and tests.
use egui;
use crate::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::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::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()), 14.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::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::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.title.height(), 14.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));
}
}

View File

@ -0,0 +1,96 @@
//! Material-slot and schema-driven parameter widgets shared by production and the gallery.
use bevy_math::{Vec2, Vec3};
use egui;
use egui_phosphor_icons::Icon;
use material_schema::{
ColorDesc, EditorAssetRef, MaterialInputDesc, MaterialInputSchema, MaterialInputSet,
MaterialParameter, MaterialParameterValue, MaterialTextureBinding, ShaderPropertyType,
TextureChannel,
};
use crate::theme::TEXT_DIM;
mod advanced_preview;
mod inputs;
mod layout;
mod panel;
mod pickers;
mod surface;
mod texture_inputs;
mod uv_transform;
pub use inputs::{
apply_arm_texture_preset, apply_separate_texture_preset, compact_text_edit,
default_value_for_shader_property, material_input_schema_editor,
material_input_schema_editor_with_inheritance, optional_path_edit, packed_map_mode,
parameter_value_matches_property, shader_kind_combo, shader_kind_picker, PackedMapMode,
};
pub use panel::{
material_slot_panel, materials_section, MaterialHealth, MaterialLayerBadge,
MaterialPickerCandidate, MaterialSlotAction, MaterialSlotPanelAction,
MaterialSlotPanelResponse, MaterialSlotPanelViewModel, MaterialsSectionViewModel,
};
pub use surface::material_surface_options_ui;
pub use texture_inputs::ensure_material_texture_binding;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MaterialLocateTarget {
File(String),
SubAsset {
parent_path: String,
sub_asset_id: String,
label: String,
source_path: Option<String>,
},
}
#[derive(Clone, Debug)]
pub struct MaterialTextureCandidate {
pub label: String,
pub detail: String,
pub reference: EditorAssetRef,
pub locate: MaterialLocateTarget,
pub thumbnail: MaterialThumbnailPresentation,
}
#[derive(Default)]
pub struct MaterialInputEditorResponse {
pub accepted_drop: bool,
pub locate: Option<MaterialLocateTarget>,
pub browse_library: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum MaterialThumbnailPresentation {
Ready(egui::TextureId),
Pending,
Failed,
Empty,
}
#[derive(Clone, Copy)]
enum TextureSlotSelection<'a> {
Reference(Option<&'a EditorAssetRef>),
}
#[derive(Default)]
struct TextureSlotResponse {
selected: Option<MaterialTextureCandidate>,
locate: Option<MaterialLocateTarget>,
clear: bool,
accepted_drop: bool,
browse_library: bool,
}
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-bold".into()),
))
}

View File

@ -0,0 +1,773 @@
//! Action-returning Penpot material-slot shell shared by primitive and mesh inspectors.
use egui;
use egui_phosphor_icons::icons;
use material_schema::{EditorAssetRef, MaterialShaderKind};
use super::layout::MaterialPanelGeometry;
use super::MaterialThumbnailPresentation;
use crate::design_system::controls::status;
use crate::design_system::typography::TypeRole;
use crate::design_system::{self, DesignPalette, HEADER_HEIGHT};
mod header_actions;
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
// origins compensate for that inset so the painted rail/caret/preview retain the exported board
// coordinates relative to the outer slot rectangle.
const SLOT_ACCENT_X: f32 = 7.0;
const SLOT_CARET_CENTER_X: f32 = 22.0;
const SLOT_PREVIEW_X: f32 = 34.0;
const SHADER_CONTROL_WIDTH: f32 = 220.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaterialLayerBadge {
ModelSource,
ModelDefault,
ProjectDefault,
DefaultGrid,
}
impl MaterialLayerBadge {
pub 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 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.text_secondary,
"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 struct MaterialPickerCandidate {
pub reference: EditorAssetRef,
pub label: String,
pub detail: String,
pub thumbnail: MaterialThumbnailPresentation,
}
#[derive(Debug, Clone)]
pub 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,
/// Initial disclosure used only until this slot has persisted UI state.
pub parameters_initially_open: bool,
/// Renderer arrays keep the full asset/action header visible when only parameters collapse.
pub expanded_header_when_parameters_closed: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum MaterialSlotAction {
Assign {
reference: EditorAssetRef,
from_drop: bool,
},
BrowseLibrary,
Locate,
Clear,
ExtractEditable,
CreateInstance,
SetShader(MaterialShaderKind),
}
#[derive(Debug, Clone, PartialEq)]
pub struct MaterialSlotPanelAction {
pub slot_id: String,
pub action: MaterialSlotAction,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MaterialsSectionViewModel {
pub slot_ids: Vec<String>,
}
pub struct MaterialSlotPanelResponse {
pub actions: Vec<MaterialSlotPanelAction>,
}
pub 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),
);
},
);
// The top-down layout already contributes the design system's four-pixel item gap after
// the 32 px heading. Add only the remaining four pixels so the slot begins at the Penpot
// board's authored eight-pixel offset instead of drifting four pixels lower.
ui.add_space(4.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 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(view.parameters_initially_open);
let mut slot_actions = Vec::new();
egui::Frame::new()
.fill(palette.recessed)
.stroke(egui::Stroke::new(1.0_f32, palette.border_strong))
.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 expanded_header = *open || view.expanded_header_when_parameters_closed;
let height = if expanded_header {
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 expanded_header { 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 expanded_header { 8.0 } else { 7.0 },
),
egui::pos2(
identity_right,
rect.bottom() - if expanded_header { 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 expanded_header {
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 expanded_header { 9.0 } else { 7.0 },
),
egui::vec2(
action_width,
if expanded_header { 64.0 } else { height - 14.0 },
),
);
if expanded_header {
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::AssetTitle
.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.recessed)
.inner_margin(egui::Margin {
// The parent slot stroke already contributes one pixel on each side. Reclaim it here
// so the parameter sections are the authored 569/369 px instead of 567/367 px.
left: 18,
right: 7,
// Own the exported PARAMETERS origin here so every primitive/static/skinned caller
// receives the same position independent of its surrounding component chrome.
top: 21,
bottom: 8,
})
.show(ui, |ui| {
let mut measure = egui::text::LayoutJob::default();
measure.append(
"PARAMETERS",
0.0,
egui::TextFormat {
font_id: TypeRole::Eyebrow.font(),
extra_letter_spacing: 0.6,
color: egui::Color32::TRANSPARENT,
..Default::default()
},
);
// Penpot positions the 9 px eyebrow two raster pixels above egui's default label
// baseline. Keep the label's normal allocation so section flow remains unchanged,
// then paint the visible glyphs at the authored baseline.
let response = ui.label(measure);
let mut visible = egui::text::LayoutJob::default();
visible.append(
"PARAMETERS",
0.0,
egui::TextFormat {
font_id: TypeRole::Eyebrow.font(),
extra_letter_spacing: 0.6,
color: palette.accent,
..Default::default()
},
);
let galley = ui.painter().layout_job(visible);
ui.painter().galley(
response.rect.min - egui::vec2(0.0, 5.0),
galley,
palette.accent,
);
let spine_x = ui.min_rect().left() - 11.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_dark),
);
});
}
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::AssetTitle
.text(&view.label)
.color(palette.text_primary),
)
.truncate()
.sense(egui::Sense::click()),
)
.on_hover_text(&view.label);
let unassigned = view.assigned.is_none() && view.effective.is_none();
let path = view.path.as_deref().unwrap_or(if unassigned {
"Drop a material here or choose from the browser"
} else {
"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 if !view.shader.is_empty() {
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,
);
}
MaterialThumbnailPresentation::Empty => {
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
icons::PALETTE.as_str(),
egui::FontId::new(22.0, egui::FontFamily::Name("phosphor-regular".into())),
palette.text_muted,
);
}
}
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,
parameters_initially_open: true,
expanded_header_when_parameters_closed: false,
}
}
#[test]
fn slot_header_insets_match_the_current_penpot_export() {
const OUTER_FRAME_STROKE_INSET: f32 = 1.0;
assert_eq!(SLOT_ACCENT_X + OUTER_FRAME_STROKE_INSET, 8.0);
assert_eq!(SLOT_CARET_CENTER_X + OUTER_FRAME_STROKE_INSET, 23.0);
assert_eq!(SLOT_PREVIEW_X + OUTER_FRAME_STROKE_INSET, 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::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().as_str(),
icons::CHECK_CIRCLE.as_str()
);
assert_eq!(
MaterialHealth::Dirty.icon().as_str(),
icons::PENCIL_SIMPLE.as_str()
);
assert_eq!(
MaterialHealth::Processing.icon().as_str(),
icons::CIRCLE_NOTCH.as_str()
);
assert_eq!(
MaterialHealth::Broken.icon().as_str(),
icons::WARNING_CIRCLE.as_str()
);
assert_eq!(
MaterialHealth::ReadOnly.icon().as_str(),
icons::LOCK_SIMPLE.as_str()
);
}
}

View File

@ -0,0 +1,238 @@
//! Exact Penpot status/action geometry for an expanded material-slot header.
use egui;
use egui_phosphor_icons::icons;
use super::{MaterialSlotAction, MaterialSlotPanelViewModel};
use crate::design_system::controls::{icon_button, icon_label, status};
use crate::design_system::typography::TypeRole;
use crate::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,
) {
if view.assigned.is_none() && view.effective.is_none() {
let button_rect = egui::Rect::from_min_size(
rect.right_bottom() - egui::vec2(116.0, 28.0),
egui::vec2(116.0, 28.0),
);
if rect_scope(ui, button_rect, |ui| {
ui.add(
egui::Button::new(icon_label(
icons::FOLDER_OPEN,
"Choose material",
TypeRole::Control,
13.0,
palette.text_primary,
))
.min_size(button_rect.size()),
)
.clicked()
}) {
actions.push(MaterialSlotAction::BrowseLibrary);
}
return;
}
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));
}
}
}

View File

@ -0,0 +1,420 @@
//! Penpot-styled material and texture asset pickers.
use egui;
use egui_phosphor_icons::icons;
use material_schema::EditorAssetRef;
use super::panel::{MaterialPickerCandidate, MaterialSlotAction, MaterialSlotPanelViewModel};
use super::{MaterialTextureCandidate, MaterialThumbnailPresentation, TextureSlotResponse};
use crate::design_system;
use crate::design_system::controls::icon_label;
use crate::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(&current.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,
);
}
MaterialThumbnailPresentation::Empty => {
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
icons::PALETTE.as_str(),
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.text_muted,
);
}
}
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(&current.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);
}
}

View File

@ -0,0 +1,120 @@
//! Penpot Surface section backed by shared material document values.
use egui;
use material_schema::{MaterialAlphaMode, MaterialInputSet, MaterialRenderState};
use super::inputs::{
apply_arm_texture_preset, apply_separate_texture_preset, packed_map_mode, PackedMapMode,
};
use super::layout::{ResponsiveMaterialLayout, SurfaceSectionGeometry};
use crate::design_system;
pub fn material_surface_options_ui(
ui: &mut egui::Ui,
state: &mut MaterialRenderState,
packed_inputs: Option<&mut MaterialInputSet>,
) {
let palette = design_system::palette(ui);
let width = ui.available_width().max(1.0);
let height = match ResponsiveMaterialLayout::for_width(width) {
ResponsiveMaterialLayout::Reference => design_system::SURFACE_SECTION_HEIGHT,
ResponsiveMaterialLayout::Compact => design_system::COMPACT_SURFACE_SECTION_HEIGHT,
ResponsiveMaterialLayout::Transient => 100.0,
};
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
ui.painter().rect(
rect,
5.0,
palette.panel,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
let geometry = SurfaceSectionGeometry::for_rect(rect);
ui.painter().text(
geometry.title.left_center(),
egui::Align2::LEFT_CENTER,
"Surface",
design_system::typography::TypeRole::Section.font(),
palette.text_primary,
);
clipped_rect_ui(ui, geometry.blend, |ui| surface_blend_controls(ui, state));
if let Some(inputs) = packed_inputs {
clipped_rect_ui(ui, geometry.packed, |ui| packed_mode_controls(ui, inputs));
}
ui.painter().text(
geometry.double_sided.left_center() + egui::vec2(7.0, 0.0),
egui::Align2::LEFT_CENTER,
"Double Sided",
design_system::typography::TypeRole::Body.font(),
palette.text_secondary,
);
let switch_rect = egui::Rect::from_min_size(
geometry.double_sided.min + egui::vec2(76.0, 3.0),
egui::vec2(31.0, 16.0),
);
clipped_rect_ui(ui, switch_rect, |ui| {
design_system::controls::switch(ui, &mut state.double_sided)
.on_hover_text("Render both sides of the surface");
});
// Explicitly placed blend/packing/switch children do not own vertical flow. Reassert the
// Surface rectangle after they render so the following authored section gap starts at the
// true 46/100 px boundary.
ui.advance_cursor_after_rect(rect);
}
fn packed_mode_controls(ui: &mut egui::Ui, inputs: &mut MaterialInputSet) {
let arm = packed_map_mode(inputs) == PackedMapMode::Arm;
match design_system::controls::two_option_segmented(ui, "Separate", "ORM", arm) {
Some(false) => apply_separate_texture_preset(inputs),
Some(true) => apply_arm_texture_preset(inputs),
None => {}
}
}
fn surface_blend_controls(ui: &mut egui::Ui, state: &mut MaterialRenderState) {
let width = ui.available_width().max(1.0);
let show_cutoff = state.alpha_mode == MaterialAlphaMode::Cutout;
let combo_width = if show_cutoff {
(width - 58.0).max(1.0)
} else {
width
};
let combo_rect = egui::Rect::from_min_size(ui.min_rect().min, egui::vec2(combo_width, 24.0));
clipped_rect_ui(ui, combo_rect, |ui| {
egui::ComboBox::from_id_salt("material_alpha_mode")
.selected_text(match state.alpha_mode {
MaterialAlphaMode::Opaque => "Opaque",
MaterialAlphaMode::Cutout => "Cutout",
})
.width(combo_width)
.show_ui(ui, |ui| {
ui.selectable_value(&mut state.alpha_mode, MaterialAlphaMode::Opaque, "Opaque");
ui.selectable_value(&mut state.alpha_mode, MaterialAlphaMode::Cutout, "Cutout");
});
});
if show_cutoff {
let cutoff_rect = egui::Rect::from_min_size(
ui.min_rect().min + egui::vec2(combo_width + 4.0, 1.0),
egui::vec2(54.0_f32.min(width - combo_width - 4.0), 22.0),
);
ui.put(
cutoff_rect,
egui::DragValue::new(&mut state.alpha_cutoff)
.range(0.0..=1.0)
.speed(0.01),
)
.on_hover_text("Alpha cutoff");
}
}
fn clipped_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
}

View File

@ -0,0 +1,413 @@
//! Texture/channel controls shared by Material and Material Instance input tables.
use super::*;
use crate::design_system;
use crate::design_system::typography::TypeRole;
use egui_phosphor_icons::icons;
#[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 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);
}
Some(MaterialThumbnailPresentation::Empty) => {
paint_field_icon(ui, image_rect, icons::IMAGE, palette.text_muted);
}
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::AssetLabel.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.locate.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.locate.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
);
}
}

View File

@ -0,0 +1,131 @@
//! Shared material UV-transform authoring used by Materials and Material Instances.
use bevy_math::Vec2;
use egui;
use egui_phosphor_icons::icons;
use material_schema::{MaterialInputSet, MaterialParameter, MaterialParameterValue};
use crate::design_system;
use crate::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::design_system::UV_SECTION_HEIGHT
}
super::layout::ResponsiveMaterialLayout::Compact => {
crate::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.panel,
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),
});
}
}

View File

@ -0,0 +1,412 @@
//! Static/skinned renderer-array presentation shared by production and the UI gallery.
use egui;
use egui_phosphor_icons::icons;
use crate::design_system;
use crate::design_system::typography::TypeRole;
use crate::theme::{BORDER, PANEL_BG_DARK, WIDGET_BG};
pub const RENDERER_ARRAY_HEADER_HEIGHT: f32 = 40.0;
pub const EXPANDED_RENDERER_HEADER_HEIGHT: f32 = 84.0;
pub const COLLAPSED_RENDERER_HEADER_HEIGHT: f32 = 60.0;
pub const RENDERER_PROPERTIES_HEADER_HEIGHT: f32 = 38.0;
pub const MATERIAL_SLOTS_HEADER_HEIGHT: f32 = 50.0;
#[derive(Clone, Copy)]
pub struct RendererPanelHeaderViewModel<'a> {
pub index: usize,
pub label: &'a str,
pub path: &'a str,
pub slot_count: usize,
pub texture_id: Option<egui::TextureId>,
}
pub fn renderer_array_header(ui: &mut egui::Ui, count: usize) -> bool {
let palette = design_system::palette(ui);
let (rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width().max(1.0), RENDERER_ARRAY_HEADER_HEIGHT),
egui::Sense::hover(),
);
ui.painter().rect_filled(rect, 4.0, palette.elevated);
ui.painter().text(
rect.left_center() + egui::vec2(12.0, 0.0),
egui::Align2::LEFT_CENTER,
icons::SQUARES_FOUR.as_str(),
egui::FontId::new(16.0, egui::FontFamily::Name("phosphor-regular".into())),
palette.text_secondary,
);
ui.painter().text(
rect.left_center() + egui::vec2(32.0, 0.0),
egui::Align2::LEFT_CENTER,
"Mesh renderers",
TypeRole::Section.font(),
palette.text_primary,
);
let badge = egui::Rect::from_center_size(
rect.left_center() + egui::vec2(124.0, 0.0),
egui::vec2(20.0, 20.0),
);
ui.painter()
.circle_filled(badge.center(), 10.0, palette.accent.linear_multiply(0.22));
ui.painter().text(
badge.center(),
egui::Align2::CENTER_CENTER,
format!("{count}"),
TypeRole::Small.font(),
palette.accent,
);
ui.painter().text(
rect.left_center() + egui::vec2(144.0, 0.0),
egui::Align2::LEFT_CENTER,
"Each renderer owns the slots exposed by its mesh",
TypeRole::Caption.font(),
palette.text_muted,
);
let add_rect = egui::Rect::from_min_size(
egui::pos2(rect.right() - 116.0, rect.top() + 7.0),
egui::vec2(104.0, 26.0),
);
ui.put(
add_rect,
egui::Button::new(design_system::controls::icon_label(
icons::PLUS,
"Add renderer",
TypeRole::Body,
12.0,
palette.text_primary,
)),
)
.clicked()
}
pub fn renderer_panel(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui)) {
egui::Frame::new()
.fill(WIDGET_BG.linear_multiply(0.82))
.stroke(egui::Stroke::new(1.0_f32, BORDER))
.corner_radius(egui::CornerRadius::same(5))
.show(ui, |ui| {
ui.set_max_width(ui.available_width());
add_contents(ui);
});
ui.add_space(10.0);
}
pub fn renderer_panel_header(
ui: &mut egui::Ui,
model: RendererPanelHeaderViewModel<'_>,
open: &mut bool,
add_actions: impl FnOnce(&mut egui::Ui),
) {
let palette = design_system::palette(ui);
let height = if *open {
EXPANDED_RENDERER_HEADER_HEIGHT
} else {
COLLAPSED_RENDERER_HEADER_HEIGHT
};
let (rect, header_response) = ui.allocate_exact_size(
egui::vec2(ui.available_width().max(1.0), height),
egui::Sense::click(),
);
ui.painter().rect_filled(
egui::Rect::from_min_size(
rect.min + egui::vec2(6.0, 8.0),
egui::vec2(3.0, height - 16.0),
),
2.0,
palette.accent,
);
ui.painter().text(
rect.left_center() + egui::vec2(24.0, 0.0),
egui::Align2::CENTER_CENTER,
if *open {
icons::CARET_DOWN.as_str()
} else {
icons::CARET_RIGHT.as_str()
},
egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())),
palette.text_secondary,
);
let preview_size = if *open { 58.0 } else { 40.0 };
let preview = egui::Rect::from_center_size(
egui::pos2(rect.left() + 68.0, rect.center().y),
egui::vec2(preview_size, preview_size),
);
ui.painter().rect(
preview,
4.0,
PANEL_BG_DARK,
egui::Stroke::new(1.0_f32, BORDER),
egui::StrokeKind::Inside,
);
if let Some(texture_id) = model.texture_id {
ui.painter().image(
texture_id,
preview.shrink(3.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,
icons::CUBE.as_str(),
egui::FontId::new(20.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.text_secondary,
);
}
let label = if model.label.trim().is_empty() {
"Mesh renderer"
} else {
model.label.trim()
};
let identity_x = rect.left() + 105.0;
if *open {
ui.painter().text(
egui::pos2(identity_x, rect.top() + 28.0),
egui::Align2::LEFT_CENTER,
format!("RENDERER {}", model.index),
TypeRole::Eyebrow.font(),
palette.accent,
);
}
ui.painter().text(
egui::pos2(
identity_x,
if *open {
rect.top() + 45.0
} else {
rect.center().y - 7.0
},
),
egui::Align2::LEFT_CENTER,
label,
TypeRole::AssetTitle.font(),
palette.text_primary,
);
ui.painter().text(
egui::pos2(
identity_x,
if *open {
rect.top() + 62.0
} else {
rect.center().y + 9.0
},
),
egui::Align2::LEFT_CENTER,
if *open {
model.path.to_owned()
} else {
format!(
"Renderer {} · {} material slot{}",
model.index,
model.slot_count,
if model.slot_count == 1 { "" } else { "s" }
)
},
TypeRole::Small.font(),
palette.text_muted,
);
let slot_badge = egui::Rect::from_min_size(
egui::pos2(
rect.right() - if *open { 60.0 } else { 78.0 },
rect.top() + if *open { 12.0 } else { 8.0 },
),
egui::vec2(40.0, 20.0),
);
ui.painter()
.rect_filled(slot_badge, 10.0, palette.accent.linear_multiply(0.22));
ui.painter().text(
slot_badge.center(),
egui::Align2::CENTER_CENTER,
format!(
"{} slot{}",
model.slot_count,
if model.slot_count == 1 { "" } else { "s" }
),
TypeRole::Eyebrow.font(),
palette.accent,
);
let action_rect = if *open {
ui.painter().circle_filled(
egui::pos2(rect.right() - 10.0, rect.top() + 22.0),
4.0,
palette.healthy,
);
let action_rect = egui::Rect::from_min_size(
egui::pos2(rect.right() - 132.0, rect.bottom() - 38.0),
egui::vec2(122.0, 28.0),
);
ui.scope_builder(egui::UiBuilder::new().max_rect(action_rect), |ui| {
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
add_actions,
);
});
action_rect
} else {
let action_rect = egui::Rect::from_center_size(
egui::pos2(rect.right() - 20.0, rect.center().y),
egui::vec2(28.0, 28.0),
);
ui.scope_builder(egui::UiBuilder::new().max_rect(action_rect), |ui| {
design_system::controls::icon_button(
ui,
icons::DOTS_THREE_VERTICAL,
"Renderer actions",
true,
);
});
action_rect
};
if header_response.clicked()
&& ui
.input(|input| input.pointer.interact_pos())
.is_some_and(|pointer| !preview.contains(pointer) && !action_rect.contains(pointer))
{
*open = !*open;
}
}
pub fn renderer_material_slots_header(ui: &mut egui::Ui, count: usize) {
let palette = design_system::palette(ui);
let (rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width().max(1.0), MATERIAL_SLOTS_HEADER_HEIGHT),
egui::Sense::hover(),
);
ui.painter().rect_filled(rect, 0.0, palette.elevated);
ui.painter().text(
rect.left_center() + egui::vec2(15.0, 0.0),
egui::Align2::CENTER_CENTER,
icons::CARET_DOWN.as_str(),
egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.text_secondary,
);
ui.painter().text(
rect.left_center() + egui::vec2(35.0, 0.0),
egui::Align2::CENTER_CENTER,
icons::PALETTE.as_str(),
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.accent,
);
ui.painter().text(
rect.left_center() + egui::vec2(49.0, 0.0),
egui::Align2::LEFT_CENTER,
"Material slots",
TypeRole::Section.font(),
palette.text_primary,
);
ui.painter().text(
rect.left_center() + egui::vec2(122.0, 0.0),
egui::Align2::LEFT_CENTER,
format!("{count} exposed by mesh"),
TypeRole::Caption.font(),
palette.text_muted,
);
ui.painter().line_segment(
[
egui::pos2(rect.left() + 201.0, rect.center().y),
egui::pos2(rect.right() - 93.0, rect.center().y),
],
egui::Stroke::new(1.0_f32, palette.border),
);
ui.painter().text(
rect.right_center() - egui::vec2(76.0, 0.0),
egui::Align2::CENTER_CENTER,
icons::LINK.as_str(),
egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.text_muted,
);
ui.painter().text(
rect.right_center() - egui::vec2(10.0, 0.0),
egui::Align2::RIGHT_CENTER,
"Index mapped",
TypeRole::Eyebrow.font(),
palette.text_muted,
);
}
pub fn renderer_properties_header(ui: &mut egui::Ui) {
let palette = design_system::palette(ui);
let (rect, _) = ui.allocate_exact_size(
egui::vec2(
ui.available_width().max(1.0),
RENDERER_PROPERTIES_HEADER_HEIGHT,
),
egui::Sense::hover(),
);
ui.painter().text(
rect.left_center() + egui::vec2(4.0, 0.0),
egui::Align2::CENTER_CENTER,
icons::SLIDERS_HORIZONTAL.as_str(),
egui::FontId::new(13.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.accent,
);
ui.painter().text(
rect.left_center() + egui::vec2(20.0, 0.0),
egui::Align2::LEFT_CENTER,
"Renderer properties",
TypeRole::AssetLabel.font(),
palette.accent,
);
ui.painter().text(
rect.right_center(),
egui::Align2::RIGHT_CENTER,
"Mesh + render flags",
TypeRole::Caption.font(),
palette.text_muted,
);
}
pub fn renderer_material_slot_row<R>(
ui: &mut egui::Ui,
index: usize,
add_contents: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
let palette = design_system::palette(ui);
let row = ui.horizontal_top(|ui| {
let (index_rect, _) = ui.allocate_exact_size(egui::vec2(39.0, 60.0), egui::Sense::hover());
ui.painter().text(
index_rect.center_top() + egui::vec2(0.0, 8.0),
egui::Align2::CENTER_CENTER,
"SLOT",
TypeRole::Caption.font(),
palette.text_muted,
);
ui.painter().text(
index_rect.center_top() + egui::vec2(0.0, 18.0),
egui::Align2::CENTER_CENTER,
index.to_string(),
TypeRole::Body.font(),
palette.text_muted,
);
ui.vertical(|ui| {
ui.set_width(ui.available_width().max(1.0));
add_contents(ui)
})
.inner
});
let rail_x = row.response.rect.left() + 23.0;
ui.painter().line_segment(
[
egui::pos2(rail_x, row.response.rect.top() + 35.0),
egui::pos2(rail_x, row.response.rect.bottom() - 8.0),
],
egui::Stroke::new(1.0_f32, palette.border_strong),
);
row.inner
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renderer_headers_match_current_penpot_heights() {
assert_eq!(RENDERER_ARRAY_HEADER_HEIGHT, 40.0);
assert_eq!(EXPANDED_RENDERER_HEADER_HEIGHT, 84.0);
assert_eq!(COLLAPSED_RENDERER_HEADER_HEIGHT, 60.0);
assert_eq!(RENDERER_PROPERTIES_HEADER_HEIGHT, 38.0);
assert_eq!(MATERIAL_SLOTS_HEADER_HEIGHT, 50.0);
}
}

View File

@ -0,0 +1,156 @@
//! Semantic Blacksite editor palette shared by production and the UI gallery.
use egui::{self, Color32, CornerRadius, FontId, Id, Stroke, Visuals};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EditorVisualPalette {
pub canvas: Color32,
pub panel: Color32,
pub panel_dark: Color32,
pub widget: Color32,
pub elevated: Color32,
pub accent: Color32,
pub accent_hover: Color32,
pub selection: Color32,
pub selection_secondary: Color32,
pub selection_bg: Color32,
pub selection_bg_muted: Color32,
pub text: Color32,
pub text_selected: Color32,
pub text_dim: Color32,
pub text_muted: Color32,
pub border: Color32,
pub border_strong: Color32,
pub success: Color32,
pub warning: Color32,
pub error: Color32,
}
pub const CANVAS_BG: Color32 = Color32::from_rgb(7, 8, 10);
pub const PANEL_BG: Color32 = Color32::from_rgb(13, 15, 18);
pub const PANEL_BG_DARK: Color32 = Color32::from_rgb(9, 11, 13);
pub const WIDGET_BG: Color32 = Color32::from_rgb(21, 24, 28);
pub const ELEVATED_BG: Color32 = Color32::from_rgb(31, 35, 40);
pub const ACCENT: Color32 = Color32::from_rgb(229, 164, 51);
pub const ACCENT_HOVER: Color32 = Color32::from_rgb(249, 193, 79);
pub const SELECTION: Color32 = Color32::from_rgb(65, 198, 207);
pub const SELECTION_SECONDARY: Color32 = Color32::from_rgb(84, 139, 155);
pub const SELECTION_BG: Color32 = Color32::from_rgb(22, 72, 78);
pub const SELECTION_BG_MUTED: Color32 = Color32::from_rgb(23, 48, 54);
pub const TEXT: Color32 = Color32::from_rgb(220, 225, 229);
pub const TEXT_SELECTED: Color32 = Color32::from_rgb(250, 251, 252);
pub const TEXT_DIM: Color32 = Color32::from_rgb(155, 163, 171);
pub const TEXT_MUTED: Color32 = Color32::from_rgb(96, 105, 114);
pub const BORDER: Color32 = Color32::from_rgb(38, 43, 49);
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 BLACKSITE_PALETTE: EditorVisualPalette = EditorVisualPalette {
canvas: CANVAS_BG,
panel: PANEL_BG,
panel_dark: PANEL_BG_DARK,
widget: WIDGET_BG,
elevated: ELEVATED_BG,
accent: ACCENT,
accent_hover: ACCENT_HOVER,
selection: SELECTION,
selection_secondary: SELECTION_SECONDARY,
selection_bg: SELECTION_BG,
selection_bg_muted: SELECTION_BG_MUTED,
text: TEXT,
text_selected: TEXT_SELECTED,
text_dim: TEXT_DIM,
text_muted: TEXT_MUTED,
border: BORDER,
border_strong: BORDER_STRONG,
success: SUCCESS,
warning: WARNING,
error: ERROR,
};
const EDITOR_PALETTE_ID: &str = "blacksite.editor.visual_palette";
pub fn editor_palette(ctx: &egui::Context) -> EditorVisualPalette {
ctx.data_mut(|data| {
data.get_temp::<EditorVisualPalette>(Id::new(EDITOR_PALETTE_ID))
.unwrap_or(BLACKSITE_PALETTE)
})
}
pub fn apply_editor_theme(ctx: &egui::Context) {
apply_editor_theme_with_palette(ctx, editor_palette(ctx));
}
pub fn apply_editor_theme_with_palette(ctx: &egui::Context, palette: EditorVisualPalette) {
ctx.data_mut(|data| data.insert_temp(Id::new(EDITOR_PALETTE_ID), palette));
let mut visuals = Visuals::dark();
visuals.panel_fill = palette.panel;
visuals.window_fill = palette.widget;
visuals.extreme_bg_color = palette.canvas;
visuals.faint_bg_color = palette.panel_dark;
visuals.code_bg_color = palette.panel_dark;
visuals.text_edit_bg_color = Some(palette.canvas);
visuals.weak_text_color = Some(palette.text_muted);
visuals.widgets.noninteractive.bg_fill = palette.panel;
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0_f32, palette.border);
visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, palette.text_dim);
visuals.widgets.inactive.bg_fill = palette.widget;
visuals.widgets.inactive.weak_bg_fill = palette.widget;
visuals.widgets.inactive.bg_stroke = Stroke::new(1.0_f32, palette.border);
visuals.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, palette.text);
visuals.widgets.hovered.bg_fill = palette.elevated;
visuals.widgets.hovered.weak_bg_fill = palette.elevated;
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, palette.border_strong);
visuals.widgets.hovered.fg_stroke = Stroke::new(1.0_f32, palette.text_selected);
visuals.widgets.active.bg_fill = palette.elevated;
visuals.widgets.active.weak_bg_fill = palette.elevated;
visuals.widgets.active.bg_stroke = Stroke::new(1.0_f32, palette.accent);
visuals.widgets.active.fg_stroke = Stroke::new(1.0_f32, palette.text_selected);
visuals.widgets.open = visuals.widgets.hovered;
visuals.selection.bg_fill = palette.selection_bg;
visuals.selection.stroke = Stroke::new(1.0_f32, palette.selection);
visuals.hyperlink_color = palette.selection;
visuals.warn_fg_color = palette.warning;
visuals.error_fg_color = palette.error;
visuals.window_stroke = Stroke::new(1.0_f32, palette.border);
visuals.window_corner_radius = CornerRadius::same(4);
visuals.menu_corner_radius = CornerRadius::same(3);
visuals.button_frame = true;
visuals.collapsing_header_frame = false;
visuals.indent_has_left_vline = true;
visuals.striped = true;
visuals.slider_trailing_fill = true;
visuals.interact_cursor = Some(egui::CursorIcon::PointingHand);
let mut style = (*ctx.global_style()).clone();
style.visuals = visuals;
style.spacing.item_spacing = egui::vec2(8.0, 7.0);
style.spacing.button_padding = egui::vec2(10.0, 5.0);
style.spacing.interact_size = egui::vec2(40.0, 28.0);
style.spacing.window_margin = egui::Margin::same(10);
style.spacing.menu_margin = egui::Margin::same(8);
style.spacing.indent = 17.0;
style.interaction.tooltip_delay = 0.32;
style.interaction.tooltip_grace_time = 0.18;
style.animation_time = 0.12;
style.compact_menu_style = true;
style.text_styles.insert(
egui::TextStyle::Body,
FontId::new(14.0, egui::FontFamily::Proportional),
);
style.text_styles.insert(
egui::TextStyle::Button,
FontId::new(13.0, egui::FontFamily::Proportional),
);
style.text_styles.insert(
egui::TextStyle::Heading,
FontId::new(16.0, egui::FontFamily::Proportional),
);
style.text_styles.insert(
egui::TextStyle::Small,
FontId::new(12.0, egui::FontFamily::Proportional),
);
ctx.set_global_style(style);
}

View File

@ -0,0 +1,12 @@
[package]
name = "material_schema"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Lightweight authored material schema and value types shared by Blacksite tools"
[dependencies]
bevy_color = { version = "0.19", features = ["serialize"] }
bevy_math = { version = "0.19", features = ["bevy_reflect", "serialize"] }
bevy_reflect = { version = "0.19", features = ["glam"] }
serde.workspace = true

View File

@ -0,0 +1,546 @@
//! Lightweight authored material values and schemas.
//!
//! This crate intentionally excludes Bevy's app, asset, rendering, scene, and physics layers so
//! headless tooling and the production UI gallery can share the exact authoring contracts without
//! compiling the game runtime.
use bevy_color::Color;
use bevy_math::{Vec2, Vec3};
use bevy_reflect::prelude::*;
use serde::{Deserialize, Serialize};
/// Reference to an imported content-browser asset or sub-asset.
#[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
#[type_path = "shared::components"]
pub struct EditorAssetRef {
/// Stable project asset registry UUID string.
pub asset_id: String,
/// Stable imported sub-asset ID inside the generated artifact.
#[serde(default, alias = "mesh_label")]
pub sub_asset_id: String,
/// UI label cached for inspectors. The asset registry/artifact remains authoritative.
#[serde(default)]
pub label: String,
/// Optional loadable project path cached for runtime hydration.
#[serde(default)]
pub source_path: Option<String>,
}
impl EditorAssetRef {
pub fn new(
asset_id: impl Into<String>,
sub_asset_id: impl Into<String>,
label: impl Into<String>,
) -> Self {
Self {
asset_id: asset_id.into(),
sub_asset_id: sub_asset_id.into(),
label: label.into(),
source_path: None,
}
}
pub fn with_source_path(mut self, source_path: impl Into<String>) -> Self {
self.source_path = Some(source_path.into());
self
}
pub fn is_resolved(&self) -> bool {
!self.asset_id.trim().is_empty() && !self.sub_asset_id.trim().is_empty()
}
}
/// How a material should be resolved at runtime.
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
#[type_path = "shared::components"]
pub enum MaterialShaderKind {
#[default]
StandardLit,
Unlit,
Custom,
}
/// Reference to a built-in or custom shader schema.
#[derive(Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
#[type_path = "shared::components"]
pub struct ShaderRefDesc {
pub kind: MaterialShaderKind,
#[serde(default)]
pub schema_path: Option<String>,
#[serde(default)]
pub shader_path: Option<String>,
}
impl Default for ShaderRefDesc {
fn default() -> Self {
Self {
kind: MaterialShaderKind::StandardLit,
schema_path: None,
shader_path: None,
}
}
}
/// Serializable color description used by authored material documents.
#[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, Serialize, Deserialize)]
#[type_path = "shared::components"]
pub struct ColorDesc {
pub r: f32,
pub g: f32,
pub b: f32,
pub a: f32,
}
impl ColorDesc {
pub const fn srgb(r: f32, g: f32, b: f32) -> Self {
Self { r, g, b, a: 1.0 }
}
pub fn to_color(self) -> Color {
Color::srgba(self.r, self.g, self.b, self.a)
}
}
impl Default for ColorDesc {
fn default() -> Self {
Self::srgb(0.8, 0.8, 0.8)
}
}
#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
#[type_path = "shared::components"]
pub enum MaterialParameterValue {
Bool(bool),
Float(f32),
Vec2(Vec2),
Vec3(Vec3),
Color(ColorDesc),
Enum(String),
}
impl Default for MaterialParameterValue {
fn default() -> Self {
Self::Float(0.0)
}
}
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
#[type_path = "shared::components"]
pub struct MaterialParameter {
pub name: String,
pub value: MaterialParameterValue,
}
/// Channels sampled from a texture binding.
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
#[type_path = "shared::components"]
pub enum TextureChannel {
R,
G,
B,
A,
Rgb,
#[default]
Rgba,
}
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
#[type_path = "shared::components"]
pub struct MaterialTextureBinding {
pub name: String,
pub texture: Option<EditorAssetRef>,
#[serde(default)]
pub channel: TextureChannel,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum MaterialAlphaMode {
#[default]
Opaque,
Cutout,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct MaterialRenderState {
#[serde(default)]
pub alpha_mode: MaterialAlphaMode,
#[serde(default = "default_alpha_cutoff")]
pub alpha_cutoff: f32,
#[serde(default)]
pub double_sided: bool,
}
const fn default_alpha_cutoff() -> f32 {
0.5
}
impl Default for MaterialRenderState {
fn default() -> Self {
Self {
alpha_mode: MaterialAlphaMode::Opaque,
alpha_cutoff: default_alpha_cutoff(),
double_sided: false,
}
}
}
/// Canonical values and texture bindings consumed by built-in and custom material schemas.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct MaterialInputSet {
#[serde(default)]
pub values: Vec<MaterialParameter>,
#[serde(default)]
pub textures: Vec<MaterialTextureBinding>,
}
impl MaterialInputSet {
pub fn from_material_desc<T: MaterialInputSource>(source: &T) -> Self {
source.to_material_inputs()
}
pub fn apply_to_material_desc<T: MaterialInputTarget>(&self, target: &mut T) {
target.apply_material_inputs(self);
}
pub fn texture(&self, name: &str) -> Option<&MaterialTextureBinding> {
self.textures.iter().find(|binding| binding.name == name)
}
pub fn validate_unique(&self) -> Result<(), String> {
let mut names = std::collections::HashSet::new();
for value in &self.values {
if !names.insert(("value", value.name.as_str())) {
return Err(format!(
"material value `{}` is assigned more than once",
value.name
));
}
}
for texture in &self.textures {
if !names.insert(("texture", texture.name.as_str())) {
return Err(format!(
"material texture `{}` is assigned more than once",
texture.name
));
}
}
Ok(())
}
}
/// Runtime-owned descriptors implement this bridge without making the schema crate depend on the
/// renderer or scene representation.
pub trait MaterialInputSource {
fn to_material_inputs(&self) -> MaterialInputSet;
}
/// Runtime-owned descriptors implement this bridge without making lightweight authoring tools
/// depend on those descriptors.
pub trait MaterialInputTarget {
fn apply_material_inputs(&mut self, inputs: &MaterialInputSet);
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ShaderPropertyType {
Bool,
Float { min: Option<f32>, max: Option<f32> },
Vec2,
Vec3,
Color,
Enum { options: Vec<String> },
Texture,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct MaterialInputGroupDesc {
pub id: String,
pub display_name: String,
#[serde(default)]
pub order: i32,
#[serde(default)]
pub advanced: bool,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum TextureSemantic {
#[default]
Auto,
Color,
Normal,
Scalar,
Mask,
Hdr,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MaterialTextureInputDesc {
#[serde(default)]
pub semantic: TextureSemantic,
#[serde(default)]
pub default_channel: TextureChannel,
#[serde(default)]
pub allow_channel_override: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum MaterialInputPresentation {
#[default]
Row,
Companion {
owner: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MaterialInputDesc {
pub name: String,
pub display_name: String,
#[serde(default)]
pub group: String,
#[serde(default)]
pub order: i32,
pub property_type: ShaderPropertyType,
#[serde(default)]
pub default_value: Option<MaterialParameterValue>,
#[serde(default)]
pub texture: Option<MaterialTextureInputDesc>,
#[serde(default)]
pub tooltip: String,
#[serde(default)]
pub advanced: bool,
#[serde(default)]
pub presentation: MaterialInputPresentation,
}
impl Default for MaterialInputDesc {
fn default() -> Self {
Self {
name: String::new(),
display_name: String::new(),
group: String::new(),
order: 0,
property_type: ShaderPropertyType::Float {
min: None,
max: None,
},
default_value: None,
texture: None,
tooltip: String::new(),
advanced: false,
presentation: MaterialInputPresentation::Row,
}
}
}
/// Compatibility source alias for code that has not yet adopted material-input terminology.
pub type ShaderPropertyDesc = MaterialInputDesc;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct MaterialInputSchema {
#[serde(default)]
pub groups: Vec<MaterialInputGroupDesc>,
#[serde(default)]
pub inputs: Vec<MaterialInputDesc>,
}
/// Engine-owned Standard Lit authoring schema. Custom Surface schemas use the same shape.
pub fn standard_lit_input_schema() -> MaterialInputSchema {
let groups = vec![
MaterialInputGroupDesc {
id: "surface_inputs".into(),
display_name: "Surface Inputs".into(),
order: 0,
advanced: false,
},
MaterialInputGroupDesc {
id: "advanced_inputs".into(),
display_name: "Advanced Inputs".into(),
order: 100,
advanced: true,
},
MaterialInputGroupDesc {
id: "uv_transform".into(),
display_name: "UV Transform".into(),
order: 90,
advanced: false,
},
];
let scalar_texture = |channel| MaterialTextureInputDesc {
semantic: TextureSemantic::Scalar,
default_channel: channel,
allow_channel_override: true,
};
let inputs = vec![
MaterialInputDesc {
name: "base_color".into(),
display_name: "Base Color".into(),
group: "surface_inputs".into(),
order: 0,
property_type: ShaderPropertyType::Color,
default_value: Some(MaterialParameterValue::Color(ColorDesc::default())),
texture: Some(MaterialTextureInputDesc {
semantic: TextureSemantic::Color,
default_channel: TextureChannel::Rgba,
allow_channel_override: false,
}),
tooltip: "Color multiplier applied to the base-color texture.".into(),
advanced: false,
presentation: MaterialInputPresentation::Row,
},
MaterialInputDesc {
name: "metallic".into(),
display_name: "Metallic".into(),
group: "surface_inputs".into(),
order: 10,
property_type: ShaderPropertyType::Float {
min: Some(0.0),
max: Some(1.0),
},
default_value: Some(MaterialParameterValue::Float(0.0)),
texture: Some(scalar_texture(TextureChannel::B)),
tooltip: "Scalar multiplier; ARM/ORM uses the blue channel.".into(),
advanced: false,
presentation: MaterialInputPresentation::Row,
},
MaterialInputDesc {
name: "roughness".into(),
display_name: "Roughness".into(),
group: "surface_inputs".into(),
order: 20,
property_type: ShaderPropertyType::Float {
min: Some(0.0),
max: Some(1.0),
},
default_value: Some(MaterialParameterValue::Float(0.65)),
texture: Some(scalar_texture(TextureChannel::G)),
tooltip: "Scalar multiplier; ARM/ORM uses the green channel.".into(),
advanced: false,
presentation: MaterialInputPresentation::Row,
},
MaterialInputDesc {
name: "occlusion".into(),
display_name: "Occlusion".into(),
group: "surface_inputs".into(),
order: 30,
property_type: ShaderPropertyType::Float {
min: Some(0.0),
max: Some(1.0),
},
default_value: Some(MaterialParameterValue::Float(1.0)),
texture: Some(scalar_texture(TextureChannel::R)),
tooltip: "Ambient-occlusion strength; ARM/ORM uses the red channel.".into(),
advanced: false,
presentation: MaterialInputPresentation::Row,
},
MaterialInputDesc {
name: "normal".into(),
display_name: "Normal".into(),
group: "surface_inputs".into(),
order: 40,
property_type: ShaderPropertyType::Float {
min: Some(0.0),
max: Some(1.0),
},
default_value: Some(MaterialParameterValue::Float(0.85)),
texture: Some(MaterialTextureInputDesc {
semantic: TextureSemantic::Normal,
default_channel: TextureChannel::Rgb,
allow_channel_override: false,
}),
tooltip: String::new(),
advanced: false,
presentation: MaterialInputPresentation::Row,
},
MaterialInputDesc {
name: "emissive_color".into(),
display_name: "Emissive".into(),
group: "surface_inputs".into(),
order: 50,
property_type: ShaderPropertyType::Color,
default_value: Some(MaterialParameterValue::Color(ColorDesc::srgb(
1.0, 1.0, 1.0,
))),
texture: Some(MaterialTextureInputDesc {
semantic: TextureSemantic::Color,
default_channel: TextureChannel::Rgb,
allow_channel_override: false,
}),
tooltip: "Emissive color multiplied by the texture and intensity.".into(),
advanced: false,
presentation: MaterialInputPresentation::Row,
},
MaterialInputDesc {
name: "emissive_intensity".into(),
display_name: "Emissive Intensity".into(),
group: "surface_inputs".into(),
order: 60,
property_type: ShaderPropertyType::Float {
min: Some(0.0),
max: Some(50_000.0),
},
default_value: Some(MaterialParameterValue::Float(0.0)),
texture: None,
tooltip: "Emissive luminance multiplier in nits.".into(),
advanced: false,
presentation: MaterialInputPresentation::Companion {
owner: "emissive_color".into(),
},
},
MaterialInputDesc {
name: "uv_offset".into(),
display_name: "Offset".into(),
group: "uv_transform".into(),
order: 0,
property_type: ShaderPropertyType::Vec2,
default_value: Some(MaterialParameterValue::Vec2(Vec2::ZERO)),
texture: None,
tooltip: "Shared UV translation for every texture on this material.".into(),
advanced: false,
presentation: MaterialInputPresentation::Row,
},
MaterialInputDesc {
name: "uv_tiling".into(),
display_name: "Tiling".into(),
group: "uv_transform".into(),
order: 10,
property_type: ShaderPropertyType::Vec2,
default_value: Some(MaterialParameterValue::Vec2(Vec2::ONE)),
texture: None,
tooltip: "Shared UV scale for every texture on this material.".into(),
advanced: false,
presentation: MaterialInputPresentation::Row,
},
];
MaterialInputSchema { groups, inputs }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn standard_lit_has_six_primary_surface_rows() {
let schema = standard_lit_input_schema();
let surface = schema
.inputs
.iter()
.filter(|input| input.group == "surface_inputs")
.collect::<Vec<_>>();
assert_eq!(surface.len(), 7);
assert_eq!(
surface
.iter()
.filter(|input| matches!(input.presentation, MaterialInputPresentation::Row))
.count(),
6
);
}
}

View File

@ -9,6 +9,7 @@ description = "Shared reflectable authoring types and scene hydration for the FP
avian3d.workspace = true avian3d.workspace = true
bevy.workspace = true bevy.workspace = true
blake3 = "1" blake3 = "1"
material_schema.workspace = true
ron = "0.8" ron = "0.8"
serde.workspace = true serde.workspace = true
settings.workspace = true settings.workspace = true

View File

@ -2,7 +2,7 @@ use avian3d::prelude::ColliderConstructor;
use bevy::prelude::*; use bevy::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ColorDesc, MaterialDesc}; use crate::{ColorDesc, EditorAssetRef, MaterialDesc};
/// Stable persisted identity for an actor. Bevy [`Entity`] IDs are runtime-only. /// Stable persisted identity for an actor. Bevy [`Entity`] IDs are runtime-only.
#[derive( #[derive(
@ -412,48 +412,6 @@ pub const COMPONENT_AUDIO_LISTENER_DESC: &str = "shared::components::AudioListen
/// Stable imported sub-asset identifier used by authored audio clip references. /// Stable imported sub-asset identifier used by authored audio clip references.
pub const AUDIO_CLIP_SUB_ASSET_ID: &str = "audio:source"; pub const AUDIO_CLIP_SUB_ASSET_ID: &str = "audio:source";
/// Reference to an imported content-browser asset or sub-asset.
#[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct EditorAssetRef {
/// Stable project asset registry UUID string.
pub asset_id: String,
/// Stable imported sub-asset ID inside the generated artifact.
#[serde(default, alias = "mesh_label")]
pub sub_asset_id: String,
/// UI label cached for inspectors. The asset registry/artifact remains authoritative.
#[serde(default)]
pub label: String,
/// Optional loadable project path cached for runtime hydration when the
/// referenced asset is not available through an imported artifact lookup.
#[serde(default)]
pub source_path: Option<String>,
}
impl EditorAssetRef {
pub fn new(
asset_id: impl Into<String>,
sub_asset_id: impl Into<String>,
label: impl Into<String>,
) -> Self {
Self {
asset_id: asset_id.into(),
sub_asset_id: sub_asset_id.into(),
label: label.into(),
source_path: None,
}
}
pub fn with_source_path(mut self, source_path: impl Into<String>) -> Self {
self.source_path = Some(source_path.into());
self
}
pub fn is_resolved(&self) -> bool {
!self.asset_id.trim().is_empty() && !self.sub_asset_id.trim().is_empty()
}
}
/// Distance curve used by an authored spatial audio source. /// Distance curve used by an authored spatial audio source.
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] #[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
@ -1237,37 +1195,6 @@ fn default_true() -> bool {
true true
} }
/// How a material should be resolved at runtime.
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub enum MaterialShaderKind {
#[default]
StandardLit,
Unlit,
Custom,
}
/// Reference to a built-in or custom shader schema.
#[derive(Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct ShaderRefDesc {
pub kind: MaterialShaderKind,
#[serde(default)]
pub schema_path: Option<String>,
#[serde(default)]
pub shader_path: Option<String>,
}
impl Default for ShaderRefDesc {
fn default() -> Self {
Self {
kind: MaterialShaderKind::StandardLit,
schema_path: None,
shader_path: None,
}
}
}
/// Typed material parameter value exposed by a shader schema. /// Typed material parameter value exposed by a shader schema.
/// Reflectable reference to an imported 3D model scene (glTF/GLB or FBX). /// Reflectable reference to an imported 3D model scene (glTF/GLB or FBX).
/// Hydration turns this into a `WorldAssetRoot` on the entity. /// Hydration turns this into a `WorldAssetRoot` on the entity.

View File

@ -19,6 +19,14 @@ mod prefab_overrides;
mod renderer_material; mod renderer_material;
mod rendering_profile_asset; mod rendering_profile_asset;
pub use material_schema::{
standard_lit_input_schema, ColorDesc, EditorAssetRef, MaterialAlphaMode, MaterialInputDesc,
MaterialInputGroupDesc, MaterialInputPresentation, MaterialInputSchema, MaterialInputSet,
MaterialInputSource, MaterialInputTarget, MaterialParameter, MaterialParameterValue,
MaterialRenderState, MaterialShaderKind, MaterialTextureBinding, MaterialTextureInputDesc,
ShaderPropertyDesc, ShaderPropertyType, ShaderRefDesc, TextureChannel, TextureSemantic,
};
pub use actor::{infer_actor_kind, validate_actor, ActorValidationError}; pub use actor::{infer_actor_kind, validate_actor, ActorValidationError};
pub use animation::*; pub use animation::*;
pub use asset_fingerprint::AssetSourceFingerprint; pub use asset_fingerprint::AssetSourceFingerprint;
@ -33,12 +41,9 @@ pub use hydration::{
StaticMeshArtifactCache, StaticMeshArtifactCache,
}; };
pub use material_asset::{ pub use material_asset::{
load_resolved_material_from_path, standard_lit_input_schema, MaterialAlphaMode, MaterialAsset, apply_material_inputs_to_desc, load_resolved_material_from_path, material_inputs_from_desc,
MaterialInputDesc, MaterialInputGroupDesc, MaterialInputPresentation, MaterialInputSchema, MaterialAsset, MaterialInstanceAsset, MaterialProvenance, ShaderSchemaAsset,
MaterialInputSet, MaterialInstanceAsset, MaterialProvenance, MaterialRenderState, MATERIAL_ASSET_SCHEMA_VERSION, MATERIAL_INSTANCE_SCHEMA_VERSION, SURFACE_SHADER_SCHEMA_VERSION,
MaterialTextureInputDesc, ShaderPropertyDesc, ShaderPropertyType, ShaderSchemaAsset,
TextureSemantic, MATERIAL_ASSET_SCHEMA_VERSION, MATERIAL_INSTANCE_SCHEMA_VERSION,
SURFACE_SHADER_SCHEMA_VERSION,
}; };
pub use material_desc::*; pub use material_desc::*;
pub use navigation::*; pub use navigation::*;

View File

@ -4,8 +4,9 @@ use bevy::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
ColorDesc, EditorAssetRef, MaterialDesc, MaterialParameter, MaterialParameterValue, EditorAssetRef, MaterialDesc, MaterialInputSchema, MaterialInputSet, MaterialInputSource,
MaterialRef, MaterialShaderKind, MaterialTextureBinding, ShaderRefDesc, TextureChannel, MaterialInputTarget, MaterialParameter, MaterialParameterValue, MaterialRef,
MaterialRenderState, MaterialShaderKind, MaterialTextureBinding, ShaderRefDesc, TextureChannel,
}; };
pub const MATERIAL_ASSET_SCHEMA_VERSION: u32 = 2; pub const MATERIAL_ASSET_SCHEMA_VERSION: u32 = 2;
@ -24,51 +25,8 @@ const fn default_surface_shader_schema_version() -> u32 {
SURFACE_SHADER_SCHEMA_VERSION SURFACE_SHADER_SCHEMA_VERSION
} }
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] /// Converts the runtime/legacy descriptor into the lightweight authored input representation.
pub enum MaterialAlphaMode { pub fn material_inputs_from_desc(material: &MaterialDesc) -> MaterialInputSet {
#[default]
Opaque,
Cutout,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct MaterialRenderState {
#[serde(default)]
pub alpha_mode: MaterialAlphaMode,
#[serde(default = "default_alpha_cutoff")]
pub alpha_cutoff: f32,
#[serde(default)]
pub double_sided: bool,
}
const fn default_alpha_cutoff() -> f32 {
0.5
}
impl Default for MaterialRenderState {
fn default() -> Self {
Self {
alpha_mode: MaterialAlphaMode::Opaque,
alpha_cutoff: default_alpha_cutoff(),
double_sided: false,
}
}
}
/// Canonical values and texture bindings consumed by both built-in and custom material schemas.
///
/// Values are keyed by stable input names. Validation rejects duplicates before publication;
/// vectors are retained instead of maps so shader-defined presentation order remains serializable.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct MaterialInputSet {
#[serde(default)]
pub values: Vec<MaterialParameter>,
#[serde(default)]
pub textures: Vec<MaterialTextureBinding>,
}
impl MaterialInputSet {
pub fn from_material_desc(material: &MaterialDesc) -> Self {
let mut values = vec![ let mut values = vec![
MaterialParameter { MaterialParameter {
name: "base_color".into(), name: "base_color".into(),
@ -135,17 +93,16 @@ impl MaterialInputSet {
upsert_texture(&mut textures, texture.clone()); upsert_texture(&mut textures, texture.clone());
} }
} }
Self { values, textures } MaterialInputSet { values, textures }
} }
pub fn apply_to_material_desc(&self, material: &mut MaterialDesc) { /// Applies authored values to the runtime/legacy material representation.
pub fn apply_material_inputs_to_desc(inputs: &MaterialInputSet, material: &mut MaterialDesc) {
material.parameters.clear(); material.parameters.clear();
material.textures.clear(); material.textures.clear();
for parameter in &self.values { for parameter in &inputs.values {
match (parameter.name.as_str(), &parameter.value) { match (parameter.name.as_str(), &parameter.value) {
("base_color", MaterialParameterValue::Color(value)) => { ("base_color", MaterialParameterValue::Color(value)) => material.base_color = *value,
material.base_color = *value
}
("metallic", MaterialParameterValue::Float(value)) => material.metallic = *value, ("metallic", MaterialParameterValue::Float(value)) => material.metallic = *value,
("roughness", MaterialParameterValue::Float(value)) => material.roughness = *value, ("roughness", MaterialParameterValue::Float(value)) => material.roughness = *value,
("emissive_color", MaterialParameterValue::Color(value)) => { ("emissive_color", MaterialParameterValue::Color(value)) => {
@ -159,47 +116,33 @@ impl MaterialInputSet {
_ => upsert_parameter(&mut material.parameters, parameter.clone()), _ => upsert_parameter(&mut material.parameters, parameter.clone()),
} }
} }
material.base_color_texture = binding_path(self.texture("base_color")); material.base_color_texture = binding_path(inputs.texture("base_color"));
material.normal_map_texture = binding_path(self.texture("normal")); material.normal_map_texture = binding_path(inputs.texture("normal"));
material.emissive_texture = binding_path(self.texture("emissive")); material.emissive_texture = binding_path(inputs.texture("emissive"));
let roughness = binding_path(self.texture("roughness")); let roughness = binding_path(inputs.texture("roughness"));
let metallic = binding_path(self.texture("metallic")); let metallic = binding_path(inputs.texture("metallic"));
material.metallic_roughness_texture = match (roughness, metallic) { material.metallic_roughness_texture = match (roughness, metallic) {
(Some(roughness), Some(metallic)) if roughness == metallic => Some(roughness), (Some(roughness), Some(metallic)) if roughness == metallic => Some(roughness),
(Some(path), None) | (None, Some(path)) => Some(path), (Some(path), None) | (None, Some(path)) => Some(path),
_ => None, _ => None,
}; };
material.occlusion_texture = binding_path(self.texture("occlusion")); material.occlusion_texture = binding_path(inputs.texture("occlusion"));
for texture in &self.textures { for texture in &inputs.textures {
if !is_standard_texture_name(&texture.name) { if !is_standard_texture_name(&texture.name) {
upsert_texture(&mut material.textures, texture.clone()); upsert_texture(&mut material.textures, texture.clone());
} }
} }
} }
pub fn texture(&self, name: &str) -> Option<&MaterialTextureBinding> { impl MaterialInputSource for MaterialDesc {
self.textures.iter().find(|binding| binding.name == name) fn to_material_inputs(&self) -> MaterialInputSet {
material_inputs_from_desc(self)
}
} }
pub fn validate_unique(&self) -> Result<(), String> { impl MaterialInputTarget for MaterialDesc {
let mut names = std::collections::HashSet::new(); fn apply_material_inputs(&mut self, inputs: &MaterialInputSet) {
for value in &self.values { apply_material_inputs_to_desc(inputs, self);
if !names.insert(("value", value.name.as_str())) {
return Err(format!(
"material value `{}` is assigned more than once",
value.name
));
}
}
for texture in &self.textures {
if !names.insert(("texture", texture.name.as_str())) {
return Err(format!(
"material texture `{}` is assigned more than once",
texture.name
));
}
}
Ok(())
} }
} }
@ -420,115 +363,6 @@ fn upsert_texture(values: &mut Vec<MaterialTextureBinding>, value: MaterialTextu
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ShaderPropertyType {
Bool,
Float { min: Option<f32>, max: Option<f32> },
Vec2,
Vec3,
Color,
Enum { options: Vec<String> },
Texture,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct MaterialInputGroupDesc {
pub id: String,
pub display_name: String,
#[serde(default)]
pub order: i32,
#[serde(default)]
pub advanced: bool,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum TextureSemantic {
#[default]
Auto,
Color,
Normal,
Scalar,
Mask,
Hdr,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MaterialTextureInputDesc {
#[serde(default)]
pub semantic: TextureSemantic,
#[serde(default)]
pub default_channel: TextureChannel,
#[serde(default)]
pub allow_channel_override: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MaterialInputDesc {
pub name: String,
pub display_name: String,
#[serde(default)]
pub group: String,
#[serde(default)]
pub order: i32,
pub property_type: ShaderPropertyType,
#[serde(default)]
pub default_value: Option<MaterialParameterValue>,
#[serde(default)]
pub texture: Option<MaterialTextureInputDesc>,
#[serde(default)]
pub tooltip: String,
#[serde(default)]
pub advanced: bool,
/// Authoring presentation only. Runtime evaluation continues to address inputs by `name`.
#[serde(default)]
pub presentation: MaterialInputPresentation,
}
impl Default for MaterialInputDesc {
fn default() -> Self {
Self {
name: String::new(),
display_name: String::new(),
group: String::new(),
order: 0,
property_type: ShaderPropertyType::Float {
min: None,
max: None,
},
default_value: None,
texture: None,
tooltip: String::new(),
advanced: false,
presentation: MaterialInputPresentation::Row,
}
}
}
/// Declares whether an input owns a visible row or is edited through another input's row.
///
/// Companion values remain independent in [`MaterialInputSet`]. This keeps the schema suitable for
/// generated/custom shaders while allowing related controls such as emissive color and intensity
/// to share one coherent Inspector row.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum MaterialInputPresentation {
#[default]
Row,
Companion {
owner: String,
},
}
/// Compatibility source alias for code that has not yet adopted the material-input terminology.
pub type ShaderPropertyDesc = MaterialInputDesc;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct MaterialInputSchema {
#[serde(default)]
pub groups: Vec<MaterialInputGroupDesc>,
#[serde(default)]
pub inputs: Vec<MaterialInputDesc>,
}
/// RON shader schema describing material inspector parameters. /// RON shader schema describing material inspector parameters.
#[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@ -552,181 +386,12 @@ impl ShaderSchemaAsset {
} }
} }
/// Engine-owned Standard Lit authoring schema. It is consumed by the same renderer used for
/// project Surface schemas, so a future graph compiler only needs to emit this descriptor shape.
pub fn standard_lit_input_schema() -> MaterialInputSchema {
let groups = vec![
MaterialInputGroupDesc {
id: "surface_inputs".into(),
display_name: "Surface Inputs".into(),
order: 0,
advanced: false,
},
MaterialInputGroupDesc {
id: "advanced_inputs".into(),
display_name: "Advanced Inputs".into(),
order: 100,
advanced: true,
},
MaterialInputGroupDesc {
id: "uv_transform".into(),
display_name: "UV Transform".into(),
order: 90,
advanced: false,
},
];
let scalar_texture = |channel| MaterialTextureInputDesc {
semantic: TextureSemantic::Scalar,
default_channel: channel,
allow_channel_override: true,
};
let inputs = vec![
MaterialInputDesc {
name: "base_color".into(),
display_name: "Base Color".into(),
group: "surface_inputs".into(),
order: 0,
property_type: ShaderPropertyType::Color,
default_value: Some(MaterialParameterValue::Color(ColorDesc::default())),
texture: Some(MaterialTextureInputDesc {
semantic: TextureSemantic::Color,
default_channel: TextureChannel::Rgba,
allow_channel_override: false,
}),
tooltip: "Color multiplier applied to the base-color texture.".into(),
advanced: false,
presentation: MaterialInputPresentation::Row,
},
MaterialInputDesc {
name: "metallic".into(),
display_name: "Metallic".into(),
group: "surface_inputs".into(),
order: 10,
property_type: ShaderPropertyType::Float {
min: Some(0.0),
max: Some(1.0),
},
default_value: Some(MaterialParameterValue::Float(0.0)),
texture: Some(scalar_texture(TextureChannel::B)),
tooltip: "Scalar multiplier; ARM/ORM uses the blue channel.".into(),
advanced: false,
presentation: MaterialInputPresentation::Row,
},
MaterialInputDesc {
name: "roughness".into(),
display_name: "Roughness".into(),
group: "surface_inputs".into(),
order: 20,
property_type: ShaderPropertyType::Float {
min: Some(0.0),
max: Some(1.0),
},
default_value: Some(MaterialParameterValue::Float(0.65)),
texture: Some(scalar_texture(TextureChannel::G)),
tooltip: "Scalar multiplier; ARM/ORM uses the green channel.".into(),
advanced: false,
presentation: MaterialInputPresentation::Row,
},
MaterialInputDesc {
name: "occlusion".into(),
display_name: "Occlusion".into(),
group: "surface_inputs".into(),
order: 30,
property_type: ShaderPropertyType::Float {
min: Some(0.0),
max: Some(1.0),
},
default_value: Some(MaterialParameterValue::Float(1.0)),
texture: Some(scalar_texture(TextureChannel::R)),
tooltip: "Ambient-occlusion strength; ARM/ORM uses the red channel.".into(),
advanced: false,
presentation: MaterialInputPresentation::Row,
},
MaterialInputDesc {
name: "normal".into(),
display_name: "Normal".into(),
group: "surface_inputs".into(),
order: 40,
property_type: ShaderPropertyType::Float {
min: Some(0.0),
max: Some(1.0),
},
default_value: Some(MaterialParameterValue::Float(0.85)),
texture: Some(MaterialTextureInputDesc {
semantic: TextureSemantic::Normal,
default_channel: TextureChannel::Rgb,
allow_channel_override: false,
}),
tooltip: String::new(),
advanced: false,
presentation: MaterialInputPresentation::Row,
},
MaterialInputDesc {
name: "emissive_color".into(),
display_name: "Emissive".into(),
group: "surface_inputs".into(),
order: 50,
property_type: ShaderPropertyType::Color,
default_value: Some(MaterialParameterValue::Color(ColorDesc::srgb(
1.0, 1.0, 1.0,
))),
texture: Some(MaterialTextureInputDesc {
semantic: TextureSemantic::Color,
default_channel: TextureChannel::Rgb,
allow_channel_override: false,
}),
tooltip: "Emissive color multiplied by the texture and intensity.".into(),
advanced: false,
presentation: MaterialInputPresentation::Row,
},
MaterialInputDesc {
name: "emissive_intensity".into(),
display_name: "Emissive Intensity".into(),
group: "surface_inputs".into(),
order: 60,
property_type: ShaderPropertyType::Float {
min: Some(0.0),
max: Some(50_000.0),
},
default_value: Some(MaterialParameterValue::Float(0.0)),
texture: None,
tooltip: "Emissive luminance multiplier in nits.".into(),
advanced: false,
presentation: MaterialInputPresentation::Companion {
owner: "emissive_color".into(),
},
},
MaterialInputDesc {
name: "uv_offset".into(),
display_name: "Offset".into(),
group: "uv_transform".into(),
order: 0,
property_type: ShaderPropertyType::Vec2,
default_value: Some(MaterialParameterValue::Vec2(Vec2::ZERO)),
texture: None,
tooltip: "Shared UV translation for every texture on this material.".into(),
advanced: false,
presentation: MaterialInputPresentation::Row,
},
MaterialInputDesc {
name: "uv_tiling".into(),
display_name: "Tiling".into(),
group: "uv_transform".into(),
order: 10,
property_type: ShaderPropertyType::Vec2,
default_value: Some(MaterialParameterValue::Vec2(Vec2::ONE)),
texture: None,
tooltip: "Shared UV scale for every texture on this material.".into(),
advanced: false,
presentation: MaterialInputPresentation::Row,
},
];
MaterialInputSchema { groups, inputs }
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::{
standard_lit_input_schema, MaterialAlphaMode, MaterialInputPresentation, ShaderPropertyType,
};
#[test] #[test]
fn material_v2_defaults_to_opaque() { fn material_v2_defaults_to_opaque() {

View File

@ -3,58 +3,10 @@
use bevy::prelude::*; use bevy::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ComponentInstanceId, EditorAssetRef, ShaderRefDesc}; use crate::{
ColorDesc, ComponentInstanceId, EditorAssetRef, MaterialParameter, MaterialTextureBinding,
#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)] ShaderRefDesc,
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] };
#[type_path = "shared::components"]
pub enum MaterialParameterValue {
Bool(bool),
Float(f32),
Vec2(Vec2),
Vec3(Vec3),
Color(ColorDesc),
Enum(String),
}
impl Default for MaterialParameterValue {
fn default() -> Self {
Self::Float(0.0)
}
}
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
#[type_path = "shared::components"]
pub struct MaterialParameter {
pub name: String,
pub value: MaterialParameterValue,
}
/// Channels sampled from a texture binding. Scalar material inputs use one channel while
/// color/normal inputs use RGB or RGBA. The default preserves the pre-v2 whole-texture behavior.
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
#[type_path = "shared::components"]
pub enum TextureChannel {
R,
G,
B,
A,
Rgb,
#[default]
Rgba,
}
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
#[type_path = "shared::components"]
pub struct MaterialTextureBinding {
pub name: String,
pub texture: Option<EditorAssetRef>,
#[serde(default)]
pub channel: TextureChannel,
}
/// Per-render-slot material override stored on scene actors. /// Per-render-slot material override stored on scene actors.
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -76,34 +28,6 @@ pub struct MaterialOverride {
pub slots: Vec<MaterialSlotOverride>, pub slots: Vec<MaterialSlotOverride>,
} }
/// Serializable color description. This avoids coupling saved scenes to any internal color
/// representation details.
#[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, Serialize, Deserialize)]
#[type_path = "shared::components"]
pub struct ColorDesc {
pub r: f32,
pub g: f32,
pub b: f32,
pub a: f32,
}
impl ColorDesc {
pub const fn srgb(r: f32, g: f32, b: f32) -> Self {
Self { r, g, b, a: 1.0 }
}
pub fn to_color(self) -> Color {
Color::srgba(self.r, self.g, self.b, self.a)
}
}
impl Default for ColorDesc {
fn default() -> Self {
Self::srgb(0.8, 0.8, 0.8)
}
}
/// Resolved runtime and legacy-compatible material representation. Project authoring uses /// Resolved runtime and legacy-compatible material representation. Project authoring uses
/// `MaterialAsset`/`MaterialInputSet`; hydration maps this descriptor to renderer materials. /// `MaterialAsset`/`MaterialInputSet`; hydration maps this descriptor to renderer materials.
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]

View File

@ -0,0 +1,16 @@
[package]
name = "ui_gallery"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Focused Blacksite editor UI gallery and Penpot comparison harness"
[dependencies]
eframe.workspace = true
editor_ui.workspace = true
egui.workspace = true
egui_phosphor_icons.workspace = true
image = { version = "0.25", default-features = false, features = ["png"] }
material_schema.workspace = true
serde.workspace = true
serde_json = "1"

View File

@ -0,0 +1,10 @@
{
"material_label": "M_Wood_Planks",
"material_path": "materials/wood/M_Wood_Planks.mat",
"metallic": 0.72,
"roughness": 0.72,
"occlusion": 0.72,
"normal_strength": 0.72,
"emissive_intensity": 0.0,
"uv_tiling": [1.0, 1.0]
}

View File

@ -0,0 +1,16 @@
{
"accent": "#E5A433",
"selection": "#41C6CF",
"panel": "#0D0F12",
"panel_dark": "#090B0D",
"widget": "#15181C",
"elevated": "#1F2328",
"text": "#DCE1E5",
"text_dim": "#9BA3AB",
"text_muted": "#606972",
"border": "#262B31",
"border_strong": "#40474F",
"success": "#54BE7F",
"warning": "#EBB448",
"error": "#E25E5E"
}

View File

@ -0,0 +1,818 @@
use std::path::PathBuf;
use editor_ui::inspector::{ComponentCardOptions, ComponentCardViewModel};
use editor_ui::materials::{
material_input_schema_editor, material_slot_panel, material_surface_options_ui,
materials_section, MaterialHealth, MaterialSlotAction, MaterialThumbnailPresentation,
MaterialsSectionViewModel,
};
use editor_ui::renderer::{
renderer_array_header, renderer_material_slot_row, renderer_material_slots_header,
renderer_panel, renderer_panel_header, renderer_properties_header,
RendererPanelHeaderViewModel,
};
use egui_phosphor_icons::icons;
use material_schema::{standard_lit_input_schema, MaterialShaderKind};
use crate::fixtures::{FixtureId, FixtureModel};
use crate::reference::{
difference_image, ComparisonMode, DifferenceStats, GalleryPreset, ReferenceImage,
};
use crate::reload::RuntimeDocuments;
pub struct UiGalleryApp {
preset: GalleryPreset,
comparison: ComparisonMode,
overlay_opacity: f32,
fixture_id: FixtureId,
fixture: FixtureModel,
fixture_textures: Vec<egui::TextureHandle>,
documents: RuntimeDocuments,
references: Vec<(GalleryPreset, ReferenceImage)>,
reference_error: Option<String>,
last_live_rect: Option<egui::Rect>,
last_reference_rect: Option<egui::Rect>,
difference_texture: Option<egui::TextureHandle>,
difference_stats: Option<DifferenceStats>,
difference_dirty: bool,
capture: Option<CaptureRequest>,
renderer_open: [bool; 3],
}
#[derive(Clone, Copy)]
struct CaptureRequest {
preset: GalleryPreset,
live_rect: egui::Rect,
reference_rect: egui::Rect,
pixels_per_point: f32,
}
impl UiGalleryApp {
pub fn new(creation: &eframe::CreationContext<'_>) -> Self {
creation
.egui_ctx
.set_fonts(editor_ui::fonts::font_definitions());
editor_ui::theme::apply_editor_theme(&creation.egui_ctx);
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."));
let mut documents = RuntimeDocuments::new(&root);
documents.poll(0.0);
if let Ok(palette) = documents.theme_value.palette() {
editor_ui::theme::apply_editor_theme_with_palette(&creation.egui_ctx, palette);
}
let fixture_textures = make_fixture_textures(&creation.egui_ctx);
let (material_thumbnail, texture_thumbnails) = fixture_thumbnail_ids(&fixture_textures);
let fixture_id = FixtureId::Reference;
let fixture = FixtureModel::new(
fixture_id,
&documents.fixture_value,
material_thumbnail,
texture_thumbnails,
);
let mut references = Vec::new();
let mut reference_error = None;
for preset in GalleryPreset::ALL {
match ReferenceImage::load(&creation.egui_ctx, &root, preset) {
Ok(reference) => references.push((preset, reference)),
Err(error) => reference_error = Some(error),
}
}
Self {
preset: GalleryPreset::Materials620,
comparison: ComparisonMode::SideBySide,
overlay_opacity: 0.5,
fixture_id,
fixture,
fixture_textures,
documents,
references,
reference_error,
last_live_rect: None,
last_reference_rect: None,
difference_texture: None,
difference_stats: None,
difference_dirty: true,
capture: None,
renderer_open: [true, false, false],
}
}
fn rebuild_fixture(&mut self, ctx: &egui::Context) {
let (material_thumbnail, texture_thumbnails) =
fixture_thumbnail_ids(&self.fixture_textures);
self.fixture = FixtureModel::new(
self.fixture_id,
&self.documents.fixture_value,
material_thumbnail,
texture_thumbnails,
);
ctx.request_repaint();
self.difference_dirty = true;
}
fn select_preset(&mut self, ctx: &egui::Context, preset: GalleryPreset) {
if self.preset == preset {
return;
}
self.preset = preset;
if preset.is_renderer() && !self.fixture_id.is_renderer() {
self.fixture_id = FixtureId::StaticMesh;
self.rebuild_fixture(ctx);
}
self.difference_dirty = true;
}
fn select_fixture(&mut self, ctx: &egui::Context, fixture: FixtureId) {
if self.fixture_id == fixture {
return;
}
self.fixture_id = fixture;
self.rebuild_fixture(ctx);
}
fn select_comparison(&mut self, comparison: ComparisonMode) {
if self.comparison == comparison {
return;
}
self.comparison = comparison;
self.difference_dirty = true;
}
fn handle_shortcuts(&mut self, ctx: &egui::Context) {
let (function_key, shift, previous_fixture, next_fixture) = ctx.input(|input| {
let function_key = [egui::Key::F1, egui::Key::F2, egui::Key::F3, egui::Key::F4]
.into_iter()
.position(|key| input.key_pressed(key));
(
function_key,
input.modifiers.shift,
input.key_pressed(egui::Key::PageUp),
input.key_pressed(egui::Key::PageDown),
)
});
if let Some(index) = function_key {
if shift {
self.select_comparison(ComparisonMode::ALL[index]);
} else {
self.select_preset(ctx, GalleryPreset::ALL[index]);
}
}
if previous_fixture || next_fixture {
let current = FixtureId::ALL
.iter()
.position(|fixture| *fixture == self.fixture_id)
.unwrap_or_default();
let next = if previous_fixture {
current.checked_sub(1).unwrap_or(FixtureId::ALL.len() - 1)
} else {
(current + 1) % FixtureId::ALL.len()
};
self.select_fixture(ctx, FixtureId::ALL[next]);
}
}
fn reference(&self) -> Option<&ReferenceImage> {
self.references
.iter()
.find_map(|(preset, image)| (*preset == self.preset).then_some(image))
}
fn handle_screenshot_events(&mut self, ctx: &egui::Context) {
let screenshots = ctx.input(|input| {
input
.raw
.events
.iter()
.filter_map(|event| match event {
egui::Event::Screenshot { image, .. } => Some(image.clone()),
_ => None,
})
.collect::<Vec<_>>()
});
let Some(request) = self.capture.take() else {
return;
};
let Some(screenshot) = screenshots.last() else {
self.capture = Some(request);
return;
};
let (difference, stats) = difference_image(
screenshot,
request.live_rect,
request.reference_rect,
request.pixels_per_point,
request.preset,
);
if let Some(texture) = &mut self.difference_texture {
texture.set(difference, egui::TextureOptions::NEAREST);
} else {
self.difference_texture = Some(ctx.load_texture(
"blacksite-ui-gallery-difference",
difference,
egui::TextureOptions::NEAREST,
));
}
self.difference_stats = Some(stats);
self.difference_dirty = false;
}
fn toolbar(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
ui.strong("Blacksite UI Gallery");
ui.separator();
egui::ComboBox::from_id_salt("gallery_preset")
.selected_text(self.preset.label())
.show_ui(ui, |ui| {
for preset in GalleryPreset::ALL {
let selected = self.preset == preset;
if ui.selectable_label(selected, preset.label()).clicked() {
self.select_preset(ctx, preset);
}
}
});
egui::ComboBox::from_id_salt("gallery_fixture")
.selected_text(self.fixture_id.label())
.show_ui(ui, |ui| {
for fixture in FixtureId::ALL {
let selected = self.fixture_id == fixture;
if ui.selectable_label(selected, fixture.label()).clicked() {
self.select_fixture(ctx, fixture);
}
}
});
for mode in ComparisonMode::ALL {
let selected = self.comparison == mode;
if ui.selectable_label(selected, mode.label()).clicked() {
self.select_comparison(mode);
}
}
if self.comparison == ComparisonMode::Overlay {
ui.add(
egui::Slider::new(&mut self.overlay_opacity, 0.0..=1.0)
.text("Reference opacity")
.show_value(false),
);
}
if self.comparison == ComparisonMode::Difference
&& ui.button("Refresh difference").clicked()
{
self.difference_dirty = true;
}
});
ui.horizontal_wrapped(|ui| {
ui.small(format!(
"Canvas: {} px · reference: {}",
self.preset.width() as u32,
self.preset.file_name()
));
ui.separator();
ui.small("Theme and fixture JSON reload without rebuilding");
ui.separator();
ui.small("F1F4 presets · Shift+F1F4 modes · PgUp/PgDn fixtures");
if let Some(stats) = self.difference_stats {
ui.separator();
ui.small(format!(
"Δ mean {:.2} · max {} · {:.1}% pixels > 8",
stats.mean_channel_delta, stats.max_channel_delta, stats.changed_percent
));
}
if let Some(error) = self
.documents
.error
.as_deref()
.or(self.reference_error.as_deref())
{
ui.colored_label(editor_ui::theme::ERROR, error);
}
});
}
fn render_comparison(&mut self, ui: &mut egui::Ui) {
let preset = self.preset;
match self.comparison {
ComparisonMode::Live => {
self.last_live_rect = Some(self.render_live_canvas(ui));
}
ComparisonMode::SideBySide => {
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.strong("Penpot reference");
if let Some(reference) = self.reference() {
reference.paint(ui, preset, 1.0);
}
});
ui.add_space(16.0);
ui.vertical(|ui| {
ui.strong("Production component");
self.last_live_rect = Some(self.render_live_canvas(ui));
});
});
}
ComparisonMode::Overlay => {
self.last_live_rect = Some(self.render_live_canvas(ui));
if let (Some(rect), Some(reference)) = (self.last_live_rect, self.reference()) {
let mut overlay_ui = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect)
.layout(egui::Layout::top_down(egui::Align::Min)),
);
reference.paint(&mut overlay_ui, preset, self.overlay_opacity);
}
}
ComparisonMode::Difference => {
ui.horizontal_top(|ui| {
self.last_live_rect = Some(self.render_live_canvas(ui));
ui.add_space(16.0);
if self.difference_dirty || self.capture.is_some() {
self.last_reference_rect = self
.reference()
.map(|reference| reference.paint(ui, preset, 1.0).rect);
} else if let Some(texture) = &self.difference_texture {
self.last_reference_rect = None;
ui.image((
texture.id(),
egui::vec2(preset.width(), preset.target_height()),
));
} else {
ui.allocate_ui(egui::vec2(preset.width(), preset.target_height()), |ui| {
ui.centered_and_justified(|ui| {
ui.label("Difference capture pending…");
});
});
}
});
}
}
}
fn render_live_canvas(&mut self, ui: &mut egui::Ui) -> egui::Rect {
let size = egui::vec2(self.preset.width(), self.preset.target_height());
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
let palette = editor_ui::design_system::palette(ui);
let (canvas_fill, canvas_radius) = if self.preset.is_renderer() {
(palette.recessed, 4.0)
} else {
(palette.panel, 7.0)
};
ui.painter().rect(
rect,
canvas_radius,
canvas_fill,
egui::Stroke::new(1.0_f32, palette.border_strong),
egui::StrokeKind::Inside,
);
// Penpot material boards are complete Inspector canvases, while renderer boards include a
// authored top inset before the component card. The preset owns both cases.
let content_rect = self.preset.content_rect(rect);
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(content_rect)
.layout(egui::Layout::top_down(egui::Align::Min)),
);
child.set_clip_rect(rect.intersect(ui.clip_rect()));
let previous_inputs = self.fixture.inputs.clone();
let previous_renderer_inputs = self
.fixture
.renderer_slots
.iter()
.map(|fixture| fixture.inputs.clone())
.collect::<Vec<_>>();
if self.preset.is_renderer() {
self.render_renderer_fixture(&mut child);
} else {
self.render_material_fixture(&mut child);
}
if previous_inputs != self.fixture.inputs
|| previous_renderer_inputs
!= self
.fixture
.renderer_slots
.iter()
.map(|fixture| fixture.inputs.clone())
.collect::<Vec<_>>()
{
self.difference_dirty = true;
}
rect
}
fn render_renderer_fixture(&mut self, ui: &mut egui::Ui) {
let title = if self.fixture_id == FixtureId::SkinnedMesh {
"Skinned Mesh Renderer"
} else {
"Static Mesh Renderer"
};
let component = ComponentCardViewModel {
options: ComponentCardOptions {
type_name: "gallery::Renderer",
title,
icon: icons::CUBE,
active_toggle: true,
removable: true,
reorderable: true,
resettable: true,
copyable: true,
summary: "3 renderers · 5 material slots",
body_margin: 0,
},
active: true,
collapsed: false,
pasteable: false,
can_move_up: false,
can_move_down: true,
};
editor_ui::inspector::component_card(ui, &component, |ui| {
let _ = renderer_array_header(ui, self.renderer_open.len());
ui.add_space(26.0);
for renderer_index in 0..self.renderer_open.len() {
let renderer_label = match renderer_index {
0 => "metal_office_desk / Primitive 0",
1 => "metal_office_desk_drawer_06 / Primitive 0",
_ => "metal_office_desk_tray_01 / Primitive 0",
};
renderer_panel(ui, |ui| {
renderer_panel_header(
ui,
RendererPanelHeaderViewModel {
index: renderer_index,
label: renderer_label,
path: "draw:scene0:node2:mesh2:primitive0",
slot_count: if renderer_index == 0 { 3 } else { 1 },
texture_id: None,
},
&mut self.renderer_open[renderer_index],
|ui| {
editor_ui::design_system::controls::icon_button(
ui,
icons::DOTS_THREE_VERTICAL,
"Renderer actions",
true,
);
editor_ui::design_system::controls::icon_button(
ui,
icons::X,
"Remove renderer",
true,
);
editor_ui::design_system::controls::icon_button(
ui,
icons::CROSSHAIR,
"Locate mesh",
true,
);
editor_ui::design_system::controls::icon_button(
ui,
icons::FOLDER_OPEN,
"Browse mesh",
true,
);
},
);
if !self.renderer_open[renderer_index] {
return;
}
renderer_properties_fixture(ui, renderer_label);
let slot_count = if renderer_index == 0 { 3 } else { 1 };
renderer_material_slots_header(ui, slot_count);
if renderer_index == 0 {
for slot_index in 0..self.fixture.renderer_slots.len() {
renderer_material_slot_row(ui, slot_index, |ui| {
self.render_renderer_material_slot(ui, slot_index);
});
}
}
});
}
});
}
fn render_material_fixture(&mut self, ui: &mut egui::Ui) {
let section = MaterialsSectionViewModel {
slot_ids: vec![self.fixture.slot.slot_id.clone()],
};
materials_section(ui, &section, |ui| {
let slot = &mut self.fixture.slot;
let render_state = &mut self.fixture.render_state;
let inputs = &mut self.fixture.inputs;
let textures = &self.fixture.textures;
let response = material_slot_panel(
ui,
("gallery-slot", &slot.slot_id),
slot,
|ui| {
material_surface_options_ui(ui, render_state, Some(inputs));
ui.add_space(4.0);
material_input_schema_editor(
ui,
&standard_lit_input_schema(),
inputs,
textures,
None,
);
},
|ui| {
ui.label(format!("Stable slot: {}", slot.slot_id));
ui.label("Gallery fixture — no project state is loaded");
},
);
for action in response.actions {
apply_slot_action(slot, action.action);
}
});
}
fn render_renderer_material_slot(&mut self, ui: &mut egui::Ui, slot_index: usize) {
let preset_label = self.preset.label();
let fixture = &mut self.fixture.renderer_slots[slot_index];
let slot_id = fixture.slot.slot_id.clone();
fixture.slot.expanded_header_when_parameters_closed = true;
fixture.slot.parameters_initially_open =
slot_index == 0 && self.preset == GalleryPreset::Renderer760;
let textures = &self.fixture.textures;
ui.push_id(
(
"gallery-renderer-slot-scope",
preset_label,
slot_id.as_str(),
),
|ui| {
let response = material_slot_panel(
ui,
"material-panel",
&fixture.slot,
|ui| {
material_surface_options_ui(
ui,
&mut fixture.render_state,
Some(&mut fixture.inputs),
);
ui.add_space(4.0);
material_input_schema_editor(
ui,
&standard_lit_input_schema(),
&mut fixture.inputs,
textures,
None,
);
},
|ui| {
ui.label(format!("Stable slot: {}", fixture.slot.slot_id));
ui.label("Gallery renderer fixture — no project state is loaded");
},
);
for action in response.actions {
apply_slot_action(&mut fixture.slot, action.action);
}
},
);
}
}
impl eframe::App for UiGalleryApp {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.handle_screenshot_events(ctx);
self.handle_shortcuts(ctx);
let now = ctx.input(|input| input.time);
if self.documents.poll(now) {
if let Ok(palette) = self.documents.theme_value.palette() {
editor_ui::theme::apply_editor_theme_with_palette(ctx, palette);
}
self.rebuild_fixture(ctx);
}
ctx.request_repaint_after(std::time::Duration::from_millis(250));
}
fn ui(&mut self, root: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = root.ctx().clone();
egui::CentralPanel::default().show_inside(root, |ui| {
self.toolbar(&ctx, ui);
ui.separator();
egui::ScrollArea::both()
.id_salt("gallery_canvas_scroll")
.auto_shrink([false, false])
.show(ui, |ui| self.render_comparison(ui));
if self.comparison == ComparisonMode::Difference
&& self.difference_dirty
&& self.capture.is_none()
{
if let (Some(live_rect), Some(reference_rect)) =
(self.last_live_rect, self.last_reference_rect)
{
self.capture = Some(CaptureRequest {
preset: self.preset,
live_rect,
reference_rect,
pixels_per_point: ctx.pixels_per_point(),
});
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(
"blacksite-ui-gallery-difference",
)));
}
}
});
}
}
fn renderer_properties_fixture(ui: &mut egui::Ui, name: &str) {
let palette = editor_ui::design_system::palette(ui);
let mut name_buffer = name.to_owned();
egui::Frame::new()
.inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| {
renderer_properties_header(ui);
ui.add_space(2.0);
property_fixture_row(ui, "Name", |ui| {
ui.add_sized(
[ui.available_width().max(1.0), 24.0],
egui::TextEdit::singleline(&mut name_buffer),
);
});
ui.add_space(4.0);
property_fixture_row(ui, "Mesh", |ui| {
let field = ui.available_rect_before_wrap();
let field =
egui::Rect::from_min_size(field.min, egui::vec2(field.width().max(1.0), 38.0));
ui.allocate_rect(field, egui::Sense::hover());
ui.painter().rect(
field,
4.0,
palette.control,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
ui.painter().text(
field.left_center() + egui::vec2(10.0, -7.0),
egui::Align2::LEFT_CENTER,
name,
editor_ui::design_system::typography::TypeRole::Body.font(),
palette.text_primary,
);
ui.painter().text(
field.left_center() + egui::vec2(10.0, 8.0),
egui::Align2::LEFT_CENTER,
"assets/Furniture/Office/metal_office_desk.mesh",
editor_ui::design_system::typography::TypeRole::Small.font(),
palette.text_muted,
);
});
ui.add_space(4.0);
property_fixture_row(ui, "Flags", |ui| {
let mut visible = true;
let mut cast_shadows = true;
let mut receive_shadows = true;
ui.horizontal_wrapped(|ui| {
ui.checkbox(&mut visible, "Visible");
ui.checkbox(&mut cast_shadows, "Cast shadows");
ui.checkbox(&mut receive_shadows, "Receive shadows");
});
});
ui.add_space(4.0);
});
}
fn property_fixture_row(ui: &mut egui::Ui, label: &str, add_value: impl FnOnce(&mut egui::Ui)) {
ui.horizontal_top(|ui| {
ui.allocate_ui_with_layout(
egui::vec2(72.0, 28.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
ui.label(label);
},
);
ui.vertical(add_value);
});
}
fn apply_slot_action(
slot: &mut editor_ui::materials::MaterialSlotPanelViewModel,
action: MaterialSlotAction,
) {
match action {
MaterialSlotAction::Assign { reference, .. } => {
slot.label = reference.label.clone();
slot.path = reference.source_path.clone();
slot.assigned = Some(reference);
slot.health = MaterialHealth::Dirty;
}
MaterialSlotAction::Clear => {
slot.assigned = None;
slot.health = MaterialHealth::Dirty;
}
MaterialSlotAction::SetShader(kind) => {
slot.shader_kind = Some(kind);
slot.shader = match kind {
MaterialShaderKind::StandardLit => "Standard Lit",
MaterialShaderKind::Unlit => "Unlit",
MaterialShaderKind::Custom => "Custom Surface",
}
.to_owned();
slot.health = MaterialHealth::Dirty;
}
MaterialSlotAction::BrowseLibrary
| MaterialSlotAction::Locate
| MaterialSlotAction::ExtractEditable
| MaterialSlotAction::CreateInstance => {}
}
}
fn make_fixture_textures(ctx: &egui::Context) -> Vec<egui::TextureHandle> {
let mut handles = vec![make_material_thumbnail(ctx)];
handles.extend([
checker_thumbnail(ctx, "gallery-base-color", [126, 100, 74], [87, 69, 51]),
checker_thumbnail(ctx, "gallery-orm", [198, 238, 18], [103, 164, 8]),
checker_thumbnail(ctx, "gallery-normal", [116, 92, 232], [73, 145, 242]),
]);
handles
}
fn fixture_thumbnail_ids(
handles: &[egui::TextureHandle],
) -> (
MaterialThumbnailPresentation,
[MaterialThumbnailPresentation; 3],
) {
(
MaterialThumbnailPresentation::Ready(handles[0].id()),
[
MaterialThumbnailPresentation::Ready(handles[1].id()),
MaterialThumbnailPresentation::Ready(handles[2].id()),
MaterialThumbnailPresentation::Ready(handles[3].id()),
],
)
}
fn make_material_thumbnail(ctx: &egui::Context) -> egui::TextureHandle {
let size = 64;
let mut image = egui::ColorImage::new([size, size], vec![egui::Color32::BLACK; size * size]);
for y in 0..size {
for x in 0..size {
image[(x, y)] = if (x / 16 + y / 16) % 2 == 0 {
egui::Color32::from_rgb(32, 37, 43)
} else {
egui::Color32::from_rgb(43, 50, 58)
};
}
}
paint_fixture_ellipse(&mut image, [37.0, 42.0], [19.0, 19.0], [17, 22, 27], 0.8);
paint_fixture_ellipse(&mut image, [34.0, 38.0], [20.0, 20.0], [109, 123, 138], 1.0);
paint_fixture_ellipse(&mut image, [27.0, 28.5], [6.0, 4.5], [199, 211, 221], 0.85);
paint_fixture_ellipse(&mut image, [38.0, 42.0], [9.0, 9.0], [63, 74, 86], 1.0);
ctx.load_texture(
"gallery-material-sphere",
image,
egui::TextureOptions::NEAREST,
)
}
fn paint_fixture_ellipse(
image: &mut egui::ColorImage,
center: [f32; 2],
radius: [f32; 2],
color: [u8; 3],
opacity: f32,
) {
for y in 0..image.size[1] {
for x in 0..image.size[0] {
let dx = (x as f32 + 0.5 - center[0]) / radius[0];
let dy = (y as f32 + 0.5 - center[1]) / radius[1];
let distance = (dx * dx + dy * dy).sqrt();
let coverage = ((1.0 - distance) * radius[0].min(radius[1])).clamp(0.0, 1.0);
let alpha = coverage * opacity;
if alpha <= 0.0 {
continue;
}
let previous = image[(x, y)];
let blend = |source: u8, destination: u8| {
(source as f32 * alpha + destination as f32 * (1.0 - alpha)).round() as u8
};
image[(x, y)] = egui::Color32::from_rgb(
blend(color[0], previous.r()),
blend(color[1], previous.g()),
blend(color[2], previous.b()),
);
}
}
}
fn checker_thumbnail(
ctx: &egui::Context,
name: &str,
first: [u8; 3],
second: [u8; 3],
) -> egui::TextureHandle {
let size = 18;
let mut image = egui::ColorImage::new([size, size], vec![egui::Color32::BLACK; size * size]);
for y in 0..size {
for x in 0..size {
let color = if (x / 3 + y / 3) % 2 == 0 {
first
} else {
second
};
image[(x, y)] = egui::Color32::from_rgb(color[0], color[1], color[2]);
}
}
ctx.load_texture(name, image, egui::TextureOptions::NEAREST)
}

View File

@ -0,0 +1,457 @@
use editor_ui::materials::{
MaterialHealth, MaterialLayerBadge, MaterialLocateTarget, MaterialPickerCandidate,
MaterialSlotPanelViewModel, MaterialTextureCandidate, MaterialThumbnailPresentation,
};
use material_schema::{
standard_lit_input_schema, ColorDesc, EditorAssetRef, MaterialInputSet, MaterialParameter,
MaterialParameterValue, MaterialRenderState, MaterialShaderKind, MaterialTextureBinding,
TextureChannel,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FixtureId {
Reference,
Primitive,
StaticMesh,
SkinnedMesh,
Inherited,
Imported,
Dirty,
Processing,
Failed,
ReadOnly,
BuiltIn,
}
impl FixtureId {
pub const ALL: [Self; 11] = [
Self::Reference,
Self::Primitive,
Self::StaticMesh,
Self::SkinnedMesh,
Self::Inherited,
Self::Imported,
Self::Dirty,
Self::Processing,
Self::Failed,
Self::ReadOnly,
Self::BuiltIn,
];
pub const fn label(self) -> &'static str {
match self {
Self::Reference => "Penpot Layout",
Self::Primitive => "Primitive",
Self::StaticMesh => "Static Mesh",
Self::SkinnedMesh => "Skinned Mesh",
Self::Inherited => "Inherited",
Self::Imported => "Imported Source",
Self::Dirty => "Dirty",
Self::Processing => "Processing",
Self::Failed => "Failed",
Self::ReadOnly => "Read only",
Self::BuiltIn => "Built-in DefaultGrid",
}
}
pub const fn is_renderer(self) -> bool {
matches!(self, Self::StaticMesh | Self::SkinnedMesh)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct FixtureOverrides {
pub material_label: Option<String>,
pub material_path: Option<String>,
pub metallic: Option<f32>,
pub roughness: Option<f32>,
pub occlusion: Option<f32>,
pub normal_strength: Option<f32>,
pub emissive_intensity: Option<f32>,
pub uv_tiling: Option<[f32; 2]>,
}
pub struct FixtureModel {
pub slot: MaterialSlotPanelViewModel,
pub render_state: MaterialRenderState,
pub inputs: MaterialInputSet,
pub renderer_slots: Vec<RendererMaterialFixture>,
pub textures: Vec<MaterialTextureCandidate>,
}
pub struct RendererMaterialFixture {
pub slot: MaterialSlotPanelViewModel,
pub render_state: MaterialRenderState,
pub inputs: MaterialInputSet,
}
impl FixtureModel {
pub fn new(
id: FixtureId,
overrides: &FixtureOverrides,
material_thumbnail: MaterialThumbnailPresentation,
texture_thumbnails: [MaterialThumbnailPresentation; 3],
) -> Self {
let material_ref = EditorAssetRef::new(
"c07a5b05-f27d-46c6-8b8c-9de9cdd1472c",
"material:source",
"M_Wood_Planks",
)
.with_source_path("materials/wood/M_Wood_Planks.mat");
let label = overrides
.material_label
.clone()
.unwrap_or_else(|| fixture_material_label(id).to_owned());
let path = overrides
.material_path
.clone()
.unwrap_or_else(|| fixture_material_path(id).to_owned());
let (health, inherited, can_edit_shader, can_clear) = fixture_state(id);
let slot = MaterialSlotPanelViewModel {
slot_id: fixture_slot_id(id).to_owned(),
label,
path: Some(path),
shader: "Standard Lit".to_owned(),
shader_kind: Some(MaterialShaderKind::StandardLit),
can_edit_shader,
thumbnail: material_thumbnail,
inherited,
health,
assigned: (!matches!(id, FixtureId::Inherited | FixtureId::BuiltIn))
.then_some(material_ref.clone()),
effective: Some(material_ref),
candidates: material_candidates(material_thumbnail),
drop_candidate: None,
invalid_drop_reason: None,
can_locate: !matches!(id, FixtureId::BuiltIn),
can_clear,
can_extract: matches!(id, FixtureId::Imported),
can_create_instance: !matches!(id, FixtureId::Imported | FixtureId::BuiltIn),
parameters_initially_open: true,
expanded_header_when_parameters_closed: false,
};
let textures = texture_candidates(texture_thumbnails);
let inputs = fixture_inputs(id, overrides, &textures);
let renderer_slots = renderer_material_fixtures(&slot, &inputs);
Self {
slot,
render_state: MaterialRenderState::default(),
inputs,
renderer_slots,
textures,
}
}
}
fn renderer_material_fixtures(
primary: &MaterialSlotPanelViewModel,
inputs: &MaterialInputSet,
) -> Vec<RendererMaterialFixture> {
let mut painted = primary.clone();
painted.slot_id = "slot:draw:scene0:node2:mesh2:primitive1".to_owned();
painted.label = "M_Painted_Metal".to_owned();
painted.path = Some("assets/materials/office/M_Painted_Metal.mat".to_owned());
painted.assigned = painted.assigned.as_ref().map(|_| {
EditorAssetRef::new(
"gallery-painted-metal",
"material:source",
"M_Painted_Metal",
)
.with_source_path("assets/materials/office/M_Painted_Metal.mat")
});
painted.effective = painted.assigned.clone();
painted.parameters_initially_open = false;
let mut empty = primary.clone();
empty.slot_id = "slot:draw:scene0:node2:mesh2:primitive2".to_owned();
empty.label = "No material assigned".to_owned();
empty.path = None;
empty.shader.clear();
empty.shader_kind = None;
empty.can_edit_shader = false;
empty.thumbnail = MaterialThumbnailPresentation::Empty;
empty.health = MaterialHealth::Healthy;
empty.assigned = None;
empty.effective = None;
empty.can_locate = false;
empty.can_clear = false;
empty.can_create_instance = false;
empty.parameters_initially_open = false;
[primary.clone(), painted, empty]
.into_iter()
.map(|slot| RendererMaterialFixture {
slot,
render_state: MaterialRenderState::default(),
inputs: inputs.clone(),
})
.collect()
}
fn fixture_state(id: FixtureId) -> (MaterialHealth, Option<MaterialLayerBadge>, bool, bool) {
match id {
FixtureId::Inherited => (
MaterialHealth::Healthy,
Some(MaterialLayerBadge::ModelDefault),
false,
false,
),
FixtureId::Imported => (
MaterialHealth::ReadOnly,
Some(MaterialLayerBadge::ModelSource),
false,
false,
),
FixtureId::Dirty => (MaterialHealth::Dirty, None, true, true),
FixtureId::Processing => (MaterialHealth::Processing, None, true, true),
FixtureId::Failed => (MaterialHealth::Broken, None, false, true),
FixtureId::ReadOnly => (MaterialHealth::ReadOnly, None, false, false),
FixtureId::BuiltIn => (
MaterialHealth::ReadOnly,
Some(MaterialLayerBadge::DefaultGrid),
false,
false,
),
FixtureId::Reference
| FixtureId::Primitive
| FixtureId::StaticMesh
| FixtureId::SkinnedMesh => (MaterialHealth::Healthy, None, true, true),
}
}
fn fixture_slot_id(id: FixtureId) -> &'static str {
match id {
FixtureId::Reference => "slot:gallery:penpot-reference",
FixtureId::Primitive => "slot:primitive:surface",
FixtureId::StaticMesh => "slot:draw:scene0:node2:mesh2:primitive0",
FixtureId::SkinnedMesh => "slot:skin:scene0:node1:mesh0:primitive0",
_ => "slot:gallery:surface",
}
}
fn fixture_material_label(id: FixtureId) -> &'static str {
match id {
FixtureId::Imported => "metal_office_desk",
FixtureId::BuiltIn => "Default Grid — Engine Built-in",
FixtureId::Failed => "Missing explicit material",
_ => "M_Wood_Planks",
}
}
fn fixture_material_path(id: FixtureId) -> &'static str {
match id {
FixtureId::Imported => "assets/Furniture/Office/metal_office_desk_2k.gltf#material:0",
FixtureId::BuiltIn => "builtin://materials/default-grid",
FixtureId::Failed => "assets/materials/missing.material.ron",
_ => "materials/wood/M_Wood_Planks.mat",
}
}
fn material_candidates(thumbnail: MaterialThumbnailPresentation) -> Vec<MaterialPickerCandidate> {
["M_Wood_Planks", "M_Painted_Metal", "M_Concrete"]
.into_iter()
.enumerate()
.map(|(index, label)| MaterialPickerCandidate {
reference: EditorAssetRef::new(
format!("gallery-material-{index}"),
"material:source",
label,
)
.with_source_path(format!("assets/materials/{label}.material.ron")),
label: label.to_owned(),
detail: format!("assets/materials/{label}.material.ron"),
thumbnail,
})
.collect()
}
fn texture_candidates(
thumbnails: [MaterialThumbnailPresentation; 3],
) -> Vec<MaterialTextureCandidate> {
[
("T_Wood_ORM", "materials/textures/T_Wood_ORM.png"),
("T_Wood_ORM", "materials/textures/T_Wood_ORM.png"),
("T_Wood_ORM", "materials/textures/T_Wood_ORM.png"),
]
.into_iter()
.enumerate()
.map(|(index, (label, path))| MaterialTextureCandidate {
label: label.to_owned(),
detail: path.to_owned(),
reference: EditorAssetRef::new(format!("gallery-texture-{index}"), "texture:source", label)
.with_source_path(path),
locate: MaterialLocateTarget::File(path.to_owned()),
thumbnail: thumbnails[index],
})
.collect()
}
fn fixture_inputs(
id: FixtureId,
overrides: &FixtureOverrides,
textures: &[MaterialTextureCandidate],
) -> MaterialInputSet {
let schema = standard_lit_input_schema();
let mut set = MaterialInputSet::default();
for input in &schema.inputs {
if let Some(value) = input.default_value.clone() {
set.values.push(MaterialParameter {
name: input.name.clone(),
value,
});
}
if let Some(texture) = input.texture.as_ref() {
set.textures.push(MaterialTextureBinding {
name: input.name.clone(),
texture: None,
channel: texture.default_channel,
});
}
}
set_value(
&mut set,
"base_color",
MaterialParameterValue::Color(ColorDesc::srgb(231.0 / 255.0, 212.0 / 255.0, 180.0 / 255.0)),
);
set_value(
&mut set,
"emissive_color",
MaterialParameterValue::Color(ColorDesc::srgb(231.0 / 255.0, 212.0 / 255.0, 180.0 / 255.0)),
);
set_float(&mut set, "metallic", overrides.metallic.unwrap_or(0.72));
set_float(&mut set, "roughness", overrides.roughness.unwrap_or(0.72));
set_float(&mut set, "occlusion", overrides.occlusion.unwrap_or(0.72));
set_float(
&mut set,
"normal",
overrides.normal_strength.unwrap_or(0.72),
);
set_float(
&mut set,
"emissive_intensity",
overrides.emissive_intensity.unwrap_or(0.0),
);
if let Some([x, y]) = overrides.uv_tiling {
if let Some(parameter) = set
.values
.iter_mut()
.find(|value| value.name == "uv_tiling")
{
if let MaterialParameterValue::Vec2(value) = &mut parameter.value {
value.x = x;
value.y = y;
}
}
}
bind(&mut set, "base_color", &textures[0], TextureChannel::Rgba);
for (name, channel) in [
("occlusion", TextureChannel::R),
("roughness", TextureChannel::G),
("metallic", TextureChannel::B),
] {
bind(&mut set, name, &textures[1], channel);
}
bind(&mut set, "normal", &textures[2], TextureChannel::Rgb);
if id == FixtureId::Reference {
// Penpot's sample binds the same mock texture to Emissive. Its all-G scalar labels conflict
// with the simultaneously selected ORM mode; keep Blacksite's valid R/G/B contract and
// document those three sample glyphs as an intentional handoff delta.
bind(
&mut set,
"emissive_color",
&textures[1],
TextureChannel::Rgb,
);
}
set
}
fn set_float(inputs: &mut MaterialInputSet, name: &str, value: f32) {
set_value(inputs, name, MaterialParameterValue::Float(value));
}
fn set_value(inputs: &mut MaterialInputSet, name: &str, value: MaterialParameterValue) {
if let Some(parameter) = inputs.values.iter_mut().find(|item| item.name == name) {
parameter.value = value;
}
}
fn bind(
inputs: &mut MaterialInputSet,
name: &str,
candidate: &MaterialTextureCandidate,
channel: TextureChannel,
) {
if let Some(binding) = inputs.textures.iter_mut().find(|item| item.name == name) {
binding.texture = Some(candidate.reference.clone());
binding.channel = channel;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_textures() -> Vec<MaterialTextureCandidate> {
texture_candidates([MaterialThumbnailPresentation::Pending; 3])
}
#[test]
fn reference_fixture_keeps_valid_orm_channels_and_adds_penpot_emissive_sample() {
let textures = test_textures();
let reference = fixture_inputs(
FixtureId::Reference,
&FixtureOverrides::default(),
&textures,
);
assert_eq!(
reference
.texture("occlusion")
.map(|binding| binding.channel),
Some(TextureChannel::R)
);
assert_eq!(
reference
.texture("roughness")
.map(|binding| binding.channel),
Some(TextureChannel::G)
);
assert_eq!(
reference.texture("metallic").map(|binding| binding.channel),
Some(TextureChannel::B)
);
assert!(reference
.texture("emissive_color")
.and_then(|binding| binding.texture.as_ref())
.is_some());
let primitive = fixture_inputs(
FixtureId::Primitive,
&FixtureOverrides::default(),
&textures,
);
assert_eq!(
primitive
.texture("occlusion")
.map(|binding| binding.channel),
Some(TextureChannel::R)
);
assert_eq!(
primitive
.texture("roughness")
.map(|binding| binding.channel),
Some(TextureChannel::G)
);
assert_eq!(
primitive.texture("metallic").map(|binding| binding.channel),
Some(TextureChannel::B)
);
assert!(primitive
.texture("emissive_color")
.and_then(|binding| binding.texture.as_ref())
.is_none());
}
}

View File

@ -0,0 +1,19 @@
mod app;
mod fixtures;
mod reference;
mod reload;
fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("Blacksite UI Gallery")
.with_inner_size([1420.0, 940.0])
.with_min_inner_size([900.0, 620.0]),
..Default::default()
};
eframe::run_native(
"Blacksite UI Gallery",
options,
Box::new(|creation| Ok(Box::new(app::UiGalleryApp::new(creation)))),
)
}

View File

@ -0,0 +1,318 @@
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GalleryPreset {
Materials620,
Materials420,
Renderer760,
Renderer480,
}
impl GalleryPreset {
pub const ALL: [Self; 4] = [
Self::Materials620,
Self::Materials420,
Self::Renderer760,
Self::Renderer480,
];
pub const fn label(self) -> &'static str {
match self {
Self::Materials620 => "Materials · 620",
Self::Materials420 => "Materials · 420",
Self::Renderer760 => "Renderer · 760",
Self::Renderer480 => "Renderer · 480",
}
}
pub const fn width(self) -> f32 {
match self {
Self::Materials620 => 620.0,
Self::Materials420 => 420.0,
Self::Renderer760 => 760.0,
Self::Renderer480 => 480.0,
}
}
pub const fn target_height(self) -> f32 {
match self {
Self::Materials620 => 539.0,
Self::Materials420 => 735.0,
Self::Renderer760 => 1233.0,
Self::Renderer480 => 828.0,
}
}
pub const fn file_name(self) -> &'static str {
match self {
Self::Materials620 => "materials-wide-620.png",
Self::Materials420 => "materials-narrow-420.png",
Self::Renderer760 => "static-mesh-renderer-wide-760.png",
Self::Renderer480 => "static-mesh-renderer-narrow-480.png",
}
}
pub const fn is_renderer(self) -> bool {
matches!(self, Self::Renderer760 | Self::Renderer480)
}
/// Production-content canvas inside the exported Penpot reference.
///
/// Material boards include the Inspector border and its 12 px horizontal / 10 px top inset;
/// renderer boards are already complete component canvases.
pub fn content_rect(self, canvas: egui::Rect) -> egui::Rect {
if self.is_renderer() {
egui::Rect::from_min_max(
canvas.min + egui::vec2(0.0, 12.0),
egui::pos2(canvas.right(), canvas.bottom()),
)
} else {
egui::Rect::from_min_max(
canvas.min + egui::vec2(12.0, 10.0),
egui::pos2(canvas.right() - 12.0, canvas.bottom()),
)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComparisonMode {
Live,
SideBySide,
Overlay,
Difference,
}
impl ComparisonMode {
pub const ALL: [Self; 4] = [
Self::Live,
Self::SideBySide,
Self::Overlay,
Self::Difference,
];
pub const fn label(self) -> &'static str {
match self {
Self::Live => "Live",
Self::SideBySide => "Side by side",
Self::Overlay => "Overlay",
Self::Difference => "Difference",
}
}
}
pub struct ReferenceImage {
pub source_size: [usize; 2],
pub texture: egui::TextureHandle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ReferenceCrop {
origin: [usize; 2],
size: [usize; 2],
}
fn reference_crop(source_size: [usize; 2], preset: GalleryPreset) -> ReferenceCrop {
let target_width = preset.width().round().max(1.0) as usize;
let target_height = preset.target_height().round().max(1.0) as usize;
// Penpot's material exports include the frame's one-pixel outer stroke in their viewBox,
// producing 622/422 px PNGs for 620/420 px boards. That stroke is export overscan, not part of
// the authored canvas. Cropping it preserves direct 1:1 pixels; scaling it into the canvas
// blurs text and introduces a false global geometry delta.
let overscan = source_size[0].saturating_sub(target_width);
let inset = usize::from(overscan == 2);
ReferenceCrop {
origin: [inset, inset],
size: [
target_width.min(source_size[0].saturating_sub(inset)),
target_height.min(source_size[1].saturating_sub(inset)),
],
}
}
impl ReferenceImage {
pub fn load(ctx: &egui::Context, root: &Path, preset: GalleryPreset) -> Result<Self, String> {
let path = reference_path(root, preset);
let bytes = std::fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?;
let decoded = image::load_from_memory(&bytes)
.map_err(|error| format!("{}: {error}", path.display()))?
.to_rgba8();
let source_size = [decoded.width() as usize, decoded.height() as usize];
let pixels = egui::ColorImage::from_rgba_unmultiplied(source_size, decoded.as_raw());
let texture = ctx.load_texture(
format!("penpot-reference-{}", preset.label()),
pixels.clone(),
egui::TextureOptions::NEAREST,
);
Ok(Self {
source_size,
texture,
})
}
pub fn paint(&self, ui: &mut egui::Ui, preset: GalleryPreset, opacity: f32) -> egui::Response {
let size = egui::vec2(preset.width(), preset.target_height());
let crop = reference_crop(self.source_size, preset);
let uv_min = egui::pos2(
crop.origin[0] as f32 / self.source_size[0] as f32,
crop.origin[1] as f32 / self.source_size[1] as f32,
);
let uv_max = egui::pos2(
(crop.origin[0] + crop.size[0]) as f32 / self.source_size[0] as f32,
(crop.origin[1] + crop.size[1]) as f32 / self.source_size[1] as f32,
);
ui.add(
egui::Image::new((self.texture.id(), size))
.uv(egui::Rect::from_min_max(uv_min, uv_max))
.tint(egui::Color32::from_white_alpha((opacity * 255.0) as u8)),
)
}
}
pub fn difference_image(
screenshot: &egui::ColorImage,
live_rect: egui::Rect,
reference_rect: egui::Rect,
pixels_per_point: f32,
preset: GalleryPreset,
) -> (egui::ColorImage, DifferenceStats) {
let width = preset.width().round().max(1.0) as usize;
let height = preset.target_height().round().max(1.0) as usize;
let mut output =
egui::ColorImage::new([width, height], vec![egui::Color32::BLACK; width * height]);
let mut total = 0_u64;
let mut max_delta = 0_u8;
let mut changed = 0_u64;
for y in 0..height {
for x in 0..width {
let live =
sample_screenshot(screenshot, live_rect, pixels_per_point, x, y, width, height);
let reference_color = sample_screenshot(
screenshot,
reference_rect,
pixels_per_point,
x,
y,
width,
height,
);
let delta = [
live.r().abs_diff(reference_color.r()),
live.g().abs_diff(reference_color.g()),
live.b().abs_diff(reference_color.b()),
];
let peak = *delta.iter().max().unwrap_or(&0);
max_delta = max_delta.max(peak);
total += delta.into_iter().map(u64::from).sum::<u64>();
changed += u64::from(peak > 8);
let amplified = |value: u8| value.saturating_mul(4);
output[(x, y)] = egui::Color32::from_rgb(
amplified(delta[0]),
amplified(delta[1]),
amplified(delta[2]),
);
}
}
let samples = (width * height * 3).max(1) as f32;
let pixels = (width * height).max(1) as f32;
(
output,
DifferenceStats {
mean_channel_delta: total as f32 / samples,
max_channel_delta: max_delta,
changed_percent: changed as f32 * 100.0 / pixels,
},
)
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DifferenceStats {
pub mean_channel_delta: f32,
pub max_channel_delta: u8,
pub changed_percent: f32,
}
fn reference_path(root: &Path, preset: GalleryPreset) -> PathBuf {
root.join("docs/editor/evaluations/content-workspace-m2/penpot")
.join(preset.file_name())
}
fn sample_screenshot(
image: &egui::ColorImage,
rect: egui::Rect,
pixels_per_point: f32,
x: usize,
y: usize,
width: usize,
height: usize,
) -> egui::Color32 {
let px = ((rect.left() + (x as f32 + 0.5) * rect.width() / width as f32) * pixels_per_point)
.floor() as isize;
let py = ((rect.top() + (y as f32 + 0.5) * rect.height() / height as f32) * pixels_per_point)
.floor() as isize;
if px < 0 || py < 0 || px as usize >= image.size[0] || py as usize >= image.size[1] {
return egui::Color32::BLACK;
}
image[(px as usize, py as usize)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn material_presets_supply_exported_slot_widths() {
for (preset, expected_width) in [
(GalleryPreset::Materials620, 596.0),
(GalleryPreset::Materials420, 396.0),
] {
let canvas = egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(preset.width(), preset.target_height()),
);
let content = preset.content_rect(canvas);
assert_eq!(content.left(), 12.0);
assert_eq!(content.top(), 10.0);
assert_eq!(content.width(), expected_width);
}
}
#[test]
fn renderer_presets_preserve_the_exported_top_inset() {
for preset in [GalleryPreset::Renderer760, GalleryPreset::Renderer480] {
let canvas = egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(preset.width(), preset.target_height()),
);
let content = preset.content_rect(canvas);
assert_eq!(content.left(), 0.0);
assert_eq!(content.top(), 12.0);
assert_eq!(content.width(), preset.width());
}
}
#[test]
fn material_export_overscan_is_cropped_instead_of_scaled() {
assert_eq!(
reference_crop([622, 661], GalleryPreset::Materials620),
ReferenceCrop {
origin: [1, 1],
size: [620, 539],
}
);
assert_eq!(
reference_crop([422, 857], GalleryPreset::Materials420),
ReferenceCrop {
origin: [1, 1],
size: [420, 735],
}
);
assert_eq!(
reference_crop([760, 1233], GalleryPreset::Renderer760),
ReferenceCrop {
origin: [0, 0],
size: [760, 1233],
}
);
}
}

View File

@ -0,0 +1,243 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use editor_ui::theme::{EditorVisualPalette, BLACKSITE_PALETTE};
use serde::{Deserialize, Serialize};
use crate::fixtures::FixtureOverrides;
const POLL_INTERVAL: Duration = Duration::from_millis(300);
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ThemeOverrides {
pub canvas: Option<String>,
pub panel: Option<String>,
pub panel_dark: Option<String>,
pub widget: Option<String>,
pub elevated: Option<String>,
pub accent: Option<String>,
pub selection: Option<String>,
pub text: Option<String>,
pub text_dim: Option<String>,
pub text_muted: Option<String>,
pub border: Option<String>,
pub border_strong: Option<String>,
pub success: Option<String>,
pub warning: Option<String>,
pub error: Option<String>,
}
impl ThemeOverrides {
pub fn palette(&self) -> Result<EditorVisualPalette, String> {
let mut palette = BLACKSITE_PALETTE;
macro_rules! apply {
($field:ident) => {
if let Some(value) = self.$field.as_deref() {
palette.$field = parse_color(value)?;
}
};
}
apply!(canvas);
apply!(panel);
apply!(panel_dark);
apply!(widget);
apply!(elevated);
apply!(accent);
apply!(selection);
apply!(text);
apply!(text_dim);
apply!(text_muted);
apply!(border);
apply!(border_strong);
apply!(success);
apply!(warning);
apply!(error);
Ok(palette)
}
}
pub struct RuntimeDocuments {
theme: ReloadableDocument<ThemeOverrides>,
fixture: ReloadableDocument<FixtureOverrides>,
pub theme_value: ThemeOverrides,
pub fixture_value: FixtureOverrides,
pub error: Option<String>,
}
impl RuntimeDocuments {
pub fn new(root: &Path) -> Self {
Self::from_directory(root.join("crates/ui_gallery/fixtures"))
}
fn from_directory(directory: PathBuf) -> Self {
Self {
theme: ReloadableDocument::new(directory.join("theme.json")),
fixture: ReloadableDocument::new(directory.join("material.json")),
theme_value: ThemeOverrides::default(),
fixture_value: FixtureOverrides::default(),
error: None,
}
}
pub fn poll(&mut self, now: f64) -> bool {
let mut changed = false;
match self.theme.poll(now) {
Ok(Some(value)) => {
self.theme_value = value;
self.error = None;
changed = true;
}
Err(error) => self.error = Some(error),
Ok(None) => {}
}
match self.fixture.poll(now) {
Ok(Some(value)) => {
self.fixture_value = value;
self.error = None;
changed = true;
}
Err(error) => self.error = Some(error),
Ok(None) => {}
}
changed
}
}
struct ReloadableDocument<T> {
path: PathBuf,
modified: Option<SystemTime>,
next_poll: f64,
marker: std::marker::PhantomData<T>,
}
impl<T> ReloadableDocument<T>
where
T: for<'de> Deserialize<'de>,
{
fn new(path: PathBuf) -> Self {
Self {
path,
modified: None,
next_poll: 0.0,
marker: std::marker::PhantomData,
}
}
fn poll(&mut self, now: f64) -> Result<Option<T>, String> {
if now < self.next_poll {
return Ok(None);
}
self.next_poll = now + POLL_INTERVAL.as_secs_f64();
let metadata = fs::metadata(&self.path)
.map_err(|error| format!("{}: {error}", self.path.display()))?;
let modified = metadata.modified().ok();
if self.modified == modified {
return Ok(None);
}
let bytes =
fs::read(&self.path).map_err(|error| format!("{}: {error}", self.path.display()))?;
let value = serde_json::from_slice(&bytes)
.map_err(|error| format!("{}: {error}", self.path.display()))?;
self.modified = modified;
Ok(Some(value))
}
}
fn parse_color(value: &str) -> Result<egui::Color32, String> {
let value = value.trim().trim_start_matches('#');
if value.len() != 6 && value.len() != 8 {
return Err(format!("expected #RRGGBB or #RRGGBBAA, got {value}"));
}
let byte = |offset| {
u8::from_str_radix(&value[offset..offset + 2], 16)
.map_err(|error| format!("invalid color {value}: {error}"))
};
Ok(egui::Color32::from_rgba_unmultiplied(
byte(0)?,
byte(2)?,
byte(4)?,
if value.len() == 8 { byte(6)? } else { 255 },
))
}
#[cfg(test)]
mod tests {
use super::*;
fn temporary_directory() -> PathBuf {
let nonce = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock after epoch")
.as_nanos();
let directory = std::env::temp_dir().join(format!(
"blacksite-ui-gallery-reload-{}-{nonce}",
std::process::id()
));
fs::create_dir_all(&directory).expect("create temporary fixture directory");
directory
}
#[test]
fn runtime_documents_reload_fixture_without_reconstruction() {
let directory = temporary_directory();
fs::write(directory.join("theme.json"), "{}\n").expect("write theme fixture");
fs::write(directory.join("material.json"), "{}\n").expect("write material fixture");
let mut documents = RuntimeDocuments::from_directory(directory.clone());
assert!(documents.poll(0.0));
assert_eq!(documents.fixture_value.material_label, None);
fs::write(
directory.join("material.json"),
r#"{"material_label":"LIVE_RELOAD_PROOF"}"#,
)
.expect("update material fixture");
assert!(documents.poll(1.0));
assert_eq!(
documents.fixture_value.material_label.as_deref(),
Some("LIVE_RELOAD_PROOF")
);
assert_eq!(documents.error, None);
fs::remove_dir_all(directory).expect("remove temporary fixture directory");
}
#[test]
fn invalid_reload_keeps_the_last_valid_document_and_reports_the_error() {
let directory = temporary_directory();
fs::write(directory.join("theme.json"), "{}\n").expect("write theme fixture");
fs::write(
directory.join("material.json"),
r#"{"material_label":"baseline"}"#,
)
.expect("write material fixture");
let mut documents = RuntimeDocuments::from_directory(directory.clone());
assert!(documents.poll(0.0));
fs::write(directory.join("material.json"), "{ invalid json\n")
.expect("write invalid material fixture");
assert!(!documents.poll(1.0));
assert_eq!(
documents.fixture_value.material_label.as_deref(),
Some("baseline")
);
assert!(documents
.error
.as_deref()
.is_some_and(|error| error.contains("material.json")));
fs::remove_dir_all(directory).expect("remove temporary fixture directory");
}
#[test]
fn theme_colors_accept_rgb_and_rgba_hex() {
assert_eq!(
parse_color("#41C6CF").expect("valid RGB"),
egui::Color32::from_rgb(65, 198, 207)
);
assert_eq!(
parse_color("E25E5E80").expect("valid RGBA"),
egui::Color32::from_rgba_unmultiplied(226, 94, 94, 128)
);
assert!(parse_color("#not-a-color").is_err());
}
}

View File

@ -75,11 +75,27 @@ Standard Lit declares seven independently stored inputs but six primary presenta
restores both values as one interaction without merging their authored/runtime identities. Custom restores both values as one interaction without merging their authored/runtime identities. Custom
shader binding counts likewise count primary rows while rendering companions through their owner. shader binding counts likewise count primary rows while rendering companions through their owner.
Bounded scalar material inputs use one 132 px control composed from a 78 px track, 6 px gap, and Bounded scalar material inputs use the exported visible-child geometry rather than the narrower
48 x 22 numeric field. Expanding the 178 px Advanced preview requests a bounded transparent Penpot parent frame. At the 620 px reference that is a 138.8 px group composed from a
6 px leading inset, 93.8 px track, 6 px gap, and 33 x 22 numeric field. At 420 px the track expands
to 160.3 px while the number remains fixed. Expanding the 178 px Advanced preview requests a bounded
Inspector scroll reveal so its note and lower boundary are visible. The same no-implicit-persistence Inspector scroll reveal so its note and lower boundary are visible. The same no-implicit-persistence
rule applies to sliders, texture assignments, and shader controls. rule applies to sliders, texture assignments, and shader controls.
Focused visual iteration uses a standalone `ui_gallery` binary that links the same `editor_ui` and
`material_schema` production crates as the editor. It owns no project, scene, renderer, or gameplay
runtime. Exact reference presets, direct Penpot PNG comparison, runtime fixture/theme reload, and a
managed last-good rebuild/restart loop shorten the edit cycle without creating a second widget
implementation. Gallery evidence is diagnostic until the same component passes a named full-editor
interaction scenario.
Renderer comparison uses the same rule. The renderer array, renderer identity, property heading,
indexed material heading, and material slots are production `editor_ui` components. A material
slot's asset header and parameter disclosure are related but distinct presentation state: renderer
arrays may keep the complete asset/action header visible while the expensive parameter body is
collapsed. Empty assignments use a first-class empty presentation instead of reusing pending or
failed thumbnail state.
Inspector overflow is part of the shared visual contract. A floating scrollbar keeps component Inspector overflow is part of the shared visual contract. A floating scrollbar keeps component
width stable as content crosses the vertical overflow threshold, and every nested component clip is width stable as content crosses the vertical overflow threshold, and every nested component clip is
an intersection with the owning Inspector body. Ready texture fields use the typed thumbnail as an intersection with the owning Inspector body. Ready texture fields use the typed thumbnail as

View File

@ -56,15 +56,14 @@ body. A 32 px **Materials** heading precedes repeated slots. Expanded headers ar
expanded. An expanded wide row keeps its label/value/channel geometry fixed while only the texture expanded. An expanded wide row keeps its label/value/channel geometry fixed while only the texture
field grows above the reference width. field grows above the reference width.
| Column | Width | | Element | Exported geometry |
|--------|-------| |---------|-------------------|
| Parameter label | 82 px | | Parameter label | x12, 82 px |
| Value | 132 px | | Scalar group | x103.35, 138.8 px visible ownership |
| Value/channel gap | 6 px | | Scalar internals | 6 px leading inset, 93.8 px track, 6 px gap, 33 px number |
| Channel | 48 px | | Channel | x242.5, 48 px |
| Channel/texture gap | 16.5 px | | Texture group | x298.5; 198.5 px field at the reference width |
| Texture field | 198.5 px at the reference width | | Locate / Clear | 24 px each, separated from the field by 6 px |
| Locate / Clear | 24 px each, separated by 6 px |
At the 420 px Inspector floor, the slot is 396 px and parameter sections are 369 px. Surface remains At the 420 px Inspector floor, the slot is 396 px and parameter sections are 369 px. Surface remains
46 px and Inputs is 372 px: each of the six rows uses a 58 px two-line layout with parameter, 46 px and Inputs is 372 px: each of the six rows uses a 58 px two-line layout with parameter,
@ -72,9 +71,10 @@ value, and channel above a 345 px asset picker. UV reflows from 54 px to 94 px.
scrolling, clipped buttons, or height-dependent width changes. scrolling, clipped buttons, or height-dependent width changes.
These reference dimensions are implemented as owning rectangles, not nested `horizontal` flow. These reference dimensions are implemented as owning rectangles, not nested `horizontal` flow.
At 569 px, each input row begins its value at x96, channel at x234, and texture group at x298.5. At 569 px, a scalar row begins its value group at x103.35, channel at x242.5, and texture group at
At 369 px, the parameter line is `x12/y5/w345` and the texture line is x298.5. At 369 px, the scalar group remains at x103.35 and expands to 205.3 px (a 160.3 px track),
`x12/y31/w345`. Texture names are clipped and ellipsized inside the field, so content cannot move the channel begins at x309, and the texture line is `x12/y31/w345`. Texture names are clipped and
ellipsized inside the field, so content cannot move
the fixed Locate/Clear anchors. A transient width below 372 px retains finite clipped rectangles the fixed Locate/Clear anchors. A transient width below 372 px retains finite clipped rectangles
and replaces the two low-frequency texture actions with one overflow control until the Inspector and replaces the two low-frequency texture actions with one overflow control until the Inspector
minimum is restored. minimum is restored.
@ -138,11 +138,44 @@ non-interactive preview labelled `Planned — screen sampling pending`.
Every preview updates the live material document. Apply keeps the dirty in-memory value; Cancel, Every preview updates the live material document. Apply keeps the dirty in-memory value; Cancel,
Escape, Close, and outside dismissal restore the exact value captured on open. No picker action Escape, Close, and outside dismissal restore the exact value captured on open. No picker action
writes source or queues derived processing. Material scalar inputs use the reusable 130 x 22 writes source or queues derived processing. At the 620 px reference, Material scalar inputs use a
Penpot control: a 70 x 14 track, 10 px thumb, 8 px separation, and 48 x 22 numeric field. Expanding 138.8 x 22 visible group: 6 px leading inset, 93.8 x 10 interaction track with a 3 px painted rail
and 4 px thumb radius, 6 px separation, and a 33 x 22 numeric field. At the 420 px reference only
the track grows, to 160.3 px. Expanding
Advanced scrolls its complete 178 px disabled-preview body into view rather than revealing a clipped Advanced scrolls its complete 178 px disabled-preview body into view rather than revealing a clipped
partial section. partial section.
## Focused UI gallery workflow
Use the lightweight production-component gallery for routine design iteration:
```bash
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. F1F4 select Materials 620, Materials 420, Renderer 760, and Renderer
480; Shift+F1F4 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
build leaves the last-good process available.
Renderer presets are not screenshots of a gallery-only approximation. `editor_ui::renderer` owns
the shared renderer-array header, 84/60 px renderer identity headers, 38 px property heading, 50 px
indexed material heading, and slot rail used by both the gallery and the full Static Mesh Renderer
Inspector. Renderer material slots reuse `MaterialSlotPanel`; the first slot may retain its full
82 px identity/action header while only its parameter body is collapsed, secondary slots use the
compact state, and an actually empty slot has an explicit drop/Choose Material presentation.
Difference statistics are diagnostic, not a standalone acceptance threshold: truthful runtime
semantics and renderer rasterization can intentionally differ from an authored mock. Every such
difference is recorded in the [M2 UI gallery delta matrix](evaluations/content-workspace-m2/ui-gallery-delta-matrix.md).
Final acceptance still requires a paired full-editor capture and interaction scenario. If the
build-storage hard limit blocks a rebuild, keep the current last-good binary and evidence, record
the gate as pending, and resolve storage through the managed lane workflow rather than deleting
individual Cargo files.
## Review checklist ## Review checklist
- Verify the 620 px geometry numerically and in a native capture. - Verify the 620 px geometry numerically and in a native capture.

View File

@ -14,10 +14,12 @@
**Release-candidate commit:** Pending publication commit **Release-candidate commit:** Pending publication commit
**Native interaction acceptance:** **Pass for the workflows recorded below** **Native interaction acceptance:** **Pass for the recorded content workflows; current Inspector UI acceptance remains in progress**
**Pinned Penpot acceptance targets:** [export index](penpot/README.md) **Pinned Penpot acceptance targets:** [export index](penpot/README.md)
**Current UI comparison record:** [UI gallery delta matrix](ui-gallery-delta-matrix.md)
This feature-level record covers the Content Browser interaction pass added during M2 completion. This feature-level record covers the Content Browser interaction pass added during M2 completion.
It does not close the milestone or replace the exact-commit, clean-checkout, import/reimport, It does not close the milestone or replace the exact-commit, clean-checkout, import/reimport,
forward/Solari, packaging, and independent signoff gates in the M2 plan. forward/Solari, packaging, and independent signoff gates in the M2 plan.

View File

@ -4,6 +4,10 @@
The current Penpot README makes pages 0130 authoritative, pages 9091 implementation masters only, and page 99 forbidden as current guidance. The `CODEGEN MAP` block is handoff documentation and never renders in Blacksite. The current Penpot README makes pages 0130 authoritative, pages 9091 implementation masters only, and page 99 forbidden as current guidance. The `CODEGEN MAP` block is handoff documentation and never renders in Blacksite.
Measured production/reference differences and intentional semantic exceptions are tracked in the
[UI gallery delta matrix](../ui-gallery-delta-matrix.md). A raw Difference score never overrides a
runtime invariant or substitutes for the paired full-editor acceptance capture.
## Responsive Inspector targets ## Responsive Inspector targets
| Export | Penpot page and shape | Required match | | Export | Penpot page and shape | Required match |

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,58 @@
# M2 UI Gallery Delta Matrix
> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md)
> for current guidance. The canonical visual contract is the [Editor Design System](../../design-system.md),
> with architecture in [ADR 0049](../../../adr/0049-penpot-led-editor-visual-system.md).
This matrix distinguishes correctable production drift from deliberate differences between an
authored Penpot mock and a truthful interactive editor. The direct PNG is the visual reference;
the SVG is used to inspect numeric child geometry because it does not embed Source Sans Pro.
| Area | Penpot reference | Production/gallery truth | Classification | Evidence/state |
|------|------------------|--------------------------|----------------|----------------|
| Materials canvases | 620 x 539 and 420 x 735 | Exact presets render the shared `editor_ui` components | Required match | Geometry/unit tests pass; fresh paired gallery captures and full-editor primitive captures at 620/420 pass |
| Renderer canvases | 760 x 1233 and 480 x 828 | Exact gallery presets use shared renderer/component chrome | Required match | Fresh paired gallery captures cover both widths; the rebuilt full editor now has managed static-renderer captures at 760/480 |
| Renderer hierarchy | 50 px component header, 40 px array header, 84/60 px entry headers, 38 px properties heading, and 50 px indexed-material heading | The gallery and full editor call the same `editor_ui::renderer` view-model/chrome functions | Resolved structural drift | Focused checks pass and the fresh full-editor static renderer shows the same hierarchy without the former duplicate slot presentation |
| Renderer slot states | Full primary slot, compact secondary slot, explicit unassigned drop target, and collapsed sibling renderers | The shared material panel supports a full header with collapsed parameters and a first-class empty thumbnail/Choose Material action | Resolved structural drift | Fresh 760/480 gallery pairs show all states without gallery-only material cards |
| Scalar geometry | 6 px inset, 93.8/160.3 px track, 6 px gap, 33 px number | Same exported child coordinates at 620/420 | Resolved drift | Focused tests and 620 overlay pass |
| Scalar sample value | Text reads `0.72`; thumb is positioned near 0.55 | Thumb position is computed from the displayed/runtime value | Intentional semantic delta | Keep truthful 0.72 normalization |
| ORM channels | ORM selected while AO, Roughness, and Metallic sample glyphs all read G | Canonical ORM is AO=R, Roughness=G, Metallic=B | Intentional semantic delta | Runtime/schema invariant and fixture test |
| Material preview | Authored static sample sphere | Gallery uses a deterministic fixture approximation; full editor uses the real typed thumbnail | Intentional content delta | Full-editor thumbnail was observed pending and then resolved in place without layout movement |
| Texture identity | Mock fields use generic/synthetic asset art | Ready project textures show their typed thumbnail without a redundant generic icon | Intentional product delta | Texture-field test passes |
| Borders/rasterization | Browser-rendered SVG strokes can antialias across pixel boundaries | egui uses deterministic inside strokes | Renderer-level delta | Do not alter semantic colors or geometry to mimic browser antialiasing |
| Typography | Direct PNG preserves Source Sans Pro | Production bundles Source Sans Pro Regular/Bold and maps explicit 400/700 families | Required match | Font/type-role tests and fresh gallery/full-editor captures pass |
| Difference metric | Raw per-pixel comparison includes all sample and raster deltas | Latest Materials 620 diagnostic: mean 7.41, max 231, 22.8% of pixels above 8 | Diagnostic only | Must be interpreted through this matrix, never used alone for acceptance |
| Runtime reload | Not applicable to the design mock | Theme and material fixture JSON reload without rebuild/restart | Framework requirement | Focused reload tests pass |
| Rebuild/restart | Not applicable to the design mock | Focused managed build reports bounded progress; replacement launches before last-good exits; failures preserve last-good | Framework requirement | Eight workflow tests pass |
## Remaining acceptance
- Primitive Materials pass full-editor 620/420 and deep-scroll containment. The rebuilt static
renderer passes the native 760/480 hierarchy and containment check. The current checked-in scene
has no placed skinned actor, so skinned presentation remains proven through the shared production
component, renderer gallery fixture, focused tests, and editor compile rather than a separate
native actor capture.
- The broad `penpot-material-inspector` scenario intentionally remains incomplete because picker,
color, invalid-drop, dirty/save/relaunch, and broken-reference assertions are outside this gallery
framework boundary. Its fixture was restored byte-for-byte after the two managed integration
captures.
- Attach the paired reference/native evidence to #68 at the next authorized tracker synchronization.
Keep #68, #59, #65, and M2 in Acceptance-in-progress; this framework slice is evidence for the
next visual pass, not permission to publish or close the milestone.
## Fresh local evidence — 2026-07-18 10:20 UTC
- Materials 620: `ui-gallery-materials-620-20260718T102015Z.png`
(`cc918c567522204f9dc8d8f179e24e49ba802fe03a81d3a65fa1e5b6058623c9`)
- Materials 420: `ui-gallery-materials-420-20260718T102015Z.png`
(`70dea081df62ab38d589ea559d73a6e8d3685f26fee54dbc87cc8682ebed8474`)
- Renderer 760: `ui-gallery-renderer-760-20260718T102015Z.png`
(`724d9cedf25753de9315993f9e6ea656949f54e5b54c44f1f8ab46f1db88b0d6`)
- Renderer 480: `ui-gallery-renderer-480-20260718T102015Z.png`
(`f90c3f72b757649f5c11362ed6d776e5e85288751ffd74f08f8cc386f162fc4f`)
- Full-editor primitive Inspector 620, 420, and deep-scroll captures are recorded under the same
local `.codex/evidence/` store with timestamp `20260718T095413Z`.
- Full-editor static renderer 480: `penpot-material-inspector-20260718T104504Z.png`
(`3c5c6585822afffa6c29994aa757adf645c81d653e369e059bbee08c26c1aa26`)
- Full-editor static renderer 760: `penpot-material-inspector-20260718T104550Z.png`
(`95ef7366d1671ac494cb5fd389d3332d51a0d5636cd0a02f66e9faf0399eb82f`)

View File

@ -181,6 +181,17 @@ section stacks the texture field beneath that line. A bounded transient fallback
Locate/Clear into overflow instead of allowing negative or out-of-clip geometry. Slot IDs, source Locate/Clear into overflow instead of allowing negative or out-of-clip geometry. Slot IDs, source
paths, fingerprints, and usage counts are diagnostics rather than material parameters. paths, fingerprints, and usage counts are diagnostics rather than material parameters.
Routine visual iteration uses `python scripts/editor/ui_gallery.py watch`, which renders these same
production components against the direct 620/420 Penpot exports without loading material runtime
state. The gallery is diagnostic; final behavior and persistence acceptance still runs in the full
editor. Intentional mock/runtime differences are listed in the
[M2 UI gallery delta matrix](evaluations/content-workspace-m2/ui-gallery-delta-matrix.md).
In renderer arrays, collapsing Parameters does not have to erase material identity or assignment
actions. The shared slot can retain its complete asset header while the parameter body is closed;
secondary compact rows and genuinely empty drop targets are explicit states of that same component,
not separate renderer-only material widgets.
Base Color is a multiplier over its albedo texture. Metallic and Roughness are scalar multipliers Base Color is a multiplier over its albedo texture. Metallic and Roughness are scalar multipliers
over their selected channel; Occlusion is a scalar texture input. The **ARM / glTF ORM** preset uses over their selected channel; Occlusion is a scalar texture input. The **ARM / glTF ORM** preset uses
R=ambient occlusion, G=roughness, and B=metallic. **Separate Maps** samples each map's R channel, R=ambient occlusion, G=roughness, and B=metallic. **Separate Maps** samples each map's R channel,

View File

@ -24,6 +24,10 @@ field() {
LANE="$(field build_lane)" LANE="$(field build_lane)"
[[ -n "$LANE" ]] || LANE="dev" [[ -n "$LANE" ]] || LANE="dev"
PACKAGE="$(field package)"
[[ -n "$PACKAGE" ]] || PACKAGE="editor"
BINARY_NAME="$(field binary)"
[[ -n "$BINARY_NAME" ]] || BINARY_NAME="editor"
FIXTURE="$(field fixture)" FIXTURE="$(field fixture)"
mkdir -p "$STATE_ROOT" "$EVIDENCE_ROOT" mkdir -p "$STATE_ROOT" "$EVIDENCE_ROOT"
@ -102,14 +106,15 @@ PY
case "$ACTION" in case "$ACTION" in
plan) plan)
LANE_JSON="$(lane_json)" python - "$SCENARIO" "$FIXTURE" <<'PY' LANE_JSON="$(lane_json)" PACKAGE="$PACKAGE" BINARY_NAME="$BINARY_NAME" python - "$SCENARIO" "$FIXTURE" <<'PY'
import json, os, pathlib, sys import json, os, pathlib, sys
layout = json.loads(os.environ["LANE_JSON"]) layout = json.loads(os.environ["LANE_JSON"])
scenario = pathlib.Path(sys.argv[1]) scenario = pathlib.Path(sys.argv[1])
fixture = sys.argv[2] fixture = sys.argv[2]
binary = pathlib.Path(layout["target_dir"]) / layout["profile_dir"] / "editor" binary = pathlib.Path(layout["target_dir"]) / layout["profile_dir"] / os.environ["BINARY_NAME"]
print(f"PASS native-qa-plan — {scenario.stem}") print(f"PASS native-qa-plan — {scenario.stem}")
print(f"Lane: {layout['lane']} ({layout['mode']})") print(f"Lane: {layout['lane']} ({layout['mode']})")
print(f"Package: {os.environ['PACKAGE']}")
print(f"Binary: {binary}") print(f"Binary: {binary}")
print(f"Runtime deps: {layout['runtime_deps']}") print(f"Runtime deps: {layout['runtime_deps']}")
print(f"Fixture: {fixture or 'scenario-defined'}") print(f"Fixture: {fixture or 'scenario-defined'}")
@ -131,10 +136,14 @@ PY
--gate "native-$NAME-build" \ --gate "native-$NAME-build" \
--log "$ROOT/.codex/logs/native-$NAME-build.log" \ --log "$ROOT/.codex/logs/native-$NAME-build.log" \
--json-result "$ROOT/.codex/logs/native-$NAME-build.json" \ --json-result "$ROOT/.codex/logs/native-$NAME-build.json" \
-- python "$ROOT/scripts/codex/cargo_lane.py" exec "$LANE" -- cargo build -p editor --bin editor -- python "$ROOT/scripts/codex/cargo_lane.py" exec "$LANE" -- cargo build -p "$PACKAGE" --bin "$BINARY_NAME"
python "$ROOT/scripts/codex/build_storage.py" enforce --phase post python "$ROOT/scripts/codex/build_storage.py" enforce --phase post
LAUNCH_ARGS=()
if [[ "$BINARY_NAME" = "editor" ]]; then
LAUNCH_ARGS=(--project "$ROOT")
fi
nohup python "$ROOT/scripts/codex/cargo_lane.py" run "$LANE" -- \ nohup python "$ROOT/scripts/codex/cargo_lane.py" run "$LANE" -- \
target/debug/editor --project "$ROOT" \ "target/debug/$BINARY_NAME" "${LAUNCH_ARGS[@]}" \
>"$ROOT/.codex/logs/native-$NAME-launch.log" 2>&1 & >"$ROOT/.codex/logs/native-$NAME-launch.log" 2>&1 &
PID=$! PID=$!
python - "$STATE_ROOT/native-$NAME.json" "$PID" <<'PY' python - "$STATE_ROOT/native-$NAME.json" "$PID" <<'PY'
@ -151,7 +160,7 @@ PY
PID="$3" PID="$3"
kill -0 "$PID" 2>/dev/null || { echo "native-qa: PID $PID is not running" >&2; exit 2; } kill -0 "$PID" 2>/dev/null || { echo "native-qa: PID $PID is not running" >&2; exit 2; }
CMDLINE="$(tr '\0' ' ' <"/proc/$PID/cmdline")" CMDLINE="$(tr '\0' ' ' <"/proc/$PID/cmdline")"
[[ "$CMDLINE" == *"editor"* ]] || { echo "native-qa: refusing unexpected process: $CMDLINE" >&2; exit 2; } [[ "$CMDLINE" == *"$BINARY_NAME"* ]] || { echo "native-qa: refusing unexpected process: $CMDLINE" >&2; exit 2; }
snapshot_fixture snapshot_fixture
python - "$STATE_ROOT/native-$NAME.json" "$PID" <<'PY' python - "$STATE_ROOT/native-$NAME.json" "$PID" <<'PY'
import json, pathlib, sys, time import json, pathlib, sys, time
@ -328,7 +337,7 @@ PY
SPAWNED="$(python -c 'import json,sys; print(str(bool(json.load(open(sys.argv[1])).get("spawned_by_runner"))).lower())' "$RECORD")" SPAWNED="$(python -c 'import json,sys; print(str(bool(json.load(open(sys.argv[1])).get("spawned_by_runner"))).lower())' "$RECORD")"
if [[ "$SPAWNED" = "true" ]] && kill -0 "$PID" 2>/dev/null; then if [[ "$SPAWNED" = "true" ]] && kill -0 "$PID" 2>/dev/null; then
CMDLINE="$(tr '\0' ' ' <"/proc/$PID/cmdline")" CMDLINE="$(tr '\0' ' ' <"/proc/$PID/cmdline")"
[[ "$CMDLINE" == *"editor"* ]] || { echo "native-qa: refusing to stop unexpected process: $CMDLINE" >&2; exit 2; } [[ "$CMDLINE" == *"$BINARY_NAME"* ]] || { echo "native-qa: refusing to stop unexpected process: $CMDLINE" >&2; exit 2; }
kill -TERM "$PID" kill -TERM "$PID"
fi fi
python - "$RECORD" <<'PY' python - "$RECORD" <<'PY'

128
scripts/editor/test_ui_gallery.py Executable file
View File

@ -0,0 +1,128 @@
#!/usr/bin/env python3
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
from unittest import mock
MODULE_PATH = Path(__file__).with_name("ui_gallery.py")
SPEC = importlib.util.spec_from_file_location("ui_gallery", MODULE_PATH)
assert SPEC is not None and SPEC.loader is not None
UI_GALLERY = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(UI_GALLERY)
class UiGalleryWorkflowTests(unittest.TestCase):
def test_compiler_artifact_reports_identity_target_and_freshness(self) -> None:
message = {
"reason": "compiler-artifact",
"package_id": "path+file:///workspace#editor_ui@0.1.0",
"target": {"name": "editor_ui"},
"fresh": True,
}
self.assertEqual(
UI_GALLERY.compiler_artifact(message),
(
"path+file:///workspace#editor_ui@0.1.0",
"editor_ui",
True,
),
)
def test_compiler_artifact_rejects_non_artifact_messages(self) -> None:
self.assertIsNone(UI_GALLERY.compiler_artifact({"reason": "build-finished"}))
def test_bounded_output_rejects_oversized_machine_documents(self) -> None:
with self.assertRaises(RuntimeError):
UI_GALLERY.bounded_command_output(
[
"python3",
"-c",
"import sys; sys.stdout.write('x' * 4097)",
],
4096,
)
def test_bounded_output_returns_small_json(self) -> None:
document = {"packages": [], "resolve": {"nodes": []}}
output = UI_GALLERY.bounded_command_output(
["python3", "-c", f"print({json.dumps(json.dumps(document))})"],
4096,
)
self.assertEqual(json.loads(output), document)
def test_source_snapshot_tracks_only_rust_and_manifests(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "src"
source.mkdir()
(source / "panel.rs").write_text("fn panel() {}\n", encoding="utf-8")
(source / "theme.json").write_text("{}\n", encoding="utf-8")
original_roots = UI_GALLERY.WATCH_ROOTS
original_files = UI_GALLERY.WATCH_FILES
try:
UI_GALLERY.WATCH_ROOTS = (source,)
UI_GALLERY.WATCH_FILES = ()
snapshot = UI_GALLERY.source_snapshot()
finally:
UI_GALLERY.WATCH_ROOTS = original_roots
UI_GALLERY.WATCH_FILES = original_files
self.assertEqual(list(snapshot), [source / "panel.rs"])
def test_build_graph_cache_requires_matching_manifest_key(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
cache = Path(temporary) / "graph.json"
original = UI_GALLERY.BUILD_GRAPH_CACHE
UI_GALLERY.BUILD_GRAPH_CACHE = cache
try:
UI_GALLERY.save_build_graph("current", {"package-a", "package-b"})
self.assertEqual(
UI_GALLERY.cached_build_graph("current"),
{"package-a", "package-b"},
)
self.assertIsNone(UI_GALLERY.cached_build_graph("stale"))
finally:
UI_GALLERY.BUILD_GRAPH_CACHE = original
def test_failed_rebuild_preserves_the_last_good_gallery(self) -> None:
current = mock.Mock()
with (
mock.patch.object(UI_GALLERY, "build_gallery", return_value=1),
mock.patch.object(UI_GALLERY, "launch_gallery") as launch,
mock.patch.object(UI_GALLERY, "stop_gallery") as stop,
):
replacement = UI_GALLERY.rebuild_and_replace_gallery(current)
self.assertIs(replacement, current)
launch.assert_not_called()
stop.assert_not_called()
def test_successful_rebuild_launches_before_retiring_the_last_good_gallery(self) -> None:
current = mock.Mock()
next_gallery = mock.Mock()
order: list[str] = []
with (
mock.patch.object(UI_GALLERY, "build_gallery", return_value=0),
mock.patch.object(
UI_GALLERY,
"launch_gallery",
side_effect=lambda: order.append("launch") or next_gallery,
),
mock.patch.object(
UI_GALLERY,
"stop_gallery",
side_effect=lambda process: order.append("stop"),
) as stop,
):
replacement = UI_GALLERY.rebuild_and_replace_gallery(current)
self.assertIs(replacement, next_gallery)
self.assertEqual(order, ["launch", "stop"])
stop.assert_called_once_with(current)
if __name__ == "__main__":
unittest.main()

366
scripts/editor/ui_gallery.py Executable file
View File

@ -0,0 +1,366 @@
#!/usr/bin/env python3
"""Build, launch, and automatically restart the lightweight Blacksite UI gallery."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import time
from typing import Any, Iterable, Sequence
ROOT = Path(__file__).resolve().parents[2]
CARGO_LANE = ROOT / "scripts/codex/cargo_lane.py"
BUILD_STORAGE = ROOT / "scripts/codex/build_storage.py"
LOG_DIR = ROOT / ".codex/logs"
BUILD_GRAPH_CACHE = LOG_DIR / "ui-gallery-build-units.json"
METADATA_LIMIT_BYTES = 32 * 1024 * 1024
POLL_SECONDS = 0.25
DEBOUNCE_SECONDS = 0.35
WATCH_ROOTS = (
ROOT / "crates/editor_ui/src",
ROOT / "crates/material_schema/src",
ROOT / "crates/ui_gallery/src",
)
WATCH_FILES = (
ROOT / "Cargo.toml",
ROOT / "Cargo.lock",
ROOT / "crates/editor_ui/Cargo.toml",
ROOT / "crates/material_schema/Cargo.toml",
ROOT / "crates/ui_gallery/Cargo.toml",
)
def managed_command(*command: str) -> list[str]:
return [sys.executable, str(CARGO_LANE), "exec", "dev", *command]
def storage_enforce(phase: str) -> int:
return subprocess.run(
[sys.executable, str(BUILD_STORAGE), "enforce", "--phase", phase],
cwd=ROOT,
check=False,
).returncode
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 bounded_command_output(command: Sequence[str], limit: int) -> bytes:
"""Capture a known-small machine document without ever buffering unbounded stdout."""
with tempfile.TemporaryFile() as output, tempfile.TemporaryFile() as errors:
with subprocess.Popen(
command,
cwd=ROOT,
stdout=subprocess.PIPE,
stderr=errors,
) as process:
assert process.stdout is not None
size = 0
while chunk := process.stdout.read(64 * 1024):
size += len(chunk)
if size > limit:
process.kill()
process.wait()
raise RuntimeError(
f"machine output exceeded the {limit // (1024 * 1024)} MiB safety cap"
)
output.write(chunk)
return_code = process.wait()
if return_code != 0:
errors.seek(0)
detail = errors.read()[-4000:].decode("utf-8", errors="replace")
raise RuntimeError(f"command failed ({return_code}): {detail}")
output.seek(0)
return output.read()
def build_graph_key() -> str:
digest = hashlib.sha256()
for path in WATCH_FILES:
if path.is_file():
digest.update(path.relative_to(ROOT).as_posix().encode("utf-8"))
digest.update(path.read_bytes())
digest.update(host_target().encode("utf-8"))
return digest.hexdigest()
def cached_build_graph(key: str) -> set[str] | None:
try:
document = json.loads(BUILD_GRAPH_CACHE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
package_ids = document.get("package_ids")
if document.get("key") != key or not isinstance(package_ids, list):
return None
if not all(isinstance(package_id, str) for package_id in package_ids):
return None
return set(package_ids)
def save_build_graph(key: str, package_ids: set[str]) -> None:
BUILD_GRAPH_CACHE.parent.mkdir(parents=True, exist_ok=True)
BUILD_GRAPH_CACHE.write_text(
json.dumps(
{"key": key, "package_ids": sorted(package_ids)},
indent=2,
)
+ "\n",
encoding="utf-8",
)
def compiler_artifact(message: object) -> tuple[str, str, bool] | None:
if not isinstance(message, dict) or message.get("reason") != "compiler-artifact":
return None
package_id = message.get("package_id")
if not isinstance(package_id, str):
return None
target = message.get("target")
if isinstance(target, dict) and isinstance(target.get("name"), str):
target_name = target["name"]
else:
target_name = package_id.rsplit("#", 1)[-1]
return package_id, target_name, bool(message.get("fresh", False))
def diagnostic_lines(message: object, raw_line: str) -> Iterable[str]:
if not isinstance(message, dict):
yield raw_line
return
if message.get("reason") == "compiler-message":
diagnostic = message.get("message")
if isinstance(diagnostic, dict):
rendered = diagnostic.get("rendered")
if isinstance(rendered, str) and rendered:
yield rendered.rstrip("\n")
def build_gallery() -> int:
graph_key = build_graph_key()
graph = cached_build_graph(graph_key)
total = len(graph) if graph else None
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_path = LOG_DIR / "ui-gallery-build.log"
command = managed_command(
"cargo",
"build",
"-p",
"ui_gallery",
"--message-format=json-render-diagnostics",
"--color",
"never",
)
process = subprocess.Popen(
command,
cwd=ROOT,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
)
assert process.stdout is not None
completed: set[str] = set()
rebuilt: set[str] = set()
last_progress_bucket = -1
with log_path.open("w", encoding="utf-8") as log:
for raw_line in process.stdout:
stripped = raw_line.rstrip("\n")
try:
message: object = json.loads(stripped)
except json.JSONDecodeError:
message = stripped
for diagnostic in diagnostic_lines(message, stripped):
log.write(diagnostic + "\n")
log.flush()
artifact = compiler_artifact(message)
if artifact is None:
continue
package_id, target_name, fresh = artifact
if not fresh:
rebuilt.add(package_id)
if package_id in completed:
continue
completed.add(package_id)
if graph is None:
in_graph = len(completed)
if in_graph != 1 and in_graph % 5 != 0:
continue
counter = f"{in_graph}/? discovering · {len(rebuilt)} rebuilt"
else:
in_graph = len(completed.intersection(graph))
percent = min(100, (in_graph * 100) // max(1, total))
progress_bucket = percent // 5
if progress_bucket == last_progress_bucket and in_graph != total:
continue
last_progress_bucket = progress_bucket
counter = f"{min(in_graph, total)}/{total} resolved · {len(rebuilt)} rebuilt"
print(
f"\rResolving ui_gallery ({counter}) {target_name:<36}",
end="",
flush=True,
)
return_code = process.wait()
print()
if return_code == 0:
save_build_graph(graph_key, completed)
expected = len(graph) if graph else len(completed)
resolved = len(completed.intersection(graph)) if graph else len(completed)
print(
f"ui_gallery ready ({resolved}/{expected} resolved · "
f"{len(rebuilt)} rebuilt)"
)
else:
print(f"ui_gallery build failed; see {log_path}", file=sys.stderr)
return return_code
def lane_runtime_environment() -> dict[str, str]:
completed = subprocess.run(
[sys.executable, str(CARGO_LANE), "env", "--json", "dev"],
cwd=ROOT,
text=True,
stdout=subprocess.PIPE,
check=True,
)
document = json.loads(completed.stdout)
environment = os.environ.copy()
environment.update(document.get("environment", {}))
runtime_deps = document.get("runtime_deps")
if isinstance(runtime_deps, str) and runtime_deps:
previous = environment.get("LD_LIBRARY_PATH")
environment["LD_LIBRARY_PATH"] = (
runtime_deps if not previous else runtime_deps + os.pathsep + previous
)
return environment
def gallery_binary() -> Path:
completed = subprocess.run(
[sys.executable, str(CARGO_LANE), "env", "--json", "dev"],
cwd=ROOT,
text=True,
stdout=subprocess.PIPE,
check=True,
)
document = json.loads(completed.stdout)
return Path(document["target_dir"]) / "debug/ui_gallery"
def launch_gallery() -> subprocess.Popen[bytes]:
binary = gallery_binary()
if not binary.is_file():
raise RuntimeError(f"gallery binary is missing after build: {binary}")
return subprocess.Popen([str(binary)], cwd=ROOT, env=lane_runtime_environment())
def stop_gallery(process: subprocess.Popen[bytes] | None) -> None:
if process is None or process.poll() is not None:
return
process.send_signal(signal.SIGTERM)
try:
process.wait(timeout=3.0)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
def rebuild_and_replace_gallery(
current: subprocess.Popen[bytes] | None,
) -> subprocess.Popen[bytes] | None:
"""Keep the last-good gallery alive until its replacement has built and launched."""
if build_gallery() != 0:
return current
replacement = launch_gallery()
stop_gallery(current)
return replacement
def source_snapshot() -> dict[Path, int]:
files = list(WATCH_FILES)
for root in WATCH_ROOTS:
if root.is_dir():
files.extend(root.rglob("*.rs"))
return {
path: path.stat().st_mtime_ns
for path in files
if path.is_file()
}
def watch() -> int:
if storage_enforce("pre") != 0:
return 1
child: subprocess.Popen[bytes] | None = None
try:
if build_gallery() != 0:
return 1
child = launch_gallery()
print("Watching material_schema, editor_ui, and ui_gallery Rust sources (Ctrl+C to stop)")
snapshot = source_snapshot()
changed_at: float | None = None
while True:
time.sleep(POLL_SECONDS)
current = source_snapshot()
if current != snapshot:
snapshot = current
changed_at = time.monotonic()
if changed_at is None or time.monotonic() - changed_at < DEBOUNCE_SECONDS:
continue
changed_at = None
child = rebuild_and_replace_gallery(child)
except KeyboardInterrupt:
print("\nStopping ui_gallery")
return 0
finally:
stop_gallery(child)
storage_enforce("post")
def run_once(*, build_only: bool) -> int:
if storage_enforce("pre") != 0:
return 1
try:
result = build_gallery()
if result != 0 or build_only:
return result
return launch_gallery().wait()
finally:
storage_enforce("post")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"command",
choices=("watch", "run", "build"),
nargs="?",
default="watch",
)
arguments = parser.parse_args()
if arguments.command == "watch":
return watch()
return run_once(build_only=arguments.command == "build")
if __name__ == "__main__":
raise SystemExit(main())