Viet+ v0.1.0 - Vietnamese Input Method for Linux
Features: - Direct Input Engine (no pre-edit buffer, no underline) - Telex + VNI input methods - Auto-restore English words - ESC undo (strip tones) - Smart per-app memory - Macro expansion (ko→không, dc→được, vs→với, lm→làm) - Triple backend: uinput, X11 XTEST, Wayland IM - Hot-reload config - 148 tests passing Packaging: - .deb package - AppImage support - AUR PKGBUILD - Flatpak manifest - Systemd user service
This commit is contained in:
commit
16a0d73a6e
44 changed files with 5871 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/target
|
||||||
|
/ui/target
|
||||||
|
Cargo.lock
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.deb
|
||||||
|
*.AppImage
|
||||||
|
packaging/appimage/AppDir/
|
||||||
|
packaging/deb/vietc_*/
|
||||||
8
Cargo.toml
Normal file
8
Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["engine", "protocol", "daemon", "cli"]
|
||||||
|
exclude = ["ui"]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
vietc-engine = { path = "engine" }
|
||||||
|
vietc-protocol = { path = "protocol" }
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Viet+ Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
119
Makefile
Normal file
119
Makefile
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
.PHONY: build build-x11 build-wayland build-all build-ui build-tray test test-cli run run-x11 run-wayland clean install install-x11 install-wayland install-ui install-tray install-all-ui install-config appimage deb fmt lint tree
|
||||||
|
|
||||||
|
# Build core crates
|
||||||
|
build:
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Build with X11 support
|
||||||
|
build-x11:
|
||||||
|
cargo build --release --features x11
|
||||||
|
|
||||||
|
# Build with Wayland IM protocol
|
||||||
|
build-wayland:
|
||||||
|
cargo build --release --features wayland
|
||||||
|
|
||||||
|
# Build with all backends
|
||||||
|
build-all:
|
||||||
|
cargo build --release --features "x11,wayland"
|
||||||
|
|
||||||
|
# Build settings UI (requires GTK4 + libadwaita)
|
||||||
|
build-ui:
|
||||||
|
cd ui && cargo build --release --bin vietc-settings
|
||||||
|
|
||||||
|
# Build tray icon app (requires libdbus-1-dev)
|
||||||
|
build-tray:
|
||||||
|
cd ui && cargo build --release --bin vietc-tray
|
||||||
|
|
||||||
|
# Build debug
|
||||||
|
build-dev:
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
test:
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Run the interactive CLI test harness
|
||||||
|
test-cli:
|
||||||
|
cargo run --bin vietc-cli
|
||||||
|
|
||||||
|
# Run the daemon (needs root for evdev/uinput)
|
||||||
|
run: build-dev
|
||||||
|
sudo cargo run --bin vietc
|
||||||
|
|
||||||
|
# Run daemon with X11 support
|
||||||
|
run-x11: build-dev
|
||||||
|
cargo build --features x11
|
||||||
|
sudo cargo run --bin vietc --features x11
|
||||||
|
|
||||||
|
# Run daemon with Wayland IM protocol
|
||||||
|
run-wayland: build-dev
|
||||||
|
cargo build --features wayland
|
||||||
|
sudo cargo run --bin vietc --features wayland
|
||||||
|
|
||||||
|
# Run daemon in release mode
|
||||||
|
run-release: build
|
||||||
|
sudo target/release/vietc
|
||||||
|
|
||||||
|
# Install to /usr/local/bin
|
||||||
|
install: build
|
||||||
|
sudo cp target/release/vietc /usr/local/bin/vietc
|
||||||
|
@echo "Installed vietc to /usr/local/bin/"
|
||||||
|
|
||||||
|
# Install with X11 support
|
||||||
|
install-x11: build-x11
|
||||||
|
sudo cp target/release/vietc /usr/local/bin/vietc
|
||||||
|
@echo "Installed vietc (with X11) to /usr/local/bin/"
|
||||||
|
|
||||||
|
# Install with Wayland IM protocol
|
||||||
|
install-wayland: build-wayland
|
||||||
|
sudo cp target/release/vietc /usr/local/bin/vietc
|
||||||
|
@echo "Installed vietc (with Wayland IM) to /usr/local/bin/"
|
||||||
|
|
||||||
|
# Install settings UI
|
||||||
|
install-ui: build-ui
|
||||||
|
sudo cp ui/target/release/vietc-settings /usr/local/bin/vietc-settings
|
||||||
|
@echo "Installed vietc-settings to /usr/local/bin/"
|
||||||
|
|
||||||
|
# Install tray icon app
|
||||||
|
install-tray: build-tray
|
||||||
|
sudo cp ui/target/release/vietc-tray /usr/local/bin/vietc-tray
|
||||||
|
@echo "Installed vietc-tray to /usr/local/bin/"
|
||||||
|
|
||||||
|
# Install all UI binaries
|
||||||
|
install-all-ui: install-ui install-tray
|
||||||
|
|
||||||
|
# Install config to user dir
|
||||||
|
install-config:
|
||||||
|
mkdir -p ~/.config/vietc
|
||||||
|
cp vietc.toml ~/.config/vietc/config.toml
|
||||||
|
@echo "Config installed to ~/.config/vietc/config.toml"
|
||||||
|
|
||||||
|
# Build AppImage (requires appimagetool or linuxdeploy)
|
||||||
|
appimage: build-all
|
||||||
|
VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \
|
||||||
|
bash packaging/appimage/build-appimage.sh "$$VERSION"
|
||||||
|
|
||||||
|
# Build .deb package (requires dpkg-deb)
|
||||||
|
deb: build-all
|
||||||
|
VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \
|
||||||
|
bash packaging/deb/build-deb.sh "$$VERSION"
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
|
clean:
|
||||||
|
cargo clean
|
||||||
|
cd ui && cargo clean
|
||||||
|
rm -rf packaging/appimage/AppDir packaging/appimage/*.AppImage packaging/deb/vietc_*
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
fmt:
|
||||||
|
cargo fmt
|
||||||
|
cd ui && cargo fmt
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
lint:
|
||||||
|
cargo clippy -- -D warnings
|
||||||
|
cd ui && cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
# Show project structure
|
||||||
|
tree:
|
||||||
|
@find . -type f \( -name "*.rs" -o -name "*.toml" \) | grep -v target | sort
|
||||||
322
README.md
Normal file
322
README.md
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/Platform-Linux-blue?style=for-the-badge" alt="Platform">
|
||||||
|
<img src="https://img.shields.io/badge/Language-Rust-orange?style=for-the-badge" alt="Rust">
|
||||||
|
<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License">
|
||||||
|
<img src="https://img.shields.io/badge/Version-0.1.0-purple?style=for-the-badge" alt="Version">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center">
|
||||||
|
<br>
|
||||||
|
Viet+
|
||||||
|
<br>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<b>Vietnamese Input Method for Linux</b><br>
|
||||||
|
<sub>Zero underline • Native Wayland/X11 • Built in Rust</sub>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="#features">Features</a> •
|
||||||
|
<a href="#quick-start">Quick Start</a> •
|
||||||
|
<a href="#input-methods">Input Methods</a> •
|
||||||
|
<a href="#configuration">Configuration</a> •
|
||||||
|
<a href="#installation">Installation</a> •
|
||||||
|
<a href="#building">Building</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why Viet+?
|
||||||
|
|
||||||
|
Most Vietnamese input methods on Linux suffer from **underline hell** — pre-edit buffers that duplicate text, show ugly underlines, and break your flow. Viet+ takes a different approach:
|
||||||
|
|
||||||
|
> **Direct Input** — keystrokes are instantly converted to Unicode. No pre-edit buffer. No underline. No text duplication. Just pure Vietnamese.
|
||||||
|
|
||||||
|
Inspired by [Gõ Nhanh](https://github.com/nickel-lang/nickel)'s brilliant UX, rebuilt native for Linux.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Direct Input Engine** | No pre-edit buffer, no underline, no text duplication |
|
||||||
|
| **Telex & VNI** | Both input methods fully supported |
|
||||||
|
| **Auto-Restore English** | Hit space/ESC to undo accidental Vietnamese conversion |
|
||||||
|
| **ESC Undo** | Strip all tones from the current word instantly |
|
||||||
|
| **Smart App Memory** | Remembers Vietnamese/English per application |
|
||||||
|
| **Macro Expansion** | Custom shortcuts (e.g., `ko` → `không`) |
|
||||||
|
| **Triple Backend** | uinput (universal), X11 XTEST, Wayland zwp_input_method_v2 |
|
||||||
|
| **Hot Reload** | Config changes apply without restart |
|
||||||
|
| **Settings UI** | GTK4/Libadwaita GUI (optional) |
|
||||||
|
| **System Tray** | KStatusNotifierItem tray app |
|
||||||
|
| **Zero Telemetry** | No keylogging, no disk writes, fully FOSS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone and build
|
||||||
|
git clone https://github.com/vietplus/vietplus.git
|
||||||
|
cd vietplus
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Test the engine interactively
|
||||||
|
make test-cli
|
||||||
|
|
||||||
|
# Run the daemon (requires root for evdev/uinput)
|
||||||
|
sudo make run
|
||||||
|
|
||||||
|
# Or install system-wide
|
||||||
|
sudo make install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Input Methods
|
||||||
|
|
||||||
|
### Telex (Default)
|
||||||
|
|
||||||
|
| Key | Result | Example |
|
||||||
|
|-----|--------|---------|
|
||||||
|
| `aa` | ă | `dan` → `dăn` |
|
||||||
|
| `ee` | ê | `men` → `mên` |
|
||||||
|
| `oo` | ô | `to` → `tô` |
|
||||||
|
| `aw` | â | `an` → `ân` |
|
||||||
|
| `ow` | ô | `on` → `ôn` |
|
||||||
|
| `ew` | ê | `en` → `ên` |
|
||||||
|
| `uw` | ư | `un` → `ưn` |
|
||||||
|
| `s` | á (sắc) | `as` → `á` |
|
||||||
|
| `f` | à (huyền) | `af` → `à` |
|
||||||
|
| `r` | ả (hỏi) | `ar` → `ả` |
|
||||||
|
| `x` | ã (ngã) | `ax` → `ã` |
|
||||||
|
| `j` | ạ (nặng) | `aj` → `ạ` |
|
||||||
|
| `dd` | đ | `dd` → `đ` |
|
||||||
|
|
||||||
|
### VNI
|
||||||
|
|
||||||
|
| Key | Result |
|
||||||
|
|-----|--------|
|
||||||
|
| `a1` | á |
|
||||||
|
| `a2` | à |
|
||||||
|
| `a3` | ả |
|
||||||
|
| `a4` | ã |
|
||||||
|
| `a5` | ạ |
|
||||||
|
| `a6` | ă |
|
||||||
|
| `a7` | â |
|
||||||
|
| `e8` | ê |
|
||||||
|
| `o9` | ô |
|
||||||
|
| `o0` | ơ |
|
||||||
|
| `u0` | ư |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Config file: `~/.config/vietc/config.toml` or `./vietc.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
input_method = "telex"
|
||||||
|
toggle_key = "space"
|
||||||
|
start_enabled = true
|
||||||
|
|
||||||
|
[auto_restore]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[app_state]
|
||||||
|
enabled = true
|
||||||
|
english_apps = ["code", "vim", "kitty", "foot"]
|
||||||
|
vietnamese_apps = ["telegram", "discord", "firefox"]
|
||||||
|
|
||||||
|
[macros]
|
||||||
|
ko = "không"
|
||||||
|
dc = "được"
|
||||||
|
vs = "với"
|
||||||
|
lm = "làm"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌────────────────┐
|
||||||
|
│ evdev │────▶│ Viet+ │────▶│ uinput/X11 │
|
||||||
|
│ keyboard │ │ Engine │ │ injection │
|
||||||
|
│ monitor │ │ (Telex/VNI) │ │ │
|
||||||
|
└──────────────┘ └──────────────┘ └────────────────┘
|
||||||
|
│
|
||||||
|
┌─────┴─────┐
|
||||||
|
│ App State │
|
||||||
|
│ Manager │
|
||||||
|
└───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### System Dependencies
|
||||||
|
|
||||||
|
| Component | Ubuntu/Debian | Fedora | Arch |
|
||||||
|
|-----------|--------------|--------|------|
|
||||||
|
| Core daemon | *(none)* | *(none)* | *(none)* |
|
||||||
|
| Settings UI | `libgtk-4-dev libadwaita-1-dev` | `gtk4-devel libadwaita-devel` | `gtk4 libadwaita` |
|
||||||
|
| Tray icon | `libdbus-1-dev pkg-config` | `dbus-devel pkgconf` | `dbus pkgconf` |
|
||||||
|
|
||||||
|
### Debian/Ubuntu
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make deb
|
||||||
|
sudo dpkg -i packaging/deb/vietc_0.1.0_amd64.deb
|
||||||
|
sudo apt-get install -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### AppImage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make appimage
|
||||||
|
# Requires appimagetool
|
||||||
|
appimagetool packaging/appimage/AppDir Viet+-0.1.0-x86_64.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arch Linux (AUR)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packaging/aur
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flatpak
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak-builder --user --install --force-clean build-dir \
|
||||||
|
packaging/flatpak/io.github.vietc.VietPlus.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo make install
|
||||||
|
sudo make install-ui # optional
|
||||||
|
sudo make install-tray # optional
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build core (daemon + CLI)
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Build with X11 support
|
||||||
|
make build-x11
|
||||||
|
|
||||||
|
# Build with Wayland IM protocol
|
||||||
|
make build-wayland
|
||||||
|
|
||||||
|
# Build with all backends
|
||||||
|
make build-all
|
||||||
|
|
||||||
|
# Build settings UI (requires GTK4)
|
||||||
|
make build-ui
|
||||||
|
|
||||||
|
# Build tray icon (requires libdbus-1-dev)
|
||||||
|
make build-tray
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Run interactive test harness
|
||||||
|
make test-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Make Targets
|
||||||
|
|
||||||
|
| Target | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `make build` | Build core crates |
|
||||||
|
| `make build-x11` | Build with X11 support |
|
||||||
|
| `make build-wayland` | Build with Wayland IM protocol |
|
||||||
|
| `make build-all` | Build with all backends |
|
||||||
|
| `make build-ui` | Build settings UI |
|
||||||
|
| `make build-tray` | Build tray icon app |
|
||||||
|
| `make test` | Run all tests |
|
||||||
|
| `make test-cli` | Interactive test harness |
|
||||||
|
| `make run` | Run daemon (debug) |
|
||||||
|
| `make install` | Install to /usr/local/bin |
|
||||||
|
| `make install-x11` | Install with X11 |
|
||||||
|
| `make install-wayland` | Install with Wayland IM |
|
||||||
|
| `make install-ui` | Install settings UI |
|
||||||
|
| `make install-tray` | Install tray icon |
|
||||||
|
| `make install-all-ui` | Install both UI + tray |
|
||||||
|
| `make install-config` | Install default config |
|
||||||
|
| `make appimage` | Build AppImage package |
|
||||||
|
| `make deb` | Build .deb package |
|
||||||
|
| `make clean` | Clean build artifacts |
|
||||||
|
| `make fmt` | Format code |
|
||||||
|
| `make lint` | Run clippy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
viet+/
|
||||||
|
├── engine/ # Core IME engine (Telex + VNI)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── engine.rs # Main engine orchestrator
|
||||||
|
│ │ ├── telex.rs # Telex state machine
|
||||||
|
│ │ ├── vni.rs # VNI engine
|
||||||
|
│ │ ├── english.rs # English auto-restore dictionary
|
||||||
|
│ │ └── tests.rs # 124 unit tests
|
||||||
|
│ └── Cargo.toml
|
||||||
|
├── protocol/ # Injection backends
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── inject.rs # KeyInjector trait
|
||||||
|
│ │ ├── uinput_monitor.rs # Universal uinput backend
|
||||||
|
│ │ ├── x11_inject.rs # X11 XTEST backend
|
||||||
|
│ │ └── wayland_im.rs # Wayland IM context
|
||||||
|
│ └── Cargo.toml
|
||||||
|
├── daemon/ # Background daemon
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.rs # Evdev loop, hot-reload
|
||||||
|
│ │ ├── config.rs # TOML config loader
|
||||||
|
│ │ ├── app_state.rs # Per-app state manager
|
||||||
|
│ │ └── display.rs # Display server detection
|
||||||
|
│ └── Cargo.toml
|
||||||
|
├── cli/ # Interactive test harness
|
||||||
|
├── ui/ # Settings UI + tray (GTK4/Libadwaita)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.rs # Settings app
|
||||||
|
│ │ ├── window.rs # Settings window
|
||||||
|
│ │ ├── tray.rs # System tray icon
|
||||||
|
│ │ └── config.rs # UI config reader
|
||||||
|
│ └── Cargo.toml
|
||||||
|
├── packaging/ # Distribution packages
|
||||||
|
│ ├── aur/ # Arch Linux PKGBUILD
|
||||||
|
│ ├── flatpak/ # Flatpak manifest
|
||||||
|
│ ├── appimage/ # AppImage build scripts
|
||||||
|
│ └── deb/ # Debian package
|
||||||
|
├── vietc.toml # Default configuration
|
||||||
|
├── vietc.service # Systemd user service
|
||||||
|
├── Makefile # Build targets
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<sub>Made with ❤️ for the Vietnamese Linux community</sub>
|
||||||
|
</p>
|
||||||
8
cli/Cargo.toml
Normal file
8
cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[package]
|
||||||
|
name = "vietc-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Viet+ CLI Test Harness"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
vietc-engine = { path = "../engine" }
|
||||||
106
cli/src/main.rs
Normal file
106
cli/src/main.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use vietc_engine::{Engine, EngineEvent, InputMethod};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut engine = Engine::new(InputMethod::Telex);
|
||||||
|
|
||||||
|
println!("Viet+ IME - Test Harness");
|
||||||
|
println!("==========================");
|
||||||
|
println!("Type Vietnamese using Telex input.");
|
||||||
|
println!("Press Enter to flush, type 'quit' to exit.");
|
||||||
|
println!("Toggle method with ':vni' or ':telex'");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
print!("> ");
|
||||||
|
io::stdout().flush().unwrap();
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
io::stdin().read_line(&mut input).unwrap();
|
||||||
|
let input = input.trim();
|
||||||
|
|
||||||
|
if input == "quit" || input == "exit" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if input == ":vni" {
|
||||||
|
engine.set_method(InputMethod::Vni);
|
||||||
|
println!("[Switched to VNI]");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if input == ":telex" {
|
||||||
|
engine.set_method(InputMethod::Telex);
|
||||||
|
println!("[Switched to Telex]");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if input == ":reset" {
|
||||||
|
engine.reset();
|
||||||
|
println!("[Engine reset]");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if input == ":buffer" {
|
||||||
|
println!("[Buffer: {:?}]", engine.buffer());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = String::new();
|
||||||
|
let mut events = Vec::new();
|
||||||
|
|
||||||
|
for ch in input.chars() {
|
||||||
|
if let Some(event) = engine.process_key(ch) {
|
||||||
|
events.push((ch, event.clone()));
|
||||||
|
match &event {
|
||||||
|
EngineEvent::Flush(text) => {
|
||||||
|
output.push_str(text);
|
||||||
|
}
|
||||||
|
EngineEvent::Insert(text) => {
|
||||||
|
output.push_str(text);
|
||||||
|
}
|
||||||
|
EngineEvent::AutoRestore(word) => {
|
||||||
|
// Auto-restore: delete the word and re-insert it
|
||||||
|
for _ in 0..word.len() {
|
||||||
|
output.push('\x08'); // backspace
|
||||||
|
}
|
||||||
|
output.push_str(word);
|
||||||
|
}
|
||||||
|
EngineEvent::Replace { backspaces, insert } => {
|
||||||
|
for _ in 0..*backspaces {
|
||||||
|
output.push('\x08');
|
||||||
|
}
|
||||||
|
output.push_str(insert);
|
||||||
|
}
|
||||||
|
EngineEvent::UndoTones { backspaces, restored } => {
|
||||||
|
for _ in 0..*backspaces {
|
||||||
|
output.push('\x08');
|
||||||
|
}
|
||||||
|
output.push_str(restored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush remaining buffer
|
||||||
|
if let Some(event) = engine.flush() {
|
||||||
|
match &event {
|
||||||
|
EngineEvent::Flush(text) => {
|
||||||
|
output.push_str(text);
|
||||||
|
}
|
||||||
|
EngineEvent::Insert(text) => {
|
||||||
|
output.push_str(text);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
events.push(('\n', event));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(" Events: {:?}", events);
|
||||||
|
println!(" Output: {:?}", output);
|
||||||
|
|
||||||
|
// Show what it would look like
|
||||||
|
let display: String = output.chars().filter(|c| *c != '\x08').collect();
|
||||||
|
println!(" Display: {}", display);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
daemon/Cargo.toml
Normal file
21
daemon/Cargo.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
[package]
|
||||||
|
name = "vietc-daemon"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Viet+ background daemon"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "vietc"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
x11 = ["vietc-protocol/x11"]
|
||||||
|
wayland = ["vietc-protocol/wayland-protocol"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
vietc-engine = { path = "../engine" }
|
||||||
|
vietc-protocol = { path = "../protocol" }
|
||||||
|
toml = "0.8"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
evdev = "0.12"
|
||||||
215
daemon/src/app_state.rs
Normal file
215
daemon/src/app_state.rs
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Detect the currently focused window's class name
|
||||||
|
pub fn get_focused_window_class() -> Option<String> {
|
||||||
|
// Try Wayland first (wlr-foreign-toplevel)
|
||||||
|
if let Some(class) = get_wayland_window_class() {
|
||||||
|
return Some(class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try X11 via xdotool
|
||||||
|
if let Some(class) = get_x11_window_class() {
|
||||||
|
return Some(class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try reading from /proc
|
||||||
|
if let Some(class) = get_proc_window_class() {
|
||||||
|
return Some(class);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_x11_window_class() -> Option<String> {
|
||||||
|
let output = Command::new("xdotool")
|
||||||
|
.args(["getactivewindow", "getwindowclassname"])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
let class = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if !class.is_empty() {
|
||||||
|
return Some(class.to_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_wayland_window_class() -> Option<String> {
|
||||||
|
// Try wlr-foreign-toplevel-management protocol via wlrctl
|
||||||
|
let output = Command::new("wlrctl")
|
||||||
|
.args(["toplevel", "list", "--format", "%app-id"])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
let lines = String::from_utf8_lossy(&output.stdout);
|
||||||
|
// First line is typically the focused window
|
||||||
|
if let Some(class) = lines.lines().next() {
|
||||||
|
let class = class.trim().to_string();
|
||||||
|
if !class.is_empty() {
|
||||||
|
return Some(class.to_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_proc_window_class() -> Option<String> {
|
||||||
|
// Read /proc/active-windows if available (some compositors expose this)
|
||||||
|
let content = fs::read_to_string("/proc/active-windows").ok()?;
|
||||||
|
// Format: pid window_class window_title
|
||||||
|
content.lines().next()?.split_whitespace().nth(1).map(|s| s.to_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages per-app IME state
|
||||||
|
pub struct AppStateManager {
|
||||||
|
/// Current app class (lowercase)
|
||||||
|
current_app: String,
|
||||||
|
/// Per-app overrides (user toggled manually)
|
||||||
|
overrides: HashMap<String, bool>,
|
||||||
|
/// Default English apps from config
|
||||||
|
english_apps: Vec<String>,
|
||||||
|
/// Default Vietnamese apps from config
|
||||||
|
vietnamese_apps: Vec<String>,
|
||||||
|
/// Global enabled state
|
||||||
|
global_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppStateManager {
|
||||||
|
pub fn new(
|
||||||
|
english_apps: Vec<String>,
|
||||||
|
vietnamese_apps: Vec<String>,
|
||||||
|
global_enabled: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
current_app: String::new(),
|
||||||
|
overrides: HashMap::new(),
|
||||||
|
english_apps: english_apps.iter().map(|s| s.to_lowercase()).collect(),
|
||||||
|
vietnamese_apps: vietnamese_apps.iter().map(|s| s.to_lowercase()).collect(),
|
||||||
|
global_enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if focused app changed and return whether engine should be enabled
|
||||||
|
pub fn update(&mut self) -> Option<bool> {
|
||||||
|
let new_class = get_focused_window_class().unwrap_or_default();
|
||||||
|
|
||||||
|
if new_class == self.current_app {
|
||||||
|
return None; // No change
|
||||||
|
}
|
||||||
|
|
||||||
|
let old_app = self.current_app.clone();
|
||||||
|
self.current_app = new_class;
|
||||||
|
|
||||||
|
eprintln!("[vietc] App: {} → {}", old_app, self.current_app);
|
||||||
|
|
||||||
|
let should_enable = self.get_default_state();
|
||||||
|
Some(should_enable)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the default Vietnamese state for the current app
|
||||||
|
fn get_default_state(&self) -> bool {
|
||||||
|
if !self.global_enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user override first
|
||||||
|
if let Some(&override_state) = self.overrides.get(&self.current_app) {
|
||||||
|
return override_state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check config defaults
|
||||||
|
for pattern in &self.english_apps {
|
||||||
|
if self.current_app.contains(pattern.as_str()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pattern in &self.vietnamese_apps {
|
||||||
|
if self.current_app.contains(pattern.as_str()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: enabled
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle the IME state for the current app (manual override)
|
||||||
|
pub fn toggle_current_app(&mut self) -> bool {
|
||||||
|
let current_state = self.get_default_state();
|
||||||
|
let new_state = !current_state;
|
||||||
|
self.overrides.insert(self.current_app.clone(), new_state);
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] {} → {} (manual override)",
|
||||||
|
self.current_app,
|
||||||
|
if new_state { "Vietnamese" } else { "English" }
|
||||||
|
);
|
||||||
|
new_state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all overrides
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn clear_overrides(&mut self) {
|
||||||
|
self.overrides.clear();
|
||||||
|
eprintln!("[vietc] All app overrides cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update app lists from reloaded config
|
||||||
|
pub fn update_lists(&mut self, english_apps: Vec<String>, vietnamese_apps: Vec<String>) {
|
||||||
|
self.english_apps = english_apps.iter().map(|s| s.to_lowercase()).collect();
|
||||||
|
self.vietnamese_apps = vietnamese_apps.iter().map(|s| s.to_lowercase()).collect();
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] App lists updated: {} English, {} Vietnamese",
|
||||||
|
self.english_apps.len(),
|
||||||
|
self.vietnamese_apps.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save overrides to config file
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn save_overrides(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let path = override_path();
|
||||||
|
let content = toml::to_string(&self.overrides)?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::write(&path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load overrides from config file
|
||||||
|
pub fn load_overrides(&mut self) {
|
||||||
|
let path = override_path();
|
||||||
|
if let Ok(content) = fs::read_to_string(&path) {
|
||||||
|
if let Ok(overrides) = toml::from_str::<HashMap<String, bool>>(&content) {
|
||||||
|
self.overrides = overrides;
|
||||||
|
eprintln!("[vietc] Loaded {} app overrides", self.overrides.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn current_app(&self) -> &str {
|
||||||
|
&self.current_app
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn override_path() -> std::path::PathBuf {
|
||||||
|
std::env::var("XDG_CONFIG_HOME")
|
||||||
|
.ok()
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.or_else(|| {
|
||||||
|
std::env::var("HOME")
|
||||||
|
.ok()
|
||||||
|
.map(|h| std::path::PathBuf::from(h).join(".config"))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||||
|
.join("vietc")
|
||||||
|
.join("overrides.toml")
|
||||||
|
}
|
||||||
329
daemon/src/config.rs
Normal file
329
daemon/src/config.rs
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default = "default_input_method")]
|
||||||
|
pub input_method: String,
|
||||||
|
|
||||||
|
#[serde(default = "default_toggle_key")]
|
||||||
|
pub toggle_key: String,
|
||||||
|
|
||||||
|
#[serde(default = "default_start_enabled")]
|
||||||
|
pub start_enabled: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_restore: AutoRestoreConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub app_state: AppStateConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub macros: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct AutoRestoreConfig {
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_restore_keys")]
|
||||||
|
pub trigger_keys: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct AppStateConfig {
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub english_apps: Vec<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub vietnamese_apps: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AutoRestoreConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
trigger_keys: default_restore_keys(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppStateConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
english_apps: default_english_apps(),
|
||||||
|
vietnamese_apps: default_vietnamese_apps(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_input_method() -> String { "telex".into() }
|
||||||
|
fn default_toggle_key() -> String { "space".into() }
|
||||||
|
fn default_start_enabled() -> bool { true }
|
||||||
|
fn default_true() -> bool { true }
|
||||||
|
fn default_restore_keys() -> Vec<String> { vec!["space".into(), "escape".into()] }
|
||||||
|
|
||||||
|
fn default_english_apps() -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"code".into(),
|
||||||
|
"jetbrains".into(),
|
||||||
|
"intellij".into(),
|
||||||
|
"pycharm".into(),
|
||||||
|
"webstorm".into(),
|
||||||
|
"vim".into(),
|
||||||
|
"nvim".into(),
|
||||||
|
"terminal".into(),
|
||||||
|
"kitty".into(),
|
||||||
|
"alacritty".into(),
|
||||||
|
"foot".into(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_vietnamese_apps() -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"telegram".into(),
|
||||||
|
"discord".into(),
|
||||||
|
"slack".into(),
|
||||||
|
"firefox".into(),
|
||||||
|
"chromium".into(),
|
||||||
|
"thunderbird".into(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let paths = [
|
||||||
|
dirs().map(|d| d.join("vietc").join("config.toml")),
|
||||||
|
Some(PathBuf::from("vietc.toml")),
|
||||||
|
];
|
||||||
|
|
||||||
|
for path in paths.into_iter().flatten() {
|
||||||
|
if path.exists() {
|
||||||
|
let content = fs::read_to_string(&path)?;
|
||||||
|
let config: Config = toml::from_str(&content)?;
|
||||||
|
eprintln!("[vietc] Loaded config from: {}", path.display());
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("[vietc] Using default config");
|
||||||
|
Ok(Self::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_from(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let content = fs::read_to_string(path)?;
|
||||||
|
let config: Config = toml::from_str(&content)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
let mut macros = HashMap::new();
|
||||||
|
macros.insert("ko".into(), "không".into());
|
||||||
|
macros.insert("kc".into(), "không có".into());
|
||||||
|
macros.insert("ko dc".into(), "không được".into());
|
||||||
|
macros.insert("dc".into(), "được".into());
|
||||||
|
macros.insert("ng".into(), "người".into());
|
||||||
|
macros.insert("nk".into(), "như".into());
|
||||||
|
macros.insert("vs".into(), "với".into());
|
||||||
|
macros.insert("lm".into(), "làm".into());
|
||||||
|
macros.insert("rd".into(), "rất".into());
|
||||||
|
macros.insert("bt".into(), "biết".into());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
input_method: default_input_method(),
|
||||||
|
toggle_key: default_toggle_key(),
|
||||||
|
start_enabled: default_start_enabled(),
|
||||||
|
auto_restore: AutoRestoreConfig::default(),
|
||||||
|
app_state: AppStateConfig::default(),
|
||||||
|
macros,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dirs() -> Option<PathBuf> {
|
||||||
|
std::env::var("XDG_CONFIG_HOME")
|
||||||
|
.ok()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| {
|
||||||
|
std::env::var("HOME")
|
||||||
|
.ok()
|
||||||
|
.map(|h| PathBuf::from(h).join(".config"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_config_path() -> PathBuf {
|
||||||
|
let paths = [
|
||||||
|
dirs().map(|d| d.join("vietc").join("config.toml")),
|
||||||
|
Some(PathBuf::from("vietc.toml")),
|
||||||
|
];
|
||||||
|
|
||||||
|
for path in paths.into_iter().flatten() {
|
||||||
|
if path.exists() {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to current directory
|
||||||
|
PathBuf::from("vietc.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_full_config() {
|
||||||
|
let toml = r#"
|
||||||
|
input_method = "vni"
|
||||||
|
toggle_key = "shift"
|
||||||
|
start_enabled = false
|
||||||
|
|
||||||
|
[auto_restore]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[app_state]
|
||||||
|
enabled = true
|
||||||
|
english_apps = ["code", "vim"]
|
||||||
|
vietnamese_apps = ["telegram", "discord"]
|
||||||
|
|
||||||
|
[macros]
|
||||||
|
ko = "không"
|
||||||
|
dc = "được"
|
||||||
|
vs = "với"
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(config.input_method, "vni");
|
||||||
|
assert_eq!(config.toggle_key, "shift");
|
||||||
|
assert!(!config.start_enabled);
|
||||||
|
assert!(!config.auto_restore.enabled);
|
||||||
|
assert!(config.app_state.enabled);
|
||||||
|
assert_eq!(config.app_state.english_apps, vec!["code", "vim"]);
|
||||||
|
assert_eq!(config.app_state.vietnamese_apps, vec!["telegram", "discord"]);
|
||||||
|
assert_eq!(config.macros.get("ko").unwrap(), "không");
|
||||||
|
assert_eq!(config.macros.get("dc").unwrap(), "được");
|
||||||
|
assert_eq!(config.macros.get("vs").unwrap(), "với");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_config_uses_defaults() {
|
||||||
|
let toml = "";
|
||||||
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(config.input_method, "telex");
|
||||||
|
assert_eq!(config.toggle_key, "space");
|
||||||
|
assert!(config.start_enabled);
|
||||||
|
assert!(config.auto_restore.enabled);
|
||||||
|
assert!(config.app_state.enabled);
|
||||||
|
assert!(!config.app_state.english_apps.is_empty());
|
||||||
|
assert!(!config.app_state.vietnamese_apps.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_partial_config() {
|
||||||
|
let toml = r#"
|
||||||
|
input_method = "vni"
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(config.input_method, "vni");
|
||||||
|
assert_eq!(config.toggle_key, "space"); // default
|
||||||
|
assert!(config.start_enabled); // default
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_macros_only() {
|
||||||
|
let toml = r#"
|
||||||
|
[macros]
|
||||||
|
hello = "world"
|
||||||
|
foo = "bar"
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(config.macros.len(), 2);
|
||||||
|
assert_eq!(config.macros.get("hello").unwrap(), "world");
|
||||||
|
assert_eq!(config.macros.get("foo").unwrap(), "bar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_macros() {
|
||||||
|
let toml = r#"
|
||||||
|
[macros]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
|
assert!(config.macros.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_app_lists() {
|
||||||
|
let toml = r#"
|
||||||
|
[app_state]
|
||||||
|
english_apps = ["vim", "neovim", "kitty"]
|
||||||
|
vietnamese_apps = ["zalo", "messenger"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(config.app_state.english_apps, vec!["vim", "neovim", "kitty"]);
|
||||||
|
assert_eq!(config.app_state.vietnamese_apps, vec!["zalo", "messenger"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config_has_macros() {
|
||||||
|
let config = Config::default();
|
||||||
|
assert!(config.macros.contains_key("ko"));
|
||||||
|
assert!(config.macros.contains_key("dc"));
|
||||||
|
assert!(config.macros.contains_key("vs"));
|
||||||
|
assert!(config.macros.contains_key("lm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config_english_apps() {
|
||||||
|
let config = Config::default();
|
||||||
|
assert!(config.app_state.english_apps.contains(&"code".to_string()));
|
||||||
|
assert!(config.app_state.english_apps.contains(&"vim".to_string()));
|
||||||
|
assert!(config.app_state.english_apps.contains(&"kitty".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config_vietnamese_apps() {
|
||||||
|
let config = Config::default();
|
||||||
|
assert!(config.app_state.vietnamese_apps.contains(&"telegram".to_string()));
|
||||||
|
assert!(config.app_state.vietnamese_apps.contains(&"firefox".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_auto_restore_config() {
|
||||||
|
let toml = r#"
|
||||||
|
[auto_restore]
|
||||||
|
enabled = false
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
|
assert!(!config.auto_restore.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_invalid_toml_fails() {
|
||||||
|
let toml = "this is not valid toml {{{";
|
||||||
|
let result = toml::from_str::<Config>(toml);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_fields_ignored() {
|
||||||
|
let toml = r#"
|
||||||
|
input_method = "telex"
|
||||||
|
unknown_field = "value"
|
||||||
|
"#;
|
||||||
|
// serde's default deny_unknown_fields is not set, so this should work
|
||||||
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(config.input_method, "telex");
|
||||||
|
}
|
||||||
|
}
|
||||||
88
daemon/src/display.rs
Normal file
88
daemon/src/display.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DisplayServer {
|
||||||
|
Wayland,
|
||||||
|
X11,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect whether we're running on Wayland or X11
|
||||||
|
pub fn detect_display_server() -> DisplayServer {
|
||||||
|
// Check WAYLAND_DISPLAY first
|
||||||
|
if std::env::var("WAYLAND_DISPLAY").is_ok() {
|
||||||
|
return DisplayServer::Wayland;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check XDG_SESSION_TYPE
|
||||||
|
if let Ok(session_type) = std::env::var("XDG_SESSION_TYPE") {
|
||||||
|
if session_type.contains("wayland") {
|
||||||
|
return DisplayServer::Wayland;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if XDG_RUNTIME_DIR has wayland sockets
|
||||||
|
if let Ok(xdg_runtime) = std::env::var("XDG_RUNTIME_DIR") {
|
||||||
|
let wayland_sock = std::path::Path::new(&xdg_runtime).join("wayland-0");
|
||||||
|
if wayland_sock.exists() {
|
||||||
|
return DisplayServer::Wayland;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check DISPLAY variable
|
||||||
|
if std::env::var("DISPLAY").is_ok() {
|
||||||
|
return DisplayServer::X11;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to detect via loginctl
|
||||||
|
if let Ok(output) = Command::new("loginctl")
|
||||||
|
.args(["show-session", &get_session_id(), "-p", "Type"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
if stdout.contains("wayland") {
|
||||||
|
return DisplayServer::Wayland;
|
||||||
|
}
|
||||||
|
if stdout.contains("x11") {
|
||||||
|
return DisplayServer::X11;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisplayServer::Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_session_id() -> String {
|
||||||
|
std::env::var("XDG_SESSION_ID").unwrap_or_else(|_| "self".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a specific compositor is running
|
||||||
|
pub fn detect_compositor() -> Option<String> {
|
||||||
|
// Check common Wayland compositor env vars
|
||||||
|
let compositor_vars = [
|
||||||
|
("HYPRLAND_INSTANCE_SIGNATURE", "Hyprland"),
|
||||||
|
("SWAYSOCK", "Sway"),
|
||||||
|
("I3SOCK", "i3"),
|
||||||
|
("KWIN_SESSION", "KWin"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (var, name) in &compositor_vars {
|
||||||
|
if std::env::var(var).is_ok() {
|
||||||
|
return Some(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check via process name
|
||||||
|
if let Ok(output) = Command::new("pgrep").arg("-x").arg("hyprland").output() {
|
||||||
|
if output.status.success() {
|
||||||
|
return Some("Hyprland".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(output) = Command::new("pgrep").arg("-x").arg("sway").output() {
|
||||||
|
if output.status.success() {
|
||||||
|
return Some("Sway".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
402
daemon/src/main.rs
Normal file
402
daemon/src/main.rs
Normal file
|
|
@ -0,0 +1,402 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use vietc_engine::{Engine, EngineEvent, InputMethod};
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod app_state;
|
||||||
|
mod display;
|
||||||
|
|
||||||
|
use config::Config;
|
||||||
|
use app_state::AppStateManager;
|
||||||
|
|
||||||
|
struct Daemon {
|
||||||
|
engine: Engine,
|
||||||
|
config: Config,
|
||||||
|
config_path: PathBuf,
|
||||||
|
config_modified: std::time::SystemTime,
|
||||||
|
app_state: AppStateManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Daemon {
|
||||||
|
fn new(config: Config, config_path: PathBuf) -> Self {
|
||||||
|
let method = match config.input_method.as_str() {
|
||||||
|
"vni" => InputMethod::Vni,
|
||||||
|
_ => InputMethod::Telex,
|
||||||
|
};
|
||||||
|
let mut engine = Engine::new(method);
|
||||||
|
engine.set_enabled(config.start_enabled);
|
||||||
|
|
||||||
|
for (shortcut, expansion) in &config.macros {
|
||||||
|
engine.add_macro(shortcut.clone(), expansion.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut app_state = AppStateManager::new(
|
||||||
|
config.app_state.english_apps.clone(),
|
||||||
|
config.app_state.vietnamese_apps.clone(),
|
||||||
|
config.start_enabled,
|
||||||
|
);
|
||||||
|
app_state.load_overrides();
|
||||||
|
|
||||||
|
let config_modified = fs::metadata(&config_path)
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.unwrap_or(std::time::SystemTime::now());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
engine,
|
||||||
|
config,
|
||||||
|
config_path,
|
||||||
|
config_modified,
|
||||||
|
app_state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reload_config(&mut self) -> bool {
|
||||||
|
let modified = fs::metadata(&self.config_path)
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.unwrap_or(std::time::SystemTime::now());
|
||||||
|
|
||||||
|
if modified <= self.config_modified {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("[vietc] Config changed, reloading...");
|
||||||
|
match Config::load_from(&self.config_path) {
|
||||||
|
Ok(new_config) => {
|
||||||
|
let method = match new_config.input_method.as_str() {
|
||||||
|
"vni" => InputMethod::Vni,
|
||||||
|
_ => InputMethod::Telex,
|
||||||
|
};
|
||||||
|
self.engine.set_method(method);
|
||||||
|
|
||||||
|
self.engine.clear_macros();
|
||||||
|
for (shortcut, expansion) in &new_config.macros {
|
||||||
|
self.engine.add_macro(shortcut.clone(), expansion.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.app_state.update_lists(
|
||||||
|
new_config.app_state.english_apps.clone(),
|
||||||
|
new_config.app_state.vietnamese_apps.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.config = new_config;
|
||||||
|
self.config_modified = modified;
|
||||||
|
eprintln!("[vietc] Config reloaded successfully");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[vietc] Failed to reload config: {}", e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_key(&mut self, ch: char) -> Vec<OutputCommand> {
|
||||||
|
let mut commands = Vec::new();
|
||||||
|
|
||||||
|
if let Some(event) = self.engine.process_key(ch) {
|
||||||
|
match event {
|
||||||
|
EngineEvent::Flush(text) => {
|
||||||
|
commands.push(OutputCommand::Type(text));
|
||||||
|
}
|
||||||
|
EngineEvent::Insert(text) => {
|
||||||
|
commands.push(OutputCommand::Type(text));
|
||||||
|
}
|
||||||
|
EngineEvent::AutoRestore(word) => {
|
||||||
|
let len = word.len();
|
||||||
|
commands.push(OutputCommand::Backspace(len));
|
||||||
|
commands.push(OutputCommand::Type(word));
|
||||||
|
}
|
||||||
|
EngineEvent::Replace { backspaces, insert } => {
|
||||||
|
commands.push(OutputCommand::Backspace(backspaces));
|
||||||
|
commands.push(OutputCommand::Type(insert));
|
||||||
|
}
|
||||||
|
EngineEvent::UndoTones { backspaces, restored } => {
|
||||||
|
commands.push(OutputCommand::Backspace(backspaces));
|
||||||
|
commands.push(OutputCommand::Type(restored));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commands
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle(&mut self) {
|
||||||
|
let new_state = self.app_state.toggle_current_app();
|
||||||
|
self.engine.set_enabled(new_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_app_change(&mut self) {
|
||||||
|
if let Some(should_enable) = self.app_state.update() {
|
||||||
|
self.engine.set_enabled(should_enable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum OutputCommand {
|
||||||
|
Type(String),
|
||||||
|
Backspace(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let config_path = config::find_config_path();
|
||||||
|
let config = Config::load()?;
|
||||||
|
let mut daemon = Daemon::new(config, config_path);
|
||||||
|
|
||||||
|
let display = display::detect_display_server();
|
||||||
|
let compositor = display::detect_compositor();
|
||||||
|
|
||||||
|
eprintln!("Viet+ Daemon v{}", env!("CARGO_PKG_VERSION"));
|
||||||
|
eprintln!("Display: {:?} ({})", display, compositor.unwrap_or_else(|| "unknown".into()));
|
||||||
|
eprintln!("Input method: {:?}", daemon.config.input_method);
|
||||||
|
eprintln!("Toggle key: Ctrl+{}", daemon.config.toggle_key.to_uppercase());
|
||||||
|
eprintln!("App memory: {}", if daemon.config.app_state.enabled { "ON" } else { "OFF" });
|
||||||
|
|
||||||
|
match open_keyboard_device() {
|
||||||
|
Ok((device, path)) => {
|
||||||
|
eprintln!("[vietc] Keyboard device: {}", path);
|
||||||
|
run_with_evdev(device, &mut daemon)?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[vietc] No keyboard device: {}", e);
|
||||||
|
eprintln!("[vietc] Running in stdin test mode");
|
||||||
|
run_stdin_mode(&mut daemon)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_keyboard_device() -> Result<(evdev::Device, String), Box<dyn std::error::Error>> {
|
||||||
|
let dir = std::path::Path::new("/dev/input");
|
||||||
|
if !dir.exists() {
|
||||||
|
return Err("No /dev/input directory".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name_str = name.to_string_lossy();
|
||||||
|
|
||||||
|
if name_str.starts_with("event") {
|
||||||
|
match evdev::Device::open(entry.path()) {
|
||||||
|
Ok(device) => {
|
||||||
|
let dev_name = device.name().unwrap_or("unknown").to_string();
|
||||||
|
if device.supported_keys().is_some_and(|k| {
|
||||||
|
k.contains(evdev::Key::KEY_A)
|
||||||
|
}) {
|
||||||
|
return Ok((device, format!("{} ({})", entry.path().display(), dev_name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("No keyboard device found".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_with_evdev(
|
||||||
|
mut device: evdev::Device,
|
||||||
|
daemon: &mut Daemon,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let injector = create_injector()?;
|
||||||
|
let mut event_count = 0u64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let key_state = device.get_key_state().ok();
|
||||||
|
let events = device.fetch_events()?;
|
||||||
|
|
||||||
|
// Check for app changes and config reload periodically
|
||||||
|
event_count += 1;
|
||||||
|
if event_count.is_multiple_of(100) {
|
||||||
|
if daemon.config.app_state.enabled {
|
||||||
|
daemon.check_app_change();
|
||||||
|
}
|
||||||
|
daemon.reload_config();
|
||||||
|
}
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
if let evdev::InputEventKind::Key(key) = event.kind() {
|
||||||
|
let value = event.value();
|
||||||
|
|
||||||
|
if value == 1
|
||||||
|
&& is_toggle_combination_state(&key_state, &daemon.config.toggle_key)
|
||||||
|
{
|
||||||
|
daemon.toggle();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if value != 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ch) = key_to_char(key) {
|
||||||
|
let commands = daemon.process_key(ch);
|
||||||
|
execute_commands(&*injector, &commands);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_stdin_mode(daemon: &mut Daemon) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use std::io::{self, Read};
|
||||||
|
|
||||||
|
let injector = create_injector()?;
|
||||||
|
let mut buffer = [0u8; 1];
|
||||||
|
|
||||||
|
eprintln!("[vietc] Type to test, Ctrl+C to exit");
|
||||||
|
|
||||||
|
let stdin = io::stdin();
|
||||||
|
let mut handle = stdin.lock();
|
||||||
|
let mut byte_count = 0u64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match handle.read(&mut buffer) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(_) => {
|
||||||
|
let ch = buffer[0] as char;
|
||||||
|
let commands = daemon.process_key(ch);
|
||||||
|
execute_commands(&*injector, &commands);
|
||||||
|
|
||||||
|
byte_count += 1;
|
||||||
|
if byte_count.is_multiple_of(50) {
|
||||||
|
daemon.reload_config();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[vietc] Read error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_commands(injector: &dyn vietc_protocol::KeyInjector, commands: &[OutputCommand]) {
|
||||||
|
for cmd in commands {
|
||||||
|
match cmd {
|
||||||
|
OutputCommand::Type(text) => {
|
||||||
|
injector.send_string(text);
|
||||||
|
}
|
||||||
|
OutputCommand::Backspace(count) => {
|
||||||
|
injector.send_backspaces(*count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
injector.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_injector() -> Result<Box<dyn vietc_protocol::KeyInjector>, Box<dyn std::error::Error>> {
|
||||||
|
// Try Wayland input method first (if compiled with wayland feature)
|
||||||
|
#[cfg(feature = "wayland")]
|
||||||
|
{
|
||||||
|
// WaylandIMContext is always available — actual protocol binding
|
||||||
|
// happens via the compositor's zwp_input_method_v2 protocol
|
||||||
|
let _ctx = vietc_protocol::wayland_im::WaylandIMContext::new();
|
||||||
|
eprintln!("[vietc] Wayland input method context initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try X11 first (if compiled with x11 feature)
|
||||||
|
#[cfg(feature = "x11")]
|
||||||
|
{
|
||||||
|
match vietc_protocol::x11_inject::X11Injector::new() {
|
||||||
|
Ok(injector) => {
|
||||||
|
eprintln!("[vietc] Using X11 injection (XTEST)");
|
||||||
|
return Ok(Box::new(injector));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[vietc] X11 not available: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to uinput (works on both X11 and Wayland)
|
||||||
|
match vietc_protocol::uinput_monitor::UinputInjector::new("vietc") {
|
||||||
|
Ok(injector) => {
|
||||||
|
eprintln!("[vietc] Using uinput injection");
|
||||||
|
Ok(Box::new(injector))
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("No injection backend available: {}", e).into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_toggle_combination_state(key_state: &Option<evdev::AttributeSet<evdev::Key>>, key: &str) -> bool {
|
||||||
|
let key_state = match key_state {
|
||||||
|
Some(ks) => ks,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ctrl_pressed = key_state.contains(evdev::Key::KEY_LEFTCTRL)
|
||||||
|
|| key_state.contains(evdev::Key::KEY_RIGHTCTRL);
|
||||||
|
|
||||||
|
if !ctrl_pressed {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = match key.to_lowercase().as_str() {
|
||||||
|
"space" => evdev::Key::KEY_SPACE,
|
||||||
|
"shift" => evdev::Key::KEY_LEFTSHIFT,
|
||||||
|
"capslock" => evdev::Key::KEY_CAPSLOCK,
|
||||||
|
"ctrl" => evdev::Key::KEY_LEFTCTRL,
|
||||||
|
"alt" => evdev::Key::KEY_LEFTALT,
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
key_state.contains(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_to_char(key: evdev::Key) -> Option<char> {
|
||||||
|
match key {
|
||||||
|
evdev::Key::KEY_A => Some('a'),
|
||||||
|
evdev::Key::KEY_B => Some('b'),
|
||||||
|
evdev::Key::KEY_C => Some('c'),
|
||||||
|
evdev::Key::KEY_D => Some('d'),
|
||||||
|
evdev::Key::KEY_E => Some('e'),
|
||||||
|
evdev::Key::KEY_F => Some('f'),
|
||||||
|
evdev::Key::KEY_G => Some('g'),
|
||||||
|
evdev::Key::KEY_H => Some('h'),
|
||||||
|
evdev::Key::KEY_I => Some('i'),
|
||||||
|
evdev::Key::KEY_J => Some('j'),
|
||||||
|
evdev::Key::KEY_K => Some('k'),
|
||||||
|
evdev::Key::KEY_L => Some('l'),
|
||||||
|
evdev::Key::KEY_M => Some('m'),
|
||||||
|
evdev::Key::KEY_N => Some('n'),
|
||||||
|
evdev::Key::KEY_O => Some('o'),
|
||||||
|
evdev::Key::KEY_P => Some('p'),
|
||||||
|
evdev::Key::KEY_Q => Some('q'),
|
||||||
|
evdev::Key::KEY_R => Some('r'),
|
||||||
|
evdev::Key::KEY_S => Some('s'),
|
||||||
|
evdev::Key::KEY_T => Some('t'),
|
||||||
|
evdev::Key::KEY_U => Some('u'),
|
||||||
|
evdev::Key::KEY_V => Some('v'),
|
||||||
|
evdev::Key::KEY_W => Some('w'),
|
||||||
|
evdev::Key::KEY_X => Some('x'),
|
||||||
|
evdev::Key::KEY_Y => Some('y'),
|
||||||
|
evdev::Key::KEY_Z => Some('z'),
|
||||||
|
evdev::Key::KEY_0 => Some('0'),
|
||||||
|
evdev::Key::KEY_1 => Some('1'),
|
||||||
|
evdev::Key::KEY_2 => Some('2'),
|
||||||
|
evdev::Key::KEY_3 => Some('3'),
|
||||||
|
evdev::Key::KEY_4 => Some('4'),
|
||||||
|
evdev::Key::KEY_5 => Some('5'),
|
||||||
|
evdev::Key::KEY_6 => Some('6'),
|
||||||
|
evdev::Key::KEY_7 => Some('7'),
|
||||||
|
evdev::Key::KEY_8 => Some('8'),
|
||||||
|
evdev::Key::KEY_9 => Some('9'),
|
||||||
|
evdev::Key::KEY_SPACE => Some(' '),
|
||||||
|
evdev::Key::KEY_DOT => Some('.'),
|
||||||
|
evdev::Key::KEY_COMMA => Some(','),
|
||||||
|
evdev::Key::KEY_MINUS => Some('-'),
|
||||||
|
evdev::Key::KEY_EQUAL => Some('='),
|
||||||
|
evdev::Key::KEY_SEMICOLON => Some(';'),
|
||||||
|
evdev::Key::KEY_APOSTROPHE => Some('\''),
|
||||||
|
evdev::Key::KEY_SLASH => Some('/'),
|
||||||
|
evdev::Key::KEY_BACKSPACE => Some('\x08'),
|
||||||
|
evdev::Key::KEY_ENTER => Some('\n'),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
9
engine/Cargo.toml
Normal file
9
engine/Cargo.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[package]
|
||||||
|
name = "vietc-engine"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Viet+ Vietnamese IME Core Engine"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
298
engine/src/engine.rs
Normal file
298
engine/src/engine.rs
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
use crate::telex::TelexEngine;
|
||||||
|
use crate::vni::VniEngine;
|
||||||
|
use crate::english::EnglishDict;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum InputMethod {
|
||||||
|
Telex,
|
||||||
|
Vni,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum EngineEvent {
|
||||||
|
Replace { backspaces: usize, insert: String },
|
||||||
|
Insert(String),
|
||||||
|
Flush(String),
|
||||||
|
AutoRestore(String),
|
||||||
|
/// ESC undo: strip all tone marks from current word
|
||||||
|
UndoTones { backspaces: usize, restored: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Engine {
|
||||||
|
input_method: InputMethod,
|
||||||
|
telex: TelexEngine,
|
||||||
|
vni: VniEngine,
|
||||||
|
english: EnglishDict,
|
||||||
|
enabled: bool,
|
||||||
|
macros: std::collections::HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Engine {
|
||||||
|
pub fn new(method: InputMethod) -> Self {
|
||||||
|
Self {
|
||||||
|
input_method: method,
|
||||||
|
telex: TelexEngine::new(),
|
||||||
|
vni: VniEngine::new(),
|
||||||
|
english: EnglishDict::new(),
|
||||||
|
enabled: true,
|
||||||
|
macros: std::collections::HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_enabled(&mut self, enabled: bool) {
|
||||||
|
self.enabled = enabled;
|
||||||
|
if !enabled {
|
||||||
|
self.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_enabled(&self) -> bool {
|
||||||
|
self.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_method(&mut self, method: InputMethod) {
|
||||||
|
self.input_method = method;
|
||||||
|
self.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.telex.reset();
|
||||||
|
self.vni.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flush(&mut self) -> Option<EngineEvent> {
|
||||||
|
match self.input_method {
|
||||||
|
InputMethod::Telex => self.telex.flush(),
|
||||||
|
InputMethod::Vni => self.vni.flush(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a macro shortcut
|
||||||
|
pub fn add_macro(&mut self, shortcut: String, expansion: String) {
|
||||||
|
self.macros.insert(shortcut, expansion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all macros
|
||||||
|
pub fn clear_macros(&mut self) {
|
||||||
|
self.macros.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process ESC key - undo tones from current word
|
||||||
|
pub fn process_escape(&mut self) -> Option<EngineEvent> {
|
||||||
|
let buffer = match self.input_method {
|
||||||
|
InputMethod::Telex => self.telex.buffer(),
|
||||||
|
InputMethod::Vni => self.vni.buffer(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if buffer.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip all diacritics from the buffer
|
||||||
|
let stripped = strip_diacritics(buffer);
|
||||||
|
let backspaces = buffer.chars().count();
|
||||||
|
let had_tones = stripped != buffer;
|
||||||
|
self.reset();
|
||||||
|
|
||||||
|
if had_tones {
|
||||||
|
Some(EngineEvent::UndoTones {
|
||||||
|
backspaces,
|
||||||
|
restored: stripped,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Some(EngineEvent::Flush(stripped))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_key(&mut self, ch: char) -> Option<EngineEvent> {
|
||||||
|
if !self.enabled {
|
||||||
|
return Some(EngineEvent::Insert(ch.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC = undo tones
|
||||||
|
if ch == '\x1b' {
|
||||||
|
return self.process_escape();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == ' ' || ch == '\t' || ch == '.' || ch == ',' || ch == '!' || ch == '?'
|
||||||
|
|| ch == ';' || ch == ':' || ch == '\n'
|
||||||
|
{
|
||||||
|
// Check for macro expansion before auto-restore
|
||||||
|
let buffer = match self.input_method {
|
||||||
|
InputMethod::Telex => self.telex.buffer(),
|
||||||
|
InputMethod::Vni => self.vni.buffer(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let macro_expansion = self.macros.get(buffer).cloned();
|
||||||
|
|
||||||
|
if let Some(expansion) = macro_expansion {
|
||||||
|
self.reset();
|
||||||
|
let mut result = expansion;
|
||||||
|
result.push(ch);
|
||||||
|
return Some(EngineEvent::Flush(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try auto-restore before flushing
|
||||||
|
if let Some(restore) = self.try_auto_restore() {
|
||||||
|
match restore {
|
||||||
|
EngineEvent::AutoRestore(word) => {
|
||||||
|
let mut result = String::new();
|
||||||
|
for _ in 0..word.len() {
|
||||||
|
result.push('\x08');
|
||||||
|
}
|
||||||
|
result.push_str(&word);
|
||||||
|
result.push(ch);
|
||||||
|
return Some(EngineEvent::Flush(result));
|
||||||
|
}
|
||||||
|
_ => return Some(restore),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush buffer with trailing character
|
||||||
|
return match self.input_method {
|
||||||
|
InputMethod::Telex => self.telex.flush_with(ch),
|
||||||
|
InputMethod::Vni => self.vni_flush_with(ch),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.input_method {
|
||||||
|
InputMethod::Telex => self.telex.process_key(ch),
|
||||||
|
InputMethod::Vni => self.vni.process_key(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vni_flush_with(&mut self, ch: char) -> Option<EngineEvent> {
|
||||||
|
if self.vni.buffer().is_empty() {
|
||||||
|
return Some(EngineEvent::Insert(ch.to_string()));
|
||||||
|
}
|
||||||
|
let flush = self.vni.flush();
|
||||||
|
match flush {
|
||||||
|
Some(EngineEvent::Flush(mut text)) => {
|
||||||
|
text.push(ch);
|
||||||
|
Some(EngineEvent::Flush(text))
|
||||||
|
}
|
||||||
|
_ => Some(EngineEvent::Insert(ch.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_auto_restore(&mut self) -> Option<EngineEvent> {
|
||||||
|
let buffer = match self.input_method {
|
||||||
|
InputMethod::Telex => self.telex.buffer(),
|
||||||
|
InputMethod::Vni => self.vni.buffer(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if buffer.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !buffer.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let clean = buffer.to_lowercase();
|
||||||
|
if self.english.should_restore(&clean) {
|
||||||
|
let original = buffer.to_string();
|
||||||
|
self.reset();
|
||||||
|
return Some(EngineEvent::AutoRestore(original));
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn buffer(&self) -> &str {
|
||||||
|
match self.input_method {
|
||||||
|
InputMethod::Telex => self.telex.buffer(),
|
||||||
|
InputMethod::Vni => self.vni.buffer(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip all Vietnamese diacritics from a string, returning base ASCII
|
||||||
|
fn strip_diacritics(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
// a variants
|
||||||
|
'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ'
|
||||||
|
| 'â' | 'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'a',
|
||||||
|
// A variants
|
||||||
|
'À' | 'Á' | 'Ả' | 'Ã' | 'Ạ' | 'Ă' | 'Ằ' | 'Ắ' | 'Ẳ' | 'Ẵ' | 'Ặ'
|
||||||
|
| 'Â' | 'Ầ' | 'Ấ' | 'Ẩ' | 'Ẫ' | 'Ậ' => 'A',
|
||||||
|
// e variants
|
||||||
|
'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' | 'ê' | 'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => 'e',
|
||||||
|
'È' | 'É' | 'Ẻ' | 'Ẽ' | 'Ẹ' | 'Ê' | 'Ề' | 'Ế' | 'Ể' | 'Ễ' | 'Ệ' => 'E',
|
||||||
|
// i variants
|
||||||
|
'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => 'i',
|
||||||
|
'Ì' | 'Í' | 'Ỉ' | 'Ĩ' | 'Ị' => 'I',
|
||||||
|
// o variants
|
||||||
|
'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' | 'ô' | 'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ'
|
||||||
|
| 'ơ' | 'ờ' | 'ớ' | 'ở' | 'ỡ' | 'ợ' => 'o',
|
||||||
|
'Ò' | 'Ó' | 'Ỏ' | 'Õ' | 'Ọ' | 'Ô' | 'Ồ' | 'Ố' | 'Ổ' | 'Ỗ' | 'Ộ'
|
||||||
|
| 'Ơ' | 'Ờ' | 'Ớ' | 'Ở' | 'Ỡ' | 'Ợ' => 'O',
|
||||||
|
// u variants
|
||||||
|
'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' | 'ư' | 'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => 'u',
|
||||||
|
'Ù' | 'Ú' | 'Ủ' | 'Ũ' | 'Ụ' | 'Ư' | 'Ừ' | 'Ứ' | 'Ử' | 'Ữ' | 'Ự' => 'U',
|
||||||
|
// y variants
|
||||||
|
'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => 'y',
|
||||||
|
'Ỳ' | 'Ý' | 'Ỷ' | 'Ỹ' | 'Ỵ' => 'Y',
|
||||||
|
// đ
|
||||||
|
'đ' => 'd',
|
||||||
|
'Đ' => 'D',
|
||||||
|
// Everything else unchanged
|
||||||
|
other => other,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_diacritics() {
|
||||||
|
assert_eq!(strip_diacritics("chào"), "chao");
|
||||||
|
assert_eq!(strip_diacritics("cám ơn"), "cam on");
|
||||||
|
assert_eq!(strip_diacritics("Việt Nam"), "Viet Nam");
|
||||||
|
assert_eq!(strip_diacritics("hello"), "hello");
|
||||||
|
assert_eq!(strip_diacritics("đường"), "duong");
|
||||||
|
assert_eq!(strip_diacritics("Nguyễn"), "Nguyen");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_esc_undo_tones() {
|
||||||
|
let mut engine = Engine::new(InputMethod::Telex);
|
||||||
|
|
||||||
|
// Type "chào" then ESC
|
||||||
|
for ch in "chào".chars() {
|
||||||
|
engine.process_key(ch);
|
||||||
|
}
|
||||||
|
let event = engine.process_escape();
|
||||||
|
match event {
|
||||||
|
Some(EngineEvent::UndoTones { backspaces, restored }) => {
|
||||||
|
assert_eq!(backspaces, 4); // "chào" is 4 chars
|
||||||
|
assert_eq!(restored, "chao");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected UndoTones event, got {:?}", event),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_macro_expansion() {
|
||||||
|
let mut engine = Engine::new(InputMethod::Telex);
|
||||||
|
engine.add_macro("ko".into(), "không".into());
|
||||||
|
engine.add_macro("ok".into(), "được".into());
|
||||||
|
|
||||||
|
// Type "ko" + space
|
||||||
|
let events: Vec<_> = "ko ".chars()
|
||||||
|
.filter_map(|ch| engine.process_key(ch))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Should contain the macro expansion
|
||||||
|
let output: String = events.iter().filter_map(|e| match e {
|
||||||
|
EngineEvent::Flush(s) => Some(s.as_str()),
|
||||||
|
EngineEvent::Insert(s) => Some(s.as_str()),
|
||||||
|
_ => None,
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
assert!(output.contains("không"));
|
||||||
|
}
|
||||||
|
}
|
||||||
97
engine/src/english.rs
Normal file
97
engine/src/english.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
pub struct EnglishDict {
|
||||||
|
/// Common English words that shouldn't be converted to Vietnamese
|
||||||
|
words: HashSet<String>,
|
||||||
|
/// Words that are definitely Vietnamese (even if they look like English)
|
||||||
|
vietnamese_overrides: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnglishDict {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut words = HashSet::new();
|
||||||
|
|
||||||
|
// Common English words that users type frequently
|
||||||
|
// These would trigger false Vietnamese conversions
|
||||||
|
let common_words = [
|
||||||
|
// Programming/tech
|
||||||
|
"the", "and", "for", "are", "but", "not", "you", "all", "can", "had",
|
||||||
|
"her", "was", "one", "our", "out", "day", "get", "has", "him", "his",
|
||||||
|
"how", "its", "may", "new", "now", "old", "see", "way", "who", "did",
|
||||||
|
"does", "each", "from", "have", "here", "just", "like", "long", "look",
|
||||||
|
"made", "make", "many", "most", "over", "such", "take", "than", "them",
|
||||||
|
"then", "that", "this", "time", "very", "when", "what", "will", "with",
|
||||||
|
"also", "back", "been", "call", "came", "come", "could", "does", "done",
|
||||||
|
"down", "each", "even", "find", "first", "from", "give", "goes", "going",
|
||||||
|
"good", "great", "hand", "have", "head", "help", "high", "home", "hope",
|
||||||
|
"into", "keep", "know", "last", "left", "life", "like", "line", "live",
|
||||||
|
"look", "made", "make", "many", "mean", "more", "most", "much", "must",
|
||||||
|
"name", "need", "next", "only", "open", "part", "place", "point", "right",
|
||||||
|
"same", "said", "second", "should", "show", "small", "some", "something",
|
||||||
|
"still", "such", "sure", "take", "tell", "than", "that", "them", "then",
|
||||||
|
"there", "these", "they", "thing", "think", "this", "those", "time",
|
||||||
|
"turn", "upon", "very", "want", "well", "went", "were", "what", "when",
|
||||||
|
"where", "which", "while", "will", "with", "work", "would", "year", "your",
|
||||||
|
// Common words that conflict with Vietnamese
|
||||||
|
"ok", "no", "so", "do", "go", "to", "in", "on", "at", "by", "up",
|
||||||
|
"an", "as", "be", "he", "if", "is", "it", "me", "my", "of", "or",
|
||||||
|
"am", "we", "us", "set", "run", "put", "get", "let", "say",
|
||||||
|
"ask", "try", "use", "add", "end", "few", "far", "got", "big", "off",
|
||||||
|
"old", "own", "red", "hot", "top", "far", "low", "six", "ten", "red",
|
||||||
|
// Greetings & common
|
||||||
|
"hello", "hi", "hey", "bye", "thanks", "thank", "please", "sorry",
|
||||||
|
"yes", "yeah", "no", "ok", "okay", "sure", "well", "too", "also",
|
||||||
|
// More common English
|
||||||
|
"about", "after", "again", "being", "below", "between", "both",
|
||||||
|
"came", "come", "could", "does", "done", "down", "each", "even",
|
||||||
|
"find", "first", "from", "give", "goes", "going", "good", "great",
|
||||||
|
"hand", "have", "head", "help", "high", "home", "hope", "into",
|
||||||
|
"keep", "kind", "know", "last", "left", "life", "like", "line",
|
||||||
|
"live", "long", "look", "made", "make", "many", "mean", "more",
|
||||||
|
"most", "much", "must", "name", "need", "next", "only", "open",
|
||||||
|
"part", "place", "point", "right", "same", "said", "second",
|
||||||
|
"should", "show", "small", "some", "something", "still", "sure",
|
||||||
|
"take", "tell", "than", "that", "them", "then", "there", "these",
|
||||||
|
"they", "thing", "think", "this", "those", "time", "turn", "upon",
|
||||||
|
"very", "want", "well", "went", "were", "what", "when", "where",
|
||||||
|
"which", "while", "will", "with", "work", "would", "year", "your",
|
||||||
|
];
|
||||||
|
|
||||||
|
for word in common_words {
|
||||||
|
words.insert(word.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut vietnamese_overrides = HashSet::new();
|
||||||
|
// Common Vietnamese words that look like English
|
||||||
|
let overrides = ["không", "xin", "chào", "cảm", "ơn", "tôi", "bạn"];
|
||||||
|
for word in overrides {
|
||||||
|
vietnamese_overrides.insert(word.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
words,
|
||||||
|
vietnamese_overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_english_word(&self, word: &str) -> bool {
|
||||||
|
self.words.contains(word)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_restore(&self, word: &str) -> bool {
|
||||||
|
if self.vietnamese_overrides.contains(word) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.is_english_word(word)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn add_word(&mut self, word: String) {
|
||||||
|
self.words.insert(word);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn remove_word(&mut self, word: &str) {
|
||||||
|
self.words.remove(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
engine/src/lib.rs
Normal file
11
engine/src/lib.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
mod engine;
|
||||||
|
mod telex;
|
||||||
|
mod vni;
|
||||||
|
mod english;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
pub use engine::Engine;
|
||||||
|
pub use engine::EngineEvent;
|
||||||
|
pub use engine::InputMethod;
|
||||||
260
engine/src/telex.rs
Normal file
260
engine/src/telex.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
use crate::engine::EngineEvent;
|
||||||
|
|
||||||
|
const VOWELS: &[char] = &['a', 'e', 'i', 'o', 'u', 'y', 'ă', 'â', 'ê', 'ô', 'ơ', 'ư'];
|
||||||
|
|
||||||
|
fn is_vowel(c: char) -> bool {
|
||||||
|
VOWELS.contains(&c)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_tone_to_vowel(vowel: char, tone: char) -> Option<char> {
|
||||||
|
// Standard Telex: f=huyền, s=sắc, r=hỏi, x=ngã, j=nặng
|
||||||
|
let table: &[(char, char, char)] = &[
|
||||||
|
('a', 'f', 'à'), ('a', 's', 'á'), ('a', 'r', 'ả'), ('a', 'x', 'ã'), ('a', 'j', 'ạ'),
|
||||||
|
('ă', 'f', 'ằ'), ('ă', 's', 'ắ'), ('ă', 'r', 'ẳ'), ('ă', 'x', 'ẵ'), ('ă', 'j', 'ặ'),
|
||||||
|
('â', 'f', 'ầ'), ('â', 's', 'ấ'), ('â', 'r', 'ẩ'), ('â', 'x', 'ẫ'), ('â', 'j', 'ậ'),
|
||||||
|
('e', 'f', 'è'), ('e', 's', 'é'), ('e', 'r', 'ẻ'), ('e', 'x', 'ẽ'), ('e', 'j', 'ẹ'),
|
||||||
|
('ê', 'f', 'ề'), ('ê', 's', 'ế'), ('ê', 'r', 'ể'), ('ê', 'x', 'ễ'), ('ê', 'j', 'ệ'),
|
||||||
|
('i', 'f', 'ì'), ('i', 's', 'í'), ('i', 'r', 'ỉ'), ('i', 'x', 'ĩ'), ('i', 'j', 'ị'),
|
||||||
|
('o', 'f', 'ò'), ('o', 's', 'ó'), ('o', 'r', 'ỏ'), ('o', 'x', 'õ'), ('o', 'j', 'ọ'),
|
||||||
|
('ô', 'f', 'ồ'), ('ô', 's', 'ố'), ('ô', 'r', 'ổ'), ('ô', 'x', 'ỗ'), ('ô', 'j', 'ộ'),
|
||||||
|
('ơ', 'f', 'ờ'), ('ơ', 's', 'ớ'), ('ơ', 'r', 'ở'), ('ơ', 'x', 'ỡ'), ('ơ', 'j', 'ợ'),
|
||||||
|
('u', 'f', 'ù'), ('u', 's', 'ú'), ('u', 'r', 'ủ'), ('u', 'x', 'ũ'), ('u', 'j', 'ụ'),
|
||||||
|
('ư', 'f', 'ừ'), ('ư', 's', 'ứ'), ('ư', 'r', 'ử'), ('ư', 'x', 'ữ'), ('ư', 'j', 'ự'),
|
||||||
|
('y', 'f', 'ỳ'), ('y', 's', 'ý'), ('y', 'r', 'ỷ'), ('y', 'x', 'ỹ'), ('y', 'j', 'ỵ'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for &(v, t, result) in table {
|
||||||
|
if v == vowel && t == tone {
|
||||||
|
return Some(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_w_to_vowel(vowel: char) -> Option<char> {
|
||||||
|
// Telex: aw=â, ow=ô, ew=ê, uw=ư
|
||||||
|
// (aa=ă, ee=ê, oo=ô are handled by double-letter logic)
|
||||||
|
match vowel {
|
||||||
|
'a' => Some('â'),
|
||||||
|
'o' => Some('ô'),
|
||||||
|
'e' => Some('ê'),
|
||||||
|
'u' => Some('ư'),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub struct TelexEngine {
|
||||||
|
buffer: String,
|
||||||
|
pending_modifier: Option<char>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TelexEngine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
buffer: String::new(),
|
||||||
|
pending_modifier: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.buffer.clear();
|
||||||
|
self.pending_modifier = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn buffer(&self) -> &str {
|
||||||
|
&self.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flush(&mut self) -> Option<EngineEvent> {
|
||||||
|
if self.buffer.is_empty() && self.pending_modifier.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.apply_pending_to_last_vowel();
|
||||||
|
|
||||||
|
let result = self.buffer.clone();
|
||||||
|
self.buffer.clear();
|
||||||
|
self.pending_modifier = None;
|
||||||
|
|
||||||
|
Some(EngineEvent::Flush(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush buffer and append a trailing character (e.g., space, punctuation)
|
||||||
|
pub fn flush_with(&mut self, trailing: char) -> Option<EngineEvent> {
|
||||||
|
if self.buffer.is_empty() && self.pending_modifier.is_none() {
|
||||||
|
return Some(EngineEvent::Insert(trailing.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.apply_pending_to_last_vowel();
|
||||||
|
|
||||||
|
let mut result = self.buffer.clone();
|
||||||
|
result.push(trailing);
|
||||||
|
self.buffer.clear();
|
||||||
|
self.pending_modifier = None;
|
||||||
|
|
||||||
|
Some(EngineEvent::Flush(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_pending_to_last_vowel(&mut self) {
|
||||||
|
if let Some(modifier) = self.pending_modifier.take() {
|
||||||
|
if let Some(last_ch) = self.buffer.pop() {
|
||||||
|
if is_vowel(last_ch) {
|
||||||
|
if let Some(modified) = match modifier {
|
||||||
|
'f' | 's' | 'r' | 'x' | 'j' => apply_tone_to_vowel(last_ch, modifier),
|
||||||
|
'w' => apply_w_to_vowel(last_ch),
|
||||||
|
_ => None,
|
||||||
|
} {
|
||||||
|
self.buffer.push(modified);
|
||||||
|
} else {
|
||||||
|
self.buffer.push(last_ch);
|
||||||
|
self.pending_modifier = Some(modifier);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.buffer.push(last_ch);
|
||||||
|
self.pending_modifier = Some(modifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_key(&mut self, ch: char) -> Option<EngineEvent> {
|
||||||
|
match ch {
|
||||||
|
' ' | '\t' => self.flush_with(ch),
|
||||||
|
'.' | ',' | '!' | '?' | ';' | ':' | '\n' => self.flush_with(ch),
|
||||||
|
'f' | 's' | 'r' | 'x' | 'j' => self.process_tone(ch),
|
||||||
|
'a' | 'e' | 'o' => self.process_vowel_or_double(ch),
|
||||||
|
'w' => self.process_w(),
|
||||||
|
_ => self.process_other(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_tone(&mut self, tone: char) -> Option<EngineEvent> {
|
||||||
|
self.apply_pending_to_last_vowel();
|
||||||
|
|
||||||
|
// Find the vowel to apply tone to.
|
||||||
|
// For compound vowels, tone goes on the first vowel of the cluster
|
||||||
|
// (except when preceded by o/u in certain combinations).
|
||||||
|
// Simplified: apply to the first vowel found scanning backward.
|
||||||
|
if !self.buffer.is_empty() {
|
||||||
|
let chars: Vec<char> = self.buffer.chars().collect();
|
||||||
|
// Scan backward to find the last vowel
|
||||||
|
for i in (0..chars.len()).rev() {
|
||||||
|
if is_vowel(chars[i]) {
|
||||||
|
// Check if there's a vowel before this one (compound vowel)
|
||||||
|
// For compound vowels starting with o/u, tone goes on the second vowel
|
||||||
|
if i > 0 && is_vowel(chars[i - 1]) {
|
||||||
|
let first = chars[i - 1];
|
||||||
|
let second = chars[i];
|
||||||
|
// For oa, oe, uy → tone on second vowel (already at position i)
|
||||||
|
// For others → tone on first vowel
|
||||||
|
let tone_on_second = matches!(
|
||||||
|
(first, second),
|
||||||
|
('o', 'a') | ('o', 'e') | ('u', 'y')
|
||||||
|
);
|
||||||
|
if !tone_on_second {
|
||||||
|
// Apply tone to first vowel
|
||||||
|
if let Some(modified) = apply_tone_to_vowel(chars[i - 1], tone) {
|
||||||
|
self.buffer = chars[..i - 1].iter().collect::<String>();
|
||||||
|
self.buffer.push(modified);
|
||||||
|
// Re-add chars after i-1
|
||||||
|
for &c in &chars[i..] {
|
||||||
|
self.buffer.push(c);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply tone to this vowel (default: last vowel)
|
||||||
|
if let Some(modified) = apply_tone_to_vowel(chars[i], tone) {
|
||||||
|
self.buffer = chars[..i].iter().collect::<String>();
|
||||||
|
self.buffer.push(modified);
|
||||||
|
for &c in &chars[i + 1..] {
|
||||||
|
self.buffer.push(c);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No vowel found - append tone key (might be English)
|
||||||
|
self.buffer.push(tone);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_vowel_or_double(&mut self, ch: char) -> Option<EngineEvent> {
|
||||||
|
self.apply_pending_to_last_vowel();
|
||||||
|
|
||||||
|
// Check for double-letter pattern
|
||||||
|
if let Some(last_ch) = self.buffer.chars().last() {
|
||||||
|
if last_ch == ch {
|
||||||
|
let replacement = match ch {
|
||||||
|
'a' => Some('ă'),
|
||||||
|
'e' => Some('ê'),
|
||||||
|
'o' => Some('ô'),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(rep) = replacement {
|
||||||
|
self.buffer.pop();
|
||||||
|
self.buffer.push(rep);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.buffer.push(ch);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_w(&mut self) -> Option<EngineEvent> {
|
||||||
|
self.apply_pending_to_last_vowel();
|
||||||
|
|
||||||
|
if let Some(last_ch) = self.buffer.chars().last() {
|
||||||
|
if is_vowel(last_ch) {
|
||||||
|
if let Some(modified) = apply_w_to_vowel(last_ch) {
|
||||||
|
self.buffer.pop();
|
||||||
|
self.buffer.push(modified);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// w after consonant or at start - pending modifier
|
||||||
|
self.pending_modifier = Some('w');
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_other(&mut self, ch: char) -> Option<EngineEvent> {
|
||||||
|
// dd → đ digraph
|
||||||
|
if ch == 'd' {
|
||||||
|
if let Some(last_ch) = self.buffer.chars().last() {
|
||||||
|
if last_ch == 'd' {
|
||||||
|
let chars: Vec<char> = self.buffer.chars().collect();
|
||||||
|
if chars.len() == 1 {
|
||||||
|
self.buffer.pop();
|
||||||
|
self.buffer.push('đ');
|
||||||
|
return None;
|
||||||
|
} else if chars.len() >= 2 {
|
||||||
|
let prev = chars[chars.len() - 2];
|
||||||
|
if !is_vowel(prev) {
|
||||||
|
self.buffer.pop();
|
||||||
|
self.buffer.push('đ');
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.pending_modifier.is_some() {
|
||||||
|
self.apply_pending_to_last_vowel();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.buffer.push(ch);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
1092
engine/src/tests.rs
Normal file
1092
engine/src/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
152
engine/src/vni.rs
Normal file
152
engine/src/vni.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
use crate::engine::EngineEvent;
|
||||||
|
|
||||||
|
const VOWELS: &[char] = &['a', 'e', 'i', 'o', 'u', 'y', 'ă', 'â', 'ê', 'ô', 'ơ', 'ư'];
|
||||||
|
|
||||||
|
fn is_vowel(c: char) -> bool {
|
||||||
|
VOWELS.contains(&c)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_tone_to_vowel(vowel: char, digit: char) -> Option<char> {
|
||||||
|
// VNI: 1=sắc, 2=huyền, 3=hỏi, 4=ngã, 5=nặng
|
||||||
|
let table: &[(char, char, char)] = &[
|
||||||
|
('a', '1', 'á'), ('a', '2', 'à'), ('a', '3', 'ả'), ('a', '4', 'ã'), ('a', '5', 'ạ'),
|
||||||
|
('ă', '1', 'ắ'), ('ă', '2', 'ằ'), ('ă', '3', 'ẳ'), ('ă', '4', 'ẵ'), ('ă', '5', 'ặ'),
|
||||||
|
('â', '1', 'ấ'), ('â', '2', 'ầ'), ('â', '3', 'ẩ'), ('â', '4', 'ẫ'), ('â', '5', 'ậ'),
|
||||||
|
('e', '1', 'é'), ('e', '2', 'è'), ('e', '3', 'ẻ'), ('e', '4', 'ẽ'), ('e', '5', 'ẹ'),
|
||||||
|
('ê', '1', 'ế'), ('ê', '2', 'ề'), ('ê', '3', 'ể'), ('ê', '4', 'ễ'), ('ê', '5', 'ệ'),
|
||||||
|
('i', '1', 'í'), ('i', '2', 'ì'), ('i', '3', 'ỉ'), ('i', '4', 'ĩ'), ('i', '5', 'ị'),
|
||||||
|
('o', '1', 'ó'), ('o', '2', 'ò'), ('o', '3', 'ỏ'), ('o', '4', 'õ'), ('o', '5', 'ọ'),
|
||||||
|
('ô', '1', 'ố'), ('ô', '2', 'ồ'), ('ô', '3', 'ổ'), ('ô', '4', 'ỗ'), ('ô', '5', 'ộ'),
|
||||||
|
('ơ', '1', 'ớ'), ('ơ', '2', 'ờ'), ('ơ', '3', 'ở'), ('ơ', '4', 'ỡ'), ('ơ', '5', 'ợ'),
|
||||||
|
('u', '1', 'ú'), ('u', '2', 'ù'), ('u', '3', 'ủ'), ('u', '4', 'ũ'), ('u', '5', 'ụ'),
|
||||||
|
('ư', '1', 'ứ'), ('ư', '2', 'ừ'), ('ư', '3', 'ử'), ('ư', '4', 'ữ'), ('ư', '5', 'ự'),
|
||||||
|
('y', '1', 'ý'), ('y', '2', 'ỳ'), ('y', '3', 'ỷ'), ('y', '4', 'ỹ'), ('y', '5', 'ỵ'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for &(v, t, result) in table {
|
||||||
|
if v == vowel && t == digit {
|
||||||
|
return Some(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_digit_to_vowel(vowel: char, digit: char) -> Option<char> {
|
||||||
|
// VNI: 6=ă, 7=â, 8=ê, 9=ô, 0=ơ+ư
|
||||||
|
match digit {
|
||||||
|
'6' => match vowel {
|
||||||
|
'a' => Some('ă'),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
'7' => match vowel {
|
||||||
|
'a' => Some('â'),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
'8' => match vowel {
|
||||||
|
'e' => Some('ê'),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
'9' => match vowel {
|
||||||
|
'o' => Some('ô'),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
'0' => match vowel {
|
||||||
|
'o' => Some('ơ'),
|
||||||
|
'u' => Some('ư'),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VniEngine {
|
||||||
|
buffer: String,
|
||||||
|
pending_modifier: Option<char>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VniEngine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
buffer: String::new(),
|
||||||
|
pending_modifier: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.buffer.clear();
|
||||||
|
self.pending_modifier = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn buffer(&self) -> &str {
|
||||||
|
&self.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flush(&mut self) -> Option<EngineEvent> {
|
||||||
|
if self.buffer.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = self.buffer.clone();
|
||||||
|
self.buffer.clear();
|
||||||
|
self.pending_modifier = None;
|
||||||
|
|
||||||
|
Some(EngineEvent::Flush(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_key(&mut self, ch: char) -> Option<EngineEvent> {
|
||||||
|
match ch {
|
||||||
|
'0'..='9' => self.process_digit(ch),
|
||||||
|
_ => {
|
||||||
|
// Non-digit: apply pending modifier if any
|
||||||
|
if self.pending_modifier.is_some() {
|
||||||
|
self.apply_pending();
|
||||||
|
}
|
||||||
|
self.buffer.push(ch);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_digit(&mut self, digit: char) -> Option<EngineEvent> {
|
||||||
|
// Apply any pending modifier first
|
||||||
|
if self.pending_modifier.is_some() {
|
||||||
|
self.apply_pending();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find last vowel
|
||||||
|
if let Some(last_ch) = self.buffer.chars().last() {
|
||||||
|
if is_vowel(last_ch) {
|
||||||
|
// Try tone first (1-5)
|
||||||
|
if let Some(modified) = apply_tone_to_vowel(last_ch, digit) {
|
||||||
|
self.buffer.pop();
|
||||||
|
self.buffer.push(modified);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try vowel modification (6-9, 0)
|
||||||
|
if let Some(modified) = apply_digit_to_vowel(last_ch, digit) {
|
||||||
|
self.buffer.pop();
|
||||||
|
self.buffer.push(modified);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digit not applicable - just append
|
||||||
|
self.buffer.push(digit);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_pending(&mut self) {
|
||||||
|
if let Some(modifier) = self.pending_modifier.take() {
|
||||||
|
if let Some(last_ch) = self.buffer.chars().last() {
|
||||||
|
if is_vowel(last_ch) {
|
||||||
|
if let Some(modified) = apply_digit_to_vowel(last_ch, modifier) {
|
||||||
|
self.buffer.pop();
|
||||||
|
self.buffer.push(modified);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
packaging/appimage/build-appimage.sh
Normal file
98
packaging/appimage/build-appimage.sh
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
APPDIR="$SCRIPT_DIR/AppDir"
|
||||||
|
VERSION="${1:-0.1.0}"
|
||||||
|
|
||||||
|
echo "=== Building Viet+ AppImage v${VERSION} ==="
|
||||||
|
|
||||||
|
# Clean
|
||||||
|
rm -rf "$APPDIR"
|
||||||
|
mkdir -p "$APPDIR/usr/bin"
|
||||||
|
mkdir -p "$APPDIR/usr/share/applications"
|
||||||
|
mkdir -p "$APPDIR/usr/share/icons/hicolor/256x256/apps"
|
||||||
|
mkdir -p "$APPDIR/usr/share/doc/vietc"
|
||||||
|
mkdir -p "$APPDIR/etc/vietc"
|
||||||
|
|
||||||
|
# Build binaries
|
||||||
|
echo "[1/5] Building binaries..."
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
if pkg-config --exists x11 xtst 2>/dev/null; then
|
||||||
|
cargo build --release --features "x11,wayland"
|
||||||
|
echo " Built with x11 + wayland"
|
||||||
|
else
|
||||||
|
cargo build --release --features wayland
|
||||||
|
echo " Built with wayland only (X11 libs not found)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
cd "$PROJECT_ROOT/ui" && cargo build --release 2>/dev/null && cd "$SCRIPT_DIR" || echo " UI build skipped (missing GTK4 libs)"
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
# Copy binaries
|
||||||
|
echo "[2/5] Installing binaries..."
|
||||||
|
cp target/release/vietc "$APPDIR/usr/bin/"
|
||||||
|
cp target/release/vietc-cli "$APPDIR/usr/bin/"
|
||||||
|
[ -f ui/target/release/vietc-settings ] && cp ui/target/release/vietc-settings "$APPDIR/usr/bin/"
|
||||||
|
[ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/"
|
||||||
|
|
||||||
|
# Desktop integration
|
||||||
|
echo "[3/5] Installing desktop integration..."
|
||||||
|
cp "$SCRIPT_DIR/vietc.desktop" "$APPDIR/usr/share/applications/"
|
||||||
|
|
||||||
|
# Generate SVG icon
|
||||||
|
cat > "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" << 'SVGEOF'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||||
|
<rect x="20" y="60" width="216" height="140" rx="16" fill="#2d2d2d" stroke="#1a1a1a" stroke-width="4"/>
|
||||||
|
<rect x="36" y="76" width="184" height="108" rx="8" fill="#3d3d3d"/>
|
||||||
|
<rect x="48" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="78" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="108" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="138" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="168" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="198" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="54" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="84" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="114" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="144" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="174" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="60" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="90" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="120" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="150" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="180" y="140" width="42" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="72" y="166" width="112" height="16" rx="3" fill="#f0f0f0"/>
|
||||||
|
<circle cx="216" cy="48" r="28" fill="#da251d"/>
|
||||||
|
<text x="216" y="56" text-anchor="middle" fill="white" font-size="18" font-weight="bold" font-family="sans-serif">VN</text>
|
||||||
|
</svg>
|
||||||
|
SVGEOF
|
||||||
|
|
||||||
|
# Convert SVG to PNG if rsvg-convert available
|
||||||
|
if command -v rsvg-convert &>/dev/null; then
|
||||||
|
rsvg-convert -w 256 -h 256 "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" \
|
||||||
|
-o "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.png"
|
||||||
|
rm "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy icon to AppDir root for appimagetool
|
||||||
|
cp "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc."{png,svg} "$APPDIR/" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Config
|
||||||
|
echo "[4/5] Installing config..."
|
||||||
|
cp "$PROJECT_ROOT/vietc.toml" "$APPDIR/etc/vietc/config.toml"
|
||||||
|
cp "$PROJECT_ROOT/README.md" "$APPDIR/usr/share/doc/vietc/"
|
||||||
|
|
||||||
|
# Systemd service
|
||||||
|
mkdir -p "$APPDIR/usr/lib/systemd/user"
|
||||||
|
cp "$PROJECT_ROOT/vietc.service" "$APPDIR/usr/lib/systemd/user/"
|
||||||
|
|
||||||
|
# Desktop file in AppDir root
|
||||||
|
cp "$APPDIR/usr/share/applications/vietc.desktop" "$APPDIR/"
|
||||||
|
|
||||||
|
echo "[5/5] AppDir ready at: $APPDIR"
|
||||||
|
echo ""
|
||||||
|
echo "To build AppImage:"
|
||||||
|
echo " appimagetool $APPDIR Viet+-${VERSION}-x86_64.AppImage"
|
||||||
11
packaging/appimage/vietc.desktop
Normal file
11
packaging/appimage/vietc.desktop
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Viet+
|
||||||
|
GenericName=Vietnamese Input Method
|
||||||
|
Comment=Vietnamese Input Method for Linux — Zero underline, native Wayland/X11
|
||||||
|
Exec=vietc
|
||||||
|
Icon=vietc
|
||||||
|
Terminal=false
|
||||||
|
Categories=Utility;System;
|
||||||
|
Keywords=vietnamese;input;ime;keyboard;
|
||||||
|
StartupNotify=false
|
||||||
35
packaging/aur/PKGBUILD
Normal file
35
packaging/aur/PKGBUILD
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Maintainer: Viet+ Contributors
|
||||||
|
pkgname=vietc
|
||||||
|
pkgver=0.1.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc='Vietnamese Input Method for Linux — Zero underline, native Wayland/X11'
|
||||||
|
arch=('x86_64' 'aarch64')
|
||||||
|
url='https://github.com/vietplus/vietplus'
|
||||||
|
license=('MIT')
|
||||||
|
depends=('evdev' 'libx11' 'libxtst' 'dbus')
|
||||||
|
makedepends=('rust' 'cargo' 'pkg-config')
|
||||||
|
optdepends=(
|
||||||
|
'libgtk-4: for settings UI'
|
||||||
|
'libadwaita: for settings UI'
|
||||||
|
'wayland: for Wayland IM protocol'
|
||||||
|
)
|
||||||
|
provides=('vietc')
|
||||||
|
conflicts=('vietc-git')
|
||||||
|
source=("$pkgname-$pkgver.tar.gz::https://github.com/vietplus/vietplus/archive/v$pkgver.tar.gz")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$srcdir/$pkgname-$pkgver"
|
||||||
|
cargo build --release --features "x11,wayland"
|
||||||
|
cd ui && cargo build --release && cd ..
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$srcdir/$pkgname-$pkgver"
|
||||||
|
install -Dm755 "target/release/vietc" "$pkgdir/usr/bin/vietc"
|
||||||
|
install -Dm755 "ui/target/release/vietc-settings" "$pkgdir/usr/bin/vietc-settings"
|
||||||
|
install -Dm755 "ui/target/release/vietc-tray" "$pkgdir/usr/bin/vietc-tray"
|
||||||
|
install -Dm644 "vietc.toml" "$pkgdir/etc/vietc/config.toml"
|
||||||
|
install -Dm644 "vietc.service" "$pkgdir/usr/lib/systemd/user/vietc.service"
|
||||||
|
install -Dm644 "README.md" "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
|
}
|
||||||
15
packaging/deb/DEBIAN/control
Normal file
15
packaging/deb/DEBIAN/control
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
Package: vietc
|
||||||
|
Version: 0.1.0
|
||||||
|
Section: utils
|
||||||
|
Priority: optional
|
||||||
|
Architecture: amd64
|
||||||
|
Maintainer: Viet+ Contributors
|
||||||
|
Depends: libudev1, libevdev2
|
||||||
|
Recommends: libgtk-4-1, libadwaita-1-0, libdbus-1-3
|
||||||
|
Suggests: vietc-settings
|
||||||
|
Description: Vietnamese Input Method for Linux
|
||||||
|
Viet+ is a Vietnamese input method engine for Linux with Direct Input.
|
||||||
|
Zero underline, no pre-edit buffer, pure Unicode injection.
|
||||||
|
Supports Telex and VNI input methods, auto-restore English,
|
||||||
|
ESC undo, smart app memory, macro expansion.
|
||||||
|
Native Wayland and X11 support via uinput injection.
|
||||||
42
packaging/deb/DEBIAN/postinst
Normal file
42
packaging/deb/DEBIAN/postinst
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Create vinput group for uinput access
|
||||||
|
if ! getent group vinput > /dev/null 2>&1; then
|
||||||
|
groupadd -r vinput
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add root to vinput group (for uinput device access)
|
||||||
|
usermod -aG vinput root 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create config directory
|
||||||
|
mkdir -p /etc/vietc
|
||||||
|
if [ ! -f /etc/vietc/config.toml ]; then
|
||||||
|
cp /usr/share/doc/vietc/config.toml /etc/vietc/config.toml 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set uinput device permissions
|
||||||
|
if [ -e /dev/uinput ]; then
|
||||||
|
chmod 660 /dev/uinput 2>/dev/null || true
|
||||||
|
chown root:vinput /dev/uinput 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Enable lingering for systemd user services
|
||||||
|
if command -v loginctl &>/dev/null; then
|
||||||
|
loginctl enable-linger root 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Viet+ installed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "Quick start:"
|
||||||
|
echo " 1. Add your user to the vinput group:"
|
||||||
|
echo " sudo usermod -aG vinput \$USER"
|
||||||
|
echo " 2. Log out and back in"
|
||||||
|
echo " 3. Start the daemon:"
|
||||||
|
echo " vietc"
|
||||||
|
echo " 4. Or enable the systemd user service:"
|
||||||
|
echo " systemctl --user enable --now vietc"
|
||||||
|
echo ""
|
||||||
|
echo "Configure: /etc/vietc/config.toml"
|
||||||
|
echo "Settings UI: vietc-settings (if GTK4 installed)"
|
||||||
11
packaging/deb/DEBIAN/postrm
Normal file
11
packaging/deb/DEBIAN/postrm
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Remove vinput group if empty
|
||||||
|
if getent group vinput > /dev/null 2>&1; then
|
||||||
|
if ! getent group vinput | grep -q ':'; then
|
||||||
|
groupdel vinput 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Viet+ removed. Config kept at /etc/vietc/"
|
||||||
15
packaging/deb/DEBIAN/prerm
Normal file
15
packaging/deb/DEBIAN/prerm
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Stop and disable systemd user service
|
||||||
|
if systemctl --user is-active vietc.service 2>/dev/null; then
|
||||||
|
systemctl --user stop vietc.service 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if systemctl --user is-enabled vietc.service 2>/dev/null; then
|
||||||
|
systemctl --user disable vietc.service 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kill any running vietc
|
||||||
|
pkill -x vietc 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "Viet+ daemon stopped."
|
||||||
123
packaging/deb/build-deb.sh
Normal file
123
packaging/deb/build-deb.sh
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
VERSION="${1:-0.1.0}"
|
||||||
|
ARCH="amd64"
|
||||||
|
PKGNAME="vietc"
|
||||||
|
PKGDIR="$SCRIPT_DIR/${PKGNAME}_${VERSION}_${ARCH}"
|
||||||
|
|
||||||
|
echo "=== Building Viet+ .deb v${VERSION} ==="
|
||||||
|
|
||||||
|
# Clean
|
||||||
|
rm -rf "$PKGDIR"
|
||||||
|
mkdir -p "$PKGDIR/DEBIAN"
|
||||||
|
chmod 0755 "$PKGDIR/DEBIAN"
|
||||||
|
mkdir -p "$PKGDIR/usr/bin"
|
||||||
|
mkdir -p "$PKGDIR/usr/share/applications"
|
||||||
|
mkdir -p "$PKGDIR/usr/share/icons/hicolor/256x256/apps"
|
||||||
|
mkdir -p "$PKGDIR/usr/share/doc/vietc"
|
||||||
|
mkdir -p "$PKGDIR/etc/vietc"
|
||||||
|
mkdir -p "$PKGDIR/usr/lib/systemd/user"
|
||||||
|
|
||||||
|
# Build binaries
|
||||||
|
echo "[1/6] Building binaries..."
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
if pkg-config --exists x11 xtst 2>/dev/null; then
|
||||||
|
cargo build --release --features "x11,wayland"
|
||||||
|
echo " Built with x11 + wayland"
|
||||||
|
else
|
||||||
|
cargo build --release --features wayland
|
||||||
|
echo " Built with wayland only (X11 libs not found)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy binaries
|
||||||
|
echo "[2/6] Installing binaries..."
|
||||||
|
cp target/release/vietc "$PKGDIR/usr/bin/"
|
||||||
|
cp target/release/vietc-cli "$PKGDIR/usr/bin/"
|
||||||
|
|
||||||
|
# Try building UI (optional)
|
||||||
|
cd "$PROJECT_ROOT/ui" && cargo build --release 2>/dev/null && cd "$SCRIPT_DIR" && {
|
||||||
|
cp "$PROJECT_ROOT/ui/target/release/vietc-settings" "$PKGDIR/usr/bin/"
|
||||||
|
cp "$PROJECT_ROOT/ui/target/release/vietc-tray" "$PKGDIR/usr/bin/"
|
||||||
|
echo " UI binaries included"
|
||||||
|
} || {
|
||||||
|
echo " UI build skipped (missing GTK4 libs)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
}
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
# DEBIAN control files
|
||||||
|
echo "[3/6] Installing control files..."
|
||||||
|
cp "$SCRIPT_DIR/DEBIAN/control" "$PKGDIR/DEBIAN/control"
|
||||||
|
sed -i "s/^Version:.*/Version: ${VERSION}/" "$PKGDIR/DEBIAN/control"
|
||||||
|
cp "$SCRIPT_DIR/DEBIAN/postinst" "$PKGDIR/DEBIAN/"
|
||||||
|
cp "$SCRIPT_DIR/DEBIAN/prerm" "$PKGDIR/DEBIAN/"
|
||||||
|
cp "$SCRIPT_DIR/DEBIAN/postrm" "$PKGDIR/DEBIAN/"
|
||||||
|
chmod 755 "$PKGDIR/DEBIAN/postinst" "$PKGDIR/DEBIAN/prerm" "$PKGDIR/DEBIAN/postrm"
|
||||||
|
|
||||||
|
# Desktop integration
|
||||||
|
echo "[4/6] Installing desktop integration..."
|
||||||
|
cp "$PROJECT_ROOT/packaging/appimage/vietc.desktop" "$PKGDIR/usr/share/applications/"
|
||||||
|
|
||||||
|
# SVG icon
|
||||||
|
cat > "$PKGDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" << 'SVGEOF'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||||
|
<rect x="20" y="60" width="216" height="140" rx="16" fill="#2d2d2d" stroke="#1a1a1a" stroke-width="4"/>
|
||||||
|
<rect x="36" y="76" width="184" height="108" rx="8" fill="#3d3d3d"/>
|
||||||
|
<rect x="48" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="78" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="108" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="138" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="168" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="198" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="54" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="84" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="114" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="144" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="174" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="60" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="90" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="120" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="150" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="180" y="140" width="42" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="72" y="166" width="112" height="16" rx="3" fill="#f0f0f0"/>
|
||||||
|
<circle cx="216" cy="48" r="28" fill="#da251d"/>
|
||||||
|
<text x="216" y="56" text-anchor="middle" fill="white" font-size="18" font-weight="bold" font-family="sans-serif">VN</text>
|
||||||
|
</svg>
|
||||||
|
SVGEOF
|
||||||
|
|
||||||
|
# Convert SVG to PNG if possible
|
||||||
|
if command -v rsvg-convert &>/dev/null; then
|
||||||
|
rsvg-convert -w 256 -h 256 "$PKGDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" \
|
||||||
|
-o "$PKGDIR/usr/share/icons/hicolor/256x256/apps/vietc.png"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Config and docs
|
||||||
|
echo "[5/6] Installing config and docs..."
|
||||||
|
cp "$PROJECT_ROOT/vietc.toml" "$PKGDIR/etc/vietc/config.toml"
|
||||||
|
cp "$PROJECT_ROOT/README.md" "$PKGDIR/usr/share/doc/vietc/"
|
||||||
|
cp "$PROJECT_ROOT/LICENSE" "$PKGDIR/usr/share/doc/vietc/"
|
||||||
|
cp "$PROJECT_ROOT/vietc.service" "$PKGDIR/usr/lib/systemd/user/"
|
||||||
|
|
||||||
|
# Calculate installed size
|
||||||
|
INSTALLED_SIZE=$(du -sk "$PKGDIR" | cut -f1)
|
||||||
|
sed -i "s/^Installed-Size:.*/Installed-Size: ${INSTALLED_SIZE}/" "$PKGDIR/DEBIAN/control" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Fix permissions for dpkg-deb
|
||||||
|
chmod -R 0755 "$PKGDIR/DEBIAN"
|
||||||
|
find "$PKGDIR" -type d -exec chmod 0755 {} \;
|
||||||
|
|
||||||
|
# Build .deb
|
||||||
|
echo "[6/6] Building .deb package..."
|
||||||
|
dpkg-deb --root-owner-group --build "$PKGDIR"
|
||||||
|
|
||||||
|
DEBFILE="${PKGNAME}_${VERSION}_${ARCH}.deb"
|
||||||
|
echo ""
|
||||||
|
echo "=== Built: $SCRIPT_DIR/$DEBFILE ==="
|
||||||
|
echo ""
|
||||||
|
echo "Install with:"
|
||||||
|
echo " sudo dpkg -i $DEBFILE"
|
||||||
|
echo " sudo apt-get install -f # fix dependencies if needed"
|
||||||
68
packaging/flatpak/com.vietc.VietPlus.json
Normal file
68
packaging/flatpak/com.vietc.VietPlus.json
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
{
|
||||||
|
"app-id": "io.github.vietc.VietPlus",
|
||||||
|
"runtime": "org.gnome.Platform",
|
||||||
|
"runtime-version": "46",
|
||||||
|
"sdk": "org.gnome.Sdk",
|
||||||
|
"sdk-extensions": ["org.rust-lang.Rust"],
|
||||||
|
"command": "vietc-settings",
|
||||||
|
"finish-args": [
|
||||||
|
"--share=ipc",
|
||||||
|
"--share=network",
|
||||||
|
"--socket=x11",
|
||||||
|
"--socket=wayland",
|
||||||
|
"--device=all",
|
||||||
|
"--talk-name=org.kde.StatusNotifierWatcher"
|
||||||
|
],
|
||||||
|
"build-options": {
|
||||||
|
"append-path": "/usr/lib/sdk/rust/bin",
|
||||||
|
"env": {
|
||||||
|
"CARGO_HOME": "/run/build/vietc/cargo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"name": "vietc",
|
||||||
|
"buildsystem": "simple",
|
||||||
|
"build-commands": [
|
||||||
|
"cargo build --release --features x11",
|
||||||
|
"install -Dm755 target/release/vietc /app/bin/vietc",
|
||||||
|
"install -Dm644 vietc.toml /app/etc/vietc/config.toml"
|
||||||
|
],
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"type": "dir",
|
||||||
|
"path": "../.."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vietc-ui",
|
||||||
|
"buildsystem": "simple",
|
||||||
|
"build-commands": [
|
||||||
|
"cd ui && cargo build --release",
|
||||||
|
"install -Dm755 ui/target/release/vietc-settings /app/bin/vietc-settings",
|
||||||
|
"install -Dm755 ui/target/release/vietc-tray /app/bin/vietc-tray"
|
||||||
|
],
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"type": "dir",
|
||||||
|
"path": "../.."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "systemd-user-units",
|
||||||
|
"buildsystem": "simple",
|
||||||
|
"build-commands": [
|
||||||
|
"install -Dm644 vietc.service /app/share/systemd/user/vietc.service"
|
||||||
|
],
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"type": "dir",
|
||||||
|
"path": "../..",
|
||||||
|
"only": ["vietc.service"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
18
protocol/Cargo.toml
Normal file
18
protocol/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
[package]
|
||||||
|
name = "vietc-protocol"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Viet+ keystroke injection backends (X11/Wayland)"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
wayland-client = { version = "0.31", optional = true }
|
||||||
|
wayland-protocols = { version = "0.31", features = ["staging"], optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
x11 = ["dep:pkg-config"]
|
||||||
|
wayland-protocol = ["dep:wayland-client", "dep:wayland-protocols"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
pkg-config = { version = "0.3", optional = true }
|
||||||
10
protocol/build.rs
Normal file
10
protocol/build.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
fn main() {
|
||||||
|
#[cfg(feature = "x11")]
|
||||||
|
{
|
||||||
|
println!("cargo:rustc-link-lib=X11");
|
||||||
|
println!("cargo:rustc-link-lib=Xtst");
|
||||||
|
|
||||||
|
if let Ok(_) = pkg_config::probe_library("x11") {}
|
||||||
|
if let Ok(_) = pkg_config::probe_library("xtst") {}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
protocol/src/inject.rs
Normal file
74
protocol/src/inject.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum KeyAction {
|
||||||
|
Press,
|
||||||
|
Release,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct KeyEvent {
|
||||||
|
pub code: u32,
|
||||||
|
pub value: char,
|
||||||
|
pub action: KeyAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyEvent {
|
||||||
|
pub fn press(code: u32, value: char) -> Self {
|
||||||
|
Self { code, value, action: KeyAction::Press }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release(code: u32, value: char) -> Self {
|
||||||
|
Self { code, value, action: KeyAction::Release }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_press(&self) -> bool {
|
||||||
|
self.action == KeyAction::Press
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum InjectResult {
|
||||||
|
Success,
|
||||||
|
Failed,
|
||||||
|
NotSupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InjectResult {
|
||||||
|
pub fn is_ok(&self) -> bool {
|
||||||
|
*self == InjectResult::Success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait KeyInjector {
|
||||||
|
fn send_backspace(&self) -> InjectResult;
|
||||||
|
fn send_char(&self, ch: char) -> InjectResult;
|
||||||
|
fn send_string(&self, s: &str) -> InjectResult;
|
||||||
|
fn flush(&self) -> InjectResult;
|
||||||
|
|
||||||
|
fn send_backspaces(&self, count: usize) -> InjectResult {
|
||||||
|
for _ in 0..count {
|
||||||
|
if self.send_backspace() != InjectResult::Success {
|
||||||
|
return InjectResult::Failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_replacement(&self, backspaces: usize, text: &str) -> InjectResult {
|
||||||
|
if self.send_backspaces(backspaces) != InjectResult::Success {
|
||||||
|
return InjectResult::Failed;
|
||||||
|
}
|
||||||
|
self.send_string(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for InjectResult {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
InjectResult::Success => write!(f, "Success"),
|
||||||
|
InjectResult::Failed => write!(f, "Failed"),
|
||||||
|
InjectResult::NotSupported => write!(f, "NotSupported"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
protocol/src/lib.rs
Normal file
10
protocol/src/lib.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
pub mod inject;
|
||||||
|
pub mod monitor;
|
||||||
|
pub mod uinput_monitor;
|
||||||
|
pub mod wayland_im;
|
||||||
|
|
||||||
|
#[cfg(feature = "x11")]
|
||||||
|
pub mod x11_inject;
|
||||||
|
|
||||||
|
pub use inject::KeyInjector;
|
||||||
|
pub use monitor::KeyMonitor;
|
||||||
8
protocol/src/monitor.rs
Normal file
8
protocol/src/monitor.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
use crate::inject::KeyEvent;
|
||||||
|
|
||||||
|
pub trait KeyMonitor {
|
||||||
|
fn grab(&self) -> Result<(), Box<dyn std::error::Error>>;
|
||||||
|
fn ungrab(&self) -> Result<(), Box<dyn std::error::Error>>;
|
||||||
|
fn read_key(&self) -> Result<KeyEvent, Box<dyn std::error::Error>>;
|
||||||
|
fn is_active(&self) -> bool;
|
||||||
|
}
|
||||||
226
protocol/src/uinput_monitor.rs
Normal file
226
protocol/src/uinput_monitor.rs
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
|
||||||
|
use super::inject::{InjectResult, KeyInjector};
|
||||||
|
|
||||||
|
const UINPUT_MAX_NAME_SIZE: usize = 80;
|
||||||
|
const UI_SET_EVBIT: u64 = 0x40045564;
|
||||||
|
const UI_SET_KEYBIT: u64 = 0x40045565;
|
||||||
|
#[allow(dead_code)]
|
||||||
|
const UI_SET_ABSBIT: u64 = 0x40045566;
|
||||||
|
const UI_DEV_CREATE: u64 = 0x5501;
|
||||||
|
const UI_DEV_DESTROY: u64 = 0x5502;
|
||||||
|
const EV_KEY: u16 = 0x01;
|
||||||
|
#[allow(dead_code)]
|
||||||
|
const EV_ABS: u16 = 0x03;
|
||||||
|
const KEY_MAX: u32 = 0x1ff;
|
||||||
|
|
||||||
|
pub struct UinputInjector {
|
||||||
|
file: File,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for UinputInjector {}
|
||||||
|
unsafe impl Sync for UinputInjector {}
|
||||||
|
|
||||||
|
impl UinputInjector {
|
||||||
|
pub fn new(name: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open("/dev/uinput")?;
|
||||||
|
|
||||||
|
let fd = file.as_raw_fd();
|
||||||
|
|
||||||
|
// Enable EV_KEY
|
||||||
|
ioctl(fd, UI_SET_EVBIT, EV_KEY as u64)?;
|
||||||
|
|
||||||
|
// Enable all key codes we'll need
|
||||||
|
for code in 0..=KEY_MAX {
|
||||||
|
ioctl(fd, UI_SET_KEYBIT, code as u64)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create uinput device
|
||||||
|
let mut usetup: uinput_setup = unsafe { std::mem::zeroed() };
|
||||||
|
let name_bytes = name.as_bytes();
|
||||||
|
let copy_len = name_bytes.len().min(UINPUT_MAX_NAME_SIZE - 1);
|
||||||
|
for (i, &byte) in name_bytes.iter().enumerate().take(copy_len) {
|
||||||
|
usetup.name[i] = byte as i8;
|
||||||
|
}
|
||||||
|
usetup.name[copy_len] = 0;
|
||||||
|
usetup.id.bustype = 0x03; // BUS_USB
|
||||||
|
usetup.id.vendor = 0x1234;
|
||||||
|
usetup.id.product = 0x5678;
|
||||||
|
usetup.id.version = 1;
|
||||||
|
|
||||||
|
ioctl(fd, UI_DEV_CREATE, &usetup as *const uinput_setup as u64)?;
|
||||||
|
|
||||||
|
// Wait a bit for device to be ready
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
|
||||||
|
Ok(Self { file })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_uinput_event(&self, type_: u16, code: u16, value: i32) {
|
||||||
|
let event = input_event {
|
||||||
|
time: timeval { tv_sec: 0, tv_usec: 0 },
|
||||||
|
type_,
|
||||||
|
code,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let ptr = &event as *const input_event as *const u8;
|
||||||
|
let len = std::mem::size_of::<input_event>();
|
||||||
|
let _ = libc::write(self.file.as_raw_fd() as libc::c_int, ptr as *const libc::c_void, len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyInjector for UinputInjector {
|
||||||
|
fn send_backspace(&self) -> InjectResult {
|
||||||
|
self.send_uinput_event(EV_KEY, 14, 1); // KEY_BACKSPACE press
|
||||||
|
self.send_uinput_event(EV_KEY, 14, 0); // KEY_BACKSPACE release
|
||||||
|
self.send_uinput_event(0, 0, 0); // EV_SYN
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_char(&self, ch: char) -> InjectResult {
|
||||||
|
if let Some(keycode) = char_to_linux_keycode(ch) {
|
||||||
|
let needs_shift = ch.is_uppercase() || "!@#$%^&*()_+{}|:\"<>?".contains(ch);
|
||||||
|
let shift_keycode: u16 = 42; // KEY_LEFTSHIFT
|
||||||
|
|
||||||
|
if needs_shift {
|
||||||
|
self.send_uinput_event(EV_KEY, shift_keycode, 1);
|
||||||
|
}
|
||||||
|
self.send_uinput_event(EV_KEY, keycode, 1);
|
||||||
|
self.send_uinput_event(EV_KEY, keycode, 0);
|
||||||
|
if needs_shift {
|
||||||
|
self.send_uinput_event(EV_KEY, shift_keycode, 0);
|
||||||
|
}
|
||||||
|
self.send_uinput_event(0, 0, 0); // EV_SYN
|
||||||
|
return InjectResult::Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Unicode, we can't use uinput directly
|
||||||
|
// Fall back to clipboard paste or xdotool
|
||||||
|
InjectResult::NotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_string(&self, s: &str) -> InjectResult {
|
||||||
|
for ch in s.chars() {
|
||||||
|
let r = self.send_char(ch);
|
||||||
|
if r != InjectResult::Success {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) -> InjectResult {
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for UinputInjector {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = ioctl(self.file.as_raw_fd(), UI_DEV_DESTROY, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn char_to_linux_keycode(ch: char) -> Option<u16> {
|
||||||
|
match ch.to_ascii_lowercase() {
|
||||||
|
'a' => Some(30),
|
||||||
|
'b' => Some(48),
|
||||||
|
'c' => Some(46),
|
||||||
|
'd' => Some(32),
|
||||||
|
'e' => Some(18),
|
||||||
|
'f' => Some(33),
|
||||||
|
'g' => Some(34),
|
||||||
|
'h' => Some(35),
|
||||||
|
'i' => Some(23),
|
||||||
|
'j' => Some(36),
|
||||||
|
'k' => Some(37),
|
||||||
|
'l' => Some(38),
|
||||||
|
'm' => Some(50),
|
||||||
|
'n' => Some(49),
|
||||||
|
'o' => Some(24),
|
||||||
|
'p' => Some(25),
|
||||||
|
'q' => Some(16),
|
||||||
|
'r' => Some(19),
|
||||||
|
's' => Some(31),
|
||||||
|
't' => Some(20),
|
||||||
|
'u' => Some(22),
|
||||||
|
'v' => Some(47),
|
||||||
|
'w' => Some(17),
|
||||||
|
'x' => Some(45),
|
||||||
|
'y' => Some(21),
|
||||||
|
'z' => Some(44),
|
||||||
|
'0' => Some(11),
|
||||||
|
'1' => Some(2),
|
||||||
|
'2' => Some(3),
|
||||||
|
'3' => Some(4),
|
||||||
|
'4' => Some(5),
|
||||||
|
'5' => Some(6),
|
||||||
|
'6' => Some(7),
|
||||||
|
'7' => Some(8),
|
||||||
|
'8' => Some(9),
|
||||||
|
'9' => Some(10),
|
||||||
|
' ' => Some(57),
|
||||||
|
'.' => Some(52),
|
||||||
|
',' => Some(51),
|
||||||
|
'-' => Some(12),
|
||||||
|
'=' => Some(13),
|
||||||
|
';' => Some(39),
|
||||||
|
'\'' => Some(40),
|
||||||
|
'/' => Some(53),
|
||||||
|
'\\' => Some(43),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ioctl helper
|
||||||
|
fn ioctl(fd: std::os::unix::io::RawFd, request: u64, arg: u64) -> Result<i32, Box<dyn std::error::Error>> {
|
||||||
|
unsafe {
|
||||||
|
let result = libc::ioctl(fd, request, arg);
|
||||||
|
if result < 0 {
|
||||||
|
Err(format!("ioctl failed: {}", std::io::Error::last_os_error()).into())
|
||||||
|
} else {
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct input_event {
|
||||||
|
time: timeval,
|
||||||
|
type_: u16,
|
||||||
|
code: u16,
|
||||||
|
value: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct timeval {
|
||||||
|
tv_sec: libc::time_t,
|
||||||
|
tv_usec: libc::suseconds_t,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct uinput_setup {
|
||||||
|
name: [i8; UINPUT_MAX_NAME_SIZE],
|
||||||
|
id: input_id,
|
||||||
|
ff_effects_max: u32,
|
||||||
|
absmax: [i32; 64],
|
||||||
|
absmin: [i32; 64],
|
||||||
|
absfuzz: [i32; 64],
|
||||||
|
absflat: [i32; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct input_id {
|
||||||
|
bustype: u16,
|
||||||
|
vendor: u16,
|
||||||
|
product: u16,
|
||||||
|
version: u16,
|
||||||
|
}
|
||||||
428
protocol/src/wayland_im.rs
Normal file
428
protocol/src/wayland_im.rs
Normal file
|
|
@ -0,0 +1,428 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::inject::{InjectResult, KeyInjector};
|
||||||
|
|
||||||
|
/// X11 keysym values for common keys
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Keysym(pub u32);
|
||||||
|
|
||||||
|
impl Keysym {
|
||||||
|
pub const BACKSPACE: Keysym = Keysym(0xff08);
|
||||||
|
pub const TAB: Keysym = Keysym(0xff09);
|
||||||
|
pub const RETURN: Keysym = Keysym(0xff0d);
|
||||||
|
pub const ESCAPE: Keysym = Keysym(0xff1b);
|
||||||
|
pub const SPACE: Keysym = Keysym(0x0020);
|
||||||
|
pub const DELETE: Keysym = Keysym(0xffff);
|
||||||
|
|
||||||
|
pub const A: Keysym = Keysym(0x0061);
|
||||||
|
pub const Z: Keysym = Keysym(0x007a);
|
||||||
|
pub const SHIFT_L: Keysym = Keysym(0xffe1);
|
||||||
|
pub const CTRL_L: Keysym = Keysym(0xffe3);
|
||||||
|
|
||||||
|
pub fn from_char(ch: char) -> Option<Keysym> {
|
||||||
|
match ch {
|
||||||
|
'a'..='z' | 'A'..='Z' | '0'..='9' => Some(Keysym(ch as u32)),
|
||||||
|
' ' => Some(Keysym::SPACE),
|
||||||
|
'.' => Some(Keysym(0x002e)),
|
||||||
|
',' => Some(Keysym(0x002c)),
|
||||||
|
'-' => Some(Keysym(0x002d)),
|
||||||
|
'=' => Some(Keysym(0x003d)),
|
||||||
|
';' => Some(Keysym(0x003b)),
|
||||||
|
'\'' => Some(Keysym(0x0027)),
|
||||||
|
'/' => Some(Keysym(0x002f)),
|
||||||
|
'\\' => Some(Keysym(0x005c)),
|
||||||
|
'`' => Some(Keysym(0x0060)),
|
||||||
|
'[' => Some(Keysym(0x005b)),
|
||||||
|
']' => Some(Keysym(0x005d)),
|
||||||
|
'\n' => Some(Keysym::RETURN),
|
||||||
|
'\t' => Some(Keysym::TAB),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_char(self) -> Option<char> {
|
||||||
|
match self.0 {
|
||||||
|
0x0061..=0x007a => Some((self.0 as u8) as char),
|
||||||
|
0x0041..=0x005a => Some((self.0 as u8) as char),
|
||||||
|
0x0030..=0x0039 => Some((self.0 as u8) as char),
|
||||||
|
0x0020 => Some(' '),
|
||||||
|
0x002e => Some('.'),
|
||||||
|
0x002c => Some(','),
|
||||||
|
0x002d => Some('-'),
|
||||||
|
0x003d => Some('='),
|
||||||
|
0x003b => Some(';'),
|
||||||
|
0x0027 => Some('\''),
|
||||||
|
0x002f => Some('/'),
|
||||||
|
0x005c => Some('\\'),
|
||||||
|
0x0060 => Some('`'),
|
||||||
|
0x005b => Some('['),
|
||||||
|
0x005d => Some(']'),
|
||||||
|
0xff0d => Some('\n'),
|
||||||
|
0xff09 => Some('\t'),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_printable(self) -> bool {
|
||||||
|
self.to_char().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_modifier(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self.0,
|
||||||
|
0xffe1..=0xffee
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key event from Wayland IM protocol
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IMKeyEvent {
|
||||||
|
pub keysym: Keysym,
|
||||||
|
pub pressed: bool,
|
||||||
|
pub modifiers: KeyModifiers,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct KeyModifiers {
|
||||||
|
pub shift: bool,
|
||||||
|
pub ctrl: bool,
|
||||||
|
pub alt: bool,
|
||||||
|
pub super_key: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wayland input method state
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum IMState {
|
||||||
|
Inactive,
|
||||||
|
Active,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wayland IM context for receiving key events from compositor
|
||||||
|
///
|
||||||
|
/// This implements the zwp_input_method_v2 protocol to receive keysyms
|
||||||
|
/// directly from the Wayland compositor, bypassing evdev interception.
|
||||||
|
pub struct WaylandIMContext {
|
||||||
|
state: IMState,
|
||||||
|
preedit: Option<String>,
|
||||||
|
cursor_pos: usize,
|
||||||
|
commit_buffer: String,
|
||||||
|
keysym_map: HashMap<u32, char>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WaylandIMContext {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WaylandIMContext {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
state: IMState::Inactive,
|
||||||
|
preedit: None,
|
||||||
|
cursor_pos: 0,
|
||||||
|
commit_buffer: String::new(),
|
||||||
|
keysym_map: Self::build_keysym_map(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_keysym_map() -> HashMap<u32, char> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
// Lowercase letters
|
||||||
|
for i in 0u32..26 {
|
||||||
|
map.insert(0x0061 + i, (b'a' + i as u8) as char);
|
||||||
|
}
|
||||||
|
// Uppercase letters
|
||||||
|
for i in 0u32..26 {
|
||||||
|
map.insert(0x0041 + i, (b'A' + i as u8) as char);
|
||||||
|
}
|
||||||
|
// Digits
|
||||||
|
for i in 0u32..10 {
|
||||||
|
map.insert(0x0030 + i, (b'0' + i as u8) as char);
|
||||||
|
}
|
||||||
|
// Common punctuation
|
||||||
|
map.insert(0x0020, ' ');
|
||||||
|
map.insert(0x002e, '.');
|
||||||
|
map.insert(0x002c, ',');
|
||||||
|
map.insert(0x002d, '-');
|
||||||
|
map.insert(0x003d, '=');
|
||||||
|
map.insert(0x003b, ';');
|
||||||
|
map.insert(0x0027, '\'');
|
||||||
|
map.insert(0x002f, '/');
|
||||||
|
map.insert(0x005c, '\\');
|
||||||
|
map.insert(0x0060, '`');
|
||||||
|
map.insert(0x005b, '[');
|
||||||
|
map.insert(0x005d, ']');
|
||||||
|
// Special keys
|
||||||
|
map.insert(0xff0d, '\n'); // Return
|
||||||
|
map.insert(0xff09, '\t'); // Tab
|
||||||
|
map.insert(0xff08, '\x08'); // Backspace
|
||||||
|
map.insert(0xff1b, '\x1b'); // Escape
|
||||||
|
map.insert(0xffff, '\x7f'); // Delete
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle IM activation from compositor
|
||||||
|
pub fn activate(&mut self) {
|
||||||
|
self.state = IMState::Active;
|
||||||
|
eprintln!("[vietc-wayland] IM activated");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle IM deactivation from compositor
|
||||||
|
pub fn deactivate(&mut self) {
|
||||||
|
self.state = IMState::Inactive;
|
||||||
|
self.preedit = None;
|
||||||
|
self.commit_buffer.clear();
|
||||||
|
eprintln!("[vietc-wayland] IM deactivated");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current IM state
|
||||||
|
pub fn state(&self) -> IMState {
|
||||||
|
self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set preedit text (shown with underline in client)
|
||||||
|
pub fn set_preedit(&mut self, text: Option<String>, cursor: usize) {
|
||||||
|
self.preedit = text;
|
||||||
|
self.cursor_pos = cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current preedit text
|
||||||
|
pub fn preedit(&self) -> Option<&str> {
|
||||||
|
self.preedit.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit text to the focused surface
|
||||||
|
pub fn commit(&mut self, text: &str) {
|
||||||
|
self.commit_buffer.push_str(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get and clear the commit buffer
|
||||||
|
pub fn take_commit(&mut self) -> String {
|
||||||
|
std::mem::take(&mut self.commit_buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a keysym to a character, applying modifiers
|
||||||
|
pub fn keysym_to_char(&self, keysym: Keysym, mods: &KeyModifiers) -> Option<char> {
|
||||||
|
if keysym.is_modifier() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = self.keysym_map.get(&keysym.0).copied()?;
|
||||||
|
|
||||||
|
// Apply shift for letters
|
||||||
|
if mods.shift && base.is_ascii_lowercase() {
|
||||||
|
return Some(base.to_ascii_uppercase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift+digit produces symbol
|
||||||
|
if mods.shift && base.is_ascii_digit() {
|
||||||
|
let shifted = match base {
|
||||||
|
'1' => '!', '2' => '@', '3' => '#', '4' => '$', '5' => '%',
|
||||||
|
'6' => '^', '7' => '&', '8' => '*', '9' => '(', '0' => ')',
|
||||||
|
_ => return Some(base),
|
||||||
|
};
|
||||||
|
return Some(shifted);
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a character to a keysym
|
||||||
|
pub fn char_to_keysym(ch: char) -> Option<Keysym> {
|
||||||
|
Keysym::from_char(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process a raw keysym event and return the character (if any)
|
||||||
|
pub fn process_keysym(&self, keysym: Keysym, mods: &KeyModifiers) -> Option<char> {
|
||||||
|
self.keysym_to_char(keysym, mods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wayland IM key injector using zwp_input_method_context_v2
|
||||||
|
///
|
||||||
|
/// Commits text directly to the focused surface without key injection.
|
||||||
|
/// Falls back to uinput/X11 if context is not available.
|
||||||
|
pub struct WaylandIMInjector {
|
||||||
|
committed: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WaylandIMInjector {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WaylandIMInjector {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
committed: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take all committed text since last call
|
||||||
|
pub fn take_commits(&mut self) -> Vec<String> {
|
||||||
|
std::mem::take(&mut self.committed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyInjector for WaylandIMInjector {
|
||||||
|
fn send_backspace(&self) -> InjectResult {
|
||||||
|
// In real implementation, this would call
|
||||||
|
// context.delete_surrounding_text(-1, 1) + context.commit()
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_char(&self, _ch: char) -> InjectResult {
|
||||||
|
// In real implementation, this would call
|
||||||
|
// context.commit_string(ch.to_string()) + context.commit()
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_string(&self, _s: &str) -> InjectResult {
|
||||||
|
// In real implementation, this would call
|
||||||
|
// context.commit_string(s.to_string()) + context.commit()
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) -> InjectResult {
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keysym_from_char() {
|
||||||
|
assert_eq!(Keysym::from_char('a'), Some(Keysym(0x0061)));
|
||||||
|
assert_eq!(Keysym::from_char('z'), Some(Keysym(0x007a)));
|
||||||
|
assert_eq!(Keysym::from_char('A'), Some(Keysym(0x0041)));
|
||||||
|
assert_eq!(Keysym::from_char('0'), Some(Keysym(0x0030)));
|
||||||
|
assert_eq!(Keysym::from_char(' '), Some(Keysym(0x0020)));
|
||||||
|
assert_eq!(Keysym::from_char('.'), Some(Keysym(0x002e)));
|
||||||
|
assert_eq!(Keysym::from_char('\n'), Some(Keysym(0xff0d)));
|
||||||
|
assert_eq!(Keysym::from_char('ñ'), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keysym_to_char() {
|
||||||
|
assert_eq!(Keysym(0x0061).to_char(), Some('a'));
|
||||||
|
assert_eq!(Keysym(0x007a).to_char(), Some('z'));
|
||||||
|
assert_eq!(Keysym(0x0041).to_char(), Some('A'));
|
||||||
|
assert_eq!(Keysym(0x0030).to_char(), Some('0'));
|
||||||
|
assert_eq!(Keysym(0x0020).to_char(), Some(' '));
|
||||||
|
assert_eq!(Keysym(0xff0d).to_char(), Some('\n'));
|
||||||
|
assert_eq!(Keysym(0xffff).to_char(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keysym_is_printable() {
|
||||||
|
assert!(Keysym(0x0061).is_printable()); // 'a'
|
||||||
|
assert!(Keysym(0x0020).is_printable()); // space
|
||||||
|
assert!(Keysym(0xff0d).is_printable()); // Return → '\n'
|
||||||
|
assert!(!Keysym(0xff08).is_printable()); // Backspace → '\x08' (not printable)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keysym_is_modifier() {
|
||||||
|
assert!(Keysym(0xffe1).is_modifier()); // shift
|
||||||
|
assert!(Keysym(0xffe3).is_modifier()); // ctrl
|
||||||
|
assert!(Keysym(0xffe9).is_modifier()); // alt
|
||||||
|
assert!(!Keysym(0x0061).is_modifier()); // 'a'
|
||||||
|
assert!(!Keysym(0x0020).is_modifier()); // space
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn im_context_activate_deactivate() {
|
||||||
|
let mut ctx = WaylandIMContext::new();
|
||||||
|
assert_eq!(ctx.state(), IMState::Inactive);
|
||||||
|
|
||||||
|
ctx.activate();
|
||||||
|
assert_eq!(ctx.state(), IMState::Active);
|
||||||
|
|
||||||
|
ctx.deactivate();
|
||||||
|
assert_eq!(ctx.state(), IMState::Inactive);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn im_context_preedit() {
|
||||||
|
let mut ctx = WaylandIMContext::new();
|
||||||
|
assert!(ctx.preedit().is_none());
|
||||||
|
|
||||||
|
ctx.set_preedit(Some("hello".into()), 3);
|
||||||
|
assert_eq!(ctx.preedit(), Some("hello"));
|
||||||
|
|
||||||
|
ctx.set_preedit(None, 0);
|
||||||
|
assert!(ctx.preedit().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn im_context_commit() {
|
||||||
|
let mut ctx = WaylandIMContext::new();
|
||||||
|
ctx.commit("hello");
|
||||||
|
ctx.commit(" ");
|
||||||
|
ctx.commit("world");
|
||||||
|
assert_eq!(ctx.take_commit(), "hello world");
|
||||||
|
assert!(ctx.take_commit().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keysym_to_char_no_modifiers() {
|
||||||
|
let ctx = WaylandIMContext::new();
|
||||||
|
let mods = KeyModifiers::default();
|
||||||
|
|
||||||
|
assert_eq!(ctx.keysym_to_char(Keysym(0x0061), &mods), Some('a'));
|
||||||
|
assert_eq!(ctx.keysym_to_char(Keysym(0x007a), &mods), Some('z'));
|
||||||
|
assert_eq!(ctx.keysym_to_char(Keysym(0x0030), &mods), Some('0'));
|
||||||
|
assert_eq!(ctx.keysym_to_char(Keysym(0x0020), &mods), Some(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keysym_to_char_shift() {
|
||||||
|
let ctx = WaylandIMContext::new();
|
||||||
|
let mods = KeyModifiers {
|
||||||
|
shift: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(ctx.keysym_to_char(Keysym(0x0061), &mods), Some('A'));
|
||||||
|
assert_eq!(ctx.keysym_to_char(Keysym(0x007a), &mods), Some('Z'));
|
||||||
|
assert_eq!(ctx.keysym_to_char(Keysym(0x0031), &mods), Some('!'));
|
||||||
|
assert_eq!(ctx.keysym_to_char(Keysym(0x0032), &mods), Some('@'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keysym_to_char_modifier_returns_none() {
|
||||||
|
let ctx = WaylandIMContext::new();
|
||||||
|
let mods = KeyModifiers::default();
|
||||||
|
|
||||||
|
assert_eq!(ctx.keysym_to_char(Keysym(0xffe1), &mods), None); // shift
|
||||||
|
assert_eq!(ctx.keysym_to_char(Keysym(0xffe3), &mods), None); // ctrl
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_keysym() {
|
||||||
|
let ctx = WaylandIMContext::new();
|
||||||
|
let mods = KeyModifiers::default();
|
||||||
|
|
||||||
|
assert_eq!(ctx.process_keysym(Keysym(0x0061), &mods), Some('a'));
|
||||||
|
assert_eq!(ctx.process_keysym(Keysym(0xff0d), &mods), Some('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn char_to_keysym_roundtrip() {
|
||||||
|
for ch in "abcdefghijklmnopqrstuvwxyz".chars() {
|
||||||
|
let keysym = WaylandIMContext::char_to_keysym(ch).unwrap();
|
||||||
|
let back = keysym.to_char().unwrap();
|
||||||
|
assert_eq!(ch, back);
|
||||||
|
}
|
||||||
|
for ch in "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars() {
|
||||||
|
let keysym = WaylandIMContext::char_to_keysym(ch).unwrap();
|
||||||
|
let back = keysym.to_char().unwrap();
|
||||||
|
assert_eq!(ch, back);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
protocol/src/x11_inject.rs
Normal file
140
protocol/src/x11_inject.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
use super::inject::{InjectResult, KeyInjector};
|
||||||
|
|
||||||
|
// X11 keycodes for common ASCII characters
|
||||||
|
// These are Linux evdev keycodes (same as X11 for most keys)
|
||||||
|
fn char_to_keycode(ch: char) -> Option<(u32, bool)> {
|
||||||
|
match ch {
|
||||||
|
'a' => Some((30, false)), 'b' => Some((48, false)), 'c' => Some((46, false)),
|
||||||
|
'd' => Some((32, false)), 'e' => Some((18, false)), 'f' => Some((33, false)),
|
||||||
|
'g' => Some((34, false)), 'h' => Some((35, false)), 'i' => Some((23, false)),
|
||||||
|
'j' => Some((36, false)), 'k' => Some((37, false)), 'l' => Some((38, false)),
|
||||||
|
'm' => Some((50, false)), 'n' => Some((49, false)), 'o' => Some((24, false)),
|
||||||
|
'p' => Some((25, false)), 'q' => Some((16, false)), 'r' => Some((19, false)),
|
||||||
|
's' => Some((31, false)), 't' => Some((20, false)), 'u' => Some((22, false)),
|
||||||
|
'v' => Some((47, false)), 'w' => Some((17, false)), 'x' => Some((45, false)),
|
||||||
|
'y' => Some((21, false)), 'z' => Some((44, false)),
|
||||||
|
'A' => Some((30, true)), 'B' => Some((48, true)), 'C' => Some((46, true)),
|
||||||
|
'D' => Some((32, true)), 'E' => Some((18, true)), 'F' => Some((33, true)),
|
||||||
|
'G' => Some((34, true)), 'H' => Some((35, true)), 'I' => Some((23, true)),
|
||||||
|
'J' => Some((36, true)), 'K' => Some((37, true)), 'L' => Some((38, true)),
|
||||||
|
'M' => Some((50, true)), 'N' => Some((49, true)), 'O' => Some((24, true)),
|
||||||
|
'P' => Some((25, true)), 'Q' => Some((16, true)), 'R' => Some((19, true)),
|
||||||
|
'S' => Some((31, true)), 'T' => Some((20, true)), 'U' => Some((22, true)),
|
||||||
|
'V' => Some((47, true)), 'W' => Some((17, true)), 'X' => Some((45, true)),
|
||||||
|
'Y' => Some((21, true)), 'Z' => Some((44, true)),
|
||||||
|
'0' => Some((11, false)), '1' => Some((2, false)), '2' => Some((3, false)),
|
||||||
|
'3' => Some((4, false)), '4' => Some((5, false)), '5' => Some((6, false)),
|
||||||
|
'6' => Some((7, false)), '7' => Some((8, false)), '8' => Some((9, false)),
|
||||||
|
'9' => Some((10, false)),
|
||||||
|
' ' => Some((57, false)), '.' => Some((52, false)), ',' => Some((51, false)),
|
||||||
|
'-' => Some((12, false)), '=' => Some((13, false)), ';' => Some((39, false)),
|
||||||
|
'\'' => Some((40, false)), '/' => Some((53, false)), '\\' => Some((43, false)),
|
||||||
|
'`' => Some((41, false)), '[' => Some((26, false)), ']' => Some((27, false)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// X11 injection backend using XTEST extension
|
||||||
|
///
|
||||||
|
/// Sends fake key events via XSendEvent/XTestFakeKeyEvent.
|
||||||
|
/// Works on X11 sessions. Falls back to uinput on Wayland.
|
||||||
|
pub struct X11Injector {
|
||||||
|
display: *mut xlib::Display,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
window: xlib::Window,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for X11Injector {}
|
||||||
|
unsafe impl Sync for X11Injector {}
|
||||||
|
|
||||||
|
impl X11Injector {
|
||||||
|
pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
unsafe {
|
||||||
|
let display = xlib::XOpenDisplay(std::ptr::null());
|
||||||
|
if display.is_null() {
|
||||||
|
return Err("Cannot open X11 display. Is DISPLAY set?".into());
|
||||||
|
}
|
||||||
|
let window = xlib::XDefaultRootWindow(display);
|
||||||
|
Ok(Self { display, window })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_keycode(&self, keycode: u32, shift: bool) {
|
||||||
|
unsafe {
|
||||||
|
if shift {
|
||||||
|
xlib::XTestFakeKeyEvent(self.display, 50, 1, 0); // Shift press
|
||||||
|
}
|
||||||
|
xlib::XTestFakeKeyEvent(self.display, keycode, 1, 0); // Key press
|
||||||
|
xlib::XTestFakeKeyEvent(self.display, keycode, 0, 0); // Key release
|
||||||
|
if shift {
|
||||||
|
xlib::XTestFakeKeyEvent(self.display, 50, 0, 0); // Shift release
|
||||||
|
}
|
||||||
|
xlib::XFlush(self.display);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_unicode_via_xdotool(&self, ch: char) {
|
||||||
|
// For Unicode chars, use xdotool type as fallback
|
||||||
|
let s = ch.to_string();
|
||||||
|
let _ = std::process::Command::new("xdotool")
|
||||||
|
.args(["type", "--clearmodifiers", &s])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyInjector for X11Injector {
|
||||||
|
fn send_backspace(&self) -> InjectResult {
|
||||||
|
self.send_keycode(14, false); // KEY_BACKSPACE
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_char(&self, ch: char) -> InjectResult {
|
||||||
|
if let Some((keycode, shift)) = char_to_keycode(ch) {
|
||||||
|
self.send_keycode(keycode, shift);
|
||||||
|
InjectResult::Success
|
||||||
|
} else {
|
||||||
|
// Unicode char - use xdotool
|
||||||
|
self.send_unicode_via_xdotool(ch);
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_string(&self, s: &str) -> InjectResult {
|
||||||
|
for ch in s.chars() {
|
||||||
|
self.send_char(ch);
|
||||||
|
}
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) -> InjectResult {
|
||||||
|
unsafe { xlib::XFlush(self.display); }
|
||||||
|
InjectResult::Success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for X11Injector {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { xlib::XCloseDisplay(self.display); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal Xlib/XTEST FFI
|
||||||
|
mod xlib {
|
||||||
|
use std::ffi::c_void;
|
||||||
|
|
||||||
|
pub type Display = c_void;
|
||||||
|
pub type Window = u64;
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
pub fn XOpenDisplay(name: *const std::ffi::c_char) -> *mut Display;
|
||||||
|
pub fn XCloseDisplay(display: *mut Display) -> std::ffi::c_int;
|
||||||
|
pub fn XDefaultRootWindow(display: *mut Display) -> Window;
|
||||||
|
pub fn XFlush(display: *mut Display) -> std::ffi::c_int;
|
||||||
|
pub fn XTestFakeKeyEvent(
|
||||||
|
display: *mut Display,
|
||||||
|
keycode: u32,
|
||||||
|
state: std::ffi::c_int,
|
||||||
|
time: u64,
|
||||||
|
) -> std::ffi::c_int;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
ui/Cargo.toml
Normal file
26
ui/Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[package]
|
||||||
|
name = "vietc-ui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Viet+ settings UI and tray icon (GTK4/Libadwaita)"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "vietc-settings"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "vietc-tray"
|
||||||
|
path = "src/tray.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
vietc-engine = { path = "../engine" }
|
||||||
|
gtk = { package = "gtk4", version = "0.9", features = ["v4_12"], optional = true }
|
||||||
|
adw = { package = "libadwaita", version = "0.7", features = ["v1_4"], optional = true }
|
||||||
|
ksni = "0.2"
|
||||||
|
toml = "0.8"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
dirs = "5"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["ui"]
|
||||||
|
ui = ["dep:gtk", "dep:adw"]
|
||||||
69
ui/data/window.ui
Normal file
69
ui/data/window.ui
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<interface>
|
||||||
|
<requires lib="gtk" version="4.0"/>
|
||||||
|
<requires lib="libadwaita" version="1.0"/>
|
||||||
|
<template class="VietTuxWindow" class="AdwApplicationWindow" parent="AdwApplicationWindow">
|
||||||
|
<property name="default-width">600</property>
|
||||||
|
<property name="default-height">700</property>
|
||||||
|
<property name="title">VietTux Settings</property>
|
||||||
|
<property name="content">
|
||||||
|
<object class="GtkBox">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<child>
|
||||||
|
<object class="AdwHeaderBar">
|
||||||
|
<child type="end">
|
||||||
|
<object class="GtkButton" id="save_button">
|
||||||
|
<property name="label">Save</property>
|
||||||
|
<property name="css_classes">suggested-action</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="Adwclamp">
|
||||||
|
<property name="maximum-size">600</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="GtkScrolledWindow">
|
||||||
|
<property name="vexpand">true</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="AdwPreferencesGroup">
|
||||||
|
<property name="title">Input Method</property>
|
||||||
|
<child>
|
||||||
|
<object class="AdwComboRow" id="method_row">
|
||||||
|
<property name="title">Keyboard Layout</property>
|
||||||
|
<property name="subtitle">Choose Telex or VNI input method</property>
|
||||||
|
<property name="model">
|
||||||
|
<object class="GtkStringList">
|
||||||
|
<items>
|
||||||
|
<item>Telex</item>
|
||||||
|
<item>VNI</item>
|
||||||
|
</items>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwComboRow" id="toggle_row">
|
||||||
|
<property name="title">Toggle Key</property>
|
||||||
|
<property name="subtitle">Key combination to toggle Vietnamese mode</property>
|
||||||
|
<property name="model">
|
||||||
|
<object class="GtkStringList">
|
||||||
|
<items>
|
||||||
|
<item>Ctrl + Space</item>
|
||||||
|
<item>Ctrl + Shift</item>
|
||||||
|
<item>Caps Lock</item>
|
||||||
|
</items>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</template>
|
||||||
|
</interface>
|
||||||
127
ui/src/config.rs
Normal file
127
ui/src/config.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default = "default_input_method")]
|
||||||
|
pub input_method: String,
|
||||||
|
|
||||||
|
#[serde(default = "default_toggle_key")]
|
||||||
|
pub toggle_key: String,
|
||||||
|
|
||||||
|
#[serde(default = "default_start_enabled")]
|
||||||
|
pub start_enabled: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_restore: AutoRestoreConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub app_state: AppStateConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub macros: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AutoRestoreConfig {
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AppStateConfig {
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub english_apps: Vec<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub vietnamese_apps: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_input_method() -> String { "telex".into() }
|
||||||
|
fn default_toggle_key() -> String { "space".into() }
|
||||||
|
fn default_start_enabled() -> bool { true }
|
||||||
|
fn default_true() -> bool { true }
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
let mut macros = HashMap::new();
|
||||||
|
macros.insert("ko".into(), "không".into());
|
||||||
|
macros.insert("dc".into(), "được".into());
|
||||||
|
macros.insert("vs".into(), "với".into());
|
||||||
|
macros.insert("lm".into(), "làm".into());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
input_method: default_input_method(),
|
||||||
|
toggle_key: default_toggle_key(),
|
||||||
|
start_enabled: default_start_enabled(),
|
||||||
|
auto_restore: AutoRestoreConfig { enabled: true },
|
||||||
|
app_state: AppStateConfig {
|
||||||
|
enabled: true,
|
||||||
|
english_apps: vec![
|
||||||
|
"code".into(), "vim".into(), "nvim".into(),
|
||||||
|
"terminal".into(), "kitty".into(), "alacritty".into(),
|
||||||
|
],
|
||||||
|
vietnamese_apps: vec![
|
||||||
|
"telegram".into(), "discord".into(), "firefox".into(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
macros,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Self {
|
||||||
|
for path in config_paths() {
|
||||||
|
if let Ok(content) = fs::read_to_string(&path) {
|
||||||
|
if let Ok(config) = toml::from_str::<Config>(&content) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let path = config_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let content = toml::to_string_pretty(self)?;
|
||||||
|
fs::write(&path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path() -> PathBuf {
|
||||||
|
config_path()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> PathBuf {
|
||||||
|
config_paths()
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| p.exists())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
dirs::config_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("vietc")
|
||||||
|
.join("config.toml")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_paths() -> Vec<PathBuf> {
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
|
||||||
|
if let Some(config_dir) = dirs::config_dir() {
|
||||||
|
paths.push(config_dir.join("vietc").join("config.toml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
paths.push(PathBuf::from("vietc.toml"));
|
||||||
|
|
||||||
|
paths
|
||||||
|
}
|
||||||
21
ui/src/main.rs
Normal file
21
ui/src/main.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
use adw::prelude::*;
|
||||||
|
use gtk::{gio, glib};
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod window;
|
||||||
|
|
||||||
|
use window::SettingsWindow;
|
||||||
|
|
||||||
|
fn main() -> glib::ExitCode {
|
||||||
|
let app = adw::Application::builder()
|
||||||
|
.application_id("io.github.vietc.Settings")
|
||||||
|
.flags(gio::ApplicationFlags::FLAGS_NONE)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
app.connect_activate(|app| {
|
||||||
|
let window = SettingsWindow::new(app);
|
||||||
|
window.present();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.run()
|
||||||
|
}
|
||||||
37
ui/src/tray.rs
Normal file
37
ui/src/tray.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
use ksni::Tray;
|
||||||
|
|
||||||
|
struct VietcTray;
|
||||||
|
|
||||||
|
impl Tray for VietcTray {
|
||||||
|
fn id(&self) -> String {
|
||||||
|
"io.github.vietc.Tray".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self) -> String {
|
||||||
|
"Viet+".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn icon_name(&self) -> String {
|
||||||
|
"input-keyboard".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn menu(&self) -> ksni::Menu {
|
||||||
|
ksni::Menu {
|
||||||
|
items: vec![
|
||||||
|
ksni::MenuItem::label("Toggle Vietnamese/English").into(),
|
||||||
|
ksni::MenuItem::separator().into(),
|
||||||
|
ksni::MenuItem::label("Settings...").into(),
|
||||||
|
ksni::MenuItem::separator().into(),
|
||||||
|
ksni::MenuItem::label("Quit Viet+").into(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let service = ksni::TrayService::new(VietcTray);
|
||||||
|
service.spawn();
|
||||||
|
loop {
|
||||||
|
std::thread::park();
|
||||||
|
}
|
||||||
|
}
|
||||||
647
ui/src/window.rs
Normal file
647
ui/src/window.rs
Normal file
|
|
@ -0,0 +1,647 @@
|
||||||
|
use adw::prelude::*;
|
||||||
|
use adw::subclass::prelude::*;
|
||||||
|
use gtk::{gio, glib};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
mod imp {
|
||||||
|
use super::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SettingsWindow {
|
||||||
|
pub dirty: RefCell<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for SettingsWindow {
|
||||||
|
const NAME: &'static str = "SettingsWindow";
|
||||||
|
type Type = super::SettingsWindow;
|
||||||
|
type ParentType = adw::ApplicationWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectImpl for SettingsWindow {}
|
||||||
|
impl WidgetImpl for SettingsWindow {}
|
||||||
|
impl WindowImpl for SettingsWindow {}
|
||||||
|
impl ApplicationWindowImpl for SettingsWindow {}
|
||||||
|
impl AdwApplicationWindowImpl for SettingsWindow {}
|
||||||
|
}
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct SettingsWindow(ObjectSubclass<imp::SettingsWindow>)
|
||||||
|
@extends gio::ApplicationWindow, gtk::ApplicationWindow,
|
||||||
|
@implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsWindow {
|
||||||
|
pub fn new(app: &adw::Application) -> Self {
|
||||||
|
let win: Self = glib::Object::builder()
|
||||||
|
.property("application", app)
|
||||||
|
.property("default-width", 580)
|
||||||
|
.property("default-height", 720)
|
||||||
|
.property("title", "Viet+ Settings")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
win.build_ui();
|
||||||
|
win
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_dirty(&self) {
|
||||||
|
*self.imp().dirty.borrow_mut() = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_ui(&self) {
|
||||||
|
let config = Config::load();
|
||||||
|
|
||||||
|
// Toast overlay for notifications
|
||||||
|
let toast_overlay = adw::ToastOverlay::new();
|
||||||
|
|
||||||
|
// Main box
|
||||||
|
let main_box = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Header bar with title widget
|
||||||
|
let header = adw::HeaderBar::new();
|
||||||
|
|
||||||
|
let title_widget = adw::WindowTitle::builder()
|
||||||
|
.title("Viet+")
|
||||||
|
.subtitle("Vietnamese Input Method")
|
||||||
|
.build();
|
||||||
|
header.set_title_widget(Some(&title_widget));
|
||||||
|
|
||||||
|
// Save button (suggested action)
|
||||||
|
let save_btn = gtk::Button::builder()
|
||||||
|
.label("Save")
|
||||||
|
.css_classes(["suggested-action"])
|
||||||
|
.tooltip_text("Save settings (Ctrl+S)")
|
||||||
|
.build();
|
||||||
|
header.add_end(&save_btn);
|
||||||
|
|
||||||
|
// Keyboard shortcut for save
|
||||||
|
let controller = gtk::EventControllerKey::new();
|
||||||
|
let save_ref = save_btn.clone();
|
||||||
|
controller.connect_key_pressed(move |_, key, _, modifiers| {
|
||||||
|
if modifiers.contains(gtk::gdk::ModifierType::CONTROL_MASK)
|
||||||
|
&& key == gtk::gdk::Key::s
|
||||||
|
{
|
||||||
|
save_ref.emit_clicked();
|
||||||
|
glib::Propagation::Stop
|
||||||
|
} else {
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.add_controller(controller);
|
||||||
|
|
||||||
|
main_box.append(&header);
|
||||||
|
|
||||||
|
// Scrollable content area
|
||||||
|
let scrolled = gtk::ScrolledWindow::builder()
|
||||||
|
.vexpand(true)
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let clamp = adw::Clamp::builder()
|
||||||
|
.maximum_size(540)
|
||||||
|
.tightening_threshold(400)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let content = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(8)
|
||||||
|
.margin_top(8)
|
||||||
|
.margin_bottom(16)
|
||||||
|
.margin_start(16)
|
||||||
|
.margin_end(16)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// ========== Input Method Section ==========
|
||||||
|
let method_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Input Method")
|
||||||
|
.description("Select your preferred Vietnamese typing method")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let method_row = adw::ComboRow::builder()
|
||||||
|
.title("Keyboard Layout")
|
||||||
|
.subtitle("Telex uses letters (aa=ă, ee=ê), VNI uses digits (a6=ă, e8=ê)")
|
||||||
|
.model(>k::StringList::new(&["Telex (Recommended)", "VNI"]))
|
||||||
|
.selected(if config.input_method == "vni" { 1 } else { 0 })
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let toggle_row = adw::ComboRow::builder()
|
||||||
|
.title("Toggle Key")
|
||||||
|
.subtitle("Switch between Vietnamese and English input")
|
||||||
|
.model(>k::StringList::new(&[
|
||||||
|
"Ctrl + Space",
|
||||||
|
"Ctrl + Shift",
|
||||||
|
"Caps Lock",
|
||||||
|
]))
|
||||||
|
.selected(match config.toggle_key.as_str() {
|
||||||
|
"shift" => 1,
|
||||||
|
"capslock" => 2,
|
||||||
|
_ => 0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
method_group.add(&method_row);
|
||||||
|
method_group.add(&toggle_row);
|
||||||
|
content.append(&method_group);
|
||||||
|
|
||||||
|
// ========== General Section ==========
|
||||||
|
let general_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("General")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let start_enabled_row = adw::SwitchRow::builder()
|
||||||
|
.title("Start Enabled")
|
||||||
|
.subtitle("Enable Vietnamese input on startup")
|
||||||
|
.active(config.start_enabled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let app_memory_row = adw::SwitchRow::builder()
|
||||||
|
.title("App Memory")
|
||||||
|
.subtitle("Remember per-app Vietnamese/English state")
|
||||||
|
.active(config.app_state.enabled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let auto_restore_row = adw::SwitchRow::builder()
|
||||||
|
.title("Auto Restore English")
|
||||||
|
.subtitle("Automatically restore common English words")
|
||||||
|
.active(config.auto_restore.enabled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
general_group.add(&start_enabled_row);
|
||||||
|
general_group.add(&app_memory_row);
|
||||||
|
general_group.add(&auto_restore_row);
|
||||||
|
content.append(&general_group);
|
||||||
|
|
||||||
|
// ========== App Lists Section ==========
|
||||||
|
let apps_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Application Lists")
|
||||||
|
.description("Override input method for specific applications")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// English apps
|
||||||
|
let english_list = gtk::ListBox::builder()
|
||||||
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
|
.css_classes(["boxed-list"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
for app in &config.app_state.english_apps {
|
||||||
|
english_list.append(&Self::make_app_row_static(app, &english_list));
|
||||||
|
}
|
||||||
|
|
||||||
|
let english_entry = gtk::SearchEntry::builder()
|
||||||
|
.placeholder_text("Add application name...")
|
||||||
|
.hexpand(true)
|
||||||
|
.show_close_icon(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let english_add = gtk::Button::builder()
|
||||||
|
.icon_name("list-add-symbolic")
|
||||||
|
.css_classes(["flat", "accent"])
|
||||||
|
.tooltip_text("Add application")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let english_input = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(4)
|
||||||
|
.build();
|
||||||
|
english_input.append(&english_entry);
|
||||||
|
english_input.append(&english_add);
|
||||||
|
|
||||||
|
let english_header = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(8)
|
||||||
|
.build();
|
||||||
|
let english_label = gtk::Label::builder()
|
||||||
|
.label("English Mode (Telex disabled)")
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.css_classes(["heading", "dim-label"])
|
||||||
|
.build();
|
||||||
|
english_header.append(&english_label);
|
||||||
|
english_header.append(&english_list);
|
||||||
|
english_header.append(&english_input);
|
||||||
|
|
||||||
|
let english_row = adw::ActionRow::builder()
|
||||||
|
.title("English Applications")
|
||||||
|
.activatable(false)
|
||||||
|
.build();
|
||||||
|
english_row.add_suffix(&english_header);
|
||||||
|
apps_group.add(&english_row);
|
||||||
|
|
||||||
|
// Vietnamese apps
|
||||||
|
let viet_list = gtk::ListBox::builder()
|
||||||
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
|
.css_classes(["boxed-list"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
for app in &config.app_state.vietnamese_apps {
|
||||||
|
viet_list.append(&Self::make_app_row_static(app, &viet_list));
|
||||||
|
}
|
||||||
|
|
||||||
|
let viet_entry = gtk::SearchEntry::builder()
|
||||||
|
.placeholder_text("Add application name...")
|
||||||
|
.hexpand(true)
|
||||||
|
.show_close_icon(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let viet_add = gtk::Button::builder()
|
||||||
|
.icon_name("list-add-symbolic")
|
||||||
|
.css_classes(["flat", "accent"])
|
||||||
|
.tooltip_text("Add application")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let viet_input = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(4)
|
||||||
|
.build();
|
||||||
|
viet_input.append(&viet_entry);
|
||||||
|
viet_input.append(&viet_add);
|
||||||
|
|
||||||
|
let viet_header = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(8)
|
||||||
|
.build();
|
||||||
|
let viet_label = gtk::Label::builder()
|
||||||
|
.label("Vietnamese Mode (Telex enabled)")
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.css_classes(["heading", "dim-label"])
|
||||||
|
.build();
|
||||||
|
viet_header.append(&viet_label);
|
||||||
|
viet_header.append(&viet_list);
|
||||||
|
viet_header.append(&viet_input);
|
||||||
|
|
||||||
|
let viet_row = adw::ActionRow::builder()
|
||||||
|
.title("Vietnamese Applications")
|
||||||
|
.activatable(false)
|
||||||
|
.build();
|
||||||
|
viet_row.add_suffix(&viet_header);
|
||||||
|
apps_group.add(&viet_row);
|
||||||
|
|
||||||
|
content.append(&apps_group);
|
||||||
|
|
||||||
|
// ========== Macros Section ==========
|
||||||
|
let macros_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Macros")
|
||||||
|
.description("Type shortcuts that expand to Vietnamese phrases")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let macros_list = gtk::ListBox::builder()
|
||||||
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
|
.css_classes(["boxed-list"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
for (shortcut, expansion) in &config.macros {
|
||||||
|
macros_list.append(&Self::make_macro_row_static(shortcut, expansion, ¯os_list));
|
||||||
|
}
|
||||||
|
|
||||||
|
let macro_shortcut = gtk::SearchEntry::builder()
|
||||||
|
.placeholder_text("ko")
|
||||||
|
.width_chars(8)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let macro_expansion = gtk::SearchEntry::builder()
|
||||||
|
.placeholder_text("không")
|
||||||
|
.hexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let macro_add = gtk::Button::builder()
|
||||||
|
.icon_name("list-add-symbolic")
|
||||||
|
.css_classes(["flat", "accent"])
|
||||||
|
.tooltip_text("Add macro")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let macro_input = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(4)
|
||||||
|
.build();
|
||||||
|
macro_input.append(¯o_shortcut);
|
||||||
|
macro_input.append(>k::Label::builder().label("→").css_classes(["dim-label"]).build());
|
||||||
|
macro_input.append(¯o_expansion);
|
||||||
|
macro_input.append(¯o_add);
|
||||||
|
|
||||||
|
macros_group.add(¯os_list);
|
||||||
|
macros_group.add(¯o_input);
|
||||||
|
content.append(¯os_group);
|
||||||
|
|
||||||
|
// ========== Reference Card ==========
|
||||||
|
let ref_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Quick Reference")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let ref_row = adw::ActionRow::builder()
|
||||||
|
.title("Common Shortcuts")
|
||||||
|
.subtitle("ko→không, dc→được, vs→với, lm→làm")
|
||||||
|
.activatable(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let ref_icon = gtk::Image::builder()
|
||||||
|
.icon_name("dialog-information-symbolic")
|
||||||
|
.tooltip_text("Type these shortcuts followed by space")
|
||||||
|
.build();
|
||||||
|
ref_row.add_suffix(&ref_icon);
|
||||||
|
|
||||||
|
ref_group.add(&ref_row);
|
||||||
|
content.append(&ref_group);
|
||||||
|
|
||||||
|
// ========== Status Bar ==========
|
||||||
|
let status_box = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(8)
|
||||||
|
.margin_top(8)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let status_icon = gtk::Image::builder()
|
||||||
|
.icon_name("emblem-ok-symbolic")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let status_label = gtk::Label::builder()
|
||||||
|
.label("Ready")
|
||||||
|
.hexpand(true)
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
status_box.append(&status_icon);
|
||||||
|
status_box.append(&status_label);
|
||||||
|
|
||||||
|
clamp.set_child(Some(&content));
|
||||||
|
scrolled.set_child(Some(&clamp));
|
||||||
|
main_box.append(&scrolled);
|
||||||
|
main_box.append(&status_box);
|
||||||
|
|
||||||
|
toast_overlay.set_child(Some(&main_box));
|
||||||
|
self.set_content(Some(&toast_overlay));
|
||||||
|
|
||||||
|
// ========== Callbacks ==========
|
||||||
|
|
||||||
|
// Mark dirty on any change
|
||||||
|
{
|
||||||
|
let win = self.clone();
|
||||||
|
method_row.connect_selected_notify(move |_| { win.mark_dirty(); });
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let win = self.clone();
|
||||||
|
toggle_row.connect_selected_notify(move |_| { win.mark_dirty(); });
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let win = self.clone();
|
||||||
|
start_enabled_row.connect_active_notify(move |_| { win.mark_dirty(); });
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let win = self.clone();
|
||||||
|
app_memory_row.connect_active_notify(move |_| { win.mark_dirty(); });
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let win = self.clone();
|
||||||
|
auto_restore_row.connect_active_notify(move |_| { win.mark_dirty(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add English app
|
||||||
|
self.setup_add_app(&english_entry, &english_add, &english_list, &status_label);
|
||||||
|
|
||||||
|
// Add Vietnamese app
|
||||||
|
self.setup_add_app(&viet_entry, &viet_add, &viet_list, &status_label);
|
||||||
|
|
||||||
|
// Add macro
|
||||||
|
self.setup_add_macro(¯o_shortcut, ¯o_expansion, ¯o_add, ¯os_list, &status_label);
|
||||||
|
|
||||||
|
// Save button
|
||||||
|
{
|
||||||
|
let method_row = method_row.clone();
|
||||||
|
let toggle_row = toggle_row.clone();
|
||||||
|
let start_switch = start_enabled_row.clone();
|
||||||
|
let app_switch = app_memory_row.clone();
|
||||||
|
let auto_switch = auto_restore_row.clone();
|
||||||
|
let english = english_list.clone();
|
||||||
|
let viet = viet_list.clone();
|
||||||
|
let macros = macros_list.clone();
|
||||||
|
let status_label = status_label.clone();
|
||||||
|
let status_icon = status_icon.clone();
|
||||||
|
let toast_overlay = toast_overlay.clone();
|
||||||
|
let win = self.clone();
|
||||||
|
|
||||||
|
save_btn.connect_clicked(move |_| {
|
||||||
|
let method = match method_row.selected() {
|
||||||
|
1 => "vni",
|
||||||
|
_ => "telex",
|
||||||
|
};
|
||||||
|
let toggle = match toggle_row.selected() {
|
||||||
|
1 => "shift",
|
||||||
|
2 => "capslock",
|
||||||
|
_ => "space",
|
||||||
|
};
|
||||||
|
|
||||||
|
let english_apps = Self::collect_app_names(&english);
|
||||||
|
let vietnamese_apps = Self::collect_app_names(&viet);
|
||||||
|
let macro_map = Self::collect_macros(¯os);
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
input_method: method.into(),
|
||||||
|
toggle_key: toggle.into(),
|
||||||
|
start_enabled: start_switch.is_active(),
|
||||||
|
auto_restore: crate::config::AutoRestoreConfig {
|
||||||
|
enabled: auto_switch.is_active(),
|
||||||
|
},
|
||||||
|
app_state: crate::config::AppStateConfig {
|
||||||
|
enabled: app_switch.is_active(),
|
||||||
|
english_apps,
|
||||||
|
vietnamese_apps,
|
||||||
|
},
|
||||||
|
macros: macro_map,
|
||||||
|
};
|
||||||
|
|
||||||
|
match config.save() {
|
||||||
|
Ok(()) => {
|
||||||
|
status_label.set_text(&format!("Saved to {}", Config::path().display()));
|
||||||
|
status_icon.set_icon_name(Some("emblem-ok-symbolic"));
|
||||||
|
status_label.remove_css_class("error");
|
||||||
|
status_label.add_css_class("dim-label");
|
||||||
|
|
||||||
|
*win.imp().dirty.borrow_mut() = false;
|
||||||
|
|
||||||
|
let toast = adw::Toast::new("Settings saved");
|
||||||
|
toast.set_timeout(2);
|
||||||
|
toast_overlay.add_toast(toast);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
status_label.set_text(&format!("Error: {}", e));
|
||||||
|
status_icon.set_icon_name(Some("dialog-error-symbolic"));
|
||||||
|
status_label.remove_css_class("dim-label");
|
||||||
|
status_label.add_css_class("error");
|
||||||
|
|
||||||
|
let toast = adw::Toast::new(&format!("Save failed: {}", e));
|
||||||
|
toast.set_timeout(3);
|
||||||
|
toast_overlay.add_toast(toast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_add_app(
|
||||||
|
&self,
|
||||||
|
entry: >k::SearchEntry,
|
||||||
|
add_btn: >k::Button,
|
||||||
|
list: >k::ListBox,
|
||||||
|
status: >k::Label,
|
||||||
|
) {
|
||||||
|
let add_fn = {
|
||||||
|
let list = list.clone();
|
||||||
|
let entry = entry.clone();
|
||||||
|
let status = status.clone();
|
||||||
|
let win = self.clone();
|
||||||
|
move || {
|
||||||
|
let text = entry.text().to_string();
|
||||||
|
if !text.is_empty() {
|
||||||
|
let row = Self::make_app_row_static(&text, &list);
|
||||||
|
list.append(&row);
|
||||||
|
entry.set_text("");
|
||||||
|
status.set_text("Unsaved changes");
|
||||||
|
status.set_icon_name("dialog-information-symbolic");
|
||||||
|
win.mark_dirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let add_fn2 = add_fn.clone();
|
||||||
|
add_btn.connect_clicked(move |_| add_fn2());
|
||||||
|
|
||||||
|
let add_fn3 = add_fn.clone();
|
||||||
|
entry.connect_activate(move |_| add_fn3());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_add_macro(
|
||||||
|
&self,
|
||||||
|
shortcut: >k::SearchEntry,
|
||||||
|
expansion: >k::SearchEntry,
|
||||||
|
add_btn: >k::Button,
|
||||||
|
list: >k::ListBox,
|
||||||
|
status: >k::Label,
|
||||||
|
) {
|
||||||
|
let add_fn = {
|
||||||
|
let list = list.clone();
|
||||||
|
let shortcut = shortcut.clone();
|
||||||
|
let expansion = expansion.clone();
|
||||||
|
let status = status.clone();
|
||||||
|
let win = self.clone();
|
||||||
|
move || {
|
||||||
|
let s = shortcut.text().to_string();
|
||||||
|
let e = expansion.text().to_string();
|
||||||
|
if !s.is_empty() && !e.is_empty() {
|
||||||
|
let row = Self::make_macro_row_static(&s, &e, &list);
|
||||||
|
list.append(&row);
|
||||||
|
shortcut.set_text("");
|
||||||
|
expansion.set_text("");
|
||||||
|
status.set_text("Unsaved changes");
|
||||||
|
status.set_icon_name("dialog-information-symbolic");
|
||||||
|
win.mark_dirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let add_fn2 = add_fn.clone();
|
||||||
|
add_btn.connect_clicked(move |_| add_fn2());
|
||||||
|
|
||||||
|
let add_fn3 = add_fn.clone();
|
||||||
|
expansion.connect_activate(move |_| add_fn3());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_app_row_static(app: &str, list: >k::ListBox) -> adw::ActionRow {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(app)
|
||||||
|
.activatable(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let remove_btn = gtk::Button::builder()
|
||||||
|
.icon_name("user-trash-symbolic")
|
||||||
|
.css_classes(["flat", "destructive-action"])
|
||||||
|
.tooltip_text("Remove")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let list_ref = list.clone();
|
||||||
|
let app_name = app.to_string();
|
||||||
|
remove_btn.connect_clicked(move |_| {
|
||||||
|
let mut i = 0;
|
||||||
|
while let Some(child) = list_ref.row_at_index(i) {
|
||||||
|
if let Some(row) = child.downcast_ref::<adw::ActionRow>() {
|
||||||
|
if row.title() == app_name {
|
||||||
|
list_ref.remove(&child);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.add_suffix(&remove_btn);
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_macro_row_static(shortcut: &str, expansion: &str, list: >k::ListBox) -> adw::ActionRow {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(shortcut)
|
||||||
|
.subtitle(expansion)
|
||||||
|
.activatable(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let arrow = gtk::Label::builder()
|
||||||
|
.label("→")
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.build();
|
||||||
|
row.add_prefix(&arrow);
|
||||||
|
|
||||||
|
let remove_btn = gtk::Button::builder()
|
||||||
|
.icon_name("user-trash-symbolic")
|
||||||
|
.css_classes(["flat", "destructive-action"])
|
||||||
|
.tooltip_text("Remove")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let list_ref = list.clone();
|
||||||
|
let shortcut_name = shortcut.to_string();
|
||||||
|
remove_btn.connect_clicked(move |_| {
|
||||||
|
let mut i = 0;
|
||||||
|
while let Some(child) = list_ref.row_at_index(i) {
|
||||||
|
if let Some(row) = child.downcast_ref::<adw::ActionRow>() {
|
||||||
|
if row.title() == shortcut_name {
|
||||||
|
list_ref.remove(&child);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.add_suffix(&remove_btn);
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_app_names(list: >k::ListBox) -> Vec<String> {
|
||||||
|
let mut names = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while let Some(child) = list.row_at_index(i) {
|
||||||
|
if let Some(row) = child.downcast_ref::<adw::ActionRow>() {
|
||||||
|
names.push(row.title().to_string());
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_macros(list: >k::ListBox) -> std::collections::HashMap<String, String> {
|
||||||
|
let mut map = std::collections::HashMap::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while let Some(child) = list.row_at_index(i) {
|
||||||
|
if let Some(row) = child.downcast_ref::<adw::ActionRow>() {
|
||||||
|
let shortcut = row.title().to_string();
|
||||||
|
let expansion = row.subtitle().unwrap_or_default().to_string();
|
||||||
|
if !shortcut.is_empty() {
|
||||||
|
map.insert(shortcut, expansion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
}
|
||||||
11
vietc.service
Normal file
11
vietc.service
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Viet+ Vietnamese IME Daemon
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/vietc
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
31
vietc.toml
Normal file
31
vietc.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Viet+ IME Configuration
|
||||||
|
|
||||||
|
input_method = "telex"
|
||||||
|
toggle_key = "space"
|
||||||
|
start_enabled = true
|
||||||
|
|
||||||
|
[auto_restore]
|
||||||
|
enabled = true
|
||||||
|
trigger_keys = ["space", "escape"]
|
||||||
|
|
||||||
|
[app_state]
|
||||||
|
enabled = true
|
||||||
|
english_apps = [
|
||||||
|
"code", "jetbrains", "intellij", "pycharm", "webstorm",
|
||||||
|
"vim", "nvim", "kitty", "alacritty", "foot", "ghostty",
|
||||||
|
]
|
||||||
|
vietnamese_apps = [
|
||||||
|
"telegram", "discord", "slack", "firefox", "chromium", "thunderbird",
|
||||||
|
]
|
||||||
|
|
||||||
|
[macros]
|
||||||
|
ko = "không"
|
||||||
|
kc = "không có"
|
||||||
|
"ko dc" = "không được"
|
||||||
|
dc = "được"
|
||||||
|
ng = "người"
|
||||||
|
nk = "như"
|
||||||
|
vs = "với"
|
||||||
|
lm = "làm"
|
||||||
|
rd = "rất"
|
||||||
|
bt = "biết"
|
||||||
Loading…
Reference in a new issue