diff --git a/apps/ely-doc/src/chrome.rs b/apps/ely-doc/src/chrome.rs index 60c756f..790d942 100644 --- a/apps/ely-doc/src/chrome.rs +++ b/apps/ely-doc/src/chrome.rs @@ -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(", "); diff --git a/apps/ely-doc/src/docs.rs b/apps/ely-doc/src/docs.rs index 9932c27..b388368 100644 --- a/apps/ely-doc/src/docs.rs +++ b/apps/ely-doc/src/docs.rs @@ -37,7 +37,7 @@ pub struct Docs { pub review: Entity, /// What the right column shows. pub side: Side, - pub offer: Option, + pub offer: Option, pub merge: Entity, pub navigator: Entity, /// The left column shows the navigator. @@ -88,7 +88,7 @@ impl Host for Docs { fn observed(&mut self, id: u64, view: &Entity, cx: &mut Context) { 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. diff --git a/apps/ely-doc/src/main.rs b/apps/ely-doc/src/main.rs index 36121b3..5027886 100644 --- a/apps/ely-doc/src/main.rs +++ b/apps/ely-doc/src/main.rs @@ -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; diff --git a/apps/ely-doc/src/menu.rs b/apps/ely-doc/src/menu.rs new file mode 100644 index 0000000..ae6ad4e --- /dev/null +++ b/apps/ely-doc/src/menu.rs @@ -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), +} + +/// 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, cx: &mut Context) { + 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) -> Option { + 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) { + 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) { + 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, 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; diff --git a/apps/ely-doc/src/menu/tests.rs b/apps/ely-doc/src/menu/tests.rs new file mode 100644 index 0000000..c6d3c53 --- /dev/null +++ b/apps/ely-doc/src/menu/tests.rs @@ -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, Entity); + +impl gpui::Render for Host { + fn render(&mut self, _: &mut gpui::Window, cx: &mut gpui::Context) -> 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) -> Point { + 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, at: Point, 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(", - /// Bumps for each menu, so each opens once. - request: (u64, Point), -} - -impl Docs { - /// Turns the kernel's context menu into a spelling offer. - pub(crate) fn offer_spelling( - &mut self, - id: u64, - view: &Entity, - cx: &mut Context, - ) { - 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) -> Option { - 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))) - } -} diff --git a/apps/ely-doc/src/tests/find.rs b/apps/ely-doc/src/tests/find.rs index 193ef53..6773097 100644 --- a/apps/ely-doc/src/tests/find.rs +++ b/apps/ely-doc/src/tests/find.rs @@ -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") diff --git a/crates/ely-lok/src/client/tests.rs b/crates/ely-lok/src/client/tests.rs index e887c17..6f28a7e 100644 --- a/crates/ely-lok/src/client/tests.rs +++ b/crates/ely-lok/src/client/tests.rs @@ -15,6 +15,7 @@ pub(crate) mod input; mod latency; mod layout; mod media; +mod menus; mod motion; mod output; mod paint; diff --git a/crates/ely-lok/src/client/tests/menus.rs b/crates/ely-lok/src/client/tests/menus.rs new file mode 100644 index 0000000..093adcf --- /dev/null +++ b/crates/ely-lok/src/client/tests/menus.rs @@ -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#" + + The cat ran. + + leftright + downmore + after +"#; + +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#" + + open words + sealed words + + fixedfree + after +"#; + +#[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"); +} diff --git a/crates/ely-lok/src/client/tests/tools.rs b/crates/ely-lok/src/client/tests/tools.rs index db74687..a7049a5 100644 --- a/crates/ely-lok/src/client/tests/tools.rs +++ b/crates/ely-lok/src/client/tests/tools.rs @@ -1,6 +1,6 @@ use super::input::{open, saved}; use super::*; -use crate::review; +use crate::{menu, review}; const TEXT: &str = r#" @@ -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" ); diff --git a/crates/ely-lok/src/lib.rs b/crates/ely-lok/src/lib.rs index 1c6c3eb..9acf573 100644 --- a/crates/ely-lok/src/lib.rs +++ b/crates/ely-lok/src/lib.rs @@ -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; diff --git a/crates/ely-lok/src/menu.rs b/crates/ely-lok/src/menu.rs new file mode 100644 index 0000000..de47ff5 --- /dev/null +++ b/crates/ely-lok/src/menu.rs @@ -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, + /// Each command, at any depth, and whether it runs now. + enabled: HashMap, +} + +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 { + 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, + 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; diff --git a/crates/ely-lok/src/menu/tests.rs b/crates/ely-lok/src/menu/tests.rs new file mode 100644 index 0000000..4c35520 --- /dev/null +++ b/crates/ely-lok/src/menu/tests.rs @@ -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()); +} diff --git a/crates/ely-lok/src/review.rs b/crates/ely-lok/src/review.rs index f647913..cec8ea4 100644 --- a/crates/ely-lok/src/review.rs +++ b/crates/ely-lok/src/review.rs @@ -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 { let list = parse(json)?; @@ -57,32 +50,6 @@ pub fn counts(json: &str) -> io::Result { }) } -/// A context menu's spelling part, if it has one. -pub fn spelling(json: &str) -> io::Result> { - 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> { } } -fn parse(json: &str) -> io::Result { +pub(crate) fn parse(json: &str) -> io::Result { 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> { +pub(crate) fn entries<'a>(list: &'a serde_json::Value, key: &str) -> io::Result<&'a Vec> { list[key] .as_array() .ok_or_else(|| invalid(format!("the list has no {key}"))) @@ -257,7 +224,7 @@ fn plain(html: &str) -> String { .replace("&", "&") } -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); } } diff --git a/crates/ely-lok/src/view.rs b/crates/ely-lok/src/view.rs index e921490..32c1146 100644 --- a/crates/ely-lok/src/view.rs +++ b/crates/ely-lok/src/view.rs @@ -137,6 +137,7 @@ pub struct LokView { /// The last search outcome: its count and matches. search: (u64, Vec), menu: Option, + right_at: Option>, fonts: Vec, a11y: crate::A11y, tabs: Vec, @@ -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(), diff --git a/crates/ely-lok/src/view/input.rs b/crates/ely-lok/src/view/input.rs index eb9dea6..3b79238 100644 --- a/crates/ely-lok/src/view/input.rs +++ b/crates/ely-lok/src/view/input.rs @@ -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.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) { @@ -410,11 +421,11 @@ impl LokView { } pub(super) fn on_cut(&mut self, _: &Cut, _: &mut Window, _: &mut Context) { - self.send(Input::Copy { cut: true }); + self.copy(true); } pub(super) fn on_paste(&mut self, _: &Paste, _: &mut Window, _: &mut Context) { - self.send(Input::Paste); + self.paste(); } /// Pastes values or formulas alone (TABLE-08). diff --git a/crates/ely-lok/src/view/reads.rs b/crates/ely-lok/src/view/reads.rs index 0709044..14f6d30 100644 --- a/crates/ely-lok/src/view/reads.rs +++ b/crates/ely-lok/src/view/reads.rs @@ -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 { - self.menu.take() + /// Takes the kernel's last menu and the click that asked. + pub fn take_menu(&mut self) -> Option<(String, Option>)> { + let menu = self.menu.take()?; + Some((menu, self.right_at.take())) } /// The paragraph's own tab stops, in 1/100 mm. diff --git a/crates/ely-ui/src/i18n/tools.rs b/crates/ely-ui/src/i18n/tools.rs index 05c6be1..58479a0 100644 --- a/crates/ely-ui/src/i18n/tools.rs +++ b/crates/ely-ui/src/i18n/tools.rs @@ -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"), diff --git a/tasks/BUGS.md b/tasks/BUGS.md index 7f44d2f..ef783fe 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -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`) diff --git a/tasks/TASKS.md b/tasks/TASKS.md index f9e5aaa..44d63b2 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -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 发布 diff --git a/tasks/reviews/R5.2-r1.md b/tasks/reviews/R5.2-r1.md new file mode 100644 index 0000000..eb746f8 --- /dev/null +++ b/tasks/reviews/R5.2-r1.md @@ -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. diff --git a/tasks/reviews/R5.2-r2.md b/tasks/reviews/R5.2-r2.md new file mode 100644 index 0000000..0cdd613 --- /dev/null +++ b/tasks/reviews/R5.2-r2.md @@ -0,0 +1 @@ +PASS