Skip to content

bug(widget): la tira y el panel de detalle se cortan en Windows con el tamaño de texto por encima de 100 % #228

Open

Description

@matraket

Pre-flight checks

  • I searched existing issues and found no duplicate.
  • I understand that this report enters review before implementation.

What happened?

Qué pasa

En Windows, la tira y el panel de detalle de cada proveedor se ven cortados por el borde derecho, como si tuvieran un ancho fijo. En el panel quedan cortados los porcentajes, las barras y la esquina redondeada, que se ve como un borde recto. En la tira se corta el último anillo y los iconos de acciones.

Pasa desde la instalación. No es una regresión reciente.

Causa

La app pasa los tamaños de píxeles CSS a píxeles físicos usando el scaleFactor del monitor, que solo tiene en cuenta la escala de pantalla. WebView2, en cambio, pinta el contenido con devicePixelRatio, que combina la escala de pantalla y el tamaño de texto de Accesibilidad. Si el tamaño de texto es mayor que 100 %, cada ventana nativa queda más pequeña que su contenido en esa misma proporción.

En mi caso:

  • escala personalizada 102 %, así que scaleFactor = 1,02
  • tamaño de texto 106 %, así que devicePixelRatio ≈ 1,02 × 1,06 ≈ 1,081
  • la ventana queda un ~6 % más estrecha que su contenido, unos 20 px en un panel de ~330 px

Lo comprobé así: con el tamaño de texto al 100 % (y pulsando «Aplicar»), las dos superficies se ven bien.

Dónde ocurre:

  • src/Widget.tsx geoOf: sf = m.scaleFactor.
  • Tira: src/Widget.tsx mide la tira en CSS px (ResizeObserver) y la convierte con physicalPanelBounds(win, geo.sf) antes de set_widget_bounds.
  • Detalle: src/DetailPanel.tsx convierte con physicalPanelBounds(detailBounds(data, next), data.scaleFactor) antes de set_detail_bounds. El recorte de superficie también escala con data.scaleFactor.
  • src/lib/detail-panel.ts physicalPanelBounds: multiplica posición y tamaño por el mismo factor.
  • src-tauri/src/detail_geometry.rs: SetWindowPos aplica esos píxeles tal cual.

Contexto upstream: Tauri todavía no expone el factor de texto (tauri-apps/tauri#14711, tauri-apps/tao#1168 abierta). La documentación de WebView2 (ICoreWebView2Controller3::get_RasterizationScale) lo confirma: «The rasterization scale is the combination of the monitor DPI scale and text scaling set by the user».

Propuesta de solución

Separar las dos conversiones:

  1. Posiciones con scaleFactor, como ahora, porque el área de trabajo viene en unidades del monitor.
  2. Tamaños con el window.devicePixelRatio real de la ventana que contiene el contenido: el de la ventana del widget para la tira y el de la ventana de detalle para el panel. Lo mismo para la escala del recorte de superficie.
  3. Tests unitarios de la conversión con devicePixelRatio ≠ scaleFactor (por ejemplo 1,02 / 1,0812 y 1,25 / 1,5). Hoy ningún test cubre ese caso. Tests de no regresión para devicePixelRatio == scaleFactor (100 %, 125 %, 200 %), que debe dar el mismo resultado que ahora.
  4. Escuchar los cambios de devicePixelRatio (matchMedia('(resolution: …dppx)')) para recolocar si el usuario cambia el tamaño de texto con la app abierta.

Alternativa que descarto: pasar --force-text-scale-factor=1 a WebView2 en los argumentos del navegador. Haría que Quorb ignore el tamaño de texto del usuario, que es justo un ajuste de accesibilidad, y no he comprobado que funcione.

Preguntas para cerrar la spec:

  • ¿Os parece bien el enfoque de separar posiciones y tamaños, o preferís esperar a que Tauri exponga el factor de texto?
  • En Linux y macOS, devicePixelRatio y scaleFactor deberían coincidir, así que el cambio no tendría efecto. ¿Lo limitamos a Windows o lo dejamos general?

Steps to reproduce

  1. En Windows 11, ir a Configuración > Accesibilidad > Tamaño de texto, subirlo por encima de 100 % (por ejemplo 110 %) y pulsar «Aplicar».
  2. Abrir Quorb, o cerrarlo desde la bandeja y volver a abrirlo.
  3. Mirar la tira y abrir el panel de detalle de un proveedor.

Expected behavior

Que la tira y el panel se vean enteros, con el contenido y las esquinas redondeadas dentro de la ventana, sea cual sea el tamaño de texto de Windows.

Environment

Windows 11
Quorb: v0.8.0 (pasaba también en versiones anteriores, desde la instalación)
Escala de pantalla: personalizada 102 %
Tamaño de texto (Accesibilidad): 106 %
Reproducido con un monitor (en casa); en el trabajo uso 3 monitores, no medido con esta configuración

Affected area

Application behavior

Activity

  1. egdev6 commented on Nov 1, 2026

    @egdev6
    Owner

    Gracias por el reporte tan detallado, @matraket. Lo revisé contra main y el diagnóstico encaja con el código: src/Widget.tsx:62 toma sf = m.scaleFactor, y tanto la tira (Widget.tsx:524) como el detalle (DetailPanel.tsx:122) convierten medidas CSS con ese factor. Si WebView2 pinta con un devicePixelRatio que incluye el tamaño de texto, la ventana queda más pequeña que su contenido, como describe la documentación que citas.

    Sobre la propuesta, separar "posiciones con scaleFactor" y "tamaños con devicePixelRatio" no es suficiente, porque la posición depende del tamaño. stripRect (Widget.tsx:519) calcula, por ejemplo, una tira anclada a la derecha como borde menos ancho, usando el ancho medido en CSS. Si después el tamaño se convierte con otro factor, la ventana crece pero su posición no se corrige, y la tira se saldría del borde en la misma proporción en que hoy se corta.

    Propongo normalizar antes de calcular la geometría:

    1. Convertir cada medida CSS a unidades del monitor (css × devicePixelRatio / scaleFactor) al medir, usando el devicePixelRatio de la ventana que contiene ese contenido.
    2. Calcular la geometría en esas unidades y mantener una única conversión final con scaleFactor, como ahora.
    3. Escalar el recorte de superficie con devicePixelRatio (en el detalle, zoom × devicePixelRatio).

    Hay otro punto afectado que conviene incluir: el arrastre (Widget.tsx:1065-1066) multiplica el desplazamiento del puntero por sf, así que con el texto por encima del 100 % la tira probablemente se quede atrás del cursor en esa misma proporción.

    Sobre tus preguntas:

    • No hace falta esperar a Tauri: el devicePixelRatio real ya se puede leer en cada ventana.
    • Prefiero aplicarlo de forma general, no solo en Windows. Cuando devicePixelRatio == scaleFactor el resultado es idéntico al actual, y los tests de no regresión que propones lo garantizan sin añadir ramas por plataforma. Escuchar los cambios de devicePixelRatio también cubre mover la ventana a otro monitor.

    Los tests que propones me parecen bien, añadiendo casos de tira anclada a derecha e inferior para comprobar que el borde se conserva. No lo he podido reproducir porque no tengo un equipo con Windows a mano, así que la validación manual con tus valores (102 % / 106 %) será importante en la PR.

  2. matraket commented on Nov 1, 2026

    @matraket
    ContributorAuthor

    Gracias por la revisión, @egdev6. Tienes razón en lo central: si la posición se calcula como borde - tamaño con el tamaño en CSS, convertir solo el tamaño desplaza la tira. Adopto tu enfoque de normalizar antes de calcular la geometría. Al revisarlo con detalle encontré un ajuste en el factor y varios puntos más que también hay que cubrir. Propongo esta spec para cerrarla.

    1. El factor correcto es la escala de texto, t, no dpr / geo.sf

    css × devicePixelRatio / scaleFactor solo es correcto si las dos cifras son del mismo monitor, y el código mezcla monitores:

    • geo.sf es el del monitor destino (geoOf/resolveGeo).
    • devicePixelRatio es el del monitor donde está ahora la ventana.

    Hay tres casos en que no coinciden: al cambiar el monitor en Ajustes (el efecto de colocación corre con la ventana todavía en A y geo ya en B), al arrancar con dockMonitor = B mientras la ventana nace en el principal, y en la recuperación tras desconectar un monitor. Con monitores de escala distinta, la conversión saldría mal justo ahí.

    El factor que no depende del monitor es la escala de texto, que en Windows es global:

    t = window.devicePixelRatio / await getCurrentWindow().scaleFactor()
    

    Las dos lecturas son de la misma ventana y en el mismo momento. Cuando la ventana cambia de monitor, dpr y sf cambian pero t se mantiene. Lo cacheamos y lo refrescamos cuando cambia devicePixelRatio.

    2. Invariante

    La geometría se calcula en unidades de monitor. Los px CSS solo entran multiplicando por t y solo salen a CSS dividiendo por t. A físico se pasa una sola vez, con geo.sf, como ahora.

    Un módulo puro nuevo, src/lib/text-scale.ts, con textScale(dpr, sf) (con guardas: valores no finitos o ≤ 0 dan 1, y redondeo a 4 decimales para que el ruido de coma flotante no dispare recolocaciones), toMonitor(css, t) y toCss(monitor, t). Así ningún sitio hace la cuenta a mano.

    3. Sitios que cambian

    Tira (src/Widget.tsx)

    Sitio Hoy Cambio
    stripRect(geo.area, …, stripSize.w, stripSize.h) tamaño en CSS toMonitor(stripSize, t)
    peekRect: constantes 48 / 24 (auto-hide.ts) CSS (coinciden con .peek en styles.css) × t (recibe t)
    setPos({ strip: s - win }) delta en unidades de monitor usado como left/top CSS toCss(…, t), sigue en CSS
    anchorRel del orbe al abrir el detalle CSS → anchor del payload toMonitor
    Recorte en la colocación: measureSurfaceClip(…, geo.sf) sf t × geo.sf (no el dpr vivo, porque la ventana aún puede estar en el otro monitor)
    Recorte durante el arrastre: scale = geo.sf sf devicePixelRatio actual (ahí la ventana sí está donde está)
    Arrastre: d.sf = geo.sf, delta (screenX - sx) × d.sf (move y fallback del drop) sf geo.sf × t capturado en pointerdown
    Centro del grip en el drop: grip.left + w/2 (CSS) × d.sf sf del destino antiguo devicePixelRatio actual en el drop (la ventana puede estar ya en otro monitor)

    Detalle (src/DetailPanel.tsx, src/lib/detail-panel.ts)

    fitPanel está afectado aunque nadie lo había mencionado: compara layoutHeight × base (CSS) con maxHeight (unidades de monitor). Con t > 1 el panel empieza a encogerse tarde, y la ventana queda corta también en altura, no solo en ancho. Propuesta:

    fitPanel(placement, layoutHeight, base, t)
      natural = layoutHeight × base × t           // unidades de monitor
      factor  = natural > maxHeight ? maxHeight / natural : 1
      zoom    = base × factor                     // transform CSS, no lleva t
      width   = placement.width × t × factor
      height  = scaled ? maxHeight : ceil(natural) // se mantiene el maxHeight literal
    • El detalle usa su propio t, medido en su ventana, no el scaleFactor del payload: la ventana del detalle puede estar oculta en otro monitor.
    • strip y anchor ya llegan normalizados desde la tira, así que panelHeightLimit y detailBounds (panelRect) salen correctos sin tocarlos. Con borde bottom/right se conserva y + h = strip.y - 8 y x + w = strip.x - 8.
    • Recorte: next.zoom × t × data.scaleFactor.

    Recolocación al cambiar t en caliente. Hoy nada reacciona: cambiar el tamaño de texto no altera las medidas CSS, así que el ResizeObserver no se dispara y no hay listener de resolución. Hook useTextScale() con matchMedia('(resolution: Xdppx)') (rearmándolo en cada cambio) más onScaleChanged. t entra en las dependencias de la colocación de la tira y del layout del detalle. En la tira lo guardo en un ref o en un estado declarado después de los existentes, porque el harness de auto-hide.test.ts mockea useState por índice de slot.

    4. Lo que NO cambia (y por qué)

    • physicalPanelBounds: es la única conversión final, con sf.
    • Placement.scale = geo.sf: en X11, Rust lo usa para pasar a lógico GTK, y debe ser el factor con el que se calculó el rectángulo.
    • Rust (widget_placement, detail_placement, detail_geometry, window_clip): trabaja en físico, sin suponer css × sf. window_clip::resized reescala por scale/old_scale, y t se cancela en ese cociente.
    • dockOffset: es una fracción entre 0 y 1 sin unidades, así que los config.json guardados siguen siendo válidos.
    • Gap de 8: es una distancia entre ventanas nativas, no texto, así que se queda en unidades de monitor.
    • Overlay de efectos: ya usa devicePixelRatio de forma coherente y ocupa la pantalla completa.
    • Reordenación de orbes (clientX, umbral de 6 px): todo en CSS, consistente.

    En Linux y macOS, t = 1 (el text-scaling de GNOME cambia la fuente, no el devicePixelRatio), así que el resultado es idéntico al actual sin ramas por plataforma, como pedías.

    5. Tests

    Todos sobre funciones puras, sin DOM:

    • textScale: casos normales, guardas (0, NaN, Infinity → 1) y el redondeo.
    • stripRect + peekRect con t: tira anclada a derecha e inferior conservando el borde exacto (lo que pediste), y el peek escalado.
    • fitPanel con t: umbral de encogimiento desplazado, width/height con t, y el caso escalado devolviendo maxHeight literal (sin píxel fantasma).
    • detailBounds bottom/right: y + h = strip.y - 8, x + w = strip.x - 8.
    • Escala del recorte: zoom × t × sf.
    • Arrastre en auto-hide.test.ts: delta físico = delta CSS × sf × t.
    • No regresión: tabla con t = 1 y sf ∈ {1, 1,25, 1,5, 2} que da exactamente lo mismo que ahora. Los mocks existentes (sin devicePixelRatio ni scaleFactor) caen en t = 1, así que los tests actuales no cambian.

    Pares de (dpr, sf) en los casos con t ≠ 1: (1,0812, 1,02) (el mío), (1,25, 1), (1,5, 1,25) y (2,25, 1,5).

    6. Validación manual en Windows (en la PR)

    Ya que tú no tienes Windows a mano, la hago yo y adjunto capturas y valores:

    1. Antes de tocar nada: registrar devicePixelRatio, scaleFactor() y un delta de screenX arrastrando. Así confirmo que bajo escala de texto screenX va en px CSS (físico / dpr). Es la única suposición del arrastre que no he podido comprobar leyendo código.
    2. 102 % / 106 % (mi caso): tira y detalle enteros, con bordes inferior y derecho.
    3. 100 % / 100 %: sin cambios respecto a main.
    4. Cambiar el tamaño de texto con Quorb abierto: se recoloca sin reiniciar.
    5. Arrastre: la tira sigue al cursor sin quedarse atrás.
    6. En el trabajo tengo 3 monitores: cambiar el monitor en Ajustes y arrastrar entre monitores de escala distinta.

    7. Dos decisiones para cerrar

    1. Ancho máximo. Hoy nada ajusta el ancho del detalle a area.w. Con un t grande (por ejemplo 150 % de texto en una pantalla pequeña) el panel podría salirse del área de trabajo. Propongo dejarlo fuera de esta issue como follow-up, porque no aparece en los casos reales que tenemos. ¿Te parece bien, o lo incluimos?
    2. Alcance de la PR. Una sola PR con tira, detalle, arrastre y hook, porque comparten el invariante y separarlos dejaría estados intermedios inconsistentes. Calculo unas 250-350 líneas incluyendo tests.

    Si estás de acuerdo, me asigno la issue y preparo la PR.

  3. egdev6 commented on Nov 1, 2026

    @egdev6
    Owner

    Gracias por el trabajo, @matraket. Tienes razón en el factor: mi fórmula mezclaba el scaleFactor del monitor destino con el devicePixelRatio del monitor donde está la ventana, y fallaría justo en los cambios de monitor que describes. Calcular t = devicePixelRatio / scaleFactor() sobre la misma ventana es mejor, porque la escala de texto de Windows es global.

    Verifiqué en main los sitios nuevos que señalas y los confirmo: fitPanel compara la altura en CSS con maxHeight en unidades de monitor, las constantes 48/24 de peekRect están en CSS, setPos usa un desplazamiento en unidades de monitor como CSS y anchor suma un valor CSS a una posición en unidades de monitor.

    Un único añadido a la spec: devicePixelRatio se lee de forma síncrona y scaleFactor() es asíncrono, así que durante un cambio de monitor podrían leerse en momentos distintos y producir un t transitorio incorrecto. Conviene recalcularlo cuando el cambio de escala se haya asentado y descartar valores fuera del rango posible de Windows (100–225 %), conservando el último válido.

    Sobre las dos decisiones:

    1. Ancho máximo: de acuerdo con dejarlo fuera de esta issue, pero abre una issue de seguimiento para que no se pierda.
    2. Alcance: de acuerdo con una sola PR para tira, detalle, arrastre y hook.

    Apruebo la spec con ese añadido y te asigno la issue. Adelante con la PR, incluida la validación manual en Windows que propones. ¡Gracias!

  4. added a commit that references this issue on Nov 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

status:approvedApproved for implementation by the repository ownertype:bugBug fix

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions