release: v0.1.7 — password detection, Telex enabled, GNOME Wayland support

This commit is contained in:
Khoa Vo 2026-07-01 10:58:16 +07:00
parent d34180537a
commit 6beeee2e69
15 changed files with 845 additions and 101 deletions

View file

@ -1,6 +1,40 @@
# Changelog # Changelog
## v0.1.6 (2026-06-29) ## v0.1.7 (2026-07-01)
### Password Auto-Detection
- **AT-SPI2 D-Bus integration**: Queries `org.a11y.atspi.Registry.GetFocus` + `GetRole` to detect password fields (role 62 = `PASSWORD_TEXT`). Automatically disables Vietnamese input when typing into a password field, re-enables when focus moves away.
- **Window-class fallback**: Password dialogs (pinentry, polkit, kwallet, ssh-askpass) are detected via `password_apps` config list.
- **Window-title fallback**: Window titles containing "password", "passphrase", "sudo", "mật khẩu" trigger automatic English mode.
### Telex Input Method
- **Telex now fully enabled**: Both VNI and Telex are supported. Switch via Ctrl+Shift hotkey or tray menu "Input Method > Telex / VNI".
- **Method status file** (`~/.config/vietc/method`): Daemon writes the current method so the tray can display it.
- **Tray indicator**: Red "VN" for VNI, Blue "TLX" for Telex, Gray "EN" for English mode.
- **Config option**: `toggle_method_key = "shift"` configures the Ctrl+Shift method toggle combo.
### GNOME/Wayland Support
- **Native GNOME Shell D-Bus integration**: Queries `org.gnome.Shell.Eval` for focused window class, ID, and title — works on Wayland GNOME where xdotool/xprop are unavailable.
- **Window detection chain**: GNOME Shell D-Bus → wlrctl → xdotool → /proc — ensures window tracking works across all environments.
- **Compositor detection**: Added GNOME/Mutter detection via `pgrep gnome-shell` and `XDG_CURRENT_DESKTOP`.
- **Dependencies**: `dbus` crate (0.9) added for both AT-SPI2 and GNOME Shell D-Bus queries.
### CLI Enhancements
- **Pass-through characters**: All characters now appear in output (not just those that emit engine events).
- **Screen display**: Backspace characters are properly applied to show what would appear on screen.
- **State reset**: Each input line starts with a clean engine state.
- **New commands**: `:help`, `:status`, `:vi`, `:en`, `:ar on|off`, `:macros`, `:macro add/rm/clear`, `:events`, `:events clear`.
### Bug Fixes
- **Engine state correctly reset between input lines** in CLI test harness.
- **Flush characters forwarded** after macro expansion / auto-restore replacement to preserve spacing.
---
### uinput-First Injection ### uinput-First Injection

View file

@ -2,8 +2,8 @@
<img src="https://img.shields.io/badge/Platform-Linux-blue?style=for-the-badge" alt="Platform"> <img src="https://img.shields.io/badge/Platform-Linux-blue?style=for-the-badge" alt="Platform">
<img src="https://img.shields.io/badge/Language-Rust-orange?style=for-the-badge" alt="Rust"> <img src="https://img.shields.io/badge/Language-Rust-orange?style=for-the-badge" alt="Rust">
<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License"> <img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License">
<img src="https://img.shields.io/badge/Version-0.1.6-purple?style=for-the-badge" alt="Version"> <img src="https://img.shields.io/badge/Version-0.1.7-purple?style=for-the-badge" alt="Version">
<img src="https://img.shields.io/badge/Tests-106_passing-brightgreen?style=for-the-badge" alt="Tests"> <img src="https://img.shields.io/badge/Tests-118_passing-brightgreen?style=for-the-badge" alt="Tests">
<img src="https://img.shields.io/badge/Event_Sourcing-✓-blueviolet?style=for-the-badge" alt="Event Sourcing"> <img src="https://img.shields.io/badge/Event_Sourcing-✓-blueviolet?style=for-the-badge" alt="Event Sourcing">
</p> </p>
@ -59,11 +59,13 @@ Physical Keyboard
┌──────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────┐
│ Stage 2: KEY ROUTING │ │ Stage 2: KEY ROUTING │
│ │ │ │
│ Modifier keys (Ctrl/Alt/Super) → forward directly │ │ Modifier keys (Ctrl/Alt/Super) → forward directly │
│ Ctrl+Space → toggle Vietnamese ON/OFF │ │ Ctrl+Space → toggle Vietnamese ON/OFF │
│ Backspace → replay_backspace() │ │ Ctrl+Shift → toggle VNI/Telex input method │
│ Characters → replay_and_inject(ch) │ │ Password detected → auto-disable Vietnamese │
│ VNI control keys → consume when no match │ │ Backspace → replay_backspace() │
│ Characters → replay_and_inject(ch) │
│ VNI/Telex control keys → consume when no match │
└──────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────┘
@ -149,14 +151,15 @@ vietc/
├── daemon/ # Main daemon process ├── daemon/ # Main daemon process
│ ├── main.rs # Event loops, Backspace-Replay, CPU pinning │ ├── main.rs # Event loops, Backspace-Replay, CPU pinning
│ ├── config.rs # TOML config loader + hot reload │ ├── config.rs # TOML config loader + hot reload
│ ├── app_state.rs # Per-app Vietnamese/English memory │ ├── app_state.rs # Per-app VN/EN memory + password detection
│ ├── password_detector.rs # AT-SPI2 D-Bus password field detection
│ └── display.rs # X11/Wayland/compositor detection │ └── display.rs # X11/Wayland/compositor detection
├── uinputd/ # Privileged uinput backspace daemon (VMK-style) ├── uinputd/ # Privileged uinput backspace daemon (VMK-style)
│ └── main.rs # Unix socket server for /dev/uinput injection │ └── main.rs # Unix socket server for /dev/uinput injection
├── ui/ # System tray icon ├── ui/ # System tray icon
│ └── main.rs # Tray + daemon launcher │ └── tray.rs # Tray with VN/TLX/EN mode display
├── cli/ # Interactive test harness ├── cli/ # Interactive test harness
├── packaging/ # .deb packaging scripts ├── packaging/ # .deb packaging scripts
@ -214,7 +217,12 @@ vietc/
## Input Methods ## Input Methods
### VNI (default, Telex coming in next version) Both **VNI** and **Telex** are fully supported. Switch between them via:
- **Ctrl+Shift** hotkey (toggle at runtime)
- **System tray** menu: "Input Method > Telex / VNI"
- **Config file**: `input_method = "vni"` or `"telex"`
### VNI
| Key | Result | Example | | Key | Result | Example |
|-----|--------|---------| |-----|--------|---------|
@ -228,7 +236,25 @@ vietc/
| `8` | ă | `a8``ă` | | `8` | ă | `a8``ă` |
| `9` | đ | `d9``đ` | | `9` | đ | `d9``đ` |
Flexible typing: type the full syllable, then add marks/tone keys at the end. Example: `nguye6n4``nguyễn`. The engine scans backward up to 5 characters to find the target vowel. ### Telex
| Key | Result | Example |
|-----|--------|---------|
| `s` | á (sắc) | `as``á` |
| `f` | à (huyền) | `af``à` |
| `r` | ả (hỏi) | `ar``ả` |
| `x` | ã (ngã) | `ax``ã` |
| `j` | ạ (nặng) | `aj``ạ` |
| `aa` | â | `aa``â` |
| `ee` | ê | `ee``ê` |
| `oo` | ô | `oo``ô` |
| `ow` | ơ | `ow``ơ` |
| `aw` | ă | `aw``ă` |
| `uw` | ư | `uw``ư` |
| `dd` | đ | `dd``đ` |
| `w` | ươ (uo cluster) | `chuongw``chương` |
Flexible typing: type the full syllable, then add marks/tone keys at the end. Examples: `tieengs``tiếng`, `nguyeexn``nguyễn`, `chafo``chào`. The engine scans backward up to 5 characters to find the target vowel.
--- ---
@ -248,6 +274,10 @@ Flexible typing: type the full syllable, then add marks/tone keys at the end. Ex
| **Window-Switch Reset** | Active window ID verified on every keystroke — Alt+Tab instantly clears engine state. No stale composition across apps | | **Window-Switch Reset** | Active window ID verified on every keystroke — Alt+Tab instantly clears engine state. No stale composition across apps |
| **CPU Priority** | Pins daemon to P-cores (0-3) + nice(-10) for low-latency input | | **CPU Priority** | Pins daemon to P-cores (0-3) + nice(-10) for low-latency input |
| **Uinput Injection** | Uses `/dev/uinput` for reliable keyboard injection without X11 dependency. Falls back to XTest on systems without uinput access | | **Uinput Injection** | Uses `/dev/uinput` for reliable keyboard injection without X11 dependency. Falls back to XTest on systems without uinput access |
| **Password Auto-Detection** | AT-SPI2 + window-class + window-title — automatically disables Vietnamese when typing into password fields |
| **Method Toggle** | Ctrl+Shift switches between VNI and Telex at runtime; tray icon shows current mode (VN/TLX/EN) |
| **GNOME/Wayland Support** | Native GNOME Shell D-Bus integration for window detection, app memory, and password detection on Wayland |
| **VNI & Telex** | Both input methods fully supported, switchable at runtime |
--- ---
@ -287,7 +317,7 @@ System tray icon + daemon + desktop entry. Requires user to be in the `input` gr
```bash ```bash
# Install # Install
sudo dpkg -i vietc_0.1.6-1_amd64.deb sudo dpkg -i vietc_0.1.7-1_amd64.deb
# Log out and log back in (for input group membership to take effect) # Log out and log back in (for input group membership to take effect)
# Then launch "Viet+" from your application menu # Then launch "Viet+" from your application menu
@ -318,7 +348,8 @@ Config file: `~/.config/vietc/config.toml` or `./vietc.toml`
```toml ```toml
input_method = "vni" # "vni" or "telex" input_method = "vni" # "vni" or "telex"
toggle_key = "space" # Ctrl+Space to toggle toggle_key = "space" # Ctrl+Space to toggle VN/EN
toggle_method_key = "shift" # Ctrl+Shift to toggle VNI/Telex
start_enabled = true # Vietnamese by default start_enabled = true # Vietnamese by default
grab = true # grab keyboard (evdev) grab = true # grab keyboard (evdev)
@ -326,6 +357,16 @@ grab = true # grab keyboard (evdev)
enabled = true enabled = true
trigger_keys = ["space", "escape"] trigger_keys = ["space", "escape"]
[password_detection]
enabled = true
check_atspi2 = true # AT-SPI2 accessibility bus detection
check_window_title = true
title_keywords = ["password", "passphrase", "secret", "mật khẩu", "sudo"]
password_apps = ["pinentry", "pinentry-gtk-2", "pinentry-qt",
"lxqt-sudo", "kdesudo", "gksudo",
"polkit-gnome-authentication-agent-1",
"kwallet", "gnome-keyring", "ssh-askpass"]
[app_state] [app_state]
enabled = true enabled = true
english_apps = ["code", "vim", "kitty", "foot"] english_apps = ["code", "vim", "kitty", "foot"]

View file

@ -1,6 +1,6 @@
[package] [package]
name = "vietc-cli" name = "vietc-cli"
version = "0.1.6" version = "0.1.7"
edition = "2021" edition = "2021"
description = "Viet+ CLI Test Harness" description = "Viet+ CLI Test Harness"

View file

@ -1,16 +1,47 @@
// SPDX-License-Identifier: MIT
use std::io::{self, Write}; use std::io::{self, Write};
use vietc_engine::{Engine, EngineEvent, InputMethod}; use vietc_engine::{Engine, EngineEvent, EventStore, InputEvent, InputMethod};
struct CliState {
engine: Engine,
method: InputMethod,
events: EventStore,
macros: Vec<(String, String)>,
auto_restore: bool,
}
impl CliState {
fn new() -> Self {
Self {
engine: Engine::new(InputMethod::Telex),
method: InputMethod::Telex,
events: EventStore::new(),
macros: Vec::new(),
auto_restore: true,
}
}
fn set_method(&mut self, method: InputMethod) {
self.method = method;
self.engine.set_method(method);
}
fn status(&self) {
println!(" Method: {:?}", self.method);
println!(" Enabled: {}", self.engine.is_enabled());
println!(" Auto-restore: {}", self.auto_restore);
println!(" Buffer: {:?}", self.engine.buffer());
println!(" Macros: {} defined", self.macros.len());
for (s, e) in &self.macros {
println!(" {} -> {}", s, e);
}
println!(" Events: {} recorded", self.events.len());
}
}
fn main() { fn main() {
let mut engine = Engine::new(InputMethod::Telex); let mut state = CliState::new();
println!("Viet+ IME - Test Harness"); print_help();
println!("==========================");
println!("Type Vietnamese using Telex input.");
println!("Press Enter to flush, type 'quit' to exit.");
println!("Toggle method with ':vni' or ':telex'");
println!();
loop { loop {
print!("> "); print!("> ");
@ -20,82 +51,69 @@ fn main() {
io::stdin().read_line(&mut input).unwrap(); io::stdin().read_line(&mut input).unwrap();
let input = input.trim(); let input = input.trim();
if input.is_empty() {
continue;
}
if input == "quit" || input == "exit" { if input == "quit" || input == "exit" {
break; break;
} }
if input == ":vni" { if input.starts_with(':') {
engine.set_method(InputMethod::Vni); handle_command(&mut state, input);
println!("[Switched to VNI]");
continue; continue;
} }
if input == ":telex" { state.engine.reset();
engine.set_method(InputMethod::Telex);
println!("[Switched to Telex]");
continue;
}
if input == ":reset" {
engine.reset();
println!("[Engine reset]");
continue;
}
if input == ":buffer" {
println!("[Buffer: {:?}]", engine.buffer());
continue;
}
let mut output = String::new(); let mut output = String::new();
let mut events = Vec::new(); let mut events = Vec::new();
for ch in input.chars() { for ch in input.chars() {
if let Some(event) = engine.process_key(ch) { state.events.push(InputEvent::KeyTyped(ch));
match state.engine.process_key(ch) {
None => {
output.push(ch);
}
Some(event) => {
events.push((ch, event.clone())); events.push((ch, event.clone()));
match &event { match &event {
EngineEvent::Flush(text) => { EngineEvent::Insert(text) | EngineEvent::Flush(text) => {
output.push_str(text); output.push_str(text);
} }
EngineEvent::Insert(text) => { EngineEvent::Paste(text) => {
output.push_str(text); output.push_str(text);
} }
EngineEvent::AutoRestore(word) => {
// Auto-restore: delete the word and re-insert it
for _ in 0..word.len() {
output.push('\x08'); // backspace
}
output.push_str(word);
}
EngineEvent::Replace { backspaces, insert } => { EngineEvent::Replace { backspaces, insert } => {
for _ in 0..*backspaces { for _ in 0..*backspaces {
output.push('\x08'); output.push('\x08');
} }
output.push_str(insert); output.push_str(insert);
if is_flush_char(ch) {
output.push(ch);
} }
EngineEvent::UndoTones { }
backspaces, EngineEvent::UndoTones { backspaces, restored } => {
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) => { EngineEvent::AutoRestore(word) => {
output.push_str(text); for _ in 0..word.len() {
output.push('\x08');
}
output.push_str(word);
}
} }
} }
} }
} }
// Flush remaining buffer if let Some(event) = state.engine.flush() {
if let Some(event) = engine.flush() {
match &event { match &event {
EngineEvent::Flush(text) => { EngineEvent::Flush(text) | EngineEvent::Insert(text) => {
output.push_str(text);
}
EngineEvent::Insert(text) => {
output.push_str(text); output.push_str(text);
} }
_ => {} _ => {}
@ -104,10 +122,204 @@ fn main() {
} }
println!(" Events: {:?}", events); println!(" Events: {:?}", events);
println!(" Output: {:?}", output); println!(" Raw: {:?}", output);
// Show what it would look like let display = apply_backspaces(&output);
let display: String = output.chars().filter(|c| *c != '\x08').collect(); println!(" Screen: {}", display);
println!(" Display: {}", display);
} }
} }
fn print_help() {
println!("Viet+ IME - Test Harness");
println!("=========================");
println!("Type text with VNI/Telex to see engine output.");
println!();
println!("Commands:");
println!(" :help Show this help");
println!(" :status Show engine state");
println!(" :vi Enable Vietnamese mode");
println!(" :en Disable Vietnamese mode");
println!(" :ar on|off Toggle auto-restore");
println!(" :vni Switch to VNI input");
println!(" :telex Switch to Telex input");
println!(" :reset Reset engine buffer");
println!(" :buffer Show composing buffer");
println!(" :events Show event store history");
println!(" :events clear Clear event store");
println!(" :macros List macros");
println!(" :macro add <s> <e> Add macro shortcut->expansion");
println!(" :macro rm <s> Remove a macro");
println!(" :macro clear Clear all macros");
println!(" quit/exit Quit");
println!();
}
fn handle_command(state: &mut CliState, input: &str) {
let parts: Vec<&str> = input.splitn(4, ' ').collect();
let cmd = parts[0];
match cmd {
":help" | ":h" => print_help(),
":status" | ":st" => state.status(),
":vi" => {
state.engine.set_enabled(true);
println!("[Vietnamese mode ON]");
}
":en" => {
state.engine.set_enabled(false);
println!("[Vietnamese mode OFF]");
}
":ar" => {
if parts.len() < 2 {
println!("[Usage: :ar on|off]");
return;
}
match parts[1] {
"on" => {
state.auto_restore = true;
state.engine.set_auto_restore(true);
println!("[Auto-restore ON]");
}
"off" => {
state.auto_restore = false;
state.engine.set_auto_restore(false);
println!("[Auto-restore OFF]");
}
_ => println!("[Usage: :ar on|off]"),
}
}
":vni" => {
state.set_method(InputMethod::Vni);
println!("[Switched to VNI]");
}
":telex" => {
state.set_method(InputMethod::Telex);
println!("[Switched to Telex]");
}
":reset" => {
state.engine.reset();
println!("[Engine reset]");
}
":buffer" => {
println!("[Buffer: {:?}]", state.engine.buffer());
}
":events" | ":ev" => {
if parts.len() > 1 && parts[1] == "clear" {
state.events.clear();
println!("[Event store cleared]");
return;
}
if state.events.is_empty() {
println!("[No events]");
} else {
println!("[Events: {}]", state.events.len());
for (i, event) in state.events.iter().enumerate() {
println!(" {}: {:?}", i, event);
}
println!(" Raw keystrokes: {:?}", state.events.raw_keystrokes());
println!(" Pattern hash: {}", state.events.pattern_hash());
}
}
":macros" => {
if state.macros.is_empty() {
println!("[No macros defined]");
} else {
println!("[Macros: {}]", state.macros.len());
for (s, e) in &state.macros {
println!(" {} -> {}", s, e);
}
}
}
":macro" => {
if parts.len() < 2 {
println!("[Usage: :macro add <shortcut> <expansion> or :macro rm <s> or :macro clear]");
return;
}
match parts[1] {
"add" | "a" => {
if parts.len() < 4 {
println!("[Usage: :macro add <shortcut> <expansion>]");
return;
}
let shortcut = parts[2].to_string();
let expansion = parts[3].to_string();
state.engine.add_macro(shortcut.clone(), expansion.clone());
if let Some(pos) = state.macros.iter().position(|(s, _)| *s == shortcut) {
state.macros[pos].1 = expansion.clone();
} else {
state.macros.push((shortcut.clone(), expansion.clone()));
}
println!("[Macro added: {} -> {}]", shortcut, expansion);
}
"rm" | "remove" | "del" => {
if parts.len() < 3 {
println!("[Usage: :macro rm <shortcut>]");
return;
}
let shortcut = parts[2];
if let Some(pos) = state.macros.iter().position(|(s, _)| s == shortcut) {
state.macros.remove(pos);
state.engine.clear_macros();
for (s, e) in &state.macros {
state.engine.add_macro(s.clone(), e.clone());
}
println!("[Macro removed: {}]", shortcut);
} else {
println!("[Macro not found: {}]", shortcut);
}
}
"clear" | "c" => {
state.engine.clear_macros();
state.macros.clear();
println!("[All macros cleared]");
}
_ => {
let shortcut = parts[1].to_string();
let expansion = parts.get(2).map(|s| s.to_string()).unwrap_or_default();
if expansion.is_empty() {
println!("[Usage: :macro add <shortcut> <expansion>]");
return;
}
state.engine.add_macro(shortcut.clone(), expansion.clone());
if let Some(pos) = state.macros.iter().position(|(s, _)| *s == shortcut) {
state.macros[pos].1 = expansion.clone();
} else {
state.macros.push((shortcut.clone(), expansion.clone()));
}
println!("[Macro added: {} -> {}]", shortcut, expansion);
}
}
}
_ => {
println!("[Unknown command: {}. Type :help for available commands]", cmd);
}
}
}
fn is_flush_char(ch: char) -> bool {
matches!(ch, ' ' | '\t' | '.' | ',' | '!' | '?' | ';' | ':' | '\n')
}
fn apply_backspaces(s: &str) -> String {
let mut result = String::new();
for ch in s.chars() {
if ch == '\x08' {
result.pop();
} else {
result.push(ch);
}
}
result
}

View file

@ -1,6 +1,6 @@
[package] [package]
name = "vietc-daemon" name = "vietc-daemon"
version = "0.1.6" version = "0.1.7"
edition = "2021" edition = "2021"
description = "Viet+ background daemon" description = "Viet+ background daemon"
@ -21,3 +21,4 @@ serde = { version = "1", features = ["derive"] }
evdev = "0.12" evdev = "0.12"
libc = "0.2" libc = "0.2"
dirs = "5" dirs = "5"
dbus = "0.9"

View file

@ -3,6 +3,8 @@ use std::collections::HashMap;
use std::fs; use std::fs;
use std::process::Command; use std::process::Command;
use crate::password_detector::PasswordDetector;
/// Query _NET_ACTIVE_WINDOW directly via X11 client library (dlopen). /// Query _NET_ACTIVE_WINDOW directly via X11 client library (dlopen).
/// Works inside the Flatpak sandbox where xdotool/xprop are unavailable /// Works inside the Flatpak sandbox where xdotool/xprop are unavailable
/// but libX11.so.6 is present in the GNOME runtime. No external process /// but libX11.so.6 is present in the GNOME runtime. No external process
@ -111,10 +113,44 @@ fn get_active_window_x11_dlopen() -> Option<String> {
} }
} }
/// Get the active window's title (lowercase)
pub fn get_active_window_title() -> Option<String> {
// Try GNOME Shell D-Bus (Wayland GNOME)
if let Some(title) = get_gnome_window_title() {
return Some(title.to_lowercase());
}
// Try X11 via xdotool
if let Ok(output) = Command::new("xdotool")
.args(["getactivewindow", "getwindowname"])
.output()
{
if output.status.success() {
let title = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !title.is_empty() {
return Some(title.to_lowercase());
}
}
}
None
}
/// Query GNOME Shell via D-Bus for the focused window's title
fn get_gnome_window_title() -> Option<String> {
let js = "global.display.focus_window?.get_title() ?? ''";
let (_, title) = gnome_shell_eval(js)?;
if title.is_empty() { None } else { Some(title) }
}
/// Get the active window's X11 ID (unique per window — even within the same /// Get the active window's X11 ID (unique per window — even within the same
/// application). Returns a unique window-identifier string. /// application). Returns a unique window-identifier string.
pub fn get_active_window_id() -> Option<String> { pub fn get_active_window_id() -> Option<String> {
// Try xdotool first (fast, direct) // Try GNOME Shell D-Bus (Wayland GNOME) — returns hex window ID
if let Some(id) = get_gnome_active_window_id() {
return Some(id);
}
// Try xdotool first (fast, direct, X11)
if let Ok(output) = Command::new("xdotool") if let Ok(output) = Command::new("xdotool")
.args(["getactivewindow"]) .args(["getactivewindow"])
.output() .output()
@ -152,9 +188,21 @@ pub fn get_active_window_id() -> Option<String> {
None None
} }
/// Query GNOME Shell via D-Bus for the focused window's XID
fn get_gnome_active_window_id() -> Option<String> {
let js = "global.display.focus_window?.get_id()?.toString(16) ?? ''";
let (_, id) = gnome_shell_eval(js)?;
if id.is_empty() { None } else { Some(format!("0x{}", id)) }
}
/// Detect the currently focused window's class name /// Detect the currently focused window's class name
pub fn get_focused_window_class() -> Option<String> { pub fn get_focused_window_class() -> Option<String> {
// Try Wayland first (wlr-foreign-toplevel) // Try GNOME Shell D-Bus (Wayland GNOME)
if let Some(class) = get_gnome_focused_wm_class() {
return Some(class);
}
// Try Wayland via wlrctl (wlroots compositors)
if let Some(class) = get_wayland_window_class() { if let Some(class) = get_wayland_window_class() {
return Some(class); return Some(class);
} }
@ -172,6 +220,29 @@ pub fn get_focused_window_class() -> Option<String> {
None None
} }
/// Query GNOME Shell via D-Bus for the focused window's WM class (app ID)
fn get_gnome_focused_wm_class() -> Option<String> {
let js = "global.display.focus_window?.get_wm_class() ?? ''";
let (_, result) = gnome_shell_eval(js)?;
if result.is_empty() { None } else { Some(result.to_lowercase()) }
}
/// Execute JavaScript in GNOME Shell and return (success, output)
fn gnome_shell_eval(js: &str) -> Option<(bool, String)> {
use std::time::Duration;
let conn = dbus::blocking::Connection::new_session().ok()?;
let proxy = dbus::blocking::Proxy::new(
"org.gnome.Shell",
"/org/gnome/Shell",
Duration::from_secs(1),
&conn,
);
let (success, output): (bool, String) = proxy
.method_call("org.gnome.Shell", "Eval", (js,))
.ok()?;
Some((success, output))
}
fn get_x11_window_class() -> Option<String> { fn get_x11_window_class() -> Option<String> {
let output = Command::new("xdotool") let output = Command::new("xdotool")
.args(["getactivewindow", "getwindowclassname"]) .args(["getactivewindow", "getwindowclassname"])
@ -235,6 +306,16 @@ pub struct AppStateManager {
bypass_apps: Vec<String>, bypass_apps: Vec<String>,
/// Global enabled state /// Global enabled state
global_enabled: bool, global_enabled: bool,
/// Password detection config
password_enabled: bool,
check_atspi2: bool,
check_window_title: bool,
title_keywords: Vec<String>,
password_apps: Vec<String>,
/// Password detector (AT-SPI2)
password_detector: PasswordDetector,
/// Cached password field state
is_password_field: bool,
} }
impl AppStateManager { impl AppStateManager {
@ -251,9 +332,82 @@ impl AppStateManager {
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(), bypass_apps: bypass_apps.iter().map(|s| s.to_lowercase()).collect(),
global_enabled, global_enabled,
password_enabled: false,
check_atspi2: true,
check_window_title: true,
title_keywords: Vec::new(),
password_apps: Vec::new(),
password_detector: PasswordDetector::new(),
is_password_field: false,
} }
} }
/// Update password detection config
pub fn set_password_config(
&mut self,
enabled: bool,
check_atspi2: bool,
check_window_title: bool,
title_keywords: Vec<String>,
password_apps: Vec<String>,
) {
self.password_enabled = enabled;
self.check_atspi2 = check_atspi2;
self.check_window_title = check_window_title;
self.title_keywords = title_keywords.iter().map(|s| s.to_lowercase()).collect();
self.password_apps = password_apps.iter().map(|s| s.to_lowercase()).collect();
}
/// 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 {
if !self.password_enabled {
self.is_password_field = false;
return false;
}
// Layer 1: AT-SPI2 (most accurate, works in terminals and dialogs)
if self.check_atspi2 {
if let Some(is_password) = self.password_detector.check() {
self.is_password_field = is_password;
if is_password {
log_password_detection("AT-SPI2", &self.current_app);
}
return is_password;
}
}
// Layer 2: Window class match (for known password dialogs)
for pattern in &self.password_apps {
if self.current_app.contains(pattern.as_str()) {
self.is_password_field = true;
log_password_detection("window-class", &self.current_app);
return true;
}
}
// Layer 3: Window title heuristic (for sudo prompts, browser dialogs)
if self.check_window_title {
if let Some(title) = get_active_window_title() {
for keyword in &self.title_keywords {
if title.contains(keyword.as_str()) {
self.is_password_field = true;
log_password_detection("window-title", &title);
return true;
}
}
}
}
self.is_password_field = false;
false
}
/// Is the current widget a password field? (cached)
pub fn is_password_field(&self) -> bool {
self.is_password_field
}
/// Check if focused app changed with a pre-detected class and return whether engine should be enabled /// Check if focused app changed with a pre-detected class and return whether engine should be enabled
pub fn update_with_app(&mut self, new_class: String) -> Option<bool> { pub fn update_with_app(&mut self, new_class: String) -> Option<bool> {
if new_class == self.current_app { if new_class == self.current_app {
@ -270,7 +424,7 @@ impl AppStateManager {
} }
/// Get the default Vietnamese state for the current app /// Get the default Vietnamese state for the current app
fn get_default_state(&self) -> bool { pub fn get_default_state(&self) -> bool {
if !self.global_enabled { if !self.global_enabled {
return false; return false;
} }
@ -378,6 +532,10 @@ impl AppStateManager {
} }
} }
fn log_password_detection(method: &str, context: &str) {
eprintln!("[vietc] Password field detected via {}: {}", method, context);
}
fn override_path() -> std::path::PathBuf { fn override_path() -> std::path::PathBuf {
std::env::var("XDG_CONFIG_HOME") std::env::var("XDG_CONFIG_HOME")
.ok() .ok()

View file

@ -14,12 +14,18 @@ pub struct Config {
#[serde(default = "default_toggle_key")] #[serde(default = "default_toggle_key")]
pub toggle_key: String, pub toggle_key: String,
#[serde(default = "default_toggle_method_key")]
pub toggle_method_key: String,
#[serde(default = "default_start_enabled")] #[serde(default = "default_start_enabled")]
pub start_enabled: bool, pub start_enabled: bool,
#[serde(default)] #[serde(default)]
pub auto_restore: AutoRestoreConfig, pub auto_restore: AutoRestoreConfig,
#[serde(default)]
pub password_detection: PasswordDetectionConfig,
#[serde(default)] #[serde(default)]
pub app_state: AppStateConfig, pub app_state: AppStateConfig,
@ -33,6 +39,37 @@ pub struct Config {
pub debug: bool, pub debug: bool,
} }
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct PasswordDetectionConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub check_atspi2: bool,
#[serde(default = "default_true")]
pub check_window_title: bool,
#[serde(default = "default_title_keywords")]
pub title_keywords: Vec<String>,
#[serde(default = "default_password_apps")]
pub password_apps: Vec<String>,
}
impl Default for PasswordDetectionConfig {
fn default() -> Self {
Self {
enabled: true,
check_atspi2: true,
check_window_title: true,
title_keywords: default_title_keywords(),
password_apps: default_password_apps(),
}
}
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct AutoRestoreConfig { pub struct AutoRestoreConfig {
@ -85,6 +122,9 @@ fn default_input_method() -> String {
fn default_toggle_key() -> String { fn default_toggle_key() -> String {
"space".into() "space".into()
} }
fn default_toggle_method_key() -> String {
"shift".into()
}
fn default_start_enabled() -> bool { fn default_start_enabled() -> bool {
true true
} }
@ -97,6 +137,30 @@ fn default_false() -> bool {
fn default_restore_keys() -> Vec<String> { fn default_restore_keys() -> Vec<String> {
vec!["space".into(), "escape".into()] vec!["space".into(), "escape".into()]
} }
fn default_title_keywords() -> Vec<String> {
vec![
"password".into(),
"passphrase".into(),
"secret".into(),
"mật khẩu".into(),
"mk".into(),
"sudo".into(),
]
}
fn default_password_apps() -> Vec<String> {
vec![
"pinentry".into(),
"pinentry-gtk-2".into(),
"pinentry-qt".into(),
"lxqt-sudo".into(),
"kdesudo".into(),
"gksudo".into(),
"polkit-gnome-authentication-agent-1".into(),
"kwallet".into(),
"gnome-keyring".into(),
"ssh-askpass".into(),
]
}
fn default_english_apps() -> Vec<String> { fn default_english_apps() -> Vec<String> {
vec![ vec![
@ -192,8 +256,10 @@ impl Default for Config {
Self { Self {
input_method: default_input_method(), input_method: default_input_method(),
toggle_key: default_toggle_key(), toggle_key: default_toggle_key(),
toggle_method_key: default_toggle_method_key(),
start_enabled: default_start_enabled(), start_enabled: default_start_enabled(),
auto_restore: AutoRestoreConfig::default(), auto_restore: AutoRestoreConfig::default(),
password_detection: PasswordDetectionConfig::default(),
app_state: AppStateConfig::default(), app_state: AppStateConfig::default(),
macros, macros,
grab: false, // default false so daemon works without root (needs input group for uinput) grab: false, // default false so daemon works without root (needs input group for uinput)
@ -401,4 +467,30 @@ unknown_field = "value"
let config: Config = toml::from_str(toml).unwrap(); let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.input_method, "telex"); assert_eq!(config.input_method, "telex");
} }
#[test]
fn parse_password_detection() {
let toml = r#"
[password_detection]
enabled = true
check_atspi2 = true
check_window_title = true
title_keywords = ["password", "passphrase"]
password_apps = ["pinentry", "kwallet"]
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(config.password_detection.enabled);
assert!(config.password_detection.check_atspi2);
assert_eq!(config.password_detection.title_keywords, vec!["password", "passphrase"]);
assert_eq!(config.password_detection.password_apps, vec!["pinentry", "kwallet"]);
}
#[test]
fn parse_toggle_method_key() {
let toml = r#"
toggle_method_key = "shift"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.toggle_method_key, "shift");
}
} }

View file

@ -85,5 +85,19 @@ pub fn detect_compositor() -> Option<String> {
} }
} }
// Check for GNOME/Mutter (Ubuntu default)
if let Ok(output) = Command::new("pgrep").arg("-x").arg("gnome-shell").output() {
if output.status.success() {
return Some("GNOME (Mutter)".into());
}
}
// Check XDG_CURRENT_DESKTOP for GNOME
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
if desktop.to_lowercase().contains("gnome") {
return Some("GNOME".into());
}
}
None None
} }

View file

@ -37,10 +37,12 @@ fn boost_thread_priority() {
mod app_state; mod app_state;
mod config; mod config;
mod display; mod display;
mod password_detector;
use app_state::AppStateManager; use app_state::AppStateManager;
use config::Config; use config::Config;
#[cfg(feature = "x11")] #[cfg(feature = "x11")]
use vietc_protocol::x11_capture::X11Capture; use vietc_protocol::x11_capture::X11Capture;
use vietc_protocol::x11_capture::SKIP_RECORD_EVENTS; use vietc_protocol::x11_capture::SKIP_RECORD_EVENTS;
@ -143,6 +145,13 @@ impl Daemon {
config.start_enabled, config.start_enabled,
); );
app_state.load_overrides(); app_state.load_overrides();
app_state.set_password_config(
config.password_detection.enabled,
config.password_detection.check_atspi2,
config.password_detection.check_window_title,
config.password_detection.title_keywords.clone(),
config.password_detection.password_apps.clone(),
);
let config_modified = fs::metadata(&config_path) let config_modified = fs::metadata(&config_path)
.and_then(|m| m.modified()) .and_then(|m| m.modified())
@ -171,6 +180,28 @@ impl Daemon {
} }
} }
fn write_method_status(&self) {
if let Some(parent) = self.config_path.parent() {
let method_path = parent.join("method");
let method = &self.config.input_method;
let _ = std::fs::write(method_path, method);
}
}
fn toggle_method(&mut self) {
let new_method = match self.config.input_method.as_str() {
"vni" => InputMethod::Telex,
_ => InputMethod::Vni,
};
self.config.input_method = match new_method {
InputMethod::Vni => "vni".into(),
InputMethod::Telex => "telex".into(),
};
self.engine.set_method(new_method);
self.write_method_status();
log_info(&format!("[vietc] Input method toggled to: {}", self.config.input_method));
}
fn sync_status_file(&mut self) { fn sync_status_file(&mut self) {
if let Some(parent) = self.config_path.parent() { if let Some(parent) = self.config_path.parent() {
let status_path = parent.join("status"); let status_path = parent.join("status");
@ -214,6 +245,14 @@ impl Daemon {
new_config.app_state.bypass_apps.clone(), new_config.app_state.bypass_apps.clone(),
); );
self.app_state.set_password_config(
new_config.password_detection.enabled,
new_config.password_detection.check_atspi2,
new_config.password_detection.check_window_title,
new_config.password_detection.title_keywords.clone(),
new_config.password_detection.password_apps.clone(),
);
self.grab_enabled = new_config.grab; self.grab_enabled = new_config.grab;
self.config = new_config; self.config = new_config;
self.config_modified = modified; self.config_modified = modified;
@ -996,6 +1035,31 @@ fn run_with_evdev(
continue; continue;
} }
// Ctrl+LeftShift: toggle VNI/Telex input method
if value == 1 && is_method_toggle_state(&key_state)
{
daemon.toggle_method();
continue;
}
// Password field check: disable engine if typing into a password field
if value == 1 {
let is_pw = daemon.app_state.is_password_field();
let currently_enabled = daemon.engine.is_enabled();
if is_pw && currently_enabled {
daemon.engine.set_enabled(false);
daemon.write_status();
log_info("[vietc] Password field detected — engine disabled");
} else if !is_pw && !currently_enabled && daemon.config.start_enabled {
// Only re-enable if we're not in a manual toggle state
let default_state = daemon.app_state.get_default_state();
if default_state {
daemon.engine.set_enabled(true);
daemon.write_status();
}
}
}
if !grabbed { if !grabbed {
// Legacy mode: only forward to engine on press events // Legacy mode: only forward to engine on press events
if value != 1 { if value != 1 {
@ -1074,6 +1138,15 @@ fn run_with_evdev(
}; };
daemon.check_app_change_with(class); daemon.check_app_change_with(class);
} }
// Re-check password field status on window change
if daemon.config.password_detection.enabled {
let is_pw = daemon.app_state.check_password_field();
if is_pw && daemon.engine.is_enabled() {
daemon.engine.set_enabled(false);
daemon.write_status();
}
}
} else if daemon.config.app_state.enabled { } else if daemon.config.app_state.enabled {
let class = shared_window_class.lock().unwrap().clone(); let class = shared_window_class.lock().unwrap().clone();
if !class.is_empty() { if !class.is_empty() {
@ -1351,6 +1424,19 @@ fn is_caps_lock_on(device: &evdev::Device) -> bool {
} }
} }
fn is_method_toggle_state(key_state: &evdev::AttributeSet<evdev::Key>) -> bool {
let ctrl_pressed = key_state.contains(evdev::Key::KEY_LEFTCTRL)
|| key_state.contains(evdev::Key::KEY_RIGHTCTRL);
let shift_pressed = key_state.contains(evdev::Key::KEY_LEFTSHIFT);
// Require Ctrl + LeftShift specifically, no other modifiers
ctrl_pressed && shift_pressed
&& !key_state.contains(evdev::Key::KEY_RIGHTSHIFT)
&& !key_state.contains(evdev::Key::KEY_LEFTALT)
&& !key_state.contains(evdev::Key::KEY_RIGHTALT)
&& !key_state.contains(evdev::Key::KEY_LEFTMETA)
&& !key_state.contains(evdev::Key::KEY_RIGHTMETA)
}
fn is_toggle_combination_state(key_state: &evdev::AttributeSet<evdev::Key>, key: &str) -> bool { fn is_toggle_combination_state(key_state: &evdev::AttributeSet<evdev::Key>, key: &str) -> bool {
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);

View file

@ -0,0 +1,58 @@
use std::collections::HashMap;
use std::time::Duration;
use dbus::arg::{RefArg, Variant};
use dbus::blocking::Connection;
const ROLE_PASSWORD_TEXT: i32 = 62;
pub struct PasswordDetector {
cached: Option<bool>,
atspi_ok: bool,
}
impl PasswordDetector {
pub fn new() -> Self {
Self { cached: None, atspi_ok: false }
}
pub fn check(&mut self) -> Option<bool> {
let r = self.check_atspi2();
self.atspi_ok = r.is_some();
if let Some(v) = r {
self.cached = Some(v);
}
r
}
pub fn is_available(&self) -> bool {
self.atspi_ok
}
pub fn cached_result(&self) -> Option<bool> {
self.cached
}
fn check_atspi2(&self) -> Option<bool> {
let conn = Connection::new_session().ok()?;
let timeout = Duration::from_secs(2);
let proxy = conn.with_proxy(
"org.a11y.atspi.Registry",
"/org/a11y/atspi/registry",
timeout,
);
let (bus_name, props, _children): (String, HashMap<String, Variant<Box<dyn RefArg>>>, Vec<Variant<Box<dyn RefArg>>>) =
proxy.method_call("org.a11y.atspi.Registry", "GetFocus", ()).ok()?;
let path_variant = props.get("path")?;
let path_str = path_variant.0.as_str()?;
let acc_proxy = conn.with_proxy(&bus_name, path_str, timeout);
let (role,): (i32,) = acc_proxy.method_call("org.a11y.atspi.Accessible", "GetRole", ()).ok()?;
Some(role == ROLE_PASSWORD_TEXT)
}
}

View file

@ -1,6 +1,6 @@
[package] [package]
name = "vietc-engine" name = "vietc-engine"
version = "0.1.6" version = "0.1.7"
edition = "2021" edition = "2021"
description = "Viet+ Vietnamese IME Core Engine" description = "Viet+ Vietnamese IME Core Engine"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "vietc-protocol" name = "vietc-protocol"
version = "0.1.6" version = "0.1.7"
edition = "2021" edition = "2021"
description = "Viet+ keystroke injection backends (X11/Wayland)" description = "Viet+ keystroke injection backends (X11/Wayland)"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "vietc-tray" name = "vietc-tray"
version = "0.1.6" version = "0.1.7"
edition = "2021" edition = "2021"
description = "Viet+ system tray icon" description = "Viet+ system tray icon"

View file

@ -12,6 +12,23 @@ fn write_status(state: &str) {
} }
} }
fn read_method() -> String {
let path = dirs::config_dir()
.map(|d| d.join("vietc").join("method"))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp/vietc-method"));
std::fs::read_to_string(&path)
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| {
config::Config::load().input_method
})
}
fn write_method(method: &str) {
if let Some(config_dir) = dirs::config_dir() {
let _ = std::fs::write(config_dir.join("vietc").join("method"), method);
}
}
fn read_status() -> String { fn read_status() -> String {
let path = dirs::config_dir() let path = dirs::config_dir()
.map(|d| d.join("vietc").join("status")) .map(|d| d.join("vietc").join("status"))
@ -68,6 +85,11 @@ fn ensure_icons() {
<text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">VN</text> <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>"##; </svg>"##;
let svg_tlx = 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="#2563eb"/>
<text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">TLX</text>
</svg>"##;
let svg_en = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"> 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"/> <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> <text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">EN</text>
@ -78,11 +100,15 @@ fn ensure_icons() {
if let Some(home_icons) = &home { if let Some(home_icons) = &home {
let _ = std::fs::create_dir_all(&home_icons); let _ = std::fs::create_dir_all(&home_icons);
let vn_path = home_icons.join("vietc-vn.svg"); let vn_path = home_icons.join("vietc-vn.svg");
let tlx_path = home_icons.join("vietc-tlx.svg");
let en_path = home_icons.join("vietc-en.svg"); let en_path = home_icons.join("vietc-en.svg");
if !vn_path.exists() { if !vn_path.exists() {
let _ = std::fs::write(&vn_path, svg_vn); let _ = std::fs::write(&vn_path, svg_vn);
} }
if !tlx_path.exists() {
let _ = std::fs::write(&tlx_path, svg_tlx);
}
if !en_path.exists() { if !en_path.exists() {
let _ = std::fs::write(&en_path, svg_en); let _ = std::fs::write(&en_path, svg_en);
} }
@ -95,11 +121,15 @@ fn ensure_icons() {
let _ = std::fs::create_dir_all(&icons_dir); let _ = std::fs::create_dir_all(&icons_dir);
let vn_theme = icons_dir.join("hicolor/scalable/apps/vietc-vn.svg"); let vn_theme = icons_dir.join("hicolor/scalable/apps/vietc-vn.svg");
let tlx_theme = icons_dir.join("hicolor/scalable/apps/vietc-tlx.svg");
let en_theme = icons_dir.join("hicolor/scalable/apps/vietc-en.svg"); let en_theme = icons_dir.join("hicolor/scalable/apps/vietc-en.svg");
if !vn_theme.exists() { if !vn_theme.exists() {
let _ = std::fs::write(&vn_theme, svg_vn); let _ = std::fs::write(&vn_theme, svg_vn);
} }
if !tlx_theme.exists() {
let _ = std::fs::write(&tlx_theme, svg_tlx);
}
if !en_theme.exists() { if !en_theme.exists() {
let _ = std::fs::write(&en_theme, svg_en); let _ = std::fs::write(&en_theme, svg_en);
} }
@ -247,12 +277,17 @@ impl Tray for VietTray {
} }
fn icon_name(&self) -> String { fn icon_name(&self) -> String {
let is_tlx = self.mode == "vn" && self.im == "telex";
if is_flatpak() { if is_flatpak() {
if self.mode == "vn" { if is_tlx {
"io.github.vietc.VietPlus.vietc-tlx".into()
} else if self.mode == "vn" {
"io.github.vietc.VietPlus.vietc-vn".into() "io.github.vietc.VietPlus.vietc-vn".into()
} else { } else {
"io.github.vietc.VietPlus.vietc-en".into() "io.github.vietc.VietPlus.vietc-en".into()
} }
} else if is_tlx {
"vietc-tlx".into()
} else if self.mode == "vn" { } else if self.mode == "vn" {
"vietc-vn".into() "vietc-vn".into()
} else { } else {
@ -280,10 +315,13 @@ impl Tray for VietTray {
fn icon_pixmap(&self) -> Vec<ksni::Icon> { fn icon_pixmap(&self) -> Vec<ksni::Icon> {
let is_vn = self.mode == "vn"; let is_vn = self.mode == "vn";
let bg_color = if is_vn { let is_tlx = self.mode == "vn" && self.im == "telex";
[255, 224, 36, 36] // A, R, G, B let bg_color = if is_vn && !is_tlx {
[255, 224, 36, 36] // Red for VNI
} else if is_tlx {
[255, 37, 99, 235] // Blue for Telex
} else { } else {
[255, 75, 85, 99] [255, 75, 85, 99] // Gray for English
}; };
let fg_color = [255, 255, 255, 255]; let fg_color = [255, 255, 255, 255];
@ -319,7 +357,18 @@ impl Tray for VietTray {
} }
} }
if is_vn { if is_tlx {
// T
draw_line(&mut data, 6, 10, 15, 10, fg_color);
draw_line(&mut data, 6, 11, 15, 11, fg_color);
draw_line(&mut data, 10, 10, 10, 21, fg_color);
draw_line(&mut data, 11, 10, 11, 21, fg_color);
// X
draw_line(&mut data, 18, 10, 26, 21, fg_color);
draw_line(&mut data, 19, 10, 27, 21, fg_color);
draw_line(&mut data, 26, 10, 18, 21, fg_color);
draw_line(&mut data, 27, 10, 19, 21, fg_color);
} else if is_vn {
// V // V
draw_line(&mut data, 6, 10, 11, 21, fg_color); draw_line(&mut data, 6, 10, 11, 21, fg_color);
draw_line(&mut data, 7, 10, 12, 21, fg_color); draw_line(&mut data, 7, 10, 12, 21, fg_color);
@ -369,7 +418,7 @@ impl Tray for VietTray {
fn menu(&self) -> Vec<MenuItem<Self>> { fn menu(&self) -> Vec<MenuItem<Self>> {
let is_vn = self.mode == "vn"; let is_vn = self.mode == "vn";
let im_index = if self.im == "telex" { 0 } else { 1 }; let im_index = if self.im == "telex" { 0_usize } else { 1_usize };
let mut items = vec![ let mut items = vec![
CheckmarkItem { CheckmarkItem {
@ -409,13 +458,12 @@ impl Tray for VietTray {
let mut cfg = config::Config::load(); let mut cfg = config::Config::load();
cfg.input_method = im.into(); cfg.input_method = im.into();
let _ = cfg.save(); let _ = cfg.save();
write_method(im);
this.im = im.into(); this.im = im.into();
}), }),
options: vec![ options: vec![
RadioItem { RadioItem {
label: "Telex (next version)".into(), label: "Telex".into(),
enabled: false,
disposition: Disposition::Informative,
..Default::default() ..Default::default()
}, },
RadioItem { RadioItem {
@ -542,7 +590,7 @@ pub fn run() {
loop { loop {
std::thread::sleep(std::time::Duration::from_millis(100)); std::thread::sleep(std::time::Duration::from_millis(100));
let mode = read_status(); let mode = read_status();
let im = current_im(); let im = read_method();
let autostart = config::is_autostart_installed(); let autostart = config::is_autostart_installed();
// Also check status_changed flag for immediate updates // Also check status_changed flag for immediate updates
let _ = handle.update(move |t| { let _ = handle.update(move |t| {

View file

@ -1,6 +1,6 @@
[package] [package]
name = "vietc-uinputd" name = "vietc-uinputd"
version = "0.1.6" version = "0.1.7"
edition = "2021" edition = "2021"
description = "Viet+ privileged uinput backspace injection daemon" description = "Viet+ privileged uinput backspace injection daemon"