app.mx raw

   1  // SPDX-License-Identifier: Unlicense OR MIT
   2  
   3  // Gio WASM window implementation.
   4  // Callback model: no goroutines. rAF drives the paint loop.
   5  // Events coalesce into the window state; user callbacks are fired during paint.
   6  
   7  package gio
   8  
   9  import (
  10  	"image"
  11  	"image/color"
  12  
  13  	"jsbridge/app"
  14  )
  15  
  16  // Window is the application window. Only one window is supported per WASM module.
  17  type Window struct {
  18  	glCtx GLContext
  19  	g     GPU // nil until GPU init completes
  20  
  21  	scale float32
  22  	size  image.Point // physical pixels
  23  
  24  	animating  bool
  25  	rafPending bool
  26  	focused    bool
  27  	ctxLost    bool // WebGL context currently lost
  28  
  29  	onFrame   func(FrameEvent)
  30  	onDestroy func()
  31  
  32  	ptrEvents  []AppPointerEvent
  33  	keyEvents  []AppKeyEvent
  34  	textEvents []AppTextEvent
  35  	pasteRunes []int32 // pending clipboard paste codepoints
  36  }
  37  
  38  // FrameEvent is delivered to the OnFrame callback.
  39  type FrameEvent struct {
  40  	Size   image.Point
  41  	Metric Metric
  42  	w      *Window
  43  }
  44  
  45  // Frame submits the op list for rendering. Call exactly once per FrameEvent.
  46  func (e FrameEvent) Frame(ops *OpList) {
  47  	e.w.renderFrame(ops)
  48  }
  49  
  50  // DestroyEvent signals window closure.
  51  type DestroyEvent struct{}
  52  
  53  // AppTextEvent holds a single Unicode codepoint from keyboard or IME input.
  54  type AppTextEvent struct {
  55  	Codepoint int32
  56  }
  57  
  58  // AppPasteEvent holds a single Unicode codepoint from a clipboard paste operation.
  59  type AppPasteEvent struct {
  60  	Codepoint int32
  61  }
  62  
  63  // AppFocusEvent signals window focus gain or loss.
  64  type AppFocusEvent struct {
  65  	Focused bool
  66  }
  67  
  68  // AppPointerEvent holds a coalesced pointer/mouse/touch input event.
  69  type AppPointerEvent struct {
  70  	Kind    int32  // 0=Move 1=Press 2=Release 3=Scroll 4=Cancel
  71  	X, Y    float32
  72  	Dx, Dy  float32
  73  	Buttons int32
  74  	Mods    int32
  75  }
  76  
  77  // AppKeyEvent holds a coalesced keyboard input event.
  78  type AppKeyEvent struct {
  79  	Code  int32 // Unicode codepoint or special code >= 256
  80  	Mods  int32
  81  	Press bool
  82  }
  83  
  84  // windowState holds the active window singleton (one per WASM module).
  85  // Single package-level struct instance - the only mutable global.
  86  type windowState struct {
  87  	w *Window
  88  }
  89  
  90  // wst is the window singleton. Accessed by wasmexport callbacks.
  91  var wst windowState
  92  
  93  // NewWindow creates and shows the application window.
  94  func NewWindow() *Window {
  95  	app.CreateFullscreenCanvas()
  96  
  97  	glCtxHandle := app.CreateWebGLContext()
  98  	if glCtxHandle == 0 {
  99  		panic("gio: failed to create WebGL2 context")
 100  	}
 101  	glCtx := GLContext(glCtxHandle)
 102  
 103  	w := &Window{glCtx: glCtx}
 104  	wst.w = w
 105  
 106  	w.doResize()
 107  
 108  	d, err := newOpenGLDevice(OpenGL{Context: glCtx})
 109  	if err != nil {
 110  		panic("gio: OpenGL device init failed")
 111  	}
 112  	g, err := NewGPUWithDevice(d)
 113  	if err != nil {
 114  		panic("gio: GPU init failed")
 115  	}
 116  	w.g = g
 117  
 118  	app.RegisterPointerEvents()
 119  	app.RegisterKeyEvents()
 120  	app.RegisterResizeEvents()
 121  	app.RegisterTextEvents()
 122  	app.RegisterContextLossEvents()
 123  
 124  	w.scheduleRAF()
 125  	return w
 126  }
 127  
 128  // FocusTextArea requests keyboard focus so text input is received.
 129  func (w *Window) FocusTextArea() { app.FocusTextArea() }
 130  
 131  // BlurTextArea releases keyboard focus.
 132  func (w *Window) BlurTextArea() { app.BlurTextArea() }
 133  
 134  // Focused reports whether the window textarea currently has focus.
 135  func (w *Window) Focused() bool { return w.focused }
 136  
 137  // WriteClipboard writes text to the system clipboard.
 138  func (w *Window) WriteClipboard(text string) { app.WriteClipboard(text) }
 139  
 140  // ReadClipboard requests an async clipboard read.
 141  // Paste codepoints will arrive via PasteRunes() on the next frame.
 142  func (w *Window) ReadClipboard() { app.ReadClipboard() }
 143  
 144  // OnFrame sets the frame callback. Called once per rAF with a FrameEvent.
 145  func (w *Window) OnFrame(fn func(FrameEvent)) { w.onFrame = fn }
 146  
 147  // OnDestroy sets the destroy callback.
 148  func (w *Window) OnDestroy(fn func()) { w.onDestroy = fn }
 149  
 150  // Invalidate requests a frame on the next animation frame.
 151  func (w *Window) Invalidate() {
 152  	if !w.rafPending {
 153  		w.scheduleRAF()
 154  	}
 155  }
 156  
 157  // SetAnimating enables continuous animation at display refresh rate.
 158  func (w *Window) SetAnimating(anim bool) {
 159  	w.animating = anim
 160  	if anim && !w.rafPending {
 161  		w.scheduleRAF()
 162  	}
 163  }
 164  
 165  // SetTitle sets the browser tab title.
 166  func (w *Window) SetTitle(title string) { app.SetTitle(title) }
 167  
 168  // SetCursor sets the CSS cursor style.
 169  func (w *Window) SetCursor(name string) { app.SetCursor(name) }
 170  
 171  // SetFullscreen enters or exits fullscreen mode.
 172  func (w *Window) SetFullscreen(full bool) { app.SetFullscreen(full) }
 173  
 174  func (w *Window) scheduleRAF() {
 175  	w.rafPending = true
 176  	app.RequestRAF()
 177  }
 178  
 179  func (w *Window) doResize() {
 180  	w.scale = app.GetDevicePixelRatio()
 181  	var cssW, cssH int32
 182  	app.GetCanvasCSSSize(&cssW, &cssH)
 183  	physW := int32(float32(cssW) * w.scale)
 184  	physH := int32(float32(cssH) * w.scale)
 185  	if physW < 1 {
 186  		physW = 1
 187  	}
 188  	if physH < 1 {
 189  		physH = 1
 190  	}
 191  	app.SetCanvasBacking(physW, physH)
 192  	w.size = image.Point{X: int(physW), Y: int(physH)}
 193  }
 194  
 195  func (w *Window) paint() {
 196  	w.rafPending = false
 197  	if w.size.X <= 0 || w.size.Y <= 0 || w.g == nil {
 198  		return
 199  	}
 200  	if w.onFrame != nil {
 201  		w.onFrame(FrameEvent{
 202  			Size:   w.size,
 203  			Metric: Metric{PxPerDp: w.scale, PxPerSp: w.scale},
 204  			w:      w,
 205  		})
 206  	}
 207  	if w.animating {
 208  		w.scheduleRAF()
 209  	}
 210  }
 211  
 212  func (w *Window) reinitGPU() {
 213  	d, err := newOpenGLDevice(OpenGL{Context: w.glCtx})
 214  	if err != nil {
 215  		return
 216  	}
 217  	g, err := NewGPUWithDevice(d)
 218  	if err != nil {
 219  		return
 220  	}
 221  	w.g = g
 222  	w.ctxLost = false
 223  }
 224  
 225  func (w *Window) renderFrame(ops *OpList) {
 226  	if w.g == nil {
 227  		return
 228  	}
 229  	w.g.Clear(color.NRGBA{A: 0})
 230  	var nilTarget RenderTarget // nil = render to default canvas FBO
 231  	if err := w.g.Frame(ops, nilTarget, w.size); err != nil {
 232  		return
 233  	}
 234  }
 235  
 236  // PointerEvents returns and clears pending pointer events.
 237  func (w *Window) PointerEvents() []AppPointerEvent {
 238  	evts := w.ptrEvents
 239  	w.ptrEvents = w.ptrEvents[:0]
 240  	return evts
 241  }
 242  
 243  // KeyEvents returns and clears pending key events.
 244  func (w *Window) KeyEvents() []AppKeyEvent {
 245  	evts := w.keyEvents
 246  	w.keyEvents = w.keyEvents[:0]
 247  	return evts
 248  }
 249  
 250  // TextEvents returns and clears pending text input events (keyboard + IME).
 251  func (w *Window) TextEvents() []AppTextEvent {
 252  	evts := w.textEvents
 253  	w.textEvents = w.textEvents[:0]
 254  	return evts
 255  }
 256  
 257  // PasteRunes returns and clears pending clipboard paste codepoints.
 258  func (w *Window) PasteRunes() []int32 {
 259  	r := w.pasteRunes
 260  	w.pasteRunes = w.pasteRunes[:0]
 261  	return r
 262  }
 263  
 264  // Window pointer event kind constants (raw int32 values from JS).
 265  // Distinct from event.mx's PointerKind bitmask type.
 266  const (
 267  	WinPointerMove    int32 = 0
 268  	WinPointerPress   int32 = 1
 269  	WinPointerRelease int32 = 2
 270  	WinPointerScroll  int32 = 3
 271  	WinPointerCancel  int32 = 4
 272  )
 273  
 274  // Key code constants (must match app.mjs).
 275  const (
 276  	KeyArrowUp    int32 = 256
 277  	KeyArrowDown  int32 = 257
 278  	KeyArrowLeft  int32 = 258
 279  	KeyArrowRight int32 = 259
 280  	KeyEscape     int32 = 260
 281  	KeyEnter      int32 = 261
 282  	KeyBackspace  int32 = 262
 283  	KeyDelete     int32 = 263
 284  	KeyHome       int32 = 264
 285  	KeyEnd        int32 = 265
 286  	KeyPageUp     int32 = 266
 287  	KeyPageDown   int32 = 267
 288  	KeyTab        int32 = 268
 289  	KeySpace      int32 = 269
 290  	KeyF1         int32 = 270
 291  	KeyF12        int32 = 281
 292  	KeyCtrl       int32 = 282
 293  	KeyShift      int32 = 283
 294  	KeyAlt        int32 = 284
 295  	KeySuper      int32 = 285
 296  	KeyCommand    int32 = 286
 297  )
 298  
 299  // Modifier bit constants.
 300  const (
 301  	ModAltBit   int32 = 1
 302  	ModCtrlBit  int32 = 2
 303  	ModShiftBit int32 = 4
 304  	ModMetaBit  int32 = 8
 305  )
 306  
 307  // CSS cursor strings (pass directly to SetCursor).
 308  // These are kept as package-level constants for convenience; the actual
 309  // cursor type enum lives in event.mx as PointerCursor.
 310  const (
 311  	CSSCursorDefault   = "default"
 312  	CSSCursorPointer   = "pointer"
 313  	CSSCursorText      = "text"
 314  	CSSCursorCrosshair = "crosshair"
 315  	CSSCursorGrab      = "grab"
 316  	CSSCursorGrabbing  = "grabbing"
 317  	CSSCursorNone      = "none"
 318  	CSSCursorWait      = "wait"
 319  	CSSCursorNSResize  = "ns-resize"
 320  	CSSCursorEWResize  = "ew-resize"
 321  )
 322  
 323  // --- JS export hooks ---
 324  
 325  //go:wasmexport __gio_raf
 326  func gioRAF() {
 327  	if wst.w != nil {
 328  		wst.w.paint()
 329  	}
 330  }
 331  
 332  //go:wasmexport __gio_resize
 333  func gioResize() {
 334  	if wst.w != nil {
 335  		wst.w.doResize()
 336  		wst.w.Invalidate()
 337  	}
 338  }
 339  
 340  //go:wasmexport __gio_pointer
 341  func gioPointer(kind int32, x, y, dx, dy float32, buttons, mods int32) {
 342  	if wst.w == nil {
 343  		return
 344  	}
 345  	wst.w.ptrEvents = append(wst.w.ptrEvents, AppPointerEvent{
 346  		Kind: kind, X: x, Y: y, Dx: dx, Dy: dy,
 347  		Buttons: buttons, Mods: mods,
 348  	})
 349  	wst.w.Invalidate()
 350  }
 351  
 352  //go:wasmexport __gio_key
 353  func gioKey(code, mods, press int32) {
 354  	if wst.w == nil {
 355  		return
 356  	}
 357  	wst.w.keyEvents = append(wst.w.keyEvents, AppKeyEvent{
 358  		Code: code, Mods: mods, Press: press != 0,
 359  	})
 360  	wst.w.Invalidate()
 361  }
 362  
 363  //go:wasmexport __gio_text
 364  func gioText(codepoint int32) {
 365  	if wst.w == nil {
 366  		return
 367  	}
 368  	wst.w.textEvents = append(wst.w.textEvents, AppTextEvent{Codepoint: codepoint})
 369  	wst.w.Invalidate()
 370  }
 371  
 372  //go:wasmexport __gio_paste
 373  func gioPaste(codepoint int32) {
 374  	if wst.w == nil {
 375  		return
 376  	}
 377  	wst.w.pasteRunes = append(wst.w.pasteRunes, codepoint)
 378  	wst.w.Invalidate()
 379  }
 380  
 381  //go:wasmexport __gio_focus
 382  func gioFocus(focused int32) {
 383  	if wst.w == nil {
 384  		return
 385  	}
 386  	wst.w.focused = focused != 0
 387  	wst.w.Invalidate()
 388  }
 389  
 390  //go:wasmexport __gio_context_loss
 391  func gioContextLoss(lost int32) {
 392  	if wst.w == nil {
 393  		return
 394  	}
 395  	if lost != 0 {
 396  		wst.w.g = nil
 397  		wst.w.ctxLost = true
 398  	} else {
 399  		wst.w.reinitGPU()
 400  		wst.w.Invalidate()
 401  	}
 402  }
 403