From c0b405c42ade69de786515950d7a7c654c73eef2 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 03:46:57 +0200 Subject: [PATCH] Presenter View: speaker notes keep their bullets, numbers and formatting (fixes #1128) --- crates/ui-egui/src/notes.rs | 40 ++++++++++++++-------- crates/ui-egui/src/show.rs | 66 ++++++++++++++++++++++++++++++++++--- docs/slideshow-parity.md | 3 +- 3 files changed, 91 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/notes.rs b/crates/ui-egui/src/notes.rs index 7714637b..46b93fd9 100644 --- a/crates/ui-egui/src/notes.rs +++ b/crates/ui-egui/src/notes.rs @@ -204,24 +204,38 @@ pub fn editor( edit::replace_text(&mut draft.body, &draft.text); draft.edited = true; } - // Bullets and numbers sit at the start of each paragraph's first line. if draft.body.text() == draft.text { - let painter = ui.painter().with_clip_rect(out.text_clip_rect); - let k = size / 12.0; - let mut at = 0usize; - for (p, bullet) in draft.body.paragraphs.iter().zip(bullets(&draft.body)) { - if let Some(b) = bullet { - let row = out.galley.pos_from_cursor(CCursor::new(at)); - let pos = out.galley_pos + vec2(px(indents(p).0, k), row.center().y); - let font = theme::font(row.height().clamp(size * 0.75, size * 2.0) * 0.8); - painter.text(pos, Align2::LEFT_CENTER, b, font, color); - } - at += p.char_len() + 1; - } + paint_bullets(&ui.painter().with_clip_rect(out.text_clip_rect), &draft.body, &out.galley, out.galley_pos, size, color); } out } +/// Bullets and numbers at the start of each paragraph's first line of `galley` (laid out from +/// `body`), drawn at `origin`. +fn paint_bullets(painter: &egui::Painter, body: &TextBody, galley: &Galley, origin: egui::Pos2, size: f32, color: Color32) { + let k = size / 12.0; + let mut at = 0usize; + for (p, bullet) in body.paragraphs.iter().zip(bullets(body)) { + if let Some(b) = bullet { + let row = galley.pos_from_cursor(CCursor::new(at)); + let pos = origin + vec2(px(indents(p).0, k), row.center().y); + let font = theme::font(row.height().clamp(size * 0.75, size * 2.0) * 0.8); + painter.text(pos, Align2::LEFT_CENTER, b, font, color); + } + at += p.char_len() + 1; + } +} + +/// The notes drawn read-only with their formatting, wrapped to the available width, and +/// selectable like a label (the presenter's notes pane). +pub fn view(ui: &mut Ui, body: &TextBody, look: &Look) -> egui::Response { + let color = look.color.unwrap_or_else(|| ui.visuals().text_color()); + let g = galley(ui, body, look.size, color, ui.available_width()); + let response = ui.add(egui::Label::new(egui::WidgetText::Galley(g.clone()))); + paint_bullets(ui.painter(), body, &g, response.rect.min, look.size, color); + response +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 51f859af..b9f94c33 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -1745,7 +1745,8 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no show.next_tex = Some((n, crate::textures::upload(ctx, "presenter-next", crate::textures::to_color_image(&img, false)))); } let next_tex = show.next_tex.as_ref().filter(|(k, _)| Some(*k) == next).map(|(_, t)| t.clone()); - let notes = doc.slides.get(cur).map(|s| s.notes_text()).unwrap_or_default(); + let no_notes = deckcraft_model::text::TextBody::default(); + let notes = doc.slides.get(cur).map_or(&no_notes, |s| &s.notes); let clock = wall_clock(); // The presenter clock only moves when this window is drawn again. ctx.request_repaint_after(std::time::Duration::from_millis(500)); @@ -1860,9 +1861,7 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no let notes_rect = Rect::from_min_max(pos2(right.min.x, notes_head.max.y + 4.0), right.max); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect)); egui::ScrollArea::vertical().show(&mut child, |ui| { - ui.label( - egui::RichText::new(if notes.is_empty() { "No notes." } else { ¬es }).size(show.notes_px).color(Color32::from_gray(230)), - ); + presenter_notes(ui, notes, show.notes_px); }); }); if ui.input(|i| i.viewport().close_requested()) { @@ -1877,6 +1876,16 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no } } +/// The current slide's notes in the presenter's notes pane, `px` for 12 pt text. +fn presenter_notes(ui: &mut Ui, notes: &deckcraft_model::text::TextBody, px: f32) -> egui::Response { + let color = Color32::from_gray(230); + if notes.text().is_empty() { + ui.label(egui::RichText::new("No notes.").size(px).color(color)) + } else { + crate::notes::view(ui, notes, &crate::notes::Look { size: px, color: Some(color) }) + } +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] @@ -2187,6 +2196,55 @@ mod tests { assert!(show.zoom.is_none(), "a new slide is shown whole"); } + /// #1128: the presenter drew the notes as plain text, so bullets, numbers and bold were lost + /// although the notes pane shows them; the notes stay selectable. + #[test] + fn presenter_notes_keep_bullets_numbers_and_bold() { + use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody}; + let point = |text: &str, bullet: Bullet| Paragraph { + level: 1, + props: ParaProps { bullet: Some(bullet), ..Default::default() }, + runs: vec![Run::new(text)], + ..Default::default() + }; + let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 }; + let notes = TextBody { + paragraphs: vec![ + Paragraph { runs: vec![Run::with("Opening", RunProps { bold: Some(true), ..Default::default() })], ..Default::default() }, + point("Welcome", Bullet::Char { char: "•".into() }), + point("Agenda", num()), + point("Demo", num()), + ], + ..Default::default() + }; + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut sense = None; + let mut out = ctx.run_ui(Default::default(), |ui| sense = Some(presenter_notes(ui, ¬es, 24.0).sense)); + out.textures_delta.clear(); + assert!(sense.unwrap().senses_drag(), "the notes can be selected, as the plain label could"); + let mut texts = vec![]; + let mut stack: Vec = out.shapes.into_iter().map(|c| c.shape).collect(); + while let Some(shape) = stack.pop() { + match shape { + egui::Shape::Vec(v) => stack.extend(v), + egui::Shape::Text(t) => texts.push(t.galley), + _ => {} + } + } + let painted: Vec<&str> = texts.iter().map(|g| g.job.text.as_str()).collect(); + for marker in ["•", "1.", "2."] { + assert!(painted.contains(&marker), "{marker} is drawn: {painted:?}"); + } + let notes_galley = texts.iter().find(|g| g.job.text.contains("Agenda")).expect("the notes are drawn"); + let font_of = |s: &str| { + let b = notes_galley.job.text.find(s).unwrap(); + notes_galley.job.sections.iter().find(|x| (x.byte_range.start.0..x.byte_range.end.0).contains(&b)).unwrap().format.font_id.clone() + }; + assert_eq!(font_of("Opening"), theme::bold(24.0), "bold runs stay bold at the pane's size"); + assert_eq!(font_of("Agenda"), theme::font(24.0)); + } + #[test] fn presenter_timer_pauses_resets_and_notes_size_clamps() { let mut t = PresenterTimer::start(10.0); diff --git a/docs/slideshow-parity.md b/docs/slideshow-parity.md index 921732a5..6a3f92b1 100644 --- a/docs/slideshow-parity.md +++ b/docs/slideshow-parity.md @@ -41,7 +41,7 @@ ready** (estimated), **10–16 h** to parity. | Feature | PowerPoint | DeckCraft | Notes | |---|---|---|---| -| Separate presenter window with current slide, next slide, notes | yes | yes (an egui viewport, 1100×700) | | +| Separate presenter window with current slide, next slide, notes | yes | yes (an egui viewport, 1100×700); the notes keep their bullets, numbers, indents and bold/italic, as in the notes pane | | | Elapsed timer | yes | yes | | | Pause / reset timer, clock | yes | yes: Pause/Resume and Reset in presenter view (T pauses, R resets); the clock is local time, `--:--` where there is no clock | Pausing the timer does not pause the show, animations or rehearsal | | Slide counter, previous/next/end buttons | yes | yes | | @@ -90,4 +90,5 @@ Reading View in a window with navigation: present (checklist row done). | 2026-10-14 | minor | A custom show plays only its slides, from the Custom Shows dialog, from Set Up Slide Show, or from a click action. Checklist breadth stays 66% | | 2026-10-14 | minor | Presenter view pauses and resets its timer (T, R) and changes notes size (A-/A+, brackets). The clock shows local time. Second-display placement and swap stay with #433. Checklist breadth stays 66% | | 2026-10-14 | minor | See All Slides (G) and click-to-zoom (Z) during the show. Presenter view opens that grid and sets black, white and zoom. Checklist breadth stays 66% | +| 2026-10-23 | trivial | Presenter view draws the notes with their formatting (bullets, numbers, indents, bold/italic) instead of plain text (#1128) | | 2026-10-10 | major | Created: show, presenter view, rehearse and record, compared with PowerPoint 16.113's Slide Show tab and menu |