Fix typing race conditions with unified channel injection, add persistent logging, and align config schemas
This commit is contained in:
parent
42595d4bae
commit
f618c3a5b5
19 changed files with 4048 additions and 1196 deletions
|
|
@ -27,6 +27,9 @@ pub struct Config {
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub grab: bool,
|
pub grab: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_false")]
|
||||||
|
pub debug: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -75,6 +78,7 @@ fn default_input_method() -> String { "telex".into() }
|
||||||
fn default_toggle_key() -> String { "space".into() }
|
fn default_toggle_key() -> String { "space".into() }
|
||||||
fn default_start_enabled() -> bool { true }
|
fn default_start_enabled() -> bool { true }
|
||||||
fn default_true() -> 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_restore_keys() -> Vec<String> { vec!["space".into(), "escape".into()] }
|
||||||
|
|
||||||
fn default_english_apps() -> Vec<String> {
|
fn default_english_apps() -> Vec<String> {
|
||||||
|
|
@ -160,6 +164,7 @@ impl Default for Config {
|
||||||
app_state: AppStateConfig::default(),
|
app_state: AppStateConfig::default(),
|
||||||
macros,
|
macros,
|
||||||
grab: false,
|
grab: false,
|
||||||
|
debug: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,62 @@ mod display;
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use app_state::AppStateManager;
|
use app_state::AppStateManager;
|
||||||
|
|
||||||
|
fn get_log_path() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|p| p.join("vietc").join("vietc.log"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_timestamp() -> String {
|
||||||
|
if let Ok(n) = std::time::SystemTime::now().duration_since(std::time::SystemTime::UNIX_EPOCH) {
|
||||||
|
let secs = n.as_secs();
|
||||||
|
let millis = n.subsec_millis();
|
||||||
|
unsafe {
|
||||||
|
let t = secs as libc::time_t;
|
||||||
|
let mut tm = std::mem::zeroed::<libc::tm>();
|
||||||
|
if !libc::localtime_r(&t, &mut tm).is_null() {
|
||||||
|
return format!(
|
||||||
|
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:03}",
|
||||||
|
tm.tm_year + 1900,
|
||||||
|
tm.tm_mon + 1,
|
||||||
|
tm.tm_mday,
|
||||||
|
tm.tm_hour,
|
||||||
|
tm.tm_min,
|
||||||
|
tm.tm_sec,
|
||||||
|
millis
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_info(msg: &str) {
|
||||||
|
eprintln!("{}", msg);
|
||||||
|
|
||||||
|
if let Some(log_path) = get_log_path() {
|
||||||
|
if let Some(parent) = log_path.parent() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate log if it exceeds 10MB
|
||||||
|
if let Ok(metadata) = fs::metadata(&log_path) {
|
||||||
|
if metadata.len() > 10 * 1024 * 1024 {
|
||||||
|
let backup_path = log_path.with_extension("log.old");
|
||||||
|
let _ = fs::rename(&log_path, backup_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut file) = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&log_path)
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
let timestamp = get_timestamp();
|
||||||
|
let _ = writeln!(file, "[{}] {}", timestamp, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct Daemon {
|
struct Daemon {
|
||||||
engine: Engine,
|
engine: Engine,
|
||||||
config: Config,
|
config: Config,
|
||||||
|
|
@ -77,7 +133,7 @@ impl Daemon {
|
||||||
if let Ok(content) = fs::read_to_string(&status_path) {
|
if let Ok(content) = fs::read_to_string(&status_path) {
|
||||||
let expect_enabled = content.trim() == "vn";
|
let expect_enabled = content.trim() == "vn";
|
||||||
if self.engine.is_enabled() != expect_enabled {
|
if self.engine.is_enabled() != expect_enabled {
|
||||||
eprintln!("[vietc] Syncing enabled status from file: {}", expect_enabled);
|
log_info(&format!("[vietc] Syncing enabled status from file: {}", expect_enabled));
|
||||||
self.engine.set_enabled(expect_enabled);
|
self.engine.set_enabled(expect_enabled);
|
||||||
self.engine_enabled.store(expect_enabled, Ordering::SeqCst);
|
self.engine_enabled.store(expect_enabled, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +150,7 @@ impl Daemon {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("[vietc] Config changed, reloading...");
|
log_info("[vietc] Config changed, reloading...");
|
||||||
match Config::load_from(&self.config_path) {
|
match Config::load_from(&self.config_path) {
|
||||||
Ok(new_config) => {
|
Ok(new_config) => {
|
||||||
let method = match new_config.input_method.as_str() {
|
let method = match new_config.input_method.as_str() {
|
||||||
|
|
@ -116,11 +172,11 @@ impl Daemon {
|
||||||
self.grab_enabled = new_config.grab;
|
self.grab_enabled = new_config.grab;
|
||||||
self.config = new_config;
|
self.config = new_config;
|
||||||
self.config_modified = modified;
|
self.config_modified = modified;
|
||||||
eprintln!("[vietc] Config reloaded successfully");
|
log_info("[vietc] Config reloaded successfully");
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[vietc] Failed to reload config: {}", e);
|
log_info(&format!("[vietc] Failed to reload config: {}", e));
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -130,7 +186,7 @@ impl Daemon {
|
||||||
let mut commands = Vec::new();
|
let mut commands = Vec::new();
|
||||||
|
|
||||||
if let Some(event) = self.engine.process_key(ch) {
|
if let Some(event) = self.engine.process_key(ch) {
|
||||||
eprintln!("[vietc] key='{}' buf='{}' -> {:?}", ch, self.engine.buffer(), event);
|
log_info(&format!("[vietc] key='{}' buf='{}' -> {:?}", ch, self.engine.buffer(), event));
|
||||||
match event {
|
match event {
|
||||||
EngineEvent::Flush(text) => {
|
EngineEvent::Flush(text) => {
|
||||||
commands.push(OutputCommand::Type(text));
|
commands.push(OutputCommand::Type(text));
|
||||||
|
|
@ -153,7 +209,7 @@ impl Daemon {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
eprintln!("[vietc] key='{}' -> (no event, buf='{}')", ch, self.engine.buffer());
|
log_info(&format!("[vietc] key='{}' -> (no event, buf='{}')", ch, self.engine.buffer()));
|
||||||
}
|
}
|
||||||
|
|
||||||
commands
|
commands
|
||||||
|
|
@ -191,11 +247,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let display = display::detect_display_server();
|
let display = display::detect_display_server();
|
||||||
let compositor = display::detect_compositor();
|
let compositor = display::detect_compositor();
|
||||||
|
|
||||||
eprintln!("Viet+ Daemon v{}", env!("CARGO_PKG_VERSION"));
|
log_info(&format!("Viet+ Daemon v{}", env!("CARGO_PKG_VERSION")));
|
||||||
eprintln!("Display: {:?} ({})", display, compositor.unwrap_or_else(|| "unknown".into()));
|
log_info(&format!("Display: {:?} ({})", display, compositor.unwrap_or_else(|| "unknown".into())));
|
||||||
eprintln!("Input method: {:?}", daemon.config.input_method);
|
log_info(&format!("Input method: {:?}", daemon.config.input_method));
|
||||||
eprintln!("Toggle key: Ctrl+{}", daemon.config.toggle_key.to_uppercase());
|
log_info(&format!("Toggle key: Ctrl+{}", daemon.config.toggle_key.to_uppercase()));
|
||||||
eprintln!("App memory: {}", if daemon.config.app_state.enabled { "ON" } else { "OFF" });
|
log_info(&format!("App memory: {}", if daemon.config.app_state.enabled { "ON" } else { "OFF" }));
|
||||||
|
|
||||||
// Spawn background monitor for active window, config changes, and status changes
|
// Spawn background monitor for active window, config changes, and status changes
|
||||||
let shared_active_window = Arc::new(Mutex::new(String::new()));
|
let shared_active_window = Arc::new(Mutex::new(String::new()));
|
||||||
|
|
@ -254,7 +310,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
||||||
match open_keyboard_device() {
|
match open_keyboard_device() {
|
||||||
Ok((device, path)) => {
|
Ok((device, path)) => {
|
||||||
eprintln!("[vietc] Keyboard device: {}", path);
|
log_info(&format!("[vietc] Keyboard device: {}", path));
|
||||||
run_with_evdev(
|
run_with_evdev(
|
||||||
device,
|
device,
|
||||||
&mut daemon,
|
&mut daemon,
|
||||||
|
|
@ -266,8 +322,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[vietc] No keyboard device: {}", e);
|
log_info(&format!("[vietc] No keyboard device: {}", e));
|
||||||
eprintln!("[vietc] Running in stdin test mode");
|
log_info("[vietc] Running in stdin test mode");
|
||||||
run_stdin_mode(
|
run_stdin_mode(
|
||||||
&mut daemon,
|
&mut daemon,
|
||||||
shared_active_window,
|
shared_active_window,
|
||||||
|
|
@ -362,22 +418,23 @@ fn run_with_evdev(
|
||||||
let grabbed = if daemon.grab_enabled {
|
let grabbed = if daemon.grab_enabled {
|
||||||
match device.grab() {
|
match device.grab() {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
eprintln!("[vietc] Keyboard grabbed — race condition eliminated");
|
log_info("[vietc] Keyboard grabbed — race condition eliminated");
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[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));
|
||||||
eprintln!("[vietc] Falling back to non-grabbing mode (may have race)");
|
log_info("[vietc] Falling back to non-grabbing mode (may have race)");
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
eprintln!("[vietc] Keyboard grab disabled (config grab = false)");
|
log_info("[vietc] Keyboard grab disabled (config grab = false)");
|
||||||
eprintln!("[vietc] Set grab = true in vietc.toml to enable (needs root)");
|
log_info("[vietc] Set grab = true in vietc.toml to enable (needs root)");
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut consumed_keys: HashSet<u16> = HashSet::new();
|
let mut consumed_keys: HashSet<u16> = HashSet::new();
|
||||||
|
let mut last_active_window = String::new();
|
||||||
|
|
||||||
// Safety: if grab is active and no events arrive for 30 seconds,
|
// Safety: if grab is active and no events arrive for 30 seconds,
|
||||||
// release the grab so the user isn't locked out.
|
// release the grab so the user isn't locked out.
|
||||||
|
|
@ -386,7 +443,7 @@ fn run_with_evdev(
|
||||||
loop {
|
loop {
|
||||||
// Check for event timeout (grab safety)
|
// Check for event timeout (grab safety)
|
||||||
if grabbed && last_event_time.elapsed() > std::time::Duration::from_secs(30) {
|
if grabbed && last_event_time.elapsed() > std::time::Duration::from_secs(30) {
|
||||||
eprintln!("[vietc] No events for 30s — releasing grab timeout, releasing grab for safety");
|
log_info("[vietc] No events for 30s — releasing grab timeout, releasing grab for safety");
|
||||||
let _ = device.ungrab();
|
let _ = device.ungrab();
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -401,6 +458,17 @@ fn run_with_evdev(
|
||||||
status_changed.store(false, Ordering::SeqCst);
|
status_changed.store(false, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track window changes and reset engine buffer
|
||||||
|
{
|
||||||
|
let active_window = shared_active_window.lock().unwrap().clone();
|
||||||
|
if active_window != last_active_window {
|
||||||
|
log_info(&format!("[vietc] Window changed: '{}' -> '{}'", last_active_window, active_window));
|
||||||
|
last_active_window = active_window.clone();
|
||||||
|
daemon.engine.reset();
|
||||||
|
log_info("[vietc] Reset engine buffer due to window change");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for app changes instantly using the cached state from background thread
|
// Check for app changes instantly using the cached state from background thread
|
||||||
if daemon.config.app_state.enabled {
|
if daemon.config.app_state.enabled {
|
||||||
let active_window = shared_active_window.lock().unwrap().clone();
|
let active_window = shared_active_window.lock().unwrap().clone();
|
||||||
|
|
@ -505,11 +573,11 @@ fn run_stdin_mode(
|
||||||
|
|
||||||
|
|
||||||
if !io::stdin().is_terminal() {
|
if !io::stdin().is_terminal() {
|
||||||
eprintln!("[vietc] Warning: No keyboard device and no terminal.");
|
log_info("[vietc] Warning: No keyboard device and no terminal.");
|
||||||
eprintln!("[vietc] Retrying keyboard access every 5 seconds...");
|
log_info("[vietc] Retrying keyboard access every 5 seconds...");
|
||||||
eprintln!("[vietc] Ensure you are in the 'input' group:");
|
log_info("[vietc] Ensure you are in the 'input' group:");
|
||||||
eprintln!(" sudo usermod -aG input $USER");
|
log_info(" sudo usermod -aG input $USER");
|
||||||
eprintln!(" Then log out and back in.");
|
log_info(" Then log out and back in.");
|
||||||
|
|
||||||
// Retry loop: periodically attempt to reopen the keyboard device
|
// Retry loop: periodically attempt to reopen the keyboard device
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -526,7 +594,7 @@ fn run_stdin_mode(
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok((device, path)) = open_keyboard_device() {
|
if let Ok((device, path)) = open_keyboard_device() {
|
||||||
eprintln!("[vietc] Keyboard device found: {}", path);
|
log_info(&format!("[vietc] Keyboard device found: {}", path));
|
||||||
return run_with_evdev(
|
return run_with_evdev(
|
||||||
device, daemon,
|
device, daemon,
|
||||||
shared_active_window,
|
shared_active_window,
|
||||||
|
|
@ -541,8 +609,9 @@ fn run_stdin_mode(
|
||||||
|
|
||||||
let injector = create_injector(display)?;
|
let injector = create_injector(display)?;
|
||||||
let mut buffer = [0u8; 1];
|
let mut buffer = [0u8; 1];
|
||||||
|
let mut last_active_window = String::new();
|
||||||
|
|
||||||
eprintln!("[vietc] Type to test, Ctrl+C to exit");
|
log_info("[vietc] Type to test, Ctrl+C to exit");
|
||||||
|
|
||||||
let stdin = io::stdin();
|
let stdin = io::stdin();
|
||||||
let mut handle = stdin.lock();
|
let mut handle = stdin.lock();
|
||||||
|
|
@ -553,6 +622,17 @@ fn run_stdin_mode(
|
||||||
status_changed.store(false, Ordering::SeqCst);
|
status_changed.store(false, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track window changes and reset engine buffer
|
||||||
|
{
|
||||||
|
let active_window = shared_active_window.lock().unwrap().clone();
|
||||||
|
if active_window != last_active_window {
|
||||||
|
log_info(&format!("[vietc] Window changed: '{}' -> '{}'", last_active_window, active_window));
|
||||||
|
last_active_window = active_window.clone();
|
||||||
|
daemon.engine.reset();
|
||||||
|
log_info("[vietc] Reset engine buffer due to window change");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for app changes instantly using the cached state from background thread
|
// Check for app changes instantly using the cached state from background thread
|
||||||
if daemon.config.app_state.enabled {
|
if daemon.config.app_state.enabled {
|
||||||
let active_window = shared_active_window.lock().unwrap().clone();
|
let active_window = shared_active_window.lock().unwrap().clone();
|
||||||
|
|
@ -573,7 +653,7 @@ fn run_stdin_mode(
|
||||||
execute_commands(&*injector, &commands, false);
|
execute_commands(&*injector, &commands, false);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[vietc] Read error: {}", e);
|
log_info(&format!("[vietc] Read error: {}", e));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -593,18 +673,18 @@ fn execute_commands(injector: &dyn vietc_protocol::KeyInjector, commands: &[Outp
|
||||||
match cmd {
|
match cmd {
|
||||||
OutputCommand::Backspace(count) => {
|
OutputCommand::Backspace(count) => {
|
||||||
let adjusted = if grabbed { count.saturating_sub(1) } else { *count };
|
let adjusted = if grabbed { count.saturating_sub(1) } else { *count };
|
||||||
eprintln!("[vietc] cmd: Backspace({}) -> adjusted={}", count, adjusted);
|
log_info(&format!("[vietc] cmd: Backspace({}) -> adjusted={}", count, adjusted));
|
||||||
pending_backspaces += adjusted;
|
pending_backspaces += adjusted;
|
||||||
}
|
}
|
||||||
OutputCommand::Type(text) => {
|
OutputCommand::Type(text) => {
|
||||||
eprintln!("[vietc] cmd: Type(\"{}\")", text);
|
log_info(&format!("[vietc] cmd: Type(\"{}\")", text));
|
||||||
pending_text.push_str(text);
|
pending_text.push_str(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if pending_backspaces > 0 || !pending_text.is_empty() {
|
if pending_backspaces > 0 || !pending_text.is_empty() {
|
||||||
eprintln!("[vietc] inject: BS={} text=\"{}\"", pending_backspaces, pending_text);
|
log_info(&format!("[vietc] inject: BS={} text=\"{}\"", pending_backspaces, pending_text));
|
||||||
injector.inject_replacement(pending_backspaces, &pending_text);
|
injector.inject_replacement(pending_backspaces, &pending_text);
|
||||||
}
|
}
|
||||||
injector.flush();
|
injector.flush();
|
||||||
|
|
@ -615,7 +695,7 @@ fn create_injector(display: display::DisplayServer) -> Result<Box<dyn vietc_prot
|
||||||
#[cfg(feature = "wayland")]
|
#[cfg(feature = "wayland")]
|
||||||
{
|
{
|
||||||
let _ctx = vietc_protocol::wayland_im::WaylandIMContext::new();
|
let _ctx = vietc_protocol::wayland_im::WaylandIMContext::new();
|
||||||
eprintln!("[vietc] Wayland input method context initialized");
|
log_info("[vietc] Wayland input method context initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use uinput as primary injector — it handles ASCII via direct keycodes
|
// Use uinput as primary injector — it handles ASCII via direct keycodes
|
||||||
|
|
@ -624,11 +704,11 @@ fn create_injector(display: display::DisplayServer) -> Result<Box<dyn vietc_prot
|
||||||
// (ASCII) and ydotool (Unicode) interleaving.
|
// (ASCII) and ydotool (Unicode) interleaving.
|
||||||
match vietc_protocol::uinput_monitor::UinputInjector::new("vietc") {
|
match vietc_protocol::uinput_monitor::UinputInjector::new("vietc") {
|
||||||
Ok(injector) => {
|
Ok(injector) => {
|
||||||
eprintln!("[vietc] Using uinput injection (primary)");
|
log_info("[vietc] Using uinput injection (primary)");
|
||||||
return Ok(Box::new(injector));
|
return Ok(Box::new(injector));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[vietc] uinput not available: {}", e);
|
log_info(&format!("[vietc] uinput not available: {}", e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -638,11 +718,11 @@ fn create_injector(display: display::DisplayServer) -> Result<Box<dyn vietc_prot
|
||||||
if display != display::DisplayServer::Wayland {
|
if display != display::DisplayServer::Wayland {
|
||||||
match vietc_protocol::x11_inject::X11Injector::new() {
|
match vietc_protocol::x11_inject::X11Injector::new() {
|
||||||
Ok(injector) => {
|
Ok(injector) => {
|
||||||
eprintln!("[vietc] Using X11 injection (XTEST fallback)");
|
log_info("[vietc] Using X11 injection (XTEST fallback)");
|
||||||
return Ok(Box::new(injector));
|
return Ok(Box::new(injector));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[vietc] X11 not available: {}", e);
|
log_info(&format!("[vietc] X11 not available: {}", e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
89
engine/examples/gen_tests.rs
Normal file
89
engine/examples/gen_tests.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use vietc_engine::{Engine, EngineEvent, InputMethod};
|
||||||
|
|
||||||
|
fn get_display(events: &[EngineEvent]) -> String {
|
||||||
|
let mut display = String::new();
|
||||||
|
for ev in events {
|
||||||
|
match ev {
|
||||||
|
EngineEvent::Flush(text) => { if !display.ends_with(text) { display.push_str(text); } }
|
||||||
|
EngineEvent::Insert(text) => display.push_str(text),
|
||||||
|
EngineEvent::Replace { backspaces, insert } => {
|
||||||
|
for _ in 0..*backspaces { display.pop(); }
|
||||||
|
display.push_str(insert);
|
||||||
|
}
|
||||||
|
EngineEvent::AutoRestore(word) => {
|
||||||
|
for _ in 0..word.len() { display.pop(); }
|
||||||
|
display.push_str(word);
|
||||||
|
}
|
||||||
|
EngineEvent::UndoTones { backspaces, restored } => {
|
||||||
|
for _ in 0..*backspaces { display.pop(); }
|
||||||
|
display.push_str(restored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
display
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_input(e: &mut Engine, input: &str) -> Vec<EngineEvent> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for ch in input.chars() {
|
||||||
|
if let Some(ev) = e.process_key(ch) { events.push(ev); }
|
||||||
|
}
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIALS: &[&str] = &[
|
||||||
|
"", "b", "c", "ch", "d", "g", "gh", "h", "k", "kh", "l", "m", "n",
|
||||||
|
"ng", "ngh", "nh", "p", "ph", "q", "r", "s", "t", "th", "tr", "v", "x",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FINALS: &[&str] = &["", "c", "ch", "m", "n", "ng", "nh", "p", "t"];
|
||||||
|
|
||||||
|
fn is_valid(init: &str, fin: &str) -> bool {
|
||||||
|
if init == "ngh" && !fin.is_empty() && fin != "n" && fin != "ng" && fin != "nh" { return false; }
|
||||||
|
if init == "gh" && !fin.is_empty() { return false; }
|
||||||
|
if init == "q" { return false; }
|
||||||
|
if init == "g" && !fin.is_empty() && fin != "n" && fin != "ng" { return false; }
|
||||||
|
if fin == "ch" && init == "" { return false; }
|
||||||
|
if fin == "nh" && init == "" { return false; }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Telex base vowels (as typed, before mod)
|
||||||
|
let telex_vowels: Vec<(&str, &str)> = vec![
|
||||||
|
("a", "af"), ("a", "as"), ("a", "aj"), ("a", "ar"), ("a", "ax"),
|
||||||
|
("a", "aw"), ("a", "aa"),
|
||||||
|
("e", "ee"),
|
||||||
|
("o", "oo"), ("o", "ow"),
|
||||||
|
("u", "uw"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
let stdout = io::stdout();
|
||||||
|
let mut handle = stdout.lock();
|
||||||
|
|
||||||
|
for &init in INITIALS {
|
||||||
|
for &fin in FINALS {
|
||||||
|
if !is_valid(init, fin) { continue; }
|
||||||
|
for &(base, mod_str) in &telex_vowels {
|
||||||
|
let plain = format!("{}{}{}", init, base, fin);
|
||||||
|
let full = format!("{}{}", plain, mod_str);
|
||||||
|
if plain.len() > 10 { continue; }
|
||||||
|
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
let result = get_display(&process_input(&mut e, &full));
|
||||||
|
|
||||||
|
if !result.is_empty() && result.len() <= 12 && result != full && result != plain {
|
||||||
|
count += 1;
|
||||||
|
let _ = writeln!(handle, "{{\"i\":\"{full}\",\"e\":\"{result}\",\"m\":\"telex\"}}");
|
||||||
|
}
|
||||||
|
if count >= 1000 { break; }
|
||||||
|
}
|
||||||
|
if count >= 1000 { break; }
|
||||||
|
}
|
||||||
|
if count >= 1000 { break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("Generated {count} test cases");
|
||||||
|
}
|
||||||
74
engine/examples/trace_events.rs
Normal file
74
engine/examples/trace_events.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
use vietc_engine::{Engine, InputMethod, EngineEvent};
|
||||||
|
|
||||||
|
fn trace(input: &str, method: InputMethod) {
|
||||||
|
let mut e = Engine::new(method);
|
||||||
|
eprintln!("\n=== {:?}: {} ===", method, input);
|
||||||
|
eprintln!("Ch | prev_buf → new_buf | expected_screen | Event");
|
||||||
|
eprintln!("---+-----------+-----------+---------------------+------");
|
||||||
|
for ch in input.chars() {
|
||||||
|
let prev = e.buffer().to_string();
|
||||||
|
let event = e.process_key(ch);
|
||||||
|
let curr = e.buffer().to_string();
|
||||||
|
let expected = format!("{}{}", prev, ch);
|
||||||
|
let event_str = match &event {
|
||||||
|
Some(EngineEvent::Replace { backspaces, insert }) =>
|
||||||
|
format!("Replace({}, {:?})", backspaces, insert),
|
||||||
|
Some(EngineEvent::Insert(t)) => format!("Insert({:?})", t),
|
||||||
|
Some(EngineEvent::Flush(t)) => format!("Flush({:?})", t),
|
||||||
|
Some(EngineEvent::AutoRestore(w)) => format!("AutoRestore({:?})", w),
|
||||||
|
Some(EngineEvent::UndoTones { backspaces, restored }) =>
|
||||||
|
format!("UndoTones({}, {:?})", backspaces, restored),
|
||||||
|
None => "None".to_string(),
|
||||||
|
};
|
||||||
|
let backspaces = match &event {
|
||||||
|
Some(EngineEvent::Replace { backspaces, .. }) => format!("bs={}", backspaces),
|
||||||
|
_ => " ".to_string(),
|
||||||
|
};
|
||||||
|
eprintln!("'{}' | {:<9} → {:<9} | {:<19} | {}",
|
||||||
|
ch, prev, curr, expected, event_str);
|
||||||
|
if let Some(EngineEvent::Replace { backspaces, insert }) = &event {
|
||||||
|
// In grab mode, backspace - 1 (key consumed)
|
||||||
|
let grab_bs = backspaces.saturating_sub(1);
|
||||||
|
// In non-grab mode, full backspace
|
||||||
|
eprintln!(" | | | grab_bs={} non_grab_bs={} insert={:?}",
|
||||||
|
grab_bs, backspaces, insert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Flush
|
||||||
|
if let Some(event) = e.flush() {
|
||||||
|
eprintln!("FL | | | | {:?}", event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Category 1: Basic A group
|
||||||
|
trace("traan", InputMethod::Telex); // trâ
|
||||||
|
trace("traanw", InputMethod::Telex); // trân → w → trăn
|
||||||
|
trace("tranwa", InputMethod::Telex); // trăn → a → trân
|
||||||
|
|
||||||
|
// Category 2: Basic O group
|
||||||
|
trace("coon", InputMethod::Telex); // côn
|
||||||
|
trace("coonw", InputMethod::Telex); // côn → w → cơn
|
||||||
|
trace("conwo", InputMethod::Telex); // cơn → o → côn
|
||||||
|
|
||||||
|
// Category 3: Smart cluster
|
||||||
|
trace("chuoonw", InputMethod::Telex); // chuôn → w → chươn
|
||||||
|
trace("chuonwo", InputMethod::Telex); // chươn → o → chuôn
|
||||||
|
|
||||||
|
// Category 4: With tones
|
||||||
|
trace("traansw", InputMethod::Telex); // trấn → w → trắn
|
||||||
|
|
||||||
|
// Basic typing
|
||||||
|
trace("chaof ", InputMethod::Telex); // chào + space
|
||||||
|
|
||||||
|
// VNI tests
|
||||||
|
trace("tran6", InputMethod::Vni);
|
||||||
|
trace("tran61", InputMethod::Vni);
|
||||||
|
trace("tran618", InputMethod::Vni);
|
||||||
|
trace("con67", InputMethod::Vni);
|
||||||
|
trace("con627", InputMethod::Vni);
|
||||||
|
|
||||||
|
// Smart cluster VNI
|
||||||
|
trace("chuon67", InputMethod::Vni);
|
||||||
|
trace("chuon76", InputMethod::Vni);
|
||||||
|
}
|
||||||
1000
engine/gen_tests_output.json
Normal file
1000
engine/gen_tests_output.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,24 @@
|
||||||
use crate::engine::EngineEvent;
|
use crate::engine::EngineEvent;
|
||||||
|
|
||||||
const VOWELS: &[char] = &['a', 'e', 'i', 'o', 'u', 'y', 'ă', 'â', 'ê', 'ô', 'ơ', 'ư'];
|
const VOWELS: &[char] = &[
|
||||||
|
'a', 'e', 'i', 'o', 'u', 'y',
|
||||||
|
'ă', 'â', 'ê', 'ô', 'ơ', 'ư',
|
||||||
|
];
|
||||||
|
|
||||||
|
const VOWEL_ACCENTED: &[char] = &[
|
||||||
|
'a', 'á', 'à', 'ả', 'ã', 'ạ',
|
||||||
|
'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ',
|
||||||
|
'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ',
|
||||||
|
'e', 'é', 'è', 'ẻ', 'ẽ', 'ẹ',
|
||||||
|
'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ',
|
||||||
|
'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị',
|
||||||
|
'o', 'ó', 'ò', 'ỏ', 'õ', 'ọ',
|
||||||
|
'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ',
|
||||||
|
'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ',
|
||||||
|
'u', 'ú', 'ù', 'ủ', 'ũ', 'ụ',
|
||||||
|
'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự',
|
||||||
|
'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ',
|
||||||
|
];
|
||||||
|
|
||||||
/// Maximum number of characters to scan backward during flexible placement.
|
/// Maximum number of characters to scan backward during flexible placement.
|
||||||
/// Vietnamese vowel clusters are at most 3 characters; limiting the scan
|
/// Vietnamese vowel clusters are at most 3 characters; limiting the scan
|
||||||
|
|
@ -9,7 +27,39 @@ const VOWELS: &[char] = &['a', 'e', 'i', 'o', 'u', 'y', 'ă', 'â', 'ê', 'ô',
|
||||||
const MAX_FLEXIBLE_BACKTRACK: usize = 3;
|
const MAX_FLEXIBLE_BACKTRACK: usize = 3;
|
||||||
|
|
||||||
fn is_vowel(c: char) -> bool {
|
fn is_vowel(c: char) -> bool {
|
||||||
VOWELS.contains(&c)
|
VOWEL_ACCENTED.contains(&c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip tone from a Vietnamese vowel, returning (base_modified_vowel, tone_char_or_none)
|
||||||
|
/// where base_modified_vowel still has its shape modifier (e.g., 'â', 'ă', 'ô', 'ơ').
|
||||||
|
fn strip_tone(c: char) -> (char, Option<char>) {
|
||||||
|
match c {
|
||||||
|
'a' => ('a', None), 'á' => ('a', Some('s')), 'à' => ('a', Some('f')),
|
||||||
|
'ả' => ('a', Some('r')), 'ã' => ('a', Some('x')), 'ạ' => ('a', Some('j')),
|
||||||
|
'ă' => ('ă', None), 'ắ' => ('ă', Some('s')), 'ằ' => ('ă', Some('f')),
|
||||||
|
'ẳ' => ('ă', Some('r')), 'ẵ' => ('ă', Some('x')), 'ặ' => ('ă', Some('j')),
|
||||||
|
'â' => ('â', None), 'ấ' => ('â', Some('s')), 'ầ' => ('â', Some('f')),
|
||||||
|
'ẩ' => ('â', Some('r')), 'ẫ' => ('â', Some('x')), 'ậ' => ('â', Some('j')),
|
||||||
|
'e' => ('e', None), 'é' => ('e', Some('s')), 'è' => ('e', Some('f')),
|
||||||
|
'ẻ' => ('e', Some('r')), 'ẽ' => ('e', Some('x')), 'ẹ' => ('e', Some('j')),
|
||||||
|
'ê' => ('ê', None), 'ế' => ('ê', Some('s')), 'ề' => ('ê', Some('f')),
|
||||||
|
'ể' => ('ê', Some('r')), 'ễ' => ('ê', Some('x')), 'ệ' => ('ê', Some('j')),
|
||||||
|
'i' => ('i', None), 'í' => ('i', Some('s')), 'ì' => ('i', Some('f')),
|
||||||
|
'ỉ' => ('i', Some('r')), 'ĩ' => ('i', Some('x')), 'ị' => ('i', Some('j')),
|
||||||
|
'o' => ('o', None), 'ó' => ('o', Some('s')), 'ò' => ('o', Some('f')),
|
||||||
|
'ỏ' => ('o', Some('r')), 'õ' => ('o', Some('x')), 'ọ' => ('o', Some('j')),
|
||||||
|
'ô' => ('ô', None), 'ố' => ('ô', Some('s')), 'ồ' => ('ô', Some('f')),
|
||||||
|
'ổ' => ('ô', Some('r')), 'ỗ' => ('ô', Some('x')), 'ộ' => ('ô', Some('j')),
|
||||||
|
'ơ' => ('ơ', None), 'ớ' => ('ơ', Some('s')), 'ờ' => ('ơ', Some('f')),
|
||||||
|
'ở' => ('ơ', Some('r')), 'ỡ' => ('ơ', Some('x')), 'ợ' => ('ơ', Some('j')),
|
||||||
|
'u' => ('u', None), 'ú' => ('u', Some('s')), 'ù' => ('u', Some('f')),
|
||||||
|
'ủ' => ('u', Some('r')), 'ũ' => ('u', Some('x')), 'ụ' => ('u', Some('j')),
|
||||||
|
'ư' => ('ư', None), 'ứ' => ('ư', Some('s')), 'ừ' => ('ư', Some('f')),
|
||||||
|
'ử' => ('ư', Some('r')), 'ữ' => ('ư', Some('x')), 'ự' => ('ư', Some('j')),
|
||||||
|
'y' => ('y', None), 'ý' => ('y', Some('s')), 'ỳ' => ('y', Some('f')),
|
||||||
|
'ỷ' => ('y', Some('r')), 'ỹ' => ('y', Some('x')), 'ỵ' => ('y', Some('j')),
|
||||||
|
_ => (c, None),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_tone_to_vowel(vowel: char, tone: char) -> Option<char> {
|
fn apply_tone_to_vowel(vowel: char, tone: char) -> Option<char> {
|
||||||
|
|
@ -34,9 +84,38 @@ fn apply_tone_to_vowel(vowel: char, tone: char) -> Option<char> {
|
||||||
return Some(result);
|
return Some(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tone overriding: vowel already has a tone → strip it and apply the new one
|
||||||
|
let (base, _) = strip_tone(vowel);
|
||||||
|
if base != vowel {
|
||||||
|
for &(v, t, result) in table {
|
||||||
|
if v == base && t == tone {
|
||||||
|
return Some(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Override the shape modifier on a vowel with a different one.
|
||||||
|
/// Preserves any existing tone.
|
||||||
|
/// Telex mappings: â↔ă via w/a, ô↔ơ via w/o
|
||||||
|
fn override_telex_modifier(vowel: char, key: char) -> Option<char> {
|
||||||
|
let (base, tone) = strip_tone(vowel);
|
||||||
|
let new_base = match (base, key) {
|
||||||
|
('â', 'w') => Some('ă'),
|
||||||
|
('ă', 'a') => Some('â'),
|
||||||
|
('ô', 'w') => Some('ơ'),
|
||||||
|
('ơ', 'o') => Some('ô'),
|
||||||
|
_ => None,
|
||||||
|
}?;
|
||||||
|
match tone {
|
||||||
|
None => Some(new_base),
|
||||||
|
Some(t) => apply_tone_to_vowel(new_base, t),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fn apply_w_to_vowel(vowel: char) -> Option<char> {
|
fn apply_w_to_vowel(vowel: char) -> Option<char> {
|
||||||
// Telex: aw=ă, ow=ơ, ew=ê, uw=ư
|
// Telex: aw=ă, ow=ơ, ew=ê, uw=ư
|
||||||
|
|
@ -50,6 +129,58 @@ fn apply_w_to_vowel(vowel: char) -> Option<char> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Smart cluster helpers: detect "uo" → "ươ" and transfer tones
|
||||||
|
|
||||||
|
fn is_u_vowel(c: char) -> bool {
|
||||||
|
matches!(c, 'u' | 'ú' | 'ù' | 'ủ' | 'ũ' | 'ụ')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_o_vowel(c: char) -> bool {
|
||||||
|
matches!(c, 'o' | 'ó' | 'ò' | 'ỏ' | 'õ' | 'ọ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine the tone character (Telex) from a toned vowel.
|
||||||
|
/// 'u' variants → Some('tone_char'), plain vowels → None.
|
||||||
|
fn tone_of_vowel(c: char) -> Option<char> {
|
||||||
|
match c {
|
||||||
|
'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None,
|
||||||
|
'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => Some('f'),
|
||||||
|
'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => Some('s'),
|
||||||
|
'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => Some('r'),
|
||||||
|
'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => Some('x'),
|
||||||
|
'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => Some('j'),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a Telex tone to the vowel 'ơ', returning the toned variant.
|
||||||
|
fn apply_tone_to_ơ_char(tone: Option<char>) -> char {
|
||||||
|
match tone {
|
||||||
|
None => 'ơ',
|
||||||
|
Some('f') => 'ờ',
|
||||||
|
Some('s') => 'ớ',
|
||||||
|
Some('r') => 'ở',
|
||||||
|
Some('x') => 'ỡ',
|
||||||
|
Some('j') => 'ợ',
|
||||||
|
_ => 'ơ',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a "uo" cluster (with possible tones) into "ươ" with correct tone placement.
|
||||||
|
/// The tone ends up on 'ơ' (second vowel of ươ) regardless of which vowel carried it.
|
||||||
|
fn uo_to_uơ(u_char: char, o_char: char) -> (char, char) {
|
||||||
|
let o_tone = tone_of_vowel(o_char);
|
||||||
|
let u_tone = tone_of_vowel(u_char);
|
||||||
|
let tone = o_tone.or(u_tone);
|
||||||
|
('ư', apply_tone_to_ơ_char(tone))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a position `i` (pointing at 'o' in a potential "uo" cluster) is
|
||||||
|
/// preceded by 'q' (making it a "qu" consonant cluster, not a vowel pair).
|
||||||
|
fn is_q_before_u(chars: &[char], i: usize) -> bool {
|
||||||
|
i > 1 && chars[i - 2] == 'q'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
pub struct TelexEngine {
|
pub struct TelexEngine {
|
||||||
buffer: String,
|
buffer: String,
|
||||||
|
|
@ -163,6 +294,7 @@ impl TelexEngine {
|
||||||
(first, second),
|
(first, second),
|
||||||
('o', 'a') | ('o', 'e')
|
('o', 'a') | ('o', 'e')
|
||||||
| ('u', 'â') | ('u', 'ê') | ('u', 'ơ') | ('u', 'y')
|
| ('u', 'â') | ('u', 'ê') | ('u', 'ơ') | ('u', 'y')
|
||||||
|
| ('ư', 'ơ')
|
||||||
| ('i', 'ê') | ('y', 'ê')
|
| ('i', 'ê') | ('y', 'ê')
|
||||||
);
|
);
|
||||||
if !tone_on_second {
|
if !tone_on_second {
|
||||||
|
|
@ -217,10 +349,40 @@ impl TelexEngine {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Smart cluster reverse: "ươ" + o → "uô"
|
||||||
|
if ch == 'o' && is_vowel(last_ch) {
|
||||||
|
let strip = strip_tone(last_ch);
|
||||||
|
if strip.0 == 'ơ' {
|
||||||
|
let mut chars: Vec<char> = self.buffer.chars().collect();
|
||||||
|
if chars.len() >= 2 && chars[chars.len() - 2] == 'ư' {
|
||||||
|
let ơ_char = chars.pop().unwrap();
|
||||||
|
chars.pop().unwrap();
|
||||||
|
let tone = tone_of_vowel(ơ_char);
|
||||||
|
let ô_char = match tone {
|
||||||
|
None => 'ô',
|
||||||
|
Some(t) => apply_tone_to_vowel('ô', t).unwrap_or('ô'),
|
||||||
|
};
|
||||||
|
self.buffer = chars.into_iter().collect::<String>();
|
||||||
|
self.buffer.push('u');
|
||||||
|
self.buffer.push(ô_char);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Modifier override: if last vowel has a different modifier that can
|
||||||
|
// be replaced by this key (e.g., ă+a→â, ơ+o→ô)
|
||||||
|
if is_vowel(last_ch) && ch != last_ch {
|
||||||
|
if let Some(modified) = override_telex_modifier(last_ch, ch) {
|
||||||
|
self.buffer.pop();
|
||||||
|
self.buffer.push(modified);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flexible placement: if last char is not a vowel, scan the last
|
// Flexible placement: if last char is not a vowel, scan the last
|
||||||
// N chars for a matching vowel to form a double-vowel pair.
|
// N chars for a matching vowel to form a double-vowel pair, or for
|
||||||
|
// a modified vowel that can be overridden by this key.
|
||||||
// Limited backtrack prevents modifying vowels in a different syllable.
|
// Limited backtrack prevents modifying vowels in a different syllable.
|
||||||
if matches!(ch, 'a' | 'e' | 'o') {
|
if matches!(ch, 'a' | 'e' | 'o') {
|
||||||
if let Some(last_ch) = self.buffer.chars().last() {
|
if let Some(last_ch) = self.buffer.chars().last() {
|
||||||
|
|
@ -228,6 +390,7 @@ impl TelexEngine {
|
||||||
let chars: Vec<char> = self.buffer.chars().collect();
|
let chars: Vec<char> = self.buffer.chars().collect();
|
||||||
let start = chars.len().saturating_sub(MAX_FLEXIBLE_BACKTRACK);
|
let start = chars.len().saturating_sub(MAX_FLEXIBLE_BACKTRACK);
|
||||||
for i in (start..chars.len()).rev() {
|
for i in (start..chars.len()).rev() {
|
||||||
|
if is_vowel(chars[i]) {
|
||||||
if chars[i] == ch {
|
if chars[i] == ch {
|
||||||
let replacement = match ch {
|
let replacement = match ch {
|
||||||
'a' => 'â',
|
'a' => 'â',
|
||||||
|
|
@ -242,6 +405,35 @@ impl TelexEngine {
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// Smart cluster reverse: "ươ" + o → "uô" (flexible)
|
||||||
|
if ch == 'o' {
|
||||||
|
let strip = strip_tone(chars[i]);
|
||||||
|
if strip.0 == 'ơ' && i > 0 && chars[i - 1] == 'ư' {
|
||||||
|
let ơ_char = chars[i];
|
||||||
|
let tone = tone_of_vowel(ơ_char);
|
||||||
|
let ô_char = match tone {
|
||||||
|
None => 'ô',
|
||||||
|
Some(t) => apply_tone_to_vowel('ô', t).unwrap_or('ô'),
|
||||||
|
};
|
||||||
|
self.buffer = chars[..i - 1].iter().collect::<String>();
|
||||||
|
self.buffer.push('u');
|
||||||
|
self.buffer.push(ô_char);
|
||||||
|
for &c in &chars[i + 1..] {
|
||||||
|
self.buffer.push(c);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Modifier override for flexible path
|
||||||
|
if let Some(modified) = override_telex_modifier(chars[i], ch) {
|
||||||
|
self.buffer = chars[..i].iter().collect::<String>();
|
||||||
|
self.buffer.push(modified);
|
||||||
|
for &c in &chars[i + 1..] {
|
||||||
|
self.buffer.push(c);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -254,13 +446,47 @@ impl TelexEngine {
|
||||||
fn process_w(&mut self) -> Option<EngineEvent> {
|
fn process_w(&mut self) -> Option<EngineEvent> {
|
||||||
self.apply_pending_to_last_vowel();
|
self.apply_pending_to_last_vowel();
|
||||||
|
|
||||||
|
// Direct: last char is a vowel
|
||||||
if let Some(last_ch) = self.buffer.chars().last() {
|
if let Some(last_ch) = self.buffer.chars().last() {
|
||||||
|
if is_o_vowel(last_ch) {
|
||||||
|
// Smart cluster "uo" → "ươ"
|
||||||
|
let mut chars: Vec<char> = self.buffer.chars().collect();
|
||||||
|
if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) {
|
||||||
|
let o_char = chars.pop().unwrap();
|
||||||
|
let u_char = chars.pop().unwrap();
|
||||||
|
let (new_first, new_second) = uo_to_uơ(u_char, o_char);
|
||||||
|
self.buffer = chars.into_iter().collect::<String>();
|
||||||
|
self.buffer.push(new_first);
|
||||||
|
self.buffer.push(new_second);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
if is_vowel(last_ch) {
|
if is_vowel(last_ch) {
|
||||||
if let Some(modified) = apply_w_to_vowel(last_ch) {
|
if let Some(modified) = apply_w_to_vowel(last_ch) {
|
||||||
self.buffer.pop();
|
self.buffer.pop();
|
||||||
self.buffer.push(modified);
|
self.buffer.push(modified);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// Smart cluster override: "uô" + w → "ươ"
|
||||||
|
let strip = strip_tone(last_ch);
|
||||||
|
if strip.0 == 'ô' || strip.0 == 'ơ' {
|
||||||
|
let mut chars: Vec<char> = self.buffer.chars().collect();
|
||||||
|
if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) {
|
||||||
|
let o_char = chars.pop().unwrap();
|
||||||
|
let u_char = chars.pop().unwrap();
|
||||||
|
let (new_first, new_second) = uo_to_uơ(u_char, o_char);
|
||||||
|
self.buffer = chars.into_iter().collect::<String>();
|
||||||
|
self.buffer.push(new_first);
|
||||||
|
self.buffer.push(new_second);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Modifier override: if vowel already has a different modifier
|
||||||
|
if let Some(modified) = override_telex_modifier(last_ch, 'w') {
|
||||||
|
self.buffer.pop();
|
||||||
|
self.buffer.push(modified);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,6 +498,17 @@ impl TelexEngine {
|
||||||
let start = chars.len().saturating_sub(MAX_FLEXIBLE_BACKTRACK);
|
let start = chars.len().saturating_sub(MAX_FLEXIBLE_BACKTRACK);
|
||||||
for i in (start..chars.len()).rev() {
|
for i in (start..chars.len()).rev() {
|
||||||
if is_vowel(chars[i]) {
|
if is_vowel(chars[i]) {
|
||||||
|
// Smart cluster "uo" → "ươ" (flexible)
|
||||||
|
if is_o_vowel(chars[i]) && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) {
|
||||||
|
let (new_first, new_second) = uo_to_uơ(chars[i - 1], chars[i]);
|
||||||
|
self.buffer = chars[..i - 1].iter().collect::<String>();
|
||||||
|
self.buffer.push(new_first);
|
||||||
|
self.buffer.push(new_second);
|
||||||
|
for &c in &chars[i + 1..] {
|
||||||
|
self.buffer.push(c);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if let Some(modified) = apply_w_to_vowel(chars[i]) {
|
if let Some(modified) = apply_w_to_vowel(chars[i]) {
|
||||||
self.buffer = chars[..i].iter().collect::<String>();
|
self.buffer = chars[..i].iter().collect::<String>();
|
||||||
self.buffer.push(modified);
|
self.buffer.push(modified);
|
||||||
|
|
@ -280,6 +517,29 @@ impl TelexEngine {
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// Smart cluster override: "uô" + w → "ươ" (flexible)
|
||||||
|
if i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) {
|
||||||
|
let strip = strip_tone(chars[i]);
|
||||||
|
if strip.0 == 'ô' || strip.0 == 'ơ' {
|
||||||
|
let (new_first, new_second) = uo_to_uơ(chars[i - 1], chars[i]);
|
||||||
|
self.buffer = chars[..i - 1].iter().collect::<String>();
|
||||||
|
self.buffer.push(new_first);
|
||||||
|
self.buffer.push(new_second);
|
||||||
|
for &c in &chars[i + 1..] {
|
||||||
|
self.buffer.push(c);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Modifier override: vowel already has a different modifier
|
||||||
|
if let Some(modified) = override_telex_modifier(chars[i], 'w') {
|
||||||
|
self.buffer = chars[..i].iter().collect::<String>();
|
||||||
|
self.buffer.push(modified);
|
||||||
|
for &c in &chars[i + 1..] {
|
||||||
|
self.buffer.push(c);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -644,6 +644,52 @@ mod tests {
|
||||||
assert_eq!(get_display(&process_input(&mut e, "xungw")), "xưng");
|
assert_eq!(get_display(&process_input(&mut e, "xungw")), "xưng");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Telex: Smart "uo" → "ươ" cluster
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telex_smart_uo_to_uơ_shortcut() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
// Single w at end converts "uo" → "ươ" through trailing "ng"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "chuongw")), "chương");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telex_smart_uo_to_uơ_traditional() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
// Traditional uw+ow still works
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "chuwowng")), "chương");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telex_smart_uo_to_uơ_with_tone_after_w() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
// "chuongws" → w first (cluster→ươ), then s (tone on ơ)
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "chuongws")), "chướng");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telex_smart_uo_to_uơ_with_tone_before_w() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
// "chuongsw" → s first (tone on u), then w (cluster→ươ, tone→ơ)
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "chuongsw")), "chướng");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telex_smart_uo_to_uơ_thuong_after_w() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
// "thuowngf" → w first (cluster→ươ), then f (huyền on ơ)
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "thuowngf")), "thường");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telex_smart_uo_to_uơ_thuong_before_w() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
// "thuongfw" → f first (tone on u), then w (cluster→ươ, tone→ơ)
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "thuongfw")), "thường");
|
||||||
|
}
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// VNI: Flexible diacritic placement
|
// VNI: Flexible diacritic placement
|
||||||
// ================================================================
|
// ================================================================
|
||||||
|
|
@ -697,6 +743,38 @@ mod tests {
|
||||||
assert_eq!(get_display(&process_input(&mut e, "tran6")), "trân");
|
assert_eq!(get_display(&process_input(&mut e, "tran6")), "trân");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// VNI: Smart "uo" → "ươ" cluster
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vni_smart_uo_to_uơ_shortcut() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
// Single 7 at end converts "uo" → "ươ" through trailing "ng"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "chuong7")), "chương");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vni_smart_uo_to_uơ_traditional() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
// Traditional u7+o7 still works
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "chu7o7ng")), "chương");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vni_smart_uo_to_uơ_with_tone_after_7() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
// "chuong71" → 7 first (cluster→ươ), then 1 (sắc on ơ) → "chướng"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "chuong71")), "chướng");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vni_smart_uo_to_uơ_with_tone_before_7() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
// "chuong17" → 1 first (tone on o), then 7 (cluster→ươ, tone→ơ) → "chướng"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "chuong17")), "chướng");
|
||||||
|
}
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// VNI: Tones
|
// VNI: Tones
|
||||||
// ================================================================
|
// ================================================================
|
||||||
|
|
@ -941,15 +1019,15 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn esc_undo_after_multiple_tones() {
|
fn esc_undo_after_multiple_tones() {
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
// "as" → á, then "f" has no tone mapping for á, so f is appended
|
// "as" → á, then "f" overrides tone: sắc → huyền → "à"
|
||||||
// Buffer becomes "áf", ESC strips diacritics → "af"
|
// ESC strips diacritics → "a"
|
||||||
e.process_key('a');
|
e.process_key('a');
|
||||||
e.process_key('s');
|
e.process_key('s');
|
||||||
e.process_key('f');
|
e.process_key('f');
|
||||||
let event = e.process_escape();
|
let event = e.process_escape();
|
||||||
match event {
|
match event {
|
||||||
Some(EngineEvent::UndoTones { restored, .. }) => {
|
Some(EngineEvent::UndoTones { restored, .. }) => {
|
||||||
assert_eq!(restored, "af");
|
assert_eq!(restored, "a");
|
||||||
}
|
}
|
||||||
_ => panic!("Expected UndoTones, got {:?}", event),
|
_ => panic!("Expected UndoTones, got {:?}", event),
|
||||||
}
|
}
|
||||||
|
|
@ -1737,19 +1815,145 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backspace_count_then_second_tone_replaces_previous() {
|
fn backspace_count_then_second_tone_replaces_previous() {
|
||||||
// Type "as" → á, then "f" → f goes to 'á': but 'á' is not in VOWELS
|
// Type "as" → á, then "f" → f overrides sắc with huyền → "à"
|
||||||
// So 'f' is just appended: "áf"
|
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
let events = process_input(&mut e, "asf");
|
let events = process_input(&mut e, "asf");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
||||||
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
|
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
|
||||||
_ => None,
|
_ => None,
|
||||||
}).collect();
|
}).collect();
|
||||||
// "as" → Replace {2, "á"}, "f" → buffer = "áf" (no vowel change) → no event
|
// "as" → Replace {2, "á"}, "f" → Replace {2, "à"}
|
||||||
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events);
|
assert_eq!(replace_events.len(), 2, "Expected 2 Replace: {:?}", replace_events);
|
||||||
assert_eq!(replace_events[0].0, 2);
|
assert_eq!(replace_events[0].0, 2);
|
||||||
assert_eq!(replace_events[0].1, "á");
|
assert_eq!(replace_events[0].1, "á");
|
||||||
assert_eq!(get_display(&events), "áf");
|
assert_eq!(replace_events[1].0, 2);
|
||||||
|
assert_eq!(replace_events[1].1, "à");
|
||||||
|
assert_eq!(get_display(&events), "à");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Smart Modifier Overriding (Diacritic Replacement)
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
// Category 1: The 'A' Vowel Group (a, â, ă)
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telex_override_a_aa_then_w() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
// "traan" → aa makes â → "trân", then w overrides â→ă → "trăn"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "traanw")), "trăn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telex_override_a_aw_then_a() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
// "tranw" → w modifies a→ă → "trăn", then a overrides ă→â → "trân"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "tranwa")), "trân");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vni_override_a_6_then_8() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
// "tran6" → 6 makes â → "trân", then 8 overrides â→ă → "trăn"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "tran68")), "trăn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vni_override_a_8_then_6() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
// "tran8" → 8 makes ă → "trăn", then 6 overrides ă→â → "trân"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "tran86")), "trân");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category 2: The 'O' Vowel Group (o, ô, ơ)
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telex_override_o_oo_then_w() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
// "coon" → oo makes ô → "côn", then w overrides ô→ơ → "cơn"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "coonw")), "cơn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telex_override_o_ow_then_o() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
// "conw" → w modifies o→ơ → "cơn", then o overrides ơ→ô → "côn"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "conwo")), "côn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vni_override_o_6_then_7() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
// "con6" → 6 makes ô → "côn", then 7 overrides ô→ơ → "cơn"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "con67")), "cơn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vni_override_o_7_then_6() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
// "con7" → 7 makes ơ → "cơn", then 6 overrides ơ→ô → "côn"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "con76")), "côn");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category 3: Complex Double Vowels (uo → uô / ươ)
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telex_override_uo_oo_then_w() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
// "chuoon" → oo makes ô → "chuôn", then w overrides ô→ơ → "chươn"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "chuoonw")), "chươn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telex_override_uo_ow_then_o() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
// "chuonw" → w modifies o→ơ → "chươn", then o overrides ơ→ô → "chuôn"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "chuonwo")), "chuôn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vni_override_uo_6_then_7() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
// "chuon6" → 6 makes ô → "chuôn", then 7 overrides ô→ơ → "chươn"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "chuon67")), "chươn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vni_override_uo_7_then_6() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
// "chuon7" → 7 makes ơ → "chươn", then 6 overrides ơ→ô → "chuôn"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "chuon76")), "chuôn");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category 4: Modifier Overriding while Preserving Tones
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telex_override_with_tone_preserved_aa_s_w() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
// "traans" → aa→â, s→sắc → "trấn", then w overrides â→ă, sắc preserved → "trắn"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "traansw")), "trắn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telex_override_with_tone_preserved_oo_f_w() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
// "coonsf" → oo→ô, s→sắc then f overrides sắc→huyền → "cồn", then w overrides ô→ơ, huyền preserved → "cờn"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "coonsfw")), "cờn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vni_override_with_tone_preserved_6_1_then_8() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
// "tran61" → 6→â, 1→sắc → "trấn", then 8 overrides â→ă, sắc preserved → "trắn"
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "tran618")), "trắn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vni_override_with_tone_preserved_6_2_then_7() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
// "con62" → 6→ô, 2→huyền → "cồn", then 7 overrides ô→ơ, huyền preserved → "cờn"
|
||||||
|
// Note: input is "con62" then "7", but the tone 2 comes first, then modifier 7
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "con627")), "cờn");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
|
|
@ -1775,4 +1979,127 @@ mod tests {
|
||||||
assert_eq!(replace_events[1], 4, "banj→bạn should be 4");
|
assert_eq!(replace_events[1], 4, "banj→bạn should be 4");
|
||||||
assert_eq!(get_display(&events), "xin chào bạn");
|
assert_eq!(get_display(&events), "xin chào bạn");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Core Edge Case Test Suite (from specification)
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
// Standard
|
||||||
|
#[test]
|
||||||
|
fn core_test_traafn() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "traafn")), "trần");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn core_test_tranaf() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "tranaf")), "trần");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn core_test_tran62() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "tran62")), "trần");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double vowel / smart cluster
|
||||||
|
#[test]
|
||||||
|
fn core_test_chuwowng() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "chuwowng")), "chương");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn core_test_chuongw() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "chuongw")), "chương");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn core_test_chuong7() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "chuong7")), "chương");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shape override
|
||||||
|
#[test]
|
||||||
|
fn core_test_traanw() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "traanw")), "trăn");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn core_test_trawa() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "trawa")), "trâ");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn core_test_trawan() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "trawan")), "trân");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn core_test_tran68() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "tran68")), "trăn");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tone override
|
||||||
|
#[test]
|
||||||
|
fn core_test_traansf() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "traansf")), "trần");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn core_test_tran612() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "tran612")), "trần");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complex consonant + flexible
|
||||||
|
#[test]
|
||||||
|
fn core_test_nghieeng() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "nghieeng")), "nghiêng");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn core_test_nghieengf() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "nghieengf")), "nghiềng");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn core_test_nghiengf() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "nghiengf")), "nghìeng");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn core_test_nghieng62() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "nghieng62")), "nghiềng");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tone placement
|
||||||
|
#[test]
|
||||||
|
fn core_test_hoangf() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "hoangf")), "hoàng");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn core_test_thuyr() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "thuyr")), "thuỷ");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn core_test_thuy3() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "thuy3")), "thuỷ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial đ (dd)
|
||||||
|
#[test]
|
||||||
|
fn core_test_ddang() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "ddang")), "đang");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn core_test_dang9() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "dang9")), "đang");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,62 @@
|
||||||
use crate::engine::EngineEvent;
|
use crate::engine::EngineEvent;
|
||||||
|
|
||||||
const VOWELS: &[char] = &['a', 'e', 'i', 'o', 'u', 'y', 'ă', 'â', 'ê', 'ô', 'ơ', 'ư'];
|
const VOWELS: &[char] = &[
|
||||||
|
'a', 'e', 'i', 'o', 'u', 'y',
|
||||||
|
'ă', 'â', 'ê', 'ô', 'ơ', 'ư',
|
||||||
|
];
|
||||||
|
|
||||||
|
const VOWEL_ACCENTED: &[char] = &[
|
||||||
|
'a', 'á', 'à', 'ả', 'ã', 'ạ',
|
||||||
|
'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ',
|
||||||
|
'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ',
|
||||||
|
'e', 'é', 'è', 'ẻ', 'ẽ', 'ẹ',
|
||||||
|
'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ',
|
||||||
|
'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị',
|
||||||
|
'o', 'ó', 'ò', 'ỏ', 'õ', 'ọ',
|
||||||
|
'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ',
|
||||||
|
'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ',
|
||||||
|
'u', 'ú', 'ù', 'ủ', 'ũ', 'ụ',
|
||||||
|
'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự',
|
||||||
|
'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ',
|
||||||
|
];
|
||||||
|
|
||||||
fn is_vowel(c: char) -> bool {
|
fn is_vowel(c: char) -> bool {
|
||||||
VOWELS.contains(&c)
|
VOWEL_ACCENTED.contains(&c)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FLEXIBLE_BACKTRACK: usize = 3;
|
const MAX_FLEXIBLE_BACKTRACK: usize = 3;
|
||||||
|
|
||||||
|
/// Strip tone from a Vietnamese vowel, returning (base_modified_vowel, tone_digit_or_none)
|
||||||
|
fn strip_tone_vni(c: char) -> (char, Option<char>) {
|
||||||
|
match c {
|
||||||
|
'a' => ('a', None), 'á' => ('a', Some('1')), 'à' => ('a', Some('2')),
|
||||||
|
'ả' => ('a', Some('3')), 'ã' => ('a', Some('4')), 'ạ' => ('a', Some('5')),
|
||||||
|
'ă' => ('ă', None), 'ắ' => ('ă', Some('1')), 'ằ' => ('ă', Some('2')),
|
||||||
|
'ẳ' => ('ă', Some('3')), 'ẵ' => ('ă', Some('4')), 'ặ' => ('ă', Some('5')),
|
||||||
|
'â' => ('â', None), 'ấ' => ('â', Some('1')), 'ầ' => ('â', Some('2')),
|
||||||
|
'ẩ' => ('â', Some('3')), 'ẫ' => ('â', Some('4')), 'ậ' => ('â', Some('5')),
|
||||||
|
'e' => ('e', None), 'é' => ('e', Some('1')), 'è' => ('e', Some('2')),
|
||||||
|
'ẻ' => ('e', Some('3')), 'ẽ' => ('e', Some('4')), 'ẹ' => ('e', Some('5')),
|
||||||
|
'ê' => ('ê', None), 'ế' => ('ê', Some('1')), 'ề' => ('ê', Some('2')),
|
||||||
|
'ể' => ('ê', Some('3')), 'ễ' => ('ê', Some('4')), 'ệ' => ('ê', Some('5')),
|
||||||
|
'i' => ('i', None), 'í' => ('i', Some('1')), 'ì' => ('i', Some('2')),
|
||||||
|
'ỉ' => ('i', Some('3')), 'ĩ' => ('i', Some('4')), 'ị' => ('i', Some('5')),
|
||||||
|
'o' => ('o', None), 'ó' => ('o', Some('1')), 'ò' => ('o', Some('2')),
|
||||||
|
'ỏ' => ('o', Some('3')), 'õ' => ('o', Some('4')), 'ọ' => ('o', Some('5')),
|
||||||
|
'ô' => ('ô', None), 'ố' => ('ô', Some('1')), 'ồ' => ('ô', Some('2')),
|
||||||
|
'ổ' => ('ô', Some('3')), 'ỗ' => ('ô', Some('4')), 'ộ' => ('ô', Some('5')),
|
||||||
|
'ơ' => ('ơ', None), 'ớ' => ('ơ', Some('1')), 'ờ' => ('ơ', Some('2')),
|
||||||
|
'ở' => ('ơ', Some('3')), 'ỡ' => ('ơ', Some('4')), 'ợ' => ('ơ', Some('5')),
|
||||||
|
'u' => ('u', None), 'ú' => ('u', Some('1')), 'ù' => ('u', Some('2')),
|
||||||
|
'ủ' => ('u', Some('3')), 'ũ' => ('u', Some('4')), 'ụ' => ('u', Some('5')),
|
||||||
|
'ư' => ('ư', None), 'ứ' => ('ư', Some('1')), 'ừ' => ('ư', Some('2')),
|
||||||
|
'ử' => ('ư', Some('3')), 'ữ' => ('ư', Some('4')), 'ự' => ('ư', Some('5')),
|
||||||
|
'y' => ('y', None), 'ý' => ('y', Some('1')), 'ỳ' => ('y', Some('2')),
|
||||||
|
'ỷ' => ('y', Some('3')), 'ỹ' => ('y', Some('4')), 'ỵ' => ('y', Some('5')),
|
||||||
|
_ => (c, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_tone_to_vowel(vowel: char, digit: char) -> Option<char> {
|
fn apply_tone_to_vowel(vowel: char, digit: char) -> Option<char> {
|
||||||
// VNI: 1=sắc, 2=huyền, 3=hỏi, 4=ngã, 5=nặng
|
// VNI: 1=sắc, 2=huyền, 3=hỏi, 4=ngã, 5=nặng
|
||||||
let table: &[(char, char, char)] = &[
|
let table: &[(char, char, char)] = &[
|
||||||
|
|
@ -30,9 +79,38 @@ fn apply_tone_to_vowel(vowel: char, digit: char) -> Option<char> {
|
||||||
return Some(result);
|
return Some(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tone overriding: vowel already has a tone → strip it and apply the new one
|
||||||
|
let (base, _) = strip_tone_vni(vowel);
|
||||||
|
if base != vowel {
|
||||||
|
for &(v, t, result) in table {
|
||||||
|
if v == base && t == digit {
|
||||||
|
return Some(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Override the shape modifier on a vowel with a different one.
|
||||||
|
/// Preserves any existing tone.
|
||||||
|
/// VNI mappings: â↔ă via 6↔8, ô↔ơ via 6↔7
|
||||||
|
fn override_vni_modifier(vowel: char, digit: char) -> Option<char> {
|
||||||
|
let (base, tone) = strip_tone_vni(vowel);
|
||||||
|
let new_base = match (base, digit) {
|
||||||
|
('â', '8') => Some('ă'),
|
||||||
|
('ă', '6') => Some('â'),
|
||||||
|
('ô', '7') => Some('ơ'),
|
||||||
|
('ơ', '6') => Some('ô'),
|
||||||
|
_ => None,
|
||||||
|
}?;
|
||||||
|
match tone {
|
||||||
|
None => Some(new_base),
|
||||||
|
Some(t) => apply_tone_to_vowel(new_base, t),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_digit_to_vowel(vowel: char, digit: char) -> Option<char> {
|
fn apply_digit_to_vowel(vowel: char, digit: char) -> Option<char> {
|
||||||
// VNI: 6=â, 7=ơ+ư, 8=ă+ê, 9=ô, 0=ơ+ư
|
// VNI: 6=â, 7=ơ+ư, 8=ă+ê, 9=ô, 0=ơ+ư
|
||||||
// Standard VNI: a6=â, a8=ă, e6=ê, o6=ô, o7=ơ, u7=ư
|
// Standard VNI: a6=â, a8=ă, e6=ê, o6=ô, o7=ơ, u7=ư
|
||||||
|
|
@ -56,6 +134,49 @@ fn apply_digit_to_vowel(vowel: char, digit: char) -> Option<char> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_u_vowel(c: char) -> bool {
|
||||||
|
matches!(c, 'u' | 'ú' | 'ù' | 'ủ' | 'ũ' | 'ụ')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_o_vowel(c: char) -> bool {
|
||||||
|
matches!(c, 'o' | 'ó' | 'ò' | 'ỏ' | 'õ' | 'ọ')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tone_of_vowel_vni(c: char) -> Option<char> {
|
||||||
|
match c {
|
||||||
|
'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None,
|
||||||
|
'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => Some('2'),
|
||||||
|
'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => Some('1'),
|
||||||
|
'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => Some('3'),
|
||||||
|
'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => Some('4'),
|
||||||
|
'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => Some('5'),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_tone_to_ơ_vni(tone: Option<char>) -> char {
|
||||||
|
match tone {
|
||||||
|
None => 'ơ',
|
||||||
|
Some('2') => 'ờ',
|
||||||
|
Some('1') => 'ớ',
|
||||||
|
Some('3') => 'ở',
|
||||||
|
Some('4') => 'ỡ',
|
||||||
|
Some('5') => 'ợ',
|
||||||
|
_ => 'ơ',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uo_to_uơ_vni(u_char: char, o_char: char) -> (char, char) {
|
||||||
|
let o_tone = tone_of_vowel_vni(o_char);
|
||||||
|
let u_tone = tone_of_vowel_vni(u_char);
|
||||||
|
let tone = o_tone.or(u_tone);
|
||||||
|
('ư', apply_tone_to_ơ_vni(tone))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_q_before_u(chars: &[char], i: usize) -> bool {
|
||||||
|
i > 1 && chars[i - 2] == 'q'
|
||||||
|
}
|
||||||
|
|
||||||
pub struct VniEngine {
|
pub struct VniEngine {
|
||||||
buffer: String,
|
buffer: String,
|
||||||
pending_modifier: Option<char>,
|
pending_modifier: Option<char>,
|
||||||
|
|
@ -103,6 +224,26 @@ impl VniEngine {
|
||||||
if self.pending_modifier.is_some() {
|
if self.pending_modifier.is_some() {
|
||||||
self.apply_pending();
|
self.apply_pending();
|
||||||
}
|
}
|
||||||
|
// dd → đ digraph
|
||||||
|
if ch == 'd' {
|
||||||
|
if let Some(last_ch) = self.buffer.chars().last() {
|
||||||
|
if last_ch == 'd' {
|
||||||
|
let chars: Vec<char> = self.buffer.chars().collect();
|
||||||
|
if chars.len() == 1 {
|
||||||
|
self.buffer.pop();
|
||||||
|
self.buffer.push('đ');
|
||||||
|
return None;
|
||||||
|
} else if chars.len() >= 2 {
|
||||||
|
let prev = chars[chars.len() - 2];
|
||||||
|
if !is_vowel(prev) {
|
||||||
|
self.buffer.pop();
|
||||||
|
self.buffer.push('đ');
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
self.buffer.push(ch);
|
self.buffer.push(ch);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +259,19 @@ impl VniEngine {
|
||||||
// Find last vowel (standard behavior)
|
// Find last vowel (standard behavior)
|
||||||
if let Some(last_ch) = self.buffer.chars().last() {
|
if let Some(last_ch) = self.buffer.chars().last() {
|
||||||
if is_vowel(last_ch) {
|
if is_vowel(last_ch) {
|
||||||
|
// Smart cluster "uo" → "ươ" (digit '7')
|
||||||
|
if digit == '7' && is_o_vowel(last_ch) {
|
||||||
|
let mut chars: Vec<char> = self.buffer.chars().collect();
|
||||||
|
if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) {
|
||||||
|
let o_char = chars.pop().unwrap();
|
||||||
|
let u_char = chars.pop().unwrap();
|
||||||
|
let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char);
|
||||||
|
self.buffer = chars.into_iter().collect::<String>();
|
||||||
|
self.buffer.push(new_first);
|
||||||
|
self.buffer.push(new_second);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Try tone first (1-5)
|
// Try tone first (1-5)
|
||||||
if let Some(modified) = apply_tone_to_vowel(last_ch, digit) {
|
if let Some(modified) = apply_tone_to_vowel(last_ch, digit) {
|
||||||
self.buffer.pop();
|
self.buffer.pop();
|
||||||
|
|
@ -131,6 +285,55 @@ impl VniEngine {
|
||||||
self.buffer.push(modified);
|
self.buffer.push(modified);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Smart cluster forward (override): "uô" + 7 → "ươ"
|
||||||
|
if digit == '7' {
|
||||||
|
let strip = strip_tone_vni(last_ch);
|
||||||
|
if strip.0 == 'ô' {
|
||||||
|
let mut chars: Vec<char> = self.buffer.chars().collect();
|
||||||
|
if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) {
|
||||||
|
let o_char = chars.pop().unwrap();
|
||||||
|
let u_char = chars.pop().unwrap();
|
||||||
|
let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char);
|
||||||
|
self.buffer = chars.into_iter().collect::<String>();
|
||||||
|
self.buffer.push(new_first);
|
||||||
|
self.buffer.push(new_second);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Smart cluster reverse (override): "ươ" + 6 → "uô"
|
||||||
|
if digit == '6' {
|
||||||
|
let strip = strip_tone_vni(last_ch);
|
||||||
|
if strip.0 == 'ơ' {
|
||||||
|
let mut chars: Vec<char> = self.buffer.chars().collect();
|
||||||
|
if chars.len() >= 2 && chars[chars.len() - 2] == 'ư' {
|
||||||
|
let ơ_char = chars.pop().unwrap();
|
||||||
|
chars.pop().unwrap();
|
||||||
|
let tone = tone_of_vowel_vni(ơ_char);
|
||||||
|
let ô_char = match tone {
|
||||||
|
None => 'ô',
|
||||||
|
Some(t) => apply_tone_to_vowel('ô', t).unwrap_or('ô'),
|
||||||
|
};
|
||||||
|
self.buffer = chars.into_iter().collect::<String>();
|
||||||
|
self.buffer.push('u');
|
||||||
|
self.buffer.push(ô_char);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// VNI digit 9: 'd' → 'đ'
|
||||||
|
if digit == '9' && last_ch == 'd' {
|
||||||
|
self.buffer.pop();
|
||||||
|
self.buffer.push('đ');
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Modifier override: vowel already has a different modifier
|
||||||
|
if let Some(modified) = override_vni_modifier(last_ch, digit) {
|
||||||
|
self.buffer.pop();
|
||||||
|
self.buffer.push(modified);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,6 +344,17 @@ impl VniEngine {
|
||||||
let start = chars.len().saturating_sub(MAX_FLEXIBLE_BACKTRACK);
|
let start = chars.len().saturating_sub(MAX_FLEXIBLE_BACKTRACK);
|
||||||
for i in (start..chars.len()).rev() {
|
for i in (start..chars.len()).rev() {
|
||||||
if is_vowel(chars[i]) {
|
if is_vowel(chars[i]) {
|
||||||
|
// Smart cluster "uo" → "ươ" (digit '7', flexible)
|
||||||
|
if digit == '7' && is_o_vowel(chars[i]) && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) {
|
||||||
|
let (new_first, new_second) = uo_to_uơ_vni(chars[i - 1], chars[i]);
|
||||||
|
self.buffer = chars[..i - 1].iter().collect::<String>();
|
||||||
|
self.buffer.push(new_first);
|
||||||
|
self.buffer.push(new_second);
|
||||||
|
for &c in &chars[i + 1..] {
|
||||||
|
self.buffer.push(c);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
// Try tone first (1-5)
|
// Try tone first (1-5)
|
||||||
if let Some(modified) = apply_tone_to_vowel(chars[i], digit) {
|
if let Some(modified) = apply_tone_to_vowel(chars[i], digit) {
|
||||||
self.buffer = chars[..i].iter().collect::<String>();
|
self.buffer = chars[..i].iter().collect::<String>();
|
||||||
|
|
@ -159,7 +373,73 @@ impl VniEngine {
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// Smart cluster forward (override): "uô" + 7 → "ươ" (flexible)
|
||||||
|
if digit == '7' {
|
||||||
|
let strip = strip_tone_vni(chars[i]);
|
||||||
|
if strip.0 == 'ô' && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) {
|
||||||
|
let (new_first, new_second) = uo_to_uơ_vni(chars[i - 1], chars[i]);
|
||||||
|
self.buffer = chars[..i - 1].iter().collect::<String>();
|
||||||
|
self.buffer.push(new_first);
|
||||||
|
self.buffer.push(new_second);
|
||||||
|
for &c in &chars[i + 1..] {
|
||||||
|
self.buffer.push(c);
|
||||||
}
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Smart cluster reverse (override): "ươ" + 6 → "uô" (flexible)
|
||||||
|
if digit == '6' {
|
||||||
|
let strip = strip_tone_vni(chars[i]);
|
||||||
|
if strip.0 == 'ơ' && i > 0 && chars[i - 1] == 'ư' {
|
||||||
|
let ơ_char = chars[i];
|
||||||
|
let tone = tone_of_vowel_vni(ơ_char);
|
||||||
|
let ô_char = match tone {
|
||||||
|
None => 'ô',
|
||||||
|
Some(t) => apply_tone_to_vowel('ô', t).unwrap_or('ô'),
|
||||||
|
};
|
||||||
|
self.buffer = chars[..i - 1].iter().collect::<String>();
|
||||||
|
self.buffer.push('u');
|
||||||
|
self.buffer.push(ô_char);
|
||||||
|
for &c in &chars[i + 1..] {
|
||||||
|
self.buffer.push(c);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// VNI digit 9: 'd' → 'đ' (flexible)
|
||||||
|
if digit == '9' && chars[i] == 'd' {
|
||||||
|
self.buffer = chars[..i].iter().collect::<String>();
|
||||||
|
self.buffer.push('đ');
|
||||||
|
for &c in &chars[i + 1..] {
|
||||||
|
self.buffer.push(c);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Modifier override: vowel already has a different modifier
|
||||||
|
if let Some(modified) = override_vni_modifier(chars[i], digit) {
|
||||||
|
self.buffer = chars[..i].iter().collect::<String>();
|
||||||
|
self.buffer.push(modified);
|
||||||
|
for &c in &chars[i + 1..] {
|
||||||
|
self.buffer.push(c);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digit '9' in flexible context: scan backwards for 'd' → 'đ'
|
||||||
|
if digit == '9' {
|
||||||
|
let chars: Vec<char> = self.buffer.chars().collect();
|
||||||
|
for i in (0..chars.len()).rev() {
|
||||||
|
if chars[i] == 'd' {
|
||||||
|
self.buffer = chars[..i].iter().collect::<String>();
|
||||||
|
self.buffer.push('đ');
|
||||||
|
for &c in &chars[i + 1..] {
|
||||||
|
self.buffer.push(c);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1065
engine/tests/generated_bulk.rs
Normal file
1065
engine/tests/generated_bulk.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -30,14 +30,13 @@ echo " Built with x11 + wayland"
|
||||||
|
|
||||||
|
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
cd "$PROJECT_ROOT/ui" && cargo build --release 2>/dev/null && cd "$SCRIPT_DIR" || echo " UI build skipped (missing GTK4 libs)"
|
cd "$PROJECT_ROOT/ui" && cargo build --release && cd "$SCRIPT_DIR"
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
# Copy binaries
|
# Copy binaries
|
||||||
echo "[2/5] Installing binaries..."
|
echo "[2/5] Installing binaries..."
|
||||||
cp target/release/vietc "$APPDIR/usr/bin/"
|
cp target/release/vietc "$APPDIR/usr/bin/"
|
||||||
cp target/release/vietc-cli "$APPDIR/usr/bin/"
|
cp target/release/vietc-cli "$APPDIR/usr/bin/"
|
||||||
[ -f ui/target/release/vietc-settings ] && cp ui/target/release/vietc-settings "$APPDIR/usr/bin/"
|
|
||||||
[ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/"
|
[ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/"
|
||||||
|
|
||||||
# Desktop integration
|
# Desktop integration
|
||||||
|
|
@ -200,8 +199,6 @@ trap cleanup_daemon EXIT INT TERM
|
||||||
|
|
||||||
if [ -f "$HERE/usr/bin/vietc-tray" ]; then
|
if [ -f "$HERE/usr/bin/vietc-tray" ]; then
|
||||||
"$HERE/usr/bin/vietc-tray" "$@"
|
"$HERE/usr/bin/vietc-tray" "$@"
|
||||||
elif [ -f "$HERE/usr/bin/vietc-settings" ]; then
|
|
||||||
"$HERE/usr/bin/vietc-settings" "$@"
|
|
||||||
else
|
else
|
||||||
echo "[vietc] Daemon running in foreground. Press Ctrl+C to stop."
|
echo "[vietc] Daemon running in foreground. Press Ctrl+C to stop."
|
||||||
wait "$DAEMON_PID"
|
wait "$DAEMON_PID"
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,63 @@ impl KeyInjector for UinputInjector {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UinputInjector {
|
impl UinputInjector {
|
||||||
|
/// Get the original non-root username when running as root.
|
||||||
|
/// Checks SUDO_USER (sudo), PKEXEC_UID (pkexec), /proc/self/loginuid,
|
||||||
|
/// and falls back to `logname`.
|
||||||
|
fn get_original_username() -> Option<String> {
|
||||||
|
let is_root = unsafe { libc::getuid() == 0 };
|
||||||
|
if !is_root {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(user) = std::env::var("SUDO_USER") {
|
||||||
|
if !user.is_empty() {
|
||||||
|
return Some(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(uid_str) = std::env::var("PKEXEC_UID") {
|
||||||
|
if let Ok(uid) = uid_str.parse::<u32>() {
|
||||||
|
unsafe {
|
||||||
|
let pw = libc::getpwuid(uid);
|
||||||
|
if !pw.is_null() {
|
||||||
|
let name = std::ffi::CStr::from_ptr((*pw).pw_name)
|
||||||
|
.to_string_lossy().into_owned();
|
||||||
|
if !name.is_empty() {
|
||||||
|
return Some(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(content) = std::fs::read_to_string("/proc/self/loginuid") {
|
||||||
|
if let Ok(uid) = content.trim().parse::<u32>() {
|
||||||
|
unsafe {
|
||||||
|
let pw = libc::getpwuid(uid);
|
||||||
|
if !pw.is_null() {
|
||||||
|
let name = std::ffi::CStr::from_ptr((*pw).pw_name)
|
||||||
|
.to_string_lossy().into_owned();
|
||||||
|
if !name.is_empty() {
|
||||||
|
return Some(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(output) = std::process::Command::new("logname").output() {
|
||||||
|
if output.status.success() {
|
||||||
|
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if !name.is_empty() {
|
||||||
|
return Some(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Run an external command as the original user if we're root.
|
/// Run an external command as the original user if we're root.
|
||||||
/// Wayland tools (wtype, wl-copy) need the user's session, not root.
|
/// Wayland tools (wtype, wl-copy) need the user's session, not root.
|
||||||
/// Uses explicit `env VAR=val` instead of `--preserve-env` for
|
/// Uses explicit `env VAR=val` instead of `--preserve-env` for
|
||||||
|
|
@ -146,12 +203,12 @@ impl UinputInjector {
|
||||||
fn run_as_user(program: &str, args: &[&str]) -> std::process::Output {
|
fn run_as_user(program: &str, args: &[&str]) -> std::process::Output {
|
||||||
let is_root = unsafe { libc::getuid() == 0 };
|
let is_root = unsafe { libc::getuid() == 0 };
|
||||||
if is_root {
|
if is_root {
|
||||||
if let Ok(sudo_user) = std::env::var("SUDO_USER") {
|
if let Some(original_user) = Self::get_original_username() {
|
||||||
let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default();
|
let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default();
|
||||||
let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default();
|
let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default();
|
||||||
let display = std::env::var("DISPLAY").unwrap_or_default();
|
let display = std::env::var("DISPLAY").unwrap_or_default();
|
||||||
let mut cmd = std::process::Command::new("sudo");
|
let mut cmd = std::process::Command::new("sudo");
|
||||||
cmd.args(["-u", &sudo_user, "env"]);
|
cmd.args(["-u", &original_user, "env"]);
|
||||||
if !wayland_display.is_empty() {
|
if !wayland_display.is_empty() {
|
||||||
cmd.arg(format!("WAYLAND_DISPLAY={}", wayland_display));
|
cmd.arg(format!("WAYLAND_DISPLAY={}", wayland_display));
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +223,7 @@ impl UinputInjector {
|
||||||
match cmd.output() {
|
match cmd.output() {
|
||||||
Ok(output) => return output,
|
Ok(output) => return output,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[vietc] Failed to run sudo -u {} env ... {} {}: {}", sudo_user, program, args.join(" "), e);
|
eprintln!("[vietc] Failed to run sudo -u {} env ... {} {}: {}", original_user, program, args.join(" "), e);
|
||||||
return std::process::Output {
|
return std::process::Output {
|
||||||
status: std::process::ExitStatus::default(),
|
status: std::process::ExitStatus::default(),
|
||||||
stdout: vec![],
|
stdout: vec![],
|
||||||
|
|
@ -174,6 +231,8 @@ impl UinputInjector {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!("[vietc] Running as root but could not determine original user");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match std::process::Command::new(program).args(args).output() {
|
match std::process::Command::new(program).args(args).output() {
|
||||||
|
|
@ -195,18 +254,98 @@ impl UinputInjector {
|
||||||
/// best available method: ydotool (uinput) for ASCII, xdotool (X11) or
|
/// best available method: ydotool (uinput) for ASCII, xdotool (X11) or
|
||||||
/// clipboard for Unicode.
|
/// clipboard for Unicode.
|
||||||
fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult {
|
fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult {
|
||||||
// Backspaces via uinput — reliable, no display server needed
|
let is_ascii = text.chars().all(|c| char_to_linux_keycode(c).is_some());
|
||||||
|
|
||||||
|
if is_ascii {
|
||||||
if backspaces > 0 {
|
if backspaces > 0 {
|
||||||
eprintln!("[vietc] uinput backspace x{}", backspaces);
|
|
||||||
for _ in 0..backspaces {
|
for _ in 0..backspaces {
|
||||||
let _ = self.send_backspace();
|
let _ = self.send_backspace();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !text.is_empty() {
|
for ch in text.chars() {
|
||||||
eprintln!("[vietc] text injection: \"{}\"", text);
|
let _ = self.send_char(ch);
|
||||||
self.paste_string(text);
|
|
||||||
}
|
}
|
||||||
|
return InjectResult::Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
// It is Unicode. We must use a single unified channel.
|
||||||
|
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
||||||
|
|
||||||
|
if is_wayland {
|
||||||
|
// Under Wayland, we try to use `wtype` for both backspaces and text.
|
||||||
|
let has_wtype = std::process::Command::new("which")
|
||||||
|
.arg("wtype")
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if has_wtype {
|
||||||
|
let mut args = Vec::new();
|
||||||
|
for _ in 0..backspaces {
|
||||||
|
args.push("-k");
|
||||||
|
args.push("BackSpace");
|
||||||
|
}
|
||||||
|
args.push("--");
|
||||||
|
args.push(text);
|
||||||
|
|
||||||
|
let output = Self::run_as_user("wtype", &args);
|
||||||
|
if output.status.success() {
|
||||||
|
return InjectResult::Success;
|
||||||
|
}
|
||||||
|
eprintln!("[vietc] wtype inject failed: {}", String::from_utf8_lossy(&output.stderr).trim());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Under X11, we try to use `xdotool` for both backspaces and text.
|
||||||
|
let has_xdotool = std::process::Command::new("which")
|
||||||
|
.arg("xdotool")
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if has_xdotool {
|
||||||
|
let mut args = Vec::new();
|
||||||
|
if backspaces > 0 {
|
||||||
|
args.push("key");
|
||||||
|
for _ in 0..backspaces {
|
||||||
|
args.push("BackSpace");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !text.is_empty() {
|
||||||
|
args.push("type");
|
||||||
|
args.push("--clearmodifiers");
|
||||||
|
args.push(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Self::run_as_user("xdotool", &args);
|
||||||
|
if output.status.success() {
|
||||||
|
return InjectResult::Success;
|
||||||
|
}
|
||||||
|
eprintln!("[vietc] xdotool inject failed: {}", String::from_utf8_lossy(&output.stderr).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Clipboard copy + paste.
|
||||||
|
// This is safe because both backspaces and Ctrl+V are injected into the SAME uinput device.
|
||||||
|
let copied = self.copy_to_clipboard(text);
|
||||||
|
if copied {
|
||||||
|
if backspaces > 0 {
|
||||||
|
for _ in 0..backspaces {
|
||||||
|
let _ = self.send_backspace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.send_ctrl_v();
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
|
} else {
|
||||||
|
eprintln!("[vietc] clipboard copy failed during fallback");
|
||||||
|
// Absolute last resort: try uinput backspaces followed by individual unicode paste_string
|
||||||
|
if backspaces > 0 {
|
||||||
|
for _ in 0..backspaces {
|
||||||
|
let _ = self.send_backspace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.paste_string(text);
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy text to clipboard and paste via Ctrl+V through our uinput device.
|
/// Copy text to clipboard and paste via Ctrl+V through our uinput device.
|
||||||
|
|
@ -214,14 +353,11 @@ impl UinputInjector {
|
||||||
/// unavailable. Prefers ydotool (uinput, works everywhere) to avoid
|
/// unavailable. Prefers ydotool (uinput, works everywhere) to avoid
|
||||||
/// clipboard pollution.
|
/// clipboard pollution.
|
||||||
fn paste_string(&self, s: &str) {
|
fn paste_string(&self, s: &str) {
|
||||||
let has_unicode = s.chars().any(|c| c > '\x7f');
|
// Try ydotool first (uinput-based, no display server needed).
|
||||||
|
let ydotool_result = std::process::Command::new("ydotool")
|
||||||
if !has_unicode {
|
|
||||||
// Pure ASCII: ydotool works reliably (no keycode mapping issues).
|
|
||||||
let output = std::process::Command::new("ydotool")
|
|
||||||
.args(["type", s])
|
.args(["type", s])
|
||||||
.output();
|
.output();
|
||||||
if let Ok(output) = output {
|
if let Ok(output) = ydotool_result {
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
eprintln!("[vietc] ydotool OK");
|
eprintln!("[vietc] ydotool OK");
|
||||||
return;
|
return;
|
||||||
|
|
@ -230,11 +366,8 @@ impl UinputInjector {
|
||||||
if !stderr.is_empty() {
|
if !stderr.is_empty() {
|
||||||
eprintln!("[vietc] ydotool failed: {}", stderr.trim());
|
eprintln!("[vietc] ydotool failed: {}", stderr.trim());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
eprintln!("[vietc] ydotool failed, trying xdotool...");
|
eprintln!("[vietc] ydotool failed, trying xdotool...");
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eprintln!("[vietc] contains Unicode, skipping ydotool");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try xdotool (X11): needs DISPLAY, run through run_as_user
|
// Try xdotool (X11): needs DISPLAY, run through run_as_user
|
||||||
eprintln!("[vietc] trying xdotool...");
|
eprintln!("[vietc] trying xdotool...");
|
||||||
|
|
@ -272,18 +405,16 @@ impl UinputInjector {
|
||||||
eprintln!("[vietc] WARNING: No injection method works for '{}'!", s);
|
eprintln!("[vietc] WARNING: No injection method works for '{}'!", s);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy text to clipboard using wl-copy (Wayland) or xclip (X11).
|
/// Build a command to run as the original user with display environment.
|
||||||
fn copy_to_clipboard(&self, s: &str) -> bool {
|
fn user_cmd(program: &str) -> std::process::Command {
|
||||||
let is_root = unsafe { libc::getuid() == 0 };
|
let is_root = unsafe { libc::getuid() == 0 };
|
||||||
if is_root {
|
if is_root {
|
||||||
if let Ok(sudo_user) = std::env::var("SUDO_USER") {
|
if let Some(original_user) = Self::get_original_username() {
|
||||||
let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default();
|
let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default();
|
||||||
let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default();
|
let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default();
|
||||||
let display = std::env::var("DISPLAY").unwrap_or_default();
|
let display = std::env::var("DISPLAY").unwrap_or_default();
|
||||||
eprintln!("[vietc] clipboard: is_root, SUDO_USER={} DISPLAY={} WAYLAND={} XDG_RUNTIME_DIR={}",
|
|
||||||
sudo_user, display, wayland_display, xdg_runtime_dir);
|
|
||||||
let mut cmd = std::process::Command::new("sudo");
|
let mut cmd = std::process::Command::new("sudo");
|
||||||
cmd.args(["-u", &sudo_user, "env"]);
|
cmd.args(["-u", &original_user, "env"]);
|
||||||
if !wayland_display.is_empty() {
|
if !wayland_display.is_empty() {
|
||||||
cmd.arg(format!("WAYLAND_DISPLAY={}", wayland_display));
|
cmd.arg(format!("WAYLAND_DISPLAY={}", wayland_display));
|
||||||
}
|
}
|
||||||
|
|
@ -293,7 +424,21 @@ impl UinputInjector {
|
||||||
if !display.is_empty() {
|
if !display.is_empty() {
|
||||||
cmd.arg(format!("DISPLAY={}", display));
|
cmd.arg(format!("DISPLAY={}", display));
|
||||||
}
|
}
|
||||||
cmd.arg("wl-copy");
|
cmd.arg(program);
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::process::Command::new(program)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy text to clipboard using wl-copy (Wayland) or xclip (X11).
|
||||||
|
fn copy_to_clipboard(&self, s: &str) -> bool {
|
||||||
|
let is_root = unsafe { libc::getuid() == 0 };
|
||||||
|
eprintln!("[vietc] clipboard: is_root={}", is_root);
|
||||||
|
|
||||||
|
// Try wl-copy (Wayland) via user_cmd
|
||||||
|
{
|
||||||
|
let mut cmd = Self::user_cmd("wl-copy");
|
||||||
eprintln!("[vietc] clipboard: trying wl-copy via {:?}", cmd);
|
eprintln!("[vietc] clipboard: trying wl-copy via {:?}", cmd);
|
||||||
let result = cmd
|
let result = cmd
|
||||||
.stdin(std::process::Stdio::piped())
|
.stdin(std::process::Stdio::piped())
|
||||||
|
|
@ -312,69 +457,15 @@ impl UinputInjector {
|
||||||
} else if let Err(ref e) = result {
|
} else if let Err(ref e) = result {
|
||||||
eprintln!("[vietc] clipboard: wl-copy error: {}", e);
|
eprintln!("[vietc] clipboard: wl-copy error: {}", e);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
eprintln!("[vietc] clipboard: is_root but no SUDO_USER");
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
eprintln!("[vietc] clipboard: not root, trying wl-copy directly");
|
// Try xclip (X11) via user_cmd
|
||||||
let result = std::process::Command::new("wl-copy")
|
|
||||||
.stdin(std::process::Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.and_then(|mut child| {
|
|
||||||
use std::io::Write;
|
|
||||||
child.stdin.take().unwrap().write_all(s.as_bytes())?;
|
|
||||||
child.wait()
|
|
||||||
});
|
|
||||||
if let Ok(status) = result {
|
|
||||||
if status.success() {
|
|
||||||
eprintln!("[vietc] clipboard: wl-copy OK");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
eprintln!("[vietc] clipboard: wl-copy failed (exit={:?})", status.code());
|
|
||||||
} else if let Err(ref e) = result {
|
|
||||||
eprintln!("[vietc] clipboard: wl-copy error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Try xclip (X11). When root, run as SUDO_USER so it can connect to X.
|
|
||||||
eprintln!("[vietc] clipboard: trying xclip...");
|
eprintln!("[vietc] clipboard: trying xclip...");
|
||||||
let xclip_result = if is_root {
|
{
|
||||||
if let Ok(sudo_user) = std::env::var("SUDO_USER") {
|
let mut cmd = Self::user_cmd("xclip");
|
||||||
let display = std::env::var("DISPLAY").unwrap_or_default();
|
|
||||||
let mut cmd = std::process::Command::new("sudo");
|
|
||||||
cmd.args(["-u", &sudo_user, "env"]);
|
|
||||||
if !display.is_empty() {
|
|
||||||
cmd.arg(format!("DISPLAY={}", display));
|
|
||||||
}
|
|
||||||
cmd.arg("xclip");
|
|
||||||
cmd.args(["-selection", "clipboard"]);
|
cmd.args(["-selection", "clipboard"]);
|
||||||
eprintln!("[vietc] clipboard: xclip via {:?}", cmd);
|
eprintln!("[vietc] clipboard: xclip via {:?}", cmd);
|
||||||
cmd.stdin(std::process::Stdio::piped())
|
let result = cmd
|
||||||
.spawn()
|
|
||||||
.and_then(|mut child| {
|
|
||||||
use std::io::Write;
|
|
||||||
child.stdin.take().unwrap().write_all(s.as_bytes())?;
|
|
||||||
child.wait()
|
|
||||||
})
|
|
||||||
.map(|status| {
|
|
||||||
if status.success() {
|
|
||||||
eprintln!("[vietc] clipboard: xclip OK");
|
|
||||||
} else {
|
|
||||||
eprintln!("[vietc] clipboard: xclip failed (exit={:?})", status.code());
|
|
||||||
}
|
|
||||||
status.success()
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|e| {
|
|
||||||
eprintln!("[vietc] clipboard: xclip error: {}", e);
|
|
||||||
false
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
eprintln!("[vietc] clipboard: is_root but no SUDO_USER in xclip path");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eprintln!("[vietc] clipboard: not root, trying xclip directly");
|
|
||||||
std::process::Command::new("xclip")
|
|
||||||
.args(["-selection", "clipboard"])
|
|
||||||
.stdin(std::process::Stdio::piped())
|
.stdin(std::process::Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
.and_then(|mut child| {
|
.and_then(|mut child| {
|
||||||
|
|
@ -393,10 +484,13 @@ impl UinputInjector {
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
eprintln!("[vietc] clipboard: xclip error: {}", e);
|
eprintln!("[vietc] clipboard: xclip error: {}", e);
|
||||||
false
|
false
|
||||||
})
|
});
|
||||||
};
|
if result {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
xclip_result
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send Ctrl+V through our uinput device.
|
/// Send Ctrl+V through our uinput device.
|
||||||
|
|
|
||||||
|
|
@ -167,9 +167,35 @@ impl X11Injector {
|
||||||
if ydotool_ok {
|
if ydotool_ok {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = std::process::Command::new("xdotool")
|
let xdotool_ok = std::process::Command::new("xdotool")
|
||||||
.args(["type", "--clearmodifiers", &s])
|
.args(["type", "--clearmodifiers", &s])
|
||||||
.output();
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if xdotool_ok {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clipboard fallback: xclip + Ctrl+V via XTEST
|
||||||
|
let copied = std::process::Command::new("xclip")
|
||||||
|
.args(["-selection", "clipboard"])
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.and_then(|mut child| {
|
||||||
|
use std::io::Write;
|
||||||
|
child.stdin.take().unwrap().write_all(s.as_bytes())?;
|
||||||
|
child.wait()
|
||||||
|
})
|
||||||
|
.map(|status| status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if copied {
|
||||||
|
unsafe {
|
||||||
|
(self.lib.x_test_fake_key_event)(self.display, 29, 1, 0); // Ctrl press
|
||||||
|
(self.lib.x_test_fake_key_event)(self.display, 47, 1, 0); // V press
|
||||||
|
(self.lib.x_test_fake_key_event)(self.display, 47, 0, 0); // V release
|
||||||
|
(self.lib.x_test_fake_key_event)(self.display, 29, 0, 0); // Ctrl release
|
||||||
|
(self.lib.x_flush)(self.display);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,6 +222,97 @@ impl KeyInjector for X11Injector {
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn inject_replacement(&self, backspaces: usize, text: &str) -> InjectResult {
|
||||||
|
let is_ascii = text.chars().all(|c| char_to_keycode(c).is_some());
|
||||||
|
|
||||||
|
if is_ascii {
|
||||||
|
if backspaces > 0 {
|
||||||
|
for _ in 0..backspaces {
|
||||||
|
self.send_keycode(14, false); // KEY_BACKSPACE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ch in text.chars() {
|
||||||
|
if let Some((keycode, shift)) = char_to_keycode(ch) {
|
||||||
|
self.send_keycode(keycode, shift);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return InjectResult::Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains Unicode: try xdotool with both backspaces and text in a single command
|
||||||
|
let has_xdotool = std::process::Command::new("which")
|
||||||
|
.arg("xdotool")
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if has_xdotool {
|
||||||
|
let mut args = Vec::new();
|
||||||
|
if backspaces > 0 {
|
||||||
|
args.push("key".to_string());
|
||||||
|
for _ in 0..backspaces {
|
||||||
|
args.push("BackSpace".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !text.is_empty() {
|
||||||
|
args.push("type".to_string());
|
||||||
|
args.push("--clearmodifiers".to_string());
|
||||||
|
args.push(text.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok = std::process::Command::new("xdotool")
|
||||||
|
.args(&args)
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if ok {
|
||||||
|
return InjectResult::Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Clipboard copy + paste.
|
||||||
|
// Send backspaces via XTEST, then copy to clipboard, then paste (Ctrl+V) via XTEST.
|
||||||
|
// Since all XTEST key events go through the same display connection, their ordering is guaranteed.
|
||||||
|
let mut clipboard_cmd = std::process::Command::new("xclip");
|
||||||
|
clipboard_cmd.args(["-selection", "clipboard"]);
|
||||||
|
clipboard_cmd.stdin(std::process::Stdio::piped());
|
||||||
|
let copied = clipboard_cmd.spawn()
|
||||||
|
.and_then(|mut child| {
|
||||||
|
use std::io::Write;
|
||||||
|
child.stdin.take().unwrap().write_all(text.as_bytes())?;
|
||||||
|
child.wait()
|
||||||
|
})
|
||||||
|
.map(|status| status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if copied {
|
||||||
|
if backspaces > 0 {
|
||||||
|
for _ in 0..backspaces {
|
||||||
|
self.send_keycode(14, false); // KEY_BACKSPACE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unsafe {
|
||||||
|
(self.lib.x_test_fake_key_event)(self.display, 29, 1, 0); // Ctrl press
|
||||||
|
(self.lib.x_test_fake_key_event)(self.display, 47, 1, 0); // V press
|
||||||
|
(self.lib.x_test_fake_key_event)(self.display, 47, 0, 0); // V release
|
||||||
|
(self.lib.x_test_fake_key_event)(self.display, 29, 0, 0); // Ctrl release
|
||||||
|
(self.lib.x_flush)(self.display);
|
||||||
|
}
|
||||||
|
InjectResult::Success
|
||||||
|
} else {
|
||||||
|
// Absolute last resort: backspaces via XTEST followed by individual unicode send_unicode_via_xdotool
|
||||||
|
if backspaces > 0 {
|
||||||
|
for _ in 0..backspaces {
|
||||||
|
self.send_keycode(14, false); // KEY_BACKSPACE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ch in text.chars() {
|
||||||
|
self.send_char(ch);
|
||||||
|
}
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn flush(&self) -> InjectResult {
|
fn flush(&self) -> InjectResult {
|
||||||
unsafe { (self.lib.x_flush)(self.display); }
|
unsafe { (self.lib.x_flush)(self.display); }
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
|
|
|
||||||
159
scripts/gen_test_cases.py
Normal file
159
scripts/gen_test_cases.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate 1000+ Vietnamese IME test cases and produce a Rust test file."""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
PROJECT_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, ".."))
|
||||||
|
EXAMPLE_PATH = os.path.join(PROJECT_DIR, "target", "release", "examples", "gen_tests")
|
||||||
|
|
||||||
|
def build_generator():
|
||||||
|
"""Build the Rust test case generator."""
|
||||||
|
subprocess.run(
|
||||||
|
["cargo", "run", "--example", "gen_tests", "--release"],
|
||||||
|
cwd=PROJECT_DIR,
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_generator():
|
||||||
|
"""Run the generator and return JSON lines."""
|
||||||
|
result = subprocess.run(
|
||||||
|
[EXAMPLE_PATH],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
cases = []
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("Generated"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
cases.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
return cases
|
||||||
|
|
||||||
|
def generate_rust_test(cases, output_path):
|
||||||
|
"""Generate a Rust test file with all cases."""
|
||||||
|
from datetime import datetime
|
||||||
|
lines = []
|
||||||
|
lines.append("/// Auto-generated from gen_tests example")
|
||||||
|
lines.append(f"/// Generated: {datetime.now().isoformat()}")
|
||||||
|
lines.append(f"/// Total cases: {len(cases)}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("use vietc_engine::{Engine, EngineEvent, InputMethod};")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("fn get_display(events: &[EngineEvent]) -> String {")
|
||||||
|
lines.append(" let mut display = String::new();")
|
||||||
|
lines.append(" for ev in events {")
|
||||||
|
lines.append(" match ev {")
|
||||||
|
lines.append(" EngineEvent::Flush(text) => {")
|
||||||
|
lines.append(" if !display.ends_with(text) { display.push_str(text); }")
|
||||||
|
lines.append(" }")
|
||||||
|
lines.append(" EngineEvent::Insert(text) => display.push_str(text),")
|
||||||
|
lines.append(" EngineEvent::Replace { backspaces, insert } => {")
|
||||||
|
lines.append(" for _ in 0..*backspaces { display.pop(); }")
|
||||||
|
lines.append(" display.push_str(insert);")
|
||||||
|
lines.append(" }")
|
||||||
|
lines.append(" EngineEvent::AutoRestore(word) => {")
|
||||||
|
lines.append(" for _ in 0..word.len() { display.pop(); }")
|
||||||
|
lines.append(" display.push_str(word);")
|
||||||
|
lines.append(" }")
|
||||||
|
lines.append(" EngineEvent::UndoTones { backspaces, restored } => {")
|
||||||
|
lines.append(" for _ in 0..*backspaces { display.pop(); }")
|
||||||
|
lines.append(" display.push_str(restored);")
|
||||||
|
lines.append(" }")
|
||||||
|
lines.append(" }")
|
||||||
|
lines.append(" }")
|
||||||
|
lines.append(" display")
|
||||||
|
lines.append("}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("fn process_input(e: &mut Engine, input: &str) -> Vec<EngineEvent> {")
|
||||||
|
lines.append(" let mut events = Vec::new();")
|
||||||
|
lines.append(" for ch in input.chars() {")
|
||||||
|
lines.append(" if let Some(ev) = e.process_key(ch) { events.push(ev); }")
|
||||||
|
lines.append(" }")
|
||||||
|
lines.append(" events")
|
||||||
|
lines.append("}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append(f"const TEST_CASES: &[(&str, &str, &str)] = &[")
|
||||||
|
for c in cases:
|
||||||
|
input_escaped = c["i"].replace("\\", "\\\\").replace("\"", "\\\"")
|
||||||
|
expected_escaped = c["e"].replace("\\", "\\\\").replace("\"", "\\\"")
|
||||||
|
mode = c["m"]
|
||||||
|
lines.append(f' ("{input_escaped}", "{expected_escaped}", "{mode}"),')
|
||||||
|
lines.append("];")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("#[test]")
|
||||||
|
lines.append("fn test_generated_bulk() {")
|
||||||
|
lines.append(" let mut failures = Vec::new();")
|
||||||
|
lines.append(" for (i, &(input, expected, mode)) in TEST_CASES.iter().enumerate() {")
|
||||||
|
lines.append(" let im = match mode {")
|
||||||
|
lines.append(' "telex" => InputMethod::Telex,')
|
||||||
|
lines.append(' "vni" => InputMethod::Vni,')
|
||||||
|
lines.append(" _ => unreachable!(),")
|
||||||
|
lines.append(" };")
|
||||||
|
lines.append(" let mut e = Engine::new(im);")
|
||||||
|
lines.append(" let actual = get_display(&process_input(&mut e, input));")
|
||||||
|
lines.append(" if actual != expected {")
|
||||||
|
lines.append(" failures.push(format!(\"[{}] '{}' -> '{}' (expected '{}')\", i, input, actual, expected));")
|
||||||
|
lines.append(" }")
|
||||||
|
lines.append(" }")
|
||||||
|
lines.append(" if !failures.is_empty() {")
|
||||||
|
lines.append(" for f in &failures[..10.min(failures.len())] {")
|
||||||
|
lines.append(' eprintln!("{}", f);')
|
||||||
|
lines.append(" }")
|
||||||
|
lines.append(' panic!("{}/{} tests FAILED", failures.len(), TEST_CASES.len());')
|
||||||
|
lines.append(" }")
|
||||||
|
lines.append(' eprintln!("All {} generated tests PASSED", TEST_CASES.len());')
|
||||||
|
lines.append("}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write("\n".join(lines))
|
||||||
|
|
||||||
|
print(f"Generated {output_path} with {len(cases)} test cases")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--output", default="engine/tests/generated_bulk.rs",
|
||||||
|
help="Output Rust test file path")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
output_path = os.path.join(PROJECT_DIR, args.output)
|
||||||
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||||
|
|
||||||
|
# Run generator & capture cases
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["cargo", "run", "--example", "gen_tests", "--release"],
|
||||||
|
cwd=PROJECT_DIR,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
cases = []
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("Generated"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
cases.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print("ERROR: Generator timed out", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"ERROR: Generator failed: {e.stderr}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Captured {len(cases)} test cases from generator")
|
||||||
|
generate_rust_test(cases, output_path)
|
||||||
|
|
@ -1,26 +1,15 @@
|
||||||
[package]
|
[package]
|
||||||
name = "vietc-ui"
|
name = "vietc-tray"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Viet+ settings UI and tray icon (GTK4/Libadwaita)"
|
description = "Viet+ system tray icon"
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "vietc-settings"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "vietc-tray"
|
name = "vietc-tray"
|
||||||
path = "src/tray.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
vietc-engine = { path = "../engine" }
|
|
||||||
gtk = { package = "gtk4", version = "0.9", features = ["v4_12"], optional = true }
|
|
||||||
adw = { package = "libadwaita", version = "0.7", features = ["v1_4"], optional = true }
|
|
||||||
ksni = "0.2"
|
ksni = "0.2"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
|
|
||||||
[features]
|
|
||||||
default = ["ui"]
|
|
||||||
ui = ["dep:gtk", "dep:adw"]
|
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<interface>
|
|
||||||
<requires lib="gtk" version="4.0"/>
|
|
||||||
<requires lib="libadwaita" version="1.0"/>
|
|
||||||
<template class="VietTuxWindow" class="AdwApplicationWindow" parent="AdwApplicationWindow">
|
|
||||||
<property name="default-width">600</property>
|
|
||||||
<property name="default-height">700</property>
|
|
||||||
<property name="title">VietTux Settings</property>
|
|
||||||
<property name="content">
|
|
||||||
<object class="GtkBox">
|
|
||||||
<property name="orientation">vertical</property>
|
|
||||||
<child>
|
|
||||||
<object class="AdwHeaderBar">
|
|
||||||
<child type="end">
|
|
||||||
<object class="GtkButton" id="save_button">
|
|
||||||
<property name="label">Save</property>
|
|
||||||
<property name="css_classes">suggested-action</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="Adwclamp">
|
|
||||||
<property name="maximum-size">600</property>
|
|
||||||
<property name="child">
|
|
||||||
<object class="GtkScrolledWindow">
|
|
||||||
<property name="vexpand">true</property>
|
|
||||||
<property name="child">
|
|
||||||
<object class="AdwPreferencesGroup">
|
|
||||||
<property name="title">Input Method</property>
|
|
||||||
<child>
|
|
||||||
<object class="AdwComboRow" id="method_row">
|
|
||||||
<property name="title">Keyboard Layout</property>
|
|
||||||
<property name="subtitle">Choose Telex or VNI input method</property>
|
|
||||||
<property name="model">
|
|
||||||
<object class="GtkStringList">
|
|
||||||
<items>
|
|
||||||
<item>Telex</item>
|
|
||||||
<item>VNI</item>
|
|
||||||
</items>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="AdwComboRow" id="toggle_row">
|
|
||||||
<property name="title">Toggle Key</property>
|
|
||||||
<property name="subtitle">Key combination to toggle Vietnamese mode</property>
|
|
||||||
<property name="model">
|
|
||||||
<object class="GtkStringList">
|
|
||||||
<items>
|
|
||||||
<item>Ctrl + Space</item>
|
|
||||||
<item>Ctrl + Shift</item>
|
|
||||||
<item>Caps Lock</item>
|
|
||||||
</items>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
</template>
|
|
||||||
</interface>
|
|
||||||
106
ui/src/config.rs
106
ui/src/config.rs
|
|
@ -1,8 +1,47 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AutoRestoreConfig {
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_restore_keys")]
|
||||||
|
pub trigger_keys: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AutoRestoreConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
trigger_keys: default_restore_keys(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AppStateConfig {
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub english_apps: Vec<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub vietnamese_apps: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppStateConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
english_apps: vec![],
|
||||||
|
vietnamese_apps: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default = "default_input_method")]
|
#[serde(default = "default_input_method")]
|
||||||
|
|
@ -21,67 +60,39 @@ pub struct Config {
|
||||||
pub app_state: AppStateConfig,
|
pub app_state: AppStateConfig,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub macros: HashMap<String, String>,
|
pub macros: std::collections::HashMap<String, String>,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[serde(default = "default_grab")]
|
||||||
pub struct AutoRestoreConfig {
|
pub grab: bool,
|
||||||
#[serde(default = "default_true")]
|
|
||||||
pub enabled: bool,
|
|
||||||
|
|
||||||
#[serde(default = "default_restore_keys")]
|
#[serde(default = "default_false")]
|
||||||
pub trigger_keys: Vec<String>,
|
pub debug: bool,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct AppStateConfig {
|
|
||||||
#[serde(default = "default_true")]
|
|
||||||
pub enabled: bool,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub english_apps: Vec<String>,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub vietnamese_apps: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_input_method() -> String { "telex".into() }
|
fn default_input_method() -> String { "telex".into() }
|
||||||
fn default_toggle_key() -> String { "space".into() }
|
fn default_toggle_key() -> String { "space".into() }
|
||||||
fn default_start_enabled() -> bool { true }
|
fn default_start_enabled() -> bool { true }
|
||||||
|
fn default_grab() -> bool { true }
|
||||||
fn default_true() -> 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_restore_keys() -> Vec<String> { vec!["space".into(), "escape".into()] }
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let mut macros = HashMap::new();
|
|
||||||
macros.insert("ko".into(), "không".into());
|
|
||||||
macros.insert("dc".into(), "được".into());
|
|
||||||
macros.insert("vs".into(), "với".into());
|
|
||||||
macros.insert("lm".into(), "làm".into());
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
input_method: default_input_method(),
|
input_method: default_input_method(),
|
||||||
toggle_key: default_toggle_key(),
|
toggle_key: default_toggle_key(),
|
||||||
start_enabled: default_start_enabled(),
|
start_enabled: default_start_enabled(),
|
||||||
auto_restore: AutoRestoreConfig {
|
auto_restore: AutoRestoreConfig::default(),
|
||||||
enabled: true,
|
app_state: AppStateConfig::default(),
|
||||||
trigger_keys: default_restore_keys(),
|
macros: std::collections::HashMap::new(),
|
||||||
},
|
grab: default_grab(),
|
||||||
app_state: AppStateConfig {
|
debug: default_false(),
|
||||||
enabled: true,
|
|
||||||
english_apps: vec![
|
|
||||||
"code".into(), "vim".into(), "nvim".into(),
|
|
||||||
"terminal".into(), "kitty".into(), "alacritty".into(),
|
|
||||||
],
|
|
||||||
vietnamese_apps: vec![
|
|
||||||
"telegram".into(), "discord".into(), "firefox".into(),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
macros,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
for path in config_paths() {
|
for path in config_paths() {
|
||||||
|
|
@ -103,10 +114,6 @@ impl Config {
|
||||||
fs::write(&path, content)?;
|
fs::write(&path, content)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn path() -> PathBuf {
|
|
||||||
config_path()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_path() -> PathBuf {
|
fn config_path() -> PathBuf {
|
||||||
|
|
@ -151,7 +158,7 @@ pub fn uninstall_autostart() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn install_autostart_force() {
|
pub fn install_autostart() {
|
||||||
if let Some(config_dir) = dirs::config_dir() {
|
if let Some(config_dir) = dirs::config_dir() {
|
||||||
let autostart_dir = config_dir.join("autostart");
|
let autostart_dir = config_dir.join("autostart");
|
||||||
let desktop_file = autostart_dir.join("vietc-tray.desktop");
|
let desktop_file = autostart_dir.join("vietc-tray.desktop");
|
||||||
|
|
@ -169,8 +176,8 @@ pub fn install_autostart_force() {
|
||||||
let content = format!(
|
let content = format!(
|
||||||
"[Desktop Entry]\n\
|
"[Desktop Entry]\n\
|
||||||
Type=Application\n\
|
Type=Application\n\
|
||||||
Name=Viet+ Tray\n\
|
Name=Viet+\n\
|
||||||
Comment=Vietnamese Input Method tray icon\n\
|
Comment=Vietnamese Input Method\n\
|
||||||
Exec={}\n\
|
Exec={}\n\
|
||||||
Icon=input-keyboard\n\
|
Icon=input-keyboard\n\
|
||||||
Terminal=false\n\
|
Terminal=false\n\
|
||||||
|
|
@ -184,4 +191,3 @@ pub fn install_autostart_force() {
|
||||||
eprintln!("[vietc] Installed autostart entry");
|
eprintln!("[vietc] Installed autostart entry");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
178
ui/src/main.rs
178
ui/src/main.rs
|
|
@ -1,21 +1,165 @@
|
||||||
use adw::prelude::*;
|
use std::path::PathBuf;
|
||||||
use gtk::{gio, glib};
|
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
mod window;
|
mod tray;
|
||||||
|
|
||||||
use window::SettingsWindow;
|
fn exe_dir() -> PathBuf {
|
||||||
|
std::env::current_exe()
|
||||||
fn main() -> glib::ExitCode {
|
.ok()
|
||||||
let app = adw::Application::builder()
|
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
|
||||||
.application_id("io.github.vietc.Settings")
|
.unwrap_or_else(|| PathBuf::from("/usr/bin"))
|
||||||
.flags(gio::ApplicationFlags::FLAGS_NONE)
|
}
|
||||||
.build();
|
|
||||||
|
fn find_sibling_binary(name: &str) -> String {
|
||||||
app.connect_activate(|app| {
|
let sibling = exe_dir().join(name);
|
||||||
let window = SettingsWindow::new(app);
|
if sibling.exists() {
|
||||||
gtk::prelude::GtkWindowExt::present(&window);
|
return sibling.to_string_lossy().into_owned();
|
||||||
});
|
}
|
||||||
|
name.to_string()
|
||||||
app.run()
|
}
|
||||||
|
|
||||||
|
fn is_daemon_running() -> bool {
|
||||||
|
std::process::Command::new("pgrep")
|
||||||
|
.arg("-x")
|
||||||
|
.arg("vietc")
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn needs_root() -> bool {
|
||||||
|
let cfg = config::Config::load();
|
||||||
|
cfg.grab
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show a password prompt using available desktop tools.
|
||||||
|
/// Returns the password, or empty string if cancelled.
|
||||||
|
fn prompt_password() -> String {
|
||||||
|
let title = "Viet+";
|
||||||
|
let msg = "Viet+ needs root privileges to capture keyboard input.\nPlease enter your password:";
|
||||||
|
|
||||||
|
// Try zenity (GNOME)
|
||||||
|
if let Ok(output) = std::process::Command::new("zenity")
|
||||||
|
.args(["--password", "--title", title, "--text", msg])
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
if output.status.success() {
|
||||||
|
let pw = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if !pw.is_empty() {
|
||||||
|
return pw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try kdialog (KDE)
|
||||||
|
if let Ok(output) = std::process::Command::new("kdialog")
|
||||||
|
.args(["--password", msg])
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
if output.status.success() {
|
||||||
|
let pw = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if !pw.is_empty() {
|
||||||
|
return pw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try ssh-askpass (X11 fallback)
|
||||||
|
if let Ok(output) = std::process::Command::new("ssh-askpass")
|
||||||
|
.arg(msg)
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
if output.status.success() {
|
||||||
|
let pw = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if !pw.is_empty() {
|
||||||
|
return pw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: terminal prompt
|
||||||
|
eprintln!("{}", msg);
|
||||||
|
if let Ok(child) = std::process::Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg("read -s -p 'Password: ' pw && echo \"$pw\"")
|
||||||
|
.stdin(std::process::Stdio::inherit())
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
if let Ok(output) = child.wait_with_output() {
|
||||||
|
if output.status.success() {
|
||||||
|
return String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_daemon() {
|
||||||
|
let daemon_bin = find_sibling_binary("vietc");
|
||||||
|
|
||||||
|
if needs_root() && !is_daemon_running() {
|
||||||
|
// Mark that we've attempted first launch
|
||||||
|
let flag_path = config_path().join(".first-launch-done");
|
||||||
|
|
||||||
|
if !flag_path.exists() {
|
||||||
|
let password = prompt_password();
|
||||||
|
if password.is_empty() {
|
||||||
|
eprintln!("[vietc-tray] No password provided, starting daemon without root");
|
||||||
|
let _ = std::process::Command::new(&daemon_bin).spawn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start daemon with sudo
|
||||||
|
let mut child = match std::process::Command::new("sudo")
|
||||||
|
.args(["-S", &daemon_bin])
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[vietc-tray] Failed to start daemon with sudo: {}", e);
|
||||||
|
let _ = std::process::Command::new(&daemon_bin).spawn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
use std::io::Write;
|
||||||
|
let _ = stdin.write_all(format!("{}\n", password).as_bytes());
|
||||||
|
}
|
||||||
|
let _ = child.wait();
|
||||||
|
|
||||||
|
// Mark first launch as done
|
||||||
|
let _ = std::fs::write(&flag_path, "1");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !is_daemon_running() {
|
||||||
|
eprintln!("[vietc-tray] Starting daemon: {}", daemon_bin);
|
||||||
|
let _ = std::process::Command::new(&daemon_bin).spawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> PathBuf {
|
||||||
|
dirs::config_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("vietc")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
eprintln!("[vietc-tray] Starting");
|
||||||
|
|
||||||
|
// Start daemon (with password prompt if first launch)
|
||||||
|
start_daemon();
|
||||||
|
|
||||||
|
// Run the tray
|
||||||
|
tray::run();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
273
ui/src/tray.rs
273
ui/src/tray.rs
|
|
@ -1,124 +1,135 @@
|
||||||
use ksni::{Tray, MenuItem, menu::*};
|
use ksni::{Tray, MenuItem, menu::*};
|
||||||
mod config;
|
use crate::config;
|
||||||
use config::Config;
|
|
||||||
|
|
||||||
/// Get the directory where the current executable lives.
|
fn write_status(state: &str) {
|
||||||
/// This handles AppImage, DEB installs, and dev builds correctly.
|
if let Some(config_dir) = dirs::config_dir() {
|
||||||
fn exe_dir() -> std::path::PathBuf {
|
let _ = std::fs::write(config_dir.join("vietc").join("status"), state);
|
||||||
std::env::current_exe()
|
}
|
||||||
.ok()
|
|
||||||
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
|
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from("/usr/bin"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a sibling binary (in the same directory as the current executable).
|
fn read_status() -> String {
|
||||||
/// Also searches the workspace target directory for development.
|
let path = dirs::config_dir()
|
||||||
/// Falls back to searching PATH if not found next to the executable.
|
.map(|d| d.join("vietc").join("status"))
|
||||||
fn find_sibling_binary(name: &str) -> String {
|
.unwrap_or_else(|| std::path::PathBuf::from("/tmp/vietc-status"));
|
||||||
// 1. Same directory
|
|
||||||
let sibling = exe_dir().join(name);
|
std::fs::read_to_string(&path)
|
||||||
if sibling.exists() {
|
.map(|s| s.trim().to_string())
|
||||||
return sibling.to_string_lossy().into_owned();
|
.unwrap_or_else(|_| {
|
||||||
|
let cfg = config::Config::load();
|
||||||
|
if cfg.start_enabled { "vn".into() } else { "en".into() }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Dev target/debug relative path (from ui/target/debug)
|
fn current_im() -> String {
|
||||||
let dev_debug = exe_dir().join("..").join("..").join("..").join("target").join("debug").join(name);
|
config::Config::load().input_method
|
||||||
if dev_debug.exists() {
|
|
||||||
return dev_debug.to_string_lossy().into_owned();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Dev target/release relative path (from ui/target/release)
|
fn ensure_icons() {
|
||||||
let dev_release = exe_dir().join("..").join("..").join("..").join("target").join("release").join(name);
|
let Some(config_dir) = dirs::config_dir() else { return };
|
||||||
if dev_release.exists() {
|
let icons_dir = config_dir.join("vietc").join("icons");
|
||||||
return dev_release.to_string_lossy().into_owned();
|
let _ = std::fs::create_dir_all(&icons_dir);
|
||||||
|
|
||||||
|
let vn_path = icons_dir.join("vietc-vn.svg");
|
||||||
|
let en_path = icons_dir.join("vietc-en.svg");
|
||||||
|
|
||||||
|
let _ = std::fs::write(&vn_path, r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<rect x="2" y="2" width="28" height="28" rx="6" fill="#e02424"/>
|
||||||
|
<text x="16" y="22" text-anchor="middle" fill="#ffffff" font-size="14" font-weight="900" font-family="system-ui, sans-serif">VN</text>
|
||||||
|
</svg>"##);
|
||||||
|
|
||||||
|
let _ = std::fs::write(&en_path, r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<rect x="2" y="2" width="28" height="28" rx="6" fill="#4b5563"/>
|
||||||
|
<text x="16" y="22" text-anchor="middle" fill="#ffffff" font-size="14" font-weight="900" font-family="system-ui, sans-serif">EN</text>
|
||||||
|
</svg>"##);
|
||||||
}
|
}
|
||||||
|
|
||||||
name.to_string()
|
struct VietTray {
|
||||||
|
mode: String,
|
||||||
|
im: String,
|
||||||
|
autostart: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VietcTray {
|
impl Tray for VietTray {
|
||||||
active_mode: String,
|
fn id(&self) -> String { "io.github.vietc.Tray".into() }
|
||||||
autostart_enabled: bool,
|
fn title(&self) -> String { "Viet+".into() }
|
||||||
}
|
|
||||||
|
|
||||||
impl Tray for VietcTray {
|
|
||||||
fn id(&self) -> String {
|
|
||||||
"io.github.vietc.Tray".into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self) -> String {
|
|
||||||
"Viet+".into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn icon_name(&self) -> String {
|
fn icon_name(&self) -> String {
|
||||||
if self.active_mode == "vn" {
|
if self.mode == "vn" { "vietc-vn".into() } else { "vietc-en".into() }
|
||||||
"vietc-vn".into()
|
|
||||||
} else {
|
|
||||||
"vietc-en".into()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn icon_theme_path(&self) -> String {
|
fn icon_theme_path(&self) -> String {
|
||||||
if let Some(config_dir) = dirs::config_dir() {
|
dirs::config_dir()
|
||||||
config_dir.join("vietc").join("icons").to_string_lossy().into_owned()
|
.map(|d| d.join("vietc").join("icons").to_string_lossy().into_owned())
|
||||||
} else {
|
.unwrap_or_default()
|
||||||
"".into()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn activate(&mut self, _x: i32, _y: i32) {
|
||||||
|
let next = if self.mode == "vn" { "en" } else { "vn" };
|
||||||
|
write_status(&next);
|
||||||
|
let mut cfg = config::Config::load();
|
||||||
|
cfg.start_enabled = next == "vn";
|
||||||
|
let _ = cfg.save();
|
||||||
|
self.mode = next.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn menu(&self) -> Vec<MenuItem<Self>> {
|
fn menu(&self) -> Vec<MenuItem<Self>> {
|
||||||
let is_vn = self.active_mode == "vn";
|
let is_vn = self.mode == "vn";
|
||||||
|
let im_index = if self.im == "telex" { 0 } else { 1 };
|
||||||
|
|
||||||
vec![
|
vec![
|
||||||
CheckmarkItem {
|
CheckmarkItem {
|
||||||
label: "Vietnamese Mode".into(),
|
label: "Vietnamese Mode".into(),
|
||||||
checked: is_vn,
|
checked: is_vn,
|
||||||
activate: Box::new(|this: &mut VietcTray| {
|
activate: Box::new(|this: &mut VietTray| {
|
||||||
let next_state = if this.active_mode == "vn" { "en" } else { "vn" };
|
let next = if this.mode == "vn" { "en" } else { "vn" };
|
||||||
if let Some(config_dir) = dirs::config_dir() {
|
write_status(&next);
|
||||||
let status_path = config_dir.join("vietc").join("status");
|
let mut cfg = config::Config::load();
|
||||||
let _ = std::fs::write(&status_path, next_state);
|
cfg.start_enabled = next == "vn";
|
||||||
}
|
let _ = cfg.save();
|
||||||
|
this.mode = next.to_string();
|
||||||
// Also save start_enabled to config, so it persists across reboots
|
|
||||||
let mut config = Config::load();
|
|
||||||
config.start_enabled = next_state == "vn";
|
|
||||||
let _ = config.save();
|
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}.into(),
|
}.into(),
|
||||||
|
MenuItem::Separator,
|
||||||
|
SubMenu {
|
||||||
|
label: "Input Method".into(),
|
||||||
|
submenu: vec![
|
||||||
|
RadioGroup {
|
||||||
|
selected: im_index,
|
||||||
|
select: Box::new(|this: &mut VietTray, idx: usize| {
|
||||||
|
let im = if idx == 0 { "telex" } else { "vni" };
|
||||||
|
let mut cfg = config::Config::load();
|
||||||
|
cfg.input_method = im.into();
|
||||||
|
let _ = cfg.save();
|
||||||
|
this.im = im.into();
|
||||||
|
}),
|
||||||
|
options: vec![
|
||||||
|
RadioItem { label: "Telex".into(), ..Default::default() },
|
||||||
|
RadioItem { label: "VNI".into(), ..Default::default() },
|
||||||
|
],
|
||||||
|
}.into(),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
}.into(),
|
||||||
|
MenuItem::Separator,
|
||||||
CheckmarkItem {
|
CheckmarkItem {
|
||||||
label: "Autostart on Boot".into(),
|
label: "Start with System".into(),
|
||||||
checked: self.autostart_enabled,
|
checked: self.autostart,
|
||||||
activate: Box::new(|this: &mut VietcTray| {
|
activate: Box::new(|this: &mut VietTray| {
|
||||||
if this.autostart_enabled {
|
if this.autostart {
|
||||||
config::uninstall_autostart();
|
config::uninstall_autostart();
|
||||||
} else {
|
} else {
|
||||||
config::install_autostart_force();
|
config::install_autostart();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}.into(),
|
}.into(),
|
||||||
MenuItem::Separator,
|
MenuItem::Separator,
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Settings...".into(),
|
label: "Quit".into(),
|
||||||
activate: Box::new(|_| {
|
|
||||||
let settings_bin = find_sibling_binary("vietc-settings");
|
|
||||||
eprintln!("[vietc-tray] Launching settings: {}", settings_bin);
|
|
||||||
match std::process::Command::new(&settings_bin).spawn() {
|
|
||||||
Ok(_) => {},
|
|
||||||
Err(e) => eprintln!("[vietc-tray] Failed to launch settings: {}", e),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
..Default::default()
|
|
||||||
}.into(),
|
|
||||||
MenuItem::Separator,
|
|
||||||
StandardItem {
|
|
||||||
label: "Quit Viet+".into(),
|
|
||||||
activate: Box::new(|_| {
|
activate: Box::new(|_| {
|
||||||
let _ = std::process::Command::new("pkill")
|
let _ = std::process::Command::new("pkill")
|
||||||
.arg("-x")
|
.arg("-x").arg("vietc").status();
|
||||||
.arg("vietc")
|
|
||||||
.status();
|
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|
@ -127,99 +138,33 @@ impl Tray for VietcTray {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_daemon_running() -> bool {
|
pub fn run() {
|
||||||
std::process::Command::new("pgrep")
|
ensure_icons();
|
||||||
.arg("-x")
|
|
||||||
.arg("vietc")
|
|
||||||
.status()
|
|
||||||
.map(|s| s.success())
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_icons_exist() {
|
let tray = VietTray {
|
||||||
if let Some(config_dir) = dirs::config_dir() {
|
mode: read_status(),
|
||||||
let icons_dir = config_dir.join("vietc").join("icons");
|
im: current_im(),
|
||||||
let _ = std::fs::create_dir_all(&icons_dir);
|
autostart: config::is_autostart_installed(),
|
||||||
|
|
||||||
let vn_path = icons_dir.join("vietc-vn.svg");
|
|
||||||
let en_path = icons_dir.join("vietc-en.svg");
|
|
||||||
|
|
||||||
let vn_svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
|
||||||
<rect x="2" y="2" width="28" height="28" rx="6" fill="#e02424"/>
|
|
||||||
<text x="16" y="21" text-anchor="middle" fill="#ffffff" font-size="13" font-weight="900" font-family="system-ui, -apple-system, sans-serif" letter-spacing="0.5">VN</text>
|
|
||||||
</svg>"##;
|
|
||||||
|
|
||||||
let en_svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
|
||||||
<rect x="2" y="2" width="28" height="28" rx="6" fill="#4b5563"/>
|
|
||||||
<text x="16" y="21" text-anchor="middle" fill="#ffffff" font-size="13" font-weight="900" font-family="system-ui, -apple-system, sans-serif" letter-spacing="0.5">EN</text>
|
|
||||||
</svg>"##;
|
|
||||||
|
|
||||||
let _ = std::fs::write(&vn_path, vn_svg);
|
|
||||||
let _ = std::fs::write(&en_path, en_svg);
|
|
||||||
|
|
||||||
let hicolor_apps_dir = icons_dir.join("hicolor").join("scalable").join("apps");
|
|
||||||
let _ = std::fs::create_dir_all(&hicolor_apps_dir);
|
|
||||||
let _ = std::fs::write(hicolor_apps_dir.join("vietc-vn.svg"), vn_svg);
|
|
||||||
let _ = std::fs::write(hicolor_apps_dir.join("vietc-en.svg"), en_svg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
eprintln!("[vietc-tray] Starting tray (exe dir: {:?})", exe_dir());
|
|
||||||
|
|
||||||
ensure_icons_exist();
|
|
||||||
|
|
||||||
if !is_daemon_running() {
|
|
||||||
let daemon_bin = find_sibling_binary("vietc");
|
|
||||||
eprintln!("[vietc-tray] Starting daemon: {}", daemon_bin);
|
|
||||||
match std::process::Command::new(&daemon_bin).spawn() {
|
|
||||||
Ok(child) => eprintln!("[vietc-tray] Daemon started (PID {})", child.id()),
|
|
||||||
Err(e) => eprintln!("[vietc-tray] Failed to start daemon: {}", e),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eprintln!("[vietc-tray] Daemon already running");
|
|
||||||
}
|
|
||||||
|
|
||||||
let tray = VietcTray {
|
|
||||||
active_mode: "en".into(),
|
|
||||||
autostart_enabled: config::is_autostart_installed(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let service = ksni::TrayService::new(tray);
|
let service = ksni::TrayService::new(tray);
|
||||||
let handle = service.handle();
|
let handle = service.handle();
|
||||||
service.spawn();
|
service.spawn();
|
||||||
|
|
||||||
let handle_clone = handle.clone();
|
// Poll for changes
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let status_path = dirs::config_dir()
|
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
|
|
||||||
.join("vietc")
|
|
||||||
.join("status");
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let active_mode = if let Ok(content) = std::fs::read_to_string(&status_path) {
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
content.trim().to_string()
|
let mode = read_status();
|
||||||
} else {
|
let im = current_im();
|
||||||
let config = Config::load();
|
let autostart = config::is_autostart_installed();
|
||||||
if config.start_enabled { "vn".to_string() } else { "en".to_string() }
|
let _ = handle.update(move |t| {
|
||||||
};
|
t.mode = mode;
|
||||||
|
t.im = im;
|
||||||
let autostart_enabled = config::is_autostart_installed();
|
t.autostart = autostart;
|
||||||
|
|
||||||
let _ = handle_clone.update(move |t| {
|
|
||||||
t.active_mode = active_mode;
|
|
||||||
t.autostart_enabled = autostart_enabled;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if config::is_autostart_installed() {
|
loop { std::thread::park(); }
|
||||||
config::install_autostart_force();
|
|
||||||
}
|
|
||||||
|
|
||||||
loop {
|
|
||||||
std::thread::park();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
710
ui/src/window.rs
710
ui/src/window.rs
|
|
@ -1,710 +0,0 @@
|
||||||
use adw::prelude::*;
|
|
||||||
use adw::subclass::prelude::*;
|
|
||||||
use gtk::{gio, glib};
|
|
||||||
|
|
||||||
use crate::config::Config;
|
|
||||||
|
|
||||||
mod imp {
|
|
||||||
use super::*;
|
|
||||||
use std::cell::RefCell;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct SettingsWindow {
|
|
||||||
pub dirty: RefCell<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[glib::object_subclass]
|
|
||||||
impl ObjectSubclass for SettingsWindow {
|
|
||||||
const NAME: &'static str = "SettingsWindow";
|
|
||||||
type Type = super::SettingsWindow;
|
|
||||||
type ParentType = adw::ApplicationWindow;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ObjectImpl for SettingsWindow {}
|
|
||||||
impl WidgetImpl for SettingsWindow {}
|
|
||||||
impl WindowImpl for SettingsWindow {}
|
|
||||||
impl ApplicationWindowImpl for SettingsWindow {}
|
|
||||||
impl AdwApplicationWindowImpl for SettingsWindow {}
|
|
||||||
}
|
|
||||||
|
|
||||||
glib::wrapper! {
|
|
||||||
pub struct SettingsWindow(ObjectSubclass<imp::SettingsWindow>)
|
|
||||||
@extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget,
|
|
||||||
@implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SettingsWindow {
|
|
||||||
pub fn new(app: &adw::Application) -> Self {
|
|
||||||
let win: Self = glib::Object::builder()
|
|
||||||
.property("application", app)
|
|
||||||
.property("default-width", 580)
|
|
||||||
.property("default-height", 500)
|
|
||||||
.property("title", "Viet+ Settings")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
win.build_ui();
|
|
||||||
win
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mark_dirty(&self) {
|
|
||||||
*self.imp().dirty.borrow_mut() = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_ui(&self) {
|
|
||||||
let config = Config::load();
|
|
||||||
let trigger_keys = config.auto_restore.trigger_keys.clone();
|
|
||||||
|
|
||||||
// Toast overlay for notifications
|
|
||||||
let toast_overlay = adw::ToastOverlay::new();
|
|
||||||
|
|
||||||
// Main box
|
|
||||||
let main_box = gtk::Box::builder()
|
|
||||||
.orientation(gtk::Orientation::Vertical)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// Header bar with view switcher
|
|
||||||
let header = adw::HeaderBar::new();
|
|
||||||
|
|
||||||
// View Stack
|
|
||||||
let stack = adw::ViewStack::builder()
|
|
||||||
.vexpand(true)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// View Switcher linked to stack
|
|
||||||
let switcher = adw::ViewSwitcher::builder()
|
|
||||||
.stack(&stack)
|
|
||||||
.build();
|
|
||||||
header.set_title_widget(Some(&switcher));
|
|
||||||
|
|
||||||
// Save button (suggested action)
|
|
||||||
let save_btn = gtk::Button::builder()
|
|
||||||
.label("Save")
|
|
||||||
.css_classes(["suggested-action"])
|
|
||||||
.tooltip_text("Save settings (Ctrl+S)")
|
|
||||||
.build();
|
|
||||||
header.pack_end(&save_btn);
|
|
||||||
|
|
||||||
// Keyboard shortcut for save
|
|
||||||
let controller = gtk::EventControllerKey::new();
|
|
||||||
let save_ref = save_btn.clone();
|
|
||||||
controller.connect_key_pressed(move |_, key, _, modifiers| {
|
|
||||||
if modifiers.contains(gtk::gdk::ModifierType::CONTROL_MASK)
|
|
||||||
&& key == gtk::gdk::Key::s
|
|
||||||
{
|
|
||||||
save_ref.emit_clicked();
|
|
||||||
glib::Propagation::Stop
|
|
||||||
} else {
|
|
||||||
glib::Propagation::Proceed
|
|
||||||
}
|
|
||||||
});
|
|
||||||
self.add_controller(controller);
|
|
||||||
|
|
||||||
main_box.append(&header);
|
|
||||||
|
|
||||||
// ==================== Page 1: Typing ====================
|
|
||||||
let typing_box = gtk::Box::builder()
|
|
||||||
.orientation(gtk::Orientation::Vertical)
|
|
||||||
.spacing(8)
|
|
||||||
.margin_top(16)
|
|
||||||
.margin_bottom(16)
|
|
||||||
.margin_start(16)
|
|
||||||
.margin_end(16)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// ========== Input Method Section ==========
|
|
||||||
let method_group = adw::PreferencesGroup::builder()
|
|
||||||
.title("Input Method")
|
|
||||||
.description("Select your preferred Vietnamese typing method")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let method_row = adw::ComboRow::builder()
|
|
||||||
.title("Keyboard Layout")
|
|
||||||
.subtitle("Telex uses letters (aa=ă, ee=ê), VNI uses digits (a6=ă, e8=ê)")
|
|
||||||
.model(>k::StringList::new(&["Telex (Recommended)", "VNI"]))
|
|
||||||
.selected(if config.input_method == "vni" { 1 } else { 0 })
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let toggle_row = adw::ComboRow::builder()
|
|
||||||
.title("Toggle Key")
|
|
||||||
.subtitle("Switch between Vietnamese and English input")
|
|
||||||
.model(>k::StringList::new(&[
|
|
||||||
"Ctrl + Space",
|
|
||||||
"Ctrl + Shift",
|
|
||||||
"Caps Lock",
|
|
||||||
]))
|
|
||||||
.selected(match config.toggle_key.as_str() {
|
|
||||||
"shift" => 1,
|
|
||||||
"capslock" => 2,
|
|
||||||
_ => 0,
|
|
||||||
})
|
|
||||||
.build();
|
|
||||||
|
|
||||||
method_group.add(&method_row);
|
|
||||||
method_group.add(&toggle_row);
|
|
||||||
typing_box.append(&method_group);
|
|
||||||
|
|
||||||
// ========== General Section ==========
|
|
||||||
let general_group = adw::PreferencesGroup::builder()
|
|
||||||
.title("General")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let start_enabled_row = adw::SwitchRow::builder()
|
|
||||||
.title("Start Enabled")
|
|
||||||
.subtitle("Enable Vietnamese input on startup")
|
|
||||||
.active(config.start_enabled)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let app_memory_row = adw::SwitchRow::builder()
|
|
||||||
.title("App Memory")
|
|
||||||
.subtitle("Remember per-app Vietnamese/English state")
|
|
||||||
.active(config.app_state.enabled)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let auto_restore_row = adw::SwitchRow::builder()
|
|
||||||
.title("Auto Restore English")
|
|
||||||
.subtitle("Automatically restore common English words")
|
|
||||||
.active(config.auto_restore.enabled)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let autostart_row = adw::SwitchRow::builder()
|
|
||||||
.title("Autostart on Boot")
|
|
||||||
.subtitle("Start Viet+ automatically when your system starts")
|
|
||||||
.active(crate::config::is_autostart_installed())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
general_group.add(&start_enabled_row);
|
|
||||||
general_group.add(&app_memory_row);
|
|
||||||
general_group.add(&auto_restore_row);
|
|
||||||
general_group.add(&autostart_row);
|
|
||||||
typing_box.append(&general_group);
|
|
||||||
|
|
||||||
let typing_clamp = adw::Clamp::builder().maximum_size(540).tightening_threshold(400).build();
|
|
||||||
typing_clamp.set_child(Some(&typing_box));
|
|
||||||
let typing_scrolled = gtk::ScrolledWindow::builder()
|
|
||||||
.vexpand(true)
|
|
||||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
||||||
.child(&typing_clamp)
|
|
||||||
.build();
|
|
||||||
stack.add_titled(&typing_scrolled, Some("typing"), "Typing");
|
|
||||||
|
|
||||||
// ==================== Page 2: Apps ====================
|
|
||||||
let apps_box = gtk::Box::builder()
|
|
||||||
.orientation(gtk::Orientation::Vertical)
|
|
||||||
.spacing(8)
|
|
||||||
.margin_top(16)
|
|
||||||
.margin_bottom(16)
|
|
||||||
.margin_start(16)
|
|
||||||
.margin_end(16)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let apps_group = adw::PreferencesGroup::builder()
|
|
||||||
.title("Application Lists")
|
|
||||||
.description("Override input method for specific applications")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// English apps
|
|
||||||
let english_list = gtk::ListBox::builder()
|
|
||||||
.selection_mode(gtk::SelectionMode::None)
|
|
||||||
.css_classes(["boxed-list"])
|
|
||||||
.build();
|
|
||||||
|
|
||||||
for app in &config.app_state.english_apps {
|
|
||||||
english_list.append(&Self::make_app_row_static(app, &english_list));
|
|
||||||
}
|
|
||||||
|
|
||||||
let english_entry = gtk::SearchEntry::builder()
|
|
||||||
.placeholder_text("Add application name...")
|
|
||||||
.hexpand(true)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let english_add = gtk::Button::builder()
|
|
||||||
.icon_name("list-add-symbolic")
|
|
||||||
.css_classes(["flat", "accent"])
|
|
||||||
.tooltip_text("Add application")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let english_input = gtk::Box::builder()
|
|
||||||
.orientation(gtk::Orientation::Horizontal)
|
|
||||||
.spacing(4)
|
|
||||||
.build();
|
|
||||||
english_input.append(&english_entry);
|
|
||||||
english_input.append(&english_add);
|
|
||||||
|
|
||||||
let english_header = gtk::Box::builder()
|
|
||||||
.orientation(gtk::Orientation::Vertical)
|
|
||||||
.spacing(8)
|
|
||||||
.build();
|
|
||||||
let english_label = gtk::Label::builder()
|
|
||||||
.label("English Mode (Telex disabled)")
|
|
||||||
.halign(gtk::Align::Start)
|
|
||||||
.css_classes(["heading", "dim-label"])
|
|
||||||
.build();
|
|
||||||
english_header.append(&english_label);
|
|
||||||
english_header.append(&english_list);
|
|
||||||
english_header.append(&english_input);
|
|
||||||
|
|
||||||
let english_row = adw::ActionRow::builder()
|
|
||||||
.title("English Applications")
|
|
||||||
.activatable(false)
|
|
||||||
.build();
|
|
||||||
english_row.add_suffix(&english_header);
|
|
||||||
apps_group.add(&english_row);
|
|
||||||
|
|
||||||
// Vietnamese apps
|
|
||||||
let viet_list = gtk::ListBox::builder()
|
|
||||||
.selection_mode(gtk::SelectionMode::None)
|
|
||||||
.css_classes(["boxed-list"])
|
|
||||||
.build();
|
|
||||||
|
|
||||||
for app in &config.app_state.vietnamese_apps {
|
|
||||||
viet_list.append(&Self::make_app_row_static(app, &viet_list));
|
|
||||||
}
|
|
||||||
|
|
||||||
let viet_entry = gtk::SearchEntry::builder()
|
|
||||||
.placeholder_text("Add application name...")
|
|
||||||
.hexpand(true)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let viet_add = gtk::Button::builder()
|
|
||||||
.icon_name("list-add-symbolic")
|
|
||||||
.css_classes(["flat", "accent"])
|
|
||||||
.tooltip_text("Add application")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let viet_input = gtk::Box::builder()
|
|
||||||
.orientation(gtk::Orientation::Horizontal)
|
|
||||||
.spacing(4)
|
|
||||||
.build();
|
|
||||||
viet_input.append(&viet_entry);
|
|
||||||
viet_input.append(&viet_add);
|
|
||||||
|
|
||||||
let viet_header = gtk::Box::builder()
|
|
||||||
.orientation(gtk::Orientation::Vertical)
|
|
||||||
.spacing(8)
|
|
||||||
.build();
|
|
||||||
let viet_label = gtk::Label::builder()
|
|
||||||
.label("Vietnamese Mode (Telex enabled)")
|
|
||||||
.halign(gtk::Align::Start)
|
|
||||||
.css_classes(["heading", "dim-label"])
|
|
||||||
.build();
|
|
||||||
viet_header.append(&viet_label);
|
|
||||||
viet_header.append(&viet_list);
|
|
||||||
viet_header.append(&viet_input);
|
|
||||||
|
|
||||||
let viet_row = adw::ActionRow::builder()
|
|
||||||
.title("Vietnamese Applications")
|
|
||||||
.activatable(false)
|
|
||||||
.build();
|
|
||||||
viet_row.add_suffix(&viet_header);
|
|
||||||
apps_group.add(&viet_row);
|
|
||||||
|
|
||||||
apps_box.append(&apps_group);
|
|
||||||
|
|
||||||
let apps_clamp = adw::Clamp::builder().maximum_size(540).tightening_threshold(400).build();
|
|
||||||
apps_clamp.set_child(Some(&apps_box));
|
|
||||||
let apps_scrolled = gtk::ScrolledWindow::builder()
|
|
||||||
.vexpand(true)
|
|
||||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
||||||
.child(&apps_clamp)
|
|
||||||
.build();
|
|
||||||
stack.add_titled(&apps_scrolled, Some("apps"), "Apps");
|
|
||||||
|
|
||||||
// ==================== Page 3: Shortcuts ====================
|
|
||||||
let shortcuts_box = gtk::Box::builder()
|
|
||||||
.orientation(gtk::Orientation::Vertical)
|
|
||||||
.spacing(8)
|
|
||||||
.margin_top(16)
|
|
||||||
.margin_bottom(16)
|
|
||||||
.margin_start(16)
|
|
||||||
.margin_end(16)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// ========== Macros Section ==========
|
|
||||||
let macros_group = adw::PreferencesGroup::builder()
|
|
||||||
.title("Macros")
|
|
||||||
.description("Type shortcuts that expand to Vietnamese phrases")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let macros_list = gtk::ListBox::builder()
|
|
||||||
.selection_mode(gtk::SelectionMode::None)
|
|
||||||
.css_classes(["boxed-list"])
|
|
||||||
.build();
|
|
||||||
|
|
||||||
for (shortcut, expansion) in &config.macros {
|
|
||||||
macros_list.append(&Self::make_macro_row_static(shortcut, expansion, ¯os_list));
|
|
||||||
}
|
|
||||||
|
|
||||||
let macro_shortcut = gtk::SearchEntry::builder()
|
|
||||||
.placeholder_text("ko")
|
|
||||||
.width_chars(8)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let macro_expansion = gtk::SearchEntry::builder()
|
|
||||||
.placeholder_text("không")
|
|
||||||
.hexpand(true)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let macro_add = gtk::Button::builder()
|
|
||||||
.icon_name("list-add-symbolic")
|
|
||||||
.css_classes(["flat", "accent"])
|
|
||||||
.tooltip_text("Add macro")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let macro_input = gtk::Box::builder()
|
|
||||||
.orientation(gtk::Orientation::Horizontal)
|
|
||||||
.spacing(4)
|
|
||||||
.build();
|
|
||||||
macro_input.append(¯o_shortcut);
|
|
||||||
macro_input.append(>k::Label::builder().label("→").css_classes(["dim-label"]).build());
|
|
||||||
macro_input.append(¯o_expansion);
|
|
||||||
macro_input.append(¯o_add);
|
|
||||||
|
|
||||||
macros_group.add(¯os_list);
|
|
||||||
macros_group.add(¯o_input);
|
|
||||||
shortcuts_box.append(¯os_group);
|
|
||||||
|
|
||||||
// ========== Reference Card ==========
|
|
||||||
let ref_group = adw::PreferencesGroup::builder()
|
|
||||||
.title("Quick Reference")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let ref_row = adw::ActionRow::builder()
|
|
||||||
.title("Common Shortcuts")
|
|
||||||
.subtitle("ko→không, dc→được, vs→với, lm→làm")
|
|
||||||
.activatable(false)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let ref_icon = gtk::Image::builder()
|
|
||||||
.icon_name("dialog-information-symbolic")
|
|
||||||
.tooltip_text("Type these shortcuts followed by space")
|
|
||||||
.build();
|
|
||||||
ref_row.add_suffix(&ref_icon);
|
|
||||||
|
|
||||||
ref_group.add(&ref_row);
|
|
||||||
shortcuts_box.append(&ref_group);
|
|
||||||
|
|
||||||
let shortcuts_clamp = adw::Clamp::builder().maximum_size(540).tightening_threshold(400).build();
|
|
||||||
shortcuts_clamp.set_child(Some(&shortcuts_box));
|
|
||||||
let shortcuts_scrolled = gtk::ScrolledWindow::builder()
|
|
||||||
.vexpand(true)
|
|
||||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
||||||
.child(&shortcuts_clamp)
|
|
||||||
.build();
|
|
||||||
stack.add_titled(&shortcuts_scrolled, Some("shortcuts"), "Shortcuts");
|
|
||||||
|
|
||||||
// ========== Status Bar ==========
|
|
||||||
let status_box = gtk::Box::builder()
|
|
||||||
.orientation(gtk::Orientation::Horizontal)
|
|
||||||
.spacing(8)
|
|
||||||
.margin_top(8)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let status_icon = gtk::Image::builder()
|
|
||||||
.icon_name("emblem-ok-symbolic")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let status_label = gtk::Label::builder()
|
|
||||||
.label("Ready")
|
|
||||||
.hexpand(true)
|
|
||||||
.halign(gtk::Align::Start)
|
|
||||||
.css_classes(["dim-label"])
|
|
||||||
.build();
|
|
||||||
|
|
||||||
status_box.append(&status_icon);
|
|
||||||
status_box.append(&status_label);
|
|
||||||
|
|
||||||
main_box.append(&stack);
|
|
||||||
main_box.append(&status_box);
|
|
||||||
|
|
||||||
toast_overlay.set_child(Some(&main_box));
|
|
||||||
adw::prelude::AdwApplicationWindowExt::set_content(self, Some(&toast_overlay));
|
|
||||||
|
|
||||||
// ========== Callbacks ==========
|
|
||||||
|
|
||||||
// Mark dirty on any change
|
|
||||||
{
|
|
||||||
let win = self.clone();
|
|
||||||
method_row.connect_selected_notify(move |_| { win.mark_dirty(); });
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let win = self.clone();
|
|
||||||
toggle_row.connect_selected_notify(move |_| { win.mark_dirty(); });
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let win = self.clone();
|
|
||||||
start_enabled_row.connect_active_notify(move |_| { win.mark_dirty(); });
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let win = self.clone();
|
|
||||||
app_memory_row.connect_active_notify(move |_| { win.mark_dirty(); });
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let win = self.clone();
|
|
||||||
auto_restore_row.connect_active_notify(move |_| { win.mark_dirty(); });
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let win = self.clone();
|
|
||||||
autostart_row.connect_active_notify(move |_| { win.mark_dirty(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add English app
|
|
||||||
self.setup_add_app(&english_entry, &english_add, &english_list, &status_label, &status_icon);
|
|
||||||
|
|
||||||
// Add Vietnamese app
|
|
||||||
self.setup_add_app(&viet_entry, &viet_add, &viet_list, &status_label, &status_icon);
|
|
||||||
|
|
||||||
// Add macro
|
|
||||||
self.setup_add_macro(¯o_shortcut, ¯o_expansion, ¯o_add, ¯os_list, &status_label, &status_icon);
|
|
||||||
|
|
||||||
// Save button
|
|
||||||
{
|
|
||||||
let method_row = method_row.clone();
|
|
||||||
let toggle_row = toggle_row.clone();
|
|
||||||
let start_switch = start_enabled_row.clone();
|
|
||||||
let app_switch = app_memory_row.clone();
|
|
||||||
let auto_switch = auto_restore_row.clone();
|
|
||||||
let autostart_switch = autostart_row.clone();
|
|
||||||
let english = english_list.clone();
|
|
||||||
let viet = viet_list.clone();
|
|
||||||
let macros = macros_list.clone();
|
|
||||||
let status_label = status_label.clone();
|
|
||||||
let status_icon = status_icon.clone();
|
|
||||||
let toast_overlay = toast_overlay.clone();
|
|
||||||
let win = self.clone();
|
|
||||||
let trigger_keys = trigger_keys.clone();
|
|
||||||
|
|
||||||
save_btn.connect_clicked(move |_| {
|
|
||||||
let method = match method_row.selected() {
|
|
||||||
1 => "vni",
|
|
||||||
_ => "telex",
|
|
||||||
};
|
|
||||||
let toggle = match toggle_row.selected() {
|
|
||||||
1 => "shift",
|
|
||||||
2 => "capslock",
|
|
||||||
_ => "space",
|
|
||||||
};
|
|
||||||
|
|
||||||
let english_apps = Self::collect_app_names(&english);
|
|
||||||
let vietnamese_apps = Self::collect_app_names(&viet);
|
|
||||||
let macro_map = Self::collect_macros(¯os);
|
|
||||||
|
|
||||||
let config = Config {
|
|
||||||
input_method: method.into(),
|
|
||||||
toggle_key: toggle.into(),
|
|
||||||
start_enabled: start_switch.is_active(),
|
|
||||||
auto_restore: crate::config::AutoRestoreConfig {
|
|
||||||
enabled: auto_switch.is_active(),
|
|
||||||
trigger_keys: trigger_keys.clone(),
|
|
||||||
},
|
|
||||||
app_state: crate::config::AppStateConfig {
|
|
||||||
enabled: app_switch.is_active(),
|
|
||||||
english_apps,
|
|
||||||
vietnamese_apps,
|
|
||||||
},
|
|
||||||
macros: macro_map,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save autostart state
|
|
||||||
if autostart_switch.is_active() {
|
|
||||||
crate::config::install_autostart_force();
|
|
||||||
} else {
|
|
||||||
crate::config::uninstall_autostart();
|
|
||||||
}
|
|
||||||
|
|
||||||
match config.save() {
|
|
||||||
Ok(()) => {
|
|
||||||
status_label.set_text(&format!("Saved to {}", Config::path().display()));
|
|
||||||
status_icon.set_icon_name(Some("emblem-ok-symbolic"));
|
|
||||||
status_label.remove_css_class("error");
|
|
||||||
status_label.add_css_class("dim-label");
|
|
||||||
|
|
||||||
*win.imp().dirty.borrow_mut() = false;
|
|
||||||
|
|
||||||
let toast = adw::Toast::new("Settings saved");
|
|
||||||
toast.set_timeout(2);
|
|
||||||
toast_overlay.add_toast(toast);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
status_label.set_text(&format!("Error: {}", e));
|
|
||||||
status_icon.set_icon_name(Some("dialog-error-symbolic"));
|
|
||||||
status_label.remove_css_class("dim-label");
|
|
||||||
status_label.add_css_class("error");
|
|
||||||
|
|
||||||
let toast = adw::Toast::new(&format!("Save failed: {}", e));
|
|
||||||
toast.set_timeout(3);
|
|
||||||
toast_overlay.add_toast(toast);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_add_app(
|
|
||||||
&self,
|
|
||||||
entry: >k::SearchEntry,
|
|
||||||
add_btn: >k::Button,
|
|
||||||
list: >k::ListBox,
|
|
||||||
status_label: >k::Label,
|
|
||||||
status_icon: >k::Image,
|
|
||||||
) {
|
|
||||||
let add_fn = {
|
|
||||||
let list = list.clone();
|
|
||||||
let entry = entry.clone();
|
|
||||||
let status_label = status_label.clone();
|
|
||||||
let status_icon = status_icon.clone();
|
|
||||||
let win = self.clone();
|
|
||||||
move || {
|
|
||||||
let text = entry.text().to_string();
|
|
||||||
if !text.is_empty() {
|
|
||||||
let row = Self::make_app_row_static(&text, &list);
|
|
||||||
list.append(&row);
|
|
||||||
entry.set_text("");
|
|
||||||
status_label.set_text("Unsaved changes");
|
|
||||||
status_icon.set_icon_name(Some("dialog-information-symbolic"));
|
|
||||||
win.mark_dirty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let add_fn2 = add_fn.clone();
|
|
||||||
add_btn.connect_clicked(move |_| add_fn2());
|
|
||||||
|
|
||||||
let add_fn3 = add_fn.clone();
|
|
||||||
entry.connect_activate(move |_| add_fn3());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_add_macro(
|
|
||||||
&self,
|
|
||||||
shortcut: >k::SearchEntry,
|
|
||||||
expansion: >k::SearchEntry,
|
|
||||||
add_btn: >k::Button,
|
|
||||||
list: >k::ListBox,
|
|
||||||
status_label: >k::Label,
|
|
||||||
status_icon: >k::Image,
|
|
||||||
) {
|
|
||||||
let add_fn = {
|
|
||||||
let list = list.clone();
|
|
||||||
let shortcut = shortcut.clone();
|
|
||||||
let expansion = expansion.clone();
|
|
||||||
let status_label = status_label.clone();
|
|
||||||
let status_icon = status_icon.clone();
|
|
||||||
let win = self.clone();
|
|
||||||
move || {
|
|
||||||
let s = shortcut.text().to_string();
|
|
||||||
let e = expansion.text().to_string();
|
|
||||||
if !s.is_empty() && !e.is_empty() {
|
|
||||||
let row = Self::make_macro_row_static(&s, &e, &list);
|
|
||||||
list.append(&row);
|
|
||||||
shortcut.set_text("");
|
|
||||||
expansion.set_text("");
|
|
||||||
status_label.set_text("Unsaved changes");
|
|
||||||
status_icon.set_icon_name(Some("dialog-information-symbolic"));
|
|
||||||
win.mark_dirty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let add_fn2 = add_fn.clone();
|
|
||||||
add_btn.connect_clicked(move |_| add_fn2());
|
|
||||||
|
|
||||||
let add_fn3 = add_fn.clone();
|
|
||||||
expansion.connect_activate(move |_| add_fn3());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_app_row_static(app: &str, list: >k::ListBox) -> adw::ActionRow {
|
|
||||||
let row = adw::ActionRow::builder()
|
|
||||||
.title(app)
|
|
||||||
.activatable(false)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let remove_btn = gtk::Button::builder()
|
|
||||||
.icon_name("user-trash-symbolic")
|
|
||||||
.css_classes(["flat", "destructive-action"])
|
|
||||||
.tooltip_text("Remove")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let list_ref = list.clone();
|
|
||||||
let app_name = app.to_string();
|
|
||||||
remove_btn.connect_clicked(move |_| {
|
|
||||||
let mut i = 0;
|
|
||||||
while let Some(child) = list_ref.row_at_index(i) {
|
|
||||||
if let Some(row) = child.downcast_ref::<adw::ActionRow>() {
|
|
||||||
if row.title() == app_name {
|
|
||||||
list_ref.remove(&child);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
row.add_suffix(&remove_btn);
|
|
||||||
row
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_macro_row_static(shortcut: &str, expansion: &str, list: >k::ListBox) -> adw::ActionRow {
|
|
||||||
let row = adw::ActionRow::builder()
|
|
||||||
.title(shortcut)
|
|
||||||
.subtitle(expansion)
|
|
||||||
.activatable(false)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let arrow = gtk::Label::builder()
|
|
||||||
.label("→")
|
|
||||||
.css_classes(["dim-label"])
|
|
||||||
.build();
|
|
||||||
row.add_prefix(&arrow);
|
|
||||||
|
|
||||||
let remove_btn = gtk::Button::builder()
|
|
||||||
.icon_name("user-trash-symbolic")
|
|
||||||
.css_classes(["flat", "destructive-action"])
|
|
||||||
.tooltip_text("Remove")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let list_ref = list.clone();
|
|
||||||
let shortcut_name = shortcut.to_string();
|
|
||||||
remove_btn.connect_clicked(move |_| {
|
|
||||||
let mut i = 0;
|
|
||||||
while let Some(child) = list_ref.row_at_index(i) {
|
|
||||||
if let Some(row) = child.downcast_ref::<adw::ActionRow>() {
|
|
||||||
if row.title() == shortcut_name {
|
|
||||||
list_ref.remove(&child);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
row.add_suffix(&remove_btn);
|
|
||||||
row
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_app_names(list: >k::ListBox) -> Vec<String> {
|
|
||||||
let mut names = Vec::new();
|
|
||||||
let mut i = 0;
|
|
||||||
while let Some(child) = list.row_at_index(i) {
|
|
||||||
if let Some(row) = child.downcast_ref::<adw::ActionRow>() {
|
|
||||||
names.push(row.title().to_string());
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
names
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_macros(list: >k::ListBox) -> std::collections::HashMap<String, String> {
|
|
||||||
let mut map = std::collections::HashMap::new();
|
|
||||||
let mut i = 0;
|
|
||||||
while let Some(child) = list.row_at_index(i) {
|
|
||||||
if let Some(row) = child.downcast_ref::<adw::ActionRow>() {
|
|
||||||
let shortcut = row.title().to_string();
|
|
||||||
let expansion = row.subtitle().unwrap_or_default().to_string();
|
|
||||||
if !shortcut.is_empty() {
|
|
||||||
map.insert(shortcut, expansion);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
map
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue