diff --git a/NOTES/terminal-vni.md b/NOTES/terminal-vni.md new file mode 100644 index 0000000..233004b --- /dev/null +++ b/NOTES/terminal-vni.md @@ -0,0 +1,110 @@ +# Terminal VNI Input — Design & Implementation + +## Goal + +Make Vietnamese input work in terminal emulators without breaking TUI keyboard shortcuts. + +## Approach: A + C + +### A — Remove terminals from `bypass_apps` + +All terminals are currently in `bypass_apps` (default config), which skips ALL engine +processing when the active window is a terminal. Removing them lets keystrokes flow +through the bamboo engine. + +### C — Force VNI when terminal detected + +When the active window is a terminal, the engine automatically uses VNI rules +(`1-9` for tones/marks) regardless of the global VNI/Telex setting. +This avoids key conflicts with TUI apps (vim's `j`, less's `s`, shell's `x`, etc.). + +## How It Works + +``` +User config: input_method = "telex" +Terminal window focused → effective method = "vni" (forced by terminal_apps) +GUI window focused → effective method = "telex" (user's global setting) +``` + +- **Engine** runs with effective method +- **Tray** shows global method (so user sees their configured setting) +- **Ctrl+LeftShift** toggles global method, recomputes effective method +- **Ctrl+Space** toggles VN/EN as before + +## Config Changes + +```toml +[app_state] +terminal_apps = ["kitty", "alacritty", "foot", "wezterm", "konsole", + "gnome-terminal", "gnome-terminal-server", "kgx", "st", "urxvt", "xterm", + "termite", "terminator", "tilix", "deepin-terminal", "pantheon-terminal"] +terminal_input_method = "vni" +``` + +`bypass_apps` reduced to: `["steam", "dota", "csgo", "minecraft", "factorio"]` + +## Implementation + +### 1. `daemon/src/config.rs` + +- Add `terminal_apps` (`Vec`) and `terminal_input_method` (`String`) to `AppStateConfig` +- Add `default_terminal_apps()` returning the terminal list +- Add `default_terminal_method()` returning `"vni"` +- Remove all terminal names from `default_bypass_apps()` + +### 2. `daemon/src/app_state.rs` + +- Add fields: `terminal_apps`, `terminal_input_method`, `global_method`, `effective_method` +- `new()` accepts `terminal_apps`, `terminal_input_method`, `global_method` +- `update_effective_method()`: if current_app matches any terminal, effective = terminal method; else effective = global method. Called on window change. +- `set_terminal_config()`: updates terminal_apps/terminal_input_method from config reload +- `set_global_method()`: updates global_method, recomputes effective +- `effective_method()` getter +- `is_terminal_app()` — checks if current_app is a terminal +- `update_with_app()` calls `update_effective_method()` internally +- `update_lists()` also handles terminal_apps + +### 3. `daemon/src/main.rs` + +- `Daemon::new()` — pass terminal config to `AppStateManager`, call `update_effective_method()` +- `toggle_method()` — after toggling global method, call `app_state.set_global_method()` then `engine.set_method(app_state.effective_method())` +- `check_app_change_with()` — after app change, if effective method changed from engine's current, call `engine.set_method(effective)` +- `is_vn_control_key()` calls — change from `daemon.config.input_method` to `daemon.app_state.effective_method()` +- Config reload — update `update_lists()` call to include terminal fields +- Method status file — still writes **global** method (for tray display) + +### 4. `install.sh` — Update default config block + +### 5. `README.md` — Update config example + +### 6. `NOTES/terminal-vni.md` — This file + +## Testing Checklist + +### Linux Mint (X11) + +- [ ] Type VNI in shell: `viet1 nam` → `viết nam` +- [ ] Type Telex in shell: `vieets nam` → `vieets nam` (Telex NOT active in terminal) +- [ ] Ctrl+Space toggles VN/EN +- [ ] Ctrl+LeftShift toggles global method (terminal unaffected, tray shows global) +- [ ] Vim insert mode: VNI works, `j`/`x`/`s` pass through as regular keys +- [ ] Gemini-cli: VNI typed text appears correctly +- [ ] sudo passwd: engine auto-disables +- [ ] Switch terminal ↔ GUI: method resets per app +- [ ] Tray icon shows global method, not terminal override + +### Ubuntu 24.04+ (Wayland) + +- [ ] Same VNI typing tests +- [ ] GNOME Shell D-Bus window detection +- [ ] wl-copy paste-once path for Unicode chars + +## Edge Cases + +| Case | Behavior | +|------|----------| +| Terminal in bypass_apps | No IME at all (configurable override for power users) | +| User wants Telex in terminals | Set `terminal_input_method = "telex"` in config | +| Multiple terminals open | Each follows the same rule | +| IDE integrated terminal | Window class is "code", not terminal. Needs manual config | +| Password prompt in terminal | Process-tree detection still disables engine regardless of method | diff --git a/README.md b/README.md index c632e8f..7ff5ed8 100644 --- a/README.md +++ b/README.md @@ -208,9 +208,12 @@ password_apps = ["pinentry", "pinentry-gtk-2", "pinentry-qt", [app_state] enabled = true -english_apps = ["code", "vim", "kitty", "foot"] +english_apps = ["code", "vim"] vietnamese_apps = ["telegram", "discord", "firefox"] -bypass_apps = ["kitty", "alacritty", "steam"] +bypass_apps = ["steam"] +terminal_apps = ["kitty", "alacritty", "gnome-terminal", "konsole", "foot", + "wezterm", "st", "urxvt", "xterm"] +terminal_input_method = "vni" [macros] ko = "không" diff --git a/daemon/src/app_state.rs b/daemon/src/app_state.rs index 0c0acea..b8852e2 100644 --- a/daemon/src/app_state.rs +++ b/daemon/src/app_state.rs @@ -495,6 +495,14 @@ pub struct AppStateManager { vietnamese_apps: Vec, /// Bypass apps from config bypass_apps: Vec, + /// Terminal emulator class names (force VNI mode) + terminal_apps: Vec, + /// Input method forced in terminals + terminal_input_method: String, + /// User's global input method (VNI/Telex) + global_method: String, + /// Effective method after terminal override + effective_method: String, /// Global enabled state global_enabled: bool, /// Password detection config @@ -514,14 +522,27 @@ impl AppStateManager { english_apps: Vec, vietnamese_apps: Vec, bypass_apps: Vec, + terminal_apps: Vec, + terminal_input_method: String, + global_method: String, global_enabled: bool, ) -> Self { + let effective_method = Self::compute_effective_method( + &global_method, + &terminal_input_method, + &terminal_apps, + "", + ); Self { current_app: String::new(), overrides: HashMap::new(), english_apps: english_apps.iter().map(|s| s.to_lowercase()).collect(), vietnamese_apps: vietnamese_apps.iter().map(|s| s.to_lowercase()).collect(), bypass_apps: bypass_apps.iter().map(|s| s.to_lowercase()).collect(), + terminal_apps: terminal_apps.iter().map(|s| s.to_lowercase()).collect(), + terminal_input_method, + global_method, + effective_method, global_enabled, password_enabled: false, check_atspi2: true, @@ -533,6 +554,22 @@ impl AppStateManager { } } + /// Compute effective method: use terminal override if current_app is a terminal, + /// otherwise use the global method. + fn compute_effective_method( + global_method: &str, + terminal_method: &str, + terminal_apps: &[String], + current_app: &str, + ) -> String { + for pattern in terminal_apps { + if current_app.contains(pattern.as_str()) { + return terminal_method.to_string(); + } + } + global_method.to_string() + } + /// Update password detection config pub fn set_password_config( &mut self, @@ -549,6 +586,48 @@ impl AppStateManager { self.password_apps = password_apps.iter().map(|s| s.to_lowercase()).collect(); } + /// Update terminal detection config + pub fn set_terminal_config( + &mut self, + terminal_apps: Vec, + terminal_input_method: String, + ) { + self.terminal_apps = terminal_apps.iter().map(|s| s.to_lowercase()).collect(); + self.terminal_input_method = terminal_input_method; + self.update_effective_method(); + } + + /// Set the user's global input method and recompute effective method + pub fn set_global_method(&mut self, method: &str) { + self.global_method = method.to_string(); + self.update_effective_method(); + } + + /// Recompute effective method based on terminal override + pub fn update_effective_method(&mut self) { + self.effective_method = Self::compute_effective_method( + &self.global_method, + &self.terminal_input_method, + &self.terminal_apps, + &self.current_app, + ); + } + + /// Get the effective input method (terminal override applied) + pub fn effective_method(&self) -> &str { + &self.effective_method + } + + /// Check if the current app is a terminal emulator + pub fn is_terminal_app(&self) -> bool { + for pattern in &self.terminal_apps { + if self.current_app.contains(pattern.as_str()) { + return true; + } + } + false + } + /// Check if the current focused widget is a password field /// Returns true if password detected, forcing English mode pub fn check_password_field(&mut self) -> bool { @@ -615,12 +694,22 @@ impl AppStateManager { } let old_app = self.current_app.clone(); + let old_is_terminal = self.is_terminal_app(); self.current_app = new_class; eprintln!("[vietc] App: {} → {}", old_app, self.current_app); + // Recompute effective method on window change + self.update_effective_method(); + let new_is_terminal = self.is_terminal_app(); + let method_changed = old_is_terminal != new_is_terminal; + let should_enable = self.get_default_state(); - Some(should_enable) + if method_changed { + Some(should_enable) // signal caller that method might have changed + } else { + Some(should_enable) + } } /// Get the default Vietnamese state for the current app @@ -680,15 +769,21 @@ impl AppStateManager { english_apps: Vec, vietnamese_apps: Vec, bypass_apps: Vec, + terminal_apps: Vec, + terminal_input_method: String, ) -> &Self { self.english_apps = english_apps.iter().map(|s| s.to_lowercase()).collect(); self.vietnamese_apps = vietnamese_apps.iter().map(|s| s.to_lowercase()).collect(); self.bypass_apps = bypass_apps.iter().map(|s| s.to_lowercase()).collect(); + self.terminal_apps = terminal_apps.iter().map(|s| s.to_lowercase()).collect(); + self.terminal_input_method = terminal_input_method; + self.update_effective_method(); eprintln!( - "[vietc] App lists updated: {} English, {} Vietnamese, {} Bypass", + "[vietc] App lists updated: {} English, {} Vietnamese, {} Bypass, {} Terminal", self.english_apps.len(), self.vietnamese_apps.len(), - self.bypass_apps.len() + self.bypass_apps.len(), + self.terminal_apps.len() ); self } diff --git a/daemon/src/config.rs b/daemon/src/config.rs index 8ccba98..77c1f67 100644 --- a/daemon/src/config.rs +++ b/daemon/src/config.rs @@ -94,6 +94,12 @@ pub struct AppStateConfig { #[serde(default = "default_bypass_apps")] pub bypass_apps: Vec, + + #[serde(default = "default_terminal_apps")] + pub terminal_apps: Vec, + + #[serde(default = "default_terminal_method")] + pub terminal_input_method: String, } impl Default for AutoRestoreConfig { @@ -112,6 +118,8 @@ impl Default for AppStateConfig { english_apps: default_english_apps(), vietnamese_apps: default_vietnamese_apps(), bypass_apps: default_bypass_apps(), + terminal_apps: default_terminal_apps(), + terminal_input_method: default_terminal_method(), } } } @@ -175,6 +183,16 @@ fn default_english_apps() -> Vec { } fn default_bypass_apps() -> Vec { + vec![ + "steam".into(), + "dota".into(), + "csgo".into(), + "minecraft".into(), + "factorio".into(), + ] +} + +fn default_terminal_apps() -> Vec { vec![ "terminal".into(), "kitty".into(), @@ -183,17 +201,26 @@ fn default_bypass_apps() -> Vec { "wezterm".into(), "konsole".into(), "gnome-terminal".into(), + "gnome-terminal-server".into(), + "kgx".into(), "st".into(), "urxvt".into(), "xterm".into(), - "steam".into(), - "dota".into(), - "csgo".into(), - "minecraft".into(), - "factorio".into(), + "termite".into(), + "terminator".into(), + "tilix".into(), + "deepin-terminal".into(), + "pantheon-terminal".into(), + "blackbox".into(), + "contour".into(), + "cool-retro-term".into(), ] } +fn default_terminal_method() -> String { + "vni".into() +} + fn default_vietnamese_apps() -> Vec { vec![ "telegram".into(), @@ -393,12 +420,16 @@ foo = "bar" [app_state] english_apps = ["vim", "neovim"] vietnamese_apps = ["zalo", "messenger"] -bypass_apps = ["kitty"] +bypass_apps = ["steam"] +terminal_apps = ["kitty"] +terminal_input_method = "telex" "#; 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"]); + assert_eq!(config.app_state.bypass_apps, vec!["steam"]); + assert_eq!(config.app_state.terminal_apps, vec!["kitty"]); + assert_eq!(config.app_state.terminal_input_method, "telex"); } #[test] @@ -420,11 +451,31 @@ bypass_apps = ["kitty"] #[test] fn default_config_bypass_apps() { let config = Config::default(); - assert!(config.app_state.bypass_apps.contains(&"kitty".to_string())); - assert!(config + assert!(config.app_state.bypass_apps.contains(&"steam".to_string())); + assert!(!config .app_state .bypass_apps - .contains(&"alacritty".to_string())); + .contains(&"kitty".to_string())); + } + + #[test] + fn default_config_terminal_apps() { + let config = Config::default(); + assert!(config.app_state.terminal_apps.contains(&"kitty".to_string())); + assert!(config.app_state.terminal_apps.contains(&"gnome-terminal".to_string())); + assert_eq!(config.app_state.terminal_input_method, "vni"); + } + + #[test] + fn parse_terminal_config() { + let toml = r#" +[app_state] +terminal_apps = ["foot", "alacritty"] +terminal_input_method = "telex" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.app_state.terminal_apps, vec!["foot", "alacritty"]); + assert_eq!(config.app_state.terminal_input_method, "telex"); } #[test] diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 0cfd143..0cd8f7b 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -142,6 +142,9 @@ impl Daemon { config.app_state.english_apps.clone(), config.app_state.vietnamese_apps.clone(), config.app_state.bypass_apps.clone(), + config.app_state.terminal_apps.clone(), + config.app_state.terminal_input_method.clone(), + config.input_method.clone(), config.start_enabled, ); app_state.load_overrides(); @@ -189,17 +192,23 @@ impl Daemon { } fn toggle_method(&mut self) { - let new_method = match self.config.input_method.as_str() { - "vni" => InputMethod::Telex, - _ => InputMethod::Vni, + let new_global = match self.config.input_method.as_str() { + "vni" => "telex", + _ => "vni", }; - self.config.input_method = match new_method { - InputMethod::Vni => "vni".into(), - InputMethod::Telex => "telex".into(), + self.config.input_method = new_global.into(); + self.app_state.set_global_method(new_global); + let effective = self.app_state.effective_method(); + let engine_method = match effective { + "vni" => InputMethod::Vni, + _ => InputMethod::Telex, }; - self.engine.set_method(new_method); + self.engine.set_method(engine_method); self.write_method_status(); - log_info(&format!("[vietc] Input method toggled to: {}", self.config.input_method)); + log_info(&format!( + "[vietc] Input method toggled: global={}, effective={}", + self.config.input_method, effective + )); } fn sync_status_file(&mut self) { @@ -226,11 +235,6 @@ impl Daemon { match Config::load_from(&self.config_path) { Ok(new_config) => { - let method = match new_config.input_method.as_str() { - "vni" => InputMethod::Vni, - _ => InputMethod::Telex, - }; - self.engine.set_method(method); self.engine .set_auto_restore(new_config.auto_restore.enabled); @@ -239,12 +243,23 @@ impl Daemon { self.engine.add_macro(shortcut.clone(), expansion.clone()); } + self.app_state.set_global_method(&new_config.input_method); self.app_state.update_lists( new_config.app_state.english_apps.clone(), new_config.app_state.vietnamese_apps.clone(), new_config.app_state.bypass_apps.clone(), + new_config.app_state.terminal_apps.clone(), + new_config.app_state.terminal_input_method.clone(), ); + // Apply effective method (terminal override considered) + let effective = self.app_state.effective_method(); + let engine_method = match effective { + "vni" => InputMethod::Vni, + _ => InputMethod::Telex, + }; + self.engine.set_method(engine_method); + self.app_state.set_password_config( new_config.password_detection.enabled, new_config.password_detection.check_atspi2, @@ -464,6 +479,14 @@ impl Daemon { self.engine.set_enabled(should_enable); self.write_status(); } + // Apply effective method (terminal override) + let effective = self.app_state.effective_method(); + let engine_method = match effective { + "vni" => InputMethod::Vni, + _ => InputMethod::Telex, + }; + // set_method also resets the engine buffer (safe — window already changed) + self.engine.set_method(engine_method); } } @@ -1238,7 +1261,7 @@ fn run_with_evdev( if let Some(ch) = key_to_char(key) { let mut commands = daemon.process_key(ch); if !commands.is_empty() - && is_vn_control_key(&daemon.config.input_method, ch) + && is_vn_control_key(daemon.app_state.effective_method(), ch) { for cmd in &mut commands { if let OutputCommand::Backspace(ref mut n) = cmd { @@ -1388,7 +1411,7 @@ fn run_with_evdev( } // Skip upcoming auto-repeat pile-up from injection delay skip_count = 3; - } else if is_vn_control_key(&daemon.config.input_method, ch) + } else if is_vn_control_key(daemon.app_state.effective_method(), ch) && daemon.engine.buffer().chars().count() <= buf_before { // Tone/mark key truly absorbed with no effect (no diff --git a/install.sh b/install.sh index 62ad1f1..eb90092 100755 --- a/install.sh +++ b/install.sh @@ -109,7 +109,10 @@ password_apps = ["pinentry", "pinentry-gtk-2", "pinentry-qt", "kwallet"] enabled = true english_apps = ["code", "vim"] vietnamese_apps = ["telegram", "discord", "firefox"] -bypass_apps = ["kitty", "alacritty", "steam"] +bypass_apps = ["steam"] +terminal_apps = ["kitty", "alacritty", "gnome-terminal", "konsole", "foot", + "wezterm", "st", "urxvt", "xterm"] +terminal_input_method = "vni" EOF echo -e "${GREEN}=== Done! ===${NC}"