Fix typing race conditions with unified channel injection, add persistent logging, and align config schemas

This commit is contained in:
vndangkhoa 2026-06-24 20:30:14 +07:00
parent 42595d4bae
commit f618c3a5b5
19 changed files with 4048 additions and 1196 deletions

View file

@ -27,6 +27,9 @@ pub struct Config {
#[serde(default)]
pub grab: bool,
#[serde(default = "default_false")]
pub debug: bool,
}
#[derive(Debug, Deserialize)]
@ -75,6 +78,7 @@ fn default_input_method() -> String { "telex".into() }
fn default_toggle_key() -> String { "space".into() }
fn default_start_enabled() -> bool { true }
fn default_true() -> bool { true }
fn default_false() -> bool { false }
fn default_restore_keys() -> Vec<String> { vec!["space".into(), "escape".into()] }
fn default_english_apps() -> Vec<String> {
@ -160,6 +164,7 @@ impl Default for Config {
app_state: AppStateConfig::default(),
macros,
grab: false,
debug: false,
}
}
}

View file

@ -15,6 +15,62 @@ mod display;
use config::Config;
use app_state::AppStateManager;
fn get_log_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("vietc").join("vietc.log"))
}
fn get_timestamp() -> String {
if let Ok(n) = std::time::SystemTime::now().duration_since(std::time::SystemTime::UNIX_EPOCH) {
let secs = n.as_secs();
let millis = n.subsec_millis();
unsafe {
let t = secs as libc::time_t;
let mut tm = std::mem::zeroed::<libc::tm>();
if !libc::localtime_r(&t, &mut tm).is_null() {
return format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:03}",
tm.tm_year + 1900,
tm.tm_mon + 1,
tm.tm_mday,
tm.tm_hour,
tm.tm_min,
tm.tm_sec,
millis
);
}
}
}
"".to_string()
}
fn log_info(msg: &str) {
eprintln!("{}", msg);
if let Some(log_path) = get_log_path() {
if let Some(parent) = log_path.parent() {
let _ = fs::create_dir_all(parent);
}
// Rotate log if it exceeds 10MB
if let Ok(metadata) = fs::metadata(&log_path) {
if metadata.len() > 10 * 1024 * 1024 {
let backup_path = log_path.with_extension("log.old");
let _ = fs::rename(&log_path, backup_path);
}
}
if let Ok(mut file) = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
use std::io::Write;
let timestamp = get_timestamp();
let _ = writeln!(file, "[{}] {}", timestamp, msg);
}
}
}
struct Daemon {
engine: Engine,
config: Config,
@ -77,7 +133,7 @@ impl Daemon {
if let Ok(content) = fs::read_to_string(&status_path) {
let expect_enabled = content.trim() == "vn";
if self.engine.is_enabled() != expect_enabled {
eprintln!("[vietc] Syncing enabled status from file: {}", expect_enabled);
log_info(&format!("[vietc] Syncing enabled status from file: {}", expect_enabled));
self.engine.set_enabled(expect_enabled);
self.engine_enabled.store(expect_enabled, Ordering::SeqCst);
}
@ -94,7 +150,7 @@ impl Daemon {
return false;
}
eprintln!("[vietc] Config changed, reloading...");
log_info("[vietc] Config changed, reloading...");
match Config::load_from(&self.config_path) {
Ok(new_config) => {
let method = match new_config.input_method.as_str() {
@ -116,11 +172,11 @@ impl Daemon {
self.grab_enabled = new_config.grab;
self.config = new_config;
self.config_modified = modified;
eprintln!("[vietc] Config reloaded successfully");
log_info("[vietc] Config reloaded successfully");
true
}
Err(e) => {
eprintln!("[vietc] Failed to reload config: {}", e);
log_info(&format!("[vietc] Failed to reload config: {}", e));
false
}
}
@ -130,7 +186,7 @@ impl Daemon {
let mut commands = Vec::new();
if let Some(event) = self.engine.process_key(ch) {
eprintln!("[vietc] key='{}' buf='{}' -> {:?}", ch, self.engine.buffer(), event);
log_info(&format!("[vietc] key='{}' buf='{}' -> {:?}", ch, self.engine.buffer(), event));
match event {
EngineEvent::Flush(text) => {
commands.push(OutputCommand::Type(text));
@ -153,7 +209,7 @@ impl Daemon {
}
}
} else {
eprintln!("[vietc] key='{}' -> (no event, buf='{}')", ch, self.engine.buffer());
log_info(&format!("[vietc] key='{}' -> (no event, buf='{}')", ch, self.engine.buffer()));
}
commands
@ -191,11 +247,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let display = display::detect_display_server();
let compositor = display::detect_compositor();
eprintln!("Viet+ Daemon v{}", env!("CARGO_PKG_VERSION"));
eprintln!("Display: {:?} ({})", display, compositor.unwrap_or_else(|| "unknown".into()));
eprintln!("Input method: {:?}", daemon.config.input_method);
eprintln!("Toggle key: Ctrl+{}", daemon.config.toggle_key.to_uppercase());
eprintln!("App memory: {}", if daemon.config.app_state.enabled { "ON" } else { "OFF" });
log_info(&format!("Viet+ Daemon v{}", env!("CARGO_PKG_VERSION")));
log_info(&format!("Display: {:?} ({})", display, compositor.unwrap_or_else(|| "unknown".into())));
log_info(&format!("Input method: {:?}", daemon.config.input_method));
log_info(&format!("Toggle key: Ctrl+{}", daemon.config.toggle_key.to_uppercase()));
log_info(&format!("App memory: {}", if daemon.config.app_state.enabled { "ON" } else { "OFF" }));
// Spawn background monitor for active window, config changes, and status changes
let shared_active_window = Arc::new(Mutex::new(String::new()));
@ -254,7 +310,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match open_keyboard_device() {
Ok((device, path)) => {
eprintln!("[vietc] Keyboard device: {}", path);
log_info(&format!("[vietc] Keyboard device: {}", path));
run_with_evdev(
device,
&mut daemon,
@ -266,8 +322,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
)?;
}
Err(e) => {
eprintln!("[vietc] No keyboard device: {}", e);
eprintln!("[vietc] Running in stdin test mode");
log_info(&format!("[vietc] No keyboard device: {}", e));
log_info("[vietc] Running in stdin test mode");
run_stdin_mode(
&mut daemon,
shared_active_window,
@ -362,22 +418,23 @@ fn run_with_evdev(
let grabbed = if daemon.grab_enabled {
match device.grab() {
Ok(()) => {
eprintln!("[vietc] Keyboard grabbed — race condition eliminated");
log_info("[vietc] Keyboard grabbed — race condition eliminated");
true
}
Err(e) => {
eprintln!("[vietc] Could not grab keyboard: {} (run as root for grab)", e);
eprintln!("[vietc] Falling back to non-grabbing mode (may have race)");
log_info(&format!("[vietc] Could not grab keyboard: {} (run as root for grab)", e));
log_info("[vietc] Falling back to non-grabbing mode (may have race)");
false
}
}
} else {
eprintln!("[vietc] Keyboard grab disabled (config grab = false)");
eprintln!("[vietc] Set grab = true in vietc.toml to enable (needs root)");
log_info("[vietc] Keyboard grab disabled (config grab = false)");
log_info("[vietc] Set grab = true in vietc.toml to enable (needs root)");
false
};
let mut consumed_keys: HashSet<u16> = HashSet::new();
let mut last_active_window = String::new();
// Safety: if grab is active and no events arrive for 30 seconds,
// release the grab so the user isn't locked out.
@ -386,7 +443,7 @@ fn run_with_evdev(
loop {
// Check for event timeout (grab safety)
if grabbed && last_event_time.elapsed() > std::time::Duration::from_secs(30) {
eprintln!("[vietc] No events for 30s — releasing grab timeout, releasing grab for safety");
log_info("[vietc] No events for 30s — releasing grab timeout, releasing grab for safety");
let _ = device.ungrab();
return Ok(());
}
@ -401,6 +458,17 @@ fn run_with_evdev(
status_changed.store(false, Ordering::SeqCst);
}
// Track window changes and reset engine buffer
{
let active_window = shared_active_window.lock().unwrap().clone();
if active_window != last_active_window {
log_info(&format!("[vietc] Window changed: '{}' -> '{}'", last_active_window, active_window));
last_active_window = active_window.clone();
daemon.engine.reset();
log_info("[vietc] Reset engine buffer due to window change");
}
}
// Check for app changes instantly using the cached state from background thread
if daemon.config.app_state.enabled {
let active_window = shared_active_window.lock().unwrap().clone();
@ -505,11 +573,11 @@ fn run_stdin_mode(
if !io::stdin().is_terminal() {
eprintln!("[vietc] Warning: No keyboard device and no terminal.");
eprintln!("[vietc] Retrying keyboard access every 5 seconds...");
eprintln!("[vietc] Ensure you are in the 'input' group:");
eprintln!(" sudo usermod -aG input $USER");
eprintln!(" Then log out and back in.");
log_info("[vietc] Warning: No keyboard device and no terminal.");
log_info("[vietc] Retrying keyboard access every 5 seconds...");
log_info("[vietc] Ensure you are in the 'input' group:");
log_info(" sudo usermod -aG input $USER");
log_info(" Then log out and back in.");
// Retry loop: periodically attempt to reopen the keyboard device
loop {
@ -526,7 +594,7 @@ fn run_stdin_mode(
}
if let Ok((device, path)) = open_keyboard_device() {
eprintln!("[vietc] Keyboard device found: {}", path);
log_info(&format!("[vietc] Keyboard device found: {}", path));
return run_with_evdev(
device, daemon,
shared_active_window,
@ -541,8 +609,9 @@ fn run_stdin_mode(
let injector = create_injector(display)?;
let mut buffer = [0u8; 1];
let mut last_active_window = String::new();
eprintln!("[vietc] Type to test, Ctrl+C to exit");
log_info("[vietc] Type to test, Ctrl+C to exit");
let stdin = io::stdin();
let mut handle = stdin.lock();
@ -553,6 +622,17 @@ fn run_stdin_mode(
status_changed.store(false, Ordering::SeqCst);
}
// Track window changes and reset engine buffer
{
let active_window = shared_active_window.lock().unwrap().clone();
if active_window != last_active_window {
log_info(&format!("[vietc] Window changed: '{}' -> '{}'", last_active_window, active_window));
last_active_window = active_window.clone();
daemon.engine.reset();
log_info("[vietc] Reset engine buffer due to window change");
}
}
// Check for app changes instantly using the cached state from background thread
if daemon.config.app_state.enabled {
let active_window = shared_active_window.lock().unwrap().clone();
@ -573,7 +653,7 @@ fn run_stdin_mode(
execute_commands(&*injector, &commands, false);
}
Err(e) => {
eprintln!("[vietc] Read error: {}", e);
log_info(&format!("[vietc] Read error: {}", e));
break;
}
}
@ -593,18 +673,18 @@ fn execute_commands(injector: &dyn vietc_protocol::KeyInjector, commands: &[Outp
match cmd {
OutputCommand::Backspace(count) => {
let adjusted = if grabbed { count.saturating_sub(1) } else { *count };
eprintln!("[vietc] cmd: Backspace({}) -> adjusted={}", count, adjusted);
log_info(&format!("[vietc] cmd: Backspace({}) -> adjusted={}", count, adjusted));
pending_backspaces += adjusted;
}
OutputCommand::Type(text) => {
eprintln!("[vietc] cmd: Type(\"{}\")", text);
log_info(&format!("[vietc] cmd: Type(\"{}\")", text));
pending_text.push_str(text);
}
}
}
if pending_backspaces > 0 || !pending_text.is_empty() {
eprintln!("[vietc] inject: BS={} text=\"{}\"", pending_backspaces, pending_text);
log_info(&format!("[vietc] inject: BS={} text=\"{}\"", pending_backspaces, pending_text));
injector.inject_replacement(pending_backspaces, &pending_text);
}
injector.flush();
@ -615,7 +695,7 @@ fn create_injector(display: display::DisplayServer) -> Result<Box<dyn vietc_prot
#[cfg(feature = "wayland")]
{
let _ctx = vietc_protocol::wayland_im::WaylandIMContext::new();
eprintln!("[vietc] Wayland input method context initialized");
log_info("[vietc] Wayland input method context initialized");
}
// Use uinput as primary injector — it handles ASCII via direct keycodes
@ -624,11 +704,11 @@ fn create_injector(display: display::DisplayServer) -> Result<Box<dyn vietc_prot
// (ASCII) and ydotool (Unicode) interleaving.
match vietc_protocol::uinput_monitor::UinputInjector::new("vietc") {
Ok(injector) => {
eprintln!("[vietc] Using uinput injection (primary)");
log_info("[vietc] Using uinput injection (primary)");
return Ok(Box::new(injector));
}
Err(e) => {
eprintln!("[vietc] uinput not available: {}", e);
log_info(&format!("[vietc] uinput not available: {}", e));
}
}
@ -638,11 +718,11 @@ fn create_injector(display: display::DisplayServer) -> Result<Box<dyn vietc_prot
if display != display::DisplayServer::Wayland {
match vietc_protocol::x11_inject::X11Injector::new() {
Ok(injector) => {
eprintln!("[vietc] Using X11 injection (XTEST fallback)");
log_info("[vietc] Using X11 injection (XTEST fallback)");
return Ok(Box::new(injector));
}
Err(e) => {
eprintln!("[vietc] X11 not available: {}", e);
log_info(&format!("[vietc] X11 not available: {}", e));
}
}
}

View file

@ -0,0 +1,89 @@
use std::io::{self, Write};
use vietc_engine::{Engine, EngineEvent, InputMethod};
fn get_display(events: &[EngineEvent]) -> String {
let mut display = String::new();
for ev in events {
match ev {
EngineEvent::Flush(text) => { if !display.ends_with(text) { display.push_str(text); } }
EngineEvent::Insert(text) => display.push_str(text),
EngineEvent::Replace { backspaces, insert } => {
for _ in 0..*backspaces { display.pop(); }
display.push_str(insert);
}
EngineEvent::AutoRestore(word) => {
for _ in 0..word.len() { display.pop(); }
display.push_str(word);
}
EngineEvent::UndoTones { backspaces, restored } => {
for _ in 0..*backspaces { display.pop(); }
display.push_str(restored);
}
}
}
display
}
fn process_input(e: &mut Engine, input: &str) -> Vec<EngineEvent> {
let mut events = Vec::new();
for ch in input.chars() {
if let Some(ev) = e.process_key(ch) { events.push(ev); }
}
events
}
const INITIALS: &[&str] = &[
"", "b", "c", "ch", "d", "g", "gh", "h", "k", "kh", "l", "m", "n",
"ng", "ngh", "nh", "p", "ph", "q", "r", "s", "t", "th", "tr", "v", "x",
];
const FINALS: &[&str] = &["", "c", "ch", "m", "n", "ng", "nh", "p", "t"];
fn is_valid(init: &str, fin: &str) -> bool {
if init == "ngh" && !fin.is_empty() && fin != "n" && fin != "ng" && fin != "nh" { return false; }
if init == "gh" && !fin.is_empty() { return false; }
if init == "q" { return false; }
if init == "g" && !fin.is_empty() && fin != "n" && fin != "ng" { return false; }
if fin == "ch" && init == "" { return false; }
if fin == "nh" && init == "" { return false; }
true
}
fn main() {
// Telex base vowels (as typed, before mod)
let telex_vowels: Vec<(&str, &str)> = vec![
("a", "af"), ("a", "as"), ("a", "aj"), ("a", "ar"), ("a", "ax"),
("a", "aw"), ("a", "aa"),
("e", "ee"),
("o", "oo"), ("o", "ow"),
("u", "uw"),
];
let mut count = 0;
let stdout = io::stdout();
let mut handle = stdout.lock();
for &init in INITIALS {
for &fin in FINALS {
if !is_valid(init, fin) { continue; }
for &(base, mod_str) in &telex_vowels {
let plain = format!("{}{}{}", init, base, fin);
let full = format!("{}{}", plain, mod_str);
if plain.len() > 10 { continue; }
let mut e = Engine::new(InputMethod::Telex);
let result = get_display(&process_input(&mut e, &full));
if !result.is_empty() && result.len() <= 12 && result != full && result != plain {
count += 1;
let _ = writeln!(handle, "{{\"i\":\"{full}\",\"e\":\"{result}\",\"m\":\"telex\"}}");
}
if count >= 1000 { break; }
}
if count >= 1000 { break; }
}
if count >= 1000 { break; }
}
eprintln!("Generated {count} test cases");
}

View file

@ -0,0 +1,74 @@
use vietc_engine::{Engine, InputMethod, EngineEvent};
fn trace(input: &str, method: InputMethod) {
let mut e = Engine::new(method);
eprintln!("\n=== {:?}: {} ===", method, input);
eprintln!("Ch | prev_buf → new_buf | expected_screen | Event");
eprintln!("---+-----------+-----------+---------------------+------");
for ch in input.chars() {
let prev = e.buffer().to_string();
let event = e.process_key(ch);
let curr = e.buffer().to_string();
let expected = format!("{}{}", prev, ch);
let event_str = match &event {
Some(EngineEvent::Replace { backspaces, insert }) =>
format!("Replace({}, {:?})", backspaces, insert),
Some(EngineEvent::Insert(t)) => format!("Insert({:?})", t),
Some(EngineEvent::Flush(t)) => format!("Flush({:?})", t),
Some(EngineEvent::AutoRestore(w)) => format!("AutoRestore({:?})", w),
Some(EngineEvent::UndoTones { backspaces, restored }) =>
format!("UndoTones({}, {:?})", backspaces, restored),
None => "None".to_string(),
};
let backspaces = match &event {
Some(EngineEvent::Replace { backspaces, .. }) => format!("bs={}", backspaces),
_ => " ".to_string(),
};
eprintln!("'{}' | {:<9}{:<9} | {:<19} | {}",
ch, prev, curr, expected, event_str);
if let Some(EngineEvent::Replace { backspaces, insert }) = &event {
// In grab mode, backspace - 1 (key consumed)
let grab_bs = backspaces.saturating_sub(1);
// In non-grab mode, full backspace
eprintln!(" | | | grab_bs={} non_grab_bs={} insert={:?}",
grab_bs, backspaces, insert);
}
}
// Flush
if let Some(event) = e.flush() {
eprintln!("FL | | | | {:?}", event);
}
}
fn main() {
// Category 1: Basic A group
trace("traan", InputMethod::Telex); // trâ
trace("traanw", InputMethod::Telex); // trân → w → trăn
trace("tranwa", InputMethod::Telex); // trăn → a → trân
// Category 2: Basic O group
trace("coon", InputMethod::Telex); // côn
trace("coonw", InputMethod::Telex); // côn → w → cơn
trace("conwo", InputMethod::Telex); // cơn → o → côn
// Category 3: Smart cluster
trace("chuoonw", InputMethod::Telex); // chuôn → w → chươn
trace("chuonwo", InputMethod::Telex); // chươn → o → chuôn
// Category 4: With tones
trace("traansw", InputMethod::Telex); // trấn → w → trắn
// Basic typing
trace("chaof ", InputMethod::Telex); // chào + space
// VNI tests
trace("tran6", InputMethod::Vni);
trace("tran61", InputMethod::Vni);
trace("tran618", InputMethod::Vni);
trace("con67", InputMethod::Vni);
trace("con627", InputMethod::Vni);
// Smart cluster VNI
trace("chuon67", InputMethod::Vni);
trace("chuon76", InputMethod::Vni);
}

1000
engine/gen_tests_output.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,24 @@
use crate::engine::EngineEvent;
const VOWELS: &[char] = &['a', 'e', 'i', 'o', 'u', 'y', 'ă', 'â', 'ê', 'ô', 'ơ', 'ư'];
const VOWELS: &[char] = &[
'a', 'e', 'i', 'o', 'u', 'y',
'ă', 'â', 'ê', 'ô', 'ơ', 'ư',
];
const VOWEL_ACCENTED: &[char] = &[
'a', 'á', 'à', 'ả', 'ã', 'ạ',
'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ',
'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ',
'e', 'é', 'è', 'ẻ', 'ẽ', 'ẹ',
'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ',
'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị',
'o', 'ó', 'ò', 'ỏ', 'õ', 'ọ',
'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ',
'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ',
'u', 'ú', 'ù', 'ủ', 'ũ', 'ụ',
'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự',
'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ',
];
/// Maximum number of characters to scan backward during flexible placement.
/// Vietnamese vowel clusters are at most 3 characters; limiting the scan
@ -9,7 +27,39 @@ const VOWELS: &[char] = &['a', 'e', 'i', 'o', 'u', 'y', 'ă', 'â', 'ê', 'ô',
const MAX_FLEXIBLE_BACKTRACK: usize = 3;
fn is_vowel(c: char) -> bool {
VOWELS.contains(&c)
VOWEL_ACCENTED.contains(&c)
}
/// Strip tone from a Vietnamese vowel, returning (base_modified_vowel, tone_char_or_none)
/// where base_modified_vowel still has its shape modifier (e.g., 'â', 'ă', 'ô', 'ơ').
fn strip_tone(c: char) -> (char, Option<char>) {
match c {
'a' => ('a', None), 'á' => ('a', Some('s')), 'à' => ('a', Some('f')),
'ả' => ('a', Some('r')), 'ã' => ('a', Some('x')), 'ạ' => ('a', Some('j')),
'ă' => ('ă', None), 'ắ' => ('ă', Some('s')), 'ằ' => ('ă', Some('f')),
'ẳ' => ('ă', Some('r')), 'ẵ' => ('ă', Some('x')), 'ặ' => ('ă', Some('j')),
'â' => ('â', None), 'ấ' => ('â', Some('s')), 'ầ' => ('â', Some('f')),
'ẩ' => ('â', Some('r')), 'ẫ' => ('â', Some('x')), 'ậ' => ('â', Some('j')),
'e' => ('e', None), 'é' => ('e', Some('s')), 'è' => ('e', Some('f')),
'ẻ' => ('e', Some('r')), 'ẽ' => ('e', Some('x')), 'ẹ' => ('e', Some('j')),
'ê' => ('ê', None), 'ế' => ('ê', Some('s')), 'ề' => ('ê', Some('f')),
'ể' => ('ê', Some('r')), 'ễ' => ('ê', Some('x')), 'ệ' => ('ê', Some('j')),
'i' => ('i', None), 'í' => ('i', Some('s')), 'ì' => ('i', Some('f')),
'ỉ' => ('i', Some('r')), 'ĩ' => ('i', Some('x')), 'ị' => ('i', Some('j')),
'o' => ('o', None), 'ó' => ('o', Some('s')), 'ò' => ('o', Some('f')),
'ỏ' => ('o', Some('r')), 'õ' => ('o', Some('x')), 'ọ' => ('o', Some('j')),
'ô' => ('ô', None), 'ố' => ('ô', Some('s')), 'ồ' => ('ô', Some('f')),
'ổ' => ('ô', Some('r')), 'ỗ' => ('ô', Some('x')), 'ộ' => ('ô', Some('j')),
'ơ' => ('ơ', None), 'ớ' => ('ơ', Some('s')), 'ờ' => ('ơ', Some('f')),
'ở' => ('ơ', Some('r')), 'ỡ' => ('ơ', Some('x')), 'ợ' => ('ơ', Some('j')),
'u' => ('u', None), 'ú' => ('u', Some('s')), 'ù' => ('u', Some('f')),
'ủ' => ('u', Some('r')), 'ũ' => ('u', Some('x')), 'ụ' => ('u', Some('j')),
'ư' => ('ư', None), 'ứ' => ('ư', Some('s')), 'ừ' => ('ư', Some('f')),
'ử' => ('ư', Some('r')), 'ữ' => ('ư', Some('x')), 'ự' => ('ư', Some('j')),
'y' => ('y', None), 'ý' => ('y', Some('s')), 'ỳ' => ('y', Some('f')),
'ỷ' => ('y', Some('r')), 'ỹ' => ('y', Some('x')), 'ỵ' => ('y', Some('j')),
_ => (c, None),
}
}
fn apply_tone_to_vowel(vowel: char, tone: char) -> Option<char> {
@ -34,9 +84,38 @@ fn apply_tone_to_vowel(vowel: char, tone: char) -> Option<char> {
return Some(result);
}
}
// Tone overriding: vowel already has a tone → strip it and apply the new one
let (base, _) = strip_tone(vowel);
if base != vowel {
for &(v, t, result) in table {
if v == base && t == tone {
return Some(result);
}
}
}
None
}
/// Override the shape modifier on a vowel with a different one.
/// Preserves any existing tone.
/// Telex mappings: â↔ă via w/a, ô↔ơ via w/o
fn override_telex_modifier(vowel: char, key: char) -> Option<char> {
let (base, tone) = strip_tone(vowel);
let new_base = match (base, key) {
('â', 'w') => Some('ă'),
('ă', 'a') => Some('â'),
('ô', 'w') => Some('ơ'),
('ơ', 'o') => Some('ô'),
_ => None,
}?;
match tone {
None => Some(new_base),
Some(t) => apply_tone_to_vowel(new_base, t),
}
}
fn apply_w_to_vowel(vowel: char) -> Option<char> {
// Telex: aw=ă, ow=ơ, ew=ê, uw=ư
@ -50,6 +129,58 @@ fn apply_w_to_vowel(vowel: char) -> Option<char> {
}
}
// Smart cluster helpers: detect "uo" → "ươ" and transfer tones
fn is_u_vowel(c: char) -> bool {
matches!(c, 'u' | 'ú' | 'ù' | 'ủ' | 'ũ' | 'ụ')
}
fn is_o_vowel(c: char) -> bool {
matches!(c, 'o' | 'ó' | 'ò' | 'ỏ' | 'õ' | 'ọ')
}
/// Determine the tone character (Telex) from a toned vowel.
/// 'u' variants → Some('tone_char'), plain vowels → None.
fn tone_of_vowel(c: char) -> Option<char> {
match c {
'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None,
'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => Some('f'),
'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => Some('s'),
'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => Some('r'),
'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => Some('x'),
'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => Some('j'),
_ => None,
}
}
/// Apply a Telex tone to the vowel 'ơ', returning the toned variant.
fn apply_tone_to_ơ_char(tone: Option<char>) -> char {
match tone {
None => 'ơ',
Some('f') => 'ờ',
Some('s') => 'ớ',
Some('r') => 'ở',
Some('x') => 'ỡ',
Some('j') => 'ợ',
_ => 'ơ',
}
}
/// Convert a "uo" cluster (with possible tones) into "ươ" with correct tone placement.
/// The tone ends up on 'ơ' (second vowel of ươ) regardless of which vowel carried it.
fn uo_to_uơ(u_char: char, o_char: char) -> (char, char) {
let o_tone = tone_of_vowel(o_char);
let u_tone = tone_of_vowel(u_char);
let tone = o_tone.or(u_tone);
('ư', apply_tone_to_ơ_char(tone))
}
/// Check whether a position `i` (pointing at 'o' in a potential "uo" cluster) is
/// preceded by 'q' (making it a "qu" consonant cluster, not a vowel pair).
fn is_q_before_u(chars: &[char], i: usize) -> bool {
i > 1 && chars[i - 2] == 'q'
}
pub struct TelexEngine {
buffer: String,
@ -163,6 +294,7 @@ impl TelexEngine {
(first, second),
('o', 'a') | ('o', 'e')
| ('u', 'â') | ('u', 'ê') | ('u', 'ơ') | ('u', 'y')
| ('ư', 'ơ')
| ('i', 'ê') | ('y', 'ê')
);
if !tone_on_second {
@ -217,10 +349,40 @@ impl TelexEngine {
return None;
}
}
// Smart cluster reverse: "ươ" + o → "uô"
if ch == 'o' && is_vowel(last_ch) {
let strip = strip_tone(last_ch);
if strip.0 == 'ơ' {
let mut chars: Vec<char> = self.buffer.chars().collect();
if chars.len() >= 2 && chars[chars.len() - 2] == 'ư' {
let ơ_char = chars.pop().unwrap();
chars.pop().unwrap();
let tone = tone_of_vowel(ơ_char);
let ô_char = match tone {
None => 'ô',
Some(t) => apply_tone_to_vowel('ô', t).unwrap_or('ô'),
};
self.buffer = chars.into_iter().collect::<String>();
self.buffer.push('u');
self.buffer.push(ô_char);
return None;
}
}
}
// Modifier override: if last vowel has a different modifier that can
// be replaced by this key (e.g., ă+a→â, ơ+o→ô)
if is_vowel(last_ch) && ch != last_ch {
if let Some(modified) = override_telex_modifier(last_ch, ch) {
self.buffer.pop();
self.buffer.push(modified);
return None;
}
}
}
// Flexible placement: if last char is not a vowel, scan the last
// N chars for a matching vowel to form a double-vowel pair.
// N chars for a matching vowel to form a double-vowel pair, or for
// a modified vowel that can be overridden by this key.
// Limited backtrack prevents modifying vowels in a different syllable.
if matches!(ch, 'a' | 'e' | 'o') {
if let Some(last_ch) = self.buffer.chars().last() {
@ -228,6 +390,7 @@ impl TelexEngine {
let chars: Vec<char> = self.buffer.chars().collect();
let start = chars.len().saturating_sub(MAX_FLEXIBLE_BACKTRACK);
for i in (start..chars.len()).rev() {
if is_vowel(chars[i]) {
if chars[i] == ch {
let replacement = match ch {
'a' => 'â',
@ -242,6 +405,35 @@ impl TelexEngine {
}
return None;
}
// Smart cluster reverse: "ươ" + o → "uô" (flexible)
if ch == 'o' {
let strip = strip_tone(chars[i]);
if strip.0 == 'ơ' && i > 0 && chars[i - 1] == 'ư' {
let ơ_char = chars[i];
let tone = tone_of_vowel(ơ_char);
let ô_char = match tone {
None => 'ô',
Some(t) => apply_tone_to_vowel('ô', t).unwrap_or('ô'),
};
self.buffer = chars[..i - 1].iter().collect::<String>();
self.buffer.push('u');
self.buffer.push(ô_char);
for &c in &chars[i + 1..] {
self.buffer.push(c);
}
return None;
}
}
// Modifier override for flexible path
if let Some(modified) = override_telex_modifier(chars[i], ch) {
self.buffer = chars[..i].iter().collect::<String>();
self.buffer.push(modified);
for &c in &chars[i + 1..] {
self.buffer.push(c);
}
return None;
}
}
}
}
}
@ -254,13 +446,47 @@ impl TelexEngine {
fn process_w(&mut self) -> Option<EngineEvent> {
self.apply_pending_to_last_vowel();
// Direct: last char is a vowel
if let Some(last_ch) = self.buffer.chars().last() {
if is_o_vowel(last_ch) {
// Smart cluster "uo" → "ươ"
let mut chars: Vec<char> = self.buffer.chars().collect();
if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) {
let o_char = chars.pop().unwrap();
let u_char = chars.pop().unwrap();
let (new_first, new_second) = uo_to_uơ(u_char, o_char);
self.buffer = chars.into_iter().collect::<String>();
self.buffer.push(new_first);
self.buffer.push(new_second);
return None;
}
}
if is_vowel(last_ch) {
if let Some(modified) = apply_w_to_vowel(last_ch) {
self.buffer.pop();
self.buffer.push(modified);
return None;
}
// Smart cluster override: "uô" + w → "ươ"
let strip = strip_tone(last_ch);
if strip.0 == 'ô' || strip.0 == 'ơ' {
let mut chars: Vec<char> = self.buffer.chars().collect();
if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) {
let o_char = chars.pop().unwrap();
let u_char = chars.pop().unwrap();
let (new_first, new_second) = uo_to_uơ(u_char, o_char);
self.buffer = chars.into_iter().collect::<String>();
self.buffer.push(new_first);
self.buffer.push(new_second);
return None;
}
}
// Modifier override: if vowel already has a different modifier
if let Some(modified) = override_telex_modifier(last_ch, 'w') {
self.buffer.pop();
self.buffer.push(modified);
return None;
}
}
}
@ -272,6 +498,17 @@ impl TelexEngine {
let start = chars.len().saturating_sub(MAX_FLEXIBLE_BACKTRACK);
for i in (start..chars.len()).rev() {
if is_vowel(chars[i]) {
// Smart cluster "uo" → "ươ" (flexible)
if is_o_vowel(chars[i]) && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) {
let (new_first, new_second) = uo_to_uơ(chars[i - 1], chars[i]);
self.buffer = chars[..i - 1].iter().collect::<String>();
self.buffer.push(new_first);
self.buffer.push(new_second);
for &c in &chars[i + 1..] {
self.buffer.push(c);
}
return None;
}
if let Some(modified) = apply_w_to_vowel(chars[i]) {
self.buffer = chars[..i].iter().collect::<String>();
self.buffer.push(modified);
@ -280,6 +517,29 @@ impl TelexEngine {
}
return None;
}
// Smart cluster override: "uô" + w → "ươ" (flexible)
if i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) {
let strip = strip_tone(chars[i]);
if strip.0 == 'ô' || strip.0 == 'ơ' {
let (new_first, new_second) = uo_to_uơ(chars[i - 1], chars[i]);
self.buffer = chars[..i - 1].iter().collect::<String>();
self.buffer.push(new_first);
self.buffer.push(new_second);
for &c in &chars[i + 1..] {
self.buffer.push(c);
}
return None;
}
}
// Modifier override: vowel already has a different modifier
if let Some(modified) = override_telex_modifier(chars[i], 'w') {
self.buffer = chars[..i].iter().collect::<String>();
self.buffer.push(modified);
for &c in &chars[i + 1..] {
self.buffer.push(c);
}
return None;
}
}
}
}

View file

@ -644,6 +644,52 @@ mod tests {
assert_eq!(get_display(&process_input(&mut e, "xungw")), "xưng");
}
// ================================================================
// Telex: Smart "uo" → "ươ" cluster
// ================================================================
#[test]
fn telex_smart_uo_to_uơ_shortcut() {
let mut e = Engine::new(InputMethod::Telex);
// Single w at end converts "uo" → "ươ" through trailing "ng"
assert_eq!(get_display(&process_input(&mut e, "chuongw")), "chương");
}
#[test]
fn telex_smart_uo_to_uơ_traditional() {
let mut e = Engine::new(InputMethod::Telex);
// Traditional uw+ow still works
assert_eq!(get_display(&process_input(&mut e, "chuwowng")), "chương");
}
#[test]
fn telex_smart_uo_to_uơ_with_tone_after_w() {
let mut e = Engine::new(InputMethod::Telex);
// "chuongws" → w first (cluster→ươ), then s (tone on ơ)
assert_eq!(get_display(&process_input(&mut e, "chuongws")), "chướng");
}
#[test]
fn telex_smart_uo_to_uơ_with_tone_before_w() {
let mut e = Engine::new(InputMethod::Telex);
// "chuongsw" → s first (tone on u), then w (cluster→ươ, tone→ơ)
assert_eq!(get_display(&process_input(&mut e, "chuongsw")), "chướng");
}
#[test]
fn telex_smart_uo_to_uơ_thuong_after_w() {
let mut e = Engine::new(InputMethod::Telex);
// "thuowngf" → w first (cluster→ươ), then f (huyền on ơ)
assert_eq!(get_display(&process_input(&mut e, "thuowngf")), "thường");
}
#[test]
fn telex_smart_uo_to_uơ_thuong_before_w() {
let mut e = Engine::new(InputMethod::Telex);
// "thuongfw" → f first (tone on u), then w (cluster→ươ, tone→ơ)
assert_eq!(get_display(&process_input(&mut e, "thuongfw")), "thường");
}
// ================================================================
// VNI: Flexible diacritic placement
// ================================================================
@ -697,6 +743,38 @@ mod tests {
assert_eq!(get_display(&process_input(&mut e, "tran6")), "trân");
}
// ================================================================
// VNI: Smart "uo" → "ươ" cluster
// ================================================================
#[test]
fn vni_smart_uo_to_uơ_shortcut() {
let mut e = Engine::new(InputMethod::Vni);
// Single 7 at end converts "uo" → "ươ" through trailing "ng"
assert_eq!(get_display(&process_input(&mut e, "chuong7")), "chương");
}
#[test]
fn vni_smart_uo_to_uơ_traditional() {
let mut e = Engine::new(InputMethod::Vni);
// Traditional u7+o7 still works
assert_eq!(get_display(&process_input(&mut e, "chu7o7ng")), "chương");
}
#[test]
fn vni_smart_uo_to_uơ_with_tone_after_7() {
let mut e = Engine::new(InputMethod::Vni);
// "chuong71" → 7 first (cluster→ươ), then 1 (sắc on ơ) → "chướng"
assert_eq!(get_display(&process_input(&mut e, "chuong71")), "chướng");
}
#[test]
fn vni_smart_uo_to_uơ_with_tone_before_7() {
let mut e = Engine::new(InputMethod::Vni);
// "chuong17" → 1 first (tone on o), then 7 (cluster→ươ, tone→ơ) → "chướng"
assert_eq!(get_display(&process_input(&mut e, "chuong17")), "chướng");
}
// ================================================================
// VNI: Tones
// ================================================================
@ -941,15 +1019,15 @@ mod tests {
#[test]
fn esc_undo_after_multiple_tones() {
let mut e = Engine::new(InputMethod::Telex);
// "as" → á, then "f" has no tone mapping for á, so f is appended
// Buffer becomes "áf", ESC strips diacritics → "af"
// "as" → á, then "f" overrides tone: sắc → huyền → "à"
// ESC strips diacritics → "a"
e.process_key('a');
e.process_key('s');
e.process_key('f');
let event = e.process_escape();
match event {
Some(EngineEvent::UndoTones { restored, .. }) => {
assert_eq!(restored, "af");
assert_eq!(restored, "a");
}
_ => panic!("Expected UndoTones, got {:?}", event),
}
@ -1737,19 +1815,145 @@ mod tests {
#[test]
fn backspace_count_then_second_tone_replaces_previous() {
// Type "as" → á, then "f" → f goes to 'á': but 'á' is not in VOWELS
// So 'f' is just appended: "áf"
// Type "as" → á, then "f" → f overrides sắc with huyền → "à"
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "asf");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
// "as" → Replace {2, "á"}, "f" → buffer = "áf" (no vowel change) → no event
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events);
// "as" → Replace {2, "á"}, "f" → Replace {2, "à"}
assert_eq!(replace_events.len(), 2, "Expected 2 Replace: {:?}", replace_events);
assert_eq!(replace_events[0].0, 2);
assert_eq!(replace_events[0].1, "á");
assert_eq!(get_display(&events), "áf");
assert_eq!(replace_events[1].0, 2);
assert_eq!(replace_events[1].1, "à");
assert_eq!(get_display(&events), "à");
}
// ================================================================
// Smart Modifier Overriding (Diacritic Replacement)
// ================================================================
// Category 1: The 'A' Vowel Group (a, â, ă)
#[test]
fn telex_override_a_aa_then_w() {
let mut e = Engine::new(InputMethod::Telex);
// "traan" → aa makes â → "trân", then w overrides â→ă → "trăn"
assert_eq!(get_display(&process_input(&mut e, "traanw")), "trăn");
}
#[test]
fn telex_override_a_aw_then_a() {
let mut e = Engine::new(InputMethod::Telex);
// "tranw" → w modifies a→ă → "trăn", then a overrides ă→â → "trân"
assert_eq!(get_display(&process_input(&mut e, "tranwa")), "trân");
}
#[test]
fn vni_override_a_6_then_8() {
let mut e = Engine::new(InputMethod::Vni);
// "tran6" → 6 makes â → "trân", then 8 overrides â→ă → "trăn"
assert_eq!(get_display(&process_input(&mut e, "tran68")), "trăn");
}
#[test]
fn vni_override_a_8_then_6() {
let mut e = Engine::new(InputMethod::Vni);
// "tran8" → 8 makes ă → "trăn", then 6 overrides ă→â → "trân"
assert_eq!(get_display(&process_input(&mut e, "tran86")), "trân");
}
// Category 2: The 'O' Vowel Group (o, ô, ơ)
#[test]
fn telex_override_o_oo_then_w() {
let mut e = Engine::new(InputMethod::Telex);
// "coon" → oo makes ô → "côn", then w overrides ô→ơ → "cơn"
assert_eq!(get_display(&process_input(&mut e, "coonw")), "cơn");
}
#[test]
fn telex_override_o_ow_then_o() {
let mut e = Engine::new(InputMethod::Telex);
// "conw" → w modifies o→ơ → "cơn", then o overrides ơ→ô → "côn"
assert_eq!(get_display(&process_input(&mut e, "conwo")), "côn");
}
#[test]
fn vni_override_o_6_then_7() {
let mut e = Engine::new(InputMethod::Vni);
// "con6" → 6 makes ô → "côn", then 7 overrides ô→ơ → "cơn"
assert_eq!(get_display(&process_input(&mut e, "con67")), "cơn");
}
#[test]
fn vni_override_o_7_then_6() {
let mut e = Engine::new(InputMethod::Vni);
// "con7" → 7 makes ơ → "cơn", then 6 overrides ơ→ô → "côn"
assert_eq!(get_display(&process_input(&mut e, "con76")), "côn");
}
// Category 3: Complex Double Vowels (uo → uô / ươ)
#[test]
fn telex_override_uo_oo_then_w() {
let mut e = Engine::new(InputMethod::Telex);
// "chuoon" → oo makes ô → "chuôn", then w overrides ô→ơ → "chươn"
assert_eq!(get_display(&process_input(&mut e, "chuoonw")), "chươn");
}
#[test]
fn telex_override_uo_ow_then_o() {
let mut e = Engine::new(InputMethod::Telex);
// "chuonw" → w modifies o→ơ → "chươn", then o overrides ơ→ô → "chuôn"
assert_eq!(get_display(&process_input(&mut e, "chuonwo")), "chuôn");
}
#[test]
fn vni_override_uo_6_then_7() {
let mut e = Engine::new(InputMethod::Vni);
// "chuon6" → 6 makes ô → "chuôn", then 7 overrides ô→ơ → "chươn"
assert_eq!(get_display(&process_input(&mut e, "chuon67")), "chươn");
}
#[test]
fn vni_override_uo_7_then_6() {
let mut e = Engine::new(InputMethod::Vni);
// "chuon7" → 7 makes ơ → "chươn", then 6 overrides ơ→ô → "chuôn"
assert_eq!(get_display(&process_input(&mut e, "chuon76")), "chuôn");
}
// Category 4: Modifier Overriding while Preserving Tones
#[test]
fn telex_override_with_tone_preserved_aa_s_w() {
let mut e = Engine::new(InputMethod::Telex);
// "traans" → aa→â, s→sắc → "trấn", then w overrides â→ă, sắc preserved → "trắn"
assert_eq!(get_display(&process_input(&mut e, "traansw")), "trắn");
}
#[test]
fn telex_override_with_tone_preserved_oo_f_w() {
let mut e = Engine::new(InputMethod::Telex);
// "coonsf" → oo→ô, s→sắc then f overrides sắc→huyền → "cồn", then w overrides ô→ơ, huyền preserved → "cờn"
assert_eq!(get_display(&process_input(&mut e, "coonsfw")), "cờn");
}
#[test]
fn vni_override_with_tone_preserved_6_1_then_8() {
let mut e = Engine::new(InputMethod::Vni);
// "tran61" → 6→â, 1→sắc → "trấn", then 8 overrides â→ă, sắc preserved → "trắn"
assert_eq!(get_display(&process_input(&mut e, "tran618")), "trắn");
}
#[test]
fn vni_override_with_tone_preserved_6_2_then_7() {
let mut e = Engine::new(InputMethod::Vni);
// "con62" → 6→ô, 2→huyền → "cồn", then 7 overrides ô→ơ, huyền preserved → "cờn"
// Note: input is "con62" then "7", but the tone 2 comes first, then modifier 7
assert_eq!(get_display(&process_input(&mut e, "con627")), "cờn");
}
// ================================================================
@ -1775,4 +1979,127 @@ mod tests {
assert_eq!(replace_events[1], 4, "banj→bạn should be 4");
assert_eq!(get_display(&events), "xin chào bạn");
}
// ================================================================
// Core Edge Case Test Suite (from specification)
// ================================================================
// Standard
#[test]
fn core_test_traafn() {
let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "traafn")), "trần");
}
#[test]
fn core_test_tranaf() {
let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "tranaf")), "trần");
}
#[test]
fn core_test_tran62() {
let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "tran62")), "trần");
}
// Double vowel / smart cluster
#[test]
fn core_test_chuwowng() {
let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "chuwowng")), "chương");
}
#[test]
fn core_test_chuongw() {
let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "chuongw")), "chương");
}
#[test]
fn core_test_chuong7() {
let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "chuong7")), "chương");
}
// Shape override
#[test]
fn core_test_traanw() {
let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "traanw")), "trăn");
}
#[test]
fn core_test_trawa() {
let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "trawa")), "trâ");
}
#[test]
fn core_test_trawan() {
let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "trawan")), "trân");
}
#[test]
fn core_test_tran68() {
let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "tran68")), "trăn");
}
// Tone override
#[test]
fn core_test_traansf() {
let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "traansf")), "trần");
}
#[test]
fn core_test_tran612() {
let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "tran612")), "trần");
}
// Complex consonant + flexible
#[test]
fn core_test_nghieeng() {
let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "nghieeng")), "nghiêng");
}
#[test]
fn core_test_nghieengf() {
let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "nghieengf")), "nghiềng");
}
#[test]
fn core_test_nghiengf() {
let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "nghiengf")), "nghìeng");
}
#[test]
fn core_test_nghieng62() {
let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "nghieng62")), "nghiềng");
}
// Tone placement
#[test]
fn core_test_hoangf() {
let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "hoangf")), "hoàng");
}
#[test]
fn core_test_thuyr() {
let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "thuyr")), "thuỷ");
}
#[test]
fn core_test_thuy3() {
let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "thuy3")), "thuỷ");
}
// Initial đ (dd)
#[test]
fn core_test_ddang() {
let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "ddang")), "đang");
}
#[test]
fn core_test_dang9() {
let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "dang9")), "đang");
}
}

View file

@ -1,13 +1,62 @@
use crate::engine::EngineEvent;
const VOWELS: &[char] = &['a', 'e', 'i', 'o', 'u', 'y', 'ă', 'â', 'ê', 'ô', 'ơ', 'ư'];
const VOWELS: &[char] = &[
'a', 'e', 'i', 'o', 'u', 'y',
'ă', 'â', 'ê', 'ô', 'ơ', 'ư',
];
const VOWEL_ACCENTED: &[char] = &[
'a', 'á', 'à', 'ả', 'ã', 'ạ',
'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ',
'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ',
'e', 'é', 'è', 'ẻ', 'ẽ', 'ẹ',
'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ',
'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị',
'o', 'ó', 'ò', 'ỏ', 'õ', 'ọ',
'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ',
'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ',
'u', 'ú', 'ù', 'ủ', 'ũ', 'ụ',
'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự',
'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ',
];
fn is_vowel(c: char) -> bool {
VOWELS.contains(&c)
VOWEL_ACCENTED.contains(&c)
}
const MAX_FLEXIBLE_BACKTRACK: usize = 3;
/// Strip tone from a Vietnamese vowel, returning (base_modified_vowel, tone_digit_or_none)
fn strip_tone_vni(c: char) -> (char, Option<char>) {
match c {
'a' => ('a', None), 'á' => ('a', Some('1')), 'à' => ('a', Some('2')),
'ả' => ('a', Some('3')), 'ã' => ('a', Some('4')), 'ạ' => ('a', Some('5')),
'ă' => ('ă', None), 'ắ' => ('ă', Some('1')), 'ằ' => ('ă', Some('2')),
'ẳ' => ('ă', Some('3')), 'ẵ' => ('ă', Some('4')), 'ặ' => ('ă', Some('5')),
'â' => ('â', None), 'ấ' => ('â', Some('1')), 'ầ' => ('â', Some('2')),
'ẩ' => ('â', Some('3')), 'ẫ' => ('â', Some('4')), 'ậ' => ('â', Some('5')),
'e' => ('e', None), 'é' => ('e', Some('1')), 'è' => ('e', Some('2')),
'ẻ' => ('e', Some('3')), 'ẽ' => ('e', Some('4')), 'ẹ' => ('e', Some('5')),
'ê' => ('ê', None), 'ế' => ('ê', Some('1')), 'ề' => ('ê', Some('2')),
'ể' => ('ê', Some('3')), 'ễ' => ('ê', Some('4')), 'ệ' => ('ê', Some('5')),
'i' => ('i', None), 'í' => ('i', Some('1')), 'ì' => ('i', Some('2')),
'ỉ' => ('i', Some('3')), 'ĩ' => ('i', Some('4')), 'ị' => ('i', Some('5')),
'o' => ('o', None), 'ó' => ('o', Some('1')), 'ò' => ('o', Some('2')),
'ỏ' => ('o', Some('3')), 'õ' => ('o', Some('4')), 'ọ' => ('o', Some('5')),
'ô' => ('ô', None), 'ố' => ('ô', Some('1')), 'ồ' => ('ô', Some('2')),
'ổ' => ('ô', Some('3')), 'ỗ' => ('ô', Some('4')), 'ộ' => ('ô', Some('5')),
'ơ' => ('ơ', None), 'ớ' => ('ơ', Some('1')), 'ờ' => ('ơ', Some('2')),
'ở' => ('ơ', Some('3')), 'ỡ' => ('ơ', Some('4')), 'ợ' => ('ơ', Some('5')),
'u' => ('u', None), 'ú' => ('u', Some('1')), 'ù' => ('u', Some('2')),
'ủ' => ('u', Some('3')), 'ũ' => ('u', Some('4')), 'ụ' => ('u', Some('5')),
'ư' => ('ư', None), 'ứ' => ('ư', Some('1')), 'ừ' => ('ư', Some('2')),
'ử' => ('ư', Some('3')), 'ữ' => ('ư', Some('4')), 'ự' => ('ư', Some('5')),
'y' => ('y', None), 'ý' => ('y', Some('1')), 'ỳ' => ('y', Some('2')),
'ỷ' => ('y', Some('3')), 'ỹ' => ('y', Some('4')), 'ỵ' => ('y', Some('5')),
_ => (c, None),
}
}
fn apply_tone_to_vowel(vowel: char, digit: char) -> Option<char> {
// VNI: 1=sắc, 2=huyền, 3=hỏi, 4=ngã, 5=nặng
let table: &[(char, char, char)] = &[
@ -30,9 +79,38 @@ fn apply_tone_to_vowel(vowel: char, digit: char) -> Option<char> {
return Some(result);
}
}
// Tone overriding: vowel already has a tone → strip it and apply the new one
let (base, _) = strip_tone_vni(vowel);
if base != vowel {
for &(v, t, result) in table {
if v == base && t == digit {
return Some(result);
}
}
}
None
}
/// Override the shape modifier on a vowel with a different one.
/// Preserves any existing tone.
/// VNI mappings: â↔ă via 6↔8, ô↔ơ via 6↔7
fn override_vni_modifier(vowel: char, digit: char) -> Option<char> {
let (base, tone) = strip_tone_vni(vowel);
let new_base = match (base, digit) {
('â', '8') => Some('ă'),
('ă', '6') => Some('â'),
('ô', '7') => Some('ơ'),
('ơ', '6') => Some('ô'),
_ => None,
}?;
match tone {
None => Some(new_base),
Some(t) => apply_tone_to_vowel(new_base, t),
}
}
fn apply_digit_to_vowel(vowel: char, digit: char) -> Option<char> {
// VNI: 6=â, 7=ơ+ư, 8=ă+ê, 9=ô, 0=ơ+ư
// Standard VNI: a6=â, a8=ă, e6=ê, o6=ô, o7=ơ, u7=ư
@ -56,6 +134,49 @@ fn apply_digit_to_vowel(vowel: char, digit: char) -> Option<char> {
}
}
fn is_u_vowel(c: char) -> bool {
matches!(c, 'u' | 'ú' | 'ù' | 'ủ' | 'ũ' | 'ụ')
}
fn is_o_vowel(c: char) -> bool {
matches!(c, 'o' | 'ó' | 'ò' | 'ỏ' | 'õ' | 'ọ')
}
fn tone_of_vowel_vni(c: char) -> Option<char> {
match c {
'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None,
'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => Some('2'),
'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => Some('1'),
'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => Some('3'),
'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => Some('4'),
'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => Some('5'),
_ => None,
}
}
fn apply_tone_to_ơ_vni(tone: Option<char>) -> char {
match tone {
None => 'ơ',
Some('2') => 'ờ',
Some('1') => 'ớ',
Some('3') => 'ở',
Some('4') => 'ỡ',
Some('5') => 'ợ',
_ => 'ơ',
}
}
fn uo_to_uơ_vni(u_char: char, o_char: char) -> (char, char) {
let o_tone = tone_of_vowel_vni(o_char);
let u_tone = tone_of_vowel_vni(u_char);
let tone = o_tone.or(u_tone);
('ư', apply_tone_to_ơ_vni(tone))
}
fn is_q_before_u(chars: &[char], i: usize) -> bool {
i > 1 && chars[i - 2] == 'q'
}
pub struct VniEngine {
buffer: String,
pending_modifier: Option<char>,
@ -103,6 +224,26 @@ impl VniEngine {
if self.pending_modifier.is_some() {
self.apply_pending();
}
// dd → đ digraph
if ch == 'd' {
if let Some(last_ch) = self.buffer.chars().last() {
if last_ch == 'd' {
let chars: Vec<char> = self.buffer.chars().collect();
if chars.len() == 1 {
self.buffer.pop();
self.buffer.push('đ');
return None;
} else if chars.len() >= 2 {
let prev = chars[chars.len() - 2];
if !is_vowel(prev) {
self.buffer.pop();
self.buffer.push('đ');
return None;
}
}
}
}
}
self.buffer.push(ch);
None
}
@ -118,6 +259,19 @@ impl VniEngine {
// Find last vowel (standard behavior)
if let Some(last_ch) = self.buffer.chars().last() {
if is_vowel(last_ch) {
// Smart cluster "uo" → "ươ" (digit '7')
if digit == '7' && is_o_vowel(last_ch) {
let mut chars: Vec<char> = self.buffer.chars().collect();
if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) {
let o_char = chars.pop().unwrap();
let u_char = chars.pop().unwrap();
let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char);
self.buffer = chars.into_iter().collect::<String>();
self.buffer.push(new_first);
self.buffer.push(new_second);
return None;
}
}
// Try tone first (1-5)
if let Some(modified) = apply_tone_to_vowel(last_ch, digit) {
self.buffer.pop();
@ -131,6 +285,55 @@ impl VniEngine {
self.buffer.push(modified);
return None;
}
// Smart cluster forward (override): "uô" + 7 → "ươ"
if digit == '7' {
let strip = strip_tone_vni(last_ch);
if strip.0 == 'ô' {
let mut chars: Vec<char> = self.buffer.chars().collect();
if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) {
let o_char = chars.pop().unwrap();
let u_char = chars.pop().unwrap();
let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char);
self.buffer = chars.into_iter().collect::<String>();
self.buffer.push(new_first);
self.buffer.push(new_second);
return None;
}
}
}
// Smart cluster reverse (override): "ươ" + 6 → "uô"
if digit == '6' {
let strip = strip_tone_vni(last_ch);
if strip.0 == 'ơ' {
let mut chars: Vec<char> = self.buffer.chars().collect();
if chars.len() >= 2 && chars[chars.len() - 2] == 'ư' {
let ơ_char = chars.pop().unwrap();
chars.pop().unwrap();
let tone = tone_of_vowel_vni(ơ_char);
let ô_char = match tone {
None => 'ô',
Some(t) => apply_tone_to_vowel('ô', t).unwrap_or('ô'),
};
self.buffer = chars.into_iter().collect::<String>();
self.buffer.push('u');
self.buffer.push(ô_char);
return None;
}
}
}
// VNI digit 9: 'd' → 'đ'
if digit == '9' && last_ch == 'd' {
self.buffer.pop();
self.buffer.push('đ');
return None;
}
// Modifier override: vowel already has a different modifier
if let Some(modified) = override_vni_modifier(last_ch, digit) {
self.buffer.pop();
self.buffer.push(modified);
return None;
}
}
}
@ -141,6 +344,17 @@ impl VniEngine {
let start = chars.len().saturating_sub(MAX_FLEXIBLE_BACKTRACK);
for i in (start..chars.len()).rev() {
if is_vowel(chars[i]) {
// Smart cluster "uo" → "ươ" (digit '7', flexible)
if digit == '7' && is_o_vowel(chars[i]) && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) {
let (new_first, new_second) = uo_to_uơ_vni(chars[i - 1], chars[i]);
self.buffer = chars[..i - 1].iter().collect::<String>();
self.buffer.push(new_first);
self.buffer.push(new_second);
for &c in &chars[i + 1..] {
self.buffer.push(c);
}
return None;
}
// Try tone first (1-5)
if let Some(modified) = apply_tone_to_vowel(chars[i], digit) {
self.buffer = chars[..i].iter().collect::<String>();
@ -159,7 +373,73 @@ impl VniEngine {
}
return None;
}
// Smart cluster forward (override): "uô" + 7 → "ươ" (flexible)
if digit == '7' {
let strip = strip_tone_vni(chars[i]);
if strip.0 == 'ô' && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) {
let (new_first, new_second) = uo_to_uơ_vni(chars[i - 1], chars[i]);
self.buffer = chars[..i - 1].iter().collect::<String>();
self.buffer.push(new_first);
self.buffer.push(new_second);
for &c in &chars[i + 1..] {
self.buffer.push(c);
}
return None;
}
}
// Smart cluster reverse (override): "ươ" + 6 → "uô" (flexible)
if digit == '6' {
let strip = strip_tone_vni(chars[i]);
if strip.0 == 'ơ' && i > 0 && chars[i - 1] == 'ư' {
let ơ_char = chars[i];
let tone = tone_of_vowel_vni(ơ_char);
let ô_char = match tone {
None => 'ô',
Some(t) => apply_tone_to_vowel('ô', t).unwrap_or('ô'),
};
self.buffer = chars[..i - 1].iter().collect::<String>();
self.buffer.push('u');
self.buffer.push(ô_char);
for &c in &chars[i + 1..] {
self.buffer.push(c);
}
return None;
}
}
// VNI digit 9: 'd' → 'đ' (flexible)
if digit == '9' && chars[i] == 'd' {
self.buffer = chars[..i].iter().collect::<String>();
self.buffer.push('đ');
for &c in &chars[i + 1..] {
self.buffer.push(c);
}
return None;
}
// Modifier override: vowel already has a different modifier
if let Some(modified) = override_vni_modifier(chars[i], digit) {
self.buffer = chars[..i].iter().collect::<String>();
self.buffer.push(modified);
for &c in &chars[i + 1..] {
self.buffer.push(c);
}
return None;
}
}
}
}
}
// Digit '9' in flexible context: scan backwards for 'd' → 'đ'
if digit == '9' {
let chars: Vec<char> = self.buffer.chars().collect();
for i in (0..chars.len()).rev() {
if chars[i] == 'd' {
self.buffer = chars[..i].iter().collect::<String>();
self.buffer.push('đ');
for &c in &chars[i + 1..] {
self.buffer.push(c);
}
return None;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -30,14 +30,13 @@ echo " Built with x11 + wayland"
cd "$SCRIPT_DIR"
cd "$PROJECT_ROOT/ui" && cargo build --release 2>/dev/null && cd "$SCRIPT_DIR" || echo " UI build skipped (missing GTK4 libs)"
cd "$PROJECT_ROOT/ui" && cargo build --release && cd "$SCRIPT_DIR"
cd "$PROJECT_ROOT"
# Copy binaries
echo "[2/5] Installing binaries..."
cp target/release/vietc "$APPDIR/usr/bin/"
cp target/release/vietc-cli "$APPDIR/usr/bin/"
[ -f ui/target/release/vietc-settings ] && cp ui/target/release/vietc-settings "$APPDIR/usr/bin/"
[ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/"
# Desktop integration
@ -200,8 +199,6 @@ trap cleanup_daemon EXIT INT TERM
if [ -f "$HERE/usr/bin/vietc-tray" ]; then
"$HERE/usr/bin/vietc-tray" "$@"
elif [ -f "$HERE/usr/bin/vietc-settings" ]; then
"$HERE/usr/bin/vietc-settings" "$@"
else
echo "[vietc] Daemon running in foreground. Press Ctrl+C to stop."
wait "$DAEMON_PID"

View file

@ -139,6 +139,63 @@ impl KeyInjector for UinputInjector {
}
impl UinputInjector {
/// Get the original non-root username when running as root.
/// Checks SUDO_USER (sudo), PKEXEC_UID (pkexec), /proc/self/loginuid,
/// and falls back to `logname`.
fn get_original_username() -> Option<String> {
let is_root = unsafe { libc::getuid() == 0 };
if !is_root {
return None;
}
if let Ok(user) = std::env::var("SUDO_USER") {
if !user.is_empty() {
return Some(user);
}
}
if let Ok(uid_str) = std::env::var("PKEXEC_UID") {
if let Ok(uid) = uid_str.parse::<u32>() {
unsafe {
let pw = libc::getpwuid(uid);
if !pw.is_null() {
let name = std::ffi::CStr::from_ptr((*pw).pw_name)
.to_string_lossy().into_owned();
if !name.is_empty() {
return Some(name);
}
}
}
}
}
if let Ok(content) = std::fs::read_to_string("/proc/self/loginuid") {
if let Ok(uid) = content.trim().parse::<u32>() {
unsafe {
let pw = libc::getpwuid(uid);
if !pw.is_null() {
let name = std::ffi::CStr::from_ptr((*pw).pw_name)
.to_string_lossy().into_owned();
if !name.is_empty() {
return Some(name);
}
}
}
}
}
if let Ok(output) = std::process::Command::new("logname").output() {
if output.status.success() {
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !name.is_empty() {
return Some(name);
}
}
}
None
}
/// Run an external command as the original user if we're root.
/// Wayland tools (wtype, wl-copy) need the user's session, not root.
/// Uses explicit `env VAR=val` instead of `--preserve-env` for
@ -146,12 +203,12 @@ impl UinputInjector {
fn run_as_user(program: &str, args: &[&str]) -> std::process::Output {
let is_root = unsafe { libc::getuid() == 0 };
if is_root {
if let Ok(sudo_user) = std::env::var("SUDO_USER") {
if let Some(original_user) = Self::get_original_username() {
let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default();
let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default();
let display = std::env::var("DISPLAY").unwrap_or_default();
let mut cmd = std::process::Command::new("sudo");
cmd.args(["-u", &sudo_user, "env"]);
cmd.args(["-u", &original_user, "env"]);
if !wayland_display.is_empty() {
cmd.arg(format!("WAYLAND_DISPLAY={}", wayland_display));
}
@ -166,7 +223,7 @@ impl UinputInjector {
match cmd.output() {
Ok(output) => return output,
Err(e) => {
eprintln!("[vietc] Failed to run sudo -u {} env ... {} {}: {}", sudo_user, program, args.join(" "), e);
eprintln!("[vietc] Failed to run sudo -u {} env ... {} {}: {}", original_user, program, args.join(" "), e);
return std::process::Output {
status: std::process::ExitStatus::default(),
stdout: vec![],
@ -174,6 +231,8 @@ impl UinputInjector {
};
}
}
} else {
eprintln!("[vietc] Running as root but could not determine original user");
}
}
match std::process::Command::new(program).args(args).output() {
@ -195,18 +254,98 @@ impl UinputInjector {
/// best available method: ydotool (uinput) for ASCII, xdotool (X11) or
/// clipboard for Unicode.
fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult {
// Backspaces via uinput — reliable, no display server needed
let is_ascii = text.chars().all(|c| char_to_linux_keycode(c).is_some());
if is_ascii {
if backspaces > 0 {
eprintln!("[vietc] uinput backspace x{}", backspaces);
for _ in 0..backspaces {
let _ = self.send_backspace();
}
}
if !text.is_empty() {
eprintln!("[vietc] text injection: \"{}\"", text);
self.paste_string(text);
for ch in text.chars() {
let _ = self.send_char(ch);
}
return InjectResult::Success;
}
// It is Unicode. We must use a single unified channel.
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
if is_wayland {
// Under Wayland, we try to use `wtype` for both backspaces and text.
let has_wtype = std::process::Command::new("which")
.arg("wtype")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if has_wtype {
let mut args = Vec::new();
for _ in 0..backspaces {
args.push("-k");
args.push("BackSpace");
}
args.push("--");
args.push(text);
let output = Self::run_as_user("wtype", &args);
if output.status.success() {
return InjectResult::Success;
}
eprintln!("[vietc] wtype inject failed: {}", String::from_utf8_lossy(&output.stderr).trim());
}
} else {
// Under X11, we try to use `xdotool` for both backspaces and text.
let has_xdotool = std::process::Command::new("which")
.arg("xdotool")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if has_xdotool {
let mut args = Vec::new();
if backspaces > 0 {
args.push("key");
for _ in 0..backspaces {
args.push("BackSpace");
}
}
if !text.is_empty() {
args.push("type");
args.push("--clearmodifiers");
args.push(text);
}
let output = Self::run_as_user("xdotool", &args);
if output.status.success() {
return InjectResult::Success;
}
eprintln!("[vietc] xdotool inject failed: {}", String::from_utf8_lossy(&output.stderr).trim());
}
}
// Fallback: Clipboard copy + paste.
// This is safe because both backspaces and Ctrl+V are injected into the SAME uinput device.
let copied = self.copy_to_clipboard(text);
if copied {
if backspaces > 0 {
for _ in 0..backspaces {
let _ = self.send_backspace();
}
}
self.send_ctrl_v();
InjectResult::Success
} else {
eprintln!("[vietc] clipboard copy failed during fallback");
// Absolute last resort: try uinput backspaces followed by individual unicode paste_string
if backspaces > 0 {
for _ in 0..backspaces {
let _ = self.send_backspace();
}
}
self.paste_string(text);
InjectResult::Success
}
}
/// Copy text to clipboard and paste via Ctrl+V through our uinput device.
@ -214,14 +353,11 @@ impl UinputInjector {
/// unavailable. Prefers ydotool (uinput, works everywhere) to avoid
/// clipboard pollution.
fn paste_string(&self, s: &str) {
let has_unicode = s.chars().any(|c| c > '\x7f');
if !has_unicode {
// Pure ASCII: ydotool works reliably (no keycode mapping issues).
let output = std::process::Command::new("ydotool")
// Try ydotool first (uinput-based, no display server needed).
let ydotool_result = std::process::Command::new("ydotool")
.args(["type", s])
.output();
if let Ok(output) = output {
if let Ok(output) = ydotool_result {
if output.status.success() {
eprintln!("[vietc] ydotool OK");
return;
@ -230,11 +366,8 @@ impl UinputInjector {
if !stderr.is_empty() {
eprintln!("[vietc] ydotool failed: {}", stderr.trim());
}
}
eprintln!("[vietc] ydotool failed, trying xdotool...");
}
} else {
eprintln!("[vietc] contains Unicode, skipping ydotool");
}
// Try xdotool (X11): needs DISPLAY, run through run_as_user
eprintln!("[vietc] trying xdotool...");
@ -272,18 +405,16 @@ impl UinputInjector {
eprintln!("[vietc] WARNING: No injection method works for '{}'!", s);
}
/// Copy text to clipboard using wl-copy (Wayland) or xclip (X11).
fn copy_to_clipboard(&self, s: &str) -> bool {
/// Build a command to run as the original user with display environment.
fn user_cmd(program: &str) -> std::process::Command {
let is_root = unsafe { libc::getuid() == 0 };
if is_root {
if let Ok(sudo_user) = std::env::var("SUDO_USER") {
if let Some(original_user) = Self::get_original_username() {
let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default();
let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default();
let display = std::env::var("DISPLAY").unwrap_or_default();
eprintln!("[vietc] clipboard: is_root, SUDO_USER={} DISPLAY={} WAYLAND={} XDG_RUNTIME_DIR={}",
sudo_user, display, wayland_display, xdg_runtime_dir);
let mut cmd = std::process::Command::new("sudo");
cmd.args(["-u", &sudo_user, "env"]);
cmd.args(["-u", &original_user, "env"]);
if !wayland_display.is_empty() {
cmd.arg(format!("WAYLAND_DISPLAY={}", wayland_display));
}
@ -293,7 +424,21 @@ impl UinputInjector {
if !display.is_empty() {
cmd.arg(format!("DISPLAY={}", display));
}
cmd.arg("wl-copy");
cmd.arg(program);
return cmd;
}
}
std::process::Command::new(program)
}
/// Copy text to clipboard using wl-copy (Wayland) or xclip (X11).
fn copy_to_clipboard(&self, s: &str) -> bool {
let is_root = unsafe { libc::getuid() == 0 };
eprintln!("[vietc] clipboard: is_root={}", is_root);
// Try wl-copy (Wayland) via user_cmd
{
let mut cmd = Self::user_cmd("wl-copy");
eprintln!("[vietc] clipboard: trying wl-copy via {:?}", cmd);
let result = cmd
.stdin(std::process::Stdio::piped())
@ -312,69 +457,15 @@ impl UinputInjector {
} else if let Err(ref e) = result {
eprintln!("[vietc] clipboard: wl-copy error: {}", e);
}
} else {
eprintln!("[vietc] clipboard: is_root but no SUDO_USER");
}
} else {
eprintln!("[vietc] clipboard: not root, trying wl-copy directly");
let result = std::process::Command::new("wl-copy")
.stdin(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(s.as_bytes())?;
child.wait()
});
if let Ok(status) = result {
if status.success() {
eprintln!("[vietc] clipboard: wl-copy OK");
return true;
}
eprintln!("[vietc] clipboard: wl-copy failed (exit={:?})", status.code());
} else if let Err(ref e) = result {
eprintln!("[vietc] clipboard: wl-copy error: {}", e);
}
}
// Try xclip (X11). When root, run as SUDO_USER so it can connect to X.
// Try xclip (X11) via user_cmd
eprintln!("[vietc] clipboard: trying xclip...");
let xclip_result = if is_root {
if let Ok(sudo_user) = std::env::var("SUDO_USER") {
let display = std::env::var("DISPLAY").unwrap_or_default();
let mut cmd = std::process::Command::new("sudo");
cmd.args(["-u", &sudo_user, "env"]);
if !display.is_empty() {
cmd.arg(format!("DISPLAY={}", display));
}
cmd.arg("xclip");
{
let mut cmd = Self::user_cmd("xclip");
cmd.args(["-selection", "clipboard"]);
eprintln!("[vietc] clipboard: xclip via {:?}", cmd);
cmd.stdin(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(s.as_bytes())?;
child.wait()
})
.map(|status| {
if status.success() {
eprintln!("[vietc] clipboard: xclip OK");
} else {
eprintln!("[vietc] clipboard: xclip failed (exit={:?})", status.code());
}
status.success()
})
.unwrap_or_else(|e| {
eprintln!("[vietc] clipboard: xclip error: {}", e);
false
})
} else {
eprintln!("[vietc] clipboard: is_root but no SUDO_USER in xclip path");
false
}
} else {
eprintln!("[vietc] clipboard: not root, trying xclip directly");
std::process::Command::new("xclip")
.args(["-selection", "clipboard"])
let result = cmd
.stdin(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
@ -393,10 +484,13 @@ impl UinputInjector {
.unwrap_or_else(|e| {
eprintln!("[vietc] clipboard: xclip error: {}", e);
false
})
};
});
if result {
return true;
}
}
xclip_result
false
}
/// Send Ctrl+V through our uinput device.

View file

@ -167,9 +167,35 @@ impl X11Injector {
if ydotool_ok {
return;
}
let _ = std::process::Command::new("xdotool")
let xdotool_ok = std::process::Command::new("xdotool")
.args(["type", "--clearmodifiers", &s])
.output();
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if xdotool_ok {
return;
}
// Clipboard fallback: xclip + Ctrl+V via XTEST
let copied = std::process::Command::new("xclip")
.args(["-selection", "clipboard"])
.stdin(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(s.as_bytes())?;
child.wait()
})
.map(|status| status.success())
.unwrap_or(false);
if copied {
unsafe {
(self.lib.x_test_fake_key_event)(self.display, 29, 1, 0); // Ctrl press
(self.lib.x_test_fake_key_event)(self.display, 47, 1, 0); // V press
(self.lib.x_test_fake_key_event)(self.display, 47, 0, 0); // V release
(self.lib.x_test_fake_key_event)(self.display, 29, 0, 0); // Ctrl release
(self.lib.x_flush)(self.display);
}
}
}
}
@ -196,6 +222,97 @@ impl KeyInjector for X11Injector {
InjectResult::Success
}
fn inject_replacement(&self, backspaces: usize, text: &str) -> InjectResult {
let is_ascii = text.chars().all(|c| char_to_keycode(c).is_some());
if is_ascii {
if backspaces > 0 {
for _ in 0..backspaces {
self.send_keycode(14, false); // KEY_BACKSPACE
}
}
for ch in text.chars() {
if let Some((keycode, shift)) = char_to_keycode(ch) {
self.send_keycode(keycode, shift);
}
}
return InjectResult::Success;
}
// Contains Unicode: try xdotool with both backspaces and text in a single command
let has_xdotool = std::process::Command::new("which")
.arg("xdotool")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if has_xdotool {
let mut args = Vec::new();
if backspaces > 0 {
args.push("key".to_string());
for _ in 0..backspaces {
args.push("BackSpace".to_string());
}
}
if !text.is_empty() {
args.push("type".to_string());
args.push("--clearmodifiers".to_string());
args.push(text.to_string());
}
let ok = std::process::Command::new("xdotool")
.args(&args)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if ok {
return InjectResult::Success;
}
}
// Fallback: Clipboard copy + paste.
// Send backspaces via XTEST, then copy to clipboard, then paste (Ctrl+V) via XTEST.
// Since all XTEST key events go through the same display connection, their ordering is guaranteed.
let mut clipboard_cmd = std::process::Command::new("xclip");
clipboard_cmd.args(["-selection", "clipboard"]);
clipboard_cmd.stdin(std::process::Stdio::piped());
let copied = clipboard_cmd.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(text.as_bytes())?;
child.wait()
})
.map(|status| status.success())
.unwrap_or(false);
if copied {
if backspaces > 0 {
for _ in 0..backspaces {
self.send_keycode(14, false); // KEY_BACKSPACE
}
}
unsafe {
(self.lib.x_test_fake_key_event)(self.display, 29, 1, 0); // Ctrl press
(self.lib.x_test_fake_key_event)(self.display, 47, 1, 0); // V press
(self.lib.x_test_fake_key_event)(self.display, 47, 0, 0); // V release
(self.lib.x_test_fake_key_event)(self.display, 29, 0, 0); // Ctrl release
(self.lib.x_flush)(self.display);
}
InjectResult::Success
} else {
// Absolute last resort: backspaces via XTEST followed by individual unicode send_unicode_via_xdotool
if backspaces > 0 {
for _ in 0..backspaces {
self.send_keycode(14, false); // KEY_BACKSPACE
}
}
for ch in text.chars() {
self.send_char(ch);
}
InjectResult::Success
}
}
fn flush(&self) -> InjectResult {
unsafe { (self.lib.x_flush)(self.display); }
InjectResult::Success

159
scripts/gen_test_cases.py Normal file
View file

@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Generate 1000+ Vietnamese IME test cases and produce a Rust test file."""
import json
import subprocess
import sys
import os
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, ".."))
EXAMPLE_PATH = os.path.join(PROJECT_DIR, "target", "release", "examples", "gen_tests")
def build_generator():
"""Build the Rust test case generator."""
subprocess.run(
["cargo", "run", "--example", "gen_tests", "--release"],
cwd=PROJECT_DIR,
capture_output=True,
check=True,
)
def run_generator():
"""Run the generator and return JSON lines."""
result = subprocess.run(
[EXAMPLE_PATH],
capture_output=True,
text=True,
timeout=30,
)
cases = []
for line in result.stdout.strip().split("\n"):
line = line.strip()
if not line or line.startswith("Generated"):
continue
try:
cases.append(json.loads(line))
except json.JSONDecodeError:
continue
return cases
def generate_rust_test(cases, output_path):
"""Generate a Rust test file with all cases."""
from datetime import datetime
lines = []
lines.append("/// Auto-generated from gen_tests example")
lines.append(f"/// Generated: {datetime.now().isoformat()}")
lines.append(f"/// Total cases: {len(cases)}")
lines.append("")
lines.append("use vietc_engine::{Engine, EngineEvent, InputMethod};")
lines.append("")
lines.append("fn get_display(events: &[EngineEvent]) -> String {")
lines.append(" let mut display = String::new();")
lines.append(" for ev in events {")
lines.append(" match ev {")
lines.append(" EngineEvent::Flush(text) => {")
lines.append(" if !display.ends_with(text) { display.push_str(text); }")
lines.append(" }")
lines.append(" EngineEvent::Insert(text) => display.push_str(text),")
lines.append(" EngineEvent::Replace { backspaces, insert } => {")
lines.append(" for _ in 0..*backspaces { display.pop(); }")
lines.append(" display.push_str(insert);")
lines.append(" }")
lines.append(" EngineEvent::AutoRestore(word) => {")
lines.append(" for _ in 0..word.len() { display.pop(); }")
lines.append(" display.push_str(word);")
lines.append(" }")
lines.append(" EngineEvent::UndoTones { backspaces, restored } => {")
lines.append(" for _ in 0..*backspaces { display.pop(); }")
lines.append(" display.push_str(restored);")
lines.append(" }")
lines.append(" }")
lines.append(" }")
lines.append(" display")
lines.append("}")
lines.append("")
lines.append("fn process_input(e: &mut Engine, input: &str) -> Vec<EngineEvent> {")
lines.append(" let mut events = Vec::new();")
lines.append(" for ch in input.chars() {")
lines.append(" if let Some(ev) = e.process_key(ch) { events.push(ev); }")
lines.append(" }")
lines.append(" events")
lines.append("}")
lines.append("")
lines.append(f"const TEST_CASES: &[(&str, &str, &str)] = &[")
for c in cases:
input_escaped = c["i"].replace("\\", "\\\\").replace("\"", "\\\"")
expected_escaped = c["e"].replace("\\", "\\\\").replace("\"", "\\\"")
mode = c["m"]
lines.append(f' ("{input_escaped}", "{expected_escaped}", "{mode}"),')
lines.append("];")
lines.append("")
lines.append("#[test]")
lines.append("fn test_generated_bulk() {")
lines.append(" let mut failures = Vec::new();")
lines.append(" for (i, &(input, expected, mode)) in TEST_CASES.iter().enumerate() {")
lines.append(" let im = match mode {")
lines.append(' "telex" => InputMethod::Telex,')
lines.append(' "vni" => InputMethod::Vni,')
lines.append(" _ => unreachable!(),")
lines.append(" };")
lines.append(" let mut e = Engine::new(im);")
lines.append(" let actual = get_display(&process_input(&mut e, input));")
lines.append(" if actual != expected {")
lines.append(" failures.push(format!(\"[{}] '{}' -> '{}' (expected '{}')\", i, input, actual, expected));")
lines.append(" }")
lines.append(" }")
lines.append(" if !failures.is_empty() {")
lines.append(" for f in &failures[..10.min(failures.len())] {")
lines.append(' eprintln!("{}", f);')
lines.append(" }")
lines.append(' panic!("{}/{} tests FAILED", failures.len(), TEST_CASES.len());')
lines.append(" }")
lines.append(' eprintln!("All {} generated tests PASSED", TEST_CASES.len());')
lines.append("}")
lines.append("")
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"Generated {output_path} with {len(cases)} test cases")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--output", default="engine/tests/generated_bulk.rs",
help="Output Rust test file path")
args = parser.parse_args()
output_path = os.path.join(PROJECT_DIR, args.output)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Run generator & capture cases
try:
result = subprocess.run(
["cargo", "run", "--example", "gen_tests", "--release"],
cwd=PROJECT_DIR,
capture_output=True,
text=True,
timeout=120,
)
cases = []
for line in result.stdout.strip().split("\n"):
line = line.strip()
if not line or line.startswith("Generated"):
continue
try:
cases.append(json.loads(line))
except json.JSONDecodeError:
continue
except subprocess.TimeoutExpired:
print("ERROR: Generator timed out", file=sys.stderr)
sys.exit(1)
except subprocess.CalledProcessError as e:
print(f"ERROR: Generator failed: {e.stderr}", file=sys.stderr)
sys.exit(1)
print(f"Captured {len(cases)} test cases from generator")
generate_rust_test(cases, output_path)

View file

@ -1,26 +1,15 @@
[package]
name = "vietc-ui"
name = "vietc-tray"
version = "0.1.0"
edition = "2021"
description = "Viet+ settings UI and tray icon (GTK4/Libadwaita)"
[[bin]]
name = "vietc-settings"
path = "src/main.rs"
description = "Viet+ system tray icon"
[[bin]]
name = "vietc-tray"
path = "src/tray.rs"
path = "src/main.rs"
[dependencies]
vietc-engine = { path = "../engine" }
gtk = { package = "gtk4", version = "0.9", features = ["v4_12"], optional = true }
adw = { package = "libadwaita", version = "0.7", features = ["v1_4"], optional = true }
ksni = "0.2"
toml = "0.8"
serde = { version = "1", features = ["derive"] }
dirs = "5"
[features]
default = ["ui"]
ui = ["dep:gtk", "dep:adw"]

View file

@ -1,69 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.0"/>
<template class="VietTuxWindow" class="AdwApplicationWindow" parent="AdwApplicationWindow">
<property name="default-width">600</property>
<property name="default-height">700</property>
<property name="title">VietTux Settings</property>
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<child type="end">
<object class="GtkButton" id="save_button">
<property name="label">Save</property>
<property name="css_classes">suggested-action</property>
</object>
</child>
</object>
</child>
<child>
<object class="Adwclamp">
<property name="maximum-size">600</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="vexpand">true</property>
<property name="child">
<object class="AdwPreferencesGroup">
<property name="title">Input Method</property>
<child>
<object class="AdwComboRow" id="method_row">
<property name="title">Keyboard Layout</property>
<property name="subtitle">Choose Telex or VNI input method</property>
<property name="model">
<object class="GtkStringList">
<items>
<item>Telex</item>
<item>VNI</item>
</items>
</object>
</property>
</object>
</child>
<child>
<object class="AdwComboRow" id="toggle_row">
<property name="title">Toggle Key</property>
<property name="subtitle">Key combination to toggle Vietnamese mode</property>
<property name="model">
<object class="GtkStringList">
<items>
<item>Ctrl + Space</item>
<item>Ctrl + Shift</item>
<item>Caps Lock</item>
</items>
</object>
</property>
</object>
</child>
</object>
</property>
</object>
</property>
</object>
</child>
</object>
</property>
</template>
</interface>

View file

@ -1,8 +1,47 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutoRestoreConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_restore_keys")]
pub trigger_keys: Vec<String>,
}
impl Default for AutoRestoreConfig {
fn default() -> Self {
Self {
enabled: true,
trigger_keys: default_restore_keys(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppStateConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub english_apps: Vec<String>,
#[serde(default)]
pub vietnamese_apps: Vec<String>,
}
impl Default for AppStateConfig {
fn default() -> Self {
Self {
enabled: true,
english_apps: vec![],
vietnamese_apps: vec![],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default = "default_input_method")]
@ -21,67 +60,39 @@ pub struct Config {
pub app_state: AppStateConfig,
#[serde(default)]
pub macros: HashMap<String, String>,
}
pub macros: std::collections::HashMap<String, String>,
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AutoRestoreConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_grab")]
pub grab: bool,
#[serde(default = "default_restore_keys")]
pub trigger_keys: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AppStateConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub english_apps: Vec<String>,
#[serde(default)]
pub vietnamese_apps: Vec<String>,
#[serde(default = "default_false")]
pub debug: bool,
}
fn default_input_method() -> String { "telex".into() }
fn default_toggle_key() -> String { "space".into() }
fn default_start_enabled() -> bool { true }
fn default_grab() -> bool { true }
fn default_true() -> bool { true }
fn default_false() -> bool { false }
fn default_restore_keys() -> Vec<String> { vec!["space".into(), "escape".into()] }
impl Default for Config {
fn default() -> Self {
let mut macros = HashMap::new();
macros.insert("ko".into(), "không".into());
macros.insert("dc".into(), "được".into());
macros.insert("vs".into(), "với".into());
macros.insert("lm".into(), "làm".into());
Self {
input_method: default_input_method(),
toggle_key: default_toggle_key(),
start_enabled: default_start_enabled(),
auto_restore: AutoRestoreConfig {
enabled: true,
trigger_keys: default_restore_keys(),
},
app_state: AppStateConfig {
enabled: true,
english_apps: vec![
"code".into(), "vim".into(), "nvim".into(),
"terminal".into(), "kitty".into(), "alacritty".into(),
],
vietnamese_apps: vec![
"telegram".into(), "discord".into(), "firefox".into(),
],
},
macros,
auto_restore: AutoRestoreConfig::default(),
app_state: AppStateConfig::default(),
macros: std::collections::HashMap::new(),
grab: default_grab(),
debug: default_false(),
}
}
}
impl Config {
pub fn load() -> Self {
for path in config_paths() {
@ -103,10 +114,6 @@ impl Config {
fs::write(&path, content)?;
Ok(())
}
pub fn path() -> PathBuf {
config_path()
}
}
fn config_path() -> PathBuf {
@ -151,7 +158,7 @@ pub fn uninstall_autostart() {
}
}
pub fn install_autostart_force() {
pub fn install_autostart() {
if let Some(config_dir) = dirs::config_dir() {
let autostart_dir = config_dir.join("autostart");
let desktop_file = autostart_dir.join("vietc-tray.desktop");
@ -169,8 +176,8 @@ pub fn install_autostart_force() {
let content = format!(
"[Desktop Entry]\n\
Type=Application\n\
Name=Viet+ Tray\n\
Comment=Vietnamese Input Method tray icon\n\
Name=Viet+\n\
Comment=Vietnamese Input Method\n\
Exec={}\n\
Icon=input-keyboard\n\
Terminal=false\n\
@ -184,4 +191,3 @@ pub fn install_autostart_force() {
eprintln!("[vietc] Installed autostart entry");
}
}

View file

@ -1,21 +1,165 @@
use adw::prelude::*;
use gtk::{gio, glib};
use std::path::PathBuf;
mod config;
mod window;
mod tray;
use window::SettingsWindow;
fn main() -> glib::ExitCode {
let app = adw::Application::builder()
.application_id("io.github.vietc.Settings")
.flags(gio::ApplicationFlags::FLAGS_NONE)
.build();
app.connect_activate(|app| {
let window = SettingsWindow::new(app);
gtk::prelude::GtkWindowExt::present(&window);
});
app.run()
fn exe_dir() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
.unwrap_or_else(|| PathBuf::from("/usr/bin"))
}
fn find_sibling_binary(name: &str) -> String {
let sibling = exe_dir().join(name);
if sibling.exists() {
return sibling.to_string_lossy().into_owned();
}
name.to_string()
}
fn is_daemon_running() -> bool {
std::process::Command::new("pgrep")
.arg("-x")
.arg("vietc")
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn needs_root() -> bool {
let cfg = config::Config::load();
cfg.grab
}
/// Show a password prompt using available desktop tools.
/// Returns the password, or empty string if cancelled.
fn prompt_password() -> String {
let title = "Viet+";
let msg = "Viet+ needs root privileges to capture keyboard input.\nPlease enter your password:";
// Try zenity (GNOME)
if let Ok(output) = std::process::Command::new("zenity")
.args(["--password", "--title", title, "--text", msg])
.stderr(std::process::Stdio::null())
.output()
{
if output.status.success() {
let pw = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !pw.is_empty() {
return pw;
}
}
}
// Try kdialog (KDE)
if let Ok(output) = std::process::Command::new("kdialog")
.args(["--password", msg])
.stderr(std::process::Stdio::null())
.output()
{
if output.status.success() {
let pw = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !pw.is_empty() {
return pw;
}
}
}
// Try ssh-askpass (X11 fallback)
if let Ok(output) = std::process::Command::new("ssh-askpass")
.arg(msg)
.stderr(std::process::Stdio::null())
.output()
{
if output.status.success() {
let pw = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !pw.is_empty() {
return pw;
}
}
}
// Last resort: terminal prompt
eprintln!("{}", msg);
if let Ok(child) = std::process::Command::new("sh")
.arg("-c")
.arg("read -s -p 'Password: ' pw && echo \"$pw\"")
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::piped())
.spawn()
{
if let Ok(output) = child.wait_with_output() {
if output.status.success() {
return String::from_utf8_lossy(&output.stdout).trim().to_string();
}
}
}
String::new()
}
fn start_daemon() {
let daemon_bin = find_sibling_binary("vietc");
if needs_root() && !is_daemon_running() {
// Mark that we've attempted first launch
let flag_path = config_path().join(".first-launch-done");
if !flag_path.exists() {
let password = prompt_password();
if password.is_empty() {
eprintln!("[vietc-tray] No password provided, starting daemon without root");
let _ = std::process::Command::new(&daemon_bin).spawn();
return;
}
// Start daemon with sudo
let mut child = match std::process::Command::new("sudo")
.args(["-S", &daemon_bin])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(c) => c,
Err(e) => {
eprintln!("[vietc-tray] Failed to start daemon with sudo: {}", e);
let _ = std::process::Command::new(&daemon_bin).spawn();
return;
}
};
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
let _ = stdin.write_all(format!("{}\n", password).as_bytes());
}
let _ = child.wait();
// Mark first launch as done
let _ = std::fs::write(&flag_path, "1");
return;
}
}
if !is_daemon_running() {
eprintln!("[vietc-tray] Starting daemon: {}", daemon_bin);
let _ = std::process::Command::new(&daemon_bin).spawn();
}
}
fn config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("vietc")
}
fn main() {
eprintln!("[vietc-tray] Starting");
// Start daemon (with password prompt if first launch)
start_daemon();
// Run the tray
tray::run();
}

View file

@ -1,124 +1,135 @@
use ksni::{Tray, MenuItem, menu::*};
mod config;
use config::Config;
use crate::config;
/// Get the directory where the current executable lives.
/// This handles AppImage, DEB installs, and dev builds correctly.
fn exe_dir() -> std::path::PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
.unwrap_or_else(|| std::path::PathBuf::from("/usr/bin"))
fn write_status(state: &str) {
if let Some(config_dir) = dirs::config_dir() {
let _ = std::fs::write(config_dir.join("vietc").join("status"), state);
}
}
/// Find a sibling binary (in the same directory as the current executable).
/// Also searches the workspace target directory for development.
/// Falls back to searching PATH if not found next to the executable.
fn find_sibling_binary(name: &str) -> String {
// 1. Same directory
let sibling = exe_dir().join(name);
if sibling.exists() {
return sibling.to_string_lossy().into_owned();
fn read_status() -> String {
let path = dirs::config_dir()
.map(|d| d.join("vietc").join("status"))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp/vietc-status"));
std::fs::read_to_string(&path)
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| {
let cfg = config::Config::load();
if cfg.start_enabled { "vn".into() } else { "en".into() }
})
}
// 2. Dev target/debug relative path (from ui/target/debug)
let dev_debug = exe_dir().join("..").join("..").join("..").join("target").join("debug").join(name);
if dev_debug.exists() {
return dev_debug.to_string_lossy().into_owned();
fn current_im() -> String {
config::Config::load().input_method
}
// 3. Dev target/release relative path (from ui/target/release)
let dev_release = exe_dir().join("..").join("..").join("..").join("target").join("release").join(name);
if dev_release.exists() {
return dev_release.to_string_lossy().into_owned();
fn ensure_icons() {
let Some(config_dir) = dirs::config_dir() else { return };
let icons_dir = config_dir.join("vietc").join("icons");
let _ = std::fs::create_dir_all(&icons_dir);
let vn_path = icons_dir.join("vietc-vn.svg");
let en_path = icons_dir.join("vietc-en.svg");
let _ = std::fs::write(&vn_path, r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect x="2" y="2" width="28" height="28" rx="6" fill="#e02424"/>
<text x="16" y="22" text-anchor="middle" fill="#ffffff" font-size="14" font-weight="900" font-family="system-ui, sans-serif">VN</text>
</svg>"##);
let _ = std::fs::write(&en_path, r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect x="2" y="2" width="28" height="28" rx="6" fill="#4b5563"/>
<text x="16" y="22" text-anchor="middle" fill="#ffffff" font-size="14" font-weight="900" font-family="system-ui, sans-serif">EN</text>
</svg>"##);
}
name.to_string()
struct VietTray {
mode: String,
im: String,
autostart: bool,
}
struct VietcTray {
active_mode: String,
autostart_enabled: bool,
}
impl Tray for VietcTray {
fn id(&self) -> String {
"io.github.vietc.Tray".into()
}
fn title(&self) -> String {
"Viet+".into()
}
impl Tray for VietTray {
fn id(&self) -> String { "io.github.vietc.Tray".into() }
fn title(&self) -> String { "Viet+".into() }
fn icon_name(&self) -> String {
if self.active_mode == "vn" {
"vietc-vn".into()
} else {
"vietc-en".into()
}
if self.mode == "vn" { "vietc-vn".into() } else { "vietc-en".into() }
}
fn icon_theme_path(&self) -> String {
if let Some(config_dir) = dirs::config_dir() {
config_dir.join("vietc").join("icons").to_string_lossy().into_owned()
} else {
"".into()
dirs::config_dir()
.map(|d| d.join("vietc").join("icons").to_string_lossy().into_owned())
.unwrap_or_default()
}
fn activate(&mut self, _x: i32, _y: i32) {
let next = if self.mode == "vn" { "en" } else { "vn" };
write_status(&next);
let mut cfg = config::Config::load();
cfg.start_enabled = next == "vn";
let _ = cfg.save();
self.mode = next.to_string();
}
fn menu(&self) -> Vec<MenuItem<Self>> {
let is_vn = self.active_mode == "vn";
let is_vn = self.mode == "vn";
let im_index = if self.im == "telex" { 0 } else { 1 };
vec![
CheckmarkItem {
label: "Vietnamese Mode".into(),
checked: is_vn,
activate: Box::new(|this: &mut VietcTray| {
let next_state = if this.active_mode == "vn" { "en" } else { "vn" };
if let Some(config_dir) = dirs::config_dir() {
let status_path = config_dir.join("vietc").join("status");
let _ = std::fs::write(&status_path, next_state);
}
// Also save start_enabled to config, so it persists across reboots
let mut config = Config::load();
config.start_enabled = next_state == "vn";
let _ = config.save();
activate: Box::new(|this: &mut VietTray| {
let next = if this.mode == "vn" { "en" } else { "vn" };
write_status(&next);
let mut cfg = config::Config::load();
cfg.start_enabled = next == "vn";
let _ = cfg.save();
this.mode = next.to_string();
}),
..Default::default()
}.into(),
MenuItem::Separator,
SubMenu {
label: "Input Method".into(),
submenu: vec![
RadioGroup {
selected: im_index,
select: Box::new(|this: &mut VietTray, idx: usize| {
let im = if idx == 0 { "telex" } else { "vni" };
let mut cfg = config::Config::load();
cfg.input_method = im.into();
let _ = cfg.save();
this.im = im.into();
}),
options: vec![
RadioItem { label: "Telex".into(), ..Default::default() },
RadioItem { label: "VNI".into(), ..Default::default() },
],
}.into(),
],
..Default::default()
}.into(),
MenuItem::Separator,
CheckmarkItem {
label: "Autostart on Boot".into(),
checked: self.autostart_enabled,
activate: Box::new(|this: &mut VietcTray| {
if this.autostart_enabled {
label: "Start with System".into(),
checked: self.autostart,
activate: Box::new(|this: &mut VietTray| {
if this.autostart {
config::uninstall_autostart();
} else {
config::install_autostart_force();
config::install_autostart();
}
}),
..Default::default()
}.into(),
MenuItem::Separator,
StandardItem {
label: "Settings...".into(),
activate: Box::new(|_| {
let settings_bin = find_sibling_binary("vietc-settings");
eprintln!("[vietc-tray] Launching settings: {}", settings_bin);
match std::process::Command::new(&settings_bin).spawn() {
Ok(_) => {},
Err(e) => eprintln!("[vietc-tray] Failed to launch settings: {}", e),
}
}),
..Default::default()
}.into(),
MenuItem::Separator,
StandardItem {
label: "Quit Viet+".into(),
label: "Quit".into(),
activate: Box::new(|_| {
let _ = std::process::Command::new("pkill")
.arg("-x")
.arg("vietc")
.status();
.arg("-x").arg("vietc").status();
std::process::exit(0);
}),
..Default::default()
@ -127,99 +138,33 @@ impl Tray for VietcTray {
}
}
fn is_daemon_running() -> bool {
std::process::Command::new("pgrep")
.arg("-x")
.arg("vietc")
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn run() {
ensure_icons();
fn ensure_icons_exist() {
if let Some(config_dir) = dirs::config_dir() {
let icons_dir = config_dir.join("vietc").join("icons");
let _ = std::fs::create_dir_all(&icons_dir);
let vn_path = icons_dir.join("vietc-vn.svg");
let en_path = icons_dir.join("vietc-en.svg");
let vn_svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect x="2" y="2" width="28" height="28" rx="6" fill="#e02424"/>
<text x="16" y="21" text-anchor="middle" fill="#ffffff" font-size="13" font-weight="900" font-family="system-ui, -apple-system, sans-serif" letter-spacing="0.5">VN</text>
</svg>"##;
let en_svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect x="2" y="2" width="28" height="28" rx="6" fill="#4b5563"/>
<text x="16" y="21" text-anchor="middle" fill="#ffffff" font-size="13" font-weight="900" font-family="system-ui, -apple-system, sans-serif" letter-spacing="0.5">EN</text>
</svg>"##;
let _ = std::fs::write(&vn_path, vn_svg);
let _ = std::fs::write(&en_path, en_svg);
let hicolor_apps_dir = icons_dir.join("hicolor").join("scalable").join("apps");
let _ = std::fs::create_dir_all(&hicolor_apps_dir);
let _ = std::fs::write(hicolor_apps_dir.join("vietc-vn.svg"), vn_svg);
let _ = std::fs::write(hicolor_apps_dir.join("vietc-en.svg"), en_svg);
}
}
fn main() {
eprintln!("[vietc-tray] Starting tray (exe dir: {:?})", exe_dir());
ensure_icons_exist();
if !is_daemon_running() {
let daemon_bin = find_sibling_binary("vietc");
eprintln!("[vietc-tray] Starting daemon: {}", daemon_bin);
match std::process::Command::new(&daemon_bin).spawn() {
Ok(child) => eprintln!("[vietc-tray] Daemon started (PID {})", child.id()),
Err(e) => eprintln!("[vietc-tray] Failed to start daemon: {}", e),
}
} else {
eprintln!("[vietc-tray] Daemon already running");
}
let tray = VietcTray {
active_mode: "en".into(),
autostart_enabled: config::is_autostart_installed(),
let tray = VietTray {
mode: read_status(),
im: current_im(),
autostart: config::is_autostart_installed(),
};
let service = ksni::TrayService::new(tray);
let handle = service.handle();
service.spawn();
let handle_clone = handle.clone();
// Poll for changes
std::thread::spawn(move || {
let status_path = dirs::config_dir()
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
.join("vietc")
.join("status");
loop {
let active_mode = if let Ok(content) = std::fs::read_to_string(&status_path) {
content.trim().to_string()
} else {
let config = Config::load();
if config.start_enabled { "vn".to_string() } else { "en".to_string() }
};
let autostart_enabled = config::is_autostart_installed();
let _ = handle_clone.update(move |t| {
t.active_mode = active_mode;
t.autostart_enabled = autostart_enabled;
std::thread::sleep(std::time::Duration::from_millis(500));
let mode = read_status();
let im = current_im();
let autostart = config::is_autostart_installed();
let _ = handle.update(move |t| {
t.mode = mode;
t.im = im;
t.autostart = autostart;
});
std::thread::sleep(std::time::Duration::from_millis(250));
}
});
if config::is_autostart_installed() {
config::install_autostart_force();
}
loop {
std::thread::park();
}
loop { std::thread::park(); }
}

View file

@ -1,710 +0,0 @@
use adw::prelude::*;
use adw::subclass::prelude::*;
use gtk::{gio, glib};
use crate::config::Config;
mod imp {
use super::*;
use std::cell::RefCell;
#[derive(Default)]
pub struct SettingsWindow {
pub dirty: RefCell<bool>,
}
#[glib::object_subclass]
impl ObjectSubclass for SettingsWindow {
const NAME: &'static str = "SettingsWindow";
type Type = super::SettingsWindow;
type ParentType = adw::ApplicationWindow;
}
impl ObjectImpl for SettingsWindow {}
impl WidgetImpl for SettingsWindow {}
impl WindowImpl for SettingsWindow {}
impl ApplicationWindowImpl for SettingsWindow {}
impl AdwApplicationWindowImpl for SettingsWindow {}
}
glib::wrapper! {
pub struct SettingsWindow(ObjectSubclass<imp::SettingsWindow>)
@extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget,
@implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable;
}
impl SettingsWindow {
pub fn new(app: &adw::Application) -> Self {
let win: Self = glib::Object::builder()
.property("application", app)
.property("default-width", 580)
.property("default-height", 500)
.property("title", "Viet+ Settings")
.build();
win.build_ui();
win
}
fn mark_dirty(&self) {
*self.imp().dirty.borrow_mut() = true;
}
fn build_ui(&self) {
let config = Config::load();
let trigger_keys = config.auto_restore.trigger_keys.clone();
// Toast overlay for notifications
let toast_overlay = adw::ToastOverlay::new();
// Main box
let main_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
// Header bar with view switcher
let header = adw::HeaderBar::new();
// View Stack
let stack = adw::ViewStack::builder()
.vexpand(true)
.build();
// View Switcher linked to stack
let switcher = adw::ViewSwitcher::builder()
.stack(&stack)
.build();
header.set_title_widget(Some(&switcher));
// Save button (suggested action)
let save_btn = gtk::Button::builder()
.label("Save")
.css_classes(["suggested-action"])
.tooltip_text("Save settings (Ctrl+S)")
.build();
header.pack_end(&save_btn);
// Keyboard shortcut for save
let controller = gtk::EventControllerKey::new();
let save_ref = save_btn.clone();
controller.connect_key_pressed(move |_, key, _, modifiers| {
if modifiers.contains(gtk::gdk::ModifierType::CONTROL_MASK)
&& key == gtk::gdk::Key::s
{
save_ref.emit_clicked();
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
});
self.add_controller(controller);
main_box.append(&header);
// ==================== Page 1: Typing ====================
let typing_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.margin_top(16)
.margin_bottom(16)
.margin_start(16)
.margin_end(16)
.build();
// ========== Input Method Section ==========
let method_group = adw::PreferencesGroup::builder()
.title("Input Method")
.description("Select your preferred Vietnamese typing method")
.build();
let method_row = adw::ComboRow::builder()
.title("Keyboard Layout")
.subtitle("Telex uses letters (aa=ă, ee=ê), VNI uses digits (a6=ă, e8=ê)")
.model(&gtk::StringList::new(&["Telex (Recommended)", "VNI"]))
.selected(if config.input_method == "vni" { 1 } else { 0 })
.build();
let toggle_row = adw::ComboRow::builder()
.title("Toggle Key")
.subtitle("Switch between Vietnamese and English input")
.model(&gtk::StringList::new(&[
"Ctrl + Space",
"Ctrl + Shift",
"Caps Lock",
]))
.selected(match config.toggle_key.as_str() {
"shift" => 1,
"capslock" => 2,
_ => 0,
})
.build();
method_group.add(&method_row);
method_group.add(&toggle_row);
typing_box.append(&method_group);
// ========== General Section ==========
let general_group = adw::PreferencesGroup::builder()
.title("General")
.build();
let start_enabled_row = adw::SwitchRow::builder()
.title("Start Enabled")
.subtitle("Enable Vietnamese input on startup")
.active(config.start_enabled)
.build();
let app_memory_row = adw::SwitchRow::builder()
.title("App Memory")
.subtitle("Remember per-app Vietnamese/English state")
.active(config.app_state.enabled)
.build();
let auto_restore_row = adw::SwitchRow::builder()
.title("Auto Restore English")
.subtitle("Automatically restore common English words")
.active(config.auto_restore.enabled)
.build();
let autostart_row = adw::SwitchRow::builder()
.title("Autostart on Boot")
.subtitle("Start Viet+ automatically when your system starts")
.active(crate::config::is_autostart_installed())
.build();
general_group.add(&start_enabled_row);
general_group.add(&app_memory_row);
general_group.add(&auto_restore_row);
general_group.add(&autostart_row);
typing_box.append(&general_group);
let typing_clamp = adw::Clamp::builder().maximum_size(540).tightening_threshold(400).build();
typing_clamp.set_child(Some(&typing_box));
let typing_scrolled = gtk::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&typing_clamp)
.build();
stack.add_titled(&typing_scrolled, Some("typing"), "Typing");
// ==================== Page 2: Apps ====================
let apps_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.margin_top(16)
.margin_bottom(16)
.margin_start(16)
.margin_end(16)
.build();
let apps_group = adw::PreferencesGroup::builder()
.title("Application Lists")
.description("Override input method for specific applications")
.build();
// English apps
let english_list = gtk::ListBox::builder()
.selection_mode(gtk::SelectionMode::None)
.css_classes(["boxed-list"])
.build();
for app in &config.app_state.english_apps {
english_list.append(&Self::make_app_row_static(app, &english_list));
}
let english_entry = gtk::SearchEntry::builder()
.placeholder_text("Add application name...")
.hexpand(true)
.build();
let english_add = gtk::Button::builder()
.icon_name("list-add-symbolic")
.css_classes(["flat", "accent"])
.tooltip_text("Add application")
.build();
let english_input = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.build();
english_input.append(&english_entry);
english_input.append(&english_add);
let english_header = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
let english_label = gtk::Label::builder()
.label("English Mode (Telex disabled)")
.halign(gtk::Align::Start)
.css_classes(["heading", "dim-label"])
.build();
english_header.append(&english_label);
english_header.append(&english_list);
english_header.append(&english_input);
let english_row = adw::ActionRow::builder()
.title("English Applications")
.activatable(false)
.build();
english_row.add_suffix(&english_header);
apps_group.add(&english_row);
// Vietnamese apps
let viet_list = gtk::ListBox::builder()
.selection_mode(gtk::SelectionMode::None)
.css_classes(["boxed-list"])
.build();
for app in &config.app_state.vietnamese_apps {
viet_list.append(&Self::make_app_row_static(app, &viet_list));
}
let viet_entry = gtk::SearchEntry::builder()
.placeholder_text("Add application name...")
.hexpand(true)
.build();
let viet_add = gtk::Button::builder()
.icon_name("list-add-symbolic")
.css_classes(["flat", "accent"])
.tooltip_text("Add application")
.build();
let viet_input = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.build();
viet_input.append(&viet_entry);
viet_input.append(&viet_add);
let viet_header = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
let viet_label = gtk::Label::builder()
.label("Vietnamese Mode (Telex enabled)")
.halign(gtk::Align::Start)
.css_classes(["heading", "dim-label"])
.build();
viet_header.append(&viet_label);
viet_header.append(&viet_list);
viet_header.append(&viet_input);
let viet_row = adw::ActionRow::builder()
.title("Vietnamese Applications")
.activatable(false)
.build();
viet_row.add_suffix(&viet_header);
apps_group.add(&viet_row);
apps_box.append(&apps_group);
let apps_clamp = adw::Clamp::builder().maximum_size(540).tightening_threshold(400).build();
apps_clamp.set_child(Some(&apps_box));
let apps_scrolled = gtk::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&apps_clamp)
.build();
stack.add_titled(&apps_scrolled, Some("apps"), "Apps");
// ==================== Page 3: Shortcuts ====================
let shortcuts_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.margin_top(16)
.margin_bottom(16)
.margin_start(16)
.margin_end(16)
.build();
// ========== Macros Section ==========
let macros_group = adw::PreferencesGroup::builder()
.title("Macros")
.description("Type shortcuts that expand to Vietnamese phrases")
.build();
let macros_list = gtk::ListBox::builder()
.selection_mode(gtk::SelectionMode::None)
.css_classes(["boxed-list"])
.build();
for (shortcut, expansion) in &config.macros {
macros_list.append(&Self::make_macro_row_static(shortcut, expansion, &macros_list));
}
let macro_shortcut = gtk::SearchEntry::builder()
.placeholder_text("ko")
.width_chars(8)
.build();
let macro_expansion = gtk::SearchEntry::builder()
.placeholder_text("không")
.hexpand(true)
.build();
let macro_add = gtk::Button::builder()
.icon_name("list-add-symbolic")
.css_classes(["flat", "accent"])
.tooltip_text("Add macro")
.build();
let macro_input = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.build();
macro_input.append(&macro_shortcut);
macro_input.append(&gtk::Label::builder().label("").css_classes(["dim-label"]).build());
macro_input.append(&macro_expansion);
macro_input.append(&macro_add);
macros_group.add(&macros_list);
macros_group.add(&macro_input);
shortcuts_box.append(&macros_group);
// ========== Reference Card ==========
let ref_group = adw::PreferencesGroup::builder()
.title("Quick Reference")
.build();
let ref_row = adw::ActionRow::builder()
.title("Common Shortcuts")
.subtitle("ko→không, dc→được, vs→với, lm→làm")
.activatable(false)
.build();
let ref_icon = gtk::Image::builder()
.icon_name("dialog-information-symbolic")
.tooltip_text("Type these shortcuts followed by space")
.build();
ref_row.add_suffix(&ref_icon);
ref_group.add(&ref_row);
shortcuts_box.append(&ref_group);
let shortcuts_clamp = adw::Clamp::builder().maximum_size(540).tightening_threshold(400).build();
shortcuts_clamp.set_child(Some(&shortcuts_box));
let shortcuts_scrolled = gtk::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&shortcuts_clamp)
.build();
stack.add_titled(&shortcuts_scrolled, Some("shortcuts"), "Shortcuts");
// ========== Status Bar ==========
let status_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(8)
.margin_top(8)
.build();
let status_icon = gtk::Image::builder()
.icon_name("emblem-ok-symbolic")
.build();
let status_label = gtk::Label::builder()
.label("Ready")
.hexpand(true)
.halign(gtk::Align::Start)
.css_classes(["dim-label"])
.build();
status_box.append(&status_icon);
status_box.append(&status_label);
main_box.append(&stack);
main_box.append(&status_box);
toast_overlay.set_child(Some(&main_box));
adw::prelude::AdwApplicationWindowExt::set_content(self, Some(&toast_overlay));
// ========== Callbacks ==========
// Mark dirty on any change
{
let win = self.clone();
method_row.connect_selected_notify(move |_| { win.mark_dirty(); });
}
{
let win = self.clone();
toggle_row.connect_selected_notify(move |_| { win.mark_dirty(); });
}
{
let win = self.clone();
start_enabled_row.connect_active_notify(move |_| { win.mark_dirty(); });
}
{
let win = self.clone();
app_memory_row.connect_active_notify(move |_| { win.mark_dirty(); });
}
{
let win = self.clone();
auto_restore_row.connect_active_notify(move |_| { win.mark_dirty(); });
}
{
let win = self.clone();
autostart_row.connect_active_notify(move |_| { win.mark_dirty(); });
}
// Add English app
self.setup_add_app(&english_entry, &english_add, &english_list, &status_label, &status_icon);
// Add Vietnamese app
self.setup_add_app(&viet_entry, &viet_add, &viet_list, &status_label, &status_icon);
// Add macro
self.setup_add_macro(&macro_shortcut, &macro_expansion, &macro_add, &macros_list, &status_label, &status_icon);
// Save button
{
let method_row = method_row.clone();
let toggle_row = toggle_row.clone();
let start_switch = start_enabled_row.clone();
let app_switch = app_memory_row.clone();
let auto_switch = auto_restore_row.clone();
let autostart_switch = autostart_row.clone();
let english = english_list.clone();
let viet = viet_list.clone();
let macros = macros_list.clone();
let status_label = status_label.clone();
let status_icon = status_icon.clone();
let toast_overlay = toast_overlay.clone();
let win = self.clone();
let trigger_keys = trigger_keys.clone();
save_btn.connect_clicked(move |_| {
let method = match method_row.selected() {
1 => "vni",
_ => "telex",
};
let toggle = match toggle_row.selected() {
1 => "shift",
2 => "capslock",
_ => "space",
};
let english_apps = Self::collect_app_names(&english);
let vietnamese_apps = Self::collect_app_names(&viet);
let macro_map = Self::collect_macros(&macros);
let config = Config {
input_method: method.into(),
toggle_key: toggle.into(),
start_enabled: start_switch.is_active(),
auto_restore: crate::config::AutoRestoreConfig {
enabled: auto_switch.is_active(),
trigger_keys: trigger_keys.clone(),
},
app_state: crate::config::AppStateConfig {
enabled: app_switch.is_active(),
english_apps,
vietnamese_apps,
},
macros: macro_map,
};
// Save autostart state
if autostart_switch.is_active() {
crate::config::install_autostart_force();
} else {
crate::config::uninstall_autostart();
}
match config.save() {
Ok(()) => {
status_label.set_text(&format!("Saved to {}", Config::path().display()));
status_icon.set_icon_name(Some("emblem-ok-symbolic"));
status_label.remove_css_class("error");
status_label.add_css_class("dim-label");
*win.imp().dirty.borrow_mut() = false;
let toast = adw::Toast::new("Settings saved");
toast.set_timeout(2);
toast_overlay.add_toast(toast);
}
Err(e) => {
status_label.set_text(&format!("Error: {}", e));
status_icon.set_icon_name(Some("dialog-error-symbolic"));
status_label.remove_css_class("dim-label");
status_label.add_css_class("error");
let toast = adw::Toast::new(&format!("Save failed: {}", e));
toast.set_timeout(3);
toast_overlay.add_toast(toast);
}
}
});
}
}
fn setup_add_app(
&self,
entry: &gtk::SearchEntry,
add_btn: &gtk::Button,
list: &gtk::ListBox,
status_label: &gtk::Label,
status_icon: &gtk::Image,
) {
let add_fn = {
let list = list.clone();
let entry = entry.clone();
let status_label = status_label.clone();
let status_icon = status_icon.clone();
let win = self.clone();
move || {
let text = entry.text().to_string();
if !text.is_empty() {
let row = Self::make_app_row_static(&text, &list);
list.append(&row);
entry.set_text("");
status_label.set_text("Unsaved changes");
status_icon.set_icon_name(Some("dialog-information-symbolic"));
win.mark_dirty();
}
}
};
let add_fn2 = add_fn.clone();
add_btn.connect_clicked(move |_| add_fn2());
let add_fn3 = add_fn.clone();
entry.connect_activate(move |_| add_fn3());
}
fn setup_add_macro(
&self,
shortcut: &gtk::SearchEntry,
expansion: &gtk::SearchEntry,
add_btn: &gtk::Button,
list: &gtk::ListBox,
status_label: &gtk::Label,
status_icon: &gtk::Image,
) {
let add_fn = {
let list = list.clone();
let shortcut = shortcut.clone();
let expansion = expansion.clone();
let status_label = status_label.clone();
let status_icon = status_icon.clone();
let win = self.clone();
move || {
let s = shortcut.text().to_string();
let e = expansion.text().to_string();
if !s.is_empty() && !e.is_empty() {
let row = Self::make_macro_row_static(&s, &e, &list);
list.append(&row);
shortcut.set_text("");
expansion.set_text("");
status_label.set_text("Unsaved changes");
status_icon.set_icon_name(Some("dialog-information-symbolic"));
win.mark_dirty();
}
}
};
let add_fn2 = add_fn.clone();
add_btn.connect_clicked(move |_| add_fn2());
let add_fn3 = add_fn.clone();
expansion.connect_activate(move |_| add_fn3());
}
fn make_app_row_static(app: &str, list: &gtk::ListBox) -> adw::ActionRow {
let row = adw::ActionRow::builder()
.title(app)
.activatable(false)
.build();
let remove_btn = gtk::Button::builder()
.icon_name("user-trash-symbolic")
.css_classes(["flat", "destructive-action"])
.tooltip_text("Remove")
.build();
let list_ref = list.clone();
let app_name = app.to_string();
remove_btn.connect_clicked(move |_| {
let mut i = 0;
while let Some(child) = list_ref.row_at_index(i) {
if let Some(row) = child.downcast_ref::<adw::ActionRow>() {
if row.title() == app_name {
list_ref.remove(&child);
return;
}
}
i += 1;
}
});
row.add_suffix(&remove_btn);
row
}
fn make_macro_row_static(shortcut: &str, expansion: &str, list: &gtk::ListBox) -> adw::ActionRow {
let row = adw::ActionRow::builder()
.title(shortcut)
.subtitle(expansion)
.activatable(false)
.build();
let arrow = gtk::Label::builder()
.label("")
.css_classes(["dim-label"])
.build();
row.add_prefix(&arrow);
let remove_btn = gtk::Button::builder()
.icon_name("user-trash-symbolic")
.css_classes(["flat", "destructive-action"])
.tooltip_text("Remove")
.build();
let list_ref = list.clone();
let shortcut_name = shortcut.to_string();
remove_btn.connect_clicked(move |_| {
let mut i = 0;
while let Some(child) = list_ref.row_at_index(i) {
if let Some(row) = child.downcast_ref::<adw::ActionRow>() {
if row.title() == shortcut_name {
list_ref.remove(&child);
return;
}
}
i += 1;
}
});
row.add_suffix(&remove_btn);
row
}
fn collect_app_names(list: &gtk::ListBox) -> Vec<String> {
let mut names = Vec::new();
let mut i = 0;
while let Some(child) = list.row_at_index(i) {
if let Some(row) = child.downcast_ref::<adw::ActionRow>() {
names.push(row.title().to_string());
}
i += 1;
}
names
}
fn collect_macros(list: &gtk::ListBox) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
let mut i = 0;
while let Some(child) = list.row_at_index(i) {
if let Some(row) = child.downcast_ref::<adw::ActionRow>() {
let shortcut = row.title().to_string();
let expansion = row.subtitle().unwrap_or_default().to_string();
if !shortcut.is_empty() {
map.insert(shortcut, expansion);
}
}
i += 1;
}
map
}
}