diff --git a/Cargo.lock b/Cargo.lock index 38ef4737..001b2d59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1460,6 +1460,7 @@ dependencies = [ "assets", "auto_update", "chat", + "chat_ui", "flume 0.11.1", "gpui-kit", "gpui-pre", @@ -1488,6 +1489,7 @@ version = "2.0.0-alpha2" dependencies = [ "assets", "chat", + "chat_ui", "console_error_panic_hook", "console_log", "getrandom 0.2.17", diff --git a/crates/chat_ui/src/actions.rs b/crates/chat_ui/src/actions.rs index b1c47642..2ef1f3f0 100644 --- a/crates/chat_ui/src/actions.rs +++ b/crates/chat_ui/src/actions.rs @@ -1,4 +1,4 @@ -use gpui::Action; +use gpui::{Action, actions}; use nostr_sdk::prelude::*; use serde::Deserialize; use settings::SignerKind; @@ -15,3 +15,6 @@ pub enum Command { Njump(PublicKey), Trace(EventId), } + +// Start or stop dictation in the focused composer +actions!(chat, [ToggleDictation]); diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index d23359ec..f50dcb44 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -882,6 +882,17 @@ impl ChatPanel { self.directory.read(cx).person(public_key, cx) } + fn toggle_dictation( + &mut self, + _: &ToggleDictation, + _window: &mut Window, + cx: &mut Context, + ) { + self.speech.update(cx, |speech, cx| { + speech.toggle(cx); + }); + } + fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context) { match command { Command::Insert(content) => { @@ -1871,6 +1882,7 @@ impl Render for ChatPanel { v_flex() .image_cache(retain_all(self.id.clone())) .on_action(cx.listener(Self::on_command)) + .on_action(cx.listener(Self::toggle_dictation)) .size_full() .when(*self.subject_bar.read(cx), |this| { this.child( diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index 73161f4a..21a61b96 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -2,11 +2,13 @@ use std::collections::BTreeMap; use std::time::Duration; use anyhow::Result; +use chat_ui::ToggleDictation; use gpui::prelude::FluentBuilder; use gpui::{ Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, - SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px, + FollowMode, InteractiveElement, IntoElement, ListAlignment, ListScrollEvent, ListState, + ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, + px, }; use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; @@ -1063,6 +1065,17 @@ impl GroupPanel { ) } + fn toggle_dictation( + &mut self, + _ev: &ToggleDictation, + _window: &mut Window, + cx: &mut Context, + ) { + self.speech.update(cx, |speech, cx| { + speech.toggle(cx); + }); + } + fn render_composer(&self, cx: &mut Context) -> AnyElement { let notice = self.group.upgrade().and_then(|group| { let group = group.read(cx); @@ -1341,6 +1354,7 @@ impl Render for GroupPanel { v_flex() .size_full() .min_w_0() + .on_action(cx.listener(Self::toggle_dictation)) .child(self.render_encryption_notice(cx)) .child(self.render_banner(cx)) .child(self.render_pins(cx)) diff --git a/crates/workspace/src/dialogs/settings.rs b/crates/workspace/src/dialogs/settings.rs index 40cb0af1..53a79811 100644 --- a/crates/workspace/src/dialogs/settings.rs +++ b/crates/workspace/src/dialogs/settings.rs @@ -1,21 +1,27 @@ #[cfg(not(target_arch = "wasm32"))] use std::time::Duration; +use chat_ui::ToggleDictation; #[cfg(not(target_arch = "wasm32"))] use gpui::PathPromptOptions; +#[cfg(not(target_arch = "wasm32"))] +use gpui::Task; use gpui::http_client::Url; use gpui::{ AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, - Subscription, Task, Window, div, + Subscription, Window, div, }; use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput}; +use gpui_kit::component::kbd::Kbd; use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; use gpui_kit::component::notification::Notification; +#[cfg(not(target_arch = "wasm32"))] +use gpui_kit::component::progress::Progress; use gpui_kit::component::separator::Separator; use gpui_kit::component::switch::Switch; -use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; +use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex}; use settings::{AppSettings, AppearanceMode}; #[cfg(not(target_arch = "wasm32"))] use speech::model::{self as speech_model}; @@ -236,66 +242,90 @@ impl Preferences { #[cfg(not(target_arch = "wasm32"))] { - const SPEECH: &str = "On-device speech model used by the speech to text feature. Audio never leaves the device."; + let size = speech_model::total_size() / 1_000_000; + let status = speech_model::status(); - let control = match speech_model::status() { - speech_model::Status::NotInstalled => { - let size = speech_model::total_size() / 1024 / 1024; - - h_flex().gap_1().child( - Button::new("download-model") - .label(format!("Download model ({size} MB)")) - .small() - .on_click(|_, _window, cx| { - speech_model::start_download(); - cx.refresh_windows(); - }), - ) - } - speech_model::Status::Downloading { downloaded, total } => { - let percent = downloaded * 100 / total.max(1); - let downloaded = downloaded / 1024 / 1024; - let total = total / 1024 / 1024; - - h_flex() - .gap_2() - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(format!("Downloading {percent}% · {downloaded}/{total} MB")), - ) - .child( - Button::new("cancel-download") - .label("Cancel") - .small() - .on_click(|_, _window, cx| { - speech_model::cancel_download(); - cx.refresh_windows(); - }), - ) - } - speech_model::Status::Installed => h_flex() - .gap_2() - .child(div().text_sm().child("Installed")) - .child( - Button::new("delete-model") - .label("Delete") - .small() - .on_click(|_, _window, cx| { - speech_model::delete(); - cx.refresh_windows(); - }), + let (description, control) = match &status { + speech_model::Status::NotInstalled => ( + format!("{size} MB download"), + Button::new("download-model") + .label("Download") + .secondary() + .small() + .on_click(|_, _window, cx| { + speech_model::start_download(); + cx.refresh_windows(); + }) + .into_any_element(), + ), + speech_model::Status::Downloading { downloaded, total } => ( + format!( + "Downloading · {} of {} MB", + downloaded / 1_000_000, + total / 1_000_000 ), + Button::new("cancel-download") + .label("Cancel") + .ghost() + .small() + .on_click(|_, _window, cx| { + speech_model::cancel_download(); + cx.refresh_windows(); + }) + .into_any_element(), + ), + speech_model::Status::Installed => ( + format!("Parakeet v3 · {size} MB"), + Button::new("remove-model") + .label("Remove") + .secondary() + .small() + .on_click(|_, _window, cx| { + speech_model::delete(); + cx.refresh_windows(); + }) + .into_any_element(), + ), }; - settings_block("Speech Recognition", SPEECH, control, cx).into_any_element() + let mut block = v_flex().w_full().gap_3().child(settings_row( + "Speech model", + description, + control, + cx, + )); + + if let speech_model::Status::Downloading { downloaded, total } = &status { + let (downloaded, total) = (*downloaded, *total); + + block = block.child( + Progress::new("speech-model-download") + .value(downloaded as f32 / total.max(1) as f32 * 100.) + .small(), + ); + } + + block.into_any_element() } } + + /// Shows the keystroke actually bound to the dictation action at startup + fn render_dictation_shortcut( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let control = match Kbd::global_binding_for_action(&ToggleDictation, window) { + Some(kbd) => kbd.into_any_element(), + None => div().into_any_element(), + }; + + settings_row("Shortcut", "Press to start or stop dictation", control, cx) + } } impl Render for Preferences { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { const SCREENING: &str = "Show an screening dialog to verify the unknown sender."; const WOT: &str = "Treat people followed by your contacts as known senders."; const AVATAR: &str = "Hide all avatar pictures to improve performance."; @@ -478,6 +508,9 @@ impl Render for Preferences { cx, )) .child(Separator::horizontal()) + .child(self.render_speech(cx)) + .child(self.render_dictation_shortcut(window, cx)) + .child(Separator::horizontal()) .child(settings_block( "Media Upload Service", MEDIA, @@ -495,6 +528,5 @@ impl Render for Preferences { ), cx, )) - .child(self.render_speech(cx)) } } diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 781e8b11..2db31079 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -38,6 +38,7 @@ ui = { path = "../crates/ui" } state = { path = "../crates/state" } nip4e = { path = "../crates/nip4e" } chat = { path = "../crates/chat" } +chat_ui = { path = "../crates/chat_ui" } nip29 = { path = "../crates/nip29" } settings = { path = "../crates/settings" } auto_update = { path = "../crates/auto_update" } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index a4c34176..84c0b6de 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -1,5 +1,6 @@ use std::sync::{Arc, Mutex}; +use chat_ui::ToggleDictation; use gpui::{ App, Bounds, Global, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions, @@ -45,13 +46,19 @@ fn main() { // Register the `quit` function cx.on_action(quit); - // Register the `quit` function with CMD+Q (macOS) + // Register the `quit` function with CMD+Q (macOS) and dictation with CMD+D #[cfg(target_os = "macos")] - cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); + cx.bind_keys([ + KeyBinding::new("cmd-q", Quit, None), + KeyBinding::new("cmd-d", ToggleDictation, None), + ]); - // Register the `quit` function with Super+Q (others) + // Register the `quit` function with Super+Q (others) and dictation with Ctrl+D #[cfg(not(target_os = "macos"))] - cx.bind_keys([KeyBinding::new("super-q", Quit, None)]); + cx.bind_keys([ + KeyBinding::new("super-q", Quit, None), + KeyBinding::new("ctrl-d", ToggleDictation, None), + ]); // Set menu items cx.set_menus(vec![Menu { diff --git a/web/Cargo.toml b/web/Cargo.toml index 8658a426..6251560b 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -14,6 +14,7 @@ ui = { path = "../crates/ui" } state = { path = "../crates/state" } nip4e = { path = "../crates/nip4e" } chat = { path = "../crates/chat" } +chat_ui = { path = "../crates/chat_ui" } nip29 = { path = "../crates/nip29" } settings = { path = "../crates/settings" } person = { path = "../crates/person" } diff --git a/web/src/lib.rs b/web/src/lib.rs index 5afcbbf4..abc91bac 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -78,6 +78,11 @@ pub fn run() -> Result<(), JsValue> { APPLICATION.with(|application| { *application.borrow_mut() = Some(app.run_embedded(|cx| { + cx.bind_keys([ + KeyBinding::new("cmd-d", chat_ui::ToggleDictation, None), + KeyBinding::new("ctrl-d", chat_ui::ToggleDictation, None), + ]); + gpui_kit::init(cx); load_embedded_fonts(cx);