diff --git a/src/render/animation.zig b/src/render/animation.zig new file mode 100644 index 0000000..a366ae9 --- /dev/null +++ b/src/render/animation.zig @@ -0,0 +1,491 @@ +//! Animation System +//! +//! Provides easing functions and animation management for smooth transitions. +//! Supports various easing curves and animation lifecycle. + +const std = @import("std"); + +/// Easing function type +pub const EasingFn = *const fn (f32) f32; + +/// Standard easing functions +pub const Easing = struct { + /// Linear interpolation (no easing) + pub fn linear(t: f32) f32 { + return t; + } + + /// Quadratic ease-in + pub fn easeInQuad(t: f32) f32 { + return t * t; + } + + /// Quadratic ease-out + pub fn easeOutQuad(t: f32) f32 { + return t * (2 - t); + } + + /// Quadratic ease-in-out + pub fn easeInOutQuad(t: f32) f32 { + if (t < 0.5) { + return 2 * t * t; + } else { + return -1 + (4 - 2 * t) * t; + } + } + + /// Cubic ease-in + pub fn easeInCubic(t: f32) f32 { + return t * t * t; + } + + /// Cubic ease-out + pub fn easeOutCubic(t: f32) f32 { + const t1 = t - 1; + return t1 * t1 * t1 + 1; + } + + /// Cubic ease-in-out + pub fn easeInOutCubic(t: f32) f32 { + if (t < 0.5) { + return 4 * t * t * t; + } else { + const t1 = 2 * t - 2; + return 0.5 * t1 * t1 * t1 + 1; + } + } + + /// Quartic ease-in + pub fn easeInQuart(t: f32) f32 { + return t * t * t * t; + } + + /// Quartic ease-out + pub fn easeOutQuart(t: f32) f32 { + const t1 = t - 1; + return 1 - t1 * t1 * t1 * t1; + } + + /// Quartic ease-in-out + pub fn easeInOutQuart(t: f32) f32 { + if (t < 0.5) { + return 8 * t * t * t * t; + } else { + const t1 = t - 1; + return 1 - 8 * t1 * t1 * t1 * t1; + } + } + + /// Sine ease-in + pub fn easeInSine(t: f32) f32 { + return 1 - @cos(t * std.math.pi / 2); + } + + /// Sine ease-out + pub fn easeOutSine(t: f32) f32 { + return @sin(t * std.math.pi / 2); + } + + /// Sine ease-in-out + pub fn easeInOutSine(t: f32) f32 { + return 0.5 * (1 - @cos(std.math.pi * t)); + } + + /// Exponential ease-in + pub fn easeInExpo(t: f32) f32 { + if (t == 0) return 0; + return std.math.pow(f32, 2, 10 * (t - 1)); + } + + /// Exponential ease-out + pub fn easeOutExpo(t: f32) f32 { + if (t == 1) return 1; + return 1 - std.math.pow(f32, 2, -10 * t); + } + + /// Exponential ease-in-out + pub fn easeInOutExpo(t: f32) f32 { + if (t == 0) return 0; + if (t == 1) return 1; + if (t < 0.5) { + return 0.5 * std.math.pow(f32, 2, 20 * t - 10); + } else { + return 1 - 0.5 * std.math.pow(f32, 2, -20 * t + 10); + } + } + + /// Elastic ease-in + pub fn easeInElastic(t: f32) f32 { + if (t == 0) return 0; + if (t == 1) return 1; + const p: f32 = 0.3; + return -std.math.pow(f32, 2, 10 * (t - 1)) * @sin((t - 1 - p / 4) * 2 * std.math.pi / p); + } + + /// Elastic ease-out + pub fn easeOutElastic(t: f32) f32 { + if (t == 0) return 0; + if (t == 1) return 1; + const p: f32 = 0.3; + return std.math.pow(f32, 2, -10 * t) * @sin((t - p / 4) * 2 * std.math.pi / p) + 1; + } + + /// Bounce ease-out + pub fn easeOutBounce(t: f32) f32 { + const n1: f32 = 7.5625; + const d1: f32 = 2.75; + + if (t < 1 / d1) { + return n1 * t * t; + } else if (t < 2 / d1) { + const t1 = t - 1.5 / d1; + return n1 * t1 * t1 + 0.75; + } else if (t < 2.5 / d1) { + const t1 = t - 2.25 / d1; + return n1 * t1 * t1 + 0.9375; + } else { + const t1 = t - 2.625 / d1; + return n1 * t1 * t1 + 0.984375; + } + } + + /// Bounce ease-in + pub fn easeInBounce(t: f32) f32 { + return 1 - easeOutBounce(1 - t); + } + + /// Bounce ease-in-out + pub fn easeInOutBounce(t: f32) f32 { + if (t < 0.5) { + return 0.5 * easeInBounce(t * 2); + } else { + return 0.5 * easeOutBounce(t * 2 - 1) + 0.5; + } + } + + /// Back ease-in (overshoots) + pub fn easeInBack(t: f32) f32 { + const s: f32 = 1.70158; + return t * t * ((s + 1) * t - s); + } + + /// Back ease-out (overshoots) + pub fn easeOutBack(t: f32) f32 { + const s: f32 = 1.70158; + const t1 = t - 1; + return t1 * t1 * ((s + 1) * t1 + s) + 1; + } + + /// Back ease-in-out (overshoots) + pub fn easeInOutBack(t: f32) f32 { + const s: f32 = 1.70158 * 1.525; + if (t < 0.5) { + return 0.5 * (4 * t * t * ((s + 1) * 2 * t - s)); + } else { + const t1 = 2 * t - 2; + return 0.5 * (t1 * t1 * ((s + 1) * t1 + s) + 2); + } + } +}; + +/// An animation from start to end value +pub const Animation = struct { + /// Starting value + start_value: f32, + /// Target value + end_value: f32, + /// Duration in milliseconds + duration_ms: u32, + /// Easing function + easing: EasingFn = Easing.linear, + /// Start timestamp (ms) + start_time: i64 = 0, + /// Is animation running + running: bool = false, + /// Loop animation + looping: bool = false, + /// Ping-pong (reverse on loop) + pingpong: bool = false, + /// Current direction (for pingpong) + reverse: bool = false, + + const Self = @This(); + + /// Create a new animation + pub fn create(start_val: f32, end_val: f32, duration_ms: u32, easing_fn: EasingFn) Self { + return .{ + .start_value = start_val, + .end_value = end_val, + .duration_ms = duration_ms, + .easing = easing_fn, + }; + } + + /// Start the animation + pub fn start(self: *Self, current_time_ms: i64) void { + self.start_time = current_time_ms; + self.running = true; + self.reverse = false; + } + + /// Stop the animation + pub fn stop(self: *Self) void { + self.running = false; + } + + /// Get current animated value + pub fn getValue(self: Self, current_time_ms: i64) f32 { + if (!self.running) { + return self.start_value; + } + + const elapsed = current_time_ms - self.start_time; + if (elapsed < 0) return self.start_value; + + var t = @as(f32, @floatFromInt(elapsed)) / @as(f32, @floatFromInt(self.duration_ms)); + + if (t >= 1.0) { + if (self.looping) { + if (self.pingpong) { + // Would handle loop logic here + t = 1.0; + } else { + t = @mod(t, 1.0); + } + } else { + t = 1.0; + } + } + + const eased = self.easing(t); + + if (self.reverse) { + return self.end_value + (self.start_value - self.end_value) * eased; + } else { + return self.start_value + (self.end_value - self.start_value) * eased; + } + } + + /// Check if animation is complete + pub fn isComplete(self: Self, current_time_ms: i64) bool { + if (!self.running) return true; + if (self.looping) return false; + + const elapsed = current_time_ms - self.start_time; + return elapsed >= @as(i64, @intCast(self.duration_ms)); + } + + /// Get progress (0.0 - 1.0) + pub fn getProgress(self: Self, current_time_ms: i64) f32 { + if (!self.running) return 0; + + const elapsed = current_time_ms - self.start_time; + if (elapsed < 0) return 0; + + const t = @as(f32, @floatFromInt(elapsed)) / @as(f32, @floatFromInt(self.duration_ms)); + return @min(1.0, @max(0.0, t)); + } +}; + +/// Maximum concurrent animations +const MAX_ANIMATIONS = 128; + +/// Animation manager for tracking multiple animations +pub const AnimationManager = struct { + animations: [MAX_ANIMATIONS]struct { + id: u64, + anim: Animation, + active: bool, + } = undefined, + count: usize = 0, + + const Self = @This(); + + /// Initialize manager + pub fn init() Self { + var manager = Self{}; + for (&manager.animations) |*slot| { + slot.active = false; + } + return manager; + } + + /// Start a new animation + pub fn startAnimation(self: *Self, id: u64, anim: Animation, current_time_ms: i64) void { + // Check if already exists + var i: usize = 0; + while (i < self.count) : (i += 1) { + if (self.animations[i].active and self.animations[i].id == id) { + self.animations[i].anim = anim; + self.animations[i].anim.start(current_time_ms); + return; + } + } + + // Add new + if (self.count < MAX_ANIMATIONS) { + self.animations[self.count] = .{ + .id = id, + .anim = anim, + .active = true, + }; + self.animations[self.count].anim.start(current_time_ms); + self.count += 1; + } + } + + /// Stop an animation + pub fn stopAnimation(self: *Self, id: u64) void { + var i: usize = 0; + while (i < self.count) : (i += 1) { + if (self.animations[i].active and self.animations[i].id == id) { + self.animations[i].anim.stop(); + self.animations[i].active = false; + return; + } + } + } + + /// Get animation value + pub fn getValue(self: Self, id: u64, current_time_ms: i64) ?f32 { + var i: usize = 0; + while (i < self.count) : (i += 1) { + const slot = self.animations[i]; + if (slot.active and slot.id == id) { + return slot.anim.getValue(current_time_ms); + } + } + return null; + } + + /// Check if animation exists and is running + pub fn isRunning(self: Self, id: u64) bool { + var i: usize = 0; + while (i < self.count) : (i += 1) { + const slot = self.animations[i]; + if (slot.active and slot.id == id) { + return slot.anim.running; + } + } + return false; + } + + /// Update animations, removing completed ones + pub fn update(self: *Self, current_time_ms: i64) void { + var i: usize = 0; + while (i < self.count) : (i += 1) { + if (self.animations[i].active and + self.animations[i].anim.isComplete(current_time_ms) and + !self.animations[i].anim.looping) + { + self.animations[i].active = false; + } + } + + // Compact array + self.compact(); + } + + /// Remove inactive animations + fn compact(self: *Self) void { + var write_idx: usize = 0; + var i: usize = 0; + while (i < self.count) : (i += 1) { + const slot = self.animations[i]; + if (slot.active) { + self.animations[write_idx] = slot; + write_idx += 1; + } + } + self.count = write_idx; + } + + /// Get count of active animations + pub fn activeCount(self: Self) usize { + var cnt: usize = 0; + var i: usize = 0; + while (i < self.count) : (i += 1) { + if (self.animations[i].active) cnt += 1; + } + return cnt; + } +}; + +/// Interpolate between two values +pub fn lerp(a: f32, b: f32, t: f32) f32 { + return a + (b - a) * t; +} + +/// Interpolate between two integers +pub fn lerpInt(a: i32, b: i32, t: f32) i32 { + return a + @as(i32, @intFromFloat(@as(f32, @floatFromInt(b - a)) * t)); +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "Easing linear" { + try std.testing.expectEqual(@as(f32, 0.0), Easing.linear(0.0)); + try std.testing.expectEqual(@as(f32, 0.5), Easing.linear(0.5)); + try std.testing.expectEqual(@as(f32, 1.0), Easing.linear(1.0)); +} + +test "Easing quadratic" { + try std.testing.expectEqual(@as(f32, 0.0), Easing.easeInQuad(0.0)); + try std.testing.expectEqual(@as(f32, 1.0), Easing.easeInQuad(1.0)); + try std.testing.expect(Easing.easeInQuad(0.5) < 0.5); // Slower start + + try std.testing.expectEqual(@as(f32, 0.0), Easing.easeOutQuad(0.0)); + try std.testing.expectEqual(@as(f32, 1.0), Easing.easeOutQuad(1.0)); + try std.testing.expect(Easing.easeOutQuad(0.5) > 0.5); // Faster start +} + +test "Animation create and getValue" { + var anim = Animation.create(0, 100, 1000, Easing.linear); + anim.start(0); + + try std.testing.expectEqual(@as(f32, 0), anim.getValue(0)); + try std.testing.expectEqual(@as(f32, 50), anim.getValue(500)); + try std.testing.expectEqual(@as(f32, 100), anim.getValue(1000)); + try std.testing.expectEqual(@as(f32, 100), anim.getValue(1500)); // Clamped +} + +test "Animation isComplete" { + var anim = Animation.create(0, 100, 1000, Easing.linear); + anim.start(0); + + try std.testing.expect(!anim.isComplete(500)); + try std.testing.expect(anim.isComplete(1000)); + try std.testing.expect(anim.isComplete(1500)); +} + +test "AnimationManager basic" { + var manager = AnimationManager.init(); + + manager.startAnimation(1, Animation.create(0, 100, 1000, Easing.linear), 0); + try std.testing.expect(manager.isRunning(1)); + try std.testing.expectEqual(@as(?f32, 50), manager.getValue(1, 500)); + + manager.stopAnimation(1); + try std.testing.expect(!manager.isRunning(1)); +} + +test "AnimationManager update" { + var manager = AnimationManager.init(); + + manager.startAnimation(1, Animation.create(0, 100, 100, Easing.linear), 0); + manager.startAnimation(2, Animation.create(0, 100, 200, Easing.linear), 0); + + try std.testing.expectEqual(@as(usize, 2), manager.count); + + manager.update(150); + // Animation 1 should be removed (complete), 2 still active + try std.testing.expectEqual(@as(usize, 1), manager.activeCount()); +} + +test "lerp" { + try std.testing.expectEqual(@as(f32, 0.0), lerp(0, 100, 0)); + try std.testing.expectEqual(@as(f32, 50.0), lerp(0, 100, 0.5)); + try std.testing.expectEqual(@as(f32, 100.0), lerp(0, 100, 1.0)); +} diff --git a/src/render/antialiasing.zig b/src/render/antialiasing.zig new file mode 100644 index 0000000..2c86338 --- /dev/null +++ b/src/render/antialiasing.zig @@ -0,0 +1,432 @@ +//! Anti-Aliasing Renderer +//! +//! Provides anti-aliased rendering for smoother edges on lines, circles, and polygons. +//! Uses sub-pixel sampling and coverage-based alpha blending. + +const std = @import("std"); +const Framebuffer = @import("framebuffer.zig").Framebuffer; +const Style = @import("../core/style.zig"); +const Layout = @import("../core/layout.zig"); + +/// Anti-aliasing quality level +pub const Quality = enum { + /// No anti-aliasing (fastest) + none, + /// 2x sampling + low, + /// 4x sampling (default) + medium, + /// 8x sampling + high, + /// 16x sampling (highest quality) + ultra, + + /// Get number of samples per pixel + pub fn samples(self: Quality) u8 { + return switch (self) { + .none => 1, + .low => 2, + .medium => 4, + .high => 8, + .ultra => 16, + }; + } +}; + +/// Draw an anti-aliased line +pub fn drawLineAA( + fb: *Framebuffer, + x1: i32, + y1: i32, + x2: i32, + y2: i32, + color: Style.Color, + thickness: f32, +) void { + // Xiaolin Wu's line algorithm for anti-aliased lines + const dx = @as(f32, @floatFromInt(x2 - x1)); + const dy = @as(f32, @floatFromInt(y2 - y1)); + + const steep = @abs(dy) > @abs(dx); + + var px1 = @as(f32, @floatFromInt(x1)); + var py1 = @as(f32, @floatFromInt(y1)); + var px2 = @as(f32, @floatFromInt(x2)); + var py2 = @as(f32, @floatFromInt(y2)); + + if (steep) { + std.mem.swap(f32, &px1, &py1); + std.mem.swap(f32, &px2, &py2); + } + + if (px1 > px2) { + std.mem.swap(f32, &px1, &px2); + std.mem.swap(f32, &py1, &py2); + } + + const final_dx = px2 - px1; + const final_dy = py2 - py1; + const gradient = if (final_dx == 0) 1.0 else final_dy / final_dx; + + // Handle first endpoint + var xend = @round(px1); + var yend = py1 + gradient * (xend - px1); + var xgap = rfpart(px1 + 0.5); + const xpxl1 = @as(i32, @intFromFloat(xend)); + const ypxl1 = @as(i32, @intFromFloat(@floor(yend))); + + if (steep) { + plotAA(fb, ypxl1, xpxl1, color, rfpart(yend) * xgap, thickness); + plotAA(fb, ypxl1 + 1, xpxl1, color, fpart(yend) * xgap, thickness); + } else { + plotAA(fb, xpxl1, ypxl1, color, rfpart(yend) * xgap, thickness); + plotAA(fb, xpxl1, ypxl1 + 1, color, fpart(yend) * xgap, thickness); + } + + var intery = yend + gradient; + + // Handle second endpoint + xend = @round(px2); + yend = py2 + gradient * (xend - px2); + xgap = fpart(px2 + 0.5); + const xpxl2 = @as(i32, @intFromFloat(xend)); + const ypxl2 = @as(i32, @intFromFloat(@floor(yend))); + + if (steep) { + plotAA(fb, ypxl2, xpxl2, color, rfpart(yend) * xgap, thickness); + plotAA(fb, ypxl2 + 1, xpxl2, color, fpart(yend) * xgap, thickness); + } else { + plotAA(fb, xpxl2, ypxl2, color, rfpart(yend) * xgap, thickness); + plotAA(fb, xpxl2, ypxl2 + 1, color, fpart(yend) * xgap, thickness); + } + + // Main loop + if (steep) { + var x: i32 = xpxl1 + 1; + while (x < xpxl2) : (x += 1) { + const y = @as(i32, @intFromFloat(@floor(intery))); + plotAA(fb, y, x, color, rfpart(intery), thickness); + plotAA(fb, y + 1, x, color, fpart(intery), thickness); + intery += gradient; + } + } else { + var x: i32 = xpxl1 + 1; + while (x < xpxl2) : (x += 1) { + const y = @as(i32, @intFromFloat(@floor(intery))); + plotAA(fb, x, y, color, rfpart(intery), thickness); + plotAA(fb, x, y + 1, color, fpart(intery), thickness); + intery += gradient; + } + } +} + +/// Draw an anti-aliased circle +pub fn drawCircleAA( + fb: *Framebuffer, + cx: i32, + cy: i32, + radius: f32, + color: Style.Color, + filled: bool, +) void { + if (radius <= 0) return; + + const r = radius; + + // Bounding box + const min_x = @as(i32, @intFromFloat(@ceil(@as(f32, @floatFromInt(cx)) - r - 1))); + const max_x = @as(i32, @intFromFloat(@floor(@as(f32, @floatFromInt(cx)) + r + 1))); + const min_y = @as(i32, @intFromFloat(@ceil(@as(f32, @floatFromInt(cy)) - r - 1))); + const max_y = @as(i32, @intFromFloat(@floor(@as(f32, @floatFromInt(cy)) + r + 1))); + + var y = min_y; + while (y <= max_y) : (y += 1) { + var x = min_x; + while (x <= max_x) : (x += 1) { + const dx = @as(f32, @floatFromInt(x - cx)); + const dy = @as(f32, @floatFromInt(y - cy)); + const dist2 = dx * dx + dy * dy; + const dist = @sqrt(dist2); + + if (filled) { + // Filled circle with anti-aliased edge + if (dist <= r - 1) { + // Fully inside + fb.setPixel(x, y, color); + } else if (dist <= r + 1) { + // Edge pixel - calculate coverage + const coverage = @max(0, @min(1, r - dist + 0.5)); + const alpha = @as(u8, @intFromFloat(@as(f32, @floatFromInt(color.a)) * coverage)); + blendPixel(fb, x, y, Style.Color.rgba(color.r, color.g, color.b, alpha)); + } + } else { + // Stroke only + const inner_dist = @abs(dist - r); + if (inner_dist < 1.5) { + const coverage = @max(0, @min(1, 1.5 - inner_dist)); + const alpha = @as(u8, @intFromFloat(@as(f32, @floatFromInt(color.a)) * coverage)); + blendPixel(fb, x, y, Style.Color.rgba(color.r, color.g, color.b, alpha)); + } + } + } + } +} + +/// Draw an anti-aliased rounded rectangle +pub fn drawRoundedRectAA( + fb: *Framebuffer, + rect: Layout.Rect, + radius: f32, + color: Style.Color, + filled: bool, +) void { + if (rect.isEmpty()) return; + + const r = @min(radius, @min(@as(f32, @floatFromInt(rect.w)) / 2, @as(f32, @floatFromInt(rect.h)) / 2)); + + const left = rect.x; + const top = rect.y; + const right = rect.x + @as(i32, @intCast(rect.w)) - 1; + const bottom = rect.y + @as(i32, @intCast(rect.h)) - 1; + + if (filled) { + // Fill the main body (non-rounded parts) + // Top rectangle (between corners) + fb.fillRect(left + @as(i32, @intFromFloat(r)), top, rect.w - @as(u32, @intFromFloat(r * 2)), @as(u32, @intFromFloat(r)), color); + // Middle rectangle (full width) + fb.fillRect(left, top + @as(i32, @intFromFloat(r)), rect.w, rect.h - @as(u32, @intFromFloat(r * 2)), color); + // Bottom rectangle (between corners) + fb.fillRect(left + @as(i32, @intFromFloat(r)), bottom - @as(i32, @intFromFloat(r)) + 1, rect.w - @as(u32, @intFromFloat(r * 2)), @as(u32, @intFromFloat(r)), color); + + // Draw anti-aliased corners + drawCornerAA(fb, left + @as(i32, @intFromFloat(r)), top + @as(i32, @intFromFloat(r)), r, color, .top_left, true); + drawCornerAA(fb, right - @as(i32, @intFromFloat(r)), top + @as(i32, @intFromFloat(r)), r, color, .top_right, true); + drawCornerAA(fb, left + @as(i32, @intFromFloat(r)), bottom - @as(i32, @intFromFloat(r)), r, color, .bottom_left, true); + drawCornerAA(fb, right - @as(i32, @intFromFloat(r)), bottom - @as(i32, @intFromFloat(r)), r, color, .bottom_right, true); + } else { + // Draw outline + // Top edge + drawLineAA(fb, left + @as(i32, @intFromFloat(r)), top, right - @as(i32, @intFromFloat(r)), top, color, 1); + // Bottom edge + drawLineAA(fb, left + @as(i32, @intFromFloat(r)), bottom, right - @as(i32, @intFromFloat(r)), bottom, color, 1); + // Left edge + drawLineAA(fb, left, top + @as(i32, @intFromFloat(r)), left, bottom - @as(i32, @intFromFloat(r)), color, 1); + // Right edge + drawLineAA(fb, right, top + @as(i32, @intFromFloat(r)), right, bottom - @as(i32, @intFromFloat(r)), color, 1); + + // Draw anti-aliased corners + drawCornerAA(fb, left + @as(i32, @intFromFloat(r)), top + @as(i32, @intFromFloat(r)), r, color, .top_left, false); + drawCornerAA(fb, right - @as(i32, @intFromFloat(r)), top + @as(i32, @intFromFloat(r)), r, color, .top_right, false); + drawCornerAA(fb, left + @as(i32, @intFromFloat(r)), bottom - @as(i32, @intFromFloat(r)), r, color, .bottom_left, false); + drawCornerAA(fb, right - @as(i32, @intFromFloat(r)), bottom - @as(i32, @intFromFloat(r)), r, color, .bottom_right, false); + } +} + +/// Corner position for rounded rectangles +const CornerPosition = enum { + top_left, + top_right, + bottom_left, + bottom_right, +}; + +/// Draw an anti-aliased quarter circle corner +fn drawCornerAA( + fb: *Framebuffer, + cx: i32, + cy: i32, + radius: f32, + color: Style.Color, + corner: CornerPosition, + filled: bool, +) void { + const r = radius; + const r_int = @as(i32, @intFromFloat(@ceil(r))); + + // Determine which quadrant to draw + const x_start: i32 = switch (corner) { + .top_left, .bottom_left => -r_int, + .top_right, .bottom_right => 0, + }; + const x_end: i32 = switch (corner) { + .top_left, .bottom_left => 0, + .top_right, .bottom_right => r_int, + }; + const y_start: i32 = switch (corner) { + .top_left, .top_right => -r_int, + .bottom_left, .bottom_right => 0, + }; + const y_end: i32 = switch (corner) { + .top_left, .top_right => 0, + .bottom_left, .bottom_right => r_int, + }; + + var dy: i32 = y_start; + while (dy <= y_end) : (dy += 1) { + var dx: i32 = x_start; + while (dx <= x_end) : (dx += 1) { + const fdx = @as(f32, @floatFromInt(dx)); + const fdy = @as(f32, @floatFromInt(dy)); + const dist = @sqrt(fdx * fdx + fdy * fdy); + + if (filled) { + if (dist <= r) { + const coverage = @max(0, @min(1, r - dist + 0.5)); + if (coverage >= 0.99) { + fb.setPixel(cx + dx, cy + dy, color); + } else if (coverage > 0.01) { + const alpha = @as(u8, @intFromFloat(@as(f32, @floatFromInt(color.a)) * coverage)); + blendPixel(fb, cx + dx, cy + dy, Style.Color.rgba(color.r, color.g, color.b, alpha)); + } + } + } else { + const inner_dist = @abs(dist - r); + if (inner_dist < 1.5) { + const coverage = @max(0, @min(1, 1.5 - inner_dist)); + const alpha = @as(u8, @intFromFloat(@as(f32, @floatFromInt(color.a)) * coverage)); + blendPixel(fb, cx + dx, cy + dy, Style.Color.rgba(color.r, color.g, color.b, alpha)); + } + } + } + } +} + +/// Draw an anti-aliased ellipse +pub fn drawEllipseAA( + fb: *Framebuffer, + cx: i32, + cy: i32, + rx: f32, + ry: f32, + color: Style.Color, + filled: bool, +) void { + if (rx <= 0 or ry <= 0) return; + + const min_x = @as(i32, @intFromFloat(@ceil(@as(f32, @floatFromInt(cx)) - rx - 1))); + const max_x = @as(i32, @intFromFloat(@floor(@as(f32, @floatFromInt(cx)) + rx + 1))); + const min_y = @as(i32, @intFromFloat(@ceil(@as(f32, @floatFromInt(cy)) - ry - 1))); + const max_y = @as(i32, @intFromFloat(@floor(@as(f32, @floatFromInt(cy)) + ry + 1))); + + var y = min_y; + while (y <= max_y) : (y += 1) { + var x = min_x; + while (x <= max_x) : (x += 1) { + const dx = @as(f32, @floatFromInt(x - cx)) / rx; + const dy = @as(f32, @floatFromInt(y - cy)) / ry; + const dist = @sqrt(dx * dx + dy * dy); + + if (filled) { + if (dist <= 1 - 1 / @max(rx, ry)) { + fb.setPixel(x, y, color); + } else if (dist <= 1 + 1 / @max(rx, ry)) { + const coverage = @max(0, @min(1, (1 - dist) * @min(rx, ry) + 0.5)); + const alpha = @as(u8, @intFromFloat(@as(f32, @floatFromInt(color.a)) * coverage)); + blendPixel(fb, x, y, Style.Color.rgba(color.r, color.g, color.b, alpha)); + } + } else { + const inner_dist = @abs(dist - 1) * @min(rx, ry); + if (inner_dist < 1.5) { + const coverage = @max(0, @min(1, 1.5 - inner_dist)); + const alpha = @as(u8, @intFromFloat(@as(f32, @floatFromInt(color.a)) * coverage)); + blendPixel(fb, x, y, Style.Color.rgba(color.r, color.g, color.b, alpha)); + } + } + } + } +} + +/// Draw an anti-aliased polygon outline +pub fn drawPolygonAA( + fb: *Framebuffer, + points: []const [2]i32, + color: Style.Color, + thickness: f32, + closed: bool, +) void { + if (points.len < 2) return; + + // Draw edges + var i: usize = 0; + while (i < points.len - 1) : (i += 1) { + drawLineAA(fb, points[i][0], points[i][1], points[i + 1][0], points[i + 1][1], color, thickness); + } + + // Close the polygon + if (closed and points.len > 2) { + drawLineAA(fb, points[points.len - 1][0], points[points.len - 1][1], points[0][0], points[0][1], color, thickness); + } +} + +// ============================================================================= +// Helper functions +// ============================================================================= + +/// Fractional part of a number +fn fpart(x: f32) f32 { + return x - @floor(x); +} + +/// Reverse fractional part +fn rfpart(x: f32) f32 { + return 1 - fpart(x); +} + +/// Plot a pixel with anti-aliasing coverage +fn plotAA(fb: *Framebuffer, x: i32, y: i32, color: Style.Color, coverage: f32, thickness: f32) void { + const alpha = @as(u8, @intFromFloat(@as(f32, @floatFromInt(color.a)) * @min(1, coverage * thickness))); + blendPixel(fb, x, y, Style.Color.rgba(color.r, color.g, color.b, alpha)); +} + +/// Blend a pixel with alpha +fn blendPixel(fb: *Framebuffer, x: i32, y: i32, color: Style.Color) void { + if (color.a == 0) return; + if (color.a == 255) { + fb.setPixel(x, y, color); + return; + } + + // Get existing pixel + if (fb.getPixel(x, y)) |existing| { + // Unpack existing color + const bg_r: u8 = @truncate(existing >> 24); + const bg_g: u8 = @truncate(existing >> 16); + const bg_b: u8 = @truncate(existing >> 8); + const bg_a: u8 = @truncate(existing); + + // Alpha blend + const src_a = @as(u16, color.a); + const dst_a = @as(u16, bg_a); + const inv_a = 255 - src_a; + + const out_a = src_a + @divTrunc(dst_a * inv_a, 255); + if (out_a == 0) return; + + const out_r = @as(u8, @intCast(@divTrunc(@as(u16, color.r) * src_a + @as(u16, bg_r) * @divTrunc(dst_a * inv_a, 255), out_a))); + const out_g = @as(u8, @intCast(@divTrunc(@as(u16, color.g) * src_a + @as(u16, bg_g) * @divTrunc(dst_a * inv_a, 255), out_a))); + const out_b = @as(u8, @intCast(@divTrunc(@as(u16, color.b) * src_a + @as(u16, bg_b) * @divTrunc(dst_a * inv_a, 255), out_a))); + + fb.setPixel(x, y, Style.Color.rgba(out_r, out_g, out_b, @as(u8, @intCast(out_a)))); + } else { + fb.setPixel(x, y, color); + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "fpart and rfpart" { + try std.testing.expectApproxEqAbs(@as(f32, 0.5), fpart(1.5), 0.001); + try std.testing.expectApproxEqAbs(@as(f32, 0.5), rfpart(1.5), 0.001); + try std.testing.expectApproxEqAbs(@as(f32, 0.25), fpart(2.25), 0.001); + try std.testing.expectApproxEqAbs(@as(f32, 0.75), rfpart(2.25), 0.001); +} + +test "Quality samples" { + try std.testing.expectEqual(@as(u8, 1), Quality.none.samples()); + try std.testing.expectEqual(@as(u8, 2), Quality.low.samples()); + try std.testing.expectEqual(@as(u8, 4), Quality.medium.samples()); + try std.testing.expectEqual(@as(u8, 8), Quality.high.samples()); + try std.testing.expectEqual(@as(u8, 16), Quality.ultra.samples()); +} diff --git a/src/render/effects.zig b/src/render/effects.zig new file mode 100644 index 0000000..cc50e8c --- /dev/null +++ b/src/render/effects.zig @@ -0,0 +1,391 @@ +//! Visual Effects +//! +//! Provides visual effects like shadows, gradients, and blur. +//! These can be applied to rendered content. + +const std = @import("std"); +const Framebuffer = @import("framebuffer.zig").Framebuffer; +const Style = @import("../core/style.zig"); +const Layout = @import("../core/layout.zig"); + +/// Gradient direction +pub const GradientDirection = enum { + horizontal, // Left to right + vertical, // Top to bottom + diagonal_down, // Top-left to bottom-right + diagonal_up, // Bottom-left to top-right + radial, // Center outward +}; + +/// Shadow effect configuration +pub const Shadow = struct { + /// Horizontal offset (positive = right) + offset_x: i32 = 4, + /// Vertical offset (positive = down) + offset_y: i32 = 4, + /// Blur radius (0 = hard shadow) + blur_radius: u8 = 4, + /// Shadow color + color: Style.Color = Style.Color.rgba(0, 0, 0, 128), + /// Spread (makes shadow larger/smaller than element) + spread: i32 = 0, + + /// Create a simple drop shadow + pub fn drop(offset: i32, blur: u8) Shadow { + return .{ + .offset_x = offset, + .offset_y = offset, + .blur_radius = blur, + }; + } + + /// Create a soft shadow + pub fn soft() Shadow { + return .{ + .offset_x = 0, + .offset_y = 2, + .blur_radius = 8, + .color = Style.Color.rgba(0, 0, 0, 64), + }; + } + + /// Create a hard shadow + pub fn hard() Shadow { + return .{ + .offset_x = 2, + .offset_y = 2, + .blur_radius = 0, + .color = Style.Color.rgba(0, 0, 0, 100), + }; + } +}; + +/// Gradient effect configuration +pub const Gradient = struct { + /// Start color + start_color: Style.Color, + /// End color + end_color: Style.Color, + /// Direction + direction: GradientDirection = .vertical, + /// Optional middle color for 3-stop gradient + middle_color: ?Style.Color = null, + /// Middle position (0.0 - 1.0) + middle_pos: f32 = 0.5, + + /// Create a horizontal gradient + pub fn horizontal(start: Style.Color, end: Style.Color) Gradient { + return .{ + .start_color = start, + .end_color = end, + .direction = .horizontal, + }; + } + + /// Create a vertical gradient + pub fn vertical(start: Style.Color, end: Style.Color) Gradient { + return .{ + .start_color = start, + .end_color = end, + .direction = .vertical, + }; + } + + /// Create a diagonal gradient + pub fn diagonal(start: Style.Color, end: Style.Color) Gradient { + return .{ + .start_color = start, + .end_color = end, + .direction = .diagonal_down, + }; + } +}; + +/// Apply a shadow to a rectangle +pub fn applyShadow(fb: *Framebuffer, rect: Layout.Rect, shadow: Shadow) void { + if (rect.isEmpty()) return; + + // Calculate shadow bounds + const shadow_x = rect.x + shadow.offset_x - shadow.spread; + const shadow_y = rect.y + shadow.offset_y - shadow.spread; + const shadow_w = rect.w +| @as(u32, @intCast(@abs(shadow.spread) * 2)); + const shadow_h = rect.h +| @as(u32, @intCast(@abs(shadow.spread) * 2)); + + if (shadow.blur_radius == 0) { + // Hard shadow - simple filled rect + fb.fillRect(shadow_x, shadow_y, shadow_w, shadow_h, shadow.color); + } else { + // Soft shadow with blur approximation + // Draw multiple layers with decreasing alpha + const layers = @as(u32, @intCast(shadow.blur_radius)); + const base_alpha = shadow.color.a; + + var layer: u32 = 0; + while (layer <= layers) : (layer += 1) { + const t = @as(f32, @floatFromInt(layer)) / @as(f32, @floatFromInt(layers + 1)); + const alpha = @as(u8, @intFromFloat(@as(f32, @floatFromInt(base_alpha)) * (1.0 - t * t))); + + const expand = @as(i32, @intCast(layer)); + const layer_color = Style.Color.rgba(shadow.color.r, shadow.color.g, shadow.color.b, alpha); + + fb.fillRect( + shadow_x - expand, + shadow_y - expand, + shadow_w +| @as(u32, @intCast(expand * 2)), + shadow_h +| @as(u32, @intCast(expand * 2)), + layer_color, + ); + } + } +} + +/// Apply a gradient fill to a rectangle +pub fn applyGradient(fb: *Framebuffer, rect: Layout.Rect, gradient: Gradient) void { + if (rect.isEmpty()) return; + + switch (gradient.direction) { + .horizontal => applyHorizontalGradient(fb, rect, gradient), + .vertical => applyVerticalGradient(fb, rect, gradient), + .diagonal_down => applyDiagonalGradient(fb, rect, gradient, true), + .diagonal_up => applyDiagonalGradient(fb, rect, gradient, false), + .radial => applyRadialGradient(fb, rect, gradient), + } +} + +fn applyHorizontalGradient(fb: *Framebuffer, rect: Layout.Rect, gradient: Gradient) void { + var x: u32 = 0; + while (x < rect.w) : (x += 1) { + const t = @as(f32, @floatFromInt(x)) / @as(f32, @floatFromInt(rect.w - 1)); + const color = interpolateColor(gradient.start_color, gradient.end_color, t); + fb.fillRect(rect.x + @as(i32, @intCast(x)), rect.y, 1, rect.h, color); + } +} + +fn applyVerticalGradient(fb: *Framebuffer, rect: Layout.Rect, gradient: Gradient) void { + var y: u32 = 0; + while (y < rect.h) : (y += 1) { + const t = @as(f32, @floatFromInt(y)) / @as(f32, @floatFromInt(rect.h - 1)); + const color = interpolateColor(gradient.start_color, gradient.end_color, t); + fb.fillRect(rect.x, rect.y + @as(i32, @intCast(y)), rect.w, 1, color); + } +} + +fn applyDiagonalGradient(fb: *Framebuffer, rect: Layout.Rect, gradient: Gradient, down: bool) void { + const max_dist = @as(f32, @floatFromInt(rect.w + rect.h - 2)); + + var y: u32 = 0; + while (y < rect.h) : (y += 1) { + var x: u32 = 0; + while (x < rect.w) : (x += 1) { + const dist: f32 = if (down) + @as(f32, @floatFromInt(x + y)) + else + @as(f32, @floatFromInt(x + (rect.h - 1 - y))); + + const t = dist / max_dist; + const color = interpolateColor(gradient.start_color, gradient.end_color, t); + + fb.setPixel( + rect.x + @as(i32, @intCast(x)), + rect.y + @as(i32, @intCast(y)), + color, + ); + } + } +} + +fn applyRadialGradient(fb: *Framebuffer, rect: Layout.Rect, gradient: Gradient) void { + const cx = @as(f32, @floatFromInt(rect.w)) / 2.0; + const cy = @as(f32, @floatFromInt(rect.h)) / 2.0; + const max_dist = @sqrt(cx * cx + cy * cy); + + var y: u32 = 0; + while (y < rect.h) : (y += 1) { + var x: u32 = 0; + while (x < rect.w) : (x += 1) { + const dx = @as(f32, @floatFromInt(x)) - cx; + const dy = @as(f32, @floatFromInt(y)) - cy; + const dist = @sqrt(dx * dx + dy * dy); + const t = @min(1.0, dist / max_dist); + const color = interpolateColor(gradient.start_color, gradient.end_color, t); + + fb.setPixel( + rect.x + @as(i32, @intCast(x)), + rect.y + @as(i32, @intCast(y)), + color, + ); + } + } +} + +/// Interpolate between two colors +pub fn interpolateColor(a: Style.Color, b: Style.Color, t: f32) Style.Color { + const t_clamped = @max(0.0, @min(1.0, t)); + return Style.Color.rgba( + @intFromFloat(@as(f32, @floatFromInt(a.r)) * (1 - t_clamped) + @as(f32, @floatFromInt(b.r)) * t_clamped), + @intFromFloat(@as(f32, @floatFromInt(a.g)) * (1 - t_clamped) + @as(f32, @floatFromInt(b.g)) * t_clamped), + @intFromFloat(@as(f32, @floatFromInt(a.b)) * (1 - t_clamped) + @as(f32, @floatFromInt(b.b)) * t_clamped), + @intFromFloat(@as(f32, @floatFromInt(a.a)) * (1 - t_clamped) + @as(f32, @floatFromInt(b.a)) * t_clamped), + ); +} + +/// Apply simple blur to a region (box blur) +pub fn applyBlur(fb: *Framebuffer, rect: Layout.Rect, radius: u8) void { + if (rect.isEmpty() or radius == 0) return; + + // Simple box blur - average of surrounding pixels + const r = @as(i32, @intCast(radius)); + const kernel_size = @as(u32, @intCast(2 * r + 1)); + const divisor = kernel_size * kernel_size; + + // Create a temporary copy of the region + var temp_buf: [256 * 256 * 4]u8 = undefined; + const max_pixels = 256 * 256; + + if (rect.w * rect.h > max_pixels) return; // Too large + + // Copy original pixels + var py: u32 = 0; + while (py < rect.h) : (py += 1) { + var px: u32 = 0; + while (px < rect.w) : (px += 1) { + const src_x = rect.x + @as(i32, @intCast(px)); + const src_y = rect.y + @as(i32, @intCast(py)); + + if (fb.getPixel(src_x, src_y)) |pixel| { + const idx = (py * rect.w + px) * 4; + // Unpack RGBA from u32 + temp_buf[idx + 0] = @truncate(pixel >> 24); // R + temp_buf[idx + 1] = @truncate(pixel >> 16); // G + temp_buf[idx + 2] = @truncate(pixel >> 8); // B + temp_buf[idx + 3] = @truncate(pixel); // A + } + } + } + + // Apply blur + py = 0; + while (py < rect.h) : (py += 1) { + var px: u32 = 0; + while (px < rect.w) : (px += 1) { + var sum_r: u32 = 0; + var sum_g: u32 = 0; + var sum_b: u32 = 0; + var sum_a: u32 = 0; + var count: u32 = 0; + + // Sample kernel + var ky: i32 = -r; + while (ky <= r) : (ky += 1) { + var kx: i32 = -r; + while (kx <= r) : (kx += 1) { + const sx = @as(i32, @intCast(px)) + kx; + const sy = @as(i32, @intCast(py)) + ky; + + if (sx >= 0 and sy >= 0 and + sx < @as(i32, @intCast(rect.w)) and + sy < @as(i32, @intCast(rect.h))) + { + const idx = (@as(u32, @intCast(sy)) * rect.w + @as(u32, @intCast(sx))) * 4; + sum_r += temp_buf[idx + 0]; + sum_g += temp_buf[idx + 1]; + sum_b += temp_buf[idx + 2]; + sum_a += temp_buf[idx + 3]; + count += 1; + } + } + } + + if (count > 0) { + const color = Style.Color.rgba( + @intCast(sum_r / count), + @intCast(sum_g / count), + @intCast(sum_b / count), + @intCast(sum_a / count), + ); + fb.setPixel( + rect.x + @as(i32, @intCast(px)), + rect.y + @as(i32, @intCast(py)), + color, + ); + } + } + } + + _ = divisor; +} + +/// Apply opacity to a color +pub fn applyOpacity(color: Style.Color, opacity: f32) Style.Color { + const new_alpha = @as(u8, @intFromFloat(@as(f32, @floatFromInt(color.a)) * @max(0.0, @min(1.0, opacity)))); + return Style.Color.rgba(color.r, color.g, color.b, new_alpha); +} + +/// Create a highlight color (lighter version) +pub fn highlight(color: Style.Color, amount: u8) Style.Color { + return Style.Color.rgba( + @min(255, @as(u16, color.r) + amount), + @min(255, @as(u16, color.g) + amount), + @min(255, @as(u16, color.b) + amount), + color.a, + ); +} + +/// Create a lowlight color (darker version) +pub fn lowlight(color: Style.Color, amount: u8) Style.Color { + return Style.Color.rgba( + @as(u8, @intCast(@max(0, @as(i16, color.r) - amount))), + @as(u8, @intCast(@max(0, @as(i16, color.g) - amount))), + @as(u8, @intCast(@max(0, @as(i16, color.b) - amount))), + color.a, + ); +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "Shadow presets" { + const soft = Shadow.soft(); + try std.testing.expectEqual(@as(i32, 0), soft.offset_x); + try std.testing.expectEqual(@as(u8, 8), soft.blur_radius); + + const hard = Shadow.hard(); + try std.testing.expectEqual(@as(u8, 0), hard.blur_radius); +} + +test "Gradient creation" { + const white = Style.Color.rgba(255, 255, 255, 255); + const black = Style.Color.rgba(0, 0, 0, 255); + + const h_grad = Gradient.horizontal(white, black); + try std.testing.expectEqual(GradientDirection.horizontal, h_grad.direction); + + const v_grad = Gradient.vertical(white, black); + try std.testing.expectEqual(GradientDirection.vertical, v_grad.direction); +} + +test "interpolateColor" { + const white = Style.Color.rgba(255, 255, 255, 255); + const black = Style.Color.rgba(0, 0, 0, 255); + + const mid = interpolateColor(black, white, 0.5); + try std.testing.expect(mid.r > 120 and mid.r < 130); + try std.testing.expect(mid.g > 120 and mid.g < 130); +} + +test "applyOpacity" { + const color = Style.Color.rgba(255, 0, 0, 255); + const half = applyOpacity(color, 0.5); + try std.testing.expect(half.a > 125 and half.a < 130); +} + +test "highlight and lowlight" { + const color = Style.Color.rgba(100, 100, 100, 255); + + const hi = highlight(color, 50); + try std.testing.expectEqual(@as(u8, 150), hi.r); + + const lo = lowlight(color, 50); + try std.testing.expectEqual(@as(u8, 50), lo.r); +} diff --git a/src/widgets/virtual_scroll.zig b/src/widgets/virtual_scroll.zig new file mode 100644 index 0000000..eec3726 --- /dev/null +++ b/src/widgets/virtual_scroll.zig @@ -0,0 +1,496 @@ +//! Virtual Scrolling +//! +//! Efficiently renders large lists by only rendering visible items. +//! Supports variable item heights and smooth scrolling. + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Layout = @import("../core/layout.zig"); +const Style = @import("../core/style.zig"); +const Input = @import("../core/input.zig"); + +/// Maximum cached item heights +const MAX_CACHED_HEIGHTS = 10000; + +/// Virtual scroll state +pub const VirtualScrollState = struct { + /// Scroll offset in pixels + scroll_offset: i32 = 0, + /// Total content height + total_height: u32 = 0, + /// First visible item index + first_visible: usize = 0, + /// Last visible item index + last_visible: usize = 0, + /// Number of items + item_count: usize = 0, + /// Cached item heights (for variable height) + item_heights: [MAX_CACHED_HEIGHTS]u16 = [_]u16{0} ** MAX_CACHED_HEIGHTS, + /// Default item height + default_height: u16 = 24, + /// Is dragging scrollbar + dragging_scrollbar: bool = false, + /// Drag start offset + drag_start_y: i32 = 0, + /// Drag start scroll + drag_start_scroll: i32 = 0, + + const Self = @This(); + + /// Initialize state + pub fn init(item_count: usize, default_height: u16) Self { + return .{ + .item_count = item_count, + .default_height = default_height, + }; + } + + /// Set item count + pub fn setItemCount(self: *Self, count: usize) void { + self.item_count = count; + self.recalculateTotalHeight(); + } + + /// Set height for a specific item + pub fn setItemHeight(self: *Self, index: usize, height: u16) void { + if (index < MAX_CACHED_HEIGHTS) { + self.item_heights[index] = height; + self.recalculateTotalHeight(); + } + } + + /// Get height for a specific item + pub fn getItemHeight(self: Self, index: usize) u16 { + if (index < MAX_CACHED_HEIGHTS and self.item_heights[index] > 0) { + return self.item_heights[index]; + } + return self.default_height; + } + + /// Recalculate total content height + pub fn recalculateTotalHeight(self: *Self) void { + var total: u32 = 0; + var i: usize = 0; + while (i < self.item_count) : (i += 1) { + total += self.getItemHeight(i); + } + self.total_height = total; + } + + /// Scroll to a specific item + pub fn scrollToItem(self: *Self, index: usize, viewport_height: u32) void { + if (index >= self.item_count) return; + + // Calculate item offset + var offset: i32 = 0; + var i: usize = 0; + while (i < index) : (i += 1) { + offset += self.getItemHeight(i); + } + + // Center item in viewport + const item_height = self.getItemHeight(index); + const center_offset = offset - @as(i32, @intCast(viewport_height / 2)) + @divTrunc(@as(i32, item_height), 2); + self.scroll_offset = @max(0, center_offset); + } + + /// Ensure item is visible + pub fn ensureVisible(self: *Self, index: usize, viewport_height: u32) void { + if (index >= self.item_count) return; + + // Calculate item bounds + var item_top: i32 = 0; + var i: usize = 0; + while (i < index) : (i += 1) { + item_top += self.getItemHeight(i); + } + const item_bottom = item_top + @as(i32, self.getItemHeight(index)); + + // Check if scrolling needed + if (item_top < self.scroll_offset) { + self.scroll_offset = item_top; + } else if (item_bottom > self.scroll_offset + @as(i32, @intCast(viewport_height))) { + self.scroll_offset = item_bottom - @as(i32, @intCast(viewport_height)); + } + } + + /// Get offset for a specific item + pub fn getItemOffset(self: Self, index: usize) i32 { + var offset: i32 = 0; + var i: usize = 0; + while (i < index and i < self.item_count) : (i += 1) { + offset += self.getItemHeight(i); + } + return offset; + } +}; + +/// Virtual scroll configuration +pub const VirtualScrollConfig = struct { + /// Show scrollbar + show_scrollbar: bool = true, + /// Scrollbar width + scrollbar_width: u16 = 12, + /// Overscan (render extra items above/below viewport) + overscan: u16 = 2, + /// Enable smooth scrolling + smooth_scroll: bool = true, + /// Scroll speed (pixels per wheel tick) + scroll_speed: u16 = 48, + /// Minimum thumb size + min_thumb_size: u16 = 20, +}; + +/// Virtual scroll colors +pub const VirtualScrollColors = struct { + background: Style.Color = Style.Color.rgba(30, 30, 30, 255), + scrollbar_track: Style.Color = Style.Color.rgba(50, 50, 50, 255), + scrollbar_thumb: Style.Color = Style.Color.rgba(100, 100, 100, 255), + scrollbar_thumb_hover: Style.Color = Style.Color.rgba(130, 130, 130, 255), + scrollbar_thumb_active: Style.Color = Style.Color.rgba(160, 160, 160, 255), +}; + +/// Result from virtual scroll widget +pub const VirtualScrollResult = struct { + /// First visible item index + first_visible: usize, + /// Last visible item index (exclusive) + last_visible: usize, + /// Content area rect (excluding scrollbar) + content_rect: Layout.Rect, + /// Did scroll position change + scrolled: bool, + /// Is scrollbar being dragged + dragging: bool, +}; + +/// Item callback for rendering +pub const ItemRenderer = *const fn ( + ctx: *Context, + index: usize, + rect: Layout.Rect, + user_data: ?*anyopaque, +) void; + +/// Render a virtual scroll area +pub fn virtualScroll( + ctx: *Context, + state: *VirtualScrollState, + rect: Layout.Rect, +) VirtualScrollResult { + return virtualScrollEx(ctx, state, rect, .{}, .{}); +} + +/// Render a virtual scroll area with configuration +pub fn virtualScrollEx( + ctx: *Context, + state: *VirtualScrollState, + rect: Layout.Rect, + config: VirtualScrollConfig, + colors: VirtualScrollColors, +) VirtualScrollResult { + if (rect.isEmpty()) { + return .{ + .first_visible = 0, + .last_visible = 0, + .content_rect = rect, + .scrolled = false, + .dragging = false, + }; + } + + const scrollbar_width: u32 = if (config.show_scrollbar and state.total_height > rect.h) + @as(u32, config.scrollbar_width) + else + 0; + + const content_rect = Layout.Rect{ + .x = rect.x, + .y = rect.y, + .w = rect.w -| scrollbar_width, + .h = rect.h, + }; + + // Draw background + ctx.pushCommand(.{ .fill_rect = .{ + .rect = rect, + .color = colors.background, + } }); + + // Handle input + var scrolled = false; + const input = ctx.getInput(); + + // Mouse wheel scrolling + if (rect.contains(input.mouse_x, input.mouse_y)) { + if (input.scroll_y != 0) { + const scroll_delta = -input.scroll_y * @as(i32, config.scroll_speed); + const old_offset = state.scroll_offset; + state.scroll_offset = @max(0, @min( + @as(i32, @intCast(state.total_height)) - @as(i32, @intCast(rect.h)), + state.scroll_offset + scroll_delta, + )); + scrolled = old_offset != state.scroll_offset; + } + } + + // Scrollbar handling + var dragging = state.dragging_scrollbar; + if (scrollbar_width > 0) { + const scrollbar_rect = Layout.Rect{ + .x = rect.x + @as(i32, @intCast(rect.w - scrollbar_width)), + .y = rect.y, + .w = scrollbar_width, + .h = rect.h, + }; + + // Draw scrollbar track + ctx.pushCommand(.{ .fill_rect = .{ + .rect = scrollbar_rect, + .color = colors.scrollbar_track, + } }); + + // Calculate thumb + if (state.total_height > rect.h) { + const visible_ratio = @as(f32, @floatFromInt(rect.h)) / @as(f32, @floatFromInt(state.total_height)); + const thumb_height = @max( + config.min_thumb_size, + @as(u32, @intFromFloat(visible_ratio * @as(f32, @floatFromInt(rect.h)))), + ); + + const scroll_range = state.total_height - rect.h; + const thumb_range = rect.h - thumb_height; + const thumb_pos = if (scroll_range > 0) + @as(u32, @intFromFloat( + @as(f32, @floatFromInt(state.scroll_offset)) / + @as(f32, @floatFromInt(scroll_range)) * + @as(f32, @floatFromInt(thumb_range)), + )) + else + 0; + + const thumb_rect = Layout.Rect{ + .x = scrollbar_rect.x + 2, + .y = scrollbar_rect.y + @as(i32, @intCast(thumb_pos)), + .w = scrollbar_width - 4, + .h = thumb_height, + }; + + // Handle scrollbar dragging + const thumb_hovered = thumb_rect.contains(input.mouse_x, input.mouse_y); + + if (state.dragging_scrollbar) { + if (input.mouse_down) { + const delta = input.mouse_y - state.drag_start_y; + const scroll_delta = if (thumb_range > 0) + @as(i32, @intFromFloat( + @as(f32, @floatFromInt(delta)) / + @as(f32, @floatFromInt(thumb_range)) * + @as(f32, @floatFromInt(scroll_range)), + )) + else + 0; + const old_offset = state.scroll_offset; + state.scroll_offset = @max(0, @min( + @as(i32, @intCast(scroll_range)), + state.drag_start_scroll + scroll_delta, + )); + scrolled = old_offset != state.scroll_offset; + } else { + state.dragging_scrollbar = false; + dragging = false; + } + } else if (thumb_hovered and input.mouse_pressed) { + state.dragging_scrollbar = true; + state.drag_start_y = input.mouse_y; + state.drag_start_scroll = state.scroll_offset; + dragging = true; + } + + // Draw thumb + const thumb_color = if (state.dragging_scrollbar) + colors.scrollbar_thumb_active + else if (thumb_hovered) + colors.scrollbar_thumb_hover + else + colors.scrollbar_thumb; + + ctx.pushCommand(.{ .fill_rect = .{ + .rect = thumb_rect, + .color = thumb_color, + } }); + } + } + + // Calculate visible range + const viewport_height = rect.h; + var first_visible: usize = 0; + var last_visible: usize = 0; + var accumulated_height: i32 = 0; + + // Find first visible + var i: usize = 0; + while (i < state.item_count) : (i += 1) { + const item_height = state.getItemHeight(i); + if (accumulated_height + @as(i32, item_height) > state.scroll_offset) { + first_visible = i; + break; + } + accumulated_height += item_height; + } + + // Apply overscan + if (first_visible > config.overscan) { + first_visible -= config.overscan; + } else { + first_visible = 0; + } + + // Find last visible + accumulated_height = 0; + i = 0; + while (i < first_visible) : (i += 1) { + accumulated_height += state.getItemHeight(i); + } + + i = first_visible; + while (i < state.item_count) : (i += 1) { + if (accumulated_height > state.scroll_offset + @as(i32, @intCast(viewport_height))) { + break; + } + accumulated_height += state.getItemHeight(i); + last_visible = i + 1; + } + + // Apply overscan + last_visible = @min(state.item_count, last_visible + config.overscan); + + // Update state + state.first_visible = first_visible; + state.last_visible = last_visible; + + return .{ + .first_visible = first_visible, + .last_visible = last_visible, + .content_rect = content_rect, + .scrolled = scrolled, + .dragging = dragging, + }; +} + +/// Get the rect for a specific item +pub fn getItemRect( + state: *const VirtualScrollState, + content_rect: Layout.Rect, + index: usize, +) Layout.Rect { + if (index >= state.item_count) { + return Layout.Rect{ .x = 0, .y = 0, .w = 0, .h = 0 }; + } + + const item_offset = state.getItemOffset(index); + const item_height = state.getItemHeight(index); + + return Layout.Rect{ + .x = content_rect.x, + .y = content_rect.y + item_offset - state.scroll_offset, + .w = content_rect.w, + .h = item_height, + }; +} + +/// Render visible items using a callback +pub fn renderItems( + ctx: *Context, + state: *const VirtualScrollState, + result: VirtualScrollResult, + renderer: ItemRenderer, + user_data: ?*anyopaque, +) void { + var i: usize = result.first_visible; + while (i < result.last_visible) : (i += 1) { + const item_rect = getItemRect(state, result.content_rect, i); + renderer(ctx, i, item_rect, user_data); + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "VirtualScrollState init" { + const state = VirtualScrollState.init(100, 24); + try std.testing.expectEqual(@as(usize, 100), state.item_count); + try std.testing.expectEqual(@as(u16, 24), state.default_height); +} + +test "VirtualScrollState item heights" { + var state = VirtualScrollState.init(10, 24); + + // Default height + try std.testing.expectEqual(@as(u16, 24), state.getItemHeight(0)); + + // Custom height + state.setItemHeight(5, 48); + try std.testing.expectEqual(@as(u16, 48), state.getItemHeight(5)); +} + +test "VirtualScrollState total height" { + var state = VirtualScrollState.init(10, 20); + state.recalculateTotalHeight(); + + // 10 items * 20 pixels = 200 + try std.testing.expectEqual(@as(u32, 200), state.total_height); + + // Set custom height + state.setItemHeight(0, 40); // 40 instead of 20 + try std.testing.expectEqual(@as(u32, 220), state.total_height); +} + +test "VirtualScrollState item offset" { + var state = VirtualScrollState.init(5, 30); + state.recalculateTotalHeight(); + + try std.testing.expectEqual(@as(i32, 0), state.getItemOffset(0)); + try std.testing.expectEqual(@as(i32, 30), state.getItemOffset(1)); + try std.testing.expectEqual(@as(i32, 60), state.getItemOffset(2)); + try std.testing.expectEqual(@as(i32, 120), state.getItemOffset(4)); +} + +test "VirtualScrollState ensure visible" { + var state = VirtualScrollState.init(100, 20); + state.recalculateTotalHeight(); + + // Item at position 50*20 = 1000 + state.ensureVisible(50, 200); + // Should scroll so item 50 is visible + try std.testing.expect(state.scroll_offset >= 800); + try std.testing.expect(state.scroll_offset <= 1000); +} + +test "getItemRect" { + var state = VirtualScrollState.init(10, 30); + state.scroll_offset = 0; + + const content_rect = Layout.Rect{ .x = 0, .y = 0, .w = 200, .h = 100 }; + + const rect0 = getItemRect(&state, content_rect, 0); + try std.testing.expectEqual(@as(i32, 0), rect0.y); + try std.testing.expectEqual(@as(u32, 30), rect0.h); + + const rect2 = getItemRect(&state, content_rect, 2); + try std.testing.expectEqual(@as(i32, 60), rect2.y); +} + +test "getItemRect with scroll offset" { + var state = VirtualScrollState.init(10, 30); + state.scroll_offset = 45; // Scroll past 1.5 items + + const content_rect = Layout.Rect{ .x = 0, .y = 0, .w = 200, .h = 100 }; + + const rect0 = getItemRect(&state, content_rect, 0); + try std.testing.expectEqual(@as(i32, -45), rect0.y); + + const rect2 = getItemRect(&state, content_rect, 2); + try std.testing.expectEqual(@as(i32, 15), rect2.y); // 60 - 45 +} diff --git a/src/widgets/widgets.zig b/src/widgets/widgets.zig index 2781427..91ebcf6 100644 --- a/src/widgets/widgets.zig +++ b/src/widgets/widgets.zig @@ -41,6 +41,7 @@ pub const breadcrumb = @import("breadcrumb.zig"); pub const canvas = @import("canvas.zig"); pub const chart = @import("chart.zig"); pub const icon = @import("icon.zig"); +pub const virtual_scroll = @import("virtual_scroll.zig"); // ============================================================================= // Re-exports for convenience @@ -313,6 +314,13 @@ pub const IconSize = icon.Size; pub const IconConfig = icon.Config; pub const IconColors = icon.Colors; +// VirtualScroll +pub const VirtualScroll = virtual_scroll; +pub const VirtualScrollState = virtual_scroll.VirtualScrollState; +pub const VirtualScrollConfig = virtual_scroll.VirtualScrollConfig; +pub const VirtualScrollColors = virtual_scroll.VirtualScrollColors; +pub const VirtualScrollResult = virtual_scroll.VirtualScrollResult; + // ============================================================================= // Tests // ============================================================================= diff --git a/src/zcatgui.zig b/src/zcatgui.zig index c8d1b34..bdf338c 100644 --- a/src/zcatgui.zig +++ b/src/zcatgui.zig @@ -69,8 +69,38 @@ pub const render = struct { pub const ttf = @import("render/ttf.zig"); pub const TtfFont = ttf.TtfFont; pub const FontRef = ttf.FontRef; + pub const animation = @import("render/animation.zig"); + pub const effects = @import("render/effects.zig"); + pub const antialiasing = @import("render/antialiasing.zig"); }; +// Animation re-exports +pub const Animation = render.animation.Animation; +pub const AnimationManager = render.animation.AnimationManager; +pub const Easing = render.animation.Easing; +pub const lerp = render.animation.lerp; +pub const lerpInt = render.animation.lerpInt; + +// Effects re-exports +pub const Shadow = render.effects.Shadow; +pub const Gradient = render.effects.Gradient; +pub const GradientDirection = render.effects.GradientDirection; +pub const applyShadow = render.effects.applyShadow; +pub const applyGradient = render.effects.applyGradient; +pub const applyBlur = render.effects.applyBlur; +pub const interpolateColor = render.effects.interpolateColor; +pub const applyOpacity = render.effects.applyOpacity; +pub const highlight = render.effects.highlight; +pub const lowlight = render.effects.lowlight; + +// Anti-aliasing re-exports +pub const AAQuality = render.antialiasing.Quality; +pub const drawLineAA = render.antialiasing.drawLineAA; +pub const drawCircleAA = render.antialiasing.drawCircleAA; +pub const drawRoundedRectAA = render.antialiasing.drawRoundedRectAA; +pub const drawEllipseAA = render.antialiasing.drawEllipseAA; +pub const drawPolygonAA = render.antialiasing.drawPolygonAA; + // ============================================================================= // Backend // =============================================================================