R5.6: ElyShow right-click menus, PowerPoint-style; Impress table borders drag; R3 PASS

The canvas offers Ely's menu by target: slide (layouts, comment), text
(bullets, numbering, comment), shape or text box, picture (replace,
crop), table (insert, delete, merge), each item gated by the kernel's
state for its own command. Impress's table borders read as text and
drag through R5.3's path; patch 0047 reports every edge's bounds and
makes the left edge drag. A line past its bound never moves against
the drag, so a still press sends nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-14 14:26:27 -04:00
1 parent 411d32b97e
commit 35a7bbb904
21 files changed
+920 -24

No files matched your search

+2 -2
View File
@@ -131,7 +131,7 @@ impl Docs {
return self.toast(text, Severity::Danger, cx);
}
};
if matches!(offered.target, Target::Cell | Target::Object) {
if matches!(offered.target, Target::Cell | Target::Object | Target::Picture | Target::Slide) {
let error = format!("a {:?} menu in a text document", offered.target);
let text = Docs::text(cx, "office.error.engine", &[("error", &error)]);
return self.toast(text, Severity::Danger, cx);
@@ -229,7 +229,7 @@ pub(crate) fn menu_of(this: &Entity<Docs>, offered: &Offered, cx: &App) -> Menu
Target::Table => TABLE,
Target::Frame => FRAME,
Target::Text => TEXT,
Target::Cell | Target::Object => unreachable!("offer_menu refuses them"),
Target::Cell | Target::Object | Target::Picture | Target::Slide => unreachable!("offer_menu refuses them"),
};
menu::entries(menu, rows, cx, |pick| pick.on(offered), item)
}
+7 -2
View File
@@ -57,6 +57,8 @@ pub struct Deck {
pub remarks_pane: Entity<crate::remarks::Pane>,
/// What the menus last checked: grid, guides, comments.
pub snap_menus: Option<(bool, bool, bool)>,
/// The canvas's right-click menu.
pub menu: Option<ely_office::menu::Offer<crate::menu::What>>,
/// Pictures of closed tabs and ended previews, freed later.
pub retired: ely_gpui_component::primitives::Retired,
}
@@ -103,6 +105,7 @@ impl Host for Deck {
tab.extra.dark = dark;
self.repaint_all(id, cx);
}
self.offer_menu(id, view, cx);
self.reread(id, view, cx);
}
}
@@ -172,6 +175,7 @@ impl Deck {
reading_fonts: false,
guide_drag: None,
snap_menus: Some((false, false, false)),
menu: None,
notes: Default::default(),
remarks_open: false,
preview: None,
@@ -379,7 +383,7 @@ impl Deck {
self.paint_next(id, cx);
}
fn body(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
pub(crate) fn body(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let Some(tab) = self.active_tab() else {
let title = match self.core.loading.first() {
Some((_, name)) => Self::text(cx, "office.loading", &[("name", name)]),
@@ -447,7 +451,8 @@ impl Render for Deck {
.on_action(cx.listener(Self::on_close_tab))
.child(shell)
.children(self.core_dialog(cx))
.children(self.dialog_element(cx));
.children(self.dialog_element(cx))
.children(self.context_menu(cx));
let root = crate::slideshow::bind(crate::insert::bind(crate::slides::bind(root, cx), cx), cx);
Self::bind_zoom(root, cx)
}
+1
View File
@@ -11,6 +11,7 @@ mod guides;
mod insert;
mod list;
mod media;
mod menu;
mod motion;
mod new;
mod notes;
+307
View File
@@ -0,0 +1,307 @@
//! The canvas's right-click menus, laid out as PowerPoint's (SHOW-03).
use ely_gpui_component::{
menus::{ContextMenu, Menu, MenuItem},
primitives::Severity,
};
use ely_lok::{
LokView,
menu::{Runs, Target, runs},
show::LAYOUTS,
};
use ely_office::menu::{self, Entry, Offer};
use ely_office::prelude::*;
use gpui::{App, Context, Entity, Window};
use crate::deck::Deck;
/// What was right-clicked.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Kind {
/// The slide, nothing selected.
Slide,
/// Text being edited.
Text,
/// A shape or a text box.
Object,
Picture,
Table,
}
/// A menu's kind and what the kernel lets run.
pub struct What {
pub kind: Kind,
runs: Runs,
}
/// One thing a menu does.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Pick {
Cut,
Copy,
Paste,
/// An index into `LAYOUTS`.
Layout(usize),
Comment,
Bullets,
Numbering,
Group,
Ungroup,
Front,
Forward,
Back,
Backward,
Replace,
Crop,
RowAbove,
RowBelow,
ColumnLeft,
ColumnRight,
DeleteRows,
DeleteColumns,
DeleteTable,
Merge,
}
const CLIPBOARD: [Pick; 3] = [Pick::Cut, Pick::Copy, Pick::Paste];
const LAYOUT_PICKS: [Pick; 12] = [
Pick::Layout(0),
Pick::Layout(1),
Pick::Layout(2),
Pick::Layout(3),
Pick::Layout(4),
Pick::Layout(5),
Pick::Layout(6),
Pick::Layout(7),
Pick::Layout(8),
Pick::Layout(9),
Pick::Layout(10),
Pick::Layout(11),
];
const SLIDE: &[Entry<Pick>] = &[
Entry::Line,
Entry::Sub("show.design.layout", &LAYOUT_PICKS),
Entry::Line,
Entry::Row(Pick::Comment),
];
const TEXT: &[Entry<Pick>] = &[
Entry::Line,
Entry::Row(Pick::Bullets),
Entry::Row(Pick::Numbering),
Entry::Line,
Entry::Row(Pick::Comment),
];
const OBJECT: &[Entry<Pick>] = &[
Entry::Line,
Entry::Sub("show.object.grouping", &[Pick::Group, Pick::Ungroup]),
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
Entry::Line,
Entry::Row(Pick::Comment),
];
const PICTURE: &[Entry<Pick>] = &[
Entry::Line,
Entry::Row(Pick::Replace),
Entry::Row(Pick::Crop),
Entry::Line,
Entry::Sub("show.object.grouping", &[Pick::Group, Pick::Ungroup]),
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
Entry::Line,
Entry::Row(Pick::Comment),
];
const TABLE: &[Entry<Pick>] = &[
Entry::Line,
Entry::Sub(
"doc.menu.insert",
&[Pick::RowAbove, Pick::RowBelow, Pick::ColumnLeft, Pick::ColumnRight],
),
Entry::Sub("doc.menu.delete", &[Pick::DeleteRows, Pick::DeleteColumns, Pick::DeleteTable]),
Entry::Row(Pick::Merge),
Entry::Line,
Entry::Row(Pick::Comment),
];
/// Each layout's label, in `LAYOUTS` order.
const LAYOUT_KEYS: [&str; 12] = [
"show.layout.20",
"show.layout.0",
"show.layout.1",
"show.layout.3",
"show.layout.19",
"show.layout.32",
"show.layout.12",
"show.layout.15",
"show.layout.14",
"show.layout.16",
"show.layout.18",
"show.layout.34",
];
impl menu::Pick for Pick {
fn row(self) -> (&'static str, &'static str) {
match self {
Pick::Cut => ("office.menu.cut", ".uno:Cut"),
Pick::Copy => ("office.menu.copy", ".uno:Copy"),
Pick::Paste => ("office.menu.paste", ".uno:Paste"),
Pick::Layout(index) => (LAYOUT_KEYS[index], ".uno:AssignLayout"),
Pick::Comment => ("doc.menu.comment", ".uno:InsertAnnotation"),
Pick::Bullets => ("doc.props.bullets", ".uno:DefaultBullet"),
Pick::Numbering => ("doc.props.numbering", ".uno:DefaultNumbering"),
Pick::Group => ("show.object.group", ".uno:FormatGroup"),
Pick::Ungroup => ("show.object.ungroup", ".uno:FormatUngroup"),
Pick::Front => ("office.menu.front", ".uno:BringToFront"),
Pick::Forward => ("office.menu.forward", ".uno:ObjectForwardOne"),
Pick::Back => ("office.menu.back", ".uno:SendToBack"),
Pick::Backward => ("office.menu.backward", ".uno:ObjectBackOne"),
Pick::Replace => ("show.object.replace", ".uno:ChangePicture"),
Pick::Crop => ("show.object.crop", ".uno:Crop"),
Pick::RowAbove => ("doc.menu.row_above", ".uno:InsertRowsBefore"),
Pick::RowBelow => ("doc.menu.row_below", ".uno:InsertRowsAfter"),
Pick::ColumnLeft => ("doc.menu.column_left", ".uno:InsertColumnsBefore"),
Pick::ColumnRight => ("doc.menu.column_right", ".uno:InsertColumnsAfter"),
Pick::DeleteRows => ("doc.menu.delete_rows", ".uno:DeleteRows"),
Pick::DeleteColumns => ("doc.menu.delete_columns", ".uno:DeleteColumns"),
Pick::DeleteTable => ("doc.menu.delete_table", ".uno:DeleteTable"),
Pick::Merge => ("doc.menu.merge", ".uno:MergeCells"),
}
}
}
impl What {
/// The kernel lets the pick's own command run now.
pub(crate) fn on(&self, pick: Pick) -> bool {
self.runs.allows(menu::Pick::row(pick).1)
}
}
impl Kind {
fn of(target: Target) -> Option<Kind> {
match target {
Target::Slide => Some(Kind::Slide),
Target::Text => Some(Kind::Text),
Target::Object => Some(Kind::Object),
Target::Picture => Some(Kind::Picture),
Target::Table => Some(Kind::Table),
Target::Frame | Target::Cell => None,
}
}
/// Its rows under the clipboard row.
fn rows(self) -> &'static [Entry<Pick>] {
match self {
Kind::Slide => SLIDE,
Kind::Text => TEXT,
Kind::Object => OBJECT,
Kind::Picture => PICTURE,
Kind::Table => TABLE,
}
}
/// Every command its rows run.
fn commands(self) -> Vec<&'static str> {
let rows = self.rows().iter().flat_map(|entry| match entry {
Entry::Row(pick) => std::slice::from_ref(pick),
Entry::Sub(_, picks) => picks,
Entry::Line => &[],
});
let mut commands: Vec<_> = CLIPBOARD.iter().chain(rows).map(|pick| menu::Pick::row(*pick).1).collect();
commands.sort_unstable();
commands.dedup();
commands
}
}
impl Deck {
/// Turns the kernel's context menu into Ely's.
pub(crate) fn offer_menu(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
let (offered, at) = match menu::take(view, cx) {
None => return,
Some(Ok(taken)) => taken,
Some(Err(error)) => return self.engine_failed(&error.to_string(), cx),
};
let Some(kind) = Kind::of(offered.target) else {
let error = format!("a {:?} menu in a presentation", offered.target);
return self.toast(Deck::text(cx, "office.error.engine", &[("error", &error)]), Severity::Danger, cx);
};
let commands = kind.commands();
self.ask_in(
id,
move |worker, doc| runs(worker, doc, &commands),
move |deck, read, _, cx| match read {
Ok(runs) => {
deck.menu = Some(Offer::new(deck.menu.as_ref(), id, What { kind, runs }, at));
cx.notify();
}
Err(error) => deck.engine_failed(&error.to_string(), cx),
},
cx,
);
}
/// The menu's host, for the active deck.
pub(crate) fn context_menu(&self, cx: &mut Context<Self>) -> Option<ContextMenu> {
let offer = self.menu.as_ref().filter(|offer| Some(offer.tab) == self.core.active)?;
Some(offer.host("show-menu", menu_of(&cx.entity(), &offer.what, cx)))
}
/// Runs one menu row.
pub(crate) fn choose(&mut self, pick: Pick, window: &mut Window, cx: &mut Context<Self>) {
log::info!("show: the menu picks {pick:?}");
let id = match pick {
Pick::Cut | Pick::Copy | Pick::Paste => return self.clipboard(pick, cx),
Pick::Comment => return self.ask_remark(None, window, cx),
Pick::Replace => return self.pick_picture(true, cx),
Pick::Layout(index) => {
return self.design_command("slide.layout", &[&LAYOUTS[index].to_string()], cx);
}
Pick::Bullets => "list.bullets",
Pick::Numbering => "list.numbering",
Pick::Group => "object.group",
Pick::Ungroup => "object.ungroup",
Pick::Front => "image.front",
Pick::Forward => "image.forward",
Pick::Back => "image.back",
Pick::Backward => "image.backward",
Pick::Crop => "image.crop",
Pick::RowAbove => "table.row_above",
Pick::RowBelow => "table.row_below",
Pick::ColumnLeft => "table.column_left",
Pick::ColumnRight => "table.column_right",
Pick::DeleteRows => "table.delete_rows",
Pick::DeleteColumns => "table.delete_columns",
Pick::DeleteTable => "table.delete",
Pick::Merge => "table.merge",
};
self.object_command(id, &[], cx);
}
fn clipboard(&mut self, pick: Pick, cx: &mut Context<Self>) {
let Some(view) = self.active_tab().map(|tab| tab.view.clone()) else {
log::warn!("show: {pick:?} with no deck");
return;
};
view.update(cx, |view, _| match pick {
Pick::Paste => view.paste(),
_ => view.copy(pick == Pick::Cut),
});
}
}
/// The clipboard row, then the target's rows.
pub(crate) fn menu_of(this: &Entity<Deck>, what: &What, cx: &App) -> Menu {
let item = |pick: Pick| {
let this = this.clone();
MenuItem::new(Deck::text(cx, menu::Pick::row(pick).0, &[]))
.disabled(!what.on(pick))
.on_click(move |window, cx| this.update(cx, |deck, cx| deck.choose(pick, window, cx)))
};
let menu = menu::clipboard(Menu::new(), CLIPBOARD, item);
menu::entries(menu, what.kind.rows(), cx, |pick| what.on(pick), item)
}
+1
View File
@@ -8,6 +8,7 @@ use gpui::{AppContext as _, Entity, TestAppContext};
use crate::deck::Deck;
mod menu;
mod motion;
mod notes;
mod objects;
+138
View File
@@ -0,0 +1,138 @@
use ely_gpui_component::theme::Theme;
use ely_lok::show::Objects;
use ely_office::prelude::*;
use gpui::{Entity, Modifiers, MouseButton, Pixels, Point, TestAppContext, VisualTestContext};
use super::objects::{act, blank, objects};
use super::settle;
use crate::deck::Deck;
use crate::menu::{Kind, Pick};
/// The canvas with the menu's host over it.
struct Host(Entity<Deck>);
impl gpui::Render for Host {
fn render(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
use gpui::{ParentElement as _, Styled as _};
let body = self.0.update(cx, |deck, cx| deck.body(window, cx));
let menu = self.0.update(cx, |deck, cx| deck.context_menu(cx));
gpui::div().relative().size_full().flex().flex_col().child(body).children(menu)
}
}
fn frames(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
}
/// A slide point, in 1/100 mm, in window pixels.
fn spot(cx: &mut VisualTestContext, deck: &Entity<Deck>, (x, y): (i64, i64)) -> Point<Pixels> {
frames(cx);
cx.update(|_, app| {
let read = deck.read(app).active_tab().expect("a tab").view.read(app);
let twips = |mm100| crate::object::twips(mm100) as i32;
read.origin() + read.place((twips(x), twips(y)))
})
}
/// A point `1/part` into the first object of `kind`.
fn within(cx: &mut VisualTestContext, deck: &Entity<Deck>, kind: &str, part: i64) -> Point<Pixels> {
let (x, y, w, h) = cx.update(|_, app| {
let objects = objects(app, deck).expect("objects");
objects.objects.iter().find(|object| object.kind == kind).expect("the object").rect
});
spot(cx, deck, (x + w / part, y + h / part))
}
fn click(cx: &mut VisualTestContext, at: Point<Pixels>, button: MouseButton) {
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_mouse_down(at, button, Modifiers::none());
cx.simulate_mouse_up(at, button, Modifiers::none());
}
/// Right-clicks and waits for a menu of `kind`.
fn right_click(cx: &mut VisualTestContext, deck: &Entity<Deck>, at: Point<Pixels>, kind: Kind) {
cx.update(|_, app| deck.update(app, |deck, _| deck.menu = None));
click(cx, at, MouseButton::Right);
for _ in 0..500 {
cx.run_until_parked();
if cx.update(|_, app| deck.read(app).menu.as_ref().is_some_and(|offer| offer.what.kind == kind)) {
return frames(cx);
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let got = cx.update(|_, app| deck.read(app).menu.as_ref().map(|offer| offer.what.kind));
panic!("no {kind:?} menu, got {got:?}");
}
fn on(cx: &mut VisualTestContext, deck: &Entity<Deck>, pick: Pick) -> bool {
cx.update(|_, app| deck.read(app).menu.as_ref().expect("a menu").what.on(pick))
}
fn choose(cx: &mut VisualTestContext, deck: &Entity<Deck>, pick: Pick) {
cx.update(|window, app| deck.update(app, |deck, cx| deck.choose(pick, window, cx)));
}
fn table_height(objects: &Objects) -> Option<i64> {
objects.objects.iter().find(|object| object.kind == "table").map(|object| object.rect.3)
}
#[test]
fn right_clicks_open_powerpoint_rows_on_each_target() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
cx.update(|cx| Theme::update(cx, |theme| theme.reduced_motion = true));
settle(&cx, |app| objects(app, &deck).is_some_and(|o| !o.objects.is_empty()));
let window = cx.add_window(|_, _| Host(deck.clone()));
let mut shown = VisualTestContext::from_window(window.into(), &cx);
let corner = spot(&mut shown, &deck, (27_000, 15_000));
right_click(&mut shown, &deck, corner, Kind::Slide);
let clipboard = shown.debug_bounds("menu-button-0-0").expect("the clipboard row");
assert!((clipboard.origin - corner).x < gpui::px(40.0), "at the click");
assert!(on(&mut shown, &deck, Pick::Layout(0)) && on(&mut shown, &deck, Pick::Comment));
// Typing in the title: list rows.
let title = within(&mut shown, &deck, "text", 2);
click(&mut shown, title, MouseButton::Left);
right_click(&mut shown, &deck, title, Kind::Text);
assert!(on(&mut shown, &deck, Pick::Bullets) && on(&mut shown, &deck, Pick::Numbering));
let rectangle = ("draw.shape", &["rectangle", "true"][..]);
act(&mut cx, &deck, rectangle, |o| o.selection.as_ref().is_some_and(|s| s.kind == "shape"));
let shape = within(&mut shown, &deck, "shape", 2);
right_click(&mut shown, &deck, shape, Kind::Object);
assert!(on(&mut shown, &deck, Pick::Back) && on(&mut shown, &deck, Pick::Forward));
assert!(!on(&mut shown, &deck, Pick::Group), "one shape does not group");
choose(&mut shown, &deck, Pick::Back);
settle(&cx, |app| objects(app, &deck).is_some_and(|o| o.objects.first().is_some_and(|first| first.kind == "shape")));
let path = dir.path().join("red.png");
image::RgbaImage::from_pixel(300, 200, image::Rgba([255, 0, 0, 255])).save(&path).expect("the png writes");
let image = ("image.insert", &[ely_lok::file_url(&path)][..]);
let image: (&str, Vec<&str>) = (image.0, image.1.iter().map(String::as_str).collect());
act(&mut cx, &deck, (image.0, &image.1), |o| o.selection.as_ref().is_some_and(|s| s.kind == "image"));
let picture = within(&mut shown, &deck, "image", 2);
right_click(&mut shown, &deck, picture, Kind::Picture);
assert!(on(&mut shown, &deck, Pick::Replace) && on(&mut shown, &deck, Pick::Crop));
act(&mut cx, &deck, ("table.insert", &["2", "2"]), |o| table_height(o).is_some());
// A quarter in: the middle is where the borders cross.
let cell = within(&mut shown, &deck, "table", 4);
right_click(&mut shown, &deck, cell, Kind::Table);
assert!(on(&mut shown, &deck, Pick::RowAbove), "inserting needs no cell");
// Deleting rows needs the cursor in a cell.
click(&mut shown, cell, MouseButton::Left);
right_click(&mut shown, &deck, cell, Kind::Table);
assert!(on(&mut shown, &deck, Pick::DeleteRows) && on(&mut shown, &deck, Pick::Merge));
let before = cx.update(|app| objects(app, &deck).and_then(|o| table_height(&o))).expect("a table");
choose(&mut shown, &deck, Pick::RowBelow);
settle(&cx, |app| objects(app, &deck).and_then(|o| table_height(&o)).is_some_and(|h| h > before));
// A right click that ends an edit gets no menu.
click(&mut shown, corner, MouseButton::Left);
right_click(&mut shown, &deck, corner, Kind::Slide);
choose(&mut shown, &deck, Pick::Layout(0));
let layout = |app: &gpui::App| deck.read(app).active_tab().and_then(|tab| tab.extra.design.as_ref().map(|d| d.layout));
settle(&cx, |app| layout(app) == Some(20));
}
+2 -2
View File
@@ -211,9 +211,9 @@ impl Book {
};
let kind = match offered.target {
Target::Cell => Kind::Cell,
Target::Object => Kind::Object,
Target::Object | Target::Picture => Kind::Object,
Target::Text => Kind::Text,
Target::Table | Target::Frame => {
Target::Table | Target::Frame | Target::Slide => {
let error = format!("a {:?} menu in a spreadsheet", offered.target);
return self.toast(Book::text(cx, "office.error.engine", &[("error", &error)]), Severity::Danger, cx);
}