New Widgets (4): - Image: Display images with RGBA/RGB/grayscale support, fit modes (contain, cover, fill, scale_down), LRU cache - ReorderableList: Drag and drop list reordering with drag handle, remove button, add button support - ColorPicker: RGB/HSL/Palette modes, alpha slider, preview comparison, recent colors - DatePicker: Calendar view with month navigation, range selection, min/max dates, week numbers Widget count: 27 widgets Test count: 186 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
554 lines
18 KiB
Zig
554 lines
18 KiB
Zig
//! DatePicker Widget - Date selection with calendar
|
|
//!
|
|
//! A date picker with calendar view, supporting single date
|
|
//! and date range selection.
|
|
|
|
const std = @import("std");
|
|
const Context = @import("../core/context.zig").Context;
|
|
const Command = @import("../core/command.zig");
|
|
const Layout = @import("../core/layout.zig");
|
|
const Style = @import("../core/style.zig");
|
|
const Input = @import("../core/input.zig");
|
|
|
|
/// Date structure
|
|
pub const Date = struct {
|
|
year: u16,
|
|
month: u8, // 1-12
|
|
day: u8, // 1-31
|
|
|
|
const Self = @This();
|
|
|
|
/// Create a date
|
|
pub fn init(year: u16, month: u8, day: u8) Self {
|
|
return .{
|
|
.year = year,
|
|
.month = @min(12, @max(1, month)),
|
|
.day = @min(31, @max(1, day)),
|
|
};
|
|
}
|
|
|
|
/// Get today's date (simplified - uses epoch calculation)
|
|
pub fn today() Self {
|
|
const ts = std.time.timestamp();
|
|
return fromTimestamp(ts);
|
|
}
|
|
|
|
/// Create from unix timestamp
|
|
pub fn fromTimestamp(ts: i64) Self {
|
|
// Simplified calculation - doesn't handle all edge cases
|
|
const days_since_epoch = @divTrunc(ts, 86400);
|
|
const remaining_days = days_since_epoch + 719468; // Days from year 0
|
|
|
|
const era: i64 = @divTrunc(if (remaining_days >= 0) remaining_days else remaining_days - 146096, 146097);
|
|
const doe: u32 = @intCast(remaining_days - era * 146097);
|
|
const yoe = @divTrunc(doe - @divTrunc(doe, 1460) + @divTrunc(doe, 36524) - @divTrunc(doe, 146096), 365);
|
|
const y: i64 = @as(i64, @intCast(yoe)) + era * 400;
|
|
const doy = doe - (365 * yoe + @divTrunc(yoe, 4) - @divTrunc(yoe, 100));
|
|
const mp = @divTrunc(5 * doy + 2, 153);
|
|
const d: u8 = @intCast(doy - @divTrunc(153 * mp + 2, 5) + 1);
|
|
const m: u8 = @intCast(if (mp < 10) mp + 3 else mp - 9);
|
|
const year: u16 = @intCast(y + @as(i64, if (m <= 2) 1 else 0));
|
|
|
|
return .{ .year = year, .month = m, .day = d };
|
|
}
|
|
|
|
/// Check if dates are equal
|
|
pub fn eql(self: Self, other: Self) bool {
|
|
return self.year == other.year and self.month == other.month and self.day == other.day;
|
|
}
|
|
|
|
/// Compare dates
|
|
pub fn compare(self: Self, other: Self) std.math.Order {
|
|
if (self.year != other.year) {
|
|
return std.math.order(self.year, other.year);
|
|
}
|
|
if (self.month != other.month) {
|
|
return std.math.order(self.month, other.month);
|
|
}
|
|
return std.math.order(self.day, other.day);
|
|
}
|
|
|
|
/// Check if this date is before another
|
|
pub fn isBefore(self: Self, other: Self) bool {
|
|
return self.compare(other) == .lt;
|
|
}
|
|
|
|
/// Check if this date is after another
|
|
pub fn isAfter(self: Self, other: Self) bool {
|
|
return self.compare(other) == .gt;
|
|
}
|
|
|
|
/// Get days in month
|
|
pub fn daysInMonth(self: Self) u8 {
|
|
return getDaysInMonth(self.year, self.month);
|
|
}
|
|
|
|
/// Get day of week (0 = Sunday, 6 = Saturday)
|
|
pub fn dayOfWeek(self: Self) u8 {
|
|
// Zeller's congruence
|
|
var y = @as(i32, self.year);
|
|
var m = @as(i32, self.month);
|
|
|
|
if (m < 3) {
|
|
m += 12;
|
|
y -= 1;
|
|
}
|
|
|
|
const q = @as(i32, self.day);
|
|
const k = @mod(y, 100);
|
|
const j = @divTrunc(y, 100);
|
|
|
|
const h = @mod(q + @divTrunc(13 * (m + 1), 5) + k + @divTrunc(k, 4) + @divTrunc(j, 4) - 2 * j, 7);
|
|
|
|
// Convert to 0=Sunday
|
|
return @intCast(@mod(h + 6, 7));
|
|
}
|
|
|
|
/// Format as string (YYYY-MM-DD)
|
|
pub fn format(self: Self, buf: []u8) []const u8 {
|
|
const result = std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}", .{
|
|
self.year,
|
|
self.month,
|
|
self.day,
|
|
}) catch return "";
|
|
return result;
|
|
}
|
|
};
|
|
|
|
/// Get days in a month
|
|
fn getDaysInMonth(year: u16, month: u8) u8 {
|
|
const days = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
|
|
if (month < 1 or month > 12) return 0;
|
|
|
|
if (month == 2 and isLeapYear(year)) {
|
|
return 29;
|
|
}
|
|
return days[month - 1];
|
|
}
|
|
|
|
/// Check if year is a leap year
|
|
fn isLeapYear(year: u16) bool {
|
|
return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0);
|
|
}
|
|
|
|
/// Date picker state
|
|
pub const State = struct {
|
|
/// Selected date
|
|
selected: ?Date = null,
|
|
/// Range start (for range selection)
|
|
range_start: ?Date = null,
|
|
/// Range end (for range selection)
|
|
range_end: ?Date = null,
|
|
/// Currently viewed month
|
|
view_month: u8 = 1,
|
|
/// Currently viewed year
|
|
view_year: u16 = 2025,
|
|
/// Is picker open (for popup variant)
|
|
open: bool = false,
|
|
/// Hover date
|
|
hover_date: ?Date = null,
|
|
|
|
const Self = @This();
|
|
|
|
/// Initialize with today's date
|
|
pub fn init() Self {
|
|
const today = Date.today();
|
|
return .{
|
|
.view_month = today.month,
|
|
.view_year = today.year,
|
|
};
|
|
}
|
|
|
|
/// Navigate to previous month
|
|
pub fn prevMonth(self: *Self) void {
|
|
if (self.view_month == 1) {
|
|
self.view_month = 12;
|
|
self.view_year -|= 1;
|
|
} else {
|
|
self.view_month -= 1;
|
|
}
|
|
}
|
|
|
|
/// Navigate to next month
|
|
pub fn nextMonth(self: *Self) void {
|
|
if (self.view_month == 12) {
|
|
self.view_month = 1;
|
|
self.view_year += 1;
|
|
} else {
|
|
self.view_month += 1;
|
|
}
|
|
}
|
|
|
|
/// Navigate to previous year
|
|
pub fn prevYear(self: *Self) void {
|
|
self.view_year -|= 1;
|
|
}
|
|
|
|
/// Navigate to next year
|
|
pub fn nextYear(self: *Self) void {
|
|
self.view_year += 1;
|
|
}
|
|
|
|
/// Set view to show selected date
|
|
pub fn showSelected(self: *Self) void {
|
|
if (self.selected) |date| {
|
|
self.view_month = date.month;
|
|
self.view_year = date.year;
|
|
}
|
|
}
|
|
};
|
|
|
|
/// Date picker configuration
|
|
pub const Config = struct {
|
|
/// Minimum selectable date
|
|
min_date: ?Date = null,
|
|
/// Maximum selectable date
|
|
max_date: ?Date = null,
|
|
/// First day of week (0 = Sunday, 1 = Monday)
|
|
first_day_of_week: u8 = 1,
|
|
/// Show week numbers
|
|
show_week_numbers: bool = false,
|
|
/// Enable range selection
|
|
range_selection: bool = false,
|
|
/// Show navigation arrows
|
|
show_navigation: bool = true,
|
|
/// Cell size
|
|
cell_size: u32 = 28,
|
|
};
|
|
|
|
/// Date picker colors
|
|
pub const Colors = struct {
|
|
background: Style.Color = Style.Color.rgba(40, 40, 40, 255),
|
|
header_bg: Style.Color = Style.Color.rgba(50, 50, 50, 255),
|
|
text: Style.Color = Style.Color.rgba(220, 220, 220, 255),
|
|
text_muted: Style.Color = Style.Color.rgba(120, 120, 120, 255),
|
|
today: Style.Color = Style.Color.rgba(100, 149, 237, 255),
|
|
selected: Style.Color = Style.Color.rgba(70, 130, 180, 255),
|
|
range: Style.Color = Style.Color.rgba(70, 130, 180, 100),
|
|
hover: Style.Color = Style.Color.rgba(60, 60, 60, 255),
|
|
disabled: Style.Color = Style.Color.rgba(80, 80, 80, 255),
|
|
border: Style.Color = Style.Color.rgba(80, 80, 80, 255),
|
|
weekend: Style.Color = Style.Color.rgba(180, 100, 100, 255),
|
|
|
|
pub fn fromTheme(theme: Style.Theme) Colors {
|
|
return .{
|
|
.background = theme.background,
|
|
.header_bg = theme.background.lighten(10),
|
|
.text = theme.foreground,
|
|
.text_muted = theme.secondary,
|
|
.today = theme.primary,
|
|
.selected = theme.selection_bg,
|
|
.range = theme.selection_bg.withAlpha(100),
|
|
.hover = theme.background.lighten(15),
|
|
.disabled = theme.secondary,
|
|
.border = theme.border,
|
|
.weekend = theme.error_color.lighten(30),
|
|
};
|
|
}
|
|
};
|
|
|
|
/// Date picker result
|
|
pub const Result = struct {
|
|
/// Date was selected/changed
|
|
changed: bool = false,
|
|
/// Selected date
|
|
date: ?Date = null,
|
|
/// Selected range (if range selection enabled)
|
|
range_start: ?Date = null,
|
|
range_end: ?Date = null,
|
|
};
|
|
|
|
/// Draw a date picker / calendar
|
|
pub fn datePicker(ctx: *Context, state: *State) Result {
|
|
return datePickerEx(ctx, state, .{}, .{});
|
|
}
|
|
|
|
/// Draw a date picker with configuration
|
|
pub fn datePickerEx(
|
|
ctx: *Context,
|
|
state: *State,
|
|
config: Config,
|
|
colors: Colors,
|
|
) Result {
|
|
const bounds = ctx.layout.nextRect();
|
|
return datePickerRect(ctx, bounds, state, config, colors);
|
|
}
|
|
|
|
/// Draw a date picker in specific rectangle
|
|
pub fn datePickerRect(
|
|
ctx: *Context,
|
|
bounds: Layout.Rect,
|
|
state: *State,
|
|
config: Config,
|
|
colors: Colors,
|
|
) Result {
|
|
var result = Result{
|
|
.date = state.selected,
|
|
.range_start = state.range_start,
|
|
.range_end = state.range_end,
|
|
};
|
|
|
|
if (bounds.isEmpty()) return result;
|
|
|
|
const mouse = ctx.input.mousePos();
|
|
const mouse_pressed = ctx.input.mousePressed(.left);
|
|
|
|
// Draw background
|
|
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, colors.background));
|
|
ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, colors.border));
|
|
|
|
const padding: i32 = 8;
|
|
var y = bounds.y + padding;
|
|
|
|
// Header with month/year and navigation
|
|
const header_h: u32 = 24;
|
|
|
|
ctx.pushCommand(Command.rect(
|
|
bounds.x + padding,
|
|
y,
|
|
bounds.w -| @as(u32, @intCast(padding * 2)),
|
|
header_h,
|
|
colors.header_bg,
|
|
));
|
|
|
|
// Month/Year text
|
|
var month_buf: [32]u8 = undefined;
|
|
const month_names = [_][]const u8{
|
|
"January", "February", "March", "April", "May", "June",
|
|
"July", "August", "September", "October", "November", "December",
|
|
};
|
|
const month_name = if (state.view_month >= 1 and state.view_month <= 12)
|
|
month_names[state.view_month - 1]
|
|
else
|
|
"???";
|
|
|
|
const header_text = std.fmt.bufPrint(&month_buf, "{s} {d}", .{ month_name, state.view_year }) catch "???";
|
|
const text_x = bounds.x + @as(i32, @intCast(bounds.w / 2)) - @as(i32, @intCast(header_text.len * 4));
|
|
ctx.pushCommand(Command.text(text_x, y + 8, header_text, colors.text));
|
|
|
|
// Navigation arrows
|
|
if (config.show_navigation) {
|
|
const nav_y = y + 8;
|
|
|
|
// Prev month
|
|
const prev_rect = Layout.Rect.init(bounds.x + padding, y, 24, header_h);
|
|
ctx.pushCommand(Command.text(bounds.x + padding + 8, nav_y, "<", colors.text));
|
|
if (prev_rect.contains(mouse.x, mouse.y) and mouse_pressed) {
|
|
state.prevMonth();
|
|
}
|
|
|
|
// Next month
|
|
const next_rect = Layout.Rect.init(
|
|
bounds.x + @as(i32, @intCast(bounds.w)) - padding - 24,
|
|
y,
|
|
24,
|
|
header_h,
|
|
);
|
|
ctx.pushCommand(Command.text(next_rect.x + 8, nav_y, ">", colors.text));
|
|
if (next_rect.contains(mouse.x, mouse.y) and mouse_pressed) {
|
|
state.nextMonth();
|
|
}
|
|
}
|
|
|
|
y += @as(i32, @intCast(header_h)) + 4;
|
|
|
|
// Day headers
|
|
const cell_size = config.cell_size;
|
|
const week_num_w: u32 = if (config.show_week_numbers) 24 else 0;
|
|
const grid_x = bounds.x + padding + @as(i32, @intCast(week_num_w));
|
|
|
|
const day_headers = if (config.first_day_of_week == 0)
|
|
[_][]const u8{ "Su", "Mo", "Tu", "We", "Th", "Fr", "Sa" }
|
|
else
|
|
[_][]const u8{ "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su" };
|
|
|
|
for (day_headers, 0..) |header, i| {
|
|
const hx = grid_x + @as(i32, @intCast(i * cell_size));
|
|
ctx.pushCommand(Command.text(hx + 8, y, header, colors.text_muted));
|
|
}
|
|
|
|
y += 16;
|
|
|
|
// Calendar grid
|
|
const first_day = Date.init(state.view_year, state.view_month, 1);
|
|
var first_dow = first_day.dayOfWeek();
|
|
|
|
// Adjust for first day of week
|
|
if (config.first_day_of_week == 1) {
|
|
first_dow = if (first_dow == 0) 6 else first_dow - 1;
|
|
}
|
|
|
|
const days_in_month = first_day.daysInMonth();
|
|
const today = Date.today();
|
|
|
|
var day: u8 = 1;
|
|
var row: u32 = 0;
|
|
|
|
while (day <= days_in_month) {
|
|
const row_y = y + @as(i32, @intCast(row * cell_size));
|
|
|
|
// Week number
|
|
if (config.show_week_numbers and row == 0) {
|
|
// Simplified week number calculation
|
|
var week_buf: [4]u8 = undefined;
|
|
const week_text = std.fmt.bufPrint(&week_buf, "{d}", .{row + 1}) catch "";
|
|
ctx.pushCommand(Command.text(bounds.x + padding + 4, row_y + 8, week_text, colors.text_muted));
|
|
}
|
|
|
|
var col: u32 = if (row == 0) first_dow else 0;
|
|
while (col < 7 and day <= days_in_month) : (col += 1) {
|
|
const cell_x = grid_x + @as(i32, @intCast(col * cell_size));
|
|
const cell_rect = Layout.Rect.init(cell_x, row_y, cell_size, cell_size);
|
|
const current_date = Date.init(state.view_year, state.view_month, day);
|
|
|
|
// Check if date is disabled
|
|
const is_disabled = (config.min_date != null and current_date.isBefore(config.min_date.?)) or
|
|
(config.max_date != null and current_date.isAfter(config.max_date.?));
|
|
|
|
// Check states
|
|
const is_today = current_date.eql(today);
|
|
const is_selected = state.selected != null and current_date.eql(state.selected.?);
|
|
const is_hovered = cell_rect.contains(mouse.x, mouse.y);
|
|
const is_weekend = (col == 5 or col == 6); // Sat/Sun when Monday first
|
|
|
|
// Check if in range
|
|
var is_in_range = false;
|
|
if (config.range_selection and state.range_start != null and state.range_end != null) {
|
|
is_in_range = !current_date.isBefore(state.range_start.?) and
|
|
!current_date.isAfter(state.range_end.?);
|
|
}
|
|
|
|
// Draw cell background
|
|
if (is_selected) {
|
|
ctx.pushCommand(Command.rect(cell_x, row_y, cell_size, cell_size, colors.selected));
|
|
} else if (is_in_range) {
|
|
ctx.pushCommand(Command.rect(cell_x, row_y, cell_size, cell_size, colors.range));
|
|
} else if (is_hovered and !is_disabled) {
|
|
ctx.pushCommand(Command.rect(cell_x, row_y, cell_size, cell_size, colors.hover));
|
|
}
|
|
|
|
// Draw today indicator
|
|
if (is_today) {
|
|
ctx.pushCommand(Command.rectOutline(cell_x + 2, row_y + 2, cell_size - 4, cell_size - 4, colors.today));
|
|
}
|
|
|
|
// Draw day number
|
|
var day_buf: [4]u8 = undefined;
|
|
const day_text = std.fmt.bufPrint(&day_buf, "{d}", .{day}) catch "";
|
|
const text_color = if (is_disabled)
|
|
colors.disabled
|
|
else if (is_selected)
|
|
colors.text
|
|
else if (is_weekend)
|
|
colors.weekend
|
|
else
|
|
colors.text;
|
|
|
|
const tx = cell_x + @as(i32, @intCast((cell_size - day_text.len * 8) / 2));
|
|
ctx.pushCommand(Command.text(tx, row_y + @as(i32, @intCast((cell_size - 8) / 2)), day_text, text_color));
|
|
|
|
// Handle click
|
|
if (is_hovered and mouse_pressed and !is_disabled) {
|
|
if (config.range_selection) {
|
|
if (state.range_start == null or state.range_end != null) {
|
|
state.range_start = current_date;
|
|
state.range_end = null;
|
|
} else {
|
|
if (current_date.isBefore(state.range_start.?)) {
|
|
state.range_end = state.range_start;
|
|
state.range_start = current_date;
|
|
} else {
|
|
state.range_end = current_date;
|
|
}
|
|
}
|
|
result.changed = true;
|
|
result.range_start = state.range_start;
|
|
result.range_end = state.range_end;
|
|
} else {
|
|
state.selected = current_date;
|
|
result.changed = true;
|
|
result.date = current_date;
|
|
}
|
|
}
|
|
|
|
day += 1;
|
|
}
|
|
|
|
row += 1;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
test "Date init" {
|
|
const date = Date.init(2025, 12, 25);
|
|
try std.testing.expectEqual(@as(u16, 2025), date.year);
|
|
try std.testing.expectEqual(@as(u8, 12), date.month);
|
|
try std.testing.expectEqual(@as(u8, 25), date.day);
|
|
}
|
|
|
|
test "Date compare" {
|
|
const d1 = Date.init(2025, 1, 1);
|
|
const d2 = Date.init(2025, 1, 2);
|
|
const d3 = Date.init(2025, 1, 1);
|
|
|
|
try std.testing.expect(d1.isBefore(d2));
|
|
try std.testing.expect(d2.isAfter(d1));
|
|
try std.testing.expect(d1.eql(d3));
|
|
}
|
|
|
|
test "Date daysInMonth" {
|
|
try std.testing.expectEqual(@as(u8, 31), Date.init(2025, 1, 1).daysInMonth());
|
|
try std.testing.expectEqual(@as(u8, 28), Date.init(2025, 2, 1).daysInMonth());
|
|
try std.testing.expectEqual(@as(u8, 29), Date.init(2024, 2, 1).daysInMonth()); // Leap year
|
|
try std.testing.expectEqual(@as(u8, 30), Date.init(2025, 4, 1).daysInMonth());
|
|
}
|
|
|
|
test "isLeapYear" {
|
|
try std.testing.expect(isLeapYear(2024));
|
|
try std.testing.expect(!isLeapYear(2025));
|
|
try std.testing.expect(isLeapYear(2000));
|
|
try std.testing.expect(!isLeapYear(1900));
|
|
}
|
|
|
|
test "State navigation" {
|
|
var state = State{
|
|
.view_month = 1,
|
|
.view_year = 2025,
|
|
};
|
|
|
|
state.prevMonth();
|
|
try std.testing.expectEqual(@as(u8, 12), state.view_month);
|
|
try std.testing.expectEqual(@as(u16, 2024), state.view_year);
|
|
|
|
state.nextMonth();
|
|
try std.testing.expectEqual(@as(u8, 1), state.view_month);
|
|
try std.testing.expectEqual(@as(u16, 2025), state.view_year);
|
|
}
|
|
|
|
test "datePicker generates commands" {
|
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
|
defer ctx.deinit();
|
|
|
|
var state = State.init();
|
|
|
|
ctx.beginFrame();
|
|
ctx.layout.row_height = 300;
|
|
|
|
_ = datePicker(&ctx, &state);
|
|
|
|
try std.testing.expect(ctx.commands.items.len >= 2);
|
|
|
|
ctx.endFrame();
|
|
}
|
|
|
|
test "Date format" {
|
|
const date = Date.init(2025, 12, 9);
|
|
var buf: [16]u8 = undefined;
|
|
const formatted = date.format(&buf);
|
|
try std.testing.expectEqualStrings("2025-12-09", formatted);
|
|
}
|