summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-13 22:50:59 +0900
committerAdam Malczewski <[email protected]>2026-06-13 22:50:59 +0900
commit90bd62fac2217f975c1e7ba5224111d9c069f1fc (patch)
tree4214998643a79b44cd03328ad8de6b6eac142ff8
parent6b67ae6ac1b8d0d272ddb50e6ef10d08f4fd6628 (diff)
downloadunbox-90bd62fac2217f975c1e7ba5224111d9c069f1fc.tar.gz
unbox-90bd62fac2217f975c1e7ba5224111d9c069f1fc.zip
ext-stage-dock + build: externalize the dock document to assets/ (hot-reloadable)
- The dock's inline kDockRml C++ string is gone; the document now lives in assets/ext-stage-dock/dock.rml (structure) + dock.rcss (styles), loaded via UiSurfaceSpec::rml_path = "ext-stage-dock/dock.rml". The bind_list*/event setup in activate() is unchanged (the substrate re-applies it across hot-reloads). - Build wiring (top-level meson.build): install_subdir the top-level assets/ tree to <datadir>/unbox/<unit>/, and -DUNBOX_ASSET_DIR_DEFAULT=<prefix>/<datadir>/unbox so an installed unbox finds its assets with no env. Dev runs set UNBOX_ASSET_DIR=<repo>/assets + UNBOX_DEV=1 to read the source tree and arm the hot-reload watcher. Real-seat verified: editing dock.rcss (border-radius 10dp<->70dp) updates the live dock with NO recompile and NO restart. ext-stage-dock 2/2 green on build + build-asan. Design iteration on the dock is now edit-a-file.
-rw-r--r--assets/ext-stage-dock/dock.rcss67
-rw-r--r--assets/ext-stage-dock/dock.rml11
-rw-r--r--meson.build10
-rw-r--r--packages/ext-stage-dock/src/extension.cpp205
4 files changed, 112 insertions, 181 deletions
diff --git a/assets/ext-stage-dock/dock.rcss b/assets/ext-stage-dock/dock.rcss
new file mode 100644
index 0000000..51d3013
--- /dev/null
+++ b/assets/ext-stage-dock/dock.rcss
@@ -0,0 +1,67 @@
+body.dock {
+ background-color: transparent;
+ padding: 8dp;
+ font-family: Noto Sans;
+ transform: translateX(-100%);
+ transition: transform 0.18s cubic-in-out;
+}
+body.dock.open {
+ transform: translateX(0px);
+}
+@keyframes slot-enter {
+ from {
+ opacity: 0;
+ transform: translateX(-12dp) scale(0.72);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0px) scale(1.0);
+ }
+}
+div.slot {
+ display: block;
+ position: relative;
+ width: 224dp;
+ height: 140dp;
+ min-height: 140dp;
+ margin-bottom: 8dp;
+ background-color: #2e2e32ff;
+ border-radius: 10dp;
+ overflow: hidden;
+ transform-origin: 0% 0%;
+ animation: slot-enter 0.16s cubic-out 1 normal;
+}
+div.slot div.thumb {
+ /* OVERSCAN the slot by 2dp on every side: RmlUi resolves the inset/percent
+ box anchored top-left, leaving the thumb ~2px short on the RIGHT so the
+ #2e2e32 slot placeholder peeked through there (diagnosed real-seat: the
+ sliver sampled as the placeholder, not the thumb). A negative inset makes
+ the thumb a few dp LARGER than the slot; the slot's rounded overflow:hidden
+ clips the overscan to the rounded card, so every edge is fully covered with
+ no placeholder peek and the corners stay rounded. cover/center keeps the
+ slightly-larger box fully covered + centered. */
+ display: block;
+ position: absolute;
+ left: -2dp;
+ top: -2dp;
+ right: -2dp;
+ bottom: -2dp;
+}
+div.slot span.title {
+ /* Title overlay INTENTIONALLY NOT RENDERED (user decision): the card is
+ thumbnail-only for now. display:none keeps the {{ row.title }} binding +
+ the bind_list_string("slots","title", …) getter LIVE and compiling so the
+ overlay can be re-enabled later by restoring `display: block`. The scrim
+ squared off the thumbnail's rounded bottom corners + protruded on the
+ right; hiding it lets all four corners round cleanly. */
+ display: none;
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ padding: 4dp 6dp;
+ background-color: #00000099;
+ color: #f2f2f2ff;
+ font-size: 13dp;
+ text-align: center;
+}
diff --git a/assets/ext-stage-dock/dock.rml b/assets/ext-stage-dock/dock.rml
new file mode 100644
index 0000000..512e16c
--- /dev/null
+++ b/assets/ext-stage-dock/dock.rml
@@ -0,0 +1,11 @@
+<rml>
+<head>
+<link type="text/rcss" href="dock.rcss"/>
+</head>
+<body data-model="ui" class="dock" data-class-open="open" data-event-transitionend="dock_settled()">
+<div data-for="row : slots" class="slot" data-event-click="restore(it_index)">
+<div class="thumb" data-style-decorator="'image( ' + row.preview + ' cover center center )'"/>
+<span class="title">{{ row.title }}</span>
+</div>
+</body>
+</rml>
diff --git a/meson.build b/meson.build
index 3d7fb59..6237ad7 100644
--- a/meson.build
+++ b/meson.build
@@ -18,6 +18,16 @@ wlroots_dep = dependency('wlroots-0.20')
wayland_server_dep = dependency('wayland-server')
xkbcommon_dep = dependency('xkbcommon')
+# UI assets (RML/RCSS documents) live under assets/<unit>/ and install to
+# <datadir>/unbox/<unit>/…. The substrate resolves a RELATIVE UiSurfaceSpec::
+# rml_path against $UNBOX_ASSET_DIR (dev: the source assets/ tree) or, when that
+# env is unset, this compiled-in default (production install). The dev launch also
+# sets UNBOX_DEV=1 to arm the inotify hot-reload watcher. Must precede the unit
+# subdir() calls so the kernel compiles with the define.
+unbox_asset_dir = get_option('prefix') / get_option('datadir') / 'unbox'
+add_project_arguments('-DUNBOX_ASSET_DIR_DEFAULT="' + unbox_asset_dir + '"', language: 'cpp')
+install_subdir('assets', install_dir: get_option('datadir') / 'unbox', strip_directory: true)
+
# RMLUi: CMake-only upstream, vendored as a wrap-file tarball (NO git
# submodules — settled decision, notes/plan.md §2) built via the cmake module.
cmake = import('cmake')
diff --git a/packages/ext-stage-dock/src/extension.cpp b/packages/ext-stage-dock/src/extension.cpp
index caac456..b859a04 100644
--- a/packages/ext-stage-dock/src/extension.cpp
+++ b/packages/ext-stage-dock/src/extension.cpp
@@ -96,187 +96,28 @@ struct Slot {
std::string app_id; // copied; for // TODO favicon
};
-// Inline RML for the dock document. A vertical stack of preview cards, dark/
-// rounded like the Stage-Manager reference. d1 ADDS the RCSS animation on top of
-// c2's static document — without touching the data model (same "slots" list,
-// same per-row preview/title/restore bindings). data-model "ui" (the substrate
-// default).
+// The dock document now lives in EXTERNAL ASSET FILES (loaded via
+// UiSurfaceSpec::rml_path so design changes need no recompile + dev hot-reload):
+// assets/ext-stage-dock/dock.rml — the RML STRUCTURE (data-model "ui",
+// data-for="row : slots", the rounded overflow:hidden div.slot, the
+// full-bleed div.thumb with data-style-decorator image(...), the display:none
+// title + its {{ row.title }} binding, data-event-click restore, the d1
+// transform/translateX reveal body). It links the styles via
+// <link type="text/rcss" href="dock.rcss"/> (RmlUi resolves href relative to
+// the document's own dir, which the kernel asset root sets up).
+// assets/ext-stage-dock/dock.rcss — ALL the RCSS (body.dock, div.slot,
+// div.thumb, span.title, @keyframes slot-enter, …).
+// The C++ binding setup (bind_list*/bind_string/bind_event in create_dock_surface)
+// is UNCHANGED — the substrate re-applies the bindings across hot-reloads.
//
-// TRANSPARENT STRIP (per-pixel alpha). The substrate composites this surface
-// with per-pixel alpha (ui.hpp UiSurface §PER-PIXEL ALPHA): any pixel body.dock
-// does not paint is fully transparent and the windows BELOW show through. So
-// `body.dock` paints NO background (`background-color: transparent`) — only the
-// `div.slot` CARDS paint, reading as cards floating over the window with the
-// empty strip see-through. The card corners are ROUNDED (border-radius), so the
-// pixels OUTSIDE the rounded corners are unpainted and show the window through —
-// that is correct/intended. NOTE the substrate still consumes pointer/touch over
-// the whole surface RECT regardless of visual transparency (slice-5 consumption
-// model), so the surface is sized to HUG the card stack (see content_height in
-// create/refresh) — the rest of the screen stays interactive. A real
-// input-transparent strip needs a deferred UiSurfaceSpec flag (report
-// change-req).
-//
-// CARD = PREVIEW (the card IS the image). The card structure is a ROUNDED CLIP
-// CONTAINER holding a FULL-BLEED preview child plus a title overlay:
-//
-// <div class="slot"> -- rounded clip box
-// <div class="thumb" data-style-decorator="..."/> -- full-bleed preview
-// <span class="title">{{ row.title }}</span> -- bottom scrim overlay
-// </div>
-//
-// WHY a child carries the decorator (not div.slot itself): an element's OWN
-// image() decorator is NOT clipped to its OWN border-radius — a background-color
-// rounds via geometry, but a decorator needs the clip MASK, which an element's
-// self-render never sets (RmlUi-core behaviour, confirmed by the kernel owner's
-// substrate-renderer investigation: its scissor+stencil clip is correct and
-// DOES clip a CHILD's image() decorator to a parent's rounded overflow:hidden
-// corners — even with a transform and across set_size). So `div.slot` is the
-// rounded clip container (`border-radius: 10dp; overflow: hidden;`) and the
-// preview rides on a full-bleed child `div.thumb` (position:absolute, all four
-// insets 0) whose decorator content is clipped to the rounded corners by the
-// parent. Putting the decorator on the rounded slot directly produced SQUARE
-// corners — that was the bug.
-//
-// The decorator is bound per row via the "style" data view (RmlUi 6.2 registers
-// the "style" data view, Factory.cpp:242; same runtime-property-string pattern
-// as the vendored data-style-transform samples):
-// data-style-decorator="'image( ' + row.preview + ' cover center center )'"
-// SHORTHAND ORDER (verified in vendored DecoratorTiled.cpp:220-251,
-// RegisterTileProperty("image", true)): the `image` shorthand is FallThrough
-// over image-src, image-orientation, image-fit, image-align-x, image-align-y.
-// FallThrough is not strictly positional — a value that fails one property's
-// parser falls through to the next (PropertySpecification.cpp:384-398). So after
-// the URI (image-src, "string" parser), `cover` fails image-orientation
-// (keywords none|flip-*|rotate-180) and falls through to image-fit (keywords
-// fill|contain|cover|scale-none|scale-down|repeat*) -> cover; `center` ->
-// image-align-x (left|center|right); `center` -> image-align-y (top|center|
-// bottom). Net: COVER fit, CENTER/CENTER align — the preview fills the whole
-// card centered, cropping overflow, undistorted for any source aspect. The
-// `unbox-preview://N` URI resolves the SAME imported texture an <img src> would:
-// both Decorator and ElementImage go through RenderManager::LoadTexture (vendored
-// Decorator.cpp:61-69 + ElementImage.cpp:251).
-//
-// EMPTY / not-yet-previewed slot: `div.slot { background-color: #2e2e32ff; }` is
-// a dark PLACEHOLDER fill (a background-color DOES round by geometry, so it stays
-// rounded). A slot can exist before its Preview texture is ready (or on a no-GL
-// backend) — then row.preview is "" and `image( cover center center )` fails to
-// instance a decorator on the thumb child (returns nullptr, no crash), leaving
-// the rounded dark fill visible so the card is never invisible. The fixed box
-// keeps it min-height/tappable; data-event-click="restore(it_index)" on the slot
-// is unchanged.
-//
-// TITLE OVERLAY: the title sits ON the preview as the LAST child (drawn above the
-// thumb), `position:absolute` pinned to the card bottom (left:0/right:0/bottom:0,
-// width auto) with a translucent dark scrim (`background-color: #00000099`)
-// behind the text so it stays legible over any preview; the parent's
-// overflow:hidden + border-radius clips the scrim's bottom corners to match the
-// card. The title is TEXT, so {{ row.title }} is correct there.
-//
-// d1 animation (RCSS, RMLUi 6.2; verified against the vendored source):
-//
-// 1. DOCK REVEAL SLIDE. body.dock starts translated fully off the left edge
-// (transform: translateX(-100%)); adding the `open` class translates it back
-// to 0. A `transition: transform 180ms ...` on body.dock makes that flip
-// SLIDE rather than jump. The `open` class is driven by the bound bool
-// `open` via data-class-open (RmlUi DataViewClass). The glue makes the
-// surface visible BEFORE setting open=true (slide-in), and on close keeps it
-// visible until the slide-OUT finishes — sequenced off the body's
-// `transitionend`, routed to bind_event("dock_settled") (the existing event
-// binding carries RmlUi's transitionend; no kernel change — see report).
-//
-// 2. PER-SLOT ENTER/SETTLE. Each freshly created `div.slot` plays the
-// `slot-enter` @keyframes ONCE on creation (animation: ... 1 normal): it
-// starts smaller + transparent + nudged up-left ("scaling down into a spot")
-// and settles to full size/opacity in place. RmlUi runs the animation when
-// the element is instanced, which is exactly when dirty("slots") grows the
-// list — so a new minimize animates its card in with no extra binding.
-//
-// transform-origin keeps the slot scaling toward its own top-left so the grow
-// reads as "into the dock", within what a left-strip surface can convey (the
-// literal cross-screen flight needs an input-transparent overlay — report
-// change-req). RmlUi 6.2's `transform-origin` is an X-then-Y-then-Z shorthand
-// whose X axis takes only {left,center,right} (or length/percent) — so the
-// CSS-style `top left` is a parse error (top is not a valid X keyword). We use
-// the unambiguous percentage form `0% 0%` (= top-left); `left top` also parses.
-constexpr const char* kDockRml = R"RML(<rml>
-<head>
-<style>
-body.dock {
- background-color: transparent;
- padding: 8dp;
- font-family: Noto Sans;
- transform: translateX(-100%);
- transition: transform 0.18s cubic-in-out;
-}
-body.dock.open {
- transform: translateX(0px);
-}
-@keyframes slot-enter {
- from {
- opacity: 0;
- transform: translateX(-12dp) scale(0.72);
- }
- to {
- opacity: 1;
- transform: translateX(0px) scale(1.0);
- }
-}
-div.slot {
- display: block;
- position: relative;
- width: 224dp;
- height: 140dp;
- min-height: 140dp;
- margin-bottom: 8dp;
- background-color: #2e2e32ff;
- border-radius: 10dp;
- overflow: hidden;
- transform-origin: 0% 0%;
- animation: slot-enter 0.16s cubic-out 1 normal;
-}
-div.slot div.thumb {
- /* OVERSCAN the slot by 2dp on every side: RmlUi resolves the inset/percent
- box anchored top-left, leaving the thumb ~2px short on the RIGHT so the
- #2e2e32 slot placeholder peeked through there (diagnosed real-seat: the
- sliver sampled as the placeholder, not the thumb). A negative inset makes
- the thumb a few dp LARGER than the slot; the slot's rounded overflow:hidden
- clips the overscan to the rounded card, so every edge is fully covered with
- no placeholder peek and the corners stay rounded. cover/center keeps the
- slightly-larger box fully covered + centered. */
- display: block;
- position: absolute;
- left: -2dp;
- top: -2dp;
- right: -2dp;
- bottom: -2dp;
-}
-div.slot span.title {
- /* Title overlay INTENTIONALLY NOT RENDERED (user decision): the card is
- thumbnail-only for now. display:none keeps the {{ row.title }} binding +
- the bind_list_string("slots","title", …) getter LIVE and compiling so the
- overlay can be re-enabled later by restoring `display: block`. The scrim
- squared off the thumbnail's rounded bottom corners + protruded on the
- right; hiding it lets all four corners round cleanly. */
- display: none;
- position: absolute;
- left: 0;
- right: 0;
- bottom: 0;
- padding: 4dp 6dp;
- background-color: #00000099;
- color: #f2f2f2ff;
- font-size: 13dp;
- text-align: center;
-}
-</style>
-</head>
-<body data-model="ui" class="dock" data-class-open="open" data-event-transitionend="dock_settled()">
-<div data-for="row : slots" class="slot" data-event-click="restore(it_index)">
-<div class="thumb" data-style-decorator="'image( ' + row.preview + ' cover center center )'"/>
-<span class="title">{{ row.title }}</span>
-</div>
-</body>
-</rml>)RML";
+// The design rationale that was inlined here is preserved in the asset files'
+// own comments + the report (transparent per-pixel-alpha strip; the card is a
+// rounded overflow:hidden clip container with a full-bleed child carrying the
+// image() decorator — an element's OWN decorator is not clipped to its OWN
+// border-radius, so the preview rides on a child; image( <uri> cover center
+// center ) fit/align verified against vendored DecoratorTiled.cpp:220-251; the
+// -2dp thumb overscan clipped by the rounded overflow; d1 slot-enter animation;
+// transform-origin 0% 0%).
class StageDockExtension final : public kernel::Extension, public TestProbe {
public:
@@ -537,7 +378,9 @@ private:
const layout::Box frame = layout::dock_box(m, 1.0); // fully revealed (c2)
kernel::UiSurfaceSpec spec;
- spec.rml_inline = kDockRml;
+ // External asset (RELATIVE to the asset root the orchestrator wires) so
+ // the dock document is editable without recompiling + dev hot-reloads.
+ spec.rml_path = "ext-stage-dock/dock.rml";
spec.model = "ui";
spec.x = frame.x;
spec.y = frame.y;