feat(snippets): Implement core expansion engine

This commit implements implements the `inject_text` method using the `enigo` crate for input simulation. A new `ExpansionEngine` is created to listen for keyboard input, maintain a buffer of recent characters, and check against the database for a matching keyword to trigger an expansion.
This commit is contained in:
ByteAtATime 2025-06-24 18:59:10 -07:00
parent d72b4dd450
commit 7088cb17fd
No known key found for this signature in database
7 changed files with 186 additions and 16 deletions

1
src-tauri/Cargo.lock generated
View file

@ -4441,6 +4441,7 @@ dependencies = [
"hex",
"image",
"keyring",
"lazy_static",
"once_cell",
"rand 0.9.1",
"rayon",

View file

@ -55,6 +55,7 @@ trash = "5.2.2"
rdev = "0.5.3"
evdev = "0.13.1"
anyhow = "1.0.98"
lazy_static = "1.5.0"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-global-shortcut = "2"

View file

@ -18,9 +18,11 @@ use browser_extension::WsState;
use frecency::FrecencyManager;
use quicklinks::QuicklinkManager;
use selection::get_text;
use snippets::engine::ExpansionEngine;
use snippets::input_manager::{InputManager, RdevInputManager};
use snippets::manager::SnippetManager;
use std::process::Command;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use tauri::{Emitter, Manager};
@ -155,14 +157,17 @@ fn setup_global_shortcut(app: &mut tauri::App) -> Result<(), Box<dyn std::error:
Ok(())
}
fn setup_input_listener() {
fn setup_input_listener(app: &tauri::AppHandle) {
let snippet_manager = app.state::<SnippetManager>().inner().clone();
let snippet_manager_arc = Arc::new(snippet_manager);
let input_manager = RdevInputManager::new();
let input_manager_arc = Arc::new(input_manager);
let engine = ExpansionEngine::new(snippet_manager_arc, input_manager_arc);
thread::spawn(move || {
let manager = RdevInputManager::new();
let callback = |event| {
println!("[InputManager] Received Key: {:?}", event);
};
if let Err(e) = manager.start_listening(Box::new(callback)) {
eprintln!("[InputManager] Failed to start: {}", e);
if let Err(e) = engine.start_listening() {
eprintln!("[ExpansionEngine] Failed to start: {}", e);
}
});
}
@ -256,7 +261,7 @@ pub fn run() {
setup_background_refresh();
setup_global_shortcut(app)?;
setup_input_listener();
setup_input_listener(app.handle());
Ok(())
})

View file

@ -0,0 +1,97 @@
use crate::snippets::input_manager::{key_to_char, InputEvent, InputManager};
use crate::snippets::manager::SnippetManager;
use rdev::Key;
use std::sync::{Arc, Mutex};
use std::thread;
const BUFFER_SIZE: usize = 30;
pub struct ExpansionEngine {
buffer: Arc<Mutex<String>>,
snippet_manager: Arc<SnippetManager>,
input_manager: Arc<dyn InputManager>,
}
impl ExpansionEngine {
pub fn new(
snippet_manager: Arc<SnippetManager>,
input_manager: Arc<dyn InputManager>,
) -> Self {
Self {
buffer: Arc::new(Mutex::new(String::with_capacity(BUFFER_SIZE))),
snippet_manager,
input_manager,
}
}
pub fn start_listening(&self) -> anyhow::Result<()> {
let engine = Arc::new(self.clone_for_thread());
self.input_manager
.start_listening(Box::new(move |event| {
engine.handle_key_press(event);
}))?;
Ok(())
}
fn clone_for_thread(&self) -> Self {
Self {
buffer: self.buffer.clone(),
snippet_manager: self.snippet_manager.clone(),
input_manager: self.input_manager.clone(),
}
}
fn handle_key_press(&self, event: InputEvent) {
let InputEvent::KeyPress(key) = event;
let mut buffer = self.buffer.lock().unwrap();
match key {
Key::Backspace => {
buffer.pop();
}
key => {
if let Some(ch) = key_to_char(&key) {
buffer.push(ch);
if buffer.len() > BUFFER_SIZE {
buffer.remove(0);
}
}
}
}
if let Ok(snippets) = self.snippet_manager.list_snippets() {
for snippet in snippets {
if buffer.ends_with(&snippet.keyword) {
let (keyword, content) = (snippet.keyword.clone(), snippet.content.clone());
drop(buffer);
self.expand_snippet(&keyword, &content);
break;
}
}
}
}
fn expand_snippet(&self, keyword: &str, content: &str) {
let mut backspaces = String::new();
for _ in 0..keyword.len() {
backspaces.push('\u{8}');
}
let input_manager = self.input_manager.clone();
let content_to_paste = content.to_string();
thread::spawn(move || {
if let Err(e) = input_manager.inject_text(&backspaces) {
eprintln!("Failed to inject backspaces: {}", e);
}
thread::sleep(std::time::Duration::from_millis(50));
if let Err(e) = input_manager.inject_text(&content_to_paste) {
eprintln!("Failed to inject snippet content: {}", e);
}
});
let mut buffer = self.buffer.lock().unwrap();
buffer.clear();
}
}

View file

@ -1,13 +1,20 @@
use anyhow::Result;
use enigo::{Enigo, Keyboard};
use lazy_static::lazy_static;
use rdev::Key;
use std::sync::Mutex;
use std::thread;
#[derive(Debug)]
lazy_static! {
static ref ENIGO: Mutex<Enigo> = Mutex::new(Enigo::new(&enigo::Settings::default()).unwrap());
}
#[derive(Debug, Clone)]
pub enum InputEvent {
KeyPress(Key),
}
pub trait InputManager {
pub trait InputManager: Send + Sync {
fn start_listening(&self, callback: Box<dyn Fn(InputEvent) + Send>) -> Result<()>;
fn inject_text(&self, text: &str) -> Result<()>;
}
@ -35,8 +42,10 @@ impl InputManager for RdevInputManager {
Ok(())
}
fn inject_text(&self, _text: &str) -> Result<()> {
unimplemented!()
fn inject_text(&self, text: &str) -> Result<()> {
let mut enigo = ENIGO.lock().unwrap();
enigo.text(text)?;
Ok(())
}
}
@ -69,7 +78,6 @@ impl InputManager for EvdevInputManager {
}
for mut device in devices {
let cb = callback.as_ref();
thread::spawn(move || {
loop {
match device.fetch_events() {
@ -95,4 +103,58 @@ impl InputManager for EvdevInputManager {
fn inject_text(&self, _text: &str) -> Result<()> {
unimplemented!()
}
}
pub fn key_to_char(key: &Key) -> Option<char> {
match key {
Key::KeyA => Some('a'),
Key::KeyB => Some('b'),
Key::KeyC => Some('c'),
Key::KeyD => Some('d'),
Key::KeyE => Some('e'),
Key::KeyF => Some('f'),
Key::KeyG => Some('g'),
Key::KeyH => Some('h'),
Key::KeyI => Some('i'),
Key::KeyJ => Some('j'),
Key::KeyK => Some('k'),
Key::KeyL => Some('l'),
Key::KeyM => Some('m'),
Key::KeyN => Some('n'),
Key::KeyO => Some('o'),
Key::KeyP => Some('p'),
Key::KeyQ => Some('q'),
Key::KeyR => Some('r'),
Key::KeyS => Some('s'),
Key::KeyT => Some('t'),
Key::KeyU => Some('u'),
Key::KeyV => Some('v'),
Key::KeyW => Some('w'),
Key::KeyX => Some('x'),
Key::KeyY => Some('y'),
Key::KeyZ => Some('z'),
Key::Num0 => Some('0'),
Key::Num1 => Some('1'),
Key::Num2 => Some('2'),
Key::Num3 => Some('3'),
Key::Num4 => Some('4'),
Key::Num5 => Some('5'),
Key::Num6 => Some('6'),
Key::Num7 => Some('7'),
Key::Num8 => Some('8'),
Key::Num9 => Some('9'),
Key::Space => Some(' '),
Key::Slash => Some('/'),
Key::Dot => Some('.'),
Key::Comma => Some(','),
Key::Minus => Some('-'),
Key::Equal => Some('='),
Key::LeftBracket => Some('['),
Key::RightBracket => Some(']'),
Key::BackSlash => Some('\\'),
Key::SemiColon => Some(';'),
Key::Quote => Some('\''),
Key::BackQuote => Some('`'),
_ => None,
}
}

View file

@ -2,11 +2,12 @@ use crate::error::AppError;
use crate::snippets::types::Snippet;
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection, Result as RusqliteResult};
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Manager};
#[derive(Clone)]
pub struct SnippetManager {
db: Mutex<Connection>,
db: Arc<Mutex<Connection>>,
}
impl SnippetManager {
@ -17,7 +18,9 @@ impl SnippetManager {
.map_err(|_| AppError::DirectoryNotFound)?;
let db_path = data_dir.join("snippets.sqlite");
let db = Connection::open(db_path)?;
Ok(Self { db: Mutex::new(db) })
Ok(Self {
db: Arc::new(Mutex::new(db)),
})
}
pub fn init_db(&self) -> RusqliteResult<()> {

View file

@ -1,3 +1,4 @@
pub mod engine;
pub mod input_manager;
pub mod manager;
pub mod types;