Skip to content

Commit 15f68c8

Browse files
feat(desktop): wire Cmd+Q quit and About Wisp menu (#32)
* feat(desktop): wire Cmd+Q quit and About Wisp menu GPUI requires explicit Quit actions and menu setup; restore Cmd+Q after the session-runner refactor and add an About window. Stop in-flight recordings on quit so sessions persist before exit. Co-authored-by: Cursor <cursoragent@cursor.com> * style: apply cargo fmt to desktop menu modules Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f292de4 commit 15f68c8

5 files changed

Lines changed: 281 additions & 56 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
//! Small "About Wisp" window opened from the application menu.
2+
3+
use gpui::{
4+
App, Bounds, Context, IntoElement, ParentElement, Render, Styled, TitlebarOptions, Window,
5+
WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size,
6+
};
7+
8+
actions!(wisp_desktop, [CloseAbout]);
9+
10+
pub struct AboutView;
11+
12+
impl Render for AboutView {
13+
fn render(
14+
&mut self,
15+
_window: &mut Window,
16+
_cx: &mut Context<Self>,
17+
) -> impl IntoElement {
18+
let app_version = env!("CARGO_PKG_VERSION");
19+
let audiokit_version = wisp_audiokit::version();
20+
21+
div()
22+
.flex()
23+
.flex_col()
24+
.size_full()
25+
.bg(rgb(0x0b_0e13))
26+
.text_color(rgb(0xe8_eaed))
27+
.p_6()
28+
.gap_3()
29+
.child(
30+
div()
31+
.text_xl()
32+
.font_weight(gpui::FontWeight::SEMIBOLD)
33+
.child("Wisp"),
34+
)
35+
.child(
36+
div()
37+
.text_sm()
38+
.text_color(rgb(0x8a_8f98))
39+
.child(format!("Version {app_version}")),
40+
)
41+
.child(
42+
div()
43+
.text_sm()
44+
.text_color(rgb(0x8a_8f98))
45+
.child(format!("WispAudioKit {audiokit_version}")),
46+
)
47+
.child(
48+
div()
49+
.text_sm()
50+
.text_color(rgb(0x5c_606b))
51+
.child("Fully offline meeting transcription for macOS."),
52+
)
53+
.child(div().flex_grow())
54+
.child(
55+
div().flex().justify_end().child(
56+
div()
57+
.id("about-ok")
58+
.px_3()
59+
.py_1p5()
60+
.bg(rgb(0x13_171f))
61+
.border_1()
62+
.border_color(rgb(0x1f_242e))
63+
.rounded_md()
64+
.cursor_pointer()
65+
.child("OK")
66+
.on_click(|_, window, _| {
67+
window.remove_window();
68+
}),
69+
),
70+
)
71+
.on_action(|_: &CloseAbout, window, _| {
72+
window.remove_window();
73+
})
74+
}
75+
}
76+
77+
pub fn open(cx: &mut App) {
78+
let bounds = Bounds::centered(None, size(px(360.0), px(240.0)), cx);
79+
cx.open_window(
80+
WindowOptions {
81+
window_bounds: Some(WindowBounds::Windowed(bounds)),
82+
titlebar: Some(TitlebarOptions {
83+
title: Some("About Wisp".into()),
84+
..Default::default()
85+
}),
86+
..Default::default()
87+
},
88+
|_, cx| cx.new(|_| AboutView),
89+
)
90+
.expect("failed to open About window");
91+
}

apps/wisp-desktop/src/app_menu.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//! macOS menu bar: application menu (About, Quit) and Cmd+Q.
2+
3+
use std::sync::Arc;
4+
use std::time::Duration;
5+
6+
use gpui::{App, Entity, KeyBinding, Menu, MenuItem, actions};
7+
8+
use crate::about_view;
9+
use crate::app::{AppModel, SessionState};
10+
use crate::library::SharedStorage;
11+
use crate::session_runner::SessionRunner;
12+
use crate::session_updates::apply_update;
13+
14+
actions!(wisp_desktop, [Quit, About]);
15+
16+
/// Wire up the menu bar, keyboard shortcuts, and quit handlers.
17+
pub fn configure(
18+
cx: &mut App,
19+
runner: Arc<SessionRunner>,
20+
storage: SharedStorage,
21+
model: Entity<AppModel>,
22+
) {
23+
let runner_for_quit = runner.clone();
24+
let model_for_quit = model.clone();
25+
let storage_for_quit = storage.clone();
26+
27+
cx.on_action(move |_: &Quit, cx| {
28+
graceful_stop_session(&runner_for_quit, &model_for_quit, &storage_for_quit, cx);
29+
cx.quit();
30+
});
31+
32+
cx.on_action(|_: &About, cx| {
33+
about_view::open(cx);
34+
});
35+
36+
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
37+
38+
cx.set_menus(vec![Menu {
39+
name: "Wisp".into(),
40+
items: vec![
41+
MenuItem::action("About Wisp", About),
42+
MenuItem::separator(),
43+
MenuItem::action("Quit Wisp", Quit),
44+
],
45+
}]);
46+
47+
let runner_for_shutdown = runner;
48+
let model_for_shutdown = model;
49+
let storage_for_shutdown = storage;
50+
let _ = cx.on_app_quit(move |cx| {
51+
graceful_stop_session(
52+
&runner_for_shutdown,
53+
&model_for_shutdown,
54+
&storage_for_shutdown,
55+
cx,
56+
);
57+
async move {}
58+
});
59+
}
60+
61+
/// If a recording is active (or stopping), request stop and wait for the
62+
/// worker to finish so segments can be persisted before exit.
63+
fn graceful_stop_session(
64+
runner: &SessionRunner,
65+
model: &Entity<AppModel>,
66+
storage: &SharedStorage,
67+
cx: &mut App,
68+
) {
69+
let needs_stop = model.read(cx).state;
70+
let needs_stop = matches!(
71+
needs_stop,
72+
SessionState::Recording { .. } | SessionState::Starting
73+
);
74+
if needs_stop {
75+
runner.stop();
76+
model.update(cx, |m, cx| {
77+
m.set_state(SessionState::Stopping);
78+
cx.notify();
79+
});
80+
}
81+
82+
let should_wait = matches!(
83+
model.read(cx).state,
84+
SessionState::Recording { .. } | SessionState::Starting | SessionState::Stopping
85+
);
86+
if !should_wait {
87+
return;
88+
}
89+
90+
let updates = runner.wait_for_idle(Duration::from_secs(5));
91+
let _ = model.update(cx, |m, cx| {
92+
for update in updates {
93+
apply_update(update, m, storage);
94+
}
95+
cx.notify();
96+
});
97+
}

apps/wisp-desktop/src/main.rs

Lines changed: 9 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,21 @@ use gpui::{
3434
use wisp_core::SessionId;
3535
use wisp_storage::Storage;
3636

37+
mod about_view;
3738
mod app;
39+
mod app_menu;
3840
mod library;
3941
mod permissions;
4042
mod session_runner;
43+
mod session_updates;
4144
mod transcript_view;
4245

4346
use app::{AppModel, SessionState};
47+
use app_menu::configure as configure_app_menu;
4448
use library::SharedStorage;
45-
use session_runner::{SessionRunner, Update};
46-
use transcript_view::{TranscriptView, cursor_blink_period, now, ui_tick_period};
49+
use session_runner::SessionRunner;
50+
use session_updates::apply_update;
51+
use transcript_view::{TranscriptView, cursor_blink_period, ui_tick_period};
4752

4853
/// How often we re-poll permission status from the OS while the
4954
/// onboarding screen is up. The user might flip the toggle in System
@@ -87,6 +92,8 @@ fn main() {
8792
recordings_dir,
8893
);
8994

95+
configure_app_menu(cx, runner.clone(), storage.clone(), model.clone());
96+
9097
spawn_session_update_pump(cx, runner, storage, model.clone());
9198
spawn_cursor_blink(cx, window);
9299
spawn_permission_refresh(cx, model);
@@ -194,60 +201,6 @@ fn spawn_session_update_pump(
194201
.detach();
195202
}
196203

197-
fn apply_update(
198-
update: Update,
199-
model: &mut AppModel,
200-
storage: &SharedStorage,
201-
) {
202-
match update {
203-
Update::Started => {
204-
let started_at = Utc::now();
205-
model.set_state(SessionState::Recording { started_at: now() });
206-
// Best-effort: if the DB write fails we still let the user
207-
// record; we just won't persist this session.
208-
if let Ok(store) = storage.lock() {
209-
let dir_name = library::session_dir_name(started_at);
210-
if let Ok(session_id) = library::create_session(&store, started_at, &dir_name) {
211-
model.current_session_id = Some(session_id);
212-
}
213-
}
214-
},
215-
Update::Event(e) => model.ingest(e),
216-
Update::Stopped => {
217-
model.finalize_all_segments();
218-
model.set_state(SessionState::Idle);
219-
persist_finished_session(model, storage);
220-
},
221-
Update::Error(msg) => {
222-
// Clear the in-flight DB row — without this it dangles in the
223-
// library forever with ended_at = NULL.
224-
if let Some(id) = model.current_session_id.take()
225-
&& let Ok(store) = storage.lock()
226-
{
227-
let _ = store.sessions().delete(id);
228-
}
229-
model.fail(msg);
230-
},
231-
}
232-
}
233-
234-
fn persist_finished_session(
235-
model: &mut AppModel,
236-
storage: &SharedStorage,
237-
) {
238-
let Some(session_id) = model.current_session_id.take() else {
239-
return;
240-
};
241-
let Ok(store) = storage.lock() else {
242-
return;
243-
};
244-
let ended_at = Utc::now();
245-
let _ = library::finalise_session(&store, session_id, &model.segments, ended_at);
246-
if let Ok(list) = store.sessions().list() {
247-
model.set_library(list);
248-
}
249-
}
250-
251204
/// Toggle the ghost-text cursor and refresh the status-bar elapsed counter.
252205
fn spawn_cursor_blink(
253206
cx: &mut App,

apps/wisp-desktop/src/session_runner.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,30 @@ impl SessionRunner {
7979
}
8080
out
8181
}
82+
83+
/// Block until the worker reports `Stopped`/`Error`, or `timeout` elapses.
84+
/// Used when quitting so in-flight recordings can be finalised.
85+
pub fn wait_for_idle(
86+
&self,
87+
timeout: Duration,
88+
) -> Vec<Update> {
89+
let deadline = std::time::Instant::now() + timeout;
90+
let mut collected = Vec::new();
91+
loop {
92+
collected.extend(self.drain_updates());
93+
if collected
94+
.iter()
95+
.any(|u| matches!(u, Update::Stopped | Update::Error(_)))
96+
{
97+
break;
98+
}
99+
if std::time::Instant::now() >= deadline {
100+
break;
101+
}
102+
std::thread::sleep(CMD_POLL_INTERVAL);
103+
}
104+
collected
105+
}
82106
}
83107

84108
impl Drop for SessionRunner {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//! Apply `SessionRunner` updates to `AppModel` and persist at session boundaries.
2+
3+
use chrono::Utc;
4+
5+
use crate::app::{AppModel, SessionState};
6+
use crate::library;
7+
use crate::library::SharedStorage;
8+
use crate::session_runner::Update;
9+
use crate::session_runner::Update::{Error, Event, Started, Stopped};
10+
use crate::transcript_view::now;
11+
12+
pub fn apply_update(
13+
update: Update,
14+
model: &mut AppModel,
15+
storage: &SharedStorage,
16+
) {
17+
match update {
18+
Started => {
19+
let started_at = Utc::now();
20+
model.set_state(SessionState::Recording { started_at: now() });
21+
if let Ok(store) = storage.lock() {
22+
let dir_name = library::session_dir_name(started_at);
23+
if let Ok(session_id) = library::create_session(&store, started_at, &dir_name) {
24+
model.current_session_id = Some(session_id);
25+
}
26+
}
27+
},
28+
Event(e) => model.ingest(e),
29+
Stopped => {
30+
model.finalize_all_segments();
31+
model.set_state(SessionState::Idle);
32+
persist_finished_session(model, storage);
33+
},
34+
Error(msg) => {
35+
if let Some(id) = model.current_session_id.take()
36+
&& let Ok(store) = storage.lock()
37+
{
38+
let _ = store.sessions().delete(id);
39+
}
40+
model.fail(msg);
41+
},
42+
}
43+
}
44+
45+
fn persist_finished_session(
46+
model: &mut AppModel,
47+
storage: &SharedStorage,
48+
) {
49+
let Some(session_id) = model.current_session_id.take() else {
50+
return;
51+
};
52+
let Ok(store) = storage.lock() else {
53+
return;
54+
};
55+
let ended_at = Utc::now();
56+
let _ = library::finalise_session(&store, session_id, &model.segments, ended_at);
57+
if let Ok(list) = store.sessions().list() {
58+
model.set_library(list);
59+
}
60+
}

0 commit comments

Comments
 (0)