2
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[build]
|
||||
rustc-wrapper = "sccache"
|
||||
2
.gitignore
vendored
|
|
@ -23,4 +23,4 @@ dist-ssr
|
|||
*.sln
|
||||
*.sw?
|
||||
|
||||
src-tauri/resources
|
||||
src-tauri/resources/models
|
||||
BIN
bun.lockb
|
|
@ -16,12 +16,15 @@
|
|||
"@tauri-apps/plugin-clipboard-manager": "~2",
|
||||
"@tauri-apps/plugin-global-shortcut": "~2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-store": "~2",
|
||||
"@tauri-apps/plugin-stronghold": "~2",
|
||||
"@tauri-apps/plugin-upload": "~2",
|
||||
"keycode": "^2.2.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^4.0.2",
|
||||
"tauri-plugin-macos-permissions-api": "^2.0.4"
|
||||
"tauri-plugin-macos-permissions-api": "^2.0.4",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
|
|
|
|||
1543
src-tauri/Cargo.lock
generated
|
|
@ -5,6 +5,9 @@ description = "A Tauri App"
|
|||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
[profile.dev]
|
||||
incremental = true # Compile your binary in smaller steps.
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
|
|
@ -18,7 +21,8 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
once_cell = "1"
|
||||
tauri = { version = "2", features = ["tray-icon", 'image-png'] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
|
@ -29,14 +33,11 @@ cpal = "0.15.3"
|
|||
whisper-rs = { version = "0.13.2", features = ["whisper-cpp-log"] }
|
||||
anyhow = "1.0.95"
|
||||
rubato = "0.16.1"
|
||||
samplerate = "0.2.4"
|
||||
rig-core = "0.8.0"
|
||||
env_logger = "0.11.6"
|
||||
log = "0.4.25"
|
||||
tokio = "1.43.0"
|
||||
vad-rs = "0.1.5"
|
||||
tauri-plugin-upload = "2"
|
||||
tauri-plugin-stronghold = "2"
|
||||
tauri-plugin-store = "2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
whisper-rs = { version = "0.13.2", features = ["metal"] }
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@
|
|||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"store:default",
|
||||
"global-shortcut:allow-is-registered",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"global-shortcut:allow-unregister-all",
|
||||
"macos-permissions:default"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 53 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 66 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/logo.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
src-tauri/resources/tray_idle.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src-tauri/resources/tray_recording.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
86
src-tauri/src/actions.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
use crate::managers::audio::AudioRecordingManager;
|
||||
use crate::managers::transcription::TranscriptionManager;
|
||||
use crate::utils;
|
||||
use crate::utils::change_tray_icon;
|
||||
use crate::utils::TrayIconState;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Manager;
|
||||
|
||||
// Action Handler Types
|
||||
pub type PressAction = fn(app: &AppHandle, shortcut_str: &str);
|
||||
pub type ReleaseAction = fn(app: &AppHandle, shortcut_str: &str);
|
||||
|
||||
// TODO refactor to start and stop
|
||||
pub struct ActionSet {
|
||||
pub press: PressAction,
|
||||
pub release: ReleaseAction,
|
||||
}
|
||||
|
||||
// Handler Functions
|
||||
fn transcribe_pressed_action(app: &AppHandle, _shortcut_str: &str) {
|
||||
change_tray_icon(app, TrayIconState::Recording);
|
||||
|
||||
let rm = app.state::<Arc<AudioRecordingManager>>();
|
||||
rm.try_start_recording("transcribe");
|
||||
}
|
||||
|
||||
fn transcribe_released_action(app: &AppHandle, _shortcut_str: &str) {
|
||||
let ah = app.clone();
|
||||
let rm = Arc::clone(&app.state::<Arc<AudioRecordingManager>>());
|
||||
let tm = Arc::clone(&app.state::<Arc<TranscriptionManager>>());
|
||||
|
||||
change_tray_icon(app, TrayIconState::Idle);
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Some(samples) = rm.stop_recording("transcribe") {
|
||||
match tm.transcribe(samples) {
|
||||
Ok(transcription) => {
|
||||
println!("Global Shortcut Transcription: {}", transcription);
|
||||
if transcription != "" {
|
||||
utils::paste(transcription, ah);
|
||||
}
|
||||
}
|
||||
Err(err) => println!("Global Shortcut Transcription error: {}", err),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn test_binding_pressed_action(app: &AppHandle, shortcut_str: &str) {
|
||||
println!(
|
||||
"Shortcut ID 'test': Pressed - {} (App: {})",
|
||||
shortcut_str,
|
||||
app.package_info().name
|
||||
);
|
||||
}
|
||||
|
||||
fn test_binding_released_action(app: &AppHandle, shortcut_str: &str) {
|
||||
println!(
|
||||
"Shortcut ID 'test': Released - {} (App: {})",
|
||||
shortcut_str,
|
||||
app.package_info().name
|
||||
);
|
||||
}
|
||||
|
||||
// Static Action Map
|
||||
pub static ACTION_MAP: Lazy<HashMap<String, ActionSet>> = Lazy::new(|| {
|
||||
let mut map = HashMap::new();
|
||||
map.insert(
|
||||
"transcribe".to_string(),
|
||||
ActionSet {
|
||||
press: transcribe_pressed_action,
|
||||
release: transcribe_released_action,
|
||||
},
|
||||
);
|
||||
map.insert(
|
||||
"test".to_string(),
|
||||
ActionSet {
|
||||
press: test_binding_pressed_action,
|
||||
release: test_binding_released_action,
|
||||
},
|
||||
);
|
||||
map
|
||||
});
|
||||
|
|
@ -1,238 +1,32 @@
|
|||
mod actions;
|
||||
mod managers;
|
||||
mod settings;
|
||||
mod shortcut;
|
||||
mod utils;
|
||||
|
||||
use log::info;
|
||||
use managers::audio::AudioRecordingManager;
|
||||
use managers::keybinding::KeyBindingManager;
|
||||
use managers::transcription::TranscriptionManager;
|
||||
use rdev::{simulate, EventType, Key, SimulateError};
|
||||
use rig::{completion::Prompt, providers::anthropic};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::{thread, time};
|
||||
use tauri::image::Image;
|
||||
use tauri::menu::{Menu, MenuItem};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
|
||||
fn try_send_event(event: &EventType) {
|
||||
if let Err(SimulateError) = simulate(event) {
|
||||
println!("We could not send {:?}", event);
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct ShortcutToggleStates {
|
||||
// Map: shortcut_binding_id -> is_active
|
||||
active_toggles: HashMap<String, bool>,
|
||||
}
|
||||
|
||||
fn send(event: EventType) {
|
||||
try_send_event(&event);
|
||||
thread::sleep(time::Duration::from_millis(60));
|
||||
}
|
||||
|
||||
fn send_paste() {
|
||||
// Determine the modifier key based on the OS
|
||||
#[cfg(target_os = "macos")]
|
||||
let modifier_key = Key::MetaLeft; // Command key on macOS
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let modifier_key = Key::ControlLeft; // Control key on other systems
|
||||
|
||||
// Press both keys
|
||||
send(EventType::KeyPress(modifier_key));
|
||||
send(EventType::KeyPress(Key::KeyV));
|
||||
|
||||
// Release both keys
|
||||
send(EventType::KeyRelease(Key::KeyV));
|
||||
send(EventType::KeyRelease(modifier_key));
|
||||
}
|
||||
|
||||
fn send_copy() {
|
||||
// Determine the modifier key based on the OS
|
||||
#[cfg(target_os = "macos")]
|
||||
let modifier_key = Key::MetaLeft; // Command key on macOS
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let modifier_key = Key::ControlLeft; // Control key on other systems
|
||||
|
||||
// Press both keys
|
||||
send(EventType::KeyPress(modifier_key));
|
||||
send(EventType::KeyPress(Key::KeyC));
|
||||
|
||||
// Release both keys
|
||||
send(EventType::KeyRelease(Key::KeyC));
|
||||
send(EventType::KeyRelease(modifier_key));
|
||||
}
|
||||
|
||||
fn paste(text: String, app_handle: tauri::AppHandle) {
|
||||
let clipboard = app_handle.clipboard();
|
||||
|
||||
// get the current clipboard content
|
||||
let clipboard_content = clipboard.read_text().unwrap_or_default();
|
||||
|
||||
clipboard.write_text(&text).unwrap();
|
||||
send_paste();
|
||||
|
||||
// restore the clipboard
|
||||
clipboard.write_text(&clipboard_content).unwrap();
|
||||
}
|
||||
|
||||
fn get_highlighted_text(app_handle: tauri::AppHandle) -> String {
|
||||
let clipboard = app_handle.clipboard();
|
||||
|
||||
// save the clipboard content
|
||||
let clipboard_content = clipboard.read_text().unwrap_or_default();
|
||||
|
||||
// empty the clipboard
|
||||
clipboard.write_text("").unwrap();
|
||||
|
||||
// issue 'copy'
|
||||
send_copy();
|
||||
|
||||
// get the highlighted text
|
||||
let highlighted_text = clipboard.read_text().unwrap_or_default();
|
||||
|
||||
// restore the clipboard content
|
||||
clipboard.write_text(&clipboard_content).unwrap();
|
||||
|
||||
highlighted_text
|
||||
}
|
||||
|
||||
const INSTRUCT_SYS: &str = r#"
|
||||
You are a helpful assistant. You will receive voice transcriptions
|
||||
from a user that may include both a command/question and some
|
||||
minimal context to help you respond appropriately.
|
||||
|
||||
For example, the user might say:
|
||||
- A direct question with no context: "What is the capital of France?"
|
||||
- A command with context: "get commit message I fixed the bug in the login system"
|
||||
"#;
|
||||
|
||||
const CODE_SYS: &str = r#"
|
||||
You are a code-only assistant. I will provide you with selected text or clipboard content along with instructions. If I request code, output only the exact code implementation. If I request a terminal command, provide only the valid command syntax. Never use markdown, explanations, or additional text.
|
||||
|
||||
When I share selected text or clipboard content, use that as context for generating your response. The output should be ready to copy and paste directly, with no formatting or commentary. For terminal commands, ensure they are valid for the specified environment. Note for terminal commands, I typically use lowercase instead of uppercase. You may also be given them directly, but need to translate them into a way that can actually be executed in the terminal because the transcription you are given might be poor.
|
||||
|
||||
Output only:
|
||||
- Raw code implementation when code is requested
|
||||
- Terminal command syntax when a command is requested
|
||||
- No markdown, no backticks, no explanations
|
||||
- No additional text or descriptions
|
||||
"#;
|
||||
|
||||
fn register_bindings(manager: &mut KeyBindingManager) {
|
||||
manager.register(
|
||||
"ctrl-meta".to_string(),
|
||||
vec![Key::ControlRight, Key::MetaRight],
|
||||
|ctx| {
|
||||
info!("Ctrl+Meta pressed!");
|
||||
ctx.recording_manager.try_start_recording("ctrl-meta");
|
||||
None
|
||||
},
|
||||
|ctx| {
|
||||
info!("release being called from ctrl-meta");
|
||||
let ctx = ctx.clone();
|
||||
Some(tauri::async_runtime::spawn(async move {
|
||||
if let Some(samples) = ctx.recording_manager.stop_recording("ctrl-meta") {
|
||||
match ctx.transcription_manager.transcribe(samples) {
|
||||
Ok(transcription) => {
|
||||
println!("Transcription: {}", transcription);
|
||||
paste(transcription, ctx.app_handle.clone());
|
||||
}
|
||||
Err(err) => println!("Transcription error: {}", err),
|
||||
}
|
||||
}
|
||||
}))
|
||||
},
|
||||
);
|
||||
|
||||
// Register LLM Call after Transcription
|
||||
// manager.register(
|
||||
// "shift-alt".to_string(),
|
||||
// vec![Key::ShiftLeft, Key::Alt],
|
||||
// |ctx| {
|
||||
// info!("Shift+Alt pressed!");
|
||||
// ctx.recording_manager.try_start_recording("shift-alt");
|
||||
// None
|
||||
// },
|
||||
// |ctx| {
|
||||
// info!("release being called from shift-alt");
|
||||
// let ctx = ctx.clone();
|
||||
// Some(tauri::async_runtime::spawn(async move {
|
||||
// if let Some(samples) = ctx.recording_manager.stop_recording("shift-alt") {
|
||||
// if let Ok(transcription) = ctx.transcription_manager.transcribe(samples) {
|
||||
// println!("Transcription: {}", transcription);
|
||||
|
||||
// let instruct = ctx
|
||||
// .anthropic
|
||||
// .agent(anthropic::CLAUDE_3_5_SONNET)
|
||||
// .preamble(INSTRUCT_SYS)
|
||||
// .temperature(0.5)
|
||||
// .build();
|
||||
|
||||
// let highlighted_text = get_highlighted_text(ctx.app_handle.clone());
|
||||
// println!("Highlighted Text: {}", highlighted_text);
|
||||
// let prompt = format!("{}\n\ncontext:{}\n", transcription, highlighted_text);
|
||||
|
||||
// match instruct.prompt(prompt).await {
|
||||
// Ok(response) => {
|
||||
// println!("Sonnet response: {}", response);
|
||||
// paste(response, ctx.app_handle.clone());
|
||||
// }
|
||||
// Err(err) => println!("Sonnet error: {}", err),
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }))
|
||||
// },
|
||||
// );
|
||||
|
||||
// manager.register(
|
||||
// "ctrl-alt-meta".to_string(),
|
||||
// vec![Key::ControlLeft, Key::Alt, Key::MetaLeft],
|
||||
// |ctx| {
|
||||
// info!("Ctrl+Alt+Meta pressed!");
|
||||
// ctx.recording_manager.try_start_recording("ctrl-alt-meta");
|
||||
// None
|
||||
// },
|
||||
// |ctx| {
|
||||
// info!("release being called from ctrl-alt-meta");
|
||||
// let ctx = ctx.clone();
|
||||
// Some(tauri::async_runtime::spawn(async move {
|
||||
// if let Some(samples) = ctx.recording_manager.stop_recording("ctrl-alt-meta") {
|
||||
// let samples: Vec<f32> = samples; // explicit type annotation
|
||||
// match ctx.transcription_manager.transcribe(samples) {
|
||||
// Ok(transcription) => {
|
||||
// println!("Transcription: {}", transcription);
|
||||
|
||||
// let code = ctx
|
||||
// .anthropic
|
||||
// .agent(anthropic::CLAUDE_3_5_SONNET)
|
||||
// .preamble(CODE_SYS)
|
||||
// .temperature(0.5)
|
||||
// .build();
|
||||
|
||||
// let highlighted_text = get_highlighted_text(ctx.app_handle.clone());
|
||||
// let prompt =
|
||||
// format!("{}\n\ncontext:{}\n", transcription, highlighted_text);
|
||||
|
||||
// match code.prompt(prompt).await {
|
||||
// Ok(response) => {
|
||||
// println!("Sonnet response: {}", response);
|
||||
// paste(response, ctx.app_handle.clone());
|
||||
// }
|
||||
// Err(err) => println!("Sonnet error: {}", err),
|
||||
// }
|
||||
// }
|
||||
// Err(err) => println!("Transcription error: {}", err),
|
||||
// }
|
||||
// } else {
|
||||
// println!("No samples recorded");
|
||||
// }
|
||||
// }))
|
||||
// },
|
||||
// );
|
||||
}
|
||||
type ManagedToggleState = Mutex<ShortcutToggleStates>;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
env_logger::init();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_stronghold::Builder::new(|pass| todo!()).build())
|
||||
.plugin(tauri_plugin_upload::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
MacosLauncher::LaunchAgent,
|
||||
|
|
@ -240,63 +34,90 @@ pub fn run() {
|
|||
))
|
||||
.plugin(tauri_plugin_macos_permissions::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.manage(Mutex::new(ShortcutToggleStates::default()))
|
||||
.setup(move |app| {
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
let vad_path = app.path().resolve(
|
||||
"resources/silero_vad_v4.onnx",
|
||||
tauri::path::BaseDirectory::Resource,
|
||||
)?;
|
||||
|
||||
let whisper_path = app.path().resolve(
|
||||
"resources/ggml-small.bin",
|
||||
tauri::path::BaseDirectory::Resource,
|
||||
)?;
|
||||
let settings_i = MenuItem::with_id(app, "settings", "Settings", true, None::<&str>)?;
|
||||
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&settings_i, &quit_i])?;
|
||||
let tray = TrayIconBuilder::new()
|
||||
.icon(Image::from_path(app.path().resolve(
|
||||
"resources/tray_idle.png",
|
||||
tauri::path::BaseDirectory::Resource,
|
||||
)?)?)
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(true)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"settings" => {
|
||||
if let Some(settings_window) = app.get_webview_window("main") {
|
||||
// First, ensure the window is visible
|
||||
if let Err(e) = settings_window.show() {
|
||||
eprintln!("Failed to show window: {}", e);
|
||||
}
|
||||
// Then, bring it to the front and give it focus
|
||||
if let Err(e) = settings_window.set_focus() {
|
||||
eprintln!("Failed to focus window: {}", e);
|
||||
}
|
||||
// Optional: On macOS, ensure the app becomes active if it was an accessory
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Err(e) =
|
||||
app.set_activation_policy(tauri::ActivationPolicy::Regular)
|
||||
{
|
||||
eprintln!("Failed to set activation policy to Regular: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Main window not found");
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.build(app)?;
|
||||
app.manage(tray);
|
||||
|
||||
let recording_manager = Arc::new(
|
||||
AudioRecordingManager::new(&vad_path)
|
||||
.expect("Failed to initialize recording manager"),
|
||||
AudioRecordingManager::new(app).expect("Failed to initialize recording manager"),
|
||||
);
|
||||
let transcription_manager = Arc::new(
|
||||
TranscriptionManager::new(
|
||||
whisper_path
|
||||
.to_str()
|
||||
.expect("Path contains invalid UTF-8 Chars"),
|
||||
)
|
||||
.expect("Failed to initialize transcription manager"),
|
||||
TranscriptionManager::new(&app)
|
||||
.expect("Failed to initialize transcription manager"),
|
||||
);
|
||||
|
||||
let manager = Arc::new(Mutex::new(KeyBindingManager::new(
|
||||
recording_manager.clone(),
|
||||
transcription_manager.clone(),
|
||||
// claude_client.clone(),
|
||||
app_handle.clone(),
|
||||
)));
|
||||
// Add managers to Tauri's managed state
|
||||
app.manage(recording_manager.clone());
|
||||
app.manage(transcription_manager.clone());
|
||||
|
||||
{
|
||||
let mut manager = manager.lock().unwrap();
|
||||
register_bindings(&mut manager);
|
||||
}
|
||||
shortcut::init_shortcuts(app);
|
||||
|
||||
let manager_clone = manager.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
rdev::listen(move |event| {
|
||||
if let Ok(manager) = manager_clone.lock() {
|
||||
manager.handle_event(&event);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|app, event| match event {
|
||||
.on_window_event(|window, event| match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
app.hide().unwrap();
|
||||
#[cfg(target_os = "macos")]
|
||||
let res = window
|
||||
.app_handle()
|
||||
.set_activation_policy(tauri::ActivationPolicy::Accessory);
|
||||
if let Err(e) = res {
|
||||
println!("Failed to set activation policy: {}", e);
|
||||
}
|
||||
|
||||
api.prevent_close();
|
||||
|
||||
// TODO may be different on windows, this works for macos
|
||||
tauri::AppHandle::hide(window.app_handle()).unwrap();
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
shortcut::change_binding,
|
||||
shortcut::reset_binding,
|
||||
shortcut::change_ptt_setting
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::SampleFormat;
|
||||
use rubato::{FftFixedIn, Resampler};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::vec::Vec;
|
||||
use vad_rs::{Vad, VadStatus};
|
||||
use tauri::{App, Manager};
|
||||
use vad_rs::Vad;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum RecordingState {
|
||||
|
|
@ -16,8 +17,40 @@ pub struct AudioRecordingManager {
|
|||
buffer: Arc<Mutex<Vec<f32>>>,
|
||||
}
|
||||
|
||||
trait SampleToF32 {
|
||||
fn to_f32(&self) -> f32;
|
||||
}
|
||||
|
||||
impl SampleToF32 for i8 {
|
||||
fn to_f32(&self) -> f32 {
|
||||
*self as f32 / 128.0
|
||||
}
|
||||
}
|
||||
|
||||
impl SampleToF32 for i16 {
|
||||
fn to_f32(&self) -> f32 {
|
||||
*self as f32 / 32768.0
|
||||
}
|
||||
}
|
||||
|
||||
impl SampleToF32 for i32 {
|
||||
fn to_f32(&self) -> f32 {
|
||||
*self as f32 / 2147483648.0
|
||||
}
|
||||
}
|
||||
|
||||
impl SampleToF32 for f32 {
|
||||
fn to_f32(&self) -> f32 {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioRecordingManager {
|
||||
pub fn new(vad_path: &PathBuf) -> Result<Self, anyhow::Error> {
|
||||
pub fn new(app: &App) -> Result<Self, anyhow::Error> {
|
||||
let vad_path = app.path().resolve(
|
||||
"resources/models/silero_vad_v4.onnx",
|
||||
tauri::path::BaseDirectory::Resource,
|
||||
)?;
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_input_device()
|
||||
|
|
@ -48,64 +81,140 @@ impl AudioRecordingManager {
|
|||
let vad_buffer = Arc::new(Mutex::new(Vec::new()));
|
||||
let vad_buffer_clone = Arc::clone(&vad_buffer);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let stream = match config.sample_format() {
|
||||
cpal::SampleFormat::F32 => device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
||||
let state_guard = state_clone.lock().unwrap();
|
||||
if let RecordingState::Recording { .. } = *state_guard {
|
||||
let mut temp_buffer = temp_buffer_clone.lock().unwrap();
|
||||
temp_buffer.extend_from_slice(data);
|
||||
// Generic function to process audio data
|
||||
fn process_audio<T: SampleToF32 + Send + 'static>(
|
||||
data: &[T],
|
||||
state_clone: Arc<Mutex<RecordingState>>,
|
||||
temp_buffer_clone: Arc<Mutex<Vec<f32>>>,
|
||||
resampler_clone: Arc<Mutex<FftFixedIn<f32>>>,
|
||||
vad_buffer_clone: Arc<Mutex<Vec<f32>>>,
|
||||
buffer_clone: Arc<Mutex<Vec<f32>>>,
|
||||
vad_clone: Arc<Mutex<Vad>>,
|
||||
) {
|
||||
let state_guard = state_clone.lock().unwrap();
|
||||
if let RecordingState::Recording { .. } = *state_guard {
|
||||
let mut temp_buffer = temp_buffer_clone.lock().unwrap();
|
||||
|
||||
// Process when we have enough samples
|
||||
while temp_buffer.len() >= 1024 {
|
||||
// Take the first 1024 samples for processing
|
||||
let chunk: Vec<f32> = temp_buffer.drain(..1024).collect();
|
||||
// Convert incoming data to f32
|
||||
let f32_data: Vec<f32> = data.iter().map(|sample| sample.to_f32()).collect();
|
||||
temp_buffer.extend_from_slice(&f32_data);
|
||||
|
||||
// Convert input data to the format expected by Rubato
|
||||
let input_frames = vec![chunk];
|
||||
// Process when we have enough samples
|
||||
while temp_buffer.len() >= 1024 {
|
||||
// Take the first 1024 samples for processing
|
||||
let chunk: Vec<f32> = temp_buffer.drain(..1024).collect();
|
||||
|
||||
// Process the audio chunk through the resampler
|
||||
let mut resampler = resampler_clone.lock().unwrap();
|
||||
if let Ok(resampled) = resampler.process(&input_frames, None) {
|
||||
// Add resampled data to VAD buffer
|
||||
let mut vad_buffer = vad_buffer_clone.lock().unwrap();
|
||||
vad_buffer.extend_from_slice(&resampled[0]);
|
||||
// Convert input data to the format expected by Rubato
|
||||
let input_frames = vec![chunk];
|
||||
|
||||
// Process 30ms chunks (480 samples) for VAD
|
||||
while vad_buffer.len() >= 480 {
|
||||
let chunk = vad_buffer.drain(..480).collect::<Vec<f32>>();
|
||||
// Process the audio chunk through the resampler
|
||||
let mut resampler = resampler_clone.lock().unwrap();
|
||||
if let Ok(resampled) = resampler.process(&input_frames, None) {
|
||||
// Add resampled data to VAD buffer
|
||||
let mut vad_buffer = vad_buffer_clone.lock().unwrap();
|
||||
vad_buffer.extend_from_slice(&resampled[0]);
|
||||
|
||||
// Use VAD to detect speech
|
||||
if let Ok(mut vad) = vad_clone.lock() {
|
||||
// println!("VAD lock acquired");
|
||||
match vad.compute(&chunk) {
|
||||
Ok(mut result) => {
|
||||
if result.prob > 0.15 {
|
||||
let mut buffer =
|
||||
buffer_clone.lock().unwrap();
|
||||
buffer.extend_from_slice(&chunk);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Error computing VAD: {:?}", error)
|
||||
}
|
||||
}
|
||||
// Process 30ms chunks (480 samples) for VAD
|
||||
while vad_buffer.len() >= 480 {
|
||||
let chunk = vad_buffer.drain(..480).collect::<Vec<f32>>();
|
||||
|
||||
// Use VAD to detect speech
|
||||
if let Ok(mut vad) = vad_clone.lock() {
|
||||
match vad.compute(&chunk) {
|
||||
Ok(result) => {
|
||||
if result.prob > 0.15 {
|
||||
let mut buffer = buffer_clone.lock().unwrap();
|
||||
buffer.extend_from_slice(&chunk);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Error computing VAD: {:?}", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let err_fn = |err| eprintln!("Error in stream: {}", err);
|
||||
|
||||
// Build the appropriate stream based on the sample format
|
||||
let stream = match config.sample_format() {
|
||||
SampleFormat::I8 => device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data: &[i8], _| {
|
||||
process_audio(
|
||||
data,
|
||||
Arc::clone(&state_clone),
|
||||
Arc::clone(&temp_buffer_clone),
|
||||
Arc::clone(&resampler_clone),
|
||||
Arc::clone(&vad_buffer_clone),
|
||||
Arc::clone(&buffer_clone),
|
||||
Arc::clone(&vad_clone),
|
||||
)
|
||||
},
|
||||
|err| eprintln!("Error in stream: {}", err),
|
||||
err_fn,
|
||||
None,
|
||||
),
|
||||
sample_format => panic!("Unsupported sample format: {:?}", sample_format),
|
||||
SampleFormat::I16 => device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data: &[i16], _| {
|
||||
process_audio(
|
||||
data,
|
||||
Arc::clone(&state_clone),
|
||||
Arc::clone(&temp_buffer_clone),
|
||||
Arc::clone(&resampler_clone),
|
||||
Arc::clone(&vad_buffer_clone),
|
||||
Arc::clone(&buffer_clone),
|
||||
Arc::clone(&vad_clone),
|
||||
)
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
),
|
||||
SampleFormat::I32 => device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data: &[i32], _| {
|
||||
process_audio(
|
||||
data,
|
||||
Arc::clone(&state_clone),
|
||||
Arc::clone(&temp_buffer_clone),
|
||||
Arc::clone(&resampler_clone),
|
||||
Arc::clone(&vad_buffer_clone),
|
||||
Arc::clone(&buffer_clone),
|
||||
Arc::clone(&vad_clone),
|
||||
)
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
),
|
||||
SampleFormat::F32 => device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data: &[f32], _| {
|
||||
process_audio(
|
||||
data,
|
||||
Arc::clone(&state_clone),
|
||||
Arc::clone(&temp_buffer_clone),
|
||||
Arc::clone(&resampler_clone),
|
||||
Arc::clone(&vad_buffer_clone),
|
||||
Arc::clone(&buffer_clone),
|
||||
Arc::clone(&vad_clone),
|
||||
)
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
),
|
||||
sample_format => {
|
||||
// Use anyhow to return a proper error instead of panicking
|
||||
panic!("Unsupported sample format: {:?}", sample_format);
|
||||
}
|
||||
}
|
||||
.unwrap();
|
||||
.expect("Failed to build input stream");
|
||||
|
||||
stream.play().unwrap();
|
||||
stream.play().expect("Failed to play stream");
|
||||
std::thread::park();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
use super::audio::AudioRecordingManager;
|
||||
use super::transcription::TranscriptionManager;
|
||||
use rdev::{EventType, Key};
|
||||
use rig::providers::anthropic;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
type KeySet = HashSet<Key>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BindingContext {
|
||||
pub recording_manager: Arc<AudioRecordingManager>,
|
||||
pub transcription_manager: Arc<TranscriptionManager>,
|
||||
// pub anthropic: Arc<anthropic::Client>,
|
||||
pub app_handle: tauri::AppHandle,
|
||||
}
|
||||
|
||||
pub struct KeyBinding {
|
||||
id: String,
|
||||
keys: KeySet,
|
||||
on_press: Box<
|
||||
dyn Fn(&BindingContext) -> Option<tauri::async_runtime::JoinHandle<()>> + Send + 'static,
|
||||
>,
|
||||
on_release: Box<
|
||||
dyn Fn(&BindingContext) -> Option<tauri::async_runtime::JoinHandle<()>> + Send + 'static,
|
||||
>,
|
||||
}
|
||||
|
||||
impl KeyBinding {
|
||||
fn new<F, G>(id: String, keys: Vec<Key>, on_press: F, on_release: G) -> Self
|
||||
where
|
||||
F: Fn(&BindingContext) -> Option<tauri::async_runtime::JoinHandle<()>> + Send + 'static,
|
||||
G: Fn(&BindingContext) -> Option<tauri::async_runtime::JoinHandle<()>> + Send + 'static,
|
||||
{
|
||||
Self {
|
||||
id,
|
||||
keys: keys.into_iter().collect(),
|
||||
on_press: Box::new(on_press),
|
||||
on_release: Box::new(on_release),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct KeyBindingManager {
|
||||
bindings: Vec<KeyBinding>,
|
||||
global_pressed: Arc<Mutex<HashSet<Key>>>,
|
||||
active_bindings: Arc<Mutex<HashSet<String>>>,
|
||||
context: BindingContext,
|
||||
}
|
||||
|
||||
impl KeyBindingManager {
|
||||
pub fn new(
|
||||
recording_manager: Arc<AudioRecordingManager>,
|
||||
transcription_manager: Arc<TranscriptionManager>,
|
||||
// anthropic: Arc<anthropic::Client>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Self {
|
||||
Self {
|
||||
bindings: Vec::new(),
|
||||
global_pressed: Arc::new(Mutex::new(HashSet::new())),
|
||||
active_bindings: Arc::new(Mutex::new(HashSet::new())),
|
||||
context: BindingContext {
|
||||
recording_manager,
|
||||
transcription_manager,
|
||||
// anthropic,
|
||||
app_handle,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register<F, G>(&mut self, id: String, keys: Vec<Key>, on_press: F, on_release: G)
|
||||
where
|
||||
F: Fn(&BindingContext) -> Option<tauri::async_runtime::JoinHandle<()>> + Send + 'static,
|
||||
G: Fn(&BindingContext) -> Option<tauri::async_runtime::JoinHandle<()>> + Send + 'static,
|
||||
{
|
||||
self.bindings
|
||||
.push(KeyBinding::new(id, keys, on_press, on_release));
|
||||
}
|
||||
|
||||
pub fn handle_event(&self, event: &rdev::Event) {
|
||||
if let EventType::KeyPress(key) | EventType::KeyRelease(key) = event.event_type {
|
||||
let mut is_press = matches!(event.event_type, EventType::KeyPress(_));
|
||||
|
||||
// Update global pressed keys
|
||||
let mut pressed = self.global_pressed.lock().unwrap();
|
||||
|
||||
// If we get a release event for a key that wasn't pressed,
|
||||
// treat it as a press event instead
|
||||
if !is_press && !pressed.contains(&key) {
|
||||
is_press = true;
|
||||
}
|
||||
|
||||
if is_press {
|
||||
pressed.insert(key);
|
||||
} else {
|
||||
pressed.remove(&key);
|
||||
}
|
||||
let pressed_snapshot = pressed.clone();
|
||||
drop(pressed);
|
||||
|
||||
// Check binding states
|
||||
let mut active = self.active_bindings.lock().unwrap();
|
||||
for binding in &self.bindings {
|
||||
let is_active = active.contains(&binding.id);
|
||||
let all_required_pressed = binding.keys.is_subset(&pressed_snapshot);
|
||||
let any_required_key = binding.keys.contains(&key);
|
||||
|
||||
// Only process if this event is for a key we care about
|
||||
if any_required_key {
|
||||
match (all_required_pressed, is_active) {
|
||||
(true, false) => {
|
||||
(binding.on_press)(&self.context);
|
||||
active.insert(binding.id.clone());
|
||||
}
|
||||
(false, true) => {
|
||||
// Only trigger release if none of the required keys are pressed
|
||||
let any_binding_key_pressed =
|
||||
binding.keys.iter().any(|k| pressed_snapshot.contains(k));
|
||||
|
||||
if !any_binding_key_pressed {
|
||||
(binding.on_release)(&self.context);
|
||||
active.remove(&binding.id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
pub mod audio;
|
||||
pub mod keybinding;
|
||||
pub mod transcription;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use anyhow::Result;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{App, Manager};
|
||||
use whisper_rs::install_whisper_log_trampoline;
|
||||
use whisper_rs::{
|
||||
FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters, WhisperState,
|
||||
|
|
@ -11,12 +12,19 @@ pub struct TranscriptionManager {
|
|||
}
|
||||
|
||||
impl TranscriptionManager {
|
||||
pub fn new(whisper_path: &str) -> Result<Self> {
|
||||
pub fn new(app: &App) -> Result<Self> {
|
||||
let whisper_path = app.path().resolve(
|
||||
"resources/models/ggml-small.bin",
|
||||
tauri::path::BaseDirectory::Resource,
|
||||
)?;
|
||||
let path = whisper_path
|
||||
.to_str()
|
||||
.expect("Path contains invalid UTF-8 Chars");
|
||||
|
||||
install_whisper_log_trampoline();
|
||||
// Load the model
|
||||
let context =
|
||||
WhisperContext::new_with_params(whisper_path, WhisperContextParameters::default())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load whisper model: {}", e))?;
|
||||
let context = WhisperContext::new_with_params(path, WhisperContextParameters::default())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load whisper model: {}", e))?;
|
||||
|
||||
// Create state
|
||||
let state = context.create_state().expect("failed to create state");
|
||||
|
|
@ -46,6 +54,7 @@ impl TranscriptionManager {
|
|||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
params.set_print_timestamps(false);
|
||||
params.set_suppress_blank(true);
|
||||
params.set_suppress_non_speech_tokens(true);
|
||||
|
||||
state
|
||||
|
|
|
|||
121
src-tauri/src/settings.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tauri::{App, AppHandle};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ShortcutBinding {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub default_binding: String,
|
||||
pub current_binding: String,
|
||||
}
|
||||
|
||||
/* still handy for composing the initial JSON in the store ------------- */
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct AppSettings {
|
||||
pub bindings: HashMap<String, ShortcutBinding>,
|
||||
pub push_to_talk: bool,
|
||||
}
|
||||
|
||||
pub const SETTINGS_STORE_PATH: &str = "settings_store.json";
|
||||
|
||||
pub fn get_default_settings() -> AppSettings {
|
||||
let mut bindings = HashMap::new();
|
||||
bindings.insert(
|
||||
"transcribe".to_string(),
|
||||
ShortcutBinding {
|
||||
id: "transcribe".to_string(),
|
||||
name: "Transcribe".to_string(),
|
||||
description: "Converts your speech into text.".to_string(),
|
||||
default_binding: "alt+space".to_string(),
|
||||
current_binding: "alt+space".to_string(),
|
||||
},
|
||||
);
|
||||
// bindings.insert(
|
||||
// "test".to_string(),
|
||||
// ShortcutBinding {
|
||||
// id: "test".to_string(),
|
||||
// name: "Test".to_string(),
|
||||
// description: "This is a test binding.".to_string(),
|
||||
// default_binding: "ctrl+d".to_string(),
|
||||
// current_binding: "ctrl+d".to_string(),
|
||||
// },
|
||||
// );
|
||||
|
||||
AppSettings {
|
||||
bindings,
|
||||
push_to_talk: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_or_create_app_settings(app: &App) -> AppSettings {
|
||||
// Initialize store
|
||||
let store = app
|
||||
.store(SETTINGS_STORE_PATH)
|
||||
.expect("Failed to initialize store");
|
||||
|
||||
let settings = if let Some(settings_value) = store.get("settings") {
|
||||
// Parse the entire settings object
|
||||
match serde_json::from_value::<AppSettings>(settings_value) {
|
||||
Ok(settings) => {
|
||||
println!("Found existing settings: {:?}", settings);
|
||||
|
||||
settings
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to parse settings: {}", e);
|
||||
// Fall back to default settings if parsing fails
|
||||
let default_settings = get_default_settings();
|
||||
|
||||
// Store the default settings
|
||||
store.set("settings", serde_json::to_value(&default_settings).unwrap());
|
||||
|
||||
default_settings
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create default settings
|
||||
let default_settings = get_default_settings();
|
||||
|
||||
// Store the settings
|
||||
store.set("settings", serde_json::to_value(&default_settings).unwrap());
|
||||
|
||||
default_settings
|
||||
};
|
||||
|
||||
settings
|
||||
}
|
||||
|
||||
pub fn get_settings(app: &AppHandle) -> AppSettings {
|
||||
let store = app
|
||||
.store(SETTINGS_STORE_PATH)
|
||||
.expect("Failed to initialize store");
|
||||
|
||||
let settings = serde_json::from_value::<AppSettings>(store.get("settings").unwrap()).unwrap();
|
||||
|
||||
settings
|
||||
}
|
||||
|
||||
pub fn write_settings(app: &AppHandle, settings: AppSettings) {
|
||||
let store = app
|
||||
.store(SETTINGS_STORE_PATH)
|
||||
.expect("Failed to initialize store");
|
||||
|
||||
store.set("settings", serde_json::to_value(&settings).unwrap());
|
||||
}
|
||||
|
||||
pub fn get_bindings(app: &AppHandle) -> HashMap<String, ShortcutBinding> {
|
||||
let settings = get_settings(app);
|
||||
|
||||
settings.bindings
|
||||
}
|
||||
|
||||
pub fn get_stored_binding(app: &AppHandle, id: &str) -> ShortcutBinding {
|
||||
let bindings = get_bindings(app);
|
||||
|
||||
let binding = bindings.get(id).unwrap().clone();
|
||||
|
||||
binding
|
||||
}
|
||||
170
src-tauri/src/shortcut.rs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
use serde::Serialize;
|
||||
use tauri::{App, AppHandle, Manager};
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
use tauri_plugin_global_shortcut::{Shortcut, ShortcutState};
|
||||
|
||||
use crate::actions::ACTION_MAP;
|
||||
use crate::settings::ShortcutBinding;
|
||||
use crate::settings::{self, get_settings};
|
||||
use crate::ManagedToggleState;
|
||||
|
||||
pub fn init_shortcuts(app: &App) {
|
||||
let settings = settings::load_or_create_app_settings(app);
|
||||
|
||||
// Register shortcuts with the bindings from settings
|
||||
for (_id, binding) in settings.bindings {
|
||||
// Pass app.handle() which is &AppHandle
|
||||
if let Err(e) = _register_shortcut(app.handle(), binding) {
|
||||
eprintln!("Failed to register shortcut {}: {}", _id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BindingResponse {
|
||||
success: bool,
|
||||
binding: Option<ShortcutBinding>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn change_binding(
|
||||
app: AppHandle,
|
||||
id: String,
|
||||
binding: String,
|
||||
) -> Result<BindingResponse, String> {
|
||||
let mut settings = settings::get_settings(&app);
|
||||
|
||||
// Get the binding to modify
|
||||
let binding_to_modify = match settings.bindings.get(&id) {
|
||||
Some(binding) => binding.clone(),
|
||||
None => {
|
||||
return Ok(BindingResponse {
|
||||
success: false,
|
||||
binding: None,
|
||||
error: Some(format!("Binding with id '{}' not found", id)),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// Unregister the existing binding
|
||||
if let Err(e) = _unregister_shortcut(&app, binding_to_modify.clone()) {
|
||||
return Ok(BindingResponse {
|
||||
success: false,
|
||||
binding: None,
|
||||
error: Some(format!("Failed to unregister shortcut: {}", e)),
|
||||
});
|
||||
}
|
||||
|
||||
// Create an updated binding
|
||||
let mut updated_binding = binding_to_modify;
|
||||
updated_binding.current_binding = binding;
|
||||
|
||||
// Register the new binding
|
||||
if let Err(e) = _register_shortcut(&app, updated_binding.clone()) {
|
||||
return Ok(BindingResponse {
|
||||
success: false,
|
||||
binding: None,
|
||||
error: Some(format!("Failed to register shortcut: {}", e)),
|
||||
});
|
||||
}
|
||||
|
||||
// Update the binding in the settings
|
||||
settings.bindings.insert(id, updated_binding.clone());
|
||||
|
||||
// Save the settings
|
||||
settings::write_settings(&app, settings);
|
||||
|
||||
// Return the updated binding
|
||||
Ok(BindingResponse {
|
||||
success: true,
|
||||
binding: Some(updated_binding),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn reset_binding(app: AppHandle, id: String) -> Result<BindingResponse, String> {
|
||||
let binding = settings::get_stored_binding(&app, &id);
|
||||
|
||||
return change_binding(app, id, binding.default_binding);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn change_ptt_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
|
||||
let mut settings = settings::get_settings(&app);
|
||||
|
||||
// TODO if the setting is currently false, we probably want to
|
||||
// cancel any ongoing recordings or actions
|
||||
settings.push_to_talk = enabled;
|
||||
|
||||
settings::write_settings(&app, settings);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
|
||||
// Parse shortcut and return error if it fails
|
||||
let shortcut = match binding.current_binding.parse::<Shortcut>() {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Err(format!("Failed to parse shortcut: {}", e)),
|
||||
};
|
||||
|
||||
// Clone binding.id for use in the closure
|
||||
let binding_id_for_closure = binding.id.clone();
|
||||
|
||||
app.global_shortcut()
|
||||
.on_shortcut(shortcut, move |ah, scut, event| {
|
||||
if scut == &shortcut {
|
||||
let shortcut_string = scut.into_string();
|
||||
let settings = get_settings(ah);
|
||||
|
||||
if let Some(action_set) = ACTION_MAP.get(&binding_id_for_closure) {
|
||||
if settings.push_to_talk {
|
||||
if event.state == ShortcutState::Pressed {
|
||||
(action_set.press)(ah, &shortcut_string);
|
||||
} else if event.state == ShortcutState::Released {
|
||||
(action_set.release)(ah, &shortcut_string);
|
||||
}
|
||||
} else {
|
||||
if event.state == ShortcutState::Pressed {
|
||||
let toggle_state_manager = ah.state::<ManagedToggleState>();
|
||||
|
||||
let mut states = toggle_state_manager.lock().expect("Failed to lock toggle state manager");
|
||||
|
||||
let is_currently_active = states.active_toggles.entry(binding_id_for_closure.clone()).or_insert(false);
|
||||
|
||||
if *is_currently_active {
|
||||
(action_set.release)(ah, &shortcut_string);
|
||||
*is_currently_active = false; // Update state to inactive
|
||||
} else {
|
||||
(action_set.press)(ah, &shortcut_string);
|
||||
*is_currently_active = true; // Update state to active
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"Warning: No action defined in ACTION_MAP for shortcut ID '{}'. Shortcut: '{}', State: {:?}",
|
||||
binding_id_for_closure, shortcut_string, event.state
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("Couldn't register shortcut: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn _unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
|
||||
let shortcut = match binding.current_binding.parse::<Shortcut>() {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Err(format!("Failed to parse shortcut: {}", e)),
|
||||
};
|
||||
|
||||
app.global_shortcut()
|
||||
.unregister(shortcut)
|
||||
.map_err(|e| format!("Failed to unregister shortcut: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
71
src-tauri/src/utils.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
use rdev::{simulate, EventType, Key, SimulateError};
|
||||
use std::thread;
|
||||
use std::time;
|
||||
use tauri::image::Image;
|
||||
use tauri::tray::TrayIcon;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
|
||||
fn try_send_event(event: &EventType) {
|
||||
if let Err(SimulateError) = simulate(event) {
|
||||
println!("We could not send {:?}", event);
|
||||
}
|
||||
}
|
||||
|
||||
fn send_with_delay(event: EventType, delay_ms: u64) {
|
||||
try_send_event(&event);
|
||||
thread::sleep(time::Duration::from_millis(delay_ms));
|
||||
}
|
||||
|
||||
fn send_paste() {
|
||||
// Determine the modifier key based on the OS
|
||||
#[cfg(target_os = "macos")]
|
||||
let modifier_key = Key::MetaLeft; // Command key on macOS
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let modifier_key = Key::ControlLeft; // Control key on other systems
|
||||
|
||||
// Press both keys
|
||||
send_with_delay(EventType::KeyPress(modifier_key), 100);
|
||||
send_with_delay(EventType::KeyPress(Key::KeyV), 100);
|
||||
|
||||
// Release both keys
|
||||
send_with_delay(EventType::KeyRelease(Key::KeyV), 100);
|
||||
send_with_delay(EventType::KeyRelease(modifier_key), 0);
|
||||
}
|
||||
|
||||
pub fn paste(text: String, app_handle: AppHandle) {
|
||||
let clipboard = app_handle.clipboard();
|
||||
|
||||
// get the current clipboard content
|
||||
let clipboard_content = clipboard.read_text().unwrap_or_default();
|
||||
|
||||
clipboard.write_text(&text).unwrap();
|
||||
send_paste();
|
||||
|
||||
// restore the clipboard
|
||||
clipboard.write_text(&clipboard_content).unwrap();
|
||||
}
|
||||
|
||||
pub enum TrayIconState {
|
||||
Idle,
|
||||
Recording,
|
||||
}
|
||||
|
||||
pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) {
|
||||
let tray = app.state::<TrayIcon>();
|
||||
|
||||
let icon_path = match icon {
|
||||
TrayIconState::Idle => "resources/tray_idle.png",
|
||||
TrayIconState::Recording => "resources/tray_recording.png",
|
||||
};
|
||||
|
||||
let _ = tray.set_icon(Some(
|
||||
Image::from_path(
|
||||
app.path()
|
||||
.resolve(icon_path, tauri::path::BaseDirectory::Resource)
|
||||
.expect("failed to resolve"),
|
||||
)
|
||||
.expect("failed to set icon"),
|
||||
));
|
||||
}
|
||||
|
|
@ -13,8 +13,9 @@
|
|||
"windows": [
|
||||
{
|
||||
"title": "Handy",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
"width": 540,
|
||||
"height": 400,
|
||||
"resizable": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
|
@ -24,13 +25,15 @@
|
|||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"resources": ["resources/*"],
|
||||
"resources": ["resources/**/*"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
"icons/icon.ico",
|
||||
"icons/tray_32x32.png",
|
||||
"icons/tray_64x64.png"
|
||||
],
|
||||
"macOS": {
|
||||
"files": {},
|
||||
|
|
|
|||
47
src/App.css
|
|
@ -1,27 +1,37 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@font-face {
|
||||
src: url("/src/assets/fonts/ShortStack-Regular.ttf");
|
||||
font-family: 'Short Stack';
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@theme {
|
||||
--color-text: #0f0f0f;
|
||||
--color-background: #fbfbfb;
|
||||
--color-logo-primary: #FAA2CA;
|
||||
--color-logo-stroke: #382731;
|
||||
--color-text-stroke: #f6f6f6;
|
||||
--color-mid-gray: #808080;
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: 'Short Stack', sans-serif;
|
||||
/* Typography */
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #0f0f0f;
|
||||
background-color: #DACEBA;
|
||||
/* Colors - Light Theme */
|
||||
/* --color-text: #0f0f0f;
|
||||
--color-background: #fbfbfb;
|
||||
--color-logo-primary: #FAA2CA;
|
||||
--color-logo-stroke: #382731;
|
||||
--color-text-stroke: #f6f6f6; */
|
||||
|
||||
/* Typography settings */
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
/* Apply colors */
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
.container {
|
||||
|
|
@ -35,13 +45,26 @@
|
|||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color: #f6f6f6;
|
||||
background-color: #3E394B;
|
||||
/* Colors - Dark Theme */
|
||||
--color-text: #fbfbfb;
|
||||
--color-background: #2c2b29;
|
||||
--color-logo-primary: #F28CBB;
|
||||
--color-logo-stroke: #FAD1ED;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-stroke {
|
||||
-webkit-text-stroke: 2px #f6f6f6;
|
||||
-webkit-text-stroke: 2px var(--color-text-stroke);
|
||||
}
|
||||
}
|
||||
|
||||
.logo-primary {
|
||||
fill: var(--color-logo-primary);
|
||||
}
|
||||
|
||||
.logo-stroke {
|
||||
fill: var(--color-logo-stroke);
|
||||
stroke: var(--color-logo-stroke);
|
||||
stroke-width: 1;
|
||||
}
|
||||
36
src/App.tsx
|
|
@ -1,39 +1,13 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import "./App.css";
|
||||
import { Settings } from "./components/settings/Settings";
|
||||
import {
|
||||
checkAccessibilityPermissions,
|
||||
requestAccessibilityPermissions,
|
||||
} from "tauri-plugin-macos-permissions-api";
|
||||
import HandyTextLogo from "./components/icons/HandyTextLogo";
|
||||
import AccessibilityPermissions from "./components/AccessibilityPermissions";
|
||||
|
||||
function App() {
|
||||
const [hasAccessibility, setHasAccessibility] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkAccessibilityPermissions().then((hasPermissions) => {
|
||||
if (!hasPermissions) {
|
||||
requestAccessibilityPermissions().then((permissions) => {
|
||||
setHasAccessibility(permissions);
|
||||
});
|
||||
return;
|
||||
}
|
||||
setHasAccessibility(hasPermissions);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{!hasAccessibility && (
|
||||
<div className="bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-yellow-800">
|
||||
Accessibility permissions are required for keyboard shortcuts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-screen flex flex-col items-center pt-6 w-full gap-4 px-4">
|
||||
<HandyTextLogo width={300} />
|
||||
<AccessibilityPermissions />
|
||||
<Settings />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
97
src/components/AccessibilityPermissions.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
checkAccessibilityPermissions,
|
||||
requestAccessibilityPermissions,
|
||||
} from "tauri-plugin-macos-permissions-api";
|
||||
|
||||
// Define permission state type
|
||||
type PermissionState = "request" | "verify" | "granted";
|
||||
|
||||
// Define button configuration type
|
||||
interface ButtonConfig {
|
||||
text: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
const AccessibilityPermissions: React.FC = () => {
|
||||
const [hasAccessibility, setHasAccessibility] = useState<boolean>(false);
|
||||
const [permissionState, setPermissionState] =
|
||||
useState<PermissionState>("request");
|
||||
|
||||
// Check permissions without requesting
|
||||
const checkPermissions = async (): Promise<boolean> => {
|
||||
const hasPermissions: boolean = await checkAccessibilityPermissions();
|
||||
setHasAccessibility(hasPermissions);
|
||||
setPermissionState(hasPermissions ? "granted" : "verify");
|
||||
return hasPermissions;
|
||||
};
|
||||
|
||||
// Handle the unified button action based on current state
|
||||
const handleButtonClick = async (): Promise<void> => {
|
||||
if (permissionState === "request") {
|
||||
try {
|
||||
await requestAccessibilityPermissions();
|
||||
// After system prompt, transition to verification state
|
||||
setPermissionState("verify");
|
||||
} catch (error) {
|
||||
console.error("Error requesting permissions:", error);
|
||||
setPermissionState("verify");
|
||||
}
|
||||
} else if (permissionState === "verify") {
|
||||
// State is "verify" - check if permission was granted
|
||||
await checkPermissions();
|
||||
}
|
||||
};
|
||||
|
||||
// On app boot - check permissions
|
||||
useEffect(() => {
|
||||
const initialSetup = async (): Promise<void> => {
|
||||
const hasPermissions: boolean = await checkAccessibilityPermissions();
|
||||
setHasAccessibility(hasPermissions);
|
||||
setPermissionState(hasPermissions ? "granted" : "request");
|
||||
};
|
||||
|
||||
initialSetup();
|
||||
}, []);
|
||||
|
||||
if (hasAccessibility) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Configure button text and style based on state
|
||||
const buttonConfig: Record<PermissionState, ButtonConfig | null> = {
|
||||
request: {
|
||||
text: "Grant",
|
||||
className:
|
||||
"px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary",
|
||||
},
|
||||
verify: {
|
||||
text: "Verify",
|
||||
className:
|
||||
"bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-3 rounded text-sm flex items-center justify-center cursor-pointer",
|
||||
},
|
||||
granted: null,
|
||||
};
|
||||
|
||||
const config = buttonConfig[permissionState] as ButtonConfig;
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full rounded-lg border border-mid-gray">
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<div className="">
|
||||
<p className="text-sm font-medium">
|
||||
Please grant accessibility permissions for Handy
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleButtonClick}
|
||||
className={`min-h-10 ${config.className}`}
|
||||
>
|
||||
{config.text}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessibilityPermissions;
|
||||
88
src/components/icons/HandyTextLogo.tsx
Normal file
17
src/components/icons/ResetIcon.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import React from "react";
|
||||
|
||||
const ResetIcon = ({ className = "" }: { className?: string }) => {
|
||||
return (
|
||||
<svg
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M6 6c0 .55-.45 1-1 1H1c-.55 0-1-.45-1-1V2c0-.55.45-1 1-1s1 .45 1 1v2.05C3.82 1.6 6.71 0 10 0c5.52 0 10 4.48 10 10s-4.48 10-10 10S0 15.52 0 10c0-.55.45-1 1-1s1 .45 1 1c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8C7.47 2 5.23 3.17 3.76 5H5c.55 0 1 .45 1 1" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetIcon;
|
||||
|
|
@ -1,73 +1,225 @@
|
|||
import React from "react";
|
||||
|
||||
interface Shortcut {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
keys: string[];
|
||||
}
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { load } from "@tauri-apps/plugin-store";
|
||||
import {
|
||||
BindingResponseSchema,
|
||||
SettingsSchema,
|
||||
ShortcutBindingSchema,
|
||||
ShortcutBindingsMap,
|
||||
} from "../../lib/types";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import keycode from "keycode";
|
||||
import ResetIcon from "../icons/ResetIcon";
|
||||
|
||||
export const KeyboardShortcuts: React.FC = () => {
|
||||
// These would normally come from the backend configuration
|
||||
const shortcuts: Shortcut[] = [
|
||||
{
|
||||
id: "transcribe",
|
||||
name: "Transcribe",
|
||||
description: "Convert speech to text",
|
||||
keys: ["⌃", "⌘"],
|
||||
},
|
||||
// {
|
||||
// id: 'instruct',
|
||||
// name: 'AI Chat',
|
||||
// description: 'Start a conversation with AI',
|
||||
// keys: ['⇧', '⌥'],
|
||||
// },
|
||||
// {
|
||||
// id: 'code',
|
||||
// name: 'Generate Code',
|
||||
// description: 'Generate code from voice input',
|
||||
// keys: ['⌃', '⌥', '⌘'],
|
||||
// },
|
||||
];
|
||||
const [bindings, setBindings] = React.useState<ShortcutBindingsMap>({});
|
||||
const [pttEnabled, setPttEnabled] = React.useState<boolean>(false);
|
||||
const [keyPressed, setKeyPressed] = useState<string[]>([]);
|
||||
const [recordedKeys, setRecordedKeys] = useState<string[]>([]);
|
||||
const [editingShortcutId, setEditingShortcutId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [originalBinding, setOriginalBinding] = useState<string>("");
|
||||
const shortcutRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
load("settings_store.json", { autoSave: false }).then((r) => {
|
||||
console.log("loaded store", r);
|
||||
|
||||
r.get("settings").then((s) => {
|
||||
const settings = SettingsSchema.parse(s);
|
||||
setBindings(settings.bindings);
|
||||
setPttEnabled(settings.push_to_talk);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Only add event listeners when we're in editing mode
|
||||
if (editingShortcutId === null) return;
|
||||
|
||||
console.log("keyPressed", keyPressed);
|
||||
|
||||
// Keyboard event listeners
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const key = keycode(e).toLowerCase();
|
||||
console.log("You pressed", key);
|
||||
|
||||
if (!keyPressed.includes(key)) {
|
||||
setKeyPressed((prev) => [...prev, key]);
|
||||
// Also add to recorded keys if not already there
|
||||
if (!recordedKeys.includes(key)) {
|
||||
setRecordedKeys((prev) => [...prev, key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const key = keycode(e).toLowerCase();
|
||||
|
||||
// Remove from currently pressed keys
|
||||
setKeyPressed((prev) => prev.filter((k) => k !== key));
|
||||
|
||||
// If no keys are pressed anymore, commit the shortcut
|
||||
if (keyPressed.length === 1 && keyPressed[0] === key) {
|
||||
// Create the shortcut string from all recorded keys
|
||||
const newShortcut = recordedKeys.join("+");
|
||||
|
||||
if (editingShortcutId && bindings[editingShortcutId]) {
|
||||
const updatedBinding = {
|
||||
...bindings[editingShortcutId],
|
||||
current_binding: newShortcut,
|
||||
};
|
||||
|
||||
setBindings((prev) => ({
|
||||
...prev,
|
||||
[editingShortcutId]: updatedBinding,
|
||||
}));
|
||||
|
||||
invoke("change_binding", {
|
||||
id: editingShortcutId,
|
||||
binding: newShortcut,
|
||||
});
|
||||
|
||||
// Exit editing mode and reset states
|
||||
setEditingShortcutId(null);
|
||||
setKeyPressed([]);
|
||||
setRecordedKeys([]);
|
||||
setOriginalBinding("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add click outside handler
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const activeElement = shortcutRefs.current.get(editingShortcutId);
|
||||
if (activeElement && !activeElement.contains(e.target as Node)) {
|
||||
// Cancel shortcut recording and restore original value
|
||||
if (editingShortcutId && bindings[editingShortcutId]) {
|
||||
setBindings((prev) => ({
|
||||
...prev,
|
||||
[editingShortcutId]: {
|
||||
...prev[editingShortcutId],
|
||||
current_binding: originalBinding,
|
||||
},
|
||||
}));
|
||||
|
||||
// Reset states
|
||||
setEditingShortcutId(null);
|
||||
setKeyPressed([]);
|
||||
setRecordedKeys([]);
|
||||
setOriginalBinding("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("mousedown", handleClickOutside);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [keyPressed, recordedKeys, editingShortcutId, bindings, originalBinding]);
|
||||
|
||||
// Start recording a new shortcut
|
||||
const startRecording = (id: string) => {
|
||||
// Store the original binding to restore if canceled
|
||||
setOriginalBinding(bindings[id]?.current_binding || "");
|
||||
setEditingShortcutId(id);
|
||||
setKeyPressed([]);
|
||||
setRecordedKeys([]);
|
||||
};
|
||||
|
||||
// Format the current shortcut keys being recorded
|
||||
const formatCurrentKeys = () => {
|
||||
return recordedKeys.length > 0 ? recordedKeys.join("+") : "Press keys...";
|
||||
};
|
||||
|
||||
// Store references to shortcut elements
|
||||
const setShortcutRef = (id: string, ref: HTMLDivElement | null) => {
|
||||
shortcutRefs.current.set(id, ref);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border border-mid-gray/20 ">
|
||||
<div className="max-w-2/3">
|
||||
<h3 className="text-sm font-medium ">Push To Talk</h3>
|
||||
<p className="text-sm">Hold to record, release to stop</p>
|
||||
</div>
|
||||
<label className="inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
value=""
|
||||
className="sr-only peer"
|
||||
checked={pttEnabled}
|
||||
onChange={(e) => {
|
||||
console.log("change ptt setting", e.target.checked);
|
||||
const newValue = e.target.checked;
|
||||
setPttEnabled(newValue);
|
||||
|
||||
invoke("change_ptt_setting", {
|
||||
enabled: newValue,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="relative w-11 h-6 bg-mid-gray/20 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-logo-primary rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-logo-primary"></div>
|
||||
</label>
|
||||
</div>
|
||||
{Object.entries(bindings).map(([id, binding]) => (
|
||||
<div
|
||||
key={shortcut.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:bg-gray-50"
|
||||
key={id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border border-mid-gray/20 "
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
{shortcut.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{shortcut.description}</p>
|
||||
<h3 className="text-sm font-medium ">{binding.name}</h3>
|
||||
<p className="text-sm">{binding.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
{shortcut.keys.map((key, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<kbd className="px-2 py-1 text-sm font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded">
|
||||
{key}
|
||||
</kbd>
|
||||
{index < shortcut.keys.length - 1 && (
|
||||
<span className="text-gray-500">+</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{editingShortcutId === id ? (
|
||||
<div
|
||||
ref={(ref) => setShortcutRef(id, ref)}
|
||||
className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded min-w-[100px] text-center"
|
||||
>
|
||||
{formatCurrentKeys()}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary"
|
||||
onClick={() => startRecording(id)}
|
||||
>
|
||||
{binding.current_binding}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"
|
||||
onClick={() => {
|
||||
invoke("reset_binding", { id }).then((b) => {
|
||||
console.log("reset");
|
||||
const newBinding = BindingResponseSchema.parse(b);
|
||||
|
||||
if (!newBinding.success) {
|
||||
console.error("Error resetting binding:", newBinding.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const binding = newBinding.binding!;
|
||||
|
||||
setBindings({ ...bindings, [binding.id]: binding });
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ResetIcon className="" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-4 rounded-md bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-yellow-700">
|
||||
Note: Keyboard shortcuts are currently managed by the system.
|
||||
Custom configuration will be available in a future update.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,25 +4,10 @@ import { KeyboardShortcuts } from "./KeyboardShortcuts";
|
|||
|
||||
export const Settings: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 py-8 px-4">
|
||||
<div className="max-w-3xl mx-auto space-y-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Settings</h1>
|
||||
|
||||
{/* AI Configuration Section */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
AI Configuration
|
||||
</h2>
|
||||
{/* <AIConfig /> */}
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Section */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
<KeyboardShortcuts />
|
||||
</div>
|
||||
<div className="max-w-3xl w-full mx-auto space-y-4">
|
||||
<div className="mt-8">
|
||||
{/* <h2 className="text-xl font-semibold mb-2">Keyboard Shortcuts</h2> */}
|
||||
<KeyboardShortcuts />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
30
src/lib/types.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ShortcutBindingSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
default_binding: z.string(),
|
||||
current_binding: z.string(),
|
||||
});
|
||||
|
||||
export const ShortcutBindingsMapSchema = z.record(
|
||||
z.string(),
|
||||
ShortcutBindingSchema
|
||||
);
|
||||
|
||||
export const SettingsSchema = z.object({
|
||||
bindings: ShortcutBindingsMapSchema,
|
||||
push_to_talk: z.boolean(),
|
||||
});
|
||||
|
||||
export const BindingResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
binding: ShortcutBindingSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type BindingResponse = z.infer<typeof BindingResponseSchema>;
|
||||
export type ShortcutBinding = z.infer<typeof ShortcutBindingSchema>;
|
||||
export type ShortcutBindingsMap = z.infer<typeof ShortcutBindingsMapSchema>;
|
||||
export type Settings = z.infer<typeof SettingsSchema>;
|
||||
|
|
@ -5,7 +5,15 @@ export default {
|
|||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
colors: {
|
||||
text: 'var(--color-text)',
|
||||
background: 'var(--color-background)',
|
||||
'logo-primary': 'var(--color-logo-primary)',
|
||||
'logo-stroke': 'var(--color-logo-stroke)',
|
||||
'text-stroke': 'var(--color-text-stroke)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||