xterm-input-panel 1.0.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.
@@ -0,0 +1,543 @@
1
+ import { LitElement, css, html } from 'lit'
2
+ import { Application, CanvasTextMetrics, Container, FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js'
3
+ import { createElement, SquareSlash, SquareTerminal } from 'lucide'
4
+ import { resolvePixiTheme, onThemeChange, type PixiTheme } from './pixi-theme.js'
5
+ import { buildShortcutPages, type ShortcutAction, type ShortcutItem, type ShortcutPage } from './shortcut-pages.js'
6
+ import { detectHostPlatform, type PlatformMode } from './platform.js'
7
+
8
+ const GRID_GAP = 8
9
+ const GRID_PADDING = 10
10
+ const CARD_RADIUS = 8
11
+
12
+ const CLAUDE_ICON_URL = new URL('./brand-icons/claude.png', import.meta.url).href
13
+ const CODEX_ICON_URL = new URL('./brand-icons/codex.png', import.meta.url).href
14
+ const GEMINI_ICON_URL = new URL('./brand-icons/gemini.png', import.meta.url).href
15
+
16
+ interface RenderedShortcut {
17
+ item: ShortcutItem
18
+ container: Container
19
+ gfx: Graphics
20
+ width: number
21
+ height: number
22
+ }
23
+
24
+ export class ShortcutTab extends LitElement {
25
+ static get properties() {
26
+ return {
27
+ platform: { type: String, reflect: true },
28
+ activePageId: { type: String, attribute: 'active-page' },
29
+ }
30
+ }
31
+
32
+ static styles = css`
33
+ :host {
34
+ display: block;
35
+ height: 100%;
36
+ min-height: 0;
37
+ }
38
+
39
+ .layout {
40
+ height: 100%;
41
+ min-height: 0;
42
+ display: grid;
43
+ grid-template-columns: 56px minmax(0, 1fr);
44
+ }
45
+
46
+ .pages {
47
+ border-right: 1px solid var(--border, #333);
48
+ padding: 8px 6px;
49
+ display: flex;
50
+ flex-direction: column;
51
+ gap: 8px;
52
+ overflow-y: auto;
53
+ min-height: 0;
54
+ align-items: center;
55
+ }
56
+
57
+ .page-btn {
58
+ width: 40px;
59
+ height: 40px;
60
+ border: 1px solid var(--border, #333);
61
+ border-radius: 6px;
62
+ background: var(--muted, #2a2a2a);
63
+ color: var(--muted-foreground, #aaa);
64
+ display: inline-flex;
65
+ align-items: center;
66
+ justify-content: center;
67
+ padding: 0;
68
+ cursor: pointer;
69
+ transition: border-color 0.15s, color 0.15s, background 0.15s;
70
+ }
71
+
72
+ .page-btn:hover {
73
+ border-color: var(--primary, #e04a2f);
74
+ color: var(--foreground, #fff);
75
+ }
76
+
77
+ .page-btn[data-active] {
78
+ border-color: var(--primary, #e04a2f);
79
+ background: color-mix(in srgb, var(--primary, #e04a2f), transparent 85%);
80
+ color: var(--foreground, #fff);
81
+ }
82
+
83
+ .canvas-wrap {
84
+ position: relative;
85
+ min-height: 0;
86
+ }
87
+
88
+ .pixi-host {
89
+ width: 100%;
90
+ height: 100%;
91
+ }
92
+
93
+ .page-logo {
94
+ width: 20px;
95
+ height: 20px;
96
+ object-fit: contain;
97
+ display: block;
98
+ }
99
+
100
+ .page-vector {
101
+ display: inline-flex;
102
+ width: 20px;
103
+ height: 20px;
104
+ align-items: center;
105
+ justify-content: center;
106
+ color: currentColor;
107
+ }
108
+
109
+ .page-vector svg {
110
+ width: 18px;
111
+ height: 18px;
112
+ stroke-width: 2;
113
+ }
114
+
115
+ .sr-only {
116
+ position: absolute;
117
+ width: 1px;
118
+ height: 1px;
119
+ padding: 0;
120
+ margin: -1px;
121
+ overflow: hidden;
122
+ clip: rect(0, 0, 0, 0);
123
+ white-space: nowrap;
124
+ border: 0;
125
+ }
126
+ `
127
+
128
+ declare platform: PlatformMode
129
+ declare activePageId: string
130
+
131
+ private _app: Application | null = null
132
+ private _container: Container | null = null
133
+ private _resizeObserver: ResizeObserver | null = null
134
+ private _theme: PixiTheme = resolvePixiTheme(this)
135
+ private _unsubTheme: (() => void) | null = null
136
+ private _renderedItems: RenderedShortcut[] = []
137
+
138
+ constructor() {
139
+ super()
140
+ this.platform = 'auto'
141
+ this.activePageId = ''
142
+ }
143
+
144
+ async connectedCallback() {
145
+ super.connectedCallback()
146
+ this._theme = resolvePixiTheme(this)
147
+ this._unsubTheme = onThemeChange((theme) => {
148
+ this._theme = theme
149
+ this._layoutPage()
150
+ }, this)
151
+ this._ensureActivePage()
152
+ await this.updateComplete
153
+ await this._initPixi()
154
+ }
155
+
156
+ disconnectedCallback() {
157
+ super.disconnectedCallback()
158
+ this._resizeObserver?.disconnect()
159
+ this._resizeObserver = null
160
+ this._unsubTheme?.()
161
+ this._unsubTheme = null
162
+ this._app?.destroy()
163
+ this._app = null
164
+ this._container = null
165
+ this._renderedItems = []
166
+ }
167
+
168
+ protected updated(changed: Map<string, unknown>) {
169
+ super.updated(changed)
170
+ if (changed.has('platform')) {
171
+ this._ensureActivePage()
172
+ this._layoutPage()
173
+ }
174
+ if (changed.has('activePageId')) {
175
+ this._layoutPage()
176
+ }
177
+ }
178
+
179
+ private _hostPlatform() {
180
+ if (this.platform === 'windows' || this.platform === 'macos' || this.platform === 'common') {
181
+ return this.platform
182
+ }
183
+ return detectHostPlatform()
184
+ }
185
+
186
+ private _pages(): ShortcutPage[] {
187
+ return buildShortcutPages(this._hostPlatform())
188
+ }
189
+
190
+ private _activePage(): ShortcutPage {
191
+ const pages = this._pages()
192
+ return pages.find((page) => page.id === this.activePageId) ?? pages[0]!
193
+ }
194
+
195
+ private _ensureActivePage() {
196
+ const pages = this._pages()
197
+ if (!pages.length) return
198
+ const exists = pages.some((page) => page.id === this.activePageId)
199
+ if (!exists) {
200
+ this.activePageId = pages[0]!.id
201
+ }
202
+ }
203
+
204
+ private async _initPixi() {
205
+ const host = this.shadowRoot?.querySelector('.pixi-host') as HTMLElement
206
+ if (!host) return
207
+
208
+ const app = new Application()
209
+ await app.init({
210
+ background: this._theme.background,
211
+ antialias: true,
212
+ resolution: window.devicePixelRatio || 1,
213
+ autoDensity: false,
214
+ })
215
+
216
+ host.appendChild(app.canvas as HTMLCanvasElement)
217
+ this._app = app
218
+
219
+ const canvas = app.canvas as HTMLCanvasElement
220
+ canvas.style.width = '100%'
221
+ canvas.style.height = '100%'
222
+ canvas.style.display = 'block'
223
+ canvas.addEventListener('contextmenu', (event) => event.preventDefault())
224
+
225
+ const width = host.clientWidth
226
+ const height = host.clientHeight
227
+ if (width > 0 && height > 0) {
228
+ app.renderer.resize(width, height)
229
+ }
230
+
231
+ this._container = new Container()
232
+ app.stage.addChild(this._container)
233
+ this._layoutPage()
234
+
235
+ this._resizeObserver = new ResizeObserver(() => {
236
+ const nextWidth = host.clientWidth
237
+ const nextHeight = host.clientHeight
238
+ if (nextWidth > 0 && nextHeight > 0) {
239
+ app.renderer.resize(nextWidth, nextHeight)
240
+ this._layoutPage()
241
+ }
242
+ })
243
+ this._resizeObserver.observe(host)
244
+ }
245
+
246
+ private _drawCard(gfx: Graphics, width: number, height: number, pressed: boolean) {
247
+ gfx.clear()
248
+ gfx.roundRect(0, 0, width, height, CARD_RADIUS)
249
+ gfx.fill({
250
+ color: pressed
251
+ ? this._theme.keyPressed
252
+ : this._theme.keyNormal,
253
+ })
254
+ gfx.stroke({
255
+ color: pressed ? this._theme.accent : this._theme.surfaceBorder,
256
+ width: pressed ? 1.5 : 1,
257
+ })
258
+ }
259
+
260
+ private _drawDpad(gfx: Graphics, width: number, height: number, pressed: boolean) {
261
+ const radius = Math.min(width, height) * 0.42
262
+ const cx = width / 2
263
+ const cy = height / 2
264
+
265
+ gfx.clear()
266
+ gfx.roundRect(0, 0, width, height, CARD_RADIUS)
267
+ gfx.fill({ color: this._theme.keyNormal })
268
+ gfx.stroke({ color: this._theme.surfaceBorder, width: 1 })
269
+
270
+ gfx.circle(cx, cy, radius)
271
+ gfx.fill({ color: pressed ? this._theme.keyPressed : this._theme.keyModifier })
272
+ gfx.stroke({ color: this._theme.surfaceBorder, width: 1 })
273
+
274
+ gfx.moveTo(cx - radius, cy)
275
+ gfx.lineTo(cx + radius, cy)
276
+ gfx.moveTo(cx, cy - radius)
277
+ gfx.lineTo(cx, cy + radius)
278
+ gfx.stroke({ color: this._theme.surfaceBorder, width: 1 })
279
+ }
280
+
281
+ private _measureTextWidth(text: Text): number {
282
+ const content = typeof text.text === 'string' ? text.text : String(text.text ?? '')
283
+ if (!content) return 0
284
+ try {
285
+ return CanvasTextMetrics.measureText(content, text.style).width
286
+ } catch {
287
+ return text.width / Math.max(text.scale.x, 0.0001)
288
+ }
289
+ }
290
+
291
+ private _fitText(text: Text, maxWidth: number, minScale = 0.5) {
292
+ if (maxWidth <= 0) return
293
+ text.scale.set(1)
294
+ const width = this._measureTextWidth(text)
295
+ if (width <= maxWidth) {
296
+ return
297
+ }
298
+ const scale = Math.max(minScale, maxWidth / width)
299
+ text.scale.set(scale)
300
+ }
301
+
302
+ private _layoutPage() {
303
+ const app = this._app
304
+ const container = this._container
305
+ if (!app || !container) return
306
+
307
+ container.removeChildren()
308
+ this._renderedItems = []
309
+
310
+ const page = this._activePage()
311
+ const width = app.screen.width
312
+ const height = app.screen.height
313
+
314
+ const cellWidth = (width - GRID_PADDING * 2 - GRID_GAP * (page.cols - 1)) / page.cols
315
+ const cellHeight = (height - GRID_PADDING * 2 - GRID_GAP * (page.rows - 1)) / page.rows
316
+
317
+ for (const item of page.items) {
318
+ const cols = item.cols ?? 1
319
+ const rows = item.rows ?? 1
320
+ const x = GRID_PADDING + item.col * (cellWidth + GRID_GAP)
321
+ const y = GRID_PADDING + item.row * (cellHeight + GRID_GAP)
322
+ const cardWidth = cellWidth * cols + GRID_GAP * (cols - 1)
323
+ const cardHeight = cellHeight * rows + GRID_GAP * (rows - 1)
324
+
325
+ const itemContainer = new Container()
326
+ itemContainer.x = x
327
+ itemContainer.y = y
328
+ itemContainer.eventMode = 'static'
329
+ itemContainer.cursor = 'pointer'
330
+
331
+ const gfx = new Graphics()
332
+ itemContainer.addChild(gfx)
333
+
334
+ if (item.kind === 'dpad') {
335
+ this._drawDpad(gfx, cardWidth, cardHeight, false)
336
+ } else {
337
+ this._drawCard(gfx, cardWidth, cardHeight, false)
338
+ }
339
+
340
+ const label = new Text({
341
+ text: item.label,
342
+ style: new TextStyle({
343
+ fontFamily: 'ui-monospace, SFMono-Regular, monospace',
344
+ fontSize: cardHeight > 48 ? 12 : 11,
345
+ fill: this._theme.text,
346
+ align: 'center',
347
+ }),
348
+ })
349
+
350
+ if (item.kind === 'dpad') {
351
+ label.anchor.set(0.5)
352
+ label.x = cardWidth / 2
353
+ label.y = cardHeight / 2
354
+ } else {
355
+ label.anchor.set(0.5)
356
+ label.x = cardWidth / 2
357
+ label.y = cardHeight / 2
358
+ }
359
+ this._fitText(label, cardWidth - 12)
360
+ itemContainer.addChild(label)
361
+
362
+ itemContainer.on('pointerdown', () => {
363
+ if (item.kind === 'dpad') {
364
+ this._drawDpad(gfx, cardWidth, cardHeight, true)
365
+ } else {
366
+ this._drawCard(gfx, cardWidth, cardHeight, true)
367
+ }
368
+ })
369
+
370
+ itemContainer.on('pointerleave', () => {
371
+ if (item.kind === 'dpad') {
372
+ this._drawDpad(gfx, cardWidth, cardHeight, false)
373
+ } else {
374
+ this._drawCard(gfx, cardWidth, cardHeight, false)
375
+ }
376
+ })
377
+
378
+ itemContainer.on('pointerupoutside', () => {
379
+ if (item.kind === 'dpad') {
380
+ this._drawDpad(gfx, cardWidth, cardHeight, false)
381
+ } else {
382
+ this._drawCard(gfx, cardWidth, cardHeight, false)
383
+ }
384
+ })
385
+
386
+ itemContainer.on('pointerup', (event: FederatedPointerEvent) => {
387
+ if (item.kind === 'dpad') {
388
+ this._drawDpad(gfx, cardWidth, cardHeight, false)
389
+ } else {
390
+ this._drawCard(gfx, cardWidth, cardHeight, false)
391
+ }
392
+ void this._activateShortcut(item, event, cardWidth, cardHeight, itemContainer)
393
+ })
394
+
395
+ container.addChild(itemContainer)
396
+ this._renderedItems.push({
397
+ item,
398
+ container: itemContainer,
399
+ gfx,
400
+ width: cardWidth,
401
+ height: cardHeight,
402
+ })
403
+ }
404
+ }
405
+
406
+ private _send(data: string) {
407
+ this.dispatchEvent(
408
+ new CustomEvent('input-panel:send', {
409
+ detail: { data },
410
+ bubbles: true,
411
+ composed: true,
412
+ }),
413
+ )
414
+ }
415
+
416
+ private async _handleCommand(command: 'copy' | 'paste' | 'select-all') {
417
+ if (command === 'copy') {
418
+ const selection = window.getSelection()?.toString() ?? ''
419
+ if (!selection) return
420
+ try {
421
+ await navigator.clipboard.writeText(selection)
422
+ } catch {
423
+ document.execCommand('copy')
424
+ }
425
+ return
426
+ }
427
+
428
+ if (command === 'paste') {
429
+ try {
430
+ const text = await navigator.clipboard.readText()
431
+ if (text) this._send(text)
432
+ } catch {
433
+ // ignore clipboard permission failures
434
+ }
435
+ return
436
+ }
437
+
438
+ const active = document.activeElement
439
+ if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
440
+ active.select()
441
+ return
442
+ }
443
+ document.execCommand('selectAll')
444
+ }
445
+
446
+ private _dpadData(event: FederatedPointerEvent, width: number, height: number, container: Container): string {
447
+ const local = container.toLocal(event.global)
448
+ const dx = local.x - width / 2
449
+ const dy = local.y - height / 2
450
+
451
+ if (Math.abs(dx) <= 8 && Math.abs(dy) <= 8) {
452
+ return '\r'
453
+ }
454
+
455
+ if (Math.abs(dx) > Math.abs(dy)) {
456
+ return dx < 0 ? '\x1b[D' : '\x1b[C'
457
+ }
458
+ return dy < 0 ? '\x1b[A' : '\x1b[B'
459
+ }
460
+
461
+ private async _activateShortcut(
462
+ item: ShortcutItem,
463
+ event: FederatedPointerEvent,
464
+ width: number,
465
+ height: number,
466
+ container: Container,
467
+ ) {
468
+ if (item.kind === 'dpad') {
469
+ this._send(this._dpadData(event, width, height, container))
470
+ return
471
+ }
472
+
473
+ const action: ShortcutAction | undefined = item.action
474
+ if (!action) return
475
+
476
+ if (action.type === 'send') {
477
+ this._send(action.data)
478
+ return
479
+ }
480
+
481
+ if (action.type === 'text') {
482
+ this._send(action.text)
483
+ return
484
+ }
485
+
486
+ await this._handleCommand(action.command)
487
+ }
488
+
489
+ private _setActivePage(pageId: string) {
490
+ if (this.activePageId === pageId) return
491
+ this.activePageId = pageId
492
+ }
493
+
494
+ private _renderPageIcon(pageId: string) {
495
+ if (pageId === 'system') {
496
+ return html`<span class="page-vector">${createElement(SquareTerminal)}</span>`
497
+ }
498
+ if (pageId === 'terminal') {
499
+ return html`<span class="page-vector">${createElement(SquareSlash)}</span>`
500
+ }
501
+
502
+ if (pageId === 'claude') {
503
+ return html`<img class="page-logo" src=${CLAUDE_ICON_URL} alt="" />`
504
+ }
505
+ if (pageId === 'codex') {
506
+ return html`<img class="page-logo" src=${CODEX_ICON_URL} alt="" />`
507
+ }
508
+ if (pageId === 'gemini') {
509
+ return html`<img class="page-logo" src=${GEMINI_ICON_URL} alt="" />`
510
+ }
511
+
512
+ return html`<span class="page-vector">${createElement(SquareSlash)}</span>`
513
+ }
514
+
515
+ render() {
516
+ const pages = this._pages()
517
+ const activePage = this._activePage()
518
+ return html`
519
+ <div class="layout">
520
+ <div class="pages">
521
+ ${pages.map((page) => html`
522
+ <button
523
+ type="button"
524
+ class="page-btn"
525
+ title=${page.title}
526
+ aria-label=${page.title}
527
+ ?data-active=${page.id === activePage.id}
528
+ @click=${() => this._setActivePage(page.id)}
529
+ >
530
+ ${this._renderPageIcon(page.id)}
531
+ <span class="sr-only">${page.title}</span>
532
+ </button>
533
+ `)}
534
+ </div>
535
+ <div class="canvas-wrap">
536
+ <div class="pixi-host"></div>
537
+ </div>
538
+ </div>
539
+ `
540
+ }
541
+ }
542
+
543
+ customElements.define('shortcut-tab', ShortcutTab)
@@ -0,0 +1,150 @@
1
+ import type { HostPlatform } from './platform.js'
2
+
3
+ export type ModifierKey = 'ctrl' | 'alt' | 'meta' | 'shift' | 'caps'
4
+
5
+ export interface KeyDef {
6
+ label: string
7
+ data: string
8
+ w?: number
9
+ modifier?: ModifierKey
10
+ special?: 'chord'
11
+ shift?: { label: string; data: string }
12
+ }
13
+
14
+ const letter = (char: string): KeyDef => ({
15
+ label: char,
16
+ data: char,
17
+ shift: { label: char.toUpperCase(), data: char.toUpperCase() },
18
+ })
19
+
20
+ const NAV_ROW: KeyDef[] = [
21
+ { label: 'Esc', data: '\x1b', w: 1.1 },
22
+ { label: 'Home', data: '\x1b[H', w: 1.2 },
23
+ { label: 'End', data: '\x1b[F', w: 1.2 },
24
+ { label: 'PgUp', data: '\x1b[5~', w: 1.2 },
25
+ { label: 'PgDn', data: '\x1b[6~', w: 1.2 },
26
+ { label: '⬅', data: '\x1b[D', w: 1 },
27
+ { label: '⬆', data: '\x1b[A', w: 1 },
28
+ { label: '⬇', data: '\x1b[B', w: 1 },
29
+ { label: '⮕', data: '\x1b[C', w: 1 },
30
+ ]
31
+
32
+ const NUMBER_KEYS: KeyDef[] = [
33
+ { label: '`', data: '`', shift: { label: '~', data: '~' } },
34
+ { label: '1', data: '1', shift: { label: '!', data: '!' } },
35
+ { label: '2', data: '2', shift: { label: '@', data: '@' } },
36
+ { label: '3', data: '3', shift: { label: '#', data: '#' } },
37
+ { label: '4', data: '4', shift: { label: '$', data: '$' } },
38
+ { label: '5', data: '5', shift: { label: '%', data: '%' } },
39
+ { label: '6', data: '6', shift: { label: '^', data: '^' } },
40
+ { label: '7', data: '7', shift: { label: '&', data: '&' } },
41
+ { label: '8', data: '8', shift: { label: '*', data: '*' } },
42
+ { label: '9', data: '9', shift: { label: '(', data: '(' } },
43
+ { label: '0', data: '0', shift: { label: ')', data: ')' } },
44
+ { label: '-', data: '-', shift: { label: '_', data: '_' } },
45
+ { label: '=', data: '=', shift: { label: '+', data: '+' } },
46
+ ]
47
+
48
+ const LETTERS_Q_ROW: KeyDef[] = 'qwertyuiop'.split('').map(letter)
49
+ const LETTERS_A_ROW: KeyDef[] = 'asdfghjkl'.split('').map(letter)
50
+ const LETTERS_Z_ROW: KeyDef[] = 'zxcvbnm'.split('').map(letter)
51
+
52
+ const Q_ROW: KeyDef[] = [
53
+ { label: 'Tab', data: '\t', w: 1.5 },
54
+ ...LETTERS_Q_ROW,
55
+ {
56
+ label: '[',
57
+ data: '[',
58
+ shift: { label: '{', data: '{' },
59
+ w: 1,
60
+ },
61
+ {
62
+ label: ']',
63
+ data: ']',
64
+ shift: { label: '}', data: '}' },
65
+ w: 1,
66
+ },
67
+ { label: '\\', data: '\\', shift: { label: '|', data: '|' }, w: 1.3 },
68
+ ]
69
+
70
+ const CAPS_ROW_COMMON: KeyDef[] = [
71
+ { label: 'Caps', data: '', modifier: 'caps', w: 1.8 },
72
+ ...LETTERS_A_ROW,
73
+ { label: ';', data: ';', shift: { label: ':', data: ':' } },
74
+ { label: "'", data: "'", shift: { label: '"', data: '"' } },
75
+ { label: 'Enter', data: '\r', w: 2.1 },
76
+ ]
77
+
78
+ const CAPS_ROW_MACOS: KeyDef[] = [
79
+ { label: 'Caps', data: '', modifier: 'caps', w: 1.8 },
80
+ ...LETTERS_A_ROW,
81
+ { label: ';', data: ';', shift: { label: ':', data: ':' } },
82
+ { label: "'", data: "'", shift: { label: '"', data: '"' } },
83
+ { label: 'Return', data: '\r', w: 2.1 },
84
+ ]
85
+
86
+ const SHIFT_ROW: KeyDef[] = [
87
+ { label: 'Shift', data: '', modifier: 'shift', w: 2.2 },
88
+ ...LETTERS_Z_ROW,
89
+ { label: ',', data: ',', shift: { label: '<', data: '<' } },
90
+ { label: '.', data: '.', shift: { label: '>', data: '>' } },
91
+ { label: '/', data: '/', shift: { label: '?', data: '?' } },
92
+ { label: 'Shift', data: '', modifier: 'shift', w: 2.2 },
93
+ ]
94
+
95
+ const COMMON_BOTTOM_ROW: KeyDef[] = [
96
+ { label: 'Ctrl', data: '', modifier: 'ctrl', w: 1.4 },
97
+ { label: 'Alt', data: '', modifier: 'alt', w: 1.4 },
98
+ { label: 'Meta', data: '', modifier: 'meta', w: 1.6 },
99
+ { label: 'Space', data: ' ', w: 6.1 },
100
+ { label: '⌬', data: '', special: 'chord', w: 1.9 },
101
+ ]
102
+
103
+ const WINDOWS_BOTTOM_ROW: KeyDef[] = [
104
+ { label: 'Ctrl', data: '', modifier: 'ctrl', w: 1.4 },
105
+ { label: 'Win', data: '', modifier: 'meta', w: 1.6 },
106
+ { label: 'Alt', data: '', modifier: 'alt', w: 1.4 },
107
+ { label: 'Space', data: ' ', w: 6.1 },
108
+ { label: '⌬', data: '', special: 'chord', w: 1.9 },
109
+ ]
110
+
111
+ const MACOS_BOTTOM_ROW: KeyDef[] = [
112
+ { label: 'Control', data: '', modifier: 'ctrl', w: 1.4 },
113
+ { label: 'Option', data: '', modifier: 'alt', w: 1.5 },
114
+ { label: 'Command', data: '', modifier: 'meta', w: 1.8 },
115
+ { label: 'Space', data: ' ', w: 5.8 },
116
+ { label: '⌬', data: '', special: 'chord', w: 1.9 },
117
+ ]
118
+
119
+ const COMMON_ROWS: KeyDef[][] = [
120
+ NAV_ROW,
121
+ [...NUMBER_KEYS, { label: 'Bksp', data: '\x7f', w: 1.8 }],
122
+ Q_ROW,
123
+ CAPS_ROW_COMMON,
124
+ SHIFT_ROW,
125
+ COMMON_BOTTOM_ROW,
126
+ ]
127
+
128
+ const WINDOWS_ROWS: KeyDef[][] = [
129
+ NAV_ROW,
130
+ [...NUMBER_KEYS, { label: 'Bksp', data: '\x7f', w: 1.8 }],
131
+ Q_ROW,
132
+ CAPS_ROW_COMMON,
133
+ SHIFT_ROW,
134
+ WINDOWS_BOTTOM_ROW,
135
+ ]
136
+
137
+ const MACOS_ROWS: KeyDef[][] = [
138
+ NAV_ROW,
139
+ [...NUMBER_KEYS, { label: 'Delete', data: '\x7f', w: 1.8 }],
140
+ Q_ROW,
141
+ CAPS_ROW_MACOS,
142
+ SHIFT_ROW,
143
+ MACOS_BOTTOM_ROW,
144
+ ]
145
+
146
+ export const LAYOUTS: Record<HostPlatform, KeyDef[][]> = {
147
+ windows: WINDOWS_ROWS,
148
+ macos: MACOS_ROWS,
149
+ common: COMMON_ROWS,
150
+ }