PuppetBrowserWindowPanel.kt
src/jvmMain/kotlin/afield/media/browser/PuppetBrowserWindowPanel.kt · 180 lines
package afield.media.browser
import afield.graphics.vulkan.Renderer
import afield.graphics.render.Mesh
import afield.graphics.render.Module
import afield.graphics.render.ModulePointer
import afield.graphics.render.parameters
import afield.graphics.render.pointer
import afield.graphics.texture.TextureSampler
import afield.graphics.vulkan.util.updateForever
import afield.graphics.shader.fromViews
Shows a puppet browser page letterboxed inside a Field render window, with mouse hit-testing back into page coordinates.
data class PuppetBrowserWindowViewport
An aspect-correct rectangle in one Field afield.graphics.vulkan.util.Window's logical coordinates.
data class PuppetBrowserWindowViewport(
val x: Double,
val y: Double,
val width: Double,
val height: Double,
) {
fun hitTest(px: Double, py: Double): Pair<Double, Double>?
fun hitTest(px: Double, py: Double): Pair<Double, Double>? {
if (px < x || py < y || px >= x + width || py >= y + height) return null
return (px - x) / width to (py - y) / height
}
companion object
companion object {
fun fit
Largest centered contentWidth × contentHeight rectangle that fits the window.
fun fit(
windowWidth: Double,
windowHeight: Double,
contentWidth: Double,
contentHeight: Double,
): PuppetBrowserWindowViewport {
require(windowWidth > 0.0 && windowHeight > 0.0) { "window dimensions must be positive" }
require(contentWidth > 0.0 && contentHeight > 0.0) { "content dimensions must be positive" }
val windowAspect = windowWidth / windowHeight
val contentAspect = contentWidth / contentHeight
return if (windowAspect > contentAspect) {
val width = windowHeight * contentAspect
PuppetBrowserWindowViewport((windowWidth - width) * 0.5, 0.0, width, windowHeight)
} else {
val height = windowWidth / contentAspect
PuppetBrowserWindowViewport(0.0, (windowHeight - height) * 0.5, windowWidth, height)
}
}
}
}
class PuppetBrowserWindowPanel
Draws one PuppetBrowser into a native Field window and routes that window's real mouse, keyboard, text-input and wheel stream back into Chromium.
The browser is fitted without distortion. Pointer input outside the resulting letterbox is ignored; inside it, coordinates map exactly to browser UVs and then CSS pixels.
class PuppetBrowserWindowPanel(
val renderer: Renderer,
val browser: PuppetBrowser,
val name: String = "puppet-browser-window-panel",
) : AutoCloseable {
var viewport: PuppetBrowserWindowViewport
@Volatile
var viewport: PuppetBrowserWindowViewport = fittedViewport()
private set
private val sampler = TextureSampler(renderer, mipLevels = 1, anisotropyEnable = true)
val input
val input = PuppetBrowserWindowInput(browser, renderer.window) { x, y -> viewport.hitTest(x, y) }
private var pointer: ModulePointer? = null
private var mesh: Mesh? = null
private var lastWindowWidth = -1.0
private var lastWindowHeight = -1.0
fun build(takeFocus: Boolean = true)
body — 73 lines, from line 75
fun build(takeFocus: Boolean = true) {
check(pointer == null) { "$name is already built" }
val module = Module(parameters(renderer) {
this.name = this@PuppetBrowserWindowPanel.name
color = 1
depth = 1
depthTest = false
vertex = vertexShader
fragment = fragmentShader
textureResolver = { texture ->
if (texture.name == "browserTexture") {
fromViews(listOf(browser), texture.name, sampler)
} else null
}
})
val createdPointer = module.pointer()
val createdMesh = Mesh(renderer, createdPointer)
createdMesh.resize(4, 2)
createdPointer.current.meshes[name] = createdMesh
createdMesh.v[1]!!.mapAndWrite {
floatArrayOf(0f, 0f, 0f, 1f, 1f, 1f, 1f, 0f).forEach(::put)
rewind()
}
createdMesh.t!!.mapAndWrite_ints {
intArrayOf(0, 1, 2, 0, 2, 3).forEach(::put)
rewind()
}
updateViewport(createdMesh, force = true)
createdMesh.update["browser-window-viewport"] = updateForever {
updateViewport(createdMesh)
true
}
renderer.renderList.modules[name] = createdPointer
pointer = createdPointer
mesh = createdMesh
if (takeFocus) input.takeFocus()
}
private fun fittedViewport(): PuppetBrowserWindowViewport {
val size = renderer.window.size()
return PuppetBrowserWindowViewport.fit(
size.w,
size.h,
browser.spec.width.toDouble(),
browser.spec.height.toDouble(),
)
}
private fun updateViewport(mesh: Mesh, force: Boolean = false) {
val size = renderer.window.size()
if (!force && size.w == lastWindowWidth && size.h == lastWindowHeight) return
lastWindowWidth = size.w
lastWindowHeight = size.h
viewport = PuppetBrowserWindowViewport.fit(
size.w,
size.h,
browser.spec.width.toDouble(),
browser.spec.height.toDouble(),
)
val left = (viewport.x / size.w * 2.0 - 1.0).toFloat()
val right = ((viewport.x + viewport.width) / size.w * 2.0 - 1.0).toFloat()
val top = (1.0 - viewport.y / size.h * 2.0).toFloat()
val bottom = (1.0 - (viewport.y + viewport.height) / size.h * 2.0).toFloat()
mesh.v[0]!!.mapAndWrite {
floatArrayOf(
left, top, 0f,
left, bottom, 0f,
right, bottom, 0f,
right, top, 0f,
).forEach(::put)
rewind()
}
}
override fun close()
override fun close() {
renderer.renderList.modules.remove(name)
input.close()
sampler.cleanup()
pointer = null
mesh = null
}
companion object
companion object {
private val vertexShader = """
#version 460
layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec2 inUV;
layout(location = 0) out vec2 fragUV;
void main() {
fragUV = inUV;
gl_Position = vec4(inPosition, 1.0);
}
""".trimIndent()
private val fragmentShader = """
#version 460
layout(location = 0) in vec2 fragUV;
layout(location = 0) out vec4 outColor;
layout(set = 0, binding = 0) uniform sampler2D browserTexture;
void main() {
outColor = vec4(texture(browserTexture, fragUV).rgb, 1.0);
}
""".trimIndent()
}
}