R5.6: ElyShow right-click menus, PowerPoint-style; Impress table borders drag; R3 PASS

The canvas offers Ely's menu by target: slide (layouts, comment), text
(bullets, numbering, comment), shape or text box, picture (replace,
crop), table (insert, delete, merge), each item gated by the kernel's
state for its own command. Impress's table borders read as text and
drag through R5.3's path; patch 0047 reports every edge's bounds and
makes the left edge drag. A line past its bound never moves against
the drag, so a still press sends nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-14 14:26:27 -04:00
1 parent 411d32b97e
commit 35a7bbb904
21 files changed
+920 -24

No files matched your search

+1
View File
@@ -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:` 为蟋蟀),转义与字符引用之后照样是表情,自动链接与 `<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 无批注界面,不列批注。
- 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)。
+2 -2
View File
@@ -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<Docs>, 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)
}
+7 -2
View File
@@ -57,6 +57,8 @@ pub struct Deck {
pub remarks_pane: Entity<crate::remarks::Pane>,
/// What the menus last checked: grid, guides, comments.
pub snap_menus: Option<(bool, bool, bool)>,
/// The canvas's right-click menu.
pub menu: Option<ely_office::menu::Offer<crate::menu::What>>,
/// 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<Self>) -> AnyElement {
pub(crate) fn body(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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)
}
+1
View File
@@ -11,6 +11,7 @@ mod guides;
mod insert;
mod list;
mod media;
mod menu;
mod motion;
mod new;
mod notes;
+307
View File
@@ -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<Pick>] = &[
Entry::Line,
Entry::Sub("show.design.layout", &LAYOUT_PICKS),
Entry::Line,
Entry::Row(Pick::Comment),
];
const TEXT: &[Entry<Pick>] = &[
Entry::Line,
Entry::Row(Pick::Bullets),
Entry::Row(Pick::Numbering),
Entry::Line,
Entry::Row(Pick::Comment),
];
const OBJECT: &[Entry<Pick>] = &[
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<Pick>] = &[
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<Pick>] = &[
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<Kind> {
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<Pick>] {
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<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 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<Self>) -> Option<ContextMenu> {
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<Self>) {
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<Self>) {
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<Deck>, 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)
}
+1
View File
@@ -8,6 +8,7 @@ use gpui::{AppContext as _, Entity, TestAppContext};
use crate::deck::Deck;
mod menu;
mod motion;
mod notes;
mod objects;
+138
View File
@@ -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<Deck>);
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 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<Deck>, (x, y): (i64, i64)) -> Point<Pixels> {
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<Deck>, kind: &str, part: i64) -> Point<Pixels> {
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<Pixels>, 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<Deck>, at: Point<Pixels>, 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<Deck>, 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<Deck>, pick: Pick) {
cx.update(|window, app| deck.update(app, |deck, cx| deck.choose(pick, window, cx)));
}
fn table_height(objects: &Objects) -> Option<i64> {
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));
}
+2 -2
View File
@@ -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);
}
+8
View File
@@ -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<Offered> {
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
};
+5
View File
@@ -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]
+74 -8
View File
@@ -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<Line>,
/// 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<Option<Table>, 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<Lines> {
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::<Option<_>>()?,
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<Lines> {
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::<io::Result<_>>()?;
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");
+37 -8
View File
@@ -99,15 +99,23 @@ fn inner(lines: &Lines) -> impl Iterator<Item = (u16, i32)> + '_ {
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");
}
}
+1
View File
@@ -6,6 +6,7 @@ mod commands;
mod fit;
mod input;
mod large;
mod show_borders;
mod theme;
use gpui::{App, Bounds, Entity, point, size};
@@ -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<Pixels>]) -> 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");
}
+1 -1
View File
@@ -3,7 +3,7 @@
随发行说明发布,每个版本一份。支持某格式不等于支持它的全部功能;下表只写已验证的内容、已知限制与保存行为。
版本:Ely Office 0.1.3(首版六个应用:ElyDoc、ElyTable、ElyShow、ElyPDF、ElyTree、ElyMark)。日期 2026-10-11。
内核:LibreOffice 26.2.6.3(8221e31,加 `patches/libreoffice` 0001–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 的交叉检查未做(待做)。
## 往返验证
@@ -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<XIndexAccess> xCols(mpImpl->mxTable->getColumns(), UNO_QUERY_THROW);
+ Reference<XPropertySet> 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.
+2
View File
@@ -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` 用 `\`,单测恒失败(代码无误)
+1 -1
View File
@@ -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 发布
+7
View File
@@ -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`.
+3
View File
@@ -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`).
+1
View File
@@ -0,0 +1 @@
PASS