Skip to content

fix: iced-migration bug closeout sweep (plan 014) - #301

Merged
charliek merged 7 commits into
mainfrom
feature/plan-014-iced-bug-closeout
Aug 5, 2026
Merged

fix: iced-migration bug closeout sweep (plan 014)#301
charliek merged 7 commits into
mainfrom
feature/plan-014-iced-bug-closeout

Conversation

@charliek

@charliek charliek commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Pass 2 of the iced bug closeout (brief BRIEF-013; pass 1 was PR #298). Closes #295, #252, #287, #282, #283. Six commits, each independently gated.

What changed

#295fix(iced): sidebar-grip presses anchor at the last event-carried move. iced's ButtonPressed carries no position and a drained batch reports one batch-newest cursor, so a seam press during motion was hit-tested where the pointer ended up afterwards. The grip records the last CursorMoved's own position and gates presses on it (cleared on CursorLeft; never arms from an unavailable cursor). Scope narrowed during adversarial review: the ReorderStrip half cannot use this fix — both strips sit under iced scrollables, which pass children a translated cursor but the raw event, so an event-position anchor is off by the scroll offset — split to #300 with the analysis. The harness dwell that existed solely as the workaround is removed; the shed cage run (dwell-free) is the efficacy proof.

#252fix(gtk): asymmetric paned grab zone. 4px into the sidebar / 2px terminal side (selection still wins). 4px, not the issue's 6-10px: the overlay scrollbar's hovering interactive column (~14-16px, verified against the compiled Adwaita stylesheet) sits flush at the seam, and wider bands steal proportionally more of it. A follow-up commit replaced the shed-disproven screen probe with a unit-pinned pure predicate (paned_claims_press, four boundary points) — a fixed screen-coordinate probe can't discriminate the widening under unknown CSD margin.

#287feat(mac): window_metrics parity fields. terminal_top (flip-correct: content-view height minus converted maxY, not origin.y) + terminal_font_family via a new terminalMetrics() UI-bridge accessor mirroring sidebarMetrics(). XCTest pins the flip derivation (including a guard proving origin.y math would fail); test_sidebar_resize.py asserts the fields cross-target; ipc.md updated.

#282fix(drop): control-char predicate convergence + Swift URL filtering. Both languages reject the identical 8-scalar class (Swift's isNewline classes + ESC) on path AND URL branches; Swift's previously-unfiltered dragged-URL branch now rejects (reject-don't-strip; rejected URL falls to the deliberately-unfiltered string baseline owned by #280's mitigations — pinned explicitly by a dual-flavor documenting test on both sides). Rust gains the parity URL branch (production-dead until a toolkit surfaces URL drops; flagged). Cross-pinned twin vectors for every class in both test suites.

#283refactor(gtk): last clippy debt + gate tightening. ipc_window_metrics returns the wire struct instead of the 6-tuple (the alias + iced reply site unified on it); with roost-linux clippy-clean, gtk-build's narrow denylist step becomes the same full -D warnings gate as every other crate (the #236/#234 disallowed guards are warn-by-default — nothing is lost; clippy.toml remains the source of truth). Both panel reviewers independently confirmed this was the only remaining warning.

Issue closeouts without code

Verification

Risk notes

Plan 014 (full text)
# Plan 014 — iced-migration bug closeout sweep (pass 2)

Status: panel-reviewed (GLM 5.2 + CodeRabbit, 2026-08-05; Codex
unavailable — usage limit until Aug 8). Panel corrections incorporated:
#295 anchor must reach the zone/target computation and be recorded
before early returns (both reviewers, High); #295 efficacy premise made
explicit with a verification bail-out (GLM); **#297 re-dispositioned
from "port" to "park with findings"** after CodeRabbit's idle-fuse
analysis (M2) held up against the strip's own zero-frame-guard comment;
#252 narrowed to 6px + scrollbar verification + probe-range scan
(CodeRabbit H2/M3); #287 reclassified as plumbing + coordinate-flip
formula pinned (GLM / CodeRabbit M4); doc/comment touches added to the
file map (both); #283 "zero other warnings" independently confirmed by
BOTH reviewers running clippy.
Scope brief: `~/.claude/plans/roost/BRIEF-013-iced-bug-closeout.md`
(pass 2). Pass 1 (plan 013, #292) merged: PR #298 → main b37a94e.
Delegated authority (Charlie, 2026-08-04, recorded in the brief): bugs
judged invalid, stale, or not worth fixing may be closed outright with
a reasoned comment.
Issues: #295 #287 #252 #283 #282 (fix); #297 (park with findings);
#250 (commented, left open — done 2026-08-05); #275 (untouched, stretch
condition not met).
Artifacts: `~/.claude/plans/roost/014-iced-bug-closeout/`

## 1. Problem / motivation

Open bugs from the iced-migration era. The brief triaged them; this
plan pins fixes after code-grounded discovery and a two-reviewer panel.
One PR, five commits, each independently shippable.

## 2. Current state — verified this session (2026-08-04/05)

* **#295**: `SidebarResizeGrip` computes `in_zone` in `update()` from
  the **batch** cursor (`over_seam(layout, cursor)`,
  `crates/roost-iced/src/sidebar_resize.rs:204-209`) and `owns_event`
  short-circuits `ButtonPressed` on it (`cursor.filter(|_| in_zone)`,
  :102). `ReorderStrip` hit-tests presses via `cursor.position()``id_at` (`strip_reorder.rs:662-665`). The grip's `CursorMoved` arm
  prefers the event-carried position (`sidebar_resize.rs:111-122`);
  **the strip's does NOT** — it destructures `CursorMoved { .. }` and
  uses the batch cursor (:694-700). The strip's move handling sits
  behind early returns (`shell.is_event_captured()` :627-632, gesture
  check :695); its frame tick is deliberately "counted before every
  early return" (:622-625). No shared state between the widgets. The
  sole workaround is `time.sleep(0.5 * SCALE)` at
  `tools/input/linux/iced_clipboard_check.py:1616` (comment
  :1608-1615, commit 2671ba1), run as its own CI step (ci.yml:570).
* **#297**: terminal click counting is wall-clock-only
  (`ClickTracker::primary_press`, `terminal_widget.rs:251-268`,
  `MULTI_CLICK_INTERVAL = 500ms`). The strip's frame-aware logic
  (`press_continues_gesture`, `strip_reorder.rs:328-347`) grants grace
  only when `0 < frame_gap <= 4` AND `gap <= 1s`; its own comment
  (:337-341 + the constants' docs :311-314) explains the zero-frame
  guard: "an idle app renders nothing between presses… two slow clicks
  on an already-selected row (no redraw between them) must stay two
  clicks." **The terminal breaks that premise**: a click changes
  selection state and schedules a redraw, so by a second click 500ms-1s
  later the frame gap is 1-2 — the *normal idle* signature, not a
  stall. Porting the grace would fuse deliberate slow double clicks
  into word-selection in the common case (CodeRabbit M2, verified
  against the strip's comment).
* **#287**: Swift `windowMetrics` (`mac/Sources/Roost/
  IPCHandlerImpl.swift:995-1013`) returns
  windowWidth/windowHeight/sidebarWidth/sidebarCollapsed via
  `ui.sidebarMetrics()`; **no accessor from the IPC handler to the
  active TerminalView exists** (grepped; GLM confirmed) — the fields
  need a UI-layer accessor mirroring `sidebarMetrics()`. Wire type has
  both optional fields (`crates/roost-ipc/src/messages.rs:966-987`);
  `docs/reference/ipc.md:554-569` blesses omission and currently names
  iced as the only exact `terminal_top` reporter (doc touch needed).
  iced returns `Some(34.0)` + resolved family
  (`servicing.rs:716-731`); GTK returns `None` + `Some(family)`
  (`app.rs:5602-5617`). AppKit content views are unflipped:
  `frame.origin.y` measures from the bottom.
* **#252**: `tighten_paned_grab_zone` claims `pos - 2 ..= sep_end + 2`
  on **press** (capture-phase `GestureDrag`, `Claimed` in `drag_begin`,
  `crates/roost-linux/src/app.rs:5077-5094`; stale "±2px" comment at
  :5103-5108). The sidebar is a `ScrolledWindow` whose vertical
  scrollbar renders at the sidebar's right edge — inside any widened
  claim zone when the list scrolls (CodeRabbit H2). The cage-tier
  script probes the terminal side as a *range* (sb+1..sb+10,
  `tools/input/linux/real_input_check.py:501-552`) precisely because
  of CSD-margin drift.
* **#283**: exactly ONE `type_complexity` site and **zero other
  clippy warnings** in roost-linux (independently confirmed by both
  panel reviewers running `cargo clippy -p roost-linux --all-targets
  -- -D warnings`): `ipc_window_metrics`'s tuple return
  (`app.rs:5602-5604`). `rust-lint` excludes roost-linux + roost-iced
  (ci.yml:98); roost-iced has its own full `-D warnings` pass
  (ci.yml:513-516); roost-linux has only a narrow denylist in
  `gtk-build` (ci.yml:442-450). `clippy::disallowed_types/methods`
  are warn-by-default, so a full `-D warnings` pass subsumes the
  denylist (CodeRabbit verified; `clippy.toml` comments reference the
  narrow invocation — touch needed). Makefile mirrors the split
  (:152-160).
* **#282**: Rust `drop_content::resolve`
  (`crates/roost-ui-model/src/drop_content.rs:25-46`) rejects paths
  containing `['\n', '\r', '\u{1b}']`, no URL branch (paths → text).
  Swift `dropContentString` (`TerminalView.swift:1784-1808`): path
  branch rejects `isNewline || ESC` (superset: adds VT, FF, NEL, LS,
  PS); **URL branch unfiltered**; order paths → url → string.
  Cross-pinned vector precedent: `drop_content.rs:85``ShellEscapeTests.swift:113,130`. Call sites:
  `crates/roost-linux/src/terminal_view.rs:2525-2532`,
  `crates/roost-iced/src/app/interactions.rs:1494`.
* Gates: `make check-iced` = fmt-check + `cargo test -p roost-iced` +
  clippy `-D warnings` + dependency-boundary greps + the swash
  patch-wiring assert (Makefile:159-166). No new roosttest module in
  this plan → the ICED_E2E_TESTS/ci.yml enumeration gotcha does not
  fire. Local `make e2e-mac` is prohibited in this environment (the
  Mac app is single-instance; the harness would kill the production
  Roost.app that hosts this session) — Mac e2e proof lands on CI.

## 3. Design decisions — PINNED

### 3.1 #295 — event-anchored press hit-testing (iced)

**Implementation narrowing (review-driven, 2026-08-05)**: the strip half
was REVERTED during C1's adversarial review. Both `ReorderStrip`s are
direct content of iced scrollables (`app.rs:1605`, `:1741`), and
iced_widget 0.14's scrollable passes children a TRANSLATED cursor but
the RAW event (`scrollable.rs:795-812`) — so an anchor recorded from
`CursorMoved.position` is in window space while press hit-testing runs
in content space; wrong by the scroll offset whenever scrolled, with no
clean in-widget derivation of that offset. C1 therefore fixes the grip
only (not under any scrollable; spaces coincide), hardened per review:
the anchor is cleared on `CursorLeft`, and only ever replaces an
*available* batch cursor (a `Cursor::Unavailable` press stays a no-op).
The strip variant is re-filed as its own issue with this analysis; #295
still closes (its user-facing surface and the harness dwell are the
grip's). Original design below, applied grip-only:

Both widgets get `last_cursor: Option<Point>` in their `State`,
**recorded unconditionally at the top of `update()` before any early
return** (in the strip: adjacent to the frame tick, which already
documents that pattern) from the `CursorMoved` event-carried position.
The strip's move arm changes from `CursorMoved { .. }` to binding
`{ position, .. }` (it does not carry it today — plan corrected per
panel). The anchored point `state.last_cursor.or(cursor.position())`
must reach the actual gate/target computations:
* grip: `in_zone` in `update()` (:204-209) is computed from the
  anchored point, so `owns_event`'s short-circuit (:102) sees the
  anchored verdict;
* strip: the press arm's `id_at` lookup (:662-665) uses the anchored
  point.

Why this recovers the missed press (ordering argument, to be proven in
the shed): winit delivers events in receipt order and iced drains them
in order — only `cursor.position()` is collapsed to batch-newest. In
the failure sequence move(A) → press → move(B), the press is processed
after `CursorMoved(A)` set `last_cursor = A` and before `CursorMoved(B)`
overwrites it, so the press is hit-tested at A (where it physically
happened). A press before any observed move falls back to the batch
cursor — exactly today's behavior; the change is strictly monotone.

Unit tests must drive the real `update()` path — feed a
`CursorMoved(A)` event then a `ButtonPressed` while the mocked batch
cursor sits at B outside the zone — NOT pass a precomputed `in_zone`
(the existing test helper's shortcut, `sidebar_resize.rs:335-349`,
would prove nothing).

Efficacy proof = the shed cage run with the dwell removed
(`iced_clipboard_check.py:1616`). Bail-out (explicit): if the dwell-free
check still fails, add a temporary trace probe to test the ordering
premise; if the premise is wrong (moves coalesced before delivery),
restore the dwell, keep the unit-level improvement only if it stands on
its own, and park #295 with the findings on the issue.

### 3.2 #297 — PARKED, not ported (panel-driven re-disposition)

The port is rejected: the strip's grace branch is sound only where a
zero-frame gap separates deliberate slow clicks (its own comment says
so), and the terminal violates that — click one schedules a redraw, so
`frame_gap` 1-2 is the normal idle case and the grace would fuse
deliberate 500ms-1s clicks into word-selection: a common-case,
user-visible regression traded for a bug that has **never failed CI**
and reproduces only under artificial load (4 CPU hogs on 2 vCPUs). The
issue's own disposition is "ships as-is; port if it starts flaking."
Action: findings comment on #297 recording this analysis (so a future
port starts from the fuse tradeoff), issue stays open as
backlog-class. No code, no shared `gesture_timing` module (single
consumer remains — house style says don't hoist).

### 3.3 #287 — Mac window_metrics parity fields (plumbing included)

* New UI-layer accessor mirroring `sidebarMetrics()` exposing the
  active terminal view's geometry + font to the IPC handler (none
  exists today — this is the real work; C3 is plumbing plus two
  fields, not "two fields").
* `terminal_top` from real geometry with the AppKit flip handled:
  in unflipped content-view space,
  `terminal_top = contentView.bounds.height - terminalFrame.maxY`
  (or an `isFlipped`-aware conversion) — NOT `origin.y`, which yields
  a plausible wrong number. Expected ≈ tab bar height (32). Omit (nil)
  when no terminal view exists, per the optional wire contract.
* `terminal_font_family` = `TerminalView.font.familyName ??
  font.fontName` — the family in use, matching iced/GTK semantics.
* Swift tests (XCTest — swift-testing runner SIGABRTs on this Xcode
  for value-check suites): pin the flip derivation against a known
  constructed layout, not just "present and sane". If constructing a
  windowed TerminalView proves flaky headless, fall back to asserting
  the handler's nil-tolerance + the accessor's math on a stub, and
  rely on CI e2e-mac for the populated case.
* E2E parity tightening (no new module): `test_sidebar_resize.py`
  gains asserts — `terminal_font_family` non-empty string on every
  target; `terminal_top` > 0 when present (GTK legitimately omits).
* Doc touch: `docs/reference/ipc.md:565-567` ("iced is the only exact
  reporter") updated for Mac.

### 3.4 #252 — asymmetric GTK grab zone (6px, scrollbar-checked)

Bounds become `pos - 4 <= x <= sep_end + 2` (was ±2).
**Implementation finding (fallback taken)**: the sidebar ScrolledWindow
uses GTK4 default overlay scrollbars flush against the seam (no margin,
`app.rs:595-628`), and the compiled Adwaita stylesheet (extracted from
libgtk-4) gives the hovering/dragging scrollbar an interactive column
of ~14-16px from the edge — overlapping ANY widened band (and already
overlapped by the old ±2px). The plan's 4px fallback was taken: it
doubles the old sidebar-side grab width while stealing only ~2px more
of the ~16px scrollbar column (the scrollbar stays grabbable in the
remaining ~12px). The harness's test layout cannot scroll the project
list (~6 projects in a 1000px window), so the scrollbar-overlap
scenario is production-only and not probeable by the cage tier —
recorded here rather than silently. Update the stale "±2px" comment (:5103-5108). The
`pos + 6` sep_end fallback (:5087) stays untouched.
Verification probe in `real_input_check.py`: scan a sidebar-side range
`sep-6..sep-3` (mirroring the existing sb+1..sb+10 range pattern
against CSD drift — a single-offset probe would flake) asserting at
least one offset resizes; existing left-edge-selects checks unchanged.
If the sidebar scrolls in the test layout, add a scrollbar-drag sanity
(scroll position changes) — otherwise record that the layout has no
scrollbar and the risk is theoretical in the harness.
iced's ±3px grip explicitly unchanged.

### 3.5 #283 — type_complexity + lint-gate tightening (closing commit)

Named struct for `ipc_window_metrics`'s return (house style: name
things — not a `type` alias; returning
`roost_ipc::messages::WindowMetricsResult` directly is acceptable if
the call site composes cleanly; implementer picks; no tuple). Gate
tightening: `gtk-build`'s narrow denylist step (ci.yml:442-450)
becomes a full `cargo clippy -p roost-linux --all-targets --
-D warnings` (denylist subsumed — disallowed_types/methods are
warn-by-default and `clippy.toml` remains the source of truth);
Makefile mirrors (:152-160); stale comments updated in ci.yml,
Makefile, AND `clippy.toml`. `rust-lint`'s exclusion stays (no GTK
toolchain there; the iced job is the precedent). Commit message
records: both panel reviewers independently confirmed zero other
warnings, so this genuinely flips roost-linux clippy-clean.

### 3.6 #282 — drop-path predicate convergence + Swift URL filter

* One predicate, both languages: reject content containing any of LF,
  VT, FF, CR, NEL (U+0085), LS (U+2028), PS (U+2029), or ESC. Swift
  keeps `$0.isNewline || $0 == "\u{1b}"`; Rust upgrades its 3-char
  list to the explicit 8-scalar const. Doc comment phrasing: "aligned
  with Swift's `Character.isNewline` scalar classes + ESC" —
  scalar-level, since Swift filters grapheme clusters and Rust chars
  (Rust ends up strictly stricter on pathological clusters; acceptable
  direction, vectors stay plain scalars).
* Swift URL branch: same rejection as the path branch; a
  control-bearing URL is treated as absent and falls through to the
  `string` fallback (deliberately unfiltered baseline — #280's
  bracketed-paste mitigations own that boundary; reject, don't strip).
* Rust `resolve` gains the URL branch: `resolve(paths, url:
  Option<&str>, text: Option<&str>)`, order paths → url → text
  mirroring Swift. Both Rust call sites pass `None` today — the URL
  policy is production-dead-code until a toolkit surfaces URL drops;
  the doc comment justifies the parameter on cross-UI parity grounds
  and the commit message flags that its tests are the only exercise.
* Cross-pinned fixtures: every rejected class gets a vector in BOTH
  `drop_content.rs` tests and `DropContentResolverTests`
  (ShellEscapeTests.swift), reciprocal twin-naming comments, with
  explicit individual vectors for VT (U+000B) and FF (U+000C) — the
  two classes Rust newly rejects. New Swift tests: URL rejection +
  URL-falls-to-string.

### 3.7 Issue closeouts (no code)

* **#250**: commented + left open (done 2026-08-05 — needs a real
  pointer on an unlocked Mac).
* **#297**: findings comment (per §3.2), left open.
* **#275**: untouched; stretch condition (touching a catalogued
  roosttest file) not met — the dwell lives in `tools/input/linux/`.
* #295, #287, #252, #283, #282 close via the PR.

## 4. Deviations / non-goals

* #297 not fixed (parked with analysis — see §3.2); no
  `gesture_timing` module.
* No new roosttest e2e module; no Wayland/GTK URL-drop wiring; iced
  grip geometry unchanged; 3e polish, releases, facade (#286), M4,
  #275 catalogue untouched.

## 5. Work breakdown (one PR, branch `feature/plan-014-iced-bug-closeout`)

* **C1 — #295 press anchoring** (opus: gesture-state code, the
  panel-flagged plumbing)
  Both widgets per §3.1 (unconditional recording, strip move-arm
  binding, anchored zone/target computation) + real-path unit tests +
  dwell removal in `iced_clipboard_check.py`.
  Gate: `make check-iced`. Shed proof before push (§8).
* **C2 — #252 asymmetric zone** (sonnet: bounds + comment + probe)
  §3.4: bounds, scrollbar verification, stale comment, probe-range
  scan in `real_input_check.py`.
  Gate: `cargo test -p roost-linux` + narrow clippy (pre-C5 state:
  `-A warnings -D clippy::disallowed_types -D
  clippy::disallowed_methods`) + `make fmt-check`.
* **C3 — #287 Mac fields** (sonnet: accessor plumbing + two fields +
  tests + doc)
  §3.3 incl. the UI-layer accessor, flip-pinned Swift tests,
  `test_sidebar_resize.py` asserts, ipc.md touch.
  Gate: `cd mac && swift build && swift test` + `make fmt-check`.
* **C4 — #282 predicate convergence** (opus: security-adjacent,
  cross-language)
  §3.6 both languages + fixtures.
  Gate: `cargo test --workspace` (shared crate) +
  `cd mac && swift build && swift test`.
* **C5 — #283 lint closeout** (sonnet: mechanical, closing commit)
  §3.5. Gate: `cargo clippy -p roost-linux --all-targets --
  -D warnings` + `cargo test -p roost-linux` + `make fmt-check`.

## 6. File map (indicative)

* `crates/roost-iced/src/sidebar_resize.rs`, `strip_reorder.rs`
* `tools/input/linux/iced_clipboard_check.py`, `real_input_check.py`
* `crates/roost-linux/src/app.rs` (zone bounds + comment; metrics
  struct)
* `crates/roost-ui-model/src/drop_content.rs`;
  `crates/roost-linux/src/terminal_view.rs`,
  `crates/roost-iced/src/app/interactions.rs`
* `mac/Sources/Roost/IPCHandlerImpl.swift`, `TerminalView.swift`,
  + the UI layer file exposing the new accessor (near
  `sidebarMetrics()`); `mac/Tests/RoostTests/ShellEscapeTests.swift`
  + metrics test home
* `tools/roosttest/test_sidebar_resize.py`
* `docs/reference/ipc.md`, `clippy.toml` (comment), `Makefile`,
  `.github/workflows/ci.yml`

## 7. Acceptance criteria

1. (#295) Unit tests prove the position source: a press with a stale
   batch cursor + fresh event-carried move position is hit-tested at
   the event position, through the real `update()` path (both
   widgets). Regression guard: `iced_clipboard_check.py` green on CI
   without the dwell; efficacy proof: same check green in the shed
   pre-push.
2. (#252) Cage tier proves: left-edge drag still selects; at least
   one sidebar-side offset in `sep-6..sep-3` resizes; scrollbar
   verification recorded per §3.4.
3. (#287) Mac `app.window_metrics` returns both fields with the
   flip-correct `terminal_top`; `test_sidebar_resize.py`'s asserts
   pass on gtk, iced, AND mac CI suites.
4. (#282) Both languages reject the identical 8-scalar class on path
   AND URL branches; cross-pinned vectors for every class both sides
   (VT + FF explicitly); Swift URL rejection falls through to string.
5. (#283) `cargo clippy -p roost-linux --all-targets -- -D warnings`
   green locally and in `gtk-build`; Makefile + clippy.toml comments
   updated; no tuple return.
6. Issues #295 #287 #252 #283 #282 closed by the PR; #297 commented
   (park) + left open; #250 commented + left open (done).

## 8. Verification plan

* Automated: unit tests per commit + full CI matrix (iced 2×2,
  e2e-gtk tiers, cage/uinput real-input steps, e2e-mac).
* Shed (linux-test skill; VM roost-dev stopped — shed-test.sh restarts
  it; STOP it when done): before pushing, run the cage tier —
  `iced_clipboard_check.py` with the dwell removed (#295 efficacy) and
  `real_input_check.py` with the new sidebar-side probe (#252) —
  artifacts to `~/.claude/plans/roost/014-iced-bug-closeout/`.
* Mac: swift unit tests (flip derivation, predicate vectors); CI
  e2e-mac exercises the IPC surface. No local e2e-mac (§2 last
  bullet); the Mac is locked — dumps only, no screenshots.

## 9. Risks / open items

* #295 rests on the within-batch ordering premise (§3.1); the shed run
  is the arbiter and the bail-out is pinned (restore dwell, park with
  findings). First-press-before-any-motion stays batch-anchored
  (accepted: the bug requires motion by definition).
* #252 widens a press-claim into scrollbar territory — 6px + the
  verification step + 4px fallback bound the risk; the cage probe
  guards both sides of the contract.
* #287's accessor is new plumbing on the Swift side; the flip formula
  is pinned to prevent the plausible-wrong-number trap; headless
  test-construction fallback documented.
* #282's Rust URL branch is production-dead until a toolkit surfaces
  URL drops — tests are its only exercise (flagged in the commit).
* #297 stays wall-clock-only by decision — the latent stall fragility
  remains and is re-documented on the issue with the fuse analysis.
* clippy tightening can newly fail `gtk-build` if a warning lands
  between panel verification and merge — the closing-commit gate
  catches it locally first.

## § Verified (2026-08-05, post-implementation)

* **Amendment to §3.4 / AC2**: the sidebar-side screen probe shipped in
  C2 was proven wrong-by-design by the shed run (unknown CSD margin —
  see `014-iced-bug-closeout/shed-verification.md`); commit 2b71995
  removed it in favor of a unit-pinned pure predicate
  (`paned_claims_press`, all four boundary points). AC2 is satisfied
  by: cage tier proves left-edge drag still selects + seam resizes
  (drift-tolerant), unit test pins the asymmetric bounds
  (deterministic).
* Shed cage tier (VM roost-dev, stopped after use):
  `iced_clipboard_check.py` PASS with the #295 dwell removed (the
  efficacy proof — seam-grip drag clean); `real_input_check.py` PASS
  after the probe redesign. Details + captured output:
  `014-iced-bug-closeout/shed-verification.md`.
* Automated: `make check-iced` green per iced commit;
  `cargo test -p roost-linux` + full `-D warnings` clippy green (the
  tightened gate); `cargo test --workspace` green after C4/C5 (45
  suites); `cd mac && swift build && swift test` green after C3/C4
  (693 swift-testing + XCTest incl. 7 new WindowMetrics + 15
  DropContentResolver tests).
* Known-unexercised: Mac e2e runs on CI only (single-instance
  constraint); the #252 scrollbar-overlap scenario is production-only
  (harness list can't scroll — recorded in §3.4); the #282 Rust URL
  branch is production-dead by design (tests are its only exercise).

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLKWsLV45DAk61xG6Utj3e

Summary by CodeRabbit

  • New Features

    • Improved sidebar resizing with a more responsive drag area and smoother pointer tracking.
    • Window metrics can now report terminal position and font information when available.
  • Bug Fixes

    • Improved handling of dropped files, URLs, and text, including safer treatment of potentially unsafe control characters.
    • Added fallback behavior for unsupported or invalid drop content.
  • Documentation

    • Clarified window-metrics responses and when terminal information is available.
  • Tests

    • Expanded coverage for sidebar resizing, drag-and-drop handling, and terminal metrics.

charliek and others added 6 commits August 4, 2026 23:18
iced's ButtonPressed carries no position, and iced_winit drains an
event batch with a single batch-newest cursor — so a seam press made
during pointer motion was hit-tested at a position the pointer only
reached after the press, missing the ±3px grip zone (issue #295). The
grip now records the last CursorMoved's own position (events process
in receipt order, so at press time it holds where the pointer was when
the press fired) and gates presses on that anchor, falling back to the
batch cursor before any observed motion. Hardened per review: the
anchor drops on CursorLeft, and only ever replaces an available batch
cursor, so an unavailable-cursor press stays a no-op.

The ReorderStrip half of #295 is deliberately NOT fixed: both strips
sit under iced scrollables, which pass children a translated cursor
but the raw untranslated event, so an event-position anchor is wrong
by the scroll offset — split to #300 with the analysis.

The real-input harness dwell that existed solely as the workaround
(iced_clipboard_check.py, commit 2671ba1) is removed; the cage-tier
run now proves the fix. Grip unit tests drive the real update() path
(the precomputed in_zone test shortcut is gone) — negative controls
confirmed each new test fails with its guard reverted.

Cursor review: strip coordinate-space + anchor-staleness findings →
strip reverted, grip hardened (this shape). Simplify pass: no changes.

Closes #295 (plan 014 C1)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLKWsLV45DAk61xG6Utj3e
PR #251's replacement grab zone was symmetric ±2px — tight enough to
stop the paned stealing column-0 terminal selections, but too narrow
to grab reliably ("resizing seems to only work sometimes"). The two
goals only conflict on the terminal side, so the zone is now
asymmetric: 4px into the sidebar, 2px past the separator's terminal
edge (unchanged, selection still wins).

4px, not the 6-10px the issue floated: the claim fires on press
(capture phase), and the sidebar ScrolledWindow's overlay scrollbar
sits flush against the seam with a hovering interactive column of
~14-16px (verified against the compiled Adwaita stylesheet) — wider
bands steal proportionally more of it. 4px doubles the grabbable
sidebar-side width while leaving ~12px of scrollbar reachable. The
harness layout cannot scroll the project list, so scrollbar overlap
is a production-only tradeoff, recorded in plan 014.

The cage-tier check gains a sidebar-side range probe (sep-6..sep-3,
mirroring the terminal side's drift-buffer scan): at least one offset
must resize, then the seam is restored from a press guaranteed inside
the new band (review finding: undoing from the original press offset
missed the band after drift-shifted hits).

Cursor review: probe-undo drift bug fixed; stale fn-doc comment
updated; bounds math confirmed clean.

Closes #252 (plan 014 C2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLKWsLV45DAk61xG6Utj3e
The Swift app now answers the two optional app.window_metrics fields
both Rust UIs already report (#278 added them; the Mac omission was
the parity gap). A UiBridge terminalMetrics() accessor mirrors
sidebarMetrics(), delegating to a pure static function so the
geometry contract is unit-testable headless: terminal_top is the
active terminal view's offset from the content view's TOP
(bounds.height - converted maxY — AppKit content views are unflipped,
so origin.y alone would be the bottom offset), and the family is the
NSFont actually in use (familyName over fontName), matching the
resolved-family semantics on iced/GTK. Both fields omit cleanly when
no terminal is mounted, per the optional wire contract.

XCTest coverage pins the flip derivation against constructed layouts
(including a regression guard proving origin.y-based math would fail)
plus nil-tolerance; test_sidebar_resize.py now asserts the family is
a non-empty string on every target and terminal_top is positive when
present (GTK legitimately omits it). ipc.md updated.

Cursor review: no findings (convert() flip handling verified
empirically, accessor matches the codebase's active-tab pattern,
nil-omission confirmed on the wire).

Closes #287 (plan 014 C3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLKWsLV45DAk61xG6Utj3e
…ranch

Security-adjacent closeout of the drop-path divergence: both languages
now reject dropped paths AND dragged URLs carrying any of LF, VT, FF,
CR, NEL, LS, PS, or ESC — Swift's Character.isNewline scalar classes
plus ESC, expressed in Rust as an explicit 8-scalar const (Rust ends
up strictly stricter on pathological grapheme clusters, which only
ever rejects more). Swift's dragged-URL branch — previously completely
unfiltered, letting escape bytes reach the PTY — now applies the same
predicate as the path branch; a rejected URL is treated as absent and
falls through to the plain-string fallback, which stays deliberately
unfiltered (reject, don't strip: #280's bracketed-paste mitigations
own that boundary).

Rust resolve() gains the equivalent url branch (paths → url → text,
mirroring Swift) for cross-UI parity; both Rust call sites pass None —
no toolkit surfaces a distinct URL drop payload yet, so the branch is
production-dead and its tests are its only exercise (by design).

Every rejected class has cross-pinned twin vectors in both test
suites, with individual VT/FF vectors on the Rust side (the classes it
newly rejects). Adversarial review's one finding is pinned as a
documenting test on both sides: a drag populating .URL and .string
with the same control-bearing text reaches the PTY via the string arm
— the accepted #282 baseline, made explicit rather than incidental.

Closes #282 (plan 014 C4)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLKWsLV45DAk61xG6Utj3e
…n lint gate

The last clippy type_complexity in roost-linux was ipc_window_metrics's
six-field tuple return. Instead of a type alias over the same tuple,
the function (and the shared WindowMetricsReply channel in roost-engine)
now carries roost_ipc's WindowMetricsResult directly — the wire struct
already names every field, and the dispatch arm's destructure-and-
rebuild step disappears (encode(&result), mirroring SidebarDump). The
iced handler builds the struct at its reply site for the same reason.

With roost-linux clippy-clean, the gtk-build job's narrow denylist
step (-A warnings -D disallowed_types -D disallowed_methods) becomes
the same full -D warnings gate every other crate gets; Makefile
mirrors. The GtkDnD (#236) and grab_focus (#234) guards lose nothing:
both disallowed lints are warn-by-default, so -D warnings still
enforces them, and clippy.toml keeps the full rationale as the source
of truth. rust-lint's exclusion stays (no GTK toolchain there).

Both panel reviewers independently confirmed this was the sole
remaining warning, so the tightened gate is green from this commit on.
Cursor CLI returned empty on review (flaking); self-review verified
field mapping against the old positional order at all three
construction sites, identical wire JSON via the shared struct, YAML
validity, and make clippy end to end.

Closes #283 (plan 014 C5)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLKWsLV45DAk61xG6Utj3e
…g it

The shed cage run proved C2's sidebar-side probe wrong by design: the
separator's screen x carries an unknown CSD margin (the reason the
terminal-side scan probes a range), so a fixed sb-relative range
misses the 4px sidebar-side band for most margin values — a
screen-coordinate probe cannot discriminate the widening. The probe is
removed; the claim bounds are extracted into a pure
paned_claims_press(x, pos, sep_end) and unit-pinned at all four
boundary points (claims pos-4 / sep_end+2, denies pos-5 / sep_end+3),
which fails deterministically on any revert to a symmetric zone. The
cage tier keeps its drift-tolerant checks: seam resizes, left-edge
drag still selects.

Refs #252 (plan 014 C2 follow-up; shed verification finding)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLKWsLV45DAk61xG6Utj3e
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds structured terminal metrics to app.window_metrics, strengthens drop-content safety and parity, revises sidebar resize hit-testing, removes a resize-test delay, and changes GTK Clippy checks to deny all warnings.

Changes

Roost functional updates

Layer / File(s) Summary
Drop-content safety and parity
crates/roost-ui-model/src/drop_content.rs, crates/roost-iced/src/app/interactions.rs, crates/roost-linux/src/terminal_view.rs, mac/Sources/Roost/TerminalView.swift, mac/Tests/RoostTests/ShellEscapeTests.swift
resolve now accepts paths, URLs, and text. It rejects specified control characters, preserves precedence, shell-escapes safe URLs, and falls back to text. Rust and macOS tests cover the updated behavior.
Structured window metrics contract
crates/roost-engine/src/ipc.rs, crates/roost-iced/src/app.rs, crates/roost-iced/src/app/servicing.rs, crates/roost-linux/src/app.rs, mac/Sources/Roost/App.swift, mac/Sources/Roost/RoostBackend.swift, mac/Sources/Roost/IPCHandlerImpl.swift, docs/reference/ipc.md, mac/Tests/RoostTests/WindowMetricsTests.swift, tools/roosttest/test_sidebar_resize.py
Window metrics use WindowMetricsResult with optional terminal position and font-family fields. macOS derives these values from the mounted terminal view. Documentation and tests cover availability and validation.
Sidebar resize event handling
crates/roost-iced/src/sidebar_resize.rs, crates/roost-linux/src/app.rs, tools/input/linux/iced_clipboard_check.py, tools/input/linux/real_input_check.py
The Iced resize grip uses cursor history and layout-based seam detection. GTK uses a 4 px sidebar-side and 2 px terminal-side grab zone. Tests cover boundaries, delayed presses, pointer exit, and missing cursors.
Full GTK warning gate
.github/workflows/ci.yml, Makefile, clippy.toml
CI and local checks run cargo clippy -p roost-linux --all-targets -- -D warnings. Configuration comments document this enforcement path.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • charliek/roost#294 — Shares the sidebar-resize implementation, GTK paned-grip handling, and end-to-end resize coverage.
  • charliek/roost#237 — Shares changes to GTK Clippy enforcement in CI.
  • charliek/roost#239 — Shares the GTK lint configuration and roost-linux Clippy checks.

Sequence Diagram(s)

sequenceDiagram
  participant IcedApp
  participant RoostLinux
  participant UiBridge
  participant IPCHandler
  IcedApp->>RoostLinux: request app.window_metrics
  RoostLinux->>UiBridge: obtain terminal metrics
  UiBridge-->>RoostLinux: terminal top and font family
  RoostLinux->>IPCHandler: encode WindowMetricsResult
  IPCHandler-->>IcedApp: optional terminal metrics response
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately identifies the pull request as a closeout sweep for iced-migration bugs, matching the broad multi-issue changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/plan-014-iced-bug-closeout

Comment @coderabbitai help to get the list of available commands.

…eview)

Compensating-review findings applied (the PR's CodeRabbit check was
rate-limited): the terminal_top e2e assert now distinguishes an
omitted key from an explicit null (the wire contract is omit-not-null,
and serde emits null for non-finite f64s — exactly the arm .get()
flattened away) and requires a finite value; the parity asserts moved
after _live_tab so the Mac fields are read with a terminal mounted
instead of racing the async first-tab spawn; ipc.md no longer
overstates terminal_font_family as unconditional on Mac; the grab-zone
doc names the overlay-scrollbar tradeoff the 4px bound exists for; the
grip comment records the accepted overlay-staleness window; clippy.toml
quotes the gate verbatim.

Dispositions: scrollbar overlap remains the plan-recorded accepted
tradeoff (now stated at the code site too); XCTest-vs-swift-testing
findings declined per the documented Xcode 26 SIGABRT constraint.

Refs #287 #252 #295 (plan 014, post-review)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLKWsLV45DAk61xG6Utj3e

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/roost-iced/src/sidebar_resize.rs`:
- Around line 98-120: Invalidate state.last_cursor at overlay boundaries so
cursor movement consumed by an iced overlay cannot leave a stale anchor for the
next press. Update the sidebar resize event/state flow around the existing
anchored cursor logic to recognize the overlay transition and fall back to the
current batch cursor, preserving normal in-grip movement behavior. Add coverage
for seam movement, overlay pointer movement, and a subsequent terminal-side
press ensuring no resize gesture starts from the stale position.

In `@crates/roost-linux/src/terminal_view.rs`:
- Around line 2531-2532: Update the drop handling around the String branch in
the terminal view to inspect the GdkDrop/URI MIME type explicitly: read
text/uri-list through GtkDropTargetAsync/GdkDrop and pass it to
drop_content::resolve as url, while retaining text/plain handling as text.
Ensure URI drops receive the URL filtering and escaping behavior in
drop_content::resolve.

In `@mac/Tests/RoostTests/ShellEscapeTests.swift`:
- Around line 98-136: The added ShellEscapeTests cases must use swift-testing
rather than XCTest. In mac/Tests/RoostTests/ShellEscapeTests.swift lines 98-136
and 173-228, convert the affected test methods to `@Test` functions and replace
XCTAssertEqual/XCTAssertNil assertions with `#expect`, preserving all existing
cases and expectations; do not add further XCTestCase coverage.

In `@mac/Tests/RoostTests/WindowMetricsTests.swift`:
- Around line 12-23: Convert WindowMetricsTests from XCTestCase to the
swift-testing framework by replacing the XCTest import and test class structure
with a `@Suite` and individual `@Test` cases, preserving the existing coverage and
`@MainActor` requirements. Do not retain the XCTest workaround or bypass the
test-target standard; address any Xcode 26.x runner crash separately.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 066e0586-ca37-4050-b7a4-ff51cf7a1528

📥 Commits

Reviewing files that changed from the base of the PR and between b37a94e and 5e17b62.

📒 Files selected for processing (21)
  • .github/workflows/ci.yml
  • Makefile
  • clippy.toml
  • crates/roost-engine/src/ipc.rs
  • crates/roost-iced/src/app.rs
  • crates/roost-iced/src/app/interactions.rs
  • crates/roost-iced/src/app/servicing.rs
  • crates/roost-iced/src/sidebar_resize.rs
  • crates/roost-linux/src/app.rs
  • crates/roost-linux/src/terminal_view.rs
  • crates/roost-ui-model/src/drop_content.rs
  • docs/reference/ipc.md
  • mac/Sources/Roost/App.swift
  • mac/Sources/Roost/IPCHandlerImpl.swift
  • mac/Sources/Roost/RoostBackend.swift
  • mac/Sources/Roost/TerminalView.swift
  • mac/Tests/RoostTests/ShellEscapeTests.swift
  • mac/Tests/RoostTests/WindowMetricsTests.swift
  • tools/input/linux/iced_clipboard_check.py
  • tools/input/linux/real_input_check.py
  • tools/roosttest/test_sidebar_resize.py
💤 Files with no reviewable changes (1)
  • tools/input/linux/iced_clipboard_check.py

Comment on lines +98 to +120
// Recorded before every early return: `ButtonPressed` carries no position
// of its own, and iced hit-tests it against the newest cursor of the batch
// it was drained with. A frame behind, that is wherever the pointer
// travelled *after* the button went down, so the last move the grip
// actually saw is the honest press anchor (issue #295). A pointer that
// left the window invalidates it — the next entry can land anywhere, and
// no move need be observed before the press. Moves consumed by an iced
// overlay never reach the grip and fire no CursorLeft, so the anchor can
// sit stale across an overlay session; accepted — the first post-overlay
// move refreshes it, and a press before that is a narrow window.
match event {
Event::Mouse(mouse::Event::CursorMoved { position, .. }) => {
state.last_cursor = Some(*position);
}
Event::Mouse(mouse::Event::CursorLeft) => state.last_cursor = None,
_ => {}
}
// Anchoring only ever *replaces* an available batch cursor. With no cursor
// at all the grip has always been a no-op, and a stale anchor must not
// start arming presses it used to ignore.
let anchored = cursor
.position()
.map(|batch| state.last_cursor.unwrap_or(batch));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Invalidate the cursor anchor at overlay boundaries.

crates/roost-iced/src/sidebar_resize.rs:105-120 retains last_cursor when an overlay consumes CursorMoved and CursorLeft. The next press then prefers that stale seam position over the available batch cursor. A first terminal press after an overlay can start a resize gesture.

Clear the anchor before an overlay can consume pointer events, or pass an overlay-generation boundary into this state. Add a test for seam movement, overlay pointer movement, then a terminal-side press.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/roost-iced/src/sidebar_resize.rs` around lines 98 - 120, Invalidate
state.last_cursor at overlay boundaries so cursor movement consumed by an iced
overlay cannot leave a stale anchor for the next press. Update the sidebar
resize event/state flow around the existing anchored cursor logic to recognize
the overlay transition and fall back to the current batch cursor, preserving
normal in-grip movement behavior. Add coverage for seam movement, overlay
pointer movement, and a subsequent terminal-side press ensuring no resize
gesture starts from the stale position.

Comment on lines 2531 to +2532
if let Ok(s) = value.get::<String>() {
return roost_ui_model::drop_content::resolve(std::iter::empty::<&Path>(), Some(&s));
return roost_ui_model::drop_content::resolve(std::iter::empty::<&Path>(), None, Some(&s));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 'DropTarget::new|set_types|connect_drop|drop_value_to_text|drop_content::resolve' \
  crates/roost-linux/src/terminal_view.rs \
  crates/roost-ui-model/src/drop_content.rs

Repository: charliek/roost

Length of output: 5913


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- drop_content.rs ---'
sed -n '1,180p' crates/roost-ui-model/src/drop_content.rs
printf '%s\n' '--- terminal_view.rs drop helpers ---'
sed -n '2460,2540p' crates/roost-linux/src/terminal_view.rs
printf '%s\n' '--- related tests/usages ---'
rg -n -C 5 'drop_content|drop_value_to_text|text/uri|DropTarget|URI|url' \
  crates/roost-linux crates/roost-ui-model --glob '*.rs' --glob '*.toml'
printf '%s\n' '--- current diff summary ---'
git diff --stat

Repository: charliek/roost

Length of output: 50370


🌐 Web query:

GTK4 DropTarget connect_drop GValue String text/uri-list distinguish offered MIME type GdkDrop formats

💡 Result:

In GTK4, GtkDropTarget is designed primarily for simplified, synchronous data reception using GTypes [1][2]. When you use the GtkDropTarget::drop signal, the GValue provided to your callback has already been deserialized from the underlying MIME type into the specified GType by GTK's automatic content deserialization system [3][4]. To distinguish or handle specific MIME types that are not automatically mapped to a convenient GType, or to manage complex data transfers, you have two primary approaches: 1. Use GtkDropTargetAsync: If you need control over the data transfer process, including selecting specific MIME types or performing asynchronous reads, use GtkDropTargetAsync instead of GtkDropTarget [2]. This object allows you to handle the GdkDrop object directly [4]. 2. Access the GdkDrop Object: When using a GtkDropTarget (or GtkDropTargetAsync), you can retrieve the underlying GdkDrop object (usually accessible via the controller's state or signals). From the GdkDrop object, you can: - Use gdk_drop_get_formats to inspect the GdkContentFormats available for the drop [5][6]. - Use gdk_content_formats_contain_mime_type to check if specific MIME types (like text/uri-list) are present [7]. - Use gdk_drop_read_async to manually read the data as a specific MIME type if necessary [6][8]. For common types like text/uri-list, GtkDropTarget typically supports the GDK_TYPE_FILE_LIST GType [3]. You can set this type on your GtkDropTarget using gtk_drop_target_set_gtypes, and the GValue provided to your::drop signal handler will be a boxed GdkFileList, which you can then process using gdk_file_list_get_files [3]. In summary, you do not typically distinguish MIME types inside the standard GtkDropTarget::drop signal because that signal's GValue is the result of a deserialization process that has already committed to a GType [1][4]. To work with raw MIME types, you must step down to the GdkDrop and GdkContentFormats API [7][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- GTK dependency and API references in repository ---'
rg -n -C 4 'gtk4|gdk4|DropTarget|GdkContentFormats|content_formats|current_drop|GdkDrop' \
  Cargo.toml crates/*/Cargo.toml crates/roost-linux/src --glob '*.rs' --glob '*.toml' \
  | head -n 240

printf '%s\n' '--- all drop_content resolver call sites ---'
rg -n -C 3 'drop_content::resolve|fn drop_value_to_text|install_file_drop' \
  crates --glob '*.rs'

printf '%s\n' '--- remaining drop_content URL tests ---'
sed -n '175,270p' crates/roost-ui-model/src/drop_content.rs

Repository: charliek/roost

Length of output: 28630


🌐 Web query:

site:docs.gtk.org/gtk4/class.DropTarget.html current-drop get_formats GtkDropTarget drop signal value type

💡 Result:

The GtkDropTarget::drop signal does not pass the dropped data directly as a signal argument [1]. Instead, when the drop signal is emitted, the data has been retrieved and can be accessed via the GtkDropTarget:value property or the gtk_drop_target_get_value function [1]. The value returned by gtk_drop_target_get_value is a GValue [1]. The type of this GValue is determined by the GType specified when the GtkDropTarget was initialized using gtk_drop_target_new [1]. To manage data formats, gtk_drop_target_get_formats returns a GdkContentFormats object, which defines the formats the target is configured to accept [1]. The current drop operation can be retrieved via the current-drop property or the gtk_drop_target_get_drop function [1].

Citations:


🌐 Web query:

site:gtk-rs.org gtk4 0.10 DropTarget current_drop GdkDrop read_value_async

💡 Result:

In GTK4, when using Gtk::DropTarget, you can access the current drag-and-drop operation via the current_drop method [1][2]. This returns a gdk::Drop object representing the ongoing drop [1][2]. If you need to asynchronously read the data from this drop, you can use the read_value_async method provided by the Gdk::Drop struct [3][4]. Key details regarding these components: 1. Gtk::DropTarget::current_drop: This method retrieves the gdk::Drop currently being handled by the drop target [1][2]. If no drop is in progress, it returns None [1][2]. Note that in older versions of GTK4, this was accessed via a method simply called drop, which has been deprecated since version 4.4 in favor of current_drop [2][5]. 2. Gdk::Drop::read_value_async: This method is used to asynchronously request the contents of the drag operation converted to a specific GType [3][4]. For local drag-and-drop operations where the data matches the requested GType, the value is copied directly [3][4]. For other cases, GDK attempts to use content_deserialize_async to convert the data [3][4]. While Gtk::DropTarget provides a higher-level abstraction that is generally preferred for GTK applications, these underlying Gdk::Drop methods allow for more direct, asynchronous access to the transferred data when necessary [3][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- lockfile versions ---'
rg -n -A3 -B2 '^name = "(gtk4|gdk4|glib)"' Cargo.lock

printf '%s\n' '--- project policy files ---'
git ls-files | rg '(^|/)(CLAUDE\.md|clippy\.toml)$' | xargs -r -n1 sh -c 'echo "--- $0"; sed -n "1,180p" "$0"'

printf '%s\n' '--- drop target call context ---'
sed -n '540,575p' crates/roost-linux/src/terminal_view.rs
sed -n '2484,2536p' crates/roost-linux/src/terminal_view.rs

Repository: charliek/roost

Length of output: 17508


Read URI drops as url, not text.

crates/roost-linux/src/terminal_view.rs:2531-2532 routes link drops delivered as String through the verbatim text branch. Use GtkDropTargetAsync/GdkDrop to read text/uri-list explicitly and pass it as url; keep text/plain on the text path. This enables URL filtering and escaping in crates/roost-ui-model/src/drop_content.rs:68-69.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/roost-linux/src/terminal_view.rs` around lines 2531 - 2532, Update the
drop handling around the String branch in the terminal view to inspect the
GdkDrop/URI MIME type explicitly: read text/uri-list through
GtkDropTargetAsync/GdkDrop and pass it to drop_content::resolve as url, while
retaining text/plain handling as text. Ensure URI drops receive the URL
filtering and escaping behavior in drop_content::resolve.

Comment on lines +98 to +136
/// Shared with the Rust `control_bearing_url_falls_through_to_text` vector:
/// a rejected URL is absent, not stripped, so the deliberately unfiltered
/// string fallback answers instead.
func testControlBearingURLFallsThroughToString() {
for control in ["\n", "\u{0B}", "\u{0C}", "\r", "\u{85}", "\u{2028}", "\u{2029}", "\u{1B}"] {
XCTAssertEqual(
TerminalView.dropContentString(
fileURLs: [], url: "https://example.com/\(control)evil", string: "fallback"
),
"fallback",
"url bearing \(control.unicodeScalars.map(\.value)) should fall through"
)
}
}

/// Shared with the Rust `control_bearing_url_and_text_yields_raw_text`
/// vector. Documents the accepted #282 baseline: a drag can populate both
/// `.URL` and `.string` with the same control-bearing text, and the
/// rejected URL then falls through to the deliberately unfiltered string
/// arm, so the raw text reaches the PTY. That plain-text boundary is owned
/// by #280's bracketed-paste mitigations — and because we reject rather
/// than strip, the URL arm must not launder the payload into an escaped
/// form here either.
func testControlBearingURLAndStringYieldsRawString() {
let payload = "https://example.com/\u{1B}[201~evil"
XCTAssertEqual(
TerminalView.dropContentString(fileURLs: [], url: payload, string: payload),
payload
)
}

/// Shared with the Rust `control_bearing_url_without_text_is_none` vector.
func testControlBearingURLWithoutStringIsNil() {
XCTAssertNil(
TerminalView.dropContentString(
fileURLs: [], url: "https://example.com/\u{1B}[201~evil", string: nil
)
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 2 'import XCTest|XCTestCase|XCTAssert|XCTAssertNil|import Testing|`@Test`|`#expect`' \
  mac/Tests/RoostTests/ShellEscapeTests.swift

Repository: charliek/roost

Length of output: 7616


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- test-file inventory ---'
git ls-files 'mac/Tests/RoostTests/*' | sed -n '1,120p'

printf '%s\n' '--- ShellEscapeTests.swift header and suite declarations ---'
sed -n '1,90p' mac/Tests/RoostTests/ShellEscapeTests.swift
rg -n '^(import (Testing|XCTest)|`@Suite`|`@Test`|final class .*Tests|struct .*Tests|XCTestCase)' mac/Tests/RoostTests

printf '%s\n' '--- project guidance ---'
find .. -name CLAUDE.md -print 2>/dev/null | sed -n '1,40p'

Repository: charliek/roost

Length of output: 44911


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- CLAUDE.md ---'
cat ../git/CLAUDE.md

printf '%s\n' '--- test manifest references ---'
find mac -maxdepth 3 -type f \( -name 'Package.swift' -o -name '*.pbxproj' -o -name 'project.yml' \) -print
rg -n -i 'swift-testing|xctest|testing' mac/Package.swift mac 2>/dev/null | sed -n '1,160p'

printf '%s\n' '--- current diff for the reviewed file ---'
git diff --unified=3 -- mac/Tests/RoostTests/ShellEscapeTests.swift | sed -n '1,320p'

printf '%s\n' '--- framework classification for RoostTests ---'
python3 - <<'PY'
from pathlib import Path
import re

root = Path("mac/Tests/RoostTests")
for path in sorted(root.glob("*.swift")):
    text = path.read_text()
    frameworks = []
    if re.search(r'^\s*import\s+XCTest\b', text, re.M) or "XCTestCase" in text:
        frameworks.append("XCTest")
    if re.search(r'^\s*import\s+Testing\b', text, re.M) or re.search(r'^\s*`@Test`\b', text, re.M):
        frameworks.append("swift-testing")
    if path.name == "ShellEscapeTests.swift" or "XCTest" in frameworks:
        print(f"{path}: {', '.join(frameworks) or 'none'}")
PY

Repository: charliek/roost

Length of output: 25303


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- exact cited ranges ---'
nl -ba mac/Tests/RoostTests/ShellEscapeTests.swift | sed -n '1,25p;95,140p;168,232p'

printf '%s\n' '--- XCTest exception rationale in the test suite ---'
for file in mac/Tests/RoostTests/BracketedPasteTests.swift \
            mac/Tests/RoostTests/ShellEscapeTests.swift \
            mac/Tests/RoostTests/WindowMetricsTests.swift; do
  printf '\n[%s]\n' "$file"
  nl -ba "$file" | sed -n '1,28p'
done

printf '%s\n' '--- Swift test target configuration ---'
nl -ba mac/Package.swift | sed -n '1,220p'

Repository: charliek/roost

Length of output: 219


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- exact cited ranges ---'
cat -n mac/Tests/RoostTests/ShellEscapeTests.swift | sed -n '1,25p;95,140p;168,232p'

printf '%s\n' '--- XCTest exception rationale in the test suite ---'
for file in mac/Tests/RoostTests/BracketedPasteTests.swift \
            mac/Tests/RoostTests/ShellEscapeTests.swift \
            mac/Tests/RoostTests/WindowMetricsTests.swift; do
  printf '\n[%s]\n' "$file"
  cat -n "$file" | sed -n '1,28p'
done

printf '%s\n' '--- Swift test target configuration ---'
cat -n mac/Package.swift | sed -n '1,220p'

Repository: charliek/roost

Length of output: 19968


Migrate the added cases to swift-testing. mac/Tests/RoostTests/ShellEscapeTests.swift:6-11 documents a runner workaround, but CLAUDE.md requires swift-testing under this directory. Convert mac/Tests/RoostTests/ShellEscapeTests.swift:101-135 and :174-228 to @Test and #expect; do not add further XCTestCase coverage.

📍 Affects 1 file
  • mac/Tests/RoostTests/ShellEscapeTests.swift#L98-L136 (this comment)
  • mac/Tests/RoostTests/ShellEscapeTests.swift#L173-L228
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mac/Tests/RoostTests/ShellEscapeTests.swift` around lines 98 - 136, The added
ShellEscapeTests cases must use swift-testing rather than XCTest. In
mac/Tests/RoostTests/ShellEscapeTests.swift lines 98-136 and 173-228, convert
the affected test methods to `@Test` functions and replace
XCTAssertEqual/XCTAssertNil assertions with `#expect`, preserving all existing
cases and expectations; do not add further XCTestCase coverage.

Source: Coding guidelines

Comment on lines +12 to +23
// XCTest, not swift-testing: a swarm of fast value-checks in the
// swift-testing suite reliably SIGABRTs `swiftpm-testing-helper` under
// Xcode 26.x (see `ShellEscapeTests.swift`'s header for the same note).

import AppKit
import Foundation
import XCTest

@testable import Roost

@MainActor
final class WindowMetricsTests: XCTestCase {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Use swift-testing for this test target.

mac/Tests/RoostTests/WindowMetricsTests.swift:12-23 uses XCTestCase, but this path requires swift-testing. Replace this suite with @Suite and @Test cases. If swift-testing crashes under Xcode 26.x, track and resolve that runner problem separately instead of bypassing the test-target standard.

As per coding guidelines, “Swift tests must use swift-testing under mac/Tests/RoostTests/.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mac/Tests/RoostTests/WindowMetricsTests.swift` around lines 12 - 23, Convert
WindowMetricsTests from XCTestCase to the swift-testing framework by replacing
the XCTest import and test class structure with a `@Suite` and individual `@Test`
cases, preserving the existing coverage and `@MainActor` requirements. Do not
retain the XCTest workaround or bypass the test-target standard; address any
Xcode 26.x runner crash separately.

Source: Coding guidelines

@charliek
charliek merged commit 875aa9b into main Aug 5, 2026
18 checks passed
@charliek
charliek deleted the feature/plan-014-iced-bug-closeout branch August 5, 2026 06:02
charliek added a commit that referenced this pull request Aug 5, 2026
Backlog table catches up with PRs #298/#301: #292/#282/#283/#287 fixed,
#295 fixed for the grip with the strip half split to #300 (scrollable
event/cursor space mismatch), new rows for #299 (vendored-swash
robustness follow-ups) and #297's deliberate park (idle-fuse analysis).
M4 entry criteria annotated: the real-input criterion is met; the
no-P0/P1 criterion needs a parity-inventory refresh audit (rows predate
shipped slices) which should also decide #284.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLKWsLV45DAk61xG6Utj3e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Iced hit-tests positionless presses at the batch-newest cursor (affects SidebarResizeGrip + ReorderStrip)

1 participant