diff --git a/agents.md b/agents.md index 84ae304..ad053e6 100644 --- a/agents.md +++ b/agents.md @@ -303,3 +303,5 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-15(用户报:ElyMark 代码块后的“你好↵测试”离开输入框后成了“你好 测试”):单个换行照 Typora 的默认(编辑时保留,导出与打印不保留):组件库 93efa7f 起 `MarkdownReader::new`(屏上:可视、预览、右键与命令的读法)把软换行读作换行,`MarkdownReader::joined`(输出)照 GFM 读作空格;标题的大纲与目录文字照旧一行。ElyMark 的 HTML、PDF、富文本复制与纯文本导出用 `joined`(R6.7)。组件库 c31d092 与 26af4be:画出成一个换行的 CRLF 对回其起点(原先按在 CRLF 行末键入落在 CR 与 LF 之间),行内代码照留两字则逐字对回。 - 2026-10-15(用户报:ElyMark 源码模式“测”后的插入点画在“测”的中间):组件库 `CodeEditor` 原按“列 × m 的宽”放插入点、选区、按下处与输入法框,中日韩字与表情约宽两格。组件库 9fb607b 起按 gpui 排好的这一行定位(`editor::measure`,含着色带来的字重),块状与下划线插入点取所在字的宽;标尺与缩进参考线仍按列(R6.8)。上下移动仍按字数记目标列。cd61653:按下处取最近的字首或行末(gpui 的 `closest_index_for_x` 越过末字字首即给行末);上次绘制得出的括号与配对位置只属当时的文字(`Buffer` 每次改动换一个戳),输入法改字后、下次绘制前的测量不用它们(原先把新字切进不同字体段,DirectWrite 崩溃)。 - 2026-10-15(用户报:ElyMark 三、四级标题不显眼):组件库 5954132 起标题字号随正文按 Typora 的倍数(一至六级 2.25、1.75、1.5、1.25、1、1),一律加粗,六级为次要色,行高为字号的 1.3 倍(原为固定的 24、20、16、14 像素,正文 13,三、四级只是中等字重)(R6.9)。90bd37b:粗体与标题同一字重(原粗体为半粗,标题里的粗体反而更细,正文粗体随之由半粗改为粗,同 Typora);标题行高按自身字号取 1.3 倍。Windows 真窗口核对(`.work/r69`):可视模式各级标题分明、“你好↵测试”两行,源码模式“测”后的插入点落在两字之间。 +- 2026-10-15(用户报:macOS 打开一个文档,程序坞里有两个同样的图标):LOK worker 在应用包的 `Contents/MacOS` 里启动 AppKit,系统按包的 Info.plist 把它也登记为常规应用(`lsappinfo` 里应用与 worker 都是 Foreground),程序坞多一个图标;内核自己从不设激活策略。worker 在内核启动前(`Lok::hook` 打开内核之后、调钩子之前)载入同一目录的 Aqua 插件 `libvclplug_osxlo.dylib`,取内核自己的 `VCL_NSApplication`,设为辅助应用(`NSApplicationActivationPolicyAccessory`:无程序坞图标、无菜单栏,仍可建屏外窗口),内核随后复用同一实例;类不在或设不上即报错,不启动(`ffi/accessory.rs`)。实测(Mac mini):worker 从未是 Foreground,登记即为 UIElement,图块照出(R6.12)。 +- 2026-10-15(用户报:ElyShow 编辑区滚轮与上下键不换页,三端):内核在未选中对象时把上下键当作滚动窗口(`fupoor.cxx`),Ely 的画布整页适合、无处可滚,滚轮同样。`LokView::turn_parts`(ElyShow 首次适合时调用)之后:整页可见时滚轮换页(`view/turn.rs`:每格一页;触控板一次手势只换一页,惯性不算,静止 250 毫秒或新手势再计),未选中对象、未编辑文字时上下键换页;视图发 `Turn`,ElyShow 从视图所示的页起算(连续换页累加),经缩略图条同一路径 `show_part`,到首末页即停;后台读回的当前页只在视图已无待落地输入时采用,排队的换页不被先前的读取拉回(R6.13)。 diff --git a/apps/ely-show/src/deck.rs b/apps/ely-show/src/deck.rs index 8f66376..46007fc 100644 --- a/apps/ely-show/src/deck.rs +++ b/apps/ely-show/src/deck.rs @@ -95,7 +95,11 @@ impl Host for Deck { && !tab.extra.fitted { tab.extra.fitted = true; - view.update(cx, |view, cx| view.fit_page(cx)); + view.update(cx, |view, cx| { + view.fit_page(cx); + view.turn_parts(); + }); + cx.subscribe(view, move |deck, view, turn: &ely_lok::Turn, cx| deck.turn(id, &view, turn.0, cx)).detach(); } // Pictures follow the kernel's light or dark scheme. let dark = view.read(cx).dark(); @@ -263,7 +267,12 @@ impl Deck { Ok(((slides, at), (design, objects, notes, remarks, (motion, media)), size, history)) => { tab.extra.read_at = Some(at); log::info!("show: tab {id} has {} parts", slides.list.len()); - view.update(cx, |view, _| view.set_part(slides.current)); + // A queued turn outranks a read made before it. + view.update(cx, |view, _| { + if view.settled() { + view.set_part(slides.current) + } + }); let names: HashSet<&String> = slides.list.iter().map(|slide| &slide.name).collect(); tab.extra.keep_pictures(&names); diff --git a/apps/ely-show/src/slides.rs b/apps/ely-show/src/slides.rs index 064a12a..0d9733c 100644 --- a/apps/ely-show/src/slides.rs +++ b/apps/ely-show/src/slides.rs @@ -9,10 +9,11 @@ use ely_gpui_component::{ }; use ely_office::prelude::*; use gpui::{ - Action, App, Context, Div, InteractiveElement as _, IntoElement, Menu, MenuItem, + Action, App, Context, Div, Entity, InteractiveElement as _, IntoElement, Menu, MenuItem, ParentElement as _, Render, RenderImage, Role, StatefulInteractiveElement as _, Styled as _, WeakEntity, Window, actions, div, img, prelude::FluentBuilder as _, px, }; +use ely_lok::LokView; use image::{Frame, ImageBuffer, Rgba}; use crate::deck::Deck; @@ -98,6 +99,19 @@ impl Deck { self.run_active(id, &[], cx); } + /// Shows the slide `step` away, as the strip would. + pub(crate) fn turn(&mut self, id: u64, view: &Entity, step: i32, cx: &mut Context) { + let Some(count) = self.tab(id).map(|tab| tab.extra.slides.list.len()).filter(|count| *count > 0) else { + return; + }; + // From the part asked last, so quick turns add up. + let shown = view.read(cx).part(); + let part = (shown as i64 + i64::from(step)).clamp(0, count as i64 - 1) as usize; + if part != shown { + view.update(cx, |view, _| view.show_part(part)); + } + } + /// Paints the next missing or stale slide picture. pub fn paint_next(&mut self, id: u64, cx: &mut Context) { let Some(tab) = self.tab(id) else { diff --git a/apps/ely-show/src/tests.rs b/apps/ely-show/src/tests.rs index f3d72df..4d14f83 100644 --- a/apps/ely-show/src/tests.rs +++ b/apps/ely-show/src/tests.rs @@ -19,6 +19,7 @@ mod play_time; mod review; mod slides; mod strip; +mod turn; /// An unrendered Deck over a fresh worker, if LOK exists. pub(super) fn deck(cx: &mut TestAppContext, dir: &Path) -> Option> { diff --git a/apps/ely-show/src/tests/turn.rs b/apps/ely-show/src/tests/turn.rs new file mode 100644 index 0000000..4de7bf3 --- /dev/null +++ b/apps/ely-show/src/tests/turn.rs @@ -0,0 +1,62 @@ +use ely_lok::LokView; +use ely_office::prelude::*; +use gpui::{Entity, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point}; + +use super::objects::blank; +use super::{counts, quiet, settle, update}; +use crate::deck::Deck; + +/// The slide canvas alone in a window. +struct Host(Entity); + +impl gpui::Render for Host { + fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context) -> impl gpui::IntoElement { + use gpui::{ParentElement as _, Styled as _}; + gpui::div().size_full().child(self.0.clone()) + } +} + +/// The current slide once every turn landed. +fn current(shown: &mut VisualTestContext, deck: &Entity) -> usize { + quiet(shown, deck); + shown.update(|_, app| deck.read(app).active_tab().map(|tab| tab.extra.slides.current).expect("a tab")) +} + +fn wheel(shown: &mut VisualTestContext, lines: f32) { + let middle = shown.update(|window, _| window.viewport_size()).center(); + let delta = ScrollDelta::Lines(point(0.0, lines)); + shown.simulate_event(ScrollWheelEvent { position: middle.into(), delta, ..Default::default() }); +} + +#[test] +fn the_wheel_and_up_down_turn_slides() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + return eprintln!("SKIP: no LOK build"); + }; + for n in 2..=3 { + update(&mut cx, &deck, |deck, cx| deck.object_command("slide.insert", &[], cx)); + settle(&cx, |app| counts(app, &deck).0 == n); + } + let view = cx.update(|app| deck.read(app).active_tab().expect("a tab").view.clone()); + cx.update(|app| view.update(app, |view, _| view.show_part(0))); + let window = cx.add_window(|_, _| Host(view.clone())); + let mut shown = VisualTestContext::from_window(window.into(), &cx); + shown.run_until_parked(); + assert_eq!(current(&mut shown, &deck), 0); + wheel(&mut shown, -1.0); + assert_eq!(current(&mut shown, &deck), 1, "the wheel turns down"); + shown.update(|window, app| { + let focus = view.read(app).focus_handle().clone(); + window.focus(&focus, app) + }); + shown.simulate_keystrokes("down"); + assert_eq!(current(&mut shown, &deck), 2, "Down turns"); + shown.simulate_keystrokes("down"); + assert_eq!(current(&mut shown, &deck), 2, "the last slide stays"); + shown.simulate_keystrokes("up"); + assert_eq!(current(&mut shown, &deck), 1, "Up turns back"); + wheel(&mut shown, 1.0); + assert_eq!(current(&mut shown, &deck), 0, "the wheel turns up"); +} diff --git a/crates/ely-lok/src/ffi.rs b/crates/ely-lok/src/ffi.rs index 8191cfc..017eec0 100644 --- a/crates/ely-lok/src/ffi.rs +++ b/crates/ely-lok/src/ffi.rs @@ -60,6 +60,8 @@ macro_rules! call { }}; } +#[cfg(target_os = "macos")] +mod accessory; mod document; mod slideshow; pub use document::{Raw, close_window}; @@ -80,6 +82,9 @@ impl Lok { let install_c = cstring(&install_str)?; let profile_c = cstring(profile_url)?; let lib = Library::open(install)?; + // Before the kit starts, so no Dock icon shows. + #[cfg(target_os = "macos")] + accessory::accessory(install)?; let hook: Hook2 = unsafe { lib.symbol(b"libreofficekit_hook_2\0") .or_else(|_| lib.symbol(b"libreofficekit_hook\0")) diff --git a/crates/ely-lok/src/ffi/accessory.rs b/crates/ely-lok/src/ffi/accessory.rs new file mode 100644 index 0000000..7cfe911 --- /dev/null +++ b/crates/ely-lok/src/ffi/accessory.rs @@ -0,0 +1,48 @@ +//! The worker stays out of the Dock (macOS). + +use std::ffi::{CString, c_char, c_void}; +use std::io; +use std::os::unix::ffi::OsStrExt as _; +use std::path::Path; + +#[link(name = "objc")] +unsafe extern "C" { + fn objc_getClass(name: *const c_char) -> *mut c_void; + fn sel_registerName(name: *const c_char) -> *mut c_void; + fn objc_msgSend(); +} + +/// The kernel's Aqua plugin; it defines `VCL_NSApplication`. +const PLUGIN: &str = "libvclplug_osxlo.dylib"; +/// NSApplicationActivationPolicyAccessory: no Dock icon, no menu bar. +const ACCESSORY: isize = 1; + +/// Makes the kernel's application an accessory before the kit starts. +pub fn accessory(install: &Path) -> io::Result<()> { + // Loaded for good; the kernel later opens the same image. + let path = install.join(PLUGIN); + let c = CString::new(path.as_os_str().as_bytes()).map_err(|_| io::Error::other("the path holds a NUL"))?; + if unsafe { libc::dlopen(c.as_ptr(), libc::RTLD_LAZY | libc::RTLD_GLOBAL) }.is_null() { + return Err(io::Error::other(format!("dlopen {}: {}", path.display(), super::dl_error()))); + } + // The kernel's own class, so it reuses this instance. + let class = unsafe { objc_getClass(c"VCL_NSApplication".as_ptr()) }; + if class.is_null() { + return Err(io::Error::other(format!("{PLUGIN} has no VCL_NSApplication class"))); + } + type Shared = unsafe extern "C" fn(*mut c_void, *mut c_void) -> *mut c_void; + type Policy = unsafe extern "C" fn(*mut c_void, *mut c_void, isize) -> i8; + let send = objc_msgSend as unsafe extern "C" fn(); + let shared: Shared = unsafe { std::mem::transmute(send) }; + let policy: Policy = unsafe { std::mem::transmute(send) }; + let app = unsafe { shared(class, sel_registerName(c"sharedApplication".as_ptr())) }; + if app.is_null() { + return Err(io::Error::other("the kernel's application did not start")); + } + let set = unsafe { policy(app, sel_registerName(c"setActivationPolicy:".as_ptr()), ACCESSORY) }; + if set == 0 { + return Err(io::Error::other("macOS refused the accessory policy")); + } + log::info!("lok: the worker is an accessory app"); + Ok(()) +} diff --git a/crates/ely-lok/src/lib.rs b/crates/ely-lok/src/lib.rs index a7b46e0..83f370a 100644 --- a/crates/ely-lok/src/lib.rs +++ b/crates/ely-lok/src/lib.rs @@ -20,7 +20,7 @@ pub use client::{ }; #[cfg(feature = "view")] pub use view::{ - CONTEXT, Copy, Cut, Geom, Layout, LokError, LokView, Paste, Reported, ZOOM_STEPS, + CONTEXT, Copy, Cut, Geom, Layout, LokError, LokView, Paste, Reported, Turn, ZOOM_STEPS, Zoomed, init, layout, paper, read_off, scheme, }; #[cfg(feature = "view")] diff --git a/crates/ely-lok/src/view.rs b/crates/ely-lok/src/view.rs index 84da252..23ee2d8 100644 --- a/crates/ely-lok/src/view.rs +++ b/crates/ely-lok/src/view.rs @@ -32,7 +32,9 @@ mod scroll; mod sheet; pub use input::{CONTEXT, Copy, Cut, Paste, init}; pub use off::read_off; -pub use render::scheme; +pub use render::{paper, scheme}; +mod turn; +pub use turn::Turn; pub(crate) use crate::{TILE_TWIPS, TWIPS_PER_PIXEL}; @@ -125,6 +127,8 @@ pub struct LokView { dark: Option, /// The zoom follows the viewport so a page fits. fit: bool, + /// Set when the wheel and Up, Down turn parts. + turning: Option, /// The sheet shown, and the offered function chosen. part: usize, picked: usize, @@ -150,17 +154,6 @@ pub type Layout = ((i64, i64), Vec); use sheet::{Measured, measure}; -/// A page's colour; the kernel's Ely schemes share it. -pub fn paper(cx: &gpui::App) -> gpui::Hsla { - use ely_gpui_component::theme::ActiveTheme as _; - let theme = cx.theme(); - if theme.is_dark() { - theme.colors.surface - } else { - theme.colors.paper - } -} - /// Reads `doc`'s size and page frames. pub fn layout(worker: &mut Worker, doc: &Doc) -> io::Result { Ok((doc.size(worker)?, doc.pages(worker)?)) @@ -227,6 +220,7 @@ impl LokView { cell: crate::Cell::default(), dark: None, fit: false, + turning: None, part: 0, picked: 0, marked: None, diff --git a/crates/ely-lok/src/view/input.rs b/crates/ely-lok/src/view/input.rs index c43168e..dd227e0 100644 --- a/crates/ely-lok/src/view/input.rs +++ b/crates/ely-lok/src/view/input.rs @@ -298,7 +298,10 @@ impl LokView { _: &mut Window, cx: &mut Context, ) { - if self.walk(&event.keystroke, cx) || self.key_down(&event.keystroke) { + if self.walk(&event.keystroke, cx) + || self.turn_key(&event.keystroke, cx) + || self.key_down(&event.keystroke) + { cx.stop_propagation(); } } diff --git a/crates/ely-lok/src/view/render.rs b/crates/ely-lok/src/view/render.rs index 464a41c..74c15fc 100644 --- a/crates/ely-lok/src/view/render.rs +++ b/crates/ely-lok/src/view/render.rs @@ -24,6 +24,16 @@ pub fn scheme(dark: bool) -> &'static str { if dark { DARK } else { LIGHT } } +/// A page's colour; the kernel's Ely schemes share it. +pub fn paper(cx: &gpui::App) -> gpui::Hsla { + let theme = cx.theme(); + if theme.is_dark() { + theme.colors.surface + } else { + theme.colors.paper + } +} + impl LokView { /// Paints with the app's light or dark scheme. pub(super) fn follow_theme(&mut self, dark: bool) { diff --git a/crates/ely-lok/src/view/turn.rs b/crates/ely-lok/src/view/turn.rs new file mode 100644 index 0000000..584b386 --- /dev/null +++ b/crates/ely-lok/src/view/turn.rs @@ -0,0 +1,142 @@ +//! The wheel and Up, Down turn slides (SHOW-01). + +use std::time::{Duration, Instant}; + +use gpui::{Context, EventEmitter, Keystroke, Modifiers, ScrollDelta, ScrollWheelEvent, TouchPhase}; + +use super::LokView; + +/// Asks for the part `step` away from the shown one. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Turn(pub i32); + +impl EventEmitter for LokView {} + +/// Trackpad pixels one turn takes. +const SWIPE: f32 = 50.0; +/// A pause this long ends a trackpad gesture. +const PAUSE: Duration = Duration::from_millis(250); + +/// The wheel's progress towards a turn. +#[derive(Default)] +pub(super) struct Turning { + lines: f32, + pixels: f32, + /// This gesture already turned. + spent: bool, + last: Option, +} + +impl Turning { + /// The turn a wheel event makes; 0 for none. + pub(super) fn wheel(&mut self, delta: ScrollDelta, phase: TouchPhase, now: Instant) -> i32 { + let paused = self.last.is_none_or(|last| now.duration_since(last) >= PAUSE); + self.last = Some(now); + let ahead = match delta { + // Each notch turns once. + ScrollDelta::Lines(lines) => { + if paused { + self.lines = 0.0; + } + self.lines += lines.y; + let whole = self.lines.trunc(); + self.lines -= whole; + whole + } + // One trackpad gesture turns once, momentum included. + ScrollDelta::Pixels(pixels) => { + if paused || phase == TouchPhase::Started { + (self.pixels, self.spent) = (0.0, false); + } + if self.spent { + return 0; + } + self.pixels += f32::from(pixels.y); + if self.pixels.abs() < SWIPE { + return 0; + } + self.spent = true; + self.pixels.signum() + } + }; + // Content moving down shows the part before. + -(ahead as i32) + } +} + +impl LokView { + /// The wheel and Up, Down turn parts from now on. + pub fn turn_parts(&mut self) { + self.turning.get_or_insert_default(); + } + + /// The part shown. + pub fn part(&self) -> usize { + self.part + } + + /// Up and Down turn while nothing is picked or edited. + pub(super) fn turn_key(&mut self, keystroke: &Keystroke, cx: &mut Context) -> bool { + let idle = !self.caret() && self.graphic.is_none() && self.selection.is_empty(); + if self.turning.is_none() || !idle || keystroke.modifiers != Modifiers::none() { + return false; + } + let step = match keystroke.key.as_str() { + "down" => 1, + "up" => -1, + _ => return false, + }; + cx.emit(Turn(step)); + true + } + + /// The wheel turns while the whole page shows. + pub(super) fn turn_wheel(&mut self, event: &ScrollWheelEvent, cx: &mut Context) -> bool { + let (width, height) = self.extent(); + // A pixel of slack for rounding. + let whole = self.geom.px_of(width) <= self.viewport.0 + 1.0 + && self.geom.px_of(height) <= self.viewport.1 + 1.0; + let Some(turning) = self.turning.as_mut().filter(|_| whole) else { + return false; + }; + let step = turning.wheel(event.delta, event.touch_phase, Instant::now()); + if step != 0 { + cx.emit(Turn(step)); + } + true + } +} + +#[cfg(test)] +mod tests { + use gpui::{ScrollDelta, TouchPhase, point, px}; + + use super::{PAUSE, Turning}; + + #[test] + fn each_notch_turns_once() { + let (mut turning, now) = (Turning::default(), std::time::Instant::now()); + let notch = |y| ScrollDelta::Lines(point(0.0, y)); + assert_eq!(turning.wheel(notch(-1.0), TouchPhase::Moved, now), 1); + assert_eq!(turning.wheel(notch(-1.0), TouchPhase::Moved, now), 1); + assert_eq!(turning.wheel(notch(1.0), TouchPhase::Moved, now), -1); + assert_eq!(turning.wheel(notch(0.5), TouchPhase::Moved, now), 0); + assert_eq!(turning.wheel(notch(0.5), TouchPhase::Moved, now), -1); + } + + #[test] + fn a_swipe_turns_once_until_the_next() { + let (mut turning, now) = (Turning::default(), std::time::Instant::now()); + let swipe = |y| ScrollDelta::Pixels(point(px(0.0), px(y))); + assert_eq!(turning.wheel(swipe(-30.0), TouchPhase::Started, now), 0); + assert_eq!(turning.wheel(swipe(-30.0), TouchPhase::Moved, now), 1); + // Momentum after the turn turns nothing more. + for _ in 0..20 { + assert_eq!(turning.wheel(swipe(-80.0), TouchPhase::Moved, now), 0); + } + assert_eq!(turning.wheel(swipe(60.0), TouchPhase::Started, now), -1); + // A pause ends a gesture without phases. + let later = now + PAUSE; + assert_eq!(turning.wheel(swipe(60.0), TouchPhase::Moved, later), -1); + } +} diff --git a/crates/ely-lok/src/view/zoom.rs b/crates/ely-lok/src/view/zoom.rs index 7ac9517..b7a8806 100644 --- a/crates/ely-lok/src/view/zoom.rs +++ b/crates/ely-lok/src/view/zoom.rs @@ -65,9 +65,9 @@ impl LokView { cx.notify(); } - /// Ctrl (macOS: Cmd) and the wheel zoom; else scroll. + /// Ctrl (macOS: Cmd) wheel zooms; else it turns or scrolls. pub(super) fn on_wheel(&mut self, event: &ScrollWheelEvent, window: &mut Window, cx: &mut Context) { - if !self.wheel_zoom(event, cx) { + if !self.wheel_zoom(event, cx) && !self.turn_wheel(event, cx) { self.on_scroll(event, window, cx); } } diff --git a/tasks/TASKS.md b/tasks/TASKS.md index 11f1f7c..1201608 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -270,3 +270,6 @@ - [x] R6.9 ElyMark 三、四级标题不显眼(用户报):各级字号随正文按 Typora 的倍数(2.25、1.75、1.5、1.25、1、1),一律加粗,六级为次要色(组件库渲染) - [x] R6.11 Windows 升级后旧文件仍在(R6.10 本机重装查出):安装程序只覆盖不删除,旧版 ElyMark 的 `git` 与 CEF 文件留在安装目录;安装程序记下所装的文件,升级与卸载只移去这些;文件被占用即什么也不改,失败时放回旧版。第 3 轮后(最后一轮,修完不再审查):备份按清单次序编号,近 260 字的路径照样升级与卸载;卸载先查清单本身,删不掉即放回全部 - [x] R6.10 重发 0.1.5(用户定:仍为 0.1.5):删去 v0.1.5 的全部文件,标签移到修复的提交,三端全部包重建并上传,本机重装(标签在 53630ce;推标签即起发布流程,其 Windows 与 Linux 任务建出并上传,与本机同时起的 Windows 构建撞上,本机那份未上传;macOS 任务未上传,dmg 由 Mac mini 本机建、公证、钉票;44 个文件,同名的源码包只留 Linux 的一份,见 P3-16;下载回的 ElyMark 安装程序与清单的散列与构建相同;本机六个应用 0.1.5 经新安装程序升级,ElyMark 目录无 `git`,冒烟启动均正常退出、日志无警告) +- [ ] R6.12 macOS 打开一个文档,程序坞里 ElyDoc、ElyTable、ElyShow 各出现两个图标(用户 2026-10-15 报):LOK worker 在应用包内启动 AppKit,系统按包的 Info.plist 把它也登记为常规应用(`lsappinfo` 两个 Foreground);worker 在内核启动前载入内核的 Aqua 插件,把内核自己的 `VCL_NSApplication` 设为辅助应用(无程序坞图标、无菜单栏,仍可建屏外窗口) +- [ ] R6.13 ElyShow 编辑区的滚轮与上下键不换页(用户报,三端;放映时正常):内核在未选中对象时把上下键当作滚动窗口,Ely 的画布整页适合、无处可滚;整页可见时滚轮换页(每格一页,触控板一次手势一页),未选中对象、未编辑文字时上下键换页,同缩略图条的路径,到首末页即停 +- [ ] R6.14 重发 0.1.5(用户定:仍为 0.1.5,全部重建并重传):删去 v0.1.5 的全部文件,标签移到 R6.12、R6.13 的提交,三端全部包重建并上传,本机重装