diff --git a/docs/i18n-inventory.md b/docs/i18n-inventory.md index 32a3981..40ac631 100644 --- a/docs/i18n-inventory.md +++ b/docs/i18n-inventory.md @@ -1,6 +1,6 @@ # i18n inventory (egdev6/quorb#66) -Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `8cb2bbb0fbd18e24abe8acfef5f03312de8a4540` and `src-tauri/src/` = `dd809420dfc2aa50920a3d1816ab0c38e804cf03`. +Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `7dda5bf7aa6641e119a7602491db6c5df0c5568a` and `src-tauri/src/` = `1f4fc797b9db4beca72a75f52ae08a2d61579e90`. Any commit whose `git rev-parse :src` and `:src-tauri/src` match has these exact texts. Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`. @@ -19,8 +19,8 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a | Area | Distinct texts | Sites | |---|---:|---:| | React (`src/`) | 958 | 1066 | -| Rust (`src-tauri/src/`) | 421 | 502 | -| Total | 1342 | 1568 | +| Rust (`src-tauri/src/`) | 458 | 556 | +| Total | 1379 | 1622 | ## src-tauri/src/channel.rs @@ -53,6 +53,58 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Invalid detail window bounds - Only the calling usage window can update its own bounds +## src-tauri/src/detail_placement.rs + +- Detail bounds UI callback was dropped +- the native window could not be read +- the native window origin could not be read + +## src-tauri/src/detail_placement_test.rs + +-
+- X11/XWayland: the size cycle with read-back +- a single resize with permission: {:?} +- both axes convert with the window scale: {:?} +- constraints 314x379 +- constraints {width}x{height} +- native Wayland: no anchoring, no read-back +- no geometry is reported post-cancellation +- no geometry to cache +- no move after supersession: {:?} +- no permanent wait: {} reads +- no permission left open +- no read-back, no geometry to cache +- one retry and nothing more: {:?} +- permission closed +- permission open +- position after size: {:?} +- position {x},{y} +- position {x},{y} (unconfirmed) +- read {} +- read-back after the permission closes: {:?} +- resize 314x379 +- resize {width}x{height} +- scale 1.0 is identity: {:?} +- size request + hints min == max go before the resize: {:?} +- size {width}x{height} +- size {width}x{height} (unconfirmed) +- the first generation expired +- the geometry is read-back geometry +- the native surface did not answer +- the permission ends closed +- the permission is withdrawn +- the permission lasts exactly the resize: {:?} +- the permission opened only for the size call: {:?} +- the permission stays closed +- the position is applied +- the resize permission ends closed +- the resize permission is withdrawn +- the size and move already happened, and nothing more: {:?} +- the size is granted exact +- the size survives the permission withdrawal +- the window is visible +- the window starts at its default size + ## src-tauri/src/history.rs - medidor sin id estable (label {:?}) en el proveedor {}: la serie del historial no será estable @@ -87,7 +139,6 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Abrir el más cargado - Actualizar - Cuenta de NaN no válida -- Detail bounds UI callback was dropped - La sesión de 9Router cambió durante el login. Vuelve a conectar. - Las credenciales de 9Router solo se gestionan al conectar o desconectar. - Mostrar / ocultar diff --git a/odd/tasks/x11-detail-fit.md b/odd/tasks/x11-detail-fit.md new file mode 100644 index 0000000..3473b4e --- /dev/null +++ b/odd/tasks/x11-detail-fit.md @@ -0,0 +1,69 @@ +# x11-detail-fit + +Ajuste de la geometría nativa de la ventana `detail` (aviso de API key / panel) bajo Linux/X11/XWayland — issue #215; alcance aprobado por el mantenedor (egdev6) en el hilo de la issue. Diagnóstico y A/B medidos por SrStamm en la misma sesión (comentario del 2026-10-30). + +## Problema + +Con `GDK_BACKEND=x11` (XWayland en GNOME), la tira ya está limpia tras #205, pero el aviso de API key muestra la caja translúcida de la ventana contenedora: `detail` nace `resizable(false)` + `inner_size(330, 260)`; GTK retiene un piso `PMinSize` sticky y `XResizeWindow` es rechazado en ancho ⇒ ventana `330×379` con contenido de 314 ⇒ banda de ~16 px. A/B: con `resizable(true)` la ventana queda `314×379` exacto. + +## Specs (del mantenedor en #215) + +- S1: limitar el arreglo a X11/XWayland; Windows, macOS y Wayland nativo sin cambios. Reutilizar las primitivas de #205. +- S2: no conectar el ciclo del widget tal cual — validación exclusiva de `widget`, anclaje propio y generación global de cancelación no compartibles con `detail`. +- S3: preservar la primera apertura: `DetailPanel.tsx` aplica los bounds antes de `showDetail()`; el read-back no debe bloquear la apertura esperando visibilidad ni cachear como confirmada una geometría meramente solicitada. +- S4: no dejar `resizable(true)` permanentemente. +- S5: aceptación = geometría efectiva ajustada al contenido, sin caja, sin resize manual, permiso de resize cerrado, evidencia visual en GNOME/XWayland. + +## Decisiones (validadas por el usuario) + +- D1: módulo **nuevo** `src-tauri/src/detail_placement.rs`; no extender `widget_placement.rs`. +- D2: `set_detail_bounds` devuelve el **informe** `{status, bounds, reason, read_back}` (espejo de `set_widget_bounds`). Justificación: con `void` el frontend sólo puede cachear la solicitud, exactamente lo que S3 prohíbe; el informe alimenta la caché con la geometría del read-back y da señal para el reintento tras `show()`. +- D3: asignación de #215: `SrStamm` no puede autoasignarse (permiso denegado); la hace el mantenedor. Se avisa al cierre de la implementación. + +## Diseño + +Reusar de `widget_placement.rs` las primitivas ya genéricas: `transport_from_type_name`, `set_size_constraints`, `fix_size`, `to_logical`/`to_physical`, `size_matches`/`position_matches`. Lo propio del detalle: + +- **Generación propia** (static separado del `GENERATION` del widget): una colocación posterior del detalle invalida a la anterior; comprobar antes de cada fase nativa. +- **Un solo tamaño por llamada al hilo de UI**: size request + pistas `min == max == pedido` + `resizable(true)` + resize + `resizable(false)`, confirmado `!is_resizable()` (D1 de #205). +- **Sin anclaje**: `set_position` posterior con `x,y` físicos explícitos; origen por `gdk_window_get_origin` con su valor de retorno. +- **Read-back con ventana oculta**: presupuesto acotado (`READS=4 / SETTLE=15 ms`); si no es legible aún, informe blando sin bloquear la apertura (`unverified`/`read_failed`; `size_refused` si visible pero no concedido). +- **Escala**: factor del `WebviewWindow` del detalle para pedido y read-back; tolerancia ±1 pxphys sólo sobre geometría con read-back (JD-A-006 de #205). +- **Frontend**: cachear `lastAppliedRef` sólo con geometría efectiva; si el informe no confirma, re-pedir la colocación después de `show()`. Dedup `samePhysical` conservado. +- **Motivos**: códigos ASCII sin texto de UI (sin regenerar inventario i18n salvo cambio de catálogo). + +## Base y contexto + +- Rama `fix/x11-detail-window-fit`, base `origin/main@83b0792` (fijar el sha real en el cierre). +- `origin` = egdev6/quorb (upstream); `fork` = SrStamm/quorb. +- #221 (último upstream) cambia el reintento de `not_viewable` tras `show()` en el widget: leer su patrón en `widget_placement.rs`/`Widget.tsx` antes de escribir el ciclo del detalle. + +## Tareas + +- T0: rama + este artefacto. ✔ (commit pendiente al cierre) +- T1: backend — `detail_placement.rs` con ciclo X11 del detalle + informe. +- T2: contrato IPC — `set_detail_bounds` devuelve informe; `src/lib/api.ts` tipado. +- T3: frontend — `DetailPanel.tsx` consume informe, cachea geometría efectiva, reintenta tras `show()`. +- T4: tests TDD — Rust deterministas + 1 en vivo ignorada (`GDK_BACKEND=x11`); vitest de apertura/reducción/crecimiento/reapertura/DPI; clippy/fmt/typecheck sin avisos nuevos. +- T5: evidencia en sesión real GNOME/XWayland (`xwininfo` + `xprop`), según S5. +- T6: commits por unidad + PR (sólo con autorización del usuario; asignación de la issue por el mantenedor). + +## Evidencia de sesión (T5, GNOME/XWayland real, `GDK_BACKEND=x11`, app de esta rama con config del usuario) + +Validación visual humana (SrStamm): aviso de API key abierto, **sin caja translúcida alrededor** (antes: banda de ~16 px a la derecha); captura propia del usuario + captura objetiva del servidor X (`import -window `, sonda fuera del repositorio). Medición instrumental con la ventana aviso abierta: + +| ventana | id X11 | geometría (`xwininfo -stats`) | `WM_NORMAL_HINTS` (`xprop`) | +| --- | --- | --- | --- | +| tira (widget) | `0xa00019` | `61×238+0+273` | — | +| detail (aviso) | `0xa0009d` | `314×379+69+258`, IsViewable | mínimo = máximo = base = **314×379** | + +`314 = panelWidthFor(48)`: geometría exacta al contenido, min == max == pedido ⇒ el gestor rechaza cualquier resize y no hay superficie sobrante. Prueba en vivo del backend (commit `f56138c`) en verde con webview real: tamaño concedido exacto, permiso cerrado y size request conservando el tamaño tras la retirada. + +## Log + +- L0 (T0): diagnóstico del A/B tomado del comentario de SrStamm en #215; alcance del mantenedor aprobado en su respuesta (dos cuidados: S2/S3). Plan explicado y validado por el usuario; D1–D3 decididas. +- L1 (T1, commit `9a6622d`): backend con TDD — `detail_placement.rs` nuevo (generación propia, `size_call` única con pistas + permiso confirmado cerrado, posición explícita, read-back acotado tras el cierre del permiso, informe espejo del widget) + 17 tests deterministas con RED observado (16/17 en rojo contra el stub) y 1 prueba en vivo ignorada; `set_detail_bounds` devuelve el informe; no Linux conserva la ruta de `detail_geometry::apply` con `unverified` y `bounds=null`. Verificación: `cargo test --locked --offline` 384→401 (0 fallos, 4 ignorados), fmt limpio, clippy con los mismos avisos de la base (0 en `detail_placement*`); verificador independiente sin hallazgos bloqueantes (nota informativa: si ambos reintentos del `close_permission` informan «no cerrado», el ciclo aborta con informe fallido — cubierto por test). Comentarios en español alineados al estilo de `widget_placement.rs`. Pendiente T2/T3 frontend y T5 evidencia en sesión. +- L2 (T2/T3, commit `15aea55`): frontend con TDD — `detail-placement.ts` nuevo (indecisión de caché: sólo geometría con read-back se cachea; `confirmDetailPlacement`/`retryDetailPlacement` reutilizando `readPlacementWithRetries` de #221), `DetailPanel.tsx` consume el informe, cachea con un `lastAppliedReadBackRef` nuevo, reintenta en silencio tras `showDetail()` sólo en `not_viewable` y aborta por `disposed`/cierre; orden inline place → clip → positioned → show intacto (columnas estructurales de `ui-theme.test.ts`). `api.ts` tipado con el informe. RED observado (24 fallas contra el stub; 4 en la etapa intermedia sin adaptador de reintento) → GREEN. Verificación: vitest 822 (+75) / 65 files, node 298, typecheck limpio; verificador independiente sin bloqueantes (caché vacía en unverified es por diseño: repedida en paths no X11). DECISIÓN (commit `a5bdd48`): `docs/i18n-inventory.md` regenerado — el `--check` fallaba ya en `9a6622d` por strings de error/test de Rust visibles al escáner (nada de interfaz); inventarios de `9a6622d` y HEAD idénticos en el árbol frontend. +- L3 (T5, commit `f56138c`): primera ejecución real de la prueba en vivo (el writer no la corrió) halló un defecto del fixture: pedía `(1859,899)` con una ventana 314×379, fuera de pantalla, y Mutter la reubicaba a `(1606,701)` (= clamp 1920−314 / 1080−379). Arreglado con `(100,100)`: verde con webview real (`GDK_BACKEND=x11` sobre el XWayland de la sesión GNOME): tamaño exacto concedido, permiso cerrado y el tamaño sobrevive a la retirada. Suites completas re-verificadas tras el arreglo: 401 + 4 ignoradas, fmt limpio. +- L4 (T5 cierre): validación visual humana por SrStamm en la app de la rama con config real: aviso de API key abierto **sin caja translúcida** (ver «Evidencia de sesión»: detail `314×379+69+258`, pistas min == max == base 314×379, tira intacta `61×238`; captura propia del usuario + captura del servidor X en `/tmp`, fuera del repositorio). S5 acreditado en GNOME/XWayland para el aviso; los cambios de proveedor/DPI quedan cubiertos por tests deterministas y el dedup del read-back. +- L5 (review PR #224, commit `29e7c77`): fix P2 HiDPI hallado por el mantenedor (egdev6) — `request_position` pasaba físicos tal cual a `gtk.move_` (que espera lógicos): a escala ×2 el panel quedaba desplazado ~al doble. Fix con test-first RED→GREEN: seam `Positioner` + `GtkPositioner` + `position_call` (espejo de `Sizable`/`GtkSizable`), conversión `to_logical` en los dos ejes antes del movimiento, el mismo mapeo del adaptador del widget. Test nuevo `the_position_call_converts_both_axes_to_logical_at_scale_x2` (×2: (200,300)→(100,150); ×1 identidad) que falla contra el código anterior. Verificación: `cargo test` 402 + 4 ignoradas (0 fallos), fmt limpio, clippy 0 avisos en `detail_placement*` (baseline intacta: 19 lib), verificador independiente en PASS (5/5 checks). Inventario i18n regenerado (commit `3e77693`): el `--check` quedaba roto por 2 strings nuevos, ambos aserciones de test («both axes convert with the window scale», «scale 1.0 is identity»), nada de interfaz (mismo patrón que L2). Nota ambiental: 2 fallas puntuales no reproducibles de suites ajenas al módulo (~14 reruns ok; flake preexistente, vigilar en CI). diff --git a/src-tauri/src/detail_geometry.rs b/src-tauri/src/detail_geometry.rs index ee9b1dc..0f4293d 100644 --- a/src-tauri/src/detail_geometry.rs +++ b/src-tauri/src/detail_geometry.rs @@ -12,7 +12,7 @@ pub struct Bounds { } impl Bounds { - fn validate(self, label: &str) -> Result { + pub fn validate(self, label: &str) -> Result { self.validate_for(label, "detail") } diff --git a/src-tauri/src/detail_placement.rs b/src-tauri/src/detail_placement.rs new file mode 100644 index 0000000..e67ad4c --- /dev/null +++ b/src-tauri/src/detail_placement.rs @@ -0,0 +1,474 @@ +//! Colocación nativa del panel de detalle de la clave de API en Linux/X11 (#215). +//! +//! La ventana de detalle nace con `resizable(false)` + `inner_size(330, 260)`; +//! bajo X11/XWayland GTK mantiene pegado un `PMinSize` de origen y el resize +//! externo se rechaza en ancho, así que la caja del contenedor translúcido +//! asoma alrededor del contenido. Este módulo le da a la ventana de detalle +//! el mismo ciclo de tamaño en X11 que recibió la tira en #205: el permiso de +//! resize se abre **sólo alrededor del resize programático** y la ventana +//! queda sujeta con pistas iguales (`min == max == pedido`) dentro de una +//! **única llamada de tamaño en el hilo de UI**. +//! +//! Lo que el detalle agrega por encima de #205: +//! +//! - Su **propia generación** (un estático separado, nunca +//! `widget_placement::GENERATION`): una colocación posterior de detalle +//! sustituye a la pendiente, y la generación se comprueba antes de cada +//! fase nativa. +//! - **Sin anclaje**: después del ciclo de tamaño el panel sólo necesita la +//! `x, y` física explícita de la petición. +//! - La ventana suele estar **oculta** cuando llegan los bounds +//! (`DetailPanel.tsx` los aplica antes de `showDetail()`): el read-back +//! nunca bloquea la apertura esperando visibilidad. Dentro de su +//! presupuesto acotado, una ventana ilegible da un informe suave honesto +//! (`unverified`, sin geometría cacheada): el frontend puede volver a pedir +//! después de `show()`. +//! +//! El read-back sale de GDK (la señal `gdk_window_is_viewable()`, la +//! geometría escalada con el factor de escala de la propia ventana, el origen +//! de `gdk_window_get_origin`) y sólo la geometría del read-back se compara +//! con la tolerancia de ±1 px de redondeo de #205. +//! +//! Windows/macOS/Wayland nativo conservan intacto el camino existente de +//! `detail_geometry::apply`: la petición se aplica como siempre y se informa +//! `unverified` sin geometría. + +use std::future::Future; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::detail_geometry::Bounds; +use crate::widget_placement::{ + position_matches, rooted_origin, size_matches, to_logical, to_physical, Geometry, Reason, + Transport, READS, SETTLE, +}; +// El informe es el espejo de `set_widget_bounds`: viajan los mismos tipos, +// así el frontend trata con un solo contrato. +pub use crate::widget_placement::{Report, Status}; + +/// Intentos por llamada nativa: una llamada que no se puede confirmar se +/// reintenta una vez y, si tampoco, la colocación informa del fallo. +const CALL_ATTEMPTS: usize = 2; + +/// Desvío que toma la colocación por transporte: sólo X11/XWayland tiene un +/// ciclo con read-back; los demás transportes aplican la petición y la +/// informan sin verificar (comportamiento sin cambios, S1). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Route { + /// X11/XWayland: el ciclo de tamaño con la ventana del permiso de resize. + X11Cycle, + /// Sin read-back del compositor: aplicar e informar sin verificar. + UnverifiedPath, +} + +pub(crate) fn route_of(transport: Transport) -> Route { + match transport { + Transport::X11 => Route::X11Cycle, + Transport::Other => Route::UnverifiedPath, + } +} + +/// La generación propia del detalle: un contador separado, nunca el de la +/// tira (#215): una colocación posterior de detalle sustituye a la pendiente. +static GENERATION: AtomicU64 = AtomicU64::new(0); + +/// Reserva la siguiente generación del detalle. +pub fn next_generation() -> u64 { + GENERATION.fetch_add(1, Ordering::SeqCst) + 1 +} + +/// True cuando una colocación posterior de detalle sustituyó a la +/// `generation` dada. +pub fn superseded(generation: u64) -> bool { + GENERATION.load(Ordering::SeqCst) != generation +} + +/// Ventana nativa abstracta para la llamada de tamaño, así su orden es +/// testeable de forma determinística sin GTK: primero las pistas, el permiso +/// de resize abierto sólo alrededor del resize, cerrado y confirmado después. +pub(crate) trait Sizable { + /// Petición de tamaño + pistas `min == max` SIN redimensionar. + fn set_constraints(&mut self, width: i32, height: i32); + /// Abre el permiso de resize y redimensiona (`widget_placement::fix_size`). + fn resize_with_permission(&mut self, width: i32, height: i32); + /// Retira el permiso; devuelve si quedó cerrado. + fn close_permission(&mut self) -> bool; +} + +/// La única llamada de tamaño en el hilo de UI (#215): petición de tamaño + +/// pistas `min == max == pedido` + `resizable(true)` + resize + +/// `resizable(false)`. El permiso no puede quedar abierto para siempre (S4): +/// la llamada informa si quedó cerrado. +pub(crate) fn size_call(window: &mut W, width: i32, height: i32) -> bool { + window.set_constraints(width, height); + window.resize_with_permission(width, height); + window.close_permission() +} + +/// Ventana nativa abstracta para la llamada de posición, así la conversión +/// de la petición a píxeles lógicos es testeable de forma determinística +/// sin GTK (P2 de PR #224). +pub(crate) trait Positioner { + /// El factor de escala de la propia ventana: GTK mide en píxeles + /// lógicos y la petición llega en píxeles físicos. + fn scale(&self) -> f64; + /// Mueve la ventana a coordenadas ya LÓGICAS. + fn move_to(&mut self, x: i32, y: i32); +} + +/// La única llamada de posición en el hilo de UI (#215): la petición física +/// se convierte a píxeles lógicos en **los dos ejes** antes del movimiento, +/// porque GTK mide en píxeles lógicos (el mismo mapeo que hace el adaptador +/// de widgets en `widget_placement.rs`). A escala ×2 mover los valores +/// físicos tal cual duplicaría el desplazamiento del panel (P2 de PR #224). +pub(crate) fn position_call(window: &mut W, x: i32, y: i32) -> bool { + let (lx, ly) = (to_logical(x, window.scale()), to_logical(y, window.scale())); + window.move_to(lx, ly); + true +} + +/// Operaciones nativas que necesita el ciclo del detalle: la implementación +/// real salta al hilo de UI y el doble de los tests responde en el acto. +pub trait Surface { + /// La única llamada de tamaño: petición de tamaño, pistas `min == max`, + /// resize y el permiso de resize retirado en la misma llamada. `false` = + /// la llamada no se pudo confirmar (el hilo de UI no respondió o el + /// permiso no quedó cerrado): el ciclo no puede seguir como si el tamaño + /// se hubiera aplicado. + fn request_size(&mut self, width: i32, height: i32) -> impl Future + Send; + /// Mueve la superficie nativa. `false` = la llamada no se pudo confirmar. + fn request_position(&mut self, x: i32, y: i32) -> impl Future + Send; + /// Lee la geometría que concedió el compositor. `None` mientras la + /// ventana siga sin poder leerse (reintentable dentro del presupuesto); + /// `Err` sólo si la lectura falló de verdad. + fn read_geometry(&mut self) -> impl Future, String>> + Send; + /// Espera a que el gestor responda. + fn settle(&mut self) -> impl Future + Send; +} + +/// Corre el ciclo del detalle: +/// +/// 1. **tamaño** (la única llamada con su ventana de permiso), +/// 2. **posición** (la `x, y` física explícita, sin anclaje), +/// 3. **read-back** (sólo después de que el permiso esté cerrado; +/// presupuesto acotado). +/// +/// Una colocación posterior (generación propia) se comprueba antes de cada +/// fase nativa y detiene el ciclo sin volver a tocar la ventana. +pub async fn drive( + surface: &mut S, + bounds: Bounds, + mut superseded: impl FnMut() -> bool, +) -> Report { + let wanted = (bounds.w as i32, bounds.h as i32); + // El cancelador se comprueba antes de cada fase nativa: una colocación + // posterior no debe dejar la ventana movida a medias por esta. + if superseded() { + return superseded_report(); + } + if !request_size_checked(surface, wanted).await { + // La llamada nativa no se pudo confirmar: el ciclo no sigue como si + // el tamaño se hubiera aplicado. + return failed_report(Reason::ReadFailed); + } + // La posición es explícita: sin anclaje para el detalle, la `x, y` física + // de la petición. + let target = (bounds.x, bounds.y); + if superseded() { + return superseded_report(); + } + if !request_position_checked(surface, target).await { + return failed_report(Reason::ReadFailed); + } + // El read-back ocurre después de que el permiso esté cerrado (la única + // llamada de tamaño ya lo retiró). El presupuesto es acotado: una ventana + // oculta nunca bloquea la apertura esperando visibilidad, y una que + // nunca se vuelve legible da un informe suave honesto —el frontend puede + // volver a pedir después de `show()`. + if superseded() { + return superseded_report(); + } + let mut last: Option = None; + for _ in 0..READS { + surface.settle().await; + match surface.read_geometry().await { + Ok(Some(geometry)) => { + if size_matches(wanted, geometry.size) && position_matches(target, geometry.origin) + { + return report_of(wanted, target, geometry); + } + last = Some(geometry); + } + Ok(None) => {} + Err(_) => return failed_report(Reason::ReadFailed), + } + } + match last { + Some(geometry) => report_of(wanted, target, geometry), + None => failed_report(Reason::NotViewable), + } +} + +/// Pide el tamaño con un presupuesto acotado de reintentos: una llamada +/// nativa que no se puede confirmar no se da por buena. +async fn request_size_checked(surface: &mut S, size: (i32, i32)) -> bool { + for attempt in 0..CALL_ATTEMPTS { + if surface.request_size(size.0, size.1).await { + return true; + } + if attempt + 1 < CALL_ATTEMPTS { + surface.settle().await; + } + } + false +} + +/// Mueve la ventana con un presupuesto acotado de reintentos. +async fn request_position_checked(surface: &mut S, position: (i32, i32)) -> bool { + for attempt in 0..CALL_ATTEMPTS { + if surface.request_position(position.0, position.1).await { + return true; + } + if attempt + 1 < CALL_ATTEMPTS { + surface.settle().await; + } + } + false +} + +/// Una colocación sin lectura fiable: no hay geometría que cachear. +pub(crate) fn unverified_report() -> Report { + Report { + status: Status::Unverified, + bounds: None, + reason: None, + read_back: false, + } +} + +/// Una colocación cancelada: la geometría quedó a medias y no se informa. +fn superseded_report() -> Report { + Report { + status: Status::Superseded, + bounds: None, + reason: Some(Reason::Superseded), + read_back: false, + } +} + +/// Una colocación cuya llamada nativa no se pudo confirmar: nada que cachear. +fn failed_report(reason: Reason) -> Report { + Report { + status: Status::Unverified, + bounds: None, + reason: Some(reason), + read_back: false, + } +} + +/// Compone el informe final con la geometría que quedó en pantalla: es +/// geometría de read-back, así que se compara con la tolerancia de ±1 px y se +/// informa exactamente lo concedido, nunca lo pedido. +fn report_of(wanted: (i32, i32), target: (i32, i32), effective: Geometry) -> Report { + let size_ok = size_matches(wanted, effective.size); + let position_ok = position_matches(target, effective.origin); + Report { + status: if size_ok && position_ok { + Status::Applied + } else { + Status::Limited + }, + bounds: Some(Bounds { + x: effective.origin.0, + y: effective.origin.1, + w: effective.size.0.max(0) as u32, + h: effective.size.1.max(0) as u32, + }), + reason: if size_ok && position_ok { + None + } else if size_ok { + Some(Reason::PositionRefused) + } else { + Some(Reason::SizeRefused) + }, + // La geometría es la que leyó el compositor: sólo ella admite la + // tolerancia del redondeo de píxeles de dispositivo. + read_back: true, + } +} + +/// Coloca la ventana de detalle en la superficie nativa e informa lo que +/// pasó. +#[cfg_attr(not(target_os = "linux"), allow(unused_variables))] +pub async fn place( + window: &tauri::WebviewWindow, + bounds: Bounds, + generation: u64, +) -> Result { + // Antes de tocar la superficie nativa: sólo la ventana de detalle puede + // colocar sus propios bounds y el rectángulo tiene que ser utilizable + // (validación exclusiva del detalle, sin cambios). + let bounds = bounds.validate(window.label())?; + #[cfg(target_os = "linux")] + { + // GDK sólo se puede tocar en el hilo de UI: la detección del + // transporte va allí, como todas las llamadas nativas. + if route_of(crate::widget_placement::transport_on_ui(window).await) == Route::X11Cycle { + let mut surface = UiSurface::new(window.clone(), generation); + return Ok(drive(&mut surface, bounds, || superseded(generation)).await); + } + } + // Sin read-back del compositor en esta plataforma la petición se aplica + // como antes, y la llamada nativa sigue en el hilo de UI. + let (tx, mut rx) = tauri::async_runtime::channel(1); + let ui_window = window.clone(); + window + .run_on_main_thread(move || { + let _ = tx.try_send(crate::detail_geometry::apply(&ui_window, bounds)); + }) + .map_err(|e| e.to_string())?; + rx.recv() + .await + .ok_or_else(|| "Detail bounds UI callback was dropped".to_string())? + .map(|()| unverified_report()) +} + +/// Adaptador GTK para que el orden de la llamada de tamaño siga siendo +/// testeable de forma determinística mientras el permiso nunca queda abierto +/// para siempre (S4). Genérico: la superficie de producción pasa la ventana +/// de aplicación GTK, el test en vivo pasa una `gtk::Window` simple. +#[cfg(target_os = "linux")] +pub(crate) struct GtkSizable<'a, W>(pub &'a W); + +#[cfg(target_os = "linux")] +impl Sizable for GtkSizable<'_, W> +where + W: gtk::prelude::GtkWindowExt + gtk::prelude::WidgetExt, +{ + fn set_constraints(&mut self, width: i32, height: i32) { + crate::widget_placement::set_size_constraints(self.0, width, height); + } + + fn resize_with_permission(&mut self, width: i32, height: i32) { + crate::widget_placement::fix_size(self.0, width, height); + } + + fn close_permission(&mut self) -> bool { + self.0.set_resizable(false); + !self.0.is_resizable() + } +} + +/// La contrapartida GTK del adaptador de posición: genérico como +/// `GtkSizable`, así la llamada de posición sigue siendo testeable. +#[cfg(target_os = "linux")] +pub(crate) struct GtkPositioner<'a, W>(pub &'a W); + +#[cfg(target_os = "linux")] +impl Positioner for GtkPositioner<'_, W> +where + W: gtk::prelude::GtkWindowExt + gtk::prelude::WidgetExt, +{ + fn scale(&self) -> f64 { + self.0.scale_factor() as f64 + } + + fn move_to(&mut self, x: i32, y: i32) { + self.0.move_(x, y); + } +} + +/// La superficie GTK: todas las llamadas nativas ocurren en el hilo de UI. +#[cfg(target_os = "linux")] +struct UiSurface { + window: tauri::WebviewWindow, + /// La generación de esta colocación: las closures la comprueban dentro + /// del hilo de UI, así una colocación posterior nunca mueve la ventana a + /// medias. + generation: u64, +} + +#[cfg(target_os = "linux")] +impl UiSurface { + fn new(window: tauri::WebviewWindow, generation: u64) -> Self { + Self { window, generation } + } +} + +#[cfg(target_os = "linux")] +impl Surface for UiSurface { + async fn request_size(&mut self, width: i32, height: i32) -> bool { + let generation = self.generation; + crate::widget_placement::on_ui(&self.window, move |gtk| { + if superseded(generation) { + // La colocación está vencida: la ventana no se toca (y el + // permiso queda cerrado). + return true; + } + // El factor de escala de la ventana de detalle convierte la + // petición física (GTK mide en píxeles lógicos). + use gtk::prelude::*; + let scale = gtk.scale_factor() as f64; + let (w, h) = (to_logical(width, scale), to_logical(height, scale)); + size_call(&mut GtkSizable(gtk), w, h) + }) + .await + .unwrap_or(false) + } + + async fn request_position(&mut self, x: i32, y: i32) -> bool { + let generation = self.generation; + crate::widget_placement::on_ui(&self.window, move |gtk| { + // La generación se comprueba dentro del hilo de UI: entre la + // comprobación y el movimiento no cabe otra colocación. + if superseded(generation) { + return true; + } + // GTK mide en píxeles lógicos y la petición llega en píxeles + // físicos (P2 de PR #224): la conversión a los dos ejes va + // dentro de la única llamada de posición, con el factor de + // escala de la propia ventana, el mismo mapeo que el adaptador + // de widgets en `widget_placement.rs`. + position_call(&mut GtkPositioner(gtk), x, y) + }) + .await + .unwrap_or(false) + } + + async fn read_geometry(&mut self) -> Result, String> { + crate::widget_placement::on_ui(&self.window, move |gtk| { + use gtk::prelude::*; + // Sin ventana GDK todavía no hay superficie que leer. + let Some(gdk_window) = gtk.window() else { + return Ok(None); + }; + // La señal real de visibilidad: la ventana y sus ancestros están + // mapeados. + if !gdk_window.is_viewable() { + return Ok(None); + } + // La geometría que tiene el servidor X en este momento, + // convertida con el factor de escala de la propia ventana. + let scale = gtk.scale_factor() as f64; + let (_, _, gw, gh) = gdk_window.geometry(); + let size = (to_physical(gw, scale), to_physical(gh, scale)); + // El origen: `gdk_window_get_origin` da coordenadas relativas a + // la raíz. Su valor de retorno no confirma una lectura utilizable + // (#205): un 0 se lee como «no hay origen». + let (ox, oy, rooted) = gdk_window.origin(); + let Some((ox, oy)) = rooted_origin(ox, oy, rooted != 0) else { + return Err("the native window origin could not be read".to_string()); + }; + Ok(Some(Geometry { + origin: (to_physical(ox, scale), to_physical(oy, scale)), + size, + })) + }) + .await + .ok_or_else(|| "the native window could not be read".to_string())? + } + + async fn settle(&mut self) { + // El hilo de UI queda libre mientras el gestor responde. + tokio::time::sleep(SETTLE).await; + } +} diff --git a/src-tauri/src/detail_placement_test.rs b/src-tauri/src/detail_placement_test.rs new file mode 100644 index 0000000..ee571ad --- /dev/null +++ b/src-tauri/src/detail_placement_test.rs @@ -0,0 +1,655 @@ +//! Contrato determinístico del ciclo de colocación del detalle (#215). +//! +//! Nota P2 (PR #224): mover la ventana con píxeles físicos tal cual duplica +//! el desplazamiento a escala ×2; `position_call` es el seam que lo prueba +//! sin GTK. +//! +//! El doble `Surface` modela al gestor de ventanas: la visibilidad la decide +//! el frontend, el compositor contesta tarde, el permiso de resize está +//! abierto **sólo** alrededor del resize programático dentro de la única +//! llamada de tamaño, una colocación posterior de detalle sustituye a la +//! pendiente con su propia generación, y ninguna llamada nativa se da por +//! buena sin confirmación. Las aserciones comprueban lo que el ciclo informa, +//! no sólo el orden de las llamadas. + +use super::detail_placement::*; +use crate::detail_geometry::Bounds; +use crate::widget_placement::{Geometry, Reason, Status, Transport, READS}; + +/// Doble del gestor de ventanas: la ventana suele estar oculta cuando llegan +/// los bounds, el compositor puede rechazar el tamaño, y el permiso vive +/// sólo dentro de la llamada de tamaño. +struct Fake { + /// (origen, tamaño) que el compositor concede una vez que la ventana es + /// legible. + granted: Geometry, + /// Lecturas que todavía no ven la ventana (sigue oculta). + hidden: usize, + /// Lecturas hechas y settles esperados. + reads: usize, + settles: usize, + /// Si la ventana es visible: esto lo decide el frontend. + viewable: bool, + /// Si las lecturas fallan (sin respuesta de la superficie nativa). + fail_read: bool, + /// Llamadas de tamaño que no se pueden confirmar (las primeras N). + fail_calls: usize, + /// Llamadas de posición que no se pueden confirmar (las primeras N). + fail_position_calls: usize, + /// Si el permiso de resize está activo. + resizable: bool, + /// Llamadas nativas, en orden (lecturas incluidas, así el orden es + /// observable). + calls: Vec, +} + +impl Fake { + /// El compositor concede la geometría pedida, lecturas incluidas. + fn granting() -> Self { + Fake { + granted: Geometry { + origin: (1859, 899), + size: (314, 379), + }, + hidden: 0, + reads: 0, + settles: 0, + viewable: true, + fail_read: false, + fail_calls: 0, + fail_position_calls: 0, + resizable: false, + calls: Vec::new(), + } + } + + /// La ventana que el frontend todavía no mostró. + fn hidden_window() -> Self { + Fake { + viewable: false, + ..Fake::granting() + } + } + + /// Índices de «permiso abierto» y «permiso cerrado». + fn permission_window(&self) -> (usize, usize) { + let open = self + .calls + .iter() + .position(|c| c == "permission open") + .expect("the permission opens for the resize"); + let closed = self + .calls + .iter() + .position(|c| c == "permission closed") + .expect("the permission is withdrawn"); + (open, closed) + } +} + +impl Surface for Fake { + async fn request_size(&mut self, width: i32, height: i32) -> bool { + if self.fail_calls > 0 { + self.fail_calls -= 1; + self.calls + .push(format!("size {width}x{height} (unconfirmed)")); + return false; + } + self.calls.push(format!("size {width}x{height}")); + // El permiso de resize se abre **sólo** para el resize programático y + // se retira en la misma llamada: fuera de ella no hay cursor de + // redimensionar ni arrastre de borde. + self.calls.push("permission open".into()); + self.calls.push("permission closed".into()); + self.resizable = false; + true + } + + async fn request_position(&mut self, x: i32, y: i32) -> bool { + if self.fail_position_calls > 0 { + self.fail_position_calls -= 1; + self.calls.push(format!("position {x},{y} (unconfirmed)")); + return false; + } + self.calls.push(format!("position {x},{y}")); + true + } + + async fn read_geometry(&mut self) -> Result, String> { + self.reads += 1; + self.calls.push(format!("read {}", self.reads)); + if self.fail_read { + return Err("the native surface did not answer".into()); + } + // Todavía no es visible: no es un fallo, sólo un «todavía no». + if self.reads <= self.hidden || !self.viewable { + return Ok(None); + } + Ok(Some(self.granted)) + } + + async fn settle(&mut self) { + self.settles += 1; + } +} + +/// Los bounds pedidos: el rectángulo físico que manda el frontend (contenido +/// del panel de detalle, sin anclaje). +fn bounds() -> Bounds { + Bounds { + x: 1859, + y: 899, + w: 314, + h: 379, + } +} + +/// Corre el ciclo con el doble: sin temporizadores, sin hilo de UI. +fn run(fake: &mut Fake, bounds: &Bounds, superseded: bool) -> Report { + futures::executor::block_on(drive(fake, *bounds, || superseded)) +} + +/// Corre el ciclo con un cancelador que decide en cada fase. +fn run_with(fake: &mut Fake, bounds: &Bounds, superseded: impl FnMut() -> bool) -> Report { + futures::executor::block_on(drive(fake, *bounds, superseded)) +} + +#[test] +fn the_cycle_reports_the_geometry_the_window_manager_granted() { + let mut fake = Fake::granting(); + let report = run(&mut fake, &bounds(), false); + assert_eq!(report.status, Status::Applied); + assert_eq!( + report.bounds, + Some(Bounds { + x: 1859, + y: 899, + w: 314, + h: 379 + }) + ); + assert!(report.reason.is_none()); + // La geometría viene del compositor: el frontend sabe que admite la + // tolerancia de redondeo. + assert!(report.read_back, "the geometry is read-back geometry"); +} + +#[test] +fn the_size_permission_is_open_only_during_the_single_size_call() { + let mut fake = Fake::granting(); + let report = run(&mut fake, &bounds(), false); + assert_eq!(report.status, Status::Applied); + // El permiso se abre y se retira dentro de la misma llamada nativa: + // entre las dos no cabe otra llamada (ni lectura ni movimiento). + let (open, closed) = fake.permission_window(); + assert_eq!( + closed, + open + 1, + "the permission lasts exactly the resize: {:?}", + fake.calls + ); + // Al final del ciclo no queda ningún permiso abierto, y se abrió + // exactamente una vez. + assert!(!fake.resizable, "the resize permission is withdrawn"); + assert_eq!( + fake.calls + .iter() + .filter(|c| *c == "permission open") + .count(), + 1, + "a single resize with permission: {:?}", + fake.calls + ); +} + +/// Doble para `Sizable` que registra cada paso, así el orden real de la +/// composición (`size_call`) es observable sin GTK. +struct Recorder { + calls: Vec, + resizable: bool, +} + +impl Sizable for Recorder { + fn set_constraints(&mut self, width: i32, height: i32) { + self.calls.push(format!("constraints {width}x{height}")); + } + fn resize_with_permission(&mut self, width: i32, height: i32) { + self.calls.push("permission open".into()); + self.calls.push(format!("resize {width}x{height}")); + } + fn close_permission(&mut self) -> bool { + self.calls.push("permission closed".into()); + self.resizable = false; + true + } +} + +#[test] +fn the_size_call_fixes_the_hints_before_resizing_and_closes_the_permission() { + let mut window = Recorder { + calls: Vec::new(), + resizable: true, + }; + let closed = size_call(&mut window, 314, 379); + assert!(closed, "the permission ends closed"); + assert_eq!( + window.calls, + vec![ + "constraints 314x379", + "permission open", + "resize 314x379", + "permission closed" + ], + "size request + hints min == max go before the resize: {:?}", + window.calls + ); + assert!(!window.resizable, "no permission left open"); +} + +/// Doble para `Positioner` que registra cada movimiento, así la conversión +/// de la petición física a lógica (`position_call`) es observable sin GTK. +struct Mover { + scale: f64, + calls: Vec<(i32, i32)>, +} + +impl Positioner for Mover { + fn scale(&self) -> f64 { + self.scale + } + fn move_to(&mut self, x: i32, y: i32) { + self.calls.push((x, y)); + } +} + +#[test] +fn the_position_call_converts_both_axes_to_logical_at_scale_x2() { + // P2 de PR #224: `gtk.move_` espera píxeles LÓGICOS, la petición llega + // en píxeles físicos. A escala ×2 mover los valores físicos tal cual + // duplica el desplazamiento del panel: los dos ejes se convierten + // antes del movimiento. + let mut mover = Mover { + scale: 2.0, + calls: Vec::new(), + }; + assert!(position_call(&mut mover, 200, 300)); + assert_eq!( + mover.calls, + vec![(100, 150)], + "both axes convert with the window scale: {:?}", + mover.calls + ); + + // A escala 1:1 no cabe conversión: la petición física pasa tal cual. + let mut mover = Mover { + scale: 1.0, + calls: Vec::new(), + }; + assert!(position_call(&mut mover, 200, 300)); + assert_eq!( + mover.calls, + vec![(200, 300)], + "scale 1.0 is identity: {:?}", + mover.calls + ); +} + +#[test] +fn the_position_comes_after_the_size_and_the_read_back_after_the_permission() { + let mut fake = Fake::granting(); + run(&mut fake, &bounds(), false); + // Orden: primero la llamada de tamaño, después la posición, y ninguna + // lectura antes de que el permiso quede cerrado (el read-back no debe + // ocurrir con un permiso abierto). + let size = fake + .calls + .iter() + .position(|c| c.starts_with("size ")) + .expect("the size is requested"); + let position = fake + .calls + .iter() + .position(|c| c.starts_with("position ")) + .expect("the position is requested"); + let (_, permission_closed) = fake.permission_window(); + let first_read = fake + .calls + .iter() + .position(|c| c.starts_with("read ")) + .expect("the geometry is read back"); + assert!(size < position, "position after size: {:?}", fake.calls); + assert!( + permission_closed < first_read, + "read-back after the permission closes: {:?}", + fake.calls + ); +} + +#[test] +fn a_hidden_window_with_an_exhausted_budget_reports_a_soft_unverified_reason() { + let mut hidden = Fake::hidden_window(); + hidden.hidden = usize::MAX; + let report = run(&mut hidden, &bounds(), false); + // Ni un error, ni un aplicado fingido: la apertura nunca queda bloqueada + // esperando visibilidad y nada se cachea como confirmado. + assert_eq!(report.status, Status::Unverified); + assert_eq!(report.reason, Some(Reason::NotViewable)); + assert_eq!(report.bounds, None, "no geometry to cache"); + assert!(!report.read_back); + // Y el presupuesto es acotado, no hay espera permanente. + assert!( + hidden.reads <= 2 * READS, + "no permanent wait: {} reads", + hidden.reads + ); +} + +#[test] +fn a_not_yet_viewable_window_is_retried_within_the_budget() { + let mut fake = Fake::granting(); + // Las primeras lecturas no ven la ventana: «todavía no», no un fallo. + fake.hidden = 2; + let report = run(&mut fake, &bounds(), false); + assert_eq!(report.status, Status::Applied); + assert_eq!( + report.bounds, + Some(Bounds { + x: 1859, + y: 899, + w: 314, + h: 379 + }) + ); +} + +#[test] +fn a_visible_window_whose_size_never_matches_reports_size_refused() { + // La ventana es visible pero el compositor mantiene pegado el tamaño + // viejo (p. ej. 330×379 para un panel de 314 de ancho). + let mut fake = Fake::granting(); + fake.granted.size = (330, 379); + let report = run(&mut fake, &bounds(), false); + assert_eq!(report.status, Status::Limited); + assert_eq!(report.reason, Some(Reason::SizeRefused)); + // La geometría informada es la efectiva en pantalla, no la pedida. + assert_eq!( + report.bounds, + Some(Bounds { + x: 1859, + y: 899, + w: 330, + h: 379 + }) + ); + assert!(report.read_back); +} + +#[test] +fn a_size_that_is_granted_but_a_position_that_is_not_is_a_position_problem() { + let mut fake = Fake::granting(); + fake.granted.origin = (800, 425); + let report = run(&mut fake, &bounds(), false); + assert_eq!(report.status, Status::Limited); + assert_eq!(report.reason, Some(Reason::PositionRefused)); + assert_eq!( + report.bounds, + Some(Bounds { + x: 800, + y: 425, + w: 314, + h: 379 + }) + ); +} + +#[test] +fn the_one_pixel_tolerance_is_reserved_for_the_read_back_geometry() { + // La geometría del read-back se admite dentro de ±1 px (el gestor + // redondea el tamaño lógico a píxeles de dispositivo)… + let mut fake = Fake::granting(); + fake.granted.size = (313, 379); + let report = run(&mut fake, &bounds(), false); + assert_eq!(report.status, Status::Applied); + // …pero se informa exactamente la geometría que concedió el servidor, + // nunca la pedida. + assert_eq!( + report.bounds, + Some(Bounds { + x: 1859, + y: 899, + w: 313, + h: 379 + }) + ); + // A dos píxeles ya no es el tamaño pedido. + let mut fake = Fake::granting(); + fake.granted.size = (316, 379); + let report = run(&mut fake, &bounds(), false); + assert_eq!(report.status, Status::Limited); + assert_eq!(report.reason, Some(Reason::SizeRefused)); +} + +#[test] +fn a_native_call_that_cannot_be_confirmed_is_not_a_success() { + let mut fake = Fake::granting(); + // La llamada nativa no responde (ni en el reintento): el ciclo no puede + // seguir como si el tamaño se hubiera aplicado. + fake.fail_calls = 2; + let report = run(&mut fake, &bounds(), false); + assert_eq!(report.status, Status::Unverified); + assert_eq!(report.reason, Some(Reason::ReadFailed)); + assert_eq!(report.bounds, None); + assert!(!fake.resizable, "no permission left open"); +} + +#[test] +fn a_native_call_is_retried_within_a_bounded_budget() { + let mut fake = Fake::granting(); + // La primera llamada de tamaño no se confirma: el ciclo la reintenta + // exactamente una vez y, si sale bien, la colocación sigue. + fake.fail_calls = 1; + let report = run(&mut fake, &bounds(), false); + assert_eq!(report.status, Status::Applied); + let sizes = fake.calls.iter().filter(|c| c.starts_with("size ")).count(); + assert_eq!(sizes, 2, "one retry and nothing more: {:?}", fake.calls); +} + +#[test] +fn a_later_detail_placement_supersedes_the_pending_one_with_its_own_generation() { + // Dos generaciones reales del detalle: la posterior (otra colocación del + // mismo panel) invalida la primera. La generación es la propia del + // detalle, nunca toca la de la tira. + let first = next_generation(); + let mut fake = Fake::granting(); + let mut checks = 0; + let report = run_with(&mut fake, &bounds(), || { + checks += 1; + if checks == 2 { + next_generation(); // la colocación posterior reserva su generación + } + superseded(first) + }); + assert_eq!(report.status, Status::Superseded); + assert!(superseded(first), "the first generation expired"); + assert_eq!( + report.bounds, None, + "no geometry is reported post-cancellation" + ); + // La colocación sustituida se detiene antes de la siguiente fase nativa: + // nunca vuelve a mover la ventana. + assert!( + !fake.calls.iter().any(|c| c.starts_with("position")), + "no move after supersession: {:?}", + fake.calls + ); +} + +#[test] +fn an_earlier_placement_cannot_touch_the_window_after_a_later_one_took_over() { + // El relevo llega después de la fase de tamaño: el ciclo anterior no debe + // redimensionar, mover ni pisar nada de lo que el sucesor acaba de + // colocar. + let first = next_generation(); + let mut fake = Fake::granting(); + let mut checks = 0; + let mut took_over = false; + let report = run_with(&mut fake, &bounds(), || { + checks += 1; + if checks > 2 && !took_over { + took_over = true; + next_generation(); // arranca el sucesor + } + superseded(first) + }); + assert_eq!(report.status, Status::Superseded); + // El relevo llega mientras el ciclo anterior está entre fases: ya había + // redimensionado y movido la ventana, pero no debe informar la geometría + // (el sucesor está a punto de reemplazarla) ni volver a tocar la ventana. + assert_eq!( + fake.calls + .iter() + .filter(|c| c.starts_with("size ") || c.starts_with("position ")) + .count(), + 2, + "the size and move already happened, and nothing more: {:?}", + fake.calls + ); + assert_eq!(report.bounds, None); +} + +#[test] +fn a_superseded_placement_leaves_no_resize_permission_open() { + let mut fake = Fake::granting(); + let mut checks = 0; + let report = run_with(&mut fake, &bounds(), || { + checks += 1; + checks > 1 + }); + assert_eq!(report.status, Status::Superseded); + // Ni una colocación cancelada deja el permiso abierto: se abre y se + // retira dentro de la misma llamada de tamaño. + assert!(!fake.resizable, "the permission is withdrawn"); + assert_eq!( + fake.calls + .iter() + .filter(|c| *c == "permission open") + .count(), + 1, + "the permission opened only for the size call: {:?}", + fake.calls + ); +} + +#[test] +fn a_failed_read_is_not_reported_as_a_geometry() { + let mut fake = Fake::granting(); + fake.fail_read = true; + let report = run(&mut fake, &bounds(), false); + assert_eq!(report.status, Status::Unverified); + assert_eq!(report.reason, Some(Reason::ReadFailed)); + assert_eq!(report.bounds, None, "no read-back, no geometry to cache"); + assert!(!report.read_back); +} + +#[test] +fn a_non_x11_display_keeps_the_apply_only_unverified_path() { + // S1: sólo X11/XWayland toma el ciclo; Wayland nativo (y cualquier otra + // cosa) conserva el viejo comportamiento de aplicar e informar + // unverified. + assert_eq!( + route_of(Transport::X11), + Route::X11Cycle, + "X11/XWayland: the size cycle with read-back" + ); + assert_eq!( + route_of(Transport::Other), + Route::UnverifiedPath, + "native Wayland: no anchoring, no read-back" + ); +} + +#[test] +fn the_unverified_report_carries_no_geometry_and_no_reason() { + // No X11 (y el oculto ilegible) sólo confirman que la petición se aplicó: + // sin bounds, sin motivo, sin marca de read-back. + let report = unverified_report(); + assert_eq!(report.status, Status::Unverified); + assert_eq!(report.bounds, None); + assert!(report.reason.is_none()); + assert!(!report.read_back); +} + +/// Prueba en vivo de la secuencia nativa de tamaño con un webview real (#215). +/// No determinística: +/// `GDK_BACKEND=x11 cargo test --locked detail_placement -- --ignored`. +#[cfg(target_os = "linux")] +#[test] +#[ignore = "necesita una sesión X11"] +fn the_native_size_call_grants_the_size_and_leaves_no_permission_open() { + use gtk::prelude::*; + use webkit2gtk::WebViewExt; + gtk::init().expect("GTK must initialise"); + // Mismas condiciones de nacimiento que la ventana de detalle: sin + // decoraciones, sin redimensionar y con un tamaño por defecto mayor que + // el contenido (el bug del PMinSize pegado). + let window = gtk::Window::new(gtk::WindowType::Toplevel); + window.set_decorated(false); + window.set_resizable(false); + window.set_default_size(330, 260); + let view = webkit2gtk::WebView::new(); + view.load_html( + "
", + None, + ); + window.add(&view); + settle(700); + assert_ne!( + window.size(), + (314, 379), + "the window starts at its default size" + ); + + // La visibilidad es del frontend: la ventana se muestra antes de que se + // coloquen los bounds. + window.show_all(); + settle(700); + assert!(window.is_mapped(), "the window is visible"); + + // La única llamada de tamaño de producción: primero las pistas, el + // permiso de resize abierto sólo alrededor del resize, cerrado en la + // misma llamada. + let closed = size_call(&mut GtkSizable(&window), 314, 379); + assert!(closed, "the resize permission ends closed"); + settle(600); + assert_eq!(window.size(), (314, 379), "the size is granted exact"); + assert!(!window.is_resizable(), "the permission stays closed"); + + // La posición es explícita después del ciclo de tamaño: sin anclaje, sin + // resize. Dentro del área útil: con una posición fuera de pantalla el + // gestor la reubica para que la ventana quepa y la petición no se + // reflejaría tal cual. + window.move_(100, 100); + settle(400); + assert_eq!(window.position(), (100, 100), "the position is applied"); + + // El tamaño concedido sobrevive a la retirada del permiso. + settle(700); + assert_eq!( + window.size(), + (314, 379), + "the size survives the permission withdrawal" + ); + assert_eq!(window.position(), (100, 100)); +} + +/// Le hace a GTK procesar eventos (la respuesta del gestor) durante `ms`. +#[cfg(target_os = "linux")] +fn settle(ms: u64) { + let until = std::time::Instant::now() + std::time::Duration::from_millis(ms); + while std::time::Instant::now() < until { + while gtk::events_pending() { + let _ = gtk::main_iteration_do(false); + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8905ced..1163d2f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,10 @@ mod uninstall; // El módulo se compila también en Windows/macOS (el comando se invoca siempre), // pero allí sus piezas de Linux no se usan: sin avisos de código muerto. #[cfg_attr(not(target_os = "linux"), allow(dead_code))] +mod detail_placement; +#[cfg(test)] +mod detail_placement_test; +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] mod widget_placement; #[cfg(test)] mod widget_placement_test; @@ -74,17 +78,11 @@ async fn set_surface_clip( async fn set_detail_bounds( window: WebviewWindow, bounds: detail_geometry::Bounds, -) -> Result<(), String> { - let (tx, mut rx) = tauri::async_runtime::channel(1); - let ui_window = window.clone(); - window - .run_on_main_thread(move || { - let _ = tx.try_send(detail_geometry::apply(&ui_window, bounds)); - }) - .map_err(|e| e.to_string())?; - rx.recv() - .await - .ok_or_else(|| "Detail bounds UI callback was dropped".to_string())? +) -> Result { + // La generación se reserva al llegar la petición: otra posterior la + // cancela. Es la generación **propia** del detalle, nunca la del widget. + let generation = detail_placement::next_generation(); + detail_placement::place(&window, bounds, generation).await } /// Coloca la tira en la superficie nativa y devuelve la geometría que quedó en diff --git a/src-tauri/src/widget_placement.rs b/src-tauri/src/widget_placement.rs index 05327d3..d4288e3 100644 --- a/src-tauri/src/widget_placement.rs +++ b/src-tauri/src/widget_placement.rs @@ -624,7 +624,7 @@ pub fn apply_size( /// Corre una llamada nativa en el hilo de UI y espera su resultado. #[cfg(target_os = "linux")] -async fn on_ui( +pub(crate) async fn on_ui( window: &tauri::WebviewWindow, call: impl FnOnce(>k::ApplicationWindow) -> T + Send + 'static, ) -> Option { @@ -813,7 +813,7 @@ fn transport() -> Transport { /// Detecta el transporte en el hilo de UI (GDK no admite otro hilo). #[cfg(target_os = "linux")] -async fn transport_on_ui(window: &tauri::WebviewWindow) -> Transport { +pub(crate) async fn transport_on_ui(window: &tauri::WebviewWindow) -> Transport { on_ui(window, |_| transport()) .await .unwrap_or(Transport::Other) diff --git a/src/DetailPanel.test.tsx b/src/DetailPanel.test.tsx new file mode 100644 index 0000000..15371c3 --- /dev/null +++ b/src/DetailPanel.test.tsx @@ -0,0 +1,114 @@ +// Prueba de renderizado real en servidor (renderToStaticMarkup), sin DOM falso, +// como el resto del repositorio: sin estado el panel no pinta nada. Los efectos +// (listener de estado y cola de colocación) no corren en SSR, así que la +// disciplina de la caché del panel se fija aquí sobre las mismas primitivas que +// el efecto y el listener usan literalmente — `confirmDetailPlacement` y +// `samePlacement` en la condición de salida temprana (#215). + +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { DetailPanel } from './DetailPanel'; +import { confirmDetailPlacement, type DetailPlacementReport } from './detail-placement'; +import type { PhysicalBounds } from './lib/detail-panel'; +import { samePlacement } from './lib/widget-placement'; + +describe('DetailPanel (SSR)', () => { + it('renders nothing until the widget sends state', () => { + // Los efectos no corren en el servidor: sin estado no hay marcado. + expect(renderToStaticMarkup()).toBe(''); + }); +}); + +/** + * Las refs `lastAppliedRef` + `lastAppliedReadBackRef` + `positionedRef` tal + * como el efecto de layout las cablea: se vacían antes de cada aplicación, + * `confirmDetailPlacement` sólo cachea geometría confirmada, y `wouldDedupe` + * es la condición literal de salida temprana del efecto + * (`positionedRef && samePlacement(...)`). + */ +const wireUp = () => { + const cache = { bounds: null as PhysicalBounds | null, readBack: false }; + const positioned = { current: false }; + const cacheConfirmed = (bounds: PhysicalBounds) => { cache.bounds = bounds; cache.readBack = true; }; + const apply = (report: DetailPlacementReport): boolean => + confirmDetailPlacement(report, cacheConfirmed); + const resetForClose = () => { + // Reset del listener de estado cuando el widget cierra el panel (o al + // cerrarse de nuevo): `lastAppliedRef` vacío + `positioned` false. + positioned.current = false; + cache.bounds = null; + cache.readBack = false; + }; + const wouldDedupe = (requested: PhysicalBounds): boolean => + positioned.current && samePlacement(cache.bounds, requested, cache.readBack); + const report = (over: Partial = {}): DetailPlacementReport => ({ + status: 'applied', + bounds: null, + read_back: true, + ...over, + }); + return { cache, positioned, cacheConfirmed, apply, resetForClose, wouldDedupe, report }; +}; + +describe('caché de lastApplied (#215)', () => { + const initial: PhysicalBounds = { x: 120, y: 900, w: 330, h: 379 }; + + it('sólo la geometría confirmada (read-back) deduplica un re-pedido idéntico', () => { + const { positioned, apply, wouldDedupe, report } = wireUp(); + expect(apply(report({ bounds: initial }))).toBe(false); + positioned.current = true; + expect(wouldDedupe(initial)).toBe(true); + }); + + it('el re-pedido idéntico nunca se deduplica contra la geometría pedida sin read-back', () => { + const { positioned, apply, wouldDedupe } = wireUp(); + // Informe no X11 (unverified, sin read-back): el cache queda vacío. + expect(apply({ status: 'unverified', bounds: null, read_back: false })).toBe(false); + positioned.current = true; + expect(wouldDedupe(initial)).toBe(false); + }); + + it('una colocación superseded no cachea: el re-pedido idéntico no se deduplica', () => { + const { positioned, apply, wouldDedupe } = wireUp(); + expect(apply({ status: 'superseded', bounds: null, reason: 'superseded', read_back: false })).toBe(false); + positioned.current = true; + expect(wouldDedupe(initial)).toBe(false); + }); + + it('cuando el backend limita el tamaño, queda cacheada la concedida y el pedido vuelve a aplicarse', () => { + const granted: PhysicalBounds = { ...initial, w: 314 }; + const { positioned, apply, wouldDedupe, report } = wireUp(); + expect(apply(report({ status: 'limited', reason: 'size_refused', bounds: granted }))).toBe(false); + positioned.current = true; + // El pedido original no coincide con lo concedido: el siguiente layout + // idéntico re-aplica en vez de deduplicar contra la geometría pedida. + expect(wouldDedupe(initial)).toBe(false); + expect(wouldDedupe(granted)).toBe(true); + }); + + it('cierre y reapertura reinician exactamente como hoy: la primera colocación de la reapertura no se deduplica', () => { + const { positioned, apply, resetForClose, wouldDedupe, report } = wireUp(); + expect(apply(report({ bounds: initial }))).toBe(false); + positioned.current = true; + expect(wouldDedupe(initial)).toBe(true); + resetForClose(); + expect(wouldDedupe(initial)).toBe(false); + }); + + it('un informe `not_viewable` blando no cachea: un re-pedido idéntico re-aplica', () => { + const { positioned, apply, wouldDedupe } = wireUp(); + expect(apply({ status: 'unverified', bounds: null, reason: 'not_viewable', read_back: false })).toBe(true); + positioned.current = true; + expect(wouldDedupe(initial)).toBe(false); + }); + + it('un cambio de proveedor o de DPI (bounds físicos nuevos) re-aplica la colocación', () => { + const { positioned, apply, wouldDedupe, report } = wireUp(); + expect(apply(report({ bounds: initial }))).toBe(false); + positioned.current = true; + expect(wouldDedupe(initial)).toBe(true); + // Los bounds físicos cambian: la dedupe con tolerancia ya no aplica. + expect(wouldDedupe({ ...initial, y: initial.y - 40 })).toBe(false); + expect(wouldDedupe({ ...initial, w: initial.w + 2, h: initial.h + 2 })).toBe(false); + }); +}); diff --git a/src/DetailPanel.tsx b/src/DetailPanel.tsx index d79503c..f1d43de 100644 --- a/src/DetailPanel.tsx +++ b/src/DetailPanel.tsx @@ -9,7 +9,9 @@ import { intlLocale, t } from './i18n'; import { useLocale } from './i18n/use-locale'; import { reportSummary } from './lib/format'; import { clipSurfaceSafely, measureSurfaceClip } from './lib/surface-clip'; -import { createLatestQueue, basePanelZoom, detailBounds, fitPanel, panelScaleFromTransform, physicalPanelBounds, sameFit, samePhysical, DETAIL_EVENTS, type DetailInteraction, type DetailState, type PanelFit, type PhysicalBounds } from './lib/detail-panel'; +import { createLatestQueue, basePanelZoom, detailBounds, fitPanel, panelScaleFromTransform, physicalPanelBounds, sameFit, DETAIL_EVENTS, type DetailInteraction, type DetailState, type PanelFit, type PhysicalBounds } from './lib/detail-panel'; +import { samePlacement } from './lib/widget-placement'; +import { confirmDetailPlacement, retryDetailPlacement } from './detail-placement'; async function copyText(text: string): Promise { try { @@ -37,9 +39,13 @@ export function DetailPanel() { const enqueue = useRef(createLatestQueue()).current; // Fitted geometry; `null` until the first measurement, when the basal zoom is used. const [fit, setFit] = useState(null); - // Last native geometry actually applied (physical, incl. DPI). Only recorded after a - // successful apply so superseded or failed jobs cannot skip a needed retry. + // Last native geometry actually applied (physical, incl. DPI). #215: only the + // geometry the backend read back from the compositor is ever recorded — the + // requested one never is, so a soft report cannot skip a needed retry. const lastAppliedRef = useRef(null); + // Whether `lastAppliedRef` came from the X11 read-back (the only cacheable + // kind): exactly that geometry admits the ±1 px rounding tolerance. + const lastAppliedReadBackRef = useRef(false); const positionedRef = useRef(false); const interact = (kind: DetailInteraction['kind']) => { @@ -61,7 +67,9 @@ export function DetailPanel() { dataRef.current = payload; // Invalidate an in-flight show immediately, before React commits new content. enqueue(async () => { if (!payload) await getCurrentWindow().hide(); }).catch(console.warn); - if (!payload || wasClosed) { setPositioned(false); positionedRef.current = false; lastAppliedRef.current = null; } + // #215: the read-back cache resets with `positioned` so the first + // placement after a reopen is never deduped against the old geometry. + if (!payload || wasClosed) { setPositioned(false); positionedRef.current = false; lastAppliedRef.current = null; lastAppliedReadBackRef.current = false; } setData(payload); }); if (disposed) { stateListener(); return; } @@ -100,15 +108,23 @@ export function DetailPanel() { // Compare native (physical) bounds, not logical ones: a DPI change with identical // logical geometry must still resize the window. const physical = physicalPanelBounds(detailBounds(data, next), data.scaleFactor); - if (positionedRef.current && samePhysical(lastAppliedRef.current, physical)) return; + // #215: the cache only ever holds + // read-back-confirmed geometry, so the ±1 px rounding tolerance of X11 applies + // by the same rule as the widget; on a platform without read-back the cache + // stays empty and every comparison applies exactly (no tolerance). + if (positionedRef.current && samePlacement(lastAppliedRef.current, physical, lastAppliedReadBackRef.current)) return; enqueue(async (current) => { if (!current() || disposed) return; // An in-flight move can outlive its layout. Clear the old geometry before // awaiting it, so a return to that geometry cannot incorrectly dedupe. lastAppliedRef.current = null; + lastAppliedReadBackRef.current = false; // Move and resize together: moving a taller previous panel first briefly // covers the widget below it. Physical bounds already include DPI rounding. - await api.setDetailBounds(physical); + // Only the confirmed (read-back) geometry reaches the cache; the requested + // one never does (S3/D2). + const cache = (confirmed: PhysicalBounds) => { lastAppliedRef.current = confirmed; lastAppliedReadBackRef.current = true; }; + let retry = confirmDetailPlacement(await api.setDetailBounds(physical), cache); if (!current() || disposed) return; await clipSurfaceSafely(measureSurfaceClip(panel, physical, next.zoom * data.scaleFactor), api.setSurfaceClip, console.warn, panel); if (!current() || disposed) return; @@ -117,9 +133,16 @@ export function DetailPanel() { // The native command refuses to show an orphan panel when the widget is hidden. await api.showDetail(); if (!current() || disposed) return; - // Cache only after the whole native apply succeeded, so a stale or failed job - // never marks a geometry as applied while the window still holds the old one. - lastAppliedRef.current = physical; + // The backend could not confirm the geometry because the window was still + // hidden (`not_viewable`): re-ask after showing, in silence and within the + // bounded budget, aborting if the detail closed or changed meanwhile + // (#215/#221). An exhausted budget stays soft: nothing is cached and the + // opening already succeeded. + if (retry) { + const report = await retryDetailPlacement(physical, { place: api.setDetailBounds, isCurrent: () => !disposed && current() }); + if (!current() || disposed || !report) return; + confirmDetailPlacement(report, cache); + } }).catch(console.warn); }; layout(); diff --git a/src/detail-placement.test.ts b/src/detail-placement.test.ts new file mode 100644 index 0000000..69b476e --- /dev/null +++ b/src/detail-placement.test.ts @@ -0,0 +1,190 @@ +// Contrato del informe nativo y del reintento de la colocación del detail (#215). +// +// El backend es la autoridad: responde con lo que quedó en pantalla (read-back de +// X11) o con un informe blando si no pudo confirmar. Estas pruebas fijan la +// disciplina de caché — la solicitud **nunca** se cachea como confirmada (S3/D2) — +// y el reintento silencioso tras `show()` cuando la ventana aún no era legible +// (#221: mismas primitivas que la tira, nunca duplicadas). + +import { describe, expect, it, vi } from 'vitest'; +import { + confirmDetailPlacement, + decideDetailPlacement, + retryDetailPlacement, + type DetailPlacementReport, +} from './detail-placement'; +import { samePlacement } from './lib/widget-placement'; +import type { PhysicalBounds } from './lib/detail-panel'; + +const requested: PhysicalBounds = { x: 120, y: 900, w: 330, h: 379 }; +const granted: PhysicalBounds = { x: 120, y: 900, w: 314, h: 379 }; + +const report = (over: Partial = {}): DetailPlacementReport => ({ + status: 'applied', + bounds: granted, + read_back: true, + ...over, +}); + +/** X11 con la ventana aún oculta: blando, sin geometría, reintento tras `show()`. */ +const notViewable = (): DetailPlacementReport => ({ + status: 'unverified', bounds: null, reason: 'not_viewable', read_back: false, +}); + +/** No Linux / Wayland nativo: la petición se aplicó y no hay read-back (S1). */ +const nonX11 = (): DetailPlacementReport => ({ + status: 'unverified', bounds: null, read_back: false, +}); + +describe('decideDetailPlacement', () => { + it('caches only the report geometry with read-back; the request never caches as confirmed', () => { + const decision = decideDetailPlacement(report()); + expect(decision.bounds).toEqual(granted); + expect(decision.retry).toBe(false); + }); + + it('nothing is cacheable without read-back, even if bounds are present', () => { + // Nunca lo produce este backend, pero el contrato no cachea nada sin + // confirmar (defensa en profundidad de S3). + const decision = decideDetailPlacement(report({ read_back: false })); + expect(decision.bounds).toBeNull(); + expect(decision.retry).toBe(false); + }); + + it('the non-X11 report (unverified, without geometry) caches nothing and does not retry', () => { + const decision = decideDetailPlacement(nonX11()); + expect(decision).toEqual({ bounds: null, retry: false }); + }); + + it('a failed read caches nothing and does not retry', () => { + const decision = decideDetailPlacement({ status: 'unverified', bounds: null, reason: 'read_failed', read_back: false }); + expect(decision).toEqual({ bounds: null, retry: false }); + }); + + it('a superseded placement caches nothing (the geometry was left half-done)', () => { + const decision = decideDetailPlacement({ status: 'superseded', bounds: null, reason: 'superseded', read_back: false }); + expect(decision).toEqual({ bounds: null, retry: false }); + }); + + it('`not_viewable` is soft: it caches nothing and asks for a retry after `show()`', () => { + const decision = decideDetailPlacement(notViewable()); + expect(decision).toEqual({ bounds: null, retry: true }); + }); +}); + +describe('confirmDetailPlacement', () => { + it('caches the confirmed geometry and does not ask for a retry', () => { + const cached: PhysicalBounds[] = []; + expect(confirmDetailPlacement(report(), (b) => { cached.push(b); })).toBe(false); + expect(cached).toEqual([granted]); + }); + + it('the cache is the effective geometry, never the requested one', () => { + const cached: PhysicalBounds[] = []; + confirmDetailPlacement(report({ bounds: { x: 7, y: 8, w: 314, h: 379 } }), (b) => { cached.push(b); }); + expect(cached).toEqual([{ x: 7, y: 8, w: 314, h: 379 }]); + expect(cached[0]).not.toEqual(requested); + }); + + it('limited (`size_refused`) caches the granted geometry, read-back, without a retry', () => { + const cached: PhysicalBounds[] = []; + const limited: DetailPlacementReport = report({ status: 'limited', reason: 'size_refused', bounds: granted }); + expect(confirmDetailPlacement(limited, (b) => { cached.push(b); })).toBe(false); + expect(cached).toEqual([granted]); + }); + + it('a `not_viewable` report caches nothing and asks for a retry', () => { + const cached: PhysicalBounds[] = []; + expect(confirmDetailPlacement(notViewable(), (b) => { cached.push(b); })).toBe(true); + expect(cached).toEqual([]); + }); + + it('the non-X11 report caches nothing without a retry: same flow as today', () => { + const cached: PhysicalBounds[] = []; + expect(confirmDetailPlacement(nonX11(), (b) => { cached.push(b); })).toBe(false); + expect(cached).toEqual([]); + }); + + it('superseded and read_failed cache nothing: soft, always readable open', () => { + for (const soft of [ + { status: 'superseded', bounds: null, reason: 'superseded', read_back: false }, + { status: 'unverified', bounds: null, reason: 'read_failed', read_back: false }, + ] as DetailPlacementReport[]) { + const cached: PhysicalBounds[] = []; + expect(confirmDetailPlacement(soft, (b) => { cached.push(b); })).toBe(false); + expect(cached).toEqual([]); + } + }); +}); + +describe('retryDetailPlacement (#221 pattern)', () => { + /** Devuelve los informes de la secuencia, repitiendo el último. */ + const placeFor = (reports: DetailPlacementReport[]) => { + const bounds: PhysicalBounds[] = []; + let index = 0; + return { + place: vi.fn(async (b: PhysicalBounds) => { + bounds.push(b); + return reports[Math.min(index++, reports.length - 1)]; + }), + bounds, + }; + }; + + const io = (place: (b: PhysicalBounds) => Promise, over: Partial[1]> = {}) => + ({ place, isCurrent: () => true, sleep: async () => {}, ...over } as Parameters[1]); + + it('re-asks exactly the requested placement while the window is not viewable', async () => { + const { place, bounds } = placeFor([notViewable(), notViewable(), report()]); + const result = await retryDetailPlacement(requested, io(place)); + // Misma solicitud en cada reintento; el último informe (applied) vuelve al + // llamante, que entonces cachea la geometría confirmada. + expect(place).toHaveBeenCalledTimes(3); + expect(bounds).toEqual([requested, requested, requested]); + expect(result).toEqual(report({ reason: undefined })); + }); + + it('gives up after the bounded budget without any real failure', async () => { + const { place } = placeFor([notViewable()]); + const result = await retryDetailPlacement(requested, io(place, { attempts: 4 })); + expect(place).toHaveBeenCalledTimes(4); + expect(result).toEqual(notViewable()); + }); + + it('returns null without asking when the detail already closed or changed', async () => { + const { place } = placeFor([report()]); + const result = await retryDetailPlacement(requested, io(place, { isCurrent: () => false })); + expect(place).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('stops between reads when the detail closed or changed meanwhile', async () => { + let places = 0; + const { place: rawPlace } = placeFor([notViewable()]); + const place = async (b: PhysicalBounds) => { + const r = await rawPlace(b); + places += 1; + return r; + }; + const result = await retryDetailPlacement(requested, io(place, { isCurrent: () => places < 2 })); + expect(places).toBe(2); + expect(result).toBeNull(); + }); +}); + +/** + * Invariante de cableado del efecto de layout: sobre una caché con geometría + * confirmada la misma solicitud se deduplica (tolerancia ±1 px del read-back), + * y una caché vacía —softs— deduplica nada. Es la condición literal de salida + * temprana del panel: `samePlacement(cached, requested, readBack)`. + */ +describe('dedupe sobre la caché de lastApplied', () => { + it('la geometría confirmada con read-back admite la tolerancia; sin caché, no', () => { + // El backend concedió 314 frente a 330 pedidos: nunca se deduplica contra + // la solicitud; un re-pedido igual a lo concedido sí deduplica. + expect(samePlacement(granted, requested, true)).toBe(false); + expect(samePlacement(granted, { ...granted, x: granted.x + 1 }, true)).toBe(true); + expect(samePlacement(null, requested, true)).toBe(false); + expect(samePlacement(null, requested, false)).toBe(false); + }); +}); diff --git a/src/detail-placement.ts b/src/detail-placement.ts new file mode 100644 index 0000000..7a9949a --- /dev/null +++ b/src/detail-placement.ts @@ -0,0 +1,100 @@ +// Decisión sobre el informe nativo del panel de detalle (#215). +// +// El backend es la autoridad sobre la geometría nativa efectiva: responde con +// un informe espejo del de la tira (`detail_placement.rs` re-exporta los tipos +// de `widget_placement.rs`, así ambos ciclos comparten un solo contrato). Este +// módulo decide qué se cachea de ese informe y compone el reintento silencioso +// de una ventana todavía no legible; las primitivas de `widget-placement.ts` se +// reutilizan, nunca se duplican. + +import type { PhysicalBounds } from './lib/detail-panel'; +import { readPlacementWithRetries, type WidgetPlacementReport } from './lib/widget-placement'; + +/** + * Informe de colocación nativa del detail: el backend viaja con la misma + * estructura que el de la tira, así el frontend trata con un solo contrato. + */ +export type DetailPlacementReport = WidgetPlacementReport; + +/** + * Qué hace el frontend con un informe nativo: + * + * - `bounds` es la geometría que se cachea como aplicada. Sólo la cachea si el + * backend la leyó del compositor (`read_back`): la solicitud pedida **nunca** + * se cachea como confirmada (S3), así un re-pedido idéntico no se deduplica + * por una geometría que quizá nunca se aplicó. + * - `retry` marca el informe blando de una ventana todavía no legible + * (`not_viewable`): tras `show()` se vuelve a pedir la colocación en silencio. + */ +export interface DetailPlacementDecision { + bounds: PhysicalBounds | null; + retry: boolean; +} + +/** + * Decide el cache y el reintento a partir del informe nativo. + * + * - `applied` leyó la geometría concedida y también `limited` informa tal cual + * el read-back (aunque el gestor haya concedido otra: `size_refused`/ + * `position_refused`): en ambos casos el cache es exactamente la geometría + * efectiva, no la solicitud (D2). + * - Un informe cancelado (`superseded`), una lectura fallida o el no-X11 + * (`unverified` sin geometría y sin motivo de ventana oculta) no dejan nada + * en el cache: no hay geometría fiable y no hay reintento — el flujo queda + * tal como el de #215 antes del read-back (S1). + */ +export const decideDetailPlacement = (report: DetailPlacementReport): DetailPlacementDecision => { + if (report.status === 'superseded') return { bounds: null, retry: false }; + if (report.reason === 'not_viewable') return { bounds: null, retry: true }; + return { bounds: report.read_back && report.bounds ? report.bounds : null, retry: false }; +}; + +/** + * Aplica la decisión de un informe a la caché del panel: la geometría confirmada + * (read-back) es lo único que entra; devuelve el mismo veredicto de reintento. + */ +export const confirmDetailPlacement = ( + report: DetailPlacementReport, + cache: (bounds: PhysicalBounds) => void, +): boolean => { + const decision = decideDetailPlacement(report); + if (decision.bounds) cache(decision.bounds); + return decision.retry; +}; + +/** Operaciones nativas del reintento, con el presupuesto injectable para los tests. */ +export interface DetailRetryIo { + place: (bounds: PhysicalBounds) => Promise; + /** True mientras el job sigue vigente y el panel montado: aborta los reintentos. */ + isCurrent: () => boolean; + attempts?: number; + delayMs?: number; + sleep?: (ms: number) => Promise; +} + +/** + * Vuelve a pedir la colocación en silencio después de `show()` cuando el + * informe blando dijo que la ventana todavía no era legible (`not_viewable`). + * + * El backend no puede leer una ventana oculta: ese informe no es un fallo real, + * es el compositor tardando en mapear. Con el mismo presupuesto acotado que la + * tira (#221) se re-pide la misma colocación —abortándola si el detalle cerró o + * cambió mientras tanto (isCurrent)— y se devuelve el último informe: si salió + * agotado, el llamante cachea nada y la apertura nunca se bloqueó (S3). + */ +export const retryDetailPlacement = ( + requested: PhysicalBounds, + io: DetailRetryIo, +): Promise => + readPlacementWithRetries( + async () => { + if (!io.isCurrent()) return null; + return io.place(requested); + }, + { + shouldAbort: () => !io.isCurrent(), + ...(io.attempts === undefined ? {} : { attempts: io.attempts }), + ...(io.delayMs === undefined ? {} : { delayMs: io.delayMs }), + ...(io.sleep === undefined ? {} : { sleep: io.sleep }), + }, + ); diff --git a/src/lib/api.ts b/src/lib/api.ts index d81a315..81c5dea 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -5,6 +5,7 @@ import type { CodexInstallResult, CodexSetupStatus } from './codex-install'; import type { SurfaceClip } from './surface-clip'; import type { PhysicalBounds } from './detail-panel'; import type { WidgetPlacementReport, WidgetPlacementRequest } from './widget-placement'; +import type { DetailPlacementReport } from '../detail-placement'; /** Campos que el widget puede cambiar sin arrastrar el resto de la config. */ export interface WidgetPatch { @@ -72,7 +73,13 @@ export const api = { checkProvider: (id: string) => invoke('check_provider', { id }), openSettings: () => invoke('open_settings'), showDetail: () => invoke('show_detail'), - setDetailBounds: (bounds: PhysicalBounds) => invoke('set_detail_bounds', { bounds }), + /** + * Coloca el detail y devuelve el informe de lo que quedó en pantalla (#215): + * el backend es la autoridad y responde con el read-back, no con la + * solicitud; la petición nunca se cachea como confirmada. Los tipos son el + * espejo del informe de la tira (`detail_placement.rs` re-exporta `Report`). + */ + setDetailBounds: (bounds: PhysicalBounds) => invoke('set_detail_bounds', { bounds }), /** * Coloca la tira y devuelve la geometría que quedó en pantalla (#179): el * backend es la autoridad y responde con el read-back, no con la solicitud.