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:
@ -86,6 +86,11 @@ appimage:
VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \
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:
cargo clean

View file

@ -72,12 +72,18 @@ fn main() {
}
output.push_str(insert);
}
EngineEvent::UndoTones { backspaces, restored } => {
EngineEvent::UndoTones {
backspaces,
restored,
} => {
for _ in 0..*backspaces {
output.push('\x08');
}
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)
let content = fs::read_to_string("/proc/active-windows").ok()?;
// 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
@ -76,6 +81,8 @@ pub struct AppStateManager {
english_apps: Vec<String>,
/// Default Vietnamese apps from config
vietnamese_apps: Vec<String>,
/// Bypass apps from config
bypass_apps: Vec<String>,
/// Global enabled state
global_enabled: bool,
}
@ -84,6 +91,7 @@ impl AppStateManager {
pub fn new(
english_apps: Vec<String>,
vietnamese_apps: Vec<String>,
bypass_apps: Vec<String>,
global_enabled: bool,
) -> Self {
Self {
@ -91,6 +99,7 @@ impl AppStateManager {
overrides: HashMap::new(),
english_apps: english_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,
}
}
@ -162,14 +171,32 @@ impl AppStateManager {
}
/// 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.vietnamese_apps = vietnamese_apps.iter().map(|s| s.to_lowercase()).collect();
self.bypass_apps = bypass_apps.iter().map(|s| s.to_lowercase()).collect();
eprintln!(
"[vietc] App lists updated: {} English, {} Vietnamese",
"[vietc] App lists updated: {} English, {} Vietnamese, {} Bypass",
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

View file

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

View file

@ -1,19 +1,19 @@
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use vietc_engine::{Engine, EngineEvent, InputMethod};
mod config;
mod app_state;
mod config;
mod display;
use config::Config;
use app_state::AppStateManager;
use config::Config;
fn get_log_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("vietc").join("vietc.log"))
@ -98,6 +98,7 @@ impl Daemon {
let mut app_state = AppStateManager::new(
config.app_state.english_apps.clone(),
config.app_state.vietnamese_apps.clone(),
config.app_state.bypass_apps.clone(),
config.start_enabled,
);
app_state.load_overrides();
@ -133,7 +134,10 @@ impl Daemon {
if let Ok(content) = fs::read_to_string(&status_path) {
let expect_enabled = content.trim() == "vn";
if self.engine.is_enabled() != expect_enabled {
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_enabled.store(expect_enabled, Ordering::SeqCst);
}
@ -167,6 +171,7 @@ impl Daemon {
self.app_state.update_lists(
new_config.app_state.english_apps.clone(),
new_config.app_state.vietnamese_apps.clone(),
new_config.app_state.bypass_apps.clone(),
);
self.grab_enabled = new_config.grab;
@ -185,13 +190,38 @@ impl Daemon {
fn process_key(&mut self, ch: char) -> Vec<OutputCommand> {
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) {
log_info(&format!("[vietc] key='{}' buf='{}' -> {:?}", ch, self.engine.buffer(), event));
log_info(&format!(
"[vietc] key='{}' buf='{}' -> {:?}",
ch,
self.engine.buffer(),
event
));
match event {
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));
}
EngineEvent::Insert(text) => {
log_info(&format!(
"[vietc] Insert text len={}, text={}",
text.len(),
text
));
commands.push(OutputCommand::Type(text));
}
EngineEvent::AutoRestore(word) => {
@ -200,16 +230,42 @@ impl Daemon {
commands.push(OutputCommand::Type(word));
}
EngineEvent::Replace { backspaces, insert } => {
log_info(&format!(
"[vietc] Replace BS={} text=\"{}\"",
backspaces, insert
));
commands.push(OutputCommand::Backspace(backspaces));
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::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 {
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
@ -217,8 +273,33 @@ impl Daemon {
fn toggle(&mut self) {
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.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) {
@ -248,10 +329,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let compositor = display::detect_compositor();
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!("Toggle key: Ctrl+{}", daemon.config.toggle_key.to_uppercase()));
log_info(&format!("App memory: {}", if daemon.config.app_state.enabled { "ON" } else { "OFF" }));
log_info(&format!(
"Toggle key: Ctrl+{}",
daemon.config.toggle_key.to_uppercase()
));
log_info(&format!(
"App memory: {}",
if daemon.config.app_state.enabled {
"ON"
} else {
"OFF"
}
));
// Spawn background monitor for active window, config changes, and status changes
let shared_active_window = Arc::new(Mutex::new(String::new()));
@ -361,9 +456,10 @@ fn open_keyboard_device() -> Result<(evdev::Device, String), Box<dyn std::error:
if dev_name.eq_ignore_ascii_case("vietc") {
continue;
}
if device.supported_keys().is_some_and(|k| {
k.contains(evdev::Key::KEY_A)
}) {
if device
.supported_keys()
.is_some_and(|k| k.contains(evdev::Key::KEY_A))
{
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. \
Please LOG OUT and LOG BACK IN to activate group permissions.",
permission_denied_count, total_event_count
).into())
)
.into())
} else {
Err(format!(
"Permission denied on {}/{} devices. Add your user to the 'input' group: \
sudo usermod -aG input $USER && sudo usermod -aG vinput $USER, \
then log out and log back in.",
permission_denied_count, total_event_count
).into())
)
.into())
}
} else {
Err("No keyboard device found".into())
@ -422,7 +520,10 @@ fn run_with_evdev(
true
}
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)");
false
}
@ -443,13 +544,18 @@ fn run_with_evdev(
loop {
// Check for event timeout (grab safety)
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();
return Ok(());
}
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()?;
last_event_time = std::time::Instant::now();
@ -463,7 +569,10 @@ fn run_with_evdev(
{
let active_window = shared_active_window.lock().unwrap().clone();
if active_window != last_active_window {
log_info(&format!("[vietc] Window changed: '{}' -> '{}'", last_active_window, active_window));
log_info(&format!(
"[vietc] Window changed: '{}' -> '{}'",
last_active_window, active_window
));
last_active_window = active_window.clone();
daemon.engine.reset();
log_info("[vietc] Reset engine buffer due to window change");
@ -487,8 +596,22 @@ fn run_with_evdev(
let value = event.value();
let keycode = key.0;
if value == 1
&& is_toggle_combination_state(&key_state, &daemon.config.toggle_key)
// Update key state dynamically
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();
continue;
@ -533,11 +656,9 @@ fn run_with_evdev(
}
if let Some(mut ch) = key_to_char(key) {
let shift = is_modifier_held_shift(&key_state);
if ch.is_ascii_alphabetic() {
if shift ^ caps {
if ch.is_ascii_alphabetic() && (shift ^ caps) {
ch = ch.to_ascii_uppercase();
}
}
let commands = daemon.process_key(ch);
if !commands.is_empty() {
consumed_keys.insert(keycode);
@ -576,8 +697,7 @@ fn run_stdin_mode(
_engine_enabled: Arc<AtomicBool>,
display: display::DisplayServer,
) -> Result<(), Box<dyn std::error::Error>> {
use std::io::{self, Read, IsTerminal};
use std::io::{self, IsTerminal, Read};
if !io::stdin().is_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() {
log_info(&format!("[vietc] Keyboard device found: {}", path));
return run_with_evdev(
device, daemon,
device,
daemon,
shared_active_window,
config_changed,
status_changed,
@ -633,7 +754,10 @@ fn run_stdin_mode(
{
let active_window = shared_active_window.lock().unwrap().clone();
if active_window != last_active_window {
log_info(&format!("[vietc] Window changed: '{}' -> '{}'", last_active_window, active_window));
log_info(&format!(
"[vietc] Window changed: '{}' -> '{}'",
last_active_window, active_window
));
last_active_window = active_window.clone();
daemon.engine.reset();
log_info("[vietc] Reset engine buffer due to window change");
@ -672,15 +796,26 @@ fn run_stdin_mode(
/// Execute commands — accumulate backspaces and text, then inject through
/// a single channel (ydotool or wtype) to avoid reordering between backspaces
/// (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_text = String::new();
for cmd in commands {
match cmd {
OutputCommand::Backspace(count) => {
let adjusted = if grabbed { count.saturating_sub(1) } else { *count };
log_info(&format!("[vietc] cmd: Backspace({}) -> adjusted={}", count, adjusted));
let adjusted = if grabbed {
count.saturating_sub(1)
} else {
*count
};
log_info(&format!(
"[vietc] cmd: Backspace({}) -> adjusted={}",
count, adjusted
));
pending_backspaces += adjusted;
}
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() {
log_info(&format!("[vietc] inject: BS={} text=\"{}\"", pending_backspaces, pending_text));
injector.inject_replacement(pending_backspaces, &pending_text);
}
injector.flush();
log_info(&format!(
"[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);
}
fn create_injector(display: display::DisplayServer) -> Result<Box<dyn vietc_protocol::KeyInjector>, Box<dyn std::error::Error>> {
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>> {
// Try Wayland input method first (if compiled with wayland feature)
#[cfg(feature = "wayland")]
{
@ -738,12 +892,7 @@ fn create_injector(display: display::DisplayServer) -> Result<Box<dyn vietc_prot
Err("No injection backend available".into())
}
fn is_modifier_pressed(key_state: &Option<evdev::AttributeSet<evdev::Key>>) -> bool {
let key_state = match key_state {
Some(ks) => ks,
None => return false,
};
fn is_modifier_pressed(key_state: &evdev::AttributeSet<evdev::Key>) -> bool {
key_state.contains(evdev::Key::KEY_LEFTCTRL)
|| key_state.contains(evdev::Key::KEY_RIGHTCTRL)
|| 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)
}
fn is_modifier_held_shift(key_state: &Option<evdev::AttributeSet<evdev::Key>>) -> bool {
let ks = match key_state {
Some(ks) => ks,
None => return false,
};
ks.contains(evdev::Key::KEY_LEFTSHIFT) || ks.contains(evdev::Key::KEY_RIGHTSHIFT)
fn is_modifier_held_shift(key_state: &evdev::AttributeSet<evdev::Key>) -> bool {
key_state.contains(evdev::Key::KEY_LEFTSHIFT) || key_state.contains(evdev::Key::KEY_RIGHTSHIFT)
}
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 {
let key_state = match key_state {
Some(ks) => ks,
None => return false,
};
fn is_toggle_combination_state(key_state: &evdev::AttributeSet<evdev::Key>, key: &str) -> bool {
let ctrl_pressed = key_state.contains(evdev::Key::KEY_LEFTCTRL)
|| key_state.contains(evdev::Key::KEY_RIGHTCTRL);

View file

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

View file

@ -1,89 +1,114 @@
use std::io::{self, Write};
use vietc_engine::{Engine, EngineEvent, InputMethod};
fn get_display(events: &[EngineEvent]) -> String {
let mut display = String::new();
for ev in events {
match ev {
EngineEvent::Flush(text) => { if !display.ends_with(text) { display.push_str(text); } }
EngineEvent::Insert(text) => display.push_str(text),
EngineEvent::Replace { backspaces, insert } => {
for _ in 0..*backspaces { display.pop(); }
display.push_str(insert);
}
EngineEvent::AutoRestore(word) => {
for _ in 0..word.len() { display.pop(); }
display.push_str(word);
}
EngineEvent::UndoTones { backspaces, restored } => {
for _ in 0..*backspaces { display.pop(); }
display.push_str(restored);
}
}
}
display
}
fn process_input(e: &mut Engine, input: &str) -> Vec<EngineEvent> {
let mut events = Vec::new();
for ch in input.chars() {
if let Some(ev) = e.process_key(ch) { events.push(ev); }
}
events
}
use std::fs::File;
const INITIALS: &[&str] = &[
"", "b", "c", "ch", "d", "g", "gh", "h", "k", "kh", "l", "m", "n",
"ng", "ngh", "nh", "p", "ph", "q", "r", "s", "t", "th", "tr", "v", "x",
"", "b", "c", "ch", "d", "g", "gh", "h", "k", "kh", "l", "m", "n", "ng", "ngh", "nh", "p",
"ph", "q", "r", "s", "t", "th", "tr", "v", "x",
];
const FINALS: &[&str] = &["", "c", "ch", "m", "n", "ng", "nh", "p", "t"];
fn is_valid(init: &str, fin: &str) -> bool {
if init == "ngh" && !fin.is_empty() && fin != "n" && fin != "ng" && fin != "nh" { return false; }
if init == "gh" && !fin.is_empty() { return false; }
if init == "q" { return false; }
if init == "g" && !fin.is_empty() && fin != "n" && fin != "ng" { return false; }
if fin == "ch" && init == "" { return false; }
if fin == "nh" && init == "" { return false; }
if init == "ngh" && !fin.is_empty() && fin != "n" && fin != "ng" && fin != "nh" {
return false;
}
if init == "gh" && !fin.is_empty() {
return false;
}
if init == "q" {
return false;
}
if init == "g" && !fin.is_empty() && fin != "n" && fin != "ng" {
return false;
}
if fin == "ch" && init.is_empty() {
return false;
}
if fin == "nh" && init.is_empty() {
return false;
}
true
}
fn main() {
// Telex base vowels (as typed, before mod)
// Telex
let telex_vowels: Vec<(&str, &str)> = vec![
("a", "af"), ("a", "as"), ("a", "aj"), ("a", "ar"), ("a", "ax"),
("a", "aw"), ("a", "aa"),
("a", "af"),
("a", "as"),
("a", "aj"),
("a", "ar"),
("a", "ax"),
("a", "aw"),
("a", "aa"),
("e", "ee"),
("o", "oo"), ("o", "ow"),
("o", "oo"),
("o", "ow"),
("u", "uw"),
];
let mut count = 0;
let stdout = io::stdout();
let mut handle = stdout.lock();
let mut telex_inputs = Vec::new();
for &init in INITIALS {
for &fin in FINALS {
if !is_valid(init, fin) { continue; }
if !is_valid(init, fin) {
continue;
}
for &(base, mod_str) in &telex_vowels {
let plain = format!("{}{}{}", init, base, fin);
let full = format!("{}{}", plain, mod_str);
if plain.len() > 10 { continue; }
if plain.len() > 10 {
continue;
}
telex_inputs.push(full);
}
}
}
// Limit to 500 cases to keep snapshot size reasonable but comprehensive
telex_inputs.truncate(500);
let mut e = Engine::new(InputMethod::Telex);
let result = get_display(&process_input(&mut e, &full));
// 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"),
];
if !result.is_empty() && result.len() <= 12 && result != full && result != plain {
count += 1;
let _ = writeln!(handle, "{{\"i\":\"{full}\",\"e\":\"{result}\",\"m\":\"telex\"}}");
let mut vni_inputs = Vec::new();
for &init in INITIALS {
for &fin in FINALS {
if !is_valid(init, fin) {
continue;
}
if count >= 1000 { break; }
for &(base, mod_str) in &vni_vowels {
let plain = format!("{}{}{}", init, base, fin);
let full = format!("{}{}", plain, mod_str);
if plain.len() > 10 {
continue;
}
if count >= 1000 { break; }
vni_inputs.push(full);
}
if count >= 1000 { break; }
}
}
vni_inputs.truncate(500);
eprintln!("Generated {count} test cases");
// 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) {
let mut e = Engine::new(method);
@ -11,32 +11,39 @@ fn trace(input: &str, method: InputMethod) {
let curr = e.buffer().to_string();
let expected = format!("{}{}", prev, ch);
let event_str = match &event {
Some(EngineEvent::Replace { backspaces, insert }) =>
format!("Replace({}, {:?})", backspaces, insert),
Some(EngineEvent::Replace { backspaces, insert }) => {
format!("Replace({}, {:?})", backspaces, insert)
}
Some(EngineEvent::Insert(t)) => format!("Insert({:?})", t),
Some(EngineEvent::Flush(t)) => format!("Flush({:?})", t),
Some(EngineEvent::AutoRestore(w)) => format!("AutoRestore({:?})", w),
Some(EngineEvent::UndoTones { backspaces, restored }) =>
format!("UndoTones({}, {:?})", backspaces, restored),
Some(EngineEvent::UndoTones {
backspaces,
restored,
}) => format!("UndoTones({}, {:?})", backspaces, restored),
Some(EngineEvent::Paste(t)) => format!("Paste({:?})", t),
None => "None".to_string(),
};
let backspaces = match &event {
Some(EngineEvent::Replace { backspaces, .. }) => format!("bs={}", backspaces),
_ => " ".to_string(),
};
eprintln!("'{}' | {:<9}{:<9} | {:<19} | {}",
ch, prev, curr, expected, event_str);
eprintln!(
"'{}' | {:<9} → {:<9} | {:<19} | {}",
ch, prev, curr, expected, event_str
);
if let Some(EngineEvent::Replace { backspaces, insert }) = &event {
// In grab mode, backspace - 1 (key consumed)
let grab_bs = backspaces.saturating_sub(1);
// In non-grab mode, full backspace
eprintln!(" | | | grab_bs={} non_grab_bs={} insert={:?}",
grab_bs, backspaces, insert);
eprintln!(
" | | | grab_bs={} non_grab_bs={} insert={:?}",
grab_bs, backspaces, insert
);
}
}
// Flush
if let Some(event) = e.flush() {
eprintln!("FL | | | | {:?}", event);
eprintln!(
"FL | | | | {:?}",
event
);
}
}

View file

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

View file

@ -15,46 +15,332 @@ impl EnglishDict {
// These would trigger false Vietnamese conversions
let common_words = [
// Programming/tech
"the", "and", "for", "are", "but", "not", "you", "all", "can", "had",
"her", "was", "one", "our", "out", "day", "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",
"the",
"and",
"for",
"are",
"but",
"not",
"you",
"all",
"can",
"had",
"her",
"was",
"one",
"our",
"out",
"day",
"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
"ok", "no", "so", "do", "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",
"ok",
"no",
"so",
"do",
"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
"hello", "hi", "hey", "bye", "thanks", "thank", "please", "sorry",
"yes", "yeah", "no", "ok", "okay", "sure", "well", "too", "also",
"hello",
"hi",
"hey",
"bye",
"thanks",
"thank",
"please",
"sorry",
"yes",
"yeah",
"no",
"ok",
"okay",
"sure",
"well",
"too",
"also",
// More common English
"about", "after", "again", "being", "below", "between", "both",
"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", "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",
"about",
"after",
"again",
"being",
"below",
"between",
"both",
"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",
"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 {

View file

@ -1,7 +1,8 @@
mod engine;
mod english;
mod spelling;
mod telex;
mod vni;
mod english;
#[cfg(test)]
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;
const VOWELS: &[char] = &[
'a', 'e', 'i', 'o', 'u', 'y',
'ă', 'â', 'ê', 'ô', 'ơ', 'ư',
];
const VOWEL_ACCENTED: &[char] = &[
'a', 'á', 'à', 'ả', 'ã', 'ạ',
'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ',
'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ',
'e', 'é', 'è', 'ẻ', 'ẽ', 'ẹ',
'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ',
'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị',
'o', 'ó', 'ò', 'ỏ', 'õ', 'ọ',
'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ',
'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ',
'u', 'ú', 'ù', 'ủ', 'ũ', 'ụ',
'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự',
'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ',
'a', 'á', 'à', 'ả', 'ã', 'ạ', 'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ', 'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ', 'e',
'é', 'è', 'ẻ', 'ẽ', 'ẹ', 'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ', 'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị', 'o', 'ó',
'ò', 'ỏ', 'õ', 'ọ', 'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ', 'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ', 'u', 'ú', 'ù',
'ủ', 'ũ', 'ụ', 'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự', 'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ',
];
/// 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., 'â', 'ă', 'ô', 'ơ').
fn strip_tone(c: char) -> (char, Option<char>) {
match c {
'a' => ('a', None), 'á' => ('a', Some('s')), 'à' => ('a', Some('f')),
'ả' => ('a', Some('r')), 'ã' => ('a', Some('x')), 'ạ' => ('a', Some('j')),
'ă' => ('ă', None), 'ắ' => ('ă', Some('s')), 'ằ' => ('ă', Some('f')),
'ẳ' => ('ă', Some('r')), 'ẵ' => ('ă', Some('x')), 'ặ' => ('ă', Some('j')),
'â' => ('â', None), 'ấ' => ('â', Some('s')), 'ầ' => ('â', Some('f')),
'ẩ' => ('â', Some('r')), 'ẫ' => ('â', Some('x')), 'ậ' => ('â', Some('j')),
'e' => ('e', None), 'é' => ('e', Some('s')), 'è' => ('e', Some('f')),
'ẻ' => ('e', Some('r')), 'ẽ' => ('e', Some('x')), 'ẹ' => ('e', Some('j')),
'ê' => ('ê', None), 'ế' => ('ê', Some('s')), 'ề' => ('ê', Some('f')),
'ể' => ('ê', Some('r')), 'ễ' => ('ê', Some('x')), 'ệ' => ('ê', Some('j')),
'i' => ('i', None), 'í' => ('i', Some('s')), 'ì' => ('i', Some('f')),
'ỉ' => ('i', Some('r')), 'ĩ' => ('i', Some('x')), 'ị' => ('i', Some('j')),
'o' => ('o', None), 'ó' => ('o', Some('s')), 'ò' => ('o', Some('f')),
'ỏ' => ('o', Some('r')), 'õ' => ('o', Some('x')), 'ọ' => ('o', Some('j')),
'ô' => ('ô', None), 'ố' => ('ô', Some('s')), 'ồ' => ('ô', Some('f')),
'ổ' => ('ô', Some('r')), 'ỗ' => ('ô', Some('x')), 'ộ' => ('ô', Some('j')),
'ơ' => ('ơ', None), 'ớ' => ('ơ', Some('s')), 'ờ' => ('ơ', Some('f')),
'ở' => ('ơ', Some('r')), 'ỡ' => ('ơ', Some('x')), 'ợ' => ('ơ', Some('j')),
'u' => ('u', None), 'ú' => ('u', Some('s')), 'ù' => ('u', Some('f')),
'ủ' => ('u', Some('r')), 'ũ' => ('u', Some('x')), 'ụ' => ('u', Some('j')),
'ư' => ('ư', None), 'ứ' => ('ư', Some('s')), 'ừ' => ('ư', Some('f')),
'ử' => ('ư', Some('r')), 'ữ' => ('ư', Some('x')), 'ự' => ('ư', Some('j')),
'y' => ('y', None), 'ý' => ('y', Some('s')), 'ỳ' => ('y', Some('f')),
'ỷ' => ('y', Some('r')), 'ỹ' => ('y', Some('x')), 'ỵ' => ('y', Some('j')),
'a' => ('a', None),
'á' => ('a', Some('s')),
'à' => ('a', Some('f')),
'ả' => ('a', Some('r')),
'ã' => ('a', Some('x')),
'ạ' => ('a', Some('j')),
'ă' => ('ă', None),
'ắ' => ('ă', Some('s')),
'ằ' => ('ă', Some('f')),
'ẳ' => ('ă', Some('r')),
'ẵ' => ('ă', Some('x')),
'ặ' => ('ă', Some('j')),
'â' => ('â', None),
'ấ' => ('â', Some('s')),
'ầ' => ('â', Some('f')),
'ẩ' => ('â', Some('r')),
'ẫ' => ('â', Some('x')),
'ậ' => ('â', Some('j')),
'e' => ('e', None),
'é' => ('e', Some('s')),
'è' => ('e', Some('f')),
'ẻ' => ('e', Some('r')),
'ẽ' => ('e', Some('x')),
'ẹ' => ('e', Some('j')),
'ê' => ('ê', None),
'ế' => ('ê', Some('s')),
'ề' => ('ê', Some('f')),
'ể' => ('ê', Some('r')),
'ễ' => ('ê', Some('x')),
'ệ' => ('ê', Some('j')),
'i' => ('i', None),
'í' => ('i', Some('s')),
'ì' => ('i', Some('f')),
'ỉ' => ('i', Some('r')),
'ĩ' => ('i', Some('x')),
'ị' => ('i', Some('j')),
'o' => ('o', None),
'ó' => ('o', Some('s')),
'ò' => ('o', Some('f')),
'ỏ' => ('o', Some('r')),
'õ' => ('o', Some('x')),
'ọ' => ('o', Some('j')),
'ô' => ('ô', None),
'ố' => ('ô', Some('s')),
'ồ' => ('ô', Some('f')),
'ổ' => ('ô', Some('r')),
'ỗ' => ('ô', Some('x')),
'ộ' => ('ô', Some('j')),
'ơ' => ('ơ', None),
'ớ' => ('ơ', Some('s')),
'ờ' => ('ơ', Some('f')),
'ở' => ('ơ', Some('r')),
'ỡ' => ('ơ', Some('x')),
'ợ' => ('ơ', Some('j')),
'u' => ('u', None),
'ú' => ('u', Some('s')),
'ù' => ('u', Some('f')),
'ủ' => ('u', Some('r')),
'ũ' => ('u', Some('x')),
'ụ' => ('u', Some('j')),
'ư' => ('ư', None),
'ứ' => ('ư', Some('s')),
'ừ' => ('ư', Some('f')),
'ử' => ('ư', Some('r')),
'ữ' => ('ư', Some('x')),
'ự' => ('ư', Some('j')),
'y' => ('y', None),
'ý' => ('y', Some('s')),
'ỳ' => ('y', Some('f')),
'ỷ' => ('y', Some('r')),
'ỹ' => ('y', Some('x')),
'ỵ' => ('y', Some('j')),
_ => (c, None),
}
}
@ -65,18 +100,66 @@ fn strip_tone(c: char) -> (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
let table: &[(char, char, char)] = &[
('a', 'f', 'à'), ('a', 's', 'á'), ('a', 'r', 'ả'), ('a', 'x', 'ã'), ('a', 'j', 'ạ'),
('ă', 'f', 'ằ'), ('ă', 's', 'ắ'), ('ă', 'r', 'ẳ'), ('ă', 'x', 'ẵ'), ('ă', 'j', 'ặ'),
('â', 'f', 'ầ'), ('â', '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', 'ỵ'),
('a', 'f', 'à'),
('a', 's', 'á'),
('a', 'r', 'ả'),
('a', 'x', 'ã'),
('a', 'j', 'ạ'),
('ă', 'f', 'ằ'),
('ă', 's', 'ắ'),
('ă', 'r', 'ẳ'),
('ă', 'x', 'ẵ'),
('ă', 'j', 'ặ'),
('â', 'f', 'ầ'),
('â', '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 {
@ -116,7 +199,6 @@ fn override_telex_modifier(vowel: char, key: char) -> Option<char> {
}
}
fn apply_w_to_vowel(vowel: char) -> Option<char> {
// Telex: aw=ă, ow=ơ, ew=ê, uw=ư
// (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> {
match c {
'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None,
'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => Some('f'),
'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => Some('s'),
'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => Some('r'),
'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => Some('x'),
'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => Some('j'),
'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => {
Some('f')
}
'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => {
Some('s')
}
'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => {
Some('r')
}
'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => {
Some('x')
}
'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => {
Some('j')
}
_ => None,
}
}
@ -181,7 +273,6 @@ fn is_q_before_u(chars: &[char], i: usize) -> bool {
i > 1 && chars[i - 2] == 'q'
}
pub struct TelexEngine {
buffer: String,
pending_modifier: Option<char>,
@ -292,10 +383,15 @@ impl TelexEngine {
// For oa, oe, uâ, uê, uơ, uy, iê, yê → tone on second vowel
let tone_on_second = matches!(
(first, second),
('o', 'a') | ('o', 'e')
| ('u', 'â') | ('u', 'ê') | ('u', 'ơ') | ('u', 'y')
('o', 'a')
| ('o', 'e')
| ('u', 'â')
| ('u', 'ê')
| ('u', 'ơ')
| ('u', 'y')
| ('ư', 'ơ')
| ('i', 'ê') | ('y', 'ê')
| ('i', 'ê')
| ('y', 'ê')
);
if !tone_on_second {
// Apply tone to first vowel
@ -451,7 +547,10 @@ impl TelexEngine {
if is_o_vowel(last_ch) {
// Smart cluster "uo" → "ươ"
let mut chars: Vec<char> = self.buffer.chars().collect();
if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) {
if chars.len() >= 2
&& is_u_vowel(chars[chars.len() - 2])
&& !is_q_before_u(&chars, chars.len() - 1)
{
let o_char = chars.pop().unwrap();
let u_char = chars.pop().unwrap();
let (new_first, new_second) = uo_to_uơ(u_char, o_char);
@ -471,7 +570,10 @@ impl TelexEngine {
let strip = strip_tone(last_ch);
if strip.0 == 'ô' || strip.0 == 'ơ' {
let mut chars: Vec<char> = self.buffer.chars().collect();
if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) {
if chars.len() >= 2
&& is_u_vowel(chars[chars.len() - 2])
&& !is_q_before_u(&chars, chars.len() - 1)
{
let o_char = chars.pop().unwrap();
let u_char = chars.pop().unwrap();
let (new_first, new_second) = uo_to_uơ(u_char, o_char);
@ -499,7 +601,11 @@ impl TelexEngine {
for i in (start..chars.len()).rev() {
if is_vowel(chars[i]) {
// Smart cluster "uo" → "ươ" (flexible)
if is_o_vowel(chars[i]) && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) {
if is_o_vowel(chars[i])
&& i > 0
&& is_u_vowel(chars[i - 1])
&& !is_q_before_u(&chars, i)
{
let (new_first, new_second) = uo_to_uơ(chars[i - 1], chars[i]);
self.buffer = chars[..i - 1].iter().collect::<String>();
self.buffer.push(new_first);
@ -580,4 +686,3 @@ impl TelexEngine {
None
}
}

View file

@ -6,7 +6,10 @@ mod tests {
let mut events = Vec::new();
for ch in input.chars() {
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);
continue;
}
@ -26,7 +29,7 @@ mod tests {
let mut output = String::new();
for ev in events {
match ev {
EngineEvent::Flush(text) | EngineEvent::Insert(text) => {
EngineEvent::Flush(text) | EngineEvent::Insert(text) | EngineEvent::Paste(text) => {
output.push_str(text);
}
EngineEvent::Replace { backspaces, insert } => {
@ -41,7 +44,10 @@ mod tests {
}
output.push_str(word);
}
EngineEvent::UndoTones { backspaces, restored } => {
EngineEvent::UndoTones {
backspaces,
restored,
} => {
for _ in 0..*backspaces {
output.push('\x08');
}
@ -56,7 +62,7 @@ mod tests {
let mut display = String::new();
for ev in events {
match ev {
EngineEvent::Flush(text) => {
EngineEvent::Flush(text) | EngineEvent::Paste(text) => {
if !display.ends_with(text) {
display.push_str(text);
}
@ -76,7 +82,10 @@ mod tests {
}
display.push_str(word);
}
EngineEvent::UndoTones { backspaces, restored } => {
EngineEvent::UndoTones {
backspaces,
restored,
} => {
for _ in 0..*backspaces {
display.pop();
}
@ -972,7 +981,10 @@ mod tests {
e.process_key('s');
let event = e.process_escape();
match event {
Some(EngineEvent::UndoTones { backspaces, restored }) => {
Some(EngineEvent::UndoTones {
backspaces,
restored,
}) => {
assert_eq!(backspaces, 1);
assert_eq!(restored, "a");
}
@ -988,7 +1000,10 @@ mod tests {
}
let event = e.process_escape();
match event {
Some(EngineEvent::UndoTones { backspaces, restored }) => {
Some(EngineEvent::UndoTones {
backspaces,
restored,
}) => {
assert_eq!(backspaces, 4);
assert_eq!(restored, "chao");
}
@ -1113,7 +1128,10 @@ mod tests {
fn macro_long_expansion() {
let mut e = Engine::new(InputMethod::Telex);
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]
@ -1129,7 +1147,10 @@ mod tests {
let mut e = Engine::new(InputMethod::Telex);
e.add_macro("vs".into(), "với".into());
// "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 events = process_input(&mut e, "was ");
// Verify auto-restore produces correct backspace counts
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
})
.collect();
assert_eq!(replace_events.len(), 3);
// w-pending: backspace 1 (delete 'w' from screen)
assert_eq!(replace_events[0], (1, "".to_string()));
@ -1415,10 +1439,13 @@ mod tests {
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "as");
// Find the Replace event
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
})
.collect();
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event for 'as'");
assert_eq!(replace_events[0], (2, "á".to_string()));
assert_eq!(get_display(&events), "á");
@ -1428,10 +1455,13 @@ mod tests {
fn backspace_count_double_letter() {
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "aa");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
})
.collect();
assert_eq!(replace_events.len(), 1);
assert_eq!(replace_events[0], (2, "â".to_string()));
assert_eq!(get_display(&events), "â");
@ -1441,10 +1471,13 @@ mod tests {
fn backspace_count_w_modifier() {
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "aw");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
})
.collect();
assert_eq!(replace_events.len(), 1);
assert_eq!(replace_events[0], (2, "ă".to_string()));
assert_eq!(get_display(&events), "ă");
@ -1454,12 +1487,20 @@ mod tests {
fn backspace_count_w_modifier_then_tone() {
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "aws");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
})
.collect();
// "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[1], (2, "".to_string()));
assert_eq!(get_display(&events), "");
@ -1469,12 +1510,20 @@ mod tests {
fn backspace_count_compound_vowel_tone() {
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "oas");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
})
.collect();
// "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!(get_display(&events), "");
}
@ -1483,12 +1532,20 @@ mod tests {
fn backspace_count_compound_vowel_uy_tone() {
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "uys");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
})
.collect();
// "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!(get_display(&events), "");
}
@ -1498,15 +1555,23 @@ mod tests {
// "bs" → no vowel, 's' is appended as text
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "bs");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, .. } => Some(backspaces),
_ => None,
}).collect();
})
.collect();
// 's' after consonant 'b': no vowel found, 's' appended to buffer
// 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"
// 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");
}
@ -1517,15 +1582,23 @@ mod tests {
// Then space triggers auto-restore back to "was "
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "was ");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
})
.collect();
// Expected events for "was ":
// 'w': pending modifier, no buffer change → Replace {1, ""} (blink)
// 's': tone on 'a' → Replace {2, "á"}
// ' ': 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)
assert_eq!(replace_events[0].0, 1, "w-pending backspace");
assert_eq!(replace_events[0].1, "");
@ -1545,13 +1618,20 @@ mod tests {
// "hello " → no conversion needed, should_restore("hello") → true, no diacritics → None
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "hello ");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, .. } => Some(backspaces),
_ => None,
}).collect();
})
.collect();
// "hello" has no Vietnamese conversion, should_restore returns true
// 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 ");
}
@ -1560,13 +1640,20 @@ mod tests {
let mut e = Engine::new(InputMethod::Telex);
e.add_macro("ko".into(), "không".into());
let events = process_input(&mut e, "ko ");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
})
.collect();
// "ko " → macro expansion: raw_buffer="ko", Replace { 3, "không " }
// 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].1, "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ò "
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "chof ");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
})
.collect();
// "chof":
// 'c' → no event
// 'h' → no event
// 'o' → no event
// 'f' → process_tone on 'o' → Replace { 4, "chò" } (prev_inner="cho", expected="chof")
// ' ' → 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].1, "chò");
assert_eq!(get_display(&events), "chò ");
@ -1601,7 +1696,10 @@ mod tests {
}
let event = e.process_escape();
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!(restored, "chao");
}
@ -1618,17 +1716,33 @@ mod tests {
e.process_key('s'); // buffer = "á"
let mut events = Vec::new();
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('a') { events.push(ev); } // buffer "a" (no Replace)
if let Some(ev) = e.flush() { events.push(ev); }
if let Some(ev) = e.process_key('\x08') {
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")
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { .. } => Some(()),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 0, "No Replace events after backspace + 'a'");
})
.collect();
assert_eq!(
replace_events.len(),
0,
"No Replace events after backspace + 'a'"
);
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]
@ -1636,10 +1750,13 @@ mod tests {
let mut e = Engine::new(InputMethod::Telex);
// "xin chao " (xin=no convert, chao=no convert, space flushes)
let events = process_input(&mut e, "xin chao ");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
})
.collect();
assert_eq!(replace_events.len(), 0, "No Replace events for 'xin chao '");
assert_eq!(get_display(&events), "xin chao ");
}
@ -1656,11 +1773,19 @@ mod tests {
// Apply 's' to 'o' → 'ó'. buffer = "tót"
// Replace { 4, "tót" }
let events = process_input(&mut e, "tots");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events);
})
.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].1, "tót");
assert_eq!(get_display(&events), "tót");
@ -1671,11 +1796,19 @@ mod tests {
let mut e = Engine::new(InputMethod::Telex);
// "dungj" → "dụng"
let events = process_input(&mut e, "dungj");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events);
})
.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].1, "dụng");
assert_eq!(get_display(&events), "dụng");
@ -1695,7 +1828,11 @@ mod tests {
assert_eq!(e.buffer(), "á", "Engine buffer should be 'á'");
// Backspace → pop engine, sync raw_buffer
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)
}
@ -1738,11 +1875,19 @@ mod tests {
fn vni_backspace_count_tone() {
let mut e = Engine::new(InputMethod::Vni);
let events = process_input(&mut e, "a1");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events);
})
.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].1, "á");
assert_eq!(get_display(&events), "á");
@ -1752,10 +1897,13 @@ mod tests {
fn vni_backspace_count_vowel_mod() {
let mut e = Engine::new(InputMethod::Vni);
let events = process_input(&mut e, "a6");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
})
.collect();
assert_eq!(replace_events.len(), 1);
assert_eq!(replace_events[0].0, 2, "a6→â backspace");
assert_eq!(replace_events[0].1, "â");
@ -1766,12 +1914,20 @@ mod tests {
fn vni_backspace_count_mod_then_tone() {
let mut e = Engine::new(InputMethod::Vni);
let events = process_input(&mut e, "a61");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
})
.collect();
// "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].1, "â");
assert_eq!(replace_events[1].0, 2);
@ -1784,10 +1940,13 @@ mod tests {
// "b1" → 'b' is not vowel, '1' appends as digit → no Replace
let mut e = Engine::new(InputMethod::Vni);
let events = process_input(&mut e, "b1");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { .. } => Some(()),
_ => None,
}).collect();
})
.collect();
assert_eq!(replace_events.len(), 0, "No Replace for consonant+digit");
assert_eq!(get_display(&events), "b1");
}
@ -1797,11 +1956,19 @@ mod tests {
let mut e = Engine::new(InputMethod::Vni);
// "chao2" → '2' is tone (huyền) on 'o' → "chaò"
let events = process_input(&mut e, "chao2");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events);
})
.collect();
assert_eq!(
replace_events.len(),
1,
"Expected 1 Replace: {:?}",
replace_events
);
// previous_inner = "chao" (4 chars), expected = "chao"+"2" = "chao2" (5 chars)
// backspaces = 4 + 1 = 5
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 → "à"
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "asf");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
let replace_events: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
})
.collect();
// "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].1, "á");
assert_eq!(replace_events[1].0, 2);
@ -1970,11 +2145,19 @@ mod tests {
// ' ' = flush
// b + a + n + j = "bạn" (j=nặng on 'a')
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
.iter()
.filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, .. } => Some(*backspaces),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 2, "Expected 2 Replace events: {:?}", replace_events);
})
.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[1], 4, "banj→bạn should be 4");
assert_eq!(get_display(&events), "xin chào bạn");
@ -2102,4 +2285,70 @@ mod tests {
let mut e = Engine::new(InputMethod::Vni);
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;
const VOWELS: &[char] = &[
'a', 'e', 'i', 'o', 'u', 'y',
'ă', 'â', 'ê', 'ô', 'ơ', 'ư',
];
const VOWEL_ACCENTED: &[char] = &[
'a', 'á', 'à', 'ả', 'ã', 'ạ',
'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ',
'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ',
'e', 'é', 'è', 'ẻ', 'ẽ', 'ẹ',
'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ',
'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị',
'o', 'ó', 'ò', 'ỏ', 'õ', 'ọ',
'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ',
'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ',
'u', 'ú', 'ù', 'ủ', 'ũ', 'ụ',
'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự',
'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ',
'a', 'á', 'à', 'ả', 'ã', 'ạ', 'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ', 'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ', 'e',
'é', 'è', 'ẻ', 'ẽ', 'ẹ', 'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ', 'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị', 'o', 'ó',
'ò', 'ỏ', 'õ', 'ọ', 'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ', 'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ', 'u', 'ú', 'ù',
'ủ', 'ũ', 'ụ', 'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự', 'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ',
];
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)
fn strip_tone_vni(c: char) -> (char, Option<char>) {
match c {
'a' => ('a', None), 'á' => ('a', Some('1')), 'à' => ('a', Some('2')),
'ả' => ('a', Some('3')), 'ã' => ('a', Some('4')), 'ạ' => ('a', Some('5')),
'ă' => ('ă', None), 'ắ' => ('ă', Some('1')), 'ằ' => ('ă', Some('2')),
'ẳ' => ('ă', Some('3')), 'ẵ' => ('ă', Some('4')), 'ặ' => ('ă', Some('5')),
'â' => ('â', None), 'ấ' => ('â', Some('1')), 'ầ' => ('â', Some('2')),
'ẩ' => ('â', Some('3')), 'ẫ' => ('â', Some('4')), 'ậ' => ('â', Some('5')),
'e' => ('e', None), 'é' => ('e', Some('1')), 'è' => ('e', Some('2')),
'ẻ' => ('e', Some('3')), 'ẽ' => ('e', Some('4')), 'ẹ' => ('e', Some('5')),
'ê' => ('ê', None), 'ế' => ('ê', Some('1')), 'ề' => ('ê', Some('2')),
'ể' => ('ê', Some('3')), 'ễ' => ('ê', Some('4')), 'ệ' => ('ê', Some('5')),
'i' => ('i', None), 'í' => ('i', Some('1')), 'ì' => ('i', Some('2')),
'ỉ' => ('i', Some('3')), 'ĩ' => ('i', Some('4')), 'ị' => ('i', Some('5')),
'o' => ('o', None), 'ó' => ('o', Some('1')), 'ò' => ('o', Some('2')),
'ỏ' => ('o', Some('3')), 'õ' => ('o', Some('4')), 'ọ' => ('o', Some('5')),
'ô' => ('ô', None), 'ố' => ('ô', Some('1')), 'ồ' => ('ô', Some('2')),
'ổ' => ('ô', Some('3')), 'ỗ' => ('ô', Some('4')), 'ộ' => ('ô', Some('5')),
'ơ' => ('ơ', None), 'ớ' => ('ơ', Some('1')), 'ờ' => ('ơ', Some('2')),
'ở' => ('ơ', Some('3')), 'ỡ' => ('ơ', Some('4')), 'ợ' => ('ơ', Some('5')),
'u' => ('u', None), 'ú' => ('u', Some('1')), 'ù' => ('u', Some('2')),
'ủ' => ('u', Some('3')), 'ũ' => ('u', Some('4')), 'ụ' => ('u', Some('5')),
'ư' => ('ư', None), 'ứ' => ('ư', Some('1')), 'ừ' => ('ư', Some('2')),
'ử' => ('ư', Some('3')), 'ữ' => ('ư', Some('4')), 'ự' => ('ư', Some('5')),
'y' => ('y', None), 'ý' => ('y', Some('1')), 'ỳ' => ('y', Some('2')),
'ỷ' => ('y', Some('3')), 'ỹ' => ('y', Some('4')), 'ỵ' => ('y', Some('5')),
'a' => ('a', None),
'á' => ('a', Some('1')),
'à' => ('a', Some('2')),
'ả' => ('a', Some('3')),
'ã' => ('a', Some('4')),
'ạ' => ('a', Some('5')),
'ă' => ('ă', None),
'ắ' => ('ă', Some('1')),
'ằ' => ('ă', Some('2')),
'ẳ' => ('ă', Some('3')),
'ẵ' => ('ă', Some('4')),
'ặ' => ('ă', Some('5')),
'â' => ('â', None),
'ấ' => ('â', Some('1')),
'ầ' => ('â', Some('2')),
'ẩ' => ('â', Some('3')),
'ẫ' => ('â', Some('4')),
'ậ' => ('â', Some('5')),
'e' => ('e', None),
'é' => ('e', Some('1')),
'è' => ('e', Some('2')),
'ẻ' => ('e', Some('3')),
'ẽ' => ('e', Some('4')),
'ẹ' => ('e', Some('5')),
'ê' => ('ê', None),
'ế' => ('ê', Some('1')),
'ề' => ('ê', Some('2')),
'ể' => ('ê', Some('3')),
'ễ' => ('ê', Some('4')),
'ệ' => ('ê', Some('5')),
'i' => ('i', None),
'í' => ('i', Some('1')),
'ì' => ('i', Some('2')),
'ỉ' => ('i', Some('3')),
'ĩ' => ('i', Some('4')),
'ị' => ('i', Some('5')),
'o' => ('o', None),
'ó' => ('o', Some('1')),
'ò' => ('o', Some('2')),
'ỏ' => ('o', Some('3')),
'õ' => ('o', Some('4')),
'ọ' => ('o', Some('5')),
'ô' => ('ô', None),
'ố' => ('ô', Some('1')),
'ồ' => ('ô', Some('2')),
'ổ' => ('ô', Some('3')),
'ỗ' => ('ô', Some('4')),
'ộ' => ('ô', Some('5')),
'ơ' => ('ơ', None),
'ớ' => ('ơ', Some('1')),
'ờ' => ('ơ', Some('2')),
'ở' => ('ơ', Some('3')),
'ỡ' => ('ơ', Some('4')),
'ợ' => ('ơ', Some('5')),
'u' => ('u', None),
'ú' => ('u', Some('1')),
'ù' => ('u', Some('2')),
'ủ' => ('u', Some('3')),
'ũ' => ('u', Some('4')),
'ụ' => ('u', Some('5')),
'ư' => ('ư', None),
'ứ' => ('ư', Some('1')),
'ừ' => ('ư', Some('2')),
'ử' => ('ư', Some('3')),
'ữ' => ('ư', Some('4')),
'ự' => ('ư', Some('5')),
'y' => ('y', None),
'ý' => ('y', Some('1')),
'ỳ' => ('y', Some('2')),
'ỷ' => ('y', Some('3')),
'ỹ' => ('y', Some('4')),
'ỵ' => ('y', Some('5')),
_ => (c, None),
}
}
@ -60,18 +95,66 @@ fn strip_tone_vni(c: char) -> (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
let table: &[(char, char, char)] = &[
('a', '1', 'á'), ('a', '2', 'à'), ('a', '3', 'ả'), ('a', '4', 'ã'), ('a', '5', 'ạ'),
('ă', '1', 'ắ'), ('ă', '2', 'ằ'), ('ă', '3', 'ẳ'), ('ă', '4', 'ẵ'), ('ă', '5', 'ặ'),
('â', '1', 'ấ'), ('â', '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', 'ỵ'),
('a', '1', 'á'),
('a', '2', 'à'),
('a', '3', 'ả'),
('a', '4', 'ã'),
('a', '5', 'ạ'),
('ă', '1', 'ắ'),
('ă', '2', 'ằ'),
('ă', '3', 'ẳ'),
('ă', '4', 'ẵ'),
('ă', '5', 'ặ'),
('â', '1', 'ấ'),
('â', '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 {
@ -145,11 +228,21 @@ fn is_o_vowel(c: char) -> bool {
fn tone_of_vowel_vni(c: char) -> Option<char> {
match c {
'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None,
'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => Some('2'),
'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => Some('1'),
'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => Some('3'),
'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => Some('4'),
'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => Some('5'),
'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => {
Some('2')
}
'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => {
Some('1')
}
'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => {
Some('3')
}
'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => {
Some('4')
}
'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => {
Some('5')
}
_ => None,
}
}
@ -251,6 +344,22 @@ impl VniEngine {
}
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
if self.pending_modifier.is_some() {
self.apply_pending();
@ -262,7 +371,10 @@ impl VniEngine {
// Smart cluster "uo" → "ươ" (digit '7')
if digit == '7' && is_o_vowel(last_ch) {
let mut chars: Vec<char> = self.buffer.chars().collect();
if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) {
if chars.len() >= 2
&& is_u_vowel(chars[chars.len() - 2])
&& !is_q_before_u(&chars, chars.len() - 1)
{
let o_char = chars.pop().unwrap();
let u_char = chars.pop().unwrap();
let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char);
@ -291,7 +403,10 @@ impl VniEngine {
let strip = strip_tone_vni(last_ch);
if strip.0 == 'ô' {
let mut chars: Vec<char> = self.buffer.chars().collect();
if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) {
if chars.len() >= 2
&& is_u_vowel(chars[chars.len() - 2])
&& !is_q_before_u(&chars, chars.len() - 1)
{
let o_char = chars.pop().unwrap();
let u_char = chars.pop().unwrap();
let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char);
@ -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
if let Some(modified) = override_vni_modifier(last_ch, digit) {
self.buffer.pop();
@ -345,7 +455,12 @@ impl VniEngine {
for i in (start..chars.len()).rev() {
if is_vowel(chars[i]) {
// Smart cluster "uo" → "ươ" (digit '7', flexible)
if digit == '7' && is_o_vowel(chars[i]) && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) {
if digit == '7'
&& is_o_vowel(chars[i])
&& i > 0
&& is_u_vowel(chars[i - 1])
&& !is_q_before_u(&chars, i)
{
let (new_first, new_second) = uo_to_uơ_vni(chars[i - 1], chars[i]);
self.buffer = chars[..i - 1].iter().collect::<String>();
self.buffer.push(new_first);
@ -376,7 +491,11 @@ impl VniEngine {
// Smart cluster forward (override): "uô" + 7 → "ươ" (flexible)
if digit == '7' {
let strip = strip_tone_vni(chars[i]);
if strip.0 == 'ô' && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) {
if strip.0 == 'ô'
&& i > 0
&& is_u_vowel(chars[i - 1])
&& !is_q_before_u(&chars, i)
{
let (new_first, new_second) = uo_to_uơ_vni(chars[i - 1], chars[i]);
self.buffer = chars[..i - 1].iter().collect::<String>();
self.buffer.push(new_first);

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
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 [ -f "$HOME/.cargo/bin/cargo" ]; then
export PATH="$HOME/.cargo/bin:$PATH"
@ -22,81 +22,46 @@ mkdir -p "$APPDIR/usr/share/applications"
mkdir -p "$APPDIR/usr/share/icons/hicolor/256x256/apps"
mkdir -p "$APPDIR/usr/share/doc/vietc"
mkdir -p "$APPDIR/etc/vietc"
mkdir -p "$APPDIR/usr/lib/systemd/user"
mkdir -p "$APPDIR/usr/share/metainfo"
# Build binaries
echo "[1/5] Building binaries..."
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"
cd "$SCRIPT_DIR"
cd "$PROJECT_ROOT/ui" && cargo build --release && cd "$SCRIPT_DIR"
cd "$PROJECT_ROOT"
# Copy binaries
# Copy binaries from deb-build if they exist, otherwise from target/release
echo "[2/5] Installing binaries..."
if [ -d "deb-build/usr/bin" ]; then
cp -r deb-build/usr/bin/* "$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
echo "[3/5] Installing desktop integration..."
cp "$SCRIPT_DIR/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"
if [ -f "deb-build/vietc.desktop" ]; then
cp deb-build/vietc.desktop "$APPDIR/usr/share/applications/"
else
# Fallback: generate PNG via Python/Pillow
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)"
cp "$SCRIPT_DIR/vietc.desktop" "$APPDIR/usr/share/applications/"
fi
# Copy icon to AppDir root for appimagetool
cp "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc."{png,svg} "$APPDIR/" 2>/dev/null || true
# Icons
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
mkdir -p "$APPDIR/usr/share/metainfo"
if [ -f "deb-build/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" ]; then
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"?>
<component type="console-application">
@ -113,19 +78,36 @@ cat > "$APPDIR/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" << 'XML
<categories><category>Utility</category></categories>
</component>
XML
fi
# 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
cp deb-build/etc/vietc/config.toml "$APPDIR/etc/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
mkdir -p "$APPDIR/usr/lib/systemd/user"
if [ -f "deb-build/usr/lib/systemd/user/vietc.service" ]; then
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
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
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 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)
SUDO_CMD=""
# 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.
@ -149,7 +140,9 @@ if [ "$(id -u)" = "0" ] && [ -z "$XDG_RUNTIME_DIR" ] && [ -n "$SUDO_USER" ]; the
fi
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
password=""
if command -v kdialog >/dev/null; then
@ -161,24 +154,12 @@ elif [ -n "$WAYLAND_DISPLAY" ]; then
fi
if [ -n "$password" ]; then
pkill -x vietc 2>/dev/null; sleep 0.5
echo "$password" | sudo -S env \
XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" \
WAYLAND_DISPLAY="$WAYLAND_DISPLAY" \
"$HERE/usr/bin/vietc" >/dev/null &
echo "$password" | sudo -S $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null &
DAEMON_PID=$!
fi
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
if [ "$(id -u)" = "0" ]; then
# 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
sudo $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null &
DAEMON_PID=$!
fi
@ -212,7 +193,6 @@ echo ""
# Auto build if appimagetool exists
if [ -f "$SCRIPT_DIR/appimagetool" ]; then
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"
elif command -v appimagetool &>/dev/null; then
echo "=== Running system appimagetool ==="

View file

@ -15,11 +15,19 @@ pub struct KeyEvent {
impl KeyEvent {
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 {
Self { code, value, action: KeyAction::Release }
Self {
code,
value,
action: KeyAction::Release,
}
}
pub fn is_press(&self) -> bool {
@ -64,6 +72,12 @@ pub trait KeyInjector {
}
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 {

View file

@ -58,8 +58,7 @@ impl UinputInjector {
ioctl(fd, UI_DEV_SETUP, &usetup as *const uinput_setup as u64)
.map_err(|e| format!("UI_DEV_SETUP failed: {}", e))?;
ioctl(fd, UI_DEV_CREATE, 0)
.map_err(|e| format!("UI_DEV_CREATE failed: {}", e))?;
ioctl(fd, UI_DEV_CREATE, 0).map_err(|e| format!("UI_DEV_CREATE failed: {}", e))?;
// Small delay for device to be ready
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) {
let event = input_event {
time: timeval { tv_sec: 0, tv_usec: 0 },
time: timeval {
tv_sec: 0,
tv_usec: 0,
},
type_,
code,
value,
@ -78,7 +80,33 @@ impl UinputInjector {
unsafe {
let ptr = &event as *const input_event as *const u8;
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 {
fn send_backspace(&self) -> InjectResult {
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(0, 0, 0); // EV_SYN
self.send_uinput_event(0, 0, 0); // SYN
std::thread::sleep(std::time::Duration::from_millis(2));
InjectResult::Success
}
fn send_key_event(&self, keycode: u16, value: i32) -> InjectResult {
self.send_uinput_event(EV_KEY, keycode, value);
self.send_uinput_event(0, 0, 0);
std::thread::sleep(std::time::Duration::from_millis(2));
InjectResult::Success
}
fn send_char(&self, ch: char) -> InjectResult {
if let Some(keycode) = char_to_linux_keycode(ch) {
let needs_shift = ch.is_uppercase() || "!@#$%^&*()_+{}|:\"<>?".contains(ch);
if needs_shift {
self.send_uinput_event(EV_KEY, 42, 1); // KEY_LEFTSHIFT
}
self.send_uinput_event(EV_KEY, keycode, 1);
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);
self.send_key_stroke(keycode, needs_shift);
eprintln!(
"[vietc] send_char: ASCII '{}' via uinput",
ch.escape_default()
);
return InjectResult::Success;
}
// Unicode: copy to clipboard and paste (preserves uinput ordering)
self.paste_string(&ch.to_string());
// Unicode character: use clipboard fallback for reliable injection
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
}
fn send_string(&self, s: &str) -> InjectResult {
// If all ASCII, use keycodes directly (fast path)
if s.chars().all(|c| char_to_linux_keycode(c).is_some()) {
// ASCII characters: inject directly via uinput keycodes
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() {
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 {
// 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);
}
InjectResult::Success
@ -132,10 +211,21 @@ impl KeyInjector for UinputInjector {
fn inject_replacement(&self, backspaces: usize, text: &str) -> InjectResult {
self.inject_replacement_atomic(backspaces, text)
}
fn flush(&self) -> InjectResult {
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 {
@ -160,7 +250,8 @@ impl UinputInjector {
let pw = libc::getpwuid(uid);
if !pw.is_null() {
let name = std::ffi::CStr::from_ptr((*pw).pw_name)
.to_string_lossy().into_owned();
.to_string_lossy()
.into_owned();
if !name.is_empty() {
return Some(name);
}
@ -176,7 +267,8 @@ impl UinputInjector {
let pw = libc::getpwuid(uid);
if !pw.is_null() {
let name = std::ffi::CStr::from_ptr((*pw).pw_name)
.to_string_lossy().into_owned();
.to_string_lossy()
.into_owned();
if !name.is_empty() {
return Some(name);
}
@ -247,45 +339,9 @@ impl UinputInjector {
/// 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.
fn run_as_user(program: &str, args: &[&str]) -> std::process::Output {
let is_root = unsafe { libc::getuid() == 0 };
if is_root {
if let Some((uid, gid)) = Self::get_original_uid_gid() {
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));
}
let mut cmd = Self::user_cmd(program);
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,
Err(e) => {
eprintln!("[vietc] Failed to run {}: {}", program, e);
@ -304,9 +360,22 @@ impl UinputInjector {
/// best available method: ydotool (uinput) for ASCII, xdotool (X11) or
/// clipboard for Unicode.
fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult {
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 {
for _ in 0..backspaces {
let _ = self.send_backspace();
@ -315,47 +384,81 @@ impl UinputInjector {
for ch in text.chars() {
let _ = self.send_char(ch);
}
eprintln!("[vietc] ASCII injection complete");
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();
static HAS_WTYPE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
static HAS_XDOTOOL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
if is_wayland {
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());
}
let has_xdotool = if is_wayland {
false
} else {
let has_xdotool = *HAS_XDOTOOL.get_or_init(|| {
*HAS_XDOTOOL.get_or_init(|| {
std::process::Command::new("which")
.arg("xdotool")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
});
})
};
static HAS_WTYPE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
let has_wtype = if !is_wayland {
false
} else {
*HAS_WTYPE.get_or_init(|| {
std::process::Command::new("which")
.arg("wtype")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
})
};
if is_wayland {
if has_wtype {
eprintln!(
"[vietc] Unicode detected ({} chars), injecting via wtype",
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()
);
}
if is_wayland && has_wtype {
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();
@ -367,119 +470,135 @@ impl UinputInjector {
}
if !text.is_empty() {
args.push("type");
args.push("--clearmodifiers");
args.push(text);
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 inject failed: {}", String::from_utf8_lossy(&output.stderr).trim());
}
eprintln!(
"[vietc] xdotool failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
} else if !is_wayland {
eprintln!("[vietc] xdotool not found, trying clipboard fallback...");
}
// Fallback: Clipboard copy + paste.
// This is safe because both backspaces and Ctrl+V are injected into the SAME uinput device.
// 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);
if copied {
eprintln!(
"[vietc] Clipboard fallback: copied '{}' and will Ctrl+V",
text
);
if backspaces > 0 {
for _ in 0..backspaces {
let _ = self.send_backspace();
}
}
eprintln!("[vietc] Sending Ctrl+V");
self.send_ctrl_v();
InjectResult::Success
// Record pasted text for future delete/backspace operations
let output = Self::run_as_user("vietc", &["update-pasted", "-text", text]);
if output.status.success() {
eprintln!("[vietc] update_pasted_text success");
} else {
eprintln!("[vietc] clipboard copy failed during fallback");
// Absolute last resort: try uinput backspaces followed by individual unicode paste_string
eprintln!("[vietc] update_pasted_text call ignored (not critical)");
}
eprintln!("[vietc] Clipboard injection complete");
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();
}
}
self.paste_string(text);
InjectResult::Success
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.
/// Only used as a last resort if Wayland/X11 direct typing tools are
/// unavailable. Prefers ydotool (uinput, works everywhere) to avoid
/// clipboard pollution.
/// Only used as a last resort if Wayland/X11 direct typing tools are unavailable.
/// Tries xdotool first (X11/XWayland), then clipboard fallback.
fn paste_string(&self, s: &str) {
// Try ydotool first (uinput-based, no display server needed).
let ydotool_result = std::process::Command::new("ydotool")
.args(["type", s])
.output();
if let Ok(output) = ydotool_result {
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
if is_wayland {
eprintln!("[vietc] paste_string: trying wtype...");
let output = Self::run_as_user("wtype", &["--", s]);
if output.status.success() {
eprintln!("[vietc] ydotool OK");
eprintln!("[vietc] paste_string: wtype success");
return;
}
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
eprintln!("[vietc] ydotool failed: {}", stderr.trim());
}
}
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]);
eprintln!("[vietc] paste_string: wtype failed, trying clipboard...");
} else {
// 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] xdotool OK");
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;
}
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
eprintln!("[vietc] xdotool failed: {}", stderr.trim());
eprintln!("[vietc] paste_string: xdotool failed, trying clipboard...");
}
// Try wtype (Wayland-native): needs Wayland session, run through run_as_user
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...");
// Clipboard fallback: copy + paste via our uinput device
let copied = self.copy_to_clipboard(s);
if copied {
eprintln!("[vietc] clipboard OK, sending Ctrl+V");
eprintln!("[vietc] paste_string: clipboard OK, sending Ctrl+V");
self.send_ctrl_v();
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.
fn user_cmd(program: &str) -> std::process::Command {
let is_root = unsafe { libc::getuid() == 0 };
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 xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default();
let display = std::env::var("DISPLAY").unwrap_or_default();
let mut cmd = std::process::Command::new("sudo");
cmd.args(["-u", &original_user, "env"]);
let xauthority = std::env::var("XAUTHORITY").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.arg(format!("WAYLAND_DISPLAY={}", wayland_display));
cmd.env("WAYLAND_DISPLAY", wayland_display);
}
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() {
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;
}
}
@ -508,7 +627,10 @@ impl UinputInjector {
eprintln!("[vietc] clipboard: wl-copy OK");
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 {
eprintln!("[vietc] clipboard: wl-copy error: {}", e);
}
@ -550,13 +672,22 @@ impl UinputInjector {
/// Send Ctrl+V through our uinput device.
fn send_ctrl_v(&self) {
self.send_uinput_event(EV_KEY, 29, 1); // KEY_LEFTCTRL
self.send_uinput_event(EV_KEY, 47, 1); // KEY_V
self.send_uinput_event(EV_KEY, 47, 0);
self.send_uinput_event(EV_KEY, 29, 0);
self.send_uinput_event(0, 0, 0);
}
self.send_uinput_event(EV_KEY, 29, 1); // KEY_LEFTCTRL 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, 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 {
@ -617,7 +748,11 @@ fn char_to_linux_keycode(ch: char) -> Option<u16> {
}
// 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 {
let result = libc::ioctl(fd, request, arg);
if result < 0 {

View file

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

View file

@ -57,11 +57,24 @@ impl X11Lib {
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_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));
let x_open_display = std::mem::transmute(dlsym(
x11_handle,
b"XOpenDisplay\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 {
x11_handle,
@ -91,43 +104,96 @@ const X11_KEYCODE_OFFSET: u32 = 8;
// X11 keycodes for common ASCII characters
fn char_to_keycode(ch: char) -> Option<(u32, bool)> {
match ch {
'a' => Some((30 + X11_KEYCODE_OFFSET, false)), 'b' => Some((48 + X11_KEYCODE_OFFSET, false)),
'c' => Some((46 + X11_KEYCODE_OFFSET, false)), 'd' => Some((32 + X11_KEYCODE_OFFSET, false)),
'e' => Some((18 + X11_KEYCODE_OFFSET, false)), 'f' => Some((33 + X11_KEYCODE_OFFSET, false)),
'g' => Some((34 + X11_KEYCODE_OFFSET, false)), 'h' => Some((35 + X11_KEYCODE_OFFSET, false)),
'i' => Some((23 + X11_KEYCODE_OFFSET, false)), 'j' => Some((36 + X11_KEYCODE_OFFSET, false)),
'k' => Some((37 + X11_KEYCODE_OFFSET, false)), 'l' => Some((38 + X11_KEYCODE_OFFSET, false)),
'm' => Some((50 + X11_KEYCODE_OFFSET, false)), 'n' => Some((49 + X11_KEYCODE_OFFSET, false)),
'o' => Some((24 + X11_KEYCODE_OFFSET, false)), 'p' => Some((25 + X11_KEYCODE_OFFSET, false)),
'q' => Some((16 + X11_KEYCODE_OFFSET, false)), 'r' => Some((19 + X11_KEYCODE_OFFSET, false)),
's' => Some((31 + X11_KEYCODE_OFFSET, false)), 't' => Some((20 + X11_KEYCODE_OFFSET, false)),
'u' => Some((22 + X11_KEYCODE_OFFSET, false)), 'v' => Some((47 + X11_KEYCODE_OFFSET, false)),
'w' => Some((17 + X11_KEYCODE_OFFSET, false)), 'x' => Some((45 + X11_KEYCODE_OFFSET, false)),
'y' => Some((21 + X11_KEYCODE_OFFSET, false)), 'z' => Some((44 + X11_KEYCODE_OFFSET, false)),
'A' => Some((30 + X11_KEYCODE_OFFSET, true)), 'B' => Some((48 + X11_KEYCODE_OFFSET, true)),
'C' => Some((46 + X11_KEYCODE_OFFSET, true)), 'D' => Some((32 + X11_KEYCODE_OFFSET, true)),
'E' => Some((18 + X11_KEYCODE_OFFSET, true)), 'F' => Some((33 + X11_KEYCODE_OFFSET, true)),
'G' => Some((34 + X11_KEYCODE_OFFSET, true)), 'H' => Some((35 + X11_KEYCODE_OFFSET, true)),
'I' => Some((23 + X11_KEYCODE_OFFSET, true)), 'J' => Some((36 + X11_KEYCODE_OFFSET, true)),
'K' => Some((37 + X11_KEYCODE_OFFSET, true)), 'L' => Some((38 + X11_KEYCODE_OFFSET, true)),
'M' => Some((50 + X11_KEYCODE_OFFSET, true)), 'N' => Some((49 + X11_KEYCODE_OFFSET, true)),
'O' => Some((24 + X11_KEYCODE_OFFSET, true)), 'P' => Some((25 + X11_KEYCODE_OFFSET, true)),
'Q' => Some((16 + X11_KEYCODE_OFFSET, true)), 'R' => Some((19 + X11_KEYCODE_OFFSET, true)),
'S' => Some((31 + X11_KEYCODE_OFFSET, true)), 'T' => Some((20 + X11_KEYCODE_OFFSET, true)),
'U' => Some((22 + X11_KEYCODE_OFFSET, true)), 'V' => Some((47 + X11_KEYCODE_OFFSET, true)),
'W' => Some((17 + X11_KEYCODE_OFFSET, true)), 'X' => Some((45 + X11_KEYCODE_OFFSET, true)),
'Y' => Some((21 + X11_KEYCODE_OFFSET, true)), 'Z' => Some((44 + X11_KEYCODE_OFFSET, true)),
'0' => Some((11 + X11_KEYCODE_OFFSET, false)), '1' => Some((2 + X11_KEYCODE_OFFSET, false)),
'2' => Some((3 + X11_KEYCODE_OFFSET, false)), '3' => Some((4 + X11_KEYCODE_OFFSET, false)),
'4' => Some((5 + X11_KEYCODE_OFFSET, false)), '5' => Some((6 + X11_KEYCODE_OFFSET, false)),
'6' => Some((7 + X11_KEYCODE_OFFSET, false)), '7' => Some((8 + X11_KEYCODE_OFFSET, false)),
'8' => Some((9 + X11_KEYCODE_OFFSET, false)), '9' => Some((10 + X11_KEYCODE_OFFSET, false)),
' ' => Some((57 + X11_KEYCODE_OFFSET, false)), '.' => Some((52 + X11_KEYCODE_OFFSET, false)),
',' => Some((51 + X11_KEYCODE_OFFSET, false)), '-' => Some((12 + X11_KEYCODE_OFFSET, false)),
'=' => Some((13 + X11_KEYCODE_OFFSET, false)), ';' => Some((39 + X11_KEYCODE_OFFSET, false)),
'\'' => Some((40 + X11_KEYCODE_OFFSET, false)), '/' => Some((53 + X11_KEYCODE_OFFSET, false)),
'\\' => Some((43 + X11_KEYCODE_OFFSET, false)), '`' => Some((41 + X11_KEYCODE_OFFSET, false)),
'[' => Some((26 + X11_KEYCODE_OFFSET, false)), ']' => Some((27 + X11_KEYCODE_OFFSET, false)),
'a' => Some((30, false)),
'b' => Some((48, false)),
'c' => Some((46, false)),
'd' => Some((32, false)),
'e' => Some((18, false)),
'f' => Some((33, false)),
'g' => Some((34, false)),
'h' => Some((35, false)),
'i' => Some((23, false)),
'j' => Some((36, false)),
'k' => Some((37, false)),
'l' => Some((38, false)),
'm' => Some((50, false)),
'n' => Some((49, false)),
'o' => Some((24, false)),
'p' => Some((25, false)),
'q' => Some((16, false)),
'r' => Some((19, false)),
's' => Some((31, false)),
't' => Some((20, false)),
'u' => Some((22, false)),
'v' => Some((47, false)),
'w' => Some((17, false)),
'x' => Some((45, false)),
'y' => Some((21, false)),
'z' => Some((44, false)),
'A' => Some((30, true)),
'B' => Some((48, true)),
'C' => Some((46, true)),
'D' => Some((32, true)),
'E' => Some((18, true)),
'F' => Some((33, true)),
'G' => Some((34, true)),
'H' => Some((35, true)),
'I' => Some((23, true)),
'J' => Some((36, true)),
'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,
}
}
@ -151,7 +217,11 @@ impl X11Injector {
return Err("Cannot open X11 display. Is DISPLAY set?".into());
}
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");
clipboard_cmd.args(["-selection", "clipboard"]);
clipboard_cmd.stdin(std::process::Stdio::piped());
let copied = clipboard_cmd.spawn()
let copied = clipboard_cmd
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(text.as_bytes())?;
@ -326,15 +397,27 @@ impl KeyInjector for X11Injector {
InjectResult::Success
}
}
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
}
}
impl Drop for X11Injector {
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,
}
fn default_input_method() -> String { "telex".into() }
fn default_toggle_key() -> String { "space".into() }
fn default_start_enabled() -> bool { true }
fn default_grab() -> bool { true }
fn default_true() -> bool { true }
fn default_false() -> bool { false }
fn default_restore_keys() -> Vec<String> { vec!["space".into(), "escape".into()] }
fn default_input_method() -> String {
"telex".into()
}
fn default_toggle_key() -> String {
"space".into()
}
fn default_start_enabled() -> bool {
true
}
fn default_grab() -> bool {
true
}
fn default_true() -> bool {
true
}
fn default_false() -> bool {
false
}
fn default_restore_keys() -> Vec<String> {
vec!["space".into(), "escape".into()]
}
impl Default for Config {
fn default() -> Self {
@ -92,7 +106,6 @@ impl Default for Config {
}
}
impl Config {
pub fn load() -> Self {
for path in config_paths() {
@ -142,7 +155,10 @@ fn config_paths() -> Vec<PathBuf> {
pub fn is_autostart_installed() -> bool {
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 {
false
}
@ -164,9 +180,7 @@ pub fn install_autostart() {
let desktop_file = autostart_dir.join("vietc-tray.desktop");
let _ = fs::create_dir_all(&autostart_dir);
let exec_path = std::env::var("APPIMAGE")
.ok()
.unwrap_or_else(|| {
let exec_path = std::env::var("APPIMAGE").ok().unwrap_or_else(|| {
std::env::current_exe()
.unwrap_or_else(|_| PathBuf::from("vietc-tray"))
.to_string_lossy()

View file

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