From 76bd8f96025498c0af9fe1c24a63a7dc0ae4d481 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 16:05:36 +0800 Subject: [PATCH 01/35] chore(linux-egui): advance the package revision to 103 Beta.3 merged as 913279c0; this revision carries the review fixes (fcitx5 startup barrier, UI bridge protocol v2, field-patch settings saves, history cache) into the deb/rpm channel. --- openless-all/app/linux-egui/package-revision | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 257e56326..a9c8fe829 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -102 +103 From b20c027e19624b66e62b9653e1aaee448dd96f0f Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 16:05:36 +0800 Subject: [PATCH 02/35] docs: add the Beta.3-103 device acceptance checklist Lists the observable behaviour each review fix introduced: the fcitx5 startup barrier and its failure/recovery path, the UI bridge handshake and reconnect, field-patch settings saves under conflict, history delete identity and caching, plus the previous round's parity regressions and the install/upgrade checks. --- docs/linux-egui-handoff/07-acceptance.md | 46 +++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/docs/linux-egui-handoff/07-acceptance.md b/docs/linux-egui-handoff/07-acceptance.md index 205bdc97c..31af5f990 100644 --- a/docs/linux-egui-handoff/07-acceptance.md +++ b/docs/linux-egui-handoff/07-acceptance.md @@ -1,6 +1,6 @@ # 07:验收与证据 -状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-08。 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-27。 ## 1. 两个不同的完成门 @@ -28,3 +28,47 @@ ## 4. 记录格式 `ID / owner / commit / 已完成效果 / 自动证据 / 设备证据 / 剩余限制`。“待实测”不是“未实现”,也不是“已完成”;验收报告按证据分级表述(见[范围](../2.0-requirements.md)第 4 节)。 + +## 5. 本轮(Beta.3-103)设备验收清单 + +本轮改动集中在启动/运行时/保存三条链(`fcitx5.rs`、`ui/bridge.rs`、`linux_app/{window,settings_save,history}.rs`、`preference_patch.rs`),以及上一轮的 Tauri 视觉对齐项。逐项结果按第 4 节格式记录,未实测的不要写成已验证。 + +### 5.1 fcitx5 成为启动硬依赖(`fcitx5.rs::prepare_fcitx5`) + +| 场景 | 期望 | +| --- | --- | +| 插件已装、fcitx5 运行 | 启动不阻塞(15 秒就绪预算内),热键可用 | +| 插件缺失/未启用 | 弹出错误窗口(`startup.fcitx_help` 文案);**关闭后进程以非零状态退出并释放单实例锁**,可立即重新启动 | +| 启动后才 `fcitx5 -r` 重启 | 插件重载完成后热键仍可用(验证就绪等待是否覆盖慢重启) | +| 无会话 D-Bus(ssh/`systemd-run` 启动) | 明确失败并给出修复指引,不得静默降级为全局热键 | + +### 5.2 UI 子进程协议 v2(`ui/bridge.rs`) + +| 场景 | 期望 | +| --- | --- | +| 关闭 UI 主窗后从托盘/启动器重开 | 宿主与后台能力(热键、录音、托盘)持续存活;重开后重新握手并收到全量快照,历史/设置不丢 | +| UI 进程被 `kill -9` | 宿主不崩;下次开窗握手正常 | +| 状态高频变化(历史、录音电平)时 UI 较慢 | 显示的是最新状态,不出现回退到旧快照 | +| 误接协议不匹配的 UI 二进制 | 回 `Rejected` 并断开,而不是渲染半懂数据 | + +### 5.3 设置保存改为字段补丁(`linux_app/settings_save.rs`、`preference_patch.rs`) + +| 场景 | 期望 | +| --- | --- | +| 快速连改多个开关/滑条 | 不出现“改 A 丢 B”;最终值与最后一次操作一致 | +| 制造 revision 冲突(另一端同时改设置) | 冲突被重试(≤3 次)后正确落盘;仍失败时保留草稿并显式提供重试,不打开设置页 | + +### 5.4 历史缓存与删除身份(`linux_app/history.rs`) + +| 场景 | 期望 | +| --- | --- | +| 删除历史条目 | 删除的是点中的那条(不再因刷新错位);列表与概览计数同步 | +| 历史条目很多时打开概览/设置 | 首屏不卡顿(读取与 WAV 探测在后台任务,两条页面共享缓存) | + +### 5.5 回归:上一轮 Tauri 视觉对齐项 + +OAuth 弹窗(加宽、标题字号、居中状态行、整行验证码框、关闭按钮悬停不缩放)、风格页卡片(图标选择器贴右、铅笔/重置徽标、名称/描述/标签行)、市场“我的发布”上传/更新/下架流程。 + +### 5.6 安装与升级 + +`dpkg -i` 用 103 覆盖 102(apt 视为升级,不回退);rpm 同理;`apt remove` 后无残留(fcitx5 postinst/postrm 行为);核对 `openless --version` 自报 `OpenLess 2.0.0-Beta.3-103`。 From 96ac68e353931899851e68bcdaf5b5622e407dfc Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 16:22:29 +0800 Subject: [PATCH 03/35] fix(linux): stop the fcitx5 startup barrier failing on a session-managed daemon The barrier required the `org.fcitx.Fcitx5` owner name to change after a reload, but `restart_fcitx5_via_dbus` reported success whenever the D-Bus call was dispatched. A compositor-launched fcitx5 (`fcitx5-wayland-launcher --reopen`) sets `CanRestart` false and then silently ignores `Restart`, so the owner never changed: every start burned the full 15 second budget and died in the startup error window even though the running addon answered and hotkey registration would have worked. - Readiness is now the addon interface, not a replaced daemon. A reload that the daemon does not perform is a warning (`reload_wait`, `ReloadPlan`). - `CanRestart` is consulted before `Restart`, and a session-managed daemon keeps its running addon copy instead of having a competing `fcitx5 -r` spawned next to it. Only a refused controller request uses the CLI fallback. - `parse_maps_plugin_path` no longer treats the kernel's " (deleted)" suffix as "fcitx5 is not running", so the stale-copy case is reported and judged correctly. --- openless-all/app/linux-egui/src/fcitx5.rs | 280 ++++++++++++++++++---- 1 file changed, 240 insertions(+), 40 deletions(-) diff --git a/openless-all/app/linux-egui/src/fcitx5.rs b/openless-all/app/linux-egui/src/fcitx5.rs index b18f04427..990d4ca6b 100644 --- a/openless-all/app/linux-egui/src/fcitx5.rs +++ b/openless-all/app/linux-egui/src/fcitx5.rs @@ -18,6 +18,17 @@ pub(crate) const OBJECT_PATH: &str = "/openless"; pub(crate) const INTERFACE: &str = "org.fcitx.Fcitx.OpenLess1"; #[cfg(target_os = "linux")] const TIMEOUT: Duration = Duration::from_secs(3); +/// Budget for the addon interface to answer once a reload was requested. Only a +/// missing interface consumes it; a daemon that refuses to replace itself does +/// not. +#[cfg(target_os = "linux")] +const READY_TIMEOUT: Duration = Duration::from_secs(15); +/// How long a replaced daemon gets to hand the `org.fcitx.Fcitx5` name over +/// before the host accepts the addon copy that is already serving. +#[cfg(target_os = "linux")] +const OWNER_CHANGE_GRACE: Duration = Duration::from_secs(3); +#[cfg(target_os = "linux")] +const POLL_INTERVAL: Duration = Duration::from_millis(100); /// fcitx5's own management interface. `Restart` makes the daemon replace /// itself in place: the call returns immediately and nothing of ours is /// inherited, unlike the `fcitx5 -r` command (see `reload_running_fcitx5`). @@ -271,6 +282,10 @@ fn system_plugin_library() -> Option { /// `/proc//maps`. This is the authoritative answer to "what is live". pub(crate) fn parse_maps_plugin_path(maps: &str) -> Option { maps.lines().find_map(|line| { + // The kernel appends " (deleted)" to a mapping whose inode was replaced + // underneath the daemon — the package-upgrade case this check exists + // for — so the suffix must not hide the copy fcitx5 actually loaded. + let line = line.strip_suffix(" (deleted)").unwrap_or(line); let path = line.split_whitespace().last()?; path.ends_with("/libopenless.so") .then(|| PathBuf::from(path)) @@ -1018,8 +1033,47 @@ pub fn available() -> bool { .is_ok() } +/// What the barrier does with the daemon's `org.fcitx.Fcitx5` name while a +/// reload is in flight. Extracted so the rule is testable without a live bus. +#[cfg(any(target_os = "linux", test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReloadWait { + /// Keep waiting: the replacement may still be taking the name over. + Wait, + /// The replacement arrived; probe the addon interface. + Probe, + /// The daemon kept the addon copy it was started with. + KeptPreviousCopy, +} + +/// Whether a requested reload still waits for the daemon to hand the name over. +/// +/// fcitx5 started by a session launcher reports `CanRestart` = false and +/// dispatches `Restart` without replacing itself, so an unchanged owner after +/// the grace window means "the previous addon copy keeps serving the +/// interface", not "the addon is missing". `current` is `None` while the name +/// has no owner, which is the normal transient state during a replacement. +#[cfg(any(target_os = "linux", test))] +pub(crate) fn reload_wait( + previous: &str, + current: Option<&str>, + grace_elapsed: bool, +) -> ReloadWait { + match current { + Some(current) if current != previous => ReloadWait::Probe, + Some(_) if grace_elapsed => ReloadWait::KeptPreviousCopy, + Some(_) | None => ReloadWait::Wait, + } +} + /// Production startup barrier: install, request at most one reload, then wait -/// for the new owner's addon interface before registering any shortcuts. +/// for the addon interface before registering any shortcuts. +/// +/// Readiness is the addon *interface*, not a replaced daemon: the running copy +/// serves the interface the host registers hotkeys against, so a reload the +/// daemon refuses to perform is a warning (`ReloadWait::KeptPreviousCopy`) +/// rather than a startup failure. Only an interface that never answers within +/// `READY_TIMEOUT` stops startup. #[cfg(target_os = "linux")] pub fn prepare_fcitx5(plan: &FcitxPluginInstallPlan, data_dir: &Path) -> Result<(), BackendError> { use dbus::blocking::BlockingSender; @@ -1042,42 +1096,61 @@ pub fn prepare_fcitx5(plan: &FcitxPluginInstallPlan, data_dir: &Path) -> Result< }; let previous_owner = owner(TIMEOUT)?; let reloaded = reload_fcitx5_if_plugin_updated(plan, data_dir); - let deadline = std::time::Instant::now() + Duration::from_secs(15); + let interface_ready = |timeout: Duration| -> bool { + let Ok(message) = dbus::Message::new_method_call( + DESTINATION, + OBJECT_PATH, + "org.freedesktop.DBus.Introspectable", + "Introspect", + ) else { + return false; + }; + connection + .send_with_reply_and_block(message, timeout) + .ok() + .and_then(|reply| reply.read1::().ok()) + .is_some_and(|xml| xml.contains(INTERFACE)) + }; + let deadline = std::time::Instant::now() + READY_TIMEOUT; + let owner_grace = std::time::Instant::now() + OWNER_CHANGE_GRACE; + let mut awaiting_replacement = reloaded; loop { let remaining = deadline.saturating_duration_since(std::time::Instant::now()); if remaining.is_zero() { - return Err(BackendError::new(BackendErrorCode::Platform, - "fcitx5 OpenLess interface did not become ready within 15 seconds; enable the addon and restart fcitx5")); + return Err(BackendError::new( + BackendErrorCode::Platform, + format!( + "fcitx5 OpenLess interface did not become ready within {} seconds; enable the \ + addon and restart fcitx5", + READY_TIMEOUT.as_secs() + ), + )); } - let current_owner = owner(TIMEOUT.min(remaining)); - if current_owner - .as_ref() - .is_ok_and(|current| !reloaded || *current != previous_owner) - { - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - continue; - } - let message = dbus::Message::new_method_call( - DESTINATION, - OBJECT_PATH, - "org.freedesktop.DBus.Introspectable", - "Introspect", - ) - .map_err(dbus_error)?; - if connection - .send_with_reply_and_block(message, TIMEOUT.min(remaining)) - .ok() - .and_then(|reply| reply.read1::().ok()) - .is_some_and(|xml| xml.contains(INTERFACE)) - { - return Ok(()); + if awaiting_replacement { + let current = owner(TIMEOUT.min(remaining)).ok(); + match reload_wait( + &previous_owner, + current.as_deref(), + std::time::Instant::now() >= owner_grace, + ) { + ReloadWait::Probe => awaiting_replacement = false, + ReloadWait::KeptPreviousCopy => { + log::warn!( + "[fcitx] fcitx5 kept the addon copy it was started with (the daemon did \ + not replace itself); the running copy still serves the interface" + ); + awaiting_replacement = false; + } + ReloadWait::Wait => { + std::thread::sleep(POLL_INTERVAL.min(remaining)); + continue; + } } } - std::thread::sleep( - Duration::from_millis(100) - .min(deadline.saturating_duration_since(std::time::Instant::now())), - ); + if interface_ready(TIMEOUT.min(remaining)) { + return Ok(()); + } + std::thread::sleep(POLL_INTERVAL.min(remaining)); } } @@ -1086,6 +1159,38 @@ pub fn available() -> bool { false } +/// What the host does after a plugin update was detected. +#[cfg(any(target_os = "linux", test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReloadPlan { + /// fcitx5 accepted `Restart` and replaces itself. + Replaced, + /// The session owns fcitx5 and it does not replace itself: the running addon + /// copy keeps serving until that daemon next starts. + KeepRunningCopy, + /// The controller refused the request; use the detached `fcitx5 -r`. + CliFallback, +} + +/// Which reload path applies, given fcitx5's `CanRestart` (`None` when the method +/// is missing) and whether `Restart` was accepted. +/// +/// A daemon launched by the desktop session reports `CanRestart` = false and then +/// dispatches `Restart` *without* replacing itself. Spanning a competing +/// `fcitx5 -r` would fight the session manager for the addon, so that case keeps +/// the running copy: the interface is what the host needs, and the installed +/// copy loads when the session next starts the daemon. +#[cfg(any(target_os = "linux", test))] +pub(crate) fn reload_plan(can_restart: Option, dispatched: bool) -> ReloadPlan { + if can_restart == Some(false) { + return ReloadPlan::KeepRunningCopy; + } + if dispatched { + return ReloadPlan::Replaced; + } + ReloadPlan::CliFallback +} + /// Ask a running fcitx5 daemon to reload so it loads a freshly written /// OpenLess addon, mirroring the legacy Tauri `linux_fcitx` adapter. /// @@ -1096,26 +1201,58 @@ pub fn available() -> bool { /// semantics are preserved). On an update the running instance is restarted so /// the new `.so` is actually loaded (restart semantics). /// -/// Reload failures are logged; the production startup barrier still requires -/// a ready addon and successful required hotkey registration. Returns true -/// when a reload was issued against a live instance. +/// Returns true only when the daemon is going to replace itself, because that is +/// what makes the owner name change; the startup barrier accepts the running +/// copy either way (see `prepare_fcitx5`). #[cfg(target_os = "linux")] pub fn reload_running_fcitx5() -> bool { if !fcitx5_name_has_owner() { return false; } - if restart_fcitx5_via_dbus() { - log::info!("[fcitx] reloaded fcitx5 after addon update"); - return true; + let can_restart = fcitx5_can_restart(); + let dispatched = can_restart != Some(false) && dispatch_fcitx5_restart(); + match reload_plan(can_restart, dispatched) { + ReloadPlan::Replaced => { + log::info!("[fcitx] reloaded fcitx5 after addon update"); + true + } + ReloadPlan::KeepRunningCopy => { + log::info!( + "[fcitx] the session owns fcitx5 and it does not replace itself; the running addon \ + copy keeps serving and the installed copy loads when the daemon next starts" + ); + false + } + ReloadPlan::CliFallback => spawn_detached_fcitx5_restart(), } - spawn_detached_fcitx5_restart() +} + +/// `CanRestart` on the fcitx5 controller. A missing method or an unreadable reply +/// returns `None`, which keeps the previous behaviour and lets `Restart` decide. +#[cfg(target_os = "linux")] +fn fcitx5_can_restart() -> Option { + use dbus::blocking::BlockingSender; + let connection = dbus::blocking::Connection::new_session().ok()?; + let message = dbus::Message::new_method_call( + DESTINATION, + CONTROLLER_PATH, + CONTROLLER_INTERFACE, + "CanRestart", + ) + .ok()?; + connection + .send_with_reply_and_block(message, CONTROLLER_TIMEOUT) + .ok()? + .read1::() + .ok() } /// `org.fcitx.Fcitx.Controller1.Restart` on `/controller`: the daemon replaces /// itself, the call returns as soon as the method is dispatched, and nothing of -/// ours is inherited. +/// ours is inherited. A dispatched call is still not proof of a replacement, +/// which is why the caller also watches the owner name. #[cfg(target_os = "linux")] -pub(crate) fn restart_fcitx5_via_dbus() -> bool { +fn dispatch_fcitx5_restart() -> bool { use dbus::blocking::BlockingSender; let Ok(connection) = dbus::blocking::Connection::new_session() else { return false; @@ -1299,6 +1436,69 @@ mod tests { assert_eq!(reload_reason(Some("abc"), "abc", false, true), None); } + #[test] + fn a_daemon_that_cannot_replace_itself_keeps_serving_the_installed_addon() { + // fcitx5 started by a session launcher reports CanRestart = false and + // dispatches `Restart` without replacing itself, so the owner name stays + // put. That is a warning — the running addon copy still answers — never + // the 15 second startup failure this barrier used to raise. + assert_eq!( + reload_wait(":1.27", Some(":1.27"), true), + ReloadWait::KeptPreviousCopy + ); + // A replacement that is still in flight keeps waiting, even past the + // grace window: the name is briefly unowned while the daemon swaps. + assert_eq!(reload_wait(":1.27", None, true), ReloadWait::Wait); + assert_eq!(reload_wait(":1.27", Some(":1.27"), false), ReloadWait::Wait); + // A new owner means the replacement arrived and the interface may be + // probed. + assert_eq!( + reload_wait(":1.27", Some(":1.471"), true), + ReloadWait::Probe + ); + } + + #[test] + fn a_session_owned_daemon_is_not_replaced_by_a_competing_one() { + // A compositor-launched fcitx5 reports CanRestart = false: respect it + // instead of spawning a second daemon that would fight the session for + // the addon. The running copy keeps serving. + assert_eq!(reload_plan(Some(false), false), ReloadPlan::KeepRunningCopy); + assert_eq!(reload_plan(Some(false), true), ReloadPlan::KeepRunningCopy); + // A dispatched Restart replaces the daemon... + assert_eq!(reload_plan(Some(true), true), ReloadPlan::Replaced); + // ...while a refused controller request falls back to the CLI. + assert_eq!(reload_plan(Some(true), false), ReloadPlan::CliFallback); + // An older daemon without the method keeps the previous behaviour. + assert_eq!(reload_plan(None, true), ReloadPlan::Replaced); + assert_eq!(reload_plan(None, false), ReloadPlan::CliFallback); + } + + #[test] + fn a_deleted_plugin_mapping_still_reports_the_copy_the_daemon_loaded() { + // The kernel marks a mapping whose inode a package upgrade replaced; + // that is the stale-copy case the reload check exists for, so it must + // not be misread as "fcitx5 is not running". + let replaced = "7f8e028d1000-7f8e028e1000 r--p 00000000 08:01 1056766 \ + /usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so (deleted)\n"; + assert_eq!( + parse_maps_plugin_path(replaced), + Some(PathBuf::from( + "/usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so" + )) + ); + let live = + "7f8e028d1000-7f8e028e1000 r--p 00000000 08:01 1056766 /usr/lib/fcitx5/libopenless.so\n"; + assert_eq!( + parse_maps_plugin_path(live), + Some(PathBuf::from("/usr/lib/fcitx5/libopenless.so")) + ); + assert_eq!( + parse_maps_plugin_path("1-2 r--p 00000000 00:00 0 /usr/lib/libgtk.so\n"), + None + ); + } + #[test] fn the_packaged_addon_is_searched_before_a_manual_install() { let dirs = plugin_library_search_dirs(&["x86_64-linux-gnu".to_string()]); From 6baa1e0c3335e8b2b34b8ce7eb3487ec74145de1 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 16:22:29 +0800 Subject: [PATCH 04/35] docs: record the session-managed fcitx5 case in the acceptance checklist --- docs/linux-egui-handoff/07-acceptance.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/linux-egui-handoff/07-acceptance.md b/docs/linux-egui-handoff/07-acceptance.md index 31af5f990..e57363d75 100644 --- a/docs/linux-egui-handoff/07-acceptance.md +++ b/docs/linux-egui-handoff/07-acceptance.md @@ -35,11 +35,14 @@ ### 5.1 fcitx5 成为启动硬依赖(`fcitx5.rs::prepare_fcitx5`) +就绪的判据是**插件接口能应答**,不是 fcitx5 进程被替换。桌面会话(KWin 拉起 `fcitx5-wayland-launcher --reopen`)会让 fcitx5 报告 `CanRestart=false`:此时 `Restart` 调用会“成功”但进程不替换,103 包因此每次启动都卡满 15 秒并报错。104 起该状态只记警告并继续使用正在运行的副本,安装的副本等 fcitx5 下次启动时生效。 + | 场景 | 期望 | | --- | --- | | 插件已装、fcitx5 运行 | 启动不阻塞(15 秒就绪预算内),热键可用 | | 插件缺失/未启用 | 弹出错误窗口(`startup.fcitx_help` 文案);**关闭后进程以非零状态退出并释放单实例锁**,可立即重新启动 | | 启动后才 `fcitx5 -r` 重启 | 插件重载完成后热键仍可用(验证就绪等待是否覆盖慢重启) | +| 刚升级过插件、且 fcitx5 由会话/合成器托管(`CanRestart=false`) | 启动**不得**因重启失败而卡满 15 秒或弹错;日志写明保留正在运行的副本,热键仍可用(旧 103 包在此状态下每次启动都失败,见下) | | 无会话 D-Bus(ssh/`systemd-run` 启动) | 明确失败并给出修复指引,不得静默降级为全局热键 | ### 5.2 UI 子进程协议 v2(`ui/bridge.rs`) From 8b9759962e96c672d6f5b3d566dd65813a0508f7 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 16:22:29 +0800 Subject: [PATCH 05/35] chore(linux-egui): advance the package revision to 104 --- openless-all/app/linux-egui/package-revision | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index a9c8fe829..b16e5f75e 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -103 +104 From ec0723692825fed2c7ee7ce83fcd70d8b7959c5d Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 16:37:03 +0800 Subject: [PATCH 06/35] chore(linux-egui): keep clippy quiet on the declared toolchains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host's MSRV is 1.95 while CI lints with the current stable (1.98), and clippy 1.95 flags these two dedupe decisions as `nonminimal_bool`. Rewriting them as `is_none_or` (already the shape used in the history cache) is equivalent — signal/local is None, or the stamp is outside the dedupe window — and is clean on both toolchains. Package revision advances to 105 for the rebuilt deb/rpm. --- openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/local_hotkeys.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index b16e5f75e..f96ac0672 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -104 +105 diff --git a/openless-all/app/linux-egui/src/local_hotkeys.rs b/openless-all/app/linux-egui/src/local_hotkeys.rs index 6a89dd5f6..e2a509e7f 100644 --- a/openless-all/app/linux-egui/src/local_hotkeys.rs +++ b/openless-all/app/linux-egui/src/local_hotkeys.rs @@ -297,18 +297,18 @@ impl HotkeyDeduplicator { pub fn accept_local(&mut self, hotkey: &LocalHotkey, at: Instant) -> bool { let stamps = self.seen.entry(hotkey.clone()).or_default(); stamps.local = Some(at); - !stamps + stamps .signal - .is_some_and(|signal| at.saturating_duration_since(signal) < HOTKEY_DEDUPE_WINDOW) + .is_none_or(|signal| at.saturating_duration_since(signal) >= HOTKEY_DEDUPE_WINDOW) } /// 采纳一条插件信号?本地刚命中过(同一窗口还是 XWayland 客户端)就丢弃。 pub fn accept_signal(&mut self, hotkey: &LocalHotkey, at: Instant) -> bool { let stamps = self.seen.entry(hotkey.clone()).or_default(); stamps.signal = Some(at); - !stamps + stamps .local - .is_some_and(|local| at.saturating_duration_since(local) < HOTKEY_DEDUPE_WINDOW) + .is_none_or(|local| at.saturating_duration_since(local) >= HOTKEY_DEDUPE_WINDOW) } } From c6a2a5947f647b95ddb31bb686ad9a808784f087 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 17:02:57 +0800 Subject: [PATCH 07/35] fix(linux-egui): degrade the UI snapshot instead of blinking the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge refused any snapshot over 16 MiB by dropping the window connection. The window treats a dead host as "the host is gone" and exits successfully, and the host only records a window error on a non-zero exit — so an oversized view model turned into: connection dropped, window exits, host respawns after 800 ms, same oversized snapshot, forever. The window blinked about once a second and the app was effectively unusable (it takes ~14k entries at the current average transcript length to get there, but history has no entry cap by default). Now the host trims the oldest history before sending: a ladder of 4000/1000/200/0 entries, newest first, until the payload fits a 12 MiB soft limit. The history list is refilled from the real cache every tick, so trimming only affects the frame being sent, and both sides keep indexing the same list. The count of hidden entries travels in `FrontendViewModel::history_truncated` and the history page paints a warning with that count plus the setting that actually shrinks history. If even the empty history does not fit, the frame is skipped and the connection is kept: the window keeps showing the last good state instead of dying in a loop. `OPENLESS_UI_SNAPSHOT_LIMIT=` lowers the soft limit so the degraded path can be exercised on a device without accumulating 12 MiB of history. Verified: with the limit forced to 60 kB the host logged the trim once, the window process was spawned exactly once and never exited, and steady-state publishing stayed at zero frames (the fingerprint gate still works). Package revision 106. --- openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/i18n.rs | 25 ++- .../app/linux-egui/src/linux_app/window.rs | 158 +++++++++++++++++- openless-all/app/linux-egui/src/ui/bridge.rs | 21 ++- .../app/linux-egui/src/ui/frontend/history.rs | 102 +++++++++++ .../app/linux-egui/src/ui/frontend/mod.rs | 43 +++++ .../linux-egui/src/ui/frontend/view_model.rs | 4 + 7 files changed, 342 insertions(+), 13 deletions(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index f96ac0672..fe4afb0df 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -105 +106 diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index b784238d7..6535a5fd7 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -886,6 +886,26 @@ pub const CATALOG: &[Msg] = &[ "履歴の読み込みに失敗:{}", "기록 로드 실패: {}", ), + }, + Msg { + key: "history.truncated", + text: row( + "历史记录过大:仅载入最近 {} 条,另有 {} 条未载入", + "歷史記錄過大:僅載入最近 {} 條,另有 {} 條未載入", + "History is too large: only the newest {} entries are loaded, {} more are not", + "履歴が大きすぎます:最新 {} 件のみ読み込み、{} 件は未読み込みです", + "기록이 너무 큽니다: 최신 {}개만 불러왔고 {}개는 불러오지 않았습니다", + ), + }, + Msg { + key: "history.truncated_hint", + text: row( + "可在「设置 → 录音 → 历史与上下文」里调低「历史条数上限」", + "可在「設定 → 錄音 → 歷史與上下文」調低「歷史條數上限」", + "Lower “Max history entries” under Settings → Recording → History & context", + "「設定 → 録音 → 履歴とコンテキスト」で「履歴件数の上限」を下げてください", + "설정 → 녹음 → 기록 및 컨텍스트에서 “기록 개수 상한”을 낮추세요", + ), }, Msg { key: "history.select_hint", @@ -1129,7 +1149,10 @@ pub const CATALOG: &[Msg] = &[ "fcitx5를 설치하고 시작한 후 OpenLess 애드온과 현재 데스크톱 세션의 D-Bus를 활성화하세요. 필요하면 OpenLess를 다시 설치하세요. 환경을 수정한 후 이 창을 닫고 앱을 다시 시작하세요。", ), }, - Msg { key: "common.saving", text: row("正在保存…", "正在儲存…", "Saving…", "保存中…", "저장 중…") }, + Msg { + key: "common.saving", + text: row("保存中", "儲存中", "Saving…", "保存中", "저장 중"), + }, Msg { key: "startup.connecting", text: row("正在连接 OpenLess…", "正在連線 OpenLess…", "Connecting to OpenLess…", "OpenLess に接続中…", "OpenLess에 연결 중…") }, // ---- Buttons (models / providers / vocab / styles / marketplace / history) diff --git a/openless-all/app/linux-egui/src/linux_app/window.rs b/openless-all/app/linux-egui/src/linux_app/window.rs index 8f83f4792..f64f3efae 100644 --- a/openless-all/app/linux-egui/src/linux_app/window.rs +++ b/openless-all/app/linux-egui/src/linux_app/window.rs @@ -47,6 +47,24 @@ pub(super) const MAIN_MSAA_SAMPLES: u16 = 4; /// 旧的 Linux 专用窗口配置不作为依据。 pub(super) const MAIN_WINDOW_INNER_SIZE: [f32; 2] = [1300.0, 835.0]; +/// 快照的软上限:到这个体积就先裁历史,仍留出余量给桥层的硬上限([`crate::ui::bridge`] 的帧上限)。 +/// 视图模型里唯一会随使用无限膨胀的就是历史列表,所以超限时只裁它。 +pub(super) const SNAPSHOT_SOFT_LIMIT: usize = 12 * 1024 * 1024; + +/// 软上限的实际取值。`OPENLESS_UI_SNAPSHOT_LIMIT=<字节>` 可以压低它,用来在设备上 +/// 演练「历史过大」的降级路径(否则要攒到 12 MiB 历史才能看到横幅)。 +fn snapshot_soft_limit() -> usize { + std::env::var("OPENLESS_UI_SNAPSHOT_LIMIT") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(SNAPSHOT_SOFT_LIMIT) +} + +/// 裁剪阶梯:历史列表最新在前,所以 `truncate` 丢掉的就是最旧的。 +/// 一档不够就再降一档,直到载荷落回软上限。 +pub(super) const HISTORY_TRIM_LADDER: [usize; 4] = [4_000, 1_000, 200, 0]; + /// 视图模型载荷指纹(FNV-1a 64)。够快,用来判断「要不要重发快照」: /// 内容没变就不发,UI 慢的时候也不会被无意义的帧糊住。 pub(super) fn snapshot_fingerprint(payload: &[u8]) -> u64 { @@ -206,12 +224,13 @@ impl OpenLessEguiApp { self.window.last_snapshot_fingerprint = None; return; } - let payload = match serde_json::to_vec(&self.frontend_vm) { - Ok(payload) => payload, - Err(error) => { - log::warn!("[ui-host] view model serialization failed: {error}"); - return; - } + let Some(payload) = encode_view_model( + &mut self.frontend_vm, + snapshot_soft_limit(), + &HISTORY_TRIM_LADDER, + ) else { + log::warn!("[ui-host] view model serialization failed"); + return; }; let fingerprint = snapshot_fingerprint(&payload); let keepalive = self.window.last_snapshot_at.elapsed() >= Duration::from_secs(2); @@ -224,6 +243,49 @@ impl OpenLessEguiApp { } } +/// 序列化视图模型,必要时把最旧的历史裁掉,让快照回到 `soft_limit` 以内。 +/// +/// 裁的是宿主持有的这份 VM(窗口发过来的历史索引要对齐同一份列表),下一 tick 的 +/// [`OpenLessEguiApp::sync_view_model`] 会从真实缓存重新填满,所以裁掉的内容只是 +/// 「这一帧不发」,不是永久丢弃。`ladder` 的最后一项应当能裁到空列表。 +/// +/// 返回 `None` 只表示序列化本身失败;裁完仍然超过软上限时会照常返回载荷,由调用方 +/// 交给桥层按硬上限处理(丢帧,但不断连)。 +fn encode_view_model( + view_model: &mut FrontendViewModel, + soft_limit: usize, + ladder: &[usize], +) -> Option> { + let mut payload = serde_json::to_vec(view_model).ok()?; + if payload.len() <= soft_limit { + view_model.history_truncated = None; + return Some(payload); + } + // 隐藏条数要相对**原始**条数算:阶梯会一路降档,按当前长度算只会数到最后一步。 + let total = view_model.history_entries.len(); + for keep in ladder.iter().copied() { + if total <= keep { + continue; + } + view_model.history_entries.truncate(keep); + if view_model.history_selected >= keep { + // 选中的那条被裁掉了:回到最新一条,别让详情栏空着。 + view_model.history_selected = 0; + } + let hidden = total - keep; + view_model.history_truncated = Some(u32::try_from(hidden).unwrap_or(u32::MAX)); + log::warn!( + "[ui-host] snapshot is over {} bytes; trimming history to {keep} entries ({hidden} hidden)", + soft_limit + ); + payload = serde_json::to_vec(view_model).ok()?; + if payload.len() <= soft_limit { + return Some(payload); + } + } + Some(payload) +} + /// UI 窗口进程入口:只渲染。 /// /// 它不构造 Core 后端、不打开数据目录、不抢单实例锁、不注册托盘与热键 —— @@ -709,3 +771,87 @@ pub(super) fn show_startup_error(error: &str, broker: Arc) eprintln!("OpenLess startup error window failed: {failure}"); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::frontend::view_model::{FrontendViewModel, HistoryEntry}; + + /// 一条体量接近真实长转写的假历史(列表最新在前,id 递增 = 越来越旧)。 + fn fake_entry(index: usize) -> HistoryEntry { + HistoryEntry { + id: format!("entry-{index}"), + raw_transcript: "transcript ".repeat(300), + final_text: "polished draft ".repeat(300), + ..Default::default() + } + } + + fn view_model_with(entries: usize) -> FrontendViewModel { + FrontendViewModel { + history_entries: (0..entries).map(fake_entry).collect(), + ..Default::default() + } + } + + #[test] + fn a_snapshot_that_fits_is_sent_whole() { + let mut view_model = view_model_with(4); + let payload = encode_view_model(&mut view_model, usize::MAX, &HISTORY_TRIM_LADDER).unwrap(); + assert_eq!( + view_model.history_entries.len(), + 4, + "nothing may be dropped" + ); + assert_eq!(view_model.history_truncated, None); + assert!(!payload.is_empty()); + } + + #[test] + fn an_oversized_snapshot_sheds_the_oldest_history_and_reports_how_many() { + // 软上限只装得下 8 条左右:第一档 25 太大,第二档 5 刚好。 + let empty = serde_json::to_vec(&FrontendViewModel::default()) + .unwrap() + .len(); + let one_entry = serde_json::to_vec(&fake_entry(0)).unwrap().len(); + let soft_limit = empty + one_entry * 8; + let mut view_model = view_model_with(30); + view_model.history_selected = 29; + + let payload = encode_view_model(&mut view_model, soft_limit, &[25, 5, 0]).unwrap(); + + assert!( + payload.len() <= soft_limit, + "a trimmed snapshot must fit the soft limit" + ); + assert_eq!( + view_model.history_entries.len(), + 5, + "the first ladder step that fits wins" + ); + // 隐藏条数按原始 30 条算,而不是按上一步的 25 条算。 + assert_eq!(view_model.history_truncated, Some(25)); + // 列表最新在前:裁掉的是尾部(最旧),最新的 5 条必须留下。 + assert_eq!(view_model.history_entries[0].id, "entry-0"); + assert_eq!(view_model.history_entries[4].id, "entry-4"); + // 选中的那条被裁掉后回到最新一条,而不是指向不存在的下标。 + assert_eq!(view_model.history_selected, 0); + } + + #[test] + fn trimming_stops_at_the_first_ladder_step_that_fits() { + let empty = serde_json::to_vec(&FrontendViewModel::default()) + .unwrap() + .len(); + let one_entry = serde_json::to_vec(&fake_entry(0)).unwrap().len(); + // 只够装 10 条:阶梯第一档 25 太大,第二档 5 装得下 → 停在 5,不再往 0 裁。 + let soft_limit = empty + one_entry * 10; + let mut view_model = view_model_with(30); + + let payload = encode_view_model(&mut view_model, soft_limit, &[25, 5, 0]).unwrap(); + + assert!(payload.len() <= soft_limit); + assert_eq!(view_model.history_entries.len(), 5); + assert_eq!(view_model.history_truncated, Some(25)); + } +} diff --git a/openless-all/app/linux-egui/src/ui/bridge.rs b/openless-all/app/linux-egui/src/ui/bridge.rs index e61285d66..9a70b3370 100644 --- a/openless-all/app/linux-egui/src/ui/bridge.rs +++ b/openless-all/app/linux-egui/src/ui/bridge.rs @@ -338,9 +338,15 @@ impl UiBridgeHost { /// /// 宿主为了判断「视图模型变没变」已经序列化过一次,这里直接拼帧、不再二次 /// 序列化;`sequence` 由桥推进,保证单调递增。 - pub fn send_snapshot_encoded(&mut self, payload: &[u8]) { + /// 发一份编码好的快照。返回是否真的发出去了。 + /// + /// 超过 [`MAX_FRAME_BYTES`] 的快照**不能**发:读端以同样的上限拒收,所以发出去的 + /// 结果是窗口读帧失败后退出。但这里也**不能**因此断开连接 —— 宿主会把窗口退出 + /// 当成「用户关窗」,紧接着重开一个,于是「超限 → 断连 → 窗口退出 → 重开」变成 + /// 每秒闪一次的循环。丢一帧只是让窗口继续显示上一份状态,宿主还会按保活节奏重试。 + pub fn send_snapshot_encoded(&mut self, payload: &[u8]) -> bool { if !self.is_connected() { - return; + return false; } let sequence = self.next_sequence; self.next_sequence += 1; @@ -351,15 +357,20 @@ impl UiBridgeHost { frame.extend_from_slice(payload); frame.extend_from_slice(b"}}"); if frame.len() > MAX_FRAME_BYTES { - log::error!("[ui-host] snapshot exceeds the UI frame limit"); - self.connection = None; - return; + log::error!( + "[ui-host] snapshot of {} bytes exceeds the {} byte UI frame limit; kept the connection, skipped this frame", + frame.len(), + MAX_FRAME_BYTES + ); + return false; } if let Some(connection) = self.connection.as_ref() { if connection.outgoing.send(Outgoing::Encoded(frame)).is_err() { self.connection = None; + return false; } } + true } pub fn send(&mut self, frame: HostToWindow) { diff --git a/openless-all/app/linux-egui/src/ui/frontend/history.rs b/openless-all/app/linux-egui/src/ui/frontend/history.rs index 6217d462c..07d84d009 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/history.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/history.rs @@ -384,6 +384,12 @@ fn list_card( } return; } + // 宿主只把最新的一批历史发过来时,列表里没有的条目必须说明白, + // 否则用户会以为记录丢了。 + if vm.history_truncated.is_some() { + truncated_notice(ui, vm, lang); + ui.add_space(8.0); + } if filtered.is_empty() { let query = vm.history_query.trim(); let message = if query.is_empty() { @@ -423,6 +429,32 @@ fn list_card( }); } +/// 历史被宿主截断时的警告条:说清楚「只载入了多少 / 还有多少没载入」, +/// 并指向设置里能真正减少历史的地方。 +fn truncated_notice(ui: &mut egui::Ui, vm: &FrontendViewModel, lang: Lang) { + let Some(hidden) = vm.history_truncated else { + return; + }; + let loaded = vm.history_entries.len(); + egui::Frame::new() + .fill(theme::WARN_SOFT) + .stroke(egui::Stroke::new(0.5, theme::WARN)) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(12, 8)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(fmt_l10n(lang, "history.truncated", &[&loaded, &hidden])) + .size(11.5) + .color(theme::WARN), + ); + ui.label( + egui::RichText::new(tr_l10n(lang, "history.truncated_hint")) + .size(11.0) + .color(theme::WARN), + ); + }); +} + fn hint(ui: &mut egui::Ui, width: f32, text: &str) { let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 56.0), egui::Sense::hover()); ui.painter().with_clip_rect(rect).text( @@ -1436,3 +1468,73 @@ mod playback_tests { assert_eq!(playback_clock(45_900), "0:45"); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// 一次 pass 里画出的所有文字,用来断言横幅真的被渲染出来。 + fn painted_text(output: &egui::FullOutput) -> String { + output + .shapes + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Text(text) => Some(text.galley.text().to_string()), + _ => None, + }) + .collect::>() + .join("\n") + } + + #[test] + fn a_truncated_history_paints_the_counts_and_the_way_to_fix_it() { + let ctx = egui::Context::default(); + let view_model = FrontendViewModel { + history_truncated: Some(23), + history_entries: (0..7) + .map(|index| HistoryEntry { + id: index.to_string(), + ..Default::default() + }) + .collect(), + ..Default::default() + }; + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(560.0, 240.0), + )), + ..Default::default() + }, + |ui| truncated_notice(ui, &view_model, Lang::ZhCn), + ); + + let text = painted_text(&output); + let expected = fmt_l10n(Lang::ZhCn, "history.truncated", &[&7, &23]); + assert!(text.contains(&expected), "the notice must print the loaded/hidden counts: {text}"); + assert!( + text.contains(tr_l10n(Lang::ZhCn, "history.truncated_hint")), + "the notice must point at the setting that shrinks history: {text}" + ); + } + + #[test] + fn a_complete_history_paints_no_notice() { + let ctx = egui::Context::default(); + let view_model = FrontendViewModel::default(); + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(560.0, 240.0), + )), + ..Default::default() + }, + |ui| truncated_notice(ui, &view_model, Lang::ZhCn), + ); + assert_eq!(painted_text(&output).trim(), ""); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index 88a17c578..909d57340 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -3410,4 +3410,47 @@ mod tests { "a focused window must not re-ask every frame: {focused:?}" ); } + /// 宿主截断历史时,**整页**必须把横幅画出来。只测 `truncated_notice` 自己不够: + /// 真正会漏掉的是 `list_card` 里那句接线。 + #[test] + fn history_page_paints_the_truncated_notice_when_the_host_trims_entries() { + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::History, + history_loading: false, + history_truncated: Some(23), + history_entries: vec![super::view_model::HistoryEntry { + id: "a".into(), + created_at: "2026-01-15T12:34:00+00:00".into(), + mode: super::view_model::OverviewMode::Raw, + style_label: "raw".into(), + raw_transcript: "kept transcript".into(), + final_text: "kept final".into(), + ..Default::default() + }], + ..Default::default() + }; + let mut painted = String::new(); + // 滚动区域要一帧才发布尺寸,所以多跑两帧再取最终画面。 + for _ in 0..3 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); + } + let expected = openless_linux_egui::fmt_l10n(zh, "history.truncated", &[&1, &23]); + assert!( + painted.contains(&expected), + "the history page must paint the truncation notice ({expected:?}): {painted}" + ); + assert!( + painted.contains(openless_linux_egui::tr_l10n(zh, "history.truncated_hint")), + "the notice must point at the setting that shrinks history" + ); + } } diff --git a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs index 4db74df6d..40ce46313 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs @@ -699,6 +699,9 @@ pub struct FrontendViewModel { pub history_query: String, pub history_selected: usize, pub history_entries: Vec, + /// 宿主因为 IPC 快照体积上限、没有把全部历史发给窗口时,这里是被隐藏的条数; + /// `None` 表示窗口看到的就是全部历史。 + pub history_truncated: Option, pub quick_note_recording: bool, /// Whether the dismissible shortcut card on the Quick Note page is hidden. pub quick_note_shortcut_hidden: bool, @@ -904,6 +907,7 @@ impl Default for FrontendViewModel { history_query: String::new(), history_selected: 0, history_entries: Vec::new(), + history_truncated: None, quick_note_recording: false, quick_note_shortcut_hidden: false, history_loading: true, From 29c48fabdc87147e85a4e52dcfb78042c233984e Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 17:28:29 +0800 Subject: [PATCH 08/35] feat(linux-egui): page large frontend collections --- openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/i18n.rs | 34 +- .../app/linux-egui/src/linux_app/window.rs | 124 ++++-- openless-all/app/linux-egui/src/main.rs | 352 ++++++++++++----- openless-all/app/linux-egui/src/ui/bridge.rs | 51 ++- .../linux-egui/src/ui/frontend/corrections.rs | 8 + .../app/linux-egui/src/ui/frontend/history.rs | 114 +----- .../linux-egui/src/ui/frontend/marketplace.rs | 8 + .../app/linux-egui/src/ui/frontend/mod.rs | 20 +- .../app/linux-egui/src/ui/frontend/paging.rs | 362 ++++++++++++++++++ .../app/linux-egui/src/ui/frontend/style.rs | 8 + .../linux-egui/src/ui/frontend/view_model.rs | 28 +- .../app/linux-egui/src/ui/frontend/vocab.rs | 8 + 13 files changed, 853 insertions(+), 266 deletions(-) create mode 100644 openless-all/app/linux-egui/src/ui/frontend/paging.rs diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index fe4afb0df..e34885bbc 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -106 +107 diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 6535a5fd7..156c93078 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -886,26 +886,6 @@ pub const CATALOG: &[Msg] = &[ "履歴の読み込みに失敗:{}", "기록 로드 실패: {}", ), - }, - Msg { - key: "history.truncated", - text: row( - "历史记录过大:仅载入最近 {} 条,另有 {} 条未载入", - "歷史記錄過大:僅載入最近 {} 條,另有 {} 條未載入", - "History is too large: only the newest {} entries are loaded, {} more are not", - "履歴が大きすぎます:最新 {} 件のみ読み込み、{} 件は未読み込みです", - "기록이 너무 큽니다: 최신 {}개만 불러왔고 {}개는 불러오지 않았습니다", - ), - }, - Msg { - key: "history.truncated_hint", - text: row( - "可在「设置 → 录音 → 历史与上下文」里调低「历史条数上限」", - "可在「設定 → 錄音 → 歷史與上下文」調低「歷史條數上限」", - "Lower “Max history entries” under Settings → Recording → History & context", - "「設定 → 録音 → 履歴とコンテキスト」で「履歴件数の上限」を下げてください", - "설정 → 녹음 → 기록 및 컨텍스트에서 “기록 개수 상한”을 낮추세요", - ), }, Msg { key: "history.select_hint", @@ -1148,6 +1128,20 @@ pub const CATALOG: &[Msg] = &[ "fcitx5 を起動し、OpenLess アドオンとデスクトップセッションの D-Bus を有効にしてください。必要なら OpenLess を再インストールしてください。修正後、このウィンドウを閉じてアプリを再起動してください。", "fcitx5를 설치하고 시작한 후 OpenLess 애드온과 현재 데스크톱 세션의 D-Bus를 활성화하세요. 필요하면 OpenLess를 다시 설치하세요. 환경을 수정한 후 이 창을 닫고 앱을 다시 시작하세요。", ), + }, + Msg { + key: "common.load_more", + text: row("加载更多", "載入更多", "Load more", "もっと読み込む", "더 불러오기"), + }, + Msg { + key: "common.loaded_of_total", + text: row( + "已载入 {} / 共 {}", + "已載入 {} / 共 {}", + "Loaded {} of {}", + "{} / {} 件を読み込み済み", + "{} / {}개 불러옴", + ), }, Msg { key: "common.saving", diff --git a/openless-all/app/linux-egui/src/linux_app/window.rs b/openless-all/app/linux-egui/src/linux_app/window.rs index f64f3efae..b28a59150 100644 --- a/openless-all/app/linux-egui/src/linux_app/window.rs +++ b/openless-all/app/linux-egui/src/linux_app/window.rs @@ -1,4 +1,5 @@ use super::*; +use std::collections::HashSet; pub(super) struct WindowState { pub(super) should_be_open: bool, @@ -61,9 +62,19 @@ fn snapshot_soft_limit() -> usize { .unwrap_or(SNAPSHOT_SOFT_LIMIT) } -/// 裁剪阶梯:历史列表最新在前,所以 `truncate` 丢掉的就是最旧的。 -/// 一档不够就再降一档,直到载荷落回软上限。 -pub(super) const HISTORY_TRIM_LADDER: [usize; 4] = [4_000, 1_000, 200, 0]; +/// 第一页的降档阶梯:正常情况下快照只带每个集合的第一页(很小), +/// 只有病态数据(单条超大的转写、巨大的图标 data URL)才会走到这里。 +/// 一档不够就再降一档,直到载荷落回软上限;`*_total` 不动,条目仍可被加载回来。 +pub(super) const FIRST_PAGE_LADDER: [usize; 5] = [200, 100, 40, 10, 0]; + +/// 把某个集合的第一页缩到 `keep` 条。返回是否真的裁了。 +fn trim_page(list: &mut Vec, keep: usize) -> bool { + if list.len() <= keep { + return false; + } + list.truncate(keep); + true +} /// 视图模型载荷指纹(FNV-1a 64)。够快,用来判断「要不要重发快照」: /// 内容没变就不发,UI 慢的时候也不会被无意义的帧糊住。 @@ -227,7 +238,7 @@ impl OpenLessEguiApp { let Some(payload) = encode_view_model( &mut self.frontend_vm, snapshot_soft_limit(), - &HISTORY_TRIM_LADDER, + &FIRST_PAGE_LADDER, ) else { log::warn!("[ui-host] view model serialization failed"); return; @@ -243,11 +254,12 @@ impl OpenLessEguiApp { } } -/// 序列化视图模型,必要时把最旧的历史裁掉,让快照回到 `soft_limit` 以内。 +/// 序列化视图模型,必要时把五个可增长集合的第一页一起降档,让快照回到 +/// `soft_limit` 以内。 /// -/// 裁的是宿主持有的这份 VM(窗口发过来的历史索引要对齐同一份列表),下一 tick 的 -/// [`OpenLessEguiApp::sync_view_model`] 会从真实缓存重新填满,所以裁掉的内容只是 -/// 「这一帧不发」,不是永久丢弃。`ladder` 的最后一项应当能裁到空列表。 +/// 裁的是宿主持有的这份 VM,下一 tick 的 [`OpenLessEguiApp::sync_view_model`] 会从 +/// 真实缓存重新填满第一页,所以裁掉的内容只是「这一帧不发」;总数仍在,窗口可用 +/// `LoadMore` 按需取回,不是永久丢弃。`ladder` 的最后一项应当能裁到空列表。 /// /// 返回 `None` 只表示序列化本身失败;裁完仍然超过软上限时会照常返回载荷,由调用方 /// 交给桥层按硬上限处理(丢帧,但不断连)。 @@ -258,25 +270,23 @@ fn encode_view_model( ) -> Option> { let mut payload = serde_json::to_vec(view_model).ok()?; if payload.len() <= soft_limit { - view_model.history_truncated = None; return Some(payload); } - // 隐藏条数要相对**原始**条数算:阶梯会一路降档,按当前长度算只会数到最后一步。 - let total = view_model.history_entries.len(); for keep in ladder.iter().copied() { - if total <= keep { - continue; - } - view_model.history_entries.truncate(keep); - if view_model.history_selected >= keep { - // 选中的那条被裁掉了:回到最新一条,别让详情栏空着。 + let mut trimmed = trim_page(&mut view_model.history_entries, keep); + trimmed |= trim_page(&mut view_model.vocab_entries, keep); + trimmed |= trim_page(&mut view_model.vocab_rules, keep); + trimmed |= trim_page(&mut view_model.marketplace_packs, keep); + trimmed |= trim_page(&mut view_model.style_packs, keep); + if !trimmed { + break; + } + if view_model.history_selected >= view_model.history_entries.len() { + // 选中的那条这一帧没发出去:回到最新一条,别让详情栏空着。 view_model.history_selected = 0; } - let hidden = total - keep; - view_model.history_truncated = Some(u32::try_from(hidden).unwrap_or(u32::MAX)); log::warn!( - "[ui-host] snapshot is over {} bytes; trimming history to {keep} entries ({hidden} hidden)", - soft_limit + "[ui-host] snapshot is over {soft_limit} bytes; first pages cut to {keep} entries" ); payload = serde_json::to_vec(view_model).ok()?; if payload.len() <= soft_limit { @@ -406,6 +416,8 @@ pub(super) struct UiClientApp { hotkey_matcher: crate::ui::local_hotkeys::LocalHotkeyMatcher, /// 本地热键边沿的发送序号(与动作序号分开,便于日志区分)。 hotkey_sequence: u64, + /// 自动加载时去重尚未回来的页请求,避免一帧一个重复 action。 + pending_collection_pages: HashSet<(frontend::paging::Collection, usize)>, /// 设置页模糊背板;只有拿到 wgpu 渲染状态(正常 GUI 进程)时存在。 backdrop: Option, } @@ -435,6 +447,7 @@ impl UiClientApp { hotkeys: None, hotkey_matcher: crate::ui::local_hotkeys::LocalHotkeyMatcher::default(), hotkey_sequence: 0, + pending_collection_pages: HashSet::new(), } } @@ -477,6 +490,25 @@ impl UiClientApp { log::info!("[ui-client] local hotkey bindings received"); self.hotkeys = Some(*bindings); } + Ok(HostToWindow::CollectionPage { + collection, + offset, + total, + items, + .. + }) => { + self.pending_collection_pages.remove(&(collection, offset)); + if self + .view_model + .apply_page(collection, offset, total, *items) + { + log::debug!( + "[ui-client] {} page at {offset} applied ({} loaded)", + collection.tag(), + collection.loaded(&self.view_model) + ); + } + } Ok(HostToWindow::Snapshot { sequence, view_model, @@ -587,6 +619,13 @@ impl UiClientApp { ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized)); } other => { + if let frontend::view_model::FrontendAction::LoadMore { collection, offset } = + &other + { + if !self.pending_collection_pages.insert((*collection, *offset)) { + continue; + } + } self.action_sequence += 1; if let Err(error) = self.client.send(WindowToHost::Action { sequence: self.action_sequence, @@ -775,7 +814,7 @@ pub(super) fn show_startup_error(error: &str, broker: Arc) #[cfg(test)] mod tests { use super::*; - use crate::ui::frontend::view_model::{FrontendViewModel, HistoryEntry}; + use crate::ui::frontend::view_model::{FrontendViewModel, HistoryEntry, VocabEntry}; /// 一条体量接近真实长转写的假历史(列表最新在前,id 递增 = 越来越旧)。 fn fake_entry(index: usize) -> HistoryEntry { @@ -797,18 +836,17 @@ mod tests { #[test] fn a_snapshot_that_fits_is_sent_whole() { let mut view_model = view_model_with(4); - let payload = encode_view_model(&mut view_model, usize::MAX, &HISTORY_TRIM_LADDER).unwrap(); + let payload = encode_view_model(&mut view_model, usize::MAX, &FIRST_PAGE_LADDER).unwrap(); assert_eq!( view_model.history_entries.len(), 4, "nothing may be dropped" ); - assert_eq!(view_model.history_truncated, None); assert!(!payload.is_empty()); } #[test] - fn an_oversized_snapshot_sheds_the_oldest_history_and_reports_how_many() { + fn an_oversized_snapshot_cuts_the_first_pages_but_keeps_the_totals() { // 软上限只装得下 8 条左右:第一档 25 太大,第二档 5 刚好。 let empty = serde_json::to_vec(&FrontendViewModel::default()) .unwrap() @@ -816,42 +854,56 @@ mod tests { let one_entry = serde_json::to_vec(&fake_entry(0)).unwrap().len(); let soft_limit = empty + one_entry * 8; let mut view_model = view_model_with(30); + view_model.history_list_total = 30; view_model.history_selected = 29; let payload = encode_view_model(&mut view_model, soft_limit, &[25, 5, 0]).unwrap(); assert!( payload.len() <= soft_limit, - "a trimmed snapshot must fit the soft limit" + "a cut snapshot must fit the soft limit" ); assert_eq!( view_model.history_entries.len(), 5, "the first ladder step that fits wins" ); - // 隐藏条数按原始 30 条算,而不是按上一步的 25 条算。 - assert_eq!(view_model.history_truncated, Some(25)); - // 列表最新在前:裁掉的是尾部(最旧),最新的 5 条必须留下。 + // 总数不动:切掉的条目仍然能通过「加载更多」拉回来。 + assert_eq!(view_model.history_list_total, 30); + // 列表最新在前:切掉的是尾部(最旧),最新的 5 条必须留下。 assert_eq!(view_model.history_entries[0].id, "entry-0"); assert_eq!(view_model.history_entries[4].id, "entry-4"); - // 选中的那条被裁掉后回到最新一条,而不是指向不存在的下标。 + // 选中的那条这一帧没发出去:回到最新一条。 assert_eq!(view_model.history_selected, 0); } #[test] - fn trimming_stops_at_the_first_ladder_step_that_fits() { + fn every_collection_shares_the_same_ladder_step() { + // 病态数据可能出在任何一个集合上,降档必须一起切。 + let mut view_model = view_model_with(30); + view_model.vocab_entries = (0..30) + .map(|index| VocabEntry { + phrase: "x".repeat(2_000), + hits: index, + enabled: true, + learned: false, + }) + .collect(); + view_model.vocab_total = 30; let empty = serde_json::to_vec(&FrontendViewModel::default()) .unwrap() .len(); - let one_entry = serde_json::to_vec(&fake_entry(0)).unwrap().len(); - // 只够装 10 条:阶梯第一档 25 太大,第二档 5 装得下 → 停在 5,不再往 0 裁。 - let soft_limit = empty + one_entry * 10; - let mut view_model = view_model_with(30); + // 只装得下 5 条左右:第一档 25 还是太大,第二档 5 刚好 —— 两个集合 + // 必须落在同一个档位上,不能一个切到 5、另一个还留着 25。 + let soft_limit = empty + 80_000; let payload = encode_view_model(&mut view_model, soft_limit, &[25, 5, 0]).unwrap(); assert!(payload.len() <= soft_limit); assert_eq!(view_model.history_entries.len(), 5); - assert_eq!(view_model.history_truncated, Some(25)); + assert_eq!(view_model.vocab_entries.len(), 5); + // 总数不动:切掉的条目仍能通过「加载更多」拉回来。 + assert_eq!(view_model.history_list_total, 0); + assert_eq!(view_model.vocab_total, 30); } } diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index f9b694796..fbc91b2c2 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -591,6 +591,9 @@ mod linux_app { quick_note_shortcut_hidden: bool, /// 本帧从 UI 收到、待回包的延迟探针序号。 pending_ui_pongs: Vec, + /// `LoadMore` 触发的分页回包:动作处理阶段拿不到桥,先排队, + /// 由宿主循环在 `apply_frontend_actions` 之后发出(同 `pending_ui_pongs`)。 + pending_collection_pages: Vec, /// Currently playing history recording (session id + player handle). history: HistoryCache, history_clip: Option<(String, openless_linux_egui::ClipPlayer)>, @@ -744,6 +747,7 @@ mod linux_app { popup_restarts: [PopupRestartBudget::default(); POPUP_KIND_COUNT], hotkeys_sent: None, pending_ui_pongs: Vec::new(), + pending_collection_pages: Vec::new(), history, history_clip: None, marketplace_items: Vec::new(), @@ -840,6 +844,7 @@ mod linux_app { popup_restarts: [PopupRestartBudget::default(); POPUP_KIND_COUNT], hotkeys_sent: None, pending_ui_pongs: Vec::new(), + pending_collection_pages: Vec::new(), history: HistoryCache::default(), history_clip: None, marketplace_items: Vec::new(), @@ -4136,57 +4141,28 @@ mod linux_app { .history_entries .get(vm.history_selected) .map(|entry| entry.id.clone()); - let history = self.history.entries.clone(); - vm.history_entries = history - .into_iter() - .map(|item| { - let has_audio = item.has_audio_recording.unwrap_or(false); - frontend::view_model::HistoryEntry { - quick_note: item.source == openless_core::HistorySource::QuickNote, - error_code: item.error_code, - id: item.id, - created_at: item.created_at, - mode: overview_mode(item.mode), - // History carries the exact style-pack id. Prefer the - // catalog name so imported packs do not collapse into the - // four base-mode labels; old records without an id still - // use the mode fallback. - style_label: item - .style_pack_id - .as_deref() - .and_then(|id| self.style_packs.iter().find(|pack| pack.id == id)) - .map(|pack| { - let builtin_default = - openless_core::builtin_style_pack_for_mode(pack.base_mode).name; - if pack.kind == openless_core::StylePackKind::Builtin - && pack.id - == openless_core::builtin_style_pack_id(pack.base_mode) - && pack.name == builtin_default - { - polish_mode_label(lang, pack.base_mode).to_string() - } else { - pack.name.clone() - } - }) - .unwrap_or_else(|| polish_mode_label(lang, item.mode).to_string()), - raw_transcript: item.raw_transcript, - final_text: item.final_text, - duration_ms: item.duration_ms, - has_audio, - asr_provider: item.asr_provider, - asr_model: item.asr_model, - asr_ms: item.asr_ms, - llm_provider: item.llm_provider, - app_name: item.app_name, - dictionary_count: item.dictionary_entry_count, - } - }) + // 历史按页下发:快照只带第一页,其余由窗口的 `LoadMore` 拉取 + // (见 `frontend::paging`),所以快照不会随历史增长而膨胀。 + vm.history_list_total = self.history.entries.len(); + vm.history_entries = self + .history + .entries + .iter() + .take(frontend::paging::page_limit()) + .cloned() + .map(|item| history_entry_view(item, &self.style_packs, lang)) .collect(); vm.history_loading = self.history.loading(); vm.history_error = self.history.error.clone(); - vm.history_selected = selected_id + // 分页之后宿主手里只有第一页:选中项在页内就按 id 重新定位; + // 不在页内(用户已经加载了更后面的页)就保持原下标不动 —— 那是窗口 + // 自己那份列表的下标,宿主无权按页去改,否则会把选中项弄丢。 + if let Some(index) = selected_id + .as_deref() .and_then(|id| vm.history_entries.iter().position(|entry| entry.id == id)) - .unwrap_or(0); + { + vm.history_selected = index; + } // In-app playback progress (dropped once the clip finishes). if self @@ -4208,25 +4184,19 @@ mod linux_app { // Vocabulary + correction rules: the library path is always wired, so // an empty store is an empty list — never an "unsupported" page. vm.vocab_unsupported = false; + vm.vocab_total = self.vocabulary.len(); vm.vocab_entries = self .vocabulary .iter() - .map(|entry| frontend::view_model::VocabEntry { - phrase: entry.phrase.clone(), - hits: entry.hits as usize, - enabled: entry.enabled, - learned: false, - }) + .take(frontend::paging::page_limit()) + .map(vocab_entry_view) .collect(); + vm.correction_rule_total = self.correction_rules.len(); vm.vocab_rules = self .correction_rules .iter() - .map(|rule| frontend::view_model::CorrectionRule { - pattern: rule.pattern.clone(), - replacement: rule.replacement.clone(), - enabled: rule.enabled, - learned: false, - }) + .take(frontend::paging::page_limit()) + .map(correction_rule_view) .collect(); vm.vocab_saved_presets = self .vocab_presets @@ -4238,33 +4208,26 @@ mod linux_app { .collect(); // Style packs: wired too; an empty list is a valid state. + // 图标是 base64 data URL,最占体积,同样按页下发。 vm.style_unsupported = false; + let selection_polish_style_pack_id = self + .preferences + .as_ref() + .map(|prefs| prefs.selection_polish_style_pack_id.as_str()) + .filter(|id| !id.is_empty()); + vm.style_pack_total = self.style_packs.len(); vm.style_packs = self .style_packs .iter() + .take(frontend::paging::page_limit()) .enumerate() - .map(|(index, pack)| frontend::view_model::StylePack { - id: pack.id.clone(), - icon_path: pack.icon_path.clone(), - icon_data_url: style_icon_urls.get(index).cloned().flatten(), - base_mode: match pack.base_mode { - openless_core::PolishMode::Raw => "raw", - openless_core::PolishMode::Light => "light", - openless_core::PolishMode::Structured => "structured", - openless_core::PolishMode::Formal => "formal", - } - .to_string(), - name: pack.name.clone(), - description: pack.description.clone(), - // Localized mode label (Core's display_name is zh-only). - tags: vec![polish_mode_label(lang, pack.base_mode).to_string()], - is_builtin: pack.kind == openless_core::StylePackKind::Builtin, - enabled: pack.enabled, - is_active: pack.active, - selection_active: self - .preferences - .as_ref() - .is_some_and(|prefs| prefs.selection_polish_style_pack_id == pack.id), + .map(|(index, pack)| { + style_pack_view( + pack, + style_icon_urls.get(index).and_then(|url| url.as_deref()), + selection_polish_style_pack_id, + lang, + ) }) .collect(); @@ -4278,21 +4241,13 @@ mod linux_app { vm.marketplace_loading = !self.marketplace_attempted; if !self.marketplace_items.is_empty() { vm.marketplace_loading = false; + vm.marketplace_total = self.marketplace_items.len(); vm.marketplace_packs = self .marketplace_items .iter() - .map(|item| frontend::view_model::MarketplacePack { - id: item.id.clone(), - name: item.name.clone(), - version: item.version.clone(), - description: item.description.clone(), - mode: item.base_mode.clone(), - author: item.author_login.clone(), - origin_author_login: item.origin_author_login.clone(), - tags: item.tags.clone(), - likes: item.like_count as u32, - downloads: item.download_count as u32, - liked: self.marketplace_my_likes.contains(&item.id), + .take(frontend::paging::page_limit()) + .map(|item| { + marketplace_pack_view(item, self.marketplace_my_likes.contains(&item.id)) }) .collect(); } @@ -4333,6 +4288,84 @@ mod linux_app { } /// Dispatch frontend actions to existing Core / backend methods. + /// 切一页给窗口。顺序必须与快照第一页完全一致(都是宿主存储的前缀), + /// 否则窗口按 `offset` 拼接会把两段不同的数据接在一起。 + fn collection_page( + &mut self, + collection: frontend::paging::Collection, + offset: usize, + ) -> (usize, frontend::paging::CollectionItems) { + use frontend::paging::{slice_page, Collection, CollectionItems}; + let lang = self.lang; + let limit = frontend::paging::page_limit(); + match collection { + Collection::History => { + let items = slice_page(&self.history.entries, offset, limit) + .into_iter() + .map(|item| history_entry_view(item, &self.style_packs, lang)) + .collect(); + (self.history.entries.len(), CollectionItems::History(items)) + } + Collection::Vocabulary => { + let items = slice_page(&self.vocabulary, offset, limit) + .iter() + .map(vocab_entry_view) + .collect(); + (self.vocabulary.len(), CollectionItems::Vocabulary(items)) + } + Collection::CorrectionRules => { + let items = slice_page(&self.correction_rules, offset, limit) + .iter() + .map(correction_rule_view) + .collect(); + ( + self.correction_rules.len(), + CollectionItems::CorrectionRules(items), + ) + } + Collection::Marketplace => { + let items = slice_page(&self.marketplace_items, offset, limit) + .iter() + .map(|item| { + marketplace_pack_view( + item, + self.marketplace_my_likes.contains(&item.id), + ) + }) + .collect(); + ( + self.marketplace_items.len(), + CollectionItems::Marketplace(items), + ) + } + Collection::StylePacks => { + // 图标 data URL 由 `style_icon_urls` 缓存,按 index 取。 + let icons = self.style_icon_urls(); + let selection = self + .preferences + .as_ref() + .map(|prefs| prefs.selection_polish_style_pack_id.clone()) + .filter(|id| !id.is_empty()); + let items = self + .style_packs + .iter() + .enumerate() + .skip(offset) + .take(limit) + .map(|(index, pack)| { + style_pack_view( + pack, + icons.get(index).and_then(|url| url.as_deref()), + selection.as_deref(), + lang, + ) + }) + .collect(); + (self.style_packs.len(), CollectionItems::StylePacks(items)) + } + } + } + fn apply_frontend_actions( &mut self, actions: Vec, @@ -4742,6 +4775,19 @@ mod linux_app { self.load_history(); self.frontend_vm.history_confirm = None; } + frontend::view_model::FrontendAction::LoadMore { collection, offset } => { + let (total, items) = self.collection_page(collection, offset); + self.pending_collection_pages + .push(HostToWindow::CollectionPage { + // 回显请求偏移:同一偏移重复请求幂等;窗口据此 + // 丢掉晚到的旧回包(见 `apply_page`)。 + request_id: offset as u64, + collection, + offset, + total, + items: Box::new(items), + }); + } frontend::view_model::FrontendAction::HistorySelect(index) => { self.frontend_vm.history_selected = index; } @@ -5894,6 +5940,122 @@ mod linux_app { } /// Core polish mode -> frontend display enum. + /// 历史记录 → 窗口展示条目。每帧只投影第一页,后续页按需投影 + /// (见 `frontend::paging`);两处共用这一份映射,字段与顺序不会走偏。 + fn history_entry_view( + item: openless_core::DictationSession, + style_packs: &[openless_core::StylePack], + lang: Lang, + ) -> frontend::view_model::HistoryEntry { + let has_audio = item.has_audio_recording.unwrap_or(false); + frontend::view_model::HistoryEntry { + quick_note: item.source == openless_core::HistorySource::QuickNote, + error_code: item.error_code, + id: item.id, + created_at: item.created_at, + mode: overview_mode(item.mode), + // History carries the exact style-pack id. Prefer the catalog name so + // imported packs do not collapse into the four base-mode labels; old + // records without an id still use the mode fallback. + style_label: item + .style_pack_id + .as_deref() + .and_then(|id| style_packs.iter().find(|pack| pack.id == id)) + .map(|pack| { + let builtin_default = + openless_core::builtin_style_pack_for_mode(pack.base_mode).name; + if pack.kind == openless_core::StylePackKind::Builtin + && pack.id == openless_core::builtin_style_pack_id(pack.base_mode) + && pack.name == builtin_default + { + polish_mode_label(lang, pack.base_mode).to_string() + } else { + pack.name.clone() + } + }) + .unwrap_or_else(|| polish_mode_label(lang, item.mode).to_string()), + raw_transcript: item.raw_transcript, + final_text: item.final_text, + duration_ms: item.duration_ms, + has_audio, + asr_provider: item.asr_provider, + asr_model: item.asr_model, + asr_ms: item.asr_ms, + llm_provider: item.llm_provider, + app_name: item.app_name, + dictionary_count: item.dictionary_entry_count, + } + } + + fn vocab_entry_view( + entry: &openless_core::DictionaryEntry, + ) -> frontend::view_model::VocabEntry { + frontend::view_model::VocabEntry { + phrase: entry.phrase.clone(), + hits: entry.hits as usize, + enabled: entry.enabled, + learned: false, + } + } + + fn correction_rule_view( + rule: &openless_core::CorrectionRule, + ) -> frontend::view_model::CorrectionRule { + frontend::view_model::CorrectionRule { + pattern: rule.pattern.clone(), + replacement: rule.replacement.clone(), + enabled: rule.enabled, + learned: false, + } + } + + fn style_pack_view( + pack: &openless_core::StylePack, + icon_data_url: Option<&str>, + selection_polish_style_pack_id: Option<&str>, + lang: Lang, + ) -> frontend::view_model::StylePack { + frontend::view_model::StylePack { + id: pack.id.clone(), + icon_path: pack.icon_path.clone(), + icon_data_url: icon_data_url.map(str::to_string), + base_mode: match pack.base_mode { + openless_core::PolishMode::Raw => "raw", + openless_core::PolishMode::Light => "light", + openless_core::PolishMode::Structured => "structured", + openless_core::PolishMode::Formal => "formal", + } + .to_string(), + name: pack.name.clone(), + description: pack.description.clone(), + // Localized mode label (Core's display_name is zh-only). + tags: vec![polish_mode_label(lang, pack.base_mode).to_string()], + is_builtin: pack.kind == openless_core::StylePackKind::Builtin, + enabled: pack.enabled, + is_active: pack.active, + selection_active: selection_polish_style_pack_id == Some(pack.id.as_str()), + } + } + + fn marketplace_pack_view( + item: &openless_core::MarketplaceListItem, + liked: bool, + ) -> frontend::view_model::MarketplacePack { + frontend::view_model::MarketplacePack { + id: item.id.clone(), + name: item.name.clone(), + version: item.version.clone(), + description: item.description.clone(), + mode: item.base_mode.clone(), + author: item.author_login.clone(), + origin_author_login: item.origin_author_login.clone(), + tags: item.tags.clone(), + likes: item.like_count as u32, + downloads: item.download_count as u32, + liked, + } + } + fn overview_mode(mode: openless_core::PolishMode) -> frontend::view_model::OverviewMode { match mode { openless_core::PolishMode::Raw => frontend::view_model::OverviewMode::Raw, @@ -7501,6 +7663,10 @@ focus_was_stolen={} focus_restored={} warnings={:?}", for sequence in std::mem::take(&mut app.pending_ui_pongs) { ui_bridge.send(HostToWindow::Pong { sequence }); } + // 分页回包与请求同一个 tick 送出:列表不会因为等下一帧而闪一下。 + for page in std::mem::take(&mut app.pending_collection_pages) { + ui_bridge.send(page); + } app.sync_view_model(); app.sync_hotkey_bindings(&mut ui_bridge); app.publish_view_model(&mut ui_bridge); diff --git a/openless-all/app/linux-egui/src/ui/bridge.rs b/openless-all/app/linux-egui/src/ui/bridge.rs index 9a70b3370..70c96e941 100644 --- a/openless-all/app/linux-egui/src/ui/bridge.rs +++ b/openless-all/app/linux-egui/src/ui/bridge.rs @@ -25,7 +25,7 @@ use serde::{Deserialize, Serialize}; use super::frontend::view_model::{FrontendAction, FrontendViewModel}; /// 协议版本:宿主与 UI 进程对不上就直接拒绝启动 UI(避免半懂不懂地渲染)。 -pub const UI_BRIDGE_VERSION: u32 = 2; +pub const UI_BRIDGE_VERSION: u32 = 3; /// 单帧上限。视图模型快照含历史列表,比弹窗协议大得多。 const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; @@ -53,10 +53,24 @@ pub enum HostToWindow { bindings: Box, }, /// 完整视图模型快照;`sequence` 单调递增。 + /// + /// 里面的大集合只是**第一页**(见 `frontend::paging`),其余由窗口用 + /// `FrontendAction::LoadMore` 按需拉取 —— 快照因此不会随数据增长而膨胀。 Snapshot { sequence: u64, view_model: Box, }, + /// 某个集合的一页数据。 + /// + /// 与快照分开走,因为写出线程只合并快照(丢帧安全);分页回包一旦被丢掉, + /// 这一页就永远缺了。`request_id` 回显请求,便于日志对账。 + CollectionPage { + request_id: u64, + collection: crate::ui::frontend::paging::Collection, + offset: usize, + total: usize, + items: Box, + }, /// 延迟探针回包。 Pong { sequence: u64, @@ -970,6 +984,41 @@ mod tests { )); assert!(read_frame::(&mut reader).unwrap().is_none()); } + /// 分页回包不能被「只留最新快照」的合并逻辑吃掉:丢掉一页就意味着那段 + /// 数据在窗口里永远缺一块(快照只带第一页)。 + #[test] + fn a_page_frame_is_never_coalesced_away_by_a_pending_snapshot() { + use crate::ui::frontend::paging::{Collection, CollectionItems}; + + let (mut writer, reader) = UnixStream::pair().unwrap(); + let (tx, rx) = mpsc::channel(); + tx.send(Outgoing::Encoded(b"first snapshot".to_vec())) + .unwrap(); + tx.send(Outgoing::Frame(HostToWindow::CollectionPage { + request_id: 200, + collection: Collection::History, + offset: 200, + total: 900, + items: Box::new(CollectionItems::History(Vec::new())), + })) + .unwrap(); + tx.send(Outgoing::Frame(HostToWindow::Shutdown)).unwrap(); + write_outgoing(&mut writer, rx); + drop(writer); + + let mut reader = BufReader::new(reader); + // 页面帧抢在待发快照之前发出(快照被 Shutdown 丢掉,页面帧不受影响)。 + assert!(matches!( + read_frame::(&mut reader).unwrap(), + Some(HostToWindow::CollectionPage { offset: 200, .. }) + )); + assert!(matches!( + read_frame::(&mut reader).unwrap(), + Some(HostToWindow::Shutdown) + )); + assert!(read_frame::(&mut reader).unwrap().is_none()); + } + #[test] fn eof_stops_both_client_workers_before_the_client_is_dropped() { let dir = temp_dir("eof-workers"); diff --git a/openless-all/app/linux-egui/src/ui/frontend/corrections.rs b/openless-all/app/linux-egui/src/ui/frontend/corrections.rs index f376ef4c4..e2d2e5181 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/corrections.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/corrections.rs @@ -169,6 +169,14 @@ pub fn page(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec String { - output - .shapes - .iter() - .filter_map(|clipped| match &clipped.shape { - egui::Shape::Text(text) => Some(text.galley.text().to_string()), - _ => None, - }) - .collect::>() - .join("\n") - } - - #[test] - fn a_truncated_history_paints_the_counts_and_the_way_to_fix_it() { - let ctx = egui::Context::default(); - let view_model = FrontendViewModel { - history_truncated: Some(23), - history_entries: (0..7) - .map(|index| HistoryEntry { - id: index.to_string(), - ..Default::default() - }) - .collect(), - ..Default::default() - }; - let output = crate::ui::frontend::run_pass( - &ctx, - egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size( - egui::Pos2::ZERO, - egui::vec2(560.0, 240.0), - )), - ..Default::default() - }, - |ui| truncated_notice(ui, &view_model, Lang::ZhCn), - ); - - let text = painted_text(&output); - let expected = fmt_l10n(Lang::ZhCn, "history.truncated", &[&7, &23]); - assert!(text.contains(&expected), "the notice must print the loaded/hidden counts: {text}"); - assert!( - text.contains(tr_l10n(Lang::ZhCn, "history.truncated_hint")), - "the notice must point at the setting that shrinks history: {text}" - ); - } - - #[test] - fn a_complete_history_paints_no_notice() { - let ctx = egui::Context::default(); - let view_model = FrontendViewModel::default(); - let output = crate::ui::frontend::run_pass( - &ctx, - egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size( - egui::Pos2::ZERO, - egui::vec2(560.0, 240.0), - )), - ..Default::default() - }, - |ui| truncated_notice(ui, &view_model, Lang::ZhCn), - ); - assert_eq!(painted_text(&output).trim(), ""); - } -} diff --git a/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs b/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs index e1a8dd286..72619b03b 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs @@ -294,6 +294,14 @@ pub fn marketplace_page( .map(|(index, _)| index) .collect(); + super::paging::load_more_footer( + ui, + lang, + super::paging::Collection::Marketplace, + vm, + false, + actions, + ); if visible.is_empty() { let (title, hint) = if liked_only { ( diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index 909d57340..b03968f62 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -6,6 +6,7 @@ pub mod layout; pub mod marketplace; pub mod overview; pub mod pages; +pub mod paging; pub mod popups; pub mod selection_ask; pub mod settings; @@ -3410,17 +3411,17 @@ mod tests { "a focused window must not re-ask every frame: {focused:?}" ); } - /// 宿主截断历史时,**整页**必须把横幅画出来。只测 `truncated_notice` 自己不够: - /// 真正会漏掉的是 `list_card` 里那句接线。 + /// 宿主分页下发历史时,**整页**必须画出「已载入 X / 共 Y」并给出继续加载的入口。 + /// 只测 `load_more_footer` 自己不够:真正会漏掉的是 `list_card` 里那句接线。 #[test] - fn history_page_paints_the_truncated_notice_when_the_host_trims_entries() { + fn history_page_paints_the_paging_footer_when_more_entries_exist() { let ctx = egui::Context::default(); let zh = openless_linux_egui::Lang::ZhCn; let mut vm = FrontendViewModel { lang: zh, active_page: Page::History, history_loading: false, - history_truncated: Some(23), + history_list_total: 1_234, history_entries: vec![super::view_model::HistoryEntry { id: "a".into(), created_at: "2026-01-15T12:34:00+00:00".into(), @@ -3433,24 +3434,25 @@ mod tests { ..Default::default() }; let mut painted = String::new(); + let mut actions = Vec::new(); // 滚动区域要一帧才发布尺寸,所以多跑两帧再取最终画面。 for _ in 0..3 { ctx.begin_pass(egui::RawInput { screen_rect: Some(viewport()), ..Default::default() }); - let mut actions = Vec::new(); + actions.clear(); render(&ctx, &mut vm, &mut actions); painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); } - let expected = openless_linux_egui::fmt_l10n(zh, "history.truncated", &[&1, &23]); + let expected = openless_linux_egui::fmt_l10n(zh, "common.loaded_of_total", &[&1, &1_234]); assert!( painted.contains(&expected), - "the history page must paint the truncation notice ({expected:?}): {painted}" + "the history page must show paging progress ({expected:?}): {painted}" ); assert!( - painted.contains(openless_linux_egui::tr_l10n(zh, "history.truncated_hint")), - "the notice must point at the setting that shrinks history" + painted.contains(openless_linux_egui::tr_l10n(zh, "common.load_more")), + "the history page must offer the way to load the rest" ); } } diff --git a/openless-all/app/linux-egui/src/ui/frontend/paging.rs b/openless-all/app/linux-egui/src/ui/frontend/paging.rs new file mode 100644 index 000000000..5d00363f3 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/paging.rs @@ -0,0 +1,362 @@ +//! 大集合的分页:快照只带每个集合的第一页,其余由窗口按需拉取。 +//! +//! 背景:视图模型里有一批「随使用无限增长」的集合(历史、词库、纠错规则、 +//! 市场列表、风格包含 base64 图标)。整份发过去的做法在数据变大后会把 IPC 帧 +//! 顶到上限,窗口要么拿不到数据、要么(旧实现)被宿主断开重开。 +//! +//! 约定: +//! * 宿主侧的集合顺序与窗口看到的顺序**始终一致**(都是宿主存储的前缀), +//! 所以两边的下标语义天然相同 —— 窗口发出的 `HistoryPlay(index)` 之类在这里 +//! 不需要改成 id。 +//! * 快照里的 `*_entries` 只是第一页,`*_total` 才是真实条数;窗口据此显示 +//! 「已载入 X / 共 Y」并发出 [`FrontendAction::LoadMore`]。 +//! * 过滤/搜索仍由窗口在**已载入**的前缀上做(历史搜索除外,见 `linux_app`), +//! 所以筛选不会与分页错位:窗口发出的下标永远是它自己那份列表的下标。 +//! +//! 条目数先到上限就停:按字节预算需要逐条序列化,而它每帧都会跑一遍,代价太大。 + +use eframe::egui; +use openless_linux_egui::{fmt_l10n, tr_l10n, Lang}; +use serde::{Deserialize, Serialize}; + +use super::layout::{self, ButtonKind}; +use super::theme; +use super::view_model::{ + CorrectionRule, FrontendAction, FrontendViewModel, HistoryEntry, MarketplacePack, StylePack, + VocabEntry, +}; + +/// 一页最多多少条。 +pub const PAGE_MAX_ITEMS: usize = 200; + +/// 一次发多少条。`OPENLESS_UI_PAGE_ITEMS=` 可以压低它,用来在设备上 +/// 演练「已载入 X / 共 Y + 加载更多」(否则要攒够 200 条才有分页可看)。 +pub fn page_limit() -> usize { + std::env::var("OPENLESS_UI_PAGE_ITEMS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(PAGE_MAX_ITEMS) +} + +/// 从 `items[offset..]` 取一页。宿主发第一页和后续页都走这里,保证两边 +/// 看到的是同一个顺序前缀 —— 窗口按下标拼接、宿主按下标解析动作,靠的就是它。 +pub fn slice_page(items: &[T], offset: usize, limit: usize) -> Vec { + items.iter().skip(offset).take(limit).cloned().collect() +} + +/// 自动加载的上限:搜索/筛选把可见条目压得很小时窗口会替用户往下拉, +/// 但拉到这个条数就停在按钮上,避免一次查询把整个库都灌进来。 +pub const AUTO_LOAD_MAX_ITEMS: usize = 5_000; + +/// 五个可增长的集合。 +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] +pub enum Collection { + History, + Vocabulary, + CorrectionRules, + Marketplace, + StylePacks, +} + +impl Collection { + /// 一页数据回包时用的名字(日志与测试里可读)。 + pub fn tag(self) -> &'static str { + match self { + Collection::History => "history", + Collection::Vocabulary => "vocabulary", + Collection::CorrectionRules => "correction_rules", + Collection::Marketplace => "marketplace", + Collection::StylePacks => "style_packs", + } + } + + /// 窗口当前已载入这个集合的多少条。 + pub fn loaded(self, view_model: &FrontendViewModel) -> usize { + match self { + Collection::History => view_model.history_entries.len(), + Collection::Vocabulary => view_model.vocab_entries.len(), + Collection::CorrectionRules => view_model.vocab_rules.len(), + Collection::Marketplace => view_model.marketplace_packs.len(), + Collection::StylePacks => view_model.style_packs.len(), + } + } + + /// 宿主侧一共有多少条(快照带来的真实条数)。 + pub fn total(self, view_model: &FrontendViewModel) -> usize { + match self { + Collection::History => view_model.history_list_total, + Collection::Vocabulary => view_model.vocab_total, + Collection::CorrectionRules => view_model.correction_rule_total, + Collection::Marketplace => view_model.marketplace_total, + Collection::StylePacks => view_model.style_pack_total, + } + } +} + +/// 一页的数据。每个变体对应 [`Collection`] 的一个集合。 +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum CollectionItems { + History(Vec), + Vocabulary(Vec), + CorrectionRules(Vec), + Marketplace(Vec), + StylePacks(Vec), +} + +impl CollectionItems { + pub fn collection(&self) -> Collection { + match self { + CollectionItems::History(_) => Collection::History, + CollectionItems::Vocabulary(_) => Collection::Vocabulary, + CollectionItems::CorrectionRules(_) => Collection::CorrectionRules, + CollectionItems::Marketplace(_) => Collection::Marketplace, + CollectionItems::StylePacks(_) => Collection::StylePacks, + } + } +} + +impl FrontendViewModel { + /// 收下一页数据。返回是否真的并进去了。 + /// + /// * `offset == loaded`:正常追加(用户点了「加载更多」或自动加载)。 + /// * `offset < loaded`:回包比手上更旧 —— 截断到 `offset` 再补上新的一页, + /// 这样「先发出去的请求晚回来」也不会把列表拼歪。 + /// * `offset > loaded`:中间那页还没到(帧被跳过或请求乱序),先丢掉; + /// 下一帧的快照会给回正确的前缀。 + pub fn apply_page( + &mut self, + collection: Collection, + offset: usize, + total: usize, + items: CollectionItems, + ) -> bool { + if items.collection() != collection { + log::warn!( + "[ui-client] page for {} carried {} items; ignored", + collection.tag(), + items.collection().tag() + ); + return false; + } + let loaded = collection.loaded(self); + if offset > loaded { + log::debug!( + "[ui-client] {} page at {offset} arrived with only {loaded} loaded; ignored", + collection.tag() + ); + return false; + } + let changed = match (collection, items) { + (Collection::History, CollectionItems::History(items)) => { + rewrite(&mut self.history_entries, offset, items) + } + (Collection::Vocabulary, CollectionItems::Vocabulary(items)) => { + rewrite(&mut self.vocab_entries, offset, items) + } + (Collection::CorrectionRules, CollectionItems::CorrectionRules(items)) => { + rewrite(&mut self.vocab_rules, offset, items) + } + (Collection::Marketplace, CollectionItems::Marketplace(items)) => { + rewrite(&mut self.marketplace_packs, offset, items) + } + (Collection::StylePacks, CollectionItems::StylePacks(items)) => { + rewrite(&mut self.style_packs, offset, items) + } + _ => false, + }; + match collection { + Collection::History => self.history_list_total = total, + Collection::Vocabulary => self.vocab_total = total, + Collection::CorrectionRules => self.correction_rule_total = total, + Collection::Marketplace => self.marketplace_total = total, + Collection::StylePacks => self.style_pack_total = total, + } + changed + } +} + +/// 把 `items` 放到 `list[offset..]`:先截断到 `offset`(等于追加时是空操作)。 +fn rewrite(list: &mut Vec, offset: usize, items: Vec) -> bool { + if items.is_empty() && offset == list.len() { + return false; + } + list.truncate(offset); + list.extend(items); + true +} + +/// 列表底部的「已载入 X / 共 Y」+「加载更多」。 +/// +/// 只有还有没载入的条目时才画;`auto` 为真表示窗口正在替用户自动加载 +/// (搜索/筛选把可见条目压得很小时),这时只显示进度、不画按钮。 +pub fn load_more_footer( + ui: &mut egui::Ui, + lang: Lang, + collection: Collection, + view_model: &FrontendViewModel, + auto: bool, + actions: &mut Vec, +) { + let loaded = collection.loaded(view_model); + let total = collection.total(view_model); + if total <= loaded { + return; + } + let width = ui.available_width(); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 34.0), egui::Sense::hover()); + let progress = fmt_l10n(lang, "common.loaded_of_total", &[&loaded, &total]); + let painter = ui.painter().with_clip_rect(rect); + painter.text( + egui::pos2(rect.left() + 2.0, rect.center().y), + egui::Align2::LEFT_CENTER, + &progress, + egui::FontId::proportional(11.0), + theme::INK_4, + ); + if auto { + painter.text( + egui::pos2(rect.right() - 2.0, rect.center().y), + egui::Align2::RIGHT_CENTER, + tr_l10n(lang, "common.loading"), + egui::FontId::proportional(11.0), + theme::INK_4, + ); + // 搜索/筛选下的自动加载:宿主一个 tick 内就回包,下一帧 loaded 变大, + // 条件自然不再成立;到上限后停在按钮上,交回给用户。 + if loaded < AUTO_LOAD_MAX_ITEMS { + actions.push(FrontendAction::LoadMore { + collection, + offset: loaded, + }); + } + return; + } + let more = tr_l10n(lang, "common.load_more"); + let button_width = layout::text_width(ui, more, 12.5) + 30.0; + let button_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - button_width, rect.top() + 3.0), + egui::vec2(button_width, 28.0), + ); + if layout::action_button(ui, button_rect, more, None, ButtonKind::Ghost).clicked() { + actions.push(FrontendAction::LoadMore { + collection, + offset: loaded, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn history_entries(count: usize) -> Vec { + (0..count) + .map(|index| HistoryEntry { + id: format!("entry-{index}"), + ..Default::default() + }) + .collect() + } + + #[test] + fn a_page_appends_at_the_end() { + let mut view_model = FrontendViewModel { + history_entries: history_entries(2), + ..Default::default() + }; + let applied = view_model.apply_page( + Collection::History, + 2, + 5, + CollectionItems::History(history_entries(3)), + ); + + assert!(applied); + assert_eq!(view_model.history_entries.len(), 5); + assert_eq!(view_model.history_list_total, 5); + assert_eq!(view_model.history_entries[2].id, "entry-0"); + assert_eq!(view_model.history_entries[4].id, "entry-2"); + } + + #[test] + fn a_late_page_replaces_the_tail_instead_of_duplicating() { + // 先发出的请求晚回来:offset 比手上更旧,必须截断重写,不能拼歪。 + let mut view_model = FrontendViewModel { + history_entries: history_entries(5), + history_list_total: 9, + ..Default::default() + }; + let applied = view_model.apply_page( + Collection::History, + 3, + 9, + CollectionItems::History(history_entries(2)), + ); + + assert!(applied); + assert_eq!(view_model.history_entries.len(), 5, "3 + 2"); + assert_eq!(view_model.history_entries[3].id, "entry-0"); + } + + #[test] + fn a_page_with_a_gap_is_ignored() { + let mut view_model = FrontendViewModel { + history_entries: history_entries(2), + ..Default::default() + }; + let applied = view_model.apply_page( + Collection::History, + 4, + 9, + CollectionItems::History(history_entries(2)), + ); + + assert!( + !applied, + "a page with a gap must be dropped and wait for the next snapshot" + ); + assert_eq!(view_model.history_entries.len(), 2); + } + + #[test] + fn a_page_for_another_collection_is_refused() { + let mut view_model = FrontendViewModel::default(); + let applied = view_model.apply_page( + Collection::History, + 0, + 1, + CollectionItems::Vocabulary(Vec::new()), + ); + + assert!(!applied); + assert!(view_model.history_entries.is_empty()); + } + + #[test] + fn pages_are_prefixes_of_the_same_list() { + let items: Vec = (0..5).map(|index| format!("i{index}")).collect(); + + assert_eq!(slice_page(&items, 0, 2), vec!["i0", "i1"]); + assert_eq!(slice_page(&items, 2, 2), vec!["i2", "i3"]); + // 越界只给剩下的那几条,不会报错也不会绕回开头。 + assert_eq!(slice_page(&items, 4, 9), vec!["i4"]); + assert!(slice_page(&items, 9, 2).is_empty()); + } + + #[test] + fn totals_track_each_collection() { + let view_model = FrontendViewModel { + history_entries: history_entries(3), + history_list_total: 300, + vocab_entries: Vec::new(), + vocab_total: 12, + ..Default::default() + }; + + assert_eq!(Collection::History.loaded(&view_model), 3); + assert_eq!(Collection::History.total(&view_model), 300); + assert_eq!(Collection::Vocabulary.loaded(&view_model), 0); + assert_eq!(Collection::Vocabulary.total(&view_model), 12); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/style.rs b/openless-all/app/linux-egui/src/ui/frontend/style.rs index 9e9ec99d3..f2de9e335 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/style.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/style.rs @@ -266,6 +266,14 @@ fn pack_grid( }; let card_width = ((grid_width - GAP * (columns - 1) as f32) / columns as f32).max(1.0); + super::paging::load_more_footer( + ui, + lang, + super::paging::Collection::StylePacks, + vm, + false, + actions, + ); let pack_indices: Vec = vm .style_packs .iter() diff --git a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs index 40ce46313..6cfb420d4 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs @@ -68,6 +68,12 @@ pub enum FrontendAction { MarketplaceToggleLike(usize), MarketplaceCloseMine, /// Select a history entry (index into `history_entries`). + /// 拉取某个集合的下一页;宿主按同样的顺序切片回发(见 [`super::paging`])。 + /// `offset` 用窗口当前的已载入条数,因为宿主只知道完整集合、不知道窗口手上有多少。 + LoadMore { + collection: super::paging::Collection, + offset: usize, + }, HistorySelect(usize), /// Re-read the history list from Core. HistoryRefresh, @@ -699,9 +705,11 @@ pub struct FrontendViewModel { pub history_query: String, pub history_selected: usize, pub history_entries: Vec, - /// 宿主因为 IPC 快照体积上限、没有把全部历史发给窗口时,这里是被隐藏的条数; - /// `None` 表示窗口看到的就是全部历史。 - pub history_truncated: Option, + /// 列表里的历史总条数(概览页的 `history_total` 是「累计听写」统计口径, + /// 两者不是一回事)。历史按页下发(见 [`super::paging`]),`history_entries` + /// 只是已载入的前缀;`history_list_total > history_entries.len()` 时窗口显示 + /// 「已载入 X / 共 Y」并可继续加载。 + pub history_list_total: usize, pub quick_note_recording: bool, /// Whether the dismissible shortcut card on the Quick Note page is hidden. pub quick_note_shortcut_hidden: bool, @@ -718,7 +726,11 @@ pub struct FrontendViewModel { // Vocab pub vocab_entries: Vec, + /// 词库总条数(`vocab_entries` 只是已载入的前缀)。 + pub vocab_total: usize, pub vocab_rules: Vec, + /// 纠错规则总条数(`vocab_rules` 只是已载入的前缀)。 + pub correction_rule_total: usize, /// 纠正规则页的「只看自动收集」筛选(Tauri 的 `onlyLearnedRules`)。 pub vocab_rules_only_learned: bool, /// 0 = all, 1 = auto-collected, 2 = manual. @@ -737,6 +749,8 @@ pub struct FrontendViewModel { // Style pub style_packs: Vec, + /// 风格包总条数(`style_packs` 只是已载入的前缀;图标 data URL 很占体积)。 + pub style_pack_total: usize, pub style_selected: usize, pub style_selection_workflow: bool, pub style_editor_open: bool, @@ -772,6 +786,8 @@ pub struct FrontendViewModel { pub marketplace_query: String, pub marketplace_sort: MarketplaceSort, pub marketplace_packs: Vec, + /// 市场列表总条数(`marketplace_packs` 只是已载入的前缀)。 + pub marketplace_total: usize, pub marketplace_selected: Option, pub marketplace_detail_prompt: Option, pub marketplace_mine_open: bool, @@ -907,7 +923,7 @@ impl Default for FrontendViewModel { history_query: String::new(), history_selected: 0, history_entries: Vec::new(), - history_truncated: None, + history_list_total: 0, quick_note_recording: false, quick_note_shortcut_hidden: false, history_loading: true, @@ -919,7 +935,9 @@ impl Default for FrontendViewModel { history_repolish_result: None, history_repolish_error: None, vocab_entries: Vec::new(), + vocab_total: 0, vocab_rules: Vec::new(), + correction_rule_total: 0, vocab_rules_only_learned: false, vocab_filter: 0, vocab_query: String::new(), @@ -934,6 +952,7 @@ impl Default for FrontendViewModel { vocab_error: None, vocab_unsupported: true, style_packs: Vec::new(), + style_pack_total: 0, style_selected: 0, style_selection_workflow: false, style_editor_open: false, @@ -963,6 +982,7 @@ impl Default for FrontendViewModel { marketplace_query: String::new(), marketplace_sort: MarketplaceSort::Popular, marketplace_packs: Vec::new(), + marketplace_total: 0, marketplace_selected: None, marketplace_detail_prompt: None, marketplace_mine_open: false, diff --git a/openless-all/app/linux-egui/src/ui/frontend/vocab.rs b/openless-all/app/linux-egui/src/ui/frontend/vocab.rs index 40e91c43c..9b7dee039 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/vocab.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/vocab.rs @@ -188,6 +188,14 @@ pub fn page(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec Date: Sun, 27 Sep 2026 17:51:06 +0800 Subject: [PATCH 09/35] ci: align Linux egui workflow name --- .github/workflows/check-linux-egui.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-linux-egui.yml b/.github/workflows/check-linux-egui.yml index 0e19fba96..fb769bd5b 100644 --- a/.github/workflows/check-linux-egui.yml +++ b/.github/workflows/check-linux-egui.yml @@ -1,4 +1,4 @@ -name: Linux egui build and backend checks +name: Linux Core, egui and deb/rpm checks # Only the CI and tag-release workflows call this deb/rpm build and verification path. # No standalone push trigger: a Linux PR must show one combined Linux check. From ad9f60e2348a12f7bcd2c70b5eba89f9d8aac0b5 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 19:01:05 +0800 Subject: [PATCH 10/35] fix: degrade gracefully when fcitx5 is unavailable --- openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/i18n.rs | 20 +++ openless-all/app/linux-egui/src/main.rs | 68 +++++++- openless-all/app/linux-egui/src/runtime.rs | 148 ++++++++---------- .../app/linux-egui/src/ui/frontend/mod.rs | 92 +++++++++++ .../linux-egui/src/ui/frontend/view_model.rs | 6 + 6 files changed, 249 insertions(+), 87 deletions(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index e34885bbc..3b20426c0 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -107 +108 diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 156c93078..89f7f53fc 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -1129,6 +1129,26 @@ pub const CATALOG: &[Msg] = &[ "fcitx5를 설치하고 시작한 후 OpenLess 애드온과 현재 데스크톱 세션의 D-Bus를 활성화하세요. 필요하면 OpenLess를 다시 설치하세요. 환경을 수정한 후 이 창을 닫고 앱을 다시 시작하세요。", ), }, + Msg { + key: "startup.input_method_unavailable_title", + text: row( + "输入法服务不可用", + "輸入法服務無法使用", + "Input method unavailable", + "入力メソッドを利用できません", + "입력기 서비스를 사용할 수 없음", + ), + }, + Msg { + key: "startup.input_method_unavailable", + text: row( + "输入法服务当前不可用。应用仍会继续运行,但全局快捷键和划词功能暂不可用。请安装并启动 fcitx5、启用 OpenLess 插件,并确认当前桌面会话的 D-Bus 可用。详情:{}", + "輸入法服務目前無法使用。應用程式仍會繼續執行,但全域快捷鍵和選取文字功能暫時無法使用。請安裝並啟動 fcitx5、啟用 OpenLess 插件,並確認目前桌面工作階段的 D-Bus 可用。詳情:{}", + "The input method service is unavailable. The app will continue running, but global hotkeys and selection tools are temporarily unavailable. Install and start fcitx5, enable the OpenLess addon, and check the desktop session's D-Bus. Details: {}", + "入力メソッドサービスを利用できません。アプリは起動を続けますが、グローバルショートカットと選択ツールは一時的に利用できません。fcitx5 と OpenLess アドオンを有効にし、デスクトップセッションの D-Bus を確認してください。詳細:{}", + "입력기 서비스를 사용할 수 없습니다. 앱은 계속 실행되지만 전역 단축키와 선택 도구를 일시적으로 사용할 수 없습니다. fcitx5와 OpenLess 애드온을 설치·시작하고 데스크톱 세션의 D-Bus를 확인하세요. 세부 정보: {}", + ), + }, Msg { key: "common.load_more", text: row("加载更多", "載入更多", "Load more", "もっと読み込む", "더 불러오기"), diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index fbc91b2c2..7011c53dd 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -621,6 +621,8 @@ mod linux_app { style_editor: Option, status: String, startup_error: Option, + /// Non-fatal startup/runtime warning shown in the UI overlay. + runtime_warning: Option, locale_pref: LocalePref, lang: Lang, active_page: shell::Page, @@ -764,6 +766,7 @@ mod linux_app { style_editor: None, status: tr_l10n(lang, "status.core_started").to_string(), startup_error: None, + runtime_warning: None, locale_pref, lang, active_page: shell::Page::Overview, @@ -861,6 +864,7 @@ mod linux_app { style_editor: None, status: tr_l10n(lang, "status.startup_failed").to_string(), startup_error: Some(error), + runtime_warning: None, locale_pref, lang, active_page: shell::Page::Overview, @@ -3803,6 +3807,7 @@ mod linux_app { }; vm.status = self.status.clone(); + vm.runtime_warning = self.runtime_warning.clone(); vm.quick_note_recording = backend.as_ref().is_some_and(|backend| { backend.dictation_output_target() == Some(openless_core::DictationOutputTarget::QuickNote) @@ -4414,6 +4419,9 @@ mod linux_app { self.frontend_vm.settings_open = false; self.frontend_vm.active_page = frontend::view_model::Page::Overview; } + frontend::view_model::FrontendAction::DismissRuntimeWarning => { + self.runtime_warning = None; + } frontend::view_model::FrontendAction::SidebarToggleStyle => { self.frontend_vm.style_open = !self.frontend_vm.style_open; } @@ -7627,6 +7635,7 @@ focus_was_stolen={} focus_restored={} warnings={:?}", native: LinuxNativeRuntime, tray: Option, start_minimized: bool, + runtime_warning: Option, ) -> Result<(), String> { let socket = bridge::ui_socket_path(runtime_dir); let tray_available = tray.is_some(); @@ -7634,6 +7643,7 @@ focus_was_stolen={} focus_restored={} warnings={:?}", let window_should_be_open = !start_minimized || !tray_available; let ctx = egui::Context::default(); let mut app = OpenLessEguiApp::new(tokio, Ok(native), tray, window_should_be_open); + app.runtime_warning = runtime_warning; let mut ui_bridge = UiBridgeHost::bind(socket.clone()) .map_err(|error| format!("UI bridge bind failed: {error}"))?; log::info!( @@ -7761,8 +7771,33 @@ Internal flags (set by OpenLess itself, not for regular use): if let Err(error) = openless_linux_egui::init_file_logger(&config.data_dir) { eprintln!("OpenLess file logger unavailable: {error}"); } - ensure_fcitx5_ready(&config)?; - let hotkeys = Some(Fcitx5HotkeyListener::start().map_err(|error| error.to_string())?); + // fcitx5 is an optional integration: a missing daemon, addon, or + // session bus must not prevent the Core/UI from starting. + let lang = load_locale_pref().resolve(); + let mut input_method_error = match ensure_fcitx5_ready(&config) { + Ok(()) => None, + Err(error) => { + log::warn!( + "[fcitx] degraded startup (readiness check failed); global hotkeys disabled: {error}" + ); + Some(error) + } + }; + let hotkeys = if input_method_error.is_none() { + match Fcitx5HotkeyListener::start() { + Ok(listener) => Some(listener), + Err(error) => { + let error = error.to_string(); + log::warn!( + "[fcitx] degraded startup (listener failed); global hotkeys disabled: {error}" + ); + input_method_error = Some(error); + None + } + } + } else { + None + }; config.platform = LinuxCapabilitySnapshot::detect(tray_available).capabilities; let backend = { // Construction captures the existing executor for cpal/native @@ -7774,16 +7809,19 @@ Internal flags (set by OpenLess itself, not for regular use): .build() .map_err(|error| error.to_string())? }; - tokio + let native = tokio .block_on(LinuxNativeRuntime::start( backend, Some(Arc::clone(&broker)), hotkeys, )) - .map_err(|error| error.to_string()) + .map_err(|error| error.to_string())?; + let runtime_warning = input_method_error + .map(|error| fmt_l10n(lang, "startup.input_method_unavailable", &[&error])); + Ok::<_, String>((native, runtime_warning)) })(); // 常驻宿主:本进程不再创建窗口,窗口交给独立的 UI 进程。 - let native = match native { + let (native, runtime_warning) = match native { Ok(native) => native, Err(error) => { drop(tray); @@ -7791,7 +7829,14 @@ Internal flags (set by OpenLess itself, not for regular use): return Err(error); } }; - if let Err(error) = run_host(&runtime_dir, tokio, native, tray, start_minimized) { + if let Err(error) = run_host( + &runtime_dir, + tokio, + native, + tray, + start_minimized, + runtime_warning, + ) { window::show_startup_error(&error, Arc::clone(&broker)); return Err(error); } @@ -7865,6 +7910,17 @@ Internal flags (set by OpenLess itself, not for regular use): assert_ne!(snapshot_fingerprint(&a), snapshot_fingerprint(&b)); } + #[test] + fn the_runtime_warning_can_be_dismissed_without_stopping_the_host() { + let mut app = fixture_app(true); + app.runtime_warning = Some("fixture warning".into()); + app.apply_frontend_actions( + vec![frontend::view_model::FrontendAction::DismissRuntimeWarning], + &egui::Context::default(), + ); + assert!(app.runtime_warning.is_none()); + } + #[test] fn the_host_keeps_running_when_the_window_says_goodbye() { let mut app = fixture_app(true); diff --git a/openless-all/app/linux-egui/src/runtime.rs b/openless-all/app/linux-egui/src/runtime.rs index 1f1fc0ae7..66c3c309b 100644 --- a/openless-all/app/linux-egui/src/runtime.rs +++ b/openless-all/app/linux-egui/src/runtime.rs @@ -34,23 +34,29 @@ impl LinuxNativeRuntime { let initialized = async { // fcitx5 starts with no OpenLess shortcuts on a clean installation. // Hydrate its native registrations from the same Core target used by - // settings transactions before accepting any input. Equal previous/next - // values intentionally force the effect without rewriting preferences. - let target = - openless_core::HotkeyRuntimeTarget::from(&backend.backend.get_preferences()); - backend - .settings_runtime - .commit( - &openless_core::SettingsEffectPlan { - hotkeys: Some(openless_core::SettingsValueChange { - previous: target.clone(), - next: target, - }), - ..Default::default() - }, - &mut openless_core::SettingsEffectReceipt::default(), - ) - .map_err(|failure| failure.error)?; + // settings transactions before accepting any input. When the optional + // listener is unavailable, skip this effect entirely: attempting a + // registration against a missing DBus service would make app startup + // fatal, which defeats the degraded-mode contract. + if hotkeys.is_some() { + let target = + openless_core::HotkeyRuntimeTarget::from(&backend.backend.get_preferences()); + backend + .settings_runtime + .commit( + &openless_core::SettingsEffectPlan { + hotkeys: Some(openless_core::SettingsValueChange { + previous: target.clone(), + next: target, + }), + ..Default::default() + }, + &mut openless_core::SettingsEffectReceipt::default(), + ) + .map_err(|failure| failure.error)?; + } else { + log::debug!("[fcitx] skipped native hotkey hydration; listener unavailable"); + } let startup = backend.backend.start().await?; openless_core::require_backend_contract_version(&startup.contract_version)?; let preferences = backend.backend.get_preferences(); @@ -200,18 +206,18 @@ mod tests { use super::*; #[derive(Default)] - struct StartupHotkeys( - std::sync::Mutex>, - bool, - ); + struct RecordingSettingsEffects { + targets: std::sync::Mutex>, + fail_registration: bool, + } - impl crate::LinuxSettingsEffects for StartupHotkeys { + impl crate::LinuxSettingsEffects for RecordingSettingsEffects { fn apply_hotkeys( &self, target: &openless_core::HotkeyRuntimeTarget, ) -> Result<(), BackendError> { - self.0.lock().unwrap().push(target.clone()); - if self.1 { + self.targets.lock().unwrap().push(target.clone()); + if self.fail_registration { return Err(BackendError::new( BackendErrorCode::Platform, "required hotkey registration failed", @@ -275,7 +281,7 @@ mod tests { .unwrap(), ); let backend = fixture_backend(&data_dir, services); - let hotkeys = Arc::new(StartupHotkeys::default()); + let hotkeys = Arc::new(RecordingSettingsEffects::default()); let runtime = LinuxNativeRuntime::start( LinuxBackendRuntime { backend: Arc::clone(&backend), @@ -291,12 +297,9 @@ mod tests { .unwrap(); assert!(backend.snapshot().running); - assert_eq!( - hotkeys.0.lock().unwrap().as_slice(), - &[openless_core::HotkeyRuntimeTarget::from( - &backend.get_preferences() - )] - ); + // No listener was supplied: native registration is intentionally skipped + // so a missing fcitx5 service cannot make Core startup fatal. + assert!(hotkeys.targets.lock().unwrap().is_empty()); assert_eq!( runtime.startup_snapshot().contract_version, openless_core::BACKEND_CONTRACT_VERSION @@ -311,56 +314,41 @@ mod tests { assert!(!backend.snapshot().running); let _ = std::fs::remove_dir_all(data_dir); } - #[cfg(target_os = "linux")] #[tokio::test] - async fn startup_failures_stop_core_and_keep_the_outer_lock_until_the_error_window_closes() { - for fail_hotkeys in [true, false] { - let dir = std::env::temp_dir() - .join(format!("openless-startup-failure-{}", uuid::Uuid::new_v4())); - let backend = fixture_backend(&dir, BackendServices::unsupported()); - let lock = dir.join("openless.lock"); - let broker = match crate::SingleInstanceBroker::acquire_or_forward( - &lock, - &dir.join("openless.sock"), - crate::LinuxLaunchIntent::ShowMain, - ) - .unwrap() - { - crate::SingleInstanceRole::Primary(broker) => Arc::new(broker), - crate::SingleInstanceRole::Forwarded => panic!("isolated test must own its lock"), - }; - let mut events = backend.subscribe(); - let result = LinuxNativeRuntime::start( - LinuxBackendRuntime { - backend: backend.clone(), - host_actions: Arc::new(LinuxHostActions::default()), - settings_runtime: Arc::new(crate::LinuxSettingsRuntime::with_effects( - Arc::new(StartupHotkeys(Default::default(), fail_hotkeys)), - )), - }, - Some(broker.clone()), - None, - ) - .await; - assert!(result.is_err()); - assert!(!backend.snapshot().running); - let mut started = false; - while let Ok(event) = events.try_recv() { - started |= matches!(event.kind, openless_core::BackendEventKind::BackendStarted); - } - assert_eq!( - started, !fail_hotkeys, - "registration must precede Core startup" - ); - assert!(crate::SingleInstanceGuard::acquire(&lock) - .unwrap() - .is_none()); - drop(broker); - assert!(crate::SingleInstanceGuard::acquire(&lock) - .unwrap() - .is_some()); - drop(backend); - std::fs::remove_dir_all(dir).unwrap(); + async fn missing_hotkeys_do_not_block_core_startup() { + let dir = + std::env::temp_dir().join(format!("openless-missing-hotkeys-{}", uuid::Uuid::new_v4())); + let mut services = BackendServices::unsupported(); + services.remote_input = Arc::new( + RemoteInputService::new(Arc::new(RecordingRemoteInputRuntime::default()), 8443, "en") + .unwrap(), + ); + let backend = fixture_backend(&dir, services); + let mut events = backend.subscribe(); + let runtime = LinuxNativeRuntime::start( + LinuxBackendRuntime { + backend: backend.clone(), + host_actions: Arc::new(LinuxHostActions::default()), + settings_runtime: Arc::new(crate::LinuxSettingsRuntime::with_effects(Arc::new( + RecordingSettingsEffects { + fail_registration: true, + ..Default::default() + }, + ))), + }, + None, + None, + ) + .await + .expect("Core must start without the optional fcitx5 listener"); + assert!(backend.snapshot().running); + let mut started = false; + while let Ok(event) = events.try_recv() { + started |= matches!(event.kind, openless_core::BackendEventKind::BackendStarted); } + assert!(started); + runtime.shutdown().await.unwrap(); + assert!(!backend.snapshot().running); + let _ = std::fs::remove_dir_all(dir); } } diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index b03968f62..15a4e4558 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -17,6 +17,7 @@ pub mod view_model; pub mod vocab; use eframe::egui; +use openless_linux_egui::tr_l10n; use view_model::{FrontendAction, FrontendViewModel, Page}; /// Re-export the theme module from the parent ui module. @@ -131,6 +132,65 @@ pub fn render(ctx: &egui::Context, vm: &mut FrontendViewModel, actions: &mut Vec }); } }); + + if vm.runtime_warning.is_some() { + runtime_warning_overlay(ctx, vm, actions); + } +} + +fn runtime_warning_overlay( + ctx: &egui::Context, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let Some(warning) = vm.runtime_warning.as_deref() else { + return; + }; + let screen = ctx.input(|input| input.viewport_rect()); + let card_width = screen.width().clamp(280.0, 600.0); + let card_height = screen.height().clamp(220.0, 360.0); + let card = egui::Rect::from_center_size(screen.center(), egui::vec2(card_width, card_height)); + + egui::Area::new(egui::Id::new("openless-runtime-warning-overlay")) + .order(egui::Order::Foreground) + .fixed_pos(screen.min) + .constrain(false) + .show(ctx, |ui| { + ui.set_min_size(screen.size()); + ui.set_max_size(screen.size()); + let _ = ui.allocate_rect(screen, egui::Sense::click()); + ui.painter().rect_filled( + screen, + egui::CornerRadius::ZERO, + egui::Color32::from_black_alpha(150), + ); + ui.scope_builder(egui::UiBuilder::new().max_rect(card), |ui| { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .shadow(egui::Shadow { + offset: [0, 10], + blur: 24, + spread: 0, + color: egui::Color32::from_black_alpha(48), + }) + .show(ui, |ui| { + ui.set_min_size(card.size()); + ui.set_max_size(card.size()); + ui.vertical_centered(|ui| { + ui.add_space(28.0); + ui.heading(tr_l10n(vm.lang, "startup.input_method_unavailable_title")); + ui.add_space(16.0); + ui.label(warning); + ui.add_space(20.0); + if ui.button(tr_l10n(vm.lang, "common.close")).clicked() { + actions.push(FrontendAction::DismissRuntimeWarning); + } + }); + }); + }); + }); } /// egui 0.36 起 `FullOutput` 里的 `TexturesDelta` 必须被消费,未应用就 drop 会 @@ -205,6 +265,38 @@ mod tests { } } + #[test] + fn unavailable_input_method_renders_a_localized_dismissible_overlay() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::En, + runtime_warning: Some("fixture input method error".into()), + ..Default::default() + }; + let mut actions = Vec::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + render(&ctx, &mut vm, &mut actions); + let _ = end_pass(&ctx); + } + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + render(&ctx, &mut vm, &mut actions); + let painted = painted_text(&end_pass(&ctx)); + assert!( + painted.contains(tr_l10n(vm.lang, "startup.input_method_unavailable_title")), + "painted output: {painted}" + ); + assert!(painted.contains("fixture input method error")); + assert!(painted.contains(tr_l10n(vm.lang, "common.close"))); + assert!(actions.is_empty(), "the overlay only dismisses on a click"); + } + #[test] fn overview_page_renders_populated_dashboard_without_panicking() { let ctx = egui::Context::default(); diff --git a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs index 6cfb420d4..06c62e5b6 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs @@ -249,6 +249,8 @@ pub enum FrontendAction { OverviewPeriod(usize), /// Overview: metric toggle (0 = count, 1 = chars, 2 = duration). OverviewMetric(usize), + /// Dismiss a non-fatal runtime warning overlay. + DismissRuntimeWarning, /// Window close requested. WindowClose, /// Window maximize/minimize toggle. @@ -686,6 +688,9 @@ pub struct FrontendViewModel { pub tools_open: bool, pub settings_open: bool, + /// Localized non-fatal runtime warning (for example, unavailable fcitx5). + pub runtime_warning: Option, + /// Resolved UI language, injected by the host each frame so the pure /// renderer can look up localized strings without touching global state. #[serde(with = "lang_tag")] @@ -915,6 +920,7 @@ impl Default for FrontendViewModel { style_open: true, tools_open: true, settings_open: false, + runtime_warning: None, overview_loading: true, overview_error: None, overview: None, From cfa12b44202583bd5cc8386827e7207c2fb762cc Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 19:22:34 +0800 Subject: [PATCH 11/35] chore: remove unused Linux desktop helpers --- openless-all/scripts/inject-fcitx5-plugin.sh | 68 --------- openless-all/scripts/linux-desktop/README.md | 27 ---- .../scripts/linux-desktop/gnome/bridge.js | 99 ------------- .../scripts/linux-desktop/gnome/build.mjs | 13 -- .../linux-desktop/gnome/legacy/extension.js | 106 -------------- .../linux-desktop/gnome/legacy/metadata.json | 11 -- .../linux-desktop/gnome/modern/extension.js | 107 -------------- .../linux-desktop/gnome/modern/metadata.json | 14 -- openless-all/scripts/linux-desktop/install.sh | 76 ---------- .../scripts/linux-desktop/kde/CMakeLists.txt | 18 --- .../scripts/linux-desktop/kde/bridge.cpp | 131 ------------------ .../linux-desktop/kwin/contents/code/main.js | 40 ------ .../linux-desktop/kwin/metadata.desktop | 9 -- .../scripts/linux-desktop/kwin/metadata.json | 6 - 14 files changed, 725 deletions(-) delete mode 100755 openless-all/scripts/inject-fcitx5-plugin.sh delete mode 100644 openless-all/scripts/linux-desktop/README.md delete mode 100644 openless-all/scripts/linux-desktop/gnome/bridge.js delete mode 100644 openless-all/scripts/linux-desktop/gnome/build.mjs delete mode 100644 openless-all/scripts/linux-desktop/gnome/legacy/extension.js delete mode 100644 openless-all/scripts/linux-desktop/gnome/legacy/metadata.json delete mode 100644 openless-all/scripts/linux-desktop/gnome/modern/extension.js delete mode 100644 openless-all/scripts/linux-desktop/gnome/modern/metadata.json delete mode 100644 openless-all/scripts/linux-desktop/install.sh delete mode 100644 openless-all/scripts/linux-desktop/kde/CMakeLists.txt delete mode 100644 openless-all/scripts/linux-desktop/kde/bridge.cpp delete mode 100644 openless-all/scripts/linux-desktop/kwin/contents/code/main.js delete mode 100644 openless-all/scripts/linux-desktop/kwin/metadata.desktop delete mode 100644 openless-all/scripts/linux-desktop/kwin/metadata.json diff --git a/openless-all/scripts/inject-fcitx5-plugin.sh b/openless-all/scripts/inject-fcitx5-plugin.sh deleted file mode 100755 index e8ba87715..000000000 --- a/openless-all/scripts/inject-fcitx5-plugin.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -# Inject fcitx5 plugin files into Linux packages at system paths. -# Usage: ./inject-fcitx5-plugin.sh -# -# Supports: .deb, .rpm -# AppImage resources are bundled separately and installed to ~/.local/ at runtime. -set -euo pipefail - -PKG="$1" -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PLUGIN_DIR="$SCRIPT_DIR/linux-fcitx5-plugin/build_release" -SO_SRC="$PLUGIN_DIR/libopenless.so" -CONF_SRC="$PLUGIN_DIR/openless.conf" - -if [ ! -f "$SO_SRC" ] || [ ! -f "$CONF_SRC" ]; then - echo "[inject-fcitx5] Plugin not built — run build.sh first. Skipping." - exit 0 -fi - -TARGET_CONF="/usr/share/fcitx5/addon/openless.conf" - -case "$PKG" in - *.deb) - # Detect multiarch triplet for the target architecture - MULTIARCH=$(dpkg-architecture -qDEB_HOST_MULTIARCH 2>/dev/null || echo "") - if [ -n "$MULTIARCH" ]; then - TARGET_LIB="/usr/lib/$MULTIARCH/fcitx5/libopenless.so" - else - TARGET_LIB="/usr/lib/fcitx5/libopenless.so" - fi - echo "[inject-fcitx5] Injecting into deb ($MULTIARCH): $PKG" - TMPDIR=$(mktemp -d) - trap 'rm -rf "$TMPDIR"' EXIT - dpkg-deb -R "$PKG" "$TMPDIR" - mkdir -p "$TMPDIR/$(dirname "$TARGET_LIB")" - mkdir -p "$TMPDIR/$(dirname "$TARGET_CONF")" - cp "$SO_SRC" "$TMPDIR/$TARGET_LIB" - cp "$CONF_SRC" "$TMPDIR/$TARGET_CONF" - dpkg-deb -b "$TMPDIR" "$PKG" - echo "[inject-fcitx5] Done — deb updated" - ;; - *.rpm) - TARGET_LIB="/usr/lib64/fcitx5/libopenless.so" - echo "[inject-fcitx5] Injecting into rpm: $PKG" - TMPDIR=$(mktemp -d) - trap 'rm -rf "$TMPDIR"' EXIT - cd "$TMPDIR" - rpm2cpio "$PKG" | cpio -idm 2>/dev/null || true - mkdir -p "$(dirname ".$TARGET_LIB")" - mkdir -p "$(dirname ".$TARGET_CONF")" - cp "$SO_SRC" ".$TARGET_LIB" - cp "$CONF_SRC" ".$TARGET_CONF" - if command -v rpmrebuild &>/dev/null; then - rpmrebuild -np -d "$TMPDIR" "$PKG" 2>/dev/null || { - echo "[inject-fcitx5] ERROR: rpmrebuild failed" >&2 - exit 1 - } - else - echo "[inject-fcitx5] ERROR: rpmrebuild not found — required for RPM injection. Install it with: sudo dnf install rpmrebuild" >&2 - exit 1 - fi - echo "[inject-fcitx5] Done — rpm updated" - ;; - *) - echo "[inject-fcitx5] Unknown package format: $PKG (supported: .deb, .rpm)" - exit 1 - ;; -esac diff --git a/openless-all/scripts/linux-desktop/README.md b/openless-all/scripts/linux-desktop/README.md deleted file mode 100644 index eb48f2ed4..000000000 --- a/openless-all/scripts/linux-desktop/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Linux 桌面集成 - -桥协议为 `org.openless.Desktop1` v1,与 Core 业务数据版本独立。X11 直接使用 X11 接口;Wayland 使用桌面组件提供前台窗口、屏幕工作区、焦点恢复、浮窗定位和全局快捷键。 - -安装包附带 `openless-desktop-install`。在用户会话中运行: - -```sh -openless-desktop-install install -openless-desktop-install enable -openless-desktop-install uninstall -``` - -GNOME 42–44 和 45+ 使用不同模块入口。首次安装 GNOME 扩展后可能需要注销并重新登录,再执行 `enable`。KDE 使用 KWin 脚本与 KGlobalAccel 辅助程序;安装时写入用户的脚本、D-Bus 服务和登录启动项。安装后重启 OpenLess,使当前设置重新注册到桌面桥。 - -组件只运行在当前用户的会话中,不要求 root,不读取密码输入框。fcitx5 输入目标使用 UUID 与会话票据;桌面窗口身份用于定位与恢复焦点,不代替写入前的文本目标校验。 - -开发构建: - -```sh -node gnome/build.mjs -cmake -S kde -B kde/build -DCMAKE_BUILD_TYPE=Release -cmake --build kde/build --parallel -``` - -KDE 构建支持 Qt 5 / KF5 与 Qt 6 / KF6。桌面组件的实际交互验收见 Linux 交接清单;编译通过不代表已经在相应桌面版本上完成验收。 - -接口参考:[GNOME 扩展](https://gjs.guide/extensions/)、[KWin](https://develop.kde.org/docs/plasma/kwin/api/)、[KGlobalAccel](https://api.kde.org/kglobalaccel.html)、[AT-SPI Text](https://gnome.pages.gitlab.gnome.org/at-spi2-core/libatspi/iface.Text.html)。 diff --git a/openless-all/scripts/linux-desktop/gnome/bridge.js b/openless-all/scripts/linux-desktop/gnome/bridge.js deleted file mode 100644 index 9bd46b58e..000000000 --- a/openless-all/scripts/linux-desktop/gnome/bridge.js +++ /dev/null @@ -1,99 +0,0 @@ -/* SPDX-License-Identifier: AGPL-3.0-only - * This body is shared by the GNOME 42–44 and 45+ entry points at packaging. - */ -const IFACE = ` - - - - - - -`; - -class DesktopBridge { - constructor() { - this.bindings = new Map(); this.held = new Map(); - this.object = Gio.DBusExportedObject.wrapJSObject(IFACE, this); - this.object.export(Gio.DBus.session, '/org/openless/Desktop1'); - this.owner = Gio.bus_own_name_on_connection(Gio.DBus.session, 'org.openless.Desktop1', Gio.BusNameOwnerFlags.NONE, null, null); - this.activation = global.display.connect('accelerator-activated', (_display, id) => { - const binding = this.bindings.get(id); - if (binding && !this.held.has(id)) { this.held.set(id, binding); this.emit(binding, true); } - }); - this.release = global.stage.connect('captured-event', (_actor, event) => { - if (event.type() === Clutter.EventType.KEY_RELEASE) { - for (const [id, binding] of this.held) { - if (event.get_key_symbol() === binding.symbol) { this.held.delete(id); this.emit(binding, false); } - } - } - return Clutter.EVENT_PROPAGATE; - }); - // Older Mutter versions expose modifier state outside shell focus but - // no accelerator-deactivated signal. It also releases a held modifier - // after focus changes or a compositor modal operation. - this.watch = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 20, () => { - const state = global.get_pointer()[2]; - for (const [id, binding] of this.held) { - let mask = binding.states; - if ([0xffe3, 0xffe4].includes(binding.symbol)) mask |= 4; - if ([0xffe1, 0xffe2].includes(binding.symbol)) mask |= 1; - if ([0xffe9, 0xffea].includes(binding.symbol)) mask |= 8; - if ([0xffeb, 0xffec].includes(binding.symbol)) mask |= 64; - if (mask && (state & mask) !== mask) { this.held.delete(id); this.emit(binding, false); } - } - return GLib.SOURCE_CONTINUE; - }); - try { this.deactivation = global.display.connect('accelerator-deactivated', (_d, id) => { - const binding = this.held.get(id); if (binding) { this.held.delete(id); this.emit(binding, false); } - }); } catch (_) { this.deactivation = 0; } - } - Version() { return 1; } - windows() { return global.get_window_actors().map(actor => actor.meta_window); } - Snapshot() { - const window = global.display.focus_window; - if (!window) return '{}'; - const rect = window.get_work_area_for_monitor(window.get_monitor()); - return JSON.stringify({version: 1, target: String(window.get_stable_sequence()), application: window.get_wm_class() || '', - x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1}); - } - emit(binding, pressed) { - this.object.emit_signal('Hotkey', new GLib.Variant('(suub)', [binding.action, binding.symbol, binding.states, pressed])); - } - Bind(json) { - let requested; - try { requested = JSON.parse(json); if (!Array.isArray(requested) || requested.length > 64) throw new Error('Invalid bindings'); } - catch (error) { return String(error); } - const next = new Map(); const created = []; - for (const binding of requested) { - const existing = [...this.bindings].find(([, previous]) => previous.accelerator === binding.accelerator); - const id = existing ? existing[0] : global.display.grab_accelerator(binding.accelerator, Meta.KeyBindingFlags.NONE); - if (!id) { for (const newId of created) global.display.ungrab_accelerator(newId); return `Shortcut conflict: ${binding.accelerator}`; } - if (!existing) created.push(id); - next.set(id, binding); - } - for (const [id, binding] of this.held) this.emit(binding, false); - this.held.clear(); - for (const id of this.bindings.keys()) if (!next.has(id)) global.display.ungrab_accelerator(id); - for (const id of next.keys()) Main.wm.allowKeybinding(Meta.external_binding_name_for_action(id), Shell.ActionMode.ALL); - this.bindings = next; - return ''; - } - Restore(target) { - const window = this.windows().find(window => String(window.get_stable_sequence()) === target); - if (!window) return false; - window.activate(global.get_current_time()); return true; - } - Place(title, x, y) { - if (!title.startsWith('OpenLess ')) return false; - const window = this.windows().find(window => window.get_title() === title && (window.get_wm_class() || '').toLowerCase().includes('openless')); - if (!window) return false; - window.move_frame(true, x, y); return true; - } - destroy() { - for (const binding of this.held.values()) this.emit(binding, false); - for (const id of this.bindings.keys()) global.display.ungrab_accelerator(id); - global.display.disconnect(this.activation); if (this.deactivation) global.display.disconnect(this.deactivation); - global.stage.disconnect(this.release); GLib.Source.remove(this.watch); - this.object.unexport(); Gio.bus_unown_name(this.owner); - } -} diff --git a/openless-all/scripts/linux-desktop/gnome/build.mjs b/openless-all/scripts/linux-desktop/gnome/build.mjs deleted file mode 100644 index 03f5105f5..000000000 --- a/openless-all/scripts/linux-desktop/gnome/build.mjs +++ /dev/null @@ -1,13 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; -const here=path.dirname(fileURLToPath(import.meta.url)); -const body=fs.readFileSync(path.join(here,'bridge.js'),'utf8'); -for(const [name,versions,imports,entry] of [ - ['legacy',['42','43','44'], 'const {Gio, GLib, Meta, Shell, Clutter} = imports.gi;\nconst Main = imports.ui.main;\n', 'let bridge;\nfunction init() {}\nfunction enable() { bridge = new DesktopBridge(); }\nfunction disable() { bridge?.destroy(); bridge = null; }\n'], - ['modern',['45','46','47','48','49','50'], `import Gio from 'gi://Gio';\nimport GLib from 'gi://GLib';\nimport Meta from 'gi://Meta';\nimport Shell from 'gi://Shell';\nimport Clutter from 'gi://Clutter';\nimport * as Main from 'resource:///org/gnome/shell/ui/main.js';\n`, 'export default class OpenLessExtension { enable() { this.bridge = new DesktopBridge(); } disable() { this.bridge?.destroy(); this.bridge = null; } }\n'], -]) { - const out=path.join(here,name);fs.mkdirSync(out,{recursive:true}); - fs.writeFileSync(path.join(out,'metadata.json'),JSON.stringify({uuid:'openless@openless.app',name:'OpenLess Desktop Bridge',description:'Native OpenLess hotkeys and window placement',version:1,'shell-version':versions},null,2)+'\n','utf8'); - fs.writeFileSync(path.join(out,'extension.js'),imports+body+'\n'+entry,'utf8'); -} diff --git a/openless-all/scripts/linux-desktop/gnome/legacy/extension.js b/openless-all/scripts/linux-desktop/gnome/legacy/extension.js deleted file mode 100644 index a7a9b1ad5..000000000 --- a/openless-all/scripts/linux-desktop/gnome/legacy/extension.js +++ /dev/null @@ -1,106 +0,0 @@ -const {Gio, GLib, Meta, Shell, Clutter} = imports.gi; -const Main = imports.ui.main; -/* SPDX-License-Identifier: AGPL-3.0-only - * This body is shared by the GNOME 42–44 and 45+ entry points at packaging. - */ -const IFACE = ` - - - - - - -`; - -class DesktopBridge { - constructor() { - this.bindings = new Map(); this.held = new Map(); - this.object = Gio.DBusExportedObject.wrapJSObject(IFACE, this); - this.object.export(Gio.DBus.session, '/org/openless/Desktop1'); - this.owner = Gio.bus_own_name_on_connection(Gio.DBus.session, 'org.openless.Desktop1', Gio.BusNameOwnerFlags.NONE, null, null); - this.activation = global.display.connect('accelerator-activated', (_display, id) => { - const binding = this.bindings.get(id); - if (binding && !this.held.has(id)) { this.held.set(id, binding); this.emit(binding, true); } - }); - this.release = global.stage.connect('captured-event', (_actor, event) => { - if (event.type() === Clutter.EventType.KEY_RELEASE) { - for (const [id, binding] of this.held) { - if (event.get_key_symbol() === binding.symbol) { this.held.delete(id); this.emit(binding, false); } - } - } - return Clutter.EVENT_PROPAGATE; - }); - // Older Mutter versions expose modifier state outside shell focus but - // no accelerator-deactivated signal. It also releases a held modifier - // after focus changes or a compositor modal operation. - this.watch = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 20, () => { - const state = global.get_pointer()[2]; - for (const [id, binding] of this.held) { - let mask = binding.states; - if ([0xffe3, 0xffe4].includes(binding.symbol)) mask |= 4; - if ([0xffe1, 0xffe2].includes(binding.symbol)) mask |= 1; - if ([0xffe9, 0xffea].includes(binding.symbol)) mask |= 8; - if ([0xffeb, 0xffec].includes(binding.symbol)) mask |= 64; - if (mask && (state & mask) !== mask) { this.held.delete(id); this.emit(binding, false); } - } - return GLib.SOURCE_CONTINUE; - }); - try { this.deactivation = global.display.connect('accelerator-deactivated', (_d, id) => { - const binding = this.held.get(id); if (binding) { this.held.delete(id); this.emit(binding, false); } - }); } catch (_) { this.deactivation = 0; } - } - Version() { return 1; } - windows() { return global.get_window_actors().map(actor => actor.meta_window); } - Snapshot() { - const window = global.display.focus_window; - if (!window) return '{}'; - const rect = window.get_work_area_for_monitor(window.get_monitor()); - return JSON.stringify({version: 1, target: String(window.get_stable_sequence()), application: window.get_wm_class() || '', - x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1}); - } - emit(binding, pressed) { - this.object.emit_signal('Hotkey', new GLib.Variant('(suub)', [binding.action, binding.symbol, binding.states, pressed])); - } - Bind(json) { - let requested; - try { requested = JSON.parse(json); if (!Array.isArray(requested) || requested.length > 64) throw new Error('Invalid bindings'); } - catch (error) { return String(error); } - const next = new Map(); const created = []; - for (const binding of requested) { - const existing = [...this.bindings].find(([, previous]) => previous.accelerator === binding.accelerator); - const id = existing ? existing[0] : global.display.grab_accelerator(binding.accelerator, Meta.KeyBindingFlags.NONE); - if (!id) { for (const newId of created) global.display.ungrab_accelerator(newId); return `Shortcut conflict: ${binding.accelerator}`; } - if (!existing) created.push(id); - next.set(id, binding); - } - for (const [id, binding] of this.held) this.emit(binding, false); - this.held.clear(); - for (const id of this.bindings.keys()) if (!next.has(id)) global.display.ungrab_accelerator(id); - for (const id of next.keys()) Main.wm.allowKeybinding(Meta.external_binding_name_for_action(id), Shell.ActionMode.ALL); - this.bindings = next; - return ''; - } - Restore(target) { - const window = this.windows().find(window => String(window.get_stable_sequence()) === target); - if (!window) return false; - window.activate(global.get_current_time()); return true; - } - Place(title, x, y) { - if (!title.startsWith('OpenLess ')) return false; - const window = this.windows().find(window => window.get_title() === title && (window.get_wm_class() || '').toLowerCase().includes('openless')); - if (!window) return false; - window.move_frame(true, x, y); return true; - } - destroy() { - for (const binding of this.held.values()) this.emit(binding, false); - for (const id of this.bindings.keys()) global.display.ungrab_accelerator(id); - global.display.disconnect(this.activation); if (this.deactivation) global.display.disconnect(this.deactivation); - global.stage.disconnect(this.release); GLib.Source.remove(this.watch); - this.object.unexport(); Gio.bus_unown_name(this.owner); - } -} - -let bridge; -function init() {} -function enable() { bridge = new DesktopBridge(); } -function disable() { bridge?.destroy(); bridge = null; } diff --git a/openless-all/scripts/linux-desktop/gnome/legacy/metadata.json b/openless-all/scripts/linux-desktop/gnome/legacy/metadata.json deleted file mode 100644 index 9f5993bf2..000000000 --- a/openless-all/scripts/linux-desktop/gnome/legacy/metadata.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "uuid": "openless@openless.app", - "name": "OpenLess Desktop Bridge", - "description": "Native OpenLess hotkeys and window placement", - "version": 1, - "shell-version": [ - "42", - "43", - "44" - ] -} diff --git a/openless-all/scripts/linux-desktop/gnome/modern/extension.js b/openless-all/scripts/linux-desktop/gnome/modern/extension.js deleted file mode 100644 index b835b1269..000000000 --- a/openless-all/scripts/linux-desktop/gnome/modern/extension.js +++ /dev/null @@ -1,107 +0,0 @@ -import Gio from 'gi://Gio'; -import GLib from 'gi://GLib'; -import Meta from 'gi://Meta'; -import Shell from 'gi://Shell'; -import Clutter from 'gi://Clutter'; -import * as Main from 'resource:///org/gnome/shell/ui/main.js'; -/* SPDX-License-Identifier: AGPL-3.0-only - * This body is shared by the GNOME 42–44 and 45+ entry points at packaging. - */ -const IFACE = ` - - - - - - -`; - -class DesktopBridge { - constructor() { - this.bindings = new Map(); this.held = new Map(); - this.object = Gio.DBusExportedObject.wrapJSObject(IFACE, this); - this.object.export(Gio.DBus.session, '/org/openless/Desktop1'); - this.owner = Gio.bus_own_name_on_connection(Gio.DBus.session, 'org.openless.Desktop1', Gio.BusNameOwnerFlags.NONE, null, null); - this.activation = global.display.connect('accelerator-activated', (_display, id) => { - const binding = this.bindings.get(id); - if (binding && !this.held.has(id)) { this.held.set(id, binding); this.emit(binding, true); } - }); - this.release = global.stage.connect('captured-event', (_actor, event) => { - if (event.type() === Clutter.EventType.KEY_RELEASE) { - for (const [id, binding] of this.held) { - if (event.get_key_symbol() === binding.symbol) { this.held.delete(id); this.emit(binding, false); } - } - } - return Clutter.EVENT_PROPAGATE; - }); - // Older Mutter versions expose modifier state outside shell focus but - // no accelerator-deactivated signal. It also releases a held modifier - // after focus changes or a compositor modal operation. - this.watch = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 20, () => { - const state = global.get_pointer()[2]; - for (const [id, binding] of this.held) { - let mask = binding.states; - if ([0xffe3, 0xffe4].includes(binding.symbol)) mask |= 4; - if ([0xffe1, 0xffe2].includes(binding.symbol)) mask |= 1; - if ([0xffe9, 0xffea].includes(binding.symbol)) mask |= 8; - if ([0xffeb, 0xffec].includes(binding.symbol)) mask |= 64; - if (mask && (state & mask) !== mask) { this.held.delete(id); this.emit(binding, false); } - } - return GLib.SOURCE_CONTINUE; - }); - try { this.deactivation = global.display.connect('accelerator-deactivated', (_d, id) => { - const binding = this.held.get(id); if (binding) { this.held.delete(id); this.emit(binding, false); } - }); } catch (_) { this.deactivation = 0; } - } - Version() { return 1; } - windows() { return global.get_window_actors().map(actor => actor.meta_window); } - Snapshot() { - const window = global.display.focus_window; - if (!window) return '{}'; - const rect = window.get_work_area_for_monitor(window.get_monitor()); - return JSON.stringify({version: 1, target: String(window.get_stable_sequence()), application: window.get_wm_class() || '', - x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1}); - } - emit(binding, pressed) { - this.object.emit_signal('Hotkey', new GLib.Variant('(suub)', [binding.action, binding.symbol, binding.states, pressed])); - } - Bind(json) { - let requested; - try { requested = JSON.parse(json); if (!Array.isArray(requested) || requested.length > 64) throw new Error('Invalid bindings'); } - catch (error) { return String(error); } - const next = new Map(); const created = []; - for (const binding of requested) { - const existing = [...this.bindings].find(([, previous]) => previous.accelerator === binding.accelerator); - const id = existing ? existing[0] : global.display.grab_accelerator(binding.accelerator, Meta.KeyBindingFlags.NONE); - if (!id) { for (const newId of created) global.display.ungrab_accelerator(newId); return `Shortcut conflict: ${binding.accelerator}`; } - if (!existing) created.push(id); - next.set(id, binding); - } - for (const [id, binding] of this.held) this.emit(binding, false); - this.held.clear(); - for (const id of this.bindings.keys()) if (!next.has(id)) global.display.ungrab_accelerator(id); - for (const id of next.keys()) Main.wm.allowKeybinding(Meta.external_binding_name_for_action(id), Shell.ActionMode.ALL); - this.bindings = next; - return ''; - } - Restore(target) { - const window = this.windows().find(window => String(window.get_stable_sequence()) === target); - if (!window) return false; - window.activate(global.get_current_time()); return true; - } - Place(title, x, y) { - if (!title.startsWith('OpenLess ')) return false; - const window = this.windows().find(window => window.get_title() === title && (window.get_wm_class() || '').toLowerCase().includes('openless')); - if (!window) return false; - window.move_frame(true, x, y); return true; - } - destroy() { - for (const binding of this.held.values()) this.emit(binding, false); - for (const id of this.bindings.keys()) global.display.ungrab_accelerator(id); - global.display.disconnect(this.activation); if (this.deactivation) global.display.disconnect(this.deactivation); - global.stage.disconnect(this.release); GLib.Source.remove(this.watch); - this.object.unexport(); Gio.bus_unown_name(this.owner); - } -} - -export default class OpenLessExtension { enable() { this.bridge = new DesktopBridge(); } disable() { this.bridge?.destroy(); this.bridge = null; } } diff --git a/openless-all/scripts/linux-desktop/gnome/modern/metadata.json b/openless-all/scripts/linux-desktop/gnome/modern/metadata.json deleted file mode 100644 index 22f3efb09..000000000 --- a/openless-all/scripts/linux-desktop/gnome/modern/metadata.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "uuid": "openless@openless.app", - "name": "OpenLess Desktop Bridge", - "description": "Native OpenLess hotkeys and window placement", - "version": 1, - "shell-version": [ - "45", - "46", - "47", - "48", - "49", - "50" - ] -} diff --git a/openless-all/scripts/linux-desktop/install.sh b/openless-all/scripts/linux-desktop/install.sh deleted file mode 100644 index 3bbbdeecc..000000000 --- a/openless-all/scripts/linux-desktop/install.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -MODE=${1:-install} -ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -DATA=${XDG_DATA_HOME:-"$HOME/.local/share"} -CONFIG=${XDG_CONFIG_HOME:-"$HOME/.config"} -BIN="$HOME/.local/bin" -UUID=openless@openless.app -GNOME_DIR="$DATA/gnome-shell/extensions/$UUID" -KWIN_DIR="$DATA/kwin/scripts/openless-desktop" -HELPER="$BIN/openless-desktop-bridge" -RUNTIME="$HOME/.local/lib/openless-desktop-bridge" -case "$MODE" in install|enable|uninstall) ;; *) echo 'Usage: install.sh [install|enable|uninstall]' >&2; exit 2;; esac - -is_gnome=false -desktop=${XDG_CURRENT_DESKTOP:-} -case "${desktop,,}" in *gnome*|*ubuntu*) is_gnome=true;; esac - -if [ "$MODE" = uninstall ]; then - if command -v gnome-extensions >/dev/null; then gnome-extensions disable "$UUID" || true; fi - for tool in kwriteconfig6 kwriteconfig5; do - if command -v "$tool" >/dev/null; then "$tool" --file kwinrc --group Plugins --key openless-desktopEnabled false; break; fi - done - # Only remove these package-owned directories and files under the user's - # configured roots. Never enumerate arbitrary home files or use shell globs. - test "$GNOME_DIR" = "$DATA/gnome-shell/extensions/openless@openless.app" - test "$KWIN_DIR" = "$DATA/kwin/scripts/openless-desktop" - rm -rf -- "$GNOME_DIR" "$KWIN_DIR" "$RUNTIME" - rm -f -- "$HELPER" "$DATA/dbus-1/services/org.openless.Desktop1.service" "$CONFIG/autostart/openless-desktop-bridge.desktop" - echo 'Desktop integration removed. Log out and back in to unload the running component.' - exit 0 -fi - -if [ "$MODE" = install ]; then - if $is_gnome; then - version=$(gnome-shell --version | sed -n 's/.* \([0-9][0-9]*\).*/\1/p') - test -n "$version" - variant=modern; if [ "$version" -lt 45 ]; then variant=legacy; fi - install -Dm644 "$ROOT/gnome/$variant/metadata.json" "$GNOME_DIR/metadata.json" - install -Dm644 "$ROOT/gnome/$variant/extension.js" "$GNOME_DIR/extension.js" - else - install -Dm644 "$ROOT/kwin/metadata.json" "$KWIN_DIR/metadata.json" - install -Dm644 "$ROOT/kwin/metadata.desktop" "$KWIN_DIR/metadata.desktop" - install -Dm644 "$ROOT/kwin/contents/code/main.js" "$KWIN_DIR/contents/code/main.js" - install -Dm755 "$ROOT/openless-desktop-bridge" "$RUNTIME/openless-desktop-bridge" - if [ -d "$ROOT/lib" ]; then mkdir -p "$RUNTIME/lib"; cp -a "$ROOT/lib/." "$RUNTIME/lib/"; fi - if [ -d "$ROOT/plugins" ]; then mkdir -p "$RUNTIME/plugins"; cp -a "$ROOT/plugins/." "$RUNTIME/plugins/"; fi - if [ -d "$ROOT/licenses" ]; then mkdir -p "$RUNTIME/licenses"; cp -a "$ROOT/licenses/." "$RUNTIME/licenses/"; fi - mkdir -p "$BIN" - # Keep the helper and its Qt runtime after the AppImage mount disappears. - printf '#!/usr/bin/env bash\nexec %q "$@"\n' "$RUNTIME/openless-desktop-bridge" > "$HELPER" - chmod +x "$HELPER" - mkdir -p "$DATA/dbus-1/services" "$CONFIG/autostart" - escaped=${HELPER//\\/\\\\}; escaped=${escaped//\"/\\\"} - printf '[D-BUS Service]\nName=org.openless.Desktop1\nExec="%s"\n' "$escaped" > "$DATA/dbus-1/services/org.openless.Desktop1.service" - printf '[Desktop Entry]\nType=Application\nName=OpenLess Desktop Bridge\nExec="%s"\nOnlyShowIn=KDE;\nNoDisplay=true\n' "$escaped" > "$CONFIG/autostart/openless-desktop-bridge.desktop" - fi -fi - -if $is_gnome; then - if ! gnome-extensions enable "$UUID"; then - echo 'The extension is installed. Log out and back in, then run this command with enable.' >&2 - exit 1 - fi -else - enabled=false - for tool in kwriteconfig6 kwriteconfig5; do - if command -v "$tool" >/dev/null; then "$tool" --file kwinrc --group Plugins --key openless-desktopEnabled true; enabled=true; break; fi - done - $enabled || { echo 'KDE configuration tools are missing' >&2; exit 1; } - for tool in qdbus6 qdbus qdbus-qt5; do - if command -v "$tool" >/dev/null; then "$tool" org.kde.KWin /KWin reconfigure; break; fi - done - # The service is started by D-Bus on demand and on the next KDE login. -fi -echo 'OpenLess desktop integration enabled. Restart OpenLess to bind shortcuts.' diff --git a/openless-all/scripts/linux-desktop/kde/CMakeLists.txt b/openless-all/scripts/linux-desktop/kde/CMakeLists.txt deleted file mode 100644 index 2e145aa31..000000000 --- a/openless-all/scripts/linux-desktop/kde/CMakeLists.txt +++ /dev/null @@ -1,18 +0,0 @@ -cmake_minimum_required(VERSION 3.16) -project(openless-desktop-bridge LANGUAGES CXX) -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_AUTOMOC ON) -find_package(Qt6 QUIET COMPONENTS Core Gui DBus Widgets) -if(Qt6_FOUND) - find_package(KF6GlobalAccel REQUIRED) - set(OPENLESS_QT Qt6) - set(OPENLESS_KF KF6) -else() - find_package(Qt5 REQUIRED COMPONENTS Core Gui DBus Widgets) - find_package(KF5GlobalAccel REQUIRED) - set(OPENLESS_QT Qt5) - set(OPENLESS_KF KF5) -endif() -add_executable(openless-desktop-bridge bridge.cpp) -target_link_libraries(openless-desktop-bridge PRIVATE ${OPENLESS_QT}::Core ${OPENLESS_QT}::Gui ${OPENLESS_QT}::DBus ${OPENLESS_QT}::Widgets ${OPENLESS_KF}::GlobalAccel) -install(TARGETS openless-desktop-bridge RUNTIME DESTINATION bin) diff --git a/openless-all/scripts/linux-desktop/kde/bridge.cpp b/openless-all/scripts/linux-desktop/kde/bridge.cpp deleted file mode 100644 index 15e98bb02..000000000 --- a/openless-all/scripts/linux-desktop/kde/bridge.cpp +++ /dev/null @@ -1,131 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -class Bridge : public QObject, protected QDBusContext { - Q_OBJECT - Q_CLASSINFO("D-Bus Interface", "org.openless.Desktop1") - QMap actions; - QMap bindings; - QMap acknowledgements; - QQueue commands; - QDBusMessage waiting; - quint64 waiterGeneration=0; - QString snapshot="{}"; - qint64 snapshotTime=0; - QString releasePath; - static QString encode(const QJsonObject &value) { return QString::fromUtf8(QJsonDocument(value).toJson(QJsonDocument::Compact)); } - void replyCommand() { - if (waiting.type()!=QDBusMessage::MethodCallMessage || commands.isEmpty()) return; - QDBusConnection::sessionBus().send(waiting.createReply(encode(commands.dequeue()))); - waiting=QDBusMessage(); ++waiterGeneration; - } - bool queue(QJsonObject command) { - if (!calledFromDBus() || QDateTime::currentMSecsSinceEpoch()-snapshotTime>5000) return false; - const QString id=QUuid::createUuid().toString(QUuid::WithoutBraces); - command["id"]=id; - setDelayedReply(true); acknowledgements[id]=message(); commands.enqueue(command); replyCommand(); - QTimer::singleShot(1500,this,[this,id](){ Ack(id,false); }); - return false; // delayed reply contains the actual KWin acknowledgement - } - static QKeySequence key(const QJsonObject &binding) { - QString accelerator=binding["accelerator"].toString(); - accelerator.replace("","Ctrl+").replace("","Shift+").replace("","Alt+").replace("","Meta+"); - accelerator.replace("Control_R","Ctrl").replace("Control_L","Ctrl").replace("Alt_R","Alt").replace("Alt_L","Alt"); - return QKeySequence(accelerator); - } -public: - Bridge() = default; -public slots: - void Released(const QString &name,qlonglong) {const auto binding=bindings.value(name);if(!binding.isEmpty())emit Hotkey(binding["action"].toString(),binding["symbol"].toInt(),binding["states"].toInt(),false);} - uint Version() const { return 1; } - QString Snapshot() const { return QDateTime::currentMSecsSinceEpoch()-snapshotTime<5000?snapshot:"{}"; } - void Update(const QString &json) { - const auto document=QJsonDocument::fromJson(json.toUtf8()); - if (document.isObject() && document.object()["version"].toInt()==1) { snapshot=json; snapshotTime=QDateTime::currentMSecsSinceEpoch(); } - } - QString Bind(const QString &json) { - const auto document=QJsonDocument::fromJson(json.toUtf8()); - if (!document.isArray() || document.array().size()>64) return "Invalid desktop binding document"; - QMap next; - for (const auto value:document.array()) { - const auto binding=value.toObject(); const auto sequence=key(binding); - const QString id=binding["action"].toString()+":"+QString::number(binding["symbol"].toInt())+":"+QString::number(binding["states"].toInt()); - bool owned=false; for (auto action:actions) if (KGlobalAccel::self()->shortcut(action).contains(sequence)) owned=true; - if (sequence.isEmpty() || (!owned&&!KGlobalAccel::isGlobalShortcutAvailable(sequence,"openless-desktop-bridge"))) return "Shortcut conflict: "+binding["accelerator"].toString(); - next[id]=binding; - } - const auto previous=bindings; - for (auto action:actions) KGlobalAccel::self()->removeAllShortcuts(action); - bool success=true; - for (auto it=next.begin();it!=next.end();++it) { - auto action=actions.value(it.key()); - if (!action) { - action=new QAction(it.key(),this);action->setObjectName(it.key());actions[it.key()]=action; - connect(action,&QAction::triggered,this,[this,action](){const auto binding=bindings.value(action->objectName());if(!binding.isEmpty())emit Hotkey(binding["action"].toString(),binding["symbol"].toInt(),binding["states"].toInt(),true);}); - } - if (!KGlobalAccel::self()->setShortcut(action,{key(it.value())},KGlobalAccel::NoAutoloading)) { success=false;break; } - } - if (!success) { - for (auto action:actions) KGlobalAccel::self()->removeAllShortcuts(action); - for (auto it=previous.begin();it!=previous.end();++it) KGlobalAccel::self()->setShortcut(actions[it.key()],{key(it.value())},KGlobalAccel::NoAutoloading); - return "Shortcut registration failed; previous bindings restored"; - } - bindings=next; - if(releasePath.isEmpty()) { - QDBusInterface service("org.kde.kglobalaccel","/kglobalaccel","org.kde.KGlobalAccel"); - QDBusReply component=service.call("getComponent","openless-desktop-bridge"); - if(component.isValid()) { - releasePath=component.value().path(); - QDBusConnection::sessionBus().connect("org.kde.kglobalaccel",releasePath,"org.kde.kglobalaccel.Component","globalShortcutReleased",this,SLOT(Released(QString,qlonglong))); - } - } - return {}; - } - bool Restore(const QString &target) { return queue({{"op","restore"},{"target",target}}); } - bool Place(const QString &title,int x,int y) { if(!title.startsWith("OpenLess "))return false;return queue({{"op","place"},{"title",title},{"x",x},{"y",y}}); } - QString Next() { - if (!commands.isEmpty()) return encode(commands.dequeue()); - if (waiting.type()==QDBusMessage::MethodCallMessage) return "{}"; - setDelayedReply(true);waiting=message();const auto generation=++waiterGeneration; - QTimer::singleShot(1000,this,[this,generation](){if(waiterGeneration==generation&&waiting.type()==QDBusMessage::MethodCallMessage){QDBusConnection::sessionBus().send(waiting.createReply(QString("{}")));waiting=QDBusMessage();}}); - return {}; - } - void Ack(const QString &id,bool success) { - if (!acknowledgements.contains(id)) return; - QDBusConnection::sessionBus().send(acknowledgements.take(id).createReply(success)); - } -signals: - void Hotkey(const QString &action,uint symbol,uint states,bool pressed); -}; - -int main(int argc,char **argv) { - // This helper only uses D-Bus; a private offscreen platform plugin avoids - // loading Qt plugins from an incompatible Plasma installation. - qputenv("QT_QPA_PLATFORM", "offscreen"); - const auto plugins=QFileInfo(QString::fromLocal8Bit(argv[0])).absoluteDir().filePath("plugins/platforms"); - if(QDir(plugins).exists())qputenv("QT_QPA_PLATFORM_PLUGIN_PATH",plugins.toUtf8()); - QApplication app(argc,argv);app.setApplicationName("openless-desktop-bridge");app.setQuitOnLastWindowClosed(false); - Bridge bridge; - auto bus=QDBusConnection::sessionBus(); - if(!bus.registerService("org.openless.Desktop1") || !bus.registerObject("/org/openless/Desktop1",&bridge,QDBusConnection::ExportAllSlots|QDBusConnection::ExportAllSignals))return 1; - return app.exec(); -} -#include "bridge.moc" diff --git a/openless-all/scripts/linux-desktop/kwin/contents/code/main.js b/openless-all/scripts/linux-desktop/kwin/contents/code/main.js deleted file mode 100644 index 3d17907f6..000000000 --- a/openless-all/scripts/linux-desktop/kwin/contents/code/main.js +++ /dev/null @@ -1,40 +0,0 @@ -/* SPDX-License-Identifier: AGPL-3.0-only */ -var bus = 'org.openless.Desktop1', path = '/org/openless/Desktop1'; -var polling = false; -function windows() { return workspace.windowList ? workspace.windowList() : workspace.clientList(); } -function active() { return workspace.activeWindow !== undefined ? workspace.activeWindow : workspace.activeClient; } -function identity(window) { return String(window.internalId || window.windowId); } -function update() { - var window = active(); - if (!window) return; - var area = workspace.clientArea(KWin.MaximizeArea, window); - callDBus(bus, path, bus, 'Update', JSON.stringify({version: 1, target: identity(window), application: String(window.resourceClass), x: area.x, y: area.y, width: area.width, height: area.height, scale: 1})); -} -function next() { - if (polling) return; - polling = true; - callDBus(bus, path, bus, 'Next', function (json) { - polling = false; - // A missing helper must not turn a failed D-Bus call into a busy loop. - // A subsequent focus/screen event starts the connection again. - if (typeof json !== 'string') return; - var command, success = false; - try { - command = JSON.parse(json || '{}'); - if (command.op === 'restore') { - var target = windows().filter(function (window) { return identity(window) === command.target; })[0]; - if (target) { if (workspace.activeWindow !== undefined) workspace.activeWindow = target; else workspace.activeClient = target; success = true; } - } else if (command.op === 'place' && command.title.indexOf('OpenLess ') === 0) { - var popup = windows().filter(function (window) { return window.caption === command.title && String(window.resourceClass).toLowerCase().indexOf('openless') >= 0; })[0]; - if (popup) { var rect = popup.frameGeometry; rect.x = command.x; rect.y = command.y; popup.frameGeometry = rect; success = true; } - } - } catch (error) { print('OpenLess desktop bridge: ' + error); } - if (command && command.id) callDBus(bus, path, bus, 'Ack', command.id, success); - update(); next(); - }); -} -function changed() { update(); next(); } -if (workspace.windowActivated) workspace.windowActivated.connect(changed); -else workspace.clientActivated.connect(changed); -if (workspace.screensChanged) workspace.screensChanged.connect(changed); -update(); next(); diff --git a/openless-all/scripts/linux-desktop/kwin/metadata.desktop b/openless-all/scripts/linux-desktop/kwin/metadata.desktop deleted file mode 100644 index 3219508d9..000000000 --- a/openless-all/scripts/linux-desktop/kwin/metadata.desktop +++ /dev/null @@ -1,9 +0,0 @@ -[Desktop Entry] -Name=OpenLess Desktop Bridge -Type=Service -X-KDE-ServiceTypes=KWin/Script -X-KDE-PluginInfo-Name=openless-desktop -X-KDE-PluginInfo-Version=1.0 -X-KDE-PluginInfo-License=AGPL-3.0-only -X-Plasma-API=javascript -X-Plasma-MainScript=code/main.js diff --git a/openless-all/scripts/linux-desktop/kwin/metadata.json b/openless-all/scripts/linux-desktop/kwin/metadata.json deleted file mode 100644 index b3283e6e6..000000000 --- a/openless-all/scripts/linux-desktop/kwin/metadata.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "KPlugin": {"Id": "openless-desktop", "Name": "OpenLess Desktop Bridge", "Description": "OpenLess focus and floating window integration", "Version": "1.0", "License": "AGPL-3.0-only"}, - "X-Plasma-API": "javascript", - "X-Plasma-MainScript": "code/main.js", - "KPackageStructure": "KWin/Script" -} From 38b4bddbbf9a7bd1fbc1ba70db7914b5be7eb036 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 19:39:40 +0800 Subject: [PATCH 12/35] fix: keep Linux popup panels inside X11 work area --- openless-all/app/linux-egui/src/lib.rs | 10 +-- openless-all/app/linux-egui/src/main.rs | 52 +++++++++++++-- .../app/linux-egui/src/popup_window.rs | 64 +++++++++++++++++++ 3 files changed, 115 insertions(+), 11 deletions(-) diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index 889bfeaa8..dda6cd0ba 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -93,15 +93,15 @@ pub use popup::{ pub use popup_layer::{ capsule_geometry, capsule_path_override, choose_capsule_path, detect_capsule_path, has_layer_shell, layer_shell_available, pointer_events, probe_layer_shell, run_layer_capsule, - CapsuleGeometry, CapsulePath, LayerFrame, CAPSULE_PATH_ENV, CONFIGURE_TIMEOUT, LAYER_NAMESPACE, - LAYER_SHELL_GLOBAL, MAX_FRAME_PAUSE, + wayland_display_available, CapsuleGeometry, CapsulePath, LayerFrame, CAPSULE_PATH_ENV, + CONFIGURE_TIMEOUT, LAYER_NAMESPACE, LAYER_SHELL_GLOBAL, MAX_FRAME_PAUSE, }; #[cfg(all(target_os = "linux", feature = "x11-overlay"))] pub use popup_window::X11Overlay; pub use popup_window::{ - bottom_center, clamp_to_area, monitor_containing, place_overlay, popup_position, popup_size, - select_overlay_window, x11_available, OverlayEnvironment, OverlayPlacement, OverlayX11, - WindowCandidate, WindowMatch, X11Rect, CAPSULE_BOTTOM_GAP, CAPSULE_WINDOW_SIZE, + bottom_center, clamp_to_area, monitor_containing, place_overlay, place_panel, popup_position, + popup_size, select_overlay_window, x11_available, OverlayEnvironment, OverlayPlacement, + OverlayX11, WindowCandidate, WindowMatch, X11Rect, CAPSULE_BOTTOM_GAP, CAPSULE_WINDOW_SIZE, LESS_COMPUTER_WINDOW_SIZE, QA_WINDOW_SIZE, }; diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 7011c53dd..e1a07dbb5 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -6698,8 +6698,8 @@ mod linux_app { mod popup_overlay { use super::*; use openless_linux_egui::{ - place_overlay, popup_position, OverlayEnvironment, OverlayPlacement, OverlayX11, - X11Overlay, + place_overlay, place_panel, popup_position, OverlayEnvironment, OverlayPlacement, + OverlayX11, X11Overlay, }; pub struct PopupOverlay { @@ -6717,7 +6717,15 @@ mod linux_app { impl PopupOverlay { pub fn probe(kind: PopupKind) -> Option { - // 原生 Wayland 不做任何 X11 处理。 + // XWayland may expose DISPLAY in a native Wayland session, but + // an xdg-toplevel is not an X11 window. Do not feed its + // coordinates to winit or pretend that X11 can place it. + if openless_linux_egui::wayland_display_available( + std::env::var("WAYLAND_DISPLAY").ok().as_deref(), + ) { + log::debug!("popup x11: native Wayland session, keeping compositor placement"); + return None; + } if !openless_linux_egui::x11_available(std::env::var("DISPLAY").ok().as_deref()) { log::debug!("popup x11: no DISPLAY, keeping the compositor placement"); return None; @@ -6806,10 +6814,42 @@ focus_was_stolen={} focus_restored={} warnings={:?}", } pub fn place(&mut self, ctx: &egui::Context, visible: bool) { - // 只有胶囊需要「永不聚焦 + 置顶 + 不进任务栏」;两个面板要键盘输入, - // 位置已经由 `with_position` 在创建时给过,X11 变更一概不做。 if self.kind != PopupKind::Capsule { - self.placed = true; + // Interactive panels keep normal focus/taskbar behaviour, but + // winit's initial position is only a hint. Re-apply the + // work-area position once after the X11 window is managed so + // a panel cannot be placed over a reserved taskbar. + if !self.placed { + self.attempts = self.attempts.saturating_add(1); + let placement = place_panel( + &mut self.connection, + std::process::id(), + &self.environment, + self.kind, + ); + if placement.applied() { + self.placed = true; + log::info!( + "popup x11 (panel-post-map): kind={:?} window={:?} matched={} moved_to={:?} warnings={:?}", + self.kind, + placement.window, + placement + .matched + .map(openless_linux_egui::WindowMatch::as_str) + .unwrap_or("none"), + placement.moved_to, + placement.warnings + ); + } else if self.attempts >= 100 { + self.placed = true; + log::warn!( + "popup x11: panel window not found, keeping compositor placement kind={:?}", + self.kind + ); + } else { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + } return; } if !self.placed { diff --git a/openless-all/app/linux-egui/src/popup_window.rs b/openless-all/app/linux-egui/src/popup_window.rs index a0f4a6b16..18b370637 100644 --- a/openless-all/app/linux-egui/src/popup_window.rs +++ b/openless-all/app/linux-egui/src/popup_window.rs @@ -302,6 +302,52 @@ impl OverlayPlacement { } } +/// Place an interactive panel in the requested work area without changing its +/// focus or taskbar policy. This is only a best-effort X11 post-map correction: +/// Wayland clients cannot choose an xdg-toplevel position. +pub fn place_panel( + x11: &mut dyn OverlayX11, + pid: u32, + environment: &OverlayEnvironment, + kind: crate::popup::PopupKind, +) -> OverlayPlacement { + let mut placement = OverlayPlacement::default(); + let (window, matched) = match x11.find_own_window(pid) { + Ok(Some(found)) => found, + Ok(None) => { + placement + .warnings + .push("own X11 window not found yet".to_string()); + return placement; + } + Err(error) => { + placement + .warnings + .push(format!("window lookup failed: {error}")); + return placement; + } + }; + placement.window = Some(window); + placement.matched = Some(matched); + + if let Err(error) = x11.mark_self_placed(window) { + placement + .warnings + .push(format!("position hint failed: {error}")); + } + if let Some(position) = popup_position(environment, kind) { + match x11.move_window(window, position) { + Ok(()) => placement.moved_to = Some(position), + Err(error) => placement.warnings.push(format!("move failed: {error}")), + } + } else { + placement + .warnings + .push("no usable work area or monitor".to_string()); + } + placement +} + /// Point the popup's own X11 window at the bottom centre of the work area and /// make sure it never holds the keyboard. /// @@ -1066,6 +1112,24 @@ mod tests { assert!(placement.applied()); } + #[test] + fn place_panel_moves_without_changing_focus_or_overlay_state() { + let mut x11 = FakeX11 { + window: Some(0x2a), + ..Default::default() + }; + let placement = place_panel(&mut x11, 4242, &environment(), PopupKind::Qa); + assert_eq!( + x11.calls, + vec!["find(4242)", "self_placed(42)", "move(42,750,270)"] + ); + assert_eq!(placement.window, Some(0x2a)); + assert_eq!(placement.moved_to, Some((750, 270))); + assert!(!placement.focus_was_stolen); + assert!(!placement.focus_restored); + assert!(placement.warnings.is_empty()); + } + #[test] fn place_overlay_keeps_going_when_the_input_hint_fails() { let mut x11 = FakeX11 { From ed3b00069649d795acc5615dff9e41dbd7278d92 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 19:57:51 +0800 Subject: [PATCH 13/35] fix: match Linux capsule styles to Tauri geometry --- openless-all/app/linux-egui/package-revision | 2 +- .../app/linux-egui/src/ui/frontend/icons.rs | 13 +- .../app/linux-egui/src/ui/frontend/popups.rs | 164 +++++++++++++----- 3 files changed, 138 insertions(+), 41 deletions(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 3b20426c0..e2a9fee00 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -108 +109 diff --git a/openless-all/app/linux-egui/src/ui/frontend/icons.rs b/openless-all/app/linux-egui/src/ui/frontend/icons.rs index 373917e7f..b0a1fe75b 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/icons.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/icons.rs @@ -151,6 +151,17 @@ fn rasterize_mask(source: &str) -> Option { /// Draw the same SVG outlines used by Tauri, recolored for this surface. /// Texture handles live in egui's context and are reused across frames/windows. pub fn draw_icon(ui: &egui::Ui, center: egui::Pos2, icon: IconName, color: egui::Color32) { + draw_icon_sized(ui, center, icon, color, ICON_SIZE); +} + +/// Draw an icon at a caller-selected CSS-equivalent size. +pub fn draw_icon_sized( + ui: &egui::Ui, + center: egui::Pos2, + icon: IconName, + color: egui::Color32, + size: f32, +) { let Some((name, svg)) = lucide_svg(icon) else { // Not part of Icon.tsx: the account avatar fallback is deliberately // separate from the monochrome Lucide interface controls. @@ -176,7 +187,7 @@ pub fn draw_icon(ui: &egui::Ui, center: egui::Pos2, icon: IconName, color: egui: if let Some(texture) = texture { ui.painter().image( texture.id(), - egui::Rect::from_center_size(center, egui::vec2(ICON_SIZE, ICON_SIZE)), + egui::Rect::from_center_size(center, egui::vec2(size, size)), egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), color, ); diff --git a/openless-all/app/linux-egui/src/ui/frontend/popups.rs b/openless-all/app/linux-egui/src/ui/frontend/popups.rs index eab3b32fe..f603d5e61 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/popups.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/popups.rs @@ -78,10 +78,16 @@ const PILL_HEIGHT: f32 = 42.0; const ROUND_BUTTON: f32 = 28.0; /// Tauri `getCapsuleHostMetrics(.., 'classic').bottomInset`。 const CAPSULE_BOTTOM_INSET: f32 = 16.0; -/// Typeless 胶囊(Tauri `CapsuleStyles.css`:176×64、46×46 圆形按钮、11 根波形)。 -const TYPELESS_WIDTH: f32 = 176.0; -const TYPELESS_HEIGHT: f32 = 64.0; -const TYPELESS_BUTTON: f32 = 46.0; +/// Typeless 胶囊由 Tauri 的 460×128 基准舞台按 `zoom: 0.447` 缩放, +/// 因此录音态为 232×64 → 104×29,空闲态为 176×64 → 79×29, +/// 错误态为 300×64 → 134×29,按钮为 46×46 → 21×21。 +const TYPELESS_SCALE: f32 = 0.447; +const TYPELESS_IDLE_WIDTH: f32 = 176.0 * TYPELESS_SCALE; +const TYPELESS_RECORDING_WIDTH: f32 = 232.0 * TYPELESS_SCALE; +const TYPELESS_ERROR_WIDTH: f32 = 300.0 * TYPELESS_SCALE; +const TYPELESS_HEIGHT: f32 = 64.0 * TYPELESS_SCALE; +const TYPELESS_BUTTON: f32 = 46.0 * TYPELESS_SCALE; +const TYPELESS_STOP_BUTTON: f32 = 24.0 * TYPELESS_SCALE; /// `.ol-typeless-*` 调色板。 const TYPELESS_BG: egui::Color32 = egui::Color32::from_rgb(0x18, 0x18, 0x1b); const TYPELESS_BORDER: egui::Color32 = egui::Color32::from_rgb(0x52, 0x52, 0x5b); @@ -1001,16 +1007,14 @@ fn hairline(ui: &mut egui::Ui, color: egui::Color32) { /// 药丸上方的「正在翻译」徽章(Tauri `ClassicCapsule` 的 `capsule.translating`): /// 蓝点 + 蓝字、圆角胶囊、`--ol-capsule-badge-bg` 底、`--ol-capsule-badge-border` 边。 -fn translating_badge(ui: &mut egui::Ui, pill: egui::Rect, lang: Lang) { +fn translating_badge(ui: &mut egui::Ui, pill: egui::Rect, lang: Lang, scale: f32) { let label = tr_l10n(lang, "capsule.translating"); - let text_width = layout::text_width(ui, label, 10.5); - let width = text_width + 5.0 + 5.0 + 20.0; - let height = 19.0; + let text_width = layout::text_width(ui, label, 10.5 * scale); + let width = text_width + (5.0 + 5.0 + 20.0) * scale; + let height = 19.0 * scale; + let gap = CAPSULE_BADGE_GAP * scale; let rect = egui::Rect::from_center_size( - egui::pos2( - pill.center().x, - pill.top() - CAPSULE_BADGE_GAP - height / 2.0, - ), + egui::pos2(pill.center().x, pill.top() - gap - height / 2.0), egui::vec2(width, height), ); let painter = ui.painter(); @@ -1025,13 +1029,13 @@ fn translating_badge(ui: &mut egui::Ui, pill: egui::Rect, lang: Lang) { egui::Stroke::new(0.5, theme::CAPSULE_BADGE_BORDER), egui::StrokeKind::Inside, ); - let dot = egui::pos2(rect.left() + 10.0, rect.center().y); - painter.circle_filled(dot, 2.5, theme::BLUE); + let dot = egui::pos2(rect.left() + 10.0 * scale, rect.center().y); + painter.circle_filled(dot, 2.5 * scale, theme::BLUE); painter.text( - egui::pos2(dot.x + 5.0 + 2.5, rect.center().y), + egui::pos2(dot.x + (5.0 + 2.5) * scale, rect.center().y), egui::Align2::LEFT_CENTER, label, - egui::FontId::proportional(10.5), + egui::FontId::proportional(10.5 * scale), theme::BLUE, ); } @@ -1055,7 +1059,12 @@ pub fn dictation_capsule( // 未知/空值走 siri(默认样式),只有显式选择 classic 才关掉 GPU 光效。 let use_gpu = !typeless && style != "classic"; let (pill_width, pill_height, button, bar_count) = if typeless { - (TYPELESS_WIDTH, TYPELESS_HEIGHT, TYPELESS_BUTTON, 11) + let width = match phase.as_str() { + "recording" => TYPELESS_RECORDING_WIDTH, + "failed" => TYPELESS_ERROR_WIDTH, + _ => TYPELESS_IDLE_WIDTH, + }; + (width, TYPELESS_HEIGHT, TYPELESS_BUTTON, 11) } else { (PILL_WIDTH, PILL_HEIGHT, ROUND_BUTTON, 5) }; @@ -1076,20 +1085,22 @@ pub fn dictation_capsule( let rect = egui::Rect::from_min_size( egui::pos2( available.center().x - pill_width / 2.0, - available.bottom() - CAPSULE_BOTTOM_INSET - pill_height, + available.bottom() + - if typeless { 0.0 } else { CAPSULE_BOTTOM_INSET } + - pill_height, ), egui::vec2(pill_width, pill_height), ); let _ = ui.allocate_rect(rect, egui::Sense::hover()); if state.translation_active { - translating_badge(ui, rect, lang); + translating_badge(ui, rect, lang, if typeless { TYPELESS_SCALE } else { 1.0 }); } // Tauri 的经典药丸只有「1px 中性描边」+「随音量轻微放大」两件事 // (Capsule.tsx 的 ClassicPill:border 1px var(--ol-capsule-pill-border)、 // transform scale(1 + ambient * 0.018)),**没有**任何外圈扫光/描边颜色变化。 // 所以这里不再把录音相位画成红圈(那是本仓自己加的,用户报「有一个红边」); // 运动感只保留药丸中心的音量波形。 - let ambient = if phase == "recording" { + let ambient = if phase == "recording" && !typeless { state.audio_level.unwrap_or(0.0).clamp(0.0, 1.0) } else { 0.0 @@ -1105,14 +1116,48 @@ pub fn dictation_capsule( ui.painter().rect_stroke( pill, egui::CornerRadius::same((pill_height / 2.0) as u8), - egui::Stroke::new(1.0, pill_border), + egui::Stroke::new(if typeless { 0.5 } else { 1.0 }, pill_border), egui::StrokeKind::Inside, ); } - // Siri is a clean, transparent listening indicator. The cancel / - // confirm affordances belong to the classic and Typeless capsules. + let processing = matches!( + phase.as_str(), + "starting" | "transcribing" | "polishing" | "inserting" + ); + // Siri is a clean, transparent listening indicator. Classic keeps + // both actions visible; Typeless follows Tauri and shows the two + // large actions only while recording, then only its small stop + // action while processing. let center = if siri { rect.shrink(3.0) + } else if typeless && phase != "recording" { + if processing { + let stop_rect = egui::Rect::from_center_size( + egui::pos2( + rect.right() - 7.0 - TYPELESS_STOP_BUTTON / 2.0, + rect.center().y, + ), + egui::vec2(TYPELESS_STOP_BUTTON, TYPELESS_STOP_BUTTON), + ); + let stop = ui.interact( + stop_rect, + ui.id().with("openless-typeless-stop"), + egui::Sense::click(), + ); + round_button( + ui, + stop_rect, + icons::IconName::Close, + stop.hovered(), + TYPELESS_BUTTON_BG, + TYPELESS_INK, + (0.0, 13.0 * TYPELESS_SCALE), + ); + if stop.clicked() { + action = CapsuleAction::Cancel; + } + } + rect.shrink(4.0) } else { let inset = if typeless { 7.0 } else { 8.0 }; let cancel_rect = egui::Rect::from_center_size( @@ -1136,6 +1181,14 @@ pub fn dictation_capsule( cancel.hovered(), cancel_fill, pill_ink, + ( + if typeless { 0.0 } else { 0.8 }, + if typeless { + 24.0 * TYPELESS_SCALE + } else { + 13.0 + }, + ), ); if cancel.clicked() { action = CapsuleAction::Cancel; @@ -1161,6 +1214,14 @@ pub fn dictation_capsule( confirm.hovered(), confirm_fill, confirm_ink, + ( + if typeless { 0.0 } else { 0.8 }, + if typeless { + 24.0 * TYPELESS_SCALE + } else { + 13.0 + }, + ), ); if confirm.clicked() { action = CapsuleAction::Confirm; @@ -1170,10 +1231,6 @@ pub fn dictation_capsule( egui::pos2(confirm_rect.left() - 4.0, rect.bottom() - 4.0), ) }; - let processing = matches!( - phase.as_str(), - "starting" | "transcribing" | "polishing" | "inserting" - ); if phase == "recording" { // Siri capsules are a transparent overlay. Draw the spectral // ribbons with egui primitives so they work on the Vulkan path @@ -1221,7 +1278,11 @@ pub fn dictation_capsule( center.center(), egui::Align2::CENTER_CENTER, tr_l10n(lang, "capsule.thinking"), - egui::FontId::proportional(17.0), + egui::FontId::proportional(if typeless { + 16.0 * TYPELESS_SCALE + } else { + 17.0 + }), theme::INK, ); } @@ -1235,7 +1296,13 @@ pub fn dictation_capsule( } else { tr_l10n(lang, "capsule.thinking") }; - let size = if processing { 17.0 } else { 11.0 }; + let size = if typeless { + 16.0 * TYPELESS_SCALE + } else if processing { + 17.0 + } else { + 11.0 + }; ui.painter().text( center.center(), egui::Align2::CENTER_CENTER, @@ -1251,8 +1318,20 @@ pub fn dictation_capsule( ); } else { // 11px/500 单行居中,超长省略(Tauri `getCapsuleMessageLayout`)。 - let galley = - layout::text_galley(ui, &state.text, pill_ink, 11.0, center.width(), 1); + let galley = layout::text_galley( + ui, + &state.text, + pill_ink, + if typeless { + 16.0 * TYPELESS_SCALE + } else { + 11.0 + }, + center + .width() + .min(if typeless { TYPELESS_IDLE_WIDTH } else { 84.0 }), + 1, + ); ui.painter().galley( egui::pos2( center.center().x - galley.rect.width() / 2.0, @@ -1266,7 +1345,7 @@ pub fn dictation_capsule( action } -/// 28×28 圆形按钮(Tauri `CircleButton`)。 +/// 圆形按钮(classic 为 28×28;Typeless 按 Tauri 的 zoom 比例缩放)。 fn round_button( ui: &egui::Ui, rect: egui::Rect, @@ -1274,7 +1353,9 @@ fn round_button( hovered: bool, fill: egui::Color32, ink: egui::Color32, + button_metrics: (f32, f32), ) { + let (stroke_width, icon_size) = button_metrics; ui.painter().circle_filled( rect.center(), rect.width() / 2.0, @@ -1287,9 +1368,9 @@ fn round_button( ui.painter().circle_stroke( rect.center(), rect.width() / 2.0, - egui::Stroke::new(0.8, theme::LINE), + egui::Stroke::new(stroke_width, theme::LINE), ); - icons::draw_icon(ui, rect.center(), icon, ink); + icons::draw_icon_sized(ui, rect.center(), icon, ink, icon_size); } /// 音量条:Tauri `AudioBars`(5 根 3px 竖条,包络 0.55/0.85/1/0.85/0.55, @@ -1305,9 +1386,14 @@ fn audio_bars(ui: &egui::Ui, rect: egui::Rect, level: f32, bar_count: usize, ink } else { &CLASSIC_ENVELOPE }; - const BASE: f32 = 2.0; + let scale = if bar_count > CLASSIC_ENVELOPE.len() { + TYPELESS_SCALE + } else { + 1.0 + }; + let base = 2.0 * scale; let max = if bar_count > CLASSIC_ENVELOPE.len() { - 26.0 + 28.0 * scale } else { 24.0 }; @@ -1315,12 +1401,12 @@ fn audio_bars(ui: &egui::Ui, rect: egui::Rect, level: f32, bar_count: usize, ink let gated = ((voice - 0.012) / (0.34 - 0.012)).clamp(0.0, 1.0); let eased = gated * gated * (3.0 - 2.0 * gated); let visual = eased.powf(0.42); - let bar_width = 3.0; - let gap = 3.0; + let bar_width = 3.0 * scale; + let gap = 3.0 * scale; let total = envelope.len() as f32 * bar_width + (envelope.len() - 1) as f32 * gap; let mut x = rect.center().x - total / 2.0; for envelope in envelope { - let height = BASE + (max - BASE) * visual * envelope; + let height = base + (max - base) * visual * envelope; ui.painter().rect_filled( egui::Rect::from_center_size( egui::pos2(x + bar_width / 2.0, rect.center().y), From 00fcf4d6e9d8a6712d3a526b2f4cf33245f25526 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 20:09:52 +0800 Subject: [PATCH 14/35] fix: keep typeless capsule at the OpenLess size --- openless-all/app/linux-egui/package-revision | 2 +- .../app/linux-egui/src/ui/frontend/popups.rs | 43 ++++++------------- 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index e2a9fee00..bc6298e80 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -109 +110 diff --git a/openless-all/app/linux-egui/src/ui/frontend/popups.rs b/openless-all/app/linux-egui/src/ui/frontend/popups.rs index f603d5e61..9ae85fea4 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/popups.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/popups.rs @@ -78,16 +78,14 @@ const PILL_HEIGHT: f32 = 42.0; const ROUND_BUTTON: f32 = 28.0; /// Tauri `getCapsuleHostMetrics(.., 'classic').bottomInset`。 const CAPSULE_BOTTOM_INSET: f32 = 16.0; -/// Typeless 胶囊由 Tauri 的 460×128 基准舞台按 `zoom: 0.447` 缩放, -/// 因此录音态为 232×64 → 104×29,空闲态为 176×64 → 79×29, -/// 错误态为 300×64 → 134×29,按钮为 46×46 → 21×21。 -const TYPELESS_SCALE: f32 = 0.447; -const TYPELESS_IDLE_WIDTH: f32 = 176.0 * TYPELESS_SCALE; -const TYPELESS_RECORDING_WIDTH: f32 = 232.0 * TYPELESS_SCALE; -const TYPELESS_ERROR_WIDTH: f32 = 300.0 * TYPELESS_SCALE; -const TYPELESS_HEIGHT: f32 = 64.0 * TYPELESS_SCALE; -const TYPELESS_BUTTON: f32 = 46.0 * TYPELESS_SCALE; -const TYPELESS_STOP_BUTTON: f32 = 24.0 * TYPELESS_SCALE; +/// Linux egui 让 Typeless 复用 OpenLess 经典药丸的可见 footprint,避免录音、thinking、 +/// 完成和错误状态之间发生尺寸跳变;颜色、11 根波形和状态按钮仍保留 Typeless 风格。 +const TYPELESS_SCALE: f32 = 1.0; +const TYPELESS_WIDTH: f32 = PILL_WIDTH; +const TYPELESS_HEIGHT: f32 = PILL_HEIGHT; +const TYPELESS_BUTTON: f32 = ROUND_BUTTON; +const TYPELESS_STOP_BUTTON: f32 = 24.0; +const TYPELESS_TEXT_SIZE: f32 = 16.0; /// `.ol-typeless-*` 调色板。 const TYPELESS_BG: egui::Color32 = egui::Color32::from_rgb(0x18, 0x18, 0x1b); const TYPELESS_BORDER: egui::Color32 = egui::Color32::from_rgb(0x52, 0x52, 0x5b); @@ -1059,12 +1057,7 @@ pub fn dictation_capsule( // 未知/空值走 siri(默认样式),只有显式选择 classic 才关掉 GPU 光效。 let use_gpu = !typeless && style != "classic"; let (pill_width, pill_height, button, bar_count) = if typeless { - let width = match phase.as_str() { - "recording" => TYPELESS_RECORDING_WIDTH, - "failed" => TYPELESS_ERROR_WIDTH, - _ => TYPELESS_IDLE_WIDTH, - }; - (width, TYPELESS_HEIGHT, TYPELESS_BUTTON, 11) + (TYPELESS_WIDTH, TYPELESS_HEIGHT, TYPELESS_BUTTON, 11) } else { (PILL_WIDTH, PILL_HEIGHT, ROUND_BUTTON, 5) }; @@ -1085,9 +1078,7 @@ pub fn dictation_capsule( let rect = egui::Rect::from_min_size( egui::pos2( available.center().x - pill_width / 2.0, - available.bottom() - - if typeless { 0.0 } else { CAPSULE_BOTTOM_INSET } - - pill_height, + available.bottom() - CAPSULE_BOTTOM_INSET - pill_height, ), egui::vec2(pill_width, pill_height), ); @@ -1279,7 +1270,7 @@ pub fn dictation_capsule( egui::Align2::CENTER_CENTER, tr_l10n(lang, "capsule.thinking"), egui::FontId::proportional(if typeless { - 16.0 * TYPELESS_SCALE + TYPELESS_TEXT_SIZE } else { 17.0 }), @@ -1297,7 +1288,7 @@ pub fn dictation_capsule( tr_l10n(lang, "capsule.thinking") }; let size = if typeless { - 16.0 * TYPELESS_SCALE + TYPELESS_TEXT_SIZE } else if processing { 17.0 } else { @@ -1322,14 +1313,8 @@ pub fn dictation_capsule( ui, &state.text, pill_ink, - if typeless { - 16.0 * TYPELESS_SCALE - } else { - 11.0 - }, - center - .width() - .min(if typeless { TYPELESS_IDLE_WIDTH } else { 84.0 }), + if typeless { TYPELESS_TEXT_SIZE } else { 11.0 }, + center.width().min(84.0), 1, ); ui.painter().galley( From 9869466cdc9c39bc6dc93bb7885a8dbf39efc5f9 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 20:49:05 +0800 Subject: [PATCH 15/35] refactor: remove Linux egui OpenGL rendering --- openless-all/app/Cargo.lock | 3 - openless-all/app/linux-egui/Cargo.toml | 3 - openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/lib.rs | 5 + .../app/linux-egui/src/linux_app/window.rs | 3 + openless-all/app/linux-egui/src/main.rs | 7 +- .../app/linux-egui/src/popup_layer.rs | 224 ++++--- .../app/linux-egui/src/ui/frontend/mod.rs | 2 +- .../app/linux-egui/src/ui/frontend/popups.rs | 122 ++-- .../linux-egui/src/ui/frontend/settings.rs | 6 +- .../app/linux-egui/src/ui/frontend/siri_gl.rs | 354 ----------- .../linux-egui/src/ui/frontend/siri_wgpu.rs | 553 ++++++++++++++++++ openless-all/app/linux-egui/src/ui/theme.rs | 2 +- .../app/scripts/package-linux-egui.sh | 4 +- 14 files changed, 765 insertions(+), 525 deletions(-) delete mode 100644 openless-all/app/linux-egui/src/ui/frontend/siri_gl.rs create mode 100644 openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs diff --git a/openless-all/app/Cargo.lock b/openless-all/app/Cargo.lock index f92c98e4f..bf43c6310 100644 --- a/openless-all/app/Cargo.lock +++ b/openless-all/app/Cargo.lock @@ -3705,11 +3705,8 @@ dependencies = [ "dbus", "eframe", "egui", - "egui_glow", "fs2", "futures-util", - "glow", - "glutin", "hyper-util", "image", "keyring", diff --git a/openless-all/app/linux-egui/Cargo.toml b/openless-all/app/linux-egui/Cargo.toml index 6880ad512..1d340c707 100644 --- a/openless-all/app/linux-egui/Cargo.toml +++ b/openless-all/app/linux-egui/Cargo.toml @@ -38,9 +38,6 @@ egui = "=0.36.2" # 「贴底居中 + 不抢键盘焦点」的 overlay surface;不支持时回退到 X11 叠加层。 wayland-client = "0.31" wayland-protocols-wlr = { version = "0.3", features = ["client"] } -glutin = { version = "0.32", default-features = false, features = ["egl", "wayland"] } -egui_glow = "0.36.2" -glow = "0.17" raw-window-handle = "0.6" eframe = { version = "=0.36.2", default-features = false, features = ["accesskit", "default_fonts", "wgpu_no_default_features", "wayland", "x11"] } wgpu = { version = "30.0.1", default-features = false, features = ["std", "vulkan", "wgsl"] } diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index bc6298e80..58c9bdf9d 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -110 +111 diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index dda6cd0ba..e04e6843e 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -35,6 +35,11 @@ mod single_instance; mod tray; mod ui_state; +// Shared by the eframe windows and the native Wayland layer-shell renderer so +// every Vulkan surface uses the exact same WGPU callback resource type. +#[path = "ui/frontend/siri_wgpu.rs"] +pub mod siri_wgpu; + pub use audio::LinuxCpalRecorder; pub use audio_cue::{play_cue_start, play_cue_stop, CueTone}; pub use audio_mute::AudioMuteGuard; diff --git a/openless-all/app/linux-egui/src/linux_app/window.rs b/openless-all/app/linux-egui/src/linux_app/window.rs index b28a59150..e3668a3af 100644 --- a/openless-all/app/linux-egui/src/linux_app/window.rs +++ b/openless-all/app/linux-egui/src/linux_app/window.rs @@ -339,6 +339,9 @@ pub(super) fn run_ui_client(socket: std::path::PathBuf) -> Result<(), String> { options, Box::new(move |cc| { theme::install(&cc.egui_ctx); + if let Some(state) = cc.wgpu_render_state.as_ref() { + crate::ui::frontend::siri_wgpu::install_wgpu(state, MAIN_MSAA_SAMPLES.into()); + } Ok(Box::new(UiClientApp::new( client, cc.wgpu_render_state.as_ref(), diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index e1a07dbb5..7f7b861bb 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -7459,7 +7459,7 @@ focus_was_stolen={} focus_restored={} warnings={:?}", } /// 胶囊在实现了 `zwlr_layer_shell_v1` 的合成器上跑原生 layer surface - /// (贴底居中、键盘焦点不可能、不占工作区);协议缺失、EGL 起不来或 + /// (贴底居中、键盘焦点不可能、不占工作区);协议缺失、Vulkan 初始化失败或 /// configure 超时都会返回 Err;不会回退到普通焦点窗口或 XWayland。 fn run_capsule_layer_process() -> Result<(), LayerCapsuleFailure> { let geometry = openless_linux_egui::capsule_geometry( @@ -7469,7 +7469,7 @@ focus_was_stolen={} focus_restored={} warnings={:?}", ); // The host pipe is opened lazily, on the first frame the runner asks // for: the runner only calls back once the layer surface is configured - // and EGL is live, so a preflight failure leaves stdin untouched for the + // and Vulkan is live, so a preflight failure leaves stdin untouched for the // fallback window. `started` records that the pipe is in use, which // makes a late failure fatal instead of a (broken) second attempt. let mut app: Option = None; @@ -7630,6 +7630,9 @@ focus_was_stolen={} focus_restored={} warnings={:?}", options, Box::new(move |cc| { theme::install(&cc.egui_ctx); + if let Some(state) = cc.wgpu_render_state.as_ref() { + crate::ui::frontend::siri_wgpu::install_wgpu(state, 4); + } Ok(Box::new(NativePopupApp { kind, state: PopupState::default(), diff --git a/openless-all/app/linux-egui/src/popup_layer.rs b/openless-all/app/linux-egui/src/popup_layer.rs index 03e51f7c8..e190e5a3b 100644 --- a/openless-all/app/linux-egui/src/popup_layer.rs +++ b/openless-all/app/linux-egui/src/popup_layer.rs @@ -18,8 +18,8 @@ //! compositor to respect reserved panel/taskbar space. //! //! Rendering reuses the popup's existing egui view ([`crate::ui::frontend::popups::dictation_capsule`]): -//! the runner below owns the EGL context (glutin), the `egui_glow` painter and -//! the wayland event loop, and asks the caller for one egui frame at a time. +//! the runner below owns a Vulkan/WGPU surface and the Wayland event loop, and +//! asks the caller for one egui frame at a time. //! //! Everything that can be decided without a compositor lives in pure functions //! ([`has_layer_shell`], [`choose_capsule_path`], [`capsule_geometry`], @@ -27,9 +27,7 @@ //! shell around them and reports failures as `Err`. use std::ffi::c_void; -use std::num::NonZeroU32; use std::ptr::NonNull; -use std::sync::Arc; use std::time::{Duration, Instant}; use wayland_client::protocol::{wl_compositor, wl_pointer, wl_registry, wl_seat, wl_surface}; @@ -468,108 +466,169 @@ impl Dispatch for LayerState { } } -// ── EGL + egui_glow plumbing ──────────────────────────────────────────────── +// ── Vulkan/WGPU plumbing ──────────────────────────────────────────────────── -struct GlSurface { - _display: glutin::display::Display, - surface: glutin::surface::Surface, - context: glutin::context::PossiblyCurrentContext, - gl: Arc, +struct WgpuSurface { + _instance: eframe::egui_wgpu::wgpu::Instance, + surface: eframe::egui_wgpu::wgpu::Surface<'static>, + device: eframe::egui_wgpu::wgpu::Device, + queue: eframe::egui_wgpu::wgpu::Queue, + config: eframe::egui_wgpu::wgpu::SurfaceConfiguration, + renderer: eframe::egui_wgpu::Renderer, } -impl GlSurface { +impl WgpuSurface { fn new( connection: &Connection, wl_surface: &wl_surface::WlSurface, size: (u32, u32), ) -> Result { - let size = (size.0.max(1), size.1.max(1)); - use glutin::config::{Api, ConfigTemplateBuilder}; - use glutin::context::{ContextApi, ContextAttributesBuilder}; - use glutin::display::{Display, DisplayApiPreference, GlDisplay}; - use glutin::prelude::*; - use glutin::surface::{SurfaceAttributesBuilder, WindowSurface}; + use eframe::egui_wgpu::wgpu; use raw_window_handle::{ RawDisplayHandle, RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle, }; let display_ptr = connection.backend().display_ptr().cast::(); - // `ObjectId::as_ptr` hands back the underlying `wl_proxy`, which is the - // same C object as the `wl_surface` glutin wants. let surface_ptr = wl_surface.id().as_ptr().cast::(); - let raw_display = RawDisplayHandle::Wayland(WaylandDisplayHandle::new( NonNull::new(display_ptr).ok_or("null wayland display")?, )); let raw_window = RawWindowHandle::Wayland(WaylandWindowHandle::new( NonNull::new(surface_ptr).ok_or("null wayland surface")?, )); - - let display = unsafe { Display::new(raw_display, DisplayApiPreference::Egl) } - .map_err(|error| format!("egl display: {error}"))?; - let template = ConfigTemplateBuilder::new() - .with_alpha_size(8) - .with_transparency(true) - .with_api(Api::OPENGL | Api::GLES2) - .build(); - let config = unsafe { display.find_configs(template) } - .map_err(|error| format!("egl configs: {error}"))? - .reduce(|best, candidate| { - let best_alpha = best.alpha_size(); - let candidate_alpha = candidate.alpha_size(); - if candidate_alpha > best_alpha { - candidate - } else { - best - } + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN, + flags: wgpu::InstanceFlags::default(), + memory_budget_thresholds: Default::default(), + backend_options: Default::default(), + display: None, + }); + // The Wayland connection and wl_surface are owned by run_layer_capsule + // and outlive this surface. RawHandle is therefore the correct unsafe + // constructor: no legacy graphics context is created or retained. + let surface = unsafe { + instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: Some(raw_display), + raw_window_handle: raw_window, }) - .ok_or("no suitable EGL config")?; - let attributes = SurfaceAttributesBuilder::::new().build( - raw_window, - NonZeroU32::new(size.0.max(1)).ok_or("zero width")?, - NonZeroU32::new(size.1.max(1)).ok_or("zero height")?, + } + .map_err(|error| format!("vulkan surface: {error}"))?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("wgpu runtime: {error}"))?; + let adapter = runtime + .block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: Some(&surface), + force_fallback_adapter: false, + apply_limit_buckets: false, + })) + .map_err(|error| format!("vulkan adapter: {error}"))?; + let (device, queue) = runtime + .block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) + .map_err(|error| format!("vulkan device: {error}"))?; + let mut config = surface + .get_default_config(&adapter, size.0.max(1), size.1.max(1)) + .ok_or("Wayland surface has no Vulkan format")?; + // The capsule is transparent. Auto lets the compositor select the + // premultiplied alpha mode supported by the Vulkan surface. + config.alpha_mode = wgpu::CompositeAlphaMode::Auto; + surface.configure(&device, &config); + let format = config.format; + let mut renderer = eframe::egui_wgpu::Renderer::new( + &device, + format, + eframe::egui_wgpu::RendererOptions { + msaa_samples: 1, + ..Default::default() + }, ); - let surface = unsafe { display.create_window_surface(&config, &attributes) } - .map_err(|error| format!("egl window surface: {error}"))?; - let context_attributes = ContextAttributesBuilder::new() - .with_context_api(ContextApi::OpenGl(None)) - .build(Some(raw_window)); - let context = unsafe { display.create_context(&config, &context_attributes) } - .map_err(|error| format!("egl context: {error}"))? - .make_current(&surface) - .map_err(|error| format!("egl make current: {error}"))?; - let gl = unsafe { - glow::Context::from_loader_function(|symbol| { - let symbol = std::ffi::CString::new(symbol) - .map_err(|_| ()) - .unwrap_or_default(); - display.get_proc_address(symbol.as_c_str()) - }) - }; + crate::siri_wgpu::install_renderer(&mut renderer, &device, format, 1); Ok(Self { - _display: display, + _instance: instance, surface, - context, - gl: Arc::new(gl), + device, + queue, + config, + renderer, }) } fn paint( - &self, - painter: &mut egui_glow::Painter, + &mut self, size: (u32, u32), primitives: &[egui::ClippedPrimitive], textures_delta: &mut egui::TexturesDelta, scale: f32, - ) { - // The capsule is a transparent overlay: the painter clears the buffer - // itself, so no opaque clear colour is needed. - painter.paint_and_update_textures( - [size.0.max(1), size.1.max(1)], - scale, + ) -> Result<(), String> { + let width = size.0.max(1); + let height = size.1.max(1); + if self.config.width != width || self.config.height != height { + self.config.width = width; + self.config.height = height; + self.surface.configure(&self.device, &self.config); + } + let output = match self.surface.get_current_texture() { + eframe::egui_wgpu::wgpu::CurrentSurfaceTexture::Success(texture) + | eframe::egui_wgpu::wgpu::CurrentSurfaceTexture::Suboptimal(texture) => texture, + eframe::egui_wgpu::wgpu::CurrentSurfaceTexture::Timeout + | eframe::egui_wgpu::wgpu::CurrentSurfaceTexture::Occluded => { + textures_delta.clear(); + return Ok(()); + } + other => return Err(format!("vulkan acquire: {other:?}")), + }; + let view = output + .texture + .create_view(&eframe::egui_wgpu::wgpu::TextureViewDescriptor::default()); + let screen = eframe::egui_wgpu::ScreenDescriptor { + size_in_pixels: [width, height], + pixels_per_point: scale, + }; + let mut encoder = self.device.create_command_encoder( + &eframe::egui_wgpu::wgpu::CommandEncoderDescriptor { + label: Some("openless-capsule-wgpu"), + }, + ); + let callbacks = self.renderer.update_buffers( + &self.device, + &self.queue, + &mut encoder, primitives, - textures_delta, + &screen, ); + { + let mut pass = encoder + .begin_render_pass(&eframe::egui_wgpu::wgpu::RenderPassDescriptor { + label: Some("openless-capsule"), + color_attachments: &[Some( + eframe::egui_wgpu::wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: eframe::egui_wgpu::wgpu::Operations { + load: eframe::egui_wgpu::wgpu::LoadOp::Clear( + eframe::egui_wgpu::wgpu::Color::TRANSPARENT, + ), + store: eframe::egui_wgpu::wgpu::StoreOp::Store, + }, + depth_slice: None, + }, + )], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }) + .forget_lifetime(); + self.renderer.render(&mut pass, primitives, &screen); + } + self.queue.submit(callbacks); + self.queue.submit(std::iter::once(encoder.finish())); + // In wgpu 30 presentation belongs to the queue, not SurfaceTexture. + self.queue.present(output); + textures_delta.clear(); + Ok(()) } } @@ -579,8 +638,8 @@ impl GlSurface { /// the compositor closes it. /// /// Fails (with a human-readable reason) when there is no Wayland session, the -/// compositor lacks the protocol, the configure never arrives, or EGL could not -/// be initialised — the caller then uses the X11 overlay instead. The context is +/// compositor lacks the protocol, the configure never arrives, or Vulkan/WGPU could not +/// be initialised — the caller then uses the X11 overlay instead. The renderer is /// created here; the caller installs fonts/visuals on the first frame (egui's /// built-in fonts are enough for the pill, but a host font setup should run /// once). @@ -683,13 +742,10 @@ where (layer=top, anchor=bottom, margin.bottom={}, keyboard-interactivity=none, exclusive-zone=0)", geometry.bottom_gap ); - let gl = GlSurface::new(&connection, &wl_surface, geometry.buffer_size())?; - use glutin::surface::GlSurface as _; - let mut painter = egui_glow::Painter::new(gl.gl.clone(), "", None, false) - .map_err(|error| format!("egui_glow painter: {error}"))?; - eprintln!("OpenLess capsule: EGL ready on the layer surface"); + let mut renderer = WgpuSurface::new(&connection, &wl_surface, geometry.buffer_size())?; + eprintln!("OpenLess capsule: Vulkan/WGPU ready on the layer surface"); let context = egui::Context::default(); - // This EGL layer surface is single-sample; feather egui geometry instead. + // This layer surface is single-sample; feather egui geometry instead. context.tessellation_options_mut(|options| { options.feathering = true; options.feathering_size_in_pixels = 1.0; @@ -710,16 +766,12 @@ where let mut frame = frame(&context, input, first); first = false; let primitives = context.tessellate(frame.output.shapes.clone(), scale); - gl.paint( - &mut painter, + renderer.paint( (rect.width().max(1.0) as u32, rect.height().max(1.0) as u32), &primitives, &mut frame.output.textures_delta, scale, - ); - gl.surface - .swap_buffers(&gl.context) - .map_err(|error| format!("egl swap: {error}"))?; + )?; if frame.exit { return Ok(()); } diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index 15a4e4558..acb1cb007 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -10,7 +10,7 @@ pub mod paging; pub mod popups; pub mod selection_ask; pub mod settings; -pub mod siri_gl; +pub use openless_linux_egui::siri_wgpu; pub mod style; pub mod translation; pub mod view_model; diff --git a/openless-all/app/linux-egui/src/ui/frontend/popups.rs b/openless-all/app/linux-egui/src/ui/frontend/popups.rs index 9ae85fea4..0de1f0a36 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/popups.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/popups.rs @@ -14,7 +14,7 @@ use eframe::egui; -use super::{icons, layout, siri_gl, theme}; +use super::{icons, layout, siri_wgpu, theme}; use openless_linux_egui::{ fmt_l10n, tr_l10n, CapsulePopupState, Lang, LessComputerPopupState, PopupChatMessage, QaPolishState, QaPopupState, @@ -536,7 +536,7 @@ pub fn selection_ask( } else { color_to_f32(theme::INK) }; - let drive = siri_gl::SiriDrive { + let drive = siri_wgpu::SiriDrive { level: 0.0, resolved: if recording { 1.0 } else { 0.0 }, // 思考态转得更快,和 Tauri 的 state→speed 语义一致。 @@ -544,14 +544,14 @@ pub fn selection_ask( warming: false, }; let dt = ui.input(|input| input.stable_dt); - let clock = siri_gl::tick(ui.ctx(), "qa-composer-ring", drive, dt); - let glow = siri_gl::SiriGlow::ring( + let clock = siri_wgpu::tick(ui.ctx(), "qa-composer-ring", drive, dt); + let effect = siri_wgpu::SiriEffect::ring( clock.time, 12.0, if recording { 2.0 } else { 1.6 }, ) .with_tint(tint); - if !siri_gl::paint(ui, rect.expand(3.0), glow) { + if !siri_wgpu::paint(ui, rect.expand(3.0), effect) { spinner_ring(ui, rect, if recording { theme::ERR } else { theme::INK }); } } @@ -982,7 +982,7 @@ fn rounded_rect_points(rect: egui::Rect, radius: f32, segments: usize) -> Vec [f32; 3] { [ f32::from(color.r()) / 255.0, @@ -1086,18 +1086,11 @@ pub fn dictation_capsule( if state.translation_active { translating_badge(ui, rect, lang, if typeless { TYPELESS_SCALE } else { 1.0 }); } - // Tauri 的经典药丸只有「1px 中性描边」+「随音量轻微放大」两件事 - // (Capsule.tsx 的 ClassicPill:border 1px var(--ol-capsule-pill-border)、 - // transform scale(1 + ambient * 0.018)),**没有**任何外圈扫光/描边颜色变化。 - // 所以这里不再把录音相位画成红圈(那是本仓自己加的,用户报「有一个红边」); - // 运动感只保留药丸中心的音量波形。 - let ambient = if phase == "recording" && !typeless { - state.audio_level.unwrap_or(0.0).clamp(0.0, 1.0) - } else { - 0.0 - }; - let pill = - egui::Rect::from_center_size(rect.center(), rect.size() * (1.0 + ambient * 0.018)); + // The capsule footprint is invariant across recording, thinking and + // terminal frames. The old volume scale made the pill visibly jump + // smaller when thinking ended, just before the hide deadline. + // Motion belongs to the centre effect, never to the host geometry. + let pill = rect; if !siri { ui.painter().rect_filled( pill, @@ -1223,22 +1216,22 @@ pub fn dictation_capsule( ) }; if phase == "recording" { - // Siri capsules are a transparent overlay. Draw the spectral - // ribbons with egui primitives so they work on the Vulkan path - // too (the legacy GL shader callback is unavailable there). - let drive = siri_gl::SiriDrive { + // Siri capsules are transparent overlays. Queue the spectral + // ribbon through the shared WGPU callback used by both eframe + // windows and the native layer-shell surface. + let drive = siri_wgpu::SiriDrive { level: state.audio_level.unwrap_or_default(), resolved: 1.0, speed: 1.0, warming: state.audio_level.is_none(), }; let dt = ui.input(|input| input.stable_dt); - let clock = siri_gl::tick(ui.ctx(), "capsule-siri-wave", drive, dt); + let clock = siri_wgpu::tick(ui.ctx(), "capsule-siri-wave", drive, dt); if siri { - let _ = siri_gl::paint( + let _ = siri_wgpu::paint( ui, center, - siri_gl::SiriGlow::wave(clock.time, clock.level), + siri_wgpu::SiriEffect::wave(clock.time, clock.level), ); ui.ctx() .request_repaint_after(std::time::Duration::from_millis(16)); @@ -1253,18 +1246,18 @@ pub fn dictation_capsule( } } else if processing { // 思考中:Siri 流体圆点(orb),从 wave 收拢的光点化开成环。 - let drive = siri_gl::SiriDrive { + let drive = siri_wgpu::SiriDrive { level: 0.0, resolved: 0.0, speed: 1.3, warming: false, }; let dt = ui.input(|input| input.stable_dt); - let clock = siri_gl::tick(ui.ctx(), "capsule-siri-orb", drive, dt); + let clock = siri_wgpu::tick(ui.ctx(), "capsule-siri-orb", drive, dt); // 0.3s 全聚圆心接住 wave 收拢的光点,再缓缓散开成环。 let gather = (1.0 - (clock.time / 0.9).clamp(0.0, 1.0)).clamp(0.0, 1.0); - let glow = siri_gl::SiriGlow::orb(clock.time, gather); - if !use_gpu || !siri_gl::paint(ui, center, glow) { + let effect = siri_wgpu::SiriEffect::orb(clock.time, gather); + if !use_gpu || !siri_wgpu::paint(ui, center, effect) { ui.painter().text( center.center(), egui::Align2::CENTER_CENTER, @@ -1289,7 +1282,11 @@ pub fn dictation_capsule( }; let size = if typeless { TYPELESS_TEXT_SIZE - } else if processing { + } else if processing || matches!(phase.as_str(), "completed" | "done" | "idle") { + // A terminal snapshot can briefly have no result text. Keep + // its thinking placeholder at the processing size until the + // popup is actually dismissed; never render a tiny final + // "thinking" frame. 17.0 } else { 11.0 @@ -1982,7 +1979,7 @@ mod tests { /// return everything it painted. fn run(size: egui::Vec2, mut render: impl FnMut(&mut egui::Ui) -> String) -> String { // Every popup test renders the same frontend as the GPU-state tests, so - // they share the process-global glow flags and must not run in parallel. + // they share the process-global effect flags and must not run in parallel. let ctx = egui::Context::default(); let mut painted = String::new(); for _ in 0..2 { @@ -2260,16 +2257,10 @@ mod tests { ); } - /// 录音/思考的中心视觉现在由 CPU 画法承担(着色器路径已从渲染路径摘除,见 - /// `siri_gl` 文件头):这两种状态都不许再排 GPU 回调,但中心不能是空的; - /// 终态则一个光效都没有。 - /// - /// 以前录音还会多排一个**外圈红扫光**、思考多一个黑扫光。Tauri 的经典药丸只有 - /// 1px 中性描边(Capsule.tsx 的 `border: 1px var(--ol-capsule-pill-border)`), - /// 没有外圈扫光——用户报「语音输入弹窗有一个红边」就是它,所以这里继续锁死。 + /// Recording and thinking each queue exactly one WGPU centre effect. Terminal + /// states must not leave a callback behind while the popup waits to dismiss. #[test] - fn capsule_keeps_the_centre_glow_off_the_gpu_path() { - // The GPU state is process-global; take the shared test guard. + fn capsule_queues_only_the_active_wgpu_centre_effect() { let frame = |state: CapsulePopupState| { let ctx = egui::Context::default(); let output = crate::ui::frontend::run_pass( @@ -2290,15 +2281,11 @@ mod tests { .iter() .filter(|clipped| matches!(clipped.shape, egui::Shape::Callback(_))) .count(); - let mut glow = 0; - for clipped in &output.shapes { - count_centre_glow(&clipped.shape, &mut glow); - } - (callbacks, glow) + (callbacks,) }; for (label, state, wants_centre) in [ ( - "recording = CPU wave only, no perimeter ring", + "recording = one WGPU wave, no perimeter ring", CapsulePopupState { phase: "recording".into(), audio_level: Some(0.2), @@ -2307,7 +2294,7 @@ mod tests { true, ), ( - "thinking = CPU orb only, no perimeter ring", + "thinking = one WGPU orb, no perimeter ring", CapsulePopupState { phase: "transcribing".into(), ..Default::default() @@ -2315,7 +2302,7 @@ mod tests { true, ), ( - "terminal capsule paints no glow", + "terminal capsule paints no WGPU effect", CapsulePopupState { phase: "inserted".into(), text: "hello".into(), @@ -2324,14 +2311,8 @@ mod tests { false, ), ] { - let (callbacks, glow) = frame(state); - assert_eq!( - callbacks, 0, - "{label}: the shader path is off, no GPU callback may be queued" - ); - if wants_centre { - assert!(glow > 0, "{label}: the centre glow must still be painted"); - } + let (callbacks,) = frame(state); + assert_eq!(callbacks, usize::from(wants_centre), "{label}"); } } @@ -2500,12 +2481,12 @@ mod tests { } /// Render one capsule frame and collect the colours, the GPU callback count - /// and the number of centre glow strokes (the CPU wave lines). + /// and the number of centre effect strokes (the CPU wave lines). fn capsule_frame(state: &CapsulePopupState) -> (Vec, usize, usize) { let ctx = egui::Context::default(); let mut colors = Vec::new(); let mut callbacks = 0; - let mut glow = 0; + let mut effect = 0; for _ in 0..2 { let output = crate::ui::frontend::run_pass( &ctx, @@ -2522,28 +2503,27 @@ mod tests { ); colors.clear(); callbacks = 0; - glow = 0; + effect = 0; for clipped in &output.shapes { painted_colors(&clipped.shape, &mut colors); - count_centre_glow(&clipped.shape, &mut glow); + count_centre_effect(&clipped.shape, &mut effect); if matches!(clipped.shape, egui::Shape::Callback(_)) { callbacks += 1; } } } - (colors, callbacks, glow) + (colors, callbacks, effect) } - /// 录音相位的「中心运动感」现在由 CPU 画法承担(着色器路径已从渲染路径摘除, - /// 见 `siri_gl` 文件头):波形是一串 49 点的折线,思考是流体圆点,两者都 - /// 算中心光效(CPU 竖条走的是小圆角矩形,不在这里)。 - fn count_centre_glow(shape: &egui::Shape, out: &mut usize) { + /// Classic fallback primitives still expose a centre visual for tests; Siri + /// states are represented by one WGPU callback instead. + fn count_centre_effect(shape: &egui::Shape, out: &mut usize) { match shape { egui::Shape::Path(path) if path.points.len() >= 8 => *out += 1, egui::Shape::Circle(_) => *out += 1, egui::Shape::Vec(shapes) => { for shape in shapes { - count_centre_glow(shape, out); + count_centre_effect(shape, out); } } _ => {} @@ -2558,7 +2538,7 @@ mod tests { #[test] fn recording_capsule_paints_no_coloured_outline() { // Tauri 的经典药丸只有 1px 中性描边(Capsule.tsx:border 1px - // var(--ol-capsule-pill-border)),录音时只把药丸随音量放大 1.8%。 + // var(--ol-capsule-pill-border));录音音量只驱动中心波形。 // 外圈红/黑扫光是本仓自己加的,用户报「语音输入弹窗有一个红边」—— // 这条测试锁死它不许回来。 for phase in ["Recording", "Transcribing", "Polishing"] { @@ -2593,13 +2573,13 @@ mod tests { translation_active: false, style: "siri".to_string(), }; - let (colors, callbacks, glow) = capsule_frame(&state); + let (colors, callbacks, effect) = capsule_frame(&state); // 音量竖条是 3px 宽的小圆角矩形:数一下细长条形的填充个数。 let fills = colors.iter().filter(|color| color.a() > 0).count(); assert!( - callbacks > 0 || fills >= 6 || glow > 0, + callbacks > 0 || fills >= 6 || effect > 0, "recording capsule must keep the centre visual \ - (callbacks={callbacks}, fills={fills}, glow={glow})" + (callbacks={callbacks}, fills={fills}, effect={effect})" ); } @@ -2695,7 +2675,7 @@ mod tests { "{painted}" ); - // siri 样式:中心交给 Siri 光效(现在由 CPU 画,见 `siri_gl` 文件头), + // siri 样式:中心交给 Siri 的 WGPU effect callback, // 所以不再叠一行「思考中」文字——与 GPU 就绪后的终态一致。 let transcribing_siri = CapsulePopupState { phase: "Transcribing".to_string(), diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index fb07a1d54..6f6be0230 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -3558,7 +3558,11 @@ fn capsule_style_preview(ui: &mut egui::Ui, style: usize) { 0 => { // Match the transparent, drifting spectral ribbons used by the // live Siri capsule instead of showing unrelated loading dots. - let _ = super::siri_gl::paint(ui, rect, super::siri_gl::SiriGlow::wave(1.35, 0.52)); + let _ = super::siri_wgpu::paint( + ui, + rect, + super::siri_wgpu::SiriEffect::wave(1.35, 0.52), + ); } 1 => { let pill = egui::Rect::from_center_size(rect.center(), egui::vec2(152.0, 34.0)); diff --git a/openless-all/app/linux-egui/src/ui/frontend/siri_gl.rs b/openless-all/app/linux-egui/src/ui/frontend/siri_gl.rs deleted file mode 100644 index 1c34512cd..000000000 --- a/openless-all/app/linux-egui/src/ui/frontend/siri_gl.rs +++ /dev/null @@ -1,354 +0,0 @@ -//! Siri-inspired audio visuals shared by the Vulkan eframe windows and the -//! manually-rendered Wayland capsule. Animation state is time- and level- -//! driven; the visible wave/orb/ring is drawn with renderer-independent egui -//! primitives, so a missing GPU path can never replace it with generic bars. -//! -//! The GL callback (with its shader sources and program cache) used to live -//! here as a parity reference; the render path is wgpu now and never called it, -//! so it is gone. The sources are in the git history if a wgpu port needs them. - -/// Which shader drives the glow. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SiriMode { - /// Spectrum sound wave: recording. `level` is the live voice level and - /// `resolved` 1 → 0 collapses the wave into the breathing point. - Wave, - /// Metaball fluid dots: thinking. `gather` 1 = all six merged in the middle - /// (catches the wave's collapsed point), 0 = spread into the rotating ring. - Orb, - /// Rounded-rect perimeter sweep: the ring that hugs the ask composer and - /// the recording capsule (red while recording, ink while thinking). - Ring, -} - -/// One frame of glow parameters. Everything is a scalar or a 3-vector: the -/// uniform upload is the only per-frame work besides the draw call. -#[derive(Clone, Copy, Debug)] -pub struct SiriGlow { - pub mode: SiriMode, - pub time: f32, - pub level: f32, - pub gather: f32, - /// Ring only: corner radius and band thickness in physical pixels. - pub radius: f32, - pub thickness: f32, - /// Per-call-site tint. `[1.0; 3]` keeps the spectral Siri colors (capsule); - /// the ask/composer ring uses a flat accent (red while recording, ink while - /// thinking) exactly like the previous CPU ring. - pub tint: [f32; 3], -} - -impl SiriGlow { - pub fn wave(time: f32, level: f32) -> Self { - Self { - mode: SiriMode::Wave, - time, - level, - gather: 0.0, - radius: 0.0, - thickness: 2.0, - tint: [1.0; 3], - } - } - - pub fn orb(time: f32, gather: f32) -> Self { - Self { - mode: SiriMode::Orb, - time, - level: 0.0, - gather, - radius: 0.0, - thickness: 2.0, - tint: [1.0; 3], - } - } - - /// Perimeter ring for a `rect` of `radius` (px) with a `thickness` (px) band. - pub fn ring(time: f32, radius: f32, thickness: f32) -> Self { - Self { - mode: SiriMode::Ring, - time, - level: 0.0, - gather: 0.0, - radius, - thickness, - tint: [1.0; 3], - } - } - - pub fn with_tint(mut self, tint: [f32; 3]) -> Self { - self.tint = tint; - self - } -} - -/// Voice level → visual amplitude (Tauri `SiriGL.tsx::visualVoice`): noise gate -/// at 0.012, ceiling 0.34, smoothstep, then a 0.42 power for the VU feel. -pub fn visual_voice(raw: f32) -> f32 { - const GATE: f32 = 0.012; - const CEILING: f32 = 0.34; - let gated = ((raw - GATE) / (CEILING - GATE)).clamp(0.0, 1.0); - let eased = gated * gated * (3.0 - 2.0 * gated); - eased.max(0.0).powf(0.42) -} - -/// What the caller wants to drive this frame. -#[derive(Clone, Copy, Debug)] -pub struct SiriDrive { - /// Raw RMS from the audio pipeline (`capsule:state.audio_level`). - pub level: f32, - /// 1 = wave expanded (recording), 0 = collapsed into the thinking point. - pub resolved: f32, - pub speed: f32, - /// Microphone not on yet: the wave breathes at a low, obviously-unready - /// amplitude instead of following the level. - pub warming: bool, -} - -impl Default for SiriDrive { - fn default() -> Self { - Self { - level: 0.0, - resolved: 1.0, - speed: 1.0, - warming: false, - } - } -} - -/// Smoothed animation state, kept in egui memory because egui only repaints -/// while something animates (`SiriGL.tsx` keeps the same values in refs). -#[derive(Clone, Copy, Debug)] -pub struct SiriClock { - pub time: f32, - pub level: f32, - pub resolved: f32, - pub speed: f32, -} - -/// Advance one call site's clock by `dt` (seconds). -pub fn tick(ctx: &egui::Context, id: &str, drive: SiriDrive, dt: f32) -> SiriClock { - let key = egui::Id::new(("openless-siri-clock", id)); - let dt = dt.clamp(0.0, 0.05); - let mut clock = ctx.data_mut(|data| { - data.get_temp::(key).unwrap_or(SiriClock { - time: 0.0, - level: 0.0, - resolved: drive.resolved, - speed: drive.speed, - }) - }); - // Speed is eased before it scales dt, so changing it mid-animation stays - // continuous (Tauri: `smoothSpeed += (speed - smoothSpeed) * (1-e^{-dt*2.5})`). - clock.speed += (drive.speed - clock.speed) * (1.0 - (-dt * 2.5).exp()); - clock.time += dt * clock.speed; - let target = if drive.warming { - 0.12 + 0.06 * (clock.time * 3.0).sin() - } else if drive.resolved < 0.5 { - 0.14 + 0.07 * (clock.time * 2.2).sin() - } else { - visual_voice(drive.level) - }; - // Fast attack, slow release — VU-meter feel. - let attack = if target > clock.level { 14.0 } else { 5.0 }; - clock.level += (target - clock.level) * (1.0 - (-dt * attack).exp()); - clock.resolved += (drive.resolved - clock.resolved) * (1.0 - (-dt * 3.0).exp()); - ctx.data_mut(|data| data.insert_temp(key, clock)); - clock -} - -/// Queue the GPU glow for `rect`. -/// -/// Returns `true` when the caller must *not* draw its CPU fallback: that is the -/// case only after the program has compiled and drawn successfully once, so the -/// first frames paint both (the callback is a no-op until it has a program, so -/// nothing is double-drawn) and a broken driver keeps the old look forever. -pub fn paint(ui: &egui::Ui, rect: egui::Rect, glow: SiriGlow) -> bool { - if !rect.is_positive() || !rect.is_finite() { - return false; - } - let painter = ui.painter().with_clip_rect(rect); - let color = |rgb: [f32; 3], alpha: f32| { - egui::Color32::from_rgba_unmultiplied( - (rgb[0].clamp(0.0, 1.0) * 255.0) as u8, - (rgb[1].clamp(0.0, 1.0) * 255.0) as u8, - (rgb[2].clamp(0.0, 1.0) * 255.0) as u8, - (alpha.clamp(0.0, 1.0) * 255.0) as u8, - ) - }; - match glow.mode { - SiriMode::Wave => { - let amplitude = - (rect.height() * (0.10 + glow.level * 0.36)).clamp(2.0, rect.height() * 0.48); - let center_y = rect.center().y; - let hues = [ - [0.35, 0.74, 1.0], - [0.45, 0.45, 1.0], - [0.95, 0.48, 0.94], - [1.0, 0.55, 0.72], - ]; - for (index, hue) in hues.into_iter().enumerate() { - let phase = index as f32 * 0.72; - let points = (0..=48) - .map(|step| { - let t = step as f32 / 48.0; - let envelope = (std::f32::consts::PI * t).sin().powf(0.7); - let y = center_y - + (glow.time * 2.1 + t * 10.0 + phase).sin() * amplitude * envelope; - egui::pos2(rect.left() + rect.width() * t, y) - }) - .collect::>(); - // Stack a broad, low-alpha halo beneath the crisp filament. - // A single opaque polyline reads as neon wire; Siri's reference - // has a soft colored bloom around a bright, thin wave. - painter.add(egui::Shape::line( - points.clone(), - egui::Stroke::new(7.0 + glow.level * 3.0, color(hue, 0.055)), - )); - painter.add(egui::Shape::line( - points.clone(), - egui::Stroke::new(3.8 + glow.level * 1.5, color(hue, 0.14)), - )); - painter.add(egui::Shape::line( - points, - egui::Stroke::new(if index == 1 { 1.8 } else { 1.2 }, color(hue, 0.72)), - )); - } - } - SiriMode::Orb => { - let gather = glow.gather.clamp(0.0, 1.0); - for index in 0..7 { - let angle = glow.time * 0.8 + index as f32 * std::f32::consts::TAU / 7.0; - let radius = rect.width().min(rect.height()) * (0.06 + (1.0 - gather) * 0.24); - let point = - rect.center() + egui::vec2(angle.cos() * radius, angle.sin() * radius * 0.42); - let hue = [[0.35, 0.78, 1.0], [0.63, 0.52, 1.0], [1.0, 0.49, 0.82]][index % 3]; - let dot_radius = 2.2 + (0.5 + (glow.time * 2.0 + index as f32).sin() * 0.5) * 1.8; - painter.circle_filled(point, dot_radius * 2.6, color(hue, 0.075)); - painter.circle_filled(point, dot_radius * 1.55, color(hue, 0.24)); - painter.circle_filled(point, dot_radius, color(hue, 0.92)); - } - } - SiriMode::Ring => { - let color = color(glow.tint, 0.9); - painter.rect_stroke( - rect, - egui::CornerRadius::same(glow.radius.round().clamp(0.0, 255.0) as u8), - egui::Stroke::new(glow.thickness.max(1.0), color), - egui::StrokeKind::Inside, - ); - } - } - true -} - -#[cfg(test)] -mod tests { - use super::*; - - /// 着色器路径已从渲染路径上摘除(见本文件第 7 行的 `allow(dead_code)`), - /// `paint` 现在直接用 CPU 形状画出每一种光效,并恒返回 `true`(调用方不得 - /// 再画自己的回退)。等 wgpu 光效落地后,这里要恢复成「GPU 回调 + 所有权」 - /// 的断言。 - #[test] - fn paint_draws_every_mode_on_the_cpu_and_claims_ownership() { - let ctx = egui::Context::default(); - let mut modes = 0; - let output = crate::ui::frontend::run_pass( - &ctx, - egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size( - egui::Pos2::ZERO, - egui::vec2(320.0, 240.0), - )), - ..Default::default() - }, - |ui| { - for glow in [ - SiriGlow::wave(0.5, 0.3), - SiriGlow::orb(0.5, 1.0), - SiriGlow::ring(0.5, 12.0, 2.0), - ] { - assert!( - paint(ui, ui.max_rect(), glow), - "{:?} must paint its CPU fallback and claim the centre", - glow.mode - ); - modes += 1; - } - }, - ); - assert_eq!(modes, 3, "every mode must be exercised"); - assert!( - !output.shapes.is_empty(), - "the glow must still reach the frame as CPU shapes" - ); - let callbacks = output - .shapes - .iter() - .filter(|clipped| matches!(clipped.shape, egui::Shape::Callback(_))) - .count(); - assert_eq!( - callbacks, 0, - "the shader path is off: nothing may queue a GPU callback" - ); - } - - #[test] - fn clock_smooths_level_time_and_speed() { - let ctx = egui::Context::default(); - let start = tick(&ctx, "test", SiriDrive::default(), 1.0 / 60.0); - assert!( - (start.time - 1.0 / 60.0).abs() < 1e-5, - "one frame of dt*1.0 speed: {}", - start.time - ); - assert_eq!(start.level, 0.0, "level starts from the stored clock"); - let mut clock = start; - for _ in 0..30 { - clock = tick( - &ctx, - "test", - SiriDrive { - level: 0.5, - ..Default::default() - }, - 1.0 / 60.0, - ); - } - assert!( - clock.time > 0.4 && clock.time < 0.6, - "0.5s of frames: {}", - clock.time - ); - assert!(clock.level > 0.0, "level follows the drive"); - assert!(clock.level <= visual_voice(0.5) + f32::EPSILON); - // A speed change is eased into the accumulated time (no jump). - let before = clock.time; - let after = tick( - &ctx, - "test", - SiriDrive { - speed: 3.0, - ..Default::default() - }, - 1.0 / 60.0, - ); - assert!( - after.time - before < 0.06, - "speed eased, not applied at once" - ); - } - - #[test] - fn visual_voice_gates_and_eases() { - assert_eq!(visual_voice(0.0), 0.0); - assert_eq!(visual_voice(0.012), 0.0, "noise gate"); - assert_eq!(visual_voice(0.34), 1.0, "ceiling maps to a full bar"); - let mid = visual_voice(0.18); - assert!( - mid > 0.4 && mid < 1.0, - "curve stays inside the unit range: {mid}" - ); - } -} diff --git a/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs new file mode 100644 index 000000000..70dd88a76 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs @@ -0,0 +1,553 @@ +//! Vulkan/WGPU Siri visuals. +//! +//! Siri is rendered as an egui WGPU paint callback. There are deliberately no +//! legacy graphics paths: the normal eframe renderer and the native +//! Wayland layer-shell renderer both use the same WGSL pipeline. + +use std::borrow::Cow; + +use eframe::egui; +use eframe::egui_wgpu; + +/// Which WGSL effect drives the capsule centre. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SiriMode { + /// Spectral voice wave while recording. + Wave, + /// Six fluid points while thinking. + Orb, + /// Rounded-rectangle perimeter used by the composer ring. + Ring, +} + +#[derive(Clone, Copy, Debug)] +pub struct SiriEffect { + pub mode: SiriMode, + pub time: f32, + pub level: f32, + pub resolved: f32, + pub gather: f32, + pub radius: f32, + pub thickness: f32, + pub tint: [f32; 3], +} + +impl SiriEffect { + pub fn wave(time: f32, level: f32) -> Self { + Self { + mode: SiriMode::Wave, + time, + level, + resolved: 1.0, + gather: 0.0, + radius: 0.0, + thickness: 2.0, + tint: [1.0; 3], + } + } + + pub fn orb(time: f32, gather: f32) -> Self { + Self { + mode: SiriMode::Orb, + time, + level: 0.0, + resolved: 0.0, + gather, + radius: 0.0, + thickness: 2.0, + tint: [1.0; 3], + } + } + + pub fn ring(time: f32, radius: f32, thickness: f32) -> Self { + Self { + mode: SiriMode::Ring, + time, + level: 0.0, + resolved: 1.0, + gather: 0.0, + radius, + thickness, + tint: [1.0; 3], + } + } + + pub fn with_tint(mut self, tint: [f32; 3]) -> Self { + self.tint = tint; + self + } +} + +/// Audio drive for the persistent egui animation clock. +#[derive(Clone, Copy, Debug)] +pub struct SiriDrive { + pub level: f32, + pub resolved: f32, + pub speed: f32, + pub warming: bool, +} + +impl Default for SiriDrive { + fn default() -> Self { + Self { + level: 0.0, + resolved: 1.0, + speed: 1.0, + warming: false, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct SiriClock { + pub time: f32, + pub level: f32, + pub resolved: f32, + pub speed: f32, +} + +pub fn visual_voice(raw: f32) -> f32 { + const GATE: f32 = 0.012; + const CEILING: f32 = 0.34; + let gated = ((raw - GATE) / (CEILING - GATE)).clamp(0.0, 1.0); + let eased = gated * gated * (3.0 - 2.0 * gated); + eased.max(0.0).powf(0.42) +} + +pub fn tick(ctx: &egui::Context, id: &str, drive: SiriDrive, dt: f32) -> SiriClock { + let key = egui::Id::new(("openless-siri-clock", id)); + let dt = dt.clamp(0.0, 0.05); + let mut clock = ctx.data_mut(|data| { + data.get_temp::(key).unwrap_or(SiriClock { + time: 0.0, + level: 0.0, + resolved: drive.resolved, + speed: drive.speed, + }) + }); + clock.speed += (drive.speed - clock.speed) * (1.0 - (-dt * 2.5).exp()); + clock.time += dt * clock.speed; + let target = if drive.warming { + 0.12 + 0.06 * (clock.time * 3.0).sin() + } else if drive.resolved < 0.5 { + 0.14 + 0.07 * (clock.time * 2.2).sin() + } else { + visual_voice(drive.level) + }; + let attack = if target > clock.level { 14.0 } else { 5.0 }; + clock.level += (target - clock.level) * (1.0 - (-dt * attack).exp()); + clock.resolved += (drive.resolved - clock.resolved) * (1.0 - (-dt * 3.0).exp()); + ctx.data_mut(|data| data.insert_temp(key, clock)); + clock +} + +const SIRI_WGSL: &str = r#" +struct Uniforms { + time: f32, + level: f32, + resolved: f32, + gather: f32, + mode: f32, + radius: f32, + thickness: f32, + _pad: f32, + tint: vec4, + size: vec2, + _pad2: vec2, +}; +@group(0) @binding(0) var u: Uniforms; + +struct VertexOut { @builtin(position) position: vec4 }; + +@vertex +fn vs_main(@builtin(vertex_index) index: u32) -> VertexOut { + var points = array, 3>( + vec2(-1.0, -1.0), + vec2( 3.0, -1.0), + vec2(-1.0, 3.0) + ); + var out: VertexOut; + out.position = vec4(points[index], 0.0, 1.0); + return out; +} + +fn spectral4(index: i32) -> vec3 { + let x = f32(index); + return clamp(vec3(abs(x - 3.0) - 1.0, 2.0 - abs(x - 2.0), 2.0 - abs(x - 4.0)), vec3(0.0), vec3(1.0)); +} + +fn hue2rgb(h0: f32) -> vec3 { + let h = h0 - floor(h0); + return vec3( + clamp(abs(h * 6.0 - 3.0) - 1.0, 0.0, 1.0), + clamp(2.0 - abs(h * 6.0 - 2.0), 0.0, 1.0), + clamp(2.0 - abs(h * 6.0 - 4.0), 0.0, 1.0) + ); +} + +fn wave(frag: vec2) -> vec4 { + let pi = 3.14159265359; + let aspect = u.size.x / max(u.size.y, 1.0); + var p = (frag / u.size) * 2.0 - vec2(1.0); + let y_screen = p.y; + p.y = -p.y; + p.x *= aspect; + p /= 0.6; + let res = clamp(u.resolved, 0.0, 1.0); + let level = clamp(u.level, 0.0, 1.0); + let low = clamp(0.45 + 0.45 * sin(u.time * 0.8) * sin(u.time * 0.37 + 1.0), 0.0, 1.0) * level; + let mid = clamp(0.40 + 0.40 * sin(u.time * 1.7 + 2.0) * sin(u.time * 0.53), 0.0, 1.0) * level; + let high = clamp(0.30 + 0.30 * sin(u.time * 2.9 + 4.0) * sin(u.time * 0.71 + 2.0), 0.0, 1.0) * level; + var pw = p; + pw.x *= mix(5.0, 1.0, res); + let x_norm = pw.x / max(aspect, 1.0); + let envelope = pow(max(cos(pi * 0.5 * min(abs(0.9 * x_norm), 1.0)), 0.0), 2.0); + let a1 = 0.32 * mix(0.14, 1.0, level) + 0.01 * low * 6.0; + let a2 = a1 + mid * 0.05 + high * 0.06; + let drift = u.time * 2.4; + let aberr = (2.6 + mid * 0.8 + high * 0.5) * res; + let soft = 0.01 * res * max(0.0, 2.5 + mid * 0.4); + let unres = max(length(p) - mix(0.14, 0.14, res), 0.0); + let y_main = a1 * envelope * res * sin(pw.x * 1.1 + drift); + var col = vec3(0.0); + for (var s: i32 = 0; s < 4; s = s + 1) { + let hue = mix(vec3(1.0), spectral4(s), res); + let ab = mix(-aberr, aberr, f32(s) / 3.0); + let y_line = a2 * envelope * res * sin(pw.x + drift + ab); + let distance = mix(unres, abs(p.y - y_line), res); + let line = (0.01 * (2.0 + low * 1.5)) / (sqrt(distance * distance + soft * soft) + mix(0.1, 0.03, res)); + col += hue * line; + } + let main_distance = mix(unres, abs(p.y - y_main), res); + let halo = 0.5 * (0.01 * (2.0 + low * 1.5)) / (sqrt(main_distance * main_distance + soft * soft) + 0.03); + col += vec3(halo * (1.0 + (1.0 - res) * (3.0 * low + 1.2))); + let edge_t = clamp((abs(y_screen) - 1.0 + 0.4) / -0.4, 0.0, 1.0); + let edge = edge_t * edge_t * (3.0 - 2.0 * edge_t); + let gaussian = exp(-pow(x_norm * 1.7, 2.0)); + col *= edge * gaussian * mix(0.55, 1.0, res); + col = pow(max(col, vec3(0.0)), vec3(1.5)); + col = clamp(col, vec3(0.0), vec3(1.0)); + let alpha = clamp(max(col.r, max(col.g, col.b)), 0.0, 1.0); + return vec4(col, alpha); +} + +fn orb(frag: vec2) -> vec4 { + let aspect = u.size.x / max(u.size.y, 1.0); + var p = (frag / u.size) * 2.0 - vec2(1.0); + p.y = -p.y; + p.x *= aspect; + let gather = clamp(u.gather, 0.0, 1.0); + let radius = mix(0.17, 0.008, gather); + var col = vec3(0.0); + for (var i: i32 = 0; i < 6; i = i + 1) { + let fi = f32(i); + let angle = fi / 6.0 * 6.28318530718 + u.time * 0.35; + let pos = vec2(cos(angle), sin(angle)) * radius; + let dot_radius = mix(0.036, 0.030, gather) + 0.006 * sin(u.time * 1.3 + fi); + let distance = max(length(p - pos) - dot_radius, 0.0); + let bloom = exp(-pow(distance * 18.0, 1.35)); + let hue = hue2rgb(fi / 6.0 + u.time * 0.06); + col += bloom * mix(vec3(1.0), hue, 0.22) * (1.0 + 0.30 * gather); + } + col += exp(-length(p) * 15.0) * gather * vec3(0.7, 0.8, 1.0); + col = clamp(col, vec3(0.0), vec3(1.0)); + let alpha = clamp(max(col.r, max(col.g, col.b)), 0.0, 1.0); + return vec4(col, alpha); +} + +fn round_box(p: vec2, half_size: vec2, radius: f32) -> f32 { + let q = abs(p) - half_size + vec2(radius); + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - radius; +} + +fn ring(frag: vec2) -> vec4 { + let p = frag - u.size * 0.5; + let radius = clamp(u.radius, 0.0, min(u.size.x, u.size.y) * 0.5); + let distance = round_box(p, u.size * 0.5 - vec2(radius), radius); + let edge = 1.0 - smoothstep(0.0, max(u.thickness, 1.0), abs(distance)); + let alpha = edge * 0.9; + return vec4(u.tint.rgb * alpha, alpha); +} + +@fragment +fn fs_main(@builtin(position) position: vec4) -> @location(0) vec4 { + if u.mode < 0.5 { return wave(position.xy); } + if u.mode < 1.5 { return orb(position.xy); } + return ring(position.xy); +} +"#; + +#[derive(Clone, Copy)] +struct SiriUniforms { + effect: SiriEffect, + size: [f32; 2], +} + +struct SiriGpu { + pipeline: egui_wgpu::wgpu::RenderPipeline, + bind_group: egui_wgpu::wgpu::BindGroup, + uniform: egui_wgpu::wgpu::Buffer, +} + +fn uniform_bytes(uniforms: SiriUniforms) -> [u8; 56] { + let mode = match uniforms.effect.mode { + SiriMode::Wave => 0.0, + SiriMode::Orb => 1.0, + SiriMode::Ring => 2.0, + }; + let values = [ + uniforms.effect.time, + uniforms.effect.level, + uniforms.effect.resolved, + uniforms.effect.gather, + mode, + uniforms.effect.radius, + uniforms.effect.thickness, + 0.0, + uniforms.effect.tint[0], + uniforms.effect.tint[1], + uniforms.effect.tint[2], + 1.0, + uniforms.size[0], + uniforms.size[1], + ]; + let mut bytes = [0_u8; 56]; + for (index, value) in values.into_iter().enumerate() { + bytes[index * 4..index * 4 + 4].copy_from_slice(&value.to_ne_bytes()); + } + bytes +} + +fn create_gpu( + device: &egui_wgpu::wgpu::Device, + format: egui_wgpu::wgpu::TextureFormat, + msaa_samples: u32, +) -> SiriGpu { + use egui_wgpu::wgpu; + let uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("openless-siri-uniform"), + size: 56, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("openless-siri-bind-layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("openless-siri-bind-group"), + layout: &bind_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform.as_entire_binding(), + }], + }); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("openless-siri-wgsl"), + source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(SIRI_WGSL)), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("openless-siri-pipeline-layout"), + bind_group_layouts: &[Some(&bind_layout)], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("openless-siri-wgpu"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: msaa_samples.max(1), + ..Default::default() + }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }); + SiriGpu { + pipeline, + bind_group, + uniform, + } +} + +/// Install the Siri pipeline into an eframe renderer before the first frame. +pub fn install_wgpu(state: &eframe::egui_wgpu::RenderState, msaa_samples: u32) { + let mut renderer = state.renderer.write(); + install_renderer( + &mut renderer, + &state.device, + state.target_format, + msaa_samples, + ); +} + +/// Same installation hook for the standalone Wayland layer-shell renderer. +pub fn install_renderer( + renderer: &mut egui_wgpu::Renderer, + device: &egui_wgpu::wgpu::Device, + format: egui_wgpu::wgpu::TextureFormat, + msaa_samples: u32, +) { + if renderer.callback_resources.get::().is_none() { + renderer + .callback_resources + .insert(create_gpu(device, format, msaa_samples)); + } +} + +struct SiriCallback { + uniforms: SiriUniforms, +} + +impl egui_wgpu::CallbackTrait for SiriCallback { + fn prepare( + &self, + _device: &egui_wgpu::wgpu::Device, + queue: &egui_wgpu::wgpu::Queue, + _screen: &egui_wgpu::ScreenDescriptor, + _encoder: &mut egui_wgpu::wgpu::CommandEncoder, + resources: &mut egui_wgpu::CallbackResources, + ) -> Vec { + if let Some(gpu) = resources.get_mut::() { + queue.write_buffer(&gpu.uniform, 0, &uniform_bytes(self.uniforms)); + } + Vec::new() + } + + fn paint( + &self, + _info: egui::PaintCallbackInfo, + render_pass: &mut egui_wgpu::wgpu::RenderPass<'static>, + resources: &egui_wgpu::CallbackResources, + ) { + let Some(gpu) = resources.get::() else { + return; + }; + render_pass.set_pipeline(&gpu.pipeline); + render_pass.set_bind_group(0, &gpu.bind_group, &[]); + render_pass.draw(0..3, 0..1); + } +} + +/// Queue one Vulkan/WGPU paint callback. Returning `true` means the caller must +/// not draw an egui fallback over it. +pub fn paint(ui: &egui::Ui, rect: egui::Rect, effect: SiriEffect) -> bool { + if !rect.is_positive() || !rect.is_finite() { + return false; + } + let scale = ui.ctx().pixels_per_point(); + let callback = egui_wgpu::Callback::new_paint_callback( + rect, + SiriCallback { + uniforms: SiriUniforms { + effect, + size: [rect.width() * scale, rect.height() * scale], + }, + }, + ); + ui.painter().add(egui::Shape::Callback(callback)); + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paint_queues_wgpu_callbacks_for_every_mode() { + let ctx = egui::Context::default(); + let mut output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(320.0, 240.0), + )), + ..Default::default() + }, + |ui| { + for effect in [ + SiriEffect::wave(0.5, 0.3), + SiriEffect::orb(0.5, 1.0), + SiriEffect::ring(0.5, 12.0, 2.0), + ] { + assert!(paint(ui, ui.max_rect(), effect)); + } + }, + ); + let callbacks = output + .shapes + .iter() + .filter(|clipped| matches!(clipped.shape, egui::Shape::Callback(_))) + .count(); + assert_eq!(callbacks, 3); + output.textures_delta.clear(); + } + + #[test] + fn clock_smooths_level_time_and_speed() { + let ctx = egui::Context::default(); + let start = tick(&ctx, "test", SiriDrive::default(), 1.0 / 60.0); + assert!((start.time - 1.0 / 60.0).abs() < 1e-5); + assert_eq!(start.level, 0.0); + let mut clock = start; + for _ in 0..30 { + clock = tick( + &ctx, + "test", + SiriDrive { + level: 0.5, + ..Default::default() + }, + 1.0 / 60.0, + ); + } + assert!(clock.time > 0.4 && clock.time < 0.6); + assert!(clock.level > 0.0); + assert!(clock.level <= visual_voice(0.5) + f32::EPSILON); + let before = clock.time; + let after = tick( + &ctx, + "test", + SiriDrive { + speed: 3.0, + ..Default::default() + }, + 1.0 / 60.0, + ); + assert!(after.time - before < 0.06); + } + + #[test] + fn visual_voice_gates_and_eases() { + assert_eq!(visual_voice(0.0), 0.0); + assert_eq!(visual_voice(0.012), 0.0); + assert_eq!(visual_voice(0.34), 1.0); + let mid = visual_voice(0.18); + assert!(mid > 0.4 && mid < 1.0); + } +} diff --git a/openless-all/app/linux-egui/src/ui/theme.rs b/openless-all/app/linux-egui/src/ui/theme.rs index f8813b059..4aab0fc39 100644 --- a/openless-all/app/linux-egui/src/ui/theme.rs +++ b/openless-all/app/linux-egui/src/ui/theme.rs @@ -250,7 +250,7 @@ pub fn install(ctx: &egui::Context) { } /// Feather shape edges in physical pixels on both the WGPU windows (which also -/// use 4x MSAA) and the single-sample EGL layer surface. +/// use 4x MSAA) and the single-sample Vulkan layer surface. pub fn enable_antialiasing(ctx: &egui::Context) { ctx.tessellation_options_mut(|options| { options.feathering = true; diff --git a/openless-all/app/scripts/package-linux-egui.sh b/openless-all/app/scripts/package-linux-egui.sh index 78cb2842a..d26e8dbfe 100644 --- a/openless-all/app/scripts/package-linux-egui.sh +++ b/openless-all/app/scripts/package-linux-egui.sh @@ -114,7 +114,7 @@ Priority: optional Architecture: amd64 Maintainer: OpenLess Contributors Description: OpenLess Linux egui host -Depends: fcitx5, fcitx5-module-dbus, libasound2, libbz2-1.0, libc6, libdbus-1-3, libegl1, libfcitx5config6, libfcitx5core7, libfcitx5utils2, libffi8, libgcc-s1, liblzma5, libpipewire-0.3-0, libpulse0, libstdc++6, libsystemd0, libuuid1, libvulkan1, libwayland-client0, libwayland-egl1, libx11-6, libx11-xcb1, libxcb1, libxcursor1, libxi6, libxkbcommon0, libxkbcommon-x11-0 +Depends: fcitx5, fcitx5-module-dbus, libasound2, libbz2-1.0, libc6, libdbus-1-3, libfcitx5config6, libfcitx5core7, libfcitx5utils2, libffi8, libgcc-s1, liblzma5, libpipewire-0.3-0, libpulse0, libstdc++6, libsystemd0, libuuid1, libvulkan1, libwayland-client0, libx11-6, libx11-xcb1, libxcb1, libxcursor1, libxi6, libxkbcommon0, libxkbcommon-x11-0 Recommends: mesa-vulkan-drivers Homepage: https://github.com/Open-Less/openless EOF @@ -154,7 +154,7 @@ License: AGPL-3.0-only URL: https://github.com/Open-Less/openless BuildArch: x86_64 Source0: openless-$RPM_VERSION.tar.gz -Requires: fcitx5, dbus-libs, alsa-lib, pipewire-libs, pulseaudio-libs, libX11, libxcb, libwayland-client, libxkbcommon, libglvnd-egl, vulkan-loader, libXi.so.6()(64bit), libXcursor.so.1()(64bit), libX11-xcb.so.1()(64bit), libxkbcommon-x11.so.0()(64bit), libwayland-egl.so.1()(64bit) +Requires: fcitx5, dbus-libs, alsa-lib, pipewire-libs, pulseaudio-libs, libX11, libxcb, libwayland-client, libxkbcommon, vulkan-loader, libXi.so.6()(64bit), libXcursor.so.1()(64bit), libX11-xcb.so.1()(64bit), libxkbcommon-x11.so.0()(64bit) Recommends: mesa-vulkan-drivers %description OpenLess Linux egui host. From 2a5f5296046ea88f1086c6d7382c705d5c6975d4 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 21:04:17 +0800 Subject: [PATCH 16/35] fix(linux-egui): remove standalone startup error window --- openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/i18n.rs | 10 ---- .../app/linux-egui/src/linux_app/window.rs | 57 ------------------- openless-all/app/linux-egui/src/main.rs | 11 +++- 4 files changed, 10 insertions(+), 70 deletions(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 58c9bdf9d..194b81caa 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -111 +112 diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 89f7f53fc..1267467a0 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -1119,16 +1119,6 @@ pub const CATALOG: &[Msg] = &[ "시작 실패", ), }, - Msg { - key: "startup.fcitx_help", - text: row( - "请安装并启动 fcitx5,启用 OpenLess 插件,并确认当前桌面会话的 D-Bus 可用。必要时重新安装 OpenLess 软件包。修复后关闭此窗口并重新启动应用。", - "請安裝並啟動 fcitx5,啟用 OpenLess 插件,並確認目前桌面工作階段的 D-Bus 可用。必要時重新安裝 OpenLess 套件。修復後關閉此視窗並重新啟動應用程式。", - "Install and start fcitx5, enable the OpenLess addon, and check the current desktop session's D-Bus. Reinstall the OpenLess package if needed. Close this window and restart the application after fixing the environment.", - "fcitx5 を起動し、OpenLess アドオンとデスクトップセッションの D-Bus を有効にしてください。必要なら OpenLess を再インストールしてください。修正後、このウィンドウを閉じてアプリを再起動してください。", - "fcitx5를 설치하고 시작한 후 OpenLess 애드온과 현재 데스크톱 세션의 D-Bus를 활성화하세요. 필요하면 OpenLess를 다시 설치하세요. 환경을 수정한 후 이 창을 닫고 앱을 다시 시작하세요。", - ), - }, Msg { key: "startup.input_method_unavailable_title", text: row( diff --git a/openless-all/app/linux-egui/src/linux_app/window.rs b/openless-all/app/linux-egui/src/linux_app/window.rs index e3668a3af..04b194d60 100644 --- a/openless-all/app/linux-egui/src/linux_app/window.rs +++ b/openless-all/app/linux-egui/src/linux_app/window.rs @@ -757,63 +757,6 @@ impl eframe::App for UiClientApp { } } -struct StartupErrorApp { - error: String, - lang: Lang, - broker: Arc, -} - -impl eframe::App for StartupErrorApp { - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - let mut focus = false; - self.broker.drain(|_| focus = true); - if focus { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Focus); - } - ui.heading(tr_l10n(self.lang, "status.startup_failed")); - ui.add_space(12.0); - ui.label(&self.error); - ui.add_space(12.0); - ui.label(tr_l10n(self.lang, "startup.fcitx_help")); - if ui.button(tr_l10n(self.lang, "common.close")).clicked() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); - } - ui.ctx().request_repaint_after(Duration::from_millis(100)); - } -} - -pub(super) fn show_startup_error(error: &str, broker: Arc) { - eprintln!("OpenLess startup failed: {error}"); - if ["DISPLAY", "WAYLAND_DISPLAY"] - .iter() - .all(|name| std::env::var(name).unwrap_or_default().is_empty()) - { - return; - } - let lang = load_locale_pref().resolve(); - let error = error.to_string(); - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title(tr_l10n(lang, "status.startup_failed")) - .with_inner_size([560.0, 300.0]), - ..Default::default() - }; - if let Err(failure) = eframe::run_native( - "OpenLess", - options, - Box::new(move |cc| { - theme::install(&cc.egui_ctx); - Ok(Box::new(StartupErrorApp { - error, - lang, - broker, - })) - }), - ) { - eprintln!("OpenLess startup error window failed: {failure}"); - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 7f7b861bb..7f4bd15c1 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -7868,7 +7868,11 @@ Internal flags (set by OpenLess itself, not for regular use): Ok(native) => native, Err(error) => { drop(tray); - window::show_startup_error(&error, Arc::clone(&broker)); + // Fatal startup errors are reported to stderr/journal only. Do + // not create a second egui window: recoverable integration + // failures (such as fcitx5) are rendered by the main UI's + // runtime-warning overlay instead. + log::error!("OpenLess startup failed: {error}"); return Err(error); } }; @@ -7880,7 +7884,10 @@ Internal flags (set by OpenLess itself, not for regular use): start_minimized, runtime_warning, ) { - window::show_startup_error(&error, Arc::clone(&broker)); + // The UI process owns all user-facing recoverable warnings. A + // fatal host/UI failure must not fall back to a standalone popup; + // that popup was a second, misleading application window. + log::error!("OpenLess host failed: {error}"); return Err(error); } Ok(()) From 4932d885c22621f1163890b66cd709755954e856 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 21:11:06 +0800 Subject: [PATCH 17/35] fix(linux-egui): pad Siri WGPU uniform buffer --- openless-all/app/linux-egui/package-revision | 2 +- .../linux-egui/src/ui/frontend/siri_wgpu.rs | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 194b81caa..9b252fd09 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -112 +113 diff --git a/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs index 70dd88a76..bb501355d 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs @@ -289,7 +289,7 @@ struct SiriGpu { uniform: egui_wgpu::wgpu::Buffer, } -fn uniform_bytes(uniforms: SiriUniforms) -> [u8; 56] { +fn uniform_bytes(uniforms: SiriUniforms) -> [u8; 64] { let mode = match uniforms.effect.mode { SiriMode::Wave => 0.0, SiriMode::Orb => 1.0, @@ -311,7 +311,10 @@ fn uniform_bytes(uniforms: SiriUniforms) -> [u8; 56] { uniforms.size[0], uniforms.size[1], ]; - let mut bytes = [0_u8; 56]; + // WGSL's uniform layout rounds the trailing `vec2` padding to 16-byte + // alignment: 14 values (56 bytes) are followed by `_pad2: vec2`. + // wgpu validates the complete binding size when this callback is drawn. + let mut bytes = [0_u8; 64]; for (index, value) in values.into_iter().enumerate() { bytes[index * 4..index * 4 + 4].copy_from_slice(&value.to_ne_bytes()); } @@ -326,7 +329,7 @@ fn create_gpu( use egui_wgpu::wgpu; let uniform = device.create_buffer(&wgpu::BufferDescriptor { label: Some("openless-siri-uniform"), - size: 56, + size: 64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -542,6 +545,15 @@ mod tests { assert!(after.time - before < 0.06); } + #[test] + fn uniform_buffer_matches_wgsl_alignment() { + let bytes = uniform_bytes(SiriUniforms { + effect: SiriEffect::default(), + size: [320.0, 80.0], + }); + assert_eq!(bytes.len(), 64); + } + #[test] fn visual_voice_gates_and_eases() { assert_eq!(visual_voice(0.0), 0.0); From e2f3951cd38ff7da8955e1490820a9212acfa6f1 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 21:32:29 +0800 Subject: [PATCH 18/35] fix(linux-egui): restore transparent Siri rendering and previews --- openless-all/app/linux-egui/package-revision | 2 +- .../app/linux-egui/src/popup_layer.rs | 50 +++- .../linux-egui/src/ui/frontend/siri_wgpu.rs | 81 +++--- .../src/ui/frontend/siri_wgpu_gpu_tests.rs | 243 ++++++++++++++++++ 4 files changed, 341 insertions(+), 35 deletions(-) create mode 100644 openless-all/app/linux-egui/src/ui/frontend/siri_wgpu_gpu_tests.rs diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 9b252fd09..dee79f109 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -113 +114 diff --git a/openless-all/app/linux-egui/src/popup_layer.rs b/openless-all/app/linux-egui/src/popup_layer.rs index e190e5a3b..d320e1684 100644 --- a/openless-all/app/linux-egui/src/popup_layer.rs +++ b/openless-all/app/linux-egui/src/popup_layer.rs @@ -468,6 +468,19 @@ impl Dispatch for LayerState { // ── Vulkan/WGPU plumbing ──────────────────────────────────────────────────── +fn capsule_alpha_mode( + supported: &[eframe::egui_wgpu::wgpu::CompositeAlphaMode], +) -> Result { + use eframe::egui_wgpu::wgpu::CompositeAlphaMode; + if supported.contains(&CompositeAlphaMode::PreMultiplied) { + Ok(CompositeAlphaMode::PreMultiplied) + } else { + Err(format!( + "Vulkan capsule surface lacks premultiplied transparency: {supported:?}" + )) + } +} + struct WgpuSurface { _instance: eframe::egui_wgpu::wgpu::Instance, surface: eframe::egui_wgpu::wgpu::Surface<'static>, @@ -531,9 +544,9 @@ impl WgpuSurface { let mut config = surface .get_default_config(&adapter, size.0.max(1), size.1.max(1)) .ok_or("Wayland surface has no Vulkan format")?; - // The capsule is transparent. Auto lets the compositor select the - // premultiplied alpha mode supported by the Vulkan surface. - config.alpha_mode = wgpu::CompositeAlphaMode::Auto; + // Auto may select Opaque: clearing to transparent would then present + // a black rectangle. Both egui and Siri output premultiplied alpha. + config.alpha_mode = capsule_alpha_mode(&surface.get_capabilities(&adapter).alpha_modes)?; surface.configure(&device, &config); let format = config.format; let mut renderer = eframe::egui_wgpu::Renderer::new( @@ -569,11 +582,22 @@ impl WgpuSurface { self.config.height = height; self.surface.configure(&self.device, &self.config); } + // Texture deltas are incremental (not re-sent next frame). Upload them + // even if surface acquisition times out, so fonts/icons aren't lost. + for (id, deltas) in &textures_delta.set { + for delta in deltas { + self.renderer + .update_texture(&self.device, &self.queue, *id, delta); + } + } let output = match self.surface.get_current_texture() { eframe::egui_wgpu::wgpu::CurrentSurfaceTexture::Success(texture) | eframe::egui_wgpu::wgpu::CurrentSurfaceTexture::Suboptimal(texture) => texture, eframe::egui_wgpu::wgpu::CurrentSurfaceTexture::Timeout | eframe::egui_wgpu::wgpu::CurrentSurfaceTexture::Occluded => { + for id in &textures_delta.free { + self.renderer.free_texture(id); + } textures_delta.clear(); return Ok(()); } @@ -627,6 +651,9 @@ impl WgpuSurface { self.queue.submit(std::iter::once(encoder.finish())); // In wgpu 30 presentation belongs to the queue, not SurfaceTexture. self.queue.present(output); + for id in &textures_delta.free { + self.renderer.free_texture(id); + } textures_delta.clear(); Ok(()) } @@ -902,6 +929,23 @@ mod tests { } } + #[test] + fn capsule_requires_explicit_premultiplied_transparency() { + use eframe::egui_wgpu::wgpu::CompositeAlphaMode as Alpha; + assert_eq!( + capsule_alpha_mode(&[Alpha::Opaque, Alpha::PreMultiplied]).unwrap(), + Alpha::PreMultiplied, + ); + for modes in [ + vec![], + vec![Alpha::Opaque], + vec![Alpha::Auto], + vec![Alpha::PostMultiplied], + ] { + assert!(capsule_alpha_mode(&modes).is_err()); + } + } + #[test] fn capsule_geometry_clamps_to_a_paintable_surface() { let geometry = capsule_geometry(200, 100, 12); diff --git a/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs index bb501355d..c4892867e 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs @@ -5,6 +5,7 @@ //! Wayland layer-shell renderer both use the same WGSL pipeline. use std::borrow::Cow; +use std::sync::OnceLock; use eframe::egui; use eframe::egui_wgpu; @@ -157,7 +158,10 @@ struct Uniforms { }; @group(0) @binding(0) var u: Uniforms; -struct VertexOut { @builtin(position) position: vec4 }; +struct VertexOut { + @builtin(position) position: vec4, + @location(0) uv: vec2, +}; @vertex fn vs_main(@builtin(vertex_index) index: u32) -> VertexOut { @@ -167,7 +171,11 @@ fn vs_main(@builtin(vertex_index) index: u32) -> VertexOut { vec2(-1.0, 3.0) ); var out: VertexOut; - out.position = vec4(points[index], 0.0, 1.0); + let point = points[index]; + out.position = vec4(point, 0.0, 1.0); + // Interpolated local coordinates, independent of the callback's viewport + // origin, DPI, clipping and offscreen backdrop scale. + out.uv = vec2((point.x + 1.0) * 0.5, (1.0 - point.y) * 0.5); return out; } @@ -270,10 +278,11 @@ fn ring(frag: vec2) -> vec4 { } @fragment -fn fs_main(@builtin(position) position: vec4) -> @location(0) vec4 { - if u.mode < 0.5 { return wave(position.xy); } - if u.mode < 1.5 { return orb(position.xy); } - return ring(position.xy); +fn fs_main(in: VertexOut) -> @location(0) vec4 { + let local = in.uv * u.size; + if u.mode < 0.5 { return wave(local); } + if u.mode < 1.5 { return orb(local); } + return ring(local); } "#; @@ -285,8 +294,7 @@ struct SiriUniforms { struct SiriGpu { pipeline: egui_wgpu::wgpu::RenderPipeline, - bind_group: egui_wgpu::wgpu::BindGroup, - uniform: egui_wgpu::wgpu::Buffer, + bind_layout: egui_wgpu::wgpu::BindGroupLayout, } fn uniform_bytes(uniforms: SiriUniforms) -> [u8; 64] { @@ -327,12 +335,6 @@ fn create_gpu( msaa_samples: u32, ) -> SiriGpu { use egui_wgpu::wgpu; - let uniform = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("openless-siri-uniform"), - size: 64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("openless-siri-bind-layout"), entries: &[wgpu::BindGroupLayoutEntry { @@ -341,19 +343,11 @@ fn create_gpu( ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, - min_binding_size: None, + min_binding_size: wgpu::BufferSize::new(64), }, count: None, }], }); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("openless-siri-bind-group"), - layout: &bind_layout, - entries: &[wgpu::BindGroupEntry { - binding: 0, - resource: uniform.as_entire_binding(), - }], - }); let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("openless-siri-wgsl"), source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(SIRI_WGSL)), @@ -393,8 +387,7 @@ fn create_gpu( }); SiriGpu { pipeline, - bind_group, - uniform, + bind_layout, } } @@ -425,19 +418,37 @@ pub fn install_renderer( struct SiriCallback { uniforms: SiriUniforms, + // egui prepares every callback before painting any of them. A shared + // queue-written uniform would make every draw use the last effect's data. + binding: OnceLock, } impl egui_wgpu::CallbackTrait for SiriCallback { fn prepare( &self, - _device: &egui_wgpu::wgpu::Device, - queue: &egui_wgpu::wgpu::Queue, + device: &egui_wgpu::wgpu::Device, + _queue: &egui_wgpu::wgpu::Queue, _screen: &egui_wgpu::ScreenDescriptor, _encoder: &mut egui_wgpu::wgpu::CommandEncoder, resources: &mut egui_wgpu::CallbackResources, ) -> Vec { - if let Some(gpu) = resources.get_mut::() { - queue.write_buffer(&gpu.uniform, 0, &uniform_bytes(self.uniforms)); + if let Some(gpu) = resources.get::() { + self.binding.get_or_init(|| { + use egui_wgpu::wgpu::{self, util::DeviceExt as _}; + let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("openless-siri-uniform"), + contents: &uniform_bytes(self.uniforms), + usage: wgpu::BufferUsages::UNIFORM, + }); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("openless-siri-bind-group"), + layout: &gpu.bind_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform.as_entire_binding(), + }], + }) + }); } Vec::new() } @@ -451,8 +462,11 @@ impl egui_wgpu::CallbackTrait for SiriCallback { let Some(gpu) = resources.get::() else { return; }; + let Some(binding) = self.binding.get() else { + return; + }; render_pass.set_pipeline(&gpu.pipeline); - render_pass.set_bind_group(0, &gpu.bind_group, &[]); + render_pass.set_bind_group(0, binding, &[]); render_pass.draw(0..3, 0..1); } } @@ -467,6 +481,7 @@ pub fn paint(ui: &egui::Ui, rect: egui::Rect, effect: SiriEffect) -> bool { let callback = egui_wgpu::Callback::new_paint_callback( rect, SiriCallback { + binding: OnceLock::new(), uniforms: SiriUniforms { effect, size: [rect.width() * scale, rect.height() * scale], @@ -477,6 +492,10 @@ pub fn paint(ui: &egui::Ui, rect: egui::Rect, effect: SiriEffect) -> bool { true } +#[cfg(test)] +#[path = "siri_wgpu_gpu_tests.rs"] +mod gpu_tests; + #[cfg(test)] mod tests { use super::*; @@ -548,7 +567,7 @@ mod tests { #[test] fn uniform_buffer_matches_wgsl_alignment() { let bytes = uniform_bytes(SiriUniforms { - effect: SiriEffect::default(), + effect: SiriEffect::wave(1.35, 0.52), size: [320.0, 80.0], }); assert_eq!(bytes.len(), 64); diff --git a/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu_gpu_tests.rs b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu_gpu_tests.rs new file mode 100644 index 000000000..5a3b289f0 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu_gpu_tests.rs @@ -0,0 +1,243 @@ +//! Real Vulkan draw/readback regression: non-origin viewports, per-callback +//! uniforms, MSAA, DPI, clipping and transparent pixels (not just shape counts). +use super::*; +use egui_wgpu::wgpu; + +const WIDTH: u32 = 512; +const HEIGHT: u32 = 256; +const EFFECT_SIZE: [f32; 2] = [128.0, 96.0]; + +fn draw( + device: &wgpu::Device, + queue: &wgpu::Queue, + samples: u32, + scale: f32, + effects: &[(SiriEffect, [f32; 2])], + clipped: bool, +) -> Vec { + let format = wgpu::TextureFormat::Rgba8Unorm; + let mut renderer = egui_wgpu::Renderer::new( + device, + format, + egui_wgpu::RendererOptions { + msaa_samples: samples, + ..Default::default() + }, + ); + install_renderer(&mut renderer, device, format, samples); + let primitives: Vec<_> = effects + .iter() + .map(|(effect, origin)| { + let rect = egui::Rect::from_min_size( + egui::pos2(origin[0] / scale, origin[1] / scale), + egui::vec2(EFFECT_SIZE[0] / scale, EFFECT_SIZE[1] / scale), + ); + let clip_rect = if clipped { + rect.shrink(16.0 / scale) + } else { + rect + }; + egui::ClippedPrimitive { + clip_rect, + primitive: egui::epaint::Primitive::Callback( + egui_wgpu::Callback::new_paint_callback( + rect, + SiriCallback { + uniforms: SiriUniforms { + effect: *effect, + size: EFFECT_SIZE, + }, + binding: OnceLock::new(), + }, + ), + ), + } + }) + .collect(); + let texture = |sample_count| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("siri-regression-target"), + size: wgpu::Extent3d { + width: WIDTH, + height: HEIGHT, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count, + dimension: wgpu::TextureDimension::D2, + format, + usage: if sample_count == 1 { + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC + } else { + wgpu::TextureUsages::RENDER_ATTACHMENT + }, + view_formats: &[], + }) + }; + let target = texture(1); + let view = target.create_view(&Default::default()); + let msaa = (samples > 1).then(|| texture(samples).create_view(&Default::default())); + let screen = egui_wgpu::ScreenDescriptor { + size_in_pixels: [WIDTH, HEIGHT], + pixels_per_point: scale, + }; + let mut encoder = device.create_command_encoder(&Default::default()); + let callbacks = renderer.update_buffers(device, queue, &mut encoder, &primitives, &screen); + { + let mut pass = encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("siri-regression-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: msaa.as_ref().unwrap_or(&view), + resolve_target: msaa.as_ref().map(|_| &view), + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }) + .forget_lifetime(); + renderer.render(&mut pass, &primitives, &screen); + } + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("siri-regression-readback"), + size: (WIDTH * HEIGHT * 4) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(WIDTH * 4), + rows_per_image: Some(HEIGHT), + }, + }, + wgpu::Extent3d { + width: WIDTH, + height: HEIGHT, + depth_or_array_layers: 1, + }, + ); + queue.submit( + callbacks + .into_iter() + .chain(std::iter::once(encoder.finish())), + ); + let slice = buffer.slice(..); + slice.map_async(wgpu::MapMode::Read, |result| { + result.expect("map Siri pixels") + }); + device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("GPU draw completes"); + let pixels = slice.get_mapped_range().expect("mapped pixels").to_vec(); + buffer.unmap(); + pixels +} + +#[test] +fn vulkan_effects_are_visible_local_independent_and_transparent() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN, + flags: wgpu::InstanceFlags::default(), + memory_budget_thresholds: Default::default(), + backend_options: Default::default(), + display: None, + }); + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + let adapter = match runtime.block_on(instance.request_adapter(&Default::default())) { + Ok(adapter) => adapter, + Err(error) => { + assert!( + std::env::var_os("OPENLESS_REQUIRE_GPU_TESTS").is_none(), + "Vulkan required: {error}" + ); + eprintln!("Skipping Siri Vulkan readback: {error}"); + return; + } + }; + eprintln!("Siri GPU regression adapter: {:?}", adapter.get_info()); + let (device, queue) = runtime + .block_on(adapter.request_device(&Default::default())) + .unwrap(); + let effects = [ + (SiriEffect::wave(1.35, 0.52), [32.0, 128.0]), + (SiriEffect::orb(0.7, 0.5), [192.0, 128.0]), + ( + SiriEffect::ring(0.5, 12.0, 2.0).with_tint([0.2, 0.6, 1.0]), + [352.0, 128.0], + ), + ]; + for samples in [1, 4] { + for scale in [1.0, 1.5, 2.0] { + let combined = draw(&device, &queue, samples, scale, &effects, false); + let clipped = draw(&device, &queue, samples, scale, &effects, true); + for (effect, origin) in effects { + let reference = draw( + &device, + &queue, + samples, + scale, + &[(effect, [0.0, 0.0])], + false, + ); + let mut visible = 0; + for y in 0..96 { + for x in 0..128 { + let from = ((y * WIDTH + x) * 4) as usize; + let to = + (((y + origin[1] as u32) * WIDTH + x + origin[0] as u32) * 4) as usize; + let pixel = &combined[to..to + 4]; + for channel in 0..4 { + assert!(pixel[channel].abs_diff(reference[from + channel]) <= 2, + "{:?}: viewport/shared-uniform mismatch at {x},{y}, MSAA={samples}, DPI={scale}", effect.mode); + let expected = if (16..112).contains(&x) && (16..80).contains(&y) { + pixel[channel] + } else { + 0 + }; + assert!( + clipped[to + channel].abs_diff(expected) <= 2, + "clipping moved effect" + ); + } + assert!( + pixel[..3].iter().all(|c| *c <= pixel[3]), + "RGB must be premultiplied" + ); + if pixel[..3].iter().any(|c| *c > 16) && pixel[3] > 16 { + visible += 1; + } + } + } + assert!( + visible > 50, + "{:?} must draw visible pixels, got {visible}", + effect.mode + ); + assert_eq!(reference[3], 0, "effect corner must remain transparent"); + } + assert!( + combined[..(WIDTH * 100 * 4) as usize] + .iter() + .all(|v| *v == 0), + "outside callbacks must remain transparent" + ); + } + } +} From a3a2de09899ff445df5a1cf2895b0371914ff629 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 21:49:41 +0800 Subject: [PATCH 19/35] fix(linux-egui): match Siri stage size and spectral colors --- openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/main.rs | 4 +- .../app/linux-egui/src/popup_window.rs | 16 +++--- .../app/linux-egui/src/ui/frontend/mod.rs | 16 +++++- .../app/linux-egui/src/ui/frontend/popups.rs | 33 ++++++++++- .../linux-egui/src/ui/frontend/settings.rs | 29 +++++++++- .../linux-egui/src/ui/frontend/siri_wgpu.rs | 48 +++++++++++++--- .../src/ui/frontend/siri_wgpu_gpu_tests.rs | 57 ++++++++++++++++++- 8 files changed, 181 insertions(+), 24 deletions(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index dee79f109..ee977b5ec 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -114 +115 diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 7f4bd15c1..b02ee1795 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -7577,8 +7577,8 @@ focus_was_stolen={} focus_restored={} warnings={:?}", openless_linux_egui::LESS_COMPUTER_WINDOW_SIZE.0 as f32, openless_linux_egui::LESS_COMPUTER_WINDOW_SIZE.1 as f32, ], - // 经典药丸 176×42 + 16px 下边距 + 8px 间距 + 「正在翻译」徽章 - // (Tauri `getCapsuleHostMetrics(.., 'classic')` 的 100 高度)。 + // Fixed 460×180 Siri stage; classic pills keep their own smaller + // painted bounds inside it. No phase-dependent host resizing. PopupKind::Capsule => [ openless_linux_egui::CAPSULE_WINDOW_SIZE.0 as f32, openless_linux_egui::CAPSULE_WINDOW_SIZE.1 as f32, diff --git a/openless-all/app/linux-egui/src/popup_window.rs b/openless-all/app/linux-egui/src/popup_window.rs index 18b370637..160f6dfc5 100644 --- a/openless-all/app/linux-egui/src/popup_window.rs +++ b/openless-all/app/linux-egui/src/popup_window.rs @@ -27,8 +27,9 @@ //! X11 mutation goes through the [`OverlayX11`] trait so the request sequence //! can be asserted without an X server. -/// Capsule window size (the 176×42 pill plus room for the translate badge). -pub const CAPSULE_WINDOW_SIZE: (u32, u32) = (200, 100); +/// Fixed transparent stage matching Tauri's Siri canvas. Classic/Typeless +/// still paint their small pill at the bottom; phases never resize the host. +pub const CAPSULE_WINDOW_SIZE: (u32, u32) = (460, 180); /// Gap between the capsule pill and the bottom of the work area — Tauri's /// `EDGE_GAP` for the classic / siri capsule styles. pub const CAPSULE_BOTTOM_GAP: i32 = 12; @@ -1017,6 +1018,7 @@ mod tests { #[test] fn popup_size_maps_every_kind() { assert_eq!(popup_size(PopupKind::Capsule), CAPSULE_WINDOW_SIZE); + assert_eq!(CAPSULE_WINDOW_SIZE, (460, 180)); assert_eq!(popup_size(PopupKind::Qa), QA_WINDOW_SIZE); // Tauri 的 `less-computer` 窗口与 qa 同为 420×540。 assert_eq!( @@ -1031,7 +1033,7 @@ mod tests { fn popup_position_puts_the_capsule_at_the_bottom_centre() { assert_eq!( popup_position(&environment(), PopupKind::Capsule), - Some((860, 968)) + Some((730, 888)) ); } @@ -1075,7 +1077,7 @@ mod tests { assert_eq!(popup_position(&environment, PopupKind::Qa), Some((0, 0)),); assert_eq!( popup_position(&environment, PopupKind::Capsule), - Some((100, 188)), + Some((0, 108)), ); } @@ -1098,14 +1100,14 @@ mod tests { "window_type(42)", "self_placed(42)", "states(42)", - "move(42,860,968)", + "move(42,730,888)", "active_window", "focus(64)", // 0x40 ] ); assert_eq!(placement.window, Some(0x2a)); assert_eq!(placement.matched, Some(WindowMatch::Pid)); - assert_eq!(placement.moved_to, Some((860, 968))); + assert_eq!(placement.moved_to, Some((730, 888))); assert!(placement.focus_was_stolen); assert!(placement.focus_restored); assert!(placement.warnings.is_empty()); @@ -1139,7 +1141,7 @@ mod tests { }; let placement = place_overlay(&mut x11, 1, &environment(), PopupKind::Capsule); assert!(placement.applied()); - assert_eq!(placement.moved_to, Some((860, 968))); + assert_eq!(placement.moved_to, Some((730, 888))); assert_eq!(placement.warnings, vec!["input hint failed: nope"]); } diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index acb1cb007..0f1170b91 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -3309,14 +3309,26 @@ mod tests { vm.dictation_hotkey = "Alt+A".to_string(); vm.settings.microphone_options = vec!["USB microphone".to_string()]; let mut painted = String::new(); - for _ in 0..2 { + // The full-aspect Siri preview makes this section taller. Verify the + // rows across scrolling rather than assuming they all fit above fold. + for frame in 0..8 { + let mut events = vec![egui::Event::PointerMoved(egui::pos2(900.0, 500.0))]; + if frame == 2 { + events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -240.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::default(), + }); + } ctx.begin_pass(egui::RawInput { screen_rect: Some(viewport()), + events, ..Default::default() }); let mut actions = Vec::new(); render(&ctx, &mut vm, &mut actions); - painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); + painted.push_str(&painted_text(&crate::ui::frontend::end_pass(&ctx))); } for key in [ "settings.recording.hotkey_label", diff --git a/openless-all/app/linux-egui/src/ui/frontend/popups.rs b/openless-all/app/linux-egui/src/ui/frontend/popups.rs index 0de1f0a36..a2386afd8 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/popups.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/popups.rs @@ -1113,7 +1113,9 @@ pub fn dictation_capsule( // large actions only while recording, then only its small stop // action while processing. let center = if siri { - rect.shrink(3.0) + // Siri is a full 460×180 light stage, not a wave squeezed + // inside the classic 176×42 pill. All phases share this host. + available } else if typeless && phase != "recording" { if processing { let stop_rect = egui::Rect::from_center_size( @@ -2642,6 +2644,35 @@ mod tests { } } + #[test] + fn siri_recording_and_thinking_use_the_full_stage() { + for phase in ["recording", "transcribing"] { + let ctx = egui::Context::default(); + let stage = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 180.0)); + let state = CapsulePopupState { + phase: phase.into(), + style: "siri".into(), + ..Default::default() + }; + let mut output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(stage), + ..Default::default() + }, + |ui| { + dictation_capsule(ui, &state, Lang::ZhCn); + }, + ); + assert!( + output.shapes.iter().any(|shape| matches!( + &shape.shape, egui::Shape::Callback(callback) if callback.rect == stage + )), + "{phase} must not shrink the Siri callback into the classic pill" + ); + output.textures_delta.clear(); + } + } + #[test] fn capsule_paints_state_specific_content() { let recording = CapsulePopupState { diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index 6f6be0230..3e9aadb33 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -3553,7 +3553,14 @@ fn remote_mode_row(ui: &mut egui::Ui, lang: Lang, selected: usize, on_select: im fn capsule_style_preview(ui: &mut egui::Ui, style: usize) { ui.horizontal(|ui| { ui.add_space(216.0); - let (rect, _) = ui.allocate_exact_size(egui::vec2(164.0, 42.0), egui::Sense::hover()); + // Half-size Siri stage, matching Tauri's 230×90 preview. Keep its + // aspect ratio instead of squeezing the ribbons into a pill. + let size = if style == 0 { + egui::vec2(230.0, 90.0) + } else { + egui::vec2(164.0, 42.0) + }; + let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); match style { 0 => { // Match the transparent, drifting spectral ribbons used by the @@ -3758,6 +3765,26 @@ pub(crate) fn test_render_shortcuts( #[cfg(test)] mod tests { + #[test] + fn siri_preview_uses_half_of_the_full_stage() { + let ctx = eframe::egui::Context::default(); + let mut output = ctx.run_ui( + eframe::egui::RawInput { + screen_rect: Some(eframe::egui::Rect::from_min_size( + eframe::egui::Pos2::ZERO, + eframe::egui::vec2(800.0, 600.0), + )), + ..Default::default() + }, + |ui| super::capsule_style_preview(ui, 0), + ); + assert!(output.shapes.iter().any(|shape| matches!( + &shape.shape, eframe::egui::Shape::Callback(callback) + if callback.rect.size() == eframe::egui::vec2(230.0, 90.0) + ))); + output.textures_delta.clear(); + } + use super::*; #[test] diff --git a/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs index c4892867e..79d003c07 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs @@ -212,28 +212,40 @@ fn wave(frag: vec2) -> vec4 { let envelope = pow(max(cos(pi * 0.5 * min(abs(0.9 * x_norm), 1.0)), 0.0), 2.0); let a1 = 0.32 * mix(0.14, 1.0, level) + 0.01 * low * 6.0; let a2 = a1 + mid * 0.05 + high * 0.06; - let drift = u.time * 2.4; + let drift = (u.time % (20.0 * pi)) * 2.4; let aberr = (2.6 + mid * 0.8 + high * 0.5) * res; let soft = 0.01 * res * max(0.0, 2.5 + mid * 0.4); let unres = max(length(p) - mix(0.14, 0.14, res), 0.0); let y_main = a1 * envelope * res * sin(pw.x * 1.1 + drift); - var col = vec3(0.0); + // Keep the original SiriGL spectral normalization and filled ribbons. + // Summing the four hues without dividing by their weights biases yellow. + let thickness = mix(0.1, 0.03, res); + let intensity = mix(0.1, 0.01 * (2.0 + low * 1.5), res); + let band_amount = 3.0 * intensity; + var numerator = vec3(0.0); + var denominator = vec3(0.0); for (var s: i32 = 0; s < 4; s = s + 1) { let hue = mix(vec3(1.0), spectral4(s), res); let ab = mix(-aberr, aberr, f32(s) / 3.0); let y_line = a2 * envelope * res * sin(pw.x + drift + ab); let distance = mix(unres, abs(p.y - y_line), res); - let line = (0.01 * (2.0 + low * 1.5)) / (sqrt(distance * distance + soft * soft) + mix(0.1, 0.03, res)); - col += hue * line; + let lorentz = mix(1.0 / (1.0 + pow(0.02 * distance, 2.0)), 1.0, res); + let line = intensity / (sqrt(distance * distance + soft * soft) + thickness); + let band_distance = max(0.0, max(p.y - max(y_main, y_line), min(y_main, y_line) - p.y)); + let band = band_amount / (band_distance + 0.08); + numerator += hue * lorentz * (line + band); + denominator += hue; } let main_distance = mix(unres, abs(p.y - y_main), res); - let halo = 0.5 * (0.01 * (2.0 + low * 1.5)) / (sqrt(main_distance * main_distance + soft * soft) + 0.03); - col += vec3(halo * (1.0 + (1.0 - res) * (3.0 * low + 1.2))); - let edge_t = clamp((abs(y_screen) - 1.0 + 0.4) / -0.4, 0.0, 1.0); + let main_lorentz = mix(1.0 / (1.0 + pow(0.02 * main_distance, 2.0)), 1.0, res); + let boost = (1.0 - res) * (3.0 * low + 1.2); + var col = numerator / denominator; + col += vec3(0.5 * intensity * (main_lorentz + boost) / (sqrt(main_distance * main_distance + soft * soft) + thickness)); + col = pow(max(col, vec3(0.0)), vec3(1.5)); + let edge_t = clamp((abs(y_screen) - 1.0) / -0.4, 0.0, 1.0); let edge = edge_t * edge_t * (3.0 - 2.0 * edge_t); let gaussian = exp(-pow(x_norm * 1.7, 2.0)); col *= edge * gaussian * mix(0.55, 1.0, res); - col = pow(max(col, vec3(0.0)), vec3(1.5)); col = clamp(col, vec3(0.0), vec3(1.0)); let alpha = clamp(max(col.r, max(col.g, col.b)), 0.0, 1.0); return vec4(col, alpha); @@ -284,6 +296,20 @@ fn fs_main(in: VertexOut) -> @location(0) vec4 { if u.mode < 1.5 { return orb(local); } return ring(local); } + +// Match egui's framebuffer handling: an sRGB target will encode RGB again, +// so decode our gamma-space Siri colors first. Alpha is always linear. +@fragment +fn fs_main_linear(in: VertexOut) -> @location(0) vec4 { + let local = in.uv * u.size; + var color: vec4; + if u.mode < 0.5 { color = wave(local); } + else if u.mode < 1.5 { color = orb(local); } + else { color = ring(local); } + let lower = color.rgb / 12.92; + let higher = pow((color.rgb + vec3(0.055)) / 1.055, vec3(2.4)); + return vec4(select(higher, lower, color.rgb < vec3(0.04045)), color.a); +} "#; #[derive(Clone, Copy)] @@ -374,7 +400,11 @@ fn create_gpu( }, fragment: Some(wgpu::FragmentState { module: &module, - entry_point: Some("fs_main"), + entry_point: Some(if format.is_srgb() { + "fs_main_linear" + } else { + "fs_main" + }), compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { format, diff --git a/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu_gpu_tests.rs b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu_gpu_tests.rs index 5a3b289f0..9a6b8de6c 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu_gpu_tests.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu_gpu_tests.rs @@ -15,7 +15,26 @@ fn draw( effects: &[(SiriEffect, [f32; 2])], clipped: bool, ) -> Vec { - let format = wgpu::TextureFormat::Rgba8Unorm; + draw_format( + device, + queue, + samples, + scale, + effects, + clipped, + wgpu::TextureFormat::Rgba8Unorm, + ) +} + +fn draw_format( + device: &wgpu::Device, + queue: &wgpu::Queue, + samples: u32, + scale: f32, + effects: &[(SiriEffect, [f32; 2])], + clipped: bool, + format: wgpu::TextureFormat, +) -> Vec { let mut renderer = egui_wgpu::Renderer::new( device, format, @@ -175,6 +194,42 @@ fn vulkan_effects_are_visible_local_independent_and_transparent() { let (device, queue) = runtime .block_on(adapter.request_device(&Default::default())) .unwrap(); + // Reference values evaluated independently from SiriGL.tsx's wave formula + // at pixel centers in a 128×96 canvas, t=1.35, level=0.52. These catch lost + // band fill, missing per-channel normalization and framebuffer gamma drift. + for format in [ + wgpu::TextureFormat::Rgba8Unorm, + wgpu::TextureFormat::Rgba8UnormSrgb, + ] { + let pixels = draw_format( + &device, + &queue, + 1, + 1.0, + &[(SiriEffect::wave(1.35, 0.52), [0.0, 0.0])], + false, + format, + ); + for (x, y, rgba) in [ + (32, 48, [58u8, 57, 73, 73]), + (48, 40, [69, 59, 44, 69]), + (56, 48, [206, 255, 255, 255]), + (64, 48, [255, 255, 255, 255]), + (72, 56, [52, 79, 57, 79]), + (80, 40, [52, 33, 39, 52]), + (96, 48, [66, 67, 63, 67]), + (64, 20, [12, 11, 10, 12]), + ] { + let offset = ((y * WIDTH + x) * 4) as usize; + for channel in 0..4 { + assert!( + pixels[offset + channel].abs_diff(rgba[channel]) <= 2, + "SiriGL color mismatch at {x},{y} on {format:?}: {:?} != {rgba:?}", + &pixels[offset..offset + 4] + ); + } + } + } let effects = [ (SiriEffect::wave(1.35, 0.52), [32.0, 128.0]), (SiriEffect::orb(0.7, 0.5), [192.0, 128.0]), From 7e02f2a99c1d62474342a48c89665ad70eb4ef7d Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 22:08:07 +0800 Subject: [PATCH 20/35] fix(linux-egui): animate Siri preview and show thinking label --- openless-all/app/linux-egui/package-revision | 2 +- .../app/linux-egui/src/ui/frontend/popups.rs | 51 +++++++++++++++++-- .../linux-egui/src/ui/frontend/settings.rs | 48 ++++++++++++++++- 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index ee977b5ec..4699eb3cc 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -115 +116 diff --git a/openless-all/app/linux-egui/src/ui/frontend/popups.rs b/openless-all/app/linux-egui/src/ui/frontend/popups.rs index a2386afd8..bbc3ab5cf 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/popups.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/popups.rs @@ -1259,7 +1259,28 @@ pub fn dictation_capsule( // 0.3s 全聚圆心接住 wave 收拢的光点,再缓缓散开成环。 let gather = (1.0 - (clock.time / 0.9).clamp(0.0, 1.0)).clamp(0.0, 1.0); let effect = siri_wgpu::SiriEffect::orb(clock.time, gather); - if !use_gpu || !siri_wgpu::paint(ui, center, effect) { + if siri { + let _ = siri_wgpu::paint(ui, center, effect); + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(16)); + // Keep the processing caption below the orb, with enough + // contrast on any desktop, without resizing the stage. + let label = ui.painter().layout_no_wrap( + tr_l10n(lang, "capsule.thinking").to_string(), + egui::FontId::proportional(17.0), + theme::INK, + ); + let label_rect = egui::Rect::from_center_size( + center.center() + egui::vec2(0.0, center.height() * 0.32), + label.size(), + ); + ui.painter().rect_filled( + label_rect.expand2(egui::vec2(8.0, 3.0)), + egui::CornerRadius::same(10), + theme::SURFACE, + ); + ui.painter().galley(label_rect.min, label, theme::INK); + } else { ui.painter().text( center.center(), egui::Align2::CENTER_CENTER, @@ -2644,6 +2665,27 @@ mod tests { } } + #[test] + fn siri_processing_caption_is_localized_in_every_processing_phase() { + for lang in [Lang::ZhCn, Lang::En] { + for phase in ["starting", "transcribing", "polishing", "inserting"] { + let state = CapsulePopupState { + phase: phase.into(), + style: "siri".into(), + ..Default::default() + }; + let painted = run(egui::vec2(460.0, 180.0), |ui| { + dictation_capsule(ui, &state, lang); + String::new() + }); + assert!( + has(&painted, tr_l10n(lang, "capsule.thinking")), + "{phase} must show the localized processing caption: {painted}" + ); + } + } + } + #[test] fn siri_recording_and_thinking_use_the_full_stage() { for phase in ["recording", "transcribing"] { @@ -2706,8 +2748,7 @@ mod tests { "{painted}" ); - // siri 样式:中心交给 Siri 的 WGPU effect callback, - // 所以不再叠一行「思考中」文字——与 GPU 就绪后的终态一致。 + // Siri keeps both its WGPU orb and localized processing caption. let transcribing_siri = CapsulePopupState { phase: "Transcribing".to_string(), style: "siri".to_string(), @@ -2718,8 +2759,8 @@ mod tests { String::new() }); assert!( - !has(&painted, tr_l10n(Lang::ZhCn, "capsule.thinking")), - "siri transcribing paints the orb instead of the label: {painted}" + has(&painted, tr_l10n(Lang::ZhCn, "capsule.thinking")), + "siri transcribing must retain the processing label: {painted}" ); let done = CapsulePopupState { diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index 3e9aadb33..4186f18c2 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -3565,11 +3565,23 @@ fn capsule_style_preview(ui: &mut egui::Ui, style: usize) { 0 => { // Match the transparent, drifting spectral ribbons used by the // live Siri capsule instead of showing unrelated loading dots. + let dt = ui.input(|input| input.stable_dt); + let clock = super::siri_wgpu::tick( + ui.ctx(), + "settings-siri-preview", + super::siri_wgpu::SiriDrive { + level: 0.18, + ..Default::default() + }, + dt, + ); let _ = super::siri_wgpu::paint( ui, rect, - super::siri_wgpu::SiriEffect::wave(1.35, 0.52), + super::siri_wgpu::SiriEffect::wave(clock.time, clock.level), ); + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(16)); } 1 => { let pill = egui::Rect::from_center_size(rect.center(), egui::vec2(152.0, 34.0)); @@ -3765,6 +3777,40 @@ pub(crate) fn test_render_shortcuts( #[cfg(test)] mod tests { + #[test] + fn siri_preview_advances_and_requests_animation_frames() { + let ctx = eframe::egui::Context::default(); + let mut previous = 0.0; + for frame in 0..4 { + let mut output = ctx.run_ui( + eframe::egui::RawInput { + screen_rect: Some(eframe::egui::Rect::from_min_size( + eframe::egui::Pos2::ZERO, + eframe::egui::vec2(800.0, 600.0), + )), + time: Some(frame as f64 / 60.0), + ..Default::default() + }, + |ui| super::capsule_style_preview(ui, 0), + ); + let clock = ctx + .data(|data| { + data.get_temp::(eframe::egui::Id::new(( + "openless-siri-clock", + "settings-siri-preview", + ))) + }) + .expect("preview animation clock"); + assert!(clock.time > previous); + previous = clock.time; + assert!( + output.viewport_output[&eframe::egui::ViewportId::ROOT].repaint_delay + <= std::time::Duration::from_millis(16) + ); + output.textures_delta.clear(); + } + } + #[test] fn siri_preview_uses_half_of_the_full_stage() { let ctx = eframe::egui::Context::default(); From 5fe0e3ac366f7afc0d4bf6ec0bf9c6ef4f3c1934 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 23:38:11 +0800 Subject: [PATCH 21/35] perf(linux-egui): stop wgpu reserving ~180 MiB of VRAM per process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wgpu 28 把 `MemoryHints::Performance` 定为默认值,于是 wgpu-hal 的 Vulkan 子分配器 按 device 128–256 MB 的内存块向驱动申请。每个渲染进程(主窗、胶囊、每个弹窗)都为 此付出固定代价:建 device 后显存 195 MiB,且与窗口大小、UI 内容无关 —— 460×180 的 胶囊要 197 MiB,而裸 Vulkan(vkcube,含交换链)只有 5 MiB。 现在 eframe 窗口与原生 layer-shell 胶囊共用一份设备描述,统一改要 `MemoryHints::MemoryUsage`(8–64 MB 块)。 本机实测(GTX 1660 SUPER / NVIDIA 615.71.09 / 1920×1080@120): 胶囊 460×180,Siri 录音态: 198 MiB → 18 MiB,29.8 fps 不变 设置面板打开(背板 4×MSAA + 模糊 + 动画预览): 209 MiB → 47 MiB,72.7 fps 不变 顺带加入 `frame_stats`:`OPENLESS_FPS_LOG=1` 每秒写一行帧统计, `OPENLESS_FPS_SETTINGS=1` 启动即打开设置面板,用来量上面这两组数字。 --- openless-all/app/linux-egui/package-revision | 2 +- .../app/linux-egui/src/frame_stats.rs | 156 ++++++++++++++++++ openless-all/app/linux-egui/src/lib.rs | 2 + .../app/linux-egui/src/linux_app/window.rs | 11 +- openless-all/app/linux-egui/src/main.rs | 2 + .../app/linux-egui/src/popup_layer.rs | 2 +- .../app/linux-egui/src/wgpu_device.rs | 61 +++++++ 7 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 openless-all/app/linux-egui/src/frame_stats.rs create mode 100644 openless-all/app/linux-egui/src/wgpu_device.rs diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 4699eb3cc..5bc6609e3 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -116 +117 diff --git a/openless-all/app/linux-egui/src/frame_stats.rs b/openless-all/app/linux-egui/src/frame_stats.rs new file mode 100644 index 000000000..fe42a7faf --- /dev/null +++ b/openless-all/app/linux-egui/src/frame_stats.rs @@ -0,0 +1,156 @@ +//! 帧率探针(QA 用,默认关闭)。 +//! +//! `OPENLESS_FPS_LOG=1` 时每个渲染进程每秒往日志写一行帧统计(帧数、fps、最慢一帧), +//! 用来现场回答「这台机器能跑到多少帧」。`OPENLESS_FPS_SETTINGS=1` 额外让主窗口 +//! 启动时就打开设置面板,直接测最重的那条路径(磨砂背板:离屏 4×MSAA 重绘 + 高斯模糊)。 +//! +//! 两个开关都只读环境变量;未开启时每次调用只做一次 `OnceLock` 读,日志一条不发。 + +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +/// 统计窗口:每满这么久写一行。1s 足够稳,又能在一次测量里拿到多行。 +const REPORT_WINDOW: Duration = Duration::from_secs(1); + +const FPS_LOG_ENV: &str = "OPENLESS_FPS_LOG"; +const SETTINGS_ENV: &str = "OPENLESS_FPS_SETTINGS"; + +struct Window { + label: &'static str, + started: Instant, + last: Instant, + frames: u32, + worst: Duration, +} + +impl Window { + fn new(label: &'static str, now: Instant) -> Self { + Self { + label, + started: now, + last: now, + frames: 0, + worst: Duration::ZERO, + } + } +} + +/// 记一帧;跨过 [`REPORT_WINDOW`] 时返回 `(帧数, 窗口时长, 最慢一帧)` 并重置窗口。 +fn record(window: &mut Window, now: Instant) -> Option<(u32, Duration, Duration)> { + window.worst = window.worst.max(now.saturating_duration_since(window.last)); + window.last = now; + window.frames += 1; + let elapsed = now.saturating_duration_since(window.started); + if elapsed < REPORT_WINDOW { + return None; + } + let report = (window.frames, elapsed, window.worst); + *window = Window::new(window.label, now); + Some(report) +} + +fn env_flag(name: &str) -> bool { + matches!(std::env::var(name).as_deref(), Ok("1") | Ok("true")) +} + +/// 帧率日志是否开启。 +pub fn enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| env_flag(FPS_LOG_ENV)) +} + +/// 启动即打开设置面板(只影响测量环境)。 +pub fn settings_open_on_start() -> bool { + env_flag(SETTINGS_ENV) +} + +/// 每个渲染帧调用一次。`label` 区分进程(主窗 / 弹窗 / 胶囊)。 +/// +/// 每个进程只有一个渲染面,所以内部只保留一份窗口;`label` 变了就重新开始统计。 +pub fn tick(label: &'static str) { + if !enabled() { + return; + } + let now = Instant::now(); + let mut slot = WINDOW + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let stale = !matches!(slot.as_ref(), Some(window) if window.label == label); + if stale { + *slot = Some(Window::new(label, now)); + } + let Some(window) = slot.as_mut() else { + return; + }; + if let Some((frames, elapsed, worst)) = record(window, now) { + let message = format!( + "[fps] {label}: {frames} frames / {:.0}ms = {:.1} fps, worst frame {:.1}ms", + elapsed.as_secs_f64() * 1000.0, + frames as f64 / elapsed.as_secs_f64(), + worst.as_secs_f64() * 1000.0, + ); + // 胶囊走原生 layer-shell 路径时不装文件日志器,`log::info!` 会被直接丢弃; + // 那种情况下探针退回 stderr,保证任何启动方式都能拿到数字。 + if log::log_enabled!(log::Level::Info) { + log::info!("{message}"); + } else { + eprintln!("{message}"); + } + } +} + +static WINDOW: Mutex> = Mutex::new(None); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frames_are_reported_once_per_window() { + let start = Instant::now(); + let mut window = Window::new("test", start); + // 第 1 帧只是开窗,不满一秒不报。 + assert!(record(&mut window, start).is_none()); + for frame in 1..100 { + assert!(record( + &mut window, + start + Duration::from_millis(frame as u64 * 10) + ) + .is_none()); + } + let report = record(&mut window, start + Duration::from_millis(1000)); + let (frames, elapsed, worst) = report.expect("the window closes after one second"); + assert_eq!(frames, 101); + assert_eq!(elapsed, Duration::from_millis(1000)); + assert_eq!(worst, Duration::from_millis(10)); + // 窗口被重置:下一帧重新计数,不会把上一秒的帧数带上。 + assert!(record(&mut window, start + Duration::from_millis(1010)).is_none()); + let (frames, _, _) = record(&mut window, start + Duration::from_millis(2020)) + .expect("the next window reports its own frames"); + assert_eq!(frames, 2); + } + + #[test] + fn the_worst_frame_is_the_longest_interval_in_the_window() { + let start = Instant::now(); + let mut window = Window::new("test", start); + record(&mut window, start); + record(&mut window, start + Duration::from_millis(10)); + // 一帧慢了 200ms。 + record(&mut window, start + Duration::from_millis(210)); + let (frames, _, worst) = + record(&mut window, start + Duration::from_millis(1000)).expect("report"); + assert_eq!(frames, 4); + // 最慢间隔包含「进入这次上报自身的那个间隔」:静止态不重绘时它会被拉长, + // 这是真实语义(慢帧与空闲都看得见),读数字时和 fps 一起看。 + assert_eq!(worst, Duration::from_millis(790)); + } + + #[test] + fn a_new_label_starts_a_fresh_window() { + let start = Instant::now(); + let slot = Some(Window::new("main", start)); + let stale = !matches!(slot.as_ref(), Some(window) if window.label == "popup"); + assert!(stale, "a different label must reset the statistics"); + } +} diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index e04e6843e..2efebc2c9 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -15,6 +15,7 @@ mod credentials; mod desktop; mod dictation_feedback; mod fcitx5; +pub mod frame_stats; mod host_actions; mod hotkeys; mod i18n; @@ -34,6 +35,7 @@ mod settings; mod single_instance; mod tray; mod ui_state; +pub mod wgpu_device; // Shared by the eframe windows and the native Wayland layer-shell renderer so // every Vulkan surface uses the exact same WGPU callback resource type. diff --git a/openless-all/app/linux-egui/src/linux_app/window.rs b/openless-all/app/linux-egui/src/linux_app/window.rs index 04b194d60..ec13747b2 100644 --- a/openless-all/app/linux-egui/src/linux_app/window.rs +++ b/openless-all/app/linux-egui/src/linux_app/window.rs @@ -303,6 +303,10 @@ fn encode_view_model( pub(super) fn vulkan_options(mut options: eframe::NativeOptions) -> eframe::NativeOptions { if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_options.wgpu_setup { setup.instance_descriptor.backends = eframe::egui_wgpu::wgpu::Backends::VULKAN; + // 显存优先的设备描述,与原生 layer-shell 胶囊同源(见 `wgpu_device`): + // wgpu 默认的 `Performance` hints 会让每个渲染进程预占约 180 MiB。 + setup.device_descriptor = + std::sync::Arc::new(|_adapter| openless_linux_egui::wgpu_device::device_descriptor()); } options } @@ -430,6 +434,10 @@ impl UiClientApp { client: UiBridgeClient, render_state: Option<&eframe::egui_wgpu::RenderState>, ) -> Self { + let mut view_model = FrontendViewModel::default(); + // QA:OPENLESS_FPS_SETTINGS=1 时开机就打开设置面板,直接测最重的渲染路径 + // (磨砂背板 + 离屏 4×MSAA 重绘 + 动画预览)。见 `frame_stats` 模块文档。 + view_model.settings_open |= openless_linux_egui::frame_stats::settings_open_on_start(); Self { client, connecting_since: std::time::Instant::now(), @@ -438,7 +446,7 @@ impl UiClientApp { backdrop: render_state.map(|state| { crate::ui::backdrop::BackdropBlur::new(state, MAIN_MSAA_SAMPLES.into()) }), - view_model: FrontendViewModel::default(), + view_model, last_sequence: 0, last_adopted: None, action_sequence: 0, @@ -664,6 +672,7 @@ impl eframe::App for UiClientApp { } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + openless_linux_egui::frame_stats::tick("ui-client"); let ctx = ui.ctx().clone(); if self.connection_error.is_none() { self.drain_host(&ctx); diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index b02ee1795..8e2dc3a71 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -7152,6 +7152,7 @@ focus_was_stolen={} focus_restored={} warnings={:?}", } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + openless_linux_egui::frame_stats::tick("popup"); let ctx = ui.ctx().clone(); // Overlay placement first: it must run before the window is shown so // the compositor never gives the capsule the keyboard. @@ -7476,6 +7477,7 @@ focus_was_stolen={} focus_restored={} warnings={:?}", let started = Arc::new(std::sync::atomic::AtomicBool::new(false)); let started_in_frame = Arc::clone(&started); let result = openless_linux_egui::run_layer_capsule(geometry, move |ctx, raw, first| { + openless_linux_egui::frame_stats::tick("capsule-layer"); if app.is_none() { match popup_stdio() { Ok((incoming, outgoing)) => { diff --git a/openless-all/app/linux-egui/src/popup_layer.rs b/openless-all/app/linux-egui/src/popup_layer.rs index d320e1684..bb8b7a31f 100644 --- a/openless-all/app/linux-egui/src/popup_layer.rs +++ b/openless-all/app/linux-egui/src/popup_layer.rs @@ -539,7 +539,7 @@ impl WgpuSurface { })) .map_err(|error| format!("vulkan adapter: {error}"))?; let (device, queue) = runtime - .block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) + .block_on(adapter.request_device(&crate::wgpu_device::device_descriptor())) .map_err(|error| format!("vulkan device: {error}"))?; let mut config = surface .get_default_config(&adapter, size.0.max(1), size.1.max(1)) diff --git a/openless-all/app/linux-egui/src/wgpu_device.rs b/openless-all/app/linux-egui/src/wgpu_device.rs new file mode 100644 index 000000000..4e2a835a1 --- /dev/null +++ b/openless-all/app/linux-egui/src/wgpu_device.rs @@ -0,0 +1,61 @@ +//! 所有 wgpu 设备共用的 `DeviceDescriptor`。 +//! +//! wgpu 28 起 `MemoryHints` 的默认值是 `Performance`:wgpu-hal 的 Vulkan 子分配器 +//! 会按 device 128–256 MB / host 64–128 MB 的内存块向驱动申请 +//! (`wgpu-hal/src/lib.rs` 的 `AllocationSizes::from_memory_hints`)。对 egui 这种 +//! 「一堆小 buffer + 小纹理」的负载,那块显存纯属预占: +//! +//! | hints | 单个渲染进程建 device 后的显存(GTX 1660 SUPER / NVIDIA 615.71.09) | +//! |---|---| +//! | `Performance`(wgpu 默认) | 195 MiB | +//! | `MemoryUsage`(这里) | 15 MiB | +//! +//! 帧率不受影响(实测胶囊 29.8 fps 上限、设置页 72.7 fps 两档都不变)。宿主、主窗、 +//! 每个弹窗都是独立进程,各自建一个设备,所以这里统一按最低占用配置;egui 也要求 +//! 2D 纹理上限能覆盖 4K 表面,这一点与 egui-wgpu 的默认保持一致。 + +use wgpu::{DeviceDescriptor, Limits, MemoryHints}; + +/// 与 egui-wgpu 默认的 `max_texture_dimension_2d` 一致:足够容纳 4K+ 表面。 +const MAX_TEXTURE_DIMENSION_2D: u32 = 8192; + +/// eframe 窗口与原生 layer-shell 胶囊共用的一份设备描述。 +pub fn device_descriptor() -> DeviceDescriptor<'static> { + DeviceDescriptor { + label: Some("openless-wgpu-device"), + required_limits: Limits { + max_texture_dimension_2d: MAX_TEXTURE_DIMENSION_2D, + ..Limits::default() + }, + memory_hints: MemoryHints::MemoryUsage, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn device_descriptor_prefers_memory_usage_over_performance() { + assert!( + matches!(device_descriptor().memory_hints, MemoryHints::MemoryUsage), + "Performance hints reserve a 128 MiB device block per process" + ); + } + + #[test] + fn device_descriptor_still_covers_4k_surfaces() { + assert_eq!( + device_descriptor().required_limits.max_texture_dimension_2d, + MAX_TEXTURE_DIMENSION_2D + ); + assert!(MAX_TEXTURE_DIMENSION_2D >= 3840); + } + + #[test] + fn device_descriptor_asks_for_no_optional_features() { + // 只改显存策略:不能顺手把 wgpu 的实验特性打开。 + assert!(device_descriptor().required_features.is_empty()); + } +} From 6a646a41887a9aafc9e90c4076730b8e7d0af69d Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 27 Sep 2026 23:53:26 +0800 Subject: [PATCH 22/35] fix(linux-egui): assert the Vulkan runtime deps in the release contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WGPU 迁移把包依赖从 libegl1 / libwayland-egl1(rpm: libglvnd-egl / libwayland-egl.so.1)换成了 libvulkan1(rpm: vulkan-loader)+ Recommends mesa-vulkan-drivers,但 linux-egui-release-contract.test.mjs 还在断言旧的 EGL 依赖,于是 CI 的 macOS/Windows 契约步骤从迁移那次提交起就红: AssertionError: deb must require libegl1 at scripts/linux-egui-release-contract.test.mjs:98 现在断言 Vulkan loader 在 deb/rpm 依赖里、EGL 依赖不再回来(回来了说明 GL 渲染 路径又被引回来),并断言 Recommends 里有 Vulkan 驱动。 顺带更新 docs/linux-egui-dependency-upgrade.md:layer-shell 胶囊已经走 wgpu/Vulkan,「仍存在的 GL 路径」一节以及验收清单里「该 surface 仍走 GL」的说法 都过期了;补上 MemoryHints::MemoryUsage 的显存结论。 --- docs/linux-egui-dependency-upgrade.md | 12 +++++++----- .../scripts/linux-egui-release-contract.test.mjs | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/linux-egui-dependency-upgrade.md b/docs/linux-egui-dependency-upgrade.md index 1e55c3593..177ae9bed 100644 --- a/docs/linux-egui-dependency-upgrade.md +++ b/docs/linux-egui-dependency-upgrade.md @@ -1,6 +1,6 @@ # Linux egui 依赖升级与 Vulkan 渲染 -更新:2026-09-24。以 `linux-egui/Cargo.toml`、Cargo.lock 和当前源码为准;旧 Pi 记录和此前暂缓计划只作为线索。 +更新:2026-09-27。以 `linux-egui/Cargo.toml`、Cargo.lock 和当前源码为准;旧 Pi 记录和此前暂缓计划只作为线索。 ## 已完成 @@ -9,12 +9,14 @@ - Siri 波形、圆点和选区助手环改用 egui 原生图形绘制,避免 Glow shader callback 在 WGPU 窗口中被忽略后退化成普通音量条。 - 0.36 破坏性 API 已适配:`App::ui`、`Context::run_ui`、`CentralPanel::show(&mut Ui)`、文本编辑框 frame、UI 样式按主题设置,以及 Glow layer-shell 纹理增量可变传递。 - Cargo.lock 已解析升级 62 个兼容依赖;包括 eframe/egui 图形栈、accesskit、wgpu/Naga 等。其余直接依赖未为追新而跨主版本改动。 +- Wayland wlr-layer-shell 胶囊面已从 `glutin`/EGL + `egui_glow::Painter` 迁到 wgpu/Vulkan(`popup_layer.rs` 自建 surface、presentation 与帧同步),OpenGL 渲染路径整体移除;`glutin`、`egui_glow` 及其 EGL 依赖不再出现在二进制里。 +- 包依赖随之从 `libegl1` / `libwayland-egl1`(rpm: `libglvnd-egl` / `libwayland-egl.so.1`)换成 `libvulkan1`(rpm: `vulkan-loader`),并 `Recommends: mesa-vulkan-drivers`。契约测试 `linux-egui-release-contract.test.mjs` 会同时断言 Vulkan loader 在依赖里、EGL 依赖不再回来。 -## 仍存在的 GL 路径 +## 渲染路径:全部 Vulkan -自建的 Wayland wlr-layer-shell 胶囊面仍通过 `glutin`/EGL 和 `egui_glow::Painter` 提交 epaint 图元。这是独立于 eframe 主窗/普通弹窗的 surface 实现;Siri 效果本身已不再依赖 GL shader。X11 overlay 也保留现有窗口管理器路径。 +主窗、普通弹窗、layer-shell 胶囊现在共用 `src/wgpu_device.rs::device_descriptor()`:Vulkan-only 后端、`max_texture_dimension_2d = 8192`、`MemoryHints::MemoryUsage`。 -若要让 layer-shell surface 也由 WGPU/Vulkan 提交,需要将 `popup_layer.rs` 的手工 Wayland surface、presentation 和帧同步整体改成 wgpu surface;这不是 egui 升级的 API 适配,需另行实现与设备验证。 +最后一项是必须的:wgpu 28 起 `MemoryHints` 默认 `Performance`,wgpu-hal 会按 device 128–256 MB 的内存块向驱动申请,每个渲染进程因此固定多占约 180 MiB(宿主、主窗、每个弹窗各一份)。本机 GTX 1660 SUPER 实测:胶囊 460×180 从 198 MiB 降到 18 MiB,开设置面板的主窗从 209 MiB 降到 47 MiB,帧率不变(29.8 fps / 72.7 fps)。 ## 当前验证与限制 @@ -28,5 +30,5 @@ 1. 在带 Vulkan 硬件驱动的 Wayland 与 X11 主机启动主窗及三个弹窗,确认日志枚举到硬件 Vulkan adapter。 2. 检查 Siri 录音波形、处理动画和 QA 录音环在透明 surface 上的颜色、裁剪、帧率与显存/CPU 占用。 -3. 检查 LayerShell 胶囊的 EGL surface、窗口位置、焦点与音频动画;当前这条 surface 仍走 GL。 +3. 检查 LayerShell 胶囊的位置、透明背景、焦点与音频动画,确认 Vulkan surface 换帧与 `PreMultiplied` alpha 正常。 4. 在无 Vulkan loader 或 adapter 时确认故障提示清晰。目前选择的是 Vulkan-only,启动时没有自动回退 GL。 diff --git a/openless-all/app/scripts/linux-egui-release-contract.test.mjs b/openless-all/app/scripts/linux-egui-release-contract.test.mjs index dfdccfccf..27f13dd7b 100644 --- a/openless-all/app/scripts/linux-egui-release-contract.test.mjs +++ b/openless-all/app/scripts/linux-egui-release-contract.test.mjs @@ -78,6 +78,11 @@ assert.ok(!release.includes('release_tag:'), 'the manual build cannot target a R // Keep deb/rpm only, including dynamically-loaded desktop libraries and the // fcitx5 addon. No AppImage, minisign, embedded downloader or Qwen ASR runtime. +// +// Rendering is Vulkan-only: the GL/EGL paths were removed (`refactor: remove +// Linux egui OpenGL rendering`), so the packages depend on the Vulkan loader +// and nothing dlopens libEGL / libwayland-egl any more. If libegl1 or +// libwayland-egl1 comes back, a GL renderer was reintroduced. assert.match(pack, /dpkg-deb --build/); assert.match(pack, /rpmbuild --define/); assert.match(pack, /x86_64-linux-gnu\/fcitx5\/libopenless\.so/); @@ -94,13 +99,20 @@ assert.match(verify, /fcitx5\/libopenless\.so/); assert.ok(!existsSync(join(root, 'openless-all/app/linux-egui/src/updater.rs'))); assert.doesNotMatch(manifest, /minisign-verify/); const debDeps = pack.match(/^Depends: ([^\n]+)/m)?.[1].split(/,\s*/) ?? []; -for (const dep of ['fcitx5', 'libpipewire-0.3-0', 'libegl1', 'liblzma5', 'libwayland-egl1']) { +for (const dep of ['fcitx5', 'libpipewire-0.3-0', 'libvulkan1', 'liblzma5', 'libwayland-client0']) { assert.ok(debDeps.includes(dep), `deb must require ${dep}`); } +for (const removed of ['libegl1', 'libgl1', 'libwayland-egl1']) { + assert.ok(!debDeps.includes(removed), `deb must not require ${removed} after the Vulkan migration`); +} const rpmDeps = pack.match(/^Requires: ([^\n]+)/m)?.[1].split(/,\s*/) ?? []; -for (const dep of ['fcitx5', 'pipewire-libs', 'libglvnd-egl', 'libwayland-egl.so.1()(64bit)']) { +for (const dep of ['fcitx5', 'pipewire-libs', 'vulkan-loader', 'libwayland-client']) { assert.ok(rpmDeps.includes(dep), `rpm must require ${dep}`); } +for (const removed of ['libglvnd-egl', 'libwayland-egl.so.1()(64bit)']) { + assert.ok(!rpmDeps.includes(removed), `rpm must not require ${removed} after the Vulkan migration`); +} +assert.match(pack, /^Recommends: mesa-vulkan-drivers$/m, 'packages must recommend a Vulkan driver'); for (const [file, size] of [ ['32x32.png', '32x32'], ['64x64.png', '64x64'], ['128x128.png', '128x128'], ['128x128@2x.png', '256x256'], ['icon.png', '512x512'], From 99661c68912ad3dd5d7af6ad16dc524741ca008f Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Mon, 28 Sep 2026 00:26:12 +0800 Subject: [PATCH 23/35] fix(linux-egui): keep the device descriptor test clippy-clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo clippy -p openless-linux-egui --all-targets -- -D warnings` 会把 `assert!(MAX_TEXTURE_DIMENSION_2D >= 3840)` 判成 assertions_on_constants。 挪进 const 块,仍然是编译期拦截「把 2D 纹理上限调小」的改动。 --- openless-all/app/linux-egui/src/wgpu_device.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openless-all/app/linux-egui/src/wgpu_device.rs b/openless-all/app/linux-egui/src/wgpu_device.rs index 4e2a835a1..8906d303b 100644 --- a/openless-all/app/linux-egui/src/wgpu_device.rs +++ b/openless-all/app/linux-egui/src/wgpu_device.rs @@ -50,7 +50,9 @@ mod tests { device_descriptor().required_limits.max_texture_dimension_2d, MAX_TEXTURE_DIMENSION_2D ); - assert!(MAX_TEXTURE_DIMENSION_2D >= 3840); + // 3840 宽是 egui 在 4K 表面下的硬需求;常量断言放在 const 块里, + // 编译期就能拦住把上限调小的改动。 + const { assert!(MAX_TEXTURE_DIMENSION_2D >= 3840) }; } #[test] From 61420b3d9c7209d22f86e432de2161cd23b61921 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Mon, 28 Sep 2026 00:26:12 +0800 Subject: [PATCH 24/35] fix(core): prune history with the injected clock, not the wall clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 历史保留用的是两套时间:`created_at` 由注入的 `Clock` 生成,而保留截止 (`retain_with_policy`)和「最近 N 分钟」(`recent_within_minutes`)自己读 `chrono::Utc::now()`。注入固定时钟的后端(测试、回放)因此在真实时间越过 「固定时间 + retention」之后,会把刚写入的条目在写入瞬间判成过期删掉: api::tests::completed_dictation_persists_history_and_activity_from_the_session_snapshot assertion `left == right` failed left: 0 right: 1 (api.rs:11048) 本 PR 12:13Z 那次 CI 还是绿的,12:55Z 起每次都红在同一行 —— 这个测试注入 2026-08-28T12:34:56Z、retention 30 天,真实时间在 2026-09-27T12:34:56Z 越线。 改法:`HistoryStore` 持有唯一时钟来源,后端组装时用 `attach_clock` 把注入的 `Clock` 交下去;保留裁剪与 `recent_within_minutes` 都改用它。功能、默认值 (7 天)、`0 = 不按天数清理`、只在写入时裁剪的行为一律不变;生产注入的本来 就是 `SystemClock`,行为零变化。 回归测试 `retention_follows_the_injected_clock_instead_of_the_wall_clock` (固定时钟 + 30 天保留:新条目留住、超期条目照删;把 cutoff 换回 `Utc::now()` 时该测试立刻失败,已验证)。 package revision 118。 --- .../app/crates/openless-core/src/api.rs | 4 + .../app/crates/openless-core/src/history.rs | 75 +++++++++++++++++-- openless-all/app/linux-egui/package-revision | 2 +- 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 408e7e704..b4aaca15d 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -2370,6 +2370,10 @@ impl OpenLessBackend { )); } let events = Arc::new(EventBus::new(256)); + // 保留裁剪与「最近 N 分钟」必须跟着注入的时钟走:同一个后端里 + // `created_at` 已经用 `clock` 生成,裁剪却偷读系统时钟的话,测试/ + // 回放的固定时钟下刚写入的条目会在写入瞬间被判超期。 + repositories.history.attach_clock(Arc::clone(&clock)); let preferences_revision = Arc::new(AtomicU64::new(0)); let style_pack_revision = Arc::new(AtomicU64::new(0)); let history_revision = Arc::new(AtomicU64::new(0)); diff --git a/openless-all/app/crates/openless-core/src/history.rs b/openless-all/app/crates/openless-core/src/history.rs index 31f8efa76..92a20f826 100644 --- a/openless-all/app/crates/openless-core/src/history.rs +++ b/openless-all/app/crates/openless-core/src/history.rs @@ -1,8 +1,9 @@ //! Newest-first dictation history with retention and count caps. use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; +use crate::config::{Clock, SystemClock}; use crate::errors::{BackendError, BackendErrorCode}; use crate::persistence::{atomic_write, persistence_error}; use crate::types::{DictationSession, HistorySource}; @@ -12,6 +13,12 @@ pub const HISTORY_CAP: usize = 200; pub struct HistoryStore { path: PathBuf, lock: Mutex<()>, + /// 保留裁剪和「最近 N 分钟」都只认这一个时钟,而不是各自去读 + /// `chrono::Utc::now()`:后者会让注入固定时钟的后端(测试、回放)里 + /// `created_at` 与裁剪依据分成两套时间,条目可能在写入的瞬间就超期。 + /// `BackendRepositories::open` 时还不知道时钟来源,所以由后端随后用 + /// `attach_clock` 补上;没补就是系统时钟。 + clock: Mutex>, } impl HistoryStore { @@ -23,9 +30,19 @@ impl HistoryStore { Self { path, lock: Mutex::new(()), + clock: Mutex::new(Arc::new(SystemClock)), } } + /// 把后端注入的时钟交给历史仓。同一个仓只会被后端组装一次。 + pub(crate) fn attach_clock(&self, clock: Arc) { + *self.clock.lock().expect("history clock lock") = clock; + } + + fn now_utc(&self) -> chrono::DateTime { + self.clock.lock().expect("history clock lock").now_utc() + } + pub fn list(&self) -> Result, BackendError> { let _guard = self.lock_store()?; self.read_locked() @@ -66,7 +83,7 @@ impl HistoryStore { let _guard = self.lock_store()?; let mut sessions = self.read_locked()?; sessions.insert(0, session); - retain_with_policy(&mut sessions, retention_days, max_entries); + retain_with_policy(&mut sessions, retention_days, max_entries, self.now_utc()); self.write_locked(&sessions) }, ) @@ -95,7 +112,7 @@ impl HistoryStore { } else { sessions.insert(0, session); } - retain_with_policy(&mut sessions, retention_days, max_entries); + retain_with_policy(&mut sessions, retention_days, max_entries, self.now_utc()); self.write_locked(&sessions) }, ) @@ -123,7 +140,7 @@ impl HistoryStore { } let _guard = self.lock_store()?; let sessions = self.read_locked()?; - let cutoff = chrono::Utc::now() - chrono::Duration::minutes(i64::from(minutes)); + let cutoff = self.now_utc() - chrono::Duration::minutes(i64::from(minutes)); Ok(sessions .into_iter() .take_while(|session| { @@ -206,13 +223,14 @@ fn retain_with_policy( sessions: &mut Vec, retention_days: u32, max_entries: Option, + now: chrono::DateTime, ) { // Quick notes are intentionally outside the ordinary history retention // policy. A recording-start draft is also protected until it reaches a // terminal state, because its audio may be the only recoverable artifact // after a process crash. if retention_days > 0 { - let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days)); + let cutoff = now - chrono::Duration::days(i64::from(retention_days)); sessions.retain(|session| { session.source == HistorySource::QuickNote || session.error_code.as_deref() == Some("recording") @@ -329,6 +347,53 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn retention_follows_the_injected_clock_instead_of_the_wall_clock() { + // CI 2026-09-27 之后这个场景一直是红的:裁剪偷读 `chrono::Utc::now()`, + // 于是一个 injected 固定时钟(2026-08-28)+ retention 30 天的后端,会把 + // 刚写入的条目当成过期档删掉。 + let path = std::env::temp_dir().join(format!( + "openless-history-clock-{}.json", + uuid::Uuid::new_v4().simple() + )); + let store = HistoryStore::at_path(path.clone()); + let fixed = chrono::DateTime::parse_from_rfc3339("2026-08-28T12:34:56+00:00") + .unwrap() + .with_timezone(&chrono::Utc); + store.attach_clock(Arc::new(crate::testing::FixedClock::new( + fixed, + fixed.date_naive(), + ))); + + store + .append_with_retention(session("kept", fixed.to_rfc3339()), 30, Some(20)) + .unwrap(); + assert_eq!( + store.list().unwrap().len(), + 1, + "a fresh entry is never stale" + ); + assert_eq!( + store.recent_within_minutes(5).unwrap().len(), + 1, + "the polish context window reads the same clock" + ); + + // 功能本身不能被放宽:同一个固定时钟下超期的条目照旧被裁掉。 + let stale = (fixed - chrono::Duration::days(31)).to_rfc3339(); + store + .append_with_retention(session("stale", stale), 30, None) + .unwrap(); + let ids: Vec = store + .list() + .unwrap() + .into_iter() + .map(|entry| entry.id) + .collect(); + assert_eq!(ids, vec!["kept".to_string()]); + let _ = std::fs::remove_file(path); + } + #[test] fn update_delete_clear_and_recent_queries_are_stable() { let path = std::env::temp_dir().join(format!( diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 5bc6609e3..415196e47 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -117 +118 From 2387882ca162a92c86601ef1b40086c68fa63ee4 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Mon, 28 Sep 2026 20:44:46 +0800 Subject: [PATCH 25/35] fix(linux-egui): bound the UI snapshot inbox, stop the 5.8 GB freeze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现场:一个跑了一晚的 UI 进程 RSS 5.8 GB(VSS 7.2 GB),但 21 小时只用了 60 秒 CPU、GPU 显存 47 MiB —— 涨的是纯内存,不是渲染,也不是泄漏式的匀速增长。 成因是三段代码凑在一起: 1. 宿主 `publish_view_model()`:内容没变也**每 2 秒发一份保活快照**。 2. UI 侧收件通道是无界 `mpsc::channel()`(`UiBridgeClient::connect`)。 3. 窗口被遮挡/不可见时,eframe 会**完全跳过 egui pass** (eframe 0.36 `wgpu_integration.rs` 的 `if !show_ui { /* 不做任何 egui pass */ }`), `UiClientApp::ui()` 根本不会被调用,也就没人排空那个通道。 于是「窗口不可见」的每一秒都在堆快照:21 小时 ≈ 3.7 万份 × ~154 KB ≈ 5.8 GB, 且窗口恢复可见后要先逐份消化积压再回到正常帧,看起来就是卡死。 旁证:健康实例每 40 秒左右会写一条 `[ui-client] ipc round-trip ...`(UI 每 2 秒 一次 ping),而那个实例 21 小时里一条都没有 —— 它的 egui pass 一直没跑。 改法:快照不再走通道,只进 `UiBridgeClient` 的**单一槽位**(`LatestSnapshot`)。 旧快照本来就会被后一份覆盖,中间那些没有任何价值;窗口每帧 `take_snapshot()` 一次即可。内存因此与「窗口被遮挡多久 / 宿主发过多少份」无关。 控制帧(Ready/Hotkeys/Pong/Focus…)照旧走通道:它们由事件驱动、体积小。 回归测试 `an_undrained_window_keeps_only_the_newest_snapshot`:连发 40 份快照、 一份都不取,断言只有序号 40 留在槽位、取走后清空、通道里一句快照都没有 (把快照改回走通道时该测试立刻超时失败,已验证)。 另外这次同步把 origin/beta 的两个提交(#1113 空格/鼠标热键、#1115 GPT-6 温度) 合入本分支,无冲突。 package revision 120。 --- openless-all/app/linux-egui/package-revision | 2 +- .../app/linux-egui/src/linux_app/window.rs | 23 +++- openless-all/app/linux-egui/src/main.rs | 6 + openless-all/app/linux-egui/src/ui/bridge.rs | 127 ++++++++++++++---- 4 files changed, 129 insertions(+), 29 deletions(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 415196e47..52bd8e43a 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -118 +120 diff --git a/openless-all/app/linux-egui/src/linux_app/window.rs b/openless-all/app/linux-egui/src/linux_app/window.rs index ec13747b2..6852f7e0b 100644 --- a/openless-all/app/linux-egui/src/linux_app/window.rs +++ b/openless-all/app/linux-egui/src/linux_app/window.rs @@ -462,6 +462,20 @@ impl UiClientApp { } } + /// 把单槽位里最新的快照搬进视图模型。 + /// + /// 正常情况下一帧只有一份;`while` 是为了把极端情况下「取的过程中又来了 + /// 一份」也收干净(旧的那份会被 `snapshot_supersedes` 丢掉)。 + fn adopt_latest_snapshot(&mut self) { + while let Some((sequence, view_model)) = self.client.take_snapshot() { + if snapshot_supersedes(self.last_sequence, sequence) { + self.adopt_snapshot(sequence, *view_model); + } else { + log::debug!("[ui-client] dropped stale snapshot #{sequence}"); + } + } + } + /// 采纳一份快照,但保留用户还没提交、而宿主也没有改动的输入。 pub(super) fn adopt_snapshot(&mut self, sequence: u64, incoming: FrontendViewModel) { self.last_sequence = sequence; @@ -482,7 +496,12 @@ impl UiClientApp { /// 收宿主的帧。快照按序号采纳;`Shutdown` 与断连都表示「宿主走了」, /// 此时 UI 必须自己退出(没有宿主就没有数据可渲染)。 + /// + /// 快照不在通道里(见 `UiBridgeClient` 的快照单槽位),所以本函数只在 + /// egui pass 真正跑的时候被调用 —— 窗口被遮挡时宿主发多少保活快照都不会 + /// 堆内存。 pub(super) fn drain_host(&mut self, ctx: &egui::Context) { + self.adopt_latest_snapshot(); loop { match self.client.try_recv() { Ok(HostToWindow::Ready { version }) => { @@ -520,14 +539,14 @@ impl UiClientApp { ); } } + // 快照正常不进通道(见 `UiBridgeClient` 的快照单槽位);这里兜底: + // 万一哪条路径真把它推进来了,也不能丢状态。 Ok(HostToWindow::Snapshot { sequence, view_model, }) => { if snapshot_supersedes(self.last_sequence, sequence) { self.adopt_snapshot(sequence, *view_model); - } else { - log::debug!("[ui-client] dropped stale snapshot #{sequence}"); } } Ok(HostToWindow::Pong { sequence }) => { diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 8e2dc3a71..ded6f9857 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -8067,6 +8067,12 @@ Internal flags (set by OpenLess itself, not for regular use): let (mut ready, mut hotkeys, mut snapshot, mut focus) = (false, false, false, false); while !(ready && hotkeys && snapshot && focus) { + // 快照走单槽位,不从通道出。 + if let Some((sequence, _)) = client.take_snapshot() { + assert!(ready && sequence > previous_sequence); + previous_sequence = sequence; + snapshot = true; + } match client.try_recv() { Ok(HostToWindow::Ready { .. }) => ready = true, Ok(HostToWindow::Hotkeys { .. }) => { diff --git a/openless-all/app/linux-egui/src/ui/bridge.rs b/openless-all/app/linux-egui/src/ui/bridge.rs index 70c96e941..dac73dae7 100644 --- a/openless-all/app/linux-egui/src/ui/bridge.rs +++ b/openless-all/app/linux-egui/src/ui/bridge.rs @@ -17,6 +17,7 @@ use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; +use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::Duration; @@ -24,6 +25,12 @@ use serde::{Deserialize, Serialize}; use super::frontend::view_model::{FrontendAction, FrontendViewModel}; +/// 最新一份快照的单一槽位:`(sequence, view_model)`。 +/// +/// 取名是为了表达意图,也让 clippy 的 `type_complexity` 不再对 +/// `Arc>>` 报错。 +pub type LatestSnapshot = Arc)>>>; + /// 协议版本:宿主与 UI 进程对不上就直接拒绝启动 UI(避免半懂不懂地渲染)。 pub const UI_BRIDGE_VERSION: u32 = 3; @@ -534,6 +541,15 @@ fn spawn_connection(stream: UnixStream) -> std::io::Result { /// UI 进程侧连接:连上宿主、收快照、发动作。 pub struct UiBridgeClient { incoming: Receiver, + /// 最新一份视图模型快照的单一槽位。 + /// + /// 快照**不走** `incoming`:宿主每 2 秒发一份保活快照 + /// (`publish_view_model`),而窗口被遮挡 / 最小化时 eframe 会完全跳过 + /// egui pass(eframe 的 `if !show_ui`),`ui()` 根本不会被调用,也就不会 + /// 排空任何队列。快照若是进无界通道,遮挡一晚上就是上万份堆在内存里 + /// (现场实测:21 小时 5.8 GB,窗口恢复可见后还要逐份消化,表现为卡死)。 + /// 只留最新一份即可 —— 中间那些本来就会被后一份覆盖,没有任何价值。 + latest_snapshot: LatestSnapshot, outgoing: Sender, reader: Option>, writer: Option>, @@ -580,6 +596,8 @@ impl UiBridgeClient { let (incoming_tx, incoming_rx) = mpsc::channel(); let (outgoing_tx, outgoing_rx) = mpsc::channel::(); let reader_outgoing = outgoing_tx.clone(); + let latest_snapshot: LatestSnapshot = Arc::new(Mutex::new(None)); + let reader_snapshot = Arc::clone(&latest_snapshot); let reader = std::thread::Builder::new() .name("openless-ui-client-reader".into()) .spawn(move || { @@ -612,11 +630,22 @@ impl UiBridgeClient { } loop { match read_frame::(&mut reader) { - Ok(Some(frame)) => { - if incoming_tx.send(frame).is_err() { - break; + Ok(Some(frame)) => match frame { + // 快照只进单槽位,覆盖旧的那份;其余帧照走通道。 + HostToWindow::Snapshot { + sequence, + view_model, + } => { + if let Ok(mut slot) = reader_snapshot.lock() { + *slot = Some((sequence, view_model)); + } } - } + other => { + if incoming_tx.send(other).is_err() { + break; + } + } + }, Ok(None) => break, Err(error) => { log::warn!("[ui-client] host frame error: {error}"); @@ -645,6 +674,7 @@ impl UiBridgeClient { }; Ok(Self { incoming: incoming_rx, + latest_snapshot, outgoing: outgoing_tx, reader: Some(reader), writer: Some(writer), @@ -670,6 +700,17 @@ impl UiBridgeClient { Ok(frame) } + /// 取走当前最新的视图模型快照;没有新快照就返回 `None`。 + /// + /// 取走即清空槽位,所以窗口每帧调用一次就能拿到「最后一次覆盖后的状态」, + /// 内存占用与「宿主发过多少份」无关。 + pub fn take_snapshot(&mut self) -> Option<(u64, Box)> { + self.latest_snapshot + .lock() + .ok() + .and_then(|mut slot| slot.take()) + } + pub fn send(&mut self, frame: WindowToHost) -> Result<(), String> { if !self.ready { return Err("UI handshake is not ready".into()); @@ -755,23 +796,14 @@ mod tests { }; let payload = serde_json::to_vec(&view_model).unwrap(); host.send_snapshot_encoded(&payload); - let frame = loop { - match client.try_recv() { - Ok(frame) => break frame, - Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)), - Err(TryRecvError::Disconnected) => panic!("client disconnected early"), - } - }; - match frame { - HostToWindow::Snapshot { - sequence, - view_model, - } => { - assert_eq!(sequence, 1, "first snapshot is sequence 1"); - assert_eq!(view_model.active_page, Page::History); - } - other => panic!("unexpected frame {other:?}"), - } + // 快照从单槽位取(不进通道),所以这里轮询 `take_snapshot`。 + let (sequence, view_model) = wait_for(|| client.take_snapshot()); + assert_eq!(sequence, 1, "first snapshot is sequence 1"); + assert_eq!(view_model.active_page, Page::History); + assert!( + client.take_snapshot().is_none(), + "taking a snapshot must empty the slot" + ); host.shutdown(); client.shutdown(); let _ = std::fs::remove_dir_all(&dir); @@ -801,11 +833,10 @@ mod tests { // 连发三份快照:写线程可能合并掉中间那些(UI 卡顿时宁可跳帧), // 但**到达 UI 的序号必须严格递增**,而且最后一份必然是最新的。 while std::time::Instant::now() < deadline { - match client.try_recv() { - Ok(HostToWindow::Snapshot { sequence, .. }) => sequences.push(sequence), - Ok(_) => {} - Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)), - Err(TryRecvError::Disconnected) => break, + if let Some((sequence, _)) = client.take_snapshot() { + sequences.push(sequence); + } else { + std::thread::sleep(Duration::from_millis(10)); } if sequences.last().copied() == Some(3) { break; @@ -825,6 +856,50 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn an_undrained_window_keeps_only_the_newest_snapshot() { + // 现场:窗口被遮挡时 eframe **完全跳过 egui pass**(`if !show_ui`), + // `ui()` 不会被调用,而宿主每 2 秒发一份保活快照。21 小时≈3.7 万份, + // 堆在无界通道里就是 5.8 GB。这里连发 40 份且一份不取,验证内存只由 + // 「最新一份」决定:槽位里只有一份,通道里一句快照也没有。 + let dir = temp_dir("undrained"); + let path = ui_socket_path(&dir); + let mut host = UiBridgeHost::bind(path.clone()).unwrap(); + let mut client = UiBridgeClient::connect(&path).unwrap(); + host.accept_pending(); + wait_for(|| { + host.drain(); + host.is_connected().then_some(()) + }); + assert!(matches!( + wait_for(|| client.try_recv().ok()), + HostToWindow::Ready { .. } + )); + + let payload = serde_json::to_vec(&FrontendViewModel { + active_page: Page::History, + ..Default::default() + }) + .unwrap(); + for _ in 0..40 { + host.send_snapshot_encoded(&payload); + } + // 中间那些必须被覆盖掉,只留最后一份(序号 40)。 + let (sequence, _) = wait_for(|| client.take_snapshot().filter(|(seq, _)| *seq == 40)); + assert_eq!(sequence, 40); + assert!( + client.take_snapshot().is_none(), + "taking a snapshot must empty the slot" + ); + assert!( + matches!(client.try_recv(), Err(TryRecvError::Empty)), + "snapshots must never queue in the unbounded channel" + ); + host.shutdown(); + client.shutdown(); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_dead_bridge_socket_is_replaced_on_bind() { let dir = temp_dir("stale"); From 9f2a0c99a4a7d835c76a1a9e481c5d2d7214bc5b Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Mon, 28 Sep 2026 21:27:43 +0800 Subject: [PATCH 26/35] feat(linux-egui): match the Tauri AI-services page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以 Tauri(`src/pages/settings/`)为唯一标准,把「AI 服务与模型 → 语言模型」 这一页补到对齐。用户在 Linux/Windows 两边截图对比后指出「差的有点多」。 新增(Tauri 有、我们以前没有): 1. **识别管线**(`ProvidersSection` 的 pipelineMode 行):`识别管线 ⓘ` + `[传统模式][多模态模式]` 分段控件 + 「两种模式使用完全独立的凭据配置…」 说明。只在实验性开关 `multimodalPipelineEnabled` 打开后出现 —— 与 Tauri 的 `if (prefs?.multimodalPipelineEnabled !== true) return null` 同条件。 切换只改偏好(`pipeline_mode`),两套凭据都留在凭据库里。 2. **渠道卡片**(`ChannelList` 的行)改成 Tauri 的结构: - 圆角 12 卡片,仅「当前使用」的渠道有中性灰底 + 0.5px 描边(Tauri 特意 不用蓝底竖条);第二行 `provider · model`(模型等宽),名字为空时标题 回退成供应商名(`channel.name.trim() || providerLabel`)。 - 状态块:`上次验证` + 通过(+耗时)/ 失败(+错误 + 「验证失败不会自动停用…」) / 尚未验证,再跟「多久以前」,超过 24 小时追加「结果已超过 24 小时」。 - 右侧只留「重新验证 / 启用 / 编辑 ›」(编辑按钮带铅笔与右尖括号图标)。 - 换顺序靠左侧拖拽手柄(新图标 `Grip`,走 lucide 同步管道),不再用 ↑↓。 - 点「验证」后按钮立刻变「正在验证…」并禁用,结果回包或超时后恢复 (Tauri 的 `testingIds` 也是界面本地状态)。 - 供应商选择与「删除渠道」(两步确认)移进编辑面板 —— Tauri 把它们放在 ChannelModal 里,卡片上不出现;卡片下方保留「凭据保存在系统凭据库中。」 数据管道:`SettingsChannel` 补 `id`,并把原来宿主预格式化的 `last_check` 字符串 换成结构化字段(`last_ok` / `last_error` / `last_latency_ms` / `last_check_age_seconds`),本地化仍全部走 i18n 目录。 文案:新增 15 个 key(识别管线 5 个、渠道状态 10 个),值取自 Tauri 五语目录, `sync-egui-i18n.mjs` 报告全部 in sync。 测试:`the_pipeline_mode_row_only_shows_with_the_experimental_switch`、 `a_failed_channel_card_explains_the_failure_and_the_stale_result`、 `a_verify_click_marks_the_channel_as_testing_until_the_result_arrives`; 识别管线那条做过负向验证(去掉开关判断即失败)。 package revision 121。 --- .../app/linux-egui/assets/lucide/Grip.svg | 1 + openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/i18n.rs | 156 +++++ .../linux-egui/src/linux_app/settings_save.rs | 9 + openless-all/app/linux-egui/src/main.rs | 34 +- .../app/linux-egui/src/ui/frontend/icons.rs | 4 + .../app/linux-egui/src/ui/frontend/mod.rs | 102 +++ .../linux-egui/src/ui/frontend/settings.rs | 647 +++++++++++++----- .../linux-egui/src/ui/frontend/view_model.rs | 15 +- .../app/scripts/sync-egui-lucide-icons.mjs | 2 +- 10 files changed, 791 insertions(+), 181 deletions(-) create mode 100644 openless-all/app/linux-egui/assets/lucide/Grip.svg diff --git a/openless-all/app/linux-egui/assets/lucide/Grip.svg b/openless-all/app/linux-egui/assets/lucide/Grip.svg new file mode 100644 index 000000000..0c78def14 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Grip.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 52bd8e43a..9289ddcee 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -120 +121 diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 1267467a0..fbbce7c51 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -2968,6 +2968,158 @@ pub const CATALOG: &[Msg] = &[ "자격 증명은 OS 자격 증명 저장소에 보관됩니다.", ), }, + Msg { + key: "settings.channels.provider_label", + text: row("供应商", "供應商", "Provider", "プロバイダー", "공급자"), + }, + Msg { + key: "settings.channels.drag_hint", + text: row( + "按住拖动可调整优先级", + "按住拖曳可調整優先順序", + "Drag to change priority", + "ドラッグで優先順位を変更", + "드래그해서 우선순위 변경", + ), + }, + Msg { + key: "settings.channels.delete_confirm", + text: row( + "删除后该渠道保存的密钥也会一并清除。", + "刪除後該渠道儲存的金鑰也會一併清除。", + "Deleting also clears the keys stored for this channel.", + "削除するとこのチャネルに保存された鍵も消去されます。", + "삭제하면 이 채널에 저장된 키도 함께 지워집니다.", + ), + }, + Msg { + key: "settings.channels.confirm_delete", + text: row("确认删除", "確認刪除", "Delete", "削除する", "삭제"), + }, + Msg { + key: "settings.channels.last_check", + text: row( + "上次验证", + "上次驗證", + "Last check", + "前回の接続確認", + "마지막 확인", + ), + }, + Msg { + key: "settings.channels.verifying", + text: row( + "正在验证…", + "正在驗證…", + "Checking…", + "確認中…", + "확인 중…", + ), + }, + Msg { + key: "settings.channels.reverify", + text: row("重新验证", "重新驗證", "Check again", "再確認", "다시 확인"), + }, + Msg { + key: "settings.channels.failed_plain", + text: row("验证失败", "驗證失敗", "Check failed", "確認に失敗", "확인 실패"), + }, + Msg { + key: "settings.channels.failure_keeps_enabled", + text: row( + "验证失败不会自动停用。请求仍使用列表中第一个已启用的渠道,不会自动改用下一个。", + "驗證失敗不會自動停用。請求仍使用列表中第一個已啟用的渠道,不會自動改用下一個。", + "A failed check does not turn this service off. Requests still use the first enabled service and do not switch to the next one.", + "確認に失敗しても、このサービスは自動では停止しません。リクエストは有効な一覧の先頭を使い、次へは自動で切り替わりません。", + "확인에 실패해도 이 서비스는 자동으로 꺼지지 않습니다. 요청은 켜진 목록의 첫 항목을 쓰며, 다음 항목으로 자동 전환되지 않습니다.", + ), + }, + Msg { + key: "settings.channels.stale_result", + text: row( + "结果已超过 24 小时", + "結果已超過 24 小時", + "Result is over 24 hours old", + "24 時間以上前の結果", + "24시간이 지난 결과", + ), + }, + Msg { + key: "settings.channels.just_now", + text: row("刚刚", "剛剛", "just now", "たった今", "방금"), + }, + Msg { + key: "settings.channels.minutes_ago", + text: row("{} 分钟前", "{} 分鐘前", "{}m ago", "{}分前", "{}분 전"), + }, + Msg { + key: "settings.channels.hours_ago", + text: row("{} 小时前", "{} 小時前", "{}h ago", "{}時間前", "{}시간 전"), + }, + Msg { + key: "settings.channels.days_ago", + text: row("{} 天前", "{} 天前", "{}d ago", "{}日前", "{}일 전"), + }, + Msg { + key: "settings.channels.model_not_set", + text: row( + "模型未单独设置", + "未單獨設定模型", + "No model set explicitly", + "モデルの個別設定なし", + "모델을 별도로 설정하지 않음", + ), + }, + Msg { + key: "settings.providers.pipeline_mode_label", + text: row( + "识别管线", + "識別管線", + "Pipeline mode", + "認識パイプライン", + "인식 파이프라인", + ), + }, + Msg { + key: "settings.providers.pipeline_mode_hint", + text: row( + "传统 = ASR 转写 + LLM 润色两段式;多模态 = 单个多模态模型一次完成。", + "傳統 = ASR 轉寫 + LLM 潤色兩段式;多模態 = 單一多模態模型一次完成。", + "Traditional = ASR + LLM two-stage. Multimodal = a single audio-capable model in one pass.", + "従来 = ASR 文字起こし + LLM 整形の2段式。マルチモーダル = 音声対応モデルが1回で完了。", + "전통 = ASR 전사 + LLM 다듬기 2단계. 멀티모달 = 오디오 지원 모델이 한 번에 처리.", + ), + }, + Msg { + key: "settings.providers.pipeline_mode_traditional", + text: row( + "传统模式", + "傳統模式", + "Traditional", + "従来モード", + "전통 모드", + ), + }, + Msg { + key: "settings.providers.pipeline_mode_multimodal", + text: row( + "多模态模式", + "多模態模式", + "Multimodal", + "マルチモーダルモード", + "멀티모달 모드", + ), + }, + Msg { + key: "settings.providers.pipeline_isolation_notice", + text: row( + "两种模式使用完全独立的凭据配置。切换模式不会删除另一套配置,只是暂时停用;切回即恢复。", + "兩種模式使用完全獨立的憑證設定。切換模式不會刪除另一套設定,只是暫時停用;切回即還原。", + "The two modes keep fully separate credentials. Switching modes keeps the other set stored but unused; switching back restores it.", + "2つのモードは完全に独立した認証情報を使用します。切り替えてももう一方の設定は削除されず、切り戻せば復元されます。", + "두 모드는 완전히 분리된 자격 증명을 사용합니다. 전환해도 다른 쪽 설정은 삭제되지 않으며, 다시 전환하면 복원됩니다.", + ), + }, Msg { key: "settings.recording.audio_cue_label", text: row( @@ -5718,6 +5870,10 @@ pub const CATALOG: &[Msg] = &[ text: row("书面润色", "書面潤色", "Written polish", "書面の推敲", "서면 다듬기"), }, + Msg { + key: "settings.channels.edit", + text: row("编辑", "編輯", "Edit", "編集", "편집"), + }, ]; fn lang_index(lang: Lang) -> usize { diff --git a/openless-all/app/linux-egui/src/linux_app/settings_save.rs b/openless-all/app/linux-egui/src/linux_app/settings_save.rs index 3a4f3a2a6..2e2c006d0 100644 --- a/openless-all/app/linux-egui/src/linux_app/settings_save.rs +++ b/openless-all/app/linux-egui/src/linux_app/settings_save.rs @@ -273,6 +273,15 @@ impl OpenLessEguiApp { _ => openless_core::shared_types::SelectionPolishOutputMode::DirectReplace, }; } + frontend::view_model::SettingsComboField::PipelineMode => { + // 与 Tauri 的 `onPipelineModeChange` 一致:只改偏好。两套模式的 + // 凭据都留在凭据库里,运行时只读当前模式。 + preferences.pipeline_mode = if index == 1 { + openless_core::shared_types::PipelineMode::Multimodal + } else { + openless_core::shared_types::PipelineMode::Traditional + }; + } frontend::view_model::SettingsComboField::SilenceSeconds => { preferences.silence_auto_stop_seconds = index as f32 + 1.0; } diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index ded6f9857..7266ff7cd 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -135,6 +135,8 @@ mod linux_app { last_ok: Option, last_latency_ms: Option, last_error: Option, + /// 最近一次验证的时间(epoch **秒**,与 Tauri `lastTest.at` 同一量纲)。 + last_test_at: Option, } /// 追问编辑态:Core 只在变化时下发 `Some(..)`,所以逐字段合并。 @@ -1910,6 +1912,7 @@ mod linux_app { .last_test .as_ref() .and_then(|test| test.error.clone()), + last_test_at: channel.last_test.as_ref().map(|test| test.at), }); } Ok::<_, BackendError>(rows) @@ -4083,6 +4086,7 @@ mod linux_app { .iter() .enumerate() .map(|(index, channel)| frontend::view_model::SettingsChannel { + id: channel.id.clone(), name: channel.name.clone(), provider: localized_provider_label( lang, @@ -4093,26 +4097,16 @@ mod linux_app { model: channel.model.clone(), is_active: index == active_channel, enabled: channel.enabled, - last_check: match ( - channel.last_ok, - channel.last_latency_ms, - channel.last_error.as_deref(), - ) { - (Some(true), Some(ms), _) => Some(format!( - "{} · {}", - tr_l10n(lang, "settings.channels.passed"), - fmt_l10n(lang, "settings.channels.elapsed", &[&ms]), - )), - (Some(true), None, _) => { - Some(tr_l10n(lang, "settings.channels.passed").to_string()) - } - (Some(false), _, error) => Some(fmt_l10n( - lang, - "settings.channels.failed", - &[&error.unwrap_or_default()], - )), - _ => None, - }, + last_ok: channel.last_ok, + last_error: channel.last_error.clone(), + last_latency_ms: channel.last_latency_ms, + last_check_age_seconds: channel.last_test_at.map(|at| { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs() as i64) + .unwrap_or(at); + (now - at).max(0) + }), }) .collect(); diff --git a/openless-all/app/linux-egui/src/ui/frontend/icons.rs b/openless-all/app/linux-egui/src/ui/frontend/icons.rs index b0a1fe75b..ef6c08edd 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/icons.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/icons.rs @@ -38,6 +38,9 @@ pub enum IconName { Layout, Doc, Pencil, + /// 渠道卡片左侧的拖拽手柄(Tauri 用字符 `⠿`;egui 这边用同一套 Lucide + /// 图标的 GripVertical,字形风险比外字符小)。 + Grip, Cloud, Shield, Info, @@ -109,6 +112,7 @@ fn lucide_svg(icon: IconName) -> Option<(&'static str, &'static str)> { IconName::Layout => ("Layout", include_str!("../../../assets/lucide/Layout.svg")), IconName::Doc => ("Doc", include_str!("../../../assets/lucide/Doc.svg")), IconName::Pencil => ("Pencil", include_str!("../../../assets/lucide/Pencil.svg")), + IconName::Grip => ("Grip", include_str!("../../../assets/lucide/Grip.svg")), IconName::Cloud => ("Cloud", include_str!("../../../assets/lucide/Cloud.svg")), IconName::Shield => ("Shield", include_str!("../../../assets/lucide/Shield.svg")), IconName::Info => ("Info", include_str!("../../../assets/lucide/Info.svg")), diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index 0f1170b91..7fc4d653a 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -927,6 +927,108 @@ mod tests { lines } + /// 识别管线(Tauri `ProvidersSection` 的 pipelineMode 行):行本身、两个模式 + /// 选项和“两套凭据互相独立”的说明;只有实验性开关 + /// (`multimodalPipelineEnabled` → 这里的 `multimodal_view`)打开后才出现。 + #[test] + fn the_pipeline_mode_row_only_shows_with_the_experimental_switch() { + let zh = openless_linux_egui::Lang::ZhCn; + let mut vm = FrontendViewModel { + multimodal_view: true, + pipeline_multimodal: false, + ..Default::default() + }; + let on = painted_settings_lines(super::view_model::SettingsSection::Services, &mut vm); + for key in [ + "settings.providers.pipeline_mode_label", + "settings.providers.pipeline_mode_traditional", + "settings.providers.pipeline_mode_multimodal", + "settings.providers.pipeline_isolation_notice", + ] { + let text = openless_linux_egui::tr_l10n(zh, key); + assert!( + on.iter().any(|line| line == text), + "{key} must be painted when the switch is on: {on:?}" + ); + } + + vm.multimodal_view = false; + let off = painted_settings_lines(super::view_model::SettingsSection::Services, &mut vm); + for key in [ + "settings.providers.pipeline_mode_label", + "settings.providers.pipeline_isolation_notice", + ] { + let text = openless_linux_egui::tr_l10n(zh, key); + assert!( + !off.iter().any(|line| line == text), + "{key} must disappear when the experimental switch is off" + ); + } + } + + /// 渠道卡片的状态块(Tauri `ChannelTestStatus`):失败的渠道要给出错误、 + /// 「不会自动停用」的说明、多久以前,以及超过 24 小时的过期提示;有结果 + /// 时验证按钮变成「重新验证」。 + #[test] + fn a_failed_channel_card_explains_the_failure_and_the_stale_result() { + let zh = openless_linux_egui::Lang::ZhCn; + let mut vm = FrontendViewModel { + channels: vec![super::view_model::SettingsChannel { + id: "deepseek-1".into(), + name: "DeepSeek".into(), + provider: "DeepSeek".into(), + provider_type: "deepseek".into(), + model: "deepseek-v4-flash".into(), + is_active: true, + enabled: true, + last_ok: Some(false), + last_error: Some("-401".into()), + last_latency_ms: None, + // 25 小时前:既要有「1 天前」,也要有过期提示。 + last_check_age_seconds: Some(25 * 60 * 60), + }], + ..Default::default() + }; + let lines = painted_settings_lines(super::view_model::SettingsSection::Services, &mut vm); + let painted = |text: &str| lines.iter().any(|line| line.contains(text)); + for text in [ + openless_linux_egui::tr_l10n(zh, "settings.channels.last_check"), + &openless_linux_egui::fmt_l10n(zh, "settings.channels.failed", &[&"-401"]), + openless_linux_egui::tr_l10n(zh, "settings.channels.failure_keeps_enabled"), + openless_linux_egui::tr_l10n(zh, "settings.channels.reverify"), + &openless_linux_egui::fmt_l10n(zh, "settings.channels.days_ago", &[&1]), + openless_linux_egui::tr_l10n(zh, "settings.channels.stale_result"), + ] { + assert!(painted(text), "{text:?} must be painted: {lines:?}"); + } + assert!( + !lines + .iter() + .any(|line| line.trim() + == openless_linux_egui::tr_l10n(zh, "settings.channels.verify")), + "a channel with a past result offers a re-check, not a first check" + ); + + // 从未验证过的渠道:只说「尚未验证」,不给出过期/失败字样。 + vm.channels[0].last_ok = None; + vm.channels[0].last_error = None; + vm.channels[0].last_check_age_seconds = None; + let fresh = painted_settings_lines(super::view_model::SettingsSection::Services, &mut vm); + let painted = |text: &str| fresh.iter().any(|line| line.contains(text)); + assert!(painted(openless_linux_egui::tr_l10n( + zh, + "settings.channels.not_verified" + ))); + assert!(!painted(openless_linux_egui::tr_l10n( + zh, + "settings.channels.stale_result" + ))); + assert!(!painted(openless_linux_egui::tr_l10n( + zh, + "settings.channels.reverify" + ))); + } + #[test] fn shortcut_menu_reveals_record_and_disable() { let zh = openless_linux_egui::Lang::ZhCn; diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index 4186f18c2..05d44e9f2 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -1918,6 +1918,36 @@ fn services(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec, ) { - ui.horizontal(|ui| { - ui.vertical(|ui| { + // 拖动中的行号;只存在 egui 的临时数据里,不进视图模型(它不是宿主状态)。 + let drag_id = egui::Id::new("openless-settings-channel-drag"); + let dragging = ui.data(|data| data.get_temp::(drag_id)); + + let card = egui::Frame::new() + // 当前使用中的渠道:中性灰底 + 细描边。Tauri 特意不用蓝底竖条 —— + //「当前使用」徽章已经说明问题,整行染色太花哨。 + .fill(if channel.is_active { + theme::SURFACE_2 + } else { + egui::Color32::TRANSPARENT + }) + .stroke(if channel.is_active { + egui::Stroke::new(0.5, theme::LINE) + } else { + egui::Stroke::NONE + }) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::symmetric(12, 14)) + .show(ui, |ui| { ui.horizontal(|ui| { - ui.label( - egui::RichText::new(&channel.name) - .size(12.5) - .strong() - .color(theme::INK), + ui.spacing_mut().item_spacing.x = 10.0; + + // ── 拖拽手柄(Tauri 的 `⠿`) + let (handle_rect, handle) = + ui.allocate_exact_size(egui::vec2(16.0, 24.0), egui::Sense::drag()); + let lifted = dragging == Some(index); + icons::draw_icon_sized( + ui, + handle_rect.center(), + IconName::Grip, + if lifted || handle.hovered() { + theme::INK + } else { + theme::INK_4 + }, + 16.0, ); - if channel.is_active { - egui::Frame::new() - .fill(theme::BLUE_SOFT) - .corner_radius(egui::CornerRadius::same(9)) - .inner_margin(egui::Margin::symmetric(7, 2)) - .show(ui, |ui| { + if handle.hovered() || lifted { + handle + .clone() + .on_hover_text(tr_l10n(lang, "settings.channels.drag_hint")); + } + if handle.drag_started() { + ui.data_mut(|data| data.insert_temp(drag_id, index)); + } + if handle.drag_stopped() { + ui.data_mut(|data| data.remove_temp::(drag_id)); + } + + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + // Tauri:`const label = channel.name.trim() || providerLabel` + // —— 用户没给渠道起名时,标题就是供应商名。 + let title = if channel.name.trim().is_empty() { + channel.provider.as_str() + } else { + channel.name.trim() + }; + ui.label( + egui::RichText::new(title) + .size(14.0) + .strong() + .color(theme::INK), + ); + if channel.is_active { + egui::Frame::new() + .fill(theme::BLUE_SOFT) + .corner_radius(egui::CornerRadius::same(9)) + .inner_margin(egui::Margin::symmetric(7, 2)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(tr_l10n( + lang, + "settings.channels.current", + )) + .size(10.0) + .color(theme::BLUE), + ); + }); + } + if !channel.enabled { + egui::Frame::new() + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(9)) + .inner_margin(egui::Margin::symmetric(7, 2)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(tr_l10n( + lang, + "settings.channels.disabled", + )) + .size(10.0) + .color(theme::INK_4), + ); + }); + } + }); + // 第二行:`provider · model`,模型用等宽(Tauri 的 ol-font-mono)。 + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + // 第二行的供应商前缀只在标题用了自定义名字时才加,否则 + // 会和标题重复(Tauri 的 `{channel.name.trim() && …}`)。 + if !channel.name.trim().is_empty() { ui.label( - egui::RichText::new(tr_l10n(lang, "settings.channels.current")) - .size(10.0) - .color(theme::BLUE), + egui::RichText::new(format!("{} ·", channel.provider)) + .size(12.0) + .color(theme::INK_3), ); - }); - } - if !channel.enabled { + } + let model = if channel.model.trim().is_empty() { + egui::RichText::new(tr_l10n(lang, "settings.channels.model_not_set")) + .size(12.0) + .color(theme::INK_3) + } else { + egui::RichText::new(channel.model.trim()) + .size(12.0) + .monospace() + .color(theme::INK_3) + }; + ui.label(model); + }); + channel_status_line(ui, channel, lang); + }); + + // ── 右侧动作:与 Tauri 同序(右到左压栈 → 屏幕顺序:验证、启用、编辑) + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + channel_edit_button(ui, lang, || { + actions.push(FrontendAction::SettingsChannelSelect(index)); + }); + ui.add_space(8.0); + let (switch, _) = + ui.allocate_exact_size(egui::vec2(36.0, 20.0), egui::Sense::hover()); + if layout::toggle(ui, switch, channel.enabled, ("settings-channel", index)) + .clicked() + { + actions.push(FrontendAction::SettingsChannelToggle(index)); + } ui.label( - egui::RichText::new(tr_l10n(lang, "settings.channels.disabled")) - .size(10.5) - .color(theme::INK_4), + egui::RichText::new(tr_l10n(lang, "settings.channels.enabled")) + .size(11.0) + .color(theme::INK_3), ); - } - }); - let detail = if channel.model.trim().is_empty() { - channel.provider.clone() - } else { - format!("{} · {}", channel.provider, channel.model) - }; - ui.label(egui::RichText::new(detail).size(11.0).color(theme::INK_3)); - let last_check = channel - .last_check - .clone() - .unwrap_or_else(|| tr_l10n(lang, "settings.channels.not_verified").to_string()); - ui.label( - egui::RichText::new(last_check) - .size(10.5) - .color(theme::INK_4), - ); - }); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // 编辑入口:选中渠道后由宿主向 Core 读回该渠道的描述符与凭据形态。 - if ui - .add( - egui::Button::new(egui::RichText::new(tr_l10n(lang, "btn.edit")).size(11.0)) - .fill(theme::SURFACE_2) - .stroke(egui::Stroke::new(0.8, theme::LINE)) - .corner_radius(egui::CornerRadius::same(8)) - .min_size(egui::vec2(0.0, 24.0)), - ) - .clicked() - { - actions.push(FrontendAction::SettingsChannelSelect(index)); - } - for (label, delta) in [("↑", -1isize), ("↓", 1isize)] { - if ui - .add( - egui::Button::new(egui::RichText::new(label).size(11.0)) + ui.add_space(8.0); + let testing = channel_testing(ui, channel); + let verify = if testing { + tr_l10n(lang, "settings.channels.verifying") + } else if channel.last_ok.is_some() { + tr_l10n(lang, "settings.channels.reverify") + } else { + tr_l10n(lang, "settings.channels.verify") + }; + let response = ui.add_enabled( + !testing, + egui::Button::new(egui::RichText::new(verify).size(11.0)) .fill(theme::SURFACE_2) .stroke(egui::Stroke::new(0.8, theme::LINE)) .corner_radius(egui::CornerRadius::same(8)) - .min_size(egui::vec2(22.0, 24.0)), - ) - .clicked() - { - actions.push(FrontendAction::SettingsChannelMove { index, delta }); - } - } - // 渠道的 provider 类型就是 Core 的 `set_channel_provider_type`: - // 换类型等于换描述符,因此比编辑表单更早生效。 - if !providers.is_empty() { - let selected = providers - .iter() - .position(|provider| provider.provider_type == channel.provider_type) - .unwrap_or(0); - let mut picked = selected; - egui::ComboBox::from_id_salt(("settings-channel-provider", index)) - .selected_text(&channel.provider) - .width(150.0) - .show_ui(ui, |ui| { - for (option_index, provider) in providers.iter().enumerate() { - if ui - .selectable_label(option_index == selected, &provider.label) - .clicked() - { - picked = option_index; - ui.close(); - } - } - }); - if picked != selected { - actions.push(FrontendAction::SettingsChannelProviderType { - index, - provider_type: providers[picked].provider_type.clone(), - }); - } - } - // 当前生效的渠道由 Core 记录:界面只读「哪个是当前」(is_active), - // 并通过 Core 切换,不自己判定谁该生效。 - if !channel.is_active - && ui - .add( - egui::Button::new( - egui::RichText::new(tr_l10n(lang, "btn.activate")).size(11.0), - ) - .fill(theme::SURFACE_2) - .stroke(egui::Stroke::new(0.8, theme::LINE)) - .corner_radius(egui::CornerRadius::same(8)) - .min_size(egui::vec2(0.0, 24.0)), - ) - .clicked() - { - actions.push(FrontendAction::SettingsChannelActivate(index)); - } - if ui - .add( - egui::Button::new( - egui::RichText::new(tr_l10n(lang, "settings.channels.delete")).size(11.0), - ) - .fill(theme::SURFACE_2) - .stroke(egui::Stroke::new(0.8, theme::LINE)) - .corner_radius(egui::CornerRadius::same(8)) - .min_size(egui::vec2(0.0, 24.0)), - ) - .clicked() - { - actions.push(FrontendAction::SettingsChannelDelete(index)); + .min_size(egui::vec2(0.0, 24.0)), + ); + if response.clicked() { + mark_channel_testing(ui, channel); + actions.push(FrontendAction::SettingsChannelValidate(index)); + } + }); + }); + }) + .response; + + // 拖动中越过邻居的中线就互换 —— Tauri 是实时重排,这里用「越线即交换 ±1」 + // 得到同样的手感,而且不需要先知道邻居的高度。 + if dragging == Some(index) { + if let Some(pointer) = ui.ctx().pointer_latest_pos() { + if pointer.y > card.rect.bottom() + 2.0 { + actions.push(FrontendAction::SettingsChannelMove { index, delta: 1 }); + } else if pointer.y < card.rect.top() - 2.0 { + actions.push(FrontendAction::SettingsChannelMove { index, delta: -1 }); } - let (switch, _) = ui.allocate_exact_size(egui::vec2(36.0, 20.0), egui::Sense::hover()); - if layout::toggle(ui, switch, channel.enabled, ("settings-channel", index)).clicked() { - actions.push(FrontendAction::SettingsChannelToggle(index)); + } + } +} + +/// 验证请求在飞的那几秒:按钮变「正在验证…」并禁用。 +/// +/// Tauri 的 `testingIds` 同样是**界面本地**状态(promise 落地即清除),所以这里 +/// 也放在 egui 的临时数据里:结果回包(`last_check_age_seconds` 变新)或超时即恢复。 +fn channel_testing(ui: &egui::Ui, channel: &super::view_model::SettingsChannel) -> bool { + /// 快照里出现这么新的结果就算回包了。 + const FRESH_RESULT_SECONDS: i64 = 30; + + let id = egui::Id::new(("settings-channel-testing", &channel.id)); + let Some(deadline) = ui.data(|data| data.get_temp::(id)) else { + return false; + }; + let expired = ui.input(|input| input.time) >= deadline; + let answered = channel + .last_check_age_seconds + .is_some_and(|age| age <= FRESH_RESULT_SECONDS); + if expired || answered { + ui.data_mut(|data| data.remove_temp::(id)); + return false; + } + true +} + +fn mark_channel_testing(ui: &egui::Ui, channel: &super::view_model::SettingsChannel) { + /// 与 `channel_testing` 的上限一致。 + const MAX_TESTING_SECONDS: f64 = 30.0; + let id = egui::Id::new(("settings-channel-testing", &channel.id)); + let now = ui.input(|input| input.time); + ui.data_mut(|data| data.insert_temp(id, now + MAX_TESTING_SECONDS)); +} + +/// Tauri 的 `.ol-channel-edit-button`:铅笔 + 「编辑」+ 右尖括号。 +fn channel_edit_button(ui: &mut egui::Ui, lang: Lang, mut on_click: impl FnMut()) { + let label = tr_l10n(lang, "settings.channels.edit"); + let width = 14.0 + 6.0 + layout::text_width(ui, label, 11.0) + 6.0 + 14.0 + 12.0; + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 24.0), egui::Sense::click()); + if response.hovered() { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::SURFACE_2); + } + let icon_size = 14.0; + let mut cursor = rect.left() + 6.0; + icons::draw_icon_sized( + ui, + egui::pos2(cursor + icon_size / 2.0, rect.center().y), + IconName::Pencil, + theme::INK_2, + icon_size, + ); + cursor += icon_size + 6.0; + ui.painter().text( + egui::pos2(cursor, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(11.0), + theme::INK_2, + ); + cursor += layout::text_width(ui, label, 11.0) + 6.0; + icons::draw_icon_sized( + ui, + egui::pos2(cursor + icon_size / 2.0, rect.center().y), + IconName::ChevronRight, + theme::INK_4, + icon_size, + ); + if response.clicked() { + on_click(); + } +} + +/// 「上次验证 …」状态块(Tauri `ChannelList` 的 `ChannelTestStatus`)。 +/// +/// 结构:`上次验证` + 结果(通过 + 耗时,或失败 + 错误 + 「验证失败不会自动停用」 +/// 说明)+ 多久以前;结果超过 24 小时再加一条过期提示。 +fn channel_status_line( + ui: &mut egui::Ui, + channel: &super::view_model::SettingsChannel, + lang: Lang, +) { + /// 与 Tauri `STALE_TEST_SECONDS` 一致:一天前的验证结果不可信。 + const STALE_AFTER_SECONDS: i64 = 24 * 60 * 60; + + ui.add_space(2.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.last_check")) + .size(11.0) + .color(theme::INK_3), + ); + let Some(passed) = channel.last_ok else { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.not_verified")) + .size(11.0) + .color(theme::INK_3), + ); + return; + }; + let stale = channel + .last_check_age_seconds + .is_some_and(|age| age > STALE_AFTER_SECONDS); + if passed { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.passed")) + .size(11.0) + .color(if stale { theme::INK_2 } else { theme::OK }), + ); + if let Some(ms) = channel.last_latency_ms { + ui.label( + egui::RichText::new(fmt_l10n(lang, "settings.channels.elapsed", &[&ms])) + .size(11.0) + .color(theme::INK_3), + ); } + } else { + let error = channel.last_error.as_deref().unwrap_or_default(); + let text = if error.trim().is_empty() { + tr_l10n(lang, "settings.channels.failed_plain").to_string() + } else { + fmt_l10n(lang, "settings.channels.failed", &[&error]) + }; + ui.label(egui::RichText::new(text).size(11.0).color(theme::ERR)); ui.label( - egui::RichText::new(tr_l10n(lang, "settings.channels.enabled")) + egui::RichText::new(tr_l10n(lang, "settings.channels.failure_keeps_enabled")) + .size(11.0) + .color(theme::INK_2), + ); + } + if let Some(age) = channel.last_check_age_seconds { + ui.label( + egui::RichText::new(relative_check_age(lang, age)) .size(11.0) .color(theme::INK_3), ); - if ui - .add( - egui::Button::new( - egui::RichText::new(tr_l10n(lang, "settings.channels.verify")).size(11.0), - ) - .fill(theme::SURFACE_2) - .stroke(egui::Stroke::new(0.8, theme::LINE)) - .corner_radius(egui::CornerRadius::same(8)) - .min_size(egui::vec2(0.0, 24.0)), - ) - .clicked() - { - actions.push(FrontendAction::SettingsChannelValidate(index)); + if stale { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.stale_result")) + .size(11.0) + .color(theme::INK_2), + ); } - ui.add_space(6.0); - }); + } }); - ui.separator(); +} + +/// 「刚刚 / N 分钟前 / N 小时前 / N 天前」,与 Tauri `relativeTime` 同规则。 +fn relative_check_age(lang: Lang, seconds: i64) -> String { + if seconds < 60 { + return tr_l10n(lang, "settings.channels.just_now").to_string(); + } + let minutes = seconds / 60; + if minutes < 60 { + return fmt_l10n(lang, "settings.channels.minutes_ago", &[&minutes]); + } + let hours = minutes / 60; + if hours < 24 { + return fmt_l10n(lang, "settings.channels.hours_ago", &[&hours]); + } + fmt_l10n(lang, "settings.channels.days_ago", &[&(hours / 24)]) } /// Provider + name form used by "add channel". @@ -2825,6 +3047,8 @@ fn provider_small_button(ui: &mut egui::Ui, lang: Lang, key: &'static str, prima fn provider_editor_panel( ui: &mut egui::Ui, editor: &SettingsProviderEditor, + providers: &[SettingsChannelProvider], + channel_index: Option, lang: Lang, actions: &mut Vec, ) { @@ -2853,6 +3077,42 @@ fn provider_editor_panel( ); } }); + if !providers.is_empty() { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.provider_label")) + .size(11.5) + .color(theme::INK_3), + ); + let selected = providers + .iter() + .position(|provider| provider.provider_type == editor.provider_type) + .unwrap_or(0); + let mut picked = selected; + egui::ComboBox::from_id_salt(("settings-editor-provider", &editor.channel_id)) + .selected_text(&editor.provider) + .width(180.0) + .show_ui(ui, |ui| { + for (option_index, provider) in providers.iter().enumerate() { + if ui + .selectable_label(option_index == selected, &provider.label) + .clicked() + { + picked = option_index; + ui.close(); + } + } + }); + if picked != selected { + if let Some(index) = channel_index { + actions.push(FrontendAction::SettingsChannelProviderType { + index, + provider_type: providers[picked].provider_type.clone(), + }); + } + } + }); + } provider_field( ui, tr_l10n(lang, "providers.name"), @@ -2985,6 +3245,30 @@ fn provider_editor_panel( provider_model_block(ui, editor, lang, actions); ui.add_space(4.0); ui.horizontal(|ui| { + if let Some(index) = channel_index { + // 两步确认:第一次点只是展开说明与「确认删除」,避免误删。 + let confirm_id = egui::Id::new(("settings-channel-delete", &editor.channel_id)); + let confirming = + ui.data(|data| data.get_temp::(confirm_id).unwrap_or(false)); + if confirming { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.delete_confirm")) + .size(11.0) + .color(theme::INK_2), + ); + if provider_small_button(ui, lang, "settings.channels.confirm_delete", true) + { + actions.push(FrontendAction::SettingsChannelDelete(index)); + ui.data_mut(|data| data.remove_temp::(confirm_id)); + } + if provider_small_button(ui, lang, "common.cancel", false) { + ui.data_mut(|data| data.remove_temp::(confirm_id)); + } + } else if provider_small_button(ui, lang, "settings.channels.delete", false) { + ui.data_mut(|data| data.insert_temp(confirm_id, true)); + } + ui.add_space(8.0); + } if provider_small_button(ui, lang, "btn.save_fields", true) { actions.push(FrontendAction::SettingsProviderSave); } @@ -3777,6 +4061,55 @@ pub(crate) fn test_render_shortcuts( #[cfg(test)] mod tests { + /// 点「验证」后按钮要立刻变成「正在验证…」并禁用,直到结果回包或超时。 + /// Tauri 用 `testingIds` 做同一件事(组件本地状态)。 + #[test] + fn a_verify_click_marks_the_channel_as_testing_until_the_result_arrives() { + let ctx = eframe::egui::Context::default(); + let channel = super::super::view_model::SettingsChannel { + id: "channel-1".into(), + name: "DeepSeek".into(), + model: "deepseek-v4-flash".into(), + provider: "DeepSeek".into(), + provider_type: "deepseek".into(), + is_active: true, + enabled: true, + last_ok: None, + last_error: None, + last_latency_ms: None, + last_check_age_seconds: None, + }; + let mut states = Vec::new(); + let mut output = ctx.run_ui(eframe::egui::RawInput::default(), |ui| { + states.push(super::channel_testing(ui, &channel)); + super::mark_channel_testing(ui, &channel); + states.push(super::channel_testing(ui, &channel)); + }); + output.textures_delta.clear(); + assert_eq!( + states, + vec![false, true], + "the button flips to the checking state right away" + ); + + // 快照里出现新结果(或超时)就不再自称「正在验证」。 + let answered = super::super::view_model::SettingsChannel { + last_check_age_seconds: Some(3), + last_ok: Some(true), + ..channel.clone() + }; + let mut cleared = Vec::new(); + let mut output = ctx.run_ui(eframe::egui::RawInput::default(), |ui| { + cleared.push(super::channel_testing(ui, &answered)); + }); + output.textures_delta.clear(); + assert_eq!( + cleared, + vec![false], + "a fresh result restores the normal button" + ); + } + #[test] fn siri_preview_advances_and_requests_animation_frames() { let ctx = eframe::egui::Context::default(); @@ -4004,7 +4337,7 @@ mod tests { let mut actions = Vec::new(); let ctx = egui::Context::default(); let _ = crate::ui::frontend::run_pass(&ctx, egui::RawInput::default(), |ui| { - provider_editor_panel(ui, &editor, Lang::ZhCn, &mut actions); + provider_editor_panel(ui, &editor, &[], None, Lang::ZhCn, &mut actions); }); assert!( actions.is_empty(), @@ -4086,7 +4419,7 @@ mod tests { )), ..Default::default() }, - |ui| provider_editor_panel(ui, editor, Lang::ZhCn, &mut actions), + |ui| provider_editor_panel(ui, editor, &[], None, Lang::ZhCn, &mut actions), ); let mut text = String::new(); for clipped in &output.shapes { diff --git a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs index 06c62e5b6..1ddcd06cb 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs @@ -359,6 +359,8 @@ pub enum ShortcutField { /// One credential channel shown in the AI-services settings tab. #[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] pub struct SettingsChannel { + /// 渠道 id。宿主用它定位编辑目标(Tauri 的 `Channel.id` 同义)。 + pub id: String, pub name: String, /// Model / endpoint summary shown under the channel name. pub model: String, @@ -369,8 +371,14 @@ pub struct SettingsChannel { /// True for the channel currently serving requests. pub is_active: bool, pub enabled: bool, - /// Human-readable result of the last validation, if any. - pub last_check: Option, + /// 最近一次验证是否通过;`None` = 从未验证(Tauri 的 `lastTest`)。 + pub last_ok: Option, + /// 失败时的错误文本,Core 原样回显,不在这里二次解释。 + pub last_error: Option, + /// 通过时的往返耗时(毫秒)。 + pub last_latency_ms: Option, + /// 距最近一次验证过了多少秒。宿主用 epoch 秒算好,窗口只负责本地化。 + pub last_check_age_seconds: Option, } /// A field of the provider editor. Secret fields are write-only: opening an @@ -495,6 +503,9 @@ pub enum SettingsComboField { RemoteDefaultMode, /// 选区润色交付方式:0 = 直接替换,1 = 预览确认。 SelectionPolishDelivery, + /// 识别管线模式:0 = 传统(ASR + LLM 两段),1 = 多模态(单模型一步)。 + /// 与 Tauri 的 `pipelineMode` 同义:切换只改偏好,不动另一套凭据。 + PipelineMode, /// Less Computer 的 Agent 后端(0 = Claude Code, 1 = OpenCode, 2 = Codex, 3 = dsh)。 CodingAgentProvider, /// Less Computer 权限模式(0 = 放行, 1 = 只读/计划, 2 = 默认, 3 = 完全放行)。 diff --git a/openless-all/app/scripts/sync-egui-lucide-icons.mjs b/openless-all/app/scripts/sync-egui-lucide-icons.mjs index 5991659ee..f3a7ef802 100644 --- a/openless-all/app/scripts/sync-egui-lucide-icons.mjs +++ b/openless-all/app/scripts/sync-egui-lucide-icons.mjs @@ -21,7 +21,7 @@ export const icons = { Chat: 'MessageSquare', More: 'Ellipsis', ChevronRight: 'ChevronRight', Feather: 'Feather', Layout: 'PanelLeft', Doc: 'FileText', Pencil: 'Pencil', Cloud: 'Cloud', Shield: 'ShieldCheck', Info: 'Info', Help: 'CircleHelp', - External: 'ExternalLink', Monitor: 'Monitor', + External: 'ExternalLink', Monitor: 'Monitor', Grip: 'GripVertical', }; const version = JSON.parse(readFileSync(resolve(root, 'node_modules/lucide-react/package.json'), 'utf8')).version; const check = process.argv.includes('--check'); From 402ce6ea608ff049b0c4ef234aafd53aed8c9e89 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Mon, 28 Sep 2026 22:06:20 +0800 Subject: [PATCH 27/35] feat(linux-egui): embedded channel editor, flush settings rail, right links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处对齐 Tauri(用户逐项指出): 1. **渠道编辑器改成右栏覆盖**(`ChannelModal` 的 embedded 形态)。 之前是卡片下方展开的内嵌面板,后来我先做成了全窗口遮罩 + 居中卡片,用户 指出「应该是在右侧覆盖,不是独立的覆盖层」——Tauri 桌面端确实是 `createPortal` 进右侧 `.ol-settings-content-pane`,用 `.ol-channel-dialog-embedded { position: absolute; inset: 0; background: var(--ol-sidebar-bg) }` 整栏盖住。现在照此实现: - 头部:返回按钮(`chevLeft`,新图标走 lucide 同步管道)+ 「编辑渠道」+ 渠道种类徽章(文字处理 / 语音识别)+ 自动保存说明; - 中部滚动区:白底圆角表单卡(`服务连接` → 供应商 → 渠道名称(可选)+ 说明 → 凭据 → 模型),字段照 Tauri 是整列宽、38px 高(`layout::text_input_sized`); - 底部:左侧「删除渠道」(垃圾桶图标 + 两步确认),右侧自动保存提示 + 保存字段/清除 Secret + 「完成」(蓝色主按钮)。 `SettingsProviderEditor` 增 `is_asr`(宿主 `ProviderEditorForm.kind`)决定徽章。 2. **设置弹窗左栏几何**:底色以前画在「内容高度」的 Frame 上,内容短时下半截 露出内容区底色;内容宽度又比 Tauri 少了 24px(选项 170 vs 190)。改为按整栏 高度铺底色 + 右侧 0.5px 分隔线(Tauri 的 `borderRight`),内容走 12/16 内边距, 选项与「帮助中心」上方的分隔线因此与 Tauri 同宽同位置;不再用 `ui.separator()` 以免它自己的宽度把右栏挤窄。 3. **外链指错了组织**:帮助中心/发布日志/GitHub/反馈都指向 `github.com/earendil-works/openless`,Tauri 用的是 `Open-Less/openless` (帮助中心还要带 `#readme`)。抽成常量并加回归测试。 测试:`the_channel_editor_covers_the_content_column_with_tauri_chrome`(断言编辑器 画出编辑渠道/服务连接/渠道名称/删除渠道/完成,且设置页正文不透过)、 `settings_links_match_the_tauri_targets`;i18n 新增 6 个键(值取自 Tauri 五语目录)。 package revision 122。 --- .../linux-egui/assets/lucide/ChevronLeft.svg | 1 + openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/i18n.rs | 54 + .../linux-egui/src/linux_app/settings_save.rs | 27 +- openless-all/app/linux-egui/src/main.rs | 4 + .../app/linux-egui/src/ui/frontend/icons.rs | 6 + .../app/linux-egui/src/ui/frontend/layout.rs | 26 +- .../linux-egui/src/ui/frontend/settings.rs | 1232 ++++++++++++----- .../linux-egui/src/ui/frontend/view_model.rs | 2 + openless-all/app/linux-egui/src/ui/theme.rs | 8 + .../app/scripts/sync-egui-lucide-icons.mjs | 1 + 11 files changed, 996 insertions(+), 367 deletions(-) create mode 100644 openless-all/app/linux-egui/assets/lucide/ChevronLeft.svg diff --git a/openless-all/app/linux-egui/assets/lucide/ChevronLeft.svg b/openless-all/app/linux-egui/assets/lucide/ChevronLeft.svg new file mode 100644 index 000000000..fbb681f0e --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/ChevronLeft.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 9289ddcee..9f54fe313 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -121 +122 diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index fbbce7c51..37a38cf4a 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -2716,6 +2716,60 @@ pub const CATALOG: &[Msg] = &[ "확인 실패 · {}", ), }, + Msg { + key: "settings.channels.connection_title", + text: row( + "服务连接", + "服務連線", + "Service connection", + "サービス接続", + "서비스 연결", + ), + }, + Msg { + key: "settings.channels.edit_title", + text: row( + "编辑渠道", + "編輯渠道", + "Edit channel", + "チャネルを編集", + "채널 편집", + ), + }, + Msg { + key: "settings.channels.create_title", + text: row( + "添加渠道", + "新增渠道", + "Add channel", + "チャネルを追加", + "채널 추가", + ), + }, + Msg { + key: "settings.channels.auto_save_hint", + text: row( + "字段修改后自动保存;完成配置后,可手动验证连接。", + "修改欄位後會自動儲存;完成設定後,可手動驗證連線。", + "Changes save automatically. Once configured, you can check the connection manually.", + "変更は自動保存されます。設定が終わったら、手動で接続を確認できます。", + "변경 사항은 자동으로 저장됩니다. 설정을 마친 후 연결을 직접 확인할 수 있습니다.", + ), + }, + Msg { + key: "settings.channels.name_label", + text: row( + "渠道名称(可选)", + "名稱", + "Channel name (optional)", + "チャネル名(任意)", + "채널 이름 (선택)", + ), + }, + Msg { + key: "settings.channels.done", + text: row("完成", "完成", "Done", "完了", "완료"), + }, Msg { key: "settings.channels.llm_title", text: row( diff --git a/openless-all/app/linux-egui/src/linux_app/settings_save.rs b/openless-all/app/linux-egui/src/linux_app/settings_save.rs index 2e2c006d0..54f12dd2f 100644 --- a/openless-all/app/linux-egui/src/linux_app/settings_save.rs +++ b/openless-all/app/linux-egui/src/linux_app/settings_save.rs @@ -2,6 +2,13 @@ use super::*; use serde_json::Value; use std::collections::BTreeMap; +/// 设置页里的外链目标,与 Tauri 的 `AboutSection` / `SettingsModal` 一字不差 +/// (`HELP_URL` / `RELEASE_NOTES_URL` 及仓库、issue 页)。 +pub(crate) const REPOSITORY_URL: &str = "https://github.com/Open-Less/openless"; +pub(crate) const HELP_URL: &str = "https://github.com/Open-Less/openless#readme"; +pub(crate) const RELEASE_NOTES_URL: &str = "https://github.com/Open-Less/openless/releases"; +pub(crate) const ISSUES_URL: &str = "https://github.com/Open-Less/openless/issues"; + type Patch = BTreeMap; #[derive(Default)] @@ -635,16 +642,16 @@ impl OpenLessEguiApp { openless_linux_egui::play_cue_start(); } frontend::view_model::SettingsActionField::OpenGitHub => { - let _ = open_external("https://github.com/earendil-works/openless"); + let _ = open_external(REPOSITORY_URL); } frontend::view_model::SettingsActionField::OpenHelp => { - let _ = open_external("https://github.com/earendil-works/openless"); + let _ = open_external(HELP_URL); } frontend::view_model::SettingsActionField::OpenReleaseNotes => { - let _ = open_external("https://github.com/earendil-works/openless/releases"); + let _ = open_external(RELEASE_NOTES_URL); } frontend::view_model::SettingsActionField::OpenFeedback => { - let _ = open_external("https://github.com/earendil-works/openless/issues"); + let _ = open_external(ISSUES_URL); } frontend::view_model::SettingsActionField::CopyQQ => { match fcitx5_copy_to_clipboard("1078960553") { @@ -745,4 +752,16 @@ mod tests { ); assert!(edits.contains_key("/hotkey/mode")); } + + /// 外链必须指向与 Tauri 相同的仓库/页面(回归:曾经指向 earendil-works)。 + #[test] + fn settings_links_match_the_tauri_targets() { + assert_eq!(REPOSITORY_URL, "https://github.com/Open-Less/openless"); + assert_eq!(HELP_URL, "https://github.com/Open-Less/openless#readme"); + assert_eq!( + RELEASE_NOTES_URL, + "https://github.com/Open-Less/openless/releases" + ); + assert_eq!(ISSUES_URL, "https://github.com/Open-Less/openless/issues"); + } } diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 7266ff7cd..6b2fc2d0e 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -399,6 +399,8 @@ mod linux_app { /// clobbers what the user is typing. struct ProviderEditorForm { channel_id: String, + /// 渠道种类(LLM / ASR):弹窗副标题「文字处理渠道 / 语音识别渠道」用它。 + kind: openless_core::ChannelKind, provider_type: String, label: String, auth: frontend::view_model::SettingsProviderAuth, @@ -425,6 +427,7 @@ mod linux_app { fn from_editor(editor: &ProviderEditor, lang: Lang) -> Self { Self { channel_id: editor.channel.id.clone(), + kind: editor.kind, provider_type: editor.descriptor.provider_type.as_str().to_string(), label: localized_provider_label( lang, @@ -4115,6 +4118,7 @@ mod linux_app { vm.provider_editor = self.provider_editor_form.as_ref().map(|form| { frontend::view_model::SettingsProviderEditor { channel_id: form.channel_id.clone(), + is_asr: matches!(form.kind, openless_core::ChannelKind::Asr), provider: form.label.clone(), provider_type: form.provider_type.clone(), name: form.name.clone(), diff --git a/openless-all/app/linux-egui/src/ui/frontend/icons.rs b/openless-all/app/linux-egui/src/ui/frontend/icons.rs index ef6c08edd..5aa6d16de 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/icons.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/icons.rs @@ -34,6 +34,7 @@ pub enum IconName { Github, More, ChevronRight, + ChevronLeft, Feather, Layout, Doc, @@ -105,6 +106,10 @@ fn lucide_svg(icon: IconName) -> Option<(&'static str, &'static str)> { "ChevronRight", include_str!("../../../assets/lucide/ChevronRight.svg"), ), + IconName::ChevronLeft => ( + "ChevronLeft", + include_str!("../../../assets/lucide/ChevronLeft.svg"), + ), IconName::Feather => ( "Feather", include_str!("../../../assets/lucide/Feather.svg"), @@ -232,6 +237,7 @@ mod tests { IconName::Chat, IconName::More, IconName::ChevronRight, + IconName::ChevronLeft, IconName::Feather, IconName::Layout, IconName::Doc, diff --git a/openless-all/app/linux-egui/src/ui/frontend/layout.rs b/openless-all/app/linux-egui/src/ui/frontend/layout.rs index 4d0b84fb8..2e6a24db3 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/layout.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/layout.rs @@ -1413,8 +1413,30 @@ pub fn text_input( width: f32, password: bool, ) -> egui::Response { - let width = width.clamp(80.0, INPUT_MAX_WIDTH); - let (outer, _) = ui.allocate_exact_size(egui::vec2(width, INPUT_HEIGHT), egui::Sense::hover()); + text_input_sized( + ui, + value, + id, + hint, + width.clamp(80.0, INPUT_MAX_WIDTH), + INPUT_HEIGHT, + password, + ) +} + +/// 与 [`text_input`] 相同,但宽高由调用方给:渠道编辑弹窗里的字段照 Tauri 是 +/// 整列宽、38px 高,比设置行里的紧凑输入框大一号。 +pub fn text_input_sized( + ui: &mut egui::Ui, + value: &mut String, + id: egui::Id, + hint: &str, + width: f32, + height: f32, + password: bool, +) -> egui::Response { + let width = width.clamp(80.0, 640.0); + let (outer, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); ui.painter() .rect_filled(outer, egui::CornerRadius::same(8), theme::SURFACE); ui.painter().rect_stroke( diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index 05d44e9f2..ab88213ac 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -135,31 +135,67 @@ pub fn settings_overlay( // 搜索框,右侧内容区顶端才是标题 + 自动保存提示 + 关闭按钮。 let body_height = size.y.max(120.0); ui.horizontal(|ui| { + // Tauri `.ol-settings-surface aside` 是 flex 子项:底色与右侧 + // 0.5px 分隔线都通到弹窗上下边缘,内边距 16/12,选项因此正好 + // 铺满 214−24=190 的净宽。以前底色画在「内容高度」的 Frame 上、 + // 内容宽度又少了 4px:左栏下方露出内容区底色,选项和分割线也 + // 缩进了一圈。 ui.allocate_ui_with_layout( egui::vec2(RAIL_WIDTH, body_height), egui::Layout::top_down(egui::Align::Min), |ui| { - ui.set_min_height(body_height); - ui.set_max_height(body_height); - egui::ScrollArea::vertical() - .id_salt("openless-settings-rail") - .auto_shrink([false, false]) - .show(ui, |ui| { - ui.set_width(RAIL_WIDTH - 20.0); - rail(ui, vm, actions); - }); + ui.set_min_size(egui::vec2(RAIL_WIDTH, body_height)); + ui.set_max_size(egui::vec2(RAIL_WIDTH, body_height)); + let column = ui.max_rect(); + ui.painter().rect_filled( + column, + egui::CornerRadius { + nw: 14, + sw: 14, + ne: 0, + se: 0, + }, + theme::RAIL_BG, + ); + ui.painter().rect_filled( + egui::Rect::from_min_max( + egui::pos2(column.right() - 0.5, column.top()), + column.right_bottom(), + ), + egui::CornerRadius::ZERO, + theme::LINE_SOFT, + ); + let inner = column.shrink2(egui::vec2(12.0, 16.0)); + ui.scope_builder( + egui::UiBuilder::new().max_rect(inner), + |ui| { + egui::ScrollArea::vertical() + .id_salt("openless-settings-rail") + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.set_width(inner.width()); + rail(ui, vm, actions); + }); + }, + ); }, ); - ui.separator(); - // 两列之间 egui 还会插入 item_spacing,分隔线自身也占宽:不减掉它们 - // 内容就比 size 宽十几像素,卡片会被撑宽、`fixed_pos` 居中就偏(实测 11px)。 - let gutters = ui.spacing().item_spacing.x * 3.0; + // 分隔线已经画在左栏底色上(Tauri 的 `borderRight`),不再用 + // `ui.separator()`:它自己的宽度会让右栏再窄一截。 + let gutters = ui.spacing().item_spacing.x; ui.allocate_ui_with_layout( egui::vec2((size.x - RAIL_WIDTH - gutters).max(0.0), body_height), egui::Layout::top_down(egui::Align::Min), |ui| { ui.set_min_height(body_height); - panel(ui, vm, actions); + // Tauri 桌面端把渠道编辑器 portal 进右栏并 `inset: 0` + // 盖住整栏(`.ol-channel-dialog-embedded`),而不是 + // 全窗口遮罩;所以这里按同一栏矩形覆盖。 + if vm.provider_editor.is_some() { + channel_editor_panel(ui, vm, actions); + } else { + panel(ui, vm, actions); + } }, ); }); @@ -170,91 +206,82 @@ pub fn settings_overlay( fn rail(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { let lang = vm.lang; - // Tauri `.ol-settings-surface aside`: 214px 宽的独立底色条带(左侧跟随弹窗圆角)。 - egui::Frame::new() - .fill(theme::RAIL_BG) - .corner_radius(egui::CornerRadius { - nw: 14, - sw: 14, - ne: 0, - se: 0, - }) - .inner_margin(egui::Margin::symmetric(12, 16)) - .show(ui, |ui| { - // Section search, like the Tauri rail. - egui::Frame::new() - .fill(theme::SURFACE) - .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) - .corner_radius(egui::CornerRadius::same(8)) - .inner_margin(egui::Margin::symmetric(10, 5)) - .show(ui, |ui| { - ui.set_width(SIDEBAR_RAIL_INPUT); - let width = ui.available_width().max(40.0); - ui.add_sized( - [width, 20.0], - egui::TextEdit::singleline(&mut vm.settings_query) - .id(egui::Id::new("openless-settings-search")) - .hint_text(tr_l10n(lang, "modal.search_placeholder")) - // 明确文字颜色:默认的控件前景色在浅底上过淡, - // 看上去像「输入了但没有显示字符」。 - .text_color(theme::INK) - .frame(egui::Frame::NONE) - .vertical_align(egui::Align::Center), - ); - }); - ui.add_space(10.0); - - let query = vm.settings_query.trim().to_lowercase(); - // Tauri's rail order. - for section in [ - SettingsSection::General, - SettingsSection::Shortcuts, - SettingsSection::Services, - SettingsSection::Appearance, - SettingsSection::Privacy, - SettingsSection::Advanced, - SettingsSection::About, - ] { - if !rail_section_visible(section, vm.hotkeys_supported) { - continue; - } - let label = section.label(lang); - if !query.is_empty() && !label.to_lowercase().contains(&query) { - continue; - } - let response = rail_item(ui, label, section.icon(), vm.settings_section == section); - if response.clicked() { - actions.push(FrontendAction::SettingsSection(section)); - } - } - ui.add_space(14.0); - ui.separator(); - ui.add_space(7.0); - for (label, icon) in [ - ( - tr_l10n(lang, "modal.sections.help_center"), - SettingsIcon::Help, - ), - ( - tr_l10n(lang, "modal.sections.release_notes"), - SettingsIcon::Document, - ), - ] { - let response = rail_item(ui, label, icon, false); - let row = response.rect; - draw_rail_icon( - ui, - egui::pos2(row.right() - 14.0, row.center().y), - SettingsIcon::External, - theme::INK_4, + // 底色与右侧分隔线由调用方按整栏高度铺好(见 settings_sheet)。 + ui.vertical(|ui| { + // Section search, like the Tauri rail. + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(10, 5)) + .show(ui, |ui| { + ui.set_width(SIDEBAR_RAIL_INPUT); + let width = ui.available_width().max(40.0); + ui.add_sized( + [width, 20.0], + egui::TextEdit::singleline(&mut vm.settings_query) + .id(egui::Id::new("openless-settings-search")) + .hint_text(tr_l10n(lang, "modal.search_placeholder")) + // 明确文字颜色:默认的控件前景色在浅底上过淡, + // 看上去像「输入了但没有显示字符」。 + .text_color(theme::INK) + .frame(egui::Frame::NONE) + .vertical_align(egui::Align::Center), ); - if response.clicked() { - actions.push(FrontendAction::SettingsAction( - SettingsActionField::OpenHelp, - )); - } + }); + ui.add_space(10.0); + + let query = vm.settings_query.trim().to_lowercase(); + // Tauri's rail order. + for section in [ + SettingsSection::General, + SettingsSection::Shortcuts, + SettingsSection::Services, + SettingsSection::Appearance, + SettingsSection::Privacy, + SettingsSection::Advanced, + SettingsSection::About, + ] { + if !rail_section_visible(section, vm.hotkeys_supported) { + continue; } - }); + let label = section.label(lang); + if !query.is_empty() && !label.to_lowercase().contains(&query) { + continue; + } + let response = rail_item(ui, label, section.icon(), vm.settings_section == section); + if response.clicked() { + actions.push(FrontendAction::SettingsSection(section)); + } + } + ui.add_space(14.0); + ui.separator(); + ui.add_space(7.0); + for (label, icon) in [ + ( + tr_l10n(lang, "modal.sections.help_center"), + SettingsIcon::Help, + ), + ( + tr_l10n(lang, "modal.sections.release_notes"), + SettingsIcon::Document, + ), + ] { + let response = rail_item(ui, label, icon, false); + let row = response.rect; + draw_rail_icon( + ui, + egui::pos2(row.right() - 14.0, row.center().y), + SettingsIcon::External, + theme::INK_4, + ); + if response.clicked() { + actions.push(FrontendAction::SettingsAction( + SettingsActionField::OpenHelp, + )); + } + } + }); } /// Tauri `visibleSettingsSections(supportsDesktopHotkey)`:没有桌面热键后端时 @@ -1991,23 +2018,6 @@ fn services(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec, ) { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(label).size(11.5).color(theme::INK_3)); + /// Tauri 渠道弹窗里的输入框:整列宽、38px 高。 + const MODAL_INPUT_HEIGHT: f32 = 38.0; + let mut row = |ui: &mut egui::Ui| { let mut draft = value.to_string(); let id = egui::Id::new(("openless-settings-provider-field", format!("{field:?}"))); - if layout::text_input(ui, &mut draft, id, "", 220.0, password).changed() { + let width = ui.available_width().min(420.0); + if layout::text_input_sized(ui, &mut draft, id, "", width, MODAL_INPUT_HEIGHT, password) + .changed() + { actions.push(FrontendAction::SettingsProviderField(field, draft)); } - }); + }; + if label.is_empty() { + row(ui); + } else { + modal_form_row(ui, label, row); + } } fn provider_small_button(ui: &mut egui::Ui, lang: Lang, key: &'static str, primary: bool) -> bool { @@ -3040,11 +3059,417 @@ fn provider_small_button(ui: &mut egui::Ui, lang: Lang, key: &'static str, prima .clicked() } -/// Channel editor. Core's `AuthRequirement` decides which inputs exist, and every -/// write goes back through Core's provider/credential API: the UI never owns -/// endpoints, defaults or credential semantics. Secret inputs are write-only — -/// opening an editor never shows a stored key. -fn provider_editor_panel( +/// Tauri 的渠道编辑器在桌面端是 **embedded**:`ChannelModal` 被 portal 进右侧内容栏 +/// (`.ol-settings-content-pane`),靠 `.ol-channel-dialog-embedded { position: absolute; +/// inset: 0; background: var(--ol-sidebar-bg) }` 整栏盖住——不是全窗口遮罩加居中卡片。 +/// +/// 所以这里是「在右栏里覆盖一层」:灰底(`--ol-sidebar-bg`)+ 头部(返回按钮 + 标题 + +/// 渠道种类徽章 + 自动保存说明)+ 中部滚动区(白底圆角表单卡:服务连接 → 供应商 → +/// 渠道名称 → 凭据 → 模型)+ 底部(删除渠道 / 完成)。 +fn channel_editor_panel( + ui: &mut egui::Ui, + vm: &FrontendViewModel, + actions: &mut Vec, +) { + /// Tauri `.ol-channel-dialog-embedded .ol-channel-dialog-header { padding: 18px 24px }` + /// + 标题行与说明行。 + const HEADER_HEIGHT: f32 = 92.0; + /// Tauri `.ol-channel-dialog-embedded .ol-channel-dialog-footer { padding: 16px 24px }` + /// + 34px 按钮 + 1px 顶线。 + const FOOTER_HEIGHT: f32 = 67.0; + /// 左右内边距(Tauri 的 `24px`)。 + const SIDE_PADDING: f32 = 24.0; + /// `.ol-channel-dialog-embedded .ol-channel-form-sheet { padding: 18px }`。 + const SHEET_PADDING: f32 = 18.0; + + let Some(editor) = vm.provider_editor.as_ref() else { + return; + }; + let lang = vm.lang; + let edited = vm + .channels + .iter() + .position(|channel| channel.id == editor.channel_id); + let column = ui.max_rect(); + ui.painter().rect_filled( + column, + egui::CornerRadius { + nw: 0, + sw: 0, + ne: 14, + se: 14, + }, + theme::RAIL_BG, + ); + + // ── 头部:返回按钮 + 标题/徽章 + 说明 ──────────────────────────────────── + let header = egui::Rect::from_min_size( + column.min, + egui::vec2(column.width(), HEADER_HEIGHT.min(column.height())), + ); + ui.painter().rect_filled( + egui::Rect::from_min_max( + egui::pos2(header.left(), header.bottom() - 1.0), + header.right_bottom(), + ), + egui::CornerRadius::ZERO, + theme::LINE, + ); + // Tauri `.ol-settings-back`:32×30、白底、0.5px 强描边、圆角 8。 + let back = egui::Rect::from_min_size( + egui::pos2(column.left() + SIDE_PADDING, column.top() + 18.0), + egui::vec2(32.0, 30.0), + ); + let back_response = ui.interact( + back, + egui::Id::new("openless-channel-editor-back"), + egui::Sense::click(), + ); + ui.painter().rect_filled( + back, + egui::CornerRadius::same(8), + if back_response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }, + ); + ui.painter().rect_stroke( + back, + egui::CornerRadius::same(8), + egui::Stroke::new(0.8, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + icons::draw_icon_sized( + ui, + back.center(), + IconName::ChevronLeft, + if back_response.hovered() { + theme::INK + } else { + theme::INK_2 + }, + 18.0, + ); + if back_response.clicked() { + actions.push(FrontendAction::SettingsProviderClose); + } + let heading_left = back.right() + 14.0; + let title_painter = ui.painter().clone(); + let title_y = column.top() + 18.0; + title_painter.text( + egui::pos2(heading_left, title_y), + egui::Align2::LEFT_TOP, + tr_l10n(lang, "settings.channels.edit_title"), + egui::FontId::proportional(21.0), + theme::INK, + ); + let title_width = layout::text_width(ui, tr_l10n(lang, "settings.channels.edit_title"), 21.0); + let kind_title = tr_l10n( + lang, + if editor.is_asr { + "settings.channels.asr_title" + } else { + "settings.channels.llm_title" + }, + ); + let badge_galley = title_painter.layout_no_wrap( + kind_title.to_owned(), + egui::FontId::proportional(10.5), + theme::INK_3, + ); + let badge_padding = egui::vec2(7.0, 2.0); + let badge = egui::Rect::from_min_size( + egui::pos2(heading_left + title_width + 12.0, title_y + 6.0), + badge_galley.size() + badge_padding * 2.0, + ); + title_painter.rect_stroke( + badge, + egui::CornerRadius::same(5), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); + title_painter.galley(badge.min + badge_padding, badge_galley, theme::INK_3); + title_painter.text( + egui::pos2(heading_left, title_y + 32.0), + egui::Align2::LEFT_TOP, + tr_l10n(lang, "settings.channels.auto_save_hint"), + egui::FontId::proportional(12.0), + theme::INK_3, + ); + + // ── 底部:删除渠道(两步确认)+ 完成 ──────────────────────────────────── + let footer = egui::Rect::from_min_size( + egui::pos2( + column.left(), + column.bottom() - FOOTER_HEIGHT.min(column.height()), + ), + egui::vec2(column.width(), FOOTER_HEIGHT.min(column.height())), + ); + ui.painter() + .rect_filled(footer, egui::CornerRadius::ZERO, theme::RAIL_BG); + ui.painter().rect_filled( + egui::Rect::from_min_max( + footer.left_top(), + egui::pos2(footer.right(), footer.top() + 1.0), + ), + egui::CornerRadius::ZERO, + theme::LINE, + ); + + // ── 中部:滚动区里的白色表单卡 ───────────────────────────────────────── + let body = egui::Rect::from_min_max( + egui::pos2(column.left(), header.bottom()), + egui::pos2(column.right(), footer.top()), + ); + ui.scope_builder(egui::UiBuilder::new().max_rect(body), |ui| { + egui::ScrollArea::vertical() + .id_salt("openless-channel-editor-body") + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.add_space(8.0); + let inner = egui::Rect::from_min_max( + egui::pos2(body.left() + SIDE_PADDING, ui.cursor().top()), + egui::pos2( + body.right() - SIDE_PADDING, + body.bottom().max(ui.cursor().top() + 40.0), + ), + ); + ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(SHEET_PADDING as i8)) + .show(ui, |ui| { + ui.set_width((inner.width() - SHEET_PADDING * 2.0).max(80.0)); + provider_editor_body( + ui, + editor, + &vm.channel_providers, + edited, + lang, + actions, + ); + }); + }); + ui.add_space(22.0); + }); + }); + + // ── 底部按钮 ─────────────────────────────────────────────────────────── + let confirm_id = egui::Id::new(("settings-channel-delete", &editor.channel_id)); + let confirming = ui.data(|data| data.get_temp::(confirm_id).unwrap_or(false)); + let mut footer_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(footer.shrink2(egui::vec2(SIDE_PADDING, 16.0))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + if let Some(index) = edited { + if confirming { + footer_ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.delete_confirm")) + .size(12.0) + .color(theme::INK_2), + ); + footer_ui.add_space(12.0); + if danger_button( + &mut footer_ui, + tr_l10n(lang, "settings.channels.confirm_delete"), + Some(IconName::Trash), + ) { + actions.push(FrontendAction::SettingsChannelDelete(index)); + ui.data_mut(|data| data.remove_temp::(confirm_id)); + } + footer_ui.add_space(8.0); + if provider_small_button(&mut footer_ui, lang, "common.cancel", false) { + footer_ui.data_mut(|data| data.remove_temp::(confirm_id)); + } + } else if danger_button( + &mut footer_ui, + tr_l10n(lang, "settings.channels.delete"), + Some(IconName::Trash), + ) { + footer_ui.data_mut(|data| data.insert_temp(confirm_id, true)); + } + } + if editor.busy { + footer_ui.add_space(10.0); + footer_ui.label( + egui::RichText::new(tr_l10n(lang, "common.loading")) + .size(11.5) + .color(theme::INK_4), + ); + } + // 右侧组(Tauri `.ol-channel-dialog-footer-end`):自动保存提示 + 保存/清除 + 完成。 + let hint = footer_ui.painter().layout_no_wrap( + tr_l10n(lang, "modal.auto_save_hint").to_owned(), + egui::FontId::proportional(11.5), + theme::INK_3, + ); + let done_width = + layout::text_width(&footer_ui, tr_l10n(lang, "settings.channels.done"), 12.0) + 30.0; + let save_width = layout::text_width(&footer_ui, tr_l10n(lang, "btn.save_fields"), 11.5) + 22.0; + let clear_width = + layout::text_width(&footer_ui, tr_l10n(lang, "btn.clear_secret"), 11.5) + 22.0; + let mut cursor = footer.right() - SIDE_PADDING - done_width; + let done_rect = egui::Rect::from_min_size( + egui::pos2(cursor, footer.center().y - 17.0), + egui::vec2(done_width, 34.0), + ); + cursor -= 16.0 + save_width; + let save_rect = egui::Rect::from_min_size( + egui::pos2(cursor, footer.center().y - 14.0), + egui::vec2(save_width, 28.0), + ); + cursor -= 8.0 + clear_width; + let clear_rect = egui::Rect::from_min_size( + egui::pos2(cursor, footer.center().y - 14.0), + egui::vec2(clear_width, 28.0), + ); + cursor -= 16.0 + hint.size().x; + if cursor > footer.left() + SIDE_PADDING + 200.0 { + footer_ui.painter().galley( + egui::pos2(cursor, footer.center().y - hint.size().y / 2.0), + hint, + theme::INK_3, + ); + } + let done = footer_ui.interact( + done_rect, + egui::Id::new("openless-channel-editor-done"), + egui::Sense::click(), + ); + footer_ui.painter().rect_filled( + done_rect, + egui::CornerRadius::same(10), + if done.hovered() { + theme::BLUE_HOVER + } else { + theme::BLUE + }, + ); + footer_ui.painter().text( + done_rect.center(), + egui::Align2::CENTER_CENTER, + tr_l10n(lang, "settings.channels.done"), + egui::FontId::proportional(12.0), + theme::SURFACE, + ); + if done.clicked() { + actions.push(FrontendAction::SettingsProviderClose); + } + let save = footer_ui.interact( + save_rect, + egui::Id::new("openless-channel-editor-save"), + egui::Sense::click(), + ); + paint_ghost_small_button( + &footer_ui, + save_rect, + save.hovered(), + tr_l10n(lang, "btn.save_fields"), + 11.5, + ); + if save.clicked() { + actions.push(FrontendAction::SettingsProviderSave); + } + let clear = footer_ui.interact( + clear_rect, + egui::Id::new("openless-channel-editor-clear"), + egui::Sense::click(), + ); + paint_ghost_small_button( + &footer_ui, + clear_rect, + clear.hovered(), + tr_l10n(lang, "btn.clear_secret"), + 11.5, + ); + if clear.clicked() { + actions.push(FrontendAction::SettingsProviderClearSecrets); + } +} + +/// 次要按钮(白底 + 0.8px 描边 + 圆角 8),用于弹窗底部的保存/清除。 +fn paint_ghost_small_button( + ui: &egui::Ui, + rect: egui::Rect, + hovered: bool, + label: &str, + size: f32, +) { + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(8), + if hovered { + theme::SURFACE_2 + } else { + theme::SURFACE + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(8), + egui::Stroke::new(0.8, theme::LINE), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(size), + theme::INK_2, + ); +} + +/// 危险按钮(Tauri `.ol-channel-delete-button`:淡红底、红描边、红字 + 垃圾桶图标)。 +fn danger_button(ui: &mut egui::Ui, label: &str, icon: Option) -> bool { + let text_width = layout::text_width(ui, label, 12.5); + let icon_space = if icon.is_some() { 21.0 } else { 0.0 }; + let (rect, response) = ui.allocate_exact_size( + egui::vec2(text_width + icon_space + 24.0, 34.0), + egui::Sense::click(), + ); + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(8), + if response.hovered() { + theme::DANGER_FILL_HOVER + } else { + theme::DANGER_FILL + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(8), + egui::Stroke::new(1.0, theme::DANGER_BORDER), + egui::StrokeKind::Inside, + ); + let mut text_left = rect.left() + 12.0; + if let Some(icon) = icon { + icons::draw_icon_sized( + ui, + egui::pos2(text_left + 7.5, rect.center().y), + icon, + theme::ERR, + 15.0, + ); + text_left += 21.0; + } + ui.painter().text( + egui::pos2(text_left, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.5), + theme::ERR, + ); + response.clicked() +} + +/// 弹窗正文:Tauri 的 `.ol-channel-form-sheet`——`服务连接` 分区 + 供应商 + 名称, +/// 之后是 Core 决定形状的凭据字段与模型块。 +fn provider_editor_body( ui: &mut egui::Ui, editor: &SettingsProviderEditor, providers: &[SettingsChannelProvider], @@ -3052,234 +3477,228 @@ fn provider_editor_panel( lang: Lang, actions: &mut Vec, ) { - egui::Frame::new() - .fill(theme::SURFACE_2) - .corner_radius(egui::CornerRadius::same(10)) - .inner_margin(egui::Margin::symmetric(12, 10)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(&editor.provider) - .strong() - .size(12.0) - .color(theme::INK), - ); - ui.label( - egui::RichText::new(tr_l10n(lang, "providers.editing")) - .size(10.5) - .color(theme::INK_4), - ); - if editor.busy { - ui.label( - egui::RichText::new(tr_l10n(lang, "common.loading")) - .size(10.5) - .color(theme::INK_4), - ); - } - }); - if !providers.is_empty() { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(tr_l10n(lang, "settings.channels.provider_label")) - .size(11.5) - .color(theme::INK_3), - ); - let selected = providers - .iter() - .position(|provider| provider.provider_type == editor.provider_type) - .unwrap_or(0); - let mut picked = selected; - egui::ComboBox::from_id_salt(("settings-editor-provider", &editor.channel_id)) - .selected_text(&editor.provider) - .width(180.0) - .show_ui(ui, |ui| { - for (option_index, provider) in providers.iter().enumerate() { - if ui - .selectable_label(option_index == selected, &provider.label) - .clicked() - { - picked = option_index; - ui.close(); - } + // 分区标题:云图标 + 「服务连接」(Tauri `ChannelSectionHeading`)。 + ui.horizontal(|ui| { + icons::draw_icon_sized( + ui, + ui.cursor().min + egui::vec2(7.0, 8.0), + IconName::Cloud, + theme::INK_3, + 14.0, + ); + ui.add_space(22.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.connection_title")) + .size(13.0) + .color(theme::INK) + .strong(), + ); + }); + ui.add_space(6.0); + if !providers.is_empty() { + modal_form_row( + ui, + tr_l10n(lang, "settings.channels.provider_label"), + |ui| { + let selected = providers + .iter() + .position(|provider| provider.provider_type == editor.provider_type) + .unwrap_or(0); + let mut picked = selected; + egui::ComboBox::from_id_salt(("settings-editor-provider", &editor.channel_id)) + .selected_text(&editor.provider) + .width(ui.available_width().min(420.0)) + .show_ui(ui, |ui| { + for (option_index, provider) in providers.iter().enumerate() { + if ui + .selectable_label(option_index == selected, &provider.label) + .clicked() + { + picked = option_index; + ui.close(); } - }); - if picked != selected { - if let Some(index) = channel_index { - actions.push(FrontendAction::SettingsChannelProviderType { - index, - provider_type: providers[picked].provider_type.clone(), - }); } + }); + if picked != selected { + if let Some(index) = channel_index { + actions.push(FrontendAction::SettingsChannelProviderType { + index, + provider_type: providers[picked].provider_type.clone(), + }); } - }); + } + }, + ); + } + modal_form_row(ui, tr_l10n(lang, "settings.channels.name_label"), |ui| { + provider_field( + ui, + "", + &editor.name, + SettingsProviderField::Name, + false, + actions, + ); + }); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.name_hint")) + .size(11.5) + .color(theme::INK_3), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.credentials")) + .size(11.5) + .color(theme::INK_3), + ); + match editor.auth { + SettingsProviderAuth::None => { + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.no_cloud_note")) + .size(11.5) + .color(theme::INK_4), + ); + } + SettingsProviderAuth::OAuth => { + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.oauth_note")) + .size(11.5) + .color(theme::INK_4), + ); + } + SettingsProviderAuth::Volcengine => { + let mut mode = editor.auth_mode.clone(); + modal_form_row(ui, "Auth", |ui| { + egui::ComboBox::from_id_salt("settings-provider-auth-mode") + .selected_text(&mode) + .show_ui(ui, |ui| { + for option in ["app_id_token", "api_key"] { + if ui.selectable_label(mode == option, option).clicked() { + mode = option.to_string(); + ui.close(); + } + } + }); + }); + if mode != editor.auth_mode { + actions.push(FrontendAction::SettingsProviderField( + SettingsProviderField::AuthMode, + mode.clone(), + )); + } + if mode == "api_key" { + provider_field( + ui, + "API Key", + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + actions, + ); + } else { + provider_field( + ui, + "APP ID", + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + actions, + ); + provider_field( + ui, + "Access Token", + &editor.secondary_secret, + SettingsProviderField::SecondarySecret, + true, + actions, + ); } provider_field( ui, - tr_l10n(lang, "providers.name"), - &editor.name, - SettingsProviderField::Name, + "Resource ID", + &editor.resource_id, + SettingsProviderField::ResourceId, false, actions, ); + } + SettingsProviderAuth::Xfyun => { + provider_field( + ui, + "AppID", + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + actions, + ); + provider_field( + ui, + "API Key", + &editor.secondary_secret, + SettingsProviderField::SecondarySecret, + true, + actions, + ); + } + SettingsProviderAuth::Other => { ui.label( - egui::RichText::new(tr_l10n(lang, "providers.credentials")) - .size(11.0) + egui::RichText::new(tr_l10n(lang, "providers.core_note")) + .size(11.5) .color(theme::INK_4), ); - match editor.auth { - SettingsProviderAuth::None => { - ui.label( - egui::RichText::new(tr_l10n(lang, "providers.no_cloud_note")) - .size(11.0) - .color(theme::INK_4), - ); - } - SettingsProviderAuth::OAuth => { - ui.label( - egui::RichText::new(tr_l10n(lang, "providers.oauth_note")) - .size(11.0) - .color(theme::INK_4), - ); - } - SettingsProviderAuth::Volcengine => { - let mut mode = editor.auth_mode.clone(); - ui.horizontal(|ui| { - ui.label(egui::RichText::new("Auth").size(11.5).color(theme::INK_3)); - egui::ComboBox::from_id_salt("settings-provider-auth-mode") - .selected_text(&mode) - .show_ui(ui, |ui| { - for option in ["app_id_token", "api_key"] { - if ui.selectable_label(mode == option, option).clicked() { - mode = option.to_string(); - ui.close(); - } - } - }); - }); - if mode != editor.auth_mode { - actions.push(FrontendAction::SettingsProviderField( - SettingsProviderField::AuthMode, - mode.clone(), - )); - } - if mode == "api_key" { - provider_field( - ui, - "API Key", - &editor.primary_secret, - SettingsProviderField::PrimarySecret, - true, - actions, - ); - } else { - provider_field( - ui, - "APP ID", - &editor.primary_secret, - SettingsProviderField::PrimarySecret, - true, - actions, - ); - provider_field( - ui, - "Access Token", - &editor.secondary_secret, - SettingsProviderField::SecondarySecret, - true, - actions, - ); - } - provider_field( - ui, - "Resource ID", - &editor.resource_id, - SettingsProviderField::ResourceId, - false, - actions, - ); - } - SettingsProviderAuth::Xfyun => { - provider_field( - ui, - "AppID", - &editor.primary_secret, - SettingsProviderField::PrimarySecret, - true, - actions, - ); - provider_field( - ui, - "API Key", - &editor.secondary_secret, - SettingsProviderField::SecondarySecret, - true, - actions, - ); - } - SettingsProviderAuth::Other => { - ui.label( - egui::RichText::new(tr_l10n(lang, "providers.core_note")) - .size(11.0) - .color(theme::INK_4), - ); - } - SettingsProviderAuth::ApiKey => { - provider_field( - ui, - tr_l10n(lang, "providers.api_key_hint"), - &editor.primary_secret, - SettingsProviderField::PrimarySecret, - true, - actions, - ); - provider_field( - ui, - "Endpoint", - &editor.endpoint, - SettingsProviderField::Endpoint, - false, - actions, - ); - } - } - provider_model_block(ui, editor, lang, actions); - ui.add_space(4.0); - ui.horizontal(|ui| { - if let Some(index) = channel_index { - // 两步确认:第一次点只是展开说明与「确认删除」,避免误删。 - let confirm_id = egui::Id::new(("settings-channel-delete", &editor.channel_id)); - let confirming = - ui.data(|data| data.get_temp::(confirm_id).unwrap_or(false)); - if confirming { - ui.label( - egui::RichText::new(tr_l10n(lang, "settings.channels.delete_confirm")) - .size(11.0) - .color(theme::INK_2), - ); - if provider_small_button(ui, lang, "settings.channels.confirm_delete", true) - { - actions.push(FrontendAction::SettingsChannelDelete(index)); - ui.data_mut(|data| data.remove_temp::(confirm_id)); - } - if provider_small_button(ui, lang, "common.cancel", false) { - ui.data_mut(|data| data.remove_temp::(confirm_id)); - } - } else if provider_small_button(ui, lang, "settings.channels.delete", false) { - ui.data_mut(|data| data.insert_temp(confirm_id, true)); - } - ui.add_space(8.0); - } - if provider_small_button(ui, lang, "btn.save_fields", true) { - actions.push(FrontendAction::SettingsProviderSave); - } - if provider_small_button(ui, lang, "btn.clear_secret", false) { - actions.push(FrontendAction::SettingsProviderClearSecrets); - } - if provider_small_button(ui, lang, "btn.close", false) { - actions.push(FrontendAction::SettingsProviderClose); - } - }); - }); + } + SettingsProviderAuth::ApiKey => { + provider_field( + ui, + tr_l10n(lang, "providers.api_key_hint"), + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + actions, + ); + provider_field( + ui, + "Endpoint", + &editor.endpoint, + SettingsProviderField::Endpoint, + false, + actions, + ); + } + } + ui.add_space(6.0); + provider_model_block(ui, editor, lang, actions); +} + +/// Tauri `.ol-channel-form-row`:`grid-template-columns: 140px minmax(0, 1fr)`, +/// 标签与控件同一行、标签顶部对齐(label `padding-top: 9px`)。 +fn modal_form_row( + ui: &mut egui::Ui, + label: &str, + control: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + /// Tauri 的标签列宽。 + const LABEL_WIDTH: f32 = 140.0; + /// Tauri `gap: 8px 20px`。 + const COLUMN_GAP: f32 = 20.0; + /// Tauri 渠道弹窗里的控件高 38,标签用 `padding-top: 9px` 与它对齐;标签格就取 + /// 同一行高,不要用 `available_height()`——在纵向布局里那可能是整屏高度,会把 + /// 这一行撑开、后面的控件被挤出可见区。 + const ROW_HEIGHT: f32 = 38.0; + ui.horizontal(|ui| { + let (label_rect, _) = + ui.allocate_exact_size(egui::vec2(LABEL_WIDTH, ROW_HEIGHT), egui::Sense::hover()); + ui.painter().text( + egui::pos2(label_rect.left(), label_rect.top() + 9.0), + egui::Align2::LEFT_TOP, + label, + egui::FontId::proportional(13.0), + theme::INK_2, + ); + ui.add_space(COLUMN_GAP); + control(ui) + }) + .inner } fn add_channel_form( @@ -4316,6 +4735,7 @@ mod tests { for auth in shapes { let editor = SettingsProviderEditor { channel_id: "channel".to_string(), + is_asr: false, provider: "volcengine".to_string(), provider_type: "volcengine".to_string(), name: "main".to_string(), @@ -4337,7 +4757,7 @@ mod tests { let mut actions = Vec::new(); let ctx = egui::Context::default(); let _ = crate::ui::frontend::run_pass(&ctx, egui::RawInput::default(), |ui| { - provider_editor_panel(ui, &editor, &[], None, Lang::ZhCn, &mut actions); + provider_editor_body(ui, &editor, &[], None, Lang::ZhCn, &mut actions); }); assert!( actions.is_empty(), @@ -4353,6 +4773,7 @@ mod tests { let editor = |static_models: Vec, has_models_url: bool, custom_model: bool| { SettingsProviderEditor { channel_id: "channel".to_string(), + is_asr: false, provider: "volcengine".to_string(), provider_type: "volcengine".to_string(), name: "main".to_string(), @@ -4407,6 +4828,97 @@ mod tests { ); } + /// 渠道编辑器在桌面端是**右栏覆盖**(Tauri 的 embedded 形态):整栏画灰底、 + /// 头部是返回按钮而不是关闭按钮,正文是白底圆角表单卡,底部有删除与完成。 + #[test] + fn the_channel_editor_covers_the_content_column_with_tauri_chrome() { + use super::super::view_model::{SettingsChannel, SettingsChannelProvider}; + + let ctx = egui::Context::default(); + let vm = crate::ui::frontend::FrontendViewModel { + lang: Lang::ZhCn, + provider_editor: Some(SettingsProviderEditor { + channel_id: "channel".to_string(), + is_asr: false, + provider: "DeepSeek".to_string(), + provider_type: "deepseek".to_string(), + name: "DeepSeek".to_string(), + endpoint: "https://api.deepseek.com/v1".to_string(), + model: "deepseek-v4-flash".to_string(), + resource_id: String::new(), + auth_mode: "api_key".to_string(), + auth: SettingsProviderAuth::ApiKey, + primary_secret: String::new(), + secondary_secret: String::new(), + models: Vec::new(), + models_loading: false, + static_models: Vec::new(), + default_model: String::new(), + has_models_url: true, + custom_model: false, + busy: false, + }), + channel_providers: vec![SettingsChannelProvider { + provider_type: "deepseek".to_string(), + label: "DeepSeek".to_string(), + }], + channels: vec![SettingsChannel { + id: "channel".to_string(), + name: "DeepSeek".to_string(), + provider: "DeepSeek".to_string(), + provider_type: "deepseek".to_string(), + model: "deepseek-v4-flash".to_string(), + is_active: true, + enabled: true, + last_ok: None, + last_error: None, + last_latency_ms: None, + last_check_age_seconds: None, + }], + ..Default::default() + }; + let mut actions = Vec::new(); + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(700.0, 680.0), + )), + ..Default::default() + }, + |ui| channel_editor_panel(ui, &vm, &mut actions), + ); + let mut text = String::new(); + for clipped in &output.shapes { + collect_shape_text(&clipped.shape, &mut text); + } + let painted = |needle: &str| text.contains(needle); + for key in [ + "settings.channels.edit_title", + "settings.channels.llm_title", + "settings.channels.auto_save_hint", + "settings.channels.connection_title", + "settings.channels.provider_label", + "settings.channels.name_label", + "settings.channels.name_hint", + "settings.channels.delete", + "settings.channels.done", + ] { + let expected = tr_l10n(Lang::ZhCn, key); + assert!( + painted(expected), + "{key} must be painted inside the channel editor, got {text:?}" + ); + } + // 右栏覆盖不该出现设置页自己的标题(编辑器整栏盖住内容区)。 + let section_title = tr_l10n(Lang::ZhCn, "modal.service_views.llm"); + assert!( + !painted(&format!("{section_title}服务")), + "the settings section body must not show through the editor" + ); + } + fn painted_provider_editor(editor: &SettingsProviderEditor) -> String { let ctx = egui::Context::default(); let mut actions = Vec::new(); @@ -4419,7 +4931,7 @@ mod tests { )), ..Default::default() }, - |ui| provider_editor_panel(ui, editor, &[], None, Lang::ZhCn, &mut actions), + |ui| provider_editor_body(ui, editor, &[], None, Lang::ZhCn, &mut actions), ); let mut text = String::new(); for clipped in &output.shapes { diff --git a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs index 1ddcd06cb..08613f952 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs @@ -418,6 +418,8 @@ pub enum SettingsProviderAuth { #[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] pub struct SettingsProviderEditor { pub channel_id: String, + /// 渠道种类:弹窗副标题在「文字处理渠道 / 语音识别渠道」之间切换。 + pub is_asr: bool, /// Localized provider label (read-only). pub provider: String, pub provider_type: String, diff --git a/openless-all/app/linux-egui/src/ui/theme.rs b/openless-all/app/linux-egui/src/ui/theme.rs index 4aab0fc39..56bb9686b 100644 --- a/openless-all/app/linux-egui/src/ui/theme.rs +++ b/openless-all/app/linux-egui/src/ui/theme.rs @@ -63,6 +63,14 @@ pub const CAPSULE_BADGE_BG: egui::Color32 = egui::Color32::from_rgb(250, 250, 25 pub const CAPSULE_BADGE_BORDER: egui::Color32 = egui::Color32::from_rgba_premultiplied(9, 24, 58, 64); pub const ERR: egui::Color32 = egui::Color32::from_rgb(220, 38, 38); +/// 蓝色主按钮的悬停色(Tauri 的 `.ol-btn-blue:hover` 略深)。 +pub const BLUE_HOVER: egui::Color32 = egui::Color32::from_rgb(29, 78, 216); +/// Tauri `.ol-channel-delete-button`:`rgba(220,38,38,0.05)` 底 + `0.3` 红描边。 +pub const DANGER_FILL: egui::Color32 = egui::Color32::from_rgba_premultiplied(11, 2, 2, 13); +/// 危险按钮悬停底色(比默认略深一档)。 +pub const DANGER_FILL_HOVER: egui::Color32 = egui::Color32::from_rgba_premultiplied(17, 3, 3, 26); +/// Tauri `.ol-channel-delete-button` 的 `rgba(220,38,38,0.3)` 描边。 +pub const DANGER_BORDER: egui::Color32 = egui::Color32::from_rgba_premultiplied(66, 11, 11, 77); /// Font key of the registered Medium face (see [`medium_font`]). const MEDIUM_FACE: &str = "openless-medium"; diff --git a/openless-all/app/scripts/sync-egui-lucide-icons.mjs b/openless-all/app/scripts/sync-egui-lucide-icons.mjs index f3a7ef802..6602c969b 100644 --- a/openless-all/app/scripts/sync-egui-lucide-icons.mjs +++ b/openless-all/app/scripts/sync-egui-lucide-icons.mjs @@ -19,6 +19,7 @@ export const icons = { Download: 'Download', Upload: 'Upload', Plus: 'Plus', Play: 'Play', Stop: 'Square', Close: 'X', Check: 'Check', Send: 'ArrowUp', Pin: 'Pin', Chat: 'MessageSquare', More: 'Ellipsis', ChevronRight: 'ChevronRight', + ChevronLeft: 'ChevronLeft', Feather: 'Feather', Layout: 'PanelLeft', Doc: 'FileText', Pencil: 'Pencil', Cloud: 'Cloud', Shield: 'ShieldCheck', Info: 'Info', Help: 'CircleHelp', External: 'ExternalLink', Monitor: 'Monitor', Grip: 'GripVertical', From 7d569f01a16024af3f8fa228cefd17fb2de313d4 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Mon, 28 Sep 2026 22:18:28 +0800 Subject: [PATCH 28/35] feat(linux-egui): auto-save the channel editor and add channels through it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 Tauri 补齐两处行为(用户要求「都要同步」): 1. **字段修改后自动保存**(Tauri `CredentialInput` 的 300ms 防抖 + 失焦/离开前 flush)。UI 侧记一个「防抖截止时刻」在 egui 临时数据里:任一字段(名称、 Endpoint、模型、Resource ID、Auth 模式、密钥、供应商切换、模型预设)改动即标脏, 到点推一次 `SettingsProviderSave`;`完成`/返回先 flush 再关。 因此底部去掉「保存字段/Secret」按钮,只剩「清除 Secret」与「完成」。 ——保留「清除 Secret」是有意的差异:Tauri 的密钥字段会读回已存值(清空输入框 即删除),而我们的密钥草稿始终是只写的(已存密钥不进 UI 进程),没有这个按钮就 无法擦除已保存的密钥。若要完全对齐,需要把密钥读回界面,安全性上是降级的。 2. **「添加渠道」走同一个编辑器**(Tauri `startCreate`):点按钮先在后台建一张空 渠道(名字留空、供应商取预设第一项),宿主 `ChannelCreated` 后自动打开编辑器并 标记为草稿 → 标题是「添加渠道」;用户完全没交互就关闭时把这张空渠道回收掉 (Tauri `shouldRecycleDraft` + `deleteChannelIfBlank`)。原来的内嵌小表单 (`add_channel_form` + `SettingsChannelFormOpen/Provider/Name/Create` 四个 action 与三个 VM 字段)整块删除。 测试:`editor_auto_save_debounces_and_flushes_before_closing`(防抖窗口内不保存、 到点只保存一次、关闭前 flush、干净关闭不保存)、右栏覆盖测试新增草稿标题断言、 `typed_text_survives...` 里那段内嵌表单断言改成编辑器字段断言。 package revision 123。 --- openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/main.rs | 70 +++-- .../app/linux-egui/src/ui/frontend/mod.rs | 50 +++- .../linux-egui/src/ui/frontend/settings.rs | 272 ++++++++++-------- .../linux-egui/src/ui/frontend/view_model.rs | 14 +- 5 files changed, 257 insertions(+), 151 deletions(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 9f54fe313..190a18037 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -122 +123 diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 6b2fc2d0e..aec01041e 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -553,6 +553,11 @@ mod linux_app { providers: ProvidersState, selected_channel_id: Option, provider_editor: ProviderEditorState, + /// 当前编辑器是「添加渠道」草稿(Tauri `isDraft`):标题换成「添加渠道」, + /// 且完全没交互就关闭时要把这张空渠道回收掉。 + editor_draft: bool, + /// 草稿是否被用户动过(任一字段/保存/供应商切换)。 + draft_touched: bool, /// Draft mirrored into the view model while the editor is open. provider_editor_form: Option, provider_models: Vec, @@ -728,6 +733,8 @@ mod linux_app { providers: ProvidersState::Loading, selected_channel_id: None, provider_editor: ProviderEditorState::Idle, + editor_draft: false, + draft_touched: false, provider_editor_form: None, provider_models: Vec::new(), new_provider_type: String::new(), @@ -826,6 +833,8 @@ mod linux_app { providers: ProvidersState::Loading, selected_channel_id: None, provider_editor: ProviderEditorState::Idle, + editor_draft: false, + draft_touched: false, provider_editor_form: None, provider_models: Vec::new(), new_provider_type: String::new(), @@ -2231,6 +2240,8 @@ mod linux_app { self.provider_editor = ProviderEditorState::Idle; self.provider_editor_form = None; self.frontend_vm.provider_editor = None; + self.editor_draft = false; + self.draft_touched = false; } /// Core owns the model catalog; the host only forwards the request. @@ -4119,6 +4130,7 @@ mod linux_app { frontend::view_model::SettingsProviderEditor { channel_id: form.channel_id.clone(), is_asr: matches!(form.kind, openless_core::ChannelKind::Asr), + is_draft: self.editor_draft, provider: form.label.clone(), provider_type: form.provider_type.clone(), name: form.name.clone(), @@ -5536,39 +5548,30 @@ mod linux_app { self.load_providers(kind); } } - frontend::view_model::FrontendAction::SettingsChannelFormOpen(open) => { - self.frontend_vm.channel_form_open = open; - if open { - self.frontend_vm.channel_form_name.clear(); - self.frontend_vm.channel_provider_index = 0; - } - } - frontend::view_model::FrontendAction::SettingsChannelProvider(index) => { - self.frontend_vm.channel_provider_index = index; - } - frontend::view_model::FrontendAction::SettingsChannelName(name) => { - self.frontend_vm.channel_form_name = name; - } - frontend::view_model::FrontendAction::SettingsChannelCreate => { + // Tauri `startCreate`:点「添加渠道」先在后台建一张空渠道(名字留空), + // 再打开同一个编辑器——草稿卡片只是为了让凭据有渠道 id 可落盘; + // 用户完全没交互就关掉时会被回收(见 `SettingsProviderClose`)。 + frontend::view_model::FrontendAction::SettingsChannelDraft => { let kind = self.settings_channel_kind; + // 供应商面板还没加载完时也照 Tauri 的 `presets[0]?.id ?? ''` 建 + // 一张空供应商的草稿:弹窗里可以再选。 let provider_type = self .frontend_vm .channel_providers - .get(self.frontend_vm.channel_provider_index) - .map(|provider| provider.provider_type.clone()); - let name = self.frontend_vm.channel_form_name.trim().to_string(); - if let (Some(backend), Some(provider_type)) = - (self.backend(), provider_type) - { + .first() + .map(|provider| provider.provider_type.clone()) + .unwrap_or_default(); + if let Some(backend) = self.backend() { + self.editor_draft = true; + self.draft_touched = false; let tx = self.tx.clone(); self.tokio.spawn(async move { let result = backend - .create_channel(kind, provider_type, name) + .create_channel(kind, provider_type, String::new()) .await .map_err(|error| error.to_string()); let _ = tx.send(UiResult::ChannelCreated { kind, result }); }); - self.frontend_vm.channel_form_open = false; } } frontend::view_model::FrontendAction::ShortcutMenu(field) => { @@ -5626,6 +5629,8 @@ mod linux_app { self.apply_style_hotkey_repack(index, pack_index); } frontend::view_model::FrontendAction::SettingsChannelSelect(index) => { + self.editor_draft = false; + self.draft_touched = false; self.open_provider_editor(index); } frontend::view_model::FrontendAction::SettingsChannelMove { index, delta } => { @@ -5690,6 +5695,7 @@ mod linux_app { } } frontend::view_model::FrontendAction::SettingsProviderField(field, value) => { + self.draft_touched = true; if let Some(form) = self.provider_editor_form.as_mut() { match field { frontend::view_model::SettingsProviderField::Name => { @@ -5717,6 +5723,7 @@ mod linux_app { } } frontend::view_model::FrontendAction::SettingsProviderSave => { + self.draft_touched = true; if let Some(editor) = self.editor_from_form() { if let Some(backend) = self.backend() { let lang = self.lang; @@ -5806,6 +5813,25 @@ mod linux_app { } } frontend::view_model::FrontendAction::SettingsProviderClose => { + let recycle = self.editor_draft && !self.draft_touched; + let channel_id = self + .provider_editor_form + .as_ref() + .map(|form| form.channel_id.clone()); + if recycle { + if let (Some(backend), Some(channel_id)) = (self.backend(), channel_id) + { + let kind = self.settings_channel_kind; + let lang = self.lang; + self.spawn(async move { + backend.delete_channel(kind, channel_id).await?; + Ok(tr_l10n(lang, "status.channel_deleted").to_string()) + }); + self.load_settings_channels(); + self.load_service_configured(); + self.load_providers(kind); + } + } self.close_provider_editor(); } frontend::view_model::FrontendAction::SettingsChannelToggle(index) => { diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index 7fc4d653a..df3635ac3 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -667,7 +667,9 @@ mod tests { "the typed value must actually be painted" ); - // 3) 添加渠道表单里的名称输入框(AI 服务与模型 → 语音识别) + // 3) 渠道编辑器(右栏覆盖)里的「渠道名称」输入框:输入立刻推 + // `SettingsProviderField`,宿主写回草稿并自动保存;这里按宿主的行为把 + // 动作回灌到 VM,验证字段吃字、并且画得出来。 let ctx = egui::Context::default(); let mut vm = FrontendViewModel { lang: zh, @@ -675,10 +677,31 @@ mod tests { settings_open: true, settings_section: super::view_model::SettingsSection::Services, services_view: 1, - channel_form_open: true, + provider_editor: Some(super::view_model::SettingsProviderEditor { + channel_id: "channel".to_string(), + is_asr: false, + is_draft: true, + provider: "DeepSeek".to_string(), + provider_type: "deepseek".to_string(), + name: String::new(), + endpoint: String::new(), + model: String::new(), + resource_id: String::new(), + auth_mode: String::new(), + auth: super::view_model::SettingsProviderAuth::ApiKey, + primary_secret: String::new(), + secondary_secret: String::new(), + models: Vec::new(), + models_loading: false, + static_models: Vec::new(), + default_model: String::new(), + has_models_url: false, + custom_model: false, + busy: false, + }), ..Default::default() }; - let id = egui::Id::new("openless-settings-channel-name"); + let id = egui::Id::new(("openless-settings-provider-field", "Name".to_string())); let mut painted = String::new(); for _ in 0..2 { ctx.begin_pass(egui::RawInput { @@ -699,14 +722,29 @@ mod tests { let mut actions = Vec::new(); render(&ctx, &mut vm, &mut actions); painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); + // 宿主行为:把字段写回草稿(自动保存只负责落盘,不改草稿值)。 + for action in actions { + if let super::view_model::FrontendAction::SettingsProviderField( + super::view_model::SettingsProviderField::Name, + value, + ) = action + { + if let Some(editor) = vm.provider_editor.as_mut() { + editor.name = value; + } + } + } } assert_eq!( - vm.channel_form_name, "my", - "the add-channel form must accept typed characters" + vm.provider_editor + .as_ref() + .map(|editor| editor.name.clone()), + Some("my".to_string()), + "the channel editor must accept typed characters" ); assert!( painted.contains("my"), - "the add-channel form must paint what was typed" + "the channel editor must paint what was typed" ); } diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index ab88213ac..6e930bbb2 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -2018,14 +2018,11 @@ fn services(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec egui::Id { + egui::Id::new("openless-channel-editor-autosave") +} + +/// 记一次改动并按 Tauri 的节奏安排落盘:输入停下 300ms(这里取 0.4s 留点余量)后保存。 +fn editor_mark_dirty(ui: &egui::Ui) { + const AUTO_SAVE_DELAY: f64 = 0.4; + let deadline = ui.input(|input| input.time) + AUTO_SAVE_DELAY; + ui.data_mut(|data| data.insert_temp(editor_autosave_id(), deadline)); + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(400)); +} + +/// 到点就落盘。返回是否推了保存。 +fn editor_auto_save(ui: &egui::Ui, actions: &mut Vec) -> bool { + let Some(deadline) = ui.data(|data| data.get_temp::(editor_autosave_id())) else { + return false; + }; + let now = ui.input(|input| input.time); + if now < deadline { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis( + ((deadline - now) * 1000.0).clamp(16.0, 400.0) as u64, + )); + return false; + } + ui.data_mut(|data| data.remove_temp::(editor_autosave_id())); + actions.push(FrontendAction::SettingsProviderSave); + true +} + +/// 关闭编辑器:先把还没落盘的改动冲掉(Tauri 的 flush-on-leave),再关。 +fn editor_close(ui: &egui::Ui, actions: &mut Vec) { + if ui + .data(|data| data.get_temp::(editor_autosave_id())) + .is_some() + { + ui.data_mut(|data| data.remove_temp::(editor_autosave_id())); + actions.push(FrontendAction::SettingsProviderSave); + } + actions.push(FrontendAction::SettingsProviderClose); +} + /// Tauri 的渠道编辑器在桌面端是 **embedded**:`ChannelModal` 被 portal 进右侧内容栏 /// (`.ol-settings-content-pane`),靠 `.ol-channel-dialog-embedded { position: absolute; /// inset: 0; background: var(--ol-sidebar-bg) }` 整栏盖住——不是全窗口遮罩加居中卡片。 @@ -3086,6 +3130,9 @@ fn channel_editor_panel( return; }; let lang = vm.lang; + // Tauri:字段修改后自动保存(300ms 防抖 + 失焦/离开前 flush)。这里到点就推一次 + // `SettingsProviderSave`;没到点则请求下一帧再看。 + editor_auto_save(ui, actions); let edited = vm .channels .iter() @@ -3152,19 +3199,24 @@ fn channel_editor_panel( 18.0, ); if back_response.clicked() { - actions.push(FrontendAction::SettingsProviderClose); + editor_close(ui, actions); } let heading_left = back.right() + 14.0; let title_painter = ui.painter().clone(); let title_y = column.top() + 18.0; + let title_key = if editor.is_draft { + "settings.channels.create_title" + } else { + "settings.channels.edit_title" + }; title_painter.text( egui::pos2(heading_left, title_y), egui::Align2::LEFT_TOP, - tr_l10n(lang, "settings.channels.edit_title"), + tr_l10n(lang, title_key), egui::FontId::proportional(21.0), theme::INK, ); - let title_width = layout::text_width(ui, tr_l10n(lang, "settings.channels.edit_title"), 21.0); + let title_width = layout::text_width(ui, tr_l10n(lang, title_key), 21.0); let kind_title = tr_l10n( lang, if editor.is_asr { @@ -3309,7 +3361,6 @@ fn channel_editor_panel( ); let done_width = layout::text_width(&footer_ui, tr_l10n(lang, "settings.channels.done"), 12.0) + 30.0; - let save_width = layout::text_width(&footer_ui, tr_l10n(lang, "btn.save_fields"), 11.5) + 22.0; let clear_width = layout::text_width(&footer_ui, tr_l10n(lang, "btn.clear_secret"), 11.5) + 22.0; let mut cursor = footer.right() - SIDE_PADDING - done_width; @@ -3317,12 +3368,7 @@ fn channel_editor_panel( egui::pos2(cursor, footer.center().y - 17.0), egui::vec2(done_width, 34.0), ); - cursor -= 16.0 + save_width; - let save_rect = egui::Rect::from_min_size( - egui::pos2(cursor, footer.center().y - 14.0), - egui::vec2(save_width, 28.0), - ); - cursor -= 8.0 + clear_width; + cursor -= 16.0 + clear_width; let clear_rect = egui::Rect::from_min_size( egui::pos2(cursor, footer.center().y - 14.0), egui::vec2(clear_width, 28.0), @@ -3357,22 +3403,7 @@ fn channel_editor_panel( theme::SURFACE, ); if done.clicked() { - actions.push(FrontendAction::SettingsProviderClose); - } - let save = footer_ui.interact( - save_rect, - egui::Id::new("openless-channel-editor-save"), - egui::Sense::click(), - ); - paint_ghost_small_button( - &footer_ui, - save_rect, - save.hovered(), - tr_l10n(lang, "btn.save_fields"), - 11.5, - ); - if save.clicked() { - actions.push(FrontendAction::SettingsProviderSave); + editor_close(ui, actions); } let clear = footer_ui.interact( clear_rect, @@ -3581,6 +3612,7 @@ fn provider_editor_body( }); }); if mode != editor.auth_mode { + editor_mark_dirty(ui); actions.push(FrontendAction::SettingsProviderField( SettingsProviderField::AuthMode, mode.clone(), @@ -3701,87 +3733,6 @@ fn modal_form_row( .inner } -fn add_channel_form( - ui: &mut egui::Ui, - vm: &mut FrontendViewModel, - actions: &mut Vec, -) { - let lang = vm.lang; - ui.horizontal(|ui| { - let options: Vec = vm - .channel_providers - .iter() - .map(|provider| provider.label.clone()) - .collect(); - let selected = vm - .channel_provider_index - .min(options.len().saturating_sub(1)); - let mut new_selection = selected; - egui::ComboBox::from_id_salt("settings-new-channel-provider") - .selected_text(options.get(selected).cloned().unwrap_or_default()) - .show_ui(ui, |ui| { - for (index, option) in options.iter().enumerate() { - if ui.selectable_label(index == selected, option).clicked() { - new_selection = index; - ui.close(); - } - } - }); - if new_selection != selected { - actions.push(FrontendAction::SettingsChannelProvider(new_selection)); - } - // 推的是编辑后的值:推编辑前的拷贝会让宿主把旧值写回字段,每敲一个字 - // 就被回灌一次(渠道名、下划线搜索框都踩过这个坑)。 - let response = layout::text_input( - ui, - &mut vm.channel_form_name, - egui::Id::new("openless-settings-channel-name"), - tr_l10n(lang, "settings.channels.name_placeholder"), - 200.0, - false, - ); - if response.changed() { - actions.push(FrontendAction::SettingsChannelName( - vm.channel_form_name.clone(), - )); - } - if ui - .add( - egui::Button::new( - egui::RichText::new(tr_l10n(lang, "settings.channels.create")) - .color(theme::SURFACE) - .size(11.5), - ) - .fill(theme::INK) - .stroke(egui::Stroke::NONE) - .corner_radius(egui::CornerRadius::same(8)) - .min_size(egui::vec2(0.0, 26.0)), - ) - .clicked() - { - actions.push(FrontendAction::SettingsChannelCreate); - } - if ui - .add( - egui::Button::new(egui::RichText::new(tr_l10n(lang, "common.cancel")).size(11.5)) - .fill(theme::SURFACE_2) - .stroke(egui::Stroke::new(0.8, theme::LINE)) - .corner_radius(egui::CornerRadius::same(8)) - .min_size(egui::vec2(0.0, 26.0)), - ) - .clicked() - { - actions.push(FrontendAction::SettingsChannelFormOpen(false)); - } - }); - ui.label( - egui::RichText::new(tr_l10n(lang, "settings.channels.name_hint")) - .size(11.0) - .color(theme::INK_4), - ); -} - -/// A row whose value is a button that opens a link / performs an action. fn link_row( ui: &mut egui::Ui, label: &str, @@ -4736,6 +4687,7 @@ mod tests { let editor = SettingsProviderEditor { channel_id: "channel".to_string(), is_asr: false, + is_draft: false, provider: "volcengine".to_string(), provider_type: "volcengine".to_string(), name: "main".to_string(), @@ -4774,6 +4726,7 @@ mod tests { SettingsProviderEditor { channel_id: "channel".to_string(), is_asr: false, + is_draft: false, provider: "volcengine".to_string(), provider_type: "volcengine".to_string(), name: "main".to_string(), @@ -4828,6 +4781,71 @@ mod tests { ); } + /// Tauri 的自动保存:字段改动后防抖落盘;关闭前先把未落盘的改动冲掉。 + #[test] + fn editor_auto_save_debounces_and_flushes_before_closing() { + let ctx = egui::Context::default(); + let mut actions = Vec::new(); + let raw = |time: f64| egui::RawInput { + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(700.0, 680.0), + )), + ..Default::default() + }; + + // 第一帧:标脏,未到防抖截止 → 不保存。 + crate::ui::frontend::run_pass(&ctx, raw(10.0), |ui| { + editor_mark_dirty(ui); + assert!( + !editor_auto_save(ui, &mut actions), + "the debounce window must not save yet" + ); + }); + assert!( + actions.is_empty(), + "nothing may be saved during the debounce" + ); + + // 时间推进到截止之后 → 推一次保存,并且只推一次。 + crate::ui::frontend::run_pass(&ctx, raw(11.0), |ui| { + assert!( + editor_auto_save(ui, &mut actions), + "the settled edit must be saved" + ); + assert!( + !editor_auto_save(ui, &mut actions), + "an already saved edit must not save twice" + ); + }); + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], FrontendAction::SettingsProviderSave)); + + // 关闭时:脏则先保存再关;干净则只关。 + actions.clear(); + crate::ui::frontend::run_pass(&ctx, raw(12.0), |ui| { + editor_mark_dirty(ui); + }); + crate::ui::frontend::run_pass(&ctx, raw(12.1), |ui| { + editor_close(ui, &mut actions); + }); + assert_eq!( + actions.len(), + 2, + "closing must flush the pending edit first" + ); + assert!(matches!(actions[0], FrontendAction::SettingsProviderSave)); + assert!(matches!(actions[1], FrontendAction::SettingsProviderClose)); + + actions.clear(); + crate::ui::frontend::run_pass(&ctx, raw(13.0), |ui| { + editor_close(ui, &mut actions); + }); + assert_eq!(actions.len(), 1, "a clean editor closes without saving"); + assert!(matches!(actions[0], FrontendAction::SettingsProviderClose)); + } + /// 渠道编辑器在桌面端是**右栏覆盖**(Tauri 的 embedded 形态):整栏画灰底、 /// 头部是返回按钮而不是关闭按钮,正文是白底圆角表单卡,底部有删除与完成。 #[test] @@ -4840,6 +4858,7 @@ mod tests { provider_editor: Some(SettingsProviderEditor { channel_id: "channel".to_string(), is_asr: false, + is_draft: false, provider: "DeepSeek".to_string(), provider_type: "deepseek".to_string(), name: "DeepSeek".to_string(), @@ -4917,6 +4936,35 @@ mod tests { !painted(&format!("{section_title}服务")), "the settings section body must not show through the editor" ); + + // 草稿模式(Tauri `isDraft`):标题换成「添加渠道」。 + let mut draft_vm = vm; + if let Some(editor) = draft_vm.provider_editor.as_mut() { + editor.is_draft = true; + } + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(700.0, 680.0), + )), + ..Default::default() + }, + |ui| channel_editor_panel(ui, &draft_vm, &mut actions), + ); + let mut draft_text = String::new(); + for clipped in &output.shapes { + collect_shape_text(&clipped.shape, &mut draft_text); + } + assert!( + draft_text.contains(tr_l10n(Lang::ZhCn, "settings.channels.create_title")), + "a draft editor is titled as a new channel, got {draft_text:?}" + ); + assert!( + !draft_text.contains(tr_l10n(Lang::ZhCn, "settings.channels.edit_title")), + "a draft editor must not say it is editing an existing channel" + ); } fn painted_provider_editor(editor: &SettingsProviderEditor) -> String { diff --git a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs index 08613f952..2b0d323f8 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs @@ -191,13 +191,11 @@ pub enum FrontendAction { /// Delete a channel (index into `settings.channels`). SettingsChannelDelete(usize), /// Open/close the "add channel" form. - SettingsChannelFormOpen(bool), + /// 「添加渠道」:宿主先建一张空渠道,再打开同一个编辑器(草稿模式)。 + SettingsChannelDraft, /// Provider picked in the add-channel form. - SettingsChannelProvider(usize), /// Channel name typed in the add-channel form. - SettingsChannelName(String), /// Create the channel described by the form. - SettingsChannelCreate, /// Select a channel and open its provider editor (index into `channels`). SettingsChannelSelect(usize), /// Move a channel up/down; Core's `reorder_channels` owns the order. @@ -420,6 +418,8 @@ pub struct SettingsProviderEditor { pub channel_id: String, /// 渠道种类:弹窗副标题在「文字处理渠道 / 语音识别渠道」之间切换。 pub is_asr: bool, + /// 草稿(刚从「添加渠道」建出来):标题是「添加渠道」,且未交互就关闭时要回收。 + pub is_draft: bool, /// Localized provider label (read-only). pub provider: String, pub provider_type: String, @@ -875,9 +875,6 @@ pub struct FrontendViewModel { /// Provider kinds offered by the add-channel form. pub channel_providers: Vec, pub channels_loading: bool, - pub channel_form_open: bool, - pub channel_form_name: String, - pub channel_provider_index: usize, /// Open provider editor, or `None` when the channel list is the whole view. pub provider_editor: Option, /// Rail search query in the settings modal. @@ -1030,9 +1027,6 @@ impl Default for FrontendViewModel { channels: Vec::new(), channel_providers: Vec::new(), channels_loading: false, - channel_form_open: false, - channel_form_name: String::new(), - channel_provider_index: 0, provider_editor: None, settings_query: String::new(), shortcut_pending_modifier: None, From 4a08a8fb97b7f6304e81927c26b35d8bf017d6ec Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Mon, 28 Sep 2026 22:32:20 +0800 Subject: [PATCH 29/35] fix(linux-egui): the channel editor must not hijack the settings sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报「打开以后只能显示模型编辑页面,不能改其他设置了」——上一条把渠道编辑器改成 右栏整栏覆盖时漏了两道闸,两处都补上: 1. **别自动弹编辑器**。面板加载后宿主会为「选中渠道」把编辑器拉起来(`selected_channel_id` 的兜底是 active provider 或第一个渠道),这在编辑器以内嵌小面板呈现时无害,改成 整栏覆盖后就变成「一进设置右栏永远是模型编辑器」。新增 `editor_requested`:只有用户 点了「编辑」(`SettingsChannelSelect`)或刚建出草稿(`ChannelCreated`)才允许打开, 面板刷新(保存 / 验证 / 切页 / 保活)不再顺手打开。 2. **覆盖范围跟着分区走**。`channel_editor_panel` 现在只在「AI 服务与模型」这一页替代 `panel()`;切到别的分区时宿主还会主动收起编辑器(Tauri 的 modal 宿主挂在服务页里, 切页即卸载),未交互的草稿照旧回收。 顺带修好 QA 开关 `OPENLESS_FPS_SETTINGS=1`:它以前只在窗口初始化时置一次 `settings_open`,随后被宿主快照覆盖,等于失效;改成每帧采纳快照之后再压一次。 测试:`the_channel_editor_never_covers_other_settings_sections`(通用/外观/关于三个 分区开着编辑器也不许出现「编辑渠道」)。 对照用户数据确认:这次误判没有造成删除——`credential-metadata.json` 仍列出 `llm: deepseek`,mtime 未变,日志无删除记录。 package revision 124。 --- openless-all/app/linux-egui/package-revision | 2 +- .../app/linux-egui/src/linux_app/window.rs | 6 ++ openless-all/app/linux-egui/src/main.rs | 72 +++++++++++++------ .../app/linux-egui/src/ui/frontend/mod.rs | 64 +++++++++++++++++ .../linux-egui/src/ui/frontend/settings.rs | 11 +-- 5 files changed, 128 insertions(+), 27 deletions(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 190a18037..fc902f4f9 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -123 +124 diff --git a/openless-all/app/linux-egui/src/linux_app/window.rs b/openless-all/app/linux-egui/src/linux_app/window.rs index 6852f7e0b..d1e318b48 100644 --- a/openless-all/app/linux-egui/src/linux_app/window.rs +++ b/openless-all/app/linux-egui/src/linux_app/window.rs @@ -437,6 +437,7 @@ impl UiClientApp { let mut view_model = FrontendViewModel::default(); // QA:OPENLESS_FPS_SETTINGS=1 时开机就打开设置面板,直接测最重的渲染路径 // (磨砂背板 + 离屏 4×MSAA 重绘 + 动画预览)。见 `frame_stats` 模块文档。 + // 真正的置位在每帧采纳快照之后(见 `ui()`):宿主快照会把这里覆盖掉。 view_model.settings_open |= openless_linux_egui::frame_stats::settings_open_on_start(); Self { client, @@ -696,6 +697,11 @@ impl eframe::App for UiClientApp { if self.connection_error.is_none() { self.drain_host(&ctx); } + // QA 开关要在**采纳快照之后**再压一次:宿主每帧/每次保活都会重发 view model, + // 只在初始化时置位会被随后到达的快照覆盖掉(这个开关以前就是这么失效的)。 + if openless_linux_egui::frame_stats::settings_open_on_start() { + self.view_model.settings_open = true; + } if !self.client.is_ready() || self.connection_error.is_some() { if self.connection_error.is_none() && self.connecting_since.elapsed() >= bridge::UI_CLIENT_CONNECT_TIMEOUT diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index aec01041e..50d50f588 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -552,6 +552,10 @@ mod linux_app { provider_kind: openless_core::ChannelKind, providers: ProvidersState, selected_channel_id: Option, + /// 用户(或刚建好的草稿)要求打开渠道编辑器。面板加载时只有当它为真才打开: + /// 以前只要面板加载就会为「当前选中的渠道」把编辑器拉起来,改成整栏覆盖后就是 + /// 「一进设置只见模型编辑器、别的设置都改不了」。 + editor_requested: bool, provider_editor: ProviderEditorState, /// 当前编辑器是「添加渠道」草稿(Tauri `isDraft`):标题换成「添加渠道」, /// 且完全没交互就关闭时要把这张空渠道回收掉。 @@ -732,6 +736,7 @@ mod linux_app { provider_kind: openless_core::ChannelKind::Asr, providers: ProvidersState::Loading, selected_channel_id: None, + editor_requested: false, provider_editor: ProviderEditorState::Idle, editor_draft: false, draft_touched: false, @@ -832,6 +837,7 @@ mod linux_app { provider_kind: openless_core::ChannelKind::Asr, providers: ProvidersState::Loading, selected_channel_id: None, + editor_requested: false, provider_editor: ProviderEditorState::Idle, editor_draft: false, draft_touched: false, @@ -2214,6 +2220,7 @@ mod linux_app { /// provider rules, so the UI never invents a field shape; without one /// the editor stays closed instead of guessing. fn open_provider_editor(&mut self, index: usize) { + self.editor_requested = true; let Some(channel_id) = self .settings_channels .get(index) @@ -2236,12 +2243,37 @@ mod linux_app { self.load_provider_editor(kind, channel, descriptor); } + /// 关闭渠道编辑器;完全没交互过的草稿在这条路径上被回收 + /// (Tauri `shouldRecycleDraft` + `deleteChannelIfBlank`)。 + fn request_provider_editor_close(&mut self) { + let recycle = self.editor_draft && !self.draft_touched; + let channel_id = self + .provider_editor_form + .as_ref() + .map(|form| form.channel_id.clone()); + if recycle { + if let (Some(backend), Some(channel_id)) = (self.backend(), channel_id) { + let kind = self.settings_channel_kind; + let lang = self.lang; + self.spawn(async move { + backend.delete_channel(kind, channel_id).await?; + Ok(tr_l10n(lang, "status.channel_deleted").to_string()) + }); + self.load_settings_channels(); + self.load_service_configured(); + self.load_providers(kind); + } + } + self.close_provider_editor(); + } + fn close_provider_editor(&mut self) { self.provider_editor = ProviderEditorState::Idle; self.provider_editor_form = None; self.frontend_vm.provider_editor = None; self.editor_draft = false; self.draft_touched = false; + self.editor_requested = false; } /// Core owns the model catalog; the host only forwards the request. @@ -3373,6 +3405,10 @@ mod linux_app { } self.providers = ProvidersState::Loaded(panel.clone()); self.provider_models.clear(); + // 只把「面板刚加载好」当成一个时机,是否打开编辑器看用户意图: + // 保存 / 验证 / 切换页面都会刷新面板,不能顺手把编辑器拉起来。 + let editor_wanted = self.editor_requested; + self.editor_requested = false; if let Some(channel_id) = selected { // A refresh (enable toggle, save, validation) must not // throw away the editor draft the user is editing: only @@ -3382,7 +3418,7 @@ mod linux_app { ProviderEditorState::Loaded(editor) if editor.channel.id == channel_id ); - if !already_loaded { + if !already_loaded && editor_wanted { if let Some((channel, descriptor)) = provider_channel_descriptor(&panel, &channel_id) { @@ -3634,8 +3670,12 @@ mod linux_app { Ok(channel_id) => { self.settings_channel_kind = kind; self.provider_kind = kind; - self.selected_channel_id = Some(channel_id); + // 草稿:面板加载完就自动打开编辑器,未交互关闭时回收。 self.close_provider_editor(); + self.selected_channel_id = Some(channel_id); + self.editor_draft = true; + self.draft_touched = false; + self.editor_requested = true; self.load_settings_channels(); self.load_service_configured(); self.load_providers(kind); @@ -5522,6 +5562,13 @@ mod linux_app { self.apply_settings_action(field); } frontend::view_model::FrontendAction::SettingsSection(section) => { + // 编辑器只属于「AI 服务与模型」这一页:切到别的分区就收起 + // (Tauri 的 modal 宿主挂在服务页里,切页即卸载)。 + if section != frontend::view_model::SettingsSection::Services + && self.provider_editor_form.is_some() + { + self.request_provider_editor_close(); + } self.frontend_vm.settings_section = section; } frontend::view_model::FrontendAction::SettingsServicesView(view) => { @@ -5813,26 +5860,7 @@ mod linux_app { } } frontend::view_model::FrontendAction::SettingsProviderClose => { - let recycle = self.editor_draft && !self.draft_touched; - let channel_id = self - .provider_editor_form - .as_ref() - .map(|form| form.channel_id.clone()); - if recycle { - if let (Some(backend), Some(channel_id)) = (self.backend(), channel_id) - { - let kind = self.settings_channel_kind; - let lang = self.lang; - self.spawn(async move { - backend.delete_channel(kind, channel_id).await?; - Ok(tr_l10n(lang, "status.channel_deleted").to_string()) - }); - self.load_settings_channels(); - self.load_service_configured(); - self.load_providers(kind); - } - } - self.close_provider_editor(); + self.request_provider_editor_close(); } frontend::view_model::FrontendAction::SettingsChannelToggle(index) => { let kind = self.settings_channel_kind; diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index df3635ac3..4c671dbf2 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -748,6 +748,70 @@ mod tests { ); } + /// 回归:渠道编辑器只能盖住「AI 服务与模型」这一页的右栏。以前只要 + /// `provider_editor` 有值就整栏覆盖,用户一进设置只看得到模型编辑器, + /// 其它设置页全都改不了。 + #[test] + fn the_channel_editor_never_covers_other_settings_sections() { + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let editor = || { + Some(super::view_model::SettingsProviderEditor { + channel_id: "channel".to_string(), + is_asr: false, + is_draft: false, + provider: "DeepSeek".to_string(), + provider_type: "deepseek".to_string(), + name: "DeepSeek".to_string(), + endpoint: String::new(), + model: "deepseek-v4-flash".to_string(), + resource_id: String::new(), + auth_mode: String::new(), + auth: super::view_model::SettingsProviderAuth::ApiKey, + primary_secret: String::new(), + secondary_secret: String::new(), + models: Vec::new(), + models_loading: false, + static_models: Vec::new(), + default_model: String::new(), + has_models_url: false, + custom_model: false, + busy: false, + }) + }; + for section in [ + super::view_model::SettingsSection::General, + super::view_model::SettingsSection::Appearance, + super::view_model::SettingsSection::About, + ] { + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + settings_section: section, + provider_editor: editor(), + ..Default::default() + }; + let mut painted = String::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); + } + assert!( + !painted.contains(openless_linux_egui::tr_l10n( + zh, + "settings.channels.edit_title" + )), + "an open channel editor must not cover the {section:?} section" + ); + } + } + #[test] fn settings_overlay_opens_from_every_page() { // Regression: Overview / Style / History returned early from `render`, so diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index 6e930bbb2..e6a8a591d 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -188,10 +188,13 @@ pub fn settings_overlay( egui::Layout::top_down(egui::Align::Min), |ui| { ui.set_min_height(body_height); - // Tauri 桌面端把渠道编辑器 portal 进右栏并 `inset: 0` - // 盖住整栏(`.ol-channel-dialog-embedded`),而不是 - // 全窗口遮罩;所以这里按同一栏矩形覆盖。 - if vm.provider_editor.is_some() { + // Tauri 桌面端把渠道编辑器 portal 进「AI 服务与模型」 + // 这一页的右栏并 `inset: 0` 盖住整栏,不是全窗口遮罩。 + // 覆盖范围必须跟着分区走:否则别页也会被编辑器盖住, + // 用户会觉得「打开设置只有模型编辑器、别的都改不了」。 + let editor_here = vm.provider_editor.is_some() + && vm.settings_section == SettingsSection::Services; + if editor_here { channel_editor_panel(ui, vm, actions); } else { panel(ui, vm, actions); From 2c0d28b8acc823f42ffad7e96b469968534b9b09 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Mon, 28 Sep 2026 22:52:57 +0800 Subject: [PATCH 30/35] =?UTF-8?q?feat(linux-egui):=20=E6=80=9D=E8=80=83?= =?UTF-8?q?=E5=85=89=E7=82=B9=E7=9A=84=E8=BD=AC=E9=80=9F=E8=B7=9F=E7=9D=80?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E8=BE=93=E5=87=BA=E9=80=9F=E7=8E=87=E8=B5=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户诉求:「润色时能不能根据大模型的文字输出速度转圈」。审计结论:Tauri 版没有这个反馈 (`VoiceOrbStage` 只按状态给固定 speed:thinking 1.5 / 收尾 1.0;`PolishDelta` 在 `tauri_events.rs` 里甚至被显式忽略,胶囊 payload 也没有任何速率字段),所以这里按用户 要求把它做成一个**两侧都没有过的新行为**,但基线刻意对齐 Tauri。 - 新增 `ui/frontend/stream_rate.rs`: · `StreamRate`:宿主侧把流式增量的字符数按 0.5s 滑动窗口折算成「字符/秒」; · `OrbSpeed`:胶囊侧接住宿主下发的速率、只在速率变化时刷新时间戳,然后本地按 0.45s 时间常数衰减 —— 模型吐字快就转快,网关卡住/模型想事情就慢慢回到基线,不需要宿主发心跳; · `speed_for_rate`:4 字/秒以下不加成、60 字/秒到顶,映射到 [1.5, 2.6] 转/秒。 **没有速率数据时正好是 Tauri 的 1.5**,所以默认观感与 Tauri 一致。 - 宿主(`main.rs`):新增 `stream_rate` 计量。非 final 的 `PolishDelta` 记增量字符数, final 时清空样本(流结束立刻回落基线);新会话(`Starting`)也清空。胶囊消息在 `HostToPopup::Capsule` 里多带一个 `stream_rate`(可选字段,老宿主缺省即基线)。 - 胶囊窗口(`popups.rs`):orb 的 `SiriDrive.speed` 不再写死 1.3(顺带修掉与 Tauri 1.5 的 漂移),改由 `OrbSpeed` 逐帧给出;离开思考态复位。 测试:`stream_rate` 模块 8 条(窗口折算、过期、基线、clamp、卡住后衰减、恢复后重爬)+ `the_orb_spins_faster_while_the_model_streams`(走真实渲染路径,用 Siri 时钟推进量比较 「无速率」与「60 字/秒」)+ `capsule_carries_translation_active_and_style` 覆盖协议字段。 package revision 125。 --- openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/main.rs | 26 +- openless-all/app/linux-egui/src/popup.rs | 10 + .../app/linux-egui/src/ui/frontend/mod.rs | 1 + .../app/linux-egui/src/ui/frontend/popups.rs | 86 +++++- .../linux-egui/src/ui/frontend/stream_rate.rs | 251 ++++++++++++++++++ 6 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 openless-all/app/linux-egui/src/ui/frontend/stream_rate.rs diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index fc902f4f9..d136d6a71 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -124 +125 diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 50d50f588..5995c382e 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -583,6 +583,8 @@ mod linux_app { capsule_session: Option, /// 已经为哪个会话排过收起计时,避免重复计时。 capsule_dismissal_scheduled: Option, + /// 流式输出速率计量:润色增量字符数 → 胶囊光点转速(见 `stream_rate` 模块)。 + stream_rate: frontend::stream_rate::StreamRate, tray: Option, exit_requested: bool, /// 上次打「泵心跳」日志的时间。 @@ -755,6 +757,7 @@ mod linux_app { popup_action_guard: PopupActionGuard::default(), capsule_session: None, capsule_dismissal_scheduled: None, + stream_rate: frontend::stream_rate::StreamRate::default(), tray, exit_requested: false, last_pump_heartbeat: std::time::Instant::now(), @@ -856,6 +859,7 @@ mod linux_app { popup_action_guard: PopupActionGuard::default(), capsule_session: None, capsule_dismissal_scheduled: None, + stream_rate: frontend::stream_rate::StreamRate::default(), tray, exit_requested: false, last_pump_heartbeat: std::time::Instant::now(), @@ -1330,6 +1334,7 @@ mod linux_app { CapsuleOutcome::Progress(text) => text, }; let style = self.capsule_style_tag(); + let stream_rate = self.stream_rate.rate(frontend::stream_rate::now_seconds()); self.send_popup( PopupKind::Capsule, HostToPopup::Capsule { @@ -1341,6 +1346,7 @@ mod linux_app { audio_level: Some(snapshot.level), translation_active: snapshot.translation_active, style, + stream_rate: Some(stream_rate), }, ); self.schedule_capsule_dismissal(&session_id.to_string(), snapshot.phase); @@ -1368,6 +1374,7 @@ mod linux_app { audio_level: None, translation_active: false, style: self.capsule_style_tag(), + stream_rate: None, }, ); self.schedule_capsule_dismissal(session_id, DictationPhase::Failed); @@ -2382,6 +2389,8 @@ mod linux_app { self.transcript_state = TranscriptAccumulator::default(); self.transcript.clear(); self.transcript_session = state.session_id; + // 新会话:速率样本清空,避免上一轮的尾巴把首帧转速抬起来。 + self.stream_rate.reset(); } // 终态不写状态栏:`Failed` / `Completed` / `Cancelled` 是 Core 的 // 内部词,用户已经能从胶囊看到本地化文案(Tauri 也只在那里显示)。 @@ -2412,6 +2421,8 @@ mod linux_app { _ => String::new(), }; let style = self.capsule_style_tag(); + let stream_rate = + self.stream_rate.rate(frontend::stream_rate::now_seconds()); self.send_popup( PopupKind::Capsule, HostToPopup::Capsule { @@ -2423,6 +2434,7 @@ mod linux_app { audio_level: Some(state.level), translation_active: state.translation_active, style, + stream_rate: Some(stream_rate), }, ); // 终态:按 Tauri 时序安排自动收起,否则药丸会一直贴在屏幕上。 @@ -2435,8 +2447,17 @@ mod linux_app { { self.transcript = self.transcript_state.text().to_string(); } - BackendEventKind::PolishDelta(delta) if delta.is_final => { - self.transcript = delta.text; + BackendEventKind::PolishDelta(delta) => { + if delta.is_final { + self.transcript = delta.text; + // 流结束:样本清空,胶囊立刻回落基线转速。 + self.stream_rate.reset(); + } else { + // 用户诉求:润色时光点跟着模型的吐字速度转。这里只做「增量字符数 → + // 字符/秒」,随时间衰减与转速映射在胶囊窗口侧(见 stream_rate 模块)。 + self.stream_rate + .observe_text(frontend::stream_rate::now_seconds(), &delta.text); + } } BackendEventKind::DictationCompleted(result) => { self.transcript = result.polished_text; @@ -8480,6 +8501,7 @@ Internal flags (set by OpenLess itself, not for regular use): audio_level: Some(0.2), translation_active: false, style: "siri".into(), + stream_rate: None, }) .expect("channel is open"); assert!(!app.pump(None), "a progress frame must not exit"); diff --git a/openless-all/app/linux-egui/src/popup.rs b/openless-all/app/linux-egui/src/popup.rs index 22f71097c..e3e8cc894 100644 --- a/openless-all/app/linux-egui/src/popup.rs +++ b/openless-all/app/linux-egui/src/popup.rs @@ -129,6 +129,10 @@ pub enum HostToPopup { /// 胶囊样式:`siri` / `classic` / `typeless`(Tauri `capsuleStyle`)。 #[serde(default)] style: String, + /// 流式输出速率(字符/秒),驱动思考光点的转速。宿主每条流式增量重算一次; + /// 缺省(老宿主 / 还没有增量)= 用 Tauri 的基线转速。 + #[serde(default, skip_serializing_if = "Option::is_none")] + stream_rate: Option, }, Hide { version: u16, @@ -673,6 +677,8 @@ pub struct CapsulePopupState { pub translation_active: bool, /// 胶囊样式(`siri` / `classic` / `typeless`)。 pub style: String, + /// 流式输出速率(字符/秒):思考态光点转速跟着它走,`None` = 用基线转速。 + pub stream_rate: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -781,6 +787,7 @@ impl PopupState { audio_level, translation_active, style, + stream_rate, .. } => { self.capsule = CapsulePopupState { @@ -789,6 +796,7 @@ impl PopupState { audio_level, translation_active, style, + stream_rate, }; self.visible = true; } @@ -1454,10 +1462,12 @@ mod tests { audio_level: Some(0.5), translation_active: true, style: "typeless".to_owned(), + stream_rate: Some(42.0), }; let mut state = PopupState::default(); assert_eq!(state.apply(message), ApplyOutcome::Applied); assert!(state.capsule.translation_active); + assert_eq!(state.capsule.stream_rate, Some(42.0)); assert_eq!( state.capsule.style, "typeless", "the capsule style must travel with the frame to the popup process" diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index 4c671dbf2..064691fe0 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -11,6 +11,7 @@ pub mod popups; pub mod selection_ask; pub mod settings; pub use openless_linux_egui::siri_wgpu; +pub mod stream_rate; pub mod style; pub mod translation; pub mod view_model; diff --git a/openless-all/app/linux-egui/src/ui/frontend/popups.rs b/openless-all/app/linux-egui/src/ui/frontend/popups.rs index bbc3ab5cf..c94c96be5 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/popups.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/popups.rs @@ -14,7 +14,7 @@ use eframe::egui; -use super::{icons, layout, siri_wgpu, theme}; +use super::{icons, layout, siri_wgpu, stream_rate, theme}; use openless_linux_egui::{ fmt_l10n, tr_l10n, CapsulePopupState, Lang, LessComputerPopupState, PopupChatMessage, QaPolishState, QaPopupState, @@ -1108,6 +1108,17 @@ pub fn dictation_capsule( phase.as_str(), "starting" | "transcribing" | "polishing" | "inserting" ); + // 思考光点转速的速率响应状态:只在思考态累积,离开思考态复位(下次从头开始)。 + let orb_speed_id = egui::Id::new("openless-capsule-orb-speed"); + if !processing { + let mut responder = ui.ctx().data_mut(|data| { + data.get_temp::(orb_speed_id) + .unwrap_or_default() + }); + responder.reset(); + ui.ctx() + .data_mut(|data| data.insert_temp(orb_speed_id, responder)); + } // Siri is a clean, transparent listening indicator. Classic keeps // both actions visible; Typeless follows Tauri and shows the two // large actions only while recording, then only its small stop @@ -1248,10 +1259,25 @@ pub fn dictation_capsule( } } else if processing { // 思考中:Siri 流体圆点(orb),从 wave 收拢的光点化开成环。 + // 转速跟着流式输出速率(用户诉求:润色时按大模型的吐字速度转):没有速率 + // 数据时是 `BASE_SPEED`(= Tauri 的固定 1.5),有增量就往上加,流停了再衰减 + // 回基线。映射与衰减都在 `stream_rate` 模块里,可单测。 + let now = ui.input(|input| input.time); + let dt = ui.input(|input| input.stable_dt); + let speed = { + let mut responder = ui.ctx().data_mut(|data| { + data.get_temp::(orb_speed_id) + .unwrap_or_default() + }); + let speed = responder.tick(now, dt, state.stream_rate); + ui.ctx() + .data_mut(|data| data.insert_temp(orb_speed_id, responder)); + speed + }; let drive = siri_wgpu::SiriDrive { level: 0.0, resolved: 0.0, - speed: 1.3, + speed, warming: false, }; let dt = ui.input(|input| input.stable_dt); @@ -2503,6 +2529,57 @@ mod tests { } } + /// 用户诉求:润色时光点要按大模型的吐字速度转。这条测试走完整渲染路径(同一个 egui + /// Context、逐帧推进时间),比较「没有速率」与「高速率」两种情况下 Siri 时钟推进了多少 + /// —— 时钟走得快就是光点转得快;没有速率时必须落在 Tauri 的基线转速上。 + #[test] + fn the_orb_spins_faster_while_the_model_streams() { + fn orb_clock_after(frames: usize, rate: Option) -> f32 { + let ctx = egui::Context::default(); + for frame in 0..frames { + let state = CapsulePopupState { + phase: "Polishing".to_string(), + text: String::new(), + audio_level: None, + translation_active: false, + style: "siri".to_string(), + stream_rate: rate, + }; + let _ = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(200.0, 100.0), + )), + time: Some(frame as f64 * 0.05), + predicted_dt: 0.05, + ..Default::default() + }, + |ui| { + let _ = dictation_capsule(ui, &state, Lang::ZhCn); + }, + ); + } + ctx.data(|data| { + data.get_temp::(egui::Id::new(( + "openless-siri-clock", + "capsule-siri-orb", + ))) + .map(|clock| clock.time) + .unwrap_or_default() + }) + } + + let idle = orb_clock_after(20, None); + assert!(idle > 0.0, "the orb clock must advance: {idle}"); + let streaming = orb_clock_after(20, Some(60.0)); + assert!( + streaming > idle * 1.2, + "a fast model stream must spin the orb faster: idle={idle} streaming={streaming}" + ); + } + /// Render one capsule frame and collect the colours, the GPU callback count /// and the number of centre effect strokes (the CPU wave lines). fn capsule_frame(state: &CapsulePopupState) -> (Vec, usize, usize) { @@ -2571,6 +2648,7 @@ mod tests { audio_level: Some(0.6), translation_active: false, style: "classic".to_string(), + stream_rate: None, }; let (colors, _, _) = capsule_frame(&state); let reddish: Vec<_> = colors @@ -2595,6 +2673,7 @@ mod tests { audio_level: Some(0.6), translation_active: false, style: "siri".to_string(), + stream_rate: None, }; let (colors, callbacks, effect) = capsule_frame(&state); // 音量竖条是 3px 宽的小圆角矩形:数一下细长条形的填充个数。 @@ -2723,6 +2802,7 @@ mod tests { audio_level: Some(0.4), translation_active: false, style: "siri".to_string(), + stream_rate: None, }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { dictation_capsule(ctx, &recording, Lang::ZhCn); @@ -2769,6 +2849,7 @@ mod tests { audio_level: None, translation_active: false, style: "siri".to_string(), + stream_rate: None, }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { dictation_capsule(ctx, &done, Lang::ZhCn); @@ -2782,6 +2863,7 @@ mod tests { audio_level: None, translation_active: false, style: "siri".to_string(), + stream_rate: None, }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { dictation_capsule(ctx, &failed, Lang::ZhCn); diff --git a/openless-all/app/linux-egui/src/ui/frontend/stream_rate.rs b/openless-all/app/linux-egui/src/ui/frontend/stream_rate.rs new file mode 100644 index 000000000..1e6d90e87 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/stream_rate.rs @@ -0,0 +1,251 @@ +//! 流式输出速率 → 思考光点转速。 +//! +//! 用户诉求:润色时圆点要「跟着大模型的吐字速度转」——输出快就转得快,模型卡住就慢下来。 +//! Tauri 版没有速率反馈(`VoiceOrbStage` 只按状态给固定 `speed`:thinking 1.5 / 收尾 1.0), +//! 所以这里的基线刻意定成 Tauri 的 1.5:**没有速率数据时观感与 Tauri 完全一致**,只有真的 +//! 收到流式增量(Core 的 `PolishDelta` / `TranscriptDelta`)才会往上加。 +//! +//! 两端分工: +//! · 宿主(`main.rs`)用 [`StreamRate`] 把增量字符数按滑动窗口折算成「字符/秒」下发; +//! · 胶囊窗口用 [`OrbSpeed`] 接住这个速率、把时间戳记成「刚收到」,再本地按时间衰减 —— +//! 流一停(网关卡住 / 模型想事情)就慢慢回到基线转速,不需要宿主额外发心跳。 + +use std::collections::VecDeque; + +/// 采样窗口:只统计最近这么多秒内收到的字符。 +const WINDOW_SECONDS: f64 = 0.5; +/// 低于该速率不加成:输出很慢时保持基线转速,避免圆点「抖一下快一下」。 +const RATE_FLOOR: f32 = 4.0; +/// 达到该速率即给到最高转速(中文 40 字/秒、英文 100+ 字符/秒已经算飞快)。 +const RATE_AT_MAX: f32 = 60.0; +/// 基线转速:Tauri `VoiceOrbStage` thinking 态的 `speed={1.5}`。 +pub const BASE_SPEED: f32 = 1.5; +/// 最高转速。再多也只到这里,免得圆点转成电风扇。 +pub const MAX_SPEED: f32 = 2.6; +/// 速率不再更新后的衰减时间常数(秒)。 +const DECAY_SECONDS: f64 = 0.45; +/// 转速跟随速率的平滑系数(越大越跟手;1.5 是 Tauri 里 `speed` 的固有平滑)。 +const SPEED_SMOOTHING: f32 = 6.0; +/// 速率被视为「变了」的最小差值:宿主每条增量都会重算,这条只是防止浮点噪声。 +const RATE_EPSILON: f32 = 1e-4; + +/// 速率 → 转速。没有数据 / 数据过小时返回 [`BASE_SPEED`],高到 [`MAX_SPEED`] 封顶。 +pub fn speed_for_rate(rate: f32) -> f32 { + if !rate.is_finite() || rate <= RATE_FLOOR { + return BASE_SPEED; + } + let t = ((rate - RATE_FLOOR) / (RATE_AT_MAX - RATE_FLOOR)).clamp(0.0, 1.0); + BASE_SPEED + (MAX_SPEED - BASE_SPEED) * t +} + +/// 宿主侧:把流式增量的字符数折算成「字符/秒」。 +#[derive(Debug, Clone, Default)] +pub struct StreamRate { + /// `(时刻秒, 字符数)`;窗口外的样本在读取时丢弃。 + samples: VecDeque<(f64, usize)>, +} + +impl StreamRate { + /// 记下一段流式增量。`now` 是宿主自己的单调时钟(秒)。 + pub fn observe(&mut self, now: f64, chars: usize) { + if chars == 0 { + return; + } + self.samples.push_back((now, chars)); + } + + /// 便捷入口:直接数一段文本的字符数(增量文本通常是 UTF-8 片段,按字符计更稳定)。 + pub fn observe_text(&mut self, now: f64, text: &str) { + self.observe(now, text.chars().count()); + } + + /// 当前速率(字符/秒)。窗口内没有样本就是 0,调用方据此回落基线转速。 + pub fn rate(&mut self, now: f64) -> f32 { + self.prune(now); + let chars: usize = self.samples.iter().map(|(_, chars)| *chars).sum(); + if chars == 0 { + return 0.0; + } + chars as f32 / WINDOW_SECONDS as f32 + } + + /// 新会话 / 新阶段开始时清空,避免上一轮的样本把首帧抬起来。 + pub fn reset(&mut self) { + self.samples.clear(); + } + + fn prune(&mut self, now: f64) { + while let Some((at, _)) = self.samples.front() { + if now - *at > WINDOW_SECONDS { + self.samples.pop_front(); + } else { + break; + } + } + } +} + +/// 胶囊窗口侧:接住宿主下发的速率,并在没有新数据时按时间衰减回基线。 +#[derive(Debug, Clone, Default)] +pub struct OrbSpeed { + /// 最近一次收到的速率;`None` = 还没有过数据。 + payload: Option, + /// 速率上一次发生变化的时刻(秒,胶囊窗口自己的单调时钟)。 + updated_at: f64, + /// 当前平滑后的转速。 + speed: f32, + /// 是否已经有初值(首帧直接落在目标上,避免入场时从 0 往上爬)。 + primed: bool, +} + +impl OrbSpeed { + /// 喂入宿主下发的速率并推进一帧,返回本帧该用的转速。 + /// + /// 只有速率**变化**时才刷新时间戳:宿主每条流式增量都会重算速率,数值几乎每次都会 + /// 变,所以「不变」基本等于流停了;一直一模一样(例如被 clamp 在顶)时本地衰减会略微 + /// 回退,收到下一条又会跟上,观感上察觉不到。 + pub fn tick(&mut self, now: f64, dt: f32, payload: Option) -> f32 { + let current = match payload { + Some(rate) if rate.is_finite() => rate.max(0.0), + _ => 0.0, + }; + let changed = match self.payload { + Some(previous) => (previous - current).abs() > RATE_EPSILON, + None => true, + }; + if changed { + self.updated_at = now; + } + self.payload = Some(current); + + let age = (now - self.updated_at).max(0.0); + let decayed = current as f64 * (-age / DECAY_SECONDS).exp(); + let target = speed_for_rate(decayed as f32); + if !self.primed { + self.speed = target; + self.primed = true; + return self.speed; + } + let dt = dt.clamp(0.0, 0.1); + self.speed += (target - self.speed) * (1.0 - (-dt * SPEED_SMOOTHING).exp()); + self.speed + } + + /// 离开思考态时复位,下次进入从头开始(先基线,再随增量爬升)。 + pub fn reset(&mut self) { + *self = Self::default(); + } +} + +/// 进程内单调时钟(秒)。宿主与胶囊窗口各自在自己的进程里用它对齐 `observe` / `tick`。 +pub fn now_seconds() -> f64 { + use std::sync::OnceLock; + use std::time::Instant; + static START: OnceLock = OnceLock::new(); + START.get_or_init(Instant::now).elapsed().as_secs_f64() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_empty_meter_reports_no_rate() { + let mut meter = StreamRate::default(); + assert_eq!(meter.rate(0.0), 0.0); + } + + #[test] + fn the_rate_counts_characters_inside_the_window() { + let mut meter = StreamRate::default(); + // 窗口 0.5s:0.0s 收到 10 字、0.1s 收到 10 字 → 20 字 / 0.5s = 40 字每秒。 + meter.observe(0.0, 10); + meter.observe(0.1, 10); + assert!((meter.rate(0.1) - 40.0).abs() < 0.01); + // 窗口外的样本过期:0.6s 时只剩第二笔。 + assert!((meter.rate(0.6) - 20.0).abs() < 0.01); + } + + #[test] + fn text_is_counted_in_characters_not_bytes() { + let mut meter = StreamRate::default(); + meter.observe_text(0.0, "润色结果"); + assert!((meter.rate(0.0) - 8.0).abs() < 0.01); + } + + #[test] + fn stale_samples_expire_and_reset_clears_everything() { + let mut meter = StreamRate::default(); + meter.observe(0.0, 100); + assert_eq!(meter.rate(2.0), 0.0); + meter.observe(2.0, 100); + meter.reset(); + assert_eq!(meter.rate(2.0), 0.0); + } + + #[test] + fn speed_without_data_matches_the_tauri_baseline() { + let mut orb = OrbSpeed::default(); + for frame in 0..30 { + let now = 0.1 + f64::from(frame) * 0.016; + let speed = orb.tick(now, 0.016, None); + assert!((speed - BASE_SPEED).abs() < 1e-6); + } + } + + #[test] + fn speed_rises_with_the_rate_and_is_clamped() { + assert_eq!(speed_for_rate(0.0), BASE_SPEED); + assert_eq!(speed_for_rate(RATE_FLOOR), BASE_SPEED); + assert_eq!(speed_for_rate(1_000.0), MAX_SPEED); + let slow = speed_for_rate(15.0); + let fast = speed_for_rate(45.0); + assert!( + slow > BASE_SPEED && slow < fast && fast < MAX_SPEED, + "{slow} {fast}" + ); + } + + #[test] + fn speed_decays_back_to_the_baseline_when_the_stream_stalls() { + let mut orb = OrbSpeed::default(); + let mut now = 0.0; + // 一段高速输出:转速爬上去。 + for _ in 0..40 { + // 每帧速率都略有变化,模拟宿主每条增量重算出来的值。 + now += 0.016; + orb.tick(now, 0.016, Some(55.0 + (now * 10.0).sin() as f32)); + } + let hot = orb.speed; + assert!(hot > BASE_SPEED + 0.2, "expected a visible spin-up: {hot}"); + // 流停了:不再有新速率,转速应该慢慢回到基线。 + for _ in 0..200 { + now += 0.016; + orb.tick(now, 0.016, None); + } + assert!((orb.speed - BASE_SPEED).abs() < 0.02, "speed={}", orb.speed); + // 复位后立刻回到基线(下次思考从头开始)。 + orb.reset(); + assert_eq!(orb.tick(now, 0.016, None), BASE_SPEED); + } + + #[test] + fn a_fresh_rate_after_a_stall_climbs_again() { + let mut orb = OrbSpeed::default(); + let mut now = 0.0; + for _ in 0..30 { + now += 0.016; + orb.tick(now, 0.016, Some(50.0)); + } + for _ in 0..60 { + now += 0.016; + orb.tick(now, 0.016, Some(50.0)); + } + let after_stall = orb.speed; + for _ in 0..30 { + now += 0.016; + orb.tick(now, 0.016, Some(50.0 + (now * 7.0).sin() as f32)); + } + assert!(orb.speed > after_stall, "{} -> {}", after_stall, orb.speed); + } +} From d35540e8c18759f0f563dce5324cad091a38c020 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Mon, 28 Sep 2026 23:11:27 +0800 Subject: [PATCH 31/35] =?UTF-8?q?feat(linux-egui):=20=E6=92=A4=E6=8E=89?= =?UTF-8?q?=E8=BD=AC=E9=80=9F=E5=8A=9F=E8=83=BD=EF=BC=8C=E9=80=90=E9=A1=B9?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=20Siri=20=E8=83=B6=E5=9B=8A=E7=9A=84?= =?UTF-8?q?=E5=85=A5=E5=9C=BA/=E6=94=B6=E5=B0=BE=E8=8A=82=E5=A5=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户拍板「Tauri 为准」:先撤掉上一条把思考光点转速接到流式输出速率的功能 (revert 2c0d28b8,只保留 1.3→1.5 的漂移修复),然后把审计出的六处差异全部对齐 Tauri。 - **扫光**:经典/typeless 的「思考中」文案现在有 `cap-shine` 扫光(同一段文字在高光带内 用蓝色重绘一遍,三层由窄到宽),进入转写/润色头 2 秒走 0.9s 快周期、之后回落 2.4s。 - **预测式展开**:宿主量「预备 → 就绪」耗时(Tauri 的 `warmupMs`)并按同样的 `clamp(prev*0.7 + observed*0.3, 60, 600)`、同样的 <20ms/>3s 过滤学习,落在 UI 状态文档 (`capsule_warmup_ms`);`siri_wgpu::tick` 用它算 `warmProgress`(未就绪 cap 0.9,就绪后 k=9 补满),光条从收拢态按这个节奏展开。 - **圆点出场 / 收尾**:`gather` 改由时钟驱动 —— 出场全聚圆心 → hold 0.3s → 1.6/s 散开成环; 终态 `merging` 时 4/s 合回中央一颗圆,转速回落 1.0。终态胶囊因此也要画光点(以前不画)。 - **交叉淡出**:切到思考态后 wave 继续画 —— 先保持 0.55s、再用 0.6s ease-out 淡出(预乘 alpha 的 `opacity`,新增 `SiriEffect::with_opacity`,WGSL 用 `tint.a` 同时缩 RGB 与 A)。 - **Siri 舞台不再有文案**(Tauri `VoiceOrbStage` 只有 error 才给字):删掉 orb 下方的 「思考中」白底标签与「已插入 N 字」;错误时按 Tauri 的 `errorGlowTextStyle` 画底部红字 药丸,文案优先用宿主给的 `message`。 - **新会话复位**:两个窗口进程常驻,Tauri 靠组件重新挂载拿到的「入场从头走」现在由 相位时钟算出并显式 `siri_wgpu::reset`。 测试:`capsule_motion` 6 条(wave 淡出曲线、相位会话边界、扫光快/慢周期)+ `siri_wgpu` 4 条(预测式展开、gather 生命周期、reset 重放入场、opacity 写入 tint.a)+ `ui_state` 2 条(学习上界与 EMA 收敛)+ 界面级 3 条(扫光颜色、wave→orb 双回调、 Siri 只在出错时显示宿主的 messenger 文案);另修正 3 条既有测试的期望(终态 Siri 仍画光点、 Siri 无文案、已插入字数只在经典药丸)。 package revision 126。 --- openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/lib.rs | 3 +- openless-all/app/linux-egui/src/main.rs | 73 ++- openless-all/app/linux-egui/src/popup.rs | 10 - .../src/ui/frontend/capsule_motion.rs | 158 +++++ .../app/linux-egui/src/ui/frontend/mod.rs | 2 +- .../app/linux-egui/src/ui/frontend/popups.rs | 603 +++++++++++++----- .../linux-egui/src/ui/frontend/siri_wgpu.rs | 287 ++++++++- .../linux-egui/src/ui/frontend/stream_rate.rs | 251 -------- openless-all/app/linux-egui/src/ui_state.rs | 79 +++ 10 files changed, 993 insertions(+), 475 deletions(-) create mode 100644 openless-all/app/linux-egui/src/ui/frontend/capsule_motion.rs delete mode 100644 openless-all/app/linux-egui/src/ui/frontend/stream_rate.rs diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index d136d6a71..0a3e7b042 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -125 +126 diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index 2efebc2c9..f77c0d02a 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -127,7 +127,8 @@ pub use single_instance::{ }; pub use tray::{LinuxTray, TrayCommand, TrayError, TrayMicrophone}; pub use ui_state::{ - load_locale_pref, load_quick_note_shortcut_hidden, save_locale_pref, + learn_capsule_warmup_ms, load_capsule_warmup_ms, load_locale_pref, + load_quick_note_shortcut_hidden, save_capsule_warmup_ms, save_locale_pref, save_quick_note_shortcut_hidden, ui_state_dir, ui_state_path, UiStateError, }; diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 5995c382e..383727781 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -583,8 +583,6 @@ mod linux_app { capsule_session: Option, /// 已经为哪个会话排过收起计时,避免重复计时。 capsule_dismissal_scheduled: Option, - /// 流式输出速率计量:润色增量字符数 → 胶囊光点转速(见 `stream_rate` 模块)。 - stream_rate: frontend::stream_rate::StreamRate, tray: Option, exit_requested: bool, /// 上次打「泵心跳」日志的时间。 @@ -757,7 +755,6 @@ mod linux_app { popup_action_guard: PopupActionGuard::default(), capsule_session: None, capsule_dismissal_scheduled: None, - stream_rate: frontend::stream_rate::StreamRate::default(), tray, exit_requested: false, last_pump_heartbeat: std::time::Instant::now(), @@ -859,7 +856,6 @@ mod linux_app { popup_action_guard: PopupActionGuard::default(), capsule_session: None, capsule_dismissal_scheduled: None, - stream_rate: frontend::stream_rate::StreamRate::default(), tray, exit_requested: false, last_pump_heartbeat: std::time::Instant::now(), @@ -1334,7 +1330,6 @@ mod linux_app { CapsuleOutcome::Progress(text) => text, }; let style = self.capsule_style_tag(); - let stream_rate = self.stream_rate.rate(frontend::stream_rate::now_seconds()); self.send_popup( PopupKind::Capsule, HostToPopup::Capsule { @@ -1346,7 +1341,6 @@ mod linux_app { audio_level: Some(snapshot.level), translation_active: snapshot.translation_active, style, - stream_rate: Some(stream_rate), }, ); self.schedule_capsule_dismissal(&session_id.to_string(), snapshot.phase); @@ -1374,7 +1368,6 @@ mod linux_app { audio_level: None, translation_active: false, style: self.capsule_style_tag(), - stream_rate: None, }, ); self.schedule_capsule_dismissal(session_id, DictationPhase::Failed); @@ -2389,8 +2382,6 @@ mod linux_app { self.transcript_state = TranscriptAccumulator::default(); self.transcript.clear(); self.transcript_session = state.session_id; - // 新会话:速率样本清空,避免上一轮的尾巴把首帧转速抬起来。 - self.stream_rate.reset(); } // 终态不写状态栏:`Failed` / `Completed` / `Cancelled` 是 Core 的 // 内部词,用户已经能从胶囊看到本地化文案(Tauri 也只在那里显示)。 @@ -2421,8 +2412,6 @@ mod linux_app { _ => String::new(), }; let style = self.capsule_style_tag(); - let stream_rate = - self.stream_rate.rate(frontend::stream_rate::now_seconds()); self.send_popup( PopupKind::Capsule, HostToPopup::Capsule { @@ -2434,7 +2423,6 @@ mod linux_app { audio_level: Some(state.level), translation_active: state.translation_active, style, - stream_rate: Some(stream_rate), }, ); // 终态:按 Tauri 时序安排自动收起,否则药丸会一直贴在屏幕上。 @@ -2447,17 +2435,8 @@ mod linux_app { { self.transcript = self.transcript_state.text().to_string(); } - BackendEventKind::PolishDelta(delta) => { - if delta.is_final { - self.transcript = delta.text; - // 流结束:样本清空,胶囊立刻回落基线转速。 - self.stream_rate.reset(); - } else { - // 用户诉求:润色时光点跟着模型的吐字速度转。这里只做「增量字符数 → - // 字符/秒」,随时间衰减与转速映射在胶囊窗口侧(见 stream_rate 模块)。 - self.stream_rate - .observe_text(frontend::stream_rate::now_seconds(), &delta.text); - } + BackendEventKind::PolishDelta(delta) if delta.is_final => { + self.transcript = delta.text; } BackendEventKind::DictationCompleted(result) => { self.transcript = result.polished_text; @@ -7016,6 +6995,10 @@ focus_was_stolen={} focus_restored={} warnings={:?}", overlay: Option, /// 面板有焦点时插件收不到按键,所以面板自己也要匹配本地热键。 hotkey_matcher: crate::ui::local_hotkeys::LocalHotkeyMatcher, + /// 本次会话「预备态(麦克风还没吐第一帧 PCM)」的开始时刻:用来学习 warmupMs。 + warming_started: Option, + /// 学出来的「预备 → 就绪」平均耗时(ms),驱动光条的预测式展开。 + warmup_ms: f32, } impl NativePopupApp { @@ -7158,6 +7141,30 @@ focus_was_stolen={} focus_restored={} warnings={:?}", } } + /// 学习「预备态持续多久」= 麦克风就绪耗时,EMA 更新并落盘(Tauri 用 + /// localStorage 的 `ol-capsule-warmup-ms`,公式一致;异常样本在 + /// `learn_capsule_warmup_ms` 里被过滤)。 + fn track_capsule_warmup(&mut self, warming: bool, phase: &str) { + let warming = warming + && matches!( + phase.to_ascii_lowercase().as_str(), + "starting" | "recording" + ); + if warming { + if self.warming_started.is_none() { + self.warming_started = Some(std::time::Instant::now()); + } + return; + } + let Some(started) = self.warming_started.take() else { + return; + }; + let observed_ms = started.elapsed().as_secs_f32() * 1000.0; + if let Some(next) = openless_linux_egui::learn_capsule_warmup_ms(observed_ms) { + self.warmup_ms = next; + } + } + /// One capsule frame on a layer surface: same view, same protocol and /// same send / exit rules as the eframe window, minus viewport /// commands (a layer surface is sized by the compositor). @@ -7180,7 +7187,8 @@ focus_was_stolen={} focus_restored={} warnings={:?}", let lang = self.lang; let mut action = frontend::popups::CapsuleAction::None; let output = ctx.run_ui(raw, |ui| { - action = frontend::popups::dictation_capsule(ui, &capsule, lang); + self.track_capsule_warmup(capsule.audio_level.is_none(), &capsule.phase); + action = frontend::popups::dictation_capsule(ui, &capsule, lang, self.warmup_ms); }); match action { frontend::popups::CapsuleAction::None => {} @@ -7417,7 +7425,15 @@ focus_was_stolen={} focus_restored={} warnings={:?}", capsule_phase.as_str(), "starting" | "recording" | "transcribing" | "polishing" | "inserting" ); - let action = frontend::popups::dictation_capsule(ui, &self.state.capsule, lang); + let warming = self.state.capsule.audio_level.is_none(); + let warming_phase = self.state.capsule.phase.clone(); + self.track_capsule_warmup(warming, &warming_phase); + let action = frontend::popups::dictation_capsule( + ui, + &self.state.capsule, + lang, + self.warmup_ms, + ); let message = match action { frontend::popups::CapsuleAction::Cancel => { Some(PopupToHost::CancelDictation { @@ -7564,6 +7580,8 @@ focus_was_stolen={} focus_restored={} warnings={:?}", less_computer_input: String::new(), outgoing_sequence: 0, ready_sent: false, + warming_started: None, + warmup_ms: openless_linux_egui::load_capsule_warmup_ms(), avatar: QaAvatar::default(), lang: load_locale_pref().resolve(), overlay: None, @@ -7717,6 +7735,8 @@ focus_was_stolen={} focus_restored={} warnings={:?}", less_computer_input: String::new(), outgoing_sequence: 0, ready_sent: false, + warming_started: None, + warmup_ms: openless_linux_egui::load_capsule_warmup_ms(), avatar: QaAvatar::default(), // The popup is a separate process, so it re-reads the // persisted UI-locale preference rather than sharing state. @@ -8470,6 +8490,8 @@ Internal flags (set by OpenLess itself, not for regular use): less_computer_input: String::new(), outgoing_sequence: 0, ready_sent: false, + warming_started: None, + warmup_ms: openless_linux_egui::load_capsule_warmup_ms(), avatar: QaAvatar::default(), lang: Lang::ZhCn, overlay: None, @@ -8501,7 +8523,6 @@ Internal flags (set by OpenLess itself, not for regular use): audio_level: Some(0.2), translation_active: false, style: "siri".into(), - stream_rate: None, }) .expect("channel is open"); assert!(!app.pump(None), "a progress frame must not exit"); diff --git a/openless-all/app/linux-egui/src/popup.rs b/openless-all/app/linux-egui/src/popup.rs index e3e8cc894..22f71097c 100644 --- a/openless-all/app/linux-egui/src/popup.rs +++ b/openless-all/app/linux-egui/src/popup.rs @@ -129,10 +129,6 @@ pub enum HostToPopup { /// 胶囊样式:`siri` / `classic` / `typeless`(Tauri `capsuleStyle`)。 #[serde(default)] style: String, - /// 流式输出速率(字符/秒),驱动思考光点的转速。宿主每条流式增量重算一次; - /// 缺省(老宿主 / 还没有增量)= 用 Tauri 的基线转速。 - #[serde(default, skip_serializing_if = "Option::is_none")] - stream_rate: Option, }, Hide { version: u16, @@ -677,8 +673,6 @@ pub struct CapsulePopupState { pub translation_active: bool, /// 胶囊样式(`siri` / `classic` / `typeless`)。 pub style: String, - /// 流式输出速率(字符/秒):思考态光点转速跟着它走,`None` = 用基线转速。 - pub stream_rate: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -787,7 +781,6 @@ impl PopupState { audio_level, translation_active, style, - stream_rate, .. } => { self.capsule = CapsulePopupState { @@ -796,7 +789,6 @@ impl PopupState { audio_level, translation_active, style, - stream_rate, }; self.visible = true; } @@ -1462,12 +1454,10 @@ mod tests { audio_level: Some(0.5), translation_active: true, style: "typeless".to_owned(), - stream_rate: Some(42.0), }; let mut state = PopupState::default(); assert_eq!(state.apply(message), ApplyOutcome::Applied); assert!(state.capsule.translation_active); - assert_eq!(state.capsule.stream_rate, Some(42.0)); assert_eq!( state.capsule.style, "typeless", "the capsule style must travel with the frame to the popup process" diff --git a/openless-all/app/linux-egui/src/ui/frontend/capsule_motion.rs b/openless-all/app/linux-egui/src/ui/frontend/capsule_motion.rs new file mode 100644 index 000000000..17a24ce8c --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/capsule_motion.rs @@ -0,0 +1,158 @@ +//! 胶囊相位时序:状态切换时的入场 / 收尾节奏,逐项对齐 Tauri。 +//! +//! Tauri 那边这些节奏由 CSS / React 生命周期承担(`cap-shine` 的 0.9s→2.4s burst、 +//! `VoiceOrbStage` 里 wave 的 `opacity .6s ease-out .55s`)。egui 没有 CSS,节奏只能自己 +//! 算,所以集中在这里并配单测 —— 这样「和 Tauri 不一致」是能被断言发现的,而不是靠肉眼。 + +/// 经典药丸 thinking 文案的扫光:进入转写/润色的头 2 秒走快速 burst(Tauri 用 0.9s/周期 +/// 提示「流式刚开始」),之后回落到 2.4s 的稳态。切回其它状态也复位成快速,下次重新 burst。 +pub const SHINE_BURST_SECONDS: f64 = 2.0; +pub const SHINE_FAST_CYCLE_SECONDS: f64 = 0.9; +pub const SHINE_SLOW_CYCLE_SECONDS: f64 = 2.4; + +/// wave → orb 的交叉淡出(Tauri `opacity .6s ease-out .55s`):切态后先保持可见 0.55s, +/// 再用 0.6s 淡出;Tauri 另外在 1.3s 后卸载 canvas,这里淡完就不再画(等价且更省)。 +pub const WAVE_FADE_DELAY_SECONDS: f64 = 0.55; +pub const WAVE_FADE_SECONDS: f64 = 0.6; + +/// 录音相位:只有它画满幅的声波。 +fn phase_is_recording(phase: &str) -> bool { + matches!(phase, "starting" | "recording") +} + +#[derive(Debug, Clone, Default)] +pub struct CapsuleMotion { + phase: String, + /// 当前相位是什么时候开始的(胶囊窗口自己的单调时钟,秒)。 + phase_at: f64, + /// 离开录音相位的时刻;`None` = 当前还在录音 / 本次会话没经过录音。 + recording_left_at: Option, + primed: bool, +} + +impl CapsuleMotion { + /// 推进一帧,返回 `true` 表示这是一个**新会话刚进入录音/预备相位**(调用方据此复位 + /// Siri 时钟,让入场动画从头走一遍 —— Tauri 靠组件重新挂载达到同样效果)。 + pub fn update(&mut self, phase: &str, now: f64) -> bool { + let new_session = phase_is_recording(phase) && !phase_is_recording(&self.phase); + if self.primed && self.phase == phase { + return false; + } + if phase_is_recording(phase) { + self.recording_left_at = None; + } else if phase_is_recording(&self.phase) { + // 刚从录音切走:wave 的淡出从这一刻起算。 + self.recording_left_at = Some(now); + } + self.phase = phase.to_string(); + self.phase_at = now; + self.primed = true; + new_session + } + + /// 当前相位已经持续了多久(秒)。 + pub fn phase_age(&self, now: f64) -> f64 { + (now - self.phase_at).max(0.0) + } + + /// 经典药丸 thinking 扫光的本帧周期(秒)。 + pub fn shine_cycle_seconds(&self, now: f64) -> f64 { + if self.phase_age(now) < SHINE_BURST_SECONDS { + SHINE_FAST_CYCLE_SECONDS + } else { + SHINE_SLOW_CYCLE_SECONDS + } + } + + /// wave 在本帧的整体不透明度:`None` = 不用再画(思考态且淡出已结束)。 + pub fn wave_opacity(&self, now: f64) -> Option { + let left_at = self.recording_left_at?; + let age = now - left_at; + if age < WAVE_FADE_DELAY_SECONDS { + return Some(1.0); + } + let elapsed = age - WAVE_FADE_DELAY_SECONDS; + if elapsed >= WAVE_FADE_SECONDS { + return None; + } + let t = (elapsed / WAVE_FADE_SECONDS).clamp(0.0, 1.0) as f32; + // CSS `ease-out` 的近似:起步快、收尾慢。 + let eased = 1.0 - (1.0 - t).powi(3); + Some(1.0 - eased) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_wave_stays_opaque_during_the_fade_delay() { + let mut motion = CapsuleMotion::default(); + motion.update("recording", 0.0); + motion.update("polishing", 1.0); + assert_eq!(motion.wave_opacity(1.0), Some(1.0)); + assert_eq!(motion.wave_opacity(1.5), Some(1.0)); + } + + #[test] + fn the_wave_fade_covers_half_a_second_and_then_stops() { + let mut motion = CapsuleMotion::default(); + motion.update("recording", 0.0); + motion.update("polishing", 10.0); + // 延迟期内完全不透明。 + assert_eq!(motion.wave_opacity(10.3), Some(1.0)); + assert_eq!(motion.wave_opacity(10.55), Some(1.0)); + // 淡出中:单调下降、且始终落在 (0, 1) 之间。 + let quarter = motion.wave_opacity(10.55 + 0.15).unwrap(); + let half = motion.wave_opacity(10.55 + 0.3).unwrap(); + let late = motion.wave_opacity(10.55 + 0.55).unwrap(); + assert!( + quarter < 1.0 && quarter > half && half > late && late > 0.0, + "{quarter} {half} {late}" + ); + // 淡完就不再画。 + assert_eq!(motion.wave_opacity(10.55 + WAVE_FADE_SECONDS), None); + assert_eq!(motion.wave_opacity(12.0), None); + } + + #[test] + fn no_fade_is_scheduled_without_a_recording_phase() { + let mut motion = CapsuleMotion::default(); + // 直接进思考态(例如错过了录音相位):没有 wave 可淡出。 + motion.update("polishing", 5.0); + assert_eq!(motion.wave_opacity(5.0), None); + assert_eq!(motion.wave_opacity(9.0), None); + } + + #[test] + fn a_new_recording_phase_restarts_the_session() { + let mut motion = CapsuleMotion::default(); + assert!( + motion.update("recording", 0.0), + "first recording is a session" + ); + assert!(!motion.update("recording", 0.1)); + assert!(!motion.update("polishing", 0.2)); + assert!(!motion.update("completed", 3.0)); + // 回到录音:新一轮会话。 + assert!(motion.update("recording", 5.0)); + assert_eq!(motion.phase_age(5.5), 0.5); + // `starting` 也算预备态:同一轮里 starting → recording 不算新会话。 + assert!(!motion.update("recording", 5.6)); + assert!(!motion.update("starting", 6.0)); + } + + #[test] + fn the_thinking_sweep_starts_fast_and_settles_down() { + let mut motion = CapsuleMotion::default(); + motion.update("polishing", 100.0); + assert_eq!(motion.shine_cycle_seconds(100.0), SHINE_FAST_CYCLE_SECONDS); + assert_eq!(motion.shine_cycle_seconds(101.9), SHINE_FAST_CYCLE_SECONDS); + assert_eq!(motion.shine_cycle_seconds(102.0), SHINE_SLOW_CYCLE_SECONDS); + assert_eq!(motion.shine_cycle_seconds(140.0), SHINE_SLOW_CYCLE_SECONDS); + // 切相位会重新 burst。 + motion.update("recording", 200.0); + assert_eq!(motion.shine_cycle_seconds(200.1), SHINE_FAST_CYCLE_SECONDS); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index 064691fe0..d4598575a 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -1,3 +1,4 @@ +pub mod capsule_motion; pub mod corrections; pub mod format; pub mod history; @@ -11,7 +12,6 @@ pub mod popups; pub mod selection_ask; pub mod settings; pub use openless_linux_egui::siri_wgpu; -pub mod stream_rate; pub mod style; pub mod translation; pub mod view_model; diff --git a/openless-all/app/linux-egui/src/ui/frontend/popups.rs b/openless-all/app/linux-egui/src/ui/frontend/popups.rs index c94c96be5..f2e9f427b 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/popups.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/popups.rs @@ -14,7 +14,7 @@ use eframe::egui; -use super::{icons, layout, siri_wgpu, stream_rate, theme}; +use super::{capsule_motion, icons, layout, siri_wgpu, theme}; use openless_linux_egui::{ fmt_l10n, tr_l10n, CapsulePopupState, Lang, LessComputerPopupState, PopupChatMessage, QaPolishState, QaPopupState, @@ -541,7 +541,7 @@ pub fn selection_ask( resolved: if recording { 1.0 } else { 0.0 }, // 思考态转得更快,和 Tauri 的 state→speed 语义一致。 speed: if recording { 1.0 } else { 1.45 }, - warming: false, + ..Default::default() }; let dt = ui.input(|input| input.stable_dt); let clock = siri_wgpu::tick(ui.ctx(), "qa-composer-ring", drive, dt); @@ -1039,10 +1039,14 @@ fn translating_badge(ui: &mut egui::Ui, pill: egui::Rect, lang: Lang, scale: f32 } /// 录音胶囊:经典药丸(Tauri `ClassicPill`)—— 左 ✕、中间状态、右 ✓。 +/// +/// `warmup_ms` 是学出来的「预备 → 就绪」平均耗时,驱动光条的预测式展开(Tauri `warmupMs` +/// prop;那边存在 localStorage,这边由宿主量、存在 UI 状态文档里)。 pub fn dictation_capsule( root_ui: &mut egui::Ui, state: &CapsulePopupState, lang: Lang, + warmup_ms: f32, ) -> CapsuleAction { let mut action = CapsuleAction::None; let phase = state.phase.to_ascii_lowercase(); @@ -1108,16 +1112,30 @@ pub fn dictation_capsule( phase.as_str(), "starting" | "transcribing" | "polishing" | "inserting" ); - // 思考光点转速的速率响应状态:只在思考态累积,离开思考态复位(下次从头开始)。 - let orb_speed_id = egui::Id::new("openless-capsule-orb-speed"); - if !processing { - let mut responder = ui.ctx().data_mut(|data| { - data.get_temp::(orb_speed_id) - .unwrap_or_default() - }); - responder.reset(); - ui.ctx() - .data_mut(|data| data.insert_temp(orb_speed_id, responder)); + // 终态:光点做「六点合回一颗圆」的收尾,转速回落 1.0(Tauri `VoiceOrbStage` + // 的 `merging` / `speed`)。 + let terminal = matches!( + phase.as_str(), + "completed" | "done" | "inserted" | "cancelled" | "failed" | "error" + ); + // 相位时序(扫光周期、wave 淡出)与「新会话复位」:Tauri 那边靠 CSS / 组件 + // 重新挂载,egui 没这两样,节奏都在 `capsule_motion` 里算并有单测。 + let now = ui.input(|input| input.time); + let motion_id = egui::Id::new("openless-capsule-motion"); + let mut motion = ui.ctx().data_mut(|data| { + data.get_temp::(motion_id) + .unwrap_or_default() + }); + let new_session = motion.update(&phase, now); + let wave_opacity = motion.wave_opacity(now); + let shine_cycle = motion.shine_cycle_seconds(now); + ui.ctx() + .data_mut(|data| data.insert_temp(motion_id, motion)); + if new_session { + // 两个窗口进程都常驻:新会话必须显式复位,否则上一轮的光条是展开的、 + // 圆点环也已经散开了,入场动画就没了(Tauri 每次重新挂载天然是新的)。 + siri_wgpu::reset(ui.ctx(), "capsule-siri-wave"); + siri_wgpu::reset(ui.ctx(), "capsule-siri-orb"); } // Siri is a clean, transparent listening indicator. Classic keeps // both actions visible; Typeless follows Tauri and shows the two @@ -1237,6 +1255,8 @@ pub fn dictation_capsule( resolved: 1.0, speed: 1.0, warming: state.audio_level.is_none(), + warmup_ms, + merging: false, }; let dt = ui.input(|input| input.stable_dt); let clock = siri_wgpu::tick(ui.ctx(), "capsule-siri-wave", drive, dt); @@ -1257,125 +1277,219 @@ pub fn dictation_capsule( pill_ink, ); } - } else if processing { - // 思考中:Siri 流体圆点(orb),从 wave 收拢的光点化开成环。 - // 转速跟着流式输出速率(用户诉求:润色时按大模型的吐字速度转):没有速率 - // 数据时是 `BASE_SPEED`(= Tauri 的固定 1.5),有增量就往上加,流停了再衰减 - // 回基线。映射与衰减都在 `stream_rate` 模块里,可单测。 - let now = ui.input(|input| input.time); - let dt = ui.input(|input| input.stable_dt); - let speed = { - let mut responder = ui.ctx().data_mut(|data| { - data.get_temp::(orb_speed_id) - .unwrap_or_default() - }); - let speed = responder.tick(now, dt, state.stream_rate); - ui.ctx() - .data_mut(|data| data.insert_temp(orb_speed_id, responder)); - speed - }; - let drive = siri_wgpu::SiriDrive { - level: 0.0, - resolved: 0.0, - speed, - warming: false, - }; - let dt = ui.input(|input| input.stable_dt); - let clock = siri_wgpu::tick(ui.ctx(), "capsule-siri-orb", drive, dt); - // 0.3s 全聚圆心接住 wave 收拢的光点,再缓缓散开成环。 - let gather = (1.0 - (clock.time / 0.9).clamp(0.0, 1.0)).clamp(0.0, 1.0); - let effect = siri_wgpu::SiriEffect::orb(clock.time, gather); + } else { + // 思考态 / 终态:Siri 舞台画光点(终态是「六点合回一颗圆」的收尾), + // 经典 / typeless 只画文案。 if siri { - let _ = siri_wgpu::paint(ui, center, effect); + // wave → orb 的交叉淡出:切态后 0.55s 内保持可见,再用 0.6s 淡出, + // 期间波形继续向圆心收拢(Tauri `opacity .6s ease-out .55s`)。 + if let Some(opacity) = wave_opacity { + let fade_drive = siri_wgpu::SiriDrive { + level: state.audio_level.unwrap_or_default(), + resolved: 0.0, + speed: 1.0, + warming: false, + warmup_ms, + merging: false, + }; + let dt = ui.input(|input| input.stable_dt); + let clock = siri_wgpu::tick(ui.ctx(), "capsule-siri-wave", fade_drive, dt); + let _ = siri_wgpu::paint( + ui, + center, + siri_wgpu::SiriEffect::wave(clock.time, clock.level) + .with_opacity(opacity), + ); + } + if processing || terminal { + // 思考中 1.5 速转;终态回落到 1.0 并让六点合并成中央一颗圆 + //(Tauri `VoiceOrbStage` 的 speed / merging)。 + let drive = siri_wgpu::SiriDrive { + level: 0.0, + resolved: 0.0, + speed: if processing { 1.5 } else { 1.0 }, + warming: false, + warmup_ms, + merging: terminal, + }; + let dt = ui.input(|input| input.stable_dt); + // 聚拢度 / 出场 hold 由时钟算(Tauri `GATHER_HOLD_S` + `merging`)。 + let clock = siri_wgpu::tick(ui.ctx(), "capsule-siri-orb", drive, dt); + let effect = siri_wgpu::SiriEffect::orb(clock.time, clock.gather); + let _ = siri_wgpu::paint(ui, center, effect); + } ui.ctx() .request_repaint_after(std::time::Duration::from_millis(16)); - // Keep the processing caption below the orb, with enough - // contrast on any desktop, without resizing the stage. - let label = ui.painter().layout_no_wrap( - tr_l10n(lang, "capsule.thinking").to_string(), - egui::FontId::proportional(17.0), - theme::INK, - ); - let label_rect = egui::Rect::from_center_size( - center.center() + egui::vec2(0.0, center.height() * 0.32), - label.size(), - ); - ui.painter().rect_filled( - label_rect.expand2(egui::vec2(8.0, 3.0)), - egui::CornerRadius::same(10), - theme::SURFACE, - ); - ui.painter().galley(label_rect.min, label, theme::INK); - } else { - ui.painter().text( - center.center(), - egui::Align2::CENTER_CENTER, + // Siri 舞台只有出错时给文字(Tauri `VoiceOrbStage` —— 没有「思考中」 + // 文案,也没有「已插入 N 字」,反馈全靠光效)。 + if matches!(phase.as_str(), "failed" | "error") { + paint_siri_error(ui, center, state, lang); + } + } else if processing { + // 经典 / typeless 的「思考中」:文案 + 扫光(Tauri `cap-shine`)。 + paint_thinking_label( + ui, + center, tr_l10n(lang, "capsule.thinking"), egui::FontId::proportional(if typeless { TYPELESS_TEXT_SIZE } else { 17.0 }), - theme::INK, + if typeless { TYPELESS_INK } else { theme::INK }, + shine_cycle, + now, ); - } - } else if state.text.is_empty() { - let label = if processing { - tr_l10n(lang, "capsule.thinking") - } else if phase == "cancelled" { - tr_l10n(lang, "capsule.cancelled") - } else if phase == "failed" { - tr_l10n(lang, "capsule.error") - } else { - tr_l10n(lang, "capsule.thinking") - }; - let size = if typeless { - TYPELESS_TEXT_SIZE - } else if processing || matches!(phase.as_str(), "completed" | "done" | "idle") { - // A terminal snapshot can briefly have no result text. Keep - // its thinking placeholder at the processing size until the - // popup is actually dismissed; never render a tiny final - // "thinking" frame. - 17.0 - } else { - 11.0 - }; - ui.painter().text( - center.center(), - egui::Align2::CENTER_CENTER, - label, - egui::FontId::proportional(size), - if phase == "failed" { - theme::ERR - } else if typeless { - TYPELESS_INK + } else if state.text.is_empty() { + let label = if processing { + tr_l10n(lang, "capsule.thinking") + } else if phase == "cancelled" { + tr_l10n(lang, "capsule.cancelled") + } else if phase == "failed" { + tr_l10n(lang, "capsule.error") } else { - theme::INK - }, - ); - } else { - // 11px/500 单行居中,超长省略(Tauri `getCapsuleMessageLayout`)。 - let galley = layout::text_galley( - ui, - &state.text, - pill_ink, - if typeless { TYPELESS_TEXT_SIZE } else { 11.0 }, - center.width().min(84.0), - 1, - ); - ui.painter().galley( - egui::pos2( - center.center().x - galley.rect.width() / 2.0, - center.center().y - galley.rect.height() / 2.0, - ), - galley, - pill_ink, - ); + tr_l10n(lang, "capsule.thinking") + }; + let size = if typeless { + TYPELESS_TEXT_SIZE + } else if processing || matches!(phase.as_str(), "completed" | "done" | "idle") + { + // A terminal snapshot can briefly have no result text. Keep + // its thinking placeholder at the processing size until the + // popup is actually dismissed; never render a tiny final + // "thinking" frame. + 17.0 + } else { + 11.0 + }; + ui.painter().text( + center.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(size), + if phase == "failed" { + theme::ERR + } else if typeless { + TYPELESS_INK + } else { + theme::INK + }, + ); + } else { + // 11px/500 单行居中,超长省略(Tauri `getCapsuleMessageLayout`)。 + let galley = layout::text_galley( + ui, + &state.text, + pill_ink, + if typeless { TYPELESS_TEXT_SIZE } else { 11.0 }, + center.width().min(84.0), + 1, + ); + ui.painter().galley( + egui::pos2( + center.center().x - galley.rect.width() / 2.0, + center.center().y - galley.rect.height() / 2.0, + ), + galley, + pill_ink, + ); + } } }); action } +/// Siri 舞台的错误提示(Tauri `VoiceOrbStage` 的 `errorGlowTextStyle`):底部居中的 +/// 红字药丸;文案优先用宿主给的 `message`(例如选区润色的「未选中内容」),缺省才是 +/// 通用的「出现错误」。 +pub fn paint_siri_error(ui: &egui::Ui, center: egui::Rect, state: &CapsulePopupState, lang: Lang) { + let text = if state.text.trim().is_empty() { + tr_l10n(lang, "capsule.error").to_string() + } else { + state.text.clone() + }; + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = center.width().clamp(1.0, 400.0); + job.wrap.max_rows = 2; + job.append( + &text, + 0.0, + egui::text::TextFormat { + font_id: theme::medium_font(12.0), + color: theme::ERR, + ..Default::default() + }, + ); + let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); + let rect = egui::Rect::from_center_size( + egui::pos2( + center.center().x, + center.bottom() - 24.0 - galley.rect.height() / 2.0, + ), + galley.rect.size() + egui::vec2(24.0, 12.0), + ); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(12), theme::SURFACE_2); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(12), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); + ui.painter().galley( + egui::pos2( + rect.center().x - galley.rect.width() / 2.0, + rect.center().y - galley.rect.height() / 2.0, + ), + galley, + theme::ERR, + ); +} + +/// 经典药丸「思考中」文案 + 扫光(Tauri `cap-shine`):把同一段文字在一条移动的高光带内 +/// 用蓝色重绘一遍。带子由窄到宽分三层(越宽越淡),扫过字面 —— 等价于 Tauri 用 +/// `background-clip: text` 做的高光;进入流式的头 2 秒走快周期,之后回落稳态。 +fn paint_thinking_label( + ui: &egui::Ui, + center: egui::Rect, + text: &str, + font: egui::FontId, + ink: egui::Color32, + cycle_seconds: f64, + now: f64, +) { + let painter = ui.painter(); + let galley = painter.layout_no_wrap(text.to_string(), font.clone(), ink); + let rect = egui::Rect::from_center_size(center.center(), galley.size()); + painter.galley(rect.min, galley, ink); + + let cycle = cycle_seconds.max(0.05); + let progress = ((now % cycle) / cycle).clamp(0.0, 1.0) as f32; + let span = (rect.width() * 0.5).max(8.0); + let band_left = rect.left() - span + progress * (rect.width() + span); + for (width, alpha) in [(span * 0.35, 235_u8), (span * 0.7, 130), (span, 60)] { + let band = egui::Rect::from_min_max( + egui::pos2(rect.left(), rect.top() - 2.0), + egui::pos2(rect.left() + width, rect.bottom() + 2.0), + ) + .translate(egui::vec2(band_left - rect.left(), 0.0)); + let clip = band.intersect(rect); + if clip.width() <= 0.0 { + continue; + } + let shine = egui::Color32::from_rgba_unmultiplied( + theme::BLUE.r(), + theme::BLUE.g(), + theme::BLUE.b(), + alpha, + ); + let shine_galley = painter.layout_no_wrap(text.to_string(), font.clone(), shine); + painter + .with_clip_rect(clip) + .galley(rect.min, shine_galley, shine); + } +} + /// 圆形按钮(classic 为 28×28;Typeless 按 Tauri 的 zoom 比例缩放)。 fn round_button( ui: &egui::Ui, @@ -2322,7 +2436,7 @@ mod tests { ..Default::default() }, |ui| { - let _ = dictation_capsule(ui, &state, Lang::ZhCn); + let _ = dictation_capsule(ui, &state, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); }, ); let callbacks = output @@ -2351,12 +2465,21 @@ mod tests { true, ), ( - "terminal capsule paints no WGPU effect", + "terminal Siri capsule keeps the orb for the merge-out", CapsulePopupState { phase: "inserted".into(), text: "hello".into(), ..Default::default() }, + true, + ), + ( + "classic capsule never paints a WGPU effect", + CapsulePopupState { + phase: "polishing".into(), + style: "classic".into(), + ..Default::default() + }, false, ), ] { @@ -2375,7 +2498,7 @@ mod tests { ..Default::default() }; let painted = run(egui::vec2(200.0, 100.0), |ctx| { - dictation_capsule(ctx, &idle, Lang::ZhCn); + dictation_capsule(ctx, &idle, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); String::new() }); assert!( @@ -2388,7 +2511,7 @@ mod tests { ..idle }; let painted = run(egui::vec2(200.0, 100.0), |ctx| { - dictation_capsule(ctx, &translating, Lang::ZhCn); + dictation_capsule(ctx, &translating, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); String::new() }); assert!(has(&painted, badge), "{painted}"); @@ -2529,54 +2652,160 @@ mod tests { } } - /// 用户诉求:润色时光点要按大模型的吐字速度转。这条测试走完整渲染路径(同一个 egui - /// Context、逐帧推进时间),比较「没有速率」与「高速率」两种情况下 Siri 时钟推进了多少 - /// —— 时钟走得快就是光点转得快;没有速率时必须落在 Tauri 的基线转速上。 + /// 文本的声明颜色(扫光靠重绘另一份 galley 实现,所以要看 Text 的 section 颜色)。 + fn painted_text_colors(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(text) => { + for section in &text.galley.job.sections { + out.push(section.format.color); + } + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + painted_text_colors(shape, out); + } + } + _ => {} + } + } + + /// 偏蓝 = 扫光那一遍(INK 是中性灰,TYPELESS 是白)。 + fn is_bluish(color: egui::Color32) -> bool { + color.b() > color.r().saturating_add(24) && color.b() > color.g() + } + + /// 经典药丸的「思考中」扫光(Tauri `cap-shine`):高光带扫过字面时,文字会被蓝色 + /// 重绘一遍;Siri 舞台没有这段文字,自然也不会有。 #[test] - fn the_orb_spins_faster_while_the_model_streams() { - fn orb_clock_after(frames: usize, rate: Option) -> f32 { + fn the_classic_thinking_label_sweeps_a_shine_band() { + let collect = |time: f64, style: &str| { let ctx = egui::Context::default(); - for frame in 0..frames { - let state = CapsulePopupState { - phase: "Polishing".to_string(), - text: String::new(), - audio_level: None, - translation_active: false, - style: "siri".to_string(), - stream_rate: rate, - }; - let _ = crate::ui::frontend::run_pass( + let state = CapsulePopupState { + phase: "Polishing".into(), + style: style.into(), + ..Default::default() + }; + let mut colors = Vec::new(); + // 两帧:第一帧确定相位起点,第二帧才是被测的那一帧。 + for frame in [0.0, time] { + let output = crate::ui::frontend::run_pass( &ctx, egui::RawInput { screen_rect: Some(egui::Rect::from_min_size( egui::Pos2::ZERO, - egui::vec2(200.0, 100.0), + egui::vec2(200.0, 60.0), )), - time: Some(frame as f64 * 0.05), - predicted_dt: 0.05, + time: Some(frame), ..Default::default() }, |ui| { - let _ = dictation_capsule(ui, &state, Lang::ZhCn); + let _ = + dictation_capsule(ui, &state, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); }, ); + colors.clear(); + for clipped in &output.shapes { + painted_text_colors(&clipped.shape, &mut colors); + } } - ctx.data(|data| { - data.get_temp::(egui::Id::new(( - "openless-siri-clock", - "capsule-siri-orb", - ))) - .map(|clock| clock.time) - .unwrap_or_default() - }) - } + colors + }; + // 高速 burst 周期 0.9s:t=0.45 时高光带正扫到字面中部。 + assert!( + collect(0.45, "classic").iter().copied().any(is_bluish), + "the sweep must repaint the label in blue" + ); + // 慢速阶段(>2s)同样扫,只是周期变长。 + assert!( + collect(2.7, "classic").iter().copied().any(is_bluish), + "the sweep must keep running after the initial burst" + ); + // Siri 舞台没有这段文案。 + assert!( + !collect(0.45, "siri").iter().copied().any(is_bluish), + "the Siri stage has no caption to sweep" + ); + } - let idle = orb_clock_after(20, None); - assert!(idle > 0.0, "the orb clock must advance: {idle}"); - let streaming = orb_clock_after(20, Some(60.0)); + /// Siri 舞台出错时给的是宿主的具体文案(Tauri `message || t('capsule.error')`), + /// 不是笼统的「出现错误」。 + #[test] + fn the_siri_stage_prefers_the_host_error_message() { + let message = tr_l10n(Lang::ZhCn, "capsule.selectionPolish.noSelection"); + let failed = CapsulePopupState { + phase: "Failed".to_string(), + text: message.to_string(), + style: "siri".to_string(), + ..Default::default() + }; + let painted = run(egui::vec2(460.0, 180.0), |ctx| { + dictation_capsule(ctx, &failed, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); + String::new() + }); + assert!(has(&painted, message), "{painted}"); + assert!( + !has(&painted, tr_l10n(Lang::ZhCn, "capsule.error")), + "the generic error text must not replace the host message: {painted}" + ); + // 没有具体文案时才是通用文案。 + let bare = CapsulePopupState { + text: String::new(), + ..failed.clone() + }; + let painted = run(egui::vec2(460.0, 180.0), |ctx| { + dictation_capsule(ctx, &bare, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); + String::new() + }); assert!( - streaming > idle * 1.2, - "a fast model stream must spin the orb faster: idle={idle} streaming={streaming}" + has(&painted, tr_l10n(Lang::ZhCn, "capsule.error")), + "{painted}" + ); + } + + /// wave → orb 的交叉淡出(Tauri `opacity .6s ease-out .55s`):刚切到思考态时波形 + /// 还在(两个回调),淡出之后只剩圆点环。 + #[test] + fn the_wave_cross_fades_into_the_orb_after_recording() { + let ctx = egui::Context::default(); + let stage = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 180.0)); + let render = |time: f64, phase: &str| { + let state = CapsulePopupState { + phase: phase.into(), + audio_level: Some(0.4), + style: "siri".into(), + ..Default::default() + }; + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(stage), + time: Some(time), + ..Default::default() + }, + |ui| { + let _ = dictation_capsule(ui, &state, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); + }, + ); + output + .shapes + .iter() + .filter(|clipped| matches!(clipped.shape, egui::Shape::Callback(_))) + .count() + }; + assert_eq!( + render(0.0, "recording"), + 1, + "recording paints the wave only" + ); + assert_eq!( + render(0.2, "polishing"), + 2, + "right after the switch the wave must still be fading over the orb" + ); + assert_eq!( + render(2.0, "polishing"), + 1, + "once the fade is over only the orb remains" ); } @@ -2598,7 +2827,7 @@ mod tests { ..Default::default() }, |ui| { - let _ = dictation_capsule(ui, state, Lang::ZhCn); + let _ = dictation_capsule(ui, state, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); }, ); colors.clear(); @@ -2648,7 +2877,6 @@ mod tests { audio_level: Some(0.6), translation_active: false, style: "classic".to_string(), - stream_rate: None, }; let (colors, _, _) = capsule_frame(&state); let reddish: Vec<_> = colors @@ -2673,7 +2901,6 @@ mod tests { audio_level: Some(0.6), translation_active: false, style: "siri".to_string(), - stream_rate: None, }; let (colors, callbacks, effect) = capsule_frame(&state); // 音量竖条是 3px 宽的小圆角矩形:数一下细长条形的填充个数。 @@ -2733,7 +2960,8 @@ mod tests { ..Default::default() }, |ui| { - action = dictation_capsule(ui, &state, Lang::ZhCn); + action = + dictation_capsule(ui, &state, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); }, ); } @@ -2744,23 +2972,37 @@ mod tests { } } + /// 经典药丸的「思考中」文案必须本地化并在每一个处理相位都在;Siri 舞台反过来 —— + /// 它没有文案(Tauri `VoiceOrbStage` 只有 error 才给字),反馈全靠光效。 #[test] - fn siri_processing_caption_is_localized_in_every_processing_phase() { + fn classic_processing_caption_is_localized_and_siri_has_none() { for lang in [Lang::ZhCn, Lang::En] { for phase in ["starting", "transcribing", "polishing", "inserting"] { - let state = CapsulePopupState { + let classic = CapsulePopupState { phase: phase.into(), - style: "siri".into(), + style: "classic".into(), ..Default::default() }; - let painted = run(egui::vec2(460.0, 180.0), |ui| { - dictation_capsule(ui, &state, lang); + let painted = run(egui::vec2(200.0, 60.0), |ui| { + dictation_capsule(ui, &classic, lang, siri_wgpu::DEFAULT_WARMUP_MS); String::new() }); assert!( has(&painted, tr_l10n(lang, "capsule.thinking")), "{phase} must show the localized processing caption: {painted}" ); + let siri = CapsulePopupState { + style: "siri".into(), + ..classic.clone() + }; + let painted = run(egui::vec2(460.0, 180.0), |ui| { + dictation_capsule(ui, &siri, lang, siri_wgpu::DEFAULT_WARMUP_MS); + String::new() + }); + assert!( + !has(&painted, tr_l10n(lang, "capsule.thinking")), + "the Siri stage must not paint a caption (Tauri parity): {painted}" + ); } } } @@ -2781,7 +3023,7 @@ mod tests { ..Default::default() }, |ui| { - dictation_capsule(ui, &state, Lang::ZhCn); + dictation_capsule(ui, &state, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); }, ); assert!( @@ -2802,10 +3044,9 @@ mod tests { audio_level: Some(0.4), translation_active: false, style: "siri".to_string(), - stream_rate: None, }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { - dictation_capsule(ctx, &recording, Lang::ZhCn); + dictation_capsule(ctx, &recording, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); String::new() }); assert!( @@ -2820,7 +3061,7 @@ mod tests { ..Default::default() }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { - dictation_capsule(ctx, &transcribing, Lang::ZhCn); + dictation_capsule(ctx, &transcribing, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); String::new() }); assert!( @@ -2828,31 +3069,48 @@ mod tests { "{painted}" ); - // Siri keeps both its WGPU orb and localized processing caption. + // Siri 舞台没有文案:光点换乘「收尾合并」,字都交给光效(Tauri `VoiceOrbStage`)。 let transcribing_siri = CapsulePopupState { phase: "Transcribing".to_string(), style: "siri".to_string(), ..Default::default() }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { - dictation_capsule(ctx, &transcribing_siri, Lang::ZhCn); + dictation_capsule( + ctx, + &transcribing_siri, + Lang::ZhCn, + siri_wgpu::DEFAULT_WARMUP_MS, + ); String::new() }); assert!( - has(&painted, tr_l10n(Lang::ZhCn, "capsule.thinking")), - "siri transcribing must retain the processing label: {painted}" + !has(&painted, tr_l10n(Lang::ZhCn, "capsule.thinking")), + "siri transcribing must not paint a caption: {painted}" ); + // 「已插入 N 字」是经典药丸的文案;Siri 舞台不画它。 let done = CapsulePopupState { phase: "Completed".to_string(), text: inserted_message(Lang::ZhCn, 12), audio_level: None, translation_active: false, style: "siri".to_string(), - stream_rate: None, }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { - dictation_capsule(ctx, &done, Lang::ZhCn); + dictation_capsule(ctx, &done, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); + String::new() + }); + assert!( + !has(&painted, "12"), + "the Siri stage never paints the inserted-chars text: {painted}" + ); + let done_classic = CapsulePopupState { + style: "classic".to_string(), + ..done.clone() + }; + let painted = run(egui::vec2(200.0, 60.0), |ctx| { + dictation_capsule(ctx, &done_classic, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); String::new() }); assert!(has(&painted, "12"), "{painted}"); @@ -2863,10 +3121,9 @@ mod tests { audio_level: None, translation_active: false, style: "siri".to_string(), - stream_rate: None, }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { - dictation_capsule(ctx, &failed, Lang::ZhCn); + dictation_capsule(ctx, &failed, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); String::new() }); assert!( diff --git a/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs index 79d003c07..857ad4083 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs @@ -31,6 +31,8 @@ pub struct SiriEffect { pub radius: f32, pub thickness: f32, pub tint: [f32; 3], + /// 整体不透明度(预乘 alpha):wave→orb 交叉淡出用,`1.0` = 原样。 + pub opacity: f32, } impl SiriEffect { @@ -44,6 +46,7 @@ impl SiriEffect { radius: 0.0, thickness: 2.0, tint: [1.0; 3], + opacity: 1.0, } } @@ -57,6 +60,7 @@ impl SiriEffect { radius: 0.0, thickness: 2.0, tint: [1.0; 3], + opacity: 1.0, } } @@ -70,6 +74,7 @@ impl SiriEffect { radius, thickness, tint: [1.0; 3], + opacity: 1.0, } } @@ -77,6 +82,12 @@ impl SiriEffect { self.tint = tint; self } + + /// 预乘 alpha 的整体淡出(wave→orb 交叉淡出;Tauri `opacity .6s ease-out .55s`)。 + pub fn with_opacity(mut self, opacity: f32) -> Self { + self.opacity = opacity.clamp(0.0, 1.0); + self + } } /// Audio drive for the persistent egui animation clock. @@ -86,6 +97,10 @@ pub struct SiriDrive { pub resolved: f32, pub speed: f32, pub warming: bool, + /// 预备态预期时长(ms),驱动光条的「预测式展开」(Tauri `warmupMs`)。 + pub warmup_ms: f32, + /// 思考圆点的收尾:`true` 时六点干脆合回中央一颗圆(Tauri `merging`)。 + pub merging: bool, } impl Default for SiriDrive { @@ -95,6 +110,8 @@ impl Default for SiriDrive { resolved: 1.0, speed: 1.0, warming: false, + warmup_ms: DEFAULT_WARMUP_MS, + merging: false, } } } @@ -105,8 +122,25 @@ pub struct SiriClock { pub level: f32, pub resolved: f32, pub speed: f32, + /// 预备态展开进度(0 = 收拢/加载中,1 = 展开到位)。对齐 Tauri `warmProgress`。 + pub warm_progress: f32, + /// 思考圆点的「聚拢度」:1 = 六点全在圆心(出场那一拍),0 = 散开成环。 + pub gather: f32, + /// 该时钟存活了多久(真实秒,不乘 speed):驱动 `gather` 的 hold 计时。 + pub elapsed: f32, } +/// 预备态光条的「收拢度」:wave 停在 0.2 —— 光条明显还没展开(「加载中」)。 +/// 与 Tauri `SiriGL.tsx` 的 `WARMING_RESOLVED` 同值。 +pub const WARMING_RESOLVED: f32 = 0.2; +/// 思考圆点出场时「全聚圆心」保持的时长,之后才缓缓散开成环(Tauri `GATHER_HOLD_S`)。 +const GATHER_HOLD_SECONDS: f32 = 0.3; +/// 预备态预期时长的默认值(Tauri `WARMUP_MS_DEFAULT`)。 +pub const DEFAULT_WARMUP_MS: f32 = 150.0; +/// 学习出来的预热时长上下限(Tauri `readWarmupMs` 的 clamp)。 +pub const MIN_WARMUP_MS: f32 = 60.0; +pub const MAX_WARMUP_MS: f32 = 600.0; + pub fn visual_voice(raw: f32) -> f32 { const GATE: f32 = 0.012; const CEILING: f32 = 0.34; @@ -122,26 +156,81 @@ pub fn tick(ctx: &egui::Context, id: &str, drive: SiriDrive, dt: f32) -> SiriClo data.get_temp::(key).unwrap_or(SiriClock { time: 0.0, level: 0.0, - resolved: drive.resolved, + // 预备态一上场就从收拢态起步(未成形 = 加载中),避免「先展开一下又收拢」。 + resolved: if drive.warming { + WARMING_RESOLVED + } else { + drive.resolved + }, speed: drive.speed, + warm_progress: if drive.warming { 0.0 } else { 1.0 }, + // 圆点出场那一拍:六点全聚在圆心。 + gather: 1.0, + elapsed: 0.0, }) }); clock.speed += (drive.speed - clock.speed) * (1.0 - (-dt * 2.5).exp()); clock.time += dt * clock.speed; - let target = if drive.warming { + clock.elapsed += dt; + + // 预测式展开(Tauri 用户方案):入场不再死等就绪信号,而是按历史平均加载时长 + // `warmup_ms` 从按下就平滑推进;未就绪 cap 在 0.9(留一截给就绪收尾),一旦就绪 + // 快速补到 1 —— 就绪早 = 剩得多,看上去「迅速展开完成」。 + let warmup_sec = (drive.warmup_ms.clamp(MIN_WARMUP_MS, MAX_WARMUP_MS) / 1000.0).max(0.06); + if drive.warming { + clock.warm_progress = (clock.warm_progress + dt / warmup_sec).min(0.9); + } else { + clock.warm_progress += (1.0 - clock.warm_progress) * (1.0 - (-dt * 9.0).exp()); + } + + // resolved:思考态(0)走 Tauri 原版的「从容汇聚」;入场/录音由 warmProgress + // 从收拢态展开到满。 + let thinking = drive.resolved < 0.5; + let resolved_target = if thinking { + drive.resolved + } else { + WARMING_RESOLVED + (1.0 - WARMING_RESOLVED) * clock.warm_progress + }; + let resolved_k = if thinking { 2.2 } else { 9.0 }; + clock.resolved += (resolved_target - clock.resolved) * (1.0 - (-dt * resolved_k).exp()); + + let level_target = if drive.warming { 0.12 + 0.06 * (clock.time * 3.0).sin() - } else if drive.resolved < 0.5 { + } else if thinking { 0.14 + 0.07 * (clock.time * 2.2).sin() } else { visual_voice(drive.level) }; - let attack = if target > clock.level { 14.0 } else { 5.0 }; - clock.level += (target - clock.level) * (1.0 - (-dt * attack).exp()); - clock.resolved += (drive.resolved - clock.resolved) * (1.0 - (-dt * 3.0).exp()); + let attack = if level_target > clock.level { + 14.0 + } else { + 5.0 + }; + clock.level += (level_target - clock.level) * (1.0 - (-dt * attack).exp()); + + // 圆点环的生命周期(Tauri 用户拍板):出场全聚圆心接住 wave 收缩成的光点 → + // 稳住一拍后缓缓「从中心散开」成环转动 → `merging`(终态)时干脆地合回中央一颗圆。 + if drive.merging { + clock.gather += (1.0 - clock.gather) * (1.0 - (-dt * 4.0).exp()); + } else if clock.elapsed > GATHER_HOLD_SECONDS { + clock.gather += (0.0 - clock.gather) * (1.0 - (-dt * 1.6).exp()); + } + ctx.data_mut(|data| data.insert_temp(key, clock)); clock } +/// 丢掉某个时钟,让入场动画(wave 展开 / 圆点聚拢)从头走一遍。 +/// +/// Tauri 里胶囊组件每次会话重新挂载,动画状态天然是新的;Linux 这边两个窗口进程都 +/// 常驻,所以由调用方在新会话开始时显式复位。 +pub fn reset(ctx: &egui::Context, id: &str) { + let key = egui::Id::new(("openless-siri-clock", id)); + ctx.data_mut(|data| { + data.remove::(key); + }); +} + const SIRI_WGSL: &str = r#" struct Uniforms { time: f32, @@ -292,6 +381,17 @@ fn ring(frag: vec2) -> vec4 { @fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { let local = in.uv * u.size; + return fade(mode_color(local)); +} + +// 预乘 alpha 的整体淡出:`u.tint.a` 是 opacity(RGB 与 alpha 一起缩, +// 才能让 PREMULTIPLIED_ALPHA_BLENDING 下的淡出走对)。 +fn fade(color: vec4) -> vec4 { + let opacity = clamp(u.tint.a, 0.0, 1.0); + return vec4(color.rgb * opacity, color.a * opacity); +} + +fn mode_color(local: vec2) -> vec4 { if u.mode < 0.5 { return wave(local); } if u.mode < 1.5 { return orb(local); } return ring(local); @@ -302,13 +402,11 @@ fn fs_main(in: VertexOut) -> @location(0) vec4 { @fragment fn fs_main_linear(in: VertexOut) -> @location(0) vec4 { let local = in.uv * u.size; - var color: vec4; - if u.mode < 0.5 { color = wave(local); } - else if u.mode < 1.5 { color = orb(local); } - else { color = ring(local); } + let color = mode_color(local); let lower = color.rgb / 12.92; let higher = pow((color.rgb + vec3(0.055)) / 1.055, vec3(2.4)); - return vec4(select(higher, lower, color.rgb < vec3(0.04045)), color.a); + let linear = vec4(select(higher, lower, color.rgb < vec3(0.04045)), color.a); + return fade(linear); } "#; @@ -341,7 +439,7 @@ fn uniform_bytes(uniforms: SiriUniforms) -> [u8; 64] { uniforms.effect.tint[0], uniforms.effect.tint[1], uniforms.effect.tint[2], - 1.0, + uniforms.effect.opacity.clamp(0.0, 1.0), uniforms.size[0], uniforms.size[1], ]; @@ -594,6 +692,171 @@ mod tests { assert!(after.time - before < 0.06); } + /// 预备态:光条从收拢态起步,按 `warmup_ms` 预测式展开,就绪后快速补满。 + #[test] + fn the_wave_expands_on_the_learned_warmup_schedule() { + let ctx = egui::Context::default(); + let predict = |elapsed_ms: f32| { + let ctx = egui::Context::default(); + let mut clock = tick( + &ctx, + "warm", + SiriDrive { + warming: true, + warmup_ms: 200.0, + ..Default::default() + }, + 0.0, + ); + let frames = (elapsed_ms / 1000.0 / (1.0 / 60.0)) as usize; + for _ in 0..frames { + clock = tick( + &ctx, + "warm", + SiriDrive { + warming: true, + warmup_ms: 200.0, + ..Default::default() + }, + 1.0 / 60.0, + ); + } + clock.resolved + }; + // 挂载那一帧:收拢(未成形),而不是直接展开。 + let mount = tick( + &ctx, + "mount", + SiriDrive { + warming: true, + ..Default::default() + }, + 0.0, + ); + assert!((mount.resolved - WARMING_RESOLVED).abs() < 1e-6); + assert_eq!(mount.warm_progress, 0.0); + // 200ms 的预期时长:越等越展开,但未就绪时封在 0.9 的 cap 之下。 + let early = predict(0.0); + let halfway = predict(100.0); + let capped = predict(400.0); + assert!( + early < halfway && halfway < capped, + "{early} {halfway} {capped}" + ); + assert!( + capped < 1.0, + "warming must stay short of fully open: {capped}" + ); + // 就绪:快速补到满。 + let mut clock = tick( + &ctx, + "ready", + SiriDrive { + warming: true, + warmup_ms: 200.0, + ..Default::default() + }, + 0.0, + ); + for _ in 0..60 { + clock = tick( + &ctx, + "ready", + SiriDrive { + warming: true, + warmup_ms: 200.0, + ..Default::default() + }, + 1.0 / 60.0, + ); + } + for _ in 0..60 { + clock = tick(&ctx, "ready", SiriDrive::default(), 1.0 / 60.0); + } + assert!( + clock.resolved > 0.99, + "a ready microphone must open the ribbon: {}", + clock.resolved + ); + } + + /// 思考圆点:出场全聚圆心 → 稳住 0.3s → 散开成环;终态 `merging` 时合回一颗圆。 + #[test] + fn the_orb_gathers_then_spreads_and_merges_at_the_end() { + let ctx = egui::Context::default(); + let orb = |merging: bool| SiriDrive { + level: 0.0, + resolved: 0.0, + speed: 1.5, + warming: false, + warmup_ms: DEFAULT_WARMUP_MS, + merging, + }; + let mount = tick(&ctx, "orb", orb(false), 0.0); + assert_eq!(mount.gather, 1.0, "the six dots start merged in the centre"); + let mut clock = mount; + // 0.2s(还在 hold 里):仍是全聚。 + for _ in 0..12 { + clock = tick(&ctx, "orb", orb(false), 1.0 / 60.0); + } + assert!(clock.elapsed < GATHER_HOLD_SECONDS); + assert!(clock.gather > 0.99, "hold: {}", clock.gather); + // 又过了 1.5s:应该已经散开成环。 + for _ in 0..90 { + clock = tick(&ctx, "orb", orb(false), 1.0 / 60.0); + } + assert!(clock.gather < 0.2, "spread into a ring: {}", clock.gather); + // 终态:合回中央一颗圆。 + for _ in 0..60 { + clock = tick(&ctx, "orb", orb(true), 1.0 / 60.0); + } + assert!( + clock.gather > 0.9, + "merging into one circle: {}", + clock.gather + ); + } + + /// 新会话复位:时钟丢掉之后,下一帧回到「刚出场」的状态。 + #[test] + fn resetting_the_clock_replays_the_entry_animation() { + let ctx = egui::Context::default(); + for _ in 0..30 { + let _ = tick(&ctx, "session", SiriDrive::default(), 1.0 / 60.0); + } + reset(&ctx, "session"); + let fresh = tick( + &ctx, + "session", + SiriDrive { + warming: true, + ..Default::default() + }, + 0.0, + ); + assert_eq!(fresh.time, 0.0); + assert_eq!(fresh.elapsed, 0.0); + assert_eq!(fresh.warm_progress, 0.0); + assert_eq!(fresh.gather, 1.0); + assert!((fresh.resolved - WARMING_RESOLVED).abs() < 1e-6); + } + + /// 整体不透明度(预乘 alpha)写进 tint 的第四个分量。 + #[test] + fn opacity_lands_in_the_tint_alpha_channel() { + let opaque = uniform_bytes(SiriUniforms { + effect: SiriEffect::wave(1.0, 0.5), + size: [64.0, 64.0], + }); + let faded = uniform_bytes(SiriUniforms { + effect: SiriEffect::wave(1.0, 0.5).with_opacity(0.25), + size: [64.0, 64.0], + }); + let read = |bytes: &[u8; 64]| f32::from_ne_bytes(bytes[44..48].try_into().unwrap()); + assert_eq!(read(&opaque), 1.0); + assert_eq!(read(&faded), 0.25); + } + #[test] fn uniform_buffer_matches_wgsl_alignment() { let bytes = uniform_bytes(SiriUniforms { diff --git a/openless-all/app/linux-egui/src/ui/frontend/stream_rate.rs b/openless-all/app/linux-egui/src/ui/frontend/stream_rate.rs deleted file mode 100644 index 1e6d90e87..000000000 --- a/openless-all/app/linux-egui/src/ui/frontend/stream_rate.rs +++ /dev/null @@ -1,251 +0,0 @@ -//! 流式输出速率 → 思考光点转速。 -//! -//! 用户诉求:润色时圆点要「跟着大模型的吐字速度转」——输出快就转得快,模型卡住就慢下来。 -//! Tauri 版没有速率反馈(`VoiceOrbStage` 只按状态给固定 `speed`:thinking 1.5 / 收尾 1.0), -//! 所以这里的基线刻意定成 Tauri 的 1.5:**没有速率数据时观感与 Tauri 完全一致**,只有真的 -//! 收到流式增量(Core 的 `PolishDelta` / `TranscriptDelta`)才会往上加。 -//! -//! 两端分工: -//! · 宿主(`main.rs`)用 [`StreamRate`] 把增量字符数按滑动窗口折算成「字符/秒」下发; -//! · 胶囊窗口用 [`OrbSpeed`] 接住这个速率、把时间戳记成「刚收到」,再本地按时间衰减 —— -//! 流一停(网关卡住 / 模型想事情)就慢慢回到基线转速,不需要宿主额外发心跳。 - -use std::collections::VecDeque; - -/// 采样窗口:只统计最近这么多秒内收到的字符。 -const WINDOW_SECONDS: f64 = 0.5; -/// 低于该速率不加成:输出很慢时保持基线转速,避免圆点「抖一下快一下」。 -const RATE_FLOOR: f32 = 4.0; -/// 达到该速率即给到最高转速(中文 40 字/秒、英文 100+ 字符/秒已经算飞快)。 -const RATE_AT_MAX: f32 = 60.0; -/// 基线转速:Tauri `VoiceOrbStage` thinking 态的 `speed={1.5}`。 -pub const BASE_SPEED: f32 = 1.5; -/// 最高转速。再多也只到这里,免得圆点转成电风扇。 -pub const MAX_SPEED: f32 = 2.6; -/// 速率不再更新后的衰减时间常数(秒)。 -const DECAY_SECONDS: f64 = 0.45; -/// 转速跟随速率的平滑系数(越大越跟手;1.5 是 Tauri 里 `speed` 的固有平滑)。 -const SPEED_SMOOTHING: f32 = 6.0; -/// 速率被视为「变了」的最小差值:宿主每条增量都会重算,这条只是防止浮点噪声。 -const RATE_EPSILON: f32 = 1e-4; - -/// 速率 → 转速。没有数据 / 数据过小时返回 [`BASE_SPEED`],高到 [`MAX_SPEED`] 封顶。 -pub fn speed_for_rate(rate: f32) -> f32 { - if !rate.is_finite() || rate <= RATE_FLOOR { - return BASE_SPEED; - } - let t = ((rate - RATE_FLOOR) / (RATE_AT_MAX - RATE_FLOOR)).clamp(0.0, 1.0); - BASE_SPEED + (MAX_SPEED - BASE_SPEED) * t -} - -/// 宿主侧:把流式增量的字符数折算成「字符/秒」。 -#[derive(Debug, Clone, Default)] -pub struct StreamRate { - /// `(时刻秒, 字符数)`;窗口外的样本在读取时丢弃。 - samples: VecDeque<(f64, usize)>, -} - -impl StreamRate { - /// 记下一段流式增量。`now` 是宿主自己的单调时钟(秒)。 - pub fn observe(&mut self, now: f64, chars: usize) { - if chars == 0 { - return; - } - self.samples.push_back((now, chars)); - } - - /// 便捷入口:直接数一段文本的字符数(增量文本通常是 UTF-8 片段,按字符计更稳定)。 - pub fn observe_text(&mut self, now: f64, text: &str) { - self.observe(now, text.chars().count()); - } - - /// 当前速率(字符/秒)。窗口内没有样本就是 0,调用方据此回落基线转速。 - pub fn rate(&mut self, now: f64) -> f32 { - self.prune(now); - let chars: usize = self.samples.iter().map(|(_, chars)| *chars).sum(); - if chars == 0 { - return 0.0; - } - chars as f32 / WINDOW_SECONDS as f32 - } - - /// 新会话 / 新阶段开始时清空,避免上一轮的样本把首帧抬起来。 - pub fn reset(&mut self) { - self.samples.clear(); - } - - fn prune(&mut self, now: f64) { - while let Some((at, _)) = self.samples.front() { - if now - *at > WINDOW_SECONDS { - self.samples.pop_front(); - } else { - break; - } - } - } -} - -/// 胶囊窗口侧:接住宿主下发的速率,并在没有新数据时按时间衰减回基线。 -#[derive(Debug, Clone, Default)] -pub struct OrbSpeed { - /// 最近一次收到的速率;`None` = 还没有过数据。 - payload: Option, - /// 速率上一次发生变化的时刻(秒,胶囊窗口自己的单调时钟)。 - updated_at: f64, - /// 当前平滑后的转速。 - speed: f32, - /// 是否已经有初值(首帧直接落在目标上,避免入场时从 0 往上爬)。 - primed: bool, -} - -impl OrbSpeed { - /// 喂入宿主下发的速率并推进一帧,返回本帧该用的转速。 - /// - /// 只有速率**变化**时才刷新时间戳:宿主每条流式增量都会重算速率,数值几乎每次都会 - /// 变,所以「不变」基本等于流停了;一直一模一样(例如被 clamp 在顶)时本地衰减会略微 - /// 回退,收到下一条又会跟上,观感上察觉不到。 - pub fn tick(&mut self, now: f64, dt: f32, payload: Option) -> f32 { - let current = match payload { - Some(rate) if rate.is_finite() => rate.max(0.0), - _ => 0.0, - }; - let changed = match self.payload { - Some(previous) => (previous - current).abs() > RATE_EPSILON, - None => true, - }; - if changed { - self.updated_at = now; - } - self.payload = Some(current); - - let age = (now - self.updated_at).max(0.0); - let decayed = current as f64 * (-age / DECAY_SECONDS).exp(); - let target = speed_for_rate(decayed as f32); - if !self.primed { - self.speed = target; - self.primed = true; - return self.speed; - } - let dt = dt.clamp(0.0, 0.1); - self.speed += (target - self.speed) * (1.0 - (-dt * SPEED_SMOOTHING).exp()); - self.speed - } - - /// 离开思考态时复位,下次进入从头开始(先基线,再随增量爬升)。 - pub fn reset(&mut self) { - *self = Self::default(); - } -} - -/// 进程内单调时钟(秒)。宿主与胶囊窗口各自在自己的进程里用它对齐 `observe` / `tick`。 -pub fn now_seconds() -> f64 { - use std::sync::OnceLock; - use std::time::Instant; - static START: OnceLock = OnceLock::new(); - START.get_or_init(Instant::now).elapsed().as_secs_f64() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn an_empty_meter_reports_no_rate() { - let mut meter = StreamRate::default(); - assert_eq!(meter.rate(0.0), 0.0); - } - - #[test] - fn the_rate_counts_characters_inside_the_window() { - let mut meter = StreamRate::default(); - // 窗口 0.5s:0.0s 收到 10 字、0.1s 收到 10 字 → 20 字 / 0.5s = 40 字每秒。 - meter.observe(0.0, 10); - meter.observe(0.1, 10); - assert!((meter.rate(0.1) - 40.0).abs() < 0.01); - // 窗口外的样本过期:0.6s 时只剩第二笔。 - assert!((meter.rate(0.6) - 20.0).abs() < 0.01); - } - - #[test] - fn text_is_counted_in_characters_not_bytes() { - let mut meter = StreamRate::default(); - meter.observe_text(0.0, "润色结果"); - assert!((meter.rate(0.0) - 8.0).abs() < 0.01); - } - - #[test] - fn stale_samples_expire_and_reset_clears_everything() { - let mut meter = StreamRate::default(); - meter.observe(0.0, 100); - assert_eq!(meter.rate(2.0), 0.0); - meter.observe(2.0, 100); - meter.reset(); - assert_eq!(meter.rate(2.0), 0.0); - } - - #[test] - fn speed_without_data_matches_the_tauri_baseline() { - let mut orb = OrbSpeed::default(); - for frame in 0..30 { - let now = 0.1 + f64::from(frame) * 0.016; - let speed = orb.tick(now, 0.016, None); - assert!((speed - BASE_SPEED).abs() < 1e-6); - } - } - - #[test] - fn speed_rises_with_the_rate_and_is_clamped() { - assert_eq!(speed_for_rate(0.0), BASE_SPEED); - assert_eq!(speed_for_rate(RATE_FLOOR), BASE_SPEED); - assert_eq!(speed_for_rate(1_000.0), MAX_SPEED); - let slow = speed_for_rate(15.0); - let fast = speed_for_rate(45.0); - assert!( - slow > BASE_SPEED && slow < fast && fast < MAX_SPEED, - "{slow} {fast}" - ); - } - - #[test] - fn speed_decays_back_to_the_baseline_when_the_stream_stalls() { - let mut orb = OrbSpeed::default(); - let mut now = 0.0; - // 一段高速输出:转速爬上去。 - for _ in 0..40 { - // 每帧速率都略有变化,模拟宿主每条增量重算出来的值。 - now += 0.016; - orb.tick(now, 0.016, Some(55.0 + (now * 10.0).sin() as f32)); - } - let hot = orb.speed; - assert!(hot > BASE_SPEED + 0.2, "expected a visible spin-up: {hot}"); - // 流停了:不再有新速率,转速应该慢慢回到基线。 - for _ in 0..200 { - now += 0.016; - orb.tick(now, 0.016, None); - } - assert!((orb.speed - BASE_SPEED).abs() < 0.02, "speed={}", orb.speed); - // 复位后立刻回到基线(下次思考从头开始)。 - orb.reset(); - assert_eq!(orb.tick(now, 0.016, None), BASE_SPEED); - } - - #[test] - fn a_fresh_rate_after_a_stall_climbs_again() { - let mut orb = OrbSpeed::default(); - let mut now = 0.0; - for _ in 0..30 { - now += 0.016; - orb.tick(now, 0.016, Some(50.0)); - } - for _ in 0..60 { - now += 0.016; - orb.tick(now, 0.016, Some(50.0)); - } - let after_stall = orb.speed; - for _ in 0..30 { - now += 0.016; - orb.tick(now, 0.016, Some(50.0 + (now * 7.0).sin() as f32)); - } - assert!(orb.speed > after_stall, "{} -> {}", after_stall, orb.speed); - } -} diff --git a/openless-all/app/linux-egui/src/ui_state.rs b/openless-all/app/linux-egui/src/ui_state.rs index 52cd0c41a..7cbf8e98d 100644 --- a/openless-all/app/linux-egui/src/ui_state.rs +++ b/openless-all/app/linux-egui/src/ui_state.rs @@ -141,6 +141,47 @@ pub fn save_quick_note_shortcut_hidden(hidden: bool) -> Result<(), UiStateError> ) } +/// 胶囊「预备 → 就绪」的平均耗时(ms):Tauri 存在 localStorage +/// (`ol-capsule-warmup-ms`),Linux 这边落在同一份 UI 状态文档里(`capsule_warmup_ms`)。 +/// 缺失 / 越界一律回落到默认值,学习值也不可能把动画带飞。 +pub fn load_capsule_warmup_ms() -> f32 { + let stored = read_state_object() + .get("capsule_warmup_ms") + .and_then(serde_json::Value::as_f64); + match stored { + Some(value) if value.is_finite() => (value as f32).clamp( + crate::siri_wgpu::MIN_WARMUP_MS, + crate::siri_wgpu::MAX_WARMUP_MS, + ), + _ => crate::siri_wgpu::DEFAULT_WARMUP_MS, + } +} + +/// 用一次实测的「预备 → 就绪」耗时更新学习值,并把新值落盘、返回给调用方。 +/// +/// 异常样本直接丢弃(返回 `None`):<20ms 多半不是真入场,>3s 多半是首次设备授权或 +/// 系统卡顿,都不代表常态。公式与 Tauri `Capsule.tsx` 完全一致: +/// `clamp(prev * 0.7 + observed * 0.3, 60, 600)`。 +pub fn learn_capsule_warmup_ms(observed_ms: f32) -> Option { + if !observed_ms.is_finite() || !(20.0..=3000.0).contains(&observed_ms) { + return None; + } + let previous = load_capsule_warmup_ms(); + let next = (previous * 0.7 + observed_ms * 0.3).clamp( + crate::siri_wgpu::MIN_WARMUP_MS, + crate::siri_wgpu::MAX_WARMUP_MS, + ); + let _ = save_capsule_warmup_ms(next); + Some(next) +} + +pub fn save_capsule_warmup_ms(warmup_ms: f32) -> Result<(), UiStateError> { + write_state_value( + "capsule_warmup_ms", + serde_json::Value::from(f64::from(warmup_ms)), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -223,4 +264,42 @@ mod tests { assert_eq!(load_locale_pref(), LocalePref::Lang(Lang::Ja)); }); } + + #[test] + fn capsule_warmup_defaults_and_learns_a_bounded_average() { + with_tmp_state(|_dir| { + // 缺省 = Tauri 的 150ms;损坏 / 越界值也回落默认。 + assert_eq!( + load_capsule_warmup_ms(), + crate::siri_wgpu::DEFAULT_WARMUP_MS + ); + save_capsule_warmup_ms(9_999.0).unwrap(); + assert_eq!(load_capsule_warmup_ms(), crate::siri_wgpu::MAX_WARMUP_MS); + save_capsule_warmup_ms(1.0).unwrap(); + assert_eq!(load_capsule_warmup_ms(), crate::siri_wgpu::MIN_WARMUP_MS); + }); + } + + #[test] + fn capsule_warmup_learning_moves_towards_the_observation() { + with_tmp_state(|_dir| { + // 异常样本(太快 / 太慢 / 非数)一律丢弃。 + assert_eq!(learn_capsule_warmup_ms(5.0), None); + assert_eq!(learn_capsule_warmup_ms(9_000.0), None); + assert_eq!(learn_capsule_warmup_ms(f32::NAN), None); + // 正常样本:EMA 向观测值靠(150*0.7 + 500*0.3 = 255)。 + let learned = learn_capsule_warmup_ms(500.0).expect("sample is plausible"); + assert!((learned - 255.0).abs() < 0.01, "{learned}"); + assert!((load_capsule_warmup_ms() - 255.0).abs() < 0.01); + // 连续更快就绪会把估计拉下来(但不会越出下限)。 + let mut value = learned; + for _ in 0..40 { + value = learn_capsule_warmup_ms(25.0).expect("sample is plausible"); + } + assert!( + (value - crate::siri_wgpu::MIN_WARMUP_MS).abs() < 0.01, + "{value}" + ); + }); + } } From 1529ebad8b456c942df46452dddf69b489b8801c Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Wed, 30 Sep 2026 23:37:09 +0800 Subject: [PATCH 32/35] =?UTF-8?q?test(linux-egui):=20=E6=8A=8A=E3=80=8C?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E4=B8=8D=E6=8E=92=E7=A9=BA=20+=20=E4=BF=9D?= =?UTF-8?q?=E6=B4=BB=E5=BF=AB=E7=85=A7=E3=80=8D=E7=9A=84=205.8=20GB=20?= =?UTF-8?q?=E4=BA=8B=E6=95=85=E9=92=89=E6=88=90=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现场:窗口被遮挡时 eframe 完全跳过 egui pass(`if !show_ui`),`ui()` 一次都不跑, `take_snapshot`/`try_recv` 也就没人调;宿主仍每 2 秒发一份保活快照。旧代码把快照塞进 无界通道 —— 21 小时≈3.7 万份全留在内存里(系统账本:21.1 小时那次会话峰值 5.70 GB, 折算 78.8 KB/s;同长度会话另有 0.96/1.30 GB 两个),窗口恢复可见后还要逐份消化,表现 为卡死。单槽位 `LatestSnapshot`(rev 120)修掉了语义,但没有任何测试能证明「内存不再 跟着份数涨」—— 原来那条只断言槽位里只有一份。 新测试 `an_undrained_window_survives_hours_of_keepalive_snapshots` 直接量 RSS:拿裸 socket 当宿主(绕过宿主侧合并槽这另一半修复),连发 1000 份 32 KiB 快照、一份不取, 只在等待「最新一份」可用时顺便断言驻留内存不超过 8 MiB。 验证过它能红:把读线程改回「进无界通道」,0.73 秒即失败 (`300 sent, RSS grew 10 MiB`);装回单槽位后 1.9 秒通过。踩到的两个坑写在注释里 —— 等待 `take_snapshot` 会让旧代码先以超时失败、掩盖内存断言;突发式发送会被宿主侧合并槽 吞掉中间帧,也复现不出客户端堆积。 --- openless-all/app/linux-egui/src/ui/bridge.rs | 118 +++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/openless-all/app/linux-egui/src/ui/bridge.rs b/openless-all/app/linux-egui/src/ui/bridge.rs index dac73dae7..9202d350f 100644 --- a/openless-all/app/linux-egui/src/ui/bridge.rs +++ b/openless-all/app/linux-egui/src/ui/bridge.rs @@ -900,6 +900,124 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// 常驻内存(Linux)。用来把「现场 21 小时 5.8 GB」变成一条能自己复现的断言: + /// 只断言语义(槽位里只有一份)是不够的,得看真实 RSS。 + fn rss_bytes() -> usize { + let statm = std::fs::read_to_string("/proc/self/statm").expect("read statm"); + let pages: usize = statm + .split_whitespace() + .nth(1) + .expect("resident field") + .parse() + .expect("resident pages"); + pages * 4096 + } + + #[test] + fn an_undrained_window_survives_hours_of_keepalive_snapshots() { + // 现场(21 小时 5.8 GB):窗口被遮挡时 eframe 完全跳过 egui pass + // (`if !show_ui`),`ui()` 一次都不跑,也就没有任何东西排空队列;而宿主每 2 + // 秒发一份保活快照。旧代码把快照放进无界通道 —— 21 小时≈3.7 万份全堆在内存里, + // 窗口恢复可见后还要逐份消化,表现为卡死。 + // + // 这里用**裸 socket 当宿主**,绕过宿主侧的合并槽(那是修复的另一半),单独验证 + // 客户端这一半:连发 1000 份 32 KiB 快照、一份都不取,断言 RSS 不跟着份数涨。 + // 旧代码在这条测试里会先撞上 8 MiB 的线。 + const SNAPSHOTS: u64 = 1000; + const PAYLOAD_BYTES: usize = 32 * 1024; + const GROWTH_LIMIT: usize = 8 * 1024 * 1024; + let dir = temp_dir("keepalive-pressure"); + let path = ui_socket_path(&dir); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + let mut client = UiBridgeClient::connect(&path).unwrap(); + let (mut stream, _) = listener.accept().unwrap(); + write_frame( + &mut stream, + &HostToWindow::Ready { + version: UI_BRIDGE_VERSION, + }, + ) + .unwrap(); + assert!(matches!( + wait_for(|| client.try_recv().ok()), + HostToWindow::Ready { .. } + )); + let template = FrontendViewModel { + active_page: Page::History, + history_query: "x".repeat(PAYLOAD_BYTES), + ..Default::default() + }; + // 先发一份热的,再取基线。 + write_frame( + &mut stream, + &HostToWindow::Snapshot { + sequence: 1, + view_model: Box::new(template.clone()), + }, + ) + .unwrap(); + // 不等 `take_snapshot`:旧代码里快照根本不进槽位,先等它只会以超时失败, + // 把真正的内存堆积掩盖过去。 + std::thread::sleep(Duration::from_millis(150)); + let baseline = rss_bytes(); + let snapshot = |stream: &mut std::os::unix::net::UnixStream, sequence: u64| { + write_frame( + stream, + &HostToWindow::Snapshot { + sequence, + view_model: Box::new(template.clone()), + }, + ) + .unwrap(); + }; + for sequence in 2..=SNAPSHOTS { + snapshot(&mut stream, sequence); + if sequence % 100 == 0 { + let growth = rss_bytes().saturating_sub(baseline); + assert!( + growth < GROWTH_LIMIT, + "undrained snapshots must not pile up: {sequence} sent, RSS grew {} MiB", + growth / (1024 * 1024) + ); + } + } + // 不排空任何队列,只等最新那份可用;等待期间继续量内存。 + let deadline = std::time::Instant::now() + Duration::from_secs(30); + let sequence = loop { + if let Some((sequence, _)) = client.take_snapshot() { + if sequence == SNAPSHOTS { + break sequence; + } + } + let growth = rss_bytes().saturating_sub(baseline); + assert!( + growth < GROWTH_LIMIT, + "undrained snapshots must not pile up: RSS grew {} MiB before the newest arrived", + growth / (1024 * 1024) + ); + assert!( + std::time::Instant::now() < deadline, + "the newest snapshot never arrived" + ); + std::thread::sleep(Duration::from_millis(5)); + }; + assert_eq!(sequence, SNAPSHOTS); + let growth = rss_bytes().saturating_sub(baseline); + assert!( + growth < GROWTH_LIMIT, + "RSS grew {} MiB over {SNAPSHOTS} keepalive snapshots", + growth / (1024 * 1024) + ); + assert!(client.take_snapshot().is_none()); + assert!( + matches!(client.try_recv(), Err(TryRecvError::Empty)), + "snapshots must never queue in the unbounded channel" + ); + drop(stream); + client.shutdown(); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_dead_bridge_socket_is_replaced_on_bind() { let dir = temp_dir("stale"); From a34f57c7c8e564bc8fce13b4bcb21e006fb15eaa Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Wed, 30 Sep 2026 23:48:50 +0800 Subject: [PATCH 33/35] =?UTF-8?q?fix(linux-egui):=20=E9=A6=96=E9=80=89?= =?UTF-8?q?=E9=BA=A6=E5=85=8B=E9=A3=8E=E4=B8=8D=E5=86=8D=E6=8A=8A=E9=9F=B3?= =?UTF-8?q?=E5=93=8D/HDMI=20=E7=9B=91=E5=90=AC=E5=88=97=E8=BF=9B=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报障「麦克风选择变成了音响选择」。本机实测 `cpal::input_devices()` 给出 8 个节点, 设置页的「首选麦克风」里排着 `TU116 High Definition Audio Controller Pro`(显卡 HDMI 输出的监听)、`内置音频 Pro`(内置扬声器的监听),而真实采集源 `内置音频 Pro 2` 反而在 后面 —— 选中监听就是录到扬声器回环(静音或系统声音),转写自然出不来东西。 成因:PipeWire 给每个 sink 都造一个 monitor source;Pulse 给它的描述是 `Monitor of `,而 cpal 的 Pulse 后端把 `Monitor of ` 前缀去掉后原样当成设备 名。`pactl list sources` 的 monitor 项带 `Monitor of Sink:` 字段,据此把描述精确还原成 cpal 会显示的名字即可剔除,不需要猜命名规律 —— 真实采集源的描述不带前缀,永远不会被误删。 - `pulse_sources()` 读 `pactl list sources` + `get-default-source`;判定合到纯函数 `keep_microphone()` 里,`enumerate_microphones()` 只负责 cpal 侧枚举。 - 顺手修 `is_default`:`cpal::default_input_device()` 拿到的是 PipeWire 的 `default_input` 合成节点,永远不等于真实设备名,所以「默认」标记此前从来没标上过;改用 pulse 的默认源。 - `pactl` 缺失/失败时返回空值、退回旧行为:宁可多列,也不误删用户的麦克风。 - 以前误选过监听的名字不会再匹配到任何设备,`audio.rs` 会回落到系统默认麦克风并记一条 warning —— 升级后不用手动改,录音即恢复。 真机核对:修复后列表只剩 `内置音频 Pro 2`(is_default=true),5 个监听全部消失。 测试:`monitor_sources_are_not_offered_as_microphones`(按现场 pactl 片段核对剔除集与 默认源)、`real_capture_sources_survive_the_monitor_filter`(现场 8 个节点逐个核对)、 `without_pactl_no_device_is_dropped`。 package revision 127。 --- openless-all/app/linux-egui/package-revision | 2 +- .../app/linux-egui/src/capabilities.rs | 192 +++++++++++++++++- 2 files changed, 190 insertions(+), 4 deletions(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 0a3e7b042..c75acbe2f 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -126 +127 diff --git a/openless-all/app/linux-egui/src/capabilities.rs b/openless-all/app/linux-egui/src/capabilities.rs index 68bda6223..3300b8e29 100644 --- a/openless-all/app/linux-egui/src/capabilities.rs +++ b/openless-all/app/linux-egui/src/capabilities.rs @@ -200,6 +200,7 @@ fn enumerate_microphones() -> Result, BackendError> { let host = cpal::default_host(); let default_name = host.default_input_device().map(|device| device.to_string()); + let pulse = pulse_sources(); let devices = host.input_devices().map_err(|error| { BackendError::new( BackendErrorCode::Platform, @@ -213,7 +214,7 @@ fn enumerate_microphones() -> Result, BackendError> { .default_input_config() .map(|config| config.channels()) .unwrap_or(0); - if !selectable_microphone(&name, channels) { + if !keep_microphone(&name, channels, &pulse) { continue; } // 同一个设备会以多条节点出现(例如「内置音频 Pro」出现两次)。 @@ -222,13 +223,130 @@ fn enumerate_microphones() -> Result, BackendError> { } out.push(MicrophoneDevice { id: format!("cpal:{index}:{name}"), - is_default: default_name.as_deref() == Some(name.as_str()), + // cpal 的 `default_input_device` 拿到的是 PipeWire 的 `default_input` + // 合成节点,永远不等于真实设备名;真正要标「默认」的是 pulse 的默认源。 + is_default: pulse.default.as_deref() == Some(name.as_str()) + || default_name.as_deref() == Some(name.as_str()), name, }); } Ok(out) } +/// 一台设备要不要进「首选麦克风」:先按节点特征筛([`selectable_microphone`]),再剔除 +/// 输出监听([`PulseSources::monitors`])。抽成纯函数是为了能直接测这两段合起来的结果。 +#[cfg(target_os = "linux")] +fn keep_microphone(name: &str, channels: u16, pulse: &PulseSources) -> bool { + selectable_microphone(name, channels) && !pulse.monitors.iter().any(|monitor| monitor == name) +} + +/// PipeWire/Pulse 的源列表里,我们唯一拿不到、但足以区分「音响监听」的信息。 +/// +/// 现场:本机 `cpal::input_devices()` 给出 8 个节点,其中 +/// `TU116 High Definition Audio Controller Pro`(显卡 HDMI 输出的监听)、 +/// `内置音频 Pro`(内置扬声器的监听)都被当成麦克风列了出来,真实采集源 +/// `内置音频 Pro 2` 反而排在后面 —— 用户反馈「麦克风选择变成了音响选择」。 +/// +/// 成因:PipeWire 给每个 sink 都造一个 monitor source,Pulse 给它的描述是 +/// `Monitor of `,而 cpal 的 Pulse 后端把 `Monitor of ` 前缀去掉后原样 +/// 当成设备名。`pactl list sources` 的 monitor 项带 `Monitor of Sink:` 字段,据此把 +/// 描述精确还原成 cpal 会显示的那个名字即可剔除,不需要猜命名规律:真实采集源 +/// (如本机 `内置音频 Pro 2`)的描述不带前缀,永远不会被误删。 +#[derive(Debug, Default, PartialEq, Eq)] +struct PulseSources { + /// 要剔除的 cpal 设备名(monitor 描述去掉 `Monitor of ` 前缀)。 + monitors: Vec, + /// 默认采集源的描述;cpal 对真实源用的就是 Pulse 的 `Description`。 + default: Option, +} + +/// 读 `pactl`。工具缺失或失败时返回空值:宁可多列(退回旧行为),也不误删用户的麦克风。 +#[cfg(target_os = "linux")] +fn pulse_sources() -> PulseSources { + let list = std::process::Command::new("pactl") + .args(["list", "sources"]) + .output(); + let Ok(list) = list else { + return PulseSources::default(); + }; + if !list.status.success() { + return PulseSources::default(); + } + let default = std::process::Command::new("pactl") + .arg("get-default-source") + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|name| !name.is_empty()); + parse_pulse_sources(&String::from_utf8_lossy(&list.stdout), default.as_deref()) +} + +/// 解析 `pactl list sources`。 +/// +/// 每个 source 形如(顶层字段缩进一个制表符,`Properties:` 里两个;下面用空格示意): +/// +/// ```text +/// Source #57 +/// Name: alsa_output.pci-0000_00_1f.3.pro-output-0.monitor +/// Description: Monitor of Built-in Audio Pro +/// Monitor of Sink: alsa_output.pci-0000_00_1f.3.pro-output-0 +/// Properties: +/// node.name = "alsa_output.pci-0000_00_1f.3.pro-output-0.monitor" +/// ``` +#[cfg(target_os = "linux")] +fn parse_pulse_sources(text: &str, default_name: Option<&str>) -> PulseSources { + let mut out = PulseSources::default(); + let mut name: Option = None; + let mut description: Option = None; + let mut is_monitor = false; + let flush = |name: Option, + description: Option, + is_monitor: bool, + out: &mut PulseSources| { + let Some(description) = description else { + return; + }; + if is_monitor { + let stripped = description + .strip_prefix("Monitor of ") + .unwrap_or(&description) + .to_string(); + if !out.monitors.contains(&stripped) { + out.monitors.push(stripped); + } + } else if name.as_deref() == default_name { + out.default = Some(description); + } + }; + for line in text.lines() { + let Some(field) = line.strip_prefix('\t') else { + continue; + }; + // `Properties:` 块里的字段缩进是两层,`node.name`/`device.description` 都在其中, + // 不能当成顶层字段读。 + if field.starts_with('\t') { + continue; + } + if let Some(value) = field.strip_prefix("Name: ") { + if name.is_some() { + flush(name.take(), description.take(), is_monitor, &mut out); + is_monitor = false; + } + name = Some(value.trim().to_string()); + } else if let Some(value) = field.strip_prefix("Description: ") { + description = Some(value.trim().to_string()); + } else if field.starts_with("Monitor of Sink:") { + is_monitor = field + .trim_start_matches("Monitor of Sink:") + .trim() + .ne("n/a"); + } + } + flush(name, description, is_monitor, &mut out); + out +} + /// cpal 的 PipeWire 后端把**所有节点**都当成输入设备:输出监听(`sink-*`、 /// `*.monitor`)、合成默认节点、以及 HDMI/环绕监听都会出现在 `input_devices()` 里, /// 同名设备还会重复出现。原样透传会让设置页的「首选麦克风」变成一个装满噪声的 @@ -257,7 +375,75 @@ fn selectable_microphone(name: &str, channels: u16) -> bool { #[cfg(all(test, target_os = "linux"))] mod microphone_filter_tests { - use super::selectable_microphone; + use super::{keep_microphone, parse_pulse_sources, selectable_microphone}; + + /// 本机 `pactl list sources` 的真实形态(两个 sink 监听 + 一个真采集源), + /// 设备名沿用现场的转义写法。 + const PACTL_LIST_SOURCES: &str = "Source #57\n\ +\tName: alsa_output.pci-0000_01_00.1.pro-output-3.monitor\n\ +\tDescription: Monitor of TU116 High Definition Audio Controller Pro\n\ +\tMonitor of Sink: alsa_output.pci-0000_01_00.1.pro-output-3\n\ +\tProperties:\n\ +\t\tnode.name = \"alsa_output.pci-0000_01_00.1.pro-output-3.monitor\"\n\ +\t\tdevice.description = \"TU116 High Definition Audio Controller\"\n\ +Source #5207\n\ +\tName: alsa_output.pci-0000_00_1f.3.pro-output-0.monitor\n\ +\tDescription: Monitor of \u{5185}\u{7f6e}\u{97f3}\u{9891} Pro\n\ +\tMonitor of Sink: alsa_output.pci-0000_00_1f.3.pro-output-0\n\ +Source #5208\n\ +\tName: alsa_input.pci-0000_00_1f.3.pro-input-2\n\ +\tDescription: \u{5185}\u{7f6e}\u{97f3}\u{9891} Pro 2\n\ +\tMonitor of Sink: n/a\n\ +\tProperties:\n\ +\t\tdevice.description = \"\u{5185}\u{7f6e}\u{97f3}\u{9891} Pro 2\"\n"; + + #[test] + fn monitor_sources_are_not_offered_as_microphones() { + let pulse = parse_pulse_sources( + PACTL_LIST_SOURCES, + Some("alsa_input.pci-0000_00_1f.3.pro-input-2"), + ); + // 监听源的描述在 cpal 侧就是去掉前缀后的 sink 描述。 + assert_eq!( + pulse.monitors, + vec![ + "TU116 High Definition Audio Controller Pro".to_string(), + "\u{5185}\u{7f6e}\u{97f3}\u{9891} Pro".to_string(), + ] + ); + // 默认采集源是那台真麦克风,不是任何监听。 + assert_eq!( + pulse.default.as_deref(), + Some("\u{5185}\u{7f6e}\u{97f3}\u{9891} Pro 2") + ); + } + + #[test] + fn real_capture_sources_survive_the_monitor_filter() { + let pulse = parse_pulse_sources( + PACTL_LIST_SOURCES, + Some("alsa_input.pci-0000_00_1f.3.pro-input-2"), + ); + let keep = |name: &str, channels: u16| keep_microphone(name, channels, &pulse); + // 现场 cpal 给出的 8 个节点,逐个核对(raw 顺序即此)。 + assert!(!keep("default_sink", 2)); + assert!(!keep("default_input", 2)); + assert!(!keep("TU116 High Definition Audio Controller Pro", 2)); + assert!(!keep("TU116 High Definition Audio Controller Pro 7", 8)); + assert!(!keep("TU116 High Definition Audio Controller Pro 9", 8)); + assert!(!keep("\u{5185}\u{7f6e}\u{97f3}\u{9891} Pro", 2)); + // 只有真麦克风留下来。 + assert!(keep("\u{5185}\u{7f6e}\u{97f3}\u{9891} Pro 2", 2)); + } + + #[test] + fn without_pactl_no_device_is_dropped() { + // 拿不到源列表时退回旧行为:宁可多列,也不能误删用户的麦克风。 + let pulse = parse_pulse_sources("", None); + assert_eq!(pulse, super::PulseSources::default()); + assert!(keep_microphone("Some USB microphone", 1, &pulse)); + assert!(keep_microphone("USB Headset", 2, &pulse)); + } #[test] fn non_capture_nodes_are_not_offered_as_microphones() { From c546be63b24b4105915c005893297021b244ea26 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Thu, 1 Oct 2026 00:04:00 +0800 Subject: [PATCH 34/35] =?UTF-8?q?chore(linux-egui):=20=E8=B7=9F=E4=B8=8A?= =?UTF-8?q?=20Beta.4=20=E7=9A=84=E7=89=88=E6=9C=AC=E7=BA=BF=E4=B8=8E?= =?UTF-8?q?=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit merge origin/beta(`6f9af895`,零冲突)后: - `package-revision` → 128,包名进到 2.0.0-Beta.4-128; - `sync-egui-i18n.mjs` 同步 4 条上游改过的文案(settings.selection_workspace.hint、 vocab.filter_auto、modal.descriptions.advanced、marketplace.upload.hint)。 注意 `vocab.filter_auto`:上游这版是「确认收集」(Beta.4 的 confirmation-based vocabulary learning),egui 侧目前还是「自动添加」的行为 —— 标签已对齐,**行为还没对齐**,单独跟进。 --- openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/i18n.rs | 34 ++++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index c75acbe2f..a949a93df 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -127 +128 diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 31798656b..ee51acbd7 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -1635,14 +1635,14 @@ pub const CATALOG: &[Msg] = &[ key: "vocab.kicker", text: row("词典", "VOCABULARY", "DICTIONARY", "辞書", "사전"), }, - Msg { + Msg { key: "vocab.learned_section", text: row( - "自动收集({})", - "自動收集({})", - "Auto-collected ({})", - "自動収集({})", - "자동 수집 ({})", + "确认收集({})", + "確認收集({})", + "Confirmed ({})", + "確認して追加({})", + "확인 후 수집 ({})", ), }, Msg { @@ -2263,12 +2263,12 @@ pub const CATALOG: &[Msg] = &[ "단축키 및 선택", ), }, - Msg { + Msg { key: "settings.selection_workspace.hint", text: row( - "选中文字后按同一快捷键:关闭语音编辑时直接润色;开启后口述指令,说完再选择「提问」或「编辑选区」。", + "有选区时按同一快捷键:关闭语音编辑则润色;开启后口述指令,可提问或编辑选区。无选区但光标在文本框时:可「帮我写」成稿或提问。", "選中文字後按同一快捷鍵:關閉語音編輯時直接潤色;開啟後口述指令,說完再選擇「提問」或「編輯選區」。", - "Select text, then use one shortcut: polish when voice edit is off; hold and speak when voice edit is on, then choose Ask or Edit.", + "With a selection, one shortcut polishes (voice edit off) or takes a spoken instruction to ask or edit. With no selection but focus in a text field, speak to Help me write a draft or ask a question.", "テキスト選択後、同じショートカットで:音声編集オフ時は推敲、オン時は押しながら話してから「質問」か「編集」を選択。", "텍스트 선택 후 같은 단축키: 음성 편집 끄면 바로 다듬기, 켜면 누른 채 말한 뒤 「질문」 또는 「편집」 선택.", ), @@ -2359,9 +2359,9 @@ pub const CATALOG: &[Msg] = &[ key: "vocab.filter_all", text: row("所有", "所有", "All", "すべて", "전체"), }, - Msg { + Msg { key: "vocab.filter_auto", - text: row("自动添加", "自動新增", "Auto-Added", "自動追加", "자동 추가"), + text: row("确认收集", "確認收集", "Confirmed", "確認して追加", "확인 후 수집"), }, Msg { key: "vocab.filter_manual", @@ -3986,14 +3986,14 @@ pub const CATALOG: &[Msg] = &[ "현재 버전, 업데이트 채널 및 자동 업데이트 설정을 확인합니다.", ), }, - Msg { + Msg { key: "modal.descriptions.advanced", text: row( - "按需配置 Less Computer、多模态与调试功能。", - "按需設定 Less Computer、多模態與除錯功能。", - "Configure Less Computer, multimodal processing and debugging as needed.", - "必要に応じて Less Computer、マルチモーダル処理、デバッグを設定します。", - "필요에 따라 Less Computer, 멀티모달 처리 및 디버깅을 설정합니다.", + "按需配置 Less Computer 与调试功能。", + "按需設定 Less Computer 與除錯功能。", + "Configure Less Computer and debugging as needed.", + "必要に応じて Less Computer とデバッグを設定します。", + "필요에 따라 Less Computer와 디버깅을 설정합니다.", ), }, Msg { From bafc10d1019aa7ab69c12d2c2da24a51cff4b9ec Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Thu, 1 Oct 2026 00:26:53 +0800 Subject: [PATCH 35/35] =?UTF-8?q?feat(linux-egui):=20=E5=AF=B9=E9=BD=90=20?= =?UTF-8?q?Beta.4=20=E7=9A=84=E7=A1=AE=E8=AE=A4=E5=BC=8F=E8=AF=8D=E5=BA=93?= =?UTF-8?q?=E5=AD=A6=E4=B9=A0=EF=BC=88=E8=83=B6=E5=9B=8A=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E3=80=8C=E8=A6=81=E8=AE=B0=E4=BD=8F=E8=BF=99=E4=B8=AA=E8=AF=8D?= =?UTF-8?q?=E5=90=97=E3=80=8D=E5=8D=A1=E7=89=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游 Beta.4 把学词改成了「确认收集」:手改产生的候选只进 Core 的待确认队列,用户点 「记住」才落库。Linux egui 侧完全没有这个确认入口 —— 事件被忽略、面板也不画,结果是 **自动学词永远落不了库**(候选到期自己消失),词库页的「确认收集」分段因此永远是空的。 按 Tauri `VocabSuggestionCard` 补齐: - 协议:`HostToPopup::Capsule` 增 `suggestions`(`CapsuleSuggestion{id,pattern, replacement}`,`serde(default)` 兼容旧宿主);新增 `PopupToHost::VocabSuggestion{id, accept}` 承载一次「记住 / 不用」。 - 胶囊:有候选时整窗只画卡片 —— 标题「要记住这个词吗?」+ 每行「改前 → 改后」+ 两颗 28×28 圆钮(Check/Close,与胶囊确认/取消同一套画法),几何抽成 `vocab_card_layout` 纯函数(行多到装不下时压行高并缩按钮,保证 5 条上限内不重叠、不出舞台)。 - 宿主:`VocabularySuggestionsChanged` → 重算并顶卡;卡片只在 `vocab_card_allowed`(没有 会话)时出现 —— 卡片与录音胶囊共用一个窗口,会话在飞时必须让路(照 Tauri 那道闸); 会话一开始就丢掉候选;过期候选每秒重算自己消失(Core 只按需清理、不发事件)。 - i18n:`vocabCard.title/accept/reject` 三键五语,值与 Tauri 目录逐字一致。 测试:卡片渲染 + 点中「记住/不用」各自带回对应候选 id、5 条满卡不重叠不出界、候选 经协议落地且旧宿主无 `suggestions` 仍可解析、闸门只放 Idle。 package revision 129。 --- openless-all/app/linux-egui/package-revision | 2 +- .../app/linux-egui/src/dictation_feedback.rs | 34 ++ openless-all/app/linux-egui/src/i18n.rs | 18 + openless-all/app/linux-egui/src/lib.rs | 10 +- openless-all/app/linux-egui/src/main.rs | 186 +++++++++ openless-all/app/linux-egui/src/popup.rs | 80 ++++ .../app/linux-egui/src/ui/frontend/popups.rs | 366 ++++++++++++++++++ 7 files changed, 690 insertions(+), 6 deletions(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index a949a93df..b0d73241c 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -128 +129 diff --git a/openless-all/app/linux-egui/src/dictation_feedback.rs b/openless-all/app/linux-egui/src/dictation_feedback.rs index 2500dee48..24ab63ee4 100644 --- a/openless-all/app/linux-egui/src/dictation_feedback.rs +++ b/openless-all/app/linux-egui/src/dictation_feedback.rs @@ -124,6 +124,15 @@ pub fn capsule_needs_fallback_dismissal( Some(capsule_session.to_string()) } +/// 「要记住这个词吗?」卡片能不能弹。 +/// +/// Tauri `show_vocab_suggestion_card` 的最后一道闸:卡片与录音胶囊**共用同一个 +/// 窗口**,会话在飞时弹出来会把这次会话的胶囊顶掉(尺寸、位置、输入区都不同), +/// 所以只允许在没有会话(Idle)时显示。 +pub fn vocab_card_allowed(phase: Option) -> bool { + matches!(phase, None | Some(DictationPhase::Idle)) +} + /// 这个相位是否需要胶囊在屏幕上:只有进行中的相位才该按需拉起弹窗。 /// /// 终态不再拉起——否则一个迟到的终态事件会把刚刚自动收起的药丸又喊回来; @@ -166,6 +175,31 @@ pub fn normalize_stop_result( } } +#[cfg(test)] +mod vocab_card_tests { + use super::vocab_card_allowed; + use openless_core::DictationPhase; + + /// 卡片只在没有会话时出现:卡片与录音胶囊共用一个窗口。 + #[test] + fn the_vocab_card_only_shows_without_a_dictation_session() { + assert!(vocab_card_allowed(None)); + assert!(vocab_card_allowed(Some(DictationPhase::Idle))); + for phase in [ + DictationPhase::Starting, + DictationPhase::Recording, + DictationPhase::Transcribing, + DictationPhase::Polishing, + DictationPhase::Inserting, + ] { + assert!( + !vocab_card_allowed(Some(phase)), + "{phase:?} is a live session and must keep the capsule" + ); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index ee51acbd7..c2f2839af 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -1634,6 +1634,24 @@ pub const CATALOG: &[Msg] = &[ Msg { key: "vocab.kicker", text: row("词典", "VOCABULARY", "DICTIONARY", "辞書", "사전"), + }, + Msg { + key: "vocabCard.title", + text: row( + "要记住这个词吗?", + "要記住這個詞嗎?", + "Remember this word?", + "この語を覚えますか?", + "이 단어를 기억할까요?", + ), + }, + Msg { + key: "vocabCard.accept", + text: row("记住", "記住", "Remember", "覚える", "기억하기"), + }, + Msg { + key: "vocabCard.reject", + text: row("不用", "不用", "Skip", "不要", "건너뛰기"), }, Msg { key: "vocab.learned_section", diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index f77c0d02a..2dcb0b555 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -56,7 +56,7 @@ pub use desktop::{ pub use dictation_feedback::{ capsule_hide_delay, capsule_hide_is_still_current, capsule_needs_fallback_dismissal, capsule_outcome, is_backend_error_code, is_expected_stop_error, normalize_stop_result, - phase_shows_capsule, CapsuleOutcome, CAPSULE_AUTO_HIDE_DELAY_MS, + phase_shows_capsule, vocab_card_allowed, CapsuleOutcome, CAPSULE_AUTO_HIDE_DELAY_MS, }; #[cfg(target_os = "linux")] pub use fcitx5::prepare_fcitx5; @@ -91,10 +91,10 @@ pub use local_hotkeys::{ pub use logging::{export_error_log, init_file_logger, log_path}; pub use popup::{ force_x11_for, popup_command, read_jsonl, run_popup, write_jsonl, - ApplyOutcome as PopupApplyOutcome, CapsulePopupState, HostToPopup, LessComputerApproval, - LessComputerEntry, LessComputerPopupState, PopupActionGuard, PopupChatMessage, PopupKind, - PopupSendError, PopupState, PopupSupervisor, PopupSupervisorEvent, PopupToHost, - ProtocolError as PopupProtocolError, ProtocolErrorKind as PopupProtocolErrorKind, + ApplyOutcome as PopupApplyOutcome, CapsulePopupState, CapsuleSuggestion, HostToPopup, + LessComputerApproval, LessComputerEntry, LessComputerPopupState, PopupActionGuard, + PopupChatMessage, PopupKind, PopupSendError, PopupState, PopupSupervisor, PopupSupervisorEvent, + PopupToHost, ProtocolError as PopupProtocolError, ProtocolErrorKind as PopupProtocolErrorKind, QaPolishState, QaPopupState, MAX_JSONL_LINE_BYTES, POPUP_PROTOCOL_VERSION, }; pub use popup_layer::{ diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index b13202064..4d8710a17 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -583,6 +583,11 @@ mod linux_app { capsule_session: Option, /// 已经为哪个会话排过收起计时,避免重复计时。 capsule_dismissal_scheduled: Option, + /// 「要记住这个词吗?」卡片当前内容(Core `pending_corrections`)。非空时 + /// 胶囊窗只画这张卡;见 `refresh_vocab_suggestions`。 + vocab_suggestions: Vec, + /// 卡片上一轮的重算时刻,用来让过期的候选自己消失(Core 只按需清理)。 + vocab_suggestions_checked: std::time::Instant, tray: Option, exit_requested: bool, /// 上次打「泵心跳」日志的时间。 @@ -755,6 +760,8 @@ mod linux_app { popup_action_guard: PopupActionGuard::default(), capsule_session: None, capsule_dismissal_scheduled: None, + vocab_suggestions: Vec::new(), + vocab_suggestions_checked: std::time::Instant::now(), tray, exit_requested: false, last_pump_heartbeat: std::time::Instant::now(), @@ -856,6 +863,8 @@ mod linux_app { popup_action_guard: PopupActionGuard::default(), capsule_session: None, capsule_dismissal_scheduled: None, + vocab_suggestions: Vec::new(), + vocab_suggestions_checked: std::time::Instant::now(), tray, exit_requested: false, last_pump_heartbeat: std::time::Instant::now(), @@ -1303,6 +1312,108 @@ mod linux_app { .to_string() } + /// Core 的待确认纠正变了:没有会话时把卡片顶到胶囊窗上,有会话就丢掉候选。 + /// + /// Tauri `show_vocab_suggestion_card` / `hide_vocab_suggestion_card`:卡片与 + /// 录音胶囊**共用同一个窗口**,所以「会话在飞」时必须让路,否则卡片的尺寸/位置 + /// 会把这次会话的胶囊顶掉。 + fn refresh_vocab_suggestions(&mut self) { + let Some(backend) = self.backend() else { + return; + }; + self.vocab_suggestions_checked = std::time::Instant::now(); + let pending = backend.pending_corrections(); + let phase = self + .snapshot + .as_ref() + .map(|snapshot| snapshot.dictation.phase); + if !openless_linux_egui::vocab_card_allowed(phase) { + if !pending.is_empty() { + backend.dismiss_pending_corrections(); + } + self.vocab_suggestions.clear(); + self.hide_vocab_card(); + return; + } + self.vocab_suggestions = pending + .iter() + .map(|item| openless_linux_egui::CapsuleSuggestion { + id: item.id.clone(), + pattern: item.pattern.clone(), + replacement: item.replacement.clone(), + }) + .collect(); + if self.vocab_suggestions.is_empty() { + self.hide_vocab_card(); + } else { + self.show_vocab_card(); + } + } + + /// 丢弃当前候选(新会话开始 / 胶囊开关关掉时)。 + fn dismiss_vocab_suggestions(&mut self) { + if self.vocab_suggestions.is_empty() { + return; + } + if let Some(backend) = self.backend() { + backend.dismiss_pending_corrections(); + } + self.vocab_suggestions.clear(); + } + + /// 把卡片画进胶囊窗。会话 id 留空:卡片与听写会话无关,因此**不能**走 + /// `capsule_session`/兜底收起那条路(那会把卡片当成「会话消失了」收掉)。 + fn show_vocab_card(&mut self) { + if !self.capsule_enabled() { + self.dismiss_vocab_suggestions(); + return; + } + self.ensure_popup(PopupKind::Capsule); + self.send_popup( + PopupKind::Capsule, + HostToPopup::Capsule { + version: POPUP_PROTOCOL_VERSION, + session_id: String::new(), + sequence: self.last_event_sequence.saturating_mul(2), + phase: String::new(), + text: String::new(), + audio_level: None, + translation_active: false, + style: self.capsule_style_tag(), + suggestions: self.vocab_suggestions.clone(), + }, + ); + // `send_popup` 会把胶囊会话记成空串,兜底收起会误判成「会话消失」。 + self.capsule_session = None; + } + + fn hide_vocab_card(&mut self) { + if self.vocab_suggestions.is_empty() && self.capsule_session.is_none() { + return; + } + self.send_popup( + PopupKind::Capsule, + HostToPopup::Hide { + version: POPUP_PROTOCOL_VERSION, + session_id: String::new(), + sequence: 0, + }, + ); + self.capsule_session = None; + } + + /// 卡片上有 Core 给的死线(`PendingCorrection::expires_at_ms`),Core 只按需 + /// 清理过期项、不发事件;这里按秒重算,过期的行自己消失。 + fn tick_vocab_suggestions(&mut self) { + if self.vocab_suggestions.is_empty() { + return; + } + if self.vocab_suggestions_checked.elapsed() < std::time::Duration::from_millis(500) { + return; + } + self.refresh_vocab_suggestions(); + } + fn show_capsule_popup(&mut self) { if !self.capsule_enabled() { return; @@ -1341,6 +1452,7 @@ mod linux_app { audio_level: Some(snapshot.level), translation_active: snapshot.translation_active, style, + suggestions: self.vocab_suggestions.clone(), }, ); self.schedule_capsule_dismissal(&session_id.to_string(), snapshot.phase); @@ -1368,6 +1480,7 @@ mod linux_app { audio_level: None, translation_active: false, style: self.capsule_style_tag(), + suggestions: self.vocab_suggestions.clone(), }, ); self.schedule_capsule_dismissal(session_id, DictationPhase::Failed); @@ -1630,6 +1743,28 @@ mod linux_app { self.pending_local_hotkeys .push((std::time::Instant::now(), edge)); } + PopupSupervisorEvent::Message(PopupToHost::VocabSuggestion { + id, + accept, + .. + }) => { + // 点一下就是一次词库写入(同步 IO),放到后台线程,别卡这一帧。 + if let Some(backend) = self.backend() { + let result = if accept { + backend + .accept_pending_correction(&id) + .map(|_| String::new()) + .map_err(|error| error.to_string()) + } else { + backend.reject_pending_correction(&id); + Ok(String::new()) + }; + if let Err(error) = result { + log::warn!("[vocab-card] resolve failed: {error}"); + } + self.refresh_vocab_suggestions(); + } + } PopupSupervisorEvent::Message(PopupToHost::DismissCapsule { .. }) => { if let Some(snapshot) = self.snapshot.as_mut() { snapshot.dictation.message = None; @@ -2374,6 +2509,8 @@ mod linux_app { let was_recording = self.recording_phase_active; self.recording_phase_active = state.phase == DictationPhase::Recording; if state.phase == DictationPhase::Recording && !was_recording { + // 卡片与录音胶囊共用一个窗口:会话一起来就得让路。 + self.dismiss_vocab_suggestions(); self.play_record_cue(true); } else if !self.recording_phase_active && was_recording { self.play_record_cue(false); @@ -2423,6 +2560,7 @@ mod linux_app { audio_level: Some(state.level), translation_active: state.translation_active, style, + suggestions: self.vocab_suggestions.clone(), }, ); // 终态:按 Tauri 时序安排自动收起,否则药丸会一直贴在屏幕上。 @@ -2582,6 +2720,11 @@ mod linux_app { } } } + BackendEventKind::VocabularySuggestionsChanged(_) => { + // Core 只负责攒候选;「要不要记住」的确认入口是胶囊窗上的卡片 + // (Tauri `VocabSuggestionCard`)。没有这张卡,自动学词永远落不了库。 + self.refresh_vocab_suggestions(); + } BackendEventKind::HistoryChanged(change) => { self.history.observe(change.revision); self.load_overview(); @@ -2729,6 +2872,7 @@ mod linux_app { /// 停了就说明热键消费与弹窗拉起也没在跑(这正是当初「关窗后热键失效」 /// 的判据),用户/支持可以直接看日志确认。 fn log_pump_heartbeat(&mut self, ctx: &egui::Context) { + self.tick_vocab_suggestions(); if self.last_pump_heartbeat.elapsed() < std::time::Duration::from_secs(10) { return; } @@ -7193,6 +7337,29 @@ focus_was_stolen={} focus_restored={} warnings={:?}", }); match action { frontend::popups::CapsuleAction::None => {} + frontend::popups::CapsuleAction::AcceptSuggestion(id) => { + // 卡片与听写会话无关:id 才是要处理的候选。 + let sequence = self.next_sequence(); + let session_id = self.session_id().unwrap_or_default(); + self.send(PopupToHost::VocabSuggestion { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + id, + accept: true, + }); + } + frontend::popups::CapsuleAction::RejectSuggestion(id) => { + let sequence = self.next_sequence(); + let session_id = self.session_id().unwrap_or_default(); + self.send(PopupToHost::VocabSuggestion { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + id, + accept: false, + }); + } frontend::popups::CapsuleAction::Cancel | frontend::popups::CapsuleAction::Confirm => { if let Some(session_id) = self.session_id() { @@ -7443,6 +7610,24 @@ focus_was_stolen={} focus_restored={} warnings={:?}", sequence: 0, }) } + frontend::popups::CapsuleAction::AcceptSuggestion(id) => { + Some(PopupToHost::VocabSuggestion { + version: POPUP_PROTOCOL_VERSION, + session_id: String::new(), + sequence: 0, + id, + accept: true, + }) + } + frontend::popups::CapsuleAction::RejectSuggestion(id) => { + Some(PopupToHost::VocabSuggestion { + version: POPUP_PROTOCOL_VERSION, + session_id: String::new(), + sequence: 0, + id, + accept: false, + }) + } frontend::popups::CapsuleAction::Confirm => { Some(PopupToHost::StopDictation { version: POPUP_PROTOCOL_VERSION, @@ -8524,6 +8709,7 @@ Internal flags (set by OpenLess itself, not for regular use): audio_level: Some(0.2), translation_active: false, style: "siri".into(), + suggestions: Vec::new(), }) .expect("channel is open"); assert!(!app.pump(None), "a progress frame must not exit"); diff --git a/openless-all/app/linux-egui/src/popup.rs b/openless-all/app/linux-egui/src/popup.rs index 22f71097c..9096f8896 100644 --- a/openless-all/app/linux-egui/src/popup.rs +++ b/openless-all/app/linux-egui/src/popup.rs @@ -129,6 +129,11 @@ pub enum HostToPopup { /// 胶囊样式:`siri` / `classic` / `typeless`(Tauri `capsuleStyle`)。 #[serde(default)] style: String, + /// 「要记住这个词吗?」卡片:非空时胶囊窗改画确认卡(Tauri + /// `VocabSuggestionCard`)。音频会话已经结束时也会发(phase 为空), + /// 因为这张卡只在**没有**会话时出现。 + #[serde(default)] + suggestions: Vec, }, Hide { version: u16, @@ -244,6 +249,15 @@ pub enum PopupToHost { sequence: u64, text: String, }, + /// 卡片上的一次「记住 / 不用」(Tauri `acceptPendingCorrection` / + /// `rejectPendingCorrection`)。`id` 是 Core 给的候选 id。 + VocabSuggestion { + version: u16, + session_id: String, + sequence: u64, + id: String, + accept: bool, + }, /// 取消本次润色(由选区助手面板发出)。 CancelPolish { version: u16, @@ -349,6 +363,7 @@ impl PopupToHost { | Self::ToggleQaRecording { version, .. } | Self::DismissQa { version, .. } | Self::DismissCapsule { version, .. } + | Self::VocabSuggestion { version, .. } | Self::CancelDictation { version, .. } | Self::StopDictation { version, .. } | Self::SetPinned { version, .. } @@ -372,6 +387,7 @@ impl PopupToHost { | Self::ToggleQaRecording { session_id, .. } | Self::DismissQa { session_id, .. } | Self::DismissCapsule { session_id, .. } + | Self::VocabSuggestion { session_id, .. } | Self::CancelDictation { session_id, .. } | Self::StopDictation { session_id, .. } | Self::SetPinned { session_id, .. } @@ -395,6 +411,7 @@ impl PopupToHost { | Self::ToggleQaRecording { sequence, .. } | Self::DismissQa { sequence, .. } | Self::DismissCapsule { sequence, .. } + | Self::VocabSuggestion { sequence, .. } | Self::CancelDictation { sequence, .. } | Self::StopDictation { sequence, .. } | Self::SetPinned { sequence, .. } @@ -423,6 +440,7 @@ impl PopupToHost { | Self::ApplyEdit { .. } | Self::RevertEdit { .. } => PopupKind::Qa, Self::DismissCapsule { .. } + | Self::VocabSuggestion { .. } | Self::CancelDictation { .. } | Self::StopDictation { .. } => PopupKind::Capsule, Self::SubmitLessComputer { .. } @@ -673,6 +691,20 @@ pub struct CapsulePopupState { pub translation_active: bool, /// 胶囊样式(`siri` / `classic` / `typeless`)。 pub style: String, + /// 「要记住这个词吗?」卡片的内容(Tauri `PendingCorrection`)。非空时整窗只画 + /// 这张卡 —— 它就是「确认式词库学习」的确认入口,没有它自动学词永远不会落库。 + pub suggestions: Vec, +} + +/// 卡片上的一行:用户手改出来的「错误写法 → 想要的写法」(Core +/// `PendingCorrection`,只在这里展示,不落库;接受才写进词库)。 +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CapsuleSuggestion { + pub id: String, + /// 手改前的写法,只用于让用户看清改了什么。 + pub pattern: String, + /// 用户最终想要的写法 —— 点「记住」后进词库的是它。 + pub replacement: String, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -781,6 +813,7 @@ impl PopupState { audio_level, translation_active, style, + suggestions, .. } => { self.capsule = CapsulePopupState { @@ -789,6 +822,7 @@ impl PopupState { audio_level, translation_active, style, + suggestions, }; self.visible = true; } @@ -1443,6 +1477,51 @@ mod tests { assert!(state.qa.viewer_login.is_empty()); } + #[test] + fn capsule_carries_the_vocab_suggestion_card() { + let message = HostToPopup::Capsule { + version: POPUP_PROTOCOL_VERSION, + session_id: String::new(), + sequence: 5, + phase: String::new(), + text: String::new(), + audio_level: None, + translation_active: false, + style: "siri".to_owned(), + suggestions: vec![CapsuleSuggestion { + id: "c1".to_owned(), + pattern: "banana".to_owned(), + replacement: "bananas".to_owned(), + }], + }; + let mut state = PopupState::default(); + assert_eq!(state.apply(message), ApplyOutcome::Applied); + assert_eq!(state.capsule.suggestions.len(), 1); + assert_eq!(state.capsule.suggestions[0].replacement, "bananas"); + // 卡片与听写会话无关:消息不带会话 id 也要能落地。 + assert!(state.capsule.phase.is_empty()); + // 决策消息归胶囊窗,宿主按它回写词库。 + let decision = PopupToHost::VocabSuggestion { + version: POPUP_PROTOCOL_VERSION, + session_id: String::new(), + sequence: 0, + id: "c1".to_owned(), + accept: true, + }; + assert_eq!(decision.kind(), PopupKind::Capsule); + assert_eq!(decision.version(), POPUP_PROTOCOL_VERSION); + } + + /// 老宿主(协议 v2)不带 `suggestions` 时按空卡片解析,不能报错。 + #[test] + fn a_capsule_without_suggestions_still_parses() { + let legacy = r#"{"type":"capsule","version":3,"session_id":"s","sequence":1,"phase":"Recording"}"#; + let message: HostToPopup = serde_json::from_str(legacy).expect("legacy capsule"); + let mut state = PopupState::default(); + assert_eq!(state.apply(message), ApplyOutcome::Applied); + assert!(state.capsule.suggestions.is_empty()); + } + #[test] fn capsule_carries_translation_active_and_style() { let message = HostToPopup::Capsule { @@ -1454,6 +1533,7 @@ mod tests { audio_level: Some(0.5), translation_active: true, style: "typeless".to_owned(), + suggestions: Vec::new(), }; let mut state = PopupState::default(); assert_eq!(state.apply(message), ApplyOutcome::Applied); diff --git a/openless-all/app/linux-egui/src/ui/frontend/popups.rs b/openless-all/app/linux-egui/src/ui/frontend/popups.rs index f2e9f427b..cb9e870d9 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/popups.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/popups.rs @@ -62,6 +62,90 @@ pub enum CapsuleAction { Cancel, /// ✓ → the host stops the dictation and inserts. Confirm, + /// 「要记住这个词吗?」卡片上点了「记住」(Tauri `acceptPendingCorrection`)。 + AcceptSuggestion(String), + /// 卡片上点了「不用」(Tauri `rejectPendingCorrection`)。 + RejectSuggestion(String), +} + +/// 「要记住这个词吗?」卡片的尺寸,与 Tauri `VocabSuggestionCard` 一致: +/// 宽 320、标题区 72、每行 36(`VOCAB_CARD_*`)。 +const CARD_WIDTH: f32 = 320.0; +const CARD_CHROME: f32 = 72.0; +const CARD_ROW: f32 = 36.0; +/// 卡片到窗口边缘的留白(Tauri `VOCAB_CARD_EDGE_MARGIN`)。 +const CARD_EDGE: f32 = 12.0; +/// 行数多到装不下时标题区至少留这么高,剩下的分给行 —— Tauri 会改窗口大小,我们这个 +/// 舞台是固定的 460×180,所以宁可压行高,也不让最后一行露在窗外。 +const CARD_CHROME_MIN: f32 = 44.0; +const CARD_BUTTON: f32 = 28.0; +/// 标题占的高度(画的时候用固定值,几何才能是纯函数、测试才点得中按钮)。 +const CARD_TITLE_BLOCK: f32 = 24.0; + +/// 卡片几何:一列行 + 每行右侧「不用 / 记住」两颗圆钮。 +pub(crate) struct VocabCardLayout { + pub card: egui::Rect, + pub rows: Vec, +} + +pub(crate) struct VocabCardRow { + pub text: egui::Rect, + pub accept: egui::Rect, + pub reject: egui::Rect, +} + +/// 按 Tauri `VocabSuggestionCard` 的尺寸算卡片几何。 +/// +/// 与 Tauri 的唯一差别:Tauri 会把窗口改成卡片大小(320 × 72+36n)并挪到屏幕右下角, +/// 我们的胶囊舞台是固定的 460×180,所以行数多时压缩行高,并把卡片靠右放。 +pub(crate) fn vocab_card_layout(stage: egui::Rect, rows: usize) -> VocabCardLayout { + let count = rows.max(1) as f32; + let max_height = (stage.height() - 2.0 * CARD_EDGE).max(CARD_CHROME_MIN); + let mut row_height = CARD_ROW; + let mut height = CARD_CHROME + CARD_ROW * count; + if height > max_height { + row_height = ((max_height - CARD_CHROME_MIN) / count).max(18.0); + height = (CARD_CHROME_MIN + row_height * count).min(max_height); + } + let width = CARD_WIDTH.min(stage.width() - 2.0 * CARD_EDGE).max(1.0); + let card = egui::Rect::from_min_size( + egui::pos2( + stage.right() - CARD_EDGE - width, + stage.bottom() - CARD_EDGE - height, + ), + egui::vec2(width, height), + ); + let inner = card.shrink(12.0); + let top = inner.top() + CARD_TITLE_BLOCK; + let row_height = row_height.min(((inner.bottom() - top) / count).max(1.0)); + // 行被压缩时按钮必须跟着缩:Core 允许一张卡 5 条,硬塞 28px 圆钮会让相邻两行叠在一起。 + let button = CARD_BUTTON.min((row_height - 2.0).max(14.0)); + let rows = (0..rows) + .map(|index| { + let row = egui::Rect::from_min_size( + egui::pos2(inner.left(), top + row_height * index as f32), + egui::vec2(inner.width(), row_height), + ); + // 两颗 28 宽圆钮 + 8 间距(Tauri 的 `CardButton` 就是胶囊确认/取消那一对)。 + let accept = egui::Rect::from_center_size( + egui::pos2(row.right() - button / 2.0, row.center().y), + egui::vec2(button, button), + ); + let reject = egui::Rect::from_center_size( + egui::pos2(accept.left() - 8.0 - button / 2.0, row.center().y), + egui::vec2(button, button), + ); + VocabCardRow { + text: egui::Rect::from_min_max( + row.min, + egui::pos2(reject.left() - 8.0, row.bottom()), + ), + accept, + reject, + } + }) + .collect(); + VocabCardLayout { card, rows } } /// Tauri `selection-polish-preview` 面板的边距(`padding: 18`)。 @@ -1050,6 +1134,15 @@ pub fn dictation_capsule( ) -> CapsuleAction { let mut action = CapsuleAction::None; let phase = state.phase.to_ascii_lowercase(); + // 「要记住这个词吗?」卡片与录音胶囊共用一个窗口:有候选时整窗只画这张卡。 + if !state.suggestions.is_empty() { + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(root_ui, |ui| { + action = capsule_suggestion_card(ui, state, lang); + }); + return action; + } egui::CentralPanel::default() .frame(egui::Frame::NONE) .show(root_ui, |ui| { @@ -1491,6 +1584,124 @@ fn paint_thinking_label( } /// 圆形按钮(classic 为 28×28;Typeless 按 Tauri 的 zoom 比例缩放)。 +/// 确认式词库学习的确认入口(Tauri `VocabSuggestionCard`)。 +/// +/// 用户手改一个词之后 Core 把它攒成候选(`pending_corrections`):「自动收集」在真机 +/// 上大约五条错四条,所以每一条都要人过一眼 —— 接受写进词库(词库页的「确认收集」 +/// 分段),拒绝只是丢掉,不留黑名单。 +fn capsule_suggestion_card( + ui: &mut egui::Ui, + state: &CapsulePopupState, + lang: Lang, +) -> CapsuleAction { + let mut action = CapsuleAction::None; + let stage = ui.max_rect(); + let layout = vocab_card_layout(stage, state.suggestions.len()); + let card = layout.card; + let painter = ui.painter().with_clip_rect(card.intersect(stage)); + painter.rect_filled(card, egui::CornerRadius::same(16), theme::SURFACE); + painter.rect_stroke( + card, + egui::CornerRadius::same(16), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); + let inner = card.shrink(12.0); + let title = layout::text_galley( + ui, + tr_l10n(lang, "vocabCard.title"), + theme::INK_3, + 11.0, + inner.width(), + 1, + ); + painter.galley(inner.min, title, theme::INK_3); + for (index, suggestion) in state.suggestions.iter().enumerate() { + let Some(row) = layout.rows.get(index) else { + break; + }; + let text_rect = row.text; + let (reject_rect, accept_rect) = (row.reject, row.accept); + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = text_rect.width(); + job.wrap.max_rows = 1; + let mono = egui::FontId::new(12.5, egui::FontFamily::Monospace); + // 改前(暗)→ 改后(亮):一眼能看出这次手改把什么改成了什么。 + job.append( + &suggestion.pattern, + 0.0, + egui::text::TextFormat { + font_id: mono.clone(), + color: theme::INK_4, + ..Default::default() + }, + ); + job.append( + " \u{2192} ", + 0.0, + egui::text::TextFormat { + font_id: mono.clone(), + color: theme::INK_4, + ..Default::default() + }, + ); + job.append( + &suggestion.replacement, + 0.0, + egui::text::TextFormat { + font_id: mono, + color: theme::INK, + ..Default::default() + }, + ); + let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); + painter.with_clip_rect(text_rect).galley( + egui::pos2( + text_rect.left(), + text_rect.center().y - galley.rect.height() / 2.0, + ), + galley, + theme::INK, + ); + let reject = ui.interact( + reject_rect, + ui.id().with(("openless-vocab-reject", index)), + egui::Sense::click(), + ); + let icon = (reject_rect.width() * 0.46).clamp(9.0, 13.0); + round_button( + ui, + reject_rect, + icons::IconName::Close, + reject.hovered(), + theme::SURFACE_2, + theme::INK_2, + (0.8, icon), + ); + if reject.clicked() { + action = CapsuleAction::RejectSuggestion(suggestion.id.clone()); + } + let accept = ui.interact( + accept_rect, + ui.id().with(("openless-vocab-accept", index)), + egui::Sense::click(), + ); + round_button( + ui, + accept_rect, + icons::IconName::Check, + accept.hovered(), + theme::BLUE_SOFT, + theme::BLUE, + (0.8, icon), + ); + if accept.clicked() { + action = CapsuleAction::AcceptSuggestion(suggestion.id.clone()); + } + } + action +} + fn round_button( ui: &egui::Ui, rect: egui::Rect, @@ -2161,6 +2372,145 @@ mod tests { painted } + /// 渲染胶囊并把结果动作交出来;`click` 给一个坐标时会合成一次点击(按下+抬起 + /// 同一帧,和真实点击等价)。 + fn run_capsule( + state: &CapsulePopupState, + size: egui::Vec2, + click: Option, + ) -> (CapsuleAction, String) { + let ctx = egui::Context::default(); + let mut action = CapsuleAction::None; + let mut painted = String::new(); + for frame in 0..2 { + let mut events = Vec::new(); + if frame == 1 { + if let Some(pos) = click { + for pressed in [true, false] { + events.push(egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + } + } + } + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + events, + ..Default::default() + }, + |ui| { + action = dictation_capsule(ui, state, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); + }, + ); + painted = painted_text(&output); + } + (action, painted) + } + + /// 「要记住这个词吗?」卡片:有候选时整窗只画卡片(没有药丸),点「记住」把候选 id + /// 发回宿主,点「不用」发拒绝 —— 这就是确认式词库学习的确认入口(Tauri + /// `VocabSuggestionCard` + `PendingCorrection`)。 + #[test] + fn the_vocab_card_asks_before_remembering() { + let state = CapsulePopupState { + phase: String::new(), + suggestions: vec![ + openless_linux_egui::CapsuleSuggestion { + id: "first".to_string(), + pattern: "banana".to_string(), + replacement: "bananas".to_string(), + }, + openless_linux_egui::CapsuleSuggestion { + id: "second".to_string(), + pattern: "teh".to_string(), + replacement: "the".to_string(), + }, + ], + ..Default::default() + }; + let size = egui::vec2(460.0, 180.0); + let (action, painted) = run_capsule(&state, size, None); + assert!( + matches!(action, CapsuleAction::None), + "rendering alone must not resolve anything" + ); + assert!( + painted.contains(tr_l10n(Lang::ZhCn, "vocabCard.title")), + "the card must ask the question: {painted}" + ); + for text in ["banana", "bananas", "teh", "the"] { + assert!(painted.contains(text), "row text {text} missing: {painted}"); + } + // 每行两颗圆钮都在(图标画的是 Check / Close)。 + assert_eq!( + painted + .matches(tr_l10n(Lang::ZhCn, "vocabCard.title")) + .count(), + 1, + "the title is painted once" + ); + let layout = vocab_card_layout( + egui::Rect::from_min_size(egui::Pos2::ZERO, size), + state.suggestions.len(), + ); + assert_eq!(layout.rows.len(), 2, "one row per suggestion"); + // 两行不重叠:第二行在第一行下面。 + assert!( + layout.rows[0].accept.bottom() <= layout.rows[1].accept.top(), + "rows must not overlap" + ); + let (action, _) = run_capsule(&state, size, Some(layout.rows[0].accept.center())); + assert!( + matches!(&action, CapsuleAction::AcceptSuggestion(id) if id == "first"), + "accepting the first row must carry its id, got {action:?}" + ); + let (action, _) = run_capsule(&state, size, Some(layout.rows[1].reject.center())); + assert!( + matches!(&action, CapsuleAction::RejectSuggestion(id) if id == "second"), + "rejecting the second row must carry its id, got {action:?}" + ); + } + + /// 行数拉满(Core 上限 5 条)时卡片仍然整张落在舞台里,不露在窗外。 + #[test] + fn a_full_vocab_card_still_fits_the_capsule_stage() { + let stage = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 180.0)); + let layout = vocab_card_layout(stage, 5); + assert_eq!(layout.rows.len(), 5, "every candidate gets a row"); + assert!( + stage.contains_rect(layout.card), + "the card must stay inside the stage: {:?}", + layout.card + ); + for (index, row) in layout.rows.iter().enumerate() { + assert!( + layout.card.contains_rect(row.accept) && layout.card.contains_rect(row.reject), + "row {index} buttons must stay inside the card" + ); + assert!( + row.text.right() <= row.reject.left(), + "row {index} text must not overlap the buttons" + ); + } + assert!( + layout + .rows + .windows(2) + .all(|pair| pair[0].accept.bottom() <= pair[1].accept.top()), + "rows must not overlap at the cap" + ); + // 压缩后按钮也不能叠上去。 + assert!( + layout.rows.iter().all(|row| row.accept.height() >= 14.0), + "buttons must stay tappable when the rows are compressed" + ); + } + /// 与 `run` 同款流程,但把最后一帧的 `FullOutput` 交出来(要按形状断言时用)。 fn run_output( size: egui::Vec2, @@ -2478,6 +2828,7 @@ mod tests { CapsulePopupState { phase: "polishing".into(), style: "classic".into(), + suggestions: Vec::new(), ..Default::default() }, false, @@ -2683,6 +3034,7 @@ mod tests { let state = CapsulePopupState { phase: "Polishing".into(), style: style.into(), + suggestions: Vec::new(), ..Default::default() }; let mut colors = Vec::new(); @@ -2736,6 +3088,7 @@ mod tests { phase: "Failed".to_string(), text: message.to_string(), style: "siri".to_string(), + suggestions: Vec::new(), ..Default::default() }; let painted = run(egui::vec2(460.0, 180.0), |ctx| { @@ -2773,6 +3126,7 @@ mod tests { phase: phase.into(), audio_level: Some(0.4), style: "siri".into(), + suggestions: Vec::new(), ..Default::default() }; let output = crate::ui::frontend::run_pass( @@ -2877,6 +3231,7 @@ mod tests { audio_level: Some(0.6), translation_active: false, style: "classic".to_string(), + suggestions: Vec::new(), }; let (colors, _, _) = capsule_frame(&state); let reddish: Vec<_> = colors @@ -2901,6 +3256,7 @@ mod tests { audio_level: Some(0.6), translation_active: false, style: "siri".to_string(), + suggestions: Vec::new(), }; let (colors, callbacks, effect) = capsule_frame(&state); // 音量竖条是 3px 宽的小圆角矩形:数一下细长条形的填充个数。 @@ -2933,6 +3289,7 @@ mod tests { phase: "Recording".to_string(), audio_level: Some(0.4), style: "classic".to_string(), + suggestions: Vec::new(), ..Default::default() }; let mut action = CapsuleAction::None; @@ -2981,6 +3338,7 @@ mod tests { let classic = CapsulePopupState { phase: phase.into(), style: "classic".into(), + suggestions: Vec::new(), ..Default::default() }; let painted = run(egui::vec2(200.0, 60.0), |ui| { @@ -2993,6 +3351,7 @@ mod tests { ); let siri = CapsulePopupState { style: "siri".into(), + suggestions: Vec::new(), ..classic.clone() }; let painted = run(egui::vec2(460.0, 180.0), |ui| { @@ -3015,6 +3374,7 @@ mod tests { let state = CapsulePopupState { phase: phase.into(), style: "siri".into(), + suggestions: Vec::new(), ..Default::default() }; let mut output = ctx.run_ui( @@ -3044,6 +3404,7 @@ mod tests { audio_level: Some(0.4), translation_active: false, style: "siri".to_string(), + suggestions: Vec::new(), }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { dictation_capsule(ctx, &recording, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); @@ -3058,6 +3419,7 @@ mod tests { let transcribing = CapsulePopupState { phase: "Transcribing".to_string(), style: "classic".to_string(), + suggestions: Vec::new(), ..Default::default() }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { @@ -3073,6 +3435,7 @@ mod tests { let transcribing_siri = CapsulePopupState { phase: "Transcribing".to_string(), style: "siri".to_string(), + suggestions: Vec::new(), ..Default::default() }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { @@ -3096,6 +3459,7 @@ mod tests { audio_level: None, translation_active: false, style: "siri".to_string(), + suggestions: Vec::new(), }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { dictation_capsule(ctx, &done, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); @@ -3107,6 +3471,7 @@ mod tests { ); let done_classic = CapsulePopupState { style: "classic".to_string(), + suggestions: Vec::new(), ..done.clone() }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { @@ -3121,6 +3486,7 @@ mod tests { audio_level: None, translation_active: false, style: "siri".to_string(), + suggestions: Vec::new(), }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { dictation_capsule(ctx, &failed, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS);