R5.5: ElyTable right-click menus, Excel-style; three rounds used, last fixes tested

Grid and header menus share ely_office::menu with ElyDoc. Each item's
availability is the kernel's state for the exact command it runs
(patch 0045). Patch 0046 lets a header select its row or column while
a shape is selected or its text is edited: the view shell ends the
drawing edit, keeps the typed text, leaves drawing mode, then marks.
Ely no longer sends Esc first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-14 13:02:10 -04:00
1 parent 07c0b43542
commit 87886c8a8d
30 files changed
+1010 -106

No files matched your search

+1
View File
@@ -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:` 为蟋蟀),转义与字符引用之后照样是表情,自动链接与 `<code>` 里不是,同一 DOM 文字节点里第一个 `:名:` 前为空白或空格才全部转、否则全不转(`x:smile: :smile:` 原样,过滤标签不分节点),HTML 标签与强调交叉时照浏览器修复 CommonMark 输出,`</br>` 照 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 无批注界面,不列批注。
+1 -1
View File
@@ -37,7 +37,7 @@ pub struct Docs {
pub review: Entity<Review>,
/// What the right column shows.
pub side: Side,
pub offer: Option<crate::menu::Offer>,
pub offer: Option<ely_office::menu::Offer<ely_lok::menu::Offered>>,
pub merge: Entity<crate::merge::Merge>,
pub navigator: Entity<crate::navigate::Navigator>,
/// The left column shows the navigator.
+38 -75
View File
@@ -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<Pixels>),
}
/// 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<Pick>] = &[
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<Pick>] = &[
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<Pick>] = &[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<LokView>, cx: &mut Context<Self>) {
let Some((json, at)) = view.update(cx, |view, _| view.take_menu()) else {
return;
};
let offered = match ely_lok::menu::read(&json) {
Ok(offered) => offered,
Err(error) => {
let (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<Docs>, 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<Docs>, 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)]
+2 -2
View File
@@ -41,7 +41,7 @@ fn right_click(cx: &mut VisualTestContext, docs: &Entity<Docs>, at: Point<Pixels
docs.read(app)
.offer
.as_ref()
.is_some_and(|offer| offer.offered.target == target)
.is_some_and(|offer| offer.what.target == target)
};
for _ in 0..500 {
cx.run_until_parked();
@@ -79,7 +79,7 @@ fn a_right_click_opens_word_rows_where_it_lands() {
"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))
shown.update(|_, app| pick.on(&docs.read(app).offer.as_ref().expect("an offer").what))
};
assert!(on(&mut shown, Pick::Comment));
assert!(!on(&mut shown, Pick::Link), "the kernel links a selection");
+1 -1
View File
@@ -126,7 +126,7 @@ 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 spelling = offer.offered.spelling.as_ref().expect("a spelling menu");
let spelling = offer.what.spelling.as_ref().expect("a spelling menu");
let (_, rule) = spelling
.suggestions
.iter()
+1
View File
@@ -23,6 +23,7 @@ ely-ui.workspace = true
[dev-dependencies]
ely-ui = { workspace = true, features = ["test-support"] }
ely-gpui-component = { workspace = true, features = ["test-support"] }
tempfile.workspace = true
gpui = { workspace = true, features = ["test-support"] }
+6 -1
View File
@@ -61,6 +61,8 @@ pub struct Book {
pub drag: Option<Drag>,
/// The sheet menu's request: number and place.
pub sheet_menu: Option<(u64, gpui::Point<gpui::Pixels>)>,
/// The grid or header menu.
pub menu: Option<ely_office::menu::Offer<crate::menu::What>>,
pub panel: Entity<Panel>,
pub panel_shown: bool,
pub choices: Option<crate::rules::Choices>,
@@ -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);
+1 -1
View File
@@ -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<Self>) {
pub(crate) fn ask_size(&mut self, column: bool, window: &mut Window, cx: &mut Context<Self>) {
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);
+14 -6
View File
@@ -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<Self>) {
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()
+1
View File
@@ -11,6 +11,7 @@ mod figures;
mod format;
mod formulas;
mod grid;
mod menu;
mod output;
mod page;
mod protect;
+326
View File
@@ -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<Pick>] = &[
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<Pick>] = &[
Entry::Line,
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
];
const ROWS: &[Entry<Pick>] = &[
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<Pick>] = &[
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<Pick>] {
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<LokView>, cx: &mut Context<Self>) {
let (offered, at) = match menu::take(view, cx) {
None => return,
Some(Ok(taken)) => taken,
Some(Err(error)) => return self.engine_failed(&error.to_string(), cx),
};
let 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<Pixels>, cx: &mut Context<Self>) {
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<Pixels>, cx: &mut Context<Self>) {
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<Self>) -> Option<ContextMenu> {
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<Self>) {
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<Self>) {
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<Book>, 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)
}
+1
View File
@@ -395,6 +395,7 @@ fn a_delete_stays_with_its_workbook() {
mod analysis;
mod data;
mod menu;
mod names;
mod numbers;
mod output;
+258
View File
@@ -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<Book>);
impl gpui::Render for Host {
fn render(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
use gpui::{ParentElement as _, Styled as _};
let 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<Book>) -> Entity<LokView> {
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<Book>, column: u32, row: u32, header: Option<bool>) -> Point<Pixels> {
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<Book>, at: Point<Pixels>, 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<Book>, 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<Book>, 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<Book>, range: &str) -> Vec<String> {
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<Book>) {
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<Book>, more: &[&str]) {
let allow: Vec<String> = ["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<Pixels>, 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<Book>) {
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</text:p>").count(), 2, "each shape keeps its typed text");
}
+1 -1
View File
@@ -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"
);
}
+1
View File
@@ -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`.
+1
View File
@@ -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),
+46 -2
View File
@@ -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<String, bool>);
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<Runs> {
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<Runs> {
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::<io::Result<_>>()?))
}
/// Reads a `LOK_CALLBACK_CONTEXT_MENU` payload.
pub fn read(json: &str) -> io::Result<Offered> {
let list = parse(json)?;
@@ -50,6 +90,10 @@ pub fn read(json: &str) -> io::Result<Offered> {
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
};
+15
View File
@@ -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");
}
+1
View File
@@ -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;
+86
View File
@@ -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<W> {
pub tab: u64,
pub what: W,
/// Bumps for each menu, so each opens once.
request: (u64, Point<Pixels>),
}
impl<W> Offer<W> {
/// The next menu after `last`.
pub fn new(last: Option<&Offer<W>>, tab: u64, what: W, at: Point<Pixels>) -> 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<LokView>, cx: &mut App) -> Option<io::Result<(Offered, Point<Pixels>)>> {
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<P: 'static> {
Row(P),
Sub(&'static str, &'static [P]),
Line,
}
/// Cut, copy and paste as one row of icons.
pub fn clipboard<P: Pick>(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<P: Pick>(
menu: Menu,
entries: &[Entry<P>],
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(),
})
}
+8
View File
@@ -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", "厘米"),
+14 -14
View File
@@ -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} 比较结果"),
+1 -1
View File
@@ -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 的交叉检查未做(待做)。
## 往返验证
@@ -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 <sfx2/docfile.hxx>
#include <sfx2/viewsh.hxx>
#include <sfx2/viewfrm.hxx>
+#include <sfx2/msg.hxx>
#include <sfx2/msgpool.hxx>
#include <sfx2/dispatch.hxx>
#include <sfx2/lokcomponenthelpers.hxx>
@@ -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()));
@@ -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 <reffact.hxx>
#include <tabprotection.hxx>
#include <protectiondlg.hxx>
+#include <fupoor.hxx>
#include <kitsheet.hxx>
#include <docfunc.hxx>
#include <boost/property_tree/json_parser.hpp>
@@ -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<SfxInt32Item>(FN_PARAM_1);
+ const SfxInt16Item* pModifier = rReq.GetArg<SfxInt16Item>(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<SCCOL>(pIndex->GetValue()), pModifier->GetValue());
+ else
+ MarkColumns();
+ }
+ else if (pIndex && pModifier)
+ MarkRows(static_cast<SCROW>(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) );
+2
View File
@@ -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 审查中出现,已手动删去)
+1 -1
View File
@@ -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 发布
+5
View File
@@ -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.
+5
View File
@@ -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.
+3
View File
@@ -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.