From 18ecfbbf118bf1f07643ec530f932dbfb5d540b8 Mon Sep 17 00:00:00 2001 From: tomaszbk Date: Tue, 29 Sep 2026 19:37:38 -0300 Subject: [PATCH 1/3] feat: add demand-driven custom renderer scheduling Add an opt-in on_demand render policy for the custom renderer. Static windows skip builds (and, on Metal, draws) until input, animations, tooltips, refresh() or dispatcher work invalidate them. Continuous rendering stays the default. Co-Authored-By: Claude Opus 5.5 --- tests/render_scheduler/lifecycle_darwin.c.v | 13 + tests/render_scheduler/lifecycle_darwin.h | 34 ++ tests/render_scheduler/lifecycle_other.c.v | 8 + tests/render_scheduler/main.v | 242 +++++++++++++ ui/animation.v | 71 +++- ui/animation_test.v | 83 +++++ ui/frame_scheduler.v | 333 ++++++++++++++++++ ui/frame_scheduler_test.v | 302 ++++++++++++++++ ui/menu_darwin.v | 5 +- ui/menu_default.c.v | 9 +- ui/menu_windows.c.v | 1 + ui/ui_custom_test.v | 4 +- ui/ui_immediate.c.v | 259 +++++++++++--- ui/ui_scheduler_immediate_test.v | 372 ++++++++++++++++++++ ui/ui_text_selection_immediate.c.v | 3 +- ui/ui_tooltip_immediate_test.v | 11 +- ui/vml_compiled.v | 5 + ui/vml_imports.v | 5 + ui/vml_model.v | 5 + 19 files changed, 1691 insertions(+), 74 deletions(-) create mode 100644 tests/render_scheduler/lifecycle_darwin.c.v create mode 100644 tests/render_scheduler/lifecycle_darwin.h create mode 100644 tests/render_scheduler/lifecycle_other.c.v create mode 100644 tests/render_scheduler/main.v create mode 100644 ui/frame_scheduler.v create mode 100644 ui/frame_scheduler_test.v create mode 100644 ui/ui_scheduler_immediate_test.v diff --git a/tests/render_scheduler/lifecycle_darwin.c.v b/tests/render_scheduler/lifecycle_darwin.c.v new file mode 100644 index 0000000..7da1e08 --- /dev/null +++ b/tests/render_scheduler/lifecycle_darwin.c.v @@ -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)) +} diff --git a/tests/render_scheduler/lifecycle_darwin.h b/tests/render_scheduler/lifecycle_darwin.h new file mode 100644 index 0000000..009c0fc --- /dev/null +++ b/tests/render_scheduler/lifecycle_darwin.h @@ -0,0 +1,34 @@ +#ifndef UI2_SCHEDULER_LIFECYCLE_DARWIN_H +#define UI2_SCHEDULER_LIFECYCLE_DARWIN_H + +#include +#include +#include +#include +#include +#include + +// 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 diff --git a/tests/render_scheduler/lifecycle_other.c.v b/tests/render_scheduler/lifecycle_other.c.v new file mode 100644 index 0000000..340ab55 --- /dev/null +++ b/tests/render_scheduler/lifecycle_other.c.v @@ -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 + } +} diff --git a/tests/render_scheduler/main.v b/tests/render_scheduler/main.v new file mode 100644 index 0000000..15a3f1d --- /dev/null +++ b/tests/render_scheduler/main.v @@ -0,0 +1,242 @@ +@[has_globals] +module main + +import os +import time +import ui2 + +__global g_acceptance_started = false +__global g_acceptance_policy = ui2.RenderPolicy.continuous +__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 { + '--on-demand' { g_acceptance_policy = .on_demand } + '--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.set_render_policy(g_acceptance_policy) + 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_policy, 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, selected_policy ui2.RenderPolicy, 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: policy=${selected_policy} 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 + if selected_policy == .on_demand { + assert after.builds == before.builds + if !before.presentation_required { + assert after.draws == before.draws + } + } else { + assert after.builds > before.builds + 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) + if selected_policy == .on_demand { + 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() + }) + time.sleep(1500 * time.millisecond) + settled := dispatcher.stats() + report('animation', worker_after, settled) + assert settled.draws > worker_after.draws + time.sleep(time.second) + idle := dispatcher.stats() + report('idle after animation', settled, idle) + if selected_policy == .on_demand { + assert idle.builds == settled.builds + if !settled.presentation_required { + assert idle.draws == settled.draws + } + } + if lifecycle { + $if macos { + check_lifecycle(dispatcher, selected_policy) + } $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, selected_policy ui2.RenderPolicy) { + 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) + if selected_policy == .on_demand { + 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') +} diff --git a/ui/animation.v b/ui/animation.v index 922022f..8fe40ea 100644 --- a/ui/animation.v +++ b/ui/animation.v @@ -321,7 +321,7 @@ pub fn (definition Animation) animated_properties() []string { // is already animated, the new definition starts at its currently displayed // values. pub fn (definition Animation) start(id string) { - start_widget_animation_at(id, definition, time.ticks(), true) + start_widget_animation_at(id, definition, animation_now_ms(), true) } pub fn (definition Animation) cancel(id string) { @@ -661,7 +661,7 @@ fn schedule_animation_frames() { fn animation_frame_driver() { for { time.sleep(16 * time.millisecond) - if !animations_need_frames(time.ticks()) { + if !animations_need_frames(animation_now_ms()) { // The first trailing refresh completes an animation whose deadline was // crossed; the second reflects any application state its callback changed. request_animation_refresh() @@ -710,25 +710,78 @@ fn animations_need_frames(now i64) bool { return active } +// Animation timestamps share a monotonic clock across starts, presentation +// callbacks and the native driver. Wall-clock changes must not jump a tween. +fn animation_now_ms() i64 { + return i64(time.sys_mono_now() / u64(time.millisecond)) +} + +// The custom renderer schedules at its presentation cadence, without a second +// animation timer. A running mounted animation needs a frame until evaluation +// marks it complete, even when suspension has carried it past its deadline. +// Hidden and unmounted animations retain their state but cannot keep drawing. +fn custom_animations_need_frame(root Element) bool { + runtime := g_animation_runtime + runtime.mutex.lock() + active := animation_tree_needs_frame(root, runtime.runs) + runtime.mutex.unlock() + return active +} + +fn animation_tree_needs_frame(root Element, runs map[string]AnimationRun) bool { + if root.hidden { + return false + } + if animation_run := runs[root.id] { + if animation_run.status == .running { + return true + } + } + for child in root.children { + if animation_tree_needs_frame(child, runs) { + return true + } + } + return false +} + fn apply_widget_animations(root Element) Element { - return apply_widget_animations_at(root, time.ticks()) + return apply_widget_animations_at(root, animation_now_ms()) } fn apply_widget_animations_at(root Element, now i64) Element { + return apply_widget_animations_for_visibility(root, now, false) +} + +fn apply_custom_widget_animations(root Element) Element { + return apply_custom_widget_animations_at(root, animation_now_ms()) +} + +fn apply_custom_widget_animations_at(root Element, now i64) Element { + return apply_widget_animations_for_visibility(root, now, true) +} + +fn apply_widget_animations_for_visibility(root Element, now i64, visible_only bool) Element { mut runtime := g_animation_runtime mut pending := []PendingAnimationEvent{} runtime.mutex.lock() - result := apply_widget_animation_node(root, now, mut pending) + result := apply_widget_animation_node(root, now, visible_only, mut pending) runtime.mutex.unlock() dispatch_animation_events(pending) return result } -fn apply_widget_animation_node(declared Element, now i64, mut pending []PendingAnimationEvent) Element { +fn apply_widget_animation_node(declared Element, now i64, visible_only bool, mut pending []PendingAnimationEvent) Element { + // Hidden custom subtrees retain their animation timeline without evaluating + // it. Otherwise progress callbacks could repeatedly invalidate the model and + // keep an invisible repeating animation drawing despite zero frame demand. + if visible_only && declared.hidden { + return declared + } mut runtime := g_animation_runtime mut children := []Element{cap: declared.children.len} for child in declared.children { - children << apply_widget_animation_node(child, now, mut pending) + children << apply_widget_animation_node(child, now, visible_only, mut pending) } mut result := Element{ ...declared @@ -824,6 +877,12 @@ fn dispatch_animation_events(pending []PendingAnimationEvent) { for item in pending { item.callback(item.event) } + // Callbacks may change the application model after this frame's build. + // In particular, completion must schedule one more build after the final + // animated values have stopped requesting presentation frames. + if pending.len > 0 { + request_animation_refresh() + } } fn evaluate_animation(definition Animation, start Element, elapsed f64) Element { diff --git a/ui/animation_test.v b/ui/animation_test.v index 81f2dfd..305d88b 100644 --- a/ui/animation_test.v +++ b/ui/animation_test.v @@ -4,11 +4,19 @@ module ui2 import math __global g_animation_test_events = []AnimationEvent{} +__global g_animation_test_clear_refreshes = 0 fn animation_test_event_handler(event AnimationEvent) { g_animation_test_events << event } +fn animation_test_clear_refresh_handler() { + // A refresh callback may inspect animation state, so it must run outside + // the runtime mutex and after the retained values have been removed. + assert animation_info('tile').status == .idle + g_animation_test_clear_refreshes++ +} + fn animation_test_element() Element { return view('tile', rect(10, 20, 100, 40), BoxStyle{ bg: 0x000000 @@ -243,3 +251,78 @@ fn test_animation_events_and_stop_cancel_semantics() { assert animation_info('tile').status == .stopped assert g_animation_test_events.last().kind == .complete } + +fn test_custom_animation_requests_terminal_frame_after_suspension() { + reset_widget_animations() + g_animation_test_events = []AnimationEvent{} + root := animation_test_root(animation_test_element()) + definition := animation(AnimationConfig{ + duration: 1 + x: 110 + on_event: animation_test_event_handler + }) + start_widget_animation_at('tile', definition, 1_000, false) + apply_custom_widget_animations_at(root, 1_500) + assert custom_animations_need_frame(root) + + // No presentation ran while the window was suspended. The first resumed + // presentation still has to apply the final values and emit complete. + resumed := apply_custom_widget_animations_at(root, 30_000) + assert animation_test_find(resumed, 'tile').frame.x == 110 + assert g_animation_test_events.last().kind == .complete + assert !custom_animations_need_frame(resumed) + apply_custom_widget_animations_at(root, 30_016) + assert g_animation_test_events.filter(it.kind == .complete).len == 1 +} + +fn test_custom_repeating_animation_does_not_draw_hidden_or_unmounted_nodes() { + reset_widget_animations() + definition := animation(AnimationConfig{ + duration: 1 + x: 110 + repeat: true + }) + start_widget_animation_at('tile', definition, 1_000, false) + root := animation_test_root(animation_test_element()) + apply_custom_widget_animations_at(root, 1_500) + assert custom_animations_need_frame(root) + assert !custom_animations_need_frame(screen(0xffffff, [])) + assert !custom_animations_need_frame(animation_test_root(Element{ + ...animation_test_element() + hidden: true + })) + assert !custom_animations_need_frame(Element{ + ...root + hidden: true + }) + assert animation_info('tile').status == .running + + // Visibility pauses frame demand without cancelling the animation. A later + // mount continues on the same timeline, and cancellation returns to idle. + assert custom_animations_need_frame(root) + resumed := apply_custom_widget_animations_at(root, 30_500) + assert math.close(animation_test_find(resumed, 'tile').frame.x, 110) + cancel_animation('tile') + assert !custom_animations_need_frame(root) +} + +fn test_clearing_retained_animation_requests_refresh_outside_runtime_lock() { + reset_widget_animations() + runtime := g_animation_runtime + runtime.mutex.lock() + previous_callback := runtime.refresh_callback + previous_drive_frames := runtime.drive_frames + runtime.mutex.unlock() + defer { configure_animation_driver(previous_callback, previous_drive_frames) } + root := animation_test_root(animation_test_element()) + start_widget_animation_at('tile', animation(AnimationConfig{duration: 1, x: 110}), 1_000, false) + completed := apply_widget_animations_at(root, 2_000) + assert animation_test_find(completed, 'tile').frame.x == 110 + assert !custom_animations_need_frame(completed) + g_animation_test_clear_refreshes = 0 + configure_animation_driver(animation_test_clear_refresh_handler, false) + clear_animation('tile') + assert g_animation_test_clear_refreshes == 1 + refreshed := apply_widget_animations_at(root, 3_000) + assert animation_test_find(refreshed, 'tile').frame == animation_test_element().frame +} diff --git a/ui/frame_scheduler.v b/ui/frame_scheduler.v new file mode 100644 index 0000000..56de04e --- /dev/null +++ b/ui/frame_scheduler.v @@ -0,0 +1,333 @@ +module ui2 + +import sync + +// RenderPolicy controls how the custom renderer schedules UI work. Continuous +// preserves applications that intentionally read changing state during build. +pub enum RenderPolicy { + continuous + on_demand +} + +// RenderReason describes why a custom-renderer frame was requested. +pub enum RenderReason { + build + paint + surface + animation + timer + worker + presentation +} + +// RenderStats separates callbacks from actual work. Skipping builds and draws +// does not imply that the platform event loop stopped waking up. +pub struct RenderStats { +pub: + callbacks u64 + builds u64 + draws u64 + flushes u64 + coalesced u64 + requests u64 + generation u64 + finished_generation u64 + pending bool + in_flight bool + pending_reasons []RenderReason + next_deadline i64 = -1 + animation_active bool + presentation_required bool + suspended bool + closed bool +} + +struct FrameWork { + generation u64 + serial u64 + build bool + draw bool + reasons []RenderReason +} + +// Each renderer context owns one coordinator. The lock protects only scheduler +// state; builders, drawing and posted callbacks always run outside it. +@[heap] +struct FrameCoordinator { + mutex &sync.Mutex = sync.new_mutex() +mut: + policy RenderPolicy + callbacks u64 + builds u64 + draws u64 + flushes u64 + coalesced u64 + requests u64 + generation u64 + finished_generation u64 + pending_reasons []RenderReason + next_serial u64 + active_serial u64 + next_deadline i64 = -1 + animation_active bool + presentation_required bool + suspended bool + closed bool + tasks []fn () +} + +fn new_frame_coordinator(policy RenderPolicy) &FrameCoordinator { + mut coordinator := &FrameCoordinator{ + policy: policy + } + coordinator.invalidate(.surface) + return coordinator +} + +fn (mut coordinator FrameCoordinator) set_policy(policy RenderPolicy) { + coordinator.mutex.lock() + defer { coordinator.mutex.unlock() } + if coordinator.closed || coordinator.policy == policy { + return + } + coordinator.policy = policy + coordinator.invalidate_locked(.build) +} + +// Some platform loops present their swapchain after every callback, even when +// UI2 issues no commands. Those backends must paint the retained declaration on +// idle callbacks until the embedder can suppress presentation itself. +fn (mut coordinator FrameCoordinator) set_presentation_required(required bool) { + coordinator.mutex.lock() + defer { coordinator.mutex.unlock() } + if !coordinator.closed { + coordinator.presentation_required = required + } +} + +fn (mut coordinator FrameCoordinator) invalidate(reason RenderReason) { + coordinator.mutex.lock() + defer { coordinator.mutex.unlock() } + coordinator.invalidate_locked(reason) +} + +fn (mut coordinator FrameCoordinator) invalidate_locked(reason RenderReason) { + if coordinator.closed { + return + } + coordinator.requests++ + coordinator.generation++ + if coordinator.pending_reasons.len > 0 { + coordinator.coalesced++ + } + if reason !in coordinator.pending_reasons { + coordinator.pending_reasons << reason + } +} + +// begin_frame takes a generation snapshot and detaches its reasons. Requests +// arriving while the caller works therefore belong to the following frame. +// now and deadlines use the same caller-supplied monotonic millisecond clock. +fn (mut coordinator FrameCoordinator) begin_frame(now i64) ?FrameWork { + coordinator.mutex.lock() + defer { coordinator.mutex.unlock() } + coordinator.callbacks++ + if coordinator.closed || coordinator.suspended || coordinator.active_serial != 0 { + return none + } + if coordinator.next_deadline >= 0 && now >= coordinator.next_deadline { + coordinator.next_deadline = -1 + coordinator.invalidate_locked(.timer) + } + if coordinator.animation_active { + coordinator.invalidate_locked(.animation) + } + if coordinator.policy == .on_demand && coordinator.pending_reasons.len == 0 + && !coordinator.presentation_required { + return none + } + reasons := if coordinator.policy == .on_demand && coordinator.pending_reasons.len == 0 + && coordinator.presentation_required { + // This is a platform presentation requirement, not a new invalidation. + // Keep request/generation counters about actual application work. + [RenderReason.presentation] + } else { + coordinator.pending_reasons + } + coordinator.pending_reasons = []RenderReason{} + coordinator.next_serial++ + coordinator.active_serial = coordinator.next_serial + mut build := coordinator.policy == .continuous + for reason in reasons { + if reason in [.build, .surface, .animation, .worker] { + build = true + break + } + } + return FrameWork{ + generation: coordinator.generation + serial: coordinator.active_serial + build: build + draw: true + reasons: reasons + } +} + +fn (mut coordinator FrameCoordinator) finish_frame(work FrameWork) { + coordinator.mutex.lock() + defer { coordinator.mutex.unlock() } + if coordinator.closed || work.serial == 0 || coordinator.active_serial != work.serial { + return + } + coordinator.active_serial = 0 + coordinator.finished_generation = work.generation + coordinator.flushes++ +} + +fn (mut coordinator FrameCoordinator) record_build() { + coordinator.mutex.lock() + defer { coordinator.mutex.unlock() } + coordinator.builds++ +} + +fn (mut coordinator FrameCoordinator) record_draw() { + coordinator.mutex.lock() + defer { coordinator.mutex.unlock() } + coordinator.draws++ +} + +// A negative deadline cancels the pending visual timer. The renderer combines +// its tooltip, cursor and other visual timers into the earliest deadline. +fn (mut coordinator FrameCoordinator) set_deadline(at i64) { + coordinator.mutex.lock() + defer { coordinator.mutex.unlock() } + if coordinator.closed || coordinator.suspended { + return + } + coordinator.next_deadline = at +} + +fn (mut coordinator FrameCoordinator) set_animation_active(active bool) { + coordinator.mutex.lock() + defer { coordinator.mutex.unlock() } + if !coordinator.closed { + coordinator.animation_active = active + } +} + +fn (mut coordinator FrameCoordinator) suspend() { + coordinator.mutex.lock() + defer { coordinator.mutex.unlock() } + if !coordinator.closed { + coordinator.suspended = true + coordinator.next_deadline = -1 + } +} + +fn (mut coordinator FrameCoordinator) resume() { + coordinator.mutex.lock() + defer { coordinator.mutex.unlock() } + if coordinator.closed || !coordinator.suspended { + return + } + coordinator.suspended = false + coordinator.invalidate_locked(.surface) +} + +fn (mut coordinator FrameCoordinator) close() { + coordinator.mutex.lock() + defer { coordinator.mutex.unlock() } + coordinator.closed = true + coordinator.suspended = false + coordinator.animation_active = false + coordinator.next_deadline = -1 + coordinator.active_serial = 0 + coordinator.pending_reasons = []RenderReason{} + coordinator.tasks = []fn (){} +} + +fn (mut coordinator FrameCoordinator) is_closed() bool { + coordinator.mutex.lock() + defer { coordinator.mutex.unlock() } + return coordinator.closed +} + +fn (mut coordinator FrameCoordinator) post(task fn ()) bool { + coordinator.mutex.lock() + defer { coordinator.mutex.unlock() } + if coordinator.closed { + return false + } + coordinator.tasks << task + coordinator.invalidate_locked(.worker) + return true +} + +// The UI thread drains business callbacks even while drawing is suspended. +// A callback can post another callback or invalidate without holding this lock. +// Close and callback execution belong to the UI thread; close cancels the queue. +fn (mut coordinator FrameCoordinator) take_tasks() []fn () { + coordinator.mutex.lock() + defer { coordinator.mutex.unlock() } + if coordinator.closed || coordinator.active_serial != 0 { + return []fn (){} + } + tasks := coordinator.tasks + coordinator.tasks = []fn (){} + // A callback in an earlier batch can post another task before begin_frame. + // Its post may have been acknowledged by that frame while the task itself + // waited for the next drain. Delivery must still invalidate its own frame. + if tasks.len > 0 && RenderReason.worker !in coordinator.pending_reasons { + coordinator.invalidate_locked(.worker) + } + return tasks +} + +fn (mut coordinator FrameCoordinator) stats() RenderStats { + coordinator.mutex.lock() + defer { coordinator.mutex.unlock() } + return RenderStats{ + callbacks: coordinator.callbacks + builds: coordinator.builds + draws: coordinator.draws + flushes: coordinator.flushes + coalesced: coordinator.coalesced + requests: coordinator.requests + generation: coordinator.generation + finished_generation: coordinator.finished_generation + pending: coordinator.pending_reasons.len > 0 + in_flight: coordinator.active_serial != 0 + pending_reasons: coordinator.pending_reasons.clone() + next_deadline: coordinator.next_deadline + animation_active: coordinator.animation_active + presentation_required: coordinator.presentation_required + suspended: coordinator.suspended + closed: coordinator.closed + } +} + +// UiDispatcher is a lifetime-safe handle for delivering worker results to the +// custom renderer's UI thread. Capture this handle before starting the worker; +// mutate the model only inside post's callback. A closed window rejects posts. +pub struct UiDispatcher { + coordinator &FrameCoordinator = unsafe { nil } +} + +pub fn (dispatcher UiDispatcher) post(task fn ()) bool { + if isnil(dispatcher.coordinator) { + return false + } + mut coordinator := dispatcher.coordinator + return coordinator.post(task) +} + +// stats is a synchronized snapshot; reading it never requests a frame. +pub fn (dispatcher UiDispatcher) stats() RenderStats { + if isnil(dispatcher.coordinator) { + return RenderStats{ + closed: true + } + } + mut coordinator := dispatcher.coordinator + return coordinator.stats() +} diff --git a/ui/frame_scheduler_test.v b/ui/frame_scheduler_test.v new file mode 100644 index 0000000..0a7b7ef --- /dev/null +++ b/ui/frame_scheduler_test.v @@ -0,0 +1,302 @@ +module ui2 + +fn finish_scheduler_test_frame(mut coordinator FrameCoordinator, now i64) FrameWork { + work := coordinator.begin_frame(now) or { panic('expected a frame') } + if work.build { + coordinator.record_build() + } + if work.draw { + coordinator.record_draw() + } + coordinator.finish_frame(work) + return work +} + +fn test_frame_scheduler_on_demand_idle_and_coalescing() { + mut coordinator := new_frame_coordinator(.on_demand) + initial := finish_scheduler_test_frame(mut coordinator, 0) + assert initial.build && initial.draw + for now in 1 .. 30_001 { + if _ := coordinator.begin_frame(now) { + assert false, 'idle callback must not request work' + } + } + idle := coordinator.stats() + assert idle.callbacks == 30_001 + assert idle.builds == 1 + assert idle.draws == 1 + assert idle.flushes == 1 + for _ in 0 .. 100 { + coordinator.invalidate(.build) + } + pending := coordinator.stats() + assert pending.requests == 101 + assert pending.coalesced == 99 + assert pending.pending_reasons == [.build] + finish_scheduler_test_frame(mut coordinator, 30_001) + assert coordinator.stats().flushes == 2 + assert !coordinator.stats().pending +} + +fn test_frame_scheduler_invalidation_during_flush_survives() { + mut coordinator := new_frame_coordinator(.on_demand) + work := coordinator.begin_frame(0) or { panic('expected initial frame') } + coordinator.invalidate(.build) + if _ := coordinator.begin_frame(1) { + assert false, 'a nested callback must not start a second flush' + } + coordinator.finish_frame(work) + assert coordinator.stats().pending + assert coordinator.stats().finished_generation == work.generation + next := finish_scheduler_test_frame(mut coordinator, 2) + assert next.generation > work.generation + assert next.build + coordinator.finish_frame(work) + assert coordinator.stats().flushes == 2 + assert coordinator.stats().finished_generation == next.generation +} + +fn test_frame_scheduler_paint_deadline_and_animation_return_to_idle() { + mut coordinator := new_frame_coordinator(.on_demand) + finish_scheduler_test_frame(mut coordinator, 0) + coordinator.invalidate(.paint) + paint := finish_scheduler_test_frame(mut coordinator, 10) + assert !paint.build && paint.draw + coordinator.set_deadline(500) + if _ := coordinator.begin_frame(499) { + assert false, 'timer fired before its monotonic deadline' + } + timer := finish_scheduler_test_frame(mut coordinator, 500) + assert timer.reasons == [.timer] + assert !timer.build && timer.draw + assert coordinator.stats().next_deadline == -1 + coordinator.set_animation_active(true) + animation := finish_scheduler_test_frame(mut coordinator, 501) + assert animation.build && animation.draw + assert animation.reasons == [.animation] + finish_scheduler_test_frame(mut coordinator, 509) + coordinator.set_animation_active(false) + if _ := coordinator.begin_frame(510) { + assert false, 'finished animation must return to idle' + } + coordinator.set_deadline(600) + coordinator.set_deadline(-1) + if _ := coordinator.begin_frame(600) { + assert false, 'cancelled deadline must not fire' + } +} + +fn test_frame_scheduler_posted_callbacks_are_outside_lock_and_next_drain() { + mut coordinator := new_frame_coordinator(.on_demand) + dispatcher := UiDispatcher{ + coordinator: coordinator + } + finish_scheduler_test_frame(mut coordinator, 0) + mut delivered := chan int{cap: 2} + assert dispatcher.post(fn [dispatcher, delivered] () { + delivered <- 1 + assert dispatcher.post(fn [delivered] () { + delivered <- 2 + }) + assert dispatcher.stats().pending + }) + tasks := coordinator.take_tasks() + assert tasks.len == 1 + for task in tasks { + task() + } + assert <-delivered == 1 + first := finish_scheduler_test_frame(mut coordinator, 1) + assert first.build + assert first.reasons == [.worker] + next := coordinator.take_tasks() + assert next.len == 1 + for task in next { + task() + } + assert <-delivered == 2 + assert coordinator.take_tasks().len == 0 + worker := finish_scheduler_test_frame(mut coordinator, 2) + assert worker.build + assert worker.reasons == [.worker] + assert coordinator.stats().flushes == 3 +} + +fn test_frame_scheduler_suspend_resume_preserves_business_tasks_and_restores_surface() { + mut coordinator := new_frame_coordinator(.on_demand) + finish_scheduler_test_frame(mut coordinator, 0) + coordinator.set_deadline(500) + coordinator.suspend() + assert coordinator.stats().suspended + assert coordinator.stats().next_deadline == -1 + mut delivered := chan bool{cap: 1} + assert coordinator.post(fn [delivered] () { + delivered <- true + }) + for task in coordinator.take_tasks() { + task() + } + assert <-delivered + coordinator.set_deadline(600) + if _ := coordinator.begin_frame(1000) { + assert false, 'suspended context must not draw' + } + assert coordinator.stats().callbacks == 2 + assert coordinator.stats().next_deadline == -1 + coordinator.resume() + restored := finish_scheduler_test_frame(mut coordinator, 1001) + assert restored.build && restored.draw + assert RenderReason.surface in restored.reasons + assert !coordinator.stats().suspended + if _ := coordinator.begin_frame(1002) { + assert false, 'restored surface must return to idle' + } +} + +fn test_frame_scheduler_does_not_deliver_tasks_during_an_active_flush() { + mut coordinator := new_frame_coordinator(.on_demand) + finish_scheduler_test_frame(mut coordinator, 0) + coordinator.invalidate(.paint) + work := coordinator.begin_frame(1) or { panic('expected paint frame') } + mut delivered := chan bool{cap: 1} + assert coordinator.post(fn [delivered] () { + delivered <- true + }) + // A modal dialog can spin a nested event loop during a build or draw. It + // must not deliver worker mutations in the middle of the outer flush. + assert coordinator.take_tasks().len == 0 + assert coordinator.stats().pending + coordinator.finish_frame(work) + tasks := coordinator.take_tasks() + assert tasks.len == 1 + for task in tasks { + task() + } + assert <-delivered + next := finish_scheduler_test_frame(mut coordinator, 2) + assert next.build && next.reasons == [.worker] +} + +fn test_frame_scheduler_close_cancels_queue_timers_and_rejects_old_handle() { + mut coordinator := new_frame_coordinator(.on_demand) + dispatcher := UiDispatcher{ + coordinator: coordinator + } + assert dispatcher.post(fn () { + assert false, 'closed context must cancel queued callback' + }) + coordinator.set_deadline(500) + coordinator.set_animation_active(true) + coordinator.close() + assert coordinator.take_tasks().len == 0 + assert !dispatcher.post(fn () {}) + coordinator.invalidate(.build) + coordinator.resume() + if _ := coordinator.begin_frame(1000) { + assert false, 'closed context must never render' + } + stats := dispatcher.stats() + assert stats.closed + assert !stats.pending && !stats.in_flight && !stats.animation_active + assert stats.next_deadline == -1 + assert UiDispatcher{}.stats().closed + assert !UiDispatcher{}.post(fn () {}) +} + +fn test_frame_scheduler_continuous_default_and_policy_change() { + mut coordinator := new_frame_coordinator(.continuous) + coordinator.set_presentation_required(true) + for now in 0 .. 3 { + work := finish_scheduler_test_frame(mut coordinator, now) + assert work.build && work.draw + } + coordinator.set_presentation_required(false) + coordinator.set_policy(.on_demand) + finish_scheduler_test_frame(mut coordinator, 3) + if _ := coordinator.begin_frame(4) { + assert false, 'on-demand policy must become idle' + } + coordinator.set_policy(.continuous) + finish_scheduler_test_frame(mut coordinator, 5) + finish_scheduler_test_frame(mut coordinator, 6) + assert coordinator.stats().builds == 6 +} + +fn test_frame_scheduler_required_presentation_reuses_tree_and_respects_lifecycle() { + mut coordinator := new_frame_coordinator(.on_demand) + finish_scheduler_test_frame(mut coordinator, 0) + coordinator.set_presentation_required(true) + assert coordinator.stats().presentation_required + baseline := coordinator.stats() + for now in 1 .. 4 { + idle := finish_scheduler_test_frame(mut coordinator, now) + assert !idle.build && idle.draw + assert idle.reasons == [.presentation] + } + assert coordinator.stats().requests == baseline.requests + assert coordinator.stats().generation == baseline.generation + assert coordinator.stats().builds == baseline.builds + for _ in 0 .. 10 { + coordinator.invalidate(.build) + } + changed := finish_scheduler_test_frame(mut coordinator, 4) + assert changed.build && changed.draw && changed.reasons == [.build] + assert coordinator.stats().requests == baseline.requests + 10 + assert coordinator.stats().coalesced == 9 + cached := finish_scheduler_test_frame(mut coordinator, 5) + assert !cached.build && cached.reasons == [.presentation] + coordinator.suspend() + if _ := coordinator.begin_frame(6) { + assert false, 'suspension must suppress even required presentation' + } + coordinator.resume() + restored := finish_scheduler_test_frame(mut coordinator, 7) + assert restored.build && restored.draw + assert restored.reasons == [.surface] + after_restore := finish_scheduler_test_frame(mut coordinator, 8) + assert !after_restore.build && after_restore.reasons == [.presentation] + coordinator.set_presentation_required(false) + assert !coordinator.stats().presentation_required + if _ := coordinator.begin_frame(9) { + assert false, 'a backend that can skip presentation must return to idle' + } +} + +fn test_frame_scheduler_worker_posts_are_synchronized() { + mut coordinator := new_frame_coordinator(.on_demand) + dispatcher := UiDispatcher{ + coordinator: coordinator + } + finish_scheduler_test_frame(mut coordinator, 0) + mut workers := []thread{} + for _ in 0 .. 4 { + workers << spawn fn [dispatcher] () { + for _ in 0 .. 100 { + assert dispatcher.post(fn () {}) + } + }() + } + workers.wait() + assert coordinator.take_tasks().len == 400 + assert dispatcher.stats().requests == 401 + assert dispatcher.stats().coalesced == 399 + finish_scheduler_test_frame(mut coordinator, 1) + assert dispatcher.stats().flushes == 2 +} + +fn test_frame_scheduler_contexts_keep_independent_lifetimes() { + mut first := new_frame_coordinator(.on_demand) + mut second := new_frame_coordinator(.on_demand) + finish_scheduler_test_frame(mut first, 0) + finish_scheduler_test_frame(mut second, 0) + first.invalidate(.build) + first.set_deadline(500) + first.close() + second.invalidate(.paint) + work := finish_scheduler_test_frame(mut second, 1) + assert !work.build && work.draw + assert !second.stats().closed + assert second.stats().next_deadline == -1 + assert first.stats().flushes == 1 + assert second.stats().flushes == 2 +} diff --git a/ui/menu_darwin.v b/ui/menu_darwin.v index 4c77cca..c9b7c66 100644 --- a/ui/menu_darwin.v +++ b/ui/menu_darwin.v @@ -62,10 +62,9 @@ fn native_tray_supported() bool { fn native_set_menu_bar(menus []Menu) { $if ui2_custom_rendering ? { - // The drawn bar is rebuilt from the declaration every frame; all that - // has to happen is that a menu left open by the previous one cannot - // outlive it. + // Rebuild bounds and paint after replacing the menu declaration. close_menu_bar() + refresh() } $else { macos_install_menu_bar(menu_app_name(), menus) } diff --git a/ui/menu_default.c.v b/ui/menu_default.c.v index e4dcd44..7544b1f 100644 --- a/ui/menu_default.c.v +++ b/ui/menu_default.c.v @@ -5,7 +5,7 @@ module ui2 fn native_menu_bar_supported() bool { - $if (android || linux) && !ui2_headless ? { + $if ( android || linux ) && !ui2_headless ? { return true } $else { return false @@ -17,11 +17,10 @@ fn native_tray_supported() bool { } fn native_set_menu_bar(_menus []Menu) { - $if (android || linux) && !ui2_headless ? { - // The bar is redrawn from the declaration every frame; all that has to - // happen here is that a menu left open by the previous declaration - // cannot outlive it. + $if ( android || linux ) && !ui2_headless ? { + // Rebuild bounds and paint after replacing the menu declaration. close_menu_bar() + refresh() } } diff --git a/ui/menu_windows.c.v b/ui/menu_windows.c.v index 2bd1f05..63a2b10 100644 --- a/ui/menu_windows.c.v +++ b/ui/menu_windows.c.v @@ -20,6 +20,7 @@ fn native_tray_supported() bool { fn native_set_menu_bar(menus []Menu) { $if ui2_custom_rendering ? { close_menu_bar() + refresh() } $else { menu_win32_set_menu_bar(menus) } diff --git a/ui/ui_custom_test.v b/ui/ui_custom_test.v index 8abce54..8eaa4ce 100644 --- a/ui/ui_custom_test.v +++ b/ui/ui_custom_test.v @@ -47,8 +47,8 @@ $if ui2_custom_rendering ? { g_focused_field = 'field' replace_text_value('field', 'abc') replace_text_editor('field', text_editor('abc'.clone())) - handle_key_down(.left) - handle_key_down(.backspace) + handle_key_down(.left, 0) + handle_key_down(.backspace, 0) handle_char_input(`x`) assert text('field') == 'axc' forget_text_state('field') diff --git a/ui/ui_immediate.c.v b/ui/ui_immediate.c.v index 4736cf9..35849df 100644 --- a/ui/ui_immediate.c.v +++ b/ui/ui_immediate.c.v @@ -10,6 +10,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 import math import os import sokol.sapp + import sokol.gfx import time struct HitTarget { @@ -68,6 +69,13 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 struct GgApp { mut: ctx &gg.Context = unsafe { nil } + scheduler &FrameCoordinator = new_frame_coordinator(.continuous) + declared_root Element + has_root bool + iconified bool + suspended bool + dpi_scale f32 + draining_tasks bool } // DropdownPopup caches the geometry of the open dropdown list. The list is @@ -107,9 +115,8 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 frame Rect } - // TooltipState follows the pointer from frame to frame. The window is - // redrawn continuously, so the first frame after the pointer has rested - // on a target long enough shows its tooltip, and no timer is needed. + // TooltipState follows the pointer. Its cancelable deadline belongs to the + // window coordinator, so a stationary pointer also works in on-demand mode. struct TooltipState { mut: pointer_x f64 @@ -291,6 +298,10 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 } fn run_window_with_min_size(title string, width int, height int, min_width int, min_height int, build_fn BuildFn, event_fn EventFn) { + // A dispatcher retained by an old worker stays attached to its closed window. + if g_gg_app.scheduler.is_closed() { + g_gg_app = &GgApp{} + } g_build_screen = build_fn g_event_handler = event_fn configure_animation_driver(request_refresh, false) @@ -315,6 +326,11 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 window_title: title user_data: unsafe { voidptr(g_gg_app) } init_fn: on_init + // gg's ui_mode suppresses this callback before it can drain worker + // messages or deadlines, and refresh_ui is not thread safe. Keep the + // presentation callback and gate builds/draws in our coordinator (1A). + ui_mode: false + cleanup_fn: on_cleanup frame_fn: on_frame event_fn: on_event enable_dragndrop: true @@ -324,15 +340,44 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 g_gg_app.ctx.run() } + // set_render_policy selects custom-renderer scheduling. Continuous remains + // the default. Call on the UI thread, before run or while the window is open. + pub fn set_render_policy(policy RenderPolicy) { + if g_gg_app.scheduler.is_closed() { + g_gg_app = &GgApp{} + } + g_gg_app.scheduler.set_policy(policy) + } + + pub fn render_stats() RenderStats { + return g_gg_app.scheduler.stats() + } + + // Capture this handle on the UI thread. Workers post model mutations through + // it instead of touching controls, gg, or a shared model themselves. + pub fn ui_dispatcher() UiDispatcher { + return UiDispatcher{coordinator: g_gg_app.scheduler} + } + pub fn refresh() { + g_gg_app.scheduler.invalidate(.build) } - // The immediate backend already rebuilds every frame, so both refresh entry - // points are intentionally scheduling no-ops. + // Custom refresh remains asynchronous; native refresh semantics are unchanged. pub fn request_refresh() { + g_gg_app.scheduler.invalidate(.build) } pub fn refresh_element(_id string, _element Element) { + refresh() + } + + fn invalidate_custom_paint() { + g_gg_app.scheduler.invalidate(.paint) + } + + fn renderer_now_ms() i64 { + return i64(time.sys_mono_now() / u64(time.millisecond)) } pub fn on_key(handler KeyFn) { @@ -367,6 +412,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 mut editor := g_text_editors[id] or { text_editor(t.clone()) } editor.set_text(t.clone()) replace_text_editor(id, editor) + invalidate_custom_paint() } // slider_value returns the live value currently displayed by a mounted @@ -381,6 +427,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 } spec := g_slider_specs[id] or { return } g_slider_values[id] = slider_clamped_value(value, spec.min, spec.max) + invalidate_custom_paint() } // switch_active returns the live value, including a pointer change made @@ -394,6 +441,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 return } g_switch_values[id] = active + invalidate_custom_paint() } // checkbox_checked returns the live value currently displayed by a mounted @@ -407,6 +455,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 return } g_checkbox_values[id] = checked + invalidate_custom_paint() } pub fn toggle_button_pressed(id string) bool { @@ -421,6 +470,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 release_custom_toggle_group(id) } g_toggle_values[id] = pressed + invalidate_custom_paint() } pub fn toggle_button_group_members(id string) []string { @@ -464,6 +514,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 mut editor := g_text_editors[id] or { text_editor((g_text_values[id] or { '' }).clone()) } editor.set_caret(rune_len(editor.text)) replace_text_editor(id, editor) + invalidate_custom_paint() } pub fn focused_id() string { @@ -481,9 +532,14 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 pub fn dismiss_keyboard() { g_focused_field = '' + invalidate_custom_paint() } pub fn quit() { + if g_gg_app.scheduler.is_closed() { + return + } + g_gg_app.scheduler.close() if g_gg_app.ctx != unsafe { nil } { g_gg_app.ctx.quit() } @@ -516,6 +572,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 replace_text_value(id, editor.text) replace_text_editor(id, editor) fire_field_change(id) + invalidate_custom_paint() } pub fn scroll_offset(id string) f64 { @@ -539,6 +596,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 // stored as a live position: the next frame rendered without the view would // prune it. Hold the request until the view registers and can clamp it. g_pending_scroll[id] = wanted + invalidate_custom_paint() } pub fn scroll_to_rect(id string, _x f64, y f64, _width f64, height f64) { @@ -608,24 +666,67 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 // ── Frame & event loop ───────────────────────────────────────────── - fn on_init(_ &GgApp) { + fn on_init(app &GgApp) { + // Sokol's GL/EGL/D3D loops swap even when frame_fn returns early. Their + // discarded backbuffers need a full paint; only the Metal path can skip + // submission safely until UI2 owns presentation in the platform embedder. + app.scheduler.set_presentation_required(gfx.query_backend() != .metal_macos) if voidptr(g_build_screen) == unsafe { nil } { return } // gg/Sokol must receive images during initialization to make their GPU // textures available for the first rendered frame. + g_gg_app.scheduler.record_build() preload_images(g_build_screen()) } - fn on_frame(app &GgApp) { - if app.ctx == unsafe { nil } { + fn on_cleanup(app &GgApp) { + app.scheduler.close() + mut state := unsafe { app } + state.ctx = unsafe { nil } + state.declared_root = Element{} + state.has_root = false + configure_animation_driver(unsafe { nil }, false) + reset_widget_animations() + g_tooltip = TooltipState{} + g_touch = TouchState{} + } + + // The next visual deadline is replaced after every frame. A canceled hover, + // unmounted target or released pointer therefore cannot leave a live timer. + fn custom_visual_deadline() i64 { + mut deadline := i64(-1) + if g_tooltip.pointer_in && g_tooltip.key.len > 0 && !g_tooltip.dismissed + && !g_tooltip.visible && !g_touch.down && !menu_bar_open() && g_open_dropdown.len == 0 { + deadline = g_tooltip.rest_since + tooltip_delay_ms + } + if g_touch.down && !g_touch.moved && !g_touch.long_press_fired && !g_touch.scrollbar_drag { + target := hit_test(g_touch.start_x, g_touch.start_y) + if target.long_press && target.action_id.len > 0 { + press_deadline := g_touch.start_time + 450 + if deadline < 0 || press_deadline < deadline { + deadline = press_deadline + } + } + } + return deadline + } + + fn on_frame(mut app GgApp) { + if app.scheduler.is_closed() || app.ctx == unsafe { nil } || app.draining_tasks { return } + // Callbacks run outside the coordinator lock, on this UI thread. Business + // messages may run while minimized, but visual work stays suspended. + app.draining_tasks = true + for task in app.scheduler.take_tasks() { + if app.scheduler.is_closed() { + break + } + task() + } + app.draining_tasks = false mut ctx := app.ctx - // Some window managers can choose a client size different from the one - // requested in gg.Config before gg's cached resize event catches up. UI2 - // lays out and clips against that cache, so synchronize it from Sokol's - // live logical window size before building the frame. live_size := ctx.window_size() if live_size.width > 0 && live_size.height > 0 && (ctx.width != live_size.width || ctx.height != live_size.height) { @@ -633,42 +734,57 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 ctx.height = live_size.height ctx.window.width = live_size.width ctx.window.height = live_size.height + app.scheduler.invalidate(.surface) + } + dpi := sapp.dpi_scale() + if dpi != app.dpi_scale { + app.dpi_scale = dpi + app.scheduler.invalidate(.surface) } - // gg builds its fonts in the sokol init callback, after the window has - // been created, so the fallback chain is attached on the way into the - // first frame rather than in run_window. + now := renderer_now_ms() + work := app.scheduler.begin_frame(now) or { return } + defer { app.scheduler.finish_frame(work) } ensure_symbol_fallbacks(ctx) - mut root := Element{} - mut has_root := false - if voidptr(g_build_screen) != unsafe { nil } { - g_hit_targets = []HitTarget{} - g_tooltip_targets.clear() - g_tooltip_owners = 0 - reset_scroll_frame() - g_active_fields = map[string]bool{} - g_active_sliders = map[string]bool{} - g_active_switches = map[string]bool{} - g_active_checkboxes = map[string]bool{} - g_active_toggles = map[string]bool{} - g_active_scrolls = map[string]bool{} - g_active_images = map[string]bool{} - root = apply_widget_animations(g_build_screen()) - validate_element_tree(root) or { + if work.build && voidptr(g_build_screen) != unsafe { nil } { + app.scheduler.record_build() + declared := g_build_screen() + validate_element_tree(declared) or { eprintln('ui2: ${err}') return } - // Sokol resources must be created before ctx.begin() starts its draw - // pass. Loading here lets the Linux/custom renderer draw PNGs and BMPs - // on the same frame they first appear. - preload_images(root) - has_root = true + app.declared_root = declared + app.has_root = true + } + if app.scheduler.is_closed() || app.iconified || app.suspended { + return + } + root := apply_custom_widget_animations(app.declared_root) + // Animation callbacks are user code and may close or suspend the window. + if app.scheduler.is_closed() || app.iconified || app.suspended { + return + } + validate_element_tree(root) or { + eprintln('ui2: ${err}') + return } + g_hit_targets = []HitTarget{} + g_tooltip_targets.clear() + g_tooltip_owners = 0 + reset_scroll_frame() + g_active_fields = map[string]bool{} + g_active_sliders = map[string]bool{} + g_active_switches = map[string]bool{} + g_active_checkboxes = map[string]bool{} + g_active_toggles = map[string]bool{} + g_active_scrolls = map[string]bool{} + g_active_images = map[string]bool{} + // Image resources must be available before starting the GPU pass. + preload_images(root) ctx.begin() - if has_root { + if app.has_root { g_dropdown_popup.mounted = false top := menu_bar_height() - render_element(ctx, root, 0, top, rect(0, top, f64(ctx.width), f64(ctx.height) - top), - '') + render_element(ctx, root, 0, top, rect(0, top, f64(ctx.width), f64(ctx.height) - top), '') if g_open_dropdown.len > 0 { if g_dropdown_popup.mounted { draw_dropdown_popup(ctx) @@ -676,19 +792,32 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 close_dropdown() } } - // The bar and its panels float above every control in the window. draw_menu_bar(ctx) - // A tooltip floats above everything, the bar included: one flipped - // above a control near the top of the window can reach over it. - update_tooltip(time.ticks()) + update_tooltip(now) draw_tooltip(ctx) prune_unmounted_state() } check_long_press() + // gg.begin flushes the PREVIOUS atlas. New glyphs (including tooltip + // sizes and worker text) must be uploaded before this frame's sgl draw. + // Flushing here also leaves the next gg.begin with no pending upload. + if ctx.font_inited { + ctx.ft.flush() + } ctx.end() + app.scheduler.record_draw() + app.scheduler.set_deadline(custom_visual_deadline()) + app.scheduler.set_animation_active(custom_animations_need_frame(app.declared_root)) } - fn on_event(e &gg.Event, _ &GgApp) { + fn on_event(e &gg.Event, app &GgApp) { + if app.scheduler.is_closed() { + return + } + mut state := unsafe { app } + // All input can affect local interaction or invoke application handlers. + // Legacy builds remain complete; paint-only setters/deadlines reuse root. + state.scheduler.invalidate(.build) match e.typ { .mouse_down { g_tooltip.dismiss() @@ -700,7 +829,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 .mouse_move { // Recorded before any handler below can claim the move, so the // tooltip always knows where the pointer is. - g_tooltip.pointer_moved(f64(e.mouse_x), f64(e.mouse_y), time.ticks()) + g_tooltip.pointer_moved(f64(e.mouse_x), f64(e.mouse_y), renderer_now_ms()) if g_touch.down && g_touch.pointer_target.action_id.len > 0 { handle_touch_move(f64(e.mouse_x), f64(e.mouse_y)) return @@ -718,7 +847,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 .mouse_scroll { // The content moves under a pointer that stays put, so whatever // ends up beneath it waits for a fresh rest. - g_tooltip.restart(time.ticks()) + g_tooltip.restart(renderer_now_ms()) if menu_bar_open() { return } @@ -754,7 +883,30 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 handle_touch_up(g_touch.current_x, g_touch.current_y) } } - .touches_cancelled, .unfocused, .suspended { + .iconified, .suspended { + if e.typ == .iconified { + state.iconified = true + } else { + state.suspended = true + } + state.scheduler.suspend() + g_tooltip.pointer_left() + cancel_touch() + } + .restored, .resumed { + if e.typ == .restored { + state.iconified = false + } else { + state.suspended = false + } + if !state.iconified && !state.suspended { + state.scheduler.resume() + } + } + .resized, .focused { + state.scheduler.invalidate(.surface) + } + .touches_cancelled, .unfocused { g_tooltip.dismiss() cancel_touch() } @@ -795,7 +947,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 start_y: y current_x: x current_y: y - start_time: time.ticks() + start_time: renderer_now_ms() moved: false long_press_fired: false } @@ -883,8 +1035,12 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 } previous := g_scroll_offsets[id] or { 0.0 } g_scroll_offsets[id] = next - if next != previous && voidptr(g_scroll_handler) != unsafe { nil } { - g_scroll_handler(id) + if next != previous { + invalidate_custom_paint() + if voidptr(g_scroll_handler) != unsafe { nil } { + refresh() + g_scroll_handler(id) + } } } @@ -999,7 +1155,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 if !g_touch.down || g_touch.moved || g_touch.long_press_fired || g_touch.scrollbar_drag { return } - elapsed := time.ticks() - g_touch.start_time + elapsed := renderer_now_ms() - g_touch.start_time if elapsed < 450 { return } @@ -1022,6 +1178,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 fn fire_event(id string) { if id.len > 0 && voidptr(g_event_handler) != unsafe { nil } { + refresh() g_event_handler(id) } } diff --git a/ui/ui_scheduler_immediate_test.v b/ui/ui_scheduler_immediate_test.v new file mode 100644 index 0000000..64dd965 --- /dev/null +++ b/ui/ui_scheduler_immediate_test.v @@ -0,0 +1,372 @@ +// vtest vflags: -d ui2_custom_rendering +@[has_globals] +module ui2 + +$if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && !ui2_headless ? { + import gg + + __global g_scheduler_immediate_events = []string{} + + fn scheduler_immediate_event(id string) { + g_scheduler_immediate_events << id + } + + fn scheduler_immediate_animation_event(event AnimationEvent) { + if event.kind == .complete { + g_scheduler_immediate_events << 'completed' + } + } + + fn scheduler_immediate_finish(mut app GgApp, now i64) FrameWork { + work := app.scheduler.begin_frame(now) or { panic('expected custom renderer work') } + app.scheduler.finish_frame(work) + return work + } + + fn test_custom_scheduler_lifecycle_restores_surface_after_all_suspensions_end() { + previous_app := g_gg_app + previous_touch := g_touch + previous_tooltip := g_tooltip + previous_handler := g_event_handler + previous_events := g_scheduler_immediate_events.clone() + defer { + g_gg_app = previous_app + g_touch = previous_touch + g_tooltip = previous_tooltip + g_event_handler = previous_handler + g_scheduler_immediate_events = previous_events.clone() + } + mut app := &GgApp{ scheduler: new_frame_coordinator(.on_demand) } + g_gg_app = app + g_event_handler = scheduler_immediate_event + g_scheduler_immediate_events = []string{} + scheduler_immediate_finish(mut app, 0) + g_tooltip = TooltipState{ pointer_in: true, visible: true, key: 'hover' } + g_touch = TouchState{ + down: true + current_x: 20 + current_y: 30 + pointer_target: HitTarget{ action_id: 'drag' } + } + app.scheduler.set_deadline(500) + on_event(&gg.Event{ typ: .iconified }, app) + assert app.scheduler.stats().suspended + assert app.scheduler.stats().next_deadline == -1 + assert !g_tooltip.pointer_in && !g_tooltip.visible + assert !g_touch.down + assert g_scheduler_immediate_events == [pointer_event_id('up', 'drag', 20, 30)] + on_event(&gg.Event{ typ: .suspended }, app) + on_event(&gg.Event{ typ: .restored }, app) + assert app.scheduler.stats().suspended + if _ := app.scheduler.begin_frame(1_000) { + assert false, 'restoring an iconified window must not resume a suspended app' + } + on_event(&gg.Event{ typ: .resumed }, app) + assert !app.scheduler.stats().suspended + restored := scheduler_immediate_finish(mut app, 1_001) + assert restored.build && restored.draw + assert RenderReason.surface in restored.reasons + if _ := app.scheduler.begin_frame(1_002) { + assert false, 'the restored surface must return to idle' + } + on_event(&gg.Event{ typ: .resized }, app) + resized := scheduler_immediate_finish(mut app, 1_003) + assert resized.build && resized.draw + assert RenderReason.surface in resized.reasons + } + + fn test_custom_scheduler_stationary_tooltip_deadline_and_unmount_cancellation() { + previous_app := g_gg_app + previous_tooltip := g_tooltip + previous_targets := g_tooltip_targets.clone() + previous_touch := g_touch + previous_dropdown := g_open_dropdown + previous_menu := g_menu_open_path.clone() + defer { + g_gg_app = previous_app + g_tooltip = previous_tooltip + g_tooltip_targets = previous_targets.clone() + g_touch = previous_touch + g_open_dropdown = previous_dropdown + g_menu_open_path = previous_menu.clone() + } + mut app := &GgApp{ scheduler: new_frame_coordinator(.on_demand) } + g_gg_app = app + g_tooltip = TooltipState{} + g_touch = TouchState{} + g_open_dropdown = '' + g_menu_open_path = []int{} + g_tooltip_targets = [TooltipTarget{ + key: 'help' + text: 'Stationary pointer help' + frame: rect(0, 0, 100, 40) + }] + scheduler_immediate_finish(mut app, 1_000) + g_tooltip.pointer_moved(20, 20, 1_000) + update_tooltip(1_000) + assert custom_visual_deadline() == 1_500 + app.scheduler.set_deadline(custom_visual_deadline()) + if _ := app.scheduler.begin_frame(1_499) { + assert false, 'a resting tooltip must wait until its deadline' + } + timer := app.scheduler.begin_frame(1_500) or { panic('tooltip deadline did not wake') } + assert timer.reasons == [.timer] + assert !timer.build && timer.draw + update_tooltip(1_500) + assert g_tooltip.visible + assert g_tooltip.text == 'Stationary pointer help' + app.scheduler.set_deadline(custom_visual_deadline()) + app.scheduler.finish_frame(timer) + assert app.scheduler.stats().next_deadline == -1 + if _ := app.scheduler.begin_frame(1_501) { + assert false, 'a visible tooltip must not keep requesting frames' + } + + g_tooltip.pointer_left() + g_tooltip.pointer_moved(20, 20, 2_000) + update_tooltip(2_000) + app.scheduler.set_deadline(custom_visual_deadline()) + assert app.scheduler.stats().next_deadline == 2_500 + g_tooltip_targets = []TooltipTarget{} + update_tooltip(2_100) + app.scheduler.set_deadline(custom_visual_deadline()) + assert app.scheduler.stats().next_deadline == -1 + if _ := app.scheduler.begin_frame(2_500) { + assert false, 'an unmounted tooltip target must cancel its deadline' + } + } + + fn test_custom_scheduler_long_press_fires_once_and_cancels_after_movement() { + previous_app := g_gg_app + previous_touch := g_touch + previous_tooltip := g_tooltip + previous_targets := g_hit_targets.clone() + previous_handler := g_event_handler + previous_events := g_scheduler_immediate_events.clone() + defer { + g_gg_app = previous_app + g_touch = previous_touch + g_tooltip = previous_tooltip + g_hit_targets = previous_targets.clone() + g_event_handler = previous_handler + g_scheduler_immediate_events = previous_events.clone() + } + mut app := &GgApp{ scheduler: new_frame_coordinator(.on_demand) } + g_gg_app = app + g_tooltip = TooltipState{} + g_event_handler = scheduler_immediate_event + g_scheduler_immediate_events = []string{} + g_hit_targets = [HitTarget{ + action_id: 'hold' + long_press: true + x: 0 + y: 0 + w: 100 + h: 40 + }] + g_touch = TouchState{ down: true, start_x: 20, start_y: 20, start_time: 1_000 } + scheduler_immediate_finish(mut app, 1_000) + assert custom_visual_deadline() == 1_450 + app.scheduler.set_deadline(custom_visual_deadline()) + if _ := app.scheduler.begin_frame(1_449) { + assert false, 'long press must wait until its deadline' + } + g_touch.start_time = renderer_now_ms() - 450 + app.scheduler.set_deadline(custom_visual_deadline()) + timer := app.scheduler.begin_frame(renderer_now_ms()) or { panic('long press deadline did not wake') } + assert timer.reasons == [.timer] + check_long_press() + check_long_press() + assert g_scheduler_immediate_events == ['long:hold'] + assert custom_visual_deadline() == -1 + app.scheduler.set_deadline(custom_visual_deadline()) + app.scheduler.finish_frame(timer) + // The application handler runs during the visual frame; its requested + // model build must survive completion of that frame. + assert app.scheduler.stats().pending_reasons == [.build] + scheduler_immediate_finish(mut app, renderer_now_ms()) + if _ := app.scheduler.begin_frame(renderer_now_ms()) { + assert false, 'a completed long press must return to idle' + } + g_touch = TouchState{ down: true, start_x: 20, start_y: 20, start_time: 3_000 } + app.scheduler.set_deadline(custom_visual_deadline()) + g_touch.moved = true + app.scheduler.set_deadline(custom_visual_deadline()) + assert app.scheduler.stats().next_deadline == -1 + if _ := app.scheduler.begin_frame(4_000) { + assert false, 'moving a held pointer must cancel long press work' + } + on_event(&gg.Event{ typ: .touches_cancelled }, app) + assert !g_touch.down + assert custom_visual_deadline() == -1 + assert g_scheduler_immediate_events == ['long:hold'] + } + + fn test_custom_scheduler_setters_only_paint_and_preserve_editor_state() { + previous_app := g_gg_app + previous_values := g_text_values.clone() + previous_editors := g_text_editors.clone() + previous_active_fields := g_active_fields.clone() + previous_focused := g_focused_field + previous_offsets := g_scroll_offsets.clone() + previous_sliders := g_slider_values.clone() + previous_specs := g_slider_specs.clone() + previous_active_sliders := g_active_sliders.clone() + defer { + free_owned_string(g_text_values['editor']) + free_owned_string(g_text_editors['editor'].text) + g_gg_app = previous_app + g_text_values = previous_values.clone() + g_text_editors = previous_editors.clone() + g_active_fields = previous_active_fields.clone() + g_focused_field = previous_focused + g_scroll_offsets = previous_offsets.clone() + g_slider_values = previous_sliders.clone() + g_slider_specs = previous_specs.clone() + g_active_sliders = previous_active_sliders.clone() + } + mut app := &GgApp{ scheduler: new_frame_coordinator(.on_demand) } + g_gg_app = app + g_text_values = map[string]string{} + g_text_editors = map[string]TextEditor{} + g_active_fields = { + 'editor': true + } + g_focused_field = 'editor' + g_scroll_offsets = { + 'editor': 42.0 + } + g_slider_values = { + 'volume': 0.0 + } + g_slider_specs = { + 'volume': SliderSpec{ min: 0, max: 100 } + } + g_active_sliders = { + 'volume': true + } + replace_text_value('editor', 'local draft') + replace_text_editor('editor', TextEditor{ + text: 'local draft'.clone() + selection: TextSelection{ anchor: 1, caret: 5 } + }) + scheduler_immediate_finish(mut app, 0) + set_slider_value('volume', 75) + assert text('editor') == 'local draft' + assert g_text_editors['editor'].selection == TextSelection{ anchor: 1, caret: 5 } + assert g_focused_field == 'editor' + assert scroll_offset('editor') == 42 + paint := scheduler_immediate_finish(mut app, 1) + assert paint.reasons == [.paint] + assert !paint.build && paint.draw + assert slider_value('volume') == 75 + set_text('editor', 'replacement') + assert text('editor') == 'replacement' + assert g_text_editors['editor'].selection == TextSelection{ anchor: 1, caret: 5 } + assert g_focused_field == 'editor' + assert scroll_offset('editor') == 42 + replacement := scheduler_immediate_finish(mut app, 2) + assert !replacement.build && replacement.reasons == [.paint] + set_text('missing', 'ignored') + set_slider_value('missing', 25) + if _ := app.scheduler.begin_frame(3) { + assert false, 'setters on unmounted controls must not request work' + } + } + + fn test_custom_scheduler_completion_callback_requests_final_model_build() { + previous_app := g_gg_app + previous_events := g_scheduler_immediate_events.clone() + runtime := g_animation_runtime + runtime.mutex.lock() + previous_callback := runtime.refresh_callback + previous_drive_frames := runtime.drive_frames + runtime.mutex.unlock() + defer { + reset_widget_animations() + configure_animation_driver(previous_callback, previous_drive_frames) + g_gg_app = previous_app + g_scheduler_immediate_events = previous_events.clone() + } + reset_widget_animations() + mut app := &GgApp{ scheduler: new_frame_coordinator(.on_demand) } + g_gg_app = app + g_scheduler_immediate_events = []string{} + configure_animation_driver(request_refresh, false) + root := screen(0xffffff, [view('animated', rect(0, 0, 40, 40), BoxStyle{}, [])]) + start_widget_animation_at('animated', animation(AnimationConfig{ + duration: 1 + x: 100 + on_event: scheduler_immediate_animation_event + }), 1_000, false) + initial := app.scheduler.begin_frame(1_000) or { panic('missing initial frame') } + apply_custom_widget_animations_at(root, 1_000) + app.scheduler.set_animation_active(custom_animations_need_frame(root)) + app.scheduler.finish_frame(initial) + terminal := app.scheduler.begin_frame(2_000) or { panic('missing terminal frame') } + assert terminal.build + assert g_scheduler_immediate_events.len == 0 + apply_custom_widget_animations_at(root, 2_000) + assert g_scheduler_immediate_events == ['completed'] + app.scheduler.set_animation_active(custom_animations_need_frame(root)) + assert !app.scheduler.stats().animation_active + app.scheduler.finish_frame(terminal) + // The model changed after the terminal frame's build. Its callback must + // request another build even though the animation no longer needs frames. + follow_up := app.scheduler.begin_frame(2_001) or { panic('completion lost its model update') } + assert follow_up.build + assert follow_up.reasons == [.build] + apply_custom_widget_animations_at(root, 2_001) + app.scheduler.finish_frame(follow_up) + assert g_scheduler_immediate_events == ['completed'] + if _ := app.scheduler.begin_frame(2_002) { + assert false, 'completion callback follow-up must return to idle' + } + } + + fn test_custom_scheduler_hidden_repeating_callbacks_cannot_keep_drawing() { + previous_app := g_gg_app + previous_events := g_scheduler_immediate_events.clone() + runtime := g_animation_runtime + runtime.mutex.lock() + previous_callback := runtime.refresh_callback + previous_drive_frames := runtime.drive_frames + runtime.mutex.unlock() + defer { + reset_widget_animations() + configure_animation_driver(previous_callback, previous_drive_frames) + g_gg_app = previous_app + g_scheduler_immediate_events = previous_events.clone() + } + reset_widget_animations() + mut app := &GgApp{ scheduler: new_frame_coordinator(.on_demand) } + g_gg_app = app + g_scheduler_immediate_events = []string{} + configure_animation_driver(request_refresh, false) + root := screen(0xffffff, [view('animated', rect(0, 0, 40, 40), BoxStyle{}, [])]) + start_widget_animation_at('animated', animation(AnimationConfig{ + duration: 1 + x: 100 + repeat: true + on_event: scheduler_immediate_animation_event + }), 1_000, false) + initial := app.scheduler.begin_frame(1_500) or { panic('missing initial frame') } + apply_custom_widget_animations_at(root, 1_500) + assert animation_info('animated').progress == 0.5 + app.scheduler.finish_frame(initial) + hidden := Element{ ...root, hidden: true } + hiding := app.scheduler.begin_frame(1_750) or { panic('missing callback follow-up') } + apply_custom_widget_animations_at(hidden, 1_750) + app.scheduler.set_animation_active(custom_animations_need_frame(hidden)) + app.scheduler.finish_frame(hiding) + assert animation_info('animated').status == .running + assert animation_info('animated').progress == 0.5 + if _ := app.scheduler.begin_frame(1_900) { + assert false, 'hidden progress callbacks must not invalidate forever' + } + // Showing the subtree samples the retained timeline again. + apply_custom_widget_animations_at(root, 2_750) + assert animation_info('animated').progress == 0.75 + assert custom_animations_need_frame(root) + } +} diff --git a/ui/ui_text_selection_immediate.c.v b/ui/ui_text_selection_immediate.c.v index fd79053..1c2d68d 100644 --- a/ui/ui_text_selection_immediate.c.v +++ b/ui/ui_text_selection_immediate.c.v @@ -1,6 +1,6 @@ module ui2 -$if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2_headless ? { +$if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && !ui2_headless ? { pub fn text_area_set_selection(id string, location int, length int) { if id !in g_active_fields || (g_text_kinds[id] or { Kind.screen }) != .text_area { return @@ -13,6 +13,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 end := utf16_offset_to_rune_index(value, selection.location + selection.length) editor.set_selection(start, end) replace_text_editor(id, editor) + invalidate_custom_paint() } pub fn text_area_set_caret(id string, pos int) { diff --git a/ui/ui_tooltip_immediate_test.v b/ui/ui_tooltip_immediate_test.v index b05bcf4..14d8bef 100644 --- a/ui/ui_tooltip_immediate_test.v +++ b/ui/ui_tooltip_immediate_test.v @@ -4,7 +4,6 @@ module ui2 $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2_headless ? { import gg - import time fn reset_tooltip_test() { g_tooltip = TooltipState{} @@ -171,11 +170,11 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 // The event handlers stamp moves with the real clock, so each new target is // settled at the current time before the rest is measured from it. fn settle_tooltip() { - update_tooltip(time.ticks()) + update_tooltip(renderer_now_ms()) } fn rest_tooltip() { - update_tooltip(time.ticks() + tooltip_delay_ms) + update_tooltip(renderer_now_ms() + tooltip_delay_ms) } fn test_tooltip_follows_window_events() { @@ -190,7 +189,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 // A key press closes it until the pointer moves to another target. on_event(&gg.Event{typ: .key_down, key_code: .a}, &GgApp{}) assert !g_tooltip.visible - update_tooltip(time.ticks() + 10 * tooltip_delay_ms) + update_tooltip(renderer_now_ms() + 10 * tooltip_delay_ms) assert !g_tooltip.visible on_event(&gg.Event{typ: .mouse_move, mouse_x: 10, mouse_y: 50}, &GgApp{}) settle_tooltip() @@ -215,7 +214,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 assert g_tooltip.visible // Leaving the window hides it. on_event(&gg.Event{typ: .mouse_leave}, &GgApp{}) - update_tooltip(time.ticks() + 10 * tooltip_delay_ms) + update_tooltip(renderer_now_ms() + 10 * tooltip_delay_ms) assert !g_tooltip.visible reset_tooltip_test() } @@ -223,7 +222,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 fn test_tooltip_is_held_back_while_a_dropdown_list_is_open() { reset_tooltip_test() g_tooltip_targets = [tooltip_target('button', 'Save', 0, 0, 100, 20)] - mut clock := time.ticks() + mut clock := renderer_now_ms() on_event(&gg.Event{typ: .mouse_move, mouse_x: 10, mouse_y: 10}, &GgApp{}) g_open_dropdown = 'country' update_tooltip(clock) diff --git a/ui/vml_compiled.v b/ui/vml_compiled.v index 0f9f5bd..20b5454 100644 --- a/ui/vml_compiled.v +++ b/ui/vml_compiled.v @@ -189,6 +189,8 @@ pub: height int = 800 min_width int min_height int + // Custom renderer only; native backends keep their refresh contract. + render_policy RenderPolicy = .continuous } @[heap] @@ -243,6 +245,9 @@ pub fn run_compiled_vml[T](config CompiledVmlRunConfig[T]) ! { validate_element_tree(controller.build(&controller.model))! mut runtime := compiled_vml_runtime() runtime.controller = voidptr(controller) + $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2_headless ? { + set_render_policy(config.render_policy) + } $if macos || windows || linux { run_window_with_min_size(config.title, config.width, config.height, config.min_width, config.min_height, compiled_vml_controller_build[T], compiled_vml_controller_handle[T]) diff --git a/ui/vml_imports.v b/ui/vml_imports.v index 0b7b353..2a71cfb 100644 --- a/ui/vml_imports.v +++ b/ui/vml_imports.v @@ -205,6 +205,8 @@ pub: title string = 'App' width int = 400 height int = 800 + // Custom renderer only; native backends keep their refresh contract. + render_policy RenderPolicy = .continuous } // run_vml_file owns one typed model for a file-backed VML window and resolves @@ -222,6 +224,9 @@ pub fn run_vml_file[T](config VmlFileRunConfig[T]) ! { } mut runtime := vml_runtime() runtime.controller = voidptr(controller) + $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2_headless ? { + set_render_policy(config.render_policy) + } $if macos || windows || linux { run_window(config.title, config.width, config.height, vml_controller_build[T], vml_controller_handle[T]) diff --git a/ui/vml_model.v b/ui/vml_model.v index 66df9dd..8590de8 100644 --- a/ui/vml_model.v +++ b/ui/vml_model.v @@ -1525,6 +1525,8 @@ pub: height int = 800 min_width int min_height int + // Custom renderer only; native backends keep their refresh contract. + render_policy RenderPolicy = .continuous } @[heap] @@ -1645,6 +1647,9 @@ pub fn run_vml[T](config VmlRunConfig[T]) ! { } mut runtime := vml_runtime() runtime.controller = voidptr(controller) + $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2_headless ? { + set_render_policy(config.render_policy) + } $if macos || windows || linux { run_window_with_min_size(config.title, config.width, config.height, config.min_width, config.min_height, vml_controller_build[T], vml_controller_handle[T]) From 1c49002a65e5c2e63b185a1a3452b7d999e4e548 Mon Sep 17 00:00:00 2001 From: tomaszbk Date: Fri, 9 Oct 2026 23:58:15 -0300 Subject: [PATCH 2/3] refactor: make custom rendering demand driven --- README.md | 8 +++-- docs/render-scheduling.md | 36 ++++++++++++++++++++++ tests/render_scheduler/main.v | 52 +++++++++++++------------------- ui/frame_scheduler.v | 32 +++----------------- ui/frame_scheduler_test.v | 41 +++++++------------------ ui/ui_immediate.c.v | 11 +------ ui/ui_scheduler_immediate_test.v | 16 +++++----- ui/vml_compiled.v | 21 +++++-------- ui/vml_imports.v | 33 +++++++++----------- ui/vml_model.v | 33 +++++++++----------- 10 files changed, 125 insertions(+), 158 deletions(-) create mode 100644 docs/render-scheduling.md diff --git a/README.md b/README.md index 38f9220..514724a 100644 --- a/README.md +++ b/README.md @@ -48,12 +48,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( diff --git a/docs/render-scheduling.md b/docs/render-scheduling.md new file mode 100644 index 0000000..14c5cc0 --- /dev/null +++ b/docs/render-scheduling.md @@ -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. diff --git a/tests/render_scheduler/main.v b/tests/render_scheduler/main.v index 15a3f1d..e9c8b7a 100644 --- a/tests/render_scheduler/main.v +++ b/tests/render_scheduler/main.v @@ -6,7 +6,6 @@ import time import ui2 __global g_acceptance_started = false -__global g_acceptance_policy = ui2.RenderPolicy.continuous __global g_acceptance_sample_seconds = 30 __global g_acceptance_interactive = false __global g_acceptance_message = 'Static scene — waiting for worker' @@ -19,7 +18,6 @@ __global g_acceptance_lifecycle = false fn main() { for i, arg in os.args { match arg { - '--on-demand' { g_acceptance_policy = .on_demand } '--interactive' { g_acceptance_interactive = true } '--lifecycle' { g_acceptance_lifecycle = true } '--seconds' { @@ -31,7 +29,6 @@ fn main() { else {} } } - ui2.set_render_policy(g_acceptance_policy) ui2.run_window('UI2 renderer scheduler acceptance', 640, 480, build, event) } @@ -40,7 +37,7 @@ fn build() ui2.Element { g_acceptance_started = true g_acceptance_dispatcher = ui2.ui_dispatcher() if !g_acceptance_interactive { - spawn acceptance(g_acceptance_dispatcher, g_acceptance_policy, g_acceptance_sample_seconds, + spawn acceptance(g_acceptance_dispatcher, g_acceptance_sample_seconds, g_acceptance_lifecycle) } else { spawn observe(g_acceptance_dispatcher) @@ -122,25 +119,21 @@ 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, selected_policy ui2.RenderPolicy, seconds int, lifecycle bool) { +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: policy=${selected_policy} seconds=${seconds} warmup=2 presentation_required=${before.presentation_required}') + 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 - if selected_policy == .on_demand { - assert after.builds == before.builds - if !before.presentation_required { - assert after.draws == before.draws - } - } else { - assert after.builds > before.builds - assert after.draws > before.draws + 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 @@ -151,12 +144,11 @@ fn acceptance(dispatcher ui2.UiDispatcher, selected_policy ui2.RenderPolicy, sec time.sleep(500 * time.millisecond) worker_after := dispatcher.stats() report('worker + burst + reentrant build', after, worker_after) - if selected_policy == .on_demand { - assert worker_after.builds - after.builds == 2 - if !after.presentation_required { - assert worker_after.draws - after.draws == 2 - } + 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' @@ -170,15 +162,14 @@ fn acceptance(dispatcher ui2.UiDispatcher, selected_policy ui2.RenderPolicy, sec time.sleep(time.second) idle := dispatcher.stats() report('idle after animation', settled, idle) - if selected_policy == .on_demand { - assert idle.builds == settled.builds - if !settled.presentation_required { - assert idle.draws == settled.draws - } + assert idle.builds == settled.builds + if !settled.presentation_required { + assert idle.draws == settled.draws } + if lifecycle { $if macos { - check_lifecycle(dispatcher, selected_policy) + check_lifecycle(dispatcher) } $else { println('SKIP: native lifecycle helper currently supports macOS only') } @@ -196,7 +187,7 @@ fn acceptance(dispatcher ui2.UiDispatcher, selected_policy ui2.RenderPolicy, sec }) } -fn check_lifecycle(dispatcher ui2.UiDispatcher, selected_policy ui2.RenderPolicy) { +fn check_lifecycle(dispatcher ui2.UiDispatcher) { assert dispatcher.post(fn () { assert schedule_real_window_lifecycle(2000) }) @@ -232,11 +223,10 @@ fn check_lifecycle(dispatcher ui2.UiDispatcher, selected_policy ui2.RenderPolicy time.sleep(500 * time.millisecond) idle := dispatcher.stats() report('idle after restore', settled, idle) - if selected_policy == .on_demand { - assert idle.builds == settled.builds - if !settled.presentation_required { - assert idle.draws == settled.draws - } + 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') } diff --git a/ui/frame_scheduler.v b/ui/frame_scheduler.v index 56de04e..2ea8401 100644 --- a/ui/frame_scheduler.v +++ b/ui/frame_scheduler.v @@ -2,13 +2,6 @@ module ui2 import sync -// RenderPolicy controls how the custom renderer schedules UI work. Continuous -// preserves applications that intentionally read changing state during build. -pub enum RenderPolicy { - continuous - on_demand -} - // RenderReason describes why a custom-renderer frame was requested. pub enum RenderReason { build @@ -56,7 +49,6 @@ struct FrameWork { struct FrameCoordinator { mutex &sync.Mutex = sync.new_mutex() mut: - policy RenderPolicy callbacks u64 builds u64 draws u64 @@ -76,24 +68,12 @@ mut: tasks []fn () } -fn new_frame_coordinator(policy RenderPolicy) &FrameCoordinator { - mut coordinator := &FrameCoordinator{ - policy: policy - } +fn new_frame_coordinator() &FrameCoordinator { + mut coordinator := &FrameCoordinator{} coordinator.invalidate(.surface) return coordinator } -fn (mut coordinator FrameCoordinator) set_policy(policy RenderPolicy) { - coordinator.mutex.lock() - defer { coordinator.mutex.unlock() } - if coordinator.closed || coordinator.policy == policy { - return - } - coordinator.policy = policy - coordinator.invalidate_locked(.build) -} - // Some platform loops present their swapchain after every callback, even when // UI2 issues no commands. Those backends must paint the retained declaration on // idle callbacks until the embedder can suppress presentation itself. @@ -142,12 +122,10 @@ fn (mut coordinator FrameCoordinator) begin_frame(now i64) ?FrameWork { if coordinator.animation_active { coordinator.invalidate_locked(.animation) } - if coordinator.policy == .on_demand && coordinator.pending_reasons.len == 0 - && !coordinator.presentation_required { + if coordinator.pending_reasons.len == 0 && !coordinator.presentation_required { return none } - reasons := if coordinator.policy == .on_demand && coordinator.pending_reasons.len == 0 - && coordinator.presentation_required { + reasons := if coordinator.pending_reasons.len == 0 && coordinator.presentation_required { // This is a platform presentation requirement, not a new invalidation. // Keep request/generation counters about actual application work. [RenderReason.presentation] @@ -157,7 +135,7 @@ fn (mut coordinator FrameCoordinator) begin_frame(now i64) ?FrameWork { coordinator.pending_reasons = []RenderReason{} coordinator.next_serial++ coordinator.active_serial = coordinator.next_serial - mut build := coordinator.policy == .continuous + mut build := false for reason in reasons { if reason in [.build, .surface, .animation, .worker] { build = true diff --git a/ui/frame_scheduler_test.v b/ui/frame_scheduler_test.v index 0a7b7ef..58044dc 100644 --- a/ui/frame_scheduler_test.v +++ b/ui/frame_scheduler_test.v @@ -13,7 +13,7 @@ fn finish_scheduler_test_frame(mut coordinator FrameCoordinator, now i64) FrameW } fn test_frame_scheduler_on_demand_idle_and_coalescing() { - mut coordinator := new_frame_coordinator(.on_demand) + mut coordinator := new_frame_coordinator() initial := finish_scheduler_test_frame(mut coordinator, 0) assert initial.build && initial.draw for now in 1 .. 30_001 { @@ -39,7 +39,7 @@ fn test_frame_scheduler_on_demand_idle_and_coalescing() { } fn test_frame_scheduler_invalidation_during_flush_survives() { - mut coordinator := new_frame_coordinator(.on_demand) + mut coordinator := new_frame_coordinator() work := coordinator.begin_frame(0) or { panic('expected initial frame') } coordinator.invalidate(.build) if _ := coordinator.begin_frame(1) { @@ -57,7 +57,7 @@ fn test_frame_scheduler_invalidation_during_flush_survives() { } fn test_frame_scheduler_paint_deadline_and_animation_return_to_idle() { - mut coordinator := new_frame_coordinator(.on_demand) + mut coordinator := new_frame_coordinator() finish_scheduler_test_frame(mut coordinator, 0) coordinator.invalidate(.paint) paint := finish_scheduler_test_frame(mut coordinator, 10) @@ -87,7 +87,7 @@ fn test_frame_scheduler_paint_deadline_and_animation_return_to_idle() { } fn test_frame_scheduler_posted_callbacks_are_outside_lock_and_next_drain() { - mut coordinator := new_frame_coordinator(.on_demand) + mut coordinator := new_frame_coordinator() dispatcher := UiDispatcher{ coordinator: coordinator } @@ -123,7 +123,7 @@ fn test_frame_scheduler_posted_callbacks_are_outside_lock_and_next_drain() { } fn test_frame_scheduler_suspend_resume_preserves_business_tasks_and_restores_surface() { - mut coordinator := new_frame_coordinator(.on_demand) + mut coordinator := new_frame_coordinator() finish_scheduler_test_frame(mut coordinator, 0) coordinator.set_deadline(500) coordinator.suspend() @@ -154,7 +154,7 @@ fn test_frame_scheduler_suspend_resume_preserves_business_tasks_and_restores_sur } fn test_frame_scheduler_does_not_deliver_tasks_during_an_active_flush() { - mut coordinator := new_frame_coordinator(.on_demand) + mut coordinator := new_frame_coordinator() finish_scheduler_test_frame(mut coordinator, 0) coordinator.invalidate(.paint) work := coordinator.begin_frame(1) or { panic('expected paint frame') } @@ -178,7 +178,7 @@ fn test_frame_scheduler_does_not_deliver_tasks_during_an_active_flush() { } fn test_frame_scheduler_close_cancels_queue_timers_and_rejects_old_handle() { - mut coordinator := new_frame_coordinator(.on_demand) + mut coordinator := new_frame_coordinator() dispatcher := UiDispatcher{ coordinator: coordinator } @@ -203,27 +203,8 @@ fn test_frame_scheduler_close_cancels_queue_timers_and_rejects_old_handle() { assert !UiDispatcher{}.post(fn () {}) } -fn test_frame_scheduler_continuous_default_and_policy_change() { - mut coordinator := new_frame_coordinator(.continuous) - coordinator.set_presentation_required(true) - for now in 0 .. 3 { - work := finish_scheduler_test_frame(mut coordinator, now) - assert work.build && work.draw - } - coordinator.set_presentation_required(false) - coordinator.set_policy(.on_demand) - finish_scheduler_test_frame(mut coordinator, 3) - if _ := coordinator.begin_frame(4) { - assert false, 'on-demand policy must become idle' - } - coordinator.set_policy(.continuous) - finish_scheduler_test_frame(mut coordinator, 5) - finish_scheduler_test_frame(mut coordinator, 6) - assert coordinator.stats().builds == 6 -} - fn test_frame_scheduler_required_presentation_reuses_tree_and_respects_lifecycle() { - mut coordinator := new_frame_coordinator(.on_demand) + mut coordinator := new_frame_coordinator() finish_scheduler_test_frame(mut coordinator, 0) coordinator.set_presentation_required(true) assert coordinator.stats().presentation_required @@ -263,7 +244,7 @@ fn test_frame_scheduler_required_presentation_reuses_tree_and_respects_lifecycle } fn test_frame_scheduler_worker_posts_are_synchronized() { - mut coordinator := new_frame_coordinator(.on_demand) + mut coordinator := new_frame_coordinator() dispatcher := UiDispatcher{ coordinator: coordinator } @@ -285,8 +266,8 @@ fn test_frame_scheduler_worker_posts_are_synchronized() { } fn test_frame_scheduler_contexts_keep_independent_lifetimes() { - mut first := new_frame_coordinator(.on_demand) - mut second := new_frame_coordinator(.on_demand) + mut first := new_frame_coordinator() + mut second := new_frame_coordinator() finish_scheduler_test_frame(mut first, 0) finish_scheduler_test_frame(mut second, 0) first.invalidate(.build) diff --git a/ui/ui_immediate.c.v b/ui/ui_immediate.c.v index 458600b..fd705e0 100644 --- a/ui/ui_immediate.c.v +++ b/ui/ui_immediate.c.v @@ -70,7 +70,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 struct GgApp { mut: ctx &gg.Context = unsafe { nil } - scheduler &FrameCoordinator = new_frame_coordinator(.continuous) + scheduler &FrameCoordinator = new_frame_coordinator() declared_root Element has_root bool iconified bool @@ -341,15 +341,6 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 g_gg_app.ctx.run() } - // set_render_policy selects custom-renderer scheduling. Continuous remains - // the default. Call on the UI thread, before run or while the window is open. - pub fn set_render_policy(policy RenderPolicy) { - if g_gg_app.scheduler.is_closed() { - g_gg_app = &GgApp{} - } - g_gg_app.scheduler.set_policy(policy) - } - pub fn render_stats() RenderStats { return g_gg_app.scheduler.stats() } diff --git a/ui/ui_scheduler_immediate_test.v b/ui/ui_scheduler_immediate_test.v index 64dd965..d6a25a8 100644 --- a/ui/ui_scheduler_immediate_test.v +++ b/ui/ui_scheduler_immediate_test.v @@ -2,7 +2,7 @@ @[has_globals] module ui2 -$if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && !ui2_headless ? { +$if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2_headless ? { import gg __global g_scheduler_immediate_events = []string{} @@ -36,7 +36,7 @@ $if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && g_event_handler = previous_handler g_scheduler_immediate_events = previous_events.clone() } - mut app := &GgApp{ scheduler: new_frame_coordinator(.on_demand) } + mut app := &GgApp{ scheduler: new_frame_coordinator() } g_gg_app = app g_event_handler = scheduler_immediate_event g_scheduler_immediate_events = []string{} @@ -46,7 +46,7 @@ $if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && down: true current_x: 20 current_y: 30 - pointer_target: HitTarget{ action_id: 'drag' } + pointer_target: HitTarget{ action_id: 'drag', draggable: true } } app.scheduler.set_deadline(500) on_event(&gg.Event{ typ: .iconified }, app) @@ -90,7 +90,7 @@ $if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && g_open_dropdown = previous_dropdown g_menu_open_path = previous_menu.clone() } - mut app := &GgApp{ scheduler: new_frame_coordinator(.on_demand) } + mut app := &GgApp{ scheduler: new_frame_coordinator() } g_gg_app = app g_tooltip = TooltipState{} g_touch = TouchState{} @@ -151,7 +151,7 @@ $if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && g_event_handler = previous_handler g_scheduler_immediate_events = previous_events.clone() } - mut app := &GgApp{ scheduler: new_frame_coordinator(.on_demand) } + mut app := &GgApp{ scheduler: new_frame_coordinator() } g_gg_app = app g_tooltip = TooltipState{} g_event_handler = scheduler_immediate_event @@ -225,7 +225,7 @@ $if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && g_slider_specs = previous_specs.clone() g_active_sliders = previous_active_sliders.clone() } - mut app := &GgApp{ scheduler: new_frame_coordinator(.on_demand) } + mut app := &GgApp{ scheduler: new_frame_coordinator() } g_gg_app = app g_text_values = map[string]string{} g_text_editors = map[string]TextEditor{} @@ -289,7 +289,7 @@ $if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && g_scheduler_immediate_events = previous_events.clone() } reset_widget_animations() - mut app := &GgApp{ scheduler: new_frame_coordinator(.on_demand) } + mut app := &GgApp{ scheduler: new_frame_coordinator() } g_gg_app = app g_scheduler_immediate_events = []string{} configure_animation_driver(request_refresh, false) @@ -339,7 +339,7 @@ $if ( android || linux || ( ( macos || windows ) && ui2_custom_rendering ?) ) && g_scheduler_immediate_events = previous_events.clone() } reset_widget_animations() - mut app := &GgApp{ scheduler: new_frame_coordinator(.on_demand) } + mut app := &GgApp{ scheduler: new_frame_coordinator() } g_gg_app = app g_scheduler_immediate_events = []string{} configure_animation_driver(request_refresh, false) diff --git a/ui/vml_compiled.v b/ui/vml_compiled.v index 20b5454..c718153 100644 --- a/ui/vml_compiled.v +++ b/ui/vml_compiled.v @@ -85,19 +85,19 @@ fn vml_decode_hex(value string) !string { fn vml_dispatch_compiled[T](mut model T, name string, arguments []string) ! { $for method in T.methods { if method.name == name { - $if method.is_pub && method.typ is fn ( ) { + $if method.is_pub && method.typ is fn() { if arguments.len != 0 { return error('app action `${name}` expects no arguments') } model.$method() return - } $else $if method.is_pub && method.typ is fn ( int ) { + } $else $if method.is_pub && method.typ is fn(int) { if arguments.len != 1 { return error('app action `${name}` expects one int argument') } model.$method(arguments[0].int()) return - } $else $if method.is_pub && method.typ is fn ( string ) { + } $else $if method.is_pub && method.typ is fn(string) { if arguments.len != 1 { return error('app action `${name}` expects one string argument') } @@ -182,15 +182,13 @@ pub fn handle_compiled_vml_event[T](mut model T, event_id string) !bool { // CompiledVmlRunConfig configures a window backed by a typed model and a `$vml()` build function. pub struct CompiledVmlRunConfig[T] { pub: - model T - build fn (&T) Element = unsafe { nil } - title string = 'App' - width int = 400 - height int = 800 + model T + build fn (&T) Element = unsafe { nil } + title string = 'App' + width int = 400 + height int = 800 min_width int min_height int - // Custom renderer only; native backends keep their refresh contract. - render_policy RenderPolicy = .continuous } @[heap] @@ -245,9 +243,6 @@ pub fn run_compiled_vml[T](config CompiledVmlRunConfig[T]) ! { validate_element_tree(controller.build(&controller.model))! mut runtime := compiled_vml_runtime() runtime.controller = voidptr(controller) - $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2_headless ? { - set_render_policy(config.render_policy) - } $if macos || windows || linux { run_window_with_min_size(config.title, config.width, config.height, config.min_width, config.min_height, compiled_vml_controller_build[T], compiled_vml_controller_handle[T]) diff --git a/ui/vml_imports.v b/ui/vml_imports.v index 2a71cfb..04fa245 100644 --- a/ui/vml_imports.v +++ b/ui/vml_imports.v @@ -36,8 +36,8 @@ fn (mut p Parser) parse_document() !VmlDocument { p.eat(.eof)! return VmlDocument{ module_name: module_name - imports: imports - root: root + imports: imports + root: root } } @@ -152,15 +152,15 @@ fn clone_vml_import_node(node &VNode) &VNode { children << clone_vml_import_node(child) } return &VNode{ - tag: node.tag - id: node.id - props: node.props.clone() - children: children - expressions: node.expressions.clone() + tag: node.tag + id: node.id + props: node.props.clone() + children: children + expressions: node.expressions.clone() property_types: node.property_types.clone() property_order: node.property_order.clone() - line: node.line - path: node.path + line: node.line + path: node.path } } @@ -191,7 +191,7 @@ pub fn new_vml_app_file[T](path string, model T) !&VmlApp[T] { validate_element_tree(element_from_vnode(resolved, probe)!)! return &VmlApp[T]{ template: template - model: model + model: model } } @@ -203,10 +203,8 @@ pub: source_path string model T title string = 'App' - width int = 400 - height int = 800 - // Custom renderer only; native backends keep their refresh contract. - render_policy RenderPolicy = .continuous + width int = 400 + height int = 800 } // run_vml_file owns one typed model for a file-backed VML window and resolves @@ -219,14 +217,11 @@ pub fn run_vml_file[T](config VmlFileRunConfig[T]) ! { validate_element_tree(element_from_vnode(resolved, initial_frame)!)! mut controller := &VmlController[T]{ template: template - model: config.model - events: events + model: config.model + events: events } mut runtime := vml_runtime() runtime.controller = voidptr(controller) - $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2_headless ? { - set_render_policy(config.render_policy) - } $if macos || windows || linux { run_window(config.title, config.width, config.height, vml_controller_build[T], vml_controller_handle[T]) diff --git a/ui/vml_model.v b/ui/vml_model.v index 8590de8..ad73369 100644 --- a/ui/vml_model.v +++ b/ui/vml_model.v @@ -1055,7 +1055,8 @@ fn v_eval_node(node &VNode, incoming_scope map[string]VValue, frame Rect, mut ev mut binding := ?VmlBinding(none) for key, expr in node.expressions { if key == 'id' || key in node.property_types || key.starts_with('bind.') - || key in ['on_tap', 'on_change', 'on_active', 'on_state', 'on_text', 'on_submit', 'on_text_validate', 'on_select', 'on_toggle', 'on_dismiss'] { + || key in ['on_tap', 'on_change', 'on_active', 'on_state', 'on_text', 'on_submit', + 'on_text_validate', 'on_select', 'on_toggle', 'on_dismiss'] { continue } resolved.props[key] = v_eval(expr, scope)!.string_value() @@ -1326,7 +1327,8 @@ fn v_validate_node_schema[T](node &VNode, incoming_scope map[string]VSchema) ! { } for key, expr in node.expressions { if key == 'id' || key in node.property_types || key.starts_with('bind.') - || key in ['on_tap', 'on_change', 'on_active', 'on_state', 'on_text', 'on_submit', 'on_text_validate', 'on_select', 'on_toggle', 'on_dismiss'] { + || key in ['on_tap', 'on_change', 'on_active', 'on_state', 'on_text', 'on_submit', + 'on_text_validate', 'on_select', 'on_toggle', 'on_dismiss'] { continue } v_schema_expression(expr, scope)! @@ -1457,17 +1459,17 @@ fn type_check_action[T](name string, args []VSchema, line int) ! { if method.name == name { $if !method.is_pub { return error('app action `${name}` is not public') - } $else $if method.typ is fn () { + } $else $if method.typ is fn() { if args.len != 0 { return error('app action `${name}` expects no arguments at line ${line}') } return - } $else $if method.typ is fn (int) { + } $else $if method.typ is fn(int) { if args.len != 1 || args[0].kind != .number { return error('app action `${name}` expects one int argument at line ${line}') } return - } $else $if method.typ is fn (string) { + } $else $if method.typ is fn(string) { if args.len != 1 || args[0].kind != .string_ { return error('app action `${name}` expects one string argument at line ${line}') } @@ -1495,13 +1497,13 @@ fn vml_dispatch[T](mut model T, invocation VmlInvocation) ! { } $for method in T.methods { if method.name == invocation.name { - $if method.is_pub && method.typ is fn () { + $if method.is_pub && method.typ is fn() { model.$method() return - } $else $if method.is_pub && method.typ is fn (int) { + } $else $if method.is_pub && method.typ is fn(int) { model.$method(int(args[0].numeric(invocation.line)!)) return - } $else $if method.is_pub && method.typ is fn (string) { + } $else $if method.is_pub && method.typ is fn(string) { model.$method(args[0].string_value()) return } @@ -1518,15 +1520,13 @@ fn vml_apply_assignment[T](mut model T, assignment VmlAssignment) ! { pub struct VmlRunConfig[T] { pub: - source string - model T - title string = 'App' - width int = 400 - height int = 800 + source string + model T + title string = 'App' + width int = 400 + height int = 800 min_width int min_height int - // Custom renderer only; native backends keep their refresh contract. - render_policy RenderPolicy = .continuous } @[heap] @@ -1647,9 +1647,6 @@ pub fn run_vml[T](config VmlRunConfig[T]) ! { } mut runtime := vml_runtime() runtime.controller = voidptr(controller) - $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2_headless ? { - set_render_policy(config.render_policy) - } $if macos || windows || linux { run_window_with_min_size(config.title, config.width, config.height, config.min_width, config.min_height, vml_controller_build[T], vml_controller_handle[T]) From 033e2d8a53884e2ae86353ed754e614cc74a6211 Mon Sep 17 00:00:00 2001 From: tomaszbk Date: Tue, 13 Oct 2026 23:31:09 -0300 Subject: [PATCH 3/3] Keep horizontal scrolling demand driven --- tests/render_scheduler/main.v | 16 +++++++++-- ui/ui_immediate.c.v | 9 ++++-- ui/ui_scheduler_immediate_test.v | 49 ++++++++++++++++++++++++++++++++ ui/vml_compiled.v | 16 +++++------ ui/vml_imports.v | 28 +++++++++--------- ui/vml_model.v | 28 +++++++++--------- 6 files changed, 105 insertions(+), 41 deletions(-) diff --git a/tests/render_scheduler/main.v b/tests/render_scheduler/main.v index e9c8b7a..d000245 100644 --- a/tests/render_scheduler/main.v +++ b/tests/render_scheduler/main.v @@ -155,8 +155,20 @@ fn acceptance(dispatcher ui2.UiDispatcher, seconds int, lifecycle bool) { g_acceptance_verify_reentrant = false start_animation() }) - time.sleep(1500 * time.millisecond) - settled := dispatcher.stats() + // 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) diff --git a/ui/ui_immediate.c.v b/ui/ui_immediate.c.v index 4265e53..e0251d2 100644 --- a/ui/ui_immediate.c.v +++ b/ui/ui_immediate.c.v @@ -619,6 +619,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 } } g_pending_scroll_x[id] = wanted + invalidate_custom_paint() } pub fn scroll_to_rect(id string, x f64, y f64, width f64, height f64) { @@ -1108,8 +1109,12 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 next := clamped_scroll_offset(requested, maximum) previous := g_scroll_offsets_x[id] or { 0.0 } g_scroll_offsets_x[id] = next - if next != previous && voidptr(g_scroll_handler) != unsafe { nil } { - g_scroll_handler(id) + if next != previous { + invalidate_custom_paint() + if voidptr(g_scroll_handler) != unsafe { nil } { + refresh() + g_scroll_handler(id) + } } } diff --git a/ui/ui_scheduler_immediate_test.v b/ui/ui_scheduler_immediate_test.v index d6a25a8..ea22197 100644 --- a/ui/ui_scheduler_immediate_test.v +++ b/ui/ui_scheduler_immediate_test.v @@ -23,6 +23,55 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 return work } + fn test_custom_scheduler_horizontal_scroll_paints_and_handlers_request_build() { + previous_app := g_gg_app + previous_offsets := g_scroll_offsets_x.clone() + previous_viewports := g_scroll_viewports.clone() + previous_widths := g_scroll_content_w.clone() + previous_pending := g_pending_scroll_x.clone() + previous_handler := g_scroll_handler + previous_events := g_scheduler_immediate_events.clone() + defer { + g_gg_app = previous_app + g_scroll_offsets_x = previous_offsets.clone() + g_scroll_viewports = previous_viewports.clone() + g_scroll_content_w = previous_widths.clone() + g_pending_scroll_x = previous_pending.clone() + g_scroll_handler = previous_handler + g_scheduler_immediate_events = previous_events.clone() + } + mut app := &GgApp{ scheduler: new_frame_coordinator() } + g_gg_app = app + g_scroll_offsets_x = map[string]f64{} + g_scroll_viewports = { 'pane': rect(0, 0, 100, 80) } + g_scroll_content_w = { 'pane': 400.0 } + g_pending_scroll_x = map[string]f64{} + g_scroll_handler = unsafe { nil } + g_scheduler_immediate_events = []string{} + scheduler_immediate_finish(mut app, 0) + scroll_to_horizontal_offset('pane', 120) + assert scroll_horizontal_offset('pane') == 120 + paint := scheduler_immediate_finish(mut app, 1) + assert paint.draw && !paint.build + assert paint.reasons == [.paint] + scroll_to_horizontal_offset('pane', 120) + if _ := app.scheduler.begin_frame(2) { + assert false, 'an unchanged scroll position must remain idle' + } + g_scroll_handler = scheduler_immediate_event + scroll_to_horizontal_offset('pane', 240) + assert g_scheduler_immediate_events == ['pane'] + build := scheduler_immediate_finish(mut app, 3) + assert build.draw && build.build + assert RenderReason.build in build.reasons + g_scroll_handler = unsafe { nil } + scroll_to_horizontal_offset('future', 75) + assert g_pending_scroll_x['future'] == 75 + pending := scheduler_immediate_finish(mut app, 4) + assert pending.draw && !pending.build + assert pending.reasons == [.paint] + } + fn test_custom_scheduler_lifecycle_restores_surface_after_all_suspensions_end() { previous_app := g_gg_app previous_touch := g_touch diff --git a/ui/vml_compiled.v b/ui/vml_compiled.v index c718153..0f9f5bd 100644 --- a/ui/vml_compiled.v +++ b/ui/vml_compiled.v @@ -85,19 +85,19 @@ fn vml_decode_hex(value string) !string { fn vml_dispatch_compiled[T](mut model T, name string, arguments []string) ! { $for method in T.methods { if method.name == name { - $if method.is_pub && method.typ is fn() { + $if method.is_pub && method.typ is fn ( ) { if arguments.len != 0 { return error('app action `${name}` expects no arguments') } model.$method() return - } $else $if method.is_pub && method.typ is fn(int) { + } $else $if method.is_pub && method.typ is fn ( int ) { if arguments.len != 1 { return error('app action `${name}` expects one int argument') } model.$method(arguments[0].int()) return - } $else $if method.is_pub && method.typ is fn(string) { + } $else $if method.is_pub && method.typ is fn ( string ) { if arguments.len != 1 { return error('app action `${name}` expects one string argument') } @@ -182,11 +182,11 @@ pub fn handle_compiled_vml_event[T](mut model T, event_id string) !bool { // CompiledVmlRunConfig configures a window backed by a typed model and a `$vml()` build function. pub struct CompiledVmlRunConfig[T] { pub: - model T - build fn (&T) Element = unsafe { nil } - title string = 'App' - width int = 400 - height int = 800 + model T + build fn (&T) Element = unsafe { nil } + title string = 'App' + width int = 400 + height int = 800 min_width int min_height int } diff --git a/ui/vml_imports.v b/ui/vml_imports.v index 04fa245..0b7b353 100644 --- a/ui/vml_imports.v +++ b/ui/vml_imports.v @@ -36,8 +36,8 @@ fn (mut p Parser) parse_document() !VmlDocument { p.eat(.eof)! return VmlDocument{ module_name: module_name - imports: imports - root: root + imports: imports + root: root } } @@ -152,15 +152,15 @@ fn clone_vml_import_node(node &VNode) &VNode { children << clone_vml_import_node(child) } return &VNode{ - tag: node.tag - id: node.id - props: node.props.clone() - children: children - expressions: node.expressions.clone() + tag: node.tag + id: node.id + props: node.props.clone() + children: children + expressions: node.expressions.clone() property_types: node.property_types.clone() property_order: node.property_order.clone() - line: node.line - path: node.path + line: node.line + path: node.path } } @@ -191,7 +191,7 @@ pub fn new_vml_app_file[T](path string, model T) !&VmlApp[T] { validate_element_tree(element_from_vnode(resolved, probe)!)! return &VmlApp[T]{ template: template - model: model + model: model } } @@ -203,8 +203,8 @@ pub: source_path string model T title string = 'App' - width int = 400 - height int = 800 + width int = 400 + height int = 800 } // run_vml_file owns one typed model for a file-backed VML window and resolves @@ -217,8 +217,8 @@ pub fn run_vml_file[T](config VmlFileRunConfig[T]) ! { validate_element_tree(element_from_vnode(resolved, initial_frame)!)! mut controller := &VmlController[T]{ template: template - model: config.model - events: events + model: config.model + events: events } mut runtime := vml_runtime() runtime.controller = voidptr(controller) diff --git a/ui/vml_model.v b/ui/vml_model.v index ad73369..66df9dd 100644 --- a/ui/vml_model.v +++ b/ui/vml_model.v @@ -1055,8 +1055,7 @@ fn v_eval_node(node &VNode, incoming_scope map[string]VValue, frame Rect, mut ev mut binding := ?VmlBinding(none) for key, expr in node.expressions { if key == 'id' || key in node.property_types || key.starts_with('bind.') - || key in ['on_tap', 'on_change', 'on_active', 'on_state', 'on_text', 'on_submit', - 'on_text_validate', 'on_select', 'on_toggle', 'on_dismiss'] { + || key in ['on_tap', 'on_change', 'on_active', 'on_state', 'on_text', 'on_submit', 'on_text_validate', 'on_select', 'on_toggle', 'on_dismiss'] { continue } resolved.props[key] = v_eval(expr, scope)!.string_value() @@ -1327,8 +1326,7 @@ fn v_validate_node_schema[T](node &VNode, incoming_scope map[string]VSchema) ! { } for key, expr in node.expressions { if key == 'id' || key in node.property_types || key.starts_with('bind.') - || key in ['on_tap', 'on_change', 'on_active', 'on_state', 'on_text', 'on_submit', - 'on_text_validate', 'on_select', 'on_toggle', 'on_dismiss'] { + || key in ['on_tap', 'on_change', 'on_active', 'on_state', 'on_text', 'on_submit', 'on_text_validate', 'on_select', 'on_toggle', 'on_dismiss'] { continue } v_schema_expression(expr, scope)! @@ -1459,17 +1457,17 @@ fn type_check_action[T](name string, args []VSchema, line int) ! { if method.name == name { $if !method.is_pub { return error('app action `${name}` is not public') - } $else $if method.typ is fn() { + } $else $if method.typ is fn () { if args.len != 0 { return error('app action `${name}` expects no arguments at line ${line}') } return - } $else $if method.typ is fn(int) { + } $else $if method.typ is fn (int) { if args.len != 1 || args[0].kind != .number { return error('app action `${name}` expects one int argument at line ${line}') } return - } $else $if method.typ is fn(string) { + } $else $if method.typ is fn (string) { if args.len != 1 || args[0].kind != .string_ { return error('app action `${name}` expects one string argument at line ${line}') } @@ -1497,13 +1495,13 @@ fn vml_dispatch[T](mut model T, invocation VmlInvocation) ! { } $for method in T.methods { if method.name == invocation.name { - $if method.is_pub && method.typ is fn() { + $if method.is_pub && method.typ is fn () { model.$method() return - } $else $if method.is_pub && method.typ is fn(int) { + } $else $if method.is_pub && method.typ is fn (int) { model.$method(int(args[0].numeric(invocation.line)!)) return - } $else $if method.is_pub && method.typ is fn(string) { + } $else $if method.is_pub && method.typ is fn (string) { model.$method(args[0].string_value()) return } @@ -1520,11 +1518,11 @@ fn vml_apply_assignment[T](mut model T, assignment VmlAssignment) ! { pub struct VmlRunConfig[T] { pub: - source string - model T - title string = 'App' - width int = 400 - height int = 800 + source string + model T + title string = 'App' + width int = 400 + height int = 800 min_width int min_height int }