From 10ad8d7ba7487d847a7709b3059638ae668129dd Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:44:42 +0300 Subject: [PATCH] UI: keep editor slides on the Retina pixel grid The editing canvas, thumbnails and layout previews were drawn between physical pixels, so a linear filter blurred small text. The slide show draws much larger glyphs, which is why presenting still looked sharp (#184). Co-authored-by: Cursor --- ROADMAP.md | 2 + crates/ui-egui/src/canvas.rs | 7 ++-- crates/ui-egui/src/panes.rs | 17 +++++--- crates/ui-egui/src/ribbon.rs | 10 +++-- crates/ui-egui/src/sorter.rs | 9 +++-- crates/ui-egui/src/textures.rs | 74 ++++++++++++++++++++++++++++++++-- crates/ui-egui/src/thumbs.rs | 15 ++++--- docs/hardware-parity.md | 5 ++- docs/ui-parity.md | 9 +++-- 9 files changed, 117 insertions(+), 31 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..36ab29a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-13 | Editor slide rasters sit on the physical pixel grid, so small text stays sharp on a Retina display instead of being blurred by a linear filter between pixels (#184) | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Progress log: editor rasters on the Retina pixel grid (#184) | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..8b78b903 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -95,14 +95,15 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { offset.x = offset.x.clamp(0.0, overflow.x); offset.y = offset.y.clamp(0.0, overflow.y); ui.data_mut(|d| d.insert_temp(scroll_id, offset)); - let slide_rect = content_rect.translate(-offset); + // On the physical pixel grid, so the slide texture is one texel per device pixel (#184). + let ppp = ui.ctx().pixels_per_point(); + let slide_rect = crate::textures::device_pixel_rect(content_rect.translate(-offset), ppp); app.slide_rect = Some(slide_rect); app.canvas_scale = scale; let xf = Xf { origin: slide_rect.min, scale }; let painter = ui.painter_at(avail); // Shadow + slide. painter.rect_filled(slide_rect.translate(vec2(0.0, 1.5)).expand(1.0), CornerRadius::same(2), t.shadow); - let ppp = ui.ctx().pixels_per_point(); let px = ((slide_rect.width() * ppp).round().max(1.0) as u32, (slide_rect.height() * ppp).round().max(1.0) as u32); let now = ui.input(|i| i.time); let gifs = match app.session.active() { @@ -320,7 +321,7 @@ fn master_tex( layout, &deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() }, ); - Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR), region)) + Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), crate::textures::editor_texture_options()), region)) } fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..80af92f6 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -613,18 +613,23 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { m.theme = th.clone(); } let ppp = ui.ctx().pixels_per_point(); - let px = (w * ppp) as u32; - let h = (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)) as u32; + let (r, resp) = ui.allocate_exact_size(vec2(w, w * (d.slide_size.height / d.slide_size.width.max(1.0)) as f32), Sense::click()); + let at = crate::textures::device_pixel_rect(r, ppp); + let px = (at.width() * ppp).round().max(1.0) as u32; + let h = (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)).round().max(1.0) as u32; let img = deckcraft_render::render_slide( &d, idx, &deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() }, ); - let tex = ui.ctx().load_texture(format!("idea-{}", th.name), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); - let (r, resp) = ui.allocate_exact_size(vec2(w, w * h as f32 / px.max(1) as f32), Sense::click()); - ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + let tex = ui.ctx().load_texture( + format!("idea-{}", th.name), + crate::textures::to_color_image(&img, false), + crate::textures::editor_texture_options(), + ); + ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); ui.painter().rect_stroke( - r, + at, CornerRadius::same(2), Stroke::new(if resp.hovered() { 2.5 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }), egui::StrokeKind::Outside, diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..12766d37 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -339,9 +339,13 @@ fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { ..Default::default() }, ); - let tex = - ui.ctx().load_texture(format!("layout-{li}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); - ui.painter().image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + let tex = ui.ctx().load_texture( + format!("layout-{li}"), + crate::textures::to_color_image(&img, false), + crate::textures::editor_texture_options(), + ); + let at = crate::textures::device_pixel_rect(rect, ui.ctx().pixels_per_point()); + ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } let t = Tokens::get(ui.ctx()); ui.painter().rect_stroke( diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index dd26dd85..c416d1b0 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -32,10 +32,10 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { break; } let (cell, resp) = ui.allocate_exact_size(vec2(tw + 36.0, th + 36.0), Sense::click_and_drag()); - let img = Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(tw, th)); + let img = crate::textures::device_pixel_rect(Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(tw, th)), ppp); rects.push((i, cell)); let p = ui.painter(); - match app.textures.thumb(ui.ctx(), &doc, i, (tw * ppp) as u32, &mut budget) { + match app.textures.thumb(ui.ctx(), &doc, i, (img.width() * ppp).round() as u32, &mut budget) { Some(tex) => { p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } @@ -123,10 +123,11 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) { ui.painter().rect_filled(page, CornerRadius::ZERO, Color32::WHITE); let sw = page_w * 0.78; let sh = sw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; - let srect = Rect::from_min_size(pos2(page.center().x - sw / 2.0, page.min.y + page_h * 0.08), vec2(sw, sh)); let ppp = ui.ctx().pixels_per_point(); + let srect = + crate::textures::device_pixel_rect(Rect::from_min_size(pos2(page.center().x - sw / 2.0, page.min.y + page_h * 0.08), vec2(sw, sh)), ppp); let mut budget = 2; - if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (sw * ppp) as u32, &mut budget) { + if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, &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); diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index 40498422..3304b516 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use deckcraft_geom::Rect; use deckcraft_model::{Master, Presentation, ShapeId, Slide}; use deckcraft_render::{Image, Region, RenderOpts}; -use egui::{ColorImage, TextureHandle, TextureOptions}; +use egui::{ColorImage, TextureFilter, TextureHandle, TextureOptions, TextureWrapMode}; #[derive(Clone, PartialEq)] struct Key { @@ -50,6 +50,39 @@ pub struct Textures { pub last_render_ms: f64, } +/// Snap `rect` onto the physical pixel grid. +/// +/// The editor draws the slide as a texture of device pixels. A rect that starts or ends +/// between pixels is sampled with a linear filter, so every glyph edge is a blend of two +/// texels and small text looks soft. On a Retina display that offset is half a point, which +/// is the whole edge of an editing-size glyph. The slide show draws much larger glyphs, so +/// the same blend is not visible there (#184). +pub fn device_pixel_rect(rect: egui::Rect, ppp: f32) -> egui::Rect { + if !ppp.is_finite() || ppp <= 0.0 || !rect.is_finite() { + return rect; + } + let snap = |v: f32| { + let p = v * ppp; + if p.is_finite() { p.round() / ppp } else { v } + }; + let min = egui::pos2(snap(rect.min.x), snap(rect.min.y)); + let step = 1.0 / ppp; + let max = egui::pos2(snap(rect.max.x).max(min.x + step), snap(rect.max.y).max(min.y + step)); + egui::Rect::from_min_max(min, max) +} + +/// Editor slide textures. Nearest magnification keeps a texel that is stretched by a +/// fraction of a pixel from blurring the glyph; minification still blends when many texels +/// share a screen pixel. +pub fn editor_texture_options() -> TextureOptions { + TextureOptions { + magnification: TextureFilter::Nearest, + minification: TextureFilter::Linear, + wrap_mode: TextureWrapMode::ClampToEdge, + mipmap_mode: None, + } +} + pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage { let w = img.width as usize; let h = img.height as usize; @@ -153,10 +186,10 @@ impl Textures { let ci = to_color_image(&img, grayscale); let tex = match self.canvas.take() { Some(mut e) => { - e.tex.set(ci, TextureOptions::LINEAR); + e.tex.set(ci, editor_texture_options()); e.tex } - None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR), + None => ctx.load_texture("slide-canvas", ci, editor_texture_options()), }; self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 }); Some((tex, region)) @@ -199,7 +232,7 @@ impl Textures { *budget -= 1; let scale = width_px as f64 / p.slide_size.width.max(1.0); let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() }); - let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR); + let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), editor_texture_options()); // 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 }); @@ -213,3 +246,36 @@ impl Textures { Some(tex) } } + +#[cfg(test)] +mod tests { + use super::device_pixel_rect; + use egui::{Rect, pos2, vec2}; + + fn on_grid(v: f32, ppp: f32) -> bool { + let px = v * ppp; + (px - px.round()).abs() < 1e-3 + } + + #[test] + fn a_retina_slide_rect_lands_on_device_pixels() { + let ppp = 2.0; + let r = Rect::from_min_size(pos2(10.3, 20.25), vec2(100.2, 50.4)); + let s = device_pixel_rect(r, ppp); + assert!(on_grid(s.min.x, ppp) && on_grid(s.min.y, ppp) && on_grid(s.max.x, ppp) && on_grid(s.max.y, ppp)); + assert!(s.width() > 0.0 && s.height() > 0.0); + // One texture pixel per device pixel: the snapped size is an integer pixel count. + let px = ((s.width() * ppp).round() as u32, (s.height() * ppp).round() as u32); + assert!((s.width() / px.0 as f32 * ppp - 1.0).abs() < 1e-3); + assert!((s.height() / px.1 as f32 * ppp - 1.0).abs() < 1e-3); + let aligned = Rect::from_min_max(pos2(1.0, 2.5), pos2(80.0, 40.0)); + assert_eq!(device_pixel_rect(aligned, ppp), aligned); + } + + #[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)); + assert_eq!(device_pixel_rect(r, 0.0), r); + assert_eq!(device_pixel_rect(r, f32::NAN), r); + } +} diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index ebdc81d0..38e6d4ca 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -86,7 +86,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { }); } let (row, resp) = ui.allocate_exact_size(vec2(width, th + 16.0), Sense::click_and_drag()); - let img = Rect::from_min_size(pos2(row.min.x + 30.0, row.min.y + 6.0), vec2(tw, th)); + let img = crate::textures::device_pixel_rect(Rect::from_min_size(pos2(row.min.x + 30.0, row.min.y + 6.0), vec2(tw, th)), ppp); rects.push((i, row)); let p = ui.painter(); let id = doc.slides.get(i).map(|s| s.id); @@ -106,7 +106,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { if has_anim { p.text(pos2(row.min.x + 14.0, img.min.y + 20.0), Align2::CENTER_TOP, "★", theme::font(10.0), t.text_faint); } - let wpx = (tw * ppp).round() as u32; + let wpx = (img.width() * ppp).round() as u32; match app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) { Some(tex) => { p.image( @@ -268,9 +268,9 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) { let tw = (width - indent - 14.0).max(40.0) * if li.is_some() { 0.8 } else { 1.0 }; let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; let (row, resp) = ui.allocate_exact_size(vec2(width, th + 12.0), Sense::click()); - let img = Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th)); let ppp = ui.ctx().pixels_per_point(); - let px = ((tw * ppp) as u32).max(1); + let img = crate::textures::device_pixel_rect(Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th)), ppp); + let px = ((img.width() * ppp).round() as u32).max(1); let pic = deckcraft_render::render_layout( &doc, mi, @@ -282,8 +282,11 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) { ..Default::default() }, ); - let tex = - ui.ctx().load_texture(format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false), egui::TextureOptions::LINEAR); + let tex = ui.ctx().load_texture( + format!("ml-{mi}-{li:?}"), + crate::textures::to_color_image(&pic, false), + crate::textures::editor_texture_options(), + ); ui.painter().image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); let selected = match (target, li) { (Target::Master { master }, None) => master == mi, diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 5ba34cfc..42747bd6 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (editor rasters on the Retina pixel grid, #184; previous: IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40% ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too @@ -13,7 +13,7 @@ because it mostly follows from hardware use. | Window compositing on the GPU | Metal / DirectX | wgpu (Metal) | wgpu, DX12 only by default (#16) | wgpu (Vulkan, GL) | WebGPU, WebGL2 fallback | | Slide rasterization | GPU | **CPU** (`vello_cpu`) | CPU | CPU | CPU (wasm) | | Transitions and Morph frames | GPU | CPU-rendered slide images composited as textured quads on the GPU | same | same | same | -| High-DPI / Retina | yes | yes | yes | yes | yes | +| High-DPI / Retina | yes | yes; editor slide rasters sit on the physical pixel grid (#184) | yes | yes | yes | | Wide gamut / HDR display | partly (P3 images) | no (sRGB) | no | no | no | | Startup on all GPUs | yes | yes | **no**: crashes in some Intel/AMD drivers (#40, #59) | not reported | n/a | | Software fallback when no GPU | yes (WARP on Windows) | no | no | no | no | @@ -71,5 +71,6 @@ outlines every frame (#67, fix open), and the decoders run in software. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) | | 2026-10-12 | minor | IME input methods (#101) | | 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..76580bad 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (editor slide rasters sit on the physical pixel grid, #184; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -128,8 +128,10 @@ Alt+Shift+arrows in text are handled elsewhere (#78, #93). Colours, spacing, ribbon card and tab geometry follow the observations in `plan/powerpoint/01-observed-ui.md`; icons are original (~240, drawn in code or Lucide) and must not -imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been run since -2026-10-07. Cross-app icon consistency is open (#73, #56). +imitate Microsoft's. The editing canvas, thumbnails and layout previews are drawn on the physical +pixel grid, so a Retina display does not blur them between pixels (#184). No pixel comparison +against PowerPoint screenshots has been run since 2026-10-07. Cross-app icon consistency is open +(#73, #56). ## Estimates @@ -148,6 +150,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) | | 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home | | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | | 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |