Merge pull request #4 from cjpais/refactor-manager

Refactor manager
This commit is contained in:
CJ Pais 2025-05-13 13:46:44 -07:00 committed by GitHub
commit 1b53bc1bc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 1367 additions and 1962 deletions

2
.cargo/config.toml Normal file
View file

@ -0,0 +1,2 @@
[build]
rustc-wrapper = "sccache"

2
.gitignore vendored
View file

@ -23,4 +23,4 @@ dist-ssr
*.sln
*.sw?
src-tauri/resources
src-tauri/resources/models

BIN
bun.lockb

Binary file not shown.

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"] }

View file

@ -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"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
src-tauri/icons/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

86
src-tauri/src/actions.rs Normal file
View 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
});

View file

@ -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");
}

View file

@ -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();
});

View file

@ -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);
}
}
_ => {}
}
}
}
}
}
}

View file

@ -1,3 +1,2 @@
pub mod audio;
pub mod keybinding;
pub mod transcription;

View file

@ -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
View 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
View 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
View 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"),
));
}

View file

@ -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": {},

View file

@ -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;
}

View file

@ -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>
);

View 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;

File diff suppressed because one or more lines are too long

View 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;

View file

@ -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>
);
};

View file

@ -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
View 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>;

View file

@ -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: [],
}