Presenter View: speaker notes keep their bullets, numbers and formatting (fixes #1128)

This commit is contained in:
Marco Arnulfo
2026-10-23 03:54:04 +02:00
parent b47195feec
commit c0b405c42a
3 changed files with 91 additions and 18 deletions
+27 -13
View File
@@ -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)]
+62 -4
View File
@@ -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 { &notes }).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, &notes, 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<egui::Shape> = 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);
+2 -1
View File
@@ -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 |