Skip to content

Instantly share code, notes, and snippets.

@gwpl
Created May 18, 2026 19:02
Show Gist options
  • Select an option

  • Save gwpl/0ddc1c1ac2076d779ffc127474aa80bd to your computer and use it in GitHub Desktop.

Select an option

Save gwpl/0ddc1c1ac2076d779ffc127474aa80bd to your computer and use it in GitHub Desktop.
Xpra #4883 — per-client coord translation: draft design notes (early, AI-organised from voice notes)
title Xpra #4883 — per-client coordinate translation: draft design notes
status early draft, work in progress
issue Xpra-org/xpra#4883
prior_gist https://gist.github.com/gwpl/b07b93e6a07091d1a30d5fd0cc97d831
source_url https://gist.github.com/gwpl/{TBD-after-publish}
created 2026-05-17
author Greg (gwpl), with AI assistance for wording / structuring

AI 🤝 Greg: this whole gist is an early-draft thinking artifact, not a spec. Greg dumped a lot of voice-note-style thoughts about the problem; the AI organized them into the structured-but-still-draft text you are reading. Treat every file here as "a sketch of how we might think about the cases", not as a proposal of final behaviour. Wording, naming and even axis splits are expected to change once the maintainers signal which direction is welcome.

Why this gist exists

The original issue Xpra-org/xpra#4883 asked whether per-client server↔local coordinate translation would be welcome. The maintainer's reply made two things clear:

  1. Geometry is genuinely hard, and a single-line "list of translations per client" framing is too glib — there are non-trivial edge cases the original ask under-acknowledged.
  2. The previous attempt down a similar path (#3454) ended up painful enough that the bar for any new contribution here is higher than usual.

This gist is Greg's attempt to do the organising-the-cases pre-work in public, before asking for any code review time — so that if the maintainers ever do say "yes, this direction is welcome", we already share a vocabulary for the edge cases, an agreed set of test inputs/outputs for the simple subset, and a deliberately-staged plan from "simplest useful thing" to "general multi-monitor mapping".

Files in this gist (read order)

  • 01-edge-cases-and-update-rituals.md — the edge cases Greg now sees more clearly than when filing the issue, each paired with a candidate "update ritual" (what xpra would need to recompute and re-send when the case is triggered).
  • 02-axis-limit-and-movement-interval.md — the per-axis mathematical framing of "window is bigger than the visible slot": the movement-interval shrinks to zero, after which no fully-visible anchor position exists. Drives the degenerate-case rules.
  • 03-coverage-matrix-combinatorial.md — combinatorial coverage of the (window-vs-monitor) × (server-vs-client) × (geometry-vs-input) dimensions, marking which combinations are covered / partially covered / deferred / out-of-scope for the first iteration.
  • 04-mapping-models-spectrum.md — the (A)→(F) spectrum from the original issue, refined: which model each edge case forces, and what the smallest useful subset is.
  • 05-deferred-server-virtual-desktop-LCM.md — a deliberately deferred sub-discussion: should the server's virtual desktop size be picked as an LCM / common-grid of client resolutions, to reduce rounding loss when translating? Kept separate so it doesn't block the basic case.
  • 06-acm-icpc-style-testbed-plan.md — the external-repo plan: input/output sample format, golden-test layout, what the first ~20 test cases should look like.
  • 07-library-boundary-proposal.md — sketch of a pure, side-effect-free Python module that takes (server_geometry, client_geometry, mapping_spec, event) and returns translated geometry/events — so xpra can import it and other future tools (Wayland forwarders, X11 multi-seat helpers) could too.
  • 08-pr-roadmap.md — staged plan once design alignment is reached: minimal client-only PR (option A) → richer per-client mapping → optional per-window-class layer (option F).

What this gist is not

  • Not a request for review of code. There is no code in this gist.
  • Not a proposal that the maintainers should do any of this themselves.
  • Not a claim that the cases listed here are exhaustive — they are a snapshot of what Greg currently sees, written down so that gaps become easier to spot and discuss.

Cadence note

Greg expects to work on this in spare-time bursts with potentially multiple weeks between updates. If the maintainers find this direction worth their review time at all, the request is for very low-frequency check-ins (e.g., "does this look like the right shape?" once per major file rewrite) — not sustained synchronous collaboration. If that cadence doesn't fit, that's totally fair — Greg will keep iterating in this repo regardless, and a future PR (if ever) will arrive only after the pre-work feels solid.

title Edge cases and their update rituals
status early draft
created 2026-05-17

AI 🤝 Greg: this is a first pass at enumerating cases the original issue under-stated. "Update ritual" is just shorthand for "what xpra needs to re-derive and re-send when this case happens" — geometry-out, pointer-inverse, both, or neither. Naming is throwaway.

The cases

Each case has: triggerwhat changesupdate ritual (what xpra must redo). Cases are ordered from "almost trivially handled by current infrastructure" to "needs explicit new design".

Case 1 — Window fits on the client; window center stays inside the same logical region.
  • Trigger: server moves/resizes a window, but after translation the window still fits inside the target local monitor and the mapping region selected for it does not change.
  • What changes: outgoing position only.
  • Update ritual: apply the active per-client transform on configure-out; apply inverse on any pointer event the client sends back. No re-selection of mapping, no re-negotiation.
  • Existing infra: calculate_window_offset() style adjustment is the closest precedent client-side; pointer inverse already exists for desktop-scaling. (See repo refs in ../sources/xpra-source-references.md.)
Case 2 — Window crosses a region boundary on the client.
  • Trigger: window's bounding rect now straddles two mapping regions (e.g. two physical monitors on the client, or two rect-to-rect zones the user defined).
  • What changes: "which mapping applies?" becomes ambiguous.
  • Update ritual: apply a documented tie-break rule — current candidates: (a) max-overlap area, (b) window center, (c) drag-origin sticky. Whichever rule is picked, apply it consistently for geometry-out and pointer-in (otherwise click locations and rendered window stop agreeing). When the active mapping changes, log it — these are the cases that are hardest to debug if silent.
Case 3 — Window larger than its target region (movement-interval = 0).
  • Trigger: server-side window is taller and/or wider than the local target rect (often the case for maximized server windows landing on a smaller client monitor).
  • What changes: no fully-visible position exists on the client.
  • Update ritual: see 02-axis-limit-and-movement-interval.md — model per-axis; when the movement-interval is empty pick a degenerate policy (clip-and-pin-to-origin, or scroll-pad with overflow marker, or decline-to-translate-and-warn). Must be policy-selectable; there is no "objectively correct" choice.
Case 4 — Server-side geometry changes (server randr / desktop resize / new monitor added on server side).
  • Trigger: virtual root size changes; mapping inputs are now stale.
  • What changes: every per-client mapping that referenced "server rect X" may now be out-of-bounds or unreachable.
  • Update ritual: invalidate computed transforms; re-derive them; re-send position for every visible window. Decision needed: do we re-center / re-fit windows that became unreachable, or leave them off-screen with a warning? Probably configurable; default to re-fit.
Case 5 — Client-side geometry changes mid-session (laptop docked, monitor unplugged, DPI change, lid closed).
  • Trigger: client's local monitor list changes.
  • What changes: mapping outputs now point to monitors that no longer exist, or new monitors are available.
  • Update ritual: same shape as Case 4 but from the client side. The client should re-derive per-client output geometry and (if the mapping was auto-derived from the local monitor layout) re-derive the mapping itself. This is a hot reload — windows should re-flow without reconnect.
Case 6 — Multiple clients attached simultaneously, each with its own mapping.
  • Trigger: sharing=yes with 2+ clients, each declaring a different per-client mapping.
  • What changes: server-side geometry is single-sourced, but every outgoing configure now fans out N copies through N transforms; every incoming pointer/keystroke from client K must use K's inverse only.
  • Update ritual: per-client transform state on the server source (the DisplayConnection already exists per client — natural home). Strict invariant: events from client K never use client J's inverse, even in races. Test plan: two clients, one drags a window, second client's pointer should not jump.
Case 7 — `--desktop-scaling` is also active on the client.
  • Trigger: user has both a uniform desktop-scaling factor and a per-client translation/mapping.
  • What changes: order of operations matters (translate-then-scale vs scale-then-translate produce different pixel results, and different pointer-inverse paths).
  • Update ritual: define one canonical order and document it; the internal pipeline must use the same order for outgoing geometry, outgoing cursor, incoming pointer, incoming touch, drag-and-drop hotspots, and popup-anchor coords. Easy to get wrong silently.
Case 8 — Override-redirect (OR) windows: popups, tooltips, drag previews.
  • Trigger: WM doesn't get to choose position; the app supplies it and expects it pixel-exact relative to its parent.
  • What changes: translation must preserve the parent-child offset, not treat the OR as an independent window.
  • Update ritual: translate parent first, then place child by recorded parent-relative offset; do not re-pick a mapping region for OR children — inherit parent's selected region. Cursor / tooltip positioning must use the same region as the window the cursor was last over, not the region containing the popup itself.
Case 9 — Drag-and-drop and pointer grabs crossing region boundaries.
  • Trigger: user starts dragging a window in region A, releases in region B.
  • What changes: mapping selection changes mid-gesture; if applied instantaneously, the window "teleports".
  • Update ritual: lock the active region for the duration of the active grab/drag; re-evaluate only after grab release. Same for keyboard focus during typing — the active region for input should not flip mid-keystroke.
Case 10 — Maximized / fullscreen windows.
  • Trigger: server says "maximize this window to the work area"; client has multiple monitors.
  • What changes: "the work area" is ambiguous — server's full virtual desktop, or one local monitor?
  • Update ritual: policy choice: (a) maximize-on-server, fit-to-target- region-on-client; (b) treat fullscreen as a per-window override that bypasses translation. Maintainer guidance especially welcome here — this is where the original --desktop-scaling arguments live.
Case 11 — Per-window-class rule overrides (option F).
  • Trigger: user defined "all Firefox windows go to monitor 2 at 1:1".
  • What changes: rule resolution is per-window, not per-client, and uses WM_CLASS / role / window-type metadata that is only known after window creation.
  • Update ritual: evaluate rule at window-create time; cache decision per window-id; re-evaluate on metadata change (apps do this for state, e.g. Chromium incognito vs normal can change instance). On rule re-evaluation that changes the mapping, treat as Case 2 (region change) with the same grab-locking rules.
Case 12 — Coordinate-system rounding accumulation.
  • Trigger: user drags a window 1px at a time across a non-1:1 mapping; forward + inverse roundtrip is not the identity.
  • What changes: the window slowly drifts over many small operations.
  • Update ritual: store the server-side authoritative position; never re-derive it from the round-tripped client value. (Related: deferred LCM note, 05-deferred-server-virtual-desktop-LCM.md — picking server virtual desktop as LCM of client widths reduces this loss but does not eliminate it.)

Cross-cutting concerns

  • Logging: every region change, every mapping invalidation, every degenerate-policy fallback should be log-visible at INFO so the user can see "ah, that's why my window snapped there".
  • Configuration surface: all of the above can be expressed as small policy knobs (tie-break=center|max-overlap|sticky, degenerate=clip|scroll|warn, drag-region-lock=on|off, dpi-scaling-order=translate-first|scale-first). Don't expose them all at once; expose only the ones a user can be expected to choose between, default the rest.
  • Negotiation: all of the above can be done client-only (per Greg's current bias), but several cases (Case 4, Case 6) become cleaner if the server is at least aware that a client uses translation, so it can emit re-configure events instead of silent drops. Open question for maintainers.
title Per-axis movement-interval and the degenerate limit
status early draft
created 2026-05-17

AI 🤝 Greg: this is the small mathematical kernel that came out of the brainstorming session. Most of the edge cases in 01-edge-cases-and-update-rituals.md reduce to "what happens when the movement-interval on at least one axis is empty?".

Definitions

For one axis (call it X; Y is symmetric and independent):

  • S — length of the server-side window along this axis.
  • T — length of the target region on the client along this axis (the monitor or rect-mapping region the window is being mapped into).
  • p_s — server-side position of the window (axis component).
  • f(p_s) — translated client-side position.

We define the movement-interval as the closed interval of valid client positions where the window is still fully visible inside the target region:

movement_interval(S, T) = [0,  T - S]            if S <= T
                       = empty                   if S >  T
  • When S <= T: there is a non-degenerate slot the window can occupy fully. All ordinary translation modes (offset, affine, rect-to-rect) reduce to picking some point in this interval.
  • When S == T: the interval is the single point {0}. The window only fits at one exact position. Any rounding-induced perturbation makes it not fit.
  • When S > T: the interval is empty. No fully-visible anchor position exists. This is the degenerate regime — and it is more common in practice than it sounds (server-side maximized window mapped to a smaller client monitor, large file dialog with hard-coded 1200×800 size, etc.).

Why per-axis matters

Width and height degenerate independently. A common real case is "the window is wider than the target but shorter than the target" — e.g., wide IDE on a narrow vertical-orientation monitor. Treating the case as "the window doesn't fit" globally loses information; treating each axis separately means we can say "horizontally degenerate, vertically fine — degrade horizontally only."

Degenerate-policy options (per axis, independently)

When the movement-interval is empty, we must make a choice. None of the choices is universally correct; this is a policy knob.

  • Clip-and-pin: place the window at 0 on the degenerate axis; the overflowing pixels are simply not visible. Cheapest, no protocol changes. Loses information silently — bad default for normal users, fine for power-users who set it deliberately.
  • Scroll-pad with overflow marker: render a thin visual indicator (badge/edge-arrow) that signals "this window extends past the visible region on this axis"; user can scroll it. Needs new client UI.
  • Decline-to-translate-and-warn: fall back to the current xpra behaviour for this window only (no translation), and log a warning. Best for "I don't know what to do" cases; preserves the existing baseline.
  • Anchor-and-rescale: as a last resort, shrink the window on the degenerate axis using the existing --desktop-scaling machinery — but only for windows that opted into rescaling. Risky for apps that hard-coded sizes.

Default proposal: decline-to-translate-and-warn as the safe default, because it never makes the user's life worse than today.

The continuous limit

If we treat S as continuous, the movement-interval length is max(0, T − S). As S → T⁻, the interval shrinks to a point, then disappears. The framing matters because:

  • It tells us this is a smooth degradation, not a categorical bug — there is no "magic threshold" we are crossing.
  • It tells us the test cases need to cover boundary values: S < T, S == T, S == T + 1px (just over), S == 2T (clearly over). These are the cases that catch off-by-one and "is the comparison < or <=?" bugs.

Composition with desktop-scaling

If both per-client translation and per-client uniform scaling are active, the relationship between S, T and the movement-interval needs an unambiguous order:

  • Translate-then-scale: T is in pre-scale coordinates. Movement-interval is computed in server coords; scaling applied last.
  • Scale-then-translate: T is in post-scale coordinates. Movement-interval is computed in client display coords; the same S reads as S * scale in the comparison.

These produce different answers in the degenerate regime; document which one we pick and stick to it everywhere (geometry, pointer, popups, OR windows).

Test-case suggestion

  • Generate (S, T) pairs sweeping S/T ∈ {0.5, 0.99, 1.0, 1.01, 1.5, 2.0} for each axis independently → 36 combinations. The first 20 most-likely-in- practice are the basis of 06-acm-icpc-style-testbed-plan.md.
title Combinatorial coverage matrix
status early draft
created 2026-05-17

AI 🤝 Greg: combinatorial coverage map across orthogonal dimensions of the problem. The point isn't enumerating every cell — it's making it obvious which subset of cells the first iteration should target, and which we are deliberately deferring.

Dimensions

Each independent axis along which a case can vary:

  • D1 — Number of client monitors: {1, 2, ≥3}
  • D2 — Heterogeneous clients? {no — only one client | yes — sharing=yes with ≥2 clients with different layouts}
  • D3 — DPI/scaling difference vs server? {1:1, uniform-scale-only, non-uniform-per-monitor}
  • D4 — Window vs region size: {fits | exactly-fits | overflows-1-axis | overflows-both}
  • D5 — Mapping spec source: {single-offset (A) | single-affine (B) | region-list (C/D) | per-class override (F)}
  • D6 — Event direction: {geometry-out (server → client) | pointer/keyboard-in (client → server) | both}
  • D7 — Mid-session change: {static session | client-side randr | server-side randr}
  • D8 — --desktop-scaling also active? {no | yes}

Coverage legend

  • fully covered in v1 — must work and have test cases.
  • 🟡 partially covered — works but with documented caveats / policy choice required from user.
  • deferred — out of v1 scope, will be addressed in a later milestone (typically the (C)/(D)/(F) extension or the LCM-server-resolution extension).
  • out-of-scope — explicitly not solved by this feature; user is expected to use a different mechanism (or accept the limitation).

Combinatorial subset for v1 (minimal-useful)

The goal of v1 is "works correctly for the most common pain case, fails gracefully and visibly for everything else". That subset is:

  • D1 = 1 (single client monitor)
  • D2 = no (one client at a time; sharing=yes with mixed clients is later)
  • D3 = 1:1 (no DPI scaling concurrent in v1)
  • D4 ∈ {fits, overflows-1-axis, overflows-both} (full axis coverage)
  • D5 = A (single offset+scale per client) only
  • D6 = both (geometry-out and pointer-in must match)
  • D7 = static (mid-session randr is v2)
  • D8 = no

Even this minimal subset gives us 1 × 1 × 1 × 3 × 1 × 1 × 1 × 1 = 3 primary test cases plus their boundary variants from 02-axis-limit-and-movement-interval.md.

The matrix (v1 status)

Notation: each bullet is "D1=X, D4=Y, D8=Z → status" — only changing the dimensions that matter for that row.

  • Single-monitor client, 1:1, fits, static, no scaling → ✅
  • Single-monitor client, 1:1, exact-fit (S==T), static, no scaling → ✅ (covers off-by-one in degenerate detector)
  • Single-monitor client, 1:1, overflows-1-axis, static, no scaling → 🟡 (degenerate-policy required; documented choice)
  • Single-monitor client, 1:1, overflows-both, static, no scaling → 🟡 (degenerate-policy applies on both axes)
  • 2-monitor client, mapping=A (single offset) → 🟡 (works but ignores the second monitor; user gets it by configuring rect-to-rect; v1 will print a hint)
  • 2-monitor client, mapping=C/D → ⏸ (v2)
  • Heterogeneous clients (D2=yes), v1 mapping=A on each → 🟡 (each client's translation is independent; per-client state already exists server-side)
  • DPI/scaling concurrent (D8=yes) → ⏸ (v2, after order-of-operations decision is reviewed)
  • Per-class override (D5=F) → ⏸ (v3)
  • Mid-session randr (D7≠static) → ⏸ (v2)
  • Native fullscreen behaviour → 🟡 (treated as bypass-translation in v1)
  • OR / popups / tooltips → 🟡 (inherit parent region — Case 8 of edge cases doc — v1 implements the inherit rule, no per-popup region selection)
  • DnD across regions → ⏸ (v2; v1 falls back to no-translation while a grab is active)
  • Cursor-only translation while no window is being moved → ✅ (just the inverse path on pointer events)
  • Sub-pixel rounding accumulation across many small moves → 🟡 (mitigated by server-authoritative position; not eliminated)

Out-of-scope items

  • Rotated/sheared mappings (rotation component in B) — ❌ for v1; can be added in v2 with the affine generalization.
  • Cross-client mirror / synchronized cursor — ❌; that's a different feature (issue #1369 cluster).
  • Per-client clipboard regioning — ❌; unrelated.
  • Server-side virtual desktop resolution chosen by LCM of client widths — ⏸ separate deferred discussion in 05-deferred-server-virtual-desktop-LCM.md.

Why this matrix matters

Several of the edge cases from the original issue's reply ("geometry is hard") sit in the ⏸ rows — i.e., we agree they exist and we are explicitly not promising to handle them in v1. The point of v1 is to not claim more than it does. The maintainer's concern about #3454 looks like it was partly a result of the older attempt promising too much; this matrix is the contract that says "no, just this small box, with this fallback".

title Mapping-model spectrum (A → F), revisited
status early draft
created 2026-05-17

AI 🤝 Greg: revisits the (A)→(F) spectrum from the issue body, but with the edge cases from 01-edge-cases-and-update-rituals.md filled in — i.e., which model is the smallest one that handles each case without ad-hoc patching.

Quick recap of the models

  • (A) Per-client (dx, dy, sx, sy) — 4 scalars.
  • (B) Per-client single 2D affine matrix.
  • (C) Per-client list of (region_rect_on_server, matrix) pairs.
  • (D) Per-client list of (server_rect → local_rect) mappings.
  • (F) Per-window-class layer that overrides any of the above.

(E from the issue was "hybrid/staged" — not a separate model, just a delivery strategy.)

Minimum-model table

Per edge case from doc 01:

  • Case 1 (fits, stable region) → minimum: A.
  • Case 2 (crosses region boundary) → minimum: C or D (A/B have no notion of regions to cross).
  • Case 3 (overflow / movement-interval empty) → orthogonal to choice of model; affects degenerate policy. Even A must handle it.
  • Case 4 (server randr) → orthogonal; all models need an invalidation hook.
  • Case 5 (client randr) → orthogonal; same.
  • Case 6 (heterogeneous clients) → minimum: A per client. Doesn't force C/D unless we want auto-derived mappings from each client's monitor list.
  • Case 7 (--desktop-scaling composition) → orthogonal; all models need a documented order.
  • Case 8 (OR / popups) → minimum: A (inherit parent's transform). C/D need explicit "OR inherits parent's region" rule.
  • Case 9 (DnD across regions) → only meaningful for C/D.
  • Case 10 (maximize / fullscreen) → policy, orthogonal. Worth a small override flag rather than a richer model.
  • Case 11 (per-window-class) → exactly F.
  • Case 12 (rounding drift) → orthogonal; mitigated by server-authoritative position.

So what's the smallest useful v1?

Option A only, with:

  • Documented degenerate policy (per axis).
  • A "this is a translated client" flag that the server can use to suppress unhelpful fits/maximize behaviour.
  • An invalidation hook for randr — even if v1 doesn't recompute, it should log "your mapping went stale, please reconnect" rather than silently break.

This is the most defensible thing to ship first because:

  • It composes additively with later models — (B), (C), (D) are all generalizations of (A), not replacements.
  • It is small enough to test exhaustively against the test suite in 06-acm-icpc-style-testbed-plan.md.
  • It does not promise solutions for cases 2/9/11, which are exactly where the maintainer's "geometry is hard" warning bites hardest.

What about (F) on top of (A)?

Per-window-class overrides are tempting because they look like a 20-line addition. The hidden cost is that the override has to be re-evaluated on metadata change, and metadata can change after window create (apps update WM_CLASS-ish state). So (F) is a separate feature on top, with its own test cases for "what if the rule changes mid-session". It is not something to bundle into the v1 PR.

Negotiation: hello-cap or pure client-side?

Greg's prior position was "purely client-side". After thinking about Case 4 / Case 6 / fullscreen, a tiny server-side hint flag (client has translation: bool) might be worth proposing — it lets the server make better defaults around fullscreen/maximize. This is one of the things to specifically ask the maintainer's preference on before any code lands.

title Deferred: server virtual desktop size as LCM/common-grid of client resolutions
status deferred — separate discussion
created 2026-05-17

AI 🤝 Greg: this is intentionally a separate note. The proposal in the rest of the gist assumes the server virtual desktop size is already given and the client translates into/out of it. The LCM idea below is an optimisation on top, not a prerequisite.

The idea

In an opt-in "translation-friendly server resolution" mode, xpra could pick the server virtual desktop size as a common multiple (e.g., least common multiple — LCM) of attached clients' display widths/heights. Rationale:

  • Client display widths in the wild are typically 1366, 1440, 1600, 1680, 1920, 2560, 3440, 3840, 5120. Mapping between two of them with integer ratios reduces accumulated rounding error.
  • If the server picks a common multiple, the translation f(p_s) = p_s * sx becomes an integer division when sx is rational with small numerator and denominator — clean even with no anti-aliasing.

Why it is not in v1

  • It tangles two independently-useful improvements: choosing-server-size and per-client-translation. Either can land without the other.
  • It interacts with every existing tool that sets XPRA_* resolution vars, with --start-after-connect, and with content that hard-codes pixel sizes. The blast radius is large.
  • The pain it addresses (sub-pixel drift) is real but secondary — Case 12 of 01-edge-cases-and-update-rituals.md is already mitigated by storing the server-authoritative position.

What v1 should do instead

Just declare the rounding rule explicitly:

  • Translations are computed in floating point.
  • Results are rounded half-away-from-zero for output, half-to-zero for inverse pointer mapping (so that "click a pixel" and "render that pixel" agree at the boundary).
  • The server-side authoritative position is never recomputed from the round-tripped client value (Case 12).

Linkages

  • If LCM-server-resolution ever lands, the per-client mapping configuration shape doesn't change — it just becomes simpler in many common cases (offset+integer-scale instead of offset+rational-scale).
  • This note exists mainly so that if the maintainer points at this and says "this is the part that's interesting", we can spin it out into its own issue with its own scope, instead of letting it bloat the v1 PR.
title External pre-work repo: ACM-ICPC-style I/O test suite
status early draft
created 2026-05-17

AI 🤝 Greg: the proposed external repository is structured the way a competitive-programming problem set is structured — each test case is a small input.json (the world state) and a small expected_output.json (the translation result), with a tiny driver that runs the pure-function candidate implementation and diffs the result. This is the pre-work that has to be done before any PR into xpra is sensible.

Repo shape (proposed)

xpra-coord-translation/
├── README.md
├── docs/
│   ├── model.md                          # imports from this gist
│   ├── edge-cases.md                     # imports from this gist
│   └── coverage-matrix.md                # imports from this gist
├── spec/
│   ├── inputs.schema.json                # JSON Schema for input files
│   ├── outputs.schema.json               # JSON Schema for expected outputs
│   └── policies.md                       # documented policy knobs and defaults
├── tests/
│   ├── 001-window-fits/
│   │   ├── input.json
│   │   ├── expected.json
│   │   └── README.md                     # 2-3 sentences describing the case
│   ├── 002-exact-fit/
│   │   ├── input.json
│   │   └── expected.json
│   ├── 003-overflow-x-only/...
│   ├── ...
│   └── 020-rounding-drift-roundtrip/...
├── reference/
│   └── coord_translation.py              # pure-function reference impl
├── runner/
│   ├── run.py                            # CLI test runner
│   └── diff.py                           # structural diff (with tolerance)
└── pyproject.toml

Input file shape (sketch)

{
  "server": {
    "geometry": {"w": 3840, "h": 2160},
    "windows": [
      {"id": 42, "x": 100, "y": 100, "w": 1280, "h": 720, "class": "firefox"}
    ]
  },
  "client": {
    "monitors": [
      {"name": "DP-1", "x": 0, "y": 0, "w": 1920, "h": 1080},
      {"name": "DP-2", "x": 1920, "y": 0, "w": 1920, "h": 1080}
    ]
  },
  "mapping": {
    "model": "A",
    "transform": {"dx": 0, "dy": 0, "sx": 0.5, "sy": 0.5}
  },
  "policies": {
    "degenerate_policy": "decline-and-warn",
    "scaling_order": "translate-then-scale"
  },
  "events_in": [
    {"type": "configure", "window": 42, "x": 200, "y": 200, "w": 1280, "h": 720},
    {"type": "pointer",    "client_x": 100, "client_y": 100, "window": 42}
  ]
}

Expected output file shape (sketch)

{
  "events_out": [
    {"type": "configure", "window": 42, "client_x": 100, "client_y": 100,
                                          "client_w":  640, "client_h": 360,
                                          "region": "DP-1",
                                          "degenerate": []},
    {"type": "pointer",    "server_x": 200, "server_y": 200, "window": 42}
  ],
  "warnings": []
}

Initial test catalogue (20 cases)

  • 001 — window fits, no scaling, single monitor.
  • 002 — window exact-fits (S==T) on X.
  • 003 — window exact-fits on Y.
  • 004 — window overflows X only (movement-interval-X empty).
  • 005 — window overflows Y only.
  • 006 — window overflows both.
  • 007 — window just-fits at +1px → not-fits boundary.
  • 008 — pointer event, identity transform → server == client.
  • 009 — pointer event, offset+scale → check inverse is exact for representative coords.
  • 010 — pointer event roundtrip → forward(inverse(p)) == p within rounding tolerance.
  • 011 — configure-out followed by pointer-in → both use the same active region.
  • 012 — popup/OR window — region inherited from parent.
  • 013 — DnD grab — region locked for duration of grab.
  • 014desktop-scaling order = translate-then-scale.
  • 015desktop-scaling order = scale-then-translate (same input → different expected output → asserts ordering matters).
  • 016 — two clients, two different mappings, same server event fans out.
  • 017 — server randr triggers mapping invalidation event.
  • 018 — client randr triggers mapping re-derivation.
  • 019 — fullscreen window bypasses translation.
  • 020 — per-class override (F) — Firefox windows pinned to DP-2.

The first ~10 are enough to validate v1 (option A). The last ~10 are the test bed for the follow-on milestones.

Why this is worth doing before a PR

  • It makes the conversation "do you agree about case 9?" concrete: we can point at tests/013-dnd-grab/input.json instead of debating prose.
  • It is implementation-agnostic — even if the maintainer wants to write the code themselves, the tests have value as documentation.
  • It is reusable: a future Wayland / RDP / NoMachine bridge could consume the same tests if it adopts a similar mapping abstraction.

Cadence

Greg's plan is to seed the repo with the first 5 tests and the reference impl in one sitting; subsequent tests grow as edge cases come up. Pace will be spare-moments — see 00-README.md for the realistic-cadence note.

title Library boundary — keeping the translation logic separable
status early draft
created 2026-05-17

AI 🤝 Greg: a small clarification on the "KISS" wording from Greg's earlier reply on the issue. "KISS" was poorly chosen — what Greg actually meant was modularity: the translation logic can live behind a tight, pure interface so it can be reasoned about, tested, and (later) reused — without sprinkling cross-cutting transform code throughout the xpra client.

The boundary (proposed)

A single Python module — let's tentatively call it coord_translation — with a pure functional API:

def translate_geometry(
    server_geom: ServerGeom,
    client_geom: ClientGeom,
    mapping: MappingSpec,
    policy: Policy,
    window: WindowDesc,
) -> TranslatedGeom: ...

def inverse_pointer(
    server_geom: ServerGeom,
    client_geom: ClientGeom,
    mapping: MappingSpec,
    policy: Policy,
    pointer_in_client: tuple[int, int],
    active_window_id: int | None,
) -> tuple[int, int]: ...

def invalidate_on_randr(
    old: ClientGeom | ServerGeom, new: ClientGeom | ServerGeom,
    mapping: MappingSpec,
) -> MappingSpec | None: ...
  • All inputs are plain dataclasses / TypedDicts — JSON-serialisable so test cases from 06-acm-icpc-style-testbed-plan.md can be exercised against this module directly.
  • No I/O, no GTK, no xpra imports — so it can ship as a stand-alone package, vendored into xpra, or pinned as a dependency.

What stays inside xpra

  • The integration glue: hooking translate_geometry into the configure-out path; hooking inverse_pointer into the pointer/keyboard send path; the CLI / env-var surface for --client-transform.
  • The randr listener that calls invalidate_on_randr and re-derives mappings.
  • Logging / user feedback for degenerate-policy fallbacks.

Why this shape (and what was wrong with "KISS")

  • "KISS" suggested that the right answer was to push the translation out of xpra entirely and let users compose external tools. The maintainer correctly pushed back: needing external tools to make this work for ordinary users is the opposite of KISS.
  • What was actually meant: the implementation of translation should be a self-contained module that can be reasoned about in isolation — delegatable, swappable, and shippable in xpra so users don't need external tools.
  • This satisfies both goals: (1) maintainer doesn't carry cross-cutting geometry code through the wider client; (2) users don't have to bolt on third-party things.

Re-distribution path

The library can live in three places without changes:

  • In-tree inside xpra/ if maintainers prefer.
  • Vendored as a third_party/ directory with a tag-locked snapshot.
  • External pip package consumed by xpra as a dependency.

Greg's preference is whichever the maintainers prefer. The external repo exists in any case as the pre-work / test-bed even if the production location ends up being in-tree.

What this does not propose

  • Not a separate process / IPC. The library is in-process Python.
  • Not a plugin/extension API for end users to drop in custom transforms. That can be added later if it makes sense; v1 doesn't need it.
  • Not a server-side equivalent. If the server ever needs its own version of these functions (Case 6 fan-out optimisation), it can import the same module.
title PR roadmap (only after design alignment)
status early draft — contingent on maintainer interest
created 2026-05-17

AI 🤝 Greg: this is the last doc to read. Nothing in here is a request to merge anything. It is a sketch of how the work would be split into PRs once we have alignment, so that each PR is small enough to review in spare time on both sides.

Milestones

M0 — Pre-work (external repo)

  • Seed xpra-coord-translation repo (or similar name) with:
  • Maintainer ask: none. This is Greg's pre-work to do in spare time. Maintainer review only welcomed if they have curiosity bandwidth.

M1 — Minimal client-only PR (option A)

  • Adds --client-transform="dx=…,dy=…,sx=…,sy=…" and matching env var.
  • Uses the pure-function coord_translation library (vendored or pip, per maintainer preference).
  • Touches only the geometry-out and pointer-in paths in the GTK3 client.
  • Server-side: zero changes (or a one-line hello-cap if maintainer prefers, see 04-mapping-models-spectrum.md).
  • Test coverage: tests 001–015 from the external repo run in CI.
  • Documentation: a single page added to docs/Usage/.
  • Behind a feature flag, off by default.

M2 — Region-aware mapping (options C and D)

  • Adds --client-monitor-map (rect-to-rect list).
  • Tie-break and DnD-grab-lock rules implemented.
  • Tests 011–016 from the external repo run in CI.
  • Still client-only.

M3 — Mid-session randr (Cases 4 & 5)

  • Invalidation/re-derivation hooks for both server and client randr.
  • Tests 017, 018.

M4 — --desktop-scaling composition (Case 7)

  • Documented ordering rule applied uniformly.
  • Tests 014–015 run as composition tests.

M5 — Per-class overrides (option F)

  • --client-window-rules consuming WM_CLASS / role / window-type.
  • Test 020.

M6 — Optional: server-side LCM resolution (separate proposal)

  • Spun out as a separate issue.

Cadence reality-check

Greg expects to land M0 entirely before any PR is opened. M1 will only be filed after maintainer confirmation that option A is the shape they want. Each milestone after that may take weeks of elapsed time between update and follow-up — this is a spare-time pet project.

If at any point the maintainers signal "this isn't the right direction after all", the work simply stops at the current milestone in the external repo. No PR pressure. Nobody has to politely close anything.

Explicit non-asks

  • No request for maintainer time during M0.
  • No request for fast review during M1–M5.
  • No expectation of any particular outcome.

The aim is to make the question "would a PR like this be welcome?" answerable from the external repo's state, not from prose.

title Proxy as a development vehicle for per-client coord translation
status early draft — feasibility ASK to maintainer
created 2026-05-18

AI 🤝 Greg: this file proposes an alternative deployment target for the per-client coordinate translation logic, with a clear yes/no feasibility question for the maintainer that should be answerable from existing protocol knowledge in 2–3 sentences. It is the primary ASK of the follow-up reply on issue #4883.

The premise

Xpra already ships an xpra proxy server mode (multi-user single-port routing, see xpra/server/proxy/). This means the protocol is already designed to be proxyable. The question this file asks is whether a coordinate-translating variant of that proxy pattern is feasible.

xpra-client  ──►  coord-translation-proxy  ──►  xpra-server
              ◄──                           ◄──

The proxy passes most packets through opaquely (pixel/audio/clipboard streams are not decoded), and intercepts only the packet types that carry geometry — applying the per-client (dx, dy, sx, sy) transform on the way out and its inverse on incoming pointer/keyboard events.

Why the proxy framing is attractive

  • Composability — proxies daisy-chain. A user could run translation-proxy → recording-proxy → real-server for debugging, or A/B-test two different translations side-by-side, with no changes to xpra itself.
  • Community fork velocity — a small standalone proxy repo is cheap for anyone in the community to fork, modify, and try. The fork-maintenance cost of a small focused proxy is vastly lower than the fork-maintenance cost of the xpra mono-repo.
  • Opt-in by construction — users only get translation if they explicitly route through the proxy. Nothing in the un-proxied path changes.
  • Clean test boundary — the proxy's wire-protocol input/output is exactly what we want to unit-test and fuzz-test. The ACM-ICPC-style I/O tests from 06-acm-icpc-style-testbed-plan.md map directly onto proxy-level tests with no glue layer.
  • Lower maintenance burden on xpra core — until the feature matures in the proxy and the maintainers decide they want it upstream, the main repo carries zero risk and zero new code.
  • Maturation pipeline — features prove themselves in proxy form first; only the proven, mature subset gets considered for upstream. Community half-baked prototypes can live in proxy forks without polluting the main project's history.
  • Connection to the earlier "KISS / composability" exchange — the proxy framing happens to be a concrete embodiment of the composability argument at the architectural level (rather than the end-user level the original "KISS" wording got pushed back on). Whether or not the word "KISS" fits is moot if we agree that "translation logic in a separate process behind a clean wire-protocol boundary" is a reasonable shape.

Risks / unknowns — need maintainer's gut-check

These need protocol-depth knowledge to assess; Greg can't:

  • How many packet types implicitly carry geometry? Obvious ones (configure, window-resized, pointer-position) plus likely others: drag-and-drop hotspots, OR window parent-relative coords, cursor-position notifications, popup anchor coords, window metadata, screen_sizes / monitors arrays in hello caps, randr notifications. If small set (~5–10), proxy is tractable. If sprawling, the proxy must decode every packet to classify it, and the benefit shrinks.
  • Performance — proxy must pass through pixel/audio packets opaquely (no decode) to keep overhead minimal. If decode is forced, bandwidth-sensitive workloads will feel it.
  • Encryption — TLS / SSH-tunnelled / encrypted-password modes require the proxy to terminate the secure channel and re-encrypt upstream. Real but bounded engineering cost.
  • Capability negotiation — proxy must participate in the hello exchange to know what features are negotiated, and likely rewrite screen_sizes / monitors / desktop_size so the server sees the post-translation client geometry.
  • Version drift — new geometry-carrying packet types in xpra silently break the proxy unless it has a strict-mode that errors on unknown packet types.
  • Three-party debuggingclient / proxy / server traces harder than client / server traces. Mitigated by good logging.

The single clear ASK to the maintainer

Given xpra already has a proxy-server mode, is the protocol amenable to a coordinate-translating proxy variant — i.e., something that intercepts and rewrites geometry-carrying packets in both directions while passing the rest through opaquely? Even a gut-check yes / mostly-yes-but-watch-out-for-X / no, the protocol doesn't factor that way would help enormously before I sink spare-time months into one direction over the other.

Fallback if the answer is "no"

Originally-discussed in-tree minimal client patch (option A from the issue body): per-client (dx, dy, sx, sy), applied in the GTK3 client's existing calculate_window_offset + cp()/cx()/cy() paths. Everything in this gist still applies — the test cases, the edge-case catalogue, the mapping-model spectrum — just deployed inside the client instead of a separate process.

Power-user opt-in extension (further deferred)

If the proxy direction is feasible, a natural extension (much further out) is to allow power users to plug their own transformation logic into the proxy as a script or WASM module. The proxy's clean wire- protocol boundary makes this kind of extensibility tractable in a way that in-tree implementation does not. Mentioned here only because it slots architecturally; explicitly not part of v1, v2, or v3.

Cadence

Same as the rest of the gist: spare-moments work, weeks-to-months between active sessions. The proxy framing happens to be especially tolerant of long async cycles because nothing in the main xpra repo is blocked on it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment