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