diff --git a/compositor/src/handlers/compositor.rs b/compositor/src/handlers/compositor.rs index 60ef9b5..cc00d65 100644 --- a/compositor/src/handlers/compositor.rs +++ b/compositor/src/handlers/compositor.rs @@ -77,6 +77,12 @@ impl CompositorHandler for Ferese { self.space.map_element(window.clone(), (0, 0), false); self.restore_keyboard_focus(); } + if window + .toplevel() + .is_some_and(|toplevel| !xdg_shell::initial_configure_sent(toplevel)) + { + self.restore_initial_floating_size(&window); + } } self.record_client_commit(&window); } diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index eaddd5c..a5009d1 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -285,11 +285,13 @@ impl XdgDecorationHandler for Ferese { let mode = self.decoration_mode_for(&toplevel); toplevel.with_pending_state(|state| state.decoration_mode = Some(mode)); - // This must be unconditional: the initial xdg-toplevel configure may - // already contain the same mode before the decoration object exists. - // `send_pending_configure` would then see no state change and omit the - // decoration configure, leaving clients to fall back to CSD. - toplevel.send_configure(); + // Wait for the initial surface commit so app-id and saved floating + // size can be included in the first configure. If already configured, + // send unconditionally: the existing toplevel state may have the same + // mode, but the new decoration object still needs its configure event. + if initial_configure_sent(&toplevel) { + toplevel.send_configure(); + } } fn request_mode(&mut self, toplevel: ToplevelSurface, _mode: Mode) { @@ -319,7 +321,21 @@ impl Ferese { fn set_decoration_mode(toplevel: &ToplevelSurface, mode: Mode) { toplevel.with_pending_state(|state| state.decoration_mode = Some(mode)); - toplevel.send_pending_configure(); + if initial_configure_sent(toplevel) { + toplevel.send_pending_configure(); + } +} + +pub(crate) fn initial_configure_sent(toplevel: &ToplevelSurface) -> bool { + with_states(toplevel.wl_surface(), |states| { + states + .data_map + .get::() + .unwrap() + .lock() + .unwrap() + .initial_configure_sent + }) } pub fn handle_commit(popups: &mut PopupManager, space: &mut Space, surface: &WlSurface) { diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 9f6c2ff..db7f475 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -1257,6 +1257,54 @@ impl Ferese { .is_some_and(|geometry| geometry.client.committed_size.is_some()) } + pub(crate) fn restore_initial_floating_size(&mut self, window: &Window) { + let Some(toplevel) = window.toplevel() else { return }; + let (app_id, title, transient) = with_states(toplevel.wl_surface(), |states| { + let attributes = states.data_map.get::().unwrap().lock().unwrap(); + ( + attributes.app_id.clone(), + attributes.title.clone(), + attributes.parent.is_some(), + ) + }); + let special_mode = toplevel.with_pending_state(|pending| { + pending.states.contains(xdg_toplevel::State::Fullscreen) + || pending.states.contains(xdg_toplevel::State::Maximized) + }); + let rule = resolve_window_rules(&self.window_rules, app_id.as_deref(), title.as_deref(), transient); + if transient + || special_mode + || rule.fullscreen == Some(true) + || !rule.floating.unwrap_or(rule.width.is_some() || rule.height.is_some()) + { + return; + } + let outputs = self.floating_outputs(); + let Some((remembered, work)) = app_id + .as_deref() + .and_then(|app| self.floating_memory.get(app)) + .and_then(|entry| { + let rect = entry.restore(&outputs)?; + let work = outputs.iter().find(|(name, _)| *name == entry.output)?.1; + Some((rect, work)) + }) + else { + return; + }; + // Metadata is available on the initial bufferless commit. Restore the + // size before the client creates its first buffer, without mapping or + // focusing the window. Use the same rule/constraint precedence as map. + let rect = self.place_floating( + window, + None, + work, + (remembered.width, remembered.height), + Some((rule.width, rule.height)), + ); + let size = ClientSize::from_rect(rect); + toplevel.with_pending_state(|pending| pending.size = Some((size.width, size.height).into())); + } + pub(crate) fn add_rule_placed_window( &mut self, window: Window, diff --git a/scripts/tests/fixtures/floating-size.c b/scripts/tests/fixtures/floating-size.c index 9a1c785..7f694ca 100644 --- a/scripts/tests/fixtures/floating-size.c +++ b/scripts/tests/fixtures/floating-size.c @@ -8,6 +8,10 @@ #include #include #include "xdg-shell-client-protocol.h" +#ifdef FERESE_TEST_DECORATION +#include "xdg-decoration-client-protocol.h" +static struct zxdg_decoration_manager_v1 *decoration_manager; +#endif static struct wl_compositor *compositor; static struct wl_shm *shm; @@ -50,6 +54,10 @@ static void global(void *data, struct wl_registry *registry, uint32_t name, wm = wl_registry_bind(registry, name, &xdg_wm_base_interface, 1); xdg_wm_base_add_listener(wm, &wm_listener, NULL); } +#ifdef FERESE_TEST_DECORATION + else if (!strcmp(interface, "zxdg_decoration_manager_v1")) + decoration_manager = wl_registry_bind(registry, name, &zxdg_decoration_manager_v1_interface, 1); +#endif } static void removed(void *data, struct wl_registry *registry, uint32_t name) { (void)data; (void)registry; (void)name; @@ -59,6 +67,8 @@ static void configure(void *data, struct xdg_surface *xdg, uint32_t serial) { (void)data; xdg_surface_ack_configure(xdg, serial); make_buffer(); + printf("buffer %d %d\n", width, height); + fflush(stdout); xdg_surface_set_window_geometry(xdg, 0, 0, width, height); wl_surface_attach(surface, buffer, 0, 0); wl_surface_damage(surface, 0, 0, width, height); @@ -68,6 +78,8 @@ static const struct xdg_surface_listener surface_listener = {.configure = config static void size(void *data, struct xdg_toplevel *top, int32_t w, int32_t h, struct wl_array *states) { (void)data; (void)top; (void)states; + printf("configure %d %d\n", w, h); + fflush(stdout); if (honor_configure) { if (w > 0) width = w; if (h > 0) height = h; @@ -77,6 +89,12 @@ static void close_window(void *data, struct xdg_toplevel *top) { (void)data; (void)top; exit(0); } static const struct xdg_toplevel_listener top_listener = {.configure = size, .close = close_window}; +#ifdef FERESE_TEST_DECORATION +static void decoration_configure(void *data, struct zxdg_toplevel_decoration_v1 *decoration, uint32_t mode) { + (void)data; (void)decoration; (void)mode; +} +static const struct zxdg_toplevel_decoration_v1_listener decoration_listener = {.configure = decoration_configure}; +#endif int main(int argc, char **argv) { if (argc >= 4) { width = atoi(argv[2]); height = atoi(argv[3]); } honor_configure = argc >= 5; @@ -91,6 +109,15 @@ int main(int argc, char **argv) { xdg_surface_add_listener(xdg, &surface_listener, NULL); struct xdg_toplevel *top = xdg_surface_get_toplevel(xdg); xdg_toplevel_add_listener(top, &top_listener, NULL); +#ifdef FERESE_TEST_DECORATION + if (argc >= 6 && !strcmp(argv[5], "decorated")) { + if (!decoration_manager) return 6; + struct zxdg_toplevel_decoration_v1 *decoration = + zxdg_decoration_manager_v1_get_toplevel_decoration(decoration_manager, top); + zxdg_toplevel_decoration_v1_add_listener(decoration, &decoration_listener, NULL); + zxdg_toplevel_decoration_v1_set_mode(decoration, ZXDG_TOPLEVEL_DECORATION_V1_MODE_CLIENT_SIDE); + } +#endif xdg_toplevel_set_app_id(top, argc >= 2 ? argv[1] : "ferese.test.floating-size"); xdg_toplevel_set_title(top, "Floating size regression"); wl_surface_commit(surface); diff --git a/scripts/tests/test_floating_placement_isolated.py b/scripts/tests/test_floating_placement_isolated.py index af3e3a4..fd347b9 100644 --- a/scripts/tests/test_floating_placement_isolated.py +++ b/scripts/tests/test_floating_placement_isolated.py @@ -23,21 +23,32 @@ class FloatingPlacementTest(unittest.TestCase): xml = str(Path(protocols) / "stable/xdg-shell/xdg-shell.xml") for kind, output in [("client-header", "xdg-shell-client-protocol.h"), ("private-code", "xdg-shell-protocol.c")]: subprocess.run(["wayland-scanner", kind, xml, str(root / output)], check=True) + decoration_xml = str(Path(protocols) / "unstable/xdg-decoration/xdg-decoration-unstable-v1.xml") + for kind, output in [("client-header", "xdg-decoration-client-protocol.h"), + ("private-code", "xdg-decoration-protocol.c")]: + subprocess.run(["wayland-scanner", kind, decoration_xml, str(root / output)], check=True) flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "wayland-client"], text=True).split() - subprocess.run(["cc", "-Wall", "-Wextra", "-I", str(root), str(repo / "scripts/tests/fixtures/floating-size.c"), - str(root / "xdg-shell-protocol.c"), "-o", str(root / "client"), *flags], check=True) + subprocess.run(["cc", "-Wall", "-Wextra", "-DFERESE_TEST_DECORATION", "-I", str(root), + str(repo / "scripts/tests/fixtures/floating-size.c"), str(root / "xdg-shell-protocol.c"), + str(root / "xdg-decoration-protocol.c"), "-o", str(root / "client"), *flags], check=True) runtime = root / "runtime" runtime.mkdir(mode=0o700) config = root / "config/ferese" config.mkdir(parents=True) - (config / "config.kdl").write_text('animations { enabled #false; }\n' + ''.join( + (config / "config.kdl").write_text('animations { enabled #true; }\n' + ''.join( f'window-rule app-id="ferese.test.placement.{app}" floating=#true\n' for app in ["remembered", "second", "third"]) + 'window-rule app-id="ferese.test.placement.fullscreen" floating=#true width=240 height=160 fullscreen=#true\n' + - 'window-rule app-id="ferese.test.placement.maximized" floating=#true width=240 height=160\n') + 'window-rule app-id="ferese.test.placement.maximized" floating=#true width=240 height=160\n' + + 'window-rule app-id="ferese.test.placement.reopen" floating=#true\n' + + 'window-rule app-id="ferese.test.placement.rule-size" floating=#true width=300 height=190\n') state = root / "state/ferese" state.mkdir(parents=True) memory = state / "floating.json" - memory.write_text(json.dumps({"ferese.test.placement.remembered": {"output": "ferese-winit", "fractions": [.1, .1, .2, .2]}})) + memory.write_text(json.dumps({"ferese.test.placement." + app: {"output": "ferese-winit", "fractions": fractions} + for app, fractions in [("remembered", [.1, .1, .2, .2]), + ("reopen", [.55, .55, .25, .3]), + ("rule-size", [.55, .55, .25, .3]), + ("tiled", [.55, .55, .25, .3])]})) before = memory.read_bytes() display = Path(os.environ["WAYLAND_DISPLAY"]) if not display.is_absolute(): @@ -104,6 +115,49 @@ class FloatingPlacementTest(unittest.TestCase): self.fail(f"floating rectangle changed after toggling: {expected} -> {actual}") time.sleep(.025) self.assertEqual(memory.read_bytes(), before, "nested previews must not persist drag memory") + # The first configure and every buffer must already have + # the restored size. Checking only the final rectangle + # misses a visible default-size -> saved-size replay. + for app, repeats in [("reopen", 2), ("rule-size", 1), ("tiled", 1)]: + for attempt in range(repeats): + with (root / f"{app}-{attempt}.log").open("w+") as client_log: + args = [str(root / "client"), "ferese.test.placement." + app, "240", "160", "configured"] + if attempt == 1: + # Request decorations before sending app-id: + # this must not send an early sizeless configure. + args.append("decorated") + client = launch(args, stdout=client_log, stderr=log) + deadline = time.monotonic() + 5 + while True: + client_log.seek(0) + events = client_log.read().splitlines() + buffers = [tuple(map(int, e.split()[1:])) for e in events if e.startswith("buffer ")] + if buffers: + break + if time.monotonic() > deadline: + self.fail(f"client did not map: {events}") + time.sleep(.01) + first_configure = next(tuple(map(int, e.split()[1:])) for e in events if e.startswith("configure ")) + if app == "tiled": + self.assertEqual(first_configure, (0, 0), "floating memory must not size a tiled window") + else: + output = next(o for o in command("get-outputs") if o["enabled"]) + expected = ((round(.25 * output["width"]), round(.3 * output["height"])) + if app == "reopen" else (300, 190)) + for actual, target in zip(first_configure, expected): + self.assertAlmostEqual(actual, target, delta=1) + time.sleep(.4) + client_log.seek(0) + buffers = [tuple(map(int, e.split()[1:])) for e in client_log.read().splitlines() + if e.startswith("buffer ")] + self.assertTrue(all(size == first_configure for size in buffers), repr(buffers)) + client.terminate() + client.wait(timeout=3) + deadline = time.monotonic() + 5 + while any(w["app_id"] == "ferese.test.placement." + app for w in command("get-windows")): + if time.monotonic() > deadline: + self.fail("closed client remained mapped") + time.sleep(.01) for mode in ["fullscreen", "maximized"]: app = "ferese.test.placement." + mode if mode == "maximized":