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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ current native/local edit, selection, focus, and (on native controls) input
method composition. Use `set_text` for an explicit imperative replacement.

The custom renderer is demand driven: it builds on demand and reuses its tree
while idle, and `ui2.WindowOptions{refresh: .continuous}` restores the
historical per-vsync rebuild loop. See
while idle, `ui2.WindowOptions{refresh: .continuous}` restores the historical
per-vsync rebuild loop, and `refresh: .manual` suppresses all drawing between
explicit asks. See
[render scheduling](docs/render-scheduling.md) for invalidation, worker
delivery, window lifecycle and the acceptance fixture.

Expand Down
6 changes: 4 additions & 2 deletions docs/render-scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ tree and then updates it when application work invalidates the window, and 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. `.continuous` restores the historical
per-vsync rebuild loop:
per-vsync rebuild loop, and `.manual` goes further: the frame callback stays
suppressed between explicit `refresh`/`request_refresh` asks, with deadlines
and running animations waking their own continuation:

```v
ui2.run_window_with_options(ui2.WindowOptions{
title: 'App'
width: 640
height: 480
refresh: .continuous
refresh: .manual
}, build, event)
```

Expand Down
31 changes: 27 additions & 4 deletions ui/frame_scheduler.v
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ mut:
suspended bool
closed bool
tasks []FrameTask
// wake_fn is the loop wake for fully manual refresh modes: invalidate,
// deadlines and animation continuation call it so a suppressed frame
// callback still runs. It must only touch the platform loop's own
// synchronization; it runs under the coordinator mutex.
wake_fn fn ()
}

fn new_frame_coordinator() &FrameCoordinator {
Expand Down Expand Up @@ -99,15 +104,27 @@ fn (mut coordinator FrameCoordinator) invalidate_locked(reason RenderReason) {
}
coordinator.requests++
coordinator.generation++
if coordinator.pending_reasons.len > 0 {
coordinator.coalesced++
}
if reason !in coordinator.pending_reasons {
coordinator.pending_reasons << reason
// New work the loop may not know about: in fully manual refresh modes
// the frame callback stays suppressed between asks, so new pending work
// must wake it. Under platform-controlled loops the wake is a cheap
// redundant flag flip.
if coordinator.wake_fn != unsafe { nil } {
coordinator.wake_fn()
}
} else {
coordinator.coalesced++
}
}

// begin_frame takes a generation snapshot and detaches its reasons. Requests
// set_wake_fn installs the loop wake used by manual refresh modes. Pass nil to
// detach it.
pub fn (mut coordinator FrameCoordinator) set_wake_fn(wake fn ()) {
coordinator.mutex.lock()
defer { coordinator.mutex.unlock() }
coordinator.wake_fn = wake
}
// 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 {
Expand Down Expand Up @@ -192,6 +209,12 @@ fn (mut coordinator FrameCoordinator) set_animation_active(active bool) {
defer { coordinator.mutex.unlock() }
if !coordinator.closed {
coordinator.animation_active = active
// A running animation expects its next frame: in fully manual refresh
// modes the loop stays suppressed between asks, so continuation must
// wake it.
if active && coordinator.wake_fn != unsafe { nil } {
coordinator.wake_fn()
}
}
}

Expand Down
6 changes: 6 additions & 0 deletions ui/gl_surface.v
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,10 @@ pub fn set_gl_surface_texture(id string, gl_texture u32, width int, height int)
width: width
height: height
}
// The producer usually runs on its own thread, so without this ask the new
// texture would wait for unrelated work: on GL/D3D the loop re-presents the
// retained tree each vsync and would pick it up anyway, but on Metal an
// idle window draws nothing and the update would never reach the screen.
// The scheduler's queue is mutex guarded, so this is safe off the UI thread.
request_refresh()
}
43 changes: 38 additions & 5 deletions ui/ui_immediate.c.v
Original file line number Diff line number Diff line change
Expand Up @@ -368,9 +368,16 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2
// continuous cost. An application whose frames come from outside the window —
// a video surface fed by a player, for example — calls request_refresh from
// the player thread whenever new content is ready.
//
// manual suppresses the frame callback between explicit asks, like on_demand
// did before the frame scheduler took over: refresh and request_refresh wake
// the loop for exactly one coalesced frame, deadlines and running animations
// wake their own continuation, and nothing else draws — no per-vsync present,
// no keep-alive redraw.
pub enum RefreshMode {
continuous
on_demand
manual
}

// WindowOptions carries the window creation knobs run_window_with_options
Expand Down Expand Up @@ -400,6 +407,10 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2
g_gg_app = &GgApp{}
}
g_refresh_mode = opts.refresh
// In manual mode gg suppresses the frame callback between asks, so the
// scheduler's wake hook revives it whenever new work, a running
// animation or an expired deadline needs a frame.
g_gg_app.scheduler.set_wake_fn(wake_ui_loop)
g_build_screen = build_fn
g_event_handler = event_fn
// In on_demand mode the loop sleeps between asks, so a running
Expand All @@ -424,15 +435,16 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2
min_width: opts.min_width
min_height: opts.min_height
sample_count: opts.sample_count
ui_mode: opts.refresh == .on_demand
// Manual mode suppresses the frame callback between asks; the
// scheduler's wake hook revives it. The other modes keep the
// callback flowing and gate builds/draws in the coordinator —
// gg's ui_mode would suppress it before it can drain worker
// messages or deadlines, and refresh_ui is not thread safe.
ui_mode: opts.refresh == .manual
create_window: true
window_title: opts.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
Expand Down Expand Up @@ -478,6 +490,17 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2
return UiDispatcher{coordinator: g_gg_app.scheduler}
}

// wake_ui_loop hands gg's context a redraw ask. refresh_ui flips a boolean the
// frame thread reads, so calling it off the UI thread is deliberate and safe;
// libmpv's update callback, the scheduler's wake hook and the timer threads
// land here.
fn wake_ui_loop() {
if g_gg_app.ctx == unsafe { nil } {
return
}
g_gg_app.ctx.refresh_ui()
}

// refresh asks for a rebuild over whatever the application changed outside a
// frame. In on_demand mode the scheduler turns the ask into exactly one
// coalesced frame; the continuous loop was already drawing regardless.
Expand Down Expand Up @@ -944,6 +967,16 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2
work := app.scheduler.begin_frame(now) or { return }
defer { app.scheduler.finish_frame(work) }
ensure_symbol_fallbacks(ctx)
// Manual mode suppresses the frame callback between asks, so a pending
// visual deadline needs its own wake: schedule one one-shot timer that
// lands on the deadline. The other modes re-check the deadline from the
// flowing frame callback, and only long-press and tooltip rests set
// deadlines, so this stays rare.
if g_refresh_mode == .manual {
if deadline := app.scheduler.stats().next_deadline; deadline >= 0 {
after(f64(deadline-now) / 1000.0, fn () {})
}
}
if !text_measurement_exact() && ctx.font_inited {
// Text is measured from the first frame with fonts and their symbol
// fallbacks in place; measured earlier, a glyph only a fallback face
Expand Down
Loading