feat(core): Add MainLoop helper for optimized application loops
MainLoop encapsulates the CPU optimization patterns:
- Progressive sleep (8ms → 33ms → SDL_WaitEventTimeout)
- Automatic event handling
- Dirty region support
- Configurable timing parameters
Usage:
```zig
const MyApp = struct {
pub fn handleEvent(self: *MyApp, event: Event, ctx: *Context) void { ... }
pub fn update(self: *MyApp, ctx: *Context) bool { return changed; }
pub fn draw(self: *MyApp, ctx: *Context, renderer: *Renderer) void { ... }
};
var loop = try MainLoop.init(allocator, 800, 600);
loop.run(backend, &app);
```
This moves CPU optimization from application code to library level,
making it easy for all zcatgui apps to achieve 0% CPU in idle.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3c0daa9644
commit
80b5d99bfd
2 changed files with 346 additions and 0 deletions
343
src/core/mainloop.zig
Normal file
343
src/core/mainloop.zig
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
//! MainLoop - Optimized main loop helper for zcatgui applications
|
||||
//!
|
||||
//! This module provides a main loop implementation with:
|
||||
//! - Automatic CPU optimization (progressive sleep → SDL_WaitEvent)
|
||||
//! - Dirty region tracking for minimal redraws
|
||||
//! - Event handling abstraction
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```zig
|
||||
//! const zcatgui = @import("zcatgui");
|
||||
//!
|
||||
//! const MyApp = struct {
|
||||
//! selected_row: usize = 0,
|
||||
//! // ... app state
|
||||
//!
|
||||
//! pub fn handleEvent(self: *MyApp, event: Event, ctx: *Context) void {
|
||||
//! // Handle application-specific events
|
||||
//! switch (event) {
|
||||
//! .key => |k| if (k.key == .down and k.pressed) self.selected_row += 1,
|
||||
//! else => {},
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! pub fn update(self: *MyApp, ctx: *Context) bool {
|
||||
//! // Return true if state changed (needs redraw)
|
||||
//! const changed = self.prev_row != self.selected_row;
|
||||
//! self.prev_row = self.selected_row;
|
||||
//! return changed;
|
||||
//! }
|
||||
//!
|
||||
//! pub fn draw(self: *MyApp, ctx: *Context, renderer: *SoftwareRenderer) void {
|
||||
//! renderer.clear(Color.background);
|
||||
//! // Draw UI
|
||||
//! }
|
||||
//! };
|
||||
//!
|
||||
//! pub fn main() !void {
|
||||
//! var app = MyApp{};
|
||||
//! var loop = try MainLoop.init(allocator, backend, 800, 600);
|
||||
//! defer loop.deinit();
|
||||
//!
|
||||
//! loop.run(&app);
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const Context = @import("context.zig").Context;
|
||||
const Input = @import("input.zig");
|
||||
const Layout = @import("layout.zig");
|
||||
const Style = @import("style.zig");
|
||||
const Backend = @import("../backend/backend.zig");
|
||||
const Framebuffer = @import("../render/framebuffer.zig").Framebuffer;
|
||||
const SoftwareRenderer = @import("../render/software.zig").SoftwareRenderer;
|
||||
|
||||
const Color = Style.Color;
|
||||
const Event = Backend.Event;
|
||||
|
||||
/// Configuration for the main loop
|
||||
pub const MainLoopConfig = struct {
|
||||
/// Background color for clearing
|
||||
clear_color: Color = Color.background,
|
||||
|
||||
/// Frames before entering medium sleep (33ms)
|
||||
idle_frames_medium: u32 = 3,
|
||||
|
||||
/// Frames before entering deep sleep (SDL_WaitEvent)
|
||||
idle_frames_deep: u32 = 10,
|
||||
|
||||
/// Sleep time for initial idle (ms)
|
||||
sleep_initial_ms: u32 = 8,
|
||||
|
||||
/// Sleep time for medium idle (ms)
|
||||
sleep_medium_ms: u32 = 33,
|
||||
|
||||
/// Timeout for deep idle SDL_WaitEventTimeout (ms)
|
||||
wait_timeout_ms: u32 = 100,
|
||||
|
||||
/// Enable debug timing output
|
||||
debug_timing: bool = false,
|
||||
};
|
||||
|
||||
/// Main loop state and runner
|
||||
pub const MainLoop = struct {
|
||||
allocator: Allocator,
|
||||
ctx: Context,
|
||||
framebuffer: Framebuffer,
|
||||
renderer: SoftwareRenderer,
|
||||
config: MainLoopConfig,
|
||||
|
||||
// Internal state
|
||||
running: bool,
|
||||
idle_frames: u32,
|
||||
frame_count: u64,
|
||||
needs_redraw: bool,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
/// Initialize the main loop with a backend
|
||||
pub fn init(allocator: Allocator, width: u32, height: u32) !Self {
|
||||
var ctx = try Context.init(allocator, width, height);
|
||||
errdefer ctx.deinit();
|
||||
|
||||
var framebuffer = try Framebuffer.init(allocator, width, height);
|
||||
errdefer framebuffer.deinit();
|
||||
|
||||
const renderer = SoftwareRenderer.init(&framebuffer);
|
||||
|
||||
return Self{
|
||||
.allocator = allocator,
|
||||
.ctx = ctx,
|
||||
.framebuffer = framebuffer,
|
||||
.renderer = renderer,
|
||||
.config = .{},
|
||||
.running = true,
|
||||
.idle_frames = 0,
|
||||
.frame_count = 0,
|
||||
.needs_redraw = true,
|
||||
};
|
||||
}
|
||||
|
||||
/// Initialize with custom configuration
|
||||
pub fn initWithConfig(allocator: Allocator, width: u32, height: u32, config: MainLoopConfig) !Self {
|
||||
var self = try init(allocator, width, height);
|
||||
self.config = config;
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Clean up resources
|
||||
pub fn deinit(self: *Self) void {
|
||||
self.framebuffer.deinit();
|
||||
self.ctx.deinit();
|
||||
}
|
||||
|
||||
/// Request a redraw on the next frame
|
||||
pub fn requestRedraw(self: *Self) void {
|
||||
self.needs_redraw = true;
|
||||
self.idle_frames = 0;
|
||||
}
|
||||
|
||||
/// Stop the main loop
|
||||
pub fn stop(self: *Self) void {
|
||||
self.running = false;
|
||||
}
|
||||
|
||||
/// Handle window resize
|
||||
pub fn handleResize(self: *Self, width: u32, height: u32) !void {
|
||||
try self.framebuffer.resize(width, height);
|
||||
self.ctx.resize(width, height);
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// Run the main loop with an application that implements the App interface
|
||||
/// App must have: handleEvent, update, draw
|
||||
pub fn run(self: *Self, backend: anytype, app: anytype) void {
|
||||
self.running = true;
|
||||
|
||||
while (self.running) {
|
||||
self.processFrame(backend, app);
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a single frame (useful for custom loops)
|
||||
pub fn processFrame(self: *Self, backend: anytype, app: anytype) void {
|
||||
// Phase 1: Event polling
|
||||
var had_input = false;
|
||||
while (backend.pollEvent()) |event| {
|
||||
had_input = true;
|
||||
self.handleCoreEvent(event, backend) catch {};
|
||||
|
||||
// Let app handle event
|
||||
if (@hasDecl(@TypeOf(app.*), "handleEvent")) {
|
||||
app.handleEvent(event, &self.ctx);
|
||||
}
|
||||
}
|
||||
|
||||
if (had_input) {
|
||||
self.needs_redraw = true;
|
||||
self.idle_frames = 0;
|
||||
}
|
||||
|
||||
// Phase 2: App update - check if state changed
|
||||
if (@hasDecl(@TypeOf(app.*), "update")) {
|
||||
if (app.update(&self.ctx)) {
|
||||
self.needs_redraw = true;
|
||||
self.idle_frames = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Idle handling with progressive sleep
|
||||
if (!self.needs_redraw) {
|
||||
self.idle_frames += 1;
|
||||
self.handleIdleSleep(backend);
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 4: Render
|
||||
self.idle_frames = 0;
|
||||
self.needs_redraw = false;
|
||||
self.frame_count += 1;
|
||||
|
||||
const start_time = if (self.config.debug_timing) std.time.nanoTimestamp() else 0;
|
||||
|
||||
self.ctx.beginFrame();
|
||||
self.renderer.clear(self.config.clear_color);
|
||||
|
||||
// Let app draw
|
||||
if (@hasDecl(@TypeOf(app.*), "draw")) {
|
||||
app.draw(&self.ctx, &self.renderer);
|
||||
}
|
||||
|
||||
// Execute draw commands
|
||||
self.renderer.executeAll(self.ctx.commands.items);
|
||||
|
||||
self.ctx.endFrame();
|
||||
|
||||
// Present to backend
|
||||
backend.present(&self.framebuffer);
|
||||
|
||||
if (self.config.debug_timing) {
|
||||
const elapsed_ns = std.time.nanoTimestamp() - start_time;
|
||||
const elapsed_ms = @as(f64, @floatFromInt(elapsed_ns)) / 1_000_000.0;
|
||||
std.debug.print("Frame {}: {} cmds, {d:.1}ms\n", .{
|
||||
self.frame_count,
|
||||
self.ctx.commands.items.len,
|
||||
elapsed_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle core events (quit, resize, input forwarding)
|
||||
fn handleCoreEvent(self: *Self, event: Event, backend: anytype) !void {
|
||||
_ = backend;
|
||||
switch (event) {
|
||||
.quit => self.running = false,
|
||||
.key => |key| {
|
||||
self.ctx.input.handleKeyEvent(key);
|
||||
// Escape to quit by default
|
||||
if (key.key == .escape and key.pressed) {
|
||||
self.running = false;
|
||||
}
|
||||
},
|
||||
.mouse => |m| {
|
||||
self.ctx.input.setMousePos(m.x, m.y);
|
||||
if (m.button) |btn| {
|
||||
self.ctx.input.setMouseButton(btn, m.pressed);
|
||||
}
|
||||
},
|
||||
.resize => |size| {
|
||||
self.handleResize(size.width, size.height) catch {};
|
||||
},
|
||||
.text_input => |_| {
|
||||
self.needs_redraw = true;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle idle sleep with progressive strategy
|
||||
fn handleIdleSleep(self: *Self, backend: anytype) void {
|
||||
if (self.idle_frames > self.config.idle_frames_deep) {
|
||||
// Deep idle: Use SDL_WaitEventTimeout (0% CPU)
|
||||
if (backend.waitEventTimeout(self.config.wait_timeout_ms)) |event| {
|
||||
self.handleCoreEvent(event, backend) catch {};
|
||||
self.needs_redraw = true;
|
||||
self.idle_frames = 0;
|
||||
}
|
||||
} else if (self.idle_frames > self.config.idle_frames_medium) {
|
||||
// Medium idle: 33ms sleep (~30 FPS)
|
||||
std.Thread.sleep(self.config.sleep_medium_ms * std.time.ns_per_ms);
|
||||
} else {
|
||||
// Initial idle: 8ms sleep (~120 FPS response)
|
||||
std.Thread.sleep(self.config.sleep_initial_ms * std.time.ns_per_ms);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Accessors
|
||||
// =========================================================================
|
||||
|
||||
/// Get the context for widget drawing
|
||||
pub fn getContext(self: *Self) *Context {
|
||||
return &self.ctx;
|
||||
}
|
||||
|
||||
/// Get the renderer
|
||||
pub fn getRenderer(self: *Self) *SoftwareRenderer {
|
||||
return &self.renderer;
|
||||
}
|
||||
|
||||
/// Get the framebuffer
|
||||
pub fn getFramebuffer(self: *Self) *Framebuffer {
|
||||
return &self.framebuffer;
|
||||
}
|
||||
|
||||
/// Check if running
|
||||
pub fn isRunning(self: Self) bool {
|
||||
return self.running;
|
||||
}
|
||||
|
||||
/// Get frame count
|
||||
pub fn getFrameCount(self: Self) u64 {
|
||||
return self.frame_count;
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
test "MainLoop init" {
|
||||
var loop = try MainLoop.init(std.testing.allocator, 800, 600);
|
||||
defer loop.deinit();
|
||||
|
||||
try std.testing.expect(loop.running);
|
||||
try std.testing.expectEqual(@as(u32, 0), loop.idle_frames);
|
||||
try std.testing.expectEqual(@as(u64, 0), loop.frame_count);
|
||||
}
|
||||
|
||||
test "MainLoop config" {
|
||||
var loop = try MainLoop.initWithConfig(std.testing.allocator, 800, 600, .{
|
||||
.debug_timing = true,
|
||||
.idle_frames_deep = 20,
|
||||
});
|
||||
defer loop.deinit();
|
||||
|
||||
try std.testing.expect(loop.config.debug_timing);
|
||||
try std.testing.expectEqual(@as(u32, 20), loop.config.idle_frames_deep);
|
||||
}
|
||||
|
||||
test "MainLoop request redraw" {
|
||||
var loop = try MainLoop.init(std.testing.allocator, 800, 600);
|
||||
defer loop.deinit();
|
||||
|
||||
loop.idle_frames = 100;
|
||||
loop.needs_redraw = false;
|
||||
|
||||
loop.requestRedraw();
|
||||
|
||||
try std.testing.expect(loop.needs_redraw);
|
||||
try std.testing.expectEqual(@as(u32, 0), loop.idle_frames);
|
||||
}
|
||||
|
|
@ -39,6 +39,9 @@ pub const Style = @import("core/style.zig");
|
|||
pub const Input = @import("core/input.zig");
|
||||
pub const Command = @import("core/command.zig");
|
||||
pub const clipboard = @import("core/clipboard.zig");
|
||||
pub const mainloop = @import("core/mainloop.zig");
|
||||
pub const MainLoop = mainloop.MainLoop;
|
||||
pub const MainLoopConfig = mainloop.MainLoopConfig;
|
||||
pub const dragdrop = @import("core/dragdrop.zig");
|
||||
pub const DragDropManager = dragdrop.DragDropManager;
|
||||
pub const DragData = dragdrop.DragData;
|
||||
|
|
|
|||
Loading…
Reference in a new issue