Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions docs/releases/UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ then reset this file.

## User-facing changes

- On macOS, PickScribe can now start and stop dictation with a configurable
in-app global shortcut (Cmd+Shift+Space by default). Linux continues to use
the existing desktop-environment keybinding path.
- Transcribe audio or video files by dropping them onto the app or browsing
from the dashboard. PickScribe runs local whisper.cpp transcription with
progress and cancellation, stores results in History, and exports TXT, SRT,
Expand Down Expand Up @@ -127,6 +130,12 @@ then reset this file.
machine — both are covered instead by unit tests against the
`DeliveryRuntime` seam and the pure script-building/error-mapping
functions.
- Issue #66 PR 4 (macOS in-app dictation shortcut): `cargo test` (119 tests),
`cargo test --manifest-path src-tauri/Cargo.toml` (38 tests), `cargo clippy
--workspace --all-targets -- -D warnings`, `bun install --frozen-lockfile`,
`bun run check`, `bunx vitest run` (42 tests), and `bun run build` on macOS.
Live global-shortcut registration was intentionally skipped to avoid changing
the active desktop session.
- Issue #59 (macOS compile support and CI): `cargo check --manifest-path
src-tauri/Cargo.toml`, `cargo test --manifest-path src-tauri/Cargo.toml` (37
tests), `bun install --frozen-lockfile && bun run check`, `bun run lint`, and
Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ tauri-plugin-sentry = "0.5"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
tauri-plugin-global-shortcut = "2"

[target."cfg(target_os = \"linux\")".dependencies]
gtk = "0.18"
3 changes: 2 additions & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"autostart:allow-is-enabled",
"opener:default",
"updater:default",
"process:default"
"process:default",
"global-shortcut:default"
]
}
43 changes: 40 additions & 3 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod kwin;
mod lifecycle;
mod macos;
mod settings;
mod shortcut;
mod tray;

use std::fs;
Expand Down Expand Up @@ -251,7 +252,23 @@ fn get_platform_support() -> PlatformSupport {

#[tauri::command]
fn update_app_config(app: AppHandle, config: AppConfig) -> CommandResult<AppConfig> {
settings::replace(&app, config)
let previous = AppConfig::load();
let next_shortcut = config.shortcut.toggle.clone();
shortcut::replace(&app, &previous.shortcut.toggle, &next_shortcut)?;

match settings::replace(&app, config) {
Ok(config) => Ok(config),
Err(err) => {
if let Err(rollback_err) =
shortcut::replace(&app, &next_shortcut, &previous.shortcut.toggle)
{
return Err(format!(
"{err}; additionally failed to restore the previous global shortcut: {rollback_err}"
));
}
Err(err)
}
}
}

#[tauri::command]
Expand Down Expand Up @@ -865,7 +882,7 @@ pub fn run() {
};
let engine = Arc::new(Engine::new().expect("failed to open PickScribe data directory"));

tauri::Builder::default()
let builder = tauri::Builder::default()
.manage(engine)
.plugin(sentry_plugin)
.plugin(tauri_plugin_opener::init())
Expand All @@ -884,7 +901,24 @@ pub fn run() {
} else {
focus_main_window(app);
}
}))
}));
let builder = if cfg!(target_os = "macos") {
builder.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, _shortcut, event| {
if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed {
let engine = engine::engine(app);
engine.set_chord_override(None);
engine.toggle(app);
}
})
.build(),
)
} else {
builder
};

builder
.invoke_handler(tauri::generate_handler![
get_state,
toggle_dictation,
Expand Down Expand Up @@ -912,6 +946,9 @@ pub fn run() {
tray::setup(app)?;
let cfg = AppConfig::load();
ensure_float_window(app.handle(), cfg.general.float_button);
if let Err(err) = shortcut::register_startup(app.handle(), &cfg.shortcut.toggle) {
eprintln!("{err}");
}
#[cfg(target_os = "linux")]
if let Some(window) = app.get_webview_window("main") {
fix_csd_titlebar_input(&window);
Expand Down
62 changes: 62 additions & 0 deletions src-tauri/src/shortcut.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
use tauri::AppHandle;

#[cfg(target_os = "macos")]
use tauri_plugin_global_shortcut::GlobalShortcutExt;

#[cfg(target_os = "macos")]
pub(crate) fn register_startup(app: &AppHandle, shortcut: &str) -> Result<(), String> {
if shortcut.is_empty() {
return Ok(());
}
app.global_shortcut()
.register(shortcut)
.map_err(|err| format!("failed to register global shortcut {shortcut:?}: {err}"))
}

#[cfg(not(target_os = "macos"))]
pub(crate) fn register_startup(_app: &AppHandle, _shortcut: &str) -> Result<(), String> {
Ok(())
}

#[cfg(target_os = "macos")]
pub(crate) fn replace(app: &AppHandle, old: &str, new: &str) -> Result<(), String> {
if old == new {
return Ok(());
}

let shortcuts = app.global_shortcut();
if !new.is_empty() {
shortcuts.register(new).map_err(|err| {
format!("failed to register global shortcut {new:?}; the previous shortcut is still active: {err}")
})?;
}

if !old.is_empty()
&& shortcuts.is_registered(old)
&& let Err(err) = shortcuts.unregister(old)
{
if !new.is_empty() {
let _ = shortcuts.unregister(new);
}
return Err(format!(
"failed to replace global shortcut {old:?} with {new:?}; the previous shortcut is still active: {err}"
));
}

Ok(())
}

#[cfg(not(target_os = "macos"))]
pub(crate) fn replace(_app: &AppHandle, _old: &str, _new: &str) -> Result<(), String> {
Ok(())
}

#[cfg(all(test, target_os = "macos"))]
mod tests {
use tauri_plugin_global_shortcut::Shortcut;

#[test]
fn default_macos_shortcut_is_a_valid_accelerator() {
assert!("Cmd+Shift+Space".parse::<Shortcut>().is_ok());
}
}
45 changes: 45 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,24 @@ impl Default for PasteConfig {
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ShortcutConfig {
pub toggle: String,
}

impl Default for ShortcutConfig {
fn default() -> Self {
Self {
toggle: if cfg!(target_os = "macos") {
"Cmd+Shift+Space".into()
} else {
String::new()
},
}
}
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AppConfig {
Expand All @@ -160,6 +178,7 @@ pub struct AppConfig {
pub incremental: IncrementalConfig,
pub cleanup: CleanupConfig,
pub paste: PasteConfig,
pub shortcut: ShortcutConfig,
}

pub fn config_dir() -> PathBuf {
Expand Down Expand Up @@ -375,6 +394,32 @@ mod tests {
);
}

#[test]
fn shortcut_config_uses_platform_default() {
let cfg = AppConfig::default();
let expected = if cfg!(target_os = "macos") {
"Cmd+Shift+Space"
} else {
""
};

assert_eq!(cfg.shortcut.toggle, expected);
}

#[test]
fn shortcut_config_deserializes_partial_toml() {
let cfg: AppConfig = toml::from_str(
r#"
[shortcut]
toggle = "Cmd+Option+D"
"#,
)
.unwrap();

assert_eq!(cfg.shortcut.toggle, "Cmd+Option+D");
assert_eq!(cfg.general.theme, "system");
}

#[test]
fn incremental_config_defaults_to_live_local_segments_only() {
let cfg = AppConfig::default();
Expand Down
3 changes: 3 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ export interface AppConfig {
delay_ms: number;
copy_to_clipboard: boolean;
};
shortcut: {
toggle: string;
};
}

export type ReleaseStatus = "ships_now" | "blocked";
Expand Down
23 changes: 22 additions & 1 deletion src/lib/settingsDisplay.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { settingsSaveDisplayState } from "./settingsDisplay";
import { settingsPlatformDisplayState, settingsSaveDisplayState } from "./settingsDisplay";

describe("settingsSaveDisplayState", () => {
it("shows a visible, disabled header Save action while clean and no overlay", () => {
Expand All @@ -27,3 +27,24 @@ describe("settingsSaveDisplayState", () => {
}
});
});

describe("settingsPlatformDisplayState", () => {
it("shows the in-app shortcut only on macOS", () => {
expect(settingsPlatformDisplayState("macos")).toEqual({
shortcutFieldVisible: true,
desktopKeybindingHelpVisible: false,
});
expect(settingsPlatformDisplayState("linux")).toEqual({
shortcutFieldVisible: false,
desktopKeybindingHelpVisible: true,
});
expect(settingsPlatformDisplayState("windows")).toEqual({
shortcutFieldVisible: false,
desktopKeybindingHelpVisible: false,
});
expect(settingsPlatformDisplayState("web")).toEqual({
shortcutFieldVisible: false,
desktopKeybindingHelpVisible: false,
});
});
});
Loading