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(); + } +}