├── rustfmt.toml ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── windows.yml ├── .gitignore ├── justfile ├── Cargo.toml ├── src ├── configuration.rs └── main.rs ├── komokana.example.yaml ├── LICENSE.md ├── wix ├── License.rtf └── main.wxs └── README.md /rustfmt.toml: -------------------------------------------------------------------------------- 1 | imports_granularity = "Item" -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: LGUG2Z 2 | ko_fi: lgug2z 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | /dist 3 | /target 4 | CHANGELOG.md 5 | *.iml 6 | *.ipr 7 | *.iws 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | assignees: 8 | - "LGUG2Z" 9 | commit-message: 10 | prefix: chore 11 | include: scope 12 | 13 | - package-ecosystem: "cargo" 14 | directory: "/" 15 | schedule: 16 | interval: "monthly" 17 | assignees: 18 | - "LGUG2Z" 19 | commit-message: 20 | prefix: chore 21 | include: scope 22 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | set shell := ["cmd.exe", "/C"] 2 | export RUST_BACKTRACE := "full" 3 | 4 | clean: 5 | cargo clean 6 | 7 | fmt: 8 | cargo +nightly fmt 9 | cargo +nightly clippy 10 | prettier --write README.md 11 | 12 | install: 13 | cargo +stable install --path . --locked 14 | 15 | run: 16 | cargo +stable run --bin komokana --locked 17 | 18 | warn $RUST_LOG="warn": 19 | just run 20 | 21 | info $RUST_LOG="info": 22 | just run 23 | 24 | debug $RUST_LOG="debug": 25 | just run 26 | 27 | trace $RUST_LOG="trace": 28 | just run -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "komokana" 3 | version = "0.1.6" 4 | description = "A daemon that switches kanata keyboard layers in response to komorebi events" 5 | categories = ["cli", "tiling-window-manager", "windows", "keyboard"] 6 | repository = "https://github.com/LGUG2Z/komokana" 7 | edition = "2021" 8 | 9 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 10 | 11 | [dependencies] 12 | clap = { version = "4", features = ["derive", "wrap_help"] } 13 | color-eyre = "0.6" 14 | dirs = "6" 15 | env_logger = "0.11" 16 | json_dotpath = "1" 17 | log = "0.4" 18 | parking_lot = "0.12" 19 | serde = { version = "1", features = ["derive"] } 20 | serde_json = "1" 21 | serde_yaml = "0.9" 22 | komorebi-client = { git = "https://github.com/LGUG2Z/komorebi" } 23 | 24 | [dependencies.windows] 25 | version = "0.62" 26 | features = ["Win32_UI_Input_KeyboardAndMouse"] 27 | -------------------------------------------------------------------------------- /src/configuration.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::use_self)] 2 | 3 | use serde::Deserialize; 4 | use serde::Serialize; 5 | 6 | pub type Configuration = Vec; 7 | 8 | #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 9 | pub struct Entry { 10 | pub exe: String, 11 | pub target_layer: String, 12 | pub title_overrides: Option>, 13 | pub virtual_key_overrides: Option>, 14 | pub virtual_key_ignores: Option>, 15 | } 16 | 17 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 18 | pub struct TitleOverride { 19 | pub title: String, 20 | pub strategy: Strategy, 21 | pub target_layer: String, 22 | } 23 | 24 | #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 25 | pub struct VirtualKeyOverride { 26 | pub virtual_key_code: i32, 27 | pub targer_layer: String, 28 | } 29 | 30 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 31 | #[serde(rename_all = "snake_case")] 32 | pub enum Strategy { 33 | StartsWith, 34 | EndsWith, 35 | Contains, 36 | Equals, 37 | } 38 | -------------------------------------------------------------------------------- /komokana.example.yaml: -------------------------------------------------------------------------------- 1 | - exe: "firefox.exe" # when a window with this exe is active 2 | target_layer: "firefox" # switch to this layer, a vim-like layer just for browsing! 3 | title_overrides: # unless... 4 | - title: "Slack |" # the window title matches this 5 | # valid matching strategies are: starts_with, ends_with, contains and equals 6 | strategy: "starts_with" # matching with this matching strategy 7 | target_layer: "qwerty" # if it does, then switch to this layer for chatting 8 | - title: "Mozilla Firefox" # new firefox tab, we'll probably want to switch to qwerty mode to type a url! 9 | strategy: "equals" 10 | target_layer: "qwerty" 11 | virtual_key_overrides: # unless... 12 | # list of key codes and their decimal values here: https://cherrytree.at/misc/vk.htm 13 | - virtual_key_code: 18 # this key is held down (alt in this case) when the window becomes active 14 | targer_layer: "qwerty" # if it is, then switch to this layer, so that we can continue switching window focus with alt+hjkl 15 | virtual_key_ignores: # alternatively 16 | - 18 # if this key is held down (alt in this case), then don't make any layer switches 17 | 18 | # your normal layer might have a tap-hold on j since it's a such convenient and ergonomic key 19 | # but it sucks to be in vim, holding down j to move down and have nothing happen because of the hold... 20 | # no worries! let's just switch to a layer which removes the tap-hold on the j when we are in windows 21 | # where we use vim or vim editing extensions! 22 | - exe: "WindowsTerminal.exe" 23 | target_layer: "editor" 24 | - exe: "idea64.exe" 25 | target_layer: "editor" 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Komorebi License 2 | 3 | Version 2.0.0 4 | 5 | ## Acceptance 6 | 7 | In order to get any license under these terms, you must agree 8 | to them as both strict obligations and conditions to all 9 | your licenses. 10 | 11 | ## Copyright License 12 | 13 | The licensor grants you a copyright license for the software 14 | to do everything you might do with the software that would 15 | otherwise infringe the licensor's copyright in it for any 16 | permitted purpose. However, you may only distribute the source 17 | code of the software according to the [Distribution License]( 18 | #distribution-license), you may only make changes according 19 | to the [Changes License](#changes-license), and you may not 20 | otherwise distribute the software or new works based on the 21 | software. 22 | 23 | ## Distribution License 24 | 25 | The licensor grants you an additional copyright license to 26 | distribute copies of the source code of the software. Your 27 | license to distribute covers distributing the source code of 28 | the software with changes permitted by the [Changes License]( 29 | #changes-license). 30 | 31 | ## Changes License 32 | 33 | The licensor grants you an additional copyright license to 34 | make changes for any permitted purpose. 35 | 36 | ## Patent License 37 | 38 | The licensor grants you a patent license for the software that 39 | covers patent claims the licensor can license, or becomes able 40 | to license, that you would infringe by using the software. 41 | 42 | ## Personal Uses 43 | 44 | Personal use for research, experiment, and testing for 45 | the benefit of public knowledge, personal study, private 46 | entertainment, hobby projects, amateur pursuits, or religious 47 | observance, without any anticipated commercial application, 48 | is use for a permitted purpose. 49 | 50 | ## Fair Use 51 | 52 | You may have "fair use" rights for the software under the 53 | law. These terms do not limit them. 54 | 55 | ## No Other Rights 56 | 57 | These terms do not allow you to sublicense or transfer any of 58 | your licenses to anyone else, or prevent the licensor from 59 | granting licenses to anyone else. These terms do not imply 60 | any other licenses. 61 | 62 | ## Patent Defense 63 | 64 | If you make any written claim that the software infringes or 65 | contributes to infringement of any patent, your patent license 66 | for the software granted under these terms ends immediately. If 67 | your company makes such a claim, your patent license ends 68 | immediately for work on behalf of your company. 69 | 70 | ## Violations 71 | 72 | The first time you are notified in writing that you have 73 | violated any of these terms, or done anything with the software 74 | not covered by your licenses, your licenses can nonetheless 75 | continue if you come into full compliance with these terms, 76 | and take practical steps to correct past violations, within 77 | 32 days of receiving notice. Otherwise, all your licenses 78 | end immediately. 79 | 80 | ## No Liability 81 | 82 | ***As far as the law allows, the software comes as is, without 83 | any warranty or condition, and the licensor will not be liable 84 | to you for any damages arising out of these terms or the use 85 | or nature of the software, under any kind of legal claim.*** 86 | 87 | ## Definitions 88 | 89 | The **licensor** is the individual or entity offering these 90 | terms, and the **software** is the software the licensor makes 91 | available under these terms. 92 | 93 | **You** refers to the individual or entity agreeing to these 94 | terms. 95 | 96 | **Your company** is any legal entity, sole proprietorship, 97 | or other kind of organization that you work for, plus all 98 | organizations that have control over, are under the control of, 99 | or are under common control with that organization. **Control** 100 | means ownership of substantially all the assets of an entity, 101 | or the power to direct its management and policies by vote, 102 | contract, or otherwise. Control can be direct or indirect. 103 | 104 | **Your licenses** are all the licenses granted to you for the 105 | software under these terms. 106 | 107 | **Use** means anything you do with the software requiring one 108 | of your licenses. -------------------------------------------------------------------------------- /wix/License.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\ansicpg1252\deff0\nouicompat{\fonttbl{\f0\fnil\fcharset0 Arial;}} 2 | {\*\generator Riched20 10.0.22621}\viewkind4\uc1 3 | \pard\sa200\sl276\slmult1\fs24\lang9 # Komorebi License\par 4 | \par 5 | Version 2.0.0\par 6 | \par 7 | ## Acceptance\par 8 | \par 9 | In order to get any license under these terms, you must agree\par 10 | to them as both strict obligations and conditions to all\par 11 | your licenses.\par 12 | \par 13 | ## Copyright License\par 14 | \par 15 | The licensor grants you a copyright license for the software\par 16 | to do everything you might do with the software that would\par 17 | otherwise infringe the licensor's copyright in it for any\par 18 | permitted purpose. However, you may only distribute the source\par 19 | code of the software according to the [Distribution License](\par 20 | #distribution-license), you may only make changes according\par 21 | to the [Changes License](#changes-license), and you may not\par 22 | otherwise distribute the software or new works based on the\par 23 | software.\par 24 | \par 25 | ## Distribution License\par 26 | \par 27 | The licensor grants you an additional copyright license to\par 28 | distribute copies of the source code of the software. Your\par 29 | license to distribute covers distributing the source code of\par 30 | the software with changes permitted by the [Changes License](\par 31 | #changes-license).\par 32 | \par 33 | ## Changes License\par 34 | \par 35 | The licensor grants you an additional copyright license to\par 36 | make changes for any permitted purpose.\par 37 | \par 38 | ## Patent License\par 39 | \par 40 | The licensor grants you a patent license for the software that\par 41 | covers patent claims the licensor can license, or becomes able\par 42 | to license, that you would infringe by using the software.\par 43 | \par 44 | ## Personal Uses\par 45 | \par 46 | Personal use for research, experiment, and testing for\par 47 | the benefit of public knowledge, personal study, private\par 48 | entertainment, hobby projects, amateur pursuits, or religious\par 49 | observance, without any anticipated commercial application,\par 50 | is use for a permitted purpose.\par 51 | \par 52 | ## Fair Use\par 53 | \par 54 | You may have "fair use" rights for the software under the\par 55 | law. These terms do not limit them.\par 56 | \par 57 | ## No Other Rights\par 58 | \par 59 | These terms do not allow you to sublicense or transfer any of\par 60 | your licenses to anyone else, or prevent the licensor from\par 61 | granting licenses to anyone else. These terms do not imply\par 62 | any other licenses.\par 63 | \par 64 | ## Patent Defense\par 65 | \par 66 | If you make any written claim that the software infringes or\par 67 | contributes to infringement of any patent, your patent license\par 68 | for the software granted under these terms ends immediately. If\par 69 | your company makes such a claim, your patent license ends\par 70 | immediately for work on behalf of your company.\par 71 | \par 72 | ## Violations\par 73 | \par 74 | The first time you are notified in writing that you have\par 75 | violated any of these terms, or done anything with the software\par 76 | not covered by your licenses, your licenses can nonetheless\par 77 | continue if you come into full compliance with these terms,\par 78 | and take practical steps to correct past violations, within\par 79 | 32 days of receiving notice. Otherwise, all your licenses\par 80 | end immediately.\par 81 | \par 82 | ## No Liability\par 83 | \par 84 | ***As far as the law allows, the software comes as is, without\par 85 | any warranty or condition, and the licensor will not be liable\par 86 | to you for any damages arising out of these terms or the use\par 87 | or nature of the software, under any kind of legal claim.***\par 88 | \par 89 | ## Definitions\par 90 | \par 91 | The **licensor** is the individual or entity offering these\par 92 | terms, and the **software** is the software the licensor makes\par 93 | available under these terms.\par 94 | \par 95 | **You** refers to the individual or entity agreeing to these\par 96 | terms.\par 97 | \par 98 | **Your company** is any legal entity, sole proprietorship,\par 99 | or other kind of organization that you work for, plus all\par 100 | organizations that have control over, are under the control of,\par 101 | or are under common control with that organization. **Control**\par 102 | means ownership of substantially all the assets of an entity,\par 103 | or the power to direct its management and policies by vote,\par 104 | contract, or otherwise. Control can be direct or indirect.\par 105 | \par 106 | **Your licenses** are all the licenses granted to you for the\par 107 | software under these terms.\par 108 | \par 109 | **Use** means anything you do with the software requiring one\par 110 | of your licenses.\par 111 | } 112 | -------------------------------------------------------------------------------- /.github/workflows/windows.yml: -------------------------------------------------------------------------------- 1 | name: Windows 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "*" 7 | push: 8 | branches: 9 | - master 10 | - feature/* 11 | - hotfix/* 12 | tags: 13 | - v* 14 | schedule: 15 | - cron: "30 0 * * 0" # Every day at 00:30 UTC 16 | workflow_dispatch: 17 | 18 | jobs: 19 | build: 20 | strategy: 21 | fail-fast: true 22 | matrix: 23 | platform: 24 | - os-name: Windows-x86_64 25 | runs-on: windows-latest 26 | target: x86_64-pc-windows-msvc 27 | - os-name: Windows-aarch64 28 | runs-on: windows-latest 29 | target: aarch64-pc-windows-msvc 30 | runs-on: ${{ matrix.platform.runs-on }} 31 | permissions: write-all 32 | env: 33 | RUSTFLAGS: -Ctarget-feature=+crt-static -Dwarnings 34 | GH_TOKEN: ${{ github.token }} 35 | steps: 36 | - uses: actions/checkout@v6 37 | with: 38 | fetch-depth: 0 39 | - run: rustup toolchain install stable --profile minimal 40 | - run: rustup toolchain install nightly --allow-downgrade -c rustfmt 41 | - uses: Swatinem/rust-cache@v2 42 | with: 43 | cache-on-failure: "true" 44 | cache-all-crates: "true" 45 | key: ${{ matrix.platform.target }} 46 | - run: cargo +nightly fmt --check 47 | - run: cargo clippy 48 | - uses: houseabsolute/actions-rust-cross@v1 49 | with: 50 | command: "build" 51 | target: ${{ matrix.platform.target }} 52 | args: "--locked --release" 53 | - run: | 54 | cargo install cargo-wix 55 | cargo wix --no-build --nocapture --target ${{ matrix.platform.target }} 56 | - uses: actions/upload-artifact@v5 57 | with: 58 | name: komokana-${{ matrix.platform.target }}-${{ github.sha }} 59 | path: | 60 | target/${{ matrix.platform.target }}/release/*.exe 61 | target/${{ matrix.platform.target }}/release/*.pdb 62 | target/wix/komokana-*.msi 63 | retention-days: 14 64 | 65 | release-dry-run: 66 | needs: build 67 | runs-on: windows-latest 68 | permissions: write-all 69 | if: ${{ github.ref == 'refs/heads/master' }} 70 | steps: 71 | - uses: actions/checkout@v4 72 | with: 73 | fetch-depth: 0 74 | - shell: bash 75 | run: | 76 | TAG=${{ github.ref_name }} 77 | echo "VERSION=${TAG#v}" >> $GITHUB_ENV 78 | - uses: actions/download-artifact@v6 79 | - shell: bash 80 | run: ls -R 81 | - run: | 82 | Compress-Archive -Force ./komokana-x86_64-pc-windows-msvc-${{ github.sha }}/x86_64-pc-windows-msvc/release/*.exe komokana-$Env:VERSION-x86_64-pc-windows-msvc.zip 83 | Copy-Item ./komokana-x86_64-pc-windows-msvc-${{ github.sha }}/wix/*x86_64.msi -Destination ./komokana-$Env:VERSION-x86_64.msi 84 | echo "$((Get-FileHash komokana-$Env:VERSION-x86_64-pc-windows-msvc.zip).Hash.ToLower()) komokana-$Env:VERSION-x86_64-pc-windows-msvc.zip" >checksums.txt 85 | 86 | Compress-Archive -Force ./komokana-aarch64-pc-windows-msvc-${{ github.sha }}/aarch64-pc-windows-msvc/release/*.exe komokana-$Env:VERSION-aarch64-pc-windows-msvc.zip 87 | Copy-Item ./komokana-aarch64-pc-windows-msvc-${{ github.sha }}/wix/*aarch64.msi -Destination ./komokana-$Env:VERSION-aarch64.msi 88 | echo "$((Get-FileHash komokana-$Env:VERSION-aarch64-pc-windows-msvc.zip).Hash.ToLower()) komokana-$Env:VERSION-aarch64-pc-windows-msvc.zip" >>checksums.txt 89 | - uses: Swatinem/rust-cache@v2 90 | with: 91 | cache-on-failure: "true" 92 | cache-all-crates: "true" 93 | - shell: bash 94 | run: | 95 | if ! type kokai >/dev/null; then cargo install --locked kokai --force; fi 96 | git tag -d nightly || true 97 | kokai release --no-emoji --add-links github:commits,issues --ref "${{ github.ref_name }}" >"CHANGELOG.md" 98 | - uses: softprops/action-gh-release@v2 99 | with: 100 | body_path: "CHANGELOG.md" 101 | draft: true 102 | files: | 103 | checksums.txt 104 | *.zip 105 | *.msi 106 | 107 | release: 108 | needs: build 109 | runs-on: windows-latest 110 | permissions: write-all 111 | if: startsWith(github.ref, 'refs/tags/v') 112 | env: 113 | GH_TOKEN: ${{ github.token }} 114 | steps: 115 | - uses: actions/checkout@v4 116 | with: 117 | fetch-depth: 0 118 | - shell: bash 119 | run: | 120 | TAG=${{ github.ref_name }} 121 | echo "VERSION=${TAG#v}" >> $GITHUB_ENV 122 | - uses: actions/download-artifact@v4 123 | - run: | 124 | Compress-Archive -Force ./komokana-x86_64-pc-windows-msvc-${{ github.sha }}/x86_64-pc-windows-msvc/release/*.exe komokana-$Env:VERSION-x86_64-pc-windows-msvc.zip 125 | Copy-Item ./komokana-x86_64-pc-windows-msvc-${{ github.sha }}/wix/*x86_64.msi -Destination ./komokana-$Env:VERSION-x86_64.msi 126 | echo "$((Get-FileHash komokana-$Env:VERSION-x86_64-pc-windows-msvc.zip).Hash.ToLower()) komokana-$Env:VERSION-x86_64-pc-windows-msvc.zip" >checksums.txt 127 | 128 | Compress-Archive -Force ./komokana-aarch64-pc-windows-msvc-${{ github.sha }}/aarch64-pc-windows-msvc/release/*.exe komokana-$Env:VERSION-aarch64-pc-windows-msvc.zip 129 | Copy-Item ./komokana-aarch64-pc-windows-msvc-${{ github.sha }}/wix/*aarch64.msi -Destination ./komokana-$Env:VERSION-aarch64.msi 130 | echo "$((Get-FileHash komokana-$Env:VERSION-aarch64-pc-windows-msvc.zip).Hash.ToLower()) komokana-$Env:VERSION-aarch64-pc-windows-msvc.zip" >>checksums.txt 131 | - uses: Swatinem/rust-cache@v2 132 | with: 133 | cache-on-failure: "true" 134 | cache-all-crates: "true" 135 | - shell: bash 136 | run: | 137 | if ! type kokai >/dev/null; then cargo install --locked kokai --force; fi 138 | git tag -d nightly || true 139 | kokai release --no-emoji --add-links github:commits,issues --ref "$(git tag --points-at HEAD)" >"CHANGELOG.md" 140 | - uses: softprops/action-gh-release@v2 141 | with: 142 | body_path: "CHANGELOG.md" 143 | files: | 144 | checksums.txt 145 | *.zip 146 | *.msi 147 | - if: startsWith(github.ref, 'refs/tags/v') 148 | uses: vedantmgoyal2009/winget-releaser@main 149 | with: 150 | identifier: LGUG2Z.komokana 151 | token: ${{ secrets.WINGET_TOKEN }} 152 | -------------------------------------------------------------------------------- /wix/main.wxs: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 46 | 47 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 69 | 70 | 80 | 81 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 98 | 99 | 103 | 104 | 105 | 106 | 107 | 115 | 116 | 117 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 138 | 142 | 143 | 144 | 145 | 146 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 179 | 180 | 181 | 182 | 183 | 184 | 188 | 189 | 190 | 191 | 199 | 200 | 201 | 202 | 210 | 211 | 212 | 213 | 214 | 215 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # komokana 2 | 3 | Automatic application-aware keyboard layer switching for Windows 4 | 5 | # About 6 | 7 | `komokana` is a daemon that listens to events emitted by [`komorebi`](https://github.com/LGUG2Z/komorebi) and communicates 8 | with [`kanata`](https://github.com/jtroo/kanata) to switch keyboard layers based on a set of user defined rules. 9 | 10 | `komokana` allows you associate different `kanata` keyboard layers with specific applications, and automatically switch 11 | to that keyboard layer when the windows of those applications are focused in the foreground. 12 | 13 | You may join the `komorebi` [Discord server](https://discord.gg/mGkn66PHkx) for any `komokana`-related discussion, help, 14 | troubleshooting etc. If you have any specific feature requests or bugs to report, please create an issue in this repository. 15 | 16 | Articles, blog posts, demos and videos about `komokana` can be added to this section of the readme by PR. 17 | 18 | # Description 19 | 20 | `komokana` communicates with `komorebi` 21 | using [Named Pipes](https://docs.microsoft.com/en-us/windows/win32/ipc/named-pipes), 22 | and with `kanata` via a [TCP server](https://en.wikipedia.org/wiki/Transmission_Control_Protocol) that can be optionally 23 | started by passing the `--port` flag when launching the `kanata` process. 24 | 25 | If either the `komorebi` or `kanata` processes are stopped or killed, `komokana` will attempt to reconnect to them 26 | indefinitely. However, `komokana` will not launch successfully if either one of those processes is not running. 27 | 28 | # Getting Started 29 | 30 | ## Prerequisites 31 | 32 | - The latest version of `komorebi` 33 | - `scoop install komorebi` (from the `extras` bucket) 34 | - The latest version of `kanata` 35 | - `cargo install kanata` 36 | 37 | ## GitHub Releases 38 | 39 | Prebuilt binaries of tagged releases are available on the [releases page](https://github.com/LGUG2Z/komokana/releases) 40 | in a `zip` archive. 41 | 42 | Once downloaded, you will need to move the `komokana.exe` binary to a directory in your `Path` ( 43 | you can see these directories by running `$Env:Path.split(";")` at a PowerShell prompt). 44 | 45 | Alternatively, you may add a new directory to your `Path` 46 | using [`setx`](https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/setx) or the Environment 47 | Variables pop up in System Properties Advanced (which can be launched with `SystemPropertiesAdvanced.exe` at a 48 | PowerShell prompt), and then move the binaries to that directory. 49 | 50 | ## Scoop 51 | 52 | If you use the [Scoop](https://scoop.sh/) command line installer, you can run 53 | the following commands to install the binaries from the latest GitHub Release: 54 | 55 | ```powershell 56 | scoop bucket add extras 57 | scoop install komokana 58 | ``` 59 | 60 | If you install _komokana_ using Scoop, the binary will automatically be added 61 | to your `Path`. 62 | 63 | ## Building from Source 64 | 65 | If you prefer to compile _komokana_ from source, you will need 66 | a [working Rust development environment on Windows 10](https://rustup.rs/). The `x86_64-pc-windows-msvc` toolchain is 67 | required, so make sure you have also installed 68 | the [Build Tools for Visual Studio 2019](https://stackoverflow.com/a/55603112). 69 | 70 | You can then clone this repo and compile the source code to install the binary for `komokana`: 71 | 72 | ```powershell 73 | cargo install --path . --locked 74 | ``` 75 | 76 | ## Configuring 77 | 78 | `komokana` is configured using a YAML file that can be specified using the `-c` flag. 79 | 80 | Consider the following `kanata.kbd` file which defines our keyboard layers: 81 | 82 | ```clojure 83 | (defalias 84 | ;; these are some convenient aliases to send the letter on tap, or toggle the 85 | ;; "firefox" layout on hold 86 | ft (tap-hold 50 200 f (layer-toggle firefox)) 87 | jt (tap-hold 50 200 j (layer-toggle firefox)) 88 | 89 | ;; these are some convenient aliases for us to switch layers 90 | qwr (layer-switch qwerty) 91 | ff (layer-switch firefox) 92 | ) 93 | 94 | ;; imagine this is our default layer, passed as "-d qwerty" when launching komokana 95 | ;; the only two keys overriden here are f and j, which when held, will toggle 96 | ;; our "firefox" layer 97 | (deflayer qwerty 98 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 99 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 100 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 101 | _ _ _ _ @ft _ _ @jt _ _ _ _ _ 102 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ 103 | _ _ _ _ _ _ _ _ _ _ 104 | ) 105 | 106 | ;; this is our firefox layer which lets us navigate webpages using hjkl 107 | (deflayer firefox 108 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 109 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 110 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 111 | _ _ _ _ _ _ left down up rght _ _ _ 112 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ 113 | _ _ _ _ _ _ _ _ _ _ 114 | ) 115 | 116 | ;; this is our editor layer for use in windows where the vim editor or vim extensions 117 | ;; in a text editor are running. the only thing we do here is ensure that the tap-hold 118 | ;; not present on j, so that when we hold down j we can zoom all the way down the file 119 | ;; that we are editing 120 | (deflayer editor 121 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 122 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 123 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 124 | _ _ _ _ @ft _ _ _ _ _ _ _ _ 125 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ 126 | _ _ _ _ _ _ _ _ _ _ 127 | ) 128 | ``` 129 | 130 | Based on the `kanata` layers defined above, we can have a `komokana.yaml` configuration file that looks like this: 131 | 132 | ```yaml 133 | - exe: "firefox.exe" # when a window with this exe is active 134 | target_layer: "firefox" # switch to this layer, a vim-like layer just for browsing! 135 | title_overrides: # unless... 136 | - title: "Slack |" # the window title matches this 137 | # valid matching strategies are: starts_with, ends_with, contains and equals 138 | strategy: "starts_with" # matching with this matching strategy 139 | target_layer: "qwerty" # if it does, then switch to this layer for chatting 140 | - title: "Mozilla Firefox" # new firefox tab, we'll probably want to switch to qwerty mode to type a url! 141 | strategy: "equals" 142 | target_layer: "qwerty" 143 | virtual_key_overrides: # unless... 144 | # list of key codes and their decimal values here: https://cherrytree.at/misc/vk.htm 145 | - virtual_key_code: 18 # this key is held down (alt in this case) when the window becomes active 146 | targer_layer: "qwerty" # if it is, then switch to this layer, so that we can continue switching window focus with alt+hjkl 147 | virtual_key_ignores: # alternatively 148 | - 18 # if this key is held down (alt in this case), then don't make any layer switches 149 | 150 | # your normal layer might have a tap-hold on j since it's a such convenient and ergonomic key 151 | # but it sucks to be in vim, holding down j to move down and have nothing happen because of the hold... 152 | # no worries! let's just switch to a layer which removes the tap-hold on the j when we are in windows 153 | # where we use vim or vim editing extensions! 154 | - exe: "WindowsTerminal.exe" 155 | target_layer: "editor" 156 | - exe: "idea64.exe" 157 | target_layer: "editor" 158 | ``` 159 | 160 | ## Running 161 | 162 | Once you have either the prebuilt binaries in your `Path`, or have compiled the binaries from source (these will already 163 | be in your `Path` if you installed Rust with [rustup](https://rustup.rs), which you absolutely should), you can 164 | run `komokana -p [KANATA_PORT] -d [DEFAULT_LAYER] -c [PATH_TO_YOUR_CONFIG]` at a Powershell prompt, and you should start to see log output. 165 | 166 | Remember, both `komorebi` and `kanata` must be running before you try to start `komokana`, and `kanata` must be running 167 | with the `--port` flag to enable the TCP server on the given port. 168 | 169 | This means that `komokana` is now running and listening for notifications sent to it by `komorebi`. 170 | 171 | ### `yasb` Widget 172 | 173 | When running `komokana` with the `-t` flag, a plaintext file will be updated whenever the layer changes at the following 174 | location: `~/AppData/Local/Temp/kanata_layer` 175 | 176 | You may optionally use this file to construct a simple [`yasb`](https://github.com/denBot/yasb) widget which polls and 177 | displays the contents of that file to provide a visual indicator of the currently : 178 | 179 | ```yaml 180 | # in ~/.yasb/config.yaml 181 | widgets: 182 | kanata: 183 | type: "yasb.custom.CustomWidget" 184 | options: 185 | label: "{data}" 186 | label_alt: "{data}" 187 | class_name: "kanata-widget" 188 | exec_options: 189 | run_cmd: "cat '%LOCALAPPDATA%\\Temp\\kanata_layer'" 190 | run_interval: 300 191 | return_format: "string" 192 | ``` 193 | 194 | # Contribution Guidelines 195 | 196 | If you would like to contribute to `komokana` please take the time to carefully read the guidelines below. 197 | 198 | ## Commit hygiene 199 | 200 | - Flatten all `use` statements 201 | - Run `cargo +stable clippy` and ensure that all lints and suggestions have been addressed before committing 202 | - Run `cargo +nightly fmt --all` to ensure consistent formatting before committing 203 | - Use `git cz` with 204 | the [Commitizen CLI](https://github.com/commitizen/cz-cli#conventional-commit-messages-as-a-global-utility) to prepare 205 | commit messages 206 | - Provide **at least** one short sentence or paragraph in your commit message body to describe your thought process for the 207 | changes being committed 208 | 209 | ## License 210 | 211 | `komokana` is licensed under the [Komorebi 2.0.0 license](./LICENSE.md), which 212 | is a fork of the [PolyForm Strict 1.0.0 213 | license](https://polyformproject.org/licenses/strict/1.0.0). On a high level 214 | this means that you are free to do whatever you want with `komokana` for 215 | personal use other than redistribution, or distribution of new works (i.e. 216 | hard-forks) based on the software. 217 | 218 | Anyone is free to make their own fork of `komokana` with changes intended 219 | either for personal use or for integration back upstream via pull requests. 220 | 221 | _The [Komorebi 2.0.0 License](./LICENSE.md) does not permit any kind of 222 | commercial use._ 223 | 224 | ### Contribution licensing 225 | 226 | Contributions are accepted with the following understanding: 227 | 228 | - Contributed content is licensed under the terms of the 0-BSD license 229 | - Contributors accept the terms of the project license at the time of contribution 230 | 231 | By making a contribution, you accept both the current project license terms, and that all contributions that you have 232 | made are provided under the terms of the 0-BSD license. 233 | 234 | #### Zero-Clause BSD 235 | 236 | ``` 237 | Permission to use, copy, modify, and/or distribute this software for 238 | any purpose with or without fee is hereby granted. 239 | 240 | THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL 241 | WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES 242 | OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE 243 | FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY 244 | DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN 245 | AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT 246 | OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 247 | ``` 248 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all, clippy::nursery, clippy::pedantic)] 2 | #![allow(clippy::missing_errors_doc)] 3 | 4 | use clap::Parser; 5 | use komorebi_client::SocketMessage; 6 | use komorebi_client::Window; 7 | use komorebi_client::WindowsApi; 8 | use std::io::BufRead; 9 | use std::io::BufReader; 10 | use std::io::Read; 11 | use std::io::Write; 12 | use std::net::TcpStream; 13 | use std::path::PathBuf; 14 | use std::process::Command; 15 | use std::sync::atomic::AtomicBool; 16 | use std::sync::atomic::Ordering; 17 | use std::sync::Arc; 18 | use std::time::Duration; 19 | 20 | use color_eyre::eyre::anyhow; 21 | use color_eyre::Report; 22 | use color_eyre::Result; 23 | use json_dotpath::DotPaths; 24 | use komorebi_client::Notification; 25 | use komorebi_client::NotificationEvent; 26 | use komorebi_client::UnixListener; 27 | use komorebi_client::WindowManagerEvent; 28 | use parking_lot::Mutex; 29 | use serde_json::json; 30 | use windows::Win32::UI::Input::KeyboardAndMouse::GetKeyState; 31 | 32 | use crate::configuration::Configuration; 33 | use crate::configuration::Strategy; 34 | 35 | mod configuration; 36 | 37 | static KANATA_DISCONNECTED: AtomicBool = AtomicBool::new(false); 38 | static KANATA_RECONNECT_REQUIRED: AtomicBool = AtomicBool::new(false); 39 | 40 | const NAME: &str = "komokana.sock"; 41 | 42 | #[derive(Debug, Parser)] 43 | #[clap(author, about, version, arg_required_else_help = true)] 44 | struct Cli { 45 | /// The port on which kanata's TCP server is running 46 | #[clap(short = 'p', long)] 47 | kanata_port: i32, 48 | /// Path to your komokana configuration file 49 | #[clap(short, long, default_value = "~/komokana.yaml")] 50 | configuration: String, 51 | /// Layer to default to when an active window doesn't match any rules 52 | #[clap(short, long)] 53 | default_layer: String, 54 | /// Write the current layer to `~/AppData/Local/Temp/kanata_layer` 55 | #[clap(short, long, action)] 56 | tmpfile: bool, 57 | } 58 | 59 | fn main() -> Result<()> { 60 | let cli: Cli = Cli::parse(); 61 | let configuration = resolve_windows_path(&cli.configuration)?; 62 | 63 | if std::env::var("RUST_LOG").is_err() { 64 | std::env::set_var("RUST_LOG", "info"); 65 | } 66 | 67 | color_eyre::install()?; 68 | env_logger::builder().format_timestamp(None).init(); 69 | 70 | let komokana = Komokana::init( 71 | configuration, 72 | cli.kanata_port, 73 | cli.default_layer, 74 | cli.tmpfile, 75 | )?; 76 | 77 | komokana.listen(); 78 | 79 | loop { 80 | std::thread::sleep(Duration::from_secs(60)); 81 | } 82 | } 83 | 84 | struct Komokana { 85 | komorebi: Arc>, 86 | kanata: Arc>, 87 | kanata_port: i32, 88 | configuration: Configuration, 89 | default_layer: String, 90 | tmpfile: bool, 91 | } 92 | 93 | impl Komokana { 94 | pub fn init( 95 | configuration: PathBuf, 96 | kanata_port: i32, 97 | default_layer: String, 98 | tmpfile: bool, 99 | ) -> Result { 100 | let configuration: Configuration = 101 | serde_yaml::from_str(&std::fs::read_to_string(configuration)?)?; 102 | 103 | let listener = komorebi_client::subscribe(NAME)?; 104 | log::debug!("connected to komorebi"); 105 | 106 | let stream = TcpStream::connect(format!("localhost:{kanata_port}"))?; 107 | log::debug!("connected to kanata"); 108 | 109 | Ok(Self { 110 | komorebi: Arc::new(Mutex::new(listener)), 111 | kanata: Arc::new(Mutex::new(stream)), 112 | kanata_port, 113 | configuration, 114 | default_layer, 115 | tmpfile, 116 | }) 117 | } 118 | 119 | #[allow(clippy::too_many_lines)] 120 | pub fn listen(&self) { 121 | let socket = self.komorebi.clone(); 122 | let mut stream = self.kanata.clone(); 123 | let stream_read = self.kanata.clone(); 124 | let kanata_port = self.kanata_port; 125 | let tmpfile = self.tmpfile; 126 | log::info!("listening"); 127 | 128 | std::thread::spawn(move || -> Result<()> { 129 | let mut read_stream = stream_read.lock().try_clone()?; 130 | drop(stream_read); 131 | 132 | loop { 133 | let mut buf = vec![0; 1024]; 134 | match read_stream.read(&mut buf) { 135 | Ok(bytes_read) => { 136 | let data = String::from_utf8(buf[0..bytes_read].to_vec())?; 137 | if data == "\n" { 138 | continue; 139 | } 140 | 141 | let notification: serde_json::Value = serde_json::from_str(&data)?; 142 | 143 | if notification.dot_has("LayerChange.new") { 144 | if let Some(new) = notification.dot_get::("LayerChange.new")? { 145 | log::info!("current layer: {new}"); 146 | if tmpfile { 147 | let mut tmp = std::env::temp_dir(); 148 | tmp.push("kanata_layer"); 149 | std::fs::write(tmp, new)?; 150 | } 151 | } 152 | } 153 | } 154 | Err(error) => { 155 | // Connection reset 156 | if error.raw_os_error().expect("could not get raw os error") == 10054 { 157 | KANATA_DISCONNECTED.store(true, Ordering::SeqCst); 158 | log::warn!("kanata tcp server is no longer running"); 159 | 160 | let mut result = TcpStream::connect(format!("localhost:{kanata_port}")); 161 | while result.is_err() { 162 | log::warn!("kanata tcp server is not running, retrying connection in 5 seconds"); 163 | std::thread::sleep(Duration::from_secs(5)); 164 | result = TcpStream::connect(format!("localhost:{kanata_port}")); 165 | } 166 | 167 | log::info!("reconnected to kanata on read thread"); 168 | 169 | read_stream = result?; 170 | 171 | KANATA_DISCONNECTED.store(false, Ordering::SeqCst); 172 | KANATA_RECONNECT_REQUIRED.store(true, Ordering::SeqCst); 173 | } 174 | } 175 | } 176 | } 177 | }); 178 | 179 | let config = self.configuration.clone(); 180 | let default_layer = self.default_layer.clone(); 181 | std::thread::spawn(move || -> Result<()> { 182 | #[allow(clippy::significant_drop_in_scrutinee)] 183 | for client in socket.lock().incoming() { 184 | match client { 185 | Ok(subscription) => { 186 | let reader = BufReader::new(subscription.try_clone()?); 187 | #[allow(clippy::lines_filter_map_ok)] 188 | for line in reader.lines().flatten() { 189 | let notification: Notification = match serde_json::from_str(&line) { 190 | Ok(value) => value, 191 | Err(error) => { 192 | log::debug!( 193 | "discarding malformed komorebi notification: {error}" 194 | ); 195 | continue; 196 | } 197 | }; 198 | 199 | match notification.event { 200 | NotificationEvent::WindowManager(WindowManagerEvent::Show( 201 | _, 202 | window, 203 | )) => handle_event( 204 | &config, 205 | &mut stream, 206 | &default_layer, 207 | Event::Show, 208 | &window.exe()?, 209 | &window.title()?, 210 | kanata_port, 211 | )?, 212 | NotificationEvent::WindowManager( 213 | WindowManagerEvent::FocusChange(_, window), 214 | ) => handle_event( 215 | &config, 216 | &mut stream, 217 | &default_layer, 218 | Event::FocusChange, 219 | &window.exe()?, 220 | &window.title()?, 221 | kanata_port, 222 | )?, 223 | NotificationEvent::Socket( 224 | SocketMessage::CycleFocusWindow(_) 225 | | SocketMessage::FocusStackWindow(_) 226 | | SocketMessage::PromoteFocus 227 | | SocketMessage::EagerFocus(_) 228 | | SocketMessage::FocusWindow(_), 229 | ) => { 230 | let window = Window::from(WindowsApi::foreground_window()?); 231 | 232 | handle_event( 233 | &config, 234 | &mut stream, 235 | &default_layer, 236 | Event::FocusChange, 237 | &window.exe()?, 238 | &window.title()?, 239 | kanata_port, 240 | )?; 241 | } 242 | _ => {} 243 | } 244 | } 245 | } 246 | Err(error) => { 247 | // Broken pipe 248 | if error.raw_os_error().expect("could not get raw os error") == 109 { 249 | log::warn!("komorebi is no longer running"); 250 | 251 | let mut output = Command::new("cmd.exe") 252 | .args(["/C", "komorebic.exe", "subscribe-socket", NAME]) 253 | .output()?; 254 | 255 | while !output.status.success() { 256 | log::warn!( 257 | "komorebic.exe failed with error code {:?}, retrying in 5 seconds...", 258 | output.status.code() 259 | ); 260 | 261 | std::thread::sleep(Duration::from_secs(5)); 262 | 263 | output = Command::new("cmd.exe") 264 | .args(["/C", "komorebic.exe", "subscribe-socket", NAME]) 265 | .output()?; 266 | } 267 | 268 | log::warn!("reconnected to komorebi"); 269 | } else { 270 | return Err(Report::from(error)); 271 | } 272 | } 273 | } 274 | } 275 | 276 | Ok(()) 277 | }); 278 | } 279 | } 280 | 281 | fn handle_event( 282 | configuration: &Configuration, 283 | stream: &mut Arc>, 284 | default_layer: &str, 285 | event: Event, 286 | exe: &str, 287 | title: &str, 288 | kanata_port: i32, 289 | ) -> Result<()> { 290 | let target = calculate_target( 291 | configuration, 292 | event, 293 | exe, 294 | title, 295 | if matches!(event, Event::FocusChange) { 296 | Option::from(default_layer) 297 | } else { 298 | None 299 | }, 300 | ); 301 | 302 | if let Some(target) = target { 303 | if KANATA_RECONNECT_REQUIRED.load(Ordering::SeqCst) { 304 | let mut result = TcpStream::connect(format!("localhost:{kanata_port}")); 305 | while result.is_err() { 306 | std::thread::sleep(Duration::from_secs(5)); 307 | result = TcpStream::connect(format!("localhost:{kanata_port}")); 308 | } 309 | 310 | log::info!("reconnected to kanata on write thread"); 311 | *stream = Arc::new(Mutex::new(result?)); 312 | KANATA_RECONNECT_REQUIRED.store(false, Ordering::SeqCst); 313 | } 314 | 315 | let request = json!({ 316 | "ChangeLayer": { 317 | "new": target, 318 | } 319 | }); 320 | 321 | stream.lock().write_all(request.to_string().as_bytes())?; 322 | log::debug!("request sent: {request}"); 323 | } 324 | 325 | Ok(()) 326 | } 327 | 328 | #[derive(Debug, Copy, Clone)] 329 | pub enum Event { 330 | Show, 331 | FocusChange, 332 | } 333 | 334 | fn calculate_target( 335 | configuration: &Configuration, 336 | event: Event, 337 | exe: &str, 338 | title: &str, 339 | default: Option<&str>, 340 | ) -> Option { 341 | let mut new_layer = default; 342 | for entry in configuration { 343 | if entry.exe == exe { 344 | if matches!(event, Event::FocusChange) { 345 | new_layer = Option::from(entry.target_layer.as_str()); 346 | } 347 | 348 | if let Some(title_overrides) = &entry.title_overrides { 349 | for title_override in title_overrides { 350 | match title_override.strategy { 351 | Strategy::StartsWith => { 352 | if title.starts_with(&title_override.title) { 353 | new_layer = Option::from(title_override.target_layer.as_str()); 354 | } 355 | } 356 | Strategy::EndsWith => { 357 | if title.ends_with(&title_override.title) { 358 | new_layer = Option::from(title_override.target_layer.as_str()); 359 | } 360 | } 361 | Strategy::Contains => { 362 | if title.contains(&title_override.title) { 363 | new_layer = Option::from(title_override.target_layer.as_str()); 364 | } 365 | } 366 | Strategy::Equals => { 367 | if title.eq(&title_override.title) { 368 | new_layer = Option::from(title_override.target_layer.as_str()); 369 | } 370 | } 371 | } 372 | } 373 | 374 | // This acts like a default target layer within the application 375 | // which defaults back to the entry's main target layer 376 | if new_layer.is_none() { 377 | new_layer = Option::from(entry.target_layer.as_str()); 378 | } 379 | } 380 | 381 | if matches!(event, Event::FocusChange) { 382 | if let Some(virtual_key_overrides) = &entry.virtual_key_overrides { 383 | for virtual_key_override in virtual_key_overrides { 384 | if unsafe { GetKeyState(virtual_key_override.virtual_key_code) } < 0 { 385 | new_layer = Option::from(virtual_key_override.targer_layer.as_str()); 386 | } 387 | } 388 | } 389 | 390 | if let Some(virtual_key_ignores) = &entry.virtual_key_ignores { 391 | for virtual_key in virtual_key_ignores { 392 | if unsafe { GetKeyState(*virtual_key) } < 0 { 393 | new_layer = None; 394 | } 395 | } 396 | } 397 | } 398 | } 399 | } 400 | 401 | new_layer.and_then(|new_layer| Option::from(new_layer.to_string())) 402 | } 403 | 404 | fn resolve_windows_path(raw_path: &str) -> Result { 405 | let path = if raw_path.starts_with('~') { 406 | raw_path.replacen( 407 | '~', 408 | &dirs::home_dir() 409 | .ok_or_else(|| anyhow!("there is no home directory"))? 410 | .display() 411 | .to_string(), 412 | 1, 413 | ) 414 | } else { 415 | raw_path.to_string() 416 | }; 417 | 418 | let full_path = PathBuf::from(path); 419 | 420 | let parent = full_path 421 | .parent() 422 | .ok_or_else(|| anyhow!("cannot parse directory"))?; 423 | 424 | let file = full_path 425 | .components() 426 | .next_back() 427 | .ok_or_else(|| anyhow!("cannot parse filename"))?; 428 | 429 | let mut canonicalized = std::fs::canonicalize(parent)?; 430 | canonicalized.push(file); 431 | 432 | Ok(canonicalized) 433 | } 434 | --------------------------------------------------------------------------------