summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-15 18:43:40 +0900
committerAdam Malczewski <[email protected]>2026-06-15 18:43:40 +0900
commitdb9753e6f42e43c08c43b848bc865aae97aa1655 (patch)
treecd8cc5803987def7a4e4cf78baabfd3ed835cf1e
parentaeeda3135ddb4f1b633ccf2e46c4832b35888c7b (diff)
downloadunbox-db9753e6f42e43c08c43b848bc865aae97aa1655.tar.gz
unbox-db9753e6f42e43c08c43b848bc865aae97aa1655.zip
ext-window-field(rml-compositing): floating windows + resize-to-tile
Floating windows: move (titlebar), resize (two bottom corner grips), and a per- window close button. Per-window geometry (x,y,w,h,z) is bound STATE applied via data-style left/top/width/height + z-index; a pure, doctested geometry core (move / resize_bl / resize_br with anchored-opposite-edge + min-size, field clamp) computes drag results. Positioning uses left/top (NOT a transform: RmlUi's data-style does not apply a transform here the way it does left/top/width/height, and a transform would also offset the drag hit-test). z-order raise on focus; cascade placement from the client's own size. Resize-to-tile: a frame-pumped feedback loop configures each client to its on- screen box (Toplevel::set_size) so the live texture maps 1:1. Policy is config- driven -- [window-field] resize_mode = off|settle|continuous|debounced in unbox.toml, hot-reloaded; pure doctested config core. Windows are shown via <img data-attr-src> (NOT an RCSS image() decorator) so the kernel's surface-element input-back, click-to-focus, popup placement and box readback -- all keyed off the <img> src -- work. host-bin passes the config path + --rml-compositing (the latter selecting server- side decorations) through to the extensions. tasks.md records the wave.
-rw-r--r--.gitignore3
-rw-r--r--assets/ext-window-field/field.rcss150
-rw-r--r--assets/ext-window-field/field.rml65
-rw-r--r--packages/ext-window-field/include/unbox/ext-window-field/ext_window_field.hpp12
-rw-r--r--packages/ext-window-field/meson.build60
-rw-r--r--packages/ext-window-field/src/config.cpp97
-rw-r--r--packages/ext-window-field/src/config.hpp62
-rw-r--r--packages/ext-window-field/src/extension.cpp370
-rw-r--r--packages/ext-window-field/src/geometry.cpp45
-rw-r--r--packages/ext-window-field/src/geometry.hpp56
-rw-r--r--packages/ext-window-field/tests/test_config.cpp97
-rw-r--r--packages/ext-window-field/tests/test_geometry.cpp73
-rw-r--r--packages/host-bin/src/main.cpp7
-rw-r--r--tasks.md44
-rw-r--r--unbox.toml23
15 files changed, 1068 insertions, 96 deletions
diff --git a/.gitignore b/.gitignore
index 72b9759..bd2a541 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,6 @@ subprojects/*
# local spike launcher (run-spike.sh)
/run-spike.sh
+
+# captured unbox stderr from the rml-* run-spike modes (local debug log)
+/unbox-rml.log
diff --git a/assets/ext-window-field/field.rcss b/assets/ext-window-field/field.rcss
index 5e7d5e6..e237ae6 100644
--- a/assets/ext-window-field/field.rcss
+++ b/assets/ext-window-field/field.rcss
@@ -1,79 +1,113 @@
-/* The WINDOW FIELD layout (GLOSSARY: "window field"). RML compositing Wave 3.
- LAYOUT + ANIMATION live ENTIRELY here (the user's contract decision): the C++
- glue only feeds the bound "wins" list + the per-row `focused` flag; this RCSS
- decides every window's on-screen box and animates changes. Real tiling /
- stage-manager is a LATER refinement on this same field + list — Wave 3 keeps
- it SIMPLE but clearly working.
+/* The WINDOW FIELD layout (GLOSSARY: "window field") — FLOATING windows. The C++
+ glue feeds each row's box as bound state (x,y,w,h,z) + the focused flag; this
+ stylesheet turns that into the on-screen frame, chrome, and highlight. There is
+ NO automatic layout here (that was the earlier focused-dominates tiling): every
+ window is absolutely positioned, moved by a data-style translate and sized by
+ data-style width/height. Tiled / sidebar containers are a LATER wave (windows
+ will migrate between containers with captured-state animations). */
- THE LAYOUT (simple, N-window-safe with no per-index data): the field is a
- flex row that wraps; every window is a flex tile sharing the space. The
- FOCUSED window grows to dominate the field (a large flex-basis) and is
- highlighted + lifted; the rest stay a row of smaller equal tiles. Because it
- is pure flex keyed only on the `.focused` class, it works for any window
- count and needs no geometry from C++. A `transition` on flex/transform/border
- animates focus + map/unmap changes (RmlUi tweens the class flip). */
-
-/* The field fills its whole surface (sized to the output by the glue). It is a
- wrapping flex row with a little gutter; transparent so an unpainted gap shows
- the scene below (per-pixel alpha). */
+/* The field fills its whole surface (sized to the output by the glue) and is
+ transparent so the scene below shows through every unpainted pixel (per-pixel
+ alpha). Absolutely-positioned .win children stack within it. */
body.field {
width: 100%;
height: 100%;
- display: flex;
- flex-direction: row;
- flex-wrap: wrap;
- align-items: stretch;
- justify-content: center;
- padding: 6dp;
background-color: transparent;
+ font-family: Noto Sans;
}
-/* A window tile. Default (unfocused): an equal, smaller share of the row. The
- transition animates focus changes (flex-basis grow/shrink, the lift transform,
- the highlight border) AND the layout reflow when a window maps/unmaps. RmlUi
- 6.2 has no bare `linear` keyword, so the carrier ease is cubic-in-out. */
+/* A floating window frame. Absolutely positioned; data-style left/top place it,
+ width/height size it, z-index stacks it (all driven by the bound box state).
+ Column flex so the titlebar sits above the live body with no calc(). Rounded +
+ overflow:hidden clips the body texture to the card; a 1dp border becomes the
+ bright focus ring via .focused. */
div.win {
- display: block;
- position: relative;
- flex: 1 1 30%;
- min-width: 20%;
- height: 46%;
- margin: 6dp;
+ position: absolute;
+ left: 0;
+ top: 0;
+ display: flex;
+ flex-direction: column;
background-color: #1c1c20ff;
+ border-width: 1dp;
+ border-color: #00000088;
border-radius: 8dp;
- border-width: 2dp;
- border-color: #00000000;
overflow: hidden;
- transform: scale(0.96);
- transform-origin: 50% 50%;
- transition: flex-basis 0.22s cubic-in-out,
- width 0.22s cubic-in-out,
- height 0.22s cubic-in-out,
- transform 0.22s cubic-in-out,
- border-color 0.22s cubic-in-out;
}
-/* The FOCUSED window DOMINATES the field: a large flex-basis so it takes the
- bulk of the row (the rest wrap below as the smaller tile row), full height, a
- bright highlight border, and a subtle lift (scale 1.0) — the "raise". This is
- the only state C++ drives (the `focused` bool); everything else is geometry
- RCSS owns. */
+/* The FOCUSED window gets a bright highlight ring (z-order is driven by the bound
+ `z`, so no transform/raise needed here). A short transition tweens the ring. */
div.win.focused {
- flex: 1 1 100%;
- height: 70%;
border-color: #4da3ffff;
- transform: scale(1.0);
}
-/* The live window texture fills its tile (the surface element's live_uri, painted
- via an RCSS `image()` decorator bound in field.rml — the proven dynamic-texture
- pattern, since RmlUi does NOT substitute data bindings in an <img src>). The
- tile's rounded overflow:hidden clips it to the card. */
-div.win .live {
- display: block;
+/* Titlebar: the move handle (drag: drag set inline in the RML). Fixed height,
+ full width, holds the centered title. */
+div.win .titlebar {
+ height: 30dp;
+ width: 100%;
+ background-color: #26262cff;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+}
+div.win.focused .titlebar {
+ background-color: #2e3340ff;
+}
+div.win .title {
+ color: #c7c9d1ff;
+ font-size: 13dp;
+ /* leave room so a long title does not slide under the close button */
+ padding: 0 36dp 0 36dp;
+}
+
+/* Close button: a sibling laid over the titlebar's right edge (NOT inside the
+ draggable titlebar, so a tap closes rather than starting a move). The "×" glyph
+ renders in Noto Sans (loaded by the substrate). */
+div.win .closebtn {
position: absolute;
- left: 0;
top: 0;
+ right: 0;
+ width: 30dp;
+ height: 30dp;
+ color: #c7c9d1ff;
+ font-size: 18dp;
+ text-align: center;
+ background-color: transparent;
+}
+div.win .closebtn:hover {
+ background-color: #e0414aff;
+ color: #ffffffff;
+}
+
+/* The live window texture fills the body below the titlebar (flex:1). RmlUi
+ scales the texture to this box; once the client is configured to the box size
+ (resize_mode, default "settle") it maps 1:1. */
+img.live {
+ display: block;
+ flex: 1 1 0;
width: 100%;
- height: 100%;
+ min-height: 0;
+}
+
+/* The two BOTTOM resize grips, overlaid at the bottom corners (drag: drag set in
+ the RML). Small, subtle, slightly brighter on hover; touch can grab the 22dp
+ square. The top corners have no grip (the titlebar owns the top edge). */
+div.win .grip {
+ position: absolute;
+ bottom: 0;
+ width: 22dp;
+ height: 22dp;
+ background-color: #ffffff14;
+}
+div.win .grip:hover {
+ background-color: #ffffff3c;
+}
+div.win .grip-bl {
+ left: 0;
+ border-top-right-radius: 8dp;
+}
+div.win .grip-br {
+ right: 0;
+ border-top-left-radius: 8dp;
}
diff --git a/assets/ext-window-field/field.rml b/assets/ext-window-field/field.rml
index 7fc2d95..7e8a106 100644
--- a/assets/ext-window-field/field.rml
+++ b/assets/ext-window-field/field.rml
@@ -2,24 +2,57 @@
<head>
<link type="text/rcss" href="field.rcss"/>
</head>
-<!-- The WINDOW FIELD document (GLOSSARY: "window field"). data-model "ui". The
- C++ glue (ext-window-field) drives ONLY the data: the bound "wins" list (one
- row per mapped toplevel, each a live SURFACE ELEMENT) + the per-row `focused`
- flag. LAYOUT + ANIMATION are RCSS (field.rcss) — the user's contract
- decision: C++ never computes a window's on-screen rectangle.
+<!-- The WINDOW FIELD document (GLOSSARY: "window field"). data-model "ui".
+ FLOATING windows: the C++ glue (ext-window-field) holds each window's box as
+ bound STATE (x,y,w,h,z) updated by drag gestures, and this document APPLIES
+ it as data-style (the contract: C++ owns interactive geometry STATE, RCSS
+ owns rendering/animation). One row per mapped toplevel:
- Each row paints the window's LIVE texture via an RCSS `image()` decorator
- bound with data-style-decorator (the proven dynamic-texture pattern — RmlUi
- does NOT substitute a data binding inside an <img src>, so the URI must reach
- RCSS, not the src attribute). live_uri = SurfaceElement::source_uri() from
- UiSubstrate::create_surface_element. `contain` shows the whole window
- (letterboxed) rather than cropping. The kernel manages the toplevel's whole
- subsurface/popup tree behind that single element; the document addresses only
- the root. data-class-focused keys the RCSS highlight/raise off the focused
- window. -->
+ .win — the floating frame: positioned by data-style left/top, sized
+ by data-style width/height, stacked by data-style z-index;
+ data-class-focused keys the highlight border. (left/top, NOT a
+ transform: a transform is not applied by RmlUi's data-style here
+ the way left/top/width/height are, and it would also offset the
+ drag hit-test — left/top keeps drag coords in honest surface
+ space.)
+ .titlebar — drag to MOVE (data-event-drag* -> dmove(it_index)); a tap
+ (no travel) raises/focuses (data-event-click -> raise). Holds
+ the window title.
+ .closebtn — a sibling over the titlebar's right (NOT inside the draggable
+ titlebar, so a tap closes instead of starting a move);
+ data-event-click -> close(it_index) -> Toplevel::close().
+ img.live — the live window texture, src data-bound to the surface
+ element's source_uri() (data-attr-src; the <img> is what the
+ kernel keys input-back / popup placement / size readback off).
+ .grip-bl / .grip-br — the two BOTTOM resize grips (drag -> dbl/dbr); the
+ top edge is fixed (the titlebar lives there).
+
+ it_index is the row index RmlUi supplies; the glue resolves it to a stable
+ Toplevel identity on the drag start phase. -->
<body data-model="ui" class="field">
-<div data-for="w : wins" class="win" data-class-focused="w.focused">
-<div class="live" data-style-decorator="'image( ' + w.live_uri + ' contain center center )'"/>
+<div data-for="w : wins" class="win" data-class-focused="w.focused"
+ data-style-left="'' + w.x + 'px'"
+ data-style-top="'' + w.y + 'px'"
+ data-style-width="'' + w.w + 'px'"
+ data-style-height="'' + w.h + 'px'"
+ data-style-z-index="w.z">
+ <div class="titlebar" style="drag: drag;"
+ data-event-dragstart="dmove(it_index)"
+ data-event-drag="dmove(it_index)"
+ data-event-dragend="dmove(it_index)"
+ data-event-click="raise(it_index)">
+ <span class="title">{{ w.title }}</span>
+ </div>
+ <div class="closebtn" data-event-click="close(it_index)">&#215;</div>
+ <img class="live" data-attr-src="w.live_uri"/>
+ <div class="grip grip-bl" style="drag: drag;"
+ data-event-dragstart="dbl(it_index)"
+ data-event-drag="dbl(it_index)"
+ data-event-dragend="dbl(it_index)"/>
+ <div class="grip grip-br" style="drag: drag;"
+ data-event-dragstart="dbr(it_index)"
+ data-event-drag="dbr(it_index)"
+ data-event-dragend="dbr(it_index)"/>
</div>
</body>
</rml>
diff --git a/packages/ext-window-field/include/unbox/ext-window-field/ext_window_field.hpp b/packages/ext-window-field/include/unbox/ext-window-field/ext_window_field.hpp
index 732a7c2..98b22c3 100644
--- a/packages/ext-window-field/include/unbox/ext-window-field/ext_window_field.hpp
+++ b/packages/ext-window-field/include/unbox/ext-window-field/ext_window_field.hpp
@@ -3,6 +3,8 @@
#include <unbox/kernel/extension.hpp>
#include <memory>
+#include <optional>
+#include <string>
// ext-window-field — RML compositing's window manager (GLOSSARY: "window
// field"). The CORE extension that composites application toplevels as RCSS
@@ -31,6 +33,14 @@ namespace unbox::ext_window_field {
// Construct the extension. Cheap and side-effect free (per the Extension
// contract); ALL wiring happens in activate(). Ownership transfers to the
// caller (host-bin installs it into the Server).
-[[nodiscard]] auto create() -> std::unique_ptr<kernel::Extension>;
+//
+// config_path: the explicit unbox.toml path (host-bin --config). If nullopt,
+// activate() discovers $XDG_CONFIG_HOME/unbox/unbox.toml then
+// ~/.config/unbox/unbox.toml. The [window-field] table tunes how a window is
+// resized to its tile (resize_mode / resize_debounce_ms — see src/config.hpp);
+// a missing/malformed config falls back to the compiled defaults (resize_mode
+// "settle"). The effective file is watched for live hot-reload.
+[[nodiscard]] auto create(std::optional<std::string> config_path = std::nullopt)
+ -> std::unique_ptr<kernel::Extension>;
} // namespace unbox::ext_window_field
diff --git a/packages/ext-window-field/meson.build b/packages/ext-window-field/meson.build
index 1b3766d..d0ba386 100644
--- a/packages/ext-window-field/meson.build
+++ b/packages/ext-window-field/meson.build
@@ -12,13 +12,24 @@
ext_window_field_inc = include_directories('include')
-# Glue library: needs the kernel ABI (ui substrate + SurfaceElement) and
-# ext-xdg-shell's public contract (Toplevel::wl_surface(), the Service events).
+# toml++: the APPROVED config dep (notes/plan.md §2), a project-global Meson wrap
+# (subprojects/tomlplusplus.wrap). Force default_library=static so the installed
+# `unbox` binary bakes it in (the wrap's own meson.build hardcodes shared, which
+# would need libtomlplusplus.so at runtime). Same rationale as ext-keybindings.
+tomlplusplus_dep = dependency('tomlplusplus',
+ default_options: ['default_library=static'])
+
+# Glue library: needs the kernel ABI (ui substrate + SurfaceElement), ext-xdg-
+# shell's public contract (Toplevel::wl_surface()/set_size(), the Service events),
+# and toml++ (the [window-field] resize-policy loader, src/config.cpp). The policy
+# parse is a PURE core in src/config.{hpp,cpp} (toml++ only; doctest-covered).
ext_window_field_lib = static_library(
'unbox-ext-window-field',
'src/extension.cpp',
+ 'src/config.cpp',
+ 'src/geometry.cpp',
include_directories: ext_window_field_inc,
- dependencies: [kernel_dep, ext_xdg_shell_dep],
+ dependencies: [kernel_dep, ext_xdg_shell_dep, tomlplusplus_dep],
)
# What host-bin links against (the factory). kernel_dep rides through for the
@@ -30,10 +41,43 @@ ext_window_field_dep = declare_dependency(
dependencies: [kernel_dep],
)
-# Tests, asymmetric (AGENTS.md: strict cores, lenient shell). This unit is PURE
-# GLUE — its only policy (window layout) lives in RCSS, not C++, so there is no
-# doctest-able decision core; the SMOKE test (factory/manifest) + a lenient
-# HEADLESS GLUE test (below) are the whole suite.
+# Tests, asymmetric (AGENTS.md: strict cores, lenient shell). Window LAYOUT lives
+# in RCSS (no C++ decision core), but the resize-policy CONFIG parse (src/config.
+# cpp) IS a pure core and is doctest-hard below. The SMOKE test (factory/manifest)
+# + a lenient HEADLESS GLUE test round out the suite.
+
+# CONFIG core test: the pure [window-field] toml parser (defaults, every
+# resize_mode value, debounce clamping, malformed input). No kernel/wlroots; the
+# core TU compiles config.cpp directly and needs `src` on the include path
+# (a unit may read its own src/) plus toml++.
+ext_window_field_config_test = executable(
+ 'ext-window-field-config-tests',
+ 'tests/test_config.cpp',
+ 'src/config.cpp',
+ include_directories: [ext_window_field_inc, include_directories('src')],
+ dependencies: [doctest_dep, tomlplusplus_dep],
+)
+test(
+ 'ext-window-field-config',
+ ext_window_field_config_test,
+ suite: 'ext-window-field',
+)
+
+# GEOMETRY core test: the pure floating-window drag/resize math (move, the two
+# bottom resize grips with the anchored-opposite-edge rule, min-size clamp,
+# field clamp). No kernel/wlroots.
+ext_window_field_geometry_test = executable(
+ 'ext-window-field-geometry-tests',
+ 'tests/test_geometry.cpp',
+ 'src/geometry.cpp',
+ include_directories: [ext_window_field_inc, include_directories('src')],
+ dependencies: [doctest_dep],
+)
+test(
+ 'ext-window-field-geometry',
+ ext_window_field_geometry_test,
+ suite: 'ext-window-field',
+)
# SMOKE test: the factory yields the window-field extension with the right
# manifest. Cheap, no kernel/wlroots running.
@@ -98,5 +142,7 @@ test(
# Aggregate alias: `ninja -C build ext-window-field-tests`.
alias_target('ext-window-field-tests',
ext_window_field_smoke_test,
+ ext_window_field_config_test,
+ ext_window_field_geometry_test,
ext_window_field_glue_test,
)
diff --git a/packages/ext-window-field/src/config.cpp b/packages/ext-window-field/src/config.cpp
new file mode 100644
index 0000000..8067d6e
--- /dev/null
+++ b/packages/ext-window-field/src/config.cpp
@@ -0,0 +1,97 @@
+#include "config.hpp"
+
+#include <toml++/toml.hpp>
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <string_view>
+
+// Pure toml loader for the [window-field] policy. No wlroots, no kernel, no I/O
+// (the glue reads the file and hands us the text). Mirrors ext-keybindings'
+// config.cpp posture: per-key validation, defaults on absence/invalid, every
+// problem recorded as a warning, never throws.
+
+namespace unbox::ext_window_field::config {
+
+namespace {
+
+// Map a resize_mode string to the enum. nullopt for an unknown value (the caller
+// warns + keeps the default).
+auto parse_mode(std::string_view s) -> std::optional<ResizeMode> {
+ if (s == "off") {
+ return ResizeMode::off;
+ }
+ if (s == "settle") {
+ return ResizeMode::settle;
+ }
+ if (s == "continuous") {
+ return ResizeMode::continuous;
+ }
+ if (s == "debounced") {
+ return ResizeMode::debounced;
+ }
+ return std::nullopt;
+}
+
+} // namespace
+
+auto load_from_string(std::string_view toml_text) -> LoadResult {
+ LoadResult result; // policy defaults already set
+
+ toml::table tbl;
+ try {
+ tbl = toml::parse(toml_text);
+ } catch (const toml::parse_error& e) {
+ result.parse_error = true;
+ result.warnings.emplace_back(std::string("[window-field] config parse error: ") +
+ std::string(e.description()));
+ return result; // defaults stand
+ }
+
+ const toml::node* section = tbl.get("window-field");
+ if (section == nullptr) {
+ return result; // no table => defaults, not an error
+ }
+ const toml::table* wf = section->as_table();
+ if (wf == nullptr) {
+ result.warnings.emplace_back("[window-field] is not a table; using defaults");
+ return result;
+ }
+
+ // resize_mode (string enum).
+ if (const toml::node* m = wf->get("resize_mode"); m != nullptr) {
+ if (const auto* s = m->as_string()) {
+ if (const auto parsed = parse_mode(s->get())) {
+ result.policy.mode = *parsed;
+ } else {
+ result.warnings.emplace_back(
+ "[window-field] resize_mode '" + s->get() +
+ "' is not one of off|settle|continuous|debounced; using 'settle'");
+ }
+ } else {
+ result.warnings.emplace_back(
+ "[window-field] resize_mode must be a string; using 'settle'");
+ }
+ }
+
+ // resize_debounce_ms (non-negative integer).
+ if (const toml::node* d = wf->get("resize_debounce_ms"); d != nullptr) {
+ if (const auto* v = d->as_integer()) {
+ const std::int64_t ms = v->get();
+ if (ms < 0) {
+ result.warnings.emplace_back(
+ "[window-field] resize_debounce_ms must be >= 0; using 100");
+ } else {
+ result.policy.debounce_ms = static_cast<int>(ms);
+ }
+ } else {
+ result.warnings.emplace_back(
+ "[window-field] resize_debounce_ms must be an integer; using 100");
+ }
+ }
+
+ return result;
+}
+
+} // namespace unbox::ext_window_field::config
diff --git a/packages/ext-window-field/src/config.hpp b/packages/ext-window-field/src/config.hpp
new file mode 100644
index 0000000..a0182ed
--- /dev/null
+++ b/packages/ext-window-field/src/config.hpp
@@ -0,0 +1,62 @@
+#pragma once
+
+#include <string>
+#include <string_view>
+#include <vector>
+
+// Pure decision core: the window-field config loader. Parses an unbox.toml
+// document (as TEXT — file discovery/reading is an effect kept in the glue) into
+// a ResizePolicy: how the field feeds a window's RCSS-computed tile size back to
+// the client as a render resolution (ext-xdg-shell::Toplevel::set_size). toml++
+// is the only dependency; no wlroots, no kernel. Doctest-covered in
+// tests/test_config.cpp.
+//
+// SCHEMA (the [window-field] table; every key optional — absent => default):
+// [window-field]
+// resize_mode = "settle" # off | settle | continuous | debounced
+// resize_debounce_ms = 100 # used only when resize_mode = "debounced"
+//
+// resize_mode semantics (the tile size animates over an RCSS transition on every
+// focus/map/unmap, so the question is WHEN to push the size to the client — each
+// push makes the client reallocate + redraw):
+// off — never resize: the client keeps its own size, letterbox-fit into
+// the tile by the RCSS `contain` decorator (the pre-feature look).
+// settle — wait for the tile box to stop changing (the transition done),
+// then send ONE configure to the final size (~1 client redraw per
+// focus change; honours the precious GPU frame budget).
+// continuous — send a configure every frame the box changes (exact real-time
+// tracking, ~1 redraw per animation frame; heavy on this GPU).
+// debounced — coalesce: configure at most once per resize_debounce_ms during
+// the animation, plus a final one on settle.
+
+namespace unbox::ext_window_field::config {
+
+// How the field pushes a tile's RCSS-computed size to its client.
+enum class ResizeMode { off, settle, continuous, debounced };
+
+// The parsed window-field policy. Defaults are the compiled-in fallback used
+// when no config / no [window-field] table / a bad value is present.
+struct ResizePolicy {
+ ResizeMode mode = ResizeMode::settle; // default: one configure on settle
+ int debounce_ms = 100; // debounced only; clamped to >= 0
+};
+
+// The outcome of loading. `policy` is always populated (defaults where a key is
+// absent or invalid). `warnings` holds one message per bad value or a single
+// parse-error message. `parse_error` is true iff the document failed to parse
+// (toml syntax error) — then `policy` is the pure default. NEVER throws (the
+// toml parse error is caught here). An empty document / absent [window-field] is
+// NOT an error — it yields defaults with parse_error=false and no warnings.
+struct LoadResult {
+ ResizePolicy policy;
+ std::vector<std::string> warnings;
+ bool parse_error = false;
+};
+
+// Parse `toml_text` and extract the [window-field] policy per the schema above.
+// Each key is validated independently: an unknown resize_mode string, a
+// non-string resize_mode, a negative/non-integer resize_debounce_ms falls back
+// to that field's default with a warning and never aborts the rest.
+[[nodiscard]] auto load_from_string(std::string_view toml_text) -> LoadResult;
+
+} // namespace unbox::ext_window_field::config
diff --git a/packages/ext-window-field/src/extension.cpp b/packages/ext-window-field/src/extension.cpp
index 911283b..0035b0b 100644
--- a/packages/ext-window-field/src/extension.cpp
+++ b/packages/ext-window-field/src/extension.cpp
@@ -1,5 +1,7 @@
#include <unbox/ext-window-field/ext_window_field.hpp>
+#include "config.hpp"
+#include "geometry.hpp"
#include "probe.hpp"
#include <unbox/ext-xdg-shell/ext_xdg_shell.hpp>
@@ -9,7 +11,11 @@
#include <algorithm>
#include <cstddef>
+#include <cstdlib>
+#include <fstream>
#include <memory>
+#include <optional>
+#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
@@ -54,6 +60,56 @@ namespace {
using kernel::Host;
using Toplevel = ext_xdg_shell::Toplevel;
+// ---- config load (effect): discover + read the file, parse, fall back -------
+//
+// Mirrors ext-keybindings: file discovery/reading is the effect here; the pure
+// parse lives in config.cpp. No readable file / parse error / bad values ->
+// compiled defaults (resize_mode "settle"). Never throws.
+auto read_file(const std::string& path, std::string& out) -> bool {
+ std::ifstream in(path, std::ios::binary);
+ if (!in) {
+ return false;
+ }
+ std::ostringstream ss;
+ ss << in.rdbuf();
+ out = ss.str();
+ return true;
+}
+
+auto discover_config_path(const std::optional<std::string>& explicit_path)
+ -> std::optional<std::string> {
+ if (explicit_path) {
+ return explicit_path; // host-bin --config: use it verbatim
+ }
+ if (const char* xdg = std::getenv("XDG_CONFIG_HOME"); xdg != nullptr && xdg[0] != '\0') {
+ return std::string(xdg) + "/unbox/unbox.toml";
+ }
+ if (const char* home = std::getenv("HOME"); home != nullptr && home[0] != '\0') {
+ return std::string(home) + "/.config/unbox/unbox.toml";
+ }
+ return std::nullopt;
+}
+
+// Load the window-field policy from the effective path (or compiled defaults).
+// Logs every warning. Used both at activate() and on hot-reload.
+auto load_policy(const std::optional<std::string>& effective_path) -> config::ResizePolicy {
+ if (!effective_path) {
+ wlr_log(WLR_INFO, "ext-window-field: no config path; using defaults (resize_mode settle)");
+ return config::ResizePolicy{};
+ }
+ std::string text;
+ if (!read_file(*effective_path, text)) {
+ wlr_log(WLR_INFO, "ext-window-field: no config at '%s'; using defaults",
+ effective_path->c_str());
+ return config::ResizePolicy{};
+ }
+ config::LoadResult loaded = config::load_from_string(text);
+ for (const std::string& w : loaded.warnings) {
+ wlr_log(WLR_ERROR, "ext-window-field: %s", w.c_str());
+ }
+ return loaded.policy;
+}
+
// One window in the field. The Toplevel* is a BORROW valid map..unmapped (per
// the ext-xdg-shell contract), so we may key our own tracking on it and deref
// it within that window; we drop the Window the instant on_toplevel_unmapped
@@ -66,6 +122,35 @@ struct Window {
std::unique_ptr<kernel::SurfaceElement> element; // owns the live import (null = no GL)
std::string title; // copied at map time
std::string app_id; // copied at map time
+
+ // FLOATING geometry STATE (field px), the bound source of truth RCSS applies
+ // as data-style transform/size. Updated by the drag gesture cores; never read
+ // back from the client. `z` is the stack order (higher = on top), bumped on
+ // focus/raise so the active window rises.
+ geom::Box box;
+ int z = 0;
+
+ // Resize-to-tile bookkeeping (the feedback loop, see apply_resize). `sent_*`
+ // is the size we last configured the client to (so we re-send only on a real
+ // change); `seen_*` is the element's resolved tile box observed on the
+ // PREVIOUS frame (so "settle" = box unchanged since last frame). Both 0 until
+ // the first laid-out frame.
+ int sent_w = 0, sent_h = 0;
+ int seen_w = 0, seen_h = 0;
+};
+
+// The single in-flight drag (one pointer => one move/resize at a time). Keyed on
+// the Toplevel IDENTITY (not the row index) so a list reorder/erase mid-drag
+// cannot misattribute it. `start_box` + `start_*` are captured on the start
+// phase; every move/end recomputes from the CUMULATIVE pointer delta (the pure
+// core is stateless — apply_drag(start_box, delta)).
+struct DragSession {
+ Toplevel* tl = nullptr;
+ geom::Handle handle = geom::Handle::move;
+ geom::Box start_box;
+ double start_x = 0.0;
+ double start_y = 0.0;
+ bool active = false;
};
// The window field document lives in EXTERNAL ASSET FILES (loaded via
@@ -82,6 +167,11 @@ struct Window {
class WindowFieldExtension final : public kernel::Extension, public TestProbe {
public:
+ explicit WindowFieldExtension(std::optional<std::string> config_path)
+ : config_path_(std::move(config_path)),
+ effective_path_(discover_config_path(config_path_)),
+ policy_(load_policy(effective_path_)) {}
+
[[nodiscard]] auto manifest() const -> const kernel::Manifest& override { return manifest_; }
// ---- TestProbe (src/probe.hpp; glue-test only) ----
@@ -141,6 +231,16 @@ public:
shell_->on_toplevel_unmapped(),
[this](const ext_xdg_shell::ToplevelEvent& e) { on_unmapped(e.toplevel); });
+ // Config hot-reload: watch the EFFECTIVE config path so editing
+ // unbox.toml re-applies the resize policy live, with no restart (the
+ // kernel fires on_change on create too, so a later-written file is picked
+ // up). A throwing callback is error-isolated by the kernel. No path -> no
+ // watch (defaults stand). config_watch_ is a member: the watch lives
+ // exactly as long as this extension.
+ if (effective_path_) {
+ config_watch_ = host.watch_file(*effective_path_, [this] { reload_config(); });
+ }
+
activated_ = true;
}
@@ -159,6 +259,8 @@ private:
w.tl = tl;
w.title = std::string(tl->title()); // copy: title() is call-only
w.app_id = std::string(tl->app_id()); // copy: app_id() is call-only
+ w.box = initial_placement(tl); // floating: cascade + client's size
+ w.z = ++z_counter_; // a freshly mapped window is on top
// Turn the toplevel's ROOT wl_surface into a live surface element. The
// wl_surface is a borrow valid until unmapped (we drop the element then).
@@ -197,9 +299,29 @@ private:
windows_.push_back(std::move(w));
focused_ = tl; // map-focus: a freshly mapped window is focused
+ ++map_count_;
dirty_wins();
}
+ // Initial floating box for a freshly mapped toplevel: the client's own
+ // committed size (its xdg geometry — what it asked to be) clamped to the min,
+ // cascaded down-right so stacked windows do not perfectly overlap, then
+ // clamped into the field. A 0-size geometry (no buffer yet) falls back to a
+ // sane default. Pure-ish (reads tl->geometry() + the output box).
+ [[nodiscard]] auto initial_placement(Toplevel* tl) -> geom::Box {
+ const geom::Limits lim;
+ const wlr_box g = tl->geometry();
+ geom::Box b;
+ b.w = std::max(lim.min_w, g.width > 0 ? g.width : 800);
+ b.h = std::max(lim.min_h, g.height > 0 ? g.height : 540);
+ const int step = 36;
+ const int n = static_cast<int>(map_count_ % 6);
+ b.x = 60 + n * step;
+ b.y = 60 + n * step;
+ const wlr_box f = primary_output_box();
+ return geom::clamp_to_field(b, f.width, f.height);
+ }
+
// A toplevel unmapping: drop its Window (frees the SurfaceElement — we must
// NOT sample a surface element after its wl_surface is gone; UB per ui.hpp)
// and clear focus tracking if it was the focused one. The Toplevel* borrow
@@ -281,15 +403,224 @@ private:
"wins", "app_id", [this](std::size_t i) -> std::string {
return i < windows_.size() ? windows_[i].app_id : std::string{};
});
+
+ // FLOATING geometry (field px) — the bound STATE the RML applies as
+ // data-style transform/size + z-index. x/y drive a translate, w/h the
+ // element box, z the stack order.
+ field_surface_->bind_list_int("wins", "x", [this](std::size_t i) -> int {
+ return i < windows_.size() ? windows_[i].box.x : 0;
+ });
+ field_surface_->bind_list_int("wins", "y", [this](std::size_t i) -> int {
+ return i < windows_.size() ? windows_[i].box.y : 0;
+ });
+ field_surface_->bind_list_int("wins", "w", [this](std::size_t i) -> int {
+ return i < windows_.size() ? windows_[i].box.w : 0;
+ });
+ field_surface_->bind_list_int("wins", "h", [this](std::size_t i) -> int {
+ return i < windows_.size() ? windows_[i].box.h : 0;
+ });
+ field_surface_->bind_list_int("wins", "z", [this](std::size_t i) -> int {
+ return i < windows_.size() ? windows_[i].z : 0;
+ });
+
+ // Close button (per-row click): ask the client to close. close() is a
+ // request — the window stays valid until its own unmap fires.
+ field_surface_->bind_list_event("wins", "close", [this](std::size_t i) {
+ if (i < windows_.size()) {
+ windows_[i].tl->close();
+ }
+ });
+ // Raise (titlebar tap with no drag): focus + lift, same as a body click.
+ field_surface_->bind_list_event("wins", "raise", [this](std::size_t i) {
+ if (i < windows_.size()) {
+ focus_and_raise(windows_[i].tl);
+ }
+ });
+
+ // Drag interactions: one binding per chrome control, each baking in its
+ // Handle. The row index identifies the window on the start phase; the
+ // session then tracks it by identity (see on_window_drag).
+ field_surface_->bind_list_drag(
+ "wins", "dmove", [this](std::size_t i, kernel::UiSurface::DragPhase ph, double x, double y) {
+ on_window_drag(i, geom::Handle::move, ph, x, y);
+ });
+ field_surface_->bind_list_drag(
+ "wins", "dbl", [this](std::size_t i, kernel::UiSurface::DragPhase ph, double x, double y) {
+ on_window_drag(i, geom::Handle::resize_bl, ph, x, y);
+ });
+ field_surface_->bind_list_drag(
+ "wins", "dbr", [this](std::size_t i, kernel::UiSurface::DragPhase ph, double x, double y) {
+ on_window_drag(i, geom::Handle::resize_br, ph, x, y);
+ });
+ }
+
+ // ---- floating interaction (move / resize / focus) -----------------------
+
+ // Focus + raise a window: keyboard focus (fires on_toplevel_focused -> RCSS
+ // highlight) and bump its z so it sits on top. No-op for an untracked/null tl.
+ void focus_and_raise(Toplevel* tl) {
+ const std::ptrdiff_t idx = index_of(tl);
+ if (idx < 0) {
+ return;
+ }
+ windows_[static_cast<std::size_t>(idx)].z = ++z_counter_;
+ tl->focus(); // keyboard focus + on_toplevel_focused (updates focused_)
+ dirty_wins();
+ }
+
+ // A drag on a window's chrome (titlebar=move, grips=resize). Phase start
+ // captures the box + pointer and resolves the row to a STABLE identity
+ // (Toplevel*), focusing/raising the window; move/end recompute the box from
+ // the cumulative pointer delta via the pure core and clamp it into the field.
+ // Robust to a list reorder/erase mid-drag (it tracks the Toplevel, not the
+ // row index). A throw is impossible here (pure math) but the substrate
+ // isolates it regardless.
+ void on_window_drag(std::size_t row, geom::Handle handle, kernel::UiSurface::DragPhase phase,
+ double x, double y) {
+ if (phase == kernel::UiSurface::DragPhase::start) {
+ if (row >= windows_.size()) {
+ return;
+ }
+ Window& w = windows_[row];
+ drag_ = DragSession{.tl = w.tl,
+ .handle = handle,
+ .start_box = w.box,
+ .start_x = x,
+ .start_y = y,
+ .active = true};
+ focus_and_raise(w.tl);
+ return;
+ }
+ // move / end: apply the cumulative delta to the start box, by identity.
+ if (!drag_.active || drag_.handle != handle) {
+ return; // not our session (e.g. a stale fire)
+ }
+ const std::ptrdiff_t idx = index_of(drag_.tl);
+ if (idx < 0) {
+ drag_.active = false; // the window went away mid-drag
+ return;
+ }
+ const int dx = static_cast<int>(x - drag_.start_x);
+ const int dy = static_cast<int>(y - drag_.start_y);
+ geom::Box nb = geom::apply_drag(drag_.start_box, handle, dx, dy, geom::Limits{});
+ const wlr_box f = primary_output_box();
+ nb = geom::clamp_to_field(nb, f.width, f.height);
+ windows_[static_cast<std::size_t>(idx)].box = nb;
+ if (phase == kernel::UiSurface::DragPhase::end) {
+ drag_.active = false;
+ }
+ dirty_wins(); // re-render at the new box + kick the client-resize loop
}
// Re-read the bound list (count + every visible row field) and re-render the
// field on the next frame. No-op when the surface is null (no-GL backend) —
- // the model is still tracked for the probe.
+ // the model is still tracked for the probe. Also kicks the resize loop: a
+ // map/unmap/focus/move/resize changes a window's box, so the client must be
+ // re-sized to match (apply_resize reads the resolved <img> box and configures
+ // the client).
void dirty_wins() {
if (field_surface_ != nullptr) {
field_surface_->dirty("wins");
}
+ kick_resize();
+ }
+
+ // ---- resize-to-tile feedback loop ---------------------------------------
+
+ // Start the per-frame resize pump if the policy is active and there is
+ // something to do. Cheap + idempotent: a no-op when mode==off, no surface
+ // (no-GL), or already pumping. Called whenever the layout may change
+ // (dirty_wins, size_to_primary_output, reload). The pump stops itself once
+ // every window's tile box has settled (apply_resize releases the handle).
+ void kick_resize() {
+ if (policy_.mode == config::ResizeMode::off || field_surface_ == nullptr ||
+ host_ == nullptr || resize_frames_.active()) {
+ return;
+ }
+ debounce_accum_ = 0.0;
+ resize_frames_ = host_->request_frames([this](double dt) { apply_resize(dt); });
+ }
+
+ // Per-frame: read each window's RCSS-resolved tile box (rendered_width/height
+ // — the kernel reading back the rectangle RCSS laid the <img> out to) and,
+ // per the policy, configure its client to that size so the live texture fills
+ // the tile 1:1 instead of being scaled into it. Runs only while the pump is
+ // active; releases the pump (stops the frame clock) once all windows have
+ // settled, so there is no busy render at rest.
+ void apply_resize(double dt_seconds) {
+ if (policy_.mode == config::ResizeMode::off) {
+ resize_frames_.reset();
+ return;
+ }
+ debounce_accum_ += dt_seconds;
+ const double debounce_s = static_cast<double>(policy_.debounce_ms) / 1000.0;
+
+ bool all_settled = true;
+ bool sent_this_frame = false;
+ for (Window& w : windows_) {
+ if (w.element == nullptr) {
+ continue; // no-GL: nothing to size
+ }
+ const int cw = w.element->rendered_width();
+ const int ch = w.element->rendered_height();
+ if (cw <= 0 || ch <= 0) {
+ all_settled = false; // not laid out yet; keep pumping
+ continue;
+ }
+ const bool stable = (cw == w.seen_w && ch == w.seen_h); // unchanged since last frame
+ const bool needs = (cw != w.sent_w || ch != w.sent_h); // differs from last configured
+
+ bool do_send = false;
+ switch (policy_.mode) {
+ case config::ResizeMode::continuous:
+ do_send = needs;
+ break;
+ case config::ResizeMode::settle:
+ do_send = needs && stable;
+ break;
+ case config::ResizeMode::debounced:
+ do_send = needs && (stable || debounce_accum_ >= debounce_s);
+ break;
+ case config::ResizeMode::off:
+ break;
+ }
+ if (do_send) {
+ w.tl->set_size(cw, ch);
+ w.sent_w = cw;
+ w.sent_h = ch;
+ sent_this_frame = true;
+ }
+ // Settled iff the box is stable AND matches what we last sent (nothing
+ // left to do for this window). The tile box is pure RCSS layout — it
+ // does NOT depend on the client's buffer size — so once we have sent
+ // the stable size there is no feedback that could re-dirty it.
+ if (!stable || cw != w.sent_w || ch != w.sent_h) {
+ all_settled = false;
+ }
+ w.seen_w = cw;
+ w.seen_h = ch;
+ }
+ if (sent_this_frame) {
+ debounce_accum_ = 0.0;
+ }
+ if (all_settled) {
+ resize_frames_.reset(); // stop the frame clock until the next layout change
+ }
+ }
+
+ // Re-read the effective config file and swap the live policy. Mirrors
+ // ext-keybindings' reload: error-isolated (load_policy never throws), keeps
+ // the loop coherent. A switch to "off" lets apply_resize release the pump on
+ // its next tick; any other mode re-kicks the pump so the new policy takes
+ // hold immediately. Last-sent sizes are retained (no spurious reconfigure).
+ void reload_config() {
+ if (!effective_path_) {
+ return;
+ }
+ policy_ = load_policy(effective_path_);
+ wlr_log(WLR_INFO, "ext-window-field: config reloaded (resize_mode=%d) from '%s'",
+ static_cast<int>(policy_.mode), effective_path_->c_str());
+ kick_resize();
}
// Resize/reposition the field to the primary output's box. Called on
@@ -304,6 +635,8 @@ private:
}
field_surface_->set_position(box.x, box.y);
field_surface_->set_size(box.width, box.height);
+ // The field resized -> every %-based tile box changed -> re-size clients.
+ kick_resize();
}
// The primary (first) output's box in layout coords, or an empty box (0x0)
@@ -353,11 +686,29 @@ private:
bool activated_ = false; // TestProbe; set at end of activate()
std::size_t hidden_count_ = 0; // # windows taken out of wlr_scene
+ // Config (resize-to-tile policy). config_path_ is the explicit --config (or
+ // none); effective_path_ is the resolved file actually loaded + watched.
+ // policy_ is the live policy, swapped on hot-reload; it is read by
+ // apply_resize, so it must outlive resize_frames_ (declared early => destroyed
+ // late). debounce_accum_ accumulates frame dt for the "debounced" mode.
+ std::optional<std::string> config_path_;
+ std::optional<std::string> effective_path_;
+ config::ResizePolicy policy_;
+ double debounce_accum_ = 0.0;
+
// The currently focused window (a borrow valid until its unmapped). Drives
// the per-row `focused` bool the RCSS highlights/raises. Cleared when its
// window unmaps.
Toplevel* focused_ = nullptr;
+ // Floating-window bookkeeping. z_counter_ is the monotonic stack-order source
+ // (each focus/raise/map assigns ++z_counter_ so the active window is on top).
+ // map_count_ drives the cascade placement offset. drag_ is the single in-
+ // flight move/resize session (keyed by Toplevel identity, not row index).
+ int z_counter_ = 0;
+ std::size_t map_count_ = 0;
+ DragSession drag_;
+
// The window model. Declared BEFORE field_surface_ so the surface (whose
// bindings read windows_) is destroyed FIRST — windows_ (and its
// SurfaceElements) stay valid through the surface's teardown, then drop, all
@@ -371,8 +722,12 @@ private:
// no-GL backend.
std::unique_ptr<kernel::UiSurface> field_surface_;
- // RAII subscriptions — declared LAST so they release FIRST at teardown,
- // before the field surface + model their callbacks touch (listener-lifetime).
+ // RAII handles — declared LAST so they release FIRST at teardown, before the
+ // field surface + model their callbacks touch (listener-lifetime). The frame
+ // pump (apply_resize) reads windows_/field_surface_/policy_, and the config
+ // watch (reload_config) re-kicks it, so both must stop before those die.
+ kernel::FrameRequest resize_frames_;
+ kernel::FileWatch config_watch_;
kernel::Subscription output_added_;
kernel::Subscription mapped_;
kernel::Subscription focused_sub_;
@@ -381,12 +736,15 @@ private:
} // namespace
-auto create() -> std::unique_ptr<kernel::Extension> {
- return std::make_unique<WindowFieldExtension>();
+auto create(std::optional<std::string> config_path) -> std::unique_ptr<kernel::Extension> {
+ return std::make_unique<WindowFieldExtension>(std::move(config_path));
}
auto make_extension_with_probe() -> ExtensionWithProbe {
- auto ext = std::make_unique<WindowFieldExtension>();
+ // Tests do not exercise config: no path -> compiled defaults (resize_mode
+ // settle), no watch. The headless backend has no GL substrate, so the resize
+ // loop has no elements to size anyway (apply_resize is a no-op there).
+ auto ext = std::make_unique<WindowFieldExtension>(std::nullopt);
TestProbe* probe = ext.get();
return ExtensionWithProbe{.extension = std::move(ext), .probe = probe};
}
diff --git a/packages/ext-window-field/src/geometry.cpp b/packages/ext-window-field/src/geometry.cpp
new file mode 100644
index 0000000..163f2ff
--- /dev/null
+++ b/packages/ext-window-field/src/geometry.cpp
@@ -0,0 +1,45 @@
+#include "geometry.hpp"
+
+#include <algorithm>
+
+namespace unbox::ext_window_field::geom {
+
+auto apply_drag(Box start, Handle handle, int dx, int dy, Limits lim) -> Box {
+ Box r = start;
+ switch (handle) {
+ case Handle::move:
+ r.x = start.x + dx;
+ r.y = start.y + dy;
+ break;
+ case Handle::resize_br:
+ // Right + bottom edges follow the pointer; top-left anchored.
+ r.w = std::max(lim.min_w, start.w + dx);
+ r.h = std::max(lim.min_h, start.h + dy);
+ break;
+ case Handle::resize_bl:
+ // Left + bottom edges follow; the RIGHT edge (start.x + start.w) and the
+ // top edge stay anchored. Compute the new width first (clamped), then
+ // place x so the right edge does not move.
+ r.w = std::max(lim.min_w, start.w - dx);
+ r.x = start.x + start.w - r.w;
+ r.h = std::max(lim.min_h, start.h + dy);
+ break;
+ }
+ return r;
+}
+
+auto clamp_to_field(Box b, int field_w, int field_h) -> Box {
+ if (field_w > 0) {
+ // Largest x that keeps the right edge inside; pin to 0 if the window is
+ // wider than the field. Then clamp the origin into [0, max_x].
+ const int max_x = std::max(0, field_w - b.w);
+ b.x = std::clamp(b.x, 0, max_x);
+ }
+ if (field_h > 0) {
+ const int max_y = std::max(0, field_h - b.h);
+ b.y = std::clamp(b.y, 0, max_y);
+ }
+ return b;
+}
+
+} // namespace unbox::ext_window_field::geom
diff --git a/packages/ext-window-field/src/geometry.hpp b/packages/ext-window-field/src/geometry.hpp
new file mode 100644
index 0000000..39265f7
--- /dev/null
+++ b/packages/ext-window-field/src/geometry.hpp
@@ -0,0 +1,56 @@
+#pragma once
+
+// Pure decision core: floating-window geometry math. No wlroots, no kernel, no
+// RmlUi — just integers in, integers out, so it is heavily doctest-covered
+// (tests/test_geometry.cpp). The glue (extension.cpp) holds the per-window Box
+// as bound STATE and feeds drag deltas through these functions; RCSS APPLIES the
+// result as style (the contract: C++ owns interactive geometry STATE, RCSS owns
+// rendering/animation; "gesture recognition: pure input->output" per AGENTS.md).
+
+namespace unbox::ext_window_field::geom {
+
+// A window's on-screen box in field (= output) px. Top-left origin.
+struct Box {
+ int x = 0;
+ int y = 0;
+ int w = 0;
+ int h = 0;
+};
+
+inline auto operator==(const Box& a, const Box& b) -> bool {
+ return a.x == b.x && a.y == b.y && a.w == b.w && a.h == b.h;
+}
+
+// Which chrome control is being dragged. `move` = the titlebar (translate the
+// whole box); the two resize grips live at the BOTTOM corners (the top edge is
+// fixed because the titlebar lives there):
+// resize_bl — bottom-LEFT grip: the left edge + bottom edge follow the pointer
+// (x and w change, the right edge x+w stays put; h changes).
+// resize_br — bottom-RIGHT grip: the right edge + bottom edge follow (w and h
+// change; x and y stay put).
+enum class Handle { move, resize_bl, resize_br };
+
+// Minimum window size (px). A resize never shrinks below this; the opposite edge
+// stays anchored so the window does not jump when it hits the floor.
+struct Limits {
+ int min_w = 240;
+ int min_h = 160;
+};
+
+// Apply a drag to the box captured at drag-START. `dx,dy` is the CUMULATIVE
+// pointer delta since the drag began (pointer_now - pointer_at_start). Pure: the
+// same inputs always give the same Box, so the glue just records the start box +
+// start pointer once and calls this every move/end phase. Honors `lim` (min
+// size with the anchored-opposite-edge rule); does NOT clamp to the field — see
+// clamp_to_field for that (kept separate so move and resize share one min-size
+// core and the field bound is applied once at the end).
+[[nodiscard]] auto apply_drag(Box start, Handle handle, int dx, int dy, Limits lim) -> Box;
+
+// Clamp a box's ORIGIN so the whole window stays within a field of field_w ×
+// field_h px (a window larger than the field is pinned to the top-left). Keeps
+// the titlebar reachable — a window can never be dragged entirely off-screen.
+// Size is never changed here. A non-positive field dimension is treated as "no
+// bound on that axis" (returns the box unclamped on that axis).
+[[nodiscard]] auto clamp_to_field(Box b, int field_w, int field_h) -> Box;
+
+} // namespace unbox::ext_window_field::geom
diff --git a/packages/ext-window-field/tests/test_config.cpp b/packages/ext-window-field/tests/test_config.cpp
new file mode 100644
index 0000000..21b4230
--- /dev/null
+++ b/packages/ext-window-field/tests/test_config.cpp
@@ -0,0 +1,97 @@
+#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+#include <doctest/doctest.h>
+
+#include "config.hpp"
+
+#include <string_view>
+
+// Pure-core tests for the [window-field] resize-policy loader (src/config.cpp).
+// No kernel, no wlroots — just toml text in, ResizePolicy out. Mirrors
+// ext-keybindings' config test posture: defaults on absence, per-key validation,
+// warnings on bad values, never throws.
+
+namespace cfg = unbox::ext_window_field::config;
+using cfg::ResizeMode;
+
+TEST_CASE("empty document yields defaults, no error") {
+ const cfg::LoadResult r = cfg::load_from_string("");
+ CHECK_FALSE(r.parse_error);
+ CHECK(r.warnings.empty());
+ CHECK(r.policy.mode == ResizeMode::settle);
+ CHECK(r.policy.debounce_ms == 100);
+}
+
+TEST_CASE("absent [window-field] table yields defaults") {
+ const cfg::LoadResult r = cfg::load_from_string("[other]\nkey = 1\n");
+ CHECK_FALSE(r.parse_error);
+ CHECK(r.warnings.empty());
+ CHECK(r.policy.mode == ResizeMode::settle);
+}
+
+TEST_CASE("every resize_mode value parses") {
+ CHECK(cfg::load_from_string("[window-field]\nresize_mode = \"off\"\n").policy.mode ==
+ ResizeMode::off);
+ CHECK(cfg::load_from_string("[window-field]\nresize_mode = \"settle\"\n").policy.mode ==
+ ResizeMode::settle);
+ CHECK(cfg::load_from_string("[window-field]\nresize_mode = \"continuous\"\n").policy.mode ==
+ ResizeMode::continuous);
+ CHECK(cfg::load_from_string("[window-field]\nresize_mode = \"debounced\"\n").policy.mode ==
+ ResizeMode::debounced);
+}
+
+TEST_CASE("unknown resize_mode falls back to settle with a warning") {
+ const cfg::LoadResult r =
+ cfg::load_from_string("[window-field]\nresize_mode = \"wobble\"\n");
+ CHECK_FALSE(r.parse_error);
+ CHECK(r.policy.mode == ResizeMode::settle);
+ CHECK(r.warnings.size() == 1);
+}
+
+TEST_CASE("non-string resize_mode warns and keeps default") {
+ const cfg::LoadResult r = cfg::load_from_string("[window-field]\nresize_mode = 3\n");
+ CHECK(r.policy.mode == ResizeMode::settle);
+ CHECK(r.warnings.size() == 1);
+}
+
+TEST_CASE("resize_debounce_ms parses and is independent of mode") {
+ const cfg::LoadResult r = cfg::load_from_string(
+ "[window-field]\nresize_mode = \"debounced\"\nresize_debounce_ms = 250\n");
+ CHECK(r.policy.mode == ResizeMode::debounced);
+ CHECK(r.policy.debounce_ms == 250);
+ CHECK(r.warnings.empty());
+}
+
+TEST_CASE("zero debounce is accepted") {
+ const cfg::LoadResult r =
+ cfg::load_from_string("[window-field]\nresize_debounce_ms = 0\n");
+ CHECK(r.policy.debounce_ms == 0);
+ CHECK(r.warnings.empty());
+}
+
+TEST_CASE("negative debounce warns and keeps default") {
+ const cfg::LoadResult r =
+ cfg::load_from_string("[window-field]\nresize_debounce_ms = -5\n");
+ CHECK(r.policy.debounce_ms == 100);
+ CHECK(r.warnings.size() == 1);
+}
+
+TEST_CASE("non-integer debounce warns and keeps default") {
+ const cfg::LoadResult r =
+ cfg::load_from_string("[window-field]\nresize_debounce_ms = \"soon\"\n");
+ CHECK(r.policy.debounce_ms == 100);
+ CHECK(r.warnings.size() == 1);
+}
+
+TEST_CASE("toml syntax error sets parse_error and keeps defaults") {
+ const cfg::LoadResult r = cfg::load_from_string("[window-field\nresize_mode = ");
+ CHECK(r.parse_error);
+ CHECK(r.policy.mode == ResizeMode::settle);
+ CHECK(r.warnings.size() == 1);
+}
+
+TEST_CASE("[window-field] not a table warns") {
+ const cfg::LoadResult r = cfg::load_from_string("window-field = 5\n");
+ CHECK_FALSE(r.parse_error);
+ CHECK(r.warnings.size() == 1);
+ CHECK(r.policy.mode == ResizeMode::settle);
+}
diff --git a/packages/ext-window-field/tests/test_geometry.cpp b/packages/ext-window-field/tests/test_geometry.cpp
new file mode 100644
index 0000000..69f084e
--- /dev/null
+++ b/packages/ext-window-field/tests/test_geometry.cpp
@@ -0,0 +1,73 @@
+#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+#include <doctest/doctest.h>
+
+#include "geometry.hpp"
+
+// Pure-core tests for floating-window drag/resize math. No kernel, no wlroots.
+
+namespace geom = unbox::ext_window_field::geom;
+using geom::Box;
+using geom::Handle;
+using geom::Limits;
+
+namespace {
+constexpr Limits kLim{.min_w = 240, .min_h = 160};
+constexpr Box kStart{.x = 100, .y = 80, .w = 800, .h = 500};
+} // namespace
+
+TEST_CASE("move translates the whole box, size unchanged") {
+ const Box r = geom::apply_drag(kStart, Handle::move, 30, -20, kLim);
+ CHECK(r == Box{130, 60, 800, 500});
+}
+
+TEST_CASE("move with zero delta is identity") {
+ CHECK(geom::apply_drag(kStart, Handle::move, 0, 0, kLim) == kStart);
+}
+
+TEST_CASE("resize_br grows width+height, anchors top-left") {
+ const Box r = geom::apply_drag(kStart, Handle::resize_br, 50, 40, kLim);
+ CHECK(r == Box{100, 80, 850, 540});
+}
+
+TEST_CASE("resize_br clamps to min size, top-left still anchored") {
+ const Box r = geom::apply_drag(kStart, Handle::resize_br, -10000, -10000, kLim);
+ CHECK(r == Box{100, 80, 240, 160});
+}
+
+TEST_CASE("resize_bl moves left edge, anchors right edge") {
+ // Drag left by 60 (dx=-60): width grows 60, x moves left 60, right edge fixed.
+ const Box r = geom::apply_drag(kStart, Handle::resize_bl, -60, 25, kLim);
+ CHECK(r.w == 860);
+ CHECK(r.x == 40);
+ CHECK(r.x + r.w == kStart.x + kStart.w); // right edge unchanged
+ CHECK(r.h == 525);
+}
+
+TEST_CASE("resize_bl clamps to min width with right edge anchored") {
+ // Drag right far (dx huge positive): width floors at min_w, x = right - min_w.
+ const Box r = geom::apply_drag(kStart, Handle::resize_bl, 100000, 0, kLim);
+ CHECK(r.w == 240);
+ CHECK(r.x + r.w == kStart.x + kStart.w);
+ CHECK(r.x == kStart.x + kStart.w - 240);
+}
+
+TEST_CASE("clamp_to_field keeps the window inside") {
+ CHECK(geom::clamp_to_field(Box{-50, -30, 400, 300}, 1920, 1080) ==
+ Box{0, 0, 400, 300});
+ CHECK(geom::clamp_to_field(Box{1800, 1000, 400, 300}, 1920, 1080) ==
+ Box{1520, 780, 400, 300});
+}
+
+TEST_CASE("clamp_to_field pins an oversized window to the origin") {
+ CHECK(geom::clamp_to_field(Box{200, 200, 3000, 2000}, 1920, 1080) ==
+ Box{0, 0, 3000, 2000});
+}
+
+TEST_CASE("clamp_to_field with non-positive field is a no-op on that axis") {
+ CHECK(geom::clamp_to_field(Box{-50, -30, 400, 300}, 0, 0) == Box{-50, -30, 400, 300});
+}
+
+TEST_CASE("a box already inside is unchanged") {
+ CHECK(geom::clamp_to_field(Box{100, 100, 400, 300}, 1920, 1080) ==
+ Box{100, 100, 400, 300});
+}
diff --git a/packages/host-bin/src/main.cpp b/packages/host-bin/src/main.cpp
index 507efdc..6a00bb4 100644
--- a/packages/host-bin/src/main.cpp
+++ b/packages/host-bin/src/main.cpp
@@ -62,7 +62,10 @@ auto main(int argc, char* argv[]) -> int {
// The composition root: the ONLY place that names every extension.
// install() transfers ownership; run() activates in dependency order
// (ext-keybindings depends_on xdg-shell, resolved topologically).
- server->install(unbox::ext_xdg_shell::create());
+ // When RML compositing is on, the window field draws window chrome, so
+ // tell xdg-shell to force server-side decorations (clients drop their own
+ // CSD titlebars). In the classic path, leave client-side decorations.
+ server->install(unbox::ext_xdg_shell::create(rml_compositing));
server->install(unbox::ext_layer_shell::create());
server->install(unbox::ext_keybindings::create(config_path));
// The stage dock: Super+M minimizes the focused window into a left-edge
@@ -73,7 +76,7 @@ auto main(int argc, char* argv[]) -> int {
// as RCSS surface elements. depends_on "xdg-shell" (topologically
// activated). When off, toplevels keep compositing through wlr_scene.
if (rml_compositing) {
- server->install(unbox::ext_window_field::create());
+ server->install(unbox::ext_window_field::create(config_path));
}
if (ui_demo) {
server->install(unbox::host_bin::create_demo_ui());
diff --git a/tasks.md b/tasks.md
index 9380e06..22ef29e 100644
--- a/tasks.md
+++ b/tasks.md
@@ -50,12 +50,44 @@ compositing is FUNCTIONAL behind `--rml-compositing` / `UNBOX_RML_COMPOSITING`:
- Click/tap-to-focus: kernel `SurfaceElement::on_pressed` + window-field wires it
to `Toplevel::focus()`.
All unit suites + build-asan green; no regressions.
-NEXT ACTIONS (need USER): (1) **real-seat verification** —
-`UNBOX_RML_COMPOSITING=1 ./build/packages/host-bin/unbox` nested under labwc / on
-the CF-AX3 (headless has no GL, so the visual is the user's, like the spike
-runbook). (2) **Wave 4 decision** — how minimize-to-dock coordinates with the
-window-field. (3) **Wave 5 decision** — damage limiting has real unknowns (RmlUi
-exposes no per-element screen damage), may need a spike or scoped Option-A.
+
+**POST-W3 polish + W3.5 (user-driven, real-seat CONFIRMED on the CF-AX3):**
+- **Resize-to-tile** (real-seat verified, "resolution is great"): kernel
+ `SurfaceElement::rendered_width()/height()` (reads back the RCSS-resolved <img>
+ box — substrate already computes it for popup placement), ext-xdg-shell
+ `Toplevel::set_size()`, and an ext-window-field frame-pumped feedback loop that
+ configures each client to its on-screen box so the live texture maps 1:1.
+ Policy is config-driven (`unbox.toml [window-field] resize_mode` =
+ off|settle|continuous|debounced, hot-reloaded; pure doctested core).
+- **ROOT-CAUSE FIX:** the field now shows each window via `<img data-attr-src>`,
+ NOT a `data-style-decorator`. RmlUi DOES bind `src` (data-attr); the decorator
+ had silently disabled everything the substrate keys off the `src` attribute —
+ pointer/touch input-back, click-to-focus, popup/subsurface placement, AND size
+ readback. (The dock keeps its decorator: frozen previews need none of that.)
+- **W3.5 — FLOATING WINDOWS** (this wave, inserted before the dock per user): the
+ field is now a floating desktop — move (titlebar drag), resize (two bottom
+ corner grips), and a close button per window. New kernel primitive
+ `UiSurface::bind_list_drag` (per-row drag, the list analogue of bind_drag).
+ ext-window-field: per-window geometry STATE (x,y,w,h,z) bound + applied as RCSS
+ data-style (C++ owns interactive state, RCSS renders — contract-clean); pure
+ doctested geometry core (`src/geometry.cpp`: move / resize_bl / resize_br with
+ anchored-opposite-edge + min-size, field clamp); z-order raise on focus; cascade
+ placement. The earlier focused-dominates TILING is dropped — tiled / sidebar
+ containers return LATER as containers windows migrate between (captured-state
+ animations), per the user's staged plan. All suites + build-asan green.
+- **SERVER-SIDE DECORATIONS** (CSD fix for floating windows): ext-xdg-shell now
+ advertises the xdg-decoration manager and forces SERVER_SIDE when RML
+ compositing is on (host-bin passes the flag to create()), so clients drop their
+ own titlebars and only the field's RML chrome shows. Classic path keeps CSD.
+ Kernel wrapper exposes wlr_xdg_decoration_v1. (Caveat: GTK/libadwaita ignore
+ SSD and always draw CSD — a client limitation.)
+
+NEXT ACTIONS (need USER): (1) **real-seat check of floating windows** —
+`UNBOX_RML_COMPOSITING=1 UNBOX_ASSET_DIR=<repo>/assets ./build/packages/host-bin/unbox`
+on the CF-AX3: drag titlebars to move, drag bottom grips to resize, tap × to
+close. (2) **Wave 4 (now W5) decision** — how minimize-to-dock coordinates with
+the floating window-field. (3) **Wave 6 decision** — damage limiting has real
+unknowns (RmlUi exposes no per-element screen damage), may need a spike.
DEFERRED: Wave 3b (layer-shell wallpaper as surface element — wallpaper/panels
already work via wlr_scene background/overlay bands); spike-target deletion +
final doc reconcile (after W5).
diff --git a/unbox.toml b/unbox.toml
index 398d550..90cf86a 100644
--- a/unbox.toml
+++ b/unbox.toml
@@ -39,3 +39,26 @@ action = "focus-next"
[[keybind]]
keys = "Ctrl+Alt+Backspace" # quit the unbox session
action = "quit"
+
+# Window field (RML compositing) ------------------------------------------------
+# Tunes how the window field (when --rml-compositing is on) sizes a client to the
+# on-screen TILE that RCSS lays out for it. The tile animates over a short RCSS
+# transition on every focus / map / unmap, so the question is WHEN to push the new
+# size to the client — each push makes the client reallocate its buffer + redraw.
+#
+# resize_mode
+# "off" Never resize the client: it keeps its own size, scaled to fill
+# the tile. No reconfigure cost.
+# "settle" (default) Wait for the tile to stop animating, then send ONE
+# configure to the final size. ~1 client redraw per focus change.
+# "continuous" Send a configure every frame the tile changes — exact tracking,
+# but ~1 redraw per animation frame (heavy on low-end GPUs).
+# "debounced" Coalesce: at most one configure per `resize_debounce_ms` during
+# the animation, plus a final one on settle.
+#
+# resize_debounce_ms Used only by "debounced" (milliseconds, >= 0). Default 100.
+#
+# Omit this table to get the defaults below. Edits hot-reload live.
+[window-field]
+resize_mode = "settle"
+resize_debounce_ms = 100