From d1c6daef39c3f12a0ee84ab014b11f390542badb Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 00:04:54 -0500 Subject: [PATCH 01/20] docs: add overlay anchor + cols/rows sizing implementation plan --- ...20260722-overlay-anchor-and-cell-sizing.md | 478 ++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 docs/plans/20260722-overlay-anchor-and-cell-sizing.md diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md new file mode 100644 index 00000000..54f7d6c4 --- /dev/null +++ b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md @@ -0,0 +1,478 @@ +# Overlay 9-Point Anchor Positioning + Exact Cols/Rows Sizing + +## Overview + +Floating (non-full) session overlays today support only a single `--size-percent N` (1–100), applied as +one uniform fraction to BOTH width and height, and are always centered in the pane. This adds two +capabilities to floating overlays, driven entirely over the control channel: + +1. **Exact cols×rows sizing** — `session.overlay.open`/`.resize` gain `--cols N --rows M`, sizing the + floating panel to an exact terminal grid via the surface's live cell metrics + (`ghostty_surface_size()`), as an alternative to `--size-percent`. +2. **9-point anchor positioning** — `--anchor ` places the floating panel at one of nine positions + (`top-left · top · top-right · left · center · right · bottom-left · bottom · bottom-right`), default + `center` (today's behavior). + +Both are **adaptive/clamped**: a cols×rows (or percent) request larger than the pane is clamped to fit +whole cells, and a 9-point anchor is always fully on-screen by construction (the panel is ≤ pane after +clamp, so SwiftUI `Alignment` places it against an edge or centered without any manual position math). + +Benefits: scripts can size an overlay to fit a known TUI layout or an inline image at an exact cell size, +and can park it out of the way (corner) instead of dead-center over the session. + +## Review Revisions (incorporated before implementation) + +This plan was reviewed by the `plan-review` agent and by Codex (GPT-5.6). Both findings and three +maintainer decisions are folded in below: + +- **Retina pixel↔point conversion (Codex, blocking).** `ghostty_surface_size()` returns **backing + pixels** (`GhosttySurfaceView.updateMetalLayerSize` pushes `convertToBacking(bounds)` × + `backingScaleFactor` to libghostty, `GhosttySurfaceView.swift:783`), while `GeometryReader` and + `WindowGeometry.Size` are **points**. Cell metrics MUST be converted px→points at the app boundary + before entering the host-free resolver, or cols×rows panels come out ~2× on Retina. +- **Reactive metrics lifecycle (Codex, blocking).** `Session.overlaySurface` is `@ObservationIgnored` + and assigned imperatively in `TerminalView.makeNSView`, so reading metrics from the SwiftUI view body + does not invalidate the panel. Metrics flow through **observed** live-only `Session` state, refreshed + app-side on `GHOSTTY_ACTION_CELL_SIZE` (`GhosttyCallbacks.swift:50`), surface realization, and + backing-scale changes; the view consumes the observed value. +- **Task 2 compile-break + missed callers (both reviews, blocking).** Removing + `Session.overlaySizePercent` orphans readers the original Task 2 file list missed: + `AppStore.controlTree` (`AppStore.swift:215`), the two editor-overlay callers + `AppActions.swift:357`/`:390` (`sizePercent: 95`), `TerminalZoomTests.swift:104`, and the + sizePercent-heavy `AppStorePaneTests.swift`. All are migrated inside Task 2. +- **Decision 1 — read-back reports REQUESTED + APPLIED.** `tree` carries the requested cols/rows (the + record-then-restore key) AND the actual applied grid after clamping (so a script can detect it was + clamped), the applied grid supplied app-side from the realized overlay surface. +- **Decision 2 — anchor is PRESERVED and ALWAYS REPORTED.** A `--full` round-trip keeps the anchor (only + `closeOverlay` resets it to center), and `overlayAnchor` is emitted on `tree` whenever an overlay is + open (including full), so there is no hidden anchor state. +- **Decision 3 — open validation is TIGHTENED to match resize (intentional behavior change).** Today + `session overlay open` does not validate `--size-percent` (the store silently clamps 250→100, 0→1). + Open now hard-errors on an out-of-range percent / bad cols·rows / bad anchor in the dispatcher + CLI + `validate()`, exactly like resize. The store keeps its defensive clamp for internal callers; the + wire/CLI change is documented, not silent. +- **Stronger e2e (Codex).** Wire/echo round-trips alone would pass even with the Retina bug — the e2e + additionally runs `stty size` inside the overlay to assert the actual grid, and asserts the floating + panel's frame vs the pane via an accessibility id. +- **Exactness scope.** Sizing is best-effort-exact from correctly-scaled metrics; the derived padding + (`width_px − columns·cell_width_px`) is treated as an estimate, and the **applied** read-back exposes + any residual drift or clamp rather than hiding it. A bounded post-realization correction loop is + explicitly OUT of scope for v1 (revisit only if manual verification shows real drift). + +## Context (from discovery) + +- **Files/components involved:** + - Host-free model + protocol: `agtermCore/Sources/agtermCore/` — `Session.swift`, `AppStore.swift` + (`controlTree`), `AppStore+Panes.swift` (`openOverlay`/`resizeOverlay`), `ControlProtocol.swift` + (`ControlArgs`, `ControlSessionNode`), `ControlDispatcher.swift` + (`ControlActions`, `ControlSessionOverlayOpenOptions`, validation/routing). New file: + `OverlayLayout.swift` (the pure size/anchor model + resolver). + - App target: `agterm/AppActions.swift` (editor-overlay callers), `agterm/Control/ControlServer+SessionActions.swift` + (overlay arms), `agterm/Ghostty/GhosttySurfaceView.swift` + `agterm/Ghostty/GhosttyCallbacks.swift` + (cell-metrics readout + `CELL_SIZE` signal), `agterm/Views/WindowContentView.swift` (`overlayPanel` + rendering). + - CLI: `agtermCore/Sources/agtermctlKit/SessionCommands.swift` (`Overlay` subcommand tree). + - Docs/keep-in-sync: `agterm/Resources/agent-skill/{reference.md,examples.md,SKILL.md}`, + `site/commands.html`, `site/docs.html`, `README.md`, `.claude/rules/control-api.md`. + - Tests: `agtermCore/Tests/agtermCoreTests/` (`OverlayLayoutTests.swift` new, `SessionTests.swift`, + `AppStorePaneTests.swift`, `TerminalZoomTests.swift`, `ControlProtocolTests.swift`, + `ControlDispatcherTests.swift`, `MockControlActions.swift`), + `agtermCore/Tests/agtermctlKitTests/CommandsTests.swift`, + `agtermUITests/ControlOverlaySplitUITests.swift`. + +- **Related patterns found:** + - **Dispatcher-first control commands** — host-free arg validation, error strings, and response shape + live in `ControlDispatcher.dispatch(_:)` (`ControlDispatcher.swift:489-522` already owns overlay + open/resize); `ControlServer` supplies only side effects via `ControlActions`. + - **State-mutating command owes a read-back field** on `ControlSessionNode` — existing pair is + `session.overlay.resize` ↔ `overlaySizePercent`; extended here with requested + applied cols/rows and + the anchor. + - **App-supplied live values in `controlTree`** — the `fontSize`/`splitFontSize`/`scratchFontSize` + precedent (app-side closures read a live surface value the host-free tree can't). The applied overlay + grid follows the same idea, but simpler: the app writes the realized grid into observed `Session` + state, which `controlTree` reads directly. + - **`ghostty_surface_size()`** (`GhosttyKit.xcframework/.../ghostty.h:482`) returns + `columns`/`rows`/`width_px`/`height_px`/`cell_width_px`/`cell_height_px` in **backing pixels** — the + cell + padding + realized-grid source, live and font-accurate. + - **`GHOSTTY_ACTION_CELL_SIZE`** (`GhosttyCallbacks.swift:50`) — fires on font/DPI change; the trigger + to refresh the observed metrics. + - **NSSplitView-overrun invariant** (`.claude/rules/libghostty.md`) — `overlayPanel` is an + always-present, constant-shape `sessionDetail` ZStack sibling at `.zIndex(3)`; its inner content is + gated inside `if session.overlayActive` (`WindowContentView.swift:531-564`). Changing frame size and + ZStack `alignment` does NOT change child count and must not introduce anchor-specific `if`/`switch` + view branches, so the split is never re-hosted. + - **CoreGraphics-free `agtermCore`** — the resolver uses the Double-backed `WindowGeometry.Size`, never + `CGSize`; the app converts to/from `CGFloat` at the call site. + +- **Dependencies identified:** overlay state is **live-only** (absent from `SessionSnapshot`), so there is + NO persistence/restore migration. No new `Command` case (both capabilities are new args on the existing + `session.overlay.open`/`.resize`), so the public command count stays **64**. + +## Development Approach + +- **Testing approach**: Regular (code first, then tests) — per user selection. Tests are still a required + deliverable of every task and must pass before the next task starts. +- complete each task fully before moving to the next; make small, focused changes. +- **CRITICAL: every code-change task MUST include new/updated tests** (unit tests for new/modified + functions, new cases for new code paths, success + error scenarios). +- **CRITICAL: all tests must pass before starting the next task.** +- **Intermediate build state (expected):** the **app target** intentionally does not compile from the + middle of Task 2 through Task 5 (removed/changed overlay symbols in `WindowContentView`/`ControlServer`). + Tasks 2–4 gate on `cd agtermCore && swift test` (the host-free module, which DOES compile after each + task); the app build is first re-verified in Task 5. This is by design, not a regression. +- **CRITICAL: update this plan file when scope changes during implementation.** +- run `swift test`, the app build, and `make lint` as applicable; keep backward compatibility EXCEPT the + documented Decision-3 open-validation tightening (existing `--size-percent`/`--full` behavior and the + default-center anchor are otherwise unchanged). + +## Project-Rule Gates (agterm — verify against every task before marking complete) + +- **Module boundary**: `agtermCore` stays host-free — no GhosttyKit/AppKit/Metal AND no CoreGraphics. + The layout model, resolver, and `OverlayCellMetrics` use `WindowGeometry.Size` (Double, in **points**). + Reading pixel metrics, converting px→points, applying the SwiftUI frame/alignment, and populating the + applied grid are app-target only. +- **Dispatcher-first**: all overlay arg validation, error strings, and one-of rules live in + `ControlDispatcher`; `ControlServer` arms only resolve the target and call the store. +- **Read-back obligation (Decision 1)**: the write path owes matching read-back on `ControlSessionNode`, + populated in `AppStore.controlTree`, omitted from JSON when nil, covered by round-trip + `…RoundTrips`/`…OmitsWhenNil` tests + a `controlTree` populate test. Requested cols/rows/anchor are the + restore key; applied cols/rows expose clamp/drift. +- **Anchor state (Decision 2)**: anchor preserved across `--full`; reset only on `closeOverlay`; emitted + on `tree` whenever an overlay is open. +- **Open validation (Decision 3)**: dispatcher + CLI hard-error on out-of-range percent / bad cols·rows / + bad anchor for BOTH open and resize (documented tightening); the store keeps a defensive clamp. +- **Options struct for 4+ params** (CLAUDE.md): overlay-open params go through an `OverlayOpenOptions` + struct, not a 7-positional-arg signature; both internal callers (`AppActions.swift:357/390`) migrate. +- **NSSplitView-overrun invariant**: keep `overlayPanel`'s ZStack child count constant; only frame + parameters and ZStack `alignment` may change; no anchor-specific view branches. +- **One test file per source file**; **keep-in-sync (HARD)**: agent-skill (incl. `SKILL.md`) + + `site/commands.html` + README + `site/docs.html` + `.claude/rules/control-api.md` in lockstep (Task 8); + `CHANGELOG.md` is release-only — do NOT touch it here. + +## Testing Strategy + +- **unit (agtermCore, `swift test`)**: resolver clamp/anchor + 1× / 2× (Retina) scaling + rounding + + degenerate guards (`OverlayLayoutTests`); Session predicates + anchor-preserve-on-full + close-reset + (`SessionTests`); store open/resize incl. migrated sizePercent/clamp tests (`AppStorePaneTests`, + `TerminalZoomTests`); wire round-trips incl. applied + anchor (`ControlProtocolTests`); dispatcher + validation incl. tightened open (`ControlDispatcherTests`); CLI mapping/validate (`CommandsTests`). +- **e2e (XCUITest, `ControlOverlaySplitUITests`)**: open with cols/rows/anchor, resize, re-anchor, + read-back off `tree` (requested + applied + anchor); **actual grid via `stty size` marker**; **floating + panel frame vs pane via accessibility id**; error cases; repeated re-anchor/full/cell transitions while + a split is visible (NSSplitView guard). Must pass before the next task. + +## Progress Tracking + +- mark completed items with `[x]` immediately; add ➕ for new tasks, ⚠️ for blockers; keep the plan in + sync with actual work. + +## Solution Overview + +- **Host-free model** (`agtermCore/OverlayLayout.swift`): + - `OverlaySize` enum: `.full` | `.percent(Int)` | `.cells(cols: Int, rows: Int)` — replaces stored + `Session.overlaySizePercent: Int?`. + - `OverlayAnchor` string enum (9 cases, `CaseIterable`, default `.center`) with host-free + `unitX`/`unitY` ∈ {0, 0.5, 1}. + - `OverlayCellMetrics` — Double-backed, **in points** (`cellWidth`, `cellHeight`, `padWidth`, + `padHeight`); the app converts pixel metrics ÷ backing scale before constructing it. + - `OverlayLayout.panelSize(_:pane:cell:) -> WindowGeometry.Size` — pure resolver: `.full` → pane; + `.percent(p)` → `pane * p/100`; `.cells(c,r)` → whole-cell clamp against the pane (`usedCols = + max(1, min(c, floor((pane.w − padW)/cellW)))`, width `= usedCols·cellW + padW`; same for rows), + with explicit guards for nil metrics (fallback to pane), zero/invalid cell size, and pane smaller + than one cell + padding. +- **Session state** (all observed / host-free): `overlaySize: OverlaySize = .full`, `overlayAnchor: + OverlayAnchor = .center`, `overlayCellMetrics: OverlayCellMetrics?` (points; app-maintained, drives + the view), `overlayAppliedCols: Int?`/`overlayAppliedRows: Int?` (app-maintained realized grid, for + read-back). `fullOverlayActive` = `overlayActive && overlaySize == .full`; `floatingOverlayActive` = + `overlayActive && overlaySize != .full`. +- **Store API**: `openOverlay(_:options:)` takes `OverlayOpenOptions` (command/cwd/wait/size/anchor/ + backgroundColor); `resizeOverlay(_:size:anchor:)` with optional size/anchor (nil = keep current) so a + resize, a re-anchor-in-place, or both are one call. `--full` keeps the anchor; `closeOverlay` resets + size→`.full`, anchor→`.center`, metrics/applied→nil. +- **Wire**: `ControlArgs` gains `cols`/`rows`/`anchor`; `ControlSessionNode` gains + `overlayCols`/`overlayRows` (requested, cells mode only), `overlayColsApplied`/`overlayRowsApplied` + (realized grid, any floating overlay), `overlayAnchor` (any open overlay); `overlaySizePercent` kept. +- **Dispatcher**: validates one-of {`--size-percent`, `--cols`+`--rows`} for open (no `--full` on open — + absence of a size = full) and one-of {`--full`, `--size-percent`, `--cols`+`--rows`} or none for + resize; cols·rows paired; percent 1…100 hard-error; anchor one of nine; anchor requires a floating + size on open; resize needs at least one of {size, anchor}; `--full` ⊥ `--anchor`. +- **App rendering**: `GhosttySurfaceView.overlayPixelMetrics()` reads `ghostty_surface_size()`; the app + converts px→points via `backingScaleFactor` and writes `session.overlayCellMetrics` (on realization, + `CELL_SIZE`, backing-scale change) and `session.overlayAppliedCols/Rows` (from the realized grid). + `overlayPanel` sizes via `OverlayLayout.panelSize(session.overlaySize, pane: geo.size, cell: + session.overlayCellMetrics)` and positions via `ZStack(alignment: floating ? anchor.swiftUIAlignment : + .center)`; a stable accessibility id on the floating panel enables the e2e frame assertion. + +## Technical Details + +**Host-free types (`agtermCore/Sources/agtermCore/OverlayLayout.swift`):** + +```swift +public enum OverlaySize: Equatable, Sendable { + case full + case percent(Int) // caller pre-validates 1...100 + case cells(cols: Int, rows: Int) // caller pre-validates cols>=1, rows>=1 +} + +public enum OverlayAnchor: String, CaseIterable, Sendable { + case topLeft = "top-left", top, topRight = "top-right" + case left, center, right + case bottomLeft = "bottom-left", bottom, bottomRight = "bottom-right" + public var unitX: Double { /* 0 | 0.5 | 1 */ } + public var unitY: Double { /* 0 | 0.5 | 1 */ } +} + +public struct OverlayCellMetrics: Equatable, Sendable { // POINTS, not pixels + public let cellWidth: Double, cellHeight: Double, padWidth: Double, padHeight: Double + public init(cellWidth: Double, cellHeight: Double, padWidth: Double, padHeight: Double) + public var isUsable: Bool { cellWidth > 0 && cellHeight > 0 } +} + +public enum OverlayLayout { + public static func panelSize(_ size: OverlaySize, pane: WindowGeometry.Size, + cell: OverlayCellMetrics?) -> WindowGeometry.Size + // .full -> pane; .percent -> pane*p/100 (<=pane); .cells -> whole-cell clamp; nil/unusable cell -> pane +} +``` + +**Pixel→point conversion (app-side, `GhosttySurfaceView`):** +```swift +func overlayPixelMetrics() -> (cellW: Double, cellH: Double, padW: Double, padH: Double, + cols: Int, rows: Int)? // from ghostty_surface_size(surface), all *_px +// app converts to points: OverlayCellMetrics(cellWidth: cellW/scale, cellHeight: cellH/scale, +// padWidth: (width_px - cols*cellW)/scale, padHeight: (height_px - rows*cellH)/scale) +// with scale = window.backingScaleFactor. padding is an ESTIMATE (total non-cell remainder). +``` + +**`Session`:** remove `overlaySizePercent`; add `overlaySize`/`overlayAnchor`/`overlayCellMetrics`/ +`overlayAppliedCols`/`overlayAppliedRows`; re-express `fullOverlayActive`/`floatingOverlayActive` against +`overlaySize` (the `topmostSurface` accessor reads `overlayActive`, NOT the percent — leave it alone). + +**Wire read-back (`ControlSessionNode`, populated in `AppStore.controlTree`, omitted when nil):** +- `overlaySizePercent: Int?` — set only when `overlaySize == .percent` (unchanged; back-compat). +- `overlayCols`/`overlayRows: Int?` — REQUESTED grid; set only when `overlaySize == .cells`. +- `overlayColsApplied`/`overlayRowsApplied: Int?` — realized grid from `overlayAppliedCols/Rows`; set for + ANY open floating overlay (percent or cells), so a script sees what actually rendered / any clamp. +- `overlayAnchor: String?` — anchor `rawValue`, set whenever an overlay is open (incl. full). + +**Dispatcher validation.** Open: at most one of {`sizePercent`, (`cols`&`rows`)}; `cols` and `rows` both +or neither ("provide both --cols and --rows"); `sizePercent` 1…100 (hard error — Decision 3); `cols`/ +`rows` >= 1; `anchor` parses to `OverlayAnchor`; `anchor` without a floating size errors. No size = full +overlay (open has no `--full`). Resize: one-of {`full`, `sizePercent`, `cols`+`rows`} OR none; at least +one of {a size mode, `anchor`}; `full` ⊥ `anchor`; same range/pairing/anchor rules. + +**Processing flow:** CLI parse+validate → `ControlRequest` args {sizePercent|cols,rows, anchor / full} → +`ControlDispatcher.dispatch` validates + builds `OverlayOpenOptions` / resize call → `ControlActions` arm +→ `AppStore.openOverlay/resizeOverlay` sets `overlaySize`/`overlayAnchor` → app keeps +`overlayCellMetrics`/`overlayAppliedCols/Rows` fresh via `CELL_SIZE`/realization → `overlayPanel` renders +via `OverlayLayout.panelSize` + anchor alignment. `tree` reports requested + applied + anchor. + +## What Goes Where + +- **Implementation Steps** (`[ ]`): all code, tests, and in-repo docs (agent-skill, site, README, rules). +- **Post-Completion** (no checkboxes): manual dev-instance verification of rendered placement and + cell-exact fit (not accessibility-observable beyond the e2e frame check), and the exactness/drift call. + +## Implementation Steps + +### Task 1: Host-free overlay layout model + resolver + +**Files:** +- Create: `agtermCore/Sources/agtermCore/OverlayLayout.swift` +- Create: `agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift` + +- [ ] add `OverlaySize`, `OverlayAnchor` (with `unitX`/`unitY`), `OverlayCellMetrics` (points, `isUsable`) +- [ ] add `OverlayLayout.panelSize(_:pane:cell:)` — full/percent/whole-cell-clamp with guards: nil or + unusable cell → pane; pane smaller than one cell + padding → clamp to >=1 cell but never exceed pane +- [ ] write tests: percent (incl. 100), cells within pane, cells wider/taller than pane (whole-cell + clamp), **1× and 2× (Retina) metrics producing the correct point size**, sub-pixel rounding cases, + nil/unusable metrics fallback, min-1-cell floor, all-9 anchor unit points +- [ ] run `cd agtermCore && swift test` — must pass before next task + +### Task 2: Session state + store API + controlTree percent migration (single compiling unit) + +**Files:** +- Modify: `agtermCore/Sources/agtermCore/Session.swift` +- Modify: `agtermCore/Sources/agtermCore/AppStore+Panes.swift` +- Modify: `agtermCore/Sources/agtermCore/AppStore.swift` +- Modify: `agterm/AppActions.swift` +- Modify: `agtermCore/Tests/agtermCoreTests/SessionTests.swift` +- Modify: `agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift` +- Modify: `agtermCore/Tests/agtermCoreTests/TerminalZoomTests.swift` + +- [ ] replace `Session.overlaySizePercent` with `overlaySize: OverlaySize = .full`; add `overlayAnchor: + OverlayAnchor = .center`, observed `overlayCellMetrics: OverlayCellMetrics?`, `overlayAppliedCols: + Int?`, `overlayAppliedRows: Int?`; re-express `fullOverlayActive`/`floatingOverlayActive` +- [ ] add `OverlayOpenOptions`; `AppStore.openOverlay(_:options:)` (defensive percent clamp retained); + `resizeOverlay(_:size:anchor:)` (nil = keep; `.full` keeps anchor); `closeOverlay` resets + size/anchor/metrics/applied +- [ ] migrate `AppStore.controlTree` percent read to derive from `overlaySize` (keep `overlaySizePercent` + wire field populated); migrate `AppActions.swift:357/390` to `openOverlay(options: + .init(... size: .percent(95) ...))` +- [ ] update `AppStorePaneTests` (open/resize via new API; keep the 250→100 / 0→1 defensive-clamp cases) + and `TerminalZoomTests:104` +- [ ] write tests: open sets size/anchor; resize percent→cells→full; resize anchor-only keeps size; + size-only keeps anchor; **`--full` preserves the anchor**; predicates per mode; close resets to + full/center/nil +- [ ] run `cd agtermCore && swift test` — must pass before next task (app target not yet built) + +### Task 3: Wire protocol — args + read-back node (requested + applied + anchor) + +**Files:** +- Modify: `agtermCore/Sources/agtermCore/ControlProtocol.swift` +- Modify: `agtermCore/Sources/agtermCore/AppStore.swift` +- Modify: `agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift` +- Modify: `agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift` + +- [ ] add `ControlArgs.cols`/`rows`/`anchor`; thread through its init +- [ ] add `ControlSessionNode.overlayCols`/`overlayRows`/`overlayColsApplied`/`overlayRowsApplied`/ + `overlayAnchor`; thread through its init (keep `overlaySizePercent`) +- [ ] populate the new node fields in `AppStore.controlTree`: requested cols/rows when `.cells`; applied + cols/rows from `overlayAppliedCols/Rows` when floating; `overlayAnchor` rawValue when + `overlayActive`; all omitted when nil +- [ ] write tests: `ControlArgs` round-trip with cols/rows/anchor; `ControlSessionNode` round-trip for + cells+applied+anchor and for percent+applied+anchor; `…OmitsWhenNil` for full overlay and no + overlay; a `controlTree` populate test in `AppStorePaneTests` (requested vs applied, anchor emitted + while full) +- [ ] run `swift test` — must pass before next task + +### Task 4: Dispatcher validation + routing (tightened open) + +**Files:** +- Modify: `agtermCore/Sources/agtermCore/ControlDispatcher.swift` +- Modify: `agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift` +- Modify: `agtermCore/Tests/agtermCoreTests/MockControlActions.swift` + +- [ ] extend `ControlActions.openSessionOverlay`/`resizeSessionOverlay` + `ControlSessionOverlayOpenOptions` + to carry `OverlaySize`/`OverlayAnchor`; update `MockControlActions` +- [ ] `.sessionOverlayOpen`: parse size (percent/cells, no `--full`), validate one-of + cols·rows pairing + + **percent 1…100 hard-error (Decision 3)** + anchor parse + anchor-requires-floating; build options +- [ ] `.sessionOverlayResize`: validate one-of {full/percent/cells} or none, at-least-one-of {size, + anchor}, `full` ⊥ `anchor`; pass `size: OverlaySize?`/`anchor: OverlayAnchor?` +- [ ] factor size/anchor parsing into shared pure helpers used by both arms +- [ ] write tests: each valid open/resize mode routes correctly (assert mock call args); each error + (both cols/rows required, **percent out-of-range now errors on open**, unknown anchor, + anchor-without-floating, full+anchor, resize-with-nothing) +- [ ] run `swift test` — must pass before next task + +### Task 5: App-side arms + observable metrics + overlayPanel rendering + +**Files:** +- Modify: `agterm/Control/ControlServer+SessionActions.swift` +- Modify: `agterm/Ghostty/GhosttySurfaceView.swift` +- Modify: `agterm/Ghostty/GhosttyCallbacks.swift` +- Modify: `agterm/Views/WindowContentView.swift` + +- [ ] update the overlay arms to pass `size`/`anchor` into `openOverlay(options:)`/`resizeOverlay(_:size:anchor:)` +- [ ] add `GhosttySurfaceView.overlayPixelMetrics()` from `ghostty_surface_size()`; app converts px→points + via `backingScaleFactor` and writes `session.overlayCellMetrics` + `session.overlayAppliedCols/Rows` + on surface realization, `GHOSTTY_ACTION_CELL_SIZE` (`GhosttyCallbacks.swift:50`), and backing-scale + change (view consumes the observed metrics — no view-body polling of the imperative NSView) +- [ ] `overlayPanel`: size via `OverlayLayout.panelSize(session.overlaySize, pane: geo.size, cell: + session.overlayCellMetrics)`; position via `ZStack(alignment: floating ? + session.overlayAnchor.swiftUIAlignment : .center)`; add the app-side `OverlayAnchor.swiftUIAlignment` + mapping; add a stable accessibility id to the floating panel +- [ ] confirm ZStack child count unchanged and no anchor-specific view branches (NSSplitView-overrun + rule); full path + `hideForOverlay` untouched +- [ ] app builds (`make build`); `make lint` passes +- [ ] verify in an isolated dev instance (short `AGTERM_STATE_DIR` + its own socket) that cols/rows fit, + each anchor places correctly, and Retina scaling is right — record in Post-Completion +- [ ] run `swift test` (host-free unchanged) — must pass before next task + +### Task 6: agtermctl CLI — open/resize flags + +**Files:** +- Modify: `agtermCore/Sources/agtermctlKit/SessionCommands.swift` +- Modify: `agtermCore/Tests/agtermctlKitTests/CommandsTests.swift` + +- [ ] `session overlay open`: add `--cols`/`--rows`/`--anchor`; `validate()` mirrors dispatcher (one-of + size, cols&rows together, **percent 1…100 now enforced on open**, anchor value, anchor-requires- + floating); map into `ControlArgs` +- [ ] `session overlay resize`: add `--cols`/`--rows`/`--anchor`; extend `validate()` to one-of size or + none, at-least-one of {size, anchor}, `full` ⊥ `anchor` +- [ ] update help text +- [ ] write tests: request mapping for each open/resize mode; `validate()` accepts valid combos and + rejects each invalid one (incl. the new open percent-range error) with the expected message +- [ ] run `swift test` — must pass before next task + +### Task 7: End-to-end XCUITests (wire + actual grid + frame) + +**Files:** +- Modify: `agtermUITests/ControlOverlaySplitUITests.swift` + +- [ ] e2e: open a floating overlay with `--cols/--rows` + `--anchor`; assert `tree` read-back (requested + `overlayCols/Rows`, applied `overlayColsApplied/RowsApplied`, `overlayAnchor`) +- [ ] e2e: assert the **actual grid** — run `stty size > marker; cat` in the overlay and assert the + marker reports the requested (or clamped) rows/cols (would catch the Retina bug) +- [ ] e2e: assert the floating panel's **frame vs the pane** via its accessibility id for a corner anchor +- [ ] e2e: open `--size-percent`, resize to `--cols/--rows`, re-anchor with `--anchor` only, resize back + to `--full` (read-back: cols/rows cleared, anchor retained); cycle these while a split is visible +- [ ] e2e: error cases over the socket (anchor without floating, both cols/rows required, full+anchor, + **open --size-percent out of range now errors**) +- [ ] run the overlay e2e target — must pass before next task + +### Task 8: Keep-in-sync documentation surfaces + +**Files:** +- Modify: `agterm/Resources/agent-skill/reference.md` +- Modify: `agterm/Resources/agent-skill/examples.md` +- Modify: `agterm/Resources/agent-skill/SKILL.md` +- Modify: `site/commands.html` +- Modify: `site/docs.html` +- Modify: `README.md` +- Modify: `.claude/rules/control-api.md` + +- [ ] agent-skill `reference.md`: update the `session overlay open`/`resize` entries with + `--cols/--rows/--anchor`, the tightened open validation, the clamp/adaptive note, and the read-back + fields (requested + applied + anchor); command count stays 64 +- [ ] agent-skill `SKILL.md`: update the overlay invocation line + the `overlaySizePercent` schema note + (NOT optional — it carries the overlay schema) +- [ ] agent-skill `examples.md`: add a cols/rows + anchor recipe and a re-anchor-only example +- [ ] `site/commands.html`: update the overlay entries (invocation, args, `tree` read-back fields) +- [ ] `README.md` + `site/docs.html`: update the overlay section (mirror each other) +- [ ] `.claude/rules/control-api.md`: update the overlay command **args** doc (not only the read-back + note) — new flags, tightened open validation, requested+applied read-back, anchor-preserved-on-full +- [ ] grep the skill + `control-api.md` for stale percent-only / "centered" / old-state-shape wording and + fix; note in the plan that `site/index.html` is intentionally untouched (non-major, non-release) +- [ ] re-run `make lint` to confirm nothing regressed + +### Task 9: Verify acceptance criteria +- [ ] verify Overview: cols/rows sizing, 9 anchors, size clamp, percent/full unchanged, default center, + requested+applied+anchor read-back, Decision-3 open tightening +- [ ] verify edge cases: cols/rows > pane (clamped, applied reflects it), 1×1, Retina 2× correct, + anchor with no floating size (error), resize anchor-only, `--full` preserves anchor, no new + `Command` case (count 64), no persistence changes +- [ ] run full host-free suite: `cd agtermCore && swift test` +- [ ] run e2e: the `ControlOverlaySplitUITests` overlay cases (incl. `stty size` + frame assertions) +- [ ] `make lint` clean; app builds (`make build`) + +### Task 10: Final documentation + close-out +- [ ] update `CLAUDE.md`/`.claude/rules` only if a genuinely new pattern emerged (e.g. the observed- + metrics-from-`CELL_SIZE` bridge, if worth recording) +- [ ] move this plan to `docs/plans/completed/` + +## Post-Completion +*Items requiring manual intervention or external systems — no checkboxes, informational only* + +**Manual verification:** +- Placement and cell-exact fit: verify by eye in an isolated dev instance (`open -n --env + AGTERM_STATE_DIR=/tmp/agt-overlay --env AGTERM_CONTROL_SOCKET=/tmp/agt-overlay.sock /agterm.app`, + drive via `agtermctl … --socket /tmp/agt-overlay.sock`): each of the nine anchors places against the + correct edge/corner; `--cols/--rows` renders the exact grid on BOTH a 1× and a Retina display; an + oversized request clamps to whole cells and `overlayColsApplied/RowsApplied` report the clamp. Never + touch the deployed `~/Applications/agterm.app`. +- **Exactness/drift call:** if manual verification shows the realized grid is consistently off by a cell + from the request (padding-estimate drift), open a follow-up to add a bounded post-realization frame + correction — deliberately OUT of v1 scope, with the applied read-back exposing the drift meanwhile. +- Cell metrics when the overlay font differs from the main pane's: the overlay is created with + `session.fontSize` (`agtermApp.swift:435`), so the pre-realization main-surface fallback matches in the + normal case; the differing-font case to sanity-check is an overlay zoomed AFTER creation, or a main + surface under a transient dashboard font override — not a merely-zoomed main pane. + +**External system updates:** none — no consuming projects, no deployment/config changes. + +--- +Smells pre-check: skipped — non-Go project (no `go.mod`; the Go-specific planning rules — go-architect, +code-quality block, Design Contract, smells pre-check — do not apply). agterm project-rule gates are in +the "Project-Rule Gates" section above. Reviewed by plan-review + Codex; findings and three maintainer +decisions folded into "Review Revisions". From 8e434b6424f457c9b6f0ea851723d901f64837d3 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 00:11:22 -0500 Subject: [PATCH 02/20] feat: add host-free overlay layout model and resolver --- .../Sources/agtermCore/OverlayLayout.swift | 102 ++++++++++++ .../agtermCoreTests/OverlayLayoutTests.swift | 156 ++++++++++++++++++ ...20260722-overlay-anchor-and-cell-sizing.md | 8 +- 3 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 agtermCore/Sources/agtermCore/OverlayLayout.swift create mode 100644 agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift diff --git a/agtermCore/Sources/agtermCore/OverlayLayout.swift b/agtermCore/Sources/agtermCore/OverlayLayout.swift new file mode 100644 index 00000000..7acdd133 --- /dev/null +++ b/agtermCore/Sources/agtermCore/OverlayLayout.swift @@ -0,0 +1,102 @@ +import Foundation + +/// The requested size mode for a session overlay panel. Host-free; the app resolves it to a concrete +/// panel size via `OverlayLayout.panelSize`. Replaces the stored `Session.overlaySizePercent: Int?`. +public enum OverlaySize: Equatable, Sendable { + /// Fill the whole pane. + case full + /// A uniform fraction of the pane in both dimensions; the caller pre-validates `1...100`. + case percent(Int) + /// An exact terminal grid; the caller pre-validates `cols >= 1, rows >= 1`. + case cells(cols: Int, rows: Int) +} + +/// One of nine positions a floating overlay panel anchors to within its pane. `unitX`/`unitY` express the +/// anchor as unit coordinates — 0 (leading/top), 0.5 (center), 1 (trailing/bottom) — which the app maps +/// to a SwiftUI `Alignment`. The default (`.center`) reproduces today's centered placement. +public enum OverlayAnchor: String, CaseIterable, Sendable { + case topLeft = "top-left" + case top + case topRight = "top-right" + case left + case center + case right + case bottomLeft = "bottom-left" + case bottom + case bottomRight = "bottom-right" + + /// Horizontal unit position: 0 (leading), 0.5 (center), or 1 (trailing). + public var unitX: Double { + switch self { + case .topLeft, .left, .bottomLeft: return 0 + case .top, .center, .bottom: return 0.5 + case .topRight, .right, .bottomRight: return 1 + } + } + + /// Vertical unit position: 0 (top), 0.5 (center), or 1 (bottom). + public var unitY: Double { + switch self { + case .topLeft, .top, .topRight: return 0 + case .left, .center, .right: return 0.5 + case .bottomLeft, .bottom, .bottomRight: return 1 + } + } +} + +/// Live cell + padding metrics for an overlay surface, in POINTS (not backing pixels). The app reads the +/// pixel metrics from `ghostty_surface_size()` and divides by the window's backing scale before +/// constructing this, so the host-free resolver works in the same point space as the SwiftUI pane size. +/// `padWidth`/`padHeight` are the total non-cell remainder (an estimate, per the padding-drift note). +public struct OverlayCellMetrics: Equatable, Sendable { + public let cellWidth: Double + public let cellHeight: Double + public let padWidth: Double + public let padHeight: Double + + public init(cellWidth: Double, cellHeight: Double, padWidth: Double, padHeight: Double) { + self.cellWidth = cellWidth + self.cellHeight = cellHeight + self.padWidth = padWidth + self.padHeight = padHeight + } + + /// Whether the metrics can drive a cells-mode layout — both cell dimensions must be positive. + public var isUsable: Bool { cellWidth > 0 && cellHeight > 0 } +} + +/// Pure resolver turning an `OverlaySize` request into a concrete panel size within a pane. Host-free and +/// unit-tested; the app applies the result as a SwiftUI frame. All sizes are in points. +public enum OverlayLayout { + /// Resolves `size` against `pane` and (for cells mode) the live `cell` metrics. + /// + /// - `.full` returns the full pane. + /// - `.percent(p)` returns `pane * p/100` (always <= pane for a valid `1...100` percent). + /// - `.cells(cols, rows)` snaps each dimension to whole cells that fit the pane: at least one cell, at + /// most the whole cells the pane holds after padding, and never larger than the pane. With nil or + /// unusable `cell` metrics it falls back to the full pane. + public static func panelSize(_ size: OverlaySize, pane: WindowGeometry.Size, + cell: OverlayCellMetrics?) -> WindowGeometry.Size { + switch size { + case .full: + return pane + case .percent(let percent): + let fraction = Double(percent) / 100 + return WindowGeometry.Size(width: pane.width * fraction, height: pane.height * fraction) + case .cells(let cols, let rows): + guard let cell, cell.isUsable else { return pane } + let width = cellExtent(count: cols, cellSize: cell.cellWidth, pad: cell.padWidth, available: pane.width) + let height = cellExtent(count: rows, cellSize: cell.cellHeight, pad: cell.padHeight, available: pane.height) + return WindowGeometry.Size(width: width, height: height) + } + } + + /// Snaps a requested cell count to a whole-cell extent that fits `available`: floors at one cell, caps + /// at the whole cells that fit after `pad`, and never exceeds `available` (so a pane smaller than one + /// cell + padding still yields a panel no larger than the pane). + private static func cellExtent(count: Int, cellSize: Double, pad: Double, available: Double) -> Double { + let fitCount = Int(((available - pad) / cellSize).rounded(.down)) + let usedCount = Swift.max(1, Swift.min(count, fitCount)) + return Swift.min(Double(usedCount) * cellSize + pad, available) + } +} diff --git a/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift b/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift new file mode 100644 index 00000000..93c47f1b --- /dev/null +++ b/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift @@ -0,0 +1,156 @@ +import Foundation +import Testing +@testable import agtermCore + +struct OverlayLayoutTests { + private func pane(_ width: Double, _ height: Double) -> WindowGeometry.Size { + WindowGeometry.Size(width: width, height: height) + } + + private func expectSize(_ size: WindowGeometry.Size, _ width: Double, _ height: Double) { + #expect(size.width == width) + #expect(size.height == height) + } + + // mirrors the app-side px->points conversion: every pixel metric divided by the backing scale. + private func metrics(cellWpx: Double, cellHpx: Double, padWpx: Double, padHpx: Double, + scale: Double) -> OverlayCellMetrics { + OverlayCellMetrics(cellWidth: cellWpx / scale, + cellHeight: cellHpx / scale, + padWidth: padWpx / scale, + padHeight: padHpx / scale) + } + + @Test func fullReturnsWholePane() { + expectSize(OverlayLayout.panelSize(.full, pane: pane(800, 600), cell: nil), 800, 600) + } + + @Test func percentScalesBothDimensions() { + expectSize(OverlayLayout.panelSize(.percent(50), pane: pane(800, 600), cell: nil), 400, 300) + } + + @Test func percentHundredEqualsPane() { + expectSize(OverlayLayout.panelSize(.percent(100), pane: pane(800, 600), cell: nil), 800, 600) + } + + @Test func percentOneIsTiny() { + expectSize(OverlayLayout.panelSize(.percent(1), pane: pane(800, 600), cell: nil), 8, 6) + } + + @Test func cellsWithinPaneSnapToRequestedGrid() { + let cell = OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 4, padHeight: 6) + let result = OverlayLayout.panelSize(.cells(cols: 10, rows: 5), pane: pane(800, 600), cell: cell) + // 10*8+4 = 84 wide, 5*16+6 = 86 tall. + expectSize(result, 84, 86) + } + + @Test func cellsWiderThanPaneClampToWholeCells() { + let cell = OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 4, padHeight: 6) + // pane holds floor((200-4)/8) = 24 whole columns; a 100-col request clamps to 24 -> 24*8+4 = 196. + let result = OverlayLayout.panelSize(.cells(cols: 100, rows: 5), pane: pane(200, 600), cell: cell) + expectSize(result, 196, 86) + } + + @Test func cellsTallerThanPaneClampToWholeCells() { + let cell = OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 4, padHeight: 6) + // pane holds floor((200-6)/16) = 12 whole rows; a 100-row request clamps to 12 -> 12*16+6 = 198. + let result = OverlayLayout.panelSize(.cells(cols: 10, rows: 100), pane: pane(800, 200), cell: cell) + expectSize(result, 84, 198) + } + + @Test func cellsClampBothDimensionsIndependently() { + let cell = OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 4, padHeight: 6) + let result = OverlayLayout.panelSize(.cells(cols: 100, rows: 100), pane: pane(200, 200), cell: cell) + // width floor((200-4)/8) = 24 -> 196; height floor((200-6)/16) = 12 -> 198. + expectSize(result, 196, 198) + } + + @Test func retina2xAndNonRetina1xYieldSamePointSize() { + // a surface at 1x: 8px/16px cells, 4px/2px total padding. + let oneX = metrics(cellWpx: 8, cellHpx: 16, padWpx: 4, padHpx: 2, scale: 1) + // the same physical surface at 2x: pixel metrics double, backing scale is 2. + let twoX = metrics(cellWpx: 16, cellHpx: 32, padWpx: 8, padHpx: 4, scale: 2) + #expect(oneX == twoX) + + let request = OverlaySize.cells(cols: 10, rows: 5) + let paneSize = pane(800, 600) + let sizeOneX = OverlayLayout.panelSize(request, pane: paneSize, cell: oneX) + let sizeTwoX = OverlayLayout.panelSize(request, pane: paneSize, cell: twoX) + #expect(sizeOneX == sizeTwoX) + // in points both resolve to 10*8+4 = 84 wide, 5*16+2 = 82 tall. + expectSize(sizeOneX, 84, 82) + } + + @Test func subPixelCellMetricsProduceExactPointSize() { + // 15px cells on a 2x display convert to 7.5pt; padding 3px -> 1.5pt. + let cell = OverlayCellMetrics(cellWidth: 7.5, cellHeight: 7.5, padWidth: 1.5, padHeight: 1.5) + let result = OverlayLayout.panelSize(.cells(cols: 4, rows: 4), pane: pane(800, 600), cell: cell) + // 4*7.5+1.5 = 31.5 in each dimension. + expectSize(result, 31.5, 31.5) + } + + @Test func subPixelAvailableFloorsFitCount() { + // available 100, pad 1.5, cell 7.5 -> floor((100-1.5)/7.5) = floor(13.133) = 13 columns. + let cell = OverlayCellMetrics(cellWidth: 7.5, cellHeight: 7.5, padWidth: 1.5, padHeight: 1.5) + let result = OverlayLayout.panelSize(.cells(cols: 50, rows: 50), pane: pane(100, 100), cell: cell) + // 13*7.5+1.5 = 99, both dimensions. + expectSize(result, 99, 99) + } + + @Test func nilCellMetricsFallBackToPane() { + expectSize(OverlayLayout.panelSize(.cells(cols: 10, rows: 5), pane: pane(800, 600), cell: nil), 800, 600) + } + + @Test func unusableCellMetricsFallBackToPane() { + let zeroWidth = OverlayCellMetrics(cellWidth: 0, cellHeight: 16, padWidth: 4, padHeight: 6) + expectSize(OverlayLayout.panelSize(.cells(cols: 10, rows: 5), pane: pane(800, 600), cell: zeroWidth), 800, 600) + let negative = OverlayCellMetrics(cellWidth: -8, cellHeight: -16, padWidth: 0, padHeight: 0) + expectSize(OverlayLayout.panelSize(.cells(cols: 10, rows: 5), pane: pane(800, 600), cell: negative), 800, 600) + } + + @Test func paneSmallerThanOneCellFloorsToOneCellButNeverExceedsPane() { + let cell = OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 4, padHeight: 6) + // pane too small to hold even one cell + padding: floor at one cell yet capped at the pane size. + let result = OverlayLayout.panelSize(.cells(cols: 10, rows: 5), pane: pane(5, 10), cell: cell) + expectSize(result, 5, 10) + } + + @Test func minOneCellFloorWhenRequestIsZeroFit() { + let cell = OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 4, padHeight: 6) + // a pane that fits exactly one cell + padding still yields one whole cell. + let result = OverlayLayout.panelSize(.cells(cols: 100, rows: 100), pane: pane(12, 22), cell: cell) + // width 1*8+4 = 12, height 1*16+6 = 22. + expectSize(result, 12, 22) + } + + @Test func isUsableReflectsPositiveCellDimensions() { + #expect(OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 0, padHeight: 0).isUsable) + #expect(!OverlayCellMetrics(cellWidth: 0, cellHeight: 16, padWidth: 0, padHeight: 0).isUsable) + #expect(!OverlayCellMetrics(cellWidth: 8, cellHeight: 0, padWidth: 0, padHeight: 0).isUsable) + #expect(!OverlayCellMetrics(cellWidth: -1, cellHeight: -1, padWidth: 0, padHeight: 0).isUsable) + } + + @Test func anchorHasNineCasesWithDefaultCenter() { + #expect(OverlayAnchor.allCases.count == 9) + #expect(OverlayAnchor(rawValue: "center") == .center) + } + + @Test(arguments: [ + (OverlayAnchor.topLeft, 0.0, 0.0), (.top, 0.5, 0.0), (.topRight, 1.0, 0.0), + (.left, 0.0, 0.5), (.center, 0.5, 0.5), (.right, 1.0, 0.5), + (.bottomLeft, 0.0, 1.0), (.bottom, 0.5, 1.0), (.bottomRight, 1.0, 1.0) + ]) + func anchorUnitPoints(anchor: OverlayAnchor, unitX: Double, unitY: Double) { + #expect(anchor.unitX == unitX) + #expect(anchor.unitY == unitY) + } + + @Test func anchorRawValuesUseHyphenatedCornerNames() { + #expect(OverlayAnchor.topLeft.rawValue == "top-left") + #expect(OverlayAnchor.topRight.rawValue == "top-right") + #expect(OverlayAnchor.bottomLeft.rawValue == "bottom-left") + #expect(OverlayAnchor.bottomRight.rawValue == "bottom-right") + #expect(OverlayAnchor.top.rawValue == "top") + #expect(OverlayAnchor(rawValue: "diagonal") == nil) + } +} diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md index 54f7d6c4..af43b5e6 100644 --- a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md +++ b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md @@ -281,13 +281,13 @@ via `OverlayLayout.panelSize` + anchor alignment. `tree` reports requested + app - Create: `agtermCore/Sources/agtermCore/OverlayLayout.swift` - Create: `agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift` -- [ ] add `OverlaySize`, `OverlayAnchor` (with `unitX`/`unitY`), `OverlayCellMetrics` (points, `isUsable`) -- [ ] add `OverlayLayout.panelSize(_:pane:cell:)` — full/percent/whole-cell-clamp with guards: nil or +- [x] add `OverlaySize`, `OverlayAnchor` (with `unitX`/`unitY`), `OverlayCellMetrics` (points, `isUsable`) +- [x] add `OverlayLayout.panelSize(_:pane:cell:)` — full/percent/whole-cell-clamp with guards: nil or unusable cell → pane; pane smaller than one cell + padding → clamp to >=1 cell but never exceed pane -- [ ] write tests: percent (incl. 100), cells within pane, cells wider/taller than pane (whole-cell +- [x] write tests: percent (incl. 100), cells within pane, cells wider/taller than pane (whole-cell clamp), **1× and 2× (Retina) metrics producing the correct point size**, sub-pixel rounding cases, nil/unusable metrics fallback, min-1-cell floor, all-9 anchor unit points -- [ ] run `cd agtermCore && swift test` — must pass before next task +- [x] run `cd agtermCore && swift test` — must pass before next task ### Task 2: Session state + store API + controlTree percent migration (single compiling unit) From 1b4be98cb9c46918f4bfc4cf94824f467aa1dc1c Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 00:20:52 -0500 Subject: [PATCH 03/20] feat: migrate overlay to OverlaySize enum with anchor and options --- agterm/AppActions.swift | 6 +- .../Sources/agtermCore/AppStore+Panes.swift | 86 ++++++--- agtermCore/Sources/agtermCore/AppStore.swift | 6 +- agtermCore/Sources/agtermCore/Session.swift | 52 +++-- .../agtermCoreTests/AppStorePaneTests.swift | 177 +++++++++++++----- .../Tests/agtermCoreTests/SessionTests.swift | 28 ++- .../agtermCoreTests/TerminalZoomTests.swift | 2 +- ...20260722-overlay-anchor-and-cell-sizing.md | 12 +- 8 files changed, 265 insertions(+), 104 deletions(-) diff --git a/agterm/AppActions.swift b/agterm/AppActions.swift index 15680a1d..5de1e6cd 100644 --- a/agterm/AppActions.swift +++ b/agterm/AppActions.swift @@ -354,7 +354,8 @@ final class AppActions { func editKeymap() { guard uiActionsEnabled else { return } guard let store, let id = store.selectedSessionID, let path = settingsModel?.keymapPath else { return } - if store.openOverlay(id, command: ConfigPaths.editorCommand(forPath: path), sizePercent: 95) { + if store.openOverlay(id, options: .init(command: ConfigPaths.editorCommand(forPath: path), + size: .percent(95), anchor: .center)) { keymapEditOverlaySession = id } } @@ -387,7 +388,8 @@ final class AppActions { func editGhosttyConfig() { guard uiActionsEnabled else { return } guard let store, let id = store.selectedSessionID, let path = settingsModel?.ghosttyConfigPath else { return } - if store.openOverlay(id, command: ConfigPaths.editorCommand(forPath: path), sizePercent: 95) { + if store.openOverlay(id, options: .init(command: ConfigPaths.editorCommand(forPath: path), + size: .percent(95), anchor: .center)) { ghosttyEditOverlaySession = id ghosttyEditOverlaySnapshot = try? String(contentsOfFile: path, encoding: .utf8) } diff --git a/agtermCore/Sources/agtermCore/AppStore+Panes.swift b/agtermCore/Sources/agtermCore/AppStore+Panes.swift index 28dcbc82..eca1ee91 100644 --- a/agtermCore/Sources/agtermCore/AppStore+Panes.swift +++ b/agtermCore/Sources/agtermCore/AppStore+Panes.swift @@ -1,5 +1,26 @@ import Foundation +/// Options for opening a session overlay, grouped into a struct per the 4-plus-parameter rule. Host-free; +/// the app-target arms and the internal editor-overlay callers build one and hand it to `openOverlay`. +public struct OverlayOpenOptions: Sendable { + public let command: String + public let cwd: String? + public let wait: Bool + public let size: OverlaySize + public let anchor: OverlayAnchor + public let backgroundColor: String? + + public init(command: String, cwd: String? = nil, wait: Bool = false, size: OverlaySize = .full, + anchor: OverlayAnchor = .center, backgroundColor: String? = nil) { + self.command = command + self.cwd = cwd + self.wait = wait + self.size = size + self.anchor = anchor + self.backgroundColor = backgroundColor + } +} + // MARK: - Split, overlay, and scratch panes extension AppStore { @@ -167,42 +188,53 @@ extension AppStore { closeSplit(sessionID) } - /// Opens an ephemeral overlay terminal on a session running `command` (e.g. a TUI). The overlay - /// surface is created lazily by the detail pane and runs the command as its process; when the - /// program exits, `closeOverlay` tears it down. No-op (returns false) when the session is unknown - /// or already has an overlay open. NOT persisted — the overlay never survives a relaunch. + /// Opens an ephemeral overlay terminal on a session running `options.command` (e.g. a TUI). The overlay + /// surface is created lazily by the detail pane and runs the command as its process; when the program + /// exits, `closeOverlay` tears it down. No-op (returns false) when the session is unknown or already + /// has an overlay open. NOT persisted — the overlay never survives a relaunch. /// - /// `sizePercent` (clamped to 1...100) requests a *floating* overlay: an opaque, framed panel sized - /// to that percent of the pane, with the session still visible behind it. nil gives the default - /// full-pane overlay that hides the session. + /// `options.size` chooses the layout: `.full` gives the default full-pane overlay that hides the + /// session; `.percent`/`.cells` request a *floating* overlay, an opaque framed panel with the session + /// still visible behind it, placed by `options.anchor`. A `.percent` is defensively clamped to 1...100 + /// for internal callers; the dispatcher/CLI hard-error out-of-range values before reaching here. /// - /// `backgroundColor` (`#rrggbb`) gives the overlay pane its own solid background, independent of the - /// session's; nil leaves the default theme background. Read by the overlay surface factory at creation. - @discardableResult public func openOverlay(_ sessionID: UUID, command: String, cwd: String? = nil, - wait: Bool = false, sizePercent: Int? = nil, - backgroundColor: String? = nil) -> Bool { + /// `options.backgroundColor` (`#rrggbb`) gives the overlay pane its own solid background, independent + /// of the session's; nil leaves the default theme background. Read by the surface factory at creation. + @discardableResult public func openOverlay(_ sessionID: UUID, options: OverlayOpenOptions) -> Bool { guard let session = session(withID: sessionID), !session.overlayActive else { return false } - session.overlayCommand = command - session.overlayCwd = cwd - session.overlayWait = wait + session.overlayCommand = options.command + session.overlayCwd = options.cwd + session.overlayWait = options.wait session.overlayExitCode = nil - session.overlaySizePercent = sizePercent.map { min(100, max(1, $0)) } - session.overlayBackgroundColor = backgroundColor + session.overlaySize = AppStore.clampedOverlaySize(options.size) + session.overlayAnchor = options.anchor + session.overlayBackgroundColor = options.backgroundColor session.overlayActive = true return true } - /// Resizes an already-open overlay in place. `sizePercent` (clamped to 1...100) switches it to a - /// *floating* opaque framed panel at that percent of the pane with the session visible behind it; - /// nil switches it to the full-pane overlay that hides the session and draws translucent. The overlay - /// surface stays mounted (the detail pane hosts both variants in one place), so this only re-flows the - /// layout — the program keeps running, never re-spawns. No-op (returns false) with no overlay open. - @discardableResult public func resizeOverlay(_ sessionID: UUID, sizePercent: Int?) -> Bool { + /// Resizes and/or re-anchors an already-open overlay in place. `size` switches the layout — `.full` + /// to the full-pane overlay that hides the session, `.percent`/`.cells` to a floating panel over the + /// still-visible session; `anchor` moves the floating panel. Either may be nil to KEEP the current + /// value, so a resize, an in-place re-anchor, or both are one call; a `.full` size keeps the anchor + /// (only `closeOverlay` resets it). The overlay surface stays mounted, so this only re-flows the layout + /// — the program keeps running, never re-spawns. No-op (returns false) with no overlay open. + @discardableResult public func resizeOverlay(_ sessionID: UUID, size: OverlaySize? = nil, + anchor: OverlayAnchor? = nil) -> Bool { guard let session = session(withID: sessionID), session.overlayActive else { return false } - session.overlaySizePercent = sizePercent.map { min(100, max(1, $0)) } + if let size { session.overlaySize = AppStore.clampedOverlaySize(size) } + if let anchor { session.overlayAnchor = anchor } return true } + /// Clamps a `.percent` overlay size into 1...100 — the defensive internal clamp retained from the old + /// `overlaySizePercent` setter, so a raw internal caller can't set an out-of-range percent. `.full` and + /// `.cells` pass through unchanged (their bounds are enforced at the dispatcher/CLI boundary). + static func clampedOverlaySize(_ size: OverlaySize) -> OverlaySize { + guard case .percent(let percent) = size else { return size } + return .percent(min(100, max(1, percent))) + } + /// Records the overlay program's exit status (parsed app-side from the wrapper's temp file on the /// surface's teardown) so `session.overlay.result` can report it after the overlay closes. No-op /// for an unknown session. @@ -221,7 +253,11 @@ extension AppStore { session.overlayCommand = nil session.overlayCwd = nil session.overlayWait = false - session.overlaySizePercent = nil + session.overlaySize = .full + session.overlayAnchor = .center + session.overlayCellMetrics = nil + session.overlayAppliedCols = nil + session.overlayAppliedRows = nil session.overlayBackgroundColor = nil return true } diff --git a/agtermCore/Sources/agtermCore/AppStore.swift b/agtermCore/Sources/agtermCore/AppStore.swift index b718bc03..e4f3be46 100644 --- a/agtermCore/Sources/agtermCore/AppStore.swift +++ b/agtermCore/Sources/agtermCore/AppStore.swift @@ -198,6 +198,10 @@ public final class AppStore { let idle = session.agentIndicator.status == .idle let status = idle ? nil : session.agentIndicator.status.rawValue let statusPane = idle ? nil : session.agentIndicator.statusPane?.rawValue + // `overlaySizePercent` (back-compat wire field) derives from `overlaySize`: the percent for + // a `.percent` floating overlay, omitted for full or cells (and when no overlay is open). + let overlayPercent: Int? = if session.overlayActive, + case .percent(let percent) = session.overlaySize { percent } else { nil } let surfaces = TerminalZoomSurface.allCases.compactMap { surface -> ControlSurfaceNode? in guard surface.isAvailable(in: session) else { return nil } let id = TerminalSurfaceID(sessionID: session.id, surface: surface).rawValue @@ -212,7 +216,7 @@ public final class AppStore { splitRatio: session.hasSplit ? session.splitRatio : nil, splitFocused: session.hasSplit ? session.splitFocused : nil, overlay: session.overlayActive, - overlaySizePercent: session.overlayActive ? session.overlaySizePercent : nil, + overlaySizePercent: overlayPercent, scratch: session.scratchActive, flagged: session.flagged, commandWait: (session.initialCommand != nil && session.commandWait) ? true : nil, foreground: foreground(session), diff --git a/agtermCore/Sources/agtermCore/Session.swift b/agtermCore/Sources/agtermCore/Session.swift index dc000d06..dc147733 100644 --- a/agtermCore/Sources/agtermCore/Session.swift +++ b/agtermCore/Sources/agtermCore/Session.swift @@ -206,25 +206,43 @@ public final class Session: Identifiable { /// In-memory only (absent from `snapshot()`), so it never persists. @ObservationIgnored public var overlayExitCode: Int? - /// For a *floating* overlay, the percent of the pane (both width and height) the panel occupies, - /// 1...100; nil for the default full-pane overlay. A floating overlay renders as an opaque, framed - /// panel centered in the pane with the session still VISIBLE behind it (the full overlay instead - /// hides the session and draws translucent). Observed, so the detail pane picks the right layout. - /// Set at open, cleared on close; never persisted. - public var overlaySizePercent: Int? - - /// Whether a FULL-coverage overlay is up: `overlayActive` with no size percent. A full overlay hides - /// the session content beneath it — the pane(s) AND a shown scratch — so its translucent background - /// reveals the window backing, never a covered surface (under window translucency every surface - /// renders a fully transparent background, so anything left visible below would bleed through). + /// The requested size mode for the overlay panel: `.full` (the default full-pane overlay that hides + /// the session and draws translucent), `.percent(1...100)`, or `.cells(cols, rows)` — the latter two + /// floating, an opaque framed panel over the still-visible session placed by `overlayAnchor`. Host-free; + /// the app resolves it to a concrete frame via `OverlayLayout.panelSize`. Observed, so the detail pane + /// re-flows when it changes. Set at open/resize, reset to `.full` on close; never persisted. + public var overlaySize: OverlaySize = .full + + /// Where a *floating* overlay panel anchors within the pane — one of nine positions, default `.center` + /// (today's placement). Observed, so the detail pane re-positions when it changes. Preserved across a + /// `.full` round-trip (only `closeOverlay` resets it to `.center`); never persisted. + public var overlayAnchor: OverlayAnchor = .center + + /// The overlay surface's live cell + padding metrics in POINTS, app-maintained (written on surface + /// realization, `GHOSTTY_ACTION_CELL_SIZE`, and backing-scale change) so a `.cells` layout can snap to + /// whole cells. Observed, so the panel re-lays-out when the metrics change — the imperative + /// `overlaySurface` can't drive the view. nil before the surface reports; reset on close. + public var overlayCellMetrics: OverlayCellMetrics? + + /// The overlay surface's REALIZED grid (columns/rows actually rendered after clamping), app-maintained + /// from the live surface for the `tree` read-back, so a script can detect a clamp or drift vs the + /// request. Observed; nil before the surface reports or when no overlay is open; reset on close. + public var overlayAppliedCols: Int? + /// The overlay surface's realized row count — the row analogue of `overlayAppliedCols`. + public var overlayAppliedRows: Int? + + /// Whether a FULL-coverage overlay is up: `overlayActive` with `overlaySize == .full`. A full overlay + /// hides the session content beneath it — the pane(s) AND a shown scratch — so its translucent + /// background reveals the window backing, never a covered surface (under window translucency every + /// surface renders a fully transparent background, so anything left visible below would bleed through). /// A floating (sized) overlay is not a cover: it draws an opaque panel over still-visible content. - public var fullOverlayActive: Bool { overlayActive && overlaySizePercent == nil } + public var fullOverlayActive: Bool { overlayActive && overlaySize == .full } - /// Whether a FLOATING overlay is up: `overlayActive` WITH a size percent — the complement of - /// `fullOverlayActive`. A floating overlay draws an opaque, framed panel sized to `overlaySizePercent`% - /// over the still-visible session rather than covering it, so it is not a cover. Read by the detail - /// pane to gate the floating panel. - public var floatingOverlayActive: Bool { overlayActive && overlaySizePercent != nil } + /// Whether a FLOATING overlay is up: `overlayActive` with a non-`.full` `overlaySize` — the complement + /// of `fullOverlayActive`. A floating overlay draws an opaque, framed panel sized to `overlaySize` over + /// the still-visible session rather than covering it, so it is not a cover. Read by the detail pane to + /// gate the floating panel. + public var floatingOverlayActive: Bool { overlayActive && overlaySize != .full } /// Whether the scratch terminal is shown on top of this session (full single-pane size, hiding /// the single/split content underneath, like a full overlay). The scratch is a third per-session diff --git a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift index 26bf4252..35a4cb51 100644 --- a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift +++ b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift @@ -425,28 +425,42 @@ struct AppStorePaneTests { let store = makeStore() let ws = store.addWorkspace(name: "work") let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! - #expect(store.openOverlay(session.id, command: "revdiff", cwd: "/b") == true) + #expect(store.openOverlay(session.id, options: .init(command: "revdiff", cwd: "/b")) == true) #expect(session.overlayActive == true) #expect(session.overlayCommand == "revdiff") #expect(session.overlayCwd == "/b") - // no size given → the default full-pane overlay, not a floating one. - #expect(session.overlaySizePercent == nil) + // no size given → the default full-pane overlay, not a floating one, centered. + #expect(session.overlaySize == .full) + #expect(session.overlayAnchor == .center) + #expect(session.fullOverlayActive) // a second open while one is active is a no-op. - #expect(store.openOverlay(session.id, command: "other") == false) + #expect(store.openOverlay(session.id, options: .init(command: "other")) == false) #expect(session.overlayCommand == "revdiff") } + @Test func openOverlaySetsSizeAndAnchor() { + let store = makeStore() + let ws = store.addWorkspace(name: "work") + let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! + // a floating cells-mode overlay parked in a corner: size and anchor both land on the session. + #expect(store.openOverlay(session.id, options: .init(command: "htop", size: .cells(cols: 80, rows: 24), + anchor: .bottomRight)) == true) + #expect(session.overlaySize == .cells(cols: 80, rows: 24)) + #expect(session.overlayAnchor == .bottomRight) + #expect(session.floatingOverlayActive) + } + @Test func openOverlayCarriesBackgroundColorAndCloseClears() { let store = makeStore() let ws = store.addWorkspace(name: "work") let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! - #expect(store.openOverlay(session.id, command: "revdiff", backgroundColor: "#2a1a3a") == true) + #expect(store.openOverlay(session.id, options: .init(command: "revdiff", backgroundColor: "#2a1a3a")) == true) #expect(session.overlayBackgroundColor == "#2a1a3a") // close clears the overlay's color back to nil, like the other ephemeral overlay fields. store.closeOverlay(session.id) #expect(session.overlayBackgroundColor == nil) // omitting the color leaves it nil (default theme background, unchanged behavior). - #expect(store.openOverlay(session.id, command: "revdiff") == true) + #expect(store.openOverlay(session.id, options: .init(command: "revdiff")) == true) #expect(session.overlayBackgroundColor == nil) } @@ -454,14 +468,14 @@ struct AppStorePaneTests { let store = makeStore() let ws = store.addWorkspace(name: "work") let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! - store.openOverlay(session.id, command: "revdiff") + store.openOverlay(session.id, options: .init(command: "revdiff")) #expect(session.overlayExitCode == nil) store.recordOverlayExit(session.id, code: 10) #expect(store.closeOverlay(session.id) == true) // the exit code survives close (read by session.overlay.result after the overlay vanishes)... #expect(session.overlayExitCode == 10) // ...and is reset when a new overlay opens. - #expect(store.openOverlay(session.id, command: "revdiff") == true) + #expect(store.openOverlay(session.id, options: .init(command: "revdiff")) == true) #expect(session.overlayExitCode == nil) } @@ -474,58 +488,103 @@ struct AppStorePaneTests { #expect(session.overlayExitCode == nil) } - @Test func openOverlayFloatingClampsSizePercent() { + @Test func openOverlayFloatingClampsPercent() { let store = makeStore() let ws = store.addWorkspace(name: "work") let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! - #expect(store.openOverlay(session.id, command: "htop", sizePercent: 70) == true) - #expect(session.overlaySizePercent == 70) - // close clears the floating size back to nil. + #expect(store.openOverlay(session.id, options: .init(command: "htop", size: .percent(70))) == true) + #expect(session.overlaySize == .percent(70)) + // close resets the size back to .full (and the anchor to center). store.closeOverlay(session.id) - #expect(session.overlaySizePercent == nil) - // out-of-range values clamp to 1...100, including negatives; the exact bounds pass through. - store.openOverlay(session.id, command: "htop", sizePercent: 250) - #expect(session.overlaySizePercent == 100) + #expect(session.overlaySize == .full) + #expect(session.overlayAnchor == .center) + // the defensive internal clamp: out-of-range percents clamp to 1...100, including negatives; the + // exact bounds pass through. (the dispatcher/CLI hard-error these — this guards the store's clamp.) + store.openOverlay(session.id, options: .init(command: "htop", size: .percent(250))) + #expect(session.overlaySize == .percent(100)) store.closeOverlay(session.id) - store.openOverlay(session.id, command: "htop", sizePercent: 0) - #expect(session.overlaySizePercent == 1) + store.openOverlay(session.id, options: .init(command: "htop", size: .percent(0))) + #expect(session.overlaySize == .percent(1)) store.closeOverlay(session.id) - store.openOverlay(session.id, command: "htop", sizePercent: -5) - #expect(session.overlaySizePercent == 1) + store.openOverlay(session.id, options: .init(command: "htop", size: .percent(-5))) + #expect(session.overlaySize == .percent(1)) store.closeOverlay(session.id) - store.openOverlay(session.id, command: "htop", sizePercent: 100) - #expect(session.overlaySizePercent == 100) + store.openOverlay(session.id, options: .init(command: "htop", size: .percent(100))) + #expect(session.overlaySize == .percent(100)) store.closeOverlay(session.id) - store.openOverlay(session.id, command: "htop", sizePercent: 1) - #expect(session.overlaySizePercent == 1) + store.openOverlay(session.id, options: .init(command: "htop", size: .percent(1))) + #expect(session.overlaySize == .percent(1)) } - @Test func resizeOverlaySwitchesFullAndFloatingAndClamps() { + @Test func resizeOverlayNoOpWithoutOverlay() { let store = makeStore() let ws = store.addWorkspace(name: "work") let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! - // no overlay open → no-op, leaves size untouched. - #expect(store.resizeOverlay(session.id, sizePercent: 50) == false) - #expect(session.overlaySizePercent == nil) - // open full, then resize it to a floating percent (nil → 60). - store.openOverlay(session.id, command: "htop") - #expect(session.overlaySizePercent == nil) - #expect(store.resizeOverlay(session.id, sizePercent: 60) == true) - #expect(session.overlaySizePercent == 60) + // no overlay open → no-op, leaves the size at its .full default. + #expect(store.resizeOverlay(session.id, size: .percent(50)) == false) + #expect(session.overlaySize == .full) + } + + @Test func resizeOverlayPercentThenCellsThenFullAndClamps() { + let store = makeStore() + let ws = store.addWorkspace(name: "work") + let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! + store.openOverlay(session.id, options: .init(command: "htop")) + #expect(session.overlaySize == .full) + // full → floating percent. + #expect(store.resizeOverlay(session.id, size: .percent(60)) == true) + #expect(session.overlaySize == .percent(60)) + #expect(session.floatingOverlayActive) + // percent → exact cells. + #expect(store.resizeOverlay(session.id, size: .cells(cols: 100, rows: 30)) == true) + #expect(session.overlaySize == .cells(cols: 100, rows: 30)) #expect(session.floatingOverlayActive) - // resize back to full (nil). - #expect(store.resizeOverlay(session.id, sizePercent: nil) == true) - #expect(session.overlaySizePercent == nil) + // cells → back to full. + #expect(store.resizeOverlay(session.id, size: .full) == true) + #expect(session.overlaySize == .full) #expect(session.fullOverlayActive) - // out-of-range percents clamp to 1...100. - store.resizeOverlay(session.id, sizePercent: 250) - #expect(session.overlaySizePercent == 100) - store.resizeOverlay(session.id, sizePercent: 0) - #expect(session.overlaySizePercent == 1) + // out-of-range percents clamp to 1...100 on resize too. + store.resizeOverlay(session.id, size: .percent(250)) + #expect(session.overlaySize == .percent(100)) + store.resizeOverlay(session.id, size: .percent(0)) + #expect(session.overlaySize == .percent(1)) // the overlay program keeps running across every resize (no re-spawn). #expect(session.overlayActive) } + @Test func resizeOverlayAnchorOnlyKeepsSize() { + let store = makeStore() + let ws = store.addWorkspace(name: "work") + let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! + store.openOverlay(session.id, options: .init(command: "htop", size: .percent(80), anchor: .center)) + // a nil size = keep the current size; only the anchor moves. + #expect(store.resizeOverlay(session.id, anchor: .topRight) == true) + #expect(session.overlaySize == .percent(80)) + #expect(session.overlayAnchor == .topRight) + } + + @Test func resizeOverlaySizeOnlyKeepsAnchor() { + let store = makeStore() + let ws = store.addWorkspace(name: "work") + let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! + store.openOverlay(session.id, options: .init(command: "htop", size: .percent(80), anchor: .bottomLeft)) + // a nil anchor = keep the current anchor; only the size changes. + #expect(store.resizeOverlay(session.id, size: .cells(cols: 40, rows: 12)) == true) + #expect(session.overlaySize == .cells(cols: 40, rows: 12)) + #expect(session.overlayAnchor == .bottomLeft) + } + + @Test func resizeOverlayFullPreservesAnchor() { + let store = makeStore() + let ws = store.addWorkspace(name: "work") + let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! + store.openOverlay(session.id, options: .init(command: "htop", size: .percent(80), anchor: .top)) + // resizing to .full keeps the anchor — only closeOverlay resets it to center (Decision 2). + #expect(store.resizeOverlay(session.id, size: .full) == true) + #expect(session.overlaySize == .full) + #expect(session.overlayAnchor == .top) + } + @Test func controlTreeReportsCommandWait() throws { let store = makeStore() let ws = store.addWorkspace(name: "work") @@ -553,13 +612,18 @@ struct AppStorePaneTests { var node = try #require(store.controlTree().workspaces[0].sessions.first) #expect(node.overlay == false) #expect(node.overlaySizePercent == nil) - // floating overlay: the percent rides the node so a script can record it before zooming. - store.openOverlay(session.id, command: "htop", sizePercent: 95) + // floating percent overlay: the percent rides the node so a script can record it before zooming. + store.openOverlay(session.id, options: .init(command: "htop", size: .percent(95))) node = try #require(store.controlTree().workspaces[0].sessions.first) #expect(node.overlay == true) #expect(node.overlaySizePercent == 95) - // full-pane overlay: open but no size (nil = full). - store.resizeOverlay(session.id, sizePercent: nil) + // a cells-mode overlay is floating but NOT percent, so the percent wire field is omitted. + store.resizeOverlay(session.id, size: .cells(cols: 80, rows: 24)) + node = try #require(store.controlTree().workspaces[0].sessions.first) + #expect(node.overlay == true) + #expect(node.overlaySizePercent == nil) + // full-pane overlay: open but no percent (full). + store.resizeOverlay(session.id, size: .full) node = try #require(store.controlTree().workspaces[0].sessions.first) #expect(node.overlay == true) #expect(node.overlaySizePercent == nil) @@ -716,7 +780,7 @@ struct AppStorePaneTests { let store = makeStore() let ws = store.addWorkspace(name: "work") let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! - store.openOverlay(session.id, command: "revdiff") + store.openOverlay(session.id, options: .init(command: "revdiff")) let overlay = SpySurface() session.overlaySurface = overlay #expect(store.closeOverlay(session.id) == true) @@ -728,11 +792,30 @@ struct AppStorePaneTests { #expect(store.closeOverlay(session.id) == false) } + @Test func closeOverlayResetsSizeAnchorAndMetrics() { + let store = makeStore() + let ws = store.addWorkspace(name: "work") + let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! + store.openOverlay(session.id, options: .init(command: "htop", size: .cells(cols: 80, rows: 24), + anchor: .bottomRight)) + // simulate the app-maintained live metrics + realized grid that the surface would populate. + session.overlayCellMetrics = OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 4, padHeight: 4) + session.overlayAppliedCols = 80 + session.overlayAppliedRows = 24 + #expect(store.closeOverlay(session.id) == true) + // close resets the whole floating-overlay state to full/center/nil. + #expect(session.overlaySize == .full) + #expect(session.overlayAnchor == .center) + #expect(session.overlayCellMetrics == nil) + #expect(session.overlayAppliedCols == nil) + #expect(session.overlayAppliedRows == nil) + } + @Test func closeSessionTearsDownOverlaySurface() { let store = makeStore() let ws = store.addWorkspace(name: "work") let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! - store.openOverlay(session.id, command: "revdiff") + store.openOverlay(session.id, options: .init(command: "revdiff")) let overlay = SpySurface() session.overlaySurface = overlay store.closeSession(session.id) diff --git a/agtermCore/Tests/agtermCoreTests/SessionTests.swift b/agtermCore/Tests/agtermCoreTests/SessionTests.swift index 71ed8736..417a7533 100644 --- a/agtermCore/Tests/agtermCoreTests/SessionTests.swift +++ b/agtermCore/Tests/agtermCoreTests/SessionTests.swift @@ -402,20 +402,38 @@ struct SessionTests { } @Test func fullOverlayActiveOnlyForFullCoverageOverlay() { - // the full-coverage overlay (no size) hides the session content beneath it — panes AND scratch — + // the full-coverage overlay (.full) hides the session content beneath it — panes AND scratch — // so its translucent background reveals the window backing, never the covered surfaces. let session = Session(initialCwd: "/repo") // no overlay: nothing to hide behind. #expect(session.fullOverlayActive == false) - // full-coverage overlay (no size percent): active. + #expect(session.floatingOverlayActive == false) + // full-coverage overlay (.full size): active and not floating. session.overlayActive = true #expect(session.fullOverlayActive == true) - // floating (sized) overlay: draws an opaque panel over visible content, not a full cover. - session.overlaySizePercent = 80 + #expect(session.floatingOverlayActive == false) + // floating (percent) overlay: draws an opaque panel over visible content, not a full cover. + session.overlaySize = .percent(80) #expect(session.fullOverlayActive == false) - // overlay closed with a stale size percent lingering: still not a cover. + #expect(session.floatingOverlayActive == true) + // a cells-mode floating overlay is likewise not a full cover. + session.overlaySize = .cells(cols: 80, rows: 24) + #expect(session.fullOverlayActive == false) + #expect(session.floatingOverlayActive == true) + // overlay closed with a stale non-full size lingering: neither predicate fires. session.overlayActive = false #expect(session.fullOverlayActive == false) + #expect(session.floatingOverlayActive == false) + } + + @Test func overlayStateDefaultsToFullCenterNilMetrics() { + // a fresh session carries the full/center/nil overlay defaults until an open sets them. + let session = Session(initialCwd: "/repo") + #expect(session.overlaySize == .full) + #expect(session.overlayAnchor == .center) + #expect(session.overlayCellMetrics == nil) + #expect(session.overlayAppliedCols == nil) + #expect(session.overlayAppliedRows == nil) } @Test func paneRoleResolvesTokenToItsCurrentSlot() { diff --git a/agtermCore/Tests/agtermCoreTests/TerminalZoomTests.swift b/agtermCore/Tests/agtermCoreTests/TerminalZoomTests.swift index 9429340c..529e8d93 100644 --- a/agtermCore/Tests/agtermCoreTests/TerminalZoomTests.swift +++ b/agtermCore/Tests/agtermCoreTests/TerminalZoomTests.swift @@ -101,7 +101,7 @@ struct TerminalZoomTests { store.closeScratch(session.id) #expect(!TerminalZoomController.isTargetValid(.session(session.id, .scratch), in: store, quickTerminalVisible: false)) - #expect(store.openOverlay(session.id, command: "top")) + #expect(store.openOverlay(session.id, options: .init(command: "top"))) #expect(TerminalZoomController.isTargetValid(.session(session.id, .overlay), in: store, quickTerminalVisible: false)) #expect(store.closeOverlay(session.id)) #expect(!TerminalZoomController.isTargetValid(.session(session.id, .overlay), in: store, quickTerminalVisible: false)) diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md index af43b5e6..191daab8 100644 --- a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md +++ b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md @@ -300,21 +300,21 @@ via `OverlayLayout.panelSize` + anchor alignment. `tree` reports requested + app - Modify: `agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift` - Modify: `agtermCore/Tests/agtermCoreTests/TerminalZoomTests.swift` -- [ ] replace `Session.overlaySizePercent` with `overlaySize: OverlaySize = .full`; add `overlayAnchor: +- [x] replace `Session.overlaySizePercent` with `overlaySize: OverlaySize = .full`; add `overlayAnchor: OverlayAnchor = .center`, observed `overlayCellMetrics: OverlayCellMetrics?`, `overlayAppliedCols: Int?`, `overlayAppliedRows: Int?`; re-express `fullOverlayActive`/`floatingOverlayActive` -- [ ] add `OverlayOpenOptions`; `AppStore.openOverlay(_:options:)` (defensive percent clamp retained); +- [x] add `OverlayOpenOptions`; `AppStore.openOverlay(_:options:)` (defensive percent clamp retained); `resizeOverlay(_:size:anchor:)` (nil = keep; `.full` keeps anchor); `closeOverlay` resets size/anchor/metrics/applied -- [ ] migrate `AppStore.controlTree` percent read to derive from `overlaySize` (keep `overlaySizePercent` +- [x] migrate `AppStore.controlTree` percent read to derive from `overlaySize` (keep `overlaySizePercent` wire field populated); migrate `AppActions.swift:357/390` to `openOverlay(options: .init(... size: .percent(95) ...))` -- [ ] update `AppStorePaneTests` (open/resize via new API; keep the 250→100 / 0→1 defensive-clamp cases) +- [x] update `AppStorePaneTests` (open/resize via new API; keep the 250→100 / 0→1 defensive-clamp cases) and `TerminalZoomTests:104` -- [ ] write tests: open sets size/anchor; resize percent→cells→full; resize anchor-only keeps size; +- [x] write tests: open sets size/anchor; resize percent→cells→full; resize anchor-only keeps size; size-only keeps anchor; **`--full` preserves the anchor**; predicates per mode; close resets to full/center/nil -- [ ] run `cd agtermCore && swift test` — must pass before next task (app target not yet built) +- [x] run `cd agtermCore && swift test` — must pass before next task (app target not yet built) ### Task 3: Wire protocol — args + read-back node (requested + applied + anchor) From a1e0ca9e5175db34de20eae6a8698eeaf7d4d719 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 00:30:44 -0500 Subject: [PATCH 04/20] feat: add overlay cols/rows/anchor control args and tree read-back --- .../agtermCore/AppStore+ControlTree.swift | 93 ++++++++++++++++++ agtermCore/Sources/agtermCore/AppStore.swift | 72 -------------- .../Sources/agtermCore/ControlProtocol.swift | 38 ++++++- .../agtermCoreTests/AppStorePaneTests.swift | 33 +++++++ .../Tests/agtermCoreTests/AppStoreTests.swift | 2 +- .../ControlProtocolTests.swift | 98 +++++++++++++++++++ ...20260722-overlay-anchor-and-cell-sizing.md | 10 +- 7 files changed, 267 insertions(+), 79 deletions(-) create mode 100644 agtermCore/Sources/agtermCore/AppStore+ControlTree.swift diff --git a/agtermCore/Sources/agtermCore/AppStore+ControlTree.swift b/agtermCore/Sources/agtermCore/AppStore+ControlTree.swift new file mode 100644 index 00000000..2d17dce9 --- /dev/null +++ b/agtermCore/Sources/agtermCore/AppStore+ControlTree.swift @@ -0,0 +1,93 @@ +import Foundation + +extension AppStore { + /// Projects this store's workspace/session model into the control-channel `tree` payload. Foreground + /// command lookup is supplied by the host because live process inspection is platform-specific. + public func controlTree(foreground: (Session) -> [String]? = { _ in nil }, + splitForeground: (Session) -> [String]? = { _ in nil }, + fontSize: (Session) -> Double? = { _ in nil }, + splitFontSize: (Session) -> Double? = { _ in nil }, + scratchFontSize: (Session) -> Double? = { _ in nil }, + quickVisible: () -> Bool? = { nil }, + zoomedSurface: () -> String? = { nil }, + dashboardMembers: () -> [String]? = { nil }, + dashboardHighlighted: () -> String? = { nil }, + dashboardFontSize: () -> Double? = { nil }, + dashboardFontMode: () -> String? = { nil }) -> ControlTree { + let activeID = selectedSessionID + let activeWorkspaceID = activeID.flatMap { workspace(forSession: $0)?.id } + let nodes = workspaces.map { workspace in + let sessions = workspace.sessions.map { session in + let idle = session.agentIndicator.status == .idle + let status = idle ? nil : session.agentIndicator.status.rawValue + let statusPane = idle ? nil : session.agentIndicator.statusPane?.rawValue + // `overlaySizePercent` (back-compat wire field) derives from `overlaySize`: the percent for + // a `.percent` floating overlay, omitted for full or cells (and when no overlay is open). + let overlayPercent: Int? = if session.overlayActive, + case .percent(let percent) = session.overlaySize { percent } else { nil } + // the REQUESTED grid rides the node only for a cells-mode overlay (nil for percent/full/none). + let overlayCols: Int? + let overlayRows: Int? + if session.overlayActive, case .cells(let cols, let rows) = session.overlaySize { + overlayCols = cols + overlayRows = rows + } else { + overlayCols = nil + overlayRows = nil + } + // the REALIZED grid (any floating overlay) exposes a clamp/drift; the anchor rides ANY open + // overlay incl. full (Decision 2 — anchor is always reported while an overlay is up). + let overlayColsApplied = session.floatingOverlayActive ? session.overlayAppliedCols : nil + let overlayRowsApplied = session.floatingOverlayActive ? session.overlayAppliedRows : nil + let overlayAnchor = session.overlayActive ? session.overlayAnchor.rawValue : nil + let surfaces = TerminalZoomSurface.allCases.compactMap { surface -> ControlSurfaceNode? in + guard surface.isAvailable(in: session) else { return nil } + let id = TerminalSurfaceID(sessionID: session.id, surface: surface).rawValue + return ControlSurfaceNode(id: id, kind: surface.rawValue, + active: surface.isActive(in: session), + visible: surface.isVisible(in: session)) + } + return ControlSessionNode(id: session.id.uuidString, name: session.displayName, + cwd: session.effectiveCwd, title: session.oscTitle, + active: session.id == activeID, + split: session.isSplit, + splitRatio: session.hasSplit ? session.splitRatio : nil, + splitFocused: session.hasSplit ? session.splitFocused : nil, + overlay: session.overlayActive, + overlaySizePercent: overlayPercent, + overlayCols: overlayCols, overlayRows: overlayRows, + overlayColsApplied: overlayColsApplied, + overlayRowsApplied: overlayRowsApplied, overlayAnchor: overlayAnchor, + scratch: session.scratchActive, flagged: session.flagged, + commandWait: (session.initialCommand != nil && session.commandWait) ? true : nil, + foreground: foreground(session), + splitForeground: splitForeground(session), + // the PERSISTED overrides, never the transient pending payloads, + // so a read after one fired still reports what stays pinned. + restoreCommand: session.restoreCommand, + splitRestoreCommand: session.splitRestoreCommand, status: status, + statusPane: statusPane, + statusBlink: idle ? nil : (session.agentIndicator.blink ? true : nil), + statusColor: idle ? nil : session.agentIndicator.color, + background: session.backgroundWatermark, + unseen: session.unseenCount > 0 ? session.unseenCount : nil, + fontSize: fontSize(session), + splitFontSize: splitFontSize(session), + scratchFontSize: scratchFontSize(session), + surfaces: surfaces) + } + return ControlWorkspaceNode(id: workspace.id.uuidString, name: workspace.name, + active: workspace.id == activeWorkspaceID, + focused: workspace.id == focusedWorkspaceID ? true : nil, + collapsed: workspace.isExpanded ? nil : true, + sessions: sessions) + } + return ControlTree(workspaces: nodes, idleMs: idleMs(), autoFollowMs: autoFollowMs, + sidebarVisible: sidebarVisible, sidebarMode: sidebarMode.rawValue, + quickVisible: quickVisible(), zoomedSurface: zoomedSurface(), + dashboardMembers: dashboardMembers(), + dashboardHighlighted: dashboardHighlighted(), + dashboardFontSize: dashboardFontSize(), + dashboardFontMode: dashboardFontMode()) + } +} diff --git a/agtermCore/Sources/agtermCore/AppStore.swift b/agtermCore/Sources/agtermCore/AppStore.swift index e4f3be46..6d8a1046 100644 --- a/agtermCore/Sources/agtermCore/AppStore.swift +++ b/agtermCore/Sources/agtermCore/AppStore.swift @@ -178,78 +178,6 @@ public final class AppStore { "workspace \(workspaces.count + 1)" } - /// Projects this store's workspace/session model into the control-channel `tree` payload. Foreground - /// command lookup is supplied by the host because live process inspection is platform-specific. - public func controlTree(foreground: (Session) -> [String]? = { _ in nil }, - splitForeground: (Session) -> [String]? = { _ in nil }, - fontSize: (Session) -> Double? = { _ in nil }, - splitFontSize: (Session) -> Double? = { _ in nil }, - scratchFontSize: (Session) -> Double? = { _ in nil }, - quickVisible: () -> Bool? = { nil }, - zoomedSurface: () -> String? = { nil }, - dashboardMembers: () -> [String]? = { nil }, - dashboardHighlighted: () -> String? = { nil }, - dashboardFontSize: () -> Double? = { nil }, - dashboardFontMode: () -> String? = { nil }) -> ControlTree { - let activeID = selectedSessionID - let activeWorkspaceID = activeID.flatMap { workspace(forSession: $0)?.id } - let nodes = workspaces.map { workspace in - let sessions = workspace.sessions.map { session in - let idle = session.agentIndicator.status == .idle - let status = idle ? nil : session.agentIndicator.status.rawValue - let statusPane = idle ? nil : session.agentIndicator.statusPane?.rawValue - // `overlaySizePercent` (back-compat wire field) derives from `overlaySize`: the percent for - // a `.percent` floating overlay, omitted for full or cells (and when no overlay is open). - let overlayPercent: Int? = if session.overlayActive, - case .percent(let percent) = session.overlaySize { percent } else { nil } - let surfaces = TerminalZoomSurface.allCases.compactMap { surface -> ControlSurfaceNode? in - guard surface.isAvailable(in: session) else { return nil } - let id = TerminalSurfaceID(sessionID: session.id, surface: surface).rawValue - return ControlSurfaceNode(id: id, kind: surface.rawValue, - active: surface.isActive(in: session), - visible: surface.isVisible(in: session)) - } - return ControlSessionNode(id: session.id.uuidString, name: session.displayName, - cwd: session.effectiveCwd, title: session.oscTitle, - active: session.id == activeID, - split: session.isSplit, - splitRatio: session.hasSplit ? session.splitRatio : nil, - splitFocused: session.hasSplit ? session.splitFocused : nil, - overlay: session.overlayActive, - overlaySizePercent: overlayPercent, - scratch: session.scratchActive, flagged: session.flagged, - commandWait: (session.initialCommand != nil && session.commandWait) ? true : nil, - foreground: foreground(session), - splitForeground: splitForeground(session), - // the PERSISTED overrides, never the transient pending payloads, - // so a read after one fired still reports what stays pinned. - restoreCommand: session.restoreCommand, - splitRestoreCommand: session.splitRestoreCommand, status: status, - statusPane: statusPane, - statusBlink: idle ? nil : (session.agentIndicator.blink ? true : nil), - statusColor: idle ? nil : session.agentIndicator.color, - background: session.backgroundWatermark, - unseen: session.unseenCount > 0 ? session.unseenCount : nil, - fontSize: fontSize(session), - splitFontSize: splitFontSize(session), - scratchFontSize: scratchFontSize(session), - surfaces: surfaces) - } - return ControlWorkspaceNode(id: workspace.id.uuidString, name: workspace.name, - active: workspace.id == activeWorkspaceID, - focused: workspace.id == focusedWorkspaceID ? true : nil, - collapsed: workspace.isExpanded ? nil : true, - sessions: sessions) - } - return ControlTree(workspaces: nodes, idleMs: idleMs(), autoFollowMs: autoFollowMs, - sidebarVisible: sidebarVisible, sidebarMode: sidebarMode.rawValue, - quickVisible: quickVisible(), zoomedSurface: zoomedSurface(), - dashboardMembers: dashboardMembers(), - dashboardHighlighted: dashboardHighlighted(), - dashboardFontSize: dashboardFontSize(), - dashboardFontMode: dashboardFontMode()) - } - /// Creates a workspace and appends it. When `clearFocus` (the default) it clears any active focus so /// the new (empty) workspace is immediately visible — else `visibleWorkspaces` returns only the /// focused one and the new workspace is silently hidden (the auto-reveal contract, like `addSession`). diff --git a/agtermCore/Sources/agtermCore/ControlProtocol.swift b/agtermCore/Sources/agtermCore/ControlProtocol.swift index 68deb7ad..22321c10 100644 --- a/agtermCore/Sources/agtermCore/ControlProtocol.swift +++ b/agtermCore/Sources/agtermCore/ControlProtocol.swift @@ -199,6 +199,15 @@ public struct ControlArgs: Codable, Sendable, Equatable { /// For `session.overlay.resize`, requests the full-pane (translucent, session-hidden) overlay — /// the way to switch a floating overlay back to full. Mutually exclusive with `sizePercent`. public var full: Bool? + /// For `session.overlay.open`/`.resize`, the exact terminal grid a *floating* overlay panel sizes to + /// via the surface's live cell metrics: `cols` columns × `rows` rows, both or neither, mutually + /// exclusive with `sizePercent`/`full`. The panel is clamped to whole cells that fit the pane. + public var cols: Int? + public var rows: Int? + /// For `session.overlay.open`/`.resize`, which of nine positions a *floating* overlay panel anchors to + /// within the pane (`OverlayAnchor` raw value, e.g. `top-left`/`center`/`bottom-right`); omitted = + /// `center` (today's behavior). + public var anchor: String? /// For `session.overlay.open`, whether to select/switch to the target after opening; omitted/false /// opens in the background without changing the active session (the default for both full and /// floating overlays). @@ -252,6 +261,7 @@ public struct ControlArgs: Codable, Sendable, Equatable { createWorkspace: Bool? = nil, collapsed: Bool? = nil, noSelect: Bool? = nil, text: String? = nil, select: Bool? = nil, mode: String? = nil, command: String? = nil, wait: Bool? = nil, sizePercent: Int? = nil, full: Bool? = nil, + cols: Int? = nil, rows: Int? = nil, anchor: String? = nil, follow: Bool? = nil, window: String? = nil, pane: String? = nil, paneID: String? = nil, to: String? = nil, after: String? = nil, before: String? = nil, run: String? = nil, @@ -279,6 +289,9 @@ public struct ControlArgs: Codable, Sendable, Equatable { self.wait = wait self.sizePercent = sizePercent self.full = full + self.cols = cols + self.rows = rows + self.anchor = anchor self.follow = follow self.window = window self.pane = pane @@ -381,6 +394,21 @@ public struct ControlSessionNode: Codable, Sendable, Equatable { /// floating panel's percent of the pane (1...100). Absent when no overlay is open. The read side of /// `session.overlay.resize` — record the current size before resizing so a script can restore it exactly. public let overlaySizePercent: Int? + /// For a floating overlay opened in CELLS mode, the REQUESTED grid (`session.overlay.open`/`.resize + /// --cols N --rows M`); nil/omitted for a percent overlay, a full overlay, or no overlay. The restore + /// key of a cells-mode overlay — record it before resizing, restore it exactly afterwards. + public let overlayCols: Int? + public let overlayRows: Int? + /// The REALIZED grid an OPEN floating overlay actually rendered after whole-cell clamping — set for any + /// floating overlay (percent or cells), nil/omitted for a full overlay, no overlay, or before the + /// surface reports. Distinct from the requested `overlayCols`/`overlayRows`, so a script can detect a + /// clamp (an oversized cols/rows request comes back smaller here). + public let overlayColsApplied: Int? + public let overlayRowsApplied: Int? + /// For any OPEN overlay (floating OR full), which of nine positions the panel anchors to + /// (`OverlayAnchor` raw value); nil/omitted when no overlay is open. The read side of the overlay + /// `--anchor` — preserved across a `--full` round-trip, so it is reported even for a full overlay. + public let overlayAnchor: String? public let scratch: Bool public let flagged: Bool /// For a `--command` session, whether it was created to HOLD its surface after the command exits @@ -442,7 +470,10 @@ public struct ControlSessionNode: Codable, Sendable, Equatable { public init(id: String, name: String, cwd: String, title: String? = nil, active: Bool, split: Bool, splitRatio: Double? = nil, splitFocused: Bool? = nil, - overlay: Bool = false, overlaySizePercent: Int? = nil, scratch: Bool = false, flagged: Bool = false, + overlay: Bool = false, overlaySizePercent: Int? = nil, + overlayCols: Int? = nil, overlayRows: Int? = nil, + overlayColsApplied: Int? = nil, overlayRowsApplied: Int? = nil, overlayAnchor: String? = nil, + scratch: Bool = false, flagged: Bool = false, commandWait: Bool? = nil, foreground: [String]? = nil, splitForeground: [String]? = nil, restoreCommand: String? = nil, splitRestoreCommand: String? = nil, status: String? = nil, @@ -460,6 +491,11 @@ public struct ControlSessionNode: Codable, Sendable, Equatable { self.splitFocused = splitFocused self.overlay = overlay self.overlaySizePercent = overlaySizePercent + self.overlayCols = overlayCols + self.overlayRows = overlayRows + self.overlayColsApplied = overlayColsApplied + self.overlayRowsApplied = overlayRowsApplied + self.overlayAnchor = overlayAnchor self.scratch = scratch self.flagged = flagged self.commandWait = commandWait diff --git a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift index 35a4cb51..911f652f 100644 --- a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift +++ b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift @@ -629,6 +629,39 @@ struct AppStorePaneTests { #expect(node.overlaySizePercent == nil) } + @Test func controlTreeReportsOverlayColsRowsAndAnchor() throws { + let store = makeStore() + let ws = store.addWorkspace(name: "work") + let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! + // no overlay: every overlay grid/anchor field is omitted. + var node = try #require(store.controlTree().workspaces[0].sessions.first) + #expect(node.overlayCols == nil) + #expect(node.overlayColsApplied == nil) + #expect(node.overlayAnchor == nil) + // a cells-mode floating overlay parked in a corner: the REQUESTED grid + anchor ride the node, and + // the app-maintained REALIZED grid (overlayApplied*) exposes any clamp (here clamped smaller). + store.openOverlay(session.id, options: .init(command: "htop", size: .cells(cols: 80, rows: 24), + anchor: .bottomRight)) + session.overlayAppliedCols = 72 + session.overlayAppliedRows = 20 + node = try #require(store.controlTree().workspaces[0].sessions.first) + #expect(node.overlayCols == 80) + #expect(node.overlayRows == 24) + #expect(node.overlayColsApplied == 72) + #expect(node.overlayRowsApplied == 20) + #expect(node.overlayAnchor == "bottom-right") + // resize to full keeps + reports the anchor (Decision 2) but drops the requested + realized grid + // (cols/rows/applied are floating-only). + store.resizeOverlay(session.id, size: .full) + node = try #require(store.controlTree().workspaces[0].sessions.first) + #expect(node.overlay == true) + #expect(node.overlayCols == nil) + #expect(node.overlayRows == nil) + #expect(node.overlayColsApplied == nil) + #expect(node.overlayRowsApplied == nil) + #expect(node.overlayAnchor == "bottom-right") + } + @Test func controlTreeReportsSplitRatio() throws { let store = makeStore() let ws = store.addWorkspace(name: "work") diff --git a/agtermCore/Tests/agtermCoreTests/AppStoreTests.swift b/agtermCore/Tests/agtermCoreTests/AppStoreTests.swift index f671da34..8607bafb 100644 --- a/agtermCore/Tests/agtermCoreTests/AppStoreTests.swift +++ b/agtermCore/Tests/agtermCoreTests/AppStoreTests.swift @@ -1407,7 +1407,7 @@ struct AppStoreTests { ControlSessionNode(id: b.id.uuidString, name: "remote:~/b", cwd: "/live/b", title: "remote:~/b", active: true, split: true, splitFocused: false, - overlay: true, scratch: true, flagged: true, + overlay: true, overlayAnchor: "center", scratch: true, flagged: true, status: "blocked", statusPane: "right", background: BackgroundWatermark(kind: .text, text: "PROD"), surfaces: [ diff --git a/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift b/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift index c7370fcd..eef347c2 100644 --- a/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift +++ b/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift @@ -112,6 +112,37 @@ struct ControlProtocolTests { #expect(!json.contains("follow"), "a nil follow must be omitted from the JSON; got \(json)") } + @Test func sessionOverlayRoundTripsWithColsRowsAnchor() throws { + // the new overlay sizing args: an exact cols×rows grid and a 9-point anchor ride the request, so a + // cells-mode floating overlay parked in a corner round-trips (open), as does a re-anchor-only resize. + let open = ControlRequest(cmd: .sessionOverlayOpen, target: "9f3c", + args: ControlArgs(command: "htop", cols: 80, rows: 24, anchor: "bottom-right")) + let decodedOpen = try roundTrip(open) + #expect(decodedOpen == open) + #expect(decodedOpen.args?.cols == 80) + #expect(decodedOpen.args?.rows == 24) + #expect(decodedOpen.args?.anchor == "bottom-right") + + let resize = ControlRequest(cmd: .sessionOverlayResize, target: "9f3c", args: ControlArgs(anchor: "top-left")) + let decodedResize = try roundTrip(resize) + #expect(decodedResize == resize) + #expect(decodedResize.args?.anchor == "top-left") + } + + @Test func sessionOverlayOmitsColsRowsAnchorWhenNil() throws { + let request = ControlRequest(cmd: .sessionOverlayOpen, target: "9f3c", args: ControlArgs(command: "revdiff")) + let decoded = try roundTrip(request) + #expect(decoded == request) + #expect(decoded.args?.cols == nil) + #expect(decoded.args?.rows == nil) + #expect(decoded.args?.anchor == nil) + // omit-when-nil WIRE contract: nil cols/rows/anchor must not encode at all, not emit as null. + let json = String(data: try JSONEncoder().encode(request), encoding: .utf8) ?? "" + #expect(!json.contains("cols"), "a nil cols must be omitted from the JSON; got \(json)") + #expect(!json.contains("rows"), "a nil rows must be omitted from the JSON; got \(json)") + #expect(!json.contains("anchor"), "a nil anchor must be omitted from the JSON; got \(json)") + } + @Test func sessionTextRoundTripsWithAllLinesAndPane() throws { let request = ControlRequest(cmd: .sessionText, target: "9f3c", args: ControlArgs(pane: "left", all: true, lines: 50)) @@ -616,6 +647,73 @@ struct ControlProtocolTests { #expect(decoded.overlaySizePercent == nil) } + @Test func treeSessionNodeRoundTripsWithOverlayCellsAppliedAndAnchor() throws { + // a cells-mode floating overlay: the REQUESTED grid, the REALIZED (clamped) grid, and the anchor all + // ride the node so a script can record the request, detect a clamp, and restore the anchor. + let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false, + overlay: true, overlayCols: 80, overlayRows: 24, + overlayColsApplied: 72, overlayRowsApplied: 20, overlayAnchor: "bottom-right") + let response = ControlResponse(ok: true, result: ControlResult(tree: ControlTree( + workspaces: [ControlWorkspaceNode(id: "w1", name: "work", active: true, sessions: [session])]))) + let decoded = try roundTrip(response) + #expect(decoded == response) + let node = decoded.result?.tree?.workspaces.first?.sessions.first + #expect(node?.overlayCols == 80) + #expect(node?.overlayRows == 24) + #expect(node?.overlayColsApplied == 72) + #expect(node?.overlayRowsApplied == 20) + #expect(node?.overlayAnchor == "bottom-right") + } + + @Test func treeSessionNodeRoundTripsWithOverlayPercentAppliedAndAnchor() throws { + // a percent floating overlay: NO requested cols/rows, but the realized grid + anchor still ride the + // node (applied is set for ANY floating overlay, percent or cells). + let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false, + overlay: true, overlaySizePercent: 70, + overlayColsApplied: 96, overlayRowsApplied: 28, overlayAnchor: "center") + let response = ControlResponse(ok: true, result: ControlResult(tree: ControlTree( + workspaces: [ControlWorkspaceNode(id: "w1", name: "work", active: true, sessions: [session])]))) + let decoded = try roundTrip(response) + #expect(decoded == response) + let node = decoded.result?.tree?.workspaces.first?.sessions.first + #expect(node?.overlaySizePercent == 70) + #expect(node?.overlayCols == nil) + #expect(node?.overlayRows == nil) + #expect(node?.overlayColsApplied == 96) + #expect(node?.overlayRowsApplied == 28) + #expect(node?.overlayAnchor == "center") + } + + @Test func treeSessionNodeKeepsAnchorButOmitsGridForFullOverlay() throws { + // a FULL overlay: no cols/rows/applied (those are floating-only), but the anchor IS emitted + // (Decision 2 — the anchor is preserved and reported even while full). + let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false, + overlay: true, overlayAnchor: "top") + let json = String(data: try JSONEncoder().encode(session), encoding: .utf8) ?? "" + #expect(!json.contains("overlayCols"), "a full overlay must omit overlayCols/overlayColsApplied; got \(json)") + #expect(!json.contains("overlayRows"), "a full overlay must omit overlayRows/overlayRowsApplied; got \(json)") + #expect(json.contains("\"overlayAnchor\":\"top\""), "a full overlay must emit its anchor; got \(json)") + let decoded = try JSONDecoder().decode(ControlSessionNode.self, from: Data(json.utf8)) + #expect(decoded.overlayCols == nil) + #expect(decoded.overlayColsApplied == nil) + #expect(decoded.overlayAnchor == "top") + } + + @Test func treeSessionNodeOmitsOverlayGridAndAnchorWhenNoOverlay() throws { + // no overlay open — every overlay grid/anchor key must be omitted, not emitted as null. + let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false) + let json = String(data: try JSONEncoder().encode(session), encoding: .utf8) ?? "" + #expect(!json.contains("overlayCols"), "no overlay must omit overlayCols/overlayColsApplied; got \(json)") + #expect(!json.contains("overlayRows"), "no overlay must omit overlayRows/overlayRowsApplied; got \(json)") + #expect(!json.contains("overlayAnchor"), "no overlay must omit overlayAnchor; got \(json)") + let decoded = try JSONDecoder().decode(ControlSessionNode.self, from: Data(json.utf8)) + #expect(decoded.overlayCols == nil) + #expect(decoded.overlayRows == nil) + #expect(decoded.overlayColsApplied == nil) + #expect(decoded.overlayRowsApplied == nil) + #expect(decoded.overlayAnchor == nil) + } + @Test func treeSessionNodeRoundTripsWithRestoreCommand() throws { // the read side of session.restore: the pinned shell line rides the tree node per pane so a script // can record what is pinned, change it, and restore the original. diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md index 191daab8..8d0ed473 100644 --- a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md +++ b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md @@ -324,17 +324,17 @@ via `OverlayLayout.panelSize` + anchor alignment. `tree` reports requested + app - Modify: `agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift` - Modify: `agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift` -- [ ] add `ControlArgs.cols`/`rows`/`anchor`; thread through its init -- [ ] add `ControlSessionNode.overlayCols`/`overlayRows`/`overlayColsApplied`/`overlayRowsApplied`/ +- [x] add `ControlArgs.cols`/`rows`/`anchor`; thread through its init +- [x] add `ControlSessionNode.overlayCols`/`overlayRows`/`overlayColsApplied`/`overlayRowsApplied`/ `overlayAnchor`; thread through its init (keep `overlaySizePercent`) -- [ ] populate the new node fields in `AppStore.controlTree`: requested cols/rows when `.cells`; applied +- [x] populate the new node fields in `AppStore.controlTree`: requested cols/rows when `.cells`; applied cols/rows from `overlayAppliedCols/Rows` when floating; `overlayAnchor` rawValue when `overlayActive`; all omitted when nil -- [ ] write tests: `ControlArgs` round-trip with cols/rows/anchor; `ControlSessionNode` round-trip for +- [x] write tests: `ControlArgs` round-trip with cols/rows/anchor; `ControlSessionNode` round-trip for cells+applied+anchor and for percent+applied+anchor; `…OmitsWhenNil` for full overlay and no overlay; a `controlTree` populate test in `AppStorePaneTests` (requested vs applied, anchor emitted while full) -- [ ] run `swift test` — must pass before next task +- [x] run `swift test` — must pass before next task ### Task 4: Dispatcher validation + routing (tightened open) From 8e4773b8248d113bea20e6b2421e4ae5d86bb0e6 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 00:38:51 -0500 Subject: [PATCH 05/20] feat: validate and route overlay size/anchor in dispatcher --- .../agtermCore/ControlDispatcher.swift | 167 ++++++++++++++---- .../ControlDispatcherTests.swift | 115 ++++++++++-- .../agtermCoreTests/MockControlActions.swift | 7 +- ...20260722-overlay-anchor-and-cell-sizing.md | 12 +- 4 files changed, 247 insertions(+), 54 deletions(-) diff --git a/agtermCore/Sources/agtermCore/ControlDispatcher.swift b/agtermCore/Sources/agtermCore/ControlDispatcher.swift index 154ba1ec..35dec551 100644 --- a/agtermCore/Sources/agtermCore/ControlDispatcher.swift +++ b/agtermCore/Sources/agtermCore/ControlDispatcher.swift @@ -62,7 +62,10 @@ public protocol ControlActions { func openSessionOverlay(_ target: String?, window: String?, options: ControlSessionOverlayOpenOptions) -> ControlResponse func closeSessionOverlay(_ target: String?, window: String?) -> ControlResponse - func resizeSessionOverlay(_ target: String?, window: String?, sizePercent: Int?) -> ControlResponse + /// Resize/re-anchor an open overlay. `size` nil keeps the current size, `anchor` nil keeps the current + /// anchor; the dispatcher guarantees at least one is non-nil. + func resizeSessionOverlay(_ target: String?, window: String?, + size: OverlaySize?, anchor: OverlayAnchor?) -> ControlResponse func sessionOverlayResult(_ target: String?, window: String?) -> ControlResponse func setSessionBackground(_ target: String?, window: String?, options: ControlSessionBackgroundOptions) -> ControlResponse @@ -96,16 +99,18 @@ public struct ControlSessionOverlayOpenOptions: Equatable, Sendable { public let command: String public let cwd: String? public let wait: Bool - public let sizePercent: Int? + public let size: OverlaySize + public let anchor: OverlayAnchor public let backgroundColor: String? public let follow: Bool - public init(command: String, cwd: String?, wait: Bool, sizePercent: Int?, backgroundColor: String?, - follow: Bool = false) { + public init(command: String, cwd: String?, wait: Bool, size: OverlaySize = .full, + anchor: OverlayAnchor = .center, backgroundColor: String?, follow: Bool = false) { self.command = command self.cwd = cwd self.wait = wait - self.sizePercent = sizePercent + self.size = size + self.anchor = anchor self.backgroundColor = backgroundColor self.follow = follow } @@ -487,37 +492,11 @@ public struct ControlDispatcher { return await actions.searchSession(request.target, window: request.args?.window, text: request.args?.text, to: request.args?.to) case .sessionOverlayOpen: - guard let command = request.args?.command, !command.isEmpty else { - return ControlResponse(ok: false, error: "session.overlay.open requires a command") - } - if let color = request.args?.color, !WatermarkConfig.isValidColorHex(color) { - return ControlResponse(ok: false, error: "invalid color: \(color) (#rrggbb)") - } - return actions.openSessionOverlay(request.target, window: request.args?.window, - options: ControlSessionOverlayOpenOptions( - command: command, - cwd: request.args?.cwd, - wait: request.args?.wait ?? false, - sizePercent: request.args?.sizePercent, - backgroundColor: request.args?.color, - follow: request.args?.follow ?? false - )) + return dispatchSessionOverlayOpen(request) case .sessionOverlayClose: return actions.closeSessionOverlay(request.target, window: request.args?.window) case .sessionOverlayResize: - let wantsFull = request.args?.full == true - let percent = request.args?.sizePercent - if wantsFull, percent != nil { - return ControlResponse(ok: false, error: "session.overlay.resize: --full is mutually exclusive with --size-percent") - } - if !wantsFull, percent == nil { - return ControlResponse(ok: false, error: "session.overlay.resize requires --size-percent or --full") - } - if let percent, !(1...100).contains(percent) { - return ControlResponse(ok: false, error: "session.overlay.resize: --size-percent must be 1...100") - } - return actions.resizeSessionOverlay(request.target, window: request.args?.window, - sizePercent: wantsFull ? nil : percent) + return dispatchSessionOverlayResize(request) case .sessionOverlayResult: return actions.sessionOverlayResult(request.target, window: request.args?.window) case .sessionBackground: @@ -529,6 +508,128 @@ public struct ControlDispatcher { } } + /// `session.overlay.open`: build the overlay open options. Absence of any size arg means a full-pane + /// overlay (open has no `--full`); an `--anchor` is only meaningful for a floating overlay. + private func dispatchSessionOverlayOpen(_ request: ControlRequest) -> ControlResponse { + let args = request.args + guard let command = args?.command, !command.isEmpty else { + return ControlResponse(ok: false, error: "session.overlay.open requires a command") + } + if let color = args?.color, !WatermarkConfig.isValidColorHex(color) { + return ControlResponse(ok: false, error: "invalid color: \(color) (#rrggbb)") + } + let size: OverlaySize + switch parseOverlaySize(args, command: "session.overlay.open", allowFull: false) { + case .rejected(let rejection): return rejection + case .unspecified: size = .full + case .size(let parsed): size = parsed + } + let anchor: OverlayAnchor + switch parseOverlayAnchor(args?.anchor) { + case .rejected(let rejection): return rejection + case .anchor(let parsed): + if parsed != nil, size == .full { + return ControlResponse(ok: false, + error: "--anchor requires a floating overlay: use --size-percent or --cols/--rows") + } + anchor = parsed ?? .center + } + return actions.openSessionOverlay(request.target, window: args?.window, + options: ControlSessionOverlayOpenOptions( + command: command, + cwd: args?.cwd, + wait: args?.wait ?? false, + size: size, + anchor: anchor, + backgroundColor: args?.color, + follow: args?.follow ?? false + )) + } + + /// `session.overlay.resize`: a nil `size`/`anchor` keeps the current one, so at least one must be set; + /// `--full` reverts to the full-pane overlay and cannot carry an anchor. + private func dispatchSessionOverlayResize(_ request: ControlRequest) -> ControlResponse { + let args = request.args + let size: OverlaySize? + switch parseOverlaySize(args, command: "session.overlay.resize", allowFull: true) { + case .rejected(let rejection): return rejection + case .unspecified: size = nil + case .size(let parsed): size = parsed + } + let anchor: OverlayAnchor? + switch parseOverlayAnchor(args?.anchor) { + case .rejected(let rejection): return rejection + case .anchor(let parsed): anchor = parsed + } + if case .some(.full) = size, anchor != nil { + return ControlResponse(ok: false, error: "--full cannot be combined with --anchor") + } + if size == nil, anchor == nil { + return ControlResponse(ok: false, + error: "session.overlay.resize requires a size (--full, --size-percent, --cols/--rows) or --anchor") + } + return actions.resizeSessionOverlay(request.target, window: args?.window, size: size, anchor: anchor) + } + + /// The outcome of parsing the overlay size args: a resolved size, no size arg present (open → full, + /// resize → keep current), or the rejection to return as-is. + private enum OverlaySizeParse { + case size(OverlaySize) + case unspecified + case rejected(ControlResponse) + } + + /// Shared parse + validation of the overlay size args (`--size-percent` / `--cols` / `--rows`, and + /// `--full` on resize) for both open and resize. At most one size mode may be set; a percent is a hard + /// `1...100` error (Decision 3); cols/rows are both-or-neither and each `>= 1`. `command` prefixes the + /// per-command errors; `allowFull` enables the resize-only `--full` mode. + private func parseOverlaySize(_ args: ControlArgs?, command: String, allowFull: Bool) -> OverlaySizeParse { + let full = allowFull && (args?.full == true) + let percent = args?.sizePercent + let cols = args?.cols + let rows = args?.rows + let hasCells = cols != nil || rows != nil + let modeCount = (full ? 1 : 0) + (percent != nil ? 1 : 0) + (hasCells ? 1 : 0) + if modeCount > 1 { + let modes = allowFull ? "--full, --size-percent, or --cols/--rows" : "--size-percent or --cols/--rows" + return .rejected(ControlResponse(ok: false, error: "\(command): use only one of \(modes)")) + } + if full { return .size(.full) } + if let percent { + guard (1...100).contains(percent) else { + return .rejected(ControlResponse(ok: false, error: "\(command): --size-percent must be 1...100")) + } + return .size(.percent(percent)) + } + if hasCells { + guard let cols, let rows else { + return .rejected(ControlResponse(ok: false, error: "provide both --cols and --rows")) + } + guard cols >= 1, rows >= 1 else { + return .rejected(ControlResponse(ok: false, error: "--cols and --rows must be >= 1")) + } + return .size(.cells(cols: cols, rows: rows)) + } + return .unspecified + } + + /// The outcome of parsing the `--anchor` selector: the anchor (nil when absent), or the rejection. + private enum OverlayAnchorParse { + case anchor(OverlayAnchor?) + case rejected(ControlResponse) + } + + /// Shared `--anchor` parse for both overlay arms: nil when absent, the parsed anchor when valid, or an + /// `unknown anchor` rejection listing the nine positions. + private func parseOverlayAnchor(_ raw: String?) -> OverlayAnchorParse { + guard let raw else { return .anchor(nil) } + guard let parsed = OverlayAnchor(rawValue: raw) else { + let valid = OverlayAnchor.allCases.map(\.rawValue).joined(separator: "|") + return .rejected(ControlResponse(ok: false, error: "unknown anchor: \(raw) (\(valid))")) + } + return .anchor(parsed) + } + private func dispatchAppCommand(_ request: ControlRequest) -> ControlResponse { switch request.cmd { case .fontInc: diff --git a/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift b/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift index 5ec78d70..efd8fbbe 100644 --- a/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift +++ b/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift @@ -1178,7 +1178,7 @@ struct ControlDispatcherTests { #expect(actions.calls.isEmpty) } - @Test func sessionOverlayOpenRoutesOptionsAndEchoesActionResponse() async { + @Test func sessionOverlayOpenRoutesPercentOptionsAndEchoesActionResponse() async { let actions = MockControlActions() let dispatcher = ControlDispatcher(actions: actions) actions.nextOverlayOpenResponse = ControlResponse(ok: false, error: "overlay already open") @@ -1187,19 +1187,39 @@ struct ControlDispatcherTests { cmd: .sessionOverlayOpen, target: "session", args: ControlArgs(cwd: "/tmp", command: "cat", wait: true, - sizePercent: 70, follow: true, window: "win", color: "#2a1a3a") + sizePercent: 70, anchor: "top-right", follow: true, window: "win", color: "#2a1a3a") )) #expect(response == ControlResponse(ok: false, error: "overlay already open")) #expect(actions.calls == [ .overlayOpen(target: "session", window: "win", ControlSessionOverlayOpenOptions(command: "cat", cwd: "/tmp", wait: true, - sizePercent: 70, backgroundColor: "#2a1a3a", - follow: true)) + size: .percent(70), anchor: .topRight, + backgroundColor: "#2a1a3a", follow: true)) ]) } - @Test func sessionOverlayOpenDefaultsFollowToFalseWhenOmitted() async { + @Test func sessionOverlayOpenRoutesCellsAndAnchor() async { + let actions = MockControlActions() + let dispatcher = ControlDispatcher(actions: actions) + actions.nextOverlayOpenResponse = ControlResponse(ok: true, result: ControlResult(id: "session")) + + let response = await dispatcher.dispatch(ControlRequest( + cmd: .sessionOverlayOpen, + target: "session", + args: ControlArgs(command: "htop", cols: 80, rows: 24, anchor: "bottom-left") + )) + + #expect(response == ControlResponse(ok: true, result: ControlResult(id: "session"))) + #expect(actions.calls == [ + .overlayOpen(target: "session", window: nil, + ControlSessionOverlayOpenOptions(command: "htop", cwd: nil, wait: false, + size: .cells(cols: 80, rows: 24), anchor: .bottomLeft, + backgroundColor: nil, follow: false)) + ]) + } + + @Test func sessionOverlayOpenDefaultsToFullCenterWhenSizeOmitted() async { let actions = MockControlActions() let dispatcher = ControlDispatcher(actions: actions) actions.nextOverlayOpenResponse = ControlResponse(ok: true, result: ControlResult(id: "session")) @@ -1214,11 +1234,46 @@ struct ControlDispatcherTests { #expect(actions.calls == [ .overlayOpen(target: "session", window: nil, ControlSessionOverlayOpenOptions(command: "cat", cwd: nil, wait: false, - sizePercent: nil, backgroundColor: nil, - follow: false)) + size: .full, anchor: .center, + backgroundColor: nil, follow: false)) ]) } + @Test func sessionOverlayOpenRejectsSizeAndAnchorErrors() async { + let actions = MockControlActions() + let dispatcher = ControlDispatcher(actions: actions) + + let colsWithoutRows = await dispatcher.dispatch(ControlRequest( + cmd: .sessionOverlayOpen, target: "session", args: ControlArgs(command: "cat", cols: 80))) + let percentTooBig = await dispatcher.dispatch(ControlRequest( + cmd: .sessionOverlayOpen, target: "session", args: ControlArgs(command: "cat", sizePercent: 150))) + let percentTooSmall = await dispatcher.dispatch(ControlRequest( + cmd: .sessionOverlayOpen, target: "session", args: ControlArgs(command: "cat", sizePercent: 0))) + let percentAndCells = await dispatcher.dispatch(ControlRequest( + cmd: .sessionOverlayOpen, target: "session", + args: ControlArgs(command: "cat", sizePercent: 50, cols: 80, rows: 24))) + let badCells = await dispatcher.dispatch(ControlRequest( + cmd: .sessionOverlayOpen, target: "session", args: ControlArgs(command: "cat", cols: 0, rows: 24))) + let unknownAnchor = await dispatcher.dispatch(ControlRequest( + cmd: .sessionOverlayOpen, target: "session", + args: ControlArgs(command: "cat", sizePercent: 50, anchor: "sideways"))) + let anchorWithoutFloating = await dispatcher.dispatch(ControlRequest( + cmd: .sessionOverlayOpen, target: "session", args: ControlArgs(command: "cat", anchor: "top-left"))) + + #expect(colsWithoutRows == ControlResponse(ok: false, error: "provide both --cols and --rows")) + #expect(percentTooBig == ControlResponse(ok: false, error: "session.overlay.open: --size-percent must be 1...100")) + #expect(percentTooSmall == ControlResponse(ok: false, error: "session.overlay.open: --size-percent must be 1...100")) + #expect(percentAndCells == ControlResponse( + ok: false, error: "session.overlay.open: use only one of --size-percent or --cols/--rows")) + #expect(badCells == ControlResponse(ok: false, error: "--cols and --rows must be >= 1")) + #expect(unknownAnchor == ControlResponse( + ok: false, + error: "unknown anchor: sideways (top-left|top|top-right|left|center|right|bottom-left|bottom|bottom-right)")) + #expect(anchorWithoutFloating == ControlResponse( + ok: false, error: "--anchor requires a floating overlay: use --size-percent or --cols/--rows")) + #expect(actions.calls.isEmpty) + } + @Test func sessionOverlayCloseAndResultRouteTargetAndWindow() async { let actions = MockControlActions() let dispatcher = ControlDispatcher(actions: actions) @@ -1266,10 +1321,10 @@ struct ControlDispatcherTests { )) #expect(response == ControlResponse(ok: true, result: ControlResult(id: "session"))) - #expect(actions.calls == [.overlayResize(target: "session", window: "win", sizePercent: 60)]) + #expect(actions.calls == [.overlayResize(target: "session", window: "win", size: .percent(60), anchor: nil)]) } - @Test func sessionOverlayResizeFullRoutesNilSizePercent() async { + @Test func sessionOverlayResizeFullRoutesFullSize() async { let actions = MockControlActions() let dispatcher = ControlDispatcher(actions: actions) @@ -1278,7 +1333,34 @@ struct ControlDispatcherTests { )) #expect(response?.ok == true) - #expect(actions.calls == [.overlayResize(target: "session", window: nil, sizePercent: nil)]) + #expect(actions.calls == [.overlayResize(target: "session", window: nil, size: .full, anchor: nil)]) + } + + @Test func sessionOverlayResizeRoutesCellsWithAnchor() async { + let actions = MockControlActions() + let dispatcher = ControlDispatcher(actions: actions) + + let response = await dispatcher.dispatch(ControlRequest( + cmd: .sessionOverlayResize, target: "session", + args: ControlArgs(cols: 100, rows: 30, anchor: "right") + )) + + #expect(response?.ok == true) + #expect(actions.calls == [ + .overlayResize(target: "session", window: nil, size: .cells(cols: 100, rows: 30), anchor: .right) + ]) + } + + @Test func sessionOverlayResizeAnchorOnlyKeepsSize() async { + let actions = MockControlActions() + let dispatcher = ControlDispatcher(actions: actions) + + let response = await dispatcher.dispatch(ControlRequest( + cmd: .sessionOverlayResize, target: "session", args: ControlArgs(anchor: "center") + )) + + #expect(response?.ok == true) + #expect(actions.calls == [.overlayResize(target: "session", window: nil, size: nil, anchor: .center)]) } @Test func sessionOverlayResizeRejectsMissingConflictingAndOutOfRange() async { @@ -1292,11 +1374,20 @@ struct ControlDispatcherTests { cmd: .sessionOverlayResize, target: "session", args: ControlArgs(sizePercent: 101))) let tooSmall = await dispatcher.dispatch(ControlRequest( cmd: .sessionOverlayResize, target: "session", args: ControlArgs(sizePercent: 0))) + let fullAndAnchor = await dispatcher.dispatch(ControlRequest( + cmd: .sessionOverlayResize, target: "session", args: ControlArgs(full: true, anchor: "top-left"))) + let colsWithoutRows = await dispatcher.dispatch(ControlRequest( + cmd: .sessionOverlayResize, target: "session", args: ControlArgs(cols: 80))) - #expect(missing == ControlResponse(ok: false, error: "session.overlay.resize requires --size-percent or --full")) - #expect(both == ControlResponse(ok: false, error: "session.overlay.resize: --full is mutually exclusive with --size-percent")) + #expect(missing == ControlResponse( + ok: false, + error: "session.overlay.resize requires a size (--full, --size-percent, --cols/--rows) or --anchor")) + #expect(both == ControlResponse( + ok: false, error: "session.overlay.resize: use only one of --full, --size-percent, or --cols/--rows")) #expect(tooBig == ControlResponse(ok: false, error: "session.overlay.resize: --size-percent must be 1...100")) #expect(tooSmall == ControlResponse(ok: false, error: "session.overlay.resize: --size-percent must be 1...100")) + #expect(fullAndAnchor == ControlResponse(ok: false, error: "--full cannot be combined with --anchor")) + #expect(colsWithoutRows == ControlResponse(ok: false, error: "provide both --cols and --rows")) #expect(actions.calls.isEmpty) } diff --git a/agtermCore/Tests/agtermCoreTests/MockControlActions.swift b/agtermCore/Tests/agtermCoreTests/MockControlActions.swift index 57f978c3..a3bd9271 100644 --- a/agtermCore/Tests/agtermCoreTests/MockControlActions.swift +++ b/agtermCore/Tests/agtermCoreTests/MockControlActions.swift @@ -58,7 +58,7 @@ final class MockControlActions: ControlActions { case sessionSearch(target: String?, window: String?, text: String?, to: String?) case overlayOpen(target: String?, window: String?, ControlSessionOverlayOpenOptions) case overlayClose(target: String?, window: String?) - case overlayResize(target: String?, window: String?, sizePercent: Int?) + case overlayResize(target: String?, window: String?, size: OverlaySize?, anchor: OverlayAnchor?) case overlayResult(target: String?, window: String?) case sessionBackground(target: String?, window: String?, ControlSessionBackgroundOptions) case sessionText(target: String?, window: String?, ControlSessionTextOptions) @@ -372,8 +372,9 @@ final class MockControlActions: ControlActions { return nextOverlayCloseResponse } - func resizeSessionOverlay(_ target: String?, window: String?, sizePercent: Int?) -> ControlResponse { - calls.append(.overlayResize(target: target, window: window, sizePercent: sizePercent)) + func resizeSessionOverlay(_ target: String?, window: String?, + size: OverlaySize?, anchor: OverlayAnchor?) -> ControlResponse { + calls.append(.overlayResize(target: target, window: window, size: size, anchor: anchor)) return nextOverlayResizeResponse } diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md index 8d0ed473..7b782c0e 100644 --- a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md +++ b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md @@ -343,17 +343,17 @@ via `OverlayLayout.panelSize` + anchor alignment. `tree` reports requested + app - Modify: `agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift` - Modify: `agtermCore/Tests/agtermCoreTests/MockControlActions.swift` -- [ ] extend `ControlActions.openSessionOverlay`/`resizeSessionOverlay` + `ControlSessionOverlayOpenOptions` +- [x] extend `ControlActions.openSessionOverlay`/`resizeSessionOverlay` + `ControlSessionOverlayOpenOptions` to carry `OverlaySize`/`OverlayAnchor`; update `MockControlActions` -- [ ] `.sessionOverlayOpen`: parse size (percent/cells, no `--full`), validate one-of + cols·rows pairing +- [x] `.sessionOverlayOpen`: parse size (percent/cells, no `--full`), validate one-of + cols·rows pairing + **percent 1…100 hard-error (Decision 3)** + anchor parse + anchor-requires-floating; build options -- [ ] `.sessionOverlayResize`: validate one-of {full/percent/cells} or none, at-least-one-of {size, +- [x] `.sessionOverlayResize`: validate one-of {full/percent/cells} or none, at-least-one-of {size, anchor}, `full` ⊥ `anchor`; pass `size: OverlaySize?`/`anchor: OverlayAnchor?` -- [ ] factor size/anchor parsing into shared pure helpers used by both arms -- [ ] write tests: each valid open/resize mode routes correctly (assert mock call args); each error +- [x] factor size/anchor parsing into shared pure helpers used by both arms +- [x] write tests: each valid open/resize mode routes correctly (assert mock call args); each error (both cols/rows required, **percent out-of-range now errors on open**, unknown anchor, anchor-without-floating, full+anchor, resize-with-nothing) -- [ ] run `swift test` — must pass before next task +- [x] run `swift test` — must pass before next task ### Task 5: App-side arms + observable metrics + overlayPanel rendering From 92d13c91b637b7f8b537101255ae8c8491af3897 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 00:52:20 -0500 Subject: [PATCH 06/20] feat: render overlay cols/rows sizing and 9-point anchor --- .../ControlServer+SessionActions.swift | 12 ++-- agterm/Ghostty/GhosttyCallbacks.swift | 8 ++- agterm/Ghostty/GhosttySurfaceView.swift | 68 +++++++++++++++++++ agterm/Views/WindowContentView.swift | 51 ++++++++++---- agterm/agtermApp.swift | 10 +++ ...20260722-overlay-anchor-and-cell-sizing.md | 17 ++--- 6 files changed, 139 insertions(+), 27 deletions(-) diff --git a/agterm/Control/ControlServer+SessionActions.swift b/agterm/Control/ControlServer+SessionActions.swift index 982906fb..e1a65108 100644 --- a/agterm/Control/ControlServer+SessionActions.swift +++ b/agterm/Control/ControlServer+SessionActions.swift @@ -27,9 +27,10 @@ extension ControlServer: ControlActions { func openSessionOverlay(_ target: String?, window: String?, options: ControlSessionOverlayOpenOptions) -> ControlResponse { resolver.resolveSession(target, window: window) { store, id in - guard store.openOverlay(id, command: options.command, cwd: options.cwd, - wait: options.wait, sizePercent: options.sizePercent, - backgroundColor: options.backgroundColor) else { + guard store.openOverlay(id, options: OverlayOpenOptions( + command: options.command, cwd: options.cwd, wait: options.wait, + size: options.size, anchor: options.anchor, + backgroundColor: options.backgroundColor)) else { return ControlResponse(ok: false, error: "overlay already open") } // Both overlay kinds mount and run in the per-session eager deck regardless of which session @@ -52,9 +53,10 @@ extension ControlServer: ControlActions { } } - func resizeSessionOverlay(_ target: String?, window: String?, sizePercent: Int?) -> ControlResponse { + func resizeSessionOverlay(_ target: String?, window: String?, + size: OverlaySize?, anchor: OverlayAnchor?) -> ControlResponse { resolver.resolveSession(target, window: window) { store, id in - guard store.resizeOverlay(id, sizePercent: sizePercent) else { + guard store.resizeOverlay(id, size: size, anchor: anchor) else { return ControlResponse(ok: false, error: "no overlay") } return ControlResponse(ok: true, result: ControlResult(id: id.uuidString)) diff --git a/agterm/Ghostty/GhosttyCallbacks.swift b/agterm/Ghostty/GhosttyCallbacks.swift index 4d6ad6cb..0ca8d95e 100644 --- a/agterm/Ghostty/GhosttyCallbacks.swift +++ b/agterm/Ghostty/GhosttyCallbacks.swift @@ -50,9 +50,13 @@ final class GhosttyCallbacks: @unchecked Sendable { case GHOSTTY_ACTION_CELL_SIZE: // fires when the cell pixel size changes (font-size change via cmd +/-, or DPI // change). used only as a trigger: the view reads the live font size and the app - // persists it. + // persists it, and (for an overlay surface) refreshes the overlay cell metrics so a + // `.cells` panel re-snaps to whole cells. each call self-guards on its own wiring. guard let view = surfaceView(from: target) else { return true } - DispatchQueue.main.async { view.reportFontSize() } + DispatchQueue.main.async { + view.reportFontSize() + view.refreshOverlayMetrics() + } return true case GHOSTTY_ACTION_RENDER: // libghostty signals this surface has a frame ready to paint. agterm is demand-driven (no poll diff --git a/agterm/Ghostty/GhosttySurfaceView.swift b/agterm/Ghostty/GhosttySurfaceView.swift index 32b93721..bf37c6a3 100644 --- a/agterm/Ghostty/GhosttySurfaceView.swift +++ b/agterm/Ghostty/GhosttySurfaceView.swift @@ -136,6 +136,20 @@ final class GhosttySurfaceView: NSView, TerminalSurface { /// off the CELL_SIZE action and reads the size via `ghostty_surface_inherited_config`. var onFontSizeChange: ((Double) -> Void)? + /// For an OVERLAY surface: called on the main actor with the surface's live cell metrics (POINTS) and + /// its realized grid (columns, rows) whenever they change — surface realization, `GHOSTTY_ACTION_CELL_SIZE`, + /// and any backing-size/scale change. The overlay carries no `view.session`, so the factory wires this to + /// write `session.overlayCellMetrics`/`overlayAppliedCols`/`overlayAppliedRows`, which drive the `.cells` + /// panel layout and the `tree` read-back. Nil for main/split/scratch surfaces. Nilled in `destroySurface`. + var onOverlayMetrics: ((OverlayCellMetrics, Int, Int) -> Void)? + + /// Last overlay metrics reported through `onOverlayMetrics`, so `refreshOverlayMetrics` skips a redundant + /// write — the observed session state would otherwise re-invalidate the panel every layout pass, and the + /// `.cells` panel (which converges in one resize step) would keep re-writing at the fixed point. + private var lastOverlayCell: OverlayCellMetrics? + private var lastOverlayCols: Int? + private var lastOverlayRows: Int? + /// Called on the main actor when libghostty enters search mode (START_SEARCH), carrying the current /// needle (nil when none). The factory wires this to toggle the session's search bar — if the bar is /// already visible it sends `end_search` (the ⌘F-again close), else it opens the bar and seeds the @@ -498,6 +512,50 @@ final class GhosttySurfaceView: NSView, TerminalSurface { onFontSizeChange?(size) // the store no-ops a same-value write. } + /// The overlay surface's live cell + padding + grid metrics from `ghostty_surface_size`, all in BACKING + /// PIXELS (cell width/height and the derived non-cell padding remainder, plus the realized columns/rows). + /// A struct (not a tuple) because swiftlint caps tuples at 3 members. + struct OverlayPixelMetrics { + let cellW: Double, cellH: Double, padW: Double, padH: Double + let cols: Int, rows: Int + } + + /// Reads the overlay surface's live pixel metrics from `ghostty_surface_size`. nil when the surface isn't + /// realized or has no cell size yet. The caller converts px→points via `backingScaleFactor` before handing + /// them to the host-free layout resolver (which works in the point space `GeometryReader`/`WindowGeometry.Size` + /// use). Padding is the total non-cell remainder — an estimate, per the padding-drift note in the overlay plan. + func overlayPixelMetrics() -> OverlayPixelMetrics? { + guard let surface else { return nil } + let size = ghostty_surface_size(surface) + let cellW = Double(size.cell_width_px) + let cellH = Double(size.cell_height_px) + guard cellW > 0, cellH > 0 else { return nil } + let cols = Int(size.columns) + let rows = Int(size.rows) + let padW = Double(size.width_px) - Double(cols) * cellW + let padH = Double(size.height_px) - Double(rows) * cellH + return OverlayPixelMetrics(cellW: cellW, cellH: cellH, padW: padW, padH: padH, cols: cols, rows: rows) + } + + /// Refreshes the OVERLAY surface's cell metrics + realized grid onto the owning session (via the factory + /// closure — the overlay has no `view.session`). No-op unless this is an overlay surface (`onOverlayMetrics` + /// wired) with a realized surface. Reads BACKING PIXELS from `ghostty_surface_size` and converts to POINTS + /// via the window's `backingScaleFactor` — the Retina conversion is load-bearing, since the resolver and + /// the SwiftUI pane size are in points. Skips a redundant write so it can be called freely on realization, + /// `CELL_SIZE`, and every backing-size change without re-invalidating the panel at the `.cells` fixed point. + func refreshOverlayMetrics() { + guard let report = onOverlayMetrics, let px = overlayPixelMetrics() else { return } + let scale = Double(window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0) + guard scale > 0 else { return } + let cell = OverlayCellMetrics(cellWidth: px.cellW / scale, cellHeight: px.cellH / scale, + padWidth: px.padW / scale, padHeight: px.padH / scale) + guard cell != lastOverlayCell || px.cols != lastOverlayCols || px.rows != lastOverlayRows else { return } + lastOverlayCell = cell + lastOverlayCols = px.cols + lastOverlayRows = px.rows + report(cell, px.cols, px.rows) + } + /// Draws the surface now, servicing libghostty's `GHOSTTY_ACTION_RENDER` demand. Main-actor. func renderNow() { guard let surface else { return } @@ -619,6 +677,12 @@ final class GhosttySurfaceView: NSView, TerminalSurface { // the overlay grabs first responder itself (TerminalView's once-on-attach grab misses the // deferred overlay surface); a bounded run-loop retry beats the SwiftUI/AppKit responder race. requestAutoFocus(in: window) + + // capture the overlay surface's initial cell metrics + grid so a `.cells` panel can snap to whole + // cells (no-op for non-overlay surfaces). The first realization is at the full-pane fallback size + // (metrics are nil until now); the follow-up resize to the `.cells` size refreshes the applied grid + // via updateMetalLayerSize. + refreshOverlayMetrics() } /// Marks the surface focused in libghostty after a retried `makeFirstResponder` (the overlay/reparent @@ -721,6 +785,7 @@ final class GhosttySurfaceView: NSView, TerminalSurface { onUserInputClearsStatus = nil onUserInput = nil onFontSizeChange = nil + onOverlayMetrics = nil onSearchStart = nil onSearchEnd = nil onSearchTotal = nil @@ -798,6 +863,9 @@ final class GhosttySurfaceView: NSView, TerminalSurface { // an unchanged grid is a no-op and the 120Hz `ghostty_app_tick` only draws surfaces flagged dirty, // so without this the re-hosted pane keeps a blank drawable even though its terminal buffer is intact. ghostty_surface_refresh(surface) + // a resize (incl. the post-realization settle to the `.cells` size) or a backing-scale change moves + // the overlay surface's grid/metrics, so refresh the read-back off the new size (no-op otherwise). + refreshOverlayMetrics() } // MARK: - First responder diff --git a/agterm/Views/WindowContentView.swift b/agterm/Views/WindowContentView.swift index 5173de71..7b6f7f17 100644 --- a/agterm/Views/WindowContentView.swift +++ b/agterm/Views/WindowContentView.swift @@ -365,8 +365,8 @@ struct WindowContentView: View { /// INSIDE them), so the NSSplitView never re-layouts on a zoom toggle and the divider stays put; /// `SplitRatioAccessor` rides the primary wrapper as one persistent instance, suspended while zoomed. @ViewBuilder private func sessionDetail(_ session: Session, isActive: Bool) -> some View { - // a FULL overlay (no size) hides the session beneath it (opacity 0) and draws translucent; a - // FLOATING overlay (overlaySizePercent set) leaves the session VISIBLE and draws a smaller + // a FULL overlay (`.full` size) hides the session beneath it (opacity 0) and draws translucent; a + // FLOATING overlay (a non-`.full` `overlaySize`) leaves the session VISIBLE and draws a smaller // opaque framed panel on top. Either way the pane(s) stay non-interactive while an overlay is up. let fullOverlay = session.fullOverlayActive // While zoomed OR while the dashboard is open, the normal deck stays mounted only to realize @@ -524,28 +524,34 @@ struct WindowContentView: View { /// never changes when an overlay opens/closes (constant shape = no NSSplitView re-host = no titlebar /// overrun), and BOTH variants share this single surface host, so `session.overlay.resize` switching /// full<->% only re-flows the frame — it never re-parents the NSView (which would blank its Metal drawable). - /// A nil `overlaySizePercent` fills the detail area translucent (no opaque backing/frame) with the pane(s) - /// hidden by `hideForOverlay`; a percent draws an opaque, framed panel at that size, centered, with the - /// pane(s) visible around it. Per-session in the eager deck, so the surface mounts + program runs even when - /// the session isn't active. + /// A `.full` `overlaySize` fills the detail area translucent (no opaque backing/frame) with the pane(s) + /// hidden by `hideForOverlay`; a floating size (`.percent`/`.cells`) draws an opaque, framed panel sized by + /// `OverlayLayout.panelSize` and anchored by `overlayAnchor` (nine positions, default center), with the + /// pane(s) visible around it. Only the frame parameters and the ZStack `alignment` change across + /// full/floating/anchor — the child count is constant (no anchor-specific view branches), so the + /// NSSplitView is never re-hosted. Per-session in the eager deck, so the surface mounts + program runs even + /// when the session isn't active. @ViewBuilder private func overlayPanel(session: Session, isActive: Bool) -> some View { GeometryReader { geo in - ZStack { + let floating = session.floatingOverlayActive + let panel = OverlayLayout.panelSize(session.overlaySize, + pane: WindowGeometry.Size(width: Double(geo.size.width), + height: Double(geo.size.height)), + cell: session.overlayCellMetrics) + ZStack(alignment: floating ? session.overlayAnchor.swiftUIAlignment : .center) { if session.overlayActive, deckHostsSurface(session: session, surface: .overlay) { - let floating = session.overlaySizePercent != nil - let fraction = session.overlaySizePercent.map { CGFloat($0) / 100 } ?? 1 // transparent click-catcher over the whole detail area: absorbs clicks AROUND a floating // panel so they can't reach the still-hit-testable panes and steal the overlay's first // responder (the full variant hides the panes, so it's covered either way). Color.clear.contentShape(Rectangle()) TerminalView(session: session, surfaceKeyPath: \.overlaySurface, makeSurface: makeOverlaySurface, isActive: isActive, deckVisible: isActive && !quickTerminal.isVisible) - .frame(width: geo.size.width * fraction, height: geo.size.height * fraction) + .frame(width: CGFloat(panel.width), height: CGFloat(panel.height)) // floating = opaque backing + hairline frame + shadow so it reads as a distinct window // over the still-visible session; full = translucent, no chrome (libghostty draws only // the terminal, so the window backing shows through). The modifier CHAIN stays constant - // across both variants — only the parameters go inert for full — so a full<->% resize - // keeps the same view tree and never re-hosts the surface NSView. + // across both variants — only the parameters go inert for full — so a full<->floating + // resize keeps the same view tree and never re-hosts the surface NSView. .background(floating ? terminalColor : Color.clear) .clipShape(RoundedRectangle(cornerRadius: floating ? 12 : 0)) .overlay( @@ -553,6 +559,7 @@ struct WindowContentView: View { .strokeBorder(floating ? Color.white.opacity(0.18) : Color.clear, lineWidth: 1) ) .shadow(radius: floating ? 24 : 0) + .accessibilityIdentifier("overlay-floating-panel") .id("\(session.id.uuidString)-overlay") } } @@ -830,3 +837,23 @@ struct WindowContentView: View { } } + +extension OverlayAnchor { + /// The SwiftUI `Alignment` a floating overlay panel takes inside the detail-area ZStack — the 9-point + /// anchor mapped to a `(horizontal, vertical)` pair. The host-free `unitX`/`unitY` (0/0.5/1) can't name + /// SwiftUI's alignment guides, so this app-side mapping bridges them; `.center` reproduces today's + /// centered placement. + var swiftUIAlignment: Alignment { + switch self { + case .topLeft: return .topLeading + case .top: return .top + case .topRight: return .topTrailing + case .left: return .leading + case .center: return .center + case .right: return .trailing + case .bottomLeft: return .bottomLeading + case .bottom: return .bottom + case .bottomRight: return .bottomTrailing + } + } +} diff --git a/agterm/agtermApp.swift b/agterm/agtermApp.swift index 840b9ba4..df594843 100644 --- a/agterm/agtermApp.swift +++ b/agterm/agtermApp.swift @@ -444,6 +444,16 @@ struct agtermApp: App { // removes the session first, so this no-ops there — but the result is unqueryable after that anyway. view.onExitCodeCaptured = { store.recordOverlayExit(sessionID, code: $0) } view.onExit = { store.closeOverlay(sessionID) } + // the overlay is sessionless (no view.session), so route its live cell metrics + realized grid back + // onto the owning session here (in POINTS — the view already divided the pixel metrics by the + // backing scale). These drive the `.cells` panel layout and the `tree` applied-grid read-back. + // destroySurface nils this, breaking the store -> surface -> closure retain cycle. + view.onOverlayMetrics = { metrics, cols, rows in + guard let session = store.session(withID: sessionID) else { return } + session.overlayCellMetrics = metrics + session.overlayAppliedCols = cols + session.overlayAppliedRows = rows + } // typing in the cover counts as user activity: reset the window's auto-follow idle timer so an // idle fire can't change the underlying selection (vanishing the overlay) while you type in it. // destroySurface nils this, breaking the store -> surface -> closure retain cycle. diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md index 7b782c0e..b6390160 100644 --- a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md +++ b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md @@ -363,21 +363,22 @@ via `OverlayLayout.panelSize` + anchor alignment. `tree` reports requested + app - Modify: `agterm/Ghostty/GhosttyCallbacks.swift` - Modify: `agterm/Views/WindowContentView.swift` -- [ ] update the overlay arms to pass `size`/`anchor` into `openOverlay(options:)`/`resizeOverlay(_:size:anchor:)` -- [ ] add `GhosttySurfaceView.overlayPixelMetrics()` from `ghostty_surface_size()`; app converts px→points +- [x] update the overlay arms to pass `size`/`anchor` into `openOverlay(options:)`/`resizeOverlay(_:size:anchor:)` +- [x] add `GhosttySurfaceView.overlayPixelMetrics()` from `ghostty_surface_size()`; app converts px→points via `backingScaleFactor` and writes `session.overlayCellMetrics` + `session.overlayAppliedCols/Rows` on surface realization, `GHOSTTY_ACTION_CELL_SIZE` (`GhosttyCallbacks.swift:50`), and backing-scale change (view consumes the observed metrics — no view-body polling of the imperative NSView) -- [ ] `overlayPanel`: size via `OverlayLayout.panelSize(session.overlaySize, pane: geo.size, cell: +- [x] `overlayPanel`: size via `OverlayLayout.panelSize(session.overlaySize, pane: geo.size, cell: session.overlayCellMetrics)`; position via `ZStack(alignment: floating ? session.overlayAnchor.swiftUIAlignment : .center)`; add the app-side `OverlayAnchor.swiftUIAlignment` mapping; add a stable accessibility id to the floating panel -- [ ] confirm ZStack child count unchanged and no anchor-specific view branches (NSSplitView-overrun +- [x] confirm ZStack child count unchanged and no anchor-specific view branches (NSSplitView-overrun rule); full path + `hideForOverlay` untouched -- [ ] app builds (`make build`); `make lint` passes -- [ ] verify in an isolated dev instance (short `AGTERM_STATE_DIR` + its own socket) that cols/rows fit, - each anchor places correctly, and Retina scaling is right — record in Post-Completion -- [ ] run `swift test` (host-free unchanged) — must pass before next task +- [x] app builds (`make build`); `make lint` passes +- [x] visual verification (deferred to Post-Completion — not automatable in this run): in an isolated dev + instance (short `AGTERM_STATE_DIR` + its own socket) that cols/rows fit, each anchor places correctly, + and Retina scaling is right — record in Post-Completion +- [x] run `swift test` (host-free unchanged) — must pass before next task ### Task 6: agtermctl CLI — open/resize flags From 0663d2587f9016c330fe100a737072597204b305 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 01:00:09 -0500 Subject: [PATCH 07/20] feat: add agtermctl overlay cols/rows/anchor flags --- .../agtermctlKit/SessionCommands.swift | 88 ++++++++++++++++--- .../agtermctlKitTests/CommandsTests.swift | 68 ++++++++++++++ ...20260722-overlay-anchor-and-cell-sizing.md | 10 +-- 3 files changed, 149 insertions(+), 17 deletions(-) diff --git a/agtermCore/Sources/agtermctlKit/SessionCommands.swift b/agtermCore/Sources/agtermctlKit/SessionCommands.swift index 1faf9208..692a15d4 100644 --- a/agtermCore/Sources/agtermctlKit/SessionCommands.swift +++ b/agtermCore/Sources/agtermctlKit/SessionCommands.swift @@ -597,6 +597,50 @@ struct Session: ParsableCommand { subcommands: [Open.self, Close.self, Resize.self, Result.self] ) + /// Which overlay verb is validating: carries the error-message prefix and whether `--full` is a + /// valid size mode (resize only). Keeps `validateSize` to five params by folding the correlated + /// command-name/allow-full pair. + enum SizeContext { + case open, resize + var name: String { self == .open ? "session.overlay.open" : "session.overlay.resize" } + var allowsFull: Bool { self == .resize } + } + + /// Shared overlay size validation for `open` and `resize`, mirroring the dispatcher's one-of / + /// range / pairing rules so a CLI user and a raw socket client see the same wording: at most one + /// of {--size-percent, --cols/--rows, --full (resize only)}; --size-percent 1...100 (a hard error + /// now enforced on open too — Decision 3); --cols and --rows both-or-neither, each >= 1. + static func validateSize(_ context: SizeContext, full: Bool, sizePercent: Int?, cols: Int?, rows: Int?) throws { + let hasCells = cols != nil || rows != nil + let modeCount = (full ? 1 : 0) + (sizePercent != nil ? 1 : 0) + (hasCells ? 1 : 0) + if modeCount > 1 { + let modes = context.allowsFull ? "--full, --size-percent, or --cols/--rows" : "--size-percent or --cols/--rows" + throw ValidationError("\(context.name): use only one of \(modes)") + } + if let sizePercent, !(1...100).contains(sizePercent) { + throw ValidationError("\(context.name): --size-percent must be 1...100") + } + if hasCells { + guard cols != nil, rows != nil else { + throw ValidationError("provide both --cols and --rows") + } + guard (cols ?? 0) >= 1, (rows ?? 0) >= 1 else { + throw ValidationError("--cols and --rows must be >= 1") + } + } + } + + /// Parses `--anchor` to an `OverlayAnchor`, throwing the dispatcher's `unknown anchor` message + /// (listing the nine positions) when it is not one of them. Returns nil when absent. + static func parseAnchor(_ raw: String?) throws -> OverlayAnchor? { + guard let raw else { return nil } + guard let parsed = OverlayAnchor(rawValue: raw) else { + let valid = OverlayAnchor.allCases.map(\.rawValue).joined(separator: "|") + throw ValidationError("unknown anchor: \(raw) (\(valid))") + } + return parsed + } + struct Open: RequestCommand { static let configuration = CommandConfiguration(abstract: "Open an overlay running COMMAND; it closes when COMMAND exits.") @Argument(help: "Program to run in the overlay (e.g. revdiff).") var command: String @@ -605,23 +649,32 @@ struct Session: ParsableCommand { @Flag(name: .long, help: "Block until COMMAND exits and exit with its status (the program renders normally; capture its output via the program's own output file).") var block = false @Flag(name: .long, help: "Select (switch to) the target session after opening the overlay (default: open without switching).") var follow = false @Option(name: .long, help: "Render a floating, framed panel at PERCENT (1-100) of the pane instead of full-size.") var sizePercent: Int? + @Option(name: .long, help: "Render a floating panel exactly COLS columns wide (needs --rows); sized to the grid, clamped to fit the pane.") var cols: Int? + @Option(name: .long, help: "Render a floating panel exactly ROWS rows tall (needs --cols); sized to the grid, clamped to fit the pane.") var rows: Int? + @Option(name: .long, help: "Anchor a floating panel: top-left, top, top-right, left, center (default), right, bottom-left, bottom, bottom-right.") var anchor: String? @Option(name: .long, help: "Solid background color (#rrggbb) for the overlay pane, independent of the session's own.") var backgroundColor: String? @OptionGroup var target: TargetOptions @OptionGroup var options: ClientOptions - // reject the mutually-exclusive combo + a malformed color at parse time (before any connection), - // so it's a clean usage error and is unit-testable without a socket. + // reject the mutually-exclusive combos, a bad size/anchor, and a malformed color at parse time + // (before any connection), so it's a clean usage error and is unit-testable without a socket. func validate() throws { if block && wait { throw ValidationError("--block cannot be combined with --wait") } if let backgroundColor, !WatermarkConfig.isValidColorHex(backgroundColor) { throw ValidationError("background-color must be a #rrggbb hex value") } + try Overlay.validateSize(.open, full: false, sizePercent: sizePercent, cols: cols, rows: rows) + // an --anchor is only meaningful for a floating overlay (open with no size = full-pane). + if try Overlay.parseAnchor(anchor) != nil, sizePercent == nil, cols == nil, rows == nil { + throw ValidationError("--anchor requires a floating overlay: use --size-percent or --cols/--rows") + } } func makeRequest() throws -> ControlRequest { ControlRequest(cmd: .sessionOverlayOpen, target: target.target, args: options.withWindow(ControlArgs(cwd: cwd, command: command, wait: wait ? true : nil, - sizePercent: sizePercent, follow: follow ? true : nil, + sizePercent: sizePercent, cols: cols, rows: rows, + anchor: anchor, follow: follow ? true : nil, color: backgroundColor))) } @@ -630,7 +683,8 @@ struct Session: ParsableCommand { let client = SocketClient(path: options.socketPath()) // open via the same `makeRequest()` the non-block path uses (DRY): in block mode `validate()` // guarantees `!wait`, so its `wait` is nil — identical to opening non-wait, and the floating - // `--size-percent` is carried through the single source instead of a duplicated ControlArgs. + // size (`--size-percent`/`--cols`/`--rows`) + `--anchor` are carried through the single + // source instead of a duplicated ControlArgs. let opened = try client.send(makeRequest()) guard opened.ok, let id = opened.result?.id else { SocketClient.printResponse(opened, json: options.json) @@ -671,25 +725,35 @@ struct Session: ParsableCommand { } struct Resize: RequestCommand { - static let configuration = CommandConfiguration(abstract: "Resize an open overlay: floating at a percent, or back to full-pane.") + static let configuration = CommandConfiguration( + abstract: "Resize or re-anchor an open overlay: a floating percent or exact cols/rows, full-pane, or a 9-point anchor.") @Option(name: .long, help: "Resize to a floating, framed panel at PERCENT (1-100) of the pane.") var sizePercent: Int? + @Option(name: .long, help: "Resize to a floating panel exactly COLS columns wide (needs --rows); clamped to fit the pane.") var cols: Int? + @Option(name: .long, help: "Resize to a floating panel exactly ROWS rows tall (needs --cols); clamped to fit the pane.") var rows: Int? + @Option(name: .long, help: "Re-anchor the floating panel: top-left, top, top-right, left, center, right, bottom-left, bottom, bottom-right.") var anchor: String? @Flag(name: .long, help: "Resize to full-pane (translucent, hides the session).") var full = false @OptionGroup var target: TargetOptions @OptionGroup var options: ClientOptions - // require exactly one of --size-percent / --full at parse time (before any connection), so it is a - // clean usage error and unit-testable without a socket; the dispatcher re-checks the same rules. + // validate at parse time (before any connection) so it's a clean usage error, unit-testable + // without a socket; the dispatcher re-checks the same rules. One-of {--full, --size-percent, + // --cols/--rows} OR none, at least one of {a size mode, --anchor}, and --full ⊥ --anchor. func validate() throws { - if full && sizePercent != nil { throw ValidationError("--full cannot be combined with --size-percent") } - if !full && sizePercent == nil { throw ValidationError("provide --size-percent PERCENT or --full") } - if let sizePercent, !(1...100).contains(sizePercent) { - throw ValidationError("--size-percent must be between 1 and 100") + try Overlay.validateSize(.resize, full: full, sizePercent: sizePercent, cols: cols, rows: rows) + let parsedAnchor = try Overlay.parseAnchor(anchor) + if full, parsedAnchor != nil { + throw ValidationError("--full cannot be combined with --anchor") + } + let hasSize = full || sizePercent != nil || cols != nil || rows != nil + if !hasSize, parsedAnchor == nil { + throw ValidationError("session.overlay.resize requires a size (--full, --size-percent, --cols/--rows) or --anchor") } } func makeRequest() throws -> ControlRequest { ControlRequest(cmd: .sessionOverlayResize, target: target.target, - args: options.withWindow(ControlArgs(sizePercent: sizePercent, full: full ? true : nil))) + args: options.withWindow(ControlArgs(sizePercent: sizePercent, full: full ? true : nil, + cols: cols, rows: rows, anchor: anchor))) } } diff --git a/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift b/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift index d0b02644..15b3ca24 100644 --- a/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift +++ b/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift @@ -773,6 +773,74 @@ struct CommandsTests { #expect(throws: (any Error).self) { try Agtermctl.parseAsRoot(["session", "overlay", "resize", "--size-percent", "0"]) } } + @Test func sessionOverlayOpenWithCellsAndAnchor() throws { + let expected = ControlRequest(cmd: .sessionOverlayOpen, target: "active", + args: ControlArgs(command: "htop", cols: 80, rows: 24, anchor: "top-left")) + #expect(try request(["session", "overlay", "open", "htop", "--cols", "80", "--rows", "24", + "--anchor", "top-left"]) == expected) + } + + @Test func sessionOverlayOpenPercentWithAnchor() throws { + let expected = ControlRequest(cmd: .sessionOverlayOpen, target: "active", + args: ControlArgs(command: "htop", sizePercent: 50, anchor: "bottom-right")) + #expect(try request(["session", "overlay", "open", "htop", "--size-percent", "50", + "--anchor", "bottom-right"]) == expected) + } + + @Test func sessionOverlayOpenValidatesSizeAndAnchor() { + // Decision 3: open now hard-errors on an out-of-range percent (previously silently clamped), and + // mirrors the dispatcher's one-of / pairing / anchor rules. + #expect(validationMessage(["session", "overlay", "open", "htop", "--size-percent", "0"]) + == "session.overlay.open: --size-percent must be 1...100") + #expect(validationMessage(["session", "overlay", "open", "htop", "--size-percent", "101"]) + == "session.overlay.open: --size-percent must be 1...100") + #expect(validationMessage(["session", "overlay", "open", "htop", "--cols", "80"]) + == "provide both --cols and --rows") + #expect(validationMessage(["session", "overlay", "open", "htop", "--rows", "24"]) + == "provide both --cols and --rows") + #expect(validationMessage(["session", "overlay", "open", "htop", "--cols", "0", "--rows", "24"]) + == "--cols and --rows must be >= 1") + #expect(validationMessage(["session", "overlay", "open", "htop", "--size-percent", "50", "--cols", "5", "--rows", "5"]) + == "session.overlay.open: use only one of --size-percent or --cols/--rows") + #expect(validationMessage(["session", "overlay", "open", "htop", "--size-percent", "50", "--anchor", "bogus"]) + == "unknown anchor: bogus (top-left|top|top-right|left|center|right|bottom-left|bottom|bottom-right)") + #expect(validationMessage(["session", "overlay", "open", "htop", "--anchor", "top-left"]) + == "--anchor requires a floating overlay: use --size-percent or --cols/--rows") + } + + @Test func sessionOverlayResizeWithCells() throws { + let expected = ControlRequest(cmd: .sessionOverlayResize, target: "active", args: ControlArgs(cols: 100, rows: 30)) + #expect(try request(["session", "overlay", "resize", "--cols", "100", "--rows", "30"]) == expected) + } + + @Test func sessionOverlayResizeAnchorOnly() throws { + // an --anchor with no size keeps the current size and just re-anchors (nil size on the wire). + let expected = ControlRequest(cmd: .sessionOverlayResize, target: "active", args: ControlArgs(anchor: "top")) + #expect(try request(["session", "overlay", "resize", "--anchor", "top"]) == expected) + } + + @Test func sessionOverlayResizeCellsWithAnchor() throws { + let expected = ControlRequest(cmd: .sessionOverlayResize, target: "9f3c", + args: ControlArgs(cols: 40, rows: 12, anchor: "left")) + #expect(try request(["session", "overlay", "resize", "--cols", "40", "--rows", "12", + "--anchor", "left", "--target", "9f3c"]) == expected) + } + + @Test func sessionOverlayResizeValidatesSizeAndAnchor() { + #expect(validationMessage(["session", "overlay", "resize"]) + == "session.overlay.resize requires a size (--full, --size-percent, --cols/--rows) or --anchor") + #expect(validationMessage(["session", "overlay", "resize", "--full", "--anchor", "top-left"]) + == "--full cannot be combined with --anchor") + #expect(validationMessage(["session", "overlay", "resize", "--full", "--cols", "5", "--rows", "5"]) + == "session.overlay.resize: use only one of --full, --size-percent, or --cols/--rows") + #expect(validationMessage(["session", "overlay", "resize", "--cols", "5"]) + == "provide both --cols and --rows") + #expect(validationMessage(["session", "overlay", "resize", "--size-percent", "150"]) + == "session.overlay.resize: --size-percent must be 1...100") + #expect(validationMessage(["session", "overlay", "resize", "--anchor", "bogus"]) + == "unknown anchor: bogus (top-left|top|top-right|left|center|right|bottom-left|bottom|bottom-right)") + } + @Test func sessionOverlayBlockRejectsWait() { // validate() enforces the mutually-exclusive flags at parse time (before any connection). #expect(throws: (any Error).self) { diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md index b6390160..c493455c 100644 --- a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md +++ b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md @@ -386,15 +386,15 @@ via `OverlayLayout.panelSize` + anchor alignment. `tree` reports requested + app - Modify: `agtermCore/Sources/agtermctlKit/SessionCommands.swift` - Modify: `agtermCore/Tests/agtermctlKitTests/CommandsTests.swift` -- [ ] `session overlay open`: add `--cols`/`--rows`/`--anchor`; `validate()` mirrors dispatcher (one-of +- [x] `session overlay open`: add `--cols`/`--rows`/`--anchor`; `validate()` mirrors dispatcher (one-of size, cols&rows together, **percent 1…100 now enforced on open**, anchor value, anchor-requires- floating); map into `ControlArgs` -- [ ] `session overlay resize`: add `--cols`/`--rows`/`--anchor`; extend `validate()` to one-of size or +- [x] `session overlay resize`: add `--cols`/`--rows`/`--anchor`; extend `validate()` to one-of size or none, at-least-one of {size, anchor}, `full` ⊥ `anchor` -- [ ] update help text -- [ ] write tests: request mapping for each open/resize mode; `validate()` accepts valid combos and +- [x] update help text +- [x] write tests: request mapping for each open/resize mode; `validate()` accepts valid combos and rejects each invalid one (incl. the new open percent-range error) with the expected message -- [ ] run `swift test` — must pass before next task +- [x] run `swift test` — must pass before next task ### Task 7: End-to-end XCUITests (wire + actual grid + frame) From 0d41d475361f1a7a81ffbf4271e42714705dad9a Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 01:18:34 -0500 Subject: [PATCH 08/20] test: e2e for overlay cols/rows sizing and anchor --- agterm/Views/WindowContentView.swift | 11 +- .../ControlOverlaySplitUITests.swift | 275 ++++++++++++++++++ ...20260722-overlay-anchor-and-cell-sizing.md | 20 +- 3 files changed, 299 insertions(+), 7 deletions(-) diff --git a/agterm/Views/WindowContentView.swift b/agterm/Views/WindowContentView.swift index 7b6f7f17..f0aab3cc 100644 --- a/agterm/Views/WindowContentView.swift +++ b/agterm/Views/WindowContentView.swift @@ -559,7 +559,16 @@ struct WindowContentView: View { .strokeBorder(floating ? Color.white.opacity(0.18) : Color.clear, lineWidth: 1) ) .shadow(radius: floating ? 24 : 0) - .accessibilityIdentifier("overlay-floating-panel") + // the Metal-backed surface is not in the a11y tree, so expose the panel's frame via a + // zero-content overlay marker (the dashboard-cell pattern) that the e2e reads. As an + // .overlay it adds NO ZStack child, so the constant-child-count NSSplitView-overrun + // invariant holds; allowsHitTesting(false) keeps it off the overlay's own input. + .overlay( + Color.clear + .allowsHitTesting(false) + .accessibilityElement() + .accessibilityIdentifier("overlay-floating-panel") + ) .id("\(session.id.uuidString)-overlay") } } diff --git a/agtermUITests/ControlOverlaySplitUITests.swift b/agtermUITests/ControlOverlaySplitUITests.swift index 0722fe3a..8b8a652c 100644 --- a/agtermUITests/ControlOverlaySplitUITests.swift +++ b/agtermUITests/ControlOverlaySplitUITests.swift @@ -677,6 +677,204 @@ final class ControlOverlaySplitUITests: ControlAPITestCase { XCTAssertEqual(empty["ok"] as? Bool, false, "resize with no fraction should fail: \(empty)") } + // session.overlay.open --cols/--rows/--anchor: the tree read-back reports the REQUESTED grid + // (overlayCols/Rows), the realized grid after clamping (overlayColsApplied/RowsApplied), and the anchor. + // The window is given headroom first so a 40x12 grid fits without clamping (applied == requested). + func testOverlayOpenColsRowsAnchorReadBack() throws { + let id = try activeSessionID() + try resizeWindow(width: 1100, height: 750) + + let open = try sendOverlayOpen(target: id, command: "cat", args: ["cols": 40, "rows": 12, "anchor": "top-right"]) + XCTAssertEqual(open["ok"] as? Bool, true, "overlay open with cols/rows/anchor should succeed: \(open)") + XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the overlay should be up") + + let node = try XCTUnwrap(pollOverlayApplied(id: id, timeout: 12), + "the tree should report the realized overlay grid (overlayColsApplied/RowsApplied)") + // the requested grid is echoed verbatim. + XCTAssertEqual(node["overlayCols"] as? Int, 40, "tree should echo the requested cols: \(node)") + XCTAssertEqual(node["overlayRows"] as? Int, 12, "tree should echo the requested rows: \(node)") + // the applied grid reflects the realized surface; with headroom it matches the request within padding + // drift (a 2x Retina px->point miss would size the grid ~2x off). + let appliedCols = try XCTUnwrap(node["overlayColsApplied"] as? Int, "applied cols should be present: \(node)") + let appliedRows = try XCTUnwrap(node["overlayRowsApplied"] as? Int, "applied rows should be present: \(node)") + XCTAssertTrue(abs(appliedCols - 40) <= 2, "applied cols \(appliedCols) should match the requested 40 (2x would be ~80): \(node)") + XCTAssertTrue(abs(appliedRows - 12) <= 2, "applied rows \(appliedRows) should match the requested 12 (2x would be ~24): \(node)") + XCTAssertEqual(node["overlayAnchor"] as? String, "top-right", "tree should report the anchor: \(node)") + + let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#) + XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)") + } + + // The ACTUAL grid check that a wire/echo round-trip alone can't make: run `stty size` inside the overlay + // and assert the pty's realized rows/cols match the requested cols/rows. libghostty sizes the pty from the + // panel's POINT dimensions, so a Retina px->point miss would size the grid ~2x off and this marker catches + // it. `cat` holds the overlay open so the tree read-back can be compared against stty's report. + func testOverlayColsRowsRealizedGridMatchesRequest() throws { + let id = try activeSessionID() + try resizeWindow(width: 1100, height: 750) + + let marker = markerDir.appendingPathComponent("overlay-grid") + // the overlay wraps its command in `sh -c`, so `stty size` (prints "rows cols") then `cat` work directly. + let cmd = "stty size > \(marker.path); cat" + let open = try sendOverlayOpen(target: id, command: cmd, args: ["cols": 40, "rows": 12]) + XCTAssertEqual(open["ok"] as? Bool, true, "overlay open should succeed: \(open)") + XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the overlay should be up") + + let sizeText = try XCTUnwrap(pollMarker(marker, timeout: 15), "the overlay command should write its grid size") + let numbers = sizeText.split(separator: " ").compactMap { Int($0) } + XCTAssertEqual(numbers.count, 2, "stty size should report two integers (rows cols): \(sizeText)") + let rows = numbers[0], cols = numbers[1] + // the realized grid must match the request within padding drift; a 2x Retina error would be ~80x24. + XCTAssertTrue(abs(cols - 40) <= 2, "the overlay's actual columns \(cols) should match the requested 40 (2x would be ~80)") + XCTAssertTrue(abs(rows - 12) <= 2, "the overlay's actual rows \(rows) should match the requested 12 (2x would be ~24)") + + // the tree read-back's APPLIED grid reflects the same realized surface stty reads. + let node = try XCTUnwrap(pollOverlayApplied(id: id, timeout: 12), "the tree should report the realized grid") + let appliedCols = try XCTUnwrap(node["overlayColsApplied"] as? Int, "applied cols should be present: \(node)") + let appliedRows = try XCTUnwrap(node["overlayRowsApplied"] as? Int, "applied rows should be present: \(node)") + XCTAssertTrue(abs(appliedCols - cols) <= 1, "applied cols \(appliedCols) should match stty's \(cols): \(node)") + XCTAssertTrue(abs(appliedRows - rows) <= 1, "applied rows \(appliedRows) should match stty's \(rows): \(node)") + + let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#) + XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)") + } + + // The floating panel's on-screen FRAME follows its corner anchor: a top-left-anchored 40% panel is smaller + // than the window and sits in its upper-left region; re-anchoring to bottom-right shifts the panel right AND + // down. The Metal surface is not in the a11y tree, so the panel exposes a stable `overlay-floating-panel` + // element whose frame the test reads. + func testFloatingOverlayPanelFrameFollowsCornerAnchor() throws { + let id = try activeSessionID() + try resizeWindow(width: 1100, height: 750) + + let open = try sendOverlayOpen(target: id, command: "cat", args: ["sizePercent": 40, "anchor": "top-left"]) + XCTAssertEqual(open["ok"] as? Bool, true, "floating overlay open should succeed: \(open)") + XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the floating overlay should be up") + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "window should exist") + let panel = app.descendants(matching: .any).matching(identifier: "overlay-floating-panel").firstMatch + XCTAssertTrue(panel.waitForExistence(timeout: 10), "the floating overlay panel should be in the a11y tree") + + let win = window.frame + let topLeft = panel.frame + // floating => the panel is much smaller than the whole window (a full overlay fills the pane). + XCTAssertLessThan(topLeft.width, win.width * 0.85, "a 40% floating panel should be much narrower than the window") + XCTAssertLessThan(topLeft.height, win.height * 0.85, "a 40% floating panel should be much shorter than the window") + // top-left anchor => the panel's center is left of and above the window center (XCUITest frames are y-down). + XCTAssertLessThan(topLeft.midX, win.midX, "a top-left panel's center should be left of the window center") + XCTAssertLessThan(topLeft.midY, win.midY, "a top-left panel's center should be above the window center") + + // re-anchor to the opposite corner: the panel must move right AND down, proving the anchor drives placement. + let reanchor = try sendOverlayResize(target: id, args: ["anchor": "bottom-right"]) + XCTAssertEqual(reanchor["ok"] as? Bool, true, "re-anchor should succeed: \(reanchor)") + let bottomRight = try XCTUnwrap(pollPanelMoved(panel: panel, from: topLeft, timeout: 8), + "the panel should move after re-anchoring to bottom-right") + XCTAssertGreaterThan(bottomRight.minX, topLeft.minX + 20, "bottom-right anchor should shift the panel right") + XCTAssertGreaterThan(bottomRight.minY, topLeft.minY + 20, "bottom-right anchor should shift the panel down") + + let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#) + XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)") + } + + // The full transition cycle over the socket, driven while a SPLIT is visible to guard the NSSplitView-overrun + // invariant (the overlay panel must keep a constant ZStack shape across every resize/re-anchor). Open + // --size-percent, resize to --cols/--rows, re-anchor with --anchor only (size retained), resize to --full + // (cols/rows cleared, anchor RETAINED per Decision 2), then back to a floating percent. The read-back is + // asserted after each step, and the split surviving the cycle is the no-crash/no-overrun oracle. + func testOverlayResizeTransitionsCycleWhileSplit() throws { + let id = try activeSessionID() + XCTAssertEqual(try sendCommand(#"{"cmd":"session.split","target":"\#(id)","args":{"mode":"on"}}"#)["ok"] as? Bool, + true, "opening the split should succeed") + XCTAssertTrue(pollActiveSessionSplit(true, timeout: 10), "the split should be up") + + // open a floating percent overlay: default (center) anchor, no cols/rows. + let open = try sendOverlayOpen(target: id, command: "cat", args: ["sizePercent": 50]) + XCTAssertEqual(open["ok"] as? Bool, true, "floating overlay open should succeed: \(open)") + let atPercent = try XCTUnwrap(pollOverlayNode(id: id, timeout: 10) { $0["overlaySizePercent"] as? Int == 50 }, + "tree should report the 50% floating overlay") + XCTAssertEqual(atPercent["overlayAnchor"] as? String, "center", "the default anchor is center: \(atPercent)") + XCTAssertNil(atPercent["overlayCols"] as? Int, "a percent overlay reports no requested cols: \(atPercent)") + + // resize to an exact grid with a corner anchor: percent clears, cols/rows + anchor set. + let toCells = try sendOverlayResize(target: id, args: ["cols": 30, "rows": 10, "anchor": "bottom-left"]) + XCTAssertEqual(toCells["ok"] as? Bool, true, "resize to cols/rows should succeed: \(toCells)") + let atCells = try XCTUnwrap(pollOverlayNode(id: id, timeout: 10) { $0["overlayCols"] as? Int == 30 }, + "tree should report the requested cols after resize") + XCTAssertEqual(atCells["overlayRows"] as? Int, 10, "tree should report the requested rows: \(atCells)") + XCTAssertEqual(atCells["overlayAnchor"] as? String, "bottom-left", "tree should report the new anchor: \(atCells)") + XCTAssertNil(atCells["overlaySizePercent"] as? Int, "a cells overlay reports no percent: \(atCells)") + + // re-anchor ONLY: the size (cols/rows) is retained, just the anchor moves. + let reanchor = try sendOverlayResize(target: id, args: ["anchor": "top"]) + XCTAssertEqual(reanchor["ok"] as? Bool, true, "re-anchor only should succeed: \(reanchor)") + let atTop = try XCTUnwrap(pollOverlayNode(id: id, timeout: 10) { $0["overlayAnchor"] as? String == "top" }, + "tree should report the re-anchored overlay") + XCTAssertEqual(atTop["overlayCols"] as? Int, 30, "re-anchor keeps the requested cols: \(atTop)") + XCTAssertEqual(atTop["overlayRows"] as? Int, 10, "re-anchor keeps the requested rows: \(atTop)") + + // resize to full: cols/rows + applied clear, but the anchor is PRESERVED across --full (Decision 2). + let toFull = try sendOverlayResize(target: id, args: ["full": true]) + XCTAssertEqual(toFull["ok"] as? Bool, true, "resize to full should succeed: \(toFull)") + let atFull = try XCTUnwrap(pollOverlayNode(id: id, timeout: 10) { + ($0["overlay"] as? Bool == true) && ($0["overlayCols"] as? Int == nil) && ($0["overlaySizePercent"] as? Int == nil) + }, "tree should report a full overlay with cleared cols/rows") + XCTAssertNil(atFull["overlayColsApplied"] as? Int, "a full overlay reports no applied cols: \(atFull)") + XCTAssertEqual(atFull["overlayAnchor"] as? String, "top", "--full must PRESERVE the anchor (Decision 2): \(atFull)") + + // back to a floating percent with a new anchor: the overlay never re-spawned and the split is intact. + let backToPercent = try sendOverlayResize(target: id, args: ["sizePercent": 60, "anchor": "bottom-right"]) + XCTAssertEqual(backToPercent["ok"] as? Bool, true, "resize back to percent should succeed: \(backToPercent)") + let atPercent2 = try XCTUnwrap(pollOverlayNode(id: id, timeout: 10) { $0["overlaySizePercent"] as? Int == 60 }, + "tree should report the 60% floating overlay") + XCTAssertEqual(atPercent2["overlayAnchor"] as? String, "bottom-right", "tree should report the new anchor: \(atPercent2)") + + // the split survived every transition (the NSSplitView-overrun guard: no crash, no re-host). + XCTAssertTrue(pollActiveSessionSplit(true, timeout: 5), "the split must survive the resize/re-anchor cycle") + XCTAssertTrue(pollSessionRowCount(1, timeout: 5), "the session must survive the cycle") + + let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#) + XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)") + XCTAssertTrue(pollSessionOverlay(id: id, expected: false, timeout: 10), "the overlay should be gone") + XCTAssertTrue(pollActiveSessionSplit(true, timeout: 5), "the split remains after the overlay closes") + } + + // The dispatcher rejects the invalid size/anchor combinations server-side (a raw client bypasses the CLI + // validate()): --anchor with no floating size, only one of --cols/--rows, an out-of-range OPEN percent + // (now a hard error, Decision 3), and --full combined with --anchor on resize. + func testOverlaySizingAnchorErrorsOverSocket() throws { + let id = try activeSessionID() + + // --anchor without a floating size on open is rejected, and opens nothing. + let anchorNoSize = try sendOverlayOpen(target: id, command: "cat", args: ["anchor": "top-left"]) + XCTAssertEqual(anchorNoSize["ok"] as? Bool, false, "anchor without a floating size should fail: \(anchorNoSize)") + XCTAssertEqual(anchorNoSize["error"] as? String, + "--anchor requires a floating overlay: use --size-percent or --cols/--rows", "\(anchorNoSize)") + XCTAssertTrue(pollSessionOverlay(id: id, expected: false, timeout: 5), "the rejected open must not open an overlay") + + // only one of --cols/--rows is a usage error. + let colsOnly = try sendOverlayOpen(target: id, command: "cat", args: ["cols": 40]) + XCTAssertEqual(colsOnly["ok"] as? Bool, false, "cols without rows should fail: \(colsOnly)") + XCTAssertEqual(colsOnly["error"] as? String, "provide both --cols and --rows", "\(colsOnly)") + + // an out-of-range percent now hard-errors on OPEN too (Decision 3 — open validation matches resize). + let badPercent = try sendOverlayOpen(target: id, command: "cat", args: ["sizePercent": 150]) + XCTAssertEqual(badPercent["ok"] as? Bool, false, "an out-of-range open percent should fail: \(badPercent)") + XCTAssertEqual(badPercent["error"] as? String, "session.overlay.open: --size-percent must be 1...100", "\(badPercent)") + + // open a real floating overlay, then --full + --anchor on resize is rejected. + let open = try sendOverlayOpen(target: id, command: "cat", args: ["sizePercent": 50]) + XCTAssertEqual(open["ok"] as? Bool, true, "floating overlay open should succeed: \(open)") + XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the overlay should be up") + + let fullAndAnchor = try sendOverlayResize(target: id, args: ["full": true, "anchor": "bottom-right"]) + XCTAssertEqual(fullAndAnchor["ok"] as? Bool, false, "--full with --anchor should fail: \(fullAndAnchor)") + XCTAssertEqual(fullAndAnchor["error"] as? String, "--full cannot be combined with --anchor", "\(fullAndAnchor)") + + let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#) + XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)") + } + /// Creates a session via `session.new` and returns its id as a `UUID`. `session.new` focuses the new /// session, so the returned session becomes the active one. private func newSession() throws -> UUID { @@ -779,4 +977,81 @@ final class ControlOverlaySplitUITests: ControlAPITestCase { } return false } + + // MARK: - Overlay sizing/anchor helpers + + /// Sends a `session.overlay.open` for `target` running `command`, merging `extra` (cols/rows/anchor/ + /// sizePercent/…) into the args. Built via JSONSerialization so numeric args stay numbers on the wire. + private func sendOverlayOpen(target: String, command: String, args extra: [String: Any]) throws -> [String: Any] { + var args: [String: Any] = ["command": command] + for (key, value) in extra { args[key] = value } + let obj: [String: Any] = ["cmd": "session.overlay.open", "target": target, "args": args] + return try sendCommand(String(data: JSONSerialization.data(withJSONObject: obj), encoding: .utf8)!) + } + + /// Sends a `session.overlay.resize` for `target` with `extra` as the args (size and/or anchor). + private func sendOverlayResize(target: String, args extra: [String: Any]) throws -> [String: Any] { + let obj: [String: Any] = ["cmd": "session.overlay.resize", "target": target, "args": extra] + return try sendCommand(String(data: JSONSerialization.data(withJSONObject: obj), encoding: .utf8)!) + } + + /// The `tree` session node for `id` (case-insensitive), scanning all workspaces, or nil if not found. + private func sessionNode(id: String) throws -> [String: Any]? { + let tree = try sendCommand(#"{"cmd":"tree"}"#) + guard let result = tree["result"] as? [String: Any], + let t = result["tree"] as? [String: Any], + let workspaces = t["workspaces"] as? [[String: Any]] else { return nil } + for ws in workspaces { + for s in (ws["sessions"] as? [[String: Any]] ?? []) + where (s["id"] as? String)?.lowercased() == id.lowercased() { return s } + } + return nil + } + + /// Polls the `tree` node for `id` until `predicate` holds, returning the matching node, or nil on timeout. + private func pollOverlayNode(id: String, timeout: TimeInterval, + until predicate: ([String: Any]) -> Bool) -> [String: Any]? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let node = try? sessionNode(id: id), predicate(node) { return node } + usleep(200_000) + } + return nil + } + + /// Polls the `tree` node for `id` until BOTH `overlayColsApplied` and `overlayRowsApplied` are present + /// (the realized grid is reported once the overlay surface has come up), returning the node, or nil. + private func pollOverlayApplied(id: String, timeout: TimeInterval) -> [String: Any]? { + pollOverlayNode(id: id, timeout: timeout) { + ($0["overlayColsApplied"] as? Int) != nil && ($0["overlayRowsApplied"] as? Int) != nil + } + } + + /// Resizes the frontmost window to `width`x`height` and waits until the on-screen frame reflects it, so a + /// subsequently opened overlay sizes against a known pane. + private func resizeWindow(width: Int, height: Int) throws { + let resized = try sendCommand(#"{"cmd":"window.resize","args":{"width":\#(width),"height":\#(height)}}"#) + XCTAssertEqual(resized["ok"] as? Bool, true, "window.resize should succeed: \(resized)") + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "window should exist") + let deadline = Date().addingTimeInterval(5) + while Date() < deadline { + let size = window.frame.size + if abs(size.width - CGFloat(width)) < 12, abs(size.height - CGFloat(height)) < 12 { return } + usleep(150_000) + } + } + + /// Polls `panel`'s frame until it has moved meaningfully from `original` (a re-anchor is async), returning + /// the new frame, or nil if it never moved within `timeout`. + private func pollPanelMoved(panel: XCUIElement, from original: CGRect, timeout: TimeInterval) -> CGRect? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let frame = panel.frame + if abs(frame.minX - original.minX) > 20 || abs(frame.minY - original.minY) > 20 { return frame } + usleep(200_000) + } + let frame = panel.frame + return (abs(frame.minX - original.minX) > 20 || abs(frame.minY - original.minY) > 20) ? frame : nil + } } diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md index c493455c..ff7c54aa 100644 --- a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md +++ b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md @@ -400,17 +400,25 @@ via `OverlayLayout.panelSize` + anchor alignment. `tree` reports requested + app **Files:** - Modify: `agtermUITests/ControlOverlaySplitUITests.swift` +- ➕ Modify: `agterm/Views/WindowContentView.swift` (Task-5 follow-up surfaced by the frame e2e — see note) -- [ ] e2e: open a floating overlay with `--cols/--rows` + `--anchor`; assert `tree` read-back (requested +- [x] e2e: open a floating overlay with `--cols/--rows` + `--anchor`; assert `tree` read-back (requested `overlayCols/Rows`, applied `overlayColsApplied/RowsApplied`, `overlayAnchor`) -- [ ] e2e: assert the **actual grid** — run `stty size > marker; cat` in the overlay and assert the +- [x] e2e: assert the **actual grid** — run `stty size > marker; cat` in the overlay and assert the marker reports the requested (or clamped) rows/cols (would catch the Retina bug) -- [ ] e2e: assert the floating panel's **frame vs the pane** via its accessibility id for a corner anchor -- [ ] e2e: open `--size-percent`, resize to `--cols/--rows`, re-anchor with `--anchor` only, resize back +- [x] e2e: assert the floating panel's **frame vs the pane** via its accessibility id for a corner anchor +- [x] e2e: open `--size-percent`, resize to `--cols/--rows`, re-anchor with `--anchor` only, resize back to `--full` (read-back: cols/rows cleared, anchor retained); cycle these while a split is visible -- [ ] e2e: error cases over the socket (anchor without floating, both cols/rows required, full+anchor, +- [x] e2e: error cases over the socket (anchor without floating, both cols/rows required, full+anchor, **open --size-percent out of range now errors**) -- [ ] run the overlay e2e target — must pass before next task +- [x] run the overlay e2e target — must pass before next task + +➕ **Deviation (Task-5 follow-up):** the `overlay-floating-panel` accessibility id added in Task 5 was on +the Metal-backed `TerminalView`, which the codebase precedent (`dashboard-cell`, `quick-terminal`) confirms +is NOT exposed in the a11y tree — the frame e2e failed with "panel should be in the a11y tree". Fixed by +moving the id onto a zero-content `.overlay(Color.clear …)` marker sized to the panel (the dashboard-cell +pattern). As an `.overlay` modifier it adds NO ZStack child, so the constant-child-count NSSplitView-overrun +invariant is preserved. All 5 e2e methods pass after the fix. ### Task 8: Keep-in-sync documentation surfaces From fa788d13c0be88c92fa9060ac7216a3bd3205046 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 01:32:43 -0500 Subject: [PATCH 09/20] test: e2e for overlay cols/rows sizing and anchor with 1-cell margin --- agterm/Views/WindowContentView.swift | 17 +++- .../Sources/agtermCore/OverlayLayout.swift | 37 ++++++++ .../agtermCoreTests/OverlayLayoutTests.swift | 58 +++++++++++++ .../ControlOverlaySplitUITests.swift | 84 +++++++++++++------ ...20260722-overlay-anchor-and-cell-sizing.md | 21 +++++ 5 files changed, 188 insertions(+), 29 deletions(-) diff --git a/agterm/Views/WindowContentView.swift b/agterm/Views/WindowContentView.swift index f0aab3cc..cbbb7393 100644 --- a/agterm/Views/WindowContentView.swift +++ b/agterm/Views/WindowContentView.swift @@ -534,10 +534,13 @@ struct WindowContentView: View { @ViewBuilder private func overlayPanel(session: Session, isActive: Bool) -> some View { GeometryReader { geo in let floating = session.floatingOverlayActive - let panel = OverlayLayout.panelSize(session.overlaySize, - pane: WindowGeometry.Size(width: Double(geo.size.width), - height: Double(geo.size.height)), - cell: session.overlayCellMetrics) + let paneSize = WindowGeometry.Size(width: Double(geo.size.width), height: Double(geo.size.height)) + let panel = OverlayLayout.panelSize(session.overlaySize, pane: paneSize, cell: session.overlayCellMetrics) + // a floating panel sits one cell off the pane edge(s) it anchors to (a corner insets both sides, + // an edge one, center none), so it never reads as flush against the border; full/center -> .zero. + let insets = floating + ? OverlayLayout.anchorInsets(session.overlayAnchor, panel: panel, pane: paneSize, cell: session.overlayCellMetrics) + : .zero ZStack(alignment: floating ? session.overlayAnchor.swiftUIAlignment : .center) { if session.overlayActive, deckHostsSurface(session: session, surface: .overlay) { // transparent click-catcher over the whole detail area: absorbs clicks AROUND a floating @@ -569,6 +572,12 @@ struct WindowContentView: View { .accessibilityElement() .accessibilityIdentifier("overlay-floating-panel") ) + // push the panel one cell off its anchored edge(s) — the padding wraps the panel AND + // its a11y marker, so the marker still reports the panel's own frame while the whole + // group is offset within the ZStack. A constant modifier (values-only, no anchor + // branch), so the ZStack child count never changes (NSSplitView-overrun invariant). + .padding(EdgeInsets(top: CGFloat(insets.top), leading: CGFloat(insets.leading), + bottom: CGFloat(insets.bottom), trailing: CGFloat(insets.trailing))) .id("\(session.id.uuidString)-overlay") } } diff --git a/agtermCore/Sources/agtermCore/OverlayLayout.swift b/agtermCore/Sources/agtermCore/OverlayLayout.swift index 7acdd133..5fd8e06e 100644 --- a/agtermCore/Sources/agtermCore/OverlayLayout.swift +++ b/agtermCore/Sources/agtermCore/OverlayLayout.swift @@ -65,6 +65,26 @@ public struct OverlayCellMetrics: Equatable, Sendable { public var isUsable: Bool { cellWidth > 0 && cellHeight > 0 } } +/// Per-edge insets (in points) applied to an anchored floating overlay panel so it sits one cell off the +/// pane edge(s) it anchors to, instead of flush against the border. The app maps these onto a SwiftUI +/// `padding(EdgeInsets)` on the panel; the centered axes carry a zero inset. +public struct OverlayInsets: Equatable, Sendable { + public let leading: Double + public let top: Double + public let trailing: Double + public let bottom: Double + + public init(leading: Double, top: Double, trailing: Double, bottom: Double) { + self.leading = leading + self.top = top + self.trailing = trailing + self.bottom = bottom + } + + /// No inset on any edge (`center`, full overlay, or unusable metrics). + public static let zero = OverlayInsets(leading: 0, top: 0, trailing: 0, bottom: 0) +} + /// Pure resolver turning an `OverlaySize` request into a concrete panel size within a pane. Host-free and /// unit-tested; the app applies the result as a SwiftUI frame. All sizes are in points. public enum OverlayLayout { @@ -99,4 +119,21 @@ public enum OverlayLayout { let usedCount = Swift.max(1, Swift.min(count, fitCount)) return Swift.min(Double(usedCount) * cellSize + pad, available) } + + /// The one-cell margin a floating overlay panel takes off the pane edge(s) its `anchor` sits against, so + /// an edge/corner-anchored panel is not flush with the border. The anchored horizontal side (`unitX` 0 = + /// leading, 1 = trailing) gets one `cell.cellWidth` inset and the anchored vertical side (`unitY` 0 = + /// top, 1 = bottom) one `cell.cellHeight` inset; a centered axis (`0.5`) gets none. Each inset is capped + /// at the slack on its axis (`min(oneCell, pane - panel)`), so a near-full-pane panel never overflows, + /// and nil or unusable `cell` metrics yield no inset. `center` (both axes 0.5) always yields `.zero`. + public static func anchorInsets(_ anchor: OverlayAnchor, panel: WindowGeometry.Size, + pane: WindowGeometry.Size, cell: OverlayCellMetrics?) -> OverlayInsets { + guard let cell, cell.isUsable else { return .zero } + let hInset = Swift.min(cell.cellWidth, Swift.max(0, pane.width - panel.width)) + let vInset = Swift.min(cell.cellHeight, Swift.max(0, pane.height - panel.height)) + return OverlayInsets(leading: anchor.unitX == 0 ? hInset : 0, + top: anchor.unitY == 0 ? vInset : 0, + trailing: anchor.unitX == 1 ? hInset : 0, + bottom: anchor.unitY == 1 ? vInset : 0) + } } diff --git a/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift b/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift index 93c47f1b..05810e8c 100644 --- a/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift +++ b/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift @@ -153,4 +153,62 @@ struct OverlayLayoutTests { #expect(OverlayAnchor.top.rawValue == "top") #expect(OverlayAnchor(rawValue: "diagonal") == nil) } + + // MARK: - anchorInsets (1-cell anchor margin) + + private func insetsCell() -> OverlayCellMetrics { + OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 0, padHeight: 0) + } + + @Test func cornerAnchorInsetsBothAnchoredSidesOneCell() { + // top-left: one cell off the leading edge AND one cell off the top; trailing/bottom untouched. + let insets = OverlayLayout.anchorInsets(.topLeft, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) + #expect(insets == OverlayInsets(leading: 8, top: 16, trailing: 0, bottom: 0)) + + // bottom-right: one cell off the trailing edge AND one cell off the bottom. + let br = OverlayLayout.anchorInsets(.bottomRight, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) + #expect(br == OverlayInsets(leading: 0, top: 0, trailing: 8, bottom: 16)) + } + + @Test func edgeAnchorInsetsOnlyTheAnchoredAxis() { + // top edge: inset the top only, the centered horizontal axis gets nothing. + let top = OverlayLayout.anchorInsets(.top, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) + #expect(top == OverlayInsets(leading: 0, top: 16, trailing: 0, bottom: 0)) + + // left edge: inset the leading only, the centered vertical axis gets nothing. + let left = OverlayLayout.anchorInsets(.left, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) + #expect(left == OverlayInsets(leading: 8, top: 0, trailing: 0, bottom: 0)) + + // right edge: inset the trailing only. + let right = OverlayLayout.anchorInsets(.right, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) + #expect(right == OverlayInsets(leading: 0, top: 0, trailing: 8, bottom: 0)) + + // bottom edge: inset the bottom only. + let bottom = OverlayLayout.anchorInsets(.bottom, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) + #expect(bottom == OverlayInsets(leading: 0, top: 0, trailing: 0, bottom: 16)) + } + + @Test func centerAnchorHasNoInset() { + let insets = OverlayLayout.anchorInsets(.center, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) + #expect(insets == .zero) + } + + @Test func anchorInsetsCapAtAvailableSlack() { + // a near-full-pane panel: only 3pt of horizontal slack and 5pt of vertical slack remain, so the + // one-cell (8x16) inset is capped to that slack and the panel never overflows the pane. + let insets = OverlayLayout.anchorInsets(.topLeft, panel: pane(797, 595), pane: pane(800, 600), cell: insetsCell()) + #expect(insets == OverlayInsets(leading: 3, top: 5, trailing: 0, bottom: 0)) + } + + @Test func anchorInsetsCapAtZeroWhenPanelFillsPane() { + // a panel exactly the pane size (or larger) has no slack, so an anchored inset is clamped to zero. + let insets = OverlayLayout.anchorInsets(.bottomRight, panel: pane(800, 600), pane: pane(800, 600), cell: insetsCell()) + #expect(insets == .zero) + } + + @Test func anchorInsetsAreZeroWithoutUsableMetrics() { + #expect(OverlayLayout.anchorInsets(.topLeft, panel: pane(200, 200), pane: pane(800, 600), cell: nil) == .zero) + let zeroWidth = OverlayCellMetrics(cellWidth: 0, cellHeight: 16, padWidth: 0, padHeight: 0) + #expect(OverlayLayout.anchorInsets(.topLeft, panel: pane(200, 200), pane: pane(800, 600), cell: zeroWidth) == .zero) + } } diff --git a/agtermUITests/ControlOverlaySplitUITests.swift b/agtermUITests/ControlOverlaySplitUITests.swift index 8b8a652c..4ab0ab65 100644 --- a/agtermUITests/ControlOverlaySplitUITests.swift +++ b/agtermUITests/ControlOverlaySplitUITests.swift @@ -739,39 +739,60 @@ final class ControlOverlaySplitUITests: ControlAPITestCase { XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)") } - // The floating panel's on-screen FRAME follows its corner anchor: a top-left-anchored 40% panel is smaller - // than the window and sits in its upper-left region; re-anchoring to bottom-right shifts the panel right AND - // down. The Metal surface is not in the a11y tree, so the panel exposes a stable `overlay-floating-panel` - // element whose frame the test reads. + // The floating panel follows its corner anchor AND sits ONE CELL off the anchored edges (the 1-cell anchor + // margin), never flush against the border. The detail area is captured from a FULL overlay (its panel fills + // the pane exactly with NO inset) via the same `overlay-floating-panel` marker; the floating panel's inset + // from that reference is then asserted to be a small, positive, cell-sized margin. This is discriminating: + // flush placement (margin 0) fails the > assertions, and centered placement (a large half-slack offset) + // fails the < assertions. The vertical margin exceeds the horizontal one because a terminal cell is taller + // than it is wide (each axis insets by its OWN cell dimension). The Metal surface is not in the a11y tree, + // so the panel exposes the stable `overlay-floating-panel` marker whose frame the test reads. func testFloatingOverlayPanelFrameFollowsCornerAnchor() throws { let id = try activeSessionID() try resizeWindow(width: 1100, height: 750) - let open = try sendOverlayOpen(target: id, command: "cat", args: ["sizePercent": 40, "anchor": "top-left"]) - XCTAssertEqual(open["ok"] as? Bool, true, "floating overlay open should succeed: \(open)") - XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the floating overlay should be up") + // a FULL overlay fills the detail area exactly (no anchor inset), so its marker frame is the pane the + // floating margin is measured against. + let full = try sendOverlayOpen(target: id, command: "cat", args: [:]) + XCTAssertEqual(full["ok"] as? Bool, true, "full overlay open should succeed: \(full)") + XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the full overlay should be up") - let window = app.windows.firstMatch - XCTAssertTrue(window.waitForExistence(timeout: 5), "window should exist") let panel = app.descendants(matching: .any).matching(identifier: "overlay-floating-panel").firstMatch - XCTAssertTrue(panel.waitForExistence(timeout: 10), "the floating overlay panel should be in the a11y tree") - - let win = window.frame - let topLeft = panel.frame - // floating => the panel is much smaller than the whole window (a full overlay fills the pane). - XCTAssertLessThan(topLeft.width, win.width * 0.85, "a 40% floating panel should be much narrower than the window") - XCTAssertLessThan(topLeft.height, win.height * 0.85, "a 40% floating panel should be much shorter than the window") - // top-left anchor => the panel's center is left of and above the window center (XCUITest frames are y-down). - XCTAssertLessThan(topLeft.midX, win.midX, "a top-left panel's center should be left of the window center") - XCTAssertLessThan(topLeft.midY, win.midY, "a top-left panel's center should be above the window center") - - // re-anchor to the opposite corner: the panel must move right AND down, proving the anchor drives placement. + XCTAssertTrue(panel.waitForExistence(timeout: 10), "the overlay panel should be in the a11y tree") + let detail = panel.frame + + // resize to a 40% floating panel anchored top-left: it shrinks well below the pane and insets from the + // top-left corner by ~1 cell. + let toFloating = try sendOverlayResize(target: id, args: ["sizePercent": 40, "anchor": "top-left"]) + XCTAssertEqual(toFloating["ok"] as? Bool, true, "resize to floating top-left should succeed: \(toFloating)") + let tl = try XCTUnwrap(pollPanelWidth(panel: panel, below: detail.width * 0.6, timeout: 8), + "the panel should shrink to the floating size") + XCTAssertLessThan(tl.height, detail.height * 0.6, "a 40% floating panel should be much shorter than the pane") + + // top-left => a small POSITIVE margin off the pane's left and top (NOT flush at 0, NOT the large + // half-slack offset a centered panel would show). one cell is ~8pt wide, ~17pt tall at the default font. + let marginX = tl.minX - detail.minX + let marginY = tl.minY - detail.minY + XCTAssertGreaterThan(marginX, 2, "top-left panel should inset a cell off the left, not sit flush (marginX=\(marginX))") + XCTAssertGreaterThan(marginY, 4, "top-left panel should inset a cell off the top, not sit flush (marginY=\(marginY))") + XCTAssertLessThan(marginX, 30, "the left margin should be ~1 cell, not a centered half-slack offset (marginX=\(marginX))") + XCTAssertLessThan(marginY, 45, "the top margin should be ~1 cell, not a centered half-slack offset (marginY=\(marginY))") + XCTAssertGreaterThan(marginY, marginX, "the vertical margin (cell height) should exceed the horizontal one (cell width)") + + // re-anchor to the opposite corner: the panel must move right AND down, and now insets a cell off the + // pane's RIGHT and BOTTOM edges by the same ~1-cell margin (mirrored to the anchored side). let reanchor = try sendOverlayResize(target: id, args: ["anchor": "bottom-right"]) XCTAssertEqual(reanchor["ok"] as? Bool, true, "re-anchor should succeed: \(reanchor)") - let bottomRight = try XCTUnwrap(pollPanelMoved(panel: panel, from: topLeft, timeout: 8), - "the panel should move after re-anchoring to bottom-right") - XCTAssertGreaterThan(bottomRight.minX, topLeft.minX + 20, "bottom-right anchor should shift the panel right") - XCTAssertGreaterThan(bottomRight.minY, topLeft.minY + 20, "bottom-right anchor should shift the panel down") + let br = try XCTUnwrap(pollPanelMoved(panel: panel, from: tl, timeout: 8), + "the panel should move after re-anchoring to bottom-right") + XCTAssertGreaterThan(br.minX, tl.minX + 20, "bottom-right anchor should shift the panel right") + XCTAssertGreaterThan(br.minY, tl.minY + 20, "bottom-right anchor should shift the panel down") + let marginRight = detail.maxX - br.maxX + let marginBottom = detail.maxY - br.maxY + XCTAssertGreaterThan(marginRight, 2, "bottom-right panel should inset a cell off the right (marginRight=\(marginRight))") + XCTAssertGreaterThan(marginBottom, 4, "bottom-right panel should inset a cell off the bottom (marginBottom=\(marginBottom))") + XCTAssertLessThan(marginRight, 30, "the right margin should be ~1 cell (marginRight=\(marginRight))") + XCTAssertLessThan(marginBottom, 45, "the bottom margin should be ~1 cell (marginBottom=\(marginBottom))") let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#) XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)") @@ -1042,6 +1063,19 @@ final class ControlOverlaySplitUITests: ControlAPITestCase { } } + /// Polls `panel`'s frame until its width drops below `threshold` (a full->floating resize shrinks it), + /// returning the new frame, or nil if it never shrank within `timeout`. + private func pollPanelWidth(panel: XCUIElement, below threshold: CGFloat, timeout: TimeInterval) -> CGRect? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let frame = panel.frame + if frame.width < threshold { return frame } + usleep(200_000) + } + let frame = panel.frame + return frame.width < threshold ? frame : nil + } + /// Polls `panel`'s frame until it has moved meaningfully from `original` (a re-anchor is async), returning /// the new frame, or nil if it never moved within `timeout`. private func pollPanelMoved(panel: XCUIElement, from original: CGRect, timeout: TimeInterval) -> CGRect? { diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md index ff7c54aa..4656d503 100644 --- a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md +++ b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md @@ -202,6 +202,15 @@ maintainer decisions are folded in below: `overlayPanel` sizes via `OverlayLayout.panelSize(session.overlaySize, pane: geo.size, cell: session.overlayCellMetrics)` and positions via `ZStack(alignment: floating ? anchor.swiftUIAlignment : .center)`; a stable accessibility id on the floating panel enables the e2e frame assertion. +- **1-cell anchor margin (maintainer feedback)**: an edge/corner-anchored floating panel is NOT flush with + the pane border — it sits one cell off each anchored side (a corner insets both, an edge one, `center` + none). The margin is host-free in `OverlayLayout.anchorInsets(_:panel:pane:cell:) -> OverlayInsets` + (leading/top/trailing/bottom in points): the anchored horizontal side gets `cellWidth`, the anchored + vertical side `cellHeight`, each capped at the axis slack (`min(oneCell, pane − panel)`) so a near-full + panel never overflows, and nil/unusable metrics → `.zero`. `overlayPanel` maps it to a single + `padding(EdgeInsets)` on the panel (a values-only modifier — no anchor-specific view branch — so the + ZStack child count stays constant per the NSSplitView-overrun invariant), applied AFTER the a11y marker + so the marker still reports the panel's own frame. Applies to ANY floating overlay (percent or cells). ## Technical Details @@ -420,6 +429,18 @@ moving the id onto a zero-content `.overlay(Color.clear …)` marker sized to th pattern). As an `.overlay` modifier it adds NO ZStack child, so the constant-child-count NSSplitView-overrun invariant is preserved. All 5 e2e methods pass after the fix. +➕ **Deviation (maintainer feedback — 1-cell anchor margin):** an edge/corner-anchored floating panel used +to sit FLUSH against the pane border, which looked bad. Per maintainer feedback the panel now insets one +cell off each anchored side (a corner insets both, an edge one, `center` none). Added the host-free +`OverlayLayout.anchorInsets(_:panel:pane:cell:) -> OverlayInsets` (leading/top/trailing/bottom in points; +anchored horizontal side = `cellWidth`, vertical = `cellHeight`, each capped at the axis slack, nil/unusable +metrics → `.zero`) with `OverlayLayoutTests` (corner/edge/center/slack-cap/nil cases); `overlayPanel` maps +it to a single `padding(EdgeInsets)` on the panel — a values-only modifier (no anchor branch), applied AFTER +the a11y marker so the marker keeps reporting the panel's own frame, preserving the NSSplitView-overrun +invariant. The frame e2e was rewritten to assert the ~1-cell margin (inset from a full-overlay detail-area +reference), discriminating against both flush (margin 0) and centered (large half-slack) placement. Applies +to ANY floating overlay (percent or cells). All 5 e2e methods still pass. + ### Task 8: Keep-in-sync documentation surfaces **Files:** From 24a0d3b4898163445e6af38ad1f052466c6a91a2 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 01:40:20 -0500 Subject: [PATCH 10/20] fix: use line-height for overlay anchor margin on both axes --- .../Sources/agtermCore/OverlayLayout.swift | 17 ++++++---- .../agtermCoreTests/OverlayLayoutTests.swift | 15 ++++---- .../ControlOverlaySplitUITests.swift | 27 ++++++++------- ...20260722-overlay-anchor-and-cell-sizing.md | 34 +++++++++++-------- 4 files changed, 52 insertions(+), 41 deletions(-) diff --git a/agtermCore/Sources/agtermCore/OverlayLayout.swift b/agtermCore/Sources/agtermCore/OverlayLayout.swift index 5fd8e06e..9225edfd 100644 --- a/agtermCore/Sources/agtermCore/OverlayLayout.swift +++ b/agtermCore/Sources/agtermCore/OverlayLayout.swift @@ -120,16 +120,19 @@ public enum OverlayLayout { return Swift.min(Double(usedCount) * cellSize + pad, available) } - /// The one-cell margin a floating overlay panel takes off the pane edge(s) its `anchor` sits against, so - /// an edge/corner-anchored panel is not flush with the border. The anchored horizontal side (`unitX` 0 = - /// leading, 1 = trailing) gets one `cell.cellWidth` inset and the anchored vertical side (`unitY` 0 = - /// top, 1 = bottom) one `cell.cellHeight` inset; a centered axis (`0.5`) gets none. Each inset is capped - /// at the slack on its axis (`min(oneCell, pane - panel)`), so a near-full-pane panel never overflows, - /// and nil or unusable `cell` metrics yield no inset. `center` (both axes 0.5) always yields `.zero`. + /// The one-line-height margin a floating overlay panel takes off the pane edge(s) its `anchor` sits + /// against, so an edge/corner-anchored panel is not flush with the border. BOTH anchored sides — the + /// horizontal one (`unitX` 0 = leading, 1 = trailing) and the vertical one (`unitY` 0 = top, 1 = bottom) + /// — inset by one `cell.cellHeight` (the line height), so the left/right gaps equal the top/bottom gaps + /// and the margin looks uniform; a terminal cell is ~2x taller than wide, so using the cell width for the + /// horizontal side would read as about half the vertical gap. A centered axis (`0.5`) gets none. Each + /// inset is capped at the slack on its axis (`min(oneCell, pane - panel)`), so a near-full-pane panel + /// never overflows, and nil or unusable `cell` metrics yield no inset. `center` (both axes 0.5) always + /// yields `.zero`. public static func anchorInsets(_ anchor: OverlayAnchor, panel: WindowGeometry.Size, pane: WindowGeometry.Size, cell: OverlayCellMetrics?) -> OverlayInsets { guard let cell, cell.isUsable else { return .zero } - let hInset = Swift.min(cell.cellWidth, Swift.max(0, pane.width - panel.width)) + let hInset = Swift.min(cell.cellHeight, Swift.max(0, pane.width - panel.width)) let vInset = Swift.min(cell.cellHeight, Swift.max(0, pane.height - panel.height)) return OverlayInsets(leading: anchor.unitX == 0 ? hInset : 0, top: anchor.unitY == 0 ? vInset : 0, diff --git a/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift b/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift index 05810e8c..5e946d0e 100644 --- a/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift +++ b/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift @@ -161,13 +161,14 @@ struct OverlayLayoutTests { } @Test func cornerAnchorInsetsBothAnchoredSidesOneCell() { - // top-left: one cell off the leading edge AND one cell off the top; trailing/bottom untouched. + // top-left: one line-height off the leading edge AND off the top (both use cellHeight); trailing/ + // bottom untouched. let insets = OverlayLayout.anchorInsets(.topLeft, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) - #expect(insets == OverlayInsets(leading: 8, top: 16, trailing: 0, bottom: 0)) + #expect(insets == OverlayInsets(leading: 16, top: 16, trailing: 0, bottom: 0)) - // bottom-right: one cell off the trailing edge AND one cell off the bottom. + // bottom-right: one line-height off the trailing edge AND off the bottom. let br = OverlayLayout.anchorInsets(.bottomRight, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) - #expect(br == OverlayInsets(leading: 0, top: 0, trailing: 8, bottom: 16)) + #expect(br == OverlayInsets(leading: 0, top: 0, trailing: 16, bottom: 16)) } @Test func edgeAnchorInsetsOnlyTheAnchoredAxis() { @@ -177,11 +178,11 @@ struct OverlayLayoutTests { // left edge: inset the leading only, the centered vertical axis gets nothing. let left = OverlayLayout.anchorInsets(.left, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) - #expect(left == OverlayInsets(leading: 8, top: 0, trailing: 0, bottom: 0)) + #expect(left == OverlayInsets(leading: 16, top: 0, trailing: 0, bottom: 0)) // right edge: inset the trailing only. let right = OverlayLayout.anchorInsets(.right, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) - #expect(right == OverlayInsets(leading: 0, top: 0, trailing: 8, bottom: 0)) + #expect(right == OverlayInsets(leading: 0, top: 0, trailing: 16, bottom: 0)) // bottom edge: inset the bottom only. let bottom = OverlayLayout.anchorInsets(.bottom, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) @@ -195,7 +196,7 @@ struct OverlayLayoutTests { @Test func anchorInsetsCapAtAvailableSlack() { // a near-full-pane panel: only 3pt of horizontal slack and 5pt of vertical slack remain, so the - // one-cell (8x16) inset is capped to that slack and the panel never overflows the pane. + // one-line-height (16pt) inset is capped to that slack on each axis and the panel never overflows. let insets = OverlayLayout.anchorInsets(.topLeft, panel: pane(797, 595), pane: pane(800, 600), cell: insetsCell()) #expect(insets == OverlayInsets(leading: 3, top: 5, trailing: 0, bottom: 0)) } diff --git a/agtermUITests/ControlOverlaySplitUITests.swift b/agtermUITests/ControlOverlaySplitUITests.swift index 4ab0ab65..f0422cf5 100644 --- a/agtermUITests/ControlOverlaySplitUITests.swift +++ b/agtermUITests/ControlOverlaySplitUITests.swift @@ -739,14 +739,15 @@ final class ControlOverlaySplitUITests: ControlAPITestCase { XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)") } - // The floating panel follows its corner anchor AND sits ONE CELL off the anchored edges (the 1-cell anchor + // The floating panel follows its corner anchor AND sits ONE LINE-HEIGHT off the anchored edges (the anchor // margin), never flush against the border. The detail area is captured from a FULL overlay (its panel fills // the pane exactly with NO inset) via the same `overlay-floating-panel` marker; the floating panel's inset - // from that reference is then asserted to be a small, positive, cell-sized margin. This is discriminating: - // flush placement (margin 0) fails the > assertions, and centered placement (a large half-slack offset) - // fails the < assertions. The vertical margin exceeds the horizontal one because a terminal cell is taller - // than it is wide (each axis insets by its OWN cell dimension). The Metal surface is not in the a11y tree, - // so the panel exposes the stable `overlay-floating-panel` marker whose frame the test reads. + // from that reference is then asserted to be a small, positive, roughly-uniform margin. This is + // discriminating: flush placement (margin 0) fails the > assertions, and centered placement (a large + // half-slack offset) fails the < assertions. The horizontal and vertical margins are approximately EQUAL + // because BOTH anchored sides inset by the cell HEIGHT (one line-height), so the left/right gap matches the + // top/bottom gap. The Metal surface is not in the a11y tree, so the panel exposes the stable + // `overlay-floating-panel` marker whose frame the test reads. func testFloatingOverlayPanelFrameFollowsCornerAnchor() throws { let id = try activeSessionID() try resizeWindow(width: 1100, height: 750) @@ -770,14 +771,16 @@ final class ControlOverlaySplitUITests: ControlAPITestCase { XCTAssertLessThan(tl.height, detail.height * 0.6, "a 40% floating panel should be much shorter than the pane") // top-left => a small POSITIVE margin off the pane's left and top (NOT flush at 0, NOT the large - // half-slack offset a centered panel would show). one cell is ~8pt wide, ~17pt tall at the default font. + // half-slack offset a centered panel would show). the margin is one line-height (~17pt at the default + // font) on BOTH the left and the top, so the two are approximately equal. let marginX = tl.minX - detail.minX let marginY = tl.minY - detail.minY - XCTAssertGreaterThan(marginX, 2, "top-left panel should inset a cell off the left, not sit flush (marginX=\(marginX))") - XCTAssertGreaterThan(marginY, 4, "top-left panel should inset a cell off the top, not sit flush (marginY=\(marginY))") - XCTAssertLessThan(marginX, 30, "the left margin should be ~1 cell, not a centered half-slack offset (marginX=\(marginX))") - XCTAssertLessThan(marginY, 45, "the top margin should be ~1 cell, not a centered half-slack offset (marginY=\(marginY))") - XCTAssertGreaterThan(marginY, marginX, "the vertical margin (cell height) should exceed the horizontal one (cell width)") + XCTAssertGreaterThan(marginX, 2, "top-left panel should inset a line-height off the left, not sit flush (marginX=\(marginX))") + XCTAssertGreaterThan(marginY, 4, "top-left panel should inset a line-height off the top, not sit flush (marginY=\(marginY))") + XCTAssertLessThan(marginX, 30, "the left margin should be ~1 line-height, not a centered half-slack offset (marginX=\(marginX))") + XCTAssertLessThan(marginY, 45, "the top margin should be ~1 line-height, not a centered half-slack offset (marginY=\(marginY))") + XCTAssertEqual(marginX, marginY, accuracy: 3, + "the horizontal and vertical margins should be ~equal (both one line-height): marginX=\(marginX) marginY=\(marginY)") // re-anchor to the opposite corner: the panel must move right AND down, and now insets a cell off the // pane's RIGHT and BOTTOM edges by the same ~1-cell margin (mirrored to the anchored side). diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md index 4656d503..b3340990 100644 --- a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md +++ b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md @@ -202,12 +202,14 @@ maintainer decisions are folded in below: `overlayPanel` sizes via `OverlayLayout.panelSize(session.overlaySize, pane: geo.size, cell: session.overlayCellMetrics)` and positions via `ZStack(alignment: floating ? anchor.swiftUIAlignment : .center)`; a stable accessibility id on the floating panel enables the e2e frame assertion. -- **1-cell anchor margin (maintainer feedback)**: an edge/corner-anchored floating panel is NOT flush with - the pane border — it sits one cell off each anchored side (a corner insets both, an edge one, `center` - none). The margin is host-free in `OverlayLayout.anchorInsets(_:panel:pane:cell:) -> OverlayInsets` - (leading/top/trailing/bottom in points): the anchored horizontal side gets `cellWidth`, the anchored - vertical side `cellHeight`, each capped at the axis slack (`min(oneCell, pane − panel)`) so a near-full - panel never overflows, and nil/unusable metrics → `.zero`. `overlayPanel` maps it to a single +- **Anchor margin (maintainer feedback)**: an edge/corner-anchored floating panel is NOT flush with + the pane border — it sits one line-height off each anchored side (a corner insets both, an edge one, + `center` none). The margin is host-free in `OverlayLayout.anchorInsets(_:panel:pane:cell:) -> OverlayInsets` + (leading/top/trailing/bottom in points): BOTH the anchored horizontal side AND the anchored vertical side + inset by one line-height (`cellHeight`) so the horizontal and vertical gaps are visually equal (a terminal + cell is ~2x taller than wide, so using `cellWidth` for the horizontal side would read as about half the + vertical gap), each capped at the axis slack (`min(oneCell, pane − panel)`) so a near-full panel never + overflows, and nil/unusable metrics → `.zero`. `overlayPanel` maps it to a single `padding(EdgeInsets)` on the panel (a values-only modifier — no anchor-specific view branch — so the ZStack child count stays constant per the NSSplitView-overrun invariant), applied AFTER the a11y marker so the marker still reports the panel's own frame. Applies to ANY floating overlay (percent or cells). @@ -429,17 +431,19 @@ moving the id onto a zero-content `.overlay(Color.clear …)` marker sized to th pattern). As an `.overlay` modifier it adds NO ZStack child, so the constant-child-count NSSplitView-overrun invariant is preserved. All 5 e2e methods pass after the fix. -➕ **Deviation (maintainer feedback — 1-cell anchor margin):** an edge/corner-anchored floating panel used +➕ **Deviation (maintainer feedback — anchor margin):** an edge/corner-anchored floating panel used to sit FLUSH against the pane border, which looked bad. Per maintainer feedback the panel now insets one -cell off each anchored side (a corner insets both, an edge one, `center` none). Added the host-free +line-height off each anchored side (a corner insets both, an edge one, `center` none). Added the host-free `OverlayLayout.anchorInsets(_:panel:pane:cell:) -> OverlayInsets` (leading/top/trailing/bottom in points; -anchored horizontal side = `cellWidth`, vertical = `cellHeight`, each capped at the axis slack, nil/unusable -metrics → `.zero`) with `OverlayLayoutTests` (corner/edge/center/slack-cap/nil cases); `overlayPanel` maps -it to a single `padding(EdgeInsets)` on the panel — a values-only modifier (no anchor branch), applied AFTER -the a11y marker so the marker keeps reporting the panel's own frame, preserving the NSSplitView-overrun -invariant. The frame e2e was rewritten to assert the ~1-cell margin (inset from a full-overlay detail-area -reference), discriminating against both flush (margin 0) and centered (large half-slack) placement. Applies -to ANY floating overlay (percent or cells). All 5 e2e methods still pass. +BOTH anchored sides = one line-height (`cellHeight`) so horizontal and vertical gaps are visually equal — a +cell is ~2x taller than wide, so `cellWidth` on the horizontal side would read as about half the vertical +gap — each capped at the axis slack, nil/unusable metrics → `.zero`) with `OverlayLayoutTests` +(corner/edge/center/slack-cap/nil cases); `overlayPanel` maps it to a single `padding(EdgeInsets)` on the +panel — a values-only modifier (no anchor branch), applied AFTER the a11y marker so the marker keeps +reporting the panel's own frame, preserving the NSSplitView-overrun invariant. The frame e2e asserts the +~1-line-height margin (inset from a full-overlay detail-area reference) and that the horizontal and vertical +margins are approximately equal, discriminating against both flush (margin 0) and centered (large half-slack) +placement. Applies to ANY floating overlay (percent or cells). All 5 e2e methods still pass. ### Task 8: Keep-in-sync documentation surfaces From b1f385cca1cf373c1b6b7334723a656bc11f6ab7 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 01:58:06 -0500 Subject: [PATCH 11/20] docs: document overlay cols/rows sizing and anchor across surfaces --- .claude/rules/control-api.md | 132 ++++++++++++------ README.md | 6 +- agterm/Resources/agent-skill/SKILL.md | 24 +++- agterm/Resources/agent-skill/examples.md | 19 +++ agterm/Resources/agent-skill/reference.md | 50 +++++-- ...20260722-overlay-anchor-and-cell-sizing.md | 21 +-- site/commands.html | 52 +++++-- site/docs.html | 33 +++-- 8 files changed, 241 insertions(+), 96 deletions(-) diff --git a/.claude/rules/control-api.md b/.claude/rules/control-api.md index 7c671287..bf44514b 100644 --- a/.claude/rules/control-api.md +++ b/.claude/rules/control-api.md @@ -69,7 +69,8 @@ paths: `session.status`/`status`+`statusPane` (+`statusBlink`/`statusColor` for `--blink`/`--color`), `session.flag`/`flagged`, `session.focus`/`splitFocused`, `session.resize`/`splitRatio`, `session.restore`/`restoreCommand`+`splitRestoreCommand`, - `session.overlay.resize`/`overlaySizePercent`, `sidebar`/`sidebarVisible` (top-level), + `session.overlay.open`+`session.overlay.resize`/`overlaySizePercent`+`overlayCols`/`overlayRows` (requested) + +`overlayColsApplied`/`overlayRowsApplied` (realized)+`overlayAnchor`, `sidebar`/`sidebarVisible` (top-level), `sidebar.mode`/`sidebarMode`, `workspace.focus`/`focused` (workspace node), `workspace.collapse`+`workspace.expand`/`collapsed` (workspace node), `quick`/`quickVisible` (top-level), `font.*`/`fontSize`+`splitFontSize`+`scratchFontSize` (the per-pane LIVE font size — the split/scratch @@ -674,8 +675,24 @@ paths: in `ControlAPIUITests`. `session.overlay.open`/`session.overlay.close` run an ephemeral terminal on top of a session executing one program (`args.command`, e.g. a TUI); by default it is full single-pane size, - hiding the single/split underneath, but `args.sizePercent` (1–100, clamped in `openOverlay`) makes - it a *floating* opaque framed panel at that percent of the pane with the session still visible. + hiding the single/split underneath, but a *floating* opaque framed panel with the session still visible is + requested one of two ways. + `args.sizePercent` (1–100) sizes it to that percent of the pane in both dimensions; + `args.cols`+`args.rows` (both required, each >= 1) size it to an EXACT terminal grid via the live cell + metrics. + Both are ADAPTIVE: a percent or cols/rows request larger than the pane is clamped to the whole cells that + fit (the host-free `OverlayLayout.panelSize` in `agtermCore`), and the realized grid rides back on `tree` + as `overlayColsApplied`/`overlayRowsApplied` (below), so a script sees any clamp. + Open no longer silently clamps an out-of-range `sizePercent` — Decision 3 tightened it to a HARD error in + the dispatcher + CLI `validate()`, matching resize; the store keeps its defensive clamp for internal + callers only. + `args.anchor` places a floating panel at one of nine `OverlayAnchor` positions + (`top-left`/`top`/`top-right`/`left`/`center`/`right`/`bottom-left`/`bottom`/`bottom-right`, default + `center` = today's centered placement); it requires a floating size, so an anchor on a full-pane open is a + dispatcher error. + An edge/corner-anchored floating panel is NOT flush with the pane border: it insets one line-height + (`cell.cellHeight`) off EACH anchored side (a corner insets both, an edge one, `center` none), host-free in + `OverlayLayout.anchorInsets`, each inset capped at the axis slack so a near-full panel never overflows. `args.color` (`#rrggbb`, REUSING the `session.background` field — no new arg — validated by the shared `WatermarkConfig.isValidColorHex` at BOTH the CLI `validate()` and the server arm) gives the overlay pane its OWN solid background color, independent of the session's `session.background color`; @@ -684,7 +701,7 @@ paths: honoring window translucency), built in `GhosttySurfaceView.applyOverlayBackgroundColor` from the view's `overlayBackgroundColorHex` in `createSurface` — works identically for the full + floating variants. `AppStore.openOverlay`/`closeOverlay` set non-persisted `Session.overlay*` state (incl. - `overlaySizePercent`, nil = full / non-nil = floating; and `overlayBackgroundColor`, + `overlaySize`: `.full` = full / `.percent`|`.cells` = floating; `overlayAnchor`; and `overlayBackgroundColor`, set at open / cleared at close), and the surface runs `config.command` with `onExit → closeOverlay`. Both variants render IN the per-session eager deck, so the overlay program runs regardless of which @@ -699,17 +716,20 @@ paths: keeps the AppKit `NSSplitView` from re-hosting and overrunning UP into the transparent titlebar. (The panel used to mount OUTSIDE `sessionDetail` as a `detailPane` `.overlay` for exactly this reason; the always-present constant-shape sibling holds the same invariant IN-deck.) - FULL (`overlaySizePercent` nil): fraction 1.0, drawn translucent + blurred with NO opaque backing and NO + FULL (`overlaySize == .full`): the whole pane, drawn translucent + blurred with NO opaque backing and NO chrome (`Color.clear` backing, 0 corner radius, 0 shadow), and the pane(s) behind hidden at `.opacity(0)` + `.allowsHitTesting(false)` via `hideForOverlay` (= `fullOverlay || scratchActive`; kept MOUNTED, shells alive like the deck's inactive sessions), so its transparency reveals the window backing (desktop, tint + blur), not the session. - FLOATING (`overlaySizePercent` set): fraction = `percent/100`, drawn as an opaque `terminalColor`-backed, - hairline-framed, shadowed panel centered in the detail area with the pane(s) VISIBLE around it. - The modifier CHAIN is IDENTICAL across both variants — only the parameter VALUES flip (backing color, - corner radius, shadow radius, frame fraction) — so `session.overlay.resize` switching full<->% is a - value-update, never a child add/remove or a re-parent of the overlay surface NSView (a re-parent would - blank its Metal drawable). + FLOATING (`overlaySize != .full`, i.e. `.percent` or `.cells`): sized by `OverlayLayout.panelSize` (the + percent fraction or the whole-cell-clamped grid), drawn as an opaque `terminalColor`-backed, hairline-framed, + shadowed panel PLACED by its `overlayAnchor` — `ZStack(alignment: anchor.swiftUIAlignment)` plus the + `OverlayLayout.anchorInsets` line-height margin — with the pane(s) VISIBLE around it (`center` reproduces the + original dead-center placement). + The modifier CHAIN is IDENTICAL across both variants and every anchor — only the parameter VALUES flip + (backing color, corner radius, shadow radius, frame size, ZStack alignment, padding insets) — so + `session.overlay.resize` switching full<->floating or re-anchoring is a value-update, never a child + add/remove or a re-parent of the overlay surface NSView (a re-parent would blank its Metal drawable). Hit-testing on the PANES stays gated on `.allowsHitTesting(!hideForOverlay)` and must NOT flip when a FLOATING overlay opens: changing the panes' OWN `allowsHitTesting` on overlay-open (e.g. to `!session.overlayActive`) ALSO triggers the NSSplitView titlebar-overrun — the SAME class of perturbation @@ -727,8 +747,8 @@ paths: transparent titlebar, painting the split over the header (Codex-confirmed; the quick terminal renders at this level for the same reason and never hit it). `overlayPanel`'s `GeometryReader` reports the detail area EXACTLY — no manual sidebar/titlebar insets - (computing those at the window level mis-centered the panel one line low) — so it sizes the floating panel - to `sizePercent`% and centers it in the detail area, the pane(s) visible around it. + (computing those at the window level mis-placed the panel one line off) — so it sizes the floating panel via + `OverlayLayout.panelSize` and places it by its anchor in the detail area, the pane(s) visible around it. `isActive` gates the overlay surface's focus, so a background floating overlay RUNS but does not steal focus (mirrors the full overlay). Because both kinds mount in the eager deck, `ControlServer` does NOT select on open by default; it SELECTS @@ -775,31 +795,48 @@ paths: at parse via `validate()`); the program's OUTPUT is its own concern — a TUI like revdiff renders in the overlay and writes results to its own `--output` file, which the caller reads (the control channel does NOT capture stdout). - `session.overlay.resize` (target = session) resizes an ALREADY-OPEN overlay IN PLACE between full and - floating — the way to change size without closing and re-running the program. - Exactly one of `sizePercent` (1...100 → floating) or `full: true` (→ the full-pane overlay) must be set; - both, neither, or a percent outside 1...100 is a dispatcher error (mirrored by the CLI `validate()`), and - `no overlay` when none is open. + `session.overlay.resize` (target = session) resizes AND/OR re-anchors an ALREADY-OPEN overlay IN PLACE — + the way to change size or position without closing and re-running the program. + It takes at most one size mode — `sizePercent` (1...100 → floating), `cols`+`rows` (→ an exact grid, clamped + to fit), or `full: true` (→ the full-pane overlay) — and/or an `anchor`: a nil size keeps the current size + and a nil anchor keeps the current anchor, so `--anchor` alone re-anchors in place while a size mode alone + keeps the anchor. + At least one of {a size mode, `anchor`} must be set (a bare resize is a dispatcher error); more than one + size mode, a percent outside 1...100, or `cols` without `rows` (or vice versa) is a dispatcher error + (mirrored by the CLI `validate()`); `full` ⊥ `anchor` (a full overlay is not anchored); and `no overlay` + when none is open. It is a NEW `Command` case (unlike the `--follow` arg, which rode the existing `overlay.open`) because it - needs its own arg validation, and `full` is a NEW `ControlArgs` field added to distinguish "switch to - full" (nil `overlaySizePercent`) from "unset" on the wire. - The arm mutates the non-persisted `Session.overlaySizePercent` via `AppStore.resizeOverlay` (clamping - 1...100, guarding `overlayActive`), and the detail pane re-flows the SAME surface host: the unified - `WindowContentView.overlayPanel` now renders BOTH variants (full = translucent, no chrome, panes hidden by - `hideForOverlay`; floating = opaque framed panel over visible panes), so a full<->% switch never re-parents - the overlay NSView (which would blank its Metal drawable) nor changes the ZStack shape — the old - `if fullOverlay` z2 sibling is gone, and the always-present `overlayPanel` at z3 is the single host. - Four-point keep-in-sync audit for `session.overlay.resize`: (1) `case sessionOverlayResize = "session.overlay.resize"` - + `ControlArgs.full` in `ControlProtocol.swift`, (2) the `.sessionOverlayResize` dispatcher arm (exactly-one - + range validation) → the app-side `resizeSessionOverlay` (→ `AppStore.resizeOverlay`) behind `ControlActions`, - (3) the `session overlay resize --size-percent|--full` subcommand (`Resize`, `validate()`-guarded) in - `agtermctlKit`, (4) round-trip in `ControlProtocolTests` + dispatcher routing/validation in `ControlDispatcherTests` - + `AppStorePaneTests` (resize clamp/switch/no-overlay) + CLI mapping in `CommandsTests` + the e2e - `testOverlayResizeSwitchesFloatingAndFull` in `ControlOverlaySplitUITests`. - The READ side is `ControlSessionNode.overlaySizePercent` on each `tree` node (see the `tree` read-side - fields below) — populated in `AppStore.controlTree`, round-tripped by `treeSessionNodeRoundTripsWithOverlaySizePercent`/`…OmitsOverlaySizePercentWhenNil` - and `AppStorePaneTests.controlTreeReportsOverlaySizePercent`, and mirrored in the agent-skill `reference.md` - tree schema — so a script can record an overlay's size before zooming to `--full` and restore it exactly. + needs its own arg validation, and `full` is a distinct `ControlArgs` field so the wire can tell "switch to + full" apart from "keep the current size". + The arm calls `AppStore.resizeOverlay(_:size:anchor:)` (mutating the non-persisted `Session.overlaySize`/ + `overlayAnchor`, retaining a defensive percent clamp, guarding `overlayActive`), and the detail pane + re-flows the SAME surface host: the unified `WindowContentView.overlayPanel` renders BOTH variants and every + anchor (full = translucent, no chrome, panes hidden by `hideForOverlay`; floating = opaque framed panel over + visible panes, placed by its anchor), so a full<->floating switch or a re-anchor never re-parents the overlay + NSView (which would blank its Metal drawable) nor changes the ZStack shape — the old `if fullOverlay` z2 + sibling is gone, and the always-present `overlayPanel` at z3 is the single host. + A `--full` round-trip PRESERVES the anchor (Decision 2 — only `closeOverlay` resets it to `.center`), so a + later re-float returns to where it was. + Keep-in-sync for `session.overlay.resize`: (1) `case sessionOverlayResize = "session.overlay.resize"` + + `ControlArgs.full`/`cols`/`rows`/`anchor` in `ControlProtocol.swift`, (2) the `.sessionOverlayResize` + dispatcher arm (one-of + range + cols·rows pairing + `full`⊥`anchor` + at-least-one validation) → the + app-side `resizeSessionOverlay(…size:anchor:)` (→ `AppStore.resizeOverlay`) behind `ControlActions`, (3) the + `session overlay resize --size-percent|--cols/--rows|--full|--anchor` subcommand (`Resize`, + `validate()`-guarded) in `agtermctlKit`, (4) round-trip in `ControlProtocolTests` + dispatcher + routing/validation in `ControlDispatcherTests` + `AppStorePaneTests` (resize + clamp/switch/re-anchor/no-overlay) + CLI mapping in `CommandsTests` + the e2e + (`testOverlayResizeSwitchesFloatingAndFull` plus the cols/rows + anchor cases) in `ControlOverlaySplitUITests`. + The READ side is on each `tree` node (see the `tree` read-side fields below), populated in + `AppStore.controlTree`: `overlaySizePercent` (a `.percent` overlay's percent), + `overlayCols`/`overlayRows` (a `.cells` overlay's REQUESTED grid), + `overlayColsApplied`/`overlayRowsApplied` (the REALIZED grid any floating overlay rendered after clamping — + exposes a clamp/drift), and `overlayAnchor` (any open overlay's anchor, incl. full). + Round-tripped by `treeSessionNodeRoundTripsWithOverlaySizePercent`/`…OmitsOverlaySizePercentWhenNil`, + `treeSessionNodeRoundTripsWithOverlayCellsAppliedAndAnchor`/`…WithOverlayPercentAppliedAndAnchor`, + `treeSessionNodeKeepsAnchorButOmitsGridForFullOverlay`/`…OmitsOverlayGridAndAnchorWhenNoOverlay`, and the + `AppStorePaneTests` populate tests (`controlTreeReportsOverlaySizePercent`/`…OverlayColsRowsAndAnchor`); + mirrored in the agent-skill `reference.md` tree schema — so a script can record an overlay's exact size AND + position before zooming to `--full` and restore both. `surface.zoom` (mode `show`|`hide`|`toggle`) fills the target window with ONE terminal surface, hiding the sidebar and collapsing the title bar to a slim strip (traffic lights + an exit button; the zoomed terminal is inset below `titlebarHeight`, NOT borderless) — the control half of @@ -1266,12 +1303,21 @@ paths: is each pane running". It ALSO surfaces `background` on each node — the `BackgroundWatermark` spec set via `session.background` (omitted when none), the read side of set/clear so a script can query the current watermark. - It ALSO surfaces `overlaySizePercent` on each node — an OPEN overlay's size (`session.overlayActive ? session.overlaySizePercent : nil` - in the tree builder): nil/omitted = the full-pane overlay OR no overlay (so gate on `overlay` first), - else the floating panel's percent (1...100). - It is the READ side of `session.overlay.resize` (which had only the write side), so a tmux-style zoom - script can record the current size before switching to `--full` and restore the EXACT original on un-zoom - (not a guessed default). + It ALSO surfaces the OPEN overlay's size + position on each node, all gated on `overlay` first — the READ + side of `session.overlay.open`/`.resize`, so a tmux-style zoom script can record the exact geometry before + switching to `--full` and restore the ORIGINAL on un-zoom (not a guessed default): + `overlaySizePercent` — a `.percent` overlay's floating-panel percent (1...100); omitted for cells, full, or + no overlay. + `overlayCols`/`overlayRows` — a `.cells` overlay's REQUESTED grid (the restore key); omitted otherwise. + `overlayColsApplied`/`overlayRowsApplied` — the REALIZED grid any floating overlay actually rendered after + the whole-cell clamp (`session.floatingOverlayActive ? session.overlayAppliedCols/Rows : nil`, the app + writing the realized grid into observed `Session` state on surface realization / `CELL_SIZE` / backing-scale + change); omitted for a full overlay or none. + Distinct from the requested `overlayCols`/`overlayRows`, so an oversized request comes back smaller here and + a script can detect the clamp. + `overlayAnchor` — the anchor rawValue of ANY open overlay including a full one + (`session.overlayActive ? session.overlayAnchor.rawValue : nil`), since the anchor is preserved across a + `--full` round-trip (Decision 2); omitted when no overlay is open. It ALSO surfaces `splitRatio` on each node — the left-pane divider fraction of a session that HAS a split (`session.hasSplit ? session.splitRatio : nil` in the tree builder, so shown OR hidden splits report it), nil/omitted when there is no split or the ratio was never explicitly set (divider then at the default 0.5). diff --git a/README.md b/README.md index c69b3f67..ad458a8e 100644 --- a/README.md +++ b/README.md @@ -288,20 +288,22 @@ These are also the Edit menu's Copy/Paste/Select All (⌘C/⌘V/⌘A), which act agtermctl session overlay open "revdiff HEAD~3" --target 9f3c # review the last 3 commits over session 9f3c agtermctl session overlay open "htop" # on the active session agtermctl session overlay open "htop" --size-percent 70 # a floating, framed panel at 70% of the pane +agtermctl session overlay open "htop" --cols 80 --rows 24 --anchor bottom-right # an exact 80x24 grid, parked bottom-right agtermctl session overlay open "revdiff HEAD~3" --size-percent 80 --background-color "#2a1a3a" # tint the overlay pane agtermctl session overlay open "revdiff HEAD~3" --target 9f3c --follow # switch the user to session 9f3c as the overlay opens agtermctl session overlay open "make test" --wait # keep the overlay open after exit (press a key to close) agtermctl session overlay open "make test" --block # block until it exits; exit with its status agtermctl session overlay resize --size-percent 60 --target 9f3c # resize an open overlay to a floating 60% panel +agtermctl session overlay resize --anchor top-left --target 9f3c # re-anchor the floating panel in place (same size) agtermctl session overlay resize --full --target 9f3c # switch it back to the full-pane overlay agtermctl session overlay close --target 9f3c # close it from a script ``` -By default an overlay opens on its `--target` without switching the active session — full and floating both run their program in the background and appear when the user visits that session; pass `--follow` to select the target as the overlay opens (a no-op if it is already active). `session overlay resize` changes an already-open overlay in place — `--size-percent N` (1–100) makes it a floating panel, `--full` switches it back to full size — and the program keeps running across the change. By default it closes the instant the program exits; `--wait` keeps it on a "press any key to close" prompt so you can read the program's final output. A `*` `(overlay)` tag in `agtermctl tree` marks a session whose overlay is open. +By default an overlay opens on its `--target` without switching the active session — full and floating both run their program in the background and appear when the user visits that session; pass `--follow` to select the target as the overlay opens (a no-op if it is already active). `session overlay resize` changes an already-open overlay in place — a size mode (`--size-percent N`, `--cols N --rows M`, or `--full`) and/or `--anchor` — and the program keeps running across the change. By default it closes the instant the program exits; `--wait` keeps it on a "press any key to close" prompt so you can read the program's final output. A `*` `(overlay)` tag in `agtermctl tree` marks a session whose overlay is open. `--block` runs the program in the overlay (rendering normally) and blocks until it exits, then exits with the program's status — useful in a script that needs the outcome of an interactive run. The program's output stays its own concern: a TUI writes its result to its own file (for example `revdiff --output=…`) which the script reads, while `--block` reports only the exit status (the overlay never captures stdout). `--block` can't be combined with `--wait`; `session overlay result` reports the last overlay's exit status on demand for a manual open → poll flow. -By default the overlay fills the pane, drawn translucent, hiding the session beneath it. Pass `--size-percent N` (1–100) for a *floating* variant instead: an opaque, framed panel sized to N% of the pane in both dimensions and centered in it, with the session still visible around it. Useful for a small auxiliary program (a picker, a monitor) that you want floating over — not replacing — the terminal you're working in. It composes with `--block` (a blocking floating overlay). Like a full overlay it opens in the background and runs even when the target is not active; pass `--follow` to switch the user to the target as it opens. +By default the overlay fills the pane, drawn translucent, hiding the session beneath it. For a *floating* variant instead — an opaque, framed panel with the session still visible around it — size it one of two ways: `--size-percent N` (1–100) sizes it to N% of the pane in both dimensions, or `--cols N --rows M` sizes it to an exact terminal grid. Both are adaptive: a request larger than the pane is clamped to the whole cells that fit, and the grid that actually rendered is read back on `agtermctl tree` (as `overlayColsApplied`/`overlayRowsApplied`). `--anchor POS` places a floating panel at one of nine positions — `top-left`, `top`, `top-right`, `left`, `center` (default), `right`, `bottom-left`, `bottom`, `bottom-right` — inset one line-height off each anchored edge so it is not flush with the border; `center` keeps the original dead-center placement. A floating overlay is useful for a small auxiliary program (a picker, a monitor) that you want floating over — not replacing — the terminal you're working in. It composes with `--block` (a blocking floating overlay). Like a full overlay it opens in the background and runs even when the target is not active; pass `--follow` to switch the user to the target as it opens. `session overlay resize` re-anchors or re-sizes it in place: `--anchor` alone moves the panel keeping its size, `--full` reverts to full-pane (which preserves the anchor for a later re-float, and cannot be combined with `--anchor`), and an out-of-range `--size-percent` is a hard error on both open and resize. A session's terminal surface is created lazily — it does not exist until the session has been shown at least once. Injecting text into a never-shown session therefore fails with `session not realized` unless you pass `--select`, which selects the session (realizing its surface) before injecting: diff --git a/agterm/Resources/agent-skill/SKILL.md b/agterm/Resources/agent-skill/SKILL.md index d884b22d..9a2182c4 100644 --- a/agterm/Resources/agent-skill/SKILL.md +++ b/agterm/Resources/agent-skill/SKILL.md @@ -160,9 +160,13 @@ spec — image/text watermark or solid color — set via `session background`, o `unseen` (the unseen-notification badge count — raised by `notify`/OSC 9/777, cleared by `session seen` — omitted when zero), `commandWait` (whether a `--command` session was created with `--wait` to hold open after the command exits — the read side of `session new --wait`, omitted for a plain or -non-holding session), `overlaySizePercent` (an open overlay's floating-panel percent 1–100, -omitted for a full-pane overlay or no overlay so gate on `overlay` first; the read side of `overlay -resize` for a record-then-restore zoom), `splitRatio` (the left-pane divider fraction 0.05–0.95 of a +non-holding session), `overlaySizePercent` (a `--size-percent` overlay's floating-panel percent 1–100, +omitted for a cells-mode, full-pane, or absent overlay so gate on `overlay` first; the read side of +`overlay resize` for a record-then-restore zoom), `overlayCols`/`overlayRows` (a cells-mode overlay's +REQUESTED grid), `overlayColsApplied`/`overlayRowsApplied` (the REALIZED grid any floating overlay rendered +after clamping — compare to the requested grid to detect a clamp), `overlayAnchor` (which of the nine +positions any open overlay anchors to, reported even for a full overlay — the anchor survives `--full`), +`splitRatio` (the left-pane divider fraction 0.05–0.95 of a session that has a split — shown or hidden; omitted when there's no split or the ratio was never set (at the default 0.5) — the read side of `session resize`, record it to restore the exact divider), `splitFocused` @@ -275,12 +279,18 @@ omitted when expanded). terminal background color. Per session; survives restart. `--opacity` 0.0–1.0. (An image/text watermark renders the pane opaque, overriding window translucency, so it shows; a `color` takes no opacity and honors the Settings window translucency instead.) -- `overlay open [--cwd DIR] [--wait] [--block] [--size-percent N] [--background-color #rrggbb] [--follow]` · - `overlay resize (--size-percent N | --full)` · +- `overlay open [--cwd DIR] [--wait] [--block] [--size-percent N | --cols N --rows M] [--anchor POS] [--background-color #rrggbb] [--follow]` · + `overlay resize [--size-percent N | --cols N --rows M | --full] [--anchor POS]` · `overlay close` · `overlay result` — run a program on top of a session; `--block` waits and exits with its status. - `overlay resize` changes an ALREADY-OPEN overlay: `--size-percent N` (1-100) makes it a floating panel, - `--full` switches it back to the full-pane overlay; the program keeps running (no re-spawn). + Full by default; make it a floating panel with `--size-percent N` (1–100, hard error out of range) or + `--cols N --rows M` (exact grid). Both clamp to the whole cells that fit; the realized grid reads back as + `overlayColsApplied`/`overlayRowsApplied`. `--anchor POS` parks a floating panel at one of nine positions + (`top-left` … `center` (default) … `bottom-right`), inset one line-height off each anchored edge; it needs + a floating size. `overlay resize` changes an ALREADY-OPEN overlay in place (the program keeps running, no + re-spawn): pass a size mode (`--size-percent`/`--cols`+`--rows`/`--full`) and/or `--anchor` (at least one); + `--anchor` alone re-anchors keeping the size, `--full` reverts to full-pane but preserves the anchor and + cannot be combined with `--anchor`. Target with `--target "$AGTERM_SESSION_ID"` for YOUR session (default `active` is the user's selection). **By default `overlay open` does NOT switch the user** — full and floating (`--size-percent`) both open on `--target` and run their program in the background; the panel appears when the user visits that diff --git a/agterm/Resources/agent-skill/examples.md b/agterm/Resources/agent-skill/examples.md index 28dc8c99..8960ed48 100644 --- a/agterm/Resources/agent-skill/examples.md +++ b/agterm/Resources/agent-skill/examples.md @@ -226,6 +226,25 @@ agtermctl session overlay resize --full --target "$AGTERM_SESSION_ID" agtermctl session overlay close ``` +Exact terminal-grid sizing + a corner anchor (instead of a percent): size the panel to a known TUI layout +and park it out of the way. A cols/rows request larger than the pane clamps to the whole cells that fit — +read the realized grid back off `tree`: + +```bash +# an 80x24 panel parked in the bottom-right corner (one line-height off each anchored edge, not flush) +agtermctl session overlay open "zsh -lc 'htop'" --target "$AGTERM_SESSION_ID" --cols 80 --rows 24 --anchor bottom-right +# what actually rendered (requested vs clamped): overlayCols/Rows = requested, overlayColsApplied/RowsApplied = realized +agtermctl tree --json | jq '.. | objects | select(.overlay == true) | {overlayCols, overlayRows, overlayColsApplied, overlayRowsApplied, overlayAnchor}' +``` + +Re-anchor an open floating panel in place — no size change, program keeps running (`--anchor` alone keeps +the current size; a size mode alone keeps the current anchor): + +```bash +agtermctl session overlay resize --anchor top-left --target "$AGTERM_SESSION_ID" # move it to the top-left, same size +agtermctl session overlay resize --anchor center --target "$AGTERM_SESSION_ID" # back to centered +``` + Manual open + poll for status instead of `--block`: ```bash diff --git a/agterm/Resources/agent-skill/reference.md b/agterm/Resources/agent-skill/reference.md index bede900f..65d7266d 100644 --- a/agterm/Resources/agent-skill/reference.md +++ b/agterm/Resources/agent-skill/reference.md @@ -100,10 +100,20 @@ to restore focus via `session focus --pane left|right`), `commandWait` (whether a `--command` session was created with `--wait` to hold open after the command exits — the read side of `session new --wait`; omitted for a plain or non-holding session), `overlay` (overlay shown), -`overlaySizePercent` (an open overlay's size — the -floating panel's percent of the pane, 1–100; omitted = a full-pane overlay or no overlay, so gate on -`overlay` first; the read side of `session overlay resize`, e.g. record it before switching to `--full` -to restore the exact size), `scratch` (scratch shown), `flagged` (in the +`overlaySizePercent` (an open PERCENT-mode overlay's size — the +floating panel's percent of the pane, 1–100; omitted for a cells-mode overlay, a full-pane overlay, or no +overlay, so gate on `overlay` first; the read side of a `--size-percent` overlay, e.g. record it before +switching to `--full` to restore the exact size), +`overlayCols`/`overlayRows` (the REQUESTED grid of a cells-mode overlay opened/resized with +`--cols N --rows M`; omitted for a percent, full, or absent overlay — the restore key for a cells overlay), +`overlayColsApplied`/`overlayRowsApplied` (the REALIZED grid an open floating overlay actually rendered +after whole-cell clamping — set for both percent and cells floating overlays, omitted for a full overlay or +none; an oversized `--cols/--rows` request comes back smaller here, so compare against the requested grid to +detect a clamp), +`overlayAnchor` (which of the nine positions an open overlay anchors to — `top-left` … `center` … +`bottom-right`; reported for ANY open overlay including a full one (the anchor is preserved across `--full`), +omitted when no overlay is open — the read side of the overlay `--anchor`), +`scratch` (scratch shown), `flagged` (in the flagged working-set), `status` (the agent-status — `active`|`completed`|`blocked` — omitted when idle), `statusPane` (which pane set that status — `left` (main) | `right` (split) | `scratch` — the `--pane` value from `session status`, omitted when unset or idle; gated on the same non-idle condition @@ -428,14 +438,23 @@ All ten are read-only projections of GUI state. background-opacity); a `color` instead honors the Settings window translucency. Read the current background back from a session's `background` field in `tree --json` (a `{kind, colorHex, …}` object, omitted when none). -- `session overlay open [--cwd DIR] [--wait] [--block] [--size-percent N] [--background-color #rrggbb] [--follow] [--target] [--window W]` +- `session overlay open [--cwd DIR] [--wait] [--block] [--size-percent N | --cols N --rows M] [--anchor POS] [--background-color #rrggbb] [--follow] [--target] [--window W]` — run `command` in an ephemeral terminal on top of the session; it closes when the command exits. `command` runs through `sh -c` (so shell operators DO work here) but with the app's GUI `PATH` (no `/opt/homebrew/bin`), so a bare Homebrew or other non-default binary fails with exit 127 — the overlay flashes open then vanishes and `overlay result` reports 127; give an absolute path or wrap in `"zsh -lc '…'"`. - Full-size by default (hides the session); `--size-percent N` (1–100) makes it a floating framed panel - with the session visible behind. **By default the overlay does NOT switch the active session** — full + Full-size by default (hides the session). Give it a *floating* framed panel — the session still visible + behind it — one of two ways: `--size-percent N` sizes it to N% of the pane in both dimensions, or + `--cols N --rows M` sizes it to an exact terminal grid (both required, each ≥ 1). Both are ADAPTIVE: a + request larger than the pane is clamped down to the whole cells that fit, and the grid that actually + rendered is read back on `tree` as `overlayColsApplied`/`overlayRowsApplied`. `--size-percent` must be + 1–100 — an out-of-range value is a hard error (it is no longer silently clamped on open). + `--anchor POS` places a floating panel at one of nine positions — `top-left`, `top`, `top-right`, `left`, + `center` (default), `right`, `bottom-left`, `bottom`, `bottom-right`. An edge/corner-anchored panel sits + one line-height off each side it anchors to (so it is not flush with the border); `center` is unchanged. + `--anchor` requires a floating size (`--size-percent` or `--cols/--rows`) — on a full-pane overlay it is an + error. **By default the overlay does NOT switch the active session** — full and floating both open on `--target` and run their program in the background, appearing when the user visits that session. **Pass `--follow` to select the target after opening** (a no-op if it is already active); use it when you want the user pulled to the overlay, omit it to open quietly. `--background-color #rrggbb` gives the overlay pane its own solid @@ -446,12 +465,17 @@ All ten are read-only projections of GUI state. own output file, not the control channel. Returns the overlay's session id. `--target` defaults to `active`, so an automated caller should pass `--target "$AGTERM_SESSION_ID"` — otherwise a (usually blocking, full-pane) overlay lands on whatever session is currently active, not the calling one. -- `session overlay resize (--size-percent N | --full) [--target] [--window W]` — resize an ALREADY-OPEN - overlay in place. Exactly one of `--size-percent N` (1–100, makes it a floating framed panel) or - `--full` (switches it back to the full-pane overlay that hides the session) is required; passing both - or neither, or a percent outside 1–100, is an error. The overlay program keeps running across the - resize — it is a layout re-flow, never a re-spawn. Errors `no overlay` when none is open. Returns the - session id. +- `session overlay resize [--size-percent N | --cols N --rows M | --full] [--anchor POS] [--target] [--window W]` + — resize and/or re-anchor an ALREADY-OPEN overlay in place. Give at most one size mode — + `--size-percent N` (1–100) or `--cols N --rows M` (exact grid, clamped to fit) for a floating panel, or + `--full` to switch back to the full-pane overlay that hides the session — and/or `--anchor POS` to move a + floating panel to one of the nine positions. At least one of a size mode or `--anchor` is required (a bare + resize with neither is an error); `--anchor` alone re-anchors in place keeping the current size, and a size + mode alone keeps the current anchor. `--full` cannot be combined with `--anchor` (a full overlay is not + anchored), but a `--full` round-trip PRESERVES the anchor, so a later re-float returns to where it was. + Passing more than one size mode, a percent outside 1–100, or `--cols` without `--rows` (or vice versa), is + an error. The overlay program keeps running across the resize — it is a layout re-flow, never a re-spawn. + Errors `no overlay` when none is open. Returns the session id. - `session overlay close [--target] [--window W]` — close (destroy) the overlay. - `session overlay result [--target] [--window W]` — returns `result.exitCode` once the overlay has closed. Errors `still running` while up, `no result` if none ran. diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md index b3340990..b16e0d1e 100644 --- a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md +++ b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md @@ -456,19 +456,24 @@ placement. Applies to ANY floating overlay (percent or cells). All 5 e2e methods - Modify: `README.md` - Modify: `.claude/rules/control-api.md` -- [ ] agent-skill `reference.md`: update the `session overlay open`/`resize` entries with +- [x] agent-skill `reference.md`: update the `session overlay open`/`resize` entries with `--cols/--rows/--anchor`, the tightened open validation, the clamp/adaptive note, and the read-back fields (requested + applied + anchor); command count stays 64 -- [ ] agent-skill `SKILL.md`: update the overlay invocation line + the `overlaySizePercent` schema note +- [x] agent-skill `SKILL.md`: update the overlay invocation line + the `overlaySizePercent` schema note (NOT optional — it carries the overlay schema) -- [ ] agent-skill `examples.md`: add a cols/rows + anchor recipe and a re-anchor-only example -- [ ] `site/commands.html`: update the overlay entries (invocation, args, `tree` read-back fields) -- [ ] `README.md` + `site/docs.html`: update the overlay section (mirror each other) -- [ ] `.claude/rules/control-api.md`: update the overlay command **args** doc (not only the read-back +- [x] agent-skill `examples.md`: add a cols/rows + anchor recipe and a re-anchor-only example +- [x] `site/commands.html`: update the overlay entries (invocation, args, `tree` read-back fields) +- [x] `README.md` + `site/docs.html`: update the overlay section (mirror each other) +- [x] `.claude/rules/control-api.md`: update the overlay command **args** doc (not only the read-back note) — new flags, tightened open validation, requested+applied read-back, anchor-preserved-on-full -- [ ] grep the skill + `control-api.md` for stale percent-only / "centered" / old-state-shape wording and +- [x] grep the skill + `control-api.md` for stale percent-only / "centered" / old-state-shape wording and fix; note in the plan that `site/index.html` is intentionally untouched (non-major, non-release) -- [ ] re-run `make lint` to confirm nothing regressed +- [x] re-run `make lint` to confirm nothing regressed + +> **Note:** `site/index.html` is intentionally left untouched. This change is a per-command / per-arg +> addition (overlay `--cols/--rows/--anchor` + read-back fields), not a major feature or a release; +> `site/index.html` tracks major features and the current release version (`softwareVersion` JSON-LD), so it +> is out of scope for this doc pass. ### Task 9: Verify acceptance criteria - [ ] verify Overview: cols/rows sizing, 9 anchors, size clamp, percent/full unchanged, default center, diff --git a/site/commands.html b/site/commands.html index 0d7a9f84..4c575192 100644 --- a/site/commands.html +++ b/site/commands.html @@ -423,7 +423,12 @@ splitRatio, splitFocused, overlay, - overlaySizePercent, + overlaySizePercent (a percent-mode overlay's floating-panel percent), + overlayCols / + overlayRows (a cells-mode overlay's requested grid), + overlayColsApplied / + overlayRowsApplied (the realized grid any floating overlay rendered after clamping), + overlayAnchor (which of nine positions any open overlay anchors to, reported even for a full overlay), scratch, flagged, status, @@ -1162,19 +1167,32 @@
- agtermctl session overlay open <command> [--cwd DIR] [--wait] [--block] [--size-percent N] - [--background-color #rrggbb] [--follow] [--target T] [--window W] + agtermctl session overlay open <command> [--cwd DIR] [--wait] [--block] [--size-percent N | --cols N --rows M] + [--anchor POS] [--background-color #rrggbb] [--follow] [--target T] [--window W]
session.overlay.open

- Full-size by default, hiding the session; - --size-percent N (1–100) makes it a floating - framed panel with the session visible behind. + Full-size by default, hiding the session. Make it a floating framed panel — the session visible behind — with + --size-percent N (1–100 of the pane) or + --cols N --rows M (an exact terminal grid, both required). + Both are adaptive: a request larger than the pane clamps to the whole cells that fit, and the realized grid reads back on + tree as + overlayColsApplied/overlayRowsApplied. + An out-of-range --size-percent is a hard error (no longer silently clamped on open). Returns the overlay's result.id. --background-color gives the overlay pane its own solid color, independent of the session's.

+

+ --anchor POS parks a floating panel at one of nine positions — + top-left, top, + top-right, left, + center (default), right, + bottom-left, bottom, + bottom-right — inset one line-height off each anchored edge (not flush with the border). + It requires a floating size; on a full-pane overlay it errors. +

It does not switch the active session by default — both variants open on --target and run in the background. Pass @@ -1202,19 +1220,27 @@

- agtermctl session overlay resize (--size-percent N | --full) [--target T] [--window W] + agtermctl session overlay resize [--size-percent N | --cols N --rows M | --full] [--anchor POS] [--target T] [--window W]
session.overlay.resize

- Resize an already-open overlay in place. Exactly one of - --size-percent N (1–100, floating) or - --full is required; both, neither, or an - out-of-range percent errors. The program keeps running across the resize — it is a layout re-flow, never a re-spawn. + Resize and/or re-anchor an already-open overlay in place. Give at most one size mode — + --size-percent N (1–100) or + --cols N --rows M (floating), or + --full — and/or + --anchor POS; at least one is required. + --anchor alone re-anchors keeping the current size; a size mode alone keeps the anchor. + The program keeps running across the resize — it is a layout re-flow, never a re-spawn. Errors no overlay when none is open.

- Record the node's overlaySizePercent first to - restore the exact size after a zoom to --full. + --full cannot be combined with + --anchor, but a + --full round-trip preserves the anchor. Record the node's + overlaySizePercent (or + overlayCols/overlayRows) and + overlayAnchor first to restore the exact size and position after a zoom to + --full.

diff --git a/site/docs.html b/site/docs.html index 7891b309..c50c6c9c 100644 --- a/site/docs.html +++ b/site/docs.html @@ -1306,14 +1306,18 @@ >
agtermctl session overlay open "htop" --size-percent 70
agtermctl session overlay open "revdiff HEAD~3" --size-percent 80 --background-color "#2a1a3a"

# an exact 80x24 grid, parked in the bottom-right corner (clamped to fit)
agtermctl session overlay open "htop" --cols 80 --rows 24 --anchor + bottom-right

# switch the user to the target as the overlay opens
agtermctl session overlay open "revdiff HEAD~3" --size-percent 80 --target 9f3c --follow

# keep it open after exit, or block until it exits and inherit its status
agtermctl session overlay open "make test" --wait
agtermctl session overlay open "make test" --block

# resize an open overlay in place — floating percent, or back to full (the program keeps running)
agtermctl session overlay resize --size-percent 60 --target 9f3c
agtermctl session overlay resize --full + ># resize/re-anchor an open overlay in place — a size mode and/or --anchor (the program keeps running)
agtermctl session overlay resize --size-percent 60 --target 9f3c
agtermctl session overlay resize --anchor + top-left --target 9f3c
agtermctl session overlay resize --full --target 9f3c

agtermctl session overlay close --target 9f3c
agtermctl session overlay result   # last overlay's exit status @@ -1323,17 +1327,26 @@ to close" prompt so you can read the final output; --block reports only the exit status (the overlay never captures stdout — a TUI writes its own result file). By default an overlay opens on its - target without switching the active session (full and --size-percent - floating alike); + target without switching the active session (full and floating alike); --follow switches the user to the target. +

+

+ Size a floating panel with + --size-percent N (1–100 of the pane) or + --cols N --rows M (an exact terminal grid); + both clamp to the whole cells that fit, and the realized grid reads back on + agtermctl tree as + overlayColsApplied/overlayRowsApplied. + --anchor parks it at one of nine positions + (center is the default), inset one line-height off each anchored edge. session overlay resize changes an - open overlay in place — - --size-percent makes it floating, - --full switches it back — without - restarting the program. A + open overlay in place — a size mode + (--size-percent, + --cols/--rows, or + --full) and/or + --anchor — without restarting the program; + --full preserves the anchor for a later re-float. A (overlay) tag in agtermctl tree marks a session whose overlay is open. From d80577e62c9f303e79464bc20d64cec68504b6ec Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 02:05:53 -0500 Subject: [PATCH 12/20] test: verify overlay anchor + cols/rows acceptance criteria --- ...20260722-overlay-anchor-and-cell-sizing.md | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md index b16e0d1e..702e352b 100644 --- a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md +++ b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md @@ -476,14 +476,24 @@ placement. Applies to ANY floating overlay (percent or cells). All 5 e2e methods > is out of scope for this doc pass. ### Task 9: Verify acceptance criteria -- [ ] verify Overview: cols/rows sizing, 9 anchors, size clamp, percent/full unchanged, default center, - requested+applied+anchor read-back, Decision-3 open tightening -- [ ] verify edge cases: cols/rows > pane (clamped, applied reflects it), 1×1, Retina 2× correct, +- [x] verify Overview: cols/rows sizing, 9 anchors, size clamp, percent/full unchanged, default center, + requested+applied+anchor read-back, Decision-3 open tightening — all confirmed in code: + `OverlaySize.cells` + `OverlayLayout.panelSize`/`cellExtent` (clamp); `OverlayAnchor` 9 cases, + `Session.overlayAnchor = .center` default, preserved on `--full` (`resizeOverlay`), reset only in + `closeOverlay`; anchor emitted on tree whenever `overlayActive` (`AppStore+ControlTree`); + Decision-3 percent `1...100` hard-error for open in `parseOverlaySize`/CLI `validateSize` +- [x] verify edge cases: cols/rows > pane (clamped, applied reflects it), 1×1, Retina 2× correct, anchor with no floating size (error), resize anchor-only, `--full` preserves anchor, no new - `Command` case (count 64), no persistence changes -- [ ] run full host-free suite: `cd agtermCore && swift test` -- [ ] run e2e: the `ControlOverlaySplitUITests` overlay cases (incl. `stty size` + frame assertions) -- [ ] `make lint` clean; app builds (`make build`) + `Command` case (count 64), no persistence changes — all confirmed: `cellExtent` floors at 1 cell / + caps at fit / never exceeds pane; Retina px→point via `refreshOverlayMetrics` ÷ `backingScaleFactor`; + `git diff d597d05..HEAD` adds ZERO `Command` cases; `Snapshot.swift` has no overlay fields and was + untouched by the branch +- [x] run full host-free suite: `cd agtermCore && swift test` — 1780 tests in 74 suites passed +- [x] run e2e: the `ControlOverlaySplitUITests` overlay cases (incl. `stty size` + frame assertions) — + 5/5 passed (`testOverlayOpenColsRowsAnchorReadBack`, `testOverlayColsRowsRealizedGridMatchesRequest`, + `testFloatingOverlayPanelFrameFollowsCornerAnchor`, `testOverlayResizeTransitionsCycleWhileSplit`, + `testOverlaySizingAnchorErrorsOverSocket`) +- [x] `make lint` clean; app builds (`make build`) — lint exit 0 with 0 findings; Debug BUILD SUCCEEDED ### Task 10: Final documentation + close-out - [ ] update `CLAUDE.md`/`.claude/rules` only if a genuinely new pattern emerged (e.g. the observed- From 2b12b6247bcad6292e4180699da08db7531b722b Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 02:10:41 -0500 Subject: [PATCH 13/20] docs: record overlay metrics-via-CELL_SIZE pattern and close out plan --- .claude/rules/libghostty.md | 13 +++++++++++++ .../20260722-overlay-anchor-and-cell-sizing.md | 8 +++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.claude/rules/libghostty.md b/.claude/rules/libghostty.md index 5c1fc1a2..b34a2fd3 100644 --- a/.claude/rules/libghostty.md +++ b/.claude/rules/libghostty.md @@ -299,6 +299,19 @@ paths: but is harder: it is NOT fixed by `refresh` or a forced `set_size` jitter, and a font change doesn't resize the view so this `updateMetalLayerSize` path never fires for it — still parked. +- **A live libghostty surface metric drives SwiftUI reactively by MIRRORING it into OBSERVED `Session` + state, never by reading the imperative surface from the view body.** + `Session.overlaySurface` (and `GhosttySurfaceView` generally) is `@ObservationIgnored` and assigned in + `TerminalView.makeNSView`, so a view that read `ghostty_surface_size()` off it would not re-lay-out when + the metric changes. + Instead the app refreshes OBSERVED `Session.overlayCellMetrics` (points, px÷`backingScaleFactor`) and + `overlayAppliedCols/Rows` (the realized grid) from `GHOSTTY_ACTION_CELL_SIZE` (`GhosttyCallbacks.swift`), + surface realization, and `updateMetalLayerSize` (backing-scale change), and `overlayPanel` consumes those + observed values via `OverlayLayout.panelSize` — so a font/DPI change re-sizes the floating panel + reactively with no view-body poll of the imperative NSView. + Generalize this for any "get a live libghostty metric into SwiftUI": convert at the app boundary + (pixels→points), store on observed `Session` state, refresh from the relevant `GHOSTTY_ACTION_*` + callback, and read the observed value from the body. - **Dashboard grid = the N-PANE generalization of terminal-zoom's reparent, focus inverted.** Where `surface.zoom` reparents ONE session surface into the window and focuses it, the dashboard (`DashboardView` + `WindowContentView+Dashboard.swift`, driven by the host-free `DashboardController`) diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md index 702e352b..95e81394 100644 --- a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md +++ b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md @@ -496,9 +496,11 @@ placement. Applies to ANY floating overlay (percent or cells). All 5 e2e methods - [x] `make lint` clean; app builds (`make build`) — lint exit 0 with 0 findings; Debug BUILD SUCCEEDED ### Task 10: Final documentation + close-out -- [ ] update `CLAUDE.md`/`.claude/rules` only if a genuinely new pattern emerged (e.g. the observed- - metrics-from-`CELL_SIZE` bridge, if worth recording) -- [ ] move this plan to `docs/plans/completed/` +- [x] update `CLAUDE.md`/`.claude/rules` only if a genuinely new pattern emerged (e.g. the observed- + metrics-from-`CELL_SIZE` bridge, if worth recording) — added the "live libghostty surface metric + drives SwiftUI reactively by mirroring into observed `Session` state" note to + `.claude/rules/libghostty.md` (near the `updateMetalLayerSize` / rendering-plumbing notes) +- [x] move this plan to `docs/plans/completed/` (performed by exec finalize — kept in place through review phases) ## Post-Completion *Items requiring manual intervention or external systems — no checkboxes, informational only* From b3238345b5715a8076c18fc738f79f1fa5b5a742 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 02:37:40 -0500 Subject: [PATCH 14/20] fix: address code review findings --- .claude/rules/control-api.md | 5 ++ CLAUDE.md | 3 +- README.md | 2 +- agterm/Ghostty/GhosttySurfaceView.swift | 6 +- agterm/Resources/agent-skill/SKILL.md | 3 +- agterm/Resources/agent-skill/reference.md | 5 +- agterm/Views/WindowContentView.swift | 26 ++---- .../Sources/agtermCore/AppStore+Panes.swift | 9 +- .../agtermCore/ControlDispatcher.swift | 83 ++++--------------- .../Sources/agtermCore/ControlProtocol.swift | 5 +- .../Sources/agtermCore/OverlayArgs.swift | 76 +++++++++++++++++ .../agtermctlKit/SessionCommands.swift | 51 ++++-------- .../agtermCoreTests/AppStorePaneTests.swift | 20 +++++ .../agtermCoreTests/OverlayArgsTests.swift | 79 ++++++++++++++++++ .../agtermCoreTests/OverlayLayoutTests.swift | 8 +- .../ControlOverlaySplitUITests.swift | 34 ++++++++ site/commands.html | 2 +- site/docs.html | 2 +- 18 files changed, 284 insertions(+), 135 deletions(-) create mode 100644 agtermCore/Sources/agtermCore/OverlayArgs.swift create mode 100644 agtermCore/Tests/agtermCoreTests/OverlayArgsTests.swift diff --git a/.claude/rules/control-api.md b/.claude/rules/control-api.md index bf44514b..6c4ba1af 100644 --- a/.claude/rules/control-api.md +++ b/.claude/rules/control-api.md @@ -1315,6 +1315,11 @@ paths: change); omitted for a full overlay or none. Distinct from the requested `overlayCols`/`overlayRows`, so an oversized request comes back smaller here and a script can detect the clamp. + The applied grid is EVENTUALLY CONSISTENT: the app refreshes it ASYNC off the realized surface, and a + size-changing `session.overlay.resize` nils it (`resizeOverlay` clears `overlayAppliedCols/Rows` on a size + change) so a read before the refresh returns nil rather than the PRIOR size's grid. + So a script must POLL `tree` for the applied fields after an open/resize (mirroring the e2e's + `pollOverlayApplied`), not read once, before comparing against the request. `overlayAnchor` — the anchor rawValue of ANY open overlay including a full one (`session.overlayActive ? session.overlayAnchor.rawValue : nil`), since the anchor is preserved across a `--full` round-trip (Decision 2); omitted when no overlay is open. diff --git a/CLAUDE.md b/CLAUDE.md index f650a452..f118cb39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -356,7 +356,8 @@ always in context: `session.background`/`background`, `notify`+`session.seen`/`unseen`, `session.status`/`status`+`statusPane` (+`statusBlink`/`statusColor` for `--blink`/`--color`), `session.flag`/`flagged`, `session.focus`/`splitFocused`, `session.resize`/`splitRatio`, - `session.overlay.resize`/`overlaySizePercent`, `sidebar`/`sidebarVisible`, `sidebar.mode`/`sidebarMode`, + `session.overlay.open`+`session.overlay.resize`/`overlaySizePercent`+`overlayCols`/`overlayRows` (requested)+`overlayColsApplied`/`overlayRowsApplied` (realized)+`overlayAnchor`, + `sidebar`/`sidebarVisible`, `sidebar.mode`/`sidebarMode`, `workspace.focus`/`focused`, `quick`/`quickVisible`, `window.move`+`window.resize`/`geometry`, `window.fullscreen`+`window.zoom`/`fullscreen`+`zoomed`. When adding a state-mutating command, ask "how does a script read back what I just set?" and add that diff --git a/README.md b/README.md index ad458a8e..9f3bd8c2 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,7 @@ By default an overlay opens on its `--target` without switching the active sessi `--block` runs the program in the overlay (rendering normally) and blocks until it exits, then exits with the program's status — useful in a script that needs the outcome of an interactive run. The program's output stays its own concern: a TUI writes its result to its own file (for example `revdiff --output=…`) which the script reads, while `--block` reports only the exit status (the overlay never captures stdout). `--block` can't be combined with `--wait`; `session overlay result` reports the last overlay's exit status on demand for a manual open → poll flow. -By default the overlay fills the pane, drawn translucent, hiding the session beneath it. For a *floating* variant instead — an opaque, framed panel with the session still visible around it — size it one of two ways: `--size-percent N` (1–100) sizes it to N% of the pane in both dimensions, or `--cols N --rows M` sizes it to an exact terminal grid. Both are adaptive: a request larger than the pane is clamped to the whole cells that fit, and the grid that actually rendered is read back on `agtermctl tree` (as `overlayColsApplied`/`overlayRowsApplied`). `--anchor POS` places a floating panel at one of nine positions — `top-left`, `top`, `top-right`, `left`, `center` (default), `right`, `bottom-left`, `bottom`, `bottom-right` — inset one line-height off each anchored edge so it is not flush with the border; `center` keeps the original dead-center placement. A floating overlay is useful for a small auxiliary program (a picker, a monitor) that you want floating over — not replacing — the terminal you're working in. It composes with `--block` (a blocking floating overlay). Like a full overlay it opens in the background and runs even when the target is not active; pass `--follow` to switch the user to the target as it opens. `session overlay resize` re-anchors or re-sizes it in place: `--anchor` alone moves the panel keeping its size, `--full` reverts to full-pane (which preserves the anchor for a later re-float, and cannot be combined with `--anchor`), and an out-of-range `--size-percent` is a hard error on both open and resize. +By default the overlay fills the pane, drawn translucent, hiding the session beneath it. For a *floating* variant instead — an opaque, framed panel with the session still visible around it — size it one of two ways: `--size-percent N` (1–100) sizes it to N% of the pane in both dimensions, or `--cols N --rows M` sizes it to an exact terminal grid. Both are adaptive — a `--cols/--rows` request larger than the pane is clamped down to the whole cells that fit, while a `--size-percent` panel is a fraction of the pane and so is never larger than it — and the grid that actually rendered is read back on `agtermctl tree` (as `overlayColsApplied`/`overlayRowsApplied`). `--anchor POS` places a floating panel at one of nine positions — `top-left`, `top`, `top-right`, `left`, `center` (default), `right`, `bottom-left`, `bottom`, `bottom-right` — inset one line-height off each anchored edge so it is not flush with the border; `center` keeps the original dead-center placement. A floating overlay is useful for a small auxiliary program (a picker, a monitor) that you want floating over — not replacing — the terminal you're working in. It composes with `--block` (a blocking floating overlay). Like a full overlay it opens in the background and runs even when the target is not active; pass `--follow` to switch the user to the target as it opens. `session overlay resize` re-anchors or re-sizes it in place: `--anchor` alone moves the panel keeping its size, `--full` reverts to full-pane (which preserves the anchor for a later re-float, and cannot be combined with `--anchor`), and an out-of-range `--size-percent` is a hard error on both open and resize. A session's terminal surface is created lazily — it does not exist until the session has been shown at least once. Injecting text into a never-shown session therefore fails with `session not realized` unless you pass `--select`, which selects the session (realizing its surface) before injecting: diff --git a/agterm/Ghostty/GhosttySurfaceView.swift b/agterm/Ghostty/GhosttySurfaceView.swift index bf37c6a3..9ba03fee 100644 --- a/agterm/Ghostty/GhosttySurfaceView.swift +++ b/agterm/Ghostty/GhosttySurfaceView.swift @@ -545,7 +545,11 @@ final class GhosttySurfaceView: NSView, TerminalSurface { /// `CELL_SIZE`, and every backing-size change without re-invalidating the panel at the `.cells` fixed point. func refreshOverlayMetrics() { guard let report = onOverlayMetrics, let px = overlayPixelMetrics() else { return } - let scale = Double(window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0) + // require the surface's OWN window for a real backing scale. Without one (not yet in a window) skip + // the refresh and leave the metrics unchanged rather than converting px->points with a guessed scale; + // a later realization/CELL_SIZE refresh reports the right value once the window is available. + guard let window else { return } + let scale = Double(window.backingScaleFactor) guard scale > 0 else { return } let cell = OverlayCellMetrics(cellWidth: px.cellW / scale, cellHeight: px.cellH / scale, padWidth: px.padW / scale, padHeight: px.padH / scale) diff --git a/agterm/Resources/agent-skill/SKILL.md b/agterm/Resources/agent-skill/SKILL.md index 9a2182c4..f436c9d8 100644 --- a/agterm/Resources/agent-skill/SKILL.md +++ b/agterm/Resources/agent-skill/SKILL.md @@ -284,7 +284,8 @@ omitted when expanded). `overlay close` · `overlay result` — run a program on top of a session; `--block` waits and exits with its status. Full by default; make it a floating panel with `--size-percent N` (1–100, hard error out of range) or - `--cols N --rows M` (exact grid). Both clamp to the whole cells that fit; the realized grid reads back as + `--cols N --rows M` (exact grid). `--cols/--rows` clamps to the whole cells that fit; `--size-percent` is a + fraction of the pane (never larger). Either way the realized grid reads back as `overlayColsApplied`/`overlayRowsApplied`. `--anchor POS` parks a floating panel at one of nine positions (`top-left` … `center` (default) … `bottom-right`), inset one line-height off each anchored edge; it needs a floating size. `overlay resize` changes an ALREADY-OPEN overlay in place (the program keeps running, no diff --git a/agterm/Resources/agent-skill/reference.md b/agterm/Resources/agent-skill/reference.md index 65d7266d..89b7ac04 100644 --- a/agterm/Resources/agent-skill/reference.md +++ b/agterm/Resources/agent-skill/reference.md @@ -446,8 +446,9 @@ All ten are read-only projections of GUI state. `"zsh -lc '…'"`. Full-size by default (hides the session). Give it a *floating* framed panel — the session still visible behind it — one of two ways: `--size-percent N` sizes it to N% of the pane in both dimensions, or - `--cols N --rows M` sizes it to an exact terminal grid (both required, each ≥ 1). Both are ADAPTIVE: a - request larger than the pane is clamped down to the whole cells that fit, and the grid that actually + `--cols N --rows M` sizes it to an exact terminal grid (both required, each ≥ 1). Both are ADAPTIVE — a + `--cols/--rows` request larger than the pane is clamped down to the whole cells that fit, while a + `--size-percent` panel is a fraction of the pane and never exceeds it — and the grid that actually rendered is read back on `tree` as `overlayColsApplied`/`overlayRowsApplied`. `--size-percent` must be 1–100 — an out-of-range value is a hard error (it is no longer silently clamped on open). `--anchor POS` places a floating panel at one of nine positions — `top-left`, `top`, `top-right`, `left`, diff --git a/agterm/Views/WindowContentView.swift b/agterm/Views/WindowContentView.swift index cbbb7393..f3e866d9 100644 --- a/agterm/Views/WindowContentView.swift +++ b/agterm/Views/WindowContentView.swift @@ -491,7 +491,7 @@ struct WindowContentView: View { // closing, OR resizing an overlay never re-hosts the NSSplitView (the titlebar-overrun trigger) // and never re-parents the surface (which would blank its Metal drawable). Full fills the area // translucent with the pane(s) hidden by `hideForOverlay`; floating draws an opaque framed panel - // over the still-visible pane(s). Switching full<->% (session.overlay.resize) only re-flows the frame. + // over the still-visible pane(s). Switching full<->floating (session.overlay.resize) only re-flows the frame. overlayPanel(session: session, isActive: deckInteractive && isActive) .zIndex(3) } @@ -523,7 +523,7 @@ struct WindowContentView: View { /// ALWAYS-PRESENT sibling. The content is gated INSIDE the GeometryReader, so the ZStack's child count /// never changes when an overlay opens/closes (constant shape = no NSSplitView re-host = no titlebar /// overrun), and BOTH variants share this single surface host, so `session.overlay.resize` switching - /// full<->% only re-flows the frame — it never re-parents the NSView (which would blank its Metal drawable). + /// full<->floating only re-flows the frame — it never re-parents the NSView (which would blank its Metal drawable). /// A `.full` `overlaySize` fills the detail area translucent (no opaque backing/frame) with the pane(s) /// hidden by `hideForOverlay`; a floating size (`.percent`/`.cells`) draws an opaque, framed panel sized by /// `OverlayLayout.panelSize` and anchored by `overlayAnchor` (nine positions, default center), with the @@ -857,21 +857,13 @@ struct WindowContentView: View { } extension OverlayAnchor { - /// The SwiftUI `Alignment` a floating overlay panel takes inside the detail-area ZStack — the 9-point - /// anchor mapped to a `(horizontal, vertical)` pair. The host-free `unitX`/`unitY` (0/0.5/1) can't name - /// SwiftUI's alignment guides, so this app-side mapping bridges them; `.center` reproduces today's - /// centered placement. + /// The SwiftUI `Alignment` a floating overlay panel takes inside the detail-area ZStack. DERIVED from + /// the host-free, unit-tested `unitX`/`unitY` (0 = leading/top, 0.5 = center, 1 = trailing/bottom) so a + /// per-anchor transposition is structurally impossible — the axis mapping cannot disagree with the same + /// unit coordinates the resolver/insets use; `.center` reproduces today's centered placement. var swiftUIAlignment: Alignment { - switch self { - case .topLeft: return .topLeading - case .top: return .top - case .topRight: return .topTrailing - case .left: return .leading - case .center: return .center - case .right: return .trailing - case .bottomLeft: return .bottomLeading - case .bottom: return .bottom - case .bottomRight: return .bottomTrailing - } + let horizontal: HorizontalAlignment = unitX == 0 ? .leading : (unitX == 1 ? .trailing : .center) + let vertical: VerticalAlignment = unitY == 0 ? .top : (unitY == 1 ? .bottom : .center) + return Alignment(horizontal: horizontal, vertical: vertical) } } diff --git a/agtermCore/Sources/agtermCore/AppStore+Panes.swift b/agtermCore/Sources/agtermCore/AppStore+Panes.swift index eca1ee91..e0e7b011 100644 --- a/agtermCore/Sources/agtermCore/AppStore+Panes.swift +++ b/agtermCore/Sources/agtermCore/AppStore+Panes.swift @@ -222,7 +222,14 @@ extension AppStore { @discardableResult public func resizeOverlay(_ sessionID: UUID, size: OverlaySize? = nil, anchor: OverlayAnchor? = nil) -> Bool { guard let session = session(withID: sessionID), session.overlayActive else { return false } - if let size { session.overlaySize = AppStore.clampedOverlaySize(size) } + if let size { + session.overlaySize = AppStore.clampedOverlaySize(size) + // the realized grid is stale until the surface re-settles at the new size (refreshed async by + // the app), so nil it — a `tree` read between the resize and the refresh reports nil (unknown) + // rather than the PRIOR size's grid. A re-anchor-only resize (size nil) leaves it untouched. + session.overlayAppliedCols = nil + session.overlayAppliedRows = nil + } if let anchor { session.overlayAnchor = anchor } return true } diff --git a/agtermCore/Sources/agtermCore/ControlDispatcher.swift b/agtermCore/Sources/agtermCore/ControlDispatcher.swift index 35dec551..2a239f90 100644 --- a/agtermCore/Sources/agtermCore/ControlDispatcher.swift +++ b/agtermCore/Sources/agtermCore/ControlDispatcher.swift @@ -519,20 +519,22 @@ public struct ControlDispatcher { return ControlResponse(ok: false, error: "invalid color: \(color) (#rrggbb)") } let size: OverlaySize - switch parseOverlaySize(args, command: "session.overlay.open", allowFull: false) { - case .rejected(let rejection): return rejection + switch OverlayArgs.parseSize(sizePercent: args?.sizePercent, cols: args?.cols, rows: args?.rows, + full: args?.full ?? false, command: .open) { + case .invalid(let message): return ControlResponse(ok: false, error: message) case .unspecified: size = .full case .size(let parsed): size = parsed } let anchor: OverlayAnchor - switch parseOverlayAnchor(args?.anchor) { - case .rejected(let rejection): return rejection + switch OverlayArgs.parseAnchor(args?.anchor) { + case .invalid(let message): return ControlResponse(ok: false, error: message) + case .absent: anchor = .center case .anchor(let parsed): - if parsed != nil, size == .full { + guard size != .full else { return ControlResponse(ok: false, error: "--anchor requires a floating overlay: use --size-percent or --cols/--rows") } - anchor = parsed ?? .center + anchor = parsed } return actions.openSessionOverlay(request.target, window: args?.window, options: ControlSessionOverlayOpenOptions( @@ -551,14 +553,16 @@ public struct ControlDispatcher { private func dispatchSessionOverlayResize(_ request: ControlRequest) -> ControlResponse { let args = request.args let size: OverlaySize? - switch parseOverlaySize(args, command: "session.overlay.resize", allowFull: true) { - case .rejected(let rejection): return rejection + switch OverlayArgs.parseSize(sizePercent: args?.sizePercent, cols: args?.cols, rows: args?.rows, + full: args?.full ?? false, command: .resize) { + case .invalid(let message): return ControlResponse(ok: false, error: message) case .unspecified: size = nil case .size(let parsed): size = parsed } let anchor: OverlayAnchor? - switch parseOverlayAnchor(args?.anchor) { - case .rejected(let rejection): return rejection + switch OverlayArgs.parseAnchor(args?.anchor) { + case .invalid(let message): return ControlResponse(ok: false, error: message) + case .absent: anchor = nil case .anchor(let parsed): anchor = parsed } if case .some(.full) = size, anchor != nil { @@ -571,65 +575,6 @@ public struct ControlDispatcher { return actions.resizeSessionOverlay(request.target, window: args?.window, size: size, anchor: anchor) } - /// The outcome of parsing the overlay size args: a resolved size, no size arg present (open → full, - /// resize → keep current), or the rejection to return as-is. - private enum OverlaySizeParse { - case size(OverlaySize) - case unspecified - case rejected(ControlResponse) - } - - /// Shared parse + validation of the overlay size args (`--size-percent` / `--cols` / `--rows`, and - /// `--full` on resize) for both open and resize. At most one size mode may be set; a percent is a hard - /// `1...100` error (Decision 3); cols/rows are both-or-neither and each `>= 1`. `command` prefixes the - /// per-command errors; `allowFull` enables the resize-only `--full` mode. - private func parseOverlaySize(_ args: ControlArgs?, command: String, allowFull: Bool) -> OverlaySizeParse { - let full = allowFull && (args?.full == true) - let percent = args?.sizePercent - let cols = args?.cols - let rows = args?.rows - let hasCells = cols != nil || rows != nil - let modeCount = (full ? 1 : 0) + (percent != nil ? 1 : 0) + (hasCells ? 1 : 0) - if modeCount > 1 { - let modes = allowFull ? "--full, --size-percent, or --cols/--rows" : "--size-percent or --cols/--rows" - return .rejected(ControlResponse(ok: false, error: "\(command): use only one of \(modes)")) - } - if full { return .size(.full) } - if let percent { - guard (1...100).contains(percent) else { - return .rejected(ControlResponse(ok: false, error: "\(command): --size-percent must be 1...100")) - } - return .size(.percent(percent)) - } - if hasCells { - guard let cols, let rows else { - return .rejected(ControlResponse(ok: false, error: "provide both --cols and --rows")) - } - guard cols >= 1, rows >= 1 else { - return .rejected(ControlResponse(ok: false, error: "--cols and --rows must be >= 1")) - } - return .size(.cells(cols: cols, rows: rows)) - } - return .unspecified - } - - /// The outcome of parsing the `--anchor` selector: the anchor (nil when absent), or the rejection. - private enum OverlayAnchorParse { - case anchor(OverlayAnchor?) - case rejected(ControlResponse) - } - - /// Shared `--anchor` parse for both overlay arms: nil when absent, the parsed anchor when valid, or an - /// `unknown anchor` rejection listing the nine positions. - private func parseOverlayAnchor(_ raw: String?) -> OverlayAnchorParse { - guard let raw else { return .anchor(nil) } - guard let parsed = OverlayAnchor(rawValue: raw) else { - let valid = OverlayAnchor.allCases.map(\.rawValue).joined(separator: "|") - return .rejected(ControlResponse(ok: false, error: "unknown anchor: \(raw) (\(valid))")) - } - return .anchor(parsed) - } - private func dispatchAppCommand(_ request: ControlRequest) -> ControlResponse { switch request.cmd { case .fontInc: diff --git a/agtermCore/Sources/agtermCore/ControlProtocol.swift b/agtermCore/Sources/agtermCore/ControlProtocol.swift index 22321c10..0b5b9b3e 100644 --- a/agtermCore/Sources/agtermCore/ControlProtocol.swift +++ b/agtermCore/Sources/agtermCore/ControlProtocol.swift @@ -194,10 +194,11 @@ public struct ControlArgs: Codable, Sendable, Equatable { public var wait: Bool? /// For `session.overlay.open`, the percent of the pane (1...100) a *floating* overlay panel /// occupies in both dimensions; omitted gives the default full-pane overlay. Also carries the new - /// size for `session.overlay.resize` (mutually exclusive with `full`). + /// size for `session.overlay.resize`. Mutually exclusive with `cols`/`rows` and (on resize) `full`. public var sizePercent: Int? /// For `session.overlay.resize`, requests the full-pane (translucent, session-hidden) overlay — - /// the way to switch a floating overlay back to full. Mutually exclusive with `sizePercent`. + /// the way to switch a floating overlay back to full. Mutually exclusive with `sizePercent`/`cols`/`rows`, + /// and cannot be combined with `anchor` (a full overlay is not anchored). public var full: Bool? /// For `session.overlay.open`/`.resize`, the exact terminal grid a *floating* overlay panel sizes to /// via the surface's live cell metrics: `cols` columns × `rows` rows, both or neither, mutually diff --git a/agtermCore/Sources/agtermCore/OverlayArgs.swift b/agtermCore/Sources/agtermCore/OverlayArgs.swift new file mode 100644 index 00000000..75a8e2a8 --- /dev/null +++ b/agtermCore/Sources/agtermCore/OverlayArgs.swift @@ -0,0 +1,76 @@ +import Foundation + +/// The outcome of validating the overlay SIZE args (`--size-percent` / `--cols` / `--rows`, and `--full` +/// on resize): a resolved size mode, no size arg present (open → full, resize → keep current), or a +/// validation failure carrying the canonical error string. +public enum OverlaySizeParse: Equatable, Sendable { + case size(OverlaySize) + case unspecified + case invalid(String) +} + +/// The outcome of parsing the `--anchor` selector: the parsed anchor, no `--anchor` present, or a +/// validation failure carrying the canonical error string. +public enum OverlayAnchorParse: Equatable, Sendable { + case anchor(OverlayAnchor) + case absent + case invalid(String) +} + +/// Host-free validation of the `session.overlay.open`/`.resize` size + anchor args, the SINGLE source of +/// the one-of / range / pairing rules and their error strings. Both the control dispatcher and the +/// `agtermctl` CLI call these and map the result to their own error type (`ControlResponse` vs +/// `ValidationError`), so the two surfaces cannot drift. +public enum OverlayArgs { + /// Which overlay verb is validating: carries the error-message prefix and whether `--full` is a valid + /// size mode (resize only). Folding the correlated command-name/allow-full pair keeps `parseSize` within + /// the parameter-count limit. + public enum Command: Sendable { + case open, resize + var name: String { self == .open ? "session.overlay.open" : "session.overlay.resize" } + var allowsFull: Bool { self == .resize } + } + + /// Validates the size args and resolves the `OverlaySize`. At most one size mode may be set; a percent + /// is a hard `1...100` error (Decision 3); `cols`/`rows` are both-or-neither and each `>= 1`. `command` + /// prefixes the per-command errors and gates the resize-only `--full` mode (open has no `--full`, so a + /// stray `full` is ignored there). + public static func parseSize(sizePercent: Int?, cols: Int?, rows: Int?, full: Bool, + command: Command) -> OverlaySizeParse { + let fullMode = command.allowsFull && full + let hasCells = cols != nil || rows != nil + let modeCount = (fullMode ? 1 : 0) + (sizePercent != nil ? 1 : 0) + (hasCells ? 1 : 0) + if modeCount > 1 { + let modes = command.allowsFull ? "--full, --size-percent, or --cols/--rows" : "--size-percent or --cols/--rows" + return .invalid("\(command.name): use only one of \(modes)") + } + if fullMode { return .size(.full) } + if let sizePercent { + guard (1...100).contains(sizePercent) else { + return .invalid("\(command.name): --size-percent must be 1...100") + } + return .size(.percent(sizePercent)) + } + if hasCells { + guard let cols, let rows else { + return .invalid("provide both --cols and --rows") + } + guard cols >= 1, rows >= 1 else { + return .invalid("--cols and --rows must be >= 1") + } + return .size(.cells(cols: cols, rows: rows)) + } + return .unspecified + } + + /// Parses `--anchor` to an `OverlayAnchor`: `.absent` when omitted, `.anchor` when valid, or `.invalid` + /// with the `unknown anchor` message listing the nine positions. + public static func parseAnchor(_ raw: String?) -> OverlayAnchorParse { + guard let raw else { return .absent } + guard let parsed = OverlayAnchor(rawValue: raw) else { + let valid = OverlayAnchor.allCases.map(\.rawValue).joined(separator: "|") + return .invalid("unknown anchor: \(raw) (\(valid))") + } + return .anchor(parsed) + } +} diff --git a/agtermCore/Sources/agtermctlKit/SessionCommands.swift b/agtermCore/Sources/agtermctlKit/SessionCommands.swift index 692a15d4..7d1baef7 100644 --- a/agtermCore/Sources/agtermctlKit/SessionCommands.swift +++ b/agtermCore/Sources/agtermctlKit/SessionCommands.swift @@ -597,48 +597,25 @@ struct Session: ParsableCommand { subcommands: [Open.self, Close.self, Resize.self, Result.self] ) - /// Which overlay verb is validating: carries the error-message prefix and whether `--full` is a - /// valid size mode (resize only). Keeps `validateSize` to five params by folding the correlated - /// command-name/allow-full pair. - enum SizeContext { - case open, resize - var name: String { self == .open ? "session.overlay.open" : "session.overlay.resize" } - var allowsFull: Bool { self == .resize } - } - - /// Shared overlay size validation for `open` and `resize`, mirroring the dispatcher's one-of / - /// range / pairing rules so a CLI user and a raw socket client see the same wording: at most one - /// of {--size-percent, --cols/--rows, --full (resize only)}; --size-percent 1...100 (a hard error - /// now enforced on open too — Decision 3); --cols and --rows both-or-neither, each >= 1. - static func validateSize(_ context: SizeContext, full: Bool, sizePercent: Int?, cols: Int?, rows: Int?) throws { - let hasCells = cols != nil || rows != nil - let modeCount = (full ? 1 : 0) + (sizePercent != nil ? 1 : 0) + (hasCells ? 1 : 0) - if modeCount > 1 { - let modes = context.allowsFull ? "--full, --size-percent, or --cols/--rows" : "--size-percent or --cols/--rows" - throw ValidationError("\(context.name): use only one of \(modes)") - } - if let sizePercent, !(1...100).contains(sizePercent) { - throw ValidationError("\(context.name): --size-percent must be 1...100") - } - if hasCells { - guard cols != nil, rows != nil else { - throw ValidationError("provide both --cols and --rows") - } - guard (cols ?? 0) >= 1, (rows ?? 0) >= 1 else { - throw ValidationError("--cols and --rows must be >= 1") - } + /// Shared overlay size validation for `open` and `resize`, delegating to the host-free + /// `OverlayArgs.parseSize` so the CLI and the dispatcher share ONE set of one-of / range / pairing + /// rules and error strings. Throws the canonical message on a rejection; the parsed size is + /// discarded here (the dispatcher re-parses the raw args on the wire). + static func validateSize(_ command: OverlayArgs.Command, full: Bool, sizePercent: Int?, cols: Int?, rows: Int?) throws { + if case .invalid(let message) = OverlayArgs.parseSize(sizePercent: sizePercent, cols: cols, rows: rows, + full: full, command: command) { + throw ValidationError(message) } } - /// Parses `--anchor` to an `OverlayAnchor`, throwing the dispatcher's `unknown anchor` message - /// (listing the nine positions) when it is not one of them. Returns nil when absent. + /// Parses `--anchor` to an `OverlayAnchor` via the host-free `OverlayArgs.parseAnchor`, throwing the + /// canonical `unknown anchor` message. Returns nil when absent. static func parseAnchor(_ raw: String?) throws -> OverlayAnchor? { - guard let raw else { return nil } - guard let parsed = OverlayAnchor(rawValue: raw) else { - let valid = OverlayAnchor.allCases.map(\.rawValue).joined(separator: "|") - throw ValidationError("unknown anchor: \(raw) (\(valid))") + switch OverlayArgs.parseAnchor(raw) { + case .invalid(let message): throw ValidationError(message) + case .absent: return nil + case .anchor(let parsed): return parsed } - return parsed } struct Open: RequestCommand { diff --git a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift index 911f652f..4e8a683e 100644 --- a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift +++ b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift @@ -574,6 +574,26 @@ struct AppStorePaneTests { #expect(session.overlayAnchor == .bottomLeft) } + @Test func resizeOverlaySizeChangeClearsStaleAppliedGrid() { + let store = makeStore() + let ws = store.addWorkspace(name: "work") + let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! + store.openOverlay(session.id, options: .init(command: "htop", size: .cells(cols: 80, rows: 24), anchor: .center)) + // simulate the app-maintained realized grid from the first size. + session.overlayAppliedCols = 72 + session.overlayAppliedRows = 20 + // a SIZE-changing resize nils the stale realized grid so a read before the async refresh returns nil. + #expect(store.resizeOverlay(session.id, size: .cells(cols: 30, rows: 10)) == true) + #expect(session.overlayAppliedCols == nil) + #expect(session.overlayAppliedRows == nil) + // a re-anchor-only resize (nil size) does NOT touch the realized grid. + session.overlayAppliedCols = 28 + session.overlayAppliedRows = 9 + #expect(store.resizeOverlay(session.id, anchor: .topLeft) == true) + #expect(session.overlayAppliedCols == 28) + #expect(session.overlayAppliedRows == 9) + } + @Test func resizeOverlayFullPreservesAnchor() { let store = makeStore() let ws = store.addWorkspace(name: "work") diff --git a/agtermCore/Tests/agtermCoreTests/OverlayArgsTests.swift b/agtermCore/Tests/agtermCoreTests/OverlayArgsTests.swift new file mode 100644 index 00000000..2ef01942 --- /dev/null +++ b/agtermCore/Tests/agtermCoreTests/OverlayArgsTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +@testable import agtermCore + +struct OverlayArgsTests { + // MARK: - parseSize + + @Test func sizeUnspecifiedWhenNothingSet() { + let result = OverlayArgs.parseSize(sizePercent: nil, cols: nil, rows: nil, full: false, command: .open) + #expect(result == .unspecified) + } + + @Test func sizeResolvesPercent() { + let result = OverlayArgs.parseSize(sizePercent: 50, cols: nil, rows: nil, full: false, command: .open) + #expect(result == .size(.percent(50))) + } + + @Test func sizeResolvesCells() { + let result = OverlayArgs.parseSize(sizePercent: nil, cols: 40, rows: 12, full: false, command: .open) + #expect(result == .size(.cells(cols: 40, rows: 12))) + } + + @Test func sizeResolvesFullWhenAllowed() { + let result = OverlayArgs.parseSize(sizePercent: nil, cols: nil, rows: nil, full: true, command: .resize) + #expect(result == .size(.full)) + } + + @Test func sizeIgnoresFullWhenNotAllowed() { + // open has no --full, so a stray full flag is treated as no size = unspecified (open → full-pane). + let result = OverlayArgs.parseSize(sizePercent: nil, cols: nil, rows: nil, full: true, command: .open) + #expect(result == .unspecified) + } + + @Test func sizeRejectsMultipleModesOpen() { + let result = OverlayArgs.parseSize(sizePercent: 50, cols: 40, rows: 12, full: false, command: .open) + #expect(result == .invalid("session.overlay.open: use only one of --size-percent or --cols/--rows")) + } + + @Test func sizeRejectsMultipleModesResize() { + let result = OverlayArgs.parseSize(sizePercent: 50, cols: nil, rows: nil, full: true, command: .resize) + #expect(result == .invalid("session.overlay.resize: use only one of --full, --size-percent, or --cols/--rows")) + } + + @Test func sizeRejectsPercentOutOfRange() { + let high = OverlayArgs.parseSize(sizePercent: 150, cols: nil, rows: nil, full: false, command: .open) + #expect(high == .invalid("session.overlay.open: --size-percent must be 1...100")) + let low = OverlayArgs.parseSize(sizePercent: 0, cols: nil, rows: nil, full: false, command: .resize) + #expect(low == .invalid("session.overlay.resize: --size-percent must be 1...100")) + } + + @Test func sizeRejectsColsWithoutRows() { + let colsOnly = OverlayArgs.parseSize(sizePercent: nil, cols: 40, rows: nil, full: false, command: .open) + #expect(colsOnly == .invalid("provide both --cols and --rows")) + let rowsOnly = OverlayArgs.parseSize(sizePercent: nil, cols: nil, rows: 12, full: false, command: .open) + #expect(rowsOnly == .invalid("provide both --cols and --rows")) + } + + @Test func sizeRejectsNonPositiveCells() { + let result = OverlayArgs.parseSize(sizePercent: nil, cols: 0, rows: 12, full: false, command: .open) + #expect(result == .invalid("--cols and --rows must be >= 1")) + } + + // MARK: - parseAnchor + + @Test func anchorAbsentWhenNil() { + #expect(OverlayArgs.parseAnchor(nil) == .absent) + } + + @Test func anchorResolvesEachValidValue() { + for anchor in OverlayAnchor.allCases { + #expect(OverlayArgs.parseAnchor(anchor.rawValue) == .anchor(anchor)) + } + } + + @Test func anchorRejectsUnknownWithNinePositions() { + let result = OverlayArgs.parseAnchor("middle") + #expect(result == .invalid("unknown anchor: middle (top-left|top|top-right|left|center|right|bottom-left|bottom|bottom-right)")) + } +} diff --git a/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift b/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift index 5e946d0e..45860807 100644 --- a/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift +++ b/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift @@ -65,7 +65,13 @@ struct OverlayLayoutTests { expectSize(result, 196, 198) } - @Test func retina2xAndNonRetina1xYieldSamePointSize() { + // guards ONLY that the resolver is scale-agnostic — it works in POINT space, so a 1x surface and the + // same physical surface at 2x, once BOTH are converted to points (the app divides pixels by the backing + // scale before constructing the metrics, mirrored by `metrics(scale:)`), produce identical point + // metrics and thus an identical panel. The resolver itself takes no scale, so this cannot catch a bug in + // the actual px÷backingScaleFactor conversion — that is app-side and is guarded end to end by the e2e + // `stty size` check (`testOverlayColsRowsRealizedGridMatchesRequest`). + @Test func equalPointMetricsFromDifferentPixelScalesResolveIdentically() { // a surface at 1x: 8px/16px cells, 4px/2px total padding. let oneX = metrics(cellWpx: 8, cellHpx: 16, padWpx: 4, padHpx: 2, scale: 1) // the same physical surface at 2x: pixel metrics double, backing scale is 2. diff --git a/agtermUITests/ControlOverlaySplitUITests.swift b/agtermUITests/ControlOverlaySplitUITests.swift index f0422cf5..8c6f6e95 100644 --- a/agtermUITests/ControlOverlaySplitUITests.swift +++ b/agtermUITests/ControlOverlaySplitUITests.swift @@ -739,6 +739,40 @@ final class ControlOverlaySplitUITests: ControlAPITestCase { XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)") } + // The requested-vs-applied CLAMP path end to end: a deliberately OVERSIZED grid (500x200) cannot fit any + // real pane, so the whole-cell clamp must produce a realized grid SMALLER than the request. The tree echoes + // the requested grid verbatim (overlayCols/Rows == 500/200) while the APPLIED grid + // (overlayColsApplied/RowsApplied) — read from the REALIZED surface, not the request — must be strictly + // less on BOTH axes. This is the case the fitting tests can't reach (they give headroom so applied == + // requested): a bug populating applied from the request instead of the realized surface would pass those + // but FAIL here. + func testOverlayColsRowsClampReportsAppliedBelowRequested() throws { + let id = try activeSessionID() + // a modest window so 500x200 vastly exceeds the pane and the clamp definitely binds on both axes. + try resizeWindow(width: 900, height: 650) + + let open = try sendOverlayOpen(target: id, command: "cat", args: ["cols": 500, "rows": 200]) + XCTAssertEqual(open["ok"] as? Bool, true, "oversized overlay open should succeed: \(open)") + XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the overlay should be up") + + let node = try XCTUnwrap(pollOverlayApplied(id: id, timeout: 12), + "the tree should report the realized overlay grid after clamping") + // the requested grid is echoed verbatim (the restore key), unclamped. + XCTAssertEqual(node["overlayCols"] as? Int, 500, "tree should echo the requested cols verbatim: \(node)") + XCTAssertEqual(node["overlayRows"] as? Int, 200, "tree should echo the requested rows verbatim: \(node)") + // the applied grid reflects the realized surface, clamped strictly below the impossible request. + let appliedCols = try XCTUnwrap(node["overlayColsApplied"] as? Int, "applied cols should be present: \(node)") + let appliedRows = try XCTUnwrap(node["overlayRowsApplied"] as? Int, "applied rows should be present: \(node)") + XCTAssertLessThan(appliedCols, 500, "applied cols \(appliedCols) must be clamped below the requested 500: \(node)") + XCTAssertLessThan(appliedRows, 200, "applied rows \(appliedRows) must be clamped below the requested 200: \(node)") + // sanity: the realized grid is still a real, positive grid (not zero/degenerate). + XCTAssertGreaterThan(appliedCols, 0, "applied cols should be a real grid: \(node)") + XCTAssertGreaterThan(appliedRows, 0, "applied rows should be a real grid: \(node)") + + let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#) + XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)") + } + // The floating panel follows its corner anchor AND sits ONE LINE-HEIGHT off the anchored edges (the anchor // margin), never flush against the border. The detail area is captured from a FULL overlay (its panel fills // the pane exactly with NO inset) via the same `overlay-floating-panel` marker; the floating panel's inset diff --git a/site/commands.html b/site/commands.html index 4c575192..ac115fb9 100644 --- a/site/commands.html +++ b/site/commands.html @@ -1175,7 +1175,7 @@ Full-size by default, hiding the session. Make it a floating framed panel — the session visible behind — with --size-percent N (1–100 of the pane) or --cols N --rows M (an exact terminal grid, both required). - Both are adaptive: a request larger than the pane clamps to the whole cells that fit, and the realized grid reads back on + Both are adaptive: a --cols/--rows request larger than the pane clamps to the whole cells that fit (a --size-percent panel is a fraction of the pane, never larger), and the realized grid reads back on tree as overlayColsApplied/overlayRowsApplied. An out-of-range --size-percent is a hard error (no longer silently clamped on open). diff --git a/site/docs.html b/site/docs.html index c50c6c9c..28287d70 100644 --- a/site/docs.html +++ b/site/docs.html @@ -1335,7 +1335,7 @@ Size a floating panel with --size-percent N (1–100 of the pane) or --cols N --rows M (an exact terminal grid); - both clamp to the whole cells that fit, and the realized grid reads back on + the cols/rows grid clamps to the whole cells that fit and a percent panel simply never exceeds the pane; the realized grid reads back on agtermctl tree as overlayColsApplied/overlayRowsApplied. --anchor parks it at one of nine positions From 95edc1c6e9f10eff1d4e32feed702479db5cab41 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 03:02:32 -0500 Subject: [PATCH 15/20] refactor: centralize overlay pairing validation and address smells --- agterm/Ghostty/GhosttySurfaceView.swift | 1 - agterm/Views/WindowContentView.swift | 12 +-- .../agtermCore/ControlDispatcher.swift | 40 +++------- .../Sources/agtermCore/OverlayArgs.swift | 75 +++++++++++++++++-- .../agtermctlKit/SessionCommands.swift | 46 +++--------- .../ControlDispatcherTests.swift | 6 +- .../agtermCoreTests/OverlayArgsTests.swift | 72 +++++++++++++++++- .../agtermctlKitTests/CommandsTests.swift | 8 +- .../ControlOverlaySplitUITests.swift | 14 ++-- 9 files changed, 180 insertions(+), 94 deletions(-) diff --git a/agterm/Ghostty/GhosttySurfaceView.swift b/agterm/Ghostty/GhosttySurfaceView.swift index 9ba03fee..bad4e4e9 100644 --- a/agterm/Ghostty/GhosttySurfaceView.swift +++ b/agterm/Ghostty/GhosttySurfaceView.swift @@ -514,7 +514,6 @@ final class GhosttySurfaceView: NSView, TerminalSurface { /// The overlay surface's live cell + padding + grid metrics from `ghostty_surface_size`, all in BACKING /// PIXELS (cell width/height and the derived non-cell padding remainder, plus the realized columns/rows). - /// A struct (not a tuple) because swiftlint caps tuples at 3 members. struct OverlayPixelMetrics { let cellW: Double, cellH: Double, padW: Double, padH: Double let cols: Int, rows: Int diff --git a/agterm/Views/WindowContentView.swift b/agterm/Views/WindowContentView.swift index f3e866d9..05cb6f4d 100644 --- a/agterm/Views/WindowContentView.swift +++ b/agterm/Views/WindowContentView.swift @@ -562,15 +562,17 @@ struct WindowContentView: View { .strokeBorder(floating ? Color.white.opacity(0.18) : Color.clear, lineWidth: 1) ) .shadow(radius: floating ? 24 : 0) - // the Metal-backed surface is not in the a11y tree, so expose the panel's frame via a - // zero-content overlay marker (the dashboard-cell pattern) that the e2e reads. As an - // .overlay it adds NO ZStack child, so the constant-child-count NSSplitView-overrun - // invariant holds; allowsHitTesting(false) keeps it off the overlay's own input. + // the Metal-backed surface is not in the a11y tree, so expose the FLOATING panel's frame + // via a zero-content overlay marker (the dashboard-cell pattern) that the e2e reads. As + // an .overlay it adds NO ZStack child, so the constant-child-count NSSplitView-overrun + // invariant holds; allowsHitTesting(false) keeps it off the overlay's own input. Only a + // floating panel carries the id — a full overlay covers the whole detail area, not a + // panel — and the id string flips as a VALUE (no view branch), so the chain stays constant. .overlay( Color.clear .allowsHitTesting(false) .accessibilityElement() - .accessibilityIdentifier("overlay-floating-panel") + .accessibilityIdentifier(floating ? "overlay-floating-panel" : "") ) // push the panel one cell off its anchored edge(s) — the padding wraps the panel AND // its a11y marker, so the marker still reports the panel's own frame while the whole diff --git a/agtermCore/Sources/agtermCore/ControlDispatcher.swift b/agtermCore/Sources/agtermCore/ControlDispatcher.swift index 2a239f90..6eaec317 100644 --- a/agtermCore/Sources/agtermCore/ControlDispatcher.swift +++ b/agtermCore/Sources/agtermCore/ControlDispatcher.swift @@ -519,22 +519,13 @@ public struct ControlDispatcher { return ControlResponse(ok: false, error: "invalid color: \(color) (#rrggbb)") } let size: OverlaySize - switch OverlayArgs.parseSize(sizePercent: args?.sizePercent, cols: args?.cols, rows: args?.rows, - full: args?.full ?? false, command: .open) { - case .invalid(let message): return ControlResponse(ok: false, error: message) - case .unspecified: size = .full - case .size(let parsed): size = parsed - } let anchor: OverlayAnchor - switch OverlayArgs.parseAnchor(args?.anchor) { + switch OverlayArgs.resolveOpen(sizePercent: args?.sizePercent, cols: args?.cols, rows: args?.rows, + anchor: args?.anchor) { case .invalid(let message): return ControlResponse(ok: false, error: message) - case .absent: anchor = .center - case .anchor(let parsed): - guard size != .full else { - return ControlResponse(ok: false, - error: "--anchor requires a floating overlay: use --size-percent or --cols/--rows") - } - anchor = parsed + case .resolved(let parsedSize, let parsedAnchor): + size = parsedSize + anchor = parsedAnchor } return actions.openSessionOverlay(request.target, window: args?.window, options: ControlSessionOverlayOpenOptions( @@ -553,24 +544,13 @@ public struct ControlDispatcher { private func dispatchSessionOverlayResize(_ request: ControlRequest) -> ControlResponse { let args = request.args let size: OverlaySize? - switch OverlayArgs.parseSize(sizePercent: args?.sizePercent, cols: args?.cols, rows: args?.rows, - full: args?.full ?? false, command: .resize) { - case .invalid(let message): return ControlResponse(ok: false, error: message) - case .unspecified: size = nil - case .size(let parsed): size = parsed - } let anchor: OverlayAnchor? - switch OverlayArgs.parseAnchor(args?.anchor) { + switch OverlayArgs.resolveResize(sizePercent: args?.sizePercent, cols: args?.cols, rows: args?.rows, + full: args?.full ?? false, anchor: args?.anchor) { case .invalid(let message): return ControlResponse(ok: false, error: message) - case .absent: anchor = nil - case .anchor(let parsed): anchor = parsed - } - if case .some(.full) = size, anchor != nil { - return ControlResponse(ok: false, error: "--full cannot be combined with --anchor") - } - if size == nil, anchor == nil { - return ControlResponse(ok: false, - error: "session.overlay.resize requires a size (--full, --size-percent, --cols/--rows) or --anchor") + case .resolved(let parsedSize, let parsedAnchor): + size = parsedSize + anchor = parsedAnchor } return actions.resizeSessionOverlay(request.target, window: args?.window, size: size, anchor: anchor) } diff --git a/agtermCore/Sources/agtermCore/OverlayArgs.swift b/agtermCore/Sources/agtermCore/OverlayArgs.swift index 75a8e2a8..a44da0f1 100644 --- a/agtermCore/Sources/agtermCore/OverlayArgs.swift +++ b/agtermCore/Sources/agtermCore/OverlayArgs.swift @@ -17,20 +17,83 @@ public enum OverlayAnchorParse: Equatable, Sendable { case invalid(String) } +/// The outcome of resolving the combined size + anchor for `session.overlay.open`: a concrete size (no +/// size arg → `.full`) and anchor (no `--anchor` → `.center`), or a validation failure carrying the +/// canonical error string. +public enum OverlayOpenResolution: Equatable, Sendable { + case resolved(size: OverlaySize, anchor: OverlayAnchor) + case invalid(String) +} + +/// The outcome of resolving the combined size + anchor for `session.overlay.resize`: an optional size (nil +/// = keep the current size) and optional anchor (nil = keep the current anchor), or a validation failure +/// carrying the canonical error string. +public enum OverlayResizeResolution: Equatable, Sendable { + case resolved(size: OverlaySize?, anchor: OverlayAnchor?) + case invalid(String) +} + /// Host-free validation of the `session.overlay.open`/`.resize` size + anchor args, the SINGLE source of /// the one-of / range / pairing rules and their error strings. Both the control dispatcher and the -/// `agtermctl` CLI call these and map the result to their own error type (`ControlResponse` vs -/// `ValidationError`), so the two surfaces cannot drift. +/// `agtermctl` CLI resolve the args through `resolveOpen`/`resolveResize` and map an `.invalid` result to +/// their own error type (`ControlResponse` vs `ValidationError`), so the two surfaces cannot drift. public enum OverlayArgs { /// Which overlay verb is validating: carries the error-message prefix and whether `--full` is a valid - /// size mode (resize only). Folding the correlated command-name/allow-full pair keeps `parseSize` within - /// the parameter-count limit. + /// size mode (resize only). public enum Command: Sendable { case open, resize var name: String { self == .open ? "session.overlay.open" : "session.overlay.resize" } var allowsFull: Bool { self == .resize } } + /// Resolves the `session.overlay.open` size + anchor: absence of any size arg means a full-pane overlay + /// (open has no `--full`) and absence of `--anchor` means `.center`. An `--anchor` is only meaningful + /// for a floating overlay, so an anchor over a full-pane open is rejected. + public static func resolveOpen(sizePercent: Int?, cols: Int?, rows: Int?, + anchor rawAnchor: String?) -> OverlayOpenResolution { + let size: OverlaySize + switch parseSize(sizePercent: sizePercent, cols: cols, rows: rows, full: false, command: .open) { + case .invalid(let message): return .invalid(message) + case .unspecified: size = .full + case .size(let parsed): size = parsed + } + switch parseAnchor(rawAnchor) { + case .invalid(let message): return .invalid(message) + case .absent: return .resolved(size: size, anchor: .center) + case .anchor(let parsed): + guard size != .full else { + return .invalid("--anchor requires a floating overlay: use --size-percent or --cols/--rows") + } + return .resolved(size: size, anchor: parsed) + } + } + + /// Resolves the `session.overlay.resize` size + anchor: a nil size keeps the current size and a nil + /// anchor keeps the current anchor, so at least one of {a size mode, `--anchor`} must be set. `--full` + /// reverts to the full-pane overlay and cannot carry an anchor. + public static func resolveResize(sizePercent: Int?, cols: Int?, rows: Int?, full: Bool, + anchor rawAnchor: String?) -> OverlayResizeResolution { + let size: OverlaySize? + switch parseSize(sizePercent: sizePercent, cols: cols, rows: rows, full: full, command: .resize) { + case .invalid(let message): return .invalid(message) + case .unspecified: size = nil + case .size(let parsed): size = parsed + } + let anchor: OverlayAnchor? + switch parseAnchor(rawAnchor) { + case .invalid(let message): return .invalid(message) + case .absent: anchor = nil + case .anchor(let parsed): anchor = parsed + } + if case .some(.full) = size, anchor != nil { + return .invalid("--full cannot be combined with --anchor") + } + if size == nil, anchor == nil { + return .invalid("session.overlay.resize requires a size (--full, --size-percent, --cols/--rows) or --anchor") + } + return .resolved(size: size, anchor: anchor) + } + /// Validates the size args and resolves the `OverlaySize`. At most one size mode may be set; a percent /// is a hard `1...100` error (Decision 3); `cols`/`rows` are both-or-neither and each `>= 1`. `command` /// prefixes the per-command errors and gates the resize-only `--full` mode (open has no `--full`, so a @@ -53,10 +116,10 @@ public enum OverlayArgs { } if hasCells { guard let cols, let rows else { - return .invalid("provide both --cols and --rows") + return .invalid("\(command.name): provide both --cols and --rows") } guard cols >= 1, rows >= 1 else { - return .invalid("--cols and --rows must be >= 1") + return .invalid("\(command.name): --cols and --rows must be >= 1") } return .size(.cells(cols: cols, rows: rows)) } diff --git a/agtermCore/Sources/agtermctlKit/SessionCommands.swift b/agtermCore/Sources/agtermctlKit/SessionCommands.swift index 7d1baef7..4f0a8eca 100644 --- a/agtermCore/Sources/agtermctlKit/SessionCommands.swift +++ b/agtermCore/Sources/agtermctlKit/SessionCommands.swift @@ -597,27 +597,6 @@ struct Session: ParsableCommand { subcommands: [Open.self, Close.self, Resize.self, Result.self] ) - /// Shared overlay size validation for `open` and `resize`, delegating to the host-free - /// `OverlayArgs.parseSize` so the CLI and the dispatcher share ONE set of one-of / range / pairing - /// rules and error strings. Throws the canonical message on a rejection; the parsed size is - /// discarded here (the dispatcher re-parses the raw args on the wire). - static func validateSize(_ command: OverlayArgs.Command, full: Bool, sizePercent: Int?, cols: Int?, rows: Int?) throws { - if case .invalid(let message) = OverlayArgs.parseSize(sizePercent: sizePercent, cols: cols, rows: rows, - full: full, command: command) { - throw ValidationError(message) - } - } - - /// Parses `--anchor` to an `OverlayAnchor` via the host-free `OverlayArgs.parseAnchor`, throwing the - /// canonical `unknown anchor` message. Returns nil when absent. - static func parseAnchor(_ raw: String?) throws -> OverlayAnchor? { - switch OverlayArgs.parseAnchor(raw) { - case .invalid(let message): throw ValidationError(message) - case .absent: return nil - case .anchor(let parsed): return parsed - } - } - struct Open: RequestCommand { static let configuration = CommandConfiguration(abstract: "Open an overlay running COMMAND; it closes when COMMAND exits.") @Argument(help: "Program to run in the overlay (e.g. revdiff).") var command: String @@ -635,15 +614,16 @@ struct Session: ParsableCommand { // reject the mutually-exclusive combos, a bad size/anchor, and a malformed color at parse time // (before any connection), so it's a clean usage error and is unit-testable without a socket. + // the host-free OverlayArgs.resolveOpen owns the size + anchor rules (incl. anchor-requires-a- + // floating-size), shared verbatim with the dispatcher. func validate() throws { if block && wait { throw ValidationError("--block cannot be combined with --wait") } if let backgroundColor, !WatermarkConfig.isValidColorHex(backgroundColor) { throw ValidationError("background-color must be a #rrggbb hex value") } - try Overlay.validateSize(.open, full: false, sizePercent: sizePercent, cols: cols, rows: rows) - // an --anchor is only meaningful for a floating overlay (open with no size = full-pane). - if try Overlay.parseAnchor(anchor) != nil, sizePercent == nil, cols == nil, rows == nil { - throw ValidationError("--anchor requires a floating overlay: use --size-percent or --cols/--rows") + if case .invalid(let message) = OverlayArgs.resolveOpen(sizePercent: sizePercent, cols: cols, + rows: rows, anchor: anchor) { + throw ValidationError(message) } } @@ -713,17 +693,13 @@ struct Session: ParsableCommand { @OptionGroup var options: ClientOptions // validate at parse time (before any connection) so it's a clean usage error, unit-testable - // without a socket; the dispatcher re-checks the same rules. One-of {--full, --size-percent, - // --cols/--rows} OR none, at least one of {a size mode, --anchor}, and --full ⊥ --anchor. + // without a socket; the dispatcher re-checks the same rules. The host-free OverlayArgs.resolveResize + // owns them all: one-of {--full, --size-percent, --cols/--rows} OR none, at least one of {a size + // mode, --anchor}, and --full ⊥ --anchor. func validate() throws { - try Overlay.validateSize(.resize, full: full, sizePercent: sizePercent, cols: cols, rows: rows) - let parsedAnchor = try Overlay.parseAnchor(anchor) - if full, parsedAnchor != nil { - throw ValidationError("--full cannot be combined with --anchor") - } - let hasSize = full || sizePercent != nil || cols != nil || rows != nil - if !hasSize, parsedAnchor == nil { - throw ValidationError("session.overlay.resize requires a size (--full, --size-percent, --cols/--rows) or --anchor") + if case .invalid(let message) = OverlayArgs.resolveResize(sizePercent: sizePercent, cols: cols, + rows: rows, full: full, anchor: anchor) { + throw ValidationError(message) } } diff --git a/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift b/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift index efd8fbbe..d926ea0f 100644 --- a/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift +++ b/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift @@ -1260,12 +1260,12 @@ struct ControlDispatcherTests { let anchorWithoutFloating = await dispatcher.dispatch(ControlRequest( cmd: .sessionOverlayOpen, target: "session", args: ControlArgs(command: "cat", anchor: "top-left"))) - #expect(colsWithoutRows == ControlResponse(ok: false, error: "provide both --cols and --rows")) + #expect(colsWithoutRows == ControlResponse(ok: false, error: "session.overlay.open: provide both --cols and --rows")) #expect(percentTooBig == ControlResponse(ok: false, error: "session.overlay.open: --size-percent must be 1...100")) #expect(percentTooSmall == ControlResponse(ok: false, error: "session.overlay.open: --size-percent must be 1...100")) #expect(percentAndCells == ControlResponse( ok: false, error: "session.overlay.open: use only one of --size-percent or --cols/--rows")) - #expect(badCells == ControlResponse(ok: false, error: "--cols and --rows must be >= 1")) + #expect(badCells == ControlResponse(ok: false, error: "session.overlay.open: --cols and --rows must be >= 1")) #expect(unknownAnchor == ControlResponse( ok: false, error: "unknown anchor: sideways (top-left|top|top-right|left|center|right|bottom-left|bottom|bottom-right)")) @@ -1387,7 +1387,7 @@ struct ControlDispatcherTests { #expect(tooBig == ControlResponse(ok: false, error: "session.overlay.resize: --size-percent must be 1...100")) #expect(tooSmall == ControlResponse(ok: false, error: "session.overlay.resize: --size-percent must be 1...100")) #expect(fullAndAnchor == ControlResponse(ok: false, error: "--full cannot be combined with --anchor")) - #expect(colsWithoutRows == ControlResponse(ok: false, error: "provide both --cols and --rows")) + #expect(colsWithoutRows == ControlResponse(ok: false, error: "session.overlay.resize: provide both --cols and --rows")) #expect(actions.calls.isEmpty) } diff --git a/agtermCore/Tests/agtermCoreTests/OverlayArgsTests.swift b/agtermCore/Tests/agtermCoreTests/OverlayArgsTests.swift index 2ef01942..610f6155 100644 --- a/agtermCore/Tests/agtermCoreTests/OverlayArgsTests.swift +++ b/agtermCore/Tests/agtermCoreTests/OverlayArgsTests.swift @@ -50,14 +50,14 @@ struct OverlayArgsTests { @Test func sizeRejectsColsWithoutRows() { let colsOnly = OverlayArgs.parseSize(sizePercent: nil, cols: 40, rows: nil, full: false, command: .open) - #expect(colsOnly == .invalid("provide both --cols and --rows")) - let rowsOnly = OverlayArgs.parseSize(sizePercent: nil, cols: nil, rows: 12, full: false, command: .open) - #expect(rowsOnly == .invalid("provide both --cols and --rows")) + #expect(colsOnly == .invalid("session.overlay.open: provide both --cols and --rows")) + let rowsOnly = OverlayArgs.parseSize(sizePercent: nil, cols: nil, rows: 12, full: false, command: .resize) + #expect(rowsOnly == .invalid("session.overlay.resize: provide both --cols and --rows")) } @Test func sizeRejectsNonPositiveCells() { let result = OverlayArgs.parseSize(sizePercent: nil, cols: 0, rows: 12, full: false, command: .open) - #expect(result == .invalid("--cols and --rows must be >= 1")) + #expect(result == .invalid("session.overlay.open: --cols and --rows must be >= 1")) } // MARK: - parseAnchor @@ -76,4 +76,68 @@ struct OverlayArgsTests { let result = OverlayArgs.parseAnchor("middle") #expect(result == .invalid("unknown anchor: middle (top-left|top|top-right|left|center|right|bottom-left|bottom|bottom-right)")) } + + // MARK: - resolveOpen + + @Test func openResolvesFullCenterWhenNothingSet() { + #expect(OverlayArgs.resolveOpen(sizePercent: nil, cols: nil, rows: nil, anchor: nil) + == .resolved(size: .full, anchor: .center)) + } + + @Test func openResolvesPercentWithAnchor() { + #expect(OverlayArgs.resolveOpen(sizePercent: 50, cols: nil, rows: nil, anchor: "bottom-right") + == .resolved(size: .percent(50), anchor: .bottomRight)) + } + + @Test func openResolvesCellsWithAnchor() { + #expect(OverlayArgs.resolveOpen(sizePercent: nil, cols: 40, rows: 12, anchor: "top-left") + == .resolved(size: .cells(cols: 40, rows: 12), anchor: .topLeft)) + } + + @Test func openRejectsAnchorWithoutFloating() { + #expect(OverlayArgs.resolveOpen(sizePercent: nil, cols: nil, rows: nil, anchor: "top-left") + == .invalid("--anchor requires a floating overlay: use --size-percent or --cols/--rows")) + } + + @Test func openPropagatesSizeAndAnchorRejections() { + #expect(OverlayArgs.resolveOpen(sizePercent: 0, cols: nil, rows: nil, anchor: nil) + == .invalid("session.overlay.open: --size-percent must be 1...100")) + #expect(OverlayArgs.resolveOpen(sizePercent: nil, cols: 40, rows: nil, anchor: nil) + == .invalid("session.overlay.open: provide both --cols and --rows")) + #expect(OverlayArgs.resolveOpen(sizePercent: 50, cols: nil, rows: nil, anchor: "middle") + == .invalid("unknown anchor: middle (top-left|top|top-right|left|center|right|bottom-left|bottom|bottom-right)")) + } + + // MARK: - resolveResize + + @Test func resizeResolvesAnchorOnlyKeepingSize() { + #expect(OverlayArgs.resolveResize(sizePercent: nil, cols: nil, rows: nil, full: false, anchor: "top") + == .resolved(size: nil, anchor: .top)) + } + + @Test func resizeResolvesPercentFullAndCells() { + #expect(OverlayArgs.resolveResize(sizePercent: 60, cols: nil, rows: nil, full: false, anchor: nil) + == .resolved(size: .percent(60), anchor: nil)) + #expect(OverlayArgs.resolveResize(sizePercent: nil, cols: nil, rows: nil, full: true, anchor: nil) + == .resolved(size: .full, anchor: nil)) + #expect(OverlayArgs.resolveResize(sizePercent: nil, cols: 40, rows: 12, full: false, anchor: "left") + == .resolved(size: .cells(cols: 40, rows: 12), anchor: .left)) + } + + @Test func resizeRejectsFullWithAnchor() { + #expect(OverlayArgs.resolveResize(sizePercent: nil, cols: nil, rows: nil, full: true, anchor: "top-left") + == .invalid("--full cannot be combined with --anchor")) + } + + @Test func resizeRejectsNothingSet() { + #expect(OverlayArgs.resolveResize(sizePercent: nil, cols: nil, rows: nil, full: false, anchor: nil) + == .invalid("session.overlay.resize requires a size (--full, --size-percent, --cols/--rows) or --anchor")) + } + + @Test func resizePropagatesSizeAndAnchorRejections() { + #expect(OverlayArgs.resolveResize(sizePercent: nil, cols: 5, rows: nil, full: false, anchor: nil) + == .invalid("session.overlay.resize: provide both --cols and --rows")) + #expect(OverlayArgs.resolveResize(sizePercent: nil, cols: nil, rows: nil, full: false, anchor: "bogus") + == .invalid("unknown anchor: bogus (top-left|top|top-right|left|center|right|bottom-left|bottom|bottom-right)")) + } } diff --git a/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift b/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift index 15b3ca24..61ae1e73 100644 --- a/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift +++ b/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift @@ -795,11 +795,11 @@ struct CommandsTests { #expect(validationMessage(["session", "overlay", "open", "htop", "--size-percent", "101"]) == "session.overlay.open: --size-percent must be 1...100") #expect(validationMessage(["session", "overlay", "open", "htop", "--cols", "80"]) - == "provide both --cols and --rows") + == "session.overlay.open: provide both --cols and --rows") #expect(validationMessage(["session", "overlay", "open", "htop", "--rows", "24"]) - == "provide both --cols and --rows") + == "session.overlay.open: provide both --cols and --rows") #expect(validationMessage(["session", "overlay", "open", "htop", "--cols", "0", "--rows", "24"]) - == "--cols and --rows must be >= 1") + == "session.overlay.open: --cols and --rows must be >= 1") #expect(validationMessage(["session", "overlay", "open", "htop", "--size-percent", "50", "--cols", "5", "--rows", "5"]) == "session.overlay.open: use only one of --size-percent or --cols/--rows") #expect(validationMessage(["session", "overlay", "open", "htop", "--size-percent", "50", "--anchor", "bogus"]) @@ -834,7 +834,7 @@ struct CommandsTests { #expect(validationMessage(["session", "overlay", "resize", "--full", "--cols", "5", "--rows", "5"]) == "session.overlay.resize: use only one of --full, --size-percent, or --cols/--rows") #expect(validationMessage(["session", "overlay", "resize", "--cols", "5"]) - == "provide both --cols and --rows") + == "session.overlay.resize: provide both --cols and --rows") #expect(validationMessage(["session", "overlay", "resize", "--size-percent", "150"]) == "session.overlay.resize: --size-percent must be 1...100") #expect(validationMessage(["session", "overlay", "resize", "--anchor", "bogus"]) diff --git a/agtermUITests/ControlOverlaySplitUITests.swift b/agtermUITests/ControlOverlaySplitUITests.swift index 8c6f6e95..100078d5 100644 --- a/agtermUITests/ControlOverlaySplitUITests.swift +++ b/agtermUITests/ControlOverlaySplitUITests.swift @@ -786,11 +786,13 @@ final class ControlOverlaySplitUITests: ControlAPITestCase { let id = try activeSessionID() try resizeWindow(width: 1100, height: 750) - // a FULL overlay fills the detail area exactly (no anchor inset), so its marker frame is the pane the - // floating margin is measured against. - let full = try sendOverlayOpen(target: id, command: "cat", args: [:]) - XCTAssertEqual(full["ok"] as? Bool, true, "full overlay open should succeed: \(full)") - XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the full overlay should be up") + // a 100% FLOATING overlay fills the detail area exactly (centered, so no anchor inset), so its marker + // frame is the pane the floating margin is measured against. (A FULL overlay no longer carries the + // `overlay-floating-panel` marker — that id is floating-only — so the reference is taken from a + // full-size FLOATING panel instead.) + let reference = try sendOverlayOpen(target: id, command: "cat", args: ["sizePercent": 100]) + XCTAssertEqual(reference["ok"] as? Bool, true, "reference overlay open should succeed: \(reference)") + XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the reference overlay should be up") let panel = app.descendants(matching: .any).matching(identifier: "overlay-floating-panel").firstMatch XCTAssertTrue(panel.waitForExistence(timeout: 10), "the overlay panel should be in the a11y tree") @@ -913,7 +915,7 @@ final class ControlOverlaySplitUITests: ControlAPITestCase { // only one of --cols/--rows is a usage error. let colsOnly = try sendOverlayOpen(target: id, command: "cat", args: ["cols": 40]) XCTAssertEqual(colsOnly["ok"] as? Bool, false, "cols without rows should fail: \(colsOnly)") - XCTAssertEqual(colsOnly["error"] as? String, "provide both --cols and --rows", "\(colsOnly)") + XCTAssertEqual(colsOnly["error"] as? String, "session.overlay.open: provide both --cols and --rows", "\(colsOnly)") // an out-of-range percent now hard-errors on OPEN too (Decision 3 — open validation matches resize). let badPercent = try sendOverlayOpen(target: id, command: "cat", args: ["sizePercent": 150]) From 8733135cc79db92583e3078c32bfe838a04aa916 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 03:21:09 -0500 Subject: [PATCH 16/20] feat: report terminal content area size in tree --- .claude/rules/control-api.md | 22 ++++++++++ README.md | 2 +- agterm/Control/ControlServer.swift | 20 +++++++++ agterm/Ghostty/GhosttySurfaceView.swift | 14 +++++++ agterm/Resources/agent-skill/SKILL.md | 6 ++- agterm/Resources/agent-skill/examples.md | 12 ++++++ agterm/Resources/agent-skill/reference.md | 11 ++++- .../agtermCore/AppStore+ControlTree.swift | 7 +++- .../Sources/agtermCore/ControlProtocol.swift | 20 ++++++++- .../agtermCoreTests/AppStorePaneTests.swift | 15 +++++++ .../ControlProtocolTests.swift | 25 +++++++++++ .../ControlOverlaySplitUITests.swift | 41 +++++++++++++++++++ ...20260722-overlay-anchor-and-cell-sizing.md | 29 +++++++++++++ site/commands.html | 10 ++++- site/docs.html | 5 +++ 15 files changed, 232 insertions(+), 7 deletions(-) diff --git a/.claude/rules/control-api.md b/.claude/rules/control-api.md index 6c4ba1af..1ef656b1 100644 --- a/.claude/rules/control-api.md +++ b/.claude/rules/control-api.md @@ -1335,6 +1335,28 @@ paths: It is the READ side of `session.focus` (write-only), so a script can record which pane was focused and restore it via `session.focus --pane left|right` (a `false` is emitted, distinct from the nil no-split case — the left pane being focused is real state). + It ALSO surfaces `canvasCols`/`canvasRows` on each node — the session's terminal CONTENT AREA (the overlay + "canvas") in whole cells at the BASE font, the exact coordinate system `session.overlay.open --cols/--rows` + land in, so a script can size a floating overlay as a fraction of the canvas + (`--rows round(0.30*canvasRows)` is a 30%-tall strip). + It has NO write command — it is a pure DERIVED read (the max whole-cell grid that fills the detail area), + so it is NOT a state-mutating read-back pair; it is supplied APP-SIDE like `fontSize` + (the host-free tree can't read a surface), via a `canvasGrid: (Session) -> (cols: Int, rows: Int)?` closure + on `AppStore.controlTree` that `ControlServer.buildTree` fills from `GhosttySurfaceView.liveGrid()` + (`ghostty_surface_size().columns/.rows` — UNITLESS cell counts, so unlike the overlay cell metrics there is + NO px→point Retina conversion). + It is SPLIT-AGNOSTIC — the whole detail region an overlay fills, taken as ONE area: + `canvasRows` is the full height (`ControlServer.sessionCanvasGrid` reads the main pane's rows, since a + left/right split keeps full height), and `canvasCols` is the full width — the primary pane's columns when + NOT split, the SUM of both panes' columns when the split is shown side-by-side (`session.isSplit`), and the + split pane's own full-width grid for a hidden split maximized on it (`hasSplit && splitFocused`). + A side-by-side split `canvasCols` slightly UNDERESTIMATES a single full-width surface (the thin divider and + per-pane inner padding are uncounted), exact for the unsplit case — the "as-close-as-possible for split, + exact unsplit" trade documented in the field's godoc. + Because the main surface's live font tracks `session.fontSize` (cmd-+/-) and a fresh overlay is created + with the SAME `session.fontSize`, the grid measured at the main pane's live font equals the base overlay + font by construction — so `overlay open --cols canvasCols --rows canvasRows` fills the canvas. + Omitted when no surface is realized (the overlay-metrics nil convention). `tree` ALSO carries, at the TOP level (alongside `idleMs`/`autoFollowMs`), `sidebarVisible` — the read side of the write-only `sidebar` command (per-window sidebar visibility), populated LIVE from the projected window's store in `AppStore.controlTree`. diff --git a/README.md b/README.md index 9f3bd8c2..464f9193 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,7 @@ By default an overlay opens on its `--target` without switching the active sessi `--block` runs the program in the overlay (rendering normally) and blocks until it exits, then exits with the program's status — useful in a script that needs the outcome of an interactive run. The program's output stays its own concern: a TUI writes its result to its own file (for example `revdiff --output=…`) which the script reads, while `--block` reports only the exit status (the overlay never captures stdout). `--block` can't be combined with `--wait`; `session overlay result` reports the last overlay's exit status on demand for a manual open → poll flow. -By default the overlay fills the pane, drawn translucent, hiding the session beneath it. For a *floating* variant instead — an opaque, framed panel with the session still visible around it — size it one of two ways: `--size-percent N` (1–100) sizes it to N% of the pane in both dimensions, or `--cols N --rows M` sizes it to an exact terminal grid. Both are adaptive — a `--cols/--rows` request larger than the pane is clamped down to the whole cells that fit, while a `--size-percent` panel is a fraction of the pane and so is never larger than it — and the grid that actually rendered is read back on `agtermctl tree` (as `overlayColsApplied`/`overlayRowsApplied`). `--anchor POS` places a floating panel at one of nine positions — `top-left`, `top`, `top-right`, `left`, `center` (default), `right`, `bottom-left`, `bottom`, `bottom-right` — inset one line-height off each anchored edge so it is not flush with the border; `center` keeps the original dead-center placement. A floating overlay is useful for a small auxiliary program (a picker, a monitor) that you want floating over — not replacing — the terminal you're working in. It composes with `--block` (a blocking floating overlay). Like a full overlay it opens in the background and runs even when the target is not active; pass `--follow` to switch the user to the target as it opens. `session overlay resize` re-anchors or re-sizes it in place: `--anchor` alone moves the panel keeping its size, `--full` reverts to full-pane (which preserves the anchor for a later re-float, and cannot be combined with `--anchor`), and an out-of-range `--size-percent` is a hard error on both open and resize. +By default the overlay fills the pane, drawn translucent, hiding the session beneath it. For a *floating* variant instead — an opaque, framed panel with the session still visible around it — size it one of two ways: `--size-percent N` (1–100) sizes it to N% of the pane in both dimensions, or `--cols N --rows M` sizes it to an exact terminal grid. Both are adaptive — a `--cols/--rows` request larger than the pane is clamped down to the whole cells that fit, while a `--size-percent` panel is a fraction of the pane and so is never larger than it — and the grid that actually rendered is read back on `agtermctl tree` (as `overlayColsApplied`/`overlayRowsApplied`). To size relative to the pane, `agtermctl tree` also reports `canvasCols`/`canvasRows` — the terminal content area (the overlay "canvas") in whole cells at the base font, the exact coordinate system `--cols/--rows` land in — so a script can compute a concrete grid as a fraction of the canvas (for example a 30%-tall top strip: `--cols $canvasCols --rows $((canvasRows*30/100)) --anchor top`). `--anchor POS` places a floating panel at one of nine positions — `top-left`, `top`, `top-right`, `left`, `center` (default), `right`, `bottom-left`, `bottom`, `bottom-right` — inset one line-height off each anchored edge so it is not flush with the border; `center` keeps the original dead-center placement. A floating overlay is useful for a small auxiliary program (a picker, a monitor) that you want floating over — not replacing — the terminal you're working in. It composes with `--block` (a blocking floating overlay). Like a full overlay it opens in the background and runs even when the target is not active; pass `--follow` to switch the user to the target as it opens. `session overlay resize` re-anchors or re-sizes it in place: `--anchor` alone moves the panel keeping its size, `--full` reverts to full-pane (which preserves the anchor for a later re-float, and cannot be combined with `--anchor`), and an out-of-range `--size-percent` is a hard error on both open and resize. A session's terminal surface is created lazily — it does not exist until the session has been shown at least once. Injecting text into a never-shown session therefore fails with `session not realized` unless you pass `--select`, which selects the session (realizing its surface) before injecting: diff --git a/agterm/Control/ControlServer.swift b/agterm/Control/ControlServer.swift index 2edcf731..1babc062 100644 --- a/agterm/Control/ControlServer.swift +++ b/agterm/Control/ControlServer.swift @@ -533,6 +533,7 @@ final class ControlServer { fontSize: { ($0.addressableSurface as? GhosttySurfaceView)?.currentFontSize() }, splitFontSize: { ($0.splitSurface as? GhosttySurfaceView)?.currentFontSize() }, scratchFontSize: { ($0.scratchSurface as? GhosttySurfaceView)?.currentFontSize() }, + canvasGrid: { self.sessionCanvasGrid($0) }, quickVisible: { windowID.flatMap { QuickTerminalRegistry.shared.controller(for: $0)?.isVisible } ?? false }, zoomedSurface: { windowID.flatMap { TerminalZoomRegistry.shared.controller(for: $0)?.target?.controlID } }, dashboardMembers: { @@ -558,6 +559,25 @@ final class ControlServer { ) } + /// The session's terminal content-area grid (columns × rows) for the `tree` `canvasCols`/`canvasRows` + /// read-back — the whole detail region a floating overlay fills, measured at the session's base font from + /// the live `ghostty_surface_size()` grid (unitless cell counts, no Retina conversion). The primary pane's + /// grid when NOT split; the split pane's columns are ADDED when the split is shown side-by-side (the full + /// detail width, split-agnostic). A hidden split maximized on the split pane reports that pane's own + /// full-width grid. nil when no surface is realized (the overlay-metrics nil convention). + private func sessionCanvasGrid(_ session: Session) -> (cols: Int, rows: Int)? { + let main = (session.surface as? GhosttySurfaceView)?.liveGrid() + let split = (session.splitSurface as? GhosttySurfaceView)?.liveGrid() + if session.isSplit, let main, let split { + return (cols: main.cols + split.cols, rows: main.rows) + } + // a hidden (maximized-one-pane) split focused on the split pane: that pane fills the detail width. + if session.hasSplit, session.splitFocused, let split { + return split + } + return main + } + /// Creates a session in `workspaceID` of `store` with the `session.new` args (cwd default $HOME, /// optional command/name), focuses it when it lands in the frontmost window (so a keymap `session new` /// opens focused like the GUI New Session; a background `--window` target keeps focus), and returns the diff --git a/agterm/Ghostty/GhosttySurfaceView.swift b/agterm/Ghostty/GhosttySurfaceView.swift index bad4e4e9..5e5dbcd7 100644 --- a/agterm/Ghostty/GhosttySurfaceView.swift +++ b/agterm/Ghostty/GhosttySurfaceView.swift @@ -519,6 +519,20 @@ final class GhosttySurfaceView: NSView, TerminalSurface { let cols: Int, rows: Int } + /// The surface's live terminal grid (columns × rows) from `ghostty_surface_size`, or nil when the surface + /// isn't realized or has no size yet. Unlike `overlayPixelMetrics`, this returns the raw cell COUNTS — a + /// grid count is unitless, so there is no px→point (Retina) conversion. Feeds the `tree` + /// `canvasCols`/`canvasRows` content-area read-back; measured at the surface's current font, which tracks + /// the session's base overlay font (both follow `session.fontSize`). + func liveGrid() -> (cols: Int, rows: Int)? { + guard let surface else { return nil } + let size = ghostty_surface_size(surface) + let cols = Int(size.columns) + let rows = Int(size.rows) + guard cols > 0, rows > 0 else { return nil } + return (cols: cols, rows: rows) + } + /// Reads the overlay surface's live pixel metrics from `ghostty_surface_size`. nil when the surface isn't /// realized or has no cell size yet. The caller converts px→points via `backingScaleFactor` before handing /// them to the host-free layout resolver (which works in the point space `GeometryReader`/`WindowGeometry.Size` diff --git a/agterm/Resources/agent-skill/SKILL.md b/agterm/Resources/agent-skill/SKILL.md index f436c9d8..dadc54f8 100644 --- a/agterm/Resources/agent-skill/SKILL.md +++ b/agterm/Resources/agent-skill/SKILL.md @@ -171,7 +171,11 @@ session that has a split — shown or hidden; omitted when there's no split or t the default 0.5) — the read side of `session resize`, record it to restore the exact divider), `splitFocused` (which pane holds focus in a session that has a split — `true` = split/right, `false` = main/left; omitted -when there's no split; the read side of `session focus`, record it to restore focus), and `surfaces` +when there's no split; the read side of `session focus`, record it to restore focus), +`canvasCols`/`canvasRows` (the session's terminal CONTENT AREA — the "overlay canvas" — in whole cells at +the base font, so a script sizes a floating overlay as a fraction of the canvas: `overlay open --cols +$canvasCols --rows $((canvasRows*30/100)) --anchor top` is a 30%-tall top strip; split-agnostic — full +height, full width across the whole detail area; omitted when no surface is realized), and `surfaces` (`id`, `kind`, `active`, `visible`) for `surface zoom`. The tree top level carries `zoomedSurface` (the control id of the currently zoomed surface, omitted when nothing is zoomed — the read side of `surface zoom`, so a script can check the zoom state and record-then-restore). It also carries the read diff --git a/agterm/Resources/agent-skill/examples.md b/agterm/Resources/agent-skill/examples.md index 8960ed48..cb3acadf 100644 --- a/agterm/Resources/agent-skill/examples.md +++ b/agterm/Resources/agent-skill/examples.md @@ -237,6 +237,18 @@ agtermctl session overlay open "zsh -lc 'htop'" --target "$AGTERM_SESSION_ID" -- agtermctl tree --json | jq '.. | objects | select(.overlay == true) | {overlayCols, overlayRows, overlayColsApplied, overlayRowsApplied, overlayAnchor}' ``` +Size an overlay as a FRACTION of the canvas: read `canvasCols`/`canvasRows` off `tree` (the terminal +content area in cells at the base font — the coordinate system `--cols/--rows` land in), then compute a +concrete grid. A 30%-tall top strip spanning the full width: + +```bash +read -r cols rows < <(agtermctl tree --json \ + | jq -r --arg id "$AGTERM_SESSION_ID" '.result.tree.workspaces[].sessions[] | select(.id|ascii_downcase == ($id|ascii_downcase)) | "\(.canvasCols) \(.canvasRows)"') +# a top strip 30% of the height, full width, parked at the top +agtermctl session overlay open "zsh -lc 'tail -f /var/log/system.log'" --target "$AGTERM_SESSION_ID" \ + --cols "$cols" --rows "$(( rows * 30 / 100 ))" --anchor top +``` + Re-anchor an open floating panel in place — no size change, program keeps running (`--anchor` alone keeps the current size; a size mode alone keeps the current anchor): diff --git a/agterm/Resources/agent-skill/reference.md b/agterm/Resources/agent-skill/reference.md index 89b7ac04..c2fc116f 100644 --- a/agterm/Resources/agent-skill/reference.md +++ b/agterm/Resources/agent-skill/reference.md @@ -134,7 +134,16 @@ when zero), `fontSize`/`splitFontSize`/`scratchFontSize` (the LIVE font size in the read side of `font --pane`; each omitted when that pane isn't realized. `fontSize` tracks the default/left target (the main pane, or the promoted split survivor once the primary exits — the same pane `font --pane left` writes); only the main pane's size survives a relaunch, so the split/scratch sizes and a -promoted survivor are live-only — read them back here rather than from the snapshot), and `surfaces` (array +promoted survivor are live-only — read them back here rather than from the snapshot), +`canvasCols`/`canvasRows` (the session's terminal CONTENT AREA — the "overlay canvas" — in whole cells at +the session's BASE font, the coordinate system `overlay open --cols/--rows` land in; so a script sizes a +floating overlay as a fraction of the canvas: `overlay open --cols $canvasCols --rows $canvasRows` +fills it, `--rows $((canvasRows*30/100))` is 30% of the height. It is the WHOLE detail region an overlay +fills (everything except the sidebar and title bar) taken as ONE area, split-AGNOSTIC: `canvasRows` is the +full height (a left/right split keeps full height) and `canvasCols` the full width — the primary pane's +columns unsplit, the SUM of both panes' columns when split side-by-side (a split `canvasCols` slightly +underestimates a single full-width surface, since the divider and per-pane padding are uncounted; exact +unsplit). Omitted when no surface is realized), and `surfaces` (array of `{id, kind, active, visible}` where `kind` is `left`|`right`|`scratch`|`overlay`). The surface `id` is the address for `surface zoom`; hidden-but-alive split/scratch surfaces are included so a script can zoom them without changing split/scratch visibility first. Caveat: `active`/`visible` diff --git a/agtermCore/Sources/agtermCore/AppStore+ControlTree.swift b/agtermCore/Sources/agtermCore/AppStore+ControlTree.swift index 2d17dce9..de4fb0ca 100644 --- a/agtermCore/Sources/agtermCore/AppStore+ControlTree.swift +++ b/agtermCore/Sources/agtermCore/AppStore+ControlTree.swift @@ -8,6 +8,7 @@ extension AppStore { fontSize: (Session) -> Double? = { _ in nil }, splitFontSize: (Session) -> Double? = { _ in nil }, scratchFontSize: (Session) -> Double? = { _ in nil }, + canvasGrid: (Session) -> (cols: Int, rows: Int)? = { _ in nil }, quickVisible: () -> Bool? = { nil }, zoomedSurface: () -> String? = { nil }, dashboardMembers: () -> [String]? = { nil }, @@ -40,6 +41,9 @@ extension AppStore { let overlayColsApplied = session.floatingOverlayActive ? session.overlayAppliedCols : nil let overlayRowsApplied = session.floatingOverlayActive ? session.overlayAppliedRows : nil let overlayAnchor = session.overlayActive ? session.overlayAnchor.rawValue : nil + // the terminal content area (overlay canvas) in cells at the base font — supplied app-side + // (the host-free tree can't read a surface), omitted when no surface is realized. + let canvas = canvasGrid(session) let surfaces = TerminalZoomSurface.allCases.compactMap { surface -> ControlSurfaceNode? in guard surface.isAvailable(in: session) else { return nil } let id = TerminalSurfaceID(sessionID: session.id, surface: surface).rawValue @@ -74,7 +78,8 @@ extension AppStore { fontSize: fontSize(session), splitFontSize: splitFontSize(session), scratchFontSize: scratchFontSize(session), - surfaces: surfaces) + surfaces: surfaces, + canvasCols: canvas?.cols, canvasRows: canvas?.rows) } return ControlWorkspaceNode(id: workspace.id.uuidString, name: workspace.name, active: workspace.id == activeWorkspaceID, diff --git a/agtermCore/Sources/agtermCore/ControlProtocol.swift b/agtermCore/Sources/agtermCore/ControlProtocol.swift index 0b5b9b3e..32bb6bb4 100644 --- a/agtermCore/Sources/agtermCore/ControlProtocol.swift +++ b/agtermCore/Sources/agtermCore/ControlProtocol.swift @@ -468,6 +468,21 @@ public struct ControlSessionNode: Codable, Sendable, Equatable { /// addition uses, keeping Codable synthesized). Hidden-but-alive surfaces are included so control /// clients can zoom them without mutating split/scratch visibility first. public let surfaces: [ControlSurfaceNode]? + /// The session's terminal CONTENT AREA (the overlay "canvas") in whole cells at the session's BASE font + /// — the font a fresh `session.overlay.open` uses — so a script can size a floating overlay as a + /// fraction of the canvas: `overlay open --cols canvasCols --rows canvasRows` fills it, `--rows + /// round(0.30 * canvasRows)` is 30% of the height. It is the WHOLE detail region an overlay fills + /// (everything except the sidebar and title bar) taken as ONE area, SPLIT-AGNOSTIC: `canvasRows` is the + /// full height in cells (a left/right split keeps full height) and `canvasCols` is the full width across + /// the whole detail area — the primary pane's columns when NOT split, and the SUM of the two panes' + /// columns when the split is shown side-by-side. Exact for the unsplit case; for a side-by-side split + /// the thin divider and per-pane inner padding are not counted, so `canvasCols` slightly UNDERESTIMATES a + /// single full-width surface (best-effort — it never over-reports the full width). nil/omitted when no + /// surface is realized / the grid is unknown. Measured app-side from the live `ghostty_surface_size()` + /// grid (unitless cell counts — no Retina conversion), which tracks the main pane's cmd-+/- zoom exactly + /// as the base overlay font does. + public let canvasCols: Int? + public let canvasRows: Int? public init(id: String, name: String, cwd: String, title: String? = nil, active: Bool, split: Bool, splitRatio: Double? = nil, splitFocused: Bool? = nil, @@ -481,7 +496,8 @@ public struct ControlSessionNode: Codable, Sendable, Equatable { statusPane: String? = nil, statusBlink: Bool? = nil, statusColor: String? = nil, background: BackgroundWatermark? = nil, unseen: Int? = nil, fontSize: Double? = nil, splitFontSize: Double? = nil, scratchFontSize: Double? = nil, - surfaces: [ControlSurfaceNode]? = nil) { + surfaces: [ControlSurfaceNode]? = nil, + canvasCols: Int? = nil, canvasRows: Int? = nil) { self.id = id self.name = name self.cwd = cwd @@ -514,6 +530,8 @@ public struct ControlSessionNode: Codable, Sendable, Equatable { self.splitFontSize = splitFontSize self.scratchFontSize = scratchFontSize self.surfaces = surfaces + self.canvasCols = canvasCols + self.canvasRows = canvasRows } } diff --git a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift index 4e8a683e..e5e5be2d 100644 --- a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift +++ b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift @@ -755,6 +755,21 @@ struct AppStorePaneTests { #expect(node.scratchFontSize == 11) } + @Test func controlTreeThreadsCanvasGridFromClosure() throws { + let store = makeStore() + let ws = store.addWorkspace(name: "work") + _ = store.addSession(toWorkspace: ws.id, cwd: "/a")! + // default closure (host-free / no realized surface): both canvas fields omitted (nil). + var node = try #require(store.controlTree().workspaces[0].sessions.first) + #expect(node.canvasCols == nil) + #expect(node.canvasRows == nil) + // the host supplies the content-area grid via the closure (app reads it off the live surface). + node = try #require(store.controlTree(canvasGrid: { _ in (cols: 132, rows: 43) }) + .workspaces[0].sessions.first) + #expect(node.canvasCols == 132) + #expect(node.canvasRows == 43) + } + @Test func controlTreeFontSizeReadsPromotedSurvivorViaAddressableSurface() throws { // regression: after the primary pane exits, the fontSize read-back must resolve through // addressableSurface — the same surface the font default/left WRITE path targets. With true diff --git a/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift b/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift index eef347c2..92d40737 100644 --- a/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift +++ b/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift @@ -714,6 +714,31 @@ struct ControlProtocolTests { #expect(decoded.overlayAnchor == nil) } + @Test func treeSessionNodeRoundTripsWithCanvasGrid() throws { + // the terminal content area (overlay canvas) in cells at the base font, so a script can size a + // floating overlay as a fraction of the canvas (`--cols canvasCols --rows canvasRows` fills it). + let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false, + canvasCols: 132, canvasRows: 43) + let response = ControlResponse(ok: true, result: ControlResult(tree: ControlTree( + workspaces: [ControlWorkspaceNode(id: "w1", name: "work", active: true, sessions: [session])]))) + let decoded = try roundTrip(response) + #expect(decoded == response) + let node = decoded.result?.tree?.workspaces.first?.sessions.first + #expect(node?.canvasCols == 132) + #expect(node?.canvasRows == 43) + } + + @Test func treeSessionNodeOmitsCanvasGridWhenNil() throws { + // no realized surface (grid unknown) — both keys must be omitted, not emitted as null. + let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false) + let json = String(data: try JSONEncoder().encode(session), encoding: .utf8) ?? "" + #expect(!json.contains("canvasCols"), "a nil canvas grid must omit canvasCols; got \(json)") + #expect(!json.contains("canvasRows"), "a nil canvas grid must omit canvasRows; got \(json)") + let decoded = try JSONDecoder().decode(ControlSessionNode.self, from: Data(json.utf8)) + #expect(decoded.canvasCols == nil) + #expect(decoded.canvasRows == nil) + } + @Test func treeSessionNodeRoundTripsWithRestoreCommand() throws { // the read side of session.restore: the pinned shell line rides the tree node per pane so a script // can record what is pinned, change it, and restore the original. diff --git a/agtermUITests/ControlOverlaySplitUITests.swift b/agtermUITests/ControlOverlaySplitUITests.swift index 100078d5..7eb33f1d 100644 --- a/agtermUITests/ControlOverlaySplitUITests.swift +++ b/agtermUITests/ControlOverlaySplitUITests.swift @@ -773,6 +773,39 @@ final class ControlOverlaySplitUITests: ControlAPITestCase { XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)") } + // tree canvasCols/canvasRows report the session's terminal CONTENT AREA in cells at the base font, so a + // script can size a floating overlay as a fraction of the canvas. Sizing an overlay to the WHOLE canvas + // (`--cols canvasCols --rows canvasRows`) must FILL it: the realized (applied) grid matches + // canvasCols/canvasRows within the whole-cell padding drift. A canvas reported ~2x off (a Retina cell-count + // miss) would size the overlay ~2x off and this comparison would catch it. + func testTreeReportsCanvasGridAndOverlayFillsIt() throws { + let id = try activeSessionID() + try resizeWindow(width: 1100, height: 750) + + let node = try XCTUnwrap(pollCanvasGrid(id: id, timeout: 12), + "the tree should report the terminal content area (canvasCols/canvasRows)") + let canvasCols = try XCTUnwrap(node["canvasCols"] as? Int, "canvasCols should be present: \(node)") + let canvasRows = try XCTUnwrap(node["canvasRows"] as? Int, "canvasRows should be present: \(node)") + XCTAssertGreaterThan(canvasCols, 0, "canvasCols should be a real grid: \(node)") + XCTAssertGreaterThan(canvasRows, 0, "canvasRows should be a real grid: \(node)") + + // open an overlay sized to the whole canvas; the realized grid should fill it (no clamp headroom left). + let open = try sendOverlayOpen(target: id, command: "cat", args: ["cols": canvasCols, "rows": canvasRows]) + XCTAssertEqual(open["ok"] as? Bool, true, "overlay open filling the canvas should succeed: \(open)") + XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the overlay should be up") + + let applied = try XCTUnwrap(pollOverlayApplied(id: id, timeout: 12), "the tree should report the realized grid") + let appliedCols = try XCTUnwrap(applied["overlayColsApplied"] as? Int, "applied cols should be present: \(applied)") + let appliedRows = try XCTUnwrap(applied["overlayRowsApplied"] as? Int, "applied rows should be present: \(applied)") + XCTAssertTrue(abs(appliedCols - canvasCols) <= 2, + "an overlay sized to the canvas (\(canvasCols) cols) should fill it: applied \(appliedCols)") + XCTAssertTrue(abs(appliedRows - canvasRows) <= 2, + "an overlay sized to the canvas (\(canvasRows) rows) should fill it: applied \(appliedRows)") + + let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#) + XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)") + } + // The floating panel follows its corner anchor AND sits ONE LINE-HEIGHT off the anchored edges (the anchor // margin), never flush against the border. The detail area is captured from a FULL overlay (its panel fills // the pane exactly with NO inset) via the same `overlay-floating-panel` marker; the floating panel's inset @@ -1087,6 +1120,14 @@ final class ControlOverlaySplitUITests: ControlAPITestCase { } } + /// Polls the `tree` node for `id` until BOTH `canvasCols` and `canvasRows` are present (the content-area + /// grid is reported once the session surface is realized), returning the node, or nil on timeout. + private func pollCanvasGrid(id: String, timeout: TimeInterval) -> [String: Any]? { + pollOverlayNode(id: id, timeout: timeout) { + ($0["canvasCols"] as? Int) != nil && ($0["canvasRows"] as? Int) != nil + } + } + /// Resizes the frontmost window to `width`x`height` and waits until the on-screen frame reflects it, so a /// subsequently opened overlay sizes against a known pane. private func resizeWindow(width: Int, height: Int) throws { diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md index 95e81394..3d7c036f 100644 --- a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md +++ b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md @@ -214,6 +214,35 @@ maintainer decisions are folded in below: ZStack child count stays constant per the NSSplitView-overrun invariant), applied AFTER the a11y marker so the marker still reports the panel's own frame. Applies to ANY floating overlay (percent or cells). +➕ **Follow-on addition (post-completion scope): canvas grid read-back on `tree`.** +A separate, maintainer-confirmed addition beyond the original ten tasks: report the terminal CONTENT AREA +size in cells on each `tree` session node so a script can compute concrete `overlay open --cols/--rows` as a +fraction of the canvas (`--rows canvasRows` fills the height, `--rows round(0.30*canvasRows)` is 30%). +- Two new `ControlSessionNode` fields `canvasCols`/`canvasRows` (`ControlProtocol.swift`), optional + + omit-when-nil, measured at the session's BASE font (the font a fresh overlay uses) so the reported grid is + exactly the coordinate system `--cols/--rows` land in. +- Populated app-side (the host-free tree can't read a surface), the `fontSize`-closure pattern: a + `canvasGrid: (Session) -> (cols: Int, rows: Int)?` closure on `AppStore.controlTree`, filled in + `ControlServer.buildTree` from `ControlServer.sessionCanvasGrid` reading the new + `GhosttySurfaceView.liveGrid()` (`ghostty_surface_size().columns/.rows` — UNITLESS cell counts, so NO + px→point Retina conversion, unlike the overlay cell metrics). +- **Split handling (measurement decision):** split-agnostic — the whole detail region taken as ONE area. + `canvasRows` = the main pane's rows (full height; a left/right split keeps full height). + `canvasCols` = the primary pane's columns when NOT split (exact), the SUM of both panes' columns when the + split is shown side-by-side (`isSplit`), or the split pane's own full-width grid for a hidden split + maximized on it (`hasSplit && splitFocused`). A side-by-side split `canvasCols` slightly underestimates a + single full-width surface (the divider + per-pane inner padding are uncounted) — the confirmed + "exact-unsplit, as-close-as-possible-for-split" trade, documented in the field godoc; never the half-width + main pane alone. +- Tests: round-trip + omit-when-nil in `ControlProtocolTests`; a `controlTree` populate test + (`controlTreeThreadsCanvasGridFromClosure`) in `AppStorePaneTests` (closure-based, like the fontSize + populate test); and the e2e `testTreeReportsCanvasGridAndOverlayFillsIt` in `ControlOverlaySplitUITests` + (reads `canvasCols`/`canvasRows` off `tree`, asserts > 0, then `overlay open --cols canvasCols --rows + canvasRows` yields `overlayColsApplied`/`overlayRowsApplied` ≈ the canvas — catching a ~2x cell-count miss). +- Keep-in-sync surfaces updated: agent-skill `reference.md`/`SKILL.md`/`examples.md`, `site/commands.html`, + `site/docs.html`, `README.md`, and `.claude/rules/control-api.md`. No new `Command` case (a pure derived + read), so the command count stays 64. + ## Technical Details **Host-free types (`agtermCore/Sources/agtermCore/OverlayLayout.swift`):** diff --git a/site/commands.html b/site/commands.html index ac115fb9..8efc1c11 100644 --- a/site/commands.html +++ b/site/commands.html @@ -444,8 +444,14 @@ restore override, omitted when none and an empty string when pinned to nothing), commandWait (a held --command session created with - session new --wait; omitted otherwise), and - unseen. + session new --wait; omitted otherwise), + unseen, and + canvasCols / + canvasRows (the terminal content area — the overlay + "canvas" — in whole cells at the base font, the coordinate system + overlay open --cols/--rows land in, + so a script can size a floating overlay as a fraction of the canvas; split-agnostic full height and full width, + omitted when no surface is realized).

Workspace node: diff --git a/site/docs.html b/site/docs.html index 28287d70..78b7dd58 100644 --- a/site/docs.html +++ b/site/docs.html @@ -1338,6 +1338,11 @@ the cols/rows grid clamps to the whole cells that fit and a percent panel simply never exceeds the pane; the realized grid reads back on agtermctl tree as overlayColsApplied/overlayRowsApplied. + To size relative to the pane, + agtermctl tree also reports + canvasCols/canvasRows + — the terminal content area in whole cells at the base font, the coordinate system + --cols/--rows land in — so a script can compute a concrete grid as a fraction of the canvas. --anchor parks it at one of nine positions (center is the default), inset one line-height off each anchored edge. session overlay resize changes an From f5d14676e838e44d29e7bcfe7828c397b2a91320 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 03:51:17 -0500 Subject: [PATCH 17/20] fix: apply overlay margin as a uniform safe-area inset on all sides --- .claude/rules/control-api.md | 25 ++-- README.md | 2 +- agterm/Resources/agent-skill/SKILL.md | 5 +- agterm/Resources/agent-skill/examples.md | 2 +- agterm/Resources/agent-skill/reference.md | 6 +- agterm/Views/WindowContentView.swift | 69 ++++----- .../Sources/agtermCore/OverlayLayout.swift | 74 +++++----- .../agtermCoreTests/OverlayLayoutTests.swift | 138 +++++++++++------- .../ControlOverlaySplitUITests.swift | 105 +++++++++---- ...20260722-overlay-anchor-and-cell-sizing.md | 65 +++++---- site/commands.html | 3 +- site/docs.html | 3 +- 12 files changed, 292 insertions(+), 205 deletions(-) diff --git a/.claude/rules/control-api.md b/.claude/rules/control-api.md index 1ef656b1..7da4b7ac 100644 --- a/.claude/rules/control-api.md +++ b/.claude/rules/control-api.md @@ -690,9 +690,14 @@ paths: (`top-left`/`top`/`top-right`/`left`/`center`/`right`/`bottom-left`/`bottom`/`bottom-right`, default `center` = today's centered placement); it requires a floating size, so an anchor on a full-pane open is a dispatcher error. - An edge/corner-anchored floating panel is NOT flush with the pane border: it insets one line-height - (`cell.cellHeight`) off EACH anchored side (a corner insets both, an edge one, `center` none), host-free in - `OverlayLayout.anchorInsets`, each inset capped at the axis slack so a near-full panel never overflows. + A floating panel is NEVER flush with the pane border: it sits inside a uniform SAFE AREA — the pane inset by + a base-level margin of one line-height (`cell.cellHeight`) on ALL FOUR sides, symmetric and INDEPENDENT of + the anchor (a full-width band is inset left/right exactly like the top; `center` keeps it centered within the + safe area). + Host-free in `OverlayLayout.panelRect`: the requested size is clamped to the safe area per axis + (`panelW = min(requested, paneW - 2m)`) and placed at the anchor's unit position within it + (`originX = m + anchor.unitX * (usableW - panelW)`); nil/unusable cell metrics → `m = 0` (no margin). + A `.full` overlay is unchanged (fills the whole pane, no margin). `args.color` (`#rrggbb`, REUSING the `session.background` field — no new arg — validated by the shared `WatermarkConfig.isValidColorHex` at BOTH the CLI `validate()` and the server arm) gives the overlay pane its OWN solid background color, independent of the session's `session.background color`; @@ -721,13 +726,15 @@ paths: + `.allowsHitTesting(false)` via `hideForOverlay` (= `fullOverlay || scratchActive`; kept MOUNTED, shells alive like the deck's inactive sessions), so its transparency reveals the window backing (desktop, tint + blur), not the session. - FLOATING (`overlaySize != .full`, i.e. `.percent` or `.cells`): sized by `OverlayLayout.panelSize` (the - percent fraction or the whole-cell-clamped grid), drawn as an opaque `terminalColor`-backed, hairline-framed, - shadowed panel PLACED by its `overlayAnchor` — `ZStack(alignment: anchor.swiftUIAlignment)` plus the - `OverlayLayout.anchorInsets` line-height margin — with the pane(s) VISIBLE around it (`center` reproduces the - original dead-center placement). + FLOATING (`overlaySize != .full`, i.e. `.percent` or `.cells`): sized AND placed by a single + `OverlayLayout.panelRect` (percent fraction or whole-cell-clamped grid, clamped to the safe area, positioned + at the anchor's unit point within it), drawn as an opaque `terminalColor`-backed, hairline-framed, shadowed + panel with the pane(s) VISIBLE around it (`center` reproduces the original dead-center placement, now within + the safe area). + Placement is a CONSTANT `ZStack(alignment: .topLeading)` plus a leading/top `padding` by the computed + `rect.origin` — no anchor-specific `swiftUIAlignment` branch, so the anchor axis mapping cannot desync. The modifier CHAIN is IDENTICAL across both variants and every anchor — only the parameter VALUES flip - (backing color, corner radius, shadow radius, frame size, ZStack alignment, padding insets) — so + (backing color, corner radius, shadow radius, frame size, padding origin) — so `session.overlay.resize` switching full<->floating or re-anchoring is a value-update, never a child add/remove or a re-parent of the overlay surface NSView (a re-parent would blank its Metal drawable). Hit-testing on the PANES stays gated on `.allowsHitTesting(!hideForOverlay)` and must NOT flip when a diff --git a/README.md b/README.md index 464f9193..afa651f6 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,7 @@ By default an overlay opens on its `--target` without switching the active sessi `--block` runs the program in the overlay (rendering normally) and blocks until it exits, then exits with the program's status — useful in a script that needs the outcome of an interactive run. The program's output stays its own concern: a TUI writes its result to its own file (for example `revdiff --output=…`) which the script reads, while `--block` reports only the exit status (the overlay never captures stdout). `--block` can't be combined with `--wait`; `session overlay result` reports the last overlay's exit status on demand for a manual open → poll flow. -By default the overlay fills the pane, drawn translucent, hiding the session beneath it. For a *floating* variant instead — an opaque, framed panel with the session still visible around it — size it one of two ways: `--size-percent N` (1–100) sizes it to N% of the pane in both dimensions, or `--cols N --rows M` sizes it to an exact terminal grid. Both are adaptive — a `--cols/--rows` request larger than the pane is clamped down to the whole cells that fit, while a `--size-percent` panel is a fraction of the pane and so is never larger than it — and the grid that actually rendered is read back on `agtermctl tree` (as `overlayColsApplied`/`overlayRowsApplied`). To size relative to the pane, `agtermctl tree` also reports `canvasCols`/`canvasRows` — the terminal content area (the overlay "canvas") in whole cells at the base font, the exact coordinate system `--cols/--rows` land in — so a script can compute a concrete grid as a fraction of the canvas (for example a 30%-tall top strip: `--cols $canvasCols --rows $((canvasRows*30/100)) --anchor top`). `--anchor POS` places a floating panel at one of nine positions — `top-left`, `top`, `top-right`, `left`, `center` (default), `right`, `bottom-left`, `bottom`, `bottom-right` — inset one line-height off each anchored edge so it is not flush with the border; `center` keeps the original dead-center placement. A floating overlay is useful for a small auxiliary program (a picker, a monitor) that you want floating over — not replacing — the terminal you're working in. It composes with `--block` (a blocking floating overlay). Like a full overlay it opens in the background and runs even when the target is not active; pass `--follow` to switch the user to the target as it opens. `session overlay resize` re-anchors or re-sizes it in place: `--anchor` alone moves the panel keeping its size, `--full` reverts to full-pane (which preserves the anchor for a later re-float, and cannot be combined with `--anchor`), and an out-of-range `--size-percent` is a hard error on both open and resize. +By default the overlay fills the pane, drawn translucent, hiding the session beneath it. For a *floating* variant instead — an opaque, framed panel with the session still visible around it — size it one of two ways: `--size-percent N` (1–100) sizes it to N% of the pane in both dimensions, or `--cols N --rows M` sizes it to an exact terminal grid. Both are adaptive — a `--cols/--rows` request larger than the pane is clamped down to the whole cells that fit, while a `--size-percent` panel is a fraction of the pane and so is never larger than it — and the grid that actually rendered is read back on `agtermctl tree` (as `overlayColsApplied`/`overlayRowsApplied`). To size relative to the pane, `agtermctl tree` also reports `canvasCols`/`canvasRows` — the terminal content area (the overlay "canvas") in whole cells at the base font, the exact coordinate system `--cols/--rows` land in — so a script can compute a concrete grid as a fraction of the canvas (for example a 30%-tall top strip: `--cols $canvasCols --rows $((canvasRows*30/100)) --anchor top`). `--anchor POS` places a floating panel at one of nine positions — `top-left`, `top`, `top-right`, `left`, `center` (default), `right`, `bottom-left`, `bottom`, `bottom-right` — inside a uniform safe-area margin of one line-height on all four sides, so it is never flush with any border. The margin is symmetric and independent of the anchor: a full-width band (`--cols $canvasCols`) is inset off the left and right exactly like the top, and `center` keeps the panel dead-centered within that safe area. A floating overlay is useful for a small auxiliary program (a picker, a monitor) that you want floating over — not replacing — the terminal you're working in. It composes with `--block` (a blocking floating overlay). Like a full overlay it opens in the background and runs even when the target is not active; pass `--follow` to switch the user to the target as it opens. `session overlay resize` re-anchors or re-sizes it in place: `--anchor` alone moves the panel keeping its size, `--full` reverts to full-pane (which preserves the anchor for a later re-float, and cannot be combined with `--anchor`), and an out-of-range `--size-percent` is a hard error on both open and resize. A session's terminal surface is created lazily — it does not exist until the session has been shown at least once. Injecting text into a never-shown session therefore fails with `session not realized` unless you pass `--select`, which selects the session (realizing its surface) before injecting: diff --git a/agterm/Resources/agent-skill/SKILL.md b/agterm/Resources/agent-skill/SKILL.md index dadc54f8..2bfc8012 100644 --- a/agterm/Resources/agent-skill/SKILL.md +++ b/agterm/Resources/agent-skill/SKILL.md @@ -291,8 +291,9 @@ omitted when expanded). `--cols N --rows M` (exact grid). `--cols/--rows` clamps to the whole cells that fit; `--size-percent` is a fraction of the pane (never larger). Either way the realized grid reads back as `overlayColsApplied`/`overlayRowsApplied`. `--anchor POS` parks a floating panel at one of nine positions - (`top-left` … `center` (default) … `bottom-right`), inset one line-height off each anchored edge; it needs - a floating size. `overlay resize` changes an ALREADY-OPEN overlay in place (the program keeps running, no + (`top-left` … `center` (default) … `bottom-right`) inside a uniform safe-area margin of one line-height on + all four sides (never flush with a border; symmetric and anchor-independent, so a full-width band is inset + left/right like the top); it needs a floating size. `overlay resize` changes an ALREADY-OPEN overlay in place (the program keeps running, no re-spawn): pass a size mode (`--size-percent`/`--cols`+`--rows`/`--full`) and/or `--anchor` (at least one); `--anchor` alone re-anchors keeping the size, `--full` reverts to full-pane but preserves the anchor and cannot be combined with `--anchor`. diff --git a/agterm/Resources/agent-skill/examples.md b/agterm/Resources/agent-skill/examples.md index cb3acadf..0de62ea0 100644 --- a/agterm/Resources/agent-skill/examples.md +++ b/agterm/Resources/agent-skill/examples.md @@ -231,7 +231,7 @@ and park it out of the way. A cols/rows request larger than the pane clamps to t read the realized grid back off `tree`: ```bash -# an 80x24 panel parked in the bottom-right corner (one line-height off each anchored edge, not flush) +# an 80x24 panel parked in the bottom-right corner (inset one line-height off every edge — a uniform safe-area margin) agtermctl session overlay open "zsh -lc 'htop'" --target "$AGTERM_SESSION_ID" --cols 80 --rows 24 --anchor bottom-right # what actually rendered (requested vs clamped): overlayCols/Rows = requested, overlayColsApplied/RowsApplied = realized agtermctl tree --json | jq '.. | objects | select(.overlay == true) | {overlayCols, overlayRows, overlayColsApplied, overlayRowsApplied, overlayAnchor}' diff --git a/agterm/Resources/agent-skill/reference.md b/agterm/Resources/agent-skill/reference.md index c2fc116f..41a5c17a 100644 --- a/agterm/Resources/agent-skill/reference.md +++ b/agterm/Resources/agent-skill/reference.md @@ -461,8 +461,10 @@ All ten are read-only projections of GUI state. rendered is read back on `tree` as `overlayColsApplied`/`overlayRowsApplied`. `--size-percent` must be 1–100 — an out-of-range value is a hard error (it is no longer silently clamped on open). `--anchor POS` places a floating panel at one of nine positions — `top-left`, `top`, `top-right`, `left`, - `center` (default), `right`, `bottom-left`, `bottom`, `bottom-right`. An edge/corner-anchored panel sits - one line-height off each side it anchors to (so it is not flush with the border); `center` is unchanged. + `center` (default), `right`, `bottom-left`, `bottom`, `bottom-right` — inside a uniform safe-area margin of + one line-height on ALL FOUR sides, so the panel is never flush with any border. The margin is symmetric and + independent of the anchor: a full-width band (`--cols $canvasCols`) is inset off the left and right exactly + like the top, and `center` keeps the panel dead-centered within that safe area. `--anchor` requires a floating size (`--size-percent` or `--cols/--rows`) — on a full-pane overlay it is an error. **By default the overlay does NOT switch the active session** — full and floating both open on `--target` and run their program in the background, appearing when the user diff --git a/agterm/Views/WindowContentView.swift b/agterm/Views/WindowContentView.swift index 05cb6f4d..ca5d61a4 100644 --- a/agterm/Views/WindowContentView.swift +++ b/agterm/Views/WindowContentView.swift @@ -525,23 +525,24 @@ struct WindowContentView: View { /// overrun), and BOTH variants share this single surface host, so `session.overlay.resize` switching /// full<->floating only re-flows the frame — it never re-parents the NSView (which would blank its Metal drawable). /// A `.full` `overlaySize` fills the detail area translucent (no opaque backing/frame) with the pane(s) - /// hidden by `hideForOverlay`; a floating size (`.percent`/`.cells`) draws an opaque, framed panel sized by - /// `OverlayLayout.panelSize` and anchored by `overlayAnchor` (nine positions, default center), with the - /// pane(s) visible around it. Only the frame parameters and the ZStack `alignment` change across - /// full/floating/anchor — the child count is constant (no anchor-specific view branches), so the - /// NSSplitView is never re-hosted. Per-session in the eager deck, so the surface mounts + program runs even - /// when the session isn't active. + /// hidden by `hideForOverlay`; a floating size (`.percent`/`.cells`) draws an opaque, framed panel sized and + /// placed by `OverlayLayout.panelRect` inside a uniform SAFE AREA — the pane inset by one line-height on all + /// four sides — anchored by `overlayAnchor` (nine positions, default center), with the pane(s) visible + /// around it. Only the frame size and the top-leading padding origin change across full/floating/anchor — + /// the ZStack alignment is a constant `.topLeading` and the child count is constant (no anchor-specific view + /// branches), so the NSSplitView is never re-hosted. Per-session in the eager deck, so the surface mounts + + /// program runs even when the session isn't active. @ViewBuilder private func overlayPanel(session: Session, isActive: Bool) -> some View { GeometryReader { geo in let floating = session.floatingOverlayActive let paneSize = WindowGeometry.Size(width: Double(geo.size.width), height: Double(geo.size.height)) - let panel = OverlayLayout.panelSize(session.overlaySize, pane: paneSize, cell: session.overlayCellMetrics) - // a floating panel sits one cell off the pane edge(s) it anchors to (a corner insets both sides, - // an edge one, center none), so it never reads as flush against the border; full/center -> .zero. - let insets = floating - ? OverlayLayout.anchorInsets(session.overlayAnchor, panel: panel, pane: paneSize, cell: session.overlayCellMetrics) - : .zero - ZStack(alignment: floating ? session.overlayAnchor.swiftUIAlignment : .center) { + // ONE unified safe-area size+placement: a floating panel is sized then CLAMPED to the pane inset by + // a uniform one-line-height margin on ALL FOUR sides, and placed at its anchor WITHIN that safe area + // (so a full-width band is inset left/right exactly like the top, independent of the anchor); a full + // overlay fills the whole pane at origin (0,0) with no margin. + let rect = OverlayLayout.panelRect(session.overlaySize, pane: paneSize, + cell: session.overlayCellMetrics, anchor: session.overlayAnchor) + ZStack(alignment: .topLeading) { if session.overlayActive, deckHostsSurface(session: session, surface: .overlay) { // transparent click-catcher over the whole detail area: absorbs clicks AROUND a floating // panel so they can't reach the still-hit-testable panes and steal the overlay's first @@ -549,7 +550,7 @@ struct WindowContentView: View { Color.clear.contentShape(Rectangle()) TerminalView(session: session, surfaceKeyPath: \.overlaySurface, makeSurface: makeOverlaySurface, isActive: isActive, deckVisible: isActive && !quickTerminal.isVisible) - .frame(width: CGFloat(panel.width), height: CGFloat(panel.height)) + .frame(width: CGFloat(rect.size.width), height: CGFloat(rect.size.height)) // floating = opaque backing + hairline frame + shadow so it reads as a distinct window // over the still-visible session; full = translucent, no chrome (libghostty draws only // the terminal, so the window backing shows through). The modifier CHAIN stays constant @@ -562,24 +563,26 @@ struct WindowContentView: View { .strokeBorder(floating ? Color.white.opacity(0.18) : Color.clear, lineWidth: 1) ) .shadow(radius: floating ? 24 : 0) - // the Metal-backed surface is not in the a11y tree, so expose the FLOATING panel's frame - // via a zero-content overlay marker (the dashboard-cell pattern) that the e2e reads. As - // an .overlay it adds NO ZStack child, so the constant-child-count NSSplitView-overrun - // invariant holds; allowsHitTesting(false) keeps it off the overlay's own input. Only a - // floating panel carries the id — a full overlay covers the whole detail area, not a - // panel — and the id string flips as a VALUE (no view branch), so the chain stays constant. + // the Metal-backed surface is not in the a11y tree, so expose the panel's frame via a + // zero-content overlay marker (the dashboard-cell pattern) that the e2e reads. As an + // .overlay it adds NO ZStack child, so the constant-child-count NSSplitView-overrun + // invariant holds; allowsHitTesting(false) keeps it off the overlay's own input. The + // floating panel carries `overlay-floating-panel`, the full one `overlay-full-panel` + // (its frame is the detail-area reference the e2e measures floating margins against) — + // the id string flips as a VALUE (no view branch), so the chain stays constant. .overlay( Color.clear .allowsHitTesting(false) .accessibilityElement() - .accessibilityIdentifier(floating ? "overlay-floating-panel" : "") + .accessibilityIdentifier(floating ? "overlay-floating-panel" : "overlay-full-panel") ) - // push the panel one cell off its anchored edge(s) — the padding wraps the panel AND - // its a11y marker, so the marker still reports the panel's own frame while the whole - // group is offset within the ZStack. A constant modifier (values-only, no anchor - // branch), so the ZStack child count never changes (NSSplitView-overrun invariant). - .padding(EdgeInsets(top: CGFloat(insets.top), leading: CGFloat(insets.leading), - bottom: CGFloat(insets.bottom), trailing: CGFloat(insets.trailing))) + // place the panel at its safe-area origin: a constant top-leading ZStack + leading/top + // padding by the computed origin pushes the panel (AND its a11y marker, so the marker + // still reports the panel's own frame) in from the top-left. A values-only modifier (no + // anchor-specific view branch), so the ZStack child count never changes and the + // NSSplitView is never re-hosted (NSSplitView-overrun invariant). + .padding(EdgeInsets(top: CGFloat(rect.origin.y), leading: CGFloat(rect.origin.x), + bottom: 0, trailing: 0)) .id("\(session.id.uuidString)-overlay") } } @@ -857,15 +860,3 @@ struct WindowContentView: View { } } - -extension OverlayAnchor { - /// The SwiftUI `Alignment` a floating overlay panel takes inside the detail-area ZStack. DERIVED from - /// the host-free, unit-tested `unitX`/`unitY` (0 = leading/top, 0.5 = center, 1 = trailing/bottom) so a - /// per-anchor transposition is structurally impossible — the axis mapping cannot disagree with the same - /// unit coordinates the resolver/insets use; `.center` reproduces today's centered placement. - var swiftUIAlignment: Alignment { - let horizontal: HorizontalAlignment = unitX == 0 ? .leading : (unitX == 1 ? .trailing : .center) - let vertical: VerticalAlignment = unitY == 0 ? .top : (unitY == 1 ? .bottom : .center) - return Alignment(horizontal: horizontal, vertical: vertical) - } -} diff --git a/agtermCore/Sources/agtermCore/OverlayLayout.swift b/agtermCore/Sources/agtermCore/OverlayLayout.swift index 9225edfd..8099898e 100644 --- a/agtermCore/Sources/agtermCore/OverlayLayout.swift +++ b/agtermCore/Sources/agtermCore/OverlayLayout.swift @@ -65,28 +65,8 @@ public struct OverlayCellMetrics: Equatable, Sendable { public var isUsable: Bool { cellWidth > 0 && cellHeight > 0 } } -/// Per-edge insets (in points) applied to an anchored floating overlay panel so it sits one cell off the -/// pane edge(s) it anchors to, instead of flush against the border. The app maps these onto a SwiftUI -/// `padding(EdgeInsets)` on the panel; the centered axes carry a zero inset. -public struct OverlayInsets: Equatable, Sendable { - public let leading: Double - public let top: Double - public let trailing: Double - public let bottom: Double - - public init(leading: Double, top: Double, trailing: Double, bottom: Double) { - self.leading = leading - self.top = top - self.trailing = trailing - self.bottom = bottom - } - - /// No inset on any edge (`center`, full overlay, or unusable metrics). - public static let zero = OverlayInsets(leading: 0, top: 0, trailing: 0, bottom: 0) -} - -/// Pure resolver turning an `OverlaySize` request into a concrete panel size within a pane. Host-free and -/// unit-tested; the app applies the result as a SwiftUI frame. All sizes are in points. +/// Pure resolver turning an `OverlaySize` request into a concrete panel rect within a pane. Host-free and +/// unit-tested; the app applies the result as a SwiftUI frame + placement. All sizes are in points. public enum OverlayLayout { /// Resolves `size` against `pane` and (for cells mode) the live `cell` metrics. /// @@ -120,23 +100,37 @@ public enum OverlayLayout { return Swift.min(Double(usedCount) * cellSize + pad, available) } - /// The one-line-height margin a floating overlay panel takes off the pane edge(s) its `anchor` sits - /// against, so an edge/corner-anchored panel is not flush with the border. BOTH anchored sides — the - /// horizontal one (`unitX` 0 = leading, 1 = trailing) and the vertical one (`unitY` 0 = top, 1 = bottom) - /// — inset by one `cell.cellHeight` (the line height), so the left/right gaps equal the top/bottom gaps - /// and the margin looks uniform; a terminal cell is ~2x taller than wide, so using the cell width for the - /// horizontal side would read as about half the vertical gap. A centered axis (`0.5`) gets none. Each - /// inset is capped at the slack on its axis (`min(oneCell, pane - panel)`), so a near-full-pane panel - /// never overflows, and nil or unusable `cell` metrics yield no inset. `center` (both axes 0.5) always - /// yields `.zero`. - public static func anchorInsets(_ anchor: OverlayAnchor, panel: WindowGeometry.Size, - pane: WindowGeometry.Size, cell: OverlayCellMetrics?) -> OverlayInsets { - guard let cell, cell.isUsable else { return .zero } - let hInset = Swift.min(cell.cellHeight, Swift.max(0, pane.width - panel.width)) - let vInset = Swift.min(cell.cellHeight, Swift.max(0, pane.height - panel.height)) - return OverlayInsets(leading: anchor.unitX == 0 ? hInset : 0, - top: anchor.unitY == 0 ? vInset : 0, - trailing: anchor.unitX == 1 ? hInset : 0, - bottom: anchor.unitY == 1 ? vInset : 0) + /// The concrete panel RECT (origin + size, in points) for `size` within `pane`, anchored by `anchor` + /// inside a uniform SAFE AREA — the pane inset by a base-level margin `m` on ALL FOUR sides. `m` is one + /// line-height (`cell.cellHeight`) when the cell metrics are usable, else 0. The margin is a DEFAULT on + /// every side, symmetric and independent of the anchor: a full-usable-size band carries an equal margin + /// on every edge (a full-width top band is inset left/right exactly like the top). + /// + /// - `.full` fills the whole pane at origin `(0, 0)` with NO margin (the translucent session-hiding cover). + /// - a FLOATING size (`.percent`/`.cells`) is sized by `panelSize`, then CLAMPED to the safe area per axis + /// (`panelW = min(requestedW, paneW - 2m)`, same for height), and PLACED at the anchor's unit position + /// within the safe area (`originX = m + anchor.unitX * (usableW - panelW)`, same for y) — so the panel is + /// never closer than `m` to any edge, a full-usable-size panel fills the safe area with an `m` margin all + /// around, and the anchor positions a smaller panel within it. + public static func panelRect(_ size: OverlaySize, pane: WindowGeometry.Size, + cell: OverlayCellMetrics?, anchor: OverlayAnchor) -> WindowGeometry.Rect { + if case .full = size { + return WindowGeometry.Rect(origin: WindowGeometry.Point(x: 0, y: 0), size: pane) + } + let margin: Double + if let cell, cell.isUsable { + margin = cell.cellHeight + } else { + margin = 0 + } + let usableWidth = Swift.max(0, pane.width - 2 * margin) + let usableHeight = Swift.max(0, pane.height - 2 * margin) + let requested = panelSize(size, pane: pane, cell: cell) + let width = Swift.min(requested.width, usableWidth) + let height = Swift.min(requested.height, usableHeight) + let originX = margin + anchor.unitX * (usableWidth - width) + let originY = margin + anchor.unitY * (usableHeight - height) + return WindowGeometry.Rect(origin: WindowGeometry.Point(x: originX, y: originY), + size: WindowGeometry.Size(width: width, height: height)) } } diff --git a/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift b/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift index 45860807..7ee10d3d 100644 --- a/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift +++ b/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift @@ -12,6 +12,13 @@ struct OverlayLayoutTests { #expect(size.height == height) } + private func expectRect(_ rect: WindowGeometry.Rect, x: Double, y: Double, width: Double, height: Double) { + #expect(rect.origin.x == x) + #expect(rect.origin.y == y) + #expect(rect.size.width == width) + #expect(rect.size.height == height) + } + // mirrors the app-side px->points conversion: every pixel metric divided by the backing scale. private func metrics(cellWpx: Double, cellHpx: Double, padWpx: Double, padHpx: Double, scale: Double) -> OverlayCellMetrics { @@ -160,62 +167,89 @@ struct OverlayLayoutTests { #expect(OverlayAnchor(rawValue: "diagonal") == nil) } - // MARK: - anchorInsets (1-cell anchor margin) + // MARK: - panelRect (uniform base-level safe-area size + placement) - private func insetsCell() -> OverlayCellMetrics { + // margin = cellHeight = 16, so a 800x600 pane has a usable safe area of 768x568 (16pt inset per side). + private func marginCell() -> OverlayCellMetrics { OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 0, padHeight: 0) } - @Test func cornerAnchorInsetsBothAnchoredSidesOneCell() { - // top-left: one line-height off the leading edge AND off the top (both use cellHeight); trailing/ - // bottom untouched. - let insets = OverlayLayout.anchorInsets(.topLeft, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) - #expect(insets == OverlayInsets(leading: 16, top: 16, trailing: 0, bottom: 0)) - - // bottom-right: one line-height off the trailing edge AND off the bottom. - let br = OverlayLayout.anchorInsets(.bottomRight, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) - #expect(br == OverlayInsets(leading: 0, top: 0, trailing: 16, bottom: 16)) - } - - @Test func edgeAnchorInsetsOnlyTheAnchoredAxis() { - // top edge: inset the top only, the centered horizontal axis gets nothing. - let top = OverlayLayout.anchorInsets(.top, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) - #expect(top == OverlayInsets(leading: 0, top: 16, trailing: 0, bottom: 0)) - - // left edge: inset the leading only, the centered vertical axis gets nothing. - let left = OverlayLayout.anchorInsets(.left, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) - #expect(left == OverlayInsets(leading: 16, top: 0, trailing: 0, bottom: 0)) - - // right edge: inset the trailing only. - let right = OverlayLayout.anchorInsets(.right, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) - #expect(right == OverlayInsets(leading: 0, top: 0, trailing: 16, bottom: 0)) - - // bottom edge: inset the bottom only. - let bottom = OverlayLayout.anchorInsets(.bottom, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) - #expect(bottom == OverlayInsets(leading: 0, top: 0, trailing: 0, bottom: 16)) - } - - @Test func centerAnchorHasNoInset() { - let insets = OverlayLayout.anchorInsets(.center, panel: pane(200, 200), pane: pane(800, 600), cell: insetsCell()) - #expect(insets == .zero) - } - - @Test func anchorInsetsCapAtAvailableSlack() { - // a near-full-pane panel: only 3pt of horizontal slack and 5pt of vertical slack remain, so the - // one-line-height (16pt) inset is capped to that slack on each axis and the panel never overflows. - let insets = OverlayLayout.anchorInsets(.topLeft, panel: pane(797, 595), pane: pane(800, 600), cell: insetsCell()) - #expect(insets == OverlayInsets(leading: 3, top: 5, trailing: 0, bottom: 0)) - } - - @Test func anchorInsetsCapAtZeroWhenPanelFillsPane() { - // a panel exactly the pane size (or larger) has no slack, so an anchored inset is clamped to zero. - let insets = OverlayLayout.anchorInsets(.bottomRight, panel: pane(800, 600), pane: pane(800, 600), cell: insetsCell()) - #expect(insets == .zero) - } - - @Test func anchorInsetsAreZeroWithoutUsableMetrics() { - #expect(OverlayLayout.anchorInsets(.topLeft, panel: pane(200, 200), pane: pane(800, 600), cell: nil) == .zero) + @Test func fullOverlayFillsWholePaneNoMarginOnEveryAnchor() { + // a full overlay ignores the safe-area margin on every anchor: origin (0,0), size == pane. + for anchor in OverlayAnchor.allCases { + let rect = OverlayLayout.panelRect(.full, pane: pane(800, 600), cell: marginCell(), anchor: anchor) + expectRect(rect, x: 0, y: 0, width: 800, height: 600) + } + } + + @Test func floatingFullSizeRequestFillsSafeAreaOnEveryAnchor() { + // a >= usable request (100%) clamps to the safe area (768x568) and, with no slack left, lands at + // origin (m, m) = (16, 16) regardless of the anchor — an equal margin on all four sides. + for anchor in OverlayAnchor.allCases { + let rect = OverlayLayout.panelRect(.percent(100), pane: pane(800, 600), cell: marginCell(), anchor: anchor) + expectRect(rect, x: 16, y: 16, width: 768, height: 568) + } + } + + @Test func cornerAnchorPlacesSmallPanelAtMargin() { + // top-left: a small floating panel sits exactly one line-height off the leading + top edges. + let tl = OverlayLayout.panelRect(.percent(10), pane: pane(800, 600), cell: marginCell(), anchor: .topLeft) + expectRect(tl, x: 16, y: 16, width: 80, height: 60) + + // bottom-right: one line-height off the trailing + bottom edges (mirrored via the anchor unit point). + // originX = 16 + (768-80) = 704, originY = 16 + (568-60) = 524; the right + bottom margins are both 16. + let br = OverlayLayout.panelRect(.percent(10), pane: pane(800, 600), cell: marginCell(), anchor: .bottomRight) + expectRect(br, x: 704, y: 524, width: 80, height: 60) + #expect(800 - (br.origin.x + br.size.width) == 16) + #expect(600 - (br.origin.y + br.size.height) == 16) + } + + @Test func centerAnchorCentersWithinSafeArea() { + // center: a small panel is centered, so the left/right and top/bottom margins are symmetric. + let rect = OverlayLayout.panelRect(.percent(10), pane: pane(800, 600), cell: marginCell(), anchor: .center) + expectRect(rect, x: 360, y: 270, width: 80, height: 60) + #expect(rect.origin.x == 800 - (rect.origin.x + rect.size.width)) // left margin == right margin + #expect(rect.origin.y == 600 - (rect.origin.y + rect.size.height)) // top margin == bottom margin + } + + @Test func fullWidthBandIsInsetOnAllFourSides() { + // a full-usable-width top band (cols far exceeding the pane, few rows) is clamped to the safe-area + // WIDTH and, anchored top, carries an EQUAL one-line-height margin on the left, right, AND top (the + // maintainer fix: a full-width band is inset left/right exactly like the top, independent of anchor), + // with the bottom free. + let rect = OverlayLayout.panelRect(.cells(cols: 200, rows: 5), pane: pane(800, 600), cell: marginCell(), anchor: .top) + let leftMargin = rect.origin.x + let rightMargin = 800 - (rect.origin.x + rect.size.width) + let topMargin = rect.origin.y + #expect(leftMargin == 16) + #expect(rightMargin == 16) + #expect(topMargin == 16) + #expect(leftMargin == rightMargin) // symmetric horizontal margins for a full-width band + #expect(leftMargin == topMargin) // horizontal margin equals the vertical margin (uniform) + #expect(rect.size.width == 768) // the band fills the whole safe-area width + } + + @Test func nilCellMetricsYieldNoMarginPanelMayFillPane() { + // nil cell metrics -> m = 0: a 100% floating panel fills the pane (no safe-area inset). + let rect = OverlayLayout.panelRect(.percent(100), pane: pane(800, 600), cell: nil, anchor: .center) + expectRect(rect, x: 0, y: 0, width: 800, height: 600) + } + + @Test func unusableCellMetricsYieldNoMargin() { + // a zero-width cell is unusable -> m = 0, no inset, panel may fill the pane. let zeroWidth = OverlayCellMetrics(cellWidth: 0, cellHeight: 16, padWidth: 0, padHeight: 0) - #expect(OverlayLayout.anchorInsets(.topLeft, panel: pane(200, 200), pane: pane(800, 600), cell: zeroWidth) == .zero) + let rect = OverlayLayout.panelRect(.percent(100), pane: pane(800, 600), cell: zeroWidth, anchor: .topLeft) + expectRect(rect, x: 0, y: 0, width: 800, height: 600) + } + + @Test func cellsBandInsetFromEverySideEvenAnchoredTopLeft() { + // a cells band that exactly fills the safe area sits at (m, m) with an m margin on all sides, even at + // top-left: originX = m + 0*(usableW - panelW) = m, and panelW == usableW leaves an m gap on the right. + let cell = OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 0, padHeight: 0) + let rect = OverlayLayout.panelRect(.cells(cols: 200, rows: 200), pane: pane(800, 600), cell: cell, anchor: .topLeft) + // usableW = 768 (96 whole cells of 8pt), usableH = 568. + expectRect(rect, x: 16, y: 16, width: 768, height: 568) + #expect(800 - (rect.origin.x + rect.size.width) == 16) // right margin present despite top-left anchor + #expect(600 - (rect.origin.y + rect.size.height) == 16) // bottom margin present too } } diff --git a/agtermUITests/ControlOverlaySplitUITests.swift b/agtermUITests/ControlOverlaySplitUITests.swift index 7eb33f1d..05028dec 100644 --- a/agtermUITests/ControlOverlaySplitUITests.swift +++ b/agtermUITests/ControlOverlaySplitUITests.swift @@ -773,11 +773,13 @@ final class ControlOverlaySplitUITests: ControlAPITestCase { XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)") } - // tree canvasCols/canvasRows report the session's terminal CONTENT AREA in cells at the base font, so a - // script can size a floating overlay as a fraction of the canvas. Sizing an overlay to the WHOLE canvas - // (`--cols canvasCols --rows canvasRows`) must FILL it: the realized (applied) grid matches - // canvasCols/canvasRows within the whole-cell padding drift. A canvas reported ~2x off (a Retina cell-count - // miss) would size the overlay ~2x off and this comparison would catch it. + // tree canvasCols/canvasRows report the session's terminal CONTENT AREA in cells at the base font (the RAW + // grid — the read-back semantics are UNCHANGED by the safe-area margin), so a script can size a floating + // overlay as a fraction of the canvas. Sizing an overlay to the WHOLE canvas (`--cols canvasCols --rows + // canvasRows`) now CLAMPS to the safe area (the canvas inset by one line-height on all sides), so the + // realized (applied) grid is a FEW cells smaller than the canvas — but still CLOSE to it. This stays + // discriminating against a Retina ~2x cell-count miss: a canvas reported ~2x off would size the overlay at + // ~half or ~double the canvas, both far outside the narrow "canvas minus the margin" window asserted here. func testTreeReportsCanvasGridAndOverlayFillsIt() throws { let id = try activeSessionID() try resizeWindow(width: 1100, height: 750) @@ -789,7 +791,8 @@ final class ControlOverlaySplitUITests: ControlAPITestCase { XCTAssertGreaterThan(canvasCols, 0, "canvasCols should be a real grid: \(node)") XCTAssertGreaterThan(canvasRows, 0, "canvasRows should be a real grid: \(node)") - // open an overlay sized to the whole canvas; the realized grid should fill it (no clamp headroom left). + // open an overlay sized to the whole canvas; the realized grid fills the SAFE AREA (canvas minus the + // one-line-height margin on each side), so it lands just below the canvas grid, not at it. let open = try sendOverlayOpen(target: id, command: "cat", args: ["cols": canvasCols, "rows": canvasRows]) XCTAssertEqual(open["ok"] as? Bool, true, "overlay open filling the canvas should succeed: \(open)") XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the overlay should be up") @@ -797,44 +800,50 @@ final class ControlOverlaySplitUITests: ControlAPITestCase { let applied = try XCTUnwrap(pollOverlayApplied(id: id, timeout: 12), "the tree should report the realized grid") let appliedCols = try XCTUnwrap(applied["overlayColsApplied"] as? Int, "applied cols should be present: \(applied)") let appliedRows = try XCTUnwrap(applied["overlayRowsApplied"] as? Int, "applied rows should be present: \(applied)") - XCTAssertTrue(abs(appliedCols - canvasCols) <= 2, - "an overlay sized to the canvas (\(canvasCols) cols) should fill it: applied \(appliedCols)") - XCTAssertTrue(abs(appliedRows - canvasRows) <= 2, - "an overlay sized to the canvas (\(canvasRows) rows) should fill it: applied \(appliedRows)") + // clamped by the safe-area margin: a few cells below the canvas, never above it, and nowhere near a + // ~2x miss (~half or ~double). the horizontal margin drops ~4 cols (2 line-heights / cell width), the + // vertical ~2 rows (2 line-heights / cell height). + XCTAssertLessThanOrEqual(appliedCols, canvasCols, + "the safe-area-clamped overlay never exceeds the canvas: applied \(appliedCols), canvas \(canvasCols)") + XCTAssertGreaterThan(appliedCols, canvasCols - 12, + "the overlay nearly fills the canvas width minus the margin (a ~2x miss would be far off): applied \(appliedCols), canvas \(canvasCols)") + XCTAssertLessThanOrEqual(appliedRows, canvasRows, + "the safe-area-clamped overlay never exceeds the canvas: applied \(appliedRows), canvas \(canvasRows)") + XCTAssertGreaterThan(appliedRows, canvasRows - 10, + "the overlay nearly fills the canvas height minus the margin (a ~2x miss would be far off): applied \(appliedRows), canvas \(canvasRows)") let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#) XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)") } - // The floating panel follows its corner anchor AND sits ONE LINE-HEIGHT off the anchored edges (the anchor - // margin), never flush against the border. The detail area is captured from a FULL overlay (its panel fills - // the pane exactly with NO inset) via the same `overlay-floating-panel` marker; the floating panel's inset - // from that reference is then asserted to be a small, positive, roughly-uniform margin. This is - // discriminating: flush placement (margin 0) fails the > assertions, and centered placement (a large - // half-slack offset) fails the < assertions. The horizontal and vertical margins are approximately EQUAL - // because BOTH anchored sides inset by the cell HEIGHT (one line-height), so the left/right gap matches the - // top/bottom gap. The Metal surface is not in the a11y tree, so the panel exposes the stable - // `overlay-floating-panel` marker whose frame the test reads. + // A floating panel sits inside a uniform SAFE AREA — the pane inset by one line-height on ALL FOUR sides, + // independent of the anchor. This test proves three things against a FULL-overlay detail-area reference: + // (1) a corner-anchored panel insets off BOTH anchored edges by a small, ~equal, positive margin (NOT + // flush, NOT a centered half-slack offset); (2) re-anchoring to the opposite corner mirrors the margin to + // the other two edges; (3) a FULL-WIDTH band (cols == canvasCols) is inset off the LEFT and RIGHT too — the + // maintainer fix (the margin is a base-level default on every side, so a full-width band is inset left/right + // exactly like the top, never flush against the side borders). The full overlay carries the + // `overlay-full-panel` marker (its frame is the whole detail area, the reference), the floating panel + // `overlay-floating-panel`; the Metal surface itself is not in the a11y tree. func testFloatingOverlayPanelFrameFollowsCornerAnchor() throws { let id = try activeSessionID() try resizeWindow(width: 1100, height: 750) - // a 100% FLOATING overlay fills the detail area exactly (centered, so no anchor inset), so its marker - // frame is the pane the floating margin is measured against. (A FULL overlay no longer carries the - // `overlay-floating-panel` marker — that id is floating-only — so the reference is taken from a - // full-size FLOATING panel instead.) - let reference = try sendOverlayOpen(target: id, command: "cat", args: ["sizePercent": 100]) - XCTAssertEqual(reference["ok"] as? Bool, true, "reference overlay open should succeed: \(reference)") + // a FULL overlay fills the detail area exactly (origin 0,0, size = the pane) with NO margin, so its + // `overlay-full-panel` marker frame is the reference the floating safe-area margins are measured against. + let reference = try sendOverlayOpen(target: id, command: "cat", args: [:]) + XCTAssertEqual(reference["ok"] as? Bool, true, "reference full overlay open should succeed: \(reference)") XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the reference overlay should be up") - let panel = app.descendants(matching: .any).matching(identifier: "overlay-floating-panel").firstMatch - XCTAssertTrue(panel.waitForExistence(timeout: 10), "the overlay panel should be in the a11y tree") - let detail = panel.frame + let full = app.descendants(matching: .any).matching(identifier: "overlay-full-panel").firstMatch + XCTAssertTrue(full.waitForExistence(timeout: 10), "the full overlay panel should be in the a11y tree") + let detail = full.frame - // resize to a 40% floating panel anchored top-left: it shrinks well below the pane and insets from the - // top-left corner by ~1 cell. + // resize to a 40% floating panel anchored top-left: it shrinks well below the pane and insets one + // line-height off BOTH the top and the left inside the safe area. let toFloating = try sendOverlayResize(target: id, args: ["sizePercent": 40, "anchor": "top-left"]) XCTAssertEqual(toFloating["ok"] as? Bool, true, "resize to floating top-left should succeed: \(toFloating)") + let panel = app.descendants(matching: .any).matching(identifier: "overlay-floating-panel").firstMatch let tl = try XCTUnwrap(pollPanelWidth(panel: panel, below: detail.width * 0.6, timeout: 8), "the panel should shrink to the floating size") XCTAssertLessThan(tl.height, detail.height * 0.6, "a 40% floating panel should be much shorter than the pane") @@ -866,6 +875,29 @@ final class ControlOverlaySplitUITests: ControlAPITestCase { XCTAssertLessThan(marginRight, 30, "the right margin should be ~1 cell (marginRight=\(marginRight))") XCTAssertLessThan(marginBottom, 45, "the bottom margin should be ~1 cell (marginBottom=\(marginBottom))") + // a FULL-WIDTH band (cols == canvasCols) anchored top must now be inset from the LEFT and the RIGHT too + // — the maintainer fix: the safe-area margin is a base-level default on all sides, so a full-width band + // is inset left/right exactly like the top, never flush against the side borders (the OLD anchor-edge + // model left a full-width band flush left/right, which these > assertions now catch). + let canvasNode = try XCTUnwrap(pollCanvasGrid(id: id, timeout: 12), "the tree should report the canvas grid") + let canvasCols = try XCTUnwrap(canvasNode["canvasCols"] as? Int, "canvasCols should be present: \(canvasNode)") + let band = try sendOverlayResize(target: id, args: ["cols": canvasCols, "rows": 6, "anchor": "top"]) + XCTAssertEqual(band["ok"] as? Bool, true, "resize to a full-width band should succeed: \(band)") + let bandFrame = try XCTUnwrap(pollPanelWidth(panel: panel, above: detail.width * 0.8, timeout: 8), + "the band should widen to nearly the full detail width") + let bandLeft = bandFrame.minX - detail.minX + let bandRight = detail.maxX - bandFrame.maxX + let bandTop = bandFrame.minY - detail.minY + XCTAssertGreaterThan(bandLeft, 2, "a full-width band must have a LEFT margin, not sit flush (bandLeft=\(bandLeft))") + XCTAssertGreaterThan(bandRight, 2, "a full-width band must have a RIGHT margin, not sit flush (bandRight=\(bandRight))") + XCTAssertGreaterThan(bandTop, 2, "a top-anchored band still has a top margin (bandTop=\(bandTop))") + XCTAssertLessThan(bandLeft, 30, "the band's left margin should be ~1 line-height (bandLeft=\(bandLeft))") + XCTAssertLessThan(bandRight, 30, "the band's right margin should be ~1 line-height (bandRight=\(bandRight))") + XCTAssertEqual(bandLeft, bandRight, accuracy: 3, + "the band's left and right margins should be ~equal (symmetric): left=\(bandLeft) right=\(bandRight)") + XCTAssertLessThan(bandFrame.width, detail.width, + "the band must be narrower than the full detail area (inset on both sides): band=\(bandFrame.width) detail=\(detail.width)") + let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#) XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)") } @@ -1156,6 +1188,19 @@ final class ControlOverlaySplitUITests: ControlAPITestCase { return frame.width < threshold ? frame : nil } + /// Polls `panel`'s frame until its width RISES above `threshold` (a resize to a wide band widens it), + /// returning the new frame, or nil if it never widened within `timeout`. + private func pollPanelWidth(panel: XCUIElement, above threshold: CGFloat, timeout: TimeInterval) -> CGRect? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let frame = panel.frame + if frame.width > threshold { return frame } + usleep(200_000) + } + let frame = panel.frame + return frame.width > threshold ? frame : nil + } + /// Polls `panel`'s frame until it has moved meaningfully from `original` (a re-anchor is async), returning /// the new frame, or nil if it never moved within `timeout`. private func pollPanelMoved(panel: XCUIElement, from original: CGRect, timeout: TimeInterval) -> CGRect? { diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md index 3d7c036f..b9777306 100644 --- a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md +++ b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md @@ -199,20 +199,26 @@ maintainer decisions are folded in below: - **App rendering**: `GhosttySurfaceView.overlayPixelMetrics()` reads `ghostty_surface_size()`; the app converts px→points via `backingScaleFactor` and writes `session.overlayCellMetrics` (on realization, `CELL_SIZE`, backing-scale change) and `session.overlayAppliedCols/Rows` (from the realized grid). - `overlayPanel` sizes via `OverlayLayout.panelSize(session.overlaySize, pane: geo.size, cell: - session.overlayCellMetrics)` and positions via `ZStack(alignment: floating ? anchor.swiftUIAlignment : - .center)`; a stable accessibility id on the floating panel enables the e2e frame assertion. -- **Anchor margin (maintainer feedback)**: an edge/corner-anchored floating panel is NOT flush with - the pane border — it sits one line-height off each anchored side (a corner insets both, an edge one, - `center` none). The margin is host-free in `OverlayLayout.anchorInsets(_:panel:pane:cell:) -> OverlayInsets` - (leading/top/trailing/bottom in points): BOTH the anchored horizontal side AND the anchored vertical side - inset by one line-height (`cellHeight`) so the horizontal and vertical gaps are visually equal (a terminal - cell is ~2x taller than wide, so using `cellWidth` for the horizontal side would read as about half the - vertical gap), each capped at the axis slack (`min(oneCell, pane − panel)`) so a near-full panel never - overflows, and nil/unusable metrics → `.zero`. `overlayPanel` maps it to a single - `padding(EdgeInsets)` on the panel (a values-only modifier — no anchor-specific view branch — so the - ZStack child count stays constant per the NSSplitView-overrun invariant), applied AFTER the a11y marker - so the marker still reports the panel's own frame. Applies to ANY floating overlay (percent or cells). + `overlayPanel` sizes AND positions via a single `OverlayLayout.panelRect(session.overlaySize, pane: + geo.size, cell: session.overlayCellMetrics, anchor: session.overlayAnchor)` inside a constant + `ZStack(alignment: .topLeading)` + leading/top `padding` by `rect.origin`; a stable accessibility id on the + floating panel enables the e2e frame assertion. +- **Overlay margin (maintainer feedback — REWORKED to a base-level safe-area inset)**: a floating overlay is + NEVER flush with the pane border. It sits inside a uniform SAFE AREA — the pane inset by a base-level margin + of one line-height (`cell.cellHeight`) on ALL FOUR sides, SYMMETRIC and INDEPENDENT of the anchor (a + full-width band is inset left/right exactly like the top; not only the anchored edges). The original model + (`OverlayLayout.anchorInsets` insetting only the anchored edge(s)) was WRONG — a full-width top band got a + top margin but sat flush left/right. Now host-free in `OverlayLayout.panelRect(_:pane:cell:anchor:) -> + WindowGeometry.Rect`: the requested size (percent → `pane*p/100`; cells → whole-cell extent) is clamped to + the safe area per axis (`panelW = min(requested, paneW - 2m)`) and placed at the anchor's unit position + within it (`originX = m + anchor.unitX * (usableW - panelW)`, same for y); nil/unusable cell metrics → `m = + 0` (no margin, panel may fill the pane); a `.full` overlay is UNCHANGED (fills the whole pane at origin + (0,0), no margin). `overlayPanel` applies the rect as `.frame(rect.size)` + a leading/top `padding` by + `rect.origin` (a values-only modifier — no anchor-specific view branch, no `swiftUIAlignment` — so the ZStack + child count stays constant per the NSSplitView-overrun invariant), applied AFTER the a11y marker so the + marker still reports the panel's own frame. Applies to ANY floating overlay (percent or cells). A full-usable + request clamps to the safe area (`canvasCols`/`canvasRows` read-back semantics are UNCHANGED — the raw grid; + the applied read-back shows the clamped size). ➕ **Follow-on addition (post-completion scope): canvas grid read-back on `tree`.** A separate, maintainer-confirmed addition beyond the original ten tasks: report the terminal CONTENT AREA @@ -460,19 +466,24 @@ moving the id onto a zero-content `.overlay(Color.clear …)` marker sized to th pattern). As an `.overlay` modifier it adds NO ZStack child, so the constant-child-count NSSplitView-overrun invariant is preserved. All 5 e2e methods pass after the fix. -➕ **Deviation (maintainer feedback — anchor margin):** an edge/corner-anchored floating panel used -to sit FLUSH against the pane border, which looked bad. Per maintainer feedback the panel now insets one -line-height off each anchored side (a corner insets both, an edge one, `center` none). Added the host-free -`OverlayLayout.anchorInsets(_:panel:pane:cell:) -> OverlayInsets` (leading/top/trailing/bottom in points; -BOTH anchored sides = one line-height (`cellHeight`) so horizontal and vertical gaps are visually equal — a -cell is ~2x taller than wide, so `cellWidth` on the horizontal side would read as about half the vertical -gap — each capped at the axis slack, nil/unusable metrics → `.zero`) with `OverlayLayoutTests` -(corner/edge/center/slack-cap/nil cases); `overlayPanel` maps it to a single `padding(EdgeInsets)` on the -panel — a values-only modifier (no anchor branch), applied AFTER the a11y marker so the marker keeps -reporting the panel's own frame, preserving the NSSplitView-overrun invariant. The frame e2e asserts the -~1-line-height margin (inset from a full-overlay detail-area reference) and that the horizontal and vertical -margins are approximately equal, discriminating against both flush (margin 0) and centered (large half-slack) -placement. Applies to ANY floating overlay (percent or cells). All 5 e2e methods still pass. +➕ **Deviation (maintainer feedback — overlay margin, REWORKED to a base-level safe-area inset):** a floating +overlay used to sit FLUSH against the pane border, which looked bad. The FIRST fix insetted only the ANCHORED +edge(s) (`OverlayLayout.anchorInsets`), but that was WRONG — a full-width top band got a top margin yet stayed +flush left/right (no horizontal slack). The maintainer directed a rework to a BASE-LEVEL uniform margin: a +floating overlay now sits inside a uniform SAFE AREA — the pane inset by one line-height (`cell.cellHeight`) +on ALL FOUR sides, symmetric and INDEPENDENT of the anchor (a full-width band is inset left/right exactly like +the top). `anchorInsets` + `OverlayInsets` + the `swiftUIAlignment` mapping were REMOVED; the host-free +`OverlayLayout.panelRect(_:pane:cell:anchor:) -> WindowGeometry.Rect` now returns size AND origin in ONE call +(requested size clamped to the safe area per axis, placed at the anchor's unit position within it; nil/unusable +metrics → `m = 0`; `.full` fills the pane at origin (0,0) with no margin). `overlayPanel` applies it as a +`.frame(rect.size)` + a constant `ZStack(alignment: .topLeading)` and a leading/top `padding(rect.origin)` — a +values-only modifier (no anchor branch), applied AFTER the a11y marker so the marker keeps reporting the +panel's own frame, preserving the NSSplitView-overrun invariant. `OverlayLayoutTests` replaced the +`anchorInsets` cases with `panelRect` safe-area cases (full-size-fills-safe-area / corner-at-margin / center / +full-width-band-inset-all-sides / nil-metrics-no-margin). The frame e2e now takes the detail-area reference +from a FULL overlay (`overlay-full-panel` marker) and asserts a corner panel's ~1-line-height margins AND that +a full-width band (`--cols canvasCols`) is inset off the LEFT and RIGHT (> 0), discriminating against the old +flush-left/right behavior. Applies to ANY floating overlay (percent or cells). All 5 e2e methods still pass. ### Task 8: Keep-in-sync documentation surfaces diff --git a/site/commands.html b/site/commands.html index 8efc1c11..610a51ed 100644 --- a/site/commands.html +++ b/site/commands.html @@ -1196,7 +1196,8 @@ top-right, left, center (default), right, bottom-left, bottom, - bottom-right — inset one line-height off each anchored edge (not flush with the border). + bottom-right — inside a uniform safe-area margin of one line-height on all four sides + (never flush with any border; symmetric and anchor-independent, so a full-width band is inset left/right exactly like the top). It requires a floating size; on a full-pane overlay it errors.

diff --git a/site/docs.html b/site/docs.html index 78b7dd58..b8d64470 100644 --- a/site/docs.html +++ b/site/docs.html @@ -1344,7 +1344,8 @@ — the terminal content area in whole cells at the base font, the coordinate system --cols/--rows land in — so a script can compute a concrete grid as a fraction of the canvas. --anchor parks it at one of nine positions - (center is the default), inset one line-height off each anchored edge. + (center is the default) inside a uniform safe-area margin of one + line-height on all four sides, so it is never flush with a border — the margin is symmetric and anchor-independent, so a full-width band is inset left/right exactly like the top. session overlay resize changes an open overlay in place — a size mode (--size-percent, From b57c431124e9b81e0a71993651b6286644b22321 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 04:01:43 -0500 Subject: [PATCH 18/20] fix: address codex review findings (applied read-back re-report, split canvas measurement) --- .claude/rules/control-api.md | 25 +++++++++++++------ agterm/Control/ControlServer.swift | 25 ++++++++++++------- agterm/Ghostty/GhosttySurfaceView.swift | 12 +++++++++ agterm/Resources/agent-skill/reference.md | 7 +++--- .../Sources/agtermCore/AppStore+Panes.swift | 14 +++++------ .../Sources/agtermCore/OverlayLayout.swift | 15 +++++++++++ .../agtermCoreTests/AppStorePaneTests.swift | 19 +++++++------- .../agtermCoreTests/OverlayLayoutTests.swift | 22 ++++++++++++++++ 8 files changed, 102 insertions(+), 37 deletions(-) diff --git a/.claude/rules/control-api.md b/.claude/rules/control-api.md index 7da4b7ac..b5473f03 100644 --- a/.claude/rules/control-api.md +++ b/.claude/rules/control-api.md @@ -1322,9 +1322,14 @@ paths: change); omitted for a full overlay or none. Distinct from the requested `overlayCols`/`overlayRows`, so an oversized request comes back smaller here and a script can detect the clamp. - The applied grid is EVENTUALLY CONSISTENT: the app refreshes it ASYNC off the realized surface, and a - size-changing `session.overlay.resize` nils it (`resizeOverlay` clears `overlayAppliedCols/Rows` on a size - change) so a read before the refresh returns nil rather than the PRIOR size's grid. + The applied grid is EVENTUALLY CONSISTENT: the app refreshes it ASYNC off the realized surface after an + open/resize, so a read right after a `session.overlay.resize` can briefly report the PRIOR size's grid + until the settle-resize re-reports. + `resizeOverlay` deliberately KEEPS the old applied value across a resize (it does NOT nil it): when the new + size actually changes the physical grid the app re-reports it, and when it does not (e.g. two oversized + cell requests both clamping to the same grid) the old value is already the correct realized grid — niling + it would strand the read-back at nil forever, because the app's last-value dedup suppresses re-reporting an + unchanged grid. So a script must POLL `tree` for the applied fields after an open/resize (mirroring the e2e's `pollOverlayApplied`), not read once, before comparing against the request. `overlayAnchor` — the anchor rawValue of ANY open overlay including a full one @@ -1355,11 +1360,15 @@ paths: It is SPLIT-AGNOSTIC — the whole detail region an overlay fills, taken as ONE area: `canvasRows` is the full height (`ControlServer.sessionCanvasGrid` reads the main pane's rows, since a left/right split keeps full height), and `canvasCols` is the full width — the primary pane's columns when - NOT split, the SUM of both panes' columns when the split is shown side-by-side (`session.isSplit`), and the - split pane's own full-width grid for a hidden split maximized on it (`hasSplit && splitFocused`). - A side-by-side split `canvasCols` slightly UNDERESTIMATES a single full-width surface (the thin divider and - per-pane inner padding are uncounted), exact for the unsplit case — the "as-close-as-possible for split, - exact unsplit" trade documented in the field's godoc. + NOT split (exact), and for a side-by-side split (`session.isSplit`) the WHOLE detail width measured from + BOTH panes' backing-pixel widths at ONE cell size (the primary pane's), floored ONCE + (`OverlayLayout.splitCanvasCols`) — NOT the sum of each pane's already-floored (and possibly different-font) + column count, which double-floors and mixes cell sizes. + A hidden split maximized on the split pane reports that pane's own full-width grid (`hasSplit && splitFocused`). + A side-by-side split `canvasCols` slightly UNDERESTIMATES a single full-width surface only by the thin + divider between the panes (a fraction of a cell; the per-pane inner padding IS now counted, riding in each + pane's pixel width), exact for the unsplit case — the "as-close-as-possible for split, exact unsplit" trade + documented in the field's godoc. Because the main surface's live font tracks `session.fontSize` (cmd-+/-) and a fresh overlay is created with the SAME `session.fontSize`, the grid measured at the main pane's live font equals the base overlay font by construction — so `overlay open --cols canvasCols --rows canvasRows` fills the canvas. diff --git a/agterm/Control/ControlServer.swift b/agterm/Control/ControlServer.swift index 1babc062..a411fd7a 100644 --- a/agterm/Control/ControlServer.swift +++ b/agterm/Control/ControlServer.swift @@ -562,20 +562,27 @@ final class ControlServer { /// The session's terminal content-area grid (columns × rows) for the `tree` `canvasCols`/`canvasRows` /// read-back — the whole detail region a floating overlay fills, measured at the session's base font from /// the live `ghostty_surface_size()` grid (unitless cell counts, no Retina conversion). The primary pane's - /// grid when NOT split; the split pane's columns are ADDED when the split is shown side-by-side (the full - /// detail width, split-agnostic). A hidden split maximized on the split pane reports that pane's own - /// full-width grid. nil when no surface is realized (the overlay-metrics nil convention). + /// grid when NOT split (exact). When the split is shown side-by-side, `canvasCols` is the WHOLE detail + /// width measured from BOTH panes' pixel widths at ONE cell size (`OverlayLayout.splitCanvasCols`) — + /// floored once, not the sum of each pane's already-floored (possibly different-font) column count — so + /// it is the true grid `--cols` fills (the thin divider is uncounted, a fraction of a cell). `canvasRows` + /// is the main pane's rows (a left/right split keeps full height). A hidden split maximized on the split + /// pane reports that pane's own full-width grid. nil when no surface is realized (the overlay-metrics nil + /// convention). private func sessionCanvasGrid(_ session: Session) -> (cols: Int, rows: Int)? { - let main = (session.surface as? GhosttySurfaceView)?.liveGrid() - let split = (session.splitSurface as? GhosttySurfaceView)?.liveGrid() - if session.isSplit, let main, let split { - return (cols: main.cols + split.cols, rows: main.rows) + let mainView = session.surface as? GhosttySurfaceView + let splitView = session.splitSurface as? GhosttySurfaceView + if session.isSplit, let mainView, let splitView, let main = mainView.liveGrid(), + let mainPx = mainView.liveWidthPx(), let splitPx = splitView.liveWidthPx(), + let cols = OverlayLayout.splitCanvasCols(primaryWidthPx: mainPx.widthPx, splitWidthPx: splitPx.widthPx, + primaryCellWidthPx: mainPx.cellWidthPx) { + return (cols: cols, rows: main.rows) } // a hidden (maximized-one-pane) split focused on the split pane: that pane fills the detail width. - if session.hasSplit, session.splitFocused, let split { + if session.hasSplit, session.splitFocused, let split = splitView?.liveGrid() { return split } - return main + return mainView?.liveGrid() } /// Creates a session in `workspaceID` of `store` with the `session.new` args (cwd default $HOME, diff --git a/agterm/Ghostty/GhosttySurfaceView.swift b/agterm/Ghostty/GhosttySurfaceView.swift index 5e5dbcd7..6bd23bba 100644 --- a/agterm/Ghostty/GhosttySurfaceView.swift +++ b/agterm/Ghostty/GhosttySurfaceView.swift @@ -533,6 +533,18 @@ final class GhosttySurfaceView: NSView, TerminalSurface { return (cols: cols, rows: rows) } + /// The surface's live content WIDTH and cell WIDTH in backing pixels from `ghostty_surface_size`, or nil + /// when the surface isn't realized or has no cell size yet. Feeds `sessionCanvasGrid`'s split measurement, + /// which spans both panes' pixel widths at ONE cell size — a px/px ratio, so no Retina conversion. + func liveWidthPx() -> (widthPx: Double, cellWidthPx: Double)? { + guard let surface else { return nil } + let size = ghostty_surface_size(surface) + let cellW = Double(size.cell_width_px) + let widthPx = Double(size.width_px) + guard cellW > 0, widthPx > 0 else { return nil } + return (widthPx: widthPx, cellWidthPx: cellW) + } + /// Reads the overlay surface's live pixel metrics from `ghostty_surface_size`. nil when the surface isn't /// realized or has no cell size yet. The caller converts px→points via `backingScaleFactor` before handing /// them to the host-free layout resolver (which works in the point space `GeometryReader`/`WindowGeometry.Size` diff --git a/agterm/Resources/agent-skill/reference.md b/agterm/Resources/agent-skill/reference.md index 41a5c17a..04ba3736 100644 --- a/agterm/Resources/agent-skill/reference.md +++ b/agterm/Resources/agent-skill/reference.md @@ -141,9 +141,10 @@ floating overlay as a fraction of the canvas: `overlay open --cols $canvas fills it, `--rows $((canvasRows*30/100))` is 30% of the height. It is the WHOLE detail region an overlay fills (everything except the sidebar and title bar) taken as ONE area, split-AGNOSTIC: `canvasRows` is the full height (a left/right split keeps full height) and `canvasCols` the full width — the primary pane's -columns unsplit, the SUM of both panes' columns when split side-by-side (a split `canvasCols` slightly -underestimates a single full-width surface, since the divider and per-pane padding are uncounted; exact -unsplit). Omitted when no surface is realized), and `surfaces` (array +columns unsplit (exact), and when split side-by-side the whole detail width measured from both panes' +pixel widths at ONE cell size (floored once, not a sum of per-pane floored columns; a split `canvasCols` +underestimates a single full-width surface only by the thin divider, a fraction of a cell). Omitted when +no surface is realized), and `surfaces` (array of `{id, kind, active, visible}` where `kind` is `left`|`right`|`scratch`|`overlay`). The surface `id` is the address for `surface zoom`; hidden-but-alive split/scratch surfaces are included so a script can zoom them without changing split/scratch visibility first. Caveat: `active`/`visible` diff --git a/agtermCore/Sources/agtermCore/AppStore+Panes.swift b/agtermCore/Sources/agtermCore/AppStore+Panes.swift index e0e7b011..77bb028a 100644 --- a/agtermCore/Sources/agtermCore/AppStore+Panes.swift +++ b/agtermCore/Sources/agtermCore/AppStore+Panes.swift @@ -222,14 +222,12 @@ extension AppStore { @discardableResult public func resizeOverlay(_ sessionID: UUID, size: OverlaySize? = nil, anchor: OverlayAnchor? = nil) -> Bool { guard let session = session(withID: sessionID), session.overlayActive else { return false } - if let size { - session.overlaySize = AppStore.clampedOverlaySize(size) - // the realized grid is stale until the surface re-settles at the new size (refreshed async by - // the app), so nil it — a `tree` read between the resize and the refresh reports nil (unknown) - // rather than the PRIOR size's grid. A re-anchor-only resize (size nil) leaves it untouched. - session.overlayAppliedCols = nil - session.overlayAppliedRows = nil - } + // keep the realized grid (`overlayAppliedCols/Rows`) as-is across a resize. When the new size DOES + // change the physical grid, the app re-reports it on the surface's settle-resize (eventually + // consistent). When it does NOT (e.g. two oversized cell requests clamping to the same grid), the + // old value is still the correct realized grid — and the app's last-value dedup would suppress any + // re-report, so niling it here would strand the read-back at nil forever. + if let size { session.overlaySize = AppStore.clampedOverlaySize(size) } if let anchor { session.overlayAnchor = anchor } return true } diff --git a/agtermCore/Sources/agtermCore/OverlayLayout.swift b/agtermCore/Sources/agtermCore/OverlayLayout.swift index 8099898e..0ddabea6 100644 --- a/agtermCore/Sources/agtermCore/OverlayLayout.swift +++ b/agtermCore/Sources/agtermCore/OverlayLayout.swift @@ -133,4 +133,19 @@ public enum OverlayLayout { return WindowGeometry.Rect(origin: WindowGeometry.Point(x: originX, y: originY), size: WindowGeometry.Size(width: width, height: height)) } + + /// Whole-cell column count spanning a side-by-side split's full detail WIDTH, measured from the two + /// panes' backing-pixel widths at ONE cell size (the primary pane's) and floored ONCE. This is the + /// true single full-detail grid a floating overlay's `--cols` fills — it avoids the double-floor and the + /// mixed-font error of summing each pane's already-floored column count (the panes can carry different + /// live font sizes). The thin divider between the panes is uncounted, so it underestimates the whole + /// detail width by a fraction of a cell. Unitless (px / px), so no Retina conversion. nil when the + /// primary cell width or the total width is non-positive. + public static func splitCanvasCols(primaryWidthPx: Double, splitWidthPx: Double, + primaryCellWidthPx: Double) -> Int? { + guard primaryCellWidthPx > 0 else { return nil } + let totalWidthPx = primaryWidthPx + splitWidthPx + guard totalWidthPx > 0 else { return nil } + return Int((totalWidthPx / primaryCellWidthPx).rounded(.down)) + } } diff --git a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift index e5e5be2d..9c113ecb 100644 --- a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift +++ b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift @@ -574,7 +574,7 @@ struct AppStorePaneTests { #expect(session.overlayAnchor == .bottomLeft) } - @Test func resizeOverlaySizeChangeClearsStaleAppliedGrid() { + @Test func resizeOverlayKeepsAppliedGridForAppToReReport() { let store = makeStore() let ws = store.addWorkspace(name: "work") let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! @@ -582,16 +582,17 @@ struct AppStorePaneTests { // simulate the app-maintained realized grid from the first size. session.overlayAppliedCols = 72 session.overlayAppliedRows = 20 - // a SIZE-changing resize nils the stale realized grid so a read before the async refresh returns nil. + // a SIZE-changing resize does NOT nil the realized grid: the app re-reports it on the settle-resize + // when the grid actually changes, and if the grid stays identical (e.g. two oversized requests both + // clamping to the same grid) the old value is still correct — niling it would strand the read-back at + // nil forever because the app's last-value dedup suppresses re-reporting an unchanged grid. #expect(store.resizeOverlay(session.id, size: .cells(cols: 30, rows: 10)) == true) - #expect(session.overlayAppliedCols == nil) - #expect(session.overlayAppliedRows == nil) - // a re-anchor-only resize (nil size) does NOT touch the realized grid. - session.overlayAppliedCols = 28 - session.overlayAppliedRows = 9 + #expect(session.overlayAppliedCols == 72) + #expect(session.overlayAppliedRows == 20) + // a re-anchor-only resize (nil size) also leaves the realized grid untouched. #expect(store.resizeOverlay(session.id, anchor: .topLeft) == true) - #expect(session.overlayAppliedCols == 28) - #expect(session.overlayAppliedRows == 9) + #expect(session.overlayAppliedCols == 72) + #expect(session.overlayAppliedRows == 20) } @Test func resizeOverlayFullPreservesAnchor() { diff --git a/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift b/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift index 7ee10d3d..37738669 100644 --- a/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift +++ b/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift @@ -252,4 +252,26 @@ struct OverlayLayoutTests { #expect(800 - (rect.origin.x + rect.size.width) == 16) // right margin present despite top-left anchor #expect(600 - (rect.origin.y + rect.size.height) == 16) // bottom margin present too } + + @Test func splitCanvasColsFloorsCombinedWidthOnce() { + // two 800px panes at an 8px cell: 1600/8 = 200 columns spanning the whole detail width. + #expect(OverlayLayout.splitCanvasCols(primaryWidthPx: 800, splitWidthPx: 800, primaryCellWidthPx: 8) == 200) + } + + @Test func splitCanvasColsFloorsOnceNotPerPane() { + // each pane holds 12.5 cells (100/8); summing per-pane floored counts gives 12+12 = 24, but the whole + // detail width is 200/8 = 25 — flooring ONCE recovers the cell straddling the (uncounted) divider. + #expect(OverlayLayout.splitCanvasCols(primaryWidthPx: 100, splitWidthPx: 100, primaryCellWidthPx: 8) == 25) + } + + @Test func splitCanvasColsUsesPrimaryCellSizeIgnoringSplitFont() { + // the split pane's own (larger) font is irrelevant — the combined width is measured at the PRIMARY + // cell size: (400 + 320) / 8 = 90, not a mix of 8px and 16px cells. + #expect(OverlayLayout.splitCanvasCols(primaryWidthPx: 400, splitWidthPx: 320, primaryCellWidthPx: 8) == 90) + } + + @Test func splitCanvasColsNilForNonPositiveInputs() { + #expect(OverlayLayout.splitCanvasCols(primaryWidthPx: 800, splitWidthPx: 800, primaryCellWidthPx: 0) == nil) + #expect(OverlayLayout.splitCanvasCols(primaryWidthPx: 0, splitWidthPx: 0, primaryCellWidthPx: 8) == nil) + } } From 3a6b642380060c06be8115eba1500494bd40d087 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 04:14:11 -0500 Subject: [PATCH 19/20] docs: correct canvasCols contract (safe-area clamp + pixel split measurement) --- .claude/rules/control-api.md | 7 +++++- agterm/Control/ControlServer.swift | 20 ++++++++------- agterm/Resources/agent-skill/SKILL.md | 6 +++-- agterm/Resources/agent-skill/examples.md | 6 +++-- agterm/Resources/agent-skill/reference.md | 19 ++++++++------ .../Sources/agtermCore/ControlProtocol.swift | 25 +++++++++++-------- ...20260722-overlay-anchor-and-cell-sizing.md | 13 ++++++---- site/commands.html | 7 ++++-- 8 files changed, 63 insertions(+), 40 deletions(-) diff --git a/.claude/rules/control-api.md b/.claude/rules/control-api.md index b5473f03..4dfce260 100644 --- a/.claude/rules/control-api.md +++ b/.claude/rules/control-api.md @@ -1371,7 +1371,12 @@ paths: documented in the field's godoc. Because the main surface's live font tracks `session.fontSize` (cmd-+/-) and a fresh overlay is created with the SAME `session.fontSize`, the grid measured at the main pane's live font equals the base overlay - font by construction — so `overlay open --cols canvasCols --rows canvasRows` fills the canvas. + font by construction. + A floating overlay does NOT fill the canvas edge-to-edge, though: every floating overlay is inset by a + uniform one-line-height SAFE-AREA margin on all four sides, so `overlay open --cols canvasCols` CLAMPS to + the usable area (canvas minus the margins) rather than filling it. + A script wanting an exact fit reads the realized grid back on `overlayColsApplied`/`overlayRowsApplied`, + or requests within (canvas − margin). Omitted when no surface is realized (the overlay-metrics nil convention). `tree` ALSO carries, at the TOP level (alongside `idleMs`/`autoFollowMs`), `sidebarVisible` — the read side of the write-only `sidebar` command (per-window sidebar visibility), populated LIVE from the diff --git a/agterm/Control/ControlServer.swift b/agterm/Control/ControlServer.swift index a411fd7a..1ca9abc8 100644 --- a/agterm/Control/ControlServer.swift +++ b/agterm/Control/ControlServer.swift @@ -560,15 +560,17 @@ final class ControlServer { } /// The session's terminal content-area grid (columns × rows) for the `tree` `canvasCols`/`canvasRows` - /// read-back — the whole detail region a floating overlay fills, measured at the session's base font from - /// the live `ghostty_surface_size()` grid (unitless cell counts, no Retina conversion). The primary pane's - /// grid when NOT split (exact). When the split is shown side-by-side, `canvasCols` is the WHOLE detail - /// width measured from BOTH panes' pixel widths at ONE cell size (`OverlayLayout.splitCanvasCols`) — - /// floored once, not the sum of each pane's already-floored (possibly different-font) column count — so - /// it is the true grid `--cols` fills (the thin divider is uncounted, a fraction of a cell). `canvasRows` - /// is the main pane's rows (a left/right split keeps full height). A hidden split maximized on the split - /// pane reports that pane's own full-width grid. nil when no surface is realized (the overlay-metrics nil - /// convention). + /// read-back — the whole detail region an overlay is placed within, measured at the session's base font + /// from the live `ghostty_surface_size()` grid (unitless cell counts, no Retina conversion). The primary + /// pane's grid when NOT split (exact). When the split is shown side-by-side, `canvasCols` is the WHOLE + /// detail width measured from BOTH panes' backing-pixel widths at ONE cell size + /// (`OverlayLayout.splitCanvasCols`) — floored once, not the sum of each pane's already-floored + /// (possibly different-font) column count — so the per-pane inner padding IS counted and only the thin + /// divider (a fraction of a cell) is uncounted. `canvasRows` is the main pane's rows (a left/right split + /// keeps full height). A hidden split maximized on the split pane reports that pane's own full-width grid. + /// A floating overlay is inset by a uniform one-line-height safe-area margin, so a `--cols canvasCols` + /// request clamps to the usable area (read `overlayColsApplied`/`overlayRowsApplied` for the realized + /// grid). nil when no surface is realized (the overlay-metrics nil convention). private func sessionCanvasGrid(_ session: Session) -> (cols: Int, rows: Int)? { let mainView = session.surface as? GhosttySurfaceView let splitView = session.splitSurface as? GhosttySurfaceView diff --git a/agterm/Resources/agent-skill/SKILL.md b/agterm/Resources/agent-skill/SKILL.md index 2bfc8012..da663196 100644 --- a/agterm/Resources/agent-skill/SKILL.md +++ b/agterm/Resources/agent-skill/SKILL.md @@ -174,8 +174,10 @@ the read side of `session resize`, record it to restore the exact divider), `spl when there's no split; the read side of `session focus`, record it to restore focus), `canvasCols`/`canvasRows` (the session's terminal CONTENT AREA — the "overlay canvas" — in whole cells at the base font, so a script sizes a floating overlay as a fraction of the canvas: `overlay open --cols -$canvasCols --rows $((canvasRows*30/100)) --anchor top` is a 30%-tall top strip; split-agnostic — full -height, full width across the whole detail area; omitted when no surface is realized), and `surfaces` +$canvasCols --rows $((canvasRows*30/100)) --anchor top` is a ~30%-tall top strip; split-agnostic — full +height, full width across the whole detail area. A floating overlay is inset by a uniform one-line-height +safe-area margin, so `--cols $canvasCols` CLAMPS to the usable width (read the realized grid back on +`overlayColsApplied`/`overlayRowsApplied`); omitted when no surface is realized), and `surfaces` (`id`, `kind`, `active`, `visible`) for `surface zoom`. The tree top level carries `zoomedSurface` (the control id of the currently zoomed surface, omitted when nothing is zoomed — the read side of `surface zoom`, so a script can check the zoom state and record-then-restore). It also carries the read diff --git a/agterm/Resources/agent-skill/examples.md b/agterm/Resources/agent-skill/examples.md index 0de62ea0..96023100 100644 --- a/agterm/Resources/agent-skill/examples.md +++ b/agterm/Resources/agent-skill/examples.md @@ -239,12 +239,14 @@ agtermctl tree --json | jq '.. | objects | select(.overlay == true) | {overlayCo Size an overlay as a FRACTION of the canvas: read `canvasCols`/`canvasRows` off `tree` (the terminal content area in cells at the base font — the coordinate system `--cols/--rows` land in), then compute a -concrete grid. A 30%-tall top strip spanning the full width: +concrete grid. A ~30%-tall top strip spanning the full usable width (a floating overlay is inset by a +uniform one-line-height safe-area margin, so a `--cols $canvasCols` request clamps to the usable width — +read `overlayColsApplied`/`overlayRowsApplied` back for the exact rendered grid): ```bash read -r cols rows < <(agtermctl tree --json \ | jq -r --arg id "$AGTERM_SESSION_ID" '.result.tree.workspaces[].sessions[] | select(.id|ascii_downcase == ($id|ascii_downcase)) | "\(.canvasCols) \(.canvasRows)"') -# a top strip 30% of the height, full width, parked at the top +# a top strip ~30% of the height, full usable width (clamped inside the safe-area margin), parked at the top agtermctl session overlay open "zsh -lc 'tail -f /var/log/system.log'" --target "$AGTERM_SESSION_ID" \ --cols "$cols" --rows "$(( rows * 30 / 100 ))" --anchor top ``` diff --git a/agterm/Resources/agent-skill/reference.md b/agterm/Resources/agent-skill/reference.md index 04ba3736..1f5c0406 100644 --- a/agterm/Resources/agent-skill/reference.md +++ b/agterm/Resources/agent-skill/reference.md @@ -137,14 +137,17 @@ default/left target (the main pane, or the promoted split survivor once the prim promoted survivor are live-only — read them back here rather than from the snapshot), `canvasCols`/`canvasRows` (the session's terminal CONTENT AREA — the "overlay canvas" — in whole cells at the session's BASE font, the coordinate system `overlay open --cols/--rows` land in; so a script sizes a -floating overlay as a fraction of the canvas: `overlay open --cols $canvasCols --rows $canvasRows` -fills it, `--rows $((canvasRows*30/100))` is 30% of the height. It is the WHOLE detail region an overlay -fills (everything except the sidebar and title bar) taken as ONE area, split-AGNOSTIC: `canvasRows` is the -full height (a left/right split keeps full height) and `canvasCols` the full width — the primary pane's -columns unsplit (exact), and when split side-by-side the whole detail width measured from both panes' -pixel widths at ONE cell size (floored once, not a sum of per-pane floored columns; a split `canvasCols` -underestimates a single full-width surface only by the thin divider, a fraction of a cell). Omitted when -no surface is realized), and `surfaces` (array +floating overlay as a fraction of the canvas: `--rows $((canvasRows*30/100))` is 30% of the height. It is +the WHOLE detail region an overlay is placed within (everything except the sidebar and title bar) taken as +ONE area, split-AGNOSTIC: `canvasRows` is the full height (a left/right split keeps full height) and +`canvasCols` the full width — the primary pane's columns unsplit (exact), and when split side-by-side the +whole detail width measured from both panes' pixel widths at ONE cell size (floored once, so the per-pane +padding IS counted, not a sum of per-pane floored columns; a split `canvasCols` underestimates a single +full-width surface only by the thin divider, a fraction of a cell). A floating overlay does NOT fill the +canvas edge-to-edge: it is inset by a uniform one-line-height safe-area margin on all four sides, so a +`--cols $canvasCols` request CLAMPS to the usable area — read the realized grid back on +`overlayColsApplied`/`overlayRowsApplied`, or request within (canvas − margin) for an exact fit. Omitted +when no surface is realized), and `surfaces` (array of `{id, kind, active, visible}` where `kind` is `left`|`right`|`scratch`|`overlay`). The surface `id` is the address for `surface zoom`; hidden-but-alive split/scratch surfaces are included so a script can zoom them without changing split/scratch visibility first. Caveat: `active`/`visible` diff --git a/agtermCore/Sources/agtermCore/ControlProtocol.swift b/agtermCore/Sources/agtermCore/ControlProtocol.swift index 32bb6bb4..567c40cd 100644 --- a/agtermCore/Sources/agtermCore/ControlProtocol.swift +++ b/agtermCore/Sources/agtermCore/ControlProtocol.swift @@ -470,17 +470,20 @@ public struct ControlSessionNode: Codable, Sendable, Equatable { public let surfaces: [ControlSurfaceNode]? /// The session's terminal CONTENT AREA (the overlay "canvas") in whole cells at the session's BASE font /// — the font a fresh `session.overlay.open` uses — so a script can size a floating overlay as a - /// fraction of the canvas: `overlay open --cols canvasCols --rows canvasRows` fills it, `--rows - /// round(0.30 * canvasRows)` is 30% of the height. It is the WHOLE detail region an overlay fills - /// (everything except the sidebar and title bar) taken as ONE area, SPLIT-AGNOSTIC: `canvasRows` is the - /// full height in cells (a left/right split keeps full height) and `canvasCols` is the full width across - /// the whole detail area — the primary pane's columns when NOT split, and the SUM of the two panes' - /// columns when the split is shown side-by-side. Exact for the unsplit case; for a side-by-side split - /// the thin divider and per-pane inner padding are not counted, so `canvasCols` slightly UNDERESTIMATES a - /// single full-width surface (best-effort — it never over-reports the full width). nil/omitted when no - /// surface is realized / the grid is unknown. Measured app-side from the live `ghostty_surface_size()` - /// grid (unitless cell counts — no Retina conversion), which tracks the main pane's cmd-+/- zoom exactly - /// as the base overlay font does. + /// fraction of the canvas: `--rows round(0.30 * canvasRows)` is a 30%-tall strip. It reports the + /// coordinate system `session.overlay.open --cols/--rows` land in, taken as ONE area and SPLIT-AGNOSTIC: + /// `canvasRows` is the full height in cells (a left/right split keeps full height) and `canvasCols` is the + /// full width across the whole detail area — the primary pane's columns when NOT split (exact), and for a + /// side-by-side split the whole detail width measured from BOTH panes' BACKING-PIXEL widths at ONE cell + /// size (the primary pane's), floored ONCE — so the per-pane inner padding IS counted and only the thin + /// divider (a fraction of a cell) is uncounted, slightly UNDERESTIMATING a single full-width surface + /// (best-effort — it never over-reports). A floating overlay does NOT fill the canvas edge-to-edge: every + /// floating overlay is inset by a uniform one-line-height SAFE-AREA margin on all four sides, so a request + /// as large as the canvas (`--cols canvasCols`) CLAMPS to the usable area (canvas minus the margins). Read + /// the realized grid back on `overlayColsApplied`/`overlayRowsApplied` for the exact result, or request + /// within (canvas − margin) for an exact fit. nil/omitted when no surface is realized / the grid is + /// unknown. Measured app-side from the live `ghostty_surface_size()` grid (unitless cell counts — no + /// Retina conversion), which tracks the main pane's cmd-+/- zoom exactly as the base overlay font does. public let canvasCols: Int? public let canvasRows: Int? diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md index b9777306..0642fe3c 100644 --- a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md +++ b/docs/plans/20260722-overlay-anchor-and-cell-sizing.md @@ -223,7 +223,8 @@ maintainer decisions are folded in below: ➕ **Follow-on addition (post-completion scope): canvas grid read-back on `tree`.** A separate, maintainer-confirmed addition beyond the original ten tasks: report the terminal CONTENT AREA size in cells on each `tree` session node so a script can compute concrete `overlay open --cols/--rows` as a -fraction of the canvas (`--rows canvasRows` fills the height, `--rows round(0.30*canvasRows)` is 30%). +fraction of the canvas (`--rows round(0.30*canvasRows)` is a ~30%-tall strip; note a floating overlay is +inset by a one-line-height safe-area margin, so a full-canvas request clamps to the usable area). - Two new `ControlSessionNode` fields `canvasCols`/`canvasRows` (`ControlProtocol.swift`), optional + omit-when-nil, measured at the session's BASE font (the font a fresh overlay uses) so the reported grid is exactly the coordinate system `--cols/--rows` land in. @@ -234,10 +235,12 @@ fraction of the canvas (`--rows canvasRows` fills the height, `--rows round(0.30 px→point Retina conversion, unlike the overlay cell metrics). - **Split handling (measurement decision):** split-agnostic — the whole detail region taken as ONE area. `canvasRows` = the main pane's rows (full height; a left/right split keeps full height). - `canvasCols` = the primary pane's columns when NOT split (exact), the SUM of both panes' columns when the - split is shown side-by-side (`isSplit`), or the split pane's own full-width grid for a hidden split - maximized on it (`hasSplit && splitFocused`). A side-by-side split `canvasCols` slightly underestimates a - single full-width surface (the divider + per-pane inner padding are uncounted) — the confirmed + `canvasCols` = the primary pane's columns when NOT split (exact), the whole detail width measured from BOTH + panes' BACKING-PIXEL widths at ONE cell size (the primary pane's) and floored ONCE + (`OverlayLayout.splitCanvasCols`) when the split is shown side-by-side (`isSplit`), or the split pane's own + full-width grid for a hidden split maximized on it (`hasSplit && splitFocused`). A side-by-side split + `canvasCols` slightly underestimates a single full-width surface by ONLY the thin divider (a fraction of a + cell; the per-pane inner padding IS counted, riding in each pane's pixel width) — the confirmed "exact-unsplit, as-close-as-possible-for-split" trade, documented in the field godoc; never the half-width main pane alone. - Tests: round-trip + omit-when-nil in `ControlProtocolTests`; a `controlTree` populate test diff --git a/site/commands.html b/site/commands.html index 610a51ed..e3584d37 100644 --- a/site/commands.html +++ b/site/commands.html @@ -450,8 +450,11 @@ canvasRows (the terminal content area — the overlay "canvas" — in whole cells at the base font, the coordinate system overlay open --cols/--rows land in, - so a script can size a floating overlay as a fraction of the canvas; split-agnostic full height and full width, - omitted when no surface is realized). + so a script can size a floating overlay as a fraction of the canvas; split-agnostic full height and full width. A floating + overlay is inset by a uniform one-line-height safe-area margin, so a + --cols canvasCols request clamps to the + usable area — read overlayColsApplied/overlayRowsApplied + back for the realized grid. Omitted when no surface is realized).

Workspace node: From 8729b28f872dc306620a93a14a45bb4724a2eb50 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 23 Jul 2026 04:20:35 -0500 Subject: [PATCH 20/20] docs: move completed plan 20260722-overlay-anchor-and-cell-sizing.md to completed/ --- .../{ => completed}/20260722-overlay-anchor-and-cell-sizing.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/plans/{ => completed}/20260722-overlay-anchor-and-cell-sizing.md (100%) diff --git a/docs/plans/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/completed/20260722-overlay-anchor-and-cell-sizing.md similarity index 100% rename from docs/plans/20260722-overlay-anchor-and-cell-sizing.md rename to docs/plans/completed/20260722-overlay-anchor-and-cell-sizing.md