R3.2: Ctrl+wheel zooms one step per notch; version 0.1.4 [skip ci]

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-12 23:15:47 -04:00
1 parent c1880288a5
commit ee1b8ad375
13 files changed
+274 -37

No files matched your search

Generated
+15 -15
View File
@@ -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",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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 整段换成 `<path>`:有引号到引号为止,否则到行末;分段标题只写文件名;账户全名、登录名与作者名按从长到短换成 `<user>`),导出为一个文本文件(`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 的锁与窗口位置。
+40 -1
View File
@@ -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);
}
}
+17 -1
View File
@@ -79,6 +79,8 @@ pub struct MarkView {
pub(crate) folder: Option<PathBuf>,
/// 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<Self>) -> impl IntoElement {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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)
}
}
+5 -2
View File
@@ -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.
+32 -1
View File
@@ -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)));
}
+11
View File
@@ -70,6 +70,8 @@ pub struct PdfView {
pub painting: bool,
/// The view's size and place, as last painted.
pub room: gpui::Bounds<gpui::Pixels>,
/// 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<Self>) {
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<Self>) {
let last = self.sizes.len().saturating_sub(1) as isize;
+40
View File
@@ -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);
});
});
}
+24 -13
View File
@@ -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<Self>) {
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<Self>) {
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<Self>) -> 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),
}
}
+1
View File
@@ -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"))]
+85
View File
@@ -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);
}
}
+2 -2
View File
@@ -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 审查略去)