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,556 @@
1
+ import { LitElement, css, html } from 'lit'
2
+ import { Application, Container, Graphics, Text, TextStyle } from 'pixi.js'
3
+ import { onThemeChange, resolvePixiTheme, type PixiTheme } from './pixi-theme.js'
4
+
5
+ // Gesture thresholds
6
+ const DRAG_THRESHOLD = 8
7
+ const LONG_PRESS_MS = 500
8
+ const DOUBLE_TAP_MS = 300
9
+ const SCROLL_STEP = 20
10
+
11
+ // Edge zone infinite sliding
12
+ const EDGE_TICK_MS = 16 // ~60fps emission rate during infinite slide
13
+ const EDGE_MIN_SPEED = 1 // px/tick at zone boundary (depth=0)
14
+ const EDGE_MAX_SPEED = 12 // px/tick at actual edge (depth=1)
15
+
16
+ // Edge zone adaptive sizing: clamp(minPx, pct * min(w,h), maxPx)
17
+ const EDGE_MIN_PX = 20
18
+ const EDGE_PCT = 0.15
19
+ const EDGE_MAX_PX = 60
20
+
21
+ // Base glow alpha when dragging (before edge proximity boosts it)
22
+ const GLOW_BASE = 0.15
23
+
24
+ /**
25
+ * Virtual trackpad — pure gesture input device.
26
+ *
27
+ * Gesture model mirrors a real laptop trackpad:
28
+ *
29
+ * Single-finger gestures:
30
+ * - Slide → `trackpad:move` { dx, dy } Move the cursor (no button pressed)
31
+ * - Tap (touch + release, no movement) → `trackpad:tap` Left click
32
+ * - Tap, then tap-and-hold, then slide → drag gesture:
33
+ * `trackpad:drag-start` → `trackpad:drag-move` { dx, dy } → `trackpad:drag-end`
34
+ * This is mousedown + mousemove(buttons=1) + mouseup — used for text selection.
35
+ * - Double-tap (two quick taps) → `trackpad:double-tap` Double-click (select word)
36
+ * - Long press (>500ms) → `trackpad:long-press` Right-click
37
+ *
38
+ * Two-finger gestures:
39
+ * - Vertical slide → `trackpad:scroll` { deltaY } Scroll
40
+ * - Tap (two fingers touch + release, little movement) → `trackpad:two-finger-tap` Right-click
41
+ */
42
+ export class VirtualTrackpadTab extends LitElement {
43
+ static get properties() {
44
+ return {
45
+ floating: { type: Boolean },
46
+ }
47
+ }
48
+
49
+ static styles = css`
50
+ :host {
51
+ display: block;
52
+ width: 100%;
53
+ height: 100%;
54
+ touch-action: none;
55
+ }
56
+
57
+ .pixi-host {
58
+ position: relative;
59
+ width: 100%;
60
+ height: 100%;
61
+ }
62
+
63
+ /* Single overlay — box-shadow: inset provides a natural inner-shadow halo */
64
+ .edge-glow {
65
+ position: absolute;
66
+ inset: 8px;
67
+ border-radius: 8px;
68
+ pointer-events: none;
69
+ z-index: 1;
70
+ }
71
+ `
72
+
73
+ declare floating: boolean
74
+
75
+ constructor() {
76
+ super()
77
+ this.floating = false
78
+ }
79
+
80
+ private _app: Application | null = null
81
+ private _container: Container | null = null
82
+ private _feedbackGfx: Graphics | null = null
83
+ private _resizeObserver: ResizeObserver | null = null
84
+ private _theme: PixiTheme = resolvePixiTheme(this)
85
+ private _unsubTheme: (() => void) | null = null
86
+
87
+ // Gesture state
88
+ private _touchStart: { x: number; y: number; time: number } | null = null
89
+ private _isDragging = false
90
+ private _longPressTimer: ReturnType<typeof setTimeout> | null = null
91
+ private _lastTapTime = 0
92
+ private _lastDragPos = { x: 0, y: 0 }
93
+
94
+ // Second-touch detection (tap-then-drag / double-tap)
95
+ private _isSecondTouch = false
96
+
97
+ // Two-finger state
98
+ private _twoFingerStart: { y: number; time: number } | null = null
99
+ private _twoFingerMoved = false
100
+
101
+ // Edge glow overlay (single element, driven by box-shadow)
102
+ private _edgeOverlay: HTMLElement | null = null
103
+ private _accentRgb = '224,74,47'
104
+ private _edgeSlideInterval: ReturnType<typeof setInterval> | null = null
105
+
106
+ async connectedCallback() {
107
+ super.connectedCallback()
108
+ this._theme = resolvePixiTheme(this)
109
+ this._syncAccentRgb()
110
+ this._unsubTheme = onThemeChange((theme) => {
111
+ this._theme = theme
112
+ this._syncAccentRgb()
113
+ this._drawSurface()
114
+ }, this)
115
+ await this.updateComplete
116
+ await this._initPixi()
117
+ }
118
+
119
+ disconnectedCallback() {
120
+ super.disconnectedCallback()
121
+ if (this._longPressTimer) clearTimeout(this._longPressTimer)
122
+ this._stopEdgeSlide()
123
+ this._resizeObserver?.disconnect()
124
+ this._resizeObserver = null
125
+ this._unsubTheme?.()
126
+ this._unsubTheme = null
127
+ this._app?.destroy()
128
+ this._app = null
129
+ this._container = null
130
+ }
131
+
132
+ /** Cache accent color as "r,g,b" string for box-shadow rgba(). */
133
+ private _syncAccentRgb() {
134
+ const hex = this._theme.accent
135
+ const r = (hex >> 16) & 0xff
136
+ const g = (hex >> 8) & 0xff
137
+ const b = hex & 0xff
138
+ this._accentRgb = `${r},${g},${b}`
139
+ }
140
+
141
+ private async _initPixi() {
142
+ const host = this.shadowRoot?.querySelector('.pixi-host') as HTMLElement
143
+ if (!host) return
144
+
145
+ this._edgeOverlay = this.shadowRoot?.querySelector('.edge-glow') as HTMLElement
146
+
147
+ const app = new Application()
148
+ await app.init({
149
+ background: this._theme.background,
150
+ backgroundAlpha: this.floating ? 0 : 1,
151
+ antialias: true,
152
+ resolution: window.devicePixelRatio || 1,
153
+ autoDensity: false,
154
+ })
155
+
156
+ host.appendChild(app.canvas as HTMLCanvasElement)
157
+ this._app = app
158
+
159
+ // CSS sizing: canvas fills host
160
+ const canvas = app.canvas as HTMLCanvasElement
161
+ canvas.style.width = '100%'
162
+ canvas.style.height = '100%'
163
+ canvas.style.display = 'block'
164
+ canvas.addEventListener('contextmenu', (e) => e.preventDefault())
165
+
166
+ // Initial size
167
+ const w = host.clientWidth
168
+ const h = host.clientHeight
169
+ if (w > 0 && h > 0) {
170
+ app.renderer.resize(w, h)
171
+ }
172
+
173
+ this._container = new Container()
174
+ app.stage.addChild(this._container)
175
+
176
+ this._drawSurface()
177
+
178
+ canvas.addEventListener('touchstart', (e) => this._onTouchStart(e), { passive: false })
179
+ canvas.addEventListener('touchmove', (e) => this._onTouchMove(e), { passive: false })
180
+ canvas.addEventListener('touchend', (e) => this._onTouchEnd(e), { passive: false })
181
+ canvas.addEventListener('touchcancel', () => this._onTouchCancel(), { passive: false })
182
+
183
+ canvas.addEventListener('pointerdown', (e) => {
184
+ if (e.pointerType === 'touch') return
185
+ this._handlePointerStart(e.offsetX, e.offsetY)
186
+ })
187
+ canvas.addEventListener('pointermove', (e) => {
188
+ if (e.pointerType === 'touch') return
189
+ this._handlePointerMove(e.offsetX, e.offsetY)
190
+ })
191
+ canvas.addEventListener('pointerup', (e) => {
192
+ if (e.pointerType === 'touch') return
193
+ this._handlePointerEnd()
194
+ })
195
+
196
+ this._resizeObserver = new ResizeObserver(() => {
197
+ const w = host.clientWidth
198
+ const h = host.clientHeight
199
+ if (w > 0 && h > 0) {
200
+ app.renderer.resize(w, h)
201
+ this._drawSurface()
202
+ }
203
+ })
204
+ this._resizeObserver.observe(host)
205
+ }
206
+
207
+ /** Compute uniform edge zone depth: clamp(EDGE_MIN_PX, EDGE_PCT * min(w,h), EDGE_MAX_PX). */
208
+ private _getEdgeZoneSize(): number {
209
+ const app = this._app
210
+ if (!app) return 40
211
+ const surfaceW = app.screen.width - 16
212
+ const surfaceH = app.screen.height - 16
213
+ return Math.max(EDGE_MIN_PX, Math.min(EDGE_MAX_PX, Math.min(surfaceW, surfaceH) * EDGE_PCT))
214
+ }
215
+
216
+ private _drawSurface() {
217
+ const app = this._app
218
+ const container = this._container
219
+ if (!app || !container) return
220
+
221
+ container.removeChildren()
222
+ this._feedbackGfx = null
223
+
224
+ const theme = this._theme
225
+ const w = app.screen.width
226
+ const h = app.screen.height
227
+
228
+ // Background surface
229
+ const surface = new Graphics()
230
+ surface.roundRect(8, 8, w - 16, h - 16, 8)
231
+ surface.fill({ color: theme.surface })
232
+ surface.stroke({ color: theme.surfaceBorder, width: 1 })
233
+ container.addChild(surface)
234
+
235
+ // Center hint label
236
+ const hintText = 'Drag: move | Tap: click | 2-finger: scroll'
237
+
238
+ const style = new TextStyle({
239
+ fontFamily: 'ui-monospace, SFMono-Regular, monospace',
240
+ fontSize: 11,
241
+ fill: theme.hintText,
242
+ align: 'center',
243
+ })
244
+ const label = new Text({ text: hintText, style })
245
+ label.anchor.set(0.5)
246
+ label.x = w / 2
247
+ label.y = h / 2
248
+ container.addChild(label)
249
+
250
+ // Feedback circle (hidden initially)
251
+ const fb = new Graphics()
252
+ fb.circle(0, 0, 20)
253
+ fb.fill({ color: theme.feedbackColor, alpha: 0 })
254
+ this._feedbackGfx = fb
255
+ container.addChild(fb)
256
+ }
257
+
258
+ // --- Touch handlers ---
259
+
260
+ private _onTouchStart(e: TouchEvent) {
261
+ e.preventDefault()
262
+ if (e.touches.length === 2) {
263
+ const y = (e.touches[0].clientY + e.touches[1].clientY) / 2
264
+ this._twoFingerStart = { y, time: Date.now() }
265
+ this._twoFingerMoved = false
266
+ this._cancelLongPress()
267
+ // Cancel any single-finger gesture in progress
268
+ this._touchStart = null
269
+ this._isDragging = false
270
+ return
271
+ }
272
+ if (e.touches.length === 1) {
273
+ const t = e.touches[0]
274
+ const rect = (e.target as HTMLElement).getBoundingClientRect()
275
+ this._handlePointerStart(t.clientX - rect.left, t.clientY - rect.top)
276
+ }
277
+ }
278
+
279
+ private _onTouchMove(e: TouchEvent) {
280
+ e.preventDefault()
281
+ if (e.touches.length === 2 && this._twoFingerStart) {
282
+ const y = (e.touches[0].clientY + e.touches[1].clientY) / 2
283
+ const dy = y - this._twoFingerStart.y
284
+
285
+ if (Math.abs(dy) > SCROLL_STEP) {
286
+ this._twoFingerMoved = true
287
+ this._dispatch('trackpad:scroll', { deltaY: dy })
288
+ this._twoFingerStart = { y, time: this._twoFingerStart.time }
289
+ this._vibrate(5)
290
+ }
291
+ return
292
+ }
293
+ if (e.touches.length === 1) {
294
+ const t = e.touches[0]
295
+ const rect = (e.target as HTMLElement).getBoundingClientRect()
296
+ this._handlePointerMove(t.clientX - rect.left, t.clientY - rect.top)
297
+ }
298
+ }
299
+
300
+ private _onTouchEnd(e: TouchEvent) {
301
+ e.preventDefault()
302
+ if (this._twoFingerStart && e.touches.length < 2) {
303
+ // Two-finger tap: both fingers touched and lifted with little/no vertical movement
304
+ if (!this._twoFingerMoved) {
305
+ this._dispatch('trackpad:two-finger-tap', { button: 'right' })
306
+ this._vibrate(15)
307
+ }
308
+ this._twoFingerStart = null
309
+ this._twoFingerMoved = false
310
+ return
311
+ }
312
+ if (e.touches.length === 0) {
313
+ this._handlePointerEnd()
314
+ }
315
+ }
316
+
317
+ private _onTouchCancel() {
318
+ this._touchStart = null
319
+ this._isDragging = false
320
+ this._isSecondTouch = false
321
+ this._twoFingerStart = null
322
+ this._twoFingerMoved = false
323
+ this._cancelLongPress()
324
+ this._stopEdgeSlide()
325
+ this._hideGlow()
326
+ }
327
+
328
+ // --- Unified pointer logic ---
329
+
330
+ private _handlePointerStart(x: number, y: number) {
331
+ const now = Date.now()
332
+ this._touchStart = { x, y, time: now }
333
+ this._lastDragPos = { x, y }
334
+ this._isDragging = false
335
+
336
+ // Is this the second touch of a double-tap sequence?
337
+ this._isSecondTouch = now - this._lastTapTime < DOUBLE_TAP_MS
338
+
339
+ this._showFeedback(x, y)
340
+
341
+ // Long press → right-click (only on fresh touches, not second-touch)
342
+ if (!this._isSecondTouch) {
343
+ this._longPressTimer = setTimeout(() => {
344
+ this._longPressTimer = null
345
+ this._dispatch('trackpad:long-press', { button: 'right' })
346
+ this._vibrate(30)
347
+ this._touchStart = null
348
+ }, LONG_PRESS_MS)
349
+ }
350
+ }
351
+
352
+ private _handlePointerMove(x: number, y: number) {
353
+ if (!this._touchStart) return
354
+
355
+ const dx = x - this._touchStart.x
356
+ const dy = y - this._touchStart.y
357
+
358
+ if (!this._isDragging && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
359
+ this._isDragging = true
360
+ this._lastDragPos = { x, y }
361
+ this._cancelLongPress()
362
+
363
+ // Show uniform base inner-shadow glow immediately
364
+ this._applyGlow({ left: 0, right: 0, top: 0, bottom: 0 })
365
+
366
+ // Second touch starts dragging → click-drag (for text selection)
367
+ if (this._isSecondTouch) {
368
+ this._dispatch('trackpad:drag-start', {})
369
+ }
370
+ }
371
+
372
+ if (this._isDragging) {
373
+ const moveDx = x - this._lastDragPos.x
374
+ const moveDy = y - this._lastDragPos.y
375
+ this._lastDragPos = { x, y }
376
+
377
+ if (this._isSecondTouch) {
378
+ // Drag with "button held" — for text selection
379
+ this._dispatch('trackpad:drag-move', { dx: moveDx, dy: moveDy })
380
+ } else {
381
+ // Normal cursor movement
382
+ this._dispatch('trackpad:move', { dx: moveDx, dy: moveDy })
383
+ }
384
+
385
+ this._showFeedback(x, y)
386
+
387
+ // Edge zone: update glow and start/stop infinite slide
388
+ const depths = this._getEdgeDepths(x, y)
389
+ this._applyGlow(depths)
390
+ const inEdge = depths.left > 0 || depths.right > 0 || depths.top > 0 || depths.bottom > 0
391
+ if (inEdge) {
392
+ this._startEdgeSlide()
393
+ } else {
394
+ this._stopEdgeSlide()
395
+ }
396
+ }
397
+ }
398
+
399
+ private _handlePointerEnd() {
400
+ this._cancelLongPress()
401
+ this._hideFeedback()
402
+ this._stopEdgeSlide()
403
+ this._hideGlow()
404
+
405
+ if (!this._touchStart) return
406
+ const now = Date.now()
407
+
408
+ if (this._isDragging) {
409
+ if (this._isSecondTouch) {
410
+ // End of click-drag
411
+ this._dispatch('trackpad:drag-end', {})
412
+ }
413
+ // End of normal cursor move — no event needed
414
+ } else {
415
+ // No drag happened — it was a tap
416
+ if (this._isSecondTouch) {
417
+ // Quick second tap → double-tap
418
+ this._dispatch('trackpad:double-tap', { button: 'left' })
419
+ this._vibrate(15)
420
+ this._lastTapTime = 0
421
+ } else {
422
+ // Single tap
423
+ this._dispatch('trackpad:tap', { button: 'left' })
424
+ this._vibrate(5)
425
+ this._lastTapTime = now
426
+ }
427
+ }
428
+
429
+ this._touchStart = null
430
+ this._isDragging = false
431
+ this._isSecondTouch = false
432
+ }
433
+
434
+ // --- Edge zone helpers ---
435
+
436
+ private _getEdgeDepths(
437
+ x: number,
438
+ y: number
439
+ ): { left: number; right: number; top: number; bottom: number } {
440
+ const app = this._app
441
+ if (!app) return { left: 0, right: 0, top: 0, bottom: 0 }
442
+
443
+ const w = app.screen.width
444
+ const h = app.screen.height
445
+ const pad = 8 // surface padding
446
+ const edge = this._getEdgeZoneSize()
447
+
448
+ const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
449
+
450
+ return {
451
+ left: clamp01((edge - (x - pad)) / edge),
452
+ right: clamp01((edge - (w - pad - x)) / edge),
453
+ top: clamp01((edge - (y - pad)) / edge),
454
+ bottom: clamp01((edge - (h - pad - y)) / edge),
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Set the inner-shadow glow via 4 directional inset box-shadows on a single overlay.
460
+ * Each edge gets: base alpha + depth * (max - base).
461
+ * box-shadow composites naturally at corners — no hard seams.
462
+ */
463
+ private _applyGlow(depths: { left: number; right: number; top: number; bottom: number }) {
464
+ if (!this._edgeOverlay) return
465
+ const edge = Math.round(this._getEdgeZoneSize())
466
+ const blur = Math.round(edge * 0.8)
467
+ const spread = Math.round(edge * 0.4)
468
+ const rgb = this._accentRgb
469
+ const a = (depth: number) => (GLOW_BASE + depth * (0.55 - GLOW_BASE)).toFixed(2)
470
+
471
+ this._edgeOverlay.style.boxShadow = [
472
+ `inset ${edge}px 0 ${blur}px -${spread}px rgba(${rgb},${a(depths.left)})`,
473
+ `inset -${edge}px 0 ${blur}px -${spread}px rgba(${rgb},${a(depths.right)})`,
474
+ `inset 0 ${edge}px ${blur}px -${spread}px rgba(${rgb},${a(depths.top)})`,
475
+ `inset 0 -${edge}px ${blur}px -${spread}px rgba(${rgb},${a(depths.bottom)})`,
476
+ ].join(',')
477
+ }
478
+
479
+ /** Remove all glow. */
480
+ private _hideGlow() {
481
+ if (!this._edgeOverlay) return
482
+ this._edgeOverlay.style.boxShadow = 'none'
483
+ }
484
+
485
+ private _startEdgeSlide() {
486
+ if (this._edgeSlideInterval) return // already running
487
+ this._edgeSlideInterval = setInterval(() => {
488
+ this._emitEdgeSlide()
489
+ }, EDGE_TICK_MS)
490
+ }
491
+
492
+ private _stopEdgeSlide() {
493
+ if (this._edgeSlideInterval) {
494
+ clearInterval(this._edgeSlideInterval)
495
+ this._edgeSlideInterval = null
496
+ }
497
+ }
498
+
499
+ private _emitEdgeSlide() {
500
+ const { x, y } = this._lastDragPos
501
+ const depths = this._getEdgeDepths(x, y)
502
+
503
+ // Per-axis speed: proportional to depth into edge zone
504
+ const speed = (depth: number) => EDGE_MIN_SPEED + depth * (EDGE_MAX_SPEED - EDGE_MIN_SPEED)
505
+ const dx = depths.right * speed(depths.right) - depths.left * speed(depths.left)
506
+ const dy = depths.bottom * speed(depths.bottom) - depths.top * speed(depths.top)
507
+
508
+ if (dx === 0 && dy === 0) return // not in any edge zone
509
+
510
+ const eventName = this._isSecondTouch ? 'trackpad:drag-move' : 'trackpad:move'
511
+ this._dispatch(eventName, { dx, dy })
512
+ }
513
+
514
+ // --- General helpers ---
515
+
516
+ private _dispatch(name: string, detail: Record<string, unknown>) {
517
+ this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }))
518
+ }
519
+
520
+ private _cancelLongPress() {
521
+ if (this._longPressTimer) {
522
+ clearTimeout(this._longPressTimer)
523
+ this._longPressTimer = null
524
+ }
525
+ }
526
+
527
+ private _showFeedback(x: number, y: number) {
528
+ if (this._feedbackGfx) {
529
+ this._feedbackGfx.x = x
530
+ this._feedbackGfx.y = y
531
+ this._feedbackGfx.alpha = 0.4
532
+ }
533
+ }
534
+
535
+ private _hideFeedback() {
536
+ if (this._feedbackGfx) this._feedbackGfx.alpha = 0
537
+ }
538
+
539
+ private _vibrate(ms: number) {
540
+ try {
541
+ navigator?.vibrate?.(ms)
542
+ } catch {
543
+ // ignore
544
+ }
545
+ }
546
+
547
+ render() {
548
+ return html`
549
+ <div class="pixi-host">
550
+ <div class="edge-glow"></div>
551
+ </div>
552
+ `
553
+ }
554
+ }
555
+
556
+ customElements.define('virtual-trackpad-tab', VirtualTrackpadTab)