├── .github └── workflows │ └── release.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── doc ├── img │ ├── class_diagram.png │ └── top.png └── uml │ └── class_diagram.pu ├── hotswitch-hs.lua └── lib ├── common ├── CanvasConstants.lua ├── Debugger.lua ├── FrameCulculator.lua ├── KeyConstants.lua ├── TimeChecker.lua └── Updater.lua ├── controller ├── Controller.lua ├── HotkeyController.lua └── MainController.lua ├── model ├── AppWatchModel.lua ├── KeyStatusModel.lua ├── Model.lua ├── PreferenceModel.lua ├── SettingModel.lua └── WindowModel.lua └── view ├── BaseCanvasView.lua ├── PanelLayoutView.lua ├── SelectedRowCanvasView.lua ├── ToastView.lua └── View.lua /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | name: Create Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | - name: Create Release 16 | id: create_release 17 | uses: actions/create-release@v1 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | with: 21 | tag_name: ${{ github.ref }} 22 | release_name: Release ${{ github.ref }} 23 | body: '' 24 | draft: false 25 | prerelease: false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Lua sources 2 | luac.out 3 | 4 | # luarocks build files 5 | *.src.rock 6 | *.zip 7 | *.tar.gz 8 | 9 | # Object files 10 | *.o 11 | *.os 12 | *.ko 13 | *.obj 14 | *.elf 15 | 16 | # Precompiled Headers 17 | *.gch 18 | *.pch 19 | 20 | # Libraries 21 | *.lib 22 | *.a 23 | *.la 24 | *.lo 25 | *.def 26 | *.exp 27 | 28 | # Shared objects (inc. Windows DLLs) 29 | *.dll 30 | *.so 31 | *.so.* 32 | *.dylib 33 | 34 | # Executables 35 | *.exe 36 | *.out 37 | *.app 38 | *.i*86 39 | *.x86_64 40 | *.hex 41 | 42 | ### https://raw.github.com/github/gitignore/3af1c2901fe89a2a75b6669a73b0a13e3f036182/Global/VisualStudioCode.gitignore 43 | 44 | .vscode/* 45 | !.vscode/settings.json 46 | !.vscode/tasks.json 47 | !.vscode/launch.json 48 | !.vscode/extensions.json 49 | 50 | 51 | ### https://raw.github.com/github/gitignore/3af1c2901fe89a2a75b6669a73b0a13e3f036182/Global/macOS.gitignore 52 | 53 | *.DS_Store 54 | .AppleDouble 55 | .LSOverride 56 | 57 | # Icon must end with two \r 58 | Icon 59 | 60 | 61 | # Thumbnails 62 | ._* 63 | 64 | # Files that might appear in the root of a volume 65 | .DocumentRevisions-V100 66 | .fseventsd 67 | .Spotlight-V100 68 | .TemporaryItems 69 | .Trashes 70 | .VolumeIcon.icns 71 | .com.apple.timemachine.donotpresent 72 | 73 | # Directories potentially created on remote AFP share 74 | .AppleDB 75 | .AppleDesktop 76 | Network Trash Folder 77 | Temporary Items 78 | .apdisk 79 | 80 | 81 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Lua.diagnostics.globals": [ 3 | "hs" 4 | ] 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 oniatsu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # What is HotSwitch-HS 2 | 3 | ![top](https://raw.githubusercontent.com/oniatsu/HotSwitch-HS/main/doc/img/top.png) 4 | 5 | HotSwitch-HS is a window switcher using **2 stroke hotkey** for macOS. 6 | 7 | It provides fastest window switching, no matter how many windows there are. 8 | HotSwitch-HS uses [Hammerspoon](https://www.hammerspoon.org/), and is rewritten for a substitution of [HotSwitch](https://github.com/oniatsu/HotSwitch). 9 | 10 | You can switch any windows by like `command + .` + `x` (this key is always fixed). 11 | 12 | HotSwitch-HS's window switching steps is these. 13 | 14 | 1. Register **a fixed key** to windows on list. (Press `Space`. It's easy and fast.) 15 | 2. Switch any windows by using the key you registered. (You can switch in a flash without thinking time.) 16 | 17 | In addition, HotSwitch-HS provides auto generated keys before your key registration. 18 | However, I highly recommend that you register keys, because it enable you to switch windows faster than ever. 19 | 20 | # Usage 21 | 22 | ## Simple way 23 | 24 | Try it. It's easy and fast to understand. 25 | 26 | | Key | Action | 27 | | --- | ------ | 28 | | The key you set | Open or close the HotSwitch-HS panel | 29 | | `Space` | Toggle registration mode | 30 | | `Tab` or `Down` | Select a next window | 31 | | `Shift+Tab` or `Up` | Select a previous window | 32 | | `Delete` | Delete the key on the selected window | 33 | | `Return` | Focus the selected window | 34 | | `Escape` | Close the panal | 35 | | `[a-zA-Z0-9]` | Focus the window or register the key | 36 | | `-` or `[` or `]` or `.` or `/` | Focus the window or register the key | 37 | 38 | ## Details 39 | 40 | Concretely, HotSwitch-HS's window switching steps is these. 41 | 42 | 1. Register **a fixed key** to windows on list. 43 | 2. Switch any windows by using the key you registered. 44 | 45 | ### 1. Register **a fixed key** to windows on list. 46 | 47 | 1. Open HotSwitch-HS panel. (Press `command + .` that you registered) 48 | 2. Select a window on lists. (Press `Tab` or cursor keys.) 49 | 3. Chanege the panel to registeration mode. (Press `Space`) 50 | 4. Register **a fixed key** to the window. (Press any character keys. `a`, `b`, `c`, etc.) 51 | 52 | The registered key become a reserversion key, so the key doesn't appear as auto generated keys. 53 | 54 | If you want to delete a registered key combined with the window, select the window on lists and press `Delete`. 55 | 56 | ### 2. Switch any windows by using the key you registered. 57 | 58 | 1. Open HotSwitch-HS panel. (Press `command + .` that you registered) 59 | 2. Switch the target window by using **a fixed key**. (Press the key you registered.) 60 | 61 | It looks like that 2 stroke hotkey is working to focus any windows. 62 | The important thing is that **the 2 stroke key bind is fixed anytime**. 63 | 64 | That is why window switching by HotSwitch-HS is always fastest. 65 | 66 | # Installation 67 | 68 | ## 1. Install [Hammerspoon](https://www.hammerspoon.org/) 69 | 70 | ## 2. Download HotSwitch-HS 71 | 72 | In terminal, execute a command. You need to place a directory to `hotswitch-hs`. 73 | ```bash 74 | git clone https://github.com/oniatsu/HotSwitch-HS.git ~/.hammerspoon/hotswitch-hs 75 | ``` 76 | 77 | Directory tree is like this: 78 | ``` 79 | ~/.hammerspoon/ 80 | ├── init.lua 81 | └── hotswitch-hs/ 82 | ├── lib/ 83 | ├── LICENSE 84 | ├── README.md 85 | └── hotswitch-hs.lua 86 | ``` 87 | 88 | If you have installed Hammerspoon just right now, `~/.hammerspoon/init.lua` doesn't exist yet. 89 | 90 | ## 3. Put a code at your Hammerspoon's `~/.hammerspoon/init.lua` 91 | If the file does not exist, create it and add the codes. 92 | 93 | ```lua 94 | local hotswitchHs = require("hotswitch-hs/hotswitch-hs") 95 | hotswitchHs.enableAutoUpdate() -- If you don't want to update automatically, remove this line. 96 | hs.hotkey.bind({"command"}, ".", hotswitchHs.openOrClose) -- Set a keybind you like to open HotSwitch-HS panel. 97 | ``` 98 | 99 | For example, you can set the keybind to open HotSwitch-HS like these. 100 | 101 | ```lua 102 | -- These are valid. 103 | hs.hotkey.bind({"command"}, ".", hotswitchHs.openOrClose) -- command + . 104 | hs.hotkey.bind({"command"}, ";", hotswitchHs.openOrClose) -- command + ; 105 | hs.hotkey.bind({"option"}, "tab", hotswitchHs.openOrClose) -- option + tab 106 | hs.hotkey.bind({"control"}, 'space', hotswitchHs.openOrClose) -- control + space 107 | hs.hotkey.bind({"command", "shift"}, "a", hotswitchHs.openOrClose) -- command + shift + a 108 | 109 | -- These are NOT valid normally. Hammerspoon cannot override the keys, because the keys may be registered and used by macOS. 110 | hs.hotkey.bind({"command"}, "tab", hotswitchHs.openOrClose) -- command + tab 111 | hs.hotkey.bind({"command"}, "space", hotswitchHs.openOrClose) -- command + space 112 | ``` 113 | 114 | [Here](https://www.hammerspoon.org/docs/hs.hotkey.html#bind) is how to set `hs.hotkey.bind()`. 115 | 116 | ### Advanced option 117 | 118 | If you want to replace the macOS's app switcher `command + tab` with HotSwitch-HS, you can do forcibly by using [Karabiner-Elements](https://karabiner-elements.pqrs.org/). 119 | 120 | #### `~/.config/karabiner/karabiner.json` 121 | 122 | ```json 123 | { 124 | "from": { 125 | "key_code": "tab", 126 | "modifiers": { "mandatory": [ "command" ] } 127 | }, 128 | "to": [ { 129 | "key_code": "f13" 130 | } ], 131 | "type": "basic" 132 | } 133 | ``` 134 | 135 | #### `~/.hammerspoon/init.lua` 136 | 137 | ```lua 138 | hs.hotkey.bind({}, "f13", hotswitchHs.openOrClose) 139 | ``` 140 | 141 | ## 4. Run Hammerspoon 142 | 143 | And open HotSwitch-HS panel by using the keybind you set. 144 | If you have some probrems, [check these](https://github.com/oniatsu/HotSwitch-HS#if-you-have-some-probrems). 145 | 146 | # Preferences 147 | 148 | If you want to set some preferences, you can use some option by adding codes at `~/.hammerspoon/init.lua`. 149 | 150 | For example: 151 | 152 | ```lua 153 | local hotswitchHs = require("hotswitch-hs/hotswitch-hs") 154 | hotswitchHs.enableAutoUpdate() 155 | hotswitchHs.setAutoGeneratedKeys({"1", "2", "3", "4", "5", "6", "7", "8", "9", "0"}) 156 | hotswitchHs.enableAllSpaceWindows() 157 | hs.hotkey.bind({"command"}, ".", hotswitchHs.openOrClose) 158 | ``` 159 | 160 | See below to know what these means. 161 | 162 | ## Auto update 163 | 164 | Add this. It will update HotSwitch-HS by `git pull` automatically when needed. 165 | 166 | ```lua 167 | hotswitchHs.enableAutoUpdate() 168 | ``` 169 | 170 | ## Auto generated keys 171 | 172 | You can define auto generated keys. 173 | The order you specified will be used to generate keys. 174 | 175 | ```lua 176 | hotswitchHs.setAutoGeneratedKeys({"1", "2", "3", "4", "5", "6", "7", "8", "9", "0"}) 177 | ``` 178 | 179 | Default auto generated keys are [these](https://github.com/oniatsu/HotSwitch-HS/blob/main/lib/common/KeyConstants.lua#L24-L26). 180 | 181 | ## Showing all space windows 182 | 183 | If you want to see all space windows on the lists, add this. 184 | 185 | ```lua 186 | hotswitchHs.enableAllSpaceWindows() 187 | ``` 188 | 189 | Default: the current space windows are only shown. 190 | 191 | ## Additonal symbol keys 192 | 193 | You can add symbol keys to register windows. (Only the [Japanese keyboard layout symbols](https://github.com/oniatsu/HotSwitch-HS/blob/main/lib/common/KeyConstants.lua#L31) are available now.) 194 | 195 | ```lua 196 | hotswitchHs.addJapaneseKeyboardLayoutSymbolKeys() 197 | ``` 198 | 199 | ## Always showing the panel on primary screen 200 | 201 | To show the panel not on main screen but on primary screen. 202 | Main screen is the one containing the currently focused window. 203 | 204 | ```lua 205 | hotswitchHs.setPanelToAlwaysShowOnPrimaryScreen() 206 | ``` 207 | 208 | ## Set module log level 209 | 210 | If you are on macOS Ventura 13.x, Hammerspoon works too slow when many logs are printed on the Hammerspoon console. 211 | So I recommend that you set the module log level to `nothing` not to print many logs. 212 | This is a [Hammerspoon's bug](https://github.com/Hammerspoon/hammerspoon/issues/3306). 213 | ```lua 214 | -- Default: nothing 215 | hotswitchHs.setLogLevel("debug") -- can be 'nothing', 'error', 'warning', 'info', 'debug', or 'verbose' 216 | ``` 217 | 218 | # If you have some probrems, 219 | 220 | Check these. 221 | 222 | - If the keybind you set is not enabled, open Hammerspoon console and check some error messages. First, click Hammerspoon's menubar icon. Second, click `Console...`. 223 | - Update HotSwtich-HS. `cd ~/.hammerspoon/hotswitch-hs && git pull` 224 | 225 | ## Known issues 226 | 227 | Sometimes, getting windows is failed after the macOS has woken up from sleep. 228 | 229 | It would be fixed by reloading Hammerspoon config. It's possibly Hammerspoon's bug. 230 | I recommend that you add a keybind to reload Hammerpoon config quickly. 231 | 232 | ```lua 233 | -- For example: you can reload by "command + option + control + r". 234 | hs.hotkey.bind({"command", "option", "control"}, "r", hs.reload) 235 | hs.hotkey.bind({"command"}, ".", hotswitchHs.openOrClose) 236 | -- It's message showing the completion of reloading. 237 | hs.alert.show("Hammerspoon is reloaded") 238 | ``` 239 | 240 | # Update manually 241 | 242 | ``` 243 | cd ~/.hammerspoon/hotswitch-hs 244 | git pull 245 | ``` 246 | 247 | # Uninstallation 248 | 249 | ``` 250 | rm -rf ~/.hammerspoon/hotswitch-hs 251 | ``` 252 | 253 | # Development 254 | 255 | ## Requirements 256 | 257 | - Hammerspoon 258 | 259 | ## Steps 260 | 261 | 1. Edit codes. 262 | 2. Reload Hammerspoon config and check that it's working correctly. 263 | 264 | ### Owner's steps 265 | 266 | 3. Check latest git tag. (`git describe --tags --abbrev=0`) 267 | 4. Add a new git tag. 268 | 5. Push the tag. Then, the release on GitHub is automatically created. 269 | 270 | ### Option 271 | 272 | If you would update the class diagram, 273 | 1. Install PlantUML. (`brew install graphviz && brew install plantuml`) 274 | 2. Edit `doc/uml/class_diagram.pu`. 275 | 3. Execute `plantuml doc/uml -o ../img` at your terminal. 276 | 277 | ## Directory structure 278 | 279 | The class diagram is roughly like this. 280 | 281 | ![class_diagram](https://raw.githubusercontent.com/oniatsu/HotSwitch-HS/main/doc/img/class_diagram.png) 282 | 283 | ## Note 284 | 285 | - Pay attention to Lua's garvage collection. 286 | 287 | # Some ChangeLogs 288 | 289 | - v2.3.4: Modify focusing a Finder window 290 | - v2.2.6: Add a utility method 291 | - `hotswitchHs:switchToNextWindow()` 292 | - v2.2.5: Add option to always show the panel on primary screen 293 | - `hotswitchHs.setPanelToAlwaysShowOnPrimaryScreen()` 294 | - v2.1.5: Change saving keys to use bundleID instead of app name 295 | - If you used this app before this version, you need register keys again. 296 | - v2.1.0: Add auto updater 297 | - `hotswitchHs.enableAutoUpdate()` 298 | - v2.0.0: Connect Git tag with GitHub Release 299 | - v1.17: Add auto generated keys 300 | - v1.4: Change app info text to app icon on panel 301 | - v1.0: First release -------------------------------------------------------------------------------- /doc/img/class_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oniatsu/HotSwitch-HS/b8ccd9730f178c4d49d48a50a50e62639adaa5e8/doc/img/class_diagram.png -------------------------------------------------------------------------------- /doc/img/top.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oniatsu/HotSwitch-HS/b8ccd9730f178c4d49d48a50a50e62639adaa5e8/doc/img/top.png -------------------------------------------------------------------------------- /doc/uml/class_diagram.pu: -------------------------------------------------------------------------------- 1 | @startuml class_diagram 2 | 3 | class hotswitchHs { 4 | {static} +openOrClose() 5 | {static} +enableAutoUpdate() 6 | {static} +setAutoGeneratedKeys() 7 | {static} +enableAllSpaceWindows() 8 | {static} +enableDebug() 9 | } 10 | 11 | class MainController { 12 | +init() 13 | +openOrClose() 14 | +checkUpdate() 15 | +setAutoGeneratedKeys() 16 | +enableAllSpaceWindows() 17 | } 18 | class HotkeyController { 19 | +createHotkeys() 20 | +enableHotkeys() 21 | +disableHotkeys() 22 | } 23 | abstract Controller 24 | 25 | class KeyStatusModel { 26 | +createKeyStatuses() 27 | +resetAutoGeneratedKeys() 28 | +setSpecifiedAutoGeneratedKeys() 29 | } 30 | class SettingModel { 31 | +get() 32 | +set() 33 | +clear() 34 | } 35 | class WindowModel { 36 | +getCachedOrderedWindowsOrFetch() 37 | +getCreatedOrderedWindows() 38 | +refreshOrderedWindows() 39 | } 40 | class AppWatchModel { 41 | +watchAppliationDeactivated() 42 | +unwatchAppliationDeactivated() 43 | } 44 | abstract Model 45 | 46 | class PanelLayoutView { 47 | +show() 48 | +hide() 49 | +getSelectedRowPosition() 50 | +selectNextRow() 51 | +selectPreviousRow() 52 | +emphasisRow() 53 | +unumphasisRow() 54 | } 55 | class BaseCanvasView { 56 | +show() 57 | +hide() 58 | } 59 | class SelectedRowCanvasView { 60 | +show() 61 | +hide() 62 | +next() 63 | +previous() 64 | } 65 | class ToastView { 66 | +show() 67 | +hide() 68 | +toast() 69 | } 70 | abstract View { 71 | +show() 72 | +hide() 73 | } 74 | 75 | class Updater { 76 | {static} +check() 77 | } 78 | 79 | hotswitchHs --> MainController 80 | 81 | MainController *-> HotkeyController 82 | 83 | MainController --> PanelLayoutView 84 | MainController --> KeyStatusModel 85 | MainController --> SettingModel 86 | MainController --> WindowModel 87 | MainController --> AppWatchModel 88 | 89 | HotkeyController --> PanelLayoutView 90 | HotkeyController --> ToastView 91 | 92 | KeyStatusModel <|.. Model 93 | SettingModel <|.. Model 94 | WindowModel <|.. Model 95 | AppWatchModel <|.. Model 96 | 97 | PanelLayoutView <|.. View 98 | BaseCanvasView <|.. View 99 | SelectedRowCanvasView <|.. View 100 | ToastView <|.. View 101 | 102 | MainController <|.. Controller 103 | HotkeyController <|.. Controller 104 | 105 | PanelLayoutView *-- BaseCanvasView 106 | PanelLayoutView *-- SelectedRowCanvasView 107 | 108 | MainController --> Updater 109 | 110 | @endum -------------------------------------------------------------------------------- /hotswitch-hs.lua: -------------------------------------------------------------------------------- 1 | local Debugger = require("hotswitch-hs/lib/common/Debugger") 2 | local MainController = require("hotswitch-hs/lib/controller/MainController") 3 | local PreferenceModel = require("hotswitch-hs/lib/model/PreferenceModel") 4 | 5 | local mainController = MainController.new() 6 | 7 | return { 8 | openOrClose = function() mainController:openOrClose() end, 9 | switchToNextWindow = function() mainController:switchToNextWindow() end, 10 | setAutoGeneratedKeys = function(specifiedAutoGeneratedKeys) 11 | mainController:setAutoGeneratedKeys(specifiedAutoGeneratedKeys) 12 | end, 13 | enableAllSpaceWindows = function() mainController:enableAllSpaceWindows() end, 14 | enableAutoUpdate = function() mainController.checkUpdate() end, 15 | addJapaneseKeyboardLayoutSymbolKeys = function() mainController:addJapaneseKeyboardLayoutSymbolKeys() end, 16 | setPanelToAlwaysShowOnPrimaryScreen = function() mainController:setPanelToAlwaysShowOnPrimaryScreen() end, 17 | clearSettings = function() mainController:clearSettings() end, 18 | clearPreferences = function() PreferenceModel.clearPreferences() end, 19 | addKeyModifier = function() mainController:addKeyModifier() end, 20 | setLogLevel = function(logLevel) 21 | mainController:setLogLevel(logLevel) 22 | end, 23 | } 24 | -------------------------------------------------------------------------------- /lib/common/CanvasConstants.lua: -------------------------------------------------------------------------------- 1 | local CanvasConstants = { 2 | FONT_SIZE = 17, 3 | 4 | PANEL_W = 520, 5 | 6 | PADDING = 10, 7 | KEY_W = 24, 8 | KEY_LEFT_PADDING = 10, 9 | ROW_HEIGHT = 26, 10 | 11 | APP_ICON_W = 26, 12 | APP_NAME_W = 160, 13 | 14 | OUTLINE_RECTANGLE_ALPHA = 0.5, 15 | INLINE_RECTANGLE_ALPHA = 0.7, 16 | SELECTED_ROW_ALPHA = 0.2, 17 | TEXT_ALPHA = 1, 18 | 19 | TEXT_WHITE_VALUE = 0.92, 20 | 21 | TOAST_W = 520, 22 | TOAST_H = 46, 23 | TOAST_ALPHA = 0.8, 24 | TOAST_FONT_SIZE = 20, 25 | } 26 | 27 | -- for debug 28 | -- local BASE_ALPHA = 0.2 29 | -- canvasConstants.OUTLINE_RECTANGLE_ALPHA = BASE_ALPHA 30 | -- canvasConstants.INLINE_RECTANGLE_ALPHA = BASE_ALPHA 31 | -- canvasConstants.SELECTED_ROW_ALPHA = BASE_ALPHA 32 | -- canvasConstants.TEXT_ALPHA = BASE_ALPHA 33 | 34 | return CanvasConstants -------------------------------------------------------------------------------- /lib/common/Debugger.lua: -------------------------------------------------------------------------------- 1 | local debuggable = false 2 | local debugLog = hs.logger.new("hotswitch", "info") 3 | 4 | local function setDebuggable(flag) 5 | debuggable = flag 6 | end 7 | 8 | local function getDebuggable() 9 | return debuggable 10 | end 11 | 12 | local function log(value) 13 | if debuggable == false then 14 | return 15 | end 16 | 17 | local message 18 | local status, err = pcall(function() 19 | message = hs.inspect.inspect(value) 20 | 21 | debugLog.w(message) 22 | end) 23 | if status == false then 24 | message = value 25 | -- print("ERROR: debugLog") -- it has error that same as above. 26 | end 27 | 28 | -- if debuggable then 29 | -- hs.alert.show(message) 30 | -- end 31 | end 32 | 33 | local function setLogLevel(level) 34 | debugLog.setLogLevel(level) 35 | end 36 | 37 | local function alert(value) 38 | if debuggable then 39 | hs.alert.show(value) 40 | end 41 | end 42 | 43 | return { 44 | log = log, 45 | setLogLevel = setLogLevel, 46 | alert = alert, 47 | setDebuggable = setDebuggable, 48 | getDebuggable = getDebuggable, 49 | } 50 | -------------------------------------------------------------------------------- /lib/common/FrameCulculator.lua: -------------------------------------------------------------------------------- 1 | local Debugger = require("hotswitch-hs/lib/common/Debugger") 2 | local TimeChecker = require("hotswitch-hs/lib/common/TimeChecker") 3 | local CanvasConstants = require("hotswitch-hs/lib/common/CanvasConstants") 4 | 5 | local doesShowOnMainScreen = true 6 | 7 | local function calcBaseCanvasFrame(orderedWindows) 8 | local panelH = #orderedWindows * CanvasConstants.ROW_HEIGHT + CanvasConstants.PADDING * 4 9 | 10 | local targetScreenFrame 11 | if doesShowOnMainScreen then 12 | targetScreenFrame = hs.screen.mainScreen():frame() 13 | else 14 | targetScreenFrame = hs.screen.primaryScreen():frame() 15 | end 16 | local panelX = targetScreenFrame.x + targetScreenFrame.w / 2 - CanvasConstants.PANEL_W / 2 17 | local panelY = targetScreenFrame.y + targetScreenFrame.h / 2 - panelH / 2 18 | 19 | local baseCanvasFrame = { 20 | x = panelX, 21 | y = panelY, 22 | h = panelH, 23 | w = CanvasConstants.PANEL_W 24 | } 25 | return baseCanvasFrame 26 | end 27 | 28 | local function setShowingOnMainScreen(flag) 29 | doesShowOnMainScreen = flag 30 | end 31 | 32 | return { 33 | calcBaseCanvasFrame = calcBaseCanvasFrame, 34 | setShowingOnMainScreen = setShowingOnMainScreen, 35 | } -------------------------------------------------------------------------------- /lib/common/KeyConstants.lua: -------------------------------------------------------------------------------- 1 | local key = { 2 | yen = 95, 3 | semicolon = 41, 4 | colon = 39, 5 | atmark = 33, 6 | openbracket = 30, 7 | closebracket = 42, 8 | hyphen = 27, 9 | hat = 24, 10 | comma = 93, 11 | dot = 47, 12 | slash = 44, 13 | underscore = 94, 14 | } 15 | 16 | local KeyConstants = { 17 | 18 | -- Sometimes, some special keys don't work 19 | BASIC_KEYS = {"q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "a", "s", "d", "f", "g", "h", "j", "k", "l", "z", "x", "c", "v", "b", "n", "m", 20 | "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", 21 | "-", "[", "]", ".", "/"}, 22 | 23 | SHIFTABLE_KEYS = {"q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "a", "s", "d", "f", "g", "h", "j", "k", "l", "z", "x", "c", "v", "b", "n", "m"}, 24 | 25 | DEFAULT_AUTO_GENERATED_KEYS = {"s", "a", "d", "f", "j", "k", "l", "e", "w", "c", "m", "p", "g", "h", "i", "o", "r", "t", "u", "n", "v", "b", "q", "x", "y", "z", 26 | "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", 27 | "S", "A", "D", "F", "J", "K", "L", "E", "W", "C", "M", "P", "G", "H", "I", "O", "R", "T", "U", "N", "V", "B", "Q", "X", "Y", "Z"}, 28 | 29 | ADDITIONAL_LAYOUT = { 30 | 31 | JAPANESE = { 32 | { {"shift"}, "1", "!" }, 33 | { {"shift"}, "2", "\"" }, 34 | { {"shift"}, "3", "#" }, 35 | { {"shift"}, "4", "$" }, 36 | { {"shift"}, "5", "%" }, 37 | { {"shift"}, "6", "&" }, 38 | { {"shift"}, "7", "'" }, 39 | { {"shift"}, "8", "(" }, 40 | { {"shift"}, "9", ")" }, 41 | -- { {"shift"}, "0", "" }, -- not exist 42 | 43 | { {}, key.hat, "^" }, 44 | -- { {}, "\\", "\\" }, -- invalid 45 | -- { {}, "¥", "¥" }, -- invalid 46 | { {"shift"}, key.hyphen, "=" }, 47 | { {"shift"}, key.hat, "~" }, 48 | -- { {"shift"}, "\\", "|" }, -- invalid 49 | -- { {"shift"}, "¥", "|" }, -- invalid 50 | -- { {"shift"}, key.yen, "|" }, -- invalid 51 | 52 | { {}, key.atmark, "@" }, 53 | { {"shift"}, key.atmark, "`" }, 54 | 55 | { {}, key.semicolon, ";" }, 56 | { {}, key.colon, ":" }, 57 | 58 | -- { {}, key.openbracket, "[" }, 59 | -- { {}, key.closebracket, "]" }, 60 | 61 | { {"shift"}, key.semicolon, "+" }, 62 | { {"shift"}, key.colon, "*" }, 63 | { {"shift"}, key.openbracket, "{" }, 64 | { {"shift"}, key.closebracket, "}" }, 65 | -- { {}, key.comma, "," }, -- invalid 66 | -- { {"shift"}, key.comma, "<" }, --invalid 67 | { {"shift"}, key.dot, ">" }, 68 | { {"shift"}, key.slash, "?" }, 69 | 70 | { {}, key.underscore, "_" }, 71 | -- { {"shift"}, key.underscore, "" }, -- not exist 72 | }, 73 | 74 | }, 75 | 76 | } 77 | return KeyConstants -------------------------------------------------------------------------------- /lib/common/TimeChecker.lua: -------------------------------------------------------------------------------- 1 | local Debugger = require("hotswitch-hs/lib/common/Debugger") 2 | 3 | local TimeChecker = {} 4 | TimeChecker.new = function() 5 | local obj = {} 6 | 7 | obj.previousTime = hs.timer.secondsSinceEpoch() * 1000 8 | 9 | obj.diff = function(self, key) 10 | if Debugger.isDebugEnabled == false then return end 11 | 12 | local currentTime = hs.timer.secondsSinceEpoch() * 1000 13 | local timeDiff = currentTime - self.previousTime 14 | 15 | local roundedTimeDiff = math.floor(timeDiff + 0.5) 16 | if key then 17 | Debugger.log(key .. ": " .. roundedTimeDiff) 18 | else 19 | Debugger.log(roundedTimeDiff) 20 | end 21 | 22 | self.previousTime = currentTime 23 | return timeDiff 24 | end 25 | 26 | return obj 27 | end 28 | return TimeChecker -------------------------------------------------------------------------------- /lib/common/Updater.lua: -------------------------------------------------------------------------------- 1 | local Debugger = require("hotswitch-hs/lib/common/Debugger") 2 | local PreferenceModel = require("hotswitch-hs/lib/model/PreferenceModel") 3 | 4 | local URL = "https://api.github.com/repos/oniatsu/HotSwitch-HS/releases/latest" 5 | local APPLE_SCRIPT_FOR_GIT_TAG = 'do shell script "cd ~/.hammerspoon/hotswitch-hs && git describe --tags --abbrev=0"' 6 | local APPLE_SCRIPT_FOR_UPDATE = 'do shell script "cd ~/.hammerspoon/hotswitch-hs && git pull"' 7 | 8 | local obj = {} 9 | 10 | local showDialog = function(remoteVersion) 11 | hs.notify.new(function(notify) 12 | local activationTypes = notify:activationType() 13 | if activationTypes == hs.notify.activationTypes.contentsClicked then 14 | -- none 15 | elseif activationTypes == hs.notify.activationTypes.actionButtonClicked then 16 | hs.notify.new(function() end, { 17 | title = "HotSwitch-HS", 18 | informativeText = "Updating ...", 19 | withdrawAfter = 0, 20 | }):send() 21 | 22 | local isSuccess, parsedOutput, rawOutput = hs.osascript.applescript(APPLE_SCRIPT_FOR_UPDATE) 23 | if isSuccess then 24 | Debugger.log("SUCCESS: updating") 25 | hs.notify.new(function() end, { 26 | title = "HotSwitch-HS", 27 | informativeText = "Updating is finished!", 28 | withdrawAfter = 0, 29 | }):send() 30 | hs.reload() 31 | else 32 | Debugger.log("ERROR: updating") 33 | hs.notify.new(function() end, { 34 | title = "HotSwitch-HS", 35 | informativeText = "ERROR: Updating HotSwitch-HS has something errors.", 36 | withdrawAfter = 0, 37 | }):send() 38 | end 39 | end 40 | end, { 41 | title = "New HotSwitch-HS is available!", 42 | informativeText = "Click 'Update' button.\nIt will update to " .. remoteVersion, 43 | hasActionButton = true, 44 | actionButtonTitle = "Update", 45 | autoWithdraw = false, 46 | withdrawAfter = 0, 47 | }):send() 48 | end 49 | 50 | obj.check = function() 51 | Debugger.log("start checking") 52 | -- if 1 == 1 then showDialog("v3.0.0") return end -- for debug 53 | 54 | local currentDate = os.date("%Y-%m-%d") 55 | local lastCheckedDate = PreferenceModel.autoUpdate.getLastCheckedDate() 56 | -- lastCheckedDate = "2021-10-10" -- for debug 57 | if lastCheckedDate == currentDate then 58 | Debugger.log("Today is same as last checked date.") 59 | return 60 | end 61 | PreferenceModel.autoUpdate.setLastCheckedDate(currentDate) 62 | 63 | Debugger.log("HTTP request") 64 | hs.http.asyncGet(URL, nil, function(status, body, header) 65 | if status == 200 then 66 | local json = hs.json.decode(body) 67 | local remoteVersion = json.tag_name 68 | 69 | local isSuccess, localVersion, rawOutput = hs.osascript.applescript(APPLE_SCRIPT_FOR_GIT_TAG) 70 | if isSuccess then 71 | -- localVersion = "v1.9.9" -- for debug 72 | if localVersion == remoteVersion then 73 | Debugger.log("This HotSwitch-HS " .. localVersion .. " is latest.") 74 | else 75 | local lastCheckedVersion = PreferenceModel.autoUpdate.getLastCheckedVersion() 76 | -- lastCheckedVersion = "v1.9.9" -- for debug 77 | if lastCheckedVersion == remoteVersion then 78 | Debugger.log("The version update was already checked.") 79 | else 80 | Debugger.log("This HotSwitch-HS can be updated.") 81 | 82 | Debugger.log("setLastCheckedVersion(" .. remoteVersion .. ")") 83 | PreferenceModel.autoUpdate.setLastCheckedVersion(remoteVersion) 84 | 85 | showDialog(remoteVersion) 86 | end 87 | end 88 | else 89 | Debugger.log("Error: getting git tag") 90 | end 91 | else 92 | Debugger.log("Error: http request") 93 | end 94 | end) 95 | end 96 | 97 | return obj -------------------------------------------------------------------------------- /lib/controller/Controller.lua: -------------------------------------------------------------------------------- 1 | local Controller = {} 2 | Controller.new = function() 3 | local obj = {} 4 | 5 | return obj 6 | end 7 | return Controller -------------------------------------------------------------------------------- /lib/controller/HotkeyController.lua: -------------------------------------------------------------------------------- 1 | local Debugger = require("hotswitch-hs/lib/common/Debugger") 2 | local Controller = require("hotswitch-hs/lib/controller/Controller") 3 | local KeyConstants = require("hotswitch-hs/lib/common/KeyConstants") 4 | local ToastView = require("hotswitch-hs/lib/view/ToastView") 5 | 6 | local HotkeyController = {} 7 | HotkeyController.new = function(mainController) 8 | local obj = Controller.new() 9 | 10 | obj.windowModel = mainController.windowModel 11 | obj.settingModel = mainController.settingModel 12 | obj.keyStatusModel = mainController.keyStatusModel 13 | obj.appWatchModel = mainController.appWatchModel 14 | obj.panelLayoutView = mainController.panelLayoutView 15 | 16 | obj.allHotkeys = {} 17 | 18 | obj.createHotkeys = function(self) 19 | Debugger.log("DEBUG: createHotkeys") 20 | if #self.allHotkeys == 0 then 21 | self:createSpecialKeys() 22 | self:createCharacterKeys(KeyConstants.BASIC_KEYS, false, false) 23 | self:createCharacterKeys(KeyConstants.SHIFTABLE_KEYS, true, false) 24 | end 25 | end 26 | 27 | obj.addJapaneseKeyboardLayoutSymbolKeys = function(self) 28 | self:createAdditionalSymbolKeys(KeyConstants.ADDITIONAL_LAYOUT.JAPANESE) 29 | end 30 | 31 | obj.enableHotkeys = function(self) 32 | -- Debugger.log(self.allHotkeys) 33 | local hasError = false 34 | local lastError 35 | for i = 1, #self.allHotkeys do 36 | local status, err = pcall(function() 37 | self.allHotkeys[i]:enable() 38 | end) 39 | if status == false then 40 | hasError = true 41 | lastError = err 42 | end 43 | end 44 | if hasError then 45 | Debugger.log("ERROR (enable Hotkeys) : " .. lastError) 46 | end 47 | end 48 | 49 | obj.disableHotkeys = function(self) 50 | local hasError = false 51 | local lastError 52 | for i = 1, #self.allHotkeys do 53 | local status, err = pcall(function() 54 | self.allHotkeys[i]:disable() 55 | end) 56 | if status == false then 57 | hasError = true 58 | lastError = err 59 | end 60 | end 61 | if hasError then 62 | Debugger.log("ERROR (disable Hotkeys) : " .. lastError) 63 | end 64 | end 65 | 66 | obj.createSpecialKeys = function(self) 67 | table.insert(self.allHotkeys, hs.hotkey.new({}, "down", function() 68 | self.isRegistrationMode = false 69 | self.panelLayoutView:selectNextRow(self.windowModel) 70 | end, nil, function() 71 | self.isRegistrationMode = false 72 | self.panelLayoutView:selectNextRow(self.windowModel) 73 | end)) 74 | table.insert(self.allHotkeys, hs.hotkey.new({}, "up", function() 75 | self.isRegistrationMode = false 76 | self.panelLayoutView:selectPreviousRow(self.windowModel) 77 | end, nil, function() 78 | self.isRegistrationMode = false 79 | self.panelLayoutView:selectPreviousRow(self.windowModel) 80 | end)) 81 | table.insert(self.allHotkeys, hs.hotkey.new({}, "tab", function() 82 | self.isRegistrationMode = false 83 | self.panelLayoutView:selectNextRow(self.windowModel) 84 | end, nil, function() 85 | self.isRegistrationMode = false 86 | self.panelLayoutView:selectNextRow(self.windowModel) 87 | end)) 88 | table.insert(self.allHotkeys, hs.hotkey.new({ "shift" }, "tab", function() 89 | self.isRegistrationMode = false 90 | self.panelLayoutView:selectPreviousRow(self.windowModel) 91 | end, nil, function() 92 | self.isRegistrationMode = false 93 | self.panelLayoutView:selectPreviousRow(self.windowModel) 94 | end)) 95 | 96 | table.insert(self.allHotkeys, hs.hotkey.new({}, "return", function() self:returnAction() end)) 97 | table.insert(self.allHotkeys, hs.hotkey.new({}, "space", function() self:spaceAction() end)) 98 | table.insert(self.allHotkeys, hs.hotkey.new({}, "delete", function() self:deleteAction() end)) 99 | table.insert(self.allHotkeys, hs.hotkey.new({}, "escape", function() self:escapeAction() end)) 100 | end 101 | 102 | obj.returnAction = function(self) 103 | self.isRegistrationMode = false 104 | 105 | self.windowModel.focusWindow(self.windowModel:getCachedOrderedWindowsOrFetch()[ 106 | self.panelLayoutView.selectedRowCanvasView.position]) 107 | 108 | self:finish() 109 | end 110 | 111 | obj.spaceAction = function(self) 112 | if self.isRegistrationMode then 113 | self.isRegistrationMode = false 114 | self.panelLayoutView:unemphasisRow() 115 | else 116 | self.isRegistrationMode = true 117 | self.panelLayoutView:emphasisRow() 118 | end 119 | end 120 | 121 | obj.escapeAction = function(self) 122 | if self.isRegistrationMode then 123 | self.isRegistrationMode = false 124 | self.panelLayoutView:unemphasisRow() 125 | else 126 | self.windowModel:focusPreviousWindowForCancel() 127 | 128 | self:finish() 129 | end 130 | end 131 | 132 | obj.deleteAction = function(self) 133 | self.isRegistrationMode = false 134 | local window = self.windowModel:getCachedOrderedWindowsOrFetch()[self.panelLayoutView:getSelectedRowPosition()] 135 | 136 | local bundleId = window:application():bundleID() 137 | 138 | local settings = self.settingModel.get() 139 | for i = 1, #settings do 140 | local setting = settings[i] 141 | 142 | if setting.app == bundleId then 143 | local windowId = window:id() 144 | 145 | for j = 1, #self.keyStatusModel.registeredKeyStatuses do 146 | local keyStatus = self.keyStatusModel.registeredKeyStatuses[j] 147 | if keyStatus.windowId == windowId then 148 | local targetKey = keyStatus.key 149 | 150 | for k = 0, #setting.keys do 151 | if setting.keys[k] == targetKey then 152 | table.remove(setting.keys, k) 153 | break 154 | end 155 | end 156 | break 157 | end 158 | end 159 | break 160 | end 161 | end 162 | 163 | for i = 1, #settings do 164 | if #settings[i].keys == 0 then 165 | table.remove(settings, i) 166 | break 167 | end 168 | end 169 | 170 | self.settingModel.set(settings) 171 | self.keyStatusModel:resetAutoGeneratedKeys() 172 | 173 | self.keyStatusModel:createKeyStatuses() 174 | self.panelLayoutView:show() 175 | self.panelLayoutView:unemphasisRow() 176 | end 177 | 178 | obj.createAdditionalSymbolKeys = function(self, keybinds) 179 | for i, keybind in ipairs(keybinds) do 180 | local modifier = keybind[1] 181 | local keycode = keybind[2] 182 | local key = keybind[3] 183 | table.insert(self.allHotkeys, hs.hotkey.new(modifier, keycode, function() 184 | Debugger.log(key) 185 | self:doKeyAction(key) 186 | end)) 187 | end 188 | end 189 | 190 | obj.doKeyAction = function(self, key) 191 | Debugger.log("doKeyAction: " .. key) 192 | if self.isRegistrationMode then 193 | Debugger.log("DEBUG: registration mode") 194 | self.isRegistrationMode = false 195 | 196 | local window = self.windowModel:getCachedOrderedWindowsOrFetch()[ 197 | self.panelLayoutView.selectedRowCanvasView.position] 198 | local windowId = window:id() 199 | 200 | local bundleId = window:application():bundleID() 201 | 202 | local hasAppSetting = false 203 | local settings = self.settingModel.get() 204 | for i = 1, #settings do 205 | local setting = settings[i] 206 | 207 | if setting.app == bundleId then 208 | hasAppSetting = true 209 | 210 | local targetKey 211 | for j = 1, #self.keyStatusModel.registeredKeyStatuses do 212 | local keyStatus = self.keyStatusModel.registeredKeyStatuses[j] 213 | if keyStatus.windowId == windowId then 214 | targetKey = keyStatus.key 215 | break 216 | end 217 | end 218 | 219 | if targetKey == nil then 220 | if self.checkTableHasTheValue(setting.keys, key) then 221 | -- It cannot find position to register the key. 222 | ToastView.new(self.windowModel:getCachedOrderedWindowsOrFetch()):toast("NOTICE: The key is already registered on the same app.") 223 | else 224 | table.insert(setting.keys, key) 225 | end 226 | else 227 | local newKeys = {} 228 | if self.checkTableHasTheValue(setting.keys, key) then 229 | local sameValueIndex = self.getIndexOfTableHavingTheValue(setting.keys, key) 230 | for j = 1, #setting.keys do 231 | local settingKey = setting.keys[j] 232 | if settingKey == targetKey then 233 | newKeys[j] = key 234 | elseif j == sameValueIndex then 235 | newKeys[j] = targetKey 236 | else 237 | newKeys[j] = settingKey 238 | end 239 | end 240 | else 241 | for j = 1, #setting.keys do 242 | local settingKey = setting.keys[j] 243 | if settingKey == targetKey then 244 | newKeys[j] = key 245 | else 246 | newKeys[j] = settingKey 247 | end 248 | end 249 | end 250 | setting.keys = newKeys 251 | end 252 | else 253 | local targetKey = key 254 | for j = 1, #setting.keys do 255 | local settingKey = setting.keys[j] 256 | if settingKey == targetKey then 257 | table.remove(setting.keys, j) 258 | break 259 | end 260 | end 261 | end 262 | end 263 | 264 | for i = 1, #settings do 265 | if #settings[i].keys == 0 then 266 | table.remove(settings, i) 267 | break 268 | end 269 | end 270 | 271 | if hasAppSetting == false then 272 | table.insert(settings, { 273 | app = bundleId, 274 | keys = { key } 275 | }) 276 | end 277 | 278 | self.settingModel.set(settings) 279 | self.keyStatusModel:resetAutoGeneratedKeys() 280 | 281 | self.keyStatusModel:createKeyStatuses() 282 | self.panelLayoutView:show() 283 | self.panelLayoutView:unemphasisRow() 284 | else 285 | Debugger.log("DEBUG: normal mode") 286 | local targetWindow 287 | for j = 1, #self.keyStatusModel.registeredAndAutoGeneratedKeyStatuses do 288 | local keyStatus = self.keyStatusModel.registeredAndAutoGeneratedKeyStatuses[j] 289 | if keyStatus.key == key then 290 | targetWindow = keyStatus.window 291 | break 292 | end 293 | end 294 | 295 | if targetWindow ~= nil then 296 | self:finish() 297 | 298 | self.windowModel.focusWindow(targetWindow) 299 | end 300 | end 301 | end 302 | 303 | obj.createCharacterKeys = function(self, keys, isShiftable, hasModifier) 304 | if isShiftable then 305 | Debugger.log("DEBUG: createCharacterKeys" .. " (shift)") 306 | else 307 | Debugger.log("DEBUG: createCharacterKeys") 308 | end 309 | 310 | local keybindModifier 311 | if isShiftable then 312 | if hasModifier then 313 | keybindModifier = { "option", "command", "control", "shift" } 314 | else 315 | keybindModifier = { "shift" } 316 | end 317 | else 318 | if hasModifier then 319 | keybindModifier = { "option", "command", "control" } 320 | else 321 | keybindModifier = {} 322 | end 323 | end 324 | 325 | for i = 1, #keys do 326 | local key = keys[i] 327 | 328 | if isShiftable then 329 | Debugger.log("DEBUG: shift + " .. key) 330 | else 331 | Debugger.log("DEBUG: " .. key) 332 | end 333 | table.insert(self.allHotkeys, hs.hotkey.new(keybindModifier, key, function() 334 | if isShiftable then 335 | key = key:upper() 336 | end 337 | self:doKeyAction(key) 338 | end)) 339 | end 340 | end 341 | 342 | obj.finish = function(self) 343 | self.panelLayoutView:hide() 344 | self:disableHotkeys() 345 | self.appWatchModel:unwatchAppliationDeactivated() 346 | end 347 | 348 | obj.checkTableHasTheValue = function(table, value) 349 | for i, tableValue in ipairs(table) do 350 | if tableValue == value then 351 | return true 352 | end 353 | end 354 | return false 355 | end 356 | 357 | obj.getIndexOfTableHavingTheValue = function(table, value) 358 | for i, tableValue in ipairs(table) do 359 | if tableValue == value then 360 | return i 361 | end 362 | end 363 | return 0 364 | end 365 | 366 | obj.addKeyModifier = function(self) 367 | self:createCharacterKeys(KeyConstants.BASIC_KEYS, false, true) 368 | self:createCharacterKeys(KeyConstants.SHIFTABLE_KEYS, true, true) 369 | end 370 | 371 | return obj 372 | end 373 | return HotkeyController 374 | -------------------------------------------------------------------------------- /lib/controller/MainController.lua: -------------------------------------------------------------------------------- 1 | local Debugger = require("hotswitch-hs/lib/common/Debugger") 2 | local TimeChecker = require("hotswitch-hs/lib/common/TimeChecker") 3 | local Updater = require("hotswitch-hs/lib/common/Updater") 4 | local Controller = require("hotswitch-hs/lib/controller/Controller") 5 | local WindowModel = require("hotswitch-hs/lib/model/WindowModel") 6 | local SettingsModel = require("hotswitch-hs/lib/model/SettingModel") 7 | local KeyStatusModel = require("hotswitch-hs/lib/model/KeyStatusModel") 8 | local AppWatchModel = require("hotswitch-hs/lib/model/AppWatchModel") 9 | local PanelLayoutView = require("hotswitch-hs/lib/view/PanelLayoutView") 10 | local HotkeyController = require("hotswitch-hs/lib/controller/HotkeyController") 11 | local FrameCulculator = require("hotswitch-hs/lib/common/FrameCulculator") 12 | 13 | local MainController = {} 14 | MainController.new = function() 15 | local obj = Controller.new() 16 | 17 | obj.isRegistrationMode = false 18 | 19 | obj.windowModel = WindowModel.new() 20 | obj.settingModel = SettingsModel.new() 21 | obj.keyStatusModel = KeyStatusModel.new(obj.windowModel, obj.settingModel) 22 | obj.appWatchModel = AppWatchModel.new() 23 | 24 | obj.panelLayoutView = PanelLayoutView.new(obj.windowModel, obj.settingModel, obj.keyStatusModel) 25 | 26 | obj.hotkeyController = HotkeyController.new(obj) 27 | 28 | obj.panelLayoutView:setClickCallback(function(position) 29 | obj.windowModel.focusWindow(obj.windowModel:getCachedOrderedWindowsOrFetch()[position]) 30 | obj:finish() 31 | end) 32 | 33 | obj.openOrClose = function(self) 34 | if self.panelLayoutView.isOpen then 35 | self.windowModel:focusPreviousWindowForCancel() 36 | self:finish() 37 | else 38 | -- local t1 = TimeChecker.new() 39 | 40 | self.windowModel.previousWindow = hs.window.frontmostWindow() 41 | 42 | -- Enable hotkeys before refresh windows, 43 | -- because refreshing windows is sometimes slow and take time. 44 | -- local t2 = TimeChecker.new() 45 | self.hotkeyController:enableHotkeys() 46 | -- t2:diff("MainController:enableHotkeys") 47 | self.panelLayoutView:activateHammerspoonWindow() 48 | -- t2:diff("MainController:activateHammerspoonWindow") 49 | 50 | self.windowModel:refreshOrderedWindows() 51 | -- t2:diff("MainController:refreshOrderedWindows") 52 | self.keyStatusModel:createKeyStatuses() 53 | -- t2:diff("MainController:createKeyStatuses") 54 | self.panelLayoutView:show() 55 | -- t2:diff("MainController:show") 56 | self.appWatchModel:watchAppliationDeactivated(function() self:finish() end) 57 | -- t2:diff("MainController:watchAppliationDeactivated") 58 | 59 | -- t1:diff("All") 60 | end 61 | end 62 | 63 | obj.switchToNextWindow = function(self) 64 | self.windowModel:focusNextWindow() 65 | end 66 | 67 | obj.clearSettings = function(self) 68 | self.settingModel.clear() 69 | end 70 | 71 | obj.setAutoGeneratedKeys = function(self, specifiedAutoGeneratedKeys) 72 | self.keyStatusModel:setSpecifiedAutoGeneratedKeys(specifiedAutoGeneratedKeys) 73 | self.keyStatusModel:resetAutoGeneratedKeys() 74 | end 75 | 76 | obj.enableAllSpaceWindows = function(self) 77 | self.windowModel:enableAllSpaceWindows() 78 | end 79 | 80 | obj.finish = function(self) 81 | self.panelLayoutView:hide() 82 | self.hotkeyController:disableHotkeys() 83 | self.appWatchModel:unwatchAppliationDeactivated() 84 | end 85 | 86 | obj.checkUpdate = function() 87 | Updater.check(); 88 | end 89 | 90 | obj.addJapaneseKeyboardLayoutSymbolKeys = function(self) 91 | self.hotkeyController:addJapaneseKeyboardLayoutSymbolKeys() 92 | end 93 | 94 | obj.setPanelToAlwaysShowOnPrimaryScreen = function(self) 95 | FrameCulculator.setShowingOnMainScreen(false) 96 | end 97 | 98 | obj.addKeyModifier = function(self) 99 | self.hotkeyController:addKeyModifier() 100 | end 101 | 102 | --- * loglevel - can be 'nothing', 'error', 'warning', 'info', 'debug', or 'verbose', or a corresponding number 103 | --- between 0 and 5 104 | obj.setLogLevel = function(self, logLevel) 105 | hs.logger.setModulesLogLevel(logLevel) 106 | Debugger.setLogLevel(logLevel) 107 | end 108 | 109 | --- init 110 | obj:setLogLevel("nothing") 111 | obj.hotkeyController:createHotkeys() 112 | obj.keyStatusModel:resetAutoGeneratedKeys() 113 | obj.windowModel:init() 114 | 115 | return obj 116 | end 117 | return MainController 118 | -------------------------------------------------------------------------------- /lib/model/AppWatchModel.lua: -------------------------------------------------------------------------------- 1 | local Debugger = require("hotswitch-hs/lib/common/Debugger") 2 | local Model = require("hotswitch-hs/lib/model/Model") 3 | 4 | local AppWatchModel = {} 5 | AppWatchModel.new = function(windowModel, settingModel, keyStatusModel) 6 | local obj = Model.new() 7 | 8 | obj.applicationWatcher = nil 9 | 10 | obj.watchAppliationDeactivated = function(self, callback) 11 | if self.applicationWatcher == nil then 12 | self.applicationWatcher = hs.application.watcher.new(function(appName, eventType, app) 13 | if appName == "Hammerspoon" and eventType == hs.application.watcher.deactivated then 14 | callback() 15 | end 16 | end) 17 | self.applicationWatcher:start() 18 | end 19 | end 20 | 21 | obj.unwatchAppliationDeactivated = function(self) 22 | if self.applicationWatcher ~= nil then 23 | self.applicationWatcher:stop() 24 | self.applicationWatcher = nil 25 | end 26 | end 27 | 28 | return obj 29 | end 30 | return AppWatchModel -------------------------------------------------------------------------------- /lib/model/KeyStatusModel.lua: -------------------------------------------------------------------------------- 1 | local Debugger = require("hotswitch-hs/lib/common/Debugger") 2 | local Model = require("hotswitch-hs/lib/model/Model") 3 | local KeyConstants = require("hotswitch-hs/lib/common/KeyConstants") 4 | 5 | --[[ 6 | data format: 7 | 8 | keyStatuses = { 9 | { 10 | app: "com.apple.Safari", 11 | key = "s", 12 | windowId = 123, 13 | window = hs.window object, 14 | isAutoGenerated = false, 15 | }, 16 | { 17 | app: "com.apple.Safari", 18 | key = "f", 19 | windowId = 321, 20 | window = hs.window object, 21 | isAutoGenerated = false, 22 | }, 23 | { 24 | app: "com.apple.mail", 25 | key = "m", 26 | windowId = 456, 27 | window = hs.window object, 28 | isAutoGenerated = false, 29 | }, 30 | { 31 | app: "com.apple.iCal", 32 | key = "c", 33 | windowId = 789, 34 | window = hs.window object, 35 | isAutoGenerated = true, 36 | } 37 | } 38 | ]] 39 | 40 | local KeyStatusModel = {} 41 | KeyStatusModel.new = function(windowModel, settingModel) 42 | local obj = Model.new() 43 | 44 | obj.windowModel = windowModel 45 | obj.settingModel = settingModel 46 | 47 | obj.registeredKeyStatuses = {} 48 | obj.registeredAndAutoGeneratedKeyStatuses = {} 49 | 50 | obj.specifiedAutoGeneratedKeys = nil 51 | obj.autoGeneratedKeys = KeyConstants.DEFAULT_AUTO_GENERATED_KEYS 52 | 53 | obj.createKeyStatuses = function(self) 54 | local settings = self.settingModel.get() 55 | 56 | local windowIdBasedOrderedWindows = self.windowModel:getCreatedOrderedWindows() 57 | 58 | local registeredKeyStatuses = {} 59 | local registeredAndAutoGeneratedKeyStatuses = {} 60 | 61 | local usedIndexOfAutoGeneratedKeys = 0 62 | for i = 1, #windowIdBasedOrderedWindows do 63 | local window = windowIdBasedOrderedWindows[i] 64 | 65 | local windowId = window:id() 66 | 67 | local bundleId = window:application():bundleID() 68 | 69 | local hasSettingKey = false 70 | for j = 1, #settings do 71 | local setting = settings[j] 72 | if setting.app == bundleId then 73 | if setting.keys[1] ~= nil then 74 | hasSettingKey = true 75 | 76 | local keyStatus = { 77 | app = bundleId, 78 | windowId = windowId, 79 | key = setting.keys[1], 80 | window = window, 81 | isAutoGenerated = false, 82 | } 83 | 84 | table.insert(registeredKeyStatuses, keyStatus) 85 | table.insert(registeredAndAutoGeneratedKeyStatuses, keyStatus) 86 | 87 | table.remove(setting.keys, 1) 88 | end 89 | break 90 | end 91 | end 92 | 93 | if hasSettingKey == false then 94 | if usedIndexOfAutoGeneratedKeys < #self.autoGeneratedKeys then 95 | usedIndexOfAutoGeneratedKeys = usedIndexOfAutoGeneratedKeys + 1 96 | table.insert(registeredAndAutoGeneratedKeyStatuses, { 97 | app = bundleId, 98 | windowId = windowId, 99 | key = self.autoGeneratedKeys[usedIndexOfAutoGeneratedKeys], 100 | window = window, 101 | isAutoGenerated = true, 102 | }) 103 | end 104 | end 105 | end 106 | 107 | self.registeredKeyStatuses = registeredKeyStatuses 108 | self.registeredAndAutoGeneratedKeyStatuses = registeredAndAutoGeneratedKeyStatuses 109 | end 110 | 111 | obj.resetAutoGeneratedKeys = function(self) 112 | local specifiedAutoGeneratedKeys 113 | if self.specifiedAutoGeneratedKeys ~= nil then 114 | specifiedAutoGeneratedKeys = self.specifiedAutoGeneratedKeys 115 | else 116 | specifiedAutoGeneratedKeys = KeyConstants.DEFAULT_AUTO_GENERATED_KEYS 117 | end 118 | 119 | local settings = self.settingModel.get() 120 | 121 | local allSettingKeys = {} 122 | for i, setting in ipairs(settings) do 123 | local keys = setting.keys 124 | for j, key in ipairs(keys) do 125 | table.insert(allSettingKeys, key) 126 | end 127 | end 128 | 129 | local autoGeneratedKeys = {} 130 | for i, specifiedAutoGeneratedKey in ipairs(specifiedAutoGeneratedKeys) do 131 | local hasSettingKey = false 132 | for j, settingKey in ipairs(allSettingKeys) do 133 | if settingKey == specifiedAutoGeneratedKey then 134 | hasSettingKey = true 135 | break 136 | end 137 | end 138 | if hasSettingKey == false then 139 | table.insert(autoGeneratedKeys, specifiedAutoGeneratedKey) 140 | end 141 | end 142 | 143 | self.autoGeneratedKeys = autoGeneratedKeys 144 | end 145 | 146 | obj.setSpecifiedAutoGeneratedKeys = function(self, specifiedAutoGeneratedKeys) 147 | self.specifiedAutoGeneratedKeys = specifiedAutoGeneratedKeys 148 | end 149 | 150 | return obj 151 | end 152 | return KeyStatusModel -------------------------------------------------------------------------------- /lib/model/Model.lua: -------------------------------------------------------------------------------- 1 | local Model = {} 2 | Model.new = function() 3 | local obj = {} 4 | 5 | return obj 6 | end 7 | return Model -------------------------------------------------------------------------------- /lib/model/PreferenceModel.lua: -------------------------------------------------------------------------------- 1 | local Debugger = require("hotswitch-hs/lib/common/Debugger") 2 | 3 | local PREFERENCE_KEY = "hotswitch-hs-preference" 4 | 5 | --[[ 6 | data format: 7 | 8 | preferences = { 9 | autoUpdate = { 10 | lastCheckedDate = "2021/11/13", 11 | lastCheckedVersion = "v2.0.1", 12 | }, 13 | } 14 | ]] 15 | 16 | local getPreferences = function() 17 | local preferences = hs.settings.get(PREFERENCE_KEY) 18 | if preferences == nil then 19 | preferences = {} 20 | end 21 | return preferences 22 | end 23 | 24 | local setPreferences = function(value) 25 | hs.settings.set(PREFERENCE_KEY, value) 26 | end 27 | 28 | local clearPreferences = function() 29 | hs.settings.clear(PREFERENCE_KEY) 30 | end 31 | 32 | local getAutoUpdate = function() 33 | local preferences = getPreferences() 34 | local autoUpdate 35 | if preferences.autoUpdate == nil then 36 | autoUpdate = {} 37 | else 38 | autoUpdate = preferences.autoUpdate 39 | end 40 | return autoUpdate 41 | end 42 | 43 | local setAutoUpdate = function(autoUpdate) 44 | local preferences = getPreferences() 45 | preferences.autoUpdate = autoUpdate 46 | setPreferences(preferences) 47 | end 48 | 49 | local obj = {} 50 | 51 | obj.clearPreferences = clearPreferences 52 | 53 | obj.autoUpdate = {} 54 | 55 | obj.autoUpdate.getLastCheckedDate = function() 56 | return getAutoUpdate().lastCheckedDate 57 | end 58 | 59 | obj.autoUpdate.setLastCheckedDate = function(date) 60 | local autoUpdate = getAutoUpdate() 61 | autoUpdate.lastCheckedDate = date 62 | setAutoUpdate(autoUpdate) 63 | end 64 | 65 | obj.autoUpdate.getLastCheckedVersion = function() 66 | return getAutoUpdate().lastCheckedVersion 67 | end 68 | 69 | obj.autoUpdate.setLastCheckedVersion = function(version) 70 | local autoUpdate = getAutoUpdate() 71 | autoUpdate.lastCheckedVersion = version 72 | setAutoUpdate(autoUpdate) 73 | end 74 | 75 | return obj -------------------------------------------------------------------------------- /lib/model/SettingModel.lua: -------------------------------------------------------------------------------- 1 | local Debugger = require("hotswitch-hs/lib/common/Debugger") 2 | local Model = require("hotswitch-hs/lib/model/Model") 3 | 4 | local SETTING_KEY = "hotswitch-hs" 5 | 6 | --[[ 7 | data format: 8 | 9 | settings = { 10 | { 11 | app = "com.apple.Safari", 12 | keys = { 13 | "s", 14 | "f", 15 | } 16 | }, 17 | { 18 | app = "com.apple.mail", 19 | keys = { 20 | "m", 21 | } 22 | }, 23 | { 24 | app = "com.apple.iCal", 25 | keys = { 26 | "c", 27 | } 28 | } 29 | } 30 | ]] 31 | 32 | local SettingModel = {} 33 | SettingModel.new = function() 34 | local obj = Model.new() 35 | 36 | obj.get = function() 37 | local settings = hs.settings.get(SETTING_KEY) 38 | if settings == nil then 39 | settings = {} 40 | end 41 | return settings 42 | end 43 | 44 | obj.set = function(value) 45 | hs.settings.set(SETTING_KEY, value) 46 | end 47 | 48 | obj.clear = function() 49 | hs.settings.clear(SETTING_KEY) 50 | end 51 | 52 | return obj 53 | end 54 | return SettingModel -------------------------------------------------------------------------------- /lib/model/WindowModel.lua: -------------------------------------------------------------------------------- 1 | local Debugger = require("hotswitch-hs/lib/common/Debugger") 2 | local TimeChecker = require("hotswitch-hs/lib/common/TimeChecker") 3 | local Model = require("hotswitch-hs/lib/model/Model") 4 | 5 | -- local SUBSCRIPTION_TARGET = { hs.window.filter.windowAllowed, hs.window.filter.windowCreated, hs.window.filter.windowsChanged, hs.window.filter.windowTitleChanged } 6 | -- local SUBSCRIPTION_TARGET = { hs.window.filter.hasWindow } 7 | local SUBSCRIPTION_TARGET = { hs.window.filter.windowVisible } 8 | 9 | local FINDER_BUNDLE_ID = "com.apple.finder" 10 | 11 | local WindowModel = {} 12 | WindowModel.new = function() 13 | local obj = Model.new() 14 | 15 | obj.cachedOrderedWindows = nil 16 | obj.previousWindow = nil 17 | obj.lastFinderWindowId = 0 18 | 19 | obj.windowFilter = hs.window.filter.defaultCurrentSpace 20 | -- obj.windowFilter = hs.window.filter.default 21 | obj.subscriptionCallback = function() 22 | end 23 | 24 | obj.init = function(self) 25 | -- Workaround: 26 | -- This is necessaary to make it faster to get all windows. 27 | -- If the `subscribe` is not set, the getting windows is slow. 28 | obj.windowFilter:subscribe(SUBSCRIPTION_TARGET, self.subscriptionCallback) 29 | end 30 | 31 | obj.enableAllSpaceWindows = function(self) 32 | obj.windowFilter:unsubscribe(SUBSCRIPTION_TARGET, self.subscriptionCallback) 33 | obj.windowFilter = hs.window.filter.default 34 | obj.windowFilter:subscribe(SUBSCRIPTION_TARGET, self.subscriptionCallback) 35 | end 36 | 37 | obj.getCachedOrderedWindowsOrFetch = function(self) 38 | if self.cachedOrderedWindows == nil then 39 | return self:refreshOrderedWindows() 40 | end 41 | return self.cachedOrderedWindows 42 | end 43 | 44 | obj.copyCachedOrderedWindows = function(self) 45 | if self.cachedOrderedWindows == nil then 46 | self:refreshOrderedWindows() 47 | end 48 | 49 | local copiedCachedOrderedWindows = {} 50 | for i = 1, #self.cachedOrderedWindows do 51 | table.insert(copiedCachedOrderedWindows, self.cachedOrderedWindows[i]) 52 | end 53 | return copiedCachedOrderedWindows 54 | end 55 | 56 | obj.getCreatedOrderedWindows = function(self) 57 | local windows = self.windowFilter:getWindows(hs.window.filter.sortByCreated) 58 | windows = self.removeInvalidWindows(windows) 59 | windows = self:removeUnusableFinderWindowsForCreatedOrderedWindows(windows) 60 | return windows 61 | 62 | -- Another way: sorting by window id 63 | -- local windowIdBasedOrderedWindows = self:copyCachedOrderedWindows() 64 | -- table.sort(windowIdBasedOrderedWindows, function(a, b) 65 | -- return (a:id() < b:id()) 66 | -- end) 67 | -- return windowIdBasedOrderedWindows 68 | end 69 | 70 | -- Note: "hs.window.orderedWindows()" cannot get "Hammerspoon Console" window. I don't know why that. 71 | obj.refreshOrderedWindows = function(self) 72 | -- Sometimes, getting window is failed. 73 | local orderedWindows = self.windowFilter:getWindows(hs.window.filter.sortByFocusedLast) 74 | 75 | -- Here is another way, but it's slow. 76 | -- local orderedWindows = hs.window.orderedWindows() 77 | 78 | orderedWindows = self.removeInvalidWindows(orderedWindows) 79 | orderedWindows = self:removeUnusableFinderWindows(orderedWindows) 80 | 81 | self.cachedOrderedWindows = orderedWindows 82 | return orderedWindows 83 | end 84 | 85 | obj.removeInvalidWindows = function(orderedWindows) 86 | local cleanedOrderedWindows = {} 87 | for i = 1, #orderedWindows do 88 | local window = orderedWindows[i] 89 | -- local role = window:role() 90 | local subrole = window:subrole() 91 | -- local id = window:id() 92 | -- local isVisible = window:isVisible() 93 | -- local isStandard = window:isStandard() 94 | -- Debugger.log(window:application():name() .. " | " .. role .. 95 | -- " : " .. 96 | -- subrole .. 97 | -- " | " .. id .. " | " .. tostring(isVisible) .. " | " .. tostring(isStandard) .. " | " .. tabCount) 98 | if subrole ~= "AXUnknown" and subrole ~= "AXSystemDialog" and subrole ~= "" then 99 | table.insert(cleanedOrderedWindows, window) 100 | 101 | -- not work 102 | -- local applicationName = window:application():name() 103 | -- if applicationName == "Finder" then 104 | -- local tabCount = window:tabCount() 105 | -- Debugger.log(applicationName .. " | " .. tabCount) 106 | -- table.insert(cleanedOrderedWindows, window) 107 | -- if tabCount > 0 then 108 | -- table.insert(cleanedOrderedWindows, window) 109 | -- end 110 | -- else 111 | -- table.insert(cleanedOrderedWindows, window) 112 | -- end 113 | end 114 | end 115 | return cleanedOrderedWindows 116 | end 117 | 118 | obj.removeUnusableFinderWindows = function(self, orderedWindows) 119 | local cleanedOrderedWindows = {} 120 | local finderWindowsCount = 0 121 | for i = 1, #orderedWindows do 122 | local window = orderedWindows[i] 123 | local bundleID = window:application():bundleID() 124 | if bundleID == FINDER_BUNDLE_ID then 125 | finderWindowsCount = finderWindowsCount + 1 126 | if finderWindowsCount == 1 then 127 | self.lastFinderWindowId = window:id() 128 | table.insert(cleanedOrderedWindows, window) 129 | end 130 | else 131 | table.insert(cleanedOrderedWindows, window) 132 | end 133 | end 134 | return cleanedOrderedWindows 135 | end 136 | 137 | obj.removeUnusableFinderWindowsForCreatedOrderedWindows = function(self, orderedWindows) 138 | local cleanedOrderedWindows = {} 139 | local finderWindowsCount = 0 140 | for i = 1, #orderedWindows do 141 | local window = orderedWindows[i] 142 | local bundleID = window:application():bundleID() 143 | if bundleID == FINDER_BUNDLE_ID then 144 | local windowId = window:id() 145 | if finderWindowsCount == 0 and windowId == self.lastFinderWindowId then 146 | table.insert(cleanedOrderedWindows, window) 147 | finderWindowsCount = finderWindowsCount + 1 148 | end 149 | else 150 | table.insert(cleanedOrderedWindows, window) 151 | end 152 | end 153 | return cleanedOrderedWindows 154 | end 155 | 156 | obj.focusPreviousWindowForCancel = function(self) 157 | if self.previousWindow ~= nil then 158 | self.focusWindow(self.previousWindow) 159 | else 160 | self.focusWindow(self.getCachedOrderedWindowsOrFetch(self)[1]) 161 | end 162 | end 163 | 164 | obj.focusNextWindow = function(self) 165 | self.focusWindow(self:refreshOrderedWindows()[2]) 166 | end 167 | 168 | -- TODO: window:focus() don't work correctly, when a application has 2 windows and each windows are on different screen. 169 | obj.focusWindow = function(targetWindow) 170 | -- if Finder window should be focused, then focus Finder application not but the specific window. 171 | -- that is because Finder window is not created collectively by HammerSpoon. 172 | if targetWindow:application():bundleID() == FINDER_BUNDLE_ID then 173 | hs.application.launchOrFocusByBundleID(FINDER_BUNDLE_ID) 174 | return 175 | end 176 | 177 | local targetAppliation = targetWindow:application() 178 | local applicationMainWindow = targetAppliation:mainWindow() 179 | if applicationMainWindow == nil then 180 | return 181 | end 182 | 183 | local applicationVisibleWindows = targetAppliation:visibleWindows() 184 | if #applicationVisibleWindows == 1 then 185 | targetWindow:focus() 186 | else 187 | local applicationMainWindowScreen = applicationMainWindow:screen() 188 | local applicationMainWindowScreenId = applicationMainWindowScreen:id() 189 | 190 | local targetWindowScreen = targetWindow:screen() 191 | local targetWindowScreenId = targetWindowScreen:id() 192 | 193 | if targetWindowScreenId == applicationMainWindowScreenId then 194 | targetWindow:focus() 195 | else 196 | local focusedWindow = hs.window.focusedWindow() 197 | if focusedWindow and focusedWindow:application():pid() == targetAppliation:pid() then 198 | targetWindow:focus() 199 | else 200 | -- Hammerspoon bug: window:focus() don't work correctly, when a application has 2 windows and each windows are on different screen. 201 | -- Issue: https://github.com/Hammerspoon/hammerspoon/issues/2978 202 | 203 | -- This way is workaround. 204 | 205 | targetWindow:focus() 206 | 207 | local status, err = pcall(function() 208 | hs.timer.doAfter(0.15, function() 209 | targetWindow:focus() 210 | end) 211 | end) 212 | if status == false then 213 | Debugger.log("ERROR (doAfter timer) : " .. err) 214 | end 215 | end 216 | end 217 | end 218 | end 219 | 220 | return obj 221 | end 222 | return WindowModel 223 | -------------------------------------------------------------------------------- /lib/view/BaseCanvasView.lua: -------------------------------------------------------------------------------- 1 | local canvas = require("hs.canvas") 2 | 3 | local Debugger = require("hotswitch-hs/lib/common/Debugger") 4 | local TimeChecker = require("hotswitch-hs/lib/common/TimeChecker") 5 | local View = require("hotswitch-hs/lib/view/View") 6 | local CanvasConstants = require("hotswitch-hs/lib/common/CanvasConstants") 7 | local FrameCulculator = require("hotswitch-hs/lib/common/FrameCulculator") 8 | 9 | local BaseCanvasView = {} 10 | BaseCanvasView.new = function(windowModel, settingModel, keyStatusModel) 11 | local obj = View.new() 12 | 13 | obj.windowModel = windowModel 14 | obj.settingModel = settingModel 15 | obj.keyStatusModel = keyStatusModel 16 | 17 | obj.clickCallback = nil 18 | 19 | obj.show = function(self) 20 | -- local t = TimeChecker.new() 21 | local orderedWindows = self.windowModel:getCachedOrderedWindowsOrFetch() 22 | -- t:diff("getCachedOrderedWindowsOrFetch") 23 | 24 | self:showRectangle(orderedWindows) 25 | -- t:diff("showRectangle") -- sometimes, slow 26 | self:showWindowInfo(orderedWindows) 27 | -- t:diff("showWindowInfo") 28 | end 29 | 30 | obj.hide = function(self) 31 | if self.baseCanvas ~= nil then 32 | self.baseCanvas:delete() 33 | self.baseCanvas = nil 34 | end 35 | end 36 | 37 | obj.showRectangle = function(self, orderedWindows) 38 | -- local t = TimeChecker.new() 39 | if self.baseCanvas == nil then 40 | local frame = FrameCulculator.calcBaseCanvasFrame(orderedWindows) 41 | -- t:diff("calcBaseCanvasFrame") 42 | self.baseCanvas = canvas.new(frame) 43 | -- t:diff("new") 44 | end 45 | -- t:diff("canvas new") 46 | 47 | self:setElements(orderedWindows) 48 | -- t:diff("setElements") 49 | 50 | -- self:activateHammerspoonWindow() 51 | -- self.baseCanvas:level("normal") -- don't need 52 | -- self.baseCanvas:bringToFront() 53 | 54 | self:initializeMouseEvent(orderedWindows) 55 | end 56 | 57 | obj.initializeMouseEvent = function(self, orderedWindows) 58 | self.baseCanvas:canvasMouseEvents(false, true, false, false) 59 | self.baseCanvas:mouseCallback(function(canvas, type, elementId, x, y) 60 | if self.clickCallback == nil then 61 | return 62 | end 63 | 64 | if type == "mouseUp" then 65 | local position = math.floor((y - CanvasConstants.PADDING * 2) / CanvasConstants.ROW_HEIGHT + 1) 66 | if position < 1 then 67 | position = 1 68 | elseif position > #orderedWindows then 69 | position = #orderedWindows 70 | end 71 | 72 | self.clickCallback(position) 73 | end 74 | end) 75 | end 76 | 77 | -- clickCallback = function(position) end 78 | obj.setClickCallback = function(self, clickCallback) 79 | self.clickCallback = clickCallback 80 | end 81 | 82 | obj.activateHammerspoonWindow = function(self) 83 | local app = hs.application.get("Hammerspoon") 84 | app:setFrontmost() 85 | end 86 | 87 | obj.setElements = function(self, orderedWindows) 88 | self.baseCanvas:replaceElements({ 89 | action = "fill", 90 | fillColor = { 91 | alpha = CanvasConstants.INLINE_RECTANGLE_ALPHA, 92 | blue = 0.5, 93 | green = 0.5, 94 | red = 0.5 95 | }, 96 | frame = { 97 | x = "0", 98 | y = "0", 99 | h = "1", 100 | w = "1" 101 | }, 102 | type = "rectangle", 103 | withShadow = true 104 | }) 105 | 106 | self.baseCanvas:appendElements({ 107 | action = "fill", 108 | fillColor = { 109 | alpha = CanvasConstants.OUTLINE_RECTANGLE_ALPHA, 110 | blue = 0, 111 | green = 0, 112 | red = 0 113 | }, 114 | frame = { 115 | x = CanvasConstants.PADDING, 116 | y = CanvasConstants.PADDING, 117 | h = #orderedWindows * CanvasConstants.ROW_HEIGHT + CanvasConstants.PADDING * 2, 118 | w = CanvasConstants.PANEL_W - CanvasConstants.PADDING * 2 119 | }, 120 | type = "rectangle" 121 | }) 122 | end 123 | 124 | obj.showWindowInfo = function(self, orderedWindows) 125 | -- local t = TimeChecker.new() 126 | for i = 1, #orderedWindows do 127 | local window = orderedWindows[i] 128 | 129 | -- t:diff(i) 130 | self:showEachKeyText(i, window) 131 | -- t:diff("showEachKeyText") 132 | self:showEachAppIcon(i, window) 133 | -- t:diff("showEachAppIcon") 134 | self:showEachWindowTitle(i, window) 135 | -- t:diff("showEachWindowTitle") 136 | end 137 | 138 | self.baseCanvas:show() 139 | -- t:diff("show") 140 | end 141 | 142 | obj.showEachKeyText = function(self, i, window) 143 | local windowId = window:id() 144 | 145 | local keyText = "" 146 | local isAutoGeneratedKey 147 | 148 | for j = 1, #self.keyStatusModel.registeredAndAutoGeneratedKeyStatuses do 149 | local keyStatus = self.keyStatusModel.registeredAndAutoGeneratedKeyStatuses[j] 150 | if keyStatus.windowId == windowId then 151 | keyText = keyStatus.key 152 | isAutoGeneratedKey = keyStatus.isAutoGenerated 153 | break 154 | end 155 | end 156 | 157 | local textColor 158 | if isAutoGeneratedKey then 159 | textColor = { 160 | alpha = CanvasConstants.TEXT_ALPHA, 161 | blue = 0.1, 162 | green = 0.9, 163 | red = 0.9 164 | } 165 | else 166 | textColor = { 167 | alpha = CanvasConstants.TEXT_ALPHA, 168 | blue = CanvasConstants.TEXT_WHITE_VALUE, 169 | green = CanvasConstants.TEXT_WHITE_VALUE, 170 | red = CanvasConstants.TEXT_WHITE_VALUE 171 | } 172 | end 173 | 174 | self.baseCanvas:appendElements({ 175 | frame = { 176 | x = CanvasConstants.PADDING * 2 + CanvasConstants.KEY_LEFT_PADDING, 177 | y = (i - 1) * CanvasConstants.ROW_HEIGHT + CanvasConstants.PADDING * 2, 178 | h = CanvasConstants.ROW_HEIGHT, 179 | w = CanvasConstants.KEY_W 180 | }, 181 | text = hs.styledtext.new(keyText, { 182 | font = { 183 | name = ".AppleSystemUIFont", 184 | size = CanvasConstants.FONT_SIZE 185 | }, 186 | color = textColor 187 | }), 188 | type = "text" 189 | }) 190 | 191 | local appName = window:application():name() 192 | Debugger.log(keyText .. " : " .. appName) 193 | end 194 | 195 | obj.showEachAppName = function(self, i, window) 196 | local appName = window:application():name() 197 | self.baseCanvas:appendElements({ 198 | frame = { 199 | x = CanvasConstants.PADDING * 2 + CanvasConstants.KEY_LEFT_PADDING + CanvasConstants.KEY_W, 200 | y = (i - 1) * CanvasConstants.ROW_HEIGHT + CanvasConstants.PADDING * 2, 201 | h = CanvasConstants.ROW_HEIGHT, 202 | w = CanvasConstants.APP_NAME_W 203 | }, 204 | text = hs.styledtext.new(appName, { 205 | font = { 206 | name = ".AppleSystemUIFont", 207 | size = CanvasConstants.FONT_SIZE 208 | }, 209 | color = { 210 | alpha = CanvasConstants.TEXT_ALPHA, 211 | blue = CanvasConstants.TEXT_WHITE_VALUE, 212 | green = CanvasConstants.TEXT_WHITE_VALUE, 213 | red = CanvasConstants.TEXT_WHITE_VALUE 214 | } 215 | }), 216 | type = "text" 217 | }) 218 | end 219 | 220 | obj.showEachAppIcon = function(self, i, window) 221 | local frame = { 222 | x = CanvasConstants.PADDING * 2 + CanvasConstants.KEY_LEFT_PADDING + CanvasConstants.KEY_W, 223 | y = (i - 1) * CanvasConstants.ROW_HEIGHT + CanvasConstants.PADDING * 2, 224 | h = CanvasConstants.ROW_HEIGHT - 3, 225 | w = CanvasConstants.APP_ICON_W - 3 226 | } 227 | 228 | local bundleID = window:application():bundleID() 229 | if bundleID then 230 | self.baseCanvas:appendElements({ 231 | frame = frame, 232 | image = hs.image.imageFromAppBundle(bundleID), 233 | imageScaling = "scaleToFit", 234 | type = "image", 235 | }) 236 | else 237 | local radius = frame.w / 2 238 | 239 | self.baseCanvas:appendElements({ 240 | center = { x = frame.x + radius, y = frame.y + radius }, 241 | action = "fill", 242 | fillColor = { alpha = 1, blue = 0.5, green = 0.5, red = 0.5 }, 243 | radius = radius - 1, 244 | type = "circle", 245 | }) 246 | end 247 | end 248 | 249 | obj.showEachWindowTitle = function(self, i, window) 250 | -- local t = TimeChecker.new() 251 | local windowName = window:title() -- sometimes slow 252 | if windowName == "" then 253 | windowName = window:application():name() 254 | -- t:diff("get application name") 255 | end 256 | -- alternative way 257 | -- local windowName = window:application():name() -- more faster 258 | -- t:diff("get window title") 259 | 260 | self.baseCanvas:appendElements({ 261 | frame = { 262 | x = CanvasConstants.PADDING * 3 + CanvasConstants.KEY_W + CanvasConstants.KEY_LEFT_PADDING + 263 | CanvasConstants.APP_ICON_W, 264 | y = (i - 1) * CanvasConstants.ROW_HEIGHT + CanvasConstants.PADDING * 2, 265 | h = CanvasConstants.ROW_HEIGHT, 266 | w = CanvasConstants.PANEL_W - CanvasConstants.KEY_W - CanvasConstants.APP_ICON_W - 267 | CanvasConstants.PADDING * 6 268 | }, 269 | text = hs.styledtext.new(windowName, { 270 | font = { 271 | name = ".AppleSystemUIFont", 272 | size = CanvasConstants.FONT_SIZE 273 | }, 274 | color = { 275 | alpha = CanvasConstants.TEXT_ALPHA, 276 | blue = CanvasConstants.TEXT_WHITE_VALUE, 277 | green = CanvasConstants.TEXT_WHITE_VALUE, 278 | red = CanvasConstants.TEXT_WHITE_VALUE 279 | } 280 | }), 281 | type = "text" 282 | }) 283 | end 284 | 285 | return obj 286 | end 287 | return BaseCanvasView 288 | -------------------------------------------------------------------------------- /lib/view/PanelLayoutView.lua: -------------------------------------------------------------------------------- 1 | local canvas = require("hs.canvas") 2 | 3 | local Debugger = require("hotswitch-hs/lib/common/Debugger") 4 | local TimeChecker = require("hotswitch-hs/lib/common/TimeChecker") 5 | local View = require("hotswitch-hs/lib/view/View") 6 | local BaseCanvasView = require("hotswitch-hs/lib/view/BaseCanvasView") 7 | local SelectedRowCanvasView = require("hotswitch-hs/lib/view/SelectedRowCanvasView") 8 | 9 | local defaultRowPosition = 2 10 | 11 | local PanelLayoutView = {} 12 | PanelLayoutView.new = function(windowModel, settingModel, keyStatusModel) 13 | local obj = View.new() 14 | 15 | obj.isOpen = false 16 | obj.baseCanvasView = BaseCanvasView.new(windowModel, settingModel, keyStatusModel) 17 | obj.selectedRowCanvasView = SelectedRowCanvasView.new(windowModel, defaultRowPosition) 18 | obj.settingModel = settingModel 19 | 20 | obj.show = function(self) 21 | if self.isOpen == false then 22 | self.isOpen = true 23 | self.selectedRowCanvasView.position = defaultRowPosition 24 | end 25 | 26 | -- local t = TimeChecker.new() 27 | self.baseCanvasView:show() 28 | -- t:diff("PanelLayoutView:baseCanvasView:show") 29 | self.selectedRowCanvasView:createSelectedRow() 30 | -- t:diff("PanelLayoutView:selectedRowCanvasView:createSelectedRow") 31 | 32 | self.selectedRowCanvasView:replaceSelectedRow() 33 | -- t:diff("PanelLayoutView:selectedRowCanvasView:replaceSelectedRow") 34 | end 35 | 36 | obj.hide = function(self) 37 | self.isOpen = false 38 | 39 | self.baseCanvasView:hide() 40 | self.selectedRowCanvasView:hide() 41 | end 42 | 43 | obj.getSelectedRowPosition = function(self) 44 | return self.selectedRowCanvasView.position 45 | end 46 | 47 | obj.selectNextRow = function(self, windowModel) 48 | self.selectedRowCanvasView:next(windowModel) 49 | end 50 | 51 | obj.selectPreviousRow = function(self, windowModel) 52 | self.selectedRowCanvasView:previous(windowModel) 53 | end 54 | 55 | obj.unemphasisRow = function(self) 56 | self.selectedRowCanvasView:replaceSelectedRow() 57 | end 58 | 59 | obj.emphasisRow = function(self) 60 | self.selectedRowCanvasView:replaceAndEmphasisSelectedRow() 61 | end 62 | 63 | obj.setClickCallback = function(self, clickCallback) 64 | self.baseCanvasView:setClickCallback(clickCallback) 65 | end 66 | 67 | obj.activateHammerspoonWindow = function(self) 68 | self.baseCanvasView:activateHammerspoonWindow() 69 | end 70 | 71 | return obj 72 | end 73 | 74 | return PanelLayoutView -------------------------------------------------------------------------------- /lib/view/SelectedRowCanvasView.lua: -------------------------------------------------------------------------------- 1 | local canvas = require("hs.canvas") 2 | 3 | local Debugger = require("hotswitch-hs/lib/common/Debugger") 4 | local View = require("hotswitch-hs/lib/view/View") 5 | local CanvasConstants = require("hotswitch-hs/lib/common/CanvasConstants") 6 | local FrameCulculator = require("hotswitch-hs/lib/common/FrameCulculator") 7 | 8 | local SelectedRowCanvasView = {} 9 | SelectedRowCanvasView.new = function(windowModel, position) 10 | local obj = View.new() 11 | 12 | obj.canvas = canvas 13 | obj.windowModel = windowModel 14 | obj.position = position 15 | 16 | obj.show = function(self) 17 | -- not be used 18 | end 19 | 20 | obj.hide = function(self) 21 | if self.selectedRowCanvas ~= nil then 22 | self.selectedRowCanvas:delete() 23 | self.selectedRowCanvas = nil 24 | end 25 | end 26 | 27 | obj.createSelectedRow = function(self) 28 | local orderedWindows = self.windowModel:getCachedOrderedWindowsOrFetch() 29 | local baseCanvasFrame = FrameCulculator.calcBaseCanvasFrame(orderedWindows) 30 | 31 | if self.selectedRowCanvas == nil then 32 | self.selectedRowCanvas = canvas.new { 33 | x = baseCanvasFrame.x, 34 | y = baseCanvasFrame.y, 35 | h = baseCanvasFrame.h, 36 | w = baseCanvasFrame.w - CanvasConstants.PADDING 37 | } 38 | end 39 | end 40 | 41 | obj.replaceSelectedRow = function(self) 42 | self.selectedRowCanvas:replaceElements({ 43 | action = "fill", 44 | fillColor = { 45 | alpha = CanvasConstants.SELECTED_ROW_ALPHA, 46 | blue = 1, 47 | green = 1, 48 | red = 1 49 | }, 50 | frame = { 51 | x = CanvasConstants.PADDING * 2, 52 | y = (self.position - 1) * CanvasConstants.ROW_HEIGHT + CanvasConstants.PADDING * 2, 53 | h = CanvasConstants.ROW_HEIGHT, 54 | w = CanvasConstants.PANEL_W - CanvasConstants.PADDING * 4 55 | }, 56 | type = "rectangle" 57 | }) 58 | 59 | self.selectedRowCanvas:show() 60 | end 61 | 62 | obj.replaceAndEmphasisSelectedRow = function(self) 63 | self.selectedRowCanvas:replaceElements({ 64 | action = "fill", 65 | fillColor = { 66 | alpha = 0.3, 67 | blue = 0.3, 68 | green = 1, 69 | red = 1 70 | }, 71 | frame = { 72 | x = CanvasConstants.PADDING * 2, 73 | y = (self.position - 1) * CanvasConstants.ROW_HEIGHT + CanvasConstants.PADDING * 2, 74 | h = CanvasConstants.ROW_HEIGHT, 75 | w = CanvasConstants.PANEL_W - CanvasConstants.PADDING * 4 76 | }, 77 | type = "rectangle" 78 | }) 79 | 80 | self.selectedRowCanvas:show() 81 | end 82 | 83 | obj.next = function(self, windowModel) 84 | self.isRegistrationMode = false 85 | 86 | self.position = self:calcNextRowPosition(self.position, windowModel) 87 | 88 | self:replaceSelectedRow() 89 | end 90 | 91 | obj.previous = function(self, windowModel) 92 | self.isRegistrationMode = false 93 | 94 | self.position = self:calcPreviousRowPosition(self.position, windowModel) 95 | 96 | self:replaceSelectedRow() 97 | end 98 | 99 | obj.calcNextRowPosition = function(self, position, windowModel) 100 | local newPosition = position + 1 101 | if newPosition > #windowModel:getCachedOrderedWindowsOrFetch() then 102 | newPosition = 1 103 | end 104 | return newPosition 105 | end 106 | 107 | obj.calcPreviousRowPosition = function(self, position, windowModel) 108 | local newPosition = position - 1 109 | if newPosition <= 0 then 110 | newPosition = #windowModel:getCachedOrderedWindowsOrFetch() 111 | end 112 | return newPosition 113 | end 114 | 115 | return obj 116 | end 117 | return SelectedRowCanvasView -------------------------------------------------------------------------------- /lib/view/ToastView.lua: -------------------------------------------------------------------------------- 1 | local canvas = require("hs.canvas") 2 | 3 | local Debugger = require("hotswitch-hs/lib/common/Debugger") 4 | local View = require("hotswitch-hs/lib/view/View") 5 | local CanvasConstants = require("hotswitch-hs/lib/common/CanvasConstants") 6 | local FrameCulculator = require("hotswitch-hs/lib/common/FrameCulculator") 7 | 8 | local ToastView = {} 9 | ToastView.new = function(orderedWindows) 10 | local obj = View.new() 11 | 12 | obj.toastCanvas = nil 13 | obj.timer = nil 14 | 15 | obj.show = function(self) 16 | self:toast("Set a message") 17 | end 18 | 19 | obj.hide = function(self) 20 | if self.toastCanvas ~= nil then 21 | self.toastCanvas:delete() 22 | end 23 | end 24 | 25 | obj.toast = function(self, message) 26 | if self.toastCanvas ~= nil then 27 | self.toastCanvas:delete() 28 | end 29 | 30 | local baseCanvasFrame = FrameCulculator.calcBaseCanvasFrame(orderedWindows) 31 | 32 | self.toastCanvas = canvas.new { 33 | x = baseCanvasFrame.x + baseCanvasFrame.w / 2 - CanvasConstants.TOAST_W / 2, 34 | y = baseCanvasFrame.y - CanvasConstants.TOAST_H - CanvasConstants.PADDING * 2, 35 | h = CanvasConstants.TOAST_H, 36 | w = CanvasConstants.TOAST_W 37 | } 38 | 39 | self.toastCanvas:appendElements({ 40 | action = "fill", 41 | fillColor = { 42 | alpha = CanvasConstants.TOAST_ALPHA, 43 | blue = 0, 44 | green = 0, 45 | red = 0 46 | }, 47 | frame = { 48 | x = 0, 49 | y = 0, 50 | h = "1", 51 | w = "1" 52 | }, 53 | type = "rectangle" 54 | }) 55 | 56 | self.toastCanvas:appendElements({ 57 | frame = { 58 | x = CanvasConstants.PADDING, 59 | y = CanvasConstants.PADDING, 60 | h = "1", 61 | w = "1", 62 | }, 63 | text = hs.styledtext.new(message, { 64 | font = { 65 | name = ".AppleSystemUIFont", 66 | size = CanvasConstants.TOAST_FONT_SIZE 67 | }, 68 | color = { 69 | alpha = CanvasConstants.TEXT_ALPHA, 70 | blue = CanvasConstants.TEXT_WHITE_VALUE, 71 | green = CanvasConstants.TEXT_WHITE_VALUE, 72 | red = CanvasConstants.TEXT_WHITE_VALUE 73 | } 74 | }), 75 | type = "text" 76 | }) 77 | 78 | self.toastCanvas:show(0.2) 79 | self.timer = hs.timer.doAfter(2, function() 80 | self.toastCanvas:delete(0.2) 81 | self.toastCanvas = nil 82 | end) 83 | end 84 | 85 | return obj 86 | end 87 | return ToastView -------------------------------------------------------------------------------- /lib/view/View.lua: -------------------------------------------------------------------------------- 1 | local View = {} 2 | View.new = function() 3 | local obj = {} 4 | 5 | obj.show = function() end 6 | obj.hide = function() end 7 | 8 | return obj 9 | end 10 | return View --------------------------------------------------------------------------------