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
This commit is contained in:
vndangkhoa 2026-06-24 10:13:10 +07:00
commit 16a0d73a6e
44 changed files with 5871 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
/target
/ui/target
Cargo.lock
*.swp
*.swo
*~
.vscode/
.idea/
*.deb
*.AppImage
packaging/appimage/AppDir/
packaging/deb/vietc_*/

8
Cargo.toml Normal file
View file

@ -0,0 +1,8 @@
[workspace]
resolver = "2"
members = ["engine", "protocol", "daemon", "cli"]
exclude = ["ui"]
[workspace.dependencies]
vietc-engine = { path = "engine" }
vietc-protocol = { path = "protocol" }

21
LICENSE Normal file
View file

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

119
Makefile Normal file
View file

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

322
README.md Normal file
View file

@ -0,0 +1,322 @@
<p align="center">
<img src="https://img.shields.io/badge/Platform-Linux-blue?style=for-the-badge" alt="Platform">
<img src="https://img.shields.io/badge/Language-Rust-orange?style=for-the-badge" alt="Rust">
<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License">
<img src="https://img.shields.io/badge/Version-0.1.0-purple?style=for-the-badge" alt="Version">
</p>
<h1 align="center">
<br>
Viet+
<br>
</h1>
<p align="center">
<b>Vietnamese Input Method for Linux</b><br>
<sub>Zero underline &bull; Native Wayland/X11 &bull; Built in Rust</sub>
</p>
<p align="center">
<a href="#features">Features</a> &bull;
<a href="#quick-start">Quick Start</a> &bull;
<a href="#input-methods">Input Methods</a> &bull;
<a href="#configuration">Configuration</a> &bull;
<a href="#installation">Installation</a> &bull;
<a href="#building">Building</a>
</p>
---
## 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.
---
<p align="center">
<sub>Made with ❤️ for the Vietnamese Linux community</sub>
</p>

8
cli/Cargo.toml Normal file
View file

@ -0,0 +1,8 @@
[package]
name = "vietc-cli"
version = "0.1.0"
edition = "2021"
description = "Viet+ CLI Test Harness"
[dependencies]
vietc-engine = { path = "../engine" }

106
cli/src/main.rs Normal file
View file

@ -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);
}
}

21
daemon/Cargo.toml Normal file
View file

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

215
daemon/src/app_state.rs Normal file
View file

@ -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<String> {
// 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<String> {
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<String> {
// 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<String> {
// 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<String, bool>,
/// Default English apps from config
english_apps: Vec<String>,
/// Default Vietnamese apps from config
vietnamese_apps: Vec<String>,
/// Global enabled state
global_enabled: bool,
}
impl AppStateManager {
pub fn new(
english_apps: Vec<String>,
vietnamese_apps: Vec<String>,
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<bool> {
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<String>, vietnamese_apps: Vec<String>) {
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<dyn std::error::Error>> {
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::<HashMap<String, bool>>(&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")
}

329
daemon/src/config.rs Normal file
View file

@ -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<String, String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct AutoRestoreConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_restore_keys")]
pub trigger_keys: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct AppStateConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub english_apps: Vec<String>,
#[serde(default)]
pub vietnamese_apps: Vec<String>,
}
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<String> { vec!["space".into(), "escape".into()] }
fn default_english_apps() -> Vec<String> {
vec![
"code".into(),
"jetbrains".into(),
"intellij".into(),
"pycharm".into(),
"webstorm".into(),
"vim".into(),
"nvim".into(),
"terminal".into(),
"kitty".into(),
"alacritty".into(),
"foot".into(),
]
}
fn default_vietnamese_apps() -> Vec<String> {
vec![
"telegram".into(),
"discord".into(),
"slack".into(),
"firefox".into(),
"chromium".into(),
"thunderbird".into(),
]
}
impl Config {
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
let paths = [
dirs().map(|d| d.join("vietc").join("config.toml")),
Some(PathBuf::from("vietc.toml")),
];
for path in paths.into_iter().flatten() {
if path.exists() {
let content = fs::read_to_string(&path)?;
let config: Config = toml::from_str(&content)?;
eprintln!("[vietc] Loaded config from: {}", path.display());
return Ok(config);
}
}
eprintln!("[vietc] Using default config");
Ok(Self::default())
}
pub fn load_from(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
let content = fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}
}
impl Default for Config {
fn default() -> Self {
let mut macros = HashMap::new();
macros.insert("ko".into(), "không".into());
macros.insert("kc".into(), "không có".into());
macros.insert("ko dc".into(), "không được".into());
macros.insert("dc".into(), "được".into());
macros.insert("ng".into(), "người".into());
macros.insert("nk".into(), "như".into());
macros.insert("vs".into(), "với".into());
macros.insert("lm".into(), "làm".into());
macros.insert("rd".into(), "rất".into());
macros.insert("bt".into(), "biết".into());
Self {
input_method: default_input_method(),
toggle_key: default_toggle_key(),
start_enabled: default_start_enabled(),
auto_restore: AutoRestoreConfig::default(),
app_state: AppStateConfig::default(),
macros,
}
}
}
fn dirs() -> Option<PathBuf> {
std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".config"))
})
}
pub fn find_config_path() -> PathBuf {
let paths = [
dirs().map(|d| d.join("vietc").join("config.toml")),
Some(PathBuf::from("vietc.toml")),
];
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::<Config>(toml);
assert!(result.is_err());
}
#[test]
fn parse_unknown_fields_ignored() {
let toml = r#"
input_method = "telex"
unknown_field = "value"
"#;
// serde's default deny_unknown_fields is not set, so this should work
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.input_method, "telex");
}
}

88
daemon/src/display.rs Normal file
View file

@ -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<String> {
// 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
}

402
daemon/src/main.rs Normal file
View file

@ -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<OutputCommand> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn vietc_protocol::KeyInjector>, Box<dyn std::error::Error>> {
// 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<evdev::AttributeSet<evdev::Key>>, 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<char> {
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,
}
}

9
engine/Cargo.toml Normal file
View file

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

298
engine/src/engine.rs Normal file
View file

@ -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<String, String>,
}
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<EngineEvent> {
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<EngineEvent> {
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<EngineEvent> {
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<EngineEvent> {
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<EngineEvent> {
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"));
}
}

97
engine/src/english.rs Normal file
View file

@ -0,0 +1,97 @@
use std::collections::HashSet;
pub struct EnglishDict {
/// Common English words that shouldn't be converted to Vietnamese
words: HashSet<String>,
/// Words that are definitely Vietnamese (even if they look like English)
vietnamese_overrides: HashSet<String>,
}
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);
}
}

11
engine/src/lib.rs Normal file
View file

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

260
engine/src/telex.rs Normal file
View file

@ -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<char> {
// 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<char> {
// 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<char>,
}
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<EngineEvent> {
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<EngineEvent> {
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<EngineEvent> {
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<EngineEvent> {
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<char> = 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::<String>();
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::<String>();
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<EngineEvent> {
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<EngineEvent> {
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<EngineEvent> {
// dd → đ digraph
if ch == 'd' {
if let Some(last_ch) = self.buffer.chars().last() {
if last_ch == 'd' {
let chars: Vec<char> = 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
}
}

1092
engine/src/tests.rs Normal file

File diff suppressed because it is too large Load diff

152
engine/src/vni.rs Normal file
View file

@ -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<char> {
// 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<char> {
// 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<char>,
}
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<EngineEvent> {
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<EngineEvent> {
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<EngineEvent> {
// 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);
}
}
}
}
}
}

View file

@ -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'
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<rect x="20" y="60" width="216" height="140" rx="16" fill="#2d2d2d" stroke="#1a1a1a" stroke-width="4"/>
<rect x="36" y="76" width="184" height="108" rx="8" fill="#3d3d3d"/>
<rect x="48" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="78" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="108" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="138" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="168" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="198" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="54" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="84" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="114" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="144" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="174" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="60" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="90" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="120" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="150" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="180" y="140" width="42" height="20" rx="3" fill="#f0f0f0"/>
<rect x="72" y="166" width="112" height="16" rx="3" fill="#f0f0f0"/>
<circle cx="216" cy="48" r="28" fill="#da251d"/>
<text x="216" y="56" text-anchor="middle" fill="white" font-size="18" font-weight="bold" font-family="sans-serif">VN</text>
</svg>
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"

View file

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

35
packaging/aur/PKGBUILD Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

123
packaging/deb/build-deb.sh Normal file
View file

@ -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'
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<rect x="20" y="60" width="216" height="140" rx="16" fill="#2d2d2d" stroke="#1a1a1a" stroke-width="4"/>
<rect x="36" y="76" width="184" height="108" rx="8" fill="#3d3d3d"/>
<rect x="48" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="78" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="108" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="138" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="168" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="198" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="54" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="84" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="114" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="144" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="174" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="60" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="90" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="120" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="150" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
<rect x="180" y="140" width="42" height="20" rx="3" fill="#f0f0f0"/>
<rect x="72" y="166" width="112" height="16" rx="3" fill="#f0f0f0"/>
<circle cx="216" cy="48" r="28" fill="#da251d"/>
<text x="216" y="56" text-anchor="middle" fill="white" font-size="18" font-weight="bold" font-family="sans-serif">VN</text>
</svg>
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"

View file

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

18
protocol/Cargo.toml Normal file
View file

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

10
protocol/build.rs Normal file
View file

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

74
protocol/src/inject.rs Normal file
View file

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

10
protocol/src/lib.rs Normal file
View file

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

8
protocol/src/monitor.rs Normal file
View file

@ -0,0 +1,8 @@
use crate::inject::KeyEvent;
pub trait KeyMonitor {
fn grab(&self) -> Result<(), Box<dyn std::error::Error>>;
fn ungrab(&self) -> Result<(), Box<dyn std::error::Error>>;
fn read_key(&self) -> Result<KeyEvent, Box<dyn std::error::Error>>;
fn is_active(&self) -> bool;
}

View file

@ -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<Self, Box<dyn std::error::Error>> {
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::<input_event>();
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<u16> {
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<i32, Box<dyn std::error::Error>> {
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,
}

428
protocol/src/wayland_im.rs Normal file
View file

@ -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<Keysym> {
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<char> {
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<String>,
cursor_pos: usize,
commit_buffer: String,
keysym_map: HashMap<u32, char>,
}
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<u32, char> {
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<String>, 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<char> {
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> {
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<char> {
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<String>,
}
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<String> {
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);
}
}
}

140
protocol/src/x11_inject.rs Normal file
View file

@ -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<Self, Box<dyn std::error::Error>> {
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;
}
}

26
ui/Cargo.toml Normal file
View file

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

69
ui/data/window.ui Normal file
View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.0"/>
<template class="VietTuxWindow" class="AdwApplicationWindow" parent="AdwApplicationWindow">
<property name="default-width">600</property>
<property name="default-height">700</property>
<property name="title">VietTux Settings</property>
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<child type="end">
<object class="GtkButton" id="save_button">
<property name="label">Save</property>
<property name="css_classes">suggested-action</property>
</object>
</child>
</object>
</child>
<child>
<object class="Adwclamp">
<property name="maximum-size">600</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="vexpand">true</property>
<property name="child">
<object class="AdwPreferencesGroup">
<property name="title">Input Method</property>
<child>
<object class="AdwComboRow" id="method_row">
<property name="title">Keyboard Layout</property>
<property name="subtitle">Choose Telex or VNI input method</property>
<property name="model">
<object class="GtkStringList">
<items>
<item>Telex</item>
<item>VNI</item>
</items>
</object>
</property>
</object>
</child>
<child>
<object class="AdwComboRow" id="toggle_row">
<property name="title">Toggle Key</property>
<property name="subtitle">Key combination to toggle Vietnamese mode</property>
<property name="model">
<object class="GtkStringList">
<items>
<item>Ctrl + Space</item>
<item>Ctrl + Shift</item>
<item>Caps Lock</item>
</items>
</object>
</property>
</object>
</child>
</object>
</property>
</object>
</property>
</object>
</child>
</object>
</property>
</template>
</interface>

127
ui/src/config.rs Normal file
View file

@ -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<String, String>,
}
#[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<String>,
#[serde(default)]
pub vietnamese_apps: Vec<String>,
}
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::<Config>(&content) {
return config;
}
}
}
Self::default()
}
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
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<PathBuf> {
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
}

21
ui/src/main.rs Normal file
View file

@ -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()
}

37
ui/src/tray.rs Normal file
View file

@ -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();
}
}

647
ui/src/window.rs Normal file
View file

@ -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<bool>,
}
#[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<imp::SettingsWindow>)
@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(&gtk::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(&gtk::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, &macros_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(&macro_shortcut);
macro_input.append(&gtk::Label::builder().label("").css_classes(["dim-label"]).build());
macro_input.append(&macro_expansion);
macro_input.append(&macro_add);
macros_group.add(&macros_list);
macros_group.add(&macro_input);
content.append(&macros_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(&macro_shortcut, &macro_expansion, &macro_add, &macros_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(&macros);
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: &gtk::SearchEntry,
add_btn: &gtk::Button,
list: &gtk::ListBox,
status: &gtk::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: &gtk::SearchEntry,
expansion: &gtk::SearchEntry,
add_btn: &gtk::Button,
list: &gtk::ListBox,
status: &gtk::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: &gtk::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::<adw::ActionRow>() {
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: &gtk::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::<adw::ActionRow>() {
if row.title() == shortcut_name {
list_ref.remove(&child);
return;
}
}
i += 1;
}
});
row.add_suffix(&remove_btn);
row
}
fn collect_app_names(list: &gtk::ListBox) -> Vec<String> {
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::<adw::ActionRow>() {
names.push(row.title().to_string());
}
i += 1;
}
names
}
fn collect_macros(list: &gtk::ListBox) -> std::collections::HashMap<String, String> {
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::<adw::ActionRow>() {
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
}
}

11
vietc.service Normal file
View file

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

31
vietc.toml Normal file
View file

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