Optimize typing performance and preserve casing on replaced syllables

This commit is contained in:
openhands 2026-06-25 19:56:43 +07:00
parent da97e945eb
commit 38f3bca022
27 changed files with 10771 additions and 965 deletions

View file

@ -1,4 +1,4 @@
.PHONY: build build-x11 build-wayland build-all build-ui test test-cli run run-x11 run-wayland clean install install-x11 install-wayland install-ui install-config appimage fmt lint tree .PHONY: build build-x11 build-wayland build-all build-ui test test-cli run run-x11 run-wayland clean install install-x11 install-wayland install-ui install-config appimage deb fmt lint tree
# Build core crates # Build core crates
build: build:
@ -86,6 +86,11 @@ appimage:
VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \ VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \
bash packaging/appimage/build-appimage.sh "$$VERSION" bash packaging/appimage/build-appimage.sh "$$VERSION"
# Build Debian package
deb:
VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \
bash packaging/build-deb.sh "$$VERSION"
# Clean build artifacts # Clean build artifacts
clean: clean:
cargo clean cargo clean

View file

@ -72,12 +72,18 @@ fn main() {
} }
output.push_str(insert); output.push_str(insert);
} }
EngineEvent::UndoTones { backspaces, restored } => { EngineEvent::UndoTones {
backspaces,
restored,
} => {
for _ in 0..*backspaces { for _ in 0..*backspaces {
output.push('\x08'); output.push('\x08');
} }
output.push_str(restored); output.push_str(restored);
} }
EngineEvent::Paste(text) => {
output.push_str(text);
}
} }
} }
} }

View file

@ -63,7 +63,12 @@ fn get_proc_window_class() -> Option<String> {
// Read /proc/active-windows if available (some compositors expose this) // Read /proc/active-windows if available (some compositors expose this)
let content = fs::read_to_string("/proc/active-windows").ok()?; let content = fs::read_to_string("/proc/active-windows").ok()?;
// Format: pid window_class window_title // Format: pid window_class window_title
content.lines().next()?.split_whitespace().nth(1).map(|s| s.to_lowercase()) content
.lines()
.next()?
.split_whitespace()
.nth(1)
.map(|s| s.to_lowercase())
} }
/// Manages per-app IME state /// Manages per-app IME state
@ -76,6 +81,8 @@ pub struct AppStateManager {
english_apps: Vec<String>, english_apps: Vec<String>,
/// Default Vietnamese apps from config /// Default Vietnamese apps from config
vietnamese_apps: Vec<String>, vietnamese_apps: Vec<String>,
/// Bypass apps from config
bypass_apps: Vec<String>,
/// Global enabled state /// Global enabled state
global_enabled: bool, global_enabled: bool,
} }
@ -84,6 +91,7 @@ impl AppStateManager {
pub fn new( pub fn new(
english_apps: Vec<String>, english_apps: Vec<String>,
vietnamese_apps: Vec<String>, vietnamese_apps: Vec<String>,
bypass_apps: Vec<String>,
global_enabled: bool, global_enabled: bool,
) -> Self { ) -> Self {
Self { Self {
@ -91,6 +99,7 @@ impl AppStateManager {
overrides: HashMap::new(), overrides: HashMap::new(),
english_apps: english_apps.iter().map(|s| s.to_lowercase()).collect(), english_apps: english_apps.iter().map(|s| s.to_lowercase()).collect(),
vietnamese_apps: vietnamese_apps.iter().map(|s| s.to_lowercase()).collect(), vietnamese_apps: vietnamese_apps.iter().map(|s| s.to_lowercase()).collect(),
bypass_apps: bypass_apps.iter().map(|s| s.to_lowercase()).collect(),
global_enabled, global_enabled,
} }
} }
@ -162,14 +171,32 @@ impl AppStateManager {
} }
/// Update app lists from reloaded config /// Update app lists from reloaded config
pub fn update_lists(&mut self, english_apps: Vec<String>, vietnamese_apps: Vec<String>) { pub fn update_lists(
&mut self,
english_apps: Vec<String>,
vietnamese_apps: Vec<String>,
bypass_apps: Vec<String>,
) -> &Self {
self.english_apps = english_apps.iter().map(|s| s.to_lowercase()).collect(); self.english_apps = english_apps.iter().map(|s| s.to_lowercase()).collect();
self.vietnamese_apps = vietnamese_apps.iter().map(|s| s.to_lowercase()).collect(); self.vietnamese_apps = vietnamese_apps.iter().map(|s| s.to_lowercase()).collect();
self.bypass_apps = bypass_apps.iter().map(|s| s.to_lowercase()).collect();
eprintln!( eprintln!(
"[vietc] App lists updated: {} English, {} Vietnamese", "[vietc] App lists updated: {} English, {} Vietnamese, {} Bypass",
self.english_apps.len(), self.english_apps.len(),
self.vietnamese_apps.len() self.vietnamese_apps.len(),
self.bypass_apps.len()
); );
self
}
/// Check if the currently active application should bypass the IME completely
pub fn is_current_app_bypassed(&self) -> bool {
for pattern in &self.bypass_apps {
if self.current_app.contains(pattern.as_str()) {
return true;
}
}
false
} }
/// Save overrides to config file /// Save overrides to config file

View file

@ -53,6 +53,9 @@ pub struct AppStateConfig {
#[serde(default)] #[serde(default)]
pub vietnamese_apps: Vec<String>, pub vietnamese_apps: Vec<String>,
#[serde(default = "default_bypass_apps")]
pub bypass_apps: Vec<String>,
} }
impl Default for AutoRestoreConfig { impl Default for AutoRestoreConfig {
@ -70,16 +73,29 @@ impl Default for AppStateConfig {
enabled: true, enabled: true,
english_apps: default_english_apps(), english_apps: default_english_apps(),
vietnamese_apps: default_vietnamese_apps(), vietnamese_apps: default_vietnamese_apps(),
bypass_apps: default_bypass_apps(),
} }
} }
} }
fn default_input_method() -> String { "telex".into() } fn default_input_method() -> String {
fn default_toggle_key() -> String { "space".into() } "telex".into()
fn default_start_enabled() -> bool { true } }
fn default_true() -> bool { true } fn default_toggle_key() -> String {
fn default_false() -> bool { false } "space".into()
fn default_restore_keys() -> Vec<String> { vec!["space".into(), "escape".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> { fn default_english_apps() -> Vec<String> {
vec![ vec![
@ -90,10 +106,26 @@ fn default_english_apps() -> Vec<String> {
"webstorm".into(), "webstorm".into(),
"vim".into(), "vim".into(),
"nvim".into(), "nvim".into(),
]
}
fn default_bypass_apps() -> Vec<String> {
vec![
"terminal".into(), "terminal".into(),
"kitty".into(), "kitty".into(),
"alacritty".into(), "alacritty".into(),
"foot".into(), "foot".into(),
"wezterm".into(),
"konsole".into(),
"gnome-terminal".into(),
"st".into(),
"urxvt".into(),
"xterm".into(),
"steam".into(),
"dota".into(),
"csgo".into(),
"minecraft".into(),
"factorio".into(),
] ]
} }
@ -233,7 +265,10 @@ vs = "với"
assert!(!config.auto_restore.enabled); assert!(!config.auto_restore.enabled);
assert!(config.app_state.enabled); assert!(config.app_state.enabled);
assert_eq!(config.app_state.english_apps, vec!["code", "vim"]); assert_eq!(config.app_state.english_apps, vec!["code", "vim"]);
assert_eq!(config.app_state.vietnamese_apps, vec!["telegram", "discord"]); assert_eq!(
config.app_state.vietnamese_apps,
vec!["telegram", "discord"]
);
assert_eq!(config.macros.get("ko").unwrap(), "không"); assert_eq!(config.macros.get("ko").unwrap(), "không");
assert_eq!(config.macros.get("dc").unwrap(), "được"); assert_eq!(config.macros.get("dc").unwrap(), "được");
assert_eq!(config.macros.get("vs").unwrap(), "với"); assert_eq!(config.macros.get("vs").unwrap(), "với");
@ -289,12 +324,14 @@ foo = "bar"
fn parse_app_lists() { fn parse_app_lists() {
let toml = r#" let toml = r#"
[app_state] [app_state]
english_apps = ["vim", "neovim", "kitty"] english_apps = ["vim", "neovim"]
vietnamese_apps = ["zalo", "messenger"] vietnamese_apps = ["zalo", "messenger"]
bypass_apps = ["kitty"]
"#; "#;
let config: Config = toml::from_str(toml).unwrap(); let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.app_state.english_apps, vec!["vim", "neovim", "kitty"]); assert_eq!(config.app_state.english_apps, vec!["vim", "neovim"]);
assert_eq!(config.app_state.vietnamese_apps, vec!["zalo", "messenger"]); assert_eq!(config.app_state.vietnamese_apps, vec!["zalo", "messenger"]);
assert_eq!(config.app_state.bypass_apps, vec!["kitty"]);
} }
#[test] #[test]
@ -311,14 +348,29 @@ vietnamese_apps = ["zalo", "messenger"]
let config = Config::default(); let config = Config::default();
assert!(config.app_state.english_apps.contains(&"code".to_string())); assert!(config.app_state.english_apps.contains(&"code".to_string()));
assert!(config.app_state.english_apps.contains(&"vim".to_string())); assert!(config.app_state.english_apps.contains(&"vim".to_string()));
assert!(config.app_state.english_apps.contains(&"kitty".to_string())); }
#[test]
fn default_config_bypass_apps() {
let config = Config::default();
assert!(config.app_state.bypass_apps.contains(&"kitty".to_string()));
assert!(config
.app_state
.bypass_apps
.contains(&"alacritty".to_string()));
} }
#[test] #[test]
fn default_config_vietnamese_apps() { fn default_config_vietnamese_apps() {
let config = Config::default(); let config = Config::default();
assert!(config.app_state.vietnamese_apps.contains(&"telegram".to_string())); assert!(config
assert!(config.app_state.vietnamese_apps.contains(&"firefox".to_string())); .app_state
.vietnamese_apps
.contains(&"telegram".to_string()));
assert!(config
.app_state
.vietnamese_apps
.contains(&"firefox".to_string()));
} }
#[test] #[test]

View file

@ -1,19 +1,19 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
use vietc_engine::{Engine, EngineEvent, InputMethod}; use vietc_engine::{Engine, EngineEvent, InputMethod};
mod config;
mod app_state; mod app_state;
mod config;
mod display; mod display;
use config::Config;
use app_state::AppStateManager; use app_state::AppStateManager;
use config::Config;
fn get_log_path() -> Option<PathBuf> { fn get_log_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("vietc").join("vietc.log")) dirs::config_dir().map(|p| p.join("vietc").join("vietc.log"))
@ -98,6 +98,7 @@ impl Daemon {
let mut app_state = AppStateManager::new( let mut app_state = AppStateManager::new(
config.app_state.english_apps.clone(), config.app_state.english_apps.clone(),
config.app_state.vietnamese_apps.clone(), config.app_state.vietnamese_apps.clone(),
config.app_state.bypass_apps.clone(),
config.start_enabled, config.start_enabled,
); );
app_state.load_overrides(); app_state.load_overrides();
@ -133,7 +134,10 @@ impl Daemon {
if let Ok(content) = fs::read_to_string(&status_path) { if let Ok(content) = fs::read_to_string(&status_path) {
let expect_enabled = content.trim() == "vn"; let expect_enabled = content.trim() == "vn";
if self.engine.is_enabled() != expect_enabled { if self.engine.is_enabled() != expect_enabled {
log_info(&format!("[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.set_enabled(expect_enabled);
self.engine_enabled.store(expect_enabled, Ordering::SeqCst); self.engine_enabled.store(expect_enabled, Ordering::SeqCst);
} }
@ -167,6 +171,7 @@ impl Daemon {
self.app_state.update_lists( self.app_state.update_lists(
new_config.app_state.english_apps.clone(), new_config.app_state.english_apps.clone(),
new_config.app_state.vietnamese_apps.clone(), new_config.app_state.vietnamese_apps.clone(),
new_config.app_state.bypass_apps.clone(),
); );
self.grab_enabled = new_config.grab; self.grab_enabled = new_config.grab;
@ -185,13 +190,38 @@ impl Daemon {
fn process_key(&mut self, ch: char) -> Vec<OutputCommand> { fn process_key(&mut self, ch: char) -> Vec<OutputCommand> {
let mut commands = Vec::new(); let mut commands = Vec::new();
// Log each keystroke with character info
log_info(&format!(
"[vietc] process_key: U+{:04X} '{}' raw_buffer='{}' enabled={}",
ch as u32,
ch,
self.engine.buffer(),
self.engine.is_enabled()
));
if let Some(event) = self.engine.process_key(ch) { if let Some(event) = self.engine.process_key(ch) {
log_info(&format!("[vietc] key='{}' buf='{}' -> {:?}", ch, self.engine.buffer(), event)); log_info(&format!(
"[vietc] key='{}' buf='{}' -> {:?}",
ch,
self.engine.buffer(),
event
));
match event { match event {
EngineEvent::Flush(text) => { EngineEvent::Flush(text) => {
log_info(&format!(
"[vietc] Flush text len={}, bytes={} text={}",
text.len(),
text.len() * 3,
text.escape_default()
));
commands.push(OutputCommand::Type(text)); commands.push(OutputCommand::Type(text));
} }
EngineEvent::Insert(text) => { EngineEvent::Insert(text) => {
log_info(&format!(
"[vietc] Insert text len={}, text={}",
text.len(),
text
));
commands.push(OutputCommand::Type(text)); commands.push(OutputCommand::Type(text));
} }
EngineEvent::AutoRestore(word) => { EngineEvent::AutoRestore(word) => {
@ -200,16 +230,42 @@ impl Daemon {
commands.push(OutputCommand::Type(word)); commands.push(OutputCommand::Type(word));
} }
EngineEvent::Replace { backspaces, insert } => { EngineEvent::Replace { backspaces, insert } => {
log_info(&format!(
"[vietc] Replace BS={} text=\"{}\"",
backspaces, insert
));
commands.push(OutputCommand::Backspace(backspaces)); commands.push(OutputCommand::Backspace(backspaces));
commands.push(OutputCommand::Type(insert)); commands.push(OutputCommand::Type(insert));
} }
EngineEvent::UndoTones { backspaces, restored } => { EngineEvent::UndoTones {
backspaces,
restored,
} => {
log_info(&format!(
"[vietc] UndoTones BS={} restored=\"{}\"",
backspaces, restored
));
commands.push(OutputCommand::Backspace(backspaces)); commands.push(OutputCommand::Backspace(backspaces));
commands.push(OutputCommand::Type(restored)); commands.push(OutputCommand::Type(restored));
} }
EngineEvent::Paste(text) => {
log_info(&format!(
"[vietc] Paste raw text len={}, bytes={} text={}",
text.len(),
text.len() * 3,
text.escape_default()
));
// Exit paste mode after pasting
self.engine.exit_paste_mode();
commands.push(OutputCommand::Type(text));
}
} }
} else { } else {
log_info(&format!("[vietc] key='{}' -> (no event, buf='{}')", ch, self.engine.buffer())); log_info(&format!(
"[vietc] key='{}' -> (no event, buf='{}')",
ch,
self.engine.buffer()
));
} }
commands commands
@ -217,8 +273,33 @@ impl Daemon {
fn toggle(&mut self) { fn toggle(&mut self) {
let new_state = self.app_state.toggle_current_app(); let new_state = self.app_state.toggle_current_app();
log_info(&format!(
"[vietc] toggle: engine.enabled={}",
self.engine.is_enabled()
));
self.engine.set_enabled(new_state); self.engine.set_enabled(new_state);
self.write_status(); self.write_status();
// Reset engine buffer when enabling Vietnamese mode to clear stale state
if new_state {
log_info(&format!(
"[vietc] reset() called - raw_buffer='{}' before reset",
self.engine.buffer()
));
self.engine.reset();
log_info(&format!(
"[vietc] after reset() - raw_buffer='{}'",
self.engine.buffer()
));
}
}
fn is_current_app_bypassed(&self) -> bool {
if !self.config.app_state.enabled {
return false;
}
self.app_state.is_current_app_bypassed()
} }
fn check_app_change_with(&mut self, new_class: String) { fn check_app_change_with(&mut self, new_class: String) {
@ -248,10 +329,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let compositor = display::detect_compositor(); let compositor = display::detect_compositor();
log_info(&format!("Viet+ Daemon v{}", env!("CARGO_PKG_VERSION"))); log_info(&format!("Viet+ Daemon v{}", env!("CARGO_PKG_VERSION")));
log_info(&format!("Display: {:?} ({})", display, compositor.unwrap_or_else(|| "unknown".into()))); log_info(&format!(
"Display: {:?} ({})",
display,
compositor.unwrap_or_else(|| "unknown".into())
));
log_info(&format!("Input method: {:?}", daemon.config.input_method)); log_info(&format!("Input method: {:?}", daemon.config.input_method));
log_info(&format!("Toggle key: Ctrl+{}", daemon.config.toggle_key.to_uppercase())); log_info(&format!(
log_info(&format!("App memory: {}", if daemon.config.app_state.enabled { "ON" } else { "OFF" })); "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 // Spawn background monitor for active window, config changes, and status changes
let shared_active_window = Arc::new(Mutex::new(String::new())); let shared_active_window = Arc::new(Mutex::new(String::new()));
@ -361,9 +456,10 @@ fn open_keyboard_device() -> Result<(evdev::Device, String), Box<dyn std::error:
if dev_name.eq_ignore_ascii_case("vietc") { if dev_name.eq_ignore_ascii_case("vietc") {
continue; continue;
} }
if device.supported_keys().is_some_and(|k| { if device
k.contains(evdev::Key::KEY_A) .supported_keys()
}) { .is_some_and(|k| k.contains(evdev::Key::KEY_A))
{
return Ok((device, format!("{} ({})", entry.path().display(), dev_name))); return Ok((device, format!("{} ({})", entry.path().display(), dev_name)));
} }
} }
@ -390,14 +486,16 @@ fn open_keyboard_device() -> Result<(evdev::Device, String), Box<dyn std::error:
but your current session hasn't picked it up yet. \ but your current session hasn't picked it up yet. \
Please LOG OUT and LOG BACK IN to activate group permissions.", Please LOG OUT and LOG BACK IN to activate group permissions.",
permission_denied_count, total_event_count permission_denied_count, total_event_count
).into()) )
.into())
} else { } else {
Err(format!( Err(format!(
"Permission denied on {}/{} devices. Add your user to the 'input' group: \ "Permission denied on {}/{} devices. Add your user to the 'input' group: \
sudo usermod -aG input $USER && sudo usermod -aG vinput $USER, \ sudo usermod -aG input $USER && sudo usermod -aG vinput $USER, \
then log out and log back in.", then log out and log back in.",
permission_denied_count, total_event_count permission_denied_count, total_event_count
).into()) )
.into())
} }
} else { } else {
Err("No keyboard device found".into()) Err("No keyboard device found".into())
@ -422,7 +520,10 @@ fn run_with_evdev(
true true
} }
Err(e) => { Err(e) => {
log_info(&format!("[vietc] Could not grab keyboard: {} (run as root for grab)", e)); 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)"); log_info("[vietc] Falling back to non-grabbing mode (may have race)");
false false
} }
@ -443,13 +544,18 @@ fn run_with_evdev(
loop { loop {
// Check for event timeout (grab safety) // Check for event timeout (grab safety)
if grabbed && last_event_time.elapsed() > std::time::Duration::from_secs(30) { if grabbed && last_event_time.elapsed() > std::time::Duration::from_secs(30) {
log_info("[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(); let _ = device.ungrab();
return Ok(()); return Ok(());
} }
let caps = is_caps_lock_on(&device); let caps = is_caps_lock_on(&device);
let key_state = device.get_key_state().ok(); let mut key_state = device
.get_key_state()
.ok()
.unwrap_or_else(evdev::AttributeSet::new);
let events = device.fetch_events()?; let events = device.fetch_events()?;
last_event_time = std::time::Instant::now(); last_event_time = std::time::Instant::now();
@ -463,7 +569,10 @@ fn run_with_evdev(
{ {
let active_window = shared_active_window.lock().unwrap().clone(); let active_window = shared_active_window.lock().unwrap().clone();
if active_window != last_active_window { if active_window != last_active_window {
log_info(&format!("[vietc] Window changed: '{}' -> '{}'", last_active_window, active_window)); log_info(&format!(
"[vietc] Window changed: '{}' -> '{}'",
last_active_window, active_window
));
last_active_window = active_window.clone(); last_active_window = active_window.clone();
daemon.engine.reset(); daemon.engine.reset();
log_info("[vietc] Reset engine buffer due to window change"); log_info("[vietc] Reset engine buffer due to window change");
@ -487,8 +596,22 @@ fn run_with_evdev(
let value = event.value(); let value = event.value();
let keycode = key.0; let keycode = key.0;
if value == 1 // Update key state dynamically
&& is_toggle_combination_state(&key_state, &daemon.config.toggle_key) if value == 1 {
key_state.insert(key);
} else if value == 0 {
key_state.remove(key);
}
// Completely bypass all IME processing/interception for terminal emulators, IDE terminals, and games
if daemon.is_current_app_bypassed() {
if grabbed {
injector.send_key_event(keycode, value);
}
continue;
}
if value == 1 && is_toggle_combination_state(&key_state, &daemon.config.toggle_key)
{ {
daemon.toggle(); daemon.toggle();
continue; continue;
@ -533,10 +656,8 @@ fn run_with_evdev(
} }
if let Some(mut ch) = key_to_char(key) { if let Some(mut ch) = key_to_char(key) {
let shift = is_modifier_held_shift(&key_state); let shift = is_modifier_held_shift(&key_state);
if ch.is_ascii_alphabetic() { if ch.is_ascii_alphabetic() && (shift ^ caps) {
if shift ^ caps { ch = ch.to_ascii_uppercase();
ch = ch.to_ascii_uppercase();
}
} }
let commands = daemon.process_key(ch); let commands = daemon.process_key(ch);
if !commands.is_empty() { if !commands.is_empty() {
@ -576,8 +697,7 @@ fn run_stdin_mode(
_engine_enabled: Arc<AtomicBool>, _engine_enabled: Arc<AtomicBool>,
display: display::DisplayServer, display: display::DisplayServer,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
use std::io::{self, Read, IsTerminal}; use std::io::{self, IsTerminal, Read};
if !io::stdin().is_terminal() { if !io::stdin().is_terminal() {
log_info("[vietc] Warning: No keyboard device and no terminal."); log_info("[vietc] Warning: No keyboard device and no terminal.");
@ -603,7 +723,8 @@ fn run_stdin_mode(
if let Ok((device, path)) = open_keyboard_device() { if let Ok((device, path)) = open_keyboard_device() {
log_info(&format!("[vietc] Keyboard device found: {}", path)); log_info(&format!("[vietc] Keyboard device found: {}", path));
return run_with_evdev( return run_with_evdev(
device, daemon, device,
daemon,
shared_active_window, shared_active_window,
config_changed, config_changed,
status_changed, status_changed,
@ -633,7 +754,10 @@ fn run_stdin_mode(
{ {
let active_window = shared_active_window.lock().unwrap().clone(); let active_window = shared_active_window.lock().unwrap().clone();
if active_window != last_active_window { if active_window != last_active_window {
log_info(&format!("[vietc] Window changed: '{}' -> '{}'", last_active_window, active_window)); log_info(&format!(
"[vietc] Window changed: '{}' -> '{}'",
last_active_window, active_window
));
last_active_window = active_window.clone(); last_active_window = active_window.clone();
daemon.engine.reset(); daemon.engine.reset();
log_info("[vietc] Reset engine buffer due to window change"); log_info("[vietc] Reset engine buffer due to window change");
@ -672,15 +796,26 @@ fn run_stdin_mode(
/// Execute commands — accumulate backspaces and text, then inject through /// Execute commands — accumulate backspaces and text, then inject through
/// a single channel (ydotool or wtype) to avoid reordering between backspaces /// a single channel (ydotool or wtype) to avoid reordering between backspaces
/// (uinput) and text (ydotool). /// (uinput) and text (ydotool).
fn execute_commands(injector: &dyn vietc_protocol::KeyInjector, commands: &[OutputCommand], grabbed: bool) { fn execute_commands(
injector: &dyn vietc_protocol::KeyInjector,
commands: &[OutputCommand],
grabbed: bool,
) {
let mut pending_backspaces: usize = 0; let mut pending_backspaces: usize = 0;
let mut pending_text = String::new(); let mut pending_text = String::new();
for cmd in commands { for cmd in commands {
match cmd { match cmd {
OutputCommand::Backspace(count) => { OutputCommand::Backspace(count) => {
let adjusted = if grabbed { count.saturating_sub(1) } else { *count }; let adjusted = if grabbed {
log_info(&format!("[vietc] cmd: Backspace({}) -> adjusted={}", count, adjusted)); count.saturating_sub(1)
} else {
*count
};
log_info(&format!(
"[vietc] cmd: Backspace({}) -> adjusted={}",
count, adjusted
));
pending_backspaces += adjusted; pending_backspaces += adjusted;
} }
OutputCommand::Type(text) => { OutputCommand::Type(text) => {
@ -691,13 +826,32 @@ fn execute_commands(injector: &dyn vietc_protocol::KeyInjector, commands: &[Outp
} }
if pending_backspaces > 0 || !pending_text.is_empty() { if pending_backspaces > 0 || !pending_text.is_empty() {
log_info(&format!("[vietc] inject: BS={} text=\"{}\"", pending_backspaces, pending_text)); log_info(&format!(
injector.inject_replacement(pending_backspaces, &pending_text); "[vietc] inject: BS={} text=\"{}\"",
pending_backspaces, pending_text
));
// Use injector for text (ydotool/xdotool/wtype)
let _ = injector.inject_replacement(pending_backspaces, &pending_text);
} else if !commands.is_empty() {
// Empty text but commands exist (e.g. Backspace only or Flush empty string)
log_info(&format!("[vietc] inject: BS={}", pending_backspaces));
let _ = injector.inject_replacement(pending_backspaces, &pending_text);
} }
injector.flush(); injector.flush();
// Sleep briefly to let the display server and target application process the
// injected key strokes and clear any modifier states before we handle subsequent physical keys.
if grabbed && !commands.is_empty() {
std::thread::sleep(std::time::Duration::from_millis(20));
}
} }
fn create_injector(display: display::DisplayServer) -> Result<Box<dyn vietc_protocol::KeyInjector>, Box<dyn std::error::Error>> { fn create_injector(
display: display::DisplayServer,
) -> Result<Box<dyn vietc_protocol::KeyInjector>, Box<dyn std::error::Error>> {
// Try Wayland input method first (if compiled with wayland feature) // Try Wayland input method first (if compiled with wayland feature)
#[cfg(feature = "wayland")] #[cfg(feature = "wayland")]
{ {
@ -738,12 +892,7 @@ fn create_injector(display: display::DisplayServer) -> Result<Box<dyn vietc_prot
Err("No injection backend available".into()) Err("No injection backend available".into())
} }
fn is_modifier_pressed(key_state: &Option<evdev::AttributeSet<evdev::Key>>) -> bool { fn is_modifier_pressed(key_state: &evdev::AttributeSet<evdev::Key>) -> bool {
let key_state = match key_state {
Some(ks) => ks,
None => return false,
};
key_state.contains(evdev::Key::KEY_LEFTCTRL) key_state.contains(evdev::Key::KEY_LEFTCTRL)
|| key_state.contains(evdev::Key::KEY_RIGHTCTRL) || key_state.contains(evdev::Key::KEY_RIGHTCTRL)
|| key_state.contains(evdev::Key::KEY_LEFTALT) || key_state.contains(evdev::Key::KEY_LEFTALT)
@ -752,12 +901,8 @@ fn is_modifier_pressed(key_state: &Option<evdev::AttributeSet<evdev::Key>>) -> b
|| key_state.contains(evdev::Key::KEY_RIGHTMETA) || key_state.contains(evdev::Key::KEY_RIGHTMETA)
} }
fn is_modifier_held_shift(key_state: &Option<evdev::AttributeSet<evdev::Key>>) -> bool { fn is_modifier_held_shift(key_state: &evdev::AttributeSet<evdev::Key>) -> bool {
let ks = match key_state { key_state.contains(evdev::Key::KEY_LEFTSHIFT) || key_state.contains(evdev::Key::KEY_RIGHTSHIFT)
Some(ks) => ks,
None => return false,
};
ks.contains(evdev::Key::KEY_LEFTSHIFT) || ks.contains(evdev::Key::KEY_RIGHTSHIFT)
} }
fn is_caps_lock_on(device: &evdev::Device) -> bool { fn is_caps_lock_on(device: &evdev::Device) -> bool {
@ -768,12 +913,7 @@ fn is_caps_lock_on(device: &evdev::Device) -> bool {
} }
} }
fn is_toggle_combination_state(key_state: &Option<evdev::AttributeSet<evdev::Key>>, key: &str) -> bool { fn is_toggle_combination_state(key_state: &evdev::AttributeSet<evdev::Key>, key: &str) -> bool {
let key_state = match key_state {
Some(ks) => ks,
None => return false,
};
let ctrl_pressed = key_state.contains(evdev::Key::KEY_LEFTCTRL) let ctrl_pressed = key_state.contains(evdev::Key::KEY_LEFTCTRL)
|| key_state.contains(evdev::Key::KEY_RIGHTCTRL); || key_state.contains(evdev::Key::KEY_RIGHTCTRL);

View file

@ -7,3 +7,6 @@ description = "Viet+ Vietnamese IME Core Engine"
[dependencies] [dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
[dev-dependencies]
insta = { version = "1.34", features = ["yaml"] }

View file

@ -1,89 +1,114 @@
use std::io::{self, Write}; use std::fs::File;
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] = &[ const INITIALS: &[&str] = &[
"", "b", "c", "ch", "d", "g", "gh", "h", "k", "kh", "l", "m", "n", "", "b", "c", "ch", "d", "g", "gh", "h", "k", "kh", "l", "m", "n", "ng", "ngh", "nh", "p",
"ng", "ngh", "nh", "p", "ph", "q", "r", "s", "t", "th", "tr", "v", "x", "ph", "q", "r", "s", "t", "th", "tr", "v", "x",
]; ];
const FINALS: &[&str] = &["", "c", "ch", "m", "n", "ng", "nh", "p", "t"]; const FINALS: &[&str] = &["", "c", "ch", "m", "n", "ng", "nh", "p", "t"];
fn is_valid(init: &str, fin: &str) -> bool { fn is_valid(init: &str, fin: &str) -> bool {
if init == "ngh" && !fin.is_empty() && fin != "n" && fin != "ng" && fin != "nh" { return false; } if init == "ngh" && !fin.is_empty() && fin != "n" && fin != "ng" && fin != "nh" {
if init == "gh" && !fin.is_empty() { return false; } return false;
if init == "q" { return false; } }
if init == "g" && !fin.is_empty() && fin != "n" && fin != "ng" { return false; } if init == "gh" && !fin.is_empty() {
if fin == "ch" && init == "" { return false; } return false;
if fin == "nh" && init == "" { return false; } }
if init == "q" {
return false;
}
if init == "g" && !fin.is_empty() && fin != "n" && fin != "ng" {
return false;
}
if fin == "ch" && init.is_empty() {
return false;
}
if fin == "nh" && init.is_empty() {
return false;
}
true true
} }
fn main() { fn main() {
// Telex base vowels (as typed, before mod) // Telex
let telex_vowels: Vec<(&str, &str)> = vec![ let telex_vowels: Vec<(&str, &str)> = vec![
("a", "af"), ("a", "as"), ("a", "aj"), ("a", "ar"), ("a", "ax"), ("a", "af"),
("a", "aw"), ("a", "aa"), ("a", "as"),
("a", "aj"),
("a", "ar"),
("a", "ax"),
("a", "aw"),
("a", "aa"),
("e", "ee"), ("e", "ee"),
("o", "oo"), ("o", "ow"), ("o", "oo"),
("o", "ow"),
("u", "uw"), ("u", "uw"),
]; ];
let mut count = 0; let mut telex_inputs = Vec::new();
let stdout = io::stdout();
let mut handle = stdout.lock();
for &init in INITIALS { for &init in INITIALS {
for &fin in FINALS { for &fin in FINALS {
if !is_valid(init, fin) { continue; } if !is_valid(init, fin) {
continue;
}
for &(base, mod_str) in &telex_vowels { for &(base, mod_str) in &telex_vowels {
let plain = format!("{}{}{}", init, base, fin); let plain = format!("{}{}{}", init, base, fin);
let full = format!("{}{}", plain, mod_str); let full = format!("{}{}", plain, mod_str);
if plain.len() > 10 { continue; } 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; } telex_inputs.push(full);
} }
if count >= 1000 { break; }
} }
if count >= 1000 { break; }
} }
// Limit to 500 cases to keep snapshot size reasonable but comprehensive
telex_inputs.truncate(500);
eprintln!("Generated {count} test cases"); // VNI
let vni_vowels: Vec<(&str, &str)> = vec![
("a", "1"),
("a", "2"),
("a", "3"),
("a", "4"),
("a", "5"),
("a", "6"),
("a", "8"),
("e", "6"),
("o", "6"),
("o", "7"),
("u", "7"),
];
let mut vni_inputs = Vec::new();
for &init in INITIALS {
for &fin in FINALS {
if !is_valid(init, fin) {
continue;
}
for &(base, mod_str) in &vni_vowels {
let plain = format!("{}{}{}", init, base, fin);
let full = format!("{}{}", plain, mod_str);
if plain.len() > 10 {
continue;
}
vni_inputs.push(full);
}
}
}
vni_inputs.truncate(500);
// Ensure output directory exists
std::fs::create_dir_all("tests/testdata").unwrap();
let mut f_telex = File::create("tests/testdata/telex_inputs.json").unwrap();
serde_json::to_writer_pretty(&mut f_telex, &telex_inputs).unwrap();
let mut f_vni = File::create("tests/testdata/vni_inputs.json").unwrap();
serde_json::to_writer_pretty(&mut f_vni, &vni_inputs).unwrap();
println!(
"Generated {} Telex and {} VNI test inputs under tests/testdata/",
telex_inputs.len(),
vni_inputs.len()
);
} }

View file

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

View file

@ -1,21 +1,29 @@
use crate::english::EnglishDict;
use crate::telex::TelexEngine; use crate::telex::TelexEngine;
use crate::vni::VniEngine; use crate::vni::VniEngine;
use crate::english::EnglishDict;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum InputMethod { pub enum InputMethod {
Telex, Telex,
Vni, Vni,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum EngineEvent { pub enum EngineEvent {
Replace { backspaces: usize, insert: String }, Replace {
backspaces: usize,
insert: String,
},
Insert(String), Insert(String),
Flush(String), Flush(String),
AutoRestore(String), AutoRestore(String),
/// ESC undo: strip all tone marks from current word /// ESC undo: strip all tone marks from current word
UndoTones { backspaces: usize, restored: String }, UndoTones {
backspaces: usize,
restored: String,
},
/// Text was pasted via clipboard - update buffer directly without telex parsing
Paste(String),
} }
pub struct Engine { pub struct Engine {
@ -26,6 +34,8 @@ pub struct Engine {
enabled: bool, enabled: bool,
macros: std::collections::HashMap<String, String>, macros: std::collections::HashMap<String, String>,
raw_buffer: String, raw_buffer: String,
/// Flag to bypass telex/vni parsing when Unicode text has been pasted via clipboard
paste_mode: bool,
} }
impl Engine { impl Engine {
@ -38,6 +48,7 @@ impl Engine {
enabled: true, enabled: true,
macros: std::collections::HashMap::new(), macros: std::collections::HashMap::new(),
raw_buffer: String::new(), raw_buffer: String::new(),
paste_mode: false,
} }
} }
@ -57,6 +68,36 @@ impl Engine {
self.reset(); self.reset();
} }
/// Enter "paste mode" - bypass telex/vni parsing for Unicode pasted text
pub fn enter_paste_mode(&mut self) {
self.paste_mode = true;
}
/// Exit paste mode (for Paste event handling)
pub fn exit_paste_mode(&mut self) {
self.paste_mode = false;
}
/// Paste raw text into buffer without telex/vni processing
pub fn paste(&mut self, text: &str) -> EngineEvent {
// Clear buffer if entering paste mode and exit paste mode after
if self.paste_mode {
self.raw_buffer.clear();
} else {
self.enter_paste_mode();
}
let event = EngineEvent::Paste(text.to_string());
self.raw_buffer.push_str(text);
event
}
/// Update buffer with pasted text for subsequent edit operations (delete/backspace)
pub fn update_with_pasted_text(&mut self, text: &str) {
self.raw_buffer.clear();
self.raw_buffer.push_str(text);
}
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.telex.reset(); self.telex.reset();
self.vni.reset(); self.vni.reset();
@ -64,6 +105,18 @@ impl Engine {
} }
pub fn flush(&mut self) -> Option<EngineEvent> { pub fn flush(&mut self) -> Option<EngineEvent> {
// If in paste mode, bypass telex/vni parsing and return raw text as-is
if self.paste_mode && !self.raw_buffer.is_empty() {
// Only set paste_mode if buffer contains non-ASCII Unicode chars (pasted content)
let has_unicode = self.raw_buffer.chars().any(|c| !c.is_ascii());
if has_unicode {
let word = self.raw_buffer.clone();
self.raw_buffer.clear();
self.paste_mode = false; // Exit paste mode after flush
return Some(EngineEvent::Flush(word));
}
}
let event = match self.input_method { let event = match self.input_method {
InputMethod::Telex => self.telex.flush(), InputMethod::Telex => self.telex.flush(),
InputMethod::Vni => self.vni.flush(), InputMethod::Vni => self.vni.flush(),
@ -151,8 +204,15 @@ impl Engine {
ch.to_lowercase().next().unwrap_or(ch) ch.to_lowercase().next().unwrap_or(ch)
}; };
if lowercase_ch == ' ' || lowercase_ch == '\t' || lowercase_ch == '.' || lowercase_ch == ',' || lowercase_ch == '!' || lowercase_ch == '?' if lowercase_ch == ' '
|| lowercase_ch == ';' || lowercase_ch == ':' || lowercase_ch == '\n' || lowercase_ch == '\t'
|| lowercase_ch == '.'
|| lowercase_ch == ','
|| lowercase_ch == '!'
|| lowercase_ch == '?'
|| lowercase_ch == ';'
|| lowercase_ch == ':'
|| lowercase_ch == '\n'
{ {
if self.raw_buffer.is_empty() { if self.raw_buffer.is_empty() {
return None; return None;
@ -171,11 +231,14 @@ impl Engine {
// Try auto-restore before flushing // Try auto-restore before flushing
let clean_raw = self.raw_buffer.to_lowercase(); let clean_raw = self.raw_buffer.to_lowercase();
if self.english.should_restore(&clean_raw) { let inner_buf = self.buffer().to_string();
let inner_buf = self.buffer().to_string(); let clean_inner = strip_diacritics(&inner_buf).to_lowercase();
let clean_inner = strip_diacritics(&inner_buf).to_lowercase(); let has_diacritics = clean_inner != inner_buf.to_lowercase();
let has_diacritics = clean_inner != inner_buf.to_lowercase();
let should_restore = self.english.should_restore(&clean_raw)
|| (has_diacritics && !crate::spelling::is_valid_vietnamese_syllable(&inner_buf));
if should_restore {
let original_raw = self.raw_buffer.clone(); let original_raw = self.raw_buffer.clone();
let inner_len = inner_buf.chars().count(); let inner_len = inner_buf.chars().count();
self.reset(); self.reset();
@ -214,26 +277,48 @@ impl Engine {
return result; return result;
} }
// Regular character processing
let previous_inner = self.buffer().to_string(); let previous_inner = self.buffer().to_string();
self.raw_buffer.push(ch); self.raw_buffer.push(ch);
match self.input_method {
InputMethod::Telex => { self.telex.process_key(lowercase_ch); }
InputMethod::Vni => { self.vni.process_key(lowercase_ch); }
}
let new_inner = self.buffer().to_string();
let expected_screen = format!("{}{}", previous_inner, lowercase_ch); let expected_screen = format!("{}{}", previous_inner, lowercase_ch);
if new_inner != expected_screen { if self.paste_mode {
let cased_inner = match_casing(&self.raw_buffer, &new_inner); if ch.is_ascii() {
Some(EngineEvent::Replace { match self.input_method {
backspaces: previous_inner.chars().count() + 1, InputMethod::Telex => {
insert: cased_inner, self.telex.process_key(lowercase_ch);
}) }
InputMethod::Vni => {
self.vni.process_key(lowercase_ch);
}
}
None
} else {
Some(EngineEvent::Replace {
backspaces: previous_inner.chars().count() + 1,
insert: ch.to_string(),
})
}
} else { } else {
None match self.input_method {
InputMethod::Telex => {
self.telex.process_key(lowercase_ch);
}
InputMethod::Vni => {
self.vni.process_key(lowercase_ch);
}
}
let new_inner = self.buffer().to_string();
if new_inner != expected_screen {
let cased_inner = match_casing(&self.raw_buffer, &new_inner);
Some(EngineEvent::Replace {
backspaces: previous_inner.chars().count() + 1,
insert: cased_inner,
})
} else {
None
}
} }
} }
@ -250,25 +335,33 @@ fn strip_diacritics(s: &str) -> String {
s.chars() s.chars()
.map(|c| match c { .map(|c| match c {
// a variants // a variants
'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' 'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' | 'â' | 'ầ' | 'ấ'
| 'â' | 'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'a', | 'ẩ' | 'ẫ' | 'ậ' => 'a',
// A variants // A variants
'À' | 'Á' | 'Ả' | 'Ã' | 'Ạ' | 'Ă' | 'Ằ' | 'Ắ' | 'Ẳ' | 'Ẵ' | 'Ặ' 'À' | 'Á' | 'Ả' | 'Ã' | 'Ạ' | 'Ă' | 'Ằ' | 'Ắ' | 'Ẳ' | 'Ẵ' | 'Ặ' | 'Â' | 'Ầ' | 'Ấ'
| 'Â' | 'Ầ' | 'Ấ' | 'Ẩ' | 'Ẫ' | 'Ậ' => 'A', | 'Ẩ' | 'Ẫ' | 'Ậ' => 'A',
// e variants // e variants
'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' | 'ê' | 'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => 'e', 'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' | 'ê' | 'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => {
'È' | 'É' | 'Ẻ' | 'Ẽ' | 'Ẹ' | 'Ê' | 'Ề' | 'Ế' | 'Ể' | 'Ễ' | 'Ệ' => 'E', 'e'
}
'È' | 'É' | 'Ẻ' | 'Ẽ' | 'Ẹ' | 'Ê' | 'Ề' | 'Ế' | 'Ể' | 'Ễ' | 'Ệ' => {
'E'
}
// i variants // i variants
'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => 'i', 'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => 'i',
'Ì' | 'Í' | 'Ỉ' | 'Ĩ' | 'Ị' => 'I', 'Ì' | 'Í' | 'Ỉ' | 'Ĩ' | 'Ị' => 'I',
// o variants // o variants
'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' | 'ô' | 'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ' 'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' | 'ô' | 'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ' | 'ơ' | 'ờ' | 'ớ'
| 'ơ' | 'ờ' | 'ớ' | 'ở' | 'ỡ' | 'ợ' => 'o', | 'ở' | 'ỡ' | 'ợ' => 'o',
'Ò' | 'Ó' | 'Ỏ' | 'Õ' | 'Ọ' | 'Ô' | 'Ồ' | 'Ố' | 'Ổ' | 'Ỗ' | 'Ộ' 'Ò' | 'Ó' | 'Ỏ' | 'Õ' | 'Ọ' | 'Ô' | 'Ồ' | 'Ố' | 'Ổ' | 'Ỗ' | 'Ộ' | 'Ơ' | 'Ờ' | 'Ớ'
| 'Ơ' | 'Ờ' | 'Ớ' | 'Ở' | 'Ỡ' | 'Ợ' => 'O', | 'Ở' | 'Ỡ' | 'Ợ' => 'O',
// u variants // u variants
'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' | 'ư' | 'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => 'u', 'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' | 'ư' | 'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => {
'Ù' | 'Ú' | 'Ủ' | 'Ũ' | 'Ụ' | 'Ư' | 'Ừ' | 'Ứ' | 'Ử' | 'Ữ' | 'Ự' => 'U', 'u'
}
'Ù' | 'Ú' | 'Ủ' | 'Ũ' | 'Ụ' | 'Ư' | 'Ừ' | 'Ứ' | 'Ử' | 'Ữ' | 'Ự' => {
'U'
}
// y variants // y variants
'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => 'y', 'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => 'y',
'Ỳ' | 'Ý' | 'Ỷ' | 'Ỹ' | 'Ỵ' => 'Y', 'Ỳ' | 'Ý' | 'Ỷ' | 'Ỹ' | 'Ỵ' => 'Y',
@ -331,7 +424,10 @@ mod tests {
} }
let event = engine.process_escape(); let event = engine.process_escape();
match event { match event {
Some(EngineEvent::UndoTones { backspaces, restored }) => { Some(EngineEvent::UndoTones {
backspaces,
restored,
}) => {
assert_eq!(backspaces, 4); // "chào" is 4 chars assert_eq!(backspaces, 4); // "chào" is 4 chars
assert_eq!(restored, "chao"); assert_eq!(restored, "chao");
} }
@ -346,17 +442,21 @@ mod tests {
engine.add_macro("ok".into(), "được".into()); engine.add_macro("ok".into(), "được".into());
// Type "ko" + space // Type "ko" + space
let events: Vec<_> = "ko ".chars() let events: Vec<_> = "ko "
.chars()
.filter_map(|ch| engine.process_key(ch)) .filter_map(|ch| engine.process_key(ch))
.collect(); .collect();
// Should contain the macro expansion // Should contain the macro expansion
let output: String = events.iter().filter_map(|e| match e { let output: String = events
EngineEvent::Flush(s) => Some(s.as_str()), .iter()
EngineEvent::Insert(s) => Some(s.as_str()), .filter_map(|e| match e {
EngineEvent::Replace { insert, .. } => Some(insert.as_str()), EngineEvent::Flush(s) => Some(s.as_str()),
_ => None, EngineEvent::Insert(s) => Some(s.as_str()),
}).collect(); EngineEvent::Replace { insert, .. } => Some(insert.as_str()),
_ => None,
})
.collect();
assert!(output.contains("không")); assert!(output.contains("không"));
} }

View file

@ -15,46 +15,332 @@ impl EnglishDict {
// These would trigger false Vietnamese conversions // These would trigger false Vietnamese conversions
let common_words = [ let common_words = [
// Programming/tech // Programming/tech
"the", "and", "for", "are", "but", "not", "you", "all", "can", "had", "the",
"her", "was", "one", "our", "out", "day", "get", "has", "him", "his", "and",
"how", "its", "may", "new", "now", "old", "see", "way", "who", "did", "for",
"does", "each", "from", "have", "here", "just", "like", "long", "look", "are",
"made", "make", "many", "most", "over", "such", "take", "than", "them", "but",
"then", "that", "this", "time", "very", "when", "what", "will", "with", "not",
"also", "back", "been", "call", "came", "come", "could", "does", "done", "you",
"down", "each", "even", "find", "first", "from", "give", "goes", "going", "all",
"good", "great", "hand", "have", "head", "help", "high", "home", "hope", "can",
"into", "keep", "know", "last", "left", "life", "like", "line", "live", "had",
"look", "made", "make", "many", "mean", "more", "most", "much", "must", "her",
"name", "need", "next", "only", "open", "part", "place", "point", "right", "was",
"same", "said", "second", "should", "show", "small", "some", "something", "one",
"still", "such", "sure", "take", "tell", "than", "that", "them", "then", "our",
"there", "these", "they", "thing", "think", "this", "those", "time", "out",
"turn", "upon", "very", "want", "well", "went", "were", "what", "when", "day",
"where", "which", "while", "will", "with", "work", "would", "year", "your", "get",
"has",
"him",
"his",
"how",
"its",
"may",
"new",
"now",
"old",
"see",
"way",
"who",
"did",
"does",
"each",
"from",
"have",
"here",
"just",
"like",
"long",
"look",
"made",
"make",
"many",
"most",
"over",
"such",
"take",
"than",
"them",
"then",
"that",
"this",
"time",
"very",
"when",
"what",
"will",
"with",
"also",
"back",
"been",
"call",
"came",
"come",
"could",
"does",
"done",
"down",
"each",
"even",
"find",
"first",
"from",
"give",
"goes",
"going",
"good",
"great",
"hand",
"have",
"head",
"help",
"high",
"home",
"hope",
"into",
"keep",
"know",
"last",
"left",
"life",
"like",
"line",
"live",
"look",
"made",
"make",
"many",
"mean",
"more",
"most",
"much",
"must",
"name",
"need",
"next",
"only",
"open",
"part",
"place",
"point",
"right",
"same",
"said",
"second",
"should",
"show",
"small",
"some",
"something",
"still",
"such",
"sure",
"take",
"tell",
"than",
"that",
"them",
"then",
"there",
"these",
"they",
"thing",
"think",
"this",
"those",
"time",
"turn",
"upon",
"very",
"want",
"well",
"went",
"were",
"what",
"when",
"where",
"which",
"while",
"will",
"with",
"work",
"would",
"year",
"your",
// Common words that conflict with Vietnamese // Common words that conflict with Vietnamese
"ok", "no", "so", "do", "go", "to", "in", "on", "at", "by", "up", "ok",
"an", "as", "be", "he", "if", "is", "it", "me", "my", "of", "or", "no",
"am", "we", "us", "set", "run", "put", "get", "let", "say", "so",
"ask", "try", "use", "add", "end", "few", "far", "got", "big", "off", "do",
"old", "own", "red", "hot", "top", "far", "low", "six", "ten", "red", "go",
"to",
"in",
"on",
"at",
"by",
"up",
"an",
"as",
"be",
"he",
"if",
"is",
"it",
"me",
"my",
"of",
"or",
"am",
"we",
"us",
"set",
"run",
"put",
"get",
"let",
"say",
"ask",
"try",
"use",
"add",
"end",
"few",
"far",
"got",
"big",
"off",
"old",
"own",
"red",
"hot",
"top",
"far",
"low",
"six",
"ten",
"red",
// Greetings & common // Greetings & common
"hello", "hi", "hey", "bye", "thanks", "thank", "please", "sorry", "hello",
"yes", "yeah", "no", "ok", "okay", "sure", "well", "too", "also", "hi",
"hey",
"bye",
"thanks",
"thank",
"please",
"sorry",
"yes",
"yeah",
"no",
"ok",
"okay",
"sure",
"well",
"too",
"also",
// More common English // More common English
"about", "after", "again", "being", "below", "between", "both", "about",
"came", "come", "could", "does", "done", "down", "each", "even", "after",
"find", "first", "from", "give", "goes", "going", "good", "great", "again",
"hand", "have", "head", "help", "high", "home", "hope", "into", "being",
"keep", "kind", "know", "last", "left", "life", "like", "line", "below",
"live", "long", "look", "made", "make", "many", "mean", "more", "between",
"most", "much", "must", "name", "need", "next", "only", "open", "both",
"part", "place", "point", "right", "same", "said", "second", "came",
"should", "show", "small", "some", "something", "still", "sure", "come",
"take", "tell", "than", "that", "them", "then", "there", "these", "could",
"they", "thing", "think", "this", "those", "time", "turn", "upon", "does",
"very", "want", "well", "went", "were", "what", "when", "where", "done",
"which", "while", "will", "with", "work", "would", "year", "your", "down",
"each",
"even",
"find",
"first",
"from",
"give",
"goes",
"going",
"good",
"great",
"hand",
"have",
"head",
"help",
"high",
"home",
"hope",
"into",
"keep",
"kind",
"know",
"last",
"left",
"life",
"like",
"line",
"live",
"long",
"look",
"made",
"make",
"many",
"mean",
"more",
"most",
"much",
"must",
"name",
"need",
"next",
"only",
"open",
"part",
"place",
"point",
"right",
"same",
"said",
"second",
"should",
"show",
"small",
"some",
"something",
"still",
"sure",
"take",
"tell",
"than",
"that",
"them",
"then",
"there",
"these",
"they",
"thing",
"think",
"this",
"those",
"time",
"turn",
"upon",
"very",
"want",
"well",
"went",
"were",
"what",
"when",
"where",
"which",
"while",
"will",
"with",
"work",
"would",
"year",
"your",
]; ];
for word in common_words { for word in common_words {

View file

@ -1,7 +1,8 @@
mod engine; mod engine;
mod english;
mod spelling;
mod telex; mod telex;
mod vni; mod vni;
mod english;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

317
engine/src/spelling.rs Normal file
View file

@ -0,0 +1,317 @@
const FIRST_CONSONANT_SEQS: &[&str] = &[
"b d đ g gh m n nh p ph r s t tr v z",
"c h k kh qu th",
"ch gi l ng ngh x",
"đ l",
"h",
];
const VOWEL_SEQS: &[&str] = &[
"ê i ua uê uy y",
"a iê oa uyê yê",
"â ă e o oo ô ơ oe u ư uâ uô ươ",
"",
"",
"ai ao au âu ay ây eo êu ia iêu iu oai oao oay oeo oi ôi ơi ưa uây ui ưi uôi ươi ươu ưu uya uyu yêu",
"ă",
"i",
];
const LAST_CONSONANT_SEQS: &[&str] = &["ch nh", "c ng", "m n p t", "k", "c"];
const CV_MATRIX: &[&[usize]] = &[
&[0, 1, 2, 5],
&[0, 1, 2, 3, 4, 5],
&[0, 1, 2, 3, 5],
&[6],
&[7],
];
const VC_MATRIX: &[&[usize]] = &[&[0, 2], &[0, 1, 2], &[1, 2], &[1, 2], &[], &[], &[3], &[4]];
fn strip_tone(c: char) -> char {
match c {
'à' | 'á' | 'ả' | 'ã' | 'ạ' => 'a',
'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' => 'ă',
'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'â',
'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' => 'e',
'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => 'ê',
'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => 'i',
'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' => 'o',
'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ' => 'ô',
'ờ' | 'ớ' | 'ở' | 'ỡ' | 'ợ' => 'ơ',
'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' => 'u',
'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => 'ư',
'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => 'y',
_ => c,
}
}
fn is_vowel(c: char) -> bool {
matches!(
c,
'a' | 'à'
| 'á'
| 'ả'
| 'ã'
| 'ạ'
| 'ă'
| 'ằ'
| 'ắ'
| 'ẳ'
| 'ẵ'
| 'ặ'
| 'â'
| 'ầ'
| 'ấ'
| 'ẩ'
| 'ẫ'
| 'ậ'
| 'e'
| 'è'
| 'é'
| 'ẻ'
| 'ẽ'
| 'ẹ'
| 'ê'
| 'ề'
| 'ế'
| 'ể'
| 'ễ'
| 'ệ'
| 'i'
| 'ì'
| 'í'
| 'ỉ'
| 'ĩ'
| 'ị'
| 'o'
| 'ò'
| 'ó'
| 'ỏ'
| 'õ'
| 'ọ'
| 'ô'
| 'ồ'
| 'ố'
| 'ổ'
| 'ỗ'
| 'ộ'
| 'ơ'
| 'ờ'
| 'ớ'
| 'ở'
| 'ỡ'
| 'ợ'
| 'u'
| 'ù'
| 'ú'
| 'ủ'
| 'ũ'
| 'ụ'
| 'ư'
| 'ừ'
| 'ứ'
| 'ử'
| 'ữ'
| 'ự'
| 'y'
| 'ý'
| 'ỳ'
| 'ỷ'
| 'ỹ'
| 'ỵ'
)
}
/// Partition a word into (first_consonant, vowel_cluster, last_consonant)
pub fn partition(word: &str) -> (String, String, String) {
let chars: Vec<char> = word.chars().collect();
let n = chars.len();
if n == 0 {
return (String::new(), String::new(), String::new());
}
// 1. Find the first vowel index
let mut first_vowel_idx = None;
for i in 0..n {
if is_vowel(chars[i]) {
first_vowel_idx = Some(i);
break;
}
}
let first_vowel = match first_vowel_idx {
Some(idx) => idx,
None => {
return (word.to_string(), String::new(), String::new());
}
};
let mut fc_end = first_vowel;
// Adjust fc_end for "qu" or "gi" acting as onset
if first_vowel == 1 && chars[0] == 'q' && chars[1] == 'u' && n > 2 && is_vowel(chars[2]) {
fc_end = 2;
}
if first_vowel == 1 && chars[0] == 'g' && chars[1] == 'i' && n > 2 && is_vowel(chars[2]) {
fc_end = 2;
}
// 2. Find the end of the vowel cluster
let mut vo_end = fc_end;
while vo_end < n && is_vowel(chars[vo_end]) {
vo_end += 1;
}
let fc: String = chars[..fc_end].iter().collect();
let vo: String = chars[fc_end..vo_end].iter().collect();
let lc: String = chars[vo_end..].iter().collect();
(fc, vo, lc)
}
fn lookup(seqs: &[&str], input: &str) -> Vec<usize> {
let mut matching_indices = Vec::new();
if input.is_empty() {
return matching_indices;
}
for (index, row) in seqs.iter().enumerate() {
for word in row.split_whitespace() {
if word == input {
matching_indices.push(index);
break;
}
}
}
matching_indices
}
/// Check if a word is a valid Vietnamese syllable according to phonology rules
pub fn is_valid_vietnamese_syllable(word: &str) -> bool {
let lowercase_word = word.to_lowercase();
// Quick reject if it has foreign letters 'f', 'j', 'w', 'z'
if lowercase_word
.chars()
.any(|c| matches!(c, 'f' | 'j' | 'w' | 'z'))
{
return false;
}
// Clean tones from the word to validate spelling structure
let cleaned_word: String = lowercase_word.chars().map(strip_tone).collect();
let (fc, vo, lc) = partition(&cleaned_word);
// If there is no vowel, it must be a valid standalone consonant (like "d", "đ", etc.)
// but typically a full syllable must have a vowel. Let's allow empty vowel only if it's
// a valid first consonant of length 1 or 2 (e.g. for initials/abbreviations).
if vo.is_empty() {
return !fc.is_empty() && !lookup(FIRST_CONSONANT_SEQS, &fc).is_empty();
}
let fc_indices = if !fc.is_empty() {
let indices = lookup(FIRST_CONSONANT_SEQS, &fc);
if indices.is_empty() {
return false; // Invalid onset consonant
}
Some(indices)
} else {
None
};
let vo_indices = lookup(VOWEL_SEQS, &vo);
if vo_indices.is_empty() {
return false; // Invalid vowel cluster
}
let lc_indices = if !lc.is_empty() {
let indices = lookup(LAST_CONSONANT_SEQS, &lc);
if indices.is_empty() {
return false; // Invalid coda consonant
}
Some(indices)
} else {
None
};
// If we have an onset, check CV compatibility
if let Some(ref fcs) = fc_indices {
let mut cv_valid = false;
for &fc_idx in fcs {
if let Some(allowed_vos) = CV_MATRIX.get(fc_idx) {
for &allowed_vo in *allowed_vos {
if vo_indices.contains(&allowed_vo) {
cv_valid = true;
break;
}
}
}
if cv_valid {
break;
}
}
if !cv_valid {
return false;
}
}
// If we have a coda, check VC compatibility
if let Some(ref lcs) = lc_indices {
let mut vc_valid = false;
for &vo_idx in &vo_indices {
if let Some(allowed_lcs) = VC_MATRIX.get(vo_idx) {
for &allowed_lc in *allowed_lcs {
if lcs.contains(&allowed_lc) {
vc_valid = true;
break;
}
}
}
if vc_valid {
break;
}
}
if !vc_valid {
return false;
}
} else {
// If there's no coda, we must verify that the vowel allows having no coda
// (all vowel sequences allow no coda, except some specific ones in matrix, but let's see:
// vowel groups 4, 5 have no allowed last consonants in matrix, which is correct).
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_vietnamese_syllables() {
assert!(is_valid_vietnamese_syllable("chuyên"));
assert!(is_valid_vietnamese_syllable("tiếng"));
assert!(is_valid_vietnamese_syllable("việt"));
assert!(is_valid_vietnamese_syllable("quang"));
assert!(is_valid_vietnamese_syllable("giá"));
assert!(is_valid_vietnamese_syllable("oanh"));
assert!(is_valid_vietnamese_syllable("anh"));
assert!(is_valid_vietnamese_syllable("thuở"));
assert!(is_valid_vietnamese_syllable(""));
}
#[test]
fn test_invalid_vietnamese_syllables() {
assert!(!is_valid_vietnamese_syllable("fast"));
assert!(!is_valid_vietnamese_syllable("box"));
assert!(!is_valid_vietnamese_syllable("study"));
assert!(!is_valid_vietnamese_syllable("fát"));
assert!(!is_valid_vietnamese_syllable("făst"));
assert!(!is_valid_vietnamese_syllable("cargo"));
assert!(!is_valid_vietnamese_syllable("rust"));
assert!(!is_valid_vietnamese_syllable("status"));
}
}

View file

@ -1,23 +1,10 @@
use crate::engine::EngineEvent; use crate::engine::EngineEvent;
const VOWELS: &[char] = &[
'a', 'e', 'i', 'o', 'u', 'y',
'ă', 'â', 'ê', 'ô', 'ơ', 'ư',
];
const VOWEL_ACCENTED: &[char] = &[ const VOWEL_ACCENTED: &[char] = &[
'a', 'á', 'à', 'ả', 'ã', 'ạ', 'a', 'á', 'à', 'ả', 'ã', 'ạ', 'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ', 'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ', 'e',
'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ', 'é', 'è', 'ẻ', 'ẽ', 'ẹ', 'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ', 'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị', 'o', 'ó',
'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ', 'ò', 'ỏ', 'õ', 'ọ', 'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ', 'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ', 'u', 'ú', 'ù',
'e', 'é', 'è', 'ẻ', 'ẽ', 'ẹ', 'ủ', 'ũ', 'ụ', 'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự', 'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ',
'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ',
'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị',
'o', 'ó', 'ò', 'ỏ', 'õ', 'ọ',
'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ',
'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ',
'u', 'ú', 'ù', 'ủ', 'ũ', 'ụ',
'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự',
'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ',
]; ];
/// Maximum number of characters to scan backward during flexible placement. /// Maximum number of characters to scan backward during flexible placement.
@ -34,30 +21,78 @@ fn is_vowel(c: char) -> bool {
/// where base_modified_vowel still has its shape modifier (e.g., 'â', 'ă', 'ô', 'ơ'). /// where base_modified_vowel still has its shape modifier (e.g., 'â', 'ă', 'ô', 'ơ').
fn strip_tone(c: char) -> (char, Option<char>) { fn strip_tone(c: char) -> (char, Option<char>) {
match c { match c {
'a' => ('a', None), 'á' => ('a', Some('s')), 'à' => ('a', Some('f')), 'a' => ('a', None),
'ả' => ('a', Some('r')), 'ã' => ('a', Some('x')), 'ạ' => ('a', Some('j')), 'á' => ('a', Some('s')),
'ă' => ('ă', None), 'ắ' => ('ă', Some('s')), 'ằ' => ('ă', Some('f')), 'à' => ('a', Some('f')),
'ẳ' => ('ă', Some('r')), 'ẵ' => ('ă', Some('x')), 'ặ' => ('ă', Some('j')), 'ả' => ('a', Some('r')),
'â' => ('â', None), 'ấ' => ('â', Some('s')), 'ầ' => ('â', Some('f')), 'ã' => ('a', Some('x')),
'ẩ' => ('â', Some('r')), 'ẫ' => ('â', Some('x')), 'ậ' => ('â', Some('j')), 'ạ' => ('a', Some('j')),
'e' => ('e', None), 'é' => ('e', Some('s')), 'è' => ('e', Some('f')), 'ă' => ('ă', None),
'ẻ' => ('e', Some('r')), 'ẽ' => ('e', Some('x')), 'ẹ' => ('e', Some('j')), 'ắ' => ('ă', Some('s')),
'ê' => ('ê', None), 'ế' => ('ê', Some('s')), 'ề' => ('ê', Some('f')), 'ằ' => ('ă', Some('f')),
'ể' => ('ê', Some('r')), 'ễ' => ('ê', Some('x')), 'ệ' => ('ê', Some('j')), 'ẳ' => ('ă', Some('r')),
'i' => ('i', None), 'í' => ('i', Some('s')), 'ì' => ('i', Some('f')), 'ẵ' => ('ă', Some('x')),
'ỉ' => ('i', Some('r')), 'ĩ' => ('i', Some('x')), 'ị' => ('i', Some('j')), 'ặ' => ('ă', Some('j')),
'o' => ('o', None), 'ó' => ('o', Some('s')), 'ò' => ('o', Some('f')), 'â' => ('â', None),
'ỏ' => ('o', Some('r')), 'õ' => ('o', Some('x')), 'ọ' => ('o', Some('j')), 'ấ' => ('â', Some('s')),
'ô' => ('ô', None), 'ố' => ('ô', Some('s')), 'ồ' => ('ô', Some('f')), 'ầ' => ('â', Some('f')),
'ổ' => ('ô', Some('r')), 'ỗ' => ('ô', Some('x')), 'ộ' => ('ô', Some('j')), 'ẩ' => ('â', Some('r')),
'ơ' => ('ơ', None), 'ớ' => ('ơ', Some('s')), 'ờ' => ('ơ', Some('f')), 'ẫ' => ('â', Some('x')),
'ở' => ('ơ', Some('r')), 'ỡ' => ('ơ', Some('x')), 'ợ' => ('ơ', Some('j')), 'ậ' => ('â', Some('j')),
'u' => ('u', None), 'ú' => ('u', Some('s')), 'ù' => ('u', Some('f')), 'e' => ('e', None),
'ủ' => ('u', Some('r')), 'ũ' => ('u', Some('x')), 'ụ' => ('u', Some('j')), 'é' => ('e', Some('s')),
'ư' => ('ư', None), 'ứ' => ('ư', Some('s')), 'ừ' => ('ư', Some('f')), 'è' => ('e', Some('f')),
'ử' => ('ư', Some('r')), 'ữ' => ('ư', Some('x')), 'ự' => ('ư', Some('j')), 'ẻ' => ('e', Some('r')),
'y' => ('y', None), 'ý' => ('y', Some('s')), 'ỳ' => ('y', Some('f')), 'ẽ' => ('e', Some('x')),
'ỷ' => ('y', Some('r')), 'ỹ' => ('y', Some('x')), 'ỵ' => ('y', Some('j')), 'ẹ' => ('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), _ => (c, None),
} }
} }
@ -65,18 +100,66 @@ fn strip_tone(c: char) -> (char, Option<char>) {
fn apply_tone_to_vowel(vowel: char, tone: char) -> Option<char> { fn apply_tone_to_vowel(vowel: char, tone: char) -> Option<char> {
// Standard Telex: f=huyền, s=sắc, r=hỏi, x=ngã, j=nặng // Standard Telex: f=huyền, s=sắc, r=hỏi, x=ngã, j=nặng
let table: &[(char, char, char)] = &[ let table: &[(char, char, char)] = &[
('a', 'f', 'à'), ('a', 's', 'á'), ('a', 'r', 'ả'), ('a', 'x', 'ã'), ('a', 'j', 'ạ'), ('a', 'f', 'à'),
('ă', 'f', 'ằ'), ('ă', 's', 'ắ'), ('ă', 'r', 'ẳ'), ('ă', 'x', 'ẵ'), ('ă', 'j', 'ặ'), ('a', 's', 'á'),
('â', 'f', 'ầ'), ('â', 's', 'ấ'), ('â', 'r', 'ẩ'), ('â', 'x', 'ẫ'), ('â', 'j', 'ậ'), ('a', 'r', 'ả'),
('e', 'f', 'è'), ('e', 's', 'é'), ('e', 'r', 'ẻ'), ('e', 'x', 'ẽ'), ('e', 'j', 'ẹ'), ('a', 'x', 'ã'),
('ê', 'f', 'ề'), ('ê', 's', 'ế'), ('ê', 'r', 'ể'), ('ê', 'x', 'ễ'), ('ê', 'j', 'ệ'), ('a', 'j', 'ạ'),
('i', 'f', 'ì'), ('i', 's', 'í'), ('i', 'r', 'ỉ'), ('i', 'x', 'ĩ'), ('i', 'j', 'ị'), ('ă', 'f', 'ằ'),
('o', 'f', 'ò'), ('o', 's', 'ó'), ('o', 'r', 'ỏ'), ('o', 'x', 'õ'), ('o', 'j', 'ọ'), ('ă', 's', 'ắ'),
('ô', 'f', 'ồ'), ('ô', 's', 'ố'), ('ô', 'r', 'ổ'), ('ô', 'x', 'ỗ'), ('ô', 'j', 'ộ'), ('ă', 'r', 'ẳ'),
('ơ', 'f', 'ờ'), ('ơ', 's', 'ớ'), ('ơ', 'r', 'ở'), ('ơ', 'x', 'ỡ'), ('ơ', 'j', 'ợ'), ('ă', 'x', 'ẵ'),
('u', 'f', 'ù'), ('u', 's', 'ú'), ('u', 'r', 'ủ'), ('u', 'x', 'ũ'), ('u', 'j', 'ụ'), ('ă', 'j', 'ặ'),
('ư', 'f', 'ừ'), ('ư', 's', 'ứ'), ('ư', 'r', 'ử'), ('ư', 'x', 'ữ'), ('ư', 'j', 'ự'), ('â', 'f', 'ầ'),
('y', 'f', 'ỳ'), ('y', 's', 'ý'), ('y', 'r', 'ỷ'), ('y', 'x', 'ỹ'), ('y', 'j', 'ỵ'), ('â', 's', 'ấ'),
('â', 'r', 'ẩ'),
('â', 'x', 'ẫ'),
('â', 'j', 'ậ'),
('e', 'f', 'è'),
('e', 's', 'é'),
('e', 'r', 'ẻ'),
('e', 'x', 'ẽ'),
('e', 'j', 'ẹ'),
('ê', 'f', 'ề'),
('ê', 's', 'ế'),
('ê', 'r', 'ể'),
('ê', 'x', 'ễ'),
('ê', 'j', 'ệ'),
('i', 'f', 'ì'),
('i', 's', 'í'),
('i', 'r', 'ỉ'),
('i', 'x', 'ĩ'),
('i', 'j', 'ị'),
('o', 'f', 'ò'),
('o', 's', 'ó'),
('o', 'r', 'ỏ'),
('o', 'x', 'õ'),
('o', 'j', 'ọ'),
('ô', 'f', 'ồ'),
('ô', 's', 'ố'),
('ô', 'r', 'ổ'),
('ô', 'x', 'ỗ'),
('ô', 'j', 'ộ'),
('ơ', 'f', 'ờ'),
('ơ', 's', 'ớ'),
('ơ', 'r', 'ở'),
('ơ', 'x', 'ỡ'),
('ơ', 'j', 'ợ'),
('u', 'f', 'ù'),
('u', 's', 'ú'),
('u', 'r', 'ủ'),
('u', 'x', 'ũ'),
('u', 'j', 'ụ'),
('ư', 'f', 'ừ'),
('ư', 's', 'ứ'),
('ư', 'r', 'ử'),
('ư', 'x', 'ữ'),
('ư', 'j', 'ự'),
('y', 'f', 'ỳ'),
('y', 's', 'ý'),
('y', 'r', 'ỷ'),
('y', 'x', 'ỹ'),
('y', 'j', 'ỵ'),
]; ];
for &(v, t, result) in table { for &(v, t, result) in table {
@ -116,7 +199,6 @@ fn override_telex_modifier(vowel: char, key: char) -> Option<char> {
} }
} }
fn apply_w_to_vowel(vowel: char) -> Option<char> { fn apply_w_to_vowel(vowel: char) -> Option<char> {
// Telex: aw=ă, ow=ơ, ew=ê, uw=ư // Telex: aw=ă, ow=ơ, ew=ê, uw=ư
// (aa=â, ee=ê, oo=ô are handled by double-letter logic) // (aa=â, ee=ê, oo=ô are handled by double-letter logic)
@ -144,11 +226,21 @@ fn is_o_vowel(c: char) -> bool {
fn tone_of_vowel(c: char) -> Option<char> { fn tone_of_vowel(c: char) -> Option<char> {
match c { match c {
'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None, 'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None,
'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => Some('f'), 'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => {
'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => Some('s'), Some('f')
'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => Some('r'), }
'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => Some('x'), 'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => {
'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => Some('j'), Some('s')
}
'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => {
Some('r')
}
'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => {
Some('x')
}
'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => {
Some('j')
}
_ => None, _ => None,
} }
} }
@ -156,13 +248,13 @@ fn tone_of_vowel(c: char) -> Option<char> {
/// Apply a Telex tone to the vowel 'ơ', returning the toned variant. /// Apply a Telex tone to the vowel 'ơ', returning the toned variant.
fn apply_tone_to_ơ_char(tone: Option<char>) -> char { fn apply_tone_to_ơ_char(tone: Option<char>) -> char {
match tone { match tone {
None => 'ơ', None => 'ơ',
Some('f') => 'ờ', Some('f') => 'ờ',
Some('s') => 'ớ', Some('s') => 'ớ',
Some('r') => 'ở', Some('r') => 'ở',
Some('x') => 'ỡ', Some('x') => 'ỡ',
Some('j') => 'ợ', Some('j') => 'ợ',
_ => 'ơ', _ => 'ơ',
} }
} }
@ -181,7 +273,6 @@ fn is_q_before_u(chars: &[char], i: usize) -> bool {
i > 1 && chars[i - 2] == 'q' i > 1 && chars[i - 2] == 'q'
} }
pub struct TelexEngine { pub struct TelexEngine {
buffer: String, buffer: String,
pending_modifier: Option<char>, pending_modifier: Option<char>,
@ -292,10 +383,15 @@ impl TelexEngine {
// For oa, oe, uâ, uê, uơ, uy, iê, yê → tone on second vowel // For oa, oe, uâ, uê, uơ, uy, iê, yê → tone on second vowel
let tone_on_second = matches!( let tone_on_second = matches!(
(first, second), (first, second),
('o', 'a') | ('o', 'e') ('o', 'a')
| ('u', 'â') | ('u', 'ê') | ('u', 'ơ') | ('u', 'y') | ('o', 'e')
| ('ư', 'ơ') | ('u', 'â')
| ('i', 'ê') | ('y', 'ê') | ('u', 'ê')
| ('u', 'ơ')
| ('u', 'y')
| ('ư', 'ơ')
| ('i', 'ê')
| ('y', 'ê')
); );
if !tone_on_second { if !tone_on_second {
// Apply tone to first vowel // Apply tone to first vowel
@ -451,7 +547,10 @@ impl TelexEngine {
if is_o_vowel(last_ch) { if is_o_vowel(last_ch) {
// Smart cluster "uo" → "ươ" // Smart cluster "uo" → "ươ"
let mut chars: Vec<char> = self.buffer.chars().collect(); 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) { 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 o_char = chars.pop().unwrap();
let u_char = chars.pop().unwrap(); let u_char = chars.pop().unwrap();
let (new_first, new_second) = uo_to_uơ(u_char, o_char); let (new_first, new_second) = uo_to_uơ(u_char, o_char);
@ -471,7 +570,10 @@ impl TelexEngine {
let strip = strip_tone(last_ch); let strip = strip_tone(last_ch);
if strip.0 == 'ô' || strip.0 == 'ơ' { if strip.0 == 'ô' || strip.0 == 'ơ' {
let mut chars: Vec<char> = self.buffer.chars().collect(); 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) { 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 o_char = chars.pop().unwrap();
let u_char = chars.pop().unwrap(); let u_char = chars.pop().unwrap();
let (new_first, new_second) = uo_to_uơ(u_char, o_char); let (new_first, new_second) = uo_to_uơ(u_char, o_char);
@ -499,7 +601,11 @@ impl TelexEngine {
for i in (start..chars.len()).rev() { for i in (start..chars.len()).rev() {
if is_vowel(chars[i]) { if is_vowel(chars[i]) {
// Smart cluster "uo" → "ươ" (flexible) // Smart cluster "uo" → "ươ" (flexible)
if is_o_vowel(chars[i]) && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) { 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]); let (new_first, new_second) = uo_to_uơ(chars[i - 1], chars[i]);
self.buffer = chars[..i - 1].iter().collect::<String>(); self.buffer = chars[..i - 1].iter().collect::<String>();
self.buffer.push(new_first); self.buffer.push(new_first);
@ -580,4 +686,3 @@ impl TelexEngine {
None None
} }
} }

View file

@ -6,7 +6,10 @@ mod tests {
let mut events = Vec::new(); let mut events = Vec::new();
for ch in input.chars() { for ch in input.chars() {
if ch == '\x08' { if ch == '\x08' {
events.push(EngineEvent::Replace { backspaces: 1, insert: String::new() }); events.push(EngineEvent::Replace {
backspaces: 1,
insert: String::new(),
});
let _ = engine.process_key(ch); let _ = engine.process_key(ch);
continue; continue;
} }
@ -26,7 +29,7 @@ mod tests {
let mut output = String::new(); let mut output = String::new();
for ev in events { for ev in events {
match ev { match ev {
EngineEvent::Flush(text) | EngineEvent::Insert(text) => { EngineEvent::Flush(text) | EngineEvent::Insert(text) | EngineEvent::Paste(text) => {
output.push_str(text); output.push_str(text);
} }
EngineEvent::Replace { backspaces, insert } => { EngineEvent::Replace { backspaces, insert } => {
@ -41,7 +44,10 @@ mod tests {
} }
output.push_str(word); output.push_str(word);
} }
EngineEvent::UndoTones { backspaces, restored } => { EngineEvent::UndoTones {
backspaces,
restored,
} => {
for _ in 0..*backspaces { for _ in 0..*backspaces {
output.push('\x08'); output.push('\x08');
} }
@ -56,7 +62,7 @@ mod tests {
let mut display = String::new(); let mut display = String::new();
for ev in events { for ev in events {
match ev { match ev {
EngineEvent::Flush(text) => { EngineEvent::Flush(text) | EngineEvent::Paste(text) => {
if !display.ends_with(text) { if !display.ends_with(text) {
display.push_str(text); display.push_str(text);
} }
@ -76,7 +82,10 @@ mod tests {
} }
display.push_str(word); display.push_str(word);
} }
EngineEvent::UndoTones { backspaces, restored } => { EngineEvent::UndoTones {
backspaces,
restored,
} => {
for _ in 0..*backspaces { for _ in 0..*backspaces {
display.pop(); display.pop();
} }
@ -972,7 +981,10 @@ mod tests {
e.process_key('s'); e.process_key('s');
let event = e.process_escape(); let event = e.process_escape();
match event { match event {
Some(EngineEvent::UndoTones { backspaces, restored }) => { Some(EngineEvent::UndoTones {
backspaces,
restored,
}) => {
assert_eq!(backspaces, 1); assert_eq!(backspaces, 1);
assert_eq!(restored, "a"); assert_eq!(restored, "a");
} }
@ -988,7 +1000,10 @@ mod tests {
} }
let event = e.process_escape(); let event = e.process_escape();
match event { match event {
Some(EngineEvent::UndoTones { backspaces, restored }) => { Some(EngineEvent::UndoTones {
backspaces,
restored,
}) => {
assert_eq!(backspaces, 4); assert_eq!(backspaces, 4);
assert_eq!(restored, "chao"); assert_eq!(restored, "chao");
} }
@ -1113,7 +1128,10 @@ mod tests {
fn macro_long_expansion() { fn macro_long_expansion() {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
e.add_macro("bhg".into(), "bài họcгруппа".into()); e.add_macro("bhg".into(), "bài họcгруппа".into());
assert_eq!(get_display(&process_input(&mut e, "bhg ")), "bài họcгруппа "); assert_eq!(
get_display(&process_input(&mut e, "bhg ")),
"bài họcгруппа "
);
} }
#[test] #[test]
@ -1129,7 +1147,10 @@ mod tests {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
e.add_macro("vs".into(), "với".into()); e.add_macro("vs".into(), "với".into());
// "vs" expands, then "hello" is English // "vs" expands, then "hello" is English
assert_eq!(get_display(&process_input(&mut e, "vs hello ")), "với hello "); assert_eq!(
get_display(&process_input(&mut e, "vs hello ")),
"với hello "
);
} }
// ================================================================ // ================================================================
@ -1212,10 +1233,13 @@ mod tests {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "was "); let events = process_input(&mut e, "was ");
// Verify auto-restore produces correct backspace counts // Verify auto-restore produces correct backspace counts
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
})
.collect();
assert_eq!(replace_events.len(), 3); assert_eq!(replace_events.len(), 3);
// w-pending: backspace 1 (delete 'w' from screen) // w-pending: backspace 1 (delete 'w' from screen)
assert_eq!(replace_events[0], (1, "".to_string())); assert_eq!(replace_events[0], (1, "".to_string()));
@ -1415,10 +1439,13 @@ mod tests {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "as"); let events = process_input(&mut e, "as");
// Find the Replace event // Find the Replace event
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
})
.collect();
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event for 'as'"); assert_eq!(replace_events.len(), 1, "Expected 1 Replace event for 'as'");
assert_eq!(replace_events[0], (2, "á".to_string())); assert_eq!(replace_events[0], (2, "á".to_string()));
assert_eq!(get_display(&events), "á"); assert_eq!(get_display(&events), "á");
@ -1428,10 +1455,13 @@ mod tests {
fn backspace_count_double_letter() { fn backspace_count_double_letter() {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "aa"); let events = process_input(&mut e, "aa");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
})
.collect();
assert_eq!(replace_events.len(), 1); assert_eq!(replace_events.len(), 1);
assert_eq!(replace_events[0], (2, "â".to_string())); assert_eq!(replace_events[0], (2, "â".to_string()));
assert_eq!(get_display(&events), "â"); assert_eq!(get_display(&events), "â");
@ -1441,10 +1471,13 @@ mod tests {
fn backspace_count_w_modifier() { fn backspace_count_w_modifier() {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "aw"); let events = process_input(&mut e, "aw");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
})
.collect();
assert_eq!(replace_events.len(), 1); assert_eq!(replace_events.len(), 1);
assert_eq!(replace_events[0], (2, "ă".to_string())); assert_eq!(replace_events[0], (2, "ă".to_string()));
assert_eq!(get_display(&events), "ă"); assert_eq!(get_display(&events), "ă");
@ -1454,12 +1487,20 @@ mod tests {
fn backspace_count_w_modifier_then_tone() { fn backspace_count_w_modifier_then_tone() {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "aws"); let events = process_input(&mut e, "aws");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
})
.collect();
// "aw" → Replace {2, "ă"}, then "s" → Replace {2, "ắ"} // "aw" → Replace {2, "ă"}, then "s" → Replace {2, "ắ"}
assert_eq!(replace_events.len(), 2, "Expected 2 Replace events: {:?}", replace_events); assert_eq!(
replace_events.len(),
2,
"Expected 2 Replace events: {:?}",
replace_events
);
assert_eq!(replace_events[0], (2, "ă".to_string())); assert_eq!(replace_events[0], (2, "ă".to_string()));
assert_eq!(replace_events[1], (2, "".to_string())); assert_eq!(replace_events[1], (2, "".to_string()));
assert_eq!(get_display(&events), ""); assert_eq!(get_display(&events), "");
@ -1469,12 +1510,20 @@ mod tests {
fn backspace_count_compound_vowel_tone() { fn backspace_count_compound_vowel_tone() {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "oas"); let events = process_input(&mut e, "oas");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
})
.collect();
// "oas" → tone on second vowel: Replace {3, "oá"} // "oas" → tone on second vowel: Replace {3, "oá"}
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event: {:?}", replace_events); assert_eq!(
replace_events.len(),
1,
"Expected 1 Replace event: {:?}",
replace_events
);
assert_eq!(replace_events[0], (3, "".to_string())); assert_eq!(replace_events[0], (3, "".to_string()));
assert_eq!(get_display(&events), ""); assert_eq!(get_display(&events), "");
} }
@ -1483,12 +1532,20 @@ mod tests {
fn backspace_count_compound_vowel_uy_tone() { fn backspace_count_compound_vowel_uy_tone() {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "uys"); let events = process_input(&mut e, "uys");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
})
.collect();
// "uys" → tone on first vowel: Replace {3, "uý"} // "uys" → tone on first vowel: Replace {3, "uý"}
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event: {:?}", replace_events); assert_eq!(
replace_events.len(),
1,
"Expected 1 Replace event: {:?}",
replace_events
);
assert_eq!(replace_events[0], (3, "".to_string())); assert_eq!(replace_events[0], (3, "".to_string()));
assert_eq!(get_display(&events), ""); assert_eq!(get_display(&events), "");
} }
@ -1498,15 +1555,23 @@ mod tests {
// "bs" → no vowel, 's' is appended as text // "bs" → no vowel, 's' is appended as text
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "bs"); let events = process_input(&mut e, "bs");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, .. } => Some(backspaces), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, .. } => Some(backspaces),
_ => None,
})
.collect();
// 's' after consonant 'b': no vowel found, 's' appended to buffer // 's' after consonant 'b': no vowel found, 's' appended to buffer
// But s is a tone key, and process_tone is called... // But s is a tone key, and process_tone is called...
// In process_tone: buffer "b", chars=['b'], no vowel found → buffer.push('s') → "bs" // In process_tone: buffer "b", chars=['b'], no vowel found → buffer.push('s') → "bs"
// new_inner = "bs", expected = "b"+"s" = "bs" → same → None // new_inner = "bs", expected = "b"+"s" = "bs" → same → None
assert_eq!(replace_events.len(), 0, "Expected no Replace events, got: {:?}", replace_events); assert_eq!(
replace_events.len(),
0,
"Expected no Replace events, got: {:?}",
replace_events
);
assert_eq!(get_display(&events), "bs"); assert_eq!(get_display(&events), "bs");
} }
@ -1517,15 +1582,23 @@ mod tests {
// Then space triggers auto-restore back to "was " // Then space triggers auto-restore back to "was "
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "was "); let events = process_input(&mut e, "was ");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
})
.collect();
// Expected events for "was ": // Expected events for "was ":
// 'w': pending modifier, no buffer change → Replace {1, ""} (blink) // 'w': pending modifier, no buffer change → Replace {1, ""} (blink)
// 's': tone on 'a' → Replace {2, "á"} // 's': tone on 'a' → Replace {2, "á"}
// ' ': auto-restore → Replace {2, "was "} // ' ': auto-restore → Replace {2, "was "}
assert_eq!(replace_events.len(), 3, "Expected 3 Replace events, got: {:?}", replace_events); assert_eq!(
replace_events.len(),
3,
"Expected 3 Replace events, got: {:?}",
replace_events
);
// Event 0: 'w' blinks (gets deleted as pending modifier) // Event 0: 'w' blinks (gets deleted as pending modifier)
assert_eq!(replace_events[0].0, 1, "w-pending backspace"); assert_eq!(replace_events[0].0, 1, "w-pending backspace");
assert_eq!(replace_events[0].1, ""); assert_eq!(replace_events[0].1, "");
@ -1545,13 +1618,20 @@ mod tests {
// "hello " → no conversion needed, should_restore("hello") → true, no diacritics → None // "hello " → no conversion needed, should_restore("hello") → true, no diacritics → None
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "hello "); let events = process_input(&mut e, "hello ");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, .. } => Some(backspaces), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, .. } => Some(backspaces),
_ => None,
})
.collect();
// "hello" has no Vietnamese conversion, should_restore returns true // "hello" has no Vietnamese conversion, should_restore returns true
// has_diacritics = false → returns None in auto-restore path // has_diacritics = false → returns None in auto-restore path
assert_eq!(replace_events.len(), 0, "No Replace events for plain English"); assert_eq!(
replace_events.len(),
0,
"No Replace events for plain English"
);
assert_eq!(get_display(&events), "hello "); assert_eq!(get_display(&events), "hello ");
} }
@ -1560,13 +1640,20 @@ mod tests {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
e.add_macro("ko".into(), "không".into()); e.add_macro("ko".into(), "không".into());
let events = process_input(&mut e, "ko "); let events = process_input(&mut e, "ko ");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
})
.collect();
// "ko " → macro expansion: raw_buffer="ko", Replace { 3, "không " } // "ko " → macro expansion: raw_buffer="ko", Replace { 3, "không " }
// backspaces = raw_buffer.len + 1 = 2 + 1 = 3 // backspaces = raw_buffer.len + 1 = 2 + 1 = 3
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event for macro"); assert_eq!(
replace_events.len(),
1,
"Expected 1 Replace event for macro"
);
assert_eq!(replace_events[0].0, 3, "macro backspace count"); assert_eq!(replace_events[0].0, 3, "macro backspace count");
assert_eq!(replace_events[0].1, "không "); assert_eq!(replace_events[0].1, "không ");
assert_eq!(get_display(&events), "không "); assert_eq!(get_display(&events), "không ");
@ -1577,17 +1664,25 @@ mod tests {
// "chof " → 'f' is pending after 'o' on "cho", space flushes → "chò " // "chof " → 'f' is pending after 'o' on "cho", space flushes → "chò "
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "chof "); let events = process_input(&mut e, "chof ");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
})
.collect();
// "chof": // "chof":
// 'c' → no event // 'c' → no event
// 'h' → no event // 'h' → no event
// 'o' → no event // 'o' → no event
// 'f' → process_tone on 'o' → Replace { 4, "chò" } (prev_inner="cho", expected="chof") // 'f' → process_tone on 'o' → Replace { 4, "chò" } (prev_inner="cho", expected="chof")
// ' ' → flush with space, final_word="chò" == previous_inner="chò" → None // ' ' → flush with space, final_word="chò" == previous_inner="chò" → None
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event: {:?}", replace_events); assert_eq!(
replace_events.len(),
1,
"Expected 1 Replace event: {:?}",
replace_events
);
assert_eq!(replace_events[0].0, 4, "chof→chò backspace"); assert_eq!(replace_events[0].0, 4, "chof→chò backspace");
assert_eq!(replace_events[0].1, "chò"); assert_eq!(replace_events[0].1, "chò");
assert_eq!(get_display(&events), "chò "); assert_eq!(get_display(&events), "chò ");
@ -1601,7 +1696,10 @@ mod tests {
} }
let event = e.process_escape(); let event = e.process_escape();
match event { match event {
Some(EngineEvent::UndoTones { backspaces, restored }) => { Some(EngineEvent::UndoTones {
backspaces,
restored,
}) => {
assert_eq!(backspaces, 4, "ESC undo should backspace 4 chars (chào)"); assert_eq!(backspaces, 4, "ESC undo should backspace 4 chars (chào)");
assert_eq!(restored, "chao"); assert_eq!(restored, "chao");
} }
@ -1615,20 +1713,36 @@ mod tests {
// Then flush → "a". // Then flush → "a".
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
e.process_key('a'); e.process_key('a');
e.process_key('s'); // buffer = "á" e.process_key('s'); // buffer = "á"
let mut events = Vec::new(); let mut events = Vec::new();
events.push(EngineEvent::Insert(" ".to_string())); events.push(EngineEvent::Insert(" ".to_string()));
if let Some(ev) = e.process_key('\x08') { events.push(ev); } // backspace → buffer "" if let Some(ev) = e.process_key('\x08') {
if let Some(ev) = e.process_key('a') { events.push(ev); } // buffer "a" (no Replace) events.push(ev);
if let Some(ev) = e.flush() { events.push(ev); } } // backspace → buffer ""
if let Some(ev) = e.process_key('a') {
events.push(ev);
} // buffer "a" (no Replace)
if let Some(ev) = e.flush() {
events.push(ev);
}
// After backspace: buffer is empty, then 'a' → no Replace, flush returns Flush("a") // After backspace: buffer is empty, then 'a' → no Replace, flush returns Flush("a")
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { .. } => Some(()), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { .. } => Some(()),
assert_eq!(replace_events.len(), 0, "No Replace events after backspace + 'a'"); _ => None,
})
.collect();
assert_eq!(
replace_events.len(),
0,
"No Replace events after backspace + 'a'"
);
let display = get_display(&events); let display = get_display(&events);
assert_eq!(display, " a", "Display should be ' ' (from Insert) + 'a' (from flush)"); assert_eq!(
display, " a",
"Display should be ' ' (from Insert) + 'a' (from flush)"
);
} }
#[test] #[test]
@ -1636,10 +1750,13 @@ mod tests {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
// "xin chao " (xin=no convert, chao=no convert, space flushes) // "xin chao " (xin=no convert, chao=no convert, space flushes)
let events = process_input(&mut e, "xin chao "); let events = process_input(&mut e, "xin chao ");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
})
.collect();
assert_eq!(replace_events.len(), 0, "No Replace events for 'xin chao '"); assert_eq!(replace_events.len(), 0, "No Replace events for 'xin chao '");
assert_eq!(get_display(&events), "xin chao "); assert_eq!(get_display(&events), "xin chao ");
} }
@ -1656,11 +1773,19 @@ mod tests {
// Apply 's' to 'o' → 'ó'. buffer = "tót" // Apply 's' to 'o' → 'ó'. buffer = "tót"
// Replace { 4, "tót" } // Replace { 4, "tót" }
let events = process_input(&mut e, "tots"); let events = process_input(&mut e, "tots");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events); _ => None,
})
.collect();
assert_eq!(
replace_events.len(),
1,
"Expected 1 Replace: {:?}",
replace_events
);
assert_eq!(replace_events[0].0, 4, "tots→tót backspace"); assert_eq!(replace_events[0].0, 4, "tots→tót backspace");
assert_eq!(replace_events[0].1, "tót"); assert_eq!(replace_events[0].1, "tót");
assert_eq!(get_display(&events), "tót"); assert_eq!(get_display(&events), "tót");
@ -1671,11 +1796,19 @@ mod tests {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
// "dungj" → "dụng" // "dungj" → "dụng"
let events = process_input(&mut e, "dungj"); let events = process_input(&mut e, "dungj");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events); _ => None,
})
.collect();
assert_eq!(
replace_events.len(),
1,
"Expected 1 Replace: {:?}",
replace_events
);
assert_eq!(replace_events[0].0, 5, "dungj→dụng backspace"); assert_eq!(replace_events[0].0, 5, "dungj→dụng backspace");
assert_eq!(replace_events[0].1, "dụng"); assert_eq!(replace_events[0].1, "dụng");
assert_eq!(get_display(&events), "dụng"); assert_eq!(get_display(&events), "dụng");
@ -1695,7 +1828,11 @@ mod tests {
assert_eq!(e.buffer(), "á", "Engine buffer should be 'á'"); assert_eq!(e.buffer(), "á", "Engine buffer should be 'á'");
// Backspace → pop engine, sync raw_buffer // Backspace → pop engine, sync raw_buffer
e.process_key('\x08'); e.process_key('\x08');
assert_eq!(e.buffer(), "", "Engine buffer should be empty after backspace"); assert_eq!(
e.buffer(),
"",
"Engine buffer should be empty after backspace"
);
// Verify raw_buffer is also empty (sync'd via char count matching) // Verify raw_buffer is also empty (sync'd via char count matching)
} }
@ -1738,11 +1875,19 @@ mod tests {
fn vni_backspace_count_tone() { fn vni_backspace_count_tone() {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
let events = process_input(&mut e, "a1"); let events = process_input(&mut e, "a1");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events); _ => None,
})
.collect();
assert_eq!(
replace_events.len(),
1,
"Expected 1 Replace: {:?}",
replace_events
);
assert_eq!(replace_events[0].0, 2, "a1→á backspace"); assert_eq!(replace_events[0].0, 2, "a1→á backspace");
assert_eq!(replace_events[0].1, "á"); assert_eq!(replace_events[0].1, "á");
assert_eq!(get_display(&events), "á"); assert_eq!(get_display(&events), "á");
@ -1752,10 +1897,13 @@ mod tests {
fn vni_backspace_count_vowel_mod() { fn vni_backspace_count_vowel_mod() {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
let events = process_input(&mut e, "a6"); let events = process_input(&mut e, "a6");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
})
.collect();
assert_eq!(replace_events.len(), 1); assert_eq!(replace_events.len(), 1);
assert_eq!(replace_events[0].0, 2, "a6→â backspace"); assert_eq!(replace_events[0].0, 2, "a6→â backspace");
assert_eq!(replace_events[0].1, "â"); assert_eq!(replace_events[0].1, "â");
@ -1766,12 +1914,20 @@ mod tests {
fn vni_backspace_count_mod_then_tone() { fn vni_backspace_count_mod_then_tone() {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
let events = process_input(&mut e, "a61"); let events = process_input(&mut e, "a61");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
})
.collect();
// "a6" → Replace {2, "â"}, then "1" → Replace {2, "ấ"} // "a6" → Replace {2, "â"}, then "1" → Replace {2, "ấ"}
assert_eq!(replace_events.len(), 2, "Expected 2 Replace: {:?}", replace_events); assert_eq!(
replace_events.len(),
2,
"Expected 2 Replace: {:?}",
replace_events
);
assert_eq!(replace_events[0].0, 2); assert_eq!(replace_events[0].0, 2);
assert_eq!(replace_events[0].1, "â"); assert_eq!(replace_events[0].1, "â");
assert_eq!(replace_events[1].0, 2); assert_eq!(replace_events[1].0, 2);
@ -1784,10 +1940,13 @@ mod tests {
// "b1" → 'b' is not vowel, '1' appends as digit → no Replace // "b1" → 'b' is not vowel, '1' appends as digit → no Replace
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
let events = process_input(&mut e, "b1"); let events = process_input(&mut e, "b1");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { .. } => Some(()), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { .. } => Some(()),
_ => None,
})
.collect();
assert_eq!(replace_events.len(), 0, "No Replace for consonant+digit"); assert_eq!(replace_events.len(), 0, "No Replace for consonant+digit");
assert_eq!(get_display(&events), "b1"); assert_eq!(get_display(&events), "b1");
} }
@ -1797,11 +1956,19 @@ mod tests {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
// "chao2" → '2' is tone (huyền) on 'o' → "chaò" // "chao2" → '2' is tone (huyền) on 'o' → "chaò"
let events = process_input(&mut e, "chao2"); let events = process_input(&mut e, "chao2");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events); _ => None,
})
.collect();
assert_eq!(
replace_events.len(),
1,
"Expected 1 Replace: {:?}",
replace_events
);
// previous_inner = "chao" (4 chars), expected = "chao"+"2" = "chao2" (5 chars) // previous_inner = "chao" (4 chars), expected = "chao"+"2" = "chao2" (5 chars)
// backspaces = 4 + 1 = 5 // backspaces = 4 + 1 = 5
assert_eq!(replace_events[0].0, 5, "chao2→chaò backspace"); assert_eq!(replace_events[0].0, 5, "chao2→chaò backspace");
@ -1818,12 +1985,20 @@ mod tests {
// Type "as" → á, then "f" → f overrides sắc with huyền → "à" // Type "as" → á, then "f" → f overrides sắc with huyền → "à"
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "asf"); let events = process_input(&mut e, "asf");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<_> = events
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
})
.collect();
// "as" → Replace {2, "á"}, "f" → Replace {2, "à"} // "as" → Replace {2, "á"}, "f" → Replace {2, "à"}
assert_eq!(replace_events.len(), 2, "Expected 2 Replace: {:?}", replace_events); assert_eq!(
replace_events.len(),
2,
"Expected 2 Replace: {:?}",
replace_events
);
assert_eq!(replace_events[0].0, 2); assert_eq!(replace_events[0].0, 2);
assert_eq!(replace_events[0].1, "á"); assert_eq!(replace_events[0].1, "á");
assert_eq!(replace_events[1].0, 2); assert_eq!(replace_events[1].0, 2);
@ -1970,11 +2145,19 @@ mod tests {
// ' ' = flush // ' ' = flush
// b + a + n + j = "bạn" (j=nặng on 'a') // b + a + n + j = "bạn" (j=nặng on 'a')
let events = process_input(&mut e, "xin chaof banj"); let events = process_input(&mut e, "xin chaof banj");
let replace_events: Vec<usize> = events.iter().filter_map(|ev| match ev { let replace_events: Vec<usize> = events
EngineEvent::Replace { backspaces, .. } => Some(*backspaces), .iter()
_ => None, .filter_map(|ev| match ev {
}).collect(); EngineEvent::Replace { backspaces, .. } => Some(*backspaces),
assert_eq!(replace_events.len(), 2, "Expected 2 Replace events: {:?}", replace_events); _ => None,
})
.collect();
assert_eq!(
replace_events.len(),
2,
"Expected 2 Replace events: {:?}",
replace_events
);
assert_eq!(replace_events[0], 5, "chaof→chào should be 5"); assert_eq!(replace_events[0], 5, "chaof→chào should be 5");
assert_eq!(replace_events[1], 4, "banj→bạn should be 4"); assert_eq!(replace_events[1], 4, "banj→bạn should be 4");
assert_eq!(get_display(&events), "xin chào bạn"); assert_eq!(get_display(&events), "xin chào bạn");
@ -2102,4 +2285,70 @@ mod tests {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "dang9")), "đang"); assert_eq!(get_display(&process_input(&mut e, "dang9")), "đang");
} }
#[test]
fn test_spelling_auto_restore() {
let mut e = Engine::new(InputMethod::Telex);
// "fasts" -> "fást" -> restored to "fasts" on space
assert_eq!(get_display(&process_input(&mut e, "fasts ")), "fasts ");
// "statuss" -> "statús" -> restored to "statuss" on space
let mut e2 = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e2, "statuss ")), "statuss ");
}
#[test]
fn test_user_phrases_telex() {
let mut e = Engine::new(InputMethod::Telex);
assert_eq!(
get_display(&process_input(&mut e, "vox nguyeenx ddawng khoa")),
"võ nguyễn đăng khoa"
);
let mut e2 = Engine::new(InputMethod::Telex);
assert_eq!(
get_display(&process_input(&mut e2, "nguyeenx thij traam anh")),
"nguyễn thị trâm anh"
);
let mut e3 = Engine::new(InputMethod::Telex);
assert_eq!(
get_display(&process_input(&mut e3, "vox hoongf mi")),
"võ hồng mi"
);
let mut e4 = Engine::new(InputMethod::Telex);
assert_eq!(
get_display(&process_input(&mut e4, "trinhj traanf phuongw tuaans")),
"trịnh trần phương tuấn"
);
}
#[test]
fn test_user_phrases_vni() {
let mut e = Engine::new(InputMethod::Vni);
assert_eq!(
get_display(&process_input(&mut e, "vo4 nguyen64 da8ng9 khoa")),
"võ nguyễn đăng khoa"
);
let mut e2 = Engine::new(InputMethod::Vni);
assert_eq!(
get_display(&process_input(&mut e2, "nguyen64 thi5 tram6 anh")),
"nguyễn thị trâm anh"
);
let mut e3 = Engine::new(InputMethod::Vni);
assert_eq!(
get_display(&process_input(&mut e3, "vo4 hong62 mi")),
"võ hồng mi"
);
let mut e4 = Engine::new(InputMethod::Vni);
assert_eq!(
get_display(&process_input(&mut e4, "trinh5 tran62 phuong7 tuan61")),
"trịnh trần phương tuấn"
);
}
} }

View file

@ -1,23 +1,10 @@
use crate::engine::EngineEvent; use crate::engine::EngineEvent;
const VOWELS: &[char] = &[
'a', 'e', 'i', 'o', 'u', 'y',
'ă', 'â', 'ê', 'ô', 'ơ', 'ư',
];
const VOWEL_ACCENTED: &[char] = &[ const VOWEL_ACCENTED: &[char] = &[
'a', 'á', 'à', 'ả', 'ã', 'ạ', 'a', 'á', 'à', 'ả', 'ã', 'ạ', 'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ', 'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ', 'e',
'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ', 'é', 'è', 'ẻ', 'ẽ', 'ẹ', 'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ', 'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị', 'o', 'ó',
'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ', 'ò', 'ỏ', 'õ', 'ọ', 'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ', 'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ', 'u', 'ú', 'ù',
'e', 'é', 'è', 'ẻ', 'ẽ', 'ẹ', 'ủ', 'ũ', 'ụ', 'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự', 'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ',
'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ',
'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị',
'o', 'ó', 'ò', 'ỏ', 'õ', 'ọ',
'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ',
'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ',
'u', 'ú', 'ù', 'ủ', 'ũ', 'ụ',
'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự',
'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ',
]; ];
fn is_vowel(c: char) -> bool { fn is_vowel(c: char) -> bool {
@ -29,30 +16,78 @@ const MAX_FLEXIBLE_BACKTRACK: usize = 3;
/// Strip tone from a Vietnamese vowel, returning (base_modified_vowel, tone_digit_or_none) /// Strip tone from a Vietnamese vowel, returning (base_modified_vowel, tone_digit_or_none)
fn strip_tone_vni(c: char) -> (char, Option<char>) { fn strip_tone_vni(c: char) -> (char, Option<char>) {
match c { match c {
'a' => ('a', None), 'á' => ('a', Some('1')), 'à' => ('a', Some('2')), 'a' => ('a', None),
'ả' => ('a', Some('3')), 'ã' => ('a', Some('4')), 'ạ' => ('a', Some('5')), 'á' => ('a', Some('1')),
'ă' => ('ă', None), 'ắ' => ('ă', Some('1')), 'ằ' => ('ă', Some('2')), 'à' => ('a', Some('2')),
'ẳ' => ('ă', Some('3')), 'ẵ' => ('ă', Some('4')), 'ặ' => ('ă', Some('5')), 'ả' => ('a', Some('3')),
'â' => ('â', None), 'ấ' => ('â', Some('1')), 'ầ' => ('â', Some('2')), 'ã' => ('a', Some('4')),
'ẩ' => ('â', Some('3')), 'ẫ' => ('â', Some('4')), 'ậ' => ('â', Some('5')), 'ạ' => ('a', Some('5')),
'e' => ('e', None), 'é' => ('e', Some('1')), 'è' => ('e', Some('2')), 'ă' => ('ă', None),
'ẻ' => ('e', Some('3')), 'ẽ' => ('e', Some('4')), 'ẹ' => ('e', Some('5')), 'ắ' => ('ă', Some('1')),
'ê' => ('ê', None), 'ế' => ('ê', Some('1')), 'ề' => ('ê', Some('2')), 'ằ' => ('ă', Some('2')),
'ể' => ('ê', Some('3')), 'ễ' => ('ê', Some('4')), 'ệ' => ('ê', Some('5')), 'ẳ' => ('ă', Some('3')),
'i' => ('i', None), 'í' => ('i', Some('1')), 'ì' => ('i', Some('2')), 'ẵ' => ('ă', Some('4')),
'ỉ' => ('i', Some('3')), 'ĩ' => ('i', Some('4')), 'ị' => ('i', Some('5')), 'ặ' => ('ă', Some('5')),
'o' => ('o', None), 'ó' => ('o', Some('1')), 'ò' => ('o', Some('2')), 'â' => ('â', None),
'ỏ' => ('o', Some('3')), 'õ' => ('o', Some('4')), 'ọ' => ('o', Some('5')), 'ấ' => ('â', Some('1')),
'ô' => ('ô', None), 'ố' => ('ô', Some('1')), 'ồ' => ('ô', Some('2')), 'ầ' => ('â', Some('2')),
'ổ' => ('ô', Some('3')), 'ỗ' => ('ô', Some('4')), 'ộ' => ('ô', Some('5')), 'ẩ' => ('â', Some('3')),
'ơ' => ('ơ', None), 'ớ' => ('ơ', Some('1')), 'ờ' => ('ơ', Some('2')), 'ẫ' => ('â', Some('4')),
'ở' => ('ơ', Some('3')), 'ỡ' => ('ơ', Some('4')), 'ợ' => ('ơ', Some('5')), 'ậ' => ('â', Some('5')),
'u' => ('u', None), 'ú' => ('u', Some('1')), 'ù' => ('u', Some('2')), 'e' => ('e', None),
'ủ' => ('u', Some('3')), 'ũ' => ('u', Some('4')), 'ụ' => ('u', Some('5')), 'é' => ('e', Some('1')),
'ư' => ('ư', None), 'ứ' => ('ư', Some('1')), 'ừ' => ('ư', Some('2')), 'è' => ('e', Some('2')),
'ử' => ('ư', Some('3')), 'ữ' => ('ư', Some('4')), 'ự' => ('ư', Some('5')), 'ẻ' => ('e', Some('3')),
'y' => ('y', None), 'ý' => ('y', Some('1')), 'ỳ' => ('y', Some('2')), 'ẽ' => ('e', Some('4')),
'ỷ' => ('y', Some('3')), 'ỹ' => ('y', Some('4')), 'ỵ' => ('y', Some('5')), 'ẹ' => ('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), _ => (c, None),
} }
} }
@ -60,18 +95,66 @@ fn strip_tone_vni(c: char) -> (char, Option<char>) {
fn apply_tone_to_vowel(vowel: char, digit: char) -> Option<char> { 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 // VNI: 1=sắc, 2=huyền, 3=hỏi, 4=ngã, 5=nặng
let table: &[(char, char, char)] = &[ let table: &[(char, char, char)] = &[
('a', '1', 'á'), ('a', '2', 'à'), ('a', '3', 'ả'), ('a', '4', 'ã'), ('a', '5', 'ạ'), ('a', '1', 'á'),
('ă', '1', 'ắ'), ('ă', '2', 'ằ'), ('ă', '3', 'ẳ'), ('ă', '4', 'ẵ'), ('ă', '5', 'ặ'), ('a', '2', 'à'),
('â', '1', 'ấ'), ('â', '2', 'ầ'), ('â', '3', 'ẩ'), ('â', '4', 'ẫ'), ('â', '5', 'ậ'), ('a', '3', 'ả'),
('e', '1', 'é'), ('e', '2', 'è'), ('e', '3', 'ẻ'), ('e', '4', 'ẽ'), ('e', '5', 'ẹ'), ('a', '4', 'ã'),
('ê', '1', 'ế'), ('ê', '2', 'ề'), ('ê', '3', 'ể'), ('ê', '4', 'ễ'), ('ê', '5', 'ệ'), ('a', '5', 'ạ'),
('i', '1', 'í'), ('i', '2', 'ì'), ('i', '3', 'ỉ'), ('i', '4', 'ĩ'), ('i', '5', 'ị'), ('ă', '1', 'ắ'),
('o', '1', 'ó'), ('o', '2', 'ò'), ('o', '3', 'ỏ'), ('o', '4', 'õ'), ('o', '5', 'ọ'), ('ă', '2', 'ằ'),
('ô', '1', 'ố'), ('ô', '2', 'ồ'), ('ô', '3', 'ổ'), ('ô', '4', 'ỗ'), ('ô', '5', 'ộ'), ('ă', '3', 'ẳ'),
('ơ', '1', 'ớ'), ('ơ', '2', 'ờ'), ('ơ', '3', 'ở'), ('ơ', '4', 'ỡ'), ('ơ', '5', 'ợ'), ('ă', '4', 'ẵ'),
('u', '1', 'ú'), ('u', '2', 'ù'), ('u', '3', 'ủ'), ('u', '4', 'ũ'), ('u', '5', 'ụ'), ('ă', '5', 'ặ'),
('ư', '1', 'ứ'), ('ư', '2', 'ừ'), ('ư', '3', 'ử'), ('ư', '4', 'ữ'), ('ư', '5', 'ự'), ('â', '1', 'ấ'),
('y', '1', 'ý'), ('y', '2', 'ỳ'), ('y', '3', 'ỷ'), ('y', '4', 'ỹ'), ('y', '5', 'ỵ'), ('â', '2', 'ầ'),
('â', '3', 'ẩ'),
('â', '4', 'ẫ'),
('â', '5', 'ậ'),
('e', '1', 'é'),
('e', '2', 'è'),
('e', '3', 'ẻ'),
('e', '4', 'ẽ'),
('e', '5', 'ẹ'),
('ê', '1', 'ế'),
('ê', '2', 'ề'),
('ê', '3', 'ể'),
('ê', '4', 'ễ'),
('ê', '5', 'ệ'),
('i', '1', 'í'),
('i', '2', 'ì'),
('i', '3', 'ỉ'),
('i', '4', 'ĩ'),
('i', '5', 'ị'),
('o', '1', 'ó'),
('o', '2', 'ò'),
('o', '3', 'ỏ'),
('o', '4', 'õ'),
('o', '5', 'ọ'),
('ô', '1', 'ố'),
('ô', '2', 'ồ'),
('ô', '3', 'ổ'),
('ô', '4', 'ỗ'),
('ô', '5', 'ộ'),
('ơ', '1', 'ớ'),
('ơ', '2', 'ờ'),
('ơ', '3', 'ở'),
('ơ', '4', 'ỡ'),
('ơ', '5', 'ợ'),
('u', '1', 'ú'),
('u', '2', 'ù'),
('u', '3', 'ủ'),
('u', '4', 'ũ'),
('u', '5', 'ụ'),
('ư', '1', 'ứ'),
('ư', '2', 'ừ'),
('ư', '3', 'ử'),
('ư', '4', 'ữ'),
('ư', '5', 'ự'),
('y', '1', 'ý'),
('y', '2', 'ỳ'),
('y', '3', 'ỷ'),
('y', '4', 'ỹ'),
('y', '5', 'ỵ'),
]; ];
for &(v, t, result) in table { for &(v, t, result) in table {
@ -145,24 +228,34 @@ fn is_o_vowel(c: char) -> bool {
fn tone_of_vowel_vni(c: char) -> Option<char> { fn tone_of_vowel_vni(c: char) -> Option<char> {
match c { match c {
'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None, 'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None,
'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => Some('2'), 'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => {
'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => Some('1'), Some('2')
'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => Some('3'), }
'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => Some('4'), 'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => {
'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => Some('5'), Some('1')
}
'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => {
Some('3')
}
'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => {
Some('4')
}
'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => {
Some('5')
}
_ => None, _ => None,
} }
} }
fn apply_tone_to_ơ_vni(tone: Option<char>) -> char { fn apply_tone_to_ơ_vni(tone: Option<char>) -> char {
match tone { match tone {
None => 'ơ', None => 'ơ',
Some('2') => 'ờ', Some('2') => 'ờ',
Some('1') => 'ớ', Some('1') => 'ớ',
Some('3') => 'ở', Some('3') => 'ở',
Some('4') => 'ỡ', Some('4') => 'ỡ',
Some('5') => 'ợ', Some('5') => 'ợ',
_ => 'ơ', _ => 'ơ',
} }
} }
@ -251,6 +344,22 @@ impl VniEngine {
} }
fn process_digit(&mut self, digit: char) -> Option<EngineEvent> { fn process_digit(&mut self, digit: char) -> Option<EngineEvent> {
// VNI digit 9: 'd' -> 'đ' or 'D' -> 'Đ' anywhere at the start of the buffer
if digit == '9' {
let mut chars: Vec<char> = self.buffer.chars().collect();
if !chars.is_empty() {
if chars[0] == 'd' {
chars[0] = 'đ';
self.buffer = chars.into_iter().collect();
return None;
} else if chars[0] == 'D' {
chars[0] = 'Đ';
self.buffer = chars.into_iter().collect();
return None;
}
}
}
// Apply any pending modifier first // Apply any pending modifier first
if self.pending_modifier.is_some() { if self.pending_modifier.is_some() {
self.apply_pending(); self.apply_pending();
@ -262,7 +371,10 @@ impl VniEngine {
// Smart cluster "uo" → "ươ" (digit '7') // Smart cluster "uo" → "ươ" (digit '7')
if digit == '7' && is_o_vowel(last_ch) { if digit == '7' && is_o_vowel(last_ch) {
let mut chars: Vec<char> = self.buffer.chars().collect(); 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) { 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 o_char = chars.pop().unwrap();
let u_char = chars.pop().unwrap(); let u_char = chars.pop().unwrap();
let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char); let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char);
@ -291,7 +403,10 @@ impl VniEngine {
let strip = strip_tone_vni(last_ch); let strip = strip_tone_vni(last_ch);
if strip.0 == 'ô' { if strip.0 == 'ô' {
let mut chars: Vec<char> = self.buffer.chars().collect(); 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) { 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 o_char = chars.pop().unwrap();
let u_char = chars.pop().unwrap(); let u_char = chars.pop().unwrap();
let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char); let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char);
@ -322,12 +437,7 @@ impl VniEngine {
} }
} }
} }
// 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 // Modifier override: vowel already has a different modifier
if let Some(modified) = override_vni_modifier(last_ch, digit) { if let Some(modified) = override_vni_modifier(last_ch, digit) {
self.buffer.pop(); self.buffer.pop();
@ -345,7 +455,12 @@ impl VniEngine {
for i in (start..chars.len()).rev() { for i in (start..chars.len()).rev() {
if is_vowel(chars[i]) { if is_vowel(chars[i]) {
// Smart cluster "uo" → "ươ" (digit '7', flexible) // 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) { 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]); let (new_first, new_second) = uo_to_uơ_vni(chars[i - 1], chars[i]);
self.buffer = chars[..i - 1].iter().collect::<String>(); self.buffer = chars[..i - 1].iter().collect::<String>();
self.buffer.push(new_first); self.buffer.push(new_first);
@ -376,7 +491,11 @@ impl VniEngine {
// Smart cluster forward (override): "uô" + 7 → "ươ" (flexible) // Smart cluster forward (override): "uô" + 7 → "ươ" (flexible)
if digit == '7' { if digit == '7' {
let strip = strip_tone_vni(chars[i]); let strip = strip_tone_vni(chars[i]);
if strip.0 == 'ô' && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&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]); let (new_first, new_second) = uo_to_uơ_vni(chars[i - 1], chars[i]);
self.buffer = chars[..i - 1].iter().collect::<String>(); self.buffer = chars[..i - 1].iter().collect::<String>();
self.buffer.push(new_first); self.buffer.push(new_first);

View file

@ -0,0 +1,83 @@
use serde::Serialize;
use vietc_engine::{Engine, EngineEvent, InputMethod};
#[derive(Serialize)]
struct SnapshotTestCase {
input: String,
display: String,
events: Vec<EngineEvent>,
}
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::Paste(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 run_snapshot_test(inputs_json: &str, method: InputMethod) -> Vec<SnapshotTestCase> {
let inputs: Vec<String> = serde_json::from_str(inputs_json).unwrap();
let mut cases = Vec::new();
for input in inputs {
let mut engine = Engine::new(method);
let mut events = Vec::new();
for ch in input.chars() {
if let Some(event) = engine.process_key(ch) {
events.push(event);
}
}
let display = get_display(&events);
cases.push(SnapshotTestCase {
input,
display,
events,
});
}
cases
}
#[test]
fn test_telex_snapshots() {
let inputs_json = include_str!("testdata/telex_inputs.json");
let cases = run_snapshot_test(inputs_json, InputMethod::Telex);
insta::assert_yaml_snapshot!(cases);
}
#[test]
fn test_vni_snapshots() {
let inputs_json = include_str!("testdata/vni_inputs.json");
let cases = run_snapshot_test(inputs_json, InputMethod::Vni);
insta::assert_yaml_snapshot!(cases);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

502
engine/tests/testdata/telex_inputs.json vendored Normal file
View file

@ -0,0 +1,502 @@
[
"aaf",
"aas",
"aaj",
"aar",
"aax",
"aaw",
"aaa",
"eee",
"ooo",
"oow",
"uuw",
"acaf",
"acas",
"acaj",
"acar",
"acax",
"acaw",
"acaa",
"ecee",
"ocoo",
"ocow",
"ucuw",
"amaf",
"amas",
"amaj",
"amar",
"amax",
"amaw",
"amaa",
"emee",
"omoo",
"omow",
"umuw",
"anaf",
"anas",
"anaj",
"anar",
"anax",
"anaw",
"anaa",
"enee",
"onoo",
"onow",
"unuw",
"angaf",
"angas",
"angaj",
"angar",
"angax",
"angaw",
"angaa",
"engee",
"ongoo",
"ongow",
"unguw",
"apaf",
"apas",
"apaj",
"apar",
"apax",
"apaw",
"apaa",
"epee",
"opoo",
"opow",
"upuw",
"ataf",
"atas",
"ataj",
"atar",
"atax",
"ataw",
"ataa",
"etee",
"otoo",
"otow",
"utuw",
"baaf",
"baas",
"baaj",
"baar",
"baax",
"baaw",
"baaa",
"beee",
"booo",
"boow",
"buuw",
"bacaf",
"bacas",
"bacaj",
"bacar",
"bacax",
"bacaw",
"bacaa",
"becee",
"bocoo",
"bocow",
"bucuw",
"bachaf",
"bachas",
"bachaj",
"bachar",
"bachax",
"bachaw",
"bachaa",
"bechee",
"bochoo",
"bochow",
"buchuw",
"bamaf",
"bamas",
"bamaj",
"bamar",
"bamax",
"bamaw",
"bamaa",
"bemee",
"bomoo",
"bomow",
"bumuw",
"banaf",
"banas",
"banaj",
"banar",
"banax",
"banaw",
"banaa",
"benee",
"bonoo",
"bonow",
"bunuw",
"bangaf",
"bangas",
"bangaj",
"bangar",
"bangax",
"bangaw",
"bangaa",
"bengee",
"bongoo",
"bongow",
"bunguw",
"banhaf",
"banhas",
"banhaj",
"banhar",
"banhax",
"banhaw",
"banhaa",
"benhee",
"bonhoo",
"bonhow",
"bunhuw",
"bapaf",
"bapas",
"bapaj",
"bapar",
"bapax",
"bapaw",
"bapaa",
"bepee",
"bopoo",
"bopow",
"bupuw",
"bataf",
"batas",
"bataj",
"batar",
"batax",
"bataw",
"bataa",
"betee",
"botoo",
"botow",
"butuw",
"caaf",
"caas",
"caaj",
"caar",
"caax",
"caaw",
"caaa",
"ceee",
"cooo",
"coow",
"cuuw",
"cacaf",
"cacas",
"cacaj",
"cacar",
"cacax",
"cacaw",
"cacaa",
"cecee",
"cocoo",
"cocow",
"cucuw",
"cachaf",
"cachas",
"cachaj",
"cachar",
"cachax",
"cachaw",
"cachaa",
"cechee",
"cochoo",
"cochow",
"cuchuw",
"camaf",
"camas",
"camaj",
"camar",
"camax",
"camaw",
"camaa",
"cemee",
"comoo",
"comow",
"cumuw",
"canaf",
"canas",
"canaj",
"canar",
"canax",
"canaw",
"canaa",
"cenee",
"conoo",
"conow",
"cunuw",
"cangaf",
"cangas",
"cangaj",
"cangar",
"cangax",
"cangaw",
"cangaa",
"cengee",
"congoo",
"congow",
"cunguw",
"canhaf",
"canhas",
"canhaj",
"canhar",
"canhax",
"canhaw",
"canhaa",
"cenhee",
"conhoo",
"conhow",
"cunhuw",
"capaf",
"capas",
"capaj",
"capar",
"capax",
"capaw",
"capaa",
"cepee",
"copoo",
"copow",
"cupuw",
"cataf",
"catas",
"cataj",
"catar",
"catax",
"cataw",
"cataa",
"cetee",
"cotoo",
"cotow",
"cutuw",
"chaaf",
"chaas",
"chaaj",
"chaar",
"chaax",
"chaaw",
"chaaa",
"cheee",
"chooo",
"choow",
"chuuw",
"chacaf",
"chacas",
"chacaj",
"chacar",
"chacax",
"chacaw",
"chacaa",
"checee",
"chocoo",
"chocow",
"chucuw",
"chachaf",
"chachas",
"chachaj",
"chachar",
"chachax",
"chachaw",
"chachaa",
"chechee",
"chochoo",
"chochow",
"chuchuw",
"chamaf",
"chamas",
"chamaj",
"chamar",
"chamax",
"chamaw",
"chamaa",
"chemee",
"chomoo",
"chomow",
"chumuw",
"chanaf",
"chanas",
"chanaj",
"chanar",
"chanax",
"chanaw",
"chanaa",
"chenee",
"chonoo",
"chonow",
"chunuw",
"changaf",
"changas",
"changaj",
"changar",
"changax",
"changaw",
"changaa",
"chengee",
"chongoo",
"chongow",
"chunguw",
"chanhaf",
"chanhas",
"chanhaj",
"chanhar",
"chanhax",
"chanhaw",
"chanhaa",
"chenhee",
"chonhoo",
"chonhow",
"chunhuw",
"chapaf",
"chapas",
"chapaj",
"chapar",
"chapax",
"chapaw",
"chapaa",
"chepee",
"chopoo",
"chopow",
"chupuw",
"chataf",
"chatas",
"chataj",
"chatar",
"chatax",
"chataw",
"chataa",
"chetee",
"chotoo",
"chotow",
"chutuw",
"daaf",
"daas",
"daaj",
"daar",
"daax",
"daaw",
"daaa",
"deee",
"dooo",
"doow",
"duuw",
"dacaf",
"dacas",
"dacaj",
"dacar",
"dacax",
"dacaw",
"dacaa",
"decee",
"docoo",
"docow",
"ducuw",
"dachaf",
"dachas",
"dachaj",
"dachar",
"dachax",
"dachaw",
"dachaa",
"dechee",
"dochoo",
"dochow",
"duchuw",
"damaf",
"damas",
"damaj",
"damar",
"damax",
"damaw",
"damaa",
"demee",
"domoo",
"domow",
"dumuw",
"danaf",
"danas",
"danaj",
"danar",
"danax",
"danaw",
"danaa",
"denee",
"donoo",
"donow",
"dunuw",
"dangaf",
"dangas",
"dangaj",
"dangar",
"dangax",
"dangaw",
"dangaa",
"dengee",
"dongoo",
"dongow",
"dunguw",
"danhaf",
"danhas",
"danhaj",
"danhar",
"danhax",
"danhaw",
"danhaa",
"denhee",
"donhoo",
"donhow",
"dunhuw",
"dapaf",
"dapas",
"dapaj",
"dapar",
"dapax",
"dapaw",
"dapaa",
"depee",
"dopoo",
"dopow",
"dupuw",
"dataf",
"datas",
"dataj",
"datar",
"datax",
"dataw",
"dataa",
"detee",
"dotoo",
"dotow",
"dutuw",
"gaaf",
"gaas",
"gaaj",
"gaar",
"gaax",
"gaaw",
"gaaa",
"geee",
"gooo",
"goow",
"guuw",
"ganaf",
"ganas",
"ganaj",
"ganar",
"ganax",
"ganaw",
"ganaa",
"genee",
"gonoo",
"gonow",
"gunuw",
"gangaf",
"gangas",
"gangaj",
"gangar",
"gangax"
]

502
engine/tests/testdata/vni_inputs.json vendored Normal file
View file

@ -0,0 +1,502 @@
[
"a1",
"a2",
"a3",
"a4",
"a5",
"a6",
"a8",
"e6",
"o6",
"o7",
"u7",
"ac1",
"ac2",
"ac3",
"ac4",
"ac5",
"ac6",
"ac8",
"ec6",
"oc6",
"oc7",
"uc7",
"am1",
"am2",
"am3",
"am4",
"am5",
"am6",
"am8",
"em6",
"om6",
"om7",
"um7",
"an1",
"an2",
"an3",
"an4",
"an5",
"an6",
"an8",
"en6",
"on6",
"on7",
"un7",
"ang1",
"ang2",
"ang3",
"ang4",
"ang5",
"ang6",
"ang8",
"eng6",
"ong6",
"ong7",
"ung7",
"ap1",
"ap2",
"ap3",
"ap4",
"ap5",
"ap6",
"ap8",
"ep6",
"op6",
"op7",
"up7",
"at1",
"at2",
"at3",
"at4",
"at5",
"at6",
"at8",
"et6",
"ot6",
"ot7",
"ut7",
"ba1",
"ba2",
"ba3",
"ba4",
"ba5",
"ba6",
"ba8",
"be6",
"bo6",
"bo7",
"bu7",
"bac1",
"bac2",
"bac3",
"bac4",
"bac5",
"bac6",
"bac8",
"bec6",
"boc6",
"boc7",
"buc7",
"bach1",
"bach2",
"bach3",
"bach4",
"bach5",
"bach6",
"bach8",
"bech6",
"boch6",
"boch7",
"buch7",
"bam1",
"bam2",
"bam3",
"bam4",
"bam5",
"bam6",
"bam8",
"bem6",
"bom6",
"bom7",
"bum7",
"ban1",
"ban2",
"ban3",
"ban4",
"ban5",
"ban6",
"ban8",
"ben6",
"bon6",
"bon7",
"bun7",
"bang1",
"bang2",
"bang3",
"bang4",
"bang5",
"bang6",
"bang8",
"beng6",
"bong6",
"bong7",
"bung7",
"banh1",
"banh2",
"banh3",
"banh4",
"banh5",
"banh6",
"banh8",
"benh6",
"bonh6",
"bonh7",
"bunh7",
"bap1",
"bap2",
"bap3",
"bap4",
"bap5",
"bap6",
"bap8",
"bep6",
"bop6",
"bop7",
"bup7",
"bat1",
"bat2",
"bat3",
"bat4",
"bat5",
"bat6",
"bat8",
"bet6",
"bot6",
"bot7",
"but7",
"ca1",
"ca2",
"ca3",
"ca4",
"ca5",
"ca6",
"ca8",
"ce6",
"co6",
"co7",
"cu7",
"cac1",
"cac2",
"cac3",
"cac4",
"cac5",
"cac6",
"cac8",
"cec6",
"coc6",
"coc7",
"cuc7",
"cach1",
"cach2",
"cach3",
"cach4",
"cach5",
"cach6",
"cach8",
"cech6",
"coch6",
"coch7",
"cuch7",
"cam1",
"cam2",
"cam3",
"cam4",
"cam5",
"cam6",
"cam8",
"cem6",
"com6",
"com7",
"cum7",
"can1",
"can2",
"can3",
"can4",
"can5",
"can6",
"can8",
"cen6",
"con6",
"con7",
"cun7",
"cang1",
"cang2",
"cang3",
"cang4",
"cang5",
"cang6",
"cang8",
"ceng6",
"cong6",
"cong7",
"cung7",
"canh1",
"canh2",
"canh3",
"canh4",
"canh5",
"canh6",
"canh8",
"cenh6",
"conh6",
"conh7",
"cunh7",
"cap1",
"cap2",
"cap3",
"cap4",
"cap5",
"cap6",
"cap8",
"cep6",
"cop6",
"cop7",
"cup7",
"cat1",
"cat2",
"cat3",
"cat4",
"cat5",
"cat6",
"cat8",
"cet6",
"cot6",
"cot7",
"cut7",
"cha1",
"cha2",
"cha3",
"cha4",
"cha5",
"cha6",
"cha8",
"che6",
"cho6",
"cho7",
"chu7",
"chac1",
"chac2",
"chac3",
"chac4",
"chac5",
"chac6",
"chac8",
"chec6",
"choc6",
"choc7",
"chuc7",
"chach1",
"chach2",
"chach3",
"chach4",
"chach5",
"chach6",
"chach8",
"chech6",
"choch6",
"choch7",
"chuch7",
"cham1",
"cham2",
"cham3",
"cham4",
"cham5",
"cham6",
"cham8",
"chem6",
"chom6",
"chom7",
"chum7",
"chan1",
"chan2",
"chan3",
"chan4",
"chan5",
"chan6",
"chan8",
"chen6",
"chon6",
"chon7",
"chun7",
"chang1",
"chang2",
"chang3",
"chang4",
"chang5",
"chang6",
"chang8",
"cheng6",
"chong6",
"chong7",
"chung7",
"chanh1",
"chanh2",
"chanh3",
"chanh4",
"chanh5",
"chanh6",
"chanh8",
"chenh6",
"chonh6",
"chonh7",
"chunh7",
"chap1",
"chap2",
"chap3",
"chap4",
"chap5",
"chap6",
"chap8",
"chep6",
"chop6",
"chop7",
"chup7",
"chat1",
"chat2",
"chat3",
"chat4",
"chat5",
"chat6",
"chat8",
"chet6",
"chot6",
"chot7",
"chut7",
"da1",
"da2",
"da3",
"da4",
"da5",
"da6",
"da8",
"de6",
"do6",
"do7",
"du7",
"dac1",
"dac2",
"dac3",
"dac4",
"dac5",
"dac6",
"dac8",
"dec6",
"doc6",
"doc7",
"duc7",
"dach1",
"dach2",
"dach3",
"dach4",
"dach5",
"dach6",
"dach8",
"dech6",
"doch6",
"doch7",
"duch7",
"dam1",
"dam2",
"dam3",
"dam4",
"dam5",
"dam6",
"dam8",
"dem6",
"dom6",
"dom7",
"dum7",
"dan1",
"dan2",
"dan3",
"dan4",
"dan5",
"dan6",
"dan8",
"den6",
"don6",
"don7",
"dun7",
"dang1",
"dang2",
"dang3",
"dang4",
"dang5",
"dang6",
"dang8",
"deng6",
"dong6",
"dong7",
"dung7",
"danh1",
"danh2",
"danh3",
"danh4",
"danh5",
"danh6",
"danh8",
"denh6",
"donh6",
"donh7",
"dunh7",
"dap1",
"dap2",
"dap3",
"dap4",
"dap5",
"dap6",
"dap8",
"dep6",
"dop6",
"dop7",
"dup7",
"dat1",
"dat2",
"dat3",
"dat4",
"dat5",
"dat6",
"dat8",
"det6",
"dot6",
"dot7",
"dut7",
"ga1",
"ga2",
"ga3",
"ga4",
"ga5",
"ga6",
"ga8",
"ge6",
"go6",
"go7",
"gu7",
"gan1",
"gan2",
"gan3",
"gan4",
"gan5",
"gan6",
"gan8",
"gen6",
"gon6",
"gon7",
"gun7",
"gang1",
"gang2",
"gang3",
"gang4",
"gang5"
]

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Ensure cargo is in PATH (common for rustup installations) # Ensure cargo is in PATH
if ! command -v cargo &>/dev/null; then if ! command -v cargo &>/dev/null; then
if [ -f "$HOME/.cargo/bin/cargo" ]; then if [ -f "$HOME/.cargo/bin/cargo" ]; then
export PATH="$HOME/.cargo/bin:$PATH" export PATH="$HOME/.cargo/bin:$PATH"
@ -22,82 +22,47 @@ mkdir -p "$APPDIR/usr/share/applications"
mkdir -p "$APPDIR/usr/share/icons/hicolor/256x256/apps" mkdir -p "$APPDIR/usr/share/icons/hicolor/256x256/apps"
mkdir -p "$APPDIR/usr/share/doc/vietc" mkdir -p "$APPDIR/usr/share/doc/vietc"
mkdir -p "$APPDIR/etc/vietc" mkdir -p "$APPDIR/etc/vietc"
mkdir -p "$APPDIR/usr/lib/systemd/user"
mkdir -p "$APPDIR/usr/share/metainfo"
# Build binaries # Build binaries
echo "[1/5] Building binaries..." echo "[1/5] Building binaries..."
cargo build --release if [ ! -f "target/release/vietc" ]; then
cargo build --release
cd "$PROJECT_ROOT/ui" && cargo build --release && cd "$PROJECT_ROOT"
fi
echo " Built with x11 + wayland" echo " Built with x11 + wayland"
# Copy binaries from deb-build if they exist, otherwise from target/release
cd "$SCRIPT_DIR"
cd "$PROJECT_ROOT/ui" && cargo build --release && cd "$SCRIPT_DIR"
cd "$PROJECT_ROOT"
# Copy binaries
echo "[2/5] Installing binaries..." echo "[2/5] Installing binaries..."
cp target/release/vietc "$APPDIR/usr/bin/" if [ -d "deb-build/usr/bin" ]; then
cp target/release/vietc-cli "$APPDIR/usr/bin/" cp -r deb-build/usr/bin/* "$APPDIR/usr/bin/"
[ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/" else
cp target/release/vietc "$APPDIR/usr/bin/"
cp target/release/vietc-cli "$APPDIR/usr/bin/"
[ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/"
fi
# Desktop integration # Desktop integration
echo "[3/5] Installing desktop integration..." echo "[3/5] Installing desktop integration..."
cp "$SCRIPT_DIR/vietc.desktop" "$APPDIR/usr/share/applications/" if [ -f "deb-build/vietc.desktop" ]; then
cp deb-build/vietc.desktop "$APPDIR/usr/share/applications/"
# Generate SVG icon
cat > "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" << 'SVGEOF'
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<rect x="20" y="60" width="216" height="140" rx="16" fill="#2d2d2d" stroke="#1a1a1a" stroke-width="4"/>
<rect x="36" y="76" width="184" height="108" rx="8" fill="#3d3d3d"/>
<rect x="48" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="78" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="108" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="138" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="168" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="198" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="54" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="84" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="114" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="144" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="174" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="60" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="90" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="120" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="150" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="180" y="140" width="42" height="20" rx="3" fill="#f0f0f0"/>
<rect x="72" y="166" width="112" height="16" rx="3" fill="#f0f0f0"/>
<circle cx="216" cy="48" r="28" fill="#da251d"/>
<text x="216" y="56" text-anchor="middle" fill="white" font-size="18" font-weight="bold" font-family="sans-serif">VN</text>
</svg>
SVGEOF
# Convert SVG to PNG if rsvg-convert available
if command -v rsvg-convert &>/dev/null; then
rsvg-convert -w 256 -h 256 "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" \
-o "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.png"
else else
# Fallback: generate PNG via Python/Pillow cp "$SCRIPT_DIR/vietc.desktop" "$APPDIR/usr/share/applications/"
python3 -c "
from PIL import Image, ImageDraw
img = Image.new('RGBA', (256, 256), (0,0,0,0))
draw = ImageDraw.Draw(img)
draw.ellipse([(20,20),(236,236)], fill=(218,29,37), outline=(180,20,30), width=4)
try:
from PIL import ImageFont
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 80)
except:
font = ImageFont.load_default()
draw.text((128, 128), 'VN', fill=(255,255,255), font=font, anchor='mm')
img.save('$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.png')
" 2>/dev/null || echo " PNG icon generation skipped (no Pillow)"
fi fi
# Copy icon to AppDir root for appimagetool # Icons
cp "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc."{png,svg} "$APPDIR/" 2>/dev/null || true if [ -f "deb-build/vietc.svg" ]; then
cp deb-build/vietc.svg "$APPDIR/usr/share/icons/hicolor/256x256/apps/"
cp deb-build/vietc.png "$APPDIR/usr/share/icons/hicolor/256x256/apps/"
cp deb-build/vietc.png "$APPDIR/"
fi
# AppStream metadata # AppStream metadata
mkdir -p "$APPDIR/usr/share/metainfo" if [ -f "deb-build/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" ]; then
cat > "$APPDIR/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" << 'XML' cp deb-build/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml "$APPDIR/usr/share/metainfo/"
else
cat > "$APPDIR/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" << 'XML'
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<component type="console-application"> <component type="console-application">
<id>io.github.anomalyco.vietc</id> <id>io.github.anomalyco.vietc</id>
@ -113,19 +78,36 @@ cat > "$APPDIR/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" << 'XML
<categories><category>Utility</category></categories> <categories><category>Utility</category></categories>
</component> </component>
XML XML
fi
# Config # Config
echo "[4/5] Installing config..." echo "[4/5] Installing config..."
# Use grab=true by default in the AppImage; falls back gracefully for non-root if [ -f "deb-build/etc/vietc/config.toml" ]; then
sed 's/^grab = false/grab = true/' "$PROJECT_ROOT/vietc.toml" > "$APPDIR/etc/vietc/config.toml" cp deb-build/etc/vietc/config.toml "$APPDIR/etc/vietc/"
cp "$PROJECT_ROOT/README.md" "$APPDIR/usr/share/doc/vietc/" else
sed 's/^grab = false/grab = true/' "$PROJECT_ROOT/vietc.toml" > "$APPDIR/etc/vietc/config.toml"
fi
# Docs
if [ -f "deb-build/usr/share/doc/vietc/README.md" ]; then
cp deb-build/usr/share/doc/vietc/README.md "$APPDIR/usr/share/doc/vietc/"
else
cp "$PROJECT_ROOT/README.md" "$APPDIR/usr/share/doc/vietc/"
fi
# Systemd service # Systemd service
mkdir -p "$APPDIR/usr/lib/systemd/user" if [ -f "deb-build/usr/lib/systemd/user/vietc.service" ]; then
cp "$PROJECT_ROOT/vietc.service" "$APPDIR/usr/lib/systemd/user/" cp deb-build/usr/lib/systemd/user/vietc.service "$APPDIR/usr/lib/systemd/user/"
else
cp "$PROJECT_ROOT/vietc.service" "$APPDIR/usr/lib/systemd/user/"
fi
# Desktop file in AppDir root # Desktop file in AppDir root
cp "$APPDIR/usr/share/applications/vietc.desktop" "$APPDIR/" if [ -f "deb-build/vietc.desktop" ]; then
cp deb-build/vietc.desktop "$APPDIR/"
else
cp "$APPDIR/usr/share/applications/vietc.desktop" "$APPDIR/"
fi
# Create custom AppRun script # Create custom AppRun script
cat > "$APPDIR/AppRun" << 'EOF' cat > "$APPDIR/AppRun" << 'EOF'
@ -135,8 +117,17 @@ HERE="$(dirname "$(readlink -f "${0}")")"
# Export our bin dir on PATH so child processes can find sibling binaries # Export our bin dir on PATH so child processes can find sibling binaries
export PATH="$HERE/usr/bin:$PATH" export PATH="$HERE/usr/bin:$PATH"
# Build display env prefix for elevation commands.
# Capture from current user env (DISPLAY, XAUTHORITY, WAYLAND_DISPLAY, XDG_RUNTIME_DIR)
# so they are available inside the root daemon. Without this, xdotool/xclip/wtype
# fail silently because sudo/pkexec strip display env vars.
ENV_PREFIX="env"
[ -n "$DISPLAY" ] && ENV_PREFIX="$ENV_PREFIX DISPLAY=$DISPLAY"
[ -n "$XAUTHORITY" ] && ENV_PREFIX="$ENV_PREFIX XAUTHORITY=$XAUTHORITY"
[ -n "$WAYLAND_DISPLAY" ] && ENV_PREFIX="$ENV_PREFIX WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
[ -n "$XDG_RUNTIME_DIR" ] && ENV_PREFIX="$ENV_PREFIX XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
# Start daemon (kill old non-root one first if we have root) # Start daemon (kill old non-root one first if we have root)
SUDO_CMD=""
# Fix Wayland env for root: sudo resets XDG_RUNTIME_DIR, breaking wtype/wl-copy. # Fix Wayland env for root: sudo resets XDG_RUNTIME_DIR, breaking wtype/wl-copy.
# Only set WAYLAND_DISPLAY if the user actually has a Wayland session. # Only set WAYLAND_DISPLAY if the user actually has a Wayland session.
@ -149,7 +140,9 @@ if [ "$(id -u)" = "0" ] && [ -z "$XDG_RUNTIME_DIR" ] && [ -n "$SUDO_USER" ]; the
fi fi
if command -v pkexec >/dev/null && [ -z "$WAYLAND_DISPLAY" ]; then if command -v pkexec >/dev/null && [ -z "$WAYLAND_DISPLAY" ]; then
SUDO_CMD="pkexec" pkill -x vietc 2>/dev/null; sleep 0.5
pkexec $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null &
DAEMON_PID=$!
elif [ -n "$WAYLAND_DISPLAY" ]; then elif [ -n "$WAYLAND_DISPLAY" ]; then
password="" password=""
if command -v kdialog >/dev/null; then if command -v kdialog >/dev/null; then
@ -161,24 +154,12 @@ elif [ -n "$WAYLAND_DISPLAY" ]; then
fi fi
if [ -n "$password" ]; then if [ -n "$password" ]; then
pkill -x vietc 2>/dev/null; sleep 0.5 pkill -x vietc 2>/dev/null; sleep 0.5
echo "$password" | sudo -S env \ echo "$password" | sudo -S $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null &
XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" \
WAYLAND_DISPLAY="$WAYLAND_DISPLAY" \
"$HERE/usr/bin/vietc" >/dev/null &
DAEMON_PID=$! DAEMON_PID=$!
fi fi
elif command -v sudo >/dev/null; then elif command -v sudo >/dev/null; then
SUDO_CMD="sudo"
fi
if [ -n "$SUDO_CMD" ]; then
pkill -x vietc 2>/dev/null; sleep 0.5 pkill -x vietc 2>/dev/null; sleep 0.5
if [ "$(id -u)" = "0" ]; then sudo $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null &
# Already root: run daemon with stderr visible (stdout to /dev/null)
"$HERE/usr/bin/vietc" >/dev/null &
else
"$SUDO_CMD" "$HERE/usr/bin/vietc" >/dev/null &
fi
DAEMON_PID=$! DAEMON_PID=$!
fi fi
@ -212,7 +193,6 @@ echo ""
# Auto build if appimagetool exists # Auto build if appimagetool exists
if [ -f "$SCRIPT_DIR/appimagetool" ]; then if [ -f "$SCRIPT_DIR/appimagetool" ]; then
echo "=== Running appimagetool FUSE build ===" echo "=== Running appimagetool FUSE build ==="
# AppImage inside container/VM sometimes needs --appimage-extract-and-run if FUSE is not mounted
ARCH=x86_64 "$SCRIPT_DIR/appimagetool" --appimage-extract-and-run "$APPDIR" "$SCRIPT_DIR/Viet+-${VERSION}-x86_64.AppImage" ARCH=x86_64 "$SCRIPT_DIR/appimagetool" --appimage-extract-and-run "$APPDIR" "$SCRIPT_DIR/Viet+-${VERSION}-x86_64.AppImage"
elif command -v appimagetool &>/dev/null; then elif command -v appimagetool &>/dev/null; then
echo "=== Running system appimagetool ===" echo "=== Running system appimagetool ==="

View file

@ -15,11 +15,19 @@ pub struct KeyEvent {
impl KeyEvent { impl KeyEvent {
pub fn press(code: u32, value: char) -> Self { pub fn press(code: u32, value: char) -> Self {
Self { code, value, action: KeyAction::Press } Self {
code,
value,
action: KeyAction::Press,
}
} }
pub fn release(code: u32, value: char) -> Self { pub fn release(code: u32, value: char) -> Self {
Self { code, value, action: KeyAction::Release } Self {
code,
value,
action: KeyAction::Release,
}
} }
pub fn is_press(&self) -> bool { pub fn is_press(&self) -> bool {
@ -64,6 +72,12 @@ pub trait KeyInjector {
} }
self.send_string(text) self.send_string(text)
} }
/// Record that Unicode text was pasted via clipboard (for future delete/backspace support)
fn update_pasted_text(&self, _text: &str) -> InjectResult {
// Stub implementation - actual text tracking happens in engine via OutputCommand::Type
InjectResult::Success
}
} }
impl fmt::Display for InjectResult { impl fmt::Display for InjectResult {

View file

@ -58,8 +58,7 @@ impl UinputInjector {
ioctl(fd, UI_DEV_SETUP, &usetup as *const uinput_setup as u64) ioctl(fd, UI_DEV_SETUP, &usetup as *const uinput_setup as u64)
.map_err(|e| format!("UI_DEV_SETUP failed: {}", e))?; .map_err(|e| format!("UI_DEV_SETUP failed: {}", e))?;
ioctl(fd, UI_DEV_CREATE, 0) ioctl(fd, UI_DEV_CREATE, 0).map_err(|e| format!("UI_DEV_CREATE failed: {}", e))?;
.map_err(|e| format!("UI_DEV_CREATE failed: {}", e))?;
// Small delay for device to be ready // Small delay for device to be ready
std::thread::sleep(std::time::Duration::from_millis(10)); std::thread::sleep(std::time::Duration::from_millis(10));
@ -69,7 +68,10 @@ impl UinputInjector {
fn send_uinput_event(&self, type_: u16, code: u16, value: i32) { fn send_uinput_event(&self, type_: u16, code: u16, value: i32) {
let event = input_event { let event = input_event {
time: timeval { tv_sec: 0, tv_usec: 0 }, time: timeval {
tv_sec: 0,
tv_usec: 0,
},
type_, type_,
code, code,
value, value,
@ -78,7 +80,33 @@ impl UinputInjector {
unsafe { unsafe {
let ptr = &event as *const input_event as *const u8; let ptr = &event as *const input_event as *const u8;
let len = std::mem::size_of::<input_event>(); let len = std::mem::size_of::<input_event>();
let _ = libc::write(self.file.as_raw_fd() as libc::c_int, ptr as *const libc::c_void, len); let _ = libc::write(
self.file.as_raw_fd() as libc::c_int,
ptr as *const libc::c_void,
len,
);
}
}
fn send_key_stroke(&self, keycode: u16, shift: bool) {
if shift {
self.send_uinput_event(EV_KEY, 42, 1); // Shift press
self.send_uinput_event(0, 0, 0); // SYN
std::thread::sleep(std::time::Duration::from_millis(2));
}
self.send_uinput_event(EV_KEY, keycode, 1); // Key press
self.send_uinput_event(0, 0, 0); // SYN
std::thread::sleep(std::time::Duration::from_millis(2));
self.send_uinput_event(EV_KEY, keycode, 0); // Key release
self.send_uinput_event(0, 0, 0); // SYN
std::thread::sleep(std::time::Duration::from_millis(2));
if shift {
self.send_uinput_event(EV_KEY, 42, 0); // Shift release
self.send_uinput_event(0, 0, 0); // SYN
std::thread::sleep(std::time::Duration::from_millis(2));
} }
} }
} }
@ -86,44 +114,95 @@ impl UinputInjector {
impl KeyInjector for UinputInjector { impl KeyInjector for UinputInjector {
fn send_backspace(&self) -> InjectResult { fn send_backspace(&self) -> InjectResult {
self.send_uinput_event(EV_KEY, 14, 1); // KEY_BACKSPACE press self.send_uinput_event(EV_KEY, 14, 1); // KEY_BACKSPACE press
self.send_uinput_event(0, 0, 0); // SYN
std::thread::sleep(std::time::Duration::from_millis(2));
self.send_uinput_event(EV_KEY, 14, 0); // KEY_BACKSPACE release self.send_uinput_event(EV_KEY, 14, 0); // KEY_BACKSPACE release
self.send_uinput_event(0, 0, 0); // EV_SYN self.send_uinput_event(0, 0, 0); // SYN
std::thread::sleep(std::time::Duration::from_millis(2));
InjectResult::Success InjectResult::Success
} }
fn send_key_event(&self, keycode: u16, value: i32) -> InjectResult { fn send_key_event(&self, keycode: u16, value: i32) -> InjectResult {
self.send_uinput_event(EV_KEY, keycode, value); self.send_uinput_event(EV_KEY, keycode, value);
self.send_uinput_event(0, 0, 0); self.send_uinput_event(0, 0, 0);
std::thread::sleep(std::time::Duration::from_millis(2));
InjectResult::Success InjectResult::Success
} }
fn send_char(&self, ch: char) -> InjectResult { fn send_char(&self, ch: char) -> InjectResult {
if let Some(keycode) = char_to_linux_keycode(ch) { if let Some(keycode) = char_to_linux_keycode(ch) {
let needs_shift = ch.is_uppercase() || "!@#$%^&*()_+{}|:\"<>?".contains(ch); let needs_shift = ch.is_uppercase() || "!@#$%^&*()_+{}|:\"<>?".contains(ch);
if needs_shift { self.send_key_stroke(keycode, needs_shift);
self.send_uinput_event(EV_KEY, 42, 1); // KEY_LEFTSHIFT eprintln!(
} "[vietc] send_char: ASCII '{}' via uinput",
self.send_uinput_event(EV_KEY, keycode, 1); ch.escape_default()
self.send_uinput_event(EV_KEY, keycode, 0); );
if needs_shift {
self.send_uinput_event(EV_KEY, 42, 0);
}
self.send_uinput_event(0, 0, 0);
return InjectResult::Success; return InjectResult::Success;
} }
// Unicode: copy to clipboard and paste (preserves uinput ordering) // Unicode character: use clipboard fallback for reliable injection
self.paste_string(&ch.to_string()); let text = ch.to_string();
eprintln!(
"[vietc] send_char: Unicode '{}' - using clipboard",
text.escape_default()
);
let copied = self.copy_to_clipboard(&text);
if copied {
eprintln!("[vietc] send_char: clipboard OK, sending Ctrl+V");
self.send_ctrl_v();
eprintln!("[vietc] send_char complete (clipboard)");
return InjectResult::Success;
} else {
eprintln!(
"[vietc] send_char failed for '{}' (clipboard unavailable)",
text.escape_default()
);
// Last resort: try uinput directly (may not work on all systems)
eprintln!("[vietc] send_char fallback: trying direct injection...");
self.paste_string(&text);
}
InjectResult::Success InjectResult::Success
} }
fn send_string(&self, s: &str) -> InjectResult { fn send_string(&self, s: &str) -> InjectResult {
// If all ASCII, use keycodes directly (fast path) // ASCII characters: inject directly via uinput keycodes
if s.chars().all(|c| char_to_linux_keycode(c).is_some()) { let is_ascii = s.chars().all(|c| char_to_linux_keycode(c).is_some());
eprintln!(
"[vietc] send_string: len={}, is_ascii={}",
s.len(),
is_ascii
);
if is_ascii {
eprintln!(
"[vietc] send_string: ASCII '{}' via uinput",
s.escape_default()
);
for ch in s.chars() { for ch in s.chars() {
self.send_char(ch); self.send_char(ch);
} }
return InjectResult::Success;
}
// Unicode text: single clipboard copy + paste (reliable method)
eprintln!(
"[vietc] send_string: Unicode '{}' - using clipboard",
s.escape_default()
);
let copied = self.copy_to_clipboard(s);
if copied {
eprintln!("[vietc] send_string: clipboard OK, sending Ctrl+V");
self.send_ctrl_v();
eprintln!("[vietc] send_string complete (clipboard)");
return InjectResult::Success;
} else { } else {
// Contains Unicode: single clipboard copy + paste via uinput eprintln!(
"[vietc] send_string failed for '{}' (clipboard unavailable)",
s.escape_default()
);
// Last resort: try paste_string (will try clipboard internally)
self.paste_string(s); self.paste_string(s);
} }
InjectResult::Success InjectResult::Success
@ -132,10 +211,21 @@ impl KeyInjector for UinputInjector {
fn inject_replacement(&self, backspaces: usize, text: &str) -> InjectResult { fn inject_replacement(&self, backspaces: usize, text: &str) -> InjectResult {
self.inject_replacement_atomic(backspaces, text) self.inject_replacement_atomic(backspaces, text)
} }
fn flush(&self) -> InjectResult { fn flush(&self) -> InjectResult {
InjectResult::Success InjectResult::Success
} }
/// Record that Unicode text was pasted via clipboard (for future delete/backspace support)
fn update_pasted_text(&self, text: &str) -> InjectResult {
// Text tracking happens through OutputCommand pipeline in daemon
// This is called after clipboard paste to inform engine of pasted content
eprintln!(
"[vietc] update_pasted_text: recorded '{}' (len={})",
text.escape_default(),
text.len()
);
InjectResult::Success
}
} }
impl UinputInjector { impl UinputInjector {
@ -160,7 +250,8 @@ impl UinputInjector {
let pw = libc::getpwuid(uid); let pw = libc::getpwuid(uid);
if !pw.is_null() { if !pw.is_null() {
let name = std::ffi::CStr::from_ptr((*pw).pw_name) let name = std::ffi::CStr::from_ptr((*pw).pw_name)
.to_string_lossy().into_owned(); .to_string_lossy()
.into_owned();
if !name.is_empty() { if !name.is_empty() {
return Some(name); return Some(name);
} }
@ -176,7 +267,8 @@ impl UinputInjector {
let pw = libc::getpwuid(uid); let pw = libc::getpwuid(uid);
if !pw.is_null() { if !pw.is_null() {
let name = std::ffi::CStr::from_ptr((*pw).pw_name) let name = std::ffi::CStr::from_ptr((*pw).pw_name)
.to_string_lossy().into_owned(); .to_string_lossy()
.into_owned();
if !name.is_empty() { if !name.is_empty() {
return Some(name); return Some(name);
} }
@ -247,45 +339,9 @@ impl UinputInjector {
/// Run an external command as the original user if we're root. /// Run an external command as the original user if we're root.
/// Uses native OS setuid/setgid to avoid slow PAM/logging/sudo startup overhead. /// Uses native OS setuid/setgid to avoid slow PAM/logging/sudo startup overhead.
fn run_as_user(program: &str, args: &[&str]) -> std::process::Output { fn run_as_user(program: &str, args: &[&str]) -> std::process::Output {
let is_root = unsafe { libc::getuid() == 0 }; let mut cmd = Self::user_cmd(program);
if is_root { cmd.args(args);
if let Some((uid, gid)) = Self::get_original_uid_gid() { match cmd.output() {
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();
use std::os::unix::process::CommandExt;
let mut cmd = std::process::Command::new(program);
cmd.uid(uid).gid(gid);
if !wayland_display.is_empty() {
cmd.env("WAYLAND_DISPLAY", wayland_display);
}
if !xdg_runtime_dir.is_empty() {
cmd.env("XDG_RUNTIME_DIR", xdg_runtime_dir);
}
if !display.is_empty() {
cmd.env("DISPLAY", display);
}
if let Some(username) = Self::get_original_username() {
cmd.env("HOME", format!("/home/{}", username));
}
cmd.args(args);
match cmd.output() {
Ok(output) => return output,
Err(e) => {
eprintln!("[vietc] Failed to run {} as uid={}: {}", program, uid, e);
return std::process::Output {
status: std::process::ExitStatus::default(),
stdout: vec![],
stderr: format!("{}\n", e).into_bytes(),
};
}
}
}
}
match std::process::Command::new(program).args(args).output() {
Ok(output) => output, Ok(output) => output,
Err(e) => { Err(e) => {
eprintln!("[vietc] Failed to run {}: {}", program, e); eprintln!("[vietc] Failed to run {}: {}", program, e);
@ -304,9 +360,22 @@ impl UinputInjector {
/// best available method: ydotool (uinput) for ASCII, xdotool (X11) or /// best available method: ydotool (uinput) for ASCII, xdotool (X11) or
/// clipboard for Unicode. /// clipboard for Unicode.
fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult { fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult {
let is_ascii = text.chars().all(|c| char_to_linux_keycode(c).is_some()); eprintln!(
"[vietc] inject_atomic: ASCII={}",
text.chars().all(|c| char_to_linux_keycode(c).is_some())
);
eprintln!(
"[vietc] inject_atomic: ASCII check (raw_bytes={} chars={} text='{}')",
text.len(),
text.chars().count(),
text.escape_default()
);
if is_ascii { if text.chars().all(|c| char_to_linux_keycode(c).is_some()) {
eprintln!(
"[vietc] ASCII injection using uinput (backspaces={})",
backspaces
);
if backspaces > 0 { if backspaces > 0 {
for _ in 0..backspaces { for _ in 0..backspaces {
let _ = self.send_backspace(); let _ = self.send_backspace();
@ -315,171 +384,221 @@ impl UinputInjector {
for ch in text.chars() { for ch in text.chars() {
let _ = self.send_char(ch); let _ = self.send_char(ch);
} }
eprintln!("[vietc] ASCII injection complete");
return InjectResult::Success; return InjectResult::Success;
} }
// It is Unicode. We must use a single unified channel. // Unicode text: use xdotool directly (X11/XWayland) or wtype (Wayland)
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
static HAS_WTYPE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
static HAS_XDOTOOL: std::sync::OnceLock<bool> = std::sync::OnceLock::new(); static HAS_XDOTOOL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
let has_xdotool = if is_wayland {
if is_wayland { false
let has_wtype = *HAS_WTYPE.get_or_init(|| {
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 { } else {
let has_xdotool = *HAS_XDOTOOL.get_or_init(|| { *HAS_XDOTOOL.get_or_init(|| {
std::process::Command::new("which") std::process::Command::new("which")
.arg("xdotool") .arg("xdotool")
.output() .output()
.map(|o| o.status.success()) .map(|o| o.status.success())
.unwrap_or(false) .unwrap_or(false)
}); })
};
if has_xdotool { static HAS_WTYPE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
let mut args = Vec::new(); let has_wtype = if !is_wayland {
if backspaces > 0 { false
args.push("key"); } else {
for _ in 0..backspaces { *HAS_WTYPE.get_or_init(|| {
args.push("BackSpace"); std::process::Command::new("which")
} .arg("wtype")
} .output()
if !text.is_empty() { .map(|o| o.status.success())
args.push("type"); .unwrap_or(false)
args.push("--clearmodifiers"); })
args.push(text); };
}
let output = Self::run_as_user("xdotool", &args); if is_wayland {
if output.status.success() { if has_wtype {
return InjectResult::Success; eprintln!(
} "[vietc] Unicode detected ({} chars), injecting via wtype",
eprintln!("[vietc] xdotool inject failed: {}", String::from_utf8_lossy(&output.stderr).trim()); text.chars().count()
);
} else {
eprintln!(
"[vietc] Wayland session detected, using clipboard fallback instead of xdotool/wtype"
);
} }
} else {
eprintln!(
"[vietc] Unicode detected ({} chars), injecting via xdotool",
text.chars().count()
);
} }
// Fallback: Clipboard copy + paste. if is_wayland && has_wtype {
// This is safe because both backspaces and Ctrl+V are injected into the SAME uinput device. let mut args = Vec::new();
if backspaces > 0 {
for _ in 0..backspaces {
args.push("-k");
args.push("BackSpace");
}
}
if !text.is_empty() {
args.push("--");
args.push(text);
}
eprintln!("[vietc] Running: wtype {}", args.join(" "));
let output = Self::run_as_user("wtype", &args);
if output.status.success() {
eprintln!("[vietc] wtype success - Unicode text injected correctly");
return InjectResult::Success;
}
eprintln!(
"[vietc] wtype failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
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(text); // xdotool handles UTF-8 text directly
}
eprintln!("[vietc] Running: xdotool {}", args.join(" "));
let output = Self::run_as_user("xdotool", &args);
if output.status.success() {
eprintln!("[vietc] xdotool success - Unicode text injected correctly");
return InjectResult::Success;
}
eprintln!(
"[vietc] xdotool failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
} else if !is_wayland {
eprintln!("[vietc] xdotool not found, trying clipboard fallback...");
}
// Final fallback: clipboard copy + Ctrl+V via uinput device
eprintln!("[vietc] All direct tools failed, using clipboard fallback...");
// Primary choice for Unicode: clipboard copy + Ctrl+V via uinput device
let copied = self.copy_to_clipboard(text); let copied = self.copy_to_clipboard(text);
if copied { if copied {
eprintln!(
"[vietc] Clipboard fallback: copied '{}' and will Ctrl+V",
text
);
if backspaces > 0 { if backspaces > 0 {
for _ in 0..backspaces { for _ in 0..backspaces {
let _ = self.send_backspace(); let _ = self.send_backspace();
} }
} }
eprintln!("[vietc] Sending Ctrl+V");
self.send_ctrl_v(); self.send_ctrl_v();
InjectResult::Success // Record pasted text for future delete/backspace operations
} else { let output = Self::run_as_user("vietc", &["update-pasted", "-text", text]);
eprintln!("[vietc] clipboard copy failed during fallback"); if output.status.success() {
// Absolute last resort: try uinput backspaces followed by individual unicode paste_string eprintln!("[vietc] update_pasted_text success");
if backspaces > 0 { } else {
for _ in 0..backspaces { eprintln!("[vietc] update_pasted_text call ignored (not critical)");
let _ = self.send_backspace();
}
} }
self.paste_string(text); eprintln!("[vietc] Clipboard injection complete");
InjectResult::Success return InjectResult::Success;
} else {
eprintln!("[vietc] clipboard copy failed, trying individual char paste_string...");
} }
// Absolute last resort: try uinput backspaces followed by individual unicode chars via send_char
eprintln!("[vietc] Last resort: pasting '{}' char-by-char", text);
if backspaces > 0 {
for _ in 0..backspaces {
let _ = self.send_backspace();
}
}
for ch in text.chars() {
let _ = self.send_char(ch);
}
eprintln!("[vietc] Char-by-char injection complete");
InjectResult::Success
} }
/// Copy text to clipboard and paste via Ctrl+V through our uinput device. /// Copy text to clipboard and paste via Ctrl+V through our uinput device.
/// Only used as a last resort if Wayland/X11 direct typing tools are /// Only used as a last resort if Wayland/X11 direct typing tools are unavailable.
/// unavailable. Prefers ydotool (uinput, works everywhere) to avoid /// Tries xdotool first (X11/XWayland), then clipboard fallback.
/// clipboard pollution.
fn paste_string(&self, s: &str) { fn paste_string(&self, s: &str) {
// Try ydotool first (uinput-based, no display server needed). let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
let ydotool_result = std::process::Command::new("ydotool") if is_wayland {
.args(["type", s]) eprintln!("[vietc] paste_string: trying wtype...");
.output(); let output = Self::run_as_user("wtype", &["--", s]);
if let Ok(output) = ydotool_result {
if output.status.success() { if output.status.success() {
eprintln!("[vietc] ydotool OK"); eprintln!("[vietc] paste_string: wtype success");
return; return;
} }
let stderr = String::from_utf8_lossy(&output.stderr); eprintln!("[vietc] paste_string: wtype failed, trying clipboard...");
if !stderr.is_empty() { } else {
eprintln!("[vietc] ydotool failed: {}", stderr.trim()); // Try xdotool first (works on X11 and XWayland for UTF-8)
eprintln!("[vietc] paste_string: trying xdotool...");
let output = Self::run_as_user("xdotool", &["type", s]);
if output.status.success() {
eprintln!("[vietc] paste_string: xdotool success");
// Record pasted text for future delete/backspace operations
let _ = Self::run_as_user("vietc", &["update-pasted", "-text", s]);
return;
} }
} eprintln!("[vietc] paste_string: xdotool failed, trying clipboard...");
eprintln!("[vietc] ydotool failed, trying xdotool...");
// Try xdotool (X11): needs DISPLAY, run through run_as_user
eprintln!("[vietc] trying xdotool...");
let output = Self::run_as_user("xdotool", &["type", "--clearmodifiers", s]);
if output.status.success() {
eprintln!("[vietc] xdotool OK");
return;
}
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
eprintln!("[vietc] xdotool failed: {}", stderr.trim());
} }
// Try wtype (Wayland-native): needs Wayland session, run through run_as_user // Clipboard fallback: copy + paste via our uinput device
eprintln!("[vietc] xdotool failed, trying wtype...");
let output = Self::run_as_user("wtype", &[s]);
if output.status.success() {
eprintln!("[vietc] wtype OK");
return;
}
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
eprintln!("[vietc] wtype failed: {}", stderr.trim());
}
// Clipboard fallback: copy + paste via our uinput
eprintln!("[vietc] wtype failed, trying clipboard paste...");
let copied = self.copy_to_clipboard(s); let copied = self.copy_to_clipboard(s);
if copied { if copied {
eprintln!("[vietc] clipboard OK, sending Ctrl+V"); eprintln!("[vietc] paste_string: clipboard OK, sending Ctrl+V");
self.send_ctrl_v(); self.send_ctrl_v();
return; return;
} }
eprintln!("[vietc] WARNING: No injection method works for '{}'!", s); eprintln!(
"[vietc] WARNING: No injection method works for '{}'!",
s.escape_default()
);
} }
/// Build a command to run as the original user with display environment. /// Build a command to run as the original user with display environment.
fn user_cmd(program: &str) -> std::process::Command { fn user_cmd(program: &str) -> std::process::Command {
let is_root = unsafe { libc::getuid() == 0 }; let is_root = unsafe { libc::getuid() == 0 };
if is_root { if is_root {
if let Some(original_user) = Self::get_original_username() { if let Some((uid, gid)) = Self::get_original_uid_gid() {
let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default(); 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 xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default();
let display = std::env::var("DISPLAY").unwrap_or_default(); let display = std::env::var("DISPLAY").unwrap_or_default();
let mut cmd = std::process::Command::new("sudo"); let xauthority = std::env::var("XAUTHORITY").unwrap_or_default();
cmd.args(["-u", &original_user, "env"]);
use std::os::unix::process::CommandExt;
let mut cmd = std::process::Command::new(program);
cmd.uid(uid).gid(gid);
if !wayland_display.is_empty() { if !wayland_display.is_empty() {
cmd.arg(format!("WAYLAND_DISPLAY={}", wayland_display)); cmd.env("WAYLAND_DISPLAY", wayland_display);
} }
if !xdg_runtime_dir.is_empty() { if !xdg_runtime_dir.is_empty() {
cmd.arg(format!("XDG_RUNTIME_DIR={}", xdg_runtime_dir)); cmd.env("XDG_RUNTIME_DIR", xdg_runtime_dir);
} }
if !display.is_empty() { if !display.is_empty() {
cmd.arg(format!("DISPLAY={}", display)); cmd.env("DISPLAY", display);
}
if !xauthority.is_empty() {
cmd.env("XAUTHORITY", xauthority);
}
if let Some(username) = Self::get_original_username() {
cmd.env("HOME", format!("/home/{}", username));
} }
cmd.arg(program);
return cmd; return cmd;
} }
} }
@ -508,7 +627,10 @@ impl UinputInjector {
eprintln!("[vietc] clipboard: wl-copy OK"); eprintln!("[vietc] clipboard: wl-copy OK");
return true; return true;
} }
eprintln!("[vietc] clipboard: wl-copy failed (exit={:?})", status.code()); eprintln!(
"[vietc] clipboard: wl-copy failed (exit={:?})",
status.code()
);
} else if let Err(ref e) = result { } else if let Err(ref e) = result {
eprintln!("[vietc] clipboard: wl-copy error: {}", e); eprintln!("[vietc] clipboard: wl-copy error: {}", e);
} }
@ -550,13 +672,22 @@ impl UinputInjector {
/// Send Ctrl+V through our uinput device. /// Send Ctrl+V through our uinput device.
fn send_ctrl_v(&self) { fn send_ctrl_v(&self) {
self.send_uinput_event(EV_KEY, 29, 1); // KEY_LEFTCTRL self.send_uinput_event(EV_KEY, 29, 1); // KEY_LEFTCTRL press
self.send_uinput_event(EV_KEY, 47, 1); // KEY_V self.send_uinput_event(0, 0, 0); // SYN
self.send_uinput_event(EV_KEY, 47, 0); std::thread::sleep(std::time::Duration::from_millis(5));
self.send_uinput_event(EV_KEY, 29, 0);
self.send_uinput_event(0, 0, 0);
}
self.send_uinput_event(EV_KEY, 47, 1); // KEY_V press
self.send_uinput_event(0, 0, 0); // SYN
std::thread::sleep(std::time::Duration::from_millis(5));
self.send_uinput_event(EV_KEY, 47, 0); // KEY_V release
self.send_uinput_event(0, 0, 0); // SYN
std::thread::sleep(std::time::Duration::from_millis(5));
self.send_uinput_event(EV_KEY, 29, 0); // KEY_LEFTCTRL release
self.send_uinput_event(0, 0, 0); // SYN
std::thread::sleep(std::time::Duration::from_millis(10));
}
} }
impl Drop for UinputInjector { impl Drop for UinputInjector {
@ -617,7 +748,11 @@ fn char_to_linux_keycode(ch: char) -> Option<u16> {
} }
// ioctl helper // ioctl helper
fn ioctl(fd: std::os::unix::io::RawFd, request: u64, arg: u64) -> Result<i32, Box<dyn std::error::Error>> { fn ioctl(
fd: std::os::unix::io::RawFd,
request: u64,
arg: u64,
) -> Result<i32, Box<dyn std::error::Error>> {
unsafe { unsafe {
let result = libc::ioctl(fd, request, arg); let result = libc::ioctl(fd, request, arg);
if result < 0 { if result < 0 {

View file

@ -68,10 +68,7 @@ impl Keysym {
} }
pub fn is_modifier(self) -> bool { pub fn is_modifier(self) -> bool {
matches!( matches!(self.0, 0xffe1..=0xffee)
self.0,
0xffe1..=0xffee
)
} }
} }
@ -219,8 +216,16 @@ impl WaylandIMContext {
// Shift+digit produces symbol // Shift+digit produces symbol
if mods.shift && base.is_ascii_digit() { if mods.shift && base.is_ascii_digit() {
let shifted = match base { let shifted = match base {
'1' => '!', '2' => '@', '3' => '#', '4' => '$', '5' => '%', '1' => '!',
'6' => '^', '7' => '&', '8' => '*', '9' => '(', '0' => ')', '2' => '@',
'3' => '#',
'4' => '$',
'5' => '%',
'6' => '^',
'7' => '&',
'8' => '*',
'9' => '(',
'0' => ')',
_ => return Some(base), _ => return Some(base),
}; };
return Some(shifted); return Some(shifted);

View file

@ -57,11 +57,24 @@ impl X11Lib {
return Err("Failed to load libXtst.so.6".into()); return Err("Failed to load libXtst.so.6".into());
} }
let x_open_display = std::mem::transmute(dlsym(x11_handle, b"XOpenDisplay\0".as_ptr() as *const c_char)); let x_open_display = std::mem::transmute(dlsym(
let x_close_display = std::mem::transmute(dlsym(x11_handle, b"XCloseDisplay\0".as_ptr() as *const c_char)); x11_handle,
let x_default_root_window = std::mem::transmute(dlsym(x11_handle, b"XDefaultRootWindow\0".as_ptr() as *const c_char)); b"XOpenDisplay\0".as_ptr() as *const c_char,
let x_flush = std::mem::transmute(dlsym(x11_handle, b"XFlush\0".as_ptr() as *const c_char)); ));
let x_test_fake_key_event = std::mem::transmute(dlsym(xtst_handle, b"XTestFakeKeyEvent\0".as_ptr() as *const c_char)); let x_close_display = std::mem::transmute(dlsym(
x11_handle,
b"XCloseDisplay\0".as_ptr() as *const c_char,
));
let x_default_root_window = std::mem::transmute(dlsym(
x11_handle,
b"XDefaultRootWindow\0".as_ptr() as *const c_char,
));
let x_flush =
std::mem::transmute(dlsym(x11_handle, b"XFlush\0".as_ptr() as *const c_char));
let x_test_fake_key_event = std::mem::transmute(dlsym(
xtst_handle,
b"XTestFakeKeyEvent\0".as_ptr() as *const c_char,
));
Ok(Self { Ok(Self {
x11_handle, x11_handle,
@ -91,43 +104,96 @@ const X11_KEYCODE_OFFSET: u32 = 8;
// X11 keycodes for common ASCII characters // X11 keycodes for common ASCII characters
fn char_to_keycode(ch: char) -> Option<(u32, bool)> { fn char_to_keycode(ch: char) -> Option<(u32, bool)> {
match ch { match ch {
'a' => Some((30 + X11_KEYCODE_OFFSET, false)), 'b' => Some((48 + X11_KEYCODE_OFFSET, false)), 'a' => Some((30, false)),
'c' => Some((46 + X11_KEYCODE_OFFSET, false)), 'd' => Some((32 + X11_KEYCODE_OFFSET, false)), 'b' => Some((48, false)),
'e' => Some((18 + X11_KEYCODE_OFFSET, false)), 'f' => Some((33 + X11_KEYCODE_OFFSET, false)), 'c' => Some((46, false)),
'g' => Some((34 + X11_KEYCODE_OFFSET, false)), 'h' => Some((35 + X11_KEYCODE_OFFSET, false)), 'd' => Some((32, false)),
'i' => Some((23 + X11_KEYCODE_OFFSET, false)), 'j' => Some((36 + X11_KEYCODE_OFFSET, false)), 'e' => Some((18, false)),
'k' => Some((37 + X11_KEYCODE_OFFSET, false)), 'l' => Some((38 + X11_KEYCODE_OFFSET, false)), 'f' => Some((33, false)),
'm' => Some((50 + X11_KEYCODE_OFFSET, false)), 'n' => Some((49 + X11_KEYCODE_OFFSET, false)), 'g' => Some((34, false)),
'o' => Some((24 + X11_KEYCODE_OFFSET, false)), 'p' => Some((25 + X11_KEYCODE_OFFSET, false)), 'h' => Some((35, false)),
'q' => Some((16 + X11_KEYCODE_OFFSET, false)), 'r' => Some((19 + X11_KEYCODE_OFFSET, false)), 'i' => Some((23, false)),
's' => Some((31 + X11_KEYCODE_OFFSET, false)), 't' => Some((20 + X11_KEYCODE_OFFSET, false)), 'j' => Some((36, false)),
'u' => Some((22 + X11_KEYCODE_OFFSET, false)), 'v' => Some((47 + X11_KEYCODE_OFFSET, false)), 'k' => Some((37, false)),
'w' => Some((17 + X11_KEYCODE_OFFSET, false)), 'x' => Some((45 + X11_KEYCODE_OFFSET, false)), 'l' => Some((38, false)),
'y' => Some((21 + X11_KEYCODE_OFFSET, false)), 'z' => Some((44 + X11_KEYCODE_OFFSET, false)), 'm' => Some((50, false)),
'A' => Some((30 + X11_KEYCODE_OFFSET, true)), 'B' => Some((48 + X11_KEYCODE_OFFSET, true)), 'n' => Some((49, false)),
'C' => Some((46 + X11_KEYCODE_OFFSET, true)), 'D' => Some((32 + X11_KEYCODE_OFFSET, true)), 'o' => Some((24, false)),
'E' => Some((18 + X11_KEYCODE_OFFSET, true)), 'F' => Some((33 + X11_KEYCODE_OFFSET, true)), 'p' => Some((25, false)),
'G' => Some((34 + X11_KEYCODE_OFFSET, true)), 'H' => Some((35 + X11_KEYCODE_OFFSET, true)), 'q' => Some((16, false)),
'I' => Some((23 + X11_KEYCODE_OFFSET, true)), 'J' => Some((36 + X11_KEYCODE_OFFSET, true)), 'r' => Some((19, false)),
'K' => Some((37 + X11_KEYCODE_OFFSET, true)), 'L' => Some((38 + X11_KEYCODE_OFFSET, true)), 's' => Some((31, false)),
'M' => Some((50 + X11_KEYCODE_OFFSET, true)), 'N' => Some((49 + X11_KEYCODE_OFFSET, true)), 't' => Some((20, false)),
'O' => Some((24 + X11_KEYCODE_OFFSET, true)), 'P' => Some((25 + X11_KEYCODE_OFFSET, true)), 'u' => Some((22, false)),
'Q' => Some((16 + X11_KEYCODE_OFFSET, true)), 'R' => Some((19 + X11_KEYCODE_OFFSET, true)), 'v' => Some((47, false)),
'S' => Some((31 + X11_KEYCODE_OFFSET, true)), 'T' => Some((20 + X11_KEYCODE_OFFSET, true)), 'w' => Some((17, false)),
'U' => Some((22 + X11_KEYCODE_OFFSET, true)), 'V' => Some((47 + X11_KEYCODE_OFFSET, true)), 'x' => Some((45, false)),
'W' => Some((17 + X11_KEYCODE_OFFSET, true)), 'X' => Some((45 + X11_KEYCODE_OFFSET, true)), 'y' => Some((21, false)),
'Y' => Some((21 + X11_KEYCODE_OFFSET, true)), 'Z' => Some((44 + X11_KEYCODE_OFFSET, true)), 'z' => Some((44, false)),
'0' => Some((11 + X11_KEYCODE_OFFSET, false)), '1' => Some((2 + X11_KEYCODE_OFFSET, false)), 'A' => Some((30, true)),
'2' => Some((3 + X11_KEYCODE_OFFSET, false)), '3' => Some((4 + X11_KEYCODE_OFFSET, false)), 'B' => Some((48, true)),
'4' => Some((5 + X11_KEYCODE_OFFSET, false)), '5' => Some((6 + X11_KEYCODE_OFFSET, false)), 'C' => Some((46, true)),
'6' => Some((7 + X11_KEYCODE_OFFSET, false)), '7' => Some((8 + X11_KEYCODE_OFFSET, false)), 'D' => Some((32, true)),
'8' => Some((9 + X11_KEYCODE_OFFSET, false)), '9' => Some((10 + X11_KEYCODE_OFFSET, false)), 'E' => Some((18, true)),
' ' => Some((57 + X11_KEYCODE_OFFSET, false)), '.' => Some((52 + X11_KEYCODE_OFFSET, false)), 'F' => Some((33, true)),
',' => Some((51 + X11_KEYCODE_OFFSET, false)), '-' => Some((12 + X11_KEYCODE_OFFSET, false)), 'G' => Some((34, true)),
'=' => Some((13 + X11_KEYCODE_OFFSET, false)), ';' => Some((39 + X11_KEYCODE_OFFSET, false)), 'H' => Some((35, true)),
'\'' => Some((40 + X11_KEYCODE_OFFSET, false)), '/' => Some((53 + X11_KEYCODE_OFFSET, false)), 'I' => Some((23, true)),
'\\' => Some((43 + X11_KEYCODE_OFFSET, false)), '`' => Some((41 + X11_KEYCODE_OFFSET, false)), 'J' => Some((36, true)),
'[' => Some((26 + X11_KEYCODE_OFFSET, false)), ']' => Some((27 + X11_KEYCODE_OFFSET, false)), 'K' => Some((37, true)),
'L' => Some((38, true)),
'M' => Some((50, true)),
'N' => Some((49, true)),
'O' => Some((24, true)),
'P' => Some((25, true)),
'Q' => Some((16, true)),
'R' => Some((19, true)),
'S' => Some((31, true)),
'T' => Some((20, true)),
'U' => Some((22, true)),
'V' => Some((47, true)),
'W' => Some((17, true)),
'X' => Some((45, true)),
'Y' => Some((21, true)),
'Z' => Some((44, true)),
'0' => Some((11, false)),
'1' => Some((2, false)),
'2' => Some((3, false)),
'3' => Some((4, false)),
'4' => Some((5, false)),
'5' => Some((6, false)),
'6' => Some((7, false)),
'7' => Some((8, false)),
'8' => Some((9, false)),
'9' => Some((10, false)),
' ' => Some((57, false)),
'.' => Some((52, false)),
',' => Some((51, false)),
'-' => Some((12, false)),
'=' => Some((13, false)),
';' => Some((39, false)),
'\'' => Some((40, false)),
'/' => Some((53, false)),
'\\' => Some((43, false)),
'`' => Some((41, false)),
'0' => Some((11, false)),
'1' => Some((2, false)),
'2' => Some((3, false)),
'3' => Some((4, false)),
'4' => Some((5, false)),
'5' => Some((6, false)),
'6' => Some((7, false)),
'7' => Some((8, false)),
'8' => Some((9, false)),
'9' => Some((10, false)),
' ' => Some((57, false)),
'.' => Some((52, false)),
',' => Some((51, false)),
'-' => Some((12, false)),
'=' => Some((13, false)),
';' => Some((39, false)),
'\'' => Some((40, false)),
'/' => Some((53, false)),
_ => None, _ => None,
} }
} }
@ -151,7 +217,11 @@ impl X11Injector {
return Err("Cannot open X11 display. Is DISPLAY set?".into()); return Err("Cannot open X11 display. Is DISPLAY set?".into());
} }
let window = (lib.x_default_root_window)(display); let window = (lib.x_default_root_window)(display);
Ok(Self { lib, display, window }) Ok(Self {
lib,
display,
window,
})
} }
} }
@ -290,7 +360,8 @@ impl KeyInjector for X11Injector {
let mut clipboard_cmd = std::process::Command::new("xclip"); let mut clipboard_cmd = std::process::Command::new("xclip");
clipboard_cmd.args(["-selection", "clipboard"]); clipboard_cmd.args(["-selection", "clipboard"]);
clipboard_cmd.stdin(std::process::Stdio::piped()); clipboard_cmd.stdin(std::process::Stdio::piped());
let copied = clipboard_cmd.spawn() let copied = clipboard_cmd
.spawn()
.and_then(|mut child| { .and_then(|mut child| {
use std::io::Write; use std::io::Write;
child.stdin.take().unwrap().write_all(text.as_bytes())?; child.stdin.take().unwrap().write_all(text.as_bytes())?;
@ -326,15 +397,27 @@ impl KeyInjector for X11Injector {
InjectResult::Success InjectResult::Success
} }
} }
fn flush(&self) -> InjectResult { fn flush(&self) -> InjectResult {
unsafe { (self.lib.x_flush)(self.display); } unsafe {
(self.lib.x_flush)(self.display);
}
InjectResult::Success
}
/// Record that Unicode text was pasted via clipboard (for future delete/backspace support)
fn update_pasted_text(&self, _text: &str) -> InjectResult {
eprintln!(
"[vietc] X11 update_pasted_text: recorded text (len={})",
_text.len()
);
InjectResult::Success InjectResult::Success
} }
} }
impl Drop for X11Injector { impl Drop for X11Injector {
fn drop(&mut self) { fn drop(&mut self) {
unsafe { (self.lib.x_close_display)(self.display); } unsafe {
(self.lib.x_close_display)(self.display);
}
} }
} }

View file

@ -69,13 +69,27 @@ pub struct Config {
pub debug: bool, pub debug: bool,
} }
fn default_input_method() -> String { "telex".into() } fn default_input_method() -> String {
fn default_toggle_key() -> String { "space".into() } "telex".into()
fn default_start_enabled() -> bool { true } }
fn default_grab() -> bool { true } fn default_toggle_key() -> String {
fn default_true() -> bool { true } "space".into()
fn default_false() -> bool { false } }
fn default_restore_keys() -> Vec<String> { vec!["space".into(), "escape".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 { impl Default for Config {
fn default() -> Self { fn default() -> Self {
@ -92,7 +106,6 @@ impl Default for Config {
} }
} }
impl Config { impl Config {
pub fn load() -> Self { pub fn load() -> Self {
for path in config_paths() { for path in config_paths() {
@ -142,7 +155,10 @@ fn config_paths() -> Vec<PathBuf> {
pub fn is_autostart_installed() -> bool { pub fn is_autostart_installed() -> bool {
if let Some(config_dir) = dirs::config_dir() { if let Some(config_dir) = dirs::config_dir() {
config_dir.join("autostart").join("vietc-tray.desktop").exists() config_dir
.join("autostart")
.join("vietc-tray.desktop")
.exists()
} else { } else {
false false
} }
@ -164,14 +180,12 @@ pub fn install_autostart() {
let desktop_file = autostart_dir.join("vietc-tray.desktop"); let desktop_file = autostart_dir.join("vietc-tray.desktop");
let _ = fs::create_dir_all(&autostart_dir); let _ = fs::create_dir_all(&autostart_dir);
let exec_path = std::env::var("APPIMAGE") let exec_path = std::env::var("APPIMAGE").ok().unwrap_or_else(|| {
.ok() std::env::current_exe()
.unwrap_or_else(|| { .unwrap_or_else(|_| PathBuf::from("vietc-tray"))
std::env::current_exe() .to_string_lossy()
.unwrap_or_else(|_| PathBuf::from("vietc-tray")) .into_owned()
.to_string_lossy() });
.into_owned()
});
let content = format!( let content = format!(
"[Desktop Entry]\n\ "[Desktop Entry]\n\

View file

@ -1,5 +1,5 @@
use ksni::{Tray, MenuItem, menu::*};
use crate::config; use crate::config;
use ksni::{menu::*, MenuItem, Tray};
fn write_status(state: &str) { fn write_status(state: &str) {
if let Some(config_dir) = dirs::config_dir() { if let Some(config_dir) = dirs::config_dir() {
@ -16,7 +16,11 @@ fn read_status() -> String {
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.unwrap_or_else(|_| { .unwrap_or_else(|_| {
let cfg = config::Config::load(); let cfg = config::Config::load();
if cfg.start_enabled { "vn".into() } else { "en".into() } if cfg.start_enabled {
"vn".into()
} else {
"en".into()
}
}) })
} }
@ -35,9 +39,11 @@ fn draw_line(data: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32, color: [u8; 4]
loop { loop {
if x >= 0 && x < 32 && y >= 0 && y < 32 { if x >= 0 && x < 32 && y >= 0 && y < 32 {
let idx = ((y * 32 + x) * 4) as usize; let idx = ((y * 32 + x) * 4) as usize;
data[idx..idx+4].copy_from_slice(&color); data[idx..idx + 4].copy_from_slice(&color);
}
if x == x1 && y == y1 {
break;
} }
if x == x1 && y == y1 { break; }
let e2 = 2 * err; let e2 = 2 * err;
if e2 > -dy { if e2 > -dy {
err -= dy; err -= dy;
@ -51,37 +57,47 @@ fn draw_line(data: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32, color: [u8; 4]
} }
fn ensure_icons() { fn ensure_icons() {
let Some(config_dir) = dirs::config_dir() else { return }; // SVG content for Viet+ icons
let icons_dir = config_dir.join("vietc").join("icons"); let svg_vn = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
let theme_dir = icons_dir.join("hicolor").join("scalable").join("apps"); <rect x="8" y="8" width="112" height="112" rx="24" fill="#e02424"/>
let _ = std::fs::create_dir_all(&theme_dir); <text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">VN</text>
let vn_flat = icons_dir.join("vietc-vn.svg");
let en_flat = icons_dir.join("vietc-en.svg");
let vn_theme = theme_dir.join("vietc-vn.svg");
let en_theme = theme_dir.join("vietc-en.svg");
let svg_vn = 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>"##; </svg>"##;
let svg_en = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"> let svg_en = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect x="2" y="2" width="28" height="28" rx="6" fill="#4b5563"/> <rect x="8" y="8" width="112" height="112" rx="24" 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> <text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">EN</text>
</svg>"##; </svg>"##;
if !vn_flat.exists() { // Write to standard user theme path (for Wayland compositors)
let _ = std::fs::write(&vn_flat, svg_vn); let home = dirs::home_dir().map(|d| d.join(".local/share/icons"));
if let Some(home_icons) = &home {
let _ = std::fs::create_dir_all(&home_icons);
let vn_path = home_icons.join("vietc-vn.svg");
let en_path = home_icons.join("vietc-en.svg");
if !vn_path.exists() {
let _ = std::fs::write(&vn_path, svg_vn);
}
if !en_path.exists() {
let _ = std::fs::write(&en_path, svg_en);
}
} }
if !en_flat.exists() {
let _ = std::fs::write(&en_flat, svg_en); // Also write to config dir for AppImage compatibility (fallback)
} let config_dir = dirs::config_dir();
if !vn_theme.exists() { if let Some(config_dir) = &config_dir {
let _ = std::fs::write(&vn_theme, svg_vn); let icons_dir = config_dir.join("vietc").join("icons");
} let _ = std::fs::create_dir_all(&icons_dir);
if !en_theme.exists() {
let _ = std::fs::write(&en_theme, svg_en); let vn_theme = icons_dir.join("hicolor/scalable/apps/vietc-vn.svg");
let en_theme = icons_dir.join("hicolor/scalable/apps/vietc-en.svg");
if !vn_theme.exists() {
let _ = std::fs::write(&vn_theme, svg_vn);
}
if !en_theme.exists() {
let _ = std::fs::write(&en_theme, svg_en);
}
} }
} }
@ -118,10 +134,16 @@ impl VietTray {
let handle = handle.clone(); let handle = handle.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
if verbose { if verbose {
show_notification("Checking for updates...", "Contacting git.khoavo.myds.me..."); show_notification(
"Checking for updates...",
"Contacting git.khoavo.myds.me...",
);
} }
let output = std::process::Command::new("curl") let output = std::process::Command::new("curl")
.args(["-s", "https://git.khoavo.myds.me/api/v1/repos/vndangkhoa/vietc/releases"]) .args([
"-s",
"https://git.khoavo.myds.me/api/v1/repos/vndangkhoa/vietc/releases",
])
.output(); .output();
match output { match output {
@ -156,8 +178,14 @@ impl VietTray {
let handle = handle.clone(); let handle = handle.clone();
let _ = handle.update(|t| t.updating = true); let _ = handle.update(|t| t.updating = true);
std::thread::spawn(move || { std::thread::spawn(move || {
show_notification("Downloading update...", &format!("Updating Viet+ to {}...", release.tag_name)); show_notification(
let appimage_asset = release.assets.iter().find(|a| a.name.ends_with(".AppImage")); "Downloading update...",
&format!("Updating Viet+ to {}...", release.tag_name),
);
let appimage_asset = release
.assets
.iter()
.find(|a| a.name.ends_with(".AppImage"));
if let Some(asset) = appimage_asset { if let Some(asset) = appimage_asset {
if let Ok(appimage_path) = std::env::var("APPIMAGE") { if let Ok(appimage_path) = std::env::var("APPIMAGE") {
let temp_path = format!("{}.tmp-update", appimage_path); let temp_path = format!("{}.tmp-update", appimage_path);
@ -167,11 +195,14 @@ impl VietTray {
match status { match status {
Ok(s) if s.success() => { Ok(s) if s.success() => {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
if let Ok(_) = std::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o755)) { if let Ok(_) = std::fs::set_permissions(
&temp_path,
std::fs::Permissions::from_mode(0o755),
) {
if let Ok(_) = std::fs::rename(&temp_path, &appimage_path) { if let Ok(_) = std::fs::rename(&temp_path, &appimage_path) {
show_notification( show_notification(
"Update Succeeded", "Update Succeeded",
"Viet+ has been updated! Please restart the application." "Viet+ has been updated! Please restart the application.",
); );
let _ = handle.update(|t| { let _ = handle.update(|t| {
t.updating = false; t.updating = false;
@ -191,7 +222,7 @@ impl VietTray {
.status(); .status();
show_notification( show_notification(
"Opening Releases Page", "Opening Releases Page",
"Please download the update manually." "Please download the update manually.",
); );
} }
} else { } else {
@ -203,17 +234,26 @@ impl VietTray {
} }
impl Tray for VietTray { impl Tray for VietTray {
fn id(&self) -> String { "io.github.vietc.Tray".into() } fn id(&self) -> String {
fn title(&self) -> String { "Viet+".into() } "io.github.vietc.Tray".into()
}
fn title(&self) -> String {
"Viet+".into()
}
fn icon_name(&self) -> String { fn icon_name(&self) -> String {
if self.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 { fn icon_theme_path(&self) -> String {
dirs::config_dir() // Use XDG user theme path for icons
.map(|d| d.join("vietc").join("icons").to_string_lossy().into_owned()) dirs::home_dir()
.unwrap_or_default() .map(|d| d.join(".local/share/icons").to_string_lossy().into_owned())
.unwrap_or_else(|| "/usr/share/icons".into())
} }
fn icon_pixmap(&self) -> Vec<ksni::Icon> { fn icon_pixmap(&self) -> Vec<ksni::Icon> {
@ -230,13 +270,21 @@ impl Tray for VietTray {
for x in 0..32 { for x in 0..32 {
let mut inside = true; let mut inside = true;
if x < 7 && y < 7 { if x < 7 && y < 7 {
if (x - 7) * (x - 7) + (y - 7) * (y - 7) > 36 { inside = false; } if (x - 7) * (x - 7) + (y - 7) * (y - 7) > 36 {
inside = false;
}
} else if x > 24 && y < 7 { } else if x > 24 && y < 7 {
if (x - 24) * (x - 24) + (y - 7) * (y - 7) > 36 { inside = false; } if (x - 24) * (x - 24) + (y - 7) * (y - 7) > 36 {
inside = false;
}
} else if x < 7 && y > 24 { } else if x < 7 && y > 24 {
if (x - 7) * (x - 7) + (y - 24) * (y - 24) > 36 { inside = false; } if (x - 7) * (x - 7) + (y - 24) * (y - 24) > 36 {
inside = false;
}
} else if x > 24 && y > 24 { } else if x > 24 && y > 24 {
if (x - 24) * (x - 24) + (y - 24) * (y - 24) > 36 { inside = false; } if (x - 24) * (x - 24) + (y - 24) * (y - 24) > 36 {
inside = false;
}
} }
let idx = ((y * 32 + x) * 4) as usize; let idx = ((y * 32 + x) * 4) as usize;
@ -313,7 +361,8 @@ impl Tray for VietTray {
} }
}), }),
..Default::default() ..Default::default()
}.into(), }
.into(),
MenuItem::Separator, MenuItem::Separator,
CheckmarkItem { CheckmarkItem {
label: "Vietnamese Mode".into(), label: "Vietnamese Mode".into(),
@ -327,27 +376,34 @@ impl Tray for VietTray {
this.mode = next.to_string(); this.mode = next.to_string();
}), }),
..Default::default() ..Default::default()
}.into(), }
.into(),
SubMenu { SubMenu {
label: "Input Method".into(), label: "Input Method".into(),
submenu: vec![ submenu: vec![RadioGroup {
RadioGroup { selected: im_index,
selected: im_index, select: Box::new(|this: &mut VietTray, idx: usize| {
select: Box::new(|this: &mut VietTray, idx: usize| { let im = if idx == 0 { "telex" } else { "vni" };
let im = if idx == 0 { "telex" } else { "vni" }; let mut cfg = config::Config::load();
let mut cfg = config::Config::load(); cfg.input_method = im.into();
cfg.input_method = im.into(); let _ = cfg.save();
let _ = cfg.save(); this.im = im.into();
this.im = im.into(); }),
}), options: vec![
options: vec![ RadioItem {
RadioItem { label: "Telex".into(), ..Default::default() }, label: "Telex".into(),
RadioItem { label: "VNI".into(), ..Default::default() }, ..Default::default()
], },
}.into(), RadioItem {
], label: "VNI".into(),
..Default::default()
},
],
}
.into()],
..Default::default() ..Default::default()
}.into(), }
.into(),
]; ];
items.push(MenuItem::Separator); items.push(MenuItem::Separator);
@ -357,52 +413,70 @@ impl Tray for VietTray {
} else { } else {
format!("Update to {}", release.tag_name) format!("Update to {}", release.tag_name)
}; };
items.push(StandardItem { items.push(
label, StandardItem {
activate: Box::new(|this: &mut VietTray| { label,
if !this.updating { activate: Box::new(|this: &mut VietTray| {
if let Some(ref rel) = this.update_available.clone() { if !this.updating {
let handle = this.handle.lock().unwrap().clone().unwrap(); if let Some(ref rel) = this.update_available.clone() {
this.trigger_update(&handle, rel.clone()); let handle = this.handle.lock().unwrap().clone().unwrap();
this.trigger_update(&handle, rel.clone());
}
} }
} }),
}), ..Default::default()
..Default::default() }
}.into()); .into(),
);
} else { } else {
items.push(StandardItem { items.push(
label: if self.updating { "Updating...".into() } else { "Check for Updates".into() }, StandardItem {
activate: Box::new(|this: &mut VietTray| { label: if self.updating {
if !this.updating { "Updating...".into()
let handle = this.handle.lock().unwrap().clone().unwrap(); } else {
this.check_for_updates(&handle, true); "Check for Updates".into()
} },
}), activate: Box::new(|this: &mut VietTray| {
..Default::default() if !this.updating {
}.into()); let handle = this.handle.lock().unwrap().clone().unwrap();
this.check_for_updates(&handle, true);
}
}),
..Default::default()
}
.into(),
);
} }
items.push(MenuItem::Separator); items.push(MenuItem::Separator);
items.push(StandardItem { items.push(
label: "About: Viet+".into(), StandardItem {
activate: Box::new(|_| { label: "About: Viet+".into(),
let _ = std::process::Command::new("xdg-open") activate: Box::new(|_| {
.arg("https://github.com/vndangkhoa/vietc") let _ = std::process::Command::new("xdg-open")
.status(); .arg("https://github.com/vndangkhoa/vietc")
}), .status();
..Default::default() }),
}.into()); ..Default::default()
}
.into(),
);
items.push(MenuItem::Separator); items.push(MenuItem::Separator);
items.push(StandardItem { items.push(
label: "Quit".into(), StandardItem {
activate: Box::new(|_| { label: "Quit".into(),
let _ = std::process::Command::new("pkill") activate: Box::new(|_| {
.arg("-x").arg("vietc").status(); let _ = std::process::Command::new("pkill")
std::process::exit(0); .arg("-x")
}), .arg("vietc")
..Default::default() .status();
}.into()); std::process::exit(0);
}),
..Default::default()
}
.into(),
);
items items
} }
@ -439,20 +513,24 @@ pub fn run() {
tray_dummy.check_for_updates(&handle, false); tray_dummy.check_for_updates(&handle, false);
} }
// Poll for changes // Poll for changes (shorter interval for faster icon updates)
std::thread::spawn(move || { std::thread::spawn(move || {
loop { loop {
std::thread::sleep(std::time::Duration::from_millis(500)); std::thread::sleep(std::time::Duration::from_millis(100));
let mode = read_status(); let mode = read_status();
let im = current_im(); let im = current_im();
let autostart = config::is_autostart_installed(); let autostart = config::is_autostart_installed();
// Also check status_changed flag for immediate updates
let _ = handle.update(move |t| { let _ = handle.update(move |t| {
t.mode = mode; t.mode = mode;
t.im = im; t.im = im;
t.autostart = autostart; t.autostart = autostart;
// Force icon redraw on update by updating pixmap-related state
}); });
} }
}); });
loop { std::thread::park(); } loop {
std::thread::park();
}
} }