diff --git a/agents.md b/agents.md index 6274d51..8b0718e 100644 --- a/agents.md +++ b/agents.md @@ -289,3 +289,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-13(用户定):ElyMark 以 Typora 1.14.10 为对照(R4;安装包与解包放在 `TyporaRef`,不入库):可视模式仍是按下的块以原文编辑;语法照 Typora 默认(`:表情:`、行内与块级 HTML 的常用标签、GitHub 提示块、`[toc]`、front matter 框;`==`、`^`、单个 `~` 照 GFM);快捷键照 Typora(Ctrl+1–6 标题、Ctrl+0 段落、Ctrl+/ 源码、Ctrl+Shift+=/-/0 字号、F8 专注、F9 打字机);右键菜单照 Typora 的布局,剪贴板与格式为图标行(组件库 `Menu` 新增)。Typora 与 GitHub 读同一份文件不同时随 GitHub(GFM,§14.3):表情名取 GitHub 现行的 gemoji(`emojis` 库,`:cricket:` 为蟋蟀),转义与字符引用之后照样是表情,自动链接与 `` 里不是,同一 DOM 文字节点里第一个 `:名:` 前为空白或空格才全部转、否则全不转(`x:smile: :smile:` 原样,过滤标签不分节点),HTML 标签与强调交叉时照浏览器修复 CommonMark 输出,`
` 照 HTML5 也是换行,GFM 标签过滤的九种(`script`、`style` 等)照原文显示,提示块的标记读解析后的纯文字;Typora 只决定哪些扩展显示。导出、纯文本与富文本复制经组件 `MarkdownReader` 照可视读每个段落、标题、单元格与 HTML 块(各自一套新栈,块尾关闭未闭合的标签):脚注在原处、写作 `[号]`,链接不带标题(可视不显示),取代 C14.14 的页末脚注与 HTML 一律转义。审查改用 gpt-6-astra,effort max,fast。 - 2026-10-14(用户要求照 Word):ElyDoc 的右键与表格(R5)。右键菜单由 Ely 按目标自建(`apps/ely-doc/src/menu.rs`),开在所点之处(`LokView` 记下右键点,`take_menu` 一并交出):目标、各项可否与拼写建议都取自内核这次给的菜单(`ely_lok::menu`:有 `WrapMenu` 为图片,有 `TableInsertMenu` 为表格,否则正文;缺 `enabled` 即报错),命令只经 `COMMANDS`。剪切、复制、粘贴为图标行(组件库 `Menu::buttons`);正文为链接…、新建批注;表格另有插入、删除、合并、拆分、平均分布;图片为文字环绕(嵌入、四周、上下、浮于文字上方;内核无紧密、穿越、衬于文字下方的对应项,不列)、置于顶层、置于底层、插入题注…,新建批注灰显(内核的图片菜单无批注)。链接…随内核的 `.uno:InsertHyperlink`:须选中可编辑的文字,未选中时灰显(Word 未选中也可用;照做须改内核,未做)。拖动表格边线:worker 把 `LOK_CALLBACK_TABLE_SELECTED` 读成 `ely_lok::table::Table`(列与行边线、上下限),视图在边线上显示调整指针(命中范围不超过与邻线距离的四分之一,矮行的中间仍落在文档),按住时记下读到它的批次的修订与按住处的偏移,拖动时画参考线(出视图照跟),松开以 `.uno:TableChangeCurrentBorderPosition` 发一条命令(一步撤销),原地松开不发;其间文档变了即以过期拒绝(`view/borders.rs`)。内核在 LOK 下不处理边线的指针与拖动(依赖 LOK 没有的标尺)。属性栏在光标进表格时把表格组排在最前(组件库 9ff403d 起按标题记折叠)。Windows 真窗口逐项核对过(右键三种目标、插入行列、拖动列宽行高、撤销、保存后重开);拼写建议在 Windows 上内核不给(P3-01),未核对。 - 2026-10-14(用户要求核对 ElyTable 与 ElyShow):ElyTable 的右键(R5.5)。菜单机制移入 `ely_office::menu`(取内核这次给的菜单、开在所点之处、剪贴板图标行、子菜单可否随其中各项),ElyDoc 与 ElyTable 共用;`ely_lok::menu` 的目标另有单元格(有 `.uno:InsertCell`)与 Writer 之外的绘图对象(有 `.uno:ArrangeMenu`)。网格照 Excel:插入 ▸、删除 ▸、清除内容(`.uno:ClearContents`,同 Delete 键,不弹内核的删除内容对话框)、合并、拆分、设置单元格格式(打开格式面板);对象为置于顶层、置于底层;编辑单元格时只有剪贴板行。行列表头由 Ely 自画,内核不给菜单:右键先选中该行或列(已选的整行整列含它即不动,同 Excel)。选中对象或正编辑图形里的文字时,绘图外壳取代了单元格外壳,`.uno:SelectRow`/`.uno:SelectColumn` 原无处执行而被丢弃;补丁 `0046-lok-calc-headers-leave-drawings.patch` 让 LOK 下视图外壳接下这两条:先结束图形编辑(键入的文字留下)并取消选中,再选行列,同 Calc 自己的表头(`DrawDeselectAll`),菜单为插入、删除、清除内容、行高或列宽、隐藏、显示。ElyTable 每一项的可否都取自内核对它所发的那条命令此刻的状态(内核的菜单只定目标):补丁 `0045-lok-command-states.patch` 加 `getCommandValues(".uno:CommandStates?commands=…")`,先冲出调度器、再逐条问调度器(同菜单栏),未知的命令即报错(`ely_lok::menu::runs`);LOK 推送的状态只有固定的一组,且在空闲时才更新。因此受保护而许插入行列的工作表照样可插;行高、列宽只在文档只读时禁用(内核的规则),受保护时由内核拒绝并报出其错误。ElyTable 无批注界面,不列批注。 +- 2026-10-14:ElyShow 的右键(R5.6)。画布右键照 PowerPoint 按目标出 Ely 的菜单(`apps/ely-show/src/menu.rs`,共用 `ely_office::menu`);`ely_lok::menu` 的目标另有幻灯片(有 `.uno:SlideLayoutMenu`)与 Writer 之外的图片(有 `.uno:ChangePicture`)。幻灯片为版式 ▸、新建批注;编辑文字为项目符号、编号、新建批注;图形与文本框为组合 ▸、置于顶层 ▸、置于底层 ▸、新建批注;图片另有替换…、裁剪;表格为插入 ▸、删除 ▸、合并单元格、新建批注;剪贴板同为图标行。每项可否取自内核对其命令此刻的状态(补丁 0045):整表选中时可插入行列,删除与合并要光标进单元格(内核的规则)。Impress 的 `LOK_CALLBACK_TABLE_SELECTED` 由 boost 属性树写出,数值与真假都是文字,空列表是空串,`table::read` 照收;拖动表格边线走 R5.3 的同一路径(`.uno:TableChangeCurrentBorderPosition`,偏移为 twips,内核换算)。补丁 `0047-lok-impress-table-edges.patch`:Impress 原只给内侧边线的上下限,行的下限是布局器写死的 600(上游待办),参考线与落点不符;补丁让两轴都带首尾边线的上下限(`leftMin`、`leftMax`、`rightMin`、`rightMax`,列只限从左到右),内侧行线取行自身的最小高度,`table::Lines::ends` 收下,有则按它夹住(内侧线也直接用内核的上限),Writer 不给时照旧留 40 twips;已在界外的线从不逆着拖动方向移动,原地按下松开不发命令;最左列边线原什么也不做却记一步撤销(上游待办),LOK 下改为改首列宽并随边线移动表格,同一步撤销,桌面的拖动手柄不变。正编辑的单元格按编辑器量最小高度,略大于内核真正允许的(约 2 像素),新表的行因此不能再缩,参考线与落点仍一致。正编辑表格单元格时在幻灯片空白处右键,内核只结束编辑、不给菜单,再按一次才有(BUGS P3-11)。 diff --git a/apps/ely-doc/src/menu.rs b/apps/ely-doc/src/menu.rs index ce0c988..4b5a711 100644 --- a/apps/ely-doc/src/menu.rs +++ b/apps/ely-doc/src/menu.rs @@ -131,7 +131,7 @@ impl Docs { return self.toast(text, Severity::Danger, cx); } }; - if matches!(offered.target, Target::Cell | Target::Object) { + if matches!(offered.target, Target::Cell | Target::Object | Target::Picture | Target::Slide) { 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); @@ -229,7 +229,7 @@ pub(crate) fn menu_of(this: &Entity, offered: &Offered, cx: &App) -> Menu Target::Table => TABLE, Target::Frame => FRAME, Target::Text => TEXT, - Target::Cell | Target::Object => unreachable!("offer_menu refuses them"), + Target::Cell | Target::Object | Target::Picture | Target::Slide => unreachable!("offer_menu refuses them"), }; menu::entries(menu, rows, cx, |pick| pick.on(offered), item) } diff --git a/apps/ely-show/src/deck.rs b/apps/ely-show/src/deck.rs index b136993..8f66376 100644 --- a/apps/ely-show/src/deck.rs +++ b/apps/ely-show/src/deck.rs @@ -57,6 +57,8 @@ pub struct Deck { pub remarks_pane: Entity, /// What the menus last checked: grid, guides, comments. pub snap_menus: Option<(bool, bool, bool)>, + /// The canvas's right-click menu. + pub menu: Option>, /// Pictures of closed tabs and ended previews, freed later. pub retired: ely_gpui_component::primitives::Retired, } @@ -103,6 +105,7 @@ impl Host for Deck { tab.extra.dark = dark; self.repaint_all(id, cx); } + self.offer_menu(id, view, cx); self.reread(id, view, cx); } } @@ -172,6 +175,7 @@ impl Deck { reading_fonts: false, guide_drag: None, snap_menus: Some((false, false, false)), + menu: None, notes: Default::default(), remarks_open: false, preview: None, @@ -379,7 +383,7 @@ impl Deck { self.paint_next(id, cx); } - fn body(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + pub(crate) fn body(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { let Some(tab) = self.active_tab() else { let title = match self.core.loading.first() { Some((_, name)) => Self::text(cx, "office.loading", &[("name", name)]), @@ -447,7 +451,8 @@ impl Render for Deck { .on_action(cx.listener(Self::on_close_tab)) .child(shell) .children(self.core_dialog(cx)) - .children(self.dialog_element(cx)); + .children(self.dialog_element(cx)) + .children(self.context_menu(cx)); let root = crate::slideshow::bind(crate::insert::bind(crate::slides::bind(root, cx), cx), cx); Self::bind_zoom(root, cx) } diff --git a/apps/ely-show/src/main.rs b/apps/ely-show/src/main.rs index 93c373d..402a51d 100644 --- a/apps/ely-show/src/main.rs +++ b/apps/ely-show/src/main.rs @@ -11,6 +11,7 @@ mod guides; mod insert; mod list; mod media; +mod menu; mod motion; mod new; mod notes; diff --git a/apps/ely-show/src/menu.rs b/apps/ely-show/src/menu.rs new file mode 100644 index 0000000..5b24e0c --- /dev/null +++ b/apps/ely-show/src/menu.rs @@ -0,0 +1,307 @@ +//! The canvas's right-click menus, laid out as PowerPoint's (SHOW-03). + +use ely_gpui_component::{ + menus::{ContextMenu, Menu, MenuItem}, + primitives::Severity, +}; +use ely_lok::{ + LokView, + menu::{Runs, Target, runs}, + show::LAYOUTS, +}; +use ely_office::menu::{self, Entry, Offer}; +use ely_office::prelude::*; +use gpui::{App, Context, Entity, Window}; + +use crate::deck::Deck; + +/// What was right-clicked. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Kind { + /// The slide, nothing selected. + Slide, + /// Text being edited. + Text, + /// A shape or a text box. + Object, + Picture, + Table, +} + +/// 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, + /// An index into `LAYOUTS`. + Layout(usize), + Comment, + Bullets, + Numbering, + Group, + Ungroup, + Front, + Forward, + Back, + Backward, + Replace, + Crop, + RowAbove, + RowBelow, + ColumnLeft, + ColumnRight, + DeleteRows, + DeleteColumns, + DeleteTable, + Merge, +} + +const CLIPBOARD: [Pick; 3] = [Pick::Cut, Pick::Copy, Pick::Paste]; + +const LAYOUT_PICKS: [Pick; 12] = [ + Pick::Layout(0), + Pick::Layout(1), + Pick::Layout(2), + Pick::Layout(3), + Pick::Layout(4), + Pick::Layout(5), + Pick::Layout(6), + Pick::Layout(7), + Pick::Layout(8), + Pick::Layout(9), + Pick::Layout(10), + Pick::Layout(11), +]; + +const SLIDE: &[Entry] = &[ + Entry::Line, + Entry::Sub("show.design.layout", &LAYOUT_PICKS), + Entry::Line, + Entry::Row(Pick::Comment), +]; + +const TEXT: &[Entry] = &[ + Entry::Line, + Entry::Row(Pick::Bullets), + Entry::Row(Pick::Numbering), + Entry::Line, + Entry::Row(Pick::Comment), +]; + +const OBJECT: &[Entry] = &[ + Entry::Line, + Entry::Sub("show.object.grouping", &[Pick::Group, Pick::Ungroup]), + Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]), + Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]), + Entry::Line, + Entry::Row(Pick::Comment), +]; + +const PICTURE: &[Entry] = &[ + Entry::Line, + Entry::Row(Pick::Replace), + Entry::Row(Pick::Crop), + Entry::Line, + Entry::Sub("show.object.grouping", &[Pick::Group, Pick::Ungroup]), + Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]), + Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]), + Entry::Line, + Entry::Row(Pick::Comment), +]; + +const TABLE: &[Entry] = &[ + Entry::Line, + Entry::Sub( + "doc.menu.insert", + &[Pick::RowAbove, Pick::RowBelow, Pick::ColumnLeft, Pick::ColumnRight], + ), + Entry::Sub("doc.menu.delete", &[Pick::DeleteRows, Pick::DeleteColumns, Pick::DeleteTable]), + Entry::Row(Pick::Merge), + Entry::Line, + Entry::Row(Pick::Comment), +]; + +/// Each layout's label, in `LAYOUTS` order. +const LAYOUT_KEYS: [&str; 12] = [ + "show.layout.20", + "show.layout.0", + "show.layout.1", + "show.layout.3", + "show.layout.19", + "show.layout.32", + "show.layout.12", + "show.layout.15", + "show.layout.14", + "show.layout.16", + "show.layout.18", + "show.layout.34", +]; + +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::Layout(index) => (LAYOUT_KEYS[index], ".uno:AssignLayout"), + Pick::Comment => ("doc.menu.comment", ".uno:InsertAnnotation"), + Pick::Bullets => ("doc.props.bullets", ".uno:DefaultBullet"), + Pick::Numbering => ("doc.props.numbering", ".uno:DefaultNumbering"), + Pick::Group => ("show.object.group", ".uno:FormatGroup"), + Pick::Ungroup => ("show.object.ungroup", ".uno:FormatUngroup"), + 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::Replace => ("show.object.replace", ".uno:ChangePicture"), + Pick::Crop => ("show.object.crop", ".uno:Crop"), + Pick::RowAbove => ("doc.menu.row_above", ".uno:InsertRowsBefore"), + Pick::RowBelow => ("doc.menu.row_below", ".uno:InsertRowsAfter"), + Pick::ColumnLeft => ("doc.menu.column_left", ".uno:InsertColumnsBefore"), + Pick::ColumnRight => ("doc.menu.column_right", ".uno:InsertColumnsAfter"), + Pick::DeleteRows => ("doc.menu.delete_rows", ".uno:DeleteRows"), + Pick::DeleteColumns => ("doc.menu.delete_columns", ".uno:DeleteColumns"), + Pick::DeleteTable => ("doc.menu.delete_table", ".uno:DeleteTable"), + Pick::Merge => ("doc.menu.merge", ".uno:MergeCells"), + } + } +} + +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 { + fn of(target: Target) -> Option { + match target { + Target::Slide => Some(Kind::Slide), + Target::Text => Some(Kind::Text), + Target::Object => Some(Kind::Object), + Target::Picture => Some(Kind::Picture), + Target::Table => Some(Kind::Table), + Target::Frame | Target::Cell => None, + } + } + + /// Its rows under the clipboard row. + fn rows(self) -> &'static [Entry] { + match self { + Kind::Slide => SLIDE, + Kind::Text => TEXT, + Kind::Object => OBJECT, + Kind::Picture => PICTURE, + Kind::Table => TABLE, + } + } + + /// 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 + } +} + +impl Deck { + /// Turns the kernel's context menu into Ely's. + pub(crate) fn offer_menu(&mut self, id: u64, view: &Entity, cx: &mut Context) { + let (offered, at) = match menu::take(view, cx) { + None => return, + Some(Ok(taken)) => taken, + Some(Err(error)) => return self.engine_failed(&error.to_string(), cx), + }; + let Some(kind) = Kind::of(offered.target) else { + let error = format!("a {:?} menu in a presentation", offered.target); + return self.toast(Deck::text(cx, "office.error.engine", &[("error", &error)]), Severity::Danger, cx); + }; + let commands = kind.commands(); + self.ask_in( + id, + move |worker, doc| runs(worker, doc, &commands), + move |deck, read, _, cx| match read { + Ok(runs) => { + deck.menu = Some(Offer::new(deck.menu.as_ref(), id, What { kind, runs }, at)); + cx.notify(); + } + Err(error) => deck.engine_failed(&error.to_string(), cx), + }, + cx, + ); + } + + /// The menu's host, for the active deck. + pub(crate) fn context_menu(&self, cx: &mut Context) -> Option { + let offer = self.menu.as_ref().filter(|offer| Some(offer.tab) == self.core.active)?; + Some(offer.host("show-menu", menu_of(&cx.entity(), &offer.what, cx))) + } + + /// Runs one menu row. + pub(crate) fn choose(&mut self, pick: Pick, window: &mut Window, cx: &mut Context) { + log::info!("show: the menu picks {pick:?}"); + let id = match pick { + Pick::Cut | Pick::Copy | Pick::Paste => return self.clipboard(pick, cx), + Pick::Comment => return self.ask_remark(None, window, cx), + Pick::Replace => return self.pick_picture(true, cx), + Pick::Layout(index) => { + return self.design_command("slide.layout", &[&LAYOUTS[index].to_string()], cx); + } + Pick::Bullets => "list.bullets", + Pick::Numbering => "list.numbering", + Pick::Group => "object.group", + Pick::Ungroup => "object.ungroup", + Pick::Front => "image.front", + Pick::Forward => "image.forward", + Pick::Back => "image.back", + Pick::Backward => "image.backward", + Pick::Crop => "image.crop", + Pick::RowAbove => "table.row_above", + Pick::RowBelow => "table.row_below", + Pick::ColumnLeft => "table.column_left", + Pick::ColumnRight => "table.column_right", + Pick::DeleteRows => "table.delete_rows", + Pick::DeleteColumns => "table.delete_columns", + Pick::DeleteTable => "table.delete", + Pick::Merge => "table.merge", + }; + self.object_command(id, &[], cx); + } + + fn clipboard(&mut self, pick: Pick, cx: &mut Context) { + let Some(view) = self.active_tab().map(|tab| tab.view.clone()) else { + log::warn!("show: {pick:?} with no deck"); + return; + }; + view.update(cx, |view, _| match pick { + Pick::Paste => view.paste(), + _ => view.copy(pick == Pick::Cut), + }); + } +} + +/// The clipboard row, then the target's rows. +pub(crate) fn menu_of(this: &Entity, what: &What, cx: &App) -> Menu { + let item = |pick: Pick| { + let this = this.clone(); + MenuItem::new(Deck::text(cx, menu::Pick::row(pick).0, &[])) + .disabled(!what.on(pick)) + .on_click(move |window, cx| this.update(cx, |deck, cx| deck.choose(pick, window, cx))) + }; + let menu = menu::clipboard(Menu::new(), CLIPBOARD, item); + menu::entries(menu, what.kind.rows(), cx, |pick| what.on(pick), item) +} diff --git a/apps/ely-show/src/tests.rs b/apps/ely-show/src/tests.rs index 554db49..9baf1ed 100644 --- a/apps/ely-show/src/tests.rs +++ b/apps/ely-show/src/tests.rs @@ -8,6 +8,7 @@ use gpui::{AppContext as _, Entity, TestAppContext}; use crate::deck::Deck; +mod menu; mod motion; mod notes; mod objects; diff --git a/apps/ely-show/src/tests/menu.rs b/apps/ely-show/src/tests/menu.rs new file mode 100644 index 0000000..ac08894 --- /dev/null +++ b/apps/ely-show/src/tests/menu.rs @@ -0,0 +1,138 @@ +use ely_gpui_component::theme::Theme; +use ely_lok::show::Objects; +use ely_office::prelude::*; +use gpui::{Entity, Modifiers, MouseButton, Pixels, Point, TestAppContext, VisualTestContext}; + +use super::objects::{act, blank, objects}; +use super::settle; +use crate::deck::Deck; +use crate::menu::{Kind, Pick}; + +/// The canvas with the menu's host over it. +struct Host(Entity); + +impl gpui::Render for Host { + fn render(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context) -> impl gpui::IntoElement { + use gpui::{ParentElement as _, Styled as _}; + let body = self.0.update(cx, |deck, cx| deck.body(window, cx)); + let menu = self.0.update(cx, |deck, cx| deck.context_menu(cx)); + gpui::div().relative().size_full().flex().flex_col().child(body).children(menu) + } +} + +fn frames(cx: &mut VisualTestContext) { + for _ in 0..3 { + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + } +} + +/// A slide point, in 1/100 mm, in window pixels. +fn spot(cx: &mut VisualTestContext, deck: &Entity, (x, y): (i64, i64)) -> Point { + frames(cx); + cx.update(|_, app| { + let read = deck.read(app).active_tab().expect("a tab").view.read(app); + let twips = |mm100| crate::object::twips(mm100) as i32; + read.origin() + read.place((twips(x), twips(y))) + }) +} + +/// A point `1/part` into the first object of `kind`. +fn within(cx: &mut VisualTestContext, deck: &Entity, kind: &str, part: i64) -> Point { + let (x, y, w, h) = cx.update(|_, app| { + let objects = objects(app, deck).expect("objects"); + objects.objects.iter().find(|object| object.kind == kind).expect("the object").rect + }); + spot(cx, deck, (x + w / part, y + h / part)) +} + +fn click(cx: &mut VisualTestContext, at: Point, button: MouseButton) { + cx.simulate_mouse_move(at, None, Modifiers::none()); + cx.simulate_mouse_down(at, button, Modifiers::none()); + cx.simulate_mouse_up(at, button, Modifiers::none()); +} + +/// Right-clicks and waits for a menu of `kind`. +fn right_click(cx: &mut VisualTestContext, deck: &Entity, at: Point, kind: Kind) { + cx.update(|_, app| deck.update(app, |deck, _| deck.menu = None)); + click(cx, at, MouseButton::Right); + for _ in 0..500 { + cx.run_until_parked(); + if cx.update(|_, app| deck.read(app).menu.as_ref().is_some_and(|offer| offer.what.kind == kind)) { + return frames(cx); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + let got = cx.update(|_, app| deck.read(app).menu.as_ref().map(|offer| offer.what.kind)); + panic!("no {kind:?} menu, got {got:?}"); +} + +fn on(cx: &mut VisualTestContext, deck: &Entity, pick: Pick) -> bool { + cx.update(|_, app| deck.read(app).menu.as_ref().expect("a menu").what.on(pick)) +} + +fn choose(cx: &mut VisualTestContext, deck: &Entity, pick: Pick) { + cx.update(|window, app| deck.update(app, |deck, cx| deck.choose(pick, window, cx))); +} + +fn table_height(objects: &Objects) -> Option { + objects.objects.iter().find(|object| object.kind == "table").map(|object| object.rect.3) +} + +#[test] +fn right_clicks_open_powerpoint_rows_on_each_target() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + cx.update(|cx| Theme::update(cx, |theme| theme.reduced_motion = true)); + settle(&cx, |app| objects(app, &deck).is_some_and(|o| !o.objects.is_empty())); + let window = cx.add_window(|_, _| Host(deck.clone())); + let mut shown = VisualTestContext::from_window(window.into(), &cx); + let corner = spot(&mut shown, &deck, (27_000, 15_000)); + right_click(&mut shown, &deck, corner, Kind::Slide); + let clipboard = shown.debug_bounds("menu-button-0-0").expect("the clipboard row"); + assert!((clipboard.origin - corner).x < gpui::px(40.0), "at the click"); + assert!(on(&mut shown, &deck, Pick::Layout(0)) && on(&mut shown, &deck, Pick::Comment)); + // Typing in the title: list rows. + let title = within(&mut shown, &deck, "text", 2); + click(&mut shown, title, MouseButton::Left); + right_click(&mut shown, &deck, title, Kind::Text); + assert!(on(&mut shown, &deck, Pick::Bullets) && on(&mut shown, &deck, Pick::Numbering)); + let rectangle = ("draw.shape", &["rectangle", "true"][..]); + act(&mut cx, &deck, rectangle, |o| o.selection.as_ref().is_some_and(|s| s.kind == "shape")); + let shape = within(&mut shown, &deck, "shape", 2); + right_click(&mut shown, &deck, shape, Kind::Object); + assert!(on(&mut shown, &deck, Pick::Back) && on(&mut shown, &deck, Pick::Forward)); + assert!(!on(&mut shown, &deck, Pick::Group), "one shape does not group"); + choose(&mut shown, &deck, Pick::Back); + settle(&cx, |app| objects(app, &deck).is_some_and(|o| o.objects.first().is_some_and(|first| first.kind == "shape"))); + let path = dir.path().join("red.png"); + image::RgbaImage::from_pixel(300, 200, image::Rgba([255, 0, 0, 255])).save(&path).expect("the png writes"); + let image = ("image.insert", &[ely_lok::file_url(&path)][..]); + let image: (&str, Vec<&str>) = (image.0, image.1.iter().map(String::as_str).collect()); + act(&mut cx, &deck, (image.0, &image.1), |o| o.selection.as_ref().is_some_and(|s| s.kind == "image")); + let picture = within(&mut shown, &deck, "image", 2); + right_click(&mut shown, &deck, picture, Kind::Picture); + assert!(on(&mut shown, &deck, Pick::Replace) && on(&mut shown, &deck, Pick::Crop)); + act(&mut cx, &deck, ("table.insert", &["2", "2"]), |o| table_height(o).is_some()); + // A quarter in: the middle is where the borders cross. + let cell = within(&mut shown, &deck, "table", 4); + right_click(&mut shown, &deck, cell, Kind::Table); + assert!(on(&mut shown, &deck, Pick::RowAbove), "inserting needs no cell"); + // Deleting rows needs the cursor in a cell. + click(&mut shown, cell, MouseButton::Left); + right_click(&mut shown, &deck, cell, Kind::Table); + assert!(on(&mut shown, &deck, Pick::DeleteRows) && on(&mut shown, &deck, Pick::Merge)); + let before = cx.update(|app| objects(app, &deck).and_then(|o| table_height(&o))).expect("a table"); + choose(&mut shown, &deck, Pick::RowBelow); + settle(&cx, |app| objects(app, &deck).and_then(|o| table_height(&o)).is_some_and(|h| h > before)); + // A right click that ends an edit gets no menu. + click(&mut shown, corner, MouseButton::Left); + right_click(&mut shown, &deck, corner, Kind::Slide); + choose(&mut shown, &deck, Pick::Layout(0)); + let layout = |app: &gpui::App| deck.read(app).active_tab().and_then(|tab| tab.extra.design.as_ref().map(|d| d.layout)); + settle(&cx, |app| layout(app) == Some(20)); +} diff --git a/apps/ely-table/src/menu.rs b/apps/ely-table/src/menu.rs index ce4cf4a..3a8a89e 100644 --- a/apps/ely-table/src/menu.rs +++ b/apps/ely-table/src/menu.rs @@ -211,9 +211,9 @@ impl Book { }; let kind = match offered.target { Target::Cell => Kind::Cell, - Target::Object => Kind::Object, + Target::Object | Target::Picture => Kind::Object, Target::Text => Kind::Text, - Target::Table | Target::Frame => { + Target::Table | Target::Frame | Target::Slide => { let error = format!("a {:?} menu in a spreadsheet", offered.target); return self.toast(Book::text(cx, "office.error.engine", &[("error", &error)]), Severity::Danger, cx); } diff --git a/crates/ely-lok/src/menu.rs b/crates/ely-lok/src/menu.rs index 995dbe1..99c4ccd 100644 --- a/crates/ely-lok/src/menu.rs +++ b/crates/ely-lok/src/menu.rs @@ -18,6 +18,10 @@ pub enum Target { Cell, /// A drawing object outside Writer. Object, + /// A picture outside Writer. + Picture, + /// An Impress slide, nothing selected. + Slide, } /// A misspelled word's menu: suggestions as the kernel's rules. @@ -92,8 +96,12 @@ pub fn read(json: &str) -> io::Result { Target::Frame } else if enabled.contains_key(".uno:InsertCell") { Target::Cell + } else if enabled.contains_key(".uno:ChangePicture") { + Target::Picture } else if enabled.contains_key(".uno:ArrangeMenu") { Target::Object + } else if enabled.contains_key(".uno:SlideLayoutMenu") { + Target::Slide } else { Target::Text }; diff --git a/crates/ely-lok/src/menu/tests.rs b/crates/ely-lok/src/menu/tests.rs index cf41cf3..c4b609a 100644 --- a/crates/ely-lok/src/menu/tests.rs +++ b/crates/ely-lok/src/menu/tests.rs @@ -38,6 +38,11 @@ fn the_menu_names_its_target_and_flags() { 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); + // Impress: a picture also arranges; a bare slide has layouts. + let picture = format!(r#"{{"menu":[{},{}]}}"#, item(".uno:ChangePicture", true), item(".uno:ArrangeMenu", true)); + assert_eq!(read(&picture).expect("parses").target, Target::Picture); + let slide = format!(r#"{{"menu":[{},{}]}}"#, item(".uno:Paste", true), item(".uno:SlideLayoutMenu", true)); + assert_eq!(read(&slide).expect("parses").target, Target::Slide); } #[test] diff --git a/crates/ely-lok/src/table.rs b/crates/ely-lok/src/table.rs index 17034cf..2d3dfac 100644 --- a/crates/ely-lok/src/table.rs +++ b/crates/ely-lok/src/table.rs @@ -1,4 +1,4 @@ -//! The cursor's table borders, as Writer reports them (DOC-07). +//! A table's borders, as Writer and Impress report them (DOC-07). use std::io; @@ -15,6 +15,8 @@ pub struct Lines { pub right: i32, pub offset: i32, pub inner: Vec, + /// Bounds of the first and last lines, when sent. + pub ends: Option<[(i32, i32); 2]>, } /// An inner line and the kernel's bounds for it. @@ -41,22 +43,47 @@ pub fn read(text: &str) -> Result, String> { if object.is_empty() { return Ok(None); } - let int = |value: Option<&Value>| i32::try_from(value?.as_i64()?).ok(); + // Impress writes every value as text (boost's property tree). + let int = |value: Option<&Value>| match value? { + Value::Number(number) => i32::try_from(number.as_i64()?).ok(), + Value::String(text) => text.parse().ok(), + _ => None, + }; + let flag = |value: Option<&Value>| match value? { + Value::Bool(on) => Some(*on), + Value::String(text) => text.parse().ok(), + _ => None, + }; let lines = |key: &str| -> Option { let axis = object.get(key)?; - let inner = axis.get("entries")?.as_array()?.iter().map(|entry| { + // Boost writes an empty list as an empty string. + let entries = match axis.get("entries")? { + Value::Array(entries) => entries.as_slice(), + Value::String(text) if text.is_empty() => &[], + _ => return None, + }; + let inner = entries.iter().map(|entry| { Some(Line { position: int(entry.get("position"))?, min: int(entry.get("min"))?, max: int(entry.get("max"))?, - hidden: entry.get("hidden")?.as_bool()?, + hidden: flag(entry.get("hidden"))?, }) }); + let bound = |key: &str| int(axis.get(key)); + let ends = match ["leftMin", "leftMax", "rightMin", "rightMax"].map(|key| axis.get(key).is_some()) { + [false, false, false, false] => None, + [true, true, true, true] => { + Some([(bound("leftMin")?, bound("leftMax")?), (bound("rightMin")?, bound("rightMax")?)]) + } + _ => return None, + }; Some(Lines { left: int(axis.get("left"))?, right: int(axis.get("right"))?, offset: int(axis.get("tableOffset"))?, inner: inner.collect::>()?, + ends, }) }; let columns = lines("columns").ok_or_else(fault)?; @@ -75,6 +102,10 @@ impl Table { .i64(axis.right.into()) .i64(axis.offset.into()) .u64(axis.inner.len() as u64); + let pack = match axis.ends { + None => pack.u64(0), + Some(ends) => ends.iter().fold(pack.u64(1), |pack, &(low, high)| pack.i64(low.into()).i64(high.into())), + }; axis.inner.iter().fold(pack, |pack, line| { pack.i64(line.position.into()) .i64(line.min.into()) @@ -91,14 +122,22 @@ impl Table { let mut lines = || -> io::Result { let mut int = || i32::try_from(unpack.i64()?).map_err(io::Error::other); let (left, right, offset) = (int()?, int()?, int()?); - let inner = (0..unpack.u64()?) + let count = unpack.u64()?; + let ends = match unpack.u64()? { + 0 => None, + _ => { + let mut int = || i32::try_from(unpack.i64()?).map_err(io::Error::other); + Some([(int()?, int()?), (int()?, int()?)]) + } + }; + let inner = (0..count) .map(|_| { let mut int = || i32::try_from(unpack.i64()?).map_err(io::Error::other); let (position, min, max) = (int()?, int()?, int()?); Ok(Line { position, min, max, hidden: unpack.u64()? != 0 }) }) .collect::>()?; - Ok(Lines { left, right, offset, inner }) + Ok(Lines { left, right, offset, inner, ends }) }; let columns = lines()?; let rows = lines()?; @@ -112,14 +151,41 @@ mod tests { const SEEN: &str = r#"{ "columns": { "left": 0, "right": 9638, "tableOffset": 1135, "entries": [ { "position": 4819, "min": 300, "max": 9338, "hidden": false } ] }, "rows": { "left": 0, "right": 552, "tableOffset": 1418, "entries": [ { "position": 276, "min": 100, "max": 552, "hidden": false } ] } }"#; + /// As Impress sent it for a new 2x2 table. + const IMPRESS: &str = r#"{ "columns": { "tableOffset": "3929", "left": "0", "right": "7993", "entries": [ { "position": "3996", "min": "340", "max": "7653", "hidden": "false" } ] }, "rows": { "tableOffset": "4340", "left": "0", "right": "1091", "entries": [ { "position": "545", "min": "340", "max": "152183880", "hidden": "false" } ] }, "rectangle": { "x": "3940", "y": "4351", "width": "7993", "height": "1091" } }"#; + + #[test] + fn impress_writes_numbers_as_text() { + let table = read(IMPRESS).expect("readable").expect("a table"); + assert_eq!(table.columns.offset, 3929); + assert_eq!(table.columns.inner, [Line { position: 3996, min: 340, max: 7653, hidden: false }]); + assert_eq!(table.rows.right, 1091); + assert!(read(&IMPRESS.replace("\"false\"", "\"no\"")).is_err(), "a flag is true or false"); + assert_eq!(table.rows.ends, None, "an unpatched kernel sends no ends"); + } + + /// One row, as Impress sends it with patch 0047. + const ONE_ROW: &str = r#"{ "columns": { "tableOffset": "3929", "left": "0", "right": "7993", "leftMin": "-152183880", "leftMax": "7823", "rightMin": "170", "rightMax": "152183880", "entries": "" }, "rows": { "tableOffset": "4340", "left": "0", "right": "545", "leftMin": "-152183880", "leftMax": "28", "rightMin": "517", "rightMax": "152183880", "entries": "" } }"#; + + #[test] + fn one_line_has_no_inner_lines_and_its_ends_cross_the_wire() { + let table = read(ONE_ROW).expect("readable").expect("a table"); + assert!(table.columns.inner.is_empty() && table.rows.inner.is_empty()); + assert_eq!(table.rows.ends, Some([(-152_183_880, 28), (517, 152_183_880)])); + assert!(read(&ONE_ROW.replace("\"entries\": \"\"", "\"entries\": \"x\"")).is_err(), "text is no list"); + assert!(read(&ONE_ROW.replace("\"leftMin\": \"-152183880\", ", "")).is_err(), "ends come in fours"); + let bytes = Table::pack(Some(&table), Pack::new()).bytes(); + assert_eq!(Table::unpack(&mut Unpack::new(&bytes)).expect("unpacks"), Some(table)); + } + #[test] fn a_table_reads_and_crosses_the_wire() { assert_eq!(read("{ }"), Ok(None), "no table"); let table = read(SEEN).expect("readable").expect("a table"); assert_eq!(table.columns.offset, 1135); assert_eq!(table.rows.inner[0].position, 276); - let text = SEEN.replace("\"left\": 0,", "\"left\": \"0\","); - assert!(read(&text).is_err(), "a number as text is a fault"); + let text = SEEN.replace("\"left\": 0,", "\"left\": \"zero\","); + assert!(read(&text).is_err(), "a word is no number"); assert!(read("[]").is_err()); let bytes = Table::pack(Some(&table), Pack::new()).bytes(); let back = Table::unpack(&mut Unpack::new(&bytes)).expect("unpacks"); diff --git a/crates/ely-lok/src/view/borders.rs b/crates/ely-lok/src/view/borders.rs index 787651a..cdffa79 100644 --- a/crates/ely-lok/src/view/borders.rs +++ b/crates/ely-lok/src/view/borders.rs @@ -99,15 +99,23 @@ fn inner(lines: &Lines) -> impl Iterator + '_ { fn clamp(table: &Table, side: Side, offset: i32) -> i32 { let axis = if side.upright() { &table.columns } else { &table.rows }; let (first, last) = (axis.inner.first(), axis.inner.last()); - let (at, low, high) = match side { - Side::ColumnLeft => (axis.left, i32::MIN, first.map_or(axis.right, |line| line.position) - GAP), - Side::ColumnRight | Side::RowBottom => (axis.right, last.map_or(axis.left, |line| line.position) + GAP, i32::MAX), - Side::Column(index) | Side::Row(index) => { + // Impress sends its outer bounds; Writer leaves a gap. + let (at, low, high) = match (side, axis.ends) { + (Side::ColumnLeft, Some([(low, high), _])) => (axis.left, low, high), + (Side::ColumnRight | Side::RowBottom, Some([_, (low, high)])) => (axis.right, low, high), + (Side::ColumnLeft, None) => (axis.left, i32::MIN, first.map_or(axis.right, |line| line.position) - GAP), + (Side::ColumnRight | Side::RowBottom, None) => { + (axis.right, last.map_or(axis.left, |line| line.position) + GAP, i32::MAX) + } + (Side::Column(index) | Side::Row(index), ends) => { let line = axis.inner[usize::from(index)]; - (line.position, line.min, (line.max - GAP).max(line.min)) + let high = if ends.is_some() { line.max } else { (line.max - GAP).max(line.min) }; + (line.position, line.min, high) } }; - (at.saturating_add(offset)).clamp(low, high) - at + let moved = (at.saturating_add(offset)).clamp(low, high) - at; + // A line past its bound never moves against the drag. + if moved.signum() == offset.signum() { moved } else { 0 } } impl LokView { @@ -229,8 +237,8 @@ mod tests { fn table() -> Table { let line = |position, min, max| Line { position, min, max, hidden: false }; Table { - columns: Lines { left: 0, right: 9000, offset: 1000, inner: vec![line(3000, 300, 6000), line(6000, 3300, 8700)] }, - rows: Lines { left: 0, right: 800, offset: 2000, inner: vec![line(400, 100, 800)] }, + columns: Lines { left: 0, right: 9000, offset: 1000, inner: vec![line(3000, 300, 6000), line(6000, 3300, 8700)], ends: None }, + rows: Lines { left: 0, right: 800, offset: 2000, inner: vec![line(400, 100, 800)], ends: None }, } } @@ -261,4 +269,25 @@ mod tests { assert_eq!(clamp(&table, Side::RowBottom, -800), -360, "a row keeps the gap"); assert_eq!(clamp(&table, Side::Row(0), 500), 360, "max less the gap"); } + + #[test] + fn sent_ends_bound_the_outer_lines() { + let mut table = table(); + table.columns.ends = Some([(-50_000, 2800), (6200, 50_000)]); + table.rows.ends = Some([(-50_000, 380), (717, 50_000)]); + assert_eq!(clamp(&table, Side::ColumnLeft, 5000), 2800, "to the first column's minimum"); + assert_eq!(clamp(&table, Side::ColumnLeft, -900), -900, "outward too"); + assert_eq!(clamp(&table, Side::ColumnRight, -8000), -2800, "to the last column's minimum"); + assert_eq!(clamp(&table, Side::RowBottom, -800), -83, "to the last row's minimum"); + assert_eq!(clamp(&table, Side::Column(0), 9000), 3000, "Impress's max needs no gap"); + } + + #[test] + fn a_line_at_or_past_its_bound_stays_put() { + let mut table = table(); + table.columns.inner[0].max = 3000; + assert_eq!(clamp(&table, Side::Column(0), 0), 0, "a still press sends nothing"); + assert_eq!(clamp(&table, Side::Column(0), 200), 0, "no room toward the bound"); + assert_eq!(clamp(&table, Side::Column(0), -200), -200, "away from it is free"); + } } diff --git a/crates/ely-lok/src/view/tests.rs b/crates/ely-lok/src/view/tests.rs index b9dccb3..9b22d82 100644 --- a/crates/ely-lok/src/view/tests.rs +++ b/crates/ely-lok/src/view/tests.rs @@ -6,6 +6,7 @@ mod commands; mod fit; mod input; mod large; +mod show_borders; mod theme; use gpui::{App, Bounds, Entity, point, size}; diff --git a/crates/ely-lok/src/view/tests/show_borders.rs b/crates/ely-lok/src/view/tests/show_borders.rs new file mode 100644 index 0000000..f42f14b --- /dev/null +++ b/crates/ely-lok/src/view/tests/show_borders.rs @@ -0,0 +1,172 @@ +use super::*; +use crate::client::tests::{poll_until, show::DECK}; +use crate::table::Table; +use gpui::{Pixels, Point, TestAppContext}; + +const WAIT: std::time::Duration = std::time::Duration::from_secs(5); + +/// A line's place along its axis, in twips. +type Place = fn(&Table) -> i32; + +/// A new table on the slide, read into the view. +fn table_in(view: &mut LokView, (columns, rows): (&str, &str)) -> Table { + let doc = view.doc.clone(); + let mut worker = view.worker.lock().expect("the worker lock"); + worker.run(&doc, "table.insert", &[columns, rows]).expect("a table"); + let seen = poll_until(&mut worker, &doc, &Events::default(), WAIT, |seen| seen.table.is_some()); + assert!(seen.faults.is_empty(), "{:?}", seen.faults); + let table = seen.table.expect("the selected table"); + view.doc = worker.now(&doc).expect("the revision"); + drop(worker); + view.read_borders(Some(table.clone()), view.doc.rev); + table +} + +/// The view's table once `place` moves off `from`. +fn moved(view: &mut LokView, place: Place, from: i32) -> Table { + let mut worker = view.worker.lock().expect("the worker lock"); + let seen = poll_until(&mut worker, &view.doc, &Events::default(), WAIT, |seen| { + seen.table.as_ref().is_some_and(|table| place(table) != from) + }); + let table = seen.table.expect("still the table"); + view.doc = worker.now(&view.doc).expect("the revision"); + drop(worker); + view.read_borders(Some(table.clone()), view.doc.rev); + table +} + +/// The last drag's place: before, guide, landed, undone. +fn drag(size: (&str, &str), place: Place, upright: bool, bys: &[Point]) -> Option<[i32; 4]> { + let install = crate::client::tests::install()?; + let cx = TestAppContext::single(); + let (view, _) = cx.update(|app| live_view_of(app, &install, "deck.fodp", DECK)); + let mut table = cx.update(|app| view.update(app, |view, _| table_in(view, size))); + let mut last = [0; 3]; + for &by in bys { + let (before, guide) = cx.update(|app| { + view.update(app, |view, cx| { + let at = place(&table); + let across = if upright { &table.rows } else { &table.columns }; + // A quarter along: halfway may cross another line. + let along = across.offset + across.left + (across.right - across.left) / 4; + let on = view.window_point(if upright { (at, along) } else { (along, at) }); + assert!(view.hold_border(on), "the line is held"); + assert!(view.move_border(on + by)); + let guide = view.doc_point(view.guide().expect("a guide").origin); + assert!(view.drop_border(on + by, cx), "the drop runs"); + (at, if upright { guide.0 } else { guide.1 }) + }) + }); + for _ in 0..80 { + cx.executor().tick(); + } + table = cx.update(|app| view.update(app, |view, _| moved(view, place, before))); + last = [before, guide, place(&table)]; + } + let undone = cx.update(|app| { + view.update(app, |view, _| { + view.worker.lock().expect("the worker lock").run(&view.doc, "edit.undo", &[]).expect("undoes"); + place(&moved(view, place, last[2])) + }) + }); + Some([last[0], last[1], last[2], undone]) +} + +/// Lands at its guide; one undo puts it back. +#[track_caller] +fn lands([before, guide, landed, undone]: [i32; 4]) { + assert!((landed - guide).abs() <= 2, "{before} to the guide {guide}, landed {landed}"); + assert!((undone - before).abs() <= 1, "one undo: {undone} vs {before}"); +} + +#[test] +fn an_inner_line_lands_at_its_guide() { + let place: Place = |t| t.columns.offset + t.columns.inner[0].position; + let Some(moved) = drag(("2", "2"), place, true, &[gpui::point(px(40.0), px(0.0))]) else { + return eprintln!("SKIP: no pinned LOK build"); + }; + assert!(moved[1] > moved[0], "the guide went right"); + lands(moved); +} + +#[test] +fn the_left_edge_narrows_the_first_column_and_moves_the_table() { + let place: Place = |t| t.columns.offset + t.columns.left; + let Some(moved) = drag(("2", "2"), place, true, &[gpui::point(px(40.0), px(0.0))]) else { + return eprintln!("SKIP: no pinned LOK build"); + }; + assert!(moved[1] > moved[0], "the guide went right"); + lands(moved); +} + +#[test] +fn a_one_row_bottom_grows() { + let place: Place = |t| t.rows.offset + t.rows.right; + let Some(moved) = drag(("2", "1"), place, false, &[gpui::point(px(0.0), px(40.0))]) else { + return eprintln!("SKIP: no pinned LOK build"); + }; + assert!(moved[1] > moved[0], "the guide went down"); + lands(moved); +} + +#[test] +fn a_grown_bottom_stops_at_the_last_rows_minimum() { + let place: Place = |t| t.rows.offset + t.rows.right; + let Some(moved) = drag(("2", "2"), place, false, &[gpui::point(px(0.0), px(60.0)), gpui::point(px(0.0), px(-200.0))]) else { + return eprintln!("SKIP: no pinned LOK build"); + }; + assert!(moved[1] < moved[0] && moved[1] > moved[0] - 3000, "the guide stops at the minimum: {moved:?}"); + lands(moved); +} + +#[test] +fn a_one_column_right_edge_stops_at_the_columns_minimum() { + let place: Place = |t| t.columns.offset + t.columns.right; + let Some(moved) = drag(("1", "2"), place, true, &[gpui::point(px(-600.0), px(0.0))]) else { + return eprintln!("SKIP: no pinned LOK build"); + }; + assert!(moved[1] > moved[0] - 9000, "the guide stops at the minimum: {moved:?}"); + lands(moved); +} + +#[test] +fn a_still_press_beside_a_minimum_column_sends_nothing() { + let Some(install) = crate::client::tests::install() else { + return eprintln!("SKIP: no pinned LOK build"); + }; + let cx = TestAppContext::single(); + let (view, _) = cx.update(|app| live_view_of(app, &install, "deck.fodp", DECK)); + let right: Place = |t| t.columns.offset + t.columns.right; + let quarter = |t: &Table| t.rows.offset + t.rows.left + (t.rows.right - t.rows.left) / 4; + let mut table = cx.update(|app| view.update(app, |view, _| table_in(view, ("2", "2")))); + // Its minimum width drops once it narrows; shrink twice. + for _ in 0..2 { + let before = cx.update(|app| { + view.update(app, |view, cx| { + let on = view.window_point((right(&table), quarter(&table))); + let to = on + gpui::point(px(-600.0), px(0.0)); + assert!(view.hold_border(on) && view.move_border(to) && view.drop_border(to, cx)); + right(&table) + }) + }); + for _ in 0..80 { + cx.executor().tick(); + } + table = cx.update(|app| view.update(app, |view, _| moved(view, right, before))); + } + let sent = cx.update(|app| { + view.update(app, |view, cx| { + let line = table.columns.inner[0]; + assert_eq!(line.position, line.max, "the right column is at its minimum"); + let on = view.window_point((table.columns.offset + line.position, quarter(&table))); + let sent = view.sent(); + assert!(view.hold_border(on)); + assert!(view.drop_border(on, cx)); + sent + }) + }); + for _ in 0..80 { + cx.executor().tick(); + } + assert_eq!(cx.update(|app| view.read(app).sent()), sent, "a still press sends no command"); +} diff --git a/docs/compatibility.md b/docs/compatibility.md index 4f89000..ade0e8c 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -3,7 +3,7 @@ 随发行说明发布,每个版本一份。支持某格式不等于支持它的全部功能;下表只写已验证的内容、已知限制与保存行为。 版本:Ely Office 0.1.3(首版六个应用:ElyDoc、ElyTable、ElyShow、ElyPDF、ElyTree、ElyMark)。日期 2026-10-11。 -内核:LibreOffice 26.2.6.3(8221e31,加 `patches/libreoffice` 0001–0046);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–0047);MuPDF 1.28.5(8ad45e9,加 `patches/mupdf` 0001–0005);Git 2.56.0(a018953)与 git-lfs 3.8.0。 验证机:macOS 27.0 arm64。Windows 与 Linux 未验证。本机没有 Microsoft Office:与 Office 的交叉检查未做(待做)。 ## 往返验证 diff --git a/patches/libreoffice/0047-lok-impress-table-edges.patch b/patches/libreoffice/0047-lok-impress-table-edges.patch new file mode 100644 index 0000000..67349d1 --- /dev/null +++ b/patches/libreoffice/0047-lok-impress-table-edges.patch @@ -0,0 +1,149 @@ +LOK: Impress tables report every edge's bounds; the left edge drags + +A LOK client draws its own table borders from +LOK_CALLBACK_TABLE_SELECTED and clamps a drag to the bounds it reads. +Impress sent bounds only for inner edges, and its row minimum was the +layouter's fixed 600 (a todo), not the row's own minimum height; a +client's guide and the kernel's result then disagreed. The JSON now +carries leftMin/leftMax and rightMin/rightMax for both axes (columns +only left to right) and inner rows use the row's minimum height. +The leftmost column edge did nothing (DragEdge's own todo) while +adding an undo step. Over LOK's TableChangeCurrentBorderPosition it now +narrows or widens the first column and moves the table with its edge, +as the top edge also moves it, in the same undo step. Desktop drag +handles are unchanged. + +diff --git a/svx/source/table/svdotable.cxx b/svx/source/table/svdotable.cxx +--- a/svx/source/table/svdotable.cxx ++++ b/svx/source/table/svdotable.cxx +@@ -1579,8 +1579,30 @@ + + void SdrTableObj::changeEdge(bool bHorizontal, int nEdge, sal_Int32 nOffset) + { +- if (mpImpl.is()) ++ if (!mpImpl.is() || !mpImpl->mxTable.is()) ++ return; ++ const bool bLeft = !bHorizontal && nEdge == 0 && GetWritingMode() != WritingMode_RL_TB; ++ if (!bLeft) + mpImpl->DragEdge(bHorizontal, nEdge, nOffset); ++ else ++ { ++ // LOK: the left edge narrows the first column. ++ try ++ { ++ const sal_Int32 nWidth = mpImpl->mpLayouter->getColumnWidth(0) - nOffset; ++ Reference xCols(mpImpl->mxTable->getColumns(), UNO_QUERY_THROW); ++ Reference xCol(xCols->getByIndex(0), UNO_QUERY_THROW); ++ xCol->setPropertyValue(u"Size"_ustr, Any(nWidth)); ++ } ++ catch (const Exception&) ++ { ++ TOOLS_WARN_EXCEPTION("svx.table", ""); ++ return; ++ } ++ } ++ // A first edge moves the table with it. ++ if (nEdge == 0 && (bHorizontal || bLeft)) ++ NbcMove(bHorizontal ? Size(0, nOffset) : Size(nOffset, 0)); + } + + void SdrTableObj::setActiveCell( const CellPos& rPos ) +@@ -2450,11 +2472,24 @@ + (void)xmlTextWriterEndElement(pWriter); + } + ++namespace ++{ ++/// An outer edge's bounds, as LOK clients clamp a drag. ++void putEnd(boost::property_tree::ptree& rAxis, const std::string& rSide, sal_Int32 nMin, sal_Int32 nMax) ++{ ++ rAxis.put(rSide + "Min", o3tl::toTwips(nMin, o3tl::Length::mm100)); ++ rAxis.put(rSide + "Max", o3tl::toTwips(nMax, o3tl::Length::mm100)); ++} ++} ++ + bool SdrTableObj::createTableEdgesJson(boost::property_tree::ptree & rJsonRoot) + { + if (!mpImpl.is() || !mpImpl->mxTable.is()) + return false; + ++ TableLayouter& rLayouter = *mpImpl->mpLayouter; ++ const bool bRTL = GetWritingMode() == WritingMode_RL_TB; ++ + tools::Rectangle aRect = GetCurrentBoundRect(); + boost::property_tree::ptree aTableColumns; + { +@@ -2467,10 +2502,17 @@ + if (rEdge.nIndex == 0) + { + aTableColumns.put("left", o3tl::toTwips(rEdge.nPosition, o3tl::Length::mm100)); ++ if (!bRTL) ++ putEnd(aTableColumns, "left", -0x0fffffff, ++ rEdge.nPosition + rLayouter.getColumnWidth(0) - rLayouter.getMinimumColumnWidth(0)); + } + else if (rEdge.nIndex == sal_Int32(aEdges.size() - 1)) + { + aTableColumns.put("right", o3tl::toTwips(rEdge.nPosition, o3tl::Length::mm100)); ++ const sal_Int32 nLast = rEdge.nIndex - 1; ++ if (!bRTL) ++ putEnd(aTableColumns, "right", ++ rEdge.nPosition - rLayouter.getColumnWidth(nLast) + rLayouter.getMinimumColumnWidth(nLast), 0x0fffffff); + } + else + { +@@ -2492,21 +2534,28 @@ + + boost::property_tree::ptree aEntries; + auto const aEdges = mpImpl->mpLayouter->getHorizontalEdges(); ++ // A row's own minimum, not the layouter's fixed gap. ++ auto rowMin = [&rLayouter](const EdgeInfo& rEdge, sal_Int32 nRow) { ++ return rEdge.nPosition - rLayouter.getRowHeight(nRow) + rLayouter.getMinimumRowHeight(nRow); ++ }; + for (auto & rEdge : aEdges) + { + if (rEdge.nIndex == 0) + { + aTableRows.put("left", o3tl::toTwips(rEdge.nPosition, o3tl::Length::mm100)); ++ putEnd(aTableRows, "left", -0x0fffffff, ++ rEdge.nPosition + rLayouter.getRowHeight(0) - rLayouter.getMinimumRowHeight(0)); + } + else if (rEdge.nIndex == sal_Int32(aEdges.size() - 1)) + { + aTableRows.put("right", o3tl::toTwips(rEdge.nPosition, o3tl::Length::mm100)); ++ putEnd(aTableRows, "right", rowMin(rEdge, rEdge.nIndex - 1), 0x0fffffff); + } + else + { + boost::property_tree::ptree aEntry; + aEntry.put("position", o3tl::toTwips(rEdge.nPosition, o3tl::Length::mm100)); +- aEntry.put("min", o3tl::toTwips(rEdge.nPosition + rEdge.nMin, o3tl::Length::mm100)); ++ aEntry.put("min", o3tl::toTwips(rowMin(rEdge, rEdge.nIndex - 1), o3tl::Length::mm100)); + aEntry.put("max", o3tl::toTwips(rEdge.nPosition + rEdge.nMax, o3tl::Length::mm100)); + aEntry.put("hidden", false); + aEntries.push_back(std::make_pair("", aEntry)); +diff --git a/svx/source/table/tablelayouter.cxx b/svx/source/table/tablelayouter.cxx +--- a/svx/source/table/tablelayouter.cxx ++++ b/svx/source/table/tablelayouter.cxx +@@ -158,6 +158,11 @@ + return 0; + } + ++sal_Int32 TableLayouter::getMinimumRowHeight( sal_Int32 nRow ) const ++{ ++ return isValidRow(nRow) ? maRows[nRow].mnMinSize : 0; ++} ++ + + sal_Int32 TableLayouter::getColumnWidth( sal_Int32 nColumn ) const + { +diff --git a/svx/source/table/tablelayouter.hxx b/svx/source/table/tablelayouter.hxx +--- a/svx/source/table/tablelayouter.hxx ++++ b/svx/source/table/tablelayouter.hxx +@@ -93,6 +93,7 @@ + sal_Int32 calcPreferredColumnWidth( sal_Int32 nColumn, Size aSize ) const; + + sal_Int32 getMinimumColumnWidth( sal_Int32 nColumn ); ++ sal_Int32 getMinimumRowHeight( sal_Int32 nRow ) const; + + /** checks if the given edge is visible. + Edges between merged cells are not visible. diff --git a/tasks/BUGS.md b/tasks/BUGS.md index a89e462..f569809 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -151,4 +151,6 @@ P0 次序:W-01、K-01、L-01、O-01、W-02、L-02、W-03、L-03、W-04、L-04 - [ ] 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 审查中出现,已手动删去) +- [ ] P3-11 ElyShow:正编辑表格单元格时在幻灯片空白处右键,内核(Impress)只结束编辑、不发 `LOK_CALLBACK_CONTEXT_MENU`,再右键一次才有幻灯片菜单;Windows 实测,他端未核;修需内核补丁 +- [ ] P3-12 Windows:`output::tests::targets_skip_hidden_slides_and_pad_numbers` 以 `/` 拼的路径比对,Windows 的 `join` 用 `\`,单测恒失败(代码无误) diff --git a/tasks/TASKS.md b/tasks/TASKS.md index 6e6a7a4..61fb3df 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -251,6 +251,6 @@ - [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) - [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` 须收下) +- [x] R5.6 ElyShow 右键与表格:画布右键原无菜单;照 PowerPoint 按目标出菜单(幻灯片、文本框、图形、表格、图片);表格菜单在光标进单元格后才有可否(内核对整表选中全报不可用);拖动表格边线照 R5.3(Impress 的 `LOK_CALLBACK_TABLE_SELECTED` 以字符串写数值,`table::read` 须收下) — R3 PASS(补丁 0047:Impress 两轴首尾边线带上下限,内侧行线取行自身最小高度,最左列边线可拖并随之移动表格;正编辑表格单元格时在空白处右键无菜单为内核所限,BUGS P3-11) - [ ] R5.7 Gitea #1:ElyShow 左侧缩略图条滚轮不滚动(同窗口右栏正常,Linux Wayland 报);查明根因再修,加滚轮回归测试 - [ ] R5.8 0.1.5 发布(用户 2026-10-14 定):等 ElyMark 会话的 R4 做完;版本 0.1.5,Windows 与 WSL2 的 Linux 在本机建,macOS 在 Mac mini 上拉取后本机建;全部产物上传到 Gitea 的 v0.1.5 发布 diff --git a/tasks/reviews/R5.6-r1.md b/tasks/reviews/R5.6-r1.md new file mode 100644 index 0000000..9de9fff --- /dev/null +++ b/tasks/reviews/R5.6-r1.md @@ -0,0 +1,7 @@ +FAIL + +- [P2] `crates/ely-lok/src/table.rs:57` — Impress serializes an empty border list as `"entries": ""`, but this parser still requires an array. Inserting a 1×2, 2×1, or 1×1 table produces `an unreadable table` and fails to update its border state; the 2×2 control succeeds. The same problem occurs when deleting down to one row or column. Accept Boost's empty-string representation as an empty list while rejecting malformed values, and add single-row/single-column regressions. Reproduced against the rebuilt worker with `SAL_DISABLE_OPENCL=1`; payloads are in `.work/R5.6-r1-table-edge-probe.log`. + +- [P2] `crates/ely-lok/src/view/borders.rs:73` — R5.6 now feeds Impress tables into this path, which unconditionally exposes the left edge as draggable. Impress's `column-left` operation does not resize an ordinary left-to-right table, although it adds an undo entry and passes the worker's success check. On a fresh 2×2 table, dragging that edge 480 twips right moves the guide but leaves the edge at 3929 twips; the interior and right-edge controls move the requested 480 twips. Implement a working Impress left-edge resize through the kernel bridge, preserving one-step undo, and test its resulting geometry. The new Impress regression covers only an interior column edge. Evidence: `.work/R5.6-r1-border-edge-probe.log`. + +- [P2] `crates/ely-lok/src/view/borders.rs:104` — The reused Writer clamp permits an outer row or column to shrink to a fixed 40-twip gap, which does not match Impress's minimum sizes. For a fresh 2×2 table, the last inner row is at 545 and the bottom at 1091: dragging upward to the permitted minimum previews a bottom of 585 and sends an offset of -506, but the kernel lands at 1062. The released border therefore differs from the guide by 477 twips (about 32 pixels at 100% zoom); shrinking the right edge also lands short of its guide. Obtain and apply Impress's actual minimum sizes consistently to preview and commit, and add regressions at the shrink limits. Evidence: `.work/R5.6-r1-border-edge-probe.log`. diff --git a/tasks/reviews/R5.6-r2.md b/tasks/reviews/R5.6-r2.md new file mode 100644 index 0000000..290a541 --- /dev/null +++ b/tasks/reviews/R5.6-r2.md @@ -0,0 +1,3 @@ +FAIL + +- [P2] `crates/ely-lok/src/view/borders.rs:112` — Inner Impress borders still subtract Writer's 40-twip gap from the kernel's maximum, which already accounts for the neighbouring column's minimum width. In a 2×2 table, shrink the rightmost column to its minimum, then press and release the inner divider without moving it. The kernel reports `position = max = 3996` and the right edge at `4166`; `clamp(..., 0)` returns `-40`, so `drop_border` sends a resize instead of treating the click as a no-op. The kernel moves the divider to `3956` and adds an undo step. Apply Impress's reported inner bounds directly, retain the Writer gap only where required, and add a regression for a stationary divider beside a minimum-width column. Reproduced with the rebuilt worker and `SAL_DISABLE_OPENCL=1`; the failing assertion and resulting geometry are in `.work/R5.6-r2-stationary-probe.log` (probe source retained in `.work/R5.6-r2-probe-source.log`). diff --git a/tasks/reviews/R5.6-r3.md b/tasks/reviews/R5.6-r3.md new file mode 100644 index 0000000..7ef22e9 --- /dev/null +++ b/tasks/reviews/R5.6-r3.md @@ -0,0 +1 @@ +PASS