//! 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); }