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:
1 parent
07c0b43542
commit
87886c8a8d
30 files changed
+1010
-106
No files matched your search
@@ -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 无批注界面,不列批注。
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -11,6 +11,7 @@ mod figures;
|
||||
mod format;
|
||||
mod formulas;
|
||||
mod grid;
|
||||
mod menu;
|
||||
mod output;
|
||||
mod page;
|
||||
mod protect;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -395,6 +395,7 @@ fn a_delete_stays_with_its_workbook() {
|
||||
|
||||
mod analysis;
|
||||
mod data;
|
||||
mod menu;
|
||||
mod names;
|
||||
mod numbers;
|
||||
mod output;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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", "厘米"),
|
||||
|
||||
@@ -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} 比较结果"),
|
||||
|
||||
@@ -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) );
|
||||
@@ -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
@@ -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 发布
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user