R6.12, R6.13: the LOK worker has no Dock icon; Show turns slides

R6.12: on macOS the worker sets LibreOffice's own NSApplication to the
accessory policy before the kit starts, so it never shows in the Dock.

R6.13: when the whole slide fits, the wheel and Up/Down (nothing
selected, no text editing) turn slides in the ElyShow editor.

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-15 19:29:10 -04:00
1 parent bb943fb84b
commit fdb8317d24
14 files changed
+312 -19

No files matched your search

+2
View File
@@ -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)。
+11 -2
View File
@@ -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);
+15 -1
View File
@@ -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<LokView>, step: i32, cx: &mut Context<Self>) {
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<Self>) {
let Some(tab) = self.tab(id) else {
+1
View File
@@ -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<Entity<Deck>> {
+62
View File
@@ -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<LokView>);
impl gpui::Render for Host {
fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context<Self>) -> 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<Deck>) -> 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");
}
+5
View File
@@ -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"))
+48
View File
@@ -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(())
}
+1 -1
View File
@@ -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")]
+6 -12
View File
@@ -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<bool>,
/// The zoom follows the viewport so a page fits.
fit: bool,
/// Set when the wheel and Up, Down turn parts.
turning: Option<turn::Turning>,
/// The sheet shown, and the offered function chosen.
part: usize,
picked: usize,
@@ -150,17 +154,6 @@ pub type Layout = ((i64, i64), Vec<Rect>);
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<Layout> {
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,
+4 -1
View File
@@ -298,7 +298,10 @@ impl LokView {
_: &mut Window,
cx: &mut Context<Self>,
) {
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();
}
}
+10
View File
@@ -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) {
+142
View File
@@ -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<Turn> 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<Instant>,
}
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<Self>) -> 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<Self>) -> 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);
}
}
+2 -2
View File
@@ -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<Self>) {
if !self.wheel_zoom(event, cx) {
if !self.wheel_zoom(event, cx) && !self.turn_wheel(event, cx) {
self.on_scroll(event, window, cx);
}
}
+3
View File
@@ -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 的提交,三端全部包重建并上传,本机重装