UI: follow Grayscale view mode in slide thumbnails

In PowerPoint, turning on Grayscale view mode turns both the active
slide canvas and the slide thumbnail pane to grayscale, while Print
retains the original full color for output.

Previously, DeckCraft only passed `app.ui.grayscale` to the main canvas
texture renderer (`Textures::slide`), while thumbnail rendering
(`Textures::thumb`) hardcoded `false` and lacked `grayscale` in its
cache key. As a result, when Grayscale mode was enabled, the canvas
rendered in grayscale but the thumbnail pane remained in full color.

- Update `Textures::thumb` to accept `grayscale: bool` and include it in
  the cache key (`extra: grayscale as u64`).
- Pass `app.ui.grayscale` to `Textures::thumb` in the thumbnail pane
  (`panels/thumbs.rs`), Slide Sorter & Notes Page (`sorter.rs`), and
  Notes Master view (`canvas.rs`).
- Keep Print preview (`backstage/print.rs`) in full color (`false`) to
  match the actual exported PDF print output.
- Add regression test `to_color_image_turns_colors_to_grayscale_when_flag_is_set`.
This commit is contained in:
sbjwin committed 2026-11-05 12:35:59 +09:00
1 parent 6431460e80
commit b06d60b8ed
5 files changed
+33 -8

No files matched your search

+2 -1
View File
@@ -207,7 +207,8 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_
let mut budget = 4u32;
let mut draw_slide = |ui: &mut Ui, i: usize, r: Rect| {
let px = (r.width() * ui.ctx().pixels_per_point()).round().clamp(16.0, 1600.0) as u32;
match app.textures.thumb(ui.ctx(), doc, i, px, &mut budget) {
// Print preview matches the actual exported PDF output (always full color).
match app.textures.thumb(ui.ctx(), doc, i, px, false, &mut budget) {
Some(tex) => {
ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
+1 -1
View File
@@ -299,7 +299,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
vec2(w as f32 * scale, h as f32 * scale),
);
let mut budget = 2;
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (r.width() * ppp).round().max(1.0) as u32, &mut budget) {
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (r.width() * ppp).round().max(1.0) as u32, app.ui.grayscale, &mut budget) {
painter.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
}
+1 -1
View File
@@ -219,7 +219,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
// Only rows in view (plus a little margin) ask for a thumbnail: with every row asking, a
// deck bigger than the thumbnail cache evicts its own thumbnails and never settles (#969).
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * row.height())).intersects(row);
let tex = if near { app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) } else { None };
let tex = if near { app.textures.thumb(ui.ctx(), &doc, i, wpx, app.ui.grayscale, &mut budget) } else { None };
match tex {
Some(tex) => {
p.image(
+3 -2
View File
@@ -209,7 +209,8 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32,
// Only cells in view (plus a little margin) ask for a thumbnail, so a deck bigger
// than the thumbnail cache does not evict its own thumbnails forever (#969).
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * cell.height())).intersects(cell);
let tex = if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) } else { None };
let tex =
if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, app.ui.grayscale, budget) } else { None };
match tex {
Some(tex) => {
p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
@@ -391,7 +392,7 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) {
}
let ppp = ui.ctx().pixels_per_point();
let mut budget = 2;
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, &mut budget) {
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, app.ui.grayscale, &mut budget) {
ui.painter().image(tex.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside);
+26 -3
View File
@@ -363,7 +363,16 @@ impl Textures {
/// A thumbnail texture; renders at most `budget` new thumbnails per frame (returns None when
/// it is over budget, so the caller draws a placeholder and asks for another frame).
pub fn thumb(&mut self, ctx: &egui::Context, p: &Presentation, index: usize, width_px: u32, budget: &mut u32) -> Option<TextureHandle> {
/// When `grayscale` is true, the thumbnail renders in black-and-white (matching Grayscale view).
pub fn thumb(
&mut self,
ctx: &egui::Context,
p: &Presentation,
index: usize,
width_px: u32,
grayscale: bool,
budget: &mut u32,
) -> Option<TextureHandle> {
let slide = p.slides.get(index)?.clone();
let master = master_of(p, &slide);
let h = (width_px as f64 * p.slide_size.height / p.slide_size.width.max(1.0)).round().max(1.0) as u32;
@@ -372,7 +381,7 @@ impl Textures {
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
size: (width_px, h),
edit: false,
extra: 0,
extra: grayscale as u64,
want: Region::slide(width_px, h),
editing_text: None,
};
@@ -393,7 +402,7 @@ impl Textures {
let scale = tw as f64 / p.slide_size.width.max(1.0);
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((tw, th)), threads: 0, ..Default::default() });
self.thumb_renders += 1;
let tex = upload_editor(ctx, format!("thumb-{index}"), to_color_image(&img, false));
let tex = upload_editor(ctx, format!("thumb-{index}"), to_color_image(&img, grayscale));
// Drop the old entry for this slide address; entries for slides that no longer exist age out.
let region = key.want;
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock });
@@ -433,6 +442,20 @@ mod tests {
assert_eq!(device_pixel_rect(aligned, ppp), aligned);
}
#[test]
fn to_color_image_turns_colors_to_grayscale_when_flag_is_set() {
let img = deckcraft_render::Image { width: 2, height: 1, pixels: vec![0, 0, 255, 255, 255, 0, 0, 255] };
let color = super::to_color_image(&img, false);
assert_eq!(color.pixels[0], egui::Color32::from_rgba_premultiplied(0, 0, 255, 255));
assert_eq!(color.pixels[1], egui::Color32::from_rgba_premultiplied(255, 0, 0, 255));
let gray = super::to_color_image(&img, true);
assert_eq!(gray.pixels[0].r(), gray.pixels[0].g());
assert_eq!(gray.pixels[0].g(), gray.pixels[0].b());
assert_eq!(gray.pixels[1].r(), gray.pixels[1].g());
assert_eq!(gray.pixels[1].g(), gray.pixels[1].b());
}
#[test]
fn a_bad_scale_does_not_move_the_rect() {
let r = Rect::from_min_size(pos2(3.0, 4.0), vec2(10.0, 12.0));