feat: terminal VNI input — force VNI in terminals, remove from bypass_apps
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions

- Add terminal_apps / terminal_input_method to config
- AppStateManager tracks global vs effective method
- Engine uses effective method (VNI in terminals, global elsewhere)
- Terminals removed from bypass_apps, moved to terminal_apps
- Tray still shows global method (user's setting)
- NOTE: NOTES/terminal-vni.md documents the design
This commit is contained in:
Khoa Vo 2026-07-02 08:57:17 +07:00
parent 7e5281244b
commit 3ccf243f52
6 changed files with 316 additions and 31 deletions

110
NOTES/terminal-vni.md Normal file
View file

@ -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<String>`) 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 |

View file

@ -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"

View file

@ -495,6 +495,14 @@ pub struct AppStateManager {
vietnamese_apps: Vec<String>,
/// Bypass apps from config
bypass_apps: Vec<String>,
/// Terminal emulator class names (force VNI mode)
terminal_apps: Vec<String>,
/// 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<String>,
vietnamese_apps: Vec<String>,
bypass_apps: Vec<String>,
terminal_apps: Vec<String>,
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<String>,
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,13 +694,23 @@ 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();
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
pub fn get_default_state(&self) -> bool {
@ -680,15 +769,21 @@ impl AppStateManager {
english_apps: Vec<String>,
vietnamese_apps: Vec<String>,
bypass_apps: Vec<String>,
terminal_apps: Vec<String>,
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
}

View file

@ -94,6 +94,12 @@ pub struct AppStateConfig {
#[serde(default = "default_bypass_apps")]
pub bypass_apps: Vec<String>,
#[serde(default = "default_terminal_apps")]
pub terminal_apps: Vec<String>,
#[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<String> {
}
fn default_bypass_apps() -> Vec<String> {
vec![
"steam".into(),
"dota".into(),
"csgo".into(),
"minecraft".into(),
"factorio".into(),
]
}
fn default_terminal_apps() -> Vec<String> {
vec![
"terminal".into(),
"kitty".into(),
@ -183,17 +201,26 @@ fn default_bypass_apps() -> Vec<String> {
"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<String> {
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]

View file

@ -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

View file

@ -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}"