From f4860750991cde4170a972d4aa305b868f4e724c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 27 Oct 2026 12:36:45 +0700 Subject: [PATCH] update ui --- Cargo.lock | 3 ++ crates/chat_ui/Cargo.toml | 1 + crates/chat_ui/src/lib.rs | 85 +++++++++++++++++++++++++++----------- crates/nip29_ui/Cargo.toml | 1 + crates/nip29_ui/src/lib.rs | 62 +++++++++++++++++++-------- crates/speech/Cargo.toml | 1 + crates/speech/src/lib.rs | 27 ++++++++++++ 7 files changed, 139 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a6bc562..38ef4737 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1161,6 +1161,7 @@ dependencies = [ "common", "gpui-kit", "gpui-pre", + "instant", "itertools 0.13.0", "nostr-sdk", "person", @@ -5430,6 +5431,7 @@ dependencies = [ "common", "gpui-kit", "gpui-pre", + "instant", "log", "nip29", "nostr-sdk", @@ -8374,6 +8376,7 @@ dependencies = [ "flume 0.11.1", "gpui-kit", "gpui-pre", + "instant", "js-sys", "log", "ort", diff --git a/crates/chat_ui/Cargo.toml b/crates/chat_ui/Cargo.toml index 7f5b25a0..d14400cf 100644 --- a/crates/chat_ui/Cargo.toml +++ b/crates/chat_ui/Cargo.toml @@ -15,6 +15,7 @@ chat = { path = "../chat" } settings = { path = "../settings" } gpui.workspace = true +instant.workspace = true nostr-sdk.workspace = true anyhow.workspace = true itertools.workspace = true diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 73ce1548..d23359ec 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -96,6 +96,9 @@ pub struct ChatPanel { /// Dictation session for the chat input speech: Entity, + /// When the running dictation started, for the elapsed counter + speech_started: Option, + /// Subject input state subject_input: Entity, @@ -207,14 +210,25 @@ impl ChatPanel { // Insert the dictated text into the chat input cx.subscribe_in(&speech, window, move |this, _speech, event, window, cx| { match event { - SpeechEvent::Final(text) if !text.is_empty() => { - this.input.update(cx, |input, cx| { - input.insert(text.clone(), window, cx); - }); + SpeechEvent::Started => { + this.speech_started = Some(instant::Instant::now()); + cx.notify(); + } + SpeechEvent::Final(text) => { + this.speech_started = None; + + if !text.is_empty() { + this.input + .update(cx, |input, cx| input.insert(text.clone(), window, cx)); + } } SpeechEvent::Error(error) => { + this.speech_started = None; window.push_notification(Notification::error(error.to_string()), cx); } + SpeechEvent::Cancelled => { + this.speech_started = None; + } _ => {} }; }), @@ -238,6 +252,7 @@ impl ChatPanel { list_state, input, speech, + speech_started: None, subject_input, subject_bar, replies_to, @@ -1706,7 +1721,12 @@ impl ChatPanel { items } - fn render_config_menu(&self, _window: &mut Window, cx: &Context) -> impl IntoElement { + fn render_config_menu( + &self, + disabled: bool, + _window: &mut Window, + cx: &Context, + ) -> impl IntoElement { let (backup, signer_kind) = self .room .read_with(cx, |this, _cx| { @@ -1719,6 +1739,7 @@ impl ChatPanel { .icon(IconName::Settings2) .tooltip("Configuration") .ghost() + .disabled(disabled) .dropdown_menu(move |this, _window, _cx| { let auto = matches!(signer_kind, SignerKind::Auto); let encryption = matches!(signer_kind, SignerKind::Encryption); @@ -1749,7 +1770,12 @@ impl ChatPanel { }) } - fn render_emoji_menu(&self, _window: &Window, cx: &Context) -> impl IntoElement { + fn render_emoji_menu( + &self, + disabled: bool, + _window: &Window, + cx: &Context, + ) -> impl IntoElement { let nostr = NostrRegistry::global(cx); let current_user = nostr.read(cx).current_user(); @@ -1766,6 +1792,7 @@ impl ChatPanel { Button::new("emoji") .icon(IconName::Emoji) .ghost() + .disabled(disabled) .dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| { let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| { this.menu(*emoji, Box::new(Command::Insert(emoji.to_string()))) @@ -1839,7 +1866,7 @@ impl Render for ChatPanel { let is_typing = !self.input.read(cx).value().trim().is_empty(); let pending_attachments = !self.encrypted_attachments.read(cx).is_empty(); - let capturing = self.speech.read(cx).status().is_capturing(); + let dictating = self.speech.read(cx).status().is_active(); v_flex() .image_cache(retain_all(self.id.clone())) @@ -1920,42 +1947,52 @@ impl Render for ChatPanel { .icon(IconName::Paperclip) .tooltip("Upload media") .loading(self.uploading) - .disabled(self.uploading) + .disabled(dictating || self.uploading) .ghost() .on_click(cx.listener(move |this, _ev, window, cx| { this.upload(window, cx); })), ) - .child( - Textarea::new(&self.input) - .appearance(false) - .small() - .min_h_8() - .py_1() - .flex_1(), - ) + .when(dictating, |this| { + this.child( + h_flex() + .h_8() + .flex_1() + .gap_2() + .pl_1() + .child(SpeechWaveform::new(&self.speech).small().flex_1()) + .when_some(self.speech_started, |this, started| { + this.child(speech::elapsed(started, cx)) + }), + ) + }) + .when(!dictating, |this| { + this.child( + Textarea::new(&self.input) + .appearance(false) + .small() + .min_h_8() + .py_1() + .flex_1(), + ) + }) .child( h_flex() .items_end() .gap_1() .pl_1() - .when(capturing, |this| { - this.child( - SpeechWaveform::new(&self.speech).xsmall().w_12(), - ) - }) .child(SpeechButton::new(&self.speech)), ) .child( h_flex() .pl_1() .gap_1() - .child(self.render_emoji_menu(window, cx)) - .child(self.render_config_menu(window, cx)) + .child(self.render_emoji_menu(dictating, window, cx)) + .child(self.render_config_menu(dictating, window, cx)) .child( Button::new("send") .icon(IconName::PaperPlaneFill) - .disabled(self.uploading) + .disabled(dictating || self.uploading) .ghost() .on_click(cx.listener(move |this, _ev, window, cx| { this.send_text_message(window, cx); diff --git a/crates/nip29_ui/Cargo.toml b/crates/nip29_ui/Cargo.toml index 451c1d50..c6fa175d 100644 --- a/crates/nip29_ui/Cargo.toml +++ b/crates/nip29_ui/Cargo.toml @@ -17,6 +17,7 @@ gpui-kit = { workspace = true, features = ["speech"] } ui = { path = "../ui" } gpui.workspace = true +instant.workspace = true nostr-sdk.workspace = true anyhow.workspace = true log.workspace = true diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index 331c2805..73161f4a 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -316,6 +316,8 @@ pub struct GroupPanel { input: Entity, /// Dictation session for the composer input speech: Entity, + /// When the running dictation started, for the elapsed counter + speech_started: Option, /// Uploaded, non-encrypted attachments waiting to be sent. attachments: Vec, /// Whether an upload is in flight. @@ -367,14 +369,26 @@ impl GroupPanel { subscriptions.push( cx.subscribe_in(&speech, window, |this, _, event, window, cx| { match event { - SpeechEvent::Final(text) if !text.is_empty() => { - this.input.update(cx, |input, cx| { - input.insert(text.clone(), window, cx); - }); + SpeechEvent::Started => { + this.speech_started = Some(instant::Instant::now()); + cx.notify(); + } + SpeechEvent::Final(text) => { + this.speech_started = None; + + if !text.is_empty() { + this.input.update(cx, |input, cx| { + input.insert(text.clone(), window, cx); + }); + } } SpeechEvent::Error(error) => { + this.speech_started = None; window.push_notification(Notification::error(error.to_string()), cx); } + SpeechEvent::Cancelled => { + this.speech_started = None; + } _ => {} }; }), @@ -416,6 +430,7 @@ impl GroupPanel { rendered_texts_by_id: BTreeMap::new(), input, speech, + speech_started: None, attachments: Vec::new(), uploading: false, tasks: smallvec![], @@ -1073,6 +1088,8 @@ impl GroupPanel { return self.render_notice(¬ice, cx); } + let dictating = self.speech.read(cx).status().is_active(); + v_flex() .flex_shrink_0() .w_full() @@ -1091,23 +1108,34 @@ impl GroupPanel { .icon(ui::IconName::Paperclip) .tooltip("Upload media") .loading(self.uploading) - .disabled(self.uploading) + .disabled(dictating || self.uploading) .ghost() .on_click(cx.listener(|this, _event, window, cx| { this.upload(window, cx); })), ) - .child( - Textarea::new(&self.input) - .appearance(false) - .bg(cx.theme().input) - .small() - .min_h_8() - .py_1() - .flex_1(), - ) - .when(self.speech.read(cx).status().is_capturing(), |this| { - this.child(SpeechWaveform::new(&self.speech).xsmall().w_12()) + .when(dictating, |this| { + this.child( + h_flex() + .h_8() + .flex_1() + .gap_2() + .child(SpeechWaveform::new(&self.speech).small().flex_1()) + .when_some(self.speech_started, |this, started| { + this.child(speech::elapsed(started, cx)) + }), + ) + }) + .when(!dictating, |this| { + this.child( + Textarea::new(&self.input) + .appearance(false) + .bg(cx.theme().input) + .small() + .min_h_8() + .py_1() + .flex_1(), + ) }) .child(SpeechButton::new(&self.speech)) .child( @@ -1115,7 +1143,7 @@ impl GroupPanel { .icon(ui::IconName::PaperPlaneFill) .tooltip("Send") .ghost() - .disabled(self.uploading) + .disabled(dictating || self.uploading) .on_click(cx.listener(|this, _event, window, cx| { this.send(window, cx); })), diff --git a/crates/speech/Cargo.toml b/crates/speech/Cargo.toml index 444cc638..724ebf75 100644 --- a/crates/speech/Cargo.toml +++ b/crates/speech/Cargo.toml @@ -10,6 +10,7 @@ log.workspace = true gpui.workspace = true gpui-kit = { workspace = true, features = ["speech"] } flume.workspace = true +instant.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] common = { path = "../common" } diff --git a/crates/speech/src/lib.rs b/crates/speech/src/lib.rs index 99014d77..d4cfcdcb 100644 --- a/crates/speech/src/lib.rs +++ b/crates/speech/src/lib.rs @@ -5,8 +5,35 @@ mod parakeet; #[cfg(target_arch = "wasm32")] mod web; +use std::time::Duration; + +use gpui::{ + Animation, AnimationExt as _, AnyElement, App, IntoElement as _, ParentElement as _, + Styled as _, div, +}; +use gpui_kit::component::ActiveTheme as _; use gpui_kit::component::speech::SpeechRecognizer; +/// A ticking `M:SS` counter for the dictation in progress. +pub fn elapsed(started: instant::Instant, cx: &App) -> AnyElement { + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .with_animation( + "dictation-elapsed", + Animation::new(Duration::from_secs(1)) + .repeat() + .with_max_fps(1.), + move |counter, _| counter.child(format_elapsed(started)), + ) + .into_any_element() +} + +fn format_elapsed(started: instant::Instant) -> String { + let seconds = started.elapsed().as_secs(); + format!("{}:{:02}", seconds / 60, seconds % 60) +} + /// The recognizer backing the composer's microphone button. #[cfg(not(target_arch = "wasm32"))] pub fn recognizer() -> impl SpeechRecognizer {