Viet+ v0.1.1

- Flexible diacritic placement: modifiers/tone marks at end of syllable
  (Telex: tranaf -> tran, VNI: tran62 -> tran)
- uinput injector as primary backend (avoids X11/Unicode ordering bugs)
- ydotool for atomic backspace+text injection (same uinput device)
- Fix run_as_user to use explicit 'env VAR=val' (sudo compat)
- Remove deb/flatpak/aur packaging (AppImage only)
- Fix Telex key mappings (aa=aa, aw=a, ow=o)
- Fix VNI key mappings (a6=aa, a8=a, e6=e, o6=o, o7=o, u7=u)
- Fix X11Injector ydotool fallback for Unicode chars
- 162+ engine tests passing
This commit is contained in:
vndangkhoa 2026-06-24 17:29:12 +07:00
parent 16a0d73a6e
commit 95f661aaa0
32 changed files with 2170 additions and 738 deletions

1
.gitignore vendored
View file

@ -10,3 +10,4 @@ Cargo.lock
*.AppImage *.AppImage
packaging/appimage/AppDir/ packaging/appimage/AppDir/
packaging/deb/vietc_*/ packaging/deb/vietc_*/
packaging/appimage/appimagetool

View file

@ -1,4 +1,4 @@
.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 .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 fmt lint tree
# Build core crates # Build core crates
build: build:
@ -89,20 +89,15 @@ install-config:
@echo "Config installed to ~/.config/vietc/config.toml" @echo "Config installed to ~/.config/vietc/config.toml"
# Build AppImage (requires appimagetool or linuxdeploy) # Build AppImage (requires appimagetool or linuxdeploy)
appimage: build-all appimage:
VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \ VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \
bash packaging/appimage/build-appimage.sh "$$VERSION" 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 build artifacts
clean: clean:
cargo clean cargo clean
cd ui && cargo clean cd ui && cargo clean
rm -rf packaging/appimage/AppDir packaging/appimage/*.AppImage packaging/deb/vietc_* rm -rf packaging/appimage/AppDir packaging/appimage/*.AppImage
# Format code # Format code
fmt: fmt:

115
README.md
View file

@ -33,8 +33,6 @@ Most Vietnamese input methods on Linux suffer from **underline hell** — pre-ed
> **Direct Input** — keystrokes are instantly converted to Unicode. No pre-edit buffer. No underline. No text duplication. Just pure Vietnamese. > **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 ## Features
@ -43,14 +41,13 @@ Inspired by [Gõ Nhanh](https://github.com/nickel-lang/nickel)'s brilliant UX, r
|---------|-------------| |---------|-------------|
| **Direct Input Engine** | No pre-edit buffer, no underline, no text duplication | | **Direct Input Engine** | No pre-edit buffer, no underline, no text duplication |
| **Telex & VNI** | Both input methods fully supported | | **Telex & VNI** | Both input methods fully supported |
| **Flexible Diacritic Placement** | Type modifiers/tone marks at end of syllable (e.g., `tranaf``trần`) |
| **Auto-Restore English** | Hit space/ESC to undo accidental Vietnamese conversion | | **Auto-Restore English** | Hit space/ESC to undo accidental Vietnamese conversion |
| **ESC Undo** | Strip all tones from the current word instantly | | **ESC Undo** | Strip all tones from the current word instantly |
| **Smart App Memory** | Remembers Vietnamese/English per application | | **Smart App Memory** | Remembers Vietnamese/English per application |
| **Macro Expansion** | Custom shortcuts (e.g., `ko``không`) | | **Macro Expansion** | Custom shortcuts (e.g., `ko``không`) |
| **Triple Backend** | uinput (universal), X11 XTEST, Wayland zwp_input_method_v2 | | **Uinput Injection** | Direct uinput keystroke injection (no display server needed) |
| **Hot Reload** | Config changes apply without restart | | **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 | | **Zero Telemetry** | No keylogging, no disk writes, fully FOSS |
--- ---
@ -59,18 +56,18 @@ Inspired by [Gõ Nhanh](https://github.com/nickel-lang/nickel)'s brilliant UX, r
```bash ```bash
# Clone and build # Clone and build
git clone https://github.com/vietplus/vietplus.git git clone https://git.khoavo.myds.me/vndangkhoa/vietc.git
cd vietplus cd vietc
make build make build
# Test the engine interactively # Test the engine interactively
make test-cli cargo run --bin vietc-cli
# Run the daemon (requires root for evdev/uinput) # Run the daemon (requires root for keyboard grab + uinput)
sudo make run sudo make run
# Or install system-wide # Or use the AppImage
sudo make install sudo ./Viet+-0.1.0-x86_64.AppImage
``` ```
--- ---
@ -81,13 +78,13 @@ sudo make install
| Key | Result | Example | | Key | Result | Example |
|-----|--------|---------| |-----|--------|---------|
| `aa` | ă | `dan``dăn` | | `aa` | â | `tan``tân` |
| `aw` | ă | `tan``tăn` |
| `ee` | ê | `men``mên` | | `ee` | ê | `men``mên` |
| `oo` | ô | `to``tô` | | `oo` | ô | `to``tô` |
| `aw` | â | `an``ân` | | `ow` | ơ | `to``tơ` |
| `ow` | ô | `on``ôn` |
| `ew` | ê | `en``ên` | | `ew` | ê | `en``ên` |
| `uw` | ư | `un` → `ưn` | | `uw` | ư | `tu` → `tư` |
| `s` | á (sắc) | `as``á` | | `s` | á (sắc) | `as``á` |
| `f` | à (huyền) | `af``à` | | `f` | à (huyền) | `af``à` |
| `r` | ả (hỏi) | `ar``ả` | | `r` | ả (hỏi) | `ar``ả` |
@ -104,12 +101,12 @@ sudo make install
| `a3` | ả | | `a3` | ả |
| `a4` | ã | | `a4` | ã |
| `a5` | ạ | | `a5` | ạ |
| `a6` | ă | | `a6` | â |
| `a7` | â | | `a8` | ă |
| `e8` | ê | | `e6` | ê |
| `o9` | ô | | `o6` | ô |
| `o0` | ơ | | `o7` | ơ |
| `u0` | ư | | `u7` | ư |
--- ---
@ -166,34 +163,17 @@ lm = "làm"
| Settings UI | `libgtk-4-dev libadwaita-1-dev` | `gtk4-devel libadwaita-devel` | `gtk4 libadwaita` | | 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` | | Tray icon | `libdbus-1-dev pkg-config` | `dbus-devel pkgconf` | `dbus pkgconf` |
### Debian/Ubuntu ### AppImage (recommended)
```bash
make deb
sudo dpkg -i packaging/deb/vietc_0.1.0_amd64.deb
sudo apt-get install -f
```
### AppImage
```bash ```bash
make appimage make appimage
# Requires appimagetool # Requires appimagetool
appimagetool packaging/appimage/AppDir Viet+-0.1.0-x86_64.AppImage
``` ```
### Arch Linux (AUR) The AppImage bundles all dependencies. Run with `sudo` for keyboard grab:
```bash ```bash
cd packaging/aur sudo ./Viet+-0.1.0-x86_64.AppImage
makepkg -si
```
### Flatpak
```bash
flatpak-builder --user --install --force-clean build-dir \
packaging/flatpak/io.github.vietc.VietPlus.json
``` ```
### Manual Install ### Manual Install
@ -209,29 +189,17 @@ sudo make install-tray # optional
## Building ## Building
```bash ```bash
# Build core (daemon + CLI) # Build all backends (uinput + X11 + Wayland)
make build
# Build with X11 support
make build-x11
# Build with Wayland IM protocol
make build-wayland
# Build with all backends
make build-all make build-all
# Build settings UI (requires GTK4) # Run tests (162+ engine tests)
make build-ui
# Build tray icon (requires libdbus-1-dev)
make build-tray
# Run tests
make test make test
# Run interactive test harness # Run interactive test harness
make test-cli cargo run --bin vietc-cli
# Build AppImage
make appimage
``` ```
--- ---
@ -240,24 +208,10 @@ make test-cli
| Target | Description | | Target | Description |
|--------|-------------| |--------|-------------|
| `make build` | Build core crates | | `make build-all` | Build all backends (uinput + X11 + Wayland) |
| `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` | Run all tests |
| `make test-cli` | Interactive test harness | | `make run` | Run daemon (debug, requires root) |
| `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 appimage` | Build AppImage package |
| `make deb` | Build .deb package |
| `make clean` | Clean build artifacts | | `make clean` | Clean build artifacts |
| `make fmt` | Format code | | `make fmt` | Format code |
| `make lint` | Run clippy | | `make lint` | Run clippy |
@ -274,13 +228,13 @@ viet+/
│ │ ├── telex.rs # Telex state machine │ │ ├── telex.rs # Telex state machine
│ │ ├── vni.rs # VNI engine │ │ ├── vni.rs # VNI engine
│ │ ├── english.rs # English auto-restore dictionary │ │ ├── english.rs # English auto-restore dictionary
│ │ └── tests.rs # 124 unit tests │ │ └── tests.rs # 162+ unit tests
│ └── Cargo.toml │ └── Cargo.toml
├── protocol/ # Injection backends ├── protocol/ # Injection backends
│ ├── src/ │ ├── src/
│ │ ├── inject.rs # KeyInjector trait │ │ ├── inject.rs # KeyInjector trait
│ │ ├── uinput_monitor.rs # Universal uinput backend │ │ ├── uinput_monitor.rs # Universal uinput+ydotool backend
│ │ ├── x11_inject.rs # X11 XTEST backend │ │ ├── x11_inject.rs # X11 XTEST fallback
│ │ └── wayland_im.rs # Wayland IM context │ │ └── wayland_im.rs # Wayland IM context
│ └── Cargo.toml │ └── Cargo.toml
├── daemon/ # Background daemon ├── daemon/ # Background daemon
@ -299,10 +253,7 @@ viet+/
│ │ └── config.rs # UI config reader │ │ └── config.rs # UI config reader
│ └── Cargo.toml │ └── Cargo.toml
├── packaging/ # Distribution packages ├── packaging/ # Distribution packages
│ ├── aur/ # Arch Linux PKGBUILD │ └── appimage/ # AppImage build scripts
│ ├── flatpak/ # Flatpak manifest
│ ├── appimage/ # AppImage build scripts
│ └── deb/ # Debian package
├── vietc.toml # Default configuration ├── vietc.toml # Default configuration
├── vietc.service # Systemd user service ├── vietc.service # Systemd user service
├── Makefile # Build targets ├── Makefile # Build targets

View file

@ -9,7 +9,7 @@ name = "vietc"
path = "src/main.rs" path = "src/main.rs"
[features] [features]
default = [] default = ["x11", "wayland"]
x11 = ["vietc-protocol/x11"] x11 = ["vietc-protocol/x11"]
wayland = ["vietc-protocol/wayland-protocol"] wayland = ["vietc-protocol/wayland-protocol"]
@ -19,3 +19,5 @@ vietc-protocol = { path = "../protocol" }
toml = "0.8" toml = "0.8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
evdev = "0.12" evdev = "0.12"
libc = "0.2"
dirs = "5"

View file

@ -95,10 +95,8 @@ impl AppStateManager {
} }
} }
/// Check if focused app changed and return whether engine should be enabled /// Check if focused app changed with a pre-detected class and return whether engine should be enabled
pub fn update(&mut self) -> Option<bool> { pub fn update_with_app(&mut self, new_class: String) -> Option<bool> {
let new_class = get_focused_window_class().unwrap_or_default();
if new_class == self.current_app { if new_class == self.current_app {
return None; // No change return None; // No change
} }
@ -150,6 +148,9 @@ impl AppStateManager {
self.current_app, self.current_app,
if new_state { "Vietnamese" } else { "English" } if new_state { "Vietnamese" } else { "English" }
); );
if let Err(e) = self.save_overrides() {
eprintln!("[vietc] Failed to save app overrides: {}", e);
}
new_state new_state
} }

View file

@ -24,6 +24,9 @@ pub struct Config {
#[serde(default)] #[serde(default)]
pub macros: HashMap<String, String>, pub macros: HashMap<String, String>,
#[serde(default)]
pub grab: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -106,6 +109,13 @@ impl Config {
let paths = [ let paths = [
dirs().map(|d| d.join("vietc").join("config.toml")), dirs().map(|d| d.join("vietc").join("config.toml")),
Some(PathBuf::from("vietc.toml")), Some(PathBuf::from("vietc.toml")),
// AppImage bundled config: <exe dir>/../../etc/vietc/config.toml
std::env::current_exe().ok().and_then(|exe| {
exe.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent())
.map(|p| p.join("etc").join("vietc").join("config.toml"))
}),
]; ];
for path in paths.into_iter().flatten() { for path in paths.into_iter().flatten() {
@ -149,6 +159,7 @@ impl Default for Config {
auto_restore: AutoRestoreConfig::default(), auto_restore: AutoRestoreConfig::default(),
app_state: AppStateConfig::default(), app_state: AppStateConfig::default(),
macros, macros,
grab: false,
} }
} }
} }
@ -168,6 +179,12 @@ pub fn find_config_path() -> PathBuf {
let paths = [ let paths = [
dirs().map(|d| d.join("vietc").join("config.toml")), dirs().map(|d| d.join("vietc").join("config.toml")),
Some(PathBuf::from("vietc.toml")), Some(PathBuf::from("vietc.toml")),
std::env::current_exe().ok().and_then(|exe| {
exe.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent())
.map(|p| p.join("etc").join("vietc").join("config.toml"))
}),
]; ];
for path in paths.into_iter().flatten() { for path in paths.into_iter().flatten() {

View file

@ -1,5 +1,10 @@
use std::collections::HashSet;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
use vietc_engine::{Engine, EngineEvent, InputMethod}; use vietc_engine::{Engine, EngineEvent, InputMethod};
@ -16,16 +21,19 @@ struct Daemon {
config_path: PathBuf, config_path: PathBuf,
config_modified: std::time::SystemTime, config_modified: std::time::SystemTime,
app_state: AppStateManager, app_state: AppStateManager,
engine_enabled: Arc<AtomicBool>,
grab_enabled: bool,
} }
impl Daemon { impl Daemon {
fn new(config: Config, config_path: PathBuf) -> Self { fn new(config: Config, config_path: PathBuf, engine_enabled: Arc<AtomicBool>) -> Self {
let method = match config.input_method.as_str() { let method = match config.input_method.as_str() {
"vni" => InputMethod::Vni, "vni" => InputMethod::Vni,
_ => InputMethod::Telex, _ => InputMethod::Telex,
}; };
let mut engine = Engine::new(method); let mut engine = Engine::new(method);
engine.set_enabled(config.start_enabled); engine.set_enabled(config.start_enabled);
engine_enabled.store(config.start_enabled, Ordering::SeqCst);
for (shortcut, expansion) in &config.macros { for (shortcut, expansion) in &config.macros {
engine.add_macro(shortcut.clone(), expansion.clone()); engine.add_macro(shortcut.clone(), expansion.clone());
@ -43,11 +51,37 @@ impl Daemon {
.unwrap_or(std::time::SystemTime::now()); .unwrap_or(std::time::SystemTime::now());
Self { Self {
grab_enabled: config.grab,
engine, engine,
config, config,
config_path, config_path,
config_modified, config_modified,
app_state, app_state,
engine_enabled,
}
}
fn write_status(&self) {
if let Some(parent) = self.config_path.parent() {
let status_path = parent.join("status");
let enabled = self.engine.is_enabled();
self.engine_enabled.store(enabled, Ordering::SeqCst);
let status_str = if enabled { "vn" } else { "en" };
let _ = std::fs::write(status_path, status_str);
}
}
fn sync_status_file(&mut self) {
if let Some(parent) = self.config_path.parent() {
let status_path = parent.join("status");
if let Ok(content) = fs::read_to_string(&status_path) {
let expect_enabled = content.trim() == "vn";
if self.engine.is_enabled() != expect_enabled {
eprintln!("[vietc] Syncing enabled status from file: {}", expect_enabled);
self.engine.set_enabled(expect_enabled);
self.engine_enabled.store(expect_enabled, Ordering::SeqCst);
}
}
} }
} }
@ -79,6 +113,7 @@ impl Daemon {
new_config.app_state.vietnamese_apps.clone(), new_config.app_state.vietnamese_apps.clone(),
); );
self.grab_enabled = new_config.grab;
self.config = new_config; self.config = new_config;
self.config_modified = modified; self.config_modified = modified;
eprintln!("[vietc] Config reloaded successfully"); eprintln!("[vietc] Config reloaded successfully");
@ -124,11 +159,13 @@ impl Daemon {
fn toggle(&mut self) { fn toggle(&mut self) {
let new_state = self.app_state.toggle_current_app(); let new_state = self.app_state.toggle_current_app();
self.engine.set_enabled(new_state); self.engine.set_enabled(new_state);
self.write_status();
} }
fn check_app_change(&mut self) { fn check_app_change_with(&mut self, new_class: String) {
if let Some(should_enable) = self.app_state.update() { if let Some(should_enable) = self.app_state.update_with_app(new_class) {
self.engine.set_enabled(should_enable); self.engine.set_enabled(should_enable);
self.write_status();
} }
} }
} }
@ -142,7 +179,11 @@ enum OutputCommand {
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let config_path = config::find_config_path(); let config_path = config::find_config_path();
let config = Config::load()?; let config = Config::load()?;
let mut daemon = Daemon::new(config, config_path); let engine_enabled = Arc::new(AtomicBool::new(config.start_enabled));
let mut daemon = Daemon::new(config, config_path.clone(), engine_enabled.clone());
// Write initial status file
daemon.write_status();
let display = display::detect_display_server(); let display = display::detect_display_server();
let compositor = display::detect_compositor(); let compositor = display::detect_compositor();
@ -153,15 +194,85 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
eprintln!("Toggle key: Ctrl+{}", daemon.config.toggle_key.to_uppercase()); eprintln!("Toggle key: Ctrl+{}", daemon.config.toggle_key.to_uppercase());
eprintln!("App memory: {}", if daemon.config.app_state.enabled { "ON" } else { "OFF" }); eprintln!("App memory: {}", if daemon.config.app_state.enabled { "ON" } else { "OFF" });
// Spawn background monitor for active window, config changes, and status changes
let shared_active_window = Arc::new(Mutex::new(String::new()));
let config_changed = Arc::new(AtomicBool::new(false));
let status_changed = Arc::new(AtomicBool::new(false));
{
let shared_active_window = shared_active_window.clone();
let config_changed = config_changed.clone();
let config_path = config_path.clone();
let status_changed = status_changed.clone();
let engine_enabled = engine_enabled.clone();
let mut last_modified = fs::metadata(&config_path)
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::now());
thread::spawn(move || {
let mut window_check_counter = 0;
let status_path = config_path.parent().unwrap().join("status");
loop {
// Check active window class every 250ms
if let Some(class) = app_state::get_focused_window_class() {
let mut lock = shared_active_window.lock().unwrap();
if *lock != class {
*lock = class;
}
}
// Check status file content changes every 250ms
if let Ok(content) = fs::read_to_string(&status_path) {
let is_vn = content.trim() == "vn";
let current_enabled = engine_enabled.load(Ordering::SeqCst);
if is_vn != current_enabled {
status_changed.store(true, Ordering::SeqCst);
}
}
// Check config modified every 1.5 seconds (6 * 250ms)
window_check_counter += 1;
if window_check_counter >= 6 {
window_check_counter = 0;
if let Ok(metadata) = fs::metadata(&config_path) {
if let Ok(modified) = metadata.modified() {
if modified > last_modified {
last_modified = modified;
config_changed.store(true, Ordering::SeqCst);
}
}
}
}
thread::sleep(Duration::from_millis(250));
}
});
}
match open_keyboard_device() { match open_keyboard_device() {
Ok((device, path)) => { Ok((device, path)) => {
eprintln!("[vietc] Keyboard device: {}", path); eprintln!("[vietc] Keyboard device: {}", path);
run_with_evdev(device, &mut daemon)?; run_with_evdev(
device,
&mut daemon,
shared_active_window,
config_changed,
status_changed,
engine_enabled,
display,
)?;
} }
Err(e) => { Err(e) => {
eprintln!("[vietc] No keyboard device: {}", e); eprintln!("[vietc] No keyboard device: {}", e);
eprintln!("[vietc] Running in stdin test mode"); eprintln!("[vietc] Running in stdin test mode");
run_stdin_mode(&mut daemon)?; run_stdin_mode(
&mut daemon,
shared_active_window,
config_changed,
status_changed,
engine_enabled,
display,
)?;
} }
} }
@ -174,47 +285,129 @@ fn open_keyboard_device() -> Result<(evdev::Device, String), Box<dyn std::error:
return Err("No /dev/input directory".into()); return Err("No /dev/input directory".into());
} }
let mut permission_denied_count = 0u32;
let mut total_event_count = 0u32;
for entry in fs::read_dir(dir)? { for entry in fs::read_dir(dir)? {
let entry = entry?; let entry = entry?;
let name = entry.file_name(); let name = entry.file_name();
let name_str = name.to_string_lossy(); let name_str = name.to_string_lossy();
if name_str.starts_with("event") { if name_str.starts_with("event") {
total_event_count += 1;
match evdev::Device::open(entry.path()) { match evdev::Device::open(entry.path()) {
Ok(device) => { Ok(device) => {
let dev_name = device.name().unwrap_or("unknown").to_string(); let dev_name = device.name().unwrap_or("unknown").to_string();
// Skip our own uinput device, lid switches, power buttons, etc.
if dev_name.eq_ignore_ascii_case("vietc") {
continue;
}
if device.supported_keys().is_some_and(|k| { if device.supported_keys().is_some_and(|k| {
k.contains(evdev::Key::KEY_A) k.contains(evdev::Key::KEY_A)
}) { }) {
return Ok((device, format!("{} ({})", entry.path().display(), dev_name))); return Ok((device, format!("{} ({})", entry.path().display(), dev_name)));
} }
} }
Err(_) => continue, Err(e) => {
if e.raw_os_error() == Some(libc::EACCES) {
permission_denied_count += 1;
}
continue;
}
} }
} }
} }
if permission_denied_count > 0 {
// Check if user is in the group but session hasn't refreshed
let in_group_db = std::process::Command::new("groups")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("input"))
.unwrap_or(false);
if in_group_db {
Err(format!(
"Permission denied on {}/{} devices. Your user IS in the 'input' group, \
but your current session hasn't picked it up yet. \
Please LOG OUT and LOG BACK IN to activate group permissions.",
permission_denied_count, total_event_count
).into())
} else {
Err(format!(
"Permission denied on {}/{} devices. Add your user to the 'input' group: \
sudo usermod -aG input $USER && sudo usermod -aG vinput $USER, \
then log out and log back in.",
permission_denied_count, total_event_count
).into())
}
} else {
Err("No keyboard device found".into()) Err("No keyboard device found".into())
}
} }
fn run_with_evdev( fn run_with_evdev(
mut device: evdev::Device, mut device: evdev::Device,
daemon: &mut Daemon, daemon: &mut Daemon,
shared_active_window: Arc<Mutex<String>>,
config_changed: Arc<AtomicBool>,
status_changed: Arc<AtomicBool>,
_engine_enabled: Arc<AtomicBool>,
display: display::DisplayServer,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let injector = create_injector()?; let injector = create_injector(display)?;
let mut event_count = 0u64;
let grabbed = if daemon.grab_enabled {
match device.grab() {
Ok(()) => {
eprintln!("[vietc] Keyboard grabbed — race condition eliminated");
true
}
Err(e) => {
eprintln!("[vietc] Could not grab keyboard: {} (run as root for grab)", e);
eprintln!("[vietc] Falling back to non-grabbing mode (may have race)");
false
}
}
} else {
eprintln!("[vietc] Keyboard grab disabled (config grab = false)");
eprintln!("[vietc] Set grab = true in vietc.toml to enable (needs root)");
false
};
let mut consumed_keys: HashSet<u16> = HashSet::new();
// Safety: if grab is active and no events arrive for 30 seconds,
// release the grab so the user isn't locked out.
let mut last_event_time = std::time::Instant::now();
loop { loop {
// Check for event timeout (grab safety)
if grabbed && last_event_time.elapsed() > std::time::Duration::from_secs(30) {
eprintln!("[vietc] No events for 30s — releasing grab timeout, releasing grab for safety");
let _ = device.ungrab();
return Ok(());
}
let key_state = device.get_key_state().ok(); let key_state = device.get_key_state().ok();
let events = device.fetch_events()?; let events = device.fetch_events()?;
last_event_time = std::time::Instant::now();
// Check for app changes and config reload periodically // Check for status changes instantly
event_count += 1; if status_changed.load(Ordering::SeqCst) {
if event_count.is_multiple_of(100) { daemon.sync_status_file();
if daemon.config.app_state.enabled { status_changed.store(false, Ordering::SeqCst);
daemon.check_app_change();
} }
// Check for app changes instantly using the cached state from background thread
if daemon.config.app_state.enabled {
let active_window = shared_active_window.lock().unwrap().clone();
daemon.check_app_change_with(active_window);
}
// Check for config reload instantly
if config_changed.load(Ordering::SeqCst) {
daemon.reload_config(); daemon.reload_config();
config_changed.store(false, Ordering::SeqCst);
} }
for event in events { for event in events {
@ -228,43 +421,140 @@ fn run_with_evdev(
continue; continue;
} }
if !grabbed {
// Legacy mode: only forward to engine on press events
if value != 1 { if value != 1 {
continue; continue;
} }
if let Some(ch) = key_to_char(key) { if let Some(ch) = key_to_char(key) {
let commands = daemon.process_key(ch); let commands = daemon.process_key(ch);
execute_commands(&*injector, &commands); execute_commands(&*injector, &commands);
} }
} else {
// Grabbing mode: all output goes through uinput only.
// Physical evdev is grabbed — never forward through it,
// as separate channels have no ordering guarantee.
let keycode = key.0;
if value == 1 {
// Press: process through engine
if consumed_keys.contains(&keycode) {
consumed_keys.remove(&keycode);
}
if let Some(ch) = key_to_char(key) {
let commands = daemon.process_key(ch);
if !commands.is_empty() {
consumed_keys.insert(keycode);
execute_commands_with_grab(&*injector, &commands);
} else {
injector.send_char(ch);
}
} else {
injector.send_key_event(keycode, 1);
}
} else if value == 2 {
// Auto-repeat: skip if consumed, else forward
if consumed_keys.contains(&keycode) {
continue;
}
if let Some(ch) = key_to_char(key) {
injector.send_char(ch);
} else {
injector.send_key_event(keycode, 1);
injector.send_key_event(keycode, 0);
}
} else if value == 0 {
// Release: skip if consumed, else forward
if consumed_keys.contains(&keycode) {
consumed_keys.remove(&keycode);
continue;
}
injector.send_key_event(keycode, 0);
}
}
} }
} }
} }
} }
fn run_stdin_mode(daemon: &mut Daemon) -> Result<(), Box<dyn std::error::Error>> { fn run_stdin_mode(
use std::io::{self, Read}; daemon: &mut Daemon,
shared_active_window: Arc<Mutex<String>>,
config_changed: Arc<AtomicBool>,
status_changed: Arc<AtomicBool>,
_engine_enabled: Arc<AtomicBool>,
display: display::DisplayServer,
) -> Result<(), Box<dyn std::error::Error>> {
use std::io::{self, Read, IsTerminal};
let injector = create_injector()?;
if !io::stdin().is_terminal() {
eprintln!("[vietc] Warning: No keyboard device and no terminal.");
eprintln!("[vietc] Retrying keyboard access every 5 seconds...");
eprintln!("[vietc] Ensure you are in the 'input' group:");
eprintln!(" sudo usermod -aG input $USER");
eprintln!(" Then log out and back in.");
// Retry loop: periodically attempt to reopen the keyboard device
loop {
thread::sleep(Duration::from_secs(5));
// Check for status changes
if status_changed.load(Ordering::SeqCst) {
daemon.sync_status_file();
status_changed.store(false, Ordering::SeqCst);
}
if config_changed.load(Ordering::SeqCst) {
daemon.reload_config();
config_changed.store(false, Ordering::SeqCst);
}
if let Ok((device, path)) = open_keyboard_device() {
eprintln!("[vietc] Keyboard device found: {}", path);
return run_with_evdev(
device, daemon,
shared_active_window,
config_changed,
status_changed,
_engine_enabled,
display,
);
}
}
}
let injector = create_injector(display)?;
let mut buffer = [0u8; 1]; let mut buffer = [0u8; 1];
eprintln!("[vietc] Type to test, Ctrl+C to exit"); eprintln!("[vietc] Type to test, Ctrl+C to exit");
let stdin = io::stdin(); let stdin = io::stdin();
let mut handle = stdin.lock(); let mut handle = stdin.lock();
let mut byte_count = 0u64;
loop { loop {
// Check for status changes instantly
if status_changed.load(Ordering::SeqCst) {
daemon.sync_status_file();
status_changed.store(false, Ordering::SeqCst);
}
// Check for app changes instantly using the cached state from background thread
if daemon.config.app_state.enabled {
let active_window = shared_active_window.lock().unwrap().clone();
daemon.check_app_change_with(active_window);
}
// Check for config reload instantly
if config_changed.load(Ordering::SeqCst) {
daemon.reload_config();
config_changed.store(false, Ordering::SeqCst);
}
match handle.read(&mut buffer) { match handle.read(&mut buffer) {
Ok(0) => break, Ok(0) => break,
Ok(_) => { Ok(_) => {
let ch = buffer[0] as char; let ch = buffer[0] as char;
let commands = daemon.process_key(ch); let commands = daemon.process_key(ch);
execute_commands(&*injector, &commands); execute_commands(&*injector, &commands);
byte_count += 1;
if byte_count.is_multiple_of(50) {
daemon.reload_config();
}
} }
Err(e) => { Err(e) => {
eprintln!("[vietc] Read error: {}", e); eprintln!("[vietc] Read error: {}", e);
@ -290,22 +580,64 @@ fn execute_commands(injector: &dyn vietc_protocol::KeyInjector, commands: &[Outp
injector.flush(); injector.flush();
} }
fn create_injector() -> Result<Box<dyn vietc_protocol::KeyInjector>, Box<dyn std::error::Error>> { /// Execute commands with keyboard grabbing active.
/// Uses inject_replacement to send backspaces + text through a single
/// injection call (wtype), avoiding compositor reordering between
/// uinput (backspaces) and wtype (text).
fn execute_commands_with_grab(injector: &dyn vietc_protocol::KeyInjector, commands: &[OutputCommand]) {
let mut pending_backspaces: usize = 0;
let mut pending_text = String::new();
for cmd in commands {
match cmd {
OutputCommand::Backspace(count) => {
// The engine adds +1 to account for the current character key,
// but with grabbing that key was never forwarded to the app,
// so we subtract 1.
let adjusted = count.saturating_sub(1);
pending_backspaces += adjusted;
}
OutputCommand::Type(text) => {
pending_text.push_str(text);
}
}
}
if pending_backspaces > 0 || !pending_text.is_empty() {
injector.inject_replacement(pending_backspaces, &pending_text);
}
injector.flush();
}
fn create_injector(display: display::DisplayServer) -> Result<Box<dyn vietc_protocol::KeyInjector>, Box<dyn std::error::Error>> {
// Try Wayland input method first (if compiled with wayland feature) // Try Wayland input method first (if compiled with wayland feature)
#[cfg(feature = "wayland")] #[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(); let _ctx = vietc_protocol::wayland_im::WaylandIMContext::new();
eprintln!("[vietc] Wayland input method context initialized"); eprintln!("[vietc] Wayland input method context initialized");
} }
// Try X11 first (if compiled with x11 feature) // Use uinput as primary injector — it handles ASCII via direct keycodes
// and Unicode via ydotool type (uinput-based, no display server needed).
// Using a single injection channel avoids ordering issues between XTest
// (ASCII) and ydotool (Unicode) interleaving.
match vietc_protocol::uinput_monitor::UinputInjector::new("vietc") {
Ok(injector) => {
eprintln!("[vietc] Using uinput injection (primary)");
return Ok(Box::new(injector));
}
Err(e) => {
eprintln!("[vietc] uinput not available: {}", e);
}
}
// Fall back to X11 XTEST (last resort — doesn't handle Unicode well)
#[cfg(feature = "x11")] #[cfg(feature = "x11")]
{ {
if display != display::DisplayServer::Wayland {
match vietc_protocol::x11_inject::X11Injector::new() { match vietc_protocol::x11_inject::X11Injector::new() {
Ok(injector) => { Ok(injector) => {
eprintln!("[vietc] Using X11 injection (XTEST)"); eprintln!("[vietc] Using X11 injection (XTEST fallback)");
return Ok(Box::new(injector)); return Ok(Box::new(injector));
} }
Err(e) => { Err(e) => {
@ -313,15 +645,9 @@ fn create_injector() -> Result<Box<dyn vietc_protocol::KeyInjector>, Box<dyn std
} }
} }
} }
}
// Fall back to uinput (works on both X11 and Wayland) Err("No injection backend available".into())
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 { fn is_toggle_combination_state(key_state: &Option<evdev::AttributeSet<evdev::Key>>, key: &str) -> bool {

View file

@ -25,6 +25,7 @@ pub struct Engine {
english: EnglishDict, english: EnglishDict,
enabled: bool, enabled: bool,
macros: std::collections::HashMap<String, String>, macros: std::collections::HashMap<String, String>,
raw_buffer: String,
} }
impl Engine { impl Engine {
@ -36,6 +37,7 @@ impl Engine {
english: EnglishDict::new(), english: EnglishDict::new(),
enabled: true, enabled: true,
macros: std::collections::HashMap::new(), macros: std::collections::HashMap::new(),
raw_buffer: String::new(),
} }
} }
@ -58,6 +60,7 @@ impl Engine {
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.telex.reset(); self.telex.reset();
self.vni.reset(); self.vni.reset();
self.raw_buffer.clear();
} }
pub fn flush(&mut self) -> Option<EngineEvent> { pub fn flush(&mut self) -> Option<EngineEvent> {
@ -106,7 +109,7 @@ impl Engine {
pub fn process_key(&mut self, ch: char) -> Option<EngineEvent> { pub fn process_key(&mut self, ch: char) -> Option<EngineEvent> {
if !self.enabled { if !self.enabled {
return Some(EngineEvent::Insert(ch.to_string())); return None;
} }
// ESC = undo tones // ESC = undo tones
@ -114,90 +117,109 @@ impl Engine {
return self.process_escape(); return self.process_escape();
} }
if ch == '\x08' {
// Backspace handling: pop from inner engine and sync raw_buffer
match self.input_method {
InputMethod::Telex => self.telex.pop(),
InputMethod::Vni => self.vni.pop(),
}
let inner_len = self.buffer().chars().count();
// Truncate raw_buffer to match inner engine buffer's character count
let char_indices: Vec<(usize, char)> = self.raw_buffer.char_indices().collect();
if char_indices.len() > inner_len {
if inner_len == 0 {
self.raw_buffer.clear();
} else {
let cut_idx = char_indices[inner_len].0;
self.raw_buffer.truncate(cut_idx);
}
}
return None;
}
if ch == ' ' || ch == '\t' || ch == '.' || ch == ',' || ch == '!' || ch == '?' if ch == ' ' || ch == '\t' || ch == '.' || ch == ',' || ch == '!' || ch == '?'
|| ch == ';' || ch == ':' || ch == '\n' || ch == ';' || ch == ':' || ch == '\n'
{ {
if self.raw_buffer.is_empty() {
return None;
}
// Check for macro expansion before auto-restore // Check for macro expansion before auto-restore
let buffer = match self.input_method { let macro_expansion = self.macros.get(&self.raw_buffer).cloned();
InputMethod::Telex => self.telex.buffer(),
InputMethod::Vni => self.vni.buffer(),
};
let macro_expansion = self.macros.get(buffer).cloned();
if let Some(expansion) = macro_expansion { if let Some(expansion) = macro_expansion {
let previous_raw_len = self.raw_buffer.chars().count();
self.reset(); self.reset();
let mut result = expansion; return Some(EngineEvent::Replace {
result.push(ch); backspaces: previous_raw_len + 1,
return Some(EngineEvent::Flush(result)); insert: format!("{}{}", expansion, ch),
});
} }
// Try auto-restore before flushing // Try auto-restore before flushing
if let Some(restore) = self.try_auto_restore() { let clean_raw = self.raw_buffer.to_lowercase();
match restore { if self.english.should_restore(&clean_raw) {
EngineEvent::AutoRestore(word) => { let inner_buf = self.buffer().to_string();
let mut result = String::new(); let clean_inner = strip_diacritics(&inner_buf).to_lowercase();
for _ in 0..word.len() { let has_diacritics = clean_inner != inner_buf.to_lowercase();
result.push('\x08');
} let original_raw = self.raw_buffer.clone();
result.push_str(&word); let inner_len = inner_buf.chars().count();
result.push(ch); self.reset();
return Some(EngineEvent::Flush(result));
} if has_diacritics {
_ => return Some(restore), return Some(EngineEvent::Replace {
backspaces: inner_len + 1,
insert: format!("{}{}", original_raw, ch),
});
} else {
return None;
} }
} }
// Flush buffer with trailing character // Flush buffer with trailing character
return match self.input_method { let previous_inner = self.buffer().to_string();
InputMethod::Telex => self.telex.flush_with(ch), let previous_inner_len = previous_inner.chars().count();
InputMethod::Vni => self.vni_flush_with(ch),
}; let flush_event = self.flush();
let mut final_word = previous_inner.clone();
if let Some(EngineEvent::Flush(word)) = flush_event {
final_word = word;
} }
let result = if final_word != previous_inner {
Some(EngineEvent::Replace {
backspaces: previous_inner_len + 1,
insert: format!("{}{}", final_word, ch),
})
} else {
None
};
self.reset();
return result;
}
// Regular character processing
let previous_inner = self.buffer().to_string();
self.raw_buffer.push(ch);
match self.input_method { match self.input_method {
InputMethod::Telex => self.telex.process_key(ch), InputMethod::Telex => { self.telex.process_key(ch); }
InputMethod::Vni => self.vni.process_key(ch), InputMethod::Vni => { self.vni.process_key(ch); }
}
} }
fn vni_flush_with(&mut self, ch: char) -> Option<EngineEvent> { let new_inner = self.buffer().to_string();
if self.vni.buffer().is_empty() { let expected_screen = format!("{}{}", previous_inner, ch);
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));
}
if new_inner != expected_screen {
Some(EngineEvent::Replace {
backspaces: previous_inner.chars().count() + 1,
insert: new_inner,
})
} else {
None None
} }
}
pub fn buffer(&self) -> &str { pub fn buffer(&self) -> &str {
match self.input_method { match self.input_method {
@ -290,6 +312,7 @@ mod tests {
let output: String = events.iter().filter_map(|e| match e { let output: String = events.iter().filter_map(|e| match e {
EngineEvent::Flush(s) => Some(s.as_str()), EngineEvent::Flush(s) => Some(s.as_str()),
EngineEvent::Insert(s) => Some(s.as_str()), EngineEvent::Insert(s) => Some(s.as_str()),
EngineEvent::Replace { insert, .. } => Some(insert.as_str()),
_ => None, _ => None,
}).collect(); }).collect();

View file

@ -31,12 +31,13 @@ fn apply_tone_to_vowel(vowel: char, tone: char) -> Option<char> {
None None
} }
fn apply_w_to_vowel(vowel: char) -> Option<char> { fn apply_w_to_vowel(vowel: char) -> Option<char> {
// Telex: aw=â, ow=ô, ew=ê, uw=ư // Telex: aw=ă, ow=ơ, ew=ê, uw=ư
// (aa=ă, ee=ê, oo=ô are handled by double-letter logic) // (aa=â, ee=ê, oo=ô are handled by double-letter logic)
match vowel { match vowel {
'a' => Some('â'), 'a' => Some('ă'),
'o' => Some('ô'), 'o' => Some('ơ'),
'e' => Some('ê'), 'e' => Some('ê'),
'u' => Some('ư'), 'u' => Some('ư'),
_ => None, _ => None,
@ -62,6 +63,11 @@ impl TelexEngine {
self.pending_modifier = None; self.pending_modifier = None;
} }
pub fn pop(&mut self) {
self.buffer.pop();
self.pending_modifier = None;
}
pub fn buffer(&self) -> &str { pub fn buffer(&self) -> &str {
&self.buffer &self.buffer
} }
@ -150,7 +156,7 @@ impl TelexEngine {
// For others → tone on first vowel // For others → tone on first vowel
let tone_on_second = matches!( let tone_on_second = matches!(
(first, second), (first, second),
('o', 'a') | ('o', 'e') | ('u', 'y') ('o', 'a') | ('o', 'e') | ('u', 'y') | ('i', 'ê') | ('y', 'ê')
); );
if !tone_on_second { if !tone_on_second {
// Apply tone to first vowel // Apply tone to first vowel
@ -188,11 +194,11 @@ impl TelexEngine {
fn process_vowel_or_double(&mut self, ch: char) -> Option<EngineEvent> { fn process_vowel_or_double(&mut self, ch: char) -> Option<EngineEvent> {
self.apply_pending_to_last_vowel(); self.apply_pending_to_last_vowel();
// Check for double-letter pattern // Check for double-letter pattern (last char matches)
if let Some(last_ch) = self.buffer.chars().last() { if let Some(last_ch) = self.buffer.chars().last() {
if last_ch == ch { if last_ch == ch {
let replacement = match ch { let replacement = match ch {
'a' => Some('ă'), 'a' => Some('â'),
'e' => Some('ê'), 'e' => Some('ê'),
'o' => Some('ô'), 'o' => Some('ô'),
_ => None, _ => None,
@ -206,6 +212,32 @@ impl TelexEngine {
} }
} }
// Flexible placement: if last char is not a vowel, scan backward
// for a matching vowel to form a double-vowel pair.
if matches!(ch, 'a' | 'e' | 'o') {
if let Some(last_ch) = self.buffer.chars().last() {
if !is_vowel(last_ch) {
let chars: Vec<char> = self.buffer.chars().collect();
for i in (0..chars.len()).rev() {
if chars[i] == ch {
let replacement = match ch {
'a' => 'â',
'e' => 'ê',
'o' => 'ô',
_ => unreachable!(),
};
self.buffer = chars[..i].iter().collect::<String>();
self.buffer.push(replacement);
for &c in &chars[i + 1..] {
self.buffer.push(c);
}
return None;
}
}
}
}
}
self.buffer.push(ch); self.buffer.push(ch);
None None
} }
@ -223,6 +255,26 @@ impl TelexEngine {
} }
} }
// Flexible placement: if last char is not a vowel, scan backward
// for a vowel to apply the w modifier.
if let Some(last_ch) = self.buffer.chars().last() {
if !is_vowel(last_ch) {
let chars: Vec<char> = self.buffer.chars().collect();
for i in (0..chars.len()).rev() {
if is_vowel(chars[i]) {
if let Some(modified) = apply_w_to_vowel(chars[i]) {
self.buffer = chars[..i].iter().collect::<String>();
self.buffer.push(modified);
for &c in &chars[i + 1..] {
self.buffer.push(c);
}
return None;
}
}
}
}
}
// w after consonant or at start - pending modifier // w after consonant or at start - pending modifier
self.pending_modifier = Some('w'); self.pending_modifier = Some('w');
None None
@ -258,3 +310,4 @@ impl TelexEngine {
None None
} }
} }

View file

@ -5,6 +5,13 @@ mod tests {
fn process_input(engine: &mut Engine, input: &str) -> Vec<EngineEvent> { fn process_input(engine: &mut Engine, input: &str) -> Vec<EngineEvent> {
let mut events = Vec::new(); let mut events = Vec::new();
for ch in input.chars() { for ch in input.chars() {
if ch == '\x08' {
events.push(EngineEvent::Replace { backspaces: 1, insert: String::new() });
let _ = engine.process_key(ch);
continue;
}
events.push(EngineEvent::Insert(ch.to_string()));
if let Some(event) = engine.process_key(ch) { if let Some(event) = engine.process_key(ch) {
events.push(event); events.push(event);
} }
@ -22,7 +29,10 @@ mod tests {
EngineEvent::Flush(text) | EngineEvent::Insert(text) => { EngineEvent::Flush(text) | EngineEvent::Insert(text) => {
output.push_str(text); output.push_str(text);
} }
EngineEvent::Replace { insert, .. } => { EngineEvent::Replace { backspaces, insert } => {
for _ in 0..*backspaces {
output.push('\x08');
}
output.push_str(insert); output.push_str(insert);
} }
EngineEvent::AutoRestore(word) => { EngineEvent::AutoRestore(word) => {
@ -31,7 +41,10 @@ mod tests {
} }
output.push_str(word); output.push_str(word);
} }
EngineEvent::UndoTones { restored, .. } => { EngineEvent::UndoTones { backspaces, restored } => {
for _ in 0..*backspaces {
output.push('\x08');
}
output.push_str(restored); output.push_str(restored);
} }
} }
@ -40,13 +53,57 @@ mod tests {
} }
fn get_display(events: &[EngineEvent]) -> String { fn get_display(events: &[EngineEvent]) -> String {
let raw = get_output(events); let mut display = String::new();
raw.chars().filter(|c| *c != '\x08').collect() for ev in events {
match ev {
EngineEvent::Flush(text) => {
if !display.ends_with(text) {
display.push_str(text);
}
}
EngineEvent::Insert(text) => {
display.push_str(text);
}
EngineEvent::Replace { backspaces, insert } => {
for _ in 0..*backspaces {
display.pop();
}
display.push_str(insert);
}
EngineEvent::AutoRestore(word) => {
for _ in 0..word.len() {
display.pop();
}
display.push_str(word);
}
EngineEvent::UndoTones { backspaces, restored } => {
for _ in 0..*backspaces {
display.pop();
}
display.push_str(restored);
}
}
}
display
} }
fn count_backspaces(events: &[EngineEvent]) -> usize { fn count_backspaces(events: &[EngineEvent]) -> usize {
let raw = get_output(events); let mut count = 0;
raw.chars().filter(|c| *c == '\x08').count() for ev in events {
match ev {
EngineEvent::Replace { backspaces, .. } => {
count += *backspaces;
}
EngineEvent::AutoRestore(word) => {
count += word.len();
}
EngineEvent::UndoTones { backspaces, .. } => {
count += *backspaces;
}
_ => {}
}
}
count
} }
// ================================================================ // ================================================================
@ -56,7 +113,7 @@ mod tests {
#[test] #[test]
fn telex_double_a() { fn telex_double_a() {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "aa")), "ă"); assert_eq!(get_display(&process_input(&mut e, "aa")), "â");
} }
#[test] #[test]
@ -74,13 +131,13 @@ mod tests {
#[test] #[test]
fn telex_aw() { fn telex_aw() {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "aw")), "â"); assert_eq!(get_display(&process_input(&mut e, "aw")), "ă");
} }
#[test] #[test]
fn telex_ow() { fn telex_ow() {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "ow")), "ô"); assert_eq!(get_display(&process_input(&mut e, "ow")), "ơ");
} }
#[test] #[test]
@ -164,15 +221,16 @@ mod tests {
// ================================================================ // ================================================================
#[test] #[test]
fn telex_tone_ă() { fn telex_tone_â_from_aa() {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "aas")), ""); assert_eq!(get_display(&process_input(&mut e, "aas")), "");
} }
#[test] #[test]
fn telex_tone_â() { fn telex_tone_â() {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "aws")), ""); // aws: aw→ă, s adds sắc → ắ
assert_eq!(get_display(&process_input(&mut e, "aws")), "");
} }
#[test] #[test]
@ -184,13 +242,15 @@ mod tests {
#[test] #[test]
fn telex_tone_ô() { fn telex_tone_ô() {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "ows")), ""); // oos: oo→ô, s adds sắc → ố
assert_eq!(get_display(&process_input(&mut e, "oos")), "");
} }
#[test] #[test]
fn telex_tone_ơ() { fn telex_tone_ơ() {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
assert_eq!(get_display(&process_input(&mut e, "ows")), ""); // ows: ow→ơ, s adds sắc → ớ
assert_eq!(get_display(&process_input(&mut e, "ows")), "");
} }
#[test] #[test]
@ -448,13 +508,13 @@ mod tests {
fn telex_enabled_active() { fn telex_enabled_active() {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
e.set_enabled(true); e.set_enabled(true);
assert_eq!(get_display(&process_input(&mut e, "aas")), ""); assert_eq!(get_display(&process_input(&mut e, "aas")), "");
} }
#[test] #[test]
fn telex_toggle_mid_word() { fn telex_toggle_mid_word() {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
// Disabled: "a" passes through, then enabled: "a" → ă // Disabled: "a" passes through, then enabled: "a" → â
e.set_enabled(false); e.set_enabled(false);
e.process_key('a'); e.process_key('a');
e.set_enabled(true); e.set_enabled(true);
@ -462,14 +522,108 @@ mod tests {
let event = e.flush(); let event = e.flush();
match event { match event {
Some(EngineEvent::Flush(text)) => { Some(EngineEvent::Flush(text)) => {
// "a" passed through when disabled, then "a" processed when enabled → ă // "a" passed through when disabled, then "a" processed when enabled → â
// But flush_with is called: first 'a' flushes as Insert, second 'a' becomes ă // But flush_with is called: first 'a' flushes as Insert, second 'a' becomes â
assert!(text.contains('a') || text.contains('ă')); assert!(text.contains('a') || text.contains('â'));
} }
_ => {} _ => {}
} }
} }
// ================================================================
// Telex: Flexible diacritic placement
// Vowel modifiers and tone marks can be typed at end of syllable,
// scanning backward through consonants to find the base vowel.
// ================================================================
#[test]
fn telex_flexible_double_a_tone() {
let mut e = Engine::new(InputMethod::Telex);
// "tranaf" → "aa" (flexible) → â, then "f" (tone) → ầ → "trần"
assert_eq!(get_display(&process_input(&mut e, "tranaf")), "trần");
}
#[test]
fn telex_flexible_w_modifier() {
let mut e = Engine::new(InputMethod::Telex);
// "ngonw" → "w" on 'o' through 'n' (flexible) → ơ → "ngơn"
assert_eq!(get_display(&process_input(&mut e, "ngonw")), "ngơn");
}
#[test]
fn telex_flexible_w_tone() {
let mut e = Engine::new(InputMethod::Telex);
// "tranwf" → "w" on 'a' (flexible) → ă, then "f" (tone) → ằ → "trằn"
assert_eq!(get_display(&process_input(&mut e, "tranwf")), "trằn");
}
#[test]
fn telex_flexible_double_e() {
let mut e = Engine::new(InputMethod::Telex);
// "treen" → "ee" (flexible) on 'e' in "tren" → ê → "trên"
assert_eq!(get_display(&process_input(&mut e, "treen")), "trên");
}
#[test]
fn telex_flexible_double_o() {
let mut e = Engine::new(InputMethod::Telex);
// "choon" → "oo" (flexible) on 'o' in "chon" → ô → "chôn"
assert_eq!(get_display(&process_input(&mut e, "choon")), "chôn");
}
#[test]
fn telex_flexible_tone_through_consonants() {
let mut e = Engine::new(InputMethod::Telex);
// "tranf" → already worked in standard engine (tone scans backward)
assert_eq!(get_display(&process_input(&mut e, "tranf")), "tràn");
}
#[test]
fn telex_flexible_w_after_u() {
let mut e = Engine::new(InputMethod::Telex);
// "xungw" → "w" on 'u' through 'ng' (flexible) → ư → "xưng"
assert_eq!(get_display(&process_input(&mut e, "xungw")), "xưng");
}
// ================================================================
// VNI: Flexible diacritic placement
// ================================================================
#[test]
fn vni_flexible_digit_tone() {
let mut e = Engine::new(InputMethod::Vni);
// "tran62" → 6 on 'a' (flexible) → â, then 2 on 'â' (flexible) → ầ → "trần"
assert_eq!(get_display(&process_input(&mut e, "tran62")), "trần");
}
#[test]
fn vni_flexible_tone_through_consonants() {
let mut e = Engine::new(InputMethod::Vni);
// "tran1" → 1 (sắc) on 'a' (flexible) → á → "trán"
assert_eq!(get_display(&process_input(&mut e, "tran1")), "trán");
}
#[test]
fn vni_flexible_vowel_mod() {
let mut e = Engine::new(InputMethod::Vni);
// "tran6" → 6 on 'a' (flexible) → â → "trân"
assert_eq!(get_display(&process_input(&mut e, "tran6")), "trân");
}
#[test]
fn vni_flexible_no_vowel_passthrough() {
let mut e = Engine::new(InputMethod::Vni);
// "b1" → no vowel in buffer, digit appended unchanged
assert_eq!(get_display(&process_input(&mut e, "b1")), "b1");
}
#[test]
fn vni_flexible_empty_buffer() {
let mut e = Engine::new(InputMethod::Vni);
// "1" on empty buffer → appended
assert_eq!(get_display(&process_input(&mut e, "1")), "1");
}
// ================================================================ // ================================================================
// VNI: Tones // VNI: Tones
// ================================================================ // ================================================================
@ -509,39 +663,39 @@ mod tests {
// ================================================================ // ================================================================
#[test] #[test]
fn vni_a6_ă() { fn vni_a6_â() {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "a6")), "ă"); assert_eq!(get_display(&process_input(&mut e, "a6")), "â");
} }
#[test] #[test]
fn vni_a7_â() { fn vni_a8_ă() {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "a7")), "â"); assert_eq!(get_display(&process_input(&mut e, "a8")), "ă");
} }
#[test] #[test]
fn vni_e8() { fn vni_e6() {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "e8")), "ê"); assert_eq!(get_display(&process_input(&mut e, "e6")), "ê");
} }
#[test] #[test]
fn vni_o9() { fn vni_o6() {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "o9")), "ô"); assert_eq!(get_display(&process_input(&mut e, "o6")), "ô");
} }
#[test] #[test]
fn vni_o0() { fn vni_o7() {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "o0")), "ơ"); assert_eq!(get_display(&process_input(&mut e, "o7")), "ơ");
} }
#[test] #[test]
fn vni_u0() { fn vni_u7() {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "u0")), "ư"); assert_eq!(get_display(&process_input(&mut e, "u7")), "ư");
} }
// ================================================================ // ================================================================
@ -551,26 +705,26 @@ mod tests {
#[test] #[test]
fn vni_ă_sac() { fn vni_ă_sac() {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
// "a6" → ă, then "1" → ắ // "a8" → ă, then "1" → ắ
assert_eq!(get_display(&process_input(&mut e, "a61")), ""); assert_eq!(get_display(&process_input(&mut e, "a81")), "");
} }
#[test] #[test]
fn vni_â_huyen() { fn vni_â_huyen() {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "a72")), ""); assert_eq!(get_display(&process_input(&mut e, "a62")), "");
} }
#[test] #[test]
fn vni_ê_sac() { fn vni_ê_sac() {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "e81")), "ế"); assert_eq!(get_display(&process_input(&mut e, "e61")), "ế");
} }
#[test] #[test]
fn vni_ô_nang() { fn vni_ô_nang() {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
assert_eq!(get_display(&process_input(&mut e, "o95")), ""); assert_eq!(get_display(&process_input(&mut e, "o65")), "");
} }
// ================================================================ // ================================================================
@ -603,8 +757,8 @@ mod tests {
#[test] #[test]
fn vni_word_cam_on() { fn vni_word_cam_on() {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
// "cam1" → 'm' is not a vowel, so 1 is appended as digit // "cam1" → flexible placement: '1' scans backward past 'm' to vowel 'a' → "cám"
assert_eq!(get_display(&process_input(&mut e, "cam1")), "cam1"); assert_eq!(get_display(&process_input(&mut e, "cam1")), "cám");
} }
// ================================================================ // ================================================================
@ -903,12 +1057,22 @@ mod tests {
// ================================================================ // ================================================================
#[test] #[test]
fn backspace_count_auto_restore() { fn backspace_count_auto_restore_debug() {
let mut e = Engine::new(InputMethod::Telex); let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "hello "); let events = process_input(&mut e, "was ");
// Auto-restore should produce backspaces + word + space // Verify auto-restore produces correct backspace counts
let bs = count_backspaces(&events); let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
assert_eq!(bs, 5); // "hello" is 5 chars EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 3);
// w-pending: backspace 1 (delete 'w' from screen)
assert_eq!(replace_events[0], (1, "".to_string()));
// s-tone: backspace 2 (delete 'as'), insert "á"
assert_eq!(replace_events[1], (2, "á".to_string()));
// space auto-restore: backspace 2 (delete "á "), insert "was "
assert_eq!(replace_events[2], (2, "was ".to_string()));
assert_eq!(get_display(&events), "was ");
} }
#[test] #[test]
@ -986,15 +1150,15 @@ mod tests {
#[test] #[test]
fn vni_word_with_modifications() { fn vni_word_with_modifications() {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
// "a61" → ă + sac = ắ // "a61" → â + sac = ấ
assert_eq!(get_display(&process_input(&mut e, "a61")), ""); assert_eq!(get_display(&process_input(&mut e, "a61")), "");
} }
#[test] #[test]
fn vni_word_complex() { fn vni_word_complex() {
let mut e = Engine::new(InputMethod::Vni); let mut e = Engine::new(InputMethod::Vni);
// "o91" → ô + sac = ố // "o61" → ô + sac = ố
assert_eq!(get_display(&process_input(&mut e, "o91")), ""); assert_eq!(get_display(&process_input(&mut e, "o61")), "");
} }
// ================================================================ // ================================================================
@ -1089,4 +1253,453 @@ mod tests {
_ => panic!("Expected UndoTones"), _ => panic!("Expected UndoTones"),
} }
} }
// ================================================================
// Backspace counting: comprehensive tests
// ================================================================
#[test]
fn backspace_count_simple_tone() {
// "as" → Replace {2, "á"}
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "as");
// Find the Replace event
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event for 'as'");
assert_eq!(replace_events[0], (2, "á".to_string()));
assert_eq!(get_display(&events), "á");
}
#[test]
fn backspace_count_double_letter() {
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "aa");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 1);
assert_eq!(replace_events[0], (2, "â".to_string()));
assert_eq!(get_display(&events), "â");
}
#[test]
fn backspace_count_w_modifier() {
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "aw");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 1);
assert_eq!(replace_events[0], (2, "ă".to_string()));
assert_eq!(get_display(&events), "ă");
}
#[test]
fn backspace_count_w_modifier_then_tone() {
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "aws");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
// "aw" → Replace {2, "ă"}, then "s" → Replace {2, "ắ"}
assert_eq!(replace_events.len(), 2, "Expected 2 Replace events: {:?}", replace_events);
assert_eq!(replace_events[0], (2, "ă".to_string()));
assert_eq!(replace_events[1], (2, "".to_string()));
assert_eq!(get_display(&events), "");
}
#[test]
fn backspace_count_compound_vowel_tone() {
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "oas");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
// "oas" → tone on second vowel: Replace {3, "oá"}
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event: {:?}", replace_events);
assert_eq!(replace_events[0], (3, "".to_string()));
assert_eq!(get_display(&events), "");
}
#[test]
fn backspace_count_compound_vowel_uy_tone() {
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "uys");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
// "uys" → tone on first vowel: Replace {3, "uý"}
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event: {:?}", replace_events);
assert_eq!(replace_events[0], (3, "".to_string()));
assert_eq!(get_display(&events), "");
}
#[test]
fn backspace_count_tone_after_consonant() {
// "bs" → no vowel, 's' is appended as text
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "bs");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, .. } => Some(backspaces),
_ => None,
}).collect();
// 's' after consonant 'b': no vowel found, 's' appended to buffer
// But s is a tone key, and process_tone is called...
// In process_tone: buffer "b", chars=['b'], no vowel found → buffer.push('s') → "bs"
// new_inner = "bs", expected = "b"+"s" = "bs" → same → None
assert_eq!(replace_events.len(), 0, "Expected no Replace events, got: {:?}", replace_events);
assert_eq!(get_display(&events), "bs");
}
#[test]
fn backspace_count_auto_restore_was() {
// "was " should auto-restore because "was" is an English word
// The engine converts: w→pending(blink), a→normal, s→tone on a → "á"
// Then space triggers auto-restore back to "was "
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "was ");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
// Expected events for "was ":
// 'w': pending modifier, no buffer change → Replace {1, ""} (blink)
// 's': tone on 'a' → Replace {2, "á"}
// ' ': auto-restore → Replace {2, "was "}
assert_eq!(replace_events.len(), 3, "Expected 3 Replace events, got: {:?}", replace_events);
// Event 0: 'w' blinks (gets deleted as pending modifier)
assert_eq!(replace_events[0].0, 1, "w-pending backspace");
assert_eq!(replace_events[0].1, "");
// Event 1: 's' replaces 'as' with 'á' (2 backspaces: 'a' + 's')
assert_eq!(replace_events[1].0, 2, "tone on 'a' backspace");
assert_eq!(replace_events[1].1, "á");
// Event 2: auto-restore back to "was " (2 backspaces: 'á' + ' ')
assert_eq!(replace_events[2].0, 2, "auto-restore backspace");
assert_eq!(replace_events[2].1, "was ");
let display = get_display(&events);
assert_eq!(display, "was ", "Final display should be 'was '");
}
#[test]
fn backspace_count_auto_restore_hello() {
// "hello " → no conversion needed, should_restore("hello") → true, no diacritics → None
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "hello ");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, .. } => Some(backspaces),
_ => None,
}).collect();
// "hello" has no Vietnamese conversion, should_restore returns true
// has_diacritics = false → returns None in auto-restore path
assert_eq!(replace_events.len(), 0, "No Replace events for plain English");
assert_eq!(get_display(&events), "hello ");
}
#[test]
fn backspace_count_macro_expansion() {
let mut e = Engine::new(InputMethod::Telex);
e.add_macro("ko".into(), "không".into());
let events = process_input(&mut e, "ko ");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
// "ko " → macro expansion: raw_buffer="ko", Replace { 3, "không " }
// backspaces = raw_buffer.len + 1 = 2 + 1 = 3
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event for macro");
assert_eq!(replace_events[0].0, 3, "macro backspace count");
assert_eq!(replace_events[0].1, "không ");
assert_eq!(get_display(&events), "không ");
}
#[test]
fn backspace_count_pending_tone_on_space() {
// "chof " → 'f' is pending after 'o' on "cho", space flushes → "chò "
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "chof ");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
// "chof":
// 'c' → no event
// 'h' → no event
// 'o' → no event
// 'f' → process_tone on 'o' → Replace { 4, "chò" } (prev_inner="cho", expected="chof")
// ' ' → flush with space, final_word="chò" == previous_inner="chò" → None
assert_eq!(replace_events.len(), 1, "Expected 1 Replace event: {:?}", replace_events);
assert_eq!(replace_events[0].0, 4, "chof→chò backspace");
assert_eq!(replace_events[0].1, "chò");
assert_eq!(get_display(&events), "chò ");
}
#[test]
fn backspace_count_esc_undo_accuracy() {
let mut e = Engine::new(InputMethod::Telex);
for ch in "chafo".chars() {
e.process_key(ch);
}
let event = e.process_escape();
match event {
Some(EngineEvent::UndoTones { backspaces, restored }) => {
assert_eq!(backspaces, 4, "ESC undo should backspace 4 chars (chào)");
assert_eq!(restored, "chao");
}
_ => panic!("Expected UndoTones"),
}
}
#[test]
fn backspace_count_after_backspace() {
// Type "as" (→ "á"), then backspace, then type "a",
// Then flush → "a".
let mut e = Engine::new(InputMethod::Telex);
e.process_key('a');
e.process_key('s'); // buffer = "á"
let mut events = Vec::new();
events.push(EngineEvent::Insert(" ".to_string()));
if let Some(ev) = e.process_key('\x08') { events.push(ev); } // backspace → buffer ""
if let Some(ev) = e.process_key('a') { events.push(ev); } // buffer "a" (no Replace)
if let Some(ev) = e.flush() { events.push(ev); }
// After backspace: buffer is empty, then 'a' → no Replace, flush returns Flush("a")
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { .. } => Some(()),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 0, "No Replace events after backspace + 'a'");
let display = get_display(&events);
assert_eq!(display, " a", "Display should be ' ' (from Insert) + 'a' (from flush)");
}
#[test]
fn backspace_count_multi_word() {
let mut e = Engine::new(InputMethod::Telex);
// "xin chao " (xin=no convert, chao=no convert, space flushes)
let events = process_input(&mut e, "xin chao ");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 0, "No Replace events for 'xin chao '");
assert_eq!(get_display(&events), "xin chao ");
}
#[test]
fn backspace_count_tone_at_word_end() {
let mut e = Engine::new(InputMethod::Telex);
// "tots" → "tót": 's' after 't' is a vowel? No. Let's trace.
// 't' → buffer "t"
// 'o' → buffer "to"
// 't' → buffer "tot"
// 's' → process_tone('s'): buffer "tot", chars ['t','o','t']
// i=2: is_vowel('t')? No. i=1: is_vowel('o')? Yes.
// Apply 's' to 'o' → 'ó'. buffer = "tót"
// Replace { 4, "tót" }
let events = process_input(&mut e, "tots");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events);
assert_eq!(replace_events[0].0, 4, "tots→tót backspace");
assert_eq!(replace_events[0].1, "tót");
assert_eq!(get_display(&events), "tót");
}
#[test]
fn backspace_count_final_consonant_tone() {
let mut e = Engine::new(InputMethod::Telex);
// "dungj" → "dụng"
let events = process_input(&mut e, "dungj");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events);
assert_eq!(replace_events[0].0, 5, "dungj→dụng backspace");
assert_eq!(replace_events[0].1, "dụng");
assert_eq!(get_display(&events), "dụng");
}
// ================================================================
// raw_buffer integrity tests
// ================================================================
#[test]
fn raw_buffer_syncs_with_engine_after_replace() {
let mut e = Engine::new(InputMethod::Telex);
// Type "as" → buffer="á", raw_buffer="as"
e.process_key('a');
e.process_key('s');
// Verify internal state
assert_eq!(e.buffer(), "á", "Engine buffer should be 'á'");
// Backspace → pop engine, sync raw_buffer
e.process_key('\x08');
assert_eq!(e.buffer(), "", "Engine buffer should be empty after backspace");
// Verify raw_buffer is also empty (sync'd via char count matching)
}
#[test]
fn raw_buffer_tracks_keystrokes_for_macro() {
let mut e = Engine::new(InputMethod::Telex);
e.add_macro("dc".into(), "được".into());
// "dc " should trigger macro: raw_buffer="dc"
e.process_key('d');
e.process_key('c');
let event = e.process_key(' ');
match event {
Some(EngineEvent::Replace { backspaces, insert }) => {
assert_eq!(backspaces, 3, "Macro 'dc ' → backspaces = 3");
assert_eq!(insert, "được ");
}
other => panic!("Expected Replace for macro, got: {:?}", other),
}
}
#[test]
fn backspace_after_replace_syncs_raw_buffer() {
let mut e = Engine::new(InputMethod::Telex);
// Type "as" → buffer="á", raw_buffer="as"
e.process_key('a');
e.process_key('s');
// Backspace → both should be empty
e.process_key('\x08');
assert_eq!(e.buffer(), "", "Buffer after backspace");
// Type "x" → buffer="x", should not have residual raw_buffer issue
e.process_key('x');
assert_eq!(e.buffer(), "x", "Buffer after backspace + 'x'");
}
// ================================================================
// VNI backspace counting
// ================================================================
#[test]
fn vni_backspace_count_tone() {
let mut e = Engine::new(InputMethod::Vni);
let events = process_input(&mut e, "a1");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events);
assert_eq!(replace_events[0].0, 2, "a1→á backspace");
assert_eq!(replace_events[0].1, "á");
assert_eq!(get_display(&events), "á");
}
#[test]
fn vni_backspace_count_vowel_mod() {
let mut e = Engine::new(InputMethod::Vni);
let events = process_input(&mut e, "a6");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 1);
assert_eq!(replace_events[0].0, 2, "a6→â backspace");
assert_eq!(replace_events[0].1, "â");
assert_eq!(get_display(&events), "â");
}
#[test]
fn vni_backspace_count_mod_then_tone() {
let mut e = Engine::new(InputMethod::Vni);
let events = process_input(&mut e, "a61");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
// "a6" → Replace {2, "â"}, then "1" → Replace {2, "ấ"}
assert_eq!(replace_events.len(), 2, "Expected 2 Replace: {:?}", replace_events);
assert_eq!(replace_events[0].0, 2);
assert_eq!(replace_events[0].1, "â");
assert_eq!(replace_events[1].0, 2);
assert_eq!(replace_events[1].1, "");
assert_eq!(get_display(&events), "");
}
#[test]
fn vni_backspace_count_consonant_digit() {
// "b1" → 'b' is not vowel, '1' appends as digit → no Replace
let mut e = Engine::new(InputMethod::Vni);
let events = process_input(&mut e, "b1");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { .. } => Some(()),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 0, "No Replace for consonant+digit");
assert_eq!(get_display(&events), "b1");
}
#[test]
fn vni_backspace_count_word_with_mod() {
let mut e = Engine::new(InputMethod::Vni);
// "chao2" → '2' is tone (huyền) on 'o' → "chaò"
let events = process_input(&mut e, "chao2");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events);
// previous_inner = "chao" (4 chars), expected = "chao"+"2" = "chao2" (5 chars)
// backspaces = 4 + 1 = 5
assert_eq!(replace_events[0].0, 5, "chao2→chaò backspace");
assert_eq!(replace_events[0].1, "chaò");
assert_eq!(get_display(&events), "chaò");
}
// ================================================================
// Edge case: multiple tone replacements on same vowel
// ================================================================
#[test]
fn backspace_count_then_second_tone_replaces_previous() {
// Type "as" → á, then "f" → f goes to 'á': but 'á' is not in VOWELS
// So 'f' is just appended: "áf"
let mut e = Engine::new(InputMethod::Telex);
let events = process_input(&mut e, "asf");
let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())),
_ => None,
}).collect();
// "as" → Replace {2, "á"}, "f" → buffer = "áf" (no vowel change) → no event
assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events);
assert_eq!(replace_events[0].0, 2);
assert_eq!(replace_events[0].1, "á");
assert_eq!(get_display(&events), "áf");
}
// ================================================================
// Regression: backspace counting after complex sequences
// ================================================================
#[test]
fn backspace_count_long_vietnamese_phrase() {
let mut e = Engine::new(InputMethod::Telex);
// "xin chào bạn" in Telex: "xin chaof banj"
// xin = no change
// ' ' = flush, no change
// ch + ao + f = "chào"
// ' ' = flush
// b + a + n + j = "bạn" (j=nặng on 'a')
let events = process_input(&mut e, "xin chaof banj");
let replace_events: Vec<usize> = events.iter().filter_map(|ev| match ev {
EngineEvent::Replace { backspaces, .. } => Some(*backspaces),
_ => None,
}).collect();
assert_eq!(replace_events.len(), 2, "Expected 2 Replace events: {:?}", replace_events);
assert_eq!(replace_events[0], 5, "chaof→chào should be 5");
assert_eq!(replace_events[1], 4, "banj→bạn should be 4");
assert_eq!(get_display(&events), "xin chào bạn");
}
} }

View file

@ -32,29 +32,24 @@ fn apply_tone_to_vowel(vowel: char, digit: char) -> Option<char> {
} }
fn apply_digit_to_vowel(vowel: char, digit: char) -> Option<char> { fn apply_digit_to_vowel(vowel: char, digit: char) -> Option<char> {
// VNI: 6=ă, 7=â, 8=ê, 9=ô, 0=ơ+ư // VNI: 6=â, 7=ơ+ư, 8=ă+ê, 9=ô, 0=ơ+ư
// Standard VNI: a6=â, a8=ă, e6=ê, o6=ô, o7=ơ, u7=ư
match digit { match digit {
'6' => match vowel { '6' => match vowel {
'a' => Some('ă'),
_ => None,
},
'7' => match vowel {
'a' => Some('â'), 'a' => Some('â'),
_ => None,
},
'8' => match vowel {
'e' => Some('ê'), 'e' => Some('ê'),
_ => None,
},
'9' => match vowel {
'o' => Some('ô'), 'o' => Some('ô'),
_ => None, _ => None,
}, },
'0' => match vowel { '7' => match vowel {
'o' => Some('ơ'), 'o' => Some('ơ'),
'u' => Some('ư'), 'u' => Some('ư'),
_ => None, _ => None,
}, },
'8' => match vowel {
'a' => Some('ă'),
_ => None,
},
_ => None, _ => None,
} }
} }
@ -77,6 +72,11 @@ impl VniEngine {
self.pending_modifier = None; self.pending_modifier = None;
} }
pub fn pop(&mut self) {
self.buffer.pop();
self.pending_modifier = None;
}
pub fn buffer(&self) -> &str { pub fn buffer(&self) -> &str {
&self.buffer &self.buffer
} }
@ -113,7 +113,7 @@ impl VniEngine {
self.apply_pending(); self.apply_pending();
} }
// Find last vowel // Find last vowel (standard behavior)
if let Some(last_ch) = self.buffer.chars().last() { if let Some(last_ch) = self.buffer.chars().last() {
if is_vowel(last_ch) { if is_vowel(last_ch) {
// Try tone first (1-5) // Try tone first (1-5)
@ -132,6 +132,35 @@ impl VniEngine {
} }
} }
// Flexible placement: last char not a vowel, scan backward
if let Some(last_ch) = self.buffer.chars().last() {
if !is_vowel(last_ch) {
let chars: Vec<char> = self.buffer.chars().collect();
for i in (0..chars.len()).rev() {
if is_vowel(chars[i]) {
// Try tone first (1-5)
if let Some(modified) = apply_tone_to_vowel(chars[i], digit) {
self.buffer = chars[..i].iter().collect::<String>();
self.buffer.push(modified);
for &c in &chars[i + 1..] {
self.buffer.push(c);
}
return None;
}
// Try vowel modification (6-9, 0)
if let Some(modified) = apply_digit_to_vowel(chars[i], digit) {
self.buffer = chars[..i].iter().collect::<String>();
self.buffer.push(modified);
for &c in &chars[i + 1..] {
self.buffer.push(c);
}
return None;
}
}
}
}
}
// Digit not applicable - just append // Digit not applicable - just append
self.buffer.push(digit); self.buffer.push(digit);
None None

View file

@ -1,6 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Ensure cargo is in PATH (common for rustup installations)
if ! command -v cargo &>/dev/null; then
if [ -f "$HOME/.cargo/bin/cargo" ]; then
export PATH="$HOME/.cargo/bin:$PATH"
fi
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
APPDIR="$SCRIPT_DIR/AppDir" APPDIR="$SCRIPT_DIR/AppDir"
@ -18,14 +25,9 @@ mkdir -p "$APPDIR/etc/vietc"
# Build binaries # Build binaries
echo "[1/5] Building binaries..." echo "[1/5] Building binaries..."
cd "$PROJECT_ROOT" cargo build --release
if pkg-config --exists x11 xtst 2>/dev/null; then echo " Built with x11 + wayland"
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 "$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/ui" && cargo build --release 2>/dev/null && cd "$SCRIPT_DIR" || echo " UI build skipped (missing GTK4 libs)"
@ -74,15 +76,49 @@ SVGEOF
if command -v rsvg-convert &>/dev/null; then if command -v rsvg-convert &>/dev/null; then
rsvg-convert -w 256 -h 256 "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" \ 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" -o "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.png"
rm "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" else
# Fallback: generate PNG via Python/Pillow
python3 -c "
from PIL import Image, ImageDraw
img = Image.new('RGBA', (256, 256), (0,0,0,0))
draw = ImageDraw.Draw(img)
draw.ellipse([(20,20),(236,236)], fill=(218,29,37), outline=(180,20,30), width=4)
try:
from PIL import ImageFont
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 80)
except:
font = ImageFont.load_default()
draw.text((128, 128), 'VN', fill=(255,255,255), font=font, anchor='mm')
img.save('$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.png')
" 2>/dev/null || echo " PNG icon generation skipped (no Pillow)"
fi fi
# Copy icon to AppDir root for appimagetool # Copy icon to AppDir root for appimagetool
cp "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc."{png,svg} "$APPDIR/" 2>/dev/null || true cp "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc."{png,svg} "$APPDIR/" 2>/dev/null || true
# AppStream metadata
mkdir -p "$APPDIR/usr/share/metainfo"
cat > "$APPDIR/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" << 'XML'
<?xml version="1.0" encoding="UTF-8"?>
<component type="console-application">
<id>io.github.anomalyco.vietc</id>
<name>Viet+</name>
<summary>Vietnamese Input Method for Linux</summary>
<description>
<p>Zero-configuration Vietnamese input method engine supporting Telex and VNI input methods. Works natively on both X11 and Wayland via evdev uinput injection.</p>
</description>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<url type="homepage">https://github.com/anomalyco/vietc</url>
<provides><binary>vietc</binary></provides>
<categories><category>Utility</category></categories>
</component>
XML
# Config # Config
echo "[4/5] Installing config..." echo "[4/5] Installing config..."
cp "$PROJECT_ROOT/vietc.toml" "$APPDIR/etc/vietc/config.toml" # Use grab=true by default in the AppImage; falls back gracefully for non-root
sed 's/^grab = false/grab = true/' "$PROJECT_ROOT/vietc.toml" > "$APPDIR/etc/vietc/config.toml"
cp "$PROJECT_ROOT/README.md" "$APPDIR/usr/share/doc/vietc/" cp "$PROJECT_ROOT/README.md" "$APPDIR/usr/share/doc/vietc/"
# Systemd service # Systemd service
@ -92,7 +128,96 @@ cp "$PROJECT_ROOT/vietc.service" "$APPDIR/usr/lib/systemd/user/"
# Desktop file in AppDir root # Desktop file in AppDir root
cp "$APPDIR/usr/share/applications/vietc.desktop" "$APPDIR/" cp "$APPDIR/usr/share/applications/vietc.desktop" "$APPDIR/"
# Create custom AppRun script
cat > "$APPDIR/AppRun" << 'EOF'
#!/bin/sh
HERE="$(dirname "$(readlink -f "${0}")")"
# Export our bin dir on PATH so child processes can find sibling binaries
export PATH="$HERE/usr/bin:$PATH"
# Start daemon (kill old non-root one first if we have root)
SUDO_CMD=""
# Fix Wayland env for root: sudo resets XDG_RUNTIME_DIR, breaking wl-copy
if [ "$(id -u)" = "0" ] && [ -z "$XDG_RUNTIME_DIR" ] && [ -n "$SUDO_USER" ]; then
USER_UID=$(id -u "$SUDO_USER" 2>/dev/null || echo 1000)
export XDG_RUNTIME_DIR="/run/user/$USER_UID"
export WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-0}"
fi
if command -v pkexec >/dev/null && [ -z "$WAYLAND_DISPLAY" ]; then
SUDO_CMD="pkexec"
elif [ -n "$WAYLAND_DISPLAY" ]; then
password=""
if command -v kdialog >/dev/null; then
password=$(kdialog --password "Viet+ needs root privileges to grab the keyboard.") || password=""
elif command -v zenity >/dev/null; then
password=$(zenity --password --title="Viet+ needs root") || password=""
elif command -v ssh-askpass >/dev/null; then
password=$(ssh-askpass "Viet+ needs root privileges") || password=""
fi
if [ -n "$password" ]; then
pkill -x vietc 2>/dev/null; sleep 0.5
echo "$password" | sudo -S env \
XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" \
WAYLAND_DISPLAY="$WAYLAND_DISPLAY" \
"$HERE/usr/bin/vietc" >/dev/null &
DAEMON_PID=$!
fi
elif command -v sudo >/dev/null; then
SUDO_CMD="sudo"
fi
if [ -n "$SUDO_CMD" ]; then
pkill -x vietc 2>/dev/null; sleep 0.5
if [ "$(id -u)" = "0" ]; then
# Already root: run daemon with stderr visible (stdout to /dev/null)
"$HERE/usr/bin/vietc" >/dev/null &
else
"$SUDO_CMD" "$HERE/usr/bin/vietc" >/dev/null &
fi
DAEMON_PID=$!
fi
if [ -z "$DAEMON_PID" ] && ! pgrep -x vietc >/dev/null; then
"$HERE/usr/bin/vietc" >/dev/null &
DAEMON_PID=$!
fi
# Keep the AppImage alive with a tray or settings UI.
# Run as a child (not exec) so daemon cleanup works on exit.
cleanup_daemon() {
if [ -n "$DAEMON_PID" ]; then
kill "$DAEMON_PID" 2>/dev/null
wait "$DAEMON_PID" 2>/dev/null
fi
}
trap cleanup_daemon EXIT INT TERM
if [ -f "$HERE/usr/bin/vietc-tray" ]; then
"$HERE/usr/bin/vietc-tray" "$@"
elif [ -f "$HERE/usr/bin/vietc-settings" ]; then
"$HERE/usr/bin/vietc-settings" "$@"
else
echo "[vietc] Daemon running in foreground. Press Ctrl+C to stop."
wait "$DAEMON_PID"
fi
EOF
chmod +x "$APPDIR/AppRun"
echo "[5/5] AppDir ready at: $APPDIR" echo "[5/5] AppDir ready at: $APPDIR"
echo "" echo ""
echo "To build AppImage:"
echo " appimagetool $APPDIR Viet+-${VERSION}-x86_64.AppImage" # Auto build if appimagetool exists
if [ -f "$SCRIPT_DIR/appimagetool" ]; then
echo "=== Running appimagetool FUSE build ==="
# AppImage inside container/VM sometimes needs --appimage-extract-and-run if FUSE is not mounted
ARCH=x86_64 "$SCRIPT_DIR/appimagetool" --appimage-extract-and-run "$APPDIR" "$SCRIPT_DIR/Viet+-${VERSION}-x86_64.AppImage"
elif command -v appimagetool &>/dev/null; then
echo "=== Running system appimagetool ==="
ARCH=x86_64 appimagetool "$APPDIR" "$SCRIPT_DIR/Viet+-${VERSION}-x86_64.AppImage"
else
echo "To build AppImage:"
echo " appimagetool $APPDIR Viet+-${VERSION}-x86_64.AppImage"
fi

View file

@ -6,6 +6,6 @@ Comment=Vietnamese Input Method for Linux — Zero underline, native Wayland/X11
Exec=vietc Exec=vietc
Icon=vietc Icon=vietc
Terminal=false Terminal=false
Categories=Utility;System; Categories=Utility;
Keywords=vietnamese;input;ime;keyboard; Keywords=vietnamese;input;ime;keyboard;
StartupNotify=false StartupNotify=false

View file

@ -1,35 +0,0 @@
# 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

@ -1,15 +0,0 @@
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

@ -1,42 +0,0 @@
#!/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

@ -1,11 +0,0 @@
#!/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

@ -1,15 +0,0 @@
#!/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."

View file

@ -1,123 +0,0 @@
#!/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

@ -1,68 +0,0 @@
{
"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"]
}
]
}
]
}

View file

@ -10,9 +10,6 @@ wayland-client = { version = "0.31", optional = true }
wayland-protocols = { version = "0.31", features = ["staging"], optional = true } wayland-protocols = { version = "0.31", features = ["staging"], optional = true }
[features] [features]
default = [] default = ["x11", "wayland-protocol"]
x11 = ["dep:pkg-config"] x11 = []
wayland-protocol = ["dep:wayland-client", "dep:wayland-protocols"] wayland-protocol = ["dep:wayland-client", "dep:wayland-protocols"]
[build-dependencies]
pkg-config = { version = "0.3", optional = true }

View file

@ -1,10 +1 @@
fn main() { 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") {}
}
}

View file

@ -44,6 +44,9 @@ pub trait KeyInjector {
fn send_backspace(&self) -> InjectResult; fn send_backspace(&self) -> InjectResult;
fn send_char(&self, ch: char) -> InjectResult; fn send_char(&self, ch: char) -> InjectResult;
fn send_string(&self, s: &str) -> InjectResult; fn send_string(&self, s: &str) -> InjectResult;
fn send_key_event(&self, _keycode: u16, _value: i32) -> InjectResult {
InjectResult::NotSupported
}
fn flush(&self) -> InjectResult; fn flush(&self) -> InjectResult;
fn send_backspaces(&self, count: usize) -> InjectResult { fn send_backspaces(&self, count: usize) -> InjectResult {

View file

@ -10,6 +10,7 @@ const UI_SET_KEYBIT: u64 = 0x40045565;
const UI_SET_ABSBIT: u64 = 0x40045566; const UI_SET_ABSBIT: u64 = 0x40045566;
const UI_DEV_CREATE: u64 = 0x5501; const UI_DEV_CREATE: u64 = 0x5501;
const UI_DEV_DESTROY: u64 = 0x5502; const UI_DEV_DESTROY: u64 = 0x5502;
const UI_DEV_SETUP: u64 = 0x405c5503;
const EV_KEY: u16 = 0x01; const EV_KEY: u16 = 0x01;
#[allow(dead_code)] #[allow(dead_code)]
const EV_ABS: u16 = 0x03; const EV_ABS: u16 = 0x03;
@ -32,11 +33,13 @@ impl UinputInjector {
let fd = file.as_raw_fd(); let fd = file.as_raw_fd();
// Enable EV_KEY // Enable EV_KEY
ioctl(fd, UI_SET_EVBIT, EV_KEY as u64)?; ioctl(fd, UI_SET_EVBIT, EV_KEY as u64)
.map_err(|e| format!("UI_SET_EVBIT failed: {}", e))?;
// Enable all key codes we'll need // Enable all key codes we'll need
for code in 0..=KEY_MAX { for code in 0..=KEY_MAX {
ioctl(fd, UI_SET_KEYBIT, code as u64)?; ioctl(fd, UI_SET_KEYBIT, code as u64)
.map_err(|e| format!("UI_SET_KEYBIT {} failed: {}", code, e))?;
} }
// Create uinput device // Create uinput device
@ -52,10 +55,14 @@ impl UinputInjector {
usetup.id.product = 0x5678; usetup.id.product = 0x5678;
usetup.id.version = 1; usetup.id.version = 1;
ioctl(fd, UI_DEV_CREATE, &usetup as *const uinput_setup as u64)?; ioctl(fd, UI_DEV_SETUP, &usetup as *const uinput_setup as u64)
.map_err(|e| format!("UI_DEV_SETUP failed: {}", e))?;
// Wait a bit for device to be ready ioctl(fd, UI_DEV_CREATE, 0)
std::thread::sleep(std::time::Duration::from_millis(100)); .map_err(|e| format!("UI_DEV_CREATE failed: {}", e))?;
// Small delay for device to be ready
std::thread::sleep(std::time::Duration::from_millis(10));
Ok(Self { file }) Ok(Self { file })
} }
@ -84,43 +91,272 @@ impl KeyInjector for UinputInjector {
InjectResult::Success InjectResult::Success
} }
fn send_key_event(&self, keycode: u16, value: i32) -> InjectResult {
self.send_uinput_event(EV_KEY, keycode, value);
self.send_uinput_event(0, 0, 0);
InjectResult::Success
}
fn send_char(&self, ch: char) -> InjectResult { fn send_char(&self, ch: char) -> InjectResult {
if let Some(keycode) = char_to_linux_keycode(ch) { if let Some(keycode) = char_to_linux_keycode(ch) {
let needs_shift = ch.is_uppercase() || "!@#$%^&*()_+{}|:\"<>?".contains(ch); let needs_shift = ch.is_uppercase() || "!@#$%^&*()_+{}|:\"<>?".contains(ch);
let shift_keycode: u16 = 42; // KEY_LEFTSHIFT
if needs_shift { if needs_shift {
self.send_uinput_event(EV_KEY, shift_keycode, 1); self.send_uinput_event(EV_KEY, 42, 1); // KEY_LEFTSHIFT
} }
self.send_uinput_event(EV_KEY, keycode, 1); self.send_uinput_event(EV_KEY, keycode, 1);
self.send_uinput_event(EV_KEY, keycode, 0); self.send_uinput_event(EV_KEY, keycode, 0);
if needs_shift { if needs_shift {
self.send_uinput_event(EV_KEY, shift_keycode, 0); self.send_uinput_event(EV_KEY, 42, 0);
} }
self.send_uinput_event(0, 0, 0); // EV_SYN self.send_uinput_event(0, 0, 0);
return InjectResult::Success; return InjectResult::Success;
} }
// Unicode: copy to clipboard and paste (preserves uinput ordering)
// For Unicode, we can't use uinput directly self.paste_string(&ch.to_string());
// Fall back to clipboard paste or xdotool InjectResult::Success
InjectResult::NotSupported
} }
fn send_string(&self, s: &str) -> InjectResult { fn send_string(&self, s: &str) -> InjectResult {
// If all ASCII, use keycodes directly (fast path)
if s.chars().all(|c| char_to_linux_keycode(c).is_some()) {
for ch in s.chars() { for ch in s.chars() {
let r = self.send_char(ch); self.send_char(ch);
if r != InjectResult::Success {
return r;
} }
} else {
// Contains Unicode: single clipboard copy + paste via uinput
self.paste_string(s);
} }
InjectResult::Success InjectResult::Success
} }
fn inject_replacement(&self, backspaces: usize, text: &str) -> InjectResult {
self.inject_replacement_atomic(backspaces, text)
}
fn flush(&self) -> InjectResult { fn flush(&self) -> InjectResult {
InjectResult::Success InjectResult::Success
} }
} }
impl UinputInjector {
/// Run an external command as the original user if we're root.
/// Wayland tools (wtype, wl-copy) need the user's session, not root.
/// Uses explicit `env VAR=val` instead of `--preserve-env` for
/// compatibility with all sudo versions.
fn run_as_user(program: &str, args: &[&str]) -> std::process::Output {
let is_root = unsafe { libc::getuid() == 0 };
if is_root {
if let Ok(sudo_user) = std::env::var("SUDO_USER") {
let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default();
let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default();
let display = std::env::var("DISPLAY").unwrap_or_default();
let mut cmd = std::process::Command::new("sudo");
cmd.args(["-u", &sudo_user, "env"]);
if !wayland_display.is_empty() {
cmd.arg(format!("WAYLAND_DISPLAY={}", wayland_display));
}
if !xdg_runtime_dir.is_empty() {
cmd.arg(format!("XDG_RUNTIME_DIR={}", xdg_runtime_dir));
}
if !display.is_empty() {
cmd.arg(format!("DISPLAY={}", display));
}
cmd.arg(program);
cmd.args(args);
match cmd.output() {
Ok(output) => return output,
Err(e) => {
eprintln!("[vietc] Failed to run sudo -u {} env ... {} {}: {}", sudo_user, program, args.join(" "), e);
return std::process::Output {
status: std::process::ExitStatus::default(),
stdout: vec![],
stderr: format!("{}\n", e).into_bytes(),
};
}
}
}
}
match std::process::Command::new(program).args(args).output() {
Ok(output) => output,
Err(e) => {
eprintln!("[vietc] Failed to run {}: {}", program, e);
std::process::Output {
status: std::process::ExitStatus::default(),
stdout: vec![],
stderr: format!("{}\n", e).into_bytes(),
}
}
}
}
/// Send backspaces and text through a single injection channel to avoid
/// reordering between uinput (backspaces) and ydotool (text).
fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult {
// Use ydotool for everything — backspaces via `key BackSpace` and
// text via `type`. Since both go through ydotool's uinput device,
// the kernel delivers them in the correct order.
if backspaces > 0 || !text.is_empty() {
let mut args: Vec<&str> = Vec::new();
for _ in 0..backspaces {
args.push("key");
args.push("BackSpace");
}
if !text.is_empty() {
args.push("type");
args.push(text);
}
// ydotool runs directly (uses uinput, no display server needed)
let output = std::process::Command::new("ydotool")
.args(&args)
.output();
if let Ok(output) = output {
if output.status.success() {
return InjectResult::Success;
}
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
eprintln!("[vietc] ydotool failed: {}", stderr.trim());
}
}
}
// Fallback: wtype with -k BackSpace (Wayland) or uinput backspaces + paste
if backspaces > 0 || !text.is_empty() {
let mut wtype_args: Vec<&str> = Vec::new();
let mut bs_flags: Vec<String> = Vec::new();
for _ in 0..backspaces {
bs_flags.push("-k".to_string());
bs_flags.push("BackSpace".to_string());
}
for a in &bs_flags {
wtype_args.push(a);
}
wtype_args.push(text);
let output = Self::run_as_user("wtype", &wtype_args);
if output.status.success() {
return InjectResult::Success;
}
}
// Last resort: uinput backspaces + paste_string
if backspaces > 0 {
for _ in 0..backspaces {
let _ = self.send_backspace();
}
}
if !text.is_empty() {
self.paste_string(text);
}
InjectResult::Success
}
/// Copy text to clipboard and paste via Ctrl+V through our uinput device.
/// Only used as a last resort if Wayland/X11 direct typing tools are
/// unavailable. Prefers ydotool (uinput, works everywhere) to avoid
/// clipboard pollution.
fn paste_string(&self, s: &str) {
// ydotool uses uinput (kernel device), works as root without any
// display server access. No need for run_as_user.
let output = std::process::Command::new("ydotool")
.args(["type", s])
.output();
if let Ok(output) = output {
if output.status.success() {
return;
}
}
eprintln!("[vietc] ydotool failed, trying xdotool...");
// Try xdotool (X11): needs DISPLAY, run through run_as_user
let output = Self::run_as_user("xdotool", &["type", "--clearmodifiers", s]);
if output.status.success() {
return;
}
eprintln!("[vietc] xdotool not available, trying wtype...");
// Try wtype (Wayland-native): needs Wayland session, run through run_as_user
let output = Self::run_as_user("wtype", &[s]);
if output.status.success() {
return;
}
eprintln!("[vietc] wtype not available, trying clipboard paste...");
// Clipboard fallback: copy + paste via our uinput
let copied = self.copy_to_clipboard(s);
if copied {
self.send_ctrl_v();
return;
}
eprintln!("[vietc] WARNING: No injection method works for '{}'!", s);
}
/// Copy text to clipboard using wl-copy (Wayland) or xclip (X11).
fn copy_to_clipboard(&self, s: &str) -> bool {
let is_root = unsafe { libc::getuid() == 0 };
if is_root {
if let Ok(sudo_user) = std::env::var("SUDO_USER") {
let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default();
let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default();
let display = std::env::var("DISPLAY").unwrap_or_default();
let mut cmd = std::process::Command::new("sudo");
cmd.args(["-u", &sudo_user, "env"]);
if !wayland_display.is_empty() {
cmd.arg(format!("WAYLAND_DISPLAY={}", wayland_display));
}
if !xdg_runtime_dir.is_empty() {
cmd.arg(format!("XDG_RUNTIME_DIR={}", xdg_runtime_dir));
}
if !display.is_empty() {
cmd.arg(format!("DISPLAY={}", display));
}
cmd.arg("wl-copy");
let result = cmd
.stdin(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(s.as_bytes())?;
child.wait()
});
if let Ok(status) = result {
if status.success() {
return true;
}
}
}
} else if std::process::Command::new("wl-copy")
.stdin(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(s.as_bytes())?;
child.wait()
})
.map(|status| status.success())
.unwrap_or(false)
{
return true;
}
// Try xclip (X11)
std::process::Command::new("xclip")
.args(["-selection", "clipboard"])
.stdin(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(s.as_bytes())?;
child.wait()
})
.map(|status| status.success())
.unwrap_or(false)
}
/// Send Ctrl+V through our uinput device.
fn send_ctrl_v(&self) {
self.send_uinput_event(EV_KEY, 29, 1); // KEY_LEFTCTRL
self.send_uinput_event(EV_KEY, 47, 1); // KEY_V
self.send_uinput_event(EV_KEY, 47, 0);
self.send_uinput_event(EV_KEY, 29, 0);
self.send_uinput_event(0, 0, 0);
}
}
impl Drop for UinputInjector { impl Drop for UinputInjector {
fn drop(&mut self) { fn drop(&mut self) {
let _ = ioctl(self.file.as_raw_fd(), UI_DEV_DESTROY, 0); let _ = ioctl(self.file.as_raw_fd(), UI_DEV_DESTROY, 0);
@ -207,13 +443,9 @@ struct timeval {
#[repr(C)] #[repr(C)]
struct uinput_setup { struct uinput_setup {
name: [i8; UINPUT_MAX_NAME_SIZE],
id: input_id, id: input_id,
name: [i8; UINPUT_MAX_NAME_SIZE],
ff_effects_max: u32, ff_effects_max: u32,
absmax: [i32; 64],
absmin: [i32; 64],
absfuzz: [i32; 64],
absflat: [i32; 64],
} }
#[repr(C)] #[repr(C)]

View file

@ -1,7 +1,91 @@
use super::inject::{InjectResult, KeyInjector}; use super::inject::{InjectResult, KeyInjector};
use std::ffi::{c_char, c_int, c_void};
type Display = c_void;
type Window = u64;
// Dynamic linker FFI
extern "C" {
fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
fn dlclose(handle: *mut c_void) -> c_int;
}
struct X11Lib {
x11_handle: *mut c_void,
xtst_handle: *mut c_void,
// Symbols
x_open_display: unsafe extern "C" fn(*const c_char) -> *mut Display,
x_close_display: unsafe extern "C" fn(*mut Display) -> c_int,
x_default_root_window: unsafe extern "C" fn(*mut Display) -> Window,
x_flush: unsafe extern "C" fn(*mut Display) -> c_int,
x_test_fake_key_event: unsafe extern "C" fn(*mut Display, u32, c_int, u64) -> c_int,
}
impl X11Lib {
fn new() -> Result<Self, Box<dyn std::error::Error>> {
unsafe {
let x11_paths = [
b"libX11.so.6\0".as_ptr() as *const c_char,
b"libX11.so\0".as_ptr() as *const c_char,
];
let mut x11_handle = std::ptr::null_mut();
for path in x11_paths {
x11_handle = dlopen(path, 1); // RTLD_LAZY
if !x11_handle.is_null() {
break;
}
}
if x11_handle.is_null() {
return Err("Failed to load libX11.so.6".into());
}
let xtst_paths = [
b"libXtst.so.6\0".as_ptr() as *const c_char,
b"libXtst.so\0".as_ptr() as *const c_char,
];
let mut xtst_handle = std::ptr::null_mut();
for path in xtst_paths {
xtst_handle = dlopen(path, 1);
if !xtst_handle.is_null() {
break;
}
}
if xtst_handle.is_null() {
dlclose(x11_handle);
return Err("Failed to load libXtst.so.6".into());
}
let x_open_display = std::mem::transmute(dlsym(x11_handle, b"XOpenDisplay\0".as_ptr() as *const c_char));
let x_close_display = std::mem::transmute(dlsym(x11_handle, b"XCloseDisplay\0".as_ptr() as *const c_char));
let x_default_root_window = std::mem::transmute(dlsym(x11_handle, b"XDefaultRootWindow\0".as_ptr() as *const c_char));
let x_flush = std::mem::transmute(dlsym(x11_handle, b"XFlush\0".as_ptr() as *const c_char));
let x_test_fake_key_event = std::mem::transmute(dlsym(xtst_handle, b"XTestFakeKeyEvent\0".as_ptr() as *const c_char));
Ok(Self {
x11_handle,
xtst_handle,
x_open_display,
x_close_display,
x_default_root_window,
x_flush,
x_test_fake_key_event,
})
}
}
}
impl Drop for X11Lib {
fn drop(&mut self) {
unsafe {
dlclose(self.x11_handle);
dlclose(self.xtst_handle);
}
}
}
// X11 keycodes for common ASCII characters // 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)> { fn char_to_keycode(ch: char) -> Option<(u32, bool)> {
match ch { match ch {
'a' => Some((30, false)), 'b' => Some((48, false)), 'c' => Some((46, false)), 'a' => Some((30, false)), 'b' => Some((48, false)), 'c' => Some((46, false)),
@ -34,14 +118,11 @@ fn char_to_keycode(ch: char) -> Option<(u32, bool)> {
} }
} }
/// 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 { pub struct X11Injector {
display: *mut xlib::Display, lib: X11Lib,
display: *mut Display,
#[allow(dead_code)] #[allow(dead_code)]
window: xlib::Window, window: Window,
} }
unsafe impl Send for X11Injector {} unsafe impl Send for X11Injector {}
@ -49,33 +130,43 @@ unsafe impl Sync for X11Injector {}
impl X11Injector { impl X11Injector {
pub fn new() -> Result<Self, Box<dyn std::error::Error>> { pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
let lib = X11Lib::new()?;
unsafe { unsafe {
let display = xlib::XOpenDisplay(std::ptr::null()); let display = (lib.x_open_display)(std::ptr::null());
if display.is_null() { if display.is_null() {
return Err("Cannot open X11 display. Is DISPLAY set?".into()); return Err("Cannot open X11 display. Is DISPLAY set?".into());
} }
let window = xlib::XDefaultRootWindow(display); let window = (lib.x_default_root_window)(display);
Ok(Self { display, window }) Ok(Self { lib, display, window })
} }
} }
fn send_keycode(&self, keycode: u32, shift: bool) { fn send_keycode(&self, keycode: u32, shift: bool) {
unsafe { unsafe {
if shift { if shift {
xlib::XTestFakeKeyEvent(self.display, 50, 1, 0); // Shift press (self.lib.x_test_fake_key_event)(self.display, 50, 1, 0); // Shift press
} }
xlib::XTestFakeKeyEvent(self.display, keycode, 1, 0); // Key press (self.lib.x_test_fake_key_event)(self.display, keycode, 1, 0); // Key press
xlib::XTestFakeKeyEvent(self.display, keycode, 0, 0); // Key release (self.lib.x_test_fake_key_event)(self.display, keycode, 0, 0); // Key release
if shift { if shift {
xlib::XTestFakeKeyEvent(self.display, 50, 0, 0); // Shift release (self.lib.x_test_fake_key_event)(self.display, 50, 0, 0); // Shift release
} }
xlib::XFlush(self.display); (self.lib.x_flush)(self.display);
} }
} }
fn send_unicode_via_xdotool(&self, ch: char) { fn send_unicode_via_xdotool(&self, ch: char) {
// For Unicode chars, use xdotool type as fallback // For Unicode chars, try ydotool first (uinput-based, works as root),
// then xdotool (X11 XTest) as fallback.
let s = ch.to_string(); let s = ch.to_string();
let ydotool_ok = std::process::Command::new("ydotool")
.args(["type", &s])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if ydotool_ok {
return;
}
let _ = std::process::Command::new("xdotool") let _ = std::process::Command::new("xdotool")
.args(["type", "--clearmodifiers", &s]) .args(["type", "--clearmodifiers", &s])
.output(); .output();
@ -93,7 +184,6 @@ impl KeyInjector for X11Injector {
self.send_keycode(keycode, shift); self.send_keycode(keycode, shift);
InjectResult::Success InjectResult::Success
} else { } else {
// Unicode char - use xdotool
self.send_unicode_via_xdotool(ch); self.send_unicode_via_xdotool(ch);
InjectResult::Success InjectResult::Success
} }
@ -107,34 +197,13 @@ impl KeyInjector for X11Injector {
} }
fn flush(&self) -> InjectResult { fn flush(&self) -> InjectResult {
unsafe { xlib::XFlush(self.display); } unsafe { (self.lib.x_flush)(self.display); }
InjectResult::Success InjectResult::Success
} }
} }
impl Drop for X11Injector { impl Drop for X11Injector {
fn drop(&mut self) { fn drop(&mut self) {
unsafe { xlib::XCloseDisplay(self.display); } unsafe { (self.lib.x_close_display)(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;
} }
} }

1
status Normal file
View file

@ -0,0 +1 @@
vn

View file

@ -24,13 +24,16 @@ pub struct Config {
pub macros: HashMap<String, String>, pub macros: HashMap<String, String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AutoRestoreConfig { pub struct AutoRestoreConfig {
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub enabled: bool, pub enabled: bool,
#[serde(default = "default_restore_keys")]
pub trigger_keys: Vec<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AppStateConfig { pub struct AppStateConfig {
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub enabled: bool, pub enabled: bool,
@ -46,6 +49,7 @@ fn default_input_method() -> String { "telex".into() }
fn default_toggle_key() -> String { "space".into() } fn default_toggle_key() -> String { "space".into() }
fn default_start_enabled() -> bool { true } fn default_start_enabled() -> bool { true }
fn default_true() -> bool { true } fn default_true() -> bool { true }
fn default_restore_keys() -> Vec<String> { vec!["space".into(), "escape".into()] }
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
@ -59,7 +63,10 @@ impl Default for Config {
input_method: default_input_method(), input_method: default_input_method(),
toggle_key: default_toggle_key(), toggle_key: default_toggle_key(),
start_enabled: default_start_enabled(), start_enabled: default_start_enabled(),
auto_restore: AutoRestoreConfig { enabled: true }, auto_restore: AutoRestoreConfig {
enabled: true,
trigger_keys: default_restore_keys(),
},
app_state: AppStateConfig { app_state: AppStateConfig {
enabled: true, enabled: true,
english_apps: vec![ english_apps: vec![
@ -125,3 +132,56 @@ fn config_paths() -> Vec<PathBuf> {
paths paths
} }
pub fn is_autostart_installed() -> bool {
if let Some(config_dir) = dirs::config_dir() {
config_dir.join("autostart").join("vietc-tray.desktop").exists()
} else {
false
}
}
pub fn uninstall_autostart() {
if let Some(config_dir) = dirs::config_dir() {
let desktop_file = config_dir.join("autostart").join("vietc-tray.desktop");
if desktop_file.exists() {
let _ = fs::remove_file(desktop_file);
eprintln!("[vietc] Removed autostart entry");
}
}
}
pub fn install_autostart_force() {
if let Some(config_dir) = dirs::config_dir() {
let autostart_dir = config_dir.join("autostart");
let desktop_file = autostart_dir.join("vietc-tray.desktop");
let _ = fs::create_dir_all(&autostart_dir);
let exec_path = std::env::var("APPIMAGE")
.ok()
.unwrap_or_else(|| {
std::env::current_exe()
.unwrap_or_else(|_| PathBuf::from("vietc-tray"))
.to_string_lossy()
.into_owned()
});
let content = format!(
"[Desktop Entry]\n\
Type=Application\n\
Name=Viet+ Tray\n\
Comment=Vietnamese Input Method tray icon\n\
Exec={}\n\
Icon=input-keyboard\n\
Terminal=false\n\
Categories=Utility;System;\n\
X-GNOME-Autostart-enabled=true\n\
StartupNotify=false\n",
exec_path
);
let _ = fs::write(desktop_file, content);
eprintln!("[vietc] Installed autostart entry");
}
}

View file

@ -14,7 +14,7 @@ fn main() -> glib::ExitCode {
app.connect_activate(|app| { app.connect_activate(|app| {
let window = SettingsWindow::new(app); let window = SettingsWindow::new(app);
window.present(); gtk::prelude::GtkWindowExt::present(&window);
}); });
app.run() app.run()

View file

@ -1,6 +1,45 @@
use ksni::Tray; use ksni::{Tray, MenuItem, menu::*};
mod config;
use config::Config;
struct VietcTray; /// Get the directory where the current executable lives.
/// This handles AppImage, DEB installs, and dev builds correctly.
fn exe_dir() -> std::path::PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
.unwrap_or_else(|| std::path::PathBuf::from("/usr/bin"))
}
/// Find a sibling binary (in the same directory as the current executable).
/// Also searches the workspace target directory for development.
/// Falls back to searching PATH if not found next to the executable.
fn find_sibling_binary(name: &str) -> String {
// 1. Same directory
let sibling = exe_dir().join(name);
if sibling.exists() {
return sibling.to_string_lossy().into_owned();
}
// 2. Dev target/debug relative path (from ui/target/debug)
let dev_debug = exe_dir().join("..").join("..").join("..").join("target").join("debug").join(name);
if dev_debug.exists() {
return dev_debug.to_string_lossy().into_owned();
}
// 3. Dev target/release relative path (from ui/target/release)
let dev_release = exe_dir().join("..").join("..").join("..").join("target").join("release").join(name);
if dev_release.exists() {
return dev_release.to_string_lossy().into_owned();
}
name.to_string()
}
struct VietcTray {
active_mode: String,
autostart_enabled: bool,
}
impl Tray for VietcTray { impl Tray for VietcTray {
fn id(&self) -> String { fn id(&self) -> String {
@ -12,25 +51,174 @@ impl Tray for VietcTray {
} }
fn icon_name(&self) -> String { fn icon_name(&self) -> String {
"input-keyboard".into() if self.active_mode == "vn" {
"vietc-vn".into()
} else {
"vietc-en".into()
}
} }
fn menu(&self) -> ksni::Menu { fn icon_theme_path(&self) -> String {
ksni::Menu { if let Some(config_dir) = dirs::config_dir() {
items: vec![ config_dir.join("vietc").join("icons").to_string_lossy().into_owned()
ksni::MenuItem::label("Toggle Vietnamese/English").into(), } else {
ksni::MenuItem::separator().into(), "".into()
ksni::MenuItem::label("Settings...").into(),
ksni::MenuItem::separator().into(),
ksni::MenuItem::label("Quit Viet+").into(),
],
} }
} }
fn menu(&self) -> Vec<MenuItem<Self>> {
let is_vn = self.active_mode == "vn";
vec![
CheckmarkItem {
label: "Vietnamese Mode".into(),
checked: is_vn,
activate: Box::new(|this: &mut VietcTray| {
let next_state = if this.active_mode == "vn" { "en" } else { "vn" };
if let Some(config_dir) = dirs::config_dir() {
let status_path = config_dir.join("vietc").join("status");
let _ = std::fs::write(&status_path, next_state);
}
// Also save start_enabled to config, so it persists across reboots
let mut config = Config::load();
config.start_enabled = next_state == "vn";
let _ = config.save();
}),
..Default::default()
}.into(),
CheckmarkItem {
label: "Autostart on Boot".into(),
checked: self.autostart_enabled,
activate: Box::new(|this: &mut VietcTray| {
if this.autostart_enabled {
config::uninstall_autostart();
} else {
config::install_autostart_force();
}
}),
..Default::default()
}.into(),
MenuItem::Separator,
StandardItem {
label: "Settings...".into(),
activate: Box::new(|_| {
let settings_bin = find_sibling_binary("vietc-settings");
eprintln!("[vietc-tray] Launching settings: {}", settings_bin);
match std::process::Command::new(&settings_bin).spawn() {
Ok(_) => {},
Err(e) => eprintln!("[vietc-tray] Failed to launch settings: {}", e),
}
}),
..Default::default()
}.into(),
MenuItem::Separator,
StandardItem {
label: "Quit Viet+".into(),
activate: Box::new(|_| {
let _ = std::process::Command::new("pkill")
.arg("-x")
.arg("vietc")
.status();
std::process::exit(0);
}),
..Default::default()
}.into(),
]
}
}
fn is_daemon_running() -> bool {
std::process::Command::new("pgrep")
.arg("-x")
.arg("vietc")
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn ensure_icons_exist() {
if let Some(config_dir) = dirs::config_dir() {
let icons_dir = config_dir.join("vietc").join("icons");
let _ = std::fs::create_dir_all(&icons_dir);
let vn_path = icons_dir.join("vietc-vn.svg");
let en_path = icons_dir.join("vietc-en.svg");
let vn_svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect x="2" y="2" width="28" height="28" rx="6" fill="#e02424"/>
<text x="16" y="21" text-anchor="middle" fill="#ffffff" font-size="13" font-weight="900" font-family="system-ui, -apple-system, sans-serif" letter-spacing="0.5">VN</text>
</svg>"##;
let en_svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect x="2" y="2" width="28" height="28" rx="6" fill="#4b5563"/>
<text x="16" y="21" text-anchor="middle" fill="#ffffff" font-size="13" font-weight="900" font-family="system-ui, -apple-system, sans-serif" letter-spacing="0.5">EN</text>
</svg>"##;
let _ = std::fs::write(&vn_path, vn_svg);
let _ = std::fs::write(&en_path, en_svg);
let hicolor_apps_dir = icons_dir.join("hicolor").join("scalable").join("apps");
let _ = std::fs::create_dir_all(&hicolor_apps_dir);
let _ = std::fs::write(hicolor_apps_dir.join("vietc-vn.svg"), vn_svg);
let _ = std::fs::write(hicolor_apps_dir.join("vietc-en.svg"), en_svg);
}
} }
fn main() { fn main() {
let service = ksni::TrayService::new(VietcTray); eprintln!("[vietc-tray] Starting tray (exe dir: {:?})", exe_dir());
ensure_icons_exist();
if !is_daemon_running() {
let daemon_bin = find_sibling_binary("vietc");
eprintln!("[vietc-tray] Starting daemon: {}", daemon_bin);
match std::process::Command::new(&daemon_bin).spawn() {
Ok(child) => eprintln!("[vietc-tray] Daemon started (PID {})", child.id()),
Err(e) => eprintln!("[vietc-tray] Failed to start daemon: {}", e),
}
} else {
eprintln!("[vietc-tray] Daemon already running");
}
let tray = VietcTray {
active_mode: "en".into(),
autostart_enabled: config::is_autostart_installed(),
};
let service = ksni::TrayService::new(tray);
let handle = service.handle();
service.spawn(); service.spawn();
let handle_clone = handle.clone();
std::thread::spawn(move || {
let status_path = dirs::config_dir()
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
.join("vietc")
.join("status");
loop {
let active_mode = if let Ok(content) = std::fs::read_to_string(&status_path) {
content.trim().to_string()
} else {
let config = Config::load();
if config.start_enabled { "vn".to_string() } else { "en".to_string() }
};
let autostart_enabled = config::is_autostart_installed();
let _ = handle_clone.update(move |t| {
t.active_mode = active_mode;
t.autostart_enabled = autostart_enabled;
});
std::thread::sleep(std::time::Duration::from_millis(250));
}
});
if config::is_autostart_installed() {
config::install_autostart_force();
}
loop { loop {
std::thread::park(); std::thread::park();
} }

View file

@ -29,7 +29,7 @@ mod imp {
glib::wrapper! { glib::wrapper! {
pub struct SettingsWindow(ObjectSubclass<imp::SettingsWindow>) pub struct SettingsWindow(ObjectSubclass<imp::SettingsWindow>)
@extends gio::ApplicationWindow, gtk::ApplicationWindow, @extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget,
@implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable; @implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable;
} }
@ -38,7 +38,7 @@ impl SettingsWindow {
let win: Self = glib::Object::builder() let win: Self = glib::Object::builder()
.property("application", app) .property("application", app)
.property("default-width", 580) .property("default-width", 580)
.property("default-height", 720) .property("default-height", 500)
.property("title", "Viet+ Settings") .property("title", "Viet+ Settings")
.build(); .build();
@ -52,6 +52,7 @@ impl SettingsWindow {
fn build_ui(&self) { fn build_ui(&self) {
let config = Config::load(); let config = Config::load();
let trigger_keys = config.auto_restore.trigger_keys.clone();
// Toast overlay for notifications // Toast overlay for notifications
let toast_overlay = adw::ToastOverlay::new(); let toast_overlay = adw::ToastOverlay::new();
@ -61,14 +62,19 @@ impl SettingsWindow {
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
.build(); .build();
// Header bar with title widget // Header bar with view switcher
let header = adw::HeaderBar::new(); let header = adw::HeaderBar::new();
let title_widget = adw::WindowTitle::builder() // View Stack
.title("Viet+") let stack = adw::ViewStack::builder()
.subtitle("Vietnamese Input Method") .vexpand(true)
.build(); .build();
header.set_title_widget(Some(&title_widget));
// View Switcher linked to stack
let switcher = adw::ViewSwitcher::builder()
.stack(&stack)
.build();
header.set_title_widget(Some(&switcher));
// Save button (suggested action) // Save button (suggested action)
let save_btn = gtk::Button::builder() let save_btn = gtk::Button::builder()
@ -76,7 +82,7 @@ impl SettingsWindow {
.css_classes(["suggested-action"]) .css_classes(["suggested-action"])
.tooltip_text("Save settings (Ctrl+S)") .tooltip_text("Save settings (Ctrl+S)")
.build(); .build();
header.add_end(&save_btn); header.pack_end(&save_btn);
// Keyboard shortcut for save // Keyboard shortcut for save
let controller = gtk::EventControllerKey::new(); let controller = gtk::EventControllerKey::new();
@ -95,21 +101,11 @@ impl SettingsWindow {
main_box.append(&header); main_box.append(&header);
// Scrollable content area // ==================== Page 1: Typing ====================
let scrolled = gtk::ScrolledWindow::builder() let typing_box = gtk::Box::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) .orientation(gtk::Orientation::Vertical)
.spacing(8) .spacing(8)
.margin_top(8) .margin_top(16)
.margin_bottom(16) .margin_bottom(16)
.margin_start(16) .margin_start(16)
.margin_end(16) .margin_end(16)
@ -145,7 +141,7 @@ impl SettingsWindow {
method_group.add(&method_row); method_group.add(&method_row);
method_group.add(&toggle_row); method_group.add(&toggle_row);
content.append(&method_group); typing_box.append(&method_group);
// ========== General Section ========== // ========== General Section ==========
let general_group = adw::PreferencesGroup::builder() let general_group = adw::PreferencesGroup::builder()
@ -170,12 +166,37 @@ impl SettingsWindow {
.active(config.auto_restore.enabled) .active(config.auto_restore.enabled)
.build(); .build();
let autostart_row = adw::SwitchRow::builder()
.title("Autostart on Boot")
.subtitle("Start Viet+ automatically when your system starts")
.active(crate::config::is_autostart_installed())
.build();
general_group.add(&start_enabled_row); general_group.add(&start_enabled_row);
general_group.add(&app_memory_row); general_group.add(&app_memory_row);
general_group.add(&auto_restore_row); general_group.add(&auto_restore_row);
content.append(&general_group); general_group.add(&autostart_row);
typing_box.append(&general_group);
let typing_clamp = adw::Clamp::builder().maximum_size(540).tightening_threshold(400).build();
typing_clamp.set_child(Some(&typing_box));
let typing_scrolled = gtk::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&typing_clamp)
.build();
stack.add_titled(&typing_scrolled, Some("typing"), "Typing");
// ==================== Page 2: Apps ====================
let apps_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.margin_top(16)
.margin_bottom(16)
.margin_start(16)
.margin_end(16)
.build();
// ========== App Lists Section ==========
let apps_group = adw::PreferencesGroup::builder() let apps_group = adw::PreferencesGroup::builder()
.title("Application Lists") .title("Application Lists")
.description("Override input method for specific applications") .description("Override input method for specific applications")
@ -194,7 +215,6 @@ impl SettingsWindow {
let english_entry = gtk::SearchEntry::builder() let english_entry = gtk::SearchEntry::builder()
.placeholder_text("Add application name...") .placeholder_text("Add application name...")
.hexpand(true) .hexpand(true)
.show_close_icon(false)
.build(); .build();
let english_add = gtk::Button::builder() let english_add = gtk::Button::builder()
@ -243,7 +263,6 @@ impl SettingsWindow {
let viet_entry = gtk::SearchEntry::builder() let viet_entry = gtk::SearchEntry::builder()
.placeholder_text("Add application name...") .placeholder_text("Add application name...")
.hexpand(true) .hexpand(true)
.show_close_icon(false)
.build(); .build();
let viet_add = gtk::Button::builder() let viet_add = gtk::Button::builder()
@ -279,7 +298,26 @@ impl SettingsWindow {
viet_row.add_suffix(&viet_header); viet_row.add_suffix(&viet_header);
apps_group.add(&viet_row); apps_group.add(&viet_row);
content.append(&apps_group); apps_box.append(&apps_group);
let apps_clamp = adw::Clamp::builder().maximum_size(540).tightening_threshold(400).build();
apps_clamp.set_child(Some(&apps_box));
let apps_scrolled = gtk::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&apps_clamp)
.build();
stack.add_titled(&apps_scrolled, Some("apps"), "Apps");
// ==================== Page 3: Shortcuts ====================
let shortcuts_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.margin_top(16)
.margin_bottom(16)
.margin_start(16)
.margin_end(16)
.build();
// ========== Macros Section ========== // ========== Macros Section ==========
let macros_group = adw::PreferencesGroup::builder() let macros_group = adw::PreferencesGroup::builder()
@ -323,7 +361,7 @@ impl SettingsWindow {
macros_group.add(&macros_list); macros_group.add(&macros_list);
macros_group.add(&macro_input); macros_group.add(&macro_input);
content.append(&macros_group); shortcuts_box.append(&macros_group);
// ========== Reference Card ========== // ========== Reference Card ==========
let ref_group = adw::PreferencesGroup::builder() let ref_group = adw::PreferencesGroup::builder()
@ -343,7 +381,16 @@ impl SettingsWindow {
ref_row.add_suffix(&ref_icon); ref_row.add_suffix(&ref_icon);
ref_group.add(&ref_row); ref_group.add(&ref_row);
content.append(&ref_group); shortcuts_box.append(&ref_group);
let shortcuts_clamp = adw::Clamp::builder().maximum_size(540).tightening_threshold(400).build();
shortcuts_clamp.set_child(Some(&shortcuts_box));
let shortcuts_scrolled = gtk::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&shortcuts_clamp)
.build();
stack.add_titled(&shortcuts_scrolled, Some("shortcuts"), "Shortcuts");
// ========== Status Bar ========== // ========== Status Bar ==========
let status_box = gtk::Box::builder() let status_box = gtk::Box::builder()
@ -366,13 +413,11 @@ impl SettingsWindow {
status_box.append(&status_icon); status_box.append(&status_icon);
status_box.append(&status_label); status_box.append(&status_label);
clamp.set_child(Some(&content)); main_box.append(&stack);
scrolled.set_child(Some(&clamp));
main_box.append(&scrolled);
main_box.append(&status_box); main_box.append(&status_box);
toast_overlay.set_child(Some(&main_box)); toast_overlay.set_child(Some(&main_box));
self.set_content(Some(&toast_overlay)); adw::prelude::AdwApplicationWindowExt::set_content(self, Some(&toast_overlay));
// ========== Callbacks ========== // ========== Callbacks ==========
@ -397,15 +442,19 @@ impl SettingsWindow {
let win = self.clone(); let win = self.clone();
auto_restore_row.connect_active_notify(move |_| { win.mark_dirty(); }); auto_restore_row.connect_active_notify(move |_| { win.mark_dirty(); });
} }
{
let win = self.clone();
autostart_row.connect_active_notify(move |_| { win.mark_dirty(); });
}
// Add English app // Add English app
self.setup_add_app(&english_entry, &english_add, &english_list, &status_label); self.setup_add_app(&english_entry, &english_add, &english_list, &status_label, &status_icon);
// Add Vietnamese app // Add Vietnamese app
self.setup_add_app(&viet_entry, &viet_add, &viet_list, &status_label); self.setup_add_app(&viet_entry, &viet_add, &viet_list, &status_label, &status_icon);
// Add macro // Add macro
self.setup_add_macro(&macro_shortcut, &macro_expansion, &macro_add, &macros_list, &status_label); self.setup_add_macro(&macro_shortcut, &macro_expansion, &macro_add, &macros_list, &status_label, &status_icon);
// Save button // Save button
{ {
@ -414,6 +463,7 @@ impl SettingsWindow {
let start_switch = start_enabled_row.clone(); let start_switch = start_enabled_row.clone();
let app_switch = app_memory_row.clone(); let app_switch = app_memory_row.clone();
let auto_switch = auto_restore_row.clone(); let auto_switch = auto_restore_row.clone();
let autostart_switch = autostart_row.clone();
let english = english_list.clone(); let english = english_list.clone();
let viet = viet_list.clone(); let viet = viet_list.clone();
let macros = macros_list.clone(); let macros = macros_list.clone();
@ -421,6 +471,7 @@ impl SettingsWindow {
let status_icon = status_icon.clone(); let status_icon = status_icon.clone();
let toast_overlay = toast_overlay.clone(); let toast_overlay = toast_overlay.clone();
let win = self.clone(); let win = self.clone();
let trigger_keys = trigger_keys.clone();
save_btn.connect_clicked(move |_| { save_btn.connect_clicked(move |_| {
let method = match method_row.selected() { let method = match method_row.selected() {
@ -443,6 +494,7 @@ impl SettingsWindow {
start_enabled: start_switch.is_active(), start_enabled: start_switch.is_active(),
auto_restore: crate::config::AutoRestoreConfig { auto_restore: crate::config::AutoRestoreConfig {
enabled: auto_switch.is_active(), enabled: auto_switch.is_active(),
trigger_keys: trigger_keys.clone(),
}, },
app_state: crate::config::AppStateConfig { app_state: crate::config::AppStateConfig {
enabled: app_switch.is_active(), enabled: app_switch.is_active(),
@ -452,6 +504,13 @@ impl SettingsWindow {
macros: macro_map, macros: macro_map,
}; };
// Save autostart state
if autostart_switch.is_active() {
crate::config::install_autostart_force();
} else {
crate::config::uninstall_autostart();
}
match config.save() { match config.save() {
Ok(()) => { Ok(()) => {
status_label.set_text(&format!("Saved to {}", Config::path().display())); status_label.set_text(&format!("Saved to {}", Config::path().display()));
@ -485,12 +544,14 @@ impl SettingsWindow {
entry: &gtk::SearchEntry, entry: &gtk::SearchEntry,
add_btn: &gtk::Button, add_btn: &gtk::Button,
list: &gtk::ListBox, list: &gtk::ListBox,
status: &gtk::Label, status_label: &gtk::Label,
status_icon: &gtk::Image,
) { ) {
let add_fn = { let add_fn = {
let list = list.clone(); let list = list.clone();
let entry = entry.clone(); let entry = entry.clone();
let status = status.clone(); let status_label = status_label.clone();
let status_icon = status_icon.clone();
let win = self.clone(); let win = self.clone();
move || { move || {
let text = entry.text().to_string(); let text = entry.text().to_string();
@ -498,8 +559,8 @@ impl SettingsWindow {
let row = Self::make_app_row_static(&text, &list); let row = Self::make_app_row_static(&text, &list);
list.append(&row); list.append(&row);
entry.set_text(""); entry.set_text("");
status.set_text("Unsaved changes"); status_label.set_text("Unsaved changes");
status.set_icon_name("dialog-information-symbolic"); status_icon.set_icon_name(Some("dialog-information-symbolic"));
win.mark_dirty(); win.mark_dirty();
} }
} }
@ -518,13 +579,15 @@ impl SettingsWindow {
expansion: &gtk::SearchEntry, expansion: &gtk::SearchEntry,
add_btn: &gtk::Button, add_btn: &gtk::Button,
list: &gtk::ListBox, list: &gtk::ListBox,
status: &gtk::Label, status_label: &gtk::Label,
status_icon: &gtk::Image,
) { ) {
let add_fn = { let add_fn = {
let list = list.clone(); let list = list.clone();
let shortcut = shortcut.clone(); let shortcut = shortcut.clone();
let expansion = expansion.clone(); let expansion = expansion.clone();
let status = status.clone(); let status_label = status_label.clone();
let status_icon = status_icon.clone();
let win = self.clone(); let win = self.clone();
move || { move || {
let s = shortcut.text().to_string(); let s = shortcut.text().to_string();
@ -534,8 +597,8 @@ impl SettingsWindow {
list.append(&row); list.append(&row);
shortcut.set_text(""); shortcut.set_text("");
expansion.set_text(""); expansion.set_text("");
status.set_text("Unsaved changes"); status_label.set_text("Unsaved changes");
status.set_icon_name("dialog-information-symbolic"); status_icon.set_icon_name(Some("dialog-information-symbolic"));
win.mark_dirty(); win.mark_dirty();
} }
} }

View file

@ -3,7 +3,7 @@ Description=Viet+ Vietnamese IME Daemon
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/local/bin/vietc ExecStart=/usr/bin/vietc
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5

View file

@ -3,6 +3,7 @@
input_method = "telex" input_method = "telex"
toggle_key = "space" toggle_key = "space"
start_enabled = true start_enabled = true
grab = false
[auto_restore] [auto_restore]
enabled = true enabled = true