From c5ba4baea06484803330519f3bed03e408316a2c Mon Sep 17 00:00:00 2001 From: lkn Date: Sat, 6 Jun 2026 17:49:10 +0200 Subject: [PATCH] phase 1-2: wl_screen_refresh_heads() enumerates all outputs from wlr_output_layout, populates heads/head_count/primary_head and sets correct scr_width/scr_height from bounding box --- MULTI_MONITOR_IMPLEMENTATION_PLAN.md | 481 +++++++++++++++++++++++++++ src/backend/wayland/wl_screen.c | 117 +++++-- 2 files changed, 570 insertions(+), 28 deletions(-) create mode 100644 MULTI_MONITOR_IMPLEMENTATION_PLAN.md diff --git a/MULTI_MONITOR_IMPLEMENTATION_PLAN.md b/MULTI_MONITOR_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..9b6df4e --- /dev/null +++ b/MULTI_MONITOR_IMPLEMENTATION_PLAN.md @@ -0,0 +1,481 @@ +# WindowMaker Multi-Monitor Implementation Plan + +## Purpose + +Implement multi-monitor support in a way that another coding agent can execute with minimal guesswork. + +This plan is written for the current `wmaker` tree and assumes: +- X11 and Wayland backends both remain supported +- shared policy code should continue to use the existing `head` abstraction +- the initial goal is **correct multi-monitor geometry and behavior**, not a redesign of workspaces + +## Scope + +### In scope +- Populate `WScreen.heads[]`, `head_count`, and `primary_head` correctly on both backends +- Make Wayland support multiple outputs instead of only one +- Recompute monitor geometry on output changes +- Keep placement, maximize, fullscreen, icons, dock reservations, and menus head-aware +- Handle output hotplug without leaving windows off-screen +- Preserve existing single-monitor behavior + +### Out of scope for phase 1 +- Per-monitor workspaces +- Per-monitor dock/clip instances +- Full mixed-DPI polish beyond basic correctness + +--- + +## Existing architecture to preserve + +### Shared abstraction +The core already has a backend-neutral monitor/head abstraction. + +Relevant files: +- `src/screen.h` +- `src/xinerama.h` +- `src/xinerama.c` + +Important fields in `WScreen`: +- `heads` +- `head_count` +- `primary_head` +- `usableArea` +- `totalUsableArea` + +Important helper APIs: +- `wGetHeadForRect` +- `wGetHeadForWindow` +- `wGetHeadRelativeToCurrentHead` +- `wGetHeadForPoint` +- `wGetHeadForPointerLocation` +- `wGetRectForHead` +- `wGetUsableAreaForHead` +- `wGetPointToCenterRectInHead` + +### Existing backend support + +#### X11 +- Monitor enumeration already exists in: + - `src/backend/x11/x11_monitor.c` +- Uses Xinerama with RandR screen-change subscriptions + +#### Wayland +- Output layout support already exists via wlroots: + - `src/backend/wayland/wl_backend.c` + - `src/backend/wayland/wl_output.c` + - `src/backend/wayland/wl_screen.c` +- Current limitation: + - `wl_screen.c` populates only `heads[0]` + - some Wayland code assumes `usableArea[0]` + +--- + +## Non-goals and policy choices + +### Workspace policy +Do **not** implement per-monitor workspaces in this effort. + +Use this policy instead: +- a workspace still spans the full virtual desktop +- placement/maximize/focus/icon logic remains **head-aware within that workspace** + +This keeps the patch set tractable and aligns with the current helper model. + +### Dock / Clip policy +For now: +- keep one logical Dock and one logical Clip model +- make their geometry reservations and placement logic head-aware + +Do not introduce per-head docks in this plan. + +--- + +## Canonical monitor contract + +Before changing behavior, normalize the meaning of `heads[]`. + +### Required invariants +1. `scr->heads` contains monitor rectangles in **global root coordinates**. +2. `scr->head_count >= 1` after screen initialization succeeds. +3. `scr->primary_head` is always a valid index when `head_count > 0`. +4. `scr->scr_width` / `scr->scr_height` represent the bounding box of the full virtual desktop. +5. `wGetRectForHead(scr, i)` must always return a valid monitor rectangle for `0 <= i < head_count`. +6. Single-monitor mode should still behave correctly. + +### Preferred normalization +Use **one explicit head even in single-monitor mode**. + +That means avoiding the old pattern where `head_count == 0` implies fallback-to-single-head behavior. + +If changing all callers at once is too risky, do this in two steps: +1. preserve compatibility in helpers +2. gradually move callers to assume `head_count >= 1` + +--- + +## Execution plan + +## Phase 1 — Audit and normalize head initialization + +### Goal +Make head data reliable and backend-neutral before extending Wayland. + +### Files +- `src/screen.h` +- `src/xinerama.h` +- `src/xinerama.c` +- `src/screen.c` +- `src/backend/x11/x11_monitor.c` + +### Tasks +1. Audit all places that read: + - `scr->heads` + - `scr->head_count` + - `scr->primary_head` +2. Decide whether to enforce `head_count >= 1` immediately or via compatibility layer. +3. Ensure the X11 path populates a valid single fallback head when Xinerama is inactive. +4. Ensure head arrays are allocated/freed consistently. +5. Add comments documenting the head contract. + +### Acceptance criteria +- X11 single-monitor behavior is unchanged. +- There is a clear contract for `head_count`, `heads`, and `primary_head`. +- Helper functions behave correctly with one or many heads. + +--- + +## Phase 2 — Implement Wayland monitor enumeration + +### Goal +Populate all heads from `wlr_output_layout`, not just one. + +### Files +- `src/backend/wayland/wl_screen.c` +- `src/backend/wayland/wl_output.c` +- `src/backend/wayland/wl_backend.c` +- `src/backend/backend_types.h` + +### Tasks +1. Audit the Wayland global state for available outputs and layout access. +2. In `wl_screen_open()` or the appropriate geometry refresh path: + - enumerate all active outputs + - query each output's box via `wlr_output_layout_get_box` + - build a `WHeadGeometry[]` +3. Set: + - `scr->heads` + - `scr->head_count` + - `scr->primary_head` +4. Set `scr->scr_width` and `scr->scr_height` from the **union of all head rectangles**, not from a single output. +5. Preserve deterministic ordering of heads. + - Recommended: sort by `(x, y)` or preserve wlroots layout iteration order if stable. +6. Choose primary head policy. + - Recommended initial policy: first active output or currently selected backend primary output. + +### Acceptance criteria +- On Wayland with two outputs, `scr->head_count == 2`. +- `wGetRectForHead()` returns both outputs with correct geometry. +- `scr->scr_width` / `scr->scr_height` reflect the virtual desktop bounds. + +### Notes for the implementing agent +- Keep this backend-only. +- Do not add special placement logic here. +- Just make monitor geometry correct and complete. + +--- + +## Phase 3 — Add dynamic output refresh / hotplug support + +### Goal +Rebuild monitor geometry whenever outputs change. + +### Files +- `src/backend/wayland/wl_output.c` +- `src/backend/wayland/wl_screen.c` +- `src/screen.c` +- optionally `src/window.c`, `src/dock.c` + +### Tasks +1. Identify Wayland output lifecycle hooks: + - new output + - destroyed/removed output + - layout or mode change +2. Add a shared helper to rebuild screen head data. + - Example helper name: `wl_screen_refresh_heads(scr)` +3. Recompute after each relevant event: + - heads + - virtual bounds + - usable areas +4. If an output disappears, repair windows/icons that now lie outside all heads. + +### Suggested repair policy +For each affected window: +1. find nearest surviving head +2. preserve relative position when possible +3. clamp inside that head if necessary +4. fall back to centering in `primary_head` + +For icons/appicons: +- do the same, but prefer the head they touched before removal + +### Acceptance criteria +- Plugging/unplugging outputs does not leave windows permanently off-screen. +- Head helpers reflect the new output topology immediately. + +--- + +## Phase 4 — Make usable-area and reservations per-head + +### Goal +Ensure placement/maximize respects per-head reserved areas. + +### Files +- `src/xinerama.c` +- `src/screen.c` +- `src/dock.c` +- `src/backend/wayland/wl_dock.c` + +### Known problem to fix +Wayland dock code currently assumes `scr->usableArea[0]` in places. + +### Tasks +1. Audit all reads/writes of: + - `scr->usableArea` + - `scr->totalUsableArea` + - `scr->reservedAreas` +2. Ensure these arrays are sized for all heads. +3. Ensure dock/clip reservations affect the correct head. +4. When monitor geometry changes, recompute all per-head usable areas. +5. Remove hardcoded `[0]` assumptions. + +### Acceptance criteria +- Docks and clips reserve space only on the relevant head. +- Maximize avoids reserved space on the current head. +- Single-monitor behavior remains unchanged. + +--- + +## Phase 5 — Audit all head-aware placement and movement paths + +### Goal +Make all geometry-sensitive actions consistent across monitors. + +### Files to audit +- `src/placement.c` +- `src/window.c` +- `src/actions.c` +- `src/cycling.c` +- `src/rootmenu.c` +- `src/dialog.c` +- `src/dock.c` + +### Tasks +Audit and fix the following behaviors: + +#### New window placement +- New windows should open on: + - the pointer head, or + - the owner/transient head, or + - the current head policy already used by helpers + +#### Restore/session placement +- Restored geometry should be clamped or translated to a valid head. + +#### Maximize/fullscreen +- Maximize should apply to the window's current head unless multi-head fullscreen is explicitly requested. +- Existing fullscreen-monitor helpers in `actions.c` should keep working. + +#### Menus/dialogs/transients +- Menus should open on the head that contains the invocation point. +- Transients should prefer the owner head. +- Dialog centering should use the correct head. + +#### Icons / miniwindows +- Icon placement should remain on the window's head. +- Auto-arrange should consider all heads, not just head 0. + +### Acceptance criteria +- Placement/maximize/fullscreen/menus/transients/icons all behave correctly on two monitors. + +--- + +## Phase 6 — Add explicit monitor navigation commands + +### Goal +Expose useful multi-monitor actions to users. + +### Files +- `src/actions.c` +- `src/defaults.c` +- `WPrefs.app/KeyboardShortcuts.c` +- possibly menu/defaults docs + +### Tasks +1. Audit existing actions using: + - `wGetHeadRelativeToCurrentHead` + - `wGetHeadForWindow` +2. Finish or add commands such as: + - move window to next monitor + - move window to previous monitor + - move focus to next monitor + - maximize on current monitor +3. Expose them through defaults and WPrefs if not already exposed. + +### Acceptance criteria +- A user can move windows between monitors with shortcuts. +- The actions behave correctly on X11 and Wayland. + +--- + +## Phase 7 — Mixed scale / DPI follow-up (optional but recommended) + +### Goal +Avoid rendering problems on mixed-scale Wayland outputs. + +### Files +- `src/backend/wayland/wl_output.c` +- `src/backend/wayland/wl_screen.c` +- WINGs font/render paths + +### Tasks +1. Track output scale per output/head. +2. Set output scale in wlroots where appropriate. +3. Feed DPI/scale into font/layout code. +4. Validate titlebars, menus, icons, screenshots, and WINGs rendering. + +### Acceptance criteria +- Multi-output works correctly even when outputs have different scales. + +--- + +## Concrete code audit checklist + +Use this as a literal checklist while implementing. + +### Shared monitor/head code +- [ ] `src/xinerama.c` +- [ ] `src/xinerama.h` +- [ ] `src/screen.c` +- [ ] `src/screen.h` + +### X11 backend +- [ ] `src/backend/x11/x11_monitor.c` + +### Wayland backend +- [ ] `src/backend/wayland/wl_backend.c` +- [ ] `src/backend/wayland/wl_output.c` +- [ ] `src/backend/wayland/wl_screen.c` +- [ ] `src/backend/wayland/wl_dock.c` + +### Placement/window policy +- [ ] `src/placement.c` +- [ ] `src/window.c` +- [ ] `src/actions.c` +- [ ] `src/cycling.c` +- [ ] `src/rootmenu.c` +- [ ] `src/dialog.c` +- [ ] `src/dock.c` + +### User-facing configuration +- [ ] `src/defaults.c` +- [ ] `WPrefs.app/KeyboardShortcuts.c` +- [ ] docs / release notes + +--- + +## Suggested implementation order for an agent + +If working incrementally, use this order: + +1. **Normalize head contract** +2. **Enumerate all Wayland outputs into `heads[]`** +3. **Set virtual desktop bounds correctly** +4. **Recompute heads on hotplug / output change** +5. **Fix per-head usable area and dock reservations** +6. **Audit maximize/placement/fullscreen** +7. **Audit menus/transients/icons** +8. **Add monitor-navigation actions** +9. **Do scale/DPI follow-up** + +Do not start with user-facing shortcuts or DPI. +Start with geometry correctness. + +--- + +## Test matrix + +## X11 tests +- [ ] single monitor +- [ ] dual monitor horizontal +- [ ] dual monitor vertical +- [ ] mixed resolutions +- [ ] RandR hotplug/unplug +- [ ] maximize on each head +- [ ] fullscreen on each head +- [ ] menu placement +- [ ] dialog placement +- [ ] icon placement + +## Wayland tests +Use nested mode if needed. + +Suggested setup: +- `WMAKER_WAYLAND_NESTED=1` +- adjust outputs with `wlr-randr` + +Run: +- [ ] single output +- [ ] two outputs horizontal +- [ ] two outputs vertical +- [ ] asymmetric offset layout +- [ ] output removal with windows present +- [ ] output addition after startup +- [ ] maximize/fullscreen correctness +- [ ] dock/clip reservation correctness +- [ ] pointer-head placement correctness +- [ ] WINGs app placement correctness +- [ ] XWayland client placement correctness + +--- + +## Debugging guidance for the implementing agent + +When behavior is wrong, log these values first: +- `scr->head_count` +- all `scr->heads[i]` +- `scr->primary_head` +- `scr->scr_width`, `scr->scr_height` +- result of `wGetHeadForWindow()` for the affected window +- result of `wGetHeadForPointerLocation()` during placement +- per-head `usableArea` and `totalUsableArea` + +For Wayland output changes, log: +- output add/remove +- output layout box for each output +- geometry refresh entry/exit +- window migration decisions on output removal + +--- + +## Definition of done + +This effort is complete when all of the following are true: +- `heads[]` is correct on X11 and Wayland +- Wayland supports more than one output +- output hotplug updates geometry correctly +- windows never remain stranded off-screen after output removal +- maximize/fullscreen/placement/icons/menus are head-aware +- dock/clip reservations are per-head +- single-monitor behavior is unchanged + +--- + +## Minimal deliverable if time is limited + +If this must be split across multiple PRs, the **minimum useful first PR** is: +1. normalize monitor contract +2. populate all Wayland heads +3. set correct virtual desktop bounds +4. fix obvious `usableArea[0]` assumptions + +That alone provides the base needed for all follow-up work. diff --git a/src/backend/wayland/wl_screen.c b/src/backend/wayland/wl_screen.c index 14e80f5..63f0014 100644 --- a/src/backend/wayland/wl_screen.c +++ b/src/backend/wayland/wl_screen.c @@ -1,6 +1,8 @@ #ifdef HAVE_CONFIG_H #include "config.h" #endif +#include +#include #include #include #include @@ -26,17 +28,87 @@ extern void fb_attach_scene_buf_listener(struct wl_frame_buf *fb); void *W_RegisterBacking(unsigned long id, void *img); +#define MAX_WL_HEADS 16 + +/* Rebuild scr->heads from the wlr_output_layout. Called during screen + * init and on output hotplug. */ +static void +wl_screen_refresh_heads(WScreen *scr) +{ + /* Free old head array if any */ + if (scr->heads) { + wfree(scr->heads); + scr->heads = NULL; + } + scr->head_count = 0; + scr->primary_head = 0; + + if (!wl_state.output_layout) + return; + + /* Count outputs first */ + int count = 0; + struct wlr_output_layout_output *lo; + wl_list_for_each(lo, &wl_state.output_layout->outputs, link) { + count++; + } + if (count == 0) + return; + if (count > MAX_WL_HEADS) + count = MAX_WL_HEADS; + + scr->heads = wmalloc(sizeof(WHeadGeometry) * count); + scr->head_count = count; + + /* Compute bounding box across all heads */ + int min_x = INT_MAX, min_y = INT_MAX; + int max_x = 0, max_y = 0; + int idx = 0; + wl_list_for_each(lo, &wl_state.output_layout->outputs, link) { + if (idx >= count) break; + struct wlr_box box = {0}; + wlr_output_layout_get_box(wl_state.output_layout, + lo->output, &box); + if (box.width == 0 || box.height == 0) { + box.x = 0; box.y = 0; + box.width = (int)lo->output->width; + box.height = (int)lo->output->height; + } + scr->heads[idx].x = box.x; + scr->heads[idx].y = box.y; + scr->heads[idx].width = box.width; + scr->heads[idx].height = box.height; + if (box.x < min_x) min_x = box.x; + if (box.y < min_y) min_y = box.y; + if (box.x + box.width > max_x) max_x = box.x + box.width; + if (box.y + box.height > max_y) max_y = box.y + box.height; + idx++; + } + + scr->scr_width = max_x - min_x; + scr->scr_height = max_y - min_y; + + /* Use the first output's geometry for scr_width/scr_height if we + * only got a single output anyway (the bounding box is the same). */ + if (count == 1) { + scr->scr_width = scr->heads[0].width; + scr->scr_height = scr->heads[0].height; + } +} + /* ------------------------------------------------------------------ */ /* Screen open/close */ void wl_screen_open(WScreen *scr) { - /* Set geometry from the headless output created in display_open(). */ - scr->scr_width = wl_state.output ? - (int)wl_state.output->width : 1920; - scr->scr_height = wl_state.output ? - (int)wl_state.output->height : 1080; + wl_screen_refresh_heads(scr); + + /* Fallback if no output is available yet */ + if (scr->head_count == 0) { + scr->scr_width = 1920; + scr->scr_height = 1080; + } scr->depth = 24; scr->root_win = (WNativeWindow)0x0FFFFFFF; /* synthetic root window ID */ scr->colormap = 0; @@ -518,37 +590,26 @@ wl_screen_load_tech_font(WScreen *scr) void wl_monitors_query(WScreen *scr, WHeadGeometry **heads_out, int *count_out) { - (void)scr; - - if (!wl_state.output) { + if (!wl_state.output_layout) { *heads_out = NULL; *count_out = 0; return; } - WHeadGeometry *heads = wmalloc(sizeof(WHeadGeometry)); + /* Rebuild head data from current layout */ + wl_screen_refresh_heads(scr); - if (wl_state.output_layout) { - struct wlr_box box = {0}; - wlr_output_layout_get_box(wl_state.output_layout, wl_state.output, &box); - if (box.width > 0 || box.height > 0) { - heads[0].x = box.x; - heads[0].y = box.y; - heads[0].width = box.width; - heads[0].height = box.height; - } else { - heads[0].x = heads[0].y = 0; - heads[0].width = (int)wl_state.output->width; - heads[0].height = (int)wl_state.output->height; - } - } else { - heads[0].x = heads[0].y = 0; - heads[0].width = (int)wl_state.output->width; - heads[0].height = (int)wl_state.output->height; + if (scr->head_count == 0 || !scr->heads) { + *heads_out = NULL; + *count_out = 0; + return; } - *heads_out = heads; - *count_out = 1; + /* Return a copy the caller owns */ + WHeadGeometry *out = wmalloc(sizeof(WHeadGeometry) * scr->head_count); + memcpy(out, scr->heads, sizeof(WHeadGeometry) * scr->head_count); + *heads_out = out; + *count_out = scr->head_count; } void wl_monitors_select_events(WScreen *scr) { (void)scr; }