From 4df5d1639cff3b88ce85145b56daa870051989aa Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:40:40 +0200 Subject: [PATCH] Align Story Editor paragraph style labels with the wrapped text rows (#1251) --- crates/ui-egui/src/story_editor.rs | 59 ++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/story_editor.rs b/crates/ui-egui/src/story_editor.rs index fd7e618b..2bd481d8 100644 --- a/crates/ui-egui/src/story_editor.rs +++ b/crates/ui-egui/src/story_editor.rs @@ -51,27 +51,26 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { .show(ctx, |ui| { egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { ui.horizontal_top(|ui| { - // Style column. - ui.vertical(|ui| { - ui.set_width(130.0); - for (i, r) in story.para_ranges().iter().enumerate() { - let lines = story.text[r.clone()].len() / 60 + 1; - let style = &story.paras[i].style; - crate::rtl::label(ui, egui::RichText::new(crate::i18n::style_name(&app.ui.language, style)).size(11.0).color(t.text_dim)); - for _ in 1..lines { - ui.label(egui::RichText::new(" ").size(11.0)); - } - } - }); + // Style column: the labels are painted after the text, at the rows the text really wraps to. + let (column, _) = ui.allocate_exact_size(vec2(130.0, 0.0), egui::Sense::hover()); ui.separator(); let mut text = story.text.clone(); - let r = ui.add( - egui::TextEdit::multiline(&mut text) - .font(egui::FontId::proportional(app.ui.story_editor_size.clamp(8.0, 36.0))) - .desired_width(f32::INFINITY) - .desired_rows(20) - .frame(egui::Frame::NONE), - ); + let out = egui::TextEdit::multiline(&mut text) + .font(egui::FontId::proportional(app.ui.story_editor_size.clamp(8.0, 36.0))) + .desired_width(f32::INFINITY) + .desired_rows(20) + .frame(egui::Frame::NONE) + .show(ui); + let r = out.response; + let starts = paragraph_start_offsets(out.galley.rows.iter().map(|row| (row.pos.y, row.ends_with_newline))); + for (i, y) in starts.into_iter().enumerate() { + let Some(para) = story.paras.get(i) else { break }; + let rect = egui::Rect::from_min_size(egui::pos2(column.min.x, out.galley_pos.y + y), vec2(130.0, 14.0)); + ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + let name = crate::i18n::style_name(&app.ui.language, ¶.style); + crate::rtl::label(ui, egui::RichText::new(name).size(11.0).color(t.text_dim)); + }); + } if r.changed() { // Minimal diff: common prefix and suffix. let old = &story.text; @@ -111,6 +110,21 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } } +/// Vertical offset of the first laid-out row of each paragraph, from `(row y, row ends with a newline)` +/// in layout order. A paragraph that wraps spans several rows, so counting characters per line +/// drifts from what the editor draws; the rows themselves are the truth. +fn paragraph_start_offsets(rows: impl Iterator) -> Vec { + let mut starts = Vec::new(); + let mut at_start = true; + for (y, ends_with_newline) in rows { + if at_start { + starts.push(y); + } + at_start = ends_with_newline; + } + starts +} + #[cfg(test)] mod tests { use egui_kittest::kittest::Queryable; @@ -123,6 +137,13 @@ mod tests { st.doc.stories.values().next().unwrap().text.clone() } + /// A wrapped paragraph takes several rows; the next paragraph's label goes to the row after them. + #[test] + fn labels_follow_wrapped_rows() { + let rows = [(0.0, false), (20.0, false), (40.0, true), (60.0, true), (80.0, false)]; + assert_eq!(super::paragraph_start_offsets(rows.into_iter()), vec![0.0, 60.0, 80.0]); + } + /// Story ids are counted per document: typing in the editor after switching documents edited /// the other document's story with the same id. #[test]