Repository: huytd/goxkey Branch: main Commit: d833a3b3a764 Files: 36 Total size: 279.2 KB Directory structure: gitextract_0uwjvva_/ ├── .editorconfig ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── main.yml │ ├── pr.yml │ └── update-cask.yml ├── .gitignore ├── CLAUDE.md ├── Cargo.toml ├── Casks/ │ └── goxkey.rb ├── DEVELOPMENT.md ├── LICENSE ├── Makefile ├── NIGHTLY_RELEASE.md ├── README.md ├── icons/ │ └── icon.icns ├── scripts/ │ ├── pre-commit │ └── release └── src/ ├── config.rs ├── hotkey.rs ├── input.rs ├── main.rs ├── platform/ │ ├── linux.rs │ ├── macos.rs │ ├── macos_ext.rs │ ├── mod.rs │ └── windows.rs ├── scripting/ │ ├── mod.rs │ └── parser.rs └── ui/ ├── colors.rs ├── controllers.rs ├── data.rs ├── locale.rs ├── mod.rs ├── selectors.rs ├── views.rs └── widgets.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ [*.rs] indent_style = space indent_size = 4 tab_width = 4 ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: huytd ko_fi: thefullsnack ================================================ FILE: .github/workflows/main.yml ================================================ on: push: branches: - 'main' name: Stable jobs: test: name: Test project runs-on: macos-11 # add other OS later steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - run: cargo test cargo-bundle build: name: Build project permissions: write-all runs-on: macos-latest # add other OS later steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-darwin - run: cargo install cargo-bundle - run: cargo bundle --release - name: Upload artifact uses: actions/upload-artifact@v4 with: name: GoKey.app path: target/release/bundle/osx/GoKey.app retention-days: 2 - name: Release nightly env: GH_TOKEN: ${{ github.token }} run: | cd target/release/bundle/osx zip -r GoKey.zip GoKey.app gh release delete-asset nightly-build GoKey.zip gh release upload nightly-build GoKey.zip ================================================ FILE: .github/workflows/pr.yml ================================================ on: push: branches-ignore: - 'main' name: Pull request jobs: test: name: Test project runs-on: macos-11 # add other OS later steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - run: cargo test cargo-bundle build: name: Build project runs-on: macos-11 # add other OS later steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - run: cargo install cargo-bundle - run: cargo bundle --release ================================================ FILE: .github/workflows/update-cask.yml ================================================ on: release: types: [published] name: Update Homebrew Cask jobs: update-cask: name: Update Casks/goxkey.rb runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v4 - name: Compute SHA256 of release asset id: sha env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.event.release.tag_name }} run: | VERSION="${TAG#v}" URL="https://github.com/huytd/goxkey/releases/download/${TAG}/GoKey-v${VERSION}.zip" SHA=$(curl -sL "$URL" | shasum -a 256 | awk '{print $1}') echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "sha256=$SHA" >> "$GITHUB_OUTPUT" - name: Update cask version and sha256 env: VERSION: ${{ steps.sha.outputs.version }} SHA256: ${{ steps.sha.outputs.sha256 }} run: | sed -i "s/version \".*\"/version \"$VERSION\"/" Casks/goxkey.rb sed -i "s/sha256 \".*\"/sha256 \"$SHA256\"/" Casks/goxkey.rb - name: Commit and push env: VERSION: ${{ steps.sha.outputs.version }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add Casks/goxkey.rb git commit -m "chore: bump cask to v$VERSION" git push ================================================ FILE: .gitignore ================================================ /target .DS_Store .idea ================================================ FILE: CLAUDE.md ================================================ ## Project Overview Gõkey is a Vietnamese input method editor (IME) for macOS, written in Rust. It intercepts keyboard events via `CGEventTap`, accumulates typed characters into a buffer, delegates transformation to the [`vi-rs`](https://github.com/zerox-dg/vi-rs) crate, then replaces the typed characters using the backspace technique. ## Commands ```sh make setup # Install git hooks (run once after cloning) make run # cargo r make bundle # cargo bundle (creates .app bundle, requires cargo-bundle) cargo test # Run all tests cargo test # Run a single test by name ``` **Requirements:** `cargo-bundle` must be installed (`cargo install cargo-bundle`). The app requires macOS Accessibility permission granted before first run. ## Architecture ### Data Flow ``` macOS CGEventTap → event_handler() in main.rs → INPUT_STATE (global InputState) → vi-rs (transformation engine) → send_backspace + send_string → target app ``` App change events feed into `InputState` for auto-toggling Vietnamese per-app. ### Key Modules - **`src/main.rs`** — Entry point. Sets up the Druid UI window, spawns the keyboard event listener thread, and contains `event_handler()` which is the core dispatch function for every keystroke. - **`src/input.rs`** — `InputState`: the central state machine. Manages the typing buffer, calls `vi-rs` for transformation, handles macro expansion, word restoration (reverting invalid transformations), and app-specific auto-toggle. - **`src/platform/macos.rs`** — All macOS-specific code: `CGEventTap` setup, synthetic key event generation, accessibility permission checks, active app detection, system tray. - **`src/platform/mod.rs`** — Platform abstraction: `PressedKey`, `KeyModifier` bitflags, `EventTapType`, and the `send_string`/`send_backspace`/`run_event_listener` interface. - **`src/config.rs`** — `ConfigStore`: reads/writes `~/.goxkey` in a simple key-value format. Stores hotkey, input method, macros, VN/EN app lists, and allowed words. - **`src/hotkey.rs`** — Parses hotkey strings (e.g., `"super+shift+z"`) and matches them against current key + modifiers. - **`src/ui/`** — Druid-based settings UI. `views.rs` defines the window layout; `data.rs` defines `UIDataAdapter` (Druid data binding); `widgets.rs` has custom widgets (`SegmentedControl`, `ToggleSwitch`, `HotkeyBadgesWidget`, `AppsListWidget`). The `UPDATE_UI` selector in `selectors.rs` synchronizes input state changes to the UI. ### Threading Model - **Main thread**: Druid UI event loop. - **Listener thread**: `run_event_listener()` runs the `CGEventTap` callback. Communicates back to UI via `EventSink` (stored in global `UI_EVENT_SINK`). - Global state (`INPUT_STATE`, `UI_EVENT_SINK`) uses `unsafe` static access, as callbacks cannot carry context. ### Input Handling Logic (`main.rs` `event_handler`) 1. Check if key matches the toggle hotkey → enable/disable. 2. Non-character keys (arrows, function keys) → reset word tracking. 3. Space/Enter/Tab → finalize word, attempt macro replacement. 4. Backspace → pop character from buffer. 5. Regular character → push to buffer, call `do_transform_keys()` which runs `vi-rs` and uses backspace+retype to replace the word. 6. Word restoration: `should_restore_transformed_word()` determines when to revert a transformation (e.g., when the user types a non-Vietnamese sequence). ================================================ FILE: Cargo.toml ================================================ [package] description = "Bộ gõ tiếng Việt mã nguồn mở đa hệ điều hành Gõ Key" edition = "2021" name = "goxkey" version = "0.3.1" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] env_logger = "0.10.0" libc = "0.2.139" log = "0.4.17" vi = "0.6.2" bitflags = "1.3.2" druid = { features = [ "image", "png", ], git = "https://github.com/huytd/druid", branch = "master" } once_cell = "1.17.0" auto-launch = "0.5.0" nom = "7.1.3" [target.'cfg(target_os="macos")'.dependencies] core-foundation = "0.9.3" core-graphics = "0.22.3" foreign-types = "0.3.2" rdev = "0.5.2" cocoa = "0.24" objc = "0.2" objc-foundation = "0.1" objc_id = "0.1" accessibility = "0.1.6" accessibility-sys = "0.1.3" [package.metadata.bundle] copyright = "Copyright (c) Huy Tran 2023. All rights reserved." icon = ["icons/icon.icns", "icons/icon.png"] identifier = "com.goxkey.app" name = "GoKey" version = "0.3.1" ================================================ FILE: Casks/goxkey.rb ================================================ cask "goxkey" do version "0.3.0" sha256 "e747009b9c78d2ea3d72ed5419c24090553cbc1c7095dc63145de89467c7649e" url "https://github.com/huytd/goxkey/releases/download/v#{version}/GoKey-v#{version}.zip" name "Gõ Key" desc "Vietnamese input method editor for macOS" homepage "https://github.com/huytd/goxkey" depends_on macos: ">= :monterey" app "GoKey.app" caveats <<~EOS Gõ Key requires Accessibility permission to intercept keyboard events. After launching the app, go to: System Settings → Privacy & Security → Accessibility → enable Gõ Key Default toggle shortcut: Ctrl+Space EOS end ================================================ FILE: DEVELOPMENT.md ================================================ ## Development Currently, only macOS is supported. Windows and Linux could also be supported as well but it's not our primary goal. If you're on these OSes, consider contributing! Any help would be greatly appreciated! This project will only focus on the input handling logic, and provide a frontend for the input engine ([`vi-rs`](https://github.com/zerox-dg/vi-rs)). The following diagram explains how `goxkey` communicates with other components like OS's input source and `vi-rs`: ``` INPUT LAYER +------------------+ FRONTEND ENGINE | macOS | [d,d,a,a,y] +---------+ "ddaay" +-------+ | +- CGEventTap | -----------> | goxkey | ----------> | vi-rs | | | +---------+ +-------+ | Linux (TBD) | | ^ | | Windows (TBD) | | | "đây" | +------------------+ | +--------------------+ | | (send_key) v Target Application ``` On macOS, we run an instance of `CGEventTap` to listen for every `keydown` event. A callback function will be called on every keystroke. In this callback, we have a buffer (`TYPING_BUF`) to keep track of the word that the user is typing. This buffer will be reset whenever the user hit the `SPACE` or `ENTER` key. The input engine (`vi-rs`) will receive this buffer and convert it to a correct word, for example: `vieetj` will be transformed into `việt`. The result string will be sent back to `goxkey`, and from there, it will perform an edit on the target application. The edit is done using [the BACKSPACE technique](https://notes.huy.rocks/posts/go-tieng-viet-linux.html#k%C4%A9-thu%E1%BA%ADt-backspace). It's unreliable but it has the benefit of not having the pre-edit line so it's worth it. To get yourself familiar with IME, here are some good article on the topic: - [Vietnamese Keyboard Engine with Prolog](https://followthe.trailing.space/To-the-Root-of-the-Tree-dc170bf0e8de44a6b812ca3e01025236?p=0dd31fe76ebd45dca5b4466c9441fa1c&pm=s), lewtds - [Ước mơ bộ gõ kiểu Unikey trên Linux](https://followthe.trailing.space/To-the-Root-of-the-Tree-dc170bf0e8de44a6b812ca3e01025236?p=9b12cc2fcdbe43149b10eefc7db6b161&pm=s), lewtds - [Vấn đề về IME trên Linux](https://viethung.space/blog/2020/07/21/Van-de-ve-IME-tren-Linux/), zerox-dg - [Bỏ dấu trong tiếng Việt](https://viethung.space/blog/2020/07/14/Bo-dau-trong-tieng-Viet/), zerox-dg - [Chuyện gõ tiếng Việt trên Linux](https://notes.huy.rocks/posts/go-tieng-viet-linux.html), huytd ## Local development setup To setup the project locally, first, checkout the code and run the install script to have all the Git hooks configured: ```sh $ git clone https://github.com/huytd/goxkey && cd goxkey $ make setup ``` After this step, you can use the `make` commands to run or bundle the code as needed: ```sh $ make run # or $ make bundle ``` ================================================ FILE: LICENSE ================================================ BSD 3-Clause License Copyright (c) 2023, Huy Tran Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: Makefile ================================================ VERSION := $(shell grep '^version' Cargo.toml | head -1 | sed 's/.*= *"\(.*\)"/\1/') run: cargo r bundle: cargo bundle --release setup: mkdir -p .git/hooks cp -rf scripts/pre-commit .git/hooks chmod +x .git/hooks/pre-commit # Build, sign, notarize, and produce GoKey-v.zip ready for release. # Requires: cargo-bundle, a valid "Developer ID Application" cert, and the # AC_PASSWORD keychain profile configured via xcrun notarytool. release: bundle bash scripts/release cd target/release/bundle/osx && \ ditto -c -k --keepParent GoKey.app GoKey-v$(VERSION).zip @echo "Release asset: target/release/bundle/osx/GoKey-v$(VERSION).zip" @echo "SHA256: $$(shasum -a 256 target/release/bundle/osx/GoKey-v$(VERSION).zip | awk '{print $$1}')" # Update Casks/goxkey.rb with the SHA256 of the just-built release zip. # Run after `make release` before tagging. update-cask: $(eval SHA256 := $(shell shasum -a 256 target/release/bundle/osx/GoKey-v$(VERSION).zip | awk '{print $$1}')) sed -i '' 's/version ".*"/version "$(VERSION)"/' Casks/goxkey.rb sed -i '' 's/sha256 ".*"/sha256 "$(SHA256)"/' Casks/goxkey.rb @echo "Casks/goxkey.rb updated → version=$(VERSION) sha256=$(SHA256)" ================================================ FILE: NIGHTLY_RELEASE.md ================================================ 🍎 Bản build này được release tự động sau mỗi lần update code từ nhóm phát triển. Hiện tại chỉ mới hỗ trợ macOS. 🖥 Để cài đặt và sử dụng, các bạn có thể làm theo các bước sau: 1. Download file **GoKey.zip** về, giải nén ra, sẽ thấy file **Gõ Key.app** 2. Kéo thả file **Gõ Key.app** vào thư mục **/Applications** của macOS 3. Làm theo [hướng dẫn ở đây để cấp quyền Accessibility cho **Gõ Key.app**](https://github.com/huytd/goxkey/wiki/H%C6%B0%E1%BB%9Bng-d%E1%BA%ABn-s%E1%BB%ADa-l%E1%BB%97i-kh%C3%B4ng-g%C3%B5-%C4%91%C6%B0%E1%BB%A3c-ti%E1%BA%BFng-Vi%E1%BB%87t-tr%C3%AAn-macOS#tr%C6%B0%E1%BB%9Dng-h%E1%BB%A3p-l%E1%BB%97i-do-ch%C6%B0a-c%E1%BA%A5p-quy%E1%BB%81n-accessibility) 4. Click phải chuột vào **Gõ Key.app** và chọn Open image 🔬 Vì đây là bản build chưa chính thức, và chưa được notarized, macOS sẽ hỏi lại vài lần để chắc là bạn có muốn mở app không, khi xuất hiện các hộp thoại này, xin đừng nhấn nút _"Move to Trash"_ 😂 🐞 Trong quá trình sử dụng, nếu có lỗi xảy ra, xin đừng chửi tác giả, mà vui lòng [Tạo issue mới tại đây](https://github.com/huytd/goxkey/issues) và mô tả vấn đề bạn gặp phải. Nhóm phát triển chân thành cảm ơn sự ủng hộ của các bạn :D ================================================ FILE: README.md ================================================

screenshots **Gõkey** - A Vietnamese input method editor. - :zap: Excellent performance (Gen Z translation: Blazing fast!) - :crab: Written completely in Rust. - :keyboard: Supported both Telex and VNI input method. - :sparkles: Focused on typing experience and features that you will use. ## Why another Vietnamese IME? > technical curiosity ## About This is my attempt to build an input method editor using only Rust. It's not the first, and definitely not the last. The goal is to create an input method editor that enable users to type Vietnamese text on the computer using either VNI or TELEX method. Other than that, no other features are planned. ## How to install There are 2 options to download GõKey at this moment: Build from source or Download the Nightly build. ### Option 1: Download the Nightly Build Nightly build is the prebuilt binary that automatically bundled everytime we merged the code to the `main` branch. You can download it at the Release page here: https://github.com/huytd/goxkey/releases/tag/nightly-build ### Option 2: Build from source The source code can be compiled easily: 1. Get the latest stable version of the Rust compiler ([see here](https://rustup.rs/)) 2. Install the [cargo-bundle](https://github.com/burtonageo/cargo-bundle) extension, this is necessary for bundling macOS apps ``` cargo install cargo-bundle ``` 3. Checkout the source code of the **gõkey** project ``` git clone https://github.com/huytd/goxkey && cd goxkey ``` 4. Run the bundle command: ``` cargo bundle ``` After that, you'll find the `GoKey.app` file in the `target/debug/bundle` folder. Copy it to your `/Applications` folder. 5. **(Important!):** Before you run the app, make you you already allowed Accessibility access for the app. Follow the [guide in the Wiki](https://github.com/huytd/goxkey/wiki/H%C6%B0%E1%BB%9Bng-d%E1%BA%ABn-s%E1%BB%ADa-l%E1%BB%97i-kh%C3%B4ng-g%C3%B5-%C4%91%C6%B0%E1%BB%A3c-ti%E1%BA%BFng-Vi%E1%BB%87t-tr%C3%AAn-macOS) to do so. Without this step, the app will crash and can't be use. ## Development ```sh # Run with UI-only mode (skip Accessibility permission check) cargo r -- --skip-permission # Force a specific UI language (vi or en), ignoring OS language cargo r -- --lang vi cargo r -- --lang en ``` ## Dependencies - [core-foundation](https://crates.io/crates/core-foundation), [core-graphics](https://crates.io/crates/core-graphics): for event handling on macOS - [vi-rs](https://github.com/zerox-dg/vi-rs): the Vietnamese Input Engine ## Fun fact Do you know how to type gõkey in Telex? Do this: `goxkey` ================================================ FILE: scripts/pre-commit ================================================ cargo fmt git add . ================================================ FILE: scripts/release ================================================ codesign -s "Developer ID Application: Huy Tran" --timestamp --options=runtime target/release/bundle/osx/GoKey.app ditto -c -k --keepParent target/release/bundle/osx/GoKey.app target/release/bundle/osx/GoKey.zip xcrun notarytool submit target/release/bundle/osx/GoKey.zip --keychain-profile "AC_PASSWORD" --wait xcrun stapler staple target/release/bundle/osx/GoKey.app rm target/release/bundle/osx/GoKey.zip ================================================ FILE: src/config.rs ================================================ use std::collections::BTreeMap; use std::io::BufRead; use std::{ fs::File, io, io::{Result, Write}, path::PathBuf, sync::Mutex, }; use once_cell::sync::Lazy; use crate::platform::get_home_dir; pub static CONFIG_MANAGER: Lazy> = Lazy::new(|| Mutex::new(ConfigStore::new())); pub struct ConfigStore { hotkey: String, method: String, vn_apps: Vec, en_apps: Vec, is_macro_enabled: bool, is_macro_autocap_enabled: bool, macro_table: BTreeMap, is_auto_toggle_enabled: bool, is_gox_mode_enabled: bool, is_w_literal_enabled: bool, ui_language: String, allowed_words: Vec, } fn parse_vec_string(line: String) -> Vec { line.split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() } pub(crate) fn parse_kv_string(line: &str) -> Option<(String, String)> { if let Some((left, right)) = line.split_once("\"=\"") { let left = left.strip_prefix("\"").map(|s| s.replace("\\\"", "\"")); let right = right.strip_suffix("\"").map(|s| s.replace("\\\"", "\"")); return left.zip(right); } return None; } pub(crate) fn build_kv_string(k: &str, v: &str) -> String { format!( "\"{}\"=\"{}\"", k.replace("\"", "\\\""), v.replace("\"", "\\\"") ) } impl ConfigStore { fn get_config_path() -> PathBuf { get_home_dir() .expect("Cannot read home directory!") .join(".goxkey") } fn write_config_data(&mut self) -> Result<()> { let mut file = File::create(ConfigStore::get_config_path())?; writeln!(file, "{} = {}", HOTKEY_CONFIG_KEY, self.hotkey)?; writeln!(file, "{} = {}", TYPING_METHOD_CONFIG_KEY, self.method)?; writeln!(file, "{} = {}", VN_APPS_CONFIG_KEY, self.vn_apps.join(","))?; writeln!(file, "{} = {}", EN_APPS_CONFIG_KEY, self.en_apps.join(","))?; writeln!( file, "{} = {}", ALLOWED_WORDS_CONFIG_KEY, self.allowed_words.join(",") )?; writeln!( file, "{} = {}", AUTOS_TOGGLE_ENABLED_CONFIG_KEY, self.is_auto_toggle_enabled )?; writeln!( file, "{} = {}", MACRO_ENABLED_CONFIG_KEY, self.is_macro_enabled )?; writeln!( file, "{} = {}", MACRO_AUTOCAP_ENABLED_CONFIG_KEY, self.is_macro_autocap_enabled )?; for (k, v) in self.macro_table.iter() { writeln!(file, "{} = {}", MACROS_CONFIG_KEY, build_kv_string(k, &v))?; } writeln!( file, "{} = {}", GOX_MODE_CONFIG_KEY, self.is_gox_mode_enabled )?; writeln!( file, "{} = {}", W_LITERAL_CONFIG_KEY, self.is_w_literal_enabled )?; writeln!(file, "{} = {}", UI_LANGUAGE_CONFIG_KEY, self.ui_language)?; Ok(()) } pub fn new() -> Self { let mut config = Self { hotkey: "ctrl+space".to_string(), method: "telex".to_string(), vn_apps: Vec::new(), en_apps: Vec::new(), is_macro_enabled: false, is_macro_autocap_enabled: false, macro_table: BTreeMap::new(), is_auto_toggle_enabled: false, is_gox_mode_enabled: false, is_w_literal_enabled: false, ui_language: "auto".to_string(), allowed_words: vec!["đc".to_string()], }; let config_path = ConfigStore::get_config_path(); if let Ok(file) = File::open(config_path) { let reader = io::BufReader::new(file); for line in reader.lines() { if let Some((left, right)) = line.unwrap_or_default().split_once(" = ") { match left { HOTKEY_CONFIG_KEY => config.hotkey = right.to_string(), TYPING_METHOD_CONFIG_KEY => config.method = right.to_string(), VN_APPS_CONFIG_KEY => config.vn_apps = parse_vec_string(right.to_string()), EN_APPS_CONFIG_KEY => config.en_apps = parse_vec_string(right.to_string()), ALLOWED_WORDS_CONFIG_KEY => { config.allowed_words = parse_vec_string(right.to_string()) } AUTOS_TOGGLE_ENABLED_CONFIG_KEY => { config.is_auto_toggle_enabled = matches!(right.trim(), "true") } MACRO_ENABLED_CONFIG_KEY => { config.is_macro_enabled = matches!(right.trim(), "true") } MACRO_AUTOCAP_ENABLED_CONFIG_KEY => { config.is_macro_autocap_enabled = matches!(right.trim(), "true") } MACROS_CONFIG_KEY => { if let Some((k, v)) = parse_kv_string(right) { config.macro_table.insert(k, v); } } GOX_MODE_CONFIG_KEY => { config.is_gox_mode_enabled = matches!(right.trim(), "true") } W_LITERAL_CONFIG_KEY => { config.is_w_literal_enabled = matches!(right.trim(), "true") } UI_LANGUAGE_CONFIG_KEY => config.ui_language = right.trim().to_string(), _ => {} } } } } config } // Hotkey pub fn get_hotkey(&self) -> &str { &self.hotkey } pub fn set_hotkey(&mut self, hotkey: &str) { self.hotkey = hotkey.to_string(); self.save(); } // Method pub fn get_method(&self) -> &str { &self.method } pub fn set_method(&mut self, method: &str) { self.method = method.to_string(); self.save(); } pub fn is_vietnamese_app(&self, app_name: &str) -> bool { self.vn_apps.contains(&app_name.to_string()) } pub fn is_english_app(&self, app_name: &str) -> bool { self.en_apps.contains(&app_name.to_string()) } pub fn get_vn_apps(&self) -> Vec { self.vn_apps.clone() } pub fn get_en_apps(&self) -> Vec { self.en_apps.clone() } pub fn add_vietnamese_app(&mut self, app_name: &str) { if self.is_english_app(app_name) { self.en_apps.retain(|x| x != app_name); } if !self.is_vietnamese_app(app_name) { self.vn_apps.push(app_name.to_string()); } self.save(); } pub fn add_english_app(&mut self, app_name: &str) { if self.is_vietnamese_app(app_name) { self.vn_apps.retain(|x| x != app_name); } if !self.is_english_app(app_name) { self.en_apps.push(app_name.to_string()); } self.save(); } pub fn remove_vietnamese_app(&mut self, app_name: &str) { self.vn_apps.retain(|x| x != app_name); self.save(); } pub fn remove_english_app(&mut self, app_name: &str) { self.en_apps.retain(|x| x != app_name); self.save(); } pub fn is_allowed_word(&self, word: &str) -> bool { self.allowed_words.contains(&word.to_string()) } pub fn is_auto_toggle_enabled(&self) -> bool { self.is_auto_toggle_enabled } pub fn set_auto_toggle_enabled(&mut self, flag: bool) { self.is_auto_toggle_enabled = flag; self.save(); } pub fn is_gox_mode_enabled(&self) -> bool { self.is_gox_mode_enabled } pub fn set_gox_mode_enabled(&mut self, flag: bool) { self.is_gox_mode_enabled = flag; self.save(); } pub fn is_w_literal_enabled(&self) -> bool { self.is_w_literal_enabled } pub fn set_w_literal_enabled(&mut self, flag: bool) { self.is_w_literal_enabled = flag; self.save(); } pub fn get_ui_language(&self) -> &str { &self.ui_language } pub fn set_ui_language(&mut self, lang: &str) { self.ui_language = lang.to_string(); self.save(); } pub fn is_macro_enabled(&self) -> bool { self.is_macro_enabled } pub fn set_macro_enabled(&mut self, flag: bool) { self.is_macro_enabled = flag; self.save(); } pub fn is_macro_autocap_enabled(&self) -> bool { self.is_macro_autocap_enabled } pub fn set_macro_autocap_enabled(&mut self, flag: bool) { self.is_macro_autocap_enabled = flag; self.save(); } pub fn get_macro_table(&self) -> &BTreeMap { &self.macro_table } pub fn add_macro(&mut self, from: String, to: String) { self.macro_table.insert(from, to); self.save(); } pub fn delete_macro(&mut self, from: &String) { self.macro_table.remove(from); self.save(); } // Save config to file fn save(&mut self) { self.write_config_data().expect("Failed to write config"); } } const HOTKEY_CONFIG_KEY: &str = "hotkey"; const TYPING_METHOD_CONFIG_KEY: &str = "method"; const VN_APPS_CONFIG_KEY: &str = "vn-apps"; const EN_APPS_CONFIG_KEY: &str = "en-apps"; const MACRO_ENABLED_CONFIG_KEY: &str = "is_macro_enabled"; const MACRO_AUTOCAP_ENABLED_CONFIG_KEY: &str = "is_macro_autocap_enabled"; const AUTOS_TOGGLE_ENABLED_CONFIG_KEY: &str = "is_auto_toggle_enabled"; const MACROS_CONFIG_KEY: &str = "macros"; const GOX_MODE_CONFIG_KEY: &str = "is_gox_mode_enabled"; const W_LITERAL_CONFIG_KEY: &str = "is_w_literal_enabled"; const UI_LANGUAGE_CONFIG_KEY: &str = "ui_language"; const ALLOWED_WORDS_CONFIG_KEY: &str = "allowed_words"; ================================================ FILE: src/hotkey.rs ================================================ use std::fmt::Display; use crate::platform::{ KeyModifier, KEY_DELETE, KEY_ENTER, KEY_ESCAPE, KEY_SPACE, KEY_TAB, SYMBOL_ALT, SYMBOL_CTRL, SYMBOL_SHIFT, SYMBOL_SUPER, }; pub struct Hotkey { modifiers: KeyModifier, keycode: Option, } impl Hotkey { pub fn from_str(input: &str) -> Self { let mut modifiers = KeyModifier::new(); let mut keycode: Option = None; input .split('+') .for_each(|token| match token.trim().to_uppercase().as_str() { "SHIFT" => modifiers.add_shift(), "ALT" => modifiers.add_alt(), "SUPER" => modifiers.add_super(), "CTRL" => modifiers.add_control(), "ENTER" => keycode = Some(KEY_ENTER), "SPACE" => keycode = Some(KEY_SPACE), "TAB" => keycode = Some(KEY_TAB), "DELETE" => keycode = Some(KEY_DELETE), "ESC" => keycode = Some(KEY_ESCAPE), c => { keycode = c.chars().last(); } }); Self { modifiers, keycode } } pub fn is_match(&self, mut modifiers: KeyModifier, keycode: Option) -> bool { // Caps Lock should not interfere with any hotkey modifiers.remove(KeyModifier::MODIFIER_CAPSLOCK); let letter_matched = keycode.eq(&self.keycode) || keycode .and_then(|a| self.keycode.map(|b| a.eq_ignore_ascii_case(&b))) .is_some_and(|c| c == true); self.modifiers == modifiers && letter_matched } pub fn inner(&self) -> (KeyModifier, Option) { (self.modifiers, self.keycode) } } impl Display for Hotkey { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.modifiers.is_control() { write!(f, "{} ", SYMBOL_CTRL)?; } if self.modifiers.is_shift() { write!(f, "{} ", SYMBOL_SHIFT)?; } if self.modifiers.is_alt() { write!(f, "{} ", SYMBOL_ALT)?; } if self.modifiers.is_super() { write!(f, "{} ", SYMBOL_SUPER)?; } match self.keycode { Some(KEY_ENTER) => write!(f, "Enter"), Some(KEY_SPACE) => write!(f, "Space"), Some(KEY_TAB) => write!(f, "Tab"), Some(KEY_DELETE) => write!(f, "Del"), Some(KEY_ESCAPE) => write!(f, "Esc"), Some(c) => write!(f, "{}", c.to_ascii_uppercase()), _ => write!(f, ""), } } } #[test] fn test_parse() { let hotkey = Hotkey::from_str("super+shift+z"); let mut actual_modifier = KeyModifier::new(); actual_modifier.add_shift(); actual_modifier.add_super(); assert_eq!(hotkey.modifiers, actual_modifier); assert_eq!(hotkey.keycode, Some('Z')); assert!(hotkey.is_match(actual_modifier, Some('z'))); } #[test] fn test_parse_long_input() { let hotkey = Hotkey::from_str("super+shift+ctrl+alt+w"); let mut actual_modifier = KeyModifier::new(); actual_modifier.add_shift(); actual_modifier.add_super(); actual_modifier.add_control(); actual_modifier.add_alt(); assert_eq!(hotkey.modifiers, actual_modifier); assert_eq!(hotkey.keycode, Some('W')); assert!(hotkey.is_match(actual_modifier, Some('W'))); } #[test] fn test_parse_with_named_keycode() { let hotkey = Hotkey::from_str("super+ctrl+space"); let mut actual_modifier = KeyModifier::new(); actual_modifier.add_super(); actual_modifier.add_control(); assert_eq!(hotkey.modifiers, actual_modifier); assert_eq!(hotkey.keycode, Some(KEY_SPACE)); assert!(hotkey.is_match(actual_modifier, Some(KEY_SPACE))); } #[test] fn test_can_match_with_or_without_capslock() { let hotkey = Hotkey::from_str("super+ctrl+space"); let mut actual_modifier = KeyModifier::new(); actual_modifier.add_super(); actual_modifier.add_control(); assert_eq!(hotkey.is_match(actual_modifier, Some(' ')), true); actual_modifier.add_capslock(); assert!(hotkey.is_match(actual_modifier, Some(' '))); } #[test] fn test_parse_with_just_modifiers() { let hotkey = Hotkey::from_str("ctrl+shift"); let mut actual_modifier = KeyModifier::new(); actual_modifier.add_control(); actual_modifier.add_shift(); assert_eq!(hotkey.modifiers, actual_modifier); assert_eq!(hotkey.keycode, None); assert!(hotkey.is_match(actual_modifier, None)); } #[test] fn test_display() { assert_eq!( format!("{}", Hotkey::from_str("super+ctrl+space")), format!("{} {} Space", SYMBOL_CTRL, SYMBOL_SUPER) ); assert_eq!( format!("{}", Hotkey::from_str("super+alt+z")), format!("{} {} Z", SYMBOL_ALT, SYMBOL_SUPER) ); assert_eq!( format!("{}", Hotkey::from_str("ctrl+shift+o")), format!("{} {} O", SYMBOL_CTRL, SYMBOL_SHIFT) ); } ================================================ FILE: src/input.rs ================================================ use std::collections::BTreeMap; use std::{collections::HashMap, fmt::Display, str::FromStr}; use druid::{Data, Target}; use log::debug; use once_cell::sync::{Lazy, OnceCell}; use rdev::{Keyboard, KeyboardState}; use vi::TransformResult; use crate::platform::{get_active_app_name, KeyModifier}; use crate::{ config::CONFIG_MANAGER, hotkey::Hotkey, platform::is_in_text_selection, ui::UPDATE_UI, UI_EVENT_SINK, }; // According to Google search, the longest possible Vietnamese word // is "nghiêng", which is 7 letters long. Add a little buffer for // tone and marks, I guess the longest possible buffer length would // be around 10 to 12. const MAX_POSSIBLE_WORD_LENGTH: usize = 10; const MAX_DUPLICATE_LENGTH: usize = 4; const TONE_DUPLICATE_PATTERNS: [&str; 17] = [ "ss", "ff", "jj", "rr", "xx", "ww", "kk", "tt", "nn", "mm", "yy", "hh", "ii", "aaa", "eee", "ooo", "ddd", ]; pub static mut INPUT_STATE: Lazy = Lazy::new(InputState::new); pub static mut HOTKEY_MODIFIERS: KeyModifier = KeyModifier::MODIFIER_NONE; pub static mut HOTKEY_MATCHING: bool = false; pub static mut HOTKEY_MATCHING_CIRCUIT_BREAK: bool = false; pub const PREDEFINED_CHARS: [char; 47] = [ 'a', '`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', '\'', '\\', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/', ]; pub const STOP_TRACKING_WORDS: [&str; 4] = [";", "'", "?", "/"]; /// In w-literal mode, replace standalone 'w' with placeholder bytes that the telex /// engine ignores (falls through to `_ => Transformation::Ignored`), then restore them /// after transformation. A 'w' is "standalone" when NOT preceded by a Horn/Breve-eligible /// vowel — those cases (uw→ư, ow→ơ, aw→ă) should still be handled by telex normally. enum CapPattern { Lower, TitleCase, AllCaps, } fn detect_cap_pattern(s: &str) -> CapPattern { let mut chars = s.chars().filter(|c| c.is_alphabetic()); match chars.next() { Some(first) if first.is_uppercase() => { if chars.all(|c| c.is_uppercase()) { CapPattern::AllCaps } else { CapPattern::TitleCase } } _ => CapPattern::Lower, } } fn apply_cap_pattern(s: &str, pattern: CapPattern) -> String { match pattern { CapPattern::Lower => s.to_string(), CapPattern::AllCaps => s.to_uppercase(), CapPattern::TitleCase => { let mut chars = s.chars(); match chars.next() { None => String::new(), Some(first) => first.to_uppercase().to_string() + chars.as_str(), } } } } fn mask_standalone_w(buffer: &str) -> String { // Characters that can accept Horn (w) modification: u, o and all their toned forms. // Characters that can accept Breve (w) modification: a and all its toned forms. const HORN_BREVE_ELIGIBLE: &str = "uoaUOA\u{01b0}\u{01a1}\u{0103}\ \u{00fa}\u{00f3}\u{00e1}\u{00f9}\u{00f2}\u{00e0}\ \u{1ee7}\u{1ecf}\u{1ea3}\u{0169}\u{00f5}\u{00e3}\u{1ecd}\u{1ea1}\ \u{00da}\u{00d3}\u{00c1}\u{00d9}\u{00d2}\u{00c0}\ \u{1ee6}\u{1ece}\u{1ea2}\u{0168}\u{00d5}\u{00c3}\u{1ecc}\u{1ea0}"; let chars: Vec = buffer.chars().collect(); let mut result = String::with_capacity(buffer.len() + 4); for (i, &ch) in chars.iter().enumerate() { if ch == 'w' || ch == 'W' { let preceded_by_eligible = i > 0 && HORN_BREVE_ELIGIBLE.contains(chars[i - 1]); // Also pass through when this 'w' follows a 'w' that was itself // preceded by an eligible vowel (e.g. "aww", "uww", "oww"). // This lets telex see the full "ww" sequence and undo the // Horn/Breve modification, producing the raw text. let preceded_by_w_after_eligible = i >= 2 && (chars[i - 1] == 'w' || chars[i - 1] == 'W') && HORN_BREVE_ELIGIBLE.contains(chars[i - 2]); if preceded_by_eligible || preceded_by_w_after_eligible { result.push(ch); // let telex transform it: uw→ư, ow→ơ, aw→ă, or ww→undo } else { // Mask it — telex ignores \x01/\x02, we restore them after transform result.push(if ch == 'w' { '\x01' } else { '\x02' }); } } else { result.push(ch); } } result } pub fn get_key_from_char(c: char) -> rdev::Key { use rdev::Key::*; match &c { 'a' => KeyA, '`' => BackQuote, '1' => Num1, '2' => Num2, '3' => Num3, '4' => Num4, '5' => Num5, '6' => Num6, '7' => Num7, '8' => Num8, '9' => Num9, '0' => Num0, '-' => Minus, '=' => Equal, 'q' => KeyQ, 'w' => KeyW, 'e' => KeyE, 'r' => KeyR, 't' => KeyT, 'y' => KeyY, 'u' => KeyU, 'i' => KeyI, 'o' => KeyO, 'p' => KeyP, '[' => LeftBracket, ']' => RightBracket, 's' => KeyS, 'd' => KeyD, 'f' => KeyF, 'g' => KeyG, 'h' => KeyH, 'j' => KeyJ, 'k' => KeyK, 'l' => KeyL, ';' => SemiColon, '\'' => Quote, '\\' => BackSlash, 'z' => KeyZ, 'x' => KeyX, 'c' => KeyC, 'v' => KeyV, 'b' => KeyB, 'n' => KeyN, 'm' => KeyM, ',' => Comma, '.' => Dot, '/' => Slash, _ => Unknown(0), } } pub static mut KEYBOARD_LAYOUT_CHARACTER_MAP: OnceCell> = OnceCell::new(); fn build_keyboard_layout_map(map: &mut HashMap) { map.clear(); let mut kb = Keyboard::new().unwrap(); for c in PREDEFINED_CHARS { let key = rdev::EventType::KeyPress(get_key_from_char(c)); if let Some(s) = kb.add(&key) { let ch = s.chars().last().unwrap(); map.insert(c, ch); } } } pub fn rebuild_keyboard_layout_map() { unsafe { if let Some(map) = KEYBOARD_LAYOUT_CHARACTER_MAP.get_mut() { debug!("Rebuild keyboard layout map..."); build_keyboard_layout_map(map); debug!("Done"); } else { debug!("Creating keyboard layout map..."); let mut map = HashMap::new(); build_keyboard_layout_map(&mut map); _ = KEYBOARD_LAYOUT_CHARACTER_MAP.set(map); debug!("Done"); } } } #[allow(clippy::upper_case_acronyms)] #[derive(PartialEq, Eq, Data, Clone, Copy)] pub enum TypingMethod { VNI, Telex, TelexVNI, } impl FromStr for TypingMethod { type Err = (); fn from_str(s: &str) -> Result { Ok(match s.to_ascii_lowercase().as_str() { "vni" => TypingMethod::VNI, "telexvni" => TypingMethod::TelexVNI, _ => TypingMethod::Telex, }) } } impl Display for TypingMethod { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}", match self { Self::VNI => "vni", Self::Telex => "telex", Self::TelexVNI => "telexvni", } ) } } /// Compute the minimal edit needed to transform what is currently displayed (`old`) /// into the desired output (`new`) by finding their longest common prefix. /// /// Returns `(backspace_count, suffix)` where: /// - `backspace_count` is the number of backspaces to send (to erase only the /// diverging tail of `old`) /// - `suffix` is the slice of `new` that must be typed after those backspaces /// /// Both counts are in **Unicode scalar values** (chars), not bytes, because /// each backspace deletes one displayed character regardless of its byte width. /// The returned `suffix` is a byte slice of `new` starting at the first /// diverging char — no allocation, no `.collect()`. /// /// # Example /// ``` /// // old = "mô" (on screen after typing "moo") /// // new = "mộ" (engine output after pressing 'j' for nặng tone) /// // common prefix = "m" → only "ô" needs deleting, only "ộ" needs typing /// let (bs, suffix) = get_diff_parts("mô", "mộ"); /// assert_eq!(bs, 1); /// assert_eq!(suffix, "ộ"); /// ``` pub fn get_diff_parts<'a>(old: &str, new: &'a str) -> (usize, &'a str) { // Walk both strings char-by-char simultaneously. // We track the byte offset into `new` so we can return a zero-copy suffix slice. let mut old_chars = old.chars(); let mut new_chars = new.char_indices(); // Number of chars that are identical from the start. let mut common = 0usize; // Byte offset in `new` where divergence begins (used for the suffix slice). let mut diverge_byte = new.len(); // default: full match, empty suffix loop { match (old_chars.next(), new_chars.next()) { (Some(a), Some((byte_pos, b))) if a == b => { common += 1; diverge_byte = byte_pos + b.len_utf8(); } (_, Some((byte_pos, _))) => { // Diverged — note byte position of the first differing char in `new`. diverge_byte = byte_pos; break; } (_, None) => { // `new` is a prefix of (or equal to) `old` — no suffix to type. diverge_byte = new.len(); break; } } } // old_tail_len = number of chars in old that are NOT part of the common prefix. let old_len = old.chars().count(); let backspace_count = old_len.saturating_sub(common); let suffix = &new[diverge_byte..]; (backspace_count, suffix) } pub struct InputState { buffer: String, display_buffer: String, method: TypingMethod, hotkey: Hotkey, enabled: bool, should_track: bool, previous_word: String, previous_display: String, can_resume_previous_word: bool, active_app: String, is_macro_enabled: bool, is_macro_autocap_enabled: bool, macro_table: BTreeMap, temporary_disabled: bool, previous_modifiers: KeyModifier, is_auto_toggle_enabled: bool, is_gox_mode_enabled: bool, is_w_literal_enabled: bool, } impl InputState { pub fn new() -> Self { let config = CONFIG_MANAGER.lock().unwrap(); Self { buffer: String::new(), display_buffer: String::new(), method: TypingMethod::from_str(config.get_method()).unwrap(), hotkey: Hotkey::from_str(config.get_hotkey()), enabled: true, should_track: true, previous_word: String::new(), previous_display: String::new(), can_resume_previous_word: false, active_app: String::new(), is_macro_enabled: config.is_macro_enabled(), is_macro_autocap_enabled: config.is_macro_autocap_enabled(), macro_table: config.get_macro_table().clone(), temporary_disabled: false, previous_modifiers: KeyModifier::empty(), is_auto_toggle_enabled: config.is_auto_toggle_enabled(), is_gox_mode_enabled: config.is_gox_mode_enabled(), is_w_literal_enabled: config.is_w_literal_enabled(), } } pub fn update_active_app(&mut self) -> Option<()> { let current_active_app = get_active_app_name(); // Only check if switch app if current_active_app == self.active_app { return None; } self.active_app = current_active_app; let config = CONFIG_MANAGER.lock().unwrap(); // Only switch the input mode if we found the app in the config if config.is_vietnamese_app(&self.active_app) { self.enabled = true; } if config.is_english_app(&self.active_app) { self.enabled = false; } Some(()) } pub fn set_temporary_disabled(&mut self) { self.temporary_disabled = true; } pub fn is_gox_mode_enabled(&self) -> bool { self.is_gox_mode_enabled } pub fn is_w_literal_enabled(&self) -> bool { self.is_w_literal_enabled } pub fn toggle_w_literal(&mut self) { self.is_w_literal_enabled = !self.is_w_literal_enabled; CONFIG_MANAGER .lock() .unwrap() .set_w_literal_enabled(self.is_w_literal_enabled); } pub fn is_enabled(&self) -> bool { !self.temporary_disabled && self.enabled } pub fn is_tracking(&self) -> bool { self.should_track } pub fn is_buffer_empty(&self) -> bool { self.buffer.is_empty() } pub fn new_word(&mut self) { if !self.buffer.is_empty() { self.clear(); } if self.temporary_disabled { self.temporary_disabled = false; } self.should_track = true; self.can_resume_previous_word = false; } /// Mark that the previous word can be resumed if the user presses /// backspace immediately (i.e. the word was ended by space/tab/enter). pub fn mark_resumable(&mut self) { self.can_resume_previous_word = true; } /// Try to restore the previous word's buffers so editing can continue. /// Returns true if the word was resumed, false otherwise. pub fn try_resume_previous_word(&mut self) -> bool { if !self.can_resume_previous_word || self.previous_word.is_empty() { return false; } self.buffer = self.previous_word.clone(); self.display_buffer = self.previous_display.clone(); self.should_track = true; self.can_resume_previous_word = false; true } pub fn get_macro_target(&self) -> Option { if !self.is_macro_enabled { return None; } // Exact match if let Some(target) = self.macro_table.get(&self.display_buffer) { return Some(target.clone()); } // Auto-capitalize: try lowercase lookup, then apply cap pattern if self.is_macro_autocap_enabled { let lower = self.display_buffer.to_lowercase(); if let Some(target) = self.macro_table.get(&lower) { let pattern = detect_cap_pattern(&self.display_buffer); return Some(apply_cap_pattern(target, pattern)); } } None } pub fn is_macro_autocap_enabled(&self) -> bool { self.is_macro_autocap_enabled } pub fn toggle_macro_autocap(&mut self) { self.is_macro_autocap_enabled = !self.is_macro_autocap_enabled; CONFIG_MANAGER .lock() .unwrap() .set_macro_autocap_enabled(self.is_macro_autocap_enabled); } pub fn get_typing_buffer(&self) -> &str { &self.buffer } pub fn get_displaying_word(&self) -> &str { &self.display_buffer } pub fn stop_tracking(&mut self) { self.clear(); self.should_track = false; } pub fn toggle_vietnamese(&mut self) { self.enabled = !self.enabled; self.temporary_disabled = false; let mut config = CONFIG_MANAGER.lock().unwrap(); if self.enabled { config.add_vietnamese_app(&self.active_app); } else { config.add_english_app(&self.active_app); } self.new_word(); } pub fn add_vietnamese_app(&mut self, app_name: &str) { CONFIG_MANAGER.lock().unwrap().add_vietnamese_app(app_name); } pub fn add_english_app(&mut self, app_name: &str) { CONFIG_MANAGER.lock().unwrap().add_english_app(app_name); } pub fn remove_vietnamese_app(&mut self, app_name: &str) { CONFIG_MANAGER .lock() .unwrap() .remove_vietnamese_app(app_name); } pub fn remove_english_app(&mut self, app_name: &str) { CONFIG_MANAGER.lock().unwrap().remove_english_app(app_name); } pub fn get_vn_apps(&self) -> Vec { CONFIG_MANAGER.lock().unwrap().get_vn_apps() } pub fn get_en_apps(&self) -> Vec { CONFIG_MANAGER.lock().unwrap().get_en_apps() } pub fn set_method(&mut self, method: TypingMethod) { self.method = method; self.new_word(); CONFIG_MANAGER .lock() .unwrap() .set_method(&method.to_string()); if let Some(event_sink) = UI_EVENT_SINK.get() { _ = event_sink.submit_command(UPDATE_UI, (), Target::Auto); } } pub fn get_method(&self) -> TypingMethod { self.method } pub fn set_hotkey(&mut self, key_sequence: &str) { self.hotkey = Hotkey::from_str(key_sequence); CONFIG_MANAGER.lock().unwrap().set_hotkey(key_sequence); if let Some(event_sink) = UI_EVENT_SINK.get() { _ = event_sink.submit_command(UPDATE_UI, (), Target::Auto); } } pub fn get_hotkey(&self) -> &Hotkey { &self.hotkey } pub fn is_auto_toggle_enabled(&self) -> bool { self.is_auto_toggle_enabled } pub fn toggle_auto_toggle(&mut self) { self.is_auto_toggle_enabled = !self.is_auto_toggle_enabled; CONFIG_MANAGER .lock() .unwrap() .set_auto_toggle_enabled(self.is_auto_toggle_enabled); } pub fn is_macro_enabled(&self) -> bool { self.is_macro_enabled } pub fn toggle_macro_enabled(&mut self) { self.is_macro_enabled = !self.is_macro_enabled; CONFIG_MANAGER .lock() .unwrap() .set_macro_enabled(self.is_macro_enabled); } pub fn get_macro_table(&self) -> &BTreeMap { &self.macro_table } pub fn delete_macro(&mut self, from: &String) { self.macro_table.remove(from); CONFIG_MANAGER.lock().unwrap().delete_macro(from); } pub fn add_macro(&mut self, from: String, to: String) { CONFIG_MANAGER .lock() .unwrap() .add_macro(from.clone(), to.clone()); self.macro_table.insert(from, to); } pub fn export_macros_to_file(&self, path: &str) -> std::io::Result<()> { use crate::config::build_kv_string; use std::fs::File; use std::io::Write; let mut file = File::create(path)?; for (k, v) in &self.macro_table { writeln!(file, "{}", build_kv_string(k, v))?; } Ok(()) } pub fn import_macros_from_file(&mut self, path: &str) -> std::io::Result { use crate::config::parse_kv_string; use std::fs::File; use std::io::{BufRead, BufReader}; let file = File::open(path)?; let reader = BufReader::new(file); let mut count = 0; for line in reader.lines() { let line = line?; let line = line.trim(); if line.is_empty() { continue; } if let Some((from, to)) = parse_kv_string(line) { self.add_macro(from, to); count += 1; } } Ok(count) } pub fn should_transform_keys(&self, c: &char) -> bool { self.enabled } pub fn transform_keys(&self) -> Result<(String, TransformResult), ()> { // In w-literal mode (Telex only), replace standalone 'w' with a placeholder // before feeding to the telex engine, then restore it in the output. // A 'w' is considered standalone if NOT preceded by a Horn/Breve-eligible vowel // (u, o for Horn; a for Breve). This preserves uw→ư, ow→ơ, aw→ă etc. let effective_buffer = if self.is_w_literal_enabled && matches!(self.method, TypingMethod::Telex | TypingMethod::TelexVNI) { mask_standalone_w(&self.buffer) } else { self.buffer.clone() }; if self.method == TypingMethod::TelexVNI { // Try both methods; prefer VNI when the buffer contains digits // (VNI's key differentiator), otherwise fall back to Telex. let buffer = effective_buffer; let result = std::panic::catch_unwind(move || { let has_digits = buffer.chars().any(|c| c.is_ascii_digit()); if has_digits { let mut output = String::new(); let transform_result = vi::vni::transform_buffer(buffer.chars(), &mut output); (output, transform_result) } else { let mut output = String::new(); let transform_result = vi::telex::transform_buffer(buffer.chars(), &mut output); let output = output.replace('\x01', "w").replace('\x02', "W"); (output, transform_result) } }); return result.map_err(|_| ()); } let method = self.method; let buffer = effective_buffer; let is_w_literal = self.is_w_literal_enabled; let result = std::panic::catch_unwind(move || { let mut output = String::new(); let transform_result = match method { TypingMethod::VNI => vi::vni::transform_buffer(buffer.chars(), &mut output), TypingMethod::Telex | TypingMethod::TelexVNI => { vi::telex::transform_buffer(buffer.chars(), &mut output) } }; // Restore masked standalone w's back to literal 'w'/'W' let output = if is_w_literal { output.replace('\x01', "w").replace('\x02', "W") } else { output }; (output, transform_result) }); if let Ok((output, transform_result)) = result { return Ok((output, transform_result)); } Err(()) } pub fn should_send_keyboard_event(&self, word: &str) -> bool { !self.display_buffer.eq(word) } pub fn should_dismiss_selection_if_needed(&self) -> bool { const DISMISS_APPS: [&str; 3] = ["Firefox", "Floorp", "Zen"]; return DISMISS_APPS.iter().any(|app| self.active_app.contains(app)); } pub fn get_backspace_count(&self, is_delete: bool) -> usize { let dp_len = self.display_buffer.chars().count(); let backspace_count = if is_delete && dp_len >= 1 { dp_len } else { dp_len - 1 }; if is_in_text_selection() { backspace_count + 1 } else { backspace_count } } pub fn replace(&mut self, buf: String) { self.display_buffer = buf; } pub fn push(&mut self, c: char) { if let Some(first_char) = self.buffer.chars().next() { if first_char.is_numeric() { self.buffer.remove(0); self.display_buffer.remove(0); } } if self.buffer.len() <= MAX_POSSIBLE_WORD_LENGTH { self.buffer.push(c); self.display_buffer.push(c); debug!( "Input buffer: {:?} - Display buffer: {:?}", self.buffer, self.display_buffer ); } } pub fn pop(&mut self) { self.buffer.pop(); if self.buffer.is_empty() { self.display_buffer.clear(); self.new_word(); } } pub fn clear(&mut self) { self.previous_word = self.buffer.to_owned(); self.previous_display = self.display_buffer.to_owned(); self.buffer.clear(); self.display_buffer.clear(); } pub fn get_previous_word(&self) -> &str { &self.previous_word } pub fn clear_previous_word(&mut self) { self.previous_word.clear(); } pub fn previous_word_is_stop_tracking_words(&self) -> bool { STOP_TRACKING_WORDS.contains(&self.previous_word.as_str()) } pub fn should_stop_tracking(&mut self) -> bool { let len = self.buffer.len(); if len > MAX_POSSIBLE_WORD_LENGTH { return true; } let buf = &self.buffer; if TONE_DUPLICATE_PATTERNS .iter() .find(|p| buf.to_ascii_lowercase().contains(*p)) .is_some() { return true; } if self.previous_word_is_stop_tracking_words() { return true; } false } pub fn stop_tracking_if_needed(&mut self) { if self.should_stop_tracking() { self.stop_tracking(); debug!("! Stop tracking"); } } pub fn get_previous_modifiers(&self) -> KeyModifier { self.previous_modifiers } pub fn save_previous_modifiers(&mut self, modifiers: KeyModifier) { self.previous_modifiers = modifiers; } pub fn is_allowed_word(&self, word: &str) -> bool { let config = CONFIG_MANAGER.lock().unwrap(); return config.is_allowed_word(word); } } #[cfg(test)] mod diff_tests { use super::get_diff_parts; // ── Basic tone application ──────────────────────────────────────────────── /// "mô" → "mộ": only the vowel+tone char is replaced, "m" stays. #[test] fn tone_on_vowel_preserves_consonant_prefix() { let (bs, sfx) = get_diff_parts("mô", "mộ"); assert_eq!(bs, 1, "should delete only 'ô'"); assert_eq!(sfx, "ộ"); } /// "mo" → "mô": typing 'o' again applies the circumflex. #[test] fn circumflex_application() { let (bs, sfx) = get_diff_parts("mo", "mô"); assert_eq!(bs, 1); assert_eq!(sfx, "ô"); } /// "tieng" → "tiếng": "ti" preserved, vowel+tone suffix replaced. #[test] fn multi_char_prefix_preserved() { let (bs, sfx) = get_diff_parts("tieng", "tiếng"); assert_eq!(bs, 3); // "eng" deleted assert_eq!(sfx, "ếng"); } /// "nguyen" → "nguyên": "nguy" is common. #[test] fn longer_common_prefix() { let (bs, sfx) = get_diff_parts("nguyen", "nguyên"); assert_eq!(bs, 2); // "en" deleted assert_eq!(sfx, "ên"); } // ── No-op / identical strings ───────────────────────────────────────────── /// Identical strings → 0 backspaces, empty suffix. #[test] fn identical_strings_no_op() { let (bs, sfx) = get_diff_parts("mộ", "mộ"); assert_eq!(bs, 0); assert_eq!(sfx, ""); } // ── Empty edge cases ────────────────────────────────────────────────────── #[test] fn both_empty() { let (bs, sfx) = get_diff_parts("", ""); assert_eq!(bs, 0); assert_eq!(sfx, ""); } #[test] fn old_empty_new_nonempty() { let (bs, sfx) = get_diff_parts("", "mộ"); assert_eq!(bs, 0); assert_eq!(sfx, "mộ"); } #[test] fn old_nonempty_new_empty() { let (bs, sfx) = get_diff_parts("mô", ""); assert_eq!(bs, 2); assert_eq!(sfx, ""); } // ── Prefix / suffix relationships ───────────────────────────────────────── /// new is a strict prefix of old: delete tail, type nothing. #[test] fn new_is_prefix_of_old() { let (bs, sfx) = get_diff_parts("mộng", "mộ"); assert_eq!(bs, 2); // delete "ng" assert_eq!(sfx, ""); } /// old is a strict prefix of new: 0 backspaces, append tail. #[test] fn old_is_prefix_of_new() { let (bs, sfx) = get_diff_parts("mộ", "mộng"); assert_eq!(bs, 0); assert_eq!(sfx, "ng"); } // ── Completely different strings ────────────────────────────────────────── #[test] fn no_common_prefix() { let (bs, sfx) = get_diff_parts("abc", "xyz"); assert_eq!(bs, 3); assert_eq!(sfx, "xyz"); } // ── Multi-byte / Unicode correctness ───────────────────────────────────── /// Each Vietnamese toned vowel is 1 char, possibly 3 bytes. /// backspace_count must be in chars, not bytes. #[test] fn char_count_not_byte_count() { let (bs, sfx) = get_diff_parts("ộ", "ô"); assert_eq!(bs, 1, "one char deleted, not three bytes"); assert_eq!(sfx, "ô"); } #[test] fn all_multibyte_no_common_prefix() { let (bs, sfx) = get_diff_parts("ộ", "ể"); assert_eq!(bs, 1); assert_eq!(sfx, "ể"); } // ── Realistic Telex sequences ───────────────────────────────────────────── /// "moo" (buffer) → "mô" (engine output). #[test] fn telex_moo_to_mo_hat() { let (bs, sfx) = get_diff_parts("moo", "mô"); assert_eq!(bs, 2); assert_eq!(sfx, "ô"); } /// "cas" → "cá": "c" preserved. #[test] fn telex_cas_to_ca_sac() { let (bs, sfx) = get_diff_parts("cas", "cá"); assert_eq!(bs, 2); assert_eq!(sfx, "á"); } /// "viet" → "việt" #[test] fn telex_viet_transform() { let (bs, sfx) = get_diff_parts("viet", "việt"); assert_eq!(bs, 2); // common = "vi" assert_eq!(sfx, "ệt"); } /// Tone cycling: "tiến" → "tiền" (sắc → huyền), "ti" preserved. #[test] fn tone_cycling_preserves_prefix() { let (bs, sfx) = get_diff_parts("tiến", "tiền"); assert_eq!(bs, 2); assert_eq!(sfx, "ền"); } // ── Suffix slice is a zero-copy view into `new` ─────────────────────────── #[test] fn suffix_is_valid_utf8_slice_of_new() { let new = "nguyên"; let (_, sfx) = get_diff_parts("nguyen", new); let new_start = new.as_ptr() as usize; let sfx_start = sfx.as_ptr() as usize; assert!(sfx_start >= new_start); assert!(sfx_start + sfx.len() <= new_start + new.len()); assert_eq!(sfx, "ên"); } } #[cfg(test)] mod mask_w_tests { use super::mask_standalone_w; #[test] fn standalone_w_is_masked() { // 'w' not preceded by eligible vowel → masked assert_eq!(mask_standalone_w("w"), "\x01"); assert_eq!(mask_standalone_w("rw"), "r\x01"); } #[test] fn standalone_upper_w_is_masked() { assert_eq!(mask_standalone_w("W"), "\x02"); assert_eq!(mask_standalone_w("RW"), "R\x02"); } #[test] fn w_after_eligible_vowel_is_not_masked() { // aw→ă, uw→ư, ow→ơ should pass through assert_eq!(mask_standalone_w("aw"), "aw"); assert_eq!(mask_standalone_w("uw"), "uw"); assert_eq!(mask_standalone_w("ow"), "ow"); } #[test] fn ww_after_eligible_vowel_not_masked() { // "aww" → both w's passed through so telex sees "ww" and undoes breve assert_eq!(mask_standalone_w("aww"), "aww"); assert_eq!(mask_standalone_w("uww"), "uww"); assert_eq!(mask_standalone_w("oww"), "oww"); assert_eq!(mask_standalone_w("raww"), "raww"); } #[test] fn standalone_ww_both_masked() { // "ww" with no eligible vowel before → both masked assert_eq!(mask_standalone_w("ww"), "\x01\x01"); assert_eq!(mask_standalone_w("rww"), "r\x01\x01"); } #[test] fn mixed_case_ww_after_eligible() { assert_eq!(mask_standalone_w("aWW"), "aWW"); assert_eq!(mask_standalone_w("AWw"), "AWw"); } } #[cfg(test)] mod tracking_tests { use super::InputState; #[test] fn stop_tracking_disables_tracking() { let mut state = InputState::new(); state.push('r'); assert!(state.is_tracking()); state.stop_tracking(); assert!(!state.is_tracking()); assert!(state.is_buffer_empty()); } #[test] fn new_word_re_enables_tracking_after_stop() { let mut state = InputState::new(); state.push('r'); state.stop_tracking(); assert!(!state.is_tracking()); state.new_word(); assert!(state.is_tracking()); } #[test] fn pop_to_empty_then_new_word_re_enables_tracking() { // Simulates: type "raww" → stop_tracking → backspace to empty → new_word let mut state = InputState::new(); state.push('r'); state.push('a'); state.push('w'); state.push('w'); state.stop_tracking(); // triggered by "ww" pattern assert!(!state.is_tracking()); assert!(state.is_buffer_empty()); // Backspaces clear the screen (handled by OS), buffer already empty. // Calling new_word() re-enables tracking for the next keystrokes. state.new_word(); assert!(state.is_tracking()); // New characters should be tracked state.push('o'); state.push('o'); assert_eq!(state.get_typing_buffer(), "oo"); } #[test] fn resume_previous_word_re_enables_tracking() { let mut state = InputState::new(); state.push('t'); state.push('e'); state.push('s'); state.push('t'); // Simulate end-of-word (space) → new_word + mark_resumable state.new_word(); state.mark_resumable(); assert!(state.is_buffer_empty()); // Resume should restore the previous word assert!(state.try_resume_previous_word()); assert!(state.is_tracking()); assert_eq!(state.get_typing_buffer(), "test"); } } ================================================ FILE: src/main.rs ================================================ mod config; mod hotkey; mod input; mod platform; mod scripting; mod ui; use std::thread; use druid::{AppLauncher, ExtEventSink, Target, WindowDesc}; use input::{ get_diff_parts, rebuild_keyboard_layout_map, TypingMethod, HOTKEY_MATCHING, HOTKEY_MATCHING_CIRCUIT_BREAK, HOTKEY_MODIFIERS, INPUT_STATE, }; use log::debug; use once_cell::sync::OnceCell; use platform::{ add_app_change_callback, add_appearance_change_callback, dispatch_set_systray_title, ensure_accessibility_permission, run_event_listener, send_arrow_left, send_arrow_right, send_backspace, send_string, EventTapType, Handle, KeyModifier, PressedKey, KEY_DELETE, KEY_ENTER, KEY_ESCAPE, KEY_SPACE, KEY_TAB, RAW_ARROW_DOWN, RAW_ARROW_LEFT, RAW_ARROW_RIGHT, RAW_ARROW_UP, RAW_KEY_GLOBE, }; use ui::{get_theme, UIDataAdapter, IS_DARK, THEME, UPDATE_UI}; static UI_EVENT_SINK: OnceCell = OnceCell::new(); const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); fn apply_capslock_to_output(output: String, is_capslock: bool) -> String { if is_capslock { output.to_uppercase() } else { output } } fn normalize_input_char(c: char, is_shift: bool) -> char { if is_shift { c.to_ascii_uppercase() } else { c } } fn do_transform_keys(handle: Handle, is_delete: bool, is_capslock: bool) -> bool { unsafe { if let Ok((raw_output, transform_result)) = INPUT_STATE.transform_keys() { let should_send_event = INPUT_STATE.should_send_keyboard_event(&raw_output); let output = apply_capslock_to_output(raw_output, is_capslock); debug!("Transformed: {:?}", output); if should_send_event || is_delete { // This is a workaround for Firefox-based browsers, where macOS's Accessibility API cannot work. // We cannot get the selected text in the address bar, so we will go with another // hacky way: Always send a space and delete it immediately. This will dismiss the // current pre-selected URL and fix the double character issue. if INPUT_STATE.should_dismiss_selection_if_needed() { _ = send_string(handle, " "); _ = send_backspace(handle, 1); } // Compute the minimal diff between what is currently displayed // and the new output. Only delete and retype the diverging // suffix — the common prefix stays on screen untouched, which // eliminates flicker in Chromium/Electron apps (e.g. Messenger) // caused by a VSync frame landing between the backspace burst // and the reinsertion of the full word. // // Exception: when `is_delete` is true the caller wants the // entire word erased (e.g. the user pressed Delete/Backspace), // so we fall back to full-replace in that case. let (backspace_count, suffix_offset, screen_char_count) = if is_delete { let bs = INPUT_STATE.get_backspace_count(is_delete); (bs, 0usize, bs) } else { // Clone the display buffer so we hold no borrow into INPUT_STATE // while calling get_diff_parts, which borrows `output`. let displaying = INPUT_STATE.get_displaying_word().to_owned(); // `push(c)` was called just before this function, appending the // typed char to display_buffer. That char has NOT yet appeared on // screen because we are about to block the key event and replace it // ourselves. Strip it so `old` reflects the true on-screen state. let screen_end = displaying .char_indices() .next_back() .map(|(i, _)| i) .unwrap_or(displaying.len()); let screen = &displaying[..screen_end]; let sc = screen.chars().count(); let (bs, sfx) = get_diff_parts(screen, &output); let offset = output.len() - sfx.len(); (bs, offset, sc) }; let suffix = &output[suffix_offset..]; debug!("Backspace count: {}", backspace_count); // When the entire on-screen word would be erased (no common // prefix), Chromium/Electron apps fire an "empty value" event // that swallows subsequent keystrokes. Avoid this by keeping // one sentinel char on screen: type the new text first, then // navigate back to delete the sentinel. let needs_sentinel = !is_delete && backspace_count > 1 && backspace_count == screen_char_count; if needs_sentinel { // Keep one old char as a sentinel so the field never // empties (Chromium/Electron kill pending events on // empty). Build the new word left-to-right: let first_char_end = suffix .char_indices() .nth(1) .map(|(i, _)| i) .unwrap_or(suffix.len()); let first_char = &suffix[..first_char_end]; let rest = &suffix[first_char_end..]; // 1. Delete all old chars except the last (sentinel) _ = send_backspace(handle, backspace_count - 1); // 2. Type the first char of the new output _ = send_string(handle, first_char); // 3. Move left behind the first char (before sentinel) _ = send_arrow_left(handle, 1); // 4. Delete the sentinel _ = send_backspace(handle, 1); // 5. Move right past the first char _ = send_arrow_right(handle, 1); // 6. Type the rest of the output if !rest.is_empty() { _ = send_string(handle, rest); } } else { _ = send_backspace(handle, backspace_count); if !suffix.is_empty() { _ = send_string(handle, suffix); } } debug!("Sent suffix: {:?}", suffix); INPUT_STATE.replace(output); if transform_result.letter_modification_removed || transform_result.tone_mark_removed { INPUT_STATE.stop_tracking(); } return true; } } } false } fn do_restore_word(handle: Handle, is_capslock: bool) { unsafe { let backspace_count = INPUT_STATE.get_backspace_count(true); debug!("Backspace count: {}", backspace_count); _ = send_backspace(handle, backspace_count); let typing_buffer = INPUT_STATE.get_typing_buffer(); let output = apply_capslock_to_output(typing_buffer.to_owned(), is_capslock); _ = send_string(handle, &output); debug!("Sent: {:?}", output); INPUT_STATE.replace(output); } } fn should_restore_transformed_word( method: TypingMethod, typing_buffer: &str, display_buffer: &str, is_valid_word: bool, is_allowed_word: bool, ) -> bool { let is_transformed_word = typing_buffer != display_buffer; if !is_transformed_word || is_valid_word || is_allowed_word { return false; } // Keep VNI shorthand words (like d9m -> đm) when ending a word with space/tab/enter. let is_vni_numeric_shortcut = method == TypingMethod::VNI && typing_buffer.chars().any(|c| c.is_numeric()); !is_vni_numeric_shortcut } fn do_macro_replace(handle: Handle, target: &String) { unsafe { let backspace_count = INPUT_STATE.get_backspace_count(true); debug!("Backspace count: {}", backspace_count); _ = send_backspace(handle, backspace_count); _ = send_string(handle, target); debug!("Sent: {:?}", target); INPUT_STATE.replace(target.to_owned()); } } /// Compute the tray title from the current INPUT_STATE and dispatch it /// directly to the main queue, so the status bar updates instantly. pub unsafe fn update_systray_title_immediately() { let is_enabled = INPUT_STATE.is_enabled(); let is_gox = INPUT_STATE.is_gox_mode_enabled(); let title = if is_enabled { if is_gox { "gõ" } else { "VN" } } else if is_gox { match INPUT_STATE.get_method() { TypingMethod::Telex => "gox", TypingMethod::VNI => "go4", TypingMethod::TelexVNI => "go+", } } else { "EN" }; dispatch_set_systray_title(title, is_enabled); } unsafe fn toggle_vietnamese() { INPUT_STATE.toggle_vietnamese(); update_systray_title_immediately(); if let Some(event_sink) = UI_EVENT_SINK.get() { if let Err(e) = event_sink.submit_command(UPDATE_UI, (), Target::Auto) { debug!("Failed to submit UPDATE_UI command: {:?}", e); } } } unsafe fn auto_toggle_vietnamese() { if !INPUT_STATE.is_auto_toggle_enabled() { return; } let has_change = INPUT_STATE.update_active_app().is_some(); if !has_change { return; } update_systray_title_immediately(); if let Some(event_sink) = UI_EVENT_SINK.get() { if let Err(e) = event_sink.submit_command(UPDATE_UI, (), Target::Auto) { debug!("Failed to submit UPDATE_UI command: {:?}", e); } } } fn event_handler( handle: Handle, event_type: EventTapType, pressed_key: Option, modifiers: KeyModifier, ) -> bool { unsafe { let pressed_key_code = pressed_key.and_then(|p| match p { PressedKey::Char(c) => Some(c), _ => None, }); if event_type == EventTapType::FlagsChanged { if modifiers.is_empty() { // Modifier keys are released if HOTKEY_MATCHING && !HOTKEY_MATCHING_CIRCUIT_BREAK { toggle_vietnamese(); } HOTKEY_MODIFIERS = KeyModifier::MODIFIER_NONE; HOTKEY_MATCHING = false; HOTKEY_MATCHING_CIRCUIT_BREAK = false; } else { HOTKEY_MODIFIERS.set(modifiers, true); } } let is_hotkey_matched = INPUT_STATE .get_hotkey() .is_match(HOTKEY_MODIFIERS, pressed_key_code); if HOTKEY_MATCHING && !is_hotkey_matched { HOTKEY_MATCHING_CIRCUIT_BREAK = true; } HOTKEY_MATCHING = is_hotkey_matched; // If the hotkey matched on a key press, toggle immediately and // suppress the event so macOS does not insert the character // (e.g. Option+Z → Ω). Set HOTKEY_MATCHING_CIRCUIT_BREAK so // the FlagsChanged handler does not toggle again on key release. if is_hotkey_matched && pressed_key_code.is_some() { toggle_vietnamese(); HOTKEY_MATCHING_CIRCUIT_BREAK = true; return true; } match pressed_key { Some(pressed_key) => { match pressed_key { PressedKey::Raw(raw_keycode) => { if raw_keycode == RAW_KEY_GLOBE { toggle_vietnamese(); return true; } if raw_keycode == RAW_ARROW_UP || raw_keycode == RAW_ARROW_DOWN { INPUT_STATE.new_word(); } if raw_keycode == RAW_ARROW_LEFT || raw_keycode == RAW_ARROW_RIGHT { // TODO: Implement a better cursor tracking on each word here INPUT_STATE.new_word(); } } PressedKey::Char(keycode) => { if INPUT_STATE.is_enabled() { match keycode { KEY_ENTER | KEY_TAB | KEY_SPACE | KEY_ESCAPE => { let typing_buffer = INPUT_STATE.get_typing_buffer(); let display_word = INPUT_STATE.get_displaying_word(); let is_valid_word = vi::validation::is_valid_word(display_word); let is_allowed_word = INPUT_STATE.is_allowed_word(display_word); if should_restore_transformed_word( INPUT_STATE.get_method(), typing_buffer, display_word, is_valid_word, is_allowed_word, ) { do_restore_word(handle, modifiers.is_capslock()); } if INPUT_STATE.previous_word_is_stop_tracking_words() { INPUT_STATE.clear_previous_word(); } if keycode == KEY_TAB || keycode == KEY_SPACE { if let Some(macro_target) = INPUT_STATE.get_macro_target() { debug!("Macro: {}", macro_target); do_macro_replace(handle, ¯o_target) } } let had_content = !INPUT_STATE.is_buffer_empty(); INPUT_STATE.new_word(); if had_content && (keycode == KEY_SPACE || keycode == KEY_TAB) { INPUT_STATE.mark_resumable(); } } KEY_DELETE => { if !modifiers.is_empty() && !modifiers.is_shift() { INPUT_STATE.new_word(); } else if INPUT_STATE.is_buffer_empty() { // Buffer is empty — the user just started a new // word (e.g. after space). Try to resume editing // the previous word so backspace + retype works. // If resume fails, reset to a fresh tracking state // so the next keystrokes are processed (e.g. after // stop_tracking from a duplicate pattern like "ww"). if !INPUT_STATE.try_resume_previous_word() { INPUT_STATE.new_word(); } } else { INPUT_STATE.pop(); if !INPUT_STATE.is_buffer_empty() { return do_transform_keys( handle, true, modifiers.is_capslock(), ); } } } c => { if "()[]{}<>/\\!@#$%^&*-_=+|~`,.;'\"/".contains(c) || (c.is_numeric() && modifiers.is_shift()) { // If special characters detected, dismiss the current tracking word if c.is_numeric() { INPUT_STATE.push(c); } INPUT_STATE.new_word(); } else { // Otherwise, process the character if modifiers.is_super() || modifiers.is_alt() { INPUT_STATE.new_word(); } else if INPUT_STATE.is_tracking() { INPUT_STATE.push(normalize_input_char( c, modifiers.is_shift(), )); let ret = do_transform_keys( handle, false, modifiers.is_capslock(), ); INPUT_STATE.stop_tracking_if_needed(); return ret; } } } } } else { match keycode { KEY_ENTER | KEY_TAB | KEY_SPACE | KEY_ESCAPE => { INPUT_STATE.new_word(); } _ => { if !modifiers.is_empty() { INPUT_STATE.new_word(); } } } } } }; } None => { let previous_modifiers = INPUT_STATE.get_previous_modifiers(); if previous_modifiers.is_empty() { if modifiers.is_control() { if !INPUT_STATE.get_typing_buffer().is_empty() { do_restore_word(handle, modifiers.is_capslock()); } INPUT_STATE.set_temporary_disabled(); } if modifiers.is_super() || event_type == EventTapType::Other { INPUT_STATE.new_word(); } } } } INPUT_STATE.save_previous_modifiers(modifiers); } false } #[cfg(test)] mod tests { use super::{apply_capslock_to_output, normalize_input_char, should_restore_transformed_word}; use crate::input::TypingMethod; #[test] fn restore_when_invalid_and_not_allowed() { let should_restore = should_restore_transformed_word(TypingMethod::Telex, "maaa", "màa", false, false); assert!(should_restore); } #[test] fn no_restore_for_valid_word() { let should_restore = should_restore_transformed_word(TypingMethod::Telex, "tieens", "tiến", true, false); assert!(!should_restore); } #[test] fn no_restore_for_allowed_word() { let should_restore = should_restore_transformed_word(TypingMethod::Telex, "ddc", "đc", false, true); assert!(!should_restore); } #[test] fn no_restore_for_vni_numeric_shorthand() { let should_restore = should_restore_transformed_word(TypingMethod::VNI, "d9m", "đm", false, false); assert!(!should_restore); } #[test] fn restore_for_vni_invalid_without_numeric_shorthand() { let should_restore = should_restore_transformed_word(TypingMethod::VNI, "dam", "đm", false, false); assert!(should_restore); } #[test] fn normalize_input_char_only_depends_on_shift() { assert_eq!(normalize_input_char('d', true), 'D'); assert_eq!(normalize_input_char('d', false), 'd'); } #[test] fn apply_capslock_to_transformed_output() { let lower = String::from("duyệt"); assert_eq!(apply_capslock_to_output(lower.clone(), false), "duyệt"); assert_eq!(apply_capslock_to_output(lower, true), "DUYỆT"); } #[test] fn capslock_path_keeps_telex_tone_position() { let mut transformed = String::new(); vi::telex::transform_buffer("duyeetj".chars(), &mut transformed); assert_eq!(apply_capslock_to_output(transformed, true), "DUYỆT"); } #[test] fn no_send_needed_for_plain_letter_with_capslock_only_case_change() { // For plain letters with Caps Lock, OS already inserts uppercase characters. // We should not treat case-only difference as a transform event. let mut transformed = String::new(); vi::telex::transform_buffer("z".chars(), &mut transformed); assert_eq!(transformed, "z"); } } fn main() { let app_title = format!("gõkey v{APP_VERSION}"); env_logger::init(); { let config = crate::config::CONFIG_MANAGER.lock().unwrap(); ui::locale::init_lang(config.get_ui_language()); } let skip_permission = std::env::args().any(|a| a == "--skip-permission"); if !skip_permission && !ensure_accessibility_permission() { // Show the Accessibility Permission Request screen let win = WindowDesc::new(ui::permission_request_ui_builder()) .title(app_title) .window_size((500.0, 360.0)) .resizable(false); let app = AppLauncher::with_window(win); _ = app.launch(()); } else { // Start the GõKey application rebuild_keyboard_layout_map(); let win = WindowDesc::new(ui::main_ui_builder()) .title(app_title) .window_size((ui::WINDOW_WIDTH, ui::WINDOW_HEIGHT)) .set_position(ui::center_window_position()) .set_always_on_top(true) .resizable(false); let app = AppLauncher::with_window(win); let event_sink = app.get_external_handle(); _ = UI_EVENT_SINK.set(event_sink); thread::spawn(|| { run_event_listener(&event_handler); }); add_app_change_callback(|| { unsafe { auto_toggle_vietnamese() }; }); add_appearance_change_callback(|| { if let Some(sink) = UI_EVENT_SINK.get() { _ = sink.submit_command(UPDATE_UI, (), Target::Auto); } }); _ = app .configure_env(|env: &mut druid::Env, data: &UIDataAdapter| { env.set(THEME.clone(), std::sync::Arc::new(get_theme(data.is_dark))); env.set(IS_DARK.clone(), data.is_dark); }) .launch(UIDataAdapter::new()); } } ================================================ FILE: src/platform/linux.rs ================================================ // TODO: Implement this use druid::{commands::CLOSE_WINDOW, Selector}; use super::CallbackFn; pub const SYMBOL_SHIFT: &str = "⇧"; pub const SYMBOL_CTRL: &str = "⌃"; pub const SYMBOL_SUPER: &str = "❖"; pub const SYMBOL_ALT: &str = "⌥"; pub fn get_home_dir() -> Option { env::var("HOME").ok().map(PathBuf::from) } pub fn send_backspace(count: usize) -> Result<(), ()> { todo!() } pub fn send_string(string: &str) -> Result<(), ()> { todo!() } pub fn run_event_listener(callback: &CallbackFn) { todo!() } pub fn ensure_accessibility_permission() -> bool { true } pub fn is_in_text_selection() -> bool { todo!() } pub fn update_launch_on_login(is_enable: bool) { todo!() } pub fn is_launch_on_login() { todo!() } ================================================ FILE: src/platform/macos.rs ================================================ use std::env::current_exe; use std::path::Path; use std::{env, path::PathBuf, ptr}; mod macos_ext; use auto_launch::{AutoLaunch, AutoLaunchBuilder}; use cocoa::base::id; use cocoa::{ base::{nil, BOOL, YES}, foundation::NSDictionary, }; use core_graphics::{ event::{ CGEventFlags, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, CGEventType, CGKeyCode, EventField, KeyCode, }, sys, }; use objc::{class, msg_send, sel, sel_impl}; pub use macos_ext::defer_open_app_file_picker; pub use macos_ext::defer_open_text_file_picker; pub use macos_ext::defer_save_text_file_picker; pub use macos_ext::dispatch_set_systray_title; pub use macos_ext::SystemTray; pub use macos_ext::SystemTrayMenuItemKey; use once_cell::sync::Lazy; use crate::input::KEYBOARD_LAYOUT_CHARACTER_MAP; use accessibility::{AXAttribute, AXUIElement}; use accessibility_sys::{kAXFocusedUIElementAttribute, kAXSelectedTextAttribute}; use core_foundation::{ runloop::{kCFRunLoopCommonModes, CFRunLoop}, string::CFString, }; pub use self::macos_ext::Handle; use self::macos_ext::{ kAXTrustedCheckOptionPrompt, new_tap, AXIsProcessTrustedWithOptions, CGEventCreateKeyboardEvent, CGEventKeyboardSetUnicodeString, CGEventTapPostEvent, }; use super::{ CallbackFn, EventTapType, KeyModifier, PressedKey, KEY_DELETE, KEY_ENTER, KEY_ESCAPE, KEY_SPACE, KEY_TAB, }; pub const SYMBOL_SHIFT: &str = "⇧"; pub const SYMBOL_CTRL: &str = "⌃"; pub const SYMBOL_SUPER: &str = "⌘"; pub const SYMBOL_ALT: &str = "⌥"; impl From for EventTapType { fn from(value: CGEventType) -> Self { match value { CGEventType::KeyDown => EventTapType::KeyDown, CGEventType::FlagsChanged => EventTapType::FlagsChanged, _ => EventTapType::Other, } } } static AUTO_LAUNCH: Lazy = Lazy::new(|| { let app_path = get_current_app_path(); let app_name = Path::new(&app_path) .file_stem() .and_then(|f| f.to_str()) .unwrap(); AutoLaunchBuilder::new() .set_app_name(app_name) .set_app_path(&app_path) .build() .unwrap() }); /// On macOS, current_exe gives path to /Applications/Example.app/MacOS/Example but this results in seeing a Unix Executable in macOS login items. It must be: /Applications/Example.app /// If it didn't find exactly a single occurrence of .app, it will default to exe path to not break it. fn get_current_app_path() -> String { let current_exe = current_exe().unwrap(); let exe_path = current_exe.canonicalize().unwrap().display().to_string(); let parts: Vec<&str> = exe_path.split(".app/").collect(); return if parts.len() == 2 { format!("{}.app", parts.get(0).unwrap().to_string()) } else { exe_path }; } #[macro_export] macro_rules! nsstring_to_string { ($ns_string:expr) => {{ use objc::{sel, sel_impl}; let utf8: id = objc::msg_send![$ns_string, UTF8String]; let string = if !utf8.is_null() { Some({ std::ffi::CStr::from_ptr(utf8 as *const std::ffi::c_char) .to_string_lossy() .into_owned() }) } else { None }; string }}; } pub fn get_home_dir() -> Option { env::var("HOME").ok().map(PathBuf::from) } // List of keycode: https://eastmanreference.com/complete-list-of-applescript-key-codes fn get_char(keycode: CGKeyCode) -> Option { if let Some(key_map) = unsafe { KEYBOARD_LAYOUT_CHARACTER_MAP.get() } { return match keycode { 0 => Some(PressedKey::Char(key_map[&'a'])), 1 => Some(PressedKey::Char(key_map[&'s'])), 2 => Some(PressedKey::Char(key_map[&'d'])), 3 => Some(PressedKey::Char(key_map[&'f'])), 4 => Some(PressedKey::Char(key_map[&'h'])), 5 => Some(PressedKey::Char(key_map[&'g'])), 6 => Some(PressedKey::Char(key_map[&'z'])), 7 => Some(PressedKey::Char(key_map[&'x'])), 8 => Some(PressedKey::Char(key_map[&'c'])), 9 => Some(PressedKey::Char(key_map[&'v'])), 11 => Some(PressedKey::Char(key_map[&'b'])), 12 => Some(PressedKey::Char(key_map[&'q'])), 13 => Some(PressedKey::Char(key_map[&'w'])), 14 => Some(PressedKey::Char(key_map[&'e'])), 15 => Some(PressedKey::Char(key_map[&'r'])), 16 => Some(PressedKey::Char(key_map[&'y'])), 17 => Some(PressedKey::Char(key_map[&'t'])), 31 => Some(PressedKey::Char(key_map[&'o'])), 32 => Some(PressedKey::Char(key_map[&'u'])), 34 => Some(PressedKey::Char(key_map[&'i'])), 35 => Some(PressedKey::Char(key_map[&'p'])), 37 => Some(PressedKey::Char(key_map[&'l'])), 38 => Some(PressedKey::Char(key_map[&'j'])), 40 => Some(PressedKey::Char(key_map[&'k'])), 45 => Some(PressedKey::Char(key_map[&'n'])), 46 => Some(PressedKey::Char(key_map[&'m'])), 18 => Some(PressedKey::Char(key_map[&'1'])), 19 => Some(PressedKey::Char(key_map[&'2'])), 20 => Some(PressedKey::Char(key_map[&'3'])), 21 => Some(PressedKey::Char(key_map[&'4'])), 22 => Some(PressedKey::Char(key_map[&'6'])), 23 => Some(PressedKey::Char(key_map[&'5'])), 25 => Some(PressedKey::Char(key_map[&'9'])), 26 => Some(PressedKey::Char(key_map[&'7'])), 28 => Some(PressedKey::Char(key_map[&'8'])), 29 => Some(PressedKey::Char(key_map[&'0'])), 27 => Some(PressedKey::Char(key_map[&'-'])), 33 => Some(PressedKey::Char(key_map[&'['])), 30 => Some(PressedKey::Char(key_map[&']'])), 41 => Some(PressedKey::Char(key_map[&';'])), 43 => Some(PressedKey::Char(key_map[&','])), 24 => Some(PressedKey::Char(key_map[&'='])), 42 => Some(PressedKey::Char(key_map[&'\\'])), 44 => Some(PressedKey::Char(key_map[&'/'])), 39 => Some(PressedKey::Char(key_map[&'\''])), 47 => Some(PressedKey::Char(key_map[&'.'])), 36 | 52 => Some(PressedKey::Char(KEY_ENTER)), // ENTER 49 => Some(PressedKey::Char(KEY_SPACE)), // SPACE 48 => Some(PressedKey::Char(KEY_TAB)), // TAB 51 => Some(PressedKey::Char(KEY_DELETE)), // DELETE 53 => Some(PressedKey::Char(KEY_ESCAPE)), // ESC _ => Some(PressedKey::Raw(keycode)), }; } None } pub fn is_in_text_selection() -> bool { let system_element = AXUIElement::system_wide(); let Some(selected_element) = system_element .attribute(&AXAttribute::new(&CFString::from_static_string( kAXFocusedUIElementAttribute, ))) .map(|elemenet| elemenet.downcast_into::()) .ok() .flatten() else { return false; }; let Some(selected_text) = selected_element .attribute(&AXAttribute::new(&CFString::from_static_string( kAXSelectedTextAttribute, ))) .map(|text| text.downcast_into::()) .ok() .flatten() else { return false; }; !selected_text.to_string().is_empty() } pub fn send_backspace(handle: Handle, count: usize) -> Result<(), ()> { let null_event_source = ptr::null_mut() as *mut sys::CGEventSource; let (event_bs_down, event_bs_up) = unsafe { ( CGEventCreateKeyboardEvent(null_event_source, KeyCode::DELETE, true), CGEventCreateKeyboardEvent(null_event_source, KeyCode::DELETE, false), ) }; for _ in 0..count { unsafe { CGEventTapPostEvent(handle, event_bs_down); CGEventTapPostEvent(handle, event_bs_up); } } Ok(()) } pub fn send_arrow_left(handle: Handle, count: usize) -> Result<(), ()> { let null_event_source = ptr::null_mut() as *mut sys::CGEventSource; let (down, up) = unsafe { ( CGEventCreateKeyboardEvent(null_event_source, super::RAW_ARROW_LEFT as CGKeyCode, true), CGEventCreateKeyboardEvent( null_event_source, super::RAW_ARROW_LEFT as CGKeyCode, false, ), ) }; for _ in 0..count { unsafe { CGEventTapPostEvent(handle, down); CGEventTapPostEvent(handle, up); } } Ok(()) } pub fn send_arrow_right(handle: Handle, count: usize) -> Result<(), ()> { let null_event_source = ptr::null_mut() as *mut sys::CGEventSource; let (down, up) = unsafe { ( CGEventCreateKeyboardEvent( null_event_source, super::RAW_ARROW_RIGHT as CGKeyCode, true, ), CGEventCreateKeyboardEvent( null_event_source, super::RAW_ARROW_RIGHT as CGKeyCode, false, ), ) }; for _ in 0..count { unsafe { CGEventTapPostEvent(handle, down); CGEventTapPostEvent(handle, up); } } Ok(()) } pub fn send_string(handle: Handle, string: &str) -> Result<(), ()> { let utf_16_str: Vec = string.encode_utf16().collect(); let null_event_source = ptr::null_mut() as *mut sys::CGEventSource; unsafe { let event_str = CGEventCreateKeyboardEvent(null_event_source, 0, true); let buflen = utf_16_str.len() as libc::c_ulong; let bufptr = utf_16_str.as_ptr(); CGEventKeyboardSetUnicodeString(event_str, buflen, bufptr); CGEventTapPostEvent(handle, event_str); } Ok(()) } pub fn add_app_change_callback(cb: F) where F: Fn() + Send + 'static, { macos_ext::add_app_change_callback(cb); } pub fn add_appearance_change_callback(cb: F) where F: Fn() + Send + 'static, { macos_ext::add_appearance_change_callback(cb); } pub fn run_event_listener(callback: &CallbackFn) { let current = CFRunLoop::get_current(); if let Ok(event_tap) = new_tap::CGEventTap::new( CGEventTapLocation::HID, CGEventTapPlacement::HeadInsertEventTap, CGEventTapOptions::Default, vec![ CGEventType::KeyDown, CGEventType::RightMouseDown, CGEventType::LeftMouseDown, CGEventType::OtherMouseDown, CGEventType::FlagsChanged, ], |proxy, _, event| { if !is_process_trusted() { eprintln!("Accessibility access removed!"); std::process::exit(1); } let mut modifiers = KeyModifier::new(); let flags = event.get_flags(); if flags.contains(CGEventFlags::CGEventFlagShift) { modifiers.add_shift(); } if flags.contains(CGEventFlags::CGEventFlagAlphaShift) { modifiers.add_capslock(); } if flags.contains(CGEventFlags::CGEventFlagControl) { modifiers.add_control(); } if flags.contains(CGEventFlags::CGEventFlagCommand) { modifiers.add_super(); } if flags.contains(CGEventFlags::CGEventFlagAlternate) { modifiers.add_alt(); } if flags.eq(&CGEventFlags::CGEventFlagNonCoalesced) || flags.eq(&CGEventFlags::CGEventFlagNull) { modifiers = KeyModifier::MODIFIER_NONE; } let event_tap_type: EventTapType = EventTapType::from(event.get_type()); match event_tap_type { EventTapType::KeyDown => { let source_state_id = event.get_integer_value_field(EventField::EVENT_SOURCE_STATE_ID); if source_state_id == 1 { let key_code = event .get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE) as CGKeyCode; if callback(proxy, event_tap_type, get_char(key_code), modifiers) { // block the key if already processed return None; } } } EventTapType::FlagsChanged => { callback(proxy, event_tap_type, None, modifiers); } _ => { callback(proxy, event_tap_type, None, KeyModifier::new()); } } Some(event.to_owned()) }, ) { unsafe { let loop_source = event_tap.mach_port.create_runloop_source(0).expect("Cannot start event tap. Make sure you have granted Accessibility Access for the application."); current.add_source(&loop_source, kCFRunLoopCommonModes); event_tap.enable(); CFRunLoop::run_current(); } } } pub fn is_process_trusted() -> bool { unsafe { accessibility_sys::AXIsProcessTrusted() } } pub fn ensure_accessibility_permission() -> bool { unsafe { let options = NSDictionary::dictionaryWithObject_forKey_( nil, msg_send![class!(NSNumber), numberWithBool: YES], kAXTrustedCheckOptionPrompt as _, ); return AXIsProcessTrustedWithOptions(options as _); } } /// Return the RGBA pixel data (pre-multiplied) and dimensions for the icon of /// the application at `app_path` (e.g. "/Applications/Safari.app"). /// The icon is rendered at `size`×`size` points. Returns `None` on failure. pub fn get_app_icon_rgba(app_path: &str, size: u32) -> Option<(Vec, u32, u32)> { unsafe { use cocoa::base::nil; use cocoa::foundation::NSString; let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace]; let path_ns = NSString::alloc(nil).init_str(app_path); let icon: id = msg_send![workspace, iconForFile: path_ns]; if icon.is_null() { return None; } let ns_size: cocoa::foundation::NSSize = cocoa::foundation::NSSize::new(size as f64, size as f64); let _: () = msg_send![icon, setSize: ns_size]; // Create an NSBitmapImageRep to rasterize into RGBA let rep: id = msg_send![class!(NSBitmapImageRep), alloc]; let planes: *mut u8 = std::ptr::null_mut(); let color_space_name = NSString::alloc(nil).init_str("NSCalibratedRGBColorSpace"); let rep: id = msg_send![rep, initWithBitmapDataPlanes: &planes pixelsWide: size as i64 pixelsHigh: size as i64 bitsPerSample: 8_i64 samplesPerPixel: 4_i64 hasAlpha: YES isPlanar: cocoa::base::NO colorSpaceName: color_space_name bytesPerRow: (size * 4) as i64 bitsPerPixel: 32_i64 ]; if rep.is_null() { return None; } // Draw the icon into the bitmap context let _: () = msg_send![class!(NSGraphicsContext), saveGraphicsState]; let gfx_ctx: id = msg_send![class!(NSGraphicsContext), graphicsContextWithBitmapImageRep: rep]; let _: () = msg_send![class!(NSGraphicsContext), setCurrentContext: gfx_ctx]; let draw_rect = cocoa::foundation::NSRect::new(cocoa::foundation::NSPoint::new(0.0, 0.0), ns_size); let from_rect = cocoa::foundation::NSRect::new( cocoa::foundation::NSPoint::new(0.0, 0.0), cocoa::foundation::NSSize::new(0.0, 0.0), // zero = entire image ); let _: () = msg_send![icon, drawInRect: draw_rect fromRect: from_rect operation: 2_i64 // NSCompositingOperationSourceOver fraction: 1.0_f64 ]; let _: () = msg_send![class!(NSGraphicsContext), restoreGraphicsState]; // Extract pixel data let bitmap_data: *const u8 = msg_send![rep, bitmapData]; if bitmap_data.is_null() { let _: () = msg_send![rep, release]; return None; } let len = (size * size * 4) as usize; let pixels = std::slice::from_raw_parts(bitmap_data, len).to_vec(); let _: () = msg_send![rep, release]; Some((pixels, size, size)) } } /// Return the user's preferred language code (e.g. "vi", "en", "ja"). pub fn get_preferred_language() -> String { unsafe { let langs: id = msg_send![class!(NSLocale), preferredLanguages]; let first: id = msg_send![langs, firstObject]; if first.is_null() { return "en".to_string(); } nsstring_to_string!(first).unwrap_or_else(|| "en".to_string()) } } pub fn get_active_app_name() -> String { unsafe { let shared_workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace]; let front_most_app: id = msg_send![shared_workspace, frontmostApplication]; let bundle_url: id = msg_send![front_most_app, bundleURL]; let path: id = msg_send![bundle_url, path]; nsstring_to_string!(path).unwrap_or("/Unknown.app".to_string()) } } pub fn update_launch_on_login(is_enable: bool) -> Result<(), auto_launch::Error> { match is_enable { true => AUTO_LAUNCH.enable(), false => AUTO_LAUNCH.disable(), } } pub fn is_launch_on_login() -> bool { AUTO_LAUNCH.is_enabled().unwrap() } pub fn is_dark_mode() -> bool { unsafe { use cocoa::base::nil; use cocoa::foundation::NSString; let app: id = msg_send![class!(NSApplication), sharedApplication]; let appearance: id = msg_send![app, effectiveAppearance]; let name: id = msg_send![appearance, name]; let dark_aqua = NSString::alloc(nil).init_str("NSAppearanceNameDarkAqua"); let is_dark: BOOL = msg_send![name, isEqual: dark_aqua]; is_dark == YES } } ================================================ FILE: src/platform/macos_ext.rs ================================================ use cocoa::appkit::{ NSApp, NSApplication, NSButton, NSMenu, NSMenuItem, NSStatusBar, NSStatusItem, }; use cocoa::base::{id, nil, BOOL, NO, YES}; use cocoa::foundation::{NSAutoreleasePool, NSString}; use core_foundation::dictionary::CFDictionaryRef; use core_foundation::string::CFStringRef; use core_graphics::{ event::{CGEventTapProxy, CGKeyCode}, sys, }; use druid::{Data, Lens}; use libc::c_void; use objc::{ class, declare::ClassDecl, msg_send, runtime::{Class, Object, Sel}, sel, sel_impl, Message, }; use objc_foundation::{INSObject, NSObject}; use objc_id::Id; use once_cell::sync::OnceCell; use std::mem; /// Global reference to the NSStatusItem pointer so we can update the tray /// title directly from any thread (via dispatch_async to the main queue), /// bypassing Druid's event loop which can lag ~1 s when the window is hidden. static SYSTRAY_ITEM: OnceCell = OnceCell::new(); #[derive(Clone, PartialEq, Eq)] struct Wrapper(*mut objc::runtime::Object); impl Data for Wrapper { fn same(&self, _other: &Self) -> bool { true } } pub enum SystemTrayMenuItemKey { ShowUI, Enable, TypingMethodTelex, TypingMethodVNI, TypingMethodTelexVNI, Exit, } #[derive(Clone, Data, Lens, PartialEq, Eq)] pub struct SystemTray { _pool: Wrapper, menu: Wrapper, item: Wrapper, } impl SystemTray { pub fn new() -> Self { unsafe { let pool = NSAutoreleasePool::new(nil); let menu = NSMenu::new(nil).autorelease(); let app = NSApp(); app.activateIgnoringOtherApps_(YES); let item = NSStatusBar::systemStatusBar(nil).statusItemWithLength_(-1.0); let button: id = msg_send![item, button]; let image = create_badge_image("VN", true); let _: () = msg_send![button, setImage: image]; item.setMenu_(menu); // Store the raw pointer globally so dispatch_set_systray_title // can update the title without going through Druid's event loop. let _ = SYSTRAY_ITEM.set(item as usize); let s = Self { _pool: Wrapper(pool), menu: Wrapper(menu), item: Wrapper(item), }; s.init_menu_items(); s } } pub fn set_title(&mut self, title: &str, is_vietnamese: bool) { unsafe { let button: id = msg_send![self.item.0, button]; let image = create_badge_image(title, is_vietnamese); let _: () = msg_send![button, setImage: image]; let empty = NSString::alloc(nil).init_str(""); let _: () = msg_send![button, setTitle: empty]; let _: () = msg_send![empty, release]; } } pub fn init_menu_items(&self) { use crate::ui::locale::t; self.add_menu_item(t("menu.open_panel"), || ()); self.add_menu_separator(); self.add_menu_item(t("menu.disable_vietnamese"), || ()); self.add_menu_separator(); self.add_menu_item("Telex ✓", || ()); self.add_menu_item("VNI", || ()); self.add_menu_item("Telex+VNI", || ()); self.add_menu_separator(); self.add_menu_item(t("menu.quit"), || ()); } pub fn add_menu_separator(&self) { unsafe { NSMenu::addItem_(self.menu.0, NSMenuItem::separatorItem(nil)); } } pub fn add_menu_item(&self, label: &str, cb: F) where F: Fn() + Send + 'static, { let cb_obj = Callback::from(Box::new(cb)); unsafe { let no_key = NSString::alloc(nil).init_str(""); let itemtitle = NSString::alloc(nil).init_str(label); let action = sel!(call); let item = NSMenuItem::alloc(nil) .initWithTitle_action_keyEquivalent_(itemtitle, action, no_key); let _: () = msg_send![item, setTarget: cb_obj]; NSMenu::addItem_(self.menu.0, item); } } pub fn get_menu_item_index_by_key(&self, key: SystemTrayMenuItemKey) -> i64 { match key { SystemTrayMenuItemKey::ShowUI => 0, SystemTrayMenuItemKey::Enable => 2, SystemTrayMenuItemKey::TypingMethodTelex => 4, SystemTrayMenuItemKey::TypingMethodVNI => 5, SystemTrayMenuItemKey::TypingMethodTelexVNI => 6, SystemTrayMenuItemKey::Exit => 8, } } pub fn set_menu_item_title(&self, key: SystemTrayMenuItemKey, label: &str) { unsafe { let item_title = NSString::alloc(nil).init_str(label); let index = self.get_menu_item_index_by_key(key); let menu_item: id = msg_send![self.menu.0, itemAtIndex: index]; let _: () = msg_send![menu_item, setTitle: item_title]; let _: () = msg_send![item_title, release]; } } pub fn set_menu_item_callback(&self, key: SystemTrayMenuItemKey, cb: F) where F: Fn() + Send + 'static, { let cb_obj = Callback::from(Box::new(cb)); unsafe { let index = self.get_menu_item_index_by_key(key); let _: () = msg_send![self.menu.0.itemAtIndex_(index), setTarget: cb_obj]; } } } /// Create an NSImage with an outlined rounded rectangle and text. /// Rendered as a template image so macOS tints it automatically like other menu bar icons. unsafe fn create_badge_image(title: &str, _is_vietnamese: bool) -> id { use cocoa::foundation::{NSPoint, NSRect, NSSize}; let black: id = msg_send![class!(NSColor), blackColor]; // Measure text to determine badge width let font: id = msg_send![class!(NSFont), systemFontOfSize: 9.5_f64 weight: 0.4_f64]; let title_ns = NSString::alloc(nil).init_str(title); let font_key = NSString::alloc(nil).init_str("NSFont"); let color_key = NSString::alloc(nil).init_str("NSColor"); let keys: [id; 2] = [font_key, color_key]; let vals: [id; 2] = [font, black]; let attrs: id = msg_send![class!(NSDictionary), dictionaryWithObjects:vals.as_ptr() forKeys:keys.as_ptr() count:2_u64]; let attr_str: id = msg_send![class!(NSAttributedString), alloc]; let attr_str: id = msg_send![attr_str, initWithString:title_ns attributes:attrs]; let text_size: NSSize = msg_send![attr_str, size]; let padding_h = 4.0_f64; let padding_v = 3.5_f64; let natural_w = (text_size.width + padding_h * 2.0).ceil(); let badge_w = natural_w.max(24.0); let badge_h = (text_size.height + padding_v * 2.0).ceil(); let corner_radius = 4.0_f64; let border_width = 1.2_f64; // Draw at 2x resolution for Retina crispness let scale = 2.0_f64; let px_w = badge_w * scale; let px_h = badge_h * scale; let color_space_name = NSString::alloc(nil).init_str("NSCalibratedRGBColorSpace"); let rep: id = msg_send![class!(NSBitmapImageRep), alloc]; let planes: *mut u8 = std::ptr::null_mut(); let rep: id = msg_send![rep, initWithBitmapDataPlanes: &planes pixelsWide: px_w as i64 pixelsHigh: px_h as i64 bitsPerSample: 8_i64 samplesPerPixel: 4_i64 hasAlpha: YES isPlanar: NO colorSpaceName: color_space_name bytesPerRow: 0_i64 bitsPerPixel: 0_i64 ]; // Draw into the bitmap rep at 2x let _: () = msg_send![class!(NSGraphicsContext), saveGraphicsState]; let gfx_ctx: id = msg_send![class!(NSGraphicsContext), graphicsContextWithBitmapImageRep: rep]; let _: () = msg_send![class!(NSGraphicsContext), setCurrentContext: gfx_ctx]; // Scale the graphics context so we draw in logical points let xform: id = msg_send![class!(NSAffineTransform), transform]; let _: () = msg_send![xform, scaleBy: scale]; let _: () = msg_send![xform, concat]; // Draw rounded rect border only (no fill) let inset = border_width / 2.0; let rect = NSRect::new( NSPoint::new(inset, inset), NSSize::new(badge_w - border_width, badge_h - border_width), ); let path: id = msg_send![class!(NSBezierPath), bezierPathWithRoundedRect:rect xRadius:corner_radius yRadius:corner_radius]; let _: () = msg_send![black, setStroke]; let _: () = msg_send![path, setLineWidth: border_width]; let _: () = msg_send![path, stroke]; // Draw centered text let text_x = (badge_w - text_size.width) / 2.0; let text_y = (badge_h - text_size.height) / 2.0; let _: () = msg_send![attr_str, drawAtPoint: NSPoint::new(text_x, text_y)]; let _: () = msg_send![class!(NSGraphicsContext), restoreGraphicsState]; // Create image from the bitmap rep with logical (1x) size let img_size = NSSize::new(badge_w, badge_h); let image: id = msg_send![class!(NSImage), alloc]; let image: id = msg_send![image, initWithSize: img_size]; let _: () = msg_send![image, addRepresentation: rep]; let _: () = msg_send![image, setTemplate: YES]; let _: () = msg_send![attr_str, release]; image } /// Update the system tray title immediately by dispatching to the main queue. /// This bypasses Druid's event loop, which can be slow when the window is hidden. /// Safe to call from any thread. pub fn dispatch_set_systray_title(title: &str, is_vietnamese: bool) { let Some(&item_ptr) = SYSTRAY_ITEM.get() else { return; }; let title_owned = title.to_owned(); struct Context { item: usize, title: String, is_vietnamese: bool, } unsafe extern "C" fn work(ctx: *mut c_void) { let ctx = Box::from_raw(ctx as *mut Context); let item = ctx.item as id; let button: id = msg_send![item, button]; let image = create_badge_image(&ctx.title, ctx.is_vietnamese); let _: () = msg_send![button, setImage: image]; let empty = NSString::alloc(nil).init_str(""); let _: () = msg_send![button, setTitle: empty]; let _: () = msg_send![empty, release]; } let ctx = Box::new(Context { item: item_ptr, title: title_owned, is_vietnamese: is_vietnamese, }); let ctx_ptr = Box::into_raw(ctx) as *mut c_void; unsafe { dispatch_async_f(&_dispatch_main_q, ctx_ptr, work); } } pub type Handle = CGEventTapProxy; #[link(name = "CoreGraphics", kind = "framework")] extern "C" { pub(crate) fn CGEventTapPostEvent(proxy: CGEventTapProxy, event: sys::CGEventRef); pub(crate) fn CGEventCreateKeyboardEvent( source: sys::CGEventSourceRef, keycode: CGKeyCode, keydown: bool, ) -> sys::CGEventRef; pub(crate) fn CGEventKeyboardSetUnicodeString( event: sys::CGEventRef, length: libc::c_ulong, string: *const u16, ); } pub mod new_tap { use std::{ mem::{self, ManuallyDrop}, ptr, }; use core_foundation::{ base::TCFType, mach_port::{CFMachPort, CFMachPortRef}, }; use core_graphics::{ event::{ CGEvent, CGEventMask, CGEventTapCallBackFn, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, CGEventTapProxy, CGEventType, }, sys, }; use foreign_types::ForeignType; use libc::c_void; type CGEventTapCallBackInternal = unsafe extern "C" fn( proxy: CGEventTapProxy, etype: CGEventType, event: sys::CGEventRef, user_info: *const c_void, ) -> sys::CGEventRef; #[link(name = "CoreGraphics", kind = "framework")] extern "C" { fn CGEventTapCreate( tap: CGEventTapLocation, place: CGEventTapPlacement, options: CGEventTapOptions, eventsOfInterest: CGEventMask, callback: CGEventTapCallBackInternal, userInfo: *const c_void, ) -> CFMachPortRef; fn CGEventTapEnable(tap: CFMachPortRef, enable: bool); } #[no_mangle] unsafe extern "C" fn cg_new_tap_callback_internal( _proxy: CGEventTapProxy, _etype: CGEventType, _event: sys::CGEventRef, _user_info: *const c_void, ) -> sys::CGEventRef { let callback = _user_info as *mut CGEventTapCallBackFn; let event = CGEvent::from_ptr(_event); let new_event = (*callback)(_proxy, _etype, &event); match new_event { Some(new_event) => ManuallyDrop::new(new_event).as_ptr(), None => { mem::forget(event); ptr::null_mut() as sys::CGEventRef } } } /* Generate an event mask for a single type of event. */ macro_rules! CGEventMaskBit { ($eventType:expr) => { 1 << $eventType as CGEventMask }; } type CallbackType<'tap_life> = Box Option + 'tap_life>; pub struct CGEventTap<'tap_life> { pub mach_port: CFMachPort, pub callback_ref: CallbackType<'tap_life>, } impl<'tap_life> CGEventTap<'tap_life> { pub fn new Option + 'tap_life>( tap: CGEventTapLocation, place: CGEventTapPlacement, options: CGEventTapOptions, events_of_interest: std::vec::Vec, callback: F, ) -> Result, ()> { let event_mask: CGEventMask = events_of_interest .iter() .fold(CGEventType::Null as CGEventMask, |mask, &etype| { mask | CGEventMaskBit!(etype) }); let cb = Box::new(Box::new(callback) as CGEventTapCallBackFn); let cbr = Box::into_raw(cb); unsafe { let event_tap_ref = CGEventTapCreate( tap, place, options, event_mask, cg_new_tap_callback_internal, cbr as *const c_void, ); if !event_tap_ref.is_null() { Ok(Self { mach_port: (CFMachPort::wrap_under_create_rule(event_tap_ref)), callback_ref: Box::from_raw(cbr), }) } else { _ = Box::from_raw(cbr); Err(()) } } } pub fn enable(&self) { unsafe { CGEventTapEnable(self.mach_port.as_concrete_TypeRef(), true) } } } } pub(crate) enum Callback {} unsafe impl Message for Callback {} pub(crate) struct CallbackState { cb: Box, } impl Callback { pub(crate) fn from(cb: Box) -> Id { let cbs = CallbackState { cb }; let bcbs = Box::new(cbs); let ptr = Box::into_raw(bcbs); let ptr = ptr as *mut c_void as usize; let mut oid = ::new(); (*oid).setptr(ptr); oid } pub(crate) fn setptr(&mut self, uptr: usize) { unsafe { let obj = &mut *(self as *mut _ as *mut ::objc::runtime::Object); obj.set_ivar("_cbptr", uptr); } } } impl INSObject for Callback { fn class() -> &'static Class { let cname = "Callback"; let mut klass = Class::get(cname); if klass.is_none() { let superclass = NSObject::class(); let mut decl = ClassDecl::new(cname, superclass).unwrap(); decl.add_ivar::("_cbptr"); extern "C" fn sysbar_callback_call(this: &Object, _cmd: Sel) { unsafe { let pval: usize = *this.get_ivar("_cbptr"); let ptr = pval as *mut c_void; let ptr = ptr as *mut CallbackState; let bcbs: Box = Box::from_raw(ptr); { (*bcbs.cb)(); } mem::forget(bcbs); } } unsafe { decl.add_method( sel!(call), sysbar_callback_call as extern "C" fn(&Object, Sel), ); } decl.register(); klass = Class::get(cname); } klass.unwrap() } } #[link(name = "ApplicationServices", kind = "framework")] extern "C" { pub fn AXIsProcessTrustedWithOptions(options: CFDictionaryRef) -> bool; pub static kAXTrustedCheckOptionPrompt: CFStringRef; } #[link(name = "AppKit", kind = "framework")] extern "C" { pub static NSWorkspaceDidActivateApplicationNotification: CFStringRef; } // dispatch_get_main_queue() is a C macro expanding to (&_dispatch_main_q) extern "C" { static _dispatch_main_q: c_void; fn dispatch_async_f( queue: *const c_void, context: *mut c_void, work: unsafe extern "C" fn(*mut c_void), ); } /// Open an app file picker deferred to the next run loop iteration. /// This avoids re-entering druid's RefCell borrow during event handling. pub fn defer_open_app_file_picker(callback: Box) + Send>) { unsafe extern "C" fn work(ctx: *mut c_void) { let callback = Box::from_raw(ctx as *mut Box) + Send>); let name = open_app_file_picker(); callback(name); } let boxed: Box) + Send>> = Box::new(callback); let ctx_ptr = Box::into_raw(boxed) as *mut c_void; unsafe { dispatch_async_f(&_dispatch_main_q, ctx_ptr, work); } } pub fn open_app_file_picker() -> Option { unsafe { let panel: id = msg_send![class!(NSOpenPanel), openPanel]; let _: () = msg_send![panel, setCanChooseFiles: YES]; let _: () = msg_send![panel, setCanChooseDirectories: NO]; let _: () = msg_send![panel, setAllowsMultipleSelection: NO as BOOL]; // Allow only .app bundles let app_ext = NSString::alloc(nil).init_str("app"); let types_array: id = msg_send![class!(NSArray), arrayWithObject: app_ext]; let _: () = msg_send![panel, setAllowedFileTypes: types_array]; // Start in /Applications let apps_path = NSString::alloc(nil).init_str("/Applications"); let dir_url: id = msg_send![class!(NSURL), fileURLWithPath: apps_path]; let _: () = msg_send![panel, setDirectoryURL: dir_url]; let response: i64 = msg_send![panel, runModal]; if response == 1 { // NSModalResponseOK = 1 let url: id = msg_send![panel, URL]; let path: id = msg_send![url, path]; let utf8: *const std::ffi::c_char = msg_send![path, UTF8String]; if !utf8.is_null() { return Some( std::ffi::CStr::from_ptr(utf8) .to_string_lossy() .into_owned(), ); } } None } } pub fn open_text_file_picker() -> Option { unsafe { let panel: id = msg_send![class!(NSOpenPanel), openPanel]; let _: () = msg_send![panel, setCanChooseFiles: YES]; let _: () = msg_send![panel, setCanChooseDirectories: NO]; let _: () = msg_send![panel, setAllowsMultipleSelection: NO as BOOL]; let response: i64 = msg_send![panel, runModal]; if response == 1 { let url: id = msg_send![panel, URL]; let path: id = msg_send![url, path]; let utf8: *const std::ffi::c_char = msg_send![path, UTF8String]; if !utf8.is_null() { return Some( std::ffi::CStr::from_ptr(utf8) .to_string_lossy() .into_owned(), ); } } None } } pub fn save_text_file_picker() -> Option { unsafe { let panel: id = msg_send![class!(NSSavePanel), savePanel]; let suggested_name = NSString::alloc(nil).init_str("expansions.txt"); let _: () = msg_send![panel, setNameFieldStringValue: suggested_name]; let response: i64 = msg_send![panel, runModal]; if response == 1 { let url: id = msg_send![panel, URL]; let path: id = msg_send![url, path]; let utf8: *const std::ffi::c_char = msg_send![path, UTF8String]; if !utf8.is_null() { return Some( std::ffi::CStr::from_ptr(utf8) .to_string_lossy() .into_owned(), ); } } None } } pub fn defer_open_text_file_picker(callback: Box) + Send>) { unsafe extern "C" fn work(ctx: *mut c_void) { let callback = Box::from_raw(ctx as *mut Box) + Send>); let path = open_text_file_picker(); callback(path); } let boxed: Box) + Send>> = Box::new(callback); let ctx_ptr = Box::into_raw(boxed) as *mut c_void; unsafe { dispatch_async_f(&_dispatch_main_q, ctx_ptr, work); } } pub fn defer_save_text_file_picker(callback: Box) + Send>) { unsafe extern "C" fn work(ctx: *mut c_void) { let callback = Box::from_raw(ctx as *mut Box) + Send>); let path = save_text_file_picker(); callback(path); } let boxed: Box) + Send>> = Box::new(callback); let ctx_ptr = Box::into_raw(boxed) as *mut c_void; unsafe { dispatch_async_f(&_dispatch_main_q, ctx_ptr, work); } } pub fn add_app_change_callback(cb: F) where F: Fn() + Send + 'static, { unsafe { let shared_workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace]; let notification_center: id = msg_send![shared_workspace, notificationCenter]; let cb_obj = Callback::from(Box::new(cb)); let _: id = msg_send![notification_center, addObserver:cb_obj selector:sel!(call) name:NSWorkspaceDidActivateApplicationNotification object:nil ]; } } pub fn add_appearance_change_callback(cb: F) where F: Fn() + Send + 'static, { unsafe { use cocoa::base::nil; use cocoa::foundation::NSString; let notification_center: id = msg_send![class!(NSDistributedNotificationCenter), defaultCenter]; let cb_obj = Callback::from(Box::new(cb)); let name = NSString::alloc(nil).init_str("AppleInterfaceThemeChangedNotification"); let _: id = msg_send![notification_center, addObserver:cb_obj selector:sel!(call) name:name object:nil ]; } } ================================================ FILE: src/platform/mod.rs ================================================ #[cfg_attr(target_os = "macos", path = "macos.rs")] #[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "windows", path = "window.rs")] mod os; use std::fmt::Display; use bitflags::bitflags; pub use os::{ add_app_change_callback, add_appearance_change_callback, defer_open_app_file_picker, defer_open_text_file_picker, defer_save_text_file_picker, dispatch_set_systray_title, ensure_accessibility_permission, get_active_app_name, get_app_icon_rgba, get_home_dir, get_preferred_language, is_dark_mode, is_in_text_selection, is_launch_on_login, run_event_listener, send_arrow_left, send_arrow_right, send_backspace, send_string, update_launch_on_login, Handle, SYMBOL_ALT, SYMBOL_CTRL, SYMBOL_SHIFT, SYMBOL_SUPER, }; #[cfg(target_os = "macos")] pub use os::SystemTray; pub use os::SystemTrayMenuItemKey; pub const RAW_KEY_GLOBE: u16 = 0xb3; pub const RAW_ARROW_DOWN: u16 = 0x7d; pub const RAW_ARROW_UP: u16 = 0x7e; pub const RAW_ARROW_LEFT: u16 = 0x7b; pub const RAW_ARROW_RIGHT: u16 = 0x7c; pub const KEY_ENTER: char = '\x13'; pub const KEY_SPACE: char = '\u{0020}'; pub const KEY_TAB: char = '\x09'; pub const KEY_DELETE: char = '\x08'; pub const KEY_ESCAPE: char = '\x26'; bitflags! { pub struct KeyModifier: u32 { const MODIFIER_NONE = 0b00000000; const MODIFIER_SHIFT = 0b00000001; const MODIFIER_SUPER = 0b00000010; const MODIFIER_CONTROL = 0b00000100; const MODIFIER_ALT = 0b00001000; const MODIFIER_CAPSLOCK = 0b00010000; } } impl Display for KeyModifier { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.is_super() { write!(f, "super+")?; } if self.is_control() { write!(f, "ctrl+")?; } if self.is_alt() { write!(f, "alt+")?; } if self.is_shift() { write!(f, "shift+")?; } if self.is_capslock() { write!(f, "capslock+")?; } write!(f, "") } } impl KeyModifier { pub fn new() -> Self { Self { bits: 0 } } pub fn apply( &mut self, is_super: bool, is_ctrl: bool, is_alt: bool, is_shift: bool, is_capslock: bool, ) { self.set(Self::MODIFIER_SUPER, is_super); self.set(Self::MODIFIER_CONTROL, is_ctrl); self.set(Self::MODIFIER_ALT, is_alt); self.set(Self::MODIFIER_SHIFT, is_shift); self.set(Self::MODIFIER_CAPSLOCK, is_capslock); } pub fn add_shift(&mut self) { self.set(Self::MODIFIER_SHIFT, true); } pub fn add_super(&mut self) { self.set(Self::MODIFIER_SUPER, true); } pub fn add_control(&mut self) { self.set(Self::MODIFIER_CONTROL, true); } pub fn add_alt(&mut self) { self.set(Self::MODIFIER_ALT, true); } pub fn add_capslock(&mut self) { self.set(Self::MODIFIER_CAPSLOCK, true); } pub fn is_shift(&self) -> bool { self.contains(Self::MODIFIER_SHIFT) } pub fn is_super(&self) -> bool { self.contains(Self::MODIFIER_SUPER) } pub fn is_control(&self) -> bool { self.contains(Self::MODIFIER_CONTROL) } pub fn is_alt(&self) -> bool { self.contains(Self::MODIFIER_ALT) } pub fn is_capslock(&self) -> bool { self.contains(Self::MODIFIER_CAPSLOCK) } } #[derive(Debug, Copy, Clone)] pub enum PressedKey { Char(char), Raw(u16), } #[derive(Debug, PartialEq, Eq)] pub enum EventTapType { KeyDown, FlagsChanged, Other, } pub type CallbackFn = dyn Fn(os::Handle, EventTapType, Option, KeyModifier) -> bool; ================================================ FILE: src/platform/windows.rs ================================================ // TODO: Implement this use druid::{Selector, commands::CLOSE_WINDOW}; use super::CallbackFn; pub const SYMBOL_SHIFT: &str = "⇧"; pub const SYMBOL_CTRL: &str = "⌃"; pub const SYMBOL_SUPER: &str = "⊞"; pub const SYMBOL_ALT: &str = "⌥"; pub fn get_home_dir() -> Option { env::var("USERPROFILE").ok().map(PathBuf::from) .or_else(|| env::var("HOMEDRIVE").ok().and_then(|home_drive| { env::var("HOMEPATH").ok().map(|home_path| { PathBuf::from(format!("{}{}", home_drive, home_path)) }) })) } pub fn send_backspace(count: usize) -> Result<(), ()> { todo!() } pub fn send_string(string: &str) -> Result<(), ()> { todo!() } pub fn run_event_listener(callback: &CallbackFn) { todo!() } pub fn ensure_accessibility_permission() -> bool { true } pub fn is_in_text_selection() -> bool { todo!() } pub fn update_launch_on_login(is_enable: bool) { todo!() } pub fn is_launch_on_login() { todo!() } ================================================ FILE: src/scripting/mod.rs ================================================ /// This module, `parser`, is built for the goxscript language. /// It parses the goxscript language and returns an AST which can be used to /// generate the corresponding vi-rs rule map. /// /// # Example /// The script would look like this: /// /// ``` /// import telex /// import vni /// /// on s or ': add_tone(acute) end /// /// on a or e or o or 6: /// letter_mod(circumflex for a or e or o) /// end /// /// on w or 7 or 8: /// reset_inserted_uw() or /// letter_mod(horn or breve for u or o) or /// insert_uw() /// end /// ``` /// /// # Syntax /// The following EBNF describes the syntax of the goxscript language: /// /// ```ebnf /// ::= ? ? /// /// ::= ( )? /// ::= "import" /// /// ::= ( )? /// ::= "on" ":" "end" /// /// ::= ( "or" )? /// ::= "(" ( ( "for" )? )? ")" /// /// ::= ( "or" )? /// ::= ( | | | "_")+ /// /// ::= ( "or" )? /// ::= /// /// ::= (" " | "\n")* /// ::= | | | /// ::= "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J" | "K" | "L" | "M" | "N" | "O" | /// "P" | "Q" | "R" | "S" | "T" | "U" | "V" | "W" | "X" | "Y" | "Z" /// ::= "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | /// "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" /// ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" /// ::= "!" | "\"" | "#" | "$" | "%" | "&" | "'" | "(" | ")" | "*" | "+" | "," | "-" | "." | "/" | /// ":" | ";" | "<" | "=" | ">" | "?" | "@" | "[" | "\\" | "]" | "^" | "_" | "`" | "{" | "}" | "~" /// ``` pub mod parser; ================================================ FILE: src/scripting/parser.rs ================================================ use nom::{ bytes::complete::{tag, take_while1, take_while_m_n}, character::complete::{multispace0, multispace1}, combinator::{map, opt}, multi::separated_list1, sequence::{delimited, preceded, tuple}, IResult, }; /// Represents a program containing a list of imports and blocks. /// /// # Example /// /// ``` /// let program = Program { /// import_list: Some(vec![Import { identifier: "telex".to_string() }]), /// block_list: Some(vec![Block { /// key_list: vec!["a".to_string()], /// function_call_list: vec![FunctionCall { /// identifier: "hello".to_string(), /// identifier_list: None, /// key_list: None, /// }], /// }]), /// }; /// println!("{:?}", program); /// ``` #[derive(Debug, PartialEq)] pub struct Program { import_list: Option>, block_list: Option>, } /// Represents an import statement with an identifier. /// /// # Example /// /// ``` /// let import = Import { /// identifier: "telex".to_string(), /// }; /// println!("{:?}", import); /// ``` #[derive(Debug, PartialEq)] pub struct Import { identifier: String, } /// Represents a block containing a list of keys and function calls. /// /// # Example /// /// ``` /// let block = Block { /// key_list: vec!["a".to_string()], /// function_call_list: vec![FunctionCall { /// identifier: "hello".to_string(), /// identifier_list: None, /// key_list: None, /// }], /// }; /// println!("{:?}", block); /// ``` #[derive(Debug, PartialEq)] pub struct Block { key_list: Vec, function_call_list: Vec, } /// Represents a function call with an identifier, and optional lists of identifiers and keys. /// /// # Example /// /// ``` /// let function_call = FunctionCall { /// identifier: "hello".to_string(), /// identifier_list: Some(vec!["world".to_string()]), /// key_list: Some(vec!["a".to_string()]), /// }; /// println!("{:?}", function_call); /// ``` #[derive(Debug, PartialEq)] pub struct FunctionCall { identifier: String, identifier_list: Option>, key_list: Option>, } /// Checks if a character is a valid key character (not whitespace). /// /// # Example /// /// ``` /// let result = is_key_char('a'); /// assert!(result); /// let result = is_key_char(' '); /// assert!(!result); /// ``` fn is_key_char(c: char) -> bool { !c.is_whitespace() } /// Parses a key from the input string. /// /// # Example /// /// ``` /// let result = parse_key("a"); /// assert!(result.is_ok()); /// assert_eq!(result.unwrap().1, "a".to_string()); /// ``` fn parse_key(input: &str) -> IResult<&str, String> { map(take_while_m_n(1, 1, is_key_char), |s: &str| s.to_string())(input) } /// Parses a list of keys from the input string. /// /// # Example /// /// ``` /// let result = parse_key_list("a or b or c"); /// assert!(result.is_ok()); /// assert_eq!(result.unwrap().1, vec!["a".to_string(), "b".to_string(), "c".to_string()]); /// ``` fn parse_key_list(input: &str) -> IResult<&str, Vec> { separated_list1(delimited(multispace1, tag("or"), multispace1), parse_key)(input) } /// Checks if a character is a valid identifier character (alphanumeric or underscore). /// /// # Example /// /// ``` /// let result = is_identifier_char('a'); /// assert!(result); /// let result = is_identifier_char('1'); /// assert!(result); /// let result = is_identifier_char('_'); /// assert!(result); /// let result = is_identifier_char(' '); /// assert!(!result); /// ``` fn is_identifier_char(c: char) -> bool { c.is_alphanumeric() || c == '_' } /// Parses an identifier from the input string. /// /// # Example /// /// ``` /// let result = parse_identifier("abc123"); /// assert!(result.is_ok()); /// assert_eq!(result.unwrap().1, "abc123".to_string()); /// ``` fn parse_identifier(input: &str) -> IResult<&str, String> { map(take_while1(is_identifier_char), |s: &str| s.to_string())(input) } /// Parses a list of identifiers from the input string. /// /// # Example /// /// ``` /// let result = parse_identifier_list("abc or def or ghi"); /// assert!(result.is_ok()); /// assert_eq!(result.unwrap().1, vec!["abc".to_string(), "def".to_string(), "ghi".to_string()]); /// ``` fn parse_identifier_list(input: &str) -> IResult<&str, Vec> { separated_list1( delimited(multispace1, tag("or"), multispace1), parse_identifier, )(input) } /// Parses an import statement from the input string. /// /// # Example /// /// ``` /// let result = parse_import("import abc"); /// assert!(result.is_ok()); /// assert_eq!(result.unwrap().1, Import { identifier: "abc".to_string() }); /// ``` fn parse_import(input: &str) -> IResult<&str, Import> { let (input, _) = preceded(tag("import"), multispace1)(input)?; let (input, identifier) = parse_identifier(input)?; Ok(( input, Import { identifier: identifier.to_string(), }, )) } /// Parses a list of import statements from the input string. /// /// # Example /// /// ``` /// let result = parse_import_list("import abc import def"); /// assert!(result.is_ok()); /// assert_eq!(result.unwrap().1, vec![ /// Import { identifier: "abc".to_string() }, /// Import { identifier: "def".to_string() } /// ]); /// ``` fn parse_import_list(input: &str) -> IResult<&str, Vec> { separated_list1(multispace1, parse_import)(input) } /// Parses a function call from the input string. /// /// # Example /// /// ``` /// let result = parse_function_call("hello(world)"); /// assert!(result.is_ok()); /// assert_eq!(result.unwrap().1, FunctionCall { /// identifier: "hello".to_string(), /// identifier_list: Some(vec!["world".to_string()]), /// key_list: None, /// }); /// ``` fn parse_function_call(input: &str) -> IResult<&str, FunctionCall> { let parse_identifier_list = opt(parse_identifier_list); let parse_key_list = map( opt(tuple(( multispace1, tag("for"), multispace1, parse_key_list, ))), |x| x.map(|(_, _, _, key_list)| key_list), ); let (input, (identifier, _, _, identifier_list, key_list, _, _)) = tuple(( parse_identifier, tag("("), multispace0, parse_identifier_list, parse_key_list, multispace0, tag(")"), ))(input)?; Ok(( input, FunctionCall { identifier: identifier.to_string(), identifier_list, key_list, }, )) } /// Parses a list of function calls from the input string. /// /// # Example /// /// ``` /// let result = parse_function_call_list("hello() or world(abc)"); /// assert!(result.is_ok()); /// assert_eq!(result.unwrap().1, vec![ /// FunctionCall { /// identifier: "hello".to_string(), /// identifier_list: None, /// key_list: None, /// }, /// FunctionCall { /// identifier: "world".to_string(), /// identifier_list: Some(vec!["abc".to_string()]), /// key_list: None, /// } /// ]); /// ``` fn parse_function_call_list(input: &str) -> IResult<&str, Vec> { separated_list1( delimited(multispace1, tag("or"), multispace1), parse_function_call, )(input) } /// Parses a block from the input string. /// /// # Example /// /// ``` /// let result = parse_block("on a: hello() end"); /// assert!(result.is_ok()); /// assert_eq!(result.unwrap().1, Block { /// key_list: vec!["a".to_string()], /// function_call_list: vec![FunctionCall { /// identifier: "hello".to_string(), /// identifier_list: None, /// key_list: None, /// }], /// }); /// ``` fn parse_block(input: &str) -> IResult<&str, Block> { let (input, (_, _, key_list, _, _, _, function_call_list, _, _)) = tuple(( tag("on"), multispace1, parse_key_list, multispace0, tag(":"), multispace1, parse_function_call_list, multispace1, tag("end"), ))(input)?; Ok(( input, Block { key_list, function_call_list, }, )) } /// Parses a program from the input string. /// /// # Example /// /// ``` /// let result = parse_program("import telex\non a: hello() end"); /// assert!(result.is_ok()); /// assert_eq!(result.unwrap().1, Program { /// import_list: Some(vec![Import { identifier: "telex".to_string() }]), /// block_list: Some(vec![Block { /// key_list: vec!["a".to_string()], /// function_call_list: vec![FunctionCall { /// identifier: "hello".to_string(), /// identifier_list: None, /// key_list: None, /// }], /// }]), /// }); /// ``` pub fn parse_program(input: &str) -> IResult<&str, Program> { let parse_import_list = opt(parse_import_list); let parse_block_list = opt(separated_list1(multispace1, parse_block)); let (input, (_, import_list, _, block_list, _)) = tuple(( multispace0, parse_import_list, multispace0, parse_block_list, multispace0, ))(input)?; Ok(( input, Program { import_list, block_list, }, )) } #[test] fn test_parse_key() { let input = "a"; let result = parse_key(input); assert!(result.is_ok()); assert!(result.unwrap().1 == "a"); } #[test] fn test_parse_key_should_parse_a_single_key() { let input = "abc"; let result = parse_key(input); assert!(result.is_ok()); assert!(result.unwrap().1 == "a"); } #[test] fn test_parse_key_list() { let input = "a or b or c"; let result = parse_key_list(input); assert!(result.is_ok()); println!("{result:?}"); assert!(result.unwrap().1 == vec!["a", "b", "c"]); } #[test] fn test_parse_identifier() { let input = "abc12_abc"; let result = parse_identifier(input); assert!(result.is_ok()); assert!(result.unwrap().1 == "abc12_abc"); } #[test] fn test_parse_identifier_list() { let input = "a or abc12 or ab_cd12"; let result = parse_identifier_list(input); assert!(result.is_ok()); assert!(result.unwrap().1 == vec!["a", "abc12", "ab_cd12"]); } #[test] fn test_parse_identifier_list_single_item() { let input = "abc"; let result = parse_identifier_list(input); assert!(result.is_ok()); assert!(result.unwrap().1 == vec!["abc"]); } #[test] fn test_parse_key_list_single() { let input = "a"; let result = parse_key_list(input); assert!(result.is_ok()); assert!(result.unwrap().1 == vec!["a"]); } #[test] fn parse_import_fail() { let input = "import;"; let result = parse_import(input); assert!(result.is_err()); } #[test] fn parse_import_fail_not_a_function() { let input = "import ()"; let result = parse_import(input); assert!(result.is_err()); } #[test] fn parse_import_fail_no_module() { let input = "import"; let result = parse_import(input); assert!(result.is_err()); } #[test] fn parse_import_fail_no_module_just_space() { let input = "import "; let result = parse_import(input); assert!(result.is_err()); } #[test] fn parse_import_success() { let input = "import abc"; let result = parse_import(input); assert!(result.is_ok()); assert!( result.unwrap().1 == Import { identifier: "abc".to_string() } ); } #[test] fn parse_import_list_success_single() { let input = "import abc\n"; let result = parse_import_list(input); assert!(result.is_ok()); assert!( result.unwrap().1 == vec![Import { identifier: "abc".to_string() }] ); } #[test] fn parse_import_list_success() { let input = "import abc import def"; let result = parse_import_list(input); assert!(result.is_ok()); assert!( result.unwrap().1 == vec![ Import { identifier: "abc".to_string() }, Import { identifier: "def".to_string() } ] ); } #[test] fn parse_function_call_fail() { let input = "abc"; let result = parse_function_call(input); assert!(result.is_err()); } #[test] fn parse_function_call_space_before_parens_fail() { let input = "abc ()"; let result = parse_function_call(input); assert!(result.is_err()); } #[test] fn parse_function_call_success_with_no_params() { let input = "abc() "; let result = parse_function_call(input); assert!(result.is_ok()); assert!( result.unwrap().1 == FunctionCall { identifier: "abc".to_string(), identifier_list: None, key_list: None } ); } #[test] fn parse_function_call_success_with_no_params_with_space() { let input = "abc( )"; let result = parse_function_call(input); assert!(result.is_ok()); assert!( result.unwrap().1 == FunctionCall { identifier: "abc".to_string(), identifier_list: None, key_list: None } ); } #[test] fn parse_function_call_success_with_single_param() { let input = "abc( hello )"; let result = parse_function_call(input); assert!(result.is_ok()); assert!( result.unwrap().1 == FunctionCall { identifier: "abc".to_string(), identifier_list: Some(vec!["hello".to_string()]), key_list: None } ); } #[test] fn parse_function_call_success_with_multiple_param() { let input = "say_this( hello or word )"; let result = parse_function_call(input); assert!(result.is_ok()); assert!( result.unwrap().1 == FunctionCall { identifier: "say_this".to_string(), identifier_list: Some(vec!["hello".to_string(), "word".to_string()]), key_list: None } ); } #[test] fn parse_function_call_success_with_single_param_with_single_key() { let input = "say_this( hello for a )"; let result = parse_function_call(input); assert!(result.is_ok()); assert!( result.unwrap().1 == FunctionCall { identifier: "say_this".to_string(), identifier_list: Some(vec!["hello".to_string()]), key_list: Some(vec!["a".to_string()]) } ); } #[test] fn parse_function_call_success_with_single_param_with_multiple_key() { let input = "say_this( hello for a or b or ' )"; let result = parse_function_call(input); assert!(result.is_ok()); assert!( result.unwrap().1 == FunctionCall { identifier: "say_this".to_string(), identifier_list: Some(vec!["hello".to_string()]), key_list: Some(vec!["a".to_string(), "b".to_string(), "'".to_string()]) } ); } #[test] fn parse_function_call_success_with_multiple_param_with_single_key() { let input = "say_this_123( hello or world or zoo for a )"; let result = parse_function_call(input); assert!(result.is_ok()); assert!( result.unwrap().1 == FunctionCall { identifier: "say_this_123".to_string(), identifier_list: Some(vec![ "hello".to_string(), "world".to_string(), "zoo".to_string() ]), key_list: Some(vec!["a".to_string()]) } ); } #[test] fn parse_function_call_success_with_multiple_param_with_multiple_key() { let input = "say_this_123( hello or world or zoo for a or b or ' )"; let result = parse_function_call(input); assert!(result.is_ok()); assert!( result.unwrap().1 == FunctionCall { identifier: "say_this_123".to_string(), identifier_list: Some(vec![ "hello".to_string(), "world".to_string(), "zoo".to_string() ]), key_list: Some(vec!["a".to_string(), "b".to_string(), "'".to_string()]) } ); } #[test] fn parse_function_call_fail_with_multiple_param_with_no_key() { let input = "say_this_123( hello or world or zoo for )"; let result = parse_function_call(input); assert!(result.is_err()); } #[test] fn parse_function_call_fail_for_unclosed_call() { let input = "say_this_123( hello or world or zoo "; let result = parse_function_call(input); assert!(result.is_err()); } #[test] fn parse_function_call_list_fail() { let input = "abc"; let result = parse_function_call_list(input); assert!(result.is_err()); } #[test] fn parse_function_call_list_success_with_single_call() { let input = "abc()"; let result = parse_function_call_list(input); assert!(result.is_ok()); assert!( result.unwrap().1 == vec![FunctionCall { identifier: "abc".to_string(), identifier_list: None, key_list: None }] ); } #[test] fn parse_function_call_list_success_with_multiple_call() { let input = "abc() or foo_bar(hello) or say_this( hello or world or zoo for a or b or ' )"; let result = parse_function_call_list(input); assert!(result.is_ok()); assert!( result.unwrap().1 == vec![ FunctionCall { identifier: "abc".to_string(), identifier_list: None, key_list: None }, FunctionCall { identifier: "foo_bar".to_string(), identifier_list: Some(vec!["hello".to_string()]), key_list: None }, FunctionCall { identifier: "say_this".to_string(), identifier_list: Some(vec![ "hello".to_string(), "world".to_string(), "zoo".to_string() ]), key_list: Some(vec!["a".to_string(), "b".to_string(), "'".to_string()]) } ] ); } #[test] fn parse_block_fail() { let input = "on abc: "; let result = parse_block(input); assert!(result.is_err()); } #[test] fn parse_block_fail_no_key() { let input = "on : end"; let result = parse_block(input); assert!(result.is_err()); } #[test] fn parse_block_fail_empty_block() { let input = "on a: end"; let result = parse_block(input); assert!(result.is_err()); } #[test] fn parse_block_success_single_key() { let input = "on a: hello() end"; let result = parse_block(input); assert!(result.is_ok()); assert!( result.unwrap().1 == Block { key_list: Vec::from(["a".to_string()]), function_call_list: vec![FunctionCall { identifier: "hello".to_string(), identifier_list: None, key_list: None }] } ); } #[test] fn parse_block_success_multiple_key() { let input = "on a or ' or #: hello() end"; let result = parse_block(input); assert!(result.is_ok()); assert!( result.unwrap().1 == Block { key_list: Vec::from(["a".to_string(), "'".to_string(), "#".to_string()]), function_call_list: vec![FunctionCall { identifier: "hello".to_string(), identifier_list: None, key_list: None }] } ); } #[test] fn parse_block_success_multiple_key_multiple_calls() { let input = "on a or ' or #: hello() or foo(abc) or foo_bar(abc or bee) or foo_foo(abc or bee for a or # or c) end"; let result = parse_block(input); assert!(result.is_ok()); assert!( result.unwrap().1 == Block { key_list: Vec::from(["a".to_string(), "'".to_string(), "#".to_string()]), function_call_list: vec![ FunctionCall { identifier: "hello".to_string(), identifier_list: None, key_list: None }, FunctionCall { identifier: "foo".to_string(), identifier_list: Some(vec!["abc".to_string()]), key_list: None }, FunctionCall { identifier: "foo_bar".to_string(), identifier_list: Some(vec!["abc".to_string(), "bee".to_string()]), key_list: None }, FunctionCall { identifier: "foo_foo".to_string(), identifier_list: Some(vec!["abc".to_string(), "bee".to_string()]), key_list: Some(vec!["a".to_string(), "#".to_string(), "c".to_string()]) } ] } ); } #[test] fn parse_program_single_block() { let input = "on a: hello() end"; let result = parse_program(input); assert!(result.is_ok()); assert!( result.unwrap().1 == Program { import_list: None, block_list: Some(vec![Block { key_list: Vec::from(["a".to_string()]), function_call_list: vec![FunctionCall { identifier: "hello".to_string(), identifier_list: None, key_list: None }] }]) } ); } #[test] fn parse_program_single_block_with_import() { let input = "import telex\non a: hello() end"; let result = parse_program(input); assert!(result.is_ok()); assert!( result.unwrap().1 == Program { import_list: Some(vec![Import { identifier: "telex".to_string() }]), block_list: Some(vec![Block { key_list: Vec::from(["a".to_string()]), function_call_list: vec![FunctionCall { identifier: "hello".to_string(), identifier_list: None, key_list: None }] }]) } ); } #[test] fn parse_program_multiple_block() { let input = "on a: hello() end \n on b or c: foo() end\n\n\n\non d or e or f: bar() end"; let result = parse_program(input); assert!(result.is_ok()); assert!( result.unwrap().1 == Program { import_list: None, block_list: Some(vec![ Block { key_list: Vec::from(["a".to_string()]), function_call_list: vec![FunctionCall { identifier: "hello".to_string(), identifier_list: None, key_list: None }] }, Block { key_list: Vec::from(["b".to_string(), "c".to_string()]), function_call_list: vec![FunctionCall { identifier: "foo".to_string(), identifier_list: None, key_list: None }] }, Block { key_list: Vec::from(["d".to_string(), "e".to_string(), "f".to_string()]), function_call_list: vec![FunctionCall { identifier: "bar".to_string(), identifier_list: None, key_list: None }] } ]) } ); } #[test] fn parse_program_multiple_block_with_multiple_import() { let input = "import telex\n\n\nimport vni on a: hello() end \n on b or c: foo() end\n\n\n\non d or e or f: bar() end"; let result = parse_program(input); assert!(result.is_ok()); assert!( result.unwrap().1 == Program { import_list: Some(vec![ Import { identifier: "telex".to_string() }, Import { identifier: "vni".to_string() } ]), block_list: Some(vec![ Block { key_list: Vec::from(["a".to_string()]), function_call_list: vec![FunctionCall { identifier: "hello".to_string(), identifier_list: None, key_list: None }] }, Block { key_list: Vec::from(["b".to_string(), "c".to_string()]), function_call_list: vec![FunctionCall { identifier: "foo".to_string(), identifier_list: None, key_list: None }] }, Block { key_list: Vec::from(["d".to_string(), "e".to_string(), "f".to_string()]), function_call_list: vec![FunctionCall { identifier: "bar".to_string(), identifier_list: None, key_list: None }] } ]) } ); } #[test] fn parse_full_program_success() { let input = r#" import telex import vni on s or ': add_tone(acute) end on a or e or o or 6: letter_mod(circumflex for a or e or o) end on w or 7 or 8: reset_inserted_uw() or letter_mod(horn or breve for u or o) or insert_uw() end "#; let result = parse_program(input); println!("{result:?}"); assert!(result.is_ok()); assert!( result.unwrap().1 == Program { import_list: Some(vec![ Import { identifier: "telex".to_string() }, Import { identifier: "vni".to_string() } ]), block_list: Some(vec![ Block { key_list: Vec::from(["s".to_string(), "'".to_string()]), function_call_list: vec![FunctionCall { identifier: "add_tone".to_string(), identifier_list: Some(vec!["acute".to_string()]), key_list: None }] }, Block { key_list: Vec::from([ "a".to_string(), "e".to_string(), "o".to_string(), "6".to_string() ]), function_call_list: vec![FunctionCall { identifier: "letter_mod".to_string(), identifier_list: Some(vec!["circumflex".to_string()]), key_list: Some(vec!["a".to_string(), "e".to_string(), "o".to_string()]) }] }, Block { key_list: Vec::from(["w".to_string(), "7".to_string(), "8".to_string()]), function_call_list: vec![ FunctionCall { identifier: "reset_inserted_uw".to_string(), identifier_list: None, key_list: None }, FunctionCall { identifier: "letter_mod".to_string(), identifier_list: Some(vec![ "horn".to_string(), "breve".to_string() ]), key_list: Some(vec!["u".to_string(), "o".to_string()]) }, FunctionCall { identifier: "insert_uw".to_string(), identifier_list: None, key_list: None } ] } ]) } ); } ================================================ FILE: src/ui/colors.rs ================================================ use druid::{Color, Env, Key}; use std::sync::Arc; pub const GREEN: Color = Color::rgb8(26, 138, 110); pub const GREEN_BG: Color = Color::rgba8(26, 138, 110, 20); #[derive(Clone, Copy, Debug)] pub struct Theme { pub win_bg: Color, pub card_bg: Color, pub card_border: Color, pub divider: Color, pub text_primary: Color, pub text_secondary: Color, pub text_section: Color, pub badge_bg: Color, pub badge_border: Color, pub badge_text: Color, pub btn_reset_bg: Color, pub btn_reset_border: Color, pub btn_reset_text: Color, pub segmented_bg: Color, pub segmented_border: Color, pub segmented_text: Color, pub segmented_ring: Color, pub input_bg: Color, pub input_border: Color, pub input_text: Color, pub input_placeholder: Color, pub tab_border: Color, pub tab_inactive: Color, pub list_row_hover: Color, pub toggle_off: Color, pub checkbox_border: Color, pub tooltip_bg: Color, } pub static THEME: Key> = Key::new("goxkey.theme"); pub static IS_DARK: Key = Key::new("goxkey.is_dark"); pub fn light_theme() -> Theme { Theme { win_bg: Color::rgb8(255, 255, 255), card_bg: Color::rgb8(245, 245, 245), card_border: Color::rgba8(0, 0, 0, 30), divider: Color::rgba8(0, 0, 0, 20), text_primary: Color::rgb8(17, 17, 17), text_secondary: Color::rgb8(102, 102, 102), text_section: Color::rgb8(153, 153, 153), badge_bg: Color::rgb8(255, 255, 255), badge_border: Color::rgb8(204, 204, 204), badge_text: Color::rgb8(85, 85, 85), btn_reset_bg: Color::rgb8(240, 240, 240), btn_reset_border: Color::rgb8(204, 204, 204), btn_reset_text: Color::rgb8(51, 51, 51), segmented_bg: Color::WHITE, segmented_border: Color::rgb8(221, 221, 221), segmented_text: Color::rgb8(136, 136, 136), segmented_ring: Color::rgb8(187, 187, 187), input_bg: Color::WHITE, input_border: Color::rgb8(204, 204, 204), input_text: Color::rgb8(17, 17, 17), input_placeholder: Color::rgba8(0, 0, 0, 80), tab_border: Color::rgb8(221, 221, 221), tab_inactive: Color::rgb8(153, 153, 153), list_row_hover: Color::rgba8(0, 0, 0, 8), toggle_off: Color::rgb8(187, 187, 187), checkbox_border: Color::rgb8(204, 204, 204), tooltip_bg: Color::rgb8(40, 40, 40), } } pub fn dark_theme() -> Theme { Theme { win_bg: Color::rgb8(30, 30, 30), card_bg: Color::rgb8(45, 45, 45), card_border: Color::rgba8(255, 255, 255, 20), divider: Color::rgba8(255, 255, 255, 15), text_primary: Color::rgb8(245, 245, 245), text_secondary: Color::rgb8(170, 170, 170), text_section: Color::rgb8(120, 120, 120), badge_bg: Color::rgb8(60, 60, 60), badge_border: Color::rgb8(100, 100, 100), badge_text: Color::rgb8(200, 200, 200), btn_reset_bg: Color::rgb8(60, 60, 60), btn_reset_border: Color::rgb8(80, 80, 80), btn_reset_text: Color::rgb8(220, 220, 220), segmented_bg: Color::rgb8(45, 45, 45), segmented_border: Color::rgb8(70, 70, 70), segmented_text: Color::rgb8(150, 150, 150), segmented_ring: Color::rgb8(100, 100, 100), input_bg: Color::rgb8(45, 45, 45), input_border: Color::rgb8(70, 70, 70), input_text: Color::rgb8(245, 245, 245), input_placeholder: Color::rgba8(255, 255, 255, 80), tab_border: Color::rgb8(70, 70, 70), tab_inactive: Color::rgb8(120, 120, 120), list_row_hover: Color::rgba8(255, 255, 255, 8), toggle_off: Color::rgb8(80, 80, 80), checkbox_border: Color::rgb8(80, 80, 80), tooltip_bg: Color::rgb8(60, 60, 60), } } pub fn get_theme(is_dark: bool) -> Theme { if is_dark { dark_theme() } else { light_theme() } } pub fn theme_from_env(env: &Env) -> Theme { get_theme(env.get(&IS_DARK)) } pub const BADGE_VI_BG: Color = Color::rgba8(26, 138, 110, 20); pub const BADGE_VI_BORDER: Color = Color::rgb8(26, 138, 110); pub const BADGE_EN_BG: Color = Color::rgba8(58, 115, 199, 18); pub const BADGE_EN_BORDER: Color = Color::rgb8(58, 115, 199); ================================================ FILE: src/ui/controllers.rs ================================================ use crate::{ input::{rebuild_keyboard_layout_map, INPUT_STATE}, platform::{ defer_open_text_file_picker, defer_save_text_file_picker, update_launch_on_login, KeyModifier, }, }; use druid::{Env, Event, EventCtx, Screen, UpdateCtx, Widget, WindowDesc, WindowLevel}; use log::error; use super::{ add_macro_dialog_ui_builder, data::UIDataAdapter, edit_shortcut_dialog_ui_builder, format_letter_key, letter_key_to_char, selectors::{ ADD_MACRO, DELETE_MACRO, DELETE_SELECTED_APP, DELETE_SELECTED_MACRO, EXPORT_MACROS_TO_FILE, LOAD_MACROS_FROM_FILE, RESET_DEFAULTS, SAVE_SHORTCUT, SET_EN_APP_FROM_PICKER, SHOW_ADD_MACRO_DIALOG, SHOW_EDIT_SHORTCUT_DIALOG, TOGGLE_APP_MODE, }, ADD_MACRO_DIALOG_HEIGHT, ADD_MACRO_DIALOG_WIDTH, EDIT_SHORTCUT_DIALOG_HEIGHT, EDIT_SHORTCUT_DIALOG_WIDTH, SHOW_UI, UPDATE_UI, }; pub struct UIController; impl> druid::widget::Controller for UIController { fn event( &mut self, child: &mut W, ctx: &mut EventCtx, event: &Event, data: &mut UIDataAdapter, env: &Env, ) { match event { Event::Command(cmd) => { if cmd.get(UPDATE_UI).is_some() { data.update(); rebuild_keyboard_layout_map(); } if cmd.get(SHOW_UI).is_some() { ctx.set_handled(); ctx.window().bring_to_front_and_focus(); } if let Some(source) = cmd.get(DELETE_MACRO) { unsafe { INPUT_STATE.delete_macro(source) }; data.update(); } if cmd.get(ADD_MACRO).is_some() && !data.new_macro_from.is_empty() && !data.new_macro_to.is_empty() { unsafe { INPUT_STATE .add_macro(data.new_macro_from.clone(), data.new_macro_to.clone()) }; data.new_macro_from = String::new(); data.new_macro_to = String::new(); data.update(); } if cmd.get(SHOW_ADD_MACRO_DIALOG).is_some() { data.new_macro_from = String::new(); data.new_macro_to = String::new(); let screen = Screen::get_display_rect(); let x = (screen.width() - ADD_MACRO_DIALOG_WIDTH) / 2.0; let y = (screen.height() - ADD_MACRO_DIALOG_HEIGHT) / 2.0; let dialog = WindowDesc::new(add_macro_dialog_ui_builder()) .title("Add Text Expansion") .window_size((ADD_MACRO_DIALOG_WIDTH, ADD_MACRO_DIALOG_HEIGHT)) .resizable(false) .set_position((x, y)) .set_level(WindowLevel::Modal(ctx.window().clone())); ctx.new_window(dialog); ctx.set_handled(); } if cmd.get(SHOW_EDIT_SHORTCUT_DIALOG).is_some() { data.pending_shortcut_display = String::new(); data.pending_shortcut_super = false; data.pending_shortcut_ctrl = false; data.pending_shortcut_alt = false; data.pending_shortcut_shift = false; data.pending_shortcut_letter = String::new(); let screen = Screen::get_display_rect(); let x = (screen.width() - EDIT_SHORTCUT_DIALOG_WIDTH) / 2.0; let y = (screen.height() - EDIT_SHORTCUT_DIALOG_HEIGHT) / 2.0; let dialog = WindowDesc::new(edit_shortcut_dialog_ui_builder()) .title("Edit Shortcut") .window_size((EDIT_SHORTCUT_DIALOG_WIDTH, EDIT_SHORTCUT_DIALOG_HEIGHT)) .resizable(false) .set_position((x, y)) .set_level(WindowLevel::Modal(ctx.window().clone())); ctx.new_window(dialog); ctx.set_handled(); } if let Some((is_super, is_ctrl, is_alt, is_shift, letter)) = cmd.get(SAVE_SHORTCUT) { let mut new_mod = KeyModifier::new(); new_mod.apply(*is_super, *is_ctrl, *is_alt, *is_shift, false); let key_code = letter_key_to_char(letter); unsafe { INPUT_STATE.set_hotkey(&format!( "{}{}", new_mod, match key_code { Some(' ') => String::from("space"), Some(c) => c.to_string(), _ => String::new(), } )); } data.update(); ctx.set_handled(); } if let Some(name) = cmd.get(SET_EN_APP_FROM_PICKER) { data.new_en_app = name.clone(); // In the new Apps tab design, adding via picker immediately commits unsafe { INPUT_STATE.add_english_app(&data.new_en_app.clone()) }; data.new_en_app = String::new(); data.update(); } if let Some(app_name) = cmd.get(TOGGLE_APP_MODE) { let is_vn = data.vn_apps.iter().any(|e| &e.name == app_name); if is_vn { unsafe { INPUT_STATE.remove_vietnamese_app(app_name); INPUT_STATE.add_english_app(app_name); } } else { unsafe { INPUT_STATE.remove_english_app(app_name); INPUT_STATE.add_vietnamese_app(app_name); } } data.update(); } if cmd.get(DELETE_SELECTED_MACRO).is_some() { let idx = data.selected_macro_index; if idx >= 0 { if let Some(entry) = data.macro_table.get(idx as usize) { let source = entry.from.clone(); unsafe { INPUT_STATE.delete_macro(&source) }; } data.selected_macro_index = -1; data.update(); } } if cmd.get(LOAD_MACROS_FROM_FILE).is_some() { ctx.set_handled(); let event_sink = unsafe { crate::UI_EVENT_SINK.get().cloned() }; defer_open_text_file_picker(Box::new(move |path| { if let Some(path) = path { unsafe { let _ = INPUT_STATE.import_macros_from_file(&path); } if let Some(sink) = event_sink { let _ = sink.submit_command( crate::ui::UPDATE_UI, (), druid::Target::Global, ); } } })); } if cmd.get(EXPORT_MACROS_TO_FILE).is_some() { ctx.set_handled(); let event_sink = unsafe { crate::UI_EVENT_SINK.get().cloned() }; defer_save_text_file_picker(Box::new(move |path| { if let Some(path) = path { unsafe { let _ = INPUT_STATE.export_macros_to_file(&path); } if let Some(sink) = event_sink { let _ = sink.submit_command( crate::ui::UPDATE_UI, (), druid::Target::Global, ); } } })); } if cmd.get(DELETE_SELECTED_APP).is_some() { let idx = data.selected_app_index; if idx >= 0 { let vn_len = data.vn_apps.len() as i32; if idx < vn_len { if let Some(entry) = data.vn_apps.get(idx as usize) { let name = entry.name.clone(); unsafe { INPUT_STATE.remove_vietnamese_app(&name) }; } } else { let en_idx = (idx - vn_len) as usize; if let Some(entry) = data.en_apps.get(en_idx) { let name = entry.name.clone(); unsafe { INPUT_STATE.remove_english_app(&name) }; } } data.selected_app_index = -1; data.update(); } } if cmd.get(RESET_DEFAULTS).is_some() { unsafe { if !INPUT_STATE.is_enabled() { INPUT_STATE.toggle_vietnamese(); } INPUT_STATE.set_method(crate::input::TypingMethod::Telex); INPUT_STATE.set_hotkey("ctrl+space"); } if let Err(err) = update_launch_on_login(true) { error!("{}", err); } data.update(); ctx.set_handled(); } } Event::WindowCloseRequested => { ctx.set_handled(); ctx.window().hide(); } _ => {} } child.event(ctx, event, data, env) } fn update( &mut self, child: &mut W, ctx: &mut UpdateCtx, old_data: &UIDataAdapter, data: &UIDataAdapter, env: &Env, ) { unsafe { if old_data.typing_method != data.typing_method { INPUT_STATE.set_method(data.typing_method); } if old_data.launch_on_login != data.launch_on_login { if let Err(err) = update_launch_on_login(data.launch_on_login) { error!("{}", err); } } // Update hotkey { let mut new_mod = KeyModifier::new(); new_mod.apply( data.super_key, data.ctrl_key, data.alt_key, data.shift_key, data.capslock_key, ); let key_code = letter_key_to_char(&data.letter_key); if !INPUT_STATE.get_hotkey().is_match(new_mod, key_code) { INPUT_STATE.set_hotkey(&format!( "{}{}", new_mod, match key_code { Some(' ') => String::from("space"), Some(c) => c.to_string(), _ => String::new(), } )); } } if old_data.is_macro_enabled != data.is_macro_enabled { INPUT_STATE.toggle_macro_enabled(); } if old_data.is_macro_autocap_enabled != data.is_macro_autocap_enabled { INPUT_STATE.toggle_macro_autocap(); } if old_data.is_auto_toggle_enabled != data.is_auto_toggle_enabled { INPUT_STATE.toggle_auto_toggle(); } if old_data.is_w_literal_enabled != data.is_w_literal_enabled { INPUT_STATE.toggle_w_literal(); } if old_data.ui_language != data.ui_language { let lang_str = match data.ui_language { 1 => "vi", 2 => "en", _ => "auto", }; crate::config::CONFIG_MANAGER .lock() .unwrap() .set_ui_language(lang_str); super::locale::init_lang(lang_str); ctx.request_paint(); // Trigger data.update() so system tray menu text refreshes ctx.submit_command(super::UPDATE_UI); } } child.update(ctx, old_data, data, env); } } pub(super) struct LetterKeyController; impl> druid::widget::Controller for LetterKeyController { fn event( &mut self, child: &mut W, ctx: &mut EventCtx, event: &Event, data: &mut UIDataAdapter, env: &Env, ) { if let &Event::MouseDown(_) = event { ctx.submit_command(druid::commands::SELECT_ALL); } if let &Event::KeyUp(_) = event { match data.letter_key.as_str() { "Space" => {} s => { data.letter_key = format_letter_key(letter_key_to_char(s)); } } } child.event(ctx, event, data, env) } } ================================================ FILE: src/ui/data.rs ================================================ use std::sync::Arc; use crate::{ input::{TypingMethod, INPUT_STATE}, platform::{is_dark_mode, is_launch_on_login, SystemTray, SystemTrayMenuItemKey}, update_systray_title_immediately, UI_EVENT_SINK, }; use druid::{commands::QUIT_APP, Data, Lens, Target}; use super::{format_letter_key, locale::t, SHOW_UI, UPDATE_UI}; #[derive(Clone, Data, PartialEq, Eq)] pub(super) struct MacroEntry { pub(super) from: String, pub(super) to: String, } #[derive(Clone, Data, PartialEq, Eq)] pub(super) struct AppEntry { pub(super) name: String, } #[derive(Clone, Data, Lens, PartialEq, Eq)] pub struct UIDataAdapter { pub(super) is_enabled: bool, pub(super) typing_method: TypingMethod, pub(super) hotkey_display: String, pub(super) launch_on_login: bool, pub(super) is_auto_toggle_enabled: bool, pub(super) is_w_literal_enabled: bool, // Macro config pub(super) is_macro_enabled: bool, pub(super) is_macro_autocap_enabled: bool, pub(super) macro_table: Arc>, pub(super) new_macro_from: String, pub(super) new_macro_to: String, // App language settings pub(super) vn_apps: Arc>, pub(super) en_apps: Arc>, pub(super) new_en_app: String, // Hotkey config pub(super) super_key: bool, pub(super) ctrl_key: bool, pub(super) alt_key: bool, pub(super) shift_key: bool, pub(super) capslock_key: bool, pub(super) letter_key: String, // Pending shortcut capture (used by edit shortcut dialog) pub(super) pending_shortcut_display: String, pub(super) pending_shortcut_super: bool, pub(super) pending_shortcut_ctrl: bool, pub(super) pending_shortcut_alt: bool, pub(super) pending_shortcut_shift: bool, pub(super) pending_shortcut_letter: String, // UI language (0=Auto, 1=Vietnamese, 2=English) pub(super) ui_language: u32, // Dark mode pub is_dark: bool, // Tab navigation (0=General, 1=Apps, 2=Shortcuts, 3=Advanced) pub(super) active_tab: u32, // Apps tab selected row (combined vn+en list, -1 = none) pub(super) selected_app_index: i32, // Text Expansion tab selected row (-1 = none) pub(super) selected_macro_index: i32, // system tray pub(super) systray: SystemTray, } impl UIDataAdapter { pub fn new() -> Self { let mut ret = Self { is_enabled: true, typing_method: TypingMethod::Telex, hotkey_display: String::new(), launch_on_login: false, is_auto_toggle_enabled: false, is_w_literal_enabled: false, is_macro_enabled: false, is_macro_autocap_enabled: false, macro_table: Arc::new(Vec::new()), new_macro_from: String::new(), new_macro_to: String::new(), vn_apps: Arc::new(Vec::new()), en_apps: Arc::new(Vec::new()), new_en_app: String::new(), super_key: true, ctrl_key: true, alt_key: false, shift_key: false, capslock_key: false, letter_key: String::from("Space"), pending_shortcut_display: String::new(), pending_shortcut_super: false, pending_shortcut_ctrl: false, pending_shortcut_alt: false, pending_shortcut_shift: false, pending_shortcut_letter: String::new(), ui_language: 0, is_dark: false, active_tab: 0, selected_app_index: -1, selected_macro_index: -1, systray: SystemTray::new(), }; ret.setup_system_tray_actions(); ret.update(); ret } pub fn update(&mut self) { unsafe { self.is_enabled = INPUT_STATE.is_enabled(); self.typing_method = INPUT_STATE.get_method(); self.hotkey_display = INPUT_STATE.get_hotkey().to_string(); self.is_macro_enabled = INPUT_STATE.is_macro_enabled(); self.is_macro_autocap_enabled = INPUT_STATE.is_macro_autocap_enabled(); self.is_auto_toggle_enabled = INPUT_STATE.is_auto_toggle_enabled(); self.is_w_literal_enabled = INPUT_STATE.is_w_literal_enabled(); self.launch_on_login = is_launch_on_login(); self.is_dark = is_dark_mode(); let config = crate::config::CONFIG_MANAGER.lock().unwrap(); self.ui_language = match config.get_ui_language() { "vi" => 1, "en" => 2, _ => 0, // "auto" }; drop(config); self.macro_table = Arc::new( INPUT_STATE .get_macro_table() .iter() .map(|(source, target)| MacroEntry { from: source.to_string(), to: target.to_string(), }) .collect::>(), ); self.vn_apps = Arc::new( INPUT_STATE .get_vn_apps() .into_iter() .map(|name| AppEntry { name }) .collect(), ); self.en_apps = Arc::new( INPUT_STATE .get_en_apps() .into_iter() .map(|name| AppEntry { name }) .collect(), ); let (modifiers, keycode) = INPUT_STATE.get_hotkey().inner(); self.super_key = modifiers.is_super(); self.ctrl_key = modifiers.is_control(); self.alt_key = modifiers.is_alt(); self.shift_key = modifiers.is_shift(); self.letter_key = format_letter_key(keycode); match self.is_enabled { true => { let title = if INPUT_STATE.is_gox_mode_enabled() { "gõ" } else { "VN" }; self.systray.set_title(title, true); self.systray.set_menu_item_title( SystemTrayMenuItemKey::Enable, t("menu.disable_vietnamese"), ); } false => { let title = if INPUT_STATE.is_gox_mode_enabled() { match self.typing_method { TypingMethod::Telex => "gox", TypingMethod::VNI => "go4", TypingMethod::TelexVNI => "go+", } } else { "EN" }; self.systray.set_title(title, false); self.systray.set_menu_item_title( SystemTrayMenuItemKey::Enable, t("menu.enable_vietnamese"), ); } } match self.typing_method { TypingMethod::VNI => { self.systray .set_menu_item_title(SystemTrayMenuItemKey::TypingMethodTelex, "Telex"); self.systray .set_menu_item_title(SystemTrayMenuItemKey::TypingMethodVNI, "VNI ✓"); self.systray.set_menu_item_title( SystemTrayMenuItemKey::TypingMethodTelexVNI, "Telex+VNI", ); } TypingMethod::Telex => { self.systray .set_menu_item_title(SystemTrayMenuItemKey::TypingMethodTelex, "Telex ✓"); self.systray .set_menu_item_title(SystemTrayMenuItemKey::TypingMethodVNI, "VNI"); self.systray.set_menu_item_title( SystemTrayMenuItemKey::TypingMethodTelexVNI, "Telex+VNI", ); } TypingMethod::TelexVNI => { self.systray .set_menu_item_title(SystemTrayMenuItemKey::TypingMethodTelex, "Telex"); self.systray .set_menu_item_title(SystemTrayMenuItemKey::TypingMethodVNI, "VNI"); self.systray.set_menu_item_title( SystemTrayMenuItemKey::TypingMethodTelexVNI, "Telex+VNI ✓", ); } } // Update localizable menu items self.systray .set_menu_item_title(SystemTrayMenuItemKey::ShowUI, t("menu.open_panel")); self.systray .set_menu_item_title(SystemTrayMenuItemKey::Exit, t("menu.quit")); } } fn setup_system_tray_actions(&mut self) { self.systray .set_menu_item_callback(SystemTrayMenuItemKey::ShowUI, || { UI_EVENT_SINK .get() .map(|event| Some(event.submit_command(SHOW_UI, (), Target::Auto))); }); self.systray .set_menu_item_callback(SystemTrayMenuItemKey::Enable, || { unsafe { INPUT_STATE.toggle_vietnamese(); update_systray_title_immediately(); } UI_EVENT_SINK .get() .map(|event| Some(event.submit_command(UPDATE_UI, (), Target::Auto))); }); self.systray .set_menu_item_callback(SystemTrayMenuItemKey::TypingMethodTelex, || { unsafe { INPUT_STATE.set_method(TypingMethod::Telex); } UI_EVENT_SINK .get() .map(|event| Some(event.submit_command(UPDATE_UI, (), Target::Auto))); }); self.systray .set_menu_item_callback(SystemTrayMenuItemKey::TypingMethodVNI, || { unsafe { INPUT_STATE.set_method(TypingMethod::VNI); } UI_EVENT_SINK .get() .map(|event| Some(event.submit_command(UPDATE_UI, (), Target::Auto))); }); self.systray .set_menu_item_callback(SystemTrayMenuItemKey::TypingMethodTelexVNI, || { unsafe { INPUT_STATE.set_method(TypingMethod::TelexVNI); } UI_EVENT_SINK .get() .map(|event| Some(event.submit_command(UPDATE_UI, (), Target::Auto))); }); self.systray .set_menu_item_callback(SystemTrayMenuItemKey::Exit, || { UI_EVENT_SINK .get() .map(|event| Some(event.submit_command(QUIT_APP, (), Target::Auto))); }); } pub fn toggle_vietnamese(&mut self) { unsafe { INPUT_STATE.toggle_vietnamese(); } self.update(); } } ================================================ FILE: src/ui/locale.rs ================================================ use std::sync::atomic::{AtomicU8, Ordering}; const LANG_VI: u8 = 0; const LANG_EN: u8 = 1; #[derive(Clone, Copy, PartialEq, Eq)] pub enum Lang { Vi, En, } static LANG: AtomicU8 = AtomicU8::new(LANG_EN); /// Resolve the effective language from config, CLI flag, or OS preference. fn resolve_lang(config_value: &str) -> Lang { // CLI --lang flag takes highest priority let pref = std::env::args() .skip_while(|a| a != "--lang") .nth(1) .unwrap_or_else(|| { // Then config value, then OS preference match config_value { "vi" => "vi".to_string(), "en" => "en".to_string(), _ => crate::platform::get_preferred_language(), // "auto" or unknown } }); if pref.starts_with("vi") { Lang::Vi } else { Lang::En } } /// Initialize the language from config. Called once at startup. pub fn init_lang(config_value: &str) { let lang = resolve_lang(config_value); LANG.store( match lang { Lang::Vi => LANG_VI, Lang::En => LANG_EN, }, Ordering::Relaxed, ); } /// Update the language at runtime (e.g. from UI settings). pub fn set_lang(lang: Lang) { LANG.store( match lang { Lang::Vi => LANG_VI, Lang::En => LANG_EN, }, Ordering::Relaxed, ); } pub fn current_lang() -> Lang { match LANG.load(Ordering::Relaxed) { LANG_VI => Lang::Vi, _ => Lang::En, } } /// Translate a key to the current locale. pub fn t(key: &'static str) -> &'static str { let lang = current_lang(); match (lang, key) { // ── System tray menu ──────────────────────────────────────────── (Lang::Vi, "menu.open_panel") => "Mở bảng điều khiển", (Lang::En, "menu.open_panel") => "Open Control Panel", (Lang::Vi, "menu.disable_vietnamese") => "Tắt gõ tiếng Việt", (Lang::En, "menu.disable_vietnamese") => "Disable Vietnamese", (Lang::Vi, "menu.enable_vietnamese") => "Bật gõ tiếng Việt", (Lang::En, "menu.enable_vietnamese") => "Enable Vietnamese", (Lang::Vi, "menu.quit") => "Thoát ứng dụng", (Lang::En, "menu.quit") => "Quit", // ── Accessibility permission dialog ───────────────────────────── (Lang::Vi, "perm.title") => { "Chờ đã! Bạn cần phải cấp quyền Accessibility\ncho ứng dụng GõKey trước khi sử dụng." } (Lang::En, "perm.title") => { "Wait! You need to grant Accessibility\npermission for GõKey before using." } (Lang::Vi, "perm.subtitle") => { "Bạn vui lòng thoát khỏi ứng dụng\nvà mở lại sau khi đã cấp quyền." } (Lang::En, "perm.subtitle") => { "Please quit the application\nand reopen after granting permission." } (Lang::Vi, "perm.exit") => "Thoát", (Lang::En, "perm.exit") => "Exit", // ── Tab labels ────────────────────────────────────────────────── (Lang::Vi, "tab.general") => "Chung", (Lang::En, "tab.general") => "General", (Lang::Vi, "tab.apps") => "Ứng dụng", (Lang::En, "tab.apps") => "Apps", (Lang::Vi, "tab.text_expansion") => "Gõ tắt", (Lang::En, "tab.text_expansion") => "Text Expansion", // ── General tab ───────────────────────────────────────────────── (Lang::Vi, "general.input_mode") => "Chế độ gõ", (Lang::En, "general.input_mode") => "Input mode", (Lang::Vi, "general.language") => "Ngôn ngữ", (Lang::En, "general.language") => "Language", (Lang::Vi, "general.ui_language") => "Ngôn ngữ giao diện", (Lang::En, "general.ui_language") => "UI language", (Lang::Vi, "general.ui_language_desc") => "Thay đổi ngôn ngữ giao diện", (Lang::En, "general.ui_language_desc") => "Change the interface language", (Lang::Vi, "general.system") => "Hệ thống", (Lang::En, "general.system") => "System", (Lang::Vi, "general.shortcut") => "Phím tắt", (Lang::En, "general.shortcut") => "Shortcut", (Lang::Vi, "general.vietnamese_input") => "Gõ tiếng Việt", (Lang::En, "general.vietnamese_input") => "Vietnamese input", (Lang::Vi, "general.enable_vietnamese") => "Bật chế độ gõ tiếng Việt", (Lang::En, "general.enable_vietnamese") => "Enable Vietnamese typing mode", (Lang::Vi, "general.input_method") => "Kiểu gõ", (Lang::En, "general.input_method") => "Input method", (Lang::Vi, "general.w_literal") => "Chế độ W nguyên bản", (Lang::En, "general.w_literal") => "W literal mode", (Lang::Vi, "general.w_literal_desc") => "Gõ w ra w; dùng uw, ow, aw cho ư, ơ, ă", (Lang::En, "general.w_literal_desc") => "Type w for w; use uw, ow, aw for ư, ơ, ă", (Lang::Vi, "general.launch_at_login") => "Khởi động cùng hệ thống", (Lang::En, "general.launch_at_login") => "Launch at login", (Lang::Vi, "general.launch_at_login_desc") => "Tự động mở GõKey khi đăng nhập", (Lang::En, "general.launch_at_login_desc") => "Start gõkey when you log in", (Lang::Vi, "general.toggle_shortcut") => "Bật/tắt tiếng Việt", (Lang::En, "general.toggle_shortcut") => "Toggle Vietnamese input", (Lang::Vi, "general.toggle_shortcut_desc") => "Phím tắt bật/tắt chế độ gõ", (Lang::En, "general.toggle_shortcut_desc") => "Keyboard shortcut to toggle on/off", (Lang::Vi, "general.reset_defaults") => "Đặt lại mặc định", (Lang::En, "general.reset_defaults") => "Reset defaults", (Lang::Vi, "general.done") => "Xong", (Lang::En, "general.done") => "Done", // ── Apps tab ──────────────────────────────────────────────────── (Lang::Vi, "apps.description") => "Đặt ngôn ngữ gõ cho từng ứng dụng.", (Lang::En, "apps.description") => "Set input language per application.", (Lang::Vi, "apps.per_app_toggle") => "Chế độ theo ứng dụng", (Lang::En, "apps.per_app_toggle") => "Per-app toggle", (Lang::Vi, "apps.per_app_toggle_desc") => "Bật/tắt theo từng ứng dụng", (Lang::En, "apps.per_app_toggle_desc") => "Enable/disable per application", (Lang::Vi, "apps.vietnamese") => "Tiếng Việt", (Lang::En, "apps.vietnamese") => "Vietnamese", (Lang::Vi, "apps.english") => "Tiếng Anh", (Lang::En, "apps.english") => "English", // ── Text expansion tab ────────────────────────────────────────── (Lang::Vi, "macro.description") => "Tự động mở rộng từ viết tắt thành văn bản đầy đủ.", (Lang::En, "macro.description") => "Expand shorthand into full text automatically.", (Lang::Vi, "macro.text_expansion") => "Gõ tắt", (Lang::En, "macro.text_expansion") => "Text expansion", (Lang::Vi, "macro.enable") => "Bật chế độ gõ tắt", (Lang::En, "macro.enable") => "Enable shorthand expansion", (Lang::Vi, "macro.auto_capitalize") => "Tự động viết hoa", (Lang::En, "macro.auto_capitalize") => "Auto capitalize", (Lang::Vi, "macro.auto_capitalize_desc") => "Áp dụng kiểu viết hoa từ chữ viết tắt", (Lang::En, "macro.auto_capitalize_desc") => "Apply capitalization from typed shorthand", (Lang::Vi, "macro.shorthand") => "Viết tắt", (Lang::En, "macro.shorthand") => "Shorthand", (Lang::Vi, "macro.replacement") => "Thay thế", (Lang::En, "macro.replacement") => "Replacement", (Lang::Vi, "macro.load") => "Tải", (Lang::En, "macro.load") => "Load", (Lang::Vi, "macro.export") => "Xuất", (Lang::En, "macro.export") => "Export", // ── Common buttons ────────────────────────────────────────────── (Lang::Vi, "button.add") => "Thêm", (Lang::En, "button.add") => "Add", (Lang::Vi, "button.cancel") => "Huỷ", (Lang::En, "button.cancel") => "Cancel", (Lang::Vi, "button.save") => "Lưu", (Lang::En, "button.save") => "Save", // ── Shortcut editor ───────────────────────────────────────────── (Lang::Vi, "shortcut.new") => "Phím tắt mới", (Lang::En, "shortcut.new") => "New Shortcut", (Lang::Vi, "shortcut.hint") => "Nhấn phím. Cho phép chỉ dùng phím bổ trợ.", (Lang::En, "shortcut.hint") => "Press keys. Modifier-only combos are allowed.", // ── Shortcut capture widget ───────────────────────────────────── (Lang::Vi, "shortcut.type_prompt") => "Nhập phím tắt…", (Lang::En, "shortcut.type_prompt") => "Type a shortcut…", (Lang::Vi, "shortcut.press_keys") => "Nhấn phím…", (Lang::En, "shortcut.press_keys") => "Press keys…", (Lang::Vi, "shortcut.click_and_press") => "Nhấp và nhấn phím…", (Lang::En, "shortcut.click_and_press") => "Click and press keys…", // Fallback _ => key, } } ================================================ FILE: src/ui/mod.rs ================================================ mod colors; mod controllers; mod data; pub(crate) mod locale; mod selectors; mod views; mod widgets; use druid::Selector; pub use colors::{get_theme, IS_DARK, THEME}; pub use data::UIDataAdapter; pub use views::{ add_macro_dialog_ui_builder, center_window_position, edit_shortcut_dialog_ui_builder, main_ui_builder, permission_request_ui_builder, ADD_MACRO_DIALOG_HEIGHT, ADD_MACRO_DIALOG_WIDTH, EDIT_SHORTCUT_DIALOG_HEIGHT, EDIT_SHORTCUT_DIALOG_WIDTH, }; pub const UPDATE_UI: Selector = Selector::new("gox-ui.update-ui"); pub const SHOW_UI: Selector = Selector::new("gox-ui.show-ui"); pub const WINDOW_WIDTH: f64 = 480.0; pub const WINDOW_HEIGHT: f64 = 680.0; pub fn format_letter_key(c: Option) -> String { if let Some(c) = c { return if c.is_ascii_whitespace() { String::from("Space") } else { c.to_ascii_uppercase().to_string() }; } String::new() } pub fn letter_key_to_char(input: &str) -> Option { match input { "Space" => Some(' '), s => { if input.len() > 1 { None } else { s.chars().last() } } } } ================================================ FILE: src/ui/selectors.rs ================================================ use druid::Selector; pub(super) const DELETE_MACRO: Selector = Selector::new("gox-ui.delete-macro"); pub(super) const ADD_MACRO: Selector = Selector::new("gox-ui.add-macro"); pub(super) const DELETE_SELECTED_MACRO: Selector = Selector::new("gox-ui.delete-selected-macro"); pub(super) const SET_EN_APP_FROM_PICKER: Selector = Selector::new("gox-ui.set-en-app-from-picker"); pub(super) const DELETE_SELECTED_APP: Selector = Selector::new("gox-ui.delete-selected-app"); pub(super) const TOGGLE_APP_MODE: Selector = Selector::new("gox-ui.toggle-app-mode"); pub(super) const SHOW_ADD_MACRO_DIALOG: Selector = Selector::new("gox-ui.show-add-macro-dialog"); pub(super) const SHOW_EDIT_SHORTCUT_DIALOG: Selector = Selector::new("gox-ui.show-edit-shortcut-dialog"); pub(super) const SAVE_SHORTCUT: Selector<(bool, bool, bool, bool, String)> = Selector::new("gox-ui.save-shortcut"); pub(super) const RESET_DEFAULTS: Selector = Selector::new("gox-ui.reset-defaults"); pub(super) const LOAD_MACROS_FROM_FILE: Selector = Selector::new("gox-ui.load-macros-from-file"); pub(super) const EXPORT_MACROS_TO_FILE: Selector = Selector::new("gox-ui.export-macros-to-file"); ================================================ FILE: src/ui/views.rs ================================================ use crate::{input::TypingMethod, platform::defer_open_app_file_picker, UI_EVENT_SINK}; use druid::{ kurbo::RoundedRect, piet::{FontFamily, Text, TextLayout, TextLayoutBuilder}, widget::{ Button, Container, EnvScope, FillStrat, Flex, Image, Label, LineBreaking, List, Painter, Scroll, TextBox, ViewSwitcher, }, Application, Color, ImageBuf, Rect, RenderContext, Screen, Target, Widget, WidgetExt, }; use super::{ colors::{ theme_from_env, Theme, BADGE_EN_BG, BADGE_EN_BORDER, BADGE_VI_BG, BADGE_VI_BORDER, GREEN, IS_DARK, }, controllers::UIController, data::{MacroEntry, UIDataAdapter}, locale::t, selectors::{ ADD_MACRO, DELETE_MACRO, DELETE_SELECTED_APP, DELETE_SELECTED_MACRO, EXPORT_MACROS_TO_FILE, LOAD_MACROS_FROM_FILE, SET_EN_APP_FROM_PICKER, SHOW_ADD_MACRO_DIALOG, SHOW_EDIT_SHORTCUT_DIALOG, }, widgets::{ AppsListWidget, HotkeyBadgesWidget, InfoTooltip, MacroListWidget, SegmentedControl, ShortcutCaptureWidget, StyledCheckbox, TabBar, ToggleSwitch, U32SegmentedControl, }, WINDOW_HEIGHT, WINDOW_WIDTH, }; fn text_label( key: &'static str, font_size: f64, color_fn: fn(&Theme) -> Color, height: f64, ) -> impl Widget { Painter::new(move |ctx, _: &UIDataAdapter, env| { let theme = theme_from_env(env); let color = color_fn(&theme); let layout = make_text_layout(ctx, t(key), font_size, &color); ctx.draw_text(&layout, (0.0, 0.0)); }) .fix_height(height) .expand_width() } fn title_label(key: &'static str) -> impl Widget { text_label(key, 13.0, |t| t.text_primary, 18.0) } fn subtitle_label(key: &'static str) -> impl Widget { text_label(key, 12.0, |t| t.text_secondary, 16.0) } fn title_subtitle_column( title_key: &'static str, subtitle_key: &'static str, ) -> impl Widget { Flex::column() .cross_axis_alignment(druid::widget::CrossAxisAlignment::Start) .with_child(title_label(title_key)) .with_child(subtitle_label(subtitle_key)) } fn centered_btn( key: &'static str, width: f64, height: f64, bg_fn: fn(&Theme) -> Color, text_color_fn: fn(&Theme) -> Color, border_fn: Option Color>, ) -> impl Widget { Painter::new(move |ctx, _: &UIDataAdapter, env| { let theme = theme_from_env(env); let size = ctx.size(); let rr = RoundedRect::new(0.0, 0.0, size.width, size.height, 7.0); ctx.fill(rr, &bg_fn(&theme)); if let Some(border_f) = border_fn { ctx.stroke(rr, &border_f(&theme), 0.5); } draw_centered_text(ctx, t(key), 13.0, &text_color_fn(&theme)); }) .fix_size(width, height) } fn make_text_layout( ctx: &mut druid::PaintCtx, text: &str, font_size: f64, color: &Color, ) -> druid::piet::PietTextLayout { ctx.text() .new_text_layout(text.to_owned()) .font(FontFamily::SYSTEM_UI, font_size) .text_color(color.clone()) .build() .unwrap() } fn draw_centered_text(ctx: &mut druid::PaintCtx, text: &str, font_size: f64, color: &Color) { let layout = make_text_layout(ctx, text, font_size, color); let size = ctx.size(); ctx.draw_text( &layout, ( (size.width - layout.size().width) / 2.0, (size.height - layout.size().height) / 2.0, ), ); } fn symbol_btn(symbol: &'static str) -> impl Widget { Painter::new(move |ctx, _: &UIDataAdapter, env| { let theme = theme_from_env(env); draw_centered_text(ctx, symbol, 18.0, &theme.text_primary); }) .fix_size(44.0, 44.0) } fn remove_btn(is_enabled_fn: fn(&UIDataAdapter) -> bool) -> impl Widget { Painter::new(move |ctx, data: &UIDataAdapter, env| { let theme = theme_from_env(env); let size = ctx.size(); let color = if is_enabled_fn(data) { theme.text_primary } else { Color::rgb8(187, 187, 187) }; ctx.fill( Rect::new(0.0, 10.0, 0.5, size.height - 10.0), &theme.divider, ); draw_centered_text(ctx, "−", 18.0, &color); }) .fix_size(44.0, 44.0) } fn toolbar_btn(key: &'static str, divider: Option<&'static str>) -> impl Widget { Painter::new(move |ctx, _: &UIDataAdapter, env| { let theme = theme_from_env(env); let size = ctx.size(); if let Some(side) = divider { let x = if side == "left" { 0.0 } else { size.width - 0.5 }; ctx.fill( Rect::new(x, 10.0, x + 0.5, size.height - 10.0), &theme.divider, ); } draw_centered_text(ctx, t(key), 12.0, &theme.text_primary); }) .fix_size(60.0, 44.0) } fn h_divider() -> impl Widget { Painter::new(|ctx, _: &UIDataAdapter, env| { let theme = theme_from_env(env); let w = ctx.size().width; ctx.fill(Rect::new(0.0, 0.0, w, 0.5), &theme.divider); }) .fix_height(0.5) .expand_width() } fn section_label(key: &'static str) -> impl Widget { Painter::new(move |ctx, _data: &UIDataAdapter, env| { let theme = theme_from_env(env); let layout = make_text_layout(ctx, &t(key).to_uppercase(), 11.0, &theme.text_section); let h = ctx.size().height; ctx.draw_text(&layout, (0.0, (h - layout.size().height) / 2.0)); }) .fix_height(18.0) .expand_width() .padding((0.0, 0.0, 0.0, 6.0)) } fn card_divider() -> impl Widget { Painter::new(|ctx, _data: &UIDataAdapter, env| { let theme = theme_from_env(env); let w = ctx.size().width; ctx.fill(Rect::new(14.0, 0.0, w - 14.0, 0.5), &theme.divider); }) .fix_height(0.5) .expand_width() } fn settings_row + 'static>( title: &'static str, subtitle: &'static str, trailing: TW, ) -> impl Widget { Flex::row() .with_flex_child(title_subtitle_column(title, subtitle), 1.0) .with_child(trailing) .cross_axis_alignment(druid::widget::CrossAxisAlignment::Center) .main_axis_alignment(druid::widget::MainAxisAlignment::SpaceBetween) .must_fill_main_axis(true) .expand_width() .padding((14.0, 10.0)) } fn settings_card + 'static>(inner: TW) -> impl Widget { Container::new(inner) .background(Painter::new(|ctx, _, env| { let theme = theme_from_env(env); let rect = ctx.size().to_rect(); let rr = RoundedRect::from_rect(rect, 10.0); ctx.fill(rr, &theme.card_bg); ctx.stroke(rr, &theme.card_border, 0.5); })) .rounded(10.0) } fn tab_body() -> Flex { Flex::column().cross_axis_alignment(druid::widget::CrossAxisAlignment::Start) } const TAB_PADDING: (f64, f64, f64, f64) = (24.0, 20.0, 24.0, 24.0); fn option_group + 'static>( header: impl Widget + 'static, control: SW, ) -> impl Widget { settings_card( Flex::column() .cross_axis_alignment(druid::widget::CrossAxisAlignment::Start) .with_child(header) .with_spacer(8.0) .with_child(control.expand_width()) .expand_width() .padding((14.0, 10.0)), ) } fn v_scroll + 'static>(inner: W) -> Scroll { let mut scroll = Scroll::new(inner); scroll.set_enabled_scrollbars(druid::scroll_component::ScrollbarsEnabled::Vertical); scroll.set_horizontal_scroll_enabled(false); scroll } fn list_card( list: impl Widget + 'static, toolbar: impl Widget + 'static, ) -> impl Widget { Container::new( Flex::column() .with_flex_child(v_scroll(list.expand_width()).expand(), 1.0) .with_child(h_divider()) .with_child(toolbar.expand_width()), ) .background(Painter::new(|ctx, _, env| { let theme = theme_from_env(env); let rect = ctx.size().to_rect(); let rr = RoundedRect::from_rect(rect, 10.0); ctx.fill(rr, &theme.card_bg); ctx.stroke(rr, &theme.card_border, 0.5); })) .rounded(10.0) } fn action_btn( key: &'static str, enabled_fn: fn(&UIDataAdapter) -> bool, ) -> impl Widget { Painter::new(move |ctx, data: &UIDataAdapter, _| { let size = ctx.size(); let rr = RoundedRect::new(0.0, 0.0, size.width, size.height, 7.0); let bg = if enabled_fn(data) { GREEN } else { Color::rgb8(150, 150, 150) }; ctx.fill(rr, &bg); draw_centered_text(ctx, t(key), 13.0, &Color::WHITE); }) .fix_size(70.0, 30.0) } fn dialog_buttons( cancel: impl Widget + 'static, action: impl Widget + 'static, ) -> impl Widget { Flex::row() .with_flex_spacer(1.0) .with_child(cancel) .with_spacer(8.0) .with_child(action) .expand_width() } fn cancel_btn() -> impl Widget { centered_btn( "button.cancel", 90.0, 30.0, |t| t.btn_reset_bg, |t| t.btn_reset_text, Some(|t| t.btn_reset_border), ) } fn general_tab() -> impl Widget { let input_mode_card = settings_card( Flex::column() .with_child(settings_row( "general.vietnamese_input", "general.enable_vietnamese", ToggleSwitch.lens(UIDataAdapter::is_enabled).on_click( |_, data: &mut UIDataAdapter, _| { data.toggle_vietnamese(); }, ), )) .with_child(card_divider()) .with_child( Flex::column() .cross_axis_alignment(druid::widget::CrossAxisAlignment::Start) .with_child(title_label("general.input_method")) .with_spacer(8.0) .with_child( SegmentedControl::new(vec![ ("Telex", TypingMethod::Telex), ("VNI", TypingMethod::VNI), ("Telex + VNI", TypingMethod::TelexVNI), ]) .lens(UIDataAdapter::typing_method) .expand_width(), ) .expand_width() .padding((14.0, 10.0)), ), ); let w_literal_card = settings_card(settings_row( "general.w_literal", "general.w_literal_desc", ToggleSwitch.lens(UIDataAdapter::is_w_literal_enabled), )); let system_card = settings_card(settings_row( "general.launch_at_login", "general.launch_at_login_desc", StyledCheckbox.lens(UIDataAdapter::launch_on_login), )); let language_card = option_group( title_subtitle_column("general.ui_language", "general.ui_language_desc"), U32SegmentedControl::new(vec![("Auto", 0), ("Tiếng Việt", 1), ("English", 2)]) .lens(UIDataAdapter::ui_language), ); let edit_shortcut_btn = Painter::new(|ctx, _: &UIDataAdapter, env| { let theme = theme_from_env(env); let size = ctx.size(); let cx = size.width / 2.0; let cy = size.height / 2.0; let mut pencil = druid::kurbo::BezPath::new(); pencil.move_to((cx - 1.5, cy + 6.0)); pencil.line_to((cx - 6.0, cy + 1.5)); pencil.line_to((cx + 1.5, cy - 6.0)); pencil.line_to((cx + 6.0, cy - 1.5)); pencil.close_path(); ctx.fill(pencil, &theme.text_secondary); let mut nib = druid::kurbo::BezPath::new(); nib.move_to((cx + 1.5, cy - 6.0)); nib.line_to((cx + 6.0, cy - 1.5)); nib.line_to((cx + 7.5, cy - 3.0)); nib.line_to((cx + 3.0, cy - 7.5)); nib.close_path(); ctx.fill(nib, &theme.text_primary); let mut tip = druid::kurbo::BezPath::new(); tip.move_to((cx - 1.5, cy + 6.0)); tip.line_to((cx - 6.0, cy + 1.5)); tip.line_to((cx - 8.0, cy + 8.0)); tip.close_path(); ctx.fill(tip, &theme.text_secondary); }) .fix_size(24.0, 24.0) .on_click(|ctx, _: &mut UIDataAdapter, _| { ctx.submit_command(SHOW_EDIT_SHORTCUT_DIALOG.to(druid::Target::Global)); }); let shortcut_card = settings_card(settings_row( "general.toggle_shortcut", "general.toggle_shortcut_desc", Flex::row() .with_child(HotkeyBadgesWidget::new()) .with_spacer(8.0) .with_child(edit_shortcut_btn), )); let footer = dialog_buttons( centered_btn( "general.reset_defaults", 120.0, 30.0, |t| t.btn_reset_bg, |t| t.btn_reset_text, Some(|t| t.btn_reset_border), ) .on_click(|ctx, _data: &mut UIDataAdapter, _env| { ctx.submit_command(super::selectors::RESET_DEFAULTS); }), centered_btn( "general.done", 70.0, 30.0, |_| GREEN, |_| Color::WHITE, None, ) .on_click(|ctx, _data: &mut UIDataAdapter, _env| { ctx.window().hide(); }), ); tab_body() .with_child(section_label("general.input_mode")) .with_child(input_mode_card) .with_spacer(8.0) .with_child(w_literal_card) .with_spacer(20.0) .with_child(section_label("general.system")) .with_child(system_card) .with_spacer(8.0) .with_child(language_card) .with_spacer(20.0) .with_child(section_label("general.shortcut")) .with_child(shortcut_card) .with_flex_spacer(1.0) .with_child(footer) .padding(TAB_PADDING) } fn apps_tab() -> impl Widget { let description = title_label("apps.description"); let legend = Painter::new(|ctx, _: &UIDataAdapter, env| { let theme = theme_from_env(env); let mut x = 0.0; let bh = 22.0; let badge_y = (26.0 - bh) / 2.0; for (badge_text, badge_bg, badge_border, label_key) in [ ("VI", BADGE_VI_BG, BADGE_VI_BORDER, "apps.vietnamese"), ("EN", BADGE_EN_BG, BADGE_EN_BORDER, "apps.english"), ] { let badge_layout = make_text_layout(ctx, badge_text, 11.0, &badge_border); let bw = badge_layout.size().width + 14.0; let rr = RoundedRect::new(x, badge_y, x + bw, badge_y + bh, 5.0); ctx.fill(rr, &badge_bg); ctx.stroke(rr, &badge_border, 1.0); ctx.draw_text( &badge_layout, ( x + (bw - badge_layout.size().width) / 2.0, badge_y + (bh - badge_layout.size().height) / 2.0, ), ); x += bw + 8.0; let label = make_text_layout(ctx, t(label_key), 13.0, &theme.text_primary); ctx.draw_text(&label, (x, (26.0 - label.size().height) / 2.0)); x += label.size().width + 20.0; } }) .fix_height(26.0) .expand_width(); let add_btn = symbol_btn("+").on_click(|_, _, _| { defer_open_app_file_picker(Box::new(|name| { if let Some(name) = name { if let Some(sink) = UI_EVENT_SINK.get() { let _ = sink.submit_command(SET_EN_APP_FROM_PICKER, name, Target::Auto); } } })); }); let remove_btn = remove_btn(|d| d.selected_app_index >= 0).on_click(|ctx, data: &mut UIDataAdapter, _| { if data.selected_app_index >= 0 { ctx.submit_command(DELETE_SELECTED_APP.to(Target::Global)); } }); let card = list_card( AppsListWidget::new(), Flex::row() .with_child(add_btn) .with_child(remove_btn) .with_flex_spacer(1.0), ); let per_app_toggle_card = settings_card(settings_row( "apps.per_app_toggle", "apps.per_app_toggle_desc", ToggleSwitch.lens(UIDataAdapter::is_auto_toggle_enabled), )); tab_body() .with_child(description) .with_spacer(12.0) .with_child(per_app_toggle_card) .with_spacer(16.0) .with_child(legend) .with_spacer(12.0) .with_flex_child(card.expand_height(), 1.0) .must_fill_main_axis(true) .expand() .padding(TAB_PADDING) } fn advanced_tab() -> impl Widget { let description = title_label("macro.description"); let enable_row = settings_card(settings_row( "macro.text_expansion", "macro.enable", ToggleSwitch.lens(UIDataAdapter::is_macro_enabled), )); let autocap_row = settings_card(settings_row( "macro.auto_capitalize", "macro.auto_capitalize_desc", Flex::row() .with_child( InfoTooltip::new("ko → không\nKo → Không\nKO → KHÔNG") .padding((0.0, 0.0, 8.0, 0.0)), ) .with_child(StyledCheckbox.lens(UIDataAdapter::is_macro_autocap_enabled)), )); let add_btn = symbol_btn("+").on_click(|ctx, _data: &mut UIDataAdapter, _| { ctx.submit_command(SHOW_ADD_MACRO_DIALOG.to(Target::Global)); }); let remove_btn = remove_btn(|d| d.selected_macro_index >= 0).on_click(|ctx, data: &mut UIDataAdapter, _| { if data.selected_macro_index >= 0 { ctx.submit_command(DELETE_SELECTED_MACRO.to(Target::Global)); } }); let card = list_card( MacroListWidget::new(), Flex::row() .with_child(add_btn) .with_child(remove_btn) .with_flex_spacer(1.0) .with_child(toolbar_btn("macro.load", Some("left")).on_click( |ctx, _data: &mut UIDataAdapter, _| { ctx.submit_command(LOAD_MACROS_FROM_FILE.to(Target::Global)); }, )) .with_child(toolbar_btn("macro.export", None).on_click( |ctx, _data: &mut UIDataAdapter, _| { ctx.submit_command(EXPORT_MACROS_TO_FILE.to(Target::Global)); }, )), ); tab_body() .with_child(description) .with_spacer(12.0) .with_child(enable_row) .with_spacer(8.0) .with_child(autocap_row) .with_spacer(16.0) .with_flex_child(card.expand_height(), 1.0) .must_fill_main_axis(true) .expand() .padding(TAB_PADDING) } fn macro_row_item() -> impl Widget { Flex::row() .with_flex_child( Label::dynamic(|e: &MacroEntry, _| e.from.clone()) .with_line_break_mode(LineBreaking::WordWrap) .align_left(), 2.0, ) .with_flex_child( Label::dynamic(|e: &MacroEntry, _| e.to.clone()) .with_line_break_mode(LineBreaking::WordWrap) .align_left(), 2.0, ) .with_flex_child( Button::new("×").on_click(|ctx, data: &mut MacroEntry, _| { ctx.submit_command(DELETE_MACRO.with(data.from.clone()).to(Target::Global)) }), 1.0, ) .main_axis_alignment(druid::widget::MainAxisAlignment::SpaceBetween) .cross_axis_alignment(druid::widget::CrossAxisAlignment::Baseline) .expand_width() .border(Color::rgb8(224, 224, 224), 0.5) } pub fn main_ui_builder() -> impl Widget { let inner = Flex::column() .with_child( TabBar::new() .lens(UIDataAdapter::active_tab) .fix_height(58.0), ) .with_flex_child( ViewSwitcher::new( |data: &UIDataAdapter, _env| data.active_tab, |tab, _data, _env| match tab { 1 => Box::new(apps_tab()), 2 => Box::new(advanced_tab()), _ => Box::new(general_tab()), }, ) .expand(), 1.0, ) .background(Painter::new(|ctx, _, env| { let theme = theme_from_env(env); let size = ctx.size(); ctx.fill(size.to_rect(), &theme.win_bg); })) .controller(UIController); EnvScope::new( |env, data: &UIDataAdapter| { env.set(IS_DARK.clone(), data.is_dark); }, inner, ) } pub fn permission_request_ui_builder() -> impl Widget<()> { let image_data = ImageBuf::from_data(include_bytes!("../../assets/accessibility.png")).unwrap(); let title_label = Label::new(t("perm.title")) .with_text_color(Color::rgb8(17, 17, 17)) .with_font(druid::FontDescriptor::new(FontFamily::SYSTEM_UI).with_size(13.0)) .with_line_break_mode(LineBreaking::WordWrap); let img_container = Container::new(Image::new(image_data).fill_mode(FillStrat::Cover)) .rounded(8.0) .border(Color::rgba8(0, 0, 0, 30), 1.0); let subtitle_label = Label::new(t("perm.subtitle")) .with_text_color(Color::rgb8(102, 102, 102)) .with_font(druid::FontDescriptor::new(FontFamily::SYSTEM_UI).with_size(12.0)) .with_line_break_mode(LineBreaking::WordWrap); let exit_btn = Painter::new(|ctx, _: &(), _| { let size = ctx.size(); let rr = RoundedRect::new(0.0, 0.0, size.width, size.height, 7.0); ctx.fill(rr, &GREEN); draw_centered_text(ctx, t("perm.exit"), 13.0, &Color::WHITE); }) .fix_size(90.0, 30.0) .on_click(|_, _, _| Application::global().quit()); let buttons = Flex::row() .with_flex_spacer(1.0) .with_child(exit_btn) .expand_width(); Flex::column() .cross_axis_alignment(druid::widget::CrossAxisAlignment::Start) .main_axis_alignment(druid::widget::MainAxisAlignment::Start) .with_child(title_label) .with_spacer(16.0) .with_child(img_container) .with_spacer(16.0) .with_child(subtitle_label) .with_flex_spacer(1.0) .with_child(buttons) .padding((24.0, 20.0, 24.0, 20.0)) .background(Color::rgb8(255, 255, 255)) } pub fn macro_editor_ui_builder() -> impl Widget { Flex::column() .cross_axis_alignment(druid::widget::CrossAxisAlignment::Start) .with_flex_child( v_scroll( List::new(macro_row_item) .lens(UIDataAdapter::macro_table) .expand_width(), ) .expand(), 1.0, ) .with_child( Flex::row() .with_flex_child( TextBox::new() .with_placeholder(t("macro.shorthand")) .expand_width() .lens(UIDataAdapter::new_macro_from), 2.0, ) .with_flex_child( TextBox::new() .with_placeholder(t("macro.replacement")) .expand_width() .lens(UIDataAdapter::new_macro_to), 2.0, ) .with_child( Button::new(t("button.add")) .on_click(|ctx, _, _| ctx.submit_command(ADD_MACRO.to(Target::Global))), ) .expand_width(), ) .padding(8.0) } pub fn center_window_position() -> (f64, f64) { let screen_rect = Screen::get_display_rect(); let x = (screen_rect.width() - WINDOW_WIDTH) / 2.0; let y = (screen_rect.height() - WINDOW_HEIGHT) / 2.0; (x, y) } pub const ADD_MACRO_DIALOG_WIDTH: f64 = 340.0; pub const ADD_MACRO_DIALOG_HEIGHT: f64 = 208.0; pub const EDIT_SHORTCUT_DIALOG_WIDTH: f64 = 340.0; pub const EDIT_SHORTCUT_DIALOG_HEIGHT: f64 = 200.0; fn styled_text_input(placeholder: &'static str) -> impl Widget { use druid::theme; TextBox::new() .with_placeholder(placeholder) .expand_width() .fix_height(32.0) .env_scope(|env, _| { env.set(theme::BACKGROUND_LIGHT, Color::WHITE); env.set(theme::BACKGROUND_DARK, Color::WHITE); env.set(theme::TEXTBOX_BORDER_WIDTH, 0.0); env.set(theme::TEXTBOX_BORDER_RADIUS, 8.0); env.set(theme::TEXT_COLOR, Color::rgb8(17, 17, 17)); env.set(theme::CURSOR_COLOR, Color::rgb8(17, 17, 17)); env.set( theme::TEXTBOX_INSETS, druid::Insets::new(6.0, 6.0, 6.0, 3.0), ); }) } pub fn add_macro_dialog_ui_builder() -> impl Widget { let shorthand_label = subtitle_label("macro.shorthand"); let replacement_label = subtitle_label("macro.replacement"); let buttons = dialog_buttons( cancel_btn().on_click(|ctx, data: &mut UIDataAdapter, _| { data.new_macro_from = String::new(); data.new_macro_to = String::new(); ctx.window().close(); }), action_btn("button.add", |d| { !d.new_macro_from.is_empty() && !d.new_macro_to.is_empty() }) .on_click(|ctx, data: &mut UIDataAdapter, _| { if !data.new_macro_from.is_empty() && !data.new_macro_to.is_empty() { ctx.submit_command(ADD_MACRO.to(Target::Global)); ctx.window().close(); } }), ); Flex::column() .cross_axis_alignment(druid::widget::CrossAxisAlignment::Start) .with_child(shorthand_label) .with_spacer(4.0) .with_child( Container::new(styled_text_input("nope").lens(UIDataAdapter::new_macro_from)) .border(Color::rgb8(204, 204, 204), 1.0) .rounded(8.0) .expand_width(), ) .with_spacer(12.0) .with_child(replacement_label) .with_spacer(4.0) .with_child( Container::new(styled_text_input("dạ, thưa sếp").lens(UIDataAdapter::new_macro_to)) .border(Color::rgb8(204, 204, 204), 1.0) .rounded(8.0) .expand_width(), ) .with_flex_spacer(1.0) .with_child(buttons) .padding((24.0, 20.0, 24.0, 20.0)) .background(Painter::new(|ctx, _, env| { let theme = theme_from_env(env); let size = ctx.size(); ctx.fill(size.to_rect(), &theme.win_bg); })) .expand() } pub fn edit_shortcut_dialog_ui_builder() -> impl Widget { use super::selectors::SAVE_SHORTCUT; let title_label = subtitle_label("shortcut.new"); let hint_label = text_label("shortcut.hint", 11.0, |t| t.text_secondary, 14.0); let buttons = dialog_buttons( cancel_btn().on_click(|ctx, _: &mut UIDataAdapter, _| { ctx.window().close(); }), action_btn("button.save", |d| !d.pending_shortcut_display.is_empty()).on_click( |ctx, data: &mut UIDataAdapter, _| { if !data.pending_shortcut_display.is_empty() { ctx.submit_command( SAVE_SHORTCUT .with(( data.pending_shortcut_super, data.pending_shortcut_ctrl, data.pending_shortcut_alt, data.pending_shortcut_shift, data.pending_shortcut_letter.clone(), )) .to(Target::Global), ); ctx.window().close(); } }, ), ); Flex::column() .cross_axis_alignment(druid::widget::CrossAxisAlignment::Start) .with_child(title_label) .with_spacer(4.0) .with_child(hint_label) .with_spacer(16.0) .with_child( Container::new(ShortcutCaptureWidget::new()) .border(Color::rgb8(204, 204, 204), 1.0) .rounded(8.0) .fix_height(52.0) .expand_width(), ) .with_flex_spacer(1.0) .with_child(buttons) .padding((24.0, 20.0, 24.0, 20.0)) .background(Painter::new(|ctx, _, env| { let theme = theme_from_env(env); let size = ctx.size(); ctx.fill(size.to_rect(), &theme.win_bg); })) .expand() } ================================================ FILE: src/ui/widgets.rs ================================================ use std::collections::HashMap; use crate::input::TypingMethod; use druid::{ kurbo::{BezPath, Circle, RoundedRect}, piet::{FontFamily, ImageFormat, InterpolationMode, Text, TextLayout, TextLayoutBuilder}, BoxConstraints, Color, Env, Event, EventCtx, LayoutCtx, LifeCycle, LifeCycleCtx, PaintCtx, Point, Rect, RenderContext, Size, UpdateCtx, Widget, WidgetPod, }; use super::{ colors::{ theme_from_env, BADGE_EN_BG, BADGE_EN_BORDER, BADGE_VI_BG, BADGE_VI_BORDER, GREEN, GREEN_BG, }, data::UIDataAdapter, locale::t, selectors::TOGGLE_APP_MODE, }; pub(super) struct ToggleSwitch; impl Widget for ToggleSwitch { fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut bool, _env: &Env) { match event { Event::MouseDown(_) => { ctx.set_active(true); ctx.request_paint(); } Event::MouseUp(_) => { if ctx.is_active() { ctx.set_active(false); *data = !*data; ctx.request_paint(); } } _ => {} } } fn lifecycle(&mut self, _ctx: &mut LifeCycleCtx, _event: &LifeCycle, _data: &bool, _env: &Env) { } fn update(&mut self, ctx: &mut UpdateCtx, old_data: &bool, data: &bool, _env: &Env) { if old_data != data { ctx.request_paint(); } } fn layout( &mut self, _ctx: &mut LayoutCtx, _bc: &BoxConstraints, _data: &bool, _env: &Env, ) -> Size { Size::new(36.0, 20.0) } fn paint(&mut self, ctx: &mut PaintCtx, data: &bool, env: &Env) { let theme = theme_from_env(env); let size = ctx.size(); let radius = size.height / 2.0; let track_rect = RoundedRect::new(0.0, 0.0, size.width, size.height, radius); let track_color = if *data { GREEN } else { theme.toggle_off }; ctx.fill(track_rect, &track_color); let knob_r = radius - 2.0; let knob_x = if *data { size.width - radius } else { radius }; ctx.fill( Circle::new(Point::new(knob_x, size.height / 2.0), knob_r), &Color::WHITE, ); } } pub(super) struct StyledCheckbox; impl Widget for StyledCheckbox { fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut bool, _env: &Env) { match event { Event::MouseDown(_) => { ctx.set_active(true); ctx.request_paint(); } Event::MouseUp(_) => { if ctx.is_active() { ctx.set_active(false); *data = !*data; ctx.request_paint(); } } _ => {} } } fn lifecycle(&mut self, _ctx: &mut LifeCycleCtx, _event: &LifeCycle, _data: &bool, _env: &Env) { } fn update(&mut self, ctx: &mut UpdateCtx, old_data: &bool, data: &bool, _env: &Env) { if old_data != data { ctx.request_paint(); } } fn layout( &mut self, _ctx: &mut LayoutCtx, _bc: &BoxConstraints, _data: &bool, _env: &Env, ) -> Size { Size::new(18.0, 18.0) } fn paint(&mut self, ctx: &mut PaintCtx, data: &bool, env: &Env) { let theme = theme_from_env(env); let box_rect = RoundedRect::new(1.0, 1.0, 17.0, 17.0, 4.0); if *data { ctx.fill(box_rect, &GREEN); let mut path = BezPath::new(); path.move_to((3.5, 9.0)); path.line_to((7.0, 12.5)); path.line_to((14.0, 5.5)); ctx.stroke(path, &Color::WHITE, 1.8); } else { ctx.fill(box_rect, &theme.input_bg); ctx.stroke(box_rect, &theme.checkbox_border, 1.0); } } } pub(super) struct InfoTooltip { text: &'static str, is_hovered: bool, } impl InfoTooltip { pub fn new(text: &'static str) -> Self { Self { text, is_hovered: false, } } } impl Widget for InfoTooltip { fn event(&mut self, _ctx: &mut EventCtx, _event: &Event, _data: &mut T, _env: &Env) {} fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, _data: &T, _env: &Env) { if let LifeCycle::HotChanged(hot) = event { self.is_hovered = *hot; ctx.request_paint_rect(Rect::new(-260.0, -100.0, 20.0, 20.0)); } } fn update(&mut self, _ctx: &mut UpdateCtx, _old_data: &T, _data: &T, _env: &Env) {} fn layout(&mut self, ctx: &mut LayoutCtx, _bc: &BoxConstraints, _data: &T, _env: &Env) -> Size { ctx.set_paint_insets(druid::Insets::new(260.0, 100.0, 0.0, 0.0)); Size::new(18.0, 18.0) } fn paint(&mut self, ctx: &mut PaintCtx, _data: &T, env: &Env) { let theme = theme_from_env(env); let size = ctx.size(); let cx = size.width / 2.0; let cy = size.height / 2.0; let circle = Circle::new((cx, cy), 8.0); ctx.stroke(circle, &theme.text_secondary, 1.0); let layout = ctx .text() .new_text_layout("?") .font(FontFamily::SYSTEM_UI, 11.0) .text_color(theme.text_secondary) .build() .unwrap(); ctx.draw_text( &layout, ( cx - layout.size().width / 2.0, cy - layout.size().height / 2.0, ), ); if self.is_hovered { let tooltip_text = self.text; let tooltip_bg = theme.tooltip_bg; ctx.paint_with_z_index(10, move |ctx| { let text_layout = ctx .text() .new_text_layout(tooltip_text) .font(FontFamily::SYSTEM_UI, 12.0) .text_color(Color::WHITE) .max_width(240.0) .build() .unwrap(); let text_size = text_layout.size(); let padding = 8.0; let box_w = text_size.width + padding * 2.0; let box_h = text_size.height + padding * 2.0; let box_x = 18.0 - box_w; let box_y = -box_h - 4.0; let bg_rect = RoundedRect::new(box_x, box_y, box_x + box_w, box_y + box_h, 6.0); ctx.fill(bg_rect, &tooltip_bg); ctx.draw_text(&text_layout, (box_x + padding, box_y + padding)); }); } } } pub(super) struct SegmentedControl { options: Vec<(String, TypingMethod)>, rects: Vec, } impl SegmentedControl { pub(super) fn new(options: Vec<(&str, TypingMethod)>) -> Self { Self { options: options .into_iter() .map(|(s, m)| (s.to_string(), m)) .collect(), rects: Vec::new(), } } } impl Widget for SegmentedControl { fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut TypingMethod, _env: &Env) { if let Event::MouseDown(mouse) = event { for (i, rect) in self.rects.iter().enumerate() { if rect.contains(mouse.pos) { *data = self.options[i].1; ctx.request_paint(); break; } } } } fn lifecycle( &mut self, _ctx: &mut LifeCycleCtx, _event: &LifeCycle, _data: &TypingMethod, _env: &Env, ) { } fn update( &mut self, ctx: &mut UpdateCtx, old_data: &TypingMethod, data: &TypingMethod, _env: &Env, ) { if old_data != data { ctx.request_layout(); } } fn layout( &mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints, _data: &TypingMethod, _env: &Env, ) -> Size { let w = bc.max().width; let h = 34.0; let n = self.options.len() as f64; let gap = 8.0; let btn_w = (w - gap * (n - 1.0)) / n; self.rects = (0..self.options.len()) .map(|i| { let x = i as f64 * (btn_w + gap); Rect::new(x, 0.0, x + btn_w, h) }) .collect(); Size::new(w, h) } fn paint(&mut self, ctx: &mut PaintCtx, data: &TypingMethod, env: &Env) { let theme = theme_from_env(env); for (i, (label, method)) in self.options.iter().enumerate() { let rect = self.rects[i]; let is_active = method == data; let rr = RoundedRect::new(rect.x0, rect.y0, rect.x1, rect.y1, 8.0); ctx.fill(rr, &theme.segmented_bg); ctx.stroke(rr, &theme.segmented_border, 1.5); if is_active { ctx.fill(rr, &GREEN_BG); ctx.stroke(rr, &GREEN, 1.5); } let text_color = if is_active { GREEN } else { theme.segmented_text }; let layout = ctx .text() .new_text_layout(label.clone()) .font(FontFamily::SYSTEM_UI, 13.0) .text_color(text_color) .build() .unwrap(); let text_x = rect.x0 + (rect.width() - layout.size().width) / 2.0 + 7.0; let text_y = rect.y0 + (rect.height() - layout.size().height) / 2.0 - 1.0; ctx.draw_text(&layout, (text_x, text_y)); let dot_cx = text_x - 14.0; let dot_cy = rect.y0 + rect.height() / 2.0; let ring_color = if is_active { GREEN } else { theme.segmented_ring }; ctx.stroke(Circle::new((dot_cx, dot_cy), 5.0), &ring_color, 1.5); if is_active { ctx.fill(Circle::new((dot_cx, dot_cy), 2.5), &GREEN); } } } } pub(super) struct U32SegmentedControl { options: Vec<(&'static str, u32)>, rects: Vec, } impl U32SegmentedControl { pub(super) fn new(options: Vec<(&'static str, u32)>) -> Self { Self { options, rects: Vec::new(), } } } impl Widget for U32SegmentedControl { fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut u32, _env: &Env) { if let Event::MouseDown(mouse) = event { for (i, rect) in self.rects.iter().enumerate() { if rect.contains(mouse.pos) { *data = self.options[i].1; ctx.request_paint(); break; } } } } fn lifecycle(&mut self, _ctx: &mut LifeCycleCtx, _event: &LifeCycle, _data: &u32, _env: &Env) {} fn update(&mut self, ctx: &mut UpdateCtx, old_data: &u32, data: &u32, _env: &Env) { if old_data != data { ctx.request_layout(); } } fn layout( &mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints, _data: &u32, _env: &Env, ) -> Size { let w = bc.max().width; let h = 34.0; let n = self.options.len() as f64; let gap = 8.0; let btn_w = (w - gap * (n - 1.0)) / n; self.rects = (0..self.options.len()) .map(|i| { let x = i as f64 * (btn_w + gap); Rect::new(x, 0.0, x + btn_w, h) }) .collect(); Size::new(w, h) } fn paint(&mut self, ctx: &mut PaintCtx, data: &u32, env: &Env) { let theme = theme_from_env(env); for (i, (label, value)) in self.options.iter().enumerate() { let rect = self.rects[i]; let is_active = value == data; let rr = RoundedRect::new(rect.x0, rect.y0, rect.x1, rect.y1, 8.0); ctx.fill(rr, &theme.segmented_bg); ctx.stroke(rr, &theme.segmented_border, 1.5); if is_active { ctx.fill(rr, &GREEN_BG); ctx.stroke(rr, &GREEN, 1.5); } let text_color = if is_active { GREEN } else { theme.segmented_text }; let layout = ctx .text() .new_text_layout(*label) .font(FontFamily::SYSTEM_UI, 13.0) .text_color(text_color) .build() .unwrap(); let text_x = rect.x0 + (rect.width() - layout.size().width) / 2.0 + 7.0; let text_y = rect.y0 + (rect.height() - layout.size().height) / 2.0 - 1.0; ctx.draw_text(&layout, (text_x, text_y)); let dot_cx = text_x - 14.0; let dot_cy = rect.y0 + rect.height() / 2.0; let ring_color = if is_active { GREEN } else { theme.segmented_ring }; ctx.stroke(Circle::new((dot_cx, dot_cy), 5.0), &ring_color, 1.5); if is_active { ctx.fill(Circle::new((dot_cx, dot_cy), 2.5), &GREEN); } } } } pub(super) struct TabBar { tab_rects: Vec, } impl TabBar { pub(super) fn new() -> Self { Self { tab_rects: Vec::new(), } } fn draw_icon_general(ctx: &mut PaintCtx, cx: f64, cy: f64, color: &Color) { for (w, y_off) in [(14.0, -4.5), (9.0, 0.0), (11.0, 4.5)] { let rr = RoundedRect::new( cx - 7.0, cy + y_off - 1.5, cx - 7.0 + w, cy + y_off + 1.5, 1.5, ); ctx.fill(rr, color); } } fn draw_icon_apps(ctx: &mut PaintCtx, cx: f64, cy: f64, color: &Color) { for (dx, dy) in [(-4.5, -4.5), (1.5, -4.5), (-4.5, 1.5), (1.5, 1.5)] { let rr = RoundedRect::new(cx + dx, cy + dy, cx + dx + 4.5, cy + dy + 4.5, 1.0); ctx.fill(rr, color); } } fn draw_icon_text_expansion(ctx: &mut PaintCtx, cx: f64, cy: f64, color: &Color) { let mut brace = BezPath::new(); brace.move_to((cx - 9.0, cy - 4.5)); brace.line_to((cx - 11.0, cy - 4.5)); brace.line_to((cx - 11.0, cy - 1.5)); brace.line_to((cx - 13.0, cy)); brace.line_to((cx - 11.0, cy + 1.5)); brace.line_to((cx - 11.0, cy + 4.5)); brace.line_to((cx - 9.0, cy + 4.5)); ctx.stroke(brace, color, 1.3); let mut arrow = BezPath::new(); arrow.move_to((cx - 6.0, cy)); arrow.line_to((cx + 2.0, cy)); arrow.move_to((cx - 1.0, cy - 2.5)); arrow.line_to((cx + 2.5, cy)); arrow.line_to((cx - 1.0, cy + 2.5)); ctx.stroke(arrow, color, 1.3); let mut brace2 = BezPath::new(); brace2.move_to((cx + 5.0, cy - 4.5)); brace2.line_to((cx + 7.0, cy - 4.5)); brace2.line_to((cx + 7.0, cy - 1.5)); brace2.line_to((cx + 9.0, cy)); brace2.line_to((cx + 7.0, cy + 1.5)); brace2.line_to((cx + 7.0, cy + 4.5)); brace2.line_to((cx + 5.0, cy + 4.5)); ctx.stroke(brace2, color, 1.3); } } impl Widget for TabBar { fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut u32, _env: &Env) { if let Event::MouseDown(mouse) = event { for (i, rect) in self.tab_rects.iter().enumerate() { if rect.contains(mouse.pos) { *data = i as u32; ctx.request_paint(); break; } } } } fn lifecycle(&mut self, _ctx: &mut LifeCycleCtx, _event: &LifeCycle, _data: &u32, _env: &Env) {} fn update(&mut self, ctx: &mut UpdateCtx, old_data: &u32, data: &u32, _env: &Env) { if old_data != data { ctx.request_paint(); } } fn layout( &mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints, _data: &u32, _env: &Env, ) -> Size { let w = bc.max().width; let h = 58.0; let tab_w = w / 3.0; self.tab_rects = (0..3) .map(|i| Rect::new(i as f64 * tab_w, 0.0, (i + 1) as f64 * tab_w, h)) .collect(); Size::new(w, h) } fn paint(&mut self, ctx: &mut PaintCtx, data: &u32, env: &Env) { use druid::kurbo::Line; let theme = theme_from_env(env); let size = ctx.size(); ctx.stroke( Line::new((0.0, size.height), (size.width, size.height)), &theme.tab_border, 0.5, ); let labels = [t("tab.general"), t("tab.apps"), t("tab.text_expansion")]; let icon_fns: [fn(&mut PaintCtx, f64, f64, &Color); 3] = [ TabBar::draw_icon_general, TabBar::draw_icon_apps, TabBar::draw_icon_text_expansion, ]; for (i, rect) in self.tab_rects.iter().enumerate() { let is_active = i as u32 == *data; let color = if is_active { GREEN } else { theme.tab_inactive }; let cx = rect.x0 + rect.width() / 2.0; let icon_cy = rect.y0 + 18.0; icon_fns[i](ctx, cx, icon_cy, &color); let layout = ctx .text() .new_text_layout(labels[i]) .font(FontFamily::SYSTEM_UI, 10.0) .text_color(color.clone()) .build() .unwrap(); ctx.draw_text(&layout, (cx - layout.size().width / 2.0, icon_cy + 11.0)); if is_active { ctx.fill( Rect::new(rect.x0, size.height - 2.0, rect.x1, size.height), &GREEN, ); } } } } pub(super) struct KeyBadge { label: String, } impl KeyBadge { pub(super) fn new(label: impl Into) -> Self { Self { label: label.into(), } } } impl Widget<()> for KeyBadge { fn event(&mut self, _ctx: &mut EventCtx, _event: &Event, _data: &mut (), _env: &Env) {} fn lifecycle(&mut self, _ctx: &mut LifeCycleCtx, _event: &LifeCycle, _data: &(), _env: &Env) {} fn update(&mut self, _ctx: &mut UpdateCtx, _old: &(), _data: &(), _env: &Env) {} fn layout( &mut self, _ctx: &mut LayoutCtx, _bc: &BoxConstraints, _data: &(), _env: &Env, ) -> Size { let char_w = self.label.chars().count() as f64 * 8.0; Size::new((char_w + 14.0).max(26.0), 24.0) } fn paint(&mut self, ctx: &mut PaintCtx, _data: &(), env: &Env) { let theme = theme_from_env(env); let size = ctx.size(); let rr = RoundedRect::new(0.0, 0.0, size.width, size.height, 5.0); ctx.fill(rr, &theme.badge_bg); ctx.stroke(rr, &theme.badge_border, 0.5); let layout = ctx .text() .new_text_layout(self.label.clone()) .font(FontFamily::SYSTEM_UI, 12.0) .text_color(theme.badge_text) .build() .unwrap(); let lw = layout.size().width; let lh = layout.size().height; ctx.draw_text(&layout, ((size.width - lw) / 2.0, (size.height - lh) / 2.0)); } } pub(super) struct HotkeyBadgesWidget { badges: Vec>, last_display: String, recording: bool, pending_display: String, } impl HotkeyBadgesWidget { pub(super) fn new() -> Self { Self { badges: Vec::new(), last_display: String::new(), recording: false, pending_display: String::new(), } } fn rebuild_badges(&mut self, display: &str) { self.badges = display .split_whitespace() .map(|token| WidgetPod::new(KeyBadge::new(token))) .collect(); self.last_display = display.to_string(); } } impl Widget for HotkeyBadgesWidget { fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut UIDataAdapter, env: &Env) { if self.recording { match event { Event::KeyDown(key_event) => { use druid::KbKey; let mut parts: Vec<&str> = Vec::new(); if key_event.mods.ctrl() { parts.push("⌃"); } if key_event.mods.shift() { parts.push("⇧"); } if key_event.mods.alt() { parts.push("⌥"); } if key_event.mods.meta() { parts.push("⌘"); } let key_str = match &key_event.key { KbKey::Character(s) if s == " " => "Space".to_string(), KbKey::Character(s) => s.to_uppercase(), KbKey::Enter => "Enter".to_string(), KbKey::Tab => "Tab".to_string(), KbKey::Backspace => "Del".to_string(), KbKey::Escape => "Esc".to_string(), _ => String::new(), }; if !key_str.is_empty() { self.pending_display = parts.join(" ") + if parts.is_empty() { "" } else { " " } + &key_str; } ctx.request_paint(); ctx.set_handled(); } Event::KeyUp(key_event) => { use druid::KbKey; let is_modifier_only = matches!( &key_event.key, KbKey::Control | KbKey::Shift | KbKey::Alt | KbKey::Meta | KbKey::CapsLock | KbKey::Super ); if !is_modifier_only && !self.pending_display.is_empty() { data.super_key = key_event.mods.meta(); data.ctrl_key = key_event.mods.ctrl(); data.alt_key = key_event.mods.alt(); data.shift_key = key_event.mods.shift(); data.letter_key = match &key_event.key { KbKey::Character(s) => super::format_letter_key(s.chars().last()), KbKey::Enter => "Enter".to_string(), KbKey::Tab => "Tab".to_string(), KbKey::Backspace => "Delete".to_string(), KbKey::Escape => "Esc".to_string(), _ => data.letter_key.clone(), }; self.recording = false; ctx.resign_focus(); ctx.request_layout(); } ctx.set_handled(); } _ => {} } return; } if let Event::MouseDown(mouse) = event { if mouse.count == 2 { self.recording = true; self.pending_display = String::new(); ctx.request_focus(); ctx.request_layout(); ctx.set_handled(); return; } } for badge in &mut self.badges { badge.event(ctx, event, &mut (), env); } } fn lifecycle( &mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &UIDataAdapter, env: &Env, ) { if let LifeCycle::WidgetAdded = event { self.rebuild_badges(&data.hotkey_display); } for badge in &mut self.badges { badge.lifecycle(ctx, event, &(), env); } } fn update( &mut self, ctx: &mut UpdateCtx, _old: &UIDataAdapter, data: &UIDataAdapter, env: &Env, ) { if data.hotkey_display != self.last_display { self.rebuild_badges(&data.hotkey_display); ctx.children_changed(); return; } for badge in &mut self.badges { badge.update(ctx, &(), env); } } fn layout( &mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, _data: &UIDataAdapter, env: &Env, ) -> Size { if self.recording { return Size::new(150.0, 28.0); } let gap = 4.0; let mut x = 0.0; let mut max_h = 0.0f64; let loose = bc.loosen(); for badge in &mut self.badges { let s = badge.layout(ctx, &loose, &(), env); badge.set_origin(ctx, Point::new(x, 0.0)); x += s.width + gap; max_h = max_h.max(s.height); } Size::new((x - gap).max(0.0), max_h.max(24.0)) } fn paint(&mut self, ctx: &mut PaintCtx, data: &UIDataAdapter, env: &Env) { let theme = theme_from_env(env); if self.recording { let size = ctx.size(); let label = if self.pending_display.is_empty() { t("shortcut.type_prompt").to_string() } else { self.pending_display.clone() }; let text_color = if self.pending_display.is_empty() { theme.input_placeholder } else { theme.input_text }; let layout = ctx .text() .new_text_layout(label) .font(druid::piet::FontFamily::SYSTEM_UI, 12.0) .text_color(text_color) .build() .unwrap(); ctx.draw_text( &layout, ( (size.width - layout.size().width) / 2.0, (size.height - layout.size().height) / 2.0, ), ); return; } for badge in &mut self.badges { badge.paint(ctx, &(), env); } } } pub(super) struct MacroListWidget { row_rects: Vec, } const MACRO_ROW_HEIGHT: f64 = 44.0; const MACRO_HEADER_HEIGHT: f64 = 30.0; impl MacroListWidget { pub(super) fn new() -> Self { Self { row_rects: Vec::new(), } } } impl Widget for MacroListWidget { fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut UIDataAdapter, _env: &Env) { if let Event::MouseDown(mouse) = event { for (i, rect) in self.row_rects.iter().enumerate() { if rect.contains(mouse.pos) { data.selected_macro_index = i as i32; ctx.request_paint(); break; } } } } fn lifecycle( &mut self, _ctx: &mut LifeCycleCtx, _event: &LifeCycle, _data: &UIDataAdapter, _env: &Env, ) { } fn update( &mut self, ctx: &mut UpdateCtx, old_data: &UIDataAdapter, data: &UIDataAdapter, _env: &Env, ) { if old_data.macro_table != data.macro_table { ctx.request_layout(); } else if old_data.selected_macro_index != data.selected_macro_index { ctx.request_paint(); } } fn layout( &mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &UIDataAdapter, _env: &Env, ) -> Size { let n = data.macro_table.len(); let w = bc.max().width; self.row_rects = (0..n) .map(|i| { let y = MACRO_HEADER_HEIGHT + i as f64 * MACRO_ROW_HEIGHT; Rect::new(0.0, y, w, y + MACRO_ROW_HEIGHT) }) .collect(); Size::new( w, MACRO_HEADER_HEIGHT + (n as f64 * MACRO_ROW_HEIGHT).max(0.0), ) } fn paint(&mut self, ctx: &mut PaintCtx, data: &UIDataAdapter, env: &Env) { let theme = theme_from_env(env); let size = ctx.size(); let shorthand_header = ctx .text() .new_text_layout(t("macro.shorthand")) .font(FontFamily::SYSTEM_UI, 11.0) .text_color(theme.text_secondary) .build() .unwrap(); ctx.draw_text( &shorthand_header, ( 14.0, (MACRO_HEADER_HEIGHT - shorthand_header.size().height) / 2.0, ), ); let replacement_header = ctx .text() .new_text_layout(t("macro.replacement")) .font(FontFamily::SYSTEM_UI, 11.0) .text_color(theme.text_secondary) .build() .unwrap(); let to_x = size.width / 2.0 + 20.0; ctx.draw_text( &replacement_header, ( to_x, (MACRO_HEADER_HEIGHT - replacement_header.size().height) / 2.0, ), ); ctx.fill( Rect::new( 0.0, MACRO_HEADER_HEIGHT - 0.5, size.width, MACRO_HEADER_HEIGHT, ), &theme.divider, ); for (i, entry) in data.macro_table.iter().enumerate() { let rect = self.row_rects[i]; let is_selected = data.selected_macro_index == i as i32; if is_selected { ctx.fill( RoundedRect::new(rect.x0, rect.y0, rect.x1, rect.y1, 0.0), &theme.list_row_hover, ); } if i > 0 { ctx.fill( Rect::new(14.0, rect.y0, size.width - 14.0, rect.y0 + 0.5), &theme.divider, ); } let from_layout = ctx .text() .new_text_layout(entry.from.clone()) .font(FontFamily::SYSTEM_UI, 13.0) .text_color(theme.text_primary) .build() .unwrap(); ctx.draw_text( &from_layout, ( 14.0, rect.y0 + (MACRO_ROW_HEIGHT - from_layout.size().height) / 2.0, ), ); let arrow_layout = ctx .text() .new_text_layout("→") .font(FontFamily::SYSTEM_UI, 12.0) .text_color(theme.text_secondary) .build() .unwrap(); let arrow_x = size.width / 2.0 - arrow_layout.size().width / 2.0; ctx.draw_text( &arrow_layout, ( arrow_x, rect.y0 + (MACRO_ROW_HEIGHT - arrow_layout.size().height) / 2.0, ), ); let to_layout = ctx .text() .new_text_layout(entry.to.clone()) .font(FontFamily::SYSTEM_UI, 13.0) .text_color(theme.text_primary) .build() .unwrap(); ctx.draw_text( &to_layout, ( to_x, rect.y0 + (MACRO_ROW_HEIGHT - to_layout.size().height) / 2.0, ), ); } } } pub(super) struct CombinedAppEntry { pub(super) display_name: String, pub(super) full_name: String, pub(super) is_vn: bool, } pub(super) struct AppsListWidget { row_rects: Vec, badge_rects: Vec, avatar_colors: Vec, icon_cache: HashMap, u32, u32)>>, } const ROW_HEIGHT: f64 = 52.0; impl AppsListWidget { pub(super) fn new() -> Self { Self { row_rects: Vec::new(), badge_rects: Vec::new(), avatar_colors: vec![ Color::rgb8(196, 60, 48), Color::rgb8(72, 163, 101), Color::rgb8(58, 115, 199), Color::rgb8(133, 86, 178), Color::rgb8(203, 131, 46), ], icon_cache: HashMap::new(), } } pub(super) fn build_entries(data: &UIDataAdapter) -> Vec { let to_entry = |e: &crate::ui::data::AppEntry, is_vn: bool| CombinedAppEntry { display_name: std::path::Path::new(&e.name) .file_stem() .and_then(|s| s.to_str()) .unwrap_or(&e.name) .to_string(), full_name: e.name.clone(), is_vn, }; let mut entries: Vec = data .vn_apps .iter() .map(|e| to_entry(e, true)) .chain(data.en_apps.iter().map(|e| to_entry(e, false))) .collect(); entries.sort_by(|a, b| { a.display_name .to_lowercase() .cmp(&b.display_name.to_lowercase()) }); entries } fn initials(name: &str) -> String { let mut chars = name.chars().filter(|c| c.is_alphabetic()); let first = chars.next().map(|c| c.to_ascii_uppercase()).unwrap_or('?'); match chars.next().map(|c| c.to_ascii_uppercase()) { Some(s) => format!("{}{}", first, s), None => format!("{}", first), } } } impl Widget for AppsListWidget { fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut UIDataAdapter, _env: &Env) { if let Event::MouseDown(mouse) = event { let entries = Self::build_entries(data); for i in 0..entries.len() { let vi_rect = self.badge_rects.get(i * 2); let en_rect = self.badge_rects.get(i * 2 + 1); let clicked_vi = vi_rect.map_or(false, |r| r.contains(mouse.pos)); let clicked_en = en_rect.map_or(false, |r| r.contains(mouse.pos)); if clicked_vi || clicked_en { let entry = &entries[i]; let want_vn = clicked_vi; let already_correct = entry.is_vn == want_vn; if !already_correct { ctx.submit_command( TOGGLE_APP_MODE .with(entry.full_name.clone()) .to(druid::Target::Global), ); } return; } } for (i, rect) in self.row_rects.iter().enumerate() { if rect.contains(mouse.pos) { data.selected_app_index = i as i32; ctx.request_paint(); break; } } } } fn lifecycle( &mut self, _ctx: &mut LifeCycleCtx, _event: &LifeCycle, _data: &UIDataAdapter, _env: &Env, ) { } fn update( &mut self, ctx: &mut UpdateCtx, old_data: &UIDataAdapter, data: &UIDataAdapter, _env: &Env, ) { if old_data.vn_apps != data.vn_apps || old_data.en_apps != data.en_apps { ctx.request_layout(); } else if old_data.selected_app_index != data.selected_app_index { ctx.request_paint(); } } fn layout( &mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &UIDataAdapter, _env: &Env, ) -> Size { let entries = Self::build_entries(data); let w = bc.max().width; let opt_w = 28.0_f64; let bh = 22.0_f64; let gap = 2.0_f64; self.row_rects = entries .iter() .enumerate() .map(|(i, _)| Rect::new(0.0, i as f64 * ROW_HEIGHT, w, (i + 1) as f64 * ROW_HEIGHT)) .collect(); self.badge_rects = entries .iter() .enumerate() .flat_map(|(i, _)| { let toggle_w = opt_w * 2.0 + gap; let toggle_x = w - toggle_w - 14.0; let toggle_y = i as f64 * ROW_HEIGHT + (ROW_HEIGHT - bh) / 2.0; [ Rect::new(toggle_x, toggle_y, toggle_x + opt_w, toggle_y + bh), Rect::new( toggle_x + opt_w + gap, toggle_y, toggle_x + toggle_w, toggle_y + bh, ), ] }) .collect(); Size::new(w, (entries.len() as f64 * ROW_HEIGHT).max(0.0)) } fn paint(&mut self, ctx: &mut PaintCtx, data: &UIDataAdapter, env: &Env) { let theme = theme_from_env(env); let entries = Self::build_entries(data); let size = ctx.size(); for entry in &entries { if !self.icon_cache.contains_key(&entry.full_name) { let icon = crate::platform::get_app_icon_rgba(&entry.full_name, 72); self.icon_cache.insert(entry.full_name.clone(), icon); } } for (i, entry) in entries.iter().enumerate() { let rect = self.row_rects[i]; let is_selected = data.selected_app_index == i as i32; if is_selected { ctx.fill( RoundedRect::new(rect.x0, rect.y0, rect.x1, rect.y1, 0.0), &theme.list_row_hover, ); } if i > 0 { ctx.fill( Rect::new(54.0, rect.y0, size.width - 14.0, rect.y0 + 0.5), &theme.divider, ); } let avatar_x = 14.0; let avatar_y = rect.y0 + (ROW_HEIGHT - 36.0) / 2.0; let avatar_rect = RoundedRect::new(avatar_x, avatar_y, avatar_x + 36.0, avatar_y + 36.0, 8.0); let icon_drawn = if let Some(Some((pixels, w, h))) = self.icon_cache.get(&entry.full_name) { if let Ok(image) = ctx.make_image(*w as usize, *h as usize, pixels, ImageFormat::RgbaPremul) { let dst = Rect::new(avatar_x, avatar_y, avatar_x + 36.0, avatar_y + 36.0); ctx.draw_image(&image, dst, InterpolationMode::Bilinear); true } else { false } } else { false }; if !icon_drawn { ctx.fill( avatar_rect, &self.avatar_colors[i % self.avatar_colors.len()], ); let initials = Self::initials(&entry.display_name); let init_layout = ctx .text() .new_text_layout(initials) .font(FontFamily::SYSTEM_UI, 13.0) .text_color(Color::WHITE) .build() .unwrap(); ctx.draw_text( &init_layout, ( avatar_x + (36.0 - init_layout.size().width) / 2.0, avatar_y + (36.0 - init_layout.size().height) / 2.0, ), ); } let name_layout = ctx .text() .new_text_layout(entry.display_name.clone()) .font(FontFamily::SYSTEM_UI, 14.0) .text_color(theme.text_primary) .build() .unwrap(); ctx.draw_text( &name_layout, ( 60.0, rect.y0 + (ROW_HEIGHT - name_layout.size().height) / 2.0, ), ); let opt_w = 28.0_f64; let bh = 22.0_f64; let gap = 2.0_f64; let toggle_w = opt_w * 2.0 + gap; let toggle_x = size.width - toggle_w - 14.0; let toggle_y = rect.y0 + (ROW_HEIGHT - bh) / 2.0; for (j, (label, is_active, active_bg, active_border)) in [ ("VI", entry.is_vn, BADGE_VI_BG, BADGE_VI_BORDER), ("EN", !entry.is_vn, BADGE_EN_BG, BADGE_EN_BORDER), ] .iter() .enumerate() { let opt_x = toggle_x + j as f64 * (opt_w + gap); let corners = if j == 0 { [5.0, 0.0, 0.0, 5.0] } else { [0.0, 5.0, 5.0, 0.0] }; let opt_rr = druid::kurbo::RoundedRectRadii::new( corners[0], corners[1], corners[2], corners[3], ); let opt_rect = RoundedRect::from_rect( Rect::new(opt_x, toggle_y, opt_x + opt_w, toggle_y + bh), opt_rr, ); if *is_active { ctx.fill(opt_rect, active_bg); ctx.stroke(opt_rect, active_border, 1.0); } else { ctx.fill(opt_rect, &Color::rgba8(0, 0, 0, 0)); ctx.stroke(opt_rect, &theme.segmented_border, 1.0); } let text_color = if *is_active { *active_border } else { theme.segmented_text }; let opt_layout = ctx .text() .new_text_layout(*label) .font(FontFamily::SYSTEM_UI, 11.0) .text_color(text_color) .build() .unwrap(); ctx.draw_text( &opt_layout, ( opt_x + (opt_w - opt_layout.size().width) / 2.0, toggle_y + (bh - opt_layout.size().height) / 2.0, ), ); } } } } pub(super) struct ShortcutCaptureWidget { focused: bool, last_mods_super: bool, last_mods_ctrl: bool, last_mods_alt: bool, last_mods_shift: bool, } impl ShortcutCaptureWidget { pub(super) fn new() -> Self { Self { focused: false, last_mods_super: false, last_mods_ctrl: false, last_mods_alt: false, last_mods_shift: false, } } } impl Widget for ShortcutCaptureWidget { fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut UIDataAdapter, _env: &Env) { match event { Event::MouseDown(_) => { ctx.request_focus(); data.pending_shortcut_display = String::new(); data.pending_shortcut_super = false; data.pending_shortcut_ctrl = false; data.pending_shortcut_alt = false; data.pending_shortcut_shift = false; data.pending_shortcut_letter = String::new(); ctx.set_handled(); } Event::KeyDown(key_event) if self.focused => { use druid::KbKey; self.last_mods_super = key_event.mods.meta(); self.last_mods_ctrl = key_event.mods.ctrl(); self.last_mods_alt = key_event.mods.alt(); self.last_mods_shift = key_event.mods.shift(); let mut parts: Vec<&str> = Vec::new(); if key_event.mods.ctrl() { parts.push("⌃"); } if key_event.mods.shift() { parts.push("⇧"); } if key_event.mods.alt() { parts.push("⌥"); } if key_event.mods.meta() { parts.push("⌘"); } let key_str = match &key_event.key { KbKey::Character(s) if s == " " => "Space".to_string(), KbKey::Character(s) => s.to_uppercase(), KbKey::Enter => "Enter".to_string(), KbKey::Tab => "Tab".to_string(), KbKey::Backspace => "Delete".to_string(), KbKey::Escape => "Esc".to_string(), _ => String::new(), }; if !key_str.is_empty() { data.pending_shortcut_display = parts.join(" ") + if parts.is_empty() { "" } else { " " } + &key_str; data.pending_shortcut_super = self.last_mods_super; data.pending_shortcut_ctrl = self.last_mods_ctrl; data.pending_shortcut_alt = self.last_mods_alt; data.pending_shortcut_shift = self.last_mods_shift; data.pending_shortcut_letter = key_str; } ctx.request_paint(); ctx.set_handled(); } Event::KeyUp(key_event) if self.focused => { use druid::KbKey; let is_modifier_only = matches!( &key_event.key, KbKey::Control | KbKey::Shift | KbKey::Alt | KbKey::Meta | KbKey::CapsLock | KbKey::Super ); if is_modifier_only && !data.pending_shortcut_display.is_empty() { data.pending_shortcut_super = self.last_mods_super; data.pending_shortcut_ctrl = self.last_mods_ctrl; data.pending_shortcut_alt = self.last_mods_alt; data.pending_shortcut_shift = self.last_mods_shift; self.focused = false; ctx.resign_focus(); } ctx.set_handled(); } _ => {} } } fn lifecycle( &mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, _data: &UIDataAdapter, _env: &Env, ) { match event { LifeCycle::FocusChanged(true) => { self.focused = true; ctx.request_paint(); } LifeCycle::FocusChanged(false) => { self.focused = false; ctx.request_paint(); } _ => {} } } fn update( &mut self, ctx: &mut UpdateCtx, _old: &UIDataAdapter, _data: &UIDataAdapter, _env: &Env, ) { ctx.request_paint(); } fn layout( &mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints, _data: &UIDataAdapter, _env: &Env, ) -> Size { Size::new(bc.max().width, 52.0) } fn paint(&mut self, ctx: &mut PaintCtx, data: &UIDataAdapter, env: &Env) { let theme = theme_from_env(env); let size = ctx.size(); let display = if self.focused { if data.pending_shortcut_display.is_empty() { t("shortcut.press_keys").to_string() } else { data.pending_shortcut_display.clone() } } else { t("shortcut.click_to_record").to_string() }; let text_color = if self.focused && data.pending_shortcut_display.is_empty() { theme.input_placeholder } else { theme.input_text }; let layout = ctx .text() .new_text_layout(display) .font(FontFamily::SYSTEM_UI, 13.0) .text_color(text_color) .build() .unwrap(); ctx.draw_text( &layout, ( (size.width - layout.size().width) / 2.0, (size.height - layout.size().height) / 2.0, ), ); } }