Optimize typing performance and preserve casing on replaced syllables
This commit is contained in:
parent
da97e945eb
commit
38f3bca022
27 changed files with 10771 additions and 965 deletions
7
Makefile
7
Makefile
|
|
@ -1,4 +1,4 @@
|
||||||
.PHONY: build build-x11 build-wayland build-all build-ui test test-cli run run-x11 run-wayland clean install install-x11 install-wayland install-ui install-config appimage fmt lint tree
|
.PHONY: build build-x11 build-wayland build-all build-ui test test-cli run run-x11 run-wayland clean install install-x11 install-wayland install-ui install-config appimage deb fmt lint tree
|
||||||
|
|
||||||
# Build core crates
|
# Build core crates
|
||||||
build:
|
build:
|
||||||
|
|
@ -86,6 +86,11 @@ appimage:
|
||||||
VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \
|
VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \
|
||||||
bash packaging/appimage/build-appimage.sh "$$VERSION"
|
bash packaging/appimage/build-appimage.sh "$$VERSION"
|
||||||
|
|
||||||
|
# Build Debian package
|
||||||
|
deb:
|
||||||
|
VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \
|
||||||
|
bash packaging/build-deb.sh "$$VERSION"
|
||||||
|
|
||||||
# Clean build artifacts
|
# Clean build artifacts
|
||||||
clean:
|
clean:
|
||||||
cargo clean
|
cargo clean
|
||||||
|
|
|
||||||
|
|
@ -72,12 +72,18 @@ fn main() {
|
||||||
}
|
}
|
||||||
output.push_str(insert);
|
output.push_str(insert);
|
||||||
}
|
}
|
||||||
EngineEvent::UndoTones { backspaces, restored } => {
|
EngineEvent::UndoTones {
|
||||||
|
backspaces,
|
||||||
|
restored,
|
||||||
|
} => {
|
||||||
for _ in 0..*backspaces {
|
for _ in 0..*backspaces {
|
||||||
output.push('\x08');
|
output.push('\x08');
|
||||||
}
|
}
|
||||||
output.push_str(restored);
|
output.push_str(restored);
|
||||||
}
|
}
|
||||||
|
EngineEvent::Paste(text) => {
|
||||||
|
output.push_str(text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,12 @@ fn get_proc_window_class() -> Option<String> {
|
||||||
// Read /proc/active-windows if available (some compositors expose this)
|
// Read /proc/active-windows if available (some compositors expose this)
|
||||||
let content = fs::read_to_string("/proc/active-windows").ok()?;
|
let content = fs::read_to_string("/proc/active-windows").ok()?;
|
||||||
// Format: pid window_class window_title
|
// Format: pid window_class window_title
|
||||||
content.lines().next()?.split_whitespace().nth(1).map(|s| s.to_lowercase())
|
content
|
||||||
|
.lines()
|
||||||
|
.next()?
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.map(|s| s.to_lowercase())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manages per-app IME state
|
/// Manages per-app IME state
|
||||||
|
|
@ -76,6 +81,8 @@ pub struct AppStateManager {
|
||||||
english_apps: Vec<String>,
|
english_apps: Vec<String>,
|
||||||
/// Default Vietnamese apps from config
|
/// Default Vietnamese apps from config
|
||||||
vietnamese_apps: Vec<String>,
|
vietnamese_apps: Vec<String>,
|
||||||
|
/// Bypass apps from config
|
||||||
|
bypass_apps: Vec<String>,
|
||||||
/// Global enabled state
|
/// Global enabled state
|
||||||
global_enabled: bool,
|
global_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +91,7 @@ impl AppStateManager {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
english_apps: Vec<String>,
|
english_apps: Vec<String>,
|
||||||
vietnamese_apps: Vec<String>,
|
vietnamese_apps: Vec<String>,
|
||||||
|
bypass_apps: Vec<String>,
|
||||||
global_enabled: bool,
|
global_enabled: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -91,6 +99,7 @@ impl AppStateManager {
|
||||||
overrides: HashMap::new(),
|
overrides: HashMap::new(),
|
||||||
english_apps: english_apps.iter().map(|s| s.to_lowercase()).collect(),
|
english_apps: english_apps.iter().map(|s| s.to_lowercase()).collect(),
|
||||||
vietnamese_apps: vietnamese_apps.iter().map(|s| s.to_lowercase()).collect(),
|
vietnamese_apps: vietnamese_apps.iter().map(|s| s.to_lowercase()).collect(),
|
||||||
|
bypass_apps: bypass_apps.iter().map(|s| s.to_lowercase()).collect(),
|
||||||
global_enabled,
|
global_enabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -162,14 +171,32 @@ impl AppStateManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update app lists from reloaded config
|
/// Update app lists from reloaded config
|
||||||
pub fn update_lists(&mut self, english_apps: Vec<String>, vietnamese_apps: Vec<String>) {
|
pub fn update_lists(
|
||||||
|
&mut self,
|
||||||
|
english_apps: Vec<String>,
|
||||||
|
vietnamese_apps: Vec<String>,
|
||||||
|
bypass_apps: Vec<String>,
|
||||||
|
) -> &Self {
|
||||||
self.english_apps = english_apps.iter().map(|s| s.to_lowercase()).collect();
|
self.english_apps = english_apps.iter().map(|s| s.to_lowercase()).collect();
|
||||||
self.vietnamese_apps = vietnamese_apps.iter().map(|s| s.to_lowercase()).collect();
|
self.vietnamese_apps = vietnamese_apps.iter().map(|s| s.to_lowercase()).collect();
|
||||||
|
self.bypass_apps = bypass_apps.iter().map(|s| s.to_lowercase()).collect();
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[vietc] App lists updated: {} English, {} Vietnamese",
|
"[vietc] App lists updated: {} English, {} Vietnamese, {} Bypass",
|
||||||
self.english_apps.len(),
|
self.english_apps.len(),
|
||||||
self.vietnamese_apps.len()
|
self.vietnamese_apps.len(),
|
||||||
|
self.bypass_apps.len()
|
||||||
);
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the currently active application should bypass the IME completely
|
||||||
|
pub fn is_current_app_bypassed(&self) -> bool {
|
||||||
|
for pattern in &self.bypass_apps {
|
||||||
|
if self.current_app.contains(pattern.as_str()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save overrides to config file
|
/// Save overrides to config file
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ pub struct AppStateConfig {
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub vietnamese_apps: Vec<String>,
|
pub vietnamese_apps: Vec<String>,
|
||||||
|
|
||||||
|
#[serde(default = "default_bypass_apps")]
|
||||||
|
pub bypass_apps: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AutoRestoreConfig {
|
impl Default for AutoRestoreConfig {
|
||||||
|
|
@ -70,16 +73,29 @@ impl Default for AppStateConfig {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
english_apps: default_english_apps(),
|
english_apps: default_english_apps(),
|
||||||
vietnamese_apps: default_vietnamese_apps(),
|
vietnamese_apps: default_vietnamese_apps(),
|
||||||
|
bypass_apps: default_bypass_apps(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_input_method() -> String { "telex".into() }
|
fn default_input_method() -> String {
|
||||||
fn default_toggle_key() -> String { "space".into() }
|
"telex".into()
|
||||||
fn default_start_enabled() -> bool { true }
|
}
|
||||||
fn default_true() -> bool { true }
|
fn default_toggle_key() -> String {
|
||||||
fn default_false() -> bool { false }
|
"space".into()
|
||||||
fn default_restore_keys() -> Vec<String> { vec!["space".into(), "escape".into()] }
|
}
|
||||||
|
fn default_start_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn default_false() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn default_restore_keys() -> Vec<String> {
|
||||||
|
vec!["space".into(), "escape".into()]
|
||||||
|
}
|
||||||
|
|
||||||
fn default_english_apps() -> Vec<String> {
|
fn default_english_apps() -> Vec<String> {
|
||||||
vec![
|
vec![
|
||||||
|
|
@ -90,10 +106,26 @@ fn default_english_apps() -> Vec<String> {
|
||||||
"webstorm".into(),
|
"webstorm".into(),
|
||||||
"vim".into(),
|
"vim".into(),
|
||||||
"nvim".into(),
|
"nvim".into(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_bypass_apps() -> Vec<String> {
|
||||||
|
vec![
|
||||||
"terminal".into(),
|
"terminal".into(),
|
||||||
"kitty".into(),
|
"kitty".into(),
|
||||||
"alacritty".into(),
|
"alacritty".into(),
|
||||||
"foot".into(),
|
"foot".into(),
|
||||||
|
"wezterm".into(),
|
||||||
|
"konsole".into(),
|
||||||
|
"gnome-terminal".into(),
|
||||||
|
"st".into(),
|
||||||
|
"urxvt".into(),
|
||||||
|
"xterm".into(),
|
||||||
|
"steam".into(),
|
||||||
|
"dota".into(),
|
||||||
|
"csgo".into(),
|
||||||
|
"minecraft".into(),
|
||||||
|
"factorio".into(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -233,7 +265,10 @@ vs = "với"
|
||||||
assert!(!config.auto_restore.enabled);
|
assert!(!config.auto_restore.enabled);
|
||||||
assert!(config.app_state.enabled);
|
assert!(config.app_state.enabled);
|
||||||
assert_eq!(config.app_state.english_apps, vec!["code", "vim"]);
|
assert_eq!(config.app_state.english_apps, vec!["code", "vim"]);
|
||||||
assert_eq!(config.app_state.vietnamese_apps, vec!["telegram", "discord"]);
|
assert_eq!(
|
||||||
|
config.app_state.vietnamese_apps,
|
||||||
|
vec!["telegram", "discord"]
|
||||||
|
);
|
||||||
assert_eq!(config.macros.get("ko").unwrap(), "không");
|
assert_eq!(config.macros.get("ko").unwrap(), "không");
|
||||||
assert_eq!(config.macros.get("dc").unwrap(), "được");
|
assert_eq!(config.macros.get("dc").unwrap(), "được");
|
||||||
assert_eq!(config.macros.get("vs").unwrap(), "với");
|
assert_eq!(config.macros.get("vs").unwrap(), "với");
|
||||||
|
|
@ -289,12 +324,14 @@ foo = "bar"
|
||||||
fn parse_app_lists() {
|
fn parse_app_lists() {
|
||||||
let toml = r#"
|
let toml = r#"
|
||||||
[app_state]
|
[app_state]
|
||||||
english_apps = ["vim", "neovim", "kitty"]
|
english_apps = ["vim", "neovim"]
|
||||||
vietnamese_apps = ["zalo", "messenger"]
|
vietnamese_apps = ["zalo", "messenger"]
|
||||||
|
bypass_apps = ["kitty"]
|
||||||
"#;
|
"#;
|
||||||
let config: Config = toml::from_str(toml).unwrap();
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
assert_eq!(config.app_state.english_apps, vec!["vim", "neovim", "kitty"]);
|
assert_eq!(config.app_state.english_apps, vec!["vim", "neovim"]);
|
||||||
assert_eq!(config.app_state.vietnamese_apps, vec!["zalo", "messenger"]);
|
assert_eq!(config.app_state.vietnamese_apps, vec!["zalo", "messenger"]);
|
||||||
|
assert_eq!(config.app_state.bypass_apps, vec!["kitty"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -311,14 +348,29 @@ vietnamese_apps = ["zalo", "messenger"]
|
||||||
let config = Config::default();
|
let config = Config::default();
|
||||||
assert!(config.app_state.english_apps.contains(&"code".to_string()));
|
assert!(config.app_state.english_apps.contains(&"code".to_string()));
|
||||||
assert!(config.app_state.english_apps.contains(&"vim".to_string()));
|
assert!(config.app_state.english_apps.contains(&"vim".to_string()));
|
||||||
assert!(config.app_state.english_apps.contains(&"kitty".to_string()));
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config_bypass_apps() {
|
||||||
|
let config = Config::default();
|
||||||
|
assert!(config.app_state.bypass_apps.contains(&"kitty".to_string()));
|
||||||
|
assert!(config
|
||||||
|
.app_state
|
||||||
|
.bypass_apps
|
||||||
|
.contains(&"alacritty".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_config_vietnamese_apps() {
|
fn default_config_vietnamese_apps() {
|
||||||
let config = Config::default();
|
let config = Config::default();
|
||||||
assert!(config.app_state.vietnamese_apps.contains(&"telegram".to_string()));
|
assert!(config
|
||||||
assert!(config.app_state.vietnamese_apps.contains(&"firefox".to_string()));
|
.app_state
|
||||||
|
.vietnamese_apps
|
||||||
|
.contains(&"telegram".to_string()));
|
||||||
|
assert!(config
|
||||||
|
.app_state
|
||||||
|
.vietnamese_apps
|
||||||
|
.contains(&"firefox".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use vietc_engine::{Engine, EngineEvent, InputMethod};
|
use vietc_engine::{Engine, EngineEvent, InputMethod};
|
||||||
|
|
||||||
mod config;
|
|
||||||
mod app_state;
|
mod app_state;
|
||||||
|
mod config;
|
||||||
mod display;
|
mod display;
|
||||||
|
|
||||||
use config::Config;
|
|
||||||
use app_state::AppStateManager;
|
use app_state::AppStateManager;
|
||||||
|
use config::Config;
|
||||||
|
|
||||||
fn get_log_path() -> Option<PathBuf> {
|
fn get_log_path() -> Option<PathBuf> {
|
||||||
dirs::config_dir().map(|p| p.join("vietc").join("vietc.log"))
|
dirs::config_dir().map(|p| p.join("vietc").join("vietc.log"))
|
||||||
|
|
@ -98,6 +98,7 @@ impl Daemon {
|
||||||
let mut app_state = AppStateManager::new(
|
let mut app_state = AppStateManager::new(
|
||||||
config.app_state.english_apps.clone(),
|
config.app_state.english_apps.clone(),
|
||||||
config.app_state.vietnamese_apps.clone(),
|
config.app_state.vietnamese_apps.clone(),
|
||||||
|
config.app_state.bypass_apps.clone(),
|
||||||
config.start_enabled,
|
config.start_enabled,
|
||||||
);
|
);
|
||||||
app_state.load_overrides();
|
app_state.load_overrides();
|
||||||
|
|
@ -133,7 +134,10 @@ impl Daemon {
|
||||||
if let Ok(content) = fs::read_to_string(&status_path) {
|
if let Ok(content) = fs::read_to_string(&status_path) {
|
||||||
let expect_enabled = content.trim() == "vn";
|
let expect_enabled = content.trim() == "vn";
|
||||||
if self.engine.is_enabled() != expect_enabled {
|
if self.engine.is_enabled() != expect_enabled {
|
||||||
log_info(&format!("[vietc] Syncing enabled status from file: {}", expect_enabled));
|
log_info(&format!(
|
||||||
|
"[vietc] Syncing enabled status from file: {}",
|
||||||
|
expect_enabled
|
||||||
|
));
|
||||||
self.engine.set_enabled(expect_enabled);
|
self.engine.set_enabled(expect_enabled);
|
||||||
self.engine_enabled.store(expect_enabled, Ordering::SeqCst);
|
self.engine_enabled.store(expect_enabled, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
@ -167,6 +171,7 @@ impl Daemon {
|
||||||
self.app_state.update_lists(
|
self.app_state.update_lists(
|
||||||
new_config.app_state.english_apps.clone(),
|
new_config.app_state.english_apps.clone(),
|
||||||
new_config.app_state.vietnamese_apps.clone(),
|
new_config.app_state.vietnamese_apps.clone(),
|
||||||
|
new_config.app_state.bypass_apps.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
self.grab_enabled = new_config.grab;
|
self.grab_enabled = new_config.grab;
|
||||||
|
|
@ -185,13 +190,38 @@ impl Daemon {
|
||||||
fn process_key(&mut self, ch: char) -> Vec<OutputCommand> {
|
fn process_key(&mut self, ch: char) -> Vec<OutputCommand> {
|
||||||
let mut commands = Vec::new();
|
let mut commands = Vec::new();
|
||||||
|
|
||||||
|
// Log each keystroke with character info
|
||||||
|
log_info(&format!(
|
||||||
|
"[vietc] process_key: U+{:04X} '{}' raw_buffer='{}' enabled={}",
|
||||||
|
ch as u32,
|
||||||
|
ch,
|
||||||
|
self.engine.buffer(),
|
||||||
|
self.engine.is_enabled()
|
||||||
|
));
|
||||||
|
|
||||||
if let Some(event) = self.engine.process_key(ch) {
|
if let Some(event) = self.engine.process_key(ch) {
|
||||||
log_info(&format!("[vietc] key='{}' buf='{}' -> {:?}", ch, self.engine.buffer(), event));
|
log_info(&format!(
|
||||||
|
"[vietc] key='{}' buf='{}' -> {:?}",
|
||||||
|
ch,
|
||||||
|
self.engine.buffer(),
|
||||||
|
event
|
||||||
|
));
|
||||||
match event {
|
match event {
|
||||||
EngineEvent::Flush(text) => {
|
EngineEvent::Flush(text) => {
|
||||||
|
log_info(&format!(
|
||||||
|
"[vietc] Flush text len={}, bytes={} text={}",
|
||||||
|
text.len(),
|
||||||
|
text.len() * 3,
|
||||||
|
text.escape_default()
|
||||||
|
));
|
||||||
commands.push(OutputCommand::Type(text));
|
commands.push(OutputCommand::Type(text));
|
||||||
}
|
}
|
||||||
EngineEvent::Insert(text) => {
|
EngineEvent::Insert(text) => {
|
||||||
|
log_info(&format!(
|
||||||
|
"[vietc] Insert text len={}, text={}",
|
||||||
|
text.len(),
|
||||||
|
text
|
||||||
|
));
|
||||||
commands.push(OutputCommand::Type(text));
|
commands.push(OutputCommand::Type(text));
|
||||||
}
|
}
|
||||||
EngineEvent::AutoRestore(word) => {
|
EngineEvent::AutoRestore(word) => {
|
||||||
|
|
@ -200,16 +230,42 @@ impl Daemon {
|
||||||
commands.push(OutputCommand::Type(word));
|
commands.push(OutputCommand::Type(word));
|
||||||
}
|
}
|
||||||
EngineEvent::Replace { backspaces, insert } => {
|
EngineEvent::Replace { backspaces, insert } => {
|
||||||
|
log_info(&format!(
|
||||||
|
"[vietc] Replace BS={} text=\"{}\"",
|
||||||
|
backspaces, insert
|
||||||
|
));
|
||||||
commands.push(OutputCommand::Backspace(backspaces));
|
commands.push(OutputCommand::Backspace(backspaces));
|
||||||
commands.push(OutputCommand::Type(insert));
|
commands.push(OutputCommand::Type(insert));
|
||||||
}
|
}
|
||||||
EngineEvent::UndoTones { backspaces, restored } => {
|
EngineEvent::UndoTones {
|
||||||
|
backspaces,
|
||||||
|
restored,
|
||||||
|
} => {
|
||||||
|
log_info(&format!(
|
||||||
|
"[vietc] UndoTones BS={} restored=\"{}\"",
|
||||||
|
backspaces, restored
|
||||||
|
));
|
||||||
commands.push(OutputCommand::Backspace(backspaces));
|
commands.push(OutputCommand::Backspace(backspaces));
|
||||||
commands.push(OutputCommand::Type(restored));
|
commands.push(OutputCommand::Type(restored));
|
||||||
}
|
}
|
||||||
|
EngineEvent::Paste(text) => {
|
||||||
|
log_info(&format!(
|
||||||
|
"[vietc] Paste raw text len={}, bytes={} text={}",
|
||||||
|
text.len(),
|
||||||
|
text.len() * 3,
|
||||||
|
text.escape_default()
|
||||||
|
));
|
||||||
|
// Exit paste mode after pasting
|
||||||
|
self.engine.exit_paste_mode();
|
||||||
|
commands.push(OutputCommand::Type(text));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log_info(&format!("[vietc] key='{}' -> (no event, buf='{}')", ch, self.engine.buffer()));
|
log_info(&format!(
|
||||||
|
"[vietc] key='{}' -> (no event, buf='{}')",
|
||||||
|
ch,
|
||||||
|
self.engine.buffer()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
commands
|
commands
|
||||||
|
|
@ -217,8 +273,33 @@ impl Daemon {
|
||||||
|
|
||||||
fn toggle(&mut self) {
|
fn toggle(&mut self) {
|
||||||
let new_state = self.app_state.toggle_current_app();
|
let new_state = self.app_state.toggle_current_app();
|
||||||
|
log_info(&format!(
|
||||||
|
"[vietc] toggle: engine.enabled={}",
|
||||||
|
self.engine.is_enabled()
|
||||||
|
));
|
||||||
|
|
||||||
self.engine.set_enabled(new_state);
|
self.engine.set_enabled(new_state);
|
||||||
self.write_status();
|
self.write_status();
|
||||||
|
|
||||||
|
// Reset engine buffer when enabling Vietnamese mode to clear stale state
|
||||||
|
if new_state {
|
||||||
|
log_info(&format!(
|
||||||
|
"[vietc] reset() called - raw_buffer='{}' before reset",
|
||||||
|
self.engine.buffer()
|
||||||
|
));
|
||||||
|
self.engine.reset();
|
||||||
|
log_info(&format!(
|
||||||
|
"[vietc] after reset() - raw_buffer='{}'",
|
||||||
|
self.engine.buffer()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_current_app_bypassed(&self) -> bool {
|
||||||
|
if !self.config.app_state.enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.app_state.is_current_app_bypassed()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_app_change_with(&mut self, new_class: String) {
|
fn check_app_change_with(&mut self, new_class: String) {
|
||||||
|
|
@ -248,10 +329,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let compositor = display::detect_compositor();
|
let compositor = display::detect_compositor();
|
||||||
|
|
||||||
log_info(&format!("Viet+ Daemon v{}", env!("CARGO_PKG_VERSION")));
|
log_info(&format!("Viet+ Daemon v{}", env!("CARGO_PKG_VERSION")));
|
||||||
log_info(&format!("Display: {:?} ({})", display, compositor.unwrap_or_else(|| "unknown".into())));
|
log_info(&format!(
|
||||||
|
"Display: {:?} ({})",
|
||||||
|
display,
|
||||||
|
compositor.unwrap_or_else(|| "unknown".into())
|
||||||
|
));
|
||||||
log_info(&format!("Input method: {:?}", daemon.config.input_method));
|
log_info(&format!("Input method: {:?}", daemon.config.input_method));
|
||||||
log_info(&format!("Toggle key: Ctrl+{}", daemon.config.toggle_key.to_uppercase()));
|
log_info(&format!(
|
||||||
log_info(&format!("App memory: {}", if daemon.config.app_state.enabled { "ON" } else { "OFF" }));
|
"Toggle key: Ctrl+{}",
|
||||||
|
daemon.config.toggle_key.to_uppercase()
|
||||||
|
));
|
||||||
|
log_info(&format!(
|
||||||
|
"App memory: {}",
|
||||||
|
if daemon.config.app_state.enabled {
|
||||||
|
"ON"
|
||||||
|
} else {
|
||||||
|
"OFF"
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
// Spawn background monitor for active window, config changes, and status changes
|
// Spawn background monitor for active window, config changes, and status changes
|
||||||
let shared_active_window = Arc::new(Mutex::new(String::new()));
|
let shared_active_window = Arc::new(Mutex::new(String::new()));
|
||||||
|
|
@ -361,9 +456,10 @@ fn open_keyboard_device() -> Result<(evdev::Device, String), Box<dyn std::error:
|
||||||
if dev_name.eq_ignore_ascii_case("vietc") {
|
if dev_name.eq_ignore_ascii_case("vietc") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if device.supported_keys().is_some_and(|k| {
|
if device
|
||||||
k.contains(evdev::Key::KEY_A)
|
.supported_keys()
|
||||||
}) {
|
.is_some_and(|k| k.contains(evdev::Key::KEY_A))
|
||||||
|
{
|
||||||
return Ok((device, format!("{} ({})", entry.path().display(), dev_name)));
|
return Ok((device, format!("{} ({})", entry.path().display(), dev_name)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -390,14 +486,16 @@ fn open_keyboard_device() -> Result<(evdev::Device, String), Box<dyn std::error:
|
||||||
but your current session hasn't picked it up yet. \
|
but your current session hasn't picked it up yet. \
|
||||||
Please LOG OUT and LOG BACK IN to activate group permissions.",
|
Please LOG OUT and LOG BACK IN to activate group permissions.",
|
||||||
permission_denied_count, total_event_count
|
permission_denied_count, total_event_count
|
||||||
).into())
|
)
|
||||||
|
.into())
|
||||||
} else {
|
} else {
|
||||||
Err(format!(
|
Err(format!(
|
||||||
"Permission denied on {}/{} devices. Add your user to the 'input' group: \
|
"Permission denied on {}/{} devices. Add your user to the 'input' group: \
|
||||||
sudo usermod -aG input $USER && sudo usermod -aG vinput $USER, \
|
sudo usermod -aG input $USER && sudo usermod -aG vinput $USER, \
|
||||||
then log out and log back in.",
|
then log out and log back in.",
|
||||||
permission_denied_count, total_event_count
|
permission_denied_count, total_event_count
|
||||||
).into())
|
)
|
||||||
|
.into())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err("No keyboard device found".into())
|
Err("No keyboard device found".into())
|
||||||
|
|
@ -422,7 +520,10 @@ fn run_with_evdev(
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_info(&format!("[vietc] Could not grab keyboard: {} (run as root for grab)", e));
|
log_info(&format!(
|
||||||
|
"[vietc] Could not grab keyboard: {} (run as root for grab)",
|
||||||
|
e
|
||||||
|
));
|
||||||
log_info("[vietc] Falling back to non-grabbing mode (may have race)");
|
log_info("[vietc] Falling back to non-grabbing mode (may have race)");
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
@ -443,13 +544,18 @@ fn run_with_evdev(
|
||||||
loop {
|
loop {
|
||||||
// Check for event timeout (grab safety)
|
// Check for event timeout (grab safety)
|
||||||
if grabbed && last_event_time.elapsed() > std::time::Duration::from_secs(30) {
|
if grabbed && last_event_time.elapsed() > std::time::Duration::from_secs(30) {
|
||||||
log_info("[vietc] No events for 30s — releasing grab timeout, releasing grab for safety");
|
log_info(
|
||||||
|
"[vietc] No events for 30s — releasing grab timeout, releasing grab for safety",
|
||||||
|
);
|
||||||
let _ = device.ungrab();
|
let _ = device.ungrab();
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let caps = is_caps_lock_on(&device);
|
let caps = is_caps_lock_on(&device);
|
||||||
let key_state = device.get_key_state().ok();
|
let mut key_state = device
|
||||||
|
.get_key_state()
|
||||||
|
.ok()
|
||||||
|
.unwrap_or_else(evdev::AttributeSet::new);
|
||||||
let events = device.fetch_events()?;
|
let events = device.fetch_events()?;
|
||||||
last_event_time = std::time::Instant::now();
|
last_event_time = std::time::Instant::now();
|
||||||
|
|
||||||
|
|
@ -463,7 +569,10 @@ fn run_with_evdev(
|
||||||
{
|
{
|
||||||
let active_window = shared_active_window.lock().unwrap().clone();
|
let active_window = shared_active_window.lock().unwrap().clone();
|
||||||
if active_window != last_active_window {
|
if active_window != last_active_window {
|
||||||
log_info(&format!("[vietc] Window changed: '{}' -> '{}'", last_active_window, active_window));
|
log_info(&format!(
|
||||||
|
"[vietc] Window changed: '{}' -> '{}'",
|
||||||
|
last_active_window, active_window
|
||||||
|
));
|
||||||
last_active_window = active_window.clone();
|
last_active_window = active_window.clone();
|
||||||
daemon.engine.reset();
|
daemon.engine.reset();
|
||||||
log_info("[vietc] Reset engine buffer due to window change");
|
log_info("[vietc] Reset engine buffer due to window change");
|
||||||
|
|
@ -487,8 +596,22 @@ fn run_with_evdev(
|
||||||
let value = event.value();
|
let value = event.value();
|
||||||
let keycode = key.0;
|
let keycode = key.0;
|
||||||
|
|
||||||
if value == 1
|
// Update key state dynamically
|
||||||
&& is_toggle_combination_state(&key_state, &daemon.config.toggle_key)
|
if value == 1 {
|
||||||
|
key_state.insert(key);
|
||||||
|
} else if value == 0 {
|
||||||
|
key_state.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completely bypass all IME processing/interception for terminal emulators, IDE terminals, and games
|
||||||
|
if daemon.is_current_app_bypassed() {
|
||||||
|
if grabbed {
|
||||||
|
injector.send_key_event(keycode, value);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if value == 1 && is_toggle_combination_state(&key_state, &daemon.config.toggle_key)
|
||||||
{
|
{
|
||||||
daemon.toggle();
|
daemon.toggle();
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -533,11 +656,9 @@ fn run_with_evdev(
|
||||||
}
|
}
|
||||||
if let Some(mut ch) = key_to_char(key) {
|
if let Some(mut ch) = key_to_char(key) {
|
||||||
let shift = is_modifier_held_shift(&key_state);
|
let shift = is_modifier_held_shift(&key_state);
|
||||||
if ch.is_ascii_alphabetic() {
|
if ch.is_ascii_alphabetic() && (shift ^ caps) {
|
||||||
if shift ^ caps {
|
|
||||||
ch = ch.to_ascii_uppercase();
|
ch = ch.to_ascii_uppercase();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
let commands = daemon.process_key(ch);
|
let commands = daemon.process_key(ch);
|
||||||
if !commands.is_empty() {
|
if !commands.is_empty() {
|
||||||
consumed_keys.insert(keycode);
|
consumed_keys.insert(keycode);
|
||||||
|
|
@ -576,8 +697,7 @@ fn run_stdin_mode(
|
||||||
_engine_enabled: Arc<AtomicBool>,
|
_engine_enabled: Arc<AtomicBool>,
|
||||||
display: display::DisplayServer,
|
display: display::DisplayServer,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use std::io::{self, Read, IsTerminal};
|
use std::io::{self, IsTerminal, Read};
|
||||||
|
|
||||||
|
|
||||||
if !io::stdin().is_terminal() {
|
if !io::stdin().is_terminal() {
|
||||||
log_info("[vietc] Warning: No keyboard device and no terminal.");
|
log_info("[vietc] Warning: No keyboard device and no terminal.");
|
||||||
|
|
@ -603,7 +723,8 @@ fn run_stdin_mode(
|
||||||
if let Ok((device, path)) = open_keyboard_device() {
|
if let Ok((device, path)) = open_keyboard_device() {
|
||||||
log_info(&format!("[vietc] Keyboard device found: {}", path));
|
log_info(&format!("[vietc] Keyboard device found: {}", path));
|
||||||
return run_with_evdev(
|
return run_with_evdev(
|
||||||
device, daemon,
|
device,
|
||||||
|
daemon,
|
||||||
shared_active_window,
|
shared_active_window,
|
||||||
config_changed,
|
config_changed,
|
||||||
status_changed,
|
status_changed,
|
||||||
|
|
@ -633,7 +754,10 @@ fn run_stdin_mode(
|
||||||
{
|
{
|
||||||
let active_window = shared_active_window.lock().unwrap().clone();
|
let active_window = shared_active_window.lock().unwrap().clone();
|
||||||
if active_window != last_active_window {
|
if active_window != last_active_window {
|
||||||
log_info(&format!("[vietc] Window changed: '{}' -> '{}'", last_active_window, active_window));
|
log_info(&format!(
|
||||||
|
"[vietc] Window changed: '{}' -> '{}'",
|
||||||
|
last_active_window, active_window
|
||||||
|
));
|
||||||
last_active_window = active_window.clone();
|
last_active_window = active_window.clone();
|
||||||
daemon.engine.reset();
|
daemon.engine.reset();
|
||||||
log_info("[vietc] Reset engine buffer due to window change");
|
log_info("[vietc] Reset engine buffer due to window change");
|
||||||
|
|
@ -672,15 +796,26 @@ fn run_stdin_mode(
|
||||||
/// Execute commands — accumulate backspaces and text, then inject through
|
/// Execute commands — accumulate backspaces and text, then inject through
|
||||||
/// a single channel (ydotool or wtype) to avoid reordering between backspaces
|
/// a single channel (ydotool or wtype) to avoid reordering between backspaces
|
||||||
/// (uinput) and text (ydotool).
|
/// (uinput) and text (ydotool).
|
||||||
fn execute_commands(injector: &dyn vietc_protocol::KeyInjector, commands: &[OutputCommand], grabbed: bool) {
|
fn execute_commands(
|
||||||
|
injector: &dyn vietc_protocol::KeyInjector,
|
||||||
|
commands: &[OutputCommand],
|
||||||
|
grabbed: bool,
|
||||||
|
) {
|
||||||
let mut pending_backspaces: usize = 0;
|
let mut pending_backspaces: usize = 0;
|
||||||
let mut pending_text = String::new();
|
let mut pending_text = String::new();
|
||||||
|
|
||||||
for cmd in commands {
|
for cmd in commands {
|
||||||
match cmd {
|
match cmd {
|
||||||
OutputCommand::Backspace(count) => {
|
OutputCommand::Backspace(count) => {
|
||||||
let adjusted = if grabbed { count.saturating_sub(1) } else { *count };
|
let adjusted = if grabbed {
|
||||||
log_info(&format!("[vietc] cmd: Backspace({}) -> adjusted={}", count, adjusted));
|
count.saturating_sub(1)
|
||||||
|
} else {
|
||||||
|
*count
|
||||||
|
};
|
||||||
|
log_info(&format!(
|
||||||
|
"[vietc] cmd: Backspace({}) -> adjusted={}",
|
||||||
|
count, adjusted
|
||||||
|
));
|
||||||
pending_backspaces += adjusted;
|
pending_backspaces += adjusted;
|
||||||
}
|
}
|
||||||
OutputCommand::Type(text) => {
|
OutputCommand::Type(text) => {
|
||||||
|
|
@ -691,13 +826,32 @@ fn execute_commands(injector: &dyn vietc_protocol::KeyInjector, commands: &[Outp
|
||||||
}
|
}
|
||||||
|
|
||||||
if pending_backspaces > 0 || !pending_text.is_empty() {
|
if pending_backspaces > 0 || !pending_text.is_empty() {
|
||||||
log_info(&format!("[vietc] inject: BS={} text=\"{}\"", pending_backspaces, pending_text));
|
log_info(&format!(
|
||||||
injector.inject_replacement(pending_backspaces, &pending_text);
|
"[vietc] inject: BS={} text=\"{}\"",
|
||||||
}
|
pending_backspaces, pending_text
|
||||||
injector.flush();
|
));
|
||||||
|
|
||||||
|
// Use injector for text (ydotool/xdotool/wtype)
|
||||||
|
let _ = injector.inject_replacement(pending_backspaces, &pending_text);
|
||||||
|
} else if !commands.is_empty() {
|
||||||
|
// Empty text but commands exist (e.g. Backspace only or Flush empty string)
|
||||||
|
log_info(&format!("[vietc] inject: BS={}", pending_backspaces));
|
||||||
|
|
||||||
|
let _ = injector.inject_replacement(pending_backspaces, &pending_text);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_injector(display: display::DisplayServer) -> Result<Box<dyn vietc_protocol::KeyInjector>, Box<dyn std::error::Error>> {
|
injector.flush();
|
||||||
|
|
||||||
|
// Sleep briefly to let the display server and target application process the
|
||||||
|
// injected key strokes and clear any modifier states before we handle subsequent physical keys.
|
||||||
|
if grabbed && !commands.is_empty() {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_injector(
|
||||||
|
display: display::DisplayServer,
|
||||||
|
) -> Result<Box<dyn vietc_protocol::KeyInjector>, Box<dyn std::error::Error>> {
|
||||||
// Try Wayland input method first (if compiled with wayland feature)
|
// Try Wayland input method first (if compiled with wayland feature)
|
||||||
#[cfg(feature = "wayland")]
|
#[cfg(feature = "wayland")]
|
||||||
{
|
{
|
||||||
|
|
@ -738,12 +892,7 @@ fn create_injector(display: display::DisplayServer) -> Result<Box<dyn vietc_prot
|
||||||
Err("No injection backend available".into())
|
Err("No injection backend available".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_modifier_pressed(key_state: &Option<evdev::AttributeSet<evdev::Key>>) -> bool {
|
fn is_modifier_pressed(key_state: &evdev::AttributeSet<evdev::Key>) -> bool {
|
||||||
let key_state = match key_state {
|
|
||||||
Some(ks) => ks,
|
|
||||||
None => return false,
|
|
||||||
};
|
|
||||||
|
|
||||||
key_state.contains(evdev::Key::KEY_LEFTCTRL)
|
key_state.contains(evdev::Key::KEY_LEFTCTRL)
|
||||||
|| key_state.contains(evdev::Key::KEY_RIGHTCTRL)
|
|| key_state.contains(evdev::Key::KEY_RIGHTCTRL)
|
||||||
|| key_state.contains(evdev::Key::KEY_LEFTALT)
|
|| key_state.contains(evdev::Key::KEY_LEFTALT)
|
||||||
|
|
@ -752,12 +901,8 @@ fn is_modifier_pressed(key_state: &Option<evdev::AttributeSet<evdev::Key>>) -> b
|
||||||
|| key_state.contains(evdev::Key::KEY_RIGHTMETA)
|
|| key_state.contains(evdev::Key::KEY_RIGHTMETA)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_modifier_held_shift(key_state: &Option<evdev::AttributeSet<evdev::Key>>) -> bool {
|
fn is_modifier_held_shift(key_state: &evdev::AttributeSet<evdev::Key>) -> bool {
|
||||||
let ks = match key_state {
|
key_state.contains(evdev::Key::KEY_LEFTSHIFT) || key_state.contains(evdev::Key::KEY_RIGHTSHIFT)
|
||||||
Some(ks) => ks,
|
|
||||||
None => return false,
|
|
||||||
};
|
|
||||||
ks.contains(evdev::Key::KEY_LEFTSHIFT) || ks.contains(evdev::Key::KEY_RIGHTSHIFT)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_caps_lock_on(device: &evdev::Device) -> bool {
|
fn is_caps_lock_on(device: &evdev::Device) -> bool {
|
||||||
|
|
@ -768,12 +913,7 @@ fn is_caps_lock_on(device: &evdev::Device) -> bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_toggle_combination_state(key_state: &Option<evdev::AttributeSet<evdev::Key>>, key: &str) -> bool {
|
fn is_toggle_combination_state(key_state: &evdev::AttributeSet<evdev::Key>, key: &str) -> bool {
|
||||||
let key_state = match key_state {
|
|
||||||
Some(ks) => ks,
|
|
||||||
None => return false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let ctrl_pressed = key_state.contains(evdev::Key::KEY_LEFTCTRL)
|
let ctrl_pressed = key_state.contains(evdev::Key::KEY_LEFTCTRL)
|
||||||
|| key_state.contains(evdev::Key::KEY_RIGHTCTRL);
|
|| key_state.contains(evdev::Key::KEY_RIGHTCTRL);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,6 @@ description = "Viet+ Vietnamese IME Core Engine"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
insta = { version = "1.34", features = ["yaml"] }
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,114 @@
|
||||||
use std::io::{self, Write};
|
use std::fs::File;
|
||||||
use vietc_engine::{Engine, EngineEvent, InputMethod};
|
|
||||||
|
|
||||||
fn get_display(events: &[EngineEvent]) -> String {
|
|
||||||
let mut display = String::new();
|
|
||||||
for ev in events {
|
|
||||||
match ev {
|
|
||||||
EngineEvent::Flush(text) => { if !display.ends_with(text) { display.push_str(text); } }
|
|
||||||
EngineEvent::Insert(text) => display.push_str(text),
|
|
||||||
EngineEvent::Replace { backspaces, insert } => {
|
|
||||||
for _ in 0..*backspaces { display.pop(); }
|
|
||||||
display.push_str(insert);
|
|
||||||
}
|
|
||||||
EngineEvent::AutoRestore(word) => {
|
|
||||||
for _ in 0..word.len() { display.pop(); }
|
|
||||||
display.push_str(word);
|
|
||||||
}
|
|
||||||
EngineEvent::UndoTones { backspaces, restored } => {
|
|
||||||
for _ in 0..*backspaces { display.pop(); }
|
|
||||||
display.push_str(restored);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
display
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_input(e: &mut Engine, input: &str) -> Vec<EngineEvent> {
|
|
||||||
let mut events = Vec::new();
|
|
||||||
for ch in input.chars() {
|
|
||||||
if let Some(ev) = e.process_key(ch) { events.push(ev); }
|
|
||||||
}
|
|
||||||
events
|
|
||||||
}
|
|
||||||
|
|
||||||
const INITIALS: &[&str] = &[
|
const INITIALS: &[&str] = &[
|
||||||
"", "b", "c", "ch", "d", "g", "gh", "h", "k", "kh", "l", "m", "n",
|
"", "b", "c", "ch", "d", "g", "gh", "h", "k", "kh", "l", "m", "n", "ng", "ngh", "nh", "p",
|
||||||
"ng", "ngh", "nh", "p", "ph", "q", "r", "s", "t", "th", "tr", "v", "x",
|
"ph", "q", "r", "s", "t", "th", "tr", "v", "x",
|
||||||
];
|
];
|
||||||
|
|
||||||
const FINALS: &[&str] = &["", "c", "ch", "m", "n", "ng", "nh", "p", "t"];
|
const FINALS: &[&str] = &["", "c", "ch", "m", "n", "ng", "nh", "p", "t"];
|
||||||
|
|
||||||
fn is_valid(init: &str, fin: &str) -> bool {
|
fn is_valid(init: &str, fin: &str) -> bool {
|
||||||
if init == "ngh" && !fin.is_empty() && fin != "n" && fin != "ng" && fin != "nh" { return false; }
|
if init == "ngh" && !fin.is_empty() && fin != "n" && fin != "ng" && fin != "nh" {
|
||||||
if init == "gh" && !fin.is_empty() { return false; }
|
return false;
|
||||||
if init == "q" { return false; }
|
}
|
||||||
if init == "g" && !fin.is_empty() && fin != "n" && fin != "ng" { return false; }
|
if init == "gh" && !fin.is_empty() {
|
||||||
if fin == "ch" && init == "" { return false; }
|
return false;
|
||||||
if fin == "nh" && init == "" { return false; }
|
}
|
||||||
|
if init == "q" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if init == "g" && !fin.is_empty() && fin != "n" && fin != "ng" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if fin == "ch" && init.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if fin == "nh" && init.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Telex base vowels (as typed, before mod)
|
// Telex
|
||||||
let telex_vowels: Vec<(&str, &str)> = vec![
|
let telex_vowels: Vec<(&str, &str)> = vec![
|
||||||
("a", "af"), ("a", "as"), ("a", "aj"), ("a", "ar"), ("a", "ax"),
|
("a", "af"),
|
||||||
("a", "aw"), ("a", "aa"),
|
("a", "as"),
|
||||||
|
("a", "aj"),
|
||||||
|
("a", "ar"),
|
||||||
|
("a", "ax"),
|
||||||
|
("a", "aw"),
|
||||||
|
("a", "aa"),
|
||||||
("e", "ee"),
|
("e", "ee"),
|
||||||
("o", "oo"), ("o", "ow"),
|
("o", "oo"),
|
||||||
|
("o", "ow"),
|
||||||
("u", "uw"),
|
("u", "uw"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let mut count = 0;
|
let mut telex_inputs = Vec::new();
|
||||||
let stdout = io::stdout();
|
|
||||||
let mut handle = stdout.lock();
|
|
||||||
|
|
||||||
for &init in INITIALS {
|
for &init in INITIALS {
|
||||||
for &fin in FINALS {
|
for &fin in FINALS {
|
||||||
if !is_valid(init, fin) { continue; }
|
if !is_valid(init, fin) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for &(base, mod_str) in &telex_vowels {
|
for &(base, mod_str) in &telex_vowels {
|
||||||
let plain = format!("{}{}{}", init, base, fin);
|
let plain = format!("{}{}{}", init, base, fin);
|
||||||
let full = format!("{}{}", plain, mod_str);
|
let full = format!("{}{}", plain, mod_str);
|
||||||
if plain.len() > 10 { continue; }
|
if plain.len() > 10 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
telex_inputs.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Limit to 500 cases to keep snapshot size reasonable but comprehensive
|
||||||
|
telex_inputs.truncate(500);
|
||||||
|
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
// VNI
|
||||||
let result = get_display(&process_input(&mut e, &full));
|
let vni_vowels: Vec<(&str, &str)> = vec![
|
||||||
|
("a", "1"),
|
||||||
|
("a", "2"),
|
||||||
|
("a", "3"),
|
||||||
|
("a", "4"),
|
||||||
|
("a", "5"),
|
||||||
|
("a", "6"),
|
||||||
|
("a", "8"),
|
||||||
|
("e", "6"),
|
||||||
|
("o", "6"),
|
||||||
|
("o", "7"),
|
||||||
|
("u", "7"),
|
||||||
|
];
|
||||||
|
|
||||||
if !result.is_empty() && result.len() <= 12 && result != full && result != plain {
|
let mut vni_inputs = Vec::new();
|
||||||
count += 1;
|
for &init in INITIALS {
|
||||||
let _ = writeln!(handle, "{{\"i\":\"{full}\",\"e\":\"{result}\",\"m\":\"telex\"}}");
|
for &fin in FINALS {
|
||||||
|
if !is_valid(init, fin) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if count >= 1000 { break; }
|
for &(base, mod_str) in &vni_vowels {
|
||||||
|
let plain = format!("{}{}{}", init, base, fin);
|
||||||
|
let full = format!("{}{}", plain, mod_str);
|
||||||
|
if plain.len() > 10 {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if count >= 1000 { break; }
|
vni_inputs.push(full);
|
||||||
}
|
}
|
||||||
if count >= 1000 { break; }
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
vni_inputs.truncate(500);
|
||||||
|
|
||||||
eprintln!("Generated {count} test cases");
|
// Ensure output directory exists
|
||||||
|
std::fs::create_dir_all("tests/testdata").unwrap();
|
||||||
|
|
||||||
|
let mut f_telex = File::create("tests/testdata/telex_inputs.json").unwrap();
|
||||||
|
serde_json::to_writer_pretty(&mut f_telex, &telex_inputs).unwrap();
|
||||||
|
|
||||||
|
let mut f_vni = File::create("tests/testdata/vni_inputs.json").unwrap();
|
||||||
|
serde_json::to_writer_pretty(&mut f_vni, &vni_inputs).unwrap();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Generated {} Telex and {} VNI test inputs under tests/testdata/",
|
||||||
|
telex_inputs.len(),
|
||||||
|
vni_inputs.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use vietc_engine::{Engine, InputMethod, EngineEvent};
|
use vietc_engine::{Engine, EngineEvent, InputMethod};
|
||||||
|
|
||||||
fn trace(input: &str, method: InputMethod) {
|
fn trace(input: &str, method: InputMethod) {
|
||||||
let mut e = Engine::new(method);
|
let mut e = Engine::new(method);
|
||||||
|
|
@ -11,32 +11,39 @@ fn trace(input: &str, method: InputMethod) {
|
||||||
let curr = e.buffer().to_string();
|
let curr = e.buffer().to_string();
|
||||||
let expected = format!("{}{}", prev, ch);
|
let expected = format!("{}{}", prev, ch);
|
||||||
let event_str = match &event {
|
let event_str = match &event {
|
||||||
Some(EngineEvent::Replace { backspaces, insert }) =>
|
Some(EngineEvent::Replace { backspaces, insert }) => {
|
||||||
format!("Replace({}, {:?})", backspaces, insert),
|
format!("Replace({}, {:?})", backspaces, insert)
|
||||||
|
}
|
||||||
Some(EngineEvent::Insert(t)) => format!("Insert({:?})", t),
|
Some(EngineEvent::Insert(t)) => format!("Insert({:?})", t),
|
||||||
Some(EngineEvent::Flush(t)) => format!("Flush({:?})", t),
|
Some(EngineEvent::Flush(t)) => format!("Flush({:?})", t),
|
||||||
Some(EngineEvent::AutoRestore(w)) => format!("AutoRestore({:?})", w),
|
Some(EngineEvent::AutoRestore(w)) => format!("AutoRestore({:?})", w),
|
||||||
Some(EngineEvent::UndoTones { backspaces, restored }) =>
|
Some(EngineEvent::UndoTones {
|
||||||
format!("UndoTones({}, {:?})", backspaces, restored),
|
backspaces,
|
||||||
|
restored,
|
||||||
|
}) => format!("UndoTones({}, {:?})", backspaces, restored),
|
||||||
|
Some(EngineEvent::Paste(t)) => format!("Paste({:?})", t),
|
||||||
None => "None".to_string(),
|
None => "None".to_string(),
|
||||||
};
|
};
|
||||||
let backspaces = match &event {
|
eprintln!(
|
||||||
Some(EngineEvent::Replace { backspaces, .. }) => format!("bs={}", backspaces),
|
"'{}' | {:<9} → {:<9} | {:<19} | {}",
|
||||||
_ => " ".to_string(),
|
ch, prev, curr, expected, event_str
|
||||||
};
|
);
|
||||||
eprintln!("'{}' | {:<9} → {:<9} | {:<19} | {}",
|
|
||||||
ch, prev, curr, expected, event_str);
|
|
||||||
if let Some(EngineEvent::Replace { backspaces, insert }) = &event {
|
if let Some(EngineEvent::Replace { backspaces, insert }) = &event {
|
||||||
// In grab mode, backspace - 1 (key consumed)
|
// In grab mode, backspace - 1 (key consumed)
|
||||||
let grab_bs = backspaces.saturating_sub(1);
|
let grab_bs = backspaces.saturating_sub(1);
|
||||||
// In non-grab mode, full backspace
|
// In non-grab mode, full backspace
|
||||||
eprintln!(" | | | grab_bs={} non_grab_bs={} insert={:?}",
|
eprintln!(
|
||||||
grab_bs, backspaces, insert);
|
" | | | grab_bs={} non_grab_bs={} insert={:?}",
|
||||||
|
grab_bs, backspaces, insert
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Flush
|
// Flush
|
||||||
if let Some(event) = e.flush() {
|
if let Some(event) = e.flush() {
|
||||||
eprintln!("FL | | | | {:?}", event);
|
eprintln!(
|
||||||
|
"FL | | | | {:?}",
|
||||||
|
event
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,29 @@
|
||||||
|
use crate::english::EnglishDict;
|
||||||
use crate::telex::TelexEngine;
|
use crate::telex::TelexEngine;
|
||||||
use crate::vni::VniEngine;
|
use crate::vni::VniEngine;
|
||||||
use crate::english::EnglishDict;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||||
pub enum InputMethod {
|
pub enum InputMethod {
|
||||||
Telex,
|
Telex,
|
||||||
Vni,
|
Vni,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||||
pub enum EngineEvent {
|
pub enum EngineEvent {
|
||||||
Replace { backspaces: usize, insert: String },
|
Replace {
|
||||||
|
backspaces: usize,
|
||||||
|
insert: String,
|
||||||
|
},
|
||||||
Insert(String),
|
Insert(String),
|
||||||
Flush(String),
|
Flush(String),
|
||||||
AutoRestore(String),
|
AutoRestore(String),
|
||||||
/// ESC undo: strip all tone marks from current word
|
/// ESC undo: strip all tone marks from current word
|
||||||
UndoTones { backspaces: usize, restored: String },
|
UndoTones {
|
||||||
|
backspaces: usize,
|
||||||
|
restored: String,
|
||||||
|
},
|
||||||
|
/// Text was pasted via clipboard - update buffer directly without telex parsing
|
||||||
|
Paste(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Engine {
|
pub struct Engine {
|
||||||
|
|
@ -26,6 +34,8 @@ pub struct Engine {
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
macros: std::collections::HashMap<String, String>,
|
macros: std::collections::HashMap<String, String>,
|
||||||
raw_buffer: String,
|
raw_buffer: String,
|
||||||
|
/// Flag to bypass telex/vni parsing when Unicode text has been pasted via clipboard
|
||||||
|
paste_mode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
|
|
@ -38,6 +48,7 @@ impl Engine {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
macros: std::collections::HashMap::new(),
|
macros: std::collections::HashMap::new(),
|
||||||
raw_buffer: String::new(),
|
raw_buffer: String::new(),
|
||||||
|
paste_mode: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,6 +68,36 @@ impl Engine {
|
||||||
self.reset();
|
self.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enter "paste mode" - bypass telex/vni parsing for Unicode pasted text
|
||||||
|
pub fn enter_paste_mode(&mut self) {
|
||||||
|
self.paste_mode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exit paste mode (for Paste event handling)
|
||||||
|
pub fn exit_paste_mode(&mut self) {
|
||||||
|
self.paste_mode = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paste raw text into buffer without telex/vni processing
|
||||||
|
pub fn paste(&mut self, text: &str) -> EngineEvent {
|
||||||
|
// Clear buffer if entering paste mode and exit paste mode after
|
||||||
|
if self.paste_mode {
|
||||||
|
self.raw_buffer.clear();
|
||||||
|
} else {
|
||||||
|
self.enter_paste_mode();
|
||||||
|
}
|
||||||
|
|
||||||
|
let event = EngineEvent::Paste(text.to_string());
|
||||||
|
self.raw_buffer.push_str(text);
|
||||||
|
event
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update buffer with pasted text for subsequent edit operations (delete/backspace)
|
||||||
|
pub fn update_with_pasted_text(&mut self, text: &str) {
|
||||||
|
self.raw_buffer.clear();
|
||||||
|
self.raw_buffer.push_str(text);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.telex.reset();
|
self.telex.reset();
|
||||||
self.vni.reset();
|
self.vni.reset();
|
||||||
|
|
@ -64,6 +105,18 @@ impl Engine {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn flush(&mut self) -> Option<EngineEvent> {
|
pub fn flush(&mut self) -> Option<EngineEvent> {
|
||||||
|
// If in paste mode, bypass telex/vni parsing and return raw text as-is
|
||||||
|
if self.paste_mode && !self.raw_buffer.is_empty() {
|
||||||
|
// Only set paste_mode if buffer contains non-ASCII Unicode chars (pasted content)
|
||||||
|
let has_unicode = self.raw_buffer.chars().any(|c| !c.is_ascii());
|
||||||
|
if has_unicode {
|
||||||
|
let word = self.raw_buffer.clone();
|
||||||
|
self.raw_buffer.clear();
|
||||||
|
self.paste_mode = false; // Exit paste mode after flush
|
||||||
|
return Some(EngineEvent::Flush(word));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let event = match self.input_method {
|
let event = match self.input_method {
|
||||||
InputMethod::Telex => self.telex.flush(),
|
InputMethod::Telex => self.telex.flush(),
|
||||||
InputMethod::Vni => self.vni.flush(),
|
InputMethod::Vni => self.vni.flush(),
|
||||||
|
|
@ -151,8 +204,15 @@ impl Engine {
|
||||||
ch.to_lowercase().next().unwrap_or(ch)
|
ch.to_lowercase().next().unwrap_or(ch)
|
||||||
};
|
};
|
||||||
|
|
||||||
if lowercase_ch == ' ' || lowercase_ch == '\t' || lowercase_ch == '.' || lowercase_ch == ',' || lowercase_ch == '!' || lowercase_ch == '?'
|
if lowercase_ch == ' '
|
||||||
|| lowercase_ch == ';' || lowercase_ch == ':' || lowercase_ch == '\n'
|
|| lowercase_ch == '\t'
|
||||||
|
|| lowercase_ch == '.'
|
||||||
|
|| lowercase_ch == ','
|
||||||
|
|| lowercase_ch == '!'
|
||||||
|
|| lowercase_ch == '?'
|
||||||
|
|| lowercase_ch == ';'
|
||||||
|
|| lowercase_ch == ':'
|
||||||
|
|| lowercase_ch == '\n'
|
||||||
{
|
{
|
||||||
if self.raw_buffer.is_empty() {
|
if self.raw_buffer.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
|
|
@ -171,11 +231,14 @@ impl Engine {
|
||||||
|
|
||||||
// Try auto-restore before flushing
|
// Try auto-restore before flushing
|
||||||
let clean_raw = self.raw_buffer.to_lowercase();
|
let clean_raw = self.raw_buffer.to_lowercase();
|
||||||
if self.english.should_restore(&clean_raw) {
|
|
||||||
let inner_buf = self.buffer().to_string();
|
let inner_buf = self.buffer().to_string();
|
||||||
let clean_inner = strip_diacritics(&inner_buf).to_lowercase();
|
let clean_inner = strip_diacritics(&inner_buf).to_lowercase();
|
||||||
let has_diacritics = clean_inner != inner_buf.to_lowercase();
|
let has_diacritics = clean_inner != inner_buf.to_lowercase();
|
||||||
|
|
||||||
|
let should_restore = self.english.should_restore(&clean_raw)
|
||||||
|
|| (has_diacritics && !crate::spelling::is_valid_vietnamese_syllable(&inner_buf));
|
||||||
|
|
||||||
|
if should_restore {
|
||||||
let original_raw = self.raw_buffer.clone();
|
let original_raw = self.raw_buffer.clone();
|
||||||
let inner_len = inner_buf.chars().count();
|
let inner_len = inner_buf.chars().count();
|
||||||
self.reset();
|
self.reset();
|
||||||
|
|
@ -214,18 +277,39 @@ impl Engine {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular character processing
|
|
||||||
let previous_inner = self.buffer().to_string();
|
let previous_inner = self.buffer().to_string();
|
||||||
self.raw_buffer.push(ch);
|
self.raw_buffer.push(ch);
|
||||||
|
|
||||||
|
let expected_screen = format!("{}{}", previous_inner, lowercase_ch);
|
||||||
|
|
||||||
|
if self.paste_mode {
|
||||||
|
if ch.is_ascii() {
|
||||||
match self.input_method {
|
match self.input_method {
|
||||||
InputMethod::Telex => { self.telex.process_key(lowercase_ch); }
|
InputMethod::Telex => {
|
||||||
InputMethod::Vni => { self.vni.process_key(lowercase_ch); }
|
self.telex.process_key(lowercase_ch);
|
||||||
|
}
|
||||||
|
InputMethod::Vni => {
|
||||||
|
self.vni.process_key(lowercase_ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(EngineEvent::Replace {
|
||||||
|
backspaces: previous_inner.chars().count() + 1,
|
||||||
|
insert: ch.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match self.input_method {
|
||||||
|
InputMethod::Telex => {
|
||||||
|
self.telex.process_key(lowercase_ch);
|
||||||
|
}
|
||||||
|
InputMethod::Vni => {
|
||||||
|
self.vni.process_key(lowercase_ch);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_inner = self.buffer().to_string();
|
let new_inner = self.buffer().to_string();
|
||||||
let expected_screen = format!("{}{}", previous_inner, lowercase_ch);
|
|
||||||
|
|
||||||
if new_inner != expected_screen {
|
if new_inner != expected_screen {
|
||||||
let cased_inner = match_casing(&self.raw_buffer, &new_inner);
|
let cased_inner = match_casing(&self.raw_buffer, &new_inner);
|
||||||
Some(EngineEvent::Replace {
|
Some(EngineEvent::Replace {
|
||||||
|
|
@ -236,6 +320,7 @@ impl Engine {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn buffer(&self) -> &str {
|
pub fn buffer(&self) -> &str {
|
||||||
match self.input_method {
|
match self.input_method {
|
||||||
|
|
@ -250,25 +335,33 @@ fn strip_diacritics(s: &str) -> String {
|
||||||
s.chars()
|
s.chars()
|
||||||
.map(|c| match c {
|
.map(|c| match c {
|
||||||
// a variants
|
// a variants
|
||||||
'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ'
|
'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' | 'â' | 'ầ' | 'ấ'
|
||||||
| 'â' | 'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'a',
|
| 'ẩ' | 'ẫ' | 'ậ' => 'a',
|
||||||
// A variants
|
// A variants
|
||||||
'À' | 'Á' | 'Ả' | 'Ã' | 'Ạ' | 'Ă' | 'Ằ' | 'Ắ' | 'Ẳ' | 'Ẵ' | 'Ặ'
|
'À' | 'Á' | 'Ả' | 'Ã' | 'Ạ' | 'Ă' | 'Ằ' | 'Ắ' | 'Ẳ' | 'Ẵ' | 'Ặ' | 'Â' | 'Ầ' | 'Ấ'
|
||||||
| 'Â' | 'Ầ' | 'Ấ' | 'Ẩ' | 'Ẫ' | 'Ậ' => 'A',
|
| 'Ẩ' | 'Ẫ' | 'Ậ' => 'A',
|
||||||
// e variants
|
// e variants
|
||||||
'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' | 'ê' | 'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => 'e',
|
'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' | 'ê' | 'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => {
|
||||||
'È' | 'É' | 'Ẻ' | 'Ẽ' | 'Ẹ' | 'Ê' | 'Ề' | 'Ế' | 'Ể' | 'Ễ' | 'Ệ' => 'E',
|
'e'
|
||||||
|
}
|
||||||
|
'È' | 'É' | 'Ẻ' | 'Ẽ' | 'Ẹ' | 'Ê' | 'Ề' | 'Ế' | 'Ể' | 'Ễ' | 'Ệ' => {
|
||||||
|
'E'
|
||||||
|
}
|
||||||
// i variants
|
// i variants
|
||||||
'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => 'i',
|
'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => 'i',
|
||||||
'Ì' | 'Í' | 'Ỉ' | 'Ĩ' | 'Ị' => 'I',
|
'Ì' | 'Í' | 'Ỉ' | 'Ĩ' | 'Ị' => 'I',
|
||||||
// o variants
|
// o variants
|
||||||
'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' | 'ô' | 'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ'
|
'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' | 'ô' | 'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ' | 'ơ' | 'ờ' | 'ớ'
|
||||||
| 'ơ' | 'ờ' | 'ớ' | 'ở' | 'ỡ' | 'ợ' => 'o',
|
| 'ở' | 'ỡ' | 'ợ' => 'o',
|
||||||
'Ò' | 'Ó' | 'Ỏ' | 'Õ' | 'Ọ' | 'Ô' | 'Ồ' | 'Ố' | 'Ổ' | 'Ỗ' | 'Ộ'
|
'Ò' | 'Ó' | 'Ỏ' | 'Õ' | 'Ọ' | 'Ô' | 'Ồ' | 'Ố' | 'Ổ' | 'Ỗ' | 'Ộ' | 'Ơ' | 'Ờ' | 'Ớ'
|
||||||
| 'Ơ' | 'Ờ' | 'Ớ' | 'Ở' | 'Ỡ' | 'Ợ' => 'O',
|
| 'Ở' | 'Ỡ' | 'Ợ' => 'O',
|
||||||
// u variants
|
// u variants
|
||||||
'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' | 'ư' | 'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => 'u',
|
'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' | 'ư' | 'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => {
|
||||||
'Ù' | 'Ú' | 'Ủ' | 'Ũ' | 'Ụ' | 'Ư' | 'Ừ' | 'Ứ' | 'Ử' | 'Ữ' | 'Ự' => 'U',
|
'u'
|
||||||
|
}
|
||||||
|
'Ù' | 'Ú' | 'Ủ' | 'Ũ' | 'Ụ' | 'Ư' | 'Ừ' | 'Ứ' | 'Ử' | 'Ữ' | 'Ự' => {
|
||||||
|
'U'
|
||||||
|
}
|
||||||
// y variants
|
// y variants
|
||||||
'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => 'y',
|
'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => 'y',
|
||||||
'Ỳ' | 'Ý' | 'Ỷ' | 'Ỹ' | 'Ỵ' => 'Y',
|
'Ỳ' | 'Ý' | 'Ỷ' | 'Ỹ' | 'Ỵ' => 'Y',
|
||||||
|
|
@ -331,7 +424,10 @@ mod tests {
|
||||||
}
|
}
|
||||||
let event = engine.process_escape();
|
let event = engine.process_escape();
|
||||||
match event {
|
match event {
|
||||||
Some(EngineEvent::UndoTones { backspaces, restored }) => {
|
Some(EngineEvent::UndoTones {
|
||||||
|
backspaces,
|
||||||
|
restored,
|
||||||
|
}) => {
|
||||||
assert_eq!(backspaces, 4); // "chào" is 4 chars
|
assert_eq!(backspaces, 4); // "chào" is 4 chars
|
||||||
assert_eq!(restored, "chao");
|
assert_eq!(restored, "chao");
|
||||||
}
|
}
|
||||||
|
|
@ -346,17 +442,21 @@ mod tests {
|
||||||
engine.add_macro("ok".into(), "được".into());
|
engine.add_macro("ok".into(), "được".into());
|
||||||
|
|
||||||
// Type "ko" + space
|
// Type "ko" + space
|
||||||
let events: Vec<_> = "ko ".chars()
|
let events: Vec<_> = "ko "
|
||||||
|
.chars()
|
||||||
.filter_map(|ch| engine.process_key(ch))
|
.filter_map(|ch| engine.process_key(ch))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Should contain the macro expansion
|
// Should contain the macro expansion
|
||||||
let output: String = events.iter().filter_map(|e| match e {
|
let output: String = events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
EngineEvent::Flush(s) => Some(s.as_str()),
|
EngineEvent::Flush(s) => Some(s.as_str()),
|
||||||
EngineEvent::Insert(s) => Some(s.as_str()),
|
EngineEvent::Insert(s) => Some(s.as_str()),
|
||||||
EngineEvent::Replace { insert, .. } => Some(insert.as_str()),
|
EngineEvent::Replace { insert, .. } => Some(insert.as_str()),
|
||||||
_ => None,
|
_ => None,
|
||||||
}).collect();
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
assert!(output.contains("không"));
|
assert!(output.contains("không"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,46 +15,332 @@ impl EnglishDict {
|
||||||
// These would trigger false Vietnamese conversions
|
// These would trigger false Vietnamese conversions
|
||||||
let common_words = [
|
let common_words = [
|
||||||
// Programming/tech
|
// Programming/tech
|
||||||
"the", "and", "for", "are", "but", "not", "you", "all", "can", "had",
|
"the",
|
||||||
"her", "was", "one", "our", "out", "day", "get", "has", "him", "his",
|
"and",
|
||||||
"how", "its", "may", "new", "now", "old", "see", "way", "who", "did",
|
"for",
|
||||||
"does", "each", "from", "have", "here", "just", "like", "long", "look",
|
"are",
|
||||||
"made", "make", "many", "most", "over", "such", "take", "than", "them",
|
"but",
|
||||||
"then", "that", "this", "time", "very", "when", "what", "will", "with",
|
"not",
|
||||||
"also", "back", "been", "call", "came", "come", "could", "does", "done",
|
"you",
|
||||||
"down", "each", "even", "find", "first", "from", "give", "goes", "going",
|
"all",
|
||||||
"good", "great", "hand", "have", "head", "help", "high", "home", "hope",
|
"can",
|
||||||
"into", "keep", "know", "last", "left", "life", "like", "line", "live",
|
"had",
|
||||||
"look", "made", "make", "many", "mean", "more", "most", "much", "must",
|
"her",
|
||||||
"name", "need", "next", "only", "open", "part", "place", "point", "right",
|
"was",
|
||||||
"same", "said", "second", "should", "show", "small", "some", "something",
|
"one",
|
||||||
"still", "such", "sure", "take", "tell", "than", "that", "them", "then",
|
"our",
|
||||||
"there", "these", "they", "thing", "think", "this", "those", "time",
|
"out",
|
||||||
"turn", "upon", "very", "want", "well", "went", "were", "what", "when",
|
"day",
|
||||||
"where", "which", "while", "will", "with", "work", "would", "year", "your",
|
"get",
|
||||||
|
"has",
|
||||||
|
"him",
|
||||||
|
"his",
|
||||||
|
"how",
|
||||||
|
"its",
|
||||||
|
"may",
|
||||||
|
"new",
|
||||||
|
"now",
|
||||||
|
"old",
|
||||||
|
"see",
|
||||||
|
"way",
|
||||||
|
"who",
|
||||||
|
"did",
|
||||||
|
"does",
|
||||||
|
"each",
|
||||||
|
"from",
|
||||||
|
"have",
|
||||||
|
"here",
|
||||||
|
"just",
|
||||||
|
"like",
|
||||||
|
"long",
|
||||||
|
"look",
|
||||||
|
"made",
|
||||||
|
"make",
|
||||||
|
"many",
|
||||||
|
"most",
|
||||||
|
"over",
|
||||||
|
"such",
|
||||||
|
"take",
|
||||||
|
"than",
|
||||||
|
"them",
|
||||||
|
"then",
|
||||||
|
"that",
|
||||||
|
"this",
|
||||||
|
"time",
|
||||||
|
"very",
|
||||||
|
"when",
|
||||||
|
"what",
|
||||||
|
"will",
|
||||||
|
"with",
|
||||||
|
"also",
|
||||||
|
"back",
|
||||||
|
"been",
|
||||||
|
"call",
|
||||||
|
"came",
|
||||||
|
"come",
|
||||||
|
"could",
|
||||||
|
"does",
|
||||||
|
"done",
|
||||||
|
"down",
|
||||||
|
"each",
|
||||||
|
"even",
|
||||||
|
"find",
|
||||||
|
"first",
|
||||||
|
"from",
|
||||||
|
"give",
|
||||||
|
"goes",
|
||||||
|
"going",
|
||||||
|
"good",
|
||||||
|
"great",
|
||||||
|
"hand",
|
||||||
|
"have",
|
||||||
|
"head",
|
||||||
|
"help",
|
||||||
|
"high",
|
||||||
|
"home",
|
||||||
|
"hope",
|
||||||
|
"into",
|
||||||
|
"keep",
|
||||||
|
"know",
|
||||||
|
"last",
|
||||||
|
"left",
|
||||||
|
"life",
|
||||||
|
"like",
|
||||||
|
"line",
|
||||||
|
"live",
|
||||||
|
"look",
|
||||||
|
"made",
|
||||||
|
"make",
|
||||||
|
"many",
|
||||||
|
"mean",
|
||||||
|
"more",
|
||||||
|
"most",
|
||||||
|
"much",
|
||||||
|
"must",
|
||||||
|
"name",
|
||||||
|
"need",
|
||||||
|
"next",
|
||||||
|
"only",
|
||||||
|
"open",
|
||||||
|
"part",
|
||||||
|
"place",
|
||||||
|
"point",
|
||||||
|
"right",
|
||||||
|
"same",
|
||||||
|
"said",
|
||||||
|
"second",
|
||||||
|
"should",
|
||||||
|
"show",
|
||||||
|
"small",
|
||||||
|
"some",
|
||||||
|
"something",
|
||||||
|
"still",
|
||||||
|
"such",
|
||||||
|
"sure",
|
||||||
|
"take",
|
||||||
|
"tell",
|
||||||
|
"than",
|
||||||
|
"that",
|
||||||
|
"them",
|
||||||
|
"then",
|
||||||
|
"there",
|
||||||
|
"these",
|
||||||
|
"they",
|
||||||
|
"thing",
|
||||||
|
"think",
|
||||||
|
"this",
|
||||||
|
"those",
|
||||||
|
"time",
|
||||||
|
"turn",
|
||||||
|
"upon",
|
||||||
|
"very",
|
||||||
|
"want",
|
||||||
|
"well",
|
||||||
|
"went",
|
||||||
|
"were",
|
||||||
|
"what",
|
||||||
|
"when",
|
||||||
|
"where",
|
||||||
|
"which",
|
||||||
|
"while",
|
||||||
|
"will",
|
||||||
|
"with",
|
||||||
|
"work",
|
||||||
|
"would",
|
||||||
|
"year",
|
||||||
|
"your",
|
||||||
// Common words that conflict with Vietnamese
|
// Common words that conflict with Vietnamese
|
||||||
"ok", "no", "so", "do", "go", "to", "in", "on", "at", "by", "up",
|
"ok",
|
||||||
"an", "as", "be", "he", "if", "is", "it", "me", "my", "of", "or",
|
"no",
|
||||||
"am", "we", "us", "set", "run", "put", "get", "let", "say",
|
"so",
|
||||||
"ask", "try", "use", "add", "end", "few", "far", "got", "big", "off",
|
"do",
|
||||||
"old", "own", "red", "hot", "top", "far", "low", "six", "ten", "red",
|
"go",
|
||||||
|
"to",
|
||||||
|
"in",
|
||||||
|
"on",
|
||||||
|
"at",
|
||||||
|
"by",
|
||||||
|
"up",
|
||||||
|
"an",
|
||||||
|
"as",
|
||||||
|
"be",
|
||||||
|
"he",
|
||||||
|
"if",
|
||||||
|
"is",
|
||||||
|
"it",
|
||||||
|
"me",
|
||||||
|
"my",
|
||||||
|
"of",
|
||||||
|
"or",
|
||||||
|
"am",
|
||||||
|
"we",
|
||||||
|
"us",
|
||||||
|
"set",
|
||||||
|
"run",
|
||||||
|
"put",
|
||||||
|
"get",
|
||||||
|
"let",
|
||||||
|
"say",
|
||||||
|
"ask",
|
||||||
|
"try",
|
||||||
|
"use",
|
||||||
|
"add",
|
||||||
|
"end",
|
||||||
|
"few",
|
||||||
|
"far",
|
||||||
|
"got",
|
||||||
|
"big",
|
||||||
|
"off",
|
||||||
|
"old",
|
||||||
|
"own",
|
||||||
|
"red",
|
||||||
|
"hot",
|
||||||
|
"top",
|
||||||
|
"far",
|
||||||
|
"low",
|
||||||
|
"six",
|
||||||
|
"ten",
|
||||||
|
"red",
|
||||||
// Greetings & common
|
// Greetings & common
|
||||||
"hello", "hi", "hey", "bye", "thanks", "thank", "please", "sorry",
|
"hello",
|
||||||
"yes", "yeah", "no", "ok", "okay", "sure", "well", "too", "also",
|
"hi",
|
||||||
|
"hey",
|
||||||
|
"bye",
|
||||||
|
"thanks",
|
||||||
|
"thank",
|
||||||
|
"please",
|
||||||
|
"sorry",
|
||||||
|
"yes",
|
||||||
|
"yeah",
|
||||||
|
"no",
|
||||||
|
"ok",
|
||||||
|
"okay",
|
||||||
|
"sure",
|
||||||
|
"well",
|
||||||
|
"too",
|
||||||
|
"also",
|
||||||
// More common English
|
// More common English
|
||||||
"about", "after", "again", "being", "below", "between", "both",
|
"about",
|
||||||
"came", "come", "could", "does", "done", "down", "each", "even",
|
"after",
|
||||||
"find", "first", "from", "give", "goes", "going", "good", "great",
|
"again",
|
||||||
"hand", "have", "head", "help", "high", "home", "hope", "into",
|
"being",
|
||||||
"keep", "kind", "know", "last", "left", "life", "like", "line",
|
"below",
|
||||||
"live", "long", "look", "made", "make", "many", "mean", "more",
|
"between",
|
||||||
"most", "much", "must", "name", "need", "next", "only", "open",
|
"both",
|
||||||
"part", "place", "point", "right", "same", "said", "second",
|
"came",
|
||||||
"should", "show", "small", "some", "something", "still", "sure",
|
"come",
|
||||||
"take", "tell", "than", "that", "them", "then", "there", "these",
|
"could",
|
||||||
"they", "thing", "think", "this", "those", "time", "turn", "upon",
|
"does",
|
||||||
"very", "want", "well", "went", "were", "what", "when", "where",
|
"done",
|
||||||
"which", "while", "will", "with", "work", "would", "year", "your",
|
"down",
|
||||||
|
"each",
|
||||||
|
"even",
|
||||||
|
"find",
|
||||||
|
"first",
|
||||||
|
"from",
|
||||||
|
"give",
|
||||||
|
"goes",
|
||||||
|
"going",
|
||||||
|
"good",
|
||||||
|
"great",
|
||||||
|
"hand",
|
||||||
|
"have",
|
||||||
|
"head",
|
||||||
|
"help",
|
||||||
|
"high",
|
||||||
|
"home",
|
||||||
|
"hope",
|
||||||
|
"into",
|
||||||
|
"keep",
|
||||||
|
"kind",
|
||||||
|
"know",
|
||||||
|
"last",
|
||||||
|
"left",
|
||||||
|
"life",
|
||||||
|
"like",
|
||||||
|
"line",
|
||||||
|
"live",
|
||||||
|
"long",
|
||||||
|
"look",
|
||||||
|
"made",
|
||||||
|
"make",
|
||||||
|
"many",
|
||||||
|
"mean",
|
||||||
|
"more",
|
||||||
|
"most",
|
||||||
|
"much",
|
||||||
|
"must",
|
||||||
|
"name",
|
||||||
|
"need",
|
||||||
|
"next",
|
||||||
|
"only",
|
||||||
|
"open",
|
||||||
|
"part",
|
||||||
|
"place",
|
||||||
|
"point",
|
||||||
|
"right",
|
||||||
|
"same",
|
||||||
|
"said",
|
||||||
|
"second",
|
||||||
|
"should",
|
||||||
|
"show",
|
||||||
|
"small",
|
||||||
|
"some",
|
||||||
|
"something",
|
||||||
|
"still",
|
||||||
|
"sure",
|
||||||
|
"take",
|
||||||
|
"tell",
|
||||||
|
"than",
|
||||||
|
"that",
|
||||||
|
"them",
|
||||||
|
"then",
|
||||||
|
"there",
|
||||||
|
"these",
|
||||||
|
"they",
|
||||||
|
"thing",
|
||||||
|
"think",
|
||||||
|
"this",
|
||||||
|
"those",
|
||||||
|
"time",
|
||||||
|
"turn",
|
||||||
|
"upon",
|
||||||
|
"very",
|
||||||
|
"want",
|
||||||
|
"well",
|
||||||
|
"went",
|
||||||
|
"were",
|
||||||
|
"what",
|
||||||
|
"when",
|
||||||
|
"where",
|
||||||
|
"which",
|
||||||
|
"while",
|
||||||
|
"will",
|
||||||
|
"with",
|
||||||
|
"work",
|
||||||
|
"would",
|
||||||
|
"year",
|
||||||
|
"your",
|
||||||
];
|
];
|
||||||
|
|
||||||
for word in common_words {
|
for word in common_words {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
mod engine;
|
mod engine;
|
||||||
|
mod english;
|
||||||
|
mod spelling;
|
||||||
mod telex;
|
mod telex;
|
||||||
mod vni;
|
mod vni;
|
||||||
mod english;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
|
||||||
317
engine/src/spelling.rs
Normal file
317
engine/src/spelling.rs
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
const FIRST_CONSONANT_SEQS: &[&str] = &[
|
||||||
|
"b d đ g gh m n nh p ph r s t tr v z",
|
||||||
|
"c h k kh qu th",
|
||||||
|
"ch gi l ng ngh x",
|
||||||
|
"đ l",
|
||||||
|
"h",
|
||||||
|
];
|
||||||
|
|
||||||
|
const VOWEL_SEQS: &[&str] = &[
|
||||||
|
"ê i ua uê uy y",
|
||||||
|
"a iê oa uyê yê",
|
||||||
|
"â ă e o oo ô ơ oe u ư uâ uô ươ",
|
||||||
|
"oă",
|
||||||
|
"uơ",
|
||||||
|
"ai ao au âu ay ây eo êu ia iêu iu oai oao oay oeo oi ôi ơi ưa uây ui ưi uôi ươi ươu ưu uya uyu yêu",
|
||||||
|
"ă",
|
||||||
|
"i",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LAST_CONSONANT_SEQS: &[&str] = &["ch nh", "c ng", "m n p t", "k", "c"];
|
||||||
|
|
||||||
|
const CV_MATRIX: &[&[usize]] = &[
|
||||||
|
&[0, 1, 2, 5],
|
||||||
|
&[0, 1, 2, 3, 4, 5],
|
||||||
|
&[0, 1, 2, 3, 5],
|
||||||
|
&[6],
|
||||||
|
&[7],
|
||||||
|
];
|
||||||
|
|
||||||
|
const VC_MATRIX: &[&[usize]] = &[&[0, 2], &[0, 1, 2], &[1, 2], &[1, 2], &[], &[], &[3], &[4]];
|
||||||
|
|
||||||
|
fn strip_tone(c: char) -> char {
|
||||||
|
match c {
|
||||||
|
'à' | 'á' | 'ả' | 'ã' | 'ạ' => 'a',
|
||||||
|
'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' => 'ă',
|
||||||
|
'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'â',
|
||||||
|
'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' => 'e',
|
||||||
|
'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => 'ê',
|
||||||
|
'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => 'i',
|
||||||
|
'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' => 'o',
|
||||||
|
'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ' => 'ô',
|
||||||
|
'ờ' | 'ớ' | 'ở' | 'ỡ' | 'ợ' => 'ơ',
|
||||||
|
'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' => 'u',
|
||||||
|
'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => 'ư',
|
||||||
|
'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => 'y',
|
||||||
|
_ => c,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_vowel(c: char) -> bool {
|
||||||
|
matches!(
|
||||||
|
c,
|
||||||
|
'a' | 'à'
|
||||||
|
| 'á'
|
||||||
|
| 'ả'
|
||||||
|
| 'ã'
|
||||||
|
| 'ạ'
|
||||||
|
| 'ă'
|
||||||
|
| 'ằ'
|
||||||
|
| 'ắ'
|
||||||
|
| 'ẳ'
|
||||||
|
| 'ẵ'
|
||||||
|
| 'ặ'
|
||||||
|
| 'â'
|
||||||
|
| 'ầ'
|
||||||
|
| 'ấ'
|
||||||
|
| 'ẩ'
|
||||||
|
| 'ẫ'
|
||||||
|
| 'ậ'
|
||||||
|
| 'e'
|
||||||
|
| 'è'
|
||||||
|
| 'é'
|
||||||
|
| 'ẻ'
|
||||||
|
| 'ẽ'
|
||||||
|
| 'ẹ'
|
||||||
|
| 'ê'
|
||||||
|
| 'ề'
|
||||||
|
| 'ế'
|
||||||
|
| 'ể'
|
||||||
|
| 'ễ'
|
||||||
|
| 'ệ'
|
||||||
|
| 'i'
|
||||||
|
| 'ì'
|
||||||
|
| 'í'
|
||||||
|
| 'ỉ'
|
||||||
|
| 'ĩ'
|
||||||
|
| 'ị'
|
||||||
|
| 'o'
|
||||||
|
| 'ò'
|
||||||
|
| 'ó'
|
||||||
|
| 'ỏ'
|
||||||
|
| 'õ'
|
||||||
|
| 'ọ'
|
||||||
|
| 'ô'
|
||||||
|
| 'ồ'
|
||||||
|
| 'ố'
|
||||||
|
| 'ổ'
|
||||||
|
| 'ỗ'
|
||||||
|
| 'ộ'
|
||||||
|
| 'ơ'
|
||||||
|
| 'ờ'
|
||||||
|
| 'ớ'
|
||||||
|
| 'ở'
|
||||||
|
| 'ỡ'
|
||||||
|
| 'ợ'
|
||||||
|
| 'u'
|
||||||
|
| 'ù'
|
||||||
|
| 'ú'
|
||||||
|
| 'ủ'
|
||||||
|
| 'ũ'
|
||||||
|
| 'ụ'
|
||||||
|
| 'ư'
|
||||||
|
| 'ừ'
|
||||||
|
| 'ứ'
|
||||||
|
| 'ử'
|
||||||
|
| 'ữ'
|
||||||
|
| 'ự'
|
||||||
|
| 'y'
|
||||||
|
| 'ý'
|
||||||
|
| 'ỳ'
|
||||||
|
| 'ỷ'
|
||||||
|
| 'ỹ'
|
||||||
|
| 'ỵ'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Partition a word into (first_consonant, vowel_cluster, last_consonant)
|
||||||
|
pub fn partition(word: &str) -> (String, String, String) {
|
||||||
|
let chars: Vec<char> = word.chars().collect();
|
||||||
|
let n = chars.len();
|
||||||
|
if n == 0 {
|
||||||
|
return (String::new(), String::new(), String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Find the first vowel index
|
||||||
|
let mut first_vowel_idx = None;
|
||||||
|
for i in 0..n {
|
||||||
|
if is_vowel(chars[i]) {
|
||||||
|
first_vowel_idx = Some(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let first_vowel = match first_vowel_idx {
|
||||||
|
Some(idx) => idx,
|
||||||
|
None => {
|
||||||
|
return (word.to_string(), String::new(), String::new());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut fc_end = first_vowel;
|
||||||
|
|
||||||
|
// Adjust fc_end for "qu" or "gi" acting as onset
|
||||||
|
if first_vowel == 1 && chars[0] == 'q' && chars[1] == 'u' && n > 2 && is_vowel(chars[2]) {
|
||||||
|
fc_end = 2;
|
||||||
|
}
|
||||||
|
if first_vowel == 1 && chars[0] == 'g' && chars[1] == 'i' && n > 2 && is_vowel(chars[2]) {
|
||||||
|
fc_end = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Find the end of the vowel cluster
|
||||||
|
let mut vo_end = fc_end;
|
||||||
|
while vo_end < n && is_vowel(chars[vo_end]) {
|
||||||
|
vo_end += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fc: String = chars[..fc_end].iter().collect();
|
||||||
|
let vo: String = chars[fc_end..vo_end].iter().collect();
|
||||||
|
let lc: String = chars[vo_end..].iter().collect();
|
||||||
|
|
||||||
|
(fc, vo, lc)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup(seqs: &[&str], input: &str) -> Vec<usize> {
|
||||||
|
let mut matching_indices = Vec::new();
|
||||||
|
if input.is_empty() {
|
||||||
|
return matching_indices;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, row) in seqs.iter().enumerate() {
|
||||||
|
for word in row.split_whitespace() {
|
||||||
|
if word == input {
|
||||||
|
matching_indices.push(index);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matching_indices
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a word is a valid Vietnamese syllable according to phonology rules
|
||||||
|
pub fn is_valid_vietnamese_syllable(word: &str) -> bool {
|
||||||
|
let lowercase_word = word.to_lowercase();
|
||||||
|
|
||||||
|
// Quick reject if it has foreign letters 'f', 'j', 'w', 'z'
|
||||||
|
if lowercase_word
|
||||||
|
.chars()
|
||||||
|
.any(|c| matches!(c, 'f' | 'j' | 'w' | 'z'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean tones from the word to validate spelling structure
|
||||||
|
let cleaned_word: String = lowercase_word.chars().map(strip_tone).collect();
|
||||||
|
|
||||||
|
let (fc, vo, lc) = partition(&cleaned_word);
|
||||||
|
|
||||||
|
// If there is no vowel, it must be a valid standalone consonant (like "d", "đ", etc.)
|
||||||
|
// but typically a full syllable must have a vowel. Let's allow empty vowel only if it's
|
||||||
|
// a valid first consonant of length 1 or 2 (e.g. for initials/abbreviations).
|
||||||
|
if vo.is_empty() {
|
||||||
|
return !fc.is_empty() && !lookup(FIRST_CONSONANT_SEQS, &fc).is_empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
let fc_indices = if !fc.is_empty() {
|
||||||
|
let indices = lookup(FIRST_CONSONANT_SEQS, &fc);
|
||||||
|
if indices.is_empty() {
|
||||||
|
return false; // Invalid onset consonant
|
||||||
|
}
|
||||||
|
Some(indices)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let vo_indices = lookup(VOWEL_SEQS, &vo);
|
||||||
|
if vo_indices.is_empty() {
|
||||||
|
return false; // Invalid vowel cluster
|
||||||
|
}
|
||||||
|
|
||||||
|
let lc_indices = if !lc.is_empty() {
|
||||||
|
let indices = lookup(LAST_CONSONANT_SEQS, &lc);
|
||||||
|
if indices.is_empty() {
|
||||||
|
return false; // Invalid coda consonant
|
||||||
|
}
|
||||||
|
Some(indices)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// If we have an onset, check CV compatibility
|
||||||
|
if let Some(ref fcs) = fc_indices {
|
||||||
|
let mut cv_valid = false;
|
||||||
|
for &fc_idx in fcs {
|
||||||
|
if let Some(allowed_vos) = CV_MATRIX.get(fc_idx) {
|
||||||
|
for &allowed_vo in *allowed_vos {
|
||||||
|
if vo_indices.contains(&allowed_vo) {
|
||||||
|
cv_valid = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cv_valid {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !cv_valid {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a coda, check VC compatibility
|
||||||
|
if let Some(ref lcs) = lc_indices {
|
||||||
|
let mut vc_valid = false;
|
||||||
|
for &vo_idx in &vo_indices {
|
||||||
|
if let Some(allowed_lcs) = VC_MATRIX.get(vo_idx) {
|
||||||
|
for &allowed_lc in *allowed_lcs {
|
||||||
|
if lcs.contains(&allowed_lc) {
|
||||||
|
vc_valid = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vc_valid {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !vc_valid {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If there's no coda, we must verify that the vowel allows having no coda
|
||||||
|
// (all vowel sequences allow no coda, except some specific ones in matrix, but let's see:
|
||||||
|
// vowel groups 4, 5 have no allowed last consonants in matrix, which is correct).
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_vietnamese_syllables() {
|
||||||
|
assert!(is_valid_vietnamese_syllable("chuyên"));
|
||||||
|
assert!(is_valid_vietnamese_syllable("tiếng"));
|
||||||
|
assert!(is_valid_vietnamese_syllable("việt"));
|
||||||
|
assert!(is_valid_vietnamese_syllable("quang"));
|
||||||
|
assert!(is_valid_vietnamese_syllable("giá"));
|
||||||
|
assert!(is_valid_vietnamese_syllable("oanh"));
|
||||||
|
assert!(is_valid_vietnamese_syllable("anh"));
|
||||||
|
assert!(is_valid_vietnamese_syllable("thuở"));
|
||||||
|
assert!(is_valid_vietnamese_syllable("gì"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_vietnamese_syllables() {
|
||||||
|
assert!(!is_valid_vietnamese_syllable("fast"));
|
||||||
|
assert!(!is_valid_vietnamese_syllable("box"));
|
||||||
|
assert!(!is_valid_vietnamese_syllable("study"));
|
||||||
|
assert!(!is_valid_vietnamese_syllable("fát"));
|
||||||
|
assert!(!is_valid_vietnamese_syllable("făst"));
|
||||||
|
assert!(!is_valid_vietnamese_syllable("cargo"));
|
||||||
|
assert!(!is_valid_vietnamese_syllable("rust"));
|
||||||
|
assert!(!is_valid_vietnamese_syllable("status"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,10 @@
|
||||||
use crate::engine::EngineEvent;
|
use crate::engine::EngineEvent;
|
||||||
|
|
||||||
const VOWELS: &[char] = &[
|
|
||||||
'a', 'e', 'i', 'o', 'u', 'y',
|
|
||||||
'ă', 'â', 'ê', 'ô', 'ơ', 'ư',
|
|
||||||
];
|
|
||||||
|
|
||||||
const VOWEL_ACCENTED: &[char] = &[
|
const VOWEL_ACCENTED: &[char] = &[
|
||||||
'a', 'á', 'à', 'ả', 'ã', 'ạ',
|
'a', 'á', 'à', 'ả', 'ã', 'ạ', 'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ', 'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ', 'e',
|
||||||
'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ',
|
'é', 'è', 'ẻ', 'ẽ', 'ẹ', 'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ', 'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị', 'o', 'ó',
|
||||||
'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ',
|
'ò', 'ỏ', 'õ', 'ọ', 'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ', 'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ', 'u', 'ú', 'ù',
|
||||||
'e', 'é', 'è', 'ẻ', 'ẽ', 'ẹ',
|
'ủ', 'ũ', 'ụ', 'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự', 'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ',
|
||||||
'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ',
|
|
||||||
'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị',
|
|
||||||
'o', 'ó', 'ò', 'ỏ', 'õ', 'ọ',
|
|
||||||
'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ',
|
|
||||||
'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ',
|
|
||||||
'u', 'ú', 'ù', 'ủ', 'ũ', 'ụ',
|
|
||||||
'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự',
|
|
||||||
'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Maximum number of characters to scan backward during flexible placement.
|
/// Maximum number of characters to scan backward during flexible placement.
|
||||||
|
|
@ -34,30 +21,78 @@ fn is_vowel(c: char) -> bool {
|
||||||
/// where base_modified_vowel still has its shape modifier (e.g., 'â', 'ă', 'ô', 'ơ').
|
/// where base_modified_vowel still has its shape modifier (e.g., 'â', 'ă', 'ô', 'ơ').
|
||||||
fn strip_tone(c: char) -> (char, Option<char>) {
|
fn strip_tone(c: char) -> (char, Option<char>) {
|
||||||
match c {
|
match c {
|
||||||
'a' => ('a', None), 'á' => ('a', Some('s')), 'à' => ('a', Some('f')),
|
'a' => ('a', None),
|
||||||
'ả' => ('a', Some('r')), 'ã' => ('a', Some('x')), 'ạ' => ('a', Some('j')),
|
'á' => ('a', Some('s')),
|
||||||
'ă' => ('ă', None), 'ắ' => ('ă', Some('s')), 'ằ' => ('ă', Some('f')),
|
'à' => ('a', Some('f')),
|
||||||
'ẳ' => ('ă', Some('r')), 'ẵ' => ('ă', Some('x')), 'ặ' => ('ă', Some('j')),
|
'ả' => ('a', Some('r')),
|
||||||
'â' => ('â', None), 'ấ' => ('â', Some('s')), 'ầ' => ('â', Some('f')),
|
'ã' => ('a', Some('x')),
|
||||||
'ẩ' => ('â', Some('r')), 'ẫ' => ('â', Some('x')), 'ậ' => ('â', Some('j')),
|
'ạ' => ('a', Some('j')),
|
||||||
'e' => ('e', None), 'é' => ('e', Some('s')), 'è' => ('e', Some('f')),
|
'ă' => ('ă', None),
|
||||||
'ẻ' => ('e', Some('r')), 'ẽ' => ('e', Some('x')), 'ẹ' => ('e', Some('j')),
|
'ắ' => ('ă', Some('s')),
|
||||||
'ê' => ('ê', None), 'ế' => ('ê', Some('s')), 'ề' => ('ê', Some('f')),
|
'ằ' => ('ă', Some('f')),
|
||||||
'ể' => ('ê', Some('r')), 'ễ' => ('ê', Some('x')), 'ệ' => ('ê', Some('j')),
|
'ẳ' => ('ă', Some('r')),
|
||||||
'i' => ('i', None), 'í' => ('i', Some('s')), 'ì' => ('i', Some('f')),
|
'ẵ' => ('ă', Some('x')),
|
||||||
'ỉ' => ('i', Some('r')), 'ĩ' => ('i', Some('x')), 'ị' => ('i', Some('j')),
|
'ặ' => ('ă', Some('j')),
|
||||||
'o' => ('o', None), 'ó' => ('o', Some('s')), 'ò' => ('o', Some('f')),
|
'â' => ('â', None),
|
||||||
'ỏ' => ('o', Some('r')), 'õ' => ('o', Some('x')), 'ọ' => ('o', Some('j')),
|
'ấ' => ('â', Some('s')),
|
||||||
'ô' => ('ô', None), 'ố' => ('ô', Some('s')), 'ồ' => ('ô', Some('f')),
|
'ầ' => ('â', Some('f')),
|
||||||
'ổ' => ('ô', Some('r')), 'ỗ' => ('ô', Some('x')), 'ộ' => ('ô', Some('j')),
|
'ẩ' => ('â', Some('r')),
|
||||||
'ơ' => ('ơ', None), 'ớ' => ('ơ', Some('s')), 'ờ' => ('ơ', Some('f')),
|
'ẫ' => ('â', Some('x')),
|
||||||
'ở' => ('ơ', Some('r')), 'ỡ' => ('ơ', Some('x')), 'ợ' => ('ơ', Some('j')),
|
'ậ' => ('â', Some('j')),
|
||||||
'u' => ('u', None), 'ú' => ('u', Some('s')), 'ù' => ('u', Some('f')),
|
'e' => ('e', None),
|
||||||
'ủ' => ('u', Some('r')), 'ũ' => ('u', Some('x')), 'ụ' => ('u', Some('j')),
|
'é' => ('e', Some('s')),
|
||||||
'ư' => ('ư', None), 'ứ' => ('ư', Some('s')), 'ừ' => ('ư', Some('f')),
|
'è' => ('e', Some('f')),
|
||||||
'ử' => ('ư', Some('r')), 'ữ' => ('ư', Some('x')), 'ự' => ('ư', Some('j')),
|
'ẻ' => ('e', Some('r')),
|
||||||
'y' => ('y', None), 'ý' => ('y', Some('s')), 'ỳ' => ('y', Some('f')),
|
'ẽ' => ('e', Some('x')),
|
||||||
'ỷ' => ('y', Some('r')), 'ỹ' => ('y', Some('x')), 'ỵ' => ('y', Some('j')),
|
'ẹ' => ('e', Some('j')),
|
||||||
|
'ê' => ('ê', None),
|
||||||
|
'ế' => ('ê', Some('s')),
|
||||||
|
'ề' => ('ê', Some('f')),
|
||||||
|
'ể' => ('ê', Some('r')),
|
||||||
|
'ễ' => ('ê', Some('x')),
|
||||||
|
'ệ' => ('ê', Some('j')),
|
||||||
|
'i' => ('i', None),
|
||||||
|
'í' => ('i', Some('s')),
|
||||||
|
'ì' => ('i', Some('f')),
|
||||||
|
'ỉ' => ('i', Some('r')),
|
||||||
|
'ĩ' => ('i', Some('x')),
|
||||||
|
'ị' => ('i', Some('j')),
|
||||||
|
'o' => ('o', None),
|
||||||
|
'ó' => ('o', Some('s')),
|
||||||
|
'ò' => ('o', Some('f')),
|
||||||
|
'ỏ' => ('o', Some('r')),
|
||||||
|
'õ' => ('o', Some('x')),
|
||||||
|
'ọ' => ('o', Some('j')),
|
||||||
|
'ô' => ('ô', None),
|
||||||
|
'ố' => ('ô', Some('s')),
|
||||||
|
'ồ' => ('ô', Some('f')),
|
||||||
|
'ổ' => ('ô', Some('r')),
|
||||||
|
'ỗ' => ('ô', Some('x')),
|
||||||
|
'ộ' => ('ô', Some('j')),
|
||||||
|
'ơ' => ('ơ', None),
|
||||||
|
'ớ' => ('ơ', Some('s')),
|
||||||
|
'ờ' => ('ơ', Some('f')),
|
||||||
|
'ở' => ('ơ', Some('r')),
|
||||||
|
'ỡ' => ('ơ', Some('x')),
|
||||||
|
'ợ' => ('ơ', Some('j')),
|
||||||
|
'u' => ('u', None),
|
||||||
|
'ú' => ('u', Some('s')),
|
||||||
|
'ù' => ('u', Some('f')),
|
||||||
|
'ủ' => ('u', Some('r')),
|
||||||
|
'ũ' => ('u', Some('x')),
|
||||||
|
'ụ' => ('u', Some('j')),
|
||||||
|
'ư' => ('ư', None),
|
||||||
|
'ứ' => ('ư', Some('s')),
|
||||||
|
'ừ' => ('ư', Some('f')),
|
||||||
|
'ử' => ('ư', Some('r')),
|
||||||
|
'ữ' => ('ư', Some('x')),
|
||||||
|
'ự' => ('ư', Some('j')),
|
||||||
|
'y' => ('y', None),
|
||||||
|
'ý' => ('y', Some('s')),
|
||||||
|
'ỳ' => ('y', Some('f')),
|
||||||
|
'ỷ' => ('y', Some('r')),
|
||||||
|
'ỹ' => ('y', Some('x')),
|
||||||
|
'ỵ' => ('y', Some('j')),
|
||||||
_ => (c, None),
|
_ => (c, None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -65,18 +100,66 @@ fn strip_tone(c: char) -> (char, Option<char>) {
|
||||||
fn apply_tone_to_vowel(vowel: char, tone: char) -> Option<char> {
|
fn apply_tone_to_vowel(vowel: char, tone: char) -> Option<char> {
|
||||||
// Standard Telex: f=huyền, s=sắc, r=hỏi, x=ngã, j=nặng
|
// Standard Telex: f=huyền, s=sắc, r=hỏi, x=ngã, j=nặng
|
||||||
let table: &[(char, char, char)] = &[
|
let table: &[(char, char, char)] = &[
|
||||||
('a', 'f', 'à'), ('a', 's', 'á'), ('a', 'r', 'ả'), ('a', 'x', 'ã'), ('a', 'j', 'ạ'),
|
('a', 'f', 'à'),
|
||||||
('ă', 'f', 'ằ'), ('ă', 's', 'ắ'), ('ă', 'r', 'ẳ'), ('ă', 'x', 'ẵ'), ('ă', 'j', 'ặ'),
|
('a', 's', 'á'),
|
||||||
('â', 'f', 'ầ'), ('â', 's', 'ấ'), ('â', 'r', 'ẩ'), ('â', 'x', 'ẫ'), ('â', 'j', 'ậ'),
|
('a', 'r', 'ả'),
|
||||||
('e', 'f', 'è'), ('e', 's', 'é'), ('e', 'r', 'ẻ'), ('e', 'x', 'ẽ'), ('e', 'j', 'ẹ'),
|
('a', 'x', 'ã'),
|
||||||
('ê', 'f', 'ề'), ('ê', 's', 'ế'), ('ê', 'r', 'ể'), ('ê', 'x', 'ễ'), ('ê', 'j', 'ệ'),
|
('a', 'j', 'ạ'),
|
||||||
('i', 'f', 'ì'), ('i', 's', 'í'), ('i', 'r', 'ỉ'), ('i', 'x', 'ĩ'), ('i', 'j', 'ị'),
|
('ă', 'f', 'ằ'),
|
||||||
('o', 'f', 'ò'), ('o', 's', 'ó'), ('o', 'r', 'ỏ'), ('o', 'x', 'õ'), ('o', 'j', 'ọ'),
|
('ă', 's', 'ắ'),
|
||||||
('ô', 'f', 'ồ'), ('ô', 's', 'ố'), ('ô', 'r', 'ổ'), ('ô', 'x', 'ỗ'), ('ô', 'j', 'ộ'),
|
('ă', 'r', 'ẳ'),
|
||||||
('ơ', 'f', 'ờ'), ('ơ', 's', 'ớ'), ('ơ', 'r', 'ở'), ('ơ', 'x', 'ỡ'), ('ơ', 'j', 'ợ'),
|
('ă', 'x', 'ẵ'),
|
||||||
('u', 'f', 'ù'), ('u', 's', 'ú'), ('u', 'r', 'ủ'), ('u', 'x', 'ũ'), ('u', 'j', 'ụ'),
|
('ă', 'j', 'ặ'),
|
||||||
('ư', 'f', 'ừ'), ('ư', 's', 'ứ'), ('ư', 'r', 'ử'), ('ư', 'x', 'ữ'), ('ư', 'j', 'ự'),
|
('â', 'f', 'ầ'),
|
||||||
('y', 'f', 'ỳ'), ('y', 's', 'ý'), ('y', 'r', 'ỷ'), ('y', 'x', 'ỹ'), ('y', 'j', 'ỵ'),
|
('â', 's', 'ấ'),
|
||||||
|
('â', 'r', 'ẩ'),
|
||||||
|
('â', 'x', 'ẫ'),
|
||||||
|
('â', 'j', 'ậ'),
|
||||||
|
('e', 'f', 'è'),
|
||||||
|
('e', 's', 'é'),
|
||||||
|
('e', 'r', 'ẻ'),
|
||||||
|
('e', 'x', 'ẽ'),
|
||||||
|
('e', 'j', 'ẹ'),
|
||||||
|
('ê', 'f', 'ề'),
|
||||||
|
('ê', 's', 'ế'),
|
||||||
|
('ê', 'r', 'ể'),
|
||||||
|
('ê', 'x', 'ễ'),
|
||||||
|
('ê', 'j', 'ệ'),
|
||||||
|
('i', 'f', 'ì'),
|
||||||
|
('i', 's', 'í'),
|
||||||
|
('i', 'r', 'ỉ'),
|
||||||
|
('i', 'x', 'ĩ'),
|
||||||
|
('i', 'j', 'ị'),
|
||||||
|
('o', 'f', 'ò'),
|
||||||
|
('o', 's', 'ó'),
|
||||||
|
('o', 'r', 'ỏ'),
|
||||||
|
('o', 'x', 'õ'),
|
||||||
|
('o', 'j', 'ọ'),
|
||||||
|
('ô', 'f', 'ồ'),
|
||||||
|
('ô', 's', 'ố'),
|
||||||
|
('ô', 'r', 'ổ'),
|
||||||
|
('ô', 'x', 'ỗ'),
|
||||||
|
('ô', 'j', 'ộ'),
|
||||||
|
('ơ', 'f', 'ờ'),
|
||||||
|
('ơ', 's', 'ớ'),
|
||||||
|
('ơ', 'r', 'ở'),
|
||||||
|
('ơ', 'x', 'ỡ'),
|
||||||
|
('ơ', 'j', 'ợ'),
|
||||||
|
('u', 'f', 'ù'),
|
||||||
|
('u', 's', 'ú'),
|
||||||
|
('u', 'r', 'ủ'),
|
||||||
|
('u', 'x', 'ũ'),
|
||||||
|
('u', 'j', 'ụ'),
|
||||||
|
('ư', 'f', 'ừ'),
|
||||||
|
('ư', 's', 'ứ'),
|
||||||
|
('ư', 'r', 'ử'),
|
||||||
|
('ư', 'x', 'ữ'),
|
||||||
|
('ư', 'j', 'ự'),
|
||||||
|
('y', 'f', 'ỳ'),
|
||||||
|
('y', 's', 'ý'),
|
||||||
|
('y', 'r', 'ỷ'),
|
||||||
|
('y', 'x', 'ỹ'),
|
||||||
|
('y', 'j', 'ỵ'),
|
||||||
];
|
];
|
||||||
|
|
||||||
for &(v, t, result) in table {
|
for &(v, t, result) in table {
|
||||||
|
|
@ -116,7 +199,6 @@ fn override_telex_modifier(vowel: char, key: char) -> Option<char> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn apply_w_to_vowel(vowel: char) -> Option<char> {
|
fn apply_w_to_vowel(vowel: char) -> Option<char> {
|
||||||
// Telex: aw=ă, ow=ơ, ew=ê, uw=ư
|
// Telex: aw=ă, ow=ơ, ew=ê, uw=ư
|
||||||
// (aa=â, ee=ê, oo=ô are handled by double-letter logic)
|
// (aa=â, ee=ê, oo=ô are handled by double-letter logic)
|
||||||
|
|
@ -144,11 +226,21 @@ fn is_o_vowel(c: char) -> bool {
|
||||||
fn tone_of_vowel(c: char) -> Option<char> {
|
fn tone_of_vowel(c: char) -> Option<char> {
|
||||||
match c {
|
match c {
|
||||||
'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None,
|
'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None,
|
||||||
'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => Some('f'),
|
'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => {
|
||||||
'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => Some('s'),
|
Some('f')
|
||||||
'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => Some('r'),
|
}
|
||||||
'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => Some('x'),
|
'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => {
|
||||||
'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => Some('j'),
|
Some('s')
|
||||||
|
}
|
||||||
|
'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => {
|
||||||
|
Some('r')
|
||||||
|
}
|
||||||
|
'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => {
|
||||||
|
Some('x')
|
||||||
|
}
|
||||||
|
'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => {
|
||||||
|
Some('j')
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -181,7 +273,6 @@ fn is_q_before_u(chars: &[char], i: usize) -> bool {
|
||||||
i > 1 && chars[i - 2] == 'q'
|
i > 1 && chars[i - 2] == 'q'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub struct TelexEngine {
|
pub struct TelexEngine {
|
||||||
buffer: String,
|
buffer: String,
|
||||||
pending_modifier: Option<char>,
|
pending_modifier: Option<char>,
|
||||||
|
|
@ -292,10 +383,15 @@ impl TelexEngine {
|
||||||
// For oa, oe, uâ, uê, uơ, uy, iê, yê → tone on second vowel
|
// For oa, oe, uâ, uê, uơ, uy, iê, yê → tone on second vowel
|
||||||
let tone_on_second = matches!(
|
let tone_on_second = matches!(
|
||||||
(first, second),
|
(first, second),
|
||||||
('o', 'a') | ('o', 'e')
|
('o', 'a')
|
||||||
| ('u', 'â') | ('u', 'ê') | ('u', 'ơ') | ('u', 'y')
|
| ('o', 'e')
|
||||||
|
| ('u', 'â')
|
||||||
|
| ('u', 'ê')
|
||||||
|
| ('u', 'ơ')
|
||||||
|
| ('u', 'y')
|
||||||
| ('ư', 'ơ')
|
| ('ư', 'ơ')
|
||||||
| ('i', 'ê') | ('y', 'ê')
|
| ('i', 'ê')
|
||||||
|
| ('y', 'ê')
|
||||||
);
|
);
|
||||||
if !tone_on_second {
|
if !tone_on_second {
|
||||||
// Apply tone to first vowel
|
// Apply tone to first vowel
|
||||||
|
|
@ -451,7 +547,10 @@ impl TelexEngine {
|
||||||
if is_o_vowel(last_ch) {
|
if is_o_vowel(last_ch) {
|
||||||
// Smart cluster "uo" → "ươ"
|
// Smart cluster "uo" → "ươ"
|
||||||
let mut chars: Vec<char> = self.buffer.chars().collect();
|
let mut chars: Vec<char> = self.buffer.chars().collect();
|
||||||
if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) {
|
if chars.len() >= 2
|
||||||
|
&& is_u_vowel(chars[chars.len() - 2])
|
||||||
|
&& !is_q_before_u(&chars, chars.len() - 1)
|
||||||
|
{
|
||||||
let o_char = chars.pop().unwrap();
|
let o_char = chars.pop().unwrap();
|
||||||
let u_char = chars.pop().unwrap();
|
let u_char = chars.pop().unwrap();
|
||||||
let (new_first, new_second) = uo_to_uơ(u_char, o_char);
|
let (new_first, new_second) = uo_to_uơ(u_char, o_char);
|
||||||
|
|
@ -471,7 +570,10 @@ impl TelexEngine {
|
||||||
let strip = strip_tone(last_ch);
|
let strip = strip_tone(last_ch);
|
||||||
if strip.0 == 'ô' || strip.0 == 'ơ' {
|
if strip.0 == 'ô' || strip.0 == 'ơ' {
|
||||||
let mut chars: Vec<char> = self.buffer.chars().collect();
|
let mut chars: Vec<char> = self.buffer.chars().collect();
|
||||||
if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) {
|
if chars.len() >= 2
|
||||||
|
&& is_u_vowel(chars[chars.len() - 2])
|
||||||
|
&& !is_q_before_u(&chars, chars.len() - 1)
|
||||||
|
{
|
||||||
let o_char = chars.pop().unwrap();
|
let o_char = chars.pop().unwrap();
|
||||||
let u_char = chars.pop().unwrap();
|
let u_char = chars.pop().unwrap();
|
||||||
let (new_first, new_second) = uo_to_uơ(u_char, o_char);
|
let (new_first, new_second) = uo_to_uơ(u_char, o_char);
|
||||||
|
|
@ -499,7 +601,11 @@ impl TelexEngine {
|
||||||
for i in (start..chars.len()).rev() {
|
for i in (start..chars.len()).rev() {
|
||||||
if is_vowel(chars[i]) {
|
if is_vowel(chars[i]) {
|
||||||
// Smart cluster "uo" → "ươ" (flexible)
|
// Smart cluster "uo" → "ươ" (flexible)
|
||||||
if is_o_vowel(chars[i]) && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) {
|
if is_o_vowel(chars[i])
|
||||||
|
&& i > 0
|
||||||
|
&& is_u_vowel(chars[i - 1])
|
||||||
|
&& !is_q_before_u(&chars, i)
|
||||||
|
{
|
||||||
let (new_first, new_second) = uo_to_uơ(chars[i - 1], chars[i]);
|
let (new_first, new_second) = uo_to_uơ(chars[i - 1], chars[i]);
|
||||||
self.buffer = chars[..i - 1].iter().collect::<String>();
|
self.buffer = chars[..i - 1].iter().collect::<String>();
|
||||||
self.buffer.push(new_first);
|
self.buffer.push(new_first);
|
||||||
|
|
@ -580,4 +686,3 @@ impl TelexEngine {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ mod tests {
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
for ch in input.chars() {
|
for ch in input.chars() {
|
||||||
if ch == '\x08' {
|
if ch == '\x08' {
|
||||||
events.push(EngineEvent::Replace { backspaces: 1, insert: String::new() });
|
events.push(EngineEvent::Replace {
|
||||||
|
backspaces: 1,
|
||||||
|
insert: String::new(),
|
||||||
|
});
|
||||||
let _ = engine.process_key(ch);
|
let _ = engine.process_key(ch);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -26,7 +29,7 @@ mod tests {
|
||||||
let mut output = String::new();
|
let mut output = String::new();
|
||||||
for ev in events {
|
for ev in events {
|
||||||
match ev {
|
match ev {
|
||||||
EngineEvent::Flush(text) | EngineEvent::Insert(text) => {
|
EngineEvent::Flush(text) | EngineEvent::Insert(text) | EngineEvent::Paste(text) => {
|
||||||
output.push_str(text);
|
output.push_str(text);
|
||||||
}
|
}
|
||||||
EngineEvent::Replace { backspaces, insert } => {
|
EngineEvent::Replace { backspaces, insert } => {
|
||||||
|
|
@ -41,7 +44,10 @@ mod tests {
|
||||||
}
|
}
|
||||||
output.push_str(word);
|
output.push_str(word);
|
||||||
}
|
}
|
||||||
EngineEvent::UndoTones { backspaces, restored } => {
|
EngineEvent::UndoTones {
|
||||||
|
backspaces,
|
||||||
|
restored,
|
||||||
|
} => {
|
||||||
for _ in 0..*backspaces {
|
for _ in 0..*backspaces {
|
||||||
output.push('\x08');
|
output.push('\x08');
|
||||||
}
|
}
|
||||||
|
|
@ -56,7 +62,7 @@ mod tests {
|
||||||
let mut display = String::new();
|
let mut display = String::new();
|
||||||
for ev in events {
|
for ev in events {
|
||||||
match ev {
|
match ev {
|
||||||
EngineEvent::Flush(text) => {
|
EngineEvent::Flush(text) | EngineEvent::Paste(text) => {
|
||||||
if !display.ends_with(text) {
|
if !display.ends_with(text) {
|
||||||
display.push_str(text);
|
display.push_str(text);
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +82,10 @@ mod tests {
|
||||||
}
|
}
|
||||||
display.push_str(word);
|
display.push_str(word);
|
||||||
}
|
}
|
||||||
EngineEvent::UndoTones { backspaces, restored } => {
|
EngineEvent::UndoTones {
|
||||||
|
backspaces,
|
||||||
|
restored,
|
||||||
|
} => {
|
||||||
for _ in 0..*backspaces {
|
for _ in 0..*backspaces {
|
||||||
display.pop();
|
display.pop();
|
||||||
}
|
}
|
||||||
|
|
@ -972,7 +981,10 @@ mod tests {
|
||||||
e.process_key('s');
|
e.process_key('s');
|
||||||
let event = e.process_escape();
|
let event = e.process_escape();
|
||||||
match event {
|
match event {
|
||||||
Some(EngineEvent::UndoTones { backspaces, restored }) => {
|
Some(EngineEvent::UndoTones {
|
||||||
|
backspaces,
|
||||||
|
restored,
|
||||||
|
}) => {
|
||||||
assert_eq!(backspaces, 1);
|
assert_eq!(backspaces, 1);
|
||||||
assert_eq!(restored, "a");
|
assert_eq!(restored, "a");
|
||||||
}
|
}
|
||||||
|
|
@ -988,7 +1000,10 @@ mod tests {
|
||||||
}
|
}
|
||||||
let event = e.process_escape();
|
let event = e.process_escape();
|
||||||
match event {
|
match event {
|
||||||
Some(EngineEvent::UndoTones { backspaces, restored }) => {
|
Some(EngineEvent::UndoTones {
|
||||||
|
backspaces,
|
||||||
|
restored,
|
||||||
|
}) => {
|
||||||
assert_eq!(backspaces, 4);
|
assert_eq!(backspaces, 4);
|
||||||
assert_eq!(restored, "chao");
|
assert_eq!(restored, "chao");
|
||||||
}
|
}
|
||||||
|
|
@ -1113,7 +1128,10 @@ mod tests {
|
||||||
fn macro_long_expansion() {
|
fn macro_long_expansion() {
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
e.add_macro("bhg".into(), "bài họcгруппа".into());
|
e.add_macro("bhg".into(), "bài họcгруппа".into());
|
||||||
assert_eq!(get_display(&process_input(&mut e, "bhg ")), "bài họcгруппа ");
|
assert_eq!(
|
||||||
|
get_display(&process_input(&mut e, "bhg ")),
|
||||||
|
"bài họcгруппа "
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1129,7 +1147,10 @@ mod tests {
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
e.add_macro("vs".into(), "với".into());
|
e.add_macro("vs".into(), "với".into());
|
||||||
// "vs" expands, then "hello" is English
|
// "vs" expands, then "hello" is English
|
||||||
assert_eq!(get_display(&process_input(&mut e, "vs hello ")), "với hello ");
|
assert_eq!(
|
||||||
|
get_display(&process_input(&mut e, "vs hello ")),
|
||||||
|
"với hello "
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
|
|
@ -1212,10 +1233,13 @@ mod tests {
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
let events = process_input(&mut e, "was ");
|
let events = process_input(&mut e, "was ");
|
||||||
// Verify auto-restore produces correct backspace counts
|
// Verify auto-restore produces correct backspace counts
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
||||||
assert_eq!(replace_events.len(), 3);
|
assert_eq!(replace_events.len(), 3);
|
||||||
// w-pending: backspace 1 (delete 'w' from screen)
|
// w-pending: backspace 1 (delete 'w' from screen)
|
||||||
assert_eq!(replace_events[0], (1, "".to_string()));
|
assert_eq!(replace_events[0], (1, "".to_string()));
|
||||||
|
|
@ -1415,10 +1439,13 @@ mod tests {
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
let events = process_input(&mut e, "as");
|
let events = process_input(&mut e, "as");
|
||||||
// Find the Replace event
|
// Find the Replace event
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
||||||
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event for 'as'");
|
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event for 'as'");
|
||||||
assert_eq!(replace_events[0], (2, "á".to_string()));
|
assert_eq!(replace_events[0], (2, "á".to_string()));
|
||||||
assert_eq!(get_display(&events), "á");
|
assert_eq!(get_display(&events), "á");
|
||||||
|
|
@ -1428,10 +1455,13 @@ mod tests {
|
||||||
fn backspace_count_double_letter() {
|
fn backspace_count_double_letter() {
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
let events = process_input(&mut e, "aa");
|
let events = process_input(&mut e, "aa");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
||||||
assert_eq!(replace_events.len(), 1);
|
assert_eq!(replace_events.len(), 1);
|
||||||
assert_eq!(replace_events[0], (2, "â".to_string()));
|
assert_eq!(replace_events[0], (2, "â".to_string()));
|
||||||
assert_eq!(get_display(&events), "â");
|
assert_eq!(get_display(&events), "â");
|
||||||
|
|
@ -1441,10 +1471,13 @@ mod tests {
|
||||||
fn backspace_count_w_modifier() {
|
fn backspace_count_w_modifier() {
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
let events = process_input(&mut e, "aw");
|
let events = process_input(&mut e, "aw");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
||||||
assert_eq!(replace_events.len(), 1);
|
assert_eq!(replace_events.len(), 1);
|
||||||
assert_eq!(replace_events[0], (2, "ă".to_string()));
|
assert_eq!(replace_events[0], (2, "ă".to_string()));
|
||||||
assert_eq!(get_display(&events), "ă");
|
assert_eq!(get_display(&events), "ă");
|
||||||
|
|
@ -1454,12 +1487,20 @@ mod tests {
|
||||||
fn backspace_count_w_modifier_then_tone() {
|
fn backspace_count_w_modifier_then_tone() {
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
let events = process_input(&mut e, "aws");
|
let events = process_input(&mut e, "aws");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
||||||
// "aw" → Replace {2, "ă"}, then "s" → Replace {2, "ắ"}
|
// "aw" → Replace {2, "ă"}, then "s" → Replace {2, "ắ"}
|
||||||
assert_eq!(replace_events.len(), 2, "Expected 2 Replace events: {:?}", replace_events);
|
assert_eq!(
|
||||||
|
replace_events.len(),
|
||||||
|
2,
|
||||||
|
"Expected 2 Replace events: {:?}",
|
||||||
|
replace_events
|
||||||
|
);
|
||||||
assert_eq!(replace_events[0], (2, "ă".to_string()));
|
assert_eq!(replace_events[0], (2, "ă".to_string()));
|
||||||
assert_eq!(replace_events[1], (2, "ắ".to_string()));
|
assert_eq!(replace_events[1], (2, "ắ".to_string()));
|
||||||
assert_eq!(get_display(&events), "ắ");
|
assert_eq!(get_display(&events), "ắ");
|
||||||
|
|
@ -1469,12 +1510,20 @@ mod tests {
|
||||||
fn backspace_count_compound_vowel_tone() {
|
fn backspace_count_compound_vowel_tone() {
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
let events = process_input(&mut e, "oas");
|
let events = process_input(&mut e, "oas");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
||||||
// "oas" → tone on second vowel: Replace {3, "oá"}
|
// "oas" → tone on second vowel: Replace {3, "oá"}
|
||||||
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event: {:?}", replace_events);
|
assert_eq!(
|
||||||
|
replace_events.len(),
|
||||||
|
1,
|
||||||
|
"Expected 1 Replace event: {:?}",
|
||||||
|
replace_events
|
||||||
|
);
|
||||||
assert_eq!(replace_events[0], (3, "oá".to_string()));
|
assert_eq!(replace_events[0], (3, "oá".to_string()));
|
||||||
assert_eq!(get_display(&events), "oá");
|
assert_eq!(get_display(&events), "oá");
|
||||||
}
|
}
|
||||||
|
|
@ -1483,12 +1532,20 @@ mod tests {
|
||||||
fn backspace_count_compound_vowel_uy_tone() {
|
fn backspace_count_compound_vowel_uy_tone() {
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
let events = process_input(&mut e, "uys");
|
let events = process_input(&mut e, "uys");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
||||||
// "uys" → tone on first vowel: Replace {3, "uý"}
|
// "uys" → tone on first vowel: Replace {3, "uý"}
|
||||||
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event: {:?}", replace_events);
|
assert_eq!(
|
||||||
|
replace_events.len(),
|
||||||
|
1,
|
||||||
|
"Expected 1 Replace event: {:?}",
|
||||||
|
replace_events
|
||||||
|
);
|
||||||
assert_eq!(replace_events[0], (3, "uý".to_string()));
|
assert_eq!(replace_events[0], (3, "uý".to_string()));
|
||||||
assert_eq!(get_display(&events), "uý");
|
assert_eq!(get_display(&events), "uý");
|
||||||
}
|
}
|
||||||
|
|
@ -1498,15 +1555,23 @@ mod tests {
|
||||||
// "bs" → no vowel, 's' is appended as text
|
// "bs" → no vowel, 's' is appended as text
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
let events = process_input(&mut e, "bs");
|
let events = process_input(&mut e, "bs");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|ev| match ev {
|
||||||
EngineEvent::Replace { backspaces, .. } => Some(backspaces),
|
EngineEvent::Replace { backspaces, .. } => Some(backspaces),
|
||||||
_ => None,
|
_ => None,
|
||||||
}).collect();
|
})
|
||||||
|
.collect();
|
||||||
// 's' after consonant 'b': no vowel found, 's' appended to buffer
|
// 's' after consonant 'b': no vowel found, 's' appended to buffer
|
||||||
// But s is a tone key, and process_tone is called...
|
// But s is a tone key, and process_tone is called...
|
||||||
// In process_tone: buffer "b", chars=['b'], no vowel found → buffer.push('s') → "bs"
|
// In process_tone: buffer "b", chars=['b'], no vowel found → buffer.push('s') → "bs"
|
||||||
// new_inner = "bs", expected = "b"+"s" = "bs" → same → None
|
// new_inner = "bs", expected = "b"+"s" = "bs" → same → None
|
||||||
assert_eq!(replace_events.len(), 0, "Expected no Replace events, got: {:?}", replace_events);
|
assert_eq!(
|
||||||
|
replace_events.len(),
|
||||||
|
0,
|
||||||
|
"Expected no Replace events, got: {:?}",
|
||||||
|
replace_events
|
||||||
|
);
|
||||||
assert_eq!(get_display(&events), "bs");
|
assert_eq!(get_display(&events), "bs");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1517,15 +1582,23 @@ mod tests {
|
||||||
// Then space triggers auto-restore back to "was "
|
// Then space triggers auto-restore back to "was "
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
let events = process_input(&mut e, "was ");
|
let events = process_input(&mut e, "was ");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
||||||
// Expected events for "was ":
|
// Expected events for "was ":
|
||||||
// 'w': pending modifier, no buffer change → Replace {1, ""} (blink)
|
// 'w': pending modifier, no buffer change → Replace {1, ""} (blink)
|
||||||
// 's': tone on 'a' → Replace {2, "á"}
|
// 's': tone on 'a' → Replace {2, "á"}
|
||||||
// ' ': auto-restore → Replace {2, "was "}
|
// ' ': auto-restore → Replace {2, "was "}
|
||||||
assert_eq!(replace_events.len(), 3, "Expected 3 Replace events, got: {:?}", replace_events);
|
assert_eq!(
|
||||||
|
replace_events.len(),
|
||||||
|
3,
|
||||||
|
"Expected 3 Replace events, got: {:?}",
|
||||||
|
replace_events
|
||||||
|
);
|
||||||
// Event 0: 'w' blinks (gets deleted as pending modifier)
|
// Event 0: 'w' blinks (gets deleted as pending modifier)
|
||||||
assert_eq!(replace_events[0].0, 1, "w-pending backspace");
|
assert_eq!(replace_events[0].0, 1, "w-pending backspace");
|
||||||
assert_eq!(replace_events[0].1, "");
|
assert_eq!(replace_events[0].1, "");
|
||||||
|
|
@ -1545,13 +1618,20 @@ mod tests {
|
||||||
// "hello " → no conversion needed, should_restore("hello") → true, no diacritics → None
|
// "hello " → no conversion needed, should_restore("hello") → true, no diacritics → None
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
let events = process_input(&mut e, "hello ");
|
let events = process_input(&mut e, "hello ");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|ev| match ev {
|
||||||
EngineEvent::Replace { backspaces, .. } => Some(backspaces),
|
EngineEvent::Replace { backspaces, .. } => Some(backspaces),
|
||||||
_ => None,
|
_ => None,
|
||||||
}).collect();
|
})
|
||||||
|
.collect();
|
||||||
// "hello" has no Vietnamese conversion, should_restore returns true
|
// "hello" has no Vietnamese conversion, should_restore returns true
|
||||||
// has_diacritics = false → returns None in auto-restore path
|
// has_diacritics = false → returns None in auto-restore path
|
||||||
assert_eq!(replace_events.len(), 0, "No Replace events for plain English");
|
assert_eq!(
|
||||||
|
replace_events.len(),
|
||||||
|
0,
|
||||||
|
"No Replace events for plain English"
|
||||||
|
);
|
||||||
assert_eq!(get_display(&events), "hello ");
|
assert_eq!(get_display(&events), "hello ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1560,13 +1640,20 @@ mod tests {
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
e.add_macro("ko".into(), "không".into());
|
e.add_macro("ko".into(), "không".into());
|
||||||
let events = process_input(&mut e, "ko ");
|
let events = process_input(&mut e, "ko ");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
||||||
// "ko " → macro expansion: raw_buffer="ko", Replace { 3, "không " }
|
// "ko " → macro expansion: raw_buffer="ko", Replace { 3, "không " }
|
||||||
// backspaces = raw_buffer.len + 1 = 2 + 1 = 3
|
// backspaces = raw_buffer.len + 1 = 2 + 1 = 3
|
||||||
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event for macro");
|
assert_eq!(
|
||||||
|
replace_events.len(),
|
||||||
|
1,
|
||||||
|
"Expected 1 Replace event for macro"
|
||||||
|
);
|
||||||
assert_eq!(replace_events[0].0, 3, "macro backspace count");
|
assert_eq!(replace_events[0].0, 3, "macro backspace count");
|
||||||
assert_eq!(replace_events[0].1, "không ");
|
assert_eq!(replace_events[0].1, "không ");
|
||||||
assert_eq!(get_display(&events), "không ");
|
assert_eq!(get_display(&events), "không ");
|
||||||
|
|
@ -1577,17 +1664,25 @@ mod tests {
|
||||||
// "chof " → 'f' is pending after 'o' on "cho", space flushes → "chò "
|
// "chof " → 'f' is pending after 'o' on "cho", space flushes → "chò "
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
let events = process_input(&mut e, "chof ");
|
let events = process_input(&mut e, "chof ");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
||||||
// "chof":
|
// "chof":
|
||||||
// 'c' → no event
|
// 'c' → no event
|
||||||
// 'h' → no event
|
// 'h' → no event
|
||||||
// 'o' → no event
|
// 'o' → no event
|
||||||
// 'f' → process_tone on 'o' → Replace { 4, "chò" } (prev_inner="cho", expected="chof")
|
// 'f' → process_tone on 'o' → Replace { 4, "chò" } (prev_inner="cho", expected="chof")
|
||||||
// ' ' → flush with space, final_word="chò" == previous_inner="chò" → None
|
// ' ' → flush with space, final_word="chò" == previous_inner="chò" → None
|
||||||
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event: {:?}", replace_events);
|
assert_eq!(
|
||||||
|
replace_events.len(),
|
||||||
|
1,
|
||||||
|
"Expected 1 Replace event: {:?}",
|
||||||
|
replace_events
|
||||||
|
);
|
||||||
assert_eq!(replace_events[0].0, 4, "chof→chò backspace");
|
assert_eq!(replace_events[0].0, 4, "chof→chò backspace");
|
||||||
assert_eq!(replace_events[0].1, "chò");
|
assert_eq!(replace_events[0].1, "chò");
|
||||||
assert_eq!(get_display(&events), "chò ");
|
assert_eq!(get_display(&events), "chò ");
|
||||||
|
|
@ -1601,7 +1696,10 @@ mod tests {
|
||||||
}
|
}
|
||||||
let event = e.process_escape();
|
let event = e.process_escape();
|
||||||
match event {
|
match event {
|
||||||
Some(EngineEvent::UndoTones { backspaces, restored }) => {
|
Some(EngineEvent::UndoTones {
|
||||||
|
backspaces,
|
||||||
|
restored,
|
||||||
|
}) => {
|
||||||
assert_eq!(backspaces, 4, "ESC undo should backspace 4 chars (chào)");
|
assert_eq!(backspaces, 4, "ESC undo should backspace 4 chars (chào)");
|
||||||
assert_eq!(restored, "chao");
|
assert_eq!(restored, "chao");
|
||||||
}
|
}
|
||||||
|
|
@ -1618,17 +1716,33 @@ mod tests {
|
||||||
e.process_key('s'); // buffer = "á"
|
e.process_key('s'); // buffer = "á"
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
events.push(EngineEvent::Insert(" ".to_string()));
|
events.push(EngineEvent::Insert(" ".to_string()));
|
||||||
if let Some(ev) = e.process_key('\x08') { events.push(ev); } // backspace → buffer ""
|
if let Some(ev) = e.process_key('\x08') {
|
||||||
if let Some(ev) = e.process_key('a') { events.push(ev); } // buffer "a" (no Replace)
|
events.push(ev);
|
||||||
if let Some(ev) = e.flush() { events.push(ev); }
|
} // backspace → buffer ""
|
||||||
|
if let Some(ev) = e.process_key('a') {
|
||||||
|
events.push(ev);
|
||||||
|
} // buffer "a" (no Replace)
|
||||||
|
if let Some(ev) = e.flush() {
|
||||||
|
events.push(ev);
|
||||||
|
}
|
||||||
// After backspace: buffer is empty, then 'a' → no Replace, flush returns Flush("a")
|
// After backspace: buffer is empty, then 'a' → no Replace, flush returns Flush("a")
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|ev| match ev {
|
||||||
EngineEvent::Replace { .. } => Some(()),
|
EngineEvent::Replace { .. } => Some(()),
|
||||||
_ => None,
|
_ => None,
|
||||||
}).collect();
|
})
|
||||||
assert_eq!(replace_events.len(), 0, "No Replace events after backspace + 'a'");
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
replace_events.len(),
|
||||||
|
0,
|
||||||
|
"No Replace events after backspace + 'a'"
|
||||||
|
);
|
||||||
let display = get_display(&events);
|
let display = get_display(&events);
|
||||||
assert_eq!(display, " a", "Display should be ' ' (from Insert) + 'a' (from flush)");
|
assert_eq!(
|
||||||
|
display, " a",
|
||||||
|
"Display should be ' ' (from Insert) + 'a' (from flush)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1636,10 +1750,13 @@ mod tests {
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
// "xin chao " (xin=no convert, chao=no convert, space flushes)
|
// "xin chao " (xin=no convert, chao=no convert, space flushes)
|
||||||
let events = process_input(&mut e, "xin chao ");
|
let events = process_input(&mut e, "xin chao ");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
||||||
assert_eq!(replace_events.len(), 0, "No Replace events for 'xin chao '");
|
assert_eq!(replace_events.len(), 0, "No Replace events for 'xin chao '");
|
||||||
assert_eq!(get_display(&events), "xin chao ");
|
assert_eq!(get_display(&events), "xin chao ");
|
||||||
}
|
}
|
||||||
|
|
@ -1656,11 +1773,19 @@ mod tests {
|
||||||
// Apply 's' to 'o' → 'ó'. buffer = "tót"
|
// Apply 's' to 'o' → 'ó'. buffer = "tót"
|
||||||
// Replace { 4, "tót" }
|
// Replace { 4, "tót" }
|
||||||
let events = process_input(&mut e, "tots");
|
let events = process_input(&mut e, "tots");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
})
|
||||||
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events);
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
replace_events.len(),
|
||||||
|
1,
|
||||||
|
"Expected 1 Replace: {:?}",
|
||||||
|
replace_events
|
||||||
|
);
|
||||||
assert_eq!(replace_events[0].0, 4, "tots→tót backspace");
|
assert_eq!(replace_events[0].0, 4, "tots→tót backspace");
|
||||||
assert_eq!(replace_events[0].1, "tót");
|
assert_eq!(replace_events[0].1, "tót");
|
||||||
assert_eq!(get_display(&events), "tót");
|
assert_eq!(get_display(&events), "tót");
|
||||||
|
|
@ -1671,11 +1796,19 @@ mod tests {
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
// "dungj" → "dụng"
|
// "dungj" → "dụng"
|
||||||
let events = process_input(&mut e, "dungj");
|
let events = process_input(&mut e, "dungj");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
})
|
||||||
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events);
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
replace_events.len(),
|
||||||
|
1,
|
||||||
|
"Expected 1 Replace: {:?}",
|
||||||
|
replace_events
|
||||||
|
);
|
||||||
assert_eq!(replace_events[0].0, 5, "dungj→dụng backspace");
|
assert_eq!(replace_events[0].0, 5, "dungj→dụng backspace");
|
||||||
assert_eq!(replace_events[0].1, "dụng");
|
assert_eq!(replace_events[0].1, "dụng");
|
||||||
assert_eq!(get_display(&events), "dụng");
|
assert_eq!(get_display(&events), "dụng");
|
||||||
|
|
@ -1695,7 +1828,11 @@ mod tests {
|
||||||
assert_eq!(e.buffer(), "á", "Engine buffer should be 'á'");
|
assert_eq!(e.buffer(), "á", "Engine buffer should be 'á'");
|
||||||
// Backspace → pop engine, sync raw_buffer
|
// Backspace → pop engine, sync raw_buffer
|
||||||
e.process_key('\x08');
|
e.process_key('\x08');
|
||||||
assert_eq!(e.buffer(), "", "Engine buffer should be empty after backspace");
|
assert_eq!(
|
||||||
|
e.buffer(),
|
||||||
|
"",
|
||||||
|
"Engine buffer should be empty after backspace"
|
||||||
|
);
|
||||||
// Verify raw_buffer is also empty (sync'd via char count matching)
|
// Verify raw_buffer is also empty (sync'd via char count matching)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1738,11 +1875,19 @@ mod tests {
|
||||||
fn vni_backspace_count_tone() {
|
fn vni_backspace_count_tone() {
|
||||||
let mut e = Engine::new(InputMethod::Vni);
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
let events = process_input(&mut e, "a1");
|
let events = process_input(&mut e, "a1");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
})
|
||||||
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events);
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
replace_events.len(),
|
||||||
|
1,
|
||||||
|
"Expected 1 Replace: {:?}",
|
||||||
|
replace_events
|
||||||
|
);
|
||||||
assert_eq!(replace_events[0].0, 2, "a1→á backspace");
|
assert_eq!(replace_events[0].0, 2, "a1→á backspace");
|
||||||
assert_eq!(replace_events[0].1, "á");
|
assert_eq!(replace_events[0].1, "á");
|
||||||
assert_eq!(get_display(&events), "á");
|
assert_eq!(get_display(&events), "á");
|
||||||
|
|
@ -1752,10 +1897,13 @@ mod tests {
|
||||||
fn vni_backspace_count_vowel_mod() {
|
fn vni_backspace_count_vowel_mod() {
|
||||||
let mut e = Engine::new(InputMethod::Vni);
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
let events = process_input(&mut e, "a6");
|
let events = process_input(&mut e, "a6");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
||||||
assert_eq!(replace_events.len(), 1);
|
assert_eq!(replace_events.len(), 1);
|
||||||
assert_eq!(replace_events[0].0, 2, "a6→â backspace");
|
assert_eq!(replace_events[0].0, 2, "a6→â backspace");
|
||||||
assert_eq!(replace_events[0].1, "â");
|
assert_eq!(replace_events[0].1, "â");
|
||||||
|
|
@ -1766,12 +1914,20 @@ mod tests {
|
||||||
fn vni_backspace_count_mod_then_tone() {
|
fn vni_backspace_count_mod_then_tone() {
|
||||||
let mut e = Engine::new(InputMethod::Vni);
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
let events = process_input(&mut e, "a61");
|
let events = process_input(&mut e, "a61");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
||||||
// "a6" → Replace {2, "â"}, then "1" → Replace {2, "ấ"}
|
// "a6" → Replace {2, "â"}, then "1" → Replace {2, "ấ"}
|
||||||
assert_eq!(replace_events.len(), 2, "Expected 2 Replace: {:?}", replace_events);
|
assert_eq!(
|
||||||
|
replace_events.len(),
|
||||||
|
2,
|
||||||
|
"Expected 2 Replace: {:?}",
|
||||||
|
replace_events
|
||||||
|
);
|
||||||
assert_eq!(replace_events[0].0, 2);
|
assert_eq!(replace_events[0].0, 2);
|
||||||
assert_eq!(replace_events[0].1, "â");
|
assert_eq!(replace_events[0].1, "â");
|
||||||
assert_eq!(replace_events[1].0, 2);
|
assert_eq!(replace_events[1].0, 2);
|
||||||
|
|
@ -1784,10 +1940,13 @@ mod tests {
|
||||||
// "b1" → 'b' is not vowel, '1' appends as digit → no Replace
|
// "b1" → 'b' is not vowel, '1' appends as digit → no Replace
|
||||||
let mut e = Engine::new(InputMethod::Vni);
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
let events = process_input(&mut e, "b1");
|
let events = process_input(&mut e, "b1");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|ev| match ev {
|
||||||
EngineEvent::Replace { .. } => Some(()),
|
EngineEvent::Replace { .. } => Some(()),
|
||||||
_ => None,
|
_ => None,
|
||||||
}).collect();
|
})
|
||||||
|
.collect();
|
||||||
assert_eq!(replace_events.len(), 0, "No Replace for consonant+digit");
|
assert_eq!(replace_events.len(), 0, "No Replace for consonant+digit");
|
||||||
assert_eq!(get_display(&events), "b1");
|
assert_eq!(get_display(&events), "b1");
|
||||||
}
|
}
|
||||||
|
|
@ -1797,11 +1956,19 @@ mod tests {
|
||||||
let mut e = Engine::new(InputMethod::Vni);
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
// "chao2" → '2' is tone (huyền) on 'o' → "chaò"
|
// "chao2" → '2' is tone (huyền) on 'o' → "chaò"
|
||||||
let events = process_input(&mut e, "chao2");
|
let events = process_input(&mut e, "chao2");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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();
|
})
|
||||||
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events);
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
replace_events.len(),
|
||||||
|
1,
|
||||||
|
"Expected 1 Replace: {:?}",
|
||||||
|
replace_events
|
||||||
|
);
|
||||||
// previous_inner = "chao" (4 chars), expected = "chao"+"2" = "chao2" (5 chars)
|
// previous_inner = "chao" (4 chars), expected = "chao"+"2" = "chao2" (5 chars)
|
||||||
// backspaces = 4 + 1 = 5
|
// backspaces = 4 + 1 = 5
|
||||||
assert_eq!(replace_events[0].0, 5, "chao2→chaò backspace");
|
assert_eq!(replace_events[0].0, 5, "chao2→chaò backspace");
|
||||||
|
|
@ -1818,12 +1985,20 @@ mod tests {
|
||||||
// Type "as" → á, then "f" → f overrides sắc with huyền → "à"
|
// Type "as" → á, then "f" → f overrides sắc with huyền → "à"
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
let events = process_input(&mut e, "asf");
|
let events = process_input(&mut e, "asf");
|
||||||
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<_> = events
|
||||||
|
.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" → Replace {2, "à"}
|
// "as" → Replace {2, "á"}, "f" → Replace {2, "à"}
|
||||||
assert_eq!(replace_events.len(), 2, "Expected 2 Replace: {:?}", replace_events);
|
assert_eq!(
|
||||||
|
replace_events.len(),
|
||||||
|
2,
|
||||||
|
"Expected 2 Replace: {:?}",
|
||||||
|
replace_events
|
||||||
|
);
|
||||||
assert_eq!(replace_events[0].0, 2);
|
assert_eq!(replace_events[0].0, 2);
|
||||||
assert_eq!(replace_events[0].1, "á");
|
assert_eq!(replace_events[0].1, "á");
|
||||||
assert_eq!(replace_events[1].0, 2);
|
assert_eq!(replace_events[1].0, 2);
|
||||||
|
|
@ -1970,11 +2145,19 @@ mod tests {
|
||||||
// ' ' = flush
|
// ' ' = flush
|
||||||
// b + a + n + j = "bạn" (j=nặng on 'a')
|
// b + a + n + j = "bạn" (j=nặng on 'a')
|
||||||
let events = process_input(&mut e, "xin chaof banj");
|
let events = process_input(&mut e, "xin chaof banj");
|
||||||
let replace_events: Vec<usize> = events.iter().filter_map(|ev| match ev {
|
let replace_events: Vec<usize> = events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|ev| match ev {
|
||||||
EngineEvent::Replace { backspaces, .. } => Some(*backspaces),
|
EngineEvent::Replace { backspaces, .. } => Some(*backspaces),
|
||||||
_ => None,
|
_ => None,
|
||||||
}).collect();
|
})
|
||||||
assert_eq!(replace_events.len(), 2, "Expected 2 Replace events: {:?}", replace_events);
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
replace_events.len(),
|
||||||
|
2,
|
||||||
|
"Expected 2 Replace events: {:?}",
|
||||||
|
replace_events
|
||||||
|
);
|
||||||
assert_eq!(replace_events[0], 5, "chaof→chào should be 5");
|
assert_eq!(replace_events[0], 5, "chaof→chào should be 5");
|
||||||
assert_eq!(replace_events[1], 4, "banj→bạn should be 4");
|
assert_eq!(replace_events[1], 4, "banj→bạn should be 4");
|
||||||
assert_eq!(get_display(&events), "xin chào bạn");
|
assert_eq!(get_display(&events), "xin chào bạn");
|
||||||
|
|
@ -2102,4 +2285,70 @@ mod tests {
|
||||||
let mut e = Engine::new(InputMethod::Vni);
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
assert_eq!(get_display(&process_input(&mut e, "dang9")), "đang");
|
assert_eq!(get_display(&process_input(&mut e, "dang9")), "đang");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_spelling_auto_restore() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
|
||||||
|
// "fasts" -> "fást" -> restored to "fasts" on space
|
||||||
|
assert_eq!(get_display(&process_input(&mut e, "fasts ")), "fasts ");
|
||||||
|
|
||||||
|
// "statuss" -> "statús" -> restored to "statuss" on space
|
||||||
|
let mut e2 = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(get_display(&process_input(&mut e2, "statuss ")), "statuss ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_user_phrases_telex() {
|
||||||
|
let mut e = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(
|
||||||
|
get_display(&process_input(&mut e, "vox nguyeenx ddawng khoa")),
|
||||||
|
"võ nguyễn đăng khoa"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut e2 = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(
|
||||||
|
get_display(&process_input(&mut e2, "nguyeenx thij traam anh")),
|
||||||
|
"nguyễn thị trâm anh"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut e3 = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(
|
||||||
|
get_display(&process_input(&mut e3, "vox hoongf mi")),
|
||||||
|
"võ hồng mi"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut e4 = Engine::new(InputMethod::Telex);
|
||||||
|
assert_eq!(
|
||||||
|
get_display(&process_input(&mut e4, "trinhj traanf phuongw tuaans")),
|
||||||
|
"trịnh trần phương tuấn"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_user_phrases_vni() {
|
||||||
|
let mut e = Engine::new(InputMethod::Vni);
|
||||||
|
assert_eq!(
|
||||||
|
get_display(&process_input(&mut e, "vo4 nguyen64 da8ng9 khoa")),
|
||||||
|
"võ nguyễn đăng khoa"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut e2 = Engine::new(InputMethod::Vni);
|
||||||
|
assert_eq!(
|
||||||
|
get_display(&process_input(&mut e2, "nguyen64 thi5 tram6 anh")),
|
||||||
|
"nguyễn thị trâm anh"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut e3 = Engine::new(InputMethod::Vni);
|
||||||
|
assert_eq!(
|
||||||
|
get_display(&process_input(&mut e3, "vo4 hong62 mi")),
|
||||||
|
"võ hồng mi"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut e4 = Engine::new(InputMethod::Vni);
|
||||||
|
assert_eq!(
|
||||||
|
get_display(&process_input(&mut e4, "trinh5 tran62 phuong7 tuan61")),
|
||||||
|
"trịnh trần phương tuấn"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,10 @@
|
||||||
use crate::engine::EngineEvent;
|
use crate::engine::EngineEvent;
|
||||||
|
|
||||||
const VOWELS: &[char] = &[
|
|
||||||
'a', 'e', 'i', 'o', 'u', 'y',
|
|
||||||
'ă', 'â', 'ê', 'ô', 'ơ', 'ư',
|
|
||||||
];
|
|
||||||
|
|
||||||
const VOWEL_ACCENTED: &[char] = &[
|
const VOWEL_ACCENTED: &[char] = &[
|
||||||
'a', 'á', 'à', 'ả', 'ã', 'ạ',
|
'a', 'á', 'à', 'ả', 'ã', 'ạ', 'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ', 'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ', 'e',
|
||||||
'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ',
|
'é', 'è', 'ẻ', 'ẽ', 'ẹ', 'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ', 'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị', 'o', 'ó',
|
||||||
'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ',
|
'ò', 'ỏ', 'õ', 'ọ', 'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ', 'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ', 'u', 'ú', 'ù',
|
||||||
'e', 'é', 'è', 'ẻ', 'ẽ', 'ẹ',
|
'ủ', 'ũ', 'ụ', 'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự', 'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ',
|
||||||
'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ',
|
|
||||||
'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị',
|
|
||||||
'o', 'ó', 'ò', 'ỏ', 'õ', 'ọ',
|
|
||||||
'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ',
|
|
||||||
'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ',
|
|
||||||
'u', 'ú', 'ù', 'ủ', 'ũ', 'ụ',
|
|
||||||
'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự',
|
|
||||||
'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
fn is_vowel(c: char) -> bool {
|
fn is_vowel(c: char) -> bool {
|
||||||
|
|
@ -29,30 +16,78 @@ const MAX_FLEXIBLE_BACKTRACK: usize = 3;
|
||||||
/// Strip tone from a Vietnamese vowel, returning (base_modified_vowel, tone_digit_or_none)
|
/// Strip tone from a Vietnamese vowel, returning (base_modified_vowel, tone_digit_or_none)
|
||||||
fn strip_tone_vni(c: char) -> (char, Option<char>) {
|
fn strip_tone_vni(c: char) -> (char, Option<char>) {
|
||||||
match c {
|
match c {
|
||||||
'a' => ('a', None), 'á' => ('a', Some('1')), 'à' => ('a', Some('2')),
|
'a' => ('a', None),
|
||||||
'ả' => ('a', Some('3')), 'ã' => ('a', Some('4')), 'ạ' => ('a', Some('5')),
|
'á' => ('a', Some('1')),
|
||||||
'ă' => ('ă', None), 'ắ' => ('ă', Some('1')), 'ằ' => ('ă', Some('2')),
|
'à' => ('a', Some('2')),
|
||||||
'ẳ' => ('ă', Some('3')), 'ẵ' => ('ă', Some('4')), 'ặ' => ('ă', Some('5')),
|
'ả' => ('a', Some('3')),
|
||||||
'â' => ('â', None), 'ấ' => ('â', Some('1')), 'ầ' => ('â', Some('2')),
|
'ã' => ('a', Some('4')),
|
||||||
'ẩ' => ('â', Some('3')), 'ẫ' => ('â', Some('4')), 'ậ' => ('â', Some('5')),
|
'ạ' => ('a', Some('5')),
|
||||||
'e' => ('e', None), 'é' => ('e', Some('1')), 'è' => ('e', Some('2')),
|
'ă' => ('ă', None),
|
||||||
'ẻ' => ('e', Some('3')), 'ẽ' => ('e', Some('4')), 'ẹ' => ('e', Some('5')),
|
'ắ' => ('ă', Some('1')),
|
||||||
'ê' => ('ê', None), 'ế' => ('ê', Some('1')), 'ề' => ('ê', Some('2')),
|
'ằ' => ('ă', Some('2')),
|
||||||
'ể' => ('ê', Some('3')), 'ễ' => ('ê', Some('4')), 'ệ' => ('ê', Some('5')),
|
'ẳ' => ('ă', Some('3')),
|
||||||
'i' => ('i', None), 'í' => ('i', Some('1')), 'ì' => ('i', Some('2')),
|
'ẵ' => ('ă', Some('4')),
|
||||||
'ỉ' => ('i', Some('3')), 'ĩ' => ('i', Some('4')), 'ị' => ('i', Some('5')),
|
'ặ' => ('ă', Some('5')),
|
||||||
'o' => ('o', None), 'ó' => ('o', Some('1')), 'ò' => ('o', Some('2')),
|
'â' => ('â', None),
|
||||||
'ỏ' => ('o', Some('3')), 'õ' => ('o', Some('4')), 'ọ' => ('o', Some('5')),
|
'ấ' => ('â', Some('1')),
|
||||||
'ô' => ('ô', None), 'ố' => ('ô', Some('1')), 'ồ' => ('ô', Some('2')),
|
'ầ' => ('â', Some('2')),
|
||||||
'ổ' => ('ô', Some('3')), 'ỗ' => ('ô', Some('4')), 'ộ' => ('ô', Some('5')),
|
'ẩ' => ('â', Some('3')),
|
||||||
'ơ' => ('ơ', None), 'ớ' => ('ơ', Some('1')), 'ờ' => ('ơ', Some('2')),
|
'ẫ' => ('â', Some('4')),
|
||||||
'ở' => ('ơ', Some('3')), 'ỡ' => ('ơ', Some('4')), 'ợ' => ('ơ', Some('5')),
|
'ậ' => ('â', Some('5')),
|
||||||
'u' => ('u', None), 'ú' => ('u', Some('1')), 'ù' => ('u', Some('2')),
|
'e' => ('e', None),
|
||||||
'ủ' => ('u', Some('3')), 'ũ' => ('u', Some('4')), 'ụ' => ('u', Some('5')),
|
'é' => ('e', Some('1')),
|
||||||
'ư' => ('ư', None), 'ứ' => ('ư', Some('1')), 'ừ' => ('ư', Some('2')),
|
'è' => ('e', Some('2')),
|
||||||
'ử' => ('ư', Some('3')), 'ữ' => ('ư', Some('4')), 'ự' => ('ư', Some('5')),
|
'ẻ' => ('e', Some('3')),
|
||||||
'y' => ('y', None), 'ý' => ('y', Some('1')), 'ỳ' => ('y', Some('2')),
|
'ẽ' => ('e', Some('4')),
|
||||||
'ỷ' => ('y', Some('3')), 'ỹ' => ('y', Some('4')), 'ỵ' => ('y', Some('5')),
|
'ẹ' => ('e', Some('5')),
|
||||||
|
'ê' => ('ê', None),
|
||||||
|
'ế' => ('ê', Some('1')),
|
||||||
|
'ề' => ('ê', Some('2')),
|
||||||
|
'ể' => ('ê', Some('3')),
|
||||||
|
'ễ' => ('ê', Some('4')),
|
||||||
|
'ệ' => ('ê', Some('5')),
|
||||||
|
'i' => ('i', None),
|
||||||
|
'í' => ('i', Some('1')),
|
||||||
|
'ì' => ('i', Some('2')),
|
||||||
|
'ỉ' => ('i', Some('3')),
|
||||||
|
'ĩ' => ('i', Some('4')),
|
||||||
|
'ị' => ('i', Some('5')),
|
||||||
|
'o' => ('o', None),
|
||||||
|
'ó' => ('o', Some('1')),
|
||||||
|
'ò' => ('o', Some('2')),
|
||||||
|
'ỏ' => ('o', Some('3')),
|
||||||
|
'õ' => ('o', Some('4')),
|
||||||
|
'ọ' => ('o', Some('5')),
|
||||||
|
'ô' => ('ô', None),
|
||||||
|
'ố' => ('ô', Some('1')),
|
||||||
|
'ồ' => ('ô', Some('2')),
|
||||||
|
'ổ' => ('ô', Some('3')),
|
||||||
|
'ỗ' => ('ô', Some('4')),
|
||||||
|
'ộ' => ('ô', Some('5')),
|
||||||
|
'ơ' => ('ơ', None),
|
||||||
|
'ớ' => ('ơ', Some('1')),
|
||||||
|
'ờ' => ('ơ', Some('2')),
|
||||||
|
'ở' => ('ơ', Some('3')),
|
||||||
|
'ỡ' => ('ơ', Some('4')),
|
||||||
|
'ợ' => ('ơ', Some('5')),
|
||||||
|
'u' => ('u', None),
|
||||||
|
'ú' => ('u', Some('1')),
|
||||||
|
'ù' => ('u', Some('2')),
|
||||||
|
'ủ' => ('u', Some('3')),
|
||||||
|
'ũ' => ('u', Some('4')),
|
||||||
|
'ụ' => ('u', Some('5')),
|
||||||
|
'ư' => ('ư', None),
|
||||||
|
'ứ' => ('ư', Some('1')),
|
||||||
|
'ừ' => ('ư', Some('2')),
|
||||||
|
'ử' => ('ư', Some('3')),
|
||||||
|
'ữ' => ('ư', Some('4')),
|
||||||
|
'ự' => ('ư', Some('5')),
|
||||||
|
'y' => ('y', None),
|
||||||
|
'ý' => ('y', Some('1')),
|
||||||
|
'ỳ' => ('y', Some('2')),
|
||||||
|
'ỷ' => ('y', Some('3')),
|
||||||
|
'ỹ' => ('y', Some('4')),
|
||||||
|
'ỵ' => ('y', Some('5')),
|
||||||
_ => (c, None),
|
_ => (c, None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -60,18 +95,66 @@ fn strip_tone_vni(c: char) -> (char, Option<char>) {
|
||||||
fn apply_tone_to_vowel(vowel: char, digit: char) -> Option<char> {
|
fn apply_tone_to_vowel(vowel: char, digit: char) -> Option<char> {
|
||||||
// VNI: 1=sắc, 2=huyền, 3=hỏi, 4=ngã, 5=nặng
|
// VNI: 1=sắc, 2=huyền, 3=hỏi, 4=ngã, 5=nặng
|
||||||
let table: &[(char, char, char)] = &[
|
let table: &[(char, char, char)] = &[
|
||||||
('a', '1', 'á'), ('a', '2', 'à'), ('a', '3', 'ả'), ('a', '4', 'ã'), ('a', '5', 'ạ'),
|
('a', '1', 'á'),
|
||||||
('ă', '1', 'ắ'), ('ă', '2', 'ằ'), ('ă', '3', 'ẳ'), ('ă', '4', 'ẵ'), ('ă', '5', 'ặ'),
|
('a', '2', 'à'),
|
||||||
('â', '1', 'ấ'), ('â', '2', 'ầ'), ('â', '3', 'ẩ'), ('â', '4', 'ẫ'), ('â', '5', 'ậ'),
|
('a', '3', 'ả'),
|
||||||
('e', '1', 'é'), ('e', '2', 'è'), ('e', '3', 'ẻ'), ('e', '4', 'ẽ'), ('e', '5', 'ẹ'),
|
('a', '4', 'ã'),
|
||||||
('ê', '1', 'ế'), ('ê', '2', 'ề'), ('ê', '3', 'ể'), ('ê', '4', 'ễ'), ('ê', '5', 'ệ'),
|
('a', '5', 'ạ'),
|
||||||
('i', '1', 'í'), ('i', '2', 'ì'), ('i', '3', 'ỉ'), ('i', '4', 'ĩ'), ('i', '5', 'ị'),
|
('ă', '1', 'ắ'),
|
||||||
('o', '1', 'ó'), ('o', '2', 'ò'), ('o', '3', 'ỏ'), ('o', '4', 'õ'), ('o', '5', 'ọ'),
|
('ă', '2', 'ằ'),
|
||||||
('ô', '1', 'ố'), ('ô', '2', 'ồ'), ('ô', '3', 'ổ'), ('ô', '4', 'ỗ'), ('ô', '5', 'ộ'),
|
('ă', '3', 'ẳ'),
|
||||||
('ơ', '1', 'ớ'), ('ơ', '2', 'ờ'), ('ơ', '3', 'ở'), ('ơ', '4', 'ỡ'), ('ơ', '5', 'ợ'),
|
('ă', '4', 'ẵ'),
|
||||||
('u', '1', 'ú'), ('u', '2', 'ù'), ('u', '3', 'ủ'), ('u', '4', 'ũ'), ('u', '5', 'ụ'),
|
('ă', '5', 'ặ'),
|
||||||
('ư', '1', 'ứ'), ('ư', '2', 'ừ'), ('ư', '3', 'ử'), ('ư', '4', 'ữ'), ('ư', '5', 'ự'),
|
('â', '1', 'ấ'),
|
||||||
('y', '1', 'ý'), ('y', '2', 'ỳ'), ('y', '3', 'ỷ'), ('y', '4', 'ỹ'), ('y', '5', 'ỵ'),
|
('â', '2', 'ầ'),
|
||||||
|
('â', '3', 'ẩ'),
|
||||||
|
('â', '4', 'ẫ'),
|
||||||
|
('â', '5', 'ậ'),
|
||||||
|
('e', '1', 'é'),
|
||||||
|
('e', '2', 'è'),
|
||||||
|
('e', '3', 'ẻ'),
|
||||||
|
('e', '4', 'ẽ'),
|
||||||
|
('e', '5', 'ẹ'),
|
||||||
|
('ê', '1', 'ế'),
|
||||||
|
('ê', '2', 'ề'),
|
||||||
|
('ê', '3', 'ể'),
|
||||||
|
('ê', '4', 'ễ'),
|
||||||
|
('ê', '5', 'ệ'),
|
||||||
|
('i', '1', 'í'),
|
||||||
|
('i', '2', 'ì'),
|
||||||
|
('i', '3', 'ỉ'),
|
||||||
|
('i', '4', 'ĩ'),
|
||||||
|
('i', '5', 'ị'),
|
||||||
|
('o', '1', 'ó'),
|
||||||
|
('o', '2', 'ò'),
|
||||||
|
('o', '3', 'ỏ'),
|
||||||
|
('o', '4', 'õ'),
|
||||||
|
('o', '5', 'ọ'),
|
||||||
|
('ô', '1', 'ố'),
|
||||||
|
('ô', '2', 'ồ'),
|
||||||
|
('ô', '3', 'ổ'),
|
||||||
|
('ô', '4', 'ỗ'),
|
||||||
|
('ô', '5', 'ộ'),
|
||||||
|
('ơ', '1', 'ớ'),
|
||||||
|
('ơ', '2', 'ờ'),
|
||||||
|
('ơ', '3', 'ở'),
|
||||||
|
('ơ', '4', 'ỡ'),
|
||||||
|
('ơ', '5', 'ợ'),
|
||||||
|
('u', '1', 'ú'),
|
||||||
|
('u', '2', 'ù'),
|
||||||
|
('u', '3', 'ủ'),
|
||||||
|
('u', '4', 'ũ'),
|
||||||
|
('u', '5', 'ụ'),
|
||||||
|
('ư', '1', 'ứ'),
|
||||||
|
('ư', '2', 'ừ'),
|
||||||
|
('ư', '3', 'ử'),
|
||||||
|
('ư', '4', 'ữ'),
|
||||||
|
('ư', '5', 'ự'),
|
||||||
|
('y', '1', 'ý'),
|
||||||
|
('y', '2', 'ỳ'),
|
||||||
|
('y', '3', 'ỷ'),
|
||||||
|
('y', '4', 'ỹ'),
|
||||||
|
('y', '5', 'ỵ'),
|
||||||
];
|
];
|
||||||
|
|
||||||
for &(v, t, result) in table {
|
for &(v, t, result) in table {
|
||||||
|
|
@ -145,11 +228,21 @@ fn is_o_vowel(c: char) -> bool {
|
||||||
fn tone_of_vowel_vni(c: char) -> Option<char> {
|
fn tone_of_vowel_vni(c: char) -> Option<char> {
|
||||||
match c {
|
match c {
|
||||||
'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None,
|
'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None,
|
||||||
'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => Some('2'),
|
'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => {
|
||||||
'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => Some('1'),
|
Some('2')
|
||||||
'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => Some('3'),
|
}
|
||||||
'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => Some('4'),
|
'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => {
|
||||||
'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => Some('5'),
|
Some('1')
|
||||||
|
}
|
||||||
|
'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => {
|
||||||
|
Some('3')
|
||||||
|
}
|
||||||
|
'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => {
|
||||||
|
Some('4')
|
||||||
|
}
|
||||||
|
'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => {
|
||||||
|
Some('5')
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -251,6 +344,22 @@ impl VniEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_digit(&mut self, digit: char) -> Option<EngineEvent> {
|
fn process_digit(&mut self, digit: char) -> Option<EngineEvent> {
|
||||||
|
// VNI digit 9: 'd' -> 'đ' or 'D' -> 'Đ' anywhere at the start of the buffer
|
||||||
|
if digit == '9' {
|
||||||
|
let mut chars: Vec<char> = self.buffer.chars().collect();
|
||||||
|
if !chars.is_empty() {
|
||||||
|
if chars[0] == 'd' {
|
||||||
|
chars[0] = 'đ';
|
||||||
|
self.buffer = chars.into_iter().collect();
|
||||||
|
return None;
|
||||||
|
} else if chars[0] == 'D' {
|
||||||
|
chars[0] = 'Đ';
|
||||||
|
self.buffer = chars.into_iter().collect();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply any pending modifier first
|
// Apply any pending modifier first
|
||||||
if self.pending_modifier.is_some() {
|
if self.pending_modifier.is_some() {
|
||||||
self.apply_pending();
|
self.apply_pending();
|
||||||
|
|
@ -262,7 +371,10 @@ impl VniEngine {
|
||||||
// Smart cluster "uo" → "ươ" (digit '7')
|
// Smart cluster "uo" → "ươ" (digit '7')
|
||||||
if digit == '7' && is_o_vowel(last_ch) {
|
if digit == '7' && is_o_vowel(last_ch) {
|
||||||
let mut chars: Vec<char> = self.buffer.chars().collect();
|
let mut chars: Vec<char> = self.buffer.chars().collect();
|
||||||
if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) {
|
if chars.len() >= 2
|
||||||
|
&& is_u_vowel(chars[chars.len() - 2])
|
||||||
|
&& !is_q_before_u(&chars, chars.len() - 1)
|
||||||
|
{
|
||||||
let o_char = chars.pop().unwrap();
|
let o_char = chars.pop().unwrap();
|
||||||
let u_char = chars.pop().unwrap();
|
let u_char = chars.pop().unwrap();
|
||||||
let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char);
|
let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char);
|
||||||
|
|
@ -291,7 +403,10 @@ impl VniEngine {
|
||||||
let strip = strip_tone_vni(last_ch);
|
let strip = strip_tone_vni(last_ch);
|
||||||
if strip.0 == 'ô' {
|
if strip.0 == 'ô' {
|
||||||
let mut chars: Vec<char> = self.buffer.chars().collect();
|
let mut chars: Vec<char> = self.buffer.chars().collect();
|
||||||
if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) {
|
if chars.len() >= 2
|
||||||
|
&& is_u_vowel(chars[chars.len() - 2])
|
||||||
|
&& !is_q_before_u(&chars, chars.len() - 1)
|
||||||
|
{
|
||||||
let o_char = chars.pop().unwrap();
|
let o_char = chars.pop().unwrap();
|
||||||
let u_char = chars.pop().unwrap();
|
let u_char = chars.pop().unwrap();
|
||||||
let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char);
|
let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char);
|
||||||
|
|
@ -322,12 +437,7 @@ impl VniEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// VNI digit 9: 'd' → 'đ'
|
|
||||||
if digit == '9' && last_ch == 'd' {
|
|
||||||
self.buffer.pop();
|
|
||||||
self.buffer.push('đ');
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// Modifier override: vowel already has a different modifier
|
// Modifier override: vowel already has a different modifier
|
||||||
if let Some(modified) = override_vni_modifier(last_ch, digit) {
|
if let Some(modified) = override_vni_modifier(last_ch, digit) {
|
||||||
self.buffer.pop();
|
self.buffer.pop();
|
||||||
|
|
@ -345,7 +455,12 @@ impl VniEngine {
|
||||||
for i in (start..chars.len()).rev() {
|
for i in (start..chars.len()).rev() {
|
||||||
if is_vowel(chars[i]) {
|
if is_vowel(chars[i]) {
|
||||||
// Smart cluster "uo" → "ươ" (digit '7', flexible)
|
// Smart cluster "uo" → "ươ" (digit '7', flexible)
|
||||||
if digit == '7' && is_o_vowel(chars[i]) && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) {
|
if digit == '7'
|
||||||
|
&& is_o_vowel(chars[i])
|
||||||
|
&& i > 0
|
||||||
|
&& is_u_vowel(chars[i - 1])
|
||||||
|
&& !is_q_before_u(&chars, i)
|
||||||
|
{
|
||||||
let (new_first, new_second) = uo_to_uơ_vni(chars[i - 1], chars[i]);
|
let (new_first, new_second) = uo_to_uơ_vni(chars[i - 1], chars[i]);
|
||||||
self.buffer = chars[..i - 1].iter().collect::<String>();
|
self.buffer = chars[..i - 1].iter().collect::<String>();
|
||||||
self.buffer.push(new_first);
|
self.buffer.push(new_first);
|
||||||
|
|
@ -376,7 +491,11 @@ impl VniEngine {
|
||||||
// Smart cluster forward (override): "uô" + 7 → "ươ" (flexible)
|
// Smart cluster forward (override): "uô" + 7 → "ươ" (flexible)
|
||||||
if digit == '7' {
|
if digit == '7' {
|
||||||
let strip = strip_tone_vni(chars[i]);
|
let strip = strip_tone_vni(chars[i]);
|
||||||
if strip.0 == 'ô' && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) {
|
if strip.0 == 'ô'
|
||||||
|
&& i > 0
|
||||||
|
&& is_u_vowel(chars[i - 1])
|
||||||
|
&& !is_q_before_u(&chars, i)
|
||||||
|
{
|
||||||
let (new_first, new_second) = uo_to_uơ_vni(chars[i - 1], chars[i]);
|
let (new_first, new_second) = uo_to_uơ_vni(chars[i - 1], chars[i]);
|
||||||
self.buffer = chars[..i - 1].iter().collect::<String>();
|
self.buffer = chars[..i - 1].iter().collect::<String>();
|
||||||
self.buffer.push(new_first);
|
self.buffer.push(new_first);
|
||||||
|
|
|
||||||
83
engine/tests/snapshot_tests.rs
Normal file
83
engine/tests/snapshot_tests.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
use serde::Serialize;
|
||||||
|
use vietc_engine::{Engine, EngineEvent, InputMethod};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SnapshotTestCase {
|
||||||
|
input: String,
|
||||||
|
display: String,
|
||||||
|
events: Vec<EngineEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_display(events: &[EngineEvent]) -> String {
|
||||||
|
let mut display = String::new();
|
||||||
|
for ev in events {
|
||||||
|
match ev {
|
||||||
|
EngineEvent::Flush(text) => {
|
||||||
|
if !display.ends_with(text) {
|
||||||
|
display.push_str(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EngineEvent::Insert(text) => display.push_str(text),
|
||||||
|
EngineEvent::Paste(text) => display.push_str(text),
|
||||||
|
EngineEvent::Replace { backspaces, insert } => {
|
||||||
|
for _ in 0..*backspaces {
|
||||||
|
display.pop();
|
||||||
|
}
|
||||||
|
display.push_str(insert);
|
||||||
|
}
|
||||||
|
EngineEvent::AutoRestore(word) => {
|
||||||
|
for _ in 0..word.len() {
|
||||||
|
display.pop();
|
||||||
|
}
|
||||||
|
display.push_str(word);
|
||||||
|
}
|
||||||
|
EngineEvent::UndoTones {
|
||||||
|
backspaces,
|
||||||
|
restored,
|
||||||
|
} => {
|
||||||
|
for _ in 0..*backspaces {
|
||||||
|
display.pop();
|
||||||
|
}
|
||||||
|
display.push_str(restored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
display
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_snapshot_test(inputs_json: &str, method: InputMethod) -> Vec<SnapshotTestCase> {
|
||||||
|
let inputs: Vec<String> = serde_json::from_str(inputs_json).unwrap();
|
||||||
|
let mut cases = Vec::new();
|
||||||
|
|
||||||
|
for input in inputs {
|
||||||
|
let mut engine = Engine::new(method);
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for ch in input.chars() {
|
||||||
|
if let Some(event) = engine.process_key(ch) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let display = get_display(&events);
|
||||||
|
cases.push(SnapshotTestCase {
|
||||||
|
input,
|
||||||
|
display,
|
||||||
|
events,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cases
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_telex_snapshots() {
|
||||||
|
let inputs_json = include_str!("testdata/telex_inputs.json");
|
||||||
|
let cases = run_snapshot_test(inputs_json, InputMethod::Telex);
|
||||||
|
insta::assert_yaml_snapshot!(cases);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vni_snapshots() {
|
||||||
|
let inputs_json = include_str!("testdata/vni_inputs.json");
|
||||||
|
let cases = run_snapshot_test(inputs_json, InputMethod::Vni);
|
||||||
|
insta::assert_yaml_snapshot!(cases);
|
||||||
|
}
|
||||||
3964
engine/tests/snapshots/snapshot_tests__telex_snapshots.snap
Normal file
3964
engine/tests/snapshots/snapshot_tests__telex_snapshots.snap
Normal file
File diff suppressed because it is too large
Load diff
3004
engine/tests/snapshots/snapshot_tests__vni_snapshots.snap
Normal file
3004
engine/tests/snapshots/snapshot_tests__vni_snapshots.snap
Normal file
File diff suppressed because it is too large
Load diff
502
engine/tests/testdata/telex_inputs.json
vendored
Normal file
502
engine/tests/testdata/telex_inputs.json
vendored
Normal file
|
|
@ -0,0 +1,502 @@
|
||||||
|
[
|
||||||
|
"aaf",
|
||||||
|
"aas",
|
||||||
|
"aaj",
|
||||||
|
"aar",
|
||||||
|
"aax",
|
||||||
|
"aaw",
|
||||||
|
"aaa",
|
||||||
|
"eee",
|
||||||
|
"ooo",
|
||||||
|
"oow",
|
||||||
|
"uuw",
|
||||||
|
"acaf",
|
||||||
|
"acas",
|
||||||
|
"acaj",
|
||||||
|
"acar",
|
||||||
|
"acax",
|
||||||
|
"acaw",
|
||||||
|
"acaa",
|
||||||
|
"ecee",
|
||||||
|
"ocoo",
|
||||||
|
"ocow",
|
||||||
|
"ucuw",
|
||||||
|
"amaf",
|
||||||
|
"amas",
|
||||||
|
"amaj",
|
||||||
|
"amar",
|
||||||
|
"amax",
|
||||||
|
"amaw",
|
||||||
|
"amaa",
|
||||||
|
"emee",
|
||||||
|
"omoo",
|
||||||
|
"omow",
|
||||||
|
"umuw",
|
||||||
|
"anaf",
|
||||||
|
"anas",
|
||||||
|
"anaj",
|
||||||
|
"anar",
|
||||||
|
"anax",
|
||||||
|
"anaw",
|
||||||
|
"anaa",
|
||||||
|
"enee",
|
||||||
|
"onoo",
|
||||||
|
"onow",
|
||||||
|
"unuw",
|
||||||
|
"angaf",
|
||||||
|
"angas",
|
||||||
|
"angaj",
|
||||||
|
"angar",
|
||||||
|
"angax",
|
||||||
|
"angaw",
|
||||||
|
"angaa",
|
||||||
|
"engee",
|
||||||
|
"ongoo",
|
||||||
|
"ongow",
|
||||||
|
"unguw",
|
||||||
|
"apaf",
|
||||||
|
"apas",
|
||||||
|
"apaj",
|
||||||
|
"apar",
|
||||||
|
"apax",
|
||||||
|
"apaw",
|
||||||
|
"apaa",
|
||||||
|
"epee",
|
||||||
|
"opoo",
|
||||||
|
"opow",
|
||||||
|
"upuw",
|
||||||
|
"ataf",
|
||||||
|
"atas",
|
||||||
|
"ataj",
|
||||||
|
"atar",
|
||||||
|
"atax",
|
||||||
|
"ataw",
|
||||||
|
"ataa",
|
||||||
|
"etee",
|
||||||
|
"otoo",
|
||||||
|
"otow",
|
||||||
|
"utuw",
|
||||||
|
"baaf",
|
||||||
|
"baas",
|
||||||
|
"baaj",
|
||||||
|
"baar",
|
||||||
|
"baax",
|
||||||
|
"baaw",
|
||||||
|
"baaa",
|
||||||
|
"beee",
|
||||||
|
"booo",
|
||||||
|
"boow",
|
||||||
|
"buuw",
|
||||||
|
"bacaf",
|
||||||
|
"bacas",
|
||||||
|
"bacaj",
|
||||||
|
"bacar",
|
||||||
|
"bacax",
|
||||||
|
"bacaw",
|
||||||
|
"bacaa",
|
||||||
|
"becee",
|
||||||
|
"bocoo",
|
||||||
|
"bocow",
|
||||||
|
"bucuw",
|
||||||
|
"bachaf",
|
||||||
|
"bachas",
|
||||||
|
"bachaj",
|
||||||
|
"bachar",
|
||||||
|
"bachax",
|
||||||
|
"bachaw",
|
||||||
|
"bachaa",
|
||||||
|
"bechee",
|
||||||
|
"bochoo",
|
||||||
|
"bochow",
|
||||||
|
"buchuw",
|
||||||
|
"bamaf",
|
||||||
|
"bamas",
|
||||||
|
"bamaj",
|
||||||
|
"bamar",
|
||||||
|
"bamax",
|
||||||
|
"bamaw",
|
||||||
|
"bamaa",
|
||||||
|
"bemee",
|
||||||
|
"bomoo",
|
||||||
|
"bomow",
|
||||||
|
"bumuw",
|
||||||
|
"banaf",
|
||||||
|
"banas",
|
||||||
|
"banaj",
|
||||||
|
"banar",
|
||||||
|
"banax",
|
||||||
|
"banaw",
|
||||||
|
"banaa",
|
||||||
|
"benee",
|
||||||
|
"bonoo",
|
||||||
|
"bonow",
|
||||||
|
"bunuw",
|
||||||
|
"bangaf",
|
||||||
|
"bangas",
|
||||||
|
"bangaj",
|
||||||
|
"bangar",
|
||||||
|
"bangax",
|
||||||
|
"bangaw",
|
||||||
|
"bangaa",
|
||||||
|
"bengee",
|
||||||
|
"bongoo",
|
||||||
|
"bongow",
|
||||||
|
"bunguw",
|
||||||
|
"banhaf",
|
||||||
|
"banhas",
|
||||||
|
"banhaj",
|
||||||
|
"banhar",
|
||||||
|
"banhax",
|
||||||
|
"banhaw",
|
||||||
|
"banhaa",
|
||||||
|
"benhee",
|
||||||
|
"bonhoo",
|
||||||
|
"bonhow",
|
||||||
|
"bunhuw",
|
||||||
|
"bapaf",
|
||||||
|
"bapas",
|
||||||
|
"bapaj",
|
||||||
|
"bapar",
|
||||||
|
"bapax",
|
||||||
|
"bapaw",
|
||||||
|
"bapaa",
|
||||||
|
"bepee",
|
||||||
|
"bopoo",
|
||||||
|
"bopow",
|
||||||
|
"bupuw",
|
||||||
|
"bataf",
|
||||||
|
"batas",
|
||||||
|
"bataj",
|
||||||
|
"batar",
|
||||||
|
"batax",
|
||||||
|
"bataw",
|
||||||
|
"bataa",
|
||||||
|
"betee",
|
||||||
|
"botoo",
|
||||||
|
"botow",
|
||||||
|
"butuw",
|
||||||
|
"caaf",
|
||||||
|
"caas",
|
||||||
|
"caaj",
|
||||||
|
"caar",
|
||||||
|
"caax",
|
||||||
|
"caaw",
|
||||||
|
"caaa",
|
||||||
|
"ceee",
|
||||||
|
"cooo",
|
||||||
|
"coow",
|
||||||
|
"cuuw",
|
||||||
|
"cacaf",
|
||||||
|
"cacas",
|
||||||
|
"cacaj",
|
||||||
|
"cacar",
|
||||||
|
"cacax",
|
||||||
|
"cacaw",
|
||||||
|
"cacaa",
|
||||||
|
"cecee",
|
||||||
|
"cocoo",
|
||||||
|
"cocow",
|
||||||
|
"cucuw",
|
||||||
|
"cachaf",
|
||||||
|
"cachas",
|
||||||
|
"cachaj",
|
||||||
|
"cachar",
|
||||||
|
"cachax",
|
||||||
|
"cachaw",
|
||||||
|
"cachaa",
|
||||||
|
"cechee",
|
||||||
|
"cochoo",
|
||||||
|
"cochow",
|
||||||
|
"cuchuw",
|
||||||
|
"camaf",
|
||||||
|
"camas",
|
||||||
|
"camaj",
|
||||||
|
"camar",
|
||||||
|
"camax",
|
||||||
|
"camaw",
|
||||||
|
"camaa",
|
||||||
|
"cemee",
|
||||||
|
"comoo",
|
||||||
|
"comow",
|
||||||
|
"cumuw",
|
||||||
|
"canaf",
|
||||||
|
"canas",
|
||||||
|
"canaj",
|
||||||
|
"canar",
|
||||||
|
"canax",
|
||||||
|
"canaw",
|
||||||
|
"canaa",
|
||||||
|
"cenee",
|
||||||
|
"conoo",
|
||||||
|
"conow",
|
||||||
|
"cunuw",
|
||||||
|
"cangaf",
|
||||||
|
"cangas",
|
||||||
|
"cangaj",
|
||||||
|
"cangar",
|
||||||
|
"cangax",
|
||||||
|
"cangaw",
|
||||||
|
"cangaa",
|
||||||
|
"cengee",
|
||||||
|
"congoo",
|
||||||
|
"congow",
|
||||||
|
"cunguw",
|
||||||
|
"canhaf",
|
||||||
|
"canhas",
|
||||||
|
"canhaj",
|
||||||
|
"canhar",
|
||||||
|
"canhax",
|
||||||
|
"canhaw",
|
||||||
|
"canhaa",
|
||||||
|
"cenhee",
|
||||||
|
"conhoo",
|
||||||
|
"conhow",
|
||||||
|
"cunhuw",
|
||||||
|
"capaf",
|
||||||
|
"capas",
|
||||||
|
"capaj",
|
||||||
|
"capar",
|
||||||
|
"capax",
|
||||||
|
"capaw",
|
||||||
|
"capaa",
|
||||||
|
"cepee",
|
||||||
|
"copoo",
|
||||||
|
"copow",
|
||||||
|
"cupuw",
|
||||||
|
"cataf",
|
||||||
|
"catas",
|
||||||
|
"cataj",
|
||||||
|
"catar",
|
||||||
|
"catax",
|
||||||
|
"cataw",
|
||||||
|
"cataa",
|
||||||
|
"cetee",
|
||||||
|
"cotoo",
|
||||||
|
"cotow",
|
||||||
|
"cutuw",
|
||||||
|
"chaaf",
|
||||||
|
"chaas",
|
||||||
|
"chaaj",
|
||||||
|
"chaar",
|
||||||
|
"chaax",
|
||||||
|
"chaaw",
|
||||||
|
"chaaa",
|
||||||
|
"cheee",
|
||||||
|
"chooo",
|
||||||
|
"choow",
|
||||||
|
"chuuw",
|
||||||
|
"chacaf",
|
||||||
|
"chacas",
|
||||||
|
"chacaj",
|
||||||
|
"chacar",
|
||||||
|
"chacax",
|
||||||
|
"chacaw",
|
||||||
|
"chacaa",
|
||||||
|
"checee",
|
||||||
|
"chocoo",
|
||||||
|
"chocow",
|
||||||
|
"chucuw",
|
||||||
|
"chachaf",
|
||||||
|
"chachas",
|
||||||
|
"chachaj",
|
||||||
|
"chachar",
|
||||||
|
"chachax",
|
||||||
|
"chachaw",
|
||||||
|
"chachaa",
|
||||||
|
"chechee",
|
||||||
|
"chochoo",
|
||||||
|
"chochow",
|
||||||
|
"chuchuw",
|
||||||
|
"chamaf",
|
||||||
|
"chamas",
|
||||||
|
"chamaj",
|
||||||
|
"chamar",
|
||||||
|
"chamax",
|
||||||
|
"chamaw",
|
||||||
|
"chamaa",
|
||||||
|
"chemee",
|
||||||
|
"chomoo",
|
||||||
|
"chomow",
|
||||||
|
"chumuw",
|
||||||
|
"chanaf",
|
||||||
|
"chanas",
|
||||||
|
"chanaj",
|
||||||
|
"chanar",
|
||||||
|
"chanax",
|
||||||
|
"chanaw",
|
||||||
|
"chanaa",
|
||||||
|
"chenee",
|
||||||
|
"chonoo",
|
||||||
|
"chonow",
|
||||||
|
"chunuw",
|
||||||
|
"changaf",
|
||||||
|
"changas",
|
||||||
|
"changaj",
|
||||||
|
"changar",
|
||||||
|
"changax",
|
||||||
|
"changaw",
|
||||||
|
"changaa",
|
||||||
|
"chengee",
|
||||||
|
"chongoo",
|
||||||
|
"chongow",
|
||||||
|
"chunguw",
|
||||||
|
"chanhaf",
|
||||||
|
"chanhas",
|
||||||
|
"chanhaj",
|
||||||
|
"chanhar",
|
||||||
|
"chanhax",
|
||||||
|
"chanhaw",
|
||||||
|
"chanhaa",
|
||||||
|
"chenhee",
|
||||||
|
"chonhoo",
|
||||||
|
"chonhow",
|
||||||
|
"chunhuw",
|
||||||
|
"chapaf",
|
||||||
|
"chapas",
|
||||||
|
"chapaj",
|
||||||
|
"chapar",
|
||||||
|
"chapax",
|
||||||
|
"chapaw",
|
||||||
|
"chapaa",
|
||||||
|
"chepee",
|
||||||
|
"chopoo",
|
||||||
|
"chopow",
|
||||||
|
"chupuw",
|
||||||
|
"chataf",
|
||||||
|
"chatas",
|
||||||
|
"chataj",
|
||||||
|
"chatar",
|
||||||
|
"chatax",
|
||||||
|
"chataw",
|
||||||
|
"chataa",
|
||||||
|
"chetee",
|
||||||
|
"chotoo",
|
||||||
|
"chotow",
|
||||||
|
"chutuw",
|
||||||
|
"daaf",
|
||||||
|
"daas",
|
||||||
|
"daaj",
|
||||||
|
"daar",
|
||||||
|
"daax",
|
||||||
|
"daaw",
|
||||||
|
"daaa",
|
||||||
|
"deee",
|
||||||
|
"dooo",
|
||||||
|
"doow",
|
||||||
|
"duuw",
|
||||||
|
"dacaf",
|
||||||
|
"dacas",
|
||||||
|
"dacaj",
|
||||||
|
"dacar",
|
||||||
|
"dacax",
|
||||||
|
"dacaw",
|
||||||
|
"dacaa",
|
||||||
|
"decee",
|
||||||
|
"docoo",
|
||||||
|
"docow",
|
||||||
|
"ducuw",
|
||||||
|
"dachaf",
|
||||||
|
"dachas",
|
||||||
|
"dachaj",
|
||||||
|
"dachar",
|
||||||
|
"dachax",
|
||||||
|
"dachaw",
|
||||||
|
"dachaa",
|
||||||
|
"dechee",
|
||||||
|
"dochoo",
|
||||||
|
"dochow",
|
||||||
|
"duchuw",
|
||||||
|
"damaf",
|
||||||
|
"damas",
|
||||||
|
"damaj",
|
||||||
|
"damar",
|
||||||
|
"damax",
|
||||||
|
"damaw",
|
||||||
|
"damaa",
|
||||||
|
"demee",
|
||||||
|
"domoo",
|
||||||
|
"domow",
|
||||||
|
"dumuw",
|
||||||
|
"danaf",
|
||||||
|
"danas",
|
||||||
|
"danaj",
|
||||||
|
"danar",
|
||||||
|
"danax",
|
||||||
|
"danaw",
|
||||||
|
"danaa",
|
||||||
|
"denee",
|
||||||
|
"donoo",
|
||||||
|
"donow",
|
||||||
|
"dunuw",
|
||||||
|
"dangaf",
|
||||||
|
"dangas",
|
||||||
|
"dangaj",
|
||||||
|
"dangar",
|
||||||
|
"dangax",
|
||||||
|
"dangaw",
|
||||||
|
"dangaa",
|
||||||
|
"dengee",
|
||||||
|
"dongoo",
|
||||||
|
"dongow",
|
||||||
|
"dunguw",
|
||||||
|
"danhaf",
|
||||||
|
"danhas",
|
||||||
|
"danhaj",
|
||||||
|
"danhar",
|
||||||
|
"danhax",
|
||||||
|
"danhaw",
|
||||||
|
"danhaa",
|
||||||
|
"denhee",
|
||||||
|
"donhoo",
|
||||||
|
"donhow",
|
||||||
|
"dunhuw",
|
||||||
|
"dapaf",
|
||||||
|
"dapas",
|
||||||
|
"dapaj",
|
||||||
|
"dapar",
|
||||||
|
"dapax",
|
||||||
|
"dapaw",
|
||||||
|
"dapaa",
|
||||||
|
"depee",
|
||||||
|
"dopoo",
|
||||||
|
"dopow",
|
||||||
|
"dupuw",
|
||||||
|
"dataf",
|
||||||
|
"datas",
|
||||||
|
"dataj",
|
||||||
|
"datar",
|
||||||
|
"datax",
|
||||||
|
"dataw",
|
||||||
|
"dataa",
|
||||||
|
"detee",
|
||||||
|
"dotoo",
|
||||||
|
"dotow",
|
||||||
|
"dutuw",
|
||||||
|
"gaaf",
|
||||||
|
"gaas",
|
||||||
|
"gaaj",
|
||||||
|
"gaar",
|
||||||
|
"gaax",
|
||||||
|
"gaaw",
|
||||||
|
"gaaa",
|
||||||
|
"geee",
|
||||||
|
"gooo",
|
||||||
|
"goow",
|
||||||
|
"guuw",
|
||||||
|
"ganaf",
|
||||||
|
"ganas",
|
||||||
|
"ganaj",
|
||||||
|
"ganar",
|
||||||
|
"ganax",
|
||||||
|
"ganaw",
|
||||||
|
"ganaa",
|
||||||
|
"genee",
|
||||||
|
"gonoo",
|
||||||
|
"gonow",
|
||||||
|
"gunuw",
|
||||||
|
"gangaf",
|
||||||
|
"gangas",
|
||||||
|
"gangaj",
|
||||||
|
"gangar",
|
||||||
|
"gangax"
|
||||||
|
]
|
||||||
502
engine/tests/testdata/vni_inputs.json
vendored
Normal file
502
engine/tests/testdata/vni_inputs.json
vendored
Normal file
|
|
@ -0,0 +1,502 @@
|
||||||
|
[
|
||||||
|
"a1",
|
||||||
|
"a2",
|
||||||
|
"a3",
|
||||||
|
"a4",
|
||||||
|
"a5",
|
||||||
|
"a6",
|
||||||
|
"a8",
|
||||||
|
"e6",
|
||||||
|
"o6",
|
||||||
|
"o7",
|
||||||
|
"u7",
|
||||||
|
"ac1",
|
||||||
|
"ac2",
|
||||||
|
"ac3",
|
||||||
|
"ac4",
|
||||||
|
"ac5",
|
||||||
|
"ac6",
|
||||||
|
"ac8",
|
||||||
|
"ec6",
|
||||||
|
"oc6",
|
||||||
|
"oc7",
|
||||||
|
"uc7",
|
||||||
|
"am1",
|
||||||
|
"am2",
|
||||||
|
"am3",
|
||||||
|
"am4",
|
||||||
|
"am5",
|
||||||
|
"am6",
|
||||||
|
"am8",
|
||||||
|
"em6",
|
||||||
|
"om6",
|
||||||
|
"om7",
|
||||||
|
"um7",
|
||||||
|
"an1",
|
||||||
|
"an2",
|
||||||
|
"an3",
|
||||||
|
"an4",
|
||||||
|
"an5",
|
||||||
|
"an6",
|
||||||
|
"an8",
|
||||||
|
"en6",
|
||||||
|
"on6",
|
||||||
|
"on7",
|
||||||
|
"un7",
|
||||||
|
"ang1",
|
||||||
|
"ang2",
|
||||||
|
"ang3",
|
||||||
|
"ang4",
|
||||||
|
"ang5",
|
||||||
|
"ang6",
|
||||||
|
"ang8",
|
||||||
|
"eng6",
|
||||||
|
"ong6",
|
||||||
|
"ong7",
|
||||||
|
"ung7",
|
||||||
|
"ap1",
|
||||||
|
"ap2",
|
||||||
|
"ap3",
|
||||||
|
"ap4",
|
||||||
|
"ap5",
|
||||||
|
"ap6",
|
||||||
|
"ap8",
|
||||||
|
"ep6",
|
||||||
|
"op6",
|
||||||
|
"op7",
|
||||||
|
"up7",
|
||||||
|
"at1",
|
||||||
|
"at2",
|
||||||
|
"at3",
|
||||||
|
"at4",
|
||||||
|
"at5",
|
||||||
|
"at6",
|
||||||
|
"at8",
|
||||||
|
"et6",
|
||||||
|
"ot6",
|
||||||
|
"ot7",
|
||||||
|
"ut7",
|
||||||
|
"ba1",
|
||||||
|
"ba2",
|
||||||
|
"ba3",
|
||||||
|
"ba4",
|
||||||
|
"ba5",
|
||||||
|
"ba6",
|
||||||
|
"ba8",
|
||||||
|
"be6",
|
||||||
|
"bo6",
|
||||||
|
"bo7",
|
||||||
|
"bu7",
|
||||||
|
"bac1",
|
||||||
|
"bac2",
|
||||||
|
"bac3",
|
||||||
|
"bac4",
|
||||||
|
"bac5",
|
||||||
|
"bac6",
|
||||||
|
"bac8",
|
||||||
|
"bec6",
|
||||||
|
"boc6",
|
||||||
|
"boc7",
|
||||||
|
"buc7",
|
||||||
|
"bach1",
|
||||||
|
"bach2",
|
||||||
|
"bach3",
|
||||||
|
"bach4",
|
||||||
|
"bach5",
|
||||||
|
"bach6",
|
||||||
|
"bach8",
|
||||||
|
"bech6",
|
||||||
|
"boch6",
|
||||||
|
"boch7",
|
||||||
|
"buch7",
|
||||||
|
"bam1",
|
||||||
|
"bam2",
|
||||||
|
"bam3",
|
||||||
|
"bam4",
|
||||||
|
"bam5",
|
||||||
|
"bam6",
|
||||||
|
"bam8",
|
||||||
|
"bem6",
|
||||||
|
"bom6",
|
||||||
|
"bom7",
|
||||||
|
"bum7",
|
||||||
|
"ban1",
|
||||||
|
"ban2",
|
||||||
|
"ban3",
|
||||||
|
"ban4",
|
||||||
|
"ban5",
|
||||||
|
"ban6",
|
||||||
|
"ban8",
|
||||||
|
"ben6",
|
||||||
|
"bon6",
|
||||||
|
"bon7",
|
||||||
|
"bun7",
|
||||||
|
"bang1",
|
||||||
|
"bang2",
|
||||||
|
"bang3",
|
||||||
|
"bang4",
|
||||||
|
"bang5",
|
||||||
|
"bang6",
|
||||||
|
"bang8",
|
||||||
|
"beng6",
|
||||||
|
"bong6",
|
||||||
|
"bong7",
|
||||||
|
"bung7",
|
||||||
|
"banh1",
|
||||||
|
"banh2",
|
||||||
|
"banh3",
|
||||||
|
"banh4",
|
||||||
|
"banh5",
|
||||||
|
"banh6",
|
||||||
|
"banh8",
|
||||||
|
"benh6",
|
||||||
|
"bonh6",
|
||||||
|
"bonh7",
|
||||||
|
"bunh7",
|
||||||
|
"bap1",
|
||||||
|
"bap2",
|
||||||
|
"bap3",
|
||||||
|
"bap4",
|
||||||
|
"bap5",
|
||||||
|
"bap6",
|
||||||
|
"bap8",
|
||||||
|
"bep6",
|
||||||
|
"bop6",
|
||||||
|
"bop7",
|
||||||
|
"bup7",
|
||||||
|
"bat1",
|
||||||
|
"bat2",
|
||||||
|
"bat3",
|
||||||
|
"bat4",
|
||||||
|
"bat5",
|
||||||
|
"bat6",
|
||||||
|
"bat8",
|
||||||
|
"bet6",
|
||||||
|
"bot6",
|
||||||
|
"bot7",
|
||||||
|
"but7",
|
||||||
|
"ca1",
|
||||||
|
"ca2",
|
||||||
|
"ca3",
|
||||||
|
"ca4",
|
||||||
|
"ca5",
|
||||||
|
"ca6",
|
||||||
|
"ca8",
|
||||||
|
"ce6",
|
||||||
|
"co6",
|
||||||
|
"co7",
|
||||||
|
"cu7",
|
||||||
|
"cac1",
|
||||||
|
"cac2",
|
||||||
|
"cac3",
|
||||||
|
"cac4",
|
||||||
|
"cac5",
|
||||||
|
"cac6",
|
||||||
|
"cac8",
|
||||||
|
"cec6",
|
||||||
|
"coc6",
|
||||||
|
"coc7",
|
||||||
|
"cuc7",
|
||||||
|
"cach1",
|
||||||
|
"cach2",
|
||||||
|
"cach3",
|
||||||
|
"cach4",
|
||||||
|
"cach5",
|
||||||
|
"cach6",
|
||||||
|
"cach8",
|
||||||
|
"cech6",
|
||||||
|
"coch6",
|
||||||
|
"coch7",
|
||||||
|
"cuch7",
|
||||||
|
"cam1",
|
||||||
|
"cam2",
|
||||||
|
"cam3",
|
||||||
|
"cam4",
|
||||||
|
"cam5",
|
||||||
|
"cam6",
|
||||||
|
"cam8",
|
||||||
|
"cem6",
|
||||||
|
"com6",
|
||||||
|
"com7",
|
||||||
|
"cum7",
|
||||||
|
"can1",
|
||||||
|
"can2",
|
||||||
|
"can3",
|
||||||
|
"can4",
|
||||||
|
"can5",
|
||||||
|
"can6",
|
||||||
|
"can8",
|
||||||
|
"cen6",
|
||||||
|
"con6",
|
||||||
|
"con7",
|
||||||
|
"cun7",
|
||||||
|
"cang1",
|
||||||
|
"cang2",
|
||||||
|
"cang3",
|
||||||
|
"cang4",
|
||||||
|
"cang5",
|
||||||
|
"cang6",
|
||||||
|
"cang8",
|
||||||
|
"ceng6",
|
||||||
|
"cong6",
|
||||||
|
"cong7",
|
||||||
|
"cung7",
|
||||||
|
"canh1",
|
||||||
|
"canh2",
|
||||||
|
"canh3",
|
||||||
|
"canh4",
|
||||||
|
"canh5",
|
||||||
|
"canh6",
|
||||||
|
"canh8",
|
||||||
|
"cenh6",
|
||||||
|
"conh6",
|
||||||
|
"conh7",
|
||||||
|
"cunh7",
|
||||||
|
"cap1",
|
||||||
|
"cap2",
|
||||||
|
"cap3",
|
||||||
|
"cap4",
|
||||||
|
"cap5",
|
||||||
|
"cap6",
|
||||||
|
"cap8",
|
||||||
|
"cep6",
|
||||||
|
"cop6",
|
||||||
|
"cop7",
|
||||||
|
"cup7",
|
||||||
|
"cat1",
|
||||||
|
"cat2",
|
||||||
|
"cat3",
|
||||||
|
"cat4",
|
||||||
|
"cat5",
|
||||||
|
"cat6",
|
||||||
|
"cat8",
|
||||||
|
"cet6",
|
||||||
|
"cot6",
|
||||||
|
"cot7",
|
||||||
|
"cut7",
|
||||||
|
"cha1",
|
||||||
|
"cha2",
|
||||||
|
"cha3",
|
||||||
|
"cha4",
|
||||||
|
"cha5",
|
||||||
|
"cha6",
|
||||||
|
"cha8",
|
||||||
|
"che6",
|
||||||
|
"cho6",
|
||||||
|
"cho7",
|
||||||
|
"chu7",
|
||||||
|
"chac1",
|
||||||
|
"chac2",
|
||||||
|
"chac3",
|
||||||
|
"chac4",
|
||||||
|
"chac5",
|
||||||
|
"chac6",
|
||||||
|
"chac8",
|
||||||
|
"chec6",
|
||||||
|
"choc6",
|
||||||
|
"choc7",
|
||||||
|
"chuc7",
|
||||||
|
"chach1",
|
||||||
|
"chach2",
|
||||||
|
"chach3",
|
||||||
|
"chach4",
|
||||||
|
"chach5",
|
||||||
|
"chach6",
|
||||||
|
"chach8",
|
||||||
|
"chech6",
|
||||||
|
"choch6",
|
||||||
|
"choch7",
|
||||||
|
"chuch7",
|
||||||
|
"cham1",
|
||||||
|
"cham2",
|
||||||
|
"cham3",
|
||||||
|
"cham4",
|
||||||
|
"cham5",
|
||||||
|
"cham6",
|
||||||
|
"cham8",
|
||||||
|
"chem6",
|
||||||
|
"chom6",
|
||||||
|
"chom7",
|
||||||
|
"chum7",
|
||||||
|
"chan1",
|
||||||
|
"chan2",
|
||||||
|
"chan3",
|
||||||
|
"chan4",
|
||||||
|
"chan5",
|
||||||
|
"chan6",
|
||||||
|
"chan8",
|
||||||
|
"chen6",
|
||||||
|
"chon6",
|
||||||
|
"chon7",
|
||||||
|
"chun7",
|
||||||
|
"chang1",
|
||||||
|
"chang2",
|
||||||
|
"chang3",
|
||||||
|
"chang4",
|
||||||
|
"chang5",
|
||||||
|
"chang6",
|
||||||
|
"chang8",
|
||||||
|
"cheng6",
|
||||||
|
"chong6",
|
||||||
|
"chong7",
|
||||||
|
"chung7",
|
||||||
|
"chanh1",
|
||||||
|
"chanh2",
|
||||||
|
"chanh3",
|
||||||
|
"chanh4",
|
||||||
|
"chanh5",
|
||||||
|
"chanh6",
|
||||||
|
"chanh8",
|
||||||
|
"chenh6",
|
||||||
|
"chonh6",
|
||||||
|
"chonh7",
|
||||||
|
"chunh7",
|
||||||
|
"chap1",
|
||||||
|
"chap2",
|
||||||
|
"chap3",
|
||||||
|
"chap4",
|
||||||
|
"chap5",
|
||||||
|
"chap6",
|
||||||
|
"chap8",
|
||||||
|
"chep6",
|
||||||
|
"chop6",
|
||||||
|
"chop7",
|
||||||
|
"chup7",
|
||||||
|
"chat1",
|
||||||
|
"chat2",
|
||||||
|
"chat3",
|
||||||
|
"chat4",
|
||||||
|
"chat5",
|
||||||
|
"chat6",
|
||||||
|
"chat8",
|
||||||
|
"chet6",
|
||||||
|
"chot6",
|
||||||
|
"chot7",
|
||||||
|
"chut7",
|
||||||
|
"da1",
|
||||||
|
"da2",
|
||||||
|
"da3",
|
||||||
|
"da4",
|
||||||
|
"da5",
|
||||||
|
"da6",
|
||||||
|
"da8",
|
||||||
|
"de6",
|
||||||
|
"do6",
|
||||||
|
"do7",
|
||||||
|
"du7",
|
||||||
|
"dac1",
|
||||||
|
"dac2",
|
||||||
|
"dac3",
|
||||||
|
"dac4",
|
||||||
|
"dac5",
|
||||||
|
"dac6",
|
||||||
|
"dac8",
|
||||||
|
"dec6",
|
||||||
|
"doc6",
|
||||||
|
"doc7",
|
||||||
|
"duc7",
|
||||||
|
"dach1",
|
||||||
|
"dach2",
|
||||||
|
"dach3",
|
||||||
|
"dach4",
|
||||||
|
"dach5",
|
||||||
|
"dach6",
|
||||||
|
"dach8",
|
||||||
|
"dech6",
|
||||||
|
"doch6",
|
||||||
|
"doch7",
|
||||||
|
"duch7",
|
||||||
|
"dam1",
|
||||||
|
"dam2",
|
||||||
|
"dam3",
|
||||||
|
"dam4",
|
||||||
|
"dam5",
|
||||||
|
"dam6",
|
||||||
|
"dam8",
|
||||||
|
"dem6",
|
||||||
|
"dom6",
|
||||||
|
"dom7",
|
||||||
|
"dum7",
|
||||||
|
"dan1",
|
||||||
|
"dan2",
|
||||||
|
"dan3",
|
||||||
|
"dan4",
|
||||||
|
"dan5",
|
||||||
|
"dan6",
|
||||||
|
"dan8",
|
||||||
|
"den6",
|
||||||
|
"don6",
|
||||||
|
"don7",
|
||||||
|
"dun7",
|
||||||
|
"dang1",
|
||||||
|
"dang2",
|
||||||
|
"dang3",
|
||||||
|
"dang4",
|
||||||
|
"dang5",
|
||||||
|
"dang6",
|
||||||
|
"dang8",
|
||||||
|
"deng6",
|
||||||
|
"dong6",
|
||||||
|
"dong7",
|
||||||
|
"dung7",
|
||||||
|
"danh1",
|
||||||
|
"danh2",
|
||||||
|
"danh3",
|
||||||
|
"danh4",
|
||||||
|
"danh5",
|
||||||
|
"danh6",
|
||||||
|
"danh8",
|
||||||
|
"denh6",
|
||||||
|
"donh6",
|
||||||
|
"donh7",
|
||||||
|
"dunh7",
|
||||||
|
"dap1",
|
||||||
|
"dap2",
|
||||||
|
"dap3",
|
||||||
|
"dap4",
|
||||||
|
"dap5",
|
||||||
|
"dap6",
|
||||||
|
"dap8",
|
||||||
|
"dep6",
|
||||||
|
"dop6",
|
||||||
|
"dop7",
|
||||||
|
"dup7",
|
||||||
|
"dat1",
|
||||||
|
"dat2",
|
||||||
|
"dat3",
|
||||||
|
"dat4",
|
||||||
|
"dat5",
|
||||||
|
"dat6",
|
||||||
|
"dat8",
|
||||||
|
"det6",
|
||||||
|
"dot6",
|
||||||
|
"dot7",
|
||||||
|
"dut7",
|
||||||
|
"ga1",
|
||||||
|
"ga2",
|
||||||
|
"ga3",
|
||||||
|
"ga4",
|
||||||
|
"ga5",
|
||||||
|
"ga6",
|
||||||
|
"ga8",
|
||||||
|
"ge6",
|
||||||
|
"go6",
|
||||||
|
"go7",
|
||||||
|
"gu7",
|
||||||
|
"gan1",
|
||||||
|
"gan2",
|
||||||
|
"gan3",
|
||||||
|
"gan4",
|
||||||
|
"gan5",
|
||||||
|
"gan6",
|
||||||
|
"gan8",
|
||||||
|
"gen6",
|
||||||
|
"gon6",
|
||||||
|
"gon7",
|
||||||
|
"gun7",
|
||||||
|
"gang1",
|
||||||
|
"gang2",
|
||||||
|
"gang3",
|
||||||
|
"gang4",
|
||||||
|
"gang5"
|
||||||
|
]
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Ensure cargo is in PATH (common for rustup installations)
|
# Ensure cargo is in PATH
|
||||||
if ! command -v cargo &>/dev/null; then
|
if ! command -v cargo &>/dev/null; then
|
||||||
if [ -f "$HOME/.cargo/bin/cargo" ]; then
|
if [ -f "$HOME/.cargo/bin/cargo" ]; then
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
|
@ -22,81 +22,46 @@ mkdir -p "$APPDIR/usr/share/applications"
|
||||||
mkdir -p "$APPDIR/usr/share/icons/hicolor/256x256/apps"
|
mkdir -p "$APPDIR/usr/share/icons/hicolor/256x256/apps"
|
||||||
mkdir -p "$APPDIR/usr/share/doc/vietc"
|
mkdir -p "$APPDIR/usr/share/doc/vietc"
|
||||||
mkdir -p "$APPDIR/etc/vietc"
|
mkdir -p "$APPDIR/etc/vietc"
|
||||||
|
mkdir -p "$APPDIR/usr/lib/systemd/user"
|
||||||
|
mkdir -p "$APPDIR/usr/share/metainfo"
|
||||||
|
|
||||||
# Build binaries
|
# Build binaries
|
||||||
echo "[1/5] Building binaries..."
|
echo "[1/5] Building binaries..."
|
||||||
|
if [ ! -f "target/release/vietc" ]; then
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
cd "$PROJECT_ROOT/ui" && cargo build --release && cd "$PROJECT_ROOT"
|
||||||
|
fi
|
||||||
echo " Built with x11 + wayland"
|
echo " Built with x11 + wayland"
|
||||||
|
|
||||||
|
# Copy binaries from deb-build if they exist, otherwise from target/release
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
cd "$PROJECT_ROOT/ui" && cargo build --release && cd "$SCRIPT_DIR"
|
|
||||||
cd "$PROJECT_ROOT"
|
|
||||||
|
|
||||||
# Copy binaries
|
|
||||||
echo "[2/5] Installing binaries..."
|
echo "[2/5] Installing binaries..."
|
||||||
|
if [ -d "deb-build/usr/bin" ]; then
|
||||||
|
cp -r deb-build/usr/bin/* "$APPDIR/usr/bin/"
|
||||||
|
else
|
||||||
cp target/release/vietc "$APPDIR/usr/bin/"
|
cp target/release/vietc "$APPDIR/usr/bin/"
|
||||||
cp target/release/vietc-cli "$APPDIR/usr/bin/"
|
cp target/release/vietc-cli "$APPDIR/usr/bin/"
|
||||||
[ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/"
|
[ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/"
|
||||||
|
fi
|
||||||
|
|
||||||
# Desktop integration
|
# Desktop integration
|
||||||
echo "[3/5] Installing desktop integration..."
|
echo "[3/5] Installing desktop integration..."
|
||||||
cp "$SCRIPT_DIR/vietc.desktop" "$APPDIR/usr/share/applications/"
|
if [ -f "deb-build/vietc.desktop" ]; then
|
||||||
|
cp deb-build/vietc.desktop "$APPDIR/usr/share/applications/"
|
||||||
# Generate SVG icon
|
|
||||||
cat > "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" << 'SVGEOF'
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
|
||||||
<rect x="20" y="60" width="216" height="140" rx="16" fill="#2d2d2d" stroke="#1a1a1a" stroke-width="4"/>
|
|
||||||
<rect x="36" y="76" width="184" height="108" rx="8" fill="#3d3d3d"/>
|
|
||||||
<rect x="48" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
|
||||||
<rect x="78" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
|
||||||
<rect x="108" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
|
||||||
<rect x="138" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
|
||||||
<rect x="168" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
|
||||||
<rect x="198" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
|
||||||
<rect x="54" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
|
||||||
<rect x="84" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
|
||||||
<rect x="114" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
|
||||||
<rect x="144" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
|
||||||
<rect x="174" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
|
||||||
<rect x="60" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
|
||||||
<rect x="90" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
|
||||||
<rect x="120" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
|
||||||
<rect x="150" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
|
||||||
<rect x="180" y="140" width="42" height="20" rx="3" fill="#f0f0f0"/>
|
|
||||||
<rect x="72" y="166" width="112" height="16" rx="3" fill="#f0f0f0"/>
|
|
||||||
<circle cx="216" cy="48" r="28" fill="#da251d"/>
|
|
||||||
<text x="216" y="56" text-anchor="middle" fill="white" font-size="18" font-weight="bold" font-family="sans-serif">VN</text>
|
|
||||||
</svg>
|
|
||||||
SVGEOF
|
|
||||||
|
|
||||||
# Convert SVG to PNG if rsvg-convert available
|
|
||||||
if command -v rsvg-convert &>/dev/null; then
|
|
||||||
rsvg-convert -w 256 -h 256 "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" \
|
|
||||||
-o "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.png"
|
|
||||||
else
|
else
|
||||||
# Fallback: generate PNG via Python/Pillow
|
cp "$SCRIPT_DIR/vietc.desktop" "$APPDIR/usr/share/applications/"
|
||||||
python3 -c "
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
img = Image.new('RGBA', (256, 256), (0,0,0,0))
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
draw.ellipse([(20,20),(236,236)], fill=(218,29,37), outline=(180,20,30), width=4)
|
|
||||||
try:
|
|
||||||
from PIL import ImageFont
|
|
||||||
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 80)
|
|
||||||
except:
|
|
||||||
font = ImageFont.load_default()
|
|
||||||
draw.text((128, 128), 'VN', fill=(255,255,255), font=font, anchor='mm')
|
|
||||||
img.save('$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.png')
|
|
||||||
" 2>/dev/null || echo " PNG icon generation skipped (no Pillow)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Copy icon to AppDir root for appimagetool
|
# Icons
|
||||||
cp "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc."{png,svg} "$APPDIR/" 2>/dev/null || true
|
if [ -f "deb-build/vietc.svg" ]; then
|
||||||
|
cp deb-build/vietc.svg "$APPDIR/usr/share/icons/hicolor/256x256/apps/"
|
||||||
|
cp deb-build/vietc.png "$APPDIR/usr/share/icons/hicolor/256x256/apps/"
|
||||||
|
cp deb-build/vietc.png "$APPDIR/"
|
||||||
|
fi
|
||||||
|
|
||||||
# AppStream metadata
|
# AppStream metadata
|
||||||
mkdir -p "$APPDIR/usr/share/metainfo"
|
if [ -f "deb-build/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" ]; then
|
||||||
|
cp deb-build/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml "$APPDIR/usr/share/metainfo/"
|
||||||
|
else
|
||||||
cat > "$APPDIR/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" << 'XML'
|
cat > "$APPDIR/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" << 'XML'
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<component type="console-application">
|
<component type="console-application">
|
||||||
|
|
@ -113,19 +78,36 @@ cat > "$APPDIR/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" << 'XML
|
||||||
<categories><category>Utility</category></categories>
|
<categories><category>Utility</category></categories>
|
||||||
</component>
|
</component>
|
||||||
XML
|
XML
|
||||||
|
fi
|
||||||
|
|
||||||
# Config
|
# Config
|
||||||
echo "[4/5] Installing config..."
|
echo "[4/5] Installing config..."
|
||||||
# Use grab=true by default in the AppImage; falls back gracefully for non-root
|
if [ -f "deb-build/etc/vietc/config.toml" ]; then
|
||||||
|
cp deb-build/etc/vietc/config.toml "$APPDIR/etc/vietc/"
|
||||||
|
else
|
||||||
sed 's/^grab = false/grab = true/' "$PROJECT_ROOT/vietc.toml" > "$APPDIR/etc/vietc/config.toml"
|
sed 's/^grab = false/grab = true/' "$PROJECT_ROOT/vietc.toml" > "$APPDIR/etc/vietc/config.toml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Docs
|
||||||
|
if [ -f "deb-build/usr/share/doc/vietc/README.md" ]; then
|
||||||
|
cp deb-build/usr/share/doc/vietc/README.md "$APPDIR/usr/share/doc/vietc/"
|
||||||
|
else
|
||||||
cp "$PROJECT_ROOT/README.md" "$APPDIR/usr/share/doc/vietc/"
|
cp "$PROJECT_ROOT/README.md" "$APPDIR/usr/share/doc/vietc/"
|
||||||
|
fi
|
||||||
|
|
||||||
# Systemd service
|
# Systemd service
|
||||||
mkdir -p "$APPDIR/usr/lib/systemd/user"
|
if [ -f "deb-build/usr/lib/systemd/user/vietc.service" ]; then
|
||||||
|
cp deb-build/usr/lib/systemd/user/vietc.service "$APPDIR/usr/lib/systemd/user/"
|
||||||
|
else
|
||||||
cp "$PROJECT_ROOT/vietc.service" "$APPDIR/usr/lib/systemd/user/"
|
cp "$PROJECT_ROOT/vietc.service" "$APPDIR/usr/lib/systemd/user/"
|
||||||
|
fi
|
||||||
|
|
||||||
# Desktop file in AppDir root
|
# Desktop file in AppDir root
|
||||||
|
if [ -f "deb-build/vietc.desktop" ]; then
|
||||||
|
cp deb-build/vietc.desktop "$APPDIR/"
|
||||||
|
else
|
||||||
cp "$APPDIR/usr/share/applications/vietc.desktop" "$APPDIR/"
|
cp "$APPDIR/usr/share/applications/vietc.desktop" "$APPDIR/"
|
||||||
|
fi
|
||||||
|
|
||||||
# Create custom AppRun script
|
# Create custom AppRun script
|
||||||
cat > "$APPDIR/AppRun" << 'EOF'
|
cat > "$APPDIR/AppRun" << 'EOF'
|
||||||
|
|
@ -135,8 +117,17 @@ HERE="$(dirname "$(readlink -f "${0}")")"
|
||||||
# Export our bin dir on PATH so child processes can find sibling binaries
|
# Export our bin dir on PATH so child processes can find sibling binaries
|
||||||
export PATH="$HERE/usr/bin:$PATH"
|
export PATH="$HERE/usr/bin:$PATH"
|
||||||
|
|
||||||
|
# Build display env prefix for elevation commands.
|
||||||
|
# Capture from current user env (DISPLAY, XAUTHORITY, WAYLAND_DISPLAY, XDG_RUNTIME_DIR)
|
||||||
|
# so they are available inside the root daemon. Without this, xdotool/xclip/wtype
|
||||||
|
# fail silently because sudo/pkexec strip display env vars.
|
||||||
|
ENV_PREFIX="env"
|
||||||
|
[ -n "$DISPLAY" ] && ENV_PREFIX="$ENV_PREFIX DISPLAY=$DISPLAY"
|
||||||
|
[ -n "$XAUTHORITY" ] && ENV_PREFIX="$ENV_PREFIX XAUTHORITY=$XAUTHORITY"
|
||||||
|
[ -n "$WAYLAND_DISPLAY" ] && ENV_PREFIX="$ENV_PREFIX WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
|
||||||
|
[ -n "$XDG_RUNTIME_DIR" ] && ENV_PREFIX="$ENV_PREFIX XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
|
||||||
|
|
||||||
# Start daemon (kill old non-root one first if we have root)
|
# Start daemon (kill old non-root one first if we have root)
|
||||||
SUDO_CMD=""
|
|
||||||
|
|
||||||
# Fix Wayland env for root: sudo resets XDG_RUNTIME_DIR, breaking wtype/wl-copy.
|
# Fix Wayland env for root: sudo resets XDG_RUNTIME_DIR, breaking wtype/wl-copy.
|
||||||
# Only set WAYLAND_DISPLAY if the user actually has a Wayland session.
|
# Only set WAYLAND_DISPLAY if the user actually has a Wayland session.
|
||||||
|
|
@ -149,7 +140,9 @@ if [ "$(id -u)" = "0" ] && [ -z "$XDG_RUNTIME_DIR" ] && [ -n "$SUDO_USER" ]; the
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v pkexec >/dev/null && [ -z "$WAYLAND_DISPLAY" ]; then
|
if command -v pkexec >/dev/null && [ -z "$WAYLAND_DISPLAY" ]; then
|
||||||
SUDO_CMD="pkexec"
|
pkill -x vietc 2>/dev/null; sleep 0.5
|
||||||
|
pkexec $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null &
|
||||||
|
DAEMON_PID=$!
|
||||||
elif [ -n "$WAYLAND_DISPLAY" ]; then
|
elif [ -n "$WAYLAND_DISPLAY" ]; then
|
||||||
password=""
|
password=""
|
||||||
if command -v kdialog >/dev/null; then
|
if command -v kdialog >/dev/null; then
|
||||||
|
|
@ -161,24 +154,12 @@ elif [ -n "$WAYLAND_DISPLAY" ]; then
|
||||||
fi
|
fi
|
||||||
if [ -n "$password" ]; then
|
if [ -n "$password" ]; then
|
||||||
pkill -x vietc 2>/dev/null; sleep 0.5
|
pkill -x vietc 2>/dev/null; sleep 0.5
|
||||||
echo "$password" | sudo -S env \
|
echo "$password" | sudo -S $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null &
|
||||||
XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" \
|
|
||||||
WAYLAND_DISPLAY="$WAYLAND_DISPLAY" \
|
|
||||||
"$HERE/usr/bin/vietc" >/dev/null &
|
|
||||||
DAEMON_PID=$!
|
DAEMON_PID=$!
|
||||||
fi
|
fi
|
||||||
elif command -v sudo >/dev/null; then
|
elif command -v sudo >/dev/null; then
|
||||||
SUDO_CMD="sudo"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$SUDO_CMD" ]; then
|
|
||||||
pkill -x vietc 2>/dev/null; sleep 0.5
|
pkill -x vietc 2>/dev/null; sleep 0.5
|
||||||
if [ "$(id -u)" = "0" ]; then
|
sudo $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null &
|
||||||
# Already root: run daemon with stderr visible (stdout to /dev/null)
|
|
||||||
"$HERE/usr/bin/vietc" >/dev/null &
|
|
||||||
else
|
|
||||||
"$SUDO_CMD" "$HERE/usr/bin/vietc" >/dev/null &
|
|
||||||
fi
|
|
||||||
DAEMON_PID=$!
|
DAEMON_PID=$!
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -212,7 +193,6 @@ echo ""
|
||||||
# Auto build if appimagetool exists
|
# Auto build if appimagetool exists
|
||||||
if [ -f "$SCRIPT_DIR/appimagetool" ]; then
|
if [ -f "$SCRIPT_DIR/appimagetool" ]; then
|
||||||
echo "=== Running appimagetool FUSE build ==="
|
echo "=== Running appimagetool FUSE build ==="
|
||||||
# AppImage inside container/VM sometimes needs --appimage-extract-and-run if FUSE is not mounted
|
|
||||||
ARCH=x86_64 "$SCRIPT_DIR/appimagetool" --appimage-extract-and-run "$APPDIR" "$SCRIPT_DIR/Viet+-${VERSION}-x86_64.AppImage"
|
ARCH=x86_64 "$SCRIPT_DIR/appimagetool" --appimage-extract-and-run "$APPDIR" "$SCRIPT_DIR/Viet+-${VERSION}-x86_64.AppImage"
|
||||||
elif command -v appimagetool &>/dev/null; then
|
elif command -v appimagetool &>/dev/null; then
|
||||||
echo "=== Running system appimagetool ==="
|
echo "=== Running system appimagetool ==="
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,19 @@ pub struct KeyEvent {
|
||||||
|
|
||||||
impl KeyEvent {
|
impl KeyEvent {
|
||||||
pub fn press(code: u32, value: char) -> Self {
|
pub fn press(code: u32, value: char) -> Self {
|
||||||
Self { code, value, action: KeyAction::Press }
|
Self {
|
||||||
|
code,
|
||||||
|
value,
|
||||||
|
action: KeyAction::Press,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn release(code: u32, value: char) -> Self {
|
pub fn release(code: u32, value: char) -> Self {
|
||||||
Self { code, value, action: KeyAction::Release }
|
Self {
|
||||||
|
code,
|
||||||
|
value,
|
||||||
|
action: KeyAction::Release,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_press(&self) -> bool {
|
pub fn is_press(&self) -> bool {
|
||||||
|
|
@ -64,6 +72,12 @@ pub trait KeyInjector {
|
||||||
}
|
}
|
||||||
self.send_string(text)
|
self.send_string(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record that Unicode text was pasted via clipboard (for future delete/backspace support)
|
||||||
|
fn update_pasted_text(&self, _text: &str) -> InjectResult {
|
||||||
|
// Stub implementation - actual text tracking happens in engine via OutputCommand::Type
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for InjectResult {
|
impl fmt::Display for InjectResult {
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,7 @@ impl UinputInjector {
|
||||||
ioctl(fd, UI_DEV_SETUP, &usetup as *const uinput_setup as u64)
|
ioctl(fd, UI_DEV_SETUP, &usetup as *const uinput_setup as u64)
|
||||||
.map_err(|e| format!("UI_DEV_SETUP failed: {}", e))?;
|
.map_err(|e| format!("UI_DEV_SETUP failed: {}", e))?;
|
||||||
|
|
||||||
ioctl(fd, UI_DEV_CREATE, 0)
|
ioctl(fd, UI_DEV_CREATE, 0).map_err(|e| format!("UI_DEV_CREATE failed: {}", e))?;
|
||||||
.map_err(|e| format!("UI_DEV_CREATE failed: {}", e))?;
|
|
||||||
|
|
||||||
// Small delay for device to be ready
|
// Small delay for device to be ready
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
|
@ -69,7 +68,10 @@ impl UinputInjector {
|
||||||
|
|
||||||
fn send_uinput_event(&self, type_: u16, code: u16, value: i32) {
|
fn send_uinput_event(&self, type_: u16, code: u16, value: i32) {
|
||||||
let event = input_event {
|
let event = input_event {
|
||||||
time: timeval { tv_sec: 0, tv_usec: 0 },
|
time: timeval {
|
||||||
|
tv_sec: 0,
|
||||||
|
tv_usec: 0,
|
||||||
|
},
|
||||||
type_,
|
type_,
|
||||||
code,
|
code,
|
||||||
value,
|
value,
|
||||||
|
|
@ -78,7 +80,33 @@ impl UinputInjector {
|
||||||
unsafe {
|
unsafe {
|
||||||
let ptr = &event as *const input_event as *const u8;
|
let ptr = &event as *const input_event as *const u8;
|
||||||
let len = std::mem::size_of::<input_event>();
|
let len = std::mem::size_of::<input_event>();
|
||||||
let _ = libc::write(self.file.as_raw_fd() as libc::c_int, ptr as *const libc::c_void, len);
|
let _ = libc::write(
|
||||||
|
self.file.as_raw_fd() as libc::c_int,
|
||||||
|
ptr as *const libc::c_void,
|
||||||
|
len,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_key_stroke(&self, keycode: u16, shift: bool) {
|
||||||
|
if shift {
|
||||||
|
self.send_uinput_event(EV_KEY, 42, 1); // Shift press
|
||||||
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.send_uinput_event(EV_KEY, keycode, 1); // Key press
|
||||||
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
|
|
||||||
|
self.send_uinput_event(EV_KEY, keycode, 0); // Key release
|
||||||
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
|
|
||||||
|
if shift {
|
||||||
|
self.send_uinput_event(EV_KEY, 42, 0); // Shift release
|
||||||
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -86,44 +114,95 @@ impl UinputInjector {
|
||||||
impl KeyInjector for UinputInjector {
|
impl KeyInjector for UinputInjector {
|
||||||
fn send_backspace(&self) -> InjectResult {
|
fn send_backspace(&self) -> InjectResult {
|
||||||
self.send_uinput_event(EV_KEY, 14, 1); // KEY_BACKSPACE press
|
self.send_uinput_event(EV_KEY, 14, 1); // KEY_BACKSPACE press
|
||||||
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
|
|
||||||
self.send_uinput_event(EV_KEY, 14, 0); // KEY_BACKSPACE release
|
self.send_uinput_event(EV_KEY, 14, 0); // KEY_BACKSPACE release
|
||||||
self.send_uinput_event(0, 0, 0); // EV_SYN
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
|
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_key_event(&self, keycode: u16, value: i32) -> InjectResult {
|
fn send_key_event(&self, keycode: u16, value: i32) -> InjectResult {
|
||||||
self.send_uinput_event(EV_KEY, keycode, value);
|
self.send_uinput_event(EV_KEY, keycode, value);
|
||||||
self.send_uinput_event(0, 0, 0);
|
self.send_uinput_event(0, 0, 0);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_char(&self, ch: char) -> InjectResult {
|
fn send_char(&self, ch: char) -> InjectResult {
|
||||||
if let Some(keycode) = char_to_linux_keycode(ch) {
|
if let Some(keycode) = char_to_linux_keycode(ch) {
|
||||||
let needs_shift = ch.is_uppercase() || "!@#$%^&*()_+{}|:\"<>?".contains(ch);
|
let needs_shift = ch.is_uppercase() || "!@#$%^&*()_+{}|:\"<>?".contains(ch);
|
||||||
if needs_shift {
|
self.send_key_stroke(keycode, needs_shift);
|
||||||
self.send_uinput_event(EV_KEY, 42, 1); // KEY_LEFTSHIFT
|
eprintln!(
|
||||||
}
|
"[vietc] send_char: ASCII '{}' via uinput",
|
||||||
self.send_uinput_event(EV_KEY, keycode, 1);
|
ch.escape_default()
|
||||||
self.send_uinput_event(EV_KEY, keycode, 0);
|
);
|
||||||
if needs_shift {
|
|
||||||
self.send_uinput_event(EV_KEY, 42, 0);
|
|
||||||
}
|
|
||||||
self.send_uinput_event(0, 0, 0);
|
|
||||||
return InjectResult::Success;
|
return InjectResult::Success;
|
||||||
}
|
}
|
||||||
// Unicode: copy to clipboard and paste (preserves uinput ordering)
|
// Unicode character: use clipboard fallback for reliable injection
|
||||||
self.paste_string(&ch.to_string());
|
let text = ch.to_string();
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] send_char: Unicode '{}' - using clipboard",
|
||||||
|
text.escape_default()
|
||||||
|
);
|
||||||
|
|
||||||
|
let copied = self.copy_to_clipboard(&text);
|
||||||
|
if copied {
|
||||||
|
eprintln!("[vietc] send_char: clipboard OK, sending Ctrl+V");
|
||||||
|
self.send_ctrl_v();
|
||||||
|
eprintln!("[vietc] send_char complete (clipboard)");
|
||||||
|
return InjectResult::Success;
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] send_char failed for '{}' (clipboard unavailable)",
|
||||||
|
text.escape_default()
|
||||||
|
);
|
||||||
|
// Last resort: try uinput directly (may not work on all systems)
|
||||||
|
eprintln!("[vietc] send_char fallback: trying direct injection...");
|
||||||
|
self.paste_string(&text);
|
||||||
|
}
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_string(&self, s: &str) -> InjectResult {
|
fn send_string(&self, s: &str) -> InjectResult {
|
||||||
// If all ASCII, use keycodes directly (fast path)
|
// ASCII characters: inject directly via uinput keycodes
|
||||||
if s.chars().all(|c| char_to_linux_keycode(c).is_some()) {
|
let is_ascii = s.chars().all(|c| char_to_linux_keycode(c).is_some());
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] send_string: len={}, is_ascii={}",
|
||||||
|
s.len(),
|
||||||
|
is_ascii
|
||||||
|
);
|
||||||
|
|
||||||
|
if is_ascii {
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] send_string: ASCII '{}' via uinput",
|
||||||
|
s.escape_default()
|
||||||
|
);
|
||||||
for ch in s.chars() {
|
for ch in s.chars() {
|
||||||
self.send_char(ch);
|
self.send_char(ch);
|
||||||
}
|
}
|
||||||
|
return InjectResult::Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unicode text: single clipboard copy + paste (reliable method)
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] send_string: Unicode '{}' - using clipboard",
|
||||||
|
s.escape_default()
|
||||||
|
);
|
||||||
|
let copied = self.copy_to_clipboard(s);
|
||||||
|
if copied {
|
||||||
|
eprintln!("[vietc] send_string: clipboard OK, sending Ctrl+V");
|
||||||
|
self.send_ctrl_v();
|
||||||
|
eprintln!("[vietc] send_string complete (clipboard)");
|
||||||
|
return InjectResult::Success;
|
||||||
} else {
|
} else {
|
||||||
// Contains Unicode: single clipboard copy + paste via uinput
|
eprintln!(
|
||||||
|
"[vietc] send_string failed for '{}' (clipboard unavailable)",
|
||||||
|
s.escape_default()
|
||||||
|
);
|
||||||
|
// Last resort: try paste_string (will try clipboard internally)
|
||||||
self.paste_string(s);
|
self.paste_string(s);
|
||||||
}
|
}
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
|
|
@ -132,10 +211,21 @@ impl KeyInjector for UinputInjector {
|
||||||
fn inject_replacement(&self, backspaces: usize, text: &str) -> InjectResult {
|
fn inject_replacement(&self, backspaces: usize, text: &str) -> InjectResult {
|
||||||
self.inject_replacement_atomic(backspaces, text)
|
self.inject_replacement_atomic(backspaces, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn flush(&self) -> InjectResult {
|
fn flush(&self) -> InjectResult {
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record that Unicode text was pasted via clipboard (for future delete/backspace support)
|
||||||
|
fn update_pasted_text(&self, text: &str) -> InjectResult {
|
||||||
|
// Text tracking happens through OutputCommand pipeline in daemon
|
||||||
|
// This is called after clipboard paste to inform engine of pasted content
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] update_pasted_text: recorded '{}' (len={})",
|
||||||
|
text.escape_default(),
|
||||||
|
text.len()
|
||||||
|
);
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UinputInjector {
|
impl UinputInjector {
|
||||||
|
|
@ -160,7 +250,8 @@ impl UinputInjector {
|
||||||
let pw = libc::getpwuid(uid);
|
let pw = libc::getpwuid(uid);
|
||||||
if !pw.is_null() {
|
if !pw.is_null() {
|
||||||
let name = std::ffi::CStr::from_ptr((*pw).pw_name)
|
let name = std::ffi::CStr::from_ptr((*pw).pw_name)
|
||||||
.to_string_lossy().into_owned();
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
if !name.is_empty() {
|
if !name.is_empty() {
|
||||||
return Some(name);
|
return Some(name);
|
||||||
}
|
}
|
||||||
|
|
@ -176,7 +267,8 @@ impl UinputInjector {
|
||||||
let pw = libc::getpwuid(uid);
|
let pw = libc::getpwuid(uid);
|
||||||
if !pw.is_null() {
|
if !pw.is_null() {
|
||||||
let name = std::ffi::CStr::from_ptr((*pw).pw_name)
|
let name = std::ffi::CStr::from_ptr((*pw).pw_name)
|
||||||
.to_string_lossy().into_owned();
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
if !name.is_empty() {
|
if !name.is_empty() {
|
||||||
return Some(name);
|
return Some(name);
|
||||||
}
|
}
|
||||||
|
|
@ -247,45 +339,9 @@ impl UinputInjector {
|
||||||
/// Run an external command as the original user if we're root.
|
/// Run an external command as the original user if we're root.
|
||||||
/// Uses native OS setuid/setgid to avoid slow PAM/logging/sudo startup overhead.
|
/// Uses native OS setuid/setgid to avoid slow PAM/logging/sudo startup overhead.
|
||||||
fn run_as_user(program: &str, args: &[&str]) -> std::process::Output {
|
fn run_as_user(program: &str, args: &[&str]) -> std::process::Output {
|
||||||
let is_root = unsafe { libc::getuid() == 0 };
|
let mut cmd = Self::user_cmd(program);
|
||||||
if is_root {
|
|
||||||
if let Some((uid, gid)) = Self::get_original_uid_gid() {
|
|
||||||
let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default();
|
|
||||||
let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default();
|
|
||||||
let display = std::env::var("DISPLAY").unwrap_or_default();
|
|
||||||
|
|
||||||
use std::os::unix::process::CommandExt;
|
|
||||||
let mut cmd = std::process::Command::new(program);
|
|
||||||
cmd.uid(uid).gid(gid);
|
|
||||||
|
|
||||||
if !wayland_display.is_empty() {
|
|
||||||
cmd.env("WAYLAND_DISPLAY", wayland_display);
|
|
||||||
}
|
|
||||||
if !xdg_runtime_dir.is_empty() {
|
|
||||||
cmd.env("XDG_RUNTIME_DIR", xdg_runtime_dir);
|
|
||||||
}
|
|
||||||
if !display.is_empty() {
|
|
||||||
cmd.env("DISPLAY", display);
|
|
||||||
}
|
|
||||||
if let Some(username) = Self::get_original_username() {
|
|
||||||
cmd.env("HOME", format!("/home/{}", username));
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.args(args);
|
cmd.args(args);
|
||||||
match cmd.output() {
|
match cmd.output() {
|
||||||
Ok(output) => return output,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[vietc] Failed to run {} as uid={}: {}", program, uid, e);
|
|
||||||
return std::process::Output {
|
|
||||||
status: std::process::ExitStatus::default(),
|
|
||||||
stdout: vec![],
|
|
||||||
stderr: format!("{}\n", e).into_bytes(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match std::process::Command::new(program).args(args).output() {
|
|
||||||
Ok(output) => output,
|
Ok(output) => output,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[vietc] Failed to run {}: {}", program, e);
|
eprintln!("[vietc] Failed to run {}: {}", program, e);
|
||||||
|
|
@ -304,9 +360,22 @@ impl UinputInjector {
|
||||||
/// best available method: ydotool (uinput) for ASCII, xdotool (X11) or
|
/// best available method: ydotool (uinput) for ASCII, xdotool (X11) or
|
||||||
/// clipboard for Unicode.
|
/// clipboard for Unicode.
|
||||||
fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult {
|
fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult {
|
||||||
let is_ascii = text.chars().all(|c| char_to_linux_keycode(c).is_some());
|
eprintln!(
|
||||||
|
"[vietc] inject_atomic: ASCII={}",
|
||||||
|
text.chars().all(|c| char_to_linux_keycode(c).is_some())
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] inject_atomic: ASCII check (raw_bytes={} chars={} text='{}')",
|
||||||
|
text.len(),
|
||||||
|
text.chars().count(),
|
||||||
|
text.escape_default()
|
||||||
|
);
|
||||||
|
|
||||||
if is_ascii {
|
if text.chars().all(|c| char_to_linux_keycode(c).is_some()) {
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] ASCII injection using uinput (backspaces={})",
|
||||||
|
backspaces
|
||||||
|
);
|
||||||
if backspaces > 0 {
|
if backspaces > 0 {
|
||||||
for _ in 0..backspaces {
|
for _ in 0..backspaces {
|
||||||
let _ = self.send_backspace();
|
let _ = self.send_backspace();
|
||||||
|
|
@ -315,47 +384,81 @@ impl UinputInjector {
|
||||||
for ch in text.chars() {
|
for ch in text.chars() {
|
||||||
let _ = self.send_char(ch);
|
let _ = self.send_char(ch);
|
||||||
}
|
}
|
||||||
|
eprintln!("[vietc] ASCII injection complete");
|
||||||
return InjectResult::Success;
|
return InjectResult::Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
// It is Unicode. We must use a single unified channel.
|
// Unicode text: use xdotool directly (X11/XWayland) or wtype (Wayland)
|
||||||
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
||||||
|
|
||||||
static HAS_WTYPE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
|
||||||
static HAS_XDOTOOL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
static HAS_XDOTOOL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||||
|
let has_xdotool = if is_wayland {
|
||||||
if is_wayland {
|
false
|
||||||
let has_wtype = *HAS_WTYPE.get_or_init(|| {
|
|
||||||
std::process::Command::new("which")
|
|
||||||
.arg("wtype")
|
|
||||||
.output()
|
|
||||||
.map(|o| o.status.success())
|
|
||||||
.unwrap_or(false)
|
|
||||||
});
|
|
||||||
|
|
||||||
if has_wtype {
|
|
||||||
let mut args = Vec::new();
|
|
||||||
for _ in 0..backspaces {
|
|
||||||
args.push("-k");
|
|
||||||
args.push("BackSpace");
|
|
||||||
}
|
|
||||||
args.push("--");
|
|
||||||
args.push(text);
|
|
||||||
|
|
||||||
let output = Self::run_as_user("wtype", &args);
|
|
||||||
if output.status.success() {
|
|
||||||
return InjectResult::Success;
|
|
||||||
}
|
|
||||||
eprintln!("[vietc] wtype inject failed: {}", String::from_utf8_lossy(&output.stderr).trim());
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
let has_xdotool = *HAS_XDOTOOL.get_or_init(|| {
|
*HAS_XDOTOOL.get_or_init(|| {
|
||||||
std::process::Command::new("which")
|
std::process::Command::new("which")
|
||||||
.arg("xdotool")
|
.arg("xdotool")
|
||||||
.output()
|
.output()
|
||||||
.map(|o| o.status.success())
|
.map(|o| o.status.success())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
});
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
static HAS_WTYPE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||||
|
let has_wtype = if !is_wayland {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
*HAS_WTYPE.get_or_init(|| {
|
||||||
|
std::process::Command::new("which")
|
||||||
|
.arg("wtype")
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_wayland {
|
||||||
|
if has_wtype {
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] Unicode detected ({} chars), injecting via wtype",
|
||||||
|
text.chars().count()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] Wayland session detected, using clipboard fallback instead of xdotool/wtype"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] Unicode detected ({} chars), injecting via xdotool",
|
||||||
|
text.chars().count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_wayland && has_wtype {
|
||||||
|
let mut args = Vec::new();
|
||||||
|
if backspaces > 0 {
|
||||||
|
for _ in 0..backspaces {
|
||||||
|
args.push("-k");
|
||||||
|
args.push("BackSpace");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !text.is_empty() {
|
||||||
|
args.push("--");
|
||||||
|
args.push(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("[vietc] Running: wtype {}", args.join(" "));
|
||||||
|
let output = Self::run_as_user("wtype", &args);
|
||||||
|
if output.status.success() {
|
||||||
|
eprintln!("[vietc] wtype success - Unicode text injected correctly");
|
||||||
|
return InjectResult::Success;
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] wtype failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if has_xdotool {
|
if has_xdotool {
|
||||||
let mut args = Vec::new();
|
let mut args = Vec::new();
|
||||||
|
|
@ -367,119 +470,135 @@ impl UinputInjector {
|
||||||
}
|
}
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
args.push("type");
|
args.push("type");
|
||||||
args.push("--clearmodifiers");
|
args.push(text); // xdotool handles UTF-8 text directly
|
||||||
args.push(text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eprintln!("[vietc] Running: xdotool {}", args.join(" "));
|
||||||
let output = Self::run_as_user("xdotool", &args);
|
let output = Self::run_as_user("xdotool", &args);
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
|
eprintln!("[vietc] xdotool success - Unicode text injected correctly");
|
||||||
return InjectResult::Success;
|
return InjectResult::Success;
|
||||||
}
|
}
|
||||||
eprintln!("[vietc] xdotool inject failed: {}", String::from_utf8_lossy(&output.stderr).trim());
|
eprintln!(
|
||||||
}
|
"[vietc] xdotool failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
);
|
||||||
|
} else if !is_wayland {
|
||||||
|
eprintln!("[vietc] xdotool not found, trying clipboard fallback...");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Clipboard copy + paste.
|
// Final fallback: clipboard copy + Ctrl+V via uinput device
|
||||||
// This is safe because both backspaces and Ctrl+V are injected into the SAME uinput device.
|
eprintln!("[vietc] All direct tools failed, using clipboard fallback...");
|
||||||
|
// Primary choice for Unicode: clipboard copy + Ctrl+V via uinput device
|
||||||
let copied = self.copy_to_clipboard(text);
|
let copied = self.copy_to_clipboard(text);
|
||||||
if copied {
|
if copied {
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] Clipboard fallback: copied '{}' and will Ctrl+V",
|
||||||
|
text
|
||||||
|
);
|
||||||
if backspaces > 0 {
|
if backspaces > 0 {
|
||||||
for _ in 0..backspaces {
|
for _ in 0..backspaces {
|
||||||
let _ = self.send_backspace();
|
let _ = self.send_backspace();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
eprintln!("[vietc] Sending Ctrl+V");
|
||||||
self.send_ctrl_v();
|
self.send_ctrl_v();
|
||||||
InjectResult::Success
|
// Record pasted text for future delete/backspace operations
|
||||||
|
let output = Self::run_as_user("vietc", &["update-pasted", "-text", text]);
|
||||||
|
if output.status.success() {
|
||||||
|
eprintln!("[vietc] update_pasted_text success");
|
||||||
} else {
|
} else {
|
||||||
eprintln!("[vietc] clipboard copy failed during fallback");
|
eprintln!("[vietc] update_pasted_text call ignored (not critical)");
|
||||||
// Absolute last resort: try uinput backspaces followed by individual unicode paste_string
|
}
|
||||||
|
eprintln!("[vietc] Clipboard injection complete");
|
||||||
|
return InjectResult::Success;
|
||||||
|
} else {
|
||||||
|
eprintln!("[vietc] clipboard copy failed, trying individual char paste_string...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute last resort: try uinput backspaces followed by individual unicode chars via send_char
|
||||||
|
eprintln!("[vietc] Last resort: pasting '{}' char-by-char", text);
|
||||||
if backspaces > 0 {
|
if backspaces > 0 {
|
||||||
for _ in 0..backspaces {
|
for _ in 0..backspaces {
|
||||||
let _ = self.send_backspace();
|
let _ = self.send_backspace();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.paste_string(text);
|
for ch in text.chars() {
|
||||||
InjectResult::Success
|
let _ = self.send_char(ch);
|
||||||
}
|
}
|
||||||
|
eprintln!("[vietc] Char-by-char injection complete");
|
||||||
|
InjectResult::Success
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy text to clipboard and paste via Ctrl+V through our uinput device.
|
/// Copy text to clipboard and paste via Ctrl+V through our uinput device.
|
||||||
/// Only used as a last resort if Wayland/X11 direct typing tools are
|
/// Only used as a last resort if Wayland/X11 direct typing tools are unavailable.
|
||||||
/// unavailable. Prefers ydotool (uinput, works everywhere) to avoid
|
/// Tries xdotool first (X11/XWayland), then clipboard fallback.
|
||||||
/// clipboard pollution.
|
|
||||||
fn paste_string(&self, s: &str) {
|
fn paste_string(&self, s: &str) {
|
||||||
// Try ydotool first (uinput-based, no display server needed).
|
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
||||||
let ydotool_result = std::process::Command::new("ydotool")
|
if is_wayland {
|
||||||
.args(["type", s])
|
eprintln!("[vietc] paste_string: trying wtype...");
|
||||||
.output();
|
let output = Self::run_as_user("wtype", &["--", s]);
|
||||||
if let Ok(output) = ydotool_result {
|
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
eprintln!("[vietc] ydotool OK");
|
eprintln!("[vietc] paste_string: wtype success");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
eprintln!("[vietc] paste_string: wtype failed, trying clipboard...");
|
||||||
if !stderr.is_empty() {
|
} else {
|
||||||
eprintln!("[vietc] ydotool failed: {}", stderr.trim());
|
// Try xdotool first (works on X11 and XWayland for UTF-8)
|
||||||
}
|
eprintln!("[vietc] paste_string: trying xdotool...");
|
||||||
}
|
let output = Self::run_as_user("xdotool", &["type", s]);
|
||||||
eprintln!("[vietc] ydotool failed, trying xdotool...");
|
|
||||||
|
|
||||||
// Try xdotool (X11): needs DISPLAY, run through run_as_user
|
|
||||||
eprintln!("[vietc] trying xdotool...");
|
|
||||||
let output = Self::run_as_user("xdotool", &["type", "--clearmodifiers", s]);
|
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
eprintln!("[vietc] xdotool OK");
|
eprintln!("[vietc] paste_string: xdotool success");
|
||||||
|
// Record pasted text for future delete/backspace operations
|
||||||
|
let _ = Self::run_as_user("vietc", &["update-pasted", "-text", s]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
eprintln!("[vietc] paste_string: xdotool failed, trying clipboard...");
|
||||||
if !stderr.is_empty() {
|
|
||||||
eprintln!("[vietc] xdotool failed: {}", stderr.trim());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try wtype (Wayland-native): needs Wayland session, run through run_as_user
|
// Clipboard fallback: copy + paste via our uinput device
|
||||||
eprintln!("[vietc] xdotool failed, trying wtype...");
|
|
||||||
let output = Self::run_as_user("wtype", &[s]);
|
|
||||||
if output.status.success() {
|
|
||||||
eprintln!("[vietc] wtype OK");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
if !stderr.is_empty() {
|
|
||||||
eprintln!("[vietc] wtype failed: {}", stderr.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clipboard fallback: copy + paste via our uinput
|
|
||||||
eprintln!("[vietc] wtype failed, trying clipboard paste...");
|
|
||||||
let copied = self.copy_to_clipboard(s);
|
let copied = self.copy_to_clipboard(s);
|
||||||
if copied {
|
if copied {
|
||||||
eprintln!("[vietc] clipboard OK, sending Ctrl+V");
|
eprintln!("[vietc] paste_string: clipboard OK, sending Ctrl+V");
|
||||||
self.send_ctrl_v();
|
self.send_ctrl_v();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("[vietc] WARNING: No injection method works for '{}'!", s);
|
eprintln!(
|
||||||
|
"[vietc] WARNING: No injection method works for '{}'!",
|
||||||
|
s.escape_default()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a command to run as the original user with display environment.
|
/// Build a command to run as the original user with display environment.
|
||||||
fn user_cmd(program: &str) -> std::process::Command {
|
fn user_cmd(program: &str) -> std::process::Command {
|
||||||
let is_root = unsafe { libc::getuid() == 0 };
|
let is_root = unsafe { libc::getuid() == 0 };
|
||||||
if is_root {
|
if is_root {
|
||||||
if let Some(original_user) = Self::get_original_username() {
|
if let Some((uid, gid)) = Self::get_original_uid_gid() {
|
||||||
let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default();
|
let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default();
|
||||||
let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default();
|
let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default();
|
||||||
let display = std::env::var("DISPLAY").unwrap_or_default();
|
let display = std::env::var("DISPLAY").unwrap_or_default();
|
||||||
let mut cmd = std::process::Command::new("sudo");
|
let xauthority = std::env::var("XAUTHORITY").unwrap_or_default();
|
||||||
cmd.args(["-u", &original_user, "env"]);
|
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
let mut cmd = std::process::Command::new(program);
|
||||||
|
cmd.uid(uid).gid(gid);
|
||||||
|
|
||||||
if !wayland_display.is_empty() {
|
if !wayland_display.is_empty() {
|
||||||
cmd.arg(format!("WAYLAND_DISPLAY={}", wayland_display));
|
cmd.env("WAYLAND_DISPLAY", wayland_display);
|
||||||
}
|
}
|
||||||
if !xdg_runtime_dir.is_empty() {
|
if !xdg_runtime_dir.is_empty() {
|
||||||
cmd.arg(format!("XDG_RUNTIME_DIR={}", xdg_runtime_dir));
|
cmd.env("XDG_RUNTIME_DIR", xdg_runtime_dir);
|
||||||
}
|
}
|
||||||
if !display.is_empty() {
|
if !display.is_empty() {
|
||||||
cmd.arg(format!("DISPLAY={}", display));
|
cmd.env("DISPLAY", display);
|
||||||
|
}
|
||||||
|
if !xauthority.is_empty() {
|
||||||
|
cmd.env("XAUTHORITY", xauthority);
|
||||||
|
}
|
||||||
|
if let Some(username) = Self::get_original_username() {
|
||||||
|
cmd.env("HOME", format!("/home/{}", username));
|
||||||
}
|
}
|
||||||
cmd.arg(program);
|
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -508,7 +627,10 @@ impl UinputInjector {
|
||||||
eprintln!("[vietc] clipboard: wl-copy OK");
|
eprintln!("[vietc] clipboard: wl-copy OK");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
eprintln!("[vietc] clipboard: wl-copy failed (exit={:?})", status.code());
|
eprintln!(
|
||||||
|
"[vietc] clipboard: wl-copy failed (exit={:?})",
|
||||||
|
status.code()
|
||||||
|
);
|
||||||
} else if let Err(ref e) = result {
|
} else if let Err(ref e) = result {
|
||||||
eprintln!("[vietc] clipboard: wl-copy error: {}", e);
|
eprintln!("[vietc] clipboard: wl-copy error: {}", e);
|
||||||
}
|
}
|
||||||
|
|
@ -550,13 +672,22 @@ impl UinputInjector {
|
||||||
|
|
||||||
/// Send Ctrl+V through our uinput device.
|
/// Send Ctrl+V through our uinput device.
|
||||||
fn send_ctrl_v(&self) {
|
fn send_ctrl_v(&self) {
|
||||||
self.send_uinput_event(EV_KEY, 29, 1); // KEY_LEFTCTRL
|
self.send_uinput_event(EV_KEY, 29, 1); // KEY_LEFTCTRL press
|
||||||
self.send_uinput_event(EV_KEY, 47, 1); // KEY_V
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
self.send_uinput_event(EV_KEY, 47, 0);
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
self.send_uinput_event(EV_KEY, 29, 0);
|
|
||||||
self.send_uinput_event(0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
self.send_uinput_event(EV_KEY, 47, 1); // KEY_V press
|
||||||
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
|
|
||||||
|
self.send_uinput_event(EV_KEY, 47, 0); // KEY_V release
|
||||||
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
|
|
||||||
|
self.send_uinput_event(EV_KEY, 29, 0); // KEY_LEFTCTRL release
|
||||||
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for UinputInjector {
|
impl Drop for UinputInjector {
|
||||||
|
|
@ -617,7 +748,11 @@ fn char_to_linux_keycode(ch: char) -> Option<u16> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ioctl helper
|
// ioctl helper
|
||||||
fn ioctl(fd: std::os::unix::io::RawFd, request: u64, arg: u64) -> Result<i32, Box<dyn std::error::Error>> {
|
fn ioctl(
|
||||||
|
fd: std::os::unix::io::RawFd,
|
||||||
|
request: u64,
|
||||||
|
arg: u64,
|
||||||
|
) -> Result<i32, Box<dyn std::error::Error>> {
|
||||||
unsafe {
|
unsafe {
|
||||||
let result = libc::ioctl(fd, request, arg);
|
let result = libc::ioctl(fd, request, arg);
|
||||||
if result < 0 {
|
if result < 0 {
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,7 @@ impl Keysym {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_modifier(self) -> bool {
|
pub fn is_modifier(self) -> bool {
|
||||||
matches!(
|
matches!(self.0, 0xffe1..=0xffee)
|
||||||
self.0,
|
|
||||||
0xffe1..=0xffee
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,8 +216,16 @@ impl WaylandIMContext {
|
||||||
// Shift+digit produces symbol
|
// Shift+digit produces symbol
|
||||||
if mods.shift && base.is_ascii_digit() {
|
if mods.shift && base.is_ascii_digit() {
|
||||||
let shifted = match base {
|
let shifted = match base {
|
||||||
'1' => '!', '2' => '@', '3' => '#', '4' => '$', '5' => '%',
|
'1' => '!',
|
||||||
'6' => '^', '7' => '&', '8' => '*', '9' => '(', '0' => ')',
|
'2' => '@',
|
||||||
|
'3' => '#',
|
||||||
|
'4' => '$',
|
||||||
|
'5' => '%',
|
||||||
|
'6' => '^',
|
||||||
|
'7' => '&',
|
||||||
|
'8' => '*',
|
||||||
|
'9' => '(',
|
||||||
|
'0' => ')',
|
||||||
_ => return Some(base),
|
_ => return Some(base),
|
||||||
};
|
};
|
||||||
return Some(shifted);
|
return Some(shifted);
|
||||||
|
|
|
||||||
|
|
@ -57,11 +57,24 @@ impl X11Lib {
|
||||||
return Err("Failed to load libXtst.so.6".into());
|
return Err("Failed to load libXtst.so.6".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let x_open_display = std::mem::transmute(dlsym(x11_handle, b"XOpenDisplay\0".as_ptr() as *const c_char));
|
let x_open_display = std::mem::transmute(dlsym(
|
||||||
let x_close_display = std::mem::transmute(dlsym(x11_handle, b"XCloseDisplay\0".as_ptr() as *const c_char));
|
x11_handle,
|
||||||
let x_default_root_window = std::mem::transmute(dlsym(x11_handle, b"XDefaultRootWindow\0".as_ptr() as *const c_char));
|
b"XOpenDisplay\0".as_ptr() as *const c_char,
|
||||||
let x_flush = std::mem::transmute(dlsym(x11_handle, b"XFlush\0".as_ptr() as *const c_char));
|
));
|
||||||
let x_test_fake_key_event = std::mem::transmute(dlsym(xtst_handle, b"XTestFakeKeyEvent\0".as_ptr() as *const c_char));
|
let x_close_display = std::mem::transmute(dlsym(
|
||||||
|
x11_handle,
|
||||||
|
b"XCloseDisplay\0".as_ptr() as *const c_char,
|
||||||
|
));
|
||||||
|
let x_default_root_window = std::mem::transmute(dlsym(
|
||||||
|
x11_handle,
|
||||||
|
b"XDefaultRootWindow\0".as_ptr() as *const c_char,
|
||||||
|
));
|
||||||
|
let x_flush =
|
||||||
|
std::mem::transmute(dlsym(x11_handle, b"XFlush\0".as_ptr() as *const c_char));
|
||||||
|
let x_test_fake_key_event = std::mem::transmute(dlsym(
|
||||||
|
xtst_handle,
|
||||||
|
b"XTestFakeKeyEvent\0".as_ptr() as *const c_char,
|
||||||
|
));
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
x11_handle,
|
x11_handle,
|
||||||
|
|
@ -91,43 +104,96 @@ const X11_KEYCODE_OFFSET: u32 = 8;
|
||||||
// X11 keycodes for common ASCII characters
|
// X11 keycodes for common ASCII characters
|
||||||
fn char_to_keycode(ch: char) -> Option<(u32, bool)> {
|
fn char_to_keycode(ch: char) -> Option<(u32, bool)> {
|
||||||
match ch {
|
match ch {
|
||||||
'a' => Some((30 + X11_KEYCODE_OFFSET, false)), 'b' => Some((48 + X11_KEYCODE_OFFSET, false)),
|
'a' => Some((30, false)),
|
||||||
'c' => Some((46 + X11_KEYCODE_OFFSET, false)), 'd' => Some((32 + X11_KEYCODE_OFFSET, false)),
|
'b' => Some((48, false)),
|
||||||
'e' => Some((18 + X11_KEYCODE_OFFSET, false)), 'f' => Some((33 + X11_KEYCODE_OFFSET, false)),
|
'c' => Some((46, false)),
|
||||||
'g' => Some((34 + X11_KEYCODE_OFFSET, false)), 'h' => Some((35 + X11_KEYCODE_OFFSET, false)),
|
'd' => Some((32, false)),
|
||||||
'i' => Some((23 + X11_KEYCODE_OFFSET, false)), 'j' => Some((36 + X11_KEYCODE_OFFSET, false)),
|
'e' => Some((18, false)),
|
||||||
'k' => Some((37 + X11_KEYCODE_OFFSET, false)), 'l' => Some((38 + X11_KEYCODE_OFFSET, false)),
|
'f' => Some((33, false)),
|
||||||
'm' => Some((50 + X11_KEYCODE_OFFSET, false)), 'n' => Some((49 + X11_KEYCODE_OFFSET, false)),
|
'g' => Some((34, false)),
|
||||||
'o' => Some((24 + X11_KEYCODE_OFFSET, false)), 'p' => Some((25 + X11_KEYCODE_OFFSET, false)),
|
'h' => Some((35, false)),
|
||||||
'q' => Some((16 + X11_KEYCODE_OFFSET, false)), 'r' => Some((19 + X11_KEYCODE_OFFSET, false)),
|
'i' => Some((23, false)),
|
||||||
's' => Some((31 + X11_KEYCODE_OFFSET, false)), 't' => Some((20 + X11_KEYCODE_OFFSET, false)),
|
'j' => Some((36, false)),
|
||||||
'u' => Some((22 + X11_KEYCODE_OFFSET, false)), 'v' => Some((47 + X11_KEYCODE_OFFSET, false)),
|
'k' => Some((37, false)),
|
||||||
'w' => Some((17 + X11_KEYCODE_OFFSET, false)), 'x' => Some((45 + X11_KEYCODE_OFFSET, false)),
|
'l' => Some((38, false)),
|
||||||
'y' => Some((21 + X11_KEYCODE_OFFSET, false)), 'z' => Some((44 + X11_KEYCODE_OFFSET, false)),
|
'm' => Some((50, false)),
|
||||||
'A' => Some((30 + X11_KEYCODE_OFFSET, true)), 'B' => Some((48 + X11_KEYCODE_OFFSET, true)),
|
'n' => Some((49, false)),
|
||||||
'C' => Some((46 + X11_KEYCODE_OFFSET, true)), 'D' => Some((32 + X11_KEYCODE_OFFSET, true)),
|
'o' => Some((24, false)),
|
||||||
'E' => Some((18 + X11_KEYCODE_OFFSET, true)), 'F' => Some((33 + X11_KEYCODE_OFFSET, true)),
|
'p' => Some((25, false)),
|
||||||
'G' => Some((34 + X11_KEYCODE_OFFSET, true)), 'H' => Some((35 + X11_KEYCODE_OFFSET, true)),
|
'q' => Some((16, false)),
|
||||||
'I' => Some((23 + X11_KEYCODE_OFFSET, true)), 'J' => Some((36 + X11_KEYCODE_OFFSET, true)),
|
'r' => Some((19, false)),
|
||||||
'K' => Some((37 + X11_KEYCODE_OFFSET, true)), 'L' => Some((38 + X11_KEYCODE_OFFSET, true)),
|
's' => Some((31, false)),
|
||||||
'M' => Some((50 + X11_KEYCODE_OFFSET, true)), 'N' => Some((49 + X11_KEYCODE_OFFSET, true)),
|
't' => Some((20, false)),
|
||||||
'O' => Some((24 + X11_KEYCODE_OFFSET, true)), 'P' => Some((25 + X11_KEYCODE_OFFSET, true)),
|
'u' => Some((22, false)),
|
||||||
'Q' => Some((16 + X11_KEYCODE_OFFSET, true)), 'R' => Some((19 + X11_KEYCODE_OFFSET, true)),
|
'v' => Some((47, false)),
|
||||||
'S' => Some((31 + X11_KEYCODE_OFFSET, true)), 'T' => Some((20 + X11_KEYCODE_OFFSET, true)),
|
'w' => Some((17, false)),
|
||||||
'U' => Some((22 + X11_KEYCODE_OFFSET, true)), 'V' => Some((47 + X11_KEYCODE_OFFSET, true)),
|
'x' => Some((45, false)),
|
||||||
'W' => Some((17 + X11_KEYCODE_OFFSET, true)), 'X' => Some((45 + X11_KEYCODE_OFFSET, true)),
|
'y' => Some((21, false)),
|
||||||
'Y' => Some((21 + X11_KEYCODE_OFFSET, true)), 'Z' => Some((44 + X11_KEYCODE_OFFSET, true)),
|
'z' => Some((44, false)),
|
||||||
'0' => Some((11 + X11_KEYCODE_OFFSET, false)), '1' => Some((2 + X11_KEYCODE_OFFSET, false)),
|
'A' => Some((30, true)),
|
||||||
'2' => Some((3 + X11_KEYCODE_OFFSET, false)), '3' => Some((4 + X11_KEYCODE_OFFSET, false)),
|
'B' => Some((48, true)),
|
||||||
'4' => Some((5 + X11_KEYCODE_OFFSET, false)), '5' => Some((6 + X11_KEYCODE_OFFSET, false)),
|
'C' => Some((46, true)),
|
||||||
'6' => Some((7 + X11_KEYCODE_OFFSET, false)), '7' => Some((8 + X11_KEYCODE_OFFSET, false)),
|
'D' => Some((32, true)),
|
||||||
'8' => Some((9 + X11_KEYCODE_OFFSET, false)), '9' => Some((10 + X11_KEYCODE_OFFSET, false)),
|
'E' => Some((18, true)),
|
||||||
' ' => Some((57 + X11_KEYCODE_OFFSET, false)), '.' => Some((52 + X11_KEYCODE_OFFSET, false)),
|
'F' => Some((33, true)),
|
||||||
',' => Some((51 + X11_KEYCODE_OFFSET, false)), '-' => Some((12 + X11_KEYCODE_OFFSET, false)),
|
'G' => Some((34, true)),
|
||||||
'=' => Some((13 + X11_KEYCODE_OFFSET, false)), ';' => Some((39 + X11_KEYCODE_OFFSET, false)),
|
'H' => Some((35, true)),
|
||||||
'\'' => Some((40 + X11_KEYCODE_OFFSET, false)), '/' => Some((53 + X11_KEYCODE_OFFSET, false)),
|
'I' => Some((23, true)),
|
||||||
'\\' => Some((43 + X11_KEYCODE_OFFSET, false)), '`' => Some((41 + X11_KEYCODE_OFFSET, false)),
|
'J' => Some((36, true)),
|
||||||
'[' => Some((26 + X11_KEYCODE_OFFSET, false)), ']' => Some((27 + X11_KEYCODE_OFFSET, false)),
|
'K' => Some((37, true)),
|
||||||
|
'L' => Some((38, true)),
|
||||||
|
'M' => Some((50, true)),
|
||||||
|
'N' => Some((49, true)),
|
||||||
|
'O' => Some((24, true)),
|
||||||
|
'P' => Some((25, true)),
|
||||||
|
'Q' => Some((16, true)),
|
||||||
|
'R' => Some((19, true)),
|
||||||
|
'S' => Some((31, true)),
|
||||||
|
'T' => Some((20, true)),
|
||||||
|
'U' => Some((22, true)),
|
||||||
|
'V' => Some((47, true)),
|
||||||
|
'W' => Some((17, true)),
|
||||||
|
'X' => Some((45, true)),
|
||||||
|
'Y' => Some((21, true)),
|
||||||
|
'Z' => Some((44, true)),
|
||||||
|
'0' => Some((11, false)),
|
||||||
|
'1' => Some((2, false)),
|
||||||
|
'2' => Some((3, false)),
|
||||||
|
'3' => Some((4, false)),
|
||||||
|
'4' => Some((5, false)),
|
||||||
|
'5' => Some((6, false)),
|
||||||
|
'6' => Some((7, false)),
|
||||||
|
'7' => Some((8, false)),
|
||||||
|
'8' => Some((9, false)),
|
||||||
|
'9' => Some((10, false)),
|
||||||
|
' ' => Some((57, false)),
|
||||||
|
'.' => Some((52, false)),
|
||||||
|
',' => Some((51, false)),
|
||||||
|
'-' => Some((12, false)),
|
||||||
|
'=' => Some((13, false)),
|
||||||
|
';' => Some((39, false)),
|
||||||
|
'\'' => Some((40, false)),
|
||||||
|
'/' => Some((53, false)),
|
||||||
|
'\\' => Some((43, false)),
|
||||||
|
'`' => Some((41, false)),
|
||||||
|
'0' => Some((11, false)),
|
||||||
|
'1' => Some((2, false)),
|
||||||
|
'2' => Some((3, false)),
|
||||||
|
'3' => Some((4, false)),
|
||||||
|
'4' => Some((5, false)),
|
||||||
|
'5' => Some((6, false)),
|
||||||
|
'6' => Some((7, false)),
|
||||||
|
'7' => Some((8, false)),
|
||||||
|
'8' => Some((9, false)),
|
||||||
|
'9' => Some((10, false)),
|
||||||
|
' ' => Some((57, false)),
|
||||||
|
'.' => Some((52, false)),
|
||||||
|
',' => Some((51, false)),
|
||||||
|
'-' => Some((12, false)),
|
||||||
|
'=' => Some((13, false)),
|
||||||
|
';' => Some((39, false)),
|
||||||
|
'\'' => Some((40, false)),
|
||||||
|
'/' => Some((53, false)),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +217,11 @@ impl X11Injector {
|
||||||
return Err("Cannot open X11 display. Is DISPLAY set?".into());
|
return Err("Cannot open X11 display. Is DISPLAY set?".into());
|
||||||
}
|
}
|
||||||
let window = (lib.x_default_root_window)(display);
|
let window = (lib.x_default_root_window)(display);
|
||||||
Ok(Self { lib, display, window })
|
Ok(Self {
|
||||||
|
lib,
|
||||||
|
display,
|
||||||
|
window,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,7 +360,8 @@ impl KeyInjector for X11Injector {
|
||||||
let mut clipboard_cmd = std::process::Command::new("xclip");
|
let mut clipboard_cmd = std::process::Command::new("xclip");
|
||||||
clipboard_cmd.args(["-selection", "clipboard"]);
|
clipboard_cmd.args(["-selection", "clipboard"]);
|
||||||
clipboard_cmd.stdin(std::process::Stdio::piped());
|
clipboard_cmd.stdin(std::process::Stdio::piped());
|
||||||
let copied = clipboard_cmd.spawn()
|
let copied = clipboard_cmd
|
||||||
|
.spawn()
|
||||||
.and_then(|mut child| {
|
.and_then(|mut child| {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
child.stdin.take().unwrap().write_all(text.as_bytes())?;
|
child.stdin.take().unwrap().write_all(text.as_bytes())?;
|
||||||
|
|
@ -326,15 +397,27 @@ impl KeyInjector for X11Injector {
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn flush(&self) -> InjectResult {
|
fn flush(&self) -> InjectResult {
|
||||||
unsafe { (self.lib.x_flush)(self.display); }
|
unsafe {
|
||||||
|
(self.lib.x_flush)(self.display);
|
||||||
|
}
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that Unicode text was pasted via clipboard (for future delete/backspace support)
|
||||||
|
fn update_pasted_text(&self, _text: &str) -> InjectResult {
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] X11 update_pasted_text: recorded text (len={})",
|
||||||
|
_text.len()
|
||||||
|
);
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for X11Injector {
|
impl Drop for X11Injector {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
unsafe { (self.lib.x_close_display)(self.display); }
|
unsafe {
|
||||||
|
(self.lib.x_close_display)(self.display);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,13 +69,27 @@ pub struct Config {
|
||||||
pub debug: bool,
|
pub debug: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_input_method() -> String { "telex".into() }
|
fn default_input_method() -> String {
|
||||||
fn default_toggle_key() -> String { "space".into() }
|
"telex".into()
|
||||||
fn default_start_enabled() -> bool { true }
|
}
|
||||||
fn default_grab() -> bool { true }
|
fn default_toggle_key() -> String {
|
||||||
fn default_true() -> bool { true }
|
"space".into()
|
||||||
fn default_false() -> bool { false }
|
}
|
||||||
fn default_restore_keys() -> Vec<String> { vec!["space".into(), "escape".into()] }
|
fn default_start_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn default_grab() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn default_false() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn default_restore_keys() -> Vec<String> {
|
||||||
|
vec!["space".into(), "escape".into()]
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
|
@ -92,7 +106,6 @@ impl Default for Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
for path in config_paths() {
|
for path in config_paths() {
|
||||||
|
|
@ -142,7 +155,10 @@ fn config_paths() -> Vec<PathBuf> {
|
||||||
|
|
||||||
pub fn is_autostart_installed() -> bool {
|
pub fn is_autostart_installed() -> bool {
|
||||||
if let Some(config_dir) = dirs::config_dir() {
|
if let Some(config_dir) = dirs::config_dir() {
|
||||||
config_dir.join("autostart").join("vietc-tray.desktop").exists()
|
config_dir
|
||||||
|
.join("autostart")
|
||||||
|
.join("vietc-tray.desktop")
|
||||||
|
.exists()
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
@ -164,9 +180,7 @@ pub fn install_autostart() {
|
||||||
let desktop_file = autostart_dir.join("vietc-tray.desktop");
|
let desktop_file = autostart_dir.join("vietc-tray.desktop");
|
||||||
let _ = fs::create_dir_all(&autostart_dir);
|
let _ = fs::create_dir_all(&autostart_dir);
|
||||||
|
|
||||||
let exec_path = std::env::var("APPIMAGE")
|
let exec_path = std::env::var("APPIMAGE").ok().unwrap_or_else(|| {
|
||||||
.ok()
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
std::env::current_exe()
|
std::env::current_exe()
|
||||||
.unwrap_or_else(|_| PathBuf::from("vietc-tray"))
|
.unwrap_or_else(|_| PathBuf::from("vietc-tray"))
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
|
|
|
||||||
210
ui/src/tray.rs
210
ui/src/tray.rs
|
|
@ -1,5 +1,5 @@
|
||||||
use ksni::{Tray, MenuItem, menu::*};
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
|
use ksni::{menu::*, MenuItem, Tray};
|
||||||
|
|
||||||
fn write_status(state: &str) {
|
fn write_status(state: &str) {
|
||||||
if let Some(config_dir) = dirs::config_dir() {
|
if let Some(config_dir) = dirs::config_dir() {
|
||||||
|
|
@ -16,7 +16,11 @@ fn read_status() -> String {
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
let cfg = config::Config::load();
|
let cfg = config::Config::load();
|
||||||
if cfg.start_enabled { "vn".into() } else { "en".into() }
|
if cfg.start_enabled {
|
||||||
|
"vn".into()
|
||||||
|
} else {
|
||||||
|
"en".into()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,7 +41,9 @@ fn draw_line(data: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32, color: [u8; 4]
|
||||||
let idx = ((y * 32 + x) * 4) as usize;
|
let idx = ((y * 32 + x) * 4) as usize;
|
||||||
data[idx..idx + 4].copy_from_slice(&color);
|
data[idx..idx + 4].copy_from_slice(&color);
|
||||||
}
|
}
|
||||||
if x == x1 && y == y1 { break; }
|
if x == x1 && y == y1 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
let e2 = 2 * err;
|
let e2 = 2 * err;
|
||||||
if e2 > -dy {
|
if e2 > -dy {
|
||||||
err -= dy;
|
err -= dy;
|
||||||
|
|
@ -51,32 +57,41 @@ fn draw_line(data: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32, color: [u8; 4]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_icons() {
|
fn ensure_icons() {
|
||||||
let Some(config_dir) = dirs::config_dir() else { return };
|
// SVG content for Viet+ icons
|
||||||
|
let svg_vn = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<rect x="8" y="8" width="112" height="112" rx="24" fill="#e02424"/>
|
||||||
|
<text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">VN</text>
|
||||||
|
</svg>"##;
|
||||||
|
|
||||||
|
let svg_en = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<rect x="8" y="8" width="112" height="112" rx="24" fill="#4b5563"/>
|
||||||
|
<text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">EN</text>
|
||||||
|
</svg>"##;
|
||||||
|
|
||||||
|
// Write to standard user theme path (for Wayland compositors)
|
||||||
|
let home = dirs::home_dir().map(|d| d.join(".local/share/icons"));
|
||||||
|
if let Some(home_icons) = &home {
|
||||||
|
let _ = std::fs::create_dir_all(&home_icons);
|
||||||
|
let vn_path = home_icons.join("vietc-vn.svg");
|
||||||
|
let en_path = home_icons.join("vietc-en.svg");
|
||||||
|
|
||||||
|
if !vn_path.exists() {
|
||||||
|
let _ = std::fs::write(&vn_path, svg_vn);
|
||||||
|
}
|
||||||
|
if !en_path.exists() {
|
||||||
|
let _ = std::fs::write(&en_path, svg_en);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also write to config dir for AppImage compatibility (fallback)
|
||||||
|
let config_dir = dirs::config_dir();
|
||||||
|
if let Some(config_dir) = &config_dir {
|
||||||
let icons_dir = config_dir.join("vietc").join("icons");
|
let icons_dir = config_dir.join("vietc").join("icons");
|
||||||
let theme_dir = icons_dir.join("hicolor").join("scalable").join("apps");
|
let _ = std::fs::create_dir_all(&icons_dir);
|
||||||
let _ = std::fs::create_dir_all(&theme_dir);
|
|
||||||
|
|
||||||
let vn_flat = icons_dir.join("vietc-vn.svg");
|
let vn_theme = icons_dir.join("hicolor/scalable/apps/vietc-vn.svg");
|
||||||
let en_flat = icons_dir.join("vietc-en.svg");
|
let en_theme = icons_dir.join("hicolor/scalable/apps/vietc-en.svg");
|
||||||
let vn_theme = theme_dir.join("vietc-vn.svg");
|
|
||||||
let en_theme = theme_dir.join("vietc-en.svg");
|
|
||||||
|
|
||||||
let svg_vn = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
|
||||||
<rect x="2" y="2" width="28" height="28" rx="6" fill="#e02424"/>
|
|
||||||
<text x="16" y="22" text-anchor="middle" fill="#ffffff" font-size="14" font-weight="900" font-family="system-ui, sans-serif">VN</text>
|
|
||||||
</svg>"##;
|
|
||||||
|
|
||||||
let svg_en = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
|
||||||
<rect x="2" y="2" width="28" height="28" rx="6" fill="#4b5563"/>
|
|
||||||
<text x="16" y="22" text-anchor="middle" fill="#ffffff" font-size="14" font-weight="900" font-family="system-ui, sans-serif">EN</text>
|
|
||||||
</svg>"##;
|
|
||||||
|
|
||||||
if !vn_flat.exists() {
|
|
||||||
let _ = std::fs::write(&vn_flat, svg_vn);
|
|
||||||
}
|
|
||||||
if !en_flat.exists() {
|
|
||||||
let _ = std::fs::write(&en_flat, svg_en);
|
|
||||||
}
|
|
||||||
if !vn_theme.exists() {
|
if !vn_theme.exists() {
|
||||||
let _ = std::fs::write(&vn_theme, svg_vn);
|
let _ = std::fs::write(&vn_theme, svg_vn);
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +99,7 @@ fn ensure_icons() {
|
||||||
let _ = std::fs::write(&en_theme, svg_en);
|
let _ = std::fs::write(&en_theme, svg_en);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn show_notification(title: &str, body: &str) {
|
fn show_notification(title: &str, body: &str) {
|
||||||
let _ = std::process::Command::new("notify-send")
|
let _ = std::process::Command::new("notify-send")
|
||||||
|
|
@ -118,10 +134,16 @@ impl VietTray {
|
||||||
let handle = handle.clone();
|
let handle = handle.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
if verbose {
|
if verbose {
|
||||||
show_notification("Checking for updates...", "Contacting git.khoavo.myds.me...");
|
show_notification(
|
||||||
|
"Checking for updates...",
|
||||||
|
"Contacting git.khoavo.myds.me...",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let output = std::process::Command::new("curl")
|
let output = std::process::Command::new("curl")
|
||||||
.args(["-s", "https://git.khoavo.myds.me/api/v1/repos/vndangkhoa/vietc/releases"])
|
.args([
|
||||||
|
"-s",
|
||||||
|
"https://git.khoavo.myds.me/api/v1/repos/vndangkhoa/vietc/releases",
|
||||||
|
])
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
match output {
|
match output {
|
||||||
|
|
@ -156,8 +178,14 @@ impl VietTray {
|
||||||
let handle = handle.clone();
|
let handle = handle.clone();
|
||||||
let _ = handle.update(|t| t.updating = true);
|
let _ = handle.update(|t| t.updating = true);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
show_notification("Downloading update...", &format!("Updating Viet+ to {}...", release.tag_name));
|
show_notification(
|
||||||
let appimage_asset = release.assets.iter().find(|a| a.name.ends_with(".AppImage"));
|
"Downloading update...",
|
||||||
|
&format!("Updating Viet+ to {}...", release.tag_name),
|
||||||
|
);
|
||||||
|
let appimage_asset = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name.ends_with(".AppImage"));
|
||||||
if let Some(asset) = appimage_asset {
|
if let Some(asset) = appimage_asset {
|
||||||
if let Ok(appimage_path) = std::env::var("APPIMAGE") {
|
if let Ok(appimage_path) = std::env::var("APPIMAGE") {
|
||||||
let temp_path = format!("{}.tmp-update", appimage_path);
|
let temp_path = format!("{}.tmp-update", appimage_path);
|
||||||
|
|
@ -167,11 +195,14 @@ impl VietTray {
|
||||||
match status {
|
match status {
|
||||||
Ok(s) if s.success() => {
|
Ok(s) if s.success() => {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
if let Ok(_) = std::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o755)) {
|
if let Ok(_) = std::fs::set_permissions(
|
||||||
|
&temp_path,
|
||||||
|
std::fs::Permissions::from_mode(0o755),
|
||||||
|
) {
|
||||||
if let Ok(_) = std::fs::rename(&temp_path, &appimage_path) {
|
if let Ok(_) = std::fs::rename(&temp_path, &appimage_path) {
|
||||||
show_notification(
|
show_notification(
|
||||||
"Update Succeeded",
|
"Update Succeeded",
|
||||||
"Viet+ has been updated! Please restart the application."
|
"Viet+ has been updated! Please restart the application.",
|
||||||
);
|
);
|
||||||
let _ = handle.update(|t| {
|
let _ = handle.update(|t| {
|
||||||
t.updating = false;
|
t.updating = false;
|
||||||
|
|
@ -191,7 +222,7 @@ impl VietTray {
|
||||||
.status();
|
.status();
|
||||||
show_notification(
|
show_notification(
|
||||||
"Opening Releases Page",
|
"Opening Releases Page",
|
||||||
"Please download the update manually."
|
"Please download the update manually.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -203,17 +234,26 @@ impl VietTray {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tray for VietTray {
|
impl Tray for VietTray {
|
||||||
fn id(&self) -> String { "io.github.vietc.Tray".into() }
|
fn id(&self) -> String {
|
||||||
fn title(&self) -> String { "Viet+".into() }
|
"io.github.vietc.Tray".into()
|
||||||
|
}
|
||||||
|
fn title(&self) -> String {
|
||||||
|
"Viet+".into()
|
||||||
|
}
|
||||||
|
|
||||||
fn icon_name(&self) -> String {
|
fn icon_name(&self) -> String {
|
||||||
if self.mode == "vn" { "vietc-vn".into() } else { "vietc-en".into() }
|
if self.mode == "vn" {
|
||||||
|
"vietc-vn".into()
|
||||||
|
} else {
|
||||||
|
"vietc-en".into()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn icon_theme_path(&self) -> String {
|
fn icon_theme_path(&self) -> String {
|
||||||
dirs::config_dir()
|
// Use XDG user theme path for icons
|
||||||
.map(|d| d.join("vietc").join("icons").to_string_lossy().into_owned())
|
dirs::home_dir()
|
||||||
.unwrap_or_default()
|
.map(|d| d.join(".local/share/icons").to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| "/usr/share/icons".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
|
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
|
||||||
|
|
@ -230,13 +270,21 @@ impl Tray for VietTray {
|
||||||
for x in 0..32 {
|
for x in 0..32 {
|
||||||
let mut inside = true;
|
let mut inside = true;
|
||||||
if x < 7 && y < 7 {
|
if x < 7 && y < 7 {
|
||||||
if (x - 7) * (x - 7) + (y - 7) * (y - 7) > 36 { inside = false; }
|
if (x - 7) * (x - 7) + (y - 7) * (y - 7) > 36 {
|
||||||
|
inside = false;
|
||||||
|
}
|
||||||
} else if x > 24 && y < 7 {
|
} else if x > 24 && y < 7 {
|
||||||
if (x - 24) * (x - 24) + (y - 7) * (y - 7) > 36 { inside = false; }
|
if (x - 24) * (x - 24) + (y - 7) * (y - 7) > 36 {
|
||||||
|
inside = false;
|
||||||
|
}
|
||||||
} else if x < 7 && y > 24 {
|
} else if x < 7 && y > 24 {
|
||||||
if (x - 7) * (x - 7) + (y - 24) * (y - 24) > 36 { inside = false; }
|
if (x - 7) * (x - 7) + (y - 24) * (y - 24) > 36 {
|
||||||
|
inside = false;
|
||||||
|
}
|
||||||
} else if x > 24 && y > 24 {
|
} else if x > 24 && y > 24 {
|
||||||
if (x - 24) * (x - 24) + (y - 24) * (y - 24) > 36 { inside = false; }
|
if (x - 24) * (x - 24) + (y - 24) * (y - 24) > 36 {
|
||||||
|
inside = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let idx = ((y * 32 + x) * 4) as usize;
|
let idx = ((y * 32 + x) * 4) as usize;
|
||||||
|
|
@ -313,7 +361,8 @@ impl Tray for VietTray {
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}.into(),
|
}
|
||||||
|
.into(),
|
||||||
MenuItem::Separator,
|
MenuItem::Separator,
|
||||||
CheckmarkItem {
|
CheckmarkItem {
|
||||||
label: "Vietnamese Mode".into(),
|
label: "Vietnamese Mode".into(),
|
||||||
|
|
@ -327,11 +376,11 @@ impl Tray for VietTray {
|
||||||
this.mode = next.to_string();
|
this.mode = next.to_string();
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}.into(),
|
}
|
||||||
|
.into(),
|
||||||
SubMenu {
|
SubMenu {
|
||||||
label: "Input Method".into(),
|
label: "Input Method".into(),
|
||||||
submenu: vec![
|
submenu: vec![RadioGroup {
|
||||||
RadioGroup {
|
|
||||||
selected: im_index,
|
selected: im_index,
|
||||||
select: Box::new(|this: &mut VietTray, idx: usize| {
|
select: Box::new(|this: &mut VietTray, idx: usize| {
|
||||||
let im = if idx == 0 { "telex" } else { "vni" };
|
let im = if idx == 0 { "telex" } else { "vni" };
|
||||||
|
|
@ -341,13 +390,20 @@ impl Tray for VietTray {
|
||||||
this.im = im.into();
|
this.im = im.into();
|
||||||
}),
|
}),
|
||||||
options: vec![
|
options: vec![
|
||||||
RadioItem { label: "Telex".into(), ..Default::default() },
|
RadioItem {
|
||||||
RadioItem { label: "VNI".into(), ..Default::default() },
|
label: "Telex".into(),
|
||||||
],
|
|
||||||
}.into(),
|
|
||||||
],
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}.into(),
|
},
|
||||||
|
RadioItem {
|
||||||
|
label: "VNI".into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
.into()],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
];
|
];
|
||||||
|
|
||||||
items.push(MenuItem::Separator);
|
items.push(MenuItem::Separator);
|
||||||
|
|
@ -357,7 +413,8 @@ impl Tray for VietTray {
|
||||||
} else {
|
} else {
|
||||||
format!("Update to {}", release.tag_name)
|
format!("Update to {}", release.tag_name)
|
||||||
};
|
};
|
||||||
items.push(StandardItem {
|
items.push(
|
||||||
|
StandardItem {
|
||||||
label,
|
label,
|
||||||
activate: Box::new(|this: &mut VietTray| {
|
activate: Box::new(|this: &mut VietTray| {
|
||||||
if !this.updating {
|
if !this.updating {
|
||||||
|
|
@ -368,10 +425,17 @@ impl Tray for VietTray {
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}.into());
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
items.push(StandardItem {
|
items.push(
|
||||||
label: if self.updating { "Updating...".into() } else { "Check for Updates".into() },
|
StandardItem {
|
||||||
|
label: if self.updating {
|
||||||
|
"Updating...".into()
|
||||||
|
} else {
|
||||||
|
"Check for Updates".into()
|
||||||
|
},
|
||||||
activate: Box::new(|this: &mut VietTray| {
|
activate: Box::new(|this: &mut VietTray| {
|
||||||
if !this.updating {
|
if !this.updating {
|
||||||
let handle = this.handle.lock().unwrap().clone().unwrap();
|
let handle = this.handle.lock().unwrap().clone().unwrap();
|
||||||
|
|
@ -379,11 +443,14 @@ impl Tray for VietTray {
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}.into());
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push(MenuItem::Separator);
|
items.push(MenuItem::Separator);
|
||||||
items.push(StandardItem {
|
items.push(
|
||||||
|
StandardItem {
|
||||||
label: "About: Viet+".into(),
|
label: "About: Viet+".into(),
|
||||||
activate: Box::new(|_| {
|
activate: Box::new(|_| {
|
||||||
let _ = std::process::Command::new("xdg-open")
|
let _ = std::process::Command::new("xdg-open")
|
||||||
|
|
@ -391,18 +458,25 @@ impl Tray for VietTray {
|
||||||
.status();
|
.status();
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}.into());
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
items.push(MenuItem::Separator);
|
items.push(MenuItem::Separator);
|
||||||
items.push(StandardItem {
|
items.push(
|
||||||
|
StandardItem {
|
||||||
label: "Quit".into(),
|
label: "Quit".into(),
|
||||||
activate: Box::new(|_| {
|
activate: Box::new(|_| {
|
||||||
let _ = std::process::Command::new("pkill")
|
let _ = std::process::Command::new("pkill")
|
||||||
.arg("-x").arg("vietc").status();
|
.arg("-x")
|
||||||
|
.arg("vietc")
|
||||||
|
.status();
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}.into());
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
@ -439,20 +513,24 @@ pub fn run() {
|
||||||
tray_dummy.check_for_updates(&handle, false);
|
tray_dummy.check_for_updates(&handle, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll for changes
|
// Poll for changes (shorter interval for faster icon updates)
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
let mode = read_status();
|
let mode = read_status();
|
||||||
let im = current_im();
|
let im = current_im();
|
||||||
let autostart = config::is_autostart_installed();
|
let autostart = config::is_autostart_installed();
|
||||||
|
// Also check status_changed flag for immediate updates
|
||||||
let _ = handle.update(move |t| {
|
let _ = handle.update(move |t| {
|
||||||
t.mode = mode;
|
t.mode = mode;
|
||||||
t.im = im;
|
t.im = im;
|
||||||
t.autostart = autostart;
|
t.autostart = autostart;
|
||||||
|
// Force icon redraw on update by updating pixmap-related state
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
loop { std::thread::park(); }
|
loop {
|
||||||
|
std::thread::park();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue