agentmux_cef/browser_panes.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! BrowserPaneManager: embeds browsers as native OS child windows using
5//! CefBrowserHost::CreateBrowser. All creation runs on the CEF UI thread.
6//!
7//! The Browser instance is owned by the host reducer's `browsers` map (keyed
8//! by label, accessed via `AppState::get_browser` etc). We only need the
9//! block_id → label mapping, which also lives in the reducer's `panes` map.
10//!
11//! Lifecycle states are tracked explicitly via the reducer's `BrowserPaneLifecycle`:
12//! Created → Closing → Closed (removed from `panes`)
13//! Every pane-facing op (focus/resize/navigate/…) short-circuits when the
14//! entry is already in `Closing`. This drops late IPC that the frontend
15//! fires after it has already asked for close but before CEF has destroyed
16//! the Browser — stale IPC against a mid-destruction HWND is the shape of
17//! the crash described in `docs/specs/SPEC_BROWSER_PANE_LIFECYCLE.md` §4c.
18//!
19//! **Phase H.1.d/e (PR #5):** The legacy `pane::lifecycle::PaneStateMachine`
20//! is gone; pane state lives only in `HostState.browser_panes`. All mutations go
21//! through `HostCommand::TryRegisterBrowserPaneLive` / `EnqueueBrowserPaneClose` /
22//! `CompleteBrowserPaneClose` / `DrainBrowserPaneByLabel` and read back via the reducer's
23//! atomic `DispatchOutput` fields.
24
25use std::sync::Arc;
26
27use cef::*;
28
29use crate::browser_pane::CreateBrowserPaneTask;
30use crate::reducer::RegisterResult;
31use crate::state::AppState;
32
33/// Abstraction over the CEF-side operations `close()` performs on the host
34/// process. Production implements this over `&Arc<AppState>` and Win32;
35/// tests implement it with a recording mock so the close-path state machine
36/// can be exercised without real CEF/HWNDs.
37///
38/// Kept minimal (just two methods) to avoid a dependency graph that has to
39/// be updated every time `close()` grows. Other ops (`focus`, `resize`, …)
40/// can gain their own traits when they need testing, or graduate to a
41/// unified `BrowserPaneCefBridge` once the shape is stable.
42pub trait BrowserPaneCloseOps {
43 /// Remove the Browser for this label from the registry. Return its
44 /// outer HWND as a pointer-sized value, or `None` if there is no
45 /// Browser or no HWND. Dropping the Browser Arc is the implementation's
46 /// responsibility — production drops before returning so Chromium's
47 /// refcount isn't held by our scope.
48 fn take_browser_hwnd(&self, label: &str) -> Option<usize>;
49
50 /// Destroy the given HWND. Production calls Win32 `DestroyWindow`.
51 /// Called only with values returned from `take_browser_hwnd`.
52 fn destroy_hwnd(&self, hwnd: usize);
53}
54
55/// Production implementation of `BrowserPaneCloseOps` backed by `AppState.browsers`
56/// and Win32 `DestroyWindow`.
57struct AppStateCloseOps<'a>(&'a Arc<AppState>);
58
59impl<'a> BrowserPaneCloseOps for AppStateCloseOps<'a> {
60 fn take_browser_hwnd(&self, label: &str) -> Option<usize> {
61 // Atomic take-and-return via reducer (codex P2 PR #660). Earlier
62 // round 1 separated `get_browser` + `UnregisterBrowser` dispatch,
63 // which left a window for concurrent readers to resolve the same
64 // label and act on the closing handle. `UnregisterBrowser` now
65 // returns the removed `Browser` via `DispatchOutput.removed_browser`
66 // — single host_state lock, single mutation, no race.
67 let out = self.0.host_dispatch(
68 crate::reducer::HostCommand::UnregisterBrowser {
69 label: label.to_string(),
70 },
71 );
72 let browser = out.removed_browser?;
73
74 #[cfg(target_os = "windows")]
75 let hwnd = browser.host().and_then(|h| {
76 let wh = h.window_handle();
77 if wh.0.is_null() {
78 None
79 } else {
80 Some(wh.0 as usize)
81 }
82 });
83 #[cfg(not(target_os = "windows"))]
84 let hwnd: Option<usize> = None;
85
86 // Drop our Arc before returning so Chromium's refcount doesn't wait
87 // for the caller's scope to unwind.
88 drop(browser);
89 hwnd
90 }
91
92 fn destroy_hwnd(&self, hwnd: usize) {
93 #[cfg(target_os = "windows")]
94 unsafe {
95 use windows_sys::Win32::UI::WindowsAndMessaging::{
96 DestroyWindow, GetParent, ShowWindow, SW_HIDE,
97 };
98 use windows_sys::Win32::Graphics::Gdi::{InvalidateRect, UpdateWindow};
99 let h = hwnd as *mut std::ffi::c_void;
100
101 // Capture the parent BEFORE we destroy the HWND — GetParent(h)
102 // on a destroyed HWND returns null.
103 let parent = GetParent(h);
104
105 // Hide first so DWM stops compositing the pane's GPU surface.
106 // Without this, even after DestroyWindow the Chromium compositor's
107 // last-rendered frame can stay "stuck" on-screen because the GPU
108 // process is still alive and DWM was caching that layer. Observed
109 // in v0.33.259 with a loaded google.com pane — close fires,
110 // lifecycle entry clears, HWND is gone — but the page pixels
111 // persist over the main frame until a resize/redraw.
112 ShowWindow(h, SW_HIDE);
113
114 DestroyWindow(h);
115
116 // Ask the parent (main's top-level) to repaint the area where the
117 // pane used to sit. Without InvalidateRect + UpdateWindow, DWM
118 // may keep showing the cached pane surface until unrelated UI
119 // activity happens to repaint over it.
120 if !parent.is_null() {
121 InvalidateRect(parent, std::ptr::null(), 1 /* TRUE erase */);
122 UpdateWindow(parent);
123 }
124 }
125 #[cfg(not(target_os = "windows"))]
126 {
127 let _ = hwnd;
128 }
129 }
130}
131
132pub struct BrowserPaneManager;
133
134impl BrowserPaneManager {
135 pub fn new() -> Self {
136 Self
137 }
138
139 /// Look up the Browser iff the pane is Live. Returns None when closing
140 /// so all ops short-circuit uniformly.
141 fn live_browser(&self, state: &Arc<AppState>, block_id: &str) -> Option<Browser> {
142 let label = state.live_browser_pane_label(block_id)?;
143 state.get_browser(&label)
144 }
145
146 /// Return the current URL of the pane's main frame, if the pane
147 /// is Live. Used by the browser DOM API resolver
148 /// (`crate::browser_api::resolver`) to match CEF `/json` targets
149 /// against block ids without a first-class `browserId` field on
150 /// the CEF side.
151 pub fn pane_url(&self, state: &Arc<AppState>, block_id: &str) -> Option<String> {
152 let browser = self.live_browser(state, block_id)?;
153 let frame = browser.main_frame()?;
154 Some(CefString::from(&frame.url()).to_string())
155 }
156
157 pub fn create(
158 &self,
159 state: &Arc<AppState>,
160 block_id: &str,
161 url: &str,
162 rect: Rect,
163 window_label: &str,
164 ) -> Result<(), String> {
165 // Phase H.1.d (PR #5) — sole pane-registration entry point. The
166 // reducer atomically generates the label and inserts the entry,
167 // returning Fresh / AlreadyLive / Closing via DispatchOutput.
168 let out = state.host_dispatch(
169 crate::reducer::HostCommand::TryRegisterBrowserPaneLive {
170 block_id: block_id.to_string(),
171 },
172 );
173 let result = out.browser_pane_register_result.ok_or_else(|| {
174 format!(
175 "try_register_browser_pane_live returned no result (block_id={}); host shutting down?",
176 block_id
177 )
178 })?;
179 match result {
180 RegisterResult::AlreadyLive(label) => {
181 // Existing Live entry — re-navigate the existing browser.
182 if let Some(browser) = state.get_browser(&label) {
183 if let Some(frame) = browser.main_frame() {
184 frame.load_url(Some(&CefString::from(url)));
185 }
186 }
187 Ok(())
188 }
189 RegisterResult::Closing => {
190 // Reject rather than overwrite: the old CEF Browser is
191 // mid-teardown and its on_before_close will call
192 // DrainBrowserPaneByLabel — if we let create overwrite, drain
193 // would evict the NEW entry. Frontend retries on next tick.
194 Err(format!(
195 "browser pane for block_id={} is still closing; retry after on_before_close",
196 block_id
197 ))
198 }
199 RegisterResult::Fresh(label) => {
200 let mut task = CreateBrowserPaneTask::new(
201 state.clone(),
202 block_id.to_string(),
203 label,
204 url.to_string(),
205 rect,
206 window_label.to_string(),
207 );
208 post_task(ThreadId::UI, Some(&mut task));
209 Ok(())
210 }
211 }
212 }
213
214 pub fn navigate(&self, block_id: &str, url: &str, state: &Arc<AppState>) -> Result<(), String> {
215 if let Some(browser) = self.live_browser(state, block_id) {
216 if let Some(frame) = browser.main_frame() {
217 frame.load_url(Some(&CefString::from(url)));
218 }
219 }
220 Ok(())
221 }
222
223 pub fn resize(&self, block_id: &str, rect: Rect, state: &Arc<AppState>) {
224 #[cfg(target_os = "windows")]
225 if let Some(browser) = self.live_browser(state, block_id) {
226 if let Some(host) = browser.host() {
227 let hwnd = host.window_handle();
228 if !hwnd.0.is_null() {
229 unsafe {
230 windows_sys::Win32::UI::WindowsAndMessaging::SetWindowPos(
231 hwnd.0 as _,
232 std::ptr::null_mut(),
233 rect.x, rect.y, rect.width, rect.height,
234 0x0010, // SWP_NOACTIVATE
235 );
236 }
237 host.notify_move_or_resize_started();
238 }
239 }
240 }
241 // Linux/macOS — Views path. The pane is a CefBrowserView in the main
242 // window's view hierarchy; resizing is `View::set_bounds` in DIP. Must
243 // run on the CEF UI thread (set_bounds is UI-thread-only).
244 #[cfg(not(target_os = "windows"))]
245 {
246 let label = match state.live_browser_pane_label(block_id) {
247 Some(l) => l,
248 None => return, // pane already closed or never created
249 };
250 let mut task = ResizeBrowserPaneViewTask::new(state.clone(), label, rect);
251 cef::post_task(cef::ThreadId::UI, Some(&mut task));
252 }
253 }
254
255 /// Close a pane by destroying its child HWND directly and dropping the
256 /// Browser Arc.
257 ///
258 /// We deliberately do **not** call `host.close_browser(force)`. Empirically
259 /// (host-log trace v0.33.251 and v0.33.252 in `SPEC_BROWSER_PANE_LIFECYCLE.md`
260 /// §4), CEF Alloy treats the pane Browser and the main Browser as a single
261 /// close unit when the pane's outer HWND is a child of main's top-level:
262 /// `close_browser(pane)` fires `do_close` on main too. Previous attempts
263 /// (force=0, force=1, a cascade-guard cancelling main's do_close) either
264 /// quit the whole app or orphaned the pane's pixels while blocking the
265 /// pane's own teardown.
266 ///
267 /// Instead:
268 /// 1. Remove the Browser from `state.browsers` so subsequent lookups miss.
269 /// 2. Win32 `DestroyWindow` on the pane's outer HWND. The pane HWND is a
270 /// `WS_CHILD`; `WM_DESTROY` cascades to descendants only, never to the
271 /// parent. Main stays up.
272 /// 3. Drop our `Browser` Arc. CEF still holds refs (browser_list etc.);
273 /// `on_before_close` *may* eventually fire on the now-destroyed Browser,
274 /// which is why `drain_closed_label` is idempotent.
275 ///
276 /// Trade-off: because we bypass `close_browser`, Chromium's `beforeunload`
277 /// handler doesn't run. Acceptable for a browser pane (no form data the
278 /// user expects to persist across close). If beforeunload becomes
279 /// important, revisit.
280 pub fn close(&self, block_id: &str, state: &Arc<AppState>) {
281 // Phase H.1.d (PR #5) — sole pane-close entry point. The reducer
282 // flips Live→Closing atomically and returns the entry's label iff
283 // the transition fired. None means missing or already-Closing —
284 // both idempotent no-ops; we don't dispatch CompleteBrowserPaneClose in
285 // those cases (codex P2 PR #655 race), avoiding the entry removal
286 // while another in-flight close is still tearing down the HWND.
287 let close_out = state.host_dispatch(
288 crate::reducer::HostCommand::EnqueueBrowserPaneClose {
289 block_id: block_id.to_string(),
290 },
291 );
292 let label = match close_out.closed_browser_pane_label {
293 Some(l) => l,
294 None => return,
295 };
296 #[cfg(target_os = "windows")]
297 {
298 let ops = AppStateCloseOps(state);
299 Self::close_with(&label, &ops);
300 }
301 // Linux/macOS — Views path. Marshal the BrowserView detach onto the
302 // CEF UI thread (remove_child_view is UI-thread-only); the underlying
303 // Browser's on_before_close fires asynchronously and clears
304 // state.browsers via the existing callback (callbacks::on_before_close_browser_pane).
305 #[cfg(not(target_os = "windows"))]
306 {
307 let mut task = DetachBrowserPaneViewTask::new(state.clone(), label.clone());
308 cef::post_task(cef::ThreadId::UI, Some(&mut task));
309 }
310 state.host_dispatch(
311 crate::reducer::HostCommand::CompleteBrowserPaneClose {
312 block_id: block_id.to_string(),
313 },
314 );
315 tracing::info!(block_id, label, "browser pane closed");
316
317 // PR #6 H.7 kick — top up the pool now that this pane has closed.
318 // `spawn_pool_window` is internally idempotent (single-flight +
319 // below-target check), so calling on every pane close is safe.
320 //
321 // Cross-platform: the original `weak_ptr.h:250` race that prompted
322 // an earlier Windows-only cfg-gate is gone. With the deferred
323 // OverlayController destroy (see
324 // browser_pane/creation_views.rs::detach_browser_pane_view),
325 // close() no longer destroys the controller synchronously — it
326 // just stashes it for on_before_close to destroy later. Creating
327 // a new pool window here therefore can't race a synchronous
328 // destroy of the just-closed pane's View. drain_closed_label's
329 // pool kick can't be relied on as the sole refill source either:
330 // CompleteBrowserPaneClose (dispatched above) already removed the
331 // reducer entry, so DrainBrowserPaneByLabel inside
332 // drain_closed_label is a no-op and never reaches its
333 // spawn_pool_window() call (codex P2 on PR #788).
334 crate::commands::window_pool::spawn_pool_window(state);
335 }
336
337 /// The testable side-effect body of `close()`. Given a pane's `label`,
338 /// remove its Browser handle and destroy its HWND. The state-machine
339 /// transition (Live→Closing) and the entry removal (CompleteBrowserPaneClose)
340 /// happen in `close()` via reducer dispatch — `close_with` is purely
341 /// the FFI side-effects that follow.
342 fn close_with(label: &str, ops: &dyn BrowserPaneCloseOps) {
343 if let Some(hwnd) = ops.take_browser_hwnd(label) {
344 ops.destroy_hwnd(hwnd);
345 tracing::info!(label, "pane HWND destroyed");
346 }
347 }
348
349 /// Called from CEF's `on_before_close` if/when it fires for a pane
350 /// browser. The explicit `close()` path usually clears the entry first,
351 /// so this is a no-op in that case — but `on_before_close` may still
352 /// fire async as Chromium's refcount hits zero, and `DrainBrowserPaneByLabel`
353 /// is idempotent so the callback is safe.
354 pub fn drain_closed_label(&self, state: &Arc<AppState>, label: &str) {
355 let out = state.host_dispatch(
356 crate::reducer::HostCommand::DrainBrowserPaneByLabel {
357 label: label.to_string(),
358 },
359 );
360 if let Some(block_id) = out.drained_browser_pane_block_id {
361 tracing::info!(label, block_id = %block_id, "browser pane drained via on_before_close");
362 // PR #6 H.7 kick — see `close()` for rationale. The
363 // on_before_close path is the async drain; pool refill that
364 // was deferred while the pane was Closing should now resume.
365 crate::commands::window_pool::spawn_pool_window(state);
366 }
367 }
368
369 pub fn go_back(&self, block_id: &str, state: &Arc<AppState>) {
370 if let Some(mut b) = self.live_browser(state, block_id) { b.go_back(); }
371 }
372 pub fn go_forward(&self, block_id: &str, state: &Arc<AppState>) {
373 if let Some(mut b) = self.live_browser(state, block_id) { b.go_forward(); }
374 }
375 pub fn reload(&self, block_id: &str, state: &Arc<AppState>) {
376 if let Some(mut b) = self.live_browser(state, block_id) { b.reload(); }
377 }
378
379 /// Tell every live pane browser it has lost focus, at the Chromium level.
380 /// Panes in `Closing` are skipped — their HWND may be mid-destruction and
381 /// `set_focus(0)` against it can hit an invalid render widget.
382 pub fn defocus_all(&self, state: &Arc<AppState>) {
383 // Phase H.1.b + H.2.b — read live labels via reducer-aware helper,
384 // then look up each browser via reducer-aware helper. Both with
385 // fallback + drift logging.
386 let labels = state.live_browser_pane_labels();
387 for label in &labels {
388 if let Some(browser) = state.get_browser(label) {
389 if let Some(host) = browser.host() {
390 host.set_focus(0);
391 }
392 }
393 }
394 }
395
396 /// Apply a clip region to every live pane HWND that subtracts the given
397 /// overlay rects (in main-window client coordinates). The pane renders
398 /// normally outside the overlay region; inside it, the HWND is
399 /// transparent so the DOM overlay painted at the same screen position
400 /// shows through.
401 ///
402 /// This is the Win32 "airspace" workaround — native HWNDs always paint
403 /// above DOM regardless of CSS z-index, and `SetWindowRgn` is the one
404 /// mechanism that lets DOM bleed through a specific region of a child
405 /// HWND. Empty `overlay_rects` restores full pane visibility (same as
406 /// calling `clear_pane_overlay_clip`).
407 ///
408 /// No-op on non-Windows: other platforms don't use native child HWNDs
409 /// for panes, so there's no airspace to work around.
410 ///
411 /// `window_label` scopes the clip to panes whose top-level ancestor
412 /// matches the requesting window. Without it, a modal opened in
413 /// window B would clip panes in window A (see Codex P1 on PR #544).
414 /// Empty string matches today's legacy callers that don't know their
415 /// window label — falls through to the no-filter behaviour for
416 /// back-compat until every caller is updated.
417 #[cfg(target_os = "windows")]
418 pub fn set_pane_overlay_clip(
419 &self,
420 state: &Arc<AppState>,
421 window_label: &str,
422 overlay_rects: &[(i32, i32, i32, i32)],
423 ) {
424 use windows_sys::Win32::Foundation::{POINT, RECT};
425 use windows_sys::Win32::Graphics::Gdi::{
426 CombineRgn, CreateRectRgn, DeleteObject, MapWindowPoints, SetWindowRgn, RGN_DIFF,
427 };
428 use windows_sys::Win32::UI::WindowsAndMessaging::{
429 GetAncestor, GetParent, GetWindowRect, GA_ROOT,
430 };
431
432 // Resolve the requesting window's top-level HWND so we can filter
433 // panes by ownership. If the label is unknown we fall through with
434 // no filter — matches pre-scoping behaviour rather than silently
435 // doing nothing.
436 let requesting_top_level: *mut std::ffi::c_void = if window_label.is_empty() {
437 std::ptr::null_mut()
438 } else {
439 // Phase H.2.b — reducer-aware lookup with fallback.
440 match state.get_browser(window_label).and_then(|b| b.host()) {
441 Some(host) => {
442 let h = host.window_handle();
443 if h.0.is_null() {
444 std::ptr::null_mut()
445 } else {
446 unsafe { GetAncestor(h.0 as _, GA_ROOT) as *mut std::ffi::c_void }
447 }
448 }
449 None => std::ptr::null_mut(),
450 }
451 };
452
453 // Phase H.1.b + H.2.b — labels via reducer-aware helper; per-label
454 // browser lookup via reducer-aware helper. Drops the held-across-loop
455 // legacy lock; each iteration now snapshots independently.
456 let labels = state.live_browser_pane_labels();
457 for label in &labels {
458 let browser = match state.get_browser(label) {
459 Some(b) => b,
460 None => continue,
461 };
462 let host = match browser.host() {
463 Some(h) => h,
464 None => continue,
465 };
466 let hwnd_raw = host.window_handle();
467 if hwnd_raw.0.is_null() {
468 continue;
469 }
470 let pane_hwnd = hwnd_raw.0 as *mut std::ffi::c_void;
471
472 // Window-scope filter. Skip panes whose top-level HWND differs
473 // from the requesting window's. `null` requesting = legacy
474 // caller / no-op filter (applies to all panes).
475 if !requesting_top_level.is_null() {
476 let pane_top = unsafe { GetAncestor(pane_hwnd as _, GA_ROOT) as *mut std::ffi::c_void };
477 if pane_top != requesting_top_level {
478 continue;
479 }
480 }
481
482 unsafe {
483 // Empty overlay list = restore full visibility (region=NULL).
484 if overlay_rects.is_empty() {
485 SetWindowRgn(pane_hwnd as _, std::ptr::null_mut(), 1);
486 continue;
487 }
488
489 // Resolve the pane's position in its parent (main window)
490 // client coords so we can translate overlay rects (which
491 // arrive in main-window client coords from the frontend)
492 // into pane-local coords for the region API.
493 let parent = GetParent(pane_hwnd as _);
494 if parent.is_null() {
495 continue;
496 }
497 let mut pane_rect: RECT = std::mem::zeroed();
498 if GetWindowRect(pane_hwnd as _, &mut pane_rect) == 0 {
499 continue;
500 }
501 // Convert pane_rect from screen coords to parent client
502 // coords by mapping its two corner points.
503 let pts_ptr = &mut pane_rect as *mut RECT as *mut POINT;
504 MapWindowPoints(std::ptr::null_mut(), parent, pts_ptr, 2);
505
506 let pane_w = pane_rect.right - pane_rect.left;
507 let pane_h = pane_rect.bottom - pane_rect.top;
508 if pane_w <= 0 || pane_h <= 0 {
509 continue;
510 }
511
512 // Build region in pane-local coords: start with full pane,
513 // subtract every overlay rect that intersects it.
514 let region = CreateRectRgn(0, 0, pane_w, pane_h);
515 if region.is_null() {
516 continue;
517 }
518 for (ox, oy, ow, oh) in overlay_rects {
519 // Translate overlay rect (window client coords) →
520 // pane-local coords by subtracting pane's window pos.
521 let left = ox - pane_rect.left;
522 let top = oy - pane_rect.top;
523 let right = left + ow;
524 let bottom = top + oh;
525 // Skip if no intersection with the pane's local bounds.
526 if right <= 0 || bottom <= 0 || left >= pane_w || top >= pane_h {
527 continue;
528 }
529 let overlay_rgn = CreateRectRgn(left, top, right, bottom);
530 if !overlay_rgn.is_null() {
531 CombineRgn(region, region, overlay_rgn, RGN_DIFF);
532 DeleteObject(overlay_rgn as _);
533 }
534 }
535 // SetWindowRgn takes ownership of the region handle on
536 // success; the system frees it when the window is destroyed
537 // or a new region is set.
538 SetWindowRgn(pane_hwnd as _, region as _, 1);
539 }
540 }
541 tracing::info!(
542 pane_count = labels.len(),
543 overlay_count = overlay_rects.len(),
544 "[pane-airspace] applied overlay clip to pane HWNDs",
545 );
546 }
547 /// Linux/macOS — equivalent of the Windows SetWindowRgn airspace
548 /// workaround, but built on Views instead of HWND clip regions.
549 ///
550 /// `add_overlay_view` puts the pane on a higher z-layer than the host UI
551 /// BrowserView, so any host-side modal/dropdown/contextmenu that overlaps
552 /// a pane rect renders UNDERNEATH the pane and becomes unclickable. We
553 /// can't punch a clip hole through an Aura View the way Win32 SetWindowRgn
554 /// does on an HWND. The pragmatic workaround: when ANY overlay rect
555 /// overlaps a pane's bounds, hide that pane (`set_visible(false)`); when
556 /// no overlay rect intersects, show it again. The DOM modal renders in
557 /// the host UI BrowserView underneath, becomes the topmost paint at that
558 /// rect, and the pane's content briefly disappears — same UX trade-off
559 /// the Windows path makes (Win32 punches a hole; we hide the whole pane).
560 /// (Codex P1 on PR #682.)
561 ///
562 /// Future improvement: only hide the overlapping fraction of the pane
563 /// (would need a per-overlay set_size + position trick or a custom Layout).
564 /// Hiding the whole pane is acceptable for now — the airspace problem only
565 /// arises when modals open over panes, which is a transient case.
566 ///
567 /// `_window_label` is currently ignored on this path because we only
568 /// support a single primary window for panes (sub-window panes are a
569 /// follow-up — see PR #682's "Risks / follow-ups" section).
570 /// (See doc comment for the cfg(target_os = "windows") variant.)
571 /// Linux/macOS body — marshalled to the CEF UI thread because
572 /// `OverlayController::set_visible` and `bounds()` are UI-thread-only.
573 /// IPC handler runs on tokio so we post a task and return immediately.
574 /// `window_label` filters which panes get visibility-managed: only those
575 /// attached to the requesting window are affected.
576 #[cfg(not(target_os = "windows"))]
577 pub fn set_pane_overlay_clip(
578 &self,
579 state: &Arc<AppState>,
580 window_label: &str,
581 overlay_rects: &[(i32, i32, i32, i32)],
582 ) {
583 // Publish to AppState so resize_browser_pane_view can consult the
584 // same authoritative rect list when computing pane visibility on
585 // its own code path. Without this, a positive-dimension resize
586 // (e.g. user drags a splitter while a DOM modal is open) would
587 // call set_visible(1) and re-expose the pane on top of the modal.
588 // See state::pane_overlay_rects doc comment.
589 state
590 .pane_overlay_rects
591 .lock()
592 .insert(window_label.to_string(), overlay_rects.to_vec());
593
594 let mut task = SetPaneOverlayClipViewsTask::new(
595 state.clone(),
596 window_label.to_string(),
597 overlay_rects.to_vec(),
598 );
599 cef::post_task(cef::ThreadId::UI, Some(&mut task));
600 }
601
602 /// Give keyboard focus to the pane's child HWND so keystrokes reach the
603 /// embedded page. Called by the frontend's ViewModel.giveFocus() when the
604 /// pane becomes the active layout node — without this, focus falls back to
605 /// the main window's invisible "dummy-focus" input and keystrokes vanish.
606 ///
607 /// No-ops if the pane is `Closing`: a SetFocus against a HWND that CEF is
608 /// concurrently tearing down is the exact race documented in
609 /// `SPEC_BROWSER_PANE_LIFECYCLE.md` §5 race #2.
610 pub fn focus(&self, block_id: &str, state: &Arc<AppState>) {
611 if let Some(browser) = self.live_browser(state, block_id) {
612 if let Some(host) = browser.host() {
613 host.set_focus(1);
614 #[cfg(target_os = "windows")]
615 {
616 let hwnd = host.window_handle();
617 if !hwnd.0.is_null() {
618 // Tell the subclass this focus request is intentional
619 // (not Chromium's on-load focus steal) so it won't be
620 // redirected back to the parent.
621 crate::browser_pane::ALLOW_BROWSER_PANE_FOCUS_ONCE.store(
622 true,
623 std::sync::atomic::Ordering::Relaxed,
624 );
625 unsafe {
626 windows_sys::Win32::UI::Input::KeyboardAndMouse::SetFocus(hwnd.0 as _);
627 }
628 }
629 }
630 }
631 }
632 }
633}
634
635// `CreateBrowserPaneTask` moved to `crate::browser_pane::creation` in Phase 3.
636
637/// Two axis-aligned rects intersect iff neither is fully to one side of the
638/// other. Coordinates: (x, y, width, height). Used by the Linux/macOS
639/// pane-airspace logic to decide whether an overlay rect from the frontend
640/// covers any part of a pane's bounds.
641#[cfg(not(target_os = "windows"))]
642fn rects_intersect(a: (i32, i32, i32, i32), b: (i32, i32, i32, i32)) -> bool {
643 let (ax, ay, aw, ah) = a;
644 let (bx, by, bw, bh) = b;
645 let a_right = ax + aw;
646 let a_bottom = ay + ah;
647 let b_right = bx + bw;
648 let b_bottom = by + bh;
649 !(a_right <= bx || b_right <= ax || a_bottom <= by || b_bottom <= ay)
650}
651
652/// Compute whether a pane with the given bounds should be visible, given
653/// the pane's parent window. Both pane-airspace (`SetPaneOverlayClipViewsTask`)
654/// and per-pane resize (`resize_browser_pane_view`) call this to converge on
655/// the same answer — without it, the two paths fight each other (Codex
656/// review on PR #881 caught the dragging-splitter-while-modal-open case
657/// where a positive resize re-exposed a pane that airspace had hidden).
658///
659/// A pane is visible iff BOTH conditions hold:
660/// - Its rect has non-zero width and height (frontend places it in a
661/// `display:none` placeholder when the tab is inactive → reports 0×0).
662/// - It does not intersect any registered overlay-clip rect for its window
663/// (e.g. a hamburger menu, tooltip, modal popover).
664#[cfg(not(target_os = "windows"))]
665pub fn compute_pane_visible(
666 state: &Arc<AppState>,
667 window_label: &str,
668 pane_rect: (i32, i32, i32, i32),
669) -> bool {
670 let (_, _, w, h) = pane_rect;
671 if w <= 0 || h <= 0 {
672 return false;
673 }
674 let rects = state.pane_overlay_rects.lock();
675 let overlays = match rects.get(window_label) {
676 Some(v) => v.clone(),
677 None => return true,
678 };
679 drop(rects);
680 !overlays.iter().any(|or| rects_intersect(*or, pane_rect))
681}
682
683// ── Linux/macOS UI-thread marshalling tasks ────────────────────────────────
684//
685// `View::set_bounds` and `Window::remove_child_view` must run on the CEF UI
686// thread. Both `BrowserPaneManager::resize` and `::close` are called from IPC
687// handler tasks on tokio threads, so we wrap the UI-thread bodies in
688// `wrap_task!` structs and post them via `post_task(ThreadId::UI, ...)` —
689// same pattern as `ui_tasks::CloseWindowTask` / `MaximizeWindowTask` / etc.
690
691#[cfg(not(target_os = "windows"))]
692wrap_task! {
693 pub struct ResizeBrowserPaneViewTask {
694 state: Arc<AppState>,
695 label: String,
696 rect: Rect,
697 }
698
699 impl Task {
700 fn execute(&self) {
701 crate::browser_pane::creation_views::resize_browser_pane_view(
702 &self.state, &self.label, self.rect.clone(),
703 );
704 }
705 }
706}
707
708#[cfg(not(target_os = "windows"))]
709wrap_task! {
710 pub struct DetachBrowserPaneViewTask {
711 state: Arc<AppState>,
712 label: String,
713 }
714
715 impl Task {
716 fn execute(&self) {
717 crate::browser_pane::creation_views::detach_browser_pane_view(
718 &self.state, &self.label,
719 );
720 }
721 }
722}
723
724/// Linux/macOS pane-airspace task — fired by `set_pane_overlay_clip` for the
725/// non-Windows code path. For each live OverlayController, hide it when any
726/// overlay rect intersects its current bounds; show it otherwise. See the
727/// doc comment on `set_pane_overlay_clip` (non-Windows variant) for why this
728/// is the equivalent of the Windows SetWindowRgn airspace dance.
729#[cfg(not(target_os = "windows"))]
730wrap_task! {
731 pub struct SetPaneOverlayClipViewsTask {
732 state: Arc<AppState>,
733 // Only panes attached to this window get visibility-managed; panes
734 // in other windows are unaffected by overlay rects from this window.
735 // Mirrors the window_label filtering in the Windows path.
736 window_label: String,
737 overlay_rects: Vec<(i32, i32, i32, i32)>,
738 }
739
740 impl Task {
741 fn execute(&self) {
742 // Snapshot the (pane label, parent window label, controller) tuples,
743 // filter by parent-window-label matching the requesting window,
744 // drop the mutex before any FFI call (snapshot-and-drop discipline
745 // per docs/specs/SPEC_PHASE_F_HOST_REDUCER §6).
746 let live: Vec<(String, cef::OverlayController)> = self
747 .state
748 .browser_pane_overlays
749 .lock()
750 .iter()
751 .filter(|(_, (win_label, _))| win_label == &self.window_label)
752 .map(|(k, (_, c))| (k.clone(), c.clone()))
753 .collect();
754 if live.is_empty() {
755 return;
756 }
757 for (label, controller) in live {
758 use cef::ImplOverlayController;
759 let pb = controller.bounds();
760 let pane_rect = (pb.x, pb.y, pb.width, pb.height);
761 // Shared visibility helper consults BOTH the pane's own rect
762 // (zero → hidden because tab inactive) and the latest
763 // overlay-clip rects published in AppState. Resize path uses
764 // the same helper so both decisions converge.
765 let visible = compute_pane_visible(&self.state, &self.window_label, pane_rect);
766 controller.set_visible(if visible { 1 } else { 0 });
767 tracing::debug!(
768 label = %label,
769 window_label = %self.window_label,
770 visible,
771 pane_x = pb.x, pane_y = pb.y, pane_w = pb.width, pane_h = pb.height,
772 overlay_count = self.overlay_rects.len(),
773 "[pane-airspace] views: applied visibility"
774 );
775 }
776 }
777 }
778}
779
780// ── Tests ───────────────────────────────────────────────────────────────────
781//
782// Phase H.1.d/e (PR #5): The pane state machine lives in the host reducer
783// (`HostState.browser_panes`). Lifecycle transition tests — Live→Closing, idempotent
784// no-ops for missing or already-Closing entries, label sequence monotonicity,
785// drain-by-label — are now in `crate::reducer::tests`.
786//
787// What remains here: the FFI seam. `close_with` only takes a label and
788// drives `BrowserPaneCloseOps`; tests verify it forwards label → take → destroy
789// in order, with a None-returning `take` short-circuiting the destroy.
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794 use std::collections::HashMap;
795
796 /// Recording mock for `BrowserPaneCloseOps`. Tests inspect `taken` and
797 /// `destroyed` to assert what close_with did.
798 struct MockCloseOps {
799 registered: parking_lot::Mutex<HashMap<String, usize>>,
800 taken: parking_lot::Mutex<Vec<String>>,
801 destroyed: parking_lot::Mutex<Vec<usize>>,
802 }
803
804 impl MockCloseOps {
805 fn new() -> Self {
806 Self {
807 registered: parking_lot::Mutex::new(HashMap::new()),
808 taken: parking_lot::Mutex::new(Vec::new()),
809 destroyed: parking_lot::Mutex::new(Vec::new()),
810 }
811 }
812
813 fn register(&self, label: &str, hwnd: usize) {
814 self.registered.lock().insert(label.to_string(), hwnd);
815 }
816
817 fn taken_labels(&self) -> Vec<String> {
818 self.taken.lock().clone()
819 }
820
821 fn destroyed_hwnds(&self) -> Vec<usize> {
822 self.destroyed.lock().clone()
823 }
824 }
825
826 impl BrowserPaneCloseOps for MockCloseOps {
827 fn take_browser_hwnd(&self, label: &str) -> Option<usize> {
828 self.taken.lock().push(label.to_string());
829 self.registered.lock().remove(label)
830 }
831
832 fn destroy_hwnd(&self, hwnd: usize) {
833 self.destroyed.lock().push(hwnd);
834 }
835 }
836
837 #[test]
838 fn close_with_take_then_destroy_in_order() {
839 let ops = MockCloseOps::new();
840 ops.register("browser-pane-b1-1", 0xABCD);
841
842 BrowserPaneManager::close_with("browser-pane-b1-1", &ops);
843
844 assert_eq!(ops.taken_labels(), vec!["browser-pane-b1-1"]);
845 assert_eq!(ops.destroyed_hwnds(), vec![0xABCD]);
846 }
847
848 #[test]
849 fn close_with_no_hwnd_skips_destroy() {
850 // Browser was already gone (rare race — explicit close raced with
851 // an external close). take returns None; destroy must NOT be called.
852 let ops = MockCloseOps::new(); // no register() — lookup will miss
853
854 BrowserPaneManager::close_with("browser-pane-missing", &ops);
855
856 assert_eq!(ops.taken_labels(), vec!["browser-pane-missing"]);
857 assert!(ops.destroyed_hwnds().is_empty(),
858 "destroy_hwnd must not be called when take_browser_hwnd returns None");
859 }
860}