- X11 keyboard capture via XGrabKeyboard (no input group needed) - Direct X11 clipboard for Unicode injection (no xclip/xdotool dependency) - Proper KeyPress/KeyRelease tracking (fix Ctrl+C, Alt+Tab, held keys) - Default input_method=vni, start_enabled=false - AppImage: bundled xclip, proper keyboard+VN SVG icon - Deb: Recommends libxtst6, xclip
403 lines
11 KiB
Rust
403 lines
11 KiB
Rust
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
use serde::Deserialize;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[allow(dead_code)]
|
|
pub struct Config {
|
|
#[serde(default = "default_input_method")]
|
|
pub input_method: String,
|
|
|
|
#[serde(default = "default_toggle_key")]
|
|
pub toggle_key: String,
|
|
|
|
#[serde(default = "default_start_enabled")]
|
|
pub start_enabled: bool,
|
|
|
|
#[serde(default)]
|
|
pub auto_restore: AutoRestoreConfig,
|
|
|
|
#[serde(default)]
|
|
pub app_state: AppStateConfig,
|
|
|
|
#[serde(default)]
|
|
pub macros: HashMap<String, String>,
|
|
|
|
#[serde(default)]
|
|
pub grab: bool,
|
|
|
|
#[serde(default = "default_false")]
|
|
pub debug: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[allow(dead_code)]
|
|
pub struct AutoRestoreConfig {
|
|
#[serde(default = "default_true")]
|
|
pub enabled: bool,
|
|
|
|
#[serde(default = "default_restore_keys")]
|
|
pub trigger_keys: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[allow(dead_code)]
|
|
pub struct AppStateConfig {
|
|
#[serde(default = "default_true")]
|
|
pub enabled: bool,
|
|
|
|
#[serde(default)]
|
|
pub english_apps: Vec<String>,
|
|
|
|
#[serde(default)]
|
|
pub vietnamese_apps: Vec<String>,
|
|
|
|
#[serde(default = "default_bypass_apps")]
|
|
pub bypass_apps: Vec<String>,
|
|
}
|
|
|
|
impl Default for AutoRestoreConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
trigger_keys: default_restore_keys(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for AppStateConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
english_apps: default_english_apps(),
|
|
vietnamese_apps: default_vietnamese_apps(),
|
|
bypass_apps: default_bypass_apps(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_input_method() -> String {
|
|
"vni".into()
|
|
}
|
|
fn default_toggle_key() -> String {
|
|
"space".into()
|
|
}
|
|
fn default_start_enabled() -> bool {
|
|
false
|
|
}
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
fn default_false() -> bool {
|
|
false
|
|
}
|
|
fn default_restore_keys() -> Vec<String> {
|
|
vec!["space".into(), "escape".into()]
|
|
}
|
|
|
|
fn default_english_apps() -> Vec<String> {
|
|
vec![
|
|
"code".into(),
|
|
"jetbrains".into(),
|
|
"intellij".into(),
|
|
"pycharm".into(),
|
|
"webstorm".into(),
|
|
"vim".into(),
|
|
"nvim".into(),
|
|
]
|
|
}
|
|
|
|
fn default_bypass_apps() -> Vec<String> {
|
|
vec![
|
|
"terminal".into(),
|
|
"kitty".into(),
|
|
"alacritty".into(),
|
|
"foot".into(),
|
|
"wezterm".into(),
|
|
"konsole".into(),
|
|
"gnome-terminal".into(),
|
|
"st".into(),
|
|
"urxvt".into(),
|
|
"xterm".into(),
|
|
"steam".into(),
|
|
"dota".into(),
|
|
"csgo".into(),
|
|
"minecraft".into(),
|
|
"factorio".into(),
|
|
]
|
|
}
|
|
|
|
fn default_vietnamese_apps() -> Vec<String> {
|
|
vec![
|
|
"telegram".into(),
|
|
"discord".into(),
|
|
"slack".into(),
|
|
"firefox".into(),
|
|
"chromium".into(),
|
|
"thunderbird".into(),
|
|
]
|
|
}
|
|
|
|
impl Config {
|
|
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
|
let paths = [
|
|
dirs().map(|d| d.join("vietc").join("config.toml")),
|
|
Some(PathBuf::from("vietc.toml")),
|
|
// AppImage bundled config: <exe dir>/../../etc/vietc/config.toml
|
|
std::env::current_exe().ok().and_then(|exe| {
|
|
exe.parent()
|
|
.and_then(|p| p.parent())
|
|
.and_then(|p| p.parent())
|
|
.map(|p| p.join("etc").join("vietc").join("config.toml"))
|
|
}),
|
|
];
|
|
|
|
for path in paths.into_iter().flatten() {
|
|
if path.exists() {
|
|
let content = fs::read_to_string(&path)?;
|
|
let config: Config = toml::from_str(&content)?;
|
|
eprintln!("[vietc] Loaded config from: {}", path.display());
|
|
return Ok(config);
|
|
}
|
|
}
|
|
|
|
eprintln!("[vietc] Using default config");
|
|
Ok(Self::default())
|
|
}
|
|
|
|
pub fn load_from(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
|
|
let content = fs::read_to_string(path)?;
|
|
let config: Config = toml::from_str(&content)?;
|
|
Ok(config)
|
|
}
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Self {
|
|
let mut macros = HashMap::new();
|
|
macros.insert("ko".into(), "không".into());
|
|
macros.insert("kc".into(), "không có".into());
|
|
macros.insert("ko dc".into(), "không được".into());
|
|
macros.insert("dc".into(), "được".into());
|
|
macros.insert("ng".into(), "người".into());
|
|
macros.insert("nk".into(), "như".into());
|
|
macros.insert("vs".into(), "với".into());
|
|
macros.insert("lm".into(), "làm".into());
|
|
macros.insert("rd".into(), "rất".into());
|
|
macros.insert("bt".into(), "biết".into());
|
|
|
|
Self {
|
|
input_method: default_input_method(),
|
|
toggle_key: default_toggle_key(),
|
|
start_enabled: default_start_enabled(),
|
|
auto_restore: AutoRestoreConfig::default(),
|
|
app_state: AppStateConfig::default(),
|
|
macros,
|
|
grab: false,
|
|
debug: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn dirs() -> Option<PathBuf> {
|
|
std::env::var("XDG_CONFIG_HOME")
|
|
.ok()
|
|
.map(PathBuf::from)
|
|
.or_else(|| {
|
|
std::env::var("HOME")
|
|
.ok()
|
|
.map(|h| PathBuf::from(h).join(".config"))
|
|
})
|
|
}
|
|
|
|
pub fn find_config_path() -> PathBuf {
|
|
let paths = [
|
|
dirs().map(|d| d.join("vietc").join("config.toml")),
|
|
Some(PathBuf::from("vietc.toml")),
|
|
std::env::current_exe().ok().and_then(|exe| {
|
|
exe.parent()
|
|
.and_then(|p| p.parent())
|
|
.and_then(|p| p.parent())
|
|
.map(|p| p.join("etc").join("vietc").join("config.toml"))
|
|
}),
|
|
];
|
|
|
|
for path in paths.into_iter().flatten() {
|
|
if path.exists() {
|
|
return path;
|
|
}
|
|
}
|
|
|
|
// Default to current directory
|
|
PathBuf::from("vietc.toml")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_full_config() {
|
|
let toml = r#"
|
|
input_method = "vni"
|
|
toggle_key = "shift"
|
|
start_enabled = false
|
|
|
|
[auto_restore]
|
|
enabled = false
|
|
|
|
[app_state]
|
|
enabled = true
|
|
english_apps = ["code", "vim"]
|
|
vietnamese_apps = ["telegram", "discord"]
|
|
|
|
[macros]
|
|
ko = "không"
|
|
dc = "được"
|
|
vs = "với"
|
|
"#;
|
|
let config: Config = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.input_method, "vni");
|
|
assert_eq!(config.toggle_key, "shift");
|
|
assert!(!config.start_enabled);
|
|
assert!(!config.auto_restore.enabled);
|
|
assert!(config.app_state.enabled);
|
|
assert_eq!(config.app_state.english_apps, vec!["code", "vim"]);
|
|
assert_eq!(
|
|
config.app_state.vietnamese_apps,
|
|
vec!["telegram", "discord"]
|
|
);
|
|
assert_eq!(config.macros.get("ko").unwrap(), "không");
|
|
assert_eq!(config.macros.get("dc").unwrap(), "được");
|
|
assert_eq!(config.macros.get("vs").unwrap(), "với");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_empty_config_uses_defaults() {
|
|
let toml = "";
|
|
let config: Config = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.input_method, "vni");
|
|
assert_eq!(config.toggle_key, "space");
|
|
assert!(!config.start_enabled);
|
|
assert!(config.auto_restore.enabled);
|
|
assert!(config.app_state.enabled);
|
|
assert!(!config.app_state.english_apps.is_empty());
|
|
assert!(!config.app_state.vietnamese_apps.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_partial_config() {
|
|
let toml = r#"
|
|
input_method = "vni"
|
|
"#;
|
|
let config: Config = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.input_method, "vni");
|
|
assert_eq!(config.toggle_key, "space"); // default
|
|
assert!(!config.start_enabled); // default
|
|
}
|
|
|
|
#[test]
|
|
fn parse_macros_only() {
|
|
let toml = r#"
|
|
[macros]
|
|
hello = "world"
|
|
foo = "bar"
|
|
"#;
|
|
let config: Config = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.macros.len(), 2);
|
|
assert_eq!(config.macros.get("hello").unwrap(), "world");
|
|
assert_eq!(config.macros.get("foo").unwrap(), "bar");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_empty_macros() {
|
|
let toml = r#"
|
|
[macros]
|
|
"#;
|
|
let config: Config = toml::from_str(toml).unwrap();
|
|
assert!(config.macros.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_app_lists() {
|
|
let toml = r#"
|
|
[app_state]
|
|
english_apps = ["vim", "neovim"]
|
|
vietnamese_apps = ["zalo", "messenger"]
|
|
bypass_apps = ["kitty"]
|
|
"#;
|
|
let config: Config = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.app_state.english_apps, vec!["vim", "neovim"]);
|
|
assert_eq!(config.app_state.vietnamese_apps, vec!["zalo", "messenger"]);
|
|
assert_eq!(config.app_state.bypass_apps, vec!["kitty"]);
|
|
}
|
|
|
|
#[test]
|
|
fn default_config_has_macros() {
|
|
let config = Config::default();
|
|
assert!(config.macros.contains_key("ko"));
|
|
assert!(config.macros.contains_key("dc"));
|
|
assert!(config.macros.contains_key("vs"));
|
|
assert!(config.macros.contains_key("lm"));
|
|
}
|
|
|
|
#[test]
|
|
fn default_config_english_apps() {
|
|
let config = Config::default();
|
|
assert!(config.app_state.english_apps.contains(&"code".to_string()));
|
|
assert!(config.app_state.english_apps.contains(&"vim".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn default_config_bypass_apps() {
|
|
let config = Config::default();
|
|
assert!(config.app_state.bypass_apps.contains(&"kitty".to_string()));
|
|
assert!(config
|
|
.app_state
|
|
.bypass_apps
|
|
.contains(&"alacritty".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn default_config_vietnamese_apps() {
|
|
let config = Config::default();
|
|
assert!(config
|
|
.app_state
|
|
.vietnamese_apps
|
|
.contains(&"telegram".to_string()));
|
|
assert!(config
|
|
.app_state
|
|
.vietnamese_apps
|
|
.contains(&"firefox".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_auto_restore_config() {
|
|
let toml = r#"
|
|
[auto_restore]
|
|
enabled = false
|
|
"#;
|
|
let config: Config = toml::from_str(toml).unwrap();
|
|
assert!(!config.auto_restore.enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_invalid_toml_fails() {
|
|
let toml = "this is not valid toml {{{";
|
|
let result = toml::from_str::<Config>(toml);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_unknown_fields_ignored() {
|
|
let toml = r#"
|
|
input_method = "telex"
|
|
unknown_field = "value"
|
|
"#;
|
|
// serde's default deny_unknown_fields is not set, so this should work
|
|
let config: Config = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.input_method, "telex");
|
|
}
|
|
}
|