Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,16 @@ applies that value, while a refresh with the same declaration preserves the
current native/local edit, selection, focus, and (on native controls) input
method composition. Use `set_text` for an explicit imperative replacement.

The custom renderer builds on demand and reuses its tree while idle. See
[render scheduling](docs/render-scheduling.md) for invalidation, worker delivery,
window lifecycle and the acceptance fixture.

## Widget animations

Animations target a mounted element by `id` and are applied after each
declarative build, so they work with both V-built and VML-built trees. The
native backends schedule redraws for the duration; the custom renderer already
builds every frame.
native and custom backends schedule frames while the animation is active. The
custom renderer returns to idle when the animation completes.

```v
move := ui2.animation(
Expand Down
36 changes: 36 additions & 0 deletions docs/render-scheduling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Render scheduling

The custom renderer builds its initial element tree and then updates it when
application work invalidates the window. A static window reuses its retained tree.
Input, model actions, resize, worker deliveries and animations request the work
they need. Requests raised during a frame are kept for the next frame.

Call `ui2.refresh()` or `ui2.request_refresh()` after changing model data outside
an input or animation callback. Control setters such as `set_text` request a paint
and preserve other controls' current editing state.

Capture `ui2.ui_dispatcher()` on the UI thread and pass the handle to workers.
Its `post(fn () { ... })` method queues model work on the UI thread and requests a
frame. The handle belongs to that window; after close, `post` returns `false`.

`ui2.render_stats()` reports callbacks, builds, draws, flushes and pending work.
On Metal, idle callbacks skip both builds and draws. GL, EGL and D3D present after
each platform callback, so idle callbacks repaint the retained tree. Native
backends use their platform refresh mechanisms.

The scheduler suspends painting while the window is iconified or suspended,
continues to deliver queued business work, and refreshes the surface on restore.
Close cancels queued work and visual timers.

## Acceptance fixture

Build and run the custom renderer acceptance fixture on macOS:

```sh
v -b c -cc clang -enable-globals -d ui2_custom_rendering run tests/render_scheduler --seconds 3
```

The fixture checks static reuse, worker delivery, request coalescing, a request
issued during a build, animation completion and return to idle. Add `--lifecycle`
to exercise real window minimize and restore on macOS, or `--interactive` to
inspect editing, scrolling and tooltips with counter output.
13 changes: 13 additions & 0 deletions tests/render_scheduler/lifecycle_darwin.c.v
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module main

import sokol.sapp

#include "@VMODROOT/tests/render_scheduler/lifecycle_darwin.h"

fn C.ui2_scheduler_minimize_restore(window voidptr, delay_ms i64) bool

// Call from a dispatcher callback on the UI thread, after the window mounts.
// The native main queue restores the real window even while Sokol is paused.
fn schedule_real_window_lifecycle(delay_ms int) bool {
return C.ui2_scheduler_minimize_restore(sapp.macos_get_window(), i64(delay_ms))
}
34 changes: 34 additions & 0 deletions tests/render_scheduler/lifecycle_darwin.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#ifndef UI2_SCHEDULER_LIFECYCLE_DARWIN_H
#define UI2_SCHEDULER_LIFECYCLE_DARWIN_H

#include <dispatch/dispatch.h>
#include <objc/message.h>
#include <objc/runtime.h>
#include <pthread.h>
#include <stdbool.h>
#include <stdint.h>

// Keep these calls in plain C so the fixture works with both ARC and non-ARC
// generated sources. The opaque pointer has one explicit retain across the
// main-queue timer; no Objective-C ownership conversion is implicit here.
static void ui2_scheduler_restore_window(void *window) {
((void (*)(void *, SEL, void *))objc_msgSend)(
window, sel_registerName("deminiaturize:"), NULL);
((void (*)(void *, SEL, void *))objc_msgSend)(
window, sel_registerName("makeKeyAndOrderFront:"), NULL);
((void (*)(void *, SEL))objc_msgSend)(window, sel_registerName("release"));
}

static bool ui2_scheduler_minimize_restore(void *window, int64_t delay_ms) {
if (window == NULL || delay_ms <= 0 || !pthread_main_np()) {
return false;
}
((void *(*)(void *, SEL))objc_msgSend)(window, sel_registerName("retain"));
((void (*)(void *, SEL, void *))objc_msgSend)(
window, sel_registerName("miniaturize:"), NULL);
dispatch_after_f(dispatch_time(DISPATCH_TIME_NOW, delay_ms * NSEC_PER_MSEC),
dispatch_get_main_queue(), window, ui2_scheduler_restore_window);
return true;
}

#endif
8 changes: 8 additions & 0 deletions tests/render_scheduler/lifecycle_other.c.v
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module main

$if !macos {
// Real lifecycle automation currently uses the macOS public window handle.
fn schedule_real_window_lifecycle(_delay_ms int) bool {
return false
}
}
244 changes: 244 additions & 0 deletions tests/render_scheduler/main.v
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
@[has_globals]
module main

import os
import time
import ui2

__global g_acceptance_started = false
__global g_acceptance_sample_seconds = 30
__global g_acceptance_interactive = false
__global g_acceptance_message = 'Static scene — waiting for worker'
__global g_acceptance_worker_builds = 0
__global g_acceptance_verify_reentrant = false
__global g_acceptance_dispatcher = ui2.UiDispatcher{}
__global g_acceptance_completion_built = false
__global g_acceptance_lifecycle = false

fn main() {
for i, arg in os.args {
match arg {
'--interactive' { g_acceptance_interactive = true }
'--lifecycle' { g_acceptance_lifecycle = true }
'--seconds' {
if i + 1 >= os.args.len || os.args[i + 1].int() <= 0 {
panic('--seconds needs a positive integer')
}
g_acceptance_sample_seconds = os.args[i + 1].int()
}
else {}
}
}
ui2.run_window('UI2 renderer scheduler acceptance', 640, 480, build, event)
}

fn build() ui2.Element {
if !g_acceptance_started {
g_acceptance_started = true
g_acceptance_dispatcher = ui2.ui_dispatcher()
if !g_acceptance_interactive {
spawn acceptance(g_acceptance_dispatcher, g_acceptance_sample_seconds,
g_acceptance_lifecycle)
} else {
spawn observe(g_acceptance_dispatcher)
}
}
if g_acceptance_message == 'Animation completed' {
g_acceptance_completion_built = true
}
if g_acceptance_verify_reentrant {
g_acceptance_worker_builds++
if g_acceptance_worker_builds == 1 {
// A request issued while the current generation is being built must
// survive its acknowledgement and produce one subsequent build.
ui2.request_refresh()
}
}
style := ui2.TextStyle{ size: 14, color: 0x243247 }
box := ui2.BoxStyle{ bg: 0xe2e8f0, radius: 6 }
mut rows := []ui2.Element{}
for i in 0 .. 30 {
rows << ui2.label('row-${i}', 'Scroll row ${i + 1}', ui2.rect(8, i * 26, 240, 24), style)
}
return ui2.screen(0xf8fafc, [
ui2.label('heading', 'Renderer scheduler acceptance', ui2.rect(24, 16, 590, 28),
ui2.TextStyle{ size: 20, color: 0x0f172a }),
ui2.with_tooltip(ui2.label('status', g_acceptance_message, ui2.rect(24, 52, 590, 28), style),
'Tooltip appears after 500 ms with a stationary pointer.'),
ui2.text_field('editor', 'Type, select, then refresh', '', ui2.rect(24, 96, 340, 34), box, style, 0),
ui2.button('refresh', 'Refresh', ui2.rect(380, 96, 100, 34), box, style),
ui2.button('worker', 'Worker', ui2.rect(496, 96, 112, 34), box, style),
ui2.button('animate', 'Animate', ui2.rect(24, 148, 112, 34), box, style),
ui2.view('moving', ui2.rect(160, 148, 36, 34), ui2.BoxStyle{ bg: 0x2563eb, radius: 6 }, []ui2.Element{}),
ui2.scroll('rows', ui2.rect(24, 208, 300, 232), 0xe2e8f0, rows),
ui2.label('instructions', 'Hover status for tooltip.\nEdit, select, scroll, resize.\nMinimize and restore.\nContent must stay intact.', ui2.rect(348, 220, 268, 140), ui2.TextStyle{ ...style, lines: 4 }),
])
}

fn event(id string) {
match id {
'animate' { start_animation() }
'worker' { spawn delayed_update(ui2.ui_dispatcher()) }
'refresh' { ui2.refresh() }
else {}
}
}

fn start_animation() {
ui2.clear_animation('moving')
ui2.animation(
duration: 0.8
x: 280.0
on_event: fn (event ui2.AnimationEvent) {
if event.kind == .complete {
// Event callbacks can mutate the model without their own refresh.
g_acceptance_message = 'Animation completed'
}
}
).start('moving')
}

fn observe(dispatcher ui2.UiDispatcher) {
mut before := dispatcher.stats()
for !before.closed {
time.sleep(time.second)
after := dispatcher.stats()
report('interactive suspended=${after.suspended}', before, after)
before = after
}
}

fn delayed_update(dispatcher ui2.UiDispatcher) {
time.sleep(750 * time.millisecond)
assert dispatcher.post(fn () {
g_acceptance_message = 'Worker delivered on UI thread'
})
}

fn report(phase string, before ui2.RenderStats, after ui2.RenderStats) {
println('${phase}: callbacks=${after.callbacks - before.callbacks} builds=${after.builds - before.builds} draws=${after.draws - before.draws} flushes=${after.flushes - before.flushes} requests=${after.requests - before.requests} coalesced=${after.coalesced - before.coalesced}')
}

fn acceptance(dispatcher ui2.UiDispatcher, seconds int, lifecycle bool) {
// Observe counters directly from the worker: posting an observer callback
// would itself invalidate the scene and contaminate the static sample.
time.sleep(2 * time.second)
before := dispatcher.stats()
println('sample: scheduling=on-demand seconds=${seconds} warmup=2 presentation_required=${before.presentation_required}')
time.sleep(seconds * time.second)
after := dispatcher.stats()
report('static', before, after)
assert after.callbacks > before.callbacks
assert after.builds == before.builds
if !before.presentation_required {
assert after.draws == before.draws
}

assert dispatcher.post(fn () {
g_acceptance_message = 'Worker delivered on UI thread'
g_acceptance_verify_reentrant = true
for _ in 0 .. 20 {
ui2.request_refresh()
}
})
time.sleep(500 * time.millisecond)
worker_after := dispatcher.stats()
report('worker + burst + reentrant build', after, worker_after)
assert worker_after.builds - after.builds == 2
if !after.presentation_required {
assert worker_after.draws - after.draws == 2
}

assert dispatcher.post(fn () {
assert g_acceptance_worker_builds >= 2
assert g_acceptance_message == 'Worker delivered on UI thread'
g_acceptance_verify_reentrant = false
start_animation()
})
// Starting is queued on the UI thread. A busy host may deliver that work
// after a fixed sleep has already elapsed; wait for completion and its
// trailing model update before sampling the return to idle.
mut settled := dispatcher.stats()
for _ in 0 .. 200 {
if ui2.animation_info('moving').status == .completed && !settled.animation_active
&& !settled.pending {
break
}
time.sleep(20 * time.millisecond)
settled = dispatcher.stats()
}
assert ui2.animation_info('moving').status == .completed
assert !settled.animation_active && !settled.pending
report('animation', worker_after, settled)
assert settled.draws > worker_after.draws
time.sleep(time.second)
idle := dispatcher.stats()
report('idle after animation', settled, idle)
assert idle.builds == settled.builds
if !settled.presentation_required {
assert idle.draws == settled.draws
}

if lifecycle {
$if macos {
check_lifecycle(dispatcher)
} $else {
println('SKIP: native lifecycle helper currently supports macOS only')
}
}
assert dispatcher.post(fn [dispatcher] () {
assert ui2.animation_info('moving').status == .completed
assert g_acceptance_completion_built
println('PASS: static, worker, coalescing, reentrant request, animation, return to idle')
ui2.quit()
assert dispatcher.stats().closed
assert !dispatcher.post(fn () {
assert false, 'closed window must reject a late worker callback'
})
println('PASS: closed dispatcher rejected late worker callback')
})
}

fn check_lifecycle(dispatcher ui2.UiDispatcher) {
assert dispatcher.post(fn () {
assert schedule_real_window_lifecycle(2000)
})
mut suspended := dispatcher.stats()
for _ in 0 .. 100 {
if suspended.suspended {
break
}
time.sleep(20 * time.millisecond)
suspended = dispatcher.stats()
}
assert suspended.suspended, 'native window must become iconified'
time.sleep(500 * time.millisecond)
midpoint := dispatcher.stats()
report('real window iconified', suspended, midpoint)
assert midpoint.suspended
assert midpoint.builds == suspended.builds
assert midpoint.draws == suspended.draws
mut restored := midpoint
for _ in 0 .. 200 {
time.sleep(20 * time.millisecond)
restored = dispatcher.stats()
if !restored.suspended && restored.draws > midpoint.draws {
break
}
}
report('real window restored', midpoint, restored)
assert !restored.suspended, 'native window must restore before timeout'
assert restored.builds > midpoint.builds
assert restored.draws > midpoint.draws
time.sleep(500 * time.millisecond)
settled := dispatcher.stats()
time.sleep(500 * time.millisecond)
idle := dispatcher.stats()
report('idle after restore', settled, idle)
assert idle.builds == settled.builds
if !settled.presentation_required {
assert idle.draws == settled.draws
}

println('PASS: real window minimize, suspended draw suppression, restore, return to idle')
}
Loading