R5.2: ElyDoc's right-click menu follows Word by target, items enabled as the kernel's menu allows; two rounds, R2 PASS

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 08:52:54 -04:00
1 parent e3c515b2f4
commit 9d80ba6f39
22 files changed
+764 -146

No files matched your search

+1 -1
View File
@@ -48,7 +48,7 @@ impl Docs {
.size_full()
.child(tab.view.clone())
.children(self.find_box(cx))
.children(self.spelling_menu(cx))
.children(self.context_menu(cx))
.into_any_element();
let risk = self.stopped_banner(cx).or_else(|| self.risk_banner(cx));
let fonts = tab.view.read(cx).missing_fonts().join(", ");
+2 -2
View File
@@ -37,7 +37,7 @@ pub struct Docs {
pub review: Entity<Review>,
/// What the right column shows.
pub side: Side,
pub offer: Option<crate::spell::Offer>,
pub offer: Option<crate::menu::Offer>,
pub merge: Entity<crate::merge::Merge>,
pub navigator: Entity<crate::navigate::Navigator>,
/// The left column shows the navigator.
@@ -88,7 +88,7 @@ impl Host for Docs {
fn observed(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
self.count(id, view, cx);
self.found(id, view, cx);
self.offer_spelling(id, view, cx);
self.offer_menu(id, view, cx);
}
/// A dialog about a closed tab goes with it.
+1 -1
View File
@@ -7,13 +7,13 @@ mod compare;
mod docs;
mod files;
mod insert;
mod menu;
mod merge;
mod navigate;
mod output;
mod props;
mod refs;
mod review;
mod spell;
use ely_platform::AppId;
+275
View File
@@ -0,0 +1,275 @@
//! The right-click menu, laid out as Word's (DOC-02, DOC-07, DOC-12).
use ely_gpui_component::{
menus::{ContextMenu, Menu, MenuItem},
primitives::{IconName, Severity},
};
use ely_lok::{
LokView,
menu::{Offered, Target},
};
use gpui::{App, Context, Entity, Pixels, Point, Window};
use crate::docs::Docs;
use crate::refs::{InsertCaption, InsertComment, InsertLink};
use ely_office::prelude::*;
/// A menu the kernel offered, asked for at a point.
pub struct Offer {
tab: u64,
pub(crate) offered: Offered,
/// Bumps for each menu, so each opens once.
request: (u64, Point<Pixels>),
}
/// One thing the menu does.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Pick {
Cut,
Copy,
Paste,
Link,
Comment,
ColumnLeft,
ColumnRight,
RowAbove,
RowBelow,
DeleteRows,
DeleteColumns,
DeleteTable,
Merge,
SplitColumns,
SplitRows,
EvenRows,
EvenColumns,
Inline,
Square,
TopBottom,
InFront,
Front,
Forward,
Back,
Backward,
Caption,
}
/// A row, a submenu or a line.
enum Entry {
Row(Pick),
Sub(&'static str, &'static [Pick]),
Line,
}
const TABLE: &[Entry] = &[
Entry::Sub(
"doc.menu.insert",
&[Pick::ColumnLeft, Pick::ColumnRight, Pick::RowAbove, Pick::RowBelow],
),
Entry::Sub(
"doc.menu.delete",
&[Pick::DeleteRows, Pick::DeleteColumns, Pick::DeleteTable],
),
Entry::Row(Pick::Merge),
Entry::Sub("doc.menu.split", &[Pick::SplitColumns, Pick::SplitRows]),
Entry::Row(Pick::EvenRows),
Entry::Row(Pick::EvenColumns),
Entry::Line,
Entry::Row(Pick::Link),
Entry::Row(Pick::Comment),
];
const FRAME: &[Entry] = &[
Entry::Sub(
"doc.menu.wrap",
&[Pick::Inline, Pick::Square, Pick::TopBottom, Pick::InFront],
),
Entry::Sub("doc.menu.front", &[Pick::Front, Pick::Forward]),
Entry::Sub("doc.menu.back", &[Pick::Back, Pick::Backward]),
Entry::Line,
Entry::Row(Pick::Caption),
Entry::Row(Pick::Comment),
];
const TEXT: &[Entry] = &[Entry::Row(Pick::Link), Entry::Row(Pick::Comment)];
impl Pick {
/// Its label key and the kernel command that gates it.
fn row(self) -> (&'static str, &'static str) {
match self {
Pick::Cut => ("doc.menu.cut", ".uno:Cut"),
Pick::Copy => ("doc.menu.copy", ".uno:Copy"),
Pick::Paste => ("doc.menu.paste", ".uno:Paste"),
Pick::Link => ("doc.menu.link", ".uno:InsertHyperlink"),
Pick::Comment => ("doc.menu.comment", ".uno:InsertAnnotation"),
Pick::ColumnLeft => ("doc.menu.column_left", ".uno:InsertColumnsBefore"),
Pick::ColumnRight => ("doc.menu.column_right", ".uno:InsertColumnsAfter"),
Pick::RowAbove => ("doc.menu.row_above", ".uno:InsertRowsBefore"),
Pick::RowBelow => ("doc.menu.row_below", ".uno:InsertRowsAfter"),
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"),
Pick::SplitColumns => ("doc.menu.split_columns", ".uno:SplitCell"),
Pick::SplitRows => ("doc.menu.split_rows", ".uno:SplitCell"),
Pick::EvenRows => ("doc.menu.even_rows", ".uno:DistributeRows"),
Pick::EvenColumns => ("doc.menu.even_columns", ".uno:DistributeColumns"),
Pick::Inline => ("doc.menu.inline", ".uno:SetAnchorToChar"),
Pick::Square => ("doc.menu.square", ".uno:WrapOn"),
Pick::TopBottom => ("doc.menu.top_bottom", ".uno:WrapOff"),
Pick::InFront => ("doc.menu.in_front", ".uno:WrapThrough"),
Pick::Front => ("doc.menu.front", ".uno:BringToFront"),
Pick::Forward => ("doc.menu.forward", ".uno:ObjectForwardOne"),
Pick::Back => ("doc.menu.back", ".uno:SendToBack"),
Pick::Backward => ("doc.menu.backward", ".uno:ObjectBackOne"),
Pick::Caption => ("doc.menu.caption", ".uno:InsertCaptionDialog"),
}
}
/// The kernel offers it and it runs now.
pub(crate) fn on(self, offered: &Offered) -> bool {
offered.allows(self.row().1)
}
}
impl Docs {
/// 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 Some((json, at)) = view.update(cx, |view, _| view.take_menu()) else {
return;
};
let offered = match ely_lok::menu::read(&json) {
Ok(offered) => offered,
Err(error) => {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
return self.toast(text, Severity::Danger, cx);
}
};
// Without a click it opens at the caret.
let caret = || view.read(cx).cursor_bounds().map(|caret| caret.bottom_left());
let Some(at) = at.or_else(caret) else {
log::warn!("doc: a menu with no click and no caret");
return;
};
let number = self.offer.as_ref().map_or(1, |offer| offer.request.0 + 1);
let suggestions = offered.spelling.as_ref().map_or(0, |spelling| spelling.suggestions.len());
log::info!("doc: a {:?} menu, {suggestions} suggestions", offered.target);
self.offer = Some(Offer {
tab: id,
offered,
request: (number, at),
});
cx.notify();
}
/// The menu's host, for the active tab.
pub(crate) fn context_menu(&self, cx: &mut Context<Self>) -> Option<ContextMenu> {
let offer = self
.offer
.as_ref()
.filter(|offer| Some(offer.tab) == self.core.active)?;
let menu = menu_of(&cx.entity(), &offer.offered, cx);
Some(ContextMenu::new("doc-menu", menu).manual(Some(offer.request)))
}
/// Runs one menu row.
pub(crate) fn choose(&mut self, pick: Pick, window: &mut Window, cx: &mut Context<Self>) {
log::info!("doc: the menu picks {pick:?}");
let (id, values): (&str, &[&str]) = match pick {
Pick::Cut | Pick::Copy | Pick::Paste => return self.clipboard(pick, cx),
Pick::Link => return self.on_link(&InsertLink, window, cx),
Pick::Comment => return self.on_comment(&InsertComment, window, cx),
Pick::Caption => return self.on_caption(&InsertCaption, window, cx),
Pick::ColumnLeft => ("table.column_left", &[]),
Pick::ColumnRight => ("table.column_right", &[]),
Pick::RowAbove => ("table.row_above", &[]),
Pick::RowBelow => ("table.row_below", &[]),
Pick::DeleteRows => ("table.delete_rows", &[]),
Pick::DeleteColumns => ("table.delete_columns", &[]),
Pick::DeleteTable => ("table.delete", &[]),
Pick::Merge => ("table.merge", &[]),
Pick::SplitColumns => ("table.split", &["2", "false"]),
Pick::SplitRows => ("table.split", &["2", "true"]),
Pick::EvenRows => ("table.even_rows", &[]),
Pick::EvenColumns => ("table.even_columns", &[]),
Pick::Inline => ("image.anchor_as_char", &[]),
Pick::Square => ("image.wrap_on", &[]),
Pick::TopBottom => ("image.wrap_off", &[]),
Pick::InFront => ("image.wrap_through", &[]),
Pick::Front => ("image.front", &[]),
Pick::Forward => ("image.forward", &[]),
Pick::Back => ("image.back", &[]),
Pick::Backward => ("image.backward", &[]),
};
self.run_active(id, values, 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!("doc: {pick:?} with no document");
return;
};
view.update(cx, |view, _| match pick {
Pick::Paste => view.paste(),
_ => view.copy(pick == Pick::Cut),
});
}
}
/// Spelling first, then the clipboard row, then the target's rows.
pub(crate) fn menu_of(this: &Entity<Docs>, offered: &Offered, cx: &App) -> Menu {
let item = |pick: Pick| {
let this = this.clone();
MenuItem::new(Docs::text(cx, pick.row().0, &[]))
.disabled(!pick.on(offered))
.on_click(move |window, cx| this.update(cx, |docs, cx| docs.choose(pick, window, cx)))
};
let mut menu = Menu::new();
if let Some(spelling) = &offered.spelling {
for (label, rule) in &spelling.suggestions {
let (this, rule) = (this.clone(), rule.clone());
menu = menu.item(MenuItem::new(label.clone()).on_click(move |_, cx| {
this.update(cx, |docs, cx| docs.run_active("spell.apply", &[&rule], cx))
}));
}
if spelling.suggestions.is_empty() {
menu = menu.item(MenuItem::new(Docs::text(cx, "doc.spell.none", &[])).disabled(true));
}
let this = this.clone();
let ignore = MenuItem::new(Docs::text(cx, "doc.spell.ignore_all", &[])).on_click(
move |_, cx| {
this.update(cx, |docs, cx| {
docs.run_active("spell.ignore_all", &["Spelling"], cx)
})
},
);
menu = menu.item(ignore).separator();
}
let clipboard = [
(Pick::Cut, IconName::Scissors),
(Pick::Copy, IconName::Copy),
(Pick::Paste, IconName::Clipboard),
];
menu = menu
.buttons(clipboard.map(|(pick, icon)| item(pick).icon(icon)))
.separator();
let entries = match offered.target {
Target::Table => TABLE,
Target::Frame => FRAME,
Target::Text => TEXT,
};
for entry in entries {
menu = match entry {
Entry::Row(pick) => menu.item(item(*pick)),
Entry::Sub(key, picks) => {
let sub = picks.iter().fold(Menu::new(), |sub, pick| sub.item(item(*pick)));
let on = picks.iter().any(|pick| pick.on(offered));
menu.item(MenuItem::submenu(Docs::text(cx, key, &[]), sub).disabled(!on))
}
Entry::Line => menu.separator(),
};
}
menu
}
#[cfg(test)]
mod tests;
+117
View File
@@ -0,0 +1,117 @@
use ely_gpui_component::theme::Theme;
use ely_lok::{LokView, menu::Target};
use gpui::{Entity, Modifiers, MouseButton, Pixels, Point, TestAppContext, VisualTestContext};
use super::Pick;
use crate::docs::Docs;
use crate::tests::{blank, docs, settled, text_of, type_into, with_window, written};
use ely_office::prelude::*;
/// The view with the menu's host over it.
struct Host(Entity<Docs>, Entity<LokView>);
impl gpui::Render for Host {
fn render(&mut self, _: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
use gpui::{ParentElement as _, Styled as _};
let menu = self.0.update(cx, |docs, cx| docs.context_menu(cx));
gpui::div().relative().size_full().child(self.1.clone()).children(menu)
}
}
fn frames(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
}
/// The caret's middle, in window pixels.
fn caret(cx: &mut VisualTestContext, view: &Entity<LokView>) -> Point<Pixels> {
frames(cx);
cx.update(|_, app| view.read(app).cursor_bounds().expect("a caret").center())
}
/// Right-clicks through the view and waits for the target's menu.
fn right_click(cx: &mut VisualTestContext, docs: &Entity<Docs>, at: Point<Pixels>, target: Target) {
cx.update(|_, app| docs.update(app, |docs, _| docs.offer = None));
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
let shown = |app: &gpui::App| {
docs.read(app)
.offer
.as_ref()
.is_some_and(|offer| offer.offered.target == target)
};
for _ in 0..500 {
cx.run_until_parked();
if cx.update(|_, app| shown(app)) {
return frames(cx);
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
panic!("no {target:?} menu");
}
#[test]
fn a_right_click_opens_word_rows_where_it_lands() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
cx.update(|cx| {
ely_gpui_component::init_for_tests(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
});
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Text");
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
let window = cx.add_window(|_, _| Host(docs.clone(), view.clone()));
let mut shown = VisualTestContext::from_window(window.into(), &cx);
let text = caret(&mut shown, &view) - gpui::point(gpui::px(8.0), gpui::px(0.0));
right_click(&mut shown, &docs, text, Target::Text);
let cut = shown.debug_bounds("menu-button-0-0").expect("the clipboard row");
let off = cut.origin - text;
assert!(
off.x >= gpui::px(0.0) && off.x < gpui::px(40.0) && off.y < gpui::px(40.0),
"the menu opens at the click, not the caret: {off:?}"
);
let on = |shown: &mut VisualTestContext, pick: Pick| {
shown.update(|_, app| pick.on(&docs.read(app).offer.as_ref().expect("an offer").offered))
};
assert!(on(&mut shown, Pick::Comment));
assert!(!on(&mut shown, Pick::Link), "the kernel links a selection");
assert!(!on(&mut shown, Pick::Cut), "nothing is selected");
with_window(&mut cx, &docs, |docs, _, cx| docs.insert_table(2.0, 2.0, cx));
settled(&cx, &docs);
let cell = caret(&mut shown, &view);
right_click(&mut shown, &docs, cell, Target::Table);
assert!(on(&mut shown, Pick::RowBelow) && on(&mut shown, Pick::SplitRows));
assert!(!on(&mut shown, Pick::Merge), "one cell does not merge");
for pick in [Pick::RowBelow, Pick::ColumnRight] {
with_window(&mut cx, &docs, |docs, window, cx| docs.choose(pick, window, cx));
settled(&cx, &docs);
}
let path = dir.path().join("grown.odt");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, path.clone(), None, window, cx)
});
written(&cx, &path, None);
let flat = text_of(&mut cx, &docs, &path);
assert_eq!(flat.matches("<table:table-row").count(), 3, "a row below");
assert_eq!(flat.matches("<table:table-cell").count(), 9, "and a column right");
}
#[test]
fn link_follows_the_kernel_flag() {
let menu = |enabled: bool| {
let json = format!(
r#"{{"menu":[{{"text":"x","type":"command","command":".uno:InsertHyperlink","enabled":"{enabled}"}}]}}"#
);
ely_lok::menu::read(&json).expect("the menu parses")
};
assert!(Pick::Link.on(&menu(true)));
assert!(!Pick::Link.on(&menu(false)), "protected text gets no link");
}
-85
View File
@@ -1,85 +0,0 @@
//! Spelling suggestions from the kernel's context menu (DOC-12).
use ely_gpui_component::{
menus::{ContextMenu, Menu, MenuItem},
primitives::Severity,
};
use ely_lok::LokView;
use gpui::{Context, Entity, IntoElement, Pixels, Point};
use crate::docs::Docs;
use ely_office::prelude::*;
/// A misspelled word's menu, asked for at a point.
pub struct Offer {
tab: u64,
pub(crate) suggestions: Vec<(String, String)>,
/// Bumps for each menu, so each opens once.
request: (u64, Point<Pixels>),
}
impl Docs {
/// Turns the kernel's context menu into a spelling offer.
pub(crate) fn offer_spelling(
&mut self,
id: u64,
view: &Entity<LokView>,
cx: &mut Context<Self>,
) {
let Some(json) = view.update(cx, |view, _| view.take_menu()) else {
return;
};
let spelling = match ely_lok::review::spelling(&json) {
Ok(spelling) => spelling,
Err(error) => {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
return self.toast(text, Severity::Danger, cx);
}
};
let Some(spelling) = spelling else {
log::info!("doc: the kernel menu has no spelling");
return;
};
let Some(caret) = view.read(cx).cursor_bounds() else {
log::warn!("doc: a spelling menu without a caret");
return;
};
let number = self.offer.as_ref().map_or(1, |offer| offer.request.0 + 1);
log::info!("doc: {} spelling suggestions", spelling.suggestions.len());
self.offer = Some(Offer {
tab: id,
suggestions: spelling.suggestions,
request: (number, caret.bottom_left()),
});
cx.notify();
}
/// The spelling menu's host, for the active tab.
pub(crate) fn spelling_menu(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
let offer = self
.offer
.as_ref()
.filter(|offer| Some(offer.tab) == self.core.active)?;
let this = cx.entity();
let mut menu = Menu::new();
for (label, rule) in &offer.suggestions {
let (this, rule) = (this.clone(), rule.clone());
menu = menu.item(MenuItem::new(label.clone()).on_click(move |_, cx| {
this.update(cx, |docs, cx| docs.run_active("spell.apply", &[&rule], cx))
}));
}
if offer.suggestions.is_empty() {
let none = Docs::text(cx, "doc.spell.none", &[]);
menu = menu.item(MenuItem::new(none).disabled(true));
}
let ignore = Docs::text(cx, "doc.spell.ignore_all", &[]);
let menu = menu
.separator()
.item(MenuItem::new(ignore).on_click(move |_, cx| {
this.update(cx, |docs, cx| {
docs.run_active("spell.ignore_all", &["Spelling"], cx)
})
}));
Some(ContextMenu::new("doc-spelling", menu).manual(Some(offer.request)))
}
}
+2 -1
View File
@@ -126,7 +126,8 @@ fn a_misspelled_word_offers_suggestions() {
settle(&cx, |app| docs.read(app).offer.is_some());
let rule = cx.update(|app| {
let offer = docs.read(app).offer.as_ref().expect("an offer");
let (_, rule) = offer
let spelling = offer.offered.spelling.as_ref().expect("a spelling menu");
let (_, rule) = spelling
.suggestions
.iter()
.find(|(label, _)| label == "The")
+1
View File
@@ -15,6 +15,7 @@ pub(crate) mod input;
mod latency;
mod layout;
mod media;
mod menus;
mod motion;
mod output;
mod paint;
+116
View File
@@ -0,0 +1,116 @@
use super::input::open;
use super::layout::png;
use super::*;
use crate::menu::{self, Target};
pub(super) const DOC: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:body><office:text><text:p>The cat ran.</text:p>
<table:table table:name="T"><table:table-column table:number-columns-repeated="2"/>
<table:table-row><table:table-cell><text:p>left</text:p></table:table-cell><table:table-cell><text:p>right</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>down</text:p></table:table-cell><table:table-cell><text:p>more</text:p></table:table-cell></table:table-row>
</table:table><text:p>after</text:p></office:text></office:body>
</office:document>"#;
const WAIT: std::time::Duration = std::time::Duration::from_secs(5);
/// Right-clicks at a point and reads the kernel's menu.
fn right(worker: &mut Worker, doc: &Doc, seen: &mut Events, (x, y): (i32, i32)) -> menu::Offered {
click(worker, doc, x, y);
press(worker, doc, seen, (x, y))
}
/// Right-clicks without moving the selection first.
fn press(worker: &mut Worker, doc: &Doc, seen: &mut Events, (x, y): (i32, i32)) -> menu::Offered {
for kind in [0, 1] {
let event = Mouse { kind, x, y, count: 1, buttons: 4, modifiers: 0 };
worker.post_mouse(doc, event).expect("the click posts");
}
*seen = poll_until(worker, doc, seen, WAIT, |seen| seen.menu.is_some());
menu::read(&seen.menu.take().expect("a menu")).expect("the menu parses")
}
/// The middle of the first match of `word`.
pub(super) fn find(worker: &mut Worker, doc: &Doc, seen: &mut Events, word: &str) -> (i32, i32) {
let before = seen.searched;
worker
.run(doc, "find.run", &[word, "false", "0", "0", "0", "1"])
.expect("the search runs");
*seen = poll_until(worker, doc, seen, WAIT, |seen| seen.searched > before);
let (x, y, _, h) = seen.found[0];
(x + 60, y + h / 2)
}
#[test]
fn a_right_click_names_its_target() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(DOC);
click(&mut worker, &doc, 150, 150);
let mut seen = Events::default();
let at = find(&mut worker, &doc, &mut seen, "cat");
let text = right(&mut worker, &doc, &mut seen, at);
assert_eq!(text.target, Target::Text);
assert!(text.allows(".uno:InsertAnnotation") && text.allows(".uno:Paste"));
assert!(!text.allows(".uno:Cut"), "nothing is selected");
let at = find(&mut worker, &doc, &mut seen, "left");
let table = right(&mut worker, &doc, &mut seen, at);
assert_eq!(table.target, Target::Table);
for uno in [
".uno:InsertRowsBefore",
".uno:InsertColumnsAfter",
".uno:DeleteTable",
".uno:SplitCell",
] {
assert!(table.allows(uno), "{uno} runs in a cell");
}
assert!(!table.allows(".uno:MergeCells"), "one cell does not merge");
let at = find(&mut worker, &doc, &mut seen, "after");
click(&mut worker, &doc, at.0, at.1);
let blue = png(dir.path(), "blue.png", [0, 0, 255, 255]);
worker.run(&doc, "image.insert", &[&blue]).expect("the image lands");
seen = poll_until(&mut worker, &doc, &seen, WAIT, |seen| seen.graphic.is_some());
let (x, y, w, h) = seen.graphic.expect("the image is selected");
let frame = right(&mut worker, &doc, &mut seen, (x + w / 2, y + h / 2));
assert_eq!(frame.target, Target::Frame);
for uno in [".uno:Copy", ".uno:WrapOn", ".uno:BringToFront", ".uno:InsertCaptionDialog"] {
assert!(frame.allows(uno), "{uno} runs on an image");
}
assert!(!frame.allows(".uno:InsertAnnotation"), "no comment on an image");
}
const LOCKED: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:body><office:text><text:p>open words</text:p>
<text:section text:name="S" text:protected="true"><text:p>sealed words</text:p></text:section>
<table:table table:name="T"><table:table-column table:number-columns-repeated="2"/>
<table:table-row><table:table-cell table:protected="true"><text:p>fixed</text:p></table:table-cell><table:table-cell><text:p>free</text:p></table:table-cell></table:table-row>
</table:table><text:p>after</text:p></office:text></office:body>
</office:document>"#;
#[test]
fn protected_text_offers_no_link() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open(LOCKED);
click(&mut worker, &doc, 150, 150);
let mut seen = Events::default();
for (word, target, link) in [
("open", Target::Text, true),
("sealed", Target::Text, false),
("free", Target::Table, true),
("fixed", Target::Table, false),
] {
let at = find(&mut worker, &doc, &mut seen, word);
let offered = press(&mut worker, &doc, &mut seen, at);
assert_eq!(offered.target, target, "{word}");
assert_eq!(offered.allows(".uno:InsertHyperlink"), link, "{word} selected");
}
let at = find(&mut worker, &doc, &mut seen, "open");
let caret = right(&mut worker, &doc, &mut seen, at);
assert!(!caret.allows(".uno:InsertHyperlink"), "the kernel links a selection");
}
+5 -4
View File
@@ -1,6 +1,6 @@
use super::input::{open, saved};
use super::*;
use crate::review;
use crate::{menu, review};
const TEXT: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
@@ -125,8 +125,9 @@ fn spelling_suggests_and_ignores() {
);
let (x, y, _, h) = seen.found[0];
let json = menu(&mut worker, &doc, &mut seen, (x + 60, y + h / 2)).expect("a menu");
let spelling = review::spelling(&json)
let spelling = menu::read(&json)
.expect("the menu parses")
.spelling
.expect("a spelling menu");
let (_, rule) = spelling
.suggestions
@@ -145,13 +146,13 @@ fn spelling_suggests_and_ignores() {
);
let (x, y, _, h) = seen.found[0];
let json = menu(&mut worker, &doc, &mut seen, (x + 60, y + h / 2)).expect("a menu");
assert!(review::spelling(&json).expect("parses").is_some());
assert!(menu::read(&json).expect("parses").spelling.is_some());
worker
.run(&doc, "spell.ignore_all", &["Spelling"])
.expect("ignore all");
let json = menu(&mut worker, &doc, &mut seen, (x + 60, y + h / 2)).expect("a menu");
assert_eq!(
review::spelling(&json).expect("parses"),
menu::read(&json).expect("parses").spelling,
None,
"an ignored word is no longer wrong"
);
+1
View File
@@ -7,6 +7,7 @@ mod ffi;
mod handshake;
pub mod history;
mod ipc;
pub mod menu;
pub mod review;
pub mod sheet;
pub mod show;
+104
View File
@@ -0,0 +1,104 @@
//! The kernel's context menu: target and flags (DOC-02).
use std::{collections::HashMap, io};
use crate::review::{entries, invalid, parse};
/// Where a right click landed, per the kernel's menu.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Target {
Text,
Table,
/// A graphic, an object or a frame.
Frame,
}
/// A misspelled word's menu: suggestions as the kernel's rules.
#[derive(Clone, Debug, PartialEq)]
pub struct Spelling {
/// Label and `ApplyRule` value for each suggestion.
pub suggestions: Vec<(String, String)>,
}
/// One menu the kernel offered.
#[derive(Clone, Debug, PartialEq)]
pub struct Offered {
pub target: Target,
pub spelling: Option<Spelling>,
/// Each command, at any depth, and whether it runs now.
enabled: HashMap<String, bool>,
}
impl Offered {
/// The kernel offers `uno` and it runs now.
pub fn allows(&self, uno: &str) -> bool {
self.enabled.get(uno) == Some(&true)
}
}
/// Reads a `LOK_CALLBACK_CONTEXT_MENU` payload.
pub fn read(json: &str) -> io::Result<Offered> {
let list = parse(json)?;
let mut enabled = HashMap::new();
let mut suggestions = Vec::new();
walk(entries(&list, "menu")?, &mut enabled, &mut suggestions)?;
let spelling = enabled
.keys()
.any(|command| command.starts_with(".uno:SpellCheckIgnoreAll"))
.then_some(Spelling { suggestions });
let target = if enabled.contains_key(".uno:TableInsertMenu") {
Target::Table
} else if enabled.contains_key(".uno:WrapMenu") {
Target::Frame
} else {
Target::Text
};
Ok(Offered {
target,
spelling,
enabled,
})
}
fn walk(
items: &[serde_json::Value],
enabled: &mut HashMap<String, bool>,
suggestions: &mut Vec<(String, String)>,
) -> io::Result<()> {
for item in items {
if item["type"] == "separator" {
continue;
}
let on = match item["enabled"].as_str() {
Some("true") => true,
Some("false") => false,
_ => return Err(invalid(format!("a menu item has no enabled flag: {item}"))),
};
// A submenu may have no command.
match (item["command"].as_str(), item["type"].as_str()) {
(Some(command), _) => {
enabled.insert(command.to_string(), on);
if let Some(rule) =
command.strip_prefix(".uno:SpellCheckApplySuggestion?ApplyRule:string=")
{
let label = item["text"]
.as_str()
.ok_or_else(|| invalid(format!("a suggestion has no text: {item}")))?;
suggestions.push((label.to_string(), rule.to_string()));
}
}
(None, Some("menu")) => {}
(None, _) => return Err(invalid(format!("a menu item has no command: {item}"))),
}
if let Some(sub) = item.get("menu") {
let sub = sub
.as_array()
.ok_or_else(|| invalid(format!("a submenu is no list: {item}")))?;
walk(sub, enabled, suggestions)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests;
+44
View File
@@ -0,0 +1,44 @@
use super::*;
fn item(command: &str, enabled: bool) -> String {
format!(r#"{{"text":"x","type":"command","command":"{command}","enabled":"{enabled}"}}"#)
}
#[test]
fn spelling_rows_come_first_class() {
let menu = r#"{"menu":[{"text":"The","type":"command","command":".uno:SpellCheckApplySuggestion?ApplyRule:string=Spelling_The","enabled":"true"},{"type":"separator"},{"text":"Ignore All","type":"command","command":".uno:SpellCheckIgnoreAll?Type:string=Spelling","enabled":"true"}]}"#;
let offered = read(menu).expect("the menu parses");
let spelling = offered.spelling.expect("a spelling menu");
assert_eq!(spelling.suggestions, [("The".into(), "Spelling_The".into())]);
assert_eq!(offered.target, Target::Text);
let other = format!(r#"{{"menu":[{}]}}"#, item(".uno:Cut", true));
assert_eq!(read(&other).expect("the menu parses").spelling, None);
}
#[test]
fn the_menu_names_its_target_and_flags() {
let table = format!(
r#"{{"menu":[{},{{"text":"Insert","type":"menu","command":".uno:TableInsertMenu","menu":[{}],"enabled":"true"}}]}}"#,
item(".uno:MergeCells", false),
item(".uno:InsertRowsBefore", true),
);
let table = read(&table).expect("the table menu parses");
assert_eq!(table.target, Target::Table);
assert!(table.allows(".uno:InsertRowsBefore"), "submenu rows count");
assert!(!table.allows(".uno:MergeCells"), "a disabled row");
assert!(!table.allows(".uno:DeleteTable"), "an absent row");
let frame = format!(
r#"{{"menu":[{{"type":"menu","menu":[{}],"enabled":"true"}}]}}"#,
item(".uno:WrapMenu", true)
);
assert_eq!(read(&frame).expect("a commandless submenu").target, Target::Frame);
let text = format!(r#"{{"menu":[{}]}}"#, item(".uno:Paste", true));
assert_eq!(read(&text).expect("parses").target, Target::Text);
}
#[test]
fn a_malformed_menu_fails() {
assert!(read(r#"{"menu":[{"type":"command","command":".uno:Cut"}]}"#).is_err());
assert!(read(r#"{"menu":[{"type":"command","enabled":"true"}]}"#).is_err());
assert!(read(r#"{"items":[]}"#).is_err());
}
+3 -43
View File
@@ -38,13 +38,6 @@ pub struct Counts {
pub selected_characters: u64,
}
/// A misspelled word's menu: suggestions as the kernel's rules.
#[derive(Clone, Debug, PartialEq)]
pub struct Spelling {
/// Label and `ApplyRule` value for each suggestion.
pub suggestions: Vec<(String, String)>,
}
/// The counts from `.uno:WordCount`.
pub fn counts(json: &str) -> io::Result<Counts> {
let list = parse(json)?;
@@ -57,32 +50,6 @@ pub fn counts(json: &str) -> io::Result<Counts> {
})
}
/// A context menu's spelling part, if it has one.
pub fn spelling(json: &str) -> io::Result<Option<Spelling>> {
let list = parse(json)?;
let items = entries(&list, "menu")?;
let commands: Vec<&str> = items
.iter()
.filter_map(|item| item["command"].as_str())
.collect();
if !commands
.iter()
.any(|command| command.starts_with(".uno:SpellCheckIgnoreAll"))
{
return Ok(None);
}
let suggestions = items
.iter()
.filter_map(|item| {
let rule = item["command"]
.as_str()?
.strip_prefix(".uno:SpellCheckApplySuggestion?ApplyRule:string=")?;
Some((item["text"].as_str()?.to_string(), rule.to_string()))
})
.collect();
Ok(Some(Spelling { suggestions }))
}
/// A heading from `.uno:Outline`.
#[derive(Clone, Debug, PartialEq)]
pub struct Heading {
@@ -190,11 +157,11 @@ pub fn formula(json: &str) -> io::Result<Option<String>> {
}
}
fn parse(json: &str) -> io::Result<serde_json::Value> {
pub(crate) fn parse(json: &str) -> io::Result<serde_json::Value> {
serde_json::from_str(json).map_err(|error| invalid(format!("a bad kernel list: {error}")))
}
fn entries<'a>(list: &'a serde_json::Value, key: &str) -> io::Result<&'a Vec<serde_json::Value>> {
pub(crate) fn entries<'a>(list: &'a serde_json::Value, key: &str) -> io::Result<&'a Vec<serde_json::Value>> {
list[key]
.as_array()
.ok_or_else(|| invalid(format!("the list has no {key}")))
@@ -257,7 +224,7 @@ fn plain(html: &str) -> String {
.replace("&amp;", "&")
}
fn invalid(message: String) -> io::Error {
pub(crate) fn invalid(message: String) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
@@ -288,12 +255,5 @@ mod tests {
let counts = counts(r#"{"document":{"words":11,"characters":41,"charactersNoSpaces":31},"selection":{"words":2,"characters":7,"charactersNoSpaces":6}}"#)
.expect("counts parse");
assert_eq!((counts.words, counts.selected_characters), (11, 7));
let menu = r#"{"menu":[{"text":"The","type":"command","command":".uno:SpellCheckApplySuggestion?ApplyRule:string=Spelling_The"},{"type":"separator"},{"text":"Ignore All","type":"command","command":".uno:SpellCheckIgnoreAll?Type:string=Spelling"}]}"#;
let found = spelling(menu)
.expect("the menu parses")
.expect("a spelling menu");
assert_eq!(found.suggestions, [("The".into(), "Spelling_The".into())]);
let other = r#"{"menu":[{"text":"Cut","type":"command","command":".uno:Cut"}]}"#;
assert_eq!(spelling(other).expect("the menu parses"), None);
}
}
+2
View File
@@ -137,6 +137,7 @@ pub struct LokView {
/// The last search outcome: its count and matches.
search: (u64, Vec<Rect>),
menu: Option<String>,
right_at: Option<Point<Pixels>>,
fonts: Vec<String>,
a11y: crate::A11y,
tabs: Vec<i32>,
@@ -239,6 +240,7 @@ impl LokView {
review: 0,
search: (0, Vec::new()),
menu: None,
right_at: None,
fonts: Vec::new(),
a11y: crate::A11y::default(),
tabs: Vec::new(),
+14 -3
View File
@@ -313,6 +313,7 @@ impl LokView {
return;
};
window.focus(&self.focus, cx);
self.right_at = (event.button == MouseButton::Right).then_some(event.position);
if event.button == MouseButton::Left && self.grab(event.position) {
cx.notify();
return;
@@ -391,7 +392,17 @@ impl LokView {
}
pub(super) fn on_copy(&mut self, _: &Copy, _: &mut Window, _: &mut Context<Self>) {
self.send(Input::Copy { cut: false });
self.copy(false);
}
/// Copies the selection out, or cuts it.
pub fn copy(&mut self, cut: bool) {
self.send(Input::Copy { cut });
}
/// Pastes the system clipboard.
pub fn paste(&mut self) {
self.send(Input::Paste);
}
pub(super) fn on_next_field(&mut self, _: &NextField, _: &mut Window, _: &mut Context<Self>) {
@@ -410,11 +421,11 @@ impl LokView {
}
pub(super) fn on_cut(&mut self, _: &Cut, _: &mut Window, _: &mut Context<Self>) {
self.send(Input::Copy { cut: true });
self.copy(true);
}
pub(super) fn on_paste(&mut self, _: &Paste, _: &mut Window, _: &mut Context<Self>) {
self.send(Input::Paste);
self.paste();
}
/// Pastes values or formulas alone (TABLE-08).
+5 -4
View File
@@ -1,6 +1,6 @@
//! What the view knows, for apps to read.
use gpui::FocusHandle;
use gpui::{FocusHandle, Pixels, Point};
use super::LokView;
use crate::Rect;
@@ -91,9 +91,10 @@ impl LokView {
&self.fonts
}
/// Takes the context menu the kernel last offered.
pub fn take_menu(&mut self) -> Option<String> {
self.menu.take()
/// Takes the kernel's last menu and the click that asked.
pub fn take_menu(&mut self) -> Option<(String, Option<Point<Pixels>>)> {
let menu = self.menu.take()?;
Some((menu, self.right_at.take()))
}
/// The paragraph's own tab stops, in 1/100 mm.
+61 -1
View File
@@ -1,4 +1,4 @@
//! ElyDoc's find, spelling, compare and merge messages.
//! ElyDoc's find, spelling, menu, compare and merge messages.
pub(super) const EN: &[(&str, &str)] = &[
("menu.tools", "Tools"),
@@ -13,6 +13,36 @@ pub(super) const EN: &[(&str, &str)] = &[
("office.find.stale", "Edited. Enter counts again."),
("doc.spell.ignore_all", "Ignore All"),
("doc.spell.none", "No suggestions"),
("doc.menu.cut", "Cut"),
("doc.menu.copy", "Copy"),
("doc.menu.paste", "Paste"),
("doc.menu.link", "Link…"),
("doc.menu.comment", "New Comment"),
("doc.menu.insert", "Insert"),
("doc.menu.column_left", "Insert Columns to the Left"),
("doc.menu.column_right", "Insert Columns to the Right"),
("doc.menu.row_above", "Insert Rows Above"),
("doc.menu.row_below", "Insert Rows Below"),
("doc.menu.delete", "Delete"),
("doc.menu.delete_rows", "Delete Rows"),
("doc.menu.delete_columns", "Delete Columns"),
("doc.menu.delete_table", "Delete Table"),
("doc.menu.merge", "Merge Cells"),
("doc.menu.split", "Split Cells"),
("doc.menu.split_columns", "Two Columns"),
("doc.menu.split_rows", "Two Rows"),
("doc.menu.even_rows", "Distribute Rows Evenly"),
("doc.menu.even_columns", "Distribute Columns Evenly"),
("doc.menu.wrap", "Wrap Text"),
("doc.menu.inline", "In Line with Text"),
("doc.menu.square", "Square"),
("doc.menu.top_bottom", "Top and Bottom"),
("doc.menu.in_front", "In Front of Text"),
("doc.menu.front", "Bring to Front"),
("doc.menu.forward", "Bring Forward"),
("doc.menu.back", "Send to Back"),
("doc.menu.backward", "Send Backward"),
("doc.menu.caption", "Insert Caption…"),
("doc.compare.open", "Compare With…"),
("doc.compare.name", "{name} compared"),
("doc.output.export_title", "Export as PDF"),
@@ -78,6 +108,36 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("office.find.stale", "已改动,回车重新计数"),
("doc.spell.ignore_all", "全部忽略"),
("doc.spell.none", "无建议"),
("doc.menu.cut", "剪切"),
("doc.menu.copy", "复制"),
("doc.menu.paste", "粘贴"),
("doc.menu.link", "链接…"),
("doc.menu.comment", "新建批注"),
("doc.menu.insert", "插入"),
("doc.menu.column_left", "在左侧插入列"),
("doc.menu.column_right", "在右侧插入列"),
("doc.menu.row_above", "在上方插入行"),
("doc.menu.row_below", "在下方插入行"),
("doc.menu.delete", "删除"),
("doc.menu.delete_rows", "删除行"),
("doc.menu.delete_columns", "删除列"),
("doc.menu.delete_table", "删除表格"),
("doc.menu.merge", "合并单元格"),
("doc.menu.split", "拆分单元格"),
("doc.menu.split_columns", "拆分为两列"),
("doc.menu.split_rows", "拆分为两行"),
("doc.menu.even_rows", "平均分布各行"),
("doc.menu.even_columns", "平均分布各列"),
("doc.menu.wrap", "环绕文字"),
("doc.menu.inline", "嵌入型"),
("doc.menu.square", "四周型"),
("doc.menu.top_bottom", "上下型"),
("doc.menu.in_front", "浮于文字上方"),
("doc.menu.front", "置于顶层"),
("doc.menu.forward", "上移一层"),
("doc.menu.back", "置于底层"),
("doc.menu.backward", "下移一层"),
("doc.menu.caption", "插入题注…"),
("doc.compare.open", "与其他版本比较…"),
("doc.compare.name", "{name} 比较结果"),
("doc.output.export_title", "导出为 PDF"),
+2
View File
@@ -146,4 +146,6 @@ P0 次序:W-01、K-01、L-01、O-01、W-02、L-02、W-03、L-03、W-04、L-04
- [ ] P3-03 Windows:放映图层大于一帧时分片缺行(`motion::a_layer_larger_than_one_frame_arrives_in_pieces`)
- [ ] P3-04 Windows:放映的页脚字段文字不在占位处(`playlist::field_text_lands_where_its_placeholder_is`)
- [ ] P3-05 Windows:辅助功能的段落选区不桥接(`commands::a11y_follows_the_focused_paragraph`)
- [ ] P3-06 Windows:删不掉内核仍开着文件的标签工作目录(错误 32),写入失败无从模拟(`tests::more::a_kernel_write_failure_stays_visible`)
- [ ] P3-07 Windows:测试以 `kill -9` 杀 worker,Git Bash 的 kill 不认 Windows 进程号,worker 不死(`view::tests::commands::a_dead_worker_reaches_apps_while_idle`)
+4 -1
View File
@@ -247,6 +247,9 @@
## R5 ElyDoc 照 Word 的右键与表格(用户 2026-10-13 报:右键没反应;表格不能加行列、不能调大小;三项方案经用户确认)
- [x] R5.1 属性栏:光标在表格里时表格组排在最前(同 Word 的表格布局页签),否则次序不变(DOC-07) — R3 PASS(组件 9ff403d:属性组按标题记折叠)
- [ ] R5.2 右键菜单(DOC-02、07、08、11、12):原先只在内核菜单带拼写建议时才显示,其余右键无反应;改为照 Word 按目标出 Ely 的菜单,开在所点之处:剪切、复制、粘贴为图标行;正文为链接…、新建批注;表格单元格另有插入 ▸(左侧列、右侧列、上方行、下方行)、删除 ▸(行、列、表格)、合并单元格、拆分单元格 ▸(两列、两行)、平均分布各行与各列;图片为文字环绕 ▸、置于顶层 ▸、置于底层 ▸、插入题注…、新建批注;拼写错词时建议与“全部忽略”在最上。目标与各项可否取自内核这次给的菜单,命令只经 `COMMANDS`,文案进中英目录
- [x] R5.2 右键菜单(DOC-02、07、08、11、12):原先只在内核菜单带拼写建议时才显示,其余右键无反应;改为照 Word 按目标出 Ely 的菜单,开在所点之处:剪切、复制、粘贴为图标行;正文为链接…、新建批注;表格单元格另有插入 ▸(左侧列、右侧列、上方行、下方行)、删除 ▸(行、列、表格)、合并单元格、拆分单元格 ▸(两列、两行)、平均分布各行与各列;图片为文字环绕 ▸、置于顶层 ▸、置于底层 ▸、插入题注…、新建批注;拼写错词时建议与“全部忽略”在最上。目标与各项可否取自内核这次给的菜单,命令只经 `COMMANDS`,文案进中英目录 — R2 PASS(链接…随内核的 `.uno:InsertHyperlink`:须选中可编辑的文字,未选中时灰显)
- [ ] R5.3 拖动表格边线(DOC-07):读内核的 `LOK_CALLBACK_TABLE_SELECTED`(光标所在表格的列与行边线),指针在边线上变为调整指针,拖动时画参考线,松开以 `.uno:TableChangeCurrentBorderPosition` 改列宽或行高(左、内、右列边线;内、下行边线),一步撤销,按读到边线时的修订执行;内核在 LOK 下有意不处理边线的指针与拖动(`SwEditWin::changeMousePointer`、`RulerColumnDrag` 依赖 LOK 没有的标尺);未进入的表格先单击一次
- [ ] R5.4 验收:Windows 真窗口逐项核对(右键四种目标、加行加列、拖动列宽与行高、撤销、保存后重开);`agents.md` 随之;逐条核对表
- [ ] R5.5 ElyTable 与 ElyShow 核对同类问题(用户 2026-10-14 要求):右键是否有菜单、表格能否加删行列与调尺寸;查出的缺口照 R5.2、R5.3 的做法补齐
- [ ] R5.6 Gitea #1:ElyShow 左侧缩略图条滚轮不滚动(同窗口右栏正常,Linux Wayland 报);查明根因再修,加滚轮回归测试
- [ ] R5.7 0.1.5 发布(用户 2026-10-14 定):等 ElyMark 会话的 R4 做完;版本 0.1.5,Windows 与 WSL2 的 Linux 在本机建,macOS 在 Mac mini 上拉取后本机建;全部产物上传到 Gitea 的 v0.1.5 发布
+3
View File
@@ -0,0 +1,3 @@
FAIL
- apps/ely-doc/src/menu.rs:103 — “Link…” bypasses the kernel's enabled flag: `Pick::Link` has no command gate, so `on()` always returns true. Right-clicking a protected paragraph or a locked table cell therefore offers an enabled link dialog even though that click's kernel menu disallows `.uno:InsertHyperlink`. Submitting it fails with `link.insert: the undo history did not move`, contrary to R5.2's requirement that each item's availability follow the current kernel menu. Reproduced against the current Windows worker with both protected targets; evidence: `.work/R5.2-r1-protected.log`. Gate the entry on the offered hyperlink command and add coverage asserting that these protected targets cannot open the insertion dialog.
+1
View File
@@ -0,0 +1 @@
PASS