diff --git a/.github/workflows/check-linux-egui.yml b/.github/workflows/check-linux-egui.yml index b592bf312..b9b3e8fc8 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 # CI and independent manual Linux builds share this deb/rpm verification path. # No standalone push trigger: a Linux PR must show one combined Linux check. 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/docs/linux-egui-handoff/07-acceptance.md b/docs/linux-egui-handoff/07-acceptance.md index 205bdc97c..e57363d75 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,50 @@ ## 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 进程被替换。桌面会话(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`) + +| 场景 | 期望 | +| --- | --- | +| 关闭 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`。 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/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index a96c1cbe9..76712612e 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -2522,6 +2522,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/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/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/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 257e56326..b0d73241c 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -102 +129 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() { 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/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()]); 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/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index ec1ec21a2..c2f2839af 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -1120,16 +1120,43 @@ pub const CATALOG: &[Msg] = &[ ), }, Msg { - key: "startup.fcitx_help", + key: "startup.input_method_unavailable_title", 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를 다시 설치하세요. 환경을 수정한 후 이 창을 닫고 앱을 다시 시작하세요。", + "输入法服务不可用", + "輸入法服務無法使用", + "Input method unavailable", + "入力メソッドを利用できません", + "입력기 서비스를 사용할 수 없음", ), }, - Msg { key: "common.saving", text: row("正在保存…", "正在儲存…", "Saving…", "保存中…", "저장 중…") }, + 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", "もっと読み込む", "더 불러오기"), + }, + Msg { + key: "common.loaded_of_total", + text: row( + "已载入 {} / 共 {}", + "已載入 {} / 共 {}", + "Loaded {} of {}", + "{} / {} 件を読み込み済み", + "{} / {}개 불러옴", + ), + }, + 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) @@ -1608,14 +1635,32 @@ pub const CATALOG: &[Msg] = &[ key: "vocab.kicker", text: row("词典", "VOCABULARY", "DICTIONARY", "辞書", "사전"), }, - Msg { + 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", text: row( - "自动收集({})", - "自動收集({})", - "Auto-collected ({})", - "自動収集({})", - "자동 수집 ({})", + "确认收集({})", + "確認收集({})", + "Confirmed ({})", + "確認して追加({})", + "확인 후 수집 ({})", ), }, Msg { @@ -2236,12 +2281,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.", "テキスト選択後、同じショートカットで:音声編集オフ時は推敲、オン時は押しながら話してから「質問」か「編集」を選択。", "텍스트 선택 후 같은 단축키: 음성 편집 끄면 바로 다듬기, 켜면 누른 채 말한 뒤 「질문」 또는 「편집」 선택.", ), @@ -2332,9 +2377,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", @@ -2689,6 +2734,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( @@ -2941,6 +3040,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( @@ -3753,14 +4004,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 { @@ -5692,6 +5943,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/lib.rs b/openless-all/app/linux-egui/src/lib.rs index 889bfeaa8..2dcb0b555 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,12 @@ 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. +#[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}; @@ -49,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; @@ -84,24 +91,24 @@ 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::{ 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, }; @@ -120,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/linux_app/settings_save.rs b/openless-all/app/linux-egui/src/linux_app/settings_save.rs index 3a4f3a2a6..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)] @@ -273,6 +280,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; } @@ -626,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") { @@ -736,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/linux_app/window.rs b/openless-all/app/linux-egui/src/linux_app/window.rs index 8f83f4792..d1e318b48 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, @@ -47,6 +48,34 @@ 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) +} + +/// 第一页的降档阶梯:正常情况下快照只带每个集合的第一页(很小), +/// 只有病态数据(单条超大的转写、巨大的图标 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 慢的时候也不会被无意义的帧糊住。 pub(super) fn snapshot_fingerprint(payload: &[u8]) -> u64 { @@ -206,12 +235,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(), + &FIRST_PAGE_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 +254,48 @@ impl OpenLessEguiApp { } } +/// 序列化视图模型,必要时把五个可增长集合的第一页一起降档,让快照回到 +/// `soft_limit` 以内。 +/// +/// 裁的是宿主持有的这份 VM,下一 tick 的 [`OpenLessEguiApp::sync_view_model`] 会从 +/// 真实缓存重新填满第一页,所以裁掉的内容只是「这一帧不发」;总数仍在,窗口可用 +/// `LoadMore` 按需取回,不是永久丢弃。`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 { + return Some(payload); + } + for keep in ladder.iter().copied() { + 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; + } + log::warn!( + "[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 { + return Some(payload); + } + } + Some(payload) +} + /// UI 窗口进程入口:只渲染。 /// /// 它不构造 Core 后端、不打开数据目录、不抢单实例锁、不注册托盘与热键 —— @@ -231,6 +303,10 @@ impl OpenLessEguiApp { 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 } @@ -267,6 +343,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(), @@ -344,6 +423,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, } @@ -353,6 +434,11 @@ 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` 模块文档。 + // 真正的置位在每帧采纳快照之后(见 `ui()`):宿主快照会把这里覆盖掉。 + view_model.settings_open |= openless_linux_egui::frame_stats::settings_open_on_start(); Self { client, connecting_since: std::time::Instant::now(), @@ -361,7 +447,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, @@ -373,6 +459,21 @@ impl UiClientApp { hotkeys: None, hotkey_matcher: crate::ui::local_hotkeys::LocalHotkeyMatcher::default(), hotkey_sequence: 0, + pending_collection_pages: HashSet::new(), + } + } + + /// 把单槽位里最新的快照搬进视图模型。 + /// + /// 正常情况下一帧只有一份;`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}"); + } } } @@ -396,7 +497,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 }) => { @@ -415,14 +521,33 @@ 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) + ); + } + } + // 快照正常不进通道(见 `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 }) => { @@ -525,6 +650,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, @@ -560,10 +692,16 @@ 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); } + // 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 @@ -653,59 +791,99 @@ 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); +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::frontend::view_model::{FrontendViewModel, HistoryEntry, VocabEntry}; + + /// 一条体量接近真实长转写的假历史(列表最新在前,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() } - 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); + } + + fn view_model_with(entries: usize) -> FrontendViewModel { + FrontendViewModel { + history_entries: (0..entries).map(fake_entry).collect(), + ..Default::default() } - 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; + #[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, &FIRST_PAGE_LADDER).unwrap(); + assert_eq!( + view_model.history_entries.len(), + 4, + "nothing may be dropped" + ); + assert!(!payload.is_empty()); } - 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}"); + + #[test] + fn an_oversized_snapshot_cuts_the_first_pages_but_keeps_the_totals() { + // 软上限只装得下 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_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 cut snapshot must fit the soft limit" + ); + assert_eq!( + view_model.history_entries.len(), + 5, + "the first ladder step that fits wins" + ); + // 总数不动:切掉的条目仍然能通过「加载更多」拉回来。 + 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 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(); + // 只装得下 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.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/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) } } diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 799a50609..4d8710a17 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(..)`,所以逐字段合并。 @@ -397,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, @@ -423,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, @@ -547,7 +552,16 @@ mod linux_app { provider_kind: openless_core::ChannelKind, providers: ProvidersState, selected_channel_id: Option, + /// 用户(或刚建好的草稿)要求打开渠道编辑器。面板加载时只有当它为真才打开: + /// 以前只要面板加载就会为「当前选中的渠道」把编辑器拉起来,改成整栏覆盖后就是 + /// 「一进设置只见模型编辑器、别的设置都改不了」。 + editor_requested: bool, 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, @@ -569,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, /// 上次打「泵心跳」日志的时间。 @@ -591,6 +610,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)>, @@ -618,6 +640,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, @@ -717,7 +741,10 @@ 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, provider_editor_form: None, provider_models: Vec::new(), new_provider_type: String::new(), @@ -733,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(), @@ -744,6 +773,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(), @@ -760,6 +790,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, @@ -813,7 +844,10 @@ 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, provider_editor_form: None, provider_models: Vec::new(), new_provider_type: String::new(), @@ -829,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(), @@ -840,6 +876,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(), @@ -856,6 +893,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, @@ -1274,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; @@ -1312,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); @@ -1339,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); @@ -1601,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; @@ -1901,6 +2065,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) @@ -2190,6 +2355,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) @@ -2212,10 +2378,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. @@ -2316,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); @@ -2365,6 +2560,7 @@ mod linux_app { audio_level: Some(state.level), translation_active: state.translation_active, style, + suggestions: self.vocab_suggestions.clone(), }, ); // 终态:按 Tauri 时序安排自动收起,否则药丸会一直贴在屏幕上。 @@ -2524,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(); @@ -2671,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; } @@ -3348,6 +3550,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 @@ -3357,7 +3563,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) { @@ -3609,8 +3815,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); @@ -3799,6 +4009,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) @@ -4074,6 +4285,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, @@ -4084,26 +4296,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(); @@ -4112,6 +4314,8 @@ 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), + is_draft: self.editor_draft, provider: form.label.clone(), provider_type: form.provider_type.clone(), name: form.name.clone(), @@ -4137,57 +4341,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 @@ -4209,25 +4384,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 @@ -4239,33 +4408,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(); @@ -4279,21 +4441,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(); } @@ -4334,6 +4488,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, @@ -4382,6 +4614,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; } @@ -4743,6 +4978,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; } @@ -5459,6 +5707,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) => { @@ -5485,39 +5740,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) => { @@ -5575,6 +5821,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 } => { @@ -5639,6 +5887,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 => { @@ -5666,6 +5915,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; @@ -5755,7 +6005,7 @@ mod linux_app { } } frontend::view_model::FrontendAction::SettingsProviderClose => { - self.close_provider_editor(); + self.request_provider_editor_close(); } frontend::view_model::FrontendAction::SettingsChannelToggle(index) => { let kind = self.settings_channel_kind; @@ -5895,6 +6145,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, @@ -6529,8 +6895,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 { @@ -6548,7 +6914,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; @@ -6637,10 +7011,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 { @@ -6734,6 +7140,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 { @@ -6876,6 +7286,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). @@ -6898,10 +7332,34 @@ 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 => {} + 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() { @@ -6943,6 +7401,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. @@ -7134,7 +7593,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 { @@ -7143,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, @@ -7250,7 +7735,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( @@ -7260,13 +7745,14 @@ 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; 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)) => { @@ -7280,6 +7766,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, @@ -7368,8 +7856,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, @@ -7421,6 +7909,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(), @@ -7430,6 +7921,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. @@ -7466,6 +7959,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(); @@ -7473,6 +7967,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!( @@ -7502,6 +7997,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); @@ -7596,8 +8095,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 @@ -7609,25 +8133,42 @@ 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); - 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); } }; - if let Err(error) = run_host(&runtime_dir, tokio, native, tray, start_minimized) { - window::show_startup_error(&error, Arc::clone(&broker)); + if let Err(error) = run_host( + &runtime_dir, + tokio, + native, + tray, + start_minimized, + runtime_warning, + ) { + // 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(()) @@ -7700,6 +8241,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); @@ -7794,6 +8346,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 { .. }) => { @@ -8118,6 +8676,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, @@ -8149,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/popup_layer.rs b/openless-all/app/linux-egui/src/popup_layer.rs index 03e51f7c8..bb8b7a31f 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,196 @@ 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, +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>, + 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(&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)) + .ok_or("Wayland surface has no Vulkan format")?; + // 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( + &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); + } + // 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(()); + } + 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); + for id in &textures_delta.free { + self.renderer.free_texture(id); + } + textures_delta.clear(); + Ok(()) } } @@ -579,8 +665,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 +769,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 +793,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(()); } @@ -850,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/popup_window.rs b/openless-all/app/linux-egui/src/popup_window.rs index a0f4a6b16..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; @@ -302,6 +303,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. /// @@ -971,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!( @@ -985,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)) ); } @@ -1029,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)), ); } @@ -1052,20 +1100,38 @@ 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()); 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 { @@ -1075,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/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/bridge.rs b/openless-all/app/linux-egui/src/ui/bridge.rs index e61285d66..9202d350f 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,8 +25,14 @@ 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 = 2; +pub const UI_BRIDGE_VERSION: u32 = 3; /// 单帧上限。视图模型快照含历史列表,比弹窗协议大得多。 const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; @@ -53,10 +60,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, @@ -338,9 +359,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 +378,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) { @@ -509,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>, @@ -555,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 || { @@ -587,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}"); @@ -620,6 +674,7 @@ impl UiBridgeClient { }; Ok(Self { incoming: incoming_rx, + latest_snapshot, outgoing: outgoing_tx, reader: Some(reader), writer: Some(writer), @@ -645,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()); @@ -730,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); @@ -776,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; @@ -800,6 +856,168 @@ 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); + } + + /// 常驻内存(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"); @@ -959,6 +1177,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/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/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 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"), @@ -109,6 +117,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")), @@ -151,6 +160,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 +196,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, ); @@ -217,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/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 88a17c578..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; @@ -6,16 +7,18 @@ 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; -pub mod siri_gl; +pub use openless_linux_egui::siri_wgpu; pub mod style; pub mod translation; 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. @@ -130,6 +133,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 会 @@ -204,6 +266,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(); @@ -574,7 +668,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, @@ -582,10 +678,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 { @@ -606,17 +723,96 @@ 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" ); } + /// 回归:渠道编辑器只能盖住「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 @@ -834,6 +1030,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; @@ -3216,14 +3514,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", @@ -3410,4 +3720,48 @@ mod tests { "a focused window must not re-ask every frame: {focused:?}" ); } + /// 宿主分页下发历史时,**整页**必须画出「已载入 X / 共 Y」并给出继续加载的入口。 + /// 只测 `load_more_footer` 自己不够:真正会漏掉的是 `list_card` 里那句接线。 + #[test] + 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_list_total: 1_234, + 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(); + let mut actions = Vec::new(); + // 滚动区域要一帧才发布尺寸,所以多跑两帧再取最终画面。 + for _ in 0..3 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + 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, "common.loaded_of_total", &[&1, &1_234]); + assert!( + painted.contains(&expected), + "the history page must show paging progress ({expected:?}): {painted}" + ); + assert!( + 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/popups.rs b/openless-all/app/linux-egui/src/ui/frontend/popups.rs index eab3b32fe..cb9e870d9 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::{capsule_motion, icons, layout, siri_wgpu, theme}; use openless_linux_egui::{ fmt_l10n, tr_l10n, CapsulePopupState, Lang, LessComputerPopupState, PopupChatMessage, QaPolishState, QaPopupState, @@ -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`)。 @@ -78,10 +162,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 `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; +/// 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); @@ -532,22 +620,22 @@ 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 语义一致。 speed: if recording { 1.0 } else { 1.45 }, - warming: false, + ..Default::default() }; 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 }); } } @@ -978,7 +1066,7 @@ fn rounded_rect_points(rect: egui::Rect, radius: f32, segments: usize) -> Vec [f32; 3] { [ f32::from(color.r()) / 255.0, @@ -1001,16 +1089,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,25 +1111,38 @@ 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, ); } /// 录音胶囊:经典药丸(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(); + // 「要记住这个词吗?」卡片与录音胶囊共用一个窗口:有候选时整窗只画这张卡。 + 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| { @@ -1082,20 +1181,13 @@ pub fn dictation_capsule( ); 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" { - 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, @@ -1105,14 +1197,75 @@ 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" + ); + // 终态:光点做「六点合回一颗圆」的收尾,转速回落 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 + // 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( + 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 +1289,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 +1322,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,27 +1339,25 @@ 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 - // 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(), + warmup_ms, + merging: false, }; 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)); @@ -1203,70 +1370,338 @@ pub fn dictation_capsule( pill_ink, ); } - } else if processing { - // 思考中:Siri 流体圆点(orb),从 wave 收拢的光点化开成环。 - let drive = siri_gl::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); - // 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) { + } else { + // 思考态 / 终态:Siri 舞台画光点(终态是「六点合回一颗圆」的收尾), + // 经典 / typeless 只画文案。 + if siri { + // 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)); + // 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 + }), + 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, - tr_l10n(lang, "capsule.thinking"), - egui::FontId::proportional(17.0), - theme::INK, + label, + egui::FontId::proportional(size), + if phase == "failed" { + theme::ERR + } else if typeless { + TYPELESS_INK + } else { + theme::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 { - tr_l10n(lang, "capsule.thinking") - }; - let size = if processing { 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, 11.0, center.width(), 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, - ); + // 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 } -/// 28×28 圆形按钮(Tauri `CircleButton`)。 +/// 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 比例缩放)。 +/// 确认式词库学习的确认入口(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, @@ -1274,7 +1709,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 +1724,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 +1742,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 +1757,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), @@ -1911,7 +2353,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 { @@ -1930,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, @@ -2189,16 +2770,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( @@ -2211,7 +2786,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 @@ -2219,15 +2794,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), @@ -2236,7 +2807,7 @@ mod tests { true, ), ( - "thinking = CPU orb only, no perimeter ring", + "thinking = one WGPU orb, no perimeter ring", CapsulePopupState { phase: "transcribing".into(), ..Default::default() @@ -2244,23 +2815,27 @@ mod tests { true, ), ( - "terminal capsule paints no glow", + "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(), + suggestions: Vec::new(), + ..Default::default() + }, 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}"); } } @@ -2274,7 +2849,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!( @@ -2287,7 +2862,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}"); @@ -2428,13 +3003,173 @@ mod tests { } } + /// 文本的声明颜色(扫光靠重绘另一份 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_classic_thinking_label_sweeps_a_shine_band() { + let collect = |time: f64, style: &str| { + let ctx = egui::Context::default(); + let state = CapsulePopupState { + phase: "Polishing".into(), + style: style.into(), + suggestions: Vec::new(), + ..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, 60.0), + )), + time: Some(frame), + ..Default::default() + }, + |ui| { + 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); + } + } + 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" + ); + } + + /// 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(), + suggestions: Vec::new(), + ..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!( + 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(), + suggestions: Vec::new(), + ..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" + ); + } + /// 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, @@ -2446,33 +3181,32 @@ 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(); 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); } } _ => {} @@ -2487,7 +3221,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"] { @@ -2497,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 @@ -2521,14 +3256,15 @@ mod tests { audio_level: Some(0.6), translation_active: false, style: "siri".to_string(), + suggestions: Vec::new(), }; - 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})" ); } @@ -2553,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; @@ -2580,7 +3317,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); }, ); } @@ -2591,6 +3329,73 @@ mod tests { } } + /// 经典药丸的「思考中」文案必须本地化并在每一个处理相位都在;Siri 舞台反过来 —— + /// 它没有文案(Tauri `VoiceOrbStage` 只有 error 才给字),反馈全靠光效。 + #[test] + 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 classic = CapsulePopupState { + phase: phase.into(), + style: "classic".into(), + suggestions: Vec::new(), + ..Default::default() + }; + 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(), + suggestions: Vec::new(), + ..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}" + ); + } + } + } + + #[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(), + suggestions: Vec::new(), + ..Default::default() + }; + let mut output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(stage), + ..Default::default() + }, + |ui| { + dictation_capsule(ui, &state, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); + }, + ); + 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 { @@ -2599,9 +3404,10 @@ 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); + dictation_capsule(ctx, &recording, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); String::new() }); assert!( @@ -2613,10 +3419,11 @@ 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| { - dictation_capsule(ctx, &transcribing, Lang::ZhCn); + dictation_capsule(ctx, &transcribing, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); String::new() }); assert!( @@ -2624,31 +3431,51 @@ mod tests { "{painted}" ); - // siri 样式:中心交给 Siri 光效(现在由 CPU 画,见 `siri_gl` 文件头), - // 所以不再叠一行「思考中」文字——与 GPU 就绪后的终态一致。 + // Siri 舞台没有文案:光点换乘「收尾合并」,字都交给光效(Tauri `VoiceOrbStage`)。 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| { - 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 paints the orb instead of the label: {painted}" + "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(), + suggestions: Vec::new(), + }; + let painted = run(egui::vec2(200.0, 60.0), |ctx| { + 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(), + suggestions: Vec::new(), + ..done.clone() }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { - dictation_capsule(ctx, &done, Lang::ZhCn); + dictation_capsule(ctx, &done_classic, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); String::new() }); assert!(has(&painted, "12"), "{painted}"); @@ -2659,9 +3486,10 @@ 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); + 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/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index fb07a1d54..e6a8a591d 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,70 @@ 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 进「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); + } }, ); }); @@ -170,91 +209,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)`:没有桌面热键后端时 @@ -1918,6 +1948,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.failure_keeps_enabled")) + .size(11.0) + .color(theme::INK_2), + ); + } + if let Some(age) = channel.last_check_age_seconds { ui.label( - egui::RichText::new(tr_l10n(lang, "settings.channels.enabled")) + 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". @@ -2648,12 +2880,7 @@ fn provider_model_block( ui.add_space(4.0); // 模型字段:预设下拉 ⇄ 自定义输入。 - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(tr_l10n(lang, "settings.providers.modelLabel")) - .size(11.5) - .color(theme::INK_3), - ); + modal_form_row(ui, tr_l10n(lang, "settings.providers.modelLabel"), |ui| { let show_presets = !editor.static_models.is_empty() && !editor.custom_model; if show_presets { // Tauri 会把当前值(不在清单里时)也塞进选项,否则下拉会显示空白。 @@ -2684,25 +2911,32 @@ fn provider_model_block( } }); if pick != editor.model { + editor_mark_dirty(ui); actions.push(FrontendAction::SettingsProviderField( SettingsProviderField::Model, pick, )); } } else { - let mut draft = editor.model.clone(); - let id = egui::Id::new("openless-settings-provider-model"); - if layout::text_input(ui, &mut draft, id, "", 220.0, false).changed() { - actions.push(FrontendAction::SettingsProviderField( - SettingsProviderField::Model, - draft, - )); - } - if !editor.static_models.is_empty() - && provider_small_button(ui, lang, "settings.providers.presetListLabel", false) - { - actions.push(FrontendAction::SettingsProviderModelCustom(false)); - } + // 手输框与「回到预设列表」是上下两行(Tauri 的模型工具都在字段下方), + // 同一行会让按钮被挤出右边界、根本画不出来。 + ui.vertical(|ui| { + let mut draft = editor.model.clone(); + let id = egui::Id::new("openless-settings-provider-model"); + let width = ui.available_width().min(420.0); + if layout::text_input_sized(ui, &mut draft, id, "", width, 38.0, false).changed() { + editor_mark_dirty(ui); + actions.push(FrontendAction::SettingsProviderField( + SettingsProviderField::Model, + draft, + )); + } + if !editor.static_models.is_empty() + && provider_small_button(ui, lang, "settings.providers.presetListLabel", false) + { + actions.push(FrontendAction::SettingsProviderModelCustom(false)); + } + }); } if editor.model.trim().is_empty() && !editor.default_model.is_empty() @@ -2788,14 +3022,24 @@ fn provider_field( password: bool, 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() + { + editor_mark_dirty(ui); 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 { @@ -2818,267 +3062,680 @@ 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( +/// 自动保存的临时状态:存在即「有未落盘的改动」,值是防抖截止时刻(egui 时间轴)。 +fn editor_autosave_id() -> 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) }` 整栏盖住——不是全窗口遮罩加居中卡片。 +/// +/// 所以这里是「在右栏里覆盖一层」:灰底(`--ol-sidebar-bg`)+ 头部(返回按钮 + 标题 + +/// 渠道种类徽章 + 自动保存说明)+ 中部滚动区(白底圆角表单卡:服务连接 → 供应商 → +/// 渠道名称 → 凭据 → 模型)+ 底部(删除渠道 / 完成)。 +fn channel_editor_panel( ui: &mut egui::Ui, - editor: &SettingsProviderEditor, - lang: Lang, + vm: &FrontendViewModel, 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), + /// 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; + // Tauri:字段修改后自动保存(300ms 防抖 + 失焦/离开前 flush)。这里到点就推一次 + // `SettingsProviderSave`;没到点则请求下一帧再看。 + editor_auto_save(ui, actions); + 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() { + 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, title_key), + egui::FontId::proportional(21.0), + theme::INK, + ); + let title_width = layout::text_width(ui, tr_l10n(lang, title_key), 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), + ), ); - if editor.busy { - ui.label( - egui::RichText::new(tr_l10n(lang, "common.loading")) - .size(10.5) - .color(theme::INK_4), - ); - } + 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); }); - provider_field( - ui, - tr_l10n(lang, "providers.name"), - &editor.name, - SettingsProviderField::Name, - false, - actions, - ); - ui.label( - egui::RichText::new(tr_l10n(lang, "providers.credentials")) - .size(11.0) - .color(theme::INK_4), + }); + + // ── 底部按钮 ─────────────────────────────────────────────────────────── + 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), ); - 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, - ); - } + 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)); } - provider_model_block(ui, editor, lang, actions); - ui.add_space(4.0); - ui.horizontal(|ui| { - 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); - } - }); - }); + 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 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 + 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() { + editor_close(ui, actions); + } + 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); + } } -fn add_channel_form( +/// 次要按钮(白底 + 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, - vm: &mut FrontendViewModel, + editor: &SettingsProviderEditor, + providers: &[SettingsChannelProvider], + channel_index: Option, + lang: Lang, actions: &mut Vec, ) { - let lang = vm.lang; + // 分区标题:云图标 + 「服务连接」(Tauri `ChannelSectionHeading`)。 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(); + 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 new_selection != selected { - actions.push(FrontendAction::SettingsChannelProvider(new_selection)); - } - // 推的是编辑后的值:推编辑前的拷贝会让宿主把旧值写回字段,每敲一个字 - // 就被回灌一次(渠道名、下划线搜索框都踩过这个坑)。 - let response = layout::text_input( + }, + ); + } + modal_form_row(ui, tr_l10n(lang, "settings.channels.name_label"), |ui| { + provider_field( ui, - &mut vm.channel_form_name, - egui::Id::new("openless-settings-channel-name"), - tr_l10n(lang, "settings.channels.name_placeholder"), - 200.0, + "", + &editor.name, + SettingsProviderField::Name, false, + actions, ); - 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), + .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 { + editor_mark_dirty(ui); + 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.5) + .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, + ); + } + } + 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 } -/// A row whose value is a button that opens a link / performs an action. fn link_row( ui: &mut egui::Ui, label: &str, @@ -3553,12 +4210,35 @@ 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 // 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 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(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)); @@ -3754,6 +4434,109 @@ 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(); + 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(); + 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] @@ -3906,6 +4689,8 @@ mod tests { for auth in shapes { 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(), @@ -3927,7 +4712,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_body(ui, &editor, &[], None, Lang::ZhCn, &mut actions); }); assert!( actions.is_empty(), @@ -3943,6 +4728,8 @@ mod tests { let editor = |static_models: Vec, has_models_url: bool, custom_model: bool| { 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(), @@ -3997,6 +4784,192 @@ 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] + 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, + is_draft: 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" + ); + + // 草稿模式(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 { let ctx = egui::Context::default(); let mut actions = Vec::new(); @@ -4009,7 +4982,7 @@ mod tests { )), ..Default::default() }, - |ui| provider_editor_panel(ui, editor, 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/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..857ad4083 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs @@ -0,0 +1,877 @@ +//! 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 std::sync::OnceLock; + +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], + /// 整体不透明度(预乘 alpha):wave→orb 交叉淡出用,`1.0` = 原样。 + pub opacity: f32, +} + +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], + opacity: 1.0, + } + } + + 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], + opacity: 1.0, + } + } + + 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], + opacity: 1.0, + } + } + + pub fn with_tint(mut self, tint: [f32; 3]) -> Self { + 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. +#[derive(Clone, Copy, Debug)] +pub struct SiriDrive { + pub level: f32, + 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 { + fn default() -> Self { + Self { + level: 0.0, + resolved: 1.0, + speed: 1.0, + warming: false, + warmup_ms: DEFAULT_WARMUP_MS, + merging: false, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct SiriClock { + pub time: f32, + 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; + 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: 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; + 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 thinking { + 0.14 + 0.07 * (clock.time * 2.2).sin() + } else { + visual_voice(drive.level) + }; + 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, + 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, + @location(0) uv: vec2, +}; + +@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; + 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; +} + +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 % (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); + // 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 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 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 = 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(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); +} + +// 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; + let color = mode_color(local); + let lower = color.rgb / 12.92; + let higher = pow((color.rgb + vec3(0.055)) / 1.055, vec3(2.4)); + let linear = vec4(select(higher, lower, color.rgb < vec3(0.04045)), color.a); + return fade(linear); +} +"#; + +#[derive(Clone, Copy)] +struct SiriUniforms { + effect: SiriEffect, + size: [f32; 2], +} + +struct SiriGpu { + pipeline: egui_wgpu::wgpu::RenderPipeline, + bind_layout: egui_wgpu::wgpu::BindGroupLayout, +} + +fn uniform_bytes(uniforms: SiriUniforms) -> [u8; 64] { + 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], + uniforms.effect.opacity.clamp(0.0, 1.0), + uniforms.size[0], + uniforms.size[1], + ]; + // 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()); + } + bytes +} + +fn create_gpu( + device: &egui_wgpu::wgpu::Device, + format: egui_wgpu::wgpu::TextureFormat, + msaa_samples: u32, +) -> SiriGpu { + use egui_wgpu::wgpu; + 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: wgpu::BufferSize::new(64), + }, + count: None, + }], + }); + 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(if format.is_srgb() { + "fs_main_linear" + } else { + "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_layout, + } +} + +/// 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, + // 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, + _screen: &egui_wgpu::ScreenDescriptor, + _encoder: &mut egui_wgpu::wgpu::CommandEncoder, + resources: &mut egui_wgpu::CallbackResources, + ) -> Vec { + 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() + } + + 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; + }; + let Some(binding) = self.binding.get() else { + return; + }; + render_pass.set_pipeline(&gpu.pipeline); + render_pass.set_bind_group(0, binding, &[]); + 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 { + binding: OnceLock::new(), + uniforms: SiriUniforms { + effect, + size: [rect.width() * scale, rect.height() * scale], + }, + }, + ); + ui.painter().add(egui::Shape::Callback(callback)); + true +} + +#[cfg(test)] +#[path = "siri_wgpu_gpu_tests.rs"] +mod gpu_tests; + +#[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); + } + + /// 预备态:光条从收拢态起步,按 `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 { + effect: SiriEffect::wave(1.35, 0.52), + 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); + 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/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..9a6b8de6c --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu_gpu_tests.rs @@ -0,0 +1,298 @@ +//! 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 { + 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, + 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(); + // 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]), + ( + 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" + ); + } + } +} 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 4db74df6d..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 @@ -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, @@ -185,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. @@ -243,6 +247,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. @@ -351,6 +357,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, @@ -361,8 +369,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 @@ -402,6 +416,10 @@ pub enum SettingsProviderAuth { #[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] 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, @@ -487,6 +505,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 = 完全放行)。 @@ -680,6 +701,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")] @@ -699,6 +723,11 @@ pub struct FrontendViewModel { pub history_query: String, pub history_selected: usize, pub history_entries: Vec, + /// 列表里的历史总条数(概览页的 `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, @@ -715,7 +744,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. @@ -734,6 +767,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, @@ -769,6 +804,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, @@ -838,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. @@ -896,6 +930,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, @@ -904,6 +939,7 @@ impl Default for FrontendViewModel { history_query: String::new(), history_selected: 0, history_entries: Vec::new(), + history_list_total: 0, quick_note_recording: false, quick_note_shortcut_hidden: false, history_loading: true, @@ -915,7 +951,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(), @@ -930,6 +968,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, @@ -959,6 +998,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, @@ -987,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, 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 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}" + ); + }); + } } 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..8906d303b --- /dev/null +++ b/openless-all/app/linux-egui/src/wgpu_device.rs @@ -0,0 +1,63 @@ +//! 所有 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 + ); + // 3840 宽是 egui 在 4K 表面下的硬需求;常量断言放在 const 块里, + // 编译期就能拦住把上限调小的改动。 + const { assert!(MAX_TEXTURE_DIMENSION_2D >= 3840) }; + } + + #[test] + fn device_descriptor_asks_for_no_optional_features() { + // 只改显存策略:不能顺手把 wgpu 的实验特性打开。 + assert!(device_descriptor().required_features.is_empty()); + } +} 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 ada8157ea..c0ffe1837 100644 --- a/openless-all/app/scripts/linux-egui-release-contract.test.mjs +++ b/openless-all/app/scripts/linux-egui-release-contract.test.mjs @@ -82,6 +82,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/); @@ -98,13 +103,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'], 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. diff --git a/openless-all/app/scripts/sync-egui-lucide-icons.mjs b/openless-all/app/scripts/sync-egui-lucide-icons.mjs index 5991659ee..6602c969b 100644 --- a/openless-all/app/scripts/sync-egui-lucide-icons.mjs +++ b/openless-all/app/scripts/sync-egui-lucide-icons.mjs @@ -19,9 +19,10 @@ 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', + 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'); 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" -}