diff --git a/docs/i18n-inventory.md b/docs/i18n-inventory.md index e7aad50..1890003 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/` = `216948ef36250838d3c024b117e0f99fd870d3d4` and `src-tauri/src/` = `c8f3801d92cdff92304b2258c4e8471b6fb2d5ca`. +Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `e59862776cb98564eb2fc5680c5ecb6991629266` and `src-tauri/src/` = `cda8f14546cb8d691b994ef70f9e1696811884bd`. 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`. @@ -18,9 +18,9 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a | Area | Distinct texts | Sites | |---|---:|---:| -| React (`src/`) | 842 | 985 | -| Rust (`src-tauri/src/`) | 348 | 422 | -| Total | 1155 | 1407 | +| React (`src/`) | 852 | 995 | +| Rust (`src-tauri/src/`) | 405 | 484 | +| Total | 1222 | 1479 | ## src-tauri/src/channel.rs @@ -98,7 +98,6 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - No hay archivo para el efecto propio de este aviso - No se pudo aplicar «siempre encima»: {detail} - Surface clip UI callback was dropped -- Widget bounds UI callback was dropped - Ya hay una apertura de terminal en curso para esta cuenta. Si no se abrió, inténtalo de nuevo. - {label} ({text}): combinación no válida ({e}) - {label} ({text}): en Wayland el registro se hace vía X11/XWayland y puede no tener efecto global; el atajo puede no responder. @@ -437,6 +436,70 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Clave desconocida: {key} +## src-tauri/src/widget_placement.rs + +- Invalid widget placement scale: {} +- Widget bounds UI callback was dropped +- the native window could not be read +- the native window origin could not be read + +## src-tauri/src/widget_placement_test.rs + +-
+- abs=1859,899 size=61x181 +- constraints 120x181 +- constraints 320x260 +- constraints {width}x{height} +- constraints {w}x{h} +- el backend no muestra la ventana: {:?} +- el borde {anchor:?} se conserva: {moves:?} +- el compositor sitúa la ventana donde se pidió:\n{tree} +- el error dice qué falla: {error} +- el permiso de resize queda cerrado +- el permiso de resize se retira +- el permiso dura lo que el resize: {:?} +- el permiso está cerrado +- el permiso se retira +- el read-back de GDK +- el tamaño del informe es el que quedó en pantalla +- el tamaño se concede exacto +- el tamaño sobrevive a la retirada del permiso +- la geometría es del read-back +- la posición se aplica +- la posición sobrevive a la retirada del permiso +- la posición usa el tamaño efectivo: {:?} +- la primera generación está caducada +- la segunda sigue vigente +- la ventana arranca con su tamaño por defecto +- la ventana es visible +- la ventana está visible +- label: {label} +- las pistas quedan en el tamaño real: {:?} +- no se mueve la ventana después de cancelar: {:?} +- permission closed +- permission open +- position 1680,899 +- position 1859,899 +- position 1859,980 +- position 1880,899 +- position {x},{y} +- position {x},{y} (sin confirmar) +- reads: {} +- scale {scale}: {physical} -> {logical} -> {back} +- sin fases nativas tras cancelar: {native_after:?} +- sin recuperación permanente +- size 0 +- size 61x181 +- size {width}x{height} +- size {width}x{height} (sin confirmar) +- sólo el tamaño pedido: {call} +- sólo la ventana del widget +- the native surface did not answer +- un factor no utilizable no puede colocarse +- un reintento y nada más: {:?} +- un solo resize con permiso: {:?} +- una colocación caducada no pisa las pistas de la sucesora: {:?} + ## src-tauri/src/window_clip.rs - Invalid surface clip dimensions @@ -1033,6 +1096,11 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - no shortcut - resets {when} - scale 0–{max} % +- the native geometry could not be read +- the native window is not visible yet +- the placement was replaced by another one +- the window manager did not grant the size +- the window manager did not place the strip where it was asked - with limits: - {count} resets available - {day}: {value} tokens @@ -1289,13 +1357,18 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - caché - cada {mins} min - con límites: +- el gestor no colocó la tira donde se pidió +- el gestor no concedió el tamaño - en {d} d - en {d} d {h} h - en {h} h - en {h} h {m} min - en {mins} min - escala 0–{max} % +- la colocación fue sustituida por otra +- la ventana nativa todavía no es visible - máx. {value} +- no se pudo leer la geometría nativa - reinicia {when} - saldo comunicado por Codex; no son reinicios automáticos - saldo comunicado por z.ai; cada reset vence y se usa manualmente diff --git a/odd/tasks/x11-native-widget-resize.md b/odd/tasks/x11-native-widget-resize.md new file mode 100644 index 0000000..349164c --- /dev/null +++ b/odd/tasks/x11-native-widget-resize.md @@ -0,0 +1,187 @@ +# x11-native-widget-resize + +Corrección de la geometría nativa del widget bajo Linux/X11 para que la tira visible alcance el borde del área de trabajo — issue #179; alcance aprobado por Quique. + +## Specs + +- S1: "El límite será el área de trabajo del monitor, respetando los paneles reservados. En tu medición es `y=30…1080`, no `0…1080`. No es un cambio de selección de monitor (#113) ni del imán al soltar." +- S2: "Corregir la superficie nativa, no compensar los 84 px con un offset. Permitir el resize programático en la ruta Linux necesaria, manteniendo intactos Windows/macOS. Evitar cambiar `resizable` globalmente en la config compartida. Verificar también que no se introduce redimensionado manual accidental por bordes o por el gestor de ventanas." +- S3: "Resolver el mínimo de ancho de forma explícita. `240×181` ya arregla el margen vertical, pero no coincide con `61×181`: el `minWidth:240` sigue dejando superficie sobrante. El PR debe justificar conservarla o ajustar la restricción en la ruta correspondiente, y comprobar los bordes horizontales además de los verticales." +- S4: "Resize → estabilización → posicionamiento con la geometría efectiva → read-back. No asumir que el retorno de `set_size` acredita el tamaño aplicado por GTK. El ciclo debe ser acotado, cancelable ante un arrastre/colocación posterior y sin bucles de recuperación permanentes. Si el gestor rechaza la geometría, informar la limitación en vez de cachear la solicitud como si se hubiera aplicado." +- S5: "No extender lecturas de posición absoluta a Wayland nativo. El contrato de esta corrección y su read-back de posición es X11/XWayland; #180 sigue separado." +- S6: "Para revisión: tests deterministas de orden y estabilización, timeout/rechazo y solicitudes obsoletas; evidencia KDE/X11 con tamaño solicitado/efectivo, área de trabajo y borde visible; imán y persistencia de `dockEdge`/`dockOffset` conservados. GNOME u otros gestores quedan sin acreditar hasta probarlos." + +## V2 design + +Rediseño mínimo desde `upstream/main@299d082`: no reutiliza la máquina de estados de V1 (+2150 líneas, rechazada por revisión). + +### Decisión + +**Opción 3**: en la ruta X11/XWayland la ventana nativa se fija con pistas iguales (`PMinSize == PMaxSize == tamaño de la tira`) y se redimensiona por programa. El resize manual es **imposible por construcción**: sin decoraciones no hay bordes que arrastrar y el gestor no puede ofrecer otro tamaño porque el mínimo y el máximo coinciden. + +### Evidencia medida (sondas de un solo uso, fuera del repositorio) + +Sondas GTK3 + **WebKit2 real** (hijo `WebKit2WebView`, no `GtkLabel`) en la sesión KDE/XWayland local (`DISPLAY=:0`, `GDK_BACKEND=x11`), con la misma ventana que `tauri.conf.json`: 320×260, pistas 240×60, `resizable:false`, `decorations:false`, visual RGBA. + +| Opción | Medición | Veredicto | +| --- | --- | --- | +| (1) redimensionar el cliente X11 con GTK `resizable:false` | `XResizeWindow(61,181)` → el gestor restaura la geometría: sigue en **320×260** (también tras 1 s) | **descartada** | +| (2) secuencia GTK de V1 (`set_resizable(TRUE)` → `set_default_size` → `resize` → `set_resizable(FALSE)`) | con hijo `WebKit2WebView` real el tamaño **se pierde**: 240×181 → **240×200**; el mismo paso con `GtkLabel` conserva 240×181 → la evidencia de V1 venía de una etiqueta, no del webview de la app | **descartada** | +| (3) `resizable:true` + `min==max` + sin decoraciones | `resize(61,181)` → **61×181 exactos**; `WM_NORMAL_HINTS`: `PMinSize=PMaxSize=61×181`; un `XResizeWindow(200,200)` externo y un `ConfigureRequest(200,200)` sintético quedan **clampados a 61×181** | **aceptada** | + +Comprobaciones adicionales medidas: el tamaño se aplica y **no deriva** (61×181 estable a +1,5 s y +3 s; el cambio 61×181 → 320×64 → 61×181 se aplica exacto, con las pistas actualizadas *antes* del resize) y la **posición** se aplica y se queda (1859,899). El backend real se distingue por el **tipo de display de GDK** (`GdkX11Display` bajo XWayland, `GdkWaylandDisplay` bajo Wayland nativo; mismo binario, dos backends), sin heurística de `(0,0)` ni de variables de entorno. El webview real no impone mínimo (la ventana llega a 1×1): el `minWidth:240` de la config era el único obstáculo. El gestor responde al resize en **3–5 ms** y al movimiento en **0–2 ms**: el presupuesto (`READS = 4` lecturas por fase, `SETTLE = 15 ms`) es 3–5× esa latencia. + +### Mecanismo + +1. **Validación** (antes de tocar la superficie nativa, en todos los transportes): sólo la ventana del widget puede colocarse, el rectángulo tiene que ser utilizable (no cero, acotado a 1 000 000 px y sin desbordamiento) y el **factor de escala** tiene que ser finito y positivo (con 0 la conversión a píxeles lógicos no tiene sentido: daría infinito y su recorte, un tamaño de 2³¹ píxeles). +2. **Transporte** (detectado una vez y cacheado, **en el hilo de UI**): tipo del display GDK real → `X11` (X11 o XWayland) u `Otro` (Wayland nativo). El resto de plataformas mantiene su camino actual. +3. **Secuencia de la colocación** (sólo X11, revisada en L24 y L25): una **sola llamada al hilo de UI** hace todo el tamaño: la petición de tamaño del widget + las pistas `min == max == tamaño pedido` + `set_resizable(true)` + `resize` + `set_resizable(false)`. El permiso de resize está abierto **sólo durante ese resize** (fuera de él, `tao` no ofrece cursor de redimensionar ni arranca un arrastre de borde) y la llamada lo **confirma** (`!is_resizable()`); si el hilo de UI no responde, el ciclo no sigue como si el resize se hubiera hecho. Después, el movimiento con el borde anclado, el read-back, el recolocado con el tamaño efectivo y el **read-back final** (el tamaño **y** la posición, ya con el permiso retirado: retirarlo es justo lo que hace que GTK recalcule la configuración de la ventana, así que el tamaño se verifica después, no antes). La ventana aparece ya colocada **si** el resize previo al mapeo llega al compositor: medido, se pierde en ~1 de cada 9 carreras ⇒ puede haber un parpadeo breve del tamaño por defecto (320×260). El ciclo lo acota reintentando el tamaño con la ventana ya visible (L20). **No se afirma ausencia de flash**: falta evidencia en pantalla. +4. **Visibilidad del frontend, no del backend**: el backend **nunca** mapea ni muestra la ventana (`show_all()` fuera del ciclo); si la superficie todavía no es visible, la colocación la espera (presupuesto de lecturas) y, si no llega a verse, informa `not_viewable` sin tocar la visibilidad. El frontend es el dueño de `getCurrentWindow().show()` y, si el backend no pudo leer la ventana todavía, **vuelve a pedir la colocación** después de mostrarla. +5. **Leer**: el read-back sale de GDK. La señal de visibilidad es **real**: `gdk_window_is_viewable()` (la ventana y sus ancestros están mapeados). La geometría es la del servidor X: el tamaño en píxeles físicos del **propio** monitor de la ventana (`gdk_window_get_geometry` con el factor de la ventana) y el origen en coordenadas **relativas a la raíz** (`gdk_window_get_origin`, cuyo **valor de retorno** decide si la lectura es utilizable: si la llamada falla, **no** hay origen ⇒ la lectura falla con `read_failed`; no se usa el respaldo de `gdk_window_get_geometry`, que es **relativo al padre** y mezclaría dos espacios de coordenadas). El resultado se cruzó con `XGetGeometry`/`XTranslateCoordinates` (L24). El **configure** no se usa como puerta de la lectura: el eco del servidor X de nuestra propia petición también lo produce (medido en L25), así que no distingue una cosa de la otra; lo que prueba el informe es que la geometría leída —la que el servidor X tiene ahora mismo— coincide con la pedida. En un movimiento entre monitores con **DPI mixto** los dos factores difieren (el de la petición es el del monitor de destino; el del read-back, el de la ventana): la colocación termina entonces como `limited`/`size_refused` en vez de informar un tamaño que no es el de la pantalla (L26). +6. **Reintento y límite**: si el compositor no concede el tamaño pedido, se le pide otra vez (una petición real al gestor; las pistas siguen siendo las **pedidas**, nunca el tamaño que el compositor improyó). La **posición** se recalcula con la geometría efectiva: el borde anclado (derecho/inferior) se conserva aunque el tamaño concedido sea otro, y se vuelve a leer el origen. Al **fallar**, las pistas se dejan en el tamaño que la ventana tiene de verdad (una lectura de respaldo): **no** se sueltan, porque medido, soltar la petición de tamaño hace que la ventana vuelva a su tamaño por defecto (L25). Al **cancelar** (una colocación posterior la sustituye) la colocación caducada **no** escribe las pistas: son de la que sigue vigente, y pisarlas dejaría la ventana sujeta a un tamaño viejo (L26). **Excepción explícita**: en el camino `limited` la petición de tamaño y las pistas `min == max` se quedan en el tamaño **pedido** aunque el compositor haya concedido otro ⇒ ahí la ventana **sí** queda sujeta a un tamaño que no tiene (soltarlas la haría crecer a 320×260). + +El ciclo se cancela por generación, y el cancelador se comprueba **antes de cada fase nativa** (incluido el camino `limited`): una colocación posterior o un arrastre invalidan la estabilización pendiente sin dejar la ventana movida a medias. La estabilización está acotada (`READS = 4` lecturas por fase, `SETTLE = 15 ms` entre lecturas) y no hay bucles de recuperación. + +### Contrato IPC + +`set_widget_bounds` recibe la **petición aplanada** `{ x, y, w, h, anchor, scale }` (el `anchor` es el borde anclado y `scale` el factor con el que se calculó el rectángulo; **ambos son obligatorios**: una petición sin `scale` no deserializa) y devuelve un informe `{ status, bounds, reason, read_back }`: + +- `applied`: la geometría efectiva se leyó del compositor y coincide con la pedida (tolerancia ±1 px físico por redondeo del gestor a píxeles de dispositivo). +- `unverified`: la solicitud se aplicó pero no se pudo leer (Wayland nativo, Windows, macOS): comportamiento actual, sin lectura de posición absoluta. +- `limited`: el gestor no concedió la geometría pedida; se informa el motivo y la geometría que sí quedó en pantalla. +- `superseded`: la cancelación (otra colocación posterior sustituyó a esta): su geometría quedó a medias y **no** se cachea como origen del arrastre. + +`read_back` dice si la geometría vino del compositor (X11): **sólo esa geometría admite la tolerancia de ±1 px** del redondeo; la que se pidió (Windows, macOS, Wayland nativo) se compara exacta (L24). + +`bounds` es `null` cuando no hay lectura fiable (`unverified` sin geometría, `superseded`). **Una lectura fallida no es un error del comando**: devuelve un informe con `status: unverified` y `reason: read_failed` (o `not_viewable` si la ventana no llegó a ser visible a tiempo), y el frontend lo trata como un fallo blando: no cachea geometría y muestra el motivo en el reintento. El frontend cachea **la geometría efectiva** que devuelve el informe (nunca la solicitud como verificada), muestra el motivo del backend en el estado accesible del reintento y conserva intactos el imán y la persistencia de `dockEdge`/`dockOffset`. + +### Desvíos respecto al diseño aprobado + +- **D1 (V2, reescrito en L24 y L25)**: el permiso de resize programático **no permanece activo**: se abre **sólo** alrededor del resize (dentro de la misma llamada nativa que lo pide y lo retira) y se retira antes de cualquier movimiento o lectura. Con el permiso cerrado, `tao` deja de ofrecer cursor de redimensionar y de arrancar un arrastre de borde. Lo que conserva el tamaño concedido es la **petición de tamaño** del widget (`gtk_widget_set_size_request`): medido, sin ella la ventana volvía a 320×260 al retirarlo el permiso. **Lo que el código garantiza exactamente**: (a) el permiso está abierto durante el resize y cerrado fuera de él —la misma llamada nativa lo abre y lo cierra, y la llamada lo **confirma**: si el hilo de UI no responde en el plazo, la colocación **no** se da por buena y se reintenta una vez—; (b) el tamaño concedido sobrevive a esa retirada mientras la petición de tamaño siga puesta; (c) el resize manual sigue siendo imposible por las pistas `min == max` (que el gestor respeta) y por la ausencia de decoraciones. **Lo que no se puede garantizar desde aquí**: que en la app real el ratón no muestre cursor de redimensionar ni arranque un arrastre de borde (la prueba en vivo usa un `gtk::Window` pelado, sin los *handlers* de `tao`): queda como acreditación humana (L16, L24). +- **D2 (V2)**: el mínimo de 240 px de `tauri.conf.json` se **sustituye en tiempo de ejecución** por el tamaño real de la tira en la ruta X11 (`set_geometry_hints` + `set_size_request`), sin tocar la config compartida ni `tauri.conf.json`. El mínimo de la config sigue aplicando en el resto de plataformas. + +### Presupuesto + +**Medido en el árbol de este ciclo** (`git diff --numstat a0e999b`, la base de la rama): **+2617 líneas** en total y **-29**; desglose: **+2352 de código y tests** (1274 tests, 1078 producción), más este artefacto ODD (+187) y el inventario i18n (+78/-5). Son las cifras del **árbol commiteado** que se publica; L26 registra el delta de la cuarta pasada de corrección y su verificación. + +Los presupuestos pedidos eran ≤900 y ≤550 (y ≤1100/≤600 en el pase intermedio): **el diff se queda por encima en las tres cifras**. El desborde corresponde a los hallazgos que las revisiones exigieron: validación reutilizable, cancelador antes de cada fase nativa y dentro del hilo de UI, permiso de resize abierto sólo alrededor del resize y **confirmado**, read-back final que vuelve a verificar el tamaño, señal de visibilidad real, pistas coherentes en todos los caminos de salida, anclaje con geometría efectiva, códigos de motivo, factor de escala en la petición, dedup con tolerancia **sólo del read-back**, no-Linux sin avisos, y sus tests (25 tests Rust deterministas + 1 prueba en vivo GTK+WebKit2 + 58 tests de frontend: 14 de contrato de colocación + 44 de render/orden y colocación). La abstracción se evaluó como pidió el judge: colapsar el trait `Surface` en un enum de fases ahorra ≈48 líneas (3 %) a costa de un `match` por fase y de un seam menos legible, y **no cierra el hueco** ⇒ no se aplicó. Se recortaron comentarios y Log sin perder evidencia ni trazabilidad. + +**El exceso queda sujeto a aceptación explícita del mantenedor**: la PR declara el desglose y ofrece partirla si el reviewer lo prefiere. Pases previos: V2 original +884/+474, pase 2 +1305/+659, pase 3 +1475/+739, pase de cierre +1525/+753, +1641/-60 sobre la base `7043a84`, +1705/-27 sobre `4e973d6`, +2097/-27 sobre `a0e999b` (tercera pasada), +2419/-28 (árbol que auditó la revisión ciega) y **+2617/-29** sobre `a0e999b` (árbol final que se publica). + +## Log + +- L1: **Historia rechazada (V1, +2150 líneas)**: `fix/x11-native-widget-resize` (commits `0112370`…`deb3870`) fue rechazada por revisión adversarial con ocho bloqueantes: pérdida de tamaño con webview real, llamadas GTK fuera del hilo de UI, clasificación del transporte por heurística, tests vacíos, carreras de lectura asíncrona, destello del tamaño por defecto y +2150 de diff. **V2 no reutiliza su máquina de estados**. +- L2: **Decisión de diseño**: sondas GTK3/WebKit2 reales sobre la sesión KDE/XWayland local; las tres opciones se midieron antes de escribir código (tabla de `## V2 design`). Se eligió la opción 3. +- L3: **T1–T4 (pase V2)**: contrato RED (`drive` inexistente → 4 de 8 tests en rojo) → backend → frontend (RED: `api.cancelWidgetPlacement is not a function`) → prueba en vivo con webview real. Evidencia app-faithful: ventana nativa 320×260 antes de la corrección; después 181×61/61×181 con `min == max` y el borde dockeado sobre el área de trabajo (`0,30,1920,1050`). +- L5: **Diferencial de transporte**: con el backend nativo de Wayland el transporte es `Other` → ruta antigua, sin permiso de resize ni pistas: **el comportamiento de Wayland nativo queda intacto**. +- L6: **Suites del pase V2**: `cargo test --locked` 299→314, `npm test` 693 vitest + 225 node, typecheck, clippy sin avisos nuevos, rustfmt limpio en los ficheros nuevos, rama no-Linux compilando. **Reconciliación**: `upstream/main` avanzó de `299d082` a `7043a84` (PR #182, z.ai) y la rama se adelantó por **fast-forward** (sin rebase ni conflictos: los ficheros de #182 no tocan los de esta corrección); PR #185 (rustfmt amplio) y PR #186 (herramienta del inventario) **no** se importan. +- L8: **Pase 2 (14 hallazgos)**: se corrigieron la validación del widget, el cancelador antes de cada fase nativa, el read-back tras mapear (GDK), el ancla en la petición, la lectura fallida, las pistas del camino limitado, el motivo al usuario, `superseded` y la conversión de escala. RED: 6 tests Rust y 4 frontend en rojo con las correcciones revertidas → 14/14 y 10/10 en verde. +- L9: **Investigación de las lecturas de GDK** (sonda con `GDK_SCALE=2`): `gdk_window_get_origin`, `gtk_window_get_position` y `gdk_window_get_geometry` devuelven **píxeles lógicos** (a escala 2 el origen era 400,280 lógico = 800,560 en X11) → la conversión a físico es `round(logical × escala)` y el error es de 1 px físico como mucho; `gdk_window_get_origin` devuelve falso si la ventana no es visible (el guardián del read-back). **Flake corregido**: en 1 de ~9 carreras el resize previo al mapeo se perdía y la ventana aparecía con su tamaño por defecto (320×260) → el ciclo reintenta con la ventana ya visible; medido después: 8/8 carreras `applied`. +- L11: **Inventario i18n**: no se toca a mano. El generador se ancla a los árboles de `src/` y `src-tauri/src/` **de un commit**, así que el commit local de la unidad va primero y `docs/i18n-inventory.md` se regenera después, en un commit aparte, con la política vigente de upstream. `node scripts/i18n-inventory.mjs --check` pasa sobre el commit de la unidad; con el formato estable de #186 (textos distintos por fichero, sin números de línea) el documento regenerado cambia en **+43 / -5**: las secciones nuevas de `widget_placement.rs` y `widget_placement_test.rs`, más los cinco textos `widget.reason*` de los catálogos. +- L12: **Pase 3 (esta pasada)**: los 9 hallazgos obligatorios de la revisión adversarial final y del verifier. + + | ID | Corrección | RED → GREEN | + | --- | --- | --- | + | JD-A-001 (MAJOR) | "todavía no visible" es un estado **reintentable** (`Ok(None)`), no un error: `read_until` asienta antes de cada lectura y sólo un fallo real es error; el nunca-visible se informa `not_viewable` | `a_window_that_is_not_visible_yet_is_retried_and_not_a_failure` (cubre el reintento y el caso nunca-visible) | + | JD-A-005 (MAJOR) | el backend devuelve **códigos** de motivo (`size_refused`, `position_refused`, `not_viewable`, `read_failed`, `superseded`); el frontend los traduce a etiquetas cortas en `src/lib/widget-placement.ts` | test de serialización (`"size_refused"`) + test de etiquetas del frontend | + | JD-A-002 (MINOR) | instrumentación `QUORB_DIAG` fuera de producción (`grep -rn QUORB_DIAG src-tauri/src/` → 0) | verificación directa | + | JD-A-003 (MINOR) | `TRANSPORT`, `transport()` y `detect_transport()` **sólo existen en Linux** (`#[cfg(target_os = "linux")]`): la rama no-Linux compila con 0 avisos en estos ficheros | compilación no-Linux (L20) | + | JD-A-004 (MINOR) | el cancelador se comprueba **antes de cada fase nativa también en el camino limitado** (`request_size` y `request_position`) | `the_limited_path_does_not_move_the_window_after_a_cancellation` | + | JD-A-006 (WARNING) | la **petición** lleva el **factor con el que se calculó** (`scale`) y el backend convierte la petición con ese factor (el del monitor de destino), no con `window.scale_factor()`; el **read-back**, en cambio, convierte con el factor de la **ventana**: en un movimiento entre monitores con DPI mixto los dos pueden diferir y la colocación termina conservadora como `limited`/`size_refused` en vez de informar un tamaño equivocado (L26) | `the_scale_conversion_uses_the_factor_the_request_was_computed_with` + test de IPC del frontend | + | JD-A-007 (WARNING) | el dedup compara con la **misma tolerancia del backend** (≤1 px físico) con `samePhysicalWithin`: un redondeo no re-emite la colocación y un cambio real (≥2 px) se sigue detectando (sin oscilación) | test de la tolerancia del frontend | + | JD-A-008 (WARNING) | **un solo dueño del mapeo**: el backend mapea sólo si la ventana no está mapeada (`map_if_needed`); el frontend sigue siendo dueño de la visibilidad de usuario (`getCurrentWindow().show()`, red de seguridad de 2,5 s) | prueba en vivo ajustada al mapeo condicional | + | JD-A-010 / JD-A-011 (SUGGESTION) | el comentario de `Widget.tsx` refleja el comportamiento real (en `superseded` la caché queda `null`) y el Log cita las constantes reales (`READS = 4`, `SETTLE = 15 ms`) | revisión del diff | +- L13: **Decisión de idioma (desviación explícita, revisada en L23)**: el backend devuelve **códigos ASCII** y el frontend los traduce con los **catálogos ES/EN** (`src/i18n/es.ts` + `src/i18n/en.ts`): la clave de cada código la exporta `src/lib/widget-placement.ts` (`reasonKey(code)`) y la traducción la hace el widget con `t(locale, key)`. La primera decisión de esta rama (etiquetas en **español literal** dentro del módulo de colocación, sin claves de catálogo) quedó **superada por el upstream**: la migración de la tira a los catálogos (#195) ocurrió después, así que el catálogo es ahora el sitio natural de estas etiquetas y el módulo de colocación no lleva texto de interfaz. `data-placement-reason` lleva el **código** (machine-readable) y el `title`/`aria-label` el texto localizado. **Rama no-Linux**: la verificación real (predicación neutralizada, L20) da 0 avisos en `widget_placement*`; el proyecto no usa `-D warnings` en CI. +- L15: **Limitación heredada (no se arregla aquí)**: en el camino `limited` la ventana puede cubrir con su superficie transparente una zona algo mayor de la que ocupa la tira. `window_clip::apply` es un **no-op en Linux** ⇒ es un comportamiento **preexistente** de la app (no introducido por V2) y queda fuera de esta PR; seguimiento: hacerlo en una línea propia cuando `window_clip` tenga efecto en Linux. +- L16: **Acreditaciones humanas pendientes (no resueltas por esta pasada)**: (1) el **gesto manual de resize** del gestor (probado es recorte del cliente, no el gesto); (2) el **imán y la persistencia** `dockEdge`/`dockOffset` en una sesión real; (3) **GNOME y otros gestores**; (4) la **escala HiDPI real** (la regla está medida con `GDK_SCALE=2` y probada por unidad, no en un monitor 1,5/2); (5) el **movimiento entre monitores con DPI mixto**: la petición se convierte con el factor del monitor de destino y el read-back con el de la ventana ⇒ la colocación acaba como `limited`/`size_refused` (no verificada), sin que esté medido cómo se comporta en ese caso (L26). +- L17: **Commits** (en `fix/x11-native-widget-resize-v2`, base `upstream/main@a0e999b`; **la rama no está subida al fork**: el PR #205 publicado lleva todavía el árbol anterior y el mantenedor lo actualiza en cuanto cierre esta pasada), en este orden: `fix(linux/x11): la tira alcanza el borde del área de trabajo (#179)` (backend, frontend, catálogos ES/EN y sus tests) · `docs(i18n): regenerar el inventario de textos (#179)` (inventario regenerado con el script de #186 sobre el árbol de la unidad) · `docs(odd): artefacto de la colocación nativa en Linux/X11 (#179)` (este archivo). Los shas no se citan aquí a propósito: el artefacto se corrige dentro de la misma rama, así que un sha impreso quedaría obsoleto; la verdad es `git log --oneline upstream/main..HEAD`. Los pases de corrección 2 (L24) y 3 (L25) se reparten en los **mismos tres commits**: el código en el primero (implementación), el inventario i18n en el segundo (**no** queda «sin cambios»: frente a `upstream/main` mueve `docs/i18n-inventory.md` en **+70/-5**, con las secciones nuevas de `widget_placement.rs` y `widget_placement_test.rs`, los cinco textos `widget.reason*` y una línea que sale de `lib.rs`) y este artefacto en el tercero. El pase 4 (L26) toca código y este artefacto y **no** toca ningún texto de interfaz ⇒ el inventario i18n no lo regenera. +- L18: **Evidencia app-faithful vigente** (una carrera por borde; área de trabajo `0, 30, 1920, 1050`): en los cuatro bordes la petición, el informe del backend y la geometría X11 coinciden y `WM_NORMAL_HINTS` = `min == max == tamaño de la tira`. + + | borde | petición | informe del backend | geometría X11 | WM_NORMAL_HINTS | + | --- | --- | --- | --- | --- | + | top | 870,30 181×61 | applied 870,30 181×61 | `181x61+870+30` | min=max=181×61 | + | bottom | 870,1019 181×61 | applied 870,1019 181×61 | `181x61+870+1019` | min=max=181×61 | + | left | 0,465 61×181 | applied 0,465 61×181 | `61x181+0+465` | min=max=61×181 | + | right | 1859,465 61×181 | applied 1859,465 61×181 | `61x181+1859+465` | min=max=61×181 | + + Obtención (por borde, con `dockOffset` 0,5 y la config temporal del probe): + `GDK_BACKEND=x11 DISPLAY=:0 XDG_CONFIG_HOME= cargo run --offline` y, en la misma + carrera, `xprop -id WM_NORMAL_HINTS` + la geometría con `XGetGeometry` + (sonda `xinspect` de un solo uso, fuera del repositorio). +- L21: **Pase de cierre y revisión final sobre el árbol commiteado**: un juez independiente revisó el árbol ya commiteado (`git diff upstream/main...HEAD`) y no encontró ningún defecto MAJOR ni CRITICAL en el código: los ocho hallazgos previos están cerrados, el radio de impacto se limita a Linux/X11 (`tauri.conf.json` intacto, Wayland nativo y Windows/macOS con su ruta anterior) y el inventario i18n cuadra con los árboles commiteados. Sus hallazgos fueron de documentación (cifras del presupuesto, lista de commits, una referencia a un test inexistente, el contrato IPC y una referencia colgante de Log) y **uno de código** (MINOR-1): con `not_viewable` el frontend cacheaba la **solicitud** como si fuera la geometría efectiva y además descartaba el motivo, contra su propio contrato. Corregido en `src/lib/widget-placement.ts` (`not_viewable` se trata como `read_failed`: no cachea nada y el motivo llega al reintento) con su test de frontend; de paso, la geometría cacheada ya no arrastra el ancla y el factor (dejaba de ser un `PhysicalBounds` disfrazado). +- L22: **Evidencia de máquina con la AppImage candidata** (`Quorb_0.7.0-x11-179.AppImage`, SHA-256 `421c826a…`; KDE Plasma 6.7.5, sesión Wayland con la app forzada a X11/XWayland, `DISPLAY=:0`, área de trabajo `_NET_WORKAREA = 0, 30, 1920, 1050`): una carrera por borde, con la **ventana real del webview** (origen absoluto con `XTranslateCoordinates`, tamaño con `XGetGeometry`) y las pistas con `xprop`: + + | borde | geometría X11 | `WM_NORMAL_HINTS` min == max | toca el borde | + | --- | --- | --- | --- | + | top | `181x61+870+30` | 181×61 | y = 30 ✔ | + | bottom | `181x61+870+1019` | 181×61 | y = 1019 = 30 + 1050 − 61 ✔ | + | left | `61x181+0+465` | 61×181 | x = 0 ✔ | + | right | `61x181+1859+465` | 61×181 | x = 1859 = 1920 − 61 ✔ | + + Reproducible con herramientas estándar, sin sonda propia: `xprop -root _NET_WORKAREA` para el área de trabajo, `xwininfo -id ` para la geometría y `xprop -id WM_NORMAL_HINTS` para las pistas. **Siguen pendientes las acreditaciones humanas** (L16): el gesto manual de resize del gestor, el imán y la persistencia en una sesión real, GNOME y otros gestores, la escala HiDPI real y la percepción del destello. +- L19: **Piso de GTK (sin cambio)**: la dependencia directa es `gtk = "0.18"` **sin features** (la misma versión que tauri y tao) ⇒ ya no activamos `v3_24` por nuestra cuenta. Rebuild limpio de `gtk`/`gtk-sys` (34,6 s) ⇒ no usamos ninguna API posterior a GTK 3.22 y el `Cargo.lock` no cambia de versión. El **piso efectivo del árbol no lo cambia esta PR**: `tauri-runtime`/`tauri-runtime-wry` (ya en `4e973d6`) declaran `gtk` con `v3_24` ⇒ el build exigía GTK ≥ 3.24 antes y lo exige ahora. `docs/build-linux.md` dice «GTK ≥ 3.22»: ya era inexacta en la base (por tauri) y **no se edita** aquí. +- L20: **Rama no-Linux (verificación real)**: el intercambio ingenuo de `cfg` no reproducía la rama no-Linux (`all()` deja los ítems *compilados*). La simulación fiel neutraliza la predicación (`target_os = "linux"` → `any()`, siempre falsa) en `widget_placement.rs`, `widget_placement_test.rs` y `lib.rs`: así `not(target_os = "linux")` queda activo, como en un build real de macOS/Windows, y aparecían avisos de código muerto de los ítems Linux-only (porque `mod widget_placement;` es incondicional). Arreglo: `#[cfg_attr(not(target_os = "linux"), allow(dead_code))]` en la declaración del módulo (1 línea) ⇒ **0 avisos en `widget_placement*`** con la simulación fiel y sin cambiar el comportamiento de Windows/macOS (el comando sigue usando `detail_geometry::apply_widget` y devolviendo `Unverified`). **No verificable offline**: el `target_os` real de macOS/Windows y los ítems sólo-macOS/Windows (p. ej. `windows_backdrop`), que en la simulación son falsos positivos o no aparecen. **Correcciones de la tabla y del "sin flash"**: la tabla de las sondas (L18) y la de la AppImage candidata (L22) miden lo mismo con dos métodos distintos (sonda GTK3/WebKit2 y `cargo run` vs AppImage construida); ambas coinciden con el área de trabajo `0,30,1920,1050` y el ancho de la fila `left` es 61 (como `right`); la ausencia de destello se sustituye por la medida (el resize previo al mapeo se pierde en ~1 de cada 9 carreras ⇒ **puede** haber un parpadeo breve, acotado por el reintento con la ventana ya visible) y no se afirma ausencia de flash (falta evidencia en pantalla). +- L23: **Reconciliación con el `upstream/main` nuevo (0.8.0)**. `upstream/main` avanzó de `7043a84` a `4e973d6` (release 0.8.0 #200, gate de rustfmt #185, inventario estable y contrato estructural de CI #186, y las migraciones a los catálogos ES/EN de la tira y el detalle #195, de Ajustes #199 y de Proveedores #204). La rama se reconcilió sobre esa base **sin cambios en el mecanismo nativo**: mismas pistas `min == max`, mismo read-back de GDK, mismo cancelador por generación, mismos códigos de motivo y el mismo informe. Lo que cambió por culpa del upstream: + - `src/Widget.tsx` venía ya migrado a los catálogos (`t(locale, 'widget.show')` / `t(locale, 'widget.retryShow')`): los hunks de colocación se reaplicaron **encima** de esa versión conservando su estilo; no se reintroduce español literal en el widget. + - **Decisión de idioma revisada (L13)**: los motivos pasan a ser claves de catálogo (`widget.reason*` en ES y EN) y `src/lib/widget-placement.ts` deja de llevar texto de interfaz; `data-placement-reason` lleva el código y el `title`/`aria-label` el texto localizado. + - `src-tauri/src/detail_geometry.rs` y `src-tauri/src/lib.rs` venían rustfmt-eados (#185): `Bounds::validate_widget` y los comandos `set_widget_bounds` / `cancel_widget_placement` se reaplicaron sobre el contenido nuevo y la rama pasa `cargo fmt --check` sobre todo el crate. + - `Cargo.toml` / `Cargo.lock`: upstream sólo sube la versión a 0.8.0; nuestros pines (`gtk = "0.18"` sin features, `webkit2gtk = "=2.0.2"` como dev-dependency, `tokio` con `time`) conviven sin conflicto y `cargo test --locked` resuelve con un único `gtk 0.18.2` y un único `webkit2gtk 2.0.2`, compartidos con tauri (`webkit2gtk` sigue siendo el que exige wry: `=2.0`). + - `docs/i18n-inventory.md` se toma **tal cual** de upstream (formato estable de #186) y se regenera después con `node scripts/i18n-inventory.mjs` en su propio commit, como manda ahora la política de upstream; `--check` pasa. + - Verificación tras la reconciliación: `cargo test --locked` 307→322 (0 fallos, 3 ignorados; el ignorado nuevo es la prueba en vivo), `GDK_BACKEND=x11 cargo test --locked widget_placement -- --ignored` en verde (1 prueba en vivo con webview real), `cargo clippy --locked --all-targets` con **19 avisos de lib y 18 de lib-test, los mismos que la base** (medidos sobre una copia limpia de `upstream/main@4e973d6`: mismo conjunto de ubicaciones, **sin avisos nuevos**), `cargo fmt --all -- --check` limpio, `npm test` 744 vitest + 240 node, `npm run typecheck` limpio y `node scripts/i18n-inventory.mjs --check` limpio. +- L24: **Pase de corrección 2 (los cinco puntos obligatorios del mantenedor + los cuatro opcionales)**. Segunda vuelta sobre el mismo PR (#205): el mantenedor revisó la candidata y pidió cinco cambios obligatorios (D1, el `configure` de `tao`, el mapeo en el ciclo, la señal de visibilidad y el alcance de la tolerancia) más cuatro opcionales. Todo se midió **antes** de decidir, con sondas GTK3 + **WebKit2 real** en la sesión local (`DISPLAY=:0`, `GDK_BACKEND=x11`). + + **La pregunta empírica (D1) y su respuesta**: ¿el tamaño concedido sobrevive a `set_resizable(false)`? **No, y con una consecuencia importante**: al retirar el permiso, GTK recalcula la configuración de la ventana con su tamaño natural y **la ventana volvía a 320×260** (y el gestor la recolocaba). Con `resizable: false`, ni `gtk_window_resize` ni `gdk_window_resize` hacen nada (medido). **La solución**: la **petición de tamaño del widget** (`gtk_widget_set_size_request`): al retirar el permiso, GTK recalcula la configuración con ese mínimo y el gestor lo respeta ⇒ con la petición puesta, el 61×181 concedido **se conserva** (tamaño y posición) y el permiso queda cerrado. Medidas: sin la petición, `61×181 → 320×260` y la posición se perdía; con la petición, `61×181` en `(1859,899)` sobrevive a la retirada; y la retirada **no** genera *configure* (el contador no se mueve), así que la retirada no se usa como señal de estabilidad. + + | punto | qué se cambió | RED → GREEN | + | --- | --- | --- | + | D1 (1) | el permiso de resize se abre **sólo** alrededor del resize (`fix_size`) y se **retira antes de informar** (`withdraw_resize`); la pista de tamaño (`set_size_request`) conserva el tamaño concedido | `the_resize_permission_is_only_active_around_the_resize` (RED: sin la retirada el permiso quedaba abierto) | + | (2) `tao` restablece `resizable` en el primer *configure* | el resize va **en una sola llamada al hilo de UI** (pista + permiso + `resize`), así que el *configure* de `tao` no puede colarse en medio; el read-back espera al *configure* que prueba que el gestor colocó la ventana | prueba en vivo: el tamaño se concede exacto con el permiso abierto y se conserva tras retirarlo | + | (3) `map_if_needed` → `show_all()` | **fuera del ciclo**: el backend nunca mapea ni muestra; si la superficie no es visible la colocación la espera (presupuesto de lecturas) y si no llega a verse informa `not_viewable`. El frontend es el dueño de `show()` y, si el backend no pudo leer la ventana todavía, **vuelve a pedir la colocación** después de mostrarla | `the_backend_never_shows_the_window` + `a_window_that_is_not_viewable_yet_is_retried_and_not_a_failure` | + | (4) la señal de «visible» | `gdk_window_is_viewable()` + el *configure* correspondiente (el valor de retorno de `gdk_window_get_origin` no se usa: en GDK3 es "not meaningful, ignore"); la geometría es la del servidor X (`gdk_window_get_geometry`) | el doble de los tests **cambia de contrato**: modela «la visibilidad la decide el frontend», no «sin lectura hasta mapear» | + | (5) la tolerancia de ±1 px | el informe lleva `read_back` y el frontend sólo usa la tolerancia con esa geometría; Windows/macOS (y cualquier ruta sin read-back) comparan **exacto** | `applies the tolerance only to the geometry that came from the X11 read-back` (RED: con la tolerancia siempre activa, 1 px se daba por igual en todas las plataformas) | + | opcional 1 | `on_ui` espera con **timeout** (2 s): una llamada nativa colgada no deja la colocación esperando para siempre | revisión del código + suite completa | + | opcional 2 | la **generación se comprueba dentro del cierre** de UI (entre la comprobación y el `move_` no cabe otra colocación) | `an_already_sized_surface_is_not_positioned_after_a_cancellation` + `a_second_generation_cancels_the_first_placement` | + | opcional 3 | tests de `drive` para los cuatro bordes y con **dos generaciones reales** | `each_anchor_keeps_its_edge_with_the_effective_size` + `a_second_generation_cancels_the_first_placement` | + | opcional 4 | el test "never caches the request as verified…" se renombra a "caches the requested geometry when the backend does not answer" (afirmaba lo contrario de lo que comprueba) | `src/lib/widget-placement.test.ts` | + + **Cruce de la geometría contra el compositor** (sonda `xgeom` de un solo uso, fuera del repositorio: `XGetWindowAttributes` + `XTranslateCoordinates`): el read-back de GDK y el compositor coinciden exactamente — `abs=1859,899 size=61x181` ↔ `gdk_geom=(1859,899,61,181)` — y la ventana real de la app (L18) da las mismas cuatro esquinas. `gdk_window_get_geometry` devuelve las coordenadas **relativas al padre** (la raíz bajo XWayland; el marco de la decoración si el gestor reparenta) y `gdk_window_get_origin` las **relativas a la raíz**: cuando las dos señales no coinciden manda la de la raíz, y el valor de retorno de `get_origin` (el "not meaningful" del GIR) no se usa para nada. + + **App real con la secuencia nueva** (app de depuración + `XDG_CONFIG_HOME` temporal, `GDK_BACKEND=x11 DISPLAY=:0`): la ventana del widget queda en `181×61+870+30` (top), `61×181+0+465` (left), `61×181+1859+465` (right) y `181×61+870+1019` (bottom) — **los mismos números que la tabla de L18/L22** — y `WM_NORMAL_HINTS` = `min == max == 181×61`: el gestor rechaza cualquier resize. + + **Verificación del ciclo**: `cargo test --locked --offline` 322→**327** (0 fallos, 3 ignorados; el ignorado nuevo es la prueba en vivo), `GDK_BACKEND=x11 cargo test --locked --offline widget_placement -- --ignored` en verde (1 prueba en vivo con webview real + el cruce con el compositor), `cargo clippy --locked --offline --all-targets` con **19 avisos de lib y 18 de lib-test, los mismos que la base** (ninguno en `widget_placement*`), `cargo fmt --all -- --check` limpio, `npm test` **746** vitest + 240 node, `npm run typecheck` limpio y `node scripts/i18n-inventory.mjs --check` limpio (no cambió ningún texto de interfaz ⇒ el inventario no se regenera). + + **Pendiente de acreditación humana (no se puede hacer aquí)**: (1) en la app **real**, pasar el ratón por los bordes de la tira **no debe mostrar cursor de redimensionar ni iniciar un arrastre** (la prueba en vivo usa un `gtk::Window` pelado, sin los *handlers* de `tao`); (2) el **gesto manual de resize** del gestor; (3) GNOME/XWayland: la caja oscura translúcida que Quique observó en #180, ahora que la ventana tiene el tamaño de la tira; (4) X11 **nativo** con un gestor que reparentice: ahí `gdk_window_get_geometry` da el origen respecto al marco (por eso el read-back prefiere las coordenadas de raíz de `gdk_window_get_origin`). + + **Nota de base**: durante este ciclo `upstream/main` avanzó de `4e973d6` a `a0e999b` (el proveedor OpenCode Go, #201). La rama se reconcilió **después** del ciclo: rebase sobre `a0e999b` (un único commit nuevo; el inventario, que es un documento generado, se regeneró sobre la base nueva) ⇒ las cifras de este Log se miden contra `4e973d6` y las del apartado *Presupuesto* contra `a0e999b`. La reconciliación no cambió el mecanismo nativo: mismas pistas `min == max`, misma size request, mismo read-back de GDK, mismo cancelador por generación, mismos códigos de motivo y el mismo informe. +- L25: **Pase de corrección 3 (los cuatro MAJOR de la revisión ciega + seis hallazgos más)**. Segunda vuelta sobre el mismo PR (#205): una revisión ciega del árbol comiteado (`a2a395d`) encontró cuatro defectos MAJOR y una segunda auditoría seis más. Todos se corrigen con TDD (RED con la mutación del defecto, GREEN con la corrección) y con **mediciones** donde la afirmación era dudosa. + + **MAJOR-1 — el read-back final no volvía a verificar el TAMAÑO.** El ciclo anterior tomaba el tamaño de una lectura **anterior** a la retirada del permiso, y la retirada es justo lo que hace que GTK recalcule la configuración de la ventana: el informe podía decir `applied` con un tamaño que ya había cambiado. Ahora el permiso se retira **dentro** de la llamada de tamaño, la lectura intermedia es ya posterior a la retirada y el **read-back final lee el tamaño y la posición** (una lectura nueva, siempre) ⇒ el informe dice lo que quedó en pantalla. *RED*: con el informe usando el tamaño previo, `the_final_read_back_verifies_the_size_after_the_withdrawal` falla (`Applied` en vez de `Limited`). + **MAJOR-2 — el permiso de resize no estaba abierto sólo alrededor del resize.** Ahora **una sola llamada al hilo de UI** hace la pista de tamaño + las pistas `min == max` + `set_resizable(true)` + `resize` + `set_resizable(false)`: el permiso existe durante esa llamada y nada más (el movimiento y las lecturas van después, con el permiso cerrado). Además, la retirada ya no es un paso aparte del ciclo: al abrir y cerrar el permiso en la misma llamada, **es imposible que quede abierto** aunque el hilo de UI no responda (o no se abrió, o se abrió y se cerró). *RED*: el doble modela la ventana de permiso y el test `the_resize_permission_is_open_only_while_the_programmatic_resize_runs` falla si el permiso vive más de esa llamada. + **MAJOR-3 — la retirada se podía perder en silencio.** `on_ui` descarta el resultado (su *timeout* de 2 s devolvía `None` y la llamada se perdía). Ahora `request_size`/`request_position` **devuelven si la llamada se pudo confirmar** (`request_size` confirma además que el permiso quedó cerrado: `!is_resizable()`), el ciclo **reintenta una vez** con presupuesto acotado y, si tampoco, informa `unverified` + `read_failed` en vez de fingir que salió bien. *RED*: con la llamada sin confirmar tratada como éxito, `a_native_call_that_cannot_be_confirmed_is_not_a_success` falla (`Applied` en vez de `Unverified`) y `a_native_call_is_retried_within_a_bounded_budget` falla (un reintento y nada más). + **MAJOR-4 — la puerta del *configure* no distinguía el eco de nuestra propia petición.** Medido (sonda GTK3 + WebKit2 real): el *resize* genera 2 *configure* y **la retirada del permiso no genera ninguno** (el contador no se mueve: 4 → 6 → 6) ⇒ la retirada no cambia el tamaño **cuando la petición de tamaño está puesta** (por eso no hay *ConfigureNotify*: no hay cambio de tamaño ⇒ no hay nada que notificar). Lo que sí es cierto es que el eco del servidor X de nuestra propia `XResizeWindow` **también** produce un *configure*, así que el contador no distingue «el gestor colocó la ventana» de «nosotros pedimos un cambio». Por eso **el *configure* deja de ser puerta de la lectura**: la señal es `is_viewable()` y lo que prueba el informe es que la geometría leída —la que el servidor X tiene ahora mismo— coincide con la pedida. Es lo que se afirma en el artefacto (nada más). + + **Hallazgos adicionales (misma pasada).** (1) **El origen**: `gdk_window_get_origin` devuelve coordenadas relativas a la raíz y su **valor de retorno** dice si la llamada tuvo éxito ⇒ se usa ese retorno (la cuarta pasada, L26, quita el respaldo al origen relativo al padre de `gdk_window_get_geometry`: si la llamada a la raíz falla, la lectura **no** es utilizable) y la preferencia se dice **una vez**, en el código y aquí. (2) **El motivo equivocado**: la lectura real devolvía `None` cuando el tamaño no cuadraba, así que una ventana visible con el tamaño rechazado se informaba como `not_viewable`; ahora la lectura devuelve la geometría en cuanto la ventana es visible ⇒ el motivo correcto es `size_refused`, y el doble de los tests lo modela (`a_visible_window_whose_size_never_matches_is_a_size_problem_not_a_visibility_one`). (3) **La sujeción a un tamaño viejo**: al fallar, las pistas se dejan en el tamaño que la ventana tiene de verdad (una lectura de respaldo) ⇒ la ventana no queda sujeta a un tamaño que ya no tiene. **No se sueltan**: medido, soltar la petición de tamaño y las pistas hace que la ventana **crezca a 320×260** (el tamaño por defecto) ⇒ soltarlas sería justo el «caja translúcida más grande que la tira». **Excepción (L26)**: en el camino `limited` la solicitud de tamaño y las pistas `min == max` se quedan en el tamaño **pedido** aunque el compositor haya concedido otro ⇒ ahí la ventana **sí** queda sujeta a un tamaño que no tiene (por eso no se sueltan). (4) **La contradicción del artefacto** (la retirada «no» genera *configure* frente a «sin la petición, 61×181 → 320×260») se resuelve: son dos escenarios distintos y ahora están medidos por separado (L25). (5) **El frontend**: `shown` no se reiniciaba nunca, así que la colocación sólo se volvía a pedir en la primera visibilidad; ahora el backend avisa (`widget-hidden` / `widget-shown`) y el widget distingue «el usuario la ocultó» (no se le muestra por nuestra cuenta ni se le repregunta) de «la ventana no se pudo leer» (se muestra y se vuelve a pedir la colocación). (6) **La tolerancia de ±1 px**: se mantiene también en la posición, y la justificación queda escrita: el tamaño la necesita porque el gestor redondea el tamaño lógico a píxeles de dispositivo, y **la posición también** porque la petición pasa por la conversión de escala (píxeles lógicos ⇄ físicos), que introduce hasta 1 px físico; el test del frontend cubre las dos partes. + + **Mediciones de este pase** (sondas GTK3 + **WebKit2 real**, `DISPLAY=:0`, `GDK_BACKEND=x11`): el orden nuevo **resize → retirada → move** conserva el tamaño (61×181 en (1859,899) tras la retirada ✓) y el movimiento posterior se aplica ✓; la retirada **no** cambia el tamaño con la petición puesta ✓ y **no** genera *configure* ✓; soltar las pistas hace crecer la ventana a 320×260 ✓ (motivo de no soltarlas). + + **App real con la secuencia nueva** (app de depuración + `XDG_CONFIG_HOME` temporal, sesión D-Bus privada): las cuatro esquinas coinciden con la tabla de L18/L22 — top `181×61+870+30`, left `61×181+0+465`, right `61×181+1859+465`, bottom `181×61+870+1019` — y `WM_NORMAL_HINTS` = `min == max == 181×61` ✓. + + **Verificación del pase**: `cargo test --locked --offline` 327→**351** (0 fallos, 3 ignorados; el módulo aporta **25** tests deterministas + 1 en vivo), `GDK_BACKEND=x11 cargo test --locked --offline widget_placement -- --ignored` en verde (con el cruce con el compositor: `abs=1859,899 size=61x181` ✓), `cargo clippy --locked --offline --all-targets` con **19 avisos de lib y 18 de lib-test, los mismos que la base** (ninguno en `widget_placement*`), `cargo fmt --all -- --check` limpio, `npm test` **749** vitest (59 ficheros) + 240 node (el módulo de colocación: 14 + 44), `npm run typecheck` limpio, `node scripts/i18n-inventory.mjs --check` limpio (este ciclo no añade texto de interfaz) y `git diff --check` limpio. + + **Precisión sobre la premisa del mantenedor (punto 2).** Su preocupación —que el *configure* de `tao` puede volver a tocar el permiso de resize y cambiar el tamañoeffective— es correcta, pero el mecanismo es otro: el handler de un solo uso de `tao` restaura el valor de **creación** de la ventana, y la del widget nace con `resizable:false` (`tauri.conf.json` → `.resizable(config.resizable)`), así que restaura `false`: **no puede reabrir el permiso**. La consecuencia real de ese evento es la misma que ya se corrige aquí: un `set_resizable(false)` que hace recalcular la configuración de la ventana y puede cambiar el tamaño ⇒ por eso el tamaño se vuelve a verificar **después** de la retirada (MAJOR-1). La ventana además se crea con `visible:false`, así que ese primer *configure* puede llegar antes, durante o después del ciclo; la única garantía que damos es la del read-back final. +- L26: **Pase de corrección 4 (los seis hallazgos de la auditoría ciega del árbol commiteado)**. Cuarta vuelta sobre el mismo PR (#205): una auditoría ciega del árbol commiteado (`e76280b`) encontró seis hallazgos (cuatro de código y dos de documentación). Todos se corrigen con TDD (RED con la mutación del defecto, GREEN con la corrección) y sin tocar la semántica del mecanismo: ni el transporte, ni los códigos de motivo, ni la forma del informe, ni el alcance de la tolerancia, ni las rutas de Wayland/Windows/macOS. + + | ID | Corrección | RED → GREEN | + | --- | --- | --- | + | C-1 (código) | `set_size_constraints` **comprueba la generación** como ya lo hacían el tamaño y la posición (dentro del hilo de UI), y `release_size` sólo suelta el tamaño si la colocación sigue vigente: una colocación caducada ya no pisa la petición de tamaño ni las pistas `min == max` de la que la sustituyó | `a_superseded_placement_cannot_overwrite_the_successor_constraints` | + | C-2 (código) | la red de seguridad de 2,5 s del frontend respeta `hiddenByUser`: una tira oculta desde la bandeja no reaparece | `never shows a strip the user hid from the tray, not even through the safety net` (+ `shows the strip through the safety net when the user did not hide it`, que fija el comportamiento anterior) | + | C-3 (código) | el respaldo del origen desaparece: `gdk_window_get_origin` es **relativo a la raíz** y `gdk_window_get_geometry` **relativo al padre** ⇒ si la llamada a la raíz falla, la lectura **no** es utilizable (`read_failed`), nunca un origen de otro espacio de coordenadas | `the_origin_comes_from_the_root_or_there_is_no_reading_at_all` | + | C-4 (código) | `Placement.scale` se valida: un factor no finito o no positivo se rechaza con un error claro **antes de cualquier llamada nativa** (con 0, `to_logical` daría infinito y su recorte, un tamaño de 2³¹ píxeles) | `a_non_positive_or_non_finite_scale_is_rejected_before_any_native_call` | + | C-5 (documentación) | el encabezado de `widget_placement.rs` describía el diseño anterior (la retirada del permiso **fuera** del ciclo, la puerta del *configure* y el valor de retorno del origen como «no significa nada»): reescrito para que coincida con el código | revisión del diff | + | C-6 (documentación) | este artefacto dice lo que es cierto: cifras del presupuesto, «3 ignorados», la excepción del camino `limited`, el alcance real de la afirmación de DPI mixto (sólo la **petición**) y el estado de la rama (**no** subida) | revisión del diff | + + **Comprobaciones de no-regresión**: el camino `failed` sigue dejando las pistas en el tamaño que la ventana tiene de verdad (`a_failed_placement_leaves_the_window_in_its_real_size`) y el camino `limited` sigue con las pistas **pedidas** (`the_limited_path_keeps_the_requested_size_and_never_another_one`); el permiso de resize sigue viviendo sólo alrededor del resize (`the_resize_permission_is_open_only_while_the_programmatic_resize_runs`); el motivo de una lectura fallida no cambia (`a_failed_read_is_not_reported_as_a_zero_sized_geometry`). + + **Verificación del pase**: `cargo test --locked --offline` 351→**354** (0 fallos, 3 ignorados; el módulo aporta **28** tests deterministas + 1 en vivo), `GDK_BACKEND=x11 cargo test --locked --offline widget_placement -- --ignored` en verde (1 prueba en vivo), `cargo clippy --locked --offline --all-targets` con **19 avisos de lib y 18 de lib-test, los mismos que la base** (ninguno en `widget_placement*`), `cargo fmt --all -- --check` limpio, `npm test` **751** vitest (59 ficheros) + 240 node, `npm run typecheck` limpio, `node scripts/i18n-inventory.mjs --check` limpio (este ciclo no cambia ningún texto de interfaz) y `git diff --check` limpio. + + **Presupuesto del pase**: +253/-64 sobre el árbol commiteado (`e76280b`) ⇒ **+2617/-29** en total sobre `a0e999b` (código y tests: +2352 = 1274 tests + 1078 producción; este artefacto +187; inventario i18n +78/-5). diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7614ec1..1ff8197 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3153,6 +3153,7 @@ dependencies = [ "chrono", "dirs 6.0.0", "futures", + "gtk", "keyring", "libc", "reqwest 0.12.28", @@ -3170,6 +3171,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tokio", + "webkit2gtk", "windows", "windows-core", "windows-numerics", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 36ac81f..e7a211b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,7 +28,7 @@ tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -tokio = { version = "1", default-features = false, features = ["rt"] } +tokio = { version = "1", default-features = false, features = ["rt", "time"] } chrono = { version = "0.4.35", features = ["serde"] } futures = "0.3" dirs = "6" @@ -65,6 +65,16 @@ windows-sys = { version = "0.61", features = [ [target.'cfg(unix)'.dependencies] libc = "0.2" +# Solo Linux: la superficie GTK del ciclo de colocación nativa (#179). La misma +# dependencia que ya resuelven tauri y tao (gtk 0.18 sin features): no sube el +# piso de GTK (3.22) ni añade crates nuevos. +[target.'cfg(target_os = "linux")'.dependencies] +gtk = "0.18" + +# Solo Linux: la prueba en vivo del ciclo (#179) con un webview real. +[target.'cfg(target_os = "linux")'.dev-dependencies] +webkit2gtk = { version = "=2.0.2", features = ["v2_38"] } + [profile.release] codegen-units = 1 lto = true diff --git a/src-tauri/src/detail_geometry.rs b/src-tauri/src/detail_geometry.rs index aa5f3df..ee9b1dc 100644 --- a/src-tauri/src/detail_geometry.rs +++ b/src-tauri/src/detail_geometry.rs @@ -1,9 +1,9 @@ //! Update the popup rectangle in one native transaction on Windows. //! Moving before resizing can briefly put the old, taller popup over the widget. -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tauri::WebviewWindow; -#[derive(Clone, Copy, Debug, Deserialize)] +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct Bounds { pub x: i32, pub y: i32, @@ -16,6 +16,13 @@ impl Bounds { self.validate_for(label, "detail") } + /// Valida que quien llama sea la ventana del widget y que el rectángulo sea + /// utilizable (no cero, acotado y sin desbordamiento). Reutilizable desde + /// cualquier ruta de colocación, antes de tocar la superficie nativa. + pub fn validate_widget(self, label: &str) -> Result { + self.validate_for(label, "widget") + } + fn validate_for(self, label: &str, expected: &str) -> Result { if label != expected { return Err("Only the calling usage window can update its own bounds".into()); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b1525b..8905ced 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,12 @@ mod model; mod providers; mod secrets; 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 widget_placement; +#[cfg(test)] +mod widget_placement_test; mod window_clip; #[cfg(target_os = "windows")] mod windows_backdrop; @@ -81,21 +87,22 @@ async fn set_detail_bounds( .ok_or_else(|| "Detail bounds UI callback was dropped".to_string())? } +/// Coloca la tira en la superficie nativa y devuelve la geometría que quedó en +/// pantalla. En X11 se lee de vuelta; en el resto, la solicitud, como antes. #[tauri::command] async fn set_widget_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_widget(&ui_window, bounds)); - }) - .map_err(|e| e.to_string())?; - rx.recv() - .await - .ok_or_else(|| "Widget bounds UI callback was dropped".to_string())? + bounds: widget_placement::Placement, +) -> Result { + // La generación se reserva al llegar la petición: otra posterior la cancela. + let generation = widget_placement::next_generation(); + widget_placement::place(&window, bounds, generation).await +} + +/// Cancela la colocación pendiente: el usuario empezó a arrastrar la tira. +#[tauri::command] +async fn cancel_widget_placement() { + widget_placement::cancel(); } /// Atajos registrados → acción, y errores y avisos del último registro @@ -901,6 +908,11 @@ fn toggle_widget(app: &AppHandle) { let _ = app.emit_to(WIDGET, "widget-hidden", ()); } else { let _ = w.show(); + // La ventana vuelve a ser visible: el widget reintenta la + // colocación (la anterior pudo quedarse sin leer mientras + // estaba oculta) (#179). Si la geometría cacheada ya coincide, + // su deduplicación no vuelve a pedirla. + let _ = app.emit_to(WIDGET, "widget-shown", ()); } } } @@ -1127,6 +1139,7 @@ pub fn run() { show_detail, set_detail_bounds, set_widget_bounds, + cancel_widget_placement, set_surface_clip, set_tray_tooltip, get_history, diff --git a/src-tauri/src/widget_placement.rs b/src-tauri/src/widget_placement.rs new file mode 100644 index 0000000..3420166 --- /dev/null +++ b/src-tauri/src/widget_placement.rs @@ -0,0 +1,771 @@ +//! Colocación nativa del widget en Linux/X11 (#179). +//! +//! La ventana del widget nace con `resizable: false`; bajo X11 el gestor +//! descarta cualquier resize programático (pistas fijas min=max=320×260) y la +//! tira nunca alcanza el borde del área de trabajo. Este módulo abre el +//! permiso de resize **sólo alrededor del resize programático** y fija la +//! ventana con pistas iguales (`min == max == tamaño de la tira`): el gestor no +//! puede ofrecer otro tamaño y, con el permiso retirado, GTK no muestra cursor +//! de redimensionar ni acepta un arrastre de borde. +//! +//! Lo que conserva el tamaño cuando el permiso se retira es la **petición de +//! tamaño** del widget (`gtk_widget_set_size_request`): al retirar el +//! permiso, GTK recalcula la configuración de la ventana con el tamaño +//! mínimo pedido y el gestor lo respeta (medido: 61×181 sobrevive; sin la +//! petición, la ventana volvía a 320×260). +//! +//! El ciclo es: pedir tamaño → pedir posición → leer lo que concedió el +//! gestor → recolocar con la geometría efectiva → leer de vuelta (el tamaño +//! **y** la posición). El permiso de resize se retira **dentro** de la llamada +//! de tamaño, así que desde el read-back ya nunca está abierto. La +//! visibilidad es del frontend: el backend nunca muestra la ventana. Los +//! read-backs salen de GDK: la señal de visibilidad es +//! `gdk_window_is_viewable()` y el origen sale de `gdk_window_get_origin` +//! (relativo a la raíz): si esa llamada falla, la lectura no es utilizable. + +use serde::{Deserialize, Serialize}; +use std::future::Future; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use crate::detail_geometry::Bounds; + +/// Tolerancia al comparar la geometría efectiva con la pedida: el gestor +/// redondea el tamaño lógico a píxeles de dispositivo enteros. +const TOLERANCE: i32 = 1; +/// Lecturas permitidas por fase mientras el gestor se estabiliza. +pub const READS: usize = 4; +/// Espera de estabilización entre lecturas. +pub const SETTLE: Duration = Duration::from_millis(15); +/// 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; +/// Espera máxima de una llamada al hilo de UI: una colocación no puede +/// quedarse esperando para siempre. +const UI_TIMEOUT: Duration = Duration::from_secs(2); + +/// Cómo respondió la superficie nativa a una colocación. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Status { + /// La geometría efectiva se leyó del compositor y coincide con la pedida. + Applied, + /// La solicitud se aplicó pero no se pudo leer (plataformas sin read-back). + Unverified, + /// El gestor no concedió la geometría pedida. + Limited, + /// La colocación se canceló: otra posterior la sustituyó. + Superseded, +} + +/// Motivo por el que una colocación no pudo verificarse: un **código**, no +/// texto de interfaz (la etiqueta la pone el frontend). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Reason { + /// El gestor no concedió el tamaño pedido. + SizeRefused, + /// El gestor no colocó la tira donde se pidió. + PositionRefused, + /// La ventana todavía no era visible: no hay read-back del compositor. + NotViewable, + /// La lectura nativa falló. + ReadFailed, + /// La colocación fue sustituida por otra más reciente. + Superseded, +} + +/// Resultado de una colocación: la geometría que quedó en pantalla. +#[derive(Clone, Debug, Serialize)] +pub struct Report { + pub status: Status, + /// La geometría efectiva leída; `None` si no se pudo leer o se canceló. + pub bounds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// True cuando la geometría viene del read-back del compositor (X11): + /// sólo esa geometría admite la tolerancia de ±1 px del redondeo; la + /// pidió (Windows, macOS, Wayland nativo) se compara exacta. + #[serde(default)] + pub read_back: bool, +} + +/// Borde del área de trabajo al que la tira está anclada. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Anchor { + Top, + Bottom, + Left, + Right, +} + +/// Lo que pide el frontend: el rectángulo, el borde anclado y el factor con el +/// que se calculó (en DPI mixto, el de la ventana puede ser otro). +#[derive(Clone, Copy, Debug, Deserialize)] +pub struct Placement { + #[serde(flatten)] + pub bounds: Bounds, + pub anchor: Anchor, + /// El factor de escala con el que se calculó el rectángulo. + pub scale: f64, +} + +impl Placement { + /// Valida la petición **antes de tocar la superficie nativa**: el + /// rectángulo tiene que ser utilizable y el factor de escala tiene que ser + /// finito y positivo (con 0 la conversión a píxeles lógicos no tiene + /// sentido: `to_logical` daría infinito y su recorte sería un tamaño de + /// 2³¹ píxeles). + pub fn validate(&self, label: &str) -> Result { + let bounds = self.bounds.validate_widget(label)?; + if !self.scale.is_finite() || self.scale <= 0.0 { + return Err(format!("Invalid widget placement scale: {}", self.scale)); + } + Ok(bounds) + } +} + +/// La geometría efectiva que se leyó del compositor (píxeles físicos). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Geometry { + /// Origen absoluto del cliente. + pub origin: (i32, i32), + /// Tamaño del cliente. + pub size: (i32, i32), +} + +static GENERATION: AtomicU64 = AtomicU64::new(0); + +/// Las operaciones nativas que el ciclo necesita: la implementación real salta +/// al hilo de UI y el doble de los tests responde en el acto. +pub trait Surface { + /// Pide el tamaño fijo: la petición de tamaño del widget, las pistas + /// `min == max` y el resize. El permiso de resize se abre **sólo** para el + /// resize programático y se retira en la misma llamada nativa: fuera de + /// ella no hay cursor de redimensionar ni arrastre de borde. + /// + /// `false` = la llamada nativa 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; + /// Fija las pistas de tamaño de la ventana (la petición de tamaño del + /// widget y las pistas `min == max`) **sin** redimensionarla: la deja + /// sujeta a ese tamaño. En el camino `limited` ése es el **pedido**, no el + /// que el compositor concedió: soltar las pistas haría crecer la ventana + /// a su tamaño por defecto. + fn set_size_constraints( + &mut self, + width: i32, + height: i32, + ) -> impl Future + Send; + /// Lee la geometría del compositor. `None` si todavía no es visible (es + /// reintentable); `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; +} + +/// Transporte de la superficie nativa: X11/XWayland o native Wayland. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Transport { + /// X11 o XWayland: la ventana es un cliente X11 con posición absoluta. + X11, + /// Native Wayland: sin posición absoluta ni pistas de tamaño del gestor. + Other, +} + +/// Clasifica el tipo del display de GDK: X11 y XWayland comparten `GdkX11Display`. +#[cfg(any(target_os = "linux", test))] +pub fn transport_from_type_name(name: &str) -> Transport { + if name.contains("X11") { + Transport::X11 + } else { + Transport::Other + } +} + +/// Píxeles lógicos que pide GTK para un tamaño físico (GTK mide en lógicos). +pub fn to_logical(physical: i32, scale: f64) -> i32 { + (physical as f64 / scale).round() as i32 +} + +/// El origen que se informa: el de la raíz (`gdk_window_get_origin`). +/// +/// `None` cuando la llamada no pudo responder: el único respaldo disponible +/// (`gdk_window_get_geometry`) es **relativo al padre** —el marco de la +/// decoración si el gestor reparenta la ventana— y devolverlo mezclaría dos +/// espacios de coordenadas: la lectura no es utilizable. +pub fn rooted_origin(x: i32, y: i32, ok: bool) -> Option<(i32, i32)> { + if ok { + Some((x, y)) + } else { + None + } +} + +/// Píxeles físicos que representa un tamaño lógico de GTK. +pub fn to_physical(logical: i32, scale: f64) -> i32 { + (logical as f64 * scale).round() as i32 +} + +/// True cuando el tamaño concedido coincide con el pedido. +pub fn size_matches(requested: (i32, i32), granted: (i32, i32)) -> bool { + matches_pair(requested, granted) +} + +/// True cuando la posición concedida coincide con la pedida. +pub fn position_matches(requested: (i32, i32), granted: (i32, i32)) -> bool { + matches_pair(requested, granted) +} + +fn matches_pair(requested: (i32, i32), granted: (i32, i32)) -> bool { + (requested.0 - granted.0).abs() <= TOLERANCE && (requested.1 - granted.1).abs() <= TOLERANCE +} + +/// La posición que conserva el borde anclado con el tamaño efectivo: si el +/// gestor concede otra altura, el borde derecho o inferior no se despega. +pub fn anchored_position(placement: &Placement, size: (i32, i32)) -> (i32, i32) { + match placement.anchor { + Anchor::Top | Anchor::Left => (placement.bounds.x, placement.bounds.y), + Anchor::Bottom => ( + placement.bounds.x, + placement.bounds.y + placement.bounds.h as i32 - size.1, + ), + Anchor::Right => ( + placement.bounds.x + placement.bounds.w as i32 - size.0, + placement.bounds.y, + ), + } +} + +/// Corre el ciclo: tamaño (con el permiso de resize abierto **sólo** durante +/// el resize) → posición → read-back → recolocar → read-back final (el tamaño +/// **y** la posición). Las lecturas están acotadas y una colocación posterior +/// detiene el ciclo. +pub async fn drive( + surface: &mut S, + placement: &Placement, + mut superseded: impl FnMut() -> bool, +) -> Report { + let wanted = (placement.bounds.w as i32, placement.bounds.h as i32); + // El tamaño se pide primero: si la ventana todavía no está visible, el + // gestor la coloca así al mapearla (sin el destello del tamaño por + // defecto). El cancelador se comprueba **antes** de cada fase nativa: una + // colocación posterior (o un arrastre) no debe dejar la ventana movida a + // medias. + 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); + } + let wanted_position = anchored_position(placement, wanted); + if superseded() { + release_size(surface, &mut superseded).await; + return superseded_report(); + } + if !request_position_checked(surface, wanted_position).await { + release_size(surface, &mut superseded).await; + return failed_report(Reason::ReadFailed); + } + + // El read-back sólo vale con la ventana visible y colocada por el + // gestor: es lo que prueba que el compositor concedió la geometría. + let mut effective = match read_until(surface, wanted).await { + Ok(effective) => effective, + Err(reason) => { + release_size(surface, &mut superseded).await; + return failed_report(reason); + } + }; + if !size_matches(wanted, effective.size) { + // El compositor todavía no ha concedido el tamaño pedido: se le pide + // otra vez (las pistas siguen siendo las **pedidas**: nunca se + // sustituyen por un tamaño que el usuario no pidió). + if superseded() { + release_size(surface, &mut superseded).await; + return superseded_report(); + } + if !request_size_checked(surface, wanted).await { + release_size(surface, &mut superseded).await; + return failed_report(Reason::ReadFailed); + } + effective = match read_until(surface, wanted).await { + Ok(effective) => effective, + Err(reason) => { + release_size(surface, &mut superseded).await; + return failed_report(reason); + } + }; + } + let target = anchored_position(placement, effective.size); + if superseded() { + release_size(surface, &mut superseded).await; + return superseded_report(); + } + if !request_position_checked(surface, target).await { + release_size(surface, &mut superseded).await; + return failed_report(Reason::ReadFailed); + } + + // La lectura final: el tamaño **y** la posición, ya con el permiso de + // resize retirado. Retirarlo es justo lo que hace que GTK recalcule la + // configuración de la ventana, así que el tamaño se verifica aquí y no + // antes: el informe dice lo que quedó en pantalla. + let mut final_geometry = None; + for _ in 0..READS { + surface.settle().await; + match surface.read_geometry().await { + Ok(Some(geometry)) => { + let settled = size_matches(wanted, geometry.size) + && position_matches(target, geometry.origin); + final_geometry = Some(geometry); + if settled { + break; + } + } + Ok(None) => {} + Err(_) => return failed_report(Reason::ReadFailed), + } + } + match final_geometry { + Some(geometry) => report_of(wanted, geometry, target), + 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 +} + +/// Deja la ventana sujeta al tamaño que tiene de verdad. +/// +/// Al fallar, la ventana puede haberse quedado con otro tamaño que el pedido: +/// las pistas se dejan en el que tiene (una lectura de respaldo). **No** se +/// sueltan: medido, soltar la petición de tamaño y las pistas hace que GTK +/// recalcule la configuración con el tamaño por defecto (la ventana crecía a +/// 320×260). +/// +/// Sólo si esta colocación sigue vigente: una colocación posterior ya fijó +/// sus pistas y pisarlas dejaría la ventana sujeta a un tamaño viejo. +async fn release_size bool>(surface: &mut S, superseded: &mut F) { + if superseded() { + return; + } + if let Ok(Some(geometry)) = surface.read_geometry().await { + surface + .set_size_constraints(geometry.size.0, geometry.size.1) + .await; + } +} + +/// 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 +} + +/// Lee hasta que el gestor conceda el tamaño pedido (o se agote el presupuesto). +/// `None` (aún no visible) es reintentable; sólo un fallo real es error. +async fn read_until(surface: &mut S, wanted: (i32, i32)) -> Result { + 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) { + return Ok(geometry); + } + last = Some(geometry); + } + Ok(None) => {} + Err(_) => return Err(Reason::ReadFailed), + } + } + last.ok_or(Reason::NotViewable) +} + +/// Compone el informe final con la geometría que quedó en pantalla. +fn report_of(wanted: (i32, i32), effective: Geometry, target: (i32, i32)) -> 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. + read_back: true, + } +} + +/// 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 sin lectura fiable: no hay geometría que cachear. +fn failed_report(reason: Reason) -> Report { + Report { + status: Status::Unverified, + bounds: None, + reason: Some(reason), + read_back: false, + } +} + +/// Reserva la siguiente generación: una colocación posterior cancela la pendiente. +pub fn next_generation() -> u64 { + GENERATION.fetch_add(1, Ordering::SeqCst) + 1 +} + +/// Cancela la colocación pendiente (el usuario empezó a arrastrar la tira). +pub fn cancel() { + GENERATION.fetch_add(1, Ordering::SeqCst); +} + +/// True cuando una colocación posterior sustituyó a la `generation` dada. +pub fn superseded(generation: u64) -> bool { + GENERATION.load(Ordering::SeqCst) != generation +} + +/// Coloca el widget en la superficie nativa y devuelve la geometría efectiva. +#[cfg_attr(not(target_os = "linux"), allow(unused_variables))] +pub async fn place( + window: &tauri::WebviewWindow, + placement: Placement, + generation: u64, +) -> Result { + // Antes de tocar la superficie nativa: sólo la ventana del widget puede + // colocarse, el rectángulo tiene que ser utilizable (no cero, acotado y + // sin desbordamiento) y el factor de escala tiene que ser positivo y + // finito. Vale para todos los transportes. + let bounds = placement.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 transport_on_ui(window).await == Transport::X11 { + let mut surface = UiSurface::new(window.clone(), placement.scale, generation).await; + return Ok(drive(&mut surface, &placement, || superseded(generation)).await); + } + } + // Sin read-back en esta plataforma: la solicitud 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_widget(&ui_window, bounds)); + }) + .map_err(|e| e.to_string())?; + rx.recv() + .await + .ok_or_else(|| "Widget bounds UI callback was dropped".to_string())? + .map(|()| Report { + status: Status::Unverified, + bounds: None, + reason: None, + read_back: false, + }) +} + +/// Fija las pistas de tamaño de la ventana: la **petición de tamaño** del +/// widget y las pistas `min == max`. +/// +/// La petición de tamaño es lo que conserva el tamaño concedido cuando GTK +/// recalcula la configuración de la ventana (al retirar el permiso de resize): +/// medido, sin ella la ventana volvía a su tamaño por defecto. Las pistas +/// `min == max` son las que impiden al gestor ofrecer otro tamaño. +#[cfg(target_os = "linux")] +pub fn set_size_constraints( + window: &W, + width: i32, + height: i32, +) { + window.set_size_request(width, height); + let geometry = gtk::gdk::Geometry::new( + width, + height, + width, + height, + 0, + 0, + 0, + 0, + 0.0, + 0.0, + gtk::gdk::Gravity::Center, + ); + window.set_geometry_hints( + None::<>k::Widget>, + Some(&geometry), + gtk::gdk::WindowHints::MIN_SIZE | gtk::gdk::WindowHints::MAX_SIZE, + ); +} + +/// Fija el tamaño de la ventana nativa (#179). +/// +/// Pistas iguales (`min == max == tamaño`): el gestor no puede ofrecer otro +/// tamaño y GTK recorta cualquier reconfiguración. Además, la **petición de +/// tamaño** del widget: al retirar el permiso de resize, GTK recalcula la +/// configuración con ese mínimo y el tamaño concedido se conserva (medido: +/// sin ella la ventana volvía a su tamaño por defecto). El permiso de resize +/// se activa aquí y se retira nada más terminar el resize. +#[cfg(target_os = "linux")] +pub fn fix_size( + window: &W, + width: i32, + height: i32, +) { + set_size_constraints(window, width, height); + window.set_resizable(true); + window.resize(width, height); +} + +/// Aplica el tamaño y **retira el permiso de resize en la misma llamada +/// nativa** (#179). +/// +/// El permiso de resize se abre sólo para el resize programático: al +/// retirarlo, `tao` deja de ofrecer cursor de redimensionar y de arrancar un +/// arrastre de borde. La petición de tamaño del widget conserva el tamaño +/// concedido cuando GTK recalcula la configuración. +/// +/// Devuelve `true` si el permiso quedó cerrado: el ciclo no puede seguir como +/// si el resize se hubiera hecho si no se puede confirmar. +#[cfg(target_os = "linux")] +pub fn apply_size( + window: &W, + width: i32, + height: i32, +) -> bool { + fix_size(window, width, height); + window.set_resizable(false); + !window.is_resizable() +} + +/// Corre una llamada nativa en el hilo de UI y espera su resultado. +#[cfg(target_os = "linux")] +async fn on_ui( + window: &tauri::WebviewWindow, + call: impl FnOnce(>k::ApplicationWindow) -> T + Send + 'static, +) -> Option { + let (tx, mut rx) = tauri::async_runtime::channel(1); + let target = window.clone(); + let inner = window.clone(); + target + .run_on_main_thread(move || { + // La ventana GTK sólo se usa aquí: nunca cruza a otro hilo. + let value = inner.gtk_window().ok().map(|gtk| call(>k)); + let _ = tx.try_send(value); + }) + .ok()?; + // La espera está acotada: una llamada nativa que no responde no puede + // dejar la colocación esperando para siempre. + match tokio::time::timeout(UI_TIMEOUT, rx.recv()).await { + Ok(Some(value)) => value, + _ => None, + } +} + +/// La superficie GTK: cada llamada nativa ocurre en el hilo de UI. +#[cfg(target_os = "linux")] +struct UiSurface { + window: tauri::WebviewWindow, + /// El factor con el que se calculó la petición (no el de la ventana: en DPI + /// mixto pueden diferir). + scale: f64, + /// La generación de la colocación: el cierre la comprueba dentro del hilo + /// de UI, para que una colocación posterior no mueva la ventana a medias. + generation: u64, + /// El último tamaño pedido (físico): sirve para saber si el tamaño + /// efectivo ya es el pedido (nada que esperar al compositor). + requested: (i32, i32), +} + +#[cfg(target_os = "linux")] +impl UiSurface { + async fn new(window: tauri::WebviewWindow, scale: f64, generation: u64) -> Self { + Self { + window, + scale, + generation, + requested: (0, 0), + } + } +} + +#[cfg(target_os = "linux")] +impl Surface for UiSurface { + async fn request_size(&mut self, width: i32, height: i32) -> bool { + let (w, h) = ( + to_logical(width, self.scale), + to_logical(height, self.scale), + ); + self.requested = (width, height); + let generation = self.generation; + on_ui(&self.window, move |window| { + if superseded(generation) { + // La colocación está caducada: no se toca la ventana (y el + // permiso sigue cerrado). + return true; + } + // La pista de tamaño, las pistas `min == max` y el resize, con el + // permiso de resize abierto **sólo** para el resize: la misma + // llamada lo retira y lo confirma. + let closed = apply_size(window, w, h); + // La confirmación: el permiso tiene que quedar cerrado. + closed + }) + .await + .unwrap_or(false) + } + + async fn request_position(&mut self, x: i32, y: i32) -> bool { + let (x, y) = (to_logical(x, self.scale), to_logical(y, self.scale)); + let generation = self.generation; + on_ui(&self.window, move |window| { + // La generación se comprueba **dentro** del hilo de UI: entre la + // comprobación y el `move_` no cabe otra colocación. + if superseded(generation) { + return true; + } + use gtk::prelude::*; + window.move_(x, y); + true + }) + .await + .unwrap_or(false) + } + + async fn set_size_constraints(&mut self, width: i32, height: i32) -> bool { + let (w, h) = ( + to_logical(width, self.scale), + to_logical(height, self.scale), + ); + let generation = self.generation; + on_ui(&self.window, move |window| { + // La generación se comprueba **dentro** del hilo de UI, como en el + // tamaño y en la posición: si esta colocación está caducada, sus + // pistas no pisan las de la colocación que la sustituyó. + if superseded(generation) { + return true; + } + // La petición de tamaño y las pistas `min == max`, sin tocar el + // tamaño de la ventana. + set_size_constraints(window, w, h); + true + }) + .await + .unwrap_or(false) + } + + async fn read_geometry(&mut self) -> Result, String> { + on_ui(&self.window, move |window| { + use gtk::prelude::*; + // Sin ventana GDK todavía no hay superficie que leer. + let Some(gdk_window) = window.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 el servidor X tiene ahora mismo, en píxeles + // físicos del **propio** monitor de la ventana: en DPI mixto el + // factor de la petición puede ser otro. + let scale = window.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 las coordenadas relativas + // a la raíz (que es lo que se informa) y su valor de retorno dice + // si la llamada tuvo éxito. Si falla, **no** hay origen usable: + // el respaldo de `gdk_window_get_geometry` es relativo al padre + // (el marco de la decoración si el gestor reparenta la ventana) y + // mezclaría dos espacios de coordenadas. + 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; + } +} + +/// El transporte de la superficie nativa, detectado del display real. +/// **Sólo en el hilo de UI**: GDK no se puede tocar desde otro hilo. +#[cfg(target_os = "linux")] +fn transport() -> Transport { + static TRANSPORT: std::sync::OnceLock = std::sync::OnceLock::new(); + *TRANSPORT.get_or_init(detect_transport) +} + +#[cfg(target_os = "linux")] +fn detect_transport() -> Transport { + use gtk::prelude::*; + match gtk::gdk::Display::default() { + Some(display) => transport_from_type_name(display.type_().name()), + None => Transport::Other, + } +} + +/// 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 { + on_ui(window, |_| transport()) + .await + .unwrap_or(Transport::Other) +} diff --git a/src-tauri/src/widget_placement_test.rs b/src-tauri/src/widget_placement_test.rs new file mode 100644 index 0000000..baf8e4f --- /dev/null +++ b/src-tauri/src/widget_placement_test.rs @@ -0,0 +1,925 @@ +//! Contrato determinista del ciclo de colocación nativa (#179). +//! +//! El doble de `Surface` modela al gestor de ventanas: **la visibilidad la +//! decide el frontend** (el backend nunca mapea la ventana), el compositor +//! responde tarde, el permiso de resize se abre **sólo** alrededor del resize +//! programático y ninguna llamada nativa se da por buena sin confirmar. Las +//! aserciones miran el resultado del ciclo, no sólo el orden de las llamadas. + +use super::widget_placement::*; +use crate::detail_geometry::Bounds; + +/// Gestor de ventanas simulado: la visibilidad es del frontend, el compositor +/// responde tarde y el permiso de resize sólo vive durante el resize. +struct Fake { + /// (origen, tamaño) que concede el gestor una vez colocada la ventana. + granted: Geometry, + /// (origen, tamaño) que la ventana conserva mientras el gestor calla. + pending: Geometry, + /// Lecturas que el gestor necesita antes de conceder la geometría. + delay: usize, + /// Lecturas que aún no ven la ventana (todavía no visible). + hidden: usize, + /// Lecturas hechas y estabilizaciones esperadas. + reads: usize, + settles: usize, + /// Si la ventana es visible: lo decide el frontend, no el backend. + viewable: bool, + /// Si la lectura falla (no hay respuesta de la superficie nativa). + fail_read: bool, + /// Llamadas nativas 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, + /// El tamaño en efecto **después** de un número de lecturas: el + /// compositor puede cambiar el tamaño más tarde (su respuesta llega + /// asíncrona), y el informe tiene que reflejar lo que quedó en pantalla. + size_after_reads: Option<(usize, (i32, i32))>, + /// Las pistas que ya fijó la colocación sucesora (la que cancela a + /// esta): se aplican en la primera lectura, como en la carrera real (la + /// sucesora escribe sus pistas antes de que la cancelada suelte el + /// tamaño). + successor_constraints: Option<(i32, i32)>, + /// Si el permiso de resize está activo. + resizable: bool, + /// Llamadas nativas, en orden. + calls: Vec, +} + +impl Fake { + /// El gestor concede la geometría pedida, tras una lectura. + fn granting() -> Self { + Fake { + granted: Geometry { + origin: (1859, 899), + size: (61, 181), + }, + pending: Geometry { + origin: (800, 425), + size: (320, 260), + }, + delay: 1, + hidden: 0, + reads: 0, + settles: 0, + viewable: true, + fail_read: false, + fail_calls: 0, + fail_position_calls: 0, + size_after_reads: None, + successor_constraints: None, + resizable: false, + calls: Vec::new(), + } + } + + /// El gestor nunca concede la geometría: conserva la anterior. + fn refusing() -> Self { + Fake { + delay: usize::MAX, + ..Fake::granting() + } + } + + /// La ventana que el frontend todavía no ha mostrado. + fn hidden_window() -> Self { + Fake { + viewable: false, + ..Fake::granting() + } + } + + /// El índice de la llamada "permission open" y el de "permission closed". + fn permission_window(&self) -> (usize, usize) { + let open = self + .calls + .iter() + .position(|c| c == "permission open") + .expect("el permiso se abre para el resize"); + let closed = self + .calls + .iter() + .position(|c| c == "permission closed") + .expect("el permiso se retira"); + (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} (sin confirmar)")); + 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} (sin confirmar)")); + return false; + } + self.calls.push(format!("position {x},{y}")); + true + } + + async fn set_size_constraints(&mut self, width: i32, height: i32) -> bool { + self.calls.push(format!("constraints {width}x{height}")); + true + } + + async fn read_geometry(&mut self) -> Result, String> { + self.reads += 1; + if self.fail_read { + return Err("the native surface did not answer".into()); + } + // Todavía no visible: no es un fallo, es "todavía no". + if self.reads <= self.hidden { + return Ok(None); + } + // Sin visibilidad no hay prueba del compositor: la geometría no se lee. + if !self.viewable { + return Ok(None); + } + // La colocación sucesora ya fijó sus pistas: la cancelada no puede + // pisarlas con una lectura vieja. + if let Some((w, h)) = self.successor_constraints.take() { + self.calls.push(format!("constraints {w}x{h}")); + } + let geometry = if self.reads > self.delay { + self.granted + } else { + self.pending + }; + // El compositor puede cambiar el tamaño más tarde (su respuesta llega + // asíncrona): el informe tiene que reflejar lo que quedó en pantalla. + let geometry = match self.size_after_reads { + Some((after, size)) if self.reads >= after => Geometry { + origin: geometry.origin, + size, + }, + _ => geometry, + }; + Ok(Some(geometry)) + } + + async fn settle(&mut self) { + self.settles += 1; + } +} + +fn placement(anchor: Anchor) -> Placement { + Placement { + bounds: Bounds { + x: 1859, + y: 899, + w: 61, + h: 181, + }, + anchor, + scale: 1.0, + } +} + +/// Corre el ciclo con el doble: sin temporizadores, sin hilo de UI. +fn run(fake: &mut Fake, placement: &Placement, superseded: bool) -> Report { + futures::executor::block_on(drive(fake, placement, || superseded)) +} + +/// Corre el ciclo con un cancelador que decide en cada fase. +fn run_with(fake: &mut Fake, placement: &Placement, superseded: impl FnMut() -> bool) -> Report { + futures::executor::block_on(drive(fake, placement, superseded)) +} + +#[test] +fn the_cycle_reports_the_geometry_the_window_manager_granted() { + let mut fake = Fake::granting(); + let report = run(&mut fake, &placement(Anchor::Right), false); + assert_eq!(report.status, Status::Applied); + assert_eq!( + report.bounds, + Some(Bounds { + x: 1859, + y: 899, + w: 61, + h: 181 + }) + ); + assert!(report.reason.is_none()); + // La geometría viene del compositor: el frontend sabe que admite la + // tolerancia del redondeo. + assert!(report.read_back, "la geometría es del read-back"); +} + +#[test] +fn the_resize_permission_is_open_only_while_the_programmatic_resize_runs() { + let mut fake = Fake::granting(); + let report = run(&mut fake, &placement(Anchor::Right), 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 ninguna otra llamada (ni una lectura, ni un movimiento). + let (open, closed) = fake.permission_window(); + assert_eq!( + closed, + open + 1, + "el permiso dura lo que el resize: {:?}", + fake.calls + ); + // Y al final del ciclo no queda permiso abierto. + assert!(!fake.resizable, "el permiso de resize se retira"); + // El permiso se abre una sola vez por ciclo. + assert_eq!( + fake.calls + .iter() + .filter(|c| *c == "permission open") + .count(), + 1, + "un solo resize con permiso: {:?}", + fake.calls + ); +} + +#[test] +fn the_final_read_back_verifies_the_size_after_the_withdrawal() { + let mut fake = Fake::granting(); + fake.delay = 0; + // El compositor cambia el tamaño **después** de la lectura intermedia (su + // respuesta es asíncrona): el informe tiene que decir el tamaño que quedó + // en pantalla, no el de antes. + fake.size_after_reads = Some((2, (320, 260))); + let report = run(&mut fake, &placement(Anchor::Right), false); + // Si el tamaño ya no es el concedido, la colocación es una limitación, no + // `applied`. + assert_eq!(report.status, Status::Limited); + assert_eq!(report.reason, Some(Reason::SizeRefused)); + assert_eq!( + report.bounds, + Some(Bounds { + x: 1859, + y: 899, + w: 320, + h: 260 + }), + "el tamaño del informe es el que quedó en pantalla" + ); +} + +#[test] +fn a_native_call_that_cannot_be_confirmed_is_not_a_success() { + let mut fake = Fake::granting(); + // La llamada nativa no responde (ni al reintento): el ciclo no puede + // seguir como si el tamaño se hubiera aplicado. + fake.fail_calls = 2; + let report = run(&mut fake, &placement(Anchor::Right), false); + assert_ne!(report.status, Status::Applied); + assert_eq!(report.status, Status::Unverified); + assert_eq!(report.reason, Some(Reason::ReadFailed)); + assert_eq!(report.bounds, None); +} + +#[test] +fn a_native_call_is_retried_within_a_bounded_budget() { + let mut fake = Fake::granting(); + // La primera llamada no se confirma: el ciclo la reintenta (una sola vez) + // y si va bien, la colocación sigue. + fake.fail_calls = 1; + let report = run(&mut fake, &placement(Anchor::Right), false); + assert_eq!(report.status, Status::Applied); + let sizes = fake.calls.iter().filter(|c| c.starts_with("size ")).count(); + assert_eq!(sizes, 2, "un reintento y nada más: {:?}", fake.calls); +} + +#[test] +fn the_backend_never_shows_the_window() { + let mut fake = Fake::granting(); + run(&mut fake, &placement(Anchor::Right), false); + // La visibilidad es del frontend: el backend no mapea ni muestra nada. + assert!( + !fake + .calls + .iter() + .any(|c| c.contains("map") || c.contains("show")), + "el backend no muestra la ventana: {:?}", + fake.calls + ); +} + +#[test] +fn a_window_that_is_not_viewable_yet_is_retried_and_not_a_failure() { + let mut fake = Fake::granting(); + // Las dos primeras lecturas no ven la ventana: no es un error, es "todavía + // no" y el ciclo tiene que sobrevivir dentro de su presupuesto. + fake.hidden = 2; + let report = run(&mut fake, &placement(Anchor::Right), false); + assert_eq!(report.status, Status::Applied); + assert_eq!( + report.bounds, + Some(Bounds { + x: 1859, + y: 899, + w: 61, + h: 181 + }) + ); + + // Si la ventana no llega a verse dentro del presupuesto, tampoco es un + // fallo de lectura: se informa como tal y no se cachea geometría. + let mut hidden = Fake::hidden_window(); + hidden.hidden = usize::MAX; + let report = run(&mut hidden, &placement(Anchor::Right), false); + assert_eq!(report.status, Status::Unverified); + assert_eq!(report.reason, Some(Reason::NotViewable)); + assert_eq!(report.bounds, None); +} + +#[test] +fn a_visible_window_whose_size_never_matches_is_a_size_problem_not_a_visibility_one() { + // La ventana se ve pero el gestor no concede el tamaño: el motivo es el + // tamaño, no «la ventana todavía no es visible». + let mut fake = Fake::refusing(); + let report = run(&mut fake, &placement(Anchor::Right), false); + assert_eq!(report.status, Status::Limited); + assert_eq!(report.reason, Some(Reason::SizeRefused)); + assert_eq!( + report.bounds, + Some(Bounds { + x: 800, + y: 425, + w: 320, + h: 260 + }) + ); +} + +#[test] +fn a_size_the_window_manager_clamps_is_a_limitation_and_not_the_request() { + let mut fake = Fake::refusing(); + let report = run(&mut fake, &placement(Anchor::Right), false); + assert_eq!(report.status, Status::Limited); + assert_eq!(report.reason, Some(Reason::SizeRefused)); + // La geometría que se informa es la que quedó en pantalla, no la pedida. + assert_eq!( + report.bounds, + Some(Bounds { + x: 800, + y: 425, + w: 320, + h: 260 + }) + ); +} + +#[test] +fn the_cycle_stops_reading_after_its_budget() { + let mut fake = Fake::refusing(); + let report = run(&mut fake, &placement(Anchor::Right), false); + assert!(fake.reads <= 6 * READS, "reads: {}", fake.reads); + assert!(fake.settles <= 6 * READS, "sin recuperación permanente"); + assert_eq!(report.status, Status::Limited); +} + +#[test] +fn an_already_sized_surface_is_not_positioned_after_a_cancellation() { + let mut fake = Fake::granting(); + // El tamaño ya es el pedido: el ciclo no entra en la espera. + fake.granted.size = (61, 181); + fake.pending.size = (61, 181); + fake.delay = 0; + // La cancelación llega después de la fase de tamaño: la posición tampoco + // se pide (el cancelador se comprueba antes de cada fase nativa). + let mut checks = 0; + let report = run_with(&mut fake, &placement(Anchor::Right), || { + checks += 1; + checks > 1 + }); + assert_eq!(report.status, Status::Superseded); + assert!( + !fake.calls.iter().any(|c| c.starts_with("position")), + "no se mueve la ventana después de cancelar: {:?}", + fake.calls + ); + // Una colocación cancelada no informa geometría: quedó a medias. + assert_eq!(report.bounds, None); +} + +#[test] +fn the_limited_path_does_not_move_the_window_after_a_cancellation() { + let mut fake = Fake::refusing(); + // El cancelador pasa a true tras el primer read_until: en el camino + // limitado tampoco se piden tamaño ni posición. + let mut checks = 0; + let report = run_with(&mut fake, &placement(Anchor::Right), || { + checks += 1; + checks > 2 + }); + assert_eq!(report.status, Status::Superseded); + let after = fake + .calls + .iter() + .position(|c| c.starts_with("position")) + .expect("la ventana se mueve"); + let native_after: Vec<&String> = fake.calls[after + 1..] + .iter() + .filter(|c| c.starts_with("size") || c.starts_with("position")) + .collect(); + assert!( + native_after.is_empty(), + "sin fases nativas tras cancelar: {native_after:?}" + ); +} + +#[test] +fn a_cancelled_placement_leaves_no_resize_permission_open() { + let mut fake = Fake::granting(); + let mut checks = 0; + let report = run_with(&mut fake, &placement(Anchor::Right), || { + checks += 1; + checks > 1 + }); + assert_eq!(report.status, Status::Superseded); + // Aunque la colocación se cancele, el permiso de resize no queda abierto: + // se abre y se retira dentro de la misma llamada. + assert!(!fake.resizable, "el permiso se retira"); +} + +#[test] +fn a_failed_placement_leaves_the_window_in_its_real_size() { + // La llamada nativa no se pudo confirmar: la colocación termina, pero la + // ventana no puede quedar sujeta a un tamaño que ya no tiene: las pistas + // se dejan en el que tiene de verdad (no se sueltan: medido, soltar la + // petición de tamaño hace que la ventana vuelva a su tamaño por defecto). + let mut fake = Fake::granting(); + // La llamada de posición no se puede confirmar (ni al reintento). + fake.fail_position_calls = 2; + let report = run(&mut fake, &placement(Anchor::Right), false); + assert_eq!(report.status, Status::Unverified); + assert_eq!(report.reason, Some(Reason::ReadFailed)); + let constraints: Vec<&String> = fake + .calls + .iter() + .filter(|c| c.starts_with("constraints ")) + .collect(); + assert_eq!( + constraints, + vec!["constraints 320x260"], + "las pistas quedan en el tamaño real: {:?}", + fake.calls + ); +} + +#[test] +fn a_superseded_placement_cannot_overwrite_the_successor_constraints() { + // La carrera real: la colocación sucesora fija sus pistas y cancela a + // esta. Al soltar el tamaño, la cancelada no puede pisarlas con una + // lectura vieja: dejarían la ventana sujeta a un tamaño que ya no tiene. + let mut fake = Fake::refusing(); + // El compositor no concede el tamaño pedido: la ventana conserva el suyo. + // Las pistas de la sucesora son otras (otra medida de la tira). + fake.successor_constraints = Some((120, 181)); + let first = next_generation(); + let mut checks = 0; + let mut started = false; + let report = run_with(&mut fake, &placement(Anchor::Right), || { + checks += 1; + if checks > 2 && !started { + started = true; + next_generation(); // la sucesora empieza: cancela a esta + } + superseded(first) + }); + assert_eq!(report.status, Status::Superseded); + // Las pistas son las de la sucesora: la cancelada no escribe ninguna. + let constraints: Vec<&String> = fake + .calls + .iter() + .filter(|c| c.starts_with("constraints ")) + .collect(); + assert_eq!( + constraints, + vec!["constraints 120x181"], + "una colocación caducada no pisa las pistas de la sucesora: {:?}", + fake.calls + ); +} + +#[test] +fn a_failed_read_is_not_reported_as_a_zero_sized_geometry() { + let mut fake = Fake::granting(); + fake.fail_read = true; + let report = run(&mut fake, &placement(Anchor::Right), false); + assert_eq!(report.status, Status::Unverified); + assert_eq!(report.reason, Some(Reason::ReadFailed)); + // Sin read-back no hay geometría que cachear como origen del arrastre. + assert_eq!(report.bounds, None); + assert!(!fake.calls.iter().any(|c| c.starts_with("size 0"))); +} + +#[test] +fn the_anchored_edge_survives_a_different_granted_size() { + let mut fake = Fake::granting(); + // El gestor concede otra anchura: el borde derecho no debe despegarse. + fake.granted = Geometry { + origin: (1680, 899), + size: (240, 181), + }; + let report = run(&mut fake, &placement(Anchor::Right), false); + assert_eq!(report.status, Status::Limited); + // La posición se recalcula con el tamaño efectivo: 1859 + 61 - 240 = 1680. + assert!( + fake.calls.iter().any(|c| c == "position 1680,899"), + "la posición usa el tamaño efectivo: {:?}", + fake.calls + ); +} + +#[test] +fn each_anchor_keeps_its_edge_with_the_effective_size() { + // El gestor concede un tamaño menor que el pedido: cada borde anclado se + // conserva con la geometría efectiva. `Top` y `Left` comparten fórmula + // (conservan el origen), así que se distinguen de `Bottom` y `Right`. + for (anchor, expected) in [ + (Anchor::Top, "position 1859,899"), + (Anchor::Bottom, "position 1859,980"), + (Anchor::Left, "position 1859,899"), + (Anchor::Right, "position 1880,899"), + ] { + let mut fake = Fake::granting(); + fake.granted = Geometry { + origin: (1859, 899), + size: (40, 100), + }; + fake.pending = fake.granted; + fake.delay = 0; + let report = run(&mut fake, &placement(anchor), false); + assert_eq!(report.status, Status::Limited, "{anchor:?}"); + let moves: Vec<&String> = fake + .calls + .iter() + .filter(|c| c.starts_with("position")) + .collect(); + // El primer movimiento usa el tamaño pedido; el segundo, el efectivo. + assert_eq!( + moves.first().map(|m| m.as_str()), + Some("position 1859,899"), + "{anchor:?}" + ); + assert_eq!( + moves.last().map(|m| m.as_str()), + Some(expected), + "el borde {anchor:?} se conserva: {moves:?}" + ); + } +} + +#[test] +fn a_second_generation_cancels_the_first_placement() { + // Dos generaciones reales: la segunda (un arrastre o una colocación + // posterior) invalida la primera. La cancelación llega **después** de la + // lectura: el ciclo ya movió la ventana y se para antes de informarla. + let first = next_generation(); + let second = next_generation(); + let mut fake = Fake::granting(); + let mut checks = 0; + let report = run_with(&mut fake, &placement(Anchor::Right), || { + checks += 1; + checks > 2 + }); + assert_eq!(report.status, Status::Superseded); + assert!(superseded(first), "la primera generación está caducada"); + assert!(!superseded(second), "la segunda sigue vigente"); +} + +#[test] +fn the_limited_path_keeps_the_requested_size_and_never_another_one() { + let mut fake = Fake::refusing(); + let report = run(&mut fake, &placement(Anchor::Right), false); + assert_eq!(report.status, Status::Limited); + // Las pistas y la petición de tamaño son siempre las **pedidas**: nunca + // se sustituyen por un tamaño que el usuario no pidió (dejarían la + // ventana sujeta a un tamaño viejo). + for call in fake.calls.iter().filter(|c| c.starts_with("size ")) { + assert_eq!(call, "size 61x181", "sólo el tamaño pedido: {call}"); + } +} + +#[test] +fn a_non_positive_or_non_finite_scale_is_rejected_before_any_native_call() { + // El factor de escala convierte píxeles físicos y lógicos: con 0 (o con un + // valor no finito) la conversión no tiene sentido (a 0 el tamaño lógico + // sería infinito y su recorte, un tamaño de 2³¹ píxeles). + let mut p = placement(Anchor::Right); + for scale in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + p.scale = scale; + let error = p + .validate("widget") + .expect_err("un factor no utilizable no puede colocarse"); + assert!(error.contains("scale"), "el error dice qué falla: {error}"); + } + // Un factor utilizable pasa: la colocación sigue su curso. + p.scale = 1.5; + assert!(p.validate("widget").is_ok()); + // Y el rectángulo se sigue validando igual. + p.scale = 1.0; + p.bounds.w = 0; + assert!(p.validate("widget").is_err()); + p.bounds.w = 61; + assert!(p.validate("detail").is_err(), "sólo la ventana del widget"); +} + +#[test] +fn the_origin_comes_from_the_root_or_there_is_no_reading_at_all() { + // La llamada a la raíz respondió: su origen es el que se informa. + assert_eq!(rooted_origin(1859, 899, true), Some((1859, 899))); + // Si falla, la lectura no es utilizable: el único respaldo disponible + // (`gdk_window_get_geometry`) es relativo al padre y mezclaría dos + // espacios de coordenadas. + assert_eq!(rooted_origin(0, 0, false), None); +} + +#[test] +fn the_transport_comes_from_the_real_gdk_display_type() { + assert_eq!(transport_from_type_name("GdkX11Display"), Transport::X11); + assert_eq!( + transport_from_type_name("GdkWaylandDisplay"), + Transport::Other + ); + assert_eq!( + transport_from_type_name("GdkBroadwayDisplay"), + Transport::Other + ); + assert_eq!(transport_from_type_name(""), Transport::Other); +} + +#[test] +fn the_size_tolerance_absorbs_the_window_manager_rounding() { + assert!(size_matches((61, 181), (61, 181))); + assert!(size_matches((61, 181), (62, 181))); + assert!(size_matches((61, 181), (60, 180))); + assert!(!size_matches((61, 181), (63, 181))); + assert!(!size_matches((61, 181), (61, 183))); + assert!(!size_matches((61, 181), (240, 181))); +} + +#[test] +fn only_the_widget_window_may_place_its_own_bounds() { + let ok = Bounds { + x: 10, + y: 20, + w: 61, + h: 181, + }; + assert!(ok.validate_widget("widget").is_ok()); + // Sólo la ventana del widget puede colocarse. + for label in ["detail", "settings", "overlay", ""] { + assert!(ok.validate_widget(label).is_err(), "label: {label}"); + } + // Y el rectángulo tiene que ser utilizable. + assert!(Bounds { w: 0, ..ok }.validate_widget("widget").is_err()); + assert!(Bounds { h: 0, ..ok }.validate_widget("widget").is_err()); + assert!(Bounds { w: 1_000_001, ..ok } + .validate_widget("widget") + .is_err()); + assert!(Bounds { h: 1_000_001, ..ok } + .validate_widget("widget") + .is_err()); + assert!(Bounds { x: i32::MAX, ..ok } + .validate_widget("widget") + .is_err()); + assert!(Bounds { y: i32::MAX, ..ok } + .validate_widget("widget") + .is_err()); + assert!(Bounds { + x: -1920, + y: -100, + ..ok + } + .validate_widget("widget") + .is_ok()); +} + +#[test] +fn the_scale_conversion_uses_the_factor_the_request_was_computed_with() { + // A escala 1 el round-trip es exacto; a 1,5 y 2 el gestor redondea al + // píxel de dispositivo, así que el error físico es de 1 px como mucho. + for scale in [1.0, 1.5, 2.0] { + for physical in [61, 91, 181, 362] { + let logical = to_logical(physical, scale); + let back = to_physical(logical, scale); + assert!( + (back - physical).abs() <= 1, + "scale {scale}: {physical} -> {logical} -> {back}" + ); + } + } + // A escala 1,5 el tamaño físico pedido puede no existir: 91 px es 60,67 + // lógicos y el gestor concede 92 px físicos. + assert_eq!(to_logical(91, 1.5), 61); + assert_eq!(to_physical(61, 1.5), 92); + // En DPI mixto manda el factor de la petición, no el de la ventana. + let placement = Placement { + bounds: Bounds { + x: 0, + y: 0, + w: 100, + h: 100, + }, + anchor: Anchor::Top, + scale: 1.25, + }; + assert_eq!(to_logical(placement.bounds.w as i32, placement.scale), 80); + assert_eq!(to_physical(80, placement.scale), 100); +} + +#[test] +fn the_placement_payload_deserializes_with_its_bounds_anchor_and_scale() { + // El payload es el que manda el frontend: rectángulo, borde anclado y el + // factor con el que se calculó. + let payload = r#"{"x":1859,"y":899,"w":61,"h":181,"anchor":"right","scale":1.5}"#; + let placement: Placement = serde_json::from_str(payload).expect("the payload must deserialize"); + assert_eq!( + placement.bounds, + Bounds { + x: 1859, + y: 899, + w: 61, + h: 181 + } + ); + assert_eq!(placement.anchor, Anchor::Right); + assert_eq!(placement.scale, 1.5); + // Y sin factor no hay colocación: la conversión necesita el factor con el + // que se calculó el rectángulo. + let without_scale = r#"{"x":1859,"y":899,"w":61,"h":181,"anchor":"right"}"#; + assert!(serde_json::from_str::(without_scale).is_err()); +} + +#[test] +fn the_report_travels_with_the_effective_geometry_and_its_reason_code() { + let applied = Report { + status: Status::Applied, + bounds: Some(Bounds { + x: 1859, + y: 899, + w: 61, + h: 181, + }), + reason: None, + read_back: true, + }; + let json = serde_json::to_value(applied).unwrap(); + assert_eq!(json["status"], "applied"); + assert_eq!(json["bounds"]["x"], 1859); + assert_eq!(json["bounds"]["w"], 61); + assert!(json.get("reason").is_none()); + // La marca del read-back: el frontend sólo aplica la tolerancia del + // redondeo a la geometría que vino del compositor. + assert_eq!(json["read_back"], true); + + let limited = Report { + status: Status::Limited, + bounds: Some(Bounds { + x: 0, + y: 0, + w: 320, + h: 260, + }), + reason: Some(Reason::SizeRefused), + read_back: true, + }; + let json = serde_json::to_value(limited).unwrap(); + assert_eq!(json["status"], "limited"); + // El motivo es un código, no prosa de interfaz. + assert_eq!(json["reason"], "size_refused"); + + let superseded = Report { + status: Status::Superseded, + bounds: None, + reason: Some(Reason::Superseded), + read_back: false, + }; + let json = serde_json::to_value(superseded).unwrap(); + assert_eq!(json["status"], "superseded"); + assert_eq!(json["reason"], "superseded"); + assert!(json["bounds"].is_null()); + assert_eq!(json["read_back"], false); +} + +/// Prueba en vivo de la secuencia nativa con un webview real (#179). No es +/// determinista: `GDK_BACKEND=x11 cargo test --locked widget_placement -- --ignored`. +#[cfg(target_os = "linux")] +#[test] +#[ignore = "needs an X11 session"] +fn the_native_sequence_grants_the_size_and_leaves_no_resize_permission() { + use gtk::prelude::*; + use webkit2gtk::WebViewExt; + gtk::init().expect("GTK must initialise"); + // El transporte real de esta sesión: sin display X11 no hay transporte X11. + let display = gtk::gdk::Display::default().expect("a display"); + let name = display.type_().name().to_string(); + assert_eq!( + crate::widget_placement::transport_from_type_name(&name), + Transport::X11 + ); + + let window = gtk::Window::new(gtk::WindowType::Toplevel); + window.set_decorated(false); + window.set_resizable(false); + window.set_default_size(320, 260); + let view = webkit2gtk::WebView::new(); + view.load_html( + "
", + None, + ); + window.add(&view); + settle(700); + assert_ne!( + window.size(), + (61, 181), + "la ventana arranca con su tamaño por defecto" + ); + + // La visibilidad es del frontend: la ventana se muestra antes de colocarla. + window.show_all(); + settle(700); + assert!(window.is_mapped(), "la ventana está visible"); + + // La secuencia de producción: la pista fija el tamaño y el permiso de + // resize se abre **sólo** alrededor del resize programático (la misma + // llamada lo retira). + assert!( + crate::widget_placement::apply_size(&window, 61, 181), + "el permiso de resize queda cerrado" + ); + settle(600); + assert_eq!(window.size(), (61, 181), "el tamaño se concede exacto"); + assert!(!window.is_resizable(), "el permiso está cerrado"); + + // Y la posición se aplica con el tamaño fijo puesto: las pistas iguales no + // bloquean el movimiento. + window.move_(1859, 899); + settle(400); + assert_eq!(window.position(), (1859, 899), "la posición se aplica"); + + // El tamaño sigue en pie después de la retirada: es lo que la pide el + // widget y lo que el gestor respeta. + settle(700); + assert_eq!( + window.size(), + (61, 181), + "el tamaño sobrevive a la retirada del permiso" + ); + assert_eq!( + window.position(), + (1859, 899), + "la posición sobrevive a la retirada del permiso" + ); + + // El read-back de GDK coincide con el compositor (sonda X11 opcional). + let gdk_window = window.window().expect("the gdk window"); + let (gx, gy, gw, gh) = gdk_window.geometry(); + assert_eq!( + (gx, gy, gw, gh), + (1859, 899, 61, 181), + "el read-back de GDK" + ); + assert!(gdk_window.is_viewable(), "la ventana es visible"); + match std::env::var("QUORB_X11_GEOMETRY_PROBE") { + Ok(probe) => { + let output = std::process::Command::new(&probe) + .args(["61", "181"]) + .output() + .expect("the X11 probe must run"); + let tree = String::from_utf8_lossy(&output.stdout); + println!("X11 window tree (abs=posición de raíz, size=tamaño):\n{tree}"); + assert!( + tree.contains("abs=1859,899 size=61x181"), + "el compositor sitúa la ventana donde se pidió:\n{tree}" + ); + } + Err(_) => { + println!("sin QUORB_X11_GEOMETRY_PROBE: el contraste con el compositor no se ejecuta") + } + } +} + +/// Deja que GTK procese los 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/Widget.tsx b/src/Widget.tsx index 1f42973..36bef0a 100644 --- a/src/Widget.tsx +++ b/src/Widget.tsx @@ -16,6 +16,7 @@ import { retryWithBackoff, STARTUP_RETRY, USAGE_RETRY } from './lib/startup'; import { createSessionMonitor } from './lib/session-monitor'; import { needsRefresh } from './lib/widget-refresh'; import { createWidgetRecovery, shouldAuditNativePosition } from './lib/widget-recovery'; +import { placementOutcome, reasonKey, samePlacement, type WidgetPlacementReason, type WidgetPlacementReport } from './lib/widget-placement'; import { createLatestQueue, physicalPanelBounds, samePhysical, DETAIL_EVENTS, DETAIL_WINDOW, isCurrentDetailInteraction, panelWidthFor, type DetailInteraction, type DetailState, type PhysicalBounds } from './lib/detail-panel'; import { createDetailPresence, presenceFromPoint, shouldSampleNativePresence } from './lib/detail-presence'; import { openDelayForOs, osFromUA } from './lib/os-detect'; @@ -164,6 +165,11 @@ export function Widget() { const [peekFocused, setPeekFocused] = useState(false); const [placementRetry, setPlacementRetry] = useState(0); const [placementFailed, setPlacementFailed] = useState(false); + /** Motivo del backend cuando el gestor no concedió la geometría (#179). */ + const [placementReason, setPlacementReason] = useState(null); + /** El motivo localizado: el código del backend se traduce con el catálogo (#179). */ + const placementReasonKey = reasonKey(placementReason); + const placementReasonText = placementReasonKey ? t(locale, placementReasonKey) : null; const [revealUntil, setRevealUntil] = useState(0); // reordenar círculos @@ -175,6 +181,8 @@ export function Widget() { const peekRef = useRef(null); const widgetQueue = useRef(createLatestQueue()).current; const widgetBounds = useRef(null); + /** True si la geometría cacheada vino del read-back del compositor (X11). */ + const widgetBoundsReadBack = useRef(false); const placementInFlight = useRef(false); const orbsRef = useRef(null); const detailStateRef = useRef(null); @@ -208,6 +216,8 @@ export function Widget() { }), ).current; const shown = useRef(false); + /** El usuario ocultó la tira desde la bandeja: la visibilidad es suya (#179). */ + const hiddenByUser = useRef(false); const drag = useRef<{ sx: number; sy: number; start: PhysicalPosition | null; sf: number; raf: number; pointerId: number; ending: boolean } | null>(null); const orbDrag = useRef<{ id: string; x: number; y: number; moved: boolean } | null>(null); const lastReorderAt = useRef(0); @@ -331,6 +341,17 @@ export function Widget() { }), listen('refresh-now', () => refresh()), listen('open-peak', () => openPeakRef.current()), + // La visibilidad de la tira es del usuario (#179): cuando la oculta + // desde la bandeja no se vuelve a mostrar por nuestra cuenta, y cuando + // la vuelve a mostrar hay que pedir la colocación de nuevo (la anterior + // pudo quedarse sin leer mientras estaba oculta). + listen('widget-hidden', () => { + hiddenByUser.current = true; + }), + listen('widget-shown', () => { + hiddenByUser.current = false; + setPlacementRetry((n) => n + 1); + }), ]; return () => { controller.abort(); @@ -479,21 +500,53 @@ export function Widget() { // Expand before entering; shrink only after the exit transition. Hovering between // providers never changes this window, and each native rectangle is applied atomically. useEffect(() => { - if (dragging) { widgetBounds.current = null; return; } + if (dragging) { + // El arrastre manda: cancela la estabilización pendiente (#179). + try { + api.cancelWidgetPlacement().catch(() => {}); + } catch { + /* un backend sin este comando no impide arrastrar */ + } + widgetBounds.current = null; + return; + } if (!geo || !stripSize) return; let disposed = false; const s = stripRect(geo.area, edge, offset, stripSize.w, stripSize.h); const win = usesPeekWindow(phase) ? peekRect(s, edge) : s; - const physical = physicalPanelBounds(win, geo.sf); + // La petición lleva el borde anclado y el factor con el que se calculó el + // rectángulo: con otro tamaño efectivo, el borde se conserva y la + // conversión no depende del factor de la ventana (DPI mixto) (#179). + const physical = { ...physicalPanelBounds(win, geo.sf), anchor: edge, scale: geo.sf }; setPos({ strip: { x: s.x - win.x, y: s.y - win.y } }); placementInFlight.current = true; widgetQueue(async (current) => { if (disposed || !current()) return; - if (!samePhysical(widgetBounds.current, physical)) { + let limited = false; + let reason: WidgetPlacementReason | null = null; + // La comparación con la geometría cacheada usa la tolerancia del + // backend **sólo** si esa geometría vino del read-back de X11: en el + // resto de plataformas un cambio de 1 px sí es un cambio (#179). + const same = samePlacement(widgetBounds.current, physical, widgetBoundsReadBack.current); + // El backend es la autoridad: responde con la geometría que quedó en + // pantalla, no con la solicitud encolada (#179). + const apply = (report: WidgetPlacementReport) => { + const outcome = placementOutcome(report, physical); + // Sólo se cachea geometría fiable: una colocación cancelada (quedó a + // medias) o una lectura fallida dejan la caché vacía. + if (outcome.bounds) { + widgetBounds.current = outcome.bounds; + widgetBoundsReadBack.current = outcome.readBack; + } + limited = outcome.failed; + reason = outcome.reason; + }; + if (!same) { widgetBounds.current = null; // Never reuse a cache invalidated by a stale in-flight move. - await api.setWidgetBounds(physical); + widgetBoundsReadBack.current = false; + const report = await api.setWidgetBounds(physical); if (disposed || !current()) return; - widgetBounds.current = physical; + apply(report); } const surface = usesPeekWindow(phase) ? peekRef.current : stripRef.current; if (!surface) throw new Error('Widget surface is not mounted'); @@ -502,14 +555,26 @@ export function Widget() { // blur rectangle and no blur-radius change at the start/end of the animation. await clipSurfaceSafely(clip, (c) => api.setSurfaceClip(c, !autoHide && phase === 'open'), console.warn, surface); if (disposed || !current()) return; - if (!shown.current) { + if (!shown.current && !hiddenByUser.current) { + // La visibilidad es del frontend: el backend nunca muestra la ventana. await getCurrentWindow().show(); if (disposed || !current()) return; shown.current = true; } + // El backend no puede leer una ventana que todavía no es visible: se + // vuelve a pedir la colocación para confirmar la geometría (#179). + if (!same && reason === 'not_viewable' && !hiddenByUser.current) { + const report = await api.setWidgetBounds(physical); + if (disposed || !current()) return; + apply(report); + } if (phase === 'preparing' || phase === 'settling') await waitForViewport(); if (disposed || !current()) return; setPlacementFailed(false); + // El gestor no concedió la geometría: se informa (con su motivo) y la + // tira ofrece reintento. + if (limited) setPlacementFailed(true); + setPlacementReason(limited ? reason : null); if (phase === 'preparing' || phase === 'settling') markPrepared(phase); }).catch((error) => { if (disposed) return; @@ -774,7 +839,10 @@ export function Widget() { // Red de seguridad: si algo falla al medir/colocar, mostramos igualmente la ventana. useEffect(() => { const t = window.setTimeout(() => { - if (!shown.current) { + // La visibilidad es del usuario (#179): una tira oculta desde la bandeja + // no se vuelve a mostrar por nuestra cuenta, ni siquiera por la red de + // seguridad. + if (!shown.current && !hiddenByUser.current) { shown.current = true; getCurrentWindow().show().catch(() => {}); } @@ -952,6 +1020,7 @@ export function Widget() { data-auto-hide={showHandle || undefined} data-reveal={phase} data-placement-failed={placementFailed || undefined} + data-placement-reason={placementReason ?? undefined} onFocusCapture={() => { setFocused(true); setCollapsed(false); }} onBlurCapture={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); }} style={{ '--panel-alpha': String(opacity), '--ring-1': ring1, '--ring-2': ring2, '--ring-3': ring3 } as CSSProperties} @@ -970,7 +1039,8 @@ export function Widget() { ref={peekRef} type="button" className="peek" - aria-label={placementFailed ? t(locale, 'widget.retryShow') : t(locale, 'widget.show')} + aria-label={placementFailed ? `${t(locale, 'widget.retryShow')}${placementReasonText ? `: ${placementReasonText}` : ''}` : t(locale, 'widget.show')} + title={placementFailed ? placementReasonText ?? undefined : undefined} aria-controls="quorb-strip" aria-hidden={phase === 'open' && !peekFocused || undefined} tabIndex={phase === 'hidden' || placementFailed && (usesPeekWindow(phase) || phase === 'preparing') ? 0 : -1} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index d38f5d4..5d54038 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -247,6 +247,11 @@ export const en = { updateTo: 'Update to Quorb {version}', refreshNow: 'Update now', settings: 'Settings', + reasonSizeRefused: 'the window manager did not grant the size', + reasonPositionRefused: 'the window manager did not place the strip where it was asked', + reasonNotViewable: 'the native window is not visible yet', + reasonReadFailed: 'the native geometry could not be read', + reasonSuperseded: 'the placement was replaced by another one', }, details: { range24h: '24 h', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 1c0acb5..36c4e0f 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -260,6 +260,11 @@ export const es = { updateTo: 'Actualizar a Quorb {version}', refreshNow: 'Actualizar ahora', settings: 'Ajustes', + reasonSizeRefused: 'el gestor no concedió el tamaño', + reasonPositionRefused: 'el gestor no colocó la tira donde se pidió', + reasonNotViewable: 'la ventana nativa todavía no es visible', + reasonReadFailed: 'no se pudo leer la geometría nativa', + reasonSuperseded: 'la colocación fue sustituida por otra', }, details: { range24h: '24 h', diff --git a/src/lib/api.ts b/src/lib/api.ts index 9239d62..d81a315 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -4,6 +4,7 @@ import type { AlertMoment, AppConfig, Effect, Sample, ScopedSecretKey, SecretSta 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'; /** Campos que el widget puede cambiar sin arrastrar el resto de la config. */ export interface WidgetPatch { @@ -72,7 +73,16 @@ export const api = { openSettings: () => invoke('open_settings'), showDetail: () => invoke('show_detail'), setDetailBounds: (bounds: PhysicalBounds) => invoke('set_detail_bounds', { bounds }), - setWidgetBounds: (bounds: PhysicalBounds) => invoke('set_widget_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. + * La petición lleva el borde anclado y el factor con el que se calculó el + * rectángulo: con otro tamaño efectivo, el borde se conserva y la conversión + * no depende del factor de la ventana (DPI mixto). + */ + setWidgetBounds: (placement: WidgetPlacementRequest) => invoke('set_widget_bounds', { bounds: placement }), + /** Cancela la colocación pendiente: el usuario empezó a arrastrar la tira (#179). */ + cancelWidgetPlacement: () => invoke('cancel_widget_placement'), setSurfaceClip: (clip: SurfaceClip, backdrop = true) => invoke('set_surface_clip', { clip, backdrop }), setTrayTooltip: (text: string) => invoke('set_tray_tooltip', { text }), getHistory: (provider: string, hours: number) => invoke('get_history', { provider, hours }), diff --git a/src/lib/auto-hide.test.ts b/src/lib/auto-hide.test.ts index efc7e72..9a4cc4e 100644 --- a/src/lib/auto-hide.test.ts +++ b/src/lib/auto-hide.test.ts @@ -35,6 +35,8 @@ const widgetEffects = vi.hoisted(() => ({ effects: [] as { run: () => void | (() => void); deps?: readonly unknown[] }[], bounds: vi.fn(), clip: vi.fn(), show: vi.fn(), innerSize: vi.fn(), outerPosition: vi.fn(), setPosition: vi.fn(), monitors: vi.fn(), patch: vi.fn(), stateWrite: vi.fn(), + cancel: vi.fn(async () => {}), + listeners: new Map void)[]>(), })); vi.mock('react', async (importOriginal) => { const react = await importOriginal() as typeof import('react'); @@ -67,6 +69,7 @@ vi.mock('./api', () => ({ api: { setWidgetBounds: widgetEffects.bounds, setSurfaceClip: widgetEffects.clip, patchConfig: widgetEffects.patch, + cancelWidgetPlacement: widgetEffects.cancel, } })); vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => ({ @@ -75,6 +78,15 @@ vi.mock('@tauri-apps/api/window', () => ({ }), availableMonitors: widgetEffects.monitors, })); +vi.mock('@tauri-apps/api/event', () => ({ + listen: vi.fn(async (name: string, handler: () => void) => { + const list = widgetEffects.listeners.get(name) ?? []; + list.push(handler); + widgetEffects.listeners.set(name, list); + return () => {}; + }), + emitTo: vi.fn(), +})); describe('auto-hide geometry and phases', () => { it.each(['top', 'bottom', 'left', 'right'] as Edge[])('keeps only a centered semicircle inside the strip on %s', (edge) => { @@ -294,6 +306,82 @@ describe('Widget effect mutation ordering', () => { widgetEffects.effects = []; vi.resetAllMocks(); vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('cancels the pending native placement when the strip starts to be dragged', () => { + configureNative(); + const { placement } = renderWidget(true); // dragging + const cleanup = placement.run(); + expect(widgetEffects.cancel).toHaveBeenCalledTimes(1); + if (typeof cleanup === 'function') cleanup(); + }); + + it('asks again for the placement when the backend could not read the window', async () => { + configureNative(); + // El backend no pudo leer una ventana que todavía no era visible: tras + // mostrarla, la colocación se pide de nuevo para confirmar la geometría. + widgetEffects.bounds.mockResolvedValue({ status: 'unverified', bounds: null, reason: 'not_viewable' }); + const cleanup = renderWidget(false).placement.run(); + await flush(); + expect(widgetEffects.bounds).toHaveBeenCalledTimes(2); + expect(widgetEffects.show).toHaveBeenCalledTimes(1); + if (typeof cleanup === 'function') cleanup(); + }); + + it('never shows a strip the user hid from the tray, nor re-asks its placement', async () => { + configureNative(); + const { placement } = renderWidget(false); + // El usuario oculta la tira desde la bandeja: la visibilidad es suya. + const listener = widgetEffects.effects.find((e) => e.run.toString().includes('widget-hidden')); + expect(listener).toBeDefined(); + const cleanupListener = listener!.run(); + await flush(); + for (const handler of widgetEffects.listeners.get('widget-hidden') ?? []) handler(); + widgetEffects.bounds.mockResolvedValue({ status: 'unverified', bounds: null, reason: 'not_viewable' }); + const cleanup = placement.run(); + await flush(); + expect(widgetEffects.show).not.toHaveBeenCalled(); + expect(widgetEffects.bounds).toHaveBeenCalledTimes(1); + if (typeof cleanup === 'function') cleanup(); + if (typeof cleanupListener === 'function') cleanupListener(); + }); + + it('never shows a strip the user hid from the tray, not even through the safety net', async () => { + configureNative(); + vi.useFakeTimers(); + vi.stubGlobal('window', { + setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout, + }); + renderWidget(false); + // El usuario oculta la tira desde la bandeja: la visibilidad es suya. + const listener = widgetEffects.effects.find((e) => e.run.toString().includes('widget-hidden')); + const cleanupListener = listener!.run(); + await flush(); + for (const handler of widgetEffects.listeners.get('widget-hidden') ?? []) handler(); + // La red de seguridad (2,5 s) muestra la ventana si el ciclo no llegó a + // mostrarla: tampoco puede contradecir al usuario. + const safetyNet = widgetEffects.effects.find((e) => e.deps?.length === 0 && e.run.toString().includes('setTimeout')); + expect(safetyNet).toBeDefined(); + const cleanup = safetyNet!.run(); + await vi.advanceTimersByTimeAsync(2500); + expect(widgetEffects.show).not.toHaveBeenCalled(); + if (typeof cleanup === 'function') cleanup(); + if (typeof cleanupListener === 'function') cleanupListener(); + }); + + it('shows the strip through the safety net when the user did not hide it', async () => { + configureNative(); + vi.useFakeTimers(); + vi.stubGlobal('window', { + setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout, + }); + renderWidget(false); + const safetyNet = widgetEffects.effects.find((e) => e.deps?.length === 0 && e.run.toString().includes('setTimeout')); + const cleanup = safetyNet!.run(); + await vi.advanceTimersByTimeAsync(2500); + expect(widgetEffects.show).toHaveBeenCalledTimes(1); + if (typeof cleanup === 'function') cleanup(); }); it.each([true, false])('last accepted RAF drains before pointerup snap (delayBeforeDispatch=%s)', async (delayBeforeDispatch) => { @@ -696,3 +784,60 @@ describe('Widget effect mutation ordering', () => { if (typeof cleanupDrag === 'function') cleanupDrag(); }); }); + +describe('the localized placement reason', () => { + const geo = { + area: { x: 0, y: 0, w: 1920, h: 1080 }, sf: 1, monitor: null, + screen: { x: 0, y: 0, width: 1920, height: 1080 }, + }; + // Hook slots of Widget (useDockReveal/useUpdaterMode are isolated above): + // 0 config, 5 geo, 6 stripSize, 16 placementFailed, 17 placementReason. + const markup = (locale: 'es' | 'en' = 'es', failed = true) => { + widgetEffects.stateIndex = 0; + widgetEffects.refIndex = 0; + widgetEffects.effects = []; + widgetEffects.states[0] = { autoHide: true, locale, accounts: [], nineRouter: { baseUrl: '' } }; + widgetEffects.states[5] = geo; + widgetEffects.states[6] = { w: 400, h: 80 }; + widgetEffects.states[16] = failed; + widgetEffects.states[17] = failed ? 'size_refused' : null; + return renderToStaticMarkup(createElement(Widget)); + }; + + afterEach(() => { + widgetEffects.states = []; + widgetEffects.refs = []; + widgetEffects.effects = []; + vi.resetAllMocks(); + vi.unstubAllGlobals(); + }); + + it('carries the machine-readable code and shows the localized reason', () => { + const es = markup('es'); + // El atributo lleva el código, no el texto: es machine-readable. + expect(es).toContain('data-placement-reason="size_refused"'); + // El texto visible sale del catálogo, en el idioma de la ventana. + expect(es).toContain('title="el gestor no concedió el tamaño"'); + expect(es).toContain('aria-label="Reintentar mostrar Quorb: el gestor no concedió el tamaño"'); + // El código aparece una sola vez: en el atributo machine-readable, nunca + // en el texto visible. + expect(es.split('size_refused').length - 1).toBe(1); + + const en = markup('en'); + expect(en).toContain('data-placement-reason="size_refused"'); + expect(en).toContain('title="the window manager did not grant the size"'); + expect(en).toContain('aria-label="Retry showing Quorb: the window manager did not grant the size"'); + }); + + it('keeps the upstream retry text when no placement failed', () => { + // Sin fallo de colocación el botón conserva el texto del catálogo de + // upstream (#195): ni motivo, ni título, ni atributo de motivo. + const es = markup('es', false); + expect(es).toContain('aria-label="Mostrar Quorb"'); + expect(es).not.toContain('data-placement-reason'); + expect(es).not.toContain('size_refused'); + const en = markup('en', false); + expect(en).toContain('aria-label="Show Quorb"'); + expect(en).not.toContain('data-placement-reason'); + }); +}); diff --git a/src/lib/widget-placement.test.ts b/src/lib/widget-placement.test.ts new file mode 100644 index 0000000..1ac4199 --- /dev/null +++ b/src/lib/widget-placement.test.ts @@ -0,0 +1,204 @@ +// Contrato de la colocación nativa de la tira (#179). +// +// El backend es la autoridad: la geometría que se cachea es la que él +// informó, no la solicitud. Estas pruebas fijan ese contrato, el del motivo +// que ve el usuario y el de la llamada IPC. + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { t } from '../i18n'; +import { + placementOutcome, + reasonKey, + samePhysicalWithin, + samePlacement, + type WidgetPlacementReason, + type WidgetPlacementReport, + type WidgetPlacementRequest, +} from './widget-placement'; +import type { PhysicalBounds } from './detail-panel'; + +const requested: PhysicalBounds = { x: 1859, y: 899, w: 61, h: 181 }; +const effective: PhysicalBounds = { x: 1859, y: 899, w: 61, h: 181 }; + +const report = (over: Partial = {}): WidgetPlacementReport => ({ + status: 'applied', + bounds: effective, + ...over, +}); + +/** El motivo que ve el usuario: el código se traduce con el catálogo ES/EN. */ +const reasonText = (reason: WidgetPlacementReason | null, locale: 'es' | 'en' = 'es'): string | null => { + if (!reason) return null; + const key = reasonKey(reason); + return key ? t(locale, key) : null; +}; + +describe('placementOutcome', () => { + it('caches the geometry the backend reported, not the request', () => { + const outcome = placementOutcome(report({ bounds: { x: 10, y: 20, w: 30, h: 40 } }), requested); + expect(outcome.bounds).toEqual({ x: 10, y: 20, w: 30, h: 40 }); + }); + + it('asks for a retry and keeps the reason when the manager did not grant the geometry', () => { + const outcome = placementOutcome( + report({ status: 'limited', bounds: { x: 0, y: 0, w: 320, h: 260 }, reason: 'size_refused' }), + requested, + ); + expect(outcome.failed).toBe(true); + // El motivo del backend llega al estado accesible del widget, localizado. + expect(outcome.reason).toBe('size_refused'); + expect(reasonText(outcome.reason)).toBe('el gestor no concedió el tamaño'); + expect(outcome.bounds).toEqual({ x: 0, y: 0, w: 320, h: 260 }); + }); + + it('keeps the current behaviour where there is no read-back', () => { + // Wayland nativo, Windows y macOS no leen la geometría: no es un fallo. + const outcome = placementOutcome(report({ status: 'unverified', bounds: null }), requested); + expect(outcome.failed).toBe(false); + expect(outcome.bounds).toEqual(requested); + }); + + it('does not cache a cancelled placement as a trustworthy drag origin', () => { + // Una colocación cancelada quedó a medias: su geometría no es el origen. + const outcome = placementOutcome(report({ status: 'superseded', bounds: null }), requested); + expect(outcome.bounds).toBeNull(); + expect(outcome.failed).toBe(false); + }); + + it('does not cache anything when the native read failed', () => { + const outcome = placementOutcome( + report({ status: 'unverified', bounds: null, reason: 'read_failed' }), + requested, + ); + expect(outcome.bounds).toBeNull(); + expect(outcome.failed).toBe(true); + expect(outcome.reason).toBe('read_failed'); + expect(reasonText(outcome.reason)).toBe('no se pudo leer la geometría nativa'); + }); + + it('does not cache anything when the native window was not viewable yet', () => { + // Sin lectura no hay geometría fiable: la solicitud tampoco se cachea y + // el motivo llega al reintento. + const outcome = placementOutcome( + report({ status: 'unverified', bounds: null, reason: 'not_viewable' }), + requested, + ); + expect(outcome.bounds).toBeNull(); + expect(outcome.failed).toBe(true); + expect(outcome.reason).toBe('not_viewable'); + expect(reasonText(outcome.reason)).toBe('la ventana nativa todavía no es visible'); + expect(reasonText(outcome.reason, 'en')).toBe('the native window is not visible yet'); + }); + + it('caches the requested geometry when the backend does not answer', () => { + // Sin informe del backend no hay lectura: se conserva la geometría pedida + // (sin darla por verificada), que es lo que el widget puede arrastrar. + const outcome = placementOutcome(undefined, requested); + expect(outcome.bounds).toEqual(requested); + expect(outcome.failed).toBe(false); + expect(outcome.reason).toBeNull(); + }); +}); + +describe('the reason codes', () => { + it('maps every backend code to its catalog key', () => { + expect(reasonKey('size_refused')).toBe('widget.reasonSizeRefused'); + expect(reasonKey('position_refused')).toBe('widget.reasonPositionRefused'); + expect(reasonKey('not_viewable')).toBe('widget.reasonNotViewable'); + expect(reasonKey('read_failed')).toBe('widget.reasonReadFailed'); + expect(reasonKey('superseded')).toBe('widget.reasonSuperseded'); + expect(reasonKey(null)).toBeNull(); + expect(reasonKey(undefined)).toBeNull(); + }); + + it('carries no user-facing text: every key is translated by the catalog', () => { + const codes: WidgetPlacementReason[] = [ + 'size_refused', + 'position_refused', + 'not_viewable', + 'read_failed', + 'superseded', + ]; + for (const code of codes) { + const key = reasonKey(code)!; + // La clave existe en los dos catálogos y su texto no es la clave. + expect(t('es', key), `es · ${code}`).not.toBe(key); + expect(t('en', key), `en · ${code}`).not.toBe(key); + } + }); +}); + +describe('the dedup tolerance', () => { + it('treats a geometry rounded by the window manager as the same one', () => { + // El backend redondea a píxeles de dispositivo: con escala 1,5 la + // diferencia es de 1 px físico y no debe re-emitir la colocación. + expect(samePhysicalWithin({ x: 1859, y: 899, w: 62, h: 181 }, { x: 1859, y: 899, w: 61, h: 181 })).toBe(true); + expect(samePhysicalWithin(null, requested)).toBe(false); + // Un cambio real (2 px o más) sí se detecta: la tolerancia es menor que + // el margen real, así que no hay oscilación. + expect(samePhysicalWithin({ x: 1859, y: 899, w: 63, h: 181 }, requested)).toBe(false); + expect(samePhysicalWithin({ x: 1859, y: 901, w: 61, h: 181 }, requested)).toBe(false); + expect(samePhysicalWithin({ x: 1859, y: 899, w: 61, h: 181 }, requested)).toBe(true); + }); + + it('applies the tolerance only to the geometry that came from the X11 read-back', () => { + // El read-back de X11: el gestor redondea el tamaño lógico a píxeles de + // dispositivo, así que 1 px de diferencia es el mismo tamaño. + expect(samePlacement({ x: 1859, y: 899, w: 62, h: 181 }, requested, true)).toBe(true); + expect(samePlacement({ x: 1859, y: 899, w: 63, h: 181 }, requested, true)).toBe(false); + // La posición también pasa por la conversión de escala (píxeles lógicos ⇄ + // físicos): 1 px de diferencia es la misma posición. + expect(samePlacement({ x: 1860, y: 899, w: 61, h: 181 }, requested, true)).toBe(true); + expect(samePlacement({ x: 1861, y: 899, w: 61, h: 181 }, requested, true)).toBe(false); + // Sin read-back (Windows, macOS, Wayland nativo) un cambio de 1 px **sí** + // es un cambio: la comparación es exacta. + expect(samePlacement({ x: 1859, y: 899, w: 62, h: 181 }, requested, false)).toBe(false); + expect(samePlacement({ x: 1860, y: 899, w: 61, h: 181 }, requested, false)).toBe(false); + expect(samePlacement({ x: 1859, y: 899, w: 61, h: 181 }, requested, false)).toBe(true); + // Y sin caché no hay comparación. + expect(samePlacement(null, requested, true)).toBe(false); + expect(samePlacement(null, requested, false)).toBe(false); + }); +}); + +describe('the read-back marker', () => { + it('carries the read-back flag so only that geometry uses the tolerance', () => { + const measured = placementOutcome(report({ read_back: true }), requested); + expect(measured.readBack).toBe(true); + expect(measured.bounds).toEqual(effective); + // La ruta que no lee nada (Windows, macOS, Wayland nativo): comparación exacta. + const plain = placementOutcome(report({ read_back: false }), requested); + expect(plain.readBack).toBe(false); + // Y un informe sin geometría no es un read-back. + const none = placementOutcome( + report({ status: 'unverified', bounds: null, reason: 'not_viewable' }), + requested, + ); + expect(none.readBack).toBe(false); + expect(none.bounds).toBeNull(); + }); +}); + +describe('the placement IPC contract', () => { + afterEach(() => { + vi.resetModules(); + vi.doUnmock('@tauri-apps/api/core'); + }); + + it('asks the backend for the placement with the anchored edge and the scale', async () => { + const invoke = vi.fn(async () => report()); + vi.doMock('@tauri-apps/api/core', () => ({ invoke })); + const { api } = await import('./api'); + const placement: WidgetPlacementRequest = { ...requested, anchor: 'right', scale: 1.5 }; + expect(await api.setWidgetBounds(placement)).toEqual(report()); + expect(invoke).toHaveBeenCalledWith('set_widget_bounds', { bounds: placement }); + }); + + it('cancels a pending placement when the strip starts to be dragged', async () => { + const invoke = vi.fn(async () => undefined); + vi.doMock('@tauri-apps/api/core', () => ({ invoke })); + const { api } = await import('./api'); + await api.cancelWidgetPlacement(); + expect(invoke).toHaveBeenCalledWith('cancel_widget_placement'); + }); +}); diff --git a/src/lib/widget-placement.ts b/src/lib/widget-placement.ts new file mode 100644 index 0000000..260f059 --- /dev/null +++ b/src/lib/widget-placement.ts @@ -0,0 +1,161 @@ +// Colocación nativa de la tira (#179). +// +// El backend es la autoridad sobre la geometría nativa efectiva: responde con +// lo que quedó en pantalla, nunca con la solicitud encolada. Este módulo +// decide qué se cachea con esa respuesta, si hay que ofrecer el reintento y +// qué motivo ve el usuario. + +import { samePhysical, type PhysicalBounds } from './detail-panel'; +import type { MessageKey } from '../i18n'; + +/** Borde del área de trabajo al que la tira está anclada. */ +export type WidgetAnchor = 'top' | 'bottom' | 'left' | 'right'; + +/** + * Lo que se pide al backend: el rectángulo, el borde anclado y el factor con el + * que se calculó (en DPI mixto, el de la ventana puede ser otro). + */ +export interface WidgetPlacementRequest extends PhysicalBounds { + anchor: WidgetAnchor; + /** El factor de escala del monitor con el que se calculó el rectángulo. */ + scale: number; +} + +/** + * Códigos de motivo del backend (ASCII): son machine-readable y el widget los + * traduce con el catálogo (`reasonKey`). + */ +export type WidgetPlacementReason = + | 'size_refused' + | 'position_refused' + | 'not_viewable' + | 'read_failed' + | 'superseded'; + +/** + * Informe de colocación nativa. + * + * - `applied`: la geometría efectiva se leyó del compositor y coincide con la + * pedida. + * - `unverified`: la solicitud se aplicó pero no se pudo leer (Wayland nativo, + * Windows, macOS) o la lectura falló. + * - `limited`: el gestor no concedió la geometría pedida. + * - `superseded`: la colocación se canceló y su geometría quedó a medias: no + * es un origen fiable. + */ +export interface WidgetPlacementReport { + status: 'applied' | 'unverified' | 'limited' | 'superseded'; + /** La geometría efectiva leída; `null` si no se pudo leer o se canceló. */ + bounds: PhysicalBounds | null; + /** El código del motivo, si lo hay. */ + reason?: WidgetPlacementReason | null; + /** + * True si la geometría vino del read-back del compositor (X11). Sólo esa + * geometría admite la tolerancia del redondeo: la que se pidió (Windows, + * macOS, Wayland nativo) se compara exacta. + */ + read_back?: boolean; +} + +/** + * Clave de catálogo con la etiqueta de cada código de motivo. El módulo no + * lleva texto de interfaz: la traducción la hace el widget con `t(locale, key)`. + */ +const REASON_KEYS: Record = { + size_refused: 'widget.reasonSizeRefused', + position_refused: 'widget.reasonPositionRefused', + not_viewable: 'widget.reasonNotViewable', + read_failed: 'widget.reasonReadFailed', + superseded: 'widget.reasonSuperseded', +}; + +/** La clave de catálogo del motivo; `null` si no hay motivo. */ +export const reasonKey = (reason?: WidgetPlacementReason | null): MessageKey | null => + reason ? REASON_KEYS[reason] ?? null : null; + +/** + * Margen físico con el que se comparan dos geometrías: el mismo que usa el + * backend. Con 1 px de margen un cambio real (2 px o más) sigue detectándose, + * así que no hay oscilación. + */ +export const PLACEMENT_TOLERANCE = 1; + +/** True cuando dos geometrías son la misma dentro de la tolerancia. */ +export const samePhysicalWithin = ( + a: PhysicalBounds | null, + b: PhysicalBounds, + tolerance = PLACEMENT_TOLERANCE, +): boolean => + !!a && + Math.abs(a.x - b.x) <= tolerance && + Math.abs(a.y - b.y) <= tolerance && + Math.abs(a.w - b.w) <= tolerance && + Math.abs(a.h - b.h) <= tolerance; + +/** + * Compara la geometría cacheada con la pedida. + * + * La tolerancia de ±1 px es del read-back de X11 (el gestor redondea el + * tamaño lógico a píxeles de dispositivo), así que **sólo** se aplica a la + * geometría que vino del compositor. En el resto de plataformas (y en + * cualquier ruta sin read-back) la comparación es exacta: un cambio de 1 px + * sí es un cambio y se aplica. + */ +export const samePlacement = ( + cached: PhysicalBounds | null, + requested: PhysicalBounds, + readBack: boolean, +): boolean => + cached === null + ? false + : readBack + ? samePhysicalWithin(cached, requested) + : samePhysical(cached, requested); + +/** Sólo la geometría física: la petición también lleva el ancla y el factor. */ +const physicalOnly = (bounds: PhysicalBounds): PhysicalBounds => ({ + x: bounds.x, + y: bounds.y, + w: bounds.w, + h: bounds.h, +}); + +/** Qué hace la tira con el informe de colocación. */ +export interface PlacementOutcome { + /** La geometría que queda cacheada como origen del arrastre; null = no cachear. */ + bounds: PhysicalBounds | null; + /** True cuando el gestor no concedió la geometría: hay que ofrecer reintento. */ + failed: boolean; + /** El código del motivo (machine-readable), no su texto: lo traduce el widget. */ + reason: WidgetPlacementReason | null; + /** True si la geometría cacheada vino del read-back del compositor. */ + readBack: boolean; +} + +/** + * Traduce el informe nativo a la decisión del widget. + * + * La geometría cacheada es siempre la que el backend informó: la solicitud + * nunca se cachea como verificada. Sin informe se conserva la geometría + * pedida (sin darla por verificada); una colocación cancelada, una lectura + * fallida o una ventana que no llegó a ser visible no cachean nada: no hay + * geometría fiable. + */ +export const placementOutcome = ( + report: WidgetPlacementReport | null | undefined, + requested: PhysicalBounds, +): PlacementOutcome => { + if (!report) return { bounds: physicalOnly(requested), failed: false, reason: null, readBack: false }; + if (report.status === 'superseded') return { bounds: null, failed: false, reason: null, readBack: false }; + const reason = report.reason ?? null; + // Sin lectura fiable no hay geometría que cachear: ni la solicitud. + if (report.reason === 'read_failed' || report.reason === 'not_viewable') { + return { bounds: null, failed: true, reason, readBack: false }; + } + return { + bounds: report.bounds ?? physicalOnly(requested), + failed: report.status === 'limited', + reason, + readBack: report.read_back === true, + }; +};