Skip to content

Latest commit

 

History

History
410 lines (321 loc) · 11.8 KB

File metadata and controls

410 lines (321 loc) · 11.8 KB
icon lucide/cpu

Runtime, Hooks, And Events

Animated GIF showing Glyph runtime updates, input events, focus, and render callbacks.

Tip

See it in action: examples/basic is a full app with state, input, focus, and the update/render loop.

Glyph has one shared runtime exposed through ui.update, ui.render, input forwarding functions, hooks, and helper APIs.

Rendering

Manual wiring:

function love.update(dt)
  ui.update(dt)
end

function love.draw()
  ui.render(App)
end

Automatic wiring:

ui.load({
  window = { width = 900, height = 600, resizable = true },
  app = App,
})

ui.update(dt) advances Glyph's runtime clock, visual animations, animated meter values, style transitions, and ui.feedback sequences, including Feel-backed spring steps. Raw ui.spring(...) values are intentionally app-driven; call spring:update(dt) from your own update loop when you use one outside a feedback sequence.

ui.render(App) always draws the mounted tree. It rebuilds and lays out that tree only after invalidation or a viewport-size change; otherwise it reuses the completed geometry and skips layout-callback traversal. State setters and Glyph input helpers invalidate automatically. If app-owned state outside hooks changes layout or component props, call ui.runtime:markDirty() after the mutation.

Hooks

ui.useState(initial) stores a hook slot by call order in the current render scope (the root app, a scene layer, or an offscreen surface):

local count, setCount = ui.useState(0)

ui.useEffect(fn, deps) runs effects when dependencies change:

ui.useEffect(function()
  print("mounted or changed")
  return function()
    print("cleanup")
  end
end, { id })

An effect cleanup runs before that effect is replaced after a dependency change, and exactly once when its owning hook scope is discarded. Scene layers keep their effects through exit animation; cleanup runs after the transition completes and after onClose. Immediate stack replacement with ui.scene.set, or replacement of a duplicate layer ID with ui.scene.push, disposes the replaced scopes during that call. An offscreen surface disposes its root hook scope during surface:destroy().

Call hooks unconditionally and in the same order on every render. A node's key does not create a hook scope or make a useState / useEffect slot follow that node; keyed hook-state reconciliation is not part of v0.1.

Stable Node Keys

Node identity is path-based. Unkeyed siblings use their position in the parent; a stable key replaces that positional path segment. Use keys for dynamic siblings so path-based runtime state such as focus, an input's cursor, style transitions, and enter/exit animation stays attached when siblings reorder:

local children = {}

for _, row in ipairs(rows) do
  children[#children + 1] = ui.input({
    key = row.id,
    value = row.name,
    onChange = row.rename,
  })
end

return ui.column({}, children)

The key stabilizes each input node's runtime path. It does not affect the order of any hooks called while building the rows.

Input Forwarding

Manual input forwarding:

function love.mousemoved(x, y, dx, dy)
  ui.mousemoved(x, y, dx, dy)
end

function love.mousepressed(x, y, button)
  ui.mousepressed(x, y, button)
end

function love.mousereleased(x, y, button)
  ui.mousereleased(x, y, button)
end

function love.wheelmoved(dx, dy)
  ui.wheelmoved(dx, dy)
end

function love.textinput(text)
  ui.textinput(text)
end

function love.keypressed(key)
  ui.keypressed(key)
end

function love.keyreleased(key)
  ui.keyreleased(key)
end

function love.gamepadpressed(joystick, button)
  ui.gamepadpressed(joystick, button)
end

function love.gamepadreleased(joystick, button)
  ui.gamepadreleased(joystick, button)
end

ui.install and ui.load can install common callbacks automatically. Gamepad callbacks are installed only when install.gamepad is enabled.

Warning

Give each Love callback one owner. If an existing love.keypressed or love.keyreleased callback already calls the matching ui.* forwarder, disable Glyph's automatic copy with install = { keypressed = false, keyreleased = false }. Otherwise one physical key is delivered twice; this is especially visible in text inputs.

Note

If a fixed viewport backend is active, Glyph converts mouse and touch screen coordinates into virtual viewport coordinates before hover, focus, click, and scroll routing. Pointer events outside the virtual viewport do not hit UI.

Buttons and focusable nodes with role = "button" plus onClick use the same press lifecycle for pointer and keyboard activation: mouse/touch down and Return/Space down enter the pressed state, and release activates the node when focus is still on the same node. This keeps pressed styles, feedback, audio cues, and accessibility activation events consistent across mouse, keyboard, and gamepad mappings that forward to ui.keypressed / ui.keyreleased.

Before keyboard, mapped gamepad, or text input is delivered, Glyph validates the focused node against the active scene stack. Controls below a blocking layer and nodes removed from a rebuilt or closed layer cannot receive input.

Focus suspension dispatches focusChanged(nil, previousNode) without a focus audio cue. Restoring a still-reachable control is a normal focus acquisition: it dispatches focusChanged(restoredNode, nil) and emits the control's resolved focus audio, feedback, and accessibility events.

Touch callbacks are wired automatically by ui.install / ui.load. Gamepad mapping is opt-in:

ui.load({
  app = App,
  install = {
    gamepad = true,
  },
})

Runtime Callbacks

Use ui.on(name, fn, opts) to subscribe to runtime callbacks.

Supported names:

  • beforeUpdate
  • afterUpdate
  • beforeRender
  • afterRender
  • layout
  • audio
  • accessibility
  • feedback
  • focusChanged
  • hoverChanged
  • event

Unregister with the returned closure:

local off = ui.on("event", function(kind, ...)
  print(kind)
end)

off()

Node Layout Callbacks

Use onBounds and onLayout when app code needs node geometry for drag/drop, tooltips, popovers, minimap markers, overlays, or contextual menus.

onBounds(bounds, node) receives the node’s local parent-relative layout:

ui.box({
  width = 64,
  height = 64,
  onBounds = function(bounds, node)
    print(bounds.x, bounds.y, bounds.width, bounds.height)
  end,
})

onLayout(bounds, node) receives viewport-space bounds in the same coordinate space as routed pointer input. It includes parent offsets, scene/modal layer offsets, and scrollView visual scroll offsets.

Scroll offsets can also be controlled by stable key: use ui.scrollTo(key, offset) for pixels, ui.scrollToItem(key, index, itemHeight) for fixed-height lists, and ui.getScrollOffset(key) to read the current value.

ui.button({
  label = "Drag",
  onLayout = function(bounds)
    dragTargets.primary = bounds
  end,
})

Both callbacks fire after layout publication and before drawing, only when the reported rectangle changes for that node path or when the callback function identity changes. Reported bounds are rectangular layout geometry; they do not include visual-only animation, feedback, shape, clip, stencil, or custom transition transforms.

Pointer Drag Helper

Use ui.drag when app code needs a captured pointer lifecycle without wiring a global ui.on("event") listener. Glyph owns the pointer start/move/drop/cancel callbacks; your app still owns target lookup, validation, swapping, placement, and previews.

local startDrag = ui.drag({
  onStart = function(ctx)
    dragging = ctx.data
  end,
  onMove = function(ctx)
    pointer = { x = ctx.x, y = ctx.y }
  end,
  onDrop = function(ctx)
    dropItem(ctx.data, ctx.x, ctx.y)
  end,
  onCancel = function(ctx)
    dragging = nil
  end,
})

ui.button({
  label = "Potion",
  onMousePressed = function(x, y, button, node)
    if button == 1 then
      startDrag(x, y, button, node, { itemId = "potion" })
    end
  end,
})

ctx includes x, y, startX, startY, previousX, previousY, dx, dy, totalDx, totalDy, button, sourceNode, sourcePath, targetNode, targetPath, data, runtime, reason, and cancel(reason).

Set minDistance to delay onStart until the pointer moves far enough. Releasing before the threshold calls onCancel with reason = "threshold" and preserves normal button activation. Once a drag has started, release calls onDrop and suppresses the source button’s normal onClick. Active drags cancel on Escape, viewport exit, focus loss, or when a new drag starts.

Offscreen Surfaces

Use ui.surface.new when a Glyph tree should render into its own canvas and runtime. Surfaces are useful for render-to-texture UI, minimap labels, and Menori world billboards.

local surface = ui.surface.new({
  width = 320,
  height = 180,
  component = function(surfaceUi)
    return surfaceUi.button({ label = "World Button" })
  end,
})

surface:update(dt)
surface:render()
surface:mousepressed(24, 32, 1)

The surfaceUi argument is scoped to the surface runtime, so hooks, focus, feedback, ui.drag, and pointer state do not leak into the main screen runtime. Surfaces render with Love canvas stencil support enabled by default, so clipped controls, meters, masks, and stencil-based custom draw can render safely offscreen. Pass canvasOptions = { stencil = false } only when the surface does not need stencil-backed UI drawing.

Audio Cue Events

Glyph emits audio callbacks when configured cues resolve for interaction events. It does not load or play sounds.

local sounds = {
  hover = love.audio.newSource("hover.wav", "static"),
}

ui.on("audio", function(event)
  local source = sounds[event.cue]
  if source then
    source:stop()
    source:play()
  end
end)

The event includes cue, kind, node, type, path, variant, styleType, and a best-effort label. Supported cue kinds are hover, press, activate, and focus.

Feedback Events

Glyph emits feedback callbacks from ui.feedback emit steps. These events are for app-owned effects such as particles, camera shake, haptics, splats, or custom shader systems.

ui.on("feedback", function(event)
  if event.kind == "particles" then
    spawnParticles(event.node, event.name)
  end
end)

Feedback events include kind, name, trigger, node, path, payload, and the original step. See Feedback for sequence definitions.

Accessibility Events

Glyph exposes Love2D-friendly accessibility semantics through metadata and runtime events. It does not speak text or create native OS controls; apps own TTS, platform bridges, logs, or Love.js DOM adapters.

ui.on("accessibility", function(event)
  print(event.kind, event.message)
end)

Focus changes, button activation, manual announcements, and live-region updates can emit events with kind, message, node, path, role, label, description, valueText, and live. See Accessibility for semantic props, snapshots, i18n keys, and adapter patterns.

Interaction Helpers

Helpers:

  • ui.isHovered(node)
  • ui.isPressed(node)
  • ui.isFocused(node)
  • ui.isActive(node)
  • ui.isHot(node)

These are useful inside custom draw callbacks.

Event Routing

  • Hit testing follows visual order.
  • Higher zIndex wins among siblings.
  • ui.portal is promoted above normal content in the current render root and hit-tested before local content. The lower-level form is position = "absolute" with zScope = "root".
  • Later stack children draw above earlier children and receive events first.
  • interactive = false lets decorative nodes pass events through.
  • Scene layers route input top-down.