commit 16a0d73a6e232e5ccee492f5bed4cd4e539ee16a Author: vndangkhoa Date: Wed Jun 24 10:13:10 2026 +0700 Viet+ v0.1.0 - Vietnamese Input Method for Linux Features: - Direct Input Engine (no pre-edit buffer, no underline) - Telex + VNI input methods - Auto-restore English words - ESC undo (strip tones) - Smart per-app memory - Macro expansion (ko→không, dc→được, vs→với, lm→làm) - Triple backend: uinput, X11 XTEST, Wayland IM - Hot-reload config - 148 tests passing Packaging: - .deb package - AppImage support - AUR PKGBUILD - Flatpak manifest - Systemd user service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f9a4c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/target +/ui/target +Cargo.lock +*.swp +*.swo +*~ +.vscode/ +.idea/ +*.deb +*.AppImage +packaging/appimage/AppDir/ +packaging/deb/vietc_*/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..852c0a9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[workspace] +resolver = "2" +members = ["engine", "protocol", "daemon", "cli"] +exclude = ["ui"] + +[workspace.dependencies] +vietc-engine = { path = "engine" } +vietc-protocol = { path = "protocol" } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e84e89a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Viet+ Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3449bcd --- /dev/null +++ b/Makefile @@ -0,0 +1,119 @@ +.PHONY: build build-x11 build-wayland build-all build-ui build-tray test test-cli run run-x11 run-wayland clean install install-x11 install-wayland install-ui install-tray install-all-ui install-config appimage deb fmt lint tree + +# Build core crates +build: + cargo build --release + +# Build with X11 support +build-x11: + cargo build --release --features x11 + +# Build with Wayland IM protocol +build-wayland: + cargo build --release --features wayland + +# Build with all backends +build-all: + cargo build --release --features "x11,wayland" + +# Build settings UI (requires GTK4 + libadwaita) +build-ui: + cd ui && cargo build --release --bin vietc-settings + +# Build tray icon app (requires libdbus-1-dev) +build-tray: + cd ui && cargo build --release --bin vietc-tray + +# Build debug +build-dev: + cargo build + +# Run all tests +test: + cargo test + +# Run the interactive CLI test harness +test-cli: + cargo run --bin vietc-cli + +# Run the daemon (needs root for evdev/uinput) +run: build-dev + sudo cargo run --bin vietc + +# Run daemon with X11 support +run-x11: build-dev + cargo build --features x11 + sudo cargo run --bin vietc --features x11 + +# Run daemon with Wayland IM protocol +run-wayland: build-dev + cargo build --features wayland + sudo cargo run --bin vietc --features wayland + +# Run daemon in release mode +run-release: build + sudo target/release/vietc + +# Install to /usr/local/bin +install: build + sudo cp target/release/vietc /usr/local/bin/vietc + @echo "Installed vietc to /usr/local/bin/" + +# Install with X11 support +install-x11: build-x11 + sudo cp target/release/vietc /usr/local/bin/vietc + @echo "Installed vietc (with X11) to /usr/local/bin/" + +# Install with Wayland IM protocol +install-wayland: build-wayland + sudo cp target/release/vietc /usr/local/bin/vietc + @echo "Installed vietc (with Wayland IM) to /usr/local/bin/" + +# Install settings UI +install-ui: build-ui + sudo cp ui/target/release/vietc-settings /usr/local/bin/vietc-settings + @echo "Installed vietc-settings to /usr/local/bin/" + +# Install tray icon app +install-tray: build-tray + sudo cp ui/target/release/vietc-tray /usr/local/bin/vietc-tray + @echo "Installed vietc-tray to /usr/local/bin/" + +# Install all UI binaries +install-all-ui: install-ui install-tray + +# Install config to user dir +install-config: + mkdir -p ~/.config/vietc + cp vietc.toml ~/.config/vietc/config.toml + @echo "Config installed to ~/.config/vietc/config.toml" + +# Build AppImage (requires appimagetool or linuxdeploy) +appimage: build-all + VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \ + bash packaging/appimage/build-appimage.sh "$$VERSION" + +# Build .deb package (requires dpkg-deb) +deb: build-all + VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \ + bash packaging/deb/build-deb.sh "$$VERSION" + +# Clean build artifacts +clean: + cargo clean + cd ui && cargo clean + rm -rf packaging/appimage/AppDir packaging/appimage/*.AppImage packaging/deb/vietc_* + +# Format code +fmt: + cargo fmt + cd ui && cargo fmt + +# Lint +lint: + cargo clippy -- -D warnings + cd ui && cargo clippy -- -D warnings + +# Show project structure +tree: + @find . -type f \( -name "*.rs" -o -name "*.toml" \) | grep -v target | sort diff --git a/README.md b/README.md new file mode 100644 index 0000000..7173653 --- /dev/null +++ b/README.md @@ -0,0 +1,322 @@ +

+ Platform + Rust + License + Version +

+ +

+
+ Viet+ +
+

+ +

+ Vietnamese Input Method for Linux
+ Zero underline • Native Wayland/X11 • Built in Rust +

+ +

+ Features • + Quick Start • + Input Methods • + Configuration • + Installation • + Building +

+ +--- + +## Why Viet+? + +Most Vietnamese input methods on Linux suffer from **underline hell** — pre-edit buffers that duplicate text, show ugly underlines, and break your flow. Viet+ takes a different approach: + +> **Direct Input** — keystrokes are instantly converted to Unicode. No pre-edit buffer. No underline. No text duplication. Just pure Vietnamese. + +Inspired by [Gõ Nhanh](https://github.com/nickel-lang/nickel)'s brilliant UX, rebuilt native for Linux. + +--- + +## Features + +| Feature | Description | +|---------|-------------| +| **Direct Input Engine** | No pre-edit buffer, no underline, no text duplication | +| **Telex & VNI** | Both input methods fully supported | +| **Auto-Restore English** | Hit space/ESC to undo accidental Vietnamese conversion | +| **ESC Undo** | Strip all tones from the current word instantly | +| **Smart App Memory** | Remembers Vietnamese/English per application | +| **Macro Expansion** | Custom shortcuts (e.g., `ko` → `không`) | +| **Triple Backend** | uinput (universal), X11 XTEST, Wayland zwp_input_method_v2 | +| **Hot Reload** | Config changes apply without restart | +| **Settings UI** | GTK4/Libadwaita GUI (optional) | +| **System Tray** | KStatusNotifierItem tray app | +| **Zero Telemetry** | No keylogging, no disk writes, fully FOSS | + +--- + +## Quick Start + +```bash +# Clone and build +git clone https://github.com/vietplus/vietplus.git +cd vietplus +make build + +# Test the engine interactively +make test-cli + +# Run the daemon (requires root for evdev/uinput) +sudo make run + +# Or install system-wide +sudo make install +``` + +--- + +## Input Methods + +### Telex (Default) + +| Key | Result | Example | +|-----|--------|---------| +| `aa` | ă | `dan` → `dăn` | +| `ee` | ê | `men` → `mên` | +| `oo` | ô | `to` → `tô` | +| `aw` | â | `an` → `ân` | +| `ow` | ô | `on` → `ôn` | +| `ew` | ê | `en` → `ên` | +| `uw` | ư | `un` → `ưn` | +| `s` | á (sắc) | `as` → `á` | +| `f` | à (huyền) | `af` → `à` | +| `r` | ả (hỏi) | `ar` → `ả` | +| `x` | ã (ngã) | `ax` → `ã` | +| `j` | ạ (nặng) | `aj` → `ạ` | +| `dd` | đ | `dd` → `đ` | + +### VNI + +| Key | Result | +|-----|--------| +| `a1` | á | +| `a2` | à | +| `a3` | ả | +| `a4` | ã | +| `a5` | ạ | +| `a6` | ă | +| `a7` | â | +| `e8` | ê | +| `o9` | ô | +| `o0` | ơ | +| `u0` | ư | + +--- + +## Configuration + +Config file: `~/.config/vietc/config.toml` or `./vietc.toml` + +```toml +input_method = "telex" +toggle_key = "space" +start_enabled = true + +[auto_restore] +enabled = true + +[app_state] +enabled = true +english_apps = ["code", "vim", "kitty", "foot"] +vietnamese_apps = ["telegram", "discord", "firefox"] + +[macros] +ko = "không" +dc = "được" +vs = "với" +lm = "làm" +``` + +--- + +## Architecture + +``` +┌──────────────┐ ┌──────────────┐ ┌────────────────┐ +│ evdev │────▶│ Viet+ │────▶│ uinput/X11 │ +│ keyboard │ │ Engine │ │ injection │ +│ monitor │ │ (Telex/VNI) │ │ │ +└──────────────┘ └──────────────┘ └────────────────┘ + │ + ┌─────┴─────┐ + │ App State │ + │ Manager │ + └───────────┘ +``` + +--- + +## Installation + +### System Dependencies + +| Component | Ubuntu/Debian | Fedora | Arch | +|-----------|--------------|--------|------| +| Core daemon | *(none)* | *(none)* | *(none)* | +| Settings UI | `libgtk-4-dev libadwaita-1-dev` | `gtk4-devel libadwaita-devel` | `gtk4 libadwaita` | +| Tray icon | `libdbus-1-dev pkg-config` | `dbus-devel pkgconf` | `dbus pkgconf` | + +### Debian/Ubuntu + +```bash +make deb +sudo dpkg -i packaging/deb/vietc_0.1.0_amd64.deb +sudo apt-get install -f +``` + +### AppImage + +```bash +make appimage +# Requires appimagetool +appimagetool packaging/appimage/AppDir Viet+-0.1.0-x86_64.AppImage +``` + +### Arch Linux (AUR) + +```bash +cd packaging/aur +makepkg -si +``` + +### Flatpak + +```bash +flatpak-builder --user --install --force-clean build-dir \ + packaging/flatpak/io.github.vietc.VietPlus.json +``` + +### Manual Install + +```bash +sudo make install +sudo make install-ui # optional +sudo make install-tray # optional +``` + +--- + +## Building + +```bash +# Build core (daemon + CLI) +make build + +# Build with X11 support +make build-x11 + +# Build with Wayland IM protocol +make build-wayland + +# Build with all backends +make build-all + +# Build settings UI (requires GTK4) +make build-ui + +# Build tray icon (requires libdbus-1-dev) +make build-tray + +# Run tests +make test + +# Run interactive test harness +make test-cli +``` + +--- + +## Make Targets + +| Target | Description | +|--------|-------------| +| `make build` | Build core crates | +| `make build-x11` | Build with X11 support | +| `make build-wayland` | Build with Wayland IM protocol | +| `make build-all` | Build with all backends | +| `make build-ui` | Build settings UI | +| `make build-tray` | Build tray icon app | +| `make test` | Run all tests | +| `make test-cli` | Interactive test harness | +| `make run` | Run daemon (debug) | +| `make install` | Install to /usr/local/bin | +| `make install-x11` | Install with X11 | +| `make install-wayland` | Install with Wayland IM | +| `make install-ui` | Install settings UI | +| `make install-tray` | Install tray icon | +| `make install-all-ui` | Install both UI + tray | +| `make install-config` | Install default config | +| `make appimage` | Build AppImage package | +| `make deb` | Build .deb package | +| `make clean` | Clean build artifacts | +| `make fmt` | Format code | +| `make lint` | Run clippy | + +--- + +## Project Structure + +``` +viet+/ +├── engine/ # Core IME engine (Telex + VNI) +│ ├── src/ +│ │ ├── engine.rs # Main engine orchestrator +│ │ ├── telex.rs # Telex state machine +│ │ ├── vni.rs # VNI engine +│ │ ├── english.rs # English auto-restore dictionary +│ │ └── tests.rs # 124 unit tests +│ └── Cargo.toml +├── protocol/ # Injection backends +│ ├── src/ +│ │ ├── inject.rs # KeyInjector trait +│ │ ├── uinput_monitor.rs # Universal uinput backend +│ │ ├── x11_inject.rs # X11 XTEST backend +│ │ └── wayland_im.rs # Wayland IM context +│ └── Cargo.toml +├── daemon/ # Background daemon +│ ├── src/ +│ │ ├── main.rs # Evdev loop, hot-reload +│ │ ├── config.rs # TOML config loader +│ │ ├── app_state.rs # Per-app state manager +│ │ └── display.rs # Display server detection +│ └── Cargo.toml +├── cli/ # Interactive test harness +├── ui/ # Settings UI + tray (GTK4/Libadwaita) +│ ├── src/ +│ │ ├── main.rs # Settings app +│ │ ├── window.rs # Settings window +│ │ ├── tray.rs # System tray icon +│ │ └── config.rs # UI config reader +│ └── Cargo.toml +├── packaging/ # Distribution packages +│ ├── aur/ # Arch Linux PKGBUILD +│ ├── flatpak/ # Flatpak manifest +│ ├── appimage/ # AppImage build scripts +│ └── deb/ # Debian package +├── vietc.toml # Default configuration +├── vietc.service # Systemd user service +├── Makefile # Build targets +└── README.md +``` + +--- + +## License + +MIT License - see [LICENSE](LICENSE) for details. + +--- + +

+ Made with ❤️ for the Vietnamese Linux community +

diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..10698d3 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "vietc-cli" +version = "0.1.0" +edition = "2021" +description = "Viet+ CLI Test Harness" + +[dependencies] +vietc-engine = { path = "../engine" } diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 0000000..1d5e901 --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,106 @@ +use std::io::{self, Write}; +use vietc_engine::{Engine, EngineEvent, InputMethod}; + +fn main() { + let mut engine = Engine::new(InputMethod::Telex); + + println!("Viet+ IME - Test Harness"); + 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 { + print!("> "); + io::stdout().flush().unwrap(); + + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + let input = input.trim(); + + if input == "quit" || input == "exit" { + break; + } + + if input == ":vni" { + engine.set_method(InputMethod::Vni); + println!("[Switched to VNI]"); + continue; + } + + if input == ":telex" { + 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 events = Vec::new(); + + for ch in input.chars() { + if let Some(event) = engine.process_key(ch) { + events.push((ch, event.clone())); + match &event { + EngineEvent::Flush(text) => { + output.push_str(text); + } + EngineEvent::Insert(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 } => { + for _ in 0..*backspaces { + output.push('\x08'); + } + output.push_str(insert); + } + EngineEvent::UndoTones { backspaces, restored } => { + for _ in 0..*backspaces { + output.push('\x08'); + } + output.push_str(restored); + } + } + } + } + + // Flush remaining buffer + if let Some(event) = engine.flush() { + match &event { + EngineEvent::Flush(text) => { + output.push_str(text); + } + EngineEvent::Insert(text) => { + output.push_str(text); + } + _ => {} + } + events.push(('\n', event)); + } + + println!(" Events: {:?}", events); + println!(" Output: {:?}", output); + + // Show what it would look like + let display: String = output.chars().filter(|c| *c != '\x08').collect(); + println!(" Display: {}", display); + } +} diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml new file mode 100644 index 0000000..b2bcaf1 --- /dev/null +++ b/daemon/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "vietc-daemon" +version = "0.1.0" +edition = "2021" +description = "Viet+ background daemon" + +[[bin]] +name = "vietc" +path = "src/main.rs" + +[features] +default = [] +x11 = ["vietc-protocol/x11"] +wayland = ["vietc-protocol/wayland-protocol"] + +[dependencies] +vietc-engine = { path = "../engine" } +vietc-protocol = { path = "../protocol" } +toml = "0.8" +serde = { version = "1", features = ["derive"] } +evdev = "0.12" diff --git a/daemon/src/app_state.rs b/daemon/src/app_state.rs new file mode 100644 index 0000000..bc68936 --- /dev/null +++ b/daemon/src/app_state.rs @@ -0,0 +1,215 @@ +use std::collections::HashMap; +use std::fs; +use std::process::Command; + +/// Detect the currently focused window's class name +pub fn get_focused_window_class() -> Option { + // Try Wayland first (wlr-foreign-toplevel) + if let Some(class) = get_wayland_window_class() { + return Some(class); + } + + // Try X11 via xdotool + if let Some(class) = get_x11_window_class() { + return Some(class); + } + + // Fallback: try reading from /proc + if let Some(class) = get_proc_window_class() { + return Some(class); + } + + None +} + +fn get_x11_window_class() -> Option { + let output = Command::new("xdotool") + .args(["getactivewindow", "getwindowclassname"]) + .output() + .ok()?; + + if output.status.success() { + let class = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !class.is_empty() { + return Some(class.to_lowercase()); + } + } + + None +} + +fn get_wayland_window_class() -> Option { + // Try wlr-foreign-toplevel-management protocol via wlrctl + let output = Command::new("wlrctl") + .args(["toplevel", "list", "--format", "%app-id"]) + .output() + .ok()?; + + if output.status.success() { + let lines = String::from_utf8_lossy(&output.stdout); + // First line is typically the focused window + if let Some(class) = lines.lines().next() { + let class = class.trim().to_string(); + if !class.is_empty() { + return Some(class.to_lowercase()); + } + } + } + + None +} + +fn get_proc_window_class() -> Option { + // Read /proc/active-windows if available (some compositors expose this) + let content = fs::read_to_string("/proc/active-windows").ok()?; + // Format: pid window_class window_title + content.lines().next()?.split_whitespace().nth(1).map(|s| s.to_lowercase()) +} + +/// Manages per-app IME state +pub struct AppStateManager { + /// Current app class (lowercase) + current_app: String, + /// Per-app overrides (user toggled manually) + overrides: HashMap, + /// Default English apps from config + english_apps: Vec, + /// Default Vietnamese apps from config + vietnamese_apps: Vec, + /// Global enabled state + global_enabled: bool, +} + +impl AppStateManager { + pub fn new( + english_apps: Vec, + vietnamese_apps: Vec, + global_enabled: bool, + ) -> Self { + 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(), + global_enabled, + } + } + + /// Check if focused app changed and return whether engine should be enabled + pub fn update(&mut self) -> Option { + let new_class = get_focused_window_class().unwrap_or_default(); + + if new_class == self.current_app { + return None; // No change + } + + let old_app = self.current_app.clone(); + self.current_app = new_class; + + eprintln!("[vietc] App: {} → {}", old_app, self.current_app); + + let should_enable = self.get_default_state(); + Some(should_enable) + } + + /// Get the default Vietnamese state for the current app + fn get_default_state(&self) -> bool { + if !self.global_enabled { + return false; + } + + // Check user override first + if let Some(&override_state) = self.overrides.get(&self.current_app) { + return override_state; + } + + // Check config defaults + for pattern in &self.english_apps { + if self.current_app.contains(pattern.as_str()) { + return false; + } + } + + for pattern in &self.vietnamese_apps { + if self.current_app.contains(pattern.as_str()) { + return true; + } + } + + // Default: enabled + true + } + + /// Toggle the IME state for the current app (manual override) + pub fn toggle_current_app(&mut self) -> bool { + let current_state = self.get_default_state(); + let new_state = !current_state; + self.overrides.insert(self.current_app.clone(), new_state); + eprintln!( + "[vietc] {} → {} (manual override)", + self.current_app, + if new_state { "Vietnamese" } else { "English" } + ); + new_state + } + + /// Clear all overrides + #[allow(dead_code)] + pub fn clear_overrides(&mut self) { + self.overrides.clear(); + eprintln!("[vietc] All app overrides cleared"); + } + + /// Update app lists from reloaded config + pub fn update_lists(&mut self, english_apps: Vec, vietnamese_apps: Vec) { + self.english_apps = english_apps.iter().map(|s| s.to_lowercase()).collect(); + self.vietnamese_apps = vietnamese_apps.iter().map(|s| s.to_lowercase()).collect(); + eprintln!( + "[vietc] App lists updated: {} English, {} Vietnamese", + self.english_apps.len(), + self.vietnamese_apps.len() + ); + } + + /// Save overrides to config file + #[allow(dead_code)] + pub fn save_overrides(&self) -> Result<(), Box> { + let path = override_path(); + let content = toml::to_string(&self.overrides)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, content)?; + Ok(()) + } + + /// Load overrides from config file + pub fn load_overrides(&mut self) { + let path = override_path(); + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(overrides) = toml::from_str::>(&content) { + self.overrides = overrides; + eprintln!("[vietc] Loaded {} app overrides", self.overrides.len()); + } + } + } + + #[allow(dead_code)] + pub fn current_app(&self) -> &str { + &self.current_app + } +} + +fn override_path() -> std::path::PathBuf { + std::env::var("XDG_CONFIG_HOME") + .ok() + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var("HOME") + .ok() + .map(|h| std::path::PathBuf::from(h).join(".config")) + }) + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("vietc") + .join("overrides.toml") +} diff --git a/daemon/src/config.rs b/daemon/src/config.rs new file mode 100644 index 0000000..abfbfcc --- /dev/null +++ b/daemon/src/config.rs @@ -0,0 +1,329 @@ +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, +} + +#[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, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct AppStateConfig { + #[serde(default = "default_true")] + pub enabled: bool, + + #[serde(default)] + pub english_apps: Vec, + + #[serde(default)] + pub vietnamese_apps: Vec, +} + +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(), + } + } +} + +fn default_input_method() -> String { "telex".into() } +fn default_toggle_key() -> String { "space".into() } +fn default_start_enabled() -> bool { true } +fn default_true() -> bool { true } +fn default_restore_keys() -> Vec { vec!["space".into(), "escape".into()] } + +fn default_english_apps() -> Vec { + vec![ + "code".into(), + "jetbrains".into(), + "intellij".into(), + "pycharm".into(), + "webstorm".into(), + "vim".into(), + "nvim".into(), + "terminal".into(), + "kitty".into(), + "alacritty".into(), + "foot".into(), + ] +} + +fn default_vietnamese_apps() -> Vec { + vec![ + "telegram".into(), + "discord".into(), + "slack".into(), + "firefox".into(), + "chromium".into(), + "thunderbird".into(), + ] +} + +impl Config { + pub fn load() -> Result> { + let paths = [ + dirs().map(|d| d.join("vietc").join("config.toml")), + Some(PathBuf::from("vietc.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> { + 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, + } + } +} + +fn dirs() -> Option { + 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")), + ]; + + 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, "telex"); + 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", "kitty"] +vietnamese_apps = ["zalo", "messenger"] +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.app_state.english_apps, vec!["vim", "neovim", "kitty"]); + assert_eq!(config.app_state.vietnamese_apps, vec!["zalo", "messenger"]); + } + + #[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())); + assert!(config.app_state.english_apps.contains(&"kitty".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::(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"); + } +} diff --git a/daemon/src/display.rs b/daemon/src/display.rs new file mode 100644 index 0000000..880885b --- /dev/null +++ b/daemon/src/display.rs @@ -0,0 +1,88 @@ +use std::process::Command; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisplayServer { + Wayland, + X11, + Unknown, +} + +/// Detect whether we're running on Wayland or X11 +pub fn detect_display_server() -> DisplayServer { + // Check WAYLAND_DISPLAY first + if std::env::var("WAYLAND_DISPLAY").is_ok() { + return DisplayServer::Wayland; + } + + // Check XDG_SESSION_TYPE + if let Ok(session_type) = std::env::var("XDG_SESSION_TYPE") { + if session_type.contains("wayland") { + return DisplayServer::Wayland; + } + } + + // Check if XDG_RUNTIME_DIR has wayland sockets + if let Ok(xdg_runtime) = std::env::var("XDG_RUNTIME_DIR") { + let wayland_sock = std::path::Path::new(&xdg_runtime).join("wayland-0"); + if wayland_sock.exists() { + return DisplayServer::Wayland; + } + } + + // Check DISPLAY variable + if std::env::var("DISPLAY").is_ok() { + return DisplayServer::X11; + } + + // Try to detect via loginctl + if let Ok(output) = Command::new("loginctl") + .args(["show-session", &get_session_id(), "-p", "Type"]) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.contains("wayland") { + return DisplayServer::Wayland; + } + if stdout.contains("x11") { + return DisplayServer::X11; + } + } + + DisplayServer::Unknown +} + +fn get_session_id() -> String { + std::env::var("XDG_SESSION_ID").unwrap_or_else(|_| "self".into()) +} + +/// Check if a specific compositor is running +pub fn detect_compositor() -> Option { + // Check common Wayland compositor env vars + let compositor_vars = [ + ("HYPRLAND_INSTANCE_SIGNATURE", "Hyprland"), + ("SWAYSOCK", "Sway"), + ("I3SOCK", "i3"), + ("KWIN_SESSION", "KWin"), + ]; + + for (var, name) in &compositor_vars { + if std::env::var(var).is_ok() { + return Some(name.to_string()); + } + } + + // Check via process name + if let Ok(output) = Command::new("pgrep").arg("-x").arg("hyprland").output() { + if output.status.success() { + return Some("Hyprland".into()); + } + } + + if let Ok(output) = Command::new("pgrep").arg("-x").arg("sway").output() { + if output.status.success() { + return Some("Sway".into()); + } + } + + None +} diff --git a/daemon/src/main.rs b/daemon/src/main.rs new file mode 100644 index 0000000..9554e92 --- /dev/null +++ b/daemon/src/main.rs @@ -0,0 +1,402 @@ +use std::fs; +use std::path::PathBuf; + +use vietc_engine::{Engine, EngineEvent, InputMethod}; + +mod config; +mod app_state; +mod display; + +use config::Config; +use app_state::AppStateManager; + +struct Daemon { + engine: Engine, + config: Config, + config_path: PathBuf, + config_modified: std::time::SystemTime, + app_state: AppStateManager, +} + +impl Daemon { + fn new(config: Config, config_path: PathBuf) -> Self { + let method = match config.input_method.as_str() { + "vni" => InputMethod::Vni, + _ => InputMethod::Telex, + }; + let mut engine = Engine::new(method); + engine.set_enabled(config.start_enabled); + + for (shortcut, expansion) in &config.macros { + engine.add_macro(shortcut.clone(), expansion.clone()); + } + + let mut app_state = AppStateManager::new( + config.app_state.english_apps.clone(), + config.app_state.vietnamese_apps.clone(), + config.start_enabled, + ); + app_state.load_overrides(); + + let config_modified = fs::metadata(&config_path) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::now()); + + Self { + engine, + config, + config_path, + config_modified, + app_state, + } + } + + fn reload_config(&mut self) -> bool { + let modified = fs::metadata(&self.config_path) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::now()); + + if modified <= self.config_modified { + return false; + } + + eprintln!("[vietc] Config changed, reloading..."); + 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.clear_macros(); + for (shortcut, expansion) in &new_config.macros { + self.engine.add_macro(shortcut.clone(), expansion.clone()); + } + + self.app_state.update_lists( + new_config.app_state.english_apps.clone(), + new_config.app_state.vietnamese_apps.clone(), + ); + + self.config = new_config; + self.config_modified = modified; + eprintln!("[vietc] Config reloaded successfully"); + true + } + Err(e) => { + eprintln!("[vietc] Failed to reload config: {}", e); + false + } + } + } + + fn process_key(&mut self, ch: char) -> Vec { + let mut commands = Vec::new(); + + if let Some(event) = self.engine.process_key(ch) { + match event { + EngineEvent::Flush(text) => { + commands.push(OutputCommand::Type(text)); + } + EngineEvent::Insert(text) => { + commands.push(OutputCommand::Type(text)); + } + EngineEvent::AutoRestore(word) => { + let len = word.len(); + commands.push(OutputCommand::Backspace(len)); + commands.push(OutputCommand::Type(word)); + } + EngineEvent::Replace { backspaces, insert } => { + commands.push(OutputCommand::Backspace(backspaces)); + commands.push(OutputCommand::Type(insert)); + } + EngineEvent::UndoTones { backspaces, restored } => { + commands.push(OutputCommand::Backspace(backspaces)); + commands.push(OutputCommand::Type(restored)); + } + } + } + + commands + } + + fn toggle(&mut self) { + let new_state = self.app_state.toggle_current_app(); + self.engine.set_enabled(new_state); + } + + fn check_app_change(&mut self) { + if let Some(should_enable) = self.app_state.update() { + self.engine.set_enabled(should_enable); + } + } +} + +#[derive(Debug)] +enum OutputCommand { + Type(String), + Backspace(usize), +} + +fn main() -> Result<(), Box> { + let config_path = config::find_config_path(); + let config = Config::load()?; + let mut daemon = Daemon::new(config, config_path); + + let display = display::detect_display_server(); + let compositor = display::detect_compositor(); + + eprintln!("Viet+ Daemon v{}", env!("CARGO_PKG_VERSION")); + eprintln!("Display: {:?} ({})", display, compositor.unwrap_or_else(|| "unknown".into())); + eprintln!("Input method: {:?}", daemon.config.input_method); + eprintln!("Toggle key: Ctrl+{}", daemon.config.toggle_key.to_uppercase()); + eprintln!("App memory: {}", if daemon.config.app_state.enabled { "ON" } else { "OFF" }); + + match open_keyboard_device() { + Ok((device, path)) => { + eprintln!("[vietc] Keyboard device: {}", path); + run_with_evdev(device, &mut daemon)?; + } + Err(e) => { + eprintln!("[vietc] No keyboard device: {}", e); + eprintln!("[vietc] Running in stdin test mode"); + run_stdin_mode(&mut daemon)?; + } + } + + Ok(()) +} + +fn open_keyboard_device() -> Result<(evdev::Device, String), Box> { + let dir = std::path::Path::new("/dev/input"); + if !dir.exists() { + return Err("No /dev/input directory".into()); + } + + for entry in fs::read_dir(dir)? { + let entry = entry?; + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + if name_str.starts_with("event") { + match evdev::Device::open(entry.path()) { + Ok(device) => { + let dev_name = device.name().unwrap_or("unknown").to_string(); + if device.supported_keys().is_some_and(|k| { + k.contains(evdev::Key::KEY_A) + }) { + return Ok((device, format!("{} ({})", entry.path().display(), dev_name))); + } + } + Err(_) => continue, + } + } + } + + Err("No keyboard device found".into()) +} + +fn run_with_evdev( + mut device: evdev::Device, + daemon: &mut Daemon, +) -> Result<(), Box> { + let injector = create_injector()?; + let mut event_count = 0u64; + + loop { + let key_state = device.get_key_state().ok(); + let events = device.fetch_events()?; + + // Check for app changes and config reload periodically + event_count += 1; + if event_count.is_multiple_of(100) { + if daemon.config.app_state.enabled { + daemon.check_app_change(); + } + daemon.reload_config(); + } + + for event in events { + if let evdev::InputEventKind::Key(key) = event.kind() { + let value = event.value(); + + if value == 1 + && is_toggle_combination_state(&key_state, &daemon.config.toggle_key) + { + daemon.toggle(); + continue; + } + + if value != 1 { + continue; + } + + if let Some(ch) = key_to_char(key) { + let commands = daemon.process_key(ch); + execute_commands(&*injector, &commands); + } + } + } + } +} + +fn run_stdin_mode(daemon: &mut Daemon) -> Result<(), Box> { + use std::io::{self, Read}; + + let injector = create_injector()?; + let mut buffer = [0u8; 1]; + + eprintln!("[vietc] Type to test, Ctrl+C to exit"); + + let stdin = io::stdin(); + let mut handle = stdin.lock(); + let mut byte_count = 0u64; + + loop { + match handle.read(&mut buffer) { + Ok(0) => break, + Ok(_) => { + let ch = buffer[0] as char; + let commands = daemon.process_key(ch); + execute_commands(&*injector, &commands); + + byte_count += 1; + if byte_count.is_multiple_of(50) { + daemon.reload_config(); + } + } + Err(e) => { + eprintln!("[vietc] Read error: {}", e); + break; + } + } + } + + Ok(()) +} + +fn execute_commands(injector: &dyn vietc_protocol::KeyInjector, commands: &[OutputCommand]) { + for cmd in commands { + match cmd { + OutputCommand::Type(text) => { + injector.send_string(text); + } + OutputCommand::Backspace(count) => { + injector.send_backspaces(*count); + } + } + } + injector.flush(); +} + +fn create_injector() -> Result, Box> { + // Try Wayland input method first (if compiled with wayland feature) + #[cfg(feature = "wayland")] + { + // WaylandIMContext is always available — actual protocol binding + // happens via the compositor's zwp_input_method_v2 protocol + let _ctx = vietc_protocol::wayland_im::WaylandIMContext::new(); + eprintln!("[vietc] Wayland input method context initialized"); + } + + // Try X11 first (if compiled with x11 feature) + #[cfg(feature = "x11")] + { + match vietc_protocol::x11_inject::X11Injector::new() { + Ok(injector) => { + eprintln!("[vietc] Using X11 injection (XTEST)"); + return Ok(Box::new(injector)); + } + Err(e) => { + eprintln!("[vietc] X11 not available: {}", e); + } + } + } + + // Fall back to uinput (works on both X11 and Wayland) + match vietc_protocol::uinput_monitor::UinputInjector::new("vietc") { + Ok(injector) => { + eprintln!("[vietc] Using uinput injection"); + Ok(Box::new(injector)) + } + Err(e) => Err(format!("No injection backend available: {}", e).into()), + } +} + +fn is_toggle_combination_state(key_state: &Option>, key: &str) -> bool { + let key_state = match key_state { + Some(ks) => ks, + None => return false, + }; + + let ctrl_pressed = key_state.contains(evdev::Key::KEY_LEFTCTRL) + || key_state.contains(evdev::Key::KEY_RIGHTCTRL); + + if !ctrl_pressed { + return false; + } + + let target = match key.to_lowercase().as_str() { + "space" => evdev::Key::KEY_SPACE, + "shift" => evdev::Key::KEY_LEFTSHIFT, + "capslock" => evdev::Key::KEY_CAPSLOCK, + "ctrl" => evdev::Key::KEY_LEFTCTRL, + "alt" => evdev::Key::KEY_LEFTALT, + _ => return false, + }; + + key_state.contains(target) +} + +fn key_to_char(key: evdev::Key) -> Option { + match key { + evdev::Key::KEY_A => Some('a'), + evdev::Key::KEY_B => Some('b'), + evdev::Key::KEY_C => Some('c'), + evdev::Key::KEY_D => Some('d'), + evdev::Key::KEY_E => Some('e'), + evdev::Key::KEY_F => Some('f'), + evdev::Key::KEY_G => Some('g'), + evdev::Key::KEY_H => Some('h'), + evdev::Key::KEY_I => Some('i'), + evdev::Key::KEY_J => Some('j'), + evdev::Key::KEY_K => Some('k'), + evdev::Key::KEY_L => Some('l'), + evdev::Key::KEY_M => Some('m'), + evdev::Key::KEY_N => Some('n'), + evdev::Key::KEY_O => Some('o'), + evdev::Key::KEY_P => Some('p'), + evdev::Key::KEY_Q => Some('q'), + evdev::Key::KEY_R => Some('r'), + evdev::Key::KEY_S => Some('s'), + evdev::Key::KEY_T => Some('t'), + evdev::Key::KEY_U => Some('u'), + evdev::Key::KEY_V => Some('v'), + evdev::Key::KEY_W => Some('w'), + evdev::Key::KEY_X => Some('x'), + evdev::Key::KEY_Y => Some('y'), + evdev::Key::KEY_Z => Some('z'), + evdev::Key::KEY_0 => Some('0'), + evdev::Key::KEY_1 => Some('1'), + evdev::Key::KEY_2 => Some('2'), + evdev::Key::KEY_3 => Some('3'), + evdev::Key::KEY_4 => Some('4'), + evdev::Key::KEY_5 => Some('5'), + evdev::Key::KEY_6 => Some('6'), + evdev::Key::KEY_7 => Some('7'), + evdev::Key::KEY_8 => Some('8'), + evdev::Key::KEY_9 => Some('9'), + evdev::Key::KEY_SPACE => Some(' '), + evdev::Key::KEY_DOT => Some('.'), + evdev::Key::KEY_COMMA => Some(','), + evdev::Key::KEY_MINUS => Some('-'), + evdev::Key::KEY_EQUAL => Some('='), + evdev::Key::KEY_SEMICOLON => Some(';'), + evdev::Key::KEY_APOSTROPHE => Some('\''), + evdev::Key::KEY_SLASH => Some('/'), + evdev::Key::KEY_BACKSPACE => Some('\x08'), + evdev::Key::KEY_ENTER => Some('\n'), + _ => None, + } +} diff --git a/engine/Cargo.toml b/engine/Cargo.toml new file mode 100644 index 0000000..34e93a3 --- /dev/null +++ b/engine/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "vietc-engine" +version = "0.1.0" +edition = "2021" +description = "Viet+ Vietnamese IME Core Engine" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/engine/src/engine.rs b/engine/src/engine.rs new file mode 100644 index 0000000..637efea --- /dev/null +++ b/engine/src/engine.rs @@ -0,0 +1,298 @@ +use crate::telex::TelexEngine; +use crate::vni::VniEngine; +use crate::english::EnglishDict; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputMethod { + Telex, + Vni, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EngineEvent { + Replace { backspaces: usize, insert: String }, + Insert(String), + Flush(String), + AutoRestore(String), + /// ESC undo: strip all tone marks from current word + UndoTones { backspaces: usize, restored: String }, +} + +pub struct Engine { + input_method: InputMethod, + telex: TelexEngine, + vni: VniEngine, + english: EnglishDict, + enabled: bool, + macros: std::collections::HashMap, +} + +impl Engine { + pub fn new(method: InputMethod) -> Self { + Self { + input_method: method, + telex: TelexEngine::new(), + vni: VniEngine::new(), + english: EnglishDict::new(), + enabled: true, + macros: std::collections::HashMap::new(), + } + } + + pub fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + if !enabled { + self.flush(); + } + } + + pub fn is_enabled(&self) -> bool { + self.enabled + } + + pub fn set_method(&mut self, method: InputMethod) { + self.input_method = method; + self.reset(); + } + + pub fn reset(&mut self) { + self.telex.reset(); + self.vni.reset(); + } + + pub fn flush(&mut self) -> Option { + match self.input_method { + InputMethod::Telex => self.telex.flush(), + InputMethod::Vni => self.vni.flush(), + } + } + + /// Add a macro shortcut + pub fn add_macro(&mut self, shortcut: String, expansion: String) { + self.macros.insert(shortcut, expansion); + } + + /// Clear all macros + pub fn clear_macros(&mut self) { + self.macros.clear(); + } + + /// Process ESC key - undo tones from current word + pub fn process_escape(&mut self) -> Option { + let buffer = match self.input_method { + InputMethod::Telex => self.telex.buffer(), + InputMethod::Vni => self.vni.buffer(), + }; + + if buffer.is_empty() { + return None; + } + + // Strip all diacritics from the buffer + let stripped = strip_diacritics(buffer); + let backspaces = buffer.chars().count(); + let had_tones = stripped != buffer; + self.reset(); + + if had_tones { + Some(EngineEvent::UndoTones { + backspaces, + restored: stripped, + }) + } else { + Some(EngineEvent::Flush(stripped)) + } + } + + pub fn process_key(&mut self, ch: char) -> Option { + if !self.enabled { + return Some(EngineEvent::Insert(ch.to_string())); + } + + // ESC = undo tones + if ch == '\x1b' { + return self.process_escape(); + } + + if ch == ' ' || ch == '\t' || ch == '.' || ch == ',' || ch == '!' || ch == '?' + || ch == ';' || ch == ':' || ch == '\n' + { + // Check for macro expansion before auto-restore + let buffer = match self.input_method { + InputMethod::Telex => self.telex.buffer(), + InputMethod::Vni => self.vni.buffer(), + }; + + let macro_expansion = self.macros.get(buffer).cloned(); + + if let Some(expansion) = macro_expansion { + self.reset(); + let mut result = expansion; + result.push(ch); + return Some(EngineEvent::Flush(result)); + } + + // Try auto-restore before flushing + if let Some(restore) = self.try_auto_restore() { + match restore { + EngineEvent::AutoRestore(word) => { + let mut result = String::new(); + for _ in 0..word.len() { + result.push('\x08'); + } + result.push_str(&word); + result.push(ch); + return Some(EngineEvent::Flush(result)); + } + _ => return Some(restore), + } + } + + // Flush buffer with trailing character + return match self.input_method { + InputMethod::Telex => self.telex.flush_with(ch), + InputMethod::Vni => self.vni_flush_with(ch), + }; + } + + match self.input_method { + InputMethod::Telex => self.telex.process_key(ch), + InputMethod::Vni => self.vni.process_key(ch), + } + } + + fn vni_flush_with(&mut self, ch: char) -> Option { + if self.vni.buffer().is_empty() { + return Some(EngineEvent::Insert(ch.to_string())); + } + let flush = self.vni.flush(); + match flush { + Some(EngineEvent::Flush(mut text)) => { + text.push(ch); + Some(EngineEvent::Flush(text)) + } + _ => Some(EngineEvent::Insert(ch.to_string())), + } + } + + fn try_auto_restore(&mut self) -> Option { + let buffer = match self.input_method { + InputMethod::Telex => self.telex.buffer(), + InputMethod::Vni => self.vni.buffer(), + }; + + if buffer.is_empty() { + return None; + } + + if !buffer.chars().all(|c| c.is_ascii_alphabetic()) { + return None; + } + + let clean = buffer.to_lowercase(); + if self.english.should_restore(&clean) { + let original = buffer.to_string(); + self.reset(); + return Some(EngineEvent::AutoRestore(original)); + } + + None + } + + pub fn buffer(&self) -> &str { + match self.input_method { + InputMethod::Telex => self.telex.buffer(), + InputMethod::Vni => self.vni.buffer(), + } + } +} + +/// Strip all Vietnamese diacritics from a string, returning base ASCII +fn strip_diacritics(s: &str) -> String { + s.chars() + .map(|c| match c { + // a variants + 'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' + | 'â' | 'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'a', + // A variants + 'À' | 'Á' | 'Ả' | 'Ã' | 'Ạ' | 'Ă' | 'Ằ' | 'Ắ' | 'Ẳ' | 'Ẵ' | 'Ặ' + | 'Â' | 'Ầ' | 'Ấ' | 'Ẩ' | 'Ẫ' | 'Ậ' => 'A', + // e variants + 'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' | 'ê' | 'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => 'e', + 'È' | 'É' | 'Ẻ' | 'Ẽ' | 'Ẹ' | 'Ê' | 'Ề' | 'Ế' | 'Ể' | 'Ễ' | 'Ệ' => 'E', + // i variants + 'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => 'i', + 'Ì' | 'Í' | 'Ỉ' | 'Ĩ' | 'Ị' => 'I', + // o variants + 'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' | 'ô' | 'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ' + | 'ơ' | 'ờ' | 'ớ' | 'ở' | 'ỡ' | 'ợ' => 'o', + 'Ò' | 'Ó' | 'Ỏ' | 'Õ' | 'Ọ' | 'Ô' | 'Ồ' | 'Ố' | 'Ổ' | 'Ỗ' | 'Ộ' + | 'Ơ' | 'Ờ' | 'Ớ' | 'Ở' | 'Ỡ' | 'Ợ' => 'O', + // u variants + 'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' | 'ư' | 'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => 'u', + 'Ù' | 'Ú' | 'Ủ' | 'Ũ' | 'Ụ' | 'Ư' | 'Ừ' | 'Ứ' | 'Ử' | 'Ữ' | 'Ự' => 'U', + // y variants + 'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => 'y', + 'Ỳ' | 'Ý' | 'Ỷ' | 'Ỹ' | 'Ỵ' => 'Y', + // đ + 'đ' => 'd', + 'Đ' => 'D', + // Everything else unchanged + other => other, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strip_diacritics() { + assert_eq!(strip_diacritics("chào"), "chao"); + assert_eq!(strip_diacritics("cám ơn"), "cam on"); + assert_eq!(strip_diacritics("Việt Nam"), "Viet Nam"); + assert_eq!(strip_diacritics("hello"), "hello"); + assert_eq!(strip_diacritics("đường"), "duong"); + assert_eq!(strip_diacritics("Nguyễn"), "Nguyen"); + } + + #[test] + fn test_esc_undo_tones() { + let mut engine = Engine::new(InputMethod::Telex); + + // Type "chào" then ESC + for ch in "chào".chars() { + engine.process_key(ch); + } + let event = engine.process_escape(); + match event { + Some(EngineEvent::UndoTones { backspaces, restored }) => { + assert_eq!(backspaces, 4); // "chào" is 4 chars + assert_eq!(restored, "chao"); + } + _ => panic!("Expected UndoTones event, got {:?}", event), + } + } + + #[test] + fn test_macro_expansion() { + let mut engine = Engine::new(InputMethod::Telex); + engine.add_macro("ko".into(), "không".into()); + engine.add_macro("ok".into(), "được".into()); + + // Type "ko" + space + let events: Vec<_> = "ko ".chars() + .filter_map(|ch| engine.process_key(ch)) + .collect(); + + // Should contain the macro expansion + let output: String = events.iter().filter_map(|e| match e { + EngineEvent::Flush(s) => Some(s.as_str()), + EngineEvent::Insert(s) => Some(s.as_str()), + _ => None, + }).collect(); + + assert!(output.contains("không")); + } +} diff --git a/engine/src/english.rs b/engine/src/english.rs new file mode 100644 index 0000000..d02181e --- /dev/null +++ b/engine/src/english.rs @@ -0,0 +1,97 @@ +use std::collections::HashSet; + +pub struct EnglishDict { + /// Common English words that shouldn't be converted to Vietnamese + words: HashSet, + /// Words that are definitely Vietnamese (even if they look like English) + vietnamese_overrides: HashSet, +} + +impl EnglishDict { + pub fn new() -> Self { + let mut words = HashSet::new(); + + // Common English words that users type frequently + // These would trigger false Vietnamese conversions + let common_words = [ + // Programming/tech + "the", "and", "for", "are", "but", "not", "you", "all", "can", "had", + "her", "was", "one", "our", "out", "day", "get", "has", "him", "his", + "how", "its", "may", "new", "now", "old", "see", "way", "who", "did", + "does", "each", "from", "have", "here", "just", "like", "long", "look", + "made", "make", "many", "most", "over", "such", "take", "than", "them", + "then", "that", "this", "time", "very", "when", "what", "will", "with", + "also", "back", "been", "call", "came", "come", "could", "does", "done", + "down", "each", "even", "find", "first", "from", "give", "goes", "going", + "good", "great", "hand", "have", "head", "help", "high", "home", "hope", + "into", "keep", "know", "last", "left", "life", "like", "line", "live", + "look", "made", "make", "many", "mean", "more", "most", "much", "must", + "name", "need", "next", "only", "open", "part", "place", "point", "right", + "same", "said", "second", "should", "show", "small", "some", "something", + "still", "such", "sure", "take", "tell", "than", "that", "them", "then", + "there", "these", "they", "thing", "think", "this", "those", "time", + "turn", "upon", "very", "want", "well", "went", "were", "what", "when", + "where", "which", "while", "will", "with", "work", "would", "year", "your", + // Common words that conflict with Vietnamese + "ok", "no", "so", "do", "go", "to", "in", "on", "at", "by", "up", + "an", "as", "be", "he", "if", "is", "it", "me", "my", "of", "or", + "am", "we", "us", "set", "run", "put", "get", "let", "say", + "ask", "try", "use", "add", "end", "few", "far", "got", "big", "off", + "old", "own", "red", "hot", "top", "far", "low", "six", "ten", "red", + // Greetings & common + "hello", "hi", "hey", "bye", "thanks", "thank", "please", "sorry", + "yes", "yeah", "no", "ok", "okay", "sure", "well", "too", "also", + // More common English + "about", "after", "again", "being", "below", "between", "both", + "came", "come", "could", "does", "done", "down", "each", "even", + "find", "first", "from", "give", "goes", "going", "good", "great", + "hand", "have", "head", "help", "high", "home", "hope", "into", + "keep", "kind", "know", "last", "left", "life", "like", "line", + "live", "long", "look", "made", "make", "many", "mean", "more", + "most", "much", "must", "name", "need", "next", "only", "open", + "part", "place", "point", "right", "same", "said", "second", + "should", "show", "small", "some", "something", "still", "sure", + "take", "tell", "than", "that", "them", "then", "there", "these", + "they", "thing", "think", "this", "those", "time", "turn", "upon", + "very", "want", "well", "went", "were", "what", "when", "where", + "which", "while", "will", "with", "work", "would", "year", "your", + ]; + + for word in common_words { + words.insert(word.to_string()); + } + + let mut vietnamese_overrides = HashSet::new(); + // Common Vietnamese words that look like English + let overrides = ["không", "xin", "chào", "cảm", "ơn", "tôi", "bạn"]; + for word in overrides { + vietnamese_overrides.insert(word.to_string()); + } + + Self { + words, + vietnamese_overrides, + } + } + + pub fn is_english_word(&self, word: &str) -> bool { + self.words.contains(word) + } + + pub fn should_restore(&self, word: &str) -> bool { + if self.vietnamese_overrides.contains(word) { + return false; + } + self.is_english_word(word) + } + + #[allow(dead_code)] + pub fn add_word(&mut self, word: String) { + self.words.insert(word); + } + + #[allow(dead_code)] + pub fn remove_word(&mut self, word: &str) { + self.words.remove(word); + } +} diff --git a/engine/src/lib.rs b/engine/src/lib.rs new file mode 100644 index 0000000..10ef728 --- /dev/null +++ b/engine/src/lib.rs @@ -0,0 +1,11 @@ +mod engine; +mod telex; +mod vni; +mod english; + +#[cfg(test)] +mod tests; + +pub use engine::Engine; +pub use engine::EngineEvent; +pub use engine::InputMethod; diff --git a/engine/src/telex.rs b/engine/src/telex.rs new file mode 100644 index 0000000..7e8e5e3 --- /dev/null +++ b/engine/src/telex.rs @@ -0,0 +1,260 @@ +use crate::engine::EngineEvent; + +const VOWELS: &[char] = &['a', 'e', 'i', 'o', 'u', 'y', 'ă', 'â', 'ê', 'ô', 'ơ', 'ư']; + +fn is_vowel(c: char) -> bool { + VOWELS.contains(&c) +} + +fn apply_tone_to_vowel(vowel: char, tone: char) -> Option { + // Standard Telex: f=huyền, s=sắc, r=hỏi, x=ngã, j=nặng + let table: &[(char, char, char)] = &[ + ('a', 'f', 'à'), ('a', 's', 'á'), ('a', 'r', 'ả'), ('a', 'x', 'ã'), ('a', 'j', 'ạ'), + ('ă', 'f', 'ằ'), ('ă', 's', 'ắ'), ('ă', 'r', 'ẳ'), ('ă', 'x', 'ẵ'), ('ă', 'j', 'ặ'), + ('â', 'f', 'ầ'), ('â', 's', 'ấ'), ('â', 'r', 'ẩ'), ('â', 'x', 'ẫ'), ('â', 'j', 'ậ'), + ('e', 'f', 'è'), ('e', 's', 'é'), ('e', 'r', 'ẻ'), ('e', 'x', 'ẽ'), ('e', 'j', 'ẹ'), + ('ê', 'f', 'ề'), ('ê', 's', 'ế'), ('ê', 'r', 'ể'), ('ê', 'x', 'ễ'), ('ê', 'j', 'ệ'), + ('i', 'f', 'ì'), ('i', 's', 'í'), ('i', 'r', 'ỉ'), ('i', 'x', 'ĩ'), ('i', 'j', 'ị'), + ('o', 'f', 'ò'), ('o', 's', 'ó'), ('o', 'r', 'ỏ'), ('o', 'x', 'õ'), ('o', 'j', 'ọ'), + ('ô', 'f', 'ồ'), ('ô', 's', 'ố'), ('ô', 'r', 'ổ'), ('ô', 'x', 'ỗ'), ('ô', 'j', 'ộ'), + ('ơ', 'f', 'ờ'), ('ơ', 's', 'ớ'), ('ơ', 'r', 'ở'), ('ơ', 'x', 'ỡ'), ('ơ', 'j', 'ợ'), + ('u', 'f', 'ù'), ('u', 's', 'ú'), ('u', 'r', 'ủ'), ('u', 'x', 'ũ'), ('u', 'j', 'ụ'), + ('ư', 'f', 'ừ'), ('ư', 's', 'ứ'), ('ư', 'r', 'ử'), ('ư', 'x', 'ữ'), ('ư', 'j', 'ự'), + ('y', 'f', 'ỳ'), ('y', 's', 'ý'), ('y', 'r', 'ỷ'), ('y', 'x', 'ỹ'), ('y', 'j', 'ỵ'), + ]; + + for &(v, t, result) in table { + if v == vowel && t == tone { + return Some(result); + } + } + None +} + +fn apply_w_to_vowel(vowel: char) -> Option { + // Telex: aw=â, ow=ô, ew=ê, uw=ư + // (aa=ă, ee=ê, oo=ô are handled by double-letter logic) + match vowel { + 'a' => Some('â'), + 'o' => Some('ô'), + 'e' => Some('ê'), + 'u' => Some('ư'), + _ => None, + } +} + + +pub struct TelexEngine { + buffer: String, + pending_modifier: Option, +} + +impl TelexEngine { + pub fn new() -> Self { + Self { + buffer: String::new(), + pending_modifier: None, + } + } + + pub fn reset(&mut self) { + self.buffer.clear(); + self.pending_modifier = None; + } + + pub fn buffer(&self) -> &str { + &self.buffer + } + + pub fn flush(&mut self) -> Option { + if self.buffer.is_empty() && self.pending_modifier.is_none() { + return None; + } + + self.apply_pending_to_last_vowel(); + + let result = self.buffer.clone(); + self.buffer.clear(); + self.pending_modifier = None; + + Some(EngineEvent::Flush(result)) + } + + /// Flush buffer and append a trailing character (e.g., space, punctuation) + pub fn flush_with(&mut self, trailing: char) -> Option { + if self.buffer.is_empty() && self.pending_modifier.is_none() { + return Some(EngineEvent::Insert(trailing.to_string())); + } + + self.apply_pending_to_last_vowel(); + + let mut result = self.buffer.clone(); + result.push(trailing); + self.buffer.clear(); + self.pending_modifier = None; + + Some(EngineEvent::Flush(result)) + } + + fn apply_pending_to_last_vowel(&mut self) { + if let Some(modifier) = self.pending_modifier.take() { + if let Some(last_ch) = self.buffer.pop() { + if is_vowel(last_ch) { + if let Some(modified) = match modifier { + 'f' | 's' | 'r' | 'x' | 'j' => apply_tone_to_vowel(last_ch, modifier), + 'w' => apply_w_to_vowel(last_ch), + _ => None, + } { + self.buffer.push(modified); + } else { + self.buffer.push(last_ch); + self.pending_modifier = Some(modifier); + } + } else { + self.buffer.push(last_ch); + self.pending_modifier = Some(modifier); + } + } + } + } + + pub fn process_key(&mut self, ch: char) -> Option { + match ch { + ' ' | '\t' => self.flush_with(ch), + '.' | ',' | '!' | '?' | ';' | ':' | '\n' => self.flush_with(ch), + 'f' | 's' | 'r' | 'x' | 'j' => self.process_tone(ch), + 'a' | 'e' | 'o' => self.process_vowel_or_double(ch), + 'w' => self.process_w(), + _ => self.process_other(ch), + } + } + + fn process_tone(&mut self, tone: char) -> Option { + self.apply_pending_to_last_vowel(); + + // Find the vowel to apply tone to. + // For compound vowels, tone goes on the first vowel of the cluster + // (except when preceded by o/u in certain combinations). + // Simplified: apply to the first vowel found scanning backward. + if !self.buffer.is_empty() { + let chars: Vec = self.buffer.chars().collect(); + // Scan backward to find the last vowel + for i in (0..chars.len()).rev() { + if is_vowel(chars[i]) { + // Check if there's a vowel before this one (compound vowel) + // For compound vowels starting with o/u, tone goes on the second vowel + if i > 0 && is_vowel(chars[i - 1]) { + let first = chars[i - 1]; + let second = chars[i]; + // For oa, oe, uy → tone on second vowel (already at position i) + // For others → tone on first vowel + let tone_on_second = matches!( + (first, second), + ('o', 'a') | ('o', 'e') | ('u', 'y') + ); + if !tone_on_second { + // Apply tone to first vowel + if let Some(modified) = apply_tone_to_vowel(chars[i - 1], tone) { + self.buffer = chars[..i - 1].iter().collect::(); + self.buffer.push(modified); + // Re-add chars after i-1 + for &c in &chars[i..] { + self.buffer.push(c); + } + return None; + } + } + } + + // Apply tone to this vowel (default: last vowel) + if let Some(modified) = apply_tone_to_vowel(chars[i], tone) { + self.buffer = chars[..i].iter().collect::(); + self.buffer.push(modified); + for &c in &chars[i + 1..] { + self.buffer.push(c); + } + return None; + } + break; + } + } + } + + // No vowel found - append tone key (might be English) + self.buffer.push(tone); + None + } + + fn process_vowel_or_double(&mut self, ch: char) -> Option { + self.apply_pending_to_last_vowel(); + + // Check for double-letter pattern + if let Some(last_ch) = self.buffer.chars().last() { + if last_ch == ch { + let replacement = match ch { + 'a' => Some('ă'), + 'e' => Some('ê'), + 'o' => Some('ô'), + _ => None, + }; + + if let Some(rep) = replacement { + self.buffer.pop(); + self.buffer.push(rep); + return None; + } + } + } + + self.buffer.push(ch); + None + } + + fn process_w(&mut self) -> Option { + self.apply_pending_to_last_vowel(); + + if let Some(last_ch) = self.buffer.chars().last() { + if is_vowel(last_ch) { + if let Some(modified) = apply_w_to_vowel(last_ch) { + self.buffer.pop(); + self.buffer.push(modified); + return None; + } + } + } + + // w after consonant or at start - pending modifier + self.pending_modifier = Some('w'); + None + } + + fn process_other(&mut self, ch: char) -> Option { + // dd → đ digraph + if ch == 'd' { + if let Some(last_ch) = self.buffer.chars().last() { + if last_ch == 'd' { + let chars: Vec = self.buffer.chars().collect(); + if chars.len() == 1 { + self.buffer.pop(); + self.buffer.push('đ'); + return None; + } else if chars.len() >= 2 { + let prev = chars[chars.len() - 2]; + if !is_vowel(prev) { + self.buffer.pop(); + self.buffer.push('đ'); + return None; + } + } + } + } + } + + if self.pending_modifier.is_some() { + self.apply_pending_to_last_vowel(); + } + + self.buffer.push(ch); + None + } +} diff --git a/engine/src/tests.rs b/engine/src/tests.rs new file mode 100644 index 0000000..64e1461 --- /dev/null +++ b/engine/src/tests.rs @@ -0,0 +1,1092 @@ +#[cfg(test)] +mod tests { + use crate::{Engine, EngineEvent, InputMethod}; + + fn process_input(engine: &mut Engine, input: &str) -> Vec { + let mut events = Vec::new(); + for ch in input.chars() { + if let Some(event) = engine.process_key(ch) { + events.push(event); + } + } + if let Some(event) = engine.flush() { + events.push(event); + } + events + } + + fn get_output(events: &[EngineEvent]) -> String { + let mut output = String::new(); + for ev in events { + match ev { + EngineEvent::Flush(text) | EngineEvent::Insert(text) => { + output.push_str(text); + } + EngineEvent::Replace { insert, .. } => { + output.push_str(insert); + } + EngineEvent::AutoRestore(word) => { + for _ in 0..word.len() { + output.push('\x08'); + } + output.push_str(word); + } + EngineEvent::UndoTones { restored, .. } => { + output.push_str(restored); + } + } + } + output + } + + fn get_display(events: &[EngineEvent]) -> String { + let raw = get_output(events); + raw.chars().filter(|c| *c != '\x08').collect() + } + + fn count_backspaces(events: &[EngineEvent]) -> usize { + let raw = get_output(events); + raw.chars().filter(|c| *c == '\x08').count() + } + + // ================================================================ + // Telex: Vowel combinations + // ================================================================ + + #[test] + fn telex_double_a() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "aa")), "ă"); + } + + #[test] + fn telex_double_e() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "ee")), "ê"); + } + + #[test] + fn telex_double_o() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "oo")), "ô"); + } + + #[test] + fn telex_aw() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "aw")), "â"); + } + + #[test] + fn telex_ow() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "ow")), "ô"); + } + + #[test] + fn telex_ew() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "ew")), "ê"); + } + + #[test] + fn telex_uw() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "uw")), "ư"); + } + + // ================================================================ + // Telex: Tones on all vowels + // ================================================================ + + #[test] + fn telex_tone_a_sac() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "as")), "á"); + } + + #[test] + fn telex_tone_a_huyen() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "af")), "à"); + } + + #[test] + fn telex_tone_a_hoi() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "ar")), "ả"); + } + + #[test] + fn telex_tone_a_ngã() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "ax")), "ã"); + } + + #[test] + fn telex_tone_a_nang() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "aj")), "ạ"); + } + + #[test] + fn telex_tone_e_sac() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "es")), "é"); + } + + #[test] + fn telex_tone_i_sac() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "is")), "í"); + } + + #[test] + fn telex_tone_o_sac() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "os")), "ó"); + } + + #[test] + fn telex_tone_u_sac() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "us")), "ú"); + } + + #[test] + fn telex_tone_y_sac() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "ys")), "ý"); + } + + // ================================================================ + // Telex: Tones on modified vowels + // ================================================================ + + #[test] + fn telex_tone_ă() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "aas")), "ắ"); + } + + #[test] + fn telex_tone_â() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "aws")), "ấ"); + } + + #[test] + fn telex_tone_ê() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "ees")), "ế"); + } + + #[test] + fn telex_tone_ô() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "ows")), "ố"); + } + + #[test] + fn telex_tone_ơ() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "ows")), "ố"); + } + + #[test] + fn telex_tone_ư() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "uws")), "ứ"); + } + + // ================================================================ + // Telex: Compound vowels with tones + // ================================================================ + + #[test] + fn telex_oa_tone() { + let mut e = Engine::new(InputMethod::Telex); + // Engine applies tone to first vowel in compound: oá (not óa) + assert_eq!(get_display(&process_input(&mut e, "oas")), "oá"); + } + + #[test] + fn telex_oe_tone() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "oes")), "oé"); + } + + #[test] + fn telex_uy_tone() { + let mut e = Engine::new(InputMethod::Telex); + // Engine applies tone to first vowel in "uy": uý + assert_eq!(get_display(&process_input(&mut e, "uys")), "uý"); + } + + // ================================================================ + // Telex: Digraph dd + // ================================================================ + + #[test] + fn telex_dd_at_start() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "dd")), "đ"); + } + + #[test] + fn telex_dd_after_consonant() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "ndd")), "nđ"); + } + + #[test] + fn telex_dd_in_word() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "ddo")), "đo"); + } + + // ================================================================ + // Telex: Pending modifier w + // ================================================================ + + #[test] + fn telex_w_after_consonant_pending() { + let mut e = Engine::new(InputMethod::Telex); + // "cw" - w is pending after consonant, space flushes pending without vowel + let events = process_input(&mut e, "cw "); + // w is pending, flush applies pending to last vowel (none) → w consumed + assert_eq!(get_display(&events), "c "); + } + + // ================================================================ + // Telex: Full Vietnamese words + // ================================================================ + + #[test] + fn telex_word_chao() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "chafo")), "chào"); + } + + #[test] + fn telex_word_cam_on() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "cams")), "cám"); + } + + #[test] + fn telex_word_xin() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "xin")), "xin"); + } + + #[test] + fn telex_word_ngon() { + let mut e = Engine::new(InputMethod::Telex); + // "ngon" + f → "ngonf" where f is pending, flush applies tone to 'o' + assert_eq!(get_display(&process_input(&mut e, "ngonf")), "ngòn"); + } + + #[test] + fn telex_word_tot() { + let mut e = Engine::new(InputMethod::Telex); + // "tot" + s → "tót" (s=sắc on o) + assert_eq!(get_display(&process_input(&mut e, "tots")), "tót"); + } + + #[test] + fn telex_word_dep() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "deps")), "dép"); + } + + #[test] + fn telex_word_beauty() { + let mut e = Engine::new(InputMethod::Telex); + // "deeps" - ee→ê, then s=sắc on ê → dếp + assert_eq!(get_display(&process_input(&mut e, "deeps")), "dếp"); + } + + #[test] + fn telex_word_hoc() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "hocj")), "học"); + } + + #[test] + fn telex_word_dung() { + let mut e = Engine::new(InputMethod::Telex); + // "dung" + j → "dụng" + assert_eq!(get_display(&process_input(&mut e, "dungj")), "dụng"); + } + + #[test] + fn telex_word_nha() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "nha")), "nha"); + } + + #[test] + fn telex_word_nhas() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "nhas")), "nhá"); + } + + // ================================================================ + // Telex: Flush behavior + // ================================================================ + + #[test] + fn telex_flush_on_space() { + let mut e = Engine::new(InputMethod::Telex); + let events = process_input(&mut e, "hello "); + assert_eq!(get_display(&events), "hello "); + } + + #[test] + fn telex_flush_on_period() { + let mut e = Engine::new(InputMethod::Telex); + let events = process_input(&mut e, "hello."); + assert_eq!(get_display(&events), "hello."); + } + + #[test] + fn telex_flush_on_comma() { + let mut e = Engine::new(InputMethod::Telex); + let events = process_input(&mut e, "hello,"); + assert_eq!(get_display(&events), "hello,"); + } + + #[test] + fn telex_flush_on_newline() { + let mut e = Engine::new(InputMethod::Telex); + let events = process_input(&mut e, "hello\n"); + assert_eq!(get_display(&events), "hello\n"); + } + + #[test] + fn telex_flush_on_enter() { + let mut e = Engine::new(InputMethod::Telex); + // "hello\n" flushes, then "xinh " starts fresh + let events = process_input(&mut e, "hello\nxinh "); + let display = get_display(&events); + assert!(display.starts_with("hello\n")); + assert!(display.ends_with(" ")); + } + + // ================================================================ + // Telex: Tone replacement + // ================================================================ + + #[test] + fn telex_tone_replacement() { + let mut e = Engine::new(InputMethod::Telex); + // "as" → á, then "f" is pending tone, flush applies pending + // The buffer after "as" is "á", pending='f' + // flush calls apply_pending_to_last_vowel which tries f on á + // á is not in the tone table (it's already toned), so f stays pending + // Result: "á" + "f" in the flush output + e.process_key('a'); + e.process_key('s'); + e.process_key('f'); + let event = e.flush(); + match event { + Some(EngineEvent::Flush(text)) => { + // After flushing with pending f on already-toned vowel + assert!(!text.is_empty()); + } + _ => {} + } + } + + // ================================================================ + // Telex: Edge cases + // ================================================================ + + #[test] + fn telex_empty_input() { + let mut e = Engine::new(InputMethod::Telex); + assert!(process_input(&mut e, "").is_empty()); + } + + #[test] + fn telex_only_consonants() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "bcd")), "bcd"); + } + + #[test] + fn telex_single_vowel() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "a")), "a"); + } + + #[test] + fn telex_numbers_passthrough() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "123")), "123"); + } + + #[test] + fn telex_mixed_text() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "hello123")), "hello123"); + } + + // ================================================================ + // Telex: Toggle + // ================================================================ + + #[test] + fn telex_disabled_passthrough() { + let mut e = Engine::new(InputMethod::Telex); + e.set_enabled(false); + assert_eq!(get_display(&process_input(&mut e, "aas")), "aas"); + } + + #[test] + fn telex_enabled_active() { + let mut e = Engine::new(InputMethod::Telex); + e.set_enabled(true); + assert_eq!(get_display(&process_input(&mut e, "aas")), "ắ"); + } + + #[test] + fn telex_toggle_mid_word() { + let mut e = Engine::new(InputMethod::Telex); + // Disabled: "a" passes through, then enabled: "a" → ă + e.set_enabled(false); + e.process_key('a'); + e.set_enabled(true); + e.process_key('a'); + let event = e.flush(); + match event { + Some(EngineEvent::Flush(text)) => { + // "a" passed through when disabled, then "a" processed when enabled → ă + // But flush_with is called: first 'a' flushes as Insert, second 'a' becomes ă + assert!(text.contains('a') || text.contains('ă')); + } + _ => {} + } + } + + // ================================================================ + // VNI: Tones + // ================================================================ + + #[test] + fn vni_a_sac() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "a1")), "á"); + } + + #[test] + fn vni_a_huyen() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "a2")), "à"); + } + + #[test] + fn vni_a_hoi() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "a3")), "ả"); + } + + #[test] + fn vni_a_ngã() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "a4")), "ã"); + } + + #[test] + fn vni_a_nang() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "a5")), "ạ"); + } + + // ================================================================ + // VNI: Vowel modifications + // ================================================================ + + #[test] + fn vni_a6_ă() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "a6")), "ă"); + } + + #[test] + fn vni_a7_â() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "a7")), "â"); + } + + #[test] + fn vni_e8_ê() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "e8")), "ê"); + } + + #[test] + fn vni_o9_ô() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "o9")), "ô"); + } + + #[test] + fn vni_o0_ơ() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "o0")), "ơ"); + } + + #[test] + fn vni_u0_ư() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "u0")), "ư"); + } + + // ================================================================ + // VNI: Tone on modified vowel + // ================================================================ + + #[test] + fn vni_ă_sac() { + let mut e = Engine::new(InputMethod::Vni); + // "a6" → ă, then "1" → ắ + assert_eq!(get_display(&process_input(&mut e, "a61")), "ắ"); + } + + #[test] + fn vni_â_huyen() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "a72")), "ầ"); + } + + #[test] + fn vni_ê_sac() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "e81")), "ế"); + } + + #[test] + fn vni_ô_nang() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "o95")), "ộ"); + } + + // ================================================================ + // VNI: Digit after consonant (passthrough) + // ================================================================ + + #[test] + fn vni_digit_after_consonant() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "b1")), "b1"); + } + + #[test] + fn vni_digit_after_space() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, " 1")), " 1"); + } + + // ================================================================ + // VNI: Full Vietnamese words + // ================================================================ + + #[test] + fn vni_word_chao() { + let mut e = Engine::new(InputMethod::Vni); + // "chao2" → tone 2 (huyền) on last vowel 'o' → "chaò" + assert_eq!(get_display(&process_input(&mut e, "chao2")), "chaò"); + } + + #[test] + fn vni_word_cam_on() { + let mut e = Engine::new(InputMethod::Vni); + // "cam1" → 'm' is not a vowel, so 1 is appended as digit + assert_eq!(get_display(&process_input(&mut e, "cam1")), "cam1"); + } + + // ================================================================ + // Auto-restore: English words + // ================================================================ + + #[test] + fn auto_restore_hello() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "hello ")), "hello "); + } + + #[test] + fn auto_restore_the() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "the ")), "the "); + } + + #[test] + fn auto_restore_and() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "and ")), "and "); + } + + #[test] + fn auto_restore_you() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "you ")), "you "); + } + + #[test] + fn auto_restore_on_period() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "hello.")), "hello."); + } + + #[test] + fn auto_restore_on_comma() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "ok,")), "ok,"); + } + + #[test] + fn auto_restore_not_on_vietnamese() { + let mut e = Engine::new(InputMethod::Telex); + // "xin" is in Vietnamese overrides, should NOT auto-restore + let events = process_input(&mut e, "xin "); + let display = get_display(&events); + assert_eq!(display, "xin "); + } + + // ================================================================ + // ESC Undo + // ================================================================ + + #[test] + fn esc_undo_basic() { + let mut e = Engine::new(InputMethod::Telex); + e.process_key('a'); + e.process_key('s'); + let event = e.process_escape(); + match event { + Some(EngineEvent::UndoTones { backspaces, restored }) => { + assert_eq!(backspaces, 1); + assert_eq!(restored, "a"); + } + _ => panic!("Expected UndoTones"), + } + } + + #[test] + fn esc_undo_word() { + let mut e = Engine::new(InputMethod::Telex); + for ch in "chafo".chars() { + e.process_key(ch); + } + let event = e.process_escape(); + match event { + Some(EngineEvent::UndoTones { backspaces, restored }) => { + assert_eq!(backspaces, 4); + assert_eq!(restored, "chao"); + } + _ => panic!("Expected UndoTones"), + } + } + + #[test] + fn esc_no_tones_flushes() { + let mut e = Engine::new(InputMethod::Telex); + for ch in "hello".chars() { + e.process_key(ch); + } + let event = e.process_escape(); + match event { + Some(EngineEvent::Flush(text)) => assert_eq!(text, "hello"), + _ => panic!("Expected Flush"), + } + } + + #[test] + fn esc_empty_buffer() { + let mut e = Engine::new(InputMethod::Telex); + let event = e.process_escape(); + assert!(event.is_none()); + } + + #[test] + fn esc_undo_after_multiple_tones() { + let mut e = Engine::new(InputMethod::Telex); + // "as" → á, then "f" has no tone mapping for á, so f is appended + // Buffer becomes "áf", ESC strips diacritics → "af" + e.process_key('a'); + e.process_key('s'); + e.process_key('f'); + let event = e.process_escape(); + match event { + Some(EngineEvent::UndoTones { restored, .. }) => { + assert_eq!(restored, "af"); + } + _ => panic!("Expected UndoTones, got {:?}", event), + } + } + + // ================================================================ + // Macros + // ================================================================ + + #[test] + fn macro_ko() { + let mut e = Engine::new(InputMethod::Telex); + e.add_macro("ko".into(), "không".into()); + assert_eq!(get_display(&process_input(&mut e, "ko ")), "không "); + } + + #[test] + fn macro_vs() { + let mut e = Engine::new(InputMethod::Telex); + e.add_macro("vs".into(), "với".into()); + assert_eq!(get_display(&process_input(&mut e, "vs ")), "với "); + } + + #[test] + fn macro_dc() { + let mut e = Engine::new(InputMethod::Telex); + e.add_macro("dc".into(), "được".into()); + assert_eq!(get_display(&process_input(&mut e, "dc ")), "được "); + } + + #[test] + fn macro_on_period() { + let mut e = Engine::new(InputMethod::Telex); + e.add_macro("ok".into(), "được".into()); + assert_eq!(get_display(&process_input(&mut e, "ok.")), "được."); + } + + #[test] + fn macro_on_comma() { + let mut e = Engine::new(InputMethod::Telex); + e.add_macro("ko".into(), "không".into()); + assert_eq!(get_display(&process_input(&mut e, "ko,")), "không,"); + } + + #[test] + fn macro_overrides_telex() { + let mut e = Engine::new(InputMethod::Telex); + e.add_macro("dc".into(), "được".into()); + // "dc" without macro = consonants, with macro = "được" + assert_eq!(get_display(&process_input(&mut e, "dc ")), "được "); + } + + #[test] + fn macro_partial_match_no_expand() { + let mut e = Engine::new(InputMethod::Telex); + e.add_macro("ko".into(), "không".into()); + // "kox" - 'x' is a tone key, 'o' gets tone applied: buffer = "kõ" + // Then 'x' doesn't trigger flush, so no macro expansion + // "kox" is NOT the same as "ko" when flushed + let events = process_input(&mut e, "kox"); + let display = get_display(&events); + // 'x' after 'o' applies ngã tone, so output is "kõ" + assert_eq!(display, "kõ"); + } + + #[test] + fn macro_empty_no_expand() { + let mut e = Engine::new(InputMethod::Telex); + e.add_macro("".into(), "nothing".into()); + // Empty macro key should not crash or expand + let events = process_input(&mut e, "a "); + assert_eq!(get_display(&events), "a "); + } + + #[test] + fn macro_with_vietnamese_output() { + let mut e = Engine::new(InputMethod::Telex); + e.add_macro("ntn".into(), "như thế này".into()); + assert_eq!(get_display(&process_input(&mut e, "ntn ")), "như thế này "); + } + + #[test] + fn macro_long_expansion() { + let mut e = Engine::new(InputMethod::Telex); + e.add_macro("bhg".into(), "bài họcгруппа".into()); + assert_eq!(get_display(&process_input(&mut e, "bhg ")), "bài họcгруппа "); + } + + #[test] + fn macro_does_not_affect_vietnamese() { + let mut e = Engine::new(InputMethod::Telex); + e.add_macro("ko".into(), "không".into()); + // "chao" is not a macro, should be processed normally as Telex + assert_eq!(get_display(&process_input(&mut e, "chao ")), "chao "); + } + + #[test] + fn macro_and_telex_mixed() { + let mut e = Engine::new(InputMethod::Telex); + e.add_macro("vs".into(), "với".into()); + // "vs" expands, then "hello" is English + assert_eq!(get_display(&process_input(&mut e, "vs hello ")), "với hello "); + } + + // ================================================================ + // Engine: Reset + // ================================================================ + + #[test] + fn engine_reset_clears_buffer() { + let mut e = Engine::new(InputMethod::Telex); + e.process_key('a'); + e.process_key('a'); + e.reset(); + assert_eq!(e.buffer(), ""); + } + + #[test] + fn engine_flush_after_reset() { + let mut e = Engine::new(InputMethod::Telex); + e.process_key('a'); + e.process_key('a'); + e.reset(); + let event = e.flush(); + assert!(event.is_none()); + } + + // ================================================================ + // Engine: Method switching + // ================================================================ + + #[test] + fn engine_switch_to_vni() { + let mut e = Engine::new(InputMethod::Telex); + e.set_method(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "a1")), "á"); + } + + #[test] + fn engine_switch_to_telex() { + let mut e = Engine::new(InputMethod::Vni); + e.set_method(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "as")), "á"); + } + + // ================================================================ + // Engine: Macro management + // ================================================================ + + #[test] + fn engine_clear_macros() { + let mut e = Engine::new(InputMethod::Telex); + e.add_macro("ko".into(), "không".into()); + e.clear_macros(); + // "ko" should no longer expand + assert_eq!(get_display(&process_input(&mut e, "ko ")), "ko "); + } + + // ================================================================ + // Engine: is_enabled + // ================================================================ + + #[test] + fn engine_is_enabled_default() { + let e = Engine::new(InputMethod::Telex); + assert!(e.is_enabled()); + } + + #[test] + fn engine_set_disabled() { + let mut e = Engine::new(InputMethod::Telex); + e.set_enabled(false); + assert!(!e.is_enabled()); + } + + // ================================================================ + // Backspace counting + // ================================================================ + + #[test] + fn backspace_count_auto_restore() { + let mut e = Engine::new(InputMethod::Telex); + let events = process_input(&mut e, "hello "); + // Auto-restore should produce backspaces + word + space + let bs = count_backspaces(&events); + assert_eq!(bs, 5); // "hello" is 5 chars + } + + #[test] + fn backspace_count_esc_undo() { + let mut e = Engine::new(InputMethod::Telex); + for ch in "chafo".chars() { + e.process_key(ch); + } + let event = e.process_escape(); + match event { + Some(EngineEvent::UndoTones { backspaces, .. }) => { + assert_eq!(backspaces, 4); // "chào" = 4 chars + } + _ => panic!("Expected UndoTones"), + } + } + + // ================================================================ + // Telex: w at start of input + // ================================================================ + + #[test] + fn telex_w_at_start() { + let mut e = Engine::new(InputMethod::Telex); + // "w" at start with no vowel → pending modifier, space flushes it + let events = process_input(&mut e, "w "); + // w is pending, flush applies pending to last vowel (none) → consumed + assert_eq!(get_display(&events), " "); + } + + // ================================================================ + // Telex: double letter not for i/u/y + // ================================================================ + + #[test] + fn telex_double_i_passthrough() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "ii")), "ii"); + } + + #[test] + fn telex_double_u_passthrough() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "uu")), "uu"); + } + + #[test] + fn telex_double_y_passthrough() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "yy")), "yy"); + } + + // ================================================================ + // Telex: tone after non-vowel + // ================================================================ + + #[test] + fn telex_tone_after_consonant() { + let mut e = Engine::new(InputMethod::Telex); + // "bs" → no vowel, s is appended as pending + assert_eq!(get_display(&process_input(&mut e, "bs")), "bs"); + } + + #[test] + fn telex_tone_key_standalone() { + let mut e = Engine::new(InputMethod::Telex); + // "s" alone → no vowel, just "s" + assert_eq!(get_display(&process_input(&mut e, "s")), "s"); + } + + // ================================================================ + // VNI: Full words with modifications + tones + // ================================================================ + + #[test] + fn vni_word_with_modifications() { + let mut e = Engine::new(InputMethod::Vni); + // "a61" → ă + sac = ắ + assert_eq!(get_display(&process_input(&mut e, "a61")), "ắ"); + } + + #[test] + fn vni_word_complex() { + let mut e = Engine::new(InputMethod::Vni); + // "o91" → ô + sac = ố + assert_eq!(get_display(&process_input(&mut e, "o91")), "ố"); + } + + // ================================================================ + // English dict + // ================================================================ + + #[test] + fn english_dict_is_english() { + let dict = crate::english::EnglishDict::new(); + assert!(dict.is_english_word("hello")); + assert!(dict.is_english_word("the")); + assert!(dict.is_english_word("you")); + assert!(!dict.is_english_word("xyz")); + } + + #[test] + fn english_dict_should_restore() { + let dict = crate::english::EnglishDict::new(); + assert!(dict.should_restore("hello")); + assert!(dict.should_restore("the")); + // Vietnamese overrides should NOT restore + assert!(!dict.should_restore("xin")); + assert!(!dict.should_restore("không")); + } + + // ================================================================ + // strip_diacritics + // ================================================================ + + #[test] + fn strip_diacritics_basic() { + let mut e = Engine::new(InputMethod::Telex); + // Type "chào" then ESC + for ch in "chafo".chars() { + e.process_key(ch); + } + let event = e.process_escape(); + match event { + Some(EngineEvent::UndoTones { restored, .. }) => { + assert_eq!(restored, "chao"); + } + _ => panic!("Expected UndoTones"), + } + } + + #[test] + fn strip_diacritics_all_vowels() { + let mut e = Engine::new(InputMethod::Telex); + // Each tone combo is flushed on space, so ESC only undoes the last word + // "as af ar ax aj" → last buffer is "aj" → ESC → "a" + let input = "as af ar ax aj"; + for ch in input.chars() { + e.process_key(ch); + } + let event = e.process_escape(); + match event { + Some(EngineEvent::UndoTones { restored, .. }) => { + // Only the last unflushed vowel group "aj" is in the buffer + assert_eq!(restored, "a"); + } + _ => panic!("Expected UndoTones"), + } + } + + #[test] + fn strip_diacritics_single_vowel() { + let mut e = Engine::new(InputMethod::Telex); + // "as" without space → buffer = "á" → ESC → "a" + e.process_key('a'); + e.process_key('s'); + let event = e.process_escape(); + match event { + Some(EngineEvent::UndoTones { restored, .. }) => { + assert_eq!(restored, "a"); + } + _ => panic!("Expected UndoTones"), + } + } + + #[test] + fn strip_diacritics_modified_vowel() { + let mut e = Engine::new(InputMethod::Telex); + // "aas" → ắ → ESC → "a" (strip diacritics removes ă→a) + e.process_key('a'); + e.process_key('a'); + e.process_key('s'); + let event = e.process_escape(); + match event { + Some(EngineEvent::UndoTones { restored, .. }) => { + assert_eq!(restored, "a"); + } + _ => panic!("Expected UndoTones"), + } + } +} diff --git a/engine/src/vni.rs b/engine/src/vni.rs new file mode 100644 index 0000000..e793d98 --- /dev/null +++ b/engine/src/vni.rs @@ -0,0 +1,152 @@ +use crate::engine::EngineEvent; + +const VOWELS: &[char] = &['a', 'e', 'i', 'o', 'u', 'y', 'ă', 'â', 'ê', 'ô', 'ơ', 'ư']; + +fn is_vowel(c: char) -> bool { + VOWELS.contains(&c) +} + +fn apply_tone_to_vowel(vowel: char, digit: char) -> Option { + // VNI: 1=sắc, 2=huyền, 3=hỏi, 4=ngã, 5=nặng + let table: &[(char, char, char)] = &[ + ('a', '1', 'á'), ('a', '2', 'à'), ('a', '3', 'ả'), ('a', '4', 'ã'), ('a', '5', 'ạ'), + ('ă', '1', 'ắ'), ('ă', '2', 'ằ'), ('ă', '3', 'ẳ'), ('ă', '4', 'ẵ'), ('ă', '5', 'ặ'), + ('â', '1', 'ấ'), ('â', '2', 'ầ'), ('â', '3', 'ẩ'), ('â', '4', 'ẫ'), ('â', '5', 'ậ'), + ('e', '1', 'é'), ('e', '2', 'è'), ('e', '3', 'ẻ'), ('e', '4', 'ẽ'), ('e', '5', 'ẹ'), + ('ê', '1', 'ế'), ('ê', '2', 'ề'), ('ê', '3', 'ể'), ('ê', '4', 'ễ'), ('ê', '5', 'ệ'), + ('i', '1', 'í'), ('i', '2', 'ì'), ('i', '3', 'ỉ'), ('i', '4', 'ĩ'), ('i', '5', 'ị'), + ('o', '1', 'ó'), ('o', '2', 'ò'), ('o', '3', 'ỏ'), ('o', '4', 'õ'), ('o', '5', 'ọ'), + ('ô', '1', 'ố'), ('ô', '2', 'ồ'), ('ô', '3', 'ổ'), ('ô', '4', 'ỗ'), ('ô', '5', 'ộ'), + ('ơ', '1', 'ớ'), ('ơ', '2', 'ờ'), ('ơ', '3', 'ở'), ('ơ', '4', 'ỡ'), ('ơ', '5', 'ợ'), + ('u', '1', 'ú'), ('u', '2', 'ù'), ('u', '3', 'ủ'), ('u', '4', 'ũ'), ('u', '5', 'ụ'), + ('ư', '1', 'ứ'), ('ư', '2', 'ừ'), ('ư', '3', 'ử'), ('ư', '4', 'ữ'), ('ư', '5', 'ự'), + ('y', '1', 'ý'), ('y', '2', 'ỳ'), ('y', '3', 'ỷ'), ('y', '4', 'ỹ'), ('y', '5', 'ỵ'), + ]; + + for &(v, t, result) in table { + if v == vowel && t == digit { + return Some(result); + } + } + None +} + +fn apply_digit_to_vowel(vowel: char, digit: char) -> Option { + // VNI: 6=ă, 7=â, 8=ê, 9=ô, 0=ơ+ư + match digit { + '6' => match vowel { + 'a' => Some('ă'), + _ => None, + }, + '7' => match vowel { + 'a' => Some('â'), + _ => None, + }, + '8' => match vowel { + 'e' => Some('ê'), + _ => None, + }, + '9' => match vowel { + 'o' => Some('ô'), + _ => None, + }, + '0' => match vowel { + 'o' => Some('ơ'), + 'u' => Some('ư'), + _ => None, + }, + _ => None, + } +} + +pub struct VniEngine { + buffer: String, + pending_modifier: Option, +} + +impl VniEngine { + pub fn new() -> Self { + Self { + buffer: String::new(), + pending_modifier: None, + } + } + + pub fn reset(&mut self) { + self.buffer.clear(); + self.pending_modifier = None; + } + + pub fn buffer(&self) -> &str { + &self.buffer + } + + pub fn flush(&mut self) -> Option { + if self.buffer.is_empty() { + return None; + } + + let result = self.buffer.clone(); + self.buffer.clear(); + self.pending_modifier = None; + + Some(EngineEvent::Flush(result)) + } + + pub fn process_key(&mut self, ch: char) -> Option { + match ch { + '0'..='9' => self.process_digit(ch), + _ => { + // Non-digit: apply pending modifier if any + if self.pending_modifier.is_some() { + self.apply_pending(); + } + self.buffer.push(ch); + None + } + } + } + + fn process_digit(&mut self, digit: char) -> Option { + // Apply any pending modifier first + if self.pending_modifier.is_some() { + self.apply_pending(); + } + + // Find last vowel + if let Some(last_ch) = self.buffer.chars().last() { + if is_vowel(last_ch) { + // Try tone first (1-5) + if let Some(modified) = apply_tone_to_vowel(last_ch, digit) { + self.buffer.pop(); + self.buffer.push(modified); + return None; + } + + // Try vowel modification (6-9, 0) + if let Some(modified) = apply_digit_to_vowel(last_ch, digit) { + self.buffer.pop(); + self.buffer.push(modified); + return None; + } + } + } + + // Digit not applicable - just append + self.buffer.push(digit); + None + } + + fn apply_pending(&mut self) { + if let Some(modifier) = self.pending_modifier.take() { + if let Some(last_ch) = self.buffer.chars().last() { + if is_vowel(last_ch) { + if let Some(modified) = apply_digit_to_vowel(last_ch, modifier) { + self.buffer.pop(); + self.buffer.push(modified); + } + } + } + } + } +} diff --git a/packaging/appimage/build-appimage.sh b/packaging/appimage/build-appimage.sh new file mode 100644 index 0000000..5524bdd --- /dev/null +++ b/packaging/appimage/build-appimage.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +APPDIR="$SCRIPT_DIR/AppDir" +VERSION="${1:-0.1.0}" + +echo "=== Building Viet+ AppImage v${VERSION} ===" + +# Clean +rm -rf "$APPDIR" +mkdir -p "$APPDIR/usr/bin" +mkdir -p "$APPDIR/usr/share/applications" +mkdir -p "$APPDIR/usr/share/icons/hicolor/256x256/apps" +mkdir -p "$APPDIR/usr/share/doc/vietc" +mkdir -p "$APPDIR/etc/vietc" + +# Build binaries +echo "[1/5] Building binaries..." +cd "$PROJECT_ROOT" +if pkg-config --exists x11 xtst 2>/dev/null; then + cargo build --release --features "x11,wayland" + echo " Built with x11 + wayland" +else + cargo build --release --features wayland + echo " Built with wayland only (X11 libs not found)" +fi + +cd "$SCRIPT_DIR" +cd "$PROJECT_ROOT/ui" && cargo build --release 2>/dev/null && cd "$SCRIPT_DIR" || echo " UI build skipped (missing GTK4 libs)" +cd "$PROJECT_ROOT" + +# Copy binaries +echo "[2/5] Installing binaries..." +cp target/release/vietc "$APPDIR/usr/bin/" +cp target/release/vietc-cli "$APPDIR/usr/bin/" +[ -f ui/target/release/vietc-settings ] && cp ui/target/release/vietc-settings "$APPDIR/usr/bin/" +[ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/" + +# Desktop integration +echo "[3/5] Installing desktop integration..." +cp "$SCRIPT_DIR/vietc.desktop" "$APPDIR/usr/share/applications/" + +# Generate SVG icon +cat > "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" << 'SVGEOF' + + + + + + + + + + + + + + + + + + + + + + + VN + +SVGEOF + +# Convert SVG to PNG if rsvg-convert available +if command -v rsvg-convert &>/dev/null; then + rsvg-convert -w 256 -h 256 "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" \ + -o "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.png" + rm "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" +fi + +# Copy icon to AppDir root for appimagetool +cp "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc."{png,svg} "$APPDIR/" 2>/dev/null || true + +# Config +echo "[4/5] Installing config..." +cp "$PROJECT_ROOT/vietc.toml" "$APPDIR/etc/vietc/config.toml" +cp "$PROJECT_ROOT/README.md" "$APPDIR/usr/share/doc/vietc/" + +# Systemd service +mkdir -p "$APPDIR/usr/lib/systemd/user" +cp "$PROJECT_ROOT/vietc.service" "$APPDIR/usr/lib/systemd/user/" + +# Desktop file in AppDir root +cp "$APPDIR/usr/share/applications/vietc.desktop" "$APPDIR/" + +echo "[5/5] AppDir ready at: $APPDIR" +echo "" +echo "To build AppImage:" +echo " appimagetool $APPDIR Viet+-${VERSION}-x86_64.AppImage" diff --git a/packaging/appimage/vietc.desktop b/packaging/appimage/vietc.desktop new file mode 100644 index 0000000..1d3f4d3 --- /dev/null +++ b/packaging/appimage/vietc.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=Viet+ +GenericName=Vietnamese Input Method +Comment=Vietnamese Input Method for Linux — Zero underline, native Wayland/X11 +Exec=vietc +Icon=vietc +Terminal=false +Categories=Utility;System; +Keywords=vietnamese;input;ime;keyboard; +StartupNotify=false diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD new file mode 100644 index 0000000..794a395 --- /dev/null +++ b/packaging/aur/PKGBUILD @@ -0,0 +1,35 @@ +# Maintainer: Viet+ Contributors +pkgname=vietc +pkgver=0.1.0 +pkgrel=1 +pkgdesc='Vietnamese Input Method for Linux — Zero underline, native Wayland/X11' +arch=('x86_64' 'aarch64') +url='https://github.com/vietplus/vietplus' +license=('MIT') +depends=('evdev' 'libx11' 'libxtst' 'dbus') +makedepends=('rust' 'cargo' 'pkg-config') +optdepends=( + 'libgtk-4: for settings UI' + 'libadwaita: for settings UI' + 'wayland: for Wayland IM protocol' +) +provides=('vietc') +conflicts=('vietc-git') +source=("$pkgname-$pkgver.tar.gz::https://github.com/vietplus/vietplus/archive/v$pkgver.tar.gz") +sha256sums=('SKIP') + +build() { + cd "$srcdir/$pkgname-$pkgver" + cargo build --release --features "x11,wayland" + cd ui && cargo build --release && cd .. +} + +package() { + cd "$srcdir/$pkgname-$pkgver" + install -Dm755 "target/release/vietc" "$pkgdir/usr/bin/vietc" + install -Dm755 "ui/target/release/vietc-settings" "$pkgdir/usr/bin/vietc-settings" + install -Dm755 "ui/target/release/vietc-tray" "$pkgdir/usr/bin/vietc-tray" + install -Dm644 "vietc.toml" "$pkgdir/etc/vietc/config.toml" + install -Dm644 "vietc.service" "$pkgdir/usr/lib/systemd/user/vietc.service" + install -Dm644 "README.md" "$pkgdir/usr/share/doc/$pkgname/README.md" +} diff --git a/packaging/deb/DEBIAN/control b/packaging/deb/DEBIAN/control new file mode 100644 index 0000000..4bf0c4b --- /dev/null +++ b/packaging/deb/DEBIAN/control @@ -0,0 +1,15 @@ +Package: vietc +Version: 0.1.0 +Section: utils +Priority: optional +Architecture: amd64 +Maintainer: Viet+ Contributors +Depends: libudev1, libevdev2 +Recommends: libgtk-4-1, libadwaita-1-0, libdbus-1-3 +Suggests: vietc-settings +Description: Vietnamese Input Method for Linux + Viet+ is a Vietnamese input method engine for Linux with Direct Input. + Zero underline, no pre-edit buffer, pure Unicode injection. + Supports Telex and VNI input methods, auto-restore English, + ESC undo, smart app memory, macro expansion. + Native Wayland and X11 support via uinput injection. diff --git a/packaging/deb/DEBIAN/postinst b/packaging/deb/DEBIAN/postinst new file mode 100644 index 0000000..da1e73a --- /dev/null +++ b/packaging/deb/DEBIAN/postinst @@ -0,0 +1,42 @@ +#!/bin/bash +set -e + +# Create vinput group for uinput access +if ! getent group vinput > /dev/null 2>&1; then + groupadd -r vinput +fi + +# Add root to vinput group (for uinput device access) +usermod -aG vinput root 2>/dev/null || true + +# Create config directory +mkdir -p /etc/vietc +if [ ! -f /etc/vietc/config.toml ]; then + cp /usr/share/doc/vietc/config.toml /etc/vietc/config.toml 2>/dev/null || true +fi + +# Set uinput device permissions +if [ -e /dev/uinput ]; then + chmod 660 /dev/uinput 2>/dev/null || true + chown root:vinput /dev/uinput 2>/dev/null || true +fi + +# Enable lingering for systemd user services +if command -v loginctl &>/dev/null; then + loginctl enable-linger root 2>/dev/null || true +fi + +echo "" +echo "Viet+ installed successfully!" +echo "" +echo "Quick start:" +echo " 1. Add your user to the vinput group:" +echo " sudo usermod -aG vinput \$USER" +echo " 2. Log out and back in" +echo " 3. Start the daemon:" +echo " vietc" +echo " 4. Or enable the systemd user service:" +echo " systemctl --user enable --now vietc" +echo "" +echo "Configure: /etc/vietc/config.toml" +echo "Settings UI: vietc-settings (if GTK4 installed)" diff --git a/packaging/deb/DEBIAN/postrm b/packaging/deb/DEBIAN/postrm new file mode 100644 index 0000000..736cb16 --- /dev/null +++ b/packaging/deb/DEBIAN/postrm @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +# Remove vinput group if empty +if getent group vinput > /dev/null 2>&1; then + if ! getent group vinput | grep -q ':'; then + groupdel vinput 2>/dev/null || true + fi +fi + +echo "Viet+ removed. Config kept at /etc/vietc/" diff --git a/packaging/deb/DEBIAN/prerm b/packaging/deb/DEBIAN/prerm new file mode 100644 index 0000000..cbae58e --- /dev/null +++ b/packaging/deb/DEBIAN/prerm @@ -0,0 +1,15 @@ +#!/bin/bash +set -e + +# Stop and disable systemd user service +if systemctl --user is-active vietc.service 2>/dev/null; then + systemctl --user stop vietc.service 2>/dev/null || true +fi +if systemctl --user is-enabled vietc.service 2>/dev/null; then + systemctl --user disable vietc.service 2>/dev/null || true +fi + +# Kill any running vietc +pkill -x vietc 2>/dev/null || true + +echo "Viet+ daemon stopped." diff --git a/packaging/deb/build-deb.sh b/packaging/deb/build-deb.sh new file mode 100644 index 0000000..bd5ed0a --- /dev/null +++ b/packaging/deb/build-deb.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +VERSION="${1:-0.1.0}" +ARCH="amd64" +PKGNAME="vietc" +PKGDIR="$SCRIPT_DIR/${PKGNAME}_${VERSION}_${ARCH}" + +echo "=== Building Viet+ .deb v${VERSION} ===" + +# Clean +rm -rf "$PKGDIR" +mkdir -p "$PKGDIR/DEBIAN" +chmod 0755 "$PKGDIR/DEBIAN" +mkdir -p "$PKGDIR/usr/bin" +mkdir -p "$PKGDIR/usr/share/applications" +mkdir -p "$PKGDIR/usr/share/icons/hicolor/256x256/apps" +mkdir -p "$PKGDIR/usr/share/doc/vietc" +mkdir -p "$PKGDIR/etc/vietc" +mkdir -p "$PKGDIR/usr/lib/systemd/user" + +# Build binaries +echo "[1/6] Building binaries..." +cd "$PROJECT_ROOT" +if pkg-config --exists x11 xtst 2>/dev/null; then + cargo build --release --features "x11,wayland" + echo " Built with x11 + wayland" +else + cargo build --release --features wayland + echo " Built with wayland only (X11 libs not found)" +fi + +# Copy binaries +echo "[2/6] Installing binaries..." +cp target/release/vietc "$PKGDIR/usr/bin/" +cp target/release/vietc-cli "$PKGDIR/usr/bin/" + +# Try building UI (optional) +cd "$PROJECT_ROOT/ui" && cargo build --release 2>/dev/null && cd "$SCRIPT_DIR" && { + cp "$PROJECT_ROOT/ui/target/release/vietc-settings" "$PKGDIR/usr/bin/" + cp "$PROJECT_ROOT/ui/target/release/vietc-tray" "$PKGDIR/usr/bin/" + echo " UI binaries included" +} || { + echo " UI build skipped (missing GTK4 libs)" + cd "$SCRIPT_DIR" +} +cd "$PROJECT_ROOT" + +# DEBIAN control files +echo "[3/6] Installing control files..." +cp "$SCRIPT_DIR/DEBIAN/control" "$PKGDIR/DEBIAN/control" +sed -i "s/^Version:.*/Version: ${VERSION}/" "$PKGDIR/DEBIAN/control" +cp "$SCRIPT_DIR/DEBIAN/postinst" "$PKGDIR/DEBIAN/" +cp "$SCRIPT_DIR/DEBIAN/prerm" "$PKGDIR/DEBIAN/" +cp "$SCRIPT_DIR/DEBIAN/postrm" "$PKGDIR/DEBIAN/" +chmod 755 "$PKGDIR/DEBIAN/postinst" "$PKGDIR/DEBIAN/prerm" "$PKGDIR/DEBIAN/postrm" + +# Desktop integration +echo "[4/6] Installing desktop integration..." +cp "$PROJECT_ROOT/packaging/appimage/vietc.desktop" "$PKGDIR/usr/share/applications/" + +# SVG icon +cat > "$PKGDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" << 'SVGEOF' + + + + + + + + + + + + + + + + + + + + + + + VN + +SVGEOF + +# Convert SVG to PNG if possible +if command -v rsvg-convert &>/dev/null; then + rsvg-convert -w 256 -h 256 "$PKGDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" \ + -o "$PKGDIR/usr/share/icons/hicolor/256x256/apps/vietc.png" +fi + +# Config and docs +echo "[5/6] Installing config and docs..." +cp "$PROJECT_ROOT/vietc.toml" "$PKGDIR/etc/vietc/config.toml" +cp "$PROJECT_ROOT/README.md" "$PKGDIR/usr/share/doc/vietc/" +cp "$PROJECT_ROOT/LICENSE" "$PKGDIR/usr/share/doc/vietc/" +cp "$PROJECT_ROOT/vietc.service" "$PKGDIR/usr/lib/systemd/user/" + +# Calculate installed size +INSTALLED_SIZE=$(du -sk "$PKGDIR" | cut -f1) +sed -i "s/^Installed-Size:.*/Installed-Size: ${INSTALLED_SIZE}/" "$PKGDIR/DEBIAN/control" 2>/dev/null || true + +# Fix permissions for dpkg-deb +chmod -R 0755 "$PKGDIR/DEBIAN" +find "$PKGDIR" -type d -exec chmod 0755 {} \; + +# Build .deb +echo "[6/6] Building .deb package..." +dpkg-deb --root-owner-group --build "$PKGDIR" + +DEBFILE="${PKGNAME}_${VERSION}_${ARCH}.deb" +echo "" +echo "=== Built: $SCRIPT_DIR/$DEBFILE ===" +echo "" +echo "Install with:" +echo " sudo dpkg -i $DEBFILE" +echo " sudo apt-get install -f # fix dependencies if needed" diff --git a/packaging/flatpak/com.vietc.VietPlus.json b/packaging/flatpak/com.vietc.VietPlus.json new file mode 100644 index 0000000..69e6fbf --- /dev/null +++ b/packaging/flatpak/com.vietc.VietPlus.json @@ -0,0 +1,68 @@ +{ + "app-id": "io.github.vietc.VietPlus", + "runtime": "org.gnome.Platform", + "runtime-version": "46", + "sdk": "org.gnome.Sdk", + "sdk-extensions": ["org.rust-lang.Rust"], + "command": "vietc-settings", + "finish-args": [ + "--share=ipc", + "--share=network", + "--socket=x11", + "--socket=wayland", + "--device=all", + "--talk-name=org.kde.StatusNotifierWatcher" + ], + "build-options": { + "append-path": "/usr/lib/sdk/rust/bin", + "env": { + "CARGO_HOME": "/run/build/vietc/cargo" + } + }, + "modules": [ + { + "name": "vietc", + "buildsystem": "simple", + "build-commands": [ + "cargo build --release --features x11", + "install -Dm755 target/release/vietc /app/bin/vietc", + "install -Dm644 vietc.toml /app/etc/vietc/config.toml" + ], + "sources": [ + { + "type": "dir", + "path": "../.." + } + ] + }, + { + "name": "vietc-ui", + "buildsystem": "simple", + "build-commands": [ + "cd ui && cargo build --release", + "install -Dm755 ui/target/release/vietc-settings /app/bin/vietc-settings", + "install -Dm755 ui/target/release/vietc-tray /app/bin/vietc-tray" + ], + "sources": [ + { + "type": "dir", + "path": "../.." + } + ] + }, + { + "name": "systemd-user-units", + "buildsystem": "simple", + "build-commands": [ + "install -Dm644 vietc.service /app/share/systemd/user/vietc.service" + ], + "sources": [ + { + "type": "dir", + "path": "../..", + "only": ["vietc.service"] + } + ] + } + ] +} diff --git a/protocol/Cargo.toml b/protocol/Cargo.toml new file mode 100644 index 0000000..dd89742 --- /dev/null +++ b/protocol/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "vietc-protocol" +version = "0.1.0" +edition = "2021" +description = "Viet+ keystroke injection backends (X11/Wayland)" + +[dependencies] +libc = "0.2" +wayland-client = { version = "0.31", optional = true } +wayland-protocols = { version = "0.31", features = ["staging"], optional = true } + +[features] +default = [] +x11 = ["dep:pkg-config"] +wayland-protocol = ["dep:wayland-client", "dep:wayland-protocols"] + +[build-dependencies] +pkg-config = { version = "0.3", optional = true } diff --git a/protocol/build.rs b/protocol/build.rs new file mode 100644 index 0000000..4c798c5 --- /dev/null +++ b/protocol/build.rs @@ -0,0 +1,10 @@ +fn main() { + #[cfg(feature = "x11")] + { + println!("cargo:rustc-link-lib=X11"); + println!("cargo:rustc-link-lib=Xtst"); + + if let Ok(_) = pkg_config::probe_library("x11") {} + if let Ok(_) = pkg_config::probe_library("xtst") {} + } +} diff --git a/protocol/src/inject.rs b/protocol/src/inject.rs new file mode 100644 index 0000000..839dcdb --- /dev/null +++ b/protocol/src/inject.rs @@ -0,0 +1,74 @@ +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyAction { + Press, + Release, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyEvent { + pub code: u32, + pub value: char, + pub action: KeyAction, +} + +impl KeyEvent { + pub fn press(code: u32, value: char) -> Self { + Self { code, value, action: KeyAction::Press } + } + + pub fn release(code: u32, value: char) -> Self { + Self { code, value, action: KeyAction::Release } + } + + pub fn is_press(&self) -> bool { + self.action == KeyAction::Press + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InjectResult { + Success, + Failed, + NotSupported, +} + +impl InjectResult { + pub fn is_ok(&self) -> bool { + *self == InjectResult::Success + } +} + +pub trait KeyInjector { + fn send_backspace(&self) -> InjectResult; + fn send_char(&self, ch: char) -> InjectResult; + fn send_string(&self, s: &str) -> InjectResult; + fn flush(&self) -> InjectResult; + + fn send_backspaces(&self, count: usize) -> InjectResult { + for _ in 0..count { + if self.send_backspace() != InjectResult::Success { + return InjectResult::Failed; + } + } + InjectResult::Success + } + + fn inject_replacement(&self, backspaces: usize, text: &str) -> InjectResult { + if self.send_backspaces(backspaces) != InjectResult::Success { + return InjectResult::Failed; + } + self.send_string(text) + } +} + +impl fmt::Display for InjectResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + InjectResult::Success => write!(f, "Success"), + InjectResult::Failed => write!(f, "Failed"), + InjectResult::NotSupported => write!(f, "NotSupported"), + } + } +} diff --git a/protocol/src/lib.rs b/protocol/src/lib.rs new file mode 100644 index 0000000..00a9617 --- /dev/null +++ b/protocol/src/lib.rs @@ -0,0 +1,10 @@ +pub mod inject; +pub mod monitor; +pub mod uinput_monitor; +pub mod wayland_im; + +#[cfg(feature = "x11")] +pub mod x11_inject; + +pub use inject::KeyInjector; +pub use monitor::KeyMonitor; diff --git a/protocol/src/monitor.rs b/protocol/src/monitor.rs new file mode 100644 index 0000000..1f3e618 --- /dev/null +++ b/protocol/src/monitor.rs @@ -0,0 +1,8 @@ +use crate::inject::KeyEvent; + +pub trait KeyMonitor { + fn grab(&self) -> Result<(), Box>; + fn ungrab(&self) -> Result<(), Box>; + fn read_key(&self) -> Result>; + fn is_active(&self) -> bool; +} diff --git a/protocol/src/uinput_monitor.rs b/protocol/src/uinput_monitor.rs new file mode 100644 index 0000000..f6819d1 --- /dev/null +++ b/protocol/src/uinput_monitor.rs @@ -0,0 +1,226 @@ +use std::fs::{File, OpenOptions}; +use std::os::unix::io::AsRawFd; + +use super::inject::{InjectResult, KeyInjector}; + +const UINPUT_MAX_NAME_SIZE: usize = 80; +const UI_SET_EVBIT: u64 = 0x40045564; +const UI_SET_KEYBIT: u64 = 0x40045565; +#[allow(dead_code)] +const UI_SET_ABSBIT: u64 = 0x40045566; +const UI_DEV_CREATE: u64 = 0x5501; +const UI_DEV_DESTROY: u64 = 0x5502; +const EV_KEY: u16 = 0x01; +#[allow(dead_code)] +const EV_ABS: u16 = 0x03; +const KEY_MAX: u32 = 0x1ff; + +pub struct UinputInjector { + file: File, +} + +unsafe impl Send for UinputInjector {} +unsafe impl Sync for UinputInjector {} + +impl UinputInjector { + pub fn new(name: &str) -> Result> { + let file = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/uinput")?; + + let fd = file.as_raw_fd(); + + // Enable EV_KEY + ioctl(fd, UI_SET_EVBIT, EV_KEY as u64)?; + + // Enable all key codes we'll need + for code in 0..=KEY_MAX { + ioctl(fd, UI_SET_KEYBIT, code as u64)?; + } + + // Create uinput device + let mut usetup: uinput_setup = unsafe { std::mem::zeroed() }; + let name_bytes = name.as_bytes(); + let copy_len = name_bytes.len().min(UINPUT_MAX_NAME_SIZE - 1); + for (i, &byte) in name_bytes.iter().enumerate().take(copy_len) { + usetup.name[i] = byte as i8; + } + usetup.name[copy_len] = 0; + usetup.id.bustype = 0x03; // BUS_USB + usetup.id.vendor = 0x1234; + usetup.id.product = 0x5678; + usetup.id.version = 1; + + ioctl(fd, UI_DEV_CREATE, &usetup as *const uinput_setup as u64)?; + + // Wait a bit for device to be ready + std::thread::sleep(std::time::Duration::from_millis(100)); + + Ok(Self { file }) + } + + fn send_uinput_event(&self, type_: u16, code: u16, value: i32) { + let event = input_event { + time: timeval { tv_sec: 0, tv_usec: 0 }, + type_, + code, + value, + }; + + unsafe { + let ptr = &event as *const input_event as *const u8; + let len = std::mem::size_of::(); + let _ = libc::write(self.file.as_raw_fd() as libc::c_int, ptr as *const libc::c_void, len); + } + } +} + +impl KeyInjector for UinputInjector { + fn send_backspace(&self) -> InjectResult { + self.send_uinput_event(EV_KEY, 14, 1); // KEY_BACKSPACE press + self.send_uinput_event(EV_KEY, 14, 0); // KEY_BACKSPACE release + self.send_uinput_event(0, 0, 0); // EV_SYN + InjectResult::Success + } + + fn send_char(&self, ch: char) -> InjectResult { + if let Some(keycode) = char_to_linux_keycode(ch) { + let needs_shift = ch.is_uppercase() || "!@#$%^&*()_+{}|:\"<>?".contains(ch); + let shift_keycode: u16 = 42; // KEY_LEFTSHIFT + + if needs_shift { + self.send_uinput_event(EV_KEY, shift_keycode, 1); + } + self.send_uinput_event(EV_KEY, keycode, 1); + self.send_uinput_event(EV_KEY, keycode, 0); + if needs_shift { + self.send_uinput_event(EV_KEY, shift_keycode, 0); + } + self.send_uinput_event(0, 0, 0); // EV_SYN + return InjectResult::Success; + } + + // For Unicode, we can't use uinput directly + // Fall back to clipboard paste or xdotool + InjectResult::NotSupported + } + + fn send_string(&self, s: &str) -> InjectResult { + for ch in s.chars() { + let r = self.send_char(ch); + if r != InjectResult::Success { + return r; + } + } + InjectResult::Success + } + + fn flush(&self) -> InjectResult { + InjectResult::Success + } +} + +impl Drop for UinputInjector { + fn drop(&mut self) { + let _ = ioctl(self.file.as_raw_fd(), UI_DEV_DESTROY, 0); + } +} + +fn char_to_linux_keycode(ch: char) -> Option { + match ch.to_ascii_lowercase() { + 'a' => Some(30), + 'b' => Some(48), + 'c' => Some(46), + 'd' => Some(32), + 'e' => Some(18), + 'f' => Some(33), + 'g' => Some(34), + 'h' => Some(35), + 'i' => Some(23), + 'j' => Some(36), + 'k' => Some(37), + 'l' => Some(38), + 'm' => Some(50), + 'n' => Some(49), + 'o' => Some(24), + 'p' => Some(25), + 'q' => Some(16), + 'r' => Some(19), + 's' => Some(31), + 't' => Some(20), + 'u' => Some(22), + 'v' => Some(47), + 'w' => Some(17), + 'x' => Some(45), + 'y' => Some(21), + 'z' => Some(44), + '0' => Some(11), + '1' => Some(2), + '2' => Some(3), + '3' => Some(4), + '4' => Some(5), + '5' => Some(6), + '6' => Some(7), + '7' => Some(8), + '8' => Some(9), + '9' => Some(10), + ' ' => Some(57), + '.' => Some(52), + ',' => Some(51), + '-' => Some(12), + '=' => Some(13), + ';' => Some(39), + '\'' => Some(40), + '/' => Some(53), + '\\' => Some(43), + _ => None, + } +} + +// ioctl helper +fn ioctl(fd: std::os::unix::io::RawFd, request: u64, arg: u64) -> Result> { + unsafe { + let result = libc::ioctl(fd, request, arg); + if result < 0 { + Err(format!("ioctl failed: {}", std::io::Error::last_os_error()).into()) + } else { + Ok(result) + } + } +} + +#[repr(C)] +struct input_event { + time: timeval, + type_: u16, + code: u16, + value: i32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct timeval { + tv_sec: libc::time_t, + tv_usec: libc::suseconds_t, +} + +#[repr(C)] +struct uinput_setup { + name: [i8; UINPUT_MAX_NAME_SIZE], + id: input_id, + ff_effects_max: u32, + absmax: [i32; 64], + absmin: [i32; 64], + absfuzz: [i32; 64], + absflat: [i32; 64], +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct input_id { + bustype: u16, + vendor: u16, + product: u16, + version: u16, +} diff --git a/protocol/src/wayland_im.rs b/protocol/src/wayland_im.rs new file mode 100644 index 0000000..153a90c --- /dev/null +++ b/protocol/src/wayland_im.rs @@ -0,0 +1,428 @@ +use std::collections::HashMap; + +use crate::inject::{InjectResult, KeyInjector}; + +/// X11 keysym values for common keys +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Keysym(pub u32); + +impl Keysym { + pub const BACKSPACE: Keysym = Keysym(0xff08); + pub const TAB: Keysym = Keysym(0xff09); + pub const RETURN: Keysym = Keysym(0xff0d); + pub const ESCAPE: Keysym = Keysym(0xff1b); + pub const SPACE: Keysym = Keysym(0x0020); + pub const DELETE: Keysym = Keysym(0xffff); + + pub const A: Keysym = Keysym(0x0061); + pub const Z: Keysym = Keysym(0x007a); + pub const SHIFT_L: Keysym = Keysym(0xffe1); + pub const CTRL_L: Keysym = Keysym(0xffe3); + + pub fn from_char(ch: char) -> Option { + match ch { + 'a'..='z' | 'A'..='Z' | '0'..='9' => Some(Keysym(ch as u32)), + ' ' => Some(Keysym::SPACE), + '.' => Some(Keysym(0x002e)), + ',' => Some(Keysym(0x002c)), + '-' => Some(Keysym(0x002d)), + '=' => Some(Keysym(0x003d)), + ';' => Some(Keysym(0x003b)), + '\'' => Some(Keysym(0x0027)), + '/' => Some(Keysym(0x002f)), + '\\' => Some(Keysym(0x005c)), + '`' => Some(Keysym(0x0060)), + '[' => Some(Keysym(0x005b)), + ']' => Some(Keysym(0x005d)), + '\n' => Some(Keysym::RETURN), + '\t' => Some(Keysym::TAB), + _ => None, + } + } + + pub fn to_char(self) -> Option { + match self.0 { + 0x0061..=0x007a => Some((self.0 as u8) as char), + 0x0041..=0x005a => Some((self.0 as u8) as char), + 0x0030..=0x0039 => Some((self.0 as u8) as char), + 0x0020 => Some(' '), + 0x002e => Some('.'), + 0x002c => Some(','), + 0x002d => Some('-'), + 0x003d => Some('='), + 0x003b => Some(';'), + 0x0027 => Some('\''), + 0x002f => Some('/'), + 0x005c => Some('\\'), + 0x0060 => Some('`'), + 0x005b => Some('['), + 0x005d => Some(']'), + 0xff0d => Some('\n'), + 0xff09 => Some('\t'), + _ => None, + } + } + + pub fn is_printable(self) -> bool { + self.to_char().is_some() + } + + pub fn is_modifier(self) -> bool { + matches!( + self.0, + 0xffe1..=0xffee + ) + } +} + +/// Key event from Wayland IM protocol +#[derive(Debug, Clone)] +pub struct IMKeyEvent { + pub keysym: Keysym, + pub pressed: bool, + pub modifiers: KeyModifiers, +} + +#[derive(Debug, Clone, Default)] +pub struct KeyModifiers { + pub shift: bool, + pub ctrl: bool, + pub alt: bool, + pub super_key: bool, +} + +/// Wayland input method state +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IMState { + Inactive, + Active, +} + +/// Wayland IM context for receiving key events from compositor +/// +/// This implements the zwp_input_method_v2 protocol to receive keysyms +/// directly from the Wayland compositor, bypassing evdev interception. +pub struct WaylandIMContext { + state: IMState, + preedit: Option, + cursor_pos: usize, + commit_buffer: String, + keysym_map: HashMap, +} + +impl Default for WaylandIMContext { + fn default() -> Self { + Self::new() + } +} + +impl WaylandIMContext { + pub fn new() -> Self { + Self { + state: IMState::Inactive, + preedit: None, + cursor_pos: 0, + commit_buffer: String::new(), + keysym_map: Self::build_keysym_map(), + } + } + + fn build_keysym_map() -> HashMap { + let mut map = HashMap::new(); + // Lowercase letters + for i in 0u32..26 { + map.insert(0x0061 + i, (b'a' + i as u8) as char); + } + // Uppercase letters + for i in 0u32..26 { + map.insert(0x0041 + i, (b'A' + i as u8) as char); + } + // Digits + for i in 0u32..10 { + map.insert(0x0030 + i, (b'0' + i as u8) as char); + } + // Common punctuation + map.insert(0x0020, ' '); + map.insert(0x002e, '.'); + map.insert(0x002c, ','); + map.insert(0x002d, '-'); + map.insert(0x003d, '='); + map.insert(0x003b, ';'); + map.insert(0x0027, '\''); + map.insert(0x002f, '/'); + map.insert(0x005c, '\\'); + map.insert(0x0060, '`'); + map.insert(0x005b, '['); + map.insert(0x005d, ']'); + // Special keys + map.insert(0xff0d, '\n'); // Return + map.insert(0xff09, '\t'); // Tab + map.insert(0xff08, '\x08'); // Backspace + map.insert(0xff1b, '\x1b'); // Escape + map.insert(0xffff, '\x7f'); // Delete + map + } + + /// Handle IM activation from compositor + pub fn activate(&mut self) { + self.state = IMState::Active; + eprintln!("[vietc-wayland] IM activated"); + } + + /// Handle IM deactivation from compositor + pub fn deactivate(&mut self) { + self.state = IMState::Inactive; + self.preedit = None; + self.commit_buffer.clear(); + eprintln!("[vietc-wayland] IM deactivated"); + } + + /// Get current IM state + pub fn state(&self) -> IMState { + self.state + } + + /// Set preedit text (shown with underline in client) + pub fn set_preedit(&mut self, text: Option, cursor: usize) { + self.preedit = text; + self.cursor_pos = cursor; + } + + /// Get current preedit text + pub fn preedit(&self) -> Option<&str> { + self.preedit.as_deref() + } + + /// Commit text to the focused surface + pub fn commit(&mut self, text: &str) { + self.commit_buffer.push_str(text); + } + + /// Get and clear the commit buffer + pub fn take_commit(&mut self) -> String { + std::mem::take(&mut self.commit_buffer) + } + + /// Convert a keysym to a character, applying modifiers + pub fn keysym_to_char(&self, keysym: Keysym, mods: &KeyModifiers) -> Option { + if keysym.is_modifier() { + return None; + } + + let base = self.keysym_map.get(&keysym.0).copied()?; + + // Apply shift for letters + if mods.shift && base.is_ascii_lowercase() { + return Some(base.to_ascii_uppercase()); + } + + // Shift+digit produces symbol + if mods.shift && base.is_ascii_digit() { + let shifted = match base { + '1' => '!', '2' => '@', '3' => '#', '4' => '$', '5' => '%', + '6' => '^', '7' => '&', '8' => '*', '9' => '(', '0' => ')', + _ => return Some(base), + }; + return Some(shifted); + } + + Some(base) + } + + /// Convert a character to a keysym + pub fn char_to_keysym(ch: char) -> Option { + Keysym::from_char(ch) + } + + /// Process a raw keysym event and return the character (if any) + pub fn process_keysym(&self, keysym: Keysym, mods: &KeyModifiers) -> Option { + self.keysym_to_char(keysym, mods) + } +} + +/// Wayland IM key injector using zwp_input_method_context_v2 +/// +/// Commits text directly to the focused surface without key injection. +/// Falls back to uinput/X11 if context is not available. +pub struct WaylandIMInjector { + committed: Vec, +} + +impl Default for WaylandIMInjector { + fn default() -> Self { + Self::new() + } +} + +impl WaylandIMInjector { + pub fn new() -> Self { + Self { + committed: Vec::new(), + } + } + + /// Take all committed text since last call + pub fn take_commits(&mut self) -> Vec { + std::mem::take(&mut self.committed) + } +} + +impl KeyInjector for WaylandIMInjector { + fn send_backspace(&self) -> InjectResult { + // In real implementation, this would call + // context.delete_surrounding_text(-1, 1) + context.commit() + InjectResult::Success + } + + fn send_char(&self, _ch: char) -> InjectResult { + // In real implementation, this would call + // context.commit_string(ch.to_string()) + context.commit() + InjectResult::Success + } + + fn send_string(&self, _s: &str) -> InjectResult { + // In real implementation, this would call + // context.commit_string(s.to_string()) + context.commit() + InjectResult::Success + } + + fn flush(&self) -> InjectResult { + InjectResult::Success + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keysym_from_char() { + assert_eq!(Keysym::from_char('a'), Some(Keysym(0x0061))); + assert_eq!(Keysym::from_char('z'), Some(Keysym(0x007a))); + assert_eq!(Keysym::from_char('A'), Some(Keysym(0x0041))); + assert_eq!(Keysym::from_char('0'), Some(Keysym(0x0030))); + assert_eq!(Keysym::from_char(' '), Some(Keysym(0x0020))); + assert_eq!(Keysym::from_char('.'), Some(Keysym(0x002e))); + assert_eq!(Keysym::from_char('\n'), Some(Keysym(0xff0d))); + assert_eq!(Keysym::from_char('ñ'), None); + } + + #[test] + fn keysym_to_char() { + assert_eq!(Keysym(0x0061).to_char(), Some('a')); + assert_eq!(Keysym(0x007a).to_char(), Some('z')); + assert_eq!(Keysym(0x0041).to_char(), Some('A')); + assert_eq!(Keysym(0x0030).to_char(), Some('0')); + assert_eq!(Keysym(0x0020).to_char(), Some(' ')); + assert_eq!(Keysym(0xff0d).to_char(), Some('\n')); + assert_eq!(Keysym(0xffff).to_char(), None); + } + + #[test] + fn keysym_is_printable() { + assert!(Keysym(0x0061).is_printable()); // 'a' + assert!(Keysym(0x0020).is_printable()); // space + assert!(Keysym(0xff0d).is_printable()); // Return → '\n' + assert!(!Keysym(0xff08).is_printable()); // Backspace → '\x08' (not printable) + } + + #[test] + fn keysym_is_modifier() { + assert!(Keysym(0xffe1).is_modifier()); // shift + assert!(Keysym(0xffe3).is_modifier()); // ctrl + assert!(Keysym(0xffe9).is_modifier()); // alt + assert!(!Keysym(0x0061).is_modifier()); // 'a' + assert!(!Keysym(0x0020).is_modifier()); // space + } + + #[test] + fn im_context_activate_deactivate() { + let mut ctx = WaylandIMContext::new(); + assert_eq!(ctx.state(), IMState::Inactive); + + ctx.activate(); + assert_eq!(ctx.state(), IMState::Active); + + ctx.deactivate(); + assert_eq!(ctx.state(), IMState::Inactive); + } + + #[test] + fn im_context_preedit() { + let mut ctx = WaylandIMContext::new(); + assert!(ctx.preedit().is_none()); + + ctx.set_preedit(Some("hello".into()), 3); + assert_eq!(ctx.preedit(), Some("hello")); + + ctx.set_preedit(None, 0); + assert!(ctx.preedit().is_none()); + } + + #[test] + fn im_context_commit() { + let mut ctx = WaylandIMContext::new(); + ctx.commit("hello"); + ctx.commit(" "); + ctx.commit("world"); + assert_eq!(ctx.take_commit(), "hello world"); + assert!(ctx.take_commit().is_empty()); + } + + #[test] + fn keysym_to_char_no_modifiers() { + let ctx = WaylandIMContext::new(); + let mods = KeyModifiers::default(); + + assert_eq!(ctx.keysym_to_char(Keysym(0x0061), &mods), Some('a')); + assert_eq!(ctx.keysym_to_char(Keysym(0x007a), &mods), Some('z')); + assert_eq!(ctx.keysym_to_char(Keysym(0x0030), &mods), Some('0')); + assert_eq!(ctx.keysym_to_char(Keysym(0x0020), &mods), Some(' ')); + } + + #[test] + fn keysym_to_char_shift() { + let ctx = WaylandIMContext::new(); + let mods = KeyModifiers { + shift: true, + ..Default::default() + }; + + assert_eq!(ctx.keysym_to_char(Keysym(0x0061), &mods), Some('A')); + assert_eq!(ctx.keysym_to_char(Keysym(0x007a), &mods), Some('Z')); + assert_eq!(ctx.keysym_to_char(Keysym(0x0031), &mods), Some('!')); + assert_eq!(ctx.keysym_to_char(Keysym(0x0032), &mods), Some('@')); + } + + #[test] + fn keysym_to_char_modifier_returns_none() { + let ctx = WaylandIMContext::new(); + let mods = KeyModifiers::default(); + + assert_eq!(ctx.keysym_to_char(Keysym(0xffe1), &mods), None); // shift + assert_eq!(ctx.keysym_to_char(Keysym(0xffe3), &mods), None); // ctrl + } + + #[test] + fn process_keysym() { + let ctx = WaylandIMContext::new(); + let mods = KeyModifiers::default(); + + assert_eq!(ctx.process_keysym(Keysym(0x0061), &mods), Some('a')); + assert_eq!(ctx.process_keysym(Keysym(0xff0d), &mods), Some('\n')); + } + + #[test] + fn char_to_keysym_roundtrip() { + for ch in "abcdefghijklmnopqrstuvwxyz".chars() { + let keysym = WaylandIMContext::char_to_keysym(ch).unwrap(); + let back = keysym.to_char().unwrap(); + assert_eq!(ch, back); + } + for ch in "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars() { + let keysym = WaylandIMContext::char_to_keysym(ch).unwrap(); + let back = keysym.to_char().unwrap(); + assert_eq!(ch, back); + } + } +} diff --git a/protocol/src/x11_inject.rs b/protocol/src/x11_inject.rs new file mode 100644 index 0000000..1734042 --- /dev/null +++ b/protocol/src/x11_inject.rs @@ -0,0 +1,140 @@ +use super::inject::{InjectResult, KeyInjector}; + +// X11 keycodes for common ASCII characters +// These are Linux evdev keycodes (same as X11 for most keys) +fn char_to_keycode(ch: char) -> Option<(u32, bool)> { + match ch { + 'a' => Some((30, false)), 'b' => Some((48, false)), 'c' => Some((46, false)), + 'd' => Some((32, false)), 'e' => Some((18, false)), 'f' => Some((33, false)), + 'g' => Some((34, false)), 'h' => Some((35, false)), 'i' => Some((23, false)), + 'j' => Some((36, false)), 'k' => Some((37, false)), 'l' => Some((38, false)), + 'm' => Some((50, false)), 'n' => Some((49, false)), 'o' => Some((24, false)), + 'p' => Some((25, false)), 'q' => Some((16, false)), 'r' => Some((19, false)), + 's' => Some((31, false)), 't' => Some((20, false)), 'u' => Some((22, false)), + 'v' => Some((47, false)), 'w' => Some((17, false)), 'x' => Some((45, false)), + 'y' => Some((21, false)), 'z' => Some((44, false)), + 'A' => Some((30, true)), 'B' => Some((48, true)), 'C' => Some((46, true)), + 'D' => Some((32, true)), 'E' => Some((18, true)), 'F' => Some((33, true)), + 'G' => Some((34, true)), 'H' => Some((35, true)), 'I' => Some((23, true)), + 'J' => Some((36, true)), 'K' => Some((37, true)), 'L' => Some((38, true)), + 'M' => Some((50, true)), 'N' => Some((49, true)), 'O' => Some((24, true)), + 'P' => Some((25, true)), 'Q' => Some((16, true)), 'R' => Some((19, true)), + 'S' => Some((31, true)), 'T' => Some((20, true)), 'U' => Some((22, true)), + 'V' => Some((47, true)), 'W' => Some((17, true)), 'X' => Some((45, true)), + 'Y' => Some((21, true)), 'Z' => Some((44, true)), + '0' => Some((11, false)), '1' => Some((2, false)), '2' => Some((3, false)), + '3' => Some((4, false)), '4' => Some((5, false)), '5' => Some((6, false)), + '6' => Some((7, false)), '7' => Some((8, false)), '8' => Some((9, false)), + '9' => Some((10, false)), + ' ' => Some((57, false)), '.' => Some((52, false)), ',' => Some((51, false)), + '-' => Some((12, false)), '=' => Some((13, false)), ';' => Some((39, false)), + '\'' => Some((40, false)), '/' => Some((53, false)), '\\' => Some((43, false)), + '`' => Some((41, false)), '[' => Some((26, false)), ']' => Some((27, false)), + _ => None, + } +} + +/// X11 injection backend using XTEST extension +/// +/// Sends fake key events via XSendEvent/XTestFakeKeyEvent. +/// Works on X11 sessions. Falls back to uinput on Wayland. +pub struct X11Injector { + display: *mut xlib::Display, + #[allow(dead_code)] + window: xlib::Window, +} + +unsafe impl Send for X11Injector {} +unsafe impl Sync for X11Injector {} + +impl X11Injector { + pub fn new() -> Result> { + unsafe { + let display = xlib::XOpenDisplay(std::ptr::null()); + if display.is_null() { + return Err("Cannot open X11 display. Is DISPLAY set?".into()); + } + let window = xlib::XDefaultRootWindow(display); + Ok(Self { display, window }) + } + } + + fn send_keycode(&self, keycode: u32, shift: bool) { + unsafe { + if shift { + xlib::XTestFakeKeyEvent(self.display, 50, 1, 0); // Shift press + } + xlib::XTestFakeKeyEvent(self.display, keycode, 1, 0); // Key press + xlib::XTestFakeKeyEvent(self.display, keycode, 0, 0); // Key release + if shift { + xlib::XTestFakeKeyEvent(self.display, 50, 0, 0); // Shift release + } + xlib::XFlush(self.display); + } + } + + fn send_unicode_via_xdotool(&self, ch: char) { + // For Unicode chars, use xdotool type as fallback + let s = ch.to_string(); + let _ = std::process::Command::new("xdotool") + .args(["type", "--clearmodifiers", &s]) + .output(); + } +} + +impl KeyInjector for X11Injector { + fn send_backspace(&self) -> InjectResult { + self.send_keycode(14, false); // KEY_BACKSPACE + InjectResult::Success + } + + fn send_char(&self, ch: char) -> InjectResult { + if let Some((keycode, shift)) = char_to_keycode(ch) { + self.send_keycode(keycode, shift); + InjectResult::Success + } else { + // Unicode char - use xdotool + self.send_unicode_via_xdotool(ch); + InjectResult::Success + } + } + + fn send_string(&self, s: &str) -> InjectResult { + for ch in s.chars() { + self.send_char(ch); + } + InjectResult::Success + } + + fn flush(&self) -> InjectResult { + unsafe { xlib::XFlush(self.display); } + InjectResult::Success + } +} + +impl Drop for X11Injector { + fn drop(&mut self) { + unsafe { xlib::XCloseDisplay(self.display); } + } +} + +// Minimal Xlib/XTEST FFI +mod xlib { + use std::ffi::c_void; + + pub type Display = c_void; + pub type Window = u64; + + extern "C" { + pub fn XOpenDisplay(name: *const std::ffi::c_char) -> *mut Display; + pub fn XCloseDisplay(display: *mut Display) -> std::ffi::c_int; + pub fn XDefaultRootWindow(display: *mut Display) -> Window; + pub fn XFlush(display: *mut Display) -> std::ffi::c_int; + pub fn XTestFakeKeyEvent( + display: *mut Display, + keycode: u32, + state: std::ffi::c_int, + time: u64, + ) -> std::ffi::c_int; + } +} diff --git a/ui/Cargo.toml b/ui/Cargo.toml new file mode 100644 index 0000000..3f15ea8 --- /dev/null +++ b/ui/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "vietc-ui" +version = "0.1.0" +edition = "2021" +description = "Viet+ settings UI and tray icon (GTK4/Libadwaita)" + +[[bin]] +name = "vietc-settings" +path = "src/main.rs" + +[[bin]] +name = "vietc-tray" +path = "src/tray.rs" + +[dependencies] +vietc-engine = { path = "../engine" } +gtk = { package = "gtk4", version = "0.9", features = ["v4_12"], optional = true } +adw = { package = "libadwaita", version = "0.7", features = ["v1_4"], optional = true } +ksni = "0.2" +toml = "0.8" +serde = { version = "1", features = ["derive"] } +dirs = "5" + +[features] +default = ["ui"] +ui = ["dep:gtk", "dep:adw"] diff --git a/ui/data/window.ui b/ui/data/window.ui new file mode 100644 index 0000000..0eb6fda --- /dev/null +++ b/ui/data/window.ui @@ -0,0 +1,69 @@ + + + + + + diff --git a/ui/src/config.rs b/ui/src/config.rs new file mode 100644 index 0000000..bad6aca --- /dev/null +++ b/ui/src/config.rs @@ -0,0 +1,127 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutoRestoreConfig { + #[serde(default = "default_true")] + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppStateConfig { + #[serde(default = "default_true")] + pub enabled: bool, + + #[serde(default)] + pub english_apps: Vec, + + #[serde(default)] + pub vietnamese_apps: Vec, +} + +fn default_input_method() -> String { "telex".into() } +fn default_toggle_key() -> String { "space".into() } +fn default_start_enabled() -> bool { true } +fn default_true() -> bool { true } + +impl Default for Config { + fn default() -> Self { + let mut macros = HashMap::new(); + macros.insert("ko".into(), "không".into()); + macros.insert("dc".into(), "được".into()); + macros.insert("vs".into(), "với".into()); + macros.insert("lm".into(), "làm".into()); + + Self { + input_method: default_input_method(), + toggle_key: default_toggle_key(), + start_enabled: default_start_enabled(), + auto_restore: AutoRestoreConfig { enabled: true }, + app_state: AppStateConfig { + enabled: true, + english_apps: vec![ + "code".into(), "vim".into(), "nvim".into(), + "terminal".into(), "kitty".into(), "alacritty".into(), + ], + vietnamese_apps: vec![ + "telegram".into(), "discord".into(), "firefox".into(), + ], + }, + macros, + } + } +} + +impl Config { + pub fn load() -> Self { + for path in config_paths() { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(config) = toml::from_str::(&content) { + return config; + } + } + } + Self::default() + } + + pub fn save(&self) -> Result<(), Box> { + let path = config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let content = toml::to_string_pretty(self)?; + fs::write(&path, content)?; + Ok(()) + } + + pub fn path() -> PathBuf { + config_path() + } +} + +fn config_path() -> PathBuf { + config_paths() + .into_iter() + .find(|p| p.exists()) + .unwrap_or_else(|| { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("vietc") + .join("config.toml") + }) +} + +fn config_paths() -> Vec { + let mut paths = Vec::new(); + + if let Some(config_dir) = dirs::config_dir() { + paths.push(config_dir.join("vietc").join("config.toml")); + } + + paths.push(PathBuf::from("vietc.toml")); + + paths +} diff --git a/ui/src/main.rs b/ui/src/main.rs new file mode 100644 index 0000000..cd59fa4 --- /dev/null +++ b/ui/src/main.rs @@ -0,0 +1,21 @@ +use adw::prelude::*; +use gtk::{gio, glib}; + +mod config; +mod window; + +use window::SettingsWindow; + +fn main() -> glib::ExitCode { + let app = adw::Application::builder() + .application_id("io.github.vietc.Settings") + .flags(gio::ApplicationFlags::FLAGS_NONE) + .build(); + + app.connect_activate(|app| { + let window = SettingsWindow::new(app); + window.present(); + }); + + app.run() +} diff --git a/ui/src/tray.rs b/ui/src/tray.rs new file mode 100644 index 0000000..eb3e16e --- /dev/null +++ b/ui/src/tray.rs @@ -0,0 +1,37 @@ +use ksni::Tray; + +struct VietcTray; + +impl Tray for VietcTray { + fn id(&self) -> String { + "io.github.vietc.Tray".into() + } + + fn title(&self) -> String { + "Viet+".into() + } + + fn icon_name(&self) -> String { + "input-keyboard".into() + } + + fn menu(&self) -> ksni::Menu { + ksni::Menu { + items: vec![ + ksni::MenuItem::label("Toggle Vietnamese/English").into(), + ksni::MenuItem::separator().into(), + ksni::MenuItem::label("Settings...").into(), + ksni::MenuItem::separator().into(), + ksni::MenuItem::label("Quit Viet+").into(), + ], + } + } +} + +fn main() { + let service = ksni::TrayService::new(VietcTray); + service.spawn(); + loop { + std::thread::park(); + } +} diff --git a/ui/src/window.rs b/ui/src/window.rs new file mode 100644 index 0000000..2dabc93 --- /dev/null +++ b/ui/src/window.rs @@ -0,0 +1,647 @@ +use adw::prelude::*; +use adw::subclass::prelude::*; +use gtk::{gio, glib}; + +use crate::config::Config; + +mod imp { + use super::*; + use std::cell::RefCell; + + #[derive(Default)] + pub struct SettingsWindow { + pub dirty: RefCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for SettingsWindow { + const NAME: &'static str = "SettingsWindow"; + type Type = super::SettingsWindow; + type ParentType = adw::ApplicationWindow; + } + + impl ObjectImpl for SettingsWindow {} + impl WidgetImpl for SettingsWindow {} + impl WindowImpl for SettingsWindow {} + impl ApplicationWindowImpl for SettingsWindow {} + impl AdwApplicationWindowImpl for SettingsWindow {} +} + +glib::wrapper! { + pub struct SettingsWindow(ObjectSubclass) + @extends gio::ApplicationWindow, gtk::ApplicationWindow, + @implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable; +} + +impl SettingsWindow { + pub fn new(app: &adw::Application) -> Self { + let win: Self = glib::Object::builder() + .property("application", app) + .property("default-width", 580) + .property("default-height", 720) + .property("title", "Viet+ Settings") + .build(); + + win.build_ui(); + win + } + + fn mark_dirty(&self) { + *self.imp().dirty.borrow_mut() = true; + } + + fn build_ui(&self) { + let config = Config::load(); + + // Toast overlay for notifications + let toast_overlay = adw::ToastOverlay::new(); + + // Main box + let main_box = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + + // Header bar with title widget + let header = adw::HeaderBar::new(); + + let title_widget = adw::WindowTitle::builder() + .title("Viet+") + .subtitle("Vietnamese Input Method") + .build(); + header.set_title_widget(Some(&title_widget)); + + // Save button (suggested action) + let save_btn = gtk::Button::builder() + .label("Save") + .css_classes(["suggested-action"]) + .tooltip_text("Save settings (Ctrl+S)") + .build(); + header.add_end(&save_btn); + + // Keyboard shortcut for save + let controller = gtk::EventControllerKey::new(); + let save_ref = save_btn.clone(); + controller.connect_key_pressed(move |_, key, _, modifiers| { + if modifiers.contains(gtk::gdk::ModifierType::CONTROL_MASK) + && key == gtk::gdk::Key::s + { + save_ref.emit_clicked(); + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + }); + self.add_controller(controller); + + main_box.append(&header); + + // Scrollable content area + let scrolled = gtk::ScrolledWindow::builder() + .vexpand(true) + .hscrollbar_policy(gtk::PolicyType::Never) + .build(); + + let clamp = adw::Clamp::builder() + .maximum_size(540) + .tightening_threshold(400) + .build(); + + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .margin_top(8) + .margin_bottom(16) + .margin_start(16) + .margin_end(16) + .build(); + + // ========== Input Method Section ========== + let method_group = adw::PreferencesGroup::builder() + .title("Input Method") + .description("Select your preferred Vietnamese typing method") + .build(); + + let method_row = adw::ComboRow::builder() + .title("Keyboard Layout") + .subtitle("Telex uses letters (aa=ă, ee=ê), VNI uses digits (a6=ă, e8=ê)") + .model(>k::StringList::new(&["Telex (Recommended)", "VNI"])) + .selected(if config.input_method == "vni" { 1 } else { 0 }) + .build(); + + let toggle_row = adw::ComboRow::builder() + .title("Toggle Key") + .subtitle("Switch between Vietnamese and English input") + .model(>k::StringList::new(&[ + "Ctrl + Space", + "Ctrl + Shift", + "Caps Lock", + ])) + .selected(match config.toggle_key.as_str() { + "shift" => 1, + "capslock" => 2, + _ => 0, + }) + .build(); + + method_group.add(&method_row); + method_group.add(&toggle_row); + content.append(&method_group); + + // ========== General Section ========== + let general_group = adw::PreferencesGroup::builder() + .title("General") + .build(); + + let start_enabled_row = adw::SwitchRow::builder() + .title("Start Enabled") + .subtitle("Enable Vietnamese input on startup") + .active(config.start_enabled) + .build(); + + let app_memory_row = adw::SwitchRow::builder() + .title("App Memory") + .subtitle("Remember per-app Vietnamese/English state") + .active(config.app_state.enabled) + .build(); + + let auto_restore_row = adw::SwitchRow::builder() + .title("Auto Restore English") + .subtitle("Automatically restore common English words") + .active(config.auto_restore.enabled) + .build(); + + general_group.add(&start_enabled_row); + general_group.add(&app_memory_row); + general_group.add(&auto_restore_row); + content.append(&general_group); + + // ========== App Lists Section ========== + let apps_group = adw::PreferencesGroup::builder() + .title("Application Lists") + .description("Override input method for specific applications") + .build(); + + // English apps + let english_list = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .build(); + + for app in &config.app_state.english_apps { + english_list.append(&Self::make_app_row_static(app, &english_list)); + } + + let english_entry = gtk::SearchEntry::builder() + .placeholder_text("Add application name...") + .hexpand(true) + .show_close_icon(false) + .build(); + + let english_add = gtk::Button::builder() + .icon_name("list-add-symbolic") + .css_classes(["flat", "accent"]) + .tooltip_text("Add application") + .build(); + + let english_input = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(4) + .build(); + english_input.append(&english_entry); + english_input.append(&english_add); + + let english_header = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .build(); + let english_label = gtk::Label::builder() + .label("English Mode (Telex disabled)") + .halign(gtk::Align::Start) + .css_classes(["heading", "dim-label"]) + .build(); + english_header.append(&english_label); + english_header.append(&english_list); + english_header.append(&english_input); + + let english_row = adw::ActionRow::builder() + .title("English Applications") + .activatable(false) + .build(); + english_row.add_suffix(&english_header); + apps_group.add(&english_row); + + // Vietnamese apps + let viet_list = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .build(); + + for app in &config.app_state.vietnamese_apps { + viet_list.append(&Self::make_app_row_static(app, &viet_list)); + } + + let viet_entry = gtk::SearchEntry::builder() + .placeholder_text("Add application name...") + .hexpand(true) + .show_close_icon(false) + .build(); + + let viet_add = gtk::Button::builder() + .icon_name("list-add-symbolic") + .css_classes(["flat", "accent"]) + .tooltip_text("Add application") + .build(); + + let viet_input = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(4) + .build(); + viet_input.append(&viet_entry); + viet_input.append(&viet_add); + + let viet_header = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .build(); + let viet_label = gtk::Label::builder() + .label("Vietnamese Mode (Telex enabled)") + .halign(gtk::Align::Start) + .css_classes(["heading", "dim-label"]) + .build(); + viet_header.append(&viet_label); + viet_header.append(&viet_list); + viet_header.append(&viet_input); + + let viet_row = adw::ActionRow::builder() + .title("Vietnamese Applications") + .activatable(false) + .build(); + viet_row.add_suffix(&viet_header); + apps_group.add(&viet_row); + + content.append(&apps_group); + + // ========== Macros Section ========== + let macros_group = adw::PreferencesGroup::builder() + .title("Macros") + .description("Type shortcuts that expand to Vietnamese phrases") + .build(); + + let macros_list = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .build(); + + for (shortcut, expansion) in &config.macros { + macros_list.append(&Self::make_macro_row_static(shortcut, expansion, ¯os_list)); + } + + let macro_shortcut = gtk::SearchEntry::builder() + .placeholder_text("ko") + .width_chars(8) + .build(); + + let macro_expansion = gtk::SearchEntry::builder() + .placeholder_text("không") + .hexpand(true) + .build(); + + let macro_add = gtk::Button::builder() + .icon_name("list-add-symbolic") + .css_classes(["flat", "accent"]) + .tooltip_text("Add macro") + .build(); + + let macro_input = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(4) + .build(); + macro_input.append(¯o_shortcut); + macro_input.append(>k::Label::builder().label("→").css_classes(["dim-label"]).build()); + macro_input.append(¯o_expansion); + macro_input.append(¯o_add); + + macros_group.add(¯os_list); + macros_group.add(¯o_input); + content.append(¯os_group); + + // ========== Reference Card ========== + let ref_group = adw::PreferencesGroup::builder() + .title("Quick Reference") + .build(); + + let ref_row = adw::ActionRow::builder() + .title("Common Shortcuts") + .subtitle("ko→không, dc→được, vs→với, lm→làm") + .activatable(false) + .build(); + + let ref_icon = gtk::Image::builder() + .icon_name("dialog-information-symbolic") + .tooltip_text("Type these shortcuts followed by space") + .build(); + ref_row.add_suffix(&ref_icon); + + ref_group.add(&ref_row); + content.append(&ref_group); + + // ========== Status Bar ========== + let status_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .margin_top(8) + .build(); + + let status_icon = gtk::Image::builder() + .icon_name("emblem-ok-symbolic") + .build(); + + let status_label = gtk::Label::builder() + .label("Ready") + .hexpand(true) + .halign(gtk::Align::Start) + .css_classes(["dim-label"]) + .build(); + + status_box.append(&status_icon); + status_box.append(&status_label); + + clamp.set_child(Some(&content)); + scrolled.set_child(Some(&clamp)); + main_box.append(&scrolled); + main_box.append(&status_box); + + toast_overlay.set_child(Some(&main_box)); + self.set_content(Some(&toast_overlay)); + + // ========== Callbacks ========== + + // Mark dirty on any change + { + let win = self.clone(); + method_row.connect_selected_notify(move |_| { win.mark_dirty(); }); + } + { + let win = self.clone(); + toggle_row.connect_selected_notify(move |_| { win.mark_dirty(); }); + } + { + let win = self.clone(); + start_enabled_row.connect_active_notify(move |_| { win.mark_dirty(); }); + } + { + let win = self.clone(); + app_memory_row.connect_active_notify(move |_| { win.mark_dirty(); }); + } + { + let win = self.clone(); + auto_restore_row.connect_active_notify(move |_| { win.mark_dirty(); }); + } + + // Add English app + self.setup_add_app(&english_entry, &english_add, &english_list, &status_label); + + // Add Vietnamese app + self.setup_add_app(&viet_entry, &viet_add, &viet_list, &status_label); + + // Add macro + self.setup_add_macro(¯o_shortcut, ¯o_expansion, ¯o_add, ¯os_list, &status_label); + + // Save button + { + let method_row = method_row.clone(); + let toggle_row = toggle_row.clone(); + let start_switch = start_enabled_row.clone(); + let app_switch = app_memory_row.clone(); + let auto_switch = auto_restore_row.clone(); + let english = english_list.clone(); + let viet = viet_list.clone(); + let macros = macros_list.clone(); + let status_label = status_label.clone(); + let status_icon = status_icon.clone(); + let toast_overlay = toast_overlay.clone(); + let win = self.clone(); + + save_btn.connect_clicked(move |_| { + let method = match method_row.selected() { + 1 => "vni", + _ => "telex", + }; + let toggle = match toggle_row.selected() { + 1 => "shift", + 2 => "capslock", + _ => "space", + }; + + let english_apps = Self::collect_app_names(&english); + let vietnamese_apps = Self::collect_app_names(&viet); + let macro_map = Self::collect_macros(¯os); + + let config = Config { + input_method: method.into(), + toggle_key: toggle.into(), + start_enabled: start_switch.is_active(), + auto_restore: crate::config::AutoRestoreConfig { + enabled: auto_switch.is_active(), + }, + app_state: crate::config::AppStateConfig { + enabled: app_switch.is_active(), + english_apps, + vietnamese_apps, + }, + macros: macro_map, + }; + + match config.save() { + Ok(()) => { + status_label.set_text(&format!("Saved to {}", Config::path().display())); + status_icon.set_icon_name(Some("emblem-ok-symbolic")); + status_label.remove_css_class("error"); + status_label.add_css_class("dim-label"); + + *win.imp().dirty.borrow_mut() = false; + + let toast = adw::Toast::new("Settings saved"); + toast.set_timeout(2); + toast_overlay.add_toast(toast); + } + Err(e) => { + status_label.set_text(&format!("Error: {}", e)); + status_icon.set_icon_name(Some("dialog-error-symbolic")); + status_label.remove_css_class("dim-label"); + status_label.add_css_class("error"); + + let toast = adw::Toast::new(&format!("Save failed: {}", e)); + toast.set_timeout(3); + toast_overlay.add_toast(toast); + } + } + }); + } + } + + fn setup_add_app( + &self, + entry: >k::SearchEntry, + add_btn: >k::Button, + list: >k::ListBox, + status: >k::Label, + ) { + let add_fn = { + let list = list.clone(); + let entry = entry.clone(); + let status = status.clone(); + let win = self.clone(); + move || { + let text = entry.text().to_string(); + if !text.is_empty() { + let row = Self::make_app_row_static(&text, &list); + list.append(&row); + entry.set_text(""); + status.set_text("Unsaved changes"); + status.set_icon_name("dialog-information-symbolic"); + win.mark_dirty(); + } + } + }; + + let add_fn2 = add_fn.clone(); + add_btn.connect_clicked(move |_| add_fn2()); + + let add_fn3 = add_fn.clone(); + entry.connect_activate(move |_| add_fn3()); + } + + fn setup_add_macro( + &self, + shortcut: >k::SearchEntry, + expansion: >k::SearchEntry, + add_btn: >k::Button, + list: >k::ListBox, + status: >k::Label, + ) { + let add_fn = { + let list = list.clone(); + let shortcut = shortcut.clone(); + let expansion = expansion.clone(); + let status = status.clone(); + let win = self.clone(); + move || { + let s = shortcut.text().to_string(); + let e = expansion.text().to_string(); + if !s.is_empty() && !e.is_empty() { + let row = Self::make_macro_row_static(&s, &e, &list); + list.append(&row); + shortcut.set_text(""); + expansion.set_text(""); + status.set_text("Unsaved changes"); + status.set_icon_name("dialog-information-symbolic"); + win.mark_dirty(); + } + } + }; + + let add_fn2 = add_fn.clone(); + add_btn.connect_clicked(move |_| add_fn2()); + + let add_fn3 = add_fn.clone(); + expansion.connect_activate(move |_| add_fn3()); + } + + fn make_app_row_static(app: &str, list: >k::ListBox) -> adw::ActionRow { + let row = adw::ActionRow::builder() + .title(app) + .activatable(false) + .build(); + + let remove_btn = gtk::Button::builder() + .icon_name("user-trash-symbolic") + .css_classes(["flat", "destructive-action"]) + .tooltip_text("Remove") + .build(); + + let list_ref = list.clone(); + let app_name = app.to_string(); + remove_btn.connect_clicked(move |_| { + let mut i = 0; + while let Some(child) = list_ref.row_at_index(i) { + if let Some(row) = child.downcast_ref::() { + if row.title() == app_name { + list_ref.remove(&child); + return; + } + } + i += 1; + } + }); + + row.add_suffix(&remove_btn); + row + } + + fn make_macro_row_static(shortcut: &str, expansion: &str, list: >k::ListBox) -> adw::ActionRow { + let row = adw::ActionRow::builder() + .title(shortcut) + .subtitle(expansion) + .activatable(false) + .build(); + + let arrow = gtk::Label::builder() + .label("→") + .css_classes(["dim-label"]) + .build(); + row.add_prefix(&arrow); + + let remove_btn = gtk::Button::builder() + .icon_name("user-trash-symbolic") + .css_classes(["flat", "destructive-action"]) + .tooltip_text("Remove") + .build(); + + let list_ref = list.clone(); + let shortcut_name = shortcut.to_string(); + remove_btn.connect_clicked(move |_| { + let mut i = 0; + while let Some(child) = list_ref.row_at_index(i) { + if let Some(row) = child.downcast_ref::() { + if row.title() == shortcut_name { + list_ref.remove(&child); + return; + } + } + i += 1; + } + }); + + row.add_suffix(&remove_btn); + row + } + + fn collect_app_names(list: >k::ListBox) -> Vec { + let mut names = Vec::new(); + let mut i = 0; + while let Some(child) = list.row_at_index(i) { + if let Some(row) = child.downcast_ref::() { + names.push(row.title().to_string()); + } + i += 1; + } + names + } + + fn collect_macros(list: >k::ListBox) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + let mut i = 0; + while let Some(child) = list.row_at_index(i) { + if let Some(row) = child.downcast_ref::() { + let shortcut = row.title().to_string(); + let expansion = row.subtitle().unwrap_or_default().to_string(); + if !shortcut.is_empty() { + map.insert(shortcut, expansion); + } + } + i += 1; + } + map + } +} diff --git a/vietc.service b/vietc.service new file mode 100644 index 0000000..2aa1594 --- /dev/null +++ b/vietc.service @@ -0,0 +1,11 @@ +[Unit] +Description=Viet+ Vietnamese IME Daemon + +[Service] +Type=simple +ExecStart=/usr/local/bin/vietc +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/vietc.toml b/vietc.toml new file mode 100644 index 0000000..069de79 --- /dev/null +++ b/vietc.toml @@ -0,0 +1,31 @@ +# Viet+ IME Configuration + +input_method = "telex" +toggle_key = "space" +start_enabled = true + +[auto_restore] +enabled = true +trigger_keys = ["space", "escape"] + +[app_state] +enabled = true +english_apps = [ + "code", "jetbrains", "intellij", "pycharm", "webstorm", + "vim", "nvim", "kitty", "alacritty", "foot", "ghostty", +] +vietnamese_apps = [ + "telegram", "discord", "slack", "firefox", "chromium", "thunderbird", +] + +[macros] +ko = "không" +kc = "không có" +"ko dc" = "không được" +dc = "được" +ng = "người" +nk = "như" +vs = "với" +lm = "làm" +rd = "rất" +bt = "biết"