Merge PR #522 from NikitkaRa: UI: keep editor slides on the Retina pixel grid

# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/panes.rs
#	crates/ui-egui/src/sorter.rs
#	crates/ui-egui/src/textures.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:58:19 -04:00
9 changed files with 115 additions and 25 deletions
+2
View File
@@ -216,6 +216,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-14 | Slide Master: Preserve on the open master or layout, and Title and Footers placeholder checkboxes. Two commands (`master.preserve`, `master.placeholders`); dialogs stay 27; breadth stays 79% |
| 2026-10-14 | Trim Media: start, end and fades open from the selected clip, so OK no longer resets them to zero |
| 2026-10-14 | Format Background: a solid fill's transparency, and for a picture transparency, tile, offset, scale, alignment and mirror. Apply to All copies a picture background onto every slide. The same `design.background` command; dialogs stay 27 |
| 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 |
@@ -288,6 +289,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-14 | trivial | Progress log: Slide Master Preserve and Title/Footers placeholders. Percentages unchanged |
| 2026-10-14 | trivial | Progress log: Trim Media dialog follows the selection |
| 2026-10-14 | trivial | Progress log: Format Background picture and solid options |
| 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-13 | trivial | Progress log: slide show for interactive decks |
| 2026-10-12 | minor | Progress log: table cell editing and border resizing |
+4 -3
View File
@@ -109,7 +109,9 @@ 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);
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);
ui.data_mut(|d| {
d.insert_temp(scroll_id, offset);
d.insert_temp(last_id, (slide_rect, scale));
@@ -120,7 +122,6 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
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() {
@@ -381,7 +382,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) {
+8 -4
View File
@@ -1081,7 +1081,7 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) {
let key_doc = (st.uid, st.revision);
let w = ui.available_width() - 8.0;
let ppp = ui.ctx().pixels_per_point();
let px = (w * ppp).max(16.0) as u32;
let px = (w * ppp).round().max(16.0) as u32;
let ideas = deckcraft_engine::cmd::ideas::ideas_for(&doc, idx);
let themes = deckcraft_model::theme::builtin_themes();
let cache_id = egui::Id::new("design-idea-thumbs");
@@ -1096,7 +1096,8 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) {
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-{name}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR);
let tex =
ui.ctx().load_texture(format!("idea-{name}"), crate::textures::to_color_image(&img, false), crate::textures::editor_texture_options());
cache.tex.push((name, tex, px, h));
};
for (id, _) in &ideas {
@@ -1117,9 +1118,12 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) {
let thumb = |ui: &mut Ui, name: &str, tip: &str| -> bool {
let Some((_, tex, tw, th)) = cache.tex.iter().find(|e| e.0 == name) else { return false };
let (r, resp) = ui.allocate_exact_size(vec2(w, w * *th as f32 / (*tw).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);
// On the physical pixel grid, one texel per device pixel (#184).
let at = crate::textures::device_pixel_rect(r, ppp);
let at = Rect::from_min_size(at.min, vec2(*tw as f32 / ppp, *th as f32 / ppp));
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,
+7 -3
View File
@@ -395,9 +395,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(
+5 -5
View File
@@ -200,10 +200,10 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32,
let (cell, resp) = ui.allocate_exact_size(vec2(g.tw + 36.0, g.th + 36.0), Sense::click_and_drag());
let label = format!("Slide {}", i + 1);
resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label));
let img = Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(g.tw, g.th));
let img = crate::textures::device_pixel_rect(Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(g.tw, g.th)), ppp);
rects.push((i, cell));
let p = ui.painter();
match app.textures.thumb(ui.ctx(), g.doc, i, (g.tw * ppp) as u32, budget) {
match app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) {
Some(tex) => {
p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
@@ -375,8 +375,8 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) {
Rect::from_min_max(page.min + vec2(x0 as f32 * k, y0 as f32 * k), page.min + vec2(x1 as f32 * k, y1 as f32 * k))
};
let (slide_box, notes_box) = deckcraft_engine::pdf::notes_frames(&doc, paper);
let srect = to_screen((slide_box.x0, slide_box.y0, slide_box.x1, slide_box.y1));
let sw = srect.width();
// On the physical pixel grid, so the thumbnail is one texel per device pixel (#184).
let srect = crate::textures::device_pixel_rect(to_screen((slide_box.x0, slide_box.y0, slide_box.x1, slide_box.y1)), ui.ctx().pixels_per_point());
if let Some(m) = doc.notes_master.as_deref() {
// Laid out by the text engine as on the PDF page: wrapped, each run in its size and colour.
for piece in deckcraft_engine::pdf::master_text_pieces(&doc, m, paper, idx + 1) {
@@ -388,7 +388,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, (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);
+70 -4
View File
@@ -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 {
@@ -98,6 +98,39 @@ impl Textures {
}
}
/// 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;
@@ -201,10 +234,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))
@@ -252,7 +285,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 });
@@ -266,3 +299,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);
}
}
+9 -6
View File
@@ -94,7 +94,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);
@@ -114,7 +114,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(
@@ -276,9 +276,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,
@@ -290,8 +290,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,
+3
View File
@@ -1,6 +1,7 @@
# Hardware parity with PowerPoint
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (PNG and JPEG slide export record 144 dpi at the default 2 pixels per point; previous: IME input, #101) · **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
@@ -15,6 +16,7 @@ because it mostly follows from hardware use.
| 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 |
| Picture export resolution | 2 px per point, tagged so the file is the slide's size | PNG `pHYs` and JPEG density, 144 dpi at the default 2 px per point | same | same | same |
| 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 |
@@ -73,5 +75,6 @@ outlines every frame (#67, fix open), and the decoders run in software.
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | PNG and JPEG slide export record 144 dpi at the default 2 pixels per point, so a Retina picture opens at the slide's size |
| 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 |
+7
View File
@@ -23,6 +23,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (the frame of a text box being edited moves it; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (table cells: caret, clicks between cells, border resizing; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture and solid options; dialogs stay 27) · **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-14 · **Change:** minor (File › New starter decks; previous: minor, text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Sorter sections: add, rename, collapse, drag a whole section; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -185,6 +186,11 @@ Colours, spacing, ribbon card and tab geometry follow the observations in
and must not imitate Microsoft's. Insert › Icons places Lucide stroke icons (ISC, release 1.56.0)
on the slide as shapes. No pixel comparison against PowerPoint screenshots has been run since
2026-10-07. Cross-app icon consistency is open (#73, #56).
`plan/powerpoint/01-observed-ui.md`; icons are original (~240, drawn in code or Lucide) and must not
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
@@ -232,6 +238,7 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b
| 2026-10-12 | minor | Dragging the frame of a text box being edited moves it, as in PowerPoint |
| 2026-10-12 | minor | Table cells: caret and selection shown in the edited cell, clicks place the caret in the clicked cell, row and column borders drag to resize |
| 2026-10-14 | minor | Format Background pane: solid transparency, picture transparency, tile, offset, scale, alignment, mirror, and Apply to All copying a picture. Not a new dialog; the dialog count stays 27 |
| 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 |