diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2821c2b6c..4d3f9075c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,9 +38,8 @@ jobs: msrv: ${{ steps.detect.outputs.msrv }} linux: ${{ steps.detect.outputs.linux }} harmony: ${{ steps.detect.outputs.harmony }} - # Only worth a macOS runner when the declared MSRV is actually older than - # the stable toolchain the other jobs already build with. - msrv_needed: ${{ steps.msrv.outputs.needed }} + # Validate the pinned minimum compiler whenever native sources can change. + msrv_needed: ${{ steps.detect.outputs.msrv }} steps: - uses: actions/checkout@v4 with: @@ -59,23 +58,6 @@ jobs: # 失败即运行:脚本读不到差异时返回 true,这里也只接受明确的 false。 echo "$area=$( [ "$verdict" = 'false' ] && echo false || echo true )" >> "$GITHUB_OUTPUT" done - - name: Is the declared MSRV older than stable? - id: msrv - # `rust-version` is the promise; a dedicated compile only proves something - # while it is behind the toolchain the other jobs use. When they coincide - # (today: 1.99 vs stable 1.99.x) the macOS MSRV job is pure duplication. - run: | - set -euo pipefail - # The workspace manifest carries no `rust-version`; every member declares - # its own. `openless-core` is the shared one, so read it there. - declared=$(grep -m1 '^rust-version' openless-all/app/crates/openless-core/Cargo.toml | sed -E 's/.*"([0-9.]+)".*/\1/') - stable=$(rustc --version | sed -E 's/rustc ([0-9]+\.[0-9]+)\.[0-9]+.*/\1/') - echo "declared MSRV: ${declared:-none}, stable: $stable" - if [ -z "$declared" ] || [ "$declared" = "$stable" ]; then - echo "needed=false" >> "$GITHUB_OUTPUT" - else - echo "needed=true" >> "$GITHUB_OUTPUT" - fi ui-motion: name: UI motion (Chromium + WebKit) @@ -820,11 +802,8 @@ jobs: macos-msrv: needs: [changes] - # Two conditions, both fail-open in the direction that matters: the change set - # must be able to reach Rust sources, and the declared MSRV must be older than - # the stable toolchain (otherwise `cross-platform` already compiles that code - # with stable, which is a superset of the MSRV promise). - if: needs.changes.outputs.msrv != 'false' && needs.changes.outputs.msrv_needed == 'true' + # Run the declared minimum toolchain even when stable has the same version. + if: needs.changes.outputs.msrv != 'false' name: macOS Rust 1.99 MSRV runs-on: macos-latest timeout-minutes: 60 diff --git a/.gitignore b/.gitignore index 3044965fb..a6f6947ea 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,10 @@ openless-all/app/windows-ime/obj/ # Planning docs are kept local only, not published to the public repo. docs/plans/ +# Local review attachments and build records are not repository inputs. +/docs/reviews/ +/docs/local-builds/ + # Internal AI 协作 + 规划 + 审计文档:本地保留,绝不发布到公共仓库。 # 之前误入库的整套已在 chore/remove-internal-docs 中 git rm --cached, # 这里加规则避免再次被 `git add` 拉回来。 diff --git a/openless-all/app/Cargo.lock b/openless-all/app/Cargo.lock index 95fa2698e..bfacf02f8 100644 --- a/openless-all/app/Cargo.lock +++ b/openless-all/app/Cargo.lock @@ -546,29 +546,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "aws-lc-rs" -version = "1.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - [[package]] name = "axum" version = "0.8.9" @@ -988,15 +965,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "cmov" version = "0.5.4" @@ -1501,12 +1469,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "easyfft" version = "0.4.2" @@ -2044,12 +2006,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "fst" version = "0.4.7" @@ -2597,6 +2553,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.9", ] [[package]] @@ -4041,21 +3998,17 @@ name = "openless-native-capsule" version = "0.1.0" dependencies = [ "eframe", + "memmap2", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "openless-capsule", "raw-window-handle", + "skrifa", "windows 0.58.0", "winit", ] -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "orbclient" version = "0.3.55" @@ -4659,7 +4612,6 @@ version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ - "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", @@ -4930,11 +4882,11 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "reqwest" -version = "0.13.5" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.23.1", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -4952,7 +4904,6 @@ dependencies = [ "quinn", "rustls", "rustls-pki-types", - "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", @@ -4968,6 +4919,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots 1.0.9", ] [[package]] @@ -5183,7 +5135,6 @@ version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ - "aws-lc-rs", "log", "once_cell", "ring", @@ -5193,18 +5144,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -5215,40 +5154,12 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" - [[package]] name = "rustls-webpki" version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -5275,15 +5186,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scoped-tls" version = "1.0.1" @@ -6540,9 +6442,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.5.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" dependencies = [ "futures-util", "js-sys", @@ -6680,15 +6582,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/openless-all/app/crates/openless-capsule/src/icons.rs b/openless-all/app/crates/openless-capsule/src/icons.rs index fa63d0a16..f2c93a2ac 100644 --- a/openless-all/app/crates/openless-capsule/src/icons.rs +++ b/openless-all/app/crates/openless-capsule/src/icons.rs @@ -1,76 +1,57 @@ +//! Lucide outlines (`Icon.tsx`), rasterized at the device size and tinted. use resvg::{tiny_skia, usvg}; -#[derive(Clone, Copy)] + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum IconName { Close, Check, } -fn lucide_svg(icon: IconName) -> Option<(&'static str, &'static str)> { - Some(match icon { - IconName::Close => ( - "Close", - include_str!("../../../linux-egui/assets/lucide/Close.svg"), - ), - IconName::Check => ( - "Check", - include_str!("../../../linux-egui/assets/lucide/Check.svg"), - ), - }) + +fn paths(icon: IconName) -> &'static str { + match icon { + // lucide `x` + IconName::Close => r#""#, + // lucide `check` + IconName::Check => r#""#, + } } -use super::theme; -const ICON_PIXELS: usize = 48; // 3x the 16-point sidebar/control icon. -const ICON_SIZE: f32 = 16.0; -fn rasterize_mask(source: &str) -> Option { - let tree = usvg::Tree::from_str(source, &usvg::Options::default()).ok()?; - let mut pixmap = tiny_skia::Pixmap::new(ICON_PIXELS as u32, ICON_PIXELS as u32)?; +/// White coverage mask for `size` points. As in lucide-react, `stroke_width` +/// is in the icon's 24-unit space and scales with `size`. +pub fn texture( + ctx: &egui::Context, + icon: IconName, + size: f32, + stroke_width: f32, +) -> Option { + let pixels = (size * ctx.pixels_per_point()).round().max(1.0) as u32; + let id = egui::Id::unique(("lucide-icon", icon, pixels, (stroke_width * 100.0) as u32)); + if let Some(texture) = ctx.data(|data| data.get_temp::(id)) { + return Some(texture); + } + let svg = format!( + r##"{}"##, + paths(icon) + ); + let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).ok()?; + let mut pixmap = tiny_skia::Pixmap::new(pixels, pixels)?; + let scale = pixels as f32 / 24.0; resvg::render( &tree, - tiny_skia::Transform::from_scale(ICON_PIXELS as f32 / 24.0, ICON_PIXELS as f32 / 24.0), + tiny_skia::Transform::from_scale(scale, scale), &mut pixmap.as_mut(), ); - // tiny-skia returns premultiplied white; ColorImage expects straight RGBA. - // SVGs contain only white strokes, so opacity is the entire mask. - let mut pixels = Vec::with_capacity(ICON_PIXELS * ICON_PIXELS * 4); + // tiny-skia returns premultiplied white; the mask is the alpha channel. + let mut rgba = Vec::with_capacity(pixmap.data().len()); for pixel in pixmap.data().as_chunks::<4>().0 { - pixels.extend_from_slice(&[255, 255, 255, pixel[3]]); - } - Some(egui::ColorImage::from_rgba_unmultiplied( - [ICON_PIXELS, ICON_PIXELS], - &pixels, - )) -} - -/// Draw the same SVG outlines used by Tauri, recolored for this surface. -/// Texture handles live in egui's context and are reused across frames/windows. -pub fn draw_icon(ui: &egui::Ui, center: egui::Pos2, icon: IconName, color: egui::Color32) { - let Some((name, svg)) = lucide_svg(icon) else { - // Not part of Icon.tsx: the account avatar fallback is deliberately - // separate from the monochrome Lucide interface controls. - let p = ui.painter(); - p.circle_filled(center, 7.0, color.gamma_multiply(0.75)); - p.circle_filled(center + egui::vec2(0.0, 3.0), 3.4, theme::SURFACE_2); - return; - }; - let ctx = ui.ctx(); - let id = egui::Id::unique(("lucide-icon", name)); - let texture = ctx - .data(|data| data.get_temp::(id)) - .or_else(|| { - let image = rasterize_mask(svg)?; - let texture = ctx.load_texture( - format!("lucide-{name}"), - image, - egui::TextureOptions::LINEAR, - ); - ctx.data_mut(|data| data.insert_temp(id, texture.clone())); - Some(texture) - }); - if let Some(texture) = texture { - ui.painter().image( - texture.id(), - egui::Rect::from_center_size(center, egui::vec2(ICON_SIZE, ICON_SIZE)), - egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), - color, - ); + rgba.extend_from_slice(&[255, 255, 255, pixel[3]]); } + let image = egui::ColorImage::from_rgba_unmultiplied([pixels as usize; 2], &rgba); + let texture = ctx.load_texture( + format!("lucide-{icon:?}-{pixels}"), + image, + egui::TextureOptions::LINEAR, + ); + ctx.data_mut(|data| data.insert_temp(id, texture.clone())); + Some(texture) } diff --git a/openless-all/app/crates/openless-capsule/src/lib.rs b/openless-all/app/crates/openless-capsule/src/lib.rs index 589ccf2e2..23499b988 100644 --- a/openless-all/app/crates/openless-capsule/src/lib.rs +++ b/openless-all/app/crates/openless-capsule/src/lib.rs @@ -3,7 +3,9 @@ use serde::{Deserialize, Serialize}; use std::io::{self, BufRead, Write}; -pub const PROTOCOL_VERSION: u16 = 1; +/// v2: the default (OpenLess) capsule needs warming/operating/inserted counts, +/// the translation target, theme, the leaving (exit) frame and the full label set. +pub const PROTOCOL_VERSION: u16 = 2; pub const MAX_LINE_BYTES: usize = 1024 * 1024; #[cfg(feature = "ui")] @@ -11,8 +13,14 @@ mod icons; #[cfg(feature = "ui")] mod layout; #[cfg(feature = "ui")] +mod motion; +#[cfg(feature = "ui")] +pub mod pill; +#[cfg(feature = "ui")] pub mod renderer; #[cfg(feature = "ui")] +mod shapes; +#[cfg(feature = "ui")] pub mod siri_gl; #[cfg(feature = "host")] pub mod supervisor; @@ -26,14 +34,38 @@ pub struct CapsulePopupState { pub audio_level: Option, pub translation_active: bool, pub style: String, + /// Optimistically shown before the first microphone frame. + #[serde(default)] + pub warming: bool, + /// Less Computer session: processing reads "using" instead of "thinking". + #[serde(default)] + pub operating: bool, + #[serde(default)] + pub inserted_chars: Option, + /// Selection polish shares the capsule window but shows a one-line notice. + #[serde(default)] + pub selection_polish: bool, + /// Display name of the translation target, already localized by the host. + #[serde(default)] + pub translation_target: Option, } +/// Copy shared with the WebView capsule (`src/i18n/*.ts`, `capsule.*`). +/// `inserted` keeps the i18next `{{count}}` placeholder. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] pub struct CapsuleLabels { pub thinking: String, + pub using: String, pub cancelled: String, pub error: String, + pub inserted: String, + pub translate_to: String, pub translating: String, + pub polishing: String, + pub replaced: String, + pub no_selection: String, + pub polish_failed: String, } impl Default for CapsuleLabels { @@ -44,60 +76,170 @@ impl Default for CapsuleLabels { impl CapsuleLabels { pub fn for_locale(locale: &str) -> Self { - let (thinking, cancelled, error, translating) = - if locale.starts_with("zh-TW") || locale.starts_with("zh-HK") { - ("正在處理", "已取消", "處理失敗", "正在翻譯") - } else if locale.starts_with("zh") { - ("正在处理", "已取消", "处理失败", "正在翻译") - } else if locale.starts_with("ja") { - ("処理中", "キャンセル済み", "処理に失敗しました", "翻訳中") - } else if locale.starts_with("ko") { - ("처리 중", "취소됨", "처리 실패", "번역 중") - } else if locale.starts_with("es") { - ( - "pensando", - "Cancelado", - "Se ha producido un error", - "Traduciendo", - ) - } else if locale.starts_with("fr") { - ( - "réflexion", - "Annulé", - "Une erreur s’est produite", - "Traduction", - ) - } else if locale.starts_with("de") { - ( - "denkt nach", - "Abgebrochen", - "Ein Fehler ist aufgetreten", - "Wird übersetzt", - ) - } else { - ("Processing", "Cancelled", "Failed", "Translating") - }; + let labels: [&str; 11] = if locale.starts_with("zh-TW") || locale.starts_with("zh-HK") { + [ + "thinking", + "using", + "已取消", + "出錯了", + "已插入 {{count}}", + "翻譯為", + "正在翻譯", + "正在潤色...", + "已替換", + "未選中內容", + "潤色失敗,請重試", + ] + } else if locale.starts_with("zh") { + [ + "thinking", + "using", + "已取消", + "出错了", + "已插入 {{count}}", + "翻译为", + "正在翻译", + "正在润色...", + "已替换", + "未选中内容", + "润色失败,请重试", + ] + } else if locale.starts_with("ja") { + [ + "thinking", + "using", + "キャンセルしました", + "エラーが発生しました", + "{{count}} 文字を入力しました", + "翻訳先", + "翻訳中", + "推敲中...", + "置き換えました", + "選択されていません", + "推敲に失敗しました。もう一度お試しください", + ] + } else if locale.starts_with("ko") { + [ + "thinking", + "using", + "취소됨", + "오류 발생", + "{{count}}자 입력됨", + "번역 언어", + "번역 중", + "다듬는 중...", + "교체됨", + "선택된 내용 없음", + "다듬기 실패, 다시 시도하세요", + ] + } else if locale.starts_with("es") { + [ + "pensando", + "actuando", + "Cancelado", + "Se ha producido un error", + "{{count}} insertados", + "Traducir al", + "Traduciendo", + "Mejorando el texto…", + "Reemplazado", + "No hay texto seleccionado", + "No se pudo mejorar el texto. Inténtalo de nuevo", + ] + } else if locale.starts_with("fr") { + [ + "réflexion", + "action", + "Annulé", + "Une erreur s’est produite", + "{{count}} insérés", + "Traduire en", + "Traduction", + "Amélioration du texte…", + "Remplacé", + "Aucun texte sélectionné", + "L’amélioration a échoué. Réessayez", + ] + } else if locale.starts_with("de") { + [ + "denkt nach", + "verwendet", + "Abgebrochen", + "Ein Fehler ist aufgetreten", + "{{count}} eingefügt", + "Übersetzen in", + "Wird übersetzt", + "Wird überarbeitet…", + "Ersetzt", + "Kein Text ausgewählt", + "Überarbeitung fehlgeschlagen. Erneut versuchen", + ] + } else { + [ + "thinking", + "using", + "Cancelled", + "Something went wrong", + "Inserted {{count}}", + "Translate to", + "Translating", + "Polishing…", + "Replaced", + "Nothing selected", + "Polish failed, try again", + ] + }; + let [thinking, using, cancelled, error, inserted, translate_to, translating, polishing, replaced, no_selection, polish_failed] = + labels.map(String::from); Self { - thinking: thinking.into(), - cancelled: cancelled.into(), - error: error.into(), - translating: translating.into(), + thinking, + using, + cancelled, + error, + inserted, + translate_to, + translating, + polishing, + replaced, + no_selection, + polish_failed, } } } +/// The app's theme preference; `System` is resolved by the helper itself. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThemeMode { + #[default] + System, + Light, + Dark, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Frame { pub session_id: String, /// Presentation epoch, independent of the Core's event sequence. pub sequence: u64, pub state: CapsulePopupState, + /// Live recognition text for this session. The rail only shows it when + /// `show_transcript` is set; the processing label may still use it, as the + /// WebView capsule does. #[serde(default)] pub transcript: String, + #[serde(default)] + pub show_transcript: bool, #[serde(default = "default_transcript_font_size")] pub transcript_font_size: f32, #[serde(default)] pub labels: CapsuleLabels, + #[serde(default)] + pub theme: ThemeMode, + /// The last visible frame, replayed while the helper plays the exit motion. + /// The host stops the helper once the motion has finished. + #[serde(default)] + pub leaving: bool, /// Native pixels in the global virtual desktop; chosen by the host before /// the helper is shown, so the helper never has to activate itself. pub bounds: Option<[i32; 4]>, @@ -112,7 +254,8 @@ impl Frame { self.state.phase != "idle" } pub fn update_transcript(&mut self, session_id: &str, text: String) -> bool { - if self.session_id != session_id + if self.leaving + || self.session_id != session_id || !matches!( self.state.phase.as_str(), "recording" | "transcribing" | "polishing" @@ -125,6 +268,7 @@ impl Frame { } pub fn accepts(&self, session_id: &str, sequence: u64, action: Action) -> bool { self.interactive + && !self.leaving && self.session_id == session_id && sequence <= self.sequence && match action { @@ -135,6 +279,14 @@ impl Frame { ), } } + /// Turn the last visible frame into its exit frame: no controls, no rail. + pub fn into_leaving(mut self, sequence: u64) -> Self { + self.leaving = true; + self.interactive = false; + self.sequence = self.sequence.max(sequence); + self.transcript.clear(); + self + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -221,8 +373,11 @@ mod tests { ..Default::default() }, transcript: String::new(), + show_transcript: true, transcript_font_size: 15.0, labels: Default::default(), + theme: ThemeMode::System, + leaving: false, bounds: None, interactive: true, } @@ -251,6 +406,28 @@ mod tests { assert_eq!(frame.transcript, "latest text"); } #[test] + fn a_leaving_frame_keeps_its_look_but_drops_controls_rail_and_late_text() { + let mut leaving = frame(); + leaving.transcript = "spoken".into(); + let mut leaving = leaving.into_leaving(50); + assert!(leaving.visible() && leaving.leaving && !leaving.interactive); + assert_eq!((leaving.sequence, leaving.transcript.as_str()), (50, "")); + assert!(!leaving.accepts("new", 42, Action::Cancel)); + assert!(!leaving.update_transcript("new", "late".into())); + // An exit can never move the presentation epoch backwards. + assert_eq!(frame().into_leaving(7).sequence, 42); + } + #[test] + fn v1_frames_without_new_fields_still_parse_with_safe_defaults() { + let frame: Frame = serde_json::from_str( + r#"{"session_id":"s","sequence":1,"state":{"phase":"recording","text":"","audio_level":0.2,"translation_active":false,"style":"classic"},"bounds":null,"interactive":true}"#, + ) + .unwrap(); + assert!(!frame.leaving && !frame.state.warming && !frame.show_transcript); + assert_eq!(frame.theme, ThemeMode::System); + assert_eq!(frame.labels, CapsuleLabels::for_locale("en")); + } + #[test] fn jsonl_rejects_oversized_or_unterminated_frames() { assert!( read_message::(&mut io::Cursor::new(vec![b'x'; MAX_LINE_BYTES + 2])) @@ -263,4 +440,51 @@ mod tests { .is_none() ); } + + /// Source contract: the helper repeats the WebView's `capsule.*` copy, so a + /// change to the i18n files must be mirrored in `CapsuleLabels::for_locale`. + #[test] + fn labels_match_the_webview_i18n_sources() { + fn value(block: &str, key: &str) -> String { + let start = block + .find(&format!("\n {key}: ")) + .or_else(|| block.find(&format!("\n {key}: "))) + .unwrap_or_else(|| panic!("missing capsule.{key}")); + let rest = &block[start..]; + let quote = rest.find('\'').unwrap() + 1; + let end = quote + rest[quote..].find("',").unwrap(); + rest[quote..end].to_string() + } + for (file, locale) in [ + ("en", "en"), + ("zh-CN", "zh-CN"), + ("zh-TW", "zh-TW"), + ("ja", "ja"), + ("ko", "ko"), + ("es", "es"), + ("fr", "fr"), + ("de", "de"), + ] { + let path = format!("{}/../../src/i18n/{file}.ts", env!("CARGO_MANIFEST_DIR")); + let source = std::fs::read_to_string(&path).unwrap(); + let start = source.find("\n capsule: {").expect("capsule block"); + let block = &source[start..start + source[start..].find("\n },").unwrap()]; + let labels = CapsuleLabels::for_locale(locale); + for (key, actual) in [ + ("thinking", &labels.thinking), + ("using", &labels.using), + ("cancelled", &labels.cancelled), + ("error", &labels.error), + ("inserted", &labels.inserted), + ("translateTo", &labels.translate_to), + ("translating", &labels.translating), + ("polishing", &labels.polishing), + ("replaced", &labels.replaced), + ("noSelection", &labels.no_selection), + ("failed", &labels.polish_failed), + ] { + assert_eq!(&value(block, key), actual, "{file}: capsule.{key}"); + } + } + } } diff --git a/openless-all/app/crates/openless-capsule/src/motion.rs b/openless-all/app/crates/openless-capsule/src/motion.rs new file mode 100644 index 000000000..a8f8f2476 --- /dev/null +++ b/openless-all/app/crates/openless-capsule/src/motion.rs @@ -0,0 +1,202 @@ +//! Easing and spring integration for capsule transitions. Interrupted animations +//! continue from their current values instead of restarting at their initial state. + +/// `cubic-bezier(x1, y1, x2, y2)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Ease(pub f32, pub f32, pub f32, pub f32); + +pub const LINEAR: Ease = Ease(0.0, 0.0, 1.0, 1.0); +/// CSS `ease`. +pub const EASE: Ease = Ease(0.25, 0.1, 0.25, 1.0); +/// Symmetric acceleration and deceleration for property transitions. +pub const EASE_IN_OUT: Ease = Ease(0.42, 0.0, 0.58, 1.0); +/// `MORPH` in OpenLessCapsule.tsx. +pub const MORPH: Ease = Ease(0.22, 1.0, 0.36, 1.0); + +impl Ease { + pub fn at(self, t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + if self == LINEAR || t == 0.0 || t == 1.0 { + return t; + } + let Ease(x1, y1, x2, y2) = self; + let bezier = |a: f32, b: f32, s: f32| { + let u = 1.0 - s; + 3.0 * u * u * s * a + 3.0 * u * s * s * b + s * s * s + }; + // x(s) is monotonic for valid CSS curves: bisect for the parameter. + let (mut low, mut high) = (0.0_f32, 1.0_f32); + for _ in 0..24 { + let mid = (low + high) * 0.5; + if bezier(x1, x2, mid) < t { + low = mid; + } else { + high = mid; + } + } + bezier(y1, y2, (low + high) * 0.5) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Tween { + from: f32, + to: f32, + start: f64, + delay: f64, + duration: f64, + ease: Ease, +} + +impl Tween { + pub fn at(value: f32) -> Self { + Self { + from: value, + to: value, + start: 0.0, + delay: 0.0, + duration: 0.0, + ease: LINEAR, + } + } + + pub fn value(&self, now: f64) -> f32 { + let elapsed = now - self.start - self.delay; + if elapsed < 0.0 { + return self.from; + } + if self.duration <= 0.0 || elapsed >= self.duration { + return self.to; + } + let progress = self.ease.at((elapsed / self.duration) as f32); + self.from + (self.to - self.from) * progress + } + + pub fn active(&self, now: f64) -> bool { + now < self.start + self.delay + self.duration && self.from != self.to + } + + /// Retarget from the current value; an unchanged target keeps running. + pub fn set(&mut self, now: f64, to: f32, duration: f64, delay: f64, ease: Ease) { + if to == self.to { + return; + } + *self = Self { + from: self.value(now), + to, + start: now, + delay, + duration, + ease, + }; + } + + pub fn jump(&mut self, value: f32) { + *self = Self::at(value); + } +} + +/// A damped mass-spring system integrated in bounded time steps. +#[derive(Clone, Copy, Debug)] +pub struct Spring { + pub value: f32, + velocity: f32, + pub target: f32, + stiffness: f32, + damping: f32, + mass: f32, +} + +impl Spring { + pub fn new(value: f32, stiffness: f32, damping: f32, mass: f32) -> Self { + Self { + value, + velocity: 0.0, + target: value, + stiffness, + damping, + mass, + } + } + + pub fn step(&mut self, dt: f32) { + let mut remaining = dt.clamp(0.0, 0.05); + while remaining > 0.0 { + let h = remaining.min(1.0 / 240.0); + let force = -self.stiffness * (self.value - self.target) - self.damping * self.velocity; + self.velocity += force / self.mass * h; + self.value += self.velocity * h; + remaining -= h; + } + if !self.active() { + self.value = self.target; + self.velocity = 0.0; + } + } + + pub fn active(&self) -> bool { + (self.value - self.target).abs() > 0.05 || self.velocity.abs() > 0.5 + } + + pub fn jump(&mut self, value: f32) { + self.value = value; + self.target = value; + self.velocity = 0.0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn eases_hit_their_endpoints_and_the_morph_curve_front_loads_motion() { + for ease in [LINEAR, EASE, EASE_IN_OUT, MORPH] { + assert_eq!(ease.at(0.0), 0.0); + assert_eq!(ease.at(1.0), 1.0); + } + assert!((EASE_IN_OUT.at(0.5) - 0.5).abs() < 1e-3); + // 50% of the 220ms morph has covered most of the distance. + assert!(MORPH.at(0.5) > 0.85); + } + + #[test] + fn an_interrupted_tween_restarts_from_where_it_is() { + let mut width = Tween::at(116.0); + width.set(0.0, 80.0, 0.22, 0.0, MORPH); + let midway = width.value(0.05); + assert!(midway < 116.0 && midway > 80.0); + width.set(0.05, 116.0, 0.22, 0.0, MORPH); + assert_eq!(width.value(0.05), midway); + assert_eq!(width.value(1.0), 116.0); + assert!(!width.active(1.0)); + } + + #[test] + fn delayed_tweens_hold_their_start_value() { + let mut opacity = Tween::at(0.0); + opacity.set(1.0, 1.0, 0.08, 0.12, LINEAR); + assert_eq!(opacity.value(1.1), 0.0); + assert!((opacity.value(1.16) - 0.5).abs() < 1e-3); + assert_eq!(opacity.value(1.3), 1.0); + } + + #[test] + fn reduced_motion_lands_in_the_same_frame() { + let mut width = Tween::at(68.0); + width.set(2.0, 116.0, 0.0, 0.0, MORPH); + assert_eq!(width.value(2.0), 116.0); + assert!(!width.active(2.0)); + } + + #[test] + fn springs_settle_on_their_target() { + let mut spring = Spring::new(72.0, 330.0, 32.0, 0.9); + spring.target = 200.0; + for _ in 0..120 { + spring.step(1.0 / 60.0); + } + assert_eq!(spring.value, 200.0); + assert!(!spring.active()); + } +} diff --git a/openless-all/app/crates/openless-capsule/src/pill.rs b/openless-all/app/crates/openless-capsule/src/pill.rs new file mode 100644 index 000000000..edc16ab45 --- /dev/null +++ b/openless-all/app/crates/openless-capsule/src/pill.rs @@ -0,0 +1,1082 @@ +//! Draws the default recording capsule, translation target, transcript rail and +//! selection notice, anchored to the bottom of the helper window. +use crate::motion::{Spring, Tween, EASE, EASE_IN_OUT, LINEAR, MORPH}; +use crate::renderer::CapsuleAction; +use crate::{icons, shapes, Frame}; +use egui::emath::TSTransform; +use egui::epaint::{Shadow, TextShape}; +use egui::epaint::text::VariationCoords; +use egui::text::{LayoutJob, TextFormat, TextWrapping}; +use egui::{pos2, vec2, Color32, Painter, Pos2, Rect, Shape, Stroke, StrokeKind}; +use std::sync::Arc; + +const HEIGHT: f32 = 32.0; +const RADIUS: u8 = 16; +const RECORDING_WIDTH: f32 = 116.0; +const COMPACT_WIDTH: f32 = 68.0; +const STATUS_PADDING: f32 = 14.0; +/// `getCapsuleHostMetrics(.., 'classic').bottomInset`. +const BOTTOM_INSET: f32 = 16.0; +const BUTTON_RADIUS: f32 = 12.0; +/// Buttons sit 4px inside the fixed 116px recording layer. +const BUTTON_OFFSET: f32 = RECORDING_WIDTH / 2.0 - 4.0 - BUTTON_RADIUS; +// Measured from the 60 fps reference: 232×64 physical pixels at 2×. +const ENVELOPE: [f32; 11] = [0.22, 0.34, 0.48, 0.65, 0.82, 1.0, 0.82, 0.65, 0.48, 0.34, 0.22]; +const STACK_GAP: f32 = 8.0; +const BADGE_HEIGHT: f32 = 28.0; +const RAIL_HEIGHT: f32 = 52.0; +const RAIL_PAD: f32 = 18.0; +const RAIL_MIN_WIDTH: f32 = 72.0; +/// `maxWidth={hostMetrics.width - 16}` in the 460px Classic host. +const RAIL_MAX_WIDTH: f32 = 444.0; +const RAIL_TAIL_CHARS: usize = 160; + +const BLUE: Color32 = Color32::from_rgb(0x25, 0x63, 0xeb); +const BLUE_HOVER: Color32 = Color32::from_rgb(0x1d, 0x4e, 0xd8); +const BORDER: Color32 = Color32::from_rgb(0xd4, 0xd4, 0xd8); +const SWEEP: Color32 = Color32::from_rgb(0xdb, 0xea, 0xfe); +const CANCEL_BG: Color32 = Color32::from_rgb(0xf0, 0xf0, 0xf2); +const CANCEL_HOVER: Color32 = Color32::from_rgb(0xe4, 0xe4, 0xe7); +const CANCEL_INK: Color32 = Color32::from_rgb(0x52, 0x52, 0x5b); +const ERROR: Color32 = Color32::from_rgb(0xdc, 0x26, 0x26); + +/// `tokens.css` values for the parts that follow the app theme. The pill and +/// the badge shell are `color-scheme: light` and never change. +struct Tokens { + surface: Color32, + line: Color32, + shadow: Color32, + ring: Color32, + highlight: Color32, + ink: Color32, + medium: f32, + semibold: f32, + control: Color32, + control_line: Color32, + control_ink: Color32, +} + +fn tokens(dark: bool) -> Tokens { + let rgba = Color32::from_rgba_unmultiplied; + if dark { + Tokens { + surface: Color32::from_rgb(0x1b, 0x22, 0x2c), + line: rgba(226, 232, 240, 31), + shadow: rgba(0, 0, 0, 122), + ring: rgba(226, 232, 240, 15), + highlight: rgba(255, 255, 255, 20), + ink: Color32::from_rgb(0xf4, 0xf7, 0xfb), + medium: 460.0, + semibold: 560.0, + // rgba(24, 24, 27, .98) over the white badge. + control: Color32::from_rgb(29, 29, 32), + control_line: Color32::from_rgb(0x3f, 0x3f, 0x46), + control_ink: Color32::from_rgb(0xfa, 0xfa, 0xfa), + } + } else { + Tokens { + surface: Color32::from_rgb(0xfa, 0xfa, 0xfa), + line: rgba(0, 0, 0, 26), + shadow: rgba(15, 17, 22, 41), + ring: rgba(0, 0, 0, 15), + highlight: rgba(255, 255, 255, 184), + ink: Color32::from_rgb(0x18, 0x18, 0x1b), + medium: 500.0, + semibold: 600.0, + control: Color32::WHITE, + control_line: BORDER, + control_ink: Color32::from_rgb(0x09, 0x09, 0x0b), + } + } +} + +/// Families the helper installs for static CJK faces at CSS weights 500 and +/// 600 (`Proportional` is 400). The variable system face covers every weight. +pub const MEDIUM_FAMILY: &str = "openless-medium"; +pub const SEMIBOLD_FAMILY: &str = "openless-semibold"; + +/// `--ol-font-sans` at a CSS weight: the static face CSS weight matching +/// would pick, with the variable face's weight and optical size set exactly. +fn format(size: f32, weight: f32, line_height: f32, color: Color32) -> TextFormat { + let family = if weight < 450.0 { + egui::FontFamily::Proportional + } else if weight < 550.0 { + egui::FontFamily::Name(MEDIUM_FAMILY.into()) + } else { + egui::FontFamily::Name(SEMIBOLD_FAMILY.into()) + }; + TextFormat { + font_id: egui::FontId::new(size, family), + line_height: Some(line_height), + color, + coords: VariationCoords::new([("wght", weight), ("opsz", size)]), + ..Default::default() + } +} + +/// One line, ellipsized like `text-overflow: ellipsis` (cut at any glyph). +fn line(painter: &Painter, text: &str, format: TextFormat, max_width: f32) -> Arc { + let mut job = LayoutJob::single_section(text.to_owned(), format); + job.wrap = TextWrapping { + max_width: max_width.max(1.0), + max_rows: 1, + break_anywhere: true, + overflow_character: Some('…'), + }; + painter.layout_job(job) +} + +fn text(pos: Pos2, galley: Arc, opacity: f32) -> Shape { + TextShape::new(pos, galley, Color32::PLACEHOLDER) + .with_opacity_factor(opacity) + .into() +} + +/// Physical-pixel aligned square for a texture rasterized at `size` points. +fn snapped_square(center: Pos2, size: f32, pixels_per_point: f32) -> Rect { + let side = (size * pixels_per_point).round() / pixels_per_point; + let min = center - vec2(side, side) * 0.5; + let min = pos2( + (min.x * pixels_per_point).round() / pixels_per_point, + (min.y * pixels_per_point).round() / pixels_per_point, + ); + Rect::from_min_size(min, vec2(side, side)) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Target { + Cancel, + Confirm, + /// `.ol-default-processing-cancel`: the whole processing pill. + Processing, +} + +/// `CapsuleWaveform`: the level is smoothed per frame, then shaped by the +/// envelope and a slow ripple. +#[derive(Default)] +struct Waveform { + smoothed: f32, + previous: Option, + active: Option, +} + +impl Waveform { + fn heights(&mut self, now: f64, level: f32, active: bool, reduced: bool) -> [f32; 11] { + if self.active != Some(active) { + // The effect restarts from rest whenever `active` flips. + self.active = Some(active); + self.smoothed = 0.0; + self.previous = None; + } + let voice = if active { + ((level - 0.008) / 0.32).clamp(0.0, 1.0) + } else { + 0.0 + }; + if reduced { + // Under reduced motion the WebView draws a single frame per level. + self.smoothed = voice * (1.0 - (-16.0_f32 / 35.0).exp()); + } else { + let dt = self + .previous + .map_or(16.0, |previous| ((now - previous) * 1000.0).min(50.0) as f32); + self.previous = Some(now); + let tau = if voice > self.smoothed { 35.0 } else { 90.0 }; + self.smoothed += (voice - self.smoothed) * (1.0 - (-dt / tau).exp()); + } + let phase = (now * 1000.0 / 85.0) as f32; + let strength = self.smoothed.powf(0.6); + std::array::from_fn(|index| { + let ripple = if reduced { + 1.0 + } else { + 0.86 + 0.14 * (phase + index as f32 * 0.8).sin() + }; + 1.5 + 14.5 * strength * ENVELOPE[index] * ripple + }) + } +} + +/// `CapsuleTranscriptRail` (frost): a right-anchored line whose shell hugs the +/// text, growing on a spring and re-centring a little later. +struct Rail { + text: String, + births: Vec, + galley_width: f32, + width: Spring, + right: Spring, + shift: Spring, + width_at: Option<(f64, f32)>, + right_at: Option<(f64, f32)>, + previous: Option, +} + +impl Rail { + fn new(stage_center: f32) -> Self { + Self { + text: String::new(), + births: Vec::new(), + galley_width: 0.0, + // INSERT_TEXT_MOTION.widthSpring / recenterSpring / shiftSpring. + width: Spring::new(RAIL_MIN_WIDTH, 330.0, 32.0, 0.9), + right: Spring::new(stage_center + RAIL_MIN_WIDTH / 2.0, 180.0, 27.0, 1.0), + shift: Spring::new(0.0, 260.0, 30.0, 1.0), + width_at: None, + right_at: None, + previous: None, + } + } + + /// Births are per character of the whole transcript; only the tail is drawn. + fn tail(&self) -> (&str, &[f64]) { + let count = self.births.len(); + let skip = count.saturating_sub(RAIL_TAIL_CHARS); + let start = self + .text + .char_indices() + .nth(skip) + .map_or(self.text.len(), |(index, _)| index); + (&self.text[start..], &self.births[skip..]) + } + + fn job(&self, now: f64, size: f32, weight: f32, ink: Color32) -> LayoutJob { + let (tail, births) = self.tail(); + let mut job = LayoutJob::default(); + let mut format = format(size, weight, size * 1.2, ink); + // `glyphGapPx`: every measured glyph carries 1.4px of air. + format.extra_letter_spacing = 1.4; + let mut start = 0; + let mut current = None; + for ((index, _), birth) in tail.char_indices().zip(births) { + // INSERT_TEXT_MOTION.bornFadeMs, quantized so a line keeps few sections. + let alpha = (((now - birth) / 0.18).clamp(0.0, 1.0) * 16.0).round() as u8; + if current.is_some_and(|value| value != alpha) { + let mut section = format.clone(); + section.color = ink.gamma_multiply(current.unwrap() as f32 / 16.0); + job.append(&tail[start..index], 0.0, section); + start = index; + } + current = Some(alpha); + } + if let Some(alpha) = current { + format.color = ink.gamma_multiply(alpha as f32 / 16.0); + job.append(&tail[start..], 0.0, format); + } + job + } + + #[allow(clippy::too_many_arguments)] + fn update( + &mut self, + painter: &Painter, + text: &str, + size: f32, + weight: f32, + ink: Color32, + stage_center: f32, + now: f64, + reduced: bool, + ) { + if text != self.text { + let common = self + .text + .chars() + .zip(text.chars()) + .take_while(|(old, new)| old == new) + .count(); + self.births.truncate(common); + let born = text.chars().count() - common; + // bornStaggerMs between the glyphs of one insertion. + self.births + .extend((0..born).map(|index| if reduced { 0.0 } else { now + 0.008 * index as f64 })); + self.text = text.to_owned(); + let galley = painter.layout_job(self.job(now, size, weight, ink)); + let width = galley.size().x; + let old_content = self.galley_width.min(RAIL_MAX_WIDTH - 2.0 * RAIL_PAD); + let delta = width - self.galley_width; + self.galley_width = width; + let content = width.min(RAIL_MAX_WIDTH - 2.0 * RAIL_PAD); + let target = (content + 2.0 * RAIL_PAD).clamp(RAIL_MIN_WIDTH, RAIL_MAX_WIDTH); + let right = stage_center + target / 2.0; + if reduced { + self.width.jump(target); + self.right.jump(right); + } else { + // `planCapsuleInsertMotion`: the change ripples out from where + // the new glyphs are born, then the shell re-centres. + let width_delay = (12.0 + (old_content / 2.35).min(96.0)) as f64 / 1000.0; + self.width_at = Some((now + width_delay, target)); + self.right_at = Some((now + width_delay + 0.14, right)); + // Retained glyphs keep their place and slide to the new anchor. + self.shift.value += delta; + } + } + let dt = self + .previous + .map_or(0.0, |previous| (now - previous) as f32); + self.previous = Some(now); + for (spring, pending) in [ + (&mut self.width, &mut self.width_at), + (&mut self.right, &mut self.right_at), + ] { + match *pending { + // A stopped motion value holds still until its delayed spring starts. + Some((at, _)) if now < at => {} + Some((_, target)) => { + spring.jump(spring.value); + spring.target = target; + *pending = None; + spring.step(dt); + } + None => spring.step(dt), + } + } + self.shift.step(dt); + } + + fn animating(&self, now: f64) -> bool { + self.width_at.is_some() + || self.right_at.is_some() + || self.width.active() + || self.right.active() + || self.shift.active() + || self.births.last().is_some_and(|birth| now - birth < 0.18) + } +} + +pub struct DefaultCapsule { + started: bool, + width: Tween, + opacity: Tween, + lift: Tween, + layer: Tween, + actions: Tween, + status: Tween, + cancel_hover: Tween, + confirm_hover: Tween, + badge_opacity: Tween, + badge_lift: Tween, + badge_scale: Tween, + sweep_since: Option, + waveform: Waveform, + pressed: Option, + rail: Option, +} + +impl Default for DefaultCapsule { + fn default() -> Self { + Self { + started: false, + width: Tween::at(COMPACT_WIDTH), + opacity: Tween::at(0.0), + lift: Tween::at(8.0), + layer: Tween::at(0.0), + actions: Tween::at(0.0), + status: Tween::at(0.0), + cancel_hover: Tween::at(0.0), + confirm_hover: Tween::at(0.0), + badge_opacity: Tween::at(0.0), + badge_lift: Tween::at(7.0), + badge_scale: Tween::at(0.94), + sweep_since: None, + waveform: Waveform::default(), + pressed: None, + rail: None, + } + } +} + +/// `OpenLessPill`'s label for the non-recording states. +fn status_label(frame: &Frame) -> String { + let state = &frame.state; + let labels = &frame.labels; + // Capsule.tsx shows the local recognition while transcribing. + let live = frame.transcript.trim(); + let message = if state.phase == "transcribing" && !live.is_empty() { + live + } else { + state.text.trim() + }; + let fallback = |text: &str| { + if message.is_empty() { + text.to_owned() + } else { + message.to_owned() + } + }; + match state.phase.as_str() { + "transcribing" | "polishing" => fallback(if state.operating { + &labels.using + } else { + &labels.thinking + }), + "completed" => fallback( + &labels + .inserted + .replace("{{count}}", &state.inserted_chars.unwrap_or(0).to_string()), + ), + "cancelled" => labels.cancelled.clone(), + _ => fallback(&labels.error), + } +} + +impl DefaultCapsule { + /// Draws the capsule for `frame` and reports a clicked control. `dark` + /// is the resolved app theme; the pill itself is theme independent. + pub fn show( + &mut self, + ui: &egui::Ui, + frame: &Frame, + dark: bool, + reduced: bool, + ) -> CapsuleAction { + let painter = ui.painter().clone(); + let now = ui.input(|input| input.time); + let stage = ui.max_rect(); + let tokens = tokens(dark); + if frame.state.selection_polish { + if notice(&painter, stage, frame, &tokens, now) && !reduced { + ui.ctx().request_repaint(); + } + return CapsuleAction::None; + } + + let state = &frame.state; + let phase = state.phase.as_str(); + let recording = phase == "recording"; + let warming = recording && state.warming; + let processing = matches!(phase, "transcribing" | "polishing"); + let leaving = frame.leaving; + let label = status_label(frame); + let label_format = format(12.0, 500.0, 18.0, if phase == "failed" { ERROR } else { BLUE }); + let label_width = painter + .layout_job(LayoutJob::single_section(label.clone(), label_format.clone())) + .size() + .x; + // `scrollWidth + 28`, rounded up so a label that fits is never ellipsized. + let status_width = (label_width.ceil() + 2.0 * STATUS_PADDING).clamp(80.0, 300.0); + let time = |seconds: f64| if reduced { 0.0 } else { seconds }; + + if !self.started { + self.started = true; + let layer = (recording || warming) as u8 as f32; + self.layer.jump(layer); + self.status.jump(1.0 - layer); + self.actions.jump(recording as u8 as f32); + if reduced { + self.width.jump(if warming { COMPACT_WIDTH } else if recording { RECORDING_WIDTH } else { status_width }); + self.opacity.jump(1.0); + self.lift.jump(0.0); + } + } + // Animate shell geometry independently from the content opacity. + let width = if leaving || warming { + COMPACT_WIDTH + } else if recording { + RECORDING_WIDTH + } else { + status_width + }; + self.width.set(now, width, time(0.22), 0.0, MORPH); + self.opacity.set( + now, + if leaving { 0.0 } else { 1.0 }, + time(if leaving { 0.16 } else { 0.1 }), + 0.0, + EASE_IN_OUT, + ); + self.lift.set( + now, + if leaving { 7.0 } else { 0.0 }, + time(if leaving { 0.18 } else { 0.22 }), + 0.0, + EASE_IN_OUT, + ); + // CSS opacity transitions of the layers inside the shell. + let layer_visible = recording || warming; + self.layer + .set(now, layer_visible as u8 as f32, time(0.09), time(0.06), LINEAR); + self.actions.set( + now, + recording as u8 as f32, + time(0.08), + time(if processing { 0.05 } else { 0.0 }), + LINEAR, + ); + self.status.set( + now, + !layer_visible as u8 as f32, + time(0.08), + time(if layer_visible { 0.0 } else { 0.12 }), + LINEAR, + ); + let badge_visible = state.translation_active && recording && !leaving; + let (badge_duration, badge_delay) = if badge_visible { (0.2, 0.04) } else { (0.12, 0.0) }; + for (tween, target) in [ + (&mut self.badge_opacity, if badge_visible { 1.0 } else { 0.0 }), + (&mut self.badge_lift, if badge_visible { 0.0 } else { 7.0 }), + (&mut self.badge_scale, if badge_visible { 1.0 } else { 0.94 }), + ] { + tween.set(now, target, time(badge_duration), time(badge_delay), MORPH); + } + if processing { + self.sweep_since.get_or_insert(now); + } else { + self.sweep_since = None; + } + + // Geometry: the Classic stack is bottom-anchored in the 460px host. + let center_x = stage.center().x; + let body_bottom = stage.bottom() - BOTTOM_INSET; + let body_top = body_bottom - HEIGHT; + let width = self.width.value(now); + let opacity = self.opacity.value(now); + let lift = self.lift.value(now); + let shell = Rect::from_min_max( + pos2(center_x - width / 2.0, body_top + lift), + pos2(center_x + width / 2.0, body_bottom + lift), + ); + let outline = shapes::stadium(shell); + let cancel_center = pos2(center_x - BUTTON_OFFSET, shell.center().y); + let confirm_center = pos2(center_x + BUTTON_OFFSET, shell.center().y); + + // Pointer: the buttons while recording, the whole pill while processing. + let (pointer, pressed, released, down) = ui.input(|input| { + ( + input.pointer.hover_pos(), + input.pointer.primary_pressed(), + input.pointer.primary_released(), + input.pointer.primary_down(), + ) + }); + let hovered = pointer + .filter(|&point| { + frame.interactive + && !leaving + && shapes::rounded_rect_distance(point, shell, HEIGHT / 2.0) <= 0.0 + }) + .and_then(|point| { + if recording { + if point.distance(cancel_center) <= BUTTON_RADIUS { + Some(Target::Cancel) + } else if point.distance(confirm_center) <= BUTTON_RADIUS { + Some(Target::Confirm) + } else { + None + } + } else if processing { + Some(Target::Processing) + } else { + None + } + }); + if pressed { + self.pressed = hovered; + } + let mut action = CapsuleAction::None; + if released { + if let Some(target) = self.pressed.take().filter(|&target| Some(target) == hovered) { + action = match target { + Target::Confirm => CapsuleAction::Confirm, + Target::Cancel | Target::Processing => CapsuleAction::Cancel, + }; + } + } else if !down { + self.pressed = None; + } + if hovered.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + self.cancel_hover.set( + now, + (hovered == Some(Target::Cancel)) as u8 as f32, + time(0.1), + 0.0, + EASE, + ); + self.confirm_hover.set( + now, + (hovered == Some(Target::Confirm)) as u8 as f32, + time(0.1), + 0.0, + EASE, + ); + + // Rail and badge sit above the pill and never move with its lift. + let font_size = frame.transcript_font_size.clamp(12.0, 20.0); + let rail_text = frame.transcript.trim(); + let rail_visible = frame.show_transcript + && !leaving + && matches!(phase, "recording" | "transcribing" | "polishing") + && !rail_text.is_empty(); + let rail_bottom = body_top - STACK_GAP; + if rail_visible { + let rail = self.rail.get_or_insert_with(|| Rail::new(center_x)); + rail.update( + &painter, + rail_text, + font_size, + tokens.medium, + tokens.ink, + center_x, + now, + reduced, + ); + draw_rail(&painter, stage, rail, rail_bottom, font_size, &tokens, now); + } else { + self.rail = None; + } + let badge_bottom = body_top + - STACK_GAP + - if rail_visible && state.translation_active { + RAIL_HEIGHT + STACK_GAP + } else { + 0.0 + }; + let badge_opacity = self.badge_opacity.value(now); + if badge_opacity > 0.0 { + let badge = badge( + &painter, + frame, + &tokens, + pos2(center_x, badge_bottom), + badge_opacity, + ui.ctx().pixels_per_point(), + ); + let scale = self.badge_scale.value(now); + let pivot = pos2(center_x, badge_bottom); + let transform = TSTransform::new( + pivot.to_vec2() * (1.0 - scale) + vec2(0.0, self.badge_lift.value(now)), + scale, + ); + for mut shape in badge { + shape.transform(transform); + painter.add(shape); + } + } + + // The pill: shadow, paper, translation tint, border, processing sweep. + let fade = |color: Color32| color.gamma_multiply(opacity); + let pixels_per_point = ui.ctx().pixels_per_point(); + painter.add( + Shadow { + offset: [0, 2], + blur: 8, + spread: 0, + color: fade(Color32::from_black_alpha(36)), + } + .as_shape(shell, RADIUS), + ); + painter.rect_filled(shell, RADIUS, fade(Color32::WHITE)); + if state.translation_active && layer_visible { + painter.add(shapes::inset_shadow( + shell.shrink(1.0), + HEIGHT / 2.0 - 1.0, + pixels_per_point, + 4.0, + 7.0, + -3.0, + fade(Color32::from_rgba_unmultiplied(37, 99, 235, 166)), + )); + } + painter.rect_stroke(shell, RADIUS, Stroke::new(1.0, fade(BORDER)), StrokeKind::Inside); + if let Some(since) = self.sweep_since.filter(|_| !reduced) { + // `ol-capsule-sweep`: −100% → 0 over 85% of 2.4s, linear, then hold. + let progress = (((now - since) % 2.4) / (2.4 * 0.85)).min(1.0) as f32; + let sweep = shapes::stadium(shell.translate(vec2(-width * (1.0 - progress), 0.0))); + painter.add(shapes::fill_clipped(&sweep, &outline, fade(SWEEP))); + } + + // Recording layer: waveform and the two buttons, clipped by the shell. + let layer = self.layer.value(now) * opacity; + if layer > 0.0 { + let heights = self.waveform.heights( + now, + state.audio_level.unwrap_or(0.0), + recording && !leaving, + reduced, + ); + for (index, height) in heights.into_iter().enumerate() { + let x = center_x - 17.5 + 3.5 * index as f32; + painter.rect_filled( + Rect::from_center_size(pos2(x, shell.center().y), vec2(1.5, height)), + 1, + BLUE.gamma_multiply(layer), + ); + } + let actions = layer * self.actions.value(now); + if actions > 0.0 { + let held = if down { self.pressed } else { None }; + for (target, center, fill, ink, icon, size, stroke) in [ + ( + Target::Cancel, + cancel_center, + lerp_color(CANCEL_BG, CANCEL_HOVER, self.cancel_hover.value(now)), + CANCEL_INK, + icons::IconName::Close, + 15.0, + 1.8, + ), + ( + Target::Confirm, + confirm_center, + lerp_color(BLUE, BLUE_HOVER, self.confirm_hover.value(now)), + Color32::WHITE, + icons::IconName::Check, + 17.0, + 2.0, + ), + ] { + // `:active { scale: 0.92 }`, around the button centre. + let scale = if held == Some(target) { 0.92 } else { 1.0 }; + painter.add(shapes::fill_clipped( + &shapes::circle(center, BUTTON_RADIUS * scale), + &outline, + fill.gamma_multiply(actions), + )); + if let Some(texture) = icons::texture(ui.ctx(), icon, size, stroke) { + let rect = if scale == 1.0 { + snapped_square(center, size, pixels_per_point) + } else { + Rect::from_center_size(center, vec2(size, size) * scale) + }; + painter.add(shapes::image_clipped( + texture.id(), + rect, + &outline, + ink.gamma_multiply(actions), + )); + } + } + } + } + + // Status: one centred, ellipsized line inside 14px padding. + let status = self.status.value(now) * opacity; + if status > 0.0 { + let galley = line( + &painter, + &label, + label_format, + width - 2.0 * STATUS_PADDING, + ); + let pos = shell.center() - galley.size() * 0.5; + painter.add(text(pos, galley, status)); + } + + let animating = [ + &self.width, + &self.opacity, + &self.lift, + &self.layer, + &self.actions, + &self.status, + &self.cancel_hover, + &self.confirm_hover, + &self.badge_opacity, + &self.badge_lift, + &self.badge_scale, + ] + .iter() + .any(|tween| tween.active(now)) + || (!reduced && ((recording && !leaving) || self.sweep_since.is_some())) + || self.rail.as_ref().is_some_and(|rail| rail.animating(now)); + if animating { + ui.ctx().request_repaint(); + } + action + } +} + +fn lerp_color(from: Color32, to: Color32, t: f32) -> Color32 { + from.lerp_to_gamma(to, t.clamp(0.0, 1.0)) +} + +/// `.ol-classic-translation`: "Translate to" and the target language chip. +/// The chip is a label here: the native helper has no language menu. +fn badge( + painter: &Painter, + frame: &Frame, + tokens: &Tokens, + bottom_center: Pos2, + opacity: f32, + pixels_per_point: f32, +) -> Vec { + let fade = |color: Color32| color.gamma_multiply(opacity); + let label = painter.layout_job(LayoutJob::single_section( + frame.labels.translate_to.clone(), + format(11.0, 400.0, 16.5, BLUE), + )); + let target = frame + .state + .translation_target + .as_deref() + .filter(|target| !target.trim().is_empty()) + .unwrap_or("…"); + // SelectLite trigger: 0.5px border + 7px padding, 8px gap and an 11px chevron. + let value_width = painter + .layout_job(LayoutJob::single_section( + target.to_owned(), + format(11.0, 400.0, 16.5, tokens.control_ink), + )) + .size() + .x; + let chip_width = (value_width.ceil() + 34.0).clamp(92.0, 174.0); + let value = line( + painter, + target, + format(11.0, 400.0, 16.5, tokens.control_ink), + chip_width - 34.0, + ); + let width = 1.0 + 8.0 + label.size().x + 6.0 + chip_width + 4.0 + 1.0; + let rect = Rect::from_min_max( + pos2(bottom_center.x - width / 2.0, bottom_center.y - BADGE_HEIGHT), + pos2(bottom_center.x + width / 2.0, bottom_center.y), + ); + let center_y = rect.center().y; + let label_pos = pos2(rect.left() + 9.0, center_y - label.size().y / 2.0); + let chip = Rect::from_min_size( + pos2(label_pos.x + label.size().x + 6.0, center_y - 11.0), + vec2(chip_width, 22.0), + ); + vec![ + Shadow { + offset: [0, 2], + blur: 8, + spread: 0, + color: fade(Color32::from_black_alpha(26)), + } + .as_shape(rect, 6) + .into(), + egui::epaint::RectShape::filled(rect, 6, fade(Color32::WHITE)).into(), + shapes::inset_shadow( + rect.shrink(1.0), + 5.0, + pixels_per_point, + -2.0, + 4.0, + 0.0, + fade(Color32::from_rgba_unmultiplied(37, 99, 235, 31)), + ), + egui::epaint::RectShape::stroke( + rect, + 6, + Stroke::new(1.0, fade(SWEEP)), + StrokeKind::Inside, + ) + .into(), + text(label_pos, label, opacity), + egui::epaint::RectShape::new( + chip, + 4, + fade(tokens.control), + Stroke::new(0.5, fade(tokens.control_line)), + StrokeKind::Inside, + ) + .into(), + text( + pos2(chip.left() + 7.5, center_y - value.size().y / 2.0), + value, + opacity, + ), + ] +} + +/// `--ol-capsule-pill-shadow` + `--ol-capsule-pill-inset` around a frost shell. +fn frost_shell(painter: &Painter, rect: Rect, stage: Rect, tokens: &Tokens, border: Color32) { + let radius = rect.height() / 2.0; + // 0 18px 50px -10px: σ = 25 around the box shrunk by 10 and lowered 18. + painter.add(shapes::soft_shadow( + rect.shrink(10.0).translate(vec2(0.0, 18.0)), + 25.0, + tokens.shadow, + stage, + )); + painter.rect_stroke(rect, radius, Stroke::new(0.5, tokens.ring), StrokeKind::Outside); + painter.rect_filled(rect, radius, tokens.surface); + painter.add(shapes::inset_shadow( + rect.shrink(1.0), + radius - 1.0, + painter.ctx().pixels_per_point(), + 1.0, + 0.0, + 0.0, + tokens.highlight, + )); + painter.rect_stroke(rect, radius, Stroke::new(1.0, border), StrokeKind::Inside); +} + +fn draw_rail( + painter: &Painter, + stage: Rect, + rail: &Rail, + bottom: f32, + font_size: f32, + tokens: &Tokens, + now: f64, +) { + let width = rail.width.value; + // The rendered frame stays inside the stage while the shell re-centres. + let right = rail.right.value.min(stage.right()).max(stage.left() + width); + let rect = Rect::from_min_max(pos2(right - width, bottom - RAIL_HEIGHT), pos2(right, bottom)); + frost_shell(painter, rect, stage, tokens, tokens.line); + let track = rect.shrink2(vec2(RAIL_PAD, 0.0)); + let galley = painter.layout_job(rail.job(now, font_size, tokens.medium, tokens.ink)); + let size = galley.size(); + let clipped = painter.with_clip_rect(track.intersect(painter.clip_rect())); + clipped.add(text( + pos2( + track.right() + rail.shift.value - size.x, + rect.center().y - size.y / 2.0, + ), + galley, + 1.0, + )); + if size.x > track.width() { + // `mask-image: linear-gradient(to right, transparent, #000 22px)`. + let mut mesh = egui::epaint::Mesh::default(); + let fade = Rect::from_min_size(track.min, vec2(22.0, track.height())); + mesh.colored_vertex(fade.left_top(), tokens.surface); + mesh.colored_vertex(fade.right_top(), Color32::TRANSPARENT); + mesh.colored_vertex(fade.right_bottom(), Color32::TRANSPARENT); + mesh.colored_vertex(fade.left_bottom(), tokens.surface); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + clipped.add(Shape::mesh(mesh)); + } +} + +/// `SelectionPolishNotice`: a non-interactive one-line status. Returns true +/// while its spinner turns. +fn notice(painter: &Painter, stage: Rect, frame: &Frame, tokens: &Tokens, now: f64) -> bool { + let state = &frame.state; + let labels = &frame.labels; + let phase = state.phase.as_str(); + let processing = phase == "polishing"; + let (color, symbol, fallback) = match phase { + "failed" => (Color32::from_rgb(0xff, 0x82, 0x78), Some(("!", 16.0)), &labels.polish_failed), + "completed" => (Color32::from_rgb(0x63, 0xd5, 0x96), Some(("✓", 15.0)), &labels.replaced), + "cancelled" => (tokens.ink, Some(("·", 16.0)), &labels.no_selection), + _ => ( + Color32::from_rgb(0x8f, 0xc0, 0xff), + None, + if processing { &labels.polishing } else { &labels.polish_failed }, + ), + }; + let message = state.text.trim(); + let label = if message.is_empty() { fallback.as_str() } else { message }; + let icon_width = if processing { 12.0 } else { 13.0 }; + let mut label_format = format(13.0, tokens.semibold, 16.25, color); + label_format.extra_letter_spacing = 0.13; + let galley = line(painter, label, label_format, 360.0 - 2.0 - 28.0 - icon_width - 8.0); + let width = 2.0 + 28.0 + icon_width + 8.0 + galley.size().x; + let height = 2.0 + 16.0 + 16.25; + let bottom = stage.bottom() - BOTTOM_INSET; + let rect = Rect::from_min_max( + pos2(stage.center().x - width / 2.0, bottom - height), + pos2(stage.center().x + width / 2.0, bottom), + ); + let border = if phase == "failed" { + Color32::from_rgba_unmultiplied(255, 112, 102, 107) + } else { + tokens.line + }; + frost_shell(painter, rect, stage, tokens, border); + let icon_center = pos2(rect.left() + 15.0 + icon_width / 2.0, rect.center().y); + if processing { + // 12px ring, 2px border, the top quarter coloured, turning every .75s. + painter.circle_stroke( + icon_center, + 5.0, + Stroke::new(2.0, Color32::from_rgba_unmultiplied(143, 192, 255, 82)), + ); + let turn = ((now % 0.75) / 0.75) as f32 * std::f32::consts::TAU; + let points = (0..=12) + .map(|step| { + let angle = turn - 0.75 * std::f32::consts::PI + + step as f32 / 12.0 * 0.5 * std::f32::consts::PI; + icon_center + 5.0 * vec2(angle.cos(), angle.sin()) + }) + .collect(); + painter.add(Shape::line(points, Stroke::new(2.0, color))); + } else if let Some((symbol, size)) = symbol { + let glyph = painter.layout_job(LayoutJob::single_section( + symbol.to_owned(), + format(size, 800.0, size, color), + )); + painter.add(text(icon_center - glyph.size() * 0.5, glyph, 1.0)); + } + painter.add(text( + pos2(rect.left() + 15.0 + icon_width + 8.0, rect.center().y - galley.size().y / 2.0), + galley, + 1.0, + )); + processing +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CapsuleLabels, CapsulePopupState, ThemeMode}; + + fn frame(phase: &str) -> Frame { + Frame { + session_id: "s".into(), + sequence: 1, + state: CapsulePopupState { + phase: phase.into(), + ..Default::default() + }, + transcript: String::new(), + show_transcript: false, + transcript_font_size: 15.0, + labels: CapsuleLabels::for_locale("zh-CN"), + theme: ThemeMode::System, + leaving: false, + bounds: None, + interactive: true, + } + } + + #[test] + fn status_labels_follow_the_webview_rules() { + let mut done = frame("completed"); + done.state.inserted_chars = Some(12); + assert_eq!(status_label(&done), "已插入 12"); + done.state.text = "Pasted".into(); + assert_eq!(status_label(&done), "Pasted"); + + let mut thinking = frame("polishing"); + assert_eq!(status_label(&thinking), "thinking"); + thinking.state.operating = true; + assert_eq!(status_label(&thinking), "using"); + + // While transcribing, the live recognition replaces the message. + let mut transcribing = frame("transcribing"); + transcribing.state.text = "server".into(); + transcribing.transcript = " 你好 ".into(); + assert_eq!(status_label(&transcribing), "你好"); + + let mut cancelled = frame("cancelled"); + cancelled.state.text = "ignored".into(); + assert_eq!(status_label(&cancelled), "已取消"); + assert_eq!(status_label(&frame("failed")), "出错了"); + } + + #[test] + fn the_waveform_rests_until_the_voice_clears_the_noise_floor() { + let mut waveform = Waveform::default(); + let quiet = waveform.heights(0.0, 0.008, true, false); + assert!(quiet.iter().all(|height| (height - 1.5).abs() < 1e-4)); + let mut loud = quiet; + for frame in 1..30 { + loud = waveform.heights(frame as f64 / 60.0, 1.0, true, false); + } + // Full voice: the centre bar approaches 1.5 + 14.5 × ripple. + assert!(loud[5] > 13.0 && loud[5] <= 16.0); + assert!(loud[0] < loud[5] * 0.3); + // Leaving (inactive) drops straight back to dots. + let dots = waveform.heights(1.0, 1.0, false, false); + assert!(dots.iter().all(|height| (height - 1.5).abs() < 1e-4)); + } +} diff --git a/openless-all/app/crates/openless-capsule/src/renderer.rs b/openless-all/app/crates/openless-capsule/src/renderer.rs index 85e8e129a..1db41b217 100644 --- a/openless-all/app/crates/openless-capsule/src/renderer.rs +++ b/openless-all/app/crates/openless-capsule/src/renderer.rs @@ -1,5 +1,6 @@ -//! Disposable desktop capsule renderer, based on the Linux frontend. -use super::{icons, layout, siri_gl, theme}; +//! Disposable desktop capsule renderer for the optional spectral style. The +//! OpenLess default capsule lives in [`crate::pill`]. +use super::{layout, siri_gl, theme}; use crate::{CapsuleLabels, CapsulePopupState}; #[derive(Clone, Debug, PartialEq, Eq)] pub enum CapsuleAction { @@ -10,19 +11,8 @@ pub enum CapsuleAction { const PILL_WIDTH: f32 = 176.0; const PILL_HEIGHT: f32 = 42.0; -/// Round icon buttons in the capsule / composer. -const ROUND_BUTTON: f32 = 28.0; /// Tauri `getCapsuleHostMetrics(.., 'classic').bottomInset`。 const CAPSULE_BOTTOM_INSET: f32 = 16.0; -/// Typeless 胶囊(Tauri `CapsuleStyles.css`:176×64、46×46 圆形按钮、11 根波形)。 -const TYPELESS_WIDTH: f32 = 176.0; -const TYPELESS_HEIGHT: f32 = 64.0; -const TYPELESS_BUTTON: f32 = 46.0; -/// `.ol-typeless-*` 调色板。 -const TYPELESS_BG: egui::Color32 = egui::Color32::from_rgb(0x18, 0x18, 0x1b); -const TYPELESS_BORDER: egui::Color32 = egui::Color32::from_rgb(0x52, 0x52, 0x5b); -const TYPELESS_INK: egui::Color32 = egui::Color32::from_rgb(0xfa, 0xfa, 0xfa); -const TYPELESS_BUTTON_BG: egui::Color32 = egui::Color32::from_rgb(0x3f, 0x3f, 0x46); /// 徽章与药丸之间的间距(Tauri `badgeGap`)。 const CAPSULE_BADGE_GAP: f32 = 8.0; @@ -61,147 +51,37 @@ fn translating_badge(ui: &mut egui::Ui, pill: egui::Rect, labels: &CapsuleLabels ); } +/// A transparent spectral listening indicator without interactive controls. pub fn dictation_capsule( root_ui: &mut egui::Ui, state: &CapsulePopupState, labels: &CapsuleLabels, ) -> CapsuleAction { - let mut action = CapsuleAction::None; let phase = state.phase.to_ascii_lowercase(); egui::CentralPanel::default() .frame(egui::Frame::NONE) .show(root_ui, |ui| { - // 胶囊进程的首帧预热(同 QA 面板;录音环与 Siri 波都是 GPU 路径)。 - // Tauri `capsuleStyle`:siri = 流光药丸(GPU 波/环),classic = 经典药丸 + - // 五根音量条,typeless = 176×64 深色胶囊 + 11 根波形。 - let style = state.style.as_str(); - let typeless = style == "typeless"; - // 未知/空值走 siri(默认样式),只有显式选择 classic 才关掉 GPU 光效。 - let use_gpu = !typeless && style != "classic"; - let (pill_width, pill_height, button, bar_count) = if typeless { - (TYPELESS_WIDTH, TYPELESS_HEIGHT, TYPELESS_BUTTON, 11) - } else { - (PILL_WIDTH, PILL_HEIGHT, ROUND_BUTTON, 5) - }; - let siri = use_gpu; - let (pill_bg, pill_border, pill_ink) = if typeless { - (TYPELESS_BG, TYPELESS_BORDER, TYPELESS_INK) - } else if siri { - ( - egui::Color32::TRANSPARENT, - egui::Color32::TRANSPARENT, - theme::INK_2, - ) - } else { - (theme::SURFACE, theme::LINE, theme::INK_2) - }; // Tauri 经典药丸宿主:窗口高 100,药丸水平居中、距底 16,徽章再上移 8。 let available = ui.available_rect_before_wrap(); let rect = egui::Rect::from_min_size( egui::pos2( - available.center().x - pill_width / 2.0, - available.bottom() - CAPSULE_BOTTOM_INSET - pill_height, + available.center().x - PILL_WIDTH / 2.0, + available.bottom() - CAPSULE_BOTTOM_INSET - PILL_HEIGHT, ), - egui::vec2(pill_width, pill_height), + egui::vec2(PILL_WIDTH, PILL_HEIGHT), ); let _ = ui.allocate_rect(rect, egui::Sense::hover()); if state.translation_active { translating_badge(ui, rect, labels); } - // Tauri 的经典药丸只有「1px 中性描边」+「随音量轻微放大」两件事 - // (Capsule.tsx 的 ClassicPill:border 1px var(--ol-capsule-pill-border)、 - // transform scale(1 + ambient * 0.018)),**没有**任何外圈扫光/描边颜色变化。 - // 所以这里不再把录音相位画成红圈(那是本仓自己加的,用户报「有一个红边」); - // 运动感只保留药丸中心的音量波形。 - let ambient = if phase == "recording" { - state.audio_level.unwrap_or(0.0).clamp(0.0, 1.0) - } else { - 0.0 - }; - let pill = - egui::Rect::from_center_size(rect.center(), rect.size() * (1.0 + ambient * 0.018)); - if !siri { - ui.painter().rect_filled( - pill, - egui::CornerRadius::same((pill_height / 2.0) as u8), - pill_bg, - ); - ui.painter().rect_stroke( - pill, - egui::CornerRadius::same((pill_height / 2.0) as u8), - egui::Stroke::new(1.0, pill_border), - egui::StrokeKind::Inside, - ); - } - // Siri is a clean, transparent listening indicator. The cancel / - // confirm affordances belong to the classic and Typeless capsules. - let center = if siri { - rect.shrink(3.0) - } else { - let inset = if typeless { 7.0 } else { 8.0 }; - let cancel_rect = egui::Rect::from_center_size( - egui::pos2(rect.left() + inset + button / 2.0, rect.center().y), - egui::vec2(button, button), - ); - let cancel = ui.interact( - cancel_rect, - ui.scope_id().with("openless-capsule-cancel"), - egui::Sense::click(), - ); - let cancel_fill = if typeless { - TYPELESS_BUTTON_BG - } else { - theme::SURFACE_2 - }; - round_button( - ui, - cancel_rect, - icons::IconName::Close, - cancel.hovered(), - cancel_fill, - pill_ink, - ); - if cancel.clicked() { - action = CapsuleAction::Cancel; - } - let confirm_rect = egui::Rect::from_center_size( - egui::pos2(rect.right() - inset - button / 2.0, rect.center().y), - egui::vec2(button, button), - ); - let confirm = ui.interact( - confirm_rect, - ui.scope_id().with("openless-capsule-confirm"), - egui::Sense::click(), - ); - let (confirm_fill, confirm_ink) = if typeless { - (TYPELESS_INK, TYPELESS_BG) - } else { - (theme::SURFACE_2, theme::INK_2) - }; - round_button( - ui, - confirm_rect, - icons::IconName::Check, - confirm.hovered(), - confirm_fill, - confirm_ink, - ); - if confirm.clicked() { - action = CapsuleAction::Confirm; - } - egui::Rect::from_min_max( - egui::pos2(cancel_rect.right() + 4.0, rect.top() + 4.0), - egui::pos2(confirm_rect.left() - 4.0, rect.bottom() - 4.0), - ) - }; + let center = rect.shrink(3.0); let processing = matches!( phase.as_str(), "starting" | "transcribing" | "polishing" | "inserting" ); if phase == "recording" { - // Siri capsules are a transparent overlay. Draw the spectral - // ribbons with egui primitives so they work on the Vulkan path - // too (the legacy GL shader callback is unavailable there). + // Draw the spectral ribbons with egui primitives so they work on + // the Vulkan path too (the legacy GL shader callback is unavailable there). let drive = siri_gl::SiriDrive { level: state.audio_level.unwrap_or_default(), resolved: 1.0, @@ -210,25 +90,15 @@ pub fn dictation_capsule( }; let dt = ui.input(|input| input.stable_dt); let clock = siri_gl::tick(ui.ctx(), "capsule-siri-wave", drive, dt); - if siri { - let _ = siri_gl::paint( - ui, - center, - siri_gl::SiriGlow::wave(clock.time, clock.level), - ); - ui.ctx() - .request_repaint_after(std::time::Duration::from_millis(16)); - } else { - audio_bars( - ui, - center, - state.audio_level.unwrap_or_default(), - bar_count, - pill_ink, - ); - } + let _ = siri_gl::paint( + ui, + center, + siri_gl::SiriGlow::wave(clock.time, clock.level), + ); + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(16)); } else if processing { - // 思考中:Siri 流体圆点(orb),从 wave 收拢的光点化开成环。 + // Collapse the listening waveform into a processing orb. let drive = siri_gl::SiriDrive { level: 0.0, resolved: 0.0, @@ -240,7 +110,7 @@ pub fn dictation_capsule( // 0.3s 全聚圆心接住 wave 收拢的光点,再缓缓散开成环。 let gather = (1.0 - (clock.time / 0.9).clamp(0.0, 1.0)).clamp(0.0, 1.0); let glow = siri_gl::SiriGlow::orb(clock.time, gather); - if !use_gpu || !siri_gl::paint(ui, center, glow) { + if !siri_gl::paint(ui, center, glow) { ui.painter().text( center.center(), egui::Align2::CENTER_CENTER, @@ -250,25 +120,20 @@ pub fn dictation_capsule( ); } } else if state.text.is_empty() { - let label = if processing { - labels.thinking.as_str() - } else if phase == "cancelled" { + let label = if phase == "cancelled" { labels.cancelled.as_str() } else if phase == "failed" { labels.error.as_str() } else { labels.thinking.as_str() }; - let size = if processing { 17.0 } else { 11.0 }; ui.painter().text( center.center(), egui::Align2::CENTER_CENTER, label, - egui::FontId::proportional(size), + egui::FontId::proportional(11.0), if phase == "failed" { theme::ERR - } else if typeless { - TYPELESS_INK } else { theme::INK }, @@ -276,80 +141,16 @@ pub fn dictation_capsule( } else { // 11px/500 单行居中,超长省略(Tauri `getCapsuleMessageLayout`)。 let galley = - layout::text_galley(ui, &state.text, pill_ink, 11.0, center.width(), 1); + layout::text_galley(ui, &state.text, theme::INK_2, 11.0, center.width(), 1); ui.painter().galley( egui::pos2( center.center().x - galley.rect.width() / 2.0, center.center().y - galley.rect.height() / 2.0, ), galley, - pill_ink, + theme::INK_2, ); } }); - action -} - -fn round_button( - ui: &egui::Ui, - rect: egui::Rect, - icon: icons::IconName, - hovered: bool, - fill: egui::Color32, - ink: egui::Color32, -) { - ui.painter().circle_filled( - rect.center(), - rect.width() / 2.0, - if hovered { - fill.gamma_multiply(1.06) - } else { - fill - }, - ); - ui.painter().circle_stroke( - rect.center(), - rect.width() / 2.0, - egui::Stroke::new(0.8, theme::LINE), - ); - icons::draw_icon(ui, rect.center(), icon, ink); -} - -fn audio_bars(ui: &egui::Ui, rect: egui::Rect, level: f32, bar_count: usize, ink: egui::Color32) { - const CLASSIC_ENVELOPE: [f32; 5] = [0.55, 0.85, 1.0, 0.85, 0.55]; - // Tauri `WAVE_ENVELOPE`(Typeless 的 11 根)。 - const TYPELESS_ENVELOPE: [f32; 11] = [ - 0.28, 0.44, 0.63, 0.82, 0.96, 1.0, 0.96, 0.82, 0.63, 0.44, 0.28, - ]; - let envelope: &[f32] = if bar_count > CLASSIC_ENVELOPE.len() { - &TYPELESS_ENVELOPE - } else { - &CLASSIC_ENVELOPE - }; - const BASE: f32 = 2.0; - let max = if bar_count > CLASSIC_ENVELOPE.len() { - 26.0 - } else { - 24.0 - }; - let voice = level.clamp(0.0, 1.0); - let gated = ((voice - 0.012) / (0.34 - 0.012)).clamp(0.0, 1.0); - let eased = gated * gated * (3.0 - 2.0 * gated); - let visual = eased.powf(0.42); - let bar_width = 3.0; - let gap = 3.0; - let total = envelope.len() as f32 * bar_width + (envelope.len() - 1) as f32 * gap; - let mut x = rect.center().x - total / 2.0; - for envelope in envelope { - let height = BASE + (max - BASE) * visual * envelope; - ui.painter().rect_filled( - egui::Rect::from_center_size( - egui::pos2(x + bar_width / 2.0, rect.center().y), - egui::vec2(bar_width, height), - ), - egui::CornerRadius::same(2), - ink, - ); - x += bar_width + gap; - } + CapsuleAction::None } diff --git a/openless-all/app/crates/openless-capsule/src/shapes.rs b/openless-all/app/crates/openless-capsule/src/shapes.rs new file mode 100644 index 000000000..abda78a8a --- /dev/null +++ b/openless-all/app/crates/openless-capsule/src/shapes.rs @@ -0,0 +1,308 @@ +//! Shape helpers egui lacks: content clipped to a rounded shell (CSS +//! `overflow: hidden` + `border-radius`), inset shadows and wide soft shadows. +use egui::{epaint, pos2, vec2, Color32, Pos2, Rect, Shape}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Vertex { + pub pos: Pos2, + pub uv: Pos2, +} + +/// A fully rounded rect, clockwise on screen. +pub fn stadium(rect: Rect) -> Vec { + let radius = rect.height().min(rect.width()) * 0.5; + let caps = [ + (pos2(rect.right() - radius, rect.center().y), -0.5), + (pos2(rect.left() + radius, rect.center().y), 0.5), + ]; + const SEGMENTS: usize = 32; + let mut points = Vec::with_capacity(2 * SEGMENTS + 2); + for (center, start) in caps { + for step in 0..=SEGMENTS { + let angle = std::f32::consts::PI * (start + step as f32 / SEGMENTS as f32); + points.push(center + radius * vec2(angle.cos(), angle.sin())); + } + } + points +} + +pub fn circle(center: Pos2, radius: f32) -> Vec { + const SEGMENTS: usize = 64; + (0..SEGMENTS) + .map(|step| { + let angle = std::f32::consts::TAU * step as f32 / SEGMENTS as f32; + center + radius * vec2(angle.cos(), angle.sin()) + }) + .collect() +} + +fn cross(a: egui::Vec2, b: egui::Vec2) -> f32 { + a.x * b.y - a.y * b.x +} + +/// Sutherland–Hodgman against a convex `clip` polygon, interpolating UVs. +pub fn clip_convex(subject: &[Vertex], clip: &[Pos2]) -> Vec { + let area: f32 = (0..clip.len()) + .map(|i| cross(clip[i].to_vec2(), clip[(i + 1) % clip.len()].to_vec2())) + .sum(); + let orientation = area.signum(); + let mut output = subject.to_vec(); + for i in 0..clip.len() { + let a = clip[i]; + let edge = clip[(i + 1) % clip.len()] - a; + if edge.length_sq() < 1e-8 { + continue; + } + let input = std::mem::take(&mut output); + let Some(&last) = input.last() else { + break; + }; + let side = |p: Pos2| orientation * cross(edge, p - a); + let mut previous = last; + for current in input { + let (before, now) = (side(previous.pos), side(current.pos)); + if (before >= 0.0) != (now >= 0.0) { + let t = before / (before - now); + output.push(Vertex { + pos: previous.pos + (current.pos - previous.pos) * t, + uv: previous.uv + (current.uv - previous.uv) * t, + }); + } + if now >= 0.0 { + output.push(current); + } + previous = current; + } + } + output +} + +/// A filled convex shape clipped to `shell`, anti-aliased by egui. +pub fn fill_clipped(shape: &[Pos2], shell: &[Pos2], fill: Color32) -> Shape { + let subject: Vec<_> = shape.iter().map(|&pos| Vertex { pos, uv: pos }).collect(); + let points: Vec<_> = clip_convex(&subject, shell) + .into_iter() + .map(|vertex| vertex.pos) + .collect(); + if points.len() < 3 { + return Shape::Noop; + } + Shape::convex_polygon(points, fill, egui::Stroke::NONE) +} + +/// A tinted texture quad clipped to `shell`. +pub fn image_clipped(texture: egui::TextureId, rect: Rect, shell: &[Pos2], tint: Color32) -> Shape { + let quad = [ + (rect.left_top(), pos2(0.0, 0.0)), + (rect.right_top(), pos2(1.0, 0.0)), + (rect.right_bottom(), pos2(1.0, 1.0)), + (rect.left_bottom(), pos2(0.0, 1.0)), + ] + .map(|(pos, uv)| Vertex { pos, uv }); + let polygon = clip_convex(&quad, shell); + if polygon.len() < 3 { + return Shape::Noop; + } + let mut mesh = epaint::Mesh::with_texture(texture); + for vertex in &polygon { + mesh.vertices.push(epaint::Vertex { + pos: vertex.pos, + uv: vertex.uv, + color: tint, + }); + } + for index in 1..polygon.len() as u32 - 1 { + mesh.add_triangle(0, index, index + 1); + } + Shape::mesh(mesh) +} + +/// Standard normal CDF (Abramowitz–Stegun 7.1.26). +fn normal_cdf(x: f32) -> f32 { + let z = x.abs() / std::f32::consts::SQRT_2; + let t = 1.0 / (1.0 + 0.327_591_1 * z); + let poly = t + * (0.254_829_6 + + t * (-0.284_496_74 + t * (1.421_413_7 + t * (-1.453_152_1 + t * 1.061_405_4)))); + let erf = 1.0 - poly * (-z * z).exp(); + 0.5 * (1.0 + erf.copysign(x)) +} + +/// Signed distance to a rounded rect; negative inside. +pub fn rounded_rect_distance(p: Pos2, rect: Rect, radius: f32) -> f32 { + let radius = radius.min(rect.width() * 0.5).min(rect.height() * 0.5).max(0.0); + let q = (p - rect.center()).abs() - (rect.size() * 0.5 - vec2(radius, radius)); + q.max(egui::Vec2::ZERO).length() + q.x.max(q.y).min(0.0) - radius +} + +/// Coverage of CSS `inset 0 ` at `p` inside the +/// rounded padding box: the blurred hole is approximated from its distance. +pub fn inset_coverage( + p: Pos2, + padding_box: Rect, + radius: f32, + offset_y: f32, + blur: f32, + spread: f32, +) -> f32 { + let hole = padding_box.translate(vec2(0.0, offset_y)).shrink(spread); + let sigma = (blur * 0.5).max(0.3); + normal_cdf(rounded_rect_distance(p, hole, (radius - spread).max(0.0)) / sigma) +} + +/// An inset shadow as a mesh over the rounded padding box, one row per +/// physical pixel and dense columns near the ends. The outline itself is not +/// anti-aliased: callers keep it under a border. +#[allow(clippy::too_many_arguments)] +pub fn inset_shadow( + padding_box: Rect, + radius: f32, + pixels_per_point: f32, + offset_y: f32, + blur: f32, + spread: f32, + color: Color32, +) -> Shape { + const OFFSETS: [f32; 17] = [ + 0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0, 13.0, 16.0, 20.0, 25.0, 32.0, 40.0, + ]; + let rect = padding_box; + let radius = radius.min(rect.height() * 0.5).min(rect.width() * 0.5); + let inset = |y: f32| { + let dy = if y < rect.top() + radius { + rect.top() + radius - y + } else if y > rect.bottom() - radius { + y - (rect.bottom() - radius) + } else { + 0.0 + }; + radius - (radius * radius - dy * dy).max(0.0).sqrt() + }; + let columns = 2 * OFFSETS.len() as u32 + 1; + let rows = (rect.height() * pixels_per_point).ceil().max(1.0) as u32; + let mut mesh = epaint::Mesh::default(); + for row in 0..=rows { + let y = (rect.top() + row as f32 / pixels_per_point).min(rect.bottom()); + let (left, right) = (rect.left() + inset(y), rect.right() - inset(y)); + let half = ((right - left) * 0.5).max(0.0); + let xs = OFFSETS + .iter() + .map(|offset| left + offset.min(half)) + .chain(std::iter::once(left + half)) + .chain(OFFSETS.iter().rev().map(|offset| right - offset.min(half))); + for x in xs { + let p = pos2(x, y); + let coverage = inset_coverage(p, padding_box, radius, offset_y, blur, spread); + mesh.colored_vertex(p, color.gamma_multiply(coverage)); + } + if row > 0 { + let (above, here) = ((row - 1) * columns, row * columns); + for column in 0..columns - 1 { + mesh.add_triangle(above + column, above + column + 1, here + column); + mesh.add_triangle(above + column + 1, here + column + 1, here + column); + } + } + } + Shape::mesh(mesh) +} + +/// CSS outer `box-shadow` with a blur too wide for egui's feathered shadow +/// (which cannot exceed the box): a separable Gaussian of the spread box. +/// Corners much smaller than the blur wash out, so they are not modelled. +pub fn soft_shadow(rect: Rect, sigma: f32, color: Color32, clip: Rect) -> Shape { + let area = rect.expand(3.0 * sigma).intersect(clip); + if !area.is_positive() { + return Shape::Noop; + } + let step = (sigma / 4.0).clamp(1.0, 6.0); + let columns = (area.width() / step).ceil() as u32 + 1; + let rows = (area.height() / step).ceil() as u32 + 1; + let band = |value: f32, low: f32, high: f32| { + normal_cdf((value - low) / sigma) - normal_cdf((value - high) / sigma) + }; + let mut mesh = epaint::Mesh::default(); + for row in 0..rows { + let y = (area.top() + row as f32 * step).min(area.bottom()); + let vertical = band(y, rect.top(), rect.bottom()); + for column in 0..columns { + let x = (area.left() + column as f32 * step).min(area.right()); + let coverage = vertical * band(x, rect.left(), rect.right()); + mesh.colored_vertex(pos2(x, y), color.gamma_multiply(coverage)); + if row > 0 && column > 0 { + let here = row * columns + column; + let above = here - columns; + mesh.add_triangle(above - 1, above, here - 1); + mesh.add_triangle(above, here, here - 1); + } + } + } + Shape::mesh(mesh) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plain(points: &[Pos2]) -> Vec { + points.iter().map(|&pos| Vertex { pos, uv: pos }).collect() + } + + #[test] + fn content_inside_the_shell_is_untouched() { + let shell = stadium(Rect::from_min_size(pos2(0.0, 0.0), vec2(116.0, 32.0))); + let button = circle(pos2(16.0, 16.0), 12.0); + assert_eq!(clip_convex(&plain(&button), &shell).len(), button.len()); + } + + #[test] + fn a_contracting_shell_cuts_the_buttons_to_a_lens() { + // Warming: 68px shell, confirm button centred 42px right of the middle. + let shell = stadium(Rect::from_center_size(pos2(0.0, 0.0), vec2(68.0, 32.0))); + let clipped = clip_convex(&plain(&circle(pos2(42.0, 0.0), 12.0)), &shell); + let (min_x, max_x) = clipped + .iter() + .fold((f32::MAX, f32::MIN), |(lo, hi), v| (lo.min(v.pos.x), hi.max(v.pos.x))); + assert!((min_x - 30.0).abs() < 0.1 && (max_x - 34.0).abs() < 0.1); + assert!(clipped.iter().all(|v| v.pos.y.abs() < 12.0)); + // Fully outside: nothing left to draw. + assert!(clip_convex(&plain(&circle(pos2(80.0, 0.0), 12.0)), &shell).is_empty()); + } + + #[test] + fn texture_coordinates_follow_the_cut() { + let shell = [pos2(0.0, 0.0), pos2(5.0, 0.0), pos2(5.0, 10.0), pos2(0.0, 10.0)]; + let quad = [ + Vertex { pos: pos2(0.0, 0.0), uv: pos2(0.0, 0.0) }, + Vertex { pos: pos2(10.0, 0.0), uv: pos2(1.0, 0.0) }, + Vertex { pos: pos2(10.0, 10.0), uv: pos2(1.0, 1.0) }, + Vertex { pos: pos2(0.0, 10.0), uv: pos2(0.0, 1.0) }, + ]; + let clipped = clip_convex(&quad, &shell); + assert!(clipped.iter().all(|v| v.pos.x <= 5.0 + 1e-4)); + assert!(clipped + .iter() + .any(|v| (v.pos.x - 5.0).abs() < 1e-4 && (v.uv.x - 0.5).abs() < 1e-4)); + } + + #[test] + fn translation_inset_shadow_tints_the_top_edge_and_fades_down_the_ends() { + // `inset 0 4px 7px -3px` inside the 114×30 padding box of the 116px pill. + let padding = Rect::from_min_size(pos2(1.0, 1.0), vec2(114.0, 30.0)); + let at = |x, y| inset_coverage(pos2(x, y), padding, 15.0, 4.0, 7.0, -3.0); + assert!((at(58.0, 1.0) - 0.61).abs() < 0.02); + assert!(at(58.0, 5.0) < 0.25 && at(58.0, 11.0) < 0.01 && at(58.0, 30.0) < 0.02); + // The rounded ends keep a faint tint, as the browser's blurred hole does. + assert!((0.15..0.35).contains(&at(1.0, 16.0))); + } + + #[test] + fn a_wide_soft_shadow_peaks_below_the_box_and_never_exceeds_its_colour() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 32.0)); + let Shape::Mesh(mesh) = soft_shadow(rect, 25.0, Color32::BLACK, rect.expand(200.0)) else { + panic!("expected a mesh"); + }; + let peak = mesh.vertices.iter().map(|v| v.color.a()).max().unwrap(); + // A 32px-tall box under a 25px sigma never reaches full strength. + assert!((110..140).contains(&peak), "{peak}"); + } +} diff --git a/openless-all/app/crates/openless-capsule/src/supervisor.rs b/openless-all/app/crates/openless-capsule/src/supervisor.rs index 9d36b6131..a0715013d 100644 --- a/openless-all/app/crates/openless-capsule/src/supervisor.rs +++ b/openless-all/app/crates/openless-capsule/src/supervisor.rs @@ -272,6 +272,9 @@ mod tests { transcript: String::new(), transcript_font_size: 15.0, labels: Default::default(), + show_transcript: false, + theme: Default::default(), + leaving: false, bounds: None, interactive: true, } @@ -289,13 +292,13 @@ mod tests { .as_nanos() )); std::fs::write(&script, r#"#!/bin/sh -printf '%s\n' '{"type":"ready","version":1}' +printf '%s\n' '{"type":"ready","version":2}' while IFS= read -r frame; do case "$frame" in *'"shutdown"'*) exit 0;; *'"frame"'*) - printf '%s\n' '{"type":"action","version":1,"session_id":"old","sequence":7,"action":"cancel"}' - printf '%s\n' '{"type":"action","version":1,"session_id":"current","sequence":7,"action":"confirm"}' + printf '%s\n' '{"type":"action","version":2,"session_id":"old","sequence":7,"action":"cancel"}' + printf '%s\n' '{"type":"action","version":2,"session_id":"current","sequence":7,"action":"confirm"}' ;; esac done @@ -348,6 +351,9 @@ done transcript: String::new(), transcript_font_size: 15.0, labels: Default::default(), + show_transcript: false, + theme: Default::default(), + leaving: false, bounds: None, interactive: false, })); @@ -387,6 +393,9 @@ mod health_tests { transcript: String::new(), transcript_font_size: 14.0, labels: Default::default(), + show_transcript: false, + theme: Default::default(), + leaving: false, bounds: None, interactive: true, } @@ -422,7 +431,7 @@ mod health_tests { { let mut command = Command::new("powershell.exe"); let script = if ready { - r#"[Console]::WriteLine('{"type":"ready","version":1}'); while ($null -ne ($line = [Console]::ReadLine())) { if ($line.Contains('"type":"shutdown"')) { break } }"# + r#"[Console]::WriteLine('{"type":"ready","version":2}'); while ($null -ne ($line = [Console]::ReadLine())) { if ($line.Contains('"type":"shutdown"')) { break } }"# } else { "while ($null -ne [Console]::ReadLine()) {}" }; @@ -433,7 +442,7 @@ mod health_tests { { let mut command = Command::new("/bin/sh"); let script = if ready { - r#"printf '%s\n' '{"type":"ready","version":1}'; while IFS= read -r line; do case "$line" in *'"shutdown"'*) exit 0;; esac; done"# + r#"printf '%s\n' '{"type":"ready","version":2}'; while IFS= read -r line; do case "$line" in *'"shutdown"'*) exit 0;; esac; done"# } else { "cat >/dev/null" }; @@ -500,7 +509,7 @@ mod health_tests { ) .await .is_err()); - writer.write_all(b"\"version\":1}\n").await.unwrap(); + writer.write_all(b"\"version\":2}\n").await.unwrap(); assert!(matches!( read_ui(&mut reader, &mut pending).await.unwrap(), Some(UiMessage::Ready { diff --git a/openless-all/app/crates/openless-capsule/src/theme.rs b/openless-all/app/crates/openless-capsule/src/theme.rs index d5ea12318..68a032e84 100644 --- a/openless-all/app/crates/openless-capsule/src/theme.rs +++ b/openless-all/app/crates/openless-capsule/src/theme.rs @@ -5,6 +5,3 @@ pub const CAPSULE_BADGE_BORDER: egui::Color32 = pub const ERR: egui::Color32 = egui::Color32::from_rgb(220, 38, 38); pub const INK: egui::Color32 = egui::Color32::from_rgb(9, 9, 11); pub const INK_2: egui::Color32 = egui::Color32::from_rgb(63, 63, 70); -pub const LINE: egui::Color32 = egui::Color32::from_rgb(228, 228, 231); -pub const SURFACE: egui::Color32 = egui::Color32::WHITE; -pub const SURFACE_2: egui::Color32 = egui::Color32::from_rgb(244, 244, 245); diff --git a/openless-all/app/crates/openless-core/Cargo.toml b/openless-all/app/crates/openless-core/Cargo.toml index 88b071bbb..274e8e991 100644 --- a/openless-all/app/crates/openless-core/Cargo.toml +++ b/openless-all/app/crates/openless-core/Cargo.toml @@ -26,7 +26,7 @@ md-5 = "0.11.0" once_cell = "1" parking_lot = "0.12" regex = "1" -reqwest = { version = "0.13.5", default-features = false, features = ["json", "multipart", "rustls", "stream", "system-proxy", "query", "form"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream", "system-proxy"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha1 = "0.11.0" diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index f5b809476..ab3a2a084 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -61,6 +61,16 @@ pub struct BackendSnapshot { pub credentials: CredentialsStatus, } +/// Session-bound translation picker. Working languages come from the frozen recording context. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DictationTranslationTarget { + pub session_id: SessionId, + pub target_language: String, + pub working_languages: Vec, + pub mutable: bool, +} + #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct StartupSnapshot { @@ -1783,6 +1793,7 @@ struct MutableState { dictation_destination_changed: bool, credentials: CredentialsStatus, transcripts: HashMap, + recording_fault_cleanup: Option, silence_monitor: Option, } @@ -1998,7 +2009,15 @@ impl ActiveTextInsertion { self.drained.notify_waiters(); return; } - std::mem::take(&mut state.stream.pending) + let delta = state.stream.take_ready_prefix(); + if delta.is_empty() { + // Whitespace alone waits for another delta, not another timer + // tick. Final reconciliation will flush a trailing suffix. + state.scheduled = false; + self.drained.notify_waiters(); + return; + } + delta }; let expected = delta.chars().count(); let result = if delta.is_empty() { @@ -3332,6 +3351,7 @@ impl OpenLessBackend { dictation_destination_changed: false, credentials: CredentialsStatus::default(), transcripts: HashMap::new(), + recording_fault_cleanup: None, silence_monitor: None, })), phase_changed: Arc::new(tokio::sync::Notify::new()), @@ -5248,7 +5268,13 @@ impl OpenLessBackend { | DictationPhase::Failed => Ok(CliDispatchOutcome::Noop), }, crate::cli::CliIntent::ToggleQa => { - self.deps.services.qa.toggle_recording().await?; + let qa = &self.deps.services.qa; + let snapshot = qa.snapshot().await?; + match (snapshot.phase, snapshot.session_id) { + (crate::QaPhase::Recording, Some(session_id)) => qa.stop_recording(session_id).await?, + (crate::QaPhase::Thinking | crate::QaPhase::AwaitingApproval, _) => return Ok(CliDispatchOutcome::Noop), + _ => qa.start_question().await?, + } Ok(CliDispatchOutcome::QaToggled) } crate::cli::CliIntent::CancelDictation => { @@ -5592,6 +5618,38 @@ impl OpenLessBackend { } } + /// Transfer the recording-key prefix into a fresh voice question. Serialize + /// with physical dictation edges so a delayed prefix release cannot restart + /// dictation after QA has acquired the microphone. + pub async fn start_voice_question_from_recording_key(&self) -> Result<(), BackendError> { + let _dispatch = self.hotkey_dispatch_gate.lock().await; + let snapshot = self.snapshot().dictation; + if let Some(session_id) = snapshot.session_id { + if !matches!(snapshot.phase, DictationPhase::Starting | DictationPhase::Recording) { + return Err(BackendError::new(BackendErrorCode::Busy, "dictation is finalizing")); + } + if let Err(error) = self.cancel_dictation(Some(session_id)).await { + // The independent combo-abort bridge may have claimed this same + // prefix already. Its native cleanup still owns the voice lease. + if self.snapshot().dictation.session_id.is_some() + || error.code != BackendErrorCode::InvalidState { return Err(error); } + } + } + let provisional = std::mem::take(&mut *self.pending_audio_captures.lock() + .expect("pending audio capture lock poisoned")); + for capture in provisional.into_values() { capture.discard().await?; } + self.hotkey.lock().expect("hotkey interpreter lock poisoned").reset(); + // Logical cancellation can precede the final native cleanup hold. Never + // cancel another domain or open a second microphone during that interval. + for _ in 0..25 { + if self.voice_sessions.runtime_restore_idle() { + return self.deps.services.qa.start_question().await; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + Err(BackendError::new(BackendErrorCode::Busy, "microphone is still being released")) + } + /// Accept the only mutable dictation intent into the current session. The /// engine only uses it during final polish, so synchronize at the common /// stop boundary instead of racing engine registration during Starting. @@ -5645,6 +5703,65 @@ impl OpenLessBackend { Ok(()) } + pub fn dictation_translation_target(&self) -> Option { + let state = self.state.read().expect("backend state lock poisoned"); + let context = state.dictation_context.as_ref()?; + Some(DictationTranslationTarget { + session_id: state.dictation.session_id?, + target_language: context.polish.translation_target_language.clone(), + working_languages: context.polish.working_languages.clone(), + mutable: state.running + && state.dictation.phase == DictationPhase::Recording + && context.polish.translation_active, + }) + } + + /// Explicitly retarget this recording and remember the choice. Ordinary settings + /// edits still leave an in-flight recording's frozen context untouched. + pub fn select_dictation_translation_target( + &self, + session_id: SessionId, + target_language: String, + ) -> Result { + let target = crate::language_catalog::native_language_name(&target_language) + .ok_or_else(|| BackendError::new(BackendErrorCode::InvalidArgument, "unsupported translation language"))?; + let _write_guard = self.settings_write_gate.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut state = self.state.write().expect("backend state lock poisoned"); + ensure_running(&state)?; + if state.dictation.session_id != Some(session_id) + || state.dictation.phase != DictationPhase::Recording + { + return Err(BackendError::new(BackendErrorCode::InvalidState, + "translation picker belongs to a recording that has ended")); + } + let context = state.dictation_context.as_ref().ok_or_else(|| + BackendError::new(BackendErrorCode::InvalidState, "recording context is not ready"))?; + if !context.polish.translation_active + || !crate::shared_types::translation_effective(true, &target, &context.polish.working_languages) + { + return Err(BackendError::new(BackendErrorCode::InvalidArgument, + "translation is inactive or the target matches the only working language")); + } + let mut updated = (**context).clone(); + updated.polish.translation_target_language = target.clone(); + let result = DictationTranslationTarget { + session_id, + target_language: target.clone(), + working_languages: updated.polish.working_languages.clone(), + mutable: true, + }; + // Keep finalization outside this critical section. A failed save changes neither + // the active target nor the remembered choice, and no other preference is overwritten. + self.preferences.update(|preferences| preferences.translation_target_language = target)?; + state.dictation_context = Some(Arc::new(updated)); + // The common stop boundary must also send the changed context to a streaming engine. + state.dictation_translation_requested = Some(true); + drop(state); + self.publish_preferences_changed(); + Ok(result) + } + pub fn siwc_service(&self) -> Result, BackendError> { self.deps .credential_store @@ -8119,7 +8236,18 @@ impl OpenLessBackend { let error = failure.error; if error.code != BackendErrorCode::Cancelled { self.mark_dictation_failed(session_id, &error); - let raw_text = failure.raw_text.unwrap_or_default(); + let raw_text = failure + .raw_text + .filter(|text| !text.trim().is_empty()) + .unwrap_or_else(|| { + self.state + .read() + .expect("backend state lock poisoned") + .transcripts + .get(&session_id) + .map(|transcript| transcript.text().to_owned()) + .unwrap_or_default() + }); let (error_code, final_text, llm_used) = match failure.stage { EngineFailureStage::Transcribing => { ("transcribeFailed", String::new(), false) @@ -8712,6 +8840,7 @@ impl OpenLessBackend { state.dictation_start_output_target = None; state.silence_monitor = None; state.transcripts.remove(&session_id); + state.recording_fault_cleanup = None; hotkey.terminal(std::time::Instant::now()); self.phase_changed.notify_waiters(); drop(state); @@ -8905,8 +9034,8 @@ impl OpenLessBackend { session_id: SessionId, error: BackendError, ) -> Result<(), BackendError> { - let (context, duration_ms, already_failed) = { - let state = self.state.read().expect("backend state lock poisoned"); + let (context, duration_ms, already_failed, raw_text) = { + let mut state = self.state.write().expect("backend state lock poisoned"); ensure_running(&state)?; if state.dictation.session_id != Some(session_id) { return Err(BackendError::new( @@ -8914,15 +9043,25 @@ impl OpenLessBackend { "recording fault belongs to an inactive session", )); } + if state.recording_fault_cleanup == Some(session_id) { + return Ok(()); + } + let context = state.dictation_context.clone().ok_or_else(|| { + BackendError::new( + BackendErrorCode::Internal, + "active recording fault has no captured context", + ) + })?; + state.recording_fault_cleanup = Some(session_id); ( - state.dictation_context.clone().ok_or_else(|| { - BackendError::new( - BackendErrorCode::Internal, - "active recording fault has no captured context", - ) - })?, + context, state.dictation.elapsed_ms, state.dictation.phase == DictationPhase::Failed, + state + .transcripts + .get(&session_id) + .map(|transcript| transcript.text().to_owned()) + .unwrap_or_default(), ) }; // RecordingProgressForwarder marks the state Failed immediately so UI @@ -8932,22 +9071,31 @@ impl OpenLessBackend { if !already_failed { self.mark_dictation_failed(session_id, &error); } + let archive = if context.output_target == DictationOutputTarget::CloudNote + || self.is_cloud_note(session_id) + { + None + } else { + self.deps + .dictation_engine + .retain_failed_recording(session_id) + }; + let cancel_result = self.cancel_session_adapters(session_id).await; self.persist_failed_dictation( &context, session_id, "recordingFailed", - String::new(), + raw_text, String::new(), None, Some(duration_ms), None, None, - None, + archive.as_ref().map(|archive| archive.is_available()), false, None, None, ); - let cancel_result = self.cancel_session_adapters(session_id).await; let _ = self.hide_dictation_feedback(session_id); self.reset_dictation_session(session_id); self.cleanup_voice_activation_after_dictation(session_id) @@ -11291,12 +11439,17 @@ mod tests { #[derive(Default)] struct FakeQaControl { + starts: std::sync::atomic::AtomicUsize, stops: std::sync::atomic::AtomicUsize, cancels: std::sync::atomic::AtomicUsize, faults: std::sync::atomic::AtomicUsize, } impl crate::domains::QaApi for FakeQaControl { + fn start_question(&self) -> BoxFuture<'static, Result<(), BackendError>> { + self.starts.fetch_add(1, Ordering::AcqRel); + Box::pin(async { Ok(()) }) + } fn show(&self) -> BoxFuture<'static, Result<(), BackendError>> { Box::pin(async { Ok(()) }) } @@ -13277,6 +13430,17 @@ mod tests { let (backend, _) = backend(); backend.start().await.unwrap(); let session_id = backend.start_dictation().await.unwrap(); + backend + .engine_progress_sink() + .publish( + session_id, + EngineProgress::TranscriptDelta(crate::TranscriptDelta { + text: "recognized draft".into(), + offset: 0, + is_final: false, + }), + ) + .unwrap(); let error = BackendError::new(BackendErrorCode::Platform, "device disconnected"); backend .engine_progress_sink() @@ -13292,6 +13456,13 @@ mod tests { let history = backend.list_history().unwrap(); assert_eq!(history.len(), 1); assert_eq!(history[0].error_code.as_deref(), Some("recordingFailed")); + assert_eq!(history[0].raw_transcript, "recognized draft"); + assert_eq!( + history[0].asr_transcript.as_deref(), + Some("recognized draft") + ); + assert!(history[0].final_text.is_empty()); + let next_session = backend.start_dictation().await.unwrap(); assert_eq!( backend .report_recording_fault(session_id, error) @@ -13301,6 +13472,65 @@ mod tests { BackendErrorCode::InvalidState ); assert_eq!(backend.list_history().unwrap().len(), 1); + assert_eq!(backend.snapshot().dictation.session_id, Some(next_session)); + backend.cancel_dictation(Some(next_session)).await.unwrap(); + } + + #[tokio::test] + async fn recording_fault_preserves_draft_and_audio_but_explicit_cancel_discards_audio() { + let data_dir = TestDataDir::new("recording-fault-retention"); + let engine = crate::PipelineDictationEngine::new( + Arc::new(crate::ExternalAudioRecorder::with_recordings_directory( + data_dir.path().join("recordings"), + )), + Arc::new(crate::testing::FixtureTranscriptionEngine::successful( + "complete", 1000, + )), + Arc::new(crate::testing::FixtureTextPolisher::successful("complete")), + ); + let backend = + backend_with_dictation_engine(data_dir.path().to_path_buf(), Arc::new(engine)); + backend.start().await.unwrap(); + let pcm = vec![1, 0, 2, 0]; + let id = backend.start_external_dictation().await.unwrap(); + backend.feed_external_pcm(id, &pcm).unwrap(); + backend + .engine_progress_sink() + .publish( + id, + EngineProgress::TranscriptDelta(crate::TranscriptDelta { + text: "unfinished draft".into(), + offset: 0, + is_final: false, + }), + ) + .unwrap(); + backend + .report_recording_fault( + id, + BackendError::new(BackendErrorCode::Platform, "fixture backlog"), + ) + .await + .unwrap(); + let history = backend.list_history().unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].raw_transcript, "unfinished draft"); + assert!(history[0].final_text.is_empty()); + assert_eq!(history[0].has_audio_recording, Some(true)); + let wav = + std::fs::read(data_dir.path().join("recordings").join(format!("{id}.wav"))).unwrap(); + assert_eq!(crate::audio::decode_dictation_wav(&wav).unwrap(), pcm); + + let next = backend.start_external_dictation().await.unwrap(); + backend.feed_external_pcm(next, &pcm).unwrap(); + backend.cancel_dictation(Some(next)).await.unwrap(); + assert!(!data_dir + .path() + .join("recordings") + .join(format!("{next}.wav")) + .exists()); + assert_eq!(backend.list_history().unwrap().len(), 1); + backend.shutdown().await.unwrap(); } #[tokio::test] @@ -13593,6 +13823,111 @@ mod tests { backend.cancel_dictation(Some(second)).await.unwrap(); } + #[tokio::test] + async fn question_prefix_handoff_cancels_dictation_and_discards_its_late_release() { + let data_dir = TestDataDir::new("question-prefix-handoff"); + let engine = crate::testing::FixtureDictationEngine::successful("must not insert", "unused"); + let qa = Arc::new(FakeQaControl::default()); + let mut deps = BackendDependencies::unsupported(); + deps.dictation_engine = Arc::new(engine.clone()); + deps.host_actions = Arc::new(FakeHost::default()); + deps.text_inserter = Arc::new(crate::testing::FixtureTextInserter::with_outcome(InsertOutcome::Inserted)); + deps.credential_store = llm_configured_credentials(); + deps.services.qa = qa.clone(); + let backend = OpenLessBackend::new(BackendConfig {data_dir:data_dir.path().to_path_buf(), ..Default::default()}, deps).unwrap(); + backend.start().await.unwrap(); + let mut prefs = backend.get_preferences(); + prefs.hotkey.mode = crate::HotkeyMode::Hold; + backend.set_preferences(prefs).unwrap(); + let at = std::time::Instant::now(); + backend.dispatch_dictation_hotkey_edge(DictationHotkeyEdge::Pressed {press_id:7,at}).await.unwrap(); + assert_eq!(backend.snapshot().dictation.phase, DictationPhase::Recording); + backend.start_voice_question_from_recording_key().await.unwrap(); + assert_eq!(qa.starts.load(Ordering::Acquire), 1); + backend.dispatch_dictation_hotkey_edge(DictationHotkeyEdge::Released {press_id:7,at:std::time::Instant::now()}).await.unwrap(); + assert_eq!(backend.snapshot().dictation.phase, DictationPhase::Idle); + assert!(backend.list_history().unwrap().is_empty()); + backend.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn translation_picker_updates_current_engine_and_remembers_only_the_language() { + let data_dir = TestDataDir::new("translation-picker-current"); + let engine = crate::testing::FixtureDictationEngine::successful("raw", "translated"); + let backend = backend_with_dictation_engine(data_dir.path().to_path_buf(), Arc::new(engine.clone())); + backend.start().await.unwrap(); + let mut prefs = backend.get_preferences(); + prefs.translation_target_language = "English".into(); + prefs.working_languages = vec!["简体中文".into()]; + prefs.microphone_device_name = "captured microphone".into(); + backend.set_preferences(prefs).unwrap(); + let session = backend.start_dictation_with_options(DictationStartOptions { + translation_requested: true, ..Default::default() + }).await.unwrap(); + let mut changed = backend.get_preferences(); + changed.microphone_device_name = "next microphone".into(); + backend.set_preferences(changed).unwrap(); + let selected = backend.select_dictation_translation_target(session, "ja".into()).unwrap(); + assert_eq!(selected.target_language, "日本語"); + assert_eq!(backend.get_preferences().translation_target_language, "日本語"); + assert_eq!(backend.get_preferences().microphone_device_name, "next microphone"); + backend.stop_dictation().await.unwrap(); + let contexts = engine.contexts(); + let last = contexts.last().unwrap(); + assert_eq!(last.polish.translation_target_language, "日本語"); + assert_eq!(last.recording.microphone_device_name.as_deref(), Some("captured microphone")); + assert!(last.polish.translation_active); + backend.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn translation_picker_save_failure_keeps_active_and_saved_targets() { + let data_dir = TestDataDir::new("translation-picker-save-failure"); + let engine = crate::testing::FixtureDictationEngine::successful("raw", "translated"); + let backend = backend_with_dictation_engine(data_dir.path().to_path_buf(), Arc::new(engine)); + backend.start().await.unwrap(); + let mut prefs = backend.get_preferences(); + prefs.translation_target_language = "English".into(); + prefs.working_languages = vec!["简体中文".into()]; + backend.set_preferences(prefs).unwrap(); + let session = backend.start_dictation_with_options(DictationStartOptions { + translation_requested: true, ..Default::default() + }).await.unwrap(); + let file = backend.preferences.persistence_path().to_path_buf(); + std::fs::remove_file(&file).unwrap(); + std::fs::create_dir(&file).unwrap(); + assert!(backend.select_dictation_translation_target(session, "日本語".into()).is_err()); + assert_eq!(backend.dictation_translation_target().unwrap().target_language, "English"); + assert_eq!(backend.get_preferences().translation_target_language, "English"); + std::fs::remove_dir(&file).unwrap(); + backend.cancel_dictation(Some(session)).await.unwrap(); + backend.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn translation_picker_rejects_stale_sessions_finalization_and_redundant_targets() { + let data_dir = TestDataDir::new("translation-picker-stale"); + let engine = crate::testing::FixtureDictationEngine::successful("raw", "translated"); + let backend = backend_with_dictation_engine(data_dir.path().to_path_buf(), Arc::new(engine)); + backend.start().await.unwrap(); + let mut prefs = backend.get_preferences(); + prefs.translation_target_language = "English".into(); + prefs.working_languages = vec!["简体中文".into()]; + backend.set_preferences(prefs).unwrap(); + let options = DictationStartOptions { translation_requested: true, ..Default::default() }; + let first = backend.start_dictation_with_options(options.clone()).await.unwrap(); + backend.cancel_dictation(Some(first)).await.unwrap(); + let second = backend.start_dictation_with_options(options).await.unwrap(); + assert!(backend.select_dictation_translation_target(first, "日本語".into()).is_err()); + assert!(backend.select_dictation_translation_target(second, "简体中文".into()).is_err()); + assert!(backend.select_dictation_translation_target(second, "unknown".into()).is_err()); + assert_eq!(backend.dictation_translation_target().unwrap().target_language, "English"); + backend.stop_dictation().await.unwrap(); + assert!(backend.select_dictation_translation_target(second, "日本語".into()).is_err()); + assert_eq!(backend.get_preferences().translation_target_language, "English"); + backend.shutdown().await.unwrap(); + } + #[tokio::test] async fn dictation_captures_preferences_style_and_vocabulary_once_per_session() { let data_dir = std::env::temp_dir().join(format!( @@ -14499,7 +14834,14 @@ mod tests { #[tokio::test] async fn cloud_notes_never_retain_history_or_audio_on_success_failure_or_cancel() { - for outcome in ["success", "failure", "polish_failure", "empty", "cancel"] { + for outcome in [ + "success", + "failure", + "polish_failure", + "empty", + "cancel", + "recording_fault", + ] { let data_dir = std::env::temp_dir().join(format!("openless-cloud-note-{}", uuid::Uuid::new_v4())); let transcription = if outcome == "failure" { @@ -14541,6 +14883,14 @@ mod tests { backend.set_dictation_cloud_note(id, true).unwrap(); if outcome == "cancel" { backend.cancel_dictation(Some(id)).await.unwrap(); + } else if outcome == "recording_fault" { + backend + .report_recording_fault( + id, + BackendError::new(BackendErrorCode::Platform, "fixture recording fault"), + ) + .await + .unwrap(); } else { let result = backend.stop_dictation_session(id).await; assert_eq!(result.is_ok(), outcome == "success"); @@ -15600,6 +15950,17 @@ mod tests { backend.start().await.unwrap(); let session_id = backend.start_dictation().await.unwrap(); + backend + .engine_progress_sink() + .publish( + session_id, + EngineProgress::TranscriptDelta(crate::TranscriptDelta { + text: "draft before provider failure".into(), + offset: 0, + is_final: false, + }), + ) + .unwrap(); let error = backend.stop_dictation().await.expect_err("ASR must fail"); assert_eq!(error.code, BackendErrorCode::Provider); @@ -15607,6 +15968,8 @@ mod tests { assert_eq!(history.len(), 1); assert_eq!(history[0].id, session_id.to_string()); assert_eq!(history[0].error_code.as_deref(), Some("transcribeFailed")); + assert_eq!(history[0].raw_transcript, "draft before provider failure"); + assert!(history[0].final_text.is_empty()); assert_eq!(backend.snapshot().dictation.phase, DictationPhase::Idle); assert!(backend.start_dictation().await.is_ok()); diff --git a/openless-all/app/crates/openless-core/src/asr/volcengine.rs b/openless-all/app/crates/openless-core/src/asr/volcengine.rs index 7b073747e..9777a5fd6 100644 --- a/openless-all/app/crates/openless-core/src/asr/volcengine.rs +++ b/openless-all/app/crates/openless-core/src/asr/volcengine.rs @@ -173,7 +173,9 @@ pub enum VolcengineASRError { /// the account; the latter means network down / firewall / DNS. The message is /// short; the reasons are documented rather than piling long guidance into the /// capsule. - #[error("凭据被拒({0})")] + // Keep an explicit HTTP marker so provider validation can retain the status + // when it removes untrusted provider response details. + #[error("凭据被拒(HTTP {0})")] AuthRejected(u16), /// WebSocket handshake returned 429: too many requests / account throttled. /// Classified separately instead of falling into `ConnectionFailed` — the latter @@ -181,7 +183,7 @@ pub enum VolcengineASRError { /// worsening the throttle and showing a vague "network failure" message. Like /// `AuthRejected`, this short-circuits without retry and returns a clear message /// so the user knows it is throttling, not a disconnect. - #[error("请求过多,账号被限流({0})")] + #[error("请求过多,账号被限流(HTTP {0})")] RateLimited(u16), #[error("no final result")] NoFinalResult, @@ -482,6 +484,21 @@ impl VolcengineStreamingASR { return Ok(ws); } Ok(Err(e)) => { + if let tokio_tungstenite::tungstenite::Error::Http(response) = &e { + // Failed handshakes also carry a provider trace ID. Log + // only status and trace IDs, never auth headers or bodies. + log::warn!( + "[asr] Volcengine handshake rejected status={} connect_id={} request_id={} log_id={}", + response.status().as_u16(), + connect_id, + request_id, + response + .headers() + .get("X-Tt-Logid") + .and_then(|value| value.to_str().ok()) + .unwrap_or("-") + ); + } let classified = classify_connect_error(e); if is_non_retryable(&classified) || attempt >= CONNECT_MAX_ATTEMPTS { return Err(classified); diff --git a/openless-all/app/crates/openless-core/src/audio.rs b/openless-all/app/crates/openless-core/src/audio.rs index 254ec2684..46233a7ab 100644 --- a/openless-all/app/crates/openless-core/src/audio.rs +++ b/openless-all/app/crates/openless-core/src/audio.rs @@ -17,8 +17,9 @@ pub struct AudioProcessingConfig { } /// Shared processor for native and external microphone hosts. -/// Enhancement is session-frozen. Hosts must deliver `finish()` before closing -/// their ASR consumer or recording archive. +/// Enhancement starts with the session's settings and may be disabled under +/// load. Hosts must deliver every returned tail before feeding more input or +/// closing their ASR consumer or recording archive. pub struct AudioProcessor { config: AudioProcessingConfig, normalizer: PcmNormalizer, @@ -48,6 +49,19 @@ impl AudioProcessor { *self = Self::new(self.config); } + /// Flush delayed enhanced samples before switching to basic normalization. + /// The change lasts until the processor is replaced for a new capture. + pub fn disable_enhancement(&mut self) -> Option { + if !self.config.enhancement_enabled || self.finished { + return None; + } + let tail = self.finish(); + self.config.enhancement_enabled = false; + self.enhanced = None; + self.finished = false; + tail + } + pub fn process( &mut self, input: &[f32], @@ -408,6 +422,36 @@ mod tests { ); } + #[test] + fn disabling_enhancement_flushes_lookahead_once_and_preserves_duration() { + for rate in [16_000, 44_100, 48_000] { + let mut processor = AudioProcessor::new(AudioProcessingConfig { + enhancement_enabled: true, + ..Default::default() + }); + let mut samples = 0; + for chunk in [ + processor.process(&vec![0.25; 317], 1, rate), + processor.disable_enhancement(), + ] + .into_iter() + .flatten() + { + samples += chunk.pcm_i16_le.len() / 2; + } + assert_eq!( + samples, + (317_u64 * 16_000).div_ceil(u64::from(rate)) as usize + ); + assert!(processor.disable_enhancement().is_none()); + let next = processor.process(&[0.5; 160], 1, 16_000).unwrap(); + assert_eq!(decode(&next.pcm_i16_le), vec![16383; 160]); + assert!(processor.finish().is_none()); + assert!(processor.disable_enhancement().is_none()); + assert!(processor.process(&[0.5; 160], 1, 16_000).is_none()); + } + } + #[test] fn processor_reset_discards_resampling_state() { let mut processor = AudioProcessor::new(AudioProcessingConfig::default()); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index 6163cd56b..3b7e806c1 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -396,6 +396,13 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ shape: PreferenceShape::OptionalObject, reason: "device_bound", }, + PreferenceField { + rust_name: "qa_companion_key", + key: "qaCompanionKey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, PreferenceField { rust_name: "quick_note_hotkey", key: "quickNoteHotkey", diff --git a/openless-all/app/crates/openless-core/src/dictation_engine.rs b/openless-all/app/crates/openless-core/src/dictation_engine.rs index 08d46eafe..0782aca9b 100644 --- a/openless-all/app/crates/openless-core/src/dictation_engine.rs +++ b/openless-all/app/crates/openless-core/src/dictation_engine.rs @@ -88,7 +88,7 @@ struct PipelineSession { transcription_cancelled: AtomicBool, polishing: AtomicBool, polisher_cancelled: AtomicBool, - recording_fault: Mutex>, + retain_failed_recording: AtomicBool, resources: Mutex, } @@ -150,7 +150,7 @@ impl PipelineSession { transcription_cancelled: AtomicBool::new(false), polishing: AtomicBool::new(false), polisher_cancelled: AtomicBool::new(false), - recording_fault: Mutex::new(None), + retain_failed_recording: AtomicBool::new(false), resources: Mutex::new(PipelineResources::default()), } } @@ -313,7 +313,6 @@ impl DictationEngine for PipelineDictationEngine { let recording_progress: Arc = Arc::new(RecordingProgressForwarder { session_id, - session: Arc::downgrade(&session), progress: Arc::clone(&progress), }); let buffered = Arc::new(BufferedTranscriptionSession::new( @@ -1208,6 +1207,29 @@ impl DictationEngine for PipelineDictationEngine { self.recorder.feed_pcm(session_id, pcm) } + fn retain_failed_recording(&self, session_id: SessionId) -> Option> { + let session = self + .sessions + .lock() + .expect("pipeline session lock poisoned") + .get(&session_id) + .cloned()?; + if session.context().output_target == DictationOutputTarget::CloudNote { + return None; + } + session + .retain_failed_recording + .store(true, Ordering::Release); + let resources = session + .resources + .lock() + .expect("pipeline resource lock poisoned"); + resources + .recording + .as_ref() + .and_then(PipelineRecording::archive) + } + fn cancel(&self, session_id: SessionId) -> BoxFuture<'static, Result<(), BackendError>> { let sessions = Arc::clone(&self.sessions); let polisher = Arc::clone(&self.polisher); @@ -1249,7 +1271,15 @@ impl DictationEngine for PipelineDictationEngine { if let Some(recording) = recording { let archive = recording.archive(); retain_first_error(&mut first_error, recording.stop().await); - if !preserve_quick_note_archive { + if session.retain_failed_recording.load(Ordering::Acquire) { + let mut available = None; + demote_failed_archive( + archive.as_ref(), + session.context().output_target, + &mut available, + ) + .await; + } else if !preserve_quick_note_archive { if let Some(archive) = archive { retain_first_error(&mut first_error, archive.discard().await); } @@ -2123,7 +2153,6 @@ impl TextStreamSink for DiscardTextStream { struct RecordingProgressForwarder { session_id: SessionId, - session: std::sync::Weak, progress: Arc, } @@ -2146,16 +2175,9 @@ impl RecordingProgressSink for RecordingProgressForwarder { crate::ports::RecordingEvent::LimitReached => self .progress .publish(self.session_id, EngineProgress::RecordingLimitReached), - crate::ports::RecordingEvent::Fatal(error) => { - if let Some(session) = self.session.upgrade() { - *session - .recording_fault - .lock() - .expect("recording fault lock poisoned") = Some(error.clone()); - } - self.progress - .publish(self.session_id, EngineProgress::RecordingFault(error)) - } + crate::ports::RecordingEvent::Fatal(error) => self + .progress + .publish(self.session_id, EngineProgress::RecordingFault(error)), } } } @@ -2301,20 +2323,6 @@ mod tests { } } - #[test] - fn recording_progress_forwarder_does_not_keep_pipeline_session_alive() { - let session = Arc::new(PipelineSession::new(raw_dictation_context())); - let weak = Arc::downgrade(&session); - let _forwarder = RecordingProgressForwarder { - session: Arc::downgrade(&session), - session_id: SessionId::new(), - progress: Arc::new(RecordingProgress::default()), - }; - - drop(session); - assert!(weak.upgrade().is_none()); - } - struct FixtureRecording { stops: Arc, archive: Arc, diff --git a/openless-all/app/crates/openless-core/src/domains.rs b/openless-all/app/crates/openless-core/src/domains.rs index 1c87cab17..b5783229e 100644 --- a/openless-all/app/crates/openless-core/src/domains.rs +++ b/openless-all/app/crates/openless-core/src/domains.rs @@ -856,11 +856,26 @@ pub struct QaMessage { pub selection_text: Option, } +/// Status reported by a real provider/tool execution, never inferred from answer text. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QaActivityKind { Thinking, SearchingWeb, ReadingWeb, UsingTool } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QaActivity { + pub kind: QaActivityKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_name: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct QaSnapshot { pub phase: QaPhase, #[serde(default, skip_serializing_if = "Option::is_none")] + pub activity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub session_id: Option, /// Stable owner for resources that span multiple successful turns, such as /// an edit preview. `session_id` remains a per-turn generation token so a @@ -885,6 +900,7 @@ impl Default for QaSnapshot { fn default() -> Self { Self { phase: QaPhase::Idle, + activity: None, session_id: None, conversation_id: None, messages: Vec::new(), @@ -940,6 +956,7 @@ pub struct QaRuntimeCompletion { #[derive(Debug, Clone, PartialEq)] pub enum QaProgress { + Activity(QaActivity), RecordingLevel(f32), SelectionCaptured(Option), AnswerDelta(String), @@ -1043,6 +1060,10 @@ pub trait QaApi: Send + Sync { fn show(&self) -> BoxFuture<'static, Result<(), BackendError>>; fn snapshot(&self) -> BoxFuture<'static, Result>; fn toggle_recording(&self) -> BoxFuture<'static, Result<(), BackendError>>; + /// Start an independent, single-question voice session without a composer. + fn start_question(&self) -> BoxFuture<'static, Result<(), BackendError>> { + self.toggle_recording() + } /// Stop only this recording generation. Deferred device/silence callbacks /// must never use the UI toggle: it could start or stop a newer turn. fn stop_recording( diff --git a/openless-all/app/crates/openless-core/src/events.rs b/openless-all/app/crates/openless-core/src/events.rs index f5c75272c..cefd53fc4 100644 --- a/openless-all/app/crates/openless-core/src/events.rs +++ b/openless-all/app/crates/openless-core/src/events.rs @@ -212,6 +212,7 @@ pub struct LessComputerEvent { #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum QaStateKind { + Activity, Idle, Loading, Thinking, @@ -232,6 +233,8 @@ pub enum QaStateKind { pub struct QaStateEvent { pub kind: QaStateKind, #[serde(default, skip_serializing_if = "Option::is_none")] + pub activity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub messages: Option>, @@ -255,6 +258,7 @@ impl QaStateEvent { pub fn simple(kind: QaStateKind) -> Self { Self { kind, + activity: None, session_id: None, messages: None, selection_preview: None, @@ -330,6 +334,7 @@ impl QaStateEvent { || snapshot.edit_revert_available)); Self { kind, + activity: snapshot.activity.clone(), session_id: snapshot.session_id.map(|session_id| session_id.to_string()), messages: carries_messages.then_some(messages), selection_preview: carries_selection @@ -904,6 +909,7 @@ mod tests { #[test] fn migration_events_are_typed_and_keep_legacy_payload_fields() { let qa = QaStateEvent { + activity: None, kind: QaStateKind::AnswerDelta, session_id: Some("qa-session".into()), messages: None, diff --git a/openless-all/app/crates/openless-core/src/language_catalog.rs b/openless-all/app/crates/openless-core/src/language_catalog.rs index 6183ff2ea..6bcfcf273 100644 --- a/openless-all/app/crates/openless-core/src/language_catalog.rs +++ b/openless-all/app/crates/openless-core/src/language_catalog.rs @@ -34,6 +34,15 @@ fn language(name: &str) -> Option<&'static Language> { }) } +pub fn native_language_name(value: &str) -> Option { + language(value).map(|language| language.native_name.clone()) +} + +/// The catalog code (`zh-Hans`, `en`, ...) used to name a language in a locale. +pub fn language_code(value: &str) -> Option { + language(value).map(|language| language.code.clone()) +} + pub fn asr_language_code(native_name: &str) -> Option { language(native_name).map(|language| language.asr_code.clone()) } diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 7a4368e0a..1f363893f 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -220,8 +220,9 @@ pub mod contract { pub use activity::{ActivityDay, ActivityStore, DayStats}; pub use api::{ - AsrRoute, AsrUsage, BackendRepositories, BackendSnapshot, CliDispatchOutcome, DictationHotkeyDispatchOptions, - DictationHotkeyEdge, LessComputerHotkeyAction, LessComputerVoiceFinish, + AsrRoute, AsrUsage, BackendRepositories, BackendSnapshot, CliDispatchOutcome, + DictationHotkeyDispatchOptions, DictationHotkeyEdge, DictationTranslationTarget, + LessComputerHotkeyAction, LessComputerVoiceFinish, LessComputerVoiceOptions, LessComputerVoiceSession, OpenLessBackend, QaVoiceCaptureResult, QaVoiceCaptureSession, StartupSnapshot, VoiceTranscriptionSession, }; diff --git a/openless-all/app/crates/openless-core/src/ports.rs b/openless-all/app/crates/openless-core/src/ports.rs index 87ab414f2..66dfa69f4 100644 --- a/openless-all/app/crates/openless-core/src/ports.rs +++ b/openless-all/app/crates/openless-core/src/ports.rs @@ -471,6 +471,13 @@ pub trait DictationEngine: Send + Sync + 'static { )) } + /// Retain an existing archive during fault cleanup. Availability must be + /// checked after cancellation has drained the recorder. This never enables + /// recording storage for a session whose policy did not already allow it. + fn retain_failed_recording(&self, _session_id: SessionId) -> Option> { + None + } + fn cancel(&self, session_id: SessionId) -> BoxFuture<'static, Result<(), BackendError>>; } diff --git a/openless-all/app/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index 8212879bb..abf797bcc 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -1497,6 +1497,25 @@ mod tests { assert!(!format!("{error:?}").contains("response-secret")); } + #[test] + fn volcengine_validation_preserves_handshake_status_without_response_body() { + use crate::asr::volcengine::VolcengineASRError; + + for (failure, status) in [ + (VolcengineASRError::AuthRejected(401), 401), + (VolcengineASRError::AuthRejected(403), 403), + (VolcengineASRError::RateLimited(429), 429), + ] { + // Cloud transcription wraps the provider error before validation + // sanitizes it. Keep the status while discarding untrusted details. + let error = sanitize_validation_error(provider_error(format!( + "ASR provider failed: {failure}; response-secret" + ))); + assert_eq!(error.message, format!("providerHttpStatus:{status}")); + assert!(!format!("{error:?}").contains("response-secret")); + } + } + #[tokio::test] async fn manual_bailian_protocol_is_loaded_per_channel_for_validation() { let credentials = Arc::new(InMemoryCredentialStore::default()); diff --git a/openless-all/app/crates/openless-core/src/qa_service.rs b/openless-all/app/crates/openless-core/src/qa_service.rs index ae948b37c..63e93ce7d 100644 --- a/openless-all/app/crates/openless-core/src/qa_service.rs +++ b/openless-all/app/crates/openless-core/src/qa_service.rs @@ -172,6 +172,7 @@ impl QaService { return; } state.snapshot.phase = QaPhase::Failed; + state.snapshot.activity = None; state.snapshot.pending_approval_token = None; state.snapshot.last_error = Some(message.clone()); state.snapshot.conversation_id = None; @@ -193,7 +194,7 @@ impl QaService { } } - async fn begin_recording(&self) -> Result<(), BackendError> { + async fn begin_recording(&self, fresh: bool) -> Result<(), BackendError> { let _runtime = self.begin_runtime_work()?; let session_id = SessionId::new(); { @@ -207,8 +208,10 @@ impl QaService { self.voice_sessions .acquire(session_id, crate::voice_session::VoiceSessionKind::Qa)?; let previous = state.snapshot.clone(); + if fresh { state.snapshot = QaSnapshot::default(); } let conversation_id = state.snapshot.conversation_id.unwrap_or(session_id); state.snapshot.phase = QaPhase::Recording; + state.snapshot.activity = None; state.snapshot.session_id = Some(session_id); state.snapshot.conversation_id = Some(conversation_id); state.snapshot.pending_approval_token = None; @@ -254,6 +257,7 @@ impl QaService { // back on, stop its successor, or compete with a manual stop. ensure_current_phase(&state.snapshot, session_id, QaPhase::Recording)?; state.snapshot.phase = QaPhase::Thinking; + state.snapshot.activity = Some(crate::domains::QaActivity { kind: crate::domains::QaActivityKind::Thinking, tool_name: None }); self.runtime_work.existing_work() }; self.publish_snapshot(QaStateKind::Loading, Some((session_id, QaPhase::Thinking))); @@ -327,6 +331,7 @@ impl QaService { let previous = state.snapshot.clone(); let conversation_id = state.snapshot.conversation_id.unwrap_or(session_id); state.snapshot.phase = QaPhase::Thinking; + state.snapshot.activity = Some(crate::domains::QaActivity { kind: crate::domains::QaActivityKind::Thinking, tool_name: None }); state.snapshot.session_id = Some(session_id); state.snapshot.conversation_id = Some(conversation_id); state.snapshot.pending_approval_token = None; @@ -402,6 +407,7 @@ impl QaService { let mut state = self.state.lock().expect("QA state lock poisoned"); ensure_current_phase(&state.snapshot, session_id, QaPhase::Thinking)?; state.snapshot.phase = QaPhase::Completed; + state.snapshot.activity = None; state.snapshot.selection_preview = None; drop(state); self.publish_snapshot(QaStateKind::Idle, Some((session_id, QaPhase::Completed))); @@ -522,6 +528,7 @@ impl QaService { selection_text: None, }); state.snapshot.phase = QaPhase::Completed; + state.snapshot.activity = None; state.snapshot.selection_preview = None; state.snapshot.pending_approval_token = None; state.snapshot.last_error = None; @@ -660,6 +667,7 @@ impl QaService { .flatten(); if publish_cancelled { state.snapshot.phase = QaPhase::Cancelled; + state.snapshot.activity = None; } state.snapshot.pending_approval_token = None; state.snapshot.last_error = None; @@ -790,6 +798,11 @@ impl QaApi for QaService { }) } + fn start_question(&self) -> BoxFuture<'static, Result<(), BackendError>> { + let service = self.clone(); + Box::pin(async move { service.begin_recording(true).await }) + } + fn toggle_recording(&self) -> BoxFuture<'static, Result<(), BackendError>> { let service = self.clone(); Box::pin(async move { @@ -804,7 +817,7 @@ impl QaApi for QaService { service.finish_recording(session_id).await } (QaPhase::Idle | QaPhase::Completed | QaPhase::Cancelled | QaPhase::Failed, _) => { - service.begin_recording().await + service.begin_recording(false).await } _ => Err(BackendError::new( BackendErrorCode::Busy, @@ -1030,6 +1043,12 @@ impl QaProgressSink for QaServiceProgress { // Linearize native progress with state changes, but do not take the // presentation mutex: a Host window action must not block audio levels. match progress { + QaProgress::Activity(activity) => { + let mut state = self.state.lock().expect("QA state lock poisoned"); + ensure_current_phase(&state.snapshot, session_id, QaPhase::Thinking)?; + state.snapshot.activity = Some(activity); + publish_qa_snapshot(&self.events, &state.snapshot, QaStateKind::Activity, None, None); + } QaProgress::RecordingLevel(level) => { let state = self.state.lock().expect("QA state lock poisoned"); ensure_current_phase(&state.snapshot, session_id, QaPhase::Recording)?; diff --git a/openless-all/app/crates/openless-core/src/settings.rs b/openless-all/app/crates/openless-core/src/settings.rs index 0f1a573a3..51600f8d8 100644 --- a/openless-all/app/crates/openless-core/src/settings.rs +++ b/openless-all/app/crates/openless-core/src/settings.rs @@ -60,6 +60,8 @@ pub struct HotkeyRuntimeTarget { pub dictation: ShortcutBinding, pub dictation_mode: HotkeyMode, pub qa: Option, + #[serde(default)] + pub qa_companion_key: String, pub quick_note: Option, pub translation: ShortcutBinding, pub switch_style: Option, @@ -79,7 +81,8 @@ impl From<&UserPreferences> for HotkeyRuntimeTarget { Self { dictation: preferences.dictation_hotkey.clone(), dictation_mode: preferences.hotkey.mode, - qa: preferences.qa_hotkey.clone(), + qa: if cfg!(target_os = "linux") { preferences.qa_hotkey.clone() } else { None }, + qa_companion_key: preferences.qa_companion_key.clone(), quick_note: preferences.quick_note_hotkey.clone(), translation: preferences.translation_hotkey.clone(), switch_style: preferences.switch_style_hotkey.clone(), diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 09786dcfa..ddb570f96 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -577,6 +577,9 @@ pub struct UserPreferences { /// Ctrl+Shift+; (Windows). See issue #118. #[serde(default = "default_qa_hotkey")] pub qa_hotkey: Option, + /// Additional key held with the user-selected recording shortcut. Empty disables Ask. + #[serde(default = "default_qa_companion_key")] + pub qa_companion_key: String, /// Standalone quick-note hotkey. None = not configured; when enabled, press once to start and again to stop. #[serde(default)] pub quick_note_hotkey: Option, @@ -1094,6 +1097,8 @@ struct UserPreferencesWire { #[serde(default)] output_language_preference: OutputLanguagePreference, qa_hotkey: Option, + #[serde(default = "default_qa_companion_key")] + qa_companion_key: String, #[serde(default)] quick_note_hotkey: Option, /// Outer `None` means the field was absent in a pre-Selection-Polish file; @@ -1344,6 +1349,7 @@ impl Default for UserPreferencesWire { chinese_script_preference: prefs.chinese_script_preference, output_language_preference: prefs.output_language_preference, qa_hotkey: prefs.qa_hotkey, + qa_companion_key: prefs.qa_companion_key, quick_note_hotkey: prefs.quick_note_hotkey, selection_polish_hotkey: None, selection_polish_style_pack_id: prefs.selection_polish_style_pack_id, @@ -1572,7 +1578,8 @@ impl<'de> Deserialize<'de> for UserPreferences { translation_target_language: wire.translation_target_language, chinese_script_preference: wire.chinese_script_preference, output_language_preference: wire.output_language_preference, - qa_hotkey: wire.qa_hotkey, + qa_hotkey: if cfg!(target_os = "linux") { wire.qa_hotkey } else { None }, // retired desktop follow-up shortcut + qa_companion_key: wire.qa_companion_key, quick_note_hotkey: wire.quick_note_hotkey, selection_polish_hotkey, selection_polish_style_pack_id: wire.selection_polish_style_pack_id, @@ -1791,9 +1798,11 @@ fn salvage_without_incomplete_legacy_hotkey( } fn default_qa_hotkey() -> Option { - Some(ShortcutBinding::default_qa()) + if cfg!(target_os = "linux") { Some(ShortcutBinding::default_qa()) } else { None } } +fn default_qa_companion_key() -> String { "Space".into() } + fn default_selection_polish_hotkey() -> Option { #[cfg(target_os = "windows")] { @@ -1918,7 +1927,7 @@ impl Default for UserPreferences { custom_style_prompts: CustomStylePrompts::default(), launch_at_login: false, show_capsule: true, - capsule_style: CapsuleStyle::Siri, + capsule_style: CapsuleStyle::Classic, capsule_display_mode: CapsuleDisplayMode::CursorScreen, capsule_display_mode_v2: None, capsule_transcript_enabled: true, @@ -1960,6 +1969,7 @@ impl Default for UserPreferences { chinese_script_preference: ChineseScriptPreference::Auto, output_language_preference: OutputLanguagePreference::Auto, qa_hotkey: default_qa_hotkey(), + qa_companion_key: default_qa_companion_key(), quick_note_hotkey: None, selection_polish_hotkey: default_selection_polish_hotkey(), selection_polish_style_pack_id: default_active_style_pack_id(), @@ -2772,14 +2782,16 @@ impl CapsuleDisplayMode { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum CapsuleStyle { - /// SiriGL light-effect stage style (default). - #[default] + /// Optional spectral light-effect stage. Siri, - /// Openless default style: classic frosted-glass pill (volume bar + - /// cancel/confirm buttons). + /// OpenLess default: white pill, blue waveform and compact processing label. + /// Legacy style preferences migrate before native bounds are computed. + #[default] + #[serde(alias = "typeless")] Classic, - /// Classic dark capsule: blue waveform that narrows into a status hint - /// while processing. + /// Legacy wire variant retained for old native geometry contracts only. + /// It is no longer accepted as a distinct preference or exposed in the UI. + #[serde(skip_deserializing)] Typeless, } @@ -3039,6 +3051,21 @@ mod translation_effective_tests { mod tests { use super::*; + #[test] + fn ask_companion_migrates_legacy_shortcuts_and_round_trips_custom_or_disabled_keys() { + let mut value = serde_json::to_value(UserPreferences::default()).unwrap(); + value.as_object_mut().unwrap().remove("qaCompanionKey"); + value["qaHotkey"] = serde_json::json!({"primary":";", "modifiers":["cmd","shift"]}); + let migrated: UserPreferences = serde_json::from_value(value).unwrap(); + assert_eq!(migrated.qa_companion_key, "Space"); + if !cfg!(target_os = "linux") { assert!(migrated.qa_hotkey.is_none()); } + for key in ["F9", ""] { + let mut prefs = migrated.clone(); prefs.qa_companion_key = key.into(); + let restored: UserPreferences = serde_json::from_value(serde_json::to_value(prefs).unwrap()).unwrap(); + assert_eq!(restored.qa_companion_key, key); + } + } + #[test] fn legacy_less_computer_settings_remain_sealed_until_device_opt_in() { let legacy: UserPreferences = serde_json::from_value(serde_json::json!({ @@ -3413,15 +3440,16 @@ mod tests { } #[test] - fn capsule_style_pref_defaults_to_siri_and_round_trips_wire_key() { - // Old preferences.json has no capsuleStyle field -> falls back to the default Siri. + fn capsule_style_pref_defaults_to_openless_and_migrates_typeless() { let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); - assert_eq!(prefs.capsule_style, CapsuleStyle::Siri); + assert_eq!(prefs.capsule_style, CapsuleStyle::Classic); + let legacy: UserPreferences = serde_json::from_str(r#"{"capsuleStyle":"typeless"}"#).unwrap(); + assert_eq!(legacy.capsule_style, CapsuleStyle::Classic); + assert_eq!(serde_json::to_value(legacy).unwrap()["capsuleStyle"], "classic"); for (style, wire_name) in [ (CapsuleStyle::Siri, "siri"), (CapsuleStyle::Classic, "classic"), - (CapsuleStyle::Typeless, "typeless"), ] { let preferences = UserPreferences { capsule_style: style, diff --git a/openless-all/app/crates/openless-core/src/shortcut_types.rs b/openless-all/app/crates/openless-core/src/shortcut_types.rs index 9e9e87c69..92e436b4d 100644 --- a/openless-all/app/crates/openless-core/src/shortcut_types.rs +++ b/openless-all/app/crates/openless-core/src/shortcut_types.rs @@ -775,6 +775,19 @@ pub fn reconcile_hotkey_collisions( } pub fn reject_hotkey_collisions(preferences: &UserPreferences) -> Result<(), String> { + let companion = preferences.qa_companion_key.trim(); + if !companion.is_empty() { + let binding = ShortcutBinding { primary: companion.into(), modifiers: Vec::new() }; + validate_shortcut_binding(&binding).map_err(|error| error.to_string())?; + if legacy_modifier_trigger(&binding).is_some() + || companion.eq_ignore_ascii_case("Shift") + || companion.eq_ignore_ascii_case("Escape") + || companion.starts_with("Mouse") + || companion.eq_ignore_ascii_case(&preferences.dictation_hotkey.primary) + { + return Err("提问附加键需选择一个与录音主键不同的普通键,例如 Space".into()); + } + } reject_non_dictation_side_specific_shortcuts(preferences)?; let switch_style = preferences.switch_style_hotkey.as_ref(); let open_app = preferences.open_app_hotkey.as_ref(); diff --git a/openless-all/app/crates/openless-core/src/streaming_insert.rs b/openless-all/app/crates/openless-core/src/streaming_insert.rs index d0a02c3bd..e7d7965bf 100644 --- a/openless-all/app/crates/openless-core/src/streaming_insert.rs +++ b/openless-all/app/crates/openless-core/src/streaming_insert.rs @@ -91,6 +91,15 @@ impl StreamingInsertState { self.pending.push_str(&suffix); } + /// Keep trailing ASCII spaces with the next delta, so an SSE boundary + /// cannot turn indentation into separate synthetic Space keystrokes. + /// Final reconciliation writes any remaining suffix, even if it is all spaces. + pub fn take_ready_prefix(&mut self) -> String { + let end = self.pending.trim_end_matches(' ').len(); + let suffix = self.pending.split_off(end); + std::mem::replace(&mut self.pending, suffix) + } + /// Flushes pending text through the host inserter. A partial Unicode write /// is retained as a typed prefix and becomes an explicit fallback. pub fn flush(&mut self, mut insert: F) -> Result @@ -323,6 +332,26 @@ mod tests { ); } + #[test] + fn whitespace_split_across_deltas_stays_literal_and_final_tail_is_retained() { + let mut state = StreamingInsertState::default(); + state.push_delta(0, "变更说明\n "); + let first = state.take_ready_prefix(); + assert_eq!(first, "变更说明\n"); + state.typed_text.push_str(&first); + state.push_delta(6, " "); + assert!(state.take_ready_prefix().is_empty()); + state.push_delta(7, " (a) 保留 "); + let second = state.take_ready_prefix(); + assert_eq!(second, " (a) 保留"); + state.typed_text.push_str(&second); + assert_eq!(state.pending, " "); + assert_eq!( + state.reconcile_final("变更说明\n (a) 保留 "), + FinalReconciliation::WriteTail(" ".into()) + ); + } + #[test] fn failed_stream_preserves_the_complete_final_for_fallback() { let stream = StreamingInsertState { diff --git a/openless-all/app/crates/openless-core/tests/qa_contract.rs b/openless-all/app/crates/openless-core/tests/qa_contract.rs index 48e776623..cc682ccbe 100644 --- a/openless-all/app/crates/openless-core/tests/qa_contract.rs +++ b/openless-all/app/crates/openless-core/tests/qa_contract.rs @@ -21,6 +21,7 @@ struct FixtureQaRuntime { recording_sessions: Mutex>, bound_selection_targets: Mutex>, emit_approval: AtomicBool, + emit_web_activity: AtomicBool, fail_prepare: AtomicBool, fail_finish: AtomicBool, fail_answer: AtomicBool, @@ -50,6 +51,7 @@ impl Default for FixtureQaRuntime { recording_sessions: Mutex::new(Vec::new()), bound_selection_targets: Mutex::new(Vec::new()), emit_approval: AtomicBool::new(false), + emit_web_activity: AtomicBool::new(false), fail_prepare: AtomicBool::new(false), fail_finish: AtomicBool::new(false), fail_answer: AtomicBool::new(false), @@ -183,12 +185,18 @@ impl QaRuntimeAdapter for FixtureQaRuntime { self.requests.lock().unwrap().push(request.clone()); let answer = self.answer.lock().unwrap().clone(); let emit_approval = self.emit_approval.load(Ordering::Acquire); + let emit_web_activity = self.emit_web_activity.load(Ordering::Acquire); let fail_answer = self.fail_answer.load(Ordering::Acquire); let should_block = self.block_answer.load(Ordering::Acquire); let answer_entered = Arc::clone(&self.answer_entered); let started = Arc::clone(&self.answer_started); let gate = Arc::clone(&self.answer_gate); Box::pin(async move { + if emit_web_activity { + progress.publish(request.session_id, QaProgress::Activity(openless_core::QaActivity { + kind: openless_core::QaActivityKind::SearchingWeb, tool_name: Some("web_search".into()) + }))?; + } answer_entered.store(true, Ordering::Release); started.notify_waiters(); progress.publish( @@ -1349,6 +1357,7 @@ fn qa_snapshot_resync_uses_the_same_complete_wire_contract_as_live_events() { let session_id = SessionId::new(); let session_text = session_id.to_string(); let event = QaStateEvent::from_snapshot(&QaSnapshot { + activity: None, phase: QaPhase::AwaitingApproval, session_id: Some(session_id), conversation_id: Some(SessionId::new()), @@ -1374,6 +1383,7 @@ fn qa_snapshot_resync_uses_the_same_complete_wire_contract_as_live_events() { assert!(event.error.is_none()); let failed = QaStateEvent::from_snapshot(&QaSnapshot { + activity: None, phase: QaPhase::Failed, last_error: Some("public failure".into()), ..QaSnapshot::default() @@ -1381,3 +1391,47 @@ fn qa_snapshot_resync_uses_the_same_complete_wire_contract_as_live_events() { assert_eq!(failed.kind, QaStateKind::Error); assert_eq!(failed.error.as_deref(), Some("public failure")); } + +#[tokio::test] +async fn ask_anything_starts_directly_and_each_question_has_fresh_context() { + let runtime = Arc::new(FixtureQaRuntime::responding("answer")); + *runtime.recorded_text.lock().unwrap() = "question".into(); + *runtime.selection.lock().unwrap() = Some("selected reference".into()); + let (backend, data_dir) = backend(runtime.clone()); + let qa = &backend.services().qa; + for _ in 0..2 { + qa.start_question().await.unwrap(); + let recording = qa.snapshot().await.unwrap(); + assert_eq!(recording.phase, QaPhase::Recording); + assert!(recording.messages.is_empty()); + assert_eq!(recording.selection_preview.as_deref(), Some("selected reference")); + qa.stop_recording(recording.session_id.unwrap()).await.unwrap(); + assert_eq!(qa.snapshot().await.unwrap().phase, QaPhase::Completed); + } + let requests = runtime.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_ne!(requests[0].conversation_id, requests[1].conversation_id); + assert_eq!(requests[1].messages.len(), 1); + assert_eq!(requests[1].input.selection_text.as_deref(), Some("selected reference")); + drop(requests); + let _ = std::fs::remove_dir_all(data_dir); +} + +#[tokio::test] +async fn real_web_tool_activity_is_in_the_snapshot_and_clears_on_completion() { + let runtime = Arc::new(FixtureQaRuntime::responding("answer")); + runtime.emit_web_activity.store(true, Ordering::Release); + runtime.block_answer.store(true, Ordering::Release); + let (backend, data_dir) = backend(runtime.clone()); + let qa = backend.services().qa.clone(); + let answering = tokio::spawn(async move { qa.submit_text("question".into()).await }); + runtime.wait_for_answer().await; + let snapshot = backend.services().qa.snapshot().await.unwrap(); + assert_eq!(snapshot.activity.as_ref().unwrap().kind, openless_core::QaActivityKind::SearchingWeb); + let event = QaStateEvent::from_snapshot(&snapshot); + assert_eq!(event.activity.unwrap().tool_name.as_deref(), Some("web_search")); + runtime.answer_gate.add_permits(1); + answering.await.unwrap().unwrap(); + assert!(backend.services().qa.snapshot().await.unwrap().activity.is_none()); + let _ = std::fs::remove_dir_all(data_dir); +} diff --git a/openless-all/app/e2e/ask-anything.spec.ts b/openless-all/app/e2e/ask-anything.spec.ts new file mode 100644 index 000000000..6699bbf08 --- /dev/null +++ b/openless-all/app/e2e/ask-anything.spec.ts @@ -0,0 +1,116 @@ +import { expect, test, type Page } from '@playwright/test'; +const fixture = 'http://127.0.0.1:5326/e2e/fixtures/capsule-motion.html'; +async function emit(page: Page, event: string, payload: unknown) { + await page.evaluate((v) => (window as any).fixture.emit(v.event, v.payload), { event, payload }); +} + +test('translation uses the shared popup and native headroom; save failure preserves the old language', async ({ + page, +}, info) => { + await page.setViewportSize({ width: 460, height: 172 }); + await page.exposeFunction('resizeMenuForTest', async (open: boolean) => { + await page.setViewportSize({ width: 460, height: open ? 492 : 172 }); + }); + await page.goto(fixture); + await page.locator('.ol-default-capsule').waitFor(); + await page.evaluate(() => (window as any).fixture.setCapsule({ translation: true })); + const picker = page.getByRole('combobox', { name: '翻译目标语言' }); + await expect(picker).toBeEnabled(); + await picker.click(); + const popup = page.locator('.ol-select-popup'); + await expect(popup).toBeVisible(); + await expect(popup).toHaveCSS('border-radius', '10px'); + const bounds = await popup.boundingBox(); + expect(bounds!.y).toBeGreaterThanOrEqual(0); + expect(bounds!.height).toBeGreaterThan(200); + await page.screenshot({ path: info.outputPath('translation-menu.png') }); + await page.getByRole('option', { name: '日语', exact: true }).click(); + await expect(picker).toContainText('日语'); + await expect(popup).toBeHidden(); + await expect.poll(() => page.viewportSize()?.height).toBe(172); + expect(await page.evaluate(() => (window as any).fixture.targetChanges.at(-1))).toEqual({ + sessionId: 'recording-a', + targetLanguage: '日本語', + }); + await page.evaluate(() => ((window as any).fixture.failTarget = true)); + await picker.click(); + await page.getByRole('option', { name: '法语', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('语言切换失败'); + await expect(picker).toContainText('日语'); + await expect(picker).toBeEnabled(); +}); + +test('translation popup closes when the recording owner changes', async ({ page }) => { + await page.goto(fixture); + await page.locator('.ol-default-capsule').waitFor(); + await page.evaluate(() => (window as any).fixture.setCapsule({ translation: true })); + const picker = page.getByRole('combobox', { name: '翻译目标语言' }); + await expect(picker).toBeEnabled(); + await picker.click(); + await expect(page.locator('.ol-select-popup')).toBeVisible(); + await page.evaluate(() => { + const f = (window as any).fixture; + f.target.sessionId = 'recording-b'; + f.emit('capsule:state', { state: 'recording', sessionId: 'recording-b', translation: true }); + }); + await expect(page.locator('.ol-select-popup')).toBeHidden(); + expect(await page.evaluate(() => (window as any).fixture.targetChanges)).toEqual([]); +}); + +test('Ask has no desktop composer; provider tool status stays in the capsule until the final answer', async ({ + page, +}, info) => { + await page.goto(fixture + '?qa'); + await page.locator('.ol-default-capsule').waitFor(); + await expect(page.locator('input')).toHaveCount(0); + await emit(page, 'qa:state', { + kind: 'recording', + sessionId: 'ask-a', + messages: [], + selectionPreview: 'selected source', + }); + await expect(page.locator('.qa-voice-hint')).toContainText('selected source'); + await emit(page, 'qa:state', { kind: 'thinking', sessionId: 'ask-a', messages: [] }); + await emit(page, 'qa:state', { + kind: 'activity', + sessionId: 'ask-a', + activity: { kind: 'searching_web', toolName: 'web_search' }, + }); + await expect(page.locator('.ol-default-status')).toContainText('正在搜索网页'); + await emit(page, 'qa:state', { kind: 'answer_delta', sessionId: 'ask-a', chunk: 'partial' }); + await expect(page.locator('.qa-answer-panel')).toHaveCount(0); + await emit(page, 'qa:state', { + kind: 'activity', + sessionId: 'old-ask', + activity: { kind: 'using_tool', toolName: 'stale' }, + }); + await expect(page.locator('.ol-default-status')).toContainText('正在搜索网页'); + await emit(page, 'qa:state', { + kind: 'answer', + sessionId: 'ask-a', + messages: [ + { role: 'user', content: '问题', selectionText: 'selected source' }, + { role: 'assistant', content: '最终回答' }, + ], + }); + await expect(page.locator('.qa-answer-panel')).toBeVisible(); + await expect(page.locator('.qa-assistant-message')).toContainText('最终回答'); + await expect(page.locator('.qa-follow-up')).toHaveCount(0); + await expect(page.locator('input')).toHaveCount(0); + await page.screenshot({ path: info.outputPath('ask-result.png') }); +}); + +test('overview is restored and the Ask page remains without the added three-row guide', async ({ + page, +}, info) => { + await page.goto('/'); + await expect(page.locator('[data-ol-page="overview"]')).toBeVisible(); + await expect(page.locator('.ol-recording-key-guide')).toHaveCount(0); + await page.screenshot({ path: info.outputPath('overview-restored.png') }); + const nav = page.locator('.ol-sidebar-surface nav'); + await nav.getByRole('button', { name: '工具', exact: true }).click(); + await nav.getByRole('button', { name: '问任何问题', exact: true }).click(); + await expect(page.getByText('提问附加键', { exact: true })).toBeVisible(); + await expect(page.locator('.ol-recording-key-guide')).toHaveCount(0); + await expect(page.getByText('保存历史', { exact: true })).toBeVisible(); +}); diff --git a/openless-all/app/e2e/capsule-reference-motion.spec.ts b/openless-all/app/e2e/capsule-reference-motion.spec.ts new file mode 100644 index 000000000..52c0b237e --- /dev/null +++ b/openless-all/app/e2e/capsule-reference-motion.spec.ts @@ -0,0 +1,158 @@ +import { expect, test, type Page } from '@playwright/test'; +const fixture = 'http://127.0.0.1:5326/e2e/fixtures/capsule-motion.html'; +async function patch(page: Page, value: Record) { + await page.locator('.ol-default-capsule').waitFor(); + await page.evaluate((value) => (window as any).fixture.setCapsule(value), value); +} +async function emit(page: Page, event: string, payload: unknown) { + await page.evaluate(({ event, payload }) => (window as any).fixture.emit(event, payload), { + event, + payload, + }); +} +const messages = [ + { role: 'user', content: '这是他询问任何问题的内容' }, + { + role: 'assistant', + content: + '这句话的意思是:这是他用来提问任何问题的具体内容。\n\n如果你有任何问题或需要帮助,请随时告诉我,我会尽力为你解答。', + }, +]; + +test('capsule contracts continuously, changes content in sequence, and can reverse mid-flight', async ({ + page, +}) => { + await page.goto(fixture); + const pill = page.locator('.ol-default-capsule'); + await expect(pill).toHaveCSS('width', '116px'); + await expect(pill).toHaveCSS('height', '32px'); + await expect(pill.locator('.ol-default-waveform > span')).toHaveCount(11); + const samples = await page.evaluate(async () => { + const pill = document.querySelector('.ol-default-capsule')!; + (window as any).fixture.setCapsule({ state: 'polishing' }); + const frames: number[] = []; + const start = performance.now(); + while (performance.now() - start < 300) { + await new Promise(requestAnimationFrame); + frames.push(pill.getBoundingClientRect().width); + } + return frames; + }); + expect(samples.some((width) => width > 81 && width < 114)).toBeTruthy(); + await expect(pill).toHaveCSS('width', '80px'); + await expect(pill.locator('.ol-default-status')).toHaveCSS('opacity', '1'); + expect(await pill.evaluate((el) => getComputedStyle(el, '::after').animationName)).toBe( + 'ol-capsule-sweep', + ); + await patch(page, { state: 'recording' }); + await page.waitForTimeout(60); + await patch(page, { state: 'polishing' }); + await page.waitForTimeout(50); + await patch(page, { state: 'recording' }); + await expect(pill).toHaveCSS('width', '116px'); + await patch(page, { leaving: true }); + await page.waitForTimeout(60); + await patch(page, { leaving: false }); + await expect(pill).toHaveCSS('opacity', '1'); + await expect(pill).toHaveCSS('width', '116px'); +}); + +test('translation strip settles above the pill and exits when processing starts', async ({ + page, +}) => { + await page.goto(fixture); + await patch(page, { translation: true }); + const badge = page.locator('.ol-classic-translation'); + await expect(badge).toHaveCSS('opacity', '1'); + const body = await page.locator('.ol-default-capsule').boundingBox(); + const strip = await badge.boundingBox(); + expect(body!.y - strip!.y - strip!.height).toBeCloseTo(8, 0); + await patch(page, { state: 'polishing' }); + await expect(badge).toHaveCSS('opacity', '0'); + await expect(page.locator('.ol-default-capsule')).toHaveCSS('width', '80px'); +}); + +test('QA records in a pill, reveals a wide result, retains content on exit and reopens', async ({ + page, +}, info) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(fixture + '?qa'); + await expect(page.locator('.ol-default-capsule')).toBeVisible(); + await expect(page.locator('.qa-prompt')).toHaveCount(0); + await emit(page, 'qa:state', { kind: 'recording', sessionId: 'a', messages: [] }); + await expect(page.locator('.ol-default-capsule')).toBeVisible(); + await expect(page.locator('.qa-prompt')).toBeHidden(); + await emit(page, 'qa:state', { kind: 'thinking', sessionId: 'a', messages: [messages[0]] }); + await expect(page.locator('.qa-answer-panel')).toHaveCount(0); + await expect(page.locator('.ol-default-capsule')).toHaveAttribute('data-processing', 'true'); + await emit(page, 'qa:state', { kind: 'answer', sessionId: 'a', messages }); + await expect(page.locator('#native-host')).toHaveCSS('width', '760px'); + await expect(page.locator('.qa-answer-panel')).toBeVisible(); + await expect(page.locator('.qa-capsule-shell')).toHaveCSS('opacity', '1'); + await page.screenshot({ path: info.outputPath('qa-result.png') }); + await emit(page, 'chat-panel:closing', {}); + await expect(page.locator('.qa-assistant-message')).toContainText('我会尽力为你解答'); + await page.waitForTimeout(60); + expect( + await page.locator('.qa-capsule-shell').evaluate((el) => Number(getComputedStyle(el).opacity)), + ).toBeLessThan(1); + await emit(page, 'chat-panel:shown', {}); + await emit(page, 'qa:state', { kind: 'recording', sessionId: 'b', messages: [] }); + await expect(page.locator('.qa-capsule-shell')).not.toHaveClass(/is-closing/); + await expect(page.locator('.ol-default-capsule')).toBeVisible(); + await expect(page.locator('#native-host')).toHaveCSS('height', '80px'); + expect(errors).toEqual([]); +}); + +test('immediate geometry acknowledgements keep each QA result visible', async ({ page }) => { + await page.goto(fixture + '?qa'); + await expect(page.locator('.ol-default-capsule')).toBeVisible(); + for (let index = 0; index < 12; index++) { + const sessionId = `instant-layout-${index}`; + await emit(page, 'qa:state', { kind: 'recording', sessionId, messages: [] }); + await expect(page.locator('.ol-default-capsule')).toBeVisible(); + await expect(page.locator('#native-host')).toHaveCSS('width', '480px'); + await emit(page, 'qa:state', { kind: 'answer', sessionId, messages }); + await expect(page.locator('.qa-answer-panel')).toBeVisible(); + await expect(page.locator('#native-host')).toHaveCSS('width', '760px'); + } +}); + +test('reduced motion disables sweeps and geometry interpolation', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(fixture); + await patch(page, { state: 'polishing', translation: true }); + await expect(page.locator('.ol-default-capsule')).toHaveCSS('width', '80px'); + expect( + await page + .locator('.ol-default-capsule') + .evaluate((el) => getComputedStyle(el, '::after').animationName), + ).toBe('none'); +}); + +test('native idle keeps the same capsule node through exit; a new recording cancels removal', async ({ + page, +}) => { + await page.goto(fixture + '?full'); + await page.waitForFunction(() => (window as any).fixture.hasListener('capsule:state')); + await emit(page, 'capsule:state', { state: 'recording', level: 0.2, capsuleStyle: 'classic' }); + const pill = page.locator('.ol-default-capsule'); + await expect(pill).toHaveCSS('width', '116px'); + await pill.evaluate((el) => { + (window as any).originalPill = el; + }); + const retained = await page.evaluate(async () => { + (window as any).fixture.emit('capsule:state', { state: 'idle' }); + await new Promise((resolve) => setTimeout(resolve, 50)); + const same = document.querySelector('.ol-default-capsule') === (window as any).originalPill; + (window as any).fixture.emit('capsule:state', { state: 'recording', level: 0.1 }); + return same; + }); + expect(retained).toBe(true); + await expect(pill).toHaveCSS('opacity', '1'); + await page.waitForTimeout(250); + await expect(pill).toBeVisible(); + await emit(page, 'capsule:state', { state: 'idle' }); + await expect(pill).toHaveCount(0); +}); diff --git a/openless-all/app/e2e/fixtures/capsule-motion.html b/openless-all/app/e2e/fixtures/capsule-motion.html new file mode 100644 index 000000000..d6ed6a02f --- /dev/null +++ b/openless-all/app/e2e/fixtures/capsule-motion.html @@ -0,0 +1,11 @@ + + + + + Capsule motion review + + +
+ + + diff --git a/openless-all/app/e2e/fixtures/capsule-motion.tsx b/openless-all/app/e2e/fixtures/capsule-motion.tsx new file mode 100644 index 000000000..ee712f097 --- /dev/null +++ b/openless-all/app/e2e/fixtures/capsule-motion.tsx @@ -0,0 +1,169 @@ +// Real production components with an isolated native event/geometry adapter. +import React, { useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import type { CapsuleState } from '../../src/lib/types'; +import '../../src/styles/tokens.css'; +import '../../src/styles/global.css'; + +const callbacks = new Map void>(); +const listeners = new Map>(); +let callbackId = 0; +const qa = new URLSearchParams(location.search).has('qa'); +const bridge = { + emit(event: string, payload: unknown) { + for (const id of listeners.get(event) ?? []) callbacks.get(id)?.({ event, id, payload }); + }, + hasListener: (event: string) => Boolean(listeners.get(event)?.size), + setCapsule: (_: Record) => {}, + sizes: [] as boolean[], + target: { + sessionId: 'recording-a', + targetLanguage: 'English', + workingLanguages: ['简体中文'], + mutable: true, + }, + targetChanges: [] as any[], + menuChanges: [] as any[], + menuOwner: null as string | null, + failTarget: false, + delayTarget: 0, +}; +Object.assign(window, { fixture: bridge }); +window.__TAURI_INTERNALS__ = { + transformCallback(callback: (value: unknown) => void) { + const id = ++callbackId; + callbacks.set(id, callback); + return id; + }, + unregisterCallback(id: number) { + callbacks.delete(id); + }, + async invoke(command: string, args: any) { + if (command === 'plugin:event|listen') { + const ids = listeners.get(args.event) ?? new Set(); + ids.add(args.handler); + listeners.set(args.event, ids); + return args.handler; + } + if (command === 'plugin:event|unlisten') { + listeners.get(args.event)?.delete(args.eventId); + return; + } + if (command === 'get_startup_snapshot') + return { contractVersion: '2.0.0', backend: { running: true } }; + if (command === 'get_settings') + return { + translationTargetLanguage: bridge.target.targetLanguage, + capsuleStyle: 'classic', + capsuleTranscriptEnabled: false, + }; + if (command === 'get_dictation_translation_target') return { ...bridge.target }; + if (command === 'set_capsule_language_menu') { + if (args.open && (!bridge.target.mutable || args.sessionId !== bridge.target.sessionId)) + throw new Error('stale menu'); + if (!args.open && bridge.menuOwner !== args.sessionId) return; + bridge.menuOwner = args.open ? args.sessionId : null; + bridge.menuChanges.push(args); + const resize = (window as any).resizeMenuForTest; + if (resize) await resize(args.open); + return; + } + if (command === 'select_dictation_translation_target') { + bridge.targetChanges.push(args); + if (bridge.delayTarget) + await new Promise((resolve) => setTimeout(resolve, bridge.delayTarget)); + if (bridge.failTarget || args.sessionId !== bridge.target.sessionId || !bridge.target.mutable) + throw new Error('stale or failed'); + bridge.target.targetLanguage = args.targetLanguage; + bridge.emit('prefs:changed', {}); + return { ...bridge.target }; + } + if (command === 'qa_get_snapshot') return { kind: 'idle', messages: [] }; + if (command === 'qa_window_set_expanded') { + bridge.sizes.push(args.expanded); + const host = document.querySelector('#native-host'); + if (host) { + host.style.width = args.expanded ? '760px' : '480px'; + host.style.height = args.expanded ? '416px' : '80px'; + host.style.bottom = args.expanded ? '48px' : '8px'; + } + return; + } + if (command === 'qa_window_dismiss') bridge.emit('chat-panel:closing', {}); + return null; + }, +}; +Object.assign(window, { __TAURI_EVENT_PLUGIN_INTERNALS__: { unregisterListener() {} } }); +const { i18nReady } = await import('../../src/i18n'); +await i18nReady; +const { OpenLessPill } = await import('../../src/components/OpenLessCapsule'); +const { CapsuleTranslationBadge } = await import('../../src/components/LiveTranscriptPill'); +const { QaPanel } = await import('../../src/pages/QaPanel'); +const { Capsule } = await import('../../src/components/Capsule'); +function CapsuleFixture() { + const [state, set] = useState({ + state: 'recording' as CapsuleState, + level: 0.2, + warming: false, + translation: false, + leaving: false, + operating: false, + }); + bridge.setCapsule = (patch) => set((previous) => ({ ...previous, ...patch })); + return ( +
+ + bridge.setCapsule({ leaving: true })} + onConfirm={() => bridge.setCapsule({ state: 'polishing' })} + /> +
+ ); +} +document.body.style.background = '#0b1215'; +createRoot(document.getElementById('root')!).render( + new URLSearchParams(location.search).has('full') ? ( +
+ +
+ ) : qa ? ( +
+ +
+ ) : ( + + ), +); diff --git a/openless-all/app/e2e/motion.spec.ts b/openless-all/app/e2e/motion.spec.ts index 26aca0ef2..a59cfbe1f 100644 --- a/openless-all/app/e2e/motion.spec.ts +++ b/openless-all/app/e2e/motion.spec.ts @@ -266,6 +266,9 @@ test('changing reduced motion stops the decorative WebGL loop', async ({ page }) }); await page.emulateMedia({ reducedMotion: 'no-preference' }); await page.getByRole('button', { name: '设置', exact: true }).click(); + // OpenLess is now the default; opt into the optional WebGL style for this probe. + await page.getByRole('combobox', { name: '胶囊样式' }).click(); + await page.getByRole('option', { name: /Siri/ }).click(); await expect .poll(() => page.evaluate(() => (window as Window & { motionDraws: number }).motionDraws)) .toBeGreaterThan(5); diff --git a/openless-all/app/e2e/openless-default.spec.ts b/openless-all/app/e2e/openless-default.spec.ts new file mode 100644 index 000000000..a099f89e4 --- /dev/null +++ b/openless-all/app/e2e/openless-default.spec.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; + +test('default capsule is white and blue with eleven bars, including legacy style links', async ({ + page, +}, info) => { + for (const style of ['classic', 'typeless']) { + await page.goto(`/?window=capsule&style=${style}&state=recording&level=0.2`); + const pill = page.locator('.ol-default-capsule'); + await expect(pill).toBeVisible(); + await expect(pill).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + await expect(pill.locator('.ol-default-waveform > span')).toHaveCount(11); + expect(await pill.evaluate((el) => getComputedStyle(el, '::before').backgroundColor)).toBe( + 'rgb(255, 255, 255)', + ); + await expect(pill.locator('.ol-default-waveform > span').first()).toHaveCSS( + 'background-color', + 'rgb(37, 99, 235)', + ); + await expect(page.locator('.ol-typeless-capsule')).toHaveCount(0); + } + await page.screenshot({ path: info.outputPath('capsule-recording.png'), animations: 'disabled' }); +}); + +test('processing stays cancellable, hides confirm and shows a compact status', async ({ + page, +}, info) => { + await page.goto('/?window=capsule&style=classic&state=polishing&translation=1'); + const pill = page.locator('.ol-default-capsule'); + await expect(pill).toHaveAttribute('data-processing', 'true'); + await expect(pill.locator('.ol-default-status')).toHaveCSS('opacity', '1'); + await expect(pill.locator('.ol-default-processing-cancel')).toBeEnabled(); + await expect(pill.locator('.ol-default-confirm')).toBeDisabled(); + await expect(page.locator('.ol-classic-translation')).toHaveCSS( + 'background-color', + 'rgb(255, 255, 255)', + ); + await expect(pill).toHaveCSS('width', '80px'); + await expect(page.locator('.ol-classic-translation')).toHaveCSS('opacity', '0'); + await page.screenshot({ + path: info.outputPath('capsule-processing.png'), + animations: 'disabled', + }); +}); + +test('sidebar distinguishes hover from selection without navigating on hover', async ({ + page, +}, info) => { + await page.emulateMedia({ colorScheme: 'light' }); + await page.goto('/'); + await expect(page.locator('html')).not.toHaveAttribute('data-ol-theme', 'dark'); + const nav = page.locator('.ol-sidebar-surface nav'); + const history = nav.getByRole('button', { name: '历史', exact: true }); + const overview = nav.getByRole('button', { name: '概览', exact: true }); + // Selected: black on the darker wash. Idle: light gray, no wash. Hover: near-black on a + // lighter wash than the selection, with a pointer. + await expect(overview).toHaveCSS('color', 'rgb(0, 0, 0)'); + await expect(overview).toHaveCSS('background-color', 'rgba(0, 0, 0, 0.075)'); + await expect(history).toHaveCSS('color', 'rgb(133, 133, 142)'); + await expect(history).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + await expect(history).toHaveCSS('cursor', 'pointer'); + await history.hover(); + await expect(history).toHaveCSS('color', 'rgb(24, 24, 27)'); + await expect(history).toHaveCSS('background-color', 'rgba(0, 0, 0, 0.04)'); + await expect(page.locator('[data-ol-page="overview"]')).toBeVisible(); + await history.click(); + await expect(history).toHaveAttribute('aria-current', 'page'); + await expect(history).toHaveCSS('color', 'rgb(0, 0, 0)'); + await expect(history).toHaveCSS('background-color', 'rgba(0, 0, 0, 0.075)'); + await expect(page.locator('[data-ol-page="history"]')).toHaveCSS('opacity', '1'); + await page.screenshot({ path: info.outputPath('navigation.png'), animations: 'disabled' }); +}); + +test('rapid page choices settle immediately and reduced motion leaves no animation', async ({ + page, +}) => { + await page.goto('/'); + await page.emulateMedia({ reducedMotion: 'reduce' }); + for (const name of ['历史', '词典', '概览']) { + await page + .locator('.ol-sidebar-surface nav') + .getByRole('button', { name, exact: true }) + .click(); + } + const content = page.locator('[data-ol-page="overview"]'); + await expect(content).toBeVisible(); + await expect(content).toHaveCSS('opacity', '1'); + expect( + await content.evaluate((el) => ({ + inert: (el as HTMLElement).inert, + animations: el.getAnimations().length, + })), + ).toEqual({ inert: false, animations: 0 }); +}); + +test('quick follow-up uses white surfaces and blue accents even in dark mode', async ({ + page, +}, info) => { + await page.goto('/?window=qa'); + const shell = page.locator('.qa-capsule-shell'); + await expect(shell).toBeVisible(); + expect( + await shell.evaluate((el) => ({ + paper: getComputedStyle(el).getPropertyValue('--qa-paper').trim(), + accent: getComputedStyle(el).getPropertyValue('--qa-accent').trim(), + })), + ).toEqual({ paper: '#fff', accent: '#2563eb' }); + await page.screenshot({ path: info.outputPath('quick-followup.png'), animations: 'disabled' }); +}); diff --git a/openless-all/app/e2e/refinements.spec.ts b/openless-all/app/e2e/refinements.spec.ts index ec3ebe434..ec6456e2a 100644 --- a/openless-all/app/e2e/refinements.spec.ts +++ b/openless-all/app/e2e/refinements.spec.ts @@ -38,6 +38,8 @@ test('shortcut search and categories live in the flat settings pane; keyboard cl test('native scrolling has no JS wheel interception and is stable when stopped', async ({ page, }) => { + // The wheel distance is asserted in CSS pixels, which match device pixels only at 100%. + await page.addInitScript(() => localStorage.setItem('ol-font-scale', 'medium')); await openShortcuts(page); const content = page.locator('.ol-settings-scroll'); await expect(content).toHaveCSS('scroll-behavior', 'auto'); @@ -162,7 +164,7 @@ test('every main destination and settings category stays readable on desktop and await expect(page.locator('main h1')).toBeVisible(); } await page.getByRole('button', { name: '工具', exact: true }).click(); - for (const name of ['翻译', '划词追问', '速记', '纠正规则']) { + for (const name of ['翻译', '问任何问题', '速记', '纠正规则']) { await page.getByRole('button', { name, exact: true }).click(); await expect(page.locator('main h1')).toBeVisible(); } diff --git a/openless-all/app/e2e/resource-lifecycle.spec.ts b/openless-all/app/e2e/resource-lifecycle.spec.ts index 4033fb5ab..258dd9749 100644 --- a/openless-all/app/e2e/resource-lifecycle.spec.ts +++ b/openless-all/app/e2e/resource-lifecycle.spec.ts @@ -20,6 +20,9 @@ test('offscreen WebGL preview releases its animation clock and resumes only when }; }); await page.getByRole('button', { name: '设置', exact: true }).click(); + // The default OpenLess preview is CSS-only; opt into the WebGL style under test. + await page.getByRole('combobox', { name: '胶囊样式' }).click(); + await page.getByRole('option', { name: /Siri/ }).click(); const draws = () => page.evaluate(() => (window as Window & { draws: number }).draws); await expect.poll(draws).toBeGreaterThan(5); await page.locator('.ol-settings-scroll').evaluate((element) => { diff --git a/openless-all/app/e2e/settings-regressions.spec.ts b/openless-all/app/e2e/settings-regressions.spec.ts index 1664d4229..d0bf5de97 100644 --- a/openless-all/app/e2e/settings-regressions.spec.ts +++ b/openless-all/app/e2e/settings-regressions.spec.ts @@ -8,11 +8,11 @@ async function openSettings(page: Page) { return page.getByRole('dialog', { name: '设置', exact: true }); } -test('100% is the fresh default; explicit 110% survives reload and the settings titlebar stays draggable', async ({ +test('110% is the fresh default; explicit 100% survives reload and the settings titlebar stays draggable', async ({ page, }) => { const dialog = await openSettings(page); - await expect(page.locator('html')).toHaveCSS('zoom', '1'); + await expect(page.locator('html')).toHaveCSS('zoom', '1.1'); await expect(dialog.getByRole('button', { name: '返回应用', exact: true })).toHaveCount(1); await expect(dialog.getByRole('button', { name: '关闭', exact: true })).toHaveCount(0); expect( @@ -21,10 +21,13 @@ test('100% is the fresh default; explicit 110% survives reload and the settings ), ).toBe(true); await dialog.getByRole('button', { name: '外观与语言', exact: true }).click(); - await page.getByRole('combobox', { name: '界面大小', exact: true }).click(); - await page.getByRole('option', { name: '110%', exact: true }).click(); + const interfaceSize = page.getByRole('combobox', { name: '界面大小', exact: true }); + await expect(interfaceSize).toContainText('110%'); + await interfaceSize.click(); + await page.getByRole('option', { name: '100%', exact: true }).click(); + await expect(page.locator('html')).toHaveCSS('zoom', '1'); await page.reload(); - await expect(page.locator('html')).toHaveCSS('zoom', '1.1'); + await expect(page.locator('html')).toHaveCSS('zoom', '1'); await page.getByRole('button', { name: '设置', exact: true }).click(); expect( await page.evaluate( diff --git a/openless-all/app/e2e/switch-motion.spec.ts b/openless-all/app/e2e/switch-motion.spec.ts new file mode 100644 index 000000000..035899065 --- /dev/null +++ b/openless-all/app/e2e/switch-motion.spec.ts @@ -0,0 +1,285 @@ +import { expect, test, type ElementHandle, type Locator, type Page } from '@playwright/test'; + +// Every switch the user can reach in Settings (plus the services channel lists and the +// Selection Ask page) must slide its knob between on and off, never jump. Each switch is +// flipped twice so both directions are sampled and the mock preferences end unchanged. + +const errors = new WeakMap(); +test.beforeEach(async ({ page }) => { + const pageErrors: string[] = []; + errors.set(page, pageErrors); + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await page.goto('/?os=mac'); + await expect(page.getByRole('button', { name: '设置', exact: true })).toBeVisible(); +}); +test.afterEach(async ({ page }) => { + expect(errors.get(page)).toEqual([]); +}); + +type Sample = { + name: string; + checked: [string, string]; + start: number; + end: number; + xs: number[]; + /** Knob positions at controlled points of the real CSS transition. */ + controlledXs: number[]; + /** elapsedTime (s) of every completed knob transform transition. */ + finished: number[]; +}; + +/** Clicks a switch and records its knob's left edge on every animation frame. Works on the + * element itself: a channel row may move within its list once its switch flips. */ +async function flip(page: Page, control: ElementHandle): Promise { + await control.scrollIntoViewIfNeeded(); + // Pointer input: keyboard mode deliberately disables the knob transition. + const box = (await control.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + const before = (await control.getAttribute('aria-checked')) ?? ''; + const name = await control.evaluate((element) => { + const labelledBy = element.getAttribute('aria-labelledby'); + return ( + element.getAttribute('aria-label') ?? + (labelledBy ? document.getElementById(labelledBy)?.textContent : null) ?? + '' + ).trim(); + }); + const start = await control.evaluate((element) => { + const knob = element.querySelector('.ol-switch-thumb')!; + return knob.getBoundingClientRect().left; + }); + const sampling = control.evaluate(async (element) => { + const knob = element.querySelector('.ol-switch-thumb')!; + const finished: number[] = []; + const controlledXs: number[] = []; + const onRun = (event: TransitionEvent) => { + if (event.target !== knob || event.propertyName !== 'transform') return; + const transition = knob + .getAnimations() + .find( + (animation) => + animation instanceof CSSTransition && animation.transitionProperty === 'transform', + ); + if (!transition?.effect) return; + const duration = transition.effect.getComputedTiming().duration; + if (typeof duration !== 'number' || duration <= 0) return; + // CI can paint only every 80–100ms, missing the useful part of a 180ms ease-out. + // Seek within the real transition without pausing it. Pausing and playing can defer + // WebKit's restart until another paint, extending the slide into the row reorder. + // Restore the original timeline so sampling cannot delay normal completion. + const startTime = transition.startTime; + const currentTime = transition.currentTime; + for (const fraction of [0.25, 0.5, 0.75]) { + transition.currentTime = duration * fraction; + controlledXs.push(knob.getBoundingClientRect().left); + } + if (startTime !== null) transition.startTime = startTime; + else transition.currentTime = currentTime; + }; + const onEnd = (event: TransitionEvent) => { + if (event.target === knob && event.propertyName === 'transform') { + finished.push(event.elapsedTime); + } + }; + knob.addEventListener('transitionrun', onRun); + knob.addEventListener('transitionend', onEnd); + const xs: number[] = []; + for (let frame = 0; frame < 45; frame++) { + await new Promise(requestAnimationFrame); + // A remount would leave this node detached and frozen: count it as a jump. + xs.push(element.isConnected ? knob.getBoundingClientRect().left : Number.NaN); + } + knob.removeEventListener('transitionrun', onRun); + knob.removeEventListener('transitionend', onEnd); + return { xs, controlledXs, finished }; + }); + await page.mouse.down(); + await page.mouse.up(); + const { xs, controlledXs, finished } = await sampling; + const after = (await control.getAttribute('aria-checked')) ?? ''; + return { name, checked: [before, after], start, end: xs.at(-1)!, xs, controlledXs, finished }; +} + +function expectAnimated(sample: Sample) { + const { name, checked, start, end, controlledXs, finished } = sample; + expect(checked[1], `${name} changed state`).not.toBe(checked[0]); + expect(Math.abs(end - start), `${name} knob travelled`).toBeGreaterThan(4); + // The knob's transform transition ran to completion (a jump or remount has none). + expect( + finished.some((elapsed) => elapsed >= 0.1), + `${name} knob finished a transform transition`, + ).toBe(true); + const low = Math.min(start, end) + 0.5; + const high = Math.max(start, end) - 0.5; + expect(controlledXs, `${name} exposed its transform transition`).toHaveLength(3); + expect( + controlledXs.some((x) => x > low && x < high), + `${name} knob passed through controlled intermediate positions (${start} → ${controlledXs.join(' ')} → ${end})`, + ).toBe(true); +} + +async function exerciseVisibleSwitches(scope: Locator, label: string) { + const page = scope.page(); + // Handles, not positions: flipping a channel can move its row within the list. + const switches = await scope.locator('[role="switch"]').elementHandles(); + let exercised = 0; + for (const control of switches as ElementHandle[]) { + if (!(await control.isVisible())) continue; + const unavailable = await control.evaluate( + (element) => + element.hasAttribute('disabled') || + element.hasAttribute('data-disabled') || + element.getAttribute('aria-disabled') === 'true' || + // Collapsed groups keep their rows laid out but inert. + element.closest('[inert]') !== null, + ); + if (unavailable) continue; + expectAnimated(await flip(page, control)); + expectAnimated(await flip(page, control)); + exercised++; + } + test.info().annotations.push({ type: 'switches', description: `${label}: ${exercised}` }); + return exercised; +} + +async function openSettings(page: Page) { + await page.getByRole('button', { name: '设置', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '设置', exact: true }); + await expect(dialog).toHaveCSS('opacity', '1'); + return dialog; +} + +async function openSection(dialog: Locator, name: string) { + await dialog + .getByRole('navigation', { name: '设置分类' }) + .getByRole('button', { name, exact: true }) + .click(); +} + +test('every settings switch slides between on and off', async ({ page }) => { + test.setTimeout(180_000); + const dialog = await openSettings(page); + const sections = await dialog + .getByRole('navigation', { name: '设置分类' }) + .getByRole('button') + .allTextContents(); + expect(sections.length).toBeGreaterThan(4); + let total = 0; + for (const section of sections.map((name) => name.trim())) { + await openSection(dialog, section); + // Open every collapsible group so its switches are reachable. + const headers = dialog.locator('[data-ol-toggle-group] > button[aria-expanded]'); + for (let index = 0; index < (await headers.count()); index++) { + const header = headers.nth(index); + if ((await header.getAttribute('aria-expanded')) !== 'false') continue; + await header.click(); + await expect(header).toHaveAttribute('aria-expanded', 'true'); + } + if (section === '实验与扩展') { + const entries = dialog.locator('[data-ol-advanced-entry]'); + const visibleEntries = () => + entries.evaluateAll((items) => + items + .filter((item) => item.getClientRects().length > 0) + .map((item) => item.getAttribute('data-ol-advanced-entry')!), + ); + const visit = async (id: string, inside?: () => Promise) => { + await dialog.locator(`[data-ol-advanced-entry="${id}"]`).click(); + await expect(dialog.locator('.ol-settings-back')).toBeVisible(); + // Pages visited earlier stay mounted but hidden; only the open page is visible. + total += await exerciseVisibleSwitches(dialog, `advanced:${id}`); + await inside?.(); + await dialog.locator('.ol-settings-back').click(); + await expect(entries.first()).toBeVisible(); + }; + for (const id of await visibleEntries()) { + await visit(id, async () => { + if (id !== 'testingFeatures') return; + // Testing pages are only listed while their switch is on. + const enable = dialog.getByRole('switch', { name: '启用正在测试中的功能' }); + await enable.click(); + await expect(enable).toHaveAttribute('aria-checked', 'true'); + await expect(entries.first()).toBeVisible(); + for (const testingId of await visibleEntries()) await visit(testingId); + await enable.click(); + await expect(enable).toHaveAttribute('aria-checked', 'false'); + }); + } + continue; + } + total += await exerciseVisibleSwitches(dialog, section); + } + expect(total).toBeGreaterThan(15); +}); + +test('channel enable switches slide in every services list', async ({ page }) => { + const dialog = await openSettings(page); + await openSection(dialog, 'AI 服务与模型'); + let total = 0; + for (const view of ['语言模型', '语音识别']) { + await dialog + .locator('.ol-service-views') + .getByRole('button', { name: new RegExp(`^${view}`) }) + .click(); + const list = dialog.locator('.ol-reorder-list'); + await expect(list.locator('[role="switch"]').first()).toBeVisible(); + total += await exerciseVisibleSwitches(list, view); + } + expect(total).toBeGreaterThanOrEqual(2); +}); + +test('channel sorting waits for a slow knob transition to finish', async ({ page }) => { + const dialog = await openSettings(page); + await openSection(dialog, 'AI 服务与模型'); + await page.addStyleTag({ + content: '.ol-switch-thumb { transition-duration: 700ms !important; }', + }); + const first = dialog.locator('.ol-reorder-list [role="switch"][aria-checked="true"]').first(); + await expect(first).toBeVisible(); + const name = (await first.getAttribute('aria-label'))!; + const control = dialog.getByRole('switch', { name, exact: true }); + await control.evaluate((element) => { + const knob = element.querySelector('.ol-switch-thumb')!; + element.setAttribute('data-test-slide', 'pending'); + for (const type of ['transitionend', 'transitioncancel']) { + knob.addEventListener(type, (event) => { + if ((event as TransitionEvent).propertyName === 'transform') { + element.setAttribute('data-test-slide', type); + } + }); + } + }); + await control.click(); + await expect(control).toHaveAttribute('aria-checked', 'false'); + await expect(control).toHaveAttribute('data-test-slide', 'transitionend'); +}); + +test('the Selection Ask history switch slides', async ({ page }) => { + await page.getByRole('button', { name: '工具', exact: true }).click(); + await page.getByRole('button', { name: '问任何问题', exact: true }).click(); + const main = page.locator('main'); + await expect(main.getByRole('switch').first()).toBeVisible(); + expect(await exerciseVisibleSwitches(main, 'selectionAsk')).toBeGreaterThan(0); +}); + +test('stacked switches ripple their neighbours in collapsible groups and channel lists', async ({ + page, +}) => { + const dialog = await openSettings(page); + const neighbourMoves = async (group: Locator) => { + const switches = group.locator('[role="switch"]'); + await expect(switches.nth(1)).toBeVisible(); + await switches.nth(0).click(); + return switches.nth(1).evaluate((element) => element.getAnimations().length); + }; + const startup = dialog.getByRole('button', { name: '启动', exact: true }); + if ((await startup.getAttribute('aria-expanded')) === 'false') await startup.click(); + // The collapsible's own root is its group: switches ripple only within that list. + const startupGroup = startup.locator('xpath=..'); + await expect(startupGroup).toHaveAttribute('data-ol-toggle-group', ''); + expect(await neighbourMoves(startupGroup)).toBeGreaterThan(0); + await openSection(dialog, 'AI 服务与模型'); + const channels = dialog.locator('.ol-reorder-list[data-ol-toggle-group]'); + expect(await neighbourMoves(channels)).toBeGreaterThan(0); +}); diff --git a/openless-all/app/e2e/visual-refinements.spec.ts b/openless-all/app/e2e/visual-refinements.spec.ts index 66e7e2eec..cf3d00041 100644 --- a/openless-all/app/e2e/visual-refinements.spec.ts +++ b/openless-all/app/e2e/visual-refinements.spec.ts @@ -1,28 +1,76 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Locator } from '@playwright/test'; -// Playwright selects reviewed references per browser and runner OS: system -// font rasterization is intentionally not shared between macOS and Ubuntu. +async function expectReadableLayout(dialog: Locator) { + const scroll = dialog.locator('.ol-settings-scroll'); + expect( + await scroll.evaluate((element) => element.scrollWidth - element.clientWidth), + ).toBeLessThanOrEqual(1); + const heading = dialog.getByRole('heading', { level: 2 }).first(); + await expect(heading).toBeVisible(); + expect( + await heading.evaluate((element) => element.scrollWidth - element.clientWidth), + ).toBeLessThanOrEqual(1); +} for (const theme of ['dark', 'light'] as const) { - test(`settings visual baseline · ${theme}`, async ({ page }) => { + test(`settings color and layout contracts · ${theme}`, async ({ page }) => { await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce' }); await page.goto('/'); await page.getByRole('button', { name: '设置', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '设置', exact: true }); - await page.getByRole('button', { name: '快捷键', exact: true }).click(); - await expect(page.locator('.ol-shortcut-row').first()).toBeVisible(); - // Collect both independent screens on failure; soft assertions still fail CI. - await expect.soft(dialog).toHaveScreenshot(`shortcuts-${theme}.png`, { - animations: 'disabled', - maxDiffPixels: 100, - threshold: 0.1, - }); - await page.getByRole('button', { name: '外观与语言', exact: true }).click(); - await expect(page.locator('.ol-typography')).toBeVisible(); - await expect.soft(dialog).toHaveScreenshot(`typography-${theme}.png`, { - animations: 'disabled', - maxDiffPixels: 100, - threshold: 0.1, - }); + const navigation = dialog.getByRole('navigation', { name: '设置分类' }); + await expect(page.locator('html')).toHaveCSS('zoom', '1.1'); + await expect(page.locator('html')).toHaveCSS('color-scheme', theme); + + for (const section of ['快捷键', '外观与语言']) { + const button = navigation.getByRole('button', { name: section, exact: true }); + await button.click(); + await expect(button).toHaveAttribute('aria-current', 'page'); + await expect(button).toHaveCSS('cursor', 'pointer'); + await expect(button).toHaveCSS( + 'color', + theme === 'dark' ? 'rgb(255, 255, 255)' : 'rgb(0, 0, 0)', + ); + const heading = dialog.getByRole('heading', { name: section, exact: true }); + await expect(heading).toBeVisible(); + await expectReadableLayout(dialog); + const navigationBounds = (await navigation.boundingBox())!; + const headingBounds = (await heading.boundingBox())!; + expect(headingBounds.x).toBeGreaterThan(navigationBounds.x + navigationBounds.width); + + if (section === '快捷键') { + const rows = dialog.locator('.ol-shortcut-row'); + await expect(rows.first()).toBeVisible(); + expect(await rows.count()).toBeGreaterThanOrEqual(9); + await expect(dialog.getByText('问任何问题', { exact: true })).toBeVisible(); + const bounds = await rows.evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom, height: rect.height }; + }), + ); + for (let index = 0; index < bounds.length; index++) { + expect(bounds[index].height).toBeGreaterThanOrEqual(40); + if (index) expect(bounds[index].top).toBeGreaterThanOrEqual(bounds[index - 1].bottom - 1); + } + } else { + const controls = ['字体系列', '界面大小', '阅读宽度'].map((name) => + dialog.getByRole('combobox', { name, exact: true }), + ); + await expect(controls[1]).toContainText('110%'); + const bounds = []; + for (const control of controls) { + await expect(control).toBeVisible(); + bounds.push((await control.boundingBox())!); + } + for (let index = 0; index < bounds.length; index++) { + expect(Math.abs(bounds[index].x - bounds[0].x)).toBeLessThanOrEqual(1); + expect(bounds[index].height).toBeGreaterThanOrEqual(28); + expect(bounds[index].width).toBeGreaterThanOrEqual(200); + if (index) + expect(bounds[index].y).toBeGreaterThan(bounds[index - 1].y + bounds[index - 1].height); + } + } + } }); } diff --git a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-dark-chromium-darwin.png b/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-dark-chromium-darwin.png deleted file mode 100644 index fbba61f91..000000000 Binary files a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-dark-chromium-darwin.png and /dev/null differ diff --git a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-dark-chromium-linux.png b/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-dark-chromium-linux.png deleted file mode 100644 index 5f326a6fb..000000000 Binary files a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-dark-chromium-linux.png and /dev/null differ diff --git a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-dark-webkit-darwin.png b/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-dark-webkit-darwin.png deleted file mode 100644 index aec7b3d3f..000000000 Binary files a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-dark-webkit-darwin.png and /dev/null differ diff --git a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-dark-webkit-linux.png b/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-dark-webkit-linux.png deleted file mode 100644 index fac36a3f1..000000000 Binary files a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-dark-webkit-linux.png and /dev/null differ diff --git a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-light-chromium-darwin.png b/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-light-chromium-darwin.png deleted file mode 100644 index 82c1227f2..000000000 Binary files a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-light-chromium-darwin.png and /dev/null differ diff --git a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-light-chromium-linux.png b/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-light-chromium-linux.png deleted file mode 100644 index df0f55932..000000000 Binary files a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-light-chromium-linux.png and /dev/null differ diff --git a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-light-webkit-darwin.png b/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-light-webkit-darwin.png deleted file mode 100644 index efa97f044..000000000 Binary files a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-light-webkit-darwin.png and /dev/null differ diff --git a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-light-webkit-linux.png b/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-light-webkit-linux.png deleted file mode 100644 index 0e560328d..000000000 Binary files a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-light-webkit-linux.png and /dev/null differ diff --git a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-dark-chromium-darwin.png b/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-dark-chromium-darwin.png deleted file mode 100644 index 68ff8eea7..000000000 Binary files a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-dark-chromium-darwin.png and /dev/null differ diff --git a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-dark-chromium-linux.png b/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-dark-chromium-linux.png deleted file mode 100644 index b5b69e3e4..000000000 Binary files a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-dark-chromium-linux.png and /dev/null differ diff --git a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-dark-webkit-darwin.png b/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-dark-webkit-darwin.png deleted file mode 100644 index 8254548d2..000000000 Binary files a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-dark-webkit-darwin.png and /dev/null differ diff --git a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-dark-webkit-linux.png b/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-dark-webkit-linux.png deleted file mode 100644 index cd30d1c72..000000000 Binary files a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-dark-webkit-linux.png and /dev/null differ diff --git a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-light-chromium-darwin.png b/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-light-chromium-darwin.png deleted file mode 100644 index 9351c6ea4..000000000 Binary files a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-light-chromium-darwin.png and /dev/null differ diff --git a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-light-chromium-linux.png b/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-light-chromium-linux.png deleted file mode 100644 index 77ecf0074..000000000 Binary files a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-light-chromium-linux.png and /dev/null differ diff --git a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-light-webkit-darwin.png b/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-light-webkit-darwin.png deleted file mode 100644 index d5ed497e4..000000000 Binary files a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-light-webkit-darwin.png and /dev/null differ diff --git a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-light-webkit-linux.png b/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-light-webkit-linux.png deleted file mode 100644 index 9ef76d3f0..000000000 Binary files a/openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-light-webkit-linux.png and /dev/null differ diff --git a/openless-all/app/harmony/ipc-audit.json b/openless-all/app/harmony/ipc-audit.json index f08df1f01..d051d9e62 100644 --- a/openless-all/app/harmony/ipc-audit.json +++ b/openless-all/app/harmony/ipc-audit.json @@ -1,8 +1,8 @@ { "scope": "Static shared IPC command coverage; excludes event subscriptions, dynamic commands and desktop-only calls outside src/lib/ipc.", - "total": 195, + "total": 198, "wired": 103, - "missing": 92, + "missing": 95, "commands": [ { "name": "accept_pending_correction", @@ -473,6 +473,13 @@ ], "implementation": "rust" }, + { + "name": "get_dictation_translation_target", + "calledFrom": [ + "src/lib/ipc/dictation.ts" + ], + "implementation": "unimplemented" + }, { "name": "get_hotkey_capability", "calledFrom": [ @@ -1047,6 +1054,13 @@ ], "implementation": "rust" }, + { + "name": "select_dictation_translation_target", + "calledFrom": [ + "src/lib/ipc/dictation.ts" + ], + "implementation": "unimplemented" + }, { "name": "set_active_asr_provider", "calledFrom": [ @@ -1075,6 +1089,13 @@ ], "implementation": "rust" }, + { + "name": "set_capsule_language_menu", + "calledFrom": [ + "src/lib/ipc/dictation.ts" + ], + "implementation": "unimplemented" + }, { "name": "set_capsule_transcript_visible", "calledFrom": [ diff --git a/openless-all/app/linux-egui/Cargo.toml b/openless-all/app/linux-egui/Cargo.toml index f7b0e7dd0..d9a67780d 100644 --- a/openless-all/app/linux-egui/Cargo.toml +++ b/openless-all/app/linux-egui/Cargo.toml @@ -15,7 +15,7 @@ futures-util = "0.3" log = "0.4" simplelog = "0.12" base64 = "0.23.1" -reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream", "query", "form"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } semver = "1" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } serde = { version = "1", features = ["derive"] } diff --git a/openless-all/app/linux-egui/src/local_hotkeys.rs b/openless-all/app/linux-egui/src/local_hotkeys.rs index 47b91229b..bec59adf8 100644 --- a/openless-all/app/linux-egui/src/local_hotkeys.rs +++ b/openless-all/app/linux-egui/src/local_hotkeys.rs @@ -336,6 +336,7 @@ mod tests { dictation: binding("A", &["alt"]), dictation_mode: HotkeyMode::Toggle, qa: Some(binding(";", &["ctrl", "shift"])), + qa_companion_key: "Space".into(), quick_note: None, translation: binding("Z", &["alt"]), switch_style: Some(binding("S", &["ctrl", "shift"])), diff --git a/openless-all/app/linux-egui/src/settings.rs b/openless-all/app/linux-egui/src/settings.rs index 09b2473f6..38dfad58d 100644 --- a/openless-all/app/linux-egui/src/settings.rs +++ b/openless-all/app/linux-egui/src/settings.rs @@ -695,6 +695,7 @@ mod tests { #[test] fn registration_summary_shows_modifier_triggers() { let target = HotkeyRuntimeTarget { + qa_companion_key: "Space".into(), dictation: ShortcutBinding { primary: "A".into(), modifiers: vec!["alt".into()], diff --git a/openless-all/app/native-capsule/Cargo.toml b/openless-all/app/native-capsule/Cargo.toml index 6d54c2799..0c43b2672 100644 --- a/openless-all/app/native-capsule/Cargo.toml +++ b/openless-all/app/native-capsule/Cargo.toml @@ -9,7 +9,10 @@ publish = false [dependencies] openless-capsule = { path = "../crates/openless-capsule", features = ["ui"] } eframe = { version = "=0.37.0", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] } +memmap2 = "0.9" raw-window-handle = "0.6" +# Font metrics only; already part of egui's text stack. +skrifa = "0.44" # The macOS initializer adapter relies on WinitWindow's ivar-free layout. winit = { version = "=0.30.13", default-features = false } diff --git a/openless-all/app/native-capsule/src/main.rs b/openless-all/app/native-capsule/src/main.rs index 9cd0359de..4a030fa52 100644 --- a/openless-all/app/native-capsule/src/main.rs +++ b/openless-all/app/native-capsule/src/main.rs @@ -2,9 +2,14 @@ //! hotkey listener, or business state. Closing the parent pipe closes this UI. #![cfg_attr(windows, windows_subsystem = "windows")] use eframe::egui; +use openless_capsule::pill::{self, DefaultCapsule}; +use openless_capsule::renderer::CapsuleAction; use openless_capsule::{ - read_message, write_message, Action, Frame, HostMessage, UiMessage, PROTOCOL_VERSION, + read_message, write_message, Action, Frame, HostMessage, ThemeMode, UiMessage, + PROTOCOL_VERSION, }; +use std::collections::HashMap; +use std::path::PathBuf; use std::sync::{mpsc, Arc, Mutex}; mod platform; @@ -14,6 +19,8 @@ struct CapsuleApp { frame: Option, shown: bool, window: Arc, + capsule: DefaultCapsule, + reduce_motion: bool, } impl eframe::App for CapsuleApp { @@ -41,7 +48,10 @@ impl eframe::App for CapsuleApp { } ui.ctx() .send_viewport_cmd(egui::ViewportCommand::MousePassthrough( - !frame.interactive || frame.state.style == "siri", + !frame.interactive + || frame.leaving + || frame.state.selection_polish + || frame.state.style == "siri", )); self.frame = Some(*frame); if !self.shown { @@ -59,36 +69,21 @@ impl eframe::App for CapsuleApp { let Some(frame) = &self.frame else { return; }; - if !frame.transcript.is_empty() { - let area = egui::Rect::from_min_size(egui::pos2(12.0, 4.0), egui::vec2(436.0, 40.0)); - ui.painter() - .rect_filled(area, 12, egui::Color32::from_black_alpha(205)); - let mut job = egui::text::LayoutJob::default(); - job.wrap.max_width = 420.0; - job.wrap.max_rows = 1; - job.append( - &frame.transcript, - 0.0, - egui::text::TextFormat { - font_id: egui::FontId::proportional( - frame.transcript_font_size.clamp(11.0, 24.0), - ), - color: egui::Color32::WHITE, - ..Default::default() - }, - ); - let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); - ui.painter().galley( - area.center() - galley.size() * 0.5, - galley, - egui::Color32::WHITE, - ); - } - let action = openless_capsule::renderer::dictation_capsule(ui, &frame.state, &frame.labels); + let action = if frame.state.style == "siri" && !frame.state.selection_polish { + siri_transcript(ui, frame); + openless_capsule::renderer::dictation_capsule(ui, &frame.state, &frame.labels) + } else { + let dark = match frame.theme { + ThemeMode::Light => false, + ThemeMode::Dark => true, + ThemeMode::System => ui.ctx().system_theme() == Some(egui::Theme::Dark), + }; + self.capsule.show(ui, frame, dark, self.reduce_motion) + }; let action = match action { - openless_capsule::renderer::CapsuleAction::None => None, - openless_capsule::renderer::CapsuleAction::Cancel => Some(Action::Cancel), - openless_capsule::renderer::CapsuleAction::Confirm => Some(Action::Confirm), + CapsuleAction::None => None, + CapsuleAction::Cancel => Some(Action::Cancel), + CapsuleAction::Confirm => Some(Action::Confirm), }; if let Some(action) = action.filter(|action| frame.accepts(&frame.session_id, frame.sequence, *action)) @@ -106,41 +101,167 @@ impl eframe::App for CapsuleApp { } } +/// The spectral style keeps a compact transcript strip above the orb. +fn siri_transcript(ui: &egui::Ui, frame: &Frame) { + if !frame.show_transcript || frame.transcript.is_empty() { + return; + } + let area = egui::Rect::from_min_size(egui::pos2(12.0, 4.0), egui::vec2(436.0, 40.0)); + ui.painter() + .rect_filled(area, 12, egui::Color32::from_black_alpha(205)); + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = 420.0; + job.wrap.max_rows = 1; + job.append( + &frame.transcript, + 0.0, + egui::text::TextFormat { + font_id: egui::FontId::proportional(frame.transcript_font_size.clamp(11.0, 24.0)), + color: egui::Color32::WHITE, + ..Default::default() + }, + ); + let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); + ui.painter().galley( + area.center() - galley.size() * 0.5, + galley, + egui::Color32::WHITE, + ); +} + +/// One fallback slot: the candidate (file, face) for the regular, medium and +/// semibold families, as CSS weight matching picks static faces. +type Slot = [Vec<(PathBuf, u32)>; 3]; + +fn same(path: impl Into, faces: [u32; 3]) -> Slot { + let path = path.into(); + faces.map(|face| vec![(path.clone(), face)]) +} + +/// `--ol-font-sans` (system-ui, -apple-system, 'PingFang SC', 'Microsoft +/// YaHei') followed by the system fallbacks for Hangul and symbols. +#[cfg(target_os = "macos")] +fn font_slots() -> Vec { + // PingFang SC ships as an on-demand font asset; the UI copy + // (PingFangUI.ttc) uses an outline format egui cannot draw. + let pingfang = std::fs::read_dir("/System/Library/AssetsV2") + .into_iter() + .flatten() + .flatten() + .filter(|kind| { + kind.file_name() + .to_string_lossy() + .starts_with("com_apple_MobileAsset_Font") + }) + .flat_map(|kind| std::fs::read_dir(kind.path()).into_iter().flatten().flatten()) + .map(|asset| asset.path().join("AssetData/PingFang.ttc")) + .find(|path| path.is_file()); + let mut slots = vec![same("/System/Library/Fonts/SFNS.ttf", [0; 3])]; + slots.extend(pingfang.map(|path| same(path, [3, 7, 11]))); + slots.push(same("/System/Library/Fonts/Hiragino Sans GB.ttc", [0, 0, 2])); + slots.push(same("/System/Library/Fonts/AppleSDGothicNeo.ttc", [0, 2, 4])); + slots.push(same("/System/Library/Fonts/Apple Symbols.ttf", [0; 3])); + slots +} + +#[cfg(windows)] +fn font_slots() -> Vec { + let fonts = PathBuf::from(std::env::var_os("WINDIR").unwrap_or_else(|| "C:\\Windows".into())) + .join("Fonts"); + let face = |file: &str| (fonts.join(file), 0); + vec![ + [ + vec![face("SegUIVar.ttf"), face("segoeui.ttf")], + vec![face("SegUIVar.ttf"), face("segoeui.ttf")], + vec![face("SegUIVar.ttf"), face("seguisb.ttf")], + ], + [vec![face("msyh.ttc")], vec![face("msyh.ttc")], vec![face("msyhbd.ttc")]], + [vec![face("malgun.ttf")], vec![face("malgun.ttf")], vec![face("malgunbd.ttf")]], + same(fonts.join("seguisym.ttf"), [0; 3]), + ] +} + +#[cfg(not(any(target_os = "macos", windows)))] +fn font_slots() -> Vec { + vec![ + same("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", [0; 3]), + same("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", [0; 3]), + ] +} + +/// Installs the regular family plus the medium and semibold families the +/// pill asks for. Files are memory-mapped, so only pages holding drawn glyphs +/// become resident instead of reading ~60 MB of collections per helper. fn install_fonts(ctx: &egui::Context) { let mut fonts = egui::FontDefinitions::default(); - // System CJK fonts stay inside the disposable UI process. - #[cfg(target_os = "macos")] - let candidates = [ - "/System/Library/Fonts/PingFang.ttc", - "/System/Library/Fonts/STHeiti Medium.ttc", - ]; - #[cfg(windows)] - let candidates = [ - "C:\\Windows\\Fonts\\msyh.ttc", - "C:\\Windows\\Fonts\\simhei.ttf", - ]; - #[cfg(not(any(target_os = "macos", windows)))] - let candidates = [ - "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - ]; - for path in candidates { - if let Ok(bytes) = std::fs::read(path) { - fonts.font_data.insert( - "openless-cjk".into(), - egui::FontData::from_owned(bytes).into(), - ); - fonts - .families - .entry(egui::FontFamily::Proportional) - .or_default() - .push("openless-cjk".into()); - break; + let bundled = fonts + .families + .get(&egui::FontFamily::Proportional) + .cloned() + .unwrap_or_default(); + let mut maps: HashMap>> = HashMap::new(); + let mut primary_baseline = None; + let mut chains: [Vec; 3] = Default::default(); + for slot in font_slots() { + for (chain, candidates) in chains.iter_mut().zip(slot) { + let found = candidates.into_iter().find_map(|(path, face)| { + let map = maps + .entry(path.clone()) + .or_insert_with(|| { + let file = std::fs::File::open(&path).ok()?; + // SAFETY: system font files are not truncated while the helper runs. + unsafe { memmap2::Mmap::map(&file) }.ok().map(Arc::new) + }) + .clone()?; + let baseline = baseline(&map, face)?; + Some((format!("{}#{face}", path.display()), map, face, baseline)) + }); + let Some((name, map, face, baseline)) = found else { + continue; + }; + if !fonts.font_data.contains_key(&name) { + // egui centres a fallback face's own line box in the row; + // CSS keeps every glyph on the primary face's baseline. + let primary = *primary_baseline.get_or_insert(baseline); + let tweak = egui::FontTweak { + y_offset_factor: primary - baseline, + ..Default::default() + }; + let data = egui::FontData::from_blob(map, face).tweak(tweak); + fonts.font_data.insert(name.clone(), Arc::new(data)); + } + chain.push(name); } } + let [regular, medium, semibold] = chains; + for (family, chain) in [ + (egui::FontFamily::Proportional, regular), + (egui::FontFamily::Name(pill::MEDIUM_FAMILY.into()), medium), + (egui::FontFamily::Name(pill::SEMIBOLD_FAMILY.into()), semibold), + ] { + // egui's bundled faces stay behind the system ones as a last resort. + let names = chain.into_iter().chain(bundled.iter().cloned()).collect(); + fonts.families.insert(family, names); + } ctx.set_fonts(fonts); } +/// Baseline below the centre of egui's line box for this face, in em: half +/// of ascent + descent − line gap (descent is negative). Faces without +/// supported outlines are rejected before entering the fallback chain. +fn baseline(bytes: &[u8], face: u32) -> Option { + use skrifa::{instance::LocationRef, instance::Size, raw::TableProvider, MetadataProvider, Tag}; + let font = skrifa::FontRef::from_index(bytes, face).ok()?; + [*b"glyf", *b"CFF ", *b"CFF2"] + .into_iter() + .any(|tag| font.table_data(Tag::new(&tag)).is_some()) + .then_some(())?; + let _ = font.head().ok()?; + let metrics = font.metrics(Size::unscaled(), LocationRef::default()); + let em = f32::from(metrics.units_per_em.max(1)); + Some(0.5 * (metrics.ascent + metrics.descent - metrics.leading) / em) +} + fn main() -> eframe::Result<()> { platform::configure_early().map_err(eframe::Error::AppCreation)?; let (sender, incoming) = mpsc::sync_channel(2); @@ -166,7 +287,7 @@ fn main() -> eframe::Result<()> { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("OpenLess Capsule") - .with_inner_size([460.0, 110.0]) + .with_inner_size([460.0, 158.0]) .with_decorations(false) .with_transparent(true) .with_has_shadow(false) @@ -204,6 +325,8 @@ fn main() -> eframe::Result<()> { frame: None, shown: false, window, + capsule: DefaultCapsule::default(), + reduce_motion: platform::reduce_motion(), })) }), ) diff --git a/openless-all/app/native-capsule/src/platform.rs b/openless-all/app/native-capsule/src/platform.rs index b88c81380..84ffe0102 100644 --- a/openless-all/app/native-capsule/src/platform.rs +++ b/openless-all/app/native-capsule/src/platform.rs @@ -198,6 +198,20 @@ mod macos { } } + pub(super) fn reduce_motion() -> bool { + let Some(class) = AnyClass::get("NSWorkspace") else { + return false; + }; + unsafe { + let workspace: *mut AnyObject = msg_send![class, sharedWorkspace]; + if workspace.is_null() { + return false; + } + let reduce: Bool = msg_send![workspace, accessibilityDisplayShouldReduceMotion]; + reduce.as_bool() + } + } + pub(super) unsafe fn configure( view: *mut AnyObject, ) -> Result<(), Box> { @@ -213,6 +227,38 @@ mod macos { } } +/// The system "reduce motion" setting, as WebView's `prefers-reduced-motion`. +pub fn reduce_motion() -> bool { + #[cfg(target_os = "macos")] + return macos::reduce_motion(); + #[cfg(windows)] + return windows_reduce_motion(); + #[cfg(not(any(target_os = "macos", windows)))] + false +} + +/// Chromium maps `prefers-reduced-motion` to "Show animations in Windows". +#[cfg(windows)] +fn windows_reduce_motion() -> bool { + use windows::Win32::{ + Foundation::BOOL, + UI::WindowsAndMessaging::{ + SystemParametersInfoW, SPI_GETCLIENTAREAANIMATION, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, + }, + }; + let mut animate = BOOL(1); + unsafe { + SystemParametersInfoW( + SPI_GETCLIENTAREAANIMATION, + 0, + Some(&mut animate as *mut BOOL as *mut _), + SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0), + ) + } + .is_ok() + && !animate.as_bool() +} + pub fn place(window: &winit::window::Window, bounds: [i32; 4]) { window.set_outer_position(winit::dpi::PhysicalPosition::new(bounds[0], bounds[1])); let _ = window.request_inner_size(winit::dpi::PhysicalSize::new( diff --git a/openless-all/app/public/startup.js b/openless-all/app/public/startup.js index b0d669d35..e4634cedf 100644 --- a/openless-all/app/public/startup.js +++ b/openless-all/app/public/startup.js @@ -11,14 +11,14 @@ if (new URLSearchParams(location.search).has('window')) { document.documentElement.dataset.olAux = ''; } else { // Match fontScale.ts before the first HTML frame, not only after React loads. - let scale = 1; + let scale = 1.1; try { const saved = localStorage.getItem('ol-font-scale'); if (saved === 'small') scale = 0.9; if (saved === 'medium') scale = 1; if (saved === 'large') scale = 1.1; } catch { - /* Keep the shared 100% default when storage is unavailable. */ + /* Keep the shared 110% default when storage is unavailable. */ } document.documentElement.style.zoom = String(scale); document.documentElement.style.setProperty('--ol-interface-scale', String(scale)); diff --git a/openless-all/app/scripts/ci-changed-areas.test.mjs b/openless-all/app/scripts/ci-changed-areas.test.mjs index 23619fb16..8957e75a8 100644 --- a/openless-all/app/scripts/ci-changed-areas.test.mjs +++ b/openless-all/app/scripts/ci-changed-areas.test.mjs @@ -173,22 +173,20 @@ check( workflow.includes("if: needs.changes.outputs.msrv != 'false'"), true, ); -// A macOS runner is only spent on MSRV while the declared MSRV is behind the -// stable toolchain; otherwise `cross-platform` already compiles that code. +// Native changes must exercise the pinned minimum compiler on every platform. check( - 'msrv job is skipped when MSRV equals stable', - workflow.includes("needs.changes.outputs.msrv_needed == 'true'"), - true, + 'macos msrv is not skipped when stable has the same version', + workflow.includes("if: needs.changes.outputs.msrv != 'false' &&"), + false, ); check( - 'changes job publishes the msrv comparison', - workflow.includes('msrv_needed: ${{ steps.msrv.outputs.needed }}'), + 'changes job publishes the native-source gate for pinned toolchains', + workflow.includes('msrv_needed: ${{ steps.detect.outputs.msrv }}'), true, ); -// Windows spends ~10 minutes on its own MSRV pass; it must follow the same rule -// as the macOS job instead of running unconditionally. +// The secondary platform uses the same native-source gate. check( - 'windows msrv steps follow the comparison', + 'windows msrv steps follow the source gate', workflow.includes("if: runner.os == 'Windows' && needs.changes.outputs.msrv_needed == 'true'"), true, ); diff --git a/openless-all/app/scripts/layout-mode-contract.test.mjs b/openless-all/app/scripts/layout-mode-contract.test.mjs index 1ebe34db7..24592c8cc 100644 --- a/openless-all/app/scripts/layout-mode-contract.test.mjs +++ b/openless-all/app/scripts/layout-mode-contract.test.mjs @@ -9,6 +9,7 @@ const read = (relativePath) => readFileSync(join(appRoot, relativePath), 'utf8') const css = read('src/styles/global.css'); const stackedLayout = read('src/lib/stackedLayout.ts'); const shared = read('src/pages/settings/shared.tsx'); +const refinements = read('src/styles/refinements.css'); const pageAtoms = read('src/pages/_atoms.tsx'); const row = read('src/components/ui/Row.tsx'); const channelList = read('src/pages/settings/ChannelList.tsx'); @@ -26,12 +27,16 @@ assert.match(css, /\.ol-conservative-stack/); assert.match(stackedLayout, /stackedRowLayout\?: boolean/); assert.doesNotMatch(stackedLayout, /mobile\s*\|\|/); -// Toggle size contract: width 36 is locked by width/minWidth/maxWidth; flex only declares no grow/shrink. -// flex-basis must be auto to avoid stretching the toggle's height to 36px in a column-direction container. -assert.match(shared, /flex:\s*['"]0 0 auto['"]/); -assert.match(shared, /width:\s*36/); -assert.match(shared, /minWidth:\s*36/); -assert.match(shared, /maxWidth:\s*36/); +// Toggle size contract: the shared .ol-switch locks width 36 with width/min-width/max-width; flex only +// declares no grow/shrink. flex-basis must be auto to avoid stretching the toggle's height to 36px in a +// column-direction container. +const switchRule = refinements.match(/\.ol-switch\s*\{[^}]*\}/)?.[0] ?? ''; +assert.match(shared, /className="ol-switch"/); +assert.match(switchRule, /--ol-switch-width:\s*36px/); +assert.match(switchRule, /flex:\s*0 0 auto/); +assert.match(switchRule, /\swidth:\s*var\(--ol-switch-width\)/); +assert.match(switchRule, /min-width:\s*var\(--ol-switch-width\)/); +assert.match(switchRule, /max-width:\s*var\(--ol-switch-width\)/); assert.match(pageAtoms, /preferenceStack\s*\?/); assert.match(row, /stackLayout\s*\?\s*'minmax\(0, 1fr\)'/); diff --git a/openless-all/app/scripts/local-asr-polling-contract.test.mjs b/openless-all/app/scripts/local-asr-polling-contract.test.mjs index 184024819..ecbe7d520 100644 --- a/openless-all/app/scripts/local-asr-polling-contract.test.mjs +++ b/openless-all/app/scripts/local-asr-polling-contract.test.mjs @@ -3,8 +3,15 @@ import ts from 'typescript'; const source = await readFile(new URL('../src/pages/LocalAsr/index.tsx', import.meta.url), 'utf-8'); +// The poller skips ticks while the window is hidden, so allow that guard before the refresh. +const HIDDEN_GUARD = String.raw`(?:if \(document\.hidden\) return;\s*)?`; const refreshPolling = - source.match(/window\.setInterval\(\(\) => \{\s*void refresh\(\);?\s*\}, 3000\)/g) ?? []; + source.match( + new RegExp( + String.raw`window\.setInterval\(\(\) => \{\s*${HIDDEN_GUARD}void refresh\(\);?\s*\}, 3000\)`, + 'g', + ), + ) ?? []; const sourceFile = ts.createSourceFile( 'LocalAsr/index.tsx', @@ -62,13 +69,23 @@ if (refreshPolling.length !== 1) { } if ( - !/if \(downloadDialogOpen\) return[\s\S]{0,200}window\.setInterval\(\(\) => \{\s*void refresh\(\)/.test( - source, - ) + !new RegExp( + String.raw`if \(downloadDialogOpen\) return[\s\S]{0,300}window\.setInterval\(\(\) => \{\s*${HIDDEN_GUARD}void refresh\(\)`, + ).test(source) ) { throw new Error('LocalAsr refresh polling must stop while the download dialog is open'); } +if ( + !/if \(document\.hidden\) return;\s*void refresh\(\);/.test(source) || + !/addEventListener\('visibilitychange', onVisible\)/.test(source) || + !/removeEventListener\('visibilitychange', onVisible\)/.test(source) +) { + throw new Error( + 'LocalAsr refresh polling must pause while the window is hidden and catch up on visibilitychange', + ); +} + for (const contract of [ 'const downloadDialogOpenRef = useRef(downloadDialogOpen)', 'const refreshGenerationRef = useRef(0)', diff --git a/openless-all/app/scripts/qa-panel-runtime.test.mjs b/openless-all/app/scripts/qa-panel-runtime.test.mjs index 90c9d60e6..47f48ff74 100644 --- a/openless-all/app/scripts/qa-panel-runtime.test.mjs +++ b/openless-all/app/scripts/qa-panel-runtime.test.mjs @@ -39,7 +39,7 @@ const factory = new Function( '__listen', 'exports', 'require', - `${compiled};return {QaPanel,Composer,MessageRow,ErrorContent};`, + `${compiled};return {QaPanel,Composer,EmbeddedComposer,MessageRow,ErrorContent};`, ); const tick = () => new Promise((resolve) => setImmediate(resolve)); const deferred = () => { @@ -211,7 +211,7 @@ function context(native = true, embedded = false) { const emit = (name, payload) => { for (const fn of events.get(name) ?? []) fn({ payload }); }; - const composer = (tree) => find(tree, (n) => n.type === api.Composer).props; + const composer = (tree) => find(tree, (n) => n.type === api.EmbeddedComposer).props; return { api, hooks, render, emit, calls, pending, lifecycle, events, keys, composer }; } let passed = 0, @@ -317,30 +317,35 @@ await test('polish confirmation submits edited text and retains it after a faile assert.equal(c.calls.dismiss, 1); c.hooks.unmount(); }); -await test('native text waits for expansion and excludes repeated clicks; keeps a newer draft', async () => { +await test('text submission stays compact until a result, excludes repeats and keeps a newer draft', async () => { const c = context(); c.render(); await tick(); - assert.deepEqual(c.calls.resize, [false]); c.composer(c.render()).onChange('question'); - let p = c.composer(c.render()); - const resize = deferred(), + const p = c.composer(c.render()), send = deferred(); - c.pending.resize = (next) => (next ? resize.promise : undefined); c.pending.submit = send; const first = p.onSubmit(); void p.onSubmit(); c.render(); await tick(); - assert.deepEqual(c.calls.resize, [false, true]); - assert.deepEqual(c.calls.submit, []); - resize.resolve(); - await tick(); + assert.deepEqual(c.calls.resize, [false]); assert.deepEqual(c.calls.submit, ['question']); c.composer(c.render()).onChange('next draft'); send.resolve(); await first; assert.equal(c.composer(c.render()).value, 'next draft'); + c.emit('qa:state', { + kind: 'answer', + sessionId: 'answer', + messages: [ + { role: 'user', content: 'question' }, + { role: 'assistant', content: 'answer' }, + ], + }); + c.render(); + await tick(); + assert.deepEqual(c.calls.resize, [false, true]); c.hooks.unmount(); }); await test('send failure preserves draft and exposes error without a fake user message', async () => { @@ -362,11 +367,11 @@ await test('send failure preserves draft and exposes error without a fake user m }); await test('cancel during resize prevents queued question and late layout failures do not overwrite reopened compact state', async () => { const c = context(); + const d = deferred(); + c.pending.resize = () => d.promise; c.render(); await tick(); c.composer(c.render()).onChange('do not send'); - const d = deferred(); - c.pending.resize = (next) => (next ? d.promise : undefined); const submission = c.composer(c.render()).onSubmit(); c.render(); await tick(); @@ -376,14 +381,15 @@ await test('cancel during resize prevents queued question and late layout failur c.lifecycle.closing = false; c.lifecycle.enterEpoch++; c.render(); - assert.deepEqual(c.calls.resize, [false, true]); + assert.deepEqual(c.calls.resize, [false]); + c.pending.resize = undefined; d.reject(new Error('old geometry')); await submission; await tick(); c.render(); await tick(); assert.deepEqual(c.calls.submit, []); - assert.deepEqual(c.calls.resize, [false, true, false]); + assert.deepEqual(c.calls.resize, [false, false]); assert.equal(c.composer(c.render()).status, 'idle'); assert(!nodes(c.render()).some((n) => n.props.className === 'qa-layout-error')); c.hooks.unmount(); @@ -430,7 +436,7 @@ await test('recording level requires active session; no fake recording before ba c.hooks.unmount(); }); await test('stale answer is ignored and final answer replaces streaming buffer exactly once', async () => { - const c = context(); + const c = context(true, true); c.render(); await tick(); c.emit('qa:state', { @@ -631,12 +637,13 @@ await test('composer IME enter, WebKit 229 and form submission guards; genuine e await test('session drift during delayed native resize cancels old draft', async () => { const c = context(); + const d = deferred(); + c.pending.resize = () => d.promise; c.render(); await tick(); c.emit('qa:state', { kind: 'idle', sessionId: 'a', messages: [] }); c.composer(c.render()).onChange('belongs to a'); - const d = deferred(); - c.pending.resize = (next) => (next ? d.promise : undefined); + const result = c.composer(c.render()).onSubmit(); await tick(); c.emit('qa:state', { kind: 'idle', sessionId: 'b', messages: [] }); diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs index 7bc6c47ac..cc7b263bb 100644 --- a/openless-all/app/scripts/windows-ui-config.test.mjs +++ b/openless-all/app/scripts/windows-ui-config.test.mjs @@ -84,8 +84,8 @@ const capsuleStylesCss = await readFile( new URL('../src/components/CapsuleStyles.css', import.meta.url), 'utf-8', ); -const typelessTsx = await readFile( - new URL('../src/components/TypelessCapsule.tsx', import.meta.url), +const defaultCapsuleTsx = await readFile( + new URL('../src/components/OpenLessCapsule.tsx', import.meta.url), 'utf-8', ); const liveTranscriptTsx = await readFile( @@ -336,14 +336,14 @@ assertMatch( 'Classic translation badge and external rail should use one explicit geometry model', ); assertMatch( - capsuleTsx, - /case 'recording':[\s\S]*?AudioBars[\s\S]*?case 'transcribing':[\s\S]*?case 'polishing':[\s\S]*?cap-state-enter[\s\S]*?AudioBars[\s\S]*?cap-shine[\s\S]*?getClassicProcessingLabel\(operating\)/, - 'Classic recording/transcribing/polishing should render waveform bars and preserve thinking/using text', + defaultCapsuleTsx, + /const processing = state === 'transcribing' \|\| state === 'polishing'[\s\S]*?capsule\.using[\s\S]*?capsule\.thinking[\s\S]*?[\s\S]*?ol-typeless-translation[\s\S]*?
/, - 'typeless translation should occupy its own layout row between the rail and capsule', + capsuleLayoutTs, + /value === 'typeless'\) return 'classic'/, + 'legacy Typeless preferences and events must resolve to the OpenLess default', ); assertMatch( capsuleStylesCss, diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index d6c9d307d..a07716e7d 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -437,29 +437,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "aws-lc-rs" -version = "1.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - [[package]] name = "axum" version = "0.7.9" @@ -988,15 +965,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "cmov" version = "0.5.4" @@ -2172,12 +2140,6 @@ dependencies = [ "zip 2.6.1", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "fst" version = "0.4.7" @@ -3893,16 +3855,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", - "serde", -] - [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -3919,6 +3871,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", + "serde", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -4531,7 +4493,7 @@ dependencies = [ "once_cell", "parking_lot", "regex", - "reqwest 0.13.5", + "reqwest 0.12.28", "serde", "serde_json", "sha1 0.11.0", @@ -5147,7 +5109,6 @@ version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ - "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", @@ -5436,16 +5397,13 @@ dependencies = [ "hyper-util", "js-sys", "log", - "mime_guess", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -5615,7 +5573,6 @@ version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ - "aws-lc-rs", "log", "once_cell", "ring", @@ -5680,7 +5637,6 @@ version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index b631ed04c..d95d78684 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -61,6 +61,7 @@ tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream", "system-proxy"] } +rustls = { version = "^0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } zip = "2" thiserror = "1" anyhow = "1" @@ -97,7 +98,6 @@ x509-parser = { version = "0.16", features = ["verify"] } time = "0.3" tempfile = "3" local-ip-address = "^0.6" -rustls = { version = "^0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } tokio-rustls = { version = "^0.26", default-features = false, features = ["ring", "tls12", "logging"] } axum = { version = "^0.7", default-features = false, features = ["ws", "http1", "tokio", "query"] } hyper = { version = "^1", features = ["server", "http1"] } diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.lock b/openless-all/app/src-tauri/backend-tests/Cargo.lock index 8b9df454f..13773b023 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.lock +++ b/openless-all/app/src-tauri/backend-tests/Cargo.lock @@ -145,29 +145,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "aws-lc-rs" -version = "1.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - [[package]] name = "base16ct" version = "0.2.0" @@ -399,15 +376,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "cmov" version = "0.5.4" @@ -455,16 +423,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -501,7 +459,7 @@ dependencies = [ "core-foundation-sys", "coreaudio-rs", "dasp_sample", - "jni 0.21.1", + "jni", "js-sys", "libc", "mach2", @@ -589,12 +547,6 @@ dependencies = [ "cmov", ] -[[package]] -name = "dasp_sample" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -622,6 +574,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "data-encoding" version = "2.11.1" @@ -701,12 +659,6 @@ dependencies = [ "syn 3.0.9", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "easyfft" version = "0.4.2" @@ -720,12 +672,6 @@ dependencies = [ "rustfft", ] -[[package]] -name = "either" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" - [[package]] name = "ecdsa" version = "0.16.9" @@ -764,6 +710,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -886,12 +838,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "fst" version = "0.4.7" @@ -1142,6 +1088,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.9", ] [[package]] @@ -1354,36 +1301,6 @@ dependencies = [ "windows-sys 0.45.0", ] -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys 0.4.1", - "log", - "simd_cesu8", - "thiserror 2.0.21", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.119", -] - [[package]] name = "jni-sys" version = "0.3.1" @@ -1698,16 +1615,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", - "serde", -] - [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1724,6 +1631,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", + "serde", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1823,7 +1740,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" dependencies = [ - "jni 0.21.1", + "jni", "ndk", "ndk-context", "num-derive", @@ -1921,12 +1838,6 @@ dependencies = [ "zip", ] -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "p256" version = "0.13.2" @@ -2148,39 +2059,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "494b4379484fc92217cc7d9e3a532375b1d01a61889ec8333025dc3b0377af56" [[package]] -name = "primal-check" -version = "0.3.4" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "num-integer", + "zerocopy", ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "primal-check" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" dependencies = [ - "toml_edit", + "num-integer", ] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "primeorder" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "zerocopy", + "elliptic-curve", ] [[package]] -name = "primeorder" -version = "0.13.6" +name = "proc-macro-crate" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "elliptic-curve", + "toml_edit", ] [[package]] @@ -2238,7 +2149,6 @@ version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ - "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", @@ -2421,11 +2331,11 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.5" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.23.1", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -2443,7 +2353,6 @@ dependencies = [ "quinn", "rustls", "rustls-pki-types", - "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", @@ -2459,6 +2368,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots 1.0.9", ] [[package]] @@ -2592,7 +2502,6 @@ version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ - "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -2601,18 +2510,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -2623,40 +2520,12 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni 0.22.4", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" - [[package]] name = "rustls-webpki" version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2683,15 +2552,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -2712,29 +2572,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.2", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -2857,16 +2694,6 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" -[[package]] -name = "simd_cesu8" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" -dependencies = [ - "rustc_version", - "simdutf8", -] - [[package]] name = "simdutf8" version = "0.1.5" @@ -2995,7 +2822,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.13.2", - "core-foundation 0.9.4", + "core-foundation", "system-configuration-sys", ] @@ -3482,9 +3309,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.5.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" dependencies = [ "futures-util", "js-sys", @@ -3513,15 +3340,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/openless-all/app/src-tauri/backend-tests/tests/mouse_dictation.rs b/openless-all/app/src-tauri/backend-tests/tests/mouse_dictation.rs index c316910c6..338143c57 100644 --- a/openless-all/app/src-tauri/backend-tests/tests/mouse_dictation.rs +++ b/openless-all/app/src-tauri/backend-tests/tests/mouse_dictation.rs @@ -12,6 +12,10 @@ mod shortcut_binding { #[path = "../../src/mobile_stubs/combo_hotkey.rs"] mod combo_hotkey; +#[allow(dead_code)] +#[path = "../../src/recording_companion.rs"] +mod recording_companion; + #[allow(dead_code)] #[path = "../../src/mouse_dictation.rs"] mod mouse_dictation; diff --git a/openless-all/app/src-tauri/backend-tests/tests/tls_crypto.rs b/openless-all/app/src-tauri/backend-tests/tests/tls_crypto.rs new file mode 100644 index 000000000..f58fa4043 --- /dev/null +++ b/openless-all/app/src-tauri/backend-tests/tests/tls_crypto.rs @@ -0,0 +1,3 @@ +//! Exercise the production TLS startup without loading Tauri's native runtimes. +#[path = "../../src/tls_crypto.rs"] +mod tls_crypto; diff --git a/openless-all/app/src-tauri/capabilities/macos-native-theme.json b/openless-all/app/src-tauri/capabilities/macos-native-theme.json new file mode 100644 index 000000000..6284026e8 --- /dev/null +++ b/openless-all/app/src-tauri/capabilities/macos-native-theme.json @@ -0,0 +1,8 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "macos-native-theme", + "description": "Let the main window synchronize native appearance with the user's theme preference", + "platforms": ["macOS"], + "windows": ["main"], + "permissions": ["core:app:allow-set-app-theme"] +} diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs index 83eb45647..268185d36 100644 --- a/openless-all/app/src-tauri/src/android/native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -22,6 +22,18 @@ static OVERLAY_VISIBLE: std::sync::atomic::AtomicBool = std::sync::atomic::Atomi #[cfg(target_os = "android")] static APP_HANDLE: OnceLock = OnceLock::new(); +// A panic from a synchronously polled future must not cross an extern JNI +// boundary. Return an ordinary error so each caller keeps its wire envelope. +fn catch_jni_panic( + operation: &str, + action: impl FnOnce() -> Result, +) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(action)).unwrap_or_else(|_| { + log::error!("[android-native] {operation} panicked"); + Err(format!("{operation} failed unexpectedly")) + }) +} + #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct AndroidBackendSnapshotResponse { @@ -89,7 +101,7 @@ fn export_provider_settings_json(include_credentials: bool) -> String { fn import_provider_settings_json(json: &str, selection: bool, credentials: bool) -> String { let mut applied = Vec::new(); let mut errors = serde_json::Map::new(); - let result = (|| -> Result<(), String> { + let result = catch_jni_panic("import provider settings", || { let root: serde_json::Value = serde_json::from_str(json).map_err(|_| "invalid settings file")?; let version = root["openlessSettingsExportVersion"] @@ -174,7 +186,7 @@ fn import_provider_settings_json(json: &str, selection: bool, credentials: bool) } } Ok(()) - })(); + }); if let Err(error) = result { for (selected, key) in [ (selection, "providerSelection"), @@ -197,7 +209,7 @@ fn export_local_migration_bundle_json( include_credentials: bool, password: &str, ) -> String { - let result = (|| -> Result { + let result = catch_jni_panic("export migration bundle", || { let backend = CORE_BACKEND.get().ok_or("backend unavailable")?; let mode = match mode { "share_redacted" => openless_core::local_migration_bundle::BundleMode::ShareRedacted, @@ -233,7 +245,7 @@ fn export_local_migration_bundle_json( "inspection": artifact.inspection, "bundleBase64": base64::engine::general_purpose::STANDARD.encode(&artifact.bytes), })) - })(); + }); match result { Ok(payload) => serde_json::json!({"ok": true, "payload": payload}).to_string(), Err(error) => local_bundle_error(error), @@ -258,7 +270,7 @@ fn inspect_local_migration_bundle_json(bundle_base64: &str) -> String { } fn preview_local_migration_bundle_json(bundle_base64: &str, password: &str, mode: &str) -> String { - let result = (|| -> Result { + let result = catch_jni_panic("preview migration bundle", || { let backend = CORE_BACKEND.get().ok_or("backend unavailable")?; let bytes = base64::engine::general_purpose::STANDARD .decode(bundle_base64) @@ -281,7 +293,7 @@ fn preview_local_migration_bundle_json(bundle_base64: &str, password: &str, mode ) .map_err(|error| error.message)?; serde_json::to_value(preview).map_err(|_| "invalid bundle preview".into()) - })(); + }); match result { Ok(payload) => serde_json::json!({"ok": true, "payload": payload}).to_string(), Err(error) => local_bundle_error(error), @@ -296,7 +308,7 @@ fn import_local_migration_bundle_json( expected_generation: &str, android_revision: &str, ) -> String { - let result = (|| -> Result { + let result = catch_jni_panic("import migration bundle", || { let backend = CORE_BACKEND.get().ok_or("backend unavailable")?; let bytes = base64::engine::general_purpose::STANDARD .decode(bundle_base64) @@ -344,7 +356,7 @@ fn import_local_migration_bundle_json( tauri::async_runtime::block_on(backend.refresh_and_publish_credentials()) .map_err(|error| error.message)?; serde_json::to_value(inspection).map_err(|_| "invalid bundle inspection".into()) - })(); + }); match result { Ok(payload) => serde_json::json!({"ok": true, "payload": payload}).to_string(), Err(error) => local_bundle_error(error), @@ -1252,7 +1264,7 @@ mod jni_exports { let Ok(mut env) = JniEnv::from_raw(env) else { return std::ptr::null_mut(); }; - let result = (|| -> Result { + let result = catch_jni_panic("public dictionary", || { let base = env .get_string(&JString::from_raw(base)) .map(String::from) @@ -1276,7 +1288,7 @@ mod jni_exports { _ => return Err("unsupported dictionary action".into()), }; serde_json::to_value(status).map_err(|_| "encode dictionary status".into()) - })(); + }); crate::android::jni::android::export_jstring( &mut env, &match result { @@ -1295,10 +1307,15 @@ mod jni_exports { let Ok(mut env) = JniEnv::from_raw(env) else { return std::ptr::null_mut(); }; - let input = env.get_string(&JString::from_raw(pinyin)).map(String::from); - let result = input - .map(|query| tauri::async_runtime::block_on(openless_core::ime_cloud::query(&query))) - .unwrap_or_default(); + let result = catch_jni_panic("cloud pinyin", || { + let input = env.get_string(&JString::from_raw(pinyin)).map(String::from); + Ok(input + .map(|query| { + tauri::async_runtime::block_on(openless_core::ime_cloud::query(&query)) + }) + .unwrap_or_default()) + }) + .unwrap_or_default(); crate::android::jni::android::export_jstring( &mut env, &serde_json::json!(result).to_string(), @@ -1502,15 +1519,18 @@ mod jni_exports { env: *mut JNIEnv, _class: JClass, ) -> jstring { - let route = match CORE_BACKEND.get() { - Some(backend) => match tauri::async_runtime::block_on(backend.asr_route()) { - openless_core::AsrRoute::Local => "local", - openless_core::AsrRoute::LocalRaw => "localRaw", - openless_core::AsrRoute::Cloud => "cloud", - openless_core::AsrRoute::Unavailable => "unavailable", - }, - None => "unavailable", - }; + let route = catch_jni_panic("ASR route", || { + Ok(match CORE_BACKEND.get() { + Some(backend) => match tauri::async_runtime::block_on(backend.asr_route()) { + openless_core::AsrRoute::Local => "local", + openless_core::AsrRoute::LocalRaw => "localRaw", + openless_core::AsrRoute::Cloud => "cloud", + openless_core::AsrRoute::Unavailable => "unavailable", + }, + None => "unavailable", + }) + }) + .unwrap_or("unavailable"); match JniEnv::from_raw(env) { Ok(mut env) => crate::android::jni::android::export_jstring(&mut env, route), Err(_) => std::ptr::null_mut(), @@ -1834,6 +1854,19 @@ mod tests { InMemoryCredentialStore, InsertOutcome, TokioTaskSpawner, }; + #[test] + fn jni_panic_guard_returns_an_error_for_a_panicking_future() { + let result: Result<(), String> = catch_jni_panic("test operation", || { + tauri::async_runtime::block_on(async { panic!("simulated TLS panic") }) + }); + assert_eq!(result.unwrap_err(), "test operation failed unexpectedly"); + assert_eq!(catch_jni_panic("test operation", || Ok(42)), Ok(42)); + assert_eq!( + catch_jni_panic::<()>("test operation", || Err("network error".into())), + Err("network error".into()) + ); + } + #[test] fn android_snapshot_envelope_always_carries_contract_version() { let value: serde_json::Value = diff --git a/openless-all/app/src-tauri/src/commands/dictation.rs b/openless-all/app/src-tauri/src/commands/dictation.rs index 5e8cc54ef..2d224ba44 100644 --- a/openless-all/app/src-tauri/src/commands/dictation.rs +++ b/openless-all/app/src-tauri/src/commands/dictation.rs @@ -119,3 +119,56 @@ pub async fn repolish( .await .map_err(|error| error.to_string()) } + +#[cfg(not(mobile))] +#[tauri::command] +pub async fn get_dictation_translation_target( + core: CoreState<'_>, +) -> Result, String> { + Ok(core.dictation_translation_target()) +} + +#[cfg(not(mobile))] +#[tauri::command] +pub async fn select_dictation_translation_target( + core: CoreState<'_>, + session_id: openless_core::SessionId, + target_language: String, +) -> Result { + core.select_dictation_translation_target(session_id, target_language) + .map_err(|error| error.to_string()) +} + +#[cfg(not(mobile))] +#[tauri::command] +pub async fn set_capsule_language_menu( + window: Window, + coord: CoordinatorState<'_>, + session_id: openless_core::SessionId, + open: bool, +) -> Result<(), String> { + let label = window.label().to_string(); + if label != "capsule" && !label.starts_with("capsule-mirror-") { + return Err("capsule_window_required".into()); + } + let core = coord.backend(); + let capsule = coord + .tauri_host() + .capsule_window() + .ok_or("capsule_window_unavailable")?; + let (send, receive) = tokio::sync::oneshot::channel(); + capsule.run_on_main_thread(move |capsule| { + if open + && !core + .dictation_translation_target() + .is_some_and(|active| active.mutable && active.session_id == session_id) + { + let _ = send.send(Err("recording_translation_required".into())); + return; + } + let _ = send.send(capsule.set_translation_menu_open(label, session_id, open)); + })?; + receive + .await + .map_err(|_| "capsule_menu_cancelled".to_string())? +} diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index b0ba7db77..2b0840ed2 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -15,7 +15,6 @@ use tauri::State; // Submodules access the shared Host state, DTOs, and dependency types via use super::*. pub(crate) use serde::Serialize; -pub(crate) use serde_json::Value; pub(crate) use tauri::{AppHandle, Emitter, Window}; #[cfg(not(mobile))] diff --git a/openless-all/app/src-tauri/src/commands/qa.rs b/openless-all/app/src-tauri/src/commands/qa.rs index a125dc35f..b4e081e8d 100644 --- a/openless-all/app/src-tauri/src/commands/qa.rs +++ b/openless-all/app/src-tauri/src/commands/qa.rs @@ -2,29 +2,33 @@ use super::*; #[tauri::command] pub fn get_qa_hotkey_label(core: CoreState<'_>) -> String { - core.get_preferences() - .qa_hotkey - .as_ref() - .map(|binding| binding.display_label()) - .unwrap_or_default() + let prefs = core.get_preferences(); + if prefs.qa_companion_key.is_empty() { + return String::new(); + } + format!( + "{} + {}", + prefs.dictation_hotkey.display_label(), + prefs.qa_companion_key + ) } -/// Sets the QA hotkey and hot-updates the monitor. -/// `None`-form fields are not supported here — when the frontend passes -/// `binding == null`, it calls the "disable" form below (writing -/// prefs.qa_hotkey = None). +/// Sets the auxiliary key for recording-key + Ask. Null disables this chord; +/// the retired independent follow-up shortcut is never registered. #[tauri::command] pub fn set_qa_hotkey( coord: CoordinatorState<'_>, binding: Option, ) -> Result<(), String> { - if let Some(binding) = binding.as_ref() { - crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?; - crate::shortcut_binding::reject_side_specific_non_dictation(binding)?; - reject_bare_shift_dictation_shortcut(binding)?; + if binding + .as_ref() + .is_some_and(|binding| !binding.modifiers.is_empty()) + { + return Err("只需设置提问附加键,录音主键会自动跟随当前选择".into()); } let mut prefs = coord.backend().get_preferences(); - prefs.qa_hotkey = binding; + prefs.qa_hotkey = None; + prefs.qa_companion_key = binding.map(|binding| binding.primary).unwrap_or_default(); reject_hotkey_collisions(&prefs)?; super::settings::persist_strict_settings(&coord, prefs) } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 40cc1ede4..9c2928a0e 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -713,6 +713,10 @@ impl Coordinator { Arc::clone(&app), Arc::clone(&qa_context), ); + let voice_surface_host = host.clone(); + qa_context.bind_voice_surface_owner(Arc::new(move |active| { + voice_surface_host.set_qa_voice_active(active) + })); let hotkey_runtime_target = (&backend.get_preferences()).into(); let inner = Arc::new(Inner { host, @@ -849,6 +853,10 @@ impl Coordinator { Arc::clone(&app), Arc::clone(&qa_context), ); + let voice_surface_host = host.clone(); + qa_context.bind_voice_surface_owner(Arc::new(move |active| { + voice_surface_host.set_qa_voice_active(active) + })); let hotkey_runtime_target = (&backend.get_preferences()).into(); let inner = Arc::new(Inner { host, @@ -1210,11 +1218,7 @@ impl Coordinator { /// `start_hotkey_listener`: a daemon thread retries registration every 3s /// on failure (the user may have changed the combo). pub fn start_qa_hotkey_listener(&self) { - let inner = Arc::clone(&self.inner); - std::thread::Builder::new() - .name("openless-qa-hotkey-supervisor".into()) - .spawn(move || qa_hotkey_supervisor_loop(inner)) - .ok(); + qa::install_recording_companion(&self.inner); } /// Starts the "quick Agent" dual-hotkey supervisor, parallel to the QA @@ -1660,6 +1664,9 @@ impl Coordinator { let previous = &change.previous; let next = &change.next; *self.inner.hotkey_runtime_target.lock() = next.clone(); + if previous.dictation != next.dictation || previous.qa_companion_key != next.qa_companion_key { + crate::recording_companion::configure(next.dictation.clone(), next.qa_companion_key.clone()); + } if previous.translation != next.translation { self.try_update_translation_hotkey_binding()?; @@ -1817,14 +1824,7 @@ impl Coordinator { .backend .services() .qa - .show() - .await - .map_err(|error| error.message)?; - self.inner - .backend - .services() - .qa - .toggle_recording() + .start_question() .await .map_err(|error| error.message) } @@ -1896,6 +1896,7 @@ impl Coordinator { } pub fn set_shortcut_recording_active(&self, active: bool) { + crate::recording_companion::suspend(active); self.inner .shortcut_recording_active .store(active, Ordering::SeqCst); diff --git a/openless-all/app/src-tauri/src/coordinator/dictation_core.rs b/openless-all/app/src-tauri/src/coordinator/dictation_core.rs index fb37d3e9e..f5b080ae3 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation_core.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation_core.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use super::{qa::handle_qa_option_edge, Inner}; +use super::Inner; #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -59,11 +59,13 @@ pub(super) async fn handle_pressed_edge( pressed_at: std::time::Instant, press_id: u64, ) { - if inner.qa_context.is_panel_visible() - && inner.backend.snapshot().dictation.phase == openless_core::DictationPhase::Idle - { - handle_qa_option_edge(inner).await; - return; + if crate::recording_companion::claimed() || inner.qa_context.is_panel_visible() { + // Result/recording windows never hijack the recording key into the old + // second-stage follow-up flow. Only the explicit companion chord asks. + if let Ok(qa) = inner.backend.services().qa.snapshot().await { + if crate::recording_companion::claimed() || matches!(qa.phase, + openless_core::QaPhase::Recording | openless_core::QaPhase::Thinking | openless_core::QaPhase::AwaitingApproval) { return; } + } } match dispatch( inner, @@ -84,10 +86,10 @@ pub(super) async fn handle_released_edge( released_at: std::time::Instant, press_id: u64, ) { - if inner.qa_context.is_panel_visible() - && inner.backend.snapshot().dictation.phase == openless_core::DictationPhase::Idle - { - return; + if inner.qa_context.is_panel_visible() { + if let Ok(qa) = inner.backend.services().qa.snapshot().await { + if matches!(qa.phase, openless_core::QaPhase::Recording | openless_core::QaPhase::Thinking | openless_core::QaPhase::AwaitingApproval) { return; } + } } match dispatch( inner, diff --git a/openless-all/app/src-tauri/src/coordinator/qa.rs b/openless-all/app/src-tauri/src/coordinator/qa.rs index 36f62c0b6..665f1f726 100644 --- a/openless-all/app/src-tauri/src/coordinator/qa.rs +++ b/openless-all/app/src-tauri/src/coordinator/qa.rs @@ -1,27 +1,50 @@ +use super::Inner; use std::sync::Arc; -use super::Inner; +pub(super) fn install_recording_companion(inner: &Arc) { + let target = inner.hotkey_runtime_target.lock().clone(); + crate::recording_companion::configure(target.dictation, target.qa_companion_key); + let weak = Arc::downgrade(inner); + crate::recording_companion::install(Arc::new(move || { + let Some(inner) = weak.upgrade() else { + return; + }; + if inner + .shortcut_recording_active + .load(std::sync::atomic::Ordering::SeqCst) + { + return; + } + let task = Arc::clone(&inner); + let _ = inner.host.spawn(async move { + handle_qa_hotkey_pressed(&task).await; + }); + })); +} +/// One chord owns the complete question: start → record → finish. Processing +/// never queues another question and a completed result is replaced by a fresh turn. pub(super) async fn handle_qa_hotkey_pressed(inner: &Arc) { - let visible = inner.qa_context.is_panel_visible(); - log::info!("[coord] QA hotkey edge (panel_visible={visible})"); - let result = if visible { - inner.backend.services().qa.dismiss().await - } else { - inner.backend.services().qa.show().await + let Ok(_handoff) = inner.overlay_qa_handoff.try_lock() else { + return; }; - if let Err(error) = result { - log::warn!("[coord] QA panel toggle failed: {error}"); - } -} - -pub(super) async fn handle_qa_option_edge(inner: &Arc) { - if !inner.qa_context.is_panel_visible() { + let qa = &inner.backend.services().qa; + let Ok(snapshot) = qa.snapshot().await else { return; - } - if let Err(error) = inner.backend.services().qa.toggle_recording().await { - if error.code != openless_core::BackendErrorCode::Busy { - log::warn!("[coord] QA recording toggle failed: {error}"); + }; + let result = match (snapshot.phase, snapshot.session_id) { + (openless_core::QaPhase::Recording, Some(session_id)) => { + qa.stop_recording(session_id).await } + (openless_core::QaPhase::Thinking | openless_core::QaPhase::AwaitingApproval, _) => return, + _ => { + inner + .backend + .start_voice_question_from_recording_key() + .await + } + }; + if let Err(error) = result { + log::warn!("[ask] question hotkey failed: {error}"); } } diff --git a/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs b/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs index 05f0c10a5..88fbeeb42 100644 --- a/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs +++ b/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs @@ -163,6 +163,7 @@ fn reconcile_hotkeys( target: openless_core::HotkeyRuntimeTarget, ) -> Result<(), BackendError> { *coord.inner.hotkey_runtime_target.lock() = target.clone(); + crate::recording_companion::configure(target.dictation.clone(), target.qa_companion_key.clone()); let trigger = crate::shortcut_binding::legacy_modifier_trigger(&target.dictation); let binding = crate::types::HotkeyBinding { trigger: trigger.unwrap_or(crate::types::HotkeyTrigger::Custom), diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 22c8f12eb..b395a9efd 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -3823,6 +3823,7 @@ impl AudioRecorder for TauriAudioRecorder { } fn map_recorder_error(error: RecorderError) -> BackendError { + log::error!("[recorder] capture failed: {error}"); let code = match error { RecorderError::PermissionDenied => BackendErrorCode::PermissionDenied, RecorderError::NoInputDevice | RecorderError::EngineFailed(_) => BackendErrorCode::Platform, @@ -4113,6 +4114,9 @@ impl CoreTextInserter for TauriTextInserter { ) .await?; previous_input_source = previous; + // Keep ABC asserted for every batch: reactivating the target + // can make macOS re-apply the document's CJK input source. + crate::unicode_keystroke::set_streaming_ascii_guard(Some(app_handle.clone())); Some(worker) } else { None @@ -4841,6 +4845,8 @@ impl TauriTextInsertionSession { async fn restore_platform_state(&self) -> Result<(), BackendError> { #[cfg(target_os = "macos")] { + // Drop the guard first or it would fight the restore below. + crate::unicode_keystroke::set_streaming_ascii_guard::(None); let previous_input_source = self.previous_input_source.lock().take(); crate::unicode_keystroke::restore_input_source(&self.app, previous_input_source) .await @@ -5369,10 +5375,12 @@ impl HostActions for TauriHostActions { HostAction::ShowSelectionPreview => crate::show_selection_polish_preview(&app), HostAction::HideSelectionPreview => crate::hide_selection_polish_preview(&app), HostAction::ShowQa => { + self.qa_context.set_voice_surface_active(true); self.qa_context.prepare_show(); crate::show_qa_window(&app, "idle"); } HostAction::HideQa => { + self.qa_context.set_voice_surface_active(false); self.qa_context.clear(); crate::hide_qa_window(&app); } diff --git a/openless-all/app/src-tauri/src/desktop_resources.rs b/openless-all/app/src-tauri/src/desktop_resources.rs index 17353e1fc..f7e04edee 100644 --- a/openless-all/app/src-tauri/src/desktop_resources.rs +++ b/openless-all/app/src-tauri/src/desktop_resources.rs @@ -43,19 +43,10 @@ pub(crate) type MainWindowLifecycle = parking_lot::Mutex; #[cfg(not(mobile))] pub(crate) fn initialize_main(window: &tauri::WebviewWindow) { - #[cfg(target_os = "macos")] - { - use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState}; - let _ = window.set_decorations(true); - if let Err(error) = apply_vibrancy( - window, - NSVisualEffectMaterial::HudWindow, - Some(NSVisualEffectState::Active), - Some(20.0), - ) { - log::warn!("[main] vibrancy failed: {error}"); - } - } + // macOS deliberately applies no NSVisualEffectView: the React shell paints an opaque + // surface over the whole content view, so a behind-window blur would be invisible yet + // keep compositing every frame. Decorations and the Overlay title bar come from + // tauri.conf.json, which also covers windows recreated by `ensure_main`. #[cfg(target_os = "windows")] { if let Err(error) = window_vibrancy::apply_mica(window, None) { @@ -63,7 +54,7 @@ pub(crate) fn initialize_main(window: &tauri::WebviewWindow bool { + CAPSULE_MENU_OPEN.load(std::sync::atomic::Ordering::SeqCst) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum HotkeyEvent { Pressed { @@ -756,6 +766,7 @@ mod platform { match event_type { TAP_DISABLED_BY_TIMEOUT | TAP_DISABLED_BY_USER_INPUT => { + crate::recording_companion::reset_held(); if let Some(tap) = *ctx.handles.tap.lock().unwrap() { unsafe { CGEventTapEnable(tap, true) }; } @@ -763,9 +774,15 @@ mod platform { } FLAGS_CHANGED => handle_flags_changed(ctx, event), KEY_DOWN => { - handle_key_down(ctx, event); let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; - crate::side_aware_combo::platform::dispatch_keycode(keycode, false, 0, true); + if keycode == ESC_KEYCODE && super::capsule_menu_open() { return event; } + if !ctx.shared.recording_active.load(Ordering::SeqCst) + && crate::side_aware_combo::platform::dispatch_keycode(keycode, false, 0, true) { + crate::side_aware_combo::handle_companion_key_down(); + note_companion_key_down(ctx); + return std::ptr::null_mut(); + } + handle_key_down(ctx, event); // While a session is active, consume Esc exclusively: returning null deletes the // event (active tap) so the host app never sees it, avoiding double effect between // "cancel session" and the host's Esc semantics. See esc_exclusive docs. @@ -775,7 +792,9 @@ mod platform { } KEY_UP => { let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; - crate::side_aware_combo::platform::dispatch_keycode(keycode, false, 0, false); + if crate::side_aware_combo::platform::dispatch_keycode(keycode, false, 0, false) { + return std::ptr::null_mut(); + } } // Fn+brightness / volume / keyboard backlight and similar do not produce KEY_DOWN but // systemDefined. While the Fn trigger is held, treat these as function-layer combos — @@ -1681,6 +1700,7 @@ mod platform { return false; } let pressed = matches!(message, WM_KEYDOWN | WM_SYSKEYDOWN); + if vk_code == VK_ESCAPE && super::capsule_menu_open() { return false; } if vk_code == VK_ESCAPE && (message == WM_KEYDOWN || message == WM_SYSKEYDOWN) { crate::side_aware_combo::handle_companion_key_down(); note_companion_key_down(ctx); @@ -1691,7 +1711,13 @@ mod platform { return esc_exclusive(); } - crate::side_aware_combo::platform::dispatch_vk(vk_code, pressed); + if crate::side_aware_combo::platform::dispatch_vk(vk_code, pressed) { + if pressed { + crate::side_aware_combo::handle_companion_key_down(); + note_companion_key_down(ctx); + } + return true; + } if pressed && !is_modifier_vk(vk_code) { crate::side_aware_combo::handle_companion_key_down(); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index ec0911225..e0cf76be2 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -23,6 +23,7 @@ compile_error!( 打包用 openless-all/app/scripts/package-linux-egui.sh(deb/rpm/manual zip)。" ); +mod recording_companion; mod android; mod asr; mod audio_mute; @@ -108,6 +109,7 @@ mod side_aware_combo; #[path = "mobile_stubs/side_aware_combo.rs"] mod side_aware_combo; mod tauri_events; +mod tls_crypto; mod types; #[cfg(not(mobile))] mod unicode_keystroke; @@ -202,6 +204,7 @@ pub(crate) fn set_backend_preferences_for_test( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + tls_crypto::install_tls_crypto_provider(); // Explicit acceptance in a debug bundle bypasses UI, microphone and the // system credential vault, but exercises the actual shared/native engines. // This entry point does not exist in production release builds. @@ -255,6 +258,9 @@ macro_rules! app_invoke_handler_desktop { get_capsule_auxiliary_snapshot, native_capsule_status, commands::get_capsule_snapshot, + commands::get_dictation_translation_target, + commands::select_dictation_translation_target, + commands::set_capsule_language_menu, commands::get_settings, commands::get_settings_snapshot, commands::update_setting_fields, @@ -910,13 +916,24 @@ fn run_desktop() { // — saving 3 resident webviews. Positioning and the QA drag fix are applied in // the creation/show paths. - // Main-window frosted material: NSVisualEffectView on macOS, Mica on Windows. - // Without this layer, transparent: true makes the window see-through to - // nothing instead of frosted glass. + // Main-window material: Mica on Windows. macOS deliberately applies no + // NSVisualEffectView: the React shell (WindowChrome, --ol-window-bg) paints a fully + // opaque surface over the whole content view, so a HudWindow/Active effect view would + // be invisible yet keep a live behind-window blur compositing on every frame. The + // native title bar style (Overlay + traffic lights) comes from tauri.conf.json at + // window creation; do not call set_decorations here (tao no-ops it when unchanged). // // decorations is decided per-platform at runtime: macOS keeps true for the // system traffic lights; Windows disables native chrome so the React // WinTitleBar takes over. + // Set the native appearance before showing any window, including + // low-memory launches where the main WebView does not exist yet. + #[cfg(target_os = "macos")] + app.set_theme(match coordinator.backend().get_preferences().theme_mode { + types::ThemeMode::System => None, + types::ThemeMode::Light => Some(tauri::Theme::Light), + types::ThemeMode::Dark => Some(tauri::Theme::Dark), + }); if let Some(main) = app.get_webview_window("main") { desktop_resources::initialize_main(&main); // Silent-start switch: prefs.start_minimized = true → don't pop the main @@ -2174,12 +2191,14 @@ fn wait_for_app_activation(app: &AppHandle) { #[cfg(not(target_os = "macos"))] fn wait_for_app_activation(_app: &AppHandle) {} -/// QA starts as a small composer and grows downwards after a question. +/// QA records in a compact bottom-anchored capsule, then reveals a wide result card. const QA_WINDOW_WIDTH: f64 = 480.0; const QA_WINDOW_HEIGHT: f64 = 80.0; -const QA_WINDOW_EXPANDED_HEIGHT: f64 = 560.0; +const QA_WINDOW_EXPANDED_WIDTH: f64 = 760.0; +const QA_WINDOW_EXPANDED_HEIGHT: f64 = 416.0; /// Gap between the capsule and the QA window; matches the design spec. -const QA_WINDOW_GAP_TO_CAPSULE: f64 = 8.0; +const QA_WINDOW_BOTTOM_INSET: f64 = 8.0; +const QA_RESULT_BOTTOM_LIFT: f64 = 40.0; /// Bottom margin reserved for the macOS Dock (same source as the capsule). #[derive(Clone, Copy, Debug, PartialEq)] @@ -2707,12 +2726,11 @@ fn position_qa_window(window: &tauri::WebviewWindow) -> ta let Some((frame, scale)) = chat_window_work_area(window, true)? else { return Ok(()); }; - let capsule_height = capsule_height_for_qa(); let (x, y) = bottom_center_position( frame, QA_WINDOW_WIDTH, - QA_WINDOW_EXPANDED_HEIGHT, - capsule_height + QA_WINDOW_GAP_TO_CAPSULE, + QA_WINDOW_HEIGHT, + QA_WINDOW_BOTTOM_INSET, ); window.set_position(tauri::PhysicalPosition::new( (x * scale).round() as i32, @@ -2725,6 +2743,14 @@ fn position_qa_window(window: &tauri::WebviewWindow) -> ta Ok(()) } +// Preserve a dragged window's centre and baseline; repeated result events must not drift. +fn qa_resize_origin(x: f64, y: f64, old_width: f64, old_height: f64, + width: f64, height: f64, was_expanded: bool, expanded: bool) -> (f64, f64) { + let lift = if expanded { QA_RESULT_BOTTOM_LIFT } else { 0.0 } + - if was_expanded { QA_RESULT_BOTTOM_LIFT } else { 0.0 }; + (x + (old_width - width) / 2.0, y + old_height - height - lift) +} + /// Called on the native main thread by the restricted QA command. The compact /// state has a genuinely small native frame, so hidden content cannot eat clicks. pub(crate) fn set_qa_window_expanded( @@ -2744,23 +2770,31 @@ pub(crate) fn set_qa_window_expanded( .inner_position() .map_err(|_| "qa_geometry_unavailable")? .to_logical::(scale); - let mut width = QA_WINDOW_WIDTH; + let current_size = window.inner_size() + .map_err(|_| "qa_geometry_unavailable")?.to_logical::(scale); + let was_expanded = current_size.height > QA_WINDOW_HEIGHT + 1.0; + let mut width = if expanded { QA_WINDOW_EXPANDED_WIDTH } else { QA_WINDOW_WIDTH }; let mut height = if expanded { QA_WINDOW_EXPANDED_HEIGHT } else { QA_WINDOW_HEIGHT }; - let (mut x, mut y) = (position.x, position.y); if let Some((frame, _)) = area { width = width.min((frame.width - 32.0).max(240.0)); height = height.min((frame.height - 64.0).max(QA_WINDOW_HEIGHT)); + } + let (mut x, mut y) = qa_resize_origin( + position.x, position.y, current_size.width, current_size.height, + width, height, was_expanded, expanded, + ); + if let Some((frame, _)) = area { x = x.clamp( frame.x + 16.0, (frame.x + frame.width - width - 16.0).max(frame.x + 16.0), ); y = y.clamp( frame.y + 32.0, - (frame.y + frame.height - height - 16.0).max(frame.y + 32.0), + (frame.y + frame.height - height - QA_WINDOW_BOTTOM_INSET).max(frame.y + 32.0), ); } window @@ -3780,14 +3814,11 @@ fn capsule_visual_height(_translation_active: bool) -> f64 { 140.0 } -fn capsule_height_for_qa() -> f64 { - capsule_visual_height(false) -} #[cfg(test)] mod tests { use super::{ - bottom_center_position, capsule_height_for_qa, capsule_visual_height, + bottom_center_position, capsule_visual_height, capsule_window_bounds, capsule_window_bounds_for_style, capsule_window_bounds_for_style_with_transcript, capsule_window_bounds_for_style_with_transcript_and_translation, clamp_to_monitor, @@ -4066,8 +4097,11 @@ mod tests { } #[test] - fn qa_anchor_uses_normal_capsule_height_source() { - assert_eq!(capsule_height_for_qa(), 140.0); + fn qa_resize_preserves_centre_baseline_and_user_position() { + let expanded = super::qa_resize_origin(-700.0, 700.0, 480.0, 80.0, 760.0, 416.0, false, true); + assert_eq!(expanded, (-840.0, 324.0)); + assert_eq!(super::qa_resize_origin(expanded.0, expanded.1, 760.0, 416.0, 760.0, 416.0, true, true), expanded); + assert_eq!(super::qa_resize_origin(expanded.0, expanded.1, 760.0, 416.0, 480.0, 80.0, true, false), (-700.0, 700.0)); } #[test] diff --git a/openless-all/app/src-tauri/src/mouse_dictation.rs b/openless-all/app/src-tauri/src/mouse_dictation.rs index 3b1294d6d..57fd204bc 100644 --- a/openless-all/app/src-tauri/src/mouse_dictation.rs +++ b/openless-all/app/src-tauri/src/mouse_dictation.rs @@ -232,6 +232,7 @@ fn match_modifier_states(required: &[String], down: [bool; 4]) -> bool { /// Dispatch a Mouse4 / Mouse5 edge into the active monitor (if any). pub fn handle_button(primary: &str, pressed: bool) { + crate::recording_companion::event(primary, pressed); let Some(normalized) = normalize_mouse_primary(primary) else { return; }; diff --git a/openless-all/app/src-tauri/src/native_capsule.rs b/openless-all/app/src-tauri/src/native_capsule.rs index 0212c709c..2f7e60c6d 100644 --- a/openless-all/app/src-tauri/src/native_capsule.rs +++ b/openless-all/app/src-tauri/src/native_capsule.rs @@ -1,6 +1,6 @@ //! Adapter from the existing coherent capsule snapshot to a disposable egui UI. use crate::capsule_mirrors::{self, MonitorRect}; -use crate::types::{CapsuleDisplayMode, CapsulePayload, CapsuleState, CapsuleStyle}; +use crate::types::{CapsuleDisplayMode, CapsulePayload, CapsuleState, CapsuleStyle, ThemeMode}; use openless_capsule::{ supervisor::CapsuleSupervisor, Action, CapsuleLabels, CapsulePopupState, Frame, }; @@ -29,7 +29,17 @@ pub(crate) struct NativeCapsule { latest: parking_lot::Mutex, position_pending: AtomicBool, layout_epoch: AtomicU64, + /// Bumped by every presentation and stop; a pending exit only reaps the + /// helpers if nothing was shown after it started. + exit_epoch: AtomicU64, + /// (native target name, UI locale) -> localized target name. + translation_name: parking_lot::Mutex>, } + +/// `DEFAULT_CAPSULE_EXIT_MS` (180) plus a frame: the default capsule's exit +/// motion plays on the helper before the host reaps it. +const EXIT_MS: u64 = 200; + impl NativeCapsule { pub(crate) fn new(app: tauri::AppHandle) -> Result { let executable = std::env::current_exe().map_err(|error| error.to_string())?; @@ -108,20 +118,19 @@ impl NativeCapsule { latest: parking_lot::Mutex::new(Presentation::default()), position_pending: AtomicBool::new(false), layout_epoch: AtomicU64::new(0), + exit_epoch: AtomicU64::new(0), + translation_name: parking_lot::Mutex::new(None), }) } pub(crate) fn set_locale(&self, locale: String) { *self.locale.lock() = locale; } + /// The text also feeds the "transcribing" label, so it is sent even when + /// the transcript rail is turned off (`Frame::show_transcript`). pub(crate) fn update_transcript(&self, session_id: &str, text: String) { - let coordinator = self.app.state::>(); - let enabled = coordinator - .backend() - .get_preferences() - .capsule_transcript_enabled; let mut latest = self.latest.lock(); if let Some(frame) = latest.frame.as_mut() { - if frame.update_transcript(session_id, if enabled { text } else { String::new() }) { + if frame.update_transcript(session_id, text) { self.send_frames(&latest); } } @@ -133,12 +142,72 @@ impl NativeCapsule { .find_map(CapsuleSupervisor::last_error) } pub(crate) fn stop(&self) { - *self.latest.lock() = Presentation::default(); + let mut latest = self.latest.lock(); + self.stop_locked(&mut latest); + } + fn stop_locked(&self, latest: &mut Presentation) { + *latest = Presentation::default(); + self.exit_epoch.fetch_add(1, Ordering::SeqCst); self.layout_epoch.fetch_add(1, Ordering::Relaxed); for supervisor in self.supervisors.lock().iter() { supervisor.stop(); } } + /// Replays the last frame as its exit and reaps the helpers once the + /// motion is over. The spectral style dismisses immediately. + fn leave(self: &Arc, sequence: u64) { + let mut latest = self.latest.lock(); + let exiting = latest.frame.as_ref().is_some_and(|frame| frame.leaving); + let animated = latest + .frame + .as_ref() + .is_some_and(|frame| frame.state.style != "siri") + && !latest.bounds.is_empty(); + if exiting { + return; + } + if !animated { + self.stop_locked(&mut latest); + return; + } + latest.frame = latest.frame.take().map(|frame| frame.into_leaving(sequence)); + self.send_frames(&latest); + let epoch = self.exit_epoch.fetch_add(1, Ordering::SeqCst) + 1; + drop(latest); + let native = Arc::clone(self); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(EXIT_MS)).await; + let mut latest = native.latest.lock(); + if native.exit_epoch.load(Ordering::SeqCst) == epoch { + native.stop_locked(&mut latest); + } + }); + } + /// The session's target language, named in the UI language as the + /// WebView badge does with `Intl.DisplayNames`. + fn translation_target(&self, saved: &str) -> String { + let coordinator = self.app.state::>(); + let target = coordinator + .backend() + .dictation_translation_target() + .map(|target| target.target_language) + .filter(|target| !target.trim().is_empty()) + .unwrap_or_else(|| saved.to_owned()); + let native = + openless_core::language_catalog::native_language_name(&target).unwrap_or(target); + let locale = self.locale.lock().clone(); + let mut cache = self.translation_name.lock(); + if let Some((cached, cached_locale, name)) = cache.as_ref() { + if *cached == native && *cached_locale == locale { + return name.clone(); + } + } + let name = openless_core::language_catalog::language_code(&native) + .and_then(|code| localized_language_name(&code, &locale)) + .unwrap_or_else(|| native.clone()); + *cache = Some((native, locale, name.clone())); + name + } pub(crate) fn present( self: &Arc, payload: &CapsulePayload, @@ -174,11 +243,10 @@ impl NativeCapsule { CapsuleStyle::Classic => "classic", CapsuleStyle::Typeless => "typeless", }; - let transcript = if preferences.capsule_transcript_enabled - && matches!( - payload.state, - CapsuleState::Recording | CapsuleState::Transcribing | CapsuleState::Polishing - ) { + let transcript = if matches!( + payload.state, + CapsuleState::Recording | CapsuleState::Transcribing | CapsuleState::Polishing + ) { transcript } else { String::new() @@ -192,21 +260,37 @@ impl NativeCapsule { audio_level: (!payload.warming).then_some(payload.level), translation_active: payload.translation, style: style.into(), + warming: payload.warming && payload.state == CapsuleState::Recording, + operating: payload.operating, + inserted_chars: payload.inserted_chars, + selection_polish: payload.selection_polish, + translation_target: payload + .translation + .then(|| self.translation_target(&preferences.translation_target_language)), }, transcript, + show_transcript: preferences.capsule_transcript_enabled, transcript_font_size: preferences.capsule_transcript_font_size as f32, labels: CapsuleLabels::for_locale(&self.locale.lock()), + theme: match preferences.theme_mode { + ThemeMode::System => openless_capsule::ThemeMode::System, + ThemeMode::Light => openless_capsule::ThemeMode::Light, + ThemeMode::Dark => openless_capsule::ThemeMode::Dark, + }, + leaving: false, bounds: None, interactive, }; if !frame.visible() { - self.stop(); + self.leave(sequence); return; } let mode = crate::tauri_coordinator_host::normalized_display_mode( preferences.capsule_display_mode, ); let mut latest = self.latest.lock(); + // A new presentation reverses a running exit on the same helpers. + self.exit_epoch.fetch_add(1, Ordering::SeqCst); let was_idle = latest.frame.is_none(); let needs_position = latest.mode != mode || latest.frame.as_ref().is_none_or(|previous| { @@ -351,6 +435,34 @@ impl NativeCapsule { } } +/// `[[NSLocale localeWithLocaleIdentifier:locale] localizedStringForLocaleIdentifier:code]`, +/// the same CLDR "standard" name `Intl.DisplayNames` gives the WebView. +#[cfg(target_os = "macos")] +fn localized_language_name(code: &str, locale: &str) -> Option { + use objc2::{msg_send, runtime::AnyClass, runtime::AnyObject}; + use objc2_foundation::NSString; + let class = AnyClass::get("NSLocale")?; + let (identifier, code) = (NSString::from_str(locale), NSString::from_str(code)); + objc2::rc::autoreleasepool(|_| unsafe { + // SAFETY: both selectors take an NSString and return autoreleased + // objects that are copied out before the pool drains. + let ns_locale: *mut AnyObject = msg_send![class, localeWithLocaleIdentifier: &*identifier]; + if ns_locale.is_null() { + return None; + } + let name: *mut NSString = msg_send![ns_locale, localizedStringForLocaleIdentifier: &*code]; + name.as_ref() + .map(|name| name.to_string()) + .filter(|name| !name.is_empty()) + }) +} + +/// Windows has no per-call display locale; the native name is shown instead. +#[cfg(not(target_os = "macos"))] +fn localized_language_name(_code: &str, _locale: &str) -> Option { + None +} + #[tauri::command] pub(crate) fn native_capsule_set_locale(app: tauri::AppHandle, locale: String) { if !matches!( diff --git a/openless-all/app/src-tauri/src/qa_adapter.rs b/openless-all/app/src-tauri/src/qa_adapter.rs index 41aaa09c8..61abf0b00 100644 --- a/openless-all/app/src-tauri/src/qa_adapter.rs +++ b/openless-all/app/src-tauri/src/qa_adapter.rs @@ -33,6 +33,7 @@ pub(crate) struct TauriQaHostContext { external_focus_target: Mutex>, front_app: Mutex>, panel_visible: AtomicBool, + voice_surface_owner: Mutex>>, selection_voice_target_binder: Mutex>, } @@ -45,6 +46,15 @@ fn retain_external_focus_target( } impl TauriQaHostContext { + pub(crate) fn bind_voice_surface_owner(&self, owner: Arc) { + *self.voice_surface_owner.lock() = Some(owner); + } + + pub(crate) fn set_voice_surface_active(&self, active: bool) { + let owner = self.voice_surface_owner.lock().clone(); + if let Some(owner) = owner { owner(active); } + } + pub(crate) fn is_panel_visible(&self) -> bool { self.panel_visible.load(Ordering::Acquire) } @@ -114,9 +124,9 @@ impl TauriQaHostContext { } let _ = crate::coordinator::restore_focus_target_if_possible(*self.focus_target.lock()); let (selection, selection_target) = crate::selection::resolve_selection_workspace_capture(); - if let Some(app) = app.lock().clone() { - crate::refocus_qa_window(&app); - } + // Voice Ask keeps the original app focused throughout capture/recording. + // The result window is non-activating; there is no composer to focus. + let _ = app; let selection_text = selection.as_ref().map(|selection| selection.text.clone()); let front_app = selection .and_then(|selection| selection.source_app) diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index eb826ea61..40cf087d1 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -31,8 +31,12 @@ const TARGET_SAMPLE_RATE: u32 = 16_000; const LOG_EVERY_N_CALLBACKS: usize = 50; /// RMS -> UI level gain, matching Swift's `min(1.0, rms * 4)`. const LEVEL_RMS_GAIN: f32 = 4.0; -/// Maximum number of native callback blocks waiting for the processing worker. -const AUDIO_PROCESSING_QUEUE_CAPACITY: usize = 64; +/// Bound capture backlog by duration and memory, independently of callback size. +const AUDIO_PROCESSING_MAX_QUEUED_US: u64 = 3_000_000; +const AUDIO_PROCESSING_MAX_QUEUED_BYTES: usize = 8 * 1024 * 1024; +const AUDIO_PROCESSING_QUEUE_CAPACITY: usize = 4096; +/// Shed optional enhancement before a temporary backlog can stop capture. +const AUDIO_PROCESSING_SHED_ENHANCEMENT_US: u64 = 500_000; static NEXT_CAPTURE_ID: AtomicU64 = AtomicU64::new(1); /// Max queued PCM chunks for the archive writer thread. When full, new archive chunks are /// dropped instead of blocking the realtime callback; ASR still receives the full PCM, and @@ -1992,7 +1996,7 @@ fn run_audio_thread( /// Try the selected microphone's native format before other advertised formats/devices. fn build_input_stream( microphone_device_name: Option, - frame_tx: SyncSender, + frame_tx: AudioProcessingSender, state: Arc, stop_flag: Arc, runtime_error_tx: Sender, @@ -2045,7 +2049,7 @@ fn try_input_candidates( fn start_device_stream( device: &cpal::Device, - frame_tx: &SyncSender, + frame_tx: &AudioProcessingSender, state: &Arc, stop_flag: &Arc, runtime_error_tx: &Sender, @@ -2105,7 +2109,7 @@ fn native_input_alternatives( fn start_native_config( device: &cpal::Device, supported: cpal::SupportedStreamConfig, - frame_tx: &SyncSender, + frame_tx: &AudioProcessingSender, state: &Arc, stop_flag: &Arc, runtime_error_tx: &Sender, @@ -2226,7 +2230,7 @@ fn build_stream_for_format( device: &cpal::Device, config: &StreamConfig, sample_format: SampleFormat, - frame_tx: SyncSender, + frame_tx: AudioProcessingSender, state: Arc, stop_flag: Arc, input_sr: u32, @@ -2271,13 +2275,21 @@ fn build_stream_for_format( })) { Ok(()) => {} Err(TrySendError::Disconnected(_)) => { + if state.accepting_audio.swap(false, Ordering::AcqRel) { + let _ = runtime_error_for_callback.send( + RecorderError::EngineFailed("audio processing worker disconnected".into()), + ); + } stop_flag.store(true, Ordering::Release); } Err(TrySendError::Full(_)) => { if state.accepting_audio.swap(false, Ordering::AcqRel) { let _ = runtime_error_for_callback.send( RecorderError::EngineFailed( - "audio processing queue overflow".into(), + format!("audio processing backlog exceeded limit: queued_us={} queued_bytes={} callback_samples={} sample_rate={} channels={}", + frame_tx.backlog.micros.load(Ordering::Acquire), + frame_tx.backlog.bytes.load(Ordering::Acquire), + data.len(), input_sr, channels), ), ); } @@ -2329,9 +2341,110 @@ enum AudioProcessingMessage { Shutdown, } +#[derive(Default)] +struct AudioProcessingBacklog { + micros: AtomicU64, + bytes: AtomicUsize, +} + +struct AudioProcessingReservation { + backlog: Arc, + micros: u64, + bytes: usize, +} + +impl Drop for AudioProcessingReservation { + fn drop(&mut self) { + self.backlog.micros.fetch_sub(self.micros, Ordering::AcqRel); + self.backlog.bytes.fetch_sub(self.bytes, Ordering::AcqRel); + } +} + +struct QueuedAudioProcessingMessage { + message: AudioProcessingMessage, + _reservation: Option, +} + +#[derive(Clone)] +struct AudioProcessingSender { + sender: SyncSender, + backlog: Arc, +} + +impl AudioProcessingSender { + fn reserve( + &self, + message: AudioProcessingMessage, + ) -> Result { + let reservation = if let AudioProcessingMessage::Frame(frame) = &message { + let rate = u64::from(frame.input_sample_rate) * frame.channels as u64; + if rate == 0 { + return Err(message); + } + let micros = (frame.samples.len() as u64 * 1_000_000).div_ceil(rate); + let bytes = frame.samples.len() * std::mem::size_of::(); + if self + .backlog + .bytes + .try_update(Ordering::AcqRel, Ordering::Acquire, |used| { + used.checked_add(bytes) + .filter(|next| *next <= AUDIO_PROCESSING_MAX_QUEUED_BYTES) + }) + .is_err() + { + return Err(message); + } + if self + .backlog + .micros + .try_update(Ordering::AcqRel, Ordering::Acquire, |used| { + used.checked_add(micros) + .filter(|next| *next <= AUDIO_PROCESSING_MAX_QUEUED_US) + }) + .is_err() + { + self.backlog.bytes.fetch_sub(bytes, Ordering::AcqRel); + return Err(message); + } + Some(AudioProcessingReservation { + backlog: Arc::clone(&self.backlog), + micros, + bytes, + }) + } else { + None + }; + Ok(QueuedAudioProcessingMessage { + message, + _reservation: reservation, + }) + } + + fn try_send( + &self, + message: AudioProcessingMessage, + ) -> Result<(), TrySendError> { + let queued = self.reserve(message).map_err(TrySendError::Full)?; + self.sender.try_send(queued).map_err(|error| match error { + TrySendError::Full(queued) => TrySendError::Full(queued.message), + TrySendError::Disconnected(queued) => TrySendError::Disconnected(queued.message), + }) + } + + fn send( + &self, + message: AudioProcessingMessage, + ) -> Result<(), std::sync::mpsc::SendError> { + let queued = self.reserve(message).map_err(std::sync::mpsc::SendError)?; + self.sender + .send(queued) + .map_err(|error| std::sync::mpsc::SendError(error.0.message)) + } +} + struct AudioProcessingWorker { join_handle: Option>, - shutdown: SyncSender, + shutdown: AudioProcessingSender, } impl AudioProcessingWorker { @@ -2341,52 +2454,61 @@ impl AudioProcessingWorker { level_handler: Arc, archiver: Option>, state: Arc, - ) -> Result<(Self, SyncSender), RecorderError> { + ) -> Result<(Self, AudioProcessingSender), RecorderError> { let (sender, receiver) = - sync_channel::(AUDIO_PROCESSING_QUEUE_CAPACITY); + sync_channel::(AUDIO_PROCESSING_QUEUE_CAPACITY); + let backlog = Arc::new(AudioProcessingBacklog::default()); + let sender = AudioProcessingSender { + sender, + backlog: Arc::clone(&backlog), + }; let join_handle = thread::Builder::new() .name("openless-audio-processor".into()) .spawn(move || { let mut processor = openless_core::AudioProcessor::new(processing); - while let Ok(message) = receiver.recv() { + let mut enhancement_enabled = processing.enhancement_enabled; + let deliver = |chunk: openless_core::audio::NormalizedPcmChunk| { + state.note_first_pcm(true); + consumer.consume_pcm_chunk(&chunk.pcm_i16_le); + if let Some(archiver) = &archiver { + archiver.append(&chunk.pcm_i16_le); + } + level_handler(chunk.level); + update_peak(&state.peak_output_rms_milli, chunk.level); + }; + while let Ok(queued) = receiver.recv() { + // Keep the reservation until downstream delivery completes. + let QueuedAudioProcessingMessage { message, _reservation } = queued; match message { AudioProcessingMessage::Frame(frame) => { + let queued_us = backlog.micros.load(Ordering::Acquire); + if enhancement_enabled && queued_us >= AUDIO_PROCESSING_SHED_ENHANCEMENT_US { + log::warn!("[recorder] disabling enhancement for this capture: processing backlog {} ms", queued_us / 1_000); + if let Some(tail) = processor.disable_enhancement() { + deliver(tail); + } + enhancement_enabled = false; + } if let Some(chunk) = processor.process( &frame.samples, frame.channels, frame.input_sample_rate, ) { - state.note_first_pcm(true); - consumer.consume_pcm_chunk(&chunk.pcm_i16_le); - if let Some(archiver) = &archiver { - archiver.append(&chunk.pcm_i16_le); - } - level_handler(chunk.level); - update_peak(&state.peak_output_rms_milli, chunk.level); + deliver(chunk); } } AudioProcessingMessage::Flush(done) => { if let Some(chunk) = processor.finish() { - state.note_first_pcm(true); - consumer.consume_pcm_chunk(&chunk.pcm_i16_le); - if let Some(archiver) = &archiver { - archiver.append(&chunk.pcm_i16_le); - } - level_handler(chunk.level); - update_peak(&state.peak_output_rms_milli, chunk.level); + deliver(chunk); } - processor.reset(); + // A warm microphone reuses this worker across recording segments. + processor = openless_core::AudioProcessor::new(processing); + enhancement_enabled = processing.enhancement_enabled; let _ = done.send(()); } AudioProcessingMessage::Shutdown => { if let Some(chunk) = processor.finish() { - state.note_first_pcm(true); - consumer.consume_pcm_chunk(&chunk.pcm_i16_le); - if let Some(archiver) = &archiver { - archiver.append(&chunk.pcm_i16_le); - } - level_handler(chunk.level); - update_peak(&state.peak_output_rms_milli, chunk.level); + deliver(chunk); } break; } @@ -2425,7 +2547,7 @@ struct StreamState { created_at: std::time::Instant, first_raw_pcm_us: AtomicU64, first_output_pcm_us: AtomicU64, - processor_sender: Mutex>>, + processor_sender: Mutex>, /// Fractional position left unconsumed by the previous callback; linear-interpolation /// resampling spans buffers. resample_phase: Mutex, @@ -2791,6 +2913,138 @@ mod tests { } } + #[test] + fn audio_processing_budget_is_duration_based_and_released_on_disconnect() { + for samples in [48, 480, 4800] { + let (tx, rx) = sync_channel(AUDIO_PROCESSING_QUEUE_CAPACITY); + let sender = AudioProcessingSender { + sender: tx, + backlog: Arc::new(AudioProcessingBacklog::default()), + }; + let frame = || { + AudioProcessingMessage::Frame(RawAudioFrame { + samples: vec![0.25; samples], + channels: 1, + input_sample_rate: 48_000, + }) + }; + let frames = 3 * 48_000 / samples; + for _ in 0..frames { + assert!(sender.try_send(frame()).is_ok()); + } + assert!(matches!( + sender.try_send(frame()), + Err(TrySendError::Full(_)) + )); + assert_eq!(sender.backlog.micros.load(Ordering::Acquire), 3_000_000); + assert_eq!(sender.backlog.bytes.load(Ordering::Acquire), 3 * 48_000 * 4); + drop(rx); + assert_eq!(sender.backlog.micros.load(Ordering::Acquire), 0); + assert_eq!(sender.backlog.bytes.load(Ordering::Acquire), 0); + assert!(matches!( + sender.try_send(frame()), + Err(TrySendError::Disconnected(_)) + )); + assert_eq!(sender.backlog.micros.load(Ordering::Acquire), 0); + assert_eq!(sender.backlog.bytes.load(Ordering::Acquire), 0); + } + } + + #[test] + fn audio_processing_budget_also_bounds_large_multichannel_frames() { + let (tx, _rx) = sync_channel(AUDIO_PROCESSING_QUEUE_CAPACITY); + let sender = AudioProcessingSender { + sender: tx, + backlog: Arc::new(AudioProcessingBacklog::default()), + }; + let frame = AudioProcessingMessage::Frame(RawAudioFrame { + samples: vec![0.0; 48_000 * 64], + channels: 64, + input_sample_rate: 48_000, + }); + assert!(matches!(sender.try_send(frame), Err(TrySendError::Full(_)))); + assert_eq!(sender.backlog.micros.load(Ordering::Acquire), 0); + assert_eq!(sender.backlog.bytes.load(Ordering::Acquire), 0); + } + + #[test] + fn audio_processing_backlog_sheds_enhancement_without_losing_pcm() { + let consumer = Arc::new(RecordingConsumer::default()); + let (entered_tx, entered_rx) = channel(); + let (resume_tx, resume_rx) = channel(); + let resume_rx = Mutex::new(resume_rx); + let block_next_level = Arc::new(AtomicBool::new(false)); + let block_for_level = Arc::clone(&block_next_level); + let level_handler = Arc::new(move |_| { + if block_for_level.swap(false, Ordering::AcqRel) { + entered_tx.send(()).unwrap(); + resume_rx + .lock() + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + } + }); + let (worker, sender) = AudioProcessingWorker::start( + openless_core::AudioProcessingConfig { + enhancement_enabled: true, + ..Default::default() + }, + Arc::clone(&consumer) as Arc, + level_handler, + None, + Arc::new(StreamState::new()), + ) + .unwrap(); + for _ in 0..2 { + block_next_level.store(true, Ordering::Release); + sender + .send(AudioProcessingMessage::Frame(RawAudioFrame { + samples: vec![0.25; 997], + channels: 1, + input_sample_rate: TARGET_SAMPLE_RATE, + })) + .unwrap(); + entered_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + // One second of short callbacks exceeds the old 64-message limit. + for _ in 0..100 { + assert!(sender + .try_send(AudioProcessingMessage::Frame(RawAudioFrame { + samples: vec![0.5; 160], + channels: 1, + input_sample_rate: TARGET_SAMPLE_RATE, + })) + .is_ok()); + } + resume_tx.send(()).unwrap(); + let (done_tx, done_rx) = channel(); + sender.send(AudioProcessingMessage::Flush(done_tx)).unwrap(); + done_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + } + worker.finish(); + let delivered: Vec = consumer + .chunks + .lock() + .unwrap() + .iter() + .flatten() + .copied() + .collect(); + let segment_samples = 997 + 160 * 100; + assert_eq!(delivered.len(), segment_samples * 2 * 2); + let decoded = decode_i16_le(&delivered); + assert_eq!(&decoded[..segment_samples], &decoded[segment_samples..]); + assert!(decoded[..997].iter().any(|sample| *sample != 8191)); + assert!(decoded[997..segment_samples] + .iter() + .all(|sample| *sample == 16383)); + assert_eq!(sender.backlog.micros.load(Ordering::Acquire), 0); + assert_eq!(sender.backlog.bytes.load(Ordering::Acquire), 0); + } + #[test] fn audio_processing_worker_drains_accepted_frames_before_join() { let consumer = Arc::new(RecordingConsumer::default()); diff --git a/openless-all/app/src-tauri/src/recording_companion.rs b/openless-all/app/src-tauri/src/recording_companion.rs new file mode 100644 index 000000000..4b4599c76 --- /dev/null +++ b/openless-all/app/src-tauri/src/recording_companion.rs @@ -0,0 +1,195 @@ +//! A recording shortcut plus one auxiliary key. Uses the existing physical-key +//! tap, preserving left/right keys, Fn and custom multi-key recording bindings. +use crate::types::ShortcutBinding; +use std::collections::HashSet; +use std::sync::{Arc, Mutex, OnceLock}; + +type Action = Arc; +#[derive(Default)] +struct Route { + state: ChordState, + action: Option, + suspended: bool, +} +#[derive(Default)] +struct ChordState { + recording: Option, + auxiliary: String, + held: HashSet, + consumed: bool, +} +static ROUTE: OnceLock> = OnceLock::new(); +fn route() -> &'static Mutex { + ROUTE.get_or_init(|| Mutex::new(Route::default())) +} +fn key(value: &str) -> String { + match value.to_ascii_lowercase().as_str() { + "rightoption" | "rightalt" | "alt-right" => "altright".into(), + "leftoption" | "leftalt" | "alt-left" => "altleft".into(), + "rightcontrol" | "ctrl-right" => "ctrlright".into(), + "leftcontrol" | "ctrl-left" => "ctrlleft".into(), + "rightcommand" | "rightsuper" | "cmd-right" | "super-right" => "cmdright".into(), + "leftcommand" | "leftsuper" | "cmd-left" | "super-left" => "cmdleft".into(), + "rightshift" | "shift-right" => "shiftright".into(), + "leftshift" | "shift-left" => "shiftleft".into(), + "super" | "command" => "cmd".into(), + "control" => "ctrl".into(), + "option" => "alt".into(), + " " => "space".into(), + other => other.into(), + } +} +fn matches(required: &str, physical: &str) -> bool { + required == physical + || matches!(required, "cmd" | "ctrl" | "alt" | "shift") && physical.starts_with(required) +} +impl ChordState { + fn event(&mut self, physical: &str, pressed: bool) -> (bool, bool) { + let physical = key(physical); + let auxiliary = key(&self.auxiliary); + let was_down = self.held.contains(&physical); + if pressed { + self.held.insert(physical.clone()); + } else { + self.held.remove(&physical); + } + if physical == auxiliary && !pressed { + let consume = std::mem::take(&mut self.consumed); + return (consume, false); + } + if physical != auxiliary || !pressed { + return (false, false); + } + if self.consumed { + return (true, false); + } + if was_down || auxiliary.is_empty() { + return (false, false); + } + let Some(binding) = &self.recording else { + return (false, false); + }; + let mut required: Vec = binding.modifiers.iter().map(|value| key(value)).collect(); + if !binding.primary.eq_ignore_ascii_case("ModifierChord") { + required.push(key(&binding.primary)); + } + if required.is_empty() + || required + .iter() + .any(|value| value.is_empty() || *value == auxiliary) + { + return (false, false); + } + let complete = required + .iter() + .all(|required| self.held.iter().any(|held| matches(required, held))) + && self.held.iter().all(|held| { + held == &auxiliary || required.iter().any(|required| matches(required, held)) + }); + if complete { + self.consumed = true; + } + (complete, complete) + } +} +pub(crate) fn configure(recording: ShortcutBinding, auxiliary: String) { + let mut route = route().lock().unwrap(); + route.state = ChordState { + recording: Some(recording), + auxiliary, + ..Default::default() + }; +} +pub(crate) fn install(action: Action) { + route().lock().unwrap().action = Some(action); +} +pub(crate) fn suspend(suspended: bool) { + let mut route = route().lock().unwrap(); + route.suspended = suspended; + route.state.held.clear(); + route.state.consumed = false; +} +pub(crate) fn reset_held() { + let mut route = route().lock().unwrap(); + route.state.held.clear(); + route.state.consumed = false; +} +pub(crate) fn claimed() -> bool { + route().lock().unwrap().state.consumed +} +/// Returns true only for a claimed auxiliary key, including its autorepeat and release. +pub(crate) fn event(physical: &str, pressed: bool) -> bool { + let (consume, action) = { + let mut route = route().lock().unwrap(); + if route.suspended { + return false; + } + let (consume, fire) = route.state.event(physical, pressed); + (consume, if fire { route.action.clone() } else { None }) + }; + if let Some(action) = action { + action(); + } + consume +} + +#[cfg(test)] +mod tests { + use super::*; + fn state(primary: &str, modifiers: &[&str]) -> ChordState { + ChordState { + recording: Some(ShortcutBinding { + primary: primary.into(), + modifiers: modifiers.iter().map(|v| v.to_string()).collect(), + }), + auxiliary: "Space".into(), + ..Default::default() + } + } + #[test] + fn side_specific_recording_is_the_only_anchor_and_repeats_are_suppressed() { + let mut s = state("RightAlt", &[]); + s.event("LeftAlt", true); + assert_eq!(s.event("Space", true), (false, false)); + s.event("Space", false); + s.event("LeftAlt", false); + s.event("RightAlt", true); + assert_eq!(s.event("Space", true), (true, true)); + assert_eq!(s.event("Space", true), (true, false)); + s.event("RightAlt", false); + assert_eq!(s.event("Space", false), (true, false)); + assert_eq!(s.event("Space", true), (false, false)); + } + #[test] + fn fn_and_custom_recording_chords_keep_the_entire_base() { + let mut s = state("Fn", &[]); + s.event("Fn", true); + assert_eq!(s.event("Space", true), (true, true)); + let mut s = state("D", &["cmd", "shift"]); + s.event("LeftCommand", true); + s.event("LeftShift", true); + assert_eq!(s.event("Space", true), (false, false)); + s.event("Space", false); + s.event("D", true); + assert_eq!(s.event("Space", true), (true, true)); + } + #[test] + fn mouse_recording_anchors_and_updated_auxiliary_keys_work() { + let mut s = state("Mouse4", &[]); + s.auxiliary = "Q".into(); + s.event("Mouse4", true); + assert_eq!(s.event("Space", true), (false, false)); + s.event("Space", false); + assert_eq!(s.event("Q", true), (true, true)); + } + #[test] + fn extra_modifiers_and_auxiliary_pressed_before_base_do_not_trigger() { + let mut s = state("RightControl", &[]); + s.event("Space", true); + s.event("RightControl", true); + assert_eq!(s.event("Space", true), (false, false)); + s.event("Space", false); + s.event("LeftShift", true); + assert_eq!(s.event("Space", true), (false, false)); + } +} diff --git a/openless-all/app/src-tauri/src/side_aware_combo.rs b/openless-all/app/src-tauri/src/side_aware_combo.rs index 668e43eeb..859110c23 100644 --- a/openless-all/app/src-tauri/src/side_aware_combo.rs +++ b/openless-all/app/src-tauri/src/side_aware_combo.rs @@ -192,6 +192,17 @@ pub enum SideModifier { ShiftRight, } +impl SideModifier { + fn recording_key(self) -> &'static str { + match self { + Self::CmdLeft => "LeftCommand", Self::CmdRight => "RightCommand", + Self::CtrlLeft => "LeftControl", Self::CtrlRight => "RightControl", + Self::AltLeft => "LeftAlt", Self::AltRight => "RightAlt", + Self::ShiftLeft => "LeftShift", Self::ShiftRight => "RightShift", + } + } +} + pub struct SideAwareComboMonitor; impl SideAwareComboMonitor { @@ -351,14 +362,17 @@ pub mod platform { VK_RMENU, VK_RSHIFT, VK_RWIN, VK_SPACE, VK_TAB, VK_UP, }; - pub fn dispatch_vk(vk_code: u32, pressed: bool) { + pub fn dispatch_vk(vk_code: u32, pressed: bool) -> bool { if let Some(side) = side_from_vk(vk_code) { + crate::recording_companion::event(side.recording_key(), pressed); handle_side_modifier(side, pressed); - return; + return false; } if let Some(primary) = primary_from_vk(vk_code) { + if crate::recording_companion::event(&primary, pressed) { return true; } handle_primary_key(&primary, pressed); } + false } fn side_from_vk(vk_code: u32) -> Option { @@ -543,7 +557,7 @@ pub mod platform { const FLAG_MASK_ALTERNATE: u64 = 1 << 19; // 0x0008_0000 (Option/Alt) const FLAG_MASK_COMMAND: u64 = 1 << 20; // 0x0010_0000 - pub fn dispatch_keycode(keycode: i64, flags_changed: bool, flags: u64, pressed: bool) { + pub fn dispatch_keycode(keycode: i64, flags_changed: bool, flags: u64, pressed: bool) -> bool { if flags_changed { if let Some(side) = side_from_keycode(keycode) { // The event-carried `flags` are race-free (they describe the state @@ -556,14 +570,19 @@ pub mod platform { // `CGEventSourceKeyState` (Apple's standard way to resolve side). None => keycode_is_down(keycode), }; + crate::recording_companion::event(side.recording_key(), down); handle_side_modifier(side, down); + } else if keycode == 63 { + crate::recording_companion::event("Fn", flags & (1 << 23) != 0); } - return; + return false; } if let Some(primary) = primary_from_keycode(keycode) { + if crate::recording_companion::event(&primary, pressed) { return true; } handle_primary_key(&primary, pressed); } + false } /// Modifier-class flag bit for a side-modifier keycode, or `None` if the keycode diff --git a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs index cde3403fe..86df885be 100644 --- a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs +++ b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs @@ -64,10 +64,10 @@ fn capsule_transcript_rail_position( CapsuleStyle::Classic => { const HOST_HEIGHT: f64 = 172.0; const BODY_BOTTOM_INSET: f64 = 16.0; - const BODY_HEIGHT: f64 = 52.0; + const BODY_HEIGHT: f64 = 32.0; const RAIL_HEIGHT: f64 = 52.0; const RAIL_GAP: f64 = 8.0; - const BADGE_HEIGHT: f64 = 22.0; + const BADGE_HEIGHT: f64 = 28.0; const BADGE_GAP: f64 = 8.0; let body_top = HOST_HEIGHT - BODY_BOTTOM_INSET - BODY_HEIGHT; let gap = RAIL_GAP; @@ -187,7 +187,7 @@ fn capsule_control_hit_rect( // Classic transcript is hosted by the separate click-through rail HWND on // Windows. The capsule HWND therefore owns only its 172px host, regardless // of whether the rail is visible. - CapsuleStyle::Classic => (460.0, 172.0, 196.0, 52.0, 16.0), + CapsuleStyle::Classic => (460.0, 172.0, 116.0, 32.0, 16.0), // Typeless content is scaled by zoom: 64px CSS × 0.447. The native window may be // 57px or 65px high when the dedicated translation row is present. CapsuleStyle::Typeless => ( @@ -202,8 +202,13 @@ fn capsule_control_hit_rect( let _ = transcript_visible; let scale = (window_width as f64 / logical_host_width).min(window_height as f64 / logical_host_height); - let body_width = (logical_body_width * scale).round() as i32; - let body_height = (logical_body_height * scale).round() as i32; + // Windows clips the native input region as well as CSS. Include the language + // picker only while translation is active; the transcript remains click-through. + let picker_visible = style == CapsuleStyle::Classic && translation_active; + let interactive_width = if picker_visible { 260.0 } else { logical_body_width }; + let interactive_height = logical_body_height + if picker_visible { 36.0 } else { 0.0 }; + let body_width = (interactive_width * scale).round() as i32; + let body_height = (interactive_height * scale).round() as i32; let bottom_inset = (logical_bottom_inset * scale).round() as i32; let control_left = ((window_width as f64 - body_width as f64) / 2.0).round() as i32; let control_bottom = window_height - bottom_inset; @@ -774,8 +779,11 @@ fn capsule_window_action( fallback_card_active: bool, show_capsule: bool, state: CapsuleState, + qa_voice_active: bool, ) -> CapsuleWindowAction { - if fallback_card_active { + if qa_voice_active { + CapsuleWindowAction::HideCapsule + } else if fallback_card_active { CapsuleWindowAction::PreserveFallbackCard } else if show_capsule && !matches!(state, CapsuleState::Idle) { CapsuleWindowAction::ShowCapsule @@ -818,9 +826,23 @@ pub(crate) struct CapsuleSnapshot { pub(crate) payload_revision: u64, } +struct CapsuleMenuFrame { + label: String, + session_id: openless_core::SessionId, + position: tauri::PhysicalPosition, + size: tauri::PhysicalSize, +} + +fn capsule_menu_geometry(y: i32, height: u32, work_top: i32, scale: f64) -> (i32, u32) { + let extra = ((320.0 * scale).round() as i32).min(y.saturating_sub(work_top).max(0)); + (y - extra, height.saturating_add(extra as u32)) +} + struct CapsuleWindowState { layout: Mutex>, capsule_visible: AtomicBool, + qa_voice_active: AtomicBool, + translation_menu: Mutex>, cursor_passthrough: AtomicBool, hit_test_mode: AtomicU8, transcript_visible: AtomicBool, @@ -846,6 +868,8 @@ impl Default for CapsuleWindowState { Self { layout: Mutex::new(None), capsule_visible: AtomicBool::new(false), + qa_voice_active: AtomicBool::new(false), + translation_menu: Mutex::new(None), cursor_passthrough: AtomicBool::new(true), hit_test_mode: AtomicU8::new(HIT_TEST_MODE_CAPSULE), transcript_visible: AtomicBool::new(false), @@ -1148,6 +1172,70 @@ impl TauriCapsuleWindow { .map_err(|error| error.to_string()) } + fn restore_translation_menu(&self, owner: Option<(&str, openless_core::SessionId)>) -> Result { + let frame = { + let mut slot = self.state.translation_menu.lock(); + if let Some((label, session_id)) = owner { + if !slot.as_ref().is_some_and(|frame| frame.label == label && frame.session_id == session_id) { return Ok(false); } + } + slot.take() + }; + let Some(frame) = frame else { return Ok(false); }; + #[cfg(not(mobile))] + crate::hotkey::set_capsule_menu_open(false); + self.invalidate_layout(); + if let Some(window) = self.app.get_webview_window(&frame.label) { + window.set_size(frame.size).map_err(|error| error.to_string())?; + window.set_position(frame.position).map_err(|error| error.to_string())?; + #[cfg(target_os = "windows")] + configure_capsule_hit_test(&window, self.state.cached_style(), + self.state.transcript_visible.load(Ordering::SeqCst), true).map_err(|error| error.to_string())?; + } + Ok(true) + } + + /// Reserve room for the shared popup above the pill without moving its bottom anchor. + pub(crate) fn set_translation_menu_open(&self, label: String, session_id: openless_core::SessionId, open: bool) -> Result<(), String> { + if !open { + if self.restore_translation_menu(Some((&label, session_id)))? && self.state.capsule_visible.load(Ordering::SeqCst) { + self.start_layout_watch(); + self.refresh_style(); + self.sync_capsule_mirrors(); + } + return Ok(()); + } + if self.state.qa_voice_active.load(Ordering::SeqCst) { return Err("qa_owns_voice_surface".into()); } + if self.state.translation_menu.lock().as_ref().is_some_and(|frame| frame.label == label && frame.session_id == session_id) { return Ok(()); } + self.restore_translation_menu(None)?; + let window = self.app.get_webview_window(&label).ok_or("capsule_window_unavailable")?; + let position = window.outer_position().map_err(|error| error.to_string())?; + let size = window.inner_size().map_err(|error| error.to_string())?; + let scale = window.scale_factor().map_err(|error| error.to_string())?; + let work_top = window.current_monitor().map_err(|error| error.to_string())? + .map(|monitor| monitor.work_area().position.y).unwrap_or(position.y - (320.0 * scale) as i32); + let (top, height) = capsule_menu_geometry(position.y, size.height, work_top, scale); + *self.state.translation_menu.lock() = Some(CapsuleMenuFrame {label, session_id, position, size}); + #[cfg(not(mobile))] + crate::hotkey::set_capsule_menu_open(true); + self.stop_layout_watch(); + self.hide_transcript_overlay(); + let result = (|| -> tauri::Result<()> { + window.set_position(tauri::PhysicalPosition::new(position.x, top))?; + window.set_size(tauri::PhysicalSize::new(size.width, height))?; + #[cfg(not(mobile))] + window.set_ignore_cursor_events(false)?; + #[cfg(target_os = "windows")] + configure_card_hit_test(&window)?; + Ok(()) + })(); + if let Err(error) = result { + let _ = self.restore_translation_menu(None); + self.start_layout_watch(); + return Err(error.to_string()); + } + Ok(()) + } + pub(crate) fn set_size(&self, width: f64, height: f64) -> tauri::Result<()> { // Cards temporarily borrow this window; their geometry belongs to the card until released. self.stop_layout_watch(); @@ -1271,6 +1359,7 @@ impl TauriCapsuleWindow { #[cfg(any(target_os = "macos", target_os = "windows"))] fn apply_capsule_mirrors(&self) { + if self.state.translation_menu.lock().is_some() { return; } use crate::capsule_mirrors::{ capsule_placement, ensure_capsule_mirror, mirror_label, mirror_targets, CAPSULE_MIRROR_PREFIX, MAX_CAPSULE_MIRRORS, RAIL_MIRROR_PREFIX, @@ -1455,6 +1544,7 @@ impl TauriCapsuleWindow { } pub(crate) fn hide(&self) -> tauri::Result<()> { + let _ = self.restore_translation_menu(None); self.state.capsule_visible.store(false, Ordering::SeqCst); self.stop_layout_watch(); self.hide_transcript_overlay(); @@ -1669,6 +1759,7 @@ impl TauriCapsuleWindow { translation_active: bool, style: CapsuleStyle, ) { + if self.state.translation_menu.lock().is_some() { return; } let Some(next) = self.layout_snapshot(window, translation_active, style) else { return; }; @@ -1723,6 +1814,7 @@ impl TauriCapsuleWindow { } pub(crate) fn show_for_recording(&self, reassert_spaces: bool) { + if self.state.qa_voice_active.load(Ordering::SeqCst) { let _ = self.hide(); return; } self.state.capsule_visible.store(true, Ordering::SeqCst); if let Some(window) = self.window() { show_capsule_window_for_recording(&self.app, &window, reassert_spaces); @@ -1822,6 +1914,9 @@ impl TauriCapsuleWindow { style: CapsuleStyle, reassert_spaces: bool, ) { + if payload.state != CapsuleState::Recording || !payload.translation { + let _ = self.restore_translation_menu(None); + } self.state.cache_style(style); let Some(window) = self.window() else { return; @@ -1831,7 +1926,7 @@ impl TauriCapsuleWindow { let fallback_card_active = self.state.defer_if_fallback_active(payload); { - let action = capsule_window_action(fallback_card_active, show_capsule, payload.state); + let action = capsule_window_action(fallback_card_active, show_capsule, payload.state, self.state.qa_voice_active.load(Ordering::SeqCst)); if action == CapsuleWindowAction::PreserveFallbackCard { log::debug!( "[capsule] native window update deferred: insert fallback card owns the window" @@ -2044,6 +2139,15 @@ impl TauriCoordinatorHost { }) } + pub(crate) fn set_qa_voice_active(&self, active: bool) { + if self.capsule.qa_voice_active.swap(active, Ordering::SeqCst) == active { return; } + if active { + if let Some(capsule) = self.capsule_window() { + let _ = capsule.run_on_main_thread(|capsule| { let _ = capsule.hide(); }); + } + } + } + pub(crate) fn cached_capsule_style(&self) -> CapsuleStyle { self.capsule.cached_style() } @@ -2510,6 +2614,26 @@ mod tests { ); } + #[test] + fn dedicated_qa_voice_hides_all_shared_capsule_presentations() { + for state in [CapsuleState::Recording, CapsuleState::Transcribing, CapsuleState::Polishing, CapsuleState::Done, CapsuleState::Error, CapsuleState::Idle] { + for fallback in [true, false] { + assert_eq!(capsule_window_action(fallback, true, state, true), CapsuleWindowAction::HideCapsule); + } + } + assert_eq!(capsule_window_action(false, true, CapsuleState::Recording, false), CapsuleWindowAction::ShowCapsule); + } + + #[test] + fn language_menu_expands_upward_and_preserves_the_bottom_at_each_scale() { + for (y,height,top,scale) in [(600,172,24,1.0), (1200,344,48,2.0), (-200,172,-1000,1.0), (100,172,24,1.0)] { + let (expanded_y, expanded_height) = super::capsule_menu_geometry(y,height,top,scale); + assert!(expanded_y >= top); + assert_eq!(expanded_y + expanded_height as i32, y + height as i32); + assert!(expanded_height >= height); + } + } + #[test] fn fallback_card_owns_native_window_until_dismissed() { for state in [ @@ -2519,7 +2643,7 @@ mod tests { CapsuleState::Done, ] { assert_eq!( - capsule_window_action(true, true, state), + capsule_window_action(true, true, state, false), CapsuleWindowAction::PreserveFallbackCard ); } @@ -2528,15 +2652,15 @@ mod tests { #[test] fn capsule_window_action_follows_visibility_without_fallback_card() { assert_eq!( - capsule_window_action(false, true, CapsuleState::Recording), + capsule_window_action(false, true, CapsuleState::Recording, false), CapsuleWindowAction::ShowCapsule ); assert_eq!( - capsule_window_action(false, true, CapsuleState::Idle), + capsule_window_action(false, true, CapsuleState::Idle, false), CapsuleWindowAction::HideCapsule ); assert_eq!( - capsule_window_action(false, false, CapsuleState::Recording), + capsule_window_action(false, false, CapsuleState::Recording, false), CapsuleWindowAction::HideCapsule ); } @@ -2561,14 +2685,14 @@ mod tests { classic.top_offset, classic.gap ), - (460.0, 52.0, 44.0, 8.0) + (460.0, 52.0, 64.0, 8.0) ); assert_eq!( capsule_transcript_rail_position(CapsuleStyle::Classic, true, true), Some(CapsuleTranscriptRailPosition { width: 460.0, - height: 82.0, - top_offset: 14.0, + height: 88.0, + top_offset: 28.0, gap: 8.0, }) ); @@ -2601,7 +2725,7 @@ mod tests { capsule_transcript_rail_position(CapsuleStyle::Classic, true, false).unwrap(); assert_eq!( (classic_badge.height, classic_badge.top_offset), - (22.0, 74.0) + (28.0, 88.0) ); let typeless_badge = capsule_transcript_rail_position(CapsuleStyle::Typeless, true, false).unwrap(); @@ -2619,18 +2743,18 @@ mod tests { assert_eq!( capsule_control_hit_rect(CapsuleStyle::Classic, true, false, 460, 172), Some(CapsuleHitTestRect { - left: 132, - right: 328, - top: 104, + left: 172, + right: 288, + top: 124, bottom: 156, }) ); assert_eq!( capsule_control_hit_rect(CapsuleStyle::Classic, false, false, 460, 172), Some(CapsuleHitTestRect { - left: 132, - right: 328, - top: 104, + left: 172, + right: 288, + top: 124, bottom: 156, }) ); diff --git a/openless-all/app/src-tauri/src/tauri_events.rs b/openless-all/app/src-tauri/src/tauri_events.rs index f40dd3851..25268c57b 100644 --- a/openless-all/app/src-tauri/src/tauri_events.rs +++ b/openless-all/app/src-tauri/src/tauri_events.rs @@ -113,6 +113,16 @@ pub(crate) fn publish( backend.event_publisher().publish(session_id, kind); } +fn qa_owns_voice_surface(phase: openless_core::QaPhase) -> bool { + !cfg!(mobile) + && matches!( + phase, + openless_core::QaPhase::Recording + | openless_core::QaPhase::Thinking + | openless_core::QaPhase::AwaitingApproval + ) +} + async fn forward_legacy_event( app: &AppHandle, backend: &OpenLessBackend, @@ -124,6 +134,20 @@ async fn forward_legacy_event( kind, BackendEventKind::QaLevel(_) | BackendEventKind::QaState(_) ) { + if let Ok(qa) = backend.services().qa.snapshot().await { + if let Some(coordinator) = app.try_state::>() { + coordinator + .tauri_host() + .set_qa_voice_active(qa_owns_voice_surface(qa.phase)); + } + } + } + if cfg!(mobile) + && matches!( + kind, + BackendEventKind::QaLevel(_) | BackendEventKind::QaState(_) + ) + { let selection = backend.services().selection.snapshot().await.ok(); let selection_voice = backend.services().selection_voice.snapshot().await.ok(); // Read QA after the other async snapshots. Queued levels/terminals from @@ -138,7 +162,7 @@ async fn forward_legacy_event( selection_voice.map(|snapshot| snapshot.phase), ); let previous_voice = capsule_owners.qa_voice; - if let Some(payload) = qa_capsule_payload( + if let Some(payload) = mobile_qa_capsule_payload( capsule_owners, session_id, &kind, @@ -387,14 +411,21 @@ async fn forward_legacy_event( }); // Recheck the current owner after awaiting QA. A queued callback // from a cancelled native operation must not replace a new capsule. - if let Some(payload) = transcription_notice_payload( - session_id, - ¬ice.message, - &backend.snapshot().dictation, - qa.as_ref(), - less_voice.as_ref(), - backend.get_preferences().capsule_style, - ) { + let qa_notice = + session_id.is_some() && qa.as_ref().is_some_and(|qa| qa.session_id == session_id); + if let Some(payload) = (!qa_notice || cfg!(mobile)) + .then(|| { + transcription_notice_payload( + session_id, + ¬ice.message, + &backend.snapshot().dictation, + qa.as_ref(), + less_voice.as_ref(), + backend.get_preferences().capsule_style, + ) + }) + .flatten() + { if let Some(coordinator) = app.try_state::>() { if let Some(owner) = session_id.filter(|id| { qa.as_ref() @@ -635,7 +666,7 @@ fn finish_qa_transcription_notice( } } -fn qa_capsule_payload( +fn mobile_qa_capsule_payload( owners: &mut CapsuleOwners, session_id: Option, event: &BackendEventKind, @@ -861,6 +892,26 @@ fn resync_domain_events( mod tests { use super::*; + #[test] + fn desktop_qa_owns_one_surface_until_voice_processing_finishes() { + use openless_core::QaPhase; + for phase in [ + QaPhase::Recording, + QaPhase::Thinking, + QaPhase::AwaitingApproval, + ] { + assert_eq!(super::qa_owns_voice_surface(phase), !cfg!(mobile)); + } + for phase in [ + QaPhase::Idle, + QaPhase::Completed, + QaPhase::Failed, + QaPhase::Cancelled, + ] { + assert!(!super::qa_owns_voice_surface(phase)); + } + } + #[test] fn qa_native_epoch_is_not_reacquired_by_notice_or_resync_after_rejection() { let session_id = SessionId::new(); @@ -909,7 +960,7 @@ mod tests { } #[test] - fn qa_voice_events_restore_the_capsule_lifecycle() { + fn mobile_qa_voice_events_restore_the_capsule_lifecycle() { use openless_core::{QaPhase, QaStateKind}; let session_id = SessionId::new(); let mut qa = QaSnapshot { @@ -918,7 +969,7 @@ mod tests { ..Default::default() }; let mut owners = CapsuleOwners::default(); - let payload = qa_capsule_payload( + let payload = mobile_qa_capsule_payload( &mut owners, Some(session_id), &BackendEventKind::QaState(openless_core::QaStateEvent::from_snapshot(&qa)), @@ -926,12 +977,12 @@ mod tests { false, CapsuleStyle::Classic, ) - .expect("ordinary QA recording must show the existing capsule"); + .expect("mobile QA recording uses the overlay capsule"); assert_eq!(payload.state, CapsuleState::Recording); assert!(payload.warming); assert_eq!(owners.qa_voice, Some(session_id)); for expected_level in [0.0, 0.35] { - let level = qa_capsule_payload( + let level = mobile_qa_capsule_payload( &mut owners, Some(session_id), &BackendEventKind::QaLevel(openless_core::QaRecordingLevel { @@ -984,7 +1035,7 @@ mod tests { if kind == QaStateKind::Thinking { owners.transcription_notice = Some(session_id); } - let payload = qa_capsule_payload( + let payload = mobile_qa_capsule_payload( &mut owners, Some(session_id), &BackendEventKind::QaState(event), @@ -1032,7 +1083,7 @@ mod tests { QaStateKind::Idle, ] { assert!( - qa_capsule_payload( + mobile_qa_capsule_payload( &mut owners, Some(old), &BackendEventKind::QaState(openless_core::QaStateEvent::simple(kind)), @@ -1046,7 +1097,7 @@ mod tests { assert_eq!(owners.qa_voice, Some(current)); assert_eq!(owners.transcription_notice, Some(current)); } - assert!(qa_capsule_payload( + assert!(mobile_qa_capsule_payload( &mut owners, Some(old), &BackendEventKind::QaLevel(openless_core::QaRecordingLevel { @@ -1079,7 +1130,7 @@ mod tests { assert!(blocked); owners.qa_voice = Some(current); assert!( - qa_capsule_payload( + mobile_qa_capsule_payload( &mut owners, Some(current), &BackendEventKind::QaState(openless_core::QaStateEvent::from_snapshot( @@ -1094,7 +1145,7 @@ mod tests { ); assert_eq!(owners.qa_voice, None); assert!( - qa_capsule_payload( + mobile_qa_capsule_payload( &mut owners, Some(current), &BackendEventKind::QaLevel(openless_core::QaRecordingLevel { @@ -1110,7 +1161,7 @@ mod tests { ); assert_eq!(owners.qa_voice, None); assert!( - qa_capsule_payload( + mobile_qa_capsule_payload( &mut owners, Some(current), &BackendEventKind::QaLevel(openless_core::QaRecordingLevel { @@ -1126,7 +1177,7 @@ mod tests { ); assert_eq!(owners.qa_voice, None); assert!( - qa_capsule_payload( + mobile_qa_capsule_payload( &mut owners, Some(current), &BackendEventKind::QaState(openless_core::QaStateEvent::from_snapshot( @@ -1160,7 +1211,7 @@ mod tests { ] { qa.phase = phase; assert!( - qa_capsule_payload( + mobile_qa_capsule_payload( &mut owners, Some(session_id), &BackendEventKind::QaState(openless_core::QaStateEvent::from_snapshot(&qa)), @@ -1174,7 +1225,7 @@ mod tests { } owners.qa_voice = Some(SessionId::new()); qa.phase = QaPhase::Thinking; - let idle = qa_capsule_payload( + let idle = mobile_qa_capsule_payload( &mut owners, Some(session_id), &BackendEventKind::QaState(openless_core::QaStateEvent::from_snapshot(&qa)), @@ -1188,7 +1239,7 @@ mod tests { qa.phase = QaPhase::Recording; assert_eq!( - qa_capsule_payload( + mobile_qa_capsule_payload( &mut owners, Some(session_id), &BackendEventKind::QaState(openless_core::QaStateEvent::from_snapshot(&qa)), @@ -1202,7 +1253,7 @@ mod tests { ); qa.phase = QaPhase::Thinking; assert_eq!( - qa_capsule_payload( + mobile_qa_capsule_payload( &mut owners, Some(session_id), &BackendEventKind::QaState(openless_core::QaStateEvent::from_snapshot(&qa)), @@ -1217,7 +1268,7 @@ mod tests { let closed = QaSnapshot::default(); assert_eq!( - qa_capsule_payload( + mobile_qa_capsule_payload( &mut owners, None, &BackendEventKind::QaState(openless_core::QaStateEvent::simple(QaStateKind::Idle)), diff --git a/openless-all/app/src-tauri/src/tls_crypto.rs b/openless-all/app/src-tauri/src/tls_crypto.rs new file mode 100644 index 000000000..d0116acea --- /dev/null +++ b/openless-all/app/src-tauri/src/tls_crypto.rs @@ -0,0 +1,17 @@ +pub(crate) fn install_tls_crypto_provider() { + // Select the process provider before any HTTP or WebSocket client is built, + // including on mobile. Repeated installation leaves the existing default intact. + let _ = rustls::crypto::ring::default_provider().install_default(); +} + +#[cfg(all(test, not(any(target_os = "android", target_os = "ios"))))] +mod tests { + #[test] + fn tls_provider_install_allows_client_config() { + super::install_tls_crypto_provider(); + super::install_tls_crypto_provider(); + let _ = rustls::ClientConfig::builder() + .with_root_certificates(rustls::RootCertStore::empty()) + .with_no_client_auth(); + } +} diff --git a/openless-all/app/src-tauri/src/unicode_keystroke.rs b/openless-all/app/src-tauri/src/unicode_keystroke.rs index 6c103cb88..24ba0a41a 100644 --- a/openless-all/app/src-tauri/src/unicode_keystroke.rs +++ b/openless-all/app/src-tauri/src/unicode_keystroke.rs @@ -1,30 +1,30 @@ -//! 跨平台 Unicode keystroke 合成(流式输入用)。 +//! Platform-specific Unicode text injection for streaming insertion. //! -//! 公开 API 三件套: -//! - `type_unicode_chunk(text)` —— 阻塞地把一段文字逐 codepoint 当作键盘事件发出去, -//! 不动剪贴板。各平台用各自的原语;返回确认成功发送的字符数。 -//! - `switch_to_ascii(app)` —— 仅 macOS 有效;切到 ABC 输入源以绕过 CJK / 日文 IME -//! 对 Unicode 字符串事件的拦截。Windows 上是 no-op。 -//! - `restore_input_source(app, prev)` —— 配对调用,恢复 macOS 上的原输入源。 +//! Public operations: +//! - `type_unicode_chunk(text)` sends bounded text events without changing the +//! clipboard and returns the number of characters successfully delivered. +//! - `switch_to_ascii(app)` temporarily selects a direct input source so an active +//! input method cannot consume injected text. It is a no-op where unnecessary. +//! - `restore_input_source(app, prev)` restores the previously selected input source. //! //! ## Platform differences //! -//! - **macOS**:手写 CGEvent FFI(与 `insertion.rs::macos` 的 Cmd+V 同源)。 -//! `CGEventKeyboardSetUnicodeString` 在 CJK / 日文 IME 激活时被拦截 —— -//! 必须 `switch_to_ascii` 切到 ABC,session 结束再 `restore_input_source` 切回。 -//! - **Windows**:`SendInput(KEYEVENTF_UNICODE)` 直接发 UTF-16 scancode。TSF 不拦 -//! Unicode 事件(与 keyboard layout / IME 解耦),所以不需要切输入法。 +//! - The event-based implementation temporarily bypasses text composition and +//! restores the input source when the insertion session ends. +//! - The scan-code implementation sends UTF-16 units directly, independently of +//! keyboard layout and input-method composition. //! //! ## Known pitfalls (macOS) //! -//! - Under Secure Event Input (password fields, 1Password, ...) CGEventPost +//! - Under Secure Event Input, such as credential fields, CGEventPost //! fails silently; `type_unicode_chunk` probes with //! `IsSecureEventInputEnabled` up front and returns //! `TypeError::SecureInputActive` when set. //! - Modifier state inheritance — a held Shift maps characters to uppercase; //! every event sets `CGEventSetFlags(_, 0)` explicitly. -//! - Chromium / Electron / Tauri themselves drop characters when -//! keyDown/keyUp have no delay; sleeps 1ms per codepoint. +//! - Per-character Space events can trigger asynchronous smart punctuation in +//! WebKit, reordering spaces or replacing text. Send bounded text runs instead +//! (at most 20 UTF-16 units), preserving real newline keystrokes and event pacing. //! //! ## Thread safety (macOS) //! @@ -163,30 +163,150 @@ mod macos_impl { if is_secure_input_enabled() { return Err(TypeError::SecureInputActive); } + // Per-batch re-assertion of the ASCII input source (streaming only). + // Activating the target app / field can make macOS re-apply that + // document's own input source ("Automatically switch to a document's + // input source"), silently undoing the session-start `switch_to_ascii`. + run_ascii_guard(); + type_unicode_chunk_with_sender(text, newline_mode, |chunk, kind| match kind { + MacKeystroke::ShiftReturn => send_shift_return(), + MacKeystroke::LineFeed => send_line_feed(), + MacKeystroke::Return => send_return(), + MacKeystroke::Swallow => Ok(()), + MacKeystroke::Unicode => send_unicode_text(chunk), + }) + } + + pub(super) fn type_unicode_chunk_with_sender( + text: &str, + newline_mode: MacosNewlineMode, + mut send: impl FnMut(&str, MacKeystroke) -> Result<(), TypeError>, + ) -> Result { let mut typed_chars = 0; - for ch in text.chars() { - let sent = match classify_mac_keystroke(ch, newline_mode) { - MacKeystroke::ShiftReturn => send_shift_return(), - MacKeystroke::LineFeed => send_line_feed(), - MacKeystroke::Return => send_return(), - // Swallowed chars still count: the caller - // (`flush_streaming_insert_buffer_with`) compares `typed_chars` - // against `delta.chars().count()`, and any shortfall is judged - // a partial failure that drops all later deltas. The count - // means "this char was processed", not "one more character on - // screen". - MacKeystroke::Swallow => Ok(()), - MacKeystroke::Unicode => send_one_codepoint(ch), - }; - if let Err(e) = sent { + let mut remaining = text; + while let Some(ch) = remaining.chars().next() { + let kind = classify_mac_keystroke(ch, newline_mode); + let mut end = ch.len_utf8(); + if kind == MacKeystroke::Unicode { + let run_end = remaining.find(['\r', '\n']).unwrap_or(remaining.len()); + let run = &remaining[..run_end]; + let mut units = 0; + end = 0; + for ch in run.chars() { + if units + ch.len_utf16() > MAX_UNICODE_EVENT_UNITS { + break; + } + units += ch.len_utf16(); + end += ch.len_utf8(); + } + // Do not leave a lone space event at a chunk boundary. WebKit + // asynchronously checks such events for double-space → period, + // which can overtake later text and replace unrelated characters. + if &run[end..] == " " { + let last = run[..end].chars().next_back().expect("nonempty Unicode chunk"); + end -= last.len_utf8(); + } + } + let chunk = &remaining[..end]; + if let Err(e) = send(chunk, kind) { return Err(partial_or_original(typed_chars, e)); } - typed_chars += 1; - std::thread::sleep(INTER_KEYSTROKE_DELAY); + // Count source Unicode scalars, including swallowed CRs, not UTF-16 + // units or posted events. Preserve the exact prefix on failure. + typed_chars += chunk.chars().count(); + remaining = &remaining[end..]; + // An unavoidable single space (e.g. a final trailing space) needs + // time for the text system's async substitution check to settle. + std::thread::sleep(if chunk == " " { + Duration::from_millis(25) + } else { + INTER_KEYSTROKE_DELAY + }); } Ok(typed_chars) } + // CGEvent's Unicode payload is effectively limited to 20 UTF-16 units. + // Longer strings are silently truncated by some native text clients. + const MAX_UNICODE_EVENT_UNITS: usize = 20; + + /// Process-wide hook installed by the streaming session after it switched + /// to ABC; `None` outside streaming so one-shot typing is unaffected. + static ASCII_GUARD: parking_lot::Mutex>> = + parking_lot::Mutex::new(None); + + pub(super) fn run_ascii_guard() { + let guard = ASCII_GUARD.lock().clone(); + if let Some(guard) = guard { + guard(); + } + } + + /// Install (`Some`) or remove (`None`) the per-batch ASCII input source + /// guard. Pair with `switch_to_ascii` / `restore_input_source`. + pub fn set_streaming_ascii_guard(app: Option>) { + install_guard(app.map(|app| { + std::sync::Arc::new(move || ensure_ascii_blocking(&app)) + as std::sync::Arc + })); + } + + pub(super) fn install_guard(guard: Option>) { + *ASCII_GUARD.lock() = guard; + } + + /// Runs the check on the main thread (TIS requirement) and waits briefly. + /// Never blocks typing for long and never fails the write: a timeout only + /// skips the guard for this batch. + fn ensure_ascii_blocking(app: &AppHandle) { + let (tx, rx) = std::sync::mpsc::channel(); + if app + .run_on_main_thread(move || { + let _ = tx.send(unsafe { ensure_ascii_on_main() }); + }) + .is_err() + { + return; + } + if let Ok(true) = rx.recv_timeout(Duration::from_millis(250)) { + // Give the target app's text-input context a moment to adopt the + // new source before the next keystroke arrives. + std::thread::sleep(Duration::from_millis(30)); + } + } + + /// Returns true when the current source was not ASCII-capable and ABC was + /// re-selected. + unsafe fn ensure_ascii_on_main() -> bool { + let current = TISCopyCurrentKeyboardInputSource(); + if current.is_null() { + return false; + } + let ascii = { + let value = TISGetInputSourceProperty(current, kTISPropertyInputSourceIsASCIICapable); + !value.is_null() && CFBooleanGetValue(value) != 0 + }; + CFRelease(current as _); + if ascii { + return false; + } + match switch_to_ascii_on_main() { + Ok(prev) => { + if let Some(p) = prev { + CFRelease(p.raw as *const _); + } + log::warn!( + "[unicode-keystroke] input source drifted off ASCII during streaming; re-selected ABC" + ); + true + } + Err(error) => { + log::warn!("[unicode-keystroke] ASCII input source re-select failed: {error}"); + false + } + } + } + fn partial_or_original(typed_chars: usize, source: TypeError) -> TypeError { if typed_chars == 0 { source @@ -198,14 +318,17 @@ mod macos_impl { } } - fn send_one_codepoint(ch: char) -> Result<(), TypeError> { - let mut buf = [0u16; 2]; - let utf16 = ch.encode_utf16(&mut buf); - // Virtual keycode 0 + Unicode string override: the character is fully - // determined by the unicode string, the keycode is irrelevant. - // Flags are explicitly cleared — a held Shift would otherwise map the - // character to uppercase. - post_key_event(0, 0, Some(utf16)) + fn send_unicode_text(text: &str) -> Result<(), TypeError> { + let mut buf = [0u16; MAX_UNICODE_EVENT_UNITS]; + let mut len = 0; + for unit in text.encode_utf16() { + buf[len] = unit; + len += 1; + } + // Deliver a short text run atomically rather than simulated individual + // typing. This keeps list indentation literal and avoids macOS smart + // punctuation racing later characters in WebKit-based input controls. + post_key_event(0, 0, Some(&buf[..len])) } /// Sends one Shift+Return. Uses the real Return virtual keycode @@ -215,7 +338,7 @@ mod macos_impl { } fn send_line_feed() -> Result<(), TypeError> { - send_one_codepoint('\n') + send_unicode_text("\n") } /// Sends one Return with no modifiers. This equals "send" in chat boxes — @@ -228,9 +351,9 @@ mod macos_impl { /// Constructs and posts a down/up keyboard event pair; owns all CF resource /// release. /// - /// When `unicode` is `Some`, `CGEventKeyboardSetUnicodeString` overrides the - /// character content (`virtual_key` is then meaningless); when `None`, the - /// event presses the physical `virtual_key`. + /// When `unicode` is `Some`, the payload supplies the text. Clients that + /// ignore it may still interpret the physical key; the session therefore + /// selects an ASCII input source. With `None`, press the physical key. fn post_key_event( virtual_key: CGKeyCode, flags: CGEventFlags, @@ -401,6 +524,7 @@ mod macos_impl { #[link(name = "CoreFoundation", kind = "framework")] extern "C" { fn CFRelease(cf: *const c_void); + fn CFBooleanGetValue(boolean: *const c_void) -> u8; fn CFStringCreateWithCString( alloc: CFAllocatorRef, c_str: *const i8, @@ -414,6 +538,8 @@ mod macos_impl { fn TISCopyCurrentKeyboardInputSource() -> TISInputSourceRef; fn TISCopyInputSourceForLanguage(lang: CFStringRef) -> TISInputSourceRef; fn TISSelectInputSource(source: TISInputSourceRef) -> i32; + fn TISGetInputSourceProperty(source: TISInputSourceRef, key: CFStringRef) -> *const c_void; + static kTISPropertyInputSourceIsASCIICapable: CFStringRef; } } @@ -439,13 +565,13 @@ mod windows_impl { /// Electron / Tauri themselves drop characters when keyDown/keyUp have no delay"). Windows /// had no such gap — characters within a chunk were sent back-to-back with zero spacing, /// only pausing every [`SENDINPUT_CHUNK_CHARS`]. Observed on hardware: dictating mixed - /// Chinese/English text ("我要把这个 open list...") dropped the leading character(s) of the + /// Mixed-script text could drop the leading characters of the /// Latin-script word right after the CJK→ASCII transition (e.g. "open" arrived as "pen"). /// 1ms is inaudible/invisible to the user but gives the target app's message loop room to /// process each keystroke before the next one lands. const INTER_KEYSTROKE_DELAY: Duration = Duration::from_millis(1); - /// Windows 上没有 input source 概念,token 留空。Send/Sync 自动派生。 + /// This platform does not switch input sources, so the restoration token is empty. pub struct PreviousInputSource; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -859,6 +985,96 @@ mod tests { } } + /// A send failure keeps the exact consumed prefix; swallowed `\r` is + /// counted without producing a keystroke. + #[test] + #[cfg(target_os = "macos")] + fn mac_sender_seam_reports_consumed_prefix() { + use super::macos_impl::{type_unicode_chunk_with_sender, MacKeystroke}; + use crate::types::MacosNewlineMode; + + let mut sent = Vec::new(); + let n = type_unicode_chunk_with_sender("胶囊\r样式", MacosNewlineMode::Auto, |ch, kind| { + if kind != MacKeystroke::Swallow { + sent.push(ch.to_string()); + } + Ok(()) + }) + .unwrap(); + assert_eq!(n, 5); + assert_eq!(sent.concat(), "胶囊样式"); + + let mut calls = 0; + let err = type_unicode_chunk_with_sender("胶\r囊样式", MacosNewlineMode::Auto, |_, _| { + calls += 1; + if calls == 3 { + Err(TypeError::SecureInputActive) + } else { + Ok(()) + } + }) + .unwrap_err(); + assert_eq!(err.typed_chars(), 2); + } + + #[test] + #[cfg(target_os = "macos")] + fn unicode_runs_preserve_indentation_and_utf16_boundaries() { + use super::macos_impl::{type_unicode_chunk_with_sender, MacKeystroke}; + use crate::types::MacosNewlineMode; + for text in [ + "1. 变更说明\n (a) 相比上一个 Beta 版本", + "1234567890123456789😀 尾部", + "12345678901234567890 ", + " ", + ] { + let mut received = String::new(); + let count = type_unicode_chunk_with_sender(text, MacosNewlineMode::Auto, |chunk, kind| { + assert!(chunk.encode_utf16().count() <= 20); + assert_ne!(chunk, " ", "do not isolate spaces from their text run"); + if kind == MacKeystroke::Unicode { assert!(!chunk.contains('\n')); } + received.push_str(chunk); + Ok(()) + }).unwrap(); + assert_eq!(received, text); + assert_eq!(count, text.chars().count()); + } + } + + #[test] + #[cfg(target_os = "macos")] + fn partial_unicode_run_counts_scalars_not_utf16_units() { + use super::macos_impl::type_unicode_chunk_with_sender; + use crate::types::MacosNewlineMode; + let text = format!("{}tail", "😀".repeat(10)); + let mut calls = 0; + let error = type_unicode_chunk_with_sender(&text, MacosNewlineMode::Auto, |_, _| { + calls += 1; + if calls == 2 { Err(TypeError::EventAllocFailed) } else { Ok(()) } + }).unwrap_err(); + assert_eq!(error.typed_chars(), 10); + } + + /// The per-batch ASCII guard runs when installed and not after removal. + #[test] + #[cfg(target_os = "macos")] + fn streaming_ascii_guard_runs_only_while_installed() { + use super::macos_impl::{install_guard, run_ascii_guard}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let hits = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&hits); + install_guard(Some(Arc::new(move || { + counter.fetch_add(1, Ordering::SeqCst); + }))); + run_ascii_guard(); + run_ascii_guard(); + install_guard(None); + run_ascii_guard(); + assert_eq!(hits.load(Ordering::SeqCst), 2); + } + #[test] fn type_error_partial_reports_typed_chars() { let err = TypeError::Partial { @@ -941,8 +1157,8 @@ pub fn expected_sendinput_typed_chars(text: &str) -> usize { #[cfg(target_os = "macos")] #[allow(unused_imports)] pub use macos_impl::{ - is_secure_input_enabled, restore_input_source, switch_to_ascii, type_unicode_chunk, - type_unicode_chunk_with_options, PreviousInputSource, + is_secure_input_enabled, restore_input_source, set_streaming_ascii_guard, switch_to_ascii, + type_unicode_chunk, type_unicode_chunk_with_options, PreviousInputSource, }; #[cfg(target_os = "windows")] diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index b479a081e..5a2322fcb 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -1,20 +1,18 @@ -import { Icon } from './Icon'; -import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { detectOS, type OS } from './WindowChrome'; import { warmUpSiriShaders } from './SiriGL'; +import { useReducedMotion } from '../lib/motion'; import { VoiceOrbStage } from './VoiceOrbStage'; -import { TypelessCapsule } from './TypelessCapsule'; +import { DEFAULT_CAPSULE_EXIT_MS, OpenLessPill } from './OpenLessCapsule'; import { CapsuleTranscriptRail, CapsuleTranslationBadge } from './LiveTranscriptPill'; import { capsuleTranscriptFontSize, visibleCapsuleTranscript } from '../lib/capsuleTranscript'; import { getSettings } from '../lib/ipc/settings'; import { cancelDictation, setCapsuleTranscriptVisible, stopDictation } from '../lib/ipc/dictation'; import { getCapsuleHostMetrics, - getCapsuleMessageLayout, getCapsulePillMetrics, getClassicCapsuleGeometry, - getClassicProcessingLabel, getCapsuleTranscriptRailHeight, parseCapsuleStyle, } from '../lib/capsuleLayout'; @@ -64,17 +62,7 @@ const CAPSULE_KEYFRAMES = ` @keyframes selection-polish-spinner { to { transform: rotate(360deg); } } - /* Classic pill (Openless default style) only: - - cap-shine: blue light sweep over the "thinking" text during transcribe/polish; - - cap-state-enter: fade-in when state content switches. */ - @keyframes cap-shine { - 0% { background-position: 200% center; } - 100% { background-position: -200% center; } - } - @keyframes cap-state-enter { - from { opacity: 0; transform: translateY(2px); } - to { opacity: 1; transform: translateY(0); } - } + `; if (typeof document !== 'undefined' && !document.getElementById('capsule-keyframes')) { @@ -187,324 +175,8 @@ function SelectionPolishNotice({ state, message }: SelectionPolishNoticeProps) { } // ───────── Classic pill (Openless default style) ──────────────────────── -// Restored from the 1.3.14 capsule: frosted pill + volume bars + cancel/confirm -// buttons. Sizes match the capsuleLayout constants of that release (the Siri orb -// stage is full-bleed 460×180; the classic pill is a small centered pill). - -interface ClassicPillMetrics { - width: number; - height: number; - textWidth: number; -} - -const CLASSIC_PILL_METRICS: ClassicPillMetrics = { - width: 176, - height: 42, - textWidth: 84, -}; -const CLASSIC_PILL_METRICS_WIN: ClassicPillMetrics = { - width: 196, - height: 52, - textWidth: 104, -}; - -function classicPillMetrics(os: OS): ClassicPillMetrics { - return os === 'win' ? CLASSIC_PILL_METRICS_WIN : CLASSIC_PILL_METRICS; -} - -function AudioBars({ level, compact = false }: { level: number; compact?: boolean }) { - const envelope = [0.55, 0.85, 1.0, 0.85, 0.55]; - const base = 2; - const max = compact ? 16 : 24; - const voice = Math.min(1, Math.max(0, level)); - const silenceGate = 0.012; - const responseCeiling = 0.34; - const gatedVoice = Math.min( - 1, - Math.max(0, (voice - silenceGate) / (responseCeiling - silenceGate)), - ); - const easedVoice = gatedVoice * gatedVoice * (3 - 2 * gatedVoice); - const visualVoice = Math.pow(easedVoice, 0.42); - return ( -
- {envelope.map((env, i) => ( - - ))} -
- ); -} - -interface CenterTextProps { - os: OS; - kind: 'default' | 'processing' | 'error'; - text: string; - color?: string; -} - -function CenterText({ os, kind, text, color = 'var(--ol-capsule-center-ink)' }: CenterTextProps) { - const metrics = classicPillMetrics(os); - const layout = getCapsuleMessageLayout(os, kind); - return ( - - {text} - - ); -} - -interface CircleButtonProps { - variant: 'cancel' | 'confirm'; - enabled: boolean; - onClick: () => void; -} - -// memo: level changes every frame (~60Hz) while recording, re-rendering the pill; the -// cancel/confirm SVG buttons don't depend on level, so memo + stable onClick lets them -// skip re-renders during recording (only the volume bars actually update). -const CircleButton = memo(function CircleButton({ variant, enabled, onClick }: CircleButtonProps) { - const { t } = useTranslation(); - const isCancel = variant === 'cancel'; - return ( - - ); -}); - -interface ClassicPillProps { - os: OS; - state: CapsuleState; - level: number; - insertedChars: number; - message?: string; - operating: boolean; - onCancel: () => void; - onConfirm: () => void; -} - -function ClassicPill({ - os, - state, - level, - insertedChars, - message, - operating, - onCancel, - onConfirm, -}: ClassicPillProps) { - const { t } = useTranslation(); - const metrics = classicPillMetrics(os); - const processingLayout = useMemo(() => getCapsuleMessageLayout(os, 'processing'), [os]); - const cancelEnabled = state === 'recording' || state === 'transcribing' || state === 'polishing'; - const confirmEnabled = state === 'recording'; - - // Keep the original Classic processing treatment: the thinking/using label - // has its own entry and shine animation, while recording keeps the live bars. - const [shineFast, setShineFast] = useState(true); - useEffect(() => { - if (state === 'transcribing' || state === 'polishing') { - setShineFast(true); - const timer = setTimeout(() => setShineFast(false), 2000); - return () => clearTimeout(timer); - } - setShineFast(true); - return undefined; - }, [state]); - - let center: ReactNode; - switch (state) { - case 'recording': - center = ; - break; - case 'transcribing': - case 'polishing': - center = ( -
- - - {t(getClassicProcessingLabel(operating))} - -
- ); - break; - case 'done': - center = ( - - ); - break; - case 'cancelled': - center = ; - break; - case 'error': - center = ( - - ); - break; - default: - center = ; - } - - const ambient = state === 'recording' ? Math.min(1, Math.max(0, level)) : 0; - const scale = os === 'win' ? 1 : 1 + ambient * 0.018; - const shadowAlpha = 0.2 + ambient * 0.1; - - return ( - // Non-Linux uses fake frost; on Linux with transparent windows disabled, .ol-frost - // platform rules fall back to an opaque surface. No backdrop-filter: the webview - // can't blur the desktop behind a transparent window (Tauri upstream limitation). -
- -
- {center} -
- -
- ); -} +// Preserve the native Classic host/rail geometry; OpenLessPill owns the new +// white-and-blue recording/processing surface inside that stable host. interface ClassicCapsuleProps { os: OS; @@ -518,6 +190,8 @@ interface ClassicCapsuleProps { transcriptInSameWindow?: boolean; operating: boolean; translation: boolean; + leaving: boolean; + warming: boolean; } /** @@ -537,6 +211,8 @@ function ClassicCapsule({ transcriptInSameWindow = true, operating, translation, + leaving, + warming, }: ClassicCapsuleProps) { const hostMetrics = getCapsuleHostMetrics(os, false, 'classic'); const onCancel = useCallback(() => { @@ -560,19 +236,19 @@ function ClassicCapsule({ (translateX(-50%)) with no animation; the inner layer only does vertical shift + fade. This avoids conflicting with translateX(-50%) and avoids keyframe vs inline transform overrides causing visual jumps. */} - {transcriptInSameWindow && ( -
- -
- )} + {/* The language picker belongs to the interactive capsule, even when + Windows puts the transcript in a separate click-through rail. */} +
+ +
{liveText && ( )} - @@ -601,10 +280,9 @@ function ClassicCapsule({ // Must stay in sync with the @keyframes capsule-out duration — otherwise the timer // unmounts before the animation ends and the user sees it cut off mid-flight. Both -// styles share the same capsule-in/out keyframes with different durations: Siri orb -// 520ms (as-is), classic pill 360ms (matching 1.3.14). +// The spectral renderer exits in 520ms; the default capsule contracts in 180ms. const EXIT_ANIM_MS_SIRI = 520; -const EXIT_ANIM_MS_CLASSIC = 360; +const EXIT_ANIM_MS_CLASSIC = DEFAULT_CAPSULE_EXIT_MS; // Average capsule warm→ready duration (ms), driving the predicted pace of the entry // expand animation (see SiriGL warmProgress): EMA-updated after each recording becomes @@ -649,7 +327,7 @@ function getPreviewCapsulePayload() { translation: false, warming: false, selectionPolish: false, - style: 'siri' as CapsuleStyle, + style: 'classic' as CapsuleStyle, insertDemo: false, }; } @@ -668,7 +346,7 @@ function getPreviewCapsulePayload() { translation: params.get('translation') === '1', warming: params.get('warming') === '1', selectionPolish: params.get('selectionPolish') === '1', - style: parseCapsuleStyle(params.get('style')) ?? 'siri', + style: parseCapsuleStyle(params.get('style')) ?? 'classic', insertDemo, }; } @@ -681,6 +359,7 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { const { t } = useTranslation(); const os = forcedOs ?? detectOS(); const preview = useMemo(() => getPreviewCapsulePayload(), []); + const reducedMotion = useReducedMotion(); const metrics = getCapsulePillMetrics(os); const [state, setState] = useState(preview.state); const [level, setLevel] = useState(preview.level); @@ -705,7 +384,6 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { const [capsuleStyle, setCapsuleStyle] = useState(preview.style); const stylePreferenceReadyRef = useRef(false); const isClassic = capsuleStyle === 'classic'; - const isTypeless = capsuleStyle === 'typeless'; // Windows keeps the transcript in the dedicated capsule-rail HWND. The browser // preview and other platforms continue rendering the sibling rail in this tree. const splitTranscriptRail = isTauri && os === 'win'; @@ -1084,7 +762,7 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { } // Truly unmounted: state is idle and no exit animation is running. - if (state === 'idle' && !leaving) { + if (state === 'idle' && lastVisibleState === 'idle' && !leaving) { return
; } @@ -1105,13 +783,17 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { background: preview.insertDemo ? 'radial-gradient(ellipse at 50% 35%, #4d9a4a 0%, #1a4a22 42%, #0e2414 100%)' : 'transparent', - animation: leaving - ? `capsule-out ${exitMs}ms cubic-bezier(.55,.06,.68,.19) forwards` - : // The .68s entry got "delay after keypress" feedback: cut to .38s with the - // curve keeping a slight bounce, so the orb appears almost immediately. - 'capsule-in .38s cubic-bezier(.3,1.2,.4,1) both', + animation: reducedMotion + ? 'none' + : isClassic + ? 'none' + : leaving + ? `capsule-out ${exitMs}ms cubic-bezier(.55,.06,.68,.19) forwards` + : // The .68s entry got "delay after keypress" feedback: cut to .38s with the + // curve keeping a slight bounce, so the orb appears almost immediately. + 'capsule-in .38s cubic-bezier(.3,1.2,.4,1) both', transformOrigin: 'center', - willChange: 'transform, opacity', + willChange: isClassic ? 'auto' : 'transform, opacity', }} > {!renderedSelectionPolish && @@ -1128,19 +810,8 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { transcriptInSameWindow={!splitTranscriptRail} operating={operatingRef.current} translation={translation} - /> - ) : isTypeless ? ( - ) : ( <> diff --git a/openless-all/app/src/components/CapsuleStyles.css b/openless-all/app/src/components/CapsuleStyles.css index 519df9a47..25862bcb5 100644 --- a/openless-all/app/src/components/CapsuleStyles.css +++ b/openless-all/app/src/components/CapsuleStyles.css @@ -47,39 +47,56 @@ .ol-classic-translation { display: inline-flex; - flex: 0 0 22px; - height: 22px; + flex: 0 0 28px; + height: 28px; box-sizing: border-box; align-items: center; - gap: 5px; - padding: 0 10px; - border-radius: 999px; - font-size: 10.5px; - font-weight: var(--ol-font-weight-semibold); - color: var(--ol-blue); - background: var(--ol-capsule-badge-bg); - border: 0.5px solid var(--ol-capsule-badge-border); + gap: 6px; + padding: 3px 4px 3px 8px; + border-radius: 6px; + font-size: 11px; + color: #2563eb; + color-scheme: light; + background: #fff; + border: 1px solid #dbeafe; box-shadow: - 0 4px 12px -4px rgb(37 99 235 / 25%), - 0 0 0 0.5px rgb(0 0 0 / 4%); - letter-spacing: 0.02em; + 0 2px 6px rgb(0 0 0 / 10%), + inset 0 -2px 4px rgb(37 99 235 / 12%); white-space: nowrap; transform-origin: center bottom; - transition: - opacity 0.24s ease-out, - transform 0.34s cubic-bezier(0.2, 0.9, 0.3, 1.1); } - -.ol-classic-translation[data-visible='false'] { - opacity: 0; - transform: translateY(40px) scale(0.88); +.ol-translation-language { + pointer-events: auto; + max-width: 174px; } - -.ol-classic-translation > span { - width: 5px; - height: 5px; - border-radius: 999px; - background: var(--ol-blue); +.ol-translation-language .ol-select-trigger { + height: 22px; + min-width: 92px; + max-width: 174px; + width: auto; + padding: 0 7px; + border-radius: 4px; + font-size: 11px; +} +.ol-translation-language[data-busy='true'] { + opacity: 0.6; +} +.ol-classic-translation[data-visible='false'] .ol-translation-language { + pointer-events: none; +} +.ol-translation-error { + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + max-width: 280px; + padding: 5px 8px; + border-radius: 6px; + background: #fff; + color: #dc2626; + font-size: 10px; + white-space: normal; + width: max-content; } .ol-capsule-transcript-overlay .ol-capsule-transcript-rail { diff --git a/openless-all/app/src/components/CapsuleTranslationBadge.tsx b/openless-all/app/src/components/CapsuleTranslationBadge.tsx new file mode 100644 index 000000000..74d7d13f6 --- /dev/null +++ b/openless-all/app/src/components/CapsuleTranslationBadge.tsx @@ -0,0 +1,231 @@ +import { motion, useReducedMotion } from 'framer-motion'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SelectLite } from './ui/SelectLite'; +import './ui/Select.css'; +import { getSettings } from '../lib/ipc/settings'; +import { isTauri } from '../lib/ipc/shared'; +import { + getDictationTranslationTarget, + setCapsuleLanguageMenu, + selectDictationTranslationTarget, + type DictationTranslationTarget, +} from '../lib/ipc/dictation'; +import { localizedLanguages, nativeLanguageName } from '../lib/languageCatalog'; +import { isTranslationTargetRedundant } from '../lib/translationTarget'; +import './CapsuleStyles.css'; + +/** The same Base UI menu as settings, with temporary native headroom above the capsule. */ +export function CapsuleTranslationBadge({ visible = true }: { visible?: boolean }) { + const { t, i18n } = useTranslation(); + const reduced = useReducedMotion(); + const [snapshot, setSnapshot] = useState(null); + const [savedTarget, setSavedTarget] = useState(''); + const [busy, setBusy] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const menuEpoch = useRef(0); + const menuOwner = useRef(null); + const closeTimer = useRef>(); + const shown = useRef(visible); + shown.current = visible; + const [error, setError] = useState(false); + const revision = useRef(0); + const pickerSession = useRef(null); + const pendingChange = useRef(null); + const current = useRef(snapshot); + current.current = snapshot; + + useEffect(() => { + let disposed = false; + let unlisten: (() => void)[] = []; + let lastRecording = ''; + const refresh = async () => { + const request = ++revision.current; + try { + const [prefs, active] = await Promise.all([getSettings(), getDictationTranslationTarget()]); + if (disposed || request !== revision.current) return; + setSavedTarget(prefs.translationTargetLanguage); + setSnapshot(active); + } catch { + if (!disposed && request === revision.current) setSnapshot(null); + } + }; + if (!visible) { + setError(false); + setBusy(false); + pickerSession.current = null; + pendingChange.current = null; + return; + } + void (async () => { + if (isTauri) { + const { listen } = await import('@tauri-apps/api/event'); + const handles = await Promise.all([ + listen('prefs:changed', () => void refresh()), + listen<{ sessionId?: string; state: string; warming?: boolean; translation?: boolean }>( + 'capsule:state', + ({ payload }) => { + const key = `${payload.sessionId}:${payload.state}:${payload.warming}:${payload.translation}`; + if (key !== lastRecording) { + lastRecording = key; + void refresh(); + } + }, + ), + ]); + if (disposed) { + handles.forEach((stop) => stop()); + return; + } + unlisten = handles; + } + await refresh(); + })().catch(() => { + if (!disposed) setSnapshot(null); + }); + return () => { + disposed = true; + revision.current++; + unlisten.forEach((stop) => stop()); + }; + }, [visible]); + + const target = nativeLanguageName(snapshot?.targetLanguage ?? savedTarget); + const languages = useMemo( + () => localizedLanguages(i18n.language, [target]), + [i18n.language, target], + ); + const selected = languages.find((language) => language.nativeName === target); + const options = languages.filter( + (language) => + language.nativeName === target || + !isTranslationTargetRedundant(language.nativeName, snapshot?.workingLanguages ?? []), + ); + const openMenu = async (next: boolean) => { + const epoch = ++menuEpoch.current; + if (closeTimer.current) clearTimeout(closeTimer.current); + if (!next) { + setMenuOpen(false); + const owner = menuOwner.current; + if (owner) + closeTimer.current = setTimeout( + () => { + if (epoch !== menuEpoch.current) return; + menuOwner.current = null; + void setCapsuleLanguageMenu(false, owner).catch(() => {}); + }, + reduced ? 0 : 160, + ); + return; + } + const active = current.current; + if (!shown.current || !active?.mutable) return; + pickerSession.current = active.sessionId; + menuOwner.current = active.sessionId; + try { + await setCapsuleLanguageMenu(true, active.sessionId); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + if ( + epoch === menuEpoch.current && + shown.current && + current.current?.sessionId === active.sessionId && + current.current.mutable + ) { + setMenuOpen(true); + } else { + await setCapsuleLanguageMenu(false, active.sessionId); + } + } catch { + if (epoch === menuEpoch.current) setError(true); + } + }; + useEffect(() => { + if ( + menuOwner.current && + (!visible || !snapshot?.mutable || snapshot.sessionId !== menuOwner.current) + ) + void openMenu(false); + }, [visible, snapshot?.mutable, snapshot?.sessionId]); + useEffect( + () => () => { + menuEpoch.current++; + if (closeTimer.current) clearTimeout(closeTimer.current); + if (menuOwner.current) void setCapsuleLanguageMenu(false, menuOwner.current).catch(() => {}); + }, + [], + ); + useEffect(() => { + const closeOnBlur = () => { + if (menuOwner.current) void openMenu(false); + }; + window.addEventListener('blur', closeOnBlur); + return () => window.removeEventListener('blur', closeOnBlur); + }, []); + const change = async (value: string) => { + const sessionId = pickerSession.current ?? snapshot?.sessionId; + pickerSession.current = null; + void openMenu(false); + if (!sessionId || pendingChange.current || !snapshot?.mutable) return; + const request = Symbol('translation-target'); + pendingChange.current = request; + setBusy(true); + setError(false); + revision.current++; + try { + const updated = await selectDictationTranslationTarget(sessionId, value); + // A native dropdown may outlive the recording which opened it. + if (current.current?.sessionId === updated.sessionId) { + setSnapshot(updated); + setSavedTarget(updated.targetLanguage); + } + } catch { + if (current.current?.sessionId === sessionId) setError(true); + } finally { + if (pendingChange.current === request) { + pendingChange.current = null; + setBusy(false); + } + } + }; + + return ( + + {t('capsule.translateTo')} +
+ ({ + value: language.nativeName, + label: language.displayName, + }))} + placeholder={selected?.displayName || target || '…'} + ariaLabel={t('translation.target.title')} + disabled={!visible || busy || !snapshot?.mutable} + open={menuOpen} + onOpenChange={(next) => void openMenu(next)} + onChange={(value) => void change(value)} + side="top" + popupStyle={{ width: 240 }} + /> +
+ {error && ( + + {t('capsule.translationChangeFailed')} + + )} +
+ ); +} diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index e229f5f46..6558c5da8 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -389,6 +389,9 @@ function FloatingShellBody({ >
); } +// Warm likely destinations on hover/focus; React.lazy shares the module cache. +function preloadPage(tab: AppTab) { + const load = + tab === 'history' + ? import('../pages/History') + : tab === 'vocab' + ? import('../pages/Vocab') + : tab === 'style' + ? import('../pages/Style') + : tab === 'marketplace' + ? import('../pages/Marketplace') + : tab === 'translation' + ? import('../pages/Translation') + : tab === 'selectionAsk' + ? import('../pages/SelectionAsk') + : null; + void load?.catch(() => {}); // The normal lazy boundary owns visible load errors. +} + const MOBILE_BOTTOM_TABS: Array<{ id: AppTab; icon: string }> = [ { id: 'overview', icon: 'overview' }, { id: 'history', icon: 'history' }, @@ -901,7 +944,8 @@ const mobileNavBtnStyle: CSSProperties = { cursor: 'default', }; -/** Base style for sidebar nav buttons (shared by flat items / group titles / children; children override paddingLeft). */ +/** Base style for sidebar nav buttons (shared by flat items / group titles / children; children override paddingLeft). + * Layout only: colour, background and cursor come from the .ol-sidebar-surface rules. */ const navBtnStyle: CSSProperties = { display: 'flex', alignItems: 'center', @@ -913,8 +957,6 @@ const navBtnStyle: CSSProperties = { // (After the Codex sidebar): 15px nav text, 16px icons — closer to the reference design. fontFamily: 'inherit', fontSize: 15, - cursor: 'default', - transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)', textAlign: 'left', }; diff --git a/openless-all/app/src/components/LiveTranscriptPill.tsx b/openless-all/app/src/components/LiveTranscriptPill.tsx index 482a6752f..1e978e962 100644 --- a/openless-all/app/src/components/LiveTranscriptPill.tsx +++ b/openless-all/app/src/components/LiveTranscriptPill.tsx @@ -29,7 +29,7 @@ import { type TranscriptViewState, } from '../lib/backendEvent'; import { capsuleTranscriptFontSize, visibleCapsuleTranscript } from '../lib/capsuleTranscript'; -import { getCapsuleTranscriptRailHeight } from '../lib/capsuleLayout'; +import { getCapsuleTranscriptRailHeight, parseCapsuleStyle } from '../lib/capsuleLayout'; import { getSettings } from '../lib/ipc/settings'; import { getCapsuleSnapshot, isTauri } from '../lib/ipc'; import type { @@ -42,15 +42,7 @@ import type { export type LiveTranscriptTone = 'frost' | 'dark'; -export function CapsuleTranslationBadge({ visible = true }: { visible?: boolean }) { - const { t } = useTranslation(); - return ( -
- - {t('capsule.translating')} -
- ); -} +export { CapsuleTranslationBadge } from './CapsuleTranslationBadge'; export interface LiveTranscriptPillProps { text: string; @@ -137,7 +129,7 @@ export function CapsuleTranscriptRail({ export function CapsuleTranscriptOverlay() { const { t } = useTranslation(); const [state, setState] = useState('idle'); - const [style, setStyle] = useState('siri'); + const [style, setStyle] = useState('classic'); const [text, setText] = useState(''); const [enabled, setEnabled] = useState(true); const [fontSize, setFontSize] = useState(14); @@ -162,7 +154,7 @@ export function CapsuleTranscriptOverlay() { transcriptRef.current = sessionTranscript; stateRef.current = payload.state; setState(payload.state); - if (payload.capsuleStyle) setStyle(payload.capsuleStyle); + if (payload.capsuleStyle) setStyle(parseCapsuleStyle(payload.capsuleStyle) ?? 'classic'); setSelectionPolish(payload.selectionPolish === true); setTranslation(payload.translation === true); if (payload.state === 'recording' && previousState !== 'recording' && !sessionChanged) { @@ -184,7 +176,8 @@ export function CapsuleTranscriptOverlay() { const preferenceHandle = await listen('prefs:changed', (event) => { setEnabled(event.payload.capsuleTranscriptEnabled ?? true); setFontSize(capsuleTranscriptFontSize(event.payload.capsuleTranscriptFontSize)); - if (event.payload.capsuleStyle) setStyle(event.payload.capsuleStyle); + if (event.payload.capsuleStyle) + setStyle(parseCapsuleStyle(event.payload.capsuleStyle) ?? 'classic'); }); const applySnapshot = ( snapshot: CapsuleSnapshot, @@ -202,7 +195,7 @@ export function CapsuleTranscriptOverlay() { const previousState = stateRef.current; stateRef.current = snapshot.state; setState(snapshot.state); - setStyle(snapshot.capsuleStyle ?? fallbackStyle); + setStyle(parseCapsuleStyle(snapshot.capsuleStyle ?? fallbackStyle) ?? 'classic'); setSelectionPolish(snapshot.selectionPolish === true); setTranslation(snapshot.translation === true); if (snapshot.state === 'recording' && previousState !== 'recording' && !sessionChanged) { @@ -242,7 +235,7 @@ export function CapsuleTranscriptOverlay() { if (!cancelled) { setEnabled(preferences.capsuleTranscriptEnabled ?? true); setFontSize(capsuleTranscriptFontSize(preferences.capsuleTranscriptFontSize)); - setStyle(preferences.capsuleStyle); + setStyle(parseCapsuleStyle(preferences.capsuleStyle) ?? 'classic'); await replaySnapshot(preferences.capsuleStyle); } } @@ -270,7 +263,6 @@ export function CapsuleTranscriptOverlay() { maxWidth={440} height={railHeight} /> - {translation && !selectionPolish && style === 'classic' && } {translation && !selectionPolish && typeless && (
{t('capsule.translating')} diff --git a/openless-all/app/src/components/OpenLessCapsule.css b/openless-all/app/src/components/OpenLessCapsule.css new file mode 100644 index 000000000..76fb05b3b --- /dev/null +++ b/openless-all/app/src/components/OpenLessCapsule.css @@ -0,0 +1,174 @@ +/* Reference geometry/motion, with OpenLess's white paper and project-blue ink. */ +.ol-default-capsule { + position: relative; + height: 32px; + flex: 0 0 auto; + isolation: isolate; + overflow: hidden; + border-radius: 999px; + color-scheme: light; + color: #2563eb; + font-family: var(--ol-font-sans); + box-shadow: 0 2px 6px rgb(0 0 0 / 14%); + transform-origin: center bottom; +} +.ol-default-capsule::before { + content: ''; + position: absolute; + inset: 0; + z-index: -2; + background: #fff; + border: 1px solid #d4d4d8; + border-radius: inherit; +} +.ol-default-capsule::after { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + background: #dbeafe; + transform: translateX(-100%); + pointer-events: none; +} +.ol-default-capsule[data-processing='true']::after { + animation: ol-capsule-sweep 2.4s linear infinite; +} +/* Reference processing fill travels left→right; it is not a pulsing spinner. */ +@keyframes ol-capsule-sweep { + 0% { + transform: translateX(-100%); + } + 85%, + 100% { + transform: translateX(0); + } +} +.ol-default-capsule[data-translation='true']::before { + box-shadow: inset 0 4px 7px -3px rgb(37 99 235 / 65%); +} +.ol-default-recording { + position: absolute; + top: 0; + left: 50%; + width: 116px; + height: 32px; + transform: translateX(-50%); + display: flex; + align-items: center; + justify-content: center; + transition: opacity 90ms linear 60ms; +} +.ol-default-recording[aria-hidden='true'] { + opacity: 0; + pointer-events: none; +} +.ol-default-action { + position: absolute; + top: 4px; + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 50%; + background: #f0f0f2; + color: #52525b; + cursor: pointer; + transition: + opacity 80ms linear, + background-color 100ms ease; +} +.ol-default-cancel { + left: 4px; +} +.ol-default-confirm { + right: 4px; + color: #fff; + background: #2563eb; +} +.ol-default-action[aria-hidden='true'] { + opacity: 0; + pointer-events: none; +} +.ol-default-capsule[data-processing='true'] .ol-default-action { + transition-delay: 50ms; +} +.ol-default-action:focus-visible, +.ol-default-processing-cancel:focus-visible { + outline: 2px solid #2563eb; + outline-offset: -2px; +} +.ol-default-action:active:not(:disabled) { + scale: 0.92; +} +@media (hover: hover) and (pointer: fine) { + .ol-default-cancel:hover:not(:disabled) { + background: #e4e4e7; + } + .ol-default-confirm:hover:not(:disabled) { + background: #1d4ed8; + } +} +.ol-default-status { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 0 14px; + pointer-events: none; + font-size: 12px; + font-weight: 500; + opacity: 1; + transition: opacity 80ms linear 120ms; +} +.ol-default-status[aria-hidden='true'] { + opacity: 0; + transition-delay: 0ms; +} +.ol-default-status > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ol-default-capsule[data-error='true'] .ol-default-status { + color: #dc2626; +} +.ol-default-processing-cancel { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + border: 0; + border-radius: inherit; + background: transparent; + cursor: pointer; +} +.ol-default-waveform { + display: flex; + align-items: center; + justify-content: center; + gap: 2px; + width: 40px; + height: 18px; +} +.ol-default-waveform > span { + width: 1.5px; + height: 1.5px; + flex: 0 0 1.5px; + border-radius: 999px; + background: #2563eb; +} +@media (prefers-reduced-motion: reduce) { + .ol-default-capsule::after { + animation: none !important; + } + .ol-default-recording, + .ol-default-action, + .ol-default-status { + transition: none; + } +} diff --git a/openless-all/app/src/components/OpenLessCapsule.tsx b/openless-all/app/src/components/OpenLessCapsule.tsx new file mode 100644 index 000000000..030135997 --- /dev/null +++ b/openless-all/app/src/components/OpenLessCapsule.tsx @@ -0,0 +1,182 @@ +import { motion } from 'framer-motion'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icon } from './Icon'; +import { VoiceOrbStage } from './VoiceOrbStage'; +import { useReducedMotion } from '../lib/motion'; +import type { OS } from './WindowChrome'; +import type { CapsuleState, CapsuleStyle } from '../lib/types'; +import './CapsuleStyles.css'; +import './OpenLessCapsule.css'; + +// Measured from the 60 fps reference: 232×64 physical pixels at 2×. +const ENVELOPE = [0.22, 0.34, 0.48, 0.65, 0.82, 1, 0.82, 0.65, 0.48, 0.34, 0.22]; +const MORPH = { duration: 0.22, ease: [0.22, 1, 0.36, 1] as const }; +export const DEFAULT_CAPSULE_EXIT_MS = 180; + +export function CapsuleWaveform({ level, active = true }: { level: number; active?: boolean }) { + const root = useRef(null); + const input = useRef(level); + input.current = level; + const reduced = useReducedMotion(); + useEffect(() => { + const bars = root.current?.children; + if (!bars) return; + let frame = 0; + let previous = 0; + let smoothed = 0; + const draw = (now: number) => { + const dt = previous ? Math.min(now - previous, 50) : 16; + previous = now; + const voice = active ? Math.min(1, Math.max(0, (input.current - 0.008) / 0.32)) : 0; + smoothed += (voice - smoothed) * (1 - Math.exp(-dt / (voice > smoothed ? 35 : 90))); + ENVELOPE.forEach((envelope, index) => { + // The centre stays strongest; neighbouring bars do not move as one rigid icon. + const ripple = reduced ? 1 : 0.86 + 0.14 * Math.sin(now / 85 + index * 0.8); + (bars[index] as HTMLElement).style.height = + `${1.5 + 14.5 * Math.pow(smoothed, 0.6) * envelope * ripple}px`; + }); + if (active && !reduced) frame = requestAnimationFrame(draw); + }; + draw(performance.now()); + return () => cancelAnimationFrame(frame); + }, [active, reduced, reduced ? level : undefined]); + return ( +
-
- - {label} - -
-
-
- - - ); -} - -/** This local preview makes a saved choice visible without starting the microphone. */ -export function CapsuleStylePreview({ style }: { style: CapsuleStyle }) { - return ( - - ); -} diff --git a/openless-all/app/src/components/WindowChrome.tsx b/openless-all/app/src/components/WindowChrome.tsx index 266ef05d9..f3a748e35 100644 --- a/openless-all/app/src/components/WindowChrome.tsx +++ b/openless-all/app/src/components/WindowChrome.tsx @@ -49,7 +49,9 @@ export function WindowChrome({ os = 'mac', children, height = 800 }: WindowChrom position: 'relative', isolation: 'isolate', borderRadius: 'var(--ol-window-shell-radius)', - boxShadow: os === 'win' ? 'none' : 'var(--ol-shadow-xl)', + // The shell fills the whole window, so a CSS shadow lands outside the viewport and + // is clipped; macOS/Windows draw the real window shadow natively. + boxShadow: os === 'win' || os === 'mac' ? 'none' : 'var(--ol-shadow-xl)', overflow: 'hidden', display: 'flex', flexDirection: 'column', diff --git a/openless-all/app/src/components/ui/ChoiceChips.tsx b/openless-all/app/src/components/ui/ChoiceChips.tsx index 11bd783a3..07743f7d6 100644 --- a/openless-all/app/src/components/ui/ChoiceChips.tsx +++ b/openless-all/app/src/components/ui/ChoiceChips.tsx @@ -1,7 +1,4 @@ -import { motion } from 'framer-motion'; -import { useReducedMotion } from '../../lib/motion'; -import { keyboardInteraction } from '../../lib/interactionMode'; - +// Chips keep one size in every state: selecting only recolours, so neighbours never shift. export function ChoiceChips({ options, value, @@ -13,25 +10,17 @@ export function ChoiceChips({ onChange: (value: T) => void; label: string; }) { - const reduced = useReducedMotion(); - const quiet = reduced || keyboardInteraction(); return (
{options.map((option) => ( - onChange(option.value)} > {option.label} - + ))}
); diff --git a/openless-all/app/src/components/ui/Segmented.tsx b/openless-all/app/src/components/ui/Segmented.tsx new file mode 100644 index 000000000..4dcc5775a --- /dev/null +++ b/openless-all/app/src/components/ui/Segmented.tsx @@ -0,0 +1,65 @@ +import { useRef } from 'react'; +import { useSelectionMotion } from '../../lib/motion'; + +export interface SegmentedOption { + value: T; + label: string; + title?: string; +} + +/** The one segmented control used across Settings: a white track with the controls' + * hairline, and a neutral chip that slides under the selected option. */ +export function Segmented({ + options, + value, + onChange, + label, + disabled = false, + className, + indicatorClassName, +}: { + options: SegmentedOption[]; + value: T; + onChange: (value: T) => void; + label: string; + disabled?: boolean; + className?: string; + /** Extra hook for callers whose tests address the sliding chip directly. */ + indicatorClassName?: string; +}) { + const trackRef = useRef(null); + const indicatorRef = useRef(null); + useSelectionMotion(trackRef, indicatorRef, value); + return ( +
+ + {options.map((option) => ( + + ))} +
+ ); +} diff --git a/openless-all/app/src/components/ui/Select.css b/openless-all/app/src/components/ui/Select.css index a3ffd5c39..e332b1df3 100644 --- a/openless-all/app/src/components/ui/Select.css +++ b/openless-all/app/src/components/ui/Select.css @@ -93,7 +93,7 @@ background: var(--ol-select-option-hover-bg); } .ol-select-item[data-selected] { - color: var(--ol-blue); + color: var(--ol-choice-active-ink); font-weight: var(--ol-font-weight-semibold); } .ol-select-check { diff --git a/openless-all/app/src/components/ui/Select.tsx b/openless-all/app/src/components/ui/Select.tsx index f1ec9e097..6430d6aa0 100644 --- a/openless-all/app/src/components/ui/Select.tsx +++ b/openless-all/app/src/components/ui/Select.tsx @@ -11,6 +11,9 @@ interface SelectProps { placeholder?: string; style?: CSSProperties; onOpenChange?: (open: boolean) => void; + open?: boolean; + side?: 'top' | 'bottom'; + popupStyle?: CSSProperties; } /** Standard Base UI Select; keyboard navigation, typeahead and focus return stay upstream. */ @@ -23,9 +26,13 @@ export function Select({ placeholder, style, onOpenChange, + open, + side = 'bottom', + popupStyle, }: SelectProps) { return ( - + {options.map((option) => ( void; + open?: boolean; + side?: 'top' | 'bottom'; + popupStyle?: CSSProperties; searchable?: boolean; searchPlaceholder?: string; emptyMessage?: string; @@ -45,6 +48,9 @@ export function SelectLite({ style, ariaLabel, onOpenChange, + open: controlledOpen, + side = 'bottom', + popupStyle, searchable = false, searchPlaceholder = 'Search…', emptyMessage = 'No matching options', @@ -77,6 +83,9 @@ export function SelectLite({ ariaLabel, searchPlaceholder, emptyMessage, + open: controlledOpen, + side, + popupStyle, }} onOpenChange={handleOpenChange} /> @@ -84,7 +93,18 @@ export function SelectLite({ } return (