| ... | @@ -1,8 +1,8 @@ | ... | @@ -1,8 +1,8 @@ |
| 1 | import { assert, describe, it } from "vitest"; | 1 | import { assert, describe, it } from "vitest"; |
| 2 | | 2 | |
| 3 | import { | 3 | import { |
| | 4 | type KeybindingCommand, |
| 4 | type KeybindingShortcut, | 5 | type KeybindingShortcut, |
| 5 | type KeybindingsConfig, | | |
| 6 | type KeybindingWhenNode, | 6 | type KeybindingWhenNode, |
| 7 | type ResolvedKeybindingsConfig, | 7 | type ResolvedKeybindingsConfig, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| ... | @@ -18,14 +18,6 @@ import { | ... | @@ -18,14 +18,6 @@ import { |
| 18 | type ShortcutEventLike, | 18 | type ShortcutEventLike, |
| 19 | } from "./keybindings"; | 19 | } from "./keybindings"; |
| 20 | | 20 | |
| 21 | type WhenToken = | | |
| 22 | | { type: "identifier"; value: string } | | |
| 23 | | { type: "not" } | | |
| 24 | | { type: "and" } | | |
| 25 | | { type: "or" } | | |
| 26 | | { type: "lparen" } | | |
| 27 | | { type: "rparen" }; | | |
| 28 | | | |
| 29 | function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike { | 21 | function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike { |
| 30 | return { | 22 | return { |
| 31 | key: "j", | 23 | key: "j", |
| ... | @@ -106,144 +98,37 @@ function shortcut(value: string): KeybindingShortcut { | ... | @@ -106,144 +98,37 @@ function shortcut(value: string): KeybindingShortcut { |
| 106 | return parsed; | 98 | return parsed; |
| 107 | } | 99 | } |
| 108 | | 100 | |
| 109 | function tokenizeWhenExpression(expression: string): WhenToken[] | null { | 101 | function whenIdentifier(name: string): KeybindingWhenNode { |
| 110 | const tokens: WhenToken[] = []; | 102 | return { type: "identifier", name }; |
| 111 | let index = 0; | | |
| 112 | | | |
| 113 | while (index < expression.length) { | | |
| 114 | const current = expression[index]; | | |
| 115 | if (!current) break; | | |
| 116 | | | |
| 117 | if (/\s/.test(current)) { | | |
| 118 | index += 1; | | |
| 119 | continue; | | |
| 120 | } | | |
| 121 | if (expression.startsWith("&&", index)) { | | |
| 122 | tokens.push({ type: "and" }); | | |
| 123 | index += 2; | | |
| 124 | continue; | | |
| 125 | } | | |
| 126 | if (expression.startsWith("||", index)) { | | |
| 127 | tokens.push({ type: "or" }); | | |
| 128 | index += 2; | | |
| 129 | continue; | | |
| 130 | } | | |
| 131 | if (current === "!") { | | |
| 132 | tokens.push({ type: "not" }); | | |
| 133 | index += 1; | | |
| 134 | continue; | | |
| 135 | } | | |
| 136 | if (current === "(") { | | |
| 137 | tokens.push({ type: "lparen" }); | | |
| 138 | index += 1; | | |
| 139 | continue; | | |
| 140 | } | | |
| 141 | if (current === ")") { | | |
| 142 | tokens.push({ type: "rparen" }); | | |
| 143 | index += 1; | | |
| 144 | continue; | | |
| 145 | } | | |
| 146 | | | |
| 147 | const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); | | |
| 148 | if (!identifier) return null; | | |
| 149 | tokens.push({ type: "identifier", value: identifier[0] }); | | |
| 150 | index += identifier[0].length; | | |
| 151 | } | | |
| 152 | | | |
| 153 | return tokens; | | |
| 154 | } | 103 | } |
| 155 | | 104 | |
| 156 | function parseWhenExpression(expression: string): KeybindingWhenNode | null { | 105 | function whenNot(node: KeybindingWhenNode): KeybindingWhenNode { |
| 157 | const tokens = tokenizeWhenExpression(expression); | 106 | return { type: "not", node }; |
| 158 | if (!tokens || tokens.length === 0) return null; | 107 | } |
| 159 | let index = 0; | | |
| 160 | | | |
| 161 | const parsePrimary = (): KeybindingWhenNode | null => { | | |
| 162 | const token = tokens[index]; | | |
| 163 | if (!token) return null; | | |
| 164 | | | |
| 165 | if (token.type === "identifier") { | | |
| 166 | index += 1; | | |
| 167 | return { type: "identifier", name: token.value }; | | |
| 168 | } | | |
| 169 | | | |
| 170 | if (token.type === "lparen") { | | |
| 171 | index += 1; | | |
| 172 | const expressionNode = parseOr(); | | |
| 173 | const closeToken = tokens[index]; | | |
| 174 | if (!expressionNode || !closeToken || closeToken.type !== "rparen") { | | |
| 175 | return null; | | |
| 176 | } | | |
| 177 | index += 1; | | |
| 178 | return expressionNode; | | |
| 179 | } | | |
| 180 | | | |
| 181 | return null; | | |
| 182 | }; | | |
| 183 | | | |
| 184 | const parseUnary = (): KeybindingWhenNode | null => { | | |
| 185 | const token = tokens[index]; | | |
| 186 | if (token?.type === "not") { | | |
| 187 | index += 1; | | |
| 188 | const node = parseUnary(); | | |
| 189 | if (!node) return null; | | |
| 190 | return { type: "not", node }; | | |
| 191 | } | | |
| 192 | return parsePrimary(); | | |
| 193 | }; | | |
| 194 | | | |
| 195 | const parseAnd = (): KeybindingWhenNode | null => { | | |
| 196 | let left = parseUnary(); | | |
| 197 | if (!left) return null; | | |
| 198 | | | |
| 199 | while (tokens[index]?.type === "and") { | | |
| 200 | index += 1; | | |
| 201 | const right = parseUnary(); | | |
| 202 | if (!right) return null; | | |
| 203 | left = { type: "and", left, right }; | | |
| 204 | } | | |
| 205 | | | |
| 206 | return left; | | |
| 207 | }; | | |
| 208 | | | |
| 209 | const parseOr = (): KeybindingWhenNode | null => { | | |
| 210 | let left = parseAnd(); | | |
| 211 | if (!left) return null; | | |
| 212 | | | |
| 213 | while (tokens[index]?.type === "or") { | | |
| 214 | index += 1; | | |
| 215 | const right = parseAnd(); | | |
| 216 | if (!right) return null; | | |
| 217 | left = { type: "or", left, right }; | | |
| 218 | } | | |
| 219 | | 108 | |
| 220 | return left; | 109 | function whenAnd(left: KeybindingWhenNode, right: KeybindingWhenNode): KeybindingWhenNode { |
| 221 | }; | 110 | return { type: "and", left, right }; |
| | 111 | } |
| 222 | | 112 | |
| 223 | const ast = parseOr(); | 113 | interface TestBinding { |
| 224 | if (!ast || index !== tokens.length) return null; | 114 | key: string; |
| 225 | return ast; | 115 | command: KeybindingCommand; |
| | 116 | whenAst?: KeybindingWhenNode; |
| 226 | } | 117 | } |
| 227 | | 118 | |
| 228 | function compile(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { | 119 | function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { |
| 229 | const resolved: ResolvedKeybindingsConfig = []; | 120 | const resolved: ResolvedKeybindingsConfig = []; |
| 230 | for (const binding of bindings) { | 121 | for (const binding of bindings) { |
| 231 | const parsedShortcut = parseShortcut(binding.key); | 122 | const parsedShortcut = parseShortcut(binding.key); |
| 232 | if (!parsedShortcut) { | 123 | if (!parsedShortcut) { |
| 233 | throw new Error(`invalid shortcut in test fixture: ${binding.key}`); | 124 | throw new Error(`invalid shortcut in test fixture: ${binding.key}`); |
| 234 | } | 125 | } |
| 235 | const when = binding.when?.trim(); | 126 | if (binding.whenAst) { |
| 236 | if (when && when.length > 0) { | | |
| 237 | const whenAst = parseWhenExpression(when); | | |
| 238 | if (!whenAst) { | | |
| 239 | throw new Error(`invalid when expression in test fixture: ${when}`); | | |
| 240 | } | | |
| 241 | resolved.push({ | 127 | resolved.push({ |
| 242 | key: binding.key.trim(), | 128 | key: binding.key.trim(), |
| 243 | command: binding.command, | 129 | command: binding.command, |
| 244 | when, | | |
| 245 | shortcut: parsedShortcut, | 130 | shortcut: parsedShortcut, |
| 246 | whenAst, | 131 | whenAst: binding.whenAst, |
| 247 | }); | 132 | }); |
| 248 | continue; | 133 | continue; |
| 249 | } | 134 | } |
| ... | @@ -258,8 +143,16 @@ function compile(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { | ... | @@ -258,8 +143,16 @@ function compile(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { |
| 258 | | 143 | |
| 259 | const DEFAULT_BINDINGS = compile([ | 144 | const DEFAULT_BINDINGS = compile([ |
| 260 | { key: "mod+j", command: "terminal.toggle" }, | 145 | { key: "mod+j", command: "terminal.toggle" }, |
| 261 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | 146 | { |
| 262 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | 147 | key: "mod+d", |
| | 148 | command: "terminal.split", |
| | 149 | whenAst: whenIdentifier("terminalFocus"), |
| | 150 | }, |
| | 151 | { |
| | 152 | key: "mod+shift+d", |
| | 153 | command: "terminal.new", |
| | 154 | whenAst: whenIdentifier("terminalFocus"), |
| | 155 | }, |
| 263 | { key: "mod+shift+o", command: "chat.new" }, | 156 | { key: "mod+shift+o", command: "chat.new" }, |
| 264 | { key: "mod+o", command: "editor.openFavorite" }, | 157 | { key: "mod+o", command: "editor.openFavorite" }, |
| 265 | ]); | 158 | ]); |
| ... | @@ -314,12 +207,12 @@ describe("split/new terminal shortcuts", () => { | ... | @@ -314,12 +207,12 @@ describe("split/new terminal shortcuts", () => { |
| 314 | { | 207 | { |
| 315 | key: "mod+\\", | 208 | key: "mod+\\", |
| 316 | command: "terminal.split", | 209 | command: "terminal.split", |
| 317 | when: "terminalOpen && !terminalFocus", | 210 | whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))), |
| 318 | }, | 211 | }, |
| 319 | { | 212 | { |
| 320 | key: "mod+shift+n", | 213 | key: "mod+shift+n", |
| 321 | command: "terminal.new", | 214 | command: "terminal.new", |
| 322 | when: "terminalOpen && !terminalFocus", | 215 | whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))), |
| 323 | }, | 216 | }, |
| 324 | { key: "mod+j", command: "terminal.toggle" }, | 217 | { key: "mod+j", command: "terminal.toggle" }, |
| 325 | ]); | 218 | ]); |
| ... | @@ -345,8 +238,8 @@ describe("split/new terminal shortcuts", () => { | ... | @@ -345,8 +238,8 @@ describe("split/new terminal shortcuts", () => { |
| 345 | | 238 | |
| 346 | it("supports when boolean literals", () => { | 239 | it("supports when boolean literals", () => { |
| 347 | const keybindings = compile([ | 240 | const keybindings = compile([ |
| 348 | { key: "mod+n", command: "terminal.new", when: "true" }, | 241 | { key: "mod+n", command: "terminal.new", whenAst: whenIdentifier("true") }, |
| 349 | { key: "mod+m", command: "terminal.new", when: "false" }, | 242 | { key: "mod+m", command: "terminal.new", whenAst: whenIdentifier("false") }, |
| 350 | ]); | 243 | ]); |
| 351 | | 244 | |
| 352 | assert.isTrue( | 245 | assert.isTrue( |
| ... | @@ -365,8 +258,16 @@ describe("split/new terminal shortcuts", () => { | ... | @@ -365,8 +258,16 @@ describe("split/new terminal shortcuts", () => { |
| 365 | describe("shortcutLabelForCommand", () => { | 258 | describe("shortcutLabelForCommand", () => { |
| 366 | it("returns the most recent binding label", () => { | 259 | it("returns the most recent binding label", () => { |
| 367 | const bindings = compile([ | 260 | const bindings = compile([ |
| 368 | { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, | 261 | { |
| 369 | { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" }, | 262 | key: "mod+\\", |
| | 263 | command: "terminal.split", |
| | 264 | whenAst: whenIdentifier("terminalFocus"), |
| | 265 | }, |
| | 266 | { |
| | 267 | key: "mod+shift+\\", |
| | 268 | command: "terminal.split", |
| | 269 | whenAst: whenNot(whenIdentifier("terminalFocus")), |
| | 270 | }, |
| 370 | ]); | 271 | ]); |
| 371 | assert.strictEqual(shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\"); | 272 | assert.strictEqual(shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\"); |
| 372 | }); | 273 | }); |
| ... | @@ -412,7 +313,11 @@ describe("cross-command precedence", () => { | ... | @@ -412,7 +313,11 @@ describe("cross-command precedence", () => { |
| 412 | it("uses when + order so a later focused rule overrides a global rule", () => { | 313 | it("uses when + order so a later focused rule overrides a global rule", () => { |
| 413 | const keybindings = compile([ | 314 | const keybindings = compile([ |
| 414 | { key: "mod+n", command: "chat.new" }, | 315 | { key: "mod+n", command: "chat.new" }, |
| 415 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | 316 | { |
| | 317 | key: "mod+n", |
| | 318 | command: "terminal.new", |
| | 319 | whenAst: whenIdentifier("terminalFocus"), |
| | 320 | }, |
| 416 | ]); | 321 | ]); |
| 417 | | 322 | |
| 418 | assert.isTrue( | 323 | assert.isTrue( |
| ... | @@ -443,7 +348,11 @@ describe("cross-command precedence", () => { | ... | @@ -443,7 +348,11 @@ describe("cross-command precedence", () => { |
| 443 | | 348 | |
| 444 | it("still lets a later global rule win when both rules match", () => { | 349 | it("still lets a later global rule win when both rules match", () => { |
| 445 | const keybindings = compile([ | 350 | const keybindings = compile([ |
| 446 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | 351 | { |
| | 352 | key: "mod+n", |
| | 353 | command: "terminal.new", |
| | 354 | whenAst: whenIdentifier("terminalFocus"), |
| | 355 | }, |
| 447 | { key: "mod+n", command: "chat.new" }, | 356 | { key: "mod+n", command: "chat.new" }, |
| 448 | ]); | 357 | ]); |
| 449 | | 358 | |