diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6515e726..923774f0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,6 +188,43 @@ jobs: - name: Test remote input TLS identity and Unix key permissions run: cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --test remote_tls + linux-egui: + name: Linux egui host + runs-on: ubuntu-22.04 + defaults: + run: + working-directory: openless-all/app + steps: + - uses: actions/checkout@v4 + with: + submodules: false + + - uses: dtolnay/rust-toolchain@stable + + - uses: swatinem/rust-cache@v2 + with: + workspaces: 'openless-all/app -> target' + + - name: Install egui host build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libasound2-dev \ + libdbus-1-dev \ + libssl-dev \ + libwayland-dev \ + libx11-dev \ + libxkbcommon-dev \ + pkg-config + + # 与 release-linux-egui.yml 同款命令:lib + bin + UI headless 渲染测试。 + # egui UI 只在 Linux 目标编译,这是 2.0 界面改动的编译门禁。 + - name: Test linux-egui (lib + bin + ui) + run: cargo test --locked -p openless-linux-egui --all-targets + + - name: Check linux-egui + run: cargo check --locked -p openless-linux-egui --all-targets + cross-platform: name: ${{ matrix.label }} checks strategy: diff --git a/docs/linux-egui-handoff/02-gap-register.md b/docs/linux-egui-handoff/02-gap-register.md index 19777c015..05c73a399 100644 --- a/docs/linux-egui-handoff/02-gap-register.md +++ b/docs/linux-egui-handoff/02-gap-register.md @@ -20,7 +20,7 @@ | L06 | 界面:marketplace 服务已接入(`marketplace.rs`),市场页面缺失 | 浏览、安装、上传/下载、设备 OAuth 登录/退出,复用 Core 协议 | | L07 | 界面+L03 依赖:历史仅只读最近条目,缺完整历史/统计/录音操作 | 浏览与既有历史操作、重润色/重转写、录音播放/导出/清理与统计 | | L08 | 界面:本地 Qwen 有下载/激活/取消,缺完整模型管理与运行时控制 | 路径/镜像、详情/状态、删除、预载/释放与准备/测试取消;真实推理另附证据 | -| L09 | 界面部分改善:10 页导航、开始/环境引导、独立设置与手机输入页、跨页审批/取消已落地;多数 Linux 适用设置、Agent 检测/模型/路径/权限配置仍缺失 | 设置有实际消费者、revision/错误处理;保留 Less Computer 输出/审批/取消流程 | +| L09 | 界面部分改善:10 页导航、开始/环境引导、跨页审批/取消已落地;2026-09-10 起 2.0 外壳/设置弹窗/双主题视觉已对齐 Tauri 2.0(见 04 §0,待桌面实测);多数 Linux 适用设置、Agent 检测/模型/路径/权限配置仍缺失 | 设置有实际消费者、revision/错误处理;保留 Less Computer 输出/审批/取消流程 | | L10 | 接线+界面:无托盘/自启(`capabilities.rs` 仅探测 `supports_tray`,未实现);通知仅状态栏;AppImage 能力判定不等于更新器 | 窗口/后台运行、通知、自启、检查/下载/安装更新与重启有真实 Host 效果 | | L11 | 已实现待实测:fcitx5 输入/选区(`fcitx5.rs`)、CPAL(`audio.rs`)、Secret Service(`credentials.rs`)、Qwen 运行时、CLI 进程、Remote TLS/H5(`remote_input.rs`) | 通过真实桌面/设备矩阵;逐项记录,不把整模块称为缺失 | | L12 | 已有包构建(deb/rpm/AppImage workflow),待 Linux 产品验收与正式分发 | 完成上述应用缺口与安装/升级/回滚、签名和更新证据;不阻塞 Windows/macOS 首批交付 | diff --git a/docs/linux-egui-handoff/04-ui-domains.md b/docs/linux-egui-handoff/04-ui-domains.md index 5baab2a31..cbcf30d04 100644 --- a/docs/linux-egui-handoff/04-ui-domains.md +++ b/docs/linux-egui-handoff/04-ui-domains.md @@ -1,28 +1,39 @@ # 04:页面与领域操作 -状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。页面定义:`linux-egui/src/ui_state.rs` `Page` 枚举(10 页);主循环与交互:`main.rs`。 - -## 1. 十页现状 - -| 页面 | 已有操作 | 主要缺口 | -| --- | --- | --- | -| Start | 下一步引导、环境准备说明(fcitx/插件、桌面会话、麦克风、Secret Service 的真实可知状态;未连接时保留诊断步骤,不展示成功) | — | -| Dictation | 普通听写(复用 Core `dictation_engine`)、ticket 化落字 | — | -| Qa | QA 文本/语音(`qa.rs`)、全局待办、审批、取消 | 编辑模式/应用/撤回 UI(L04) | -| Selection | 划词润色预览/撤回(`selection.rs`) | Selection Voice 生产触发/捕获/意图路由(L04) | -| Agent | Less Computer 工具调用、审批固定在页面外、后台事件保留 | Agent 检测/模型/路径/权限配置(L09) | -| Services | AI 服务配置、渠道管理(`settings.rs` + Core provider 面) | Omni 有效模式配置入口完整性(L09) | -| Models | 本地 Qwen 下载/激活/取消 | 完整模型管理:路径/镜像/详情/删除/预载/释放(L08) | -| Remote | 手机输入独立页、连接状态、陈旧地址处理 | — | -| History | 只读最近条目 | 完整历史/统计/录音操作(L07,依赖 L03) | -| Settings | 现有设置独立页 | 词典/纠错/风格包/市场无页面(L05/L06);多数设置缺实际消费者(L09) | +状态:canonical(2026-09-07 以源码为准重写;2026-09-10 补 2.0 界面复刻现状);更新:2026-09-10。页面定义:`linux-egui/src/ui_state.rs` `Page` 枚举;主循环与交互:`main.rs`;2.0 视觉系统:`linux-egui/src/design_tokens.rs`(tokens.css 对照表,跨平台单测)+ `linux-egui/src/ui/`(theme/widgets/prefs,仅 Linux 编译)。 + +## 0. 2.0 界面复刻(2026-09-10,待真实桌面验收) + +按 Tauri/React 2.0 UI 在 egui 侧 1:1 复刻视觉与结构,功能接线沿用既有 Core 调用: + +- 设计令牌:`design_tokens.rs` 是 `src/styles/tokens.css` 的 Rust 对照表(浅/深双主题、圆角阶梯、侧栏/弹窗尺寸),单测锁定与 CSS 字面值一致。 +- 主题:`ui/theme.rs` 把令牌转成 egui Style/Visuals(白底 zinc 体系、#2563eb 强调、控件 8px 圆角);CJK 字体自动探测安装(含 `OPENLESS_IME_FONT` 覆盖,移植自 #997 并修正多候选覆盖问题);深浅主题即时切换并持久化到 `$XDG_DATA_HOME/OpenLess/ui-prefs.json`(`ui/prefs.rs`,表现层状态不进 Core 合同)。 +- 外壳:226px 主侧栏(版本行 + BETA 徽章 + 扁平导航 + 「工具」可展开分组 + 底部设置),对齐 `FloatingShell`;<760px 回落横向胶囊导航。 +- 设置弹窗:环境与设置 / AI 服务 / 本地模型 / 手机输入四分区收纳进居中 960x680 弹窗(遮罩 + 14px 圆角 + 左 rail),对齐 `SettingsModal`;这些入口不再占主导航。 +- 组件:`ui/widgets.rs`(卡片/软底卡/状态胶囊/主·蓝·危险·次级按钮/分段控件/开关/键值行/页头);问答页按 #997 验证过的双气泡布局(用户右蓝软底、助手左 surface-2、流式/思考/错误行、底部输入组)。 +- 状态:CI ubuntu 已有 linux-egui `cargo test + check` 门禁(`ci.yml` `linux-egui` 任务);真实 X11/Wayland 桌面的视觉与交互证据仍待实测(L11)。 + +## 1. 页面现状 + +| 页面 | 位置 | 已有操作 | 主要缺口 | +| --- | --- | --- | --- | +| Start | 主导航 | 2.0 概览卡(Core/管线状态、快速开始、其他任务入口);未连接保留诊断步骤 | — | +| Dictation | 主导航 | 普通听写(Core `dictation_engine`)、状态胶囊、转写卡 | — | +| Qa | 「工具」分组 | 2.0 双气泡会话、文本/语音提问、取消/关闭 | 编辑模式/应用/撤回 UI(L04) | +| Selection | 「工具」分组 | 划词润色预览(可编辑草稿)/确认/取消/撤回 | Selection Voice 生产触发/捕获/意图路由(L04) | +| Agent | 「工具」分组 | Less Computer 任务卡 + 输出卡;审批卡在页内 + 跨页横幅 | Agent 检测/模型/路径/权限配置(L09) | +| History | 主导航 | 只读最近 20 条卡片(时间 + 落字状态胶囊 + 文本) | 完整历史/统计/录音操作(L07,依赖 L03) | +| Services | 设置弹窗 | AI 服务状态胶囊、渠道管理(ASR/LLM 分段切换) | Omni 有效模式配置入口完整性(L09) | +| Models | 设置弹窗 | 本地模型目录卡(display name/家族/模式/语言/体积元数据)+ 下载/激活/取消 | 完整模型管理:路径/镜像/删除/预载/释放(L08) | +| Remote | 设置弹窗 | 启用/端口、连接状态、证书指纹核验、PIN/地址 | — | +| Settings | 设置弹窗 | 外观(深浅主题)、功能开关、环境准备卡 + 官方指南 | 词典/纠错/风格包/市场无页面(L05/L06);多数设置缺实际消费者(L09) | ## 2. 跨页约定 -- Agent 审批固定在页面外展示,QA/Selection/Agent 的后台事件保留操作或导航提示。 -- 事件消费不得因导航停止(见[06](06-events-and-sessions.md));会话取消语义由 Core 承担。 +- Agent 审批在 Agent 页内展示;其余页面顶部保留跨页审批横幅(允许/拒绝直达 Core)。 +- 事件消费不得因导航停止(见[06](06-events-and-sessions.md));会话取消语义由 Core 承担;设置弹窗打开时 Esc 只关弹窗、不触发语音取消。 - 每个旧操作保留真实调用;页面增删不改变 Core 接口。 ## 3. 新增页面的实施要求 -L04–L08 每个领域补完整成功/失败/取消路径后再算完成;领域功能扩展时优先拆分 `main.rs`(当前较大),避免继续单文件增长。 +L04–L08 每个领域补完整成功/失败/取消路径后再算完成;领域功能扩展时优先拆分 `main.rs`(当前较大),避免继续单文件增长。新增视觉元素一律引用 `design_tokens` / `ui::widgets`,不在页面里写裸色值。 diff --git a/docs/linux-egui-handoff/README.md b/docs/linux-egui-handoff/README.md index f6d42f54e..4fb5a4dc3 100644 --- a/docs/linux-egui-handoff/README.md +++ b/docs/linux-egui-handoff/README.md @@ -11,7 +11,9 @@ Linux Host Adapter 的剩余实现、全局热键、窗口/托盘/权限/更新 - 组装:`linux-egui/src/backend.rs` — `LinuxBackendBuilder::from_shared_providers(BackendConfig)` + `with_*` 注入(recorder/polisher/inserter/credential store/host actions/settings runtime/local ASR runtime/polish failure policy)。 - Host 门面:`lib.rs` `LinuxHost` — `snapshot` / `subscribe` / `save_settings` / `update_settings_strict` / `drain_events` / `backend()`。 - 已实现模块:`audio.rs`(CPAL 录制)、`credentials.rs`(Secret Service)、`fcitx5.rs`(输入/选区)、`hotkeys.rs`(fcitx5 热键监听)、`selection.rs`、`qa.rs`、`coding_agent.rs`、`marketplace.rs`、`remote_input.rs`、`settings.rs`、`single_instance.rs`、`capabilities.rs`、`resources.rs`、`runtime.rs`、`ui_state.rs`。 -- 页面:`ui_state.rs` `Page` 枚举 10 页(Start/Dictation/Qa/Selection/Agent/Services/Models/Remote/History/Settings),主循环 `main.rs`。 +- 2.0 视觉系统(2026-09-10):`design_tokens.rs`(tokens.css 对照表,跨平台单测)+ `ui/`(`theme.rs` 主题与 CJK 字体、`widgets.rs` 组件库、`prefs.rs` 外观偏好);页面渲染一律取令牌,不写裸色值。 +- 页面:`ui_state.rs` `Page` 枚举 10 页(Start/Dictation/Qa/Selection/Agent/Services/Models/Remote/History/Settings),主循环 `main.rs`;主导航 = 开始/听写/历史 + 工具分组(问答/选区/Agent),Services/Models/Remote/Settings 收纳进设置弹窗(对齐 2.0 SettingsModal)。 +- CI:`ci.yml` `linux-egui` 任务(ubuntu)跑 `cargo test + check -p openless-linux-egui --all-targets`,是 UI 改动的编译门禁。 - 打包:`release-linux-egui.yml`(deb/rpm/AppImage,独立 manifest)。 ## 3. 阅读顺序 diff --git a/openless-all/app/linux-egui/src/design_tokens.rs b/openless-all/app/linux-egui/src/design_tokens.rs new file mode 100644 index 000000000..cc2431073 --- /dev/null +++ b/openless-all/app/linux-egui/src/design_tokens.rs @@ -0,0 +1,230 @@ +//! 2.0 设计令牌(tokens.css 的 Rust 对照表)。 +//! +//! 本模块是纯数据:不依赖 egui,任何目标都能编译与单测。egui 侧的 +//! 换算(Color32 / Style / Visuals)在 `ui` 模块,仅 Linux 编译。 +//! +//! 数值必须与 `src/styles/tokens.css` 一一对应;修改任何一侧都要同步 +//! 另一侧并通过本模块的对照测试。CSS 十六进制按 `0xRRGGBB` 转录, +//! 省略 alpha 的透明色按实际叠加效果取实色近似并在字段注释里标明。 + +/// 圆角阶梯(tokens.css `--ol-r-*`)。egui 0.31 的 `CornerRadius` 以 u8 计, +/// 这里直接存 u8;需要 f32 的场合由使用方转换。 +pub mod radius { + /// `--ol-r-sm: 6px`(控件内小组件) + pub const SM: u8 = 6; + /// `--ol-control-radius: 8px`(按钮、导航项) + pub const CONTROL: u8 = 8; + /// `--ol-r-md: 10px` + pub const MD: u8 = 10; + /// `--ol-r-lg` / `--ol-card-radius: 14px`(卡片、设置弹窗) + pub const CARD: u8 = 14; + /// `--ol-panel-radius` / `--ol-r-xl: 18px`(浮层面板) + pub const PANEL: u8 = 18; + /// `--ol-shell-radius: 32px`(窗口外壳;Linux 由 WM 裁剪,这里供自定义绘制参考) + pub const SHELL: u8 = 32; +} + +/// 侧栏宽度:FloatingShell `SIDEBAR_WIDTH = 226`。 +pub const SIDEBAR_WIDTH: f32 = 226.0; +/// 设置弹窗最大宽度:SettingsModal `maxWidth: 960`。 +pub const SETTINGS_MAX_WIDTH: f32 = 960.0; +/// 设置弹窗最大高度:SettingsModal `maxHeight: 680`。 +pub const SETTINGS_MAX_HEIGHT: f32 = 680.0; + +/// 一个主题的全部颜色令牌。字段名与 tokens.css 的 `--ol-*` 变量对应。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ThemeTokens { + /// `--ol-canvas` + pub canvas: u32, + /// `--ol-surface`(内容底;2.0 为纯白) + pub surface: u32, + /// `--ol-surface-2`(次级底 / 导航激活底) + pub surface_2: u32, + /// `--ol-line` + pub line: u32, + /// `--ol-line-strong` + pub line_strong: u32, + /// `--ol-line-soft` + pub line_soft: u32, + /// `--ol-ink`(主文字) + pub ink: u32, + /// `--ol-ink-2` + pub ink_2: u32, + /// `--ol-ink-3`(次级文字 / 导航未选中) + pub ink_3: u32, + /// `--ol-ink-4`(占位 / 弱化文字) + pub ink_4: u32, + /// `--ol-ink-5` + pub ink_5: u32, + /// `--ol-blue`(强调色) + pub blue: u32, + /// `--ol-blue-hover` + pub blue_hover: u32, + /// `--ol-blue-soft`(蓝色软底;暗色主题是 alpha 色,这里取实色近似) + pub blue_soft: u32, + /// `--ol-on-accent` + pub on_accent: u32, + /// `--ol-ok` + pub ok: u32, + /// `--ol-ok-soft`(暗色主题为 alpha 色,取实色近似) + pub ok_soft: u32, + /// `--ol-warn` + pub warn: u32, + /// `--ol-warn-soft`(暗色主题为 alpha 色,取实色近似) + pub warn_soft: u32, + /// `--ol-err` + pub err: u32, + /// `--ol-sidebar-bg` + pub sidebar_bg: u32, + /// `--ol-settings-rail-bg` + pub settings_rail_bg: u32, + /// `--ol-settings-content-bg` + pub settings_content_bg: u32, + /// `--ol-pill-selected-bg`(导航 / 分段选中深色胶囊;暗色主题为蓝色渐变,取上端色) + pub pill_selected_bg: u32, + /// `--ol-pill-selected-ink` + pub pill_selected_ink: u32, + /// `--ol-segmented-bg`(alpha 色,取实色近似) + pub segmented_bg: u32, + /// `--ol-segmented-active-bg` + pub segmented_active_bg: u32, + /// `--ol-overlay-bg`(设置弹窗遮罩;alpha 在 ui 层叠加,这里存 RGB) + pub overlay_rgb: u32, +} + +/// 浅色主题(tokens.css `:root`,2.0 默认)。 +pub const LIGHT: ThemeTokens = ThemeTokens { + canvas: 0xFFFFFF, + surface: 0xFFFFFF, + surface_2: 0xF4F4F5, + line: 0xE4E4E7, + line_strong: 0xD4D4D8, + line_soft: 0xF4F4F5, + ink: 0x09090B, + ink_2: 0x3F3F46, + ink_3: 0x71717A, + ink_4: 0xA1A1AA, + ink_5: 0xD4D4D8, + blue: 0x2563EB, + blue_hover: 0x1D4ED8, + blue_soft: 0xEFF4FF, + on_accent: 0xFFFFFF, + ok: 0x16A34A, + ok_soft: 0xECFDF5, + warn: 0xD97706, + warn_soft: 0xFFF7ED, + err: 0xDC2626, + sidebar_bg: 0xF0F0F1, + settings_rail_bg: 0xF0F0F1, + settings_content_bg: 0xF7F7F8, + pill_selected_bg: 0x18181B, + pill_selected_ink: 0xFFFFFF, + segmented_bg: 0xF4F4F5, + segmented_active_bg: 0xFFFFFF, + overlay_rgb: 0x0F1116, +}; + +/// 深色主题(tokens.css `[data-ol-theme='dark']`)。 +pub const DARK: ThemeTokens = ThemeTokens { + canvas: 0x0C0C0E, + surface: 0x1C1C1F, + surface_2: 0x2A2A2E, + line: 0x27272A, + line_strong: 0x3F3F46, + line_soft: 0x1F1F23, + ink: 0xFAFAFA, + ink_2: 0xD4D4D8, + ink_3: 0xA1A1AA, + ink_4: 0x71717A, + ink_5: 0x3F3F46, + blue: 0x74B7FF, + blue_hover: 0x93C5FD, + // rgba(116,183,255,0.16) 叠加 surface 的实色近似。 + blue_soft: 0x2A3A4E, + on_accent: 0xF8FBFF, + ok: 0x4ADE80, + // rgba(74,222,128,0.14) 叠加 surface 的实色近似。 + ok_soft: 0x223529, + warn: 0xF59E0B, + // rgba(245,158,11,0.14) 叠加 surface 的实色近似。 + warn_soft: 0x3A2E1C, + err: 0xF87171, + sidebar_bg: 0x141417, + settings_rail_bg: 0x141417, + settings_content_bg: 0x18181B, + // 暗色选中是蓝色渐变 3b82f6→2563eb,取上端。 + pill_selected_bg: 0x3B82F6, + pill_selected_ink: 0xF4F7FB, + // rgba(226,232,240,0.07) 叠加 surface 的实色近似。 + segmented_bg: 0x26262A, + segmented_active_bg: 0x27272A, + overlay_rgb: 0x05080D, +}; + +/// 按主题取令牌。 +pub const fn tokens(dark: bool) -> &'static ThemeTokens { + if dark { + &DARK + } else { + &LIGHT + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 关键令牌必须与 tokens.css 字面值一致:这组断言是两个 UI 之间的对照合同。 + #[test] + fn light_tokens_match_tokens_css() { + assert_eq!(LIGHT.surface, 0xFFFFFF); + assert_eq!(LIGHT.surface_2, 0xF4F4F5); + assert_eq!(LIGHT.line, 0xE4E4E7); + assert_eq!(LIGHT.ink, 0x09090B); + assert_eq!(LIGHT.ink_3, 0x71717A); + assert_eq!(LIGHT.blue, 0x2563EB); + assert_eq!(LIGHT.blue_hover, 0x1D4ED8); + assert_eq!(LIGHT.blue_soft, 0xEFF4FF); + assert_eq!(LIGHT.ok, 0x16A34A); + assert_eq!(LIGHT.warn, 0xD97706); + assert_eq!(LIGHT.err, 0xDC2626); + assert_eq!(LIGHT.sidebar_bg, 0xF0F0F1); + assert_eq!(LIGHT.settings_content_bg, 0xF7F7F8); + assert_eq!(LIGHT.pill_selected_bg, 0x18181B); + } + + #[test] + fn dark_tokens_match_tokens_css() { + assert_eq!(DARK.canvas, 0x0C0C0E); + assert_eq!(DARK.surface, 0x1C1C1F); + assert_eq!(DARK.line, 0x27272A); + assert_eq!(DARK.ink, 0xFAFAFA); + assert_eq!(DARK.blue, 0x74B7FF); + assert_eq!(DARK.ok, 0x4ADE80); + assert_eq!(DARK.err, 0xF87171); + assert_eq!(DARK.sidebar_bg, 0x141417); + // 暗色选中胶囊是蓝色渐变(3b82f6→2563eb),ui 层取上端色。 + assert_eq!(DARK.pill_selected_bg, 0x3B82F6); + } + + /// 明暗两套令牌都自洽:文字色和底色有足够对比(不与底色撞色)。 + #[test] + fn themes_keep_ink_distinct_from_surfaces() { + for theme in [&LIGHT, &DARK] { + assert_ne!(theme.ink, theme.surface); + assert_ne!(theme.ink, theme.surface_2); + assert_ne!(theme.line, theme.surface); + assert_ne!(theme.blue, theme.surface); + } + } + + #[test] + fn radii_match_tokens_css_scale() { + assert_eq!(radius::SM, 6); + assert_eq!(radius::CONTROL, 8); + assert_eq!(radius::CARD, 14); + assert_eq!(radius::PANEL, 18); + assert_eq!(radius::SHELL, 32); + assert_eq!(SIDEBAR_WIDTH, 226.0); + } +} diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index 228339b2e..a19438d90 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -9,6 +9,7 @@ mod backend; mod capabilities; mod coding_agent; mod credentials; +pub mod design_tokens; mod fcitx5; mod host_actions; mod hotkeys; @@ -21,6 +22,11 @@ mod selection; mod settings; mod single_instance; +/// 2.0 界面承接层(主题 / 组件 / 外观偏好)。egui 是 Linux 目标依赖, +/// 因此整棵子树只在 Linux 编译;颜色令牌本体在 [`design_tokens`](跨平台)。 +#[cfg(target_os = "linux")] +pub mod ui; + pub use audio::LinuxCpalRecorder; pub use backend::{LinuxBackendBuilder, LinuxBackendRuntime}; pub use capabilities::{LinuxCapabilitySnapshot, LinuxDesktopSession, LinuxPlatformApi}; diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 2aff6bae9..f48e007bf 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -21,6 +21,7 @@ mod linux_app { LocalAsrRuntime, QaStateEvent, QaStateKind, SelectionPhase, SelectionSnapshot, TranscriptAccumulator, UserPreferences, }; + use openless_linux_egui::ui::{self as ui20, UiPrefs}; use openless_linux_egui::{ drain_events, ensure_fcitx5_plugin_installed, EventDrainOutcome, Fcitx5HotkeyListener, FcitxPluginInstallPlan, FcitxPluginStatus, LinuxBackendBuilder, LinuxCapabilitySnapshot, @@ -138,6 +139,13 @@ mod linux_app { startup_error: Option, tx: mpsc::Sender, rx: mpsc::Receiver, + /// 2.0 外观偏好(深浅主题);表现层状态,不进 Core 合同。 + ui_prefs: UiPrefs, + /// 设置弹窗当前打开的分区;None = 关闭。Services / Models / Remote / + /// Settings 与其他平台一致地收纳进设置弹窗,不再是主导航页面。 + settings_modal: Option, + /// 侧栏「工具」分组是否展开(对应 React 侧栏的可展开分组)。 + tools_group_open: bool, } impl OpenLessEguiApp { @@ -193,6 +201,9 @@ mod linux_app { startup_error: None, tx, rx, + ui_prefs: UiPrefs::load(), + settings_modal: None, + tools_group_open: true, }; app.load_models(); app.load_remote_status(); @@ -240,6 +251,9 @@ mod linux_app { startup_error: Some(error), tx, rx, + ui_prefs: UiPrefs::load(), + settings_modal: None, + tools_group_open: true, }, } } @@ -823,85 +837,154 @@ mod linux_app { } fn dictation_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("听写"); + ui20::widgets::page_header( + ui, + "听写", + Some("用已配置的听写快捷键或此页控制录音;转写与润色结果实时返回。"), + ); let phase = self .snapshot .as_ref() .map(|snapshot| snapshot.dictation.phase) .unwrap_or(DictationPhase::Idle); - ui.horizontal(|ui| { - if ui - .add_enabled(phase == DictationPhase::Idle, egui::Button::new("开始")) - .clicked() - { - if let Some(backend) = self.backend() { - self.transcript.clear(); - self.spawn(async move { - backend.start_dictation().await?; - Ok("正在录音".to_string()) - }); - } - } - if ui - .add_enabled( - phase == DictationPhase::Recording, - egui::Button::new("停止"), - ) - .clicked() - { - if let Some(backend) = self.backend() { - self.spawn(async move { - let result = backend.stop_dictation().await?; - Ok(format!("完成:{} 字", result.polished_text.chars().count())) - }); - } - } - if ui - .add_enabled(phase != DictationPhase::Idle, egui::Button::new("取消")) - .clicked() - { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.cancel_dictation(None).await?; - Ok("听写已取消".to_string()) - }); - } - } + let (phase_label, phase_status) = match phase { + DictationPhase::Idle => ("空闲", ui20::widgets::Status::Info), + DictationPhase::Starting => ("启动中", ui20::widgets::Status::Info), + DictationPhase::Recording => ("录音中", ui20::widgets::Status::Warn), + DictationPhase::Transcribing => ("转写中", ui20::widgets::Status::Info), + DictationPhase::Polishing => ("润色中", ui20::widgets::Status::Info), + DictationPhase::Inserting => ("落字中", ui20::widgets::Status::Info), + DictationPhase::Completed => ("已完成", ui20::widgets::Status::Ok), + DictationPhase::Cancelled => ("已取消", ui20::widgets::Status::Warn), + DictationPhase::Failed => ("失败", ui20::widgets::Status::Err), + }; + ui20::widgets::card(ui, |ui| { + ui.horizontal(|ui| { + ui20::widgets::status_pill(ui, phase_label, phase_status); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui20::widgets::secondary_button( + ui, + "取消", + phase != DictationPhase::Idle, + ) + .clicked() + { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.cancel_dictation(None).await?; + Ok("听写已取消".to_string()) + }); + } + } + if ui20::widgets::secondary_button( + ui, + "停止", + phase == DictationPhase::Recording, + ) + .clicked() + { + if let Some(backend) = self.backend() { + self.spawn(async move { + let result = backend.stop_dictation().await?; + Ok(format!("完成:{} 字", result.polished_text.chars().count())) + }); + } + } + if ui20::widgets::primary_button(ui, "开始", phase == DictationPhase::Idle) + .clicked() + { + if let Some(backend) = self.backend() { + self.transcript.clear(); + self.spawn(async move { + backend.start_dictation().await?; + Ok("正在录音".to_string()) + }); + } + } + }); + }); }); - ui.label(if self.transcript.is_empty() { - "尚无转写结果" - } else { - &self.transcript + ui.add_space(8.0); + ui20::widgets::subtle_card(ui, |ui| { + ui20::widgets::section_title(ui, "转写结果"); + if self.transcript.is_empty() { + ui20::widgets::hint(ui, "尚无转写结果"); + } else { + ui.label(egui::RichText::new(&self.transcript).size(14.0)); + } }); } fn less_computer_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("Less Computer"); - ui.text_edit_multiline(&mut self.less_computer_input); - ui.horizontal(|ui| { - if ui.button("运行").clicked() && !self.less_computer_input.trim().is_empty() { - if let Some(backend) = self.backend() { - let prompt = self.less_computer_input.clone(); - self.less_computer_output.clear(); - self.spawn(async move { - backend.submit_less_computer(prompt).await?; - Ok("Less Computer 已完成".to_string()) - }); + ui20::widgets::page_header( + ui, + "Less Computer", + Some("向 Agent 描述任务;工具执行沿用 Core 的审批规则。"), + ); + if self.pending_approval.is_some() { + let p = ui20::current(ui.ctx()); + egui::Frame::default() + .fill(p.warn_soft()) + .corner_radius(egui::CornerRadius::same( + openless_linux_egui::design_tokens::radius::CARD, + )) + .inner_margin(egui::Margin::same(16)) + .show(ui, |ui| { + ui20::widgets::section_title(ui, "等待审批"); + self.agent_approval_ui(ui); + }); + ui.add_space(8.0); + } + ui20::widgets::card(ui, |ui| { + ui20::widgets::section_title(ui, "任务"); + ui.add( + egui::TextEdit::multiline(&mut self.less_computer_input) + .desired_rows(4) + .desired_width(f32::INFINITY), + ); + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui20::widgets::primary_button( + ui, + "运行", + !self.less_computer_input.trim().is_empty(), + ) + .clicked() + { + if let Some(backend) = self.backend() { + let prompt = self.less_computer_input.clone(); + self.less_computer_output.clear(); + self.spawn(async move { + backend.submit_less_computer(prompt).await?; + Ok("Less Computer 已完成".to_string()) + }); + } } - } - if ui.button("取消").clicked() { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.cancel_less_computer(None).await?; - Ok("Less Computer 已取消".to_string()) - }); + if ui20::widgets::secondary_button(ui, "取消", true).clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.cancel_less_computer(None).await?; + Ok("Less Computer 已取消".to_string()) + }); + } } - } + }); }); - ui.label(if self.less_computer_output.is_empty() { - "尚无 Agent 输出" - } else { - &self.less_computer_output + ui.add_space(8.0); + ui20::widgets::subtle_card(ui, |ui| { + ui20::widgets::section_title(ui, "输出"); + if self.less_computer_output.is_empty() { + ui20::widgets::hint(ui, "尚无 Agent 输出"); + } else { + ui.add( + egui::Label::new( + egui::RichText::new(&self.less_computer_output) + .size(13.5) + .monospace(), + ) + .wrap(), + ); + } }); } @@ -911,7 +994,14 @@ mod linux_app { .id_salt("approval_command") .max_height(72.0) .show(ui, |ui| { - ui.label(format!("请求执行:{command}")); + ui.add( + egui::Label::new( + egui::RichText::new(format!("请求执行:{command}")) + .size(13.5) + .monospace(), + ) + .wrap(), + ); }); ui.horizontal(|ui| { for (label, approved) in [("允许", true), ("拒绝", false)] { @@ -935,294 +1025,456 @@ mod linux_app { } fn qa_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("问答"); + ui20::widgets::page_header( + ui, + "划词问答", + Some("文字或语音提问;切换页面保留当前会话。"), + ); if !self.qa_visible { - ui.label("打开问答后可文字提问或语音提问。切换页面会保留当前会话;关闭会话使用下方的关闭操作。"); - if ui.button("打开问答").clicked() { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.services().qa.show().await?; - Ok("问答已打开".to_string()) - }); + ui20::widgets::info_panel(ui, |ui| { + ui20::widgets::section_title(ui, "打开问答后可文字提问或语音提问。"); + ui20::widgets::hint(ui, "切换页面会保留当前会话;关闭会话使用下方的关闭操作。"); + if ui20::widgets::blue_button(ui, "打开问答", true).clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().qa.show().await?; + Ok("问答已打开".to_string()) + }); + } } - } + }); return; } - if let Some(state) = &self.qa_state { - if let Some(messages) = &state.messages { - for message in messages { - ui.label(format!("{}:{}", message.role, message.content)); - } - } - if let Some(chunk) = &state.chunk { - ui.label(chunk); - } - if let Some(error) = &state.error { - ui.colored_label(egui::Color32::RED, error); - } - } - ui.text_edit_multiline(&mut self.qa_input); - ui.horizontal(|ui| { - let recording = self - .qa_state - .as_ref() - .is_some_and(|state| state.kind == QaStateKind::Recording); - if ui - .button(if recording { - "结束录音" - } else { - "语音提问" - }) - .clicked() - { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.services().qa.toggle_recording().await?; - Ok("问答录音状态已更新".to_string()) - }); - } - } - if ui.button("发送").clicked() && !self.qa_input.trim().is_empty() { - if let Some(backend) = self.backend() { - let text = std::mem::take(&mut self.qa_input); - self.spawn(async move { - backend.services().qa.submit_text(text).await?; - Ok("问答已提交".to_string()) - }); - } - } - if ui.button("关闭").clicked() { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.services().qa.dismiss().await?; - Ok("问答已关闭".to_string()) - }); - } - } - if ui.button("取消本轮").clicked() { - if let Some(backend) = self.backend() { - let session_id = self - .qa_state - .as_ref() - .and_then(|state| state.session_id.as_deref()) - .and_then(|id| uuid::Uuid::parse_str(id).ok()) - .map(openless_core::SessionId::from_uuid); - self.spawn(async move { - backend.services().qa.cancel(session_id).await?; - Ok("问答本轮已取消".to_string()) - }); - } - } + ui20::widgets::card(ui, |ui| { + egui::ScrollArea::vertical() + .id_salt("qa_messages") + .max_height((ui.available_height() - 96.0).max(160.0)) + .auto_shrink([false, false]) + .show(ui, |ui| { + let mut last_was_assistant = false; + if let Some(state) = &self.qa_state { + if let Some(messages) = &state.messages { + for message in messages { + Self::qa_bubble(ui, &message.role, &message.content); + last_was_assistant = message.role == "assistant"; + } + } + } + // 流式增量与错误行紧跟在最后一条消息之后。 + if let Some(state) = &self.qa_state { + if let Some(chunk) = &state.chunk { + if !chunk.is_empty() && !last_was_assistant { + Self::qa_bubble(ui, "assistant", chunk); + } + } + if let Some(error) = &state.error { + ui20::widgets::status_pill(ui, error, ui20::widgets::Status::Err); + } + match state.kind { + QaStateKind::Recording => { + ui20::widgets::status_pill( + ui, + "录音中…", + ui20::widgets::Status::Warn, + ); + } + QaStateKind::Thinking => { + ui20::widgets::status_pill( + ui, + "思考中…", + ui20::widgets::Status::Info, + ); + } + _ => {} + } + } + }); }); - } - - fn selection_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("选区润色"); - let Some(selection) = self.selection.clone() else { - ui.label("先在目标应用中选中文字,再使用已配置的选区润色快捷键。预览会在此显示,确认前可以编辑或取消。"); - ui.small("此入口是现有 Selection polish;Selection Voice 的完整意图路由尚未接入。"); - return; - }; - ui.label(format!("当前状态:{:?}", selection.phase)); - if self.selection_preview_visible && selection.phase == SelectionPhase::Preview { - ui.strong("选区预览"); - ui.text_edit_multiline(&mut self.selection_draft); + ui.add_space(8.0); + ui20::widgets::subtle_card(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut self.qa_input) + .desired_rows(2) + .desired_width(f32::INFINITY) + .hint_text("输入问题,Enter 发送"), + ); + ui.add_space(6.0); ui.horizontal(|ui| { - if ui.button("确认替换").clicked() { - if let (Some(backend), Some(session_id)) = - (self.backend(), selection.session_id) - { - let text = self.selection_draft.clone(); + let recording = self + .qa_state + .as_ref() + .is_some_and(|state| state.kind == QaStateKind::Recording); + if ui20::widgets::secondary_button( + ui, + if recording { + "结束录音" + } else { + "语音提问" + }, + true, + ) + .clicked() + { + if let Some(backend) = self.backend() { self.spawn(async move { - backend - .services() - .selection - .confirm(session_id, Some(text)) - .await?; - Ok("选区替换已确认".to_string()) + backend.services().qa.toggle_recording().await?; + Ok("问答录音状态已更新".to_string()) }); } } - if ui.button("取消").clicked() { - if let (Some(backend), Some(session_id)) = - (self.backend(), selection.session_id) - { + if ui20::widgets::primary_button(ui, "发送", !self.qa_input.trim().is_empty()) + .clicked() + { + if let Some(backend) = self.backend() { + let text = std::mem::take(&mut self.qa_input); self.spawn(async move { - backend - .services() - .selection - .cancel(Some(session_id)) - .await?; - Ok("选区替换已取消".to_string()) + backend.services().qa.submit_text(text).await?; + Ok("问答已提交".to_string()) }); } } - }); - } else if selection.phase == SelectionPhase::Completed - && selection.revert_outcome.is_none() - { - ui.horizontal(|ui| { - ui.label("最近一次选区替换已完成"); - if ui.button("撤销").clicked() { - if let (Some(backend), Some(session_id)) = - (self.backend(), selection.session_id) - { + if ui20::widgets::secondary_button(ui, "取消本轮", true).clicked() { + if let Some(backend) = self.backend() { + let session_id = self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + .and_then(|id| uuid::Uuid::parse_str(id).ok()) + .map(openless_core::SessionId::from_uuid); self.spawn(async move { - backend.services().selection.revert(session_id).await?; - Ok("选区替换已撤销".to_string()) + backend.services().qa.cancel(session_id).await?; + Ok("问答本轮已取消".to_string()) }); } } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui20::widgets::danger_button(ui, "关闭会话", true).clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().qa.dismiss().await?; + Ok("问答已关闭".to_string()) + }); + } + } + }); }); - } + }); + } + + /// QA 消息气泡:用户右对齐(蓝色软底)、助手左对齐(surface-2 软底), + /// 对齐 #997 egui 弹窗与 React QaPanel 的双气泡布局。 + fn qa_bubble(ui: &mut egui::Ui, role: &str, content: &str) { + let p = ui20::current(ui.ctx()); + let user = role.eq_ignore_ascii_case("user"); + let align = if user { + egui::Layout::right_to_left(egui::Align::Min) + } else { + egui::Layout::left_to_right(egui::Align::Min) + }; + ui.with_layout(align, |ui| { + let max_width = ui.available_width() * 0.78; + egui::Frame::default() + .fill(if user { p.blue_soft() } else { p.surface_2() }) + .corner_radius(egui::CornerRadius::same( + openless_linux_egui::design_tokens::radius::MD, + )) + .inner_margin(egui::Margin::symmetric(12, 8)) + .show(ui, |ui| { + ui.set_max_width(max_width); + ui.add( + egui::Label::new( + egui::RichText::new(content).size(14.0).color(if user { + p.ink() + } else { + p.ink_2() + }), + ) + .wrap(), + ); + }); + }); + ui.add_space(2.0); + } + + fn selection_ui(&mut self, ui: &mut egui::Ui) { + ui20::widgets::page_header( + ui, + "选区润色", + Some("在目标应用选中文字后用选区润色快捷键触发;确认前可编辑或取消。"), + ); + let Some(selection) = self.selection.clone() else { + ui20::widgets::info_panel(ui, |ui| { + ui20::widgets::section_title( + ui, + "先在目标应用中选中文字,再使用已配置的选区润色快捷键。", + ); + ui20::widgets::hint( + ui, + "预览会在此显示,确认前可以编辑或取消。此入口是现有 Selection polish;Selection Voice 的完整意图路由尚未接入。", + ); + }); + return; + }; + ui20::widgets::card(ui, |ui| { + ui.horizontal(|ui| { + let (phase_label, phase_status) = match selection.phase { + SelectionPhase::Preview => ("待确认", ui20::widgets::Status::Warn), + SelectionPhase::Completed => ("已完成", ui20::widgets::Status::Ok), + _ => ("进行中", ui20::widgets::Status::Info), + }; + ui20::widgets::status_pill(ui, phase_label, phase_status); + }); + ui.add_space(6.0); + if self.selection_preview_visible && selection.phase == SelectionPhase::Preview { + ui20::widgets::section_title(ui, "选区预览"); + ui.add( + egui::TextEdit::multiline(&mut self.selection_draft) + .desired_rows(5) + .desired_width(f32::INFINITY), + ); + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui20::widgets::primary_button(ui, "确认替换", true).clicked() { + if let (Some(backend), Some(session_id)) = + (self.backend(), selection.session_id) + { + let text = self.selection_draft.clone(); + self.spawn(async move { + backend + .services() + .selection + .confirm(session_id, Some(text)) + .await?; + Ok("选区替换已确认".to_string()) + }); + } + } + if ui20::widgets::secondary_button(ui, "取消", true).clicked() { + if let (Some(backend), Some(session_id)) = + (self.backend(), selection.session_id) + { + self.spawn(async move { + backend + .services() + .selection + .cancel(Some(session_id)) + .await?; + Ok("选区替换已取消".to_string()) + }); + } + } + }); + } else if selection.phase == SelectionPhase::Completed + && selection.revert_outcome.is_none() + { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("最近一次选区替换已完成").size(14.0)); + if ui20::widgets::secondary_button(ui, "撤销", true).clicked() { + if let (Some(backend), Some(session_id)) = + (self.backend(), selection.session_id) + { + self.spawn(async move { + backend.services().selection.revert(session_id).await?; + Ok("选区替换已撤销".to_string()) + }); + } + } + }); + } + }); } fn models_ui(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { - ui.heading("本地模型"); - if ui.button("刷新").clicked() { - self.models = ModelsState::Loading; - self.load_models(); - } + ui20::widgets::page_header(ui, "本地模型", None); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui20::widgets::secondary_button(ui, "刷新", true).clicked() { + self.models = ModelsState::Loading; + self.load_models(); + } + }); }); let models = match self.models.clone() { ModelsState::Loading => { - ui.label("正在加载模型目录…"); + ui20::widgets::hint(ui, "正在加载模型目录…"); return; } ModelsState::Failed(error) => { - ui.colored_label(egui::Color32::RED, error); + ui20::widgets::status_pill(ui, &error, ui20::widgets::Status::Err); return; } ModelsState::Loaded(models) if models.is_empty() => { - ui.label("模型目录未返回任何可用模型"); + ui20::widgets::info_panel(ui, |ui| { + ui20::widgets::hint(ui, "模型目录未返回任何可用模型"); + }); return; } ModelsState::Loaded(models) => models, }; for model in models { - ui.horizontal(|ui| { - ui.label(format!( - "{} · {} · {}", - model.display_name, - model.family, - if model.installed { - "已安装" - } else { - "未安装" - } - )); - if !model.installed && ui.button("下载").clicked() { - if let Some(backend) = self.backend() { - let target = model.target.clone(); - self.spawn(async move { - backend - .services() - .local_asr - .start_download(target, None) - .await?; - Ok("模型下载完成".to_string()) - }); - } - } - if model.installed && ui.button("激活").clicked() { - if let Some(backend) = self.backend() { - let target = model.target.clone(); - self.spawn(async move { - let descriptor = - openless_core::provider_rules::provider_descriptor( - openless_core::ProviderKind::Asr, - "local-qwen3-c", - ) - .ok_or_else(|| { - openless_core::BackendError::new( - openless_core::BackendErrorCode::Unsupported, - "local Qwen provider is unavailable", - ) - })?; - let provider_type = descriptor.provider_type.as_str().to_string(); - let existing = backend - .list_channels(openless_core::ChannelKind::Asr) - .await? - .into_iter() - .find(|channel| channel.provider_type == provider_type) - .map(|channel| channel.id); - let provider_id = match existing { - Some(provider_id) => provider_id, - None => { + ui20::widgets::card(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&model.display_name).size(15.0).strong()); + ui20::widgets::status_pill( + ui, + if model.installed { + "已安装" + } else { + "未安装" + }, + if model.installed { + ui20::widgets::Status::Ok + } else { + ui20::widgets::Status::Info + }, + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui20::widgets::secondary_button(ui, "取消下载", true).clicked() { + if let Some(backend) = self.backend() { + let target = model.target.clone(); + self.spawn(async move { backend - .create_channel( - openless_core::ChannelKind::Asr, - provider_type, - descriptor.label_key, + .services() + .local_asr + .cancel_download(target) + .await?; + Ok("模型下载已取消".to_string()) + }); + } + } + if model.installed + && ui20::widgets::primary_button(ui, "激活", true).clicked() + { + if let Some(backend) = self.backend() { + let target = model.target.clone(); + self.spawn(async move { + let descriptor = + openless_core::provider_rules::provider_descriptor( + openless_core::ProviderKind::Asr, + "local-qwen3-c", ) + .ok_or_else(|| { + openless_core::BackendError::new( + openless_core::BackendErrorCode::Unsupported, + "local Qwen provider is unavailable", + ) + })?; + let provider_type = + descriptor.provider_type.as_str().to_string(); + let existing = backend + .list_channels(openless_core::ChannelKind::Asr) .await? - } - }; - backend - .activate_local_asr(openless_core::LocalAsrActivationRequest { - target, - provider_id, - }) - .await?; - Ok("本地模型已激活并预加载".to_string()) - }); - } + .into_iter() + .find(|channel| channel.provider_type == provider_type) + .map(|channel| channel.id); + let provider_id = match existing { + Some(provider_id) => provider_id, + None => { + backend + .create_channel( + openless_core::ChannelKind::Asr, + provider_type, + descriptor.label_key, + ) + .await? + } + }; + backend + .activate_local_asr( + openless_core::LocalAsrActivationRequest { + target, + provider_id, + }, + ) + .await?; + Ok("本地模型已激活并预加载".to_string()) + }); + } + } + if !model.installed + && ui20::widgets::blue_button(ui, "下载", true).clicked() + { + if let Some(backend) = self.backend() { + let target = model.target.clone(); + self.spawn(async move { + backend + .services() + .local_asr + .start_download(target, None) + .await?; + Ok("模型下载完成".to_string()) + }); + } + } + }); + }); + ui20::widgets::divider(ui); + ui20::widgets::kv_row(ui, "模型家族", &model.family); + if let Some(mode) = &model.mode { + ui20::widgets::kv_row(ui, "模式", mode); } - if ui.button("取消").clicked() { - if let Some(backend) = self.backend() { - let target = model.target.clone(); - self.spawn(async move { - backend.services().local_asr.cancel_download(target).await?; - Ok("模型下载已取消".to_string()) - }); - } + if !model.languages.is_empty() { + ui20::widgets::kv_row(ui, "支持语言", &model.languages.join(" / ")); + } + if let Some(size) = model.size_bytes { + ui20::widgets::kv_row(ui, "体积", &format!("{} MB", size / (1024 * 1024))); } }); + ui.add_space(2.0); } } fn provider_management_ui(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { - ui.strong("凭据渠道"); - for (kind, label) in [ - (openless_core::ChannelKind::Asr, "ASR"), - (openless_core::ChannelKind::Llm, "LLM"), - ] { - if ui - .selectable_label(self.provider_kind == kind, label) - .clicked() - && self.provider_kind != kind - { - self.provider_kind = kind; + ui20::widgets::section_title(ui, "凭据渠道"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui20::widgets::secondary_button(ui, "刷新渠道", true).clicked() { self.providers = ProvidersState::Loading; - self.selected_channel_id = None; - self.pending_channel_delete = None; - self.provider_editor = ProviderEditorState::Idle; - self.provider_models.clear(); - self.load_providers(kind); + self.load_providers(self.provider_kind); } - } - if ui.button("刷新渠道").clicked() { + }); + }); + ui.add_space(4.0); + let kind_index = match self.provider_kind { + openless_core::ChannelKind::Asr => 0, + openless_core::ChannelKind::Llm => 1, + }; + if let Some(picked) = ui20::widgets::segmented( + ui, + "provider-kind", + &["ASR 语音识别", "LLM 文本处理"], + kind_index, + ) { + let kind = match picked { + 0 => openless_core::ChannelKind::Asr, + _ => openless_core::ChannelKind::Llm, + }; + if self.provider_kind != kind { + self.provider_kind = kind; self.providers = ProvidersState::Loading; - self.load_providers(self.provider_kind); + self.selected_channel_id = None; + self.pending_channel_delete = None; + self.provider_editor = ProviderEditorState::Idle; + self.provider_models.clear(); + self.load_providers(kind); } - }); + } + ui.add_space(6.0); let panel = match self.providers.clone() { ProvidersState::Loading => { - ui.label("正在读取 Core 渠道目录…"); + ui20::widgets::hint(ui, "正在读取 Core 渠道目录…"); return; } ProvidersState::Failed(error) => { - ui.colored_label(egui::Color32::RED, error); + ui20::widgets::status_pill(ui, &error, ui20::widgets::Status::Err); return; } ProvidersState::Loaded(panel) => panel, }; - ui.group(|ui| { - ui.label("新增渠道"); + ui20::widgets::subtle_card(ui, |ui| { + ui20::widgets::section_title(ui, "新增渠道"); ui.horizontal(|ui| { egui::ComboBox::from_id_salt("new-provider-type") .selected_text( @@ -1485,28 +1737,52 @@ mod linux_app { } fn services_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("AI 服务"); - ui.label("选择 ASR 语音识别、LLM 文本处理或 Omni 服务,再编辑并校验渠道。已配置不代表网络请求已通过。"); + ui20::widgets::page_header( + ui, + "AI 服务", + Some("选择 ASR 语音识别、LLM 文本处理或 Omni 服务,再编辑并校验渠道。已配置不代表网络请求已通过。"), + ); if let Some(snapshot) = &self.snapshot { let credentials = &snapshot.credentials; - ui.label(format!( - "ASR:{}({})", - credentials.active_asr_provider, - if credentials.asr_configured { - "已配置" - } else { - "未配置" - } - )); - ui.label(format!( - "LLM:{}({})", - credentials.active_llm_provider, - if credentials.llm_configured { - "已配置" - } else { - "未配置" - } - )); + ui20::widgets::card(ui, |ui| { + ui.horizontal(|ui| { + ui20::widgets::status_pill( + ui, + &format!( + "ASR:{}({})", + credentials.active_asr_provider, + if credentials.asr_configured { + "已配置" + } else { + "未配置" + } + ), + if credentials.asr_configured { + ui20::widgets::Status::Ok + } else { + ui20::widgets::Status::Warn + }, + ); + ui20::widgets::status_pill( + ui, + &format!( + "LLM:{}({})", + credentials.active_llm_provider, + if credentials.llm_configured { + "已配置" + } else { + "未配置" + } + ), + if credentials.llm_configured { + ui20::widgets::Status::Ok + } else { + ui20::widgets::Status::Warn + }, + ); + }); + }); + ui.add_space(8.0); } self.provider_management_ui(ui); } @@ -1540,10 +1816,11 @@ mod linux_app { fn settings_actions_ui(&mut self, ui: &mut egui::Ui) { ui.horizontal_wrapped(|ui| { - if ui.button("保存设置").clicked() { + if ui20::widgets::primary_button(ui, "保存设置", true).clicked() { self.save_preferences(); } - if ui.button("放弃修改并重新读取").clicked() { + if ui20::widgets::secondary_button(ui, "放弃修改并重新读取", true).clicked() + { if let Some(backend) = self.backend() { self.preferences = Some(backend.get_preferences()); self.snapshot = Some(backend.snapshot()); @@ -1551,189 +1828,313 @@ mod linux_app { } } }); - ui.small( + ui20::widgets::hint( + ui, "环境与设置、手机输入共用设置草稿;保存会一起应用。保存冲突时可重新读取后再修改。", ); } fn settings_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("环境与设置"); - ui.strong("现有功能设置"); - if let Some(preferences) = self.preferences.as_mut() { - ui.checkbox(&mut preferences.streaming_insert, "流式插入"); - ui.small("将转写逐步发送到原输入目标,实际结果以听写与历史反馈为准。"); - ui.checkbox(&mut preferences.coding_agent_enabled, "启用 Less Computer"); - ui.small("使用已有 Agent 配置与 CLI;进程执行仍遵循 Core 的审批规则。"); - self.settings_actions_ui(ui); - } - ui.horizontal_wrapped(|ui| { - if ui.button("配置 AI 服务").clicked() { - self.navigation.open(Page::Services); + ui20::widgets::page_header( + ui, + "环境与设置", + Some("环境与设置、手机输入共用设置草稿;保存会一起应用。"), + ); + ui20::widgets::card(ui, |ui| { + ui20::widgets::section_title(ui, "外观"); + let mut dark = self.ui_prefs.dark; + ui20::widgets::toggle_row(ui, &mut dark, "深色主题", Some("2.0 默认浅色")); + if dark != self.ui_prefs.dark { + self.ui_prefs.dark = dark; + self.ui_prefs.save(); + ui20::apply_theme(ui.ctx(), dark); } - if ui.button("设置手机输入").clicked() { - self.navigation.open(Page::Remote); + }); + ui.add_space(8.0); + ui20::widgets::card(ui, |ui| { + ui20::widgets::section_title(ui, "现有功能设置"); + if let Some(preferences) = self.preferences.as_mut() { + let mut streaming = preferences.streaming_insert; + ui20::widgets::toggle_row( + ui, + &mut streaming, + "流式插入", + Some("将转写逐步发送到原输入目标,实际结果以听写与历史反馈为准。"), + ); + preferences.streaming_insert = streaming; + let mut agent = preferences.coding_agent_enabled; + ui20::widgets::toggle_row( + ui, + &mut agent, + "启用 Less Computer", + Some("使用已有 Agent 配置与 CLI;进程执行仍遵循 Core 的审批规则。"), + ); + preferences.coding_agent_enabled = agent; + ui.add_space(4.0); + self.settings_actions_ui(ui); + } else { + ui20::widgets::hint(ui, "Core 未连接,暂无可编辑设置。"); } + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui20::widgets::secondary_button(ui, "配置 AI 服务", true).clicked() { + self.settings_modal = Some(Page::Services); + } + if ui20::widgets::secondary_button(ui, "设置手机输入", true).clicked() { + self.settings_modal = Some(Page::Remote); + } + if ui20::widgets::secondary_button(ui, "管理本地模型", true).clicked() { + self.settings_modal = Some(Page::Models); + } + }); + ui20::widgets::hint( + ui, + "托盘、自启、自动更新、系统静音与额外全局热键尚未完整接入,此页没有对应开关。", + ); }); - ui.small( - "托盘、自启、自动更新、系统静音与额外全局热键尚未完整接入,此页没有对应开关。", - ); - ui.separator(); + ui.add_space(8.0); self.environment_ui(ui); } fn remote_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("手机输入"); - ui.label( - "先启用并保存,再让手机连接同一局域网,打开本机提供的 HTTPS 地址并输入配对码。", + ui20::widgets::page_header( + ui, + "手机输入", + Some( + "先启用并保存,再让手机连接同一局域网,打开本机提供的 HTTPS 地址并输入配对码。", + ), ); - ui.label("首次连接需要确认并信任本服务的证书;服务运行不代表手机已连接。"); - if let Some(preferences) = self.preferences.as_mut() { - ui.checkbox(&mut preferences.remote_input_enabled, "启用远程输入"); - ui.add( - egui::DragValue::new(&mut preferences.remote_input_port) - .range(1..=u16::MAX) - .prefix("端口 "), - ); - self.settings_actions_ui(ui); - } - ui.separator(); - if ui.button("刷新连接状态").clicked() { - self.load_remote_status(); - } - if let Some(error) = &self.remote_error { - ui.colored_label( - egui::Color32::YELLOW, - format!("暂时无法读取连接状态:{error}"), + ui20::widgets::card(ui, |ui| { + ui20::widgets::section_title(ui, "远程输入"); + ui.label( + egui::RichText::new( + "首次连接需要确认并信任本服务的证书;服务运行不代表手机已连接。", + ) + .size(13.0), ); - ui.label("检查桌面密钥环、网络和端口后刷新;旧地址与配对码已隐藏。"); - } else if self.remote_access.is_none() { - ui.label("尚未取得手机输入状态。"); - } - if let Some((remote, pin)) = &self.remote_access { - ui.label(if remote.running { - "远程输入服务:运行中" - } else if remote.starting { - "远程输入服务:启动中" + ui.add_space(4.0); + if let Some(preferences) = self.preferences.as_mut() { + let mut enabled = preferences.remote_input_enabled; + ui20::widgets::toggle_row(ui, &mut enabled, "启用远程输入", None); + preferences.remote_input_enabled = enabled; + ui.horizontal(|ui| { + ui.label(egui::RichText::new("端口").size(14.0)); + ui.add( + egui::DragValue::new(&mut preferences.remote_input_port) + .range(1..=u16::MAX), + ); + }); + self.settings_actions_ui(ui); } else { - "远程输入服务:已停止" - }); - ui.label(format!("当前连接数:{}", remote.connection_count)); - if remote.active_session_id.is_some() { - ui.label("手机语音会话进行中,可使用顶部的语音取消。"); + ui20::widgets::hint(ui, "Core 未连接,暂不可配置远程输入。"); } - if remote.urls_stale { - ui.colored_label( - egui::Color32::YELLOW, - "网络地址已过期,请检查网络后刷新状态。", + }); + ui.add_space(8.0); + ui20::widgets::card(ui, |ui| { + ui.horizontal(|ui| { + ui20::widgets::section_title(ui, "连接状态"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui20::widgets::secondary_button(ui, "刷新连接状态", true).clicked() + { + self.load_remote_status(); + } + }); + }); + ui.add_space(4.0); + if let Some(error) = &self.remote_error { + ui20::widgets::status_pill( + ui, + &format!("暂时无法读取连接状态:{error}"), + ui20::widgets::Status::Warn, ); - } - if remote.enabled && remote.running && !remote.urls_stale { - // 首次信任前必须核对根证书指纹:网页与描述文件名称不能证明身份。 - ui.label("本机根证书 SHA-256"); - match remote.ca_fingerprint_sha256.as_ref().filter(|value| { - value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) - }) { - Some(fingerprint) => { - let display = fingerprint - .as_bytes() - .chunks(2) - .map(|pair| std::str::from_utf8(pair).unwrap().to_ascii_uppercase()) - .collect::>() - .join(" "); - ui.add(egui::Label::new(egui::RichText::new(&display).monospace()).wrap()); - if ui.button("复制完整指纹").clicked() { - ui.ctx().copy_text(display); + ui20::widgets::hint( + ui, + "检查桌面密钥环、网络和端口后刷新;旧地址与配对码已隐藏。", + ); + } else if self.remote_access.is_none() { + ui20::widgets::hint(ui, "尚未取得手机输入状态。"); + } + if let Some((remote, pin)) = &self.remote_access { + let running_status = if remote.running { + ("运行中", ui20::widgets::Status::Ok) + } else if remote.starting { + ("启动中", ui20::widgets::Status::Info) + } else { + ("已停止", ui20::widgets::Status::Warn) + }; + ui.horizontal(|ui| { + ui20::widgets::status_pill(ui, "远程输入服务", running_status.1); + ui.label(egui::RichText::new(running_status.0).size(13.0)); + }); + ui20::widgets::kv_row(ui, "当前连接数", &remote.connection_count.to_string()); + if remote.active_session_id.is_some() { + ui20::widgets::hint(ui, "手机语音会话进行中,可使用语音取消。"); + } + if remote.urls_stale { + ui20::widgets::status_pill( + ui, + "网络地址已过期,请检查网络后刷新状态", + ui20::widgets::Status::Warn, + ); + } + if remote.enabled && remote.running && !remote.urls_stale { + // 首次信任前必须核对根证书指纹:网页与描述文件名称不能证明身份。 + ui.label("本机根证书 SHA-256"); + match remote.ca_fingerprint_sha256.as_ref().filter(|value| { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) { + Some(fingerprint) => { + let display = fingerprint + .as_bytes() + .chunks(2) + .map(|pair| { + std::str::from_utf8(pair).unwrap().to_ascii_uppercase() + }) + .collect::>() + .join(" "); + ui.add( + egui::Label::new(egui::RichText::new(&display).monospace()) + .wrap(), + ); + if ui.button("复制完整指纹").clicked() { + ui.ctx().copy_text(display); + } } + // 两臂统一为 ():None 臂不能返回 Response(match 臂类型 + // 必须一致;这是 #1048 移植后未被编译验证过的潜伏错误)。 + None => { + ui.label("完整指纹不可用。请勿安装或信任下载的证书。"); + } + } + ui.label("安装或开启完全信任前,在手机系统的证书详情中核对全部 SHA-256 字符,必须与此处一致。网页、描述文件名称和标识不能证明证书身份。若不一致或无法查看,请停止并移除已下载或安装的描述文件。"); + ui.label("描述文件应只包含一张根证书。若有其他证书、VPN 或设备管理配置,请勿安装。首次下载仍可能被局域网攻击者替换;核验后再信任。根证书可签发其他证书,不再使用时请移除。"); + ui.monospace(format!("PIN:{pin}")); + for url in &remote.urls { + ui.monospace(url); + } + if remote.urls.is_empty() { + ui.label("服务已启动,但尚未提供可用地址;请检查本机局域网连接。"); } - None => ui.label("完整指纹不可用。请勿安装或信任下载的证书。"), } - ui.label("安装或开启完全信任前,在手机系统的证书详情中核对全部 SHA-256 字符,必须与此处一致。网页、描述文件名称和标识不能证明证书身份。若不一致或无法查看,请停止并移除已下载或安装的描述文件。"); - ui.label("描述文件应只包含一张根证书。若有其他证书、VPN 或设备管理配置,请勿安装。首次下载仍可能被局域网攻击者替换;核验后再信任。根证书可签发其他证书,不再使用时请移除。"); - ui.monospace(format!("PIN:{pin}")); - for url in &remote.urls { - ui.monospace(url); - } - if remote.urls.is_empty() { - ui.label("服务已启动,但尚未提供可用地址;请检查本机局域网连接。"); - } - } - if remote.enabled && ui.button("重置配对码").clicked() { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend - .services() - .remote_input - .regenerate_pairing_pin() - .await?; - Ok("远程输入配对码已重置".to_string()) - }); + if remote.enabled && ui.button("重置配对码").clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend + .services() + .remote_input + .regenerate_pairing_pin() + .await?; + Ok("远程输入配对码已重置".to_string()) + }); + } } } - } + }); } fn environment_ui(&mut self, ui: &mut egui::Ui) { - ui.strong("Linux 环境准备"); - ui.label(if self.native.is_some() { - "Core 已连接。下面的环境检查不代表录音、落字或服务调用已经实测成功。" - } else { - "Core 未连接。可查看准备步骤;修复启动问题后,请退出并重新启动 OpenLess。" - }); - if let Some(environment) = &self.environment { - ui.label(match environment.session { - LinuxDesktopSession::X11 => "桌面会话:检测到 X11 环境", - LinuxDesktopSession::Wayland => "桌面会话:检测到 Wayland 环境", - LinuxDesktopSession::Headless => "桌面会话:未检测到 DISPLAY / WAYLAND_DISPLAY", - }); - ui.label(if environment.fcitx5_ready { - "fcitx5:D-Bus 探测有响应,插件加载、快捷键与目标应用落字仍需实际操作确认。" + ui20::widgets::card(ui, |ui| { + ui20::widgets::section_title(ui, "Linux 环境准备"); + let connected = self.native.is_some(); + ui20::widgets::status_pill( + ui, + if connected { + "Core 已连接" + } else { + "Core 未连接" + }, + if connected { + ui20::widgets::Status::Ok + } else { + ui20::widgets::Status::Warn + }, + ); + ui20::widgets::hint( + ui, + if connected { + "下面的环境检查不代表录音、落字或服务调用已经实测成功。" + } else { + "可查看准备步骤;修复启动问题后,请退出并重新启动 OpenLess。" + }, + ); + ui20::widgets::divider(ui); + if let Some(environment) = &self.environment { + ui20::widgets::kv_row( + ui, + "桌面会话", + match environment.session { + LinuxDesktopSession::X11 => "检测到 X11 环境", + LinuxDesktopSession::Wayland => "检测到 Wayland 环境", + LinuxDesktopSession::Headless => "未检测到 DISPLAY / WAYLAND_DISPLAY", + }, + ); + ui20::widgets::kv_row( + ui, + "fcitx5", + if environment.fcitx5_ready { + "D-Bus 探测有响应;插件加载、快捷键与落字仍需实际确认" + } else { + "D-Bus 探测未通过,可能是会话总线、服务或插件未就绪" + }, + ); + ui20::widgets::kv_row( + ui, + "麦克风", + match environment.permissions.microphone { + openless_core::PermissionState::Unsupported => { + "当前探测环境不支持;请进入图形桌面会话" + } + _ => "尚未验证录音;请在系统声音设置选择输入设备后短听写", + }, + ); } else { - "fcitx5:D-Bus 探测未通过,可能是会话总线、服务或插件未就绪。" - }); - ui.label(match environment.permissions.microphone { - openless_core::PermissionState::Unsupported => { - "麦克风:当前探测环境不支持;请进入图形桌面会话。" - } - _ => "麦克风:尚未验证录音。请在系统声音设置选择输入设备,再进行一次短听写。", - }); - } else { - ui.label("尚未取得桌面环境探测结果。"); - } - ui.label(match &self.plugin_check { - Some(Ok(FcitxPluginStatus::Ready)) => { - "本次启动插件检查:找到插件文件;文件存在不代表 fcitx5 已加载它。" - } - Some(Ok(FcitxPluginStatus::Updated)) => { - "本次启动插件检查:插件文件已安装或更新,需要重载配置并重新启动 fcitx5。" - } - Some(Ok(FcitxPluginStatus::Missing)) => { - "本次启动插件检查:未找到插件文件,请重新安装含 OpenLess 插件的软件包。" + ui20::widgets::hint(ui, "尚未取得桌面环境探测结果。"); + } + ui20::widgets::kv_row( + ui, + "本次启动插件检查", + match &self.plugin_check { + Some(Ok(FcitxPluginStatus::Ready)) => { + "找到插件文件;文件存在不代表 fcitx5 已加载它" + } + Some(Ok(FcitxPluginStatus::Updated)) => { + "插件文件已安装或更新,需要重载配置并重新启动 fcitx5" + } + Some(Ok(FcitxPluginStatus::Missing)) => { + "未找到插件文件,请重新安装含 OpenLess 插件的软件包" + } + Some(Err(_)) => "检查失败,请查看下方具体原因", + None => "未执行", + }, + ); + if let Some(Err(error)) = &self.plugin_check { + ui20::widgets::status_pill(ui, error, ui20::widgets::Status::Warn); } - Some(Err(_)) => "本次启动插件检查:检查失败,请查看下方具体原因。", - None => "本次启动插件检查:未执行。", - }); - if let Some(Err(error)) = &self.plugin_check { - ui.colored_label(egui::Color32::YELLOW, error); - } - if ui - .add_enabled( - !self.environment_refreshing, - egui::Button::new(if self.environment_refreshing { + ui.add_space(4.0); + if ui20::widgets::secondary_button( + ui, + if self.environment_refreshing { "正在检测…" } else { "重新检测会话与 D-Bus" - }), + }, + !self.environment_refreshing, ) .clicked() - { - self.environment_refreshing = true; - let tx = self.tx.clone(); - self.tokio.spawn_blocking(move || { - let environment = LinuxCapabilitySnapshot::detect(false, package_kind()); - let _ = tx.send(UiResult::Environment(environment)); - }); - } - ui.small("重新检测只更新上面的会话与 D-Bus 信息,不安装插件,也不重新连接 Core。本次启动检查结果保留到退出。"); + { + self.environment_refreshing = true; + let tx = self.tx.clone(); + self.tokio.spawn_blocking(move || { + let environment = LinuxCapabilitySnapshot::detect(false, package_kind()); + let _ = tx.send(UiResult::Environment(environment)); + }); + } + ui20::widgets::hint( + ui, + "重新检测只更新上面的会话与 D-Bus 信息,不安装插件,也不重新连接 Core。本次启动检查结果保留到退出。", + ); + }); + ui.add_space(6.0); egui::CollapsingHeader::new("准备步骤与官方指南") .default_open(self.native.is_none()) .show(ui, |ui| { @@ -1772,83 +2173,123 @@ mod linux_app { } fn start_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("从一次听写开始"); - ui.label("先准备 Linux 输入环境,再选择识别服务。切换页面不会停止正在进行的任务。"); + ui20::widgets::page_header( + ui, + "从一次听写开始", + Some("先准备 Linux 输入环境,再选择识别服务。切换页面不会停止正在进行的任务。"), + ); if let Some(error) = &self.startup_error { - ui.colored_label(egui::Color32::YELLOW, format!("启动未完成:{error}")); + ui20::widgets::status_pill( + ui, + &format!("启动未完成:{error}"), + ui20::widgets::Status::Warn, + ); + ui.add_space(6.0); } if let Some(snapshot) = &self.snapshot { - ui.label(if snapshot.running { - "Core:运行中" - } else { - "Core:未运行" - }); - let credentials = &snapshot.credentials; - match credentials.pipeline_mode { - openless_core::shared_types::PipelineMode::Multimodal => { - ui.label("当前管线:多模态(Omni)"); - ui.label(if credentials.omni_configured { - "Omni:已配置。" - } else { - "Omni:尚未配置,请到 AI 服务配置 Omni。" - }); - } - openless_core::shared_types::PipelineMode::Traditional => { - ui.label("当前管线:传统(ASR + LLM)"); - ui.label(if credentials.asr_configured { - "ASR 语音识别:已配置。" - } else { - "ASR 语音识别:尚未配置,请配置 AI 服务或激活本地模型。" - }); - ui.label(if credentials.llm_configured { - "LLM 润色:已配置。" - } else { - "LLM 润色:尚未配置。" - }); + ui20::widgets::card(ui, |ui| { + ui.horizontal(|ui| { + ui20::widgets::status_pill( + ui, + if snapshot.running { + "Core 运行中" + } else { + "Core 未运行" + }, + if snapshot.running { + ui20::widgets::Status::Ok + } else { + ui20::widgets::Status::Warn + }, + ); + }); + ui20::widgets::divider(ui); + let credentials = &snapshot.credentials; + match credentials.pipeline_mode { + openless_core::shared_types::PipelineMode::Multimodal => { + ui20::widgets::kv_row(ui, "当前管线", "多模态(Omni)"); + ui20::widgets::kv_row( + ui, + "Omni", + if credentials.omni_configured { + "已配置" + } else { + "尚未配置,请到 AI 服务配置 Omni" + }, + ); + } + openless_core::shared_types::PipelineMode::Traditional => { + ui20::widgets::kv_row(ui, "当前管线", "传统(ASR + LLM)"); + ui20::widgets::kv_row( + ui, + "ASR 语音识别", + if credentials.asr_configured { + "已配置" + } else { + "尚未配置,请配置 AI 服务或激活本地模型" + }, + ); + ui20::widgets::kv_row( + ui, + "LLM 润色", + if credentials.llm_configured { + "已配置" + } else { + "尚未配置" + }, + ); + } } - } - ui.small("已配置不代表校验通过;请到 AI 服务验证连接。"); + ui20::widgets::hint(ui, "已配置不代表校验通过;请到 AI 服务验证连接。"); + }); + ui.add_space(8.0); } - ui.horizontal_wrapped(|ui| { - for (page, label) in [ - (Page::Settings, "1. 准备环境"), - (Page::Services, "2. 配置 AI 服务"), - (Page::Models, "使用本地模型"), - (Page::Dictation, "3. 打开听写"), - ] { - if ui - .add_enabled( - self.native.is_some() || page == Page::Settings, - egui::Button::new(label), - ) - .clicked() - { - self.navigation.open(page); + ui20::widgets::subtle_card(ui, |ui| { + ui20::widgets::section_title(ui, "快速开始"); + ui.horizontal_wrapped(|ui| { + for (page, label) in [ + (Page::Settings, "1. 准备环境"), + (Page::Services, "2. 配置 AI 服务"), + (Page::Models, "使用本地模型"), + (Page::Dictation, "3. 打开听写"), + ] { + let enabled = self.native.is_some() || page == Page::Settings; + if ui20::widgets::secondary_button(ui, label, enabled).clicked() { + if matches!(page, Page::Settings | Page::Services | Page::Models) { + self.navigation.open(Page::Start); + self.settings_modal = Some(page); + } else { + self.navigation.open(page); + } + } } - } + }); }); - ui.separator(); + ui.add_space(8.0); if self.native.is_none() { self.environment_ui(ui); } else { - ui.strong("继续其他任务"); - ui.horizontal_wrapped(|ui| { - for page in [ - Page::Qa, - Page::Selection, - Page::Agent, - Page::Remote, - Page::History, - ] { - if ui.button(page.label()).clicked() { - self.navigation.open(page); + ui20::widgets::card(ui, |ui| { + ui20::widgets::section_title(ui, "继续其他任务"); + ui.horizontal_wrapped(|ui| { + for page in [Page::Qa, Page::Selection, Page::Agent, Page::History] { + if ui20::widgets::secondary_button(ui, page.label(), true).clicked() { + self.navigation.open(page); + } } - } + if ui20::widgets::secondary_button(ui, "手机输入", true).clicked() { + self.settings_modal = Some(Page::Remote); + } + }); + ui20::widgets::hint( + ui, + "问答支持文字与语音;选区润色保留确认、取消与撤销;Less Computer 的工具执行继续使用原有审批。", + ); + ui20::widgets::hint( + ui, + "Linux 当前提供已有 Core / Host 能力的入口,完整原生支持与发布验收仍在继续。", + ); }); - ui.label("问答支持文字与语音;选区润色保留确认、取消与撤销;Less Computer 的工具执行继续使用原有审批。"); - ui.small( - "Linux 当前提供已有 Core / Host 能力的入口,完整原生支持与发布验收仍在继续。", - ); } } @@ -1884,17 +2325,429 @@ mod linux_app { } } - fn navigation_button(&mut self, ui: &mut egui::Ui, page: Page) { - let label = match self.page_activity(page) { - Some(activity) => format!("{} · {activity}", page.label()), - None => page.label().to_string(), - }; - if ui - .selectable_label(self.navigation.page == page, label) - .clicked() - { + /// 2.0 主侧栏:226px、`--ol-sidebar-bg` 底、版本行 + 导航 + 底部设置。 + /// 服务商 / 本地模型 / 手机输入 / 环境设置与其他平台一致,收纳进设置弹窗。 + fn sidebar_ui(&mut self, ctx: &egui::Context) { + let p = ui20::current(ctx); + egui::SidePanel::left("navigation") + .resizable(false) + .exact_width(openless_linux_egui::design_tokens::SIDEBAR_WIDTH) + .frame( + egui::Frame::default() + .fill(p.sidebar_bg()) + .stroke(egui::Stroke::new(0.5_f32, p.line())) + .inner_margin(egui::Margin { + left: 10, + right: 10, + top: 14, + bottom: 12, + }), + ) + .show(ctx, |ui| { + egui::TopBottomPanel::bottom("navigation_footer") + .frame(egui::Frame::default()) + .show_inside(ui, |ui| { + ui20::widgets::divider(ui); + if !self.status.is_empty() { + let status = self.status.clone(); + ui.add( + egui::Label::new( + egui::RichText::new(status).size(11.5).color(p.ink_4()), + ) + .truncate(), + ); + } + self.nav_settings_item(ui); + }); + egui::ScrollArea::vertical() + .id_salt("navigation_scroll") + .show(ui, |ui| { + self.version_row(ui); + ui.add_space(6.0); + self.nav_item(ui, Page::Start, false); + self.nav_item(ui, Page::Dictation, false); + self.nav_item(ui, Page::History, false); + ui.add_space(2.0); + // 「工具」可展开分组(React 侧栏同款交互:点标题展开/收起)。 + let group_active = self.settings_modal.is_none() + && matches!( + self.navigation.page, + Page::Qa | Page::Selection | Page::Agent + ); + egui::CollapsingHeader::new( + egui::RichText::new("工具").size(12.5).color(p.ink_4()), + ) + .default_open(true) + .show_unindented(ui, |ui| { + if group_active { + ui.add_space(0.0); + } + self.nav_item(ui, Page::Qa, true); + self.nav_item(ui, Page::Selection, true); + self.nav_item(ui, Page::Agent, true); + }); + }); + }); + } + + /// 版本行:品牌 + BETA 徽章 + 版本号(React `shell.footer.version` 同款)。 + /// 版本显示 Core 后端合同版本,与 Tauri 端读取的合同保持同源。 + fn version_row(&mut self, ui: &mut egui::Ui) { + let p = ui20::current(ui.ctx()); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("OpenLess") + .size(13.0) + .strong() + .color(p.ink()), + ); + egui::Frame::default() + .stroke(egui::Stroke::new(0.5_f32, p.blue())) + .corner_radius(egui::CornerRadius::same(5)) + .inner_margin(egui::Margin::symmetric(6, 1)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("BETA") + .size(10.0) + .strong() + .color(p.blue()), + ); + }); + }); + ui.label( + egui::RichText::new(format!("版本 {}", openless_core::BACKEND_CONTRACT_VERSION)) + .size(12.0) + .color(p.ink_4()), + ); + } + + /// 2.0 导航项:`.ol-nav-btn` 规格(8px 10px 内边距、8px 圆角、15px 文字); + /// 选中 = surface-2 底 + ink 文字,未选中 = ink-3。 + fn nav_item(&mut self, ui: &mut egui::Ui, page: Page, indent: bool) { + let p = ui20::current(ui.ctx()); + let active = self.settings_modal.is_none() && self.navigation.page == page; + let activity = self.page_activity(page); + let frame = egui::Frame::default() + .fill(if active { + p.surface_2() + } else { + egui::Color32::TRANSPARENT + }) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin { + left: if indent { 30 } else { 10 }, + right: 10, + top: 8, + bottom: 8, + }); + let response = frame + .show(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(page.label()) + .size(15.0) + .color(if active { p.ink() } else { p.ink_3() }), + ); + if let Some(activity) = activity { + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui20::widgets::pill(ui, activity, p.blue_soft(), p.blue()); + }, + ); + } + }); + }) + .response; + let response = ui.interact( + response.rect, + ui.id().with(("nav", page)), + egui::Sense::click(), + ); + if response.clicked() { + self.navigation.open(page); + self.settings_modal = None; + } + response.on_hover_cursor(egui::CursorIcon::PointingHand); + } + + /// 侧栏底部「设置」入口:打开 2.0 设置弹窗(含 AI 服务/本地模型/手机输入)。 + fn nav_settings_item(&mut self, ui: &mut egui::Ui) { + let p = ui20::current(ui.ctx()); + let active = self.settings_modal.is_some(); + let frame = egui::Frame::default() + .fill(if active { + p.surface_2() + } else { + egui::Color32::TRANSPARENT + }) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin { + left: 10, + right: 10, + top: 8, + bottom: 8, + }); + let response = frame + .show(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("设置").size(15.0).color(if active { + p.ink() + } else { + p.ink_3() + })); + }); + }) + .response; + let response = ui.interact( + response.rect, + ui.id().with("nav-settings"), + egui::Sense::click(), + ); + if response.clicked() { + self.settings_modal = Some(Page::Settings); + } + response.on_hover_cursor(egui::CursorIcon::PointingHand); + } + + /// 紧凑窗口(<760px)下的横向导航:胶囊式主页面 + 设置入口。 + fn compact_navigation_ui(&mut self, ctx: &egui::Context) { + let p = ui20::current(ctx); + egui::TopBottomPanel::top("compact_navigation") + .frame( + egui::Frame::default() + .fill(p.surface()) + .stroke(egui::Stroke::new(0.5_f32, p.line())) + .inner_margin(egui::Margin::symmetric(10, 8)), + ) + .show(ctx, |ui| { + ui.horizontal_wrapped(|ui| { + for page in [ + Page::Start, + Page::Dictation, + Page::History, + Page::Qa, + Page::Selection, + Page::Agent, + ] { + self.compact_nav_chip(ui, page); + } + let modal_open = self.settings_modal.is_some(); + if ui20::widgets::pill( + ui, + "设置", + if modal_open { + p.surface_2() + } else { + egui::Color32::TRANSPARENT + }, + p.ink_3(), + ) + .interact(egui::Sense::click()) + .clicked() + { + self.settings_modal = Some(Page::Settings); + } + }); + }); + } + + fn compact_nav_chip(&mut self, ui: &mut egui::Ui, page: Page) { + let p = ui20::current(ui.ctx()); + let active = self.settings_modal.is_none() && self.navigation.page == page; + let response = ui20::widgets::pill( + ui, + page.label(), + if active { + p.surface_2() + } else { + egui::Color32::TRANSPARENT + }, + if active { p.ink() } else { p.ink_3() }, + ) + .interact(egui::Sense::click()); + if response.clicked() { self.navigation.open(page); + self.settings_modal = None; + } + } + + /// 2.0 设置弹窗:遮罩 + 居中 960x680 面板(14px 圆角、`--ol-settings-*`), + /// 左侧 rail 分区导航、右侧分区内容。分区复用各页面既有渲染函数。 + fn settings_modal_ui(&mut self, ctx: &egui::Context) { + let Some(section) = self.settings_modal else { + return; + }; + let p = ui20::current(ctx); + let screen = ctx.screen_rect(); + // 遮罩:`--ol-overlay-bg`。Middle 层在面板之后、弹窗(Foreground)之下。 + let overlay_layer = + egui::LayerId::new(egui::Order::Middle, egui::Id::new("settings-overlay")); + ctx.layer_painter(overlay_layer).rect_filled( + screen, + egui::CornerRadius::same(0), + p.overlay(), + ); + + let size = egui::vec2( + (screen.width() * 0.94).min(openless_linux_egui::design_tokens::SETTINGS_MAX_WIDTH), + (screen.height() * 0.92) + .min(openless_linux_egui::design_tokens::SETTINGS_MAX_HEIGHT), + ); + let mut open = true; + egui::Window::new("settings-modal") + .id(egui::Id::new("settings-modal")) + .title_bar(false) + .resizable(false) + .collapsible(false) + .movable(false) + .fixed_size(size) + .current_pos(egui::pos2( + screen.center().x - size.x / 2.0, + screen.center().y - size.y / 2.0, + )) + .frame( + egui::Frame::default() + .fill(p.settings_content_bg()) + .corner_radius(egui::CornerRadius::same( + openless_linux_egui::design_tokens::radius::CARD, + )) + .stroke(egui::Stroke::new(0.5_f32, p.line())) + .inner_margin(egui::Margin::same(0)), + ) + .show(ctx, |ui| { + egui::TopBottomPanel::top("settings-header") + .frame( + egui::Frame::default() + .fill(p.surface()) + .stroke(egui::Stroke::new(0.5_f32, p.line())) + .inner_margin(egui::Margin { + left: 18, + right: 14, + top: 12, + bottom: 12, + }), + ) + .show_inside(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("设置") + .size(17.0) + .strong() + .color(p.ink()), + ); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui20::widgets::secondary_button(ui, "关闭", true) + .clicked() + { + open = false; + } + }, + ); + }); + }); + egui::SidePanel::left("settings-rail") + .resizable(false) + .exact_width(196.0) + .frame( + egui::Frame::default() + .fill(p.settings_rail_bg()) + .stroke(egui::Stroke::new(0.5_f32, p.line())) + .inner_margin(egui::Margin { + left: 10, + right: 10, + top: 14, + bottom: 10, + }), + ) + .show_inside(ui, |ui| { + egui::ScrollArea::vertical() + .id_salt("settings-rail-scroll") + .show(ui, |ui| { + for rail_page in + [Page::Settings, Page::Services, Page::Models, Page::Remote] + { + self.settings_rail_item(ui, rail_page, section); + } + }); + }); + egui::CentralPanel::default() + .frame( + egui::Frame::default() + .fill(p.settings_content_bg()) + .inner_margin(egui::Margin::same(20)), + ) + .show_inside(ui, |ui| { + egui::ScrollArea::vertical() + .id_salt(("settings-section", section)) + .show(ui, |ui| { + if self.native.is_none() && !matches!(section, Page::Settings) { + ui20::widgets::page_header(ui, section.label(), None); + ui.label("Core 尚未连接,此分区需要 Core 启动后才能使用。"); + if ui20::widgets::blue_button(ui, "前往环境准备", true) + .clicked() + { + self.settings_modal = Some(Page::Settings); + } + return; + } + match section { + Page::Settings => self.settings_ui(ui), + Page::Services => self.services_ui(ui), + Page::Models => self.models_ui(ui), + Page::Remote => self.remote_ui(ui), + _ => {} + } + }); + }); + }); + if !open { + self.settings_modal = None; + } + } + + /// 设置弹窗左 rail 的分区项。 + fn settings_rail_item(&mut self, ui: &mut egui::Ui, page: Page, section: Page) { + let p = ui20::current(ui.ctx()); + let active = section == page; + let frame = egui::Frame::default() + .fill(if active { + p.surface() + } else { + egui::Color32::TRANSPARENT + }) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin { + left: 10, + right: 10, + top: 8, + bottom: 8, + }); + let response = frame + .show(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(page.label()) + .size(14.0) + .color(if active { p.ink() } else { p.ink_3() }), + ); + }); + }) + .response; + let response = ui.interact( + response.rect, + ui.id().with(("settings-rail", page)), + egui::Sense::click(), + ); + if response.clicked() { + self.settings_modal = Some(page); } + response.on_hover_cursor(egui::CursorIcon::PointingHand); } fn activity_ui(&mut self, ui: &mut egui::Ui) { @@ -1968,32 +2821,55 @@ mod linux_app { } fn history_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("历史"); - ui.label("最近 20 条,只读。插入、复制回退与已发送粘贴分别显示实际结果。"); + ui20::widgets::page_header( + ui, + "历史", + Some("最近 20 条,只读。插入、复制回退与已发送粘贴分别显示实际结果。"), + ); let Some(backend) = self.backend() else { return; }; match backend.list_history() { Ok(history) if history.is_empty() => { - ui.label("暂无历史记录"); + ui20::widgets::info_panel(ui, |ui| { + ui20::widgets::hint(ui, "暂无历史记录"); + }); } Ok(history) => { for item in history.into_iter().rev().take(20) { let delivery = match item.insert_status { - HistoryInsertStatus::Inserted => "已插入", - HistoryInsertStatus::CopiedFallback => "已复制", - HistoryInsertStatus::PasteSent => "已发送粘贴", - HistoryInsertStatus::Failed => "失败", - HistoryInsertStatus::NotRequested => "未请求插入", + HistoryInsertStatus::Inserted => ("已插入", ui20::widgets::Status::Ok), + HistoryInsertStatus::CopiedFallback => { + ("已复制", ui20::widgets::Status::Info) + } + HistoryInsertStatus::PasteSent => { + ("已发送粘贴", ui20::widgets::Status::Info) + } + HistoryInsertStatus::Failed => ("失败", ui20::widgets::Status::Err), + HistoryInsertStatus::NotRequested => { + ("未请求插入", ui20::widgets::Status::Warn) + } }; - ui.label(format!( - "{} · {} · {}", - item.created_at, delivery, item.final_text - )); + ui20::widgets::card(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(&item.created_at) + .size(12.5) + .color(ui20::current(ui.ctx()).ink_4()), + ); + ui20::widgets::status_pill(ui, delivery.0, delivery.1); + }); + ui.add_space(2.0); + ui.add( + egui::Label::new(egui::RichText::new(&item.final_text).size(14.0)) + .wrap(), + ); + }); + ui.add_space(2.0); } } Err(error) => { - ui.label(error.to_string()); + ui20::widgets::status_pill(ui, &error.to_string(), ui20::widgets::Status::Err); } } } @@ -2004,80 +2880,81 @@ mod linux_app { // Poll every frame before routing pages. Hidden pages retain their // drafts, session ownership, event replay and async completion paths. self.poll(ctx); + // 设置弹窗打开时 Esc 只关弹窗;关闭后才恢复全局语音取消。 if ctx.input(|input| input.key_pressed(egui::Key::Escape)) { - self.cancel_voice(); - } - egui::TopBottomPanel::top("status").show(ctx, |ui| { - ui.horizontal(|ui| { - ui.strong("OpenLess 2.0"); - ui.separator(); - ui.add(egui::Label::new(&self.status).truncate()) - .on_hover_text(&self.status); - }); - self.activity_ui(ui); - if self.pending_approval.is_some() { - ui.strong("Less Computer 等待审批"); - self.agent_approval_ui(ui); + if self.settings_modal.is_some() { + self.settings_modal = None; + } else { + self.cancel_voice(); } - }); - if ctx.screen_rect().width() < 760.0 { - egui::TopBottomPanel::top("compact_navigation").show(ctx, |ui| { - ui.horizontal_wrapped(|ui| { - for page in Page::ALL { - self.navigation_button(ui, page); - } - }); - }); + } + let compact = ctx.screen_rect().width() < 760.0; + if compact { + self.compact_navigation_ui(ctx); } else { - egui::SidePanel::left("navigation") - .resizable(false) - .default_width(176.0) - .show(ctx, |ui| { - egui::ScrollArea::vertical() - .id_salt("navigation_scroll") - .show(ui, |ui| { - ui.strong("工作空间"); - ui.add_space(8.0); - for page in Page::ALL { - if page == Page::Services { - ui.separator(); - ui.strong("准备与管理"); - } - self.navigation_button(ui, page); - } - }); - }); + self.sidebar_ui(ctx); } - egui::CentralPanel::default().show(ctx, |ui| { - let page = self.navigation.page; - egui::ScrollArea::vertical() - .id_salt(("page", page)) - .show(ui, |ui| { - if self.native.is_none() && !matches!(page, Page::Start | Page::Settings) { - ui.heading(page.label()); - ui.label("Core 尚未连接,请先完成 Linux 环境准备并重新启动应用。"); - if let Some(error) = &self.startup_error { - ui.colored_label(egui::Color32::YELLOW, error); + egui::CentralPanel::default() + .frame( + egui::Frame::default() + .fill(ui20::current(ctx).surface()) + .inner_margin(egui::Margin { + left: 20, + right: 20, + top: 14, + bottom: 14, + }), + ) + .show(ctx, |ui| { + let page = self.navigation.page; + // 跨页审批:Agent 页自身渲染审批卡,其余页面顶部保留入口。 + if self.pending_approval.is_some() && page != Page::Agent { + ui20::widgets::info_panel(ui, |ui| { + ui20::widgets::section_title(ui, "Less Computer 等待审批"); + self.agent_approval_ui(ui); + }); + ui.add_space(8.0); + } + egui::ScrollArea::vertical() + .id_salt(("page", page)) + .show(ui, |ui| { + if self.native.is_none() + && !matches!(page, Page::Start | Page::Settings) + { + ui20::widgets::page_header(ui, page.label(), None); + ui.label("Core 尚未连接,请先完成 Linux 环境准备并重新启动应用。"); + if let Some(error) = &self.startup_error { + ui20::widgets::status_pill( + ui, + &format!("启动未完成:{error}"), + ui20::widgets::Status::Warn, + ); + } + if ui20::widgets::blue_button(ui, "查看环境准备步骤", true) + .clicked() + { + self.navigation.open(Page::Start); + self.settings_modal = Some(Page::Settings); + } + return; } - if ui.button("查看环境准备步骤").clicked() { - self.navigation.open(Page::Settings); + match page { + Page::Start => self.start_ui(ui), + Page::Dictation => self.dictation_ui(ui), + Page::Qa => self.qa_ui(ui), + Page::Selection => self.selection_ui(ui), + Page::Agent => self.less_computer_ui(ui), + Page::Services => self.services_ui(ui), + Page::Models => self.models_ui(ui), + Page::Remote => self.remote_ui(ui), + Page::History => self.history_ui(ui), + Page::Settings => self.settings_ui(ui), } - return; - } - match page { - Page::Start => self.start_ui(ui), - Page::Dictation => self.dictation_ui(ui), - Page::Qa => self.qa_ui(ui), - Page::Selection => self.selection_ui(ui), - Page::Agent => self.less_computer_ui(ui), - Page::Services => self.services_ui(ui), - Page::Models => self.models_ui(ui), - Page::Remote => self.remote_ui(ui), - Page::History => self.history_ui(ui), - Page::Settings => self.settings_ui(ui), - } - }); - }); + }); + }); + if self.settings_modal.is_some() { + self.settings_modal_ui(ctx); + } ctx.request_repaint_after(Duration::from_millis(50)); } } @@ -2728,10 +3605,13 @@ mod linux_app { eframe::run_native( "OpenLess", options, - Box::new(move |_| { + Box::new(move |cc| { let mut app = OpenLessEguiApp::new(tokio, native); app.environment = Some(environment); app.plugin_check = Some(plugin_check); + // 2.0 主题与 CJK 字体:默认白底体系(偏好持久化在 ui-prefs.json)。 + ui20::install_cjk_fonts(&cc.egui_ctx); + ui20::apply_theme(&cc.egui_ctx, app.ui_prefs.dark); Ok(Box::new(app)) }), ) @@ -2802,15 +3682,17 @@ mod linux_app { ..Default::default() }); let text = rendered_text(|ui| app.start_ui(ui)); + // 2.0 卡片把状态拆成键值两行(rendered_text 按 \n 连接), + // 断言跟随结构:多模态只报 Omni,不出现传统管线的行。 let expected = if omni_configured { - "Omni:已配置" + "已配置" } else { - "Omni:尚未配置" + "尚未配置,请到 AI 服务配置 Omni" }; + assert!(text.contains("Omni"), "{text}"); assert!(text.contains(expected), "missing {expected}: {text}"); - assert!(!text.contains("语音识别:尚未配置"), "{text}"); - assert!(!text.contains("ASR 语音识别:"), "{text}"); - assert!(!text.contains("LLM 润色:"), "{text}"); + assert!(!text.contains("ASR 语音识别"), "{text}"); + assert!(!text.contains("LLM 润色"), "{text}"); assert!(text.contains("已配置不代表校验通过"), "{text}"); } } @@ -2843,21 +3725,26 @@ mod linux_app { ..Default::default() }); let text = rendered_text(|ui| app.start_ui(ui)); - for expected in [ - if asr_configured { - "ASR 语音识别:已配置" + // 键值两行结构:键行 + 状态值行分别断言(语义与旧断言一致)。 + assert!(text.contains("ASR 语音识别"), "{text}"); + assert!( + text.contains(if asr_configured { + "已配置" } else { - "ASR 语音识别:尚未配置" - }, - if llm_configured { - "LLM 润色:已配置" + "尚未配置,请配置 AI 服务或激活本地模型" + }), + "{text}" + ); + assert!(text.contains("LLM 润色"), "{text}"); + assert!( + text.contains(if llm_configured { + "已配置" } else { - "LLM 润色:尚未配置" - }, - ] { - assert!(text.contains(expected), "missing {expected}: {text}"); - } - assert!(!text.contains("Omni:"), "{text}"); + "尚未配置" + }), + "{text}" + ); + assert!(!text.contains("Omni"), "{text}"); assert!(text.contains("已配置不代表校验通过"), "{text}"); } } @@ -3003,6 +3890,7 @@ mod linux_app { urls: vec!["https://old.example.invalid".into()], urls_stale, locale: "en".into(), + ca_fingerprint_sha256: None, connection_count: 0, active_session_id: None, }, @@ -3011,7 +3899,7 @@ mod linux_app { let text = rendered_text(|ui| app.remote_ui(ui)); assert!(!text.contains("fixture-pin"), "{text}"); assert!(!text.contains("https://old.example.invalid"), "{text}"); - assert!(text.contains("当前连接数:0"), "{text}"); + assert!(text.contains("当前连接数"), "{text}"); } } diff --git a/openless-all/app/linux-egui/src/ui/mod.rs b/openless-all/app/linux-egui/src/ui/mod.rs new file mode 100644 index 000000000..8c6075752 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/mod.rs @@ -0,0 +1,11 @@ +//! 2.0 界面的 egui 承接层:主题、组件与外观偏好。 +//! +//! 只在 Linux 编译(egui/eframe 是 Linux 目标依赖);颜色数值来自 +//! [`crate::design_tokens`](tokens.css 的对照表),本模块只做换算与布局。 + +pub mod prefs; +pub mod theme; +pub mod widgets; + +pub use prefs::UiPrefs; +pub use theme::{apply_theme, current, install_cjk_fonts, Palette}; diff --git a/openless-all/app/linux-egui/src/ui/prefs.rs b/openless-all/app/linux-egui/src/ui/prefs.rs new file mode 100644 index 000000000..f8984ec2a --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/prefs.rs @@ -0,0 +1,73 @@ +//! Linux UI 的外观偏好(仅表现层状态,不进 Core 合同)。 +//! +//! 存放于 `$XDG_DATA_HOME/OpenLess/ui-prefs.json`(无 XDG 时 +//! `~/.local/share/OpenLess/`),与 Core 的 `preferences.json` 同目录但互不 +//! 干涉:Core 的 `UserPreferences` 是共享合同,深浅主题是 egui 宿主的表现 +//! 层选择,两类状态分开持久化。 + +use serde::{Deserialize, Serialize}; + +/// UI 外观偏好。字段保持向后兼容:缺失字段按默认值回落。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct UiPrefs { + /// 深色主题(2.0 默认浅色)。 + #[serde(default)] + pub dark: bool, +} + +impl UiPrefs { + fn file_path() -> Option { + let base = std::env::var_os("XDG_DATA_HOME") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(|home| std::path::PathBuf::from(home).join(".local/share")) + })?; + Some(base.join("OpenLess").join("ui-prefs.json")) + } + + /// 读取偏好;文件缺失或损坏时回落默认(浅色),不阻塞启动。 + pub fn load() -> Self { + let Some(path) = Self::file_path() else { + return Self::default(); + }; + std::fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_default() + } + + /// 写回偏好;失败只记日志,不影响运行。 + pub fn save(&self) { + let Some(path) = Self::file_path() else { + return; + }; + if let Some(dir) = path.parent() { + if std::fs::create_dir_all(dir).is_err() { + return; + } + } + if let Ok(text) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(path, text); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 解析容错:旧文件缺字段/坏 JSON 都回落默认,不 panic。 + #[test] + fn parses_legacy_and_corrupt_files_leniently() { + assert_eq!( + serde_json::from_str::("{}").unwrap(), + UiPrefs::default() + ); + assert_eq!( + serde_json::from_str::(r#"{"dark":true}"#).unwrap(), + UiPrefs { dark: true } + ); + assert!(serde_json::from_str::("not json").is_err()); + } +} diff --git a/openless-all/app/linux-egui/src/ui/theme.rs b/openless-all/app/linux-egui/src/ui/theme.rs new file mode 100644 index 000000000..eebc099c5 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/theme.rs @@ -0,0 +1,348 @@ +//! 把 [`crate::design_tokens`] 的令牌应用到 egui 上下文,并提供组件层使用的 +//! [`Palette`] 视图。2.0 的白底 zinc 体系在这里落成 egui 的 Style/Visuals。 + +use crate::design_tokens::{self as tokens, ThemeTokens}; + +use eframe::egui; + +/// 主题调色板视图:包一层令牌 + 明暗标记,组件层从这里取 `Color32`。 +#[derive(Debug, Clone, Copy)] +pub struct Palette { + tokens: &'static ThemeTokens, + dark: bool, +} + +impl Palette { + pub fn new(dark: bool) -> Self { + Self { + tokens: tokens::tokens(dark), + dark, + } + } + + pub fn is_dark(&self) -> bool { + self.dark + } + + fn c(value: u32) -> egui::Color32 { + egui::Color32::from_rgb( + ((value >> 16) & 0xFF) as u8, + ((value >> 8) & 0xFF) as u8, + (value & 0xFF) as u8, + ) + } + + /// `--ol-canvas` + pub fn canvas(&self) -> egui::Color32 { + Self::c(self.tokens.canvas) + } + /// `--ol-surface`:内容区底色(2.0 纯白 / 暗色 #1c1c1f) + pub fn surface(&self) -> egui::Color32 { + Self::c(self.tokens.surface) + } + /// `--ol-surface-2`:次级底、卡片软底、导航激活底 + pub fn surface_2(&self) -> egui::Color32 { + Self::c(self.tokens.surface_2) + } + /// `--ol-line` + pub fn line(&self) -> egui::Color32 { + Self::c(self.tokens.line) + } + /// `--ol-line-strong` + pub fn line_strong(&self) -> egui::Color32 { + Self::c(self.tokens.line_strong) + } + /// `--ol-ink` + pub fn ink(&self) -> egui::Color32 { + Self::c(self.tokens.ink) + } + /// `--ol-ink-2` + pub fn ink_2(&self) -> egui::Color32 { + Self::c(self.tokens.ink_2) + } + /// `--ol-ink-3` + pub fn ink_3(&self) -> egui::Color32 { + Self::c(self.tokens.ink_3) + } + /// `--ol-ink-4` + pub fn ink_4(&self) -> egui::Color32 { + Self::c(self.tokens.ink_4) + } + /// `--ol-ink-5`(分隔线级弱色,兼作开关关闭轨道) + pub fn ink_5(&self) -> egui::Color32 { + Self::c(self.tokens.ink_5) + } + /// `--ol-blue` + pub fn blue(&self) -> egui::Color32 { + Self::c(self.tokens.blue) + } + /// `--ol-blue-hover` + pub fn blue_hover(&self) -> egui::Color32 { + Self::c(self.tokens.blue_hover) + } + /// `--ol-blue-soft` + pub fn blue_soft(&self) -> egui::Color32 { + Self::c(self.tokens.blue_soft) + } + /// `--ol-on-accent` + pub fn on_accent(&self) -> egui::Color32 { + Self::c(self.tokens.on_accent) + } + /// `--ol-ok` + pub fn ok(&self) -> egui::Color32 { + Self::c(self.tokens.ok) + } + /// `--ol-ok-soft` + pub fn ok_soft(&self) -> egui::Color32 { + Self::c(self.tokens.ok_soft) + } + /// `--ol-warn` + pub fn warn(&self) -> egui::Color32 { + Self::c(self.tokens.warn) + } + /// `--ol-warn-soft` + pub fn warn_soft(&self) -> egui::Color32 { + Self::c(self.tokens.warn_soft) + } + /// `--ol-err` + pub fn err(&self) -> egui::Color32 { + Self::c(self.tokens.err) + } + /// `--ol-sidebar-bg` + pub fn sidebar_bg(&self) -> egui::Color32 { + Self::c(self.tokens.sidebar_bg) + } + /// `--ol-settings-rail-bg` + pub fn settings_rail_bg(&self) -> egui::Color32 { + Self::c(self.tokens.settings_rail_bg) + } + /// `--ol-settings-content-bg` + pub fn settings_content_bg(&self) -> egui::Color32 { + Self::c(self.tokens.settings_content_bg) + } + /// `--ol-pill-selected-bg`:主按钮(浅色主题的深色实心按钮) + pub fn primary_bg(&self) -> egui::Color32 { + Self::c(self.tokens.pill_selected_bg) + } + /// `--ol-pill-selected-ink` + pub fn primary_ink(&self) -> egui::Color32 { + Self::c(self.tokens.pill_selected_ink) + } + /// `--ol-segmented-bg` + pub fn segmented_bg(&self) -> egui::Color32 { + Self::c(self.tokens.segmented_bg) + } + /// `--ol-segmented-active-bg` + pub fn segmented_active_bg(&self) -> egui::Color32 { + Self::c(self.tokens.segmented_active_bg) + } + /// `--ol-overlay-bg`(alpha 固定 0.32/0.64,见 tokens.css) + pub fn overlay(&self) -> egui::Color32 { + let alpha: f32 = if self.dark { 0.64 } else { 0.32 }; + egui::Color32::from_rgba_premultiplied( + ((self.tokens.overlay_rgb >> 16) & 0xFF) as u8, + ((self.tokens.overlay_rgb >> 8) & 0xFF) as u8, + (self.tokens.overlay_rgb & 0xFF) as u8, + (alpha * 255.0).round() as u8, + ) + } +} + +/// egui 内存里记录当前明暗主题,让任意 `&mut Ui` 都能取到调色板。 +fn store_dark(ctx: &egui::Context, dark: bool) { + ctx.data_mut(|data| data.insert_temp(egui::Id::new("openless-ui-dark"), dark)); +} + +/// 读取当前主题;尚未调用 [`apply_theme`] 时按浅色处理。 +pub fn current(ctx: &egui::Context) -> Palette { + let dark = ctx + .data_mut(|data| data.get_temp::(egui::Id::new("openless-ui-dark"))) + .unwrap_or(false); + Palette::new(dark) +} + +/// 应用 2.0 主题到 egui 上下文。`dark=false` 是 2.0 默认的白底体系。 +pub fn apply_theme(ctx: &egui::Context, dark: bool) { + let p = Palette::new(dark); + store_dark(ctx, dark); + + let mut style = egui::Style::default(); + let mut visuals = if dark { + egui::Visuals::dark() + } else { + egui::Visuals::light() + }; + + // 面板与浮层底色:内容区白底(暗色 surface)。 + visuals.panel_fill = p.surface(); + visuals.window_fill = p.surface(); + visuals.window_stroke = egui::Stroke::new(0.5_f32, p.line()); + visuals.faint_bg_color = p.surface_2(); + visuals.extreme_bg_color = if dark { p.canvas() } else { p.surface() }; + visuals.code_bg_color = p.surface_2(); + visuals.hyperlink_color = p.blue(); + // egui 0.31 起 warn/error 文字色字段更名 *_fg_color。 + visuals.warn_fg_color = p.warn(); + visuals.error_fg_color = p.err(); + visuals.selection.bg_fill = p.blue_soft(); + visuals.selection.stroke = egui::Stroke::new(1.0_f32, p.ink()); + + let control_rounding = egui::CornerRadius::same(tokens::radius::CONTROL); + // 控件观感对齐 React:默认按钮 = surface 底 + 0.5px line 边 + ink-2 文字; + // 悬停 = surface-2;激活(按下)= blue 实底白字;危险文字直接用 err 色。 + visuals.widgets.noninteractive = widget_visuals( + p.surface_2(), + p.surface_2(), + egui::Stroke::new(0.5_f32, p.line()), + p.ink_3(), + control_rounding, + ); + visuals.widgets.inactive = widget_visuals( + p.surface(), + p.surface(), + egui::Stroke::new(1.0_f32, p.line()), + p.ink_2(), + control_rounding, + ); + visuals.widgets.hovered = widget_visuals( + p.surface_2(), + p.surface_2(), + egui::Stroke::new(1.0_f32, p.line_strong()), + p.ink(), + control_rounding, + ); + visuals.widgets.active = widget_visuals( + p.blue(), + p.blue(), + egui::Stroke::new(1.0_f32, p.blue()), + p.on_accent(), + control_rounding, + ); + visuals.widgets.open = widget_visuals( + p.blue_soft(), + p.blue_soft(), + egui::Stroke::new(1.0_f32, p.line()), + p.ink(), + control_rounding, + ); + + style.visuals = visuals; + + // 排版:正文 15px(导航/正文),页标题 22px,小字 12.5px,等宽 13px。 + // 必须从默认表起步只覆盖这四项:egui 的 Button/Caption 等控件会查 + // `Style::text_styles` 里自己的 TextStyle,整表替换会让它们 panic。 + let mut text_styles = egui::Style::default().text_styles; + text_styles.insert(egui::TextStyle::Heading, egui::FontId::proportional(22.0)); + text_styles.insert(egui::TextStyle::Body, egui::FontId::proportional(15.0)); + text_styles.insert(egui::TextStyle::Small, egui::FontId::proportional(12.5)); + text_styles.insert(egui::TextStyle::Monospace, egui::FontId::monospace(13.0)); + style.text_styles = text_styles; + + // 间距:横向 10px(React gap:10)、按钮内边距 12x6(padding: 6px 12px 的镜像)。 + // egui 0.31 的 Margin 以 i8 计。 + style.spacing.item_spacing = egui::vec2(10.0, 8.0); + style.spacing.button_padding = egui::vec2(12.0, 6.0); + style.spacing.window_margin = egui::Margin::same(16); + style.spacing.menu_spacing = 4.0; + + ctx.set_style(style); +} + +fn widget_visuals( + bg_fill: egui::Color32, + weak_bg_fill: egui::Color32, + bg_stroke: egui::Stroke, + ink: egui::Color32, + corner_radius: egui::CornerRadius, +) -> egui::style::WidgetVisuals { + egui::style::WidgetVisuals { + bg_fill, + weak_bg_fill, + bg_stroke, + corner_radius, + fg_stroke: egui::Stroke::new(1.0_f32, ink), + expansion: 0.0, + } +} + +/// 安装 CJK 字体(egui 默认字体不含中文字形,不装会显示成 □)。 +/// +/// 移植自 egui 弹窗实验(PR #997 `egui_host/fonts.rs`),修正了原实现 +/// 每轮循环都用全新 `FontDefinitions` 覆盖前一轮的问题:这里只构建一次、 +/// 最多装两个候选并一次 `set_fonts`。候选优先级: +/// `OPENLESS_IME_FONT` > 常见系统中文字体 > HOME 字体目录扫描。 +pub fn install_cjk_fonts(ctx: &egui::Context) { + const CANDIDATES: &[&str] = &[ + // Linux + "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + "/usr/share/fonts/truetype/wenquanyi/wqy-microhei/wqy-microhei.ttc", + // 开发宿主(macOS/Windows)上跑测试时的回退。 + "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/Hiragino Sans GB.ttc", + "C:\\Windows\\Fonts\\msyh.ttc", + ]; + + let mut paths: Vec = Vec::new(); + if let Ok(explicit) = std::env::var("OPENLESS_IME_FONT") { + paths.push(std::path::PathBuf::from(explicit)); + } + paths.extend(CANDIDATES.iter().map(std::path::PathBuf::from)); + if let Ok(home) = std::env::var("HOME") { + for sub in [".fonts", ".local/share/fonts"] { + let dir = std::path::PathBuf::from(&home).join(sub); + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if name.contains("cjk") || name.contains("wqy") { + paths.push(path); + } + } + } + } + } + + let mut fonts = egui::FontDefinitions::default(); + let mut inserted = 0usize; + let mut first_source: Option = None; + for path in paths { + if inserted >= 2 { + break; + } + if !path.exists() { + continue; + } + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let name = format!("openless-cjk-{inserted}"); + fonts.font_data.insert( + name.clone(), + std::sync::Arc::new(egui::FontData::from_owned(bytes)), + ); + for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { + if let Some(list) = fonts.families.get_mut(&family) { + list.insert(0, name.clone()); + } + } + if first_source.is_none() { + first_source = Some(path.clone()); + } + log::info!("[ui] CJK 字体: {}", path.display()); + inserted += 1; + } + if inserted == 0 { + log::warn!("[ui] 未找到 CJK 字体,中文将显示为 □(OPENLESS_IME_FONT 可指定)"); + return; + } + ctx.set_fonts(fonts); + let _ = first_source; +} diff --git a/openless-all/app/linux-egui/src/ui/widgets.rs b/openless-all/app/linux-egui/src/ui/widgets.rs new file mode 100644 index 000000000..270fe8afb --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/widgets.rs @@ -0,0 +1,357 @@ +//! 2.0 共享组件:卡片、状态胶囊、按钮、分段控件、开关与页头。 +//! +//! 视觉规格对应 React 侧组件:`--ol-card-*`(卡片)、`--ol-pill-*`(胶囊)、 +//! `--ol-primary/accent/danger-solid-*`(按钮)、`--ol-segmented-*`(分段)、 +//! `--ol-toggle-*`(开关)。egui 0.31 的 `CornerRadius` 以 u8、`Margin` 以 i8 计。 + +use crate::design_tokens::{self as tokens}; +use crate::ui::theme::{current, Palette}; + +use eframe::egui; + +/// 页头:22px 标题 + 13px 副标题(React 页面的 `title + description` 结构)。 +pub fn page_header(ui: &mut egui::Ui, title: &str, subtitle: Option<&str>) { + let p = current(ui.ctx()); + ui.add_space(4.0); + ui.label( + egui::RichText::new(title) + .size(22.0) + .strong() + .color(p.ink()), + ); + if let Some(subtitle) = subtitle { + ui.label(egui::RichText::new(subtitle).size(13.0).color(p.ink_3())); + } + ui.add_space(12.0); +} + +/// 分区标题(卡片内 15px 半粗标题)。 +pub fn section_title(ui: &mut egui::Ui, title: &str) { + let p = current(ui.ctx()); + ui.label( + egui::RichText::new(title) + .size(15.0) + .strong() + .color(p.ink()), + ); +} + +/// 白底卡片:`--ol-card-bg/-border` + 14px 圆角 + 16px 内边距。 +pub fn card( + ui: &mut egui::Ui, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> egui::InnerResponse { + let p = current(ui.ctx()); + egui::Frame::default() + .fill(p.surface()) + .stroke(egui::Stroke::new(0.5_f32, p.line())) + .corner_radius(egui::CornerRadius::same(tokens::radius::CARD)) + .inner_margin(egui::Margin::same(16)) + .show(ui, add_contents) +} + +/// 软底卡片:`--ol-surface-2` 底 + 14px 圆角(信息块、次级分组)。 +pub fn subtle_card( + ui: &mut egui::Ui, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> egui::InnerResponse { + let p = current(ui.ctx()); + egui::Frame::default() + .fill(p.surface_2()) + .corner_radius(egui::CornerRadius::same(tokens::radius::CARD)) + .inner_margin(egui::Margin::same(14)) + .show(ui, add_contents) +} + +/// 蓝色软底提示块:`--ol-blue-soft` + blue 文字(引导/提示)。 +pub fn info_panel( + ui: &mut egui::Ui, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> egui::InnerResponse { + let p = current(ui.ctx()); + egui::Frame::default() + .fill(p.blue_soft()) + .corner_radius(egui::CornerRadius::same(tokens::radius::CARD)) + .inner_margin(egui::Margin::same(14)) + .show(ui, add_contents) +} + +/// 状态胶囊的语义色。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + Ok, + Warn, + Err, + Info, +} + +impl Status { + fn colors(self, p: &Palette) -> (egui::Color32, egui::Color32) { + match self { + Self::Ok => (p.ok_soft(), p.ok()), + Self::Warn => (p.warn_soft(), p.warn()), + Self::Err => (p.err(), egui::Color32::WHITE), + Self::Info => (p.blue_soft(), p.blue()), + } + } +} + +/// 状态胶囊:软底 + 语义色文字(React 的 ok/warn/err pill)。 +pub fn status_pill(ui: &mut egui::Ui, text: &str, status: Status) -> egui::Response { + let p = current(ui.ctx()); + let (bg, fg) = status.colors(&p); + pill(ui, text, bg, fg) +} + +/// 通用胶囊绘制:全圆角软底 + 居中文字。 +pub fn pill(ui: &mut egui::Ui, text: &str, bg: egui::Color32, fg: egui::Color32) -> egui::Response { + egui::Frame::default() + .fill(bg) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::symmetric(8, 3)) + .show(ui, |ui| { + ui.label(egui::RichText::new(text).size(12.0).strong().color(fg)); + }) + .response +} + +/// 主按钮:`--ol-primary-solid`(浅色主题为深墨实底)。`enabled=false` 走 +/// `add_enabled` 真禁用(egui 的 `Response::enabled` 不追溯禁用控件)。 +pub fn primary_button(ui: &mut egui::Ui, label: &str, enabled: bool) -> egui::Response { + let p = current(ui.ctx()); + let button = egui::Button::new( + egui::RichText::new(label) + .color(p.primary_ink()) + .strong() + .size(14.0), + ) + .fill(p.primary_bg()) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(tokens::radius::CONTROL)); + add_maybe_enabled(ui, button, enabled) +} + +/// 强调按钮:`--ol-accent-solid`(蓝色实底)。 +pub fn blue_button(ui: &mut egui::Ui, label: &str, enabled: bool) -> egui::Response { + let p = current(ui.ctx()); + let button = egui::Button::new( + egui::RichText::new(label) + .color(p.on_accent()) + .strong() + .size(14.0), + ) + .fill(p.blue()) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(tokens::radius::CONTROL)); + add_maybe_enabled(ui, button, enabled) +} + +/// 危险按钮:`--ol-danger-solid`。 +pub fn danger_button(ui: &mut egui::Ui, label: &str, enabled: bool) -> egui::Response { + let p = current(ui.ctx()); + let button = egui::Button::new( + egui::RichText::new(label) + .color(egui::Color32::WHITE) + .strong() + .size(14.0), + ) + .fill(p.err()) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(tokens::radius::CONTROL)); + add_maybe_enabled(ui, button, enabled) +} + +/// 次级按钮(主题默认按钮:白底 + line 边框)。 +pub fn secondary_button(ui: &mut egui::Ui, label: &str, enabled: bool) -> egui::Response { + let button = egui::Button::new(egui::RichText::new(label).size(14.0)); + add_maybe_enabled(ui, button, enabled) +} + +fn add_maybe_enabled(ui: &mut egui::Ui, button: egui::Button<'_>, enabled: bool) -> egui::Response { + if enabled { + ui.add(button) + } else { + ui.add_enabled(false, button) + } +} + +/// 弱化文字提示(`--ol-ink-4` 小字)。 +pub fn hint(ui: &mut egui::Ui, text: &str) { + let p = current(ui.ctx()); + ui.label(egui::RichText::new(text).size(12.5).color(p.ink_4())); +} + +/// 键值行:固定键列(`--ol-ink-3`)+ 值(`--ol-ink-2`),值可换行。 +pub fn kv_row(ui: &mut egui::Ui, key: &str, value: &str) { + let p = current(ui.ctx()); + ui.horizontal(|ui| { + ui.add_sized( + [200.0, 18.0], + egui::Label::new(egui::RichText::new(key).size(13.0).color(p.ink_3())), + ); + ui.add(egui::Label::new(egui::RichText::new(value).size(13.0).color(p.ink_2())).wrap()); + }); +} + +/// 1px 分隔线(`--ol-line`)。 +pub fn divider(ui: &mut egui::Ui) { + let p = current(ui.ctx()); + ui.add_space(4.0); + let width = ui.available_width(); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 1.0), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(1), p.line()); + ui.add_space(4.0); +} + +/// 分段控件:`--ol-segmented-bg` 容器 + 选中白底片(`--ol-segmented-active-bg`)。 +/// 返回被点中的选项下标。 +pub fn segmented( + ui: &mut egui::Ui, + _id_salt: &str, + options: &[&str], + selected: usize, +) -> Option { + let p = current(ui.ctx()); + let mut picked = None; + egui::Frame::default() + .fill(p.segmented_bg()) + .corner_radius(egui::CornerRadius::same(tokens::radius::CONTROL)) + .inner_margin(egui::Margin::same(2)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.style_mut().interaction.selectable_labels = false; + for (index, option) in options.iter().enumerate() { + let active = index == selected; + let response = if active { + egui::Frame::default() + .fill(p.segmented_active_bg()) + .corner_radius(egui::CornerRadius::same(tokens::radius::SM)) + .inner_margin(egui::Margin::symmetric(12, 5)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(*option) + .size(13.0) + .strong() + .color(p.ink()), + ) + }) + .response + } else { + ui.add( + egui::Button::new( + egui::RichText::new(*option).size(13.0).color(p.ink_3()), + ) + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(tokens::radius::SM)), + ) + }; + if response.clicked() && !active { + picked = Some(index); + } + } + }); + }); + picked +} + +/// 开关(React 的 toggle switch):34x20 轨道 + 白色圆钮,点按切换。 +/// 返回是否发生了切换;`on` 由调用方持有。关闭轨道色取 `--ol-toggle-off-bg` +/// 的近似(浅色 ink-5 / 深色 ink-3,保证暗底可见)。 +pub fn switch(ui: &mut egui::Ui, on: bool) -> egui::Response { + let p = current(ui.ctx()); + let size = egui::vec2(34.0, 20.0); + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + let painter = ui.painter_at(rect); + let track = if on { + p.blue() + } else if p.is_dark() { + p.ink_3() + } else { + p.ink_5() + }; + painter.rect_filled( + rect, + egui::CornerRadius::same((rect.height() / 2.0) as u8), + track, + ); + let knob_d = 14.0; + let knob_y = rect.center().y; + let knob_center_x = if on { + rect.right() - knob_d / 2.0 - 3.0 + } else { + rect.left() + knob_d / 2.0 + 3.0 + }; + painter.circle_filled( + egui::pos2(knob_center_x, knob_y), + knob_d / 2.0, + egui::Color32::WHITE, + ); + response +} + +/// 带标签的开关行:左标签右开关(React 设置行的排布)。 +pub fn toggle_row(ui: &mut egui::Ui, on: &mut bool, label: &str, hint_text: Option<&str>) { + let p = current(ui.ctx()); + let mut changed = false; + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label(egui::RichText::new(label).size(14.0).color(p.ink_2())); + if let Some(hint_text) = hint_text { + ui.label(egui::RichText::new(hint_text).size(12.0).color(p.ink_4())); + } + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if switch(ui, *on).clicked() { + changed = true; + } + }); + }); + if changed { + *on = !*on; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 状态胶囊与开关在无 CJK 字体的 headless 环境也能完成布局。 + #[test] + fn widgets_render_headless() { + let ctx = egui::Context::default(); + crate::ui::theme::apply_theme(&ctx, false); + ctx.run( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(400.0, 300.0), + )), + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + page_header(ui, "开始", Some("副标题")); + card(ui, |ui| { + section_title(ui, "卡片标题"); + hint(ui, "弱化提示"); + }); + subtle_card(ui, |ui| { + status_pill(ui, "运行中", Status::Ok); + status_pill(ui, "待确认", Status::Warn); + status_pill(ui, "失败", Status::Err); + }); + let mut selected = 0; + if let Some(picked) = segmented(ui, "t", &["听写", "润色"], selected) { + selected = picked; + } + let mut on = false; + toggle_row(ui, &mut on, "保持亮屏", Some("录手机输入时")); + assert_eq!(selected, 0); + assert!(!on); + }); + }, + ); + } +}