diff --git a/agents.md b/agents.md index a6d5f3b..6274d51 100644 --- a/agents.md +++ b/agents.md @@ -288,3 +288,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-12(用户报:ElyTable 的网格上一直是文字指针):`LokView` 原把指针写死为文字指针。现不按键的鼠标移动也交内核(悬停,不算编辑、不加修订),内核以 `LOK_CALLBACK_MOUSE_POINTER` 回报 CSS 指针名(`vcl` 的 `gaLOKPointerMap`),随事件批次到视图,换成 gpui 的指针:单元格上为箭头,编辑单元格或文字时为文字指针,链接为手;未知的名字记警告并用箭头。 - 2026-10-13(用户定):ElyMark 以 Typora 1.14.10 为对照(R4;安装包与解包放在 `TyporaRef`,不入库):可视模式仍是按下的块以原文编辑;语法照 Typora 默认(`:表情:`、行内与块级 HTML 的常用标签、GitHub 提示块、`[toc]`、front matter 框;`==`、`^`、单个 `~` 照 GFM);快捷键照 Typora(Ctrl+1–6 标题、Ctrl+0 段落、Ctrl+/ 源码、Ctrl+Shift+=/-/0 字号、F8 专注、F9 打字机);右键菜单照 Typora 的布局,剪贴板与格式为图标行(组件库 `Menu` 新增)。Typora 与 GitHub 读同一份文件不同时随 GitHub(GFM,§14.3):表情名取 GitHub 现行的 gemoji(`emojis` 库,`:cricket:` 为蟋蟀),转义与字符引用之后照样是表情,自动链接与 `` 里不是,同一 DOM 文字节点里第一个 `:名:` 前为空白或空格才全部转、否则全不转(`x:smile: :smile:` 原样,过滤标签不分节点),HTML 标签与强调交叉时照浏览器修复 CommonMark 输出,`
` 照 HTML5 也是换行,GFM 标签过滤的九种(`script`、`style` 等)照原文显示,提示块的标记读解析后的纯文字;Typora 只决定哪些扩展显示。导出、纯文本与富文本复制经组件 `MarkdownReader` 照可视读每个段落、标题、单元格与 HTML 块(各自一套新栈,块尾关闭未闭合的标签):脚注在原处、写作 `[号]`,链接不带标题(可视不显示),取代 C14.14 的页末脚注与 HTML 一律转义。审查改用 gpt-6-astra,effort max,fast。 - 2026-10-14(用户要求照 Word):ElyDoc 的右键与表格(R5)。右键菜单由 Ely 按目标自建(`apps/ely-doc/src/menu.rs`),开在所点之处(`LokView` 记下右键点,`take_menu` 一并交出):目标、各项可否与拼写建议都取自内核这次给的菜单(`ely_lok::menu`:有 `WrapMenu` 为图片,有 `TableInsertMenu` 为表格,否则正文;缺 `enabled` 即报错),命令只经 `COMMANDS`。剪切、复制、粘贴为图标行(组件库 `Menu::buttons`);正文为链接…、新建批注;表格另有插入、删除、合并、拆分、平均分布;图片为文字环绕(嵌入、四周、上下、浮于文字上方;内核无紧密、穿越、衬于文字下方的对应项,不列)、置于顶层、置于底层、插入题注…,新建批注灰显(内核的图片菜单无批注)。链接…随内核的 `.uno:InsertHyperlink`:须选中可编辑的文字,未选中时灰显(Word 未选中也可用;照做须改内核,未做)。拖动表格边线:worker 把 `LOK_CALLBACK_TABLE_SELECTED` 读成 `ely_lok::table::Table`(列与行边线、上下限),视图在边线上显示调整指针(命中范围不超过与邻线距离的四分之一,矮行的中间仍落在文档),按住时记下读到它的批次的修订与按住处的偏移,拖动时画参考线(出视图照跟),松开以 `.uno:TableChangeCurrentBorderPosition` 发一条命令(一步撤销),原地松开不发;其间文档变了即以过期拒绝(`view/borders.rs`)。内核在 LOK 下不处理边线的指针与拖动(依赖 LOK 没有的标尺)。属性栏在光标进表格时把表格组排在最前(组件库 9ff403d 起按标题记折叠)。Windows 真窗口逐项核对过(右键三种目标、插入行列、拖动列宽行高、撤销、保存后重开);拼写建议在 Windows 上内核不给(P3-01),未核对。 +- 2026-10-14(用户要求核对 ElyTable 与 ElyShow):ElyTable 的右键(R5.5)。菜单机制移入 `ely_office::menu`(取内核这次给的菜单、开在所点之处、剪贴板图标行、子菜单可否随其中各项),ElyDoc 与 ElyTable 共用;`ely_lok::menu` 的目标另有单元格(有 `.uno:InsertCell`)与 Writer 之外的绘图对象(有 `.uno:ArrangeMenu`)。网格照 Excel:插入 ▸、删除 ▸、清除内容(`.uno:ClearContents`,同 Delete 键,不弹内核的删除内容对话框)、合并、拆分、设置单元格格式(打开格式面板);对象为置于顶层、置于底层;编辑单元格时只有剪贴板行。行列表头由 Ely 自画,内核不给菜单:右键先选中该行或列(已选的整行整列含它即不动,同 Excel)。选中对象或正编辑图形里的文字时,绘图外壳取代了单元格外壳,`.uno:SelectRow`/`.uno:SelectColumn` 原无处执行而被丢弃;补丁 `0046-lok-calc-headers-leave-drawings.patch` 让 LOK 下视图外壳接下这两条:先结束图形编辑(键入的文字留下)并取消选中,再选行列,同 Calc 自己的表头(`DrawDeselectAll`),菜单为插入、删除、清除内容、行高或列宽、隐藏、显示。ElyTable 每一项的可否都取自内核对它所发的那条命令此刻的状态(内核的菜单只定目标):补丁 `0045-lok-command-states.patch` 加 `getCommandValues(".uno:CommandStates?commands=…")`,先冲出调度器、再逐条问调度器(同菜单栏),未知的命令即报错(`ely_lok::menu::runs`);LOK 推送的状态只有固定的一组,且在空闲时才更新。因此受保护而许插入行列的工作表照样可插;行高、列宽只在文档只读时禁用(内核的规则),受保护时由内核拒绝并报出其错误。ElyTable 无批注界面,不列批注。 diff --git a/apps/ely-doc/src/docs.rs b/apps/ely-doc/src/docs.rs index b388368..bc4a09b 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. diff --git a/apps/ely-doc/src/menu.rs b/apps/ely-doc/src/menu.rs index ae6ad4e..ce0c988 100644 --- a/apps/ely-doc/src/menu.rs +++ b/apps/ely-doc/src/menu.rs @@ -2,26 +2,19 @@ use ely_gpui_component::{ menus::{ContextMenu, Menu, MenuItem}, - primitives::{IconName, Severity}, + primitives::Severity, }; use ely_lok::{ LokView, menu::{Offered, Target}, }; -use gpui::{App, Context, Entity, Pixels, Point, Window}; +use ely_office::menu::{self, Entry, Offer}; +use gpui::{App, Context, Entity, 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 { @@ -53,14 +46,8 @@ pub enum Pick { Caption, } -/// A row, a submenu or a line. -enum Entry { - Row(Pick), - Sub(&'static str, &'static [Pick]), - Line, -} - -const TABLE: &[Entry] = &[ +const TABLE: &[Entry] = &[ + Entry::Line, Entry::Sub( "doc.menu.insert", &[Pick::ColumnLeft, Pick::ColumnRight, Pick::RowAbove, Pick::RowBelow], @@ -78,27 +65,27 @@ const TABLE: &[Entry] = &[ Entry::Row(Pick::Comment), ]; -const FRAME: &[Entry] = &[ +const FRAME: &[Entry] = &[ + Entry::Line, 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::Sub("office.menu.front", &[Pick::Front, Pick::Forward]), + Entry::Sub("office.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)]; +const TEXT: &[Entry] = &[Entry::Line, Entry::Row(Pick::Link), Entry::Row(Pick::Comment)]; -impl Pick { - /// Its label key and the kernel command that gates it. +impl menu::Pick for Pick { 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::Cut => ("office.menu.cut", ".uno:Cut"), + Pick::Copy => ("office.menu.copy", ".uno:Copy"), + Pick::Paste => ("office.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"), @@ -117,47 +104,41 @@ impl Pick { 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::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::Caption => ("doc.menu.caption", ".uno:InsertCaptionDialog"), } } +} +impl Pick { /// The kernel offers it and it runs now. pub(crate) fn on(self, offered: &Offered) -> bool { - offered.allows(self.row().1) + offered.allows(menu::Pick::row(self).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 (offered, at) = match menu::take(view, cx) { + None => return, + Some(Ok(taken)) => taken, + Some(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); + if matches!(offered.target, Target::Cell | Target::Object) { + 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); + } 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), - }); + log::info!("doc: {suggestions} suggestions"); + self.offer = Some(Offer::new(self.offer.as_ref(), id, offered, at)); cx.notify(); } @@ -167,8 +148,7 @@ impl Docs { .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))) + Some(offer.host("doc-menu", menu_of(&cx.entity(), &offer.what, cx))) } /// Runs one menu row. @@ -219,7 +199,7 @@ impl Docs { 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, &[])) + MenuItem::new(Docs::text(cx, menu::Pick::row(pick).0, &[])) .disabled(!pick.on(offered)) .on_click(move |window, cx| this.update(cx, |docs, cx| docs.choose(pick, window, cx))) }; @@ -244,31 +224,14 @@ pub(crate) fn menu_of(this: &Entity, offered: &Offered, cx: &App) -> Menu ); 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 { + let menu = menu::clipboard(menu, [Pick::Cut, Pick::Copy, Pick::Paste], item); + let rows = match offered.target { Target::Table => TABLE, Target::Frame => FRAME, Target::Text => TEXT, + Target::Cell | Target::Object => unreachable!("offer_menu refuses them"), }; - 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 + menu::entries(menu, rows, cx, |pick| pick.on(offered), item) } #[cfg(test)] diff --git a/apps/ely-doc/src/menu/tests.rs b/apps/ely-doc/src/menu/tests.rs index c6d3c53..8c9a8a2 100644 --- a/apps/ely-doc/src/menu/tests.rs +++ b/apps/ely-doc/src/menu/tests.rs @@ -41,7 +41,7 @@ fn right_click(cx: &mut VisualTestContext, docs: &Entity, at: Point, /// The sheet menu's request: number and place. pub sheet_menu: Option<(u64, gpui::Point)>, + /// The grid or header menu. + pub menu: Option>, pub panel: Entity, pub panel_shown: bool, pub choices: Option, @@ -95,6 +97,7 @@ impl Host for Book { self.freeze(id, view, cx); self.found(id, view, cx); self.requested(id, view, cx); + self.offer_menu(id, view, cx); self.follow_rule(id, view, cx); self.read_links(id, view, cx); if self.core.active == Some(id) { @@ -190,6 +193,7 @@ impl Book { bars: Bars::new(window, cx), drag: None, sheet_menu: None, + menu: None, panel, panel_shown: true, choices: None, @@ -388,7 +392,8 @@ impl Render for Book { .on_action(cx.listener(Self::on_print)) .child(shell) .children(self.core_dialog(cx)) - .children(self.dialog_element(cx)); + .children(self.dialog_element(cx)) + .children(self.context_menu(cx)); let root = Self::bind_zoom(crate::cells::bind(crate::sheets::bind(root, cx), cx), cx); let root = crate::data::bind(crate::formulas::bind(root, cx), cx); let root = crate::page::bind(crate::protect::bind(root, cx), cx); diff --git a/apps/ely-table/src/cells.rs b/apps/ely-table/src/cells.rs index 6567cec..9bf70cc 100644 --- a/apps/ely-table/src/cells.rs +++ b/apps/ely-table/src/cells.rs @@ -144,7 +144,7 @@ impl Book { self.run_in(Some(tab), id, &[&size.to_string()], cx); } - fn ask_size(&mut self, column: bool, window: &mut Window, cx: &mut Context) { + pub(crate) fn ask_size(&mut self, column: bool, window: &mut Window, cx: &mut Context) { let Some(tab) = self.core.active else { let text = Book::text(cx, "command.blocked.no_document", &[]); return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx); diff --git a/apps/ely-table/src/grid.rs b/apps/ely-table/src/grid.rs index b616955..b26f0b0 100644 --- a/apps/ely-table/src/grid.rs +++ b/apps/ely-table/src/grid.rs @@ -140,6 +140,12 @@ impl Book { } } + /// Selects a header's row or column. + pub(crate) fn select_header(&mut self, column: bool, index: u32, modifier: &str, cx: &mut Context) { + let id = if column { "select.column" } else { "select.row" }; + self.run_active(id, &[&index.to_string(), modifier], cx); + } + /// One row or column header with its sizing edge. fn header( &self, @@ -201,13 +207,15 @@ impl Book { .border_color(transparent_black()) .focus_ring(cx) .tab_index(0) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |book, event: &MouseDownEvent, _, cx| { + cx.stop_propagation(); + book.header_menu(column, index, event.position, cx); + }), + ) .on_click(cx.listener(move |book, event: &ClickEvent, _, cx| { - let id = if column { - "select.column" - } else { - "select.row" - }; - book.run_active(id, &[&index.to_string(), &modifier(&event.modifiers())], cx); + book.select_header(column, index, &modifier(&event.modifiers()), cx); })) .child(label); let cell = div() diff --git a/apps/ely-table/src/main.rs b/apps/ely-table/src/main.rs index ff28c7a..8489a80 100644 --- a/apps/ely-table/src/main.rs +++ b/apps/ely-table/src/main.rs @@ -11,6 +11,7 @@ mod figures; mod format; mod formulas; mod grid; +mod menu; mod output; mod page; mod protect; diff --git a/apps/ely-table/src/menu.rs b/apps/ely-table/src/menu.rs new file mode 100644 index 0000000..ce4cf4a --- /dev/null +++ b/apps/ely-table/src/menu.rs @@ -0,0 +1,326 @@ +//! The right-click menus, laid out as Excel's (TABLE-02). + +use ely_gpui_component::{ + menus::{ContextMenu, Menu, MenuItem}, + primitives::Severity, +}; +use ely_lok::{ + LokView, Rect, + menu::{Runs, Target, runs}, + sheet::Geometry, +}; +use ely_office::menu::{self, Entry, Offer}; +use ely_office::prelude::*; +use gpui::{App, Context, Entity, Pixels, Point, Window}; + +use crate::book::Book; + +/// What was right-clicked. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Kind { + Cell, + Object, + /// A cell's text, being edited. + Text, + Rows, + Columns, +} + +/// 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, + RowsAbove, + RowsBelow, + ColumnsBefore, + ColumnsAfter, + InsertDown, + InsertRight, + DeleteRows, + DeleteColumns, + DeleteUp, + DeleteLeft, + Clear, + Merge, + Split, + Format, + RowHeight, + ColumnWidth, + HideRows, + ShowRows, + HideColumns, + ShowColumns, + Front, + Forward, + Back, + Backward, +} + +const CLIPBOARD: [Pick; 3] = [Pick::Cut, Pick::Copy, Pick::Paste]; + +const CELL: &[Entry] = &[ + Entry::Line, + Entry::Sub( + "table.menu.insert", + &[ + Pick::RowsAbove, + Pick::RowsBelow, + Pick::ColumnsBefore, + Pick::ColumnsAfter, + Pick::InsertDown, + Pick::InsertRight, + ], + ), + Entry::Sub( + "table.menu.delete", + &[Pick::DeleteRows, Pick::DeleteColumns, Pick::DeleteUp, Pick::DeleteLeft], + ), + Entry::Row(Pick::Clear), + Entry::Line, + Entry::Row(Pick::Merge), + Entry::Row(Pick::Split), + Entry::Line, + Entry::Row(Pick::Format), +]; + +const OBJECT: &[Entry] = &[ + Entry::Line, + Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]), + Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]), +]; + +const ROWS: &[Entry] = &[ + Entry::Line, + Entry::Row(Pick::RowsAbove), + Entry::Row(Pick::DeleteRows), + Entry::Row(Pick::Clear), + Entry::Line, + Entry::Row(Pick::RowHeight), + Entry::Row(Pick::HideRows), + Entry::Row(Pick::ShowRows), +]; + +const COLUMNS: &[Entry] = &[ + Entry::Line, + Entry::Row(Pick::ColumnsBefore), + Entry::Row(Pick::DeleteColumns), + Entry::Row(Pick::Clear), + Entry::Line, + Entry::Row(Pick::ColumnWidth), + Entry::Row(Pick::HideColumns), + Entry::Row(Pick::ShowColumns), +]; + +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::RowsAbove => ("table.cells.rows_above", ".uno:InsertRowsBefore"), + Pick::RowsBelow => ("table.cells.rows_below", ".uno:InsertRowsAfter"), + Pick::ColumnsBefore => ("table.cells.columns_before", ".uno:InsertColumnsBefore"), + Pick::ColumnsAfter => ("table.cells.columns_after", ".uno:InsertColumnsAfter"), + Pick::InsertDown => ("table.cells.insert_down", ".uno:InsertCell"), + Pick::InsertRight => ("table.cells.insert_right", ".uno:InsertCell"), + Pick::DeleteRows => ("table.cells.delete_rows", ".uno:DeleteRows"), + Pick::DeleteColumns => ("table.cells.delete_columns", ".uno:DeleteColumns"), + Pick::DeleteUp => ("table.cells.delete_up", ".uno:DeleteCell"), + Pick::DeleteLeft => ("table.cells.delete_left", ".uno:DeleteCell"), + Pick::Clear => ("table.menu.clear", ".uno:ClearContents"), + Pick::Merge => ("table.cells.merge", ".uno:MergeCells"), + Pick::Split => ("table.cells.split", ".uno:SplitCell"), + Pick::Format => ("table.menu.format", ".uno:FormatCellDialog"), + Pick::RowHeight => ("table.cells.row_height", ".uno:RowHeight"), + Pick::ColumnWidth => ("table.cells.column_width", ".uno:ColumnWidth"), + Pick::HideRows => ("table.cells.hide_rows", ".uno:HideRow"), + Pick::ShowRows => ("table.cells.show_rows", ".uno:ShowRow"), + Pick::HideColumns => ("table.cells.hide_columns", ".uno:HideColumn"), + Pick::ShowColumns => ("table.cells.show_columns", ".uno:ShowColumn"), + 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"), + } + } +} + +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 { + /// Its rows under the clipboard row. + fn rows(self) -> &'static [Entry] { + match self { + Kind::Cell => CELL, + Kind::Object => OBJECT, + Kind::Text => &[], + Kind::Rows => ROWS, + Kind::Columns => COLUMNS, + } + } + + /// 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 + } +} + +/// A whole selected row or column covers the header. +pub(crate) fn held(selection: &[Rect], geometry: &Geometry, column: bool, index: u32) -> bool { + let (along, across) = if column { + (&geometry.columns, &geometry.rows) + } else { + (&geometry.rows, &geometry.columns) + }; + let (from, to) = (along.start(index), along.start(index + 1)); + let end = across.start(across.count()); + selection.iter().any(|&(x, y, w, h)| { + let ((start, size), (side, span)) = if column { ((x, w), (y, h)) } else { ((y, h), (x, w)) }; + side == 0 && span + 1 >= end && start <= from && to <= start + size + 1 + }) +} + +impl Book { + /// Turns the kernel's context menu into Ely's. + pub(crate) fn offer_menu(&mut self, id: u64, view: &Entity, cx: &mut Context) { + 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 kind = match offered.target { + Target::Cell => Kind::Cell, + Target::Object => Kind::Object, + Target::Text => Kind::Text, + Target::Table | Target::Frame => { + let error = format!("a {:?} menu in a spreadsheet", offered.target); + return self.toast(Book::text(cx, "office.error.engine", &[("error", &error)]), Severity::Danger, cx); + } + }; + self.offer(id, kind, at, cx); + } + + /// Selects a header, then offers its menu. + pub(crate) fn header_menu(&mut self, column: bool, index: u32, at: Point, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + return; + }; + let id = tab.id; + // A selected object keeps nothing held. + let read = tab.view.read(cx); + if read.graphic().is_some() || !held(read.selection(), &tab.extra.geometry, column, index) { + self.select_header(column, index, "0", cx); + } + log::info!("table: a header menu, column {column}"); + self.menu = None; + let kind = if column { Kind::Columns } else { Kind::Rows }; + match self.settled(id, cx) { + true => self.offer(id, kind, at, cx), + false => self.when_settled(id, move |book, _, cx| book.offer(id, kind, at, cx)), + } + cx.notify(); + } + + /// Asks what may run now, then opens the menu. + fn offer(&mut self, id: u64, kind: Kind, at: Point, cx: &mut Context) { + let commands = kind.commands(); + self.ask_in( + id, + move |worker, doc| runs(worker, doc, &commands), + move |book, read, _, cx| match read { + Ok(runs) => { + book.menu = Some(Offer::new(book.menu.as_ref(), id, What { kind, runs }, at)); + cx.notify(); + } + Err(error) => book.engine_failed(&error.to_string(), cx), + }, + cx, + ); + } + + /// The menu's host, for the active workbook. + pub(crate) fn context_menu(&self, cx: &mut Context) -> Option { + let offer = self.menu.as_ref().filter(|offer| Some(offer.tab) == self.core.active)?; + Some(offer.host("table-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) { + log::info!("table: the menu picks {pick:?}"); + let (id, values): (&str, &[&str]) = match pick { + Pick::Cut | Pick::Copy | Pick::Paste => return self.clipboard(pick, cx), + Pick::Merge => return self.merge(self.core.active, cx), + Pick::RowHeight => return self.ask_size(false, window, cx), + Pick::ColumnWidth => return self.ask_size(true, window, cx), + Pick::Format => { + self.panel_shown = true; + return cx.notify(); + } + Pick::RowsAbove => ("rows.insert_above", &[]), + Pick::RowsBelow => ("rows.insert_below", &[]), + Pick::ColumnsBefore => ("columns.insert_before", &[]), + Pick::ColumnsAfter => ("columns.insert_after", &[]), + Pick::InsertDown => ("cells.insert", &["V"]), + Pick::InsertRight => ("cells.insert", &[">"]), + Pick::DeleteRows => ("rows.delete", &[]), + Pick::DeleteColumns => ("columns.delete", &[]), + Pick::DeleteUp => ("cells.delete", &["U"]), + Pick::DeleteLeft => ("cells.delete", &["L"]), + Pick::Clear => ("cells.clear", &[]), + Pick::Split => ("cells.split", &[]), + Pick::HideRows => ("rows.hide", &[]), + Pick::ShowRows => ("rows.show", &[]), + Pick::HideColumns => ("columns.hide", &[]), + Pick::ShowColumns => ("columns.show", &[]), + 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!("table: {pick:?} with no workbook"); + 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, what: &What, cx: &App) -> Menu { + let item = |pick: Pick| { + let this = this.clone(); + MenuItem::new(Book::text(cx, menu::Pick::row(pick).0, &[])) + .disabled(!what.on(pick)) + .on_click(move |window, cx| this.update(cx, |book, cx| book.choose(pick, window, cx))) + }; + let menu = menu::clipboard(Menu::new(), CLIPBOARD, item); + menu::entries(menu, what.kind.rows(), cx, |pick| what.on(pick), item) +} diff --git a/apps/ely-table/src/tests.rs b/apps/ely-table/src/tests.rs index 173b146..20fe2fb 100644 --- a/apps/ely-table/src/tests.rs +++ b/apps/ely-table/src/tests.rs @@ -395,6 +395,7 @@ fn a_delete_stays_with_its_workbook() { mod analysis; mod data; +mod menu; mod names; mod numbers; mod output; diff --git a/apps/ely-table/src/tests/menu.rs b/apps/ely-table/src/tests/menu.rs new file mode 100644 index 0000000..56fd842 --- /dev/null +++ b/apps/ely-table/src/tests/menu.rs @@ -0,0 +1,258 @@ +use ely_gpui_component::theme::Theme; +use ely_lok::{LokView, sheet}; +use gpui::{Modifiers, MouseButton, Pixels, Point, VisualTestContext}; + +use super::data::{cells, open, run}; +use super::*; +use crate::menu::{Kind, Pick, What, held}; + +/// The grid with the menu's host over it. +struct Host(Entity); + +impl gpui::Render for Host { + fn render(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context) -> impl gpui::IntoElement { + use gpui::{ParentElement as _, Styled as _}; + let grid = self.0.update(cx, |book, cx| book.grid(window, cx)); + let menu = self.0.update(cx, |book, cx| book.context_menu(cx)); + gpui::div().relative().size_full().flex().flex_col().child(grid).children(menu) + } +} + +fn frames(cx: &mut VisualTestContext) { + for _ in 0..3 { + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + } +} + +fn view(cx: &mut VisualTestContext, book: &Entity) -> Entity { + cx.update(|_, app| book.read(app).active_tab().expect("a tab").view.clone()) +} + +/// A cell's middle or its header's, in window pixels. +fn spot(cx: &mut VisualTestContext, book: &Entity, column: u32, row: u32, header: Option) -> Point { + frames(cx); + let view = view(cx, book); + cx.update(|_, app| { + let geometry = &book.read(app).active_tab().expect("a tab").extra.geometry; + let x = geometry.columns.start(column) + geometry.columns.size(column) / 2; + let y = geometry.rows.start(row) + geometry.rows.size(row) / 2; + let read = view.read(app); + let at = read.origin() + read.place((x, y)); + match header { + None => at, + Some(false) => gpui::point(read.origin().x - gpui::px(24.0), at.y), + Some(true) => gpui::point(at.x, read.origin().y - gpui::px(12.0)), + } + }) +} + +/// Right-clicks and waits until `shown` holds for the menu. +fn right_click(cx: &mut VisualTestContext, book: &Entity, at: Point, shown: fn(&What) -> bool) { + cx.update(|_, app| book.update(app, |book, _| book.menu = 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()); + for _ in 0..500 { + cx.run_until_parked(); + if cx.update(|_, app| book.read(app).menu.as_ref().is_some_and(|offer| shown(&offer.what))) { + return frames(cx); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("no menu"); +} + +fn on(cx: &mut VisualTestContext, book: &Entity, pick: Pick) -> bool { + cx.update(|_, app| book.read(app).menu.as_ref().expect("a menu").what.on(pick)) +} + +fn choose(cx: &mut VisualTestContext, book: &Entity, pick: Pick) { + cx.update(|window, app| book.update(app, |book, cx| book.choose(pick, window, cx))); +} + +/// A column range's cells, empty ones kept. +fn column(cx: &mut TestAppContext, book: &Entity, range: &str) -> Vec { + cells(cx, book, range).lines().map(|line| line.trim().to_string()).collect() +} + +fn cell(what: &What) -> bool { + what.kind == Kind::Cell +} + +fn rows(what: &What) -> bool { + what.kind == Kind::Rows +} + +fn columns(what: &What) -> bool { + what.kind == Kind::Columns +} + +/// A new, selected rectangle over A1. +fn corner_rectangle(cx: &mut TestAppContext, book: &Entity) { + run(cx, book, "draw.shape", &["rectangle", "true"]); + run(cx, book, "object.x", &["0"]); + run(cx, book, "object.y", &["0"]); + let selected = cx.update(|app| book.read(app).active_tab().expect("a tab").view.read(app).graphic()); + assert!(selected.is_some_and(|(x, y, ..)| x < 100 && y < 100), "selected at A1: {selected:?}"); +} + +/// Protects the sheet, allowing selection and `more`. +fn protect(cx: &mut TestAppContext, book: &Entity, more: &[&str]) { + let allow: Vec = ["selectLocked", "selectUnlocked"].iter().chain(more).map(|key| key.to_string()).collect(); + run(cx, book, "sheet.protect", &[&sheet::protect_json("pw", &allow)]); +} + +#[test] +fn right_clicks_open_excel_rows_on_cells_and_headers() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = super::book(&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); + }); + open(&mut cx, &book); + settle(&cx, |app| book.read(app).active_tab().is_some_and(|tab| tab.extra.geometry.rows.count() > 0)); + let window = cx.add_window(|_, _| Host(book.clone())); + let mut shown = VisualTestContext::from_window(window.into(), &cx); + let a2 = spot(&mut shown, &book, 0, 1, None); + right_click(&mut shown, &book, a2, cell); + let cut = shown.debug_bounds("menu-button-0-0").expect("the clipboard row"); + let off = cut.origin - a2; + assert!(off.x >= gpui::px(0.0) && off.x < gpui::px(40.0) && off.y < gpui::px(40.0), "at the click: {off:?}"); + assert!(on(&mut shown, &book, Pick::RowsBelow) && on(&mut shown, &book, Pick::Clear)); + assert!(!on(&mut shown, &book, Pick::Merge), "one cell does not merge"); + choose(&mut shown, &book, Pick::RowsBelow); + assert_eq!(column(&mut cx, &book, "A2:A4"), ["a", "", "b"], "a row below A2"); + // The read left A2:A4 selected; a click outside it moves. + run(&mut cx, &book, "cell.goto", &["D9"]); + let a2 = spot(&mut shown, &book, 0, 1, None); + right_click(&mut shown, &book, a2, cell); + choose(&mut shown, &book, Pick::Clear); + assert_eq!(column(&mut cx, &book, "A1:A4"), ["Name", "", "", "b"], "cleared"); + assert_eq!(cells(&mut cx, &book, "B2"), "3", "one cell only"); + let row5 = spot(&mut shown, &book, 0, 4, Some(false)); + right_click(&mut shown, &book, row5, rows); + assert!(on(&mut shown, &book, Pick::HideRows) && on(&mut shown, &book, Pick::DeleteRows)); + choose(&mut shown, &book, Pick::DeleteRows); + assert_eq!(cells(&mut cx, &book, "A5"), "c", "row 5 went, not row 2"); + // A selected rectangle over A1 hides no header menu. + corner_rectangle(&mut cx, &book); + let row1 = spot(&mut shown, &book, 0, 0, Some(false)); + right_click(&mut shown, &book, row1, rows); + assert!(on(&mut shown, &book, Pick::RowsAbove) && on(&mut shown, &book, Pick::HideRows)); + choose(&mut shown, &book, Pick::RowsAbove); + assert_eq!(cells(&mut cx, &book, "A2"), "Name", "a row above row 1"); + corner_rectangle(&mut cx, &book); + let a = spot(&mut shown, &book, 0, 0, Some(true)); + right_click(&mut shown, &book, a, columns); + assert!(on(&mut shown, &book, Pick::ColumnsBefore) && on(&mut shown, &book, Pick::DeleteColumns)); + protect(&mut cx, &book, &[]); + let headers: [(Point, fn(&What) -> bool, [Pick; 4]); 2] = [ + (spot(&mut shown, &book, 0, 2, Some(false)), rows, [Pick::RowsAbove, Pick::DeleteRows, Pick::HideRows, Pick::ShowRows]), + (spot(&mut shown, &book, 1, 0, Some(true)), columns, [Pick::ColumnsBefore, Pick::DeleteColumns, Pick::HideColumns, Pick::ShowColumns]), + ]; + for (at, shown_as, picks) in headers { + right_click(&mut shown, &book, at, shown_as); + assert!(on(&mut shown, &book, Pick::Copy), "copy stays on"); + for pick in picks.into_iter().chain([Pick::Cut, Pick::Clear]) { + assert!(!on(&mut shown, &book, pick), "{pick:?} is off on a protected sheet"); + } + } + // Protection that lets rows and columns in. + run(&mut cx, &book, "sheet.protect", &[&sheet::unprotect_json("pw")]); + protect(&mut cx, &book, &["insertRows", "insertColumns"]); + let b = spot(&mut shown, &book, 1, 0, Some(true)); + right_click(&mut shown, &book, b, columns); + assert!(on(&mut shown, &book, Pick::ColumnsBefore), "inserting columns is allowed"); + assert!(!on(&mut shown, &book, Pick::DeleteColumns), "deleting columns is not"); + let row2 = spot(&mut shown, &book, 0, 1, Some(false)); + right_click(&mut shown, &book, row2, rows); + assert!(on(&mut shown, &book, Pick::RowsAbove), "inserting rows is allowed"); + assert!(!on(&mut shown, &book, Pick::DeleteRows), "deleting rows is not"); + choose(&mut shown, &book, Pick::RowsAbove); + // Protected cells read as nothing. + run(&mut cx, &book, "sheet.protect", &[&sheet::unprotect_json("pw")]); + assert_eq!(cells(&mut cx, &book, "A3"), "Name", "a row above row 2"); +} + +#[test] +fn a_header_keeps_a_whole_selection_that_holds_it() { + let geometry = sheet::geometry( + r#"{"rows":{"sizes":"256:1048575","hidden":"0:1048575"},"columns":{"sizes":"1280:16383","hidden":"0:16383"}}"#, + ) + .expect("parses"); + // As Calc reports column B, then row 3. + let column = [(1280, 0, 1279, 268435455)]; + assert!(held(&column, &geometry, true, 1)); + assert!(!held(&column, &geometry, true, 0)); + assert!(!held(&column, &geometry, false, 0), "a column is no row"); + let row = [(0, 512, 20971519, 255)]; + assert!(held(&row, &geometry, false, 2)); + assert!(!held(&row, &geometry, true, 0)); + let one = [(1280, 256, 1279, 255)]; + assert!(!held(&one, &geometry, true, 1), "one cell is no column"); + assert!(!held(&[], &geometry, false, 0)); +} + +/// Starts typing into the selected shape's text. +fn type_in_shape(cx: &mut TestAppContext, book: &Entity) { + cx.update(|app| { + let book = book.read(app); + let tab = book.active_tab().expect("a tab"); + let mut worker = book.core.worker.lock().expect("the worker"); + worker.post_key(&tab.doc, 0, 769).expect("F2"); + worker.post_key(&tab.doc, 'z' as u32, 537).expect("a key"); + }); + fresh(cx, book); + let graphic = cx.update(|app| book.read(app).active_tab().expect("a tab").view.read(app).graphic()); + assert!(graphic.is_none(), "editing the shape's text: {graphic:?}"); +} + +#[test] +fn a_header_ends_shape_text_editing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = super::book(&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); + }); + open(&mut cx, &book); + fresh(&cx, &book); + let window = cx.add_window(|_, _| Host(book.clone())); + let mut shown = VisualTestContext::from_window(window.into(), &cx); + let headers: [(bool, u32, fn(&What) -> bool, Pick); 2] = + [(false, 2, rows, Pick::RowsAbove), (true, 1, columns, Pick::ColumnsBefore)]; + for (column, index, shown_as, insert) in headers { + corner_rectangle(&mut cx, &book); + type_in_shape(&mut cx, &book); + let at = if column { spot(&mut shown, &book, index, 2, Some(true)) } else { spot(&mut shown, &book, 0, index, Some(false)) }; + right_click(&mut shown, &book, at, shown_as); + let picked = cx.update(|app| { + let tab = book.read(app).active_tab().expect("a tab"); + held(tab.view.read(app).selection(), &tab.extra.geometry, column, index) + }); + assert!(picked, "the header's line is selected, column {column}"); + assert!(on(&mut shown, &book, insert), "{insert:?} is on"); + choose(&mut shown, &book, insert); + } + assert_eq!(cells(&mut cx, &book, "A4"), "b", "a row above row 3"); + assert_eq!(cells(&mut cx, &book, "C2"), "3", "a column before B"); + let path = dir.path().join("shapes.fods"); + cx.update(|app| { + let book = book.read(app); + let mut worker = book.core.worker.lock().expect("the worker"); + let doc = &book.active_tab().expect("a tab").doc; + doc.save_as(&mut worker, &ely_lok::file_url(&path), "fods").expect("the save"); + }); + let xml = std::fs::read_to_string(&path).expect("the save"); + assert_eq!(xml.matches(">z").count(), 2, "each shape keeps its typed text"); +} diff --git a/crates/ely-lok/src/client/tests/commands.rs b/crates/ely-lok/src/client/tests/commands.rs index 8382b77..68ed6cb 100644 --- a/crates/ely-lok/src/client/tests/commands.rs +++ b/crates/ely-lok/src/client/tests/commands.rs @@ -143,7 +143,7 @@ fn every_mapped_command_reaches_the_kernel_and_reports_state() { assert!(text.contains("fo:font-weight=\"bold\""), "bold landed"); assert_eq!( commands::all().count(), - 247, + 248, "the rows of text, layout, refs, tools, sheet, calc, data, analysis, page, show and draw" ); } diff --git a/crates/ely-lok/src/commands.rs b/crates/ely-lok/src/commands.rs index 6e6e833..ca4b152 100644 --- a/crates/ely-lok/src/commands.rs +++ b/crates/ely-lok/src/commands.rs @@ -153,6 +153,7 @@ pub const QUERIES: &[&str] = &[ crate::sheet::NUMBER, crate::sheet::FILTER, crate::sheet::LIST, + crate::menu::STATES, ]; /// The kernel's font families, from `.uno:CharFontName`. diff --git a/crates/ely-lok/src/commands/sheet.rs b/crates/ely-lok/src/commands/sheet.rs index d4b341b..a0b1d6a 100644 --- a/crates/ely-lok/src/commands/sheet.rs +++ b/crates/ely-lok/src/commands/sheet.rs @@ -100,6 +100,7 @@ pub(super) const ROWS: &[Command] = &[ Check::Undoable, ), command("cells.split", ".uno:SplitCell", &[], Check::Undoable), + command("cells.clear", ".uno:ClearContents", &[], Check::Undoable), command("view.freeze", ".uno:FreezePanes", &[], Check::Finished), command("rows.hide", ".uno:HideRow", &[], Check::Undoable), command("rows.show", ".uno:ShowRow", &[], Check::Undoable), diff --git a/crates/ely-lok/src/menu.rs b/crates/ely-lok/src/menu.rs index de47ff5..995dbe1 100644 --- a/crates/ely-lok/src/menu.rs +++ b/crates/ely-lok/src/menu.rs @@ -2,15 +2,22 @@ use std::{collections::HashMap, io}; -use crate::review::{entries, invalid, parse}; +use crate::{ + Doc, Worker, + 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. + /// A Writer graphic, object or frame. Frame, + /// A spreadsheet cell. + Cell, + /// A drawing object outside Writer. + Object, } /// A misspelled word's menu: suggestions as the kernel's rules. @@ -36,6 +43,39 @@ impl Offered { } } +/// Which asked commands run now, per the kernel's dispatcher. +#[derive(Clone, Debug, PartialEq)] +pub struct Runs(HashMap); + +impl Runs { + /// The kernel lets `uno` run now. + pub fn allows(&self, uno: &str) -> bool { + self.0.get(uno) == Some(&true) + } +} + +/// The read of which commands run now (patch 0045). +pub const STATES: &str = ".uno:CommandStates"; + +/// Asks the kernel which of `commands` run now. +pub fn runs(worker: &mut Worker, doc: &Doc, commands: &[&str]) -> io::Result { + let json = worker.values(doc, &format!("{STATES}?commands={}", commands.join(",")))?; + read_runs(&json, commands) +} + +/// Reads the answer; every asked command must be in it. +fn read_runs(json: &str, commands: &[&str]) -> io::Result { + let value = parse(json)?; + let states = value["commandStates"] + .as_object() + .ok_or_else(|| invalid(format!("no command states: {json}")))?; + let read = |command: &&str| match states.get(*command).and_then(serde_json::Value::as_bool) { + Some(on) => Ok((command.to_string(), on)), + None => Err(invalid(format!("no state for {command}: {json}"))), + }; + Ok(Runs(commands.iter().map(read).collect::>()?)) +} + /// Reads a `LOK_CALLBACK_CONTEXT_MENU` payload. pub fn read(json: &str) -> io::Result { let list = parse(json)?; @@ -50,6 +90,10 @@ pub fn read(json: &str) -> io::Result { Target::Table } else if enabled.contains_key(".uno:WrapMenu") { Target::Frame + } else if enabled.contains_key(".uno:InsertCell") { + Target::Cell + } else if enabled.contains_key(".uno:ArrangeMenu") { + Target::Object } else { Target::Text }; diff --git a/crates/ely-lok/src/menu/tests.rs b/crates/ely-lok/src/menu/tests.rs index 4c35520..cf41cf3 100644 --- a/crates/ely-lok/src/menu/tests.rs +++ b/crates/ely-lok/src/menu/tests.rs @@ -32,6 +32,10 @@ fn the_menu_names_its_target_and_flags() { item(".uno:WrapMenu", true) ); assert_eq!(read(&frame).expect("a commandless submenu").target, Target::Frame); + let cell = format!(r#"{{"menu":[{}]}}"#, item(".uno:InsertCell", false)); + assert_eq!(read(&cell).expect("parses").target, Target::Cell); + let object = format!(r#"{{"menu":[{}]}}"#, item(".uno:ArrangeMenu", true)); + assert_eq!(read(&object).expect("parses").target, Target::Object); let text = format!(r#"{{"menu":[{}]}}"#, item(".uno:Paste", true)); assert_eq!(read(&text).expect("parses").target, Target::Text); } @@ -42,3 +46,14 @@ fn a_malformed_menu_fails() { assert!(read(r#"{"menu":[{"type":"command","enabled":"true"}]}"#).is_err()); assert!(read(r#"{"items":[]}"#).is_err()); } + +#[test] +fn command_states_answer_each_asked_command() { + let json = r#"{"commandStates":{".uno:DeleteRows":false,".uno:InsertRowsBefore":true}}"#; + let runs = read_runs(json, &[".uno:InsertRowsBefore", ".uno:DeleteRows"]).expect("parses"); + assert!(runs.allows(".uno:InsertRowsBefore")); + assert!(!runs.allows(".uno:DeleteRows")); + assert!(read_runs(json, &[".uno:HideRow"]).is_err(), "an unanswered command"); + let text = r#"{"commandStates":{".uno:Cut":"true"}}"#; + assert!(read_runs(text, &[".uno:Cut"]).is_err(), "a state is a boolean"); +} diff --git a/crates/ely-office/src/lib.rs b/crates/ely-office/src/lib.rs index 46f2d38..4178f34 100644 --- a/crates/ely-office/src/lib.rs +++ b/crates/ely-office/src/lib.rs @@ -9,6 +9,7 @@ pub mod engine; pub mod files; mod find; pub mod lok; +pub mod menu; mod open; mod recover; pub mod panel; diff --git a/crates/ely-office/src/menu.rs b/crates/ely-office/src/menu.rs new file mode 100644 index 0000000..23b6b49 --- /dev/null +++ b/crates/ely-office/src/menu.rs @@ -0,0 +1,86 @@ +//! Ely's right-click menus, gated by the kernel's (DOC-02). + +use std::io; + +use ely_gpui_component::{ + menus::{ContextMenu, Menu, MenuItem}, + primitives::IconName, +}; +use ely_lok::{LokView, menu::Offered}; +use gpui::{App, Entity, Pixels, Point}; + +/// One menu, asked for at a point in one tab. +pub struct Offer { + pub tab: u64, + pub what: W, + /// Bumps for each menu, so each opens once. + request: (u64, Point), +} + +impl Offer { + /// The next menu after `last`. + pub fn new(last: Option<&Offer>, tab: u64, what: W, at: Point) -> Self { + let number = last.map_or(1, |last| last.request.0 + 1); + Offer { tab, what, request: (number, at) } + } + + /// Its host, opened at the point. + pub fn host(&self, id: &'static str, menu: Menu) -> ContextMenu { + ContextMenu::new(id, menu).manual(Some(self.request)) + } +} + +/// The view's last kernel menu and where it opens. +pub fn take(view: &Entity, cx: &mut App) -> Option)>> { + let (json, at) = view.update(cx, |view, _| view.take_menu())?; + let offered = match ely_lok::menu::read(&json) { + Ok(offered) => offered, + Err(error) => return Some(Err(error)), + }; + // 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!("office: a menu with no click and no caret"); + return None; + }; + log::info!("office: a {:?} menu", offered.target); + Some(Ok((offered, at))) +} + +/// A menu row: its label key and its gating command. +pub trait Pick: Copy + 'static { + fn row(self) -> (&'static str, &'static str); +} + +/// A row, a submenu or a line. +pub enum Entry { + Row(P), + Sub(&'static str, &'static [P]), + Line, +} + +/// Cut, copy and paste as one row of icons. +pub fn clipboard(menu: Menu, picks: [P; 3], item: impl Fn(P) -> MenuItem) -> Menu { + let icons = [IconName::Scissors, IconName::Copy, IconName::Clipboard]; + let row = picks.into_iter().zip(icons).map(|(pick, icon)| item(pick).icon(icon)); + menu.buttons(row) +} + +/// Adds entries; a submenu is on when any row is. +pub fn entries( + menu: Menu, + entries: &[Entry

], + cx: &App, + on: impl Fn(P) -> bool, + item: impl Fn(P) -> MenuItem, +) -> Menu { + entries.iter().fold(menu, |menu, entry| 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 any = picks.iter().any(|pick| on(*pick)); + menu.item(MenuItem::submenu(crate::text(cx, key, &[]), sub).disabled(!any)) + } + Entry::Line => menu.separator(), + }) +} diff --git a/crates/ely-ui/src/i18n/table.rs b/crates/ely-ui/src/i18n/table.rs index 0f8776f..d6bfe7a 100644 --- a/crates/ely-ui/src/i18n/table.rs +++ b/crates/ely-ui/src/i18n/table.rs @@ -66,6 +66,10 @@ pub(super) const EN: &[(&str, &str)] = &[ ("table.cells.hide_columns", "Hide Columns"), ("table.cells.show_columns", "Show Columns"), ("table.cells.freeze", "Freeze or Unfreeze Panes"), + ("table.menu.insert", "Insert"), + ("table.menu.delete", "Delete"), + ("table.menu.clear", "Clear Contents"), + ("table.menu.format", "Format Cells"), ("table.size.row", "Row Height"), ("table.size.column", "Column Width"), ("table.size.label", "Centimeters"), @@ -157,6 +161,10 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("table.cells.hide_columns", "隐藏列"), ("table.cells.show_columns", "显示列"), ("table.cells.freeze", "冻结或取消冻结窗格"), + ("table.menu.insert", "插入"), + ("table.menu.delete", "删除"), + ("table.menu.clear", "清除内容"), + ("table.menu.format", "设置单元格格式"), ("table.size.row", "行高"), ("table.size.column", "列宽"), ("table.size.label", "厘米"), diff --git a/crates/ely-ui/src/i18n/tools.rs b/crates/ely-ui/src/i18n/tools.rs index 58479a0..46db77c 100644 --- a/crates/ely-ui/src/i18n/tools.rs +++ b/crates/ely-ui/src/i18n/tools.rs @@ -13,9 +13,9 @@ 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"), + ("office.menu.cut", "Cut"), + ("office.menu.copy", "Copy"), + ("office.menu.paste", "Paste"), ("doc.menu.link", "Link…"), ("doc.menu.comment", "New Comment"), ("doc.menu.insert", "Insert"), @@ -38,10 +38,10 @@ pub(super) const EN: &[(&str, &str)] = &[ ("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"), + ("office.menu.front", "Bring to Front"), + ("office.menu.forward", "Bring Forward"), + ("office.menu.back", "Send to Back"), + ("office.menu.backward", "Send Backward"), ("doc.menu.caption", "Insert Caption…"), ("doc.compare.open", "Compare With…"), ("doc.compare.name", "{name} compared"), @@ -108,9 +108,9 @@ 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", "粘贴"), + ("office.menu.cut", "剪切"), + ("office.menu.copy", "复制"), + ("office.menu.paste", "粘贴"), ("doc.menu.link", "链接…"), ("doc.menu.comment", "新建批注"), ("doc.menu.insert", "插入"), @@ -133,10 +133,10 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("doc.menu.square", "四周型"), ("doc.menu.top_bottom", "上下型"), ("doc.menu.in_front", "浮于文字上方"), - ("doc.menu.front", "置于顶层"), - ("doc.menu.forward", "上移一层"), - ("doc.menu.back", "置于底层"), - ("doc.menu.backward", "下移一层"), + ("office.menu.front", "置于顶层"), + ("office.menu.forward", "上移一层"), + ("office.menu.back", "置于底层"), + ("office.menu.backward", "下移一层"), ("doc.menu.caption", "插入题注…"), ("doc.compare.open", "与其他版本比较…"), ("doc.compare.name", "{name} 比较结果"), diff --git a/docs/compatibility.md b/docs/compatibility.md index f3face4..4f89000 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -3,7 +3,7 @@ 随发行说明发布,每个版本一份。支持某格式不等于支持它的全部功能;下表只写已验证的内容、已知限制与保存行为。 版本:Ely Office 0.1.3(首版六个应用:ElyDoc、ElyTable、ElyShow、ElyPDF、ElyTree、ElyMark)。日期 2026-10-11。 -内核:LibreOffice 26.2.6.3(8221e31,加 `patches/libreoffice` 0001–0041);MuPDF 1.28.5(8ad45e9,加 `patches/mupdf` 0001–0005);Git 2.56.0(a018953)与 git-lfs 3.8.0。 +内核:LibreOffice 26.2.6.3(8221e31,加 `patches/libreoffice` 0001–0046);MuPDF 1.28.5(8ad45e9,加 `patches/mupdf` 0001–0005);Git 2.56.0(a018953)与 git-lfs 3.8.0。 验证机:macOS 27.0 arm64。Windows 与 Linux 未验证。本机没有 Microsoft Office:与 Office 的交叉检查未做(待做)。 ## 往返验证 diff --git a/patches/libreoffice/0045-lok-command-states.patch b/patches/libreoffice/0045-lok-command-states.patch new file mode 100644 index 0000000..30c2fc0 --- /dev/null +++ b/patches/libreoffice/0045-lok-command-states.patch @@ -0,0 +1,86 @@ +LOK: read whether listed commands may run now + +LOK pushes enable states only for a fixed list, and only after +idle bindings update. A menu opened right after a selection needs +the answer now, for commands outside that list (hide rows, column +width, clear contents). getCommandValues(".uno:CommandStates? +commands=.uno:A,.uno:B") flushes the dispatcher and asks it for +each slot, as the menu bar would: true unless disabled or unknown +to the current shells. A name that is no slot fails the read. + +diff --git a/desktop/source/lib/init.cxx b/desktop/source/lib/init.cxx +--- a/desktop/source/lib/init.cxx ++++ b/desktop/source/lib/init.cxx +@@ -152,6 +152,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -6672,6 +6673,45 @@ enum class UndoOrRedo + + } + ++/// Whether each listed command may run now. ++static char* getCommandStates(std::string_view aList) ++{ ++ SfxViewShell* pViewShell = SfxViewShell::Current(); ++ if (!pViewShell) ++ { ++ SetLastExceptionMsg(u"CommandStates: no current view"_ustr); ++ return nullptr; ++ } ++ SfxViewFrame& rFrame = pViewShell->GetViewFrame(); ++ SfxDispatcher* pDispatcher = rFrame.GetBindings().GetDispatcher(); ++ pDispatcher->Flush(); ++ const SfxSlotPool& rPool = SfxSlotPool::GetSlotPool(&rFrame); ++ tools::JsonWriter aJson; ++ { ++ auto aStates = aJson.startNode("commandStates"); ++ sal_Int32 nIndex = 0; ++ do ++ { ++ std::string_view aCommand = o3tl::getToken(aList, 0, ',', nIndex); ++ if (!aCommand.starts_with(".uno:")) ++ { ++ SetLastExceptionMsg(u"CommandStates: not a .uno: command"_ustr); ++ return nullptr; ++ } ++ const SfxSlot* pSlot = rPool.GetUnoSlot(OUString::fromUtf8(aCommand.substr(5))); ++ if (!pSlot) ++ { ++ SetLastExceptionMsg(OUString::fromUtf8(aCommand) + u" : unknown command"_ustr); ++ return nullptr; ++ } ++ SfxPoolItemHolder aState; ++ const SfxItemState eState = pDispatcher->QueryState(pSlot->GetSlotId(), aState); ++ aJson.put(aCommand, eState != SfxItemState::DISABLED && eState != SfxItemState::UNKNOWN); ++ } while (nIndex >= 0); ++ } ++ return convertOString(aJson.finishAndGetAsOString()); ++} ++ + /// Returns the JSON representation of either an undo or a redo stack. + static char* getUndoOrRedo(LibreOfficeKitDocument* pThis, UndoOrRedo eCommand) + { +@@ -6808,6 +6848,7 @@ static char* doc_getCommandValues(LibreOfficeKitDocument* pThis, const char* pCo + static constexpr std::string_view aViewRowColumnHeaders(".uno:ViewRowColumnHeaders"); + static constexpr std::string_view aSheetGeometryData(".uno:SheetGeometryData"); + static constexpr std::string_view aFontSubset(".uno:FontSubset&name="); ++ static constexpr std::string_view aCommandStates(".uno:CommandStates?commands="); + + ITiledRenderable* pDoc = getTiledRenderable(pThis); + if (!pDoc) +@@ -6983,6 +7024,10 @@ static char* doc_getCommandValues(LibreOfficeKitDocument* pThis, const char* pCo + pDoc->getCellCursor(aJsonWriter); + return convertOString(aJsonWriter.finishAndGetAsOString()); + } ++ else if (aCommand.starts_with(aCommandStates)) ++ { ++ return getCommandStates(aCommand.substr(aCommandStates.size())); ++ } + else if (aCommand.starts_with(aFontSubset)) + { + return getFontSubset(aCommand.substr(aFontSubset.size())); diff --git a/patches/libreoffice/0046-lok-calc-headers-leave-drawings.patch b/patches/libreoffice/0046-lok-calc-headers-leave-drawings.patch new file mode 100644 index 0000000..9f9ade3 --- /dev/null +++ b/patches/libreoffice/0046-lok-calc-headers-leave-drawings.patch @@ -0,0 +1,83 @@ +LOK: selecting a row or column leaves a drawing first + +Calc's own headers call DrawDeselectAll before marking. Over LOK the +header is the client's, and .uno:SelectRow / .uno:SelectColumn live +only on the cell shell. While a shape is selected or its text is +being edited, a drawing shell replaces the cell shell, so the command +finds no slot and is dropped. The view shell now takes both slots in +LOK: it ends any drawing edit (keeping the typed text), deselects, +ends drawing mode as Escape does (rerunning the active tool's slot), +then marks, as a header click does. Desktop keeps them on the cell +shell only. + +diff --git a/sc/sdi/tabvwsh.sdi b/sc/sdi/tabvwsh.sdi +--- a/sc/sdi/tabvwsh.sdi ++++ b/sc/sdi/tabvwsh.sdi +@@ -42,6 +42,8 @@ + FID_TABLE_HIDE [ ExecMethod = ExecuteTable; StateMethod = GetStateTable; ] + FID_TABLE_SHOW [ ExecMethod = ExecuteTable; StateMethod = GetStateTable; ] + SID_SELECT_TABLES [ ExecMethod = Execute; StateMethod = GetState; ] ++ SID_SELECT_COL [ ExecMethod = Execute; StateMethod = GetState; ] ++ SID_SELECT_ROW [ ExecMethod = Execute; StateMethod = GetState; ] + FID_TAB_MENU_SET_TAB_BG_COLOR [ ExecMethod = ExecuteTable; StateMethod = GetStateTable; ] + FID_TAB_EVENTS [ ExecMethod = ExecuteTable; StateMethod = GetStateTable; ] + } +diff --git a/sc/source/ui/view/tabvwsh3.cxx b/sc/source/ui/view/tabvwsh3.cxx +--- a/sc/source/ui/view/tabvwsh3.cxx ++++ b/sc/source/ui/view/tabvwsh3.cxx +@@ -45,6 +45,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -1304,6 +1305,31 @@ + rReq.Done(); + break; + ++ // A drawing shell is on top: leave it, then mark. ++ case SID_SELECT_COL: ++ case SID_SELECT_ROW: ++ { ++ const SfxInt32Item* pIndex = rReq.GetArg(FN_PARAM_1); ++ const SfxInt16Item* pModifier = rReq.GetArg(FN_PARAM_2); ++ DrawDeselectAll(); ++ // End drawing mode as Escape does: rerun the tool's slot. ++ if (FuPoor* pFunc = GetDrawFuncPtr(); pFunc && IsDrawSelMode()) ++ GetViewData().GetDispatcher().Execute(pFunc->GetSlotID(), SfxCallMode::SLOT | SfxCallMode::RECORD); ++ if (nSlot == SID_SELECT_COL) ++ { ++ if (pIndex && pModifier) ++ MarkColumns(static_cast(pIndex->GetValue()), pModifier->GetValue()); ++ else ++ MarkColumns(); ++ } ++ else if (pIndex && pModifier) ++ MarkRows(static_cast(pIndex->GetValue()), pModifier->GetValue()); ++ else ++ MarkRows(); ++ rReq.Done(); ++ break; ++ } ++ + case SID_SELECT_TABLES: + { + ScViewData& rViewData = GetViewData(); +diff --git a/sc/source/ui/view/tabvwsha.cxx b/sc/source/ui/view/tabvwsha.cxx +--- a/sc/source/ui/view/tabvwsha.cxx ++++ b/sc/source/ui/view/tabvwsha.cxx +@@ -553,6 +553,12 @@ + rSet.DisableItem( nWhich ); // enabled only if several sheets are selected + break; + ++ case SID_SELECT_COL: ++ case SID_SELECT_ROW: ++ if (!comphelper::LibreOfficeKit::isActive()) ++ rSet.DisableItem( nWhich ); // desktop: the cell shell owns them ++ break; ++ + case FID_TOGGLEHIDDENCOLROW: + const svtools::ColorConfig& rColorCfg = ScModule::get()->GetColorConfig(); + rSet.Put( SfxBoolItem( nWhich, rColorCfg.GetColorValue(svtools::CALCHIDDENROWCOL).bIsVisible) ); diff --git a/tasks/BUGS.md b/tasks/BUGS.md index f568233..a89e462 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -149,4 +149,6 @@ P0 次序:W-01、K-01、L-01、O-01、W-02、L-02、W-03、L-03、W-04、L-04 - [ ] 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`) - [ ] P3-08 Windows(AMD):多个 worker 同时启动时内核的 OpenCL 探测卡在驱动的 `clCreateCommandQueue` 里数分钟(`openclwrapper::switchOpenCLDevice`);测试以 `SAL_DISABLE_OPENCL=1` 绕开;是否在 `ely.xcd` 关掉 `UseOpenCL` 待用户定 +- [ ] P3-09 Windows:内核给的选区文字与文本导出以 CRLF 换行,三个 ElyTable 单测按 LF 比对(`tests::data::sort_filter_duplicates_and_columns_run_from_their_dialogs`、`a_column_button_sorts_its_own_block_not_the_cursors`、`tests::output::exports_write_the_shown_sheet_as_text`);导出该用哪种换行待定 +- [ ] P3-10 Windows:`crates/ely-office/tests/restart.rs` 设 `HOME` 想用临时目录,但 Windows 上 `data_dir` 取已知文件夹(不看 `HOME`),测试在真实的 ElyDoc 配置里运行;中途被杀即留下挡路的 `lok-verify.fodt` 文件夹,真实 ElyDoc 的内核核对随之失败(2026-10-14 审查中出现,已手动删去) diff --git a/tasks/TASKS.md b/tasks/TASKS.md index 71e6944..feb9c0a 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -250,7 +250,7 @@ - [x] R5.2 右键菜单(DOC-02、07、08、11、12):原先只在内核菜单带拼写建议时才显示,其余右键无反应;改为照 Word 按目标出 Ely 的菜单,开在所点之处:剪切、复制、粘贴为图标行;正文为链接…、新建批注;表格单元格另有插入 ▸(左侧列、右侧列、上方行、下方行)、删除 ▸(行、列、表格)、合并单元格、拆分单元格 ▸(两列、两行)、平均分布各行与各列;图片为文字环绕 ▸、置于顶层 ▸、置于底层 ▸、插入题注…、新建批注;拼写错词时建议与“全部忽略”在最上。目标与各项可否取自内核这次给的菜单,命令只经 `COMMANDS`,文案进中英目录 — R2 PASS(链接…随内核的 `.uno:InsertHyperlink`:须选中可编辑的文字,未选中时灰显) - [x] R5.3 拖动表格边线(DOC-07):读内核的 `LOK_CALLBACK_TABLE_SELECTED`(光标所在表格的列与行边线),指针在边线上变为调整指针,拖动时画参考线,松开以 `.uno:TableChangeCurrentBorderPosition` 改列宽或行高(左、内、右列边线;内、下行边线),一步撤销,按读到边线时的修订执行;内核在 LOK 下有意不处理边线的指针与拖动(`SwEditWin::changeMousePointer`、`RulerColumnDrag` 依赖 LOK 没有的标尺);未进入的表格先单击一次 — 三轮用完,末轮修复已测(按住处与边线的偏移保留,原地单击不发命令;松开取所在处,出视图照跟;表格带其批次的修订) - [x] R5.4 验收:Windows 真窗口逐项核对(右键四种目标、加行加列、拖动列宽与行高、撤销、保存后重开);`agents.md` 随之;逐条核对表 — R2 PASS(拼写建议在 Windows 上内核不给,未核对,P3-01) -- [ ] R5.5 ElyTable 右键(用户 2026-10-14 要求核对同类问题):网格右键原无菜单(内核给了,Ely 丢弃),行列表头只收左键;照 Excel 按目标出 Ely 的菜单:单元格(剪切、复制、粘贴,插入、删除、清除内容,合并拆分,批注、设置单元格格式),行与列表头(插入、删除、行高或列宽、隐藏、取消隐藏);可否取自内核的菜单;ElyDoc 的菜单机制移入 `ely-office` 共用 +- [x] R5.5 ElyTable 右键(用户 2026-10-14 要求核对同类问题):网格右键原无菜单(内核给了,Ely 丢弃),行列表头只收左键;照 Excel 按目标出 Ely 的菜单:单元格(剪切、复制、粘贴,插入、删除、清除内容,合并拆分,设置单元格格式;ElyTable 无批注界面,不列),行与列表头(插入、删除、行高或列宽、隐藏、取消隐藏);可否取自内核对各项命令的状态(补丁 0045);表头在选中对象或编辑图形文字时照样选行列(补丁 0046);ElyDoc 的菜单机制移入 `ely-office` 共用 — 三轮用完,末轮修复已测(编辑图形文字时右键表头:补丁 0046 结束编辑、留下文字、退出绘图模式再选行列;行与列两种表头各有回归测试,存出的文件里两个图形的文字都在) - [ ] R5.6 ElyShow 右键与表格:画布右键原无菜单;照 PowerPoint 按目标出菜单(幻灯片、文本框、图形、表格、图片);表格菜单在光标进单元格后才有可否(内核对整表选中全报不可用);拖动表格边线照 R5.3(Impress 的 `LOK_CALLBACK_TABLE_SELECTED` 以字符串写数值,`table::read` 须收下) - [ ] R5.7 Gitea #1:ElyShow 左侧缩略图条滚轮不滚动(同窗口右栏正常,Linux Wayland 报);查明根因再修,加滚轮回归测试 - [ ] R5.8 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.5-r1.md b/tasks/reviews/R5.5-r1.md new file mode 100644 index 0000000..13ba5bd --- /dev/null +++ b/tasks/reviews/R5.5-r1.md @@ -0,0 +1,5 @@ +FAIL + +apps/ely-table/src/menu.rs:150 — [P2] Header menus enable every action unconditionally, violating R5.5's requirement that availability come from the kernel. Protect a sheet with only selection allowed, then right-click a row header: Insert, Delete, Cut and Clear Contents remain enabled. The kernel disables those operations, and choosing Delete produces `rows.delete: the undo history did not move` instead of an unavailable menu item. Reproduced against the rebuilt worker; see `.work/R5.5-r1-review/kernel-probe.log`. Obtain authoritative availability after the header selection settles, store it with the menu offer, and test protected row and column headers instead of treating all header actions as enabled. + +crates/ely-office/src/menu.rs:1 — [P3] Six new comments exceed the explicit ten-word limit: this file's lines 1, 33, 50 and 69, and `apps/ely-table/src/menu.rs:155` and `:186`. Running `python tests/scripts/comments.py` exits with failure on precisely these six comments (`.work/R5.5-r1-review/comments.log`). Shorten or remove them so the repository's required comment check passes. diff --git a/tasks/reviews/R5.5-r2.md b/tasks/reviews/R5.5-r2.md new file mode 100644 index 0000000..6b1d78d --- /dev/null +++ b/tasks/reviews/R5.5-r2.md @@ -0,0 +1,5 @@ +FAIL + +apps/ely-table/src/menu.rs:199 — [P2] Requesting a header menu by right-clicking the first displayed cell also hit-tests drawing objects. Place a rectangle over the top-left cells, then right-click an affected row or column header: the synthetic click returns `Target::Object`, and `offer_menu` rejects it at line 178, showing an engine error instead of the header menu. Both header kinds fail through the application event path (`.work/R5.5-r2-review/ui-probe.log`); the rebuilt-worker probe confirms the object target. Obtain header availability without hitting or selecting overlaid objects, preserve the row/column selection, and add regression coverage for covered headers. + +apps/ely-table/src/menu.rs:117 — [P2] Row and column insertion use `.uno:InsertCell` availability, which differs from the commands they execute on protected sheets. Protect a sheet with selection and Insert Rows allowed, then right-click a row header: insertion is disabled although `rows.insert_above` succeeds. Insert Columns has the same failure. Both application probes fail; the rebuilt-worker probe reports `InsertCell=false` followed by successful insertion (`.work/R5.5-r2-review/kernel-probe.log`). Gate insertion with authoritative availability for the actual row/column commands, exposing that kernel state through the menu interface as needed, and test protection that explicitly permits each insertion. diff --git a/tasks/reviews/R5.5-r3.md b/tasks/reviews/R5.5-r3.md new file mode 100644 index 0000000..7028a1a --- /dev/null +++ b/tasks/reviews/R5.5-r3.md @@ -0,0 +1,3 @@ +FAIL + +apps/ely-table/src/grid.rs:146 — [P2] The header-selection guard only exits an object when `graphic()` is present, but Calc clears that frame while editing the object's text. Insert a rectangle, press F2, type text, then right-click a row or column header: no row or column is selected, and Insert/Delete are disabled on the unprotected sheet because the drawing-text shell remains active. Both application probes fail (`.work/R5.5-r3-review/edit-probe.log`); the ordinary cell-editing control passes. Finish drawing-text editing and deselect its object before dispatching SelectRow/SelectColumn, preserving the entered text, and add regression coverage for both header kinds.