From 2f23926f038cf9ff88d08a1a34df66e33141ac7e Mon Sep 17 00:00:00 2001 From: gnacho Date: Sat, 31 Oct 2026 18:50:09 +0100 Subject: [PATCH] fix(editor): align bullets with list text Center bullet ornaments on the first visible glyph instead of the shrunk Markdown marker. Remove the residual marker width so wrapped lines keep the same text offset. Refs #169 --- src/editor.rs | 10 +-- src/markdown_view.rs | 40 +++++++++++- tests/bullet_alignment.rs | 125 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 tests/bullet_alignment.rs diff --git a/src/editor.rs b/src/editor.rs index c78deb8..0eca8e5 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -332,13 +332,15 @@ fn build_tags() -> gtk4::TextTagTable { add(gtk4::TextTag::builder().name("unfocused").build()); // Encoger en vez de ocultar (mitigación de GNOME/gtk#8346): la marca - // sustituida sigue en la maquetación con escala mínima y totalmente + // sustituida sigue en la maquetación con escala cero y totalmente // transparente, así que el texto nunca queda excluido del layout y el - // camino del aborto es inalcanzable por construcción. El color se fija - // aquí y no en el tema: debe ser transparente con cualquier paleta. + // camino del aborto es inalcanzable por construcción. Una escala residual + // como 0.05 aún puede ocupar un píxel y desalinear la primera línea de una + // lista respecto a sus continuaciones (issue #169). El color se fija aquí + // y no en el tema: debe ser transparente con cualquier paleta. add(gtk4::TextTag::builder() .name("syn_shrink") - .scale(0.05) + .scale(0.0) .foreground_rgba(&rgba(0x000000, 0.0)) .build()); // Filas fuente de un lienzo de tabla pintado: colapsan (igual que las diff --git a/src/markdown_view.rs b/src/markdown_view.rs index 81381dc..1a0baa4 100644 --- a/src/markdown_view.rs +++ b/src/markdown_view.rs @@ -218,6 +218,36 @@ mod imp { Some((rect.x() as f32, rect.y() as f32, rect.height() as f32)) } + pub(super) fn bullet_center(&self, line: i32) -> Option<(f32, f32)> { + let (x, y, height) = self.line_origin(line)?; + let view = self.obj(); + let buffer = view.buffer(); + let hidden = buffer.tag_table().lookup("syn_shrink"); + let mut iter = buffer.iter_at_line(line)?; + let range = view.line_yrange(&iter); + + // El marcador sustituido no tiene una caja de texto útil. La viñeta + // se centra en el primer glifo visible de la primera línea visual, + // no en esa marca ni en el conjunto de líneas envueltas. + loop { + let visible = match hidden.as_ref() { + Some(tag) => !iter.has_tag(tag), + None => true, + }; + let rect = view.iter_location(&iter); + if visible && rect.height() > 0 { + return Some(( + x - BULLET_OFFSET, + rect.y() as f32 + rect.height() as f32 / 2.0, + )); + } + if !iter.forward_char() || view.line_yrange(&iter) != range { + break; + } + } + Some((x - BULLET_OFFSET, y + height / 2.0)) + } + /// Extremos horizontales de la columna de texto. fn column(&self) -> (f32, f32) { let view = self.obj(); @@ -643,7 +673,10 @@ mod imp { match ornament { Ornament::Bullet { depth, .. } => { - draw_bullet(snapshot, x - BULLET_OFFSET, middle, *depth, &palette) + let Some((bullet_x, bullet_y)) = self.bullet_center(line) else { + continue; + }; + draw_bullet(snapshot, bullet_x, bullet_y, *depth, &palette) } Ornament::Checkbox { checked, .. } => draw_checkbox( snapshot, @@ -1403,6 +1436,11 @@ impl MarkdownView { glib::Object::builder().property("buffer", buffer).build() } + #[cfg(test)] + pub fn bullet_center_for_test(&self, line: i32) -> Option<(f32, f32)> { + self.imp().bullet_center(line) + } + /// Sustituye la lista de adornos y repinta. Barata: no calcula geometría, /// eso ocurre al dibujar y solo para lo que se ve. /// diff --git a/tests/bullet_alignment.rs b/tests/bullet_alignment.rs new file mode 100644 index 0000000..3a988cc --- /dev/null +++ b/tests/bullet_alignment.rs @@ -0,0 +1,125 @@ +//! Regresión de #169: las viñetas se alinean con la primera línea de texto y +//! las continuaciones de un elemento empiezan a la misma X que esa primera +//! línea, sin el residuo del marcador Markdown encogido. +//! +//! Ejecutar: xvfb-run -a cargo test --test bullet_alignment -- --ignored --test-threads=1 + +#![allow(dead_code)] + +#[path = "../src/tema.rs"] +mod tema; + +#[path = "../src/editor.rs"] +mod editor; +#[path = "../src/markdown_render.rs"] +mod markdown_render; +#[path = "../src/markdown_view.rs"] +mod markdown_view; +#[path = "../src/settings.rs"] +mod settings; + +use editor::Editor; +use gtk4::prelude::*; +use settings::FontFamily; +use std::time::{Duration, Instant}; + +const DOCUMENTO: &str = "- Texto llano\n- **Negrita** al inicio\n- Elemento suficientemente largo para envolver varias veces dentro de una ventana estrecha y comprobar la alineación del texto.\n - Elemento anidado también suficientemente largo para envolver y comprobar que su continuación conserva la sangría.\n - Tercer nivel con otro elemento largo que envuelve y permite medir la alineación de todo el texto.\n\nfuera de la lista\n"; + +fn pump(ctx: >k4::glib::MainContext, ms: u64) { + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(ms) { + while ctx.iteration(false) {} + std::thread::sleep(Duration::from_millis(4)); + } +} + +fn editor_con_texto(text: &str, width: i32, size: i32) -> (gtk4::Window, Editor) { + let css = gtk4::CssProvider::new(); + let display = gtk4::gdk::Display::default().expect("display bajo Xvfb"); + gtk4::style_context_add_provider_for_display( + &display, + &css, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + editor::install_app_css(css); + + let ed = Editor::new(); + ed.set_font(FontFamily::Sans, size, 1.7); + let window = gtk4::Window::new(); + window.set_default_size(width, 500); + window.set_child(Some(&ed.widget)); + window.present(); + ed.set_text(text); + let last_line = text.lines().count().saturating_sub(1) as u32; + ed.place_cursor_at(last_line + 1, 1); + pump(>k4::glib::MainContext::default(), 700); + (window, ed) +} + +fn primer_rectangulo_visible( + view: &markdown_view::MarkdownView, + buffer: &impl IsA, + line: i32, +) -> gtk4::gdk::Rectangle { + let hidden = buffer.tag_table().lookup("syn_shrink"); + let mut iter = buffer.iter_at_line(line).expect("inicio del elemento"); + let range = view.line_yrange(&iter); + loop { + let visible = match hidden.as_ref() { + Some(tag) => !iter.has_tag(tag), + None => true, + }; + let rect = view.iter_location(&iter); + if visible && rect.height() > 0 { + return rect; + } + assert!( + iter.forward_char(), + "la línea {line} no tiene texto visible" + ); + assert_eq!( + view.line_yrange(&iter), + range, + "no hay glifo visible en la línea {line}" + ); + } +} + +#[test] +#[ignore = "requiere display"] +fn vinetas_y_continuaciones_se_alinean_con_el_texto() { + let _guardia = markdown_view::tests::gtk_compartido(); + for size in [9, 16, 20, 24, 32, 40] { + let (window, ed) = editor_con_texto(DOCUMENTO, 420, size); + let view = &ed.view; + let buffer = ed.buffer(); + + for line in 0..5 { + let body_rect = primer_rectangulo_visible(view, &buffer, line); + let expected = body_rect.y() as f32 + body_rect.height() as f32 / 2.0; + let (_, current) = view + .bullet_center_for_test(line) + .expect("la viñeta tiene geometría"); + assert!( + (current - expected).abs() <= 1.0, + "fuente {size}, línea {line}: centro de viñeta {current}, centro del texto {expected}, cuerpo={body_rect:?}" + ); + } + + for line in 2..5 { + let first_x = primer_rectangulo_visible(view, &buffer, line).x(); + let mut continuation = buffer.iter_at_line(line).expect("inicio del elemento"); + assert!( + view.forward_display_line(&mut continuation), + "fuente {size}, línea {line}: el elemento debe ocupar al menos dos líneas visuales" + ); + let continuation_x = view.iter_location(&continuation).x(); + assert_eq!( + first_x, continuation_x, + "fuente {size}, línea {line}: la primera línea empieza en {first_x} y la continuación en {continuation_x}" + ); + } + + window.destroy(); + } +}