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