diff options
| author | Adam Malczewski <[email protected]> | 2026-06-15 18:43:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-15 18:43:40 +0900 |
| commit | db9753e6f42e43c08c43b848bc865aae97aa1655 (patch) | |
| tree | cd8cc5803987def7a4e4cf78baabfd3ed835cf1e | |
| parent | aeeda3135ddb4f1b633ccf2e46c4832b35888c7b (diff) | |
| download | unbox-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-- | .gitignore | 3 | ||||
| -rw-r--r-- | assets/ext-window-field/field.rcss | 150 | ||||
| -rw-r--r-- | assets/ext-window-field/field.rml | 65 | ||||
| -rw-r--r-- | packages/ext-window-field/include/unbox/ext-window-field/ext_window_field.hpp | 12 | ||||
| -rw-r--r-- | packages/ext-window-field/meson.build | 60 | ||||
| -rw-r--r-- | packages/ext-window-field/src/config.cpp | 97 | ||||
| -rw-r--r-- | packages/ext-window-field/src/config.hpp | 62 | ||||
| -rw-r--r-- | packages/ext-window-field/src/extension.cpp | 370 | ||||
| -rw-r--r-- | packages/ext-window-field/src/geometry.cpp | 45 | ||||
| -rw-r--r-- | packages/ext-window-field/src/geometry.hpp | 56 | ||||
| -rw-r--r-- | packages/ext-window-field/tests/test_config.cpp | 97 | ||||
| -rw-r--r-- | packages/ext-window-field/tests/test_geometry.cpp | 73 | ||||
| -rw-r--r-- | packages/host-bin/src/main.cpp | 7 | ||||
| -rw-r--r-- | tasks.md | 44 | ||||
| -rw-r--r-- | unbox.toml | 23 |
15 files changed, 1068 insertions, 96 deletions
@@ -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)">×</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()); @@ -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). @@ -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 |
