diff --git a/README.md b/README.md index 3764eab..22b935f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/render-scheduling.md b/docs/render-scheduling.md index ceecf5c..1796168 100644 --- a/docs/render-scheduling.md +++ b/docs/render-scheduling.md @@ -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) ``` diff --git a/ui/frame_scheduler.v b/ui/frame_scheduler.v index ceccdcd..a820f48 100644 --- a/ui/frame_scheduler.v +++ b/ui/frame_scheduler.v @@ -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 { @@ -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 { @@ -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() + } } } diff --git a/ui/gl_surface.v b/ui/gl_surface.v index 433e14a..1a86114 100644 --- a/ui/gl_surface.v +++ b/ui/gl_surface.v @@ -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() } diff --git a/ui/ui_immediate.c.v b/ui/ui_immediate.c.v index ab6f582..f482715 100644 --- a/ui/ui_immediate.c.v +++ b/ui/ui_immediate.c.v @@ -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 @@ -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 @@ -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 @@ -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. @@ -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