diff --git a/Cargo.lock b/Cargo.lock index e8c40af..6c1a9c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1875,7 +1875,7 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "ely-agent" -version = "0.1.3" +version = "0.1.4" dependencies = [ "ely-platform", "ely-ui", @@ -1884,7 +1884,7 @@ dependencies = [ [[package]] name = "ely-doc" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "csv", @@ -1906,7 +1906,7 @@ dependencies = [ [[package]] name = "ely-files" -version = "0.1.3" +version = "0.1.4" dependencies = [ "aes-gcm", "dunce", @@ -1968,14 +1968,14 @@ dependencies = [ [[package]] name = "ely-ipc" -version = "0.1.3" +version = "0.1.4" dependencies = [ "log", ] [[package]] name = "ely-lok" -version = "0.1.3" +version = "0.1.4" dependencies = [ "base64 0.23.1", "dunce", @@ -1996,7 +1996,7 @@ dependencies = [ [[package]] name = "ely-mail" -version = "0.1.3" +version = "0.1.4" dependencies = [ "ely-platform", "ely-ui", @@ -2005,7 +2005,7 @@ dependencies = [ [[package]] name = "ely-mark" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "base64 0.23.1", @@ -2030,7 +2030,7 @@ dependencies = [ [[package]] name = "ely-office" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "directories", @@ -2051,7 +2051,7 @@ dependencies = [ [[package]] name = "ely-pdf" -version = "0.1.3" +version = "0.1.4" dependencies = [ "dunce", "ely-files", @@ -2069,7 +2069,7 @@ dependencies = [ [[package]] name = "ely-pdf-engine" -version = "0.1.3" +version = "0.1.4" dependencies = [ "cc", "ely-ipc", @@ -2085,7 +2085,7 @@ dependencies = [ [[package]] name = "ely-platform" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "arboard", @@ -2119,7 +2119,7 @@ dependencies = [ [[package]] name = "ely-show" -version = "0.1.3" +version = "0.1.4" dependencies = [ "ely-files", "ely-gpui-component", @@ -2139,7 +2139,7 @@ dependencies = [ [[package]] name = "ely-table" -version = "0.1.3" +version = "0.1.4" dependencies = [ "ely-gpui-component", "ely-lok", @@ -2156,7 +2156,7 @@ dependencies = [ [[package]] name = "ely-tree" -version = "0.1.3" +version = "0.1.4" dependencies = [ "dunce", "ely-files", @@ -2176,7 +2176,7 @@ dependencies = [ [[package]] name = "ely-ui" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "directories", diff --git a/Cargo.toml b/Cargo.toml index 6ffeb62..36aab30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["apps/*", "crates/*"] exclude = ["engines", "reference"] [workspace.package] -version = "0.1.3" +version = "0.1.4" edition = "2024" rust-version = "1.95" license = "AGPL-3.0-or-later" diff --git a/agents.md b/agents.md index 24c12b9..13a2598 100644 --- a/agents.md +++ b/agents.md @@ -236,7 +236,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-08:数据管理(C18.3,§18.3)。设置的“存储”页(每个应用):本应用数据目录里的日志(`ely.log`)、恢复副本(`recovery`)各自的大小与清除(`ely_platform::storage`);日志当场清空(日志器以追加打开,截短不坏句柄),恢复副本在用着,写下记号(`.clear-recovery`),下次启动在设置与窗口之前删去(删不掉即启动失败、写明原因,记号留着下次再试,不打开没删掉的数据);每次清除先问,写明删什么、何时删;每个文档保留的恢复点(10、20、50)与空间(32、64、256 MB)存在应用设置的 `recovery`(严格读,不合即报错),ElyMark 写恢复点时照用(C06.5 的文档应用同)。日志超过 8 MB 时,启动时只留后 4 MB、从整行起。数据库的升级前备份与失败只读只属 ElyMail(第二批),首版的六个应用没有数据库;邮件与 Agent 历史导出同属第二批。组件库 ea4ed4c:`SettingsRow` 是以标题命名、以说明描述的组(原先行标题不进 AX 树,两个同名按钮分不出);待清除的状态以禁用的按钮写明。 - 2026-10-08(用户定:图标照 Papirus 重画):每个应用是 Papirus 192 网格上的一个大的扁平物件(页、讲台、信封、终端),主体一种饱和色,折角为其浅色,内容为白色、至多一个点缀色,主体与内容带 Papirus 的柔和投影(下移 4 或 2、模糊、20% 黑)。macOS 为浅色 squircle 上的同一物件(占主体 86%),Linux 与 Windows 只有物件。48 像素以下用无投影、无小细节的简化版。母版由 `packaging/icons/masters.py` 写出,配色见 `assets/icons/README.md`。Papirus 图标包(`assets/papirus_icons-master.zip`,用户放入)只作参照,不随包。 - 2026-10-08(用户要求核对各系统的文件关联):文件类型只写在 `packaging/types.tsv`(应用、扩展名、MIME;`/` 为文件夹)。Linux 的桌面项由它写 `MimeType=`(原先没有,桌面环境从不提供 Ely 打开文件);Windows 安装程序由 `packaging/windows/types.sh` 生成的 NSIS 宏注册(原先没有):HKCU 下每个扩展名的 `OpenWithProgids` 与 `Applications\<程序>\SupportedTypes`,ProgID `ElyOffice.<名称>` 带图标与打开命令,ElyTree 在文件夹右键菜单加“Open in ElyTree”;只进“打开方式”,从不改默认(同 macOS 的 Alternate),卸载时删去。makensis 在本机编过 ElyDoc 与 ElyTree 的安装程序;Windows 与 Linux 上实际打开文件未验证。 -- 2026-10-08(用户定:窗口缩窄时控件挤成一团、越出边框,不可接受):窄窗口的版面。外壳一次排两侧栏(`shell/panel.rs` 的 `room`,有单测):内容区至少留 360,放不下时两侧先缩到各自最窄,再先藏右栏、后藏左栏,窗口变宽即回来,用户拖的宽度不变。组件库 6d95b24 至 69265b4:`ScrollArea::vertical` 只纵向滚动、内容不得宽于区域(侧栏一律用它,原先横向不受限,整栏按自然宽度排后被裁);`PropertyGrid` 每行可折行,放不下时控件到标签下方,标签列 104;全部图标的 `SegmentedControl` 与 `Tabs` 放不下全部文字时只显示图标,文字作提示与无障碍名(宽度取上一帧量得的条宽与文字排版宽度);`StatusBar` 两侧与各项可缩,文字以省略号结尾;`Banner` 文字折行;`Button` 不再拒绝收缩,只在无法折行时缩并以省略号结尾;未聚焦的单行文本框显示文字开头(原插入点在末尾,看到的是尾部);`Switch::name` 只给无障碍名(行已有标签时不再重复画字);`CommitInput` 的勾选与按钮一行可折。各应用固定宽度的控件框(`w_32` 等)改为占满、以原宽为上限。`tests/scripts/layout.sh`:六个应用各开样本,640、800、1100 宽下读 AX 树,任何元素越出窗口左右即失败,并留截图供目视(栏内裁切 AX 看不出)。缩放(用户问为何 Doc、Table、Show 不能缩放;视图早有 `set_zoom`,界面从未接上):`ely_office::Zooming`,Cmd+= / Cmd+- / Cmd+0,“视图”菜单三项,状态栏右侧 `ZoomControl` 的属主模式(组件 69265b4:显示属主给的百分比、按下交属主;原只缩放整个界面);Cmd+滚轮与触控板捏合以指针处为中心缩放(`view/zoom.rs`,档位 25% 至 400%);ElyShow 的 Cmd+0 回到适合幻灯片,手动缩放即停止适合。缩放只在内存里随标签,不跨重启。 +- 2026-10-08(用户定:窗口缩窄时控件挤成一团、越出边框,不可接受):窄窗口的版面。外壳一次排两侧栏(`shell/panel.rs` 的 `room`,有单测):内容区至少留 360,放不下时两侧先缩到各自最窄,再先藏右栏、后藏左栏,窗口变宽即回来,用户拖的宽度不变。组件库 6d95b24 至 69265b4:`ScrollArea::vertical` 只纵向滚动、内容不得宽于区域(侧栏一律用它,原先横向不受限,整栏按自然宽度排后被裁);`PropertyGrid` 每行可折行,放不下时控件到标签下方,标签列 104;全部图标的 `SegmentedControl` 与 `Tabs` 放不下全部文字时只显示图标,文字作提示与无障碍名(宽度取上一帧量得的条宽与文字排版宽度);`StatusBar` 两侧与各项可缩,文字以省略号结尾;`Banner` 文字折行;`Button` 不再拒绝收缩,只在无法折行时缩并以省略号结尾;未聚焦的单行文本框显示文字开头(原插入点在末尾,看到的是尾部);`Switch::name` 只给无障碍名(行已有标签时不再重复画字);`CommitInput` 的勾选与按钮一行可折。各应用固定宽度的控件框(`w_32` 等)改为占满、以原宽为上限。`tests/scripts/layout.sh`:六个应用各开样本,640、800、1100 宽下读 AX 树,任何元素越出窗口左右即失败,并留截图供目视(栏内裁切 AX 看不出)。缩放(用户问为何 Doc、Table、Show 不能缩放;视图早有 `set_zoom`,界面从未接上):`ely_office::Zooming`,Cmd+= / Cmd+- / Cmd+0,“视图”菜单三项,状态栏右侧 `ZoomControl` 的属主模式(组件 69265b4:显示属主给的百分比、按下交属主;原只缩放整个界面);Cmd(Windows、Linux 为 Ctrl)加滚轮与触控板捏合以指针处为中心缩放,滚轮一格一档(`ely_ui::wheel`,2026-10-12 起;原认 Windows 键、一格三档),ElyPDF 与 ElyMark 同(`view/zoom.rs`,档位 25% 至 400%);ElyShow 的 Cmd+0 回到适合幻灯片,手动缩放即停止适合。缩放只在内存里随标签,不跨重启。 - 2026-10-08(用户定:侧栏太占地方,要可调):侧栏宽度(C05.6)。外壳的左右侧栏(`shell/panel.rs`,八个应用共用)默认左 220、右 320(原取 `sidebar_width` 240 与 `sheet_size` 380;右 320 是 ElyDoc 属性栏不截断控件的最窄宽度,实测 280 截断数值框);内侧边缘是组件库的 `layout::resize_edge`,拖动改宽,左至少 160、右至少 280(原 220,属性栏放不下)、至多窗口的 45%;拖动停 0.5 秒后存入应用设置的 `panels`(`settings::panels`),退出时还没存的当场写入(窗口位置同),启动时严格读回(不是正数即在设置窗口写明并用默认),写入失败也在设置窗口写明;开合动画期间不显示拖动边。 - 2026-10-08:凭据与私密内容(C18.2,§18.2)。恢复密钥(加密文档的恢复副本用)存在系统凭据库(keyring:钥匙串、Windows 凭据、Secret Service);凭据库不可用或拒绝时改用本次运行的随机密钥,只在内存(`RecoveryKey { key, lasting }`,`lasting` 为假即提示恢复不跨重启),从不写成普通文件;用旧密钥封存的恢复点读不开即报“无法恢复”,不导出明文。PDF 打开密码与证书密码只在 worker 内存里、文档关闭即弃。日志:worker 的 stderr 经 `ely_ipc::relay` 按其级别并入应用日志(原先从访达启动时 worker 的日志丢失),两个 worker 与应用一样读 `ELY_LOG`、默认 info(LOK worker 原读 `RUST_LOG`,平时只出错误);日志调用不打印正文、密码、密钥;引擎(worker、内核、MuPDF)给的错误串可能引用文档,日志只记请求号与错误类别,全文只经回复交给界面;worker 的 stderr 只放行其结构化日志记录与崩溃栈帧,引擎自己打印的文字不进日志(只计数)。worker 启动失败也写成结构化记录,最后一条错误由 `Relay::last_error` 带进客户端的报错。诊断导出时姓名来源(账户名、设置)读不出即不导出并写明原因;日志尾部从整行开始。诊断(设置里的“诊断”,每个应用):先列出要导出的内容(本应用日志的最新 4 MB、macOS 上 7 天内本应用与其 worker 的崩溃报告,列表在 AX 树里),开关“去掉路径与个人姓名”默认开(凡路径与 URL 整段换成 ``:有引号到引号为止,否则到行末;分段标题只写文件名;账户全名、登录名与作者名按从长到短换成 ``),导出为一个文本文件(`ely_platform::diagnostics`),不上传。Git 服务的凭据照用用户自己的 credential helper 与 ssh-agent;打开文件不发任何请求。未验证:Windows 与 Linux 的凭据库与崩溃报告位置(那两端诊断只含日志)。 - 2026-10-08:配置(C18.1,§18.1)。通用偏好在 `<本机数据目录>/ElyOffice/settings.json`,各应用的在其数据目录的 `settings.json`;都经 `ely_platform::settings::Store`:带 `version`(本构建为 1;更新的版本或读不通即报错,不覆盖),写入在同目录的 `.settings.lock`(`ely_platform::lock::FileLock`,flock 或 LockFileEx,由 ely-files 移来共用)下先重读、只改所写的键、未知字段原样留下,临时文件 fsync 后原子替换;多个应用同时写不丢字段(单测八路并发)。通用字段(`settings::Shared`,类型不对即报错):外观(跟随系统、浅色、深色)、语言(跟随系统、English、简体中文;下次启动生效,设置里写明)、动效(跟随系统、减少、完整)、跟随 Omarchy(只在有 Omarchy 时显示)、作者名(空即账户名;`user::name` 先取它,ElyPDF 的批注、LOK 的批注与修订都用它)。环境变量 `ELY_APPEARANCE`、`ELY_OMARCHY_SYNC`、`ELY_REDUCE_MOTION`、`ELY_LOCALE` 仍优先。`ely_ui::settings` 启动时读入(读不出即用默认值、记下原因),以 `notify` 监视文件夹,别的应用写入后即刻重读:外观、Omarchy、动效当场换,作者名经 `observe_global` 在后台交给 LOK worker(`set_author`)。设置窗口(应用菜单“设置…”,Cmd+,,每个应用一个)用组件库 `SettingsLayout`、`SettingsSection`、`SettingsRow`、`ThemeSelector`(组件 489ef31 起三项文字进内建中英目录)、`SegmentedControl`、`Switch`、`forms::Input`:通用;Doc、Table、Show、PDF 另有“文档”(作者名,失焦或回车写入);ElyTree 另有“Git”(Git 程序,空即随带的;写入前在后台核对 2.40 或更新,不是即在框下以 Banner 说明、不写;`ely_platform::git::locate` 次序为 `ELY_GIT`、此设置、随带的,版本核对移入 `ely_platform::git::system_version`,下次启动 ElyTree 生效)。文件读不出时窗口上方以 Banner 写明原因,改动不保存。作者名与 Git 程序每次提交都编号,写入经 `Store::set_if` 在文件锁下核对仍是最新的提交才落盘(等锁期间改了主意,旧写入作废);写入失败另以 Banner 写明,键入的值留着可重试;作者框在未编辑时跟随别的应用写入的名字。版本核对先看 `--version` 的退出状态(`git::version_of`)。各应用主窗口的位置、大小与最大化、全屏存在应用的设置里(`window`,停止移动 0.5 秒后写),启动时所记之处的中心仍在某块显示器上才用。未验证:Windows 与 Linux 的锁与窗口位置。 diff --git a/apps/ely-mark/src/look.rs b/apps/ely-mark/src/look.rs index 3b1c042..1e53e3f 100644 --- a/apps/ely-mark/src/look.rs +++ b/apps/ely-mark/src/look.rs @@ -195,7 +195,10 @@ impl Desk { #[cfg(test)] mod tests { use ely_gpui_component::documents::DocumentLook; - use gpui::{Entity, TestAppContext, VisualTestContext, rems}; + use gpui::{ + Entity, Modifiers, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, + point, rems, + }; use crate::desk::{ Desk, @@ -275,4 +278,40 @@ mod tests { (super::Width::Wide, super::Font::Serif, 120) ); } + + #[gpui::test] + fn ctrl_and_the_wheel_size_the_text(cx: &mut TestAppContext) { + let (data, files) = ( + tempfile::tempdir().expect("data"), + tempfile::tempdir().expect("files"), + ); + let path = files.path().join("a.md"); + std::fs::write(&path, "a +").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)); + let at = cx.update(|window, _| window.viewport_size()).center(); + let wheel = |modifiers, y| ScrollWheelEvent { + position: at, + delta: ScrollDelta::Lines(point(0.0, y)), + modifiers, + ..Default::default() + }; + let other = match cfg!(target_os = "macos") { + true => Modifiers { control: true, ..Default::default() }, + false => Modifiers { platform: true, ..Default::default() }, + }; + cx.simulate_event(wheel(other, 3.0)); + cx.run_until_parked(); + assert_eq!(document_look(&desk, cx).scale, 1.0, "only Ctrl, or Cmd on macOS"); + cx.simulate_event(wheel(Modifiers::secondary_key(), 3.0)); + cx.run_until_parked(); + assert_eq!(document_look(&desk, cx).scale, 1.1, "a notch is one step"); + cx.simulate_event(wheel(Modifiers::secondary_key(), -1.0)); + cx.simulate_event(wheel(Modifiers::secondary_key(), -1.0)); + cx.run_until_parked(); + assert_eq!(document_look(&desk, cx).scale, 0.9); + } } diff --git a/apps/ely-mark/src/view.rs b/apps/ely-mark/src/view.rs index fb9293b..c4c7e9f 100644 --- a/apps/ely-mark/src/view.rs +++ b/apps/ely-mark/src/view.rs @@ -79,6 +79,8 @@ pub struct MarkView { pub(crate) folder: Option, /// The mode first took input (PERF-07). pub(crate) shown: bool, + /// Trackpad pixels toward the next size step. + wheel: f32, pub focus: FocusHandle, } @@ -123,6 +125,7 @@ impl MarkView { mode, folder: None, shown: false, + wheel: 0.0, focus, } } @@ -301,11 +304,24 @@ impl MarkView { } impl Render for MarkView { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + // Ctrl and the wheel size text, as Ctrl+= does. + let zoom = ely_ui::wheel::zoom_layer(move |event, window, cx| { + let steps = view.update(cx, |view, _| ely_ui::wheel::steps(event, &mut view.wheel)); + for _ in 0..steps.unsigned_abs() { + match steps > 0 { + true => window.dispatch_action(Box::new(crate::look::Larger), cx), + false => window.dispatch_action(Box::new(crate::look::Smaller), cx), + } + } + }); div() + .relative() .size_full() .track_focus(&self.focus) .child(self.document.clone()) + .child(zoom) } } diff --git a/apps/ely-pdf/src/pages.rs b/apps/ely-pdf/src/pages.rs index b0eb542..b5f5fb2 100644 --- a/apps/ely-pdf/src/pages.rs +++ b/apps/ely-pdf/src/pages.rs @@ -198,7 +198,7 @@ impl PdfView { }) .collect(); let this = cx.entity(); - let (down, moved, up) = (this.clone(), this.clone(), this.clone()); + let (down, moved, up, wheeled) = (this.clone(), this.clone(), this.clone(), this.clone()); let pages = div() .id("pdf-pages") .size_full() @@ -249,7 +249,10 @@ impl PdfView { .top_0() .left_0() .size_full(); - div().relative().size_full().child(pages).child(measure).into_any_element() + let zoom = ely_ui::wheel::zoom_layer(move |event, _, cx| { + wheeled.update(cx, |view, cx| view.wheel_zoom(event, cx)) + }); + div().relative().size_full().child(pages).child(measure).child(zoom).into_any_element() } /// Asks for the first missing tile, one at a time. diff --git a/apps/ely-pdf/src/tests/view.rs b/apps/ely-pdf/src/tests/view.rs index 975ea9a..af3bea1 100644 --- a/apps/ely-pdf/src/tests/view.rs +++ b/apps/ely-pdf/src/tests/view.rs @@ -1,7 +1,7 @@ //! Password prompts, zoomed pairs, jumps across (C11.1). use ely_office::{Dialog, Dialogs as _, Tabs as _}; -use gpui::{Entity, TestAppContext, VisualTestContext, point, px}; +use gpui::{Entity, Modifiers, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point, px}; use super::{file, frames, settle, shown, windowed}; use crate::layout::{Mode, Zoom}; @@ -113,3 +113,34 @@ fn a_jump_to_the_right_page_brings_it_across() { }); assert!(x >= left && x < left + wide, "page at {x}, view {left}..{}", left + wide); } + +#[test] +fn ctrl_and_a_wheel_notch_step_the_zoom_once() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"]), (612.0, 792.0, &["three"])]; + let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "three.pdf", pages)], true); + let view = shown(cx, &viewer); + settle(cx, |app| view.read(app).room.size.width > px(100.0)); + cx.update(|_, app| view.update(app, |view, cx| view.change(|view, _| view.zoom = Zoom::Scale(1.0), cx))); + frames(cx); + let at = cx.update(|_, app| view.read(app).room.center()); + let wheel = |modifiers, y| ScrollWheelEvent { position: at, delta: ScrollDelta::Lines(point(0.0, y)), modifiers, ..Default::default() }; + let other = match cfg!(target_os = "macos") { + true => Modifiers { control: true, ..Default::default() }, + false => Modifiers { platform: true, ..Default::default() }, + }; + let read = |cx: &mut VisualTestContext| cx.update(|_, app| (view.read(app).zoom, view.read(app).scroll.offset().y)); + cx.simulate_event(wheel(other, -3.0)); + frames(cx); + let (zoom, scrolled) = read(cx); + assert_eq!(zoom, Zoom::Scale(1.0), "only Ctrl, or Cmd on macOS"); + assert!(scrolled < px(0.0), "a plain wheel scrolls"); + cx.simulate_event(wheel(Modifiers::secondary_key(), 3.0)); + frames(cx); + assert_eq!(read(cx).0, Zoom::Scale(crate::keys::stepped(1.0, true)), "a notch is one step"); + cx.simulate_event(wheel(Modifiers::secondary_key(), -3.0)); + cx.simulate_event(wheel(Modifiers::secondary_key(), -3.0)); + frames(cx); + assert_eq!(read(cx).0, Zoom::Scale(crate::keys::stepped(1.0, false))); +} diff --git a/apps/ely-pdf/src/view.rs b/apps/ely-pdf/src/view.rs index 8f9e212..ad0904f 100644 --- a/apps/ely-pdf/src/view.rs +++ b/apps/ely-pdf/src/view.rs @@ -70,6 +70,8 @@ pub struct PdfView { pub painting: bool, /// The view's size and place, as last painted. pub room: gpui::Bounds, + /// Trackpad pixels toward the next zoom step. + pub wheel: f32, pub focus: FocusHandle, /// Counts edits; replies from before one are dropped. pub version: u64, @@ -199,6 +201,7 @@ impl PdfView { go_to: None, painting: false, room: gpui::Bounds::default(), + wheel: 0.0, focus: cx.focus_handle(), version: 0, edited: 0, @@ -354,6 +357,14 @@ impl PdfView { self.show(self.current, 0.0, cx); } + /// Ctrl (Cmd on macOS) and the wheel step the zoom. + pub fn wheel_zoom(&mut self, event: &gpui::ScrollWheelEvent, cx: &mut Context) { + let steps = ely_ui::wheel::steps(event, &mut self.wheel); + for _ in 0..steps.unsigned_abs() { + self.change(|view, now| view.zoom = Zoom::Scale(crate::keys::stepped(now, steps > 0)), cx); + } + } + /// Moves `by` pages; continuous views scroll there. pub fn turn(&mut self, by: isize, cx: &mut Context) { let last = self.sizes.len().saturating_sub(1) as isize; diff --git a/crates/ely-lok/src/view/tests.rs b/crates/ely-lok/src/view/tests.rs index 20aa306..2c70c92 100644 --- a/crates/ely-lok/src/view/tests.rs +++ b/crates/ely-lok/src/view/tests.rs @@ -425,3 +425,43 @@ fn zooming_keeps_the_pointed_spot_and_walks_the_steps() { }); }); } + +#[test] +fn ctrl_and_a_notch_zoom_one_step_about_the_pointer() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let cx = gpui::TestAppContext::single(); + cx.update(|app| { + let (view, _) = live_view(app, &install); + view.update(app, |view, cx| { + view.viewport = (800.0, 600.0); + view.origin = gpui::point(gpui::px(0.0), gpui::px(0.0)); + view.doc_size = (60_000, 80_000); + view.scroll = (3_000, 9_000); + view.set_zoom(1.0, cx); + let at = gpui::point(gpui::px(300.0), gpui::px(200.0)); + let wheel = |modifiers, y| gpui::ScrollWheelEvent { + position: at, + delta: gpui::ScrollDelta::Lines(gpui::point(0.0, y)), + modifiers, + ..Default::default() + }; + let other = match cfg!(target_os = "macos") { + true => gpui::Modifiers { control: true, ..Default::default() }, + false => gpui::Modifiers { platform: true, ..Default::default() }, + }; + assert!(!view.wheel_zoom(&wheel(other, 3.0), cx), "only Ctrl, or Cmd on macOS"); + assert_eq!(view.zoom(), 1.0); + let spot = |view: &LokView| view.scroll.1 + view.geom.twips_of(200.0); + let before = spot(view); + assert!(view.wheel_zoom(&wheel(gpui::Modifiers::secondary_key(), 3.0), cx)); + assert_eq!(view.zoom(), 1.25, "a three-line notch is one step"); + assert!((spot(view) - before).abs() <= 1, "the pointed spot stays"); + view.wheel_zoom(&wheel(gpui::Modifiers::secondary_key(), -1.0), cx); + view.wheel_zoom(&wheel(gpui::Modifiers::secondary_key(), -1.0), cx); + assert_eq!(view.zoom(), 0.8); + }); + }); +} diff --git a/crates/ely-lok/src/view/zoom.rs b/crates/ely-lok/src/view/zoom.rs index b80ca07..4d10d17 100644 --- a/crates/ely-lok/src/view/zoom.rs +++ b/crates/ely-lok/src/view/zoom.rs @@ -32,12 +32,7 @@ impl LokView { /// The next step in or out from the zoom now. pub fn zoom_step(&mut self, larger: bool, cx: &mut Context) { - let now = self.geom.zoom; - let next = match larger { - true => ZOOM_STEPS.into_iter().find(|step| *step > now + 0.001).unwrap_or(MOST), - false => ZOOM_STEPS.into_iter().rev().find(|step| *step < now - 0.001).unwrap_or(LEAST), - }; - self.zoom_at(next, None, cx); + self.zoom_at(next_step(self.geom.zoom, larger), None, cx); } /// Zooms keeping `at` (window pixels) still; none keeps the middle. @@ -70,17 +65,25 @@ impl LokView { cx.notify(); } - /// Command with the wheel zooms; otherwise it scrolls. + /// Ctrl (Cmd on macOS) with the wheel zooms; else it scrolls. pub(super) fn on_wheel(&mut self, event: &ScrollWheelEvent, window: &mut Window, cx: &mut Context) { - if !event.modifiers.platform { - return self.on_scroll(event, window, cx); + if !self.wheel_zoom(event, cx) { + self.on_scroll(event, window, cx); } - let dy = match event.delta { - ScrollDelta::Pixels(point) => f32::from(point.y), - ScrollDelta::Lines(point) => point.y * 40.0, + } + + /// A notch is one step; a trackpad zooms smoothly. + pub(super) fn wheel_zoom(&mut self, event: &ScrollWheelEvent, cx: &mut Context) -> bool { + if !event.modifiers.secondary() { + return false; + } + let zoom = match event.delta { + ScrollDelta::Lines(lines) if lines.y == 0.0 => return true, + ScrollDelta::Lines(lines) => next_step(self.geom.zoom, lines.y > 0.0), + ScrollDelta::Pixels(point) => self.geom.zoom * (f32::from(point.y) / 200.0).exp(), }; - let zoom = self.geom.zoom * (dy / 200.0).exp(); self.zoom_at(zoom, Some(event.position), cx); + true } /// A trackpad pinch zooms about its centre. @@ -89,3 +92,11 @@ impl LokView { self.zoom_at(zoom, Some(event.position), cx); } } + +/// The next step in or out from `now`. +fn next_step(now: f32, larger: bool) -> f32 { + match larger { + true => ZOOM_STEPS.into_iter().find(|step| *step > now + 0.001).unwrap_or(MOST), + false => ZOOM_STEPS.into_iter().rev().find(|step| *step < now - 0.001).unwrap_or(LEAST), + } +} diff --git a/crates/ely-ui/src/lib.rs b/crates/ely-ui/src/lib.rs index 268e590..737c84c 100644 --- a/crates/ely-ui/src/lib.rs +++ b/crates/ely-ui/src/lib.rs @@ -8,6 +8,7 @@ pub mod tasks; mod theme; pub mod updates; pub mod watching; +pub mod wheel; pub use opening::{launch_files, on_open}; #[cfg(any(test, feature = "test-support"))] diff --git a/crates/ely-ui/src/wheel.rs b/crates/ely-ui/src/wheel.rs new file mode 100644 index 0000000..b81c1de --- /dev/null +++ b/crates/ely-ui/src/wheel.rs @@ -0,0 +1,85 @@ +//! Ctrl (Cmd on macOS) and the wheel step a zoom. + +use gpui::{ + App, DispatchPhase, HitboxBehavior, IntoElement, ScrollDelta, ScrollWheelEvent, Styled as _, + Window, canvas, +}; + +/// Trackpad pixels one step takes. +const STEP: f32 = 50.0; + +/// Steps `event` makes, plus is larger; `carry` keeps trackpad rest. +pub fn steps(event: &ScrollWheelEvent, carry: &mut f32) -> i32 { + match event.delta { + ScrollDelta::Lines(lines) => match lines.y { + 0.0 => 0, + y => y.signum() as i32, + }, + ScrollDelta::Pixels(point) => { + *carry += f32::from(point.y); + let whole = (*carry / STEP).trunc(); + *carry -= whole * STEP; + whole as i32 + } + } +} + +/// Over its parent, takes a zooming wheel before the parent scrolls. +pub fn zoom_layer( + on: impl Fn(&ScrollWheelEvent, &mut Window, &mut App) + 'static, +) -> impl IntoElement { + canvas( + |bounds, window, _| window.insert_hitbox(bounds, HitboxBehavior::Normal), + move |_, hitbox, window, _| { + window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| { + if phase == DispatchPhase::Capture + && event.modifiers.secondary() + && hitbox.is_hovered(window) + { + cx.stop_propagation(); + on(event, window, cx); + } + }) + }, + ) + .absolute() + .top_0() + .left_0() + .size_full() +} + +#[cfg(test)] +mod tests { + use gpui::{ScrollDelta, ScrollWheelEvent, point, px}; + + use super::steps; + + fn wheel(delta: ScrollDelta) -> ScrollWheelEvent { + ScrollWheelEvent { + delta, + ..Default::default() + } + } + + #[test] + fn a_notch_is_one_step_and_pixels_add_up() { + let mut carry = 0.0; + assert_eq!( + steps(&wheel(ScrollDelta::Lines(point(0.0, 3.0))), &mut carry), + 1 + ); + assert_eq!( + steps(&wheel(ScrollDelta::Lines(point(0.0, -1.0))), &mut carry), + -1 + ); + assert_eq!( + steps(&wheel(ScrollDelta::Lines(point(2.0, 0.0))), &mut carry), + 0 + ); + let pixels = |y| wheel(ScrollDelta::Pixels(point(px(0.0), px(y)))); + assert_eq!(steps(&pixels(30.0), &mut carry), 0); + assert_eq!(steps(&pixels(30.0), &mut carry), 1); + assert_eq!(carry, 10.0); + assert_eq!(steps(&pixels(-120.0), &mut carry), -2); + } +} diff --git a/tasks/TASKS.md b/tasks/TASKS.md index 7f77b5a..52d15f7 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -225,6 +225,6 @@ ## R3 0.1.4(用户 2026-10-12 报:Windows 与 Linux 上的路径、缩放、表格指针;修完提交、推送,版本 0.1.4,不发布) - [x] R3.1 路径不带 `\?\`:凡取真实路径一律 `dunce::canonicalize`(Windows 上能写成普通路径即普通路径,他处同 `std::fs::canonicalize`);最近列表读入时去掉旧的 `\?\` 并去重;ElyMark 最近列表、ElyTree 等界面不再显示 `\?\` — R1 先简化文件夹再接文件名(结尾空格的名字改写已打开的文件)、暂存文件名越过 260;R2 另存后会话路径与测试不一;R3 PASS -- [ ] R3.2 缩放:Doc、Table、Show、PDF、Mark 以 Ctrl 加滚轮(macOS 为 Cmd)缩放,原认 Windows 键与 Super(Hyprland 占用,Linux 上无效);滚轮一格为一档(原 Windows 一格三行,一下放大 1.8 倍),触控板的像素滚动照旧连续(LOK)或累积成档(PDF、Mark);PDF 与 Mark 原无滚轮缩放 +- [x] R3.2 缩放:Doc、Table、Show、PDF、Mark 以 Ctrl 加滚轮(macOS 为 Cmd)缩放,原认 Windows 键与 Super(Hyprland 占用,Linux 上无效);滚轮一格为一档(原 Windows 一格三行,一下放大 1.8 倍),触控板的像素滚动照旧连续(LOK)或累积成档(PDF、Mark);PDF 与 Mark 原无滚轮缩放 - [ ] R3.3 LOK 视图的指针随内核:`LOK_CALLBACK_MOUSE_POINTER`(单元格上为箭头,编辑单元格或文字时为文字指针,边线为拖动指针,链接为手),原一律为文字指针 -- [ ] R3.4 版本 0.1.4:`Cargo.toml` 与随版本的文档,提交并推送 main(不打标签、不发布) +- [ ] R3.4 版本 0.1.4:`Cargo.toml` 与随版本的文档,提交并推送 main,打标签 v0.1.4,本机建三端并发布(用户定,R3.2 审查略去)