Add a global ToggleDictation keybinding

This commit is contained in:
2026-10-27 13:24:25 +07:00
parent f486075099
commit 47feaa1e23
9 changed files with 138 additions and 61 deletions
Generated
+2
View File
@@ -1460,6 +1460,7 @@ dependencies = [
"assets", "assets",
"auto_update", "auto_update",
"chat", "chat",
"chat_ui",
"flume 0.11.1", "flume 0.11.1",
"gpui-kit", "gpui-kit",
"gpui-pre", "gpui-pre",
@@ -1488,6 +1489,7 @@ version = "2.0.0-alpha2"
dependencies = [ dependencies = [
"assets", "assets",
"chat", "chat",
"chat_ui",
"console_error_panic_hook", "console_error_panic_hook",
"console_log", "console_log",
"getrandom 0.2.17", "getrandom 0.2.17",
+4 -1
View File
@@ -1,4 +1,4 @@
use gpui::Action; use gpui::{Action, actions};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use settings::SignerKind; use settings::SignerKind;
@@ -15,3 +15,6 @@ pub enum Command {
Njump(PublicKey), Njump(PublicKey),
Trace(EventId), Trace(EventId),
} }
// Start or stop dictation in the focused composer
actions!(chat, [ToggleDictation]);
+12
View File
@@ -882,6 +882,17 @@ impl ChatPanel {
self.directory.read(cx).person(public_key, cx) self.directory.read(cx).person(public_key, cx)
} }
fn toggle_dictation(
&mut self,
_: &ToggleDictation,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.speech.update(cx, |speech, cx| {
speech.toggle(cx);
});
}
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) { fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command { match command {
Command::Insert(content) => { Command::Insert(content) => {
@@ -1871,6 +1882,7 @@ impl Render for ChatPanel {
v_flex() v_flex()
.image_cache(retain_all(self.id.clone())) .image_cache(retain_all(self.id.clone()))
.on_action(cx.listener(Self::on_command)) .on_action(cx.listener(Self::on_command))
.on_action(cx.listener(Self::toggle_dictation))
.size_full() .size_full()
.when(*self.subject_bar.read(cx), |this| { .when(*self.subject_bar.read(cx), |this| {
this.child( this.child(
+16 -2
View File
@@ -2,11 +2,13 @@ use std::collections::BTreeMap;
use std::time::Duration; use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use chat_ui::ToggleDictation;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, FollowMode, InteractiveElement, IntoElement, ListAlignment, ListScrollEvent, ListState,
SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list,
px,
}; };
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; 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>,
) {
self.speech.update(cx, |speech, cx| {
speech.toggle(cx);
});
}
fn render_composer(&self, cx: &mut Context<Self>) -> AnyElement { fn render_composer(&self, cx: &mut Context<Self>) -> AnyElement {
let notice = self.group.upgrade().and_then(|group| { let notice = self.group.upgrade().and_then(|group| {
let group = group.read(cx); let group = group.read(cx);
@@ -1341,6 +1354,7 @@ impl Render for GroupPanel {
v_flex() v_flex()
.size_full() .size_full()
.min_w_0() .min_w_0()
.on_action(cx.listener(Self::toggle_dictation))
.child(self.render_encryption_notice(cx)) .child(self.render_encryption_notice(cx))
.child(self.render_banner(cx)) .child(self.render_banner(cx))
.child(self.render_pins(cx)) .child(self.render_pins(cx))
+86 -54
View File
@@ -1,21 +1,27 @@
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use std::time::Duration; use std::time::Duration;
use chat_ui::ToggleDictation;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use gpui::PathPromptOptions; use gpui::PathPromptOptions;
#[cfg(not(target_arch = "wasm32"))]
use gpui::Task;
use gpui::http_client::Url; use gpui::http_client::Url;
use gpui::{ use gpui::{
AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled,
Subscription, Task, Window, div, Subscription, Window, div,
}; };
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput}; 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::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::notification::Notification; 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::separator::Separator;
use gpui_kit::component::switch::Switch; 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}; use settings::{AppSettings, AppearanceMode};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use speech::model::{self as speech_model}; use speech::model::{self as speech_model};
@@ -236,66 +242,90 @@ impl Preferences {
#[cfg(not(target_arch = "wasm32"))] #[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() { let (description, control) = match &status {
speech_model::Status::NotInstalled => { speech_model::Status::NotInstalled => (
let size = speech_model::total_size() / 1024 / 1024; format!("{size} MB download"),
Button::new("download-model")
h_flex().gap_1().child( .label("Download")
Button::new("download-model") .secondary()
.label(format!("Download model ({size} MB)")) .small()
.small() .on_click(|_, _window, cx| {
.on_click(|_, _window, cx| { speech_model::start_download();
speech_model::start_download(); cx.refresh_windows();
cx.refresh_windows(); })
}), .into_any_element(),
) ),
} speech_model::Status::Downloading { downloaded, total } => (
speech_model::Status::Downloading { downloaded, total } => { format!(
let percent = downloaded * 100 / total.max(1); "Downloading · {} of {} MB",
let downloaded = downloaded / 1024 / 1024; downloaded / 1_000_000,
let total = total / 1024 / 1024; total / 1_000_000
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();
}),
), ),
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<Self>,
) -> 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 { impl Render for Preferences {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const SCREENING: &str = "Show an screening dialog to verify the unknown sender."; 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 WOT: &str = "Treat people followed by your contacts as known senders.";
const AVATAR: &str = "Hide all avatar pictures to improve performance."; const AVATAR: &str = "Hide all avatar pictures to improve performance.";
@@ -478,6 +508,9 @@ impl Render for Preferences {
cx, cx,
)) ))
.child(Separator::horizontal()) .child(Separator::horizontal())
.child(self.render_speech(cx))
.child(self.render_dictation_shortcut(window, cx))
.child(Separator::horizontal())
.child(settings_block( .child(settings_block(
"Media Upload Service", "Media Upload Service",
MEDIA, MEDIA,
@@ -495,6 +528,5 @@ impl Render for Preferences {
), ),
cx, cx,
)) ))
.child(self.render_speech(cx))
} }
} }
+1
View File
@@ -38,6 +38,7 @@ ui = { path = "../crates/ui" }
state = { path = "../crates/state" } state = { path = "../crates/state" }
nip4e = { path = "../crates/nip4e" } nip4e = { path = "../crates/nip4e" }
chat = { path = "../crates/chat" } chat = { path = "../crates/chat" }
chat_ui = { path = "../crates/chat_ui" }
nip29 = { path = "../crates/nip29" } nip29 = { path = "../crates/nip29" }
settings = { path = "../crates/settings" } settings = { path = "../crates/settings" }
auto_update = { path = "../crates/auto_update" } auto_update = { path = "../crates/auto_update" }
+11 -4
View File
@@ -1,5 +1,6 @@
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use chat_ui::ToggleDictation;
use gpui::{ use gpui::{
App, Bounds, Global, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions, App, Bounds, Global, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions,
WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions,
@@ -45,13 +46,19 @@ fn main() {
// Register the `quit` function // Register the `quit` function
cx.on_action(quit); 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")] #[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"))] #[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 // Set menu items
cx.set_menus(vec![Menu { cx.set_menus(vec![Menu {
+1
View File
@@ -14,6 +14,7 @@ ui = { path = "../crates/ui" }
state = { path = "../crates/state" } state = { path = "../crates/state" }
nip4e = { path = "../crates/nip4e" } nip4e = { path = "../crates/nip4e" }
chat = { path = "../crates/chat" } chat = { path = "../crates/chat" }
chat_ui = { path = "../crates/chat_ui" }
nip29 = { path = "../crates/nip29" } nip29 = { path = "../crates/nip29" }
settings = { path = "../crates/settings" } settings = { path = "../crates/settings" }
person = { path = "../crates/person" } person = { path = "../crates/person" }
+5
View File
@@ -78,6 +78,11 @@ pub fn run() -> Result<(), JsValue> {
APPLICATION.with(|application| { APPLICATION.with(|application| {
*application.borrow_mut() = Some(app.run_embedded(|cx| { *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); gpui_kit::init(cx);
load_embedded_fonts(cx); load_embedded_fonts(cx);