karules 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE +21 -0
- data/README.md +303 -0
- data/examples/config.rb +161 -0
- data/exe/karules +30 -0
- data/lib/karules/dsl.rb +202 -0
- data/lib/karules/version.rb +5 -0
- data/lib/karules.rb +15 -0
- metadata +72 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 276d1cb288bdf01c66130cebd08d2ffb2723f7e2b8fce6cbe6ffa97a0aa430d5
|
|
4
|
+
data.tar.gz: 5dd19e077072c671737e8fef8093e1f05b97a871a38c850bb24cfb0859e80935
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9933c4488162a5fd9e792c3dce4e220982d3a1709d6c9c511e123d8d0a798ba9a7c10c9925750fca3c4a1a5def0568c1a646b535d79f6f312a4530b02b76acfc
|
|
7
|
+
data.tar.gz: 2fc49300015041c4ea3dd9ade8b6dbbb0823669316013a7af98f14ffeefbeef0d733ea41ea3eb896ab4afcff103940076dddb259a046e4f8d2616c0e6ce4495e
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2025-01-23
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Initial release of KaRules gem
|
|
12
|
+
- Ruby DSL for configuring Karabiner-Elements
|
|
13
|
+
- Support for keyboard mappings with modifiers
|
|
14
|
+
- Support for mouse button mappings
|
|
15
|
+
- Application-specific rules with bundle identifiers
|
|
16
|
+
- Modal keyboard modes with state management
|
|
17
|
+
- Custom Karabiner config path via `karabiner_path` DSL
|
|
18
|
+
- XDG Base Directory specification support
|
|
19
|
+
- Command-line executable for loading user configs
|
|
20
|
+
- Example configuration file
|
|
21
|
+
- Comprehensive test suite
|
|
22
|
+
|
|
23
|
+
### Features
|
|
24
|
+
- Simple mapping syntax: `m("from", "to")`
|
|
25
|
+
- Modifier support: `+mandatory` and `-optional`
|
|
26
|
+
- Shell command execution: `!command`
|
|
27
|
+
- Complex conditions and parameters
|
|
28
|
+
- Group organization with descriptions
|
|
29
|
+
- Deep hash sorting for consistent JSON output
|
|
30
|
+
|
|
31
|
+
[0.1.0]: https://github.com/avsej/karules/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Sergey Tarasov
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# karules
|
|
2
|
+
|
|
3
|
+
Configure Karabiner-Elements with Ruby DSL - cleaner, more maintainable keyboard customization for macOS.
|
|
4
|
+
|
|
5
|
+
## What is this?
|
|
6
|
+
|
|
7
|
+
If you're like me and have a complex Karabiner-Elements configuration, you probably know the pain of editing that giant JSON file manually. I got tired of it and wrote a Ruby DSL to generate the config instead.
|
|
8
|
+
|
|
9
|
+
**Features:**
|
|
10
|
+
- Clean, readable Ruby syntax instead of JSON
|
|
11
|
+
- Group related mappings together
|
|
12
|
+
- Reusable helper methods
|
|
13
|
+
- Application-specific rules
|
|
14
|
+
- Modal keyboard modes
|
|
15
|
+
- No need to remember Karabiner's JSON structure
|
|
16
|
+
|
|
17
|
+
## Why?
|
|
18
|
+
|
|
19
|
+
Karabiner-Elements is powerful but its configuration format is...verbose. Here's a simple caps lock → control mapping:
|
|
20
|
+
|
|
21
|
+
**JSON (what Karabiner wants):**
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"description": "Caps Lock to Control",
|
|
25
|
+
"manipulators": [{
|
|
26
|
+
"type": "basic",
|
|
27
|
+
"from": {
|
|
28
|
+
"key_code": "caps_lock",
|
|
29
|
+
"modifiers": { "optional": ["any"] }
|
|
30
|
+
},
|
|
31
|
+
"to": [{ "key_code": "left_control" }]
|
|
32
|
+
}]
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Ruby (what you write):**
|
|
37
|
+
```ruby
|
|
38
|
+
group("Caps Lock") do
|
|
39
|
+
m("caps_lock -any", "left_control")
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Much better.
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
### Via Homebrew (soon)
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
brew tap dzirtusss/tap
|
|
51
|
+
brew install karules
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Via RubyGems
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
gem install karules
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Manual
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
git clone https://github.com/dzirtusss/karules.git
|
|
64
|
+
cd karules
|
|
65
|
+
gem build karules.gemspec
|
|
66
|
+
gem install karules-0.1.0.gem
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Configuration
|
|
70
|
+
|
|
71
|
+
Create your config file at `~/.config/karules/config.rb`:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
require "karules"
|
|
75
|
+
|
|
76
|
+
class MyConfig < KaRules
|
|
77
|
+
def config
|
|
78
|
+
# Define your apps
|
|
79
|
+
apps(
|
|
80
|
+
slack: "^com\\.tinyspeck\\.slackmacgap$",
|
|
81
|
+
terminal: "^com\\.apple\\.Terminal$"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Simple mapping
|
|
85
|
+
group("Caps Lock") do
|
|
86
|
+
m("caps_lock -any", "left_control")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# App-specific rules
|
|
90
|
+
group("Slack shortcuts") do
|
|
91
|
+
app_if(:slack) do
|
|
92
|
+
m("h +right_option", "left_arrow")
|
|
93
|
+
m("l +right_option", "right_arrow")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Launch apps
|
|
98
|
+
group("App launcher") do
|
|
99
|
+
m("t +right_command", "!open -a 'Terminal'")
|
|
100
|
+
m("s +right_command", "!open -a 'Safari'")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Modal modes (vim-style)
|
|
104
|
+
group("Tab mode") do
|
|
105
|
+
m("tab", "right_option lazy", to_if_alone: "tab")
|
|
106
|
+
m("j +right_option", "down_arrow")
|
|
107
|
+
m("k +right_option", "up_arrow")
|
|
108
|
+
m("h +right_option", "left_arrow")
|
|
109
|
+
m("l +right_option", "right_arrow")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
MyConfig.new.call
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Then run:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
karules
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
It will update your `~/.config/karabiner/karabiner.json` automatically.
|
|
124
|
+
|
|
125
|
+
## Usage
|
|
126
|
+
|
|
127
|
+
### Basic Mapping
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
m("from_key", "to_key")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Modifiers
|
|
134
|
+
|
|
135
|
+
**Mandatory modifiers** (must be pressed):
|
|
136
|
+
```ruby
|
|
137
|
+
m("a +command", "b") # Command+A → B
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**Optional modifiers** (can be pressed):
|
|
141
|
+
```ruby
|
|
142
|
+
m("a -any", "b") # A (with any modifiers) → B
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Shell Commands
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
m("t +command", "!open -a 'Terminal'")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Application-Specific Rules
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
apps(slack: "^com\\.tinyspeck\\.slackmacgap$")
|
|
155
|
+
|
|
156
|
+
app_if(:slack) do
|
|
157
|
+
m("j +control", "down_arrow")
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Complex Mappings
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
m("a +control",
|
|
165
|
+
to: "b",
|
|
166
|
+
to_if_alone: "escape",
|
|
167
|
+
conditions: some_condition)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Modal Modes
|
|
171
|
+
|
|
172
|
+
Create vim-like modal keyboards:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
group("Vi Mode") do
|
|
176
|
+
default_mode("vi-mode")
|
|
177
|
+
|
|
178
|
+
# Enter mode
|
|
179
|
+
m("escape", mode_on)
|
|
180
|
+
|
|
181
|
+
# Exit on any non-vi key
|
|
182
|
+
m("escape", mode_off)
|
|
183
|
+
|
|
184
|
+
# Mappings only active in mode
|
|
185
|
+
mode_if do
|
|
186
|
+
m("h", "left_arrow")
|
|
187
|
+
m("j", "down_arrow")
|
|
188
|
+
m("k", "up_arrow")
|
|
189
|
+
m("l", "right_arrow")
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Custom Karabiner Path
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
def config
|
|
198
|
+
karabiner_path "~/custom/karabiner.json"
|
|
199
|
+
# ... rest of config
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Tips
|
|
204
|
+
|
|
205
|
+
**Use groups** to organize your config:
|
|
206
|
+
```ruby
|
|
207
|
+
group("Navigation") do
|
|
208
|
+
# related mappings
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
group("App Launcher") do
|
|
212
|
+
# related mappings
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Extract common patterns** into methods:
|
|
217
|
+
```ruby
|
|
218
|
+
def vim_nav(prefix)
|
|
219
|
+
m("h #{prefix}", "left_arrow")
|
|
220
|
+
m("j #{prefix}", "down_arrow")
|
|
221
|
+
m("k #{prefix}", "up_arrow")
|
|
222
|
+
m("l #{prefix}", "right_arrow")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
group("Vim nav") do
|
|
226
|
+
vim_nav("+option")
|
|
227
|
+
end
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
**Check the example** config for more ideas:
|
|
231
|
+
- See the [example config](examples/config.rb)
|
|
232
|
+
- Or run: `gem contents karules | grep examples`
|
|
233
|
+
|
|
234
|
+
## How It Works
|
|
235
|
+
|
|
236
|
+
1. You write Ruby DSL in `~/.config/karules/config.rb`
|
|
237
|
+
2. Run `karules` command
|
|
238
|
+
3. It generates the JSON rules
|
|
239
|
+
4. Updates your Karabiner config
|
|
240
|
+
5. Karabiner picks up the changes automatically
|
|
241
|
+
|
|
242
|
+
The DSL is just Ruby, so you can:
|
|
243
|
+
- Use variables and loops
|
|
244
|
+
- Write helper methods
|
|
245
|
+
- Split config into multiple files (with `load`)
|
|
246
|
+
- Generate mappings programmatically
|
|
247
|
+
|
|
248
|
+
## Troubleshooting
|
|
249
|
+
|
|
250
|
+
**Config not loading?**
|
|
251
|
+
|
|
252
|
+
Make sure your config file is at:
|
|
253
|
+
- `~/.config/karules/config.rb` (default)
|
|
254
|
+
- Or specify: `karules /path/to/config.rb`
|
|
255
|
+
|
|
256
|
+
**Changes not applying?**
|
|
257
|
+
|
|
258
|
+
Karabiner should detect changes automatically. If not:
|
|
259
|
+
- Check Karabiner-EventViewer for errors
|
|
260
|
+
- Verify your JSON at `~/.config/karabiner/karabiner.json`
|
|
261
|
+
- Restart Karabiner-Elements
|
|
262
|
+
|
|
263
|
+
**Syntax errors?**
|
|
264
|
+
|
|
265
|
+
Your config is Ruby code. Check for:
|
|
266
|
+
- Missing `do`/`end` blocks
|
|
267
|
+
- Unmatched quotes
|
|
268
|
+
- Typos in method names
|
|
269
|
+
|
|
270
|
+
## Comparison with Alternatives
|
|
271
|
+
|
|
272
|
+
**JSON (built-in):** Maximum control but verbose and error-prone
|
|
273
|
+
|
|
274
|
+
**Goku/KarabinerDSL:** Great alternatives! This is just my take with Ruby instead of Clojure/other DSLs. Use whatever works for you.
|
|
275
|
+
|
|
276
|
+
**Why Ruby?** Because I write Ruby daily and wanted something that feels natural to me. If you prefer other languages, check out the alternatives.
|
|
277
|
+
|
|
278
|
+
## Known Limitations
|
|
279
|
+
|
|
280
|
+
- This is an early version, expect some rough edges
|
|
281
|
+
- Not all Karabiner features are wrapped (but you can use raw hashes for anything)
|
|
282
|
+
- No validation yet (invalid configs will fail at Karabiner level)
|
|
283
|
+
- Examples use my personal workflow - adapt to yours
|
|
284
|
+
|
|
285
|
+
## Future Ideas
|
|
286
|
+
|
|
287
|
+
- [ ] Config validation before writing
|
|
288
|
+
- [ ] Interactive config generator
|
|
289
|
+
- [ ] More helper methods for common patterns
|
|
290
|
+
- [ ] Better error messages
|
|
291
|
+
- [ ] Support for multiple profiles
|
|
292
|
+
|
|
293
|
+
PRs welcome!
|
|
294
|
+
|
|
295
|
+
## License
|
|
296
|
+
|
|
297
|
+
MIT - see [LICENSE](LICENSE)
|
|
298
|
+
|
|
299
|
+
## Credits
|
|
300
|
+
|
|
301
|
+
Built with frustration and coffee by [@dzirtusss](https://github.com/dzirtusss)
|
|
302
|
+
|
|
303
|
+
Inspired by everyone who's ever looked at a Karabiner JSON file and thought "there must be a better way."
|
data/examples/config.rb
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Example Karabiner configuration
|
|
4
|
+
# Copy this file to ~/.config/karules/config.rb and customize it
|
|
5
|
+
|
|
6
|
+
require "karules"
|
|
7
|
+
|
|
8
|
+
class MyKaRules < KaRules
|
|
9
|
+
def key_mode(key, mode)
|
|
10
|
+
m(
|
|
11
|
+
key,
|
|
12
|
+
to_if_alone: { key_code: key, halt: true },
|
|
13
|
+
to_after_key_up: mode_off(mode),
|
|
14
|
+
to_delayed_action: { to_if_canceled: { key_code: key }, to_if_invoked: mode_on(mode) },
|
|
15
|
+
parameters: {
|
|
16
|
+
"basic.to_if_held_down_threshold_milliseconds": 300,
|
|
17
|
+
"basic.to_delayed_action_delay_milliseconds": 300
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def config
|
|
23
|
+
# Optional: specify custom Karabiner config path
|
|
24
|
+
# Default: $XDG_CONFIG_HOME/karabiner/karabiner.json or ~/.config/karabiner/karabiner.json
|
|
25
|
+
# karabiner_path "~/custom/path/karabiner.json"
|
|
26
|
+
|
|
27
|
+
apps(slack: "^com\\.tinyspeck\\.slackmacgap$", ghostty: "^com\\.mitchellh\\.ghostty$")
|
|
28
|
+
|
|
29
|
+
group("Caps Lock") do
|
|
30
|
+
# m("caps_lock -any", "left_control", to_if_alone: "escape")
|
|
31
|
+
m("caps_lock -any", "left_control")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
group("Mouse buttons") do
|
|
35
|
+
m("pointing_button:button5 -any", "f3") # mission control
|
|
36
|
+
# m("pointing_button:button5 -any", "tab +right_command")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
group("Tmux") do
|
|
40
|
+
app_unless(:ghostty) do
|
|
41
|
+
# Example: Focus terminal app, wait, then send Ctrl+A
|
|
42
|
+
# Replace with your own terminal focus script
|
|
43
|
+
m(
|
|
44
|
+
"a +control",
|
|
45
|
+
[
|
|
46
|
+
"!open -a 'Terminal'",
|
|
47
|
+
{ key_code: "vk_none", hold_down_milliseconds: 100 },
|
|
48
|
+
"a +control"
|
|
49
|
+
]
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
group("Tab mode") do
|
|
55
|
+
m("tab", "right_option lazy", to_if_alone: "tab")
|
|
56
|
+
|
|
57
|
+
m("j +right_option", "down_arrow")
|
|
58
|
+
m("k +right_option", "up_arrow")
|
|
59
|
+
|
|
60
|
+
app_if(:slack) do
|
|
61
|
+
m("h +right_option", "f6 +shift")
|
|
62
|
+
m("l +right_option", "f6")
|
|
63
|
+
m("semicolon +right_option", "right_arrow")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
m("h +right_option", "left_arrow")
|
|
67
|
+
m("l +right_option", "right_arrow")
|
|
68
|
+
|
|
69
|
+
m("w +right_option", "right_arrow +right_option")
|
|
70
|
+
m("b +right_option", "left_arrow +right_option")
|
|
71
|
+
m("u +right_option", "page_up")
|
|
72
|
+
m("d +right_option", "page_down")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
group("Mouse mode", enabled: false) do
|
|
76
|
+
default_mode("mouse-mode")
|
|
77
|
+
scroll = "mouse-scroll"
|
|
78
|
+
|
|
79
|
+
step = 1000
|
|
80
|
+
mult1 = 0.5
|
|
81
|
+
mult2 = 2
|
|
82
|
+
wheel = 50
|
|
83
|
+
|
|
84
|
+
# m("fn -any", mode_on, to_if_alone: "fn", to_after_key_up: mode_off)
|
|
85
|
+
|
|
86
|
+
key_mode("d", "mouse-mode")
|
|
87
|
+
mode_if do
|
|
88
|
+
m("left_shift +right_shift", mode_off)
|
|
89
|
+
m("right_shift +left_shift", mode_off)
|
|
90
|
+
end
|
|
91
|
+
m("left_shift +right_shift", mode_on)
|
|
92
|
+
m("right_shift +left_shift", mode_on)
|
|
93
|
+
|
|
94
|
+
mode_if do
|
|
95
|
+
mode_if(scroll) do
|
|
96
|
+
m("j -any", { mouse_key: { vertical_wheel: wheel } })
|
|
97
|
+
m("k -any", { mouse_key: { vertical_wheel: -wheel } })
|
|
98
|
+
m("h -any", { mouse_key: { horizontal_wheel: wheel } })
|
|
99
|
+
m("l -any", { mouse_key: { horizontal_wheel: -wheel } })
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# normal movement
|
|
103
|
+
m("j -any", { mouse_key: { y: step } })
|
|
104
|
+
m("k -any", { mouse_key: { y: -step } })
|
|
105
|
+
m("h -any", { mouse_key: { x: -step } })
|
|
106
|
+
m("l -any", { mouse_key: { x: step } })
|
|
107
|
+
|
|
108
|
+
# mode modifiers
|
|
109
|
+
m("s -any", mode_on(scroll), to_after_key_up: mode_off(scroll))
|
|
110
|
+
m("c -any", { mouse_key: { speed_multiplier: mult1 } })
|
|
111
|
+
m("f -any", { mouse_key: { speed_multiplier: mult2 } })
|
|
112
|
+
|
|
113
|
+
# buttons
|
|
114
|
+
m("b -any", { pointing_button: "button1" })
|
|
115
|
+
m("spacebar -any", { pointing_button: "button1" })
|
|
116
|
+
m("n -any", { pointing_button: "button2" })
|
|
117
|
+
|
|
118
|
+
# position
|
|
119
|
+
m("u -any", { software_function: { set_mouse_cursor_position: { x: "20%", y: "20%" } } })
|
|
120
|
+
m("i -any", { software_function: { set_mouse_cursor_position: { x: "80%", y: "20%" } } })
|
|
121
|
+
m("o -any", { software_function: { set_mouse_cursor_position: { x: "20%", y: "80%" } } })
|
|
122
|
+
m("p -any", { software_function: { set_mouse_cursor_position: { x: "80%", y: "80%" } } })
|
|
123
|
+
m("m -any", { software_function: { set_mouse_cursor_position: { x: "50%", y: "50%" } } })
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
group("MacOS double CmdQ") do
|
|
128
|
+
default_mode("macos-q-command")
|
|
129
|
+
m("q +command", "q +command", conditions: mode_if)
|
|
130
|
+
m("q +command", mode_on, to_delayed_action: { to_if_canceled: mode_off, to_if_invoked: mode_off })
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Example: Switch between terminal windows/tabs
|
|
134
|
+
# Replace with your own terminal switching script
|
|
135
|
+
max = 9
|
|
136
|
+
group("terminal 1-#{max}") do
|
|
137
|
+
app_if(:ghostty) do
|
|
138
|
+
(1..max).each { |i| m("#{i} +left_command", "#{i} +command") }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
(1..max).each { |i| m("#{i} +left_option", "#{i} +command") }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Example: Application launcher shortcuts
|
|
145
|
+
group("Apps") do
|
|
146
|
+
m("j +right_command", "!open -a 'Terminal'")
|
|
147
|
+
m("k +right_command", "!open -a 'Safari'")
|
|
148
|
+
m("semicolon +right_command", "!open -a 'Mail'")
|
|
149
|
+
|
|
150
|
+
m("f +right_command", "!open -a 'Finder'")
|
|
151
|
+
m("s +right_command", "!open -a 'Slack'")
|
|
152
|
+
m("c +right_command", "!open -a 'Google Chrome'")
|
|
153
|
+
m("n +right_command", "!open -a 'Notes'")
|
|
154
|
+
|
|
155
|
+
# You can also map to other key combinations
|
|
156
|
+
m("t +right_command", "t +control +command +option")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
MyKaRules.new.call
|
data/exe/karules
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "karules"
|
|
5
|
+
|
|
6
|
+
# Determine config file path
|
|
7
|
+
config_file = ARGV.first
|
|
8
|
+
|
|
9
|
+
unless config_file
|
|
10
|
+
# Use XDG_CONFIG_HOME or default to ~/.config
|
|
11
|
+
config_home = ENV.fetch("XDG_CONFIG_HOME", File.expand_path("~/.config"))
|
|
12
|
+
config_file = File.join(config_home, "karules", "config.rb")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Check if config file exists
|
|
16
|
+
unless File.exist?(config_file)
|
|
17
|
+
warn "Config file not found: #{config_file}"
|
|
18
|
+
warn ""
|
|
19
|
+
warn "Usage: karules [CONFIG_FILE]"
|
|
20
|
+
warn ""
|
|
21
|
+
warn "Create a config file at #{config_file}"
|
|
22
|
+
warn "or specify a custom path as an argument."
|
|
23
|
+
warn ""
|
|
24
|
+
warn "See the example config at:"
|
|
25
|
+
warn " #{File.expand_path('../../examples/config.rb', __dir__)}"
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Load and execute the config file
|
|
30
|
+
load config_file
|
data/lib/karules/dsl.rb
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
4
|
+
# rubocop:disable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
|
|
5
|
+
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module KaRulesDSL # rubocop:disable Metrics/ModuleLength
|
|
9
|
+
APPLE_KEYS = %w[spotlight].freeze
|
|
10
|
+
|
|
11
|
+
def m(
|
|
12
|
+
from, to = nil, conditions: nil, to_if_alone: nil, to_delayed_action: nil,
|
|
13
|
+
to_if_held_down: nil, parameters: nil, to_after_key_up: nil
|
|
14
|
+
)
|
|
15
|
+
res = {}
|
|
16
|
+
conditions = @default_conditions || conditions
|
|
17
|
+
parameters = @default_parameters || parameters
|
|
18
|
+
res[:conditions] = wrap(conditions) if conditions
|
|
19
|
+
res[:parameters] = parameters if parameters
|
|
20
|
+
|
|
21
|
+
res[:from] = from(from)
|
|
22
|
+
|
|
23
|
+
res[:to] = to(to) if to
|
|
24
|
+
res[:to_if_alone] = to(to_if_alone) if to_if_alone
|
|
25
|
+
res[:to_if_held_down] = to(to_if_held_down) if to_if_held_down
|
|
26
|
+
res[:to_delayed_action] = to_delayed_action if to_delayed_action
|
|
27
|
+
res[:to_after_key_up] = to(to_after_key_up) if to_after_key_up
|
|
28
|
+
|
|
29
|
+
res[:type] = "basic"
|
|
30
|
+
@manipulators << res
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def from(from)
|
|
34
|
+
return from unless from.is_a?(String)
|
|
35
|
+
|
|
36
|
+
args = from.split
|
|
37
|
+
result =
|
|
38
|
+
if args.first.match?(":")
|
|
39
|
+
k, v = args.first.split(":")
|
|
40
|
+
{ k.to_sym => v }
|
|
41
|
+
else
|
|
42
|
+
{ key_code: args.first }
|
|
43
|
+
end
|
|
44
|
+
args[1..].each do |mod|
|
|
45
|
+
result[:modifiers] ||= {}
|
|
46
|
+
if mod.start_with?("+")
|
|
47
|
+
result[:modifiers][:mandatory] ||= []
|
|
48
|
+
result[:modifiers][:mandatory] << mod[1..]
|
|
49
|
+
elsif mod.start_with?("-")
|
|
50
|
+
result[:modifiers][:optional] ||= []
|
|
51
|
+
result[:modifiers][:optional] << mod[1..]
|
|
52
|
+
else
|
|
53
|
+
raise("Unknown modifier: #{mod}")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to(to)
|
|
60
|
+
return to.map { |t| to(t) } if to.is_a?(Array)
|
|
61
|
+
return to unless to.is_a?(String)
|
|
62
|
+
return { shell_command: to[1..] } if to.start_with?("!")
|
|
63
|
+
|
|
64
|
+
args = to.split
|
|
65
|
+
result = { key_code: args.first }
|
|
66
|
+
args[1..].each do |mod|
|
|
67
|
+
if mod == "lazy"
|
|
68
|
+
result[:lazy] = true
|
|
69
|
+
elsif mod.start_with?("+")
|
|
70
|
+
result[:modifiers] ||= []
|
|
71
|
+
result[:modifiers] << mod[1..]
|
|
72
|
+
# elsif mod.start_with?("!")
|
|
73
|
+
# result[:shell_command] = mod[1..] + args.split(mod).last
|
|
74
|
+
# break
|
|
75
|
+
else
|
|
76
|
+
raise("Unknown modifier: #{mod}")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
result
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def v(name, value)
|
|
83
|
+
{ set_variable: { name:, value: } }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def v_if(name, value, &)
|
|
87
|
+
block_given? ? conditions(v_if(name, value), &) : { name:, type: "variable_if", value: }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def v_unless(name, value, &)
|
|
91
|
+
block_given? ? conditions(v_unless(name, value), &) : { name:, type: "variable_unless", value: }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def mode_on(name = @default_mode) = v(name, true)
|
|
95
|
+
def mode_off(name = @default_mode)= v(name, false)
|
|
96
|
+
def mode_if(name = @default_mode, &) = v_if(name, true, &)
|
|
97
|
+
def mode_unless(name = @default_mode, &) = v_unless(name, true, &)
|
|
98
|
+
|
|
99
|
+
def apps(**apps)
|
|
100
|
+
@apps = apps
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def app_if(name, &)
|
|
104
|
+
if block_given?
|
|
105
|
+
conditions(app_if(name), &)
|
|
106
|
+
else
|
|
107
|
+
app = @apps[name] || raise("Unknown app: #{name}")
|
|
108
|
+
{ bundle_identifiers: wrap(app), type: "frontmost_application_if" }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def app_unless(name, &)
|
|
113
|
+
if block_given?
|
|
114
|
+
conditions(app_unless(name), &)
|
|
115
|
+
else
|
|
116
|
+
app = @apps[name] || raise("Unknown app: #{name}")
|
|
117
|
+
{ bundle_identifiers: wrap(app), type: "frontmost_application_unless" }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def desc(description)
|
|
122
|
+
@description = description
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def wrap(obj_or_arr)
|
|
126
|
+
obj_or_arr.is_a?(Array) ? obj_or_arr : [obj_or_arr]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def default_mode(name)
|
|
130
|
+
@default_mode = name
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def conditions(*conditions)
|
|
134
|
+
original_conditions = @default_conditions
|
|
135
|
+
@default_conditions = (@default_conditions || []) + conditions
|
|
136
|
+
yield
|
|
137
|
+
@default_conditions = original_conditions
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def parameters(**parameters)
|
|
141
|
+
original_parameters = @default_parameters
|
|
142
|
+
@default_parameters = (@default_parameters || {}).merge(parameters)
|
|
143
|
+
yield
|
|
144
|
+
@default_parameters = original_parameters
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def group(description = "", skip: false, enabled: true)
|
|
148
|
+
return if skip
|
|
149
|
+
|
|
150
|
+
@description = description
|
|
151
|
+
@manipulators = []
|
|
152
|
+
@default_mode = nil
|
|
153
|
+
@default_conditions = nil
|
|
154
|
+
@default_parameters = nil
|
|
155
|
+
yield
|
|
156
|
+
@result << { description: @description, manipulators: @manipulators }
|
|
157
|
+
@result.last[:enabled] = false unless enabled
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def karabiner_path(path = nil)
|
|
161
|
+
return @karabiner_path if path.nil?
|
|
162
|
+
|
|
163
|
+
@karabiner_path = File.expand_path(path)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def generate
|
|
167
|
+
@result = []
|
|
168
|
+
config
|
|
169
|
+
@result = deep_sort(@result)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def call
|
|
173
|
+
file = karabiner_path || default_karabiner_path
|
|
174
|
+
json = JSON.parse(File.read(file), symbolize_names: true)
|
|
175
|
+
|
|
176
|
+
json[:profiles][0][:complex_modifications][:rules].replace(generate)
|
|
177
|
+
|
|
178
|
+
File.write(file, json.to_json)
|
|
179
|
+
`karabiner_cli --format-json #{file}`
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
def default_karabiner_path
|
|
185
|
+
config_home = ENV.fetch("XDG_CONFIG_HOME", File.expand_path("~/.config"))
|
|
186
|
+
File.join(config_home, "karabiner", "karabiner.json")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def deep_sort(obj)
|
|
190
|
+
case obj
|
|
191
|
+
when Array
|
|
192
|
+
obj.map { |el| deep_sort(el) }
|
|
193
|
+
when Hash
|
|
194
|
+
obj.sort_by { |k, _| k }.to_h.transform_values { |v| deep_sort(v) }
|
|
195
|
+
else
|
|
196
|
+
obj
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# rubocop:enable Metrics/MethodLength,Metrics/AbcSize
|
|
202
|
+
# rubocop:enable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
|
data/lib/karules.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "karules/dsl"
|
|
4
|
+
require_relative "karules/version"
|
|
5
|
+
|
|
6
|
+
# Base class for Karabiner configuration
|
|
7
|
+
# Users should subclass this and override the config method
|
|
8
|
+
class KaRules
|
|
9
|
+
include KaRulesDSL
|
|
10
|
+
|
|
11
|
+
def config
|
|
12
|
+
# Override this method in your configuration file
|
|
13
|
+
# See examples/config.rb for a complete example
|
|
14
|
+
end
|
|
15
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: karules
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Sergey Tarasov
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2025-11-23 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rubocop
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.69'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.69'
|
|
27
|
+
description: A Ruby DSL for configuring Karabiner-Elements - cleaner, more maintainable
|
|
28
|
+
keyboard customization for macOS
|
|
29
|
+
email:
|
|
30
|
+
- dzirtusss@gmail.com
|
|
31
|
+
executables:
|
|
32
|
+
- karules
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- CHANGELOG.md
|
|
37
|
+
- LICENSE
|
|
38
|
+
- README.md
|
|
39
|
+
- examples/config.rb
|
|
40
|
+
- exe/karules
|
|
41
|
+
- lib/karules.rb
|
|
42
|
+
- lib/karules/dsl.rb
|
|
43
|
+
- lib/karules/version.rb
|
|
44
|
+
homepage: https://github.com/dzirtusss/karules
|
|
45
|
+
licenses:
|
|
46
|
+
- MIT
|
|
47
|
+
metadata:
|
|
48
|
+
homepage_uri: https://github.com/dzirtusss/karules
|
|
49
|
+
source_code_uri: https://github.com/dzirtusss/karules
|
|
50
|
+
bug_tracker_uri: https://github.com/dzirtusss/karules/issues
|
|
51
|
+
changelog_uri: https://github.com/dzirtusss/karules/blob/main/CHANGELOG.md
|
|
52
|
+
rubygems_mfa_required: 'true'
|
|
53
|
+
post_install_message:
|
|
54
|
+
rdoc_options: []
|
|
55
|
+
require_paths:
|
|
56
|
+
- lib
|
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: 3.3.0
|
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '0'
|
|
67
|
+
requirements: []
|
|
68
|
+
rubygems_version: 3.5.11
|
|
69
|
+
signing_key:
|
|
70
|
+
specification_version: 4
|
|
71
|
+
summary: Configure Karabiner-Elements with Ruby DSL
|
|
72
|
+
test_files: []
|