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:
parent
16a0d73a6e
commit
95f661aaa0
32 changed files with 2170 additions and 738 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,3 +10,4 @@ Cargo.lock
|
||||||
*.AppImage
|
*.AppImage
|
||||||
packaging/appimage/AppDir/
|
packaging/appimage/AppDir/
|
||||||
packaging/deb/vietc_*/
|
packaging/deb/vietc_*/
|
||||||
|
packaging/appimage/appimagetool
|
||||||
|
|
|
||||||
11
Makefile
11
Makefile
|
|
@ -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
115
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, "oá".to_string()));
|
||||||
|
assert_eq!(get_display(&events), "oá");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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, "uý".to_string()));
|
||||||
|
assert_eq!(get_display(&events), "uý");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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)"
|
|
||||||
|
|
@ -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/"
|
|
||||||
|
|
@ -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."
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -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 }
|
|
||||||
|
|
|
||||||
|
|
@ -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") {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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
1
status
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
vn
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
214
ui/src/tray.rs
214
ui/src/tray.rs
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
151
ui/src/window.rs
151
ui/src/window.rs
|
|
@ -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(¯os_list);
|
macros_group.add(¯os_list);
|
||||||
macros_group.add(¯o_input);
|
macros_group.add(¯o_input);
|
||||||
content.append(¯os_group);
|
shortcuts_box.append(¯os_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(¯o_shortcut, ¯o_expansion, ¯o_add, ¯os_list, &status_label);
|
self.setup_add_macro(¯o_shortcut, ¯o_expansion, ¯o_add, ¯os_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: >k::SearchEntry,
|
entry: >k::SearchEntry,
|
||||||
add_btn: >k::Button,
|
add_btn: >k::Button,
|
||||||
list: >k::ListBox,
|
list: >k::ListBox,
|
||||||
status: >k::Label,
|
status_label: >k::Label,
|
||||||
|
status_icon: >k::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: >k::SearchEntry,
|
expansion: >k::SearchEntry,
|
||||||
add_btn: >k::Button,
|
add_btn: >k::Button,
|
||||||
list: >k::ListBox,
|
list: >k::ListBox,
|
||||||
status: >k::Label,
|
status_label: >k::Label,
|
||||||
|
status_icon: >k::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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue