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,642 @@
1
+ import { LitElement, css, html } from 'lit'
2
+ import {
3
+ Application,
4
+ CanvasTextMetrics,
5
+ Container,
6
+ FederatedPointerEvent,
7
+ Graphics,
8
+ Text,
9
+ TextStyle,
10
+ } from 'pixi.js'
11
+ import { onThemeChange, resolvePixiTheme, type PixiTheme } from './pixi-theme.js'
12
+ import { detectHostPlatform, type PlatformMode } from './platform.js'
13
+ import { LAYOUTS, type KeyDef, type ModifierKey } from './virtual-keyboard-layouts.js'
14
+
15
+ const KEY_PADDING = 3
16
+ const KEY_RADIUS = 4
17
+ const REPEAT_DELAY_MS = 400
18
+ const REPEAT_INTERVAL_MS = 80
19
+ const SWIPE_SHIFT_THRESHOLD = 14
20
+ const DUAL_LABEL_MIN_SIZE = 18
21
+ const DUAL_LABEL_MIN_RATIO = 0.92
22
+
23
+ interface RenderedKey {
24
+ container: Container
25
+ gfx: Graphics
26
+ primaryText: Text
27
+ secondaryText: Text | null
28
+ def: KeyDef
29
+ row: number
30
+ col: number
31
+ width: number
32
+ height: number
33
+ }
34
+
35
+ interface KeyDisplay {
36
+ single: string
37
+ top?: string
38
+ bottom?: string
39
+ topActive: boolean
40
+ }
41
+
42
+ export class VirtualKeyboardTab extends LitElement {
43
+ static get properties() {
44
+ return {
45
+ floating: { type: Boolean },
46
+ platform: { type: String, reflect: true },
47
+ }
48
+ }
49
+
50
+ static styles = css`
51
+ :host {
52
+ display: block;
53
+ width: 100%;
54
+ height: 100%;
55
+ touch-action: none;
56
+ }
57
+ `
58
+
59
+ declare floating: boolean
60
+ declare platform: PlatformMode
61
+
62
+ private _app: Application | null = null
63
+ private _container: Container | null = null
64
+ private _keys: RenderedKey[] = []
65
+ private _modifiers: Record<ModifierKey, boolean> = {
66
+ ctrl: false,
67
+ alt: false,
68
+ meta: false,
69
+ shift: false,
70
+ caps: false,
71
+ }
72
+ private _resizeObserver: ResizeObserver | null = null
73
+ private _theme: PixiTheme = resolvePixiTheme(this)
74
+ private _unsubTheme: (() => void) | null = null
75
+ private _repeatTimer: ReturnType<typeof setTimeout> | null = null
76
+ private _repeatInterval: ReturnType<typeof setInterval> | null = null
77
+
78
+ private _activeKey: RenderedKey | null = null
79
+ private _activeStartY = 0
80
+ private _activeSwipeShift = false
81
+ private _activePointerId: number | null = null
82
+ private _stickyModifierMode = false
83
+
84
+ constructor() {
85
+ super()
86
+ this.floating = false
87
+ this.platform = 'auto'
88
+ }
89
+
90
+ async connectedCallback() {
91
+ super.connectedCallback()
92
+ this._theme = resolvePixiTheme(this)
93
+ this._unsubTheme = onThemeChange((theme) => {
94
+ this._theme = theme
95
+ this._layoutKeys()
96
+ }, this)
97
+ await this.updateComplete
98
+ await this._initPixi()
99
+ }
100
+
101
+ disconnectedCallback() {
102
+ super.disconnectedCallback()
103
+ this._cancelRepeat()
104
+ this._resizeObserver?.disconnect()
105
+ this._resizeObserver = null
106
+ this._unsubTheme?.()
107
+ this._unsubTheme = null
108
+ this._app?.destroy()
109
+ this._app = null
110
+ this._container = null
111
+ this._activeKey = null
112
+ this._activePointerId = null
113
+ }
114
+
115
+ private getRows(): KeyDef[][] {
116
+ const hostPlatform =
117
+ this.platform === 'windows' || this.platform === 'macos' || this.platform === 'common'
118
+ ? this.platform
119
+ : detectHostPlatform()
120
+ return LAYOUTS[hostPlatform]
121
+ }
122
+
123
+ private async _initPixi() {
124
+ const host = this.shadowRoot?.querySelector('.pixi-host') as HTMLElement
125
+ if (!host) return
126
+
127
+ const app = new Application()
128
+ await app.init({
129
+ background: this._theme.background,
130
+ backgroundAlpha: this.floating ? 0 : 1,
131
+ antialias: true,
132
+ resolution: window.devicePixelRatio || 1,
133
+ autoDensity: false,
134
+ })
135
+
136
+ host.appendChild(app.canvas as HTMLCanvasElement)
137
+ this._app = app
138
+
139
+ const canvas = app.canvas as HTMLCanvasElement
140
+ canvas.style.width = '100%'
141
+ canvas.style.height = '100%'
142
+ canvas.style.display = 'block'
143
+ canvas.addEventListener('contextmenu', (e) => e.preventDefault())
144
+ canvas.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false })
145
+ canvas.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false })
146
+
147
+ const width = host.clientWidth
148
+ const height = host.clientHeight
149
+ if (width > 0 && height > 0) {
150
+ app.renderer.resize(width, height)
151
+ }
152
+
153
+ this._container = new Container()
154
+ app.stage.addChild(this._container)
155
+ app.stage.eventMode = 'static'
156
+ app.stage.hitArea = app.screen
157
+ app.stage.on('globalpointermove', (event: FederatedPointerEvent) => {
158
+ this._onActivePointerMove(event)
159
+ })
160
+ app.stage.on('pointerup', () => {
161
+ this._onActivePointerUp()
162
+ })
163
+ app.stage.on('pointerupoutside', () => {
164
+ this._onActivePointerUp()
165
+ })
166
+ app.stage.on('pointercancel', () => {
167
+ this._onActivePointerUp()
168
+ })
169
+ this._layoutKeys()
170
+
171
+ this._resizeObserver = new ResizeObserver(() => {
172
+ const nextWidth = host.clientWidth
173
+ const nextHeight = host.clientHeight
174
+ if (nextWidth > 0 && nextHeight > 0) {
175
+ app.renderer.resize(nextWidth, nextHeight)
176
+ this._layoutKeys()
177
+ }
178
+ })
179
+ this._resizeObserver.observe(host)
180
+ }
181
+
182
+ private _layoutKeys() {
183
+ const app = this._app
184
+ const container = this._container
185
+ if (!app || !container) return
186
+
187
+ container.removeChildren()
188
+ this._keys = []
189
+
190
+ const rows = this.getRows()
191
+ const width = app.screen.width
192
+ const height = app.screen.height
193
+ const rowCount = rows.length
194
+ const rowHeight = (height - KEY_PADDING * (rowCount + 1)) / rowCount
195
+
196
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
197
+ const row = rows[rowIndex] ?? []
198
+ const totalUnits = row.reduce((sum, def) => sum + (def.w ?? 1), 0)
199
+ const keyUnitWidth = (width - KEY_PADDING * (row.length + 1)) / totalUnits
200
+ let x = KEY_PADDING
201
+
202
+ for (let colIndex = 0; colIndex < row.length; colIndex += 1) {
203
+ const def = row[colIndex]!
204
+ const keyWidth = keyUnitWidth * (def.w ?? 1)
205
+ const y = KEY_PADDING + rowIndex * (rowHeight + KEY_PADDING)
206
+
207
+ const keyContainer = new Container()
208
+ keyContainer.x = x
209
+ keyContainer.y = y
210
+ keyContainer.eventMode = 'static'
211
+ keyContainer.cursor = 'pointer'
212
+
213
+ const gfx = new Graphics()
214
+ keyContainer.addChild(gfx)
215
+
216
+ const primaryText = new Text({
217
+ text: '',
218
+ style: new TextStyle({
219
+ fontFamily: 'ui-monospace, SFMono-Regular, monospace',
220
+ fontSize: 13,
221
+ fill: this._theme.text,
222
+ align: 'center',
223
+ }),
224
+ })
225
+ primaryText.anchor.set(0.5)
226
+ keyContainer.addChild(primaryText)
227
+
228
+ let secondaryText: Text | null = null
229
+ if (
230
+ this._shouldShowDualLabels(keyWidth, rowHeight) &&
231
+ !def.modifier &&
232
+ Boolean(def.shift)
233
+ ) {
234
+ primaryText.style = new TextStyle({
235
+ fontFamily: 'ui-monospace, SFMono-Regular, monospace',
236
+ fontSize: 10,
237
+ fill: this._theme.text,
238
+ align: 'center',
239
+ })
240
+ primaryText.x = keyWidth / 2
241
+ primaryText.y = rowHeight * 0.35
242
+
243
+ secondaryText = new Text({
244
+ text: '',
245
+ style: new TextStyle({
246
+ fontFamily: 'ui-monospace, SFMono-Regular, monospace',
247
+ fontSize: 10,
248
+ fill: this._theme.textMuted,
249
+ align: 'center',
250
+ }),
251
+ })
252
+ secondaryText.anchor.set(0.5)
253
+ secondaryText.x = keyWidth / 2
254
+ secondaryText.y = rowHeight * 0.74
255
+ keyContainer.addChild(secondaryText)
256
+ } else {
257
+ primaryText.x = keyWidth / 2
258
+ primaryText.y = rowHeight / 2
259
+ }
260
+
261
+ container.addChild(keyContainer)
262
+
263
+ const rendered: RenderedKey = {
264
+ container: keyContainer,
265
+ gfx,
266
+ primaryText,
267
+ secondaryText,
268
+ def,
269
+ row: rowIndex,
270
+ col: colIndex,
271
+ width: keyWidth,
272
+ height: rowHeight,
273
+ }
274
+
275
+ this._updateKeyVisual(rendered, {
276
+ pressed: false,
277
+ forceShift: false,
278
+ })
279
+
280
+ keyContainer.on('pointerdown', (e: FederatedPointerEvent) => {
281
+ this._onKeyDown(rendered, e)
282
+ })
283
+ keyContainer.on('pointermove', (e: FederatedPointerEvent) => {
284
+ this._onKeyMove(rendered, e)
285
+ })
286
+ keyContainer.on('pointerup', () => this._onKeyUp(rendered))
287
+ keyContainer.on('pointerupoutside', () => this._onKeyUp(rendered))
288
+ keyContainer.on('pointerleave', () => this._onKeyLeave(rendered))
289
+
290
+ this._keys.push(rendered)
291
+ x += keyWidth + KEY_PADDING
292
+ }
293
+ }
294
+ }
295
+
296
+ private _drawKey(
297
+ gfx: Graphics,
298
+ width: number,
299
+ height: number,
300
+ options: {
301
+ pressed: boolean
302
+ modifier: boolean
303
+ modifierActive: boolean
304
+ forceShift: boolean
305
+ }
306
+ ) {
307
+ const { pressed, modifier, modifierActive, forceShift } = options
308
+ const bg = pressed
309
+ ? this._theme.keyPressed
310
+ : modifier
311
+ ? modifierActive
312
+ ? this._theme.keyPressed
313
+ : this._theme.keyModifier
314
+ : this._theme.keyNormal
315
+
316
+ gfx.clear()
317
+ gfx.roundRect(0, 0, width, height, KEY_RADIUS)
318
+ gfx.fill({ color: bg })
319
+ gfx.stroke({
320
+ color: forceShift ? this._theme.accent : this._theme.surfaceBorder,
321
+ width: forceShift ? 1.5 : 1,
322
+ })
323
+ }
324
+
325
+ private _measureTextWidth(text: Text): number {
326
+ const content = typeof text.text === 'string' ? text.text : String(text.text ?? '')
327
+ if (!content) return 0
328
+ try {
329
+ return CanvasTextMetrics.measureText(content, text.style).width
330
+ } catch {
331
+ return text.width / Math.max(text.scale.x, 0.0001)
332
+ }
333
+ }
334
+
335
+ private _fitText(text: Text, maxWidth: number, minScale = 0.45) {
336
+ if (maxWidth <= 0) return
337
+ text.scale.set(1)
338
+ const width = this._measureTextWidth(text)
339
+ if (width <= maxWidth) {
340
+ return
341
+ }
342
+ const scale = Math.max(minScale, maxWidth / width)
343
+ text.scale.set(scale)
344
+ }
345
+
346
+ private _isLetterKey(def: KeyDef): boolean {
347
+ return def.data.length === 1 && /[a-z]/i.test(def.data)
348
+ }
349
+
350
+ private _shouldShowDualLabels(keyWidth: number, keyHeight: number): boolean {
351
+ if (keyHeight < DUAL_LABEL_MIN_SIZE) {
352
+ return false
353
+ }
354
+ return keyHeight / keyWidth >= DUAL_LABEL_MIN_RATIO
355
+ }
356
+
357
+ private _isShifted(def: KeyDef, forceShift: boolean): boolean {
358
+ const baseShift = this._modifiers.shift || forceShift
359
+ if (this._isLetterKey(def) && this._modifiers.caps) {
360
+ return !baseShift
361
+ }
362
+ return baseShift
363
+ }
364
+
365
+ private _resolveDisplay(def: KeyDef, forceShift: boolean): KeyDisplay {
366
+ if (this._isLetterKey(def)) {
367
+ const upper = def.shift?.label ?? def.label.toUpperCase()
368
+ const lower = def.label.toLowerCase()
369
+ const topActive = this._isShifted(def, forceShift)
370
+ return {
371
+ single: topActive ? upper : lower,
372
+ top: upper,
373
+ bottom: lower,
374
+ topActive,
375
+ }
376
+ }
377
+
378
+ if (def.shift) {
379
+ const topActive = this._isShifted(def, forceShift)
380
+ return {
381
+ single: topActive ? def.shift.label : def.label,
382
+ top: def.shift.label,
383
+ bottom: def.label,
384
+ topActive,
385
+ }
386
+ }
387
+
388
+ return {
389
+ single: def.label,
390
+ topActive: false,
391
+ }
392
+ }
393
+
394
+ private _updateKeyVisual(
395
+ rendered: RenderedKey,
396
+ options: {
397
+ pressed: boolean
398
+ forceShift: boolean
399
+ }
400
+ ) {
401
+ const { def, primaryText, secondaryText, gfx, width, height } = rendered
402
+ const modifier = Boolean(def.modifier || def.special === 'chord')
403
+ const modifierActive = def.modifier
404
+ ? this._modifiers[def.modifier]
405
+ : def.special === 'chord'
406
+ ? this._stickyModifierMode
407
+ : false
408
+ const display = this._resolveDisplay(def, options.forceShift)
409
+
410
+ this._drawKey(gfx, width, height, {
411
+ pressed: options.pressed,
412
+ modifier,
413
+ modifierActive,
414
+ forceShift: options.forceShift,
415
+ })
416
+
417
+ if (secondaryText && display.top && display.bottom) {
418
+ primaryText.text = display.top
419
+ secondaryText.text = display.bottom
420
+ this._fitText(primaryText, width - 8)
421
+ this._fitText(secondaryText, width - 8)
422
+
423
+ if (options.pressed) {
424
+ primaryText.style.fill = this._theme.accentFg
425
+ secondaryText.style.fill = this._theme.accentFg
426
+ } else if (display.topActive) {
427
+ primaryText.style.fill = this._theme.text
428
+ secondaryText.style.fill = this._theme.textMuted
429
+ } else {
430
+ primaryText.style.fill = this._theme.textMuted
431
+ secondaryText.style.fill = this._theme.text
432
+ }
433
+ return
434
+ }
435
+
436
+ primaryText.text = display.single
437
+ this._fitText(primaryText, width - 8)
438
+ if (options.pressed) {
439
+ primaryText.style.fill = this._theme.accentFg
440
+ return
441
+ }
442
+
443
+ if (modifierActive) {
444
+ primaryText.style.fill = this._theme.accent
445
+ return
446
+ }
447
+
448
+ primaryText.style.fill = this._theme.text
449
+ }
450
+
451
+ private _canSwipeShift(def: KeyDef): boolean {
452
+ return !def.modifier && (Boolean(def.shift) || this._isLetterKey(def))
453
+ }
454
+
455
+ private _onKeyDown(rendered: RenderedKey, event: FederatedPointerEvent) {
456
+ const { def } = rendered
457
+
458
+ if (def.special === 'chord') {
459
+ this._stickyModifierMode = !this._stickyModifierMode
460
+ if (!this._stickyModifierMode) {
461
+ this._resetTransientModifiers()
462
+ }
463
+ this._vibrate(10)
464
+ this._layoutKeys()
465
+ return
466
+ }
467
+
468
+ if (def.modifier) {
469
+ this._modifiers[def.modifier] = !this._modifiers[def.modifier]
470
+ this._vibrate(10)
471
+ this._layoutKeys()
472
+ return
473
+ }
474
+
475
+ this._activeKey = rendered
476
+ this._setPointerCapture(event.pointerId)
477
+ this._activeStartY = event.global?.y ?? 0
478
+ this._activeSwipeShift = false
479
+
480
+ this._updateKeyVisual(rendered, { pressed: true, forceShift: false })
481
+ this._vibrate(5)
482
+
483
+ this._cancelRepeat()
484
+ this._repeatTimer = setTimeout(() => {
485
+ this._repeatTimer = null
486
+ this._repeatInterval = setInterval(() => {
487
+ this._sendKey(def, this._activeSwipeShift)
488
+ this._vibrate(3)
489
+ }, REPEAT_INTERVAL_MS)
490
+ }, REPEAT_DELAY_MS)
491
+ }
492
+
493
+ private _onActivePointerMove(event: FederatedPointerEvent) {
494
+ const activeKey = this._activeKey
495
+ if (!activeKey) return
496
+ this._onKeyMove(activeKey, event)
497
+ }
498
+
499
+ private _onActivePointerUp() {
500
+ const activeKey = this._activeKey
501
+ if (!activeKey) return
502
+ this._onKeyUp(activeKey)
503
+ }
504
+
505
+ private _onKeyMove(rendered: RenderedKey, event: FederatedPointerEvent) {
506
+ if (this._activeKey !== rendered) return
507
+ if (!this._canSwipeShift(rendered.def)) return
508
+
509
+ const pointerY = event.global?.y ?? this._activeStartY
510
+ const nextSwipeShift = this._activeStartY - pointerY >= SWIPE_SHIFT_THRESHOLD
511
+ if (nextSwipeShift === this._activeSwipeShift) return
512
+
513
+ this._activeSwipeShift = nextSwipeShift
514
+ this._updateKeyVisual(rendered, {
515
+ pressed: true,
516
+ forceShift: this._activeSwipeShift,
517
+ })
518
+ }
519
+
520
+ private _onKeyLeave(rendered: RenderedKey) {
521
+ if (this._activeKey !== rendered) return
522
+ }
523
+
524
+ private _resetTransientModifiers() {
525
+ if (this._stickyModifierMode) {
526
+ return
527
+ }
528
+ this._modifiers.ctrl = false
529
+ this._modifiers.alt = false
530
+ this._modifiers.meta = false
531
+ this._modifiers.shift = false
532
+ }
533
+
534
+ private _onKeyUp(rendered: RenderedKey) {
535
+ this._cancelRepeat()
536
+
537
+ if (rendered.def.modifier) return
538
+ if (this._activeKey !== rendered) return
539
+
540
+ const forceShift = this._activeSwipeShift
541
+ this._activeKey = null
542
+ this._activeSwipeShift = false
543
+
544
+ this._updateKeyVisual(rendered, {
545
+ pressed: false,
546
+ forceShift: false,
547
+ })
548
+
549
+ this._sendKey(rendered.def, forceShift)
550
+ this._resetTransientModifiers()
551
+ this._layoutKeys()
552
+ this._releasePointerCapture()
553
+ }
554
+
555
+ private _resolveOutputData(def: KeyDef, forceShift: boolean): string {
556
+ if (this._isLetterKey(def)) {
557
+ const upper = def.shift?.data ?? def.data.toUpperCase()
558
+ const lower = def.data.toLowerCase()
559
+ return this._isShifted(def, forceShift) ? upper : lower
560
+ }
561
+
562
+ if (this._isShifted(def, forceShift) && def.shift) {
563
+ return def.shift.data
564
+ }
565
+
566
+ return def.data
567
+ }
568
+
569
+ private _sendKey(def: KeyDef, forceShift: boolean) {
570
+ let data = this._resolveOutputData(def, forceShift)
571
+ if (!data) return
572
+
573
+ if (this._modifiers.ctrl && data.length === 1) {
574
+ const code = data.toUpperCase().charCodeAt(0) - 64
575
+ if (code > 0 && code < 32) {
576
+ data = String.fromCharCode(code)
577
+ }
578
+ }
579
+
580
+ if (this._modifiers.alt || this._modifiers.meta) {
581
+ data = `\x1b${data}`
582
+ }
583
+
584
+ this.dispatchEvent(
585
+ new CustomEvent('input-panel:send', {
586
+ detail: { data },
587
+ bubbles: true,
588
+ composed: true,
589
+ })
590
+ )
591
+ }
592
+
593
+ private _cancelRepeat() {
594
+ if (this._repeatTimer) {
595
+ clearTimeout(this._repeatTimer)
596
+ this._repeatTimer = null
597
+ }
598
+ if (this._repeatInterval) {
599
+ clearInterval(this._repeatInterval)
600
+ this._repeatInterval = null
601
+ }
602
+ }
603
+
604
+ private _setPointerCapture(pointerId: number) {
605
+ this._activePointerId = pointerId
606
+ const canvas = this._app?.canvas as HTMLCanvasElement | undefined
607
+ if (!canvas?.setPointerCapture) return
608
+ try {
609
+ canvas.setPointerCapture(pointerId)
610
+ } catch {
611
+ // ignore pointer capture failures
612
+ }
613
+ }
614
+
615
+ private _releasePointerCapture() {
616
+ const pointerId = this._activePointerId
617
+ this._activePointerId = null
618
+ if (pointerId == null) return
619
+ const canvas = this._app?.canvas as HTMLCanvasElement | undefined
620
+ if (!canvas?.releasePointerCapture) return
621
+ try {
622
+ if (!canvas.hasPointerCapture?.(pointerId)) return
623
+ canvas.releasePointerCapture(pointerId)
624
+ } catch {
625
+ // ignore pointer capture failures
626
+ }
627
+ }
628
+
629
+ private _vibrate(ms: number) {
630
+ try {
631
+ navigator.vibrate?.(ms)
632
+ } catch {
633
+ // ignore
634
+ }
635
+ }
636
+
637
+ render() {
638
+ return html`<div class="pixi-host" style="width:100%;height:100%"></div>`
639
+ }
640
+ }
641
+
642
+ customElements.define('virtual-keyboard-tab', VirtualKeyboardTab)