From 539206297c681712978212e24f8600894a49cc55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 16:38:28 -0400 Subject: [PATCH] R4.9: ElyMark menus and keys as Typora's, component pin 6b033f3 Paragraph and Format menus with Typora's items, labels and keys; Table is a Paragraph submenu with row and column moves and source tidy. Ctrl+/ toggles source, F8 focus, F9 typewriter, Ctrl+Shift+=/-/0 size, Ctrl+Shift+C copies as Markdown, Ctrl+Shift+V pastes as plain text. Refused commands show why, including refusals read in the background. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 25 +-- Cargo.toml | 2 +- agents.md | 5 +- apps/ely-mark/src/blocks.rs | 234 ++++++++++++++++++++ apps/ely-mark/src/blocks/tests.rs | 309 +++++++++++++++++++++++++++ apps/ely-mark/src/clip.rs | 56 +++++ apps/ely-mark/src/desk.rs | 52 ++--- apps/ely-mark/src/engine.rs | 9 +- apps/ely-mark/src/export/mod.rs | 2 +- apps/ely-mark/src/finding/tests.rs | 5 +- apps/ely-mark/src/keys.rs | 104 ++++++--- apps/ely-mark/src/main.rs | 2 + apps/ely-mark/src/output/html.rs | 2 +- apps/ely-mark/src/tools.rs | 121 ++++++----- apps/ely-mark/src/view.rs | 11 +- crates/ely-ui/src/i18n.rs | 3 + crates/ely-ui/src/i18n/mark.rs | 24 +-- crates/ely-ui/src/i18n/mark_menus.rs | 139 ++++++++++++ docs/help.md | 9 + scripts/fetch-references.sh | 2 +- 20 files changed, 963 insertions(+), 153 deletions(-) create mode 100644 apps/ely-mark/src/blocks.rs create mode 100644 apps/ely-mark/src/blocks/tests.rs create mode 100644 apps/ely-mark/src/clip.rs create mode 100644 crates/ely-ui/src/i18n/mark_menus.rs diff --git a/Cargo.lock b/Cargo.lock index a69d3c3..cfba28f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -275,7 +275,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -286,7 +286,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1751,7 +1751,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1923,7 +1923,7 @@ dependencies = [ [[package]] name = "ely-gpui-component" version = "0.1.1" -source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=12512217bd9bc652ed6bbf10461b00cc26c01c21#12512217bd9bc652ed6bbf10461b00cc26c01c21" +source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=6b033f3eb05af53c3dd855057608e9e0cd7c8f4b#6b033f3eb05af53c3dd855057608e9e0cd7c8f4b" dependencies = [ "alacritty_terminal", "anyhow", @@ -1960,6 +1960,7 @@ dependencies = [ "similar", "smallvec", "unicode-segmentation", + "unicode-width", "usvg", "vte", "web-time", @@ -2334,7 +2335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4501,7 +4502,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4666,7 +4667,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6418,7 +6419,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7006,7 +7007,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7346,7 +7347,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7696,7 +7697,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8495,7 +8496,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5b5dd93..bf5cc68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ jiff = "0.2" anyhow = "1" directories = "6" dunce = "1" -ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "12512217bd9bc652ed6bbf10461b00cc26c01c21" } +ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "6b033f3eb05af53c3dd855057608e9e0cd7c8f4b" } env_logger = "0.11" gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] } gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] } diff --git a/agents.md b/agents.md index 5176344..36b1657 100644 --- a/agents.md +++ b/agents.md @@ -40,7 +40,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木 ## 组件映射 -依赖 `ely-gpui-component`(pin 1251221,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。 +依赖 `ely-gpui-component`(pin 6b033f3,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。 | 需要 | 用 | | --- | --- | @@ -55,7 +55,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木 | Table | `tables::{PivotTable, FilterBuilder, SortBuilder}` `misc::{CsvImporter, ExportDialog}` `editor::{CompletionMenu, SignatureHelp, FindWidget}` `forms::{CheckboxGroup, ColorPalette}` | | Mail | `mail::{MailboxList, MailList, MailThreadView, MailReader, MailComposer, SignatureEditor, ScheduleSend}` | | Tree | `git::{ChangesList, CommitInput, CommitList, lanes, DiffViewer, ConflictResolver, BlameView, BranchList, TagList, StashList, FileHistory}` | -| Mark | `documents::{MarkdownDocument, MarkdownRenderer, ZenMode, MarkdownReader, MarkdownBlock, alert, is_toc}` `editor::{CodeEditor, FindWidget}`;导出与复制调用可视同一组读法 | +| Mark | `documents::{MarkdownDocument, MarkdownRenderer, ZenMode, MarkdownReader, MarkdownBlock, alert, is_toc, BlockCommand, InlineFormat, TableEdit, EditRefusal, TableRefusal}` `editor::{CodeEditor, FindWidget}`;导出与复制调用可视同一组读法;段落、格式与表格菜单经文档的 `block`、`format`、`edit_table` | | Agent | `agent::{ToolCallCard, PermissionPrompt, ToolApprovalDialog, AgentStepList, FileChangeCard, MultiFileDiffReview, ArtifactPanel, CostBreakdown}` `chat::{MessageList, PromptInput, StreamingMarkdown, ConversationList}` | | 动效 | `motion::{duration, Flip, Reorder, Transition, Stagger, ProgressBar, Spinner, Skeleton}` | | 主题 | `theme::{Theme, Mode}`;Omarchy 色板由 `ely-ui` 映射成组件库的调色板 | @@ -291,3 +291,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 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)。 - 2026-10-14(Gitea #1,Linux Wayland 报):ElyShow 左侧缩略图条滚轮不滚。根因:列表包在 `ContextMenu` 宿主里,宿主无尺寸,列表随内容长高、从不溢出,滚轮无处可滚(右栏正常);宿主改为 `size_full`(同 ElyPDF 的三处),`tests/strip.rs` 以 24 张幻灯片核对滚轮滚到最后一张。各应用其余 `ContextMenu` 宿主已核:ElyPDF 三处本有尺寸,ElyTable 的工作表标签条与下拉选项不是滚动列表。 +- 2026-10-14:ElyMark 的菜单与快捷键(R4.9,MARK-15)。项与键取自 Typora 1.14.10 主进程字节码的字符串表(`.work/typora-main-menus.txt`)与其 `zh-Hans.lproj/Menu.json`;实际大小在字节码里有 Ctrl+Shift+9 与 Ctrl+Shift+0 两串,照用户所定用 Ctrl+Shift+0,R4.11 在真窗口核对。表格是“段落”的子菜单(照 Typora),顶层不再有“表格”。原 Ctrl+1/2/3 切模式、Ctrl+=/-/0 字号、Ctrl+Shift+F 专注让给 Typora 的键;可视、源码、预览留在“视图”菜单,无键。Ctrl+/ 另绑在 `ElyEditor` 上(gpui 同深度后绑者胜),盖过代码编辑器的 `//` 注释切换(Markdown 没有这种注释);`MarkdownDocument` 的键上下文只在可视模式有。带 Shift 的标点:Windows 写键名(`ctrl-shift-[`),macOS 与 Linux 写字符(`cmd-{`),见 `blocks::shifted`。Ctrl+Enter 在打开的块里原是输入框的 Submit(文档不处理),改绑为下方插入行。拒绝一律成信息提示:当场的由 `Desk::edit` 说,后台读出的经 `DocumentEvent::EditRefused`、`view::Refused` 到宿主。Typora 有而组件库暂缺的不进菜单:行内公式、上下标、注释、提示块子菜单、任务状态、列表缩进 Ctrl+[/]、代码工具。 diff --git a/apps/ely-mark/src/blocks.rs b/apps/ely-mark/src/blocks.rs new file mode 100644 index 0000000..4c77287 --- /dev/null +++ b/apps/ely-mark/src/blocks.rs @@ -0,0 +1,234 @@ +//! Typora's Paragraph and Format menus at the caret (MARK-15). + +use ely_gpui_component::{ + documents::{BlockCommand, EditRefusal, InlineFormat, MarkdownDocument, TableRefusal}, + primitives::Severity, +}; +use ely_office::prelude::*; +use ely_ui::{commands, i18n::text}; +use gpui::{ + Action, App, Context, InteractiveElement as _, KeyBinding, Menu, MenuItem, SharedString, + actions, +}; + +use crate::{desk::Desk, keys::item}; + +actions!( + mark, + [ + Heading1, + Heading2, + Heading3, + Heading4, + Heading5, + Heading6, + Paragraph, + Promote, + Demote, + MathBlock, + CodeFences, + Quote, + OrderedList, + BulletList, + TaskList, + ParagraphBefore, + ParagraphAfter, + LinkReference, + Footnote, + Rule, + Toc, + FrontMatter, + Strong, + Emphasis, + Underline, + InlineCode, + Strike, + Hyperlink, + InsertImage, + ClearFormat + ] +); + +/// Shift with punctuation: Windows names the key, others the character. +pub(crate) fn shifted(key: char, typed: char) -> String { + match cfg!(target_os = "windows") { + true => format!("secondary-shift-{key}"), + false => format!("secondary-{typed}"), + } +} + +pub fn bind(cx: &mut App) { + let at = Some(commands::CONTEXT); + cx.bind_keys([ + KeyBinding::new("secondary-1", Heading1, at), + KeyBinding::new("secondary-2", Heading2, at), + KeyBinding::new("secondary-3", Heading3, at), + KeyBinding::new("secondary-4", Heading4, at), + KeyBinding::new("secondary-5", Heading5, at), + KeyBinding::new("secondary-6", Heading6, at), + KeyBinding::new("secondary-0", Paragraph, at), + KeyBinding::new("secondary-=", Promote, at), + KeyBinding::new("secondary--", Demote, at), + KeyBinding::new("secondary-shift-m", MathBlock, at), + KeyBinding::new("secondary-shift-k", CodeFences, at), + KeyBinding::new("secondary-shift-q", Quote, at), + KeyBinding::new(&shifted('[', '{'), OrderedList, at), + KeyBinding::new(&shifted(']', '}'), BulletList, at), + KeyBinding::new("secondary-shift-x", TaskList, at), + KeyBinding::new("secondary-b", Strong, at), + KeyBinding::new("secondary-i", Emphasis, at), + KeyBinding::new("secondary-u", Underline, at), + KeyBinding::new(&shifted('`', '~'), InlineCode, at), + KeyBinding::new("alt-shift-5", Strike, at), + KeyBinding::new("secondary-k", Hyperlink, at), + KeyBinding::new("secondary-shift-i", InsertImage, at), + KeyBinding::new("secondary-\\", ClearFormat, at), + ]); +} + +pub fn paragraph_menu(cx: &App) -> Menu { + let item = |key: &str, action: Box| item(key, action, cx); + Menu::new(text(cx, "mark.menu.paragraph", &[])).items([ + item("mark.block.h1", Box::new(Heading1)), + item("mark.block.h2", Box::new(Heading2)), + item("mark.block.h3", Box::new(Heading3)), + item("mark.block.h4", Box::new(Heading4)), + item("mark.block.h5", Box::new(Heading5)), + item("mark.block.h6", Box::new(Heading6)), + MenuItem::separator(), + item("mark.block.paragraph", Box::new(Paragraph)), + MenuItem::separator(), + item("mark.block.promote", Box::new(Promote)), + item("mark.block.demote", Box::new(Demote)), + MenuItem::separator(), + MenuItem::submenu(crate::tools::table_menu(cx)), + item("mark.block.math", Box::new(MathBlock)), + item("mark.block.code", Box::new(CodeFences)), + MenuItem::separator(), + item("mark.block.quote", Box::new(Quote)), + MenuItem::separator(), + item("mark.block.ordered", Box::new(OrderedList)), + item("mark.block.bullet", Box::new(BulletList)), + item("mark.block.task", Box::new(TaskList)), + MenuItem::separator(), + item("mark.block.before", Box::new(ParagraphBefore)), + item("mark.block.after", Box::new(ParagraphAfter)), + MenuItem::separator(), + item("mark.block.link_reference", Box::new(LinkReference)), + item("mark.block.footnote", Box::new(Footnote)), + MenuItem::separator(), + item("mark.block.rule", Box::new(Rule)), + item("mark.block.toc", Box::new(Toc)), + item("mark.block.front_matter", Box::new(FrontMatter)), + ]) +} + +pub fn format_menu(cx: &App) -> Menu { + let item = |key: &str, action: Box| item(key, action, cx); + Menu::new(text(cx, "mark.menu.format", &[])).items([ + item("mark.format.strong", Box::new(Strong)), + item("mark.format.emphasis", Box::new(Emphasis)), + item("mark.format.underline", Box::new(Underline)), + item("mark.format.code", Box::new(InlineCode)), + MenuItem::separator(), + item("mark.format.strike", Box::new(Strike)), + MenuItem::separator(), + item("mark.format.link", Box::new(Hyperlink)), + item("mark.format.image", Box::new(InsertImage)), + MenuItem::separator(), + item("mark.format.clear", Box::new(ClearFormat)), + ]) +} + +/// What to tell the user for `why`. +pub(crate) fn refusal(why: EditRefusal, cx: &App) -> SharedString { + let key = match why { + EditRefusal::Preview => "mark.preview.read_only", + EditRefusal::Code => "mark.refused.code", + EditRefusal::Math => "mark.refused.math", + EditRefusal::Html => "mark.refused.html", + EditRefusal::FrontMatter => "mark.refused.front_matter", + EditRefusal::Outside => "mark.refused.outside", + EditRefusal::Table => "mark.refused.table", + EditRefusal::Present => "mark.refused.present", + EditRefusal::Across => "mark.refused.across", + EditRefusal::TooLong => "mark.refused.too_long", + EditRefusal::Busy => "mark.refused.busy", + EditRefusal::Changed => "mark.refused.changed", + EditRefusal::Misread => "mark.refused.misread", + EditRefusal::Cells(TableRefusal::Outside) => "mark.table.none", + EditRefusal::Cells(TableRefusal::Kept) => "mark.table.kept", + EditRefusal::Cells(TableRefusal::Beyond) => "mark.table.beyond", + EditRefusal::Cells(TableRefusal::Mixed) => "mark.table.mixed", + EditRefusal::Cells(TableRefusal::Edge) => "mark.table.edge", + }; + text(cx, key, &[]) +} + +/// Each action to its document edit. +macro_rules! edits { + ($root:expr, $cx:expr, $($action:ident => $edit:expr),* $(,)?) => { + $root$(.on_action($cx.listener(|desk, _: &$action, _, cx| desk.edit($edit, cx))))* + }; +} +pub(crate) use edits; + +impl Desk { + /// `edit` on the shown document; a refusal is said. + pub(crate) fn edit( + &mut self, + edit: impl FnOnce( + &mut MarkdownDocument, + &mut Context, + ) -> Result<(), EditRefusal>, + cx: &mut Context, + ) { + let Some(view) = self.active_view() else { + return self.no_document(cx); + }; + let document = view.read(cx).document().clone(); + if let Err(why) = document.update(cx, edit) { + self.toast(refusal(why, cx), Severity::Info, cx); + } + } + + pub fn block_actions(root: gpui::Div, cx: &mut Context) -> gpui::Div { + use BlockCommand as B; + use InlineFormat as F; + edits!(root, cx, + Heading1 => |d, cx| d.block(B::Heading(1), cx), + Heading2 => |d, cx| d.block(B::Heading(2), cx), + Heading3 => |d, cx| d.block(B::Heading(3), cx), + Heading4 => |d, cx| d.block(B::Heading(4), cx), + Heading5 => |d, cx| d.block(B::Heading(5), cx), + Heading6 => |d, cx| d.block(B::Heading(6), cx), + Paragraph => |d, cx| d.block(B::Paragraph, cx), + Promote => |d, cx| d.block(B::Promote, cx), + Demote => |d, cx| d.block(B::Demote, cx), + MathBlock => |d, cx| d.block(B::MathBlock, cx), + CodeFences => |d, cx| d.block(B::CodeBlock, cx), + Quote => |d, cx| d.block(B::Quote, cx), + OrderedList => |d, cx| d.block(B::OrderedList, cx), + BulletList => |d, cx| d.block(B::BulletList, cx), + TaskList => |d, cx| d.block(B::TaskList, cx), + ParagraphBefore => |d, cx| d.block(B::ParagraphBefore, cx), + ParagraphAfter => |d, cx| d.block(B::ParagraphAfter, cx), + LinkReference => |d, cx| d.block(B::LinkDefinition, cx), + Footnote => |d, cx| d.block(B::Footnote, cx), + Rule => |d, cx| d.block(B::Rule, cx), + Toc => |d, cx| d.block(B::Toc, cx), + FrontMatter => |d, cx| d.block(B::FrontMatter, cx), + Strong => |d, cx| d.format(F::Strong, cx), + Emphasis => |d, cx| d.format(F::Emphasis, cx), + Underline => |d, cx| d.format(F::Underline, cx), + InlineCode => |d, cx| d.format(F::Code, cx), + Strike => |d, cx| d.format(F::Strike, cx), + Hyperlink => |d, cx| d.format(F::Link, cx), + InsertImage => |d, cx| d.format(F::Image, cx), + ClearFormat => |d, cx| d.format(F::Clear, cx), + ) + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/ely-mark/src/blocks/tests.rs b/apps/ely-mark/src/blocks/tests.rs new file mode 100644 index 0000000..87c92dd --- /dev/null +++ b/apps/ely-mark/src/blocks/tests.rs @@ -0,0 +1,309 @@ +use ely_gpui_component::documents::MarkdownDocument; +use gpui::{ + Action, ClipboardItem, Entity, Focusable as _, Menu, MenuItem, TestAppContext, + VisualTestContext, +}; + +use crate::{ + desk::{ + Desk, + tests::{open, start}, + }, + view::Mode, +}; + +fn opened( + text: &str, + cx: &mut TestAppContext, +) -> ( + Entity, + &'static mut VisualTestContext, + [tempfile::TempDir; 2], +) { + let (data, files) = ( + tempfile::tempdir().expect("data"), + tempfile::tempdir().expect("files"), + ); + let path = files.path().join("a.md"); + std::fs::write(&path, text).expect("a file"); + let (desk, cx) = start(cx, data.path()); + open(&desk, &path, cx); + let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone()); + cx.update(|window, cx| window.focus(&focus, cx)); + (desk, cx, [data, files]) +} + +fn document(desk: &Entity, cx: &mut VisualTestContext) -> Entity { + desk.read_with(cx, |desk, cx| { + let view = desk.active_view().expect("a tab"); + view.read(cx).document().clone() + }) +} + +fn current(desk: &Entity, cx: &mut VisualTestContext) -> String { + let document = document(desk, cx); + document.read_with(cx, |document, cx| document.text(cx)) +} + +fn mode(desk: &Entity, cx: &mut VisualTestContext) -> Mode { + desk.read_with(cx, |desk, cx| { + desk.active_view().expect("a tab").read(cx).mode + }) +} + +/// `keys`, then the background reads. +fn press(keys: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(keys); + cx.run_until_parked(); +} + +/// The window's toasts that hold `words`. +fn toasted(desk: &Entity, words: &str, cx: &mut VisualTestContext) -> usize { + let root = desk.read_with(cx, |desk, _| desk.core.root.upgrade().expect("the window")); + let toasts = root.read_with(cx, |root, cx| root.toasts(cx)); + toasts + .iter() + .filter(|(said, _)| said.contains(words)) + .count() +} + +#[gpui::test] +fn paragraph_keys_retype_the_caret_block(cx: &mut TestAppContext) { + let (desk, cx, _files) = opened("Intro\n", cx); + press("ctrl-1", cx); + assert_eq!(current(&desk, cx), "# Intro\n"); + press("ctrl--", cx); + assert_eq!(current(&desk, cx), "## Intro\n"); + press("ctrl-=", cx); + assert_eq!(current(&desk, cx), "# Intro\n"); + press("ctrl-0", cx); + assert_eq!(current(&desk, cx), "Intro\n"); + press("ctrl-shift-[", cx); + assert_eq!(current(&desk, cx), "1. Intro\n"); + press("ctrl-shift-]", cx); + assert_eq!(current(&desk, cx), "- Intro\n"); + press("ctrl-shift-q", cx); + assert_eq!( + current(&desk, cx), + "- > Intro\n", + "the item's text is quoted" + ); +} + +#[gpui::test] +fn format_keys_wrap_the_caret_word(cx: &mut TestAppContext) { + let (desk, cx, _files) = opened("word\n", cx); + press("ctrl-b", cx); + assert_eq!(current(&desk, cx), "**word**\n"); + press("ctrl-\\", cx); + assert_eq!(current(&desk, cx), "word\n"); + press("alt-shift-5", cx); + assert_eq!(current(&desk, cx), "~~word~~\n"); +} + +#[gpui::test] +fn ctrl_slash_toggles_source_inside_the_code_editor(cx: &mut TestAppContext) { + let (desk, cx, _files) = opened("a\n", cx); + press("ctrl-/", cx); + assert_eq!(mode(&desk, cx), Mode::Source); + let editor = document(&desk, cx).read_with(cx, |document, _| document.editor().clone()); + let focus = editor.read_with(cx, |editor, cx| editor.focus_handle(cx)); + cx.update(|window, cx| window.focus(&focus, cx)); + press("ctrl-/", cx); + assert_eq!(mode(&desk, cx), Mode::Visual); + assert_eq!(current(&desk, cx), "a\n", "no comment marks"); +} + +#[gpui::test] +fn a_refused_command_says_why(cx: &mut TestAppContext) { + let (desk, cx, _files) = opened("```\ncode\n```\n", cx); + press("ctrl-1", cx); + assert_eq!(current(&desk, cx), "```\ncode\n```\n"); + assert_eq!(toasted(&desk, "inside code", cx), 1); +} + +#[gpui::test] +fn alt_arrows_move_a_table_row(cx: &mut TestAppContext) { + let (desk, cx, _files) = opened("| A | B |\n| - | - |\n| 1 | 2 |\n", cx); + press("ctrl-/", cx); + document(&desk, cx).update(cx, |document, cx| document.set_caret(2, 2, cx)); + press("alt-up", cx); + assert_eq!( + current(&desk, cx), + "| 1 | 2 |\n| ---- | ---- |\n| A | B |\n" + ); + press("alt-up", cx); + assert_eq!(toasted(&desk, "Nothing lies past", cx), 1); +} + +#[gpui::test] +fn view_keys_follow_typora(cx: &mut TestAppContext) { + let (desk, cx, _files) = opened("a\n", cx); + press("f8 f9 ctrl-shift-=", cx); + let look = desk.read_with(cx, |desk, _| desk.look.clone()); + assert!(look.focus && look.typewriter && look.size > 100, "{look:?}"); + press("ctrl-shift-0", cx); + assert_eq!(desk.read_with(cx, |desk, _| desk.look.size), 100); +} + +#[gpui::test] +fn copy_as_markdown_and_paste_as_plain_text(cx: &mut TestAppContext) { + let (desk, cx, _files) = opened("a **b**\n", cx); + press("ctrl-shift-c", cx); + let copied = cx.read_from_clipboard().and_then(|item| item.text()); + assert_eq!(copied.as_deref(), Some("a **b**\n")); + cx.write_to_clipboard(ClipboardItem::new_string("*x*".into())); + press("ctrl-/", cx); + press("ctrl-shift-v", cx); + assert_eq!(current(&desk, cx), "*x*a **b**\n"); +} + +/// Each row's name, `-` for a line, `▸` before a submenu. +fn rows(menu: &Menu) -> Vec { + let mut out = Vec::new(); + for item in &menu.items { + match item { + MenuItem::Separator => out.push("-".into()), + MenuItem::Submenu(sub) => { + out.push(format!("▸ {}", sub.name)); + out.extend(rows(sub).into_iter().map(|row| format!(" {row}"))); + } + MenuItem::Action { name, .. } => out.push(name.to_string()), + MenuItem::SystemMenu(os) => out.push(os.name.to_string()), + } + } + out +} + +#[gpui::test] +fn the_paragraph_and_format_menus_follow_typora(cx: &mut TestAppContext) { + let (desk, cx, _files) = opened("a\n", cx); + let menus = desk.read_with(cx, |desk, cx| crate::keys::menus(&desk.look, cx)); + let named: Vec = menus.iter().map(|menu| menu.name.to_string()).collect(); + assert_eq!(named, ["Edit", "Paragraph", "Format", "View"]); + let table = [ + "Insert Table", + "-", + "Add Row Above", + "Add Row Below", + "Add Column Before", + "Add Column After", + "-", + "Move Row Up", + "Move Row Down", + "Move Column Left", + "Move Column Right", + "-", + "Delete Row", + "Delete Column", + "-", + "Prettify Source Code", + ]; + let mut paragraph: Vec = [ + "Heading 1", + "Heading 2", + "Heading 3", + "Heading 4", + "Heading 5", + "Heading 6", + "-", + "Paragraph", + "-", + "Increase Heading Level", + "Decrease Heading Level", + "-", + "▸ Table", + ] + .map(String::from) + .into(); + paragraph.extend(table.map(|row| format!(" {row}"))); + paragraph.extend( + [ + "Math Block", + "Code Fences", + "-", + "Quote", + "-", + "Ordered List", + "Unordered List", + "Task List", + "-", + "Insert Paragraph Before", + "Insert Paragraph After", + "-", + "Link Reference", + "Footnotes", + "-", + "Horizontal Line", + "Table of Contents", + "YAML Front Matter", + ] + .map(String::from), + ); + assert_eq!(rows(&menus[1]), paragraph); + assert_eq!( + rows(&menus[2]).join("|"), + "Strong|Emphasis|Underline|Code|-|Strike|-|Hyperlink|Insert Image|-|Clear Format" + ); +} + +/// The keys a menu row shows for `action`. +fn keys(action: &dyn Action, cx: &mut VisualTestContext) -> String { + cx.update(|window, _| { + let binding = window + .highest_precedence_binding_for_action(action) + .expect("a binding"); + let strokes: Vec = binding + .keystrokes() + .iter() + .map(|key| key.unparse()) + .collect(); + strokes.join(" ") + }) +} + +#[cfg(target_os = "windows")] +#[gpui::test] +fn every_command_takes_typoras_windows_key(cx: &mut TestAppContext) { + use crate::{blocks::*, clip::*, keys::ToggleSource, look, tools::*}; + let (_desk, cx, _files) = opened("a\n", cx); + let typora: [(&dyn Action, &str); 34] = [ + (&Heading1, "ctrl-1"), + (&Heading6, "ctrl-6"), + (&Paragraph, "ctrl-0"), + (&Promote, "ctrl-="), + (&Demote, "ctrl--"), + (&MathBlock, "ctrl-shift-m"), + (&CodeFences, "ctrl-shift-k"), + (&Quote, "ctrl-shift-q"), + (&OrderedList, "ctrl-shift-["), + (&BulletList, "ctrl-shift-]"), + (&TaskList, "ctrl-shift-x"), + (&Strong, "ctrl-b"), + (&Emphasis, "ctrl-i"), + (&Underline, "ctrl-u"), + (&InlineCode, "ctrl-shift-`"), + (&Strike, "alt-shift-5"), + (&Hyperlink, "ctrl-k"), + (&InsertImage, "ctrl-shift-i"), + (&ClearFormat, "ctrl-\\"), + (&InsertTable, "ctrl-t"), + (&RowBelow, "ctrl-enter"), + (&DeleteRow, "ctrl-shift-backspace"), + (&MoveRowUp, "alt-up"), + (&MoveRowDown, "alt-down"), + (&MoveColumnLeft, "alt-left"), + (&MoveColumnRight, "alt-right"), + (&ToggleSource, "ctrl-/"), + (&look::FocusMode, "f8"), + (&look::Typewriter, "f9"), + (&look::Larger, "ctrl-shift-="), + (&look::Smaller, "ctrl-shift--"), + (&look::ActualSize, "ctrl-shift-0"), + (&CopyMarkdown, "ctrl-shift-c"), + (&PastePlain, "ctrl-shift-v"), + ]; + for (action, want) in typora { + assert_eq!(keys(action, cx), want, "{}", action.name()); + } +} diff --git a/apps/ely-mark/src/clip.rs b/apps/ely-mark/src/clip.rs new file mode 100644 index 0000000..6bf0cee --- /dev/null +++ b/apps/ely-mark/src/clip.rs @@ -0,0 +1,56 @@ +//! Copy as Markdown and paste as plain text, as Typora's Edit menu. + +use ely_gpui_component::primitives::Severity; +use ely_office::prelude::*; +use ely_ui::{commands, i18n::text}; +use gpui::{App, ClipboardItem, Context, InteractiveElement as _, KeyBinding, actions}; + +use crate::desk::Desk; + +actions!(mark, [CopyMarkdown, PastePlain]); + +pub fn bind(cx: &mut App) { + let at = Some(commands::CONTEXT); + cx.bind_keys([ + KeyBinding::new("secondary-shift-c", CopyMarkdown, at), + KeyBinding::new("secondary-shift-v", PastePlain, at), + ]); +} + +impl Desk { + /// The selected Markdown, or the whole text, as written. + fn copy_markdown(&mut self, cx: &mut Context) { + let Some(view) = self.active_view() else { + return self.no_document(cx); + }; + let document = view.read(cx).document().read(cx); + let selected = document.selected(cx); + let markdown = match selected.is_empty() { + true => document.text(cx), + false => selected, + }; + log::info!("mark: {} bytes copied as Markdown", markdown.len()); + cx.write_to_clipboard(ClipboardItem::new_string(markdown)); + } + + /// The clipboard's text at the caret, pictures left out. + fn paste_plain(&mut self, cx: &mut Context) { + let Some(view) = self.active_view() else { + return self.no_document(cx); + }; + let Some(pasted) = cx.read_from_clipboard().and_then(|item| item.text()) else { + let said = text(cx, "mark.paste.none", &[]); + return self.toast(said, Severity::Info, cx); + }; + let document = view.read(cx).document().clone(); + if !document.update(cx, |document, cx| document.insert(&pasted, cx)) { + let said = text(cx, "mark.preview.read_only", &[]); + self.toast(said, Severity::Info, cx); + } + } + + pub fn clip_actions(root: gpui::Div, cx: &mut Context) -> gpui::Div { + root.on_action(cx.listener(|desk, _: &CopyMarkdown, _, cx| desk.copy_markdown(cx))) + .on_action(cx.listener(|desk, _: &PastePlain, _, cx| desk.paste_plain(cx))) + } +} diff --git a/apps/ely-mark/src/desk.rs b/apps/ely-mark/src/desk.rs index b9d59dd..b941677 100644 --- a/apps/ely-mark/src/desk.rs +++ b/apps/ely-mark/src/desk.rs @@ -23,7 +23,6 @@ use gpui::{ }; use crate::engine::Mark; -use crate::keys::{CheckDisk, PreviewMode, SourceMode, VisualMode}; use crate::view::{MarkView, Mode}; pub struct Desk { @@ -334,7 +333,7 @@ impl Desk { self.new_from_text("untitled.md", "", cx); } - fn switch(&mut self, mode: Mode, window: &mut Window, cx: &mut Context) { + pub(crate) fn switch(&mut self, mode: Mode, window: &mut Window, cx: &mut Context) { if let Some(view) = self.active_view() { view.update(cx, |view, cx| view.switch(mode, window, cx)); } @@ -417,35 +416,26 @@ impl Render for Desk { .status(self.status(cx)), } .child(body); - let root = - div() - .size_full() - .key_context(commands::CONTEXT) - .track_focus(&self.core.focus) - .on_action(cx.listener(Self::open_picked)) - .on_action(cx.listener(Self::on_new)) - .on_action(cx.listener(Self::on_save)) - .on_action(cx.listener(Self::on_save_as)) - .on_action(cx.listener(Self::on_rename)) - .on_action(cx.listener(Self::on_close_tab)) - .on_action(cx.listener(Self::on_find)) - .on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(cx))) - .on_action(cx.listener(|desk, _: &VisualMode, window, cx| { - desk.switch(Mode::Visual, window, cx) - })) - .on_action(cx.listener(|desk, _: &SourceMode, window, cx| { - desk.switch(Mode::Source, window, cx) - })) - .on_action(cx.listener(|desk, _: &PreviewMode, window, cx| { - desk.switch(Mode::Preview, window, cx) - })) - .child(shell) - .children(self.core_dialog(cx)) - .children(self.naming_element(cx)) - .children(self.compare_dialog(cx)) - .children(self.find_box(cx)) - .children(self.export_dialog(cx)); - Self::output_actions(Self::table_actions(Self::look_actions(root, cx), cx), cx) + let root = div() + .size_full() + .key_context(commands::CONTEXT) + .track_focus(&self.core.focus) + .on_action(cx.listener(Self::open_picked)) + .on_action(cx.listener(Self::on_new)) + .on_action(cx.listener(Self::on_save)) + .on_action(cx.listener(Self::on_save_as)) + .on_action(cx.listener(Self::on_rename)) + .on_action(cx.listener(Self::on_close_tab)) + .on_action(cx.listener(Self::on_find)) + .child(shell) + .children(self.core_dialog(cx)) + .children(self.naming_element(cx)) + .children(self.compare_dialog(cx)) + .children(self.find_box(cx)) + .children(self.export_dialog(cx)); + let root = Self::mode_actions(Self::look_actions(root, cx), cx); + let root = Self::block_actions(Self::table_actions(root, cx), cx); + Self::output_actions(Self::clip_actions(root, cx), cx) } } diff --git a/apps/ely-mark/src/engine.rs b/apps/ely-mark/src/engine.rs index 89094e4..e720f6b 100644 --- a/apps/ely-mark/src/engine.rs +++ b/apps/ely-mark/src/engine.rs @@ -8,11 +8,11 @@ use std::{ sync::{Arc, Mutex}, }; -use ely_office::{Engine, Host, Load}; +use ely_office::{Engine, Host, Load, Tabs as _}; use gpui::{App, AppContext as _, Context, Entity, FocusHandle, Window}; use crate::text::Source; -use crate::view::{Failed, MarkView, Reported}; +use crate::view::{Failed, MarkView, Refused, Reported}; /// A file as read, and its text now. pub struct Text { @@ -137,6 +137,11 @@ impl Engine for Mark { failed(host, &error.0, cx) }) .detach(); + cx.subscribe(view, |host, _, refused: &Refused, cx| { + let said = crate::blocks::refusal(refused.0, cx); + host.toast(said, ely_gpui_component::primitives::Severity::Info, cx) + }) + .detach(); cx.subscribe(view, move |host, view, _: &Reported, cx| { let modified = view.read(cx).modified(); reported(host, &view, modified, cx) diff --git a/apps/ely-mark/src/export/mod.rs b/apps/ely-mark/src/export/mod.rs index 44ab8d4..b38b0f7 100644 --- a/apps/ely-mark/src/export/mod.rs +++ b/apps/ely-mark/src/export/mod.rs @@ -46,7 +46,7 @@ impl Desk { .on_action(cx.listener(|desk, _: &CopyRich, _, cx| desk.copy_rich(cx))) } - fn no_document(&self, cx: &mut Context) { + pub(crate) fn no_document(&self, cx: &mut Context) { let said = text(cx, "command.blocked.no_document", &[]); self.toast(said, Severity::Warning, cx); } diff --git a/apps/ely-mark/src/finding/tests.rs b/apps/ely-mark/src/finding/tests.rs index 3a1b100..fcdb36e 100644 --- a/apps/ely-mark/src/finding/tests.rs +++ b/apps/ely-mark/src/finding/tests.rs @@ -110,10 +110,11 @@ fn the_table_menu_inserts_and_edits_a_table(cx: &mut TestAppContext) { let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone()); cx.update(|window, cx| window.focus(&focus, cx)); cx.dispatch_action(crate::tools::InsertTable); - assert!(current_text(&desk, cx).contains("| --- | --- | --- |")); + assert!(current_text(&desk, cx).contains("| ---- | ---- | ---- |")); cx.dispatch_action(crate::tools::DeleteColumn); + cx.run_until_parked(); assert!( - current_text(&desk, cx).contains("| --- | --- |\n"), + current_text(&desk, cx).contains("| ---- | ---- |\n"), "{}", current_text(&desk, cx) ); diff --git a/apps/ely-mark/src/keys.rs b/apps/ely-mark/src/keys.rs index a4ed164..964a361 100644 --- a/apps/ely-mark/src/keys.rs +++ b/apps/ely-mark/src/keys.rs @@ -1,24 +1,31 @@ -//! Modes, menus and keys (MARK-01, MARK-02). +//! Modes, menus and keys, as Typora's (MARK-01, MARK-02, MARK-15). use ely_ui::{commands, i18n::text}; -use gpui::{App, KeyBinding, Menu, MenuItem, actions}; +use gpui::{Action, App, Context, InteractiveElement as _, KeyBinding, Menu, MenuItem, actions}; -use crate::view::Mode; +use crate::{blocks::shifted, desk::Desk, view::Mode}; -actions!(mark, [VisualMode, SourceMode, PreviewMode, CheckDisk]); +actions!( + mark, + [VisualMode, SourceMode, PreviewMode, ToggleSource, CheckDisk] +); pub fn bind(cx: &mut App) { let at = Some(commands::CONTEXT); cx.bind_keys([ - KeyBinding::new("secondary-1", VisualMode, at), - KeyBinding::new("secondary-2", SourceMode, at), - KeyBinding::new("secondary-3", PreviewMode, at), - KeyBinding::new("secondary-shift-f", crate::look::FocusMode, at), - KeyBinding::new("secondary-=", crate::look::Larger, at), - KeyBinding::new("secondary--", crate::look::Smaller, at), - KeyBinding::new("secondary-0", crate::look::ActualSize, at), + KeyBinding::new("secondary-/", ToggleSource, at), + // Over the code editor's `//` toggle. + KeyBinding::new("secondary-/", ToggleSource, Some("ElyEditor")), + KeyBinding::new("f8", crate::look::FocusMode, at), + KeyBinding::new("f9", crate::look::Typewriter, at), + KeyBinding::new(&shifted('=', '+'), crate::look::Larger, at), + KeyBinding::new(&shifted('-', '_'), crate::look::Smaller, at), + KeyBinding::new(&shifted('0', ')'), crate::look::ActualSize, at), KeyBinding::new("secondary-f", ely_office::FindReplace, at), ]); + crate::blocks::bind(cx); + crate::tools::bind(cx); + crate::clip::bind(cx); } /// The label of `mode`. @@ -26,36 +33,37 @@ pub fn label(mode: Mode, cx: &App) -> gpui::SharedString { text(cx, &format!("mark.mode.{}", mode.name()), &[]) } -pub fn menus(look: &crate::look::Look, cx: &App) -> Vec { - use crate::look::{self, Font, Width}; - let checked = |key: &str, action: Box, checked: bool| MenuItem::Action { +/// A menu row named by `key`, ticked when `checked`. +pub(crate) fn checked(key: &str, action: Box, checked: bool, cx: &App) -> MenuItem { + MenuItem::Action { name: text(cx, key, &[]), action, os_action: None, checked, disabled: false, - }; - let item = |key: &str, action: Box| checked(key, action, false); - use crate::tools::{ - ColumnLeft, ColumnRight, DeleteColumn, DeleteRow, InsertTable, RowAbove, RowBelow, - }; + } +} + +pub(crate) fn item(key: &str, action: Box, cx: &App) -> MenuItem { + checked(key, action, false, cx) +} + +pub fn menus(look: &crate::look::Look, cx: &App) -> Vec { + use crate::look::{self, Font, Width}; + let checked = |key: &str, action: Box, on: bool| checked(key, action, on, cx); + let item = |key: &str, action: Box| item(key, action, cx); vec![ Menu::new(text(cx, "menu.edit", &[])).items([ - item("office.find.open", Box::new(ely_office::FindReplace)), + item("mark.copy.markdown", Box::new(crate::clip::CopyMarkdown)), item("mark.copy.rich", Box::new(crate::export::CopyRich)), - ]), - Menu::new(text(cx, "mark.menu.table", &[])).items([ - item("mark.table.insert", Box::new(InsertTable)), + item("mark.paste.plain", Box::new(crate::clip::PastePlain)), MenuItem::separator(), - item("mark.table.row_above", Box::new(RowAbove)), - item("mark.table.row_below", Box::new(RowBelow)), - item("mark.table.column_left", Box::new(ColumnLeft)), - item("mark.table.column_right", Box::new(ColumnRight)), - MenuItem::separator(), - item("mark.table.delete_row", Box::new(DeleteRow)), - item("mark.table.delete_column", Box::new(DeleteColumn)), + item("office.find.open", Box::new(ely_office::FindReplace)), ]), + crate::blocks::paragraph_menu(cx), + crate::blocks::format_menu(cx), Menu::new(text(cx, "menu.view", &[])).items([ + item("mark.view.source", Box::new(ToggleSource)), MenuItem::action(label(Mode::Visual, cx), VisualMode), MenuItem::action(label(Mode::Source, cx), SourceMode), MenuItem::action(label(Mode::Preview, cx), PreviewMode), @@ -99,11 +107,45 @@ pub fn menus(look: &crate::look::Look, cx: &App) -> Vec { look.font == Font::Mono, ), MenuItem::separator(), + item("mark.look.actual", Box::new(look::ActualSize)), item("mark.look.larger", Box::new(look::Larger)), item("mark.look.smaller", Box::new(look::Smaller)), - item("mark.look.actual", Box::new(look::ActualSize)), MenuItem::separator(), item("mark.outside.check", Box::new(CheckDisk)), ]), ] } + +impl Desk { + /// Source and visual, as Typora's Source Code Mode. + fn toggle_source(&mut self, window: &mut gpui::Window, cx: &mut Context) { + let Some(view) = self.active_view() else { + return self.no_document(cx); + }; + let mode = match view.read(cx).mode { + Mode::Source => Mode::Visual, + Mode::Visual | Mode::Preview => Mode::Source, + }; + self.switch(mode, window, cx); + } + + pub fn mode_actions(root: gpui::Div, cx: &mut Context) -> gpui::Div { + root.on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(cx))) + .on_action( + cx.listener(|desk, _: &ToggleSource, window, cx| desk.toggle_source(window, cx)), + ) + .on_action( + cx.listener(|desk, _: &VisualMode, window, cx| { + desk.switch(Mode::Visual, window, cx) + }), + ) + .on_action( + cx.listener(|desk, _: &SourceMode, window, cx| { + desk.switch(Mode::Source, window, cx) + }), + ) + .on_action(cx.listener(|desk, _: &PreviewMode, window, cx| { + desk.switch(Mode::Preview, window, cx) + })) + } +} diff --git a/apps/ely-mark/src/main.rs b/apps/ely-mark/src/main.rs index f998534..ee220cb 100644 --- a/apps/ely-mark/src/main.rs +++ b/apps/ely-mark/src/main.rs @@ -3,6 +3,8 @@ mod attach; mod autosave; +mod blocks; +mod clip; mod compare; mod desk; mod engine; diff --git a/apps/ely-mark/src/output/html.rs b/apps/ely-mark/src/output/html.rs index 95acd54..6ba8289 100644 --- a/apps/ely-mark/src/output/html.rs +++ b/apps/ely-mark/src/output/html.rs @@ -172,7 +172,7 @@ impl Writer<'_, '_, '_> { let mut written = false; for piece in pieces { match piece { - MarkdownBlock::Image { alt, url, link } => { + MarkdownBlock::Image { alt, url, link, .. } => { self.picture(url, alt, link.as_deref())?; if split { retire(stretches.pop_front(), written); diff --git a/apps/ely-mark/src/tools.rs b/apps/ely-mark/src/tools.rs index eb6ef8a..515f69f 100644 --- a/apps/ely-mark/src/tools.rs +++ b/apps/ely-mark/src/tools.rs @@ -1,14 +1,10 @@ //! The table menu's work on the shown file (MARK-06). -use ely_gpui_component::{ - documents::{TableEdit, TableRefusal}, - primitives::Severity, -}; -use ely_office::prelude::*; -use ely_ui::i18n::text; -use gpui::{Context, InteractiveElement as _, actions}; +use ely_gpui_component::documents::TableEdit; +use ely_ui::{commands, i18n::text}; +use gpui::{Action, App, Context, InteractiveElement as _, KeyBinding, Menu, MenuItem, actions}; -use crate::desk::Desk; +use crate::{blocks::edits, desk::Desk, keys::item}; actions!( mark, @@ -18,58 +14,71 @@ actions!( RowBelow, ColumnLeft, ColumnRight, + MoveRowUp, + MoveRowDown, + MoveColumnLeft, + MoveColumnRight, DeleteRow, - DeleteColumn + DeleteColumn, + TidyTable ] ); -impl Desk { - fn table(&mut self, edit: Option, cx: &mut Context) { - let Some(view) = self.active_view() else { - let said = text(cx, "command.blocked.no_document", &[]); - return self.toast(said, Severity::Warning, cx); - }; - let document = view.read(cx).document().clone(); - let done = document.update(cx, |document, cx| match edit { - Some(edit) => document.edit_table(edit, cx), - None => document.insert_table(cx), - }); - let Err(refusal) = done else { - return; - }; - let key = match refusal { - TableRefusal::Outside => "mark.table.none", - TableRefusal::Kept => "mark.table.kept", - TableRefusal::Beyond => "mark.table.beyond", - TableRefusal::Mixed => "mark.table.mixed", - TableRefusal::Preview => "mark.preview.read_only", - }; - let said = text(cx, key, &[]); - self.toast(said, Severity::Info, cx); - } +pub fn bind(cx: &mut App) { + let at = Some(commands::CONTEXT); + // An open block's input takes Ctrl+Enter as Submit. + let open = Some("MarkdownDocument > ElyInput"); + cx.bind_keys([ + KeyBinding::new("secondary-t", InsertTable, at), + KeyBinding::new("secondary-enter", RowBelow, at), + KeyBinding::new("secondary-enter", RowBelow, open), + KeyBinding::new("alt-up", MoveRowUp, at), + KeyBinding::new("alt-down", MoveRowDown, at), + KeyBinding::new("alt-left", MoveColumnLeft, at), + KeyBinding::new("alt-right", MoveColumnRight, at), + KeyBinding::new("secondary-shift-backspace", DeleteRow, at), + ]); +} - pub fn table_actions(element: gpui::Div, cx: &mut Context) -> gpui::Div { - element - .on_action(cx.listener(|desk, _: &InsertTable, _, cx| desk.table(None, cx))) - .on_action( - cx.listener(|desk, _: &RowAbove, _, cx| desk.table(Some(TableEdit::RowAbove), cx)), - ) - .on_action( - cx.listener(|desk, _: &RowBelow, _, cx| desk.table(Some(TableEdit::RowBelow), cx)), - ) - .on_action(cx.listener(|desk, _: &ColumnLeft, _, cx| { - desk.table(Some(TableEdit::ColumnLeft), cx) - })) - .on_action(cx.listener(|desk, _: &ColumnRight, _, cx| { - desk.table(Some(TableEdit::ColumnRight), cx) - })) - .on_action( - cx.listener(|desk, _: &DeleteRow, _, cx| { - desk.table(Some(TableEdit::DeleteRow), cx) - }), - ) - .on_action(cx.listener(|desk, _: &DeleteColumn, _, cx| { - desk.table(Some(TableEdit::DeleteColumn), cx) - })) +/// Typora's Paragraph ▸ Table. +pub fn table_menu(cx: &App) -> Menu { + let item = |key: &str, action: Box| item(key, action, cx); + Menu::new(text(cx, "mark.menu.table", &[])).items([ + item("mark.table.insert", Box::new(InsertTable)), + MenuItem::separator(), + item("mark.table.row_above", Box::new(RowAbove)), + item("mark.table.row_below", Box::new(RowBelow)), + item("mark.table.column_left", Box::new(ColumnLeft)), + item("mark.table.column_right", Box::new(ColumnRight)), + MenuItem::separator(), + item("mark.table.move_row_up", Box::new(MoveRowUp)), + item("mark.table.move_row_down", Box::new(MoveRowDown)), + item("mark.table.move_column_left", Box::new(MoveColumnLeft)), + item("mark.table.move_column_right", Box::new(MoveColumnRight)), + MenuItem::separator(), + item("mark.table.delete_row", Box::new(DeleteRow)), + item("mark.table.delete_column", Box::new(DeleteColumn)), + MenuItem::separator(), + item("mark.table.tidy", Box::new(TidyTable)), + ]) +} + +impl Desk { + pub fn table_actions(root: gpui::Div, cx: &mut Context) -> gpui::Div { + use TableEdit as T; + edits!(root, cx, + InsertTable => |d, cx| d.insert_table(cx), + RowAbove => |d, cx| d.edit_table(T::RowAbove, cx), + RowBelow => |d, cx| d.edit_table(T::RowBelow, cx), + ColumnLeft => |d, cx| d.edit_table(T::ColumnLeft, cx), + ColumnRight => |d, cx| d.edit_table(T::ColumnRight, cx), + MoveRowUp => |d, cx| d.edit_table(T::MoveRowUp, cx), + MoveRowDown => |d, cx| d.edit_table(T::MoveRowDown, cx), + MoveColumnLeft => |d, cx| d.edit_table(T::MoveColumnLeft, cx), + MoveColumnRight => |d, cx| d.edit_table(T::MoveColumnRight, cx), + DeleteRow => |d, cx| d.edit_table(T::DeleteRow, cx), + DeleteColumn => |d, cx| d.edit_table(T::DeleteColumn, cx), + TidyTable => |d, cx| d.edit_table(T::Tidy, cx), + ) } } diff --git a/apps/ely-mark/src/view.rs b/apps/ely-mark/src/view.rs index c4c7e9f..8f975e3 100644 --- a/apps/ely-mark/src/view.rs +++ b/apps/ely-mark/src/view.rs @@ -2,7 +2,9 @@ use std::path::PathBuf; -use ely_gpui_component::documents::{DocumentEvent, LARGEST_READ, MarkdownDocument, MarkdownMode}; +use ely_gpui_component::documents::{ + DocumentEvent, EditRefusal, LARGEST_READ, MarkdownDocument, MarkdownMode, +}; use gpui::{ AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable as _, InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _, Window, div, @@ -16,6 +18,9 @@ pub struct Reported; /// What went wrong, for the shell to say. pub struct Failed(pub String); +/// An edit asked did not happen, and why. +pub struct Refused(pub EditRefusal); + /// How a document shows (MARK-02). #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] @@ -86,6 +91,7 @@ pub struct MarkView { impl EventEmitter for MarkView {} impl EventEmitter for MarkView {} +impl EventEmitter for MarkView {} impl MarkView { pub fn new(doc: Doc, saved: String, window: &mut Window, cx: &mut Context) -> Self { @@ -269,6 +275,9 @@ impl MarkView { window: &mut Window, cx: &mut Context, ) { + if let DocumentEvent::EditRefused(why) = event { + return cx.emit(Refused(*why)); + } if *event == DocumentEvent::Changed { let text = document.read(cx).text(cx); self.doc.lock().expect("the text lock").current = text; diff --git a/crates/ely-ui/src/i18n.rs b/crates/ely-ui/src/i18n.rs index abb2a4d..a4d3d7c 100644 --- a/crates/ely-ui/src/i18n.rs +++ b/crates/ely-ui/src/i18n.rs @@ -5,6 +5,7 @@ mod calc; mod data; mod doc; mod mark; +mod mark_menus; mod motion; mod office; mod output; @@ -179,6 +180,7 @@ fn catalogs() -> (Catalog, Catalog) { .chain(tree_ops::EN) .chain(tree_places::EN) .chain(mark::EN) + .chain(mark_menus::EN) .chain(settings::EN) .copied() .collect(); @@ -202,6 +204,7 @@ fn catalogs() -> (Catalog, Catalog) { .chain(tree_ops::ZH_CN) .chain(tree_places::ZH_CN) .chain(mark::ZH_CN) + .chain(mark_menus::ZH_CN) .chain(settings::ZH_CN) .copied() .collect(); diff --git a/crates/ely-ui/src/i18n/mark.rs b/crates/ely-ui/src/i18n/mark.rs index c79cd57..3327748 100644 --- a/crates/ely-ui/src/i18n/mark.rs +++ b/crates/ely-ui/src/i18n/mark.rs @@ -16,8 +16,8 @@ pub(super) const EN: &[(&str, &str)] = &[ ("mark.table.insert", "Insert Table"), ("mark.table.row_above", "Add Row Above"), ("mark.table.row_below", "Add Row Below"), - ("mark.table.column_left", "Add Column Left"), - ("mark.table.column_right", "Add Column Right"), + ("mark.table.column_left", "Add Column Before"), + ("mark.table.column_right", "Add Column After"), ("mark.table.delete_row", "Delete Row"), ("mark.table.delete_column", "Delete Column"), ("mark.table.none", "Put the caret in a table first"), @@ -65,15 +65,15 @@ pub(super) const EN: &[(&str, &str)] = &[ ("mark.copy.failed", "Copying failed: {error}"), ("mark.export.failed", "The export failed: {error}"), ("mark.look.focus", "Focus Mode"), - ("mark.look.typewriter", "Typewriter Scrolling"), + ("mark.look.typewriter", "Typewriter Mode"), ("mark.look.narrow", "Narrow Text"), ("mark.look.medium", "Medium Text"), ("mark.look.wide", "Wide Text"), ("mark.look.sans", "Sans-Serif Font"), ("mark.look.serif", "Serif Font"), ("mark.look.mono", "Monospace Font"), - ("mark.look.larger", "Larger Text"), - ("mark.look.smaller", "Smaller Text"), + ("mark.look.larger", "Zoom In"), + ("mark.look.smaller", "Zoom Out"), ("mark.look.actual", "Actual Size"), ("mark.git.unchanged", "Git: unchanged"), ("mark.git.changed", "Git: changed"), @@ -113,10 +113,10 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("mark.search.missed", "{name} 未能搜索"), ("mark.menu.table", "表格"), ("mark.table.insert", "插入表格"), - ("mark.table.row_above", "在上方插入行"), - ("mark.table.row_below", "在下方插入行"), - ("mark.table.column_left", "在左侧插入列"), - ("mark.table.column_right", "在右侧插入列"), + ("mark.table.row_above", "上方插入行"), + ("mark.table.row_below", "下方插入行"), + ("mark.table.column_left", "左侧插入列"), + ("mark.table.column_right", "右侧插入列"), ("mark.table.delete_row", "删除行"), ("mark.table.delete_column", "删除列"), ("mark.table.none", "请先把插入点放进表格"), @@ -164,15 +164,15 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("mark.copy.failed", "复制失败:{error}"), ("mark.export.failed", "导出失败:{error}"), ("mark.look.focus", "专注模式"), - ("mark.look.typewriter", "打字机滚动"), + ("mark.look.typewriter", "打字机模式"), ("mark.look.narrow", "窄正文"), ("mark.look.medium", "中等正文"), ("mark.look.wide", "宽正文"), ("mark.look.sans", "无衬线字体"), ("mark.look.serif", "衬线字体"), ("mark.look.mono", "等宽字体"), - ("mark.look.larger", "放大文字"), - ("mark.look.smaller", "缩小文字"), + ("mark.look.larger", "放大"), + ("mark.look.smaller", "缩小"), ("mark.look.actual", "实际大小"), ("mark.git.unchanged", "Git:未改动"), ("mark.git.changed", "Git:有改动"), diff --git a/crates/ely-ui/src/i18n/mark_menus.rs b/crates/ely-ui/src/i18n/mark_menus.rs new file mode 100644 index 0000000..64c6e73 --- /dev/null +++ b/crates/ely-ui/src/i18n/mark_menus.rs @@ -0,0 +1,139 @@ +//! ElyMark's Typora menus, in Typora's own words. + +pub(super) const EN: &[(&str, &str)] = &[ + ("mark.menu.paragraph", "Paragraph"), + ("mark.menu.format", "Format"), + ("mark.block.h1", "Heading 1"), + ("mark.block.h2", "Heading 2"), + ("mark.block.h3", "Heading 3"), + ("mark.block.h4", "Heading 4"), + ("mark.block.h5", "Heading 5"), + ("mark.block.h6", "Heading 6"), + ("mark.block.paragraph", "Paragraph"), + ("mark.block.promote", "Increase Heading Level"), + ("mark.block.demote", "Decrease Heading Level"), + ("mark.block.math", "Math Block"), + ("mark.block.code", "Code Fences"), + ("mark.block.quote", "Quote"), + ("mark.block.ordered", "Ordered List"), + ("mark.block.bullet", "Unordered List"), + ("mark.block.task", "Task List"), + ("mark.block.before", "Insert Paragraph Before"), + ("mark.block.after", "Insert Paragraph After"), + ("mark.block.link_reference", "Link Reference"), + ("mark.block.footnote", "Footnotes"), + ("mark.block.rule", "Horizontal Line"), + ("mark.block.toc", "Table of Contents"), + ("mark.block.front_matter", "YAML Front Matter"), + ("mark.format.strong", "Strong"), + ("mark.format.emphasis", "Emphasis"), + ("mark.format.underline", "Underline"), + ("mark.format.code", "Code"), + ("mark.format.strike", "Strike"), + ("mark.format.link", "Hyperlink"), + ("mark.format.image", "Insert Image"), + ("mark.format.clear", "Clear Format"), + ("mark.table.move_row_up", "Move Row Up"), + ("mark.table.move_row_down", "Move Row Down"), + ("mark.table.move_column_left", "Move Column Left"), + ("mark.table.move_column_right", "Move Column Right"), + ("mark.table.tidy", "Prettify Source Code"), + ( + "mark.table.edge", + "Nothing lies past this row or column that way", + ), + ("mark.view.source", "Source Code Mode"), + ("mark.copy.markdown", "Copy as Markdown"), + ("mark.paste.plain", "Paste as Plain Text"), + ("mark.paste.none", "The clipboard holds no text"), + ("mark.refused.code", "This does not work inside code"), + ("mark.refused.math", "This does not work inside math"), + ("mark.refused.html", "This does not work inside HTML"), + ( + "mark.refused.front_matter", + "This does not work inside front matter", + ), + ( + "mark.refused.outside", + "Put the caret in a paragraph, heading or cell first", + ), + ("mark.refused.table", "This does not work inside a table"), + ("mark.refused.present", "The file has front matter already"), + ( + "mark.refused.across", + "The selection crosses a block, a link or another format", + ), + ( + "mark.refused.too_long", + "The file is too long to read; edit it as source", + ), + ("mark.refused.busy", "The last edit is still being read"), + ( + "mark.refused.changed", + "The text changed before the edit was ready; try again", + ), + ( + "mark.refused.misread", + "This would change how the text reads, so nothing changed", + ), +]; + +pub(super) const ZH_CN: &[(&str, &str)] = &[ + ("mark.menu.paragraph", "段落"), + ("mark.menu.format", "格式"), + ("mark.block.h1", "一级标题"), + ("mark.block.h2", "二级标题"), + ("mark.block.h3", "三级标题"), + ("mark.block.h4", "四级标题"), + ("mark.block.h5", "五级标题"), + ("mark.block.h6", "六级标题"), + ("mark.block.paragraph", "段落"), + ("mark.block.promote", "提升标题级别"), + ("mark.block.demote", "降低标题级别"), + ("mark.block.math", "公式块"), + ("mark.block.code", "代码块"), + ("mark.block.quote", "引用"), + ("mark.block.ordered", "有序列表"), + ("mark.block.bullet", "无序列表"), + ("mark.block.task", "任务列表"), + ("mark.block.before", "在上方插入段落"), + ("mark.block.after", "在下方插入段落"), + ("mark.block.link_reference", "链接引用"), + ("mark.block.footnote", "脚注"), + ("mark.block.rule", "水平分割线"), + ("mark.block.toc", "内容目录"), + ("mark.block.front_matter", "YAML Front Matter"), + ("mark.format.strong", "加粗"), + ("mark.format.emphasis", "斜体"), + ("mark.format.underline", "下划线"), + ("mark.format.code", "代码"), + ("mark.format.strike", "删除线"), + ("mark.format.link", "超链接"), + ("mark.format.image", "插入图片"), + ("mark.format.clear", "清除样式"), + ("mark.table.move_row_up", "上移该行"), + ("mark.table.move_row_down", "下移该行"), + ("mark.table.move_column_left", "左移该列"), + ("mark.table.move_column_right", "右移该列"), + ("mark.table.tidy", "格式化表格源码"), + ("mark.table.edge", "这一方向已到表格边缘"), + ("mark.view.source", "源代码模式"), + ("mark.copy.markdown", "复制为 Markdown"), + ("mark.paste.plain", "粘贴为纯文本"), + ("mark.paste.none", "剪贴板里没有文字"), + ("mark.refused.code", "代码里用不了这条命令"), + ("mark.refused.math", "公式里用不了这条命令"), + ("mark.refused.html", "HTML 里用不了这条命令"), + ("mark.refused.front_matter", "front matter 里用不了这条命令"), + ("mark.refused.outside", "请先把插入点放进段落、标题或单元格"), + ("mark.refused.table", "表格里用不了这条命令"), + ("mark.refused.present", "文件已有 front matter"), + ("mark.refused.across", "选区跨过了块、链接或别的格式"), + ( + "mark.refused.too_long", + "文件太长,无法解析;请在源码里编辑", + ), + ("mark.refused.busy", "上一次编辑还在处理"), + ("mark.refused.changed", "编辑完成前文字已改变,请重试"), + ("mark.refused.misread", "这样会改变文字的读法,未作改动"), +]; diff --git a/docs/help.md b/docs/help.md index fca1b33..1edac68 100644 --- a/docs/help.md +++ b/docs/help.md @@ -14,6 +14,15 @@ - 窗口变窄时,两侧栏先变窄,再先收起右栏、后收起左栏,文档区至少保留 360 点;窗口变宽即回来。侧栏的宽度可拖动内侧边缘调整,按应用记住。 - 侧栏放不下全部文字时,分页按钮只显示图标,悬停可见名称。 +## ElyMark 的菜单与快捷键 + +- 段落与格式菜单照 Typora。标题:Ctrl+1 至 Ctrl+6,Ctrl+0 段落,Ctrl+= 与 Ctrl+- 升降一级。块:Ctrl+Shift+K 代码块,Ctrl+Shift+M 公式块,Ctrl+Shift+Q 引用,Ctrl+Shift+[ 有序列表,Ctrl+Shift+] 无序列表,Ctrl+Shift+X 任务列表。 +- 格式:Ctrl+B 加粗,Ctrl+I 斜体,Ctrl+U 下划线,Ctrl+Shift+` 代码,Alt+Shift+5 删除线,Ctrl+K 超链接,Ctrl+Shift+I 插入图片,Ctrl+\ 清除样式。 +- 表格在“段落 ▸ 表格”:Ctrl+T 插入,Ctrl+Enter 下方插入行,Ctrl+Shift+Backspace 删除行,Alt+方向键移动行列,“格式化表格源码”对齐各列。 +- 视图:Ctrl+/ 在源代码与可视之间切换,F8 专注,F9 打字机,Ctrl+Shift+= 放大,Ctrl+Shift+- 缩小,Ctrl+Shift+0 实际大小;可视、源码、预览在“视图”菜单里选。 +- 编辑:Ctrl+Shift+C 复制为 Markdown(无选区时复制全文),Ctrl+Shift+V 粘贴为纯文本。 +- 命令用不了时(如在代码里设标题、插入点不在表格里),提示写明原因,文字不动。macOS 上 Ctrl 为 Cmd。 + ## 恢复 - 有未保存改动时,每 10 秒在本机数据目录写一个恢复点(加密的文档其恢复点也加密),只留在本机,不上传。 diff --git a/scripts/fetch-references.sh b/scripts/fetch-references.sh index 63cc3be..2942c8a 100755 --- a/scripts/fetch-references.sh +++ b/scripts/fetch-references.sh @@ -36,7 +36,7 @@ fetch() { # name url rev [sparse paths...] echo "fetch $name $rev" } -fetch Ely-GPUI-Components https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git 12512217bd9bc652ed6bbf10461b00cc26c01c21 +fetch Ely-GPUI-Components https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git 6b033f3eb05af53c3dd855057608e9e0cd7c8f4b fetch zed https://github.com/zed-industries/zed.git 1a28cff4b409169bac058bca40dfbfeb7621d19b fetch libreoffice-core https://github.com/LibreOffice/core.git 8221e31b3ac356a1623c672912a3d2b492f7e3d1 \ include/LibreOfficeKit libreofficekit desktop/source/lib desktop/inc \