diff --git a/apps/web/content/widgets.json b/apps/web/content/widgets.json
index 4f3d703..d34a821 100644
--- a/apps/web/content/widgets.json
+++ b/apps/web/content/widgets.json
@@ -784,6 +784,61 @@
}
}
},
+ {
+ "id": "calendar",
+ "title": "Calendar",
+ "category": "Data",
+ "width": 20,
+ "height": 8,
+ "blurb": "A month as a grid, with per-day styling. The dates are arithmetic, not a host calendar.",
+ "preview": "
September 2026 \nMo Tu We Th Fr Sa Su\n 1 2 3 4 5 6\n 7 8 9 10 11 12 13\n14 15 16 17 18 19 20\n21 22 23 24 25 26 27\n28 29 30 \n
",
+ "examples": {
+ "typescript": {
+ "code": "export function calendar(ui: Container, theme: Theme): void {\n // The dates are arithmetic, not a host calendar: every port has a different\n // Date and none of them is consulted.\n ui.calendar({\n year: 2026,\n month: 9,\n selected: 8,\n marks: [{ day: 15, color: theme.warning }, { day: 22, color: theme.success, bold: true }],\n });\n}",
+ "syntax": "ts"
+ },
+ "javascript": {
+ "code": "export function calendar(ui, theme) {\n // The dates are arithmetic, not a host calendar: every port has a different\n // Date and none of them is consulted.\n ui.calendar({\n year: 2026,\n month: 9,\n selected: 8,\n marks: [{ day: 15, color: theme.warning }, { day: 22, color: theme.success, bold: true }],\n });\n}",
+ "syntax": "js"
+ },
+ "rust": {
+ "code": "pub fn calendar(ui: &mut Container) {\n // The dates are arithmetic, not a host calendar: every port has a different\n // date type and none of them is consulted.\n ui.calendar(CalendarOptions {\n selected: Some(8),\n marks: vec![\n CalendarMark { day: 15, ..Default::default() },\n CalendarMark { day: 22, bold: true, ..Default::default() },\n ],\n ..CalendarOptions::new(2026, 9)\n });\n}",
+ "syntax": "rust"
+ },
+ "go": {
+ "code": "func Calendar(ui *hqtui.Container) {\n\t// The dates are arithmetic, not a host calendar: every port has a different\n\t// date type and none of them is consulted.\n\teighth := 8\n\tui.Calendar(hqtui.CalendarOptions{\n\t\tYear: 2026, Month: 9, Selected: &eighth,\n\t\tMarks: []hqtui.CalendarMark{{Day: 15}, {Day: 22, Bold: true}},\n\t})\n}",
+ "syntax": "go"
+ },
+ "python": {
+ "code": "def calendar(ui: Container) -> None:\n # The dates are arithmetic, not a host calendar: every port has a different\n # date type and none of them is consulted.\n ui.calendar(w.CalendarOptions(\n year=2026, month=9, selected=8,\n marks=[w.CalendarMark(day=15), w.CalendarMark(day=22, bold=True)],\n ))",
+ "syntax": "python"
+ },
+ "zig": {
+ "code": "fn calendar(ui: *Container) anyerror!void {\n // The dates are arithmetic, not a host calendar: every port has a different\n // date type and none of them is consulted.\n try ui.calendar(.{\n .year = 2026,\n .month = 9,\n .selected = 8,\n .marks = &.{ .{ .day = 15 }, .{ .day = 22, .bold = true } },\n });\n}",
+ "syntax": "zig"
+ },
+ "cpp": {
+ "code": "void widget_calendar(Surface s) {\n // The dates are arithmetic, not a host calendar: every port has a different\n // date type and none of them is consulted.\n Calendar c{2026, 9};\n c.selected = 8;\n c.marks = {CalendarMark{15}, CalendarMark{22, 0, 0, true}};\n draw_calendar(s, c);\n}",
+ "syntax": "cpp"
+ },
+ "ruby": {
+ "code": "def calendar(ui)\n # The dates are arithmetic, not a host calendar: every port has a different\n # date type and none of them is consulted.\n ui.calendar(2026, 9, selected: 8, marks: [{ day: 15 }, { day: 22, bold: true }])\nend",
+ "syntax": "ruby"
+ },
+ "php": {
+ "code": "function widget_calendar(UI $ui): void\n{\n // The dates are arithmetic, not a host calendar: every port has a different\n // date type and none of them is consulted.\n $ui->calendar(2026, 9, [\n 'selected' => 8,\n 'marks' => [['day' => 15], ['day' => 22, 'bold' => true]],\n ]);\n}",
+ "syntax": "php"
+ },
+ "perl": {
+ "code": "sub widget_calendar {\n my ($ui) = @_;\n # The dates are arithmetic, not a host calendar: every port has a different\n # date type and none of them is consulted.\n $ui->calendar(2026, 9,\n selected => 8,\n marks => [ { day => 15 }, { day => 22, bold => 1 } ],\n );\n}",
+ "syntax": "perl"
+ },
+ "cobol": {
+ "code": "CALENDAR-WIDGET.\n MOVE \"calendar\" TO SR-KEY\n PERFORM START-WIDGET\n\n *> CALDAY carries one day; its key says whether that day is the selected\n *> one, a bold mark, or a plain one. They accumulate until CALENDAR draws.\n MOVE \"CALDAY\" TO SR-VERB\n MOVE \"SELECTED\" TO SR-KEY\n MOVE \"8\" TO SR-NUM\n PERFORM EMIT-RECORD\n\n MOVE \"CALDAY\" TO SR-VERB\n MOVE \"MARK\" TO SR-KEY\n MOVE \"15\" TO SR-NUM\n PERFORM EMIT-RECORD\n\n MOVE \"CALDAY\" TO SR-VERB\n MOVE \"BOLD\" TO SR-KEY\n MOVE \"22\" TO SR-NUM\n PERFORM EMIT-RECORD\n\n *> The text carries the month as \"year|month\".\n MOVE \"CALENDAR\" TO SR-VERB\n MOVE \"MONDAY\" TO SR-KEY\n MOVE \"2026|9\" TO SR-TEXT\n PERFORM EMIT-RECORD.",
+ "syntax": "cobol"
+ }
+ }
+ },
{
"id": "meter",
"title": "Meter",
diff --git a/apps/web/test/widgets.test.ts b/apps/web/test/widgets.test.ts
index a087d30..9543cd4 100644
--- a/apps/web/test/widgets.test.ts
+++ b/apps/web/test/widgets.test.ts
@@ -26,7 +26,7 @@ test("every language has a runnable example for every widget", () => {
});
test("the catalog covers the widgets and languages the site promises", () => {
- assert.equal(catalog.widgets.length, 30);
+ assert.equal(catalog.widgets.length, 31);
assert.equal(catalog.languages.length, 11);
assert.ok(catalog.languages.some((language) => language.id === "cobol"));
});
diff --git a/examples/widgets/build-catalog.ts b/examples/widgets/build-catalog.ts
index c630717..fe2b517 100644
--- a/examples/widgets/build-catalog.ts
+++ b/examples/widgets/build-catalog.ts
@@ -99,6 +99,8 @@ export const WIDGETS: WidgetSpec[] = [
blurb: "A bar over state you own, on any of the four edges, for anything that scrolls." },
{ id: "chart", title: "Chart", category: "Meters", width: 48, height: 9,
blurb: "Arbitrary (x, y) data with a domain on both axes. Lines, scatters and bars." },
+ { id: "calendar", title: "Calendar", category: "Data", width: 20, height: 8,
+ blurb: "A month as a grid, with per-day styling. The dates are arithmetic, not a host calendar." },
{ id: "meter", title: "Meter", category: "Meters", width: 48, height: 3,
blurb: "A labelled bar. Smooth or segmented, heat-colored by default." },
diff --git a/examples/widgets/gallery.js b/examples/widgets/gallery.js
index f0eed30..224449f 100644
--- a/examples/widgets/gallery.js
+++ b/examples/widgets/gallery.js
@@ -205,6 +205,19 @@ export function chart(ui, theme) {
}
// @end
+// @widget calendar
+export function calendar(ui, theme) {
+ // The dates are arithmetic, not a host calendar: every port has a different
+ // Date and none of them is consulted.
+ ui.calendar({
+ year: 2026,
+ month: 9,
+ selected: 8,
+ marks: [{ day: 15, color: theme.warning }, { day: 22, color: theme.success, bold: true }],
+ });
+}
+// @end
+
// ----------------------------------------------------------------- meters
// @widget meter
diff --git a/examples/widgets/gallery.ts b/examples/widgets/gallery.ts
index 8d3272e..fa41afc 100644
--- a/examples/widgets/gallery.ts
+++ b/examples/widgets/gallery.ts
@@ -207,6 +207,19 @@ export function chart(ui: Container, theme: Theme): void {
}
// @end
+// @widget calendar
+export function calendar(ui: Container, theme: Theme): void {
+ // The dates are arithmetic, not a host calendar: every port has a different
+ // Date and none of them is consulted.
+ ui.calendar({
+ year: 2026,
+ month: 9,
+ selected: 8,
+ marks: [{ day: 15, color: theme.warning }, { day: 22, color: theme.success, bold: true }],
+ });
+}
+// @end
+
// ----------------------------------------------------------------- meters
// @widget meter
diff --git a/packages/hqtui/src/ui.ts b/packages/hqtui/src/ui.ts
index d7adf1b..b35fbd0 100644
--- a/packages/hqtui/src/ui.ts
+++ b/packages/hqtui/src/ui.ts
@@ -471,6 +471,18 @@ export class Container {
return this.add((s) => W.drawFill(s, options), this.sizeOf(options, "fill"));
}
+ /**
+ * A month as a grid, with per-day styling.
+ *
+ * Sized to the month it shows: a month spans four, five or six week rows
+ * depending on where its first day falls, and reserving five leaves some
+ * months a row short and others a blank row long.
+ */
+ calendar(options: W.CalendarOptions & ContainerOptions): this {
+ const height = W.calendarHeight(options);
+ return this.add((s) => W.drawCalendar(s, options), this.sizeOf(options, height, height));
+ }
+
/** A filled area graph — `graph` with `fill` on. */
areaGraph(options: W.GraphOptions & ContainerOptions): this {
return this.graph({ fill: true, ...options });
diff --git a/packages/hqtui/src/widgets/calendar.ts b/packages/hqtui/src/widgets/calendar.ts
new file mode 100644
index 0000000..5c9ce90
--- /dev/null
+++ b/packages/hqtui/src/widgets/calendar.ts
@@ -0,0 +1,164 @@
+/**
+ * A month, as a grid, with per-day styling.
+ *
+ * The dates are computed rather than read from a host calendar. `Date` is a
+ * different object in every language this library is ported to -- different
+ * epochs, different month numbering, different opinions about time zones -- and
+ * a widget whose output depends on any of that cannot be held to a fixture. So
+ * the arithmetic is here, in terms every port already has: integers.
+ */
+import type { Surface, Align } from "../surface.ts";
+import type { Style } from "../buffer.ts";
+import { Attr } from "../buffer.ts";
+import type { Color } from "../color.ts";
+import { fit } from "../unicode.ts";
+
+export const MONTH_NAMES = [
+ "January", "February", "March", "April", "May", "June",
+ "July", "August", "September", "October", "November", "December",
+] as const;
+
+/** Two letters each, so a week is exactly as wide as its days. */
+export const WEEKDAY_NAMES = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const;
+
+/** How many days a month has. Months are 1-12, as people write them. */
+export function daysInMonth(year: number, month: number): number {
+ if (month === 2) return isLeapYear(year) ? 29 : 28;
+ // April, June, September, November.
+ if (month === 4 || month === 6 || month === 9 || month === 11) return 30;
+ return 31;
+}
+
+/**
+ * The Gregorian leap rule in full: every four years, except centuries, except
+ * every fourth century. Truncating it at "every four years" is right for
+ * 1901-2099 and wrong for 1900 and 2100, which is the kind of bug that sits
+ * quietly for decades.
+ */
+export function isLeapYear(year: number): boolean {
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
+}
+
+/**
+ * Day of the week, 0 = Sunday.
+ *
+ * Sakamoto's method: a table of month offsets plus the leap-day count, which
+ * is exact for any Gregorian date and needs nothing but integer arithmetic.
+ */
+export function dayOfWeek(year: number, month: number, day: number): number {
+ const offsets = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
+ // January and February belong to the previous year for leap-counting, since
+ // the leap day falls after them.
+ const y = month < 3 ? year - 1 : year;
+ const leaps = Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400);
+ const value = (y + leaps + offsets[month - 1] + day) % 7;
+ return ((value % 7) + 7) % 7;
+}
+
+export interface CalendarMark {
+ /** Day of the month, 1-31. */
+ day: number;
+ color?: Color;
+ background?: Color;
+ bold?: boolean;
+}
+
+export interface CalendarOptions {
+ year: number;
+ /** 1-12, as people write months rather than as `Date` numbers them. */
+ month: number;
+ /** Days worth pointing at: holidays, deadlines, days with something on. */
+ marks?: CalendarMark[];
+ /** Drawn in the accent colour, as the day the view is about. */
+ selected?: number;
+ /** The month and year above the grid. Default true. */
+ header?: boolean;
+ headerAlign?: Align;
+ /** The weekday initials above the days. Default true. */
+ weekdays?: boolean;
+ /** 0 for Sunday, 1 for Monday. Default 1, which is most of the world. */
+ weekStart?: 0 | 1;
+ color?: Color;
+ background?: Color;
+}
+
+/** Three cells a day, minus the separator the last column does not need. */
+const DAY_WIDTH = 3;
+const GRID_WIDTH = 7 * DAY_WIDTH - 1;
+
+export function drawCalendar(surface: Surface, options: CalendarOptions): void {
+ if (surface.empty) return;
+ const theme = surface.theme;
+ const { year, month } = options;
+ if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) return;
+
+ const base = options.color ?? theme.foreground;
+ const bg = options.background;
+ const weekStart = options.weekStart === 0 ? 0 : 1;
+ const marks = new Map();
+ for (const mark of options.marks ?? []) marks.set(mark.day, mark);
+
+ let row = 0;
+ if (options.header !== false) {
+ const label = `${MONTH_NAMES[month - 1]} ${year}`;
+ const width = Math.min(surface.width, GRID_WIDTH);
+ surface.text(0, row, fit(label, width, options.headerAlign ?? "center"), {
+ fg: theme.title,
+ bg,
+ attrs: Attr.Bold,
+ });
+ row++;
+ }
+
+ if (options.weekdays !== false) {
+ let x = 0;
+ for (let i = 0; i < 7; i++) {
+ const name = WEEKDAY_NAMES[(i + weekStart) % 7];
+ surface.text(x, row, name, { fg: theme.muted, bg });
+ x += DAY_WIDTH;
+ }
+ row++;
+ }
+
+ // Which column the 1st falls in, once the week has been rotated to start
+ // where the caller asked.
+ const first = (dayOfWeek(year, month, 1) - weekStart + 7) % 7;
+ const total = daysInMonth(year, month);
+
+ for (let day = 1; day <= total; day++) {
+ const cell = first + day - 1;
+ const y = row + Math.floor(cell / 7);
+ if (y >= surface.height) break;
+ const x = (cell % 7) * DAY_WIDTH;
+
+ const mark = marks.get(day);
+ const selected = options.selected === day;
+ const style: Style = {
+ fg: selected ? theme.background : mark?.color ?? base,
+ bg: selected ? theme.accent : mark?.background ?? bg,
+ attrs: selected || mark?.bold ? Attr.Bold : 0,
+ };
+ // Right-aligned in two cells, so the units column lines up down the week
+ // and a calendar reads as a table rather than as a paragraph of numbers.
+ surface.text(x, y, fit(String(day), 2, "right"), style);
+ }
+}
+
+/**
+ * How many rows a month needs, so a caller can size the panel around it.
+ *
+ * A month spans four, five or six week rows depending on where its first day
+ * falls; guessing five leaves February 2026 a row short in some years and a
+ * blank row long in others.
+ */
+export function calendarHeight(options: CalendarOptions): number {
+ const { year, month } = options;
+ if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) return 0;
+ const weekStart = options.weekStart === 0 ? 0 : 1;
+ const first = (dayOfWeek(year, month, 1) - weekStart + 7) % 7;
+ const weeks = Math.ceil((first + daysInMonth(year, month)) / 7);
+ return weeks + (options.header === false ? 0 : 1) + (options.weekdays === false ? 0 : 1);
+}
+
+/** The width a calendar wants, which is the same whatever month it shows. */
+export const CALENDAR_WIDTH = GRID_WIDTH;
diff --git a/packages/hqtui/src/widgets/index.ts b/packages/hqtui/src/widgets/index.ts
index cd51fc7..25f68b9 100644
--- a/packages/hqtui/src/widgets/index.ts
+++ b/packages/hqtui/src/widgets/index.ts
@@ -1,4 +1,5 @@
export * from "./text.ts";
+export * from "./calendar.ts";
export * from "./chart.ts";
export * from "./surface.ts";
export * from "./scrollbar.ts";
diff --git a/packages/hqtui/test/calendar.test.ts b/packages/hqtui/test/calendar.test.ts
new file mode 100644
index 0000000..f66cb20
--- /dev/null
+++ b/packages/hqtui/test/calendar.test.ts
@@ -0,0 +1,127 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { renderToScreen } from "../src/index.ts";
+import {
+ calendarHeight, dayOfWeek, daysInMonth, drawCalendar, isLeapYear,
+} from "../src/widgets/calendar.ts";
+import type { CalendarOptions } from "../src/widgets/calendar.ts";
+
+const rows = (options: CalendarOptions, width = 20, height = 10): string[] =>
+ renderToScreen(({ ui }) => ui.calendar(options), { width, height })
+ .text().split("\n").map((line) => line.trimEnd());
+
+test("calendar: the leap rule is the whole rule, not every fourth year", () => {
+ assert.equal(isLeapYear(2024), true);
+ assert.equal(isLeapYear(2025), false);
+ // The two cases a truncated rule gets wrong, and gets wrong for a century.
+ assert.equal(isLeapYear(1900), false);
+ assert.equal(isLeapYear(2000), true);
+ assert.equal(isLeapYear(2100), false);
+ assert.equal(daysInMonth(2024, 2), 29);
+ assert.equal(daysInMonth(2100, 2), 28);
+});
+
+test("calendar: month lengths", () => {
+ assert.deepEqual(
+ Array.from({ length: 12 }, (_, i) => daysInMonth(2025, i + 1)),
+ [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
+ );
+});
+
+test("calendar: the weekday arithmetic agrees with known dates", () => {
+ // 0 is Sunday. Checked against dates anyone can verify.
+ assert.equal(dayOfWeek(2000, 1, 1), 6, "1 Jan 2000 was a Saturday");
+ assert.equal(dayOfWeek(1970, 1, 1), 4, "the Unix epoch was a Thursday");
+ assert.equal(dayOfWeek(2026, 9, 8), 2, "8 Sep 2026 is a Tuesday");
+ // A leap day, and the day after it.
+ assert.equal(dayOfWeek(2024, 2, 29), 4);
+ assert.equal(dayOfWeek(2024, 3, 1), 5);
+});
+
+test("calendar: the host's Date is never consulted", () => {
+ // Every port has a different one, so agreement across six of them is only
+ // possible if none of them asks. This checks the TS side keeps that bargain.
+ const source = new URL("../src/widgets/calendar.ts", import.meta.url);
+ const text = readFileSync(source, "utf8");
+ assert.ok(!/\bnew Date\b|Date\.now/.test(text), "calendar.ts reached for Date");
+});
+
+test("calendar: a month starts in the right column", () => {
+ // September 2026 starts on a Tuesday, so with weeks starting Monday the 1st
+ // sits in the second column: two cells in.
+ const out = rows({ year: 2026, month: 9 });
+ assert.equal(out[0].trim(), "September 2026");
+ assert.equal(out[1], "Mo Tu We Th Fr Sa Su");
+ assert.equal(out[2], " 1 2 3 4 5 6");
+});
+
+test("calendar: the week can start on Sunday instead", () => {
+ const out = rows({ year: 2026, month: 9, weekStart: 0 });
+ assert.equal(out[1], "Su Mo Tu We Th Fr Sa");
+ // The same Tuesday is now the third column.
+ assert.equal(out[2], " 1 2 3 4 5");
+});
+
+test("calendar: the last day of the month is the last day drawn", () => {
+ const out = rows({ year: 2026, month: 9 }).filter((line) => line.length > 0);
+ const last = out[out.length - 1];
+ assert.ok(last.includes("30"), `expected the 30th: ${JSON.stringify(last)}`);
+ assert.ok(!last.includes("31"), "September has no 31st");
+});
+
+test("calendar: February in a leap year shows the 29th", () => {
+ const out = rows({ year: 2024, month: 2 }).join("\n");
+ assert.ok(out.includes("29"), out);
+ const plain = rows({ year: 2025, month: 2 }).join("\n");
+ assert.ok(!plain.includes("29"), plain);
+});
+
+test("calendar: height is the month's own, not a guess of five weeks", () => {
+ // February 2026 starts on a Sunday and has 28 days, so with weeks starting
+ // Monday it needs five rows; reserving five for every month is wrong in both
+ // directions across a year.
+ const heights = Array.from({ length: 12 }, (_, i) =>
+ calendarHeight({ year: 2026, month: i + 1 }));
+ assert.ok(new Set(heights).size > 1, `every month the same height: ${heights}`);
+ for (const [i, h] of heights.entries()) {
+ const drawn = rows({ year: 2026, month: i + 1 }, 20, 12).filter((l) => l.length > 0).length;
+ assert.equal(h, drawn, `month ${i + 1}: reserved ${h}, drew ${drawn}`);
+ }
+});
+
+test("calendar: the header and weekday rows can be turned off", () => {
+ const bare = rows({ year: 2026, month: 9, header: false, weekdays: false });
+ assert.equal(bare[0], " 1 2 3 4 5 6");
+ assert.equal(calendarHeight({ year: 2026, month: 9, header: false, weekdays: false }), 5);
+});
+
+test("calendar: a nonsense month draws nothing rather than something wrong", () => {
+ for (const month of [0, 13, -1]) {
+ const out = renderToScreen(
+ ({ ui }) => drawCalendar(ui.surface, { year: 2026, month }),
+ { width: 20, height: 8 },
+ ).text().trim();
+ assert.equal(out, "", `month ${month} drew something`);
+ assert.equal(calendarHeight({ year: 2026, month }), 0);
+ }
+});
+
+test("calendar: marks and the selected day are styled apart from the rest", () => {
+ const screen = renderToScreen(
+ ({ ui, theme }) => ui.calendar({
+ year: 2026,
+ month: 9,
+ selected: 8,
+ marks: [{ day: 15, color: theme.danger }],
+ }),
+ { width: 20, height: 10 },
+ );
+ const plain = renderToScreen(({ ui }) => ui.calendar({ year: 2026, month: 9 }), {
+ width: 20,
+ height: 10,
+ });
+ // The characters are identical; only the colours differ.
+ assert.equal(screen.text(), plain.text());
+ assert.notEqual(screen.buffer.fg.join(","), plain.buffer.fg.join(","));
+});
diff --git a/ports/bindings/src/bridge.cpp b/ports/bindings/src/bridge.cpp
index dd605b8..f0cdd22 100644
--- a/ports/bindings/src/bridge.cpp
+++ b/ports/bindings/src/bridge.cpp
@@ -79,7 +79,7 @@ void validate(const Json &n, int depth, int &count) {
"badge", "progress", "sparkline", "heatbar", "columns", "donut",
"list", "tree", "button", "checkbox", "select", "input",
"tabs", "statusbar", "label", "heading", "meters", "modal",
- "commandpalette", "tooltip", "scrollbar", "chart"};
+ "commandpalette", "tooltip", "scrollbar", "chart", "calendar"};
if (std::find(types.begin(), types.end(), type) == types.end())
throw std::runtime_error("unknown widget: " + type);
if (!n["children"].null() &&
@@ -271,6 +271,25 @@ void node(UI &ui, const Json &n, std::vector &overlays) {
if (d.segments.size() > 64)
throw std::runtime_error("too many donut segments");
ui.donut(d, size(n));
+ } else if (type == "calendar") {
+ Calendar cal;
+ cal.year = integer(n["year"], 1970, -9999, 9999);
+ cal.month = integer(n["month"], 1, 1, 12);
+ cal.selected = integer(n["selected"], 0, 0, 31);
+ cal.week_start = integer(n["weekStart"], 1, 0, 1);
+ if (!n["header"].null())
+ cal.header = n["header"].b(true);
+ if (!n["weekdays"].null())
+ cal.weekdays = n["weekdays"].b(true);
+ for (auto &mj : n["marks"].array()) {
+ CalendarMark mark;
+ mark.day = integer(mj["day"], 0, 0, 31);
+ mark.bold = mj["bold"].b(false);
+ cal.marks.push_back(mark);
+ }
+ if (cal.marks.size() > 64)
+ throw std::runtime_error("too many calendar marks");
+ ui.calendar(cal);
} else if (type == "chart") {
Chart chart;
for (auto &sj : n["series"].array()) {
diff --git a/ports/cobol/adapter/render.ts b/ports/cobol/adapter/render.ts
index 5e6bed6..ba47c19 100644
--- a/ports/cobol/adapter/render.ts
+++ b/ports/cobol/adapter/render.ts
@@ -110,6 +110,8 @@ export function draw(scene: Scene, ui: Container, theme: Theme): void {
let palette: { query: string; selected: number } | undefined;
let tooltip: { text: string; x: number; y: number } | undefined;
const chartSeries = new Map();
+ let calendarMarks: { day: number; bold: boolean }[] = [];
+ let calendarSelected: number | undefined;
let selected = 0;
let activeTab = 0;
@@ -143,6 +145,28 @@ export function draw(scene: Scene, ui: Container, theme: Theme): void {
case "SELECT":
selected = Number(record.num) || 0;
break;
+ case "CALDAY": {
+ // One marked day per record, like CHARTPT. num is the day; key says
+ // whether it is the selected one.
+ const day = Number(record.num) || 0;
+ if (record.key === "SELECTED") calendarSelected = day;
+ else calendarMarks.push({ day, bold: record.key === "BOLD" });
+ break;
+ }
+ case "CALENDAR": {
+ // text is "year|month"; the marks accumulated so far belong to it.
+ const [year = "", month = ""] = record.text.split("|");
+ ui.calendar({
+ year: Number(year) || 1970,
+ month: Number(month) || 1,
+ selected: calendarSelected,
+ marks: calendarMarks,
+ weekStart: record.key === "SUNDAY" ? 0 : 1,
+ });
+ calendarMarks = [];
+ calendarSelected = undefined;
+ break;
+ }
case "CHARTPT": {
// One point per record, like GRAPHPT. key names the series it joins, so
// a flat record stream can describe several of them.
diff --git a/ports/cobol/examples/widgets.cbl b/ports/cobol/examples/widgets.cbl
index 603730f..0c92ea0 100644
--- a/ports/cobol/examples/widgets.cbl
+++ b/ports/cobol/examples/widgets.cbl
@@ -70,6 +70,7 @@ MAIN-PARAGRAPH.
PERFORM LIST-WIDGET
PERFORM SCROLLBAR-WIDGET
PERFORM CHART-WIDGET
+ PERFORM CALENDAR-WIDGET
PERFORM TREE-WIDGET
PERFORM BUTTON-WIDGET
PERFORM CHECKBOX-WIDGET
@@ -444,6 +445,35 @@ CHART-WIDGET.
PERFORM EMIT-RECORD.
*> @end
+*> @widget calendar
+CALENDAR-WIDGET.
+ MOVE "calendar" TO SR-KEY
+ PERFORM START-WIDGET
+
+ *> CALDAY carries one day; its key says whether that day is the selected
+ *> one, a bold mark, or a plain one. They accumulate until CALENDAR draws.
+ MOVE "CALDAY" TO SR-VERB
+ MOVE "SELECTED" TO SR-KEY
+ MOVE "8" TO SR-NUM
+ PERFORM EMIT-RECORD
+
+ MOVE "CALDAY" TO SR-VERB
+ MOVE "MARK" TO SR-KEY
+ MOVE "15" TO SR-NUM
+ PERFORM EMIT-RECORD
+
+ MOVE "CALDAY" TO SR-VERB
+ MOVE "BOLD" TO SR-KEY
+ MOVE "22" TO SR-NUM
+ PERFORM EMIT-RECORD
+
+ *> The text carries the month as "year|month".
+ MOVE "CALENDAR" TO SR-VERB
+ MOVE "MONDAY" TO SR-KEY
+ MOVE "2026|9" TO SR-TEXT
+ PERFORM EMIT-RECORD.
+*> @end
+
*> @widget tree
TREE-WIDGET.
MOVE "tree" TO SR-KEY
diff --git a/ports/conformance/fixtures/widgets.json b/ports/conformance/fixtures/widgets.json
index cc7573a..8138101 100644
--- a/ports/conformance/fixtures/widgets.json
+++ b/ports/conformance/fixtures/widgets.json
@@ -850,6 +850,2572 @@
]
}
},
+ {
+ "name": "calendar",
+ "width": 20,
+ "height": 8,
+ "result": {
+ "width": 20,
+ "height": 8,
+ "chars": [
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 83
+ ],
+ [
+ 1,
+ 101
+ ],
+ [
+ 1,
+ 112
+ ],
+ [
+ 1,
+ 116
+ ],
+ [
+ 1,
+ 101
+ ],
+ [
+ 1,
+ 109
+ ],
+ [
+ 1,
+ 98
+ ],
+ [
+ 1,
+ 101
+ ],
+ [
+ 1,
+ 114
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 77
+ ],
+ [
+ 1,
+ 111
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 84
+ ],
+ [
+ 1,
+ 117
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 87
+ ],
+ [
+ 1,
+ 101
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 84
+ ],
+ [
+ 1,
+ 104
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 70
+ ],
+ [
+ 1,
+ 114
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 83
+ ],
+ [
+ 1,
+ 97
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 83
+ ],
+ [
+ 1,
+ 117
+ ],
+ [
+ 4,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 52
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 53
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 55
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 56
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 57
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 2,
+ 49
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 52
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 53
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 55
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 56
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 57
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 2,
+ 50
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 52
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 53
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 55
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 56
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 57
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 32,
+ 32
+ ]
+ ],
+ "fg": [
+ [
+ 20,
+ 25092863
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 120,
+ 29806811
+ ]
+ ],
+ "bg": [
+ [
+ 160,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 20,
+ 1
+ ],
+ [
+ 140,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " September 2026 ",
+ "Mo Tu We Th Fr Sa Su",
+ " 1 2 3 4 5 6",
+ " 7 8 9 10 11 12 13",
+ "14 15 16 17 18 19 20",
+ "21 22 23 24 25 26 27",
+ "28 29 30 ",
+ " "
+ ]
+ }
+ },
+ {
+ "name": "calendar-sunday",
+ "width": 20,
+ "height": 8,
+ "result": {
+ "width": 20,
+ "height": 8,
+ "chars": [
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 83
+ ],
+ [
+ 1,
+ 101
+ ],
+ [
+ 1,
+ 112
+ ],
+ [
+ 1,
+ 116
+ ],
+ [
+ 1,
+ 101
+ ],
+ [
+ 1,
+ 109
+ ],
+ [
+ 1,
+ 98
+ ],
+ [
+ 1,
+ 101
+ ],
+ [
+ 1,
+ 114
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 83
+ ],
+ [
+ 1,
+ 117
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 77
+ ],
+ [
+ 1,
+ 111
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 84
+ ],
+ [
+ 1,
+ 117
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 87
+ ],
+ [
+ 1,
+ 101
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 84
+ ],
+ [
+ 1,
+ 104
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 70
+ ],
+ [
+ 1,
+ 114
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 83
+ ],
+ [
+ 1,
+ 97
+ ],
+ [
+ 7,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 52
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 53
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 55
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 56
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 57
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 2,
+ 49
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 52
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 53
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 55
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 56
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 57
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 2,
+ 50
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 52
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 53
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 55
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 56
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 57
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 29,
+ 32
+ ]
+ ],
+ "fg": [
+ [
+ 20,
+ 25092863
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 120,
+ 29806811
+ ]
+ ],
+ "bg": [
+ [
+ 160,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 20,
+ 1
+ ],
+ [
+ 140,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " September 2026 ",
+ "Su Mo Tu We Th Fr Sa",
+ " 1 2 3 4 5",
+ " 6 7 8 9 10 11 12",
+ "13 14 15 16 17 18 19",
+ "20 21 22 23 24 25 26",
+ "27 28 29 30 ",
+ " "
+ ]
+ }
+ },
+ {
+ "name": "calendar-leap",
+ "width": 20,
+ "height": 8,
+ "result": {
+ "width": 20,
+ "height": 8,
+ "chars": [
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 70
+ ],
+ [
+ 1,
+ 101
+ ],
+ [
+ 1,
+ 98
+ ],
+ [
+ 1,
+ 114
+ ],
+ [
+ 1,
+ 117
+ ],
+ [
+ 1,
+ 97
+ ],
+ [
+ 1,
+ 114
+ ],
+ [
+ 1,
+ 121
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 52
+ ],
+ [
+ 4,
+ 32
+ ],
+ [
+ 1,
+ 77
+ ],
+ [
+ 1,
+ 111
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 84
+ ],
+ [
+ 1,
+ 117
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 87
+ ],
+ [
+ 1,
+ 101
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 84
+ ],
+ [
+ 1,
+ 104
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 70
+ ],
+ [
+ 1,
+ 114
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 83
+ ],
+ [
+ 1,
+ 97
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 83
+ ],
+ [
+ 1,
+ 117
+ ],
+ [
+ 10,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 52
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 53
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 55
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 56
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 57
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 3,
+ 49
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 52
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 53
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 55
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 56
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 57
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 2,
+ 50
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 52
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 53
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 55
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 56
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 57
+ ],
+ [
+ 29,
+ 32
+ ]
+ ],
+ "fg": [
+ [
+ 20,
+ 25092863
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 120,
+ 29806811
+ ]
+ ],
+ "bg": [
+ [
+ 160,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 20,
+ 1
+ ],
+ [
+ 140,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " February 2024 ",
+ "Mo Tu We Th Fr Sa Su",
+ " 1 2 3 4",
+ " 5 6 7 8 9 10 11",
+ "12 13 14 15 16 17 18",
+ "19 20 21 22 23 24 25",
+ "26 27 28 29 ",
+ " "
+ ]
+ }
+ },
+ {
+ "name": "calendar-bare",
+ "width": 20,
+ "height": 6,
+ "result": {
+ "width": 20,
+ "height": 6,
+ "chars": [
+ [
+ 4,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 52
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 53
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 55
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 56
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 57
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 2,
+ 49
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 52
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 53
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 55
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 56
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 57
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 2,
+ 50
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 52
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 53
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 55
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 56
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 57
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 32,
+ 32
+ ]
+ ],
+ "fg": [
+ [
+ 120,
+ 29806811
+ ]
+ ],
+ "bg": [
+ [
+ 120,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 120,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " 1 2 3 4 5 6",
+ " 7 8 9 10 11 12 13",
+ "14 15 16 17 18 19 20",
+ "21 22 23 24 25 26 27",
+ "28 29 30 ",
+ " "
+ ]
+ }
+ },
+ {
+ "name": "calendar-marked",
+ "width": 20,
+ "height": 8,
+ "result": {
+ "width": 20,
+ "height": 8,
+ "chars": [
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 83
+ ],
+ [
+ 1,
+ 101
+ ],
+ [
+ 1,
+ 112
+ ],
+ [
+ 1,
+ 116
+ ],
+ [
+ 1,
+ 101
+ ],
+ [
+ 1,
+ 109
+ ],
+ [
+ 1,
+ 98
+ ],
+ [
+ 1,
+ 101
+ ],
+ [
+ 1,
+ 114
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 3,
+ 32
+ ],
+ [
+ 1,
+ 77
+ ],
+ [
+ 1,
+ 111
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 84
+ ],
+ [
+ 1,
+ 117
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 87
+ ],
+ [
+ 1,
+ 101
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 84
+ ],
+ [
+ 1,
+ 104
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 70
+ ],
+ [
+ 1,
+ 114
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 83
+ ],
+ [
+ 1,
+ 97
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 83
+ ],
+ [
+ 1,
+ 117
+ ],
+ [
+ 4,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 52
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 53
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 55
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 56
+ ],
+ [
+ 2,
+ 32
+ ],
+ [
+ 1,
+ 57
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 2,
+ 49
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 52
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 53
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 55
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 56
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 57
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 49
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 2,
+ 50
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 52
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 53
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 54
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 55
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 56
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 50
+ ],
+ [
+ 1,
+ 57
+ ],
+ [
+ 1,
+ 32
+ ],
+ [
+ 1,
+ 51
+ ],
+ [
+ 1,
+ 48
+ ],
+ [
+ 32,
+ 32
+ ]
+ ],
+ "fg": [
+ [
+ 20,
+ 25092863
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 1,
+ 29806811
+ ],
+ [
+ 2,
+ 22702973
+ ],
+ [
+ 23,
+ 29806811
+ ],
+ [
+ 2,
+ 17106698
+ ],
+ [
+ 95,
+ 29806811
+ ]
+ ],
+ "bg": [
+ [
+ 63,
+ 17106698
+ ],
+ [
+ 2,
+ 22467805
+ ],
+ [
+ 95,
+ 17106698
+ ]
+ ],
+ "attrs": [
+ [
+ 20,
+ 1
+ ],
+ [
+ 43,
+ 0
+ ],
+ [
+ 2,
+ 1
+ ],
+ [
+ 38,
+ 0
+ ],
+ [
+ 2,
+ 1
+ ],
+ [
+ 55,
+ 0
+ ]
+ ],
+ "clusters": [],
+ "text": [
+ " September 2026 ",
+ "Mo Tu We Th Fr Sa Su",
+ " 1 2 3 4 5 6",
+ " 7 8 9 10 11 12 13",
+ "14 15 16 17 18 19 20",
+ "21 22 23 24 25 26 27",
+ "28 29 30 ",
+ " "
+ ]
+ }
+ },
{
"name": "badge",
"width": 20,
diff --git a/ports/conformance/generate.ts b/ports/conformance/generate.ts
index 948a1ef..f5ee57c 100644
Binary files a/ports/conformance/generate.ts and b/ports/conformance/generate.ts differ
diff --git a/ports/cpp/CMakeLists.txt b/ports/cpp/CMakeLists.txt
index 908d7ac..9bd8cb0 100644
--- a/ports/cpp/CMakeLists.txt
+++ b/ports/cpp/CMakeLists.txt
@@ -14,7 +14,7 @@ if(HQTUI_LTO)
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON)
endif()
add_library(hqtui_cpp INTERFACE)
-add_library(hqtui_cpp_widgets src/widgets.cpp src/scrollbar.cpp src/chart.cpp)
+add_library(hqtui_cpp_widgets src/widgets.cpp src/scrollbar.cpp src/chart.cpp src/calendar.cpp)
set_target_properties(hqtui_cpp_widgets PROPERTIES POSITION_INDEPENDENT_CODE ON)
target_compile_features(hqtui_cpp_widgets PUBLIC cxx_std_17)
if(MSVC)
diff --git a/ports/cpp/examples/widgets.cpp b/ports/cpp/examples/widgets.cpp
index ed2f9ff..bc97d2c 100644
--- a/ports/cpp/examples/widgets.cpp
+++ b/ports/cpp/examples/widgets.cpp
@@ -122,6 +122,17 @@ void widget_chart(Surface s) {
}
// @end
+// @widget calendar
+void widget_calendar(Surface s) {
+ // The dates are arithmetic, not a host calendar: every port has a different
+ // date type and none of them is consulted.
+ Calendar c{2026, 9};
+ c.selected = 8;
+ c.marks = {CalendarMark{15}, CalendarMark{22, 0, 0, true}};
+ draw_calendar(s, c);
+}
+// @end
+
// @widget meter
void widget_meter(Surface s) {
const int width = s.rect().width;
@@ -532,6 +543,7 @@ int main() {
{"log", widget_log},
{"scrollbar", widget_scrollbar},
{"chart", widget_chart},
+ {"calendar", widget_calendar},
{"meter", widget_meter},
{"meters", widget_meters},
{"progress", widget_progress},
diff --git a/ports/cpp/include/hqtui/widgets.hpp b/ports/cpp/include/hqtui/widgets.hpp
index 33da895..7cb1ac7 100644
--- a/ports/cpp/include/hqtui/widgets.hpp
+++ b/ports/cpp/include/hqtui/widgets.hpp
@@ -392,6 +392,49 @@ struct Progress {
void draw_progress(Surface, const Progress &);
void draw_graph(Surface, const Graph &);
+extern const char *const MONTH_NAMES[12];
+/// Two letters each, so a week is exactly as wide as its days.
+extern const char *const WEEKDAY_NAMES[7];
+/// The width a calendar wants, which is the same whatever month it shows.
+constexpr int CALENDAR_WIDTH = 7 * 3 - 1;
+/// The Gregorian leap rule in full: every four years, except centuries, except
+/// every fourth century.
+bool is_leap_year(long year);
+/// How many days a month has. Months are 1-12, as people write them.
+long days_in_month(long year, long month);
+/// Day of the week, 0 = Sunday. Sakamoto's method, so no host calendar is
+/// consulted and every port agrees.
+long day_of_week(long year, long month, long day);
+struct CalendarMark {
+ /// Day of the month, 1-31.
+ long day = 0;
+ Color color = 0;
+ Color background = 0;
+ bool bold = false;
+};
+struct Calendar {
+ long year = 1970;
+ /// 1-12, as people write months.
+ long month = 1;
+ /// Days worth pointing at: holidays, deadlines, days with something on.
+ std::vector marks;
+ /// Drawn in the accent colour, as the day the view is about. 0 for none.
+ long selected = 0;
+ /// The month and year above the grid.
+ bool header = true;
+ int header_align = HQ_CENTER;
+ /// The weekday initials above the days.
+ bool weekdays = true;
+ /// 0 for Sunday, 1 for Monday. Most of the world starts on Monday.
+ long week_start = 1;
+ Color color = 0;
+ std::optional background;
+};
+/// How many rows a month needs, so a caller can size the panel around it.
+int calendar_height(const Calendar &);
+/// A month as a grid, with per-day styling.
+void draw_calendar(Surface, const Calendar &);
+
/// A point in a chart's own coordinates, not the grid's.
struct ChartPoint {
double x = 0, y = 0;
@@ -905,6 +948,11 @@ class UI {
void fill(Fill o = {}, Constraint size = fr()) {
draw([=](Surface s) { draw_fill(s, o); }, size);
}
+ /// A month as a grid, with per-day styling. Sized to the month it shows.
+ void calendar(Calendar o) {
+ int height = calendar_height(o);
+ draw([=](Surface s) { draw_calendar(s, o); }, cells(height));
+ }
void sparkline(Sparkline o) {
draw([=](Surface s) { draw_sparkline(s, o); }, cells(1));
}
diff --git a/ports/cpp/src/calendar.cpp b/ports/cpp/src/calendar.cpp
new file mode 100644
index 0000000..93a8ac7
--- /dev/null
+++ b/ports/cpp/src/calendar.cpp
@@ -0,0 +1,139 @@
+/// A month, as a grid, with per-day styling.
+///
+/// The dates are computed rather than read from a host calendar. Every language
+/// this library is ported to has a different date type -- different epochs,
+/// different month numbering, different opinions about time zones -- and a
+/// widget whose output depends on any of that cannot be held to a fixture. So
+/// the arithmetic is here, in terms every port already has: integers.
+#include
+
+namespace hqtui {
+namespace {
+
+/// Floor division. The leap count rounds towards negative infinity, and C++
+/// integer division truncates towards zero -- which only differs before year 1,
+/// but differs there every time.
+long floor_div(long a, long b) {
+ long q = a / b;
+ if ((a % b != 0) && ((a < 0) != (b < 0)))
+ q--;
+ return q;
+}
+
+long positive_mod(long a, long b) { return ((a % b) + b) % b; }
+
+} // namespace
+
+const char *const MONTH_NAMES[12] = {
+ "January", "February", "March", "April", "May", "June",
+ "July", "August", "September", "October", "November", "December"};
+
+const char *const WEEKDAY_NAMES[7] = {"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"};
+
+bool is_leap_year(long year) {
+ return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
+}
+
+long days_in_month(long year, long month) {
+ if (month == 2)
+ return is_leap_year(year) ? 29 : 28;
+ // April, June, September, November.
+ if (month == 4 || month == 6 || month == 9 || month == 11)
+ return 30;
+ return 31;
+}
+
+long day_of_week(long year, long month, long day) {
+ static const long offsets[12] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4};
+ // January and February belong to the previous year for leap-counting, since
+ // the leap day falls after them.
+ long y = month < 3 ? year - 1 : year;
+ long leaps = floor_div(y, 4) - floor_div(y, 100) + floor_div(y, 400);
+ return positive_mod(y + leaps + offsets[month - 1] + day, 7);
+}
+
+/// Three cells a day, minus the separator the last column does not need.
+static constexpr int DAY_WIDTH = 3;
+
+static bool calendar_valid(const Calendar &o) { return o.month >= 1 && o.month <= 12; }
+
+static long calendar_week_start(const Calendar &o) { return o.week_start == 0 ? 0 : 1; }
+
+static long calendar_start_column(const Calendar &o) {
+ return positive_mod(day_of_week(o.year, o.month, 1) - calendar_week_start(o), 7);
+}
+
+int calendar_height(const Calendar &o) {
+ if (!calendar_valid(o))
+ return 0;
+ long cells = calendar_start_column(o) + days_in_month(o.year, o.month);
+ int weeks = int((cells + 6) / 7);
+ return weeks + int(o.header) + int(o.weekdays);
+}
+
+void draw_calendar(Surface s, const Calendar &o) {
+ int sw = s.rect().width, sh = s.rect().height;
+ if (sw <= 0 || sh <= 0 || !calendar_valid(o))
+ return;
+ auto &t = theme(s);
+ Color base = o.color ? o.color : t.foreground;
+ long week_start = calendar_week_start(o);
+
+ int row = 0;
+ if (o.header) {
+ std::string label = std::string(MONTH_NAMES[o.month - 1]) + " " + std::to_string(o.year);
+ int lw = std::min(sw, CALENDAR_WIDTH);
+ hqtui::text(s, 0, row, fit(label, lw, o.header_align), t.title, HQ_BOLD, o.background);
+ row++;
+ }
+
+ if (o.weekdays) {
+ for (int i = 0; i < 7; i++) {
+ const char *name = WEEKDAY_NAMES[positive_mod(i + week_start, 7)];
+ hqtui::text(s, i * DAY_WIDTH, row, name, t.muted, 0, o.background);
+ }
+ row++;
+ }
+
+ long first = calendar_start_column(o);
+ long total = days_in_month(o.year, o.month);
+
+ for (long day = 1; day <= total; day++) {
+ long cell = first + day - 1;
+ int y = row + int(cell / 7);
+ if (y >= sh)
+ break;
+ int x = int(cell % 7) * DAY_WIDTH;
+
+ const CalendarMark *mark = nullptr;
+ for (auto &m : o.marks)
+ if (m.day == day) {
+ mark = &m;
+ break;
+ }
+ bool selected = o.selected == day;
+
+ Color fg = base;
+ std::optional bg = o.background;
+ int attrs = 0;
+ if (mark) {
+ if (mark->color)
+ fg = mark->color;
+ if (mark->background)
+ bg = mark->background;
+ if (mark->bold)
+ attrs = HQ_BOLD;
+ }
+ if (selected) {
+ fg = t.background;
+ bg = t.accent;
+ attrs = HQ_BOLD;
+ }
+ // Right-aligned in two cells, so the units column lines up down the week
+ // and a calendar reads as a table rather than as a paragraph of numbers.
+ hqtui::text(s, x, y, fit(std::to_string(day), 2, HQ_RIGHT, false), fg,
+ std::uint16_t(attrs), bg);
+ }
+}
+
+} // namespace hqtui
diff --git a/ports/cpp/tests/conformance_widgets.cpp b/ports/cpp/tests/conformance_widgets.cpp
index 6808b66..142c052 100644
--- a/ports/cpp/tests/conformance_widgets.cpp
+++ b/ports/cpp/tests/conformance_widgets.cpp
@@ -131,6 +131,34 @@ bool draw_scene(const std::string &name, Surface s) {
draw_fill(s, f);
return true;
}
+ if (name == "calendar") {
+ draw_calendar(s, Calendar{2026, 9});
+ return true;
+ }
+ if (name == "calendar-sunday") {
+ Calendar c{2026, 9};
+ c.week_start = 0;
+ draw_calendar(s, c);
+ return true;
+ }
+ if (name == "calendar-leap") {
+ draw_calendar(s, Calendar{2024, 2});
+ return true;
+ }
+ if (name == "calendar-bare") {
+ Calendar c{2026, 9};
+ c.header = false;
+ c.weekdays = false;
+ draw_calendar(s, c);
+ return true;
+ }
+ if (name == "calendar-marked") {
+ Calendar c{2026, 9};
+ c.selected = 8;
+ c.marks = {CalendarMark{15}, CalendarMark{22, 0, 0, true}};
+ draw_calendar(s, c);
+ return true;
+ }
if (name == "badge") {
{
Badge badge;
diff --git a/ports/go/conformance_widgets_test.go b/ports/go/conformance_widgets_test.go
index 4584e48..0a02aa6 100644
--- a/ports/go/conformance_widgets_test.go
+++ b/ports/go/conformance_widgets_test.go
@@ -67,6 +67,21 @@ func drawWidgetScene(t *testing.T, name string, s Surface) {
DrawFill(s, FillOptions{Symbol: "\u00b7"})
case "fill-wide":
DrawFill(s, FillOptions{Symbol: "\u65e5"})
+ case "calendar":
+ DrawCalendar(s, CalendarOptions{Year: 2026, Month: 9})
+ case "calendar-sunday":
+ sunday := 0
+ DrawCalendar(s, CalendarOptions{Year: 2026, Month: 9, WeekStart: &sunday})
+ case "calendar-leap":
+ DrawCalendar(s, CalendarOptions{Year: 2024, Month: 2})
+ case "calendar-bare":
+ DrawCalendar(s, CalendarOptions{Year: 2026, Month: 9, NoHeader: true, NoWeekdays: true})
+ case "calendar-marked":
+ eighth := 8
+ DrawCalendar(s, CalendarOptions{
+ Year: 2026, Month: 9, Selected: &eighth,
+ Marks: []CalendarMark{{Day: 15}, {Day: 22, Bold: true}},
+ })
case "badge":
DrawBadge(s, BadgeOptions{Text: "LIVE"})
case "badge-outline":
diff --git a/ports/go/examples/widgets/main.go b/ports/go/examples/widgets/main.go
index 9b5493c..d4d8130 100644
--- a/ports/go/examples/widgets/main.go
+++ b/ports/go/examples/widgets/main.go
@@ -224,6 +224,19 @@ func Chart(ui *hqtui.Container) {
// @end
+// @widget calendar
+func Calendar(ui *hqtui.Container) {
+ // The dates are arithmetic, not a host calendar: every port has a different
+ // date type and none of them is consulted.
+ eighth := 8
+ ui.Calendar(hqtui.CalendarOptions{
+ Year: 2026, Month: 9, Selected: &eighth,
+ Marks: []hqtui.CalendarMark{{Day: 15}, {Day: 22, Bold: true}},
+ })
+}
+
+// @end
+
// @widget meter
func Meter(ui *hqtui.Container) {
ui.Meter(hqtui.MeterOptions{Value: 0.62, Label: "CPU"})
@@ -426,7 +439,7 @@ func main() {
{"text", Text}, {"label", Label}, {"heading", Heading}, {"badge", Badge},
{"divider", Divider}, {"keyValues", KeyValues}, {"statusBar", StatusBar},
{"table", Table}, {"list", List}, {"tree", Tree}, {"log", Log},
- {"scrollbar", Scrollbar}, {"chart", Chart},
+ {"scrollbar", Scrollbar}, {"chart", Chart}, {"calendar", Calendar},
{"meter", Meter}, {"meters", Meters}, {"progress", Progress}, {"graph", Graph},
{"sparkline", Sparkline}, {"histogram", Histogram}, {"heatBar", HeatBar},
{"gauge", Gauge}, {"donut", Donut},
diff --git a/ports/go/ui.go b/ports/go/ui.go
index 37bd5eb..5583484 100644
--- a/ports/go/ui.go
+++ b/ports/go/ui.go
@@ -502,6 +502,18 @@ func (c *Container) Fill(o FillOptions, layout ...Layout) *Container {
return c.add(c.filling(firstLayout(layout)), func(s Surface) { DrawFill(s, o) })
}
+// Calendar draws a month as a grid, with per-day styling.
+//
+// Sized to the month it shows: a month spans four, five or six week rows
+// depending on where its first day falls, and reserving five leaves some months
+// a row short and others a blank row long.
+func (c *Container) Calendar(o CalendarOptions, layout ...Layout) *Container {
+ height := CalendarHeight(o)
+ return c.add(c.constraintOfLayout(firstLayout(layout), Cells(height), &height), func(s Surface) {
+ DrawCalendar(s, o)
+ })
+}
+
func (c *Container) Sparkline(o SparklineWidgetOptions, layout ...Layout) *Container {
return c.add(c.leaf(firstLayout(layout), 1), func(s Surface) { DrawSparkline(s, o) })
}
diff --git a/ports/go/widgets_calendar.go b/ports/go/widgets_calendar.go
new file mode 100644
index 0000000..4633215
--- /dev/null
+++ b/ports/go/widgets_calendar.go
@@ -0,0 +1,218 @@
+package hqtui
+
+import "strconv"
+
+// A month, as a grid, with per-day styling.
+//
+// The dates are computed rather than read from a host calendar. Every language
+// this library is ported to has a different date type — different epochs,
+// different month numbering, different opinions about time zones — and a widget
+// whose output depends on any of that cannot be held to a fixture. So the
+// arithmetic is here, in terms every port already has: integers.
+
+var MonthNames = [12]string{
+ "January", "February", "March", "April", "May", "June",
+ "July", "August", "September", "October", "November", "December",
+}
+
+// WeekdayNames are two letters each, so a week is exactly as wide as its days.
+var WeekdayNames = [7]string{"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"}
+
+// Three cells a day, minus the separator the last column does not need.
+const calendarDayWidth = 3
+
+// CalendarWidth is the width a calendar wants, the same whatever month it shows.
+const CalendarWidth = 7*calendarDayWidth - 1
+
+// DaysInMonth reports how many days a month has. Months are 1-12, as people
+// write them.
+func DaysInMonth(year, month int) int {
+ if month == 2 {
+ if IsLeapYear(year) {
+ return 29
+ }
+ return 28
+ }
+ // April, June, September, November.
+ if month == 4 || month == 6 || month == 9 || month == 11 {
+ return 30
+ }
+ return 31
+}
+
+// IsLeapYear is the Gregorian leap rule in full: every four years, except
+// centuries, except every fourth century. Truncating it at "every four years"
+// is right for 1901-2099 and wrong for 1900 and 2100, which is the kind of bug
+// that sits quietly for decades.
+func IsLeapYear(year int) bool {
+ return year%4 == 0 && (year%100 != 0 || year%400 == 0)
+}
+
+// DayOfWeek returns the day of the week, 0 = Sunday.
+//
+// Sakamoto's method: a table of month offsets plus the leap-day count, which is
+// exact for any Gregorian date and needs nothing but integer arithmetic.
+func DayOfWeek(year, month, day int) int {
+ offsets := [12]int{0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4}
+ // January and February belong to the previous year for leap-counting, since
+ // the leap day falls after them.
+ y := year
+ if month < 3 {
+ y--
+ }
+ // floorDiv, not /: the leap count rounds towards negative infinity, and
+ // Go's division truncates towards zero, which is off by one before year 1.
+ leaps := floorDiv(y, 4) - floorDiv(y, 100) + floorDiv(y, 400)
+ value := (y + leaps + offsets[month-1] + day) % 7
+ return ((value % 7) + 7) % 7
+}
+
+type CalendarMark struct {
+ // Day of the month, 1-31.
+ Day int
+ Color *Color
+ Background *Color
+ Bold bool
+}
+
+type CalendarOptions struct {
+ Year int
+ // Month is 1-12, as people write months.
+ Month int
+ // Marks are days worth pointing at: holidays, deadlines, days with
+ // something on.
+ Marks []CalendarMark
+ // Selected is drawn in the accent colour, as the day the view is about.
+ Selected *int
+ // NoHeader hides the month and year above the grid.
+ NoHeader bool
+ HeaderAlign *Align
+ // NoWeekdays hides the weekday initials above the days.
+ NoWeekdays bool
+ // WeekStart is 0 for Sunday, 1 for Monday. Nil means Monday, which is most
+ // of the world -- a pointer because Go's zero value would otherwise decide
+ // that for everyone, and decide it differently from the other ports.
+ WeekStart *int
+ Color *Color
+ Background *Color
+}
+
+func (o CalendarOptions) valid() bool { return o.Month >= 1 && o.Month <= 12 }
+
+// weekStart resolves the option: Monday unless the caller said otherwise.
+func (o CalendarOptions) weekStart() int {
+ if o.WeekStart != nil && *o.WeekStart == 0 {
+ return 0
+ }
+ return 1
+}
+
+func (o CalendarOptions) startColumn() int {
+ return (DayOfWeek(o.Year, o.Month, 1) - o.weekStart() + 7) % 7
+}
+
+// CalendarHeight reports how many rows a month needs, so a caller can size the
+// panel around it.
+//
+// A month spans four, five or six week rows depending on where its first day
+// falls; guessing five leaves some months a row short and others a blank row
+// long.
+func CalendarHeight(o CalendarOptions) int {
+ if !o.valid() {
+ return 0
+ }
+ cells := o.startColumn() + DaysInMonth(o.Year, o.Month)
+ rows := (cells + 6) / 7
+ if !o.NoHeader {
+ rows++
+ }
+ if !o.NoWeekdays {
+ rows++
+ }
+ return rows
+}
+
+func DrawCalendar(s Surface, o CalendarOptions) {
+ if s.IsEmpty() || !o.valid() {
+ return
+ }
+ theme := s.Theme
+ base := theme.Foreground
+ if o.Color != nil {
+ base = *o.Color
+ }
+ bg := o.Background
+
+ row := 0
+ if !o.NoHeader {
+ label := MonthNames[o.Month-1] + " " + strconv.Itoa(o.Year)
+ width := s.Width()
+ if width > CalendarWidth {
+ width = CalendarWidth
+ }
+ align := AlignCenter
+ if o.HeaderAlign != nil {
+ align = *o.HeaderAlign
+ }
+ title := theme.Title
+ attrs := AttrBold
+ s.Text(0, row, Fit(label, width, align), TextOptions{Fg: &title, Bg: bg, Attrs: &attrs})
+ row++
+ }
+
+ if !o.NoWeekdays {
+ muted := theme.Muted
+ for i := 0; i < 7; i++ {
+ name := WeekdayNames[(i+o.weekStart())%7]
+ s.Text(i*calendarDayWidth, row, name, TextOptions{Fg: &muted, Bg: bg})
+ }
+ row++
+ }
+
+ first := o.startColumn()
+ total := DaysInMonth(o.Year, o.Month)
+
+ for day := 1; day <= total; day++ {
+ cell := first + day - 1
+ y := row + cell/7
+ if y >= s.Height() {
+ break
+ }
+ x := (cell % 7) * calendarDayWidth
+
+ var mark *CalendarMark
+ for i := range o.Marks {
+ if o.Marks[i].Day == day {
+ mark = &o.Marks[i]
+ break
+ }
+ }
+ selected := o.Selected != nil && *o.Selected == day
+
+ fg := base
+ cellBg := bg
+ attrs := AttrNone
+ if mark != nil {
+ if mark.Color != nil {
+ fg = *mark.Color
+ }
+ if mark.Background != nil {
+ cellBg = mark.Background
+ }
+ if mark.Bold {
+ attrs = AttrBold
+ }
+ }
+ if selected {
+ fg = theme.Background
+ accent := theme.Accent
+ cellBg = &accent
+ attrs = AttrBold
+ }
+ // Right-aligned in two cells, so the units column lines up down the week
+ // and a calendar reads as a table rather than as a paragraph of numbers.
+ s.Text(x, y, Fit(strconv.Itoa(day), 2, AlignRight), TextOptions{
+ Fg: &fg, Bg: cellBg, Attrs: &attrs,
+ })
+ }
+}
diff --git a/ports/perl/examples/widgets.pl b/ports/perl/examples/widgets.pl
index c560789..17ab16f 100644
--- a/ports/perl/examples/widgets.pl
+++ b/ports/perl/examples/widgets.pl
@@ -110,6 +110,18 @@ sub widget_chart {
}
# @end
+# @widget calendar
+sub widget_calendar {
+ my ($ui) = @_;
+ # The dates are arithmetic, not a host calendar: every port has a different
+ # date type and none of them is consulted.
+ $ui->calendar(2026, 9,
+ selected => 8,
+ marks => [ { day => 15 }, { day => 22, bold => 1 } ],
+ );
+}
+# @end
+
# @widget meter
sub widget_meter {
my ($ui) = @_;
@@ -322,6 +334,7 @@ sub widget_tooltip {
['log', \&widget_log],
['scrollbar', \&widget_scrollbar],
['chart', \&widget_chart],
+ ['calendar', \&widget_calendar],
['meter', \&widget_meter],
['graph', \&widget_graph],
['gauge', \&widget_gauge],
diff --git a/ports/perl/lib/Hqtui.pm b/ports/perl/lib/Hqtui.pm
index f0876e4..4693033 100644
--- a/ports/perl/lib/Hqtui.pm
+++ b/ports/perl/lib/Hqtui.pm
@@ -65,6 +65,7 @@ sub donut { my ($s,$segments,%o)=@_; $s->add('donut',segments=>$segments,%o); }
sub list { my ($s,$items,%o)=@_; $s->add('list',items=>$items,%o); }
sub scrollbar { my ($s,$total,%o)=@_; $s->add('scrollbar',total=>$total,%o); }
sub chart { my ($s,$series,%o)=@_; $s->add('chart',series=>$series,%o); }
+sub calendar { my ($s,$year,$month,%o)=@_; $s->add('calendar',year=>$year,month=>$month,%o); }
sub tree { my ($s,$nodes,%o)=@_; $s->add('tree',nodes=>$nodes,%o); }
sub button { my ($s,$label,%o)=@_; $s->add('button',label=>$label,%o); }
sub checkbox { my ($s,$label,%o)=@_; $s->add('checkbox',label=>$label,%o); }
diff --git a/ports/php/examples/widgets.php b/ports/php/examples/widgets.php
index 524b4be..e8dbe58 100644
--- a/ports/php/examples/widgets.php
+++ b/ports/php/examples/widgets.php
@@ -113,6 +113,18 @@ function widget_chart(UI $ui): void
}
// @end
+// @widget calendar
+function widget_calendar(UI $ui): void
+{
+ // The dates are arithmetic, not a host calendar: every port has a different
+ // date type and none of them is consulted.
+ $ui->calendar(2026, 9, [
+ 'selected' => 8,
+ 'marks' => [['day' => 15], ['day' => 22, 'bold' => true]],
+ ]);
+}
+// @end
+
// @widget meter
function widget_meter(UI $ui): void
{
@@ -326,6 +338,7 @@ function widget_tooltip(UI $ui): void
'log' => 'widget_log',
'scrollbar' => 'widget_scrollbar',
'chart' => 'widget_chart',
+ 'calendar' => 'widget_calendar',
'meter' => 'widget_meter',
'graph' => 'widget_graph',
'gauge' => 'widget_gauge',
diff --git a/ports/php/src/Hqtui.php b/ports/php/src/Hqtui.php
index 8389299..0bbec2d 100644
--- a/ports/php/src/Hqtui.php
+++ b/ports/php/src/Hqtui.php
@@ -80,6 +80,7 @@ public function donut(array $segments, array $o = []): self { return $this->add(
public function list(array $items, array $o = []): self { return $this->add('list', ['items'=>$items, ...$o]); }
public function scrollbar(int $total, array $o = []): self { return $this->add('scrollbar', ['total'=>$total, ...$o]); }
public function chart(array $series, array $o = []): self { return $this->add('chart', ['series'=>$series, ...$o]); }
+ public function calendar(int $year, int $month, array $o = []): self { return $this->add('calendar', ['year'=>$year, 'month'=>$month, ...$o]); }
public function tree(array $nodes, array $o = []): self { return $this->add('tree', ['nodes'=>$nodes, ...$o]); }
public function button(string $label, array $o = []): self { return $this->add('button', ['label'=>$label, ...$o]); }
public function checkbox(string $label, array $o = []): self { return $this->add('checkbox', ['label'=>$label, ...$o]); }
diff --git a/ports/python/examples/widgets.py b/ports/python/examples/widgets.py
index 11b7fa5..1593fcb 100644
--- a/ports/python/examples/widgets.py
+++ b/ports/python/examples/widgets.py
@@ -224,6 +224,17 @@ def chart(ui: Container) -> None:
# @end
+# @widget calendar
+def calendar(ui: Container) -> None:
+ # The dates are arithmetic, not a host calendar: every port has a different
+ # date type and none of them is consulted.
+ ui.calendar(w.CalendarOptions(
+ year=2026, month=9, selected=8,
+ marks=[w.CalendarMark(day=15), w.CalendarMark(day=22, bold=True)],
+ ))
+# @end
+
+
# @widget meter
def meter(ui: Container) -> None:
ui.meter(w.MeterOptions(value=0.62, label="CPU"))
@@ -410,7 +421,7 @@ def tooltip(ui: Container) -> None:
("text", text), ("label", label), ("heading", heading), ("badge", badge),
("divider", divider), ("keyValues", key_values), ("statusBar", status_bar),
("table", table), ("list", list_), ("tree", tree), ("log", log),
- ("scrollbar", scrollbar), ("chart", chart),
+ ("scrollbar", scrollbar), ("chart", chart), ("calendar", calendar),
("meter", meter), ("meters", meters), ("progress", progress), ("graph", graph),
("sparkline", sparkline), ("histogram", histogram), ("heatBar", heat_bar),
("gauge", gauge), ("donut", donut),
diff --git a/ports/python/hqtui/ui.py b/ports/python/hqtui/ui.py
index 9ee03fb..0dc5754 100644
--- a/ports/python/hqtui/ui.py
+++ b/ports/python/hqtui/ui.py
@@ -503,6 +503,19 @@ def fill(self, options: w.FillOptions | None = None, layout: Layout | None = Non
lambda s: w.draw_fill(s, chosen),
)
+ def calendar(self, options: w.CalendarOptions, layout: Layout | None = None):
+ """A month as a grid, with per-day styling.
+
+ Sized to the month it shows: a month spans four, five or six week rows
+ depending on where its first day falls, and reserving five leaves some
+ months a row short and others a blank row long.
+ """
+ height = w.calendar_height(options)
+ return self._add(
+ self._constraint(layout or Layout(), height, height),
+ lambda s: w.draw_calendar(s, options),
+ )
+
def sparkline(self, options: w.SparklineWidgetOptions, layout: Layout | None = None):
return self._add(self._leaf(layout or Layout(), 1), lambda s: w.draw_sparkline(s, options))
diff --git a/ports/python/hqtui/widgets/__init__.py b/ports/python/hqtui/widgets/__init__.py
index 219913b..de15799 100644
--- a/ports/python/hqtui/widgets/__init__.py
+++ b/ports/python/hqtui/widgets/__init__.py
@@ -65,6 +65,16 @@
draw_tree,
resolve_offset,
)
+from .calendar import (
+ CALENDAR_WIDTH,
+ CalendarMark,
+ CalendarOptions,
+ calendar_height,
+ day_of_week,
+ days_in_month,
+ draw_calendar,
+ is_leap_year,
+)
from .chart import ChartOptions, draw_chart
from .surface import ClearOptions, FillOptions, draw_clear, draw_fill
from .scrollbar import (
@@ -107,6 +117,8 @@
"draw_columns", "draw_command_palette", "draw_divider", "draw_donut",
"draw_gauge", "draw_graph", "draw_heat_bar", "draw_key_values", "draw_list",
"draw_log", "draw_meter", "draw_meters", "draw_modal", "draw_progress",
+ "CalendarMark", "CalendarOptions", "calendar_height", "day_of_week", "days_in_month",
+ "draw_calendar", "is_leap_year", "CALENDAR_WIDTH",
"ChartOptions", "draw_chart", "ClearOptions", "FillOptions", "draw_clear", "draw_fill",
"ScrollbarOptions", "ScrollbarOrientation", "is_vertical", "offset_for_position",
"thumb", "draw_scrollbar", "draw_scrollbar_widget", "draw_select", "draw_sparkline", "draw_status_bar",
diff --git a/ports/python/hqtui/widgets/calendar.py b/ports/python/hqtui/widgets/calendar.py
new file mode 100644
index 0000000..3fe2a64
--- /dev/null
+++ b/ports/python/hqtui/widgets/calendar.py
@@ -0,0 +1,182 @@
+"""A month, as a grid, with per-day styling.
+
+The dates are computed rather than read from a host calendar. Every language
+this library is ported to has a different date type — different epochs,
+different month numbering, different opinions about time zones — and a widget
+whose output depends on any of that cannot be held to a fixture. So the
+arithmetic is here, in terms every port already has: integers.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Sequence
+
+from ..buffer import Attrs
+from ..color import Color
+from ..surface import Align, Surface, TextOptions
+from ..unicode import fit
+
+__all__ = [
+ "CALENDAR_WIDTH",
+ "CalendarMark",
+ "CalendarOptions",
+ "MONTH_NAMES",
+ "WEEKDAY_NAMES",
+ "calendar_height",
+ "day_of_week",
+ "days_in_month",
+ "draw_calendar",
+ "is_leap_year",
+]
+
+MONTH_NAMES = (
+ "January", "February", "March", "April", "May", "June",
+ "July", "August", "September", "October", "November", "December",
+)
+
+#: Two letters each, so a week is exactly as wide as its days.
+WEEKDAY_NAMES = ("Su", "Mo", "Tu", "We", "Th", "Fr", "Sa")
+
+#: Three cells a day, minus the separator the last column does not need.
+_DAY_WIDTH = 3
+#: The width a calendar wants, which is the same whatever month it shows.
+CALENDAR_WIDTH = 7 * _DAY_WIDTH - 1
+
+
+def is_leap_year(year: int) -> bool:
+ """The Gregorian leap rule in full: every four years, except centuries,
+ except every fourth century.
+
+ Truncating it at "every four years" is right for 1901-2099 and wrong for
+ 1900 and 2100, which is the kind of bug that sits quietly for decades.
+ """
+ return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
+
+
+def days_in_month(year: int, month: int) -> int:
+ """How many days a month has. Months are 1-12, as people write them."""
+ if month == 2:
+ return 29 if is_leap_year(year) else 28
+ # April, June, September, November.
+ if month in (4, 6, 9, 11):
+ return 30
+ return 31
+
+
+def day_of_week(year: int, month: int, day: int) -> int:
+ """Day of the week, 0 = Sunday.
+
+ Sakamoto's method: a table of month offsets plus the leap-day count, which
+ is exact for any Gregorian date and needs nothing but integer arithmetic.
+ """
+ offsets = (0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4)
+ # January and February belong to the previous year for leap-counting, since
+ # the leap day falls after them.
+ y = year - 1 if month < 3 else year
+ leaps = y // 4 - y // 100 + y // 400
+ return (y + leaps + offsets[month - 1] + day) % 7
+
+
+@dataclass(frozen=True, slots=True)
+class CalendarMark:
+ #: Day of the month, 1-31.
+ day: int = 0
+ color: Color | None = None
+ background: Color | None = None
+ bold: bool = False
+
+
+@dataclass(frozen=True, slots=True)
+class CalendarOptions:
+ year: int = 1970
+ #: 1-12, as people write months.
+ month: int = 1
+ #: Days worth pointing at: holidays, deadlines, days with something on.
+ marks: Sequence[CalendarMark] = ()
+ #: Drawn in the accent colour, as the day the view is about.
+ selected: int | None = None
+ #: The month and year above the grid.
+ header: bool = True
+ header_align: Align = "center"
+ #: The weekday initials above the days.
+ weekdays: bool = True
+ #: 0 for Sunday, 1 for Monday. Most of the world starts on Monday.
+ week_start: int = 1
+ color: Color | None = None
+ background: Color | None = None
+
+ @property
+ def valid(self) -> bool:
+ return 1 <= self.month <= 12
+
+ @property
+ def _week_start(self) -> int:
+ return 0 if self.week_start == 0 else 1
+
+ @property
+ def start_column(self) -> int:
+ return (day_of_week(self.year, self.month, 1) - self._week_start + 7) % 7
+
+
+def calendar_height(options: CalendarOptions) -> int:
+ """How many rows a month needs, so a caller can size the panel around it.
+
+ A month spans four, five or six week rows depending on where its first day
+ falls; guessing five leaves some months a row short and others a blank row
+ long.
+ """
+ if not options.valid:
+ return 0
+ cells = options.start_column + days_in_month(options.year, options.month)
+ weeks = -(-cells // 7)
+ return weeks + int(options.header) + int(options.weekdays)
+
+
+def draw_calendar(surface: Surface, options: CalendarOptions) -> None:
+ if surface.empty or not options.valid:
+ return
+ theme = surface.theme
+ base = options.color if options.color is not None else theme.foreground
+ bg = options.background
+ week_start = options._week_start
+
+ row = 0
+ if options.header:
+ label = f"{MONTH_NAMES[options.month - 1]} {options.year}"
+ width = min(surface.width, CALENDAR_WIDTH)
+ surface.text(
+ 0, row, fit(label, width, options.header_align),
+ TextOptions(fg=theme.title, bg=bg, attrs=Attrs.BOLD),
+ )
+ row += 1
+
+ if options.weekdays:
+ for i in range(7):
+ name = WEEKDAY_NAMES[(i + week_start) % 7]
+ surface.text(i * _DAY_WIDTH, row, name, TextOptions(fg=theme.muted, bg=bg))
+ row += 1
+
+ first = options.start_column
+ total = days_in_month(options.year, options.month)
+ marks = {mark.day: mark for mark in options.marks}
+
+ for day in range(1, total + 1):
+ cell = first + day - 1
+ y = row + cell // 7
+ if y >= surface.height:
+ break
+ x = (cell % 7) * _DAY_WIDTH
+
+ mark = marks.get(day)
+ if options.selected == day:
+ style = TextOptions(fg=theme.background, bg=theme.accent, attrs=Attrs.BOLD)
+ else:
+ style = TextOptions(
+ fg=mark.color if mark is not None and mark.color is not None else base,
+ bg=mark.background if mark is not None and mark.background is not None else bg,
+ attrs=Attrs.BOLD if mark is not None and mark.bold else Attrs.NONE,
+ )
+ # Right-aligned in two cells, so the units column lines up down the week
+ # and a calendar reads as a table rather than as a paragraph of numbers.
+ surface.text(x, y, fit(str(day), 2, "right"), style)
diff --git a/ports/python/tests/test_conformance_widgets.py b/ports/python/tests/test_conformance_widgets.py
index 4d7ebfd..438b6cc 100644
--- a/ports/python/tests/test_conformance_widgets.py
+++ b/ports/python/tests/test_conformance_widgets.py
@@ -78,6 +78,21 @@ def draw_scene(case, name: str, s: Surface) -> None:
w.draw_fill(s, w.FillOptions(symbol="\u00b7"))
elif name == "fill-wide":
w.draw_fill(s, w.FillOptions(symbol="\u65e5"))
+ elif name == "calendar":
+ w.draw_calendar(s, w.CalendarOptions(year=2026, month=9))
+ elif name == "calendar-sunday":
+ w.draw_calendar(s, w.CalendarOptions(year=2026, month=9, week_start=0))
+ elif name == "calendar-leap":
+ w.draw_calendar(s, w.CalendarOptions(year=2024, month=2))
+ elif name == "calendar-bare":
+ w.draw_calendar(
+ s, w.CalendarOptions(year=2026, month=9, header=False, weekdays=False)
+ )
+ elif name == "calendar-marked":
+ w.draw_calendar(s, w.CalendarOptions(
+ year=2026, month=9, selected=8,
+ marks=[w.CalendarMark(day=15), w.CalendarMark(day=22, bold=True)],
+ ))
elif name == "badge":
w.draw_badge(s, w.BadgeOptions(text="LIVE"))
elif name == "badge-outline":
diff --git a/ports/ruby/examples/widgets.rb b/ports/ruby/examples/widgets.rb
index 7de1862..a0b7c95 100644
--- a/ports/ruby/examples/widgets.rb
+++ b/ports/ruby/examples/widgets.rb
@@ -101,6 +101,14 @@ def chart(ui)
end
# @end
+# @widget calendar
+def calendar(ui)
+ # The dates are arithmetic, not a host calendar: every port has a different
+ # date type and none of them is consulted.
+ ui.calendar(2026, 9, selected: 8, marks: [{ day: 15 }, { day: 22, bold: true }])
+end
+# @end
+
# @widget meter
def meter(ui)
ui.meter(0.62, label: 'CPU')
@@ -290,6 +298,7 @@ def tooltip(ui)
'log' => method(:log),
'scrollbar' => method(:scrollbar),
'chart' => method(:chart),
+ 'calendar' => method(:calendar),
'meter' => method(:meter),
'graph' => method(:graph),
'gauge' => method(:gauge),
diff --git a/ports/ruby/lib/hqtui.rb b/ports/ruby/lib/hqtui.rb
index dc6d4d4..914daf9 100644
--- a/ports/ruby/lib/hqtui.rb
+++ b/ports/ruby/lib/hqtui.rb
@@ -73,6 +73,7 @@ def donut(segments, **options) = add('donut', segments: segments, **options)
def list(items, **options) = add('list', items: items, **options)
def scrollbar(total, **options) = add('scrollbar', total: total, **options)
def chart(series, **options) = add('chart', series: series, **options)
+ def calendar(year, month, **options) = add('calendar', year: year, month: month, **options)
def tree(nodes, **options) = add('tree', nodes: nodes, **options)
def button(label, **options) = add('button', label: label, **options)
def checkbox(label, **options) = add('checkbox', label: label, **options)
diff --git a/ports/rust/examples/cobol-bridge.rs b/ports/rust/examples/cobol-bridge.rs
index 3e3db3d..55d2d0e 100644
--- a/ports/rust/examples/cobol-bridge.rs
+++ b/ports/rust/examples/cobol-bridge.rs
@@ -88,6 +88,8 @@ fn draw(scene: &Scene, ui: &mut Container) {
let mut rows: Vec = Vec::new();
let mut keys: Vec = Vec::new();
let mut chart_series: Vec<(String, Vec<(f64, f64)>)> = Vec::new();
+ let mut calendar_marks: Vec = Vec::new();
+ let mut calendar_selected: Option = None;
let mut entries: Vec = Vec::new();
let mut points: Vec = Vec::new();
let mut bars: Vec = Vec::new();
@@ -150,6 +152,32 @@ fn draw(scene: &Scene, ui: &mut Container) {
"METER" => {
ui.meter(MeterOptions::new(record.num.parse().unwrap_or(0.0)).label(&record.key));
}
+ "CALDAY" => {
+ // One marked day per record, like CHARTPT. num is the day; key
+ // says whether it is the selected one.
+ let day: i64 = record.num.parse().unwrap_or(0);
+ if record.key == "SELECTED" {
+ calendar_selected = Some(day);
+ } else {
+ calendar_marks.push(CalendarMark {
+ day,
+ bold: record.key == "BOLD",
+ ..Default::default()
+ });
+ }
+ }
+ "CALENDAR" => {
+ // text is "year|month"; the marks accumulated so far belong to it.
+ let mut parts = record.text.split('|');
+ let year: i64 = parts.next().unwrap_or("").parse().unwrap_or(1970);
+ let month: i64 = parts.next().unwrap_or("").parse().unwrap_or(1);
+ ui.calendar(CalendarOptions {
+ selected: calendar_selected.take(),
+ marks: std::mem::take(&mut calendar_marks),
+ week_start: if record.key == "SUNDAY" { 0 } else { 1 },
+ ..CalendarOptions::new(year, month)
+ });
+ }
"CHARTPT" => {
// One point per record, like GRAPHPT. key names the series it
// joins, so a flat record stream can describe several.
diff --git a/ports/rust/examples/widgets.rs b/ports/rust/examples/widgets.rs
index 7e2268d..f9ada8f 100644
--- a/ports/rust/examples/widgets.rs
+++ b/ports/rust/examples/widgets.rs
@@ -452,6 +452,21 @@ pub fn chart(ui: &mut Container) {
}
// @end
+// @widget calendar
+pub fn calendar(ui: &mut Container) {
+ // The dates are arithmetic, not a host calendar: every port has a different
+ // date type and none of them is consulted.
+ ui.calendar(CalendarOptions {
+ selected: Some(8),
+ marks: vec![
+ CalendarMark { day: 15, ..Default::default() },
+ CalendarMark { day: 22, bold: true, ..Default::default() },
+ ],
+ ..CalendarOptions::new(2026, 9)
+ });
+}
+// @end
+
/// Renders each widget on its own small screen and prints the lot.
fn main() {
let examples: Vec<(&str, fn(&mut Container))> = vec![
@@ -468,6 +483,7 @@ fn main() {
("log", log),
("scrollbar", scrollbar),
("chart", chart),
+ ("calendar", calendar),
("meter", meter),
("meters", meters),
("progress", progress),
diff --git a/ports/rust/src/ui.rs b/ports/rust/src/ui.rs
index 8c3a41a..cda2879 100644
--- a/ports/rust/src/ui.rs
+++ b/ports/rust/src/ui.rs
@@ -774,6 +774,17 @@ impl<'a> Container<'a> {
self.add(constraint, move |s| w::draw_fill(&s, &options))
}
+ /// A month as a grid, with per-day styling.
+ ///
+ /// Sized to the month it shows: a month spans four, five or six week rows
+ /// depending on where its first day falls, and reserving five leaves some
+ /// months a row short and others a blank row long.
+ pub fn calendar(&mut self, options: w::CalendarOptions) -> &mut Self {
+ let height = w::calendar_height(&options);
+ let constraint = self.leaf(height);
+ self.add(constraint, move |s| w::draw_calendar(&s, &options))
+ }
+
pub fn sparkline(&mut self, options: w::SparklineWidgetOptions) -> &mut Self {
let constraint = self.leaf(1);
self.add(constraint, move |s| w::draw_sparkline(&s, &options))
diff --git a/ports/rust/src/widgets/calendar.rs b/ports/rust/src/widgets/calendar.rs
new file mode 100644
index 0000000..4ddc355
--- /dev/null
+++ b/ports/rust/src/widgets/calendar.rs
@@ -0,0 +1,205 @@
+//! A month, as a grid, with per-day styling.
+//!
+//! The dates are computed rather than read from a host calendar. Every language
+//! this library is ported to has a different date type -- different epochs,
+//! different month numbering, different opinions about time zones -- and a
+//! widget whose output depends on any of that cannot be held to a fixture. So
+//! the arithmetic is here, in terms every port already has: integers.
+
+use crate::buffer::{Attrs, Style};
+use crate::color::Color;
+use crate::surface::{Surface, TextOptions};
+use crate::unicode::{fit, Align};
+
+pub const MONTH_NAMES: [&str; 12] = [
+ "January", "February", "March", "April", "May", "June", "July", "August", "September",
+ "October", "November", "December",
+];
+
+/// Two letters each, so a week is exactly as wide as its days.
+pub const WEEKDAY_NAMES: [&str; 7] = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
+
+/// Three cells a day, minus the separator the last column does not need.
+const DAY_WIDTH: usize = 3;
+/// The width a calendar wants, which is the same whatever month it shows.
+pub const CALENDAR_WIDTH: usize = 7 * DAY_WIDTH - 1;
+
+/// How many days a month has. Months are 1-12, as people write them.
+pub fn days_in_month(year: i64, month: i64) -> i64 {
+ if month == 2 {
+ return if is_leap_year(year) { 29 } else { 28 };
+ }
+ // April, June, September, November.
+ if month == 4 || month == 6 || month == 9 || month == 11 {
+ return 30;
+ }
+ 31
+}
+
+/// The Gregorian leap rule in full: every four years, except centuries, except
+/// every fourth century. Truncating it at "every four years" is right for
+/// 1901-2099 and wrong for 1900 and 2100, which is the kind of bug that sits
+/// quietly for decades.
+pub fn is_leap_year(year: i64) -> bool {
+ year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
+}
+
+/// Day of the week, 0 = Sunday.
+///
+/// Sakamoto's method: a table of month offsets plus the leap-day count, which
+/// is exact for any Gregorian date and needs nothing but integer arithmetic.
+pub fn day_of_week(year: i64, month: i64, day: i64) -> i64 {
+ const OFFSETS: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
+ // January and February belong to the previous year for leap-counting, since
+ // the leap day falls after them.
+ let y = if month < 3 { year - 1 } else { year };
+ let leaps = y.div_euclid(4) - y.div_euclid(100) + y.div_euclid(400);
+ let value = (y + leaps + OFFSETS[(month - 1) as usize] + day) % 7;
+ ((value % 7) + 7) % 7
+}
+
+#[derive(Clone, Copy, Debug, Default)]
+pub struct CalendarMark {
+ /// Day of the month, 1-31.
+ pub day: i64,
+ pub color: Option,
+ pub background: Option,
+ pub bold: bool,
+}
+
+#[derive(Clone, Debug)]
+pub struct CalendarOptions {
+ pub year: i64,
+ /// 1-12, as people write months.
+ pub month: i64,
+ /// Days worth pointing at: holidays, deadlines, days with something on.
+ pub marks: Vec,
+ /// Drawn in the accent colour, as the day the view is about.
+ pub selected: Option,
+ /// The month and year above the grid.
+ pub header: bool,
+ pub header_align: Option,
+ /// The weekday initials above the days.
+ pub weekdays: bool,
+ /// 0 for Sunday, 1 for Monday. Most of the world starts on Monday.
+ pub week_start: i64,
+ pub color: Option,
+ pub background: Option,
+}
+
+impl Default for CalendarOptions {
+ fn default() -> CalendarOptions {
+ CalendarOptions {
+ year: 1970,
+ month: 1,
+ marks: Vec::new(),
+ selected: None,
+ header: true,
+ header_align: None,
+ weekdays: true,
+ week_start: 1,
+ color: None,
+ background: None,
+ }
+ }
+}
+
+impl CalendarOptions {
+ pub fn new(year: i64, month: i64) -> CalendarOptions {
+ CalendarOptions { year, month, ..Default::default() }
+ }
+
+ fn valid(&self) -> bool {
+ (1..=12).contains(&self.month)
+ }
+
+ fn start_column(&self) -> i64 {
+ let week_start = if self.week_start == 0 { 0 } else { 1 };
+ (day_of_week(self.year, self.month, 1) - week_start + 7) % 7
+ }
+}
+
+/// How many rows a month needs, so a caller can size the panel around it.
+///
+/// A month spans four, five or six week rows depending on where its first day
+/// falls; guessing five leaves some months a row short and others a blank row
+/// long.
+pub fn calendar_height(options: &CalendarOptions) -> usize {
+ if !options.valid() {
+ return 0;
+ }
+ let cells = options.start_column() + days_in_month(options.year, options.month);
+ let weeks = (cells + 6) / 7;
+ (weeks as usize) + usize::from(options.header) + usize::from(options.weekdays)
+}
+
+pub fn draw_calendar(surface: &Surface, options: &CalendarOptions) {
+ if surface.is_empty() || !options.valid() {
+ return;
+ }
+ let theme = surface.theme.clone();
+ let base = options.color.unwrap_or(theme.foreground);
+ let bg = options.background;
+ let week_start = if options.week_start == 0 { 0 } else { 1 };
+
+ let mut row = 0isize;
+ if options.header {
+ let label = format!("{} {}", MONTH_NAMES[(options.month - 1) as usize], options.year);
+ let width = surface.width().min(CALENDAR_WIDTH);
+ surface.text(
+ 0,
+ row,
+ &fit(&label, width, options.header_align.unwrap_or(Align::Center)),
+ &TextOptions::from(Style {
+ fg: Some(theme.title),
+ bg,
+ attrs: Some(Attrs::BOLD),
+ }),
+ );
+ row += 1;
+ }
+
+ if options.weekdays {
+ for i in 0..7i64 {
+ let name = WEEKDAY_NAMES[((i + week_start) % 7) as usize];
+ surface.text(
+ (i as usize * DAY_WIDTH) as isize,
+ row,
+ name,
+ &TextOptions::from(Style { fg: Some(theme.muted), bg, attrs: None }),
+ );
+ }
+ row += 1;
+ }
+
+ let first = options.start_column();
+ let total = days_in_month(options.year, options.month);
+
+ for day in 1..=total {
+ let cell = first + day - 1;
+ let y = row + (cell / 7) as isize;
+ if y >= surface.height() as isize {
+ break;
+ }
+ let x = ((cell % 7) as usize * DAY_WIDTH) as isize;
+
+ let mark = options.marks.iter().find(|m| m.day == day);
+ let selected = options.selected == Some(day);
+ let style = Style {
+ fg: Some(if selected {
+ theme.background
+ } else {
+ mark.and_then(|m| m.color).unwrap_or(base)
+ }),
+ bg: if selected { Some(theme.accent) } else { mark.and_then(|m| m.background).or(bg) },
+ attrs: Some(if selected || mark.is_some_and(|m| m.bold) {
+ Attrs::BOLD
+ } else {
+ Attrs::default()
+ }),
+ };
+ // Right-aligned in two cells, so the units column lines up down the week
+ // and a calendar reads as a table rather than as a paragraph of numbers.
+ surface.text(x, y, &fit(&day.to_string(), 2, Align::Right), &TextOptions::from(style));
+ }
+}
diff --git a/ports/rust/src/widgets/mod.rs b/ports/rust/src/widgets/mod.rs
index c52df5c..8efd479 100644
--- a/ports/rust/src/widgets/mod.rs
+++ b/ports/rust/src/widgets/mod.rs
@@ -2,6 +2,7 @@
//! builder in [`ui`](crate::ui) wraps every one of these with layout, so reach
//! for these directly only when you are drawing inside a `draw` escape hatch.
+pub mod calendar;
pub mod chart;
pub mod controls;
pub mod meters;
@@ -22,6 +23,10 @@ pub use meters::{
DonutSegment, GaugeOptions, GraphOptions, HeatBarOptions, MeterItem, MeterOptions,
MetersOptions, ProgressOptions, SparklineWidgetOptions,
};
+pub use calendar::{
+ calendar_height, day_of_week, days_in_month, draw_calendar, is_leap_year, CalendarMark,
+ CalendarOptions,
+};
pub use chart::{draw_chart, ChartOptions};
pub use scrollbar::{
draw_scrollbar, draw_scrollbar_widget, offset_for_position, thumb, thumb_of, ScrollbarOptions,
diff --git a/ports/rust/tests/conformance_widgets.rs b/ports/rust/tests/conformance_widgets.rs
index 685e2b2..c678022 100644
--- a/ports/rust/tests/conformance_widgets.rs
+++ b/ports/rust/tests/conformance_widgets.rs
@@ -66,6 +66,27 @@ fn draw_scene(name: &str, s: &Surface) {
}
"fill" => draw_fill(s, &FillOptions { symbol: "\u{b7}".into(), ..Default::default() }),
"fill-wide" => draw_fill(s, &FillOptions { symbol: "\u{65e5}".into(), ..Default::default() }),
+ "calendar" => draw_calendar(s, &CalendarOptions::new(2026, 9)),
+ "calendar-sunday" => draw_calendar(
+ s,
+ &CalendarOptions { week_start: 0, ..CalendarOptions::new(2026, 9) },
+ ),
+ "calendar-leap" => draw_calendar(s, &CalendarOptions::new(2024, 2)),
+ "calendar-bare" => draw_calendar(
+ s,
+ &CalendarOptions { header: false, weekdays: false, ..CalendarOptions::new(2026, 9) },
+ ),
+ "calendar-marked" => draw_calendar(
+ s,
+ &CalendarOptions {
+ selected: Some(8),
+ marks: vec![
+ CalendarMark { day: 15, ..Default::default() },
+ CalendarMark { day: 22, bold: true, ..Default::default() },
+ ],
+ ..CalendarOptions::new(2026, 9)
+ },
+ ),
"badge" => {
draw_badge(s, &BadgeOptions::new("LIVE"));
}
diff --git a/ports/zig/examples/widgets.zig b/ports/zig/examples/widgets.zig
index 0ab3f67..322b023 100644
--- a/ports/zig/examples/widgets.zig
+++ b/ports/zig/examples/widgets.zig
@@ -208,6 +208,19 @@ fn chart(ui: *Container) anyerror!void {
}
// @end
+// @widget calendar
+fn calendar(ui: *Container) anyerror!void {
+ // The dates are arithmetic, not a host calendar: every port has a different
+ // date type and none of them is consulted.
+ try ui.calendar(.{
+ .year = 2026,
+ .month = 9,
+ .selected = 8,
+ .marks = &.{ .{ .day = 15 }, .{ .day = 22, .bold = true } },
+ });
+}
+// @end
+
// @widget meter
fn meter(ui: *Container) anyerror!void {
try ui.meter(.{ .value = 0.62, .label = "CPU" });
@@ -397,6 +410,7 @@ const examples = [_]Example{
.{ .name = "log", .body = hqtui.Body.plain(log) },
.{ .name = "scrollbar", .body = hqtui.Body.plain(scrollbar) },
.{ .name = "chart", .body = hqtui.Body.plain(chart) },
+ .{ .name = "calendar", .body = hqtui.Body.plain(calendar) },
.{ .name = "meter", .body = hqtui.Body.plain(meter) },
.{ .name = "meters", .body = hqtui.Body.plain(meters) },
.{ .name = "progress", .body = hqtui.Body.plain(progress) },
diff --git a/ports/zig/src/conformance_widgets.zig b/ports/zig/src/conformance_widgets.zig
index 695309b..2a07b5d 100644
--- a/ports/zig/src/conformance_widgets.zig
+++ b/ports/zig/src/conformance_widgets.zig
@@ -68,6 +68,21 @@ fn drawScene(allocator: std.mem.Allocator, name: []const u8, s: Surface) !void {
w.drawFill(s, .{ .symbol = "\u{b7}" });
} else if (eq(u8, name, "fill-wide")) {
w.drawFill(s, .{ .symbol = "\u{65e5}" });
+ } else if (eq(u8, name, "calendar")) {
+ w.drawCalendar(s, .{ .year = 2026, .month = 9 });
+ } else if (eq(u8, name, "calendar-sunday")) {
+ w.drawCalendar(s, .{ .year = 2026, .month = 9, .week_start = 0 });
+ } else if (eq(u8, name, "calendar-leap")) {
+ w.drawCalendar(s, .{ .year = 2024, .month = 2 });
+ } else if (eq(u8, name, "calendar-bare")) {
+ w.drawCalendar(s, .{ .year = 2026, .month = 9, .header = false, .weekdays = false });
+ } else if (eq(u8, name, "calendar-marked")) {
+ w.drawCalendar(s, .{
+ .year = 2026,
+ .month = 9,
+ .selected = 8,
+ .marks = &.{ .{ .day = 15 }, .{ .day = 22, .bold = true } },
+ });
} else if (eq(u8, name, "badge")) {
_ = w.drawBadge(s, .{ .text = "LIVE" });
} else if (eq(u8, name, "badge-outline")) {
diff --git a/ports/zig/src/ui.zig b/ports/zig/src/ui.zig
index d2ab1d6..02f9bb9 100644
--- a/ports/zig/src/ui.zig
+++ b/ports/zig/src/ui.zig
@@ -372,6 +372,7 @@ const Node = union(enum) {
meters: w.MetersOptions,
progress: w.ProgressOptions,
graph: w.GraphOptions,
+ calendar: w.CalendarOptions,
chart: w.ChartOptions,
clear: w.ClearOptions,
fill: w.FillOptions,
@@ -462,6 +463,7 @@ fn drawNode(ctx: *Ctx, s: Surface, node: Node) anyerror!void {
.meters => |o| w.drawMeters(s, o),
.progress => |o| w.drawProgress(s, o),
.graph => |o| try w.drawGraph(allocator, s, o),
+ .calendar => |o| w.drawCalendar(s, o),
.chart => |o| try w.drawChart(allocator, s, o),
.clear => |o| w.drawClear(s, o),
.fill => |o| w.drawFill(s, o),
@@ -866,6 +868,15 @@ pub const Container = struct {
try self.add(self.filling(), .{ .fill = options });
}
+ /// A month as a grid, with per-day styling.
+ ///
+ /// Sized to the month it shows: a month spans four, five or six week rows
+ /// depending on where its first day falls, and reserving five leaves some
+ /// months a row short and others a blank row long.
+ pub fn calendar(self: *Container, options: w.CalendarOptions) !void {
+ try self.add(self.leaf(w.calendarHeight(options)), .{ .calendar = options });
+ }
+
pub fn sparkline(self: *Container, options: w.SparklineWidgetOptions) !void {
try self.add(self.leaf(1), .{ .sparkline = options });
}
diff --git a/ports/zig/src/widgets.zig b/ports/zig/src/widgets.zig
index c566dcb..f7c13da 100644
--- a/ports/zig/src/widgets.zig
+++ b/ports/zig/src/widgets.zig
@@ -4,6 +4,7 @@
pub const controls = @import("widgets/controls.zig");
pub const meters = @import("widgets/meters.zig");
+pub const calendar = @import("widgets/calendar.zig");
pub const chart = @import("widgets/chart.zig");
pub const surface_widgets = @import("widgets/surface.zig");
pub const scrollbar = @import("widgets/scrollbar.zig");
@@ -61,6 +62,10 @@ pub const TreeOptions = table.TreeOptions;
pub const TreeValue = table.TreeValue;
pub const drawList = table.drawList;
pub const drawLog = table.drawLog;
+pub const CalendarMark = calendar.CalendarMark;
+pub const CalendarOptions = calendar.CalendarOptions;
+pub const calendarHeight = calendar.calendarHeight;
+pub const drawCalendar = calendar.drawCalendar;
pub const ChartOptions = chart.ChartOptions;
pub const ClearOptions = surface_widgets.ClearOptions;
pub const FillOptions = surface_widgets.FillOptions;
diff --git a/ports/zig/src/widgets/calendar.zig b/ports/zig/src/widgets/calendar.zig
new file mode 100644
index 0000000..101acae
--- /dev/null
+++ b/ports/zig/src/widgets/calendar.zig
@@ -0,0 +1,192 @@
+//! A month, as a grid, with per-day styling.
+//!
+//! The dates are computed rather than read from a host calendar. Every language
+//! this library is ported to has a different date type -- different epochs,
+//! different month numbering, different opinions about time zones -- and a
+//! widget whose output depends on any of that cannot be held to a fixture. So
+//! the arithmetic is here, in terms every port already has: integers.
+
+const std = @import("std");
+
+const buffer_mod = @import("../buffer.zig");
+const color_mod = @import("../color.zig");
+const surface_mod = @import("../surface.zig");
+const unicode = @import("../unicode.zig");
+
+const Attrs = buffer_mod.Attrs;
+const Color = color_mod.Color;
+const Surface = surface_mod.Surface;
+
+pub const MONTH_NAMES = [_][]const u8{
+ "January", "February", "March", "April", "May", "June",
+ "July", "August", "September", "October", "November", "December",
+};
+
+/// Two letters each, so a week is exactly as wide as its days.
+pub const WEEKDAY_NAMES = [_][]const u8{ "Su", "Mo", "Tu", "We", "Th", "Fr", "Sa" };
+
+/// Three cells a day, minus the separator the last column does not need.
+const DAY_WIDTH: usize = 3;
+/// The width a calendar wants, which is the same whatever month it shows.
+pub const CALENDAR_WIDTH: usize = 7 * DAY_WIDTH - 1;
+
+/// The Gregorian leap rule in full: every four years, except centuries, except
+/// every fourth century. Truncating it at "every four years" is right for
+/// 1901-2099 and wrong for 1900 and 2100, which is the kind of bug that sits
+/// quietly for decades.
+pub fn isLeapYear(year: i64) bool {
+ return @mod(year, 4) == 0 and (@mod(year, 100) != 0 or @mod(year, 400) == 0);
+}
+
+/// How many days a month has. Months are 1-12, as people write them.
+pub fn daysInMonth(year: i64, month: i64) i64 {
+ if (month == 2) return if (isLeapYear(year)) 29 else 28;
+ // April, June, September, November.
+ if (month == 4 or month == 6 or month == 9 or month == 11) return 30;
+ return 31;
+}
+
+/// Day of the week, 0 = Sunday.
+///
+/// Sakamoto's method: a table of month offsets plus the leap-day count, which
+/// is exact for any Gregorian date and needs nothing but integer arithmetic.
+pub fn dayOfWeek(year: i64, month: i64, day: i64) i64 {
+ const offsets = [_]i64{ 0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4 };
+ // January and February belong to the previous year for leap-counting, since
+ // the leap day falls after them.
+ const y = if (month < 3) year - 1 else year;
+ // Floor division, not truncation: the leap count rounds towards negative
+ // infinity, which only differs before year 1 but differs there every time.
+ const leaps = @divFloor(y, 4) - @divFloor(y, 100) + @divFloor(y, 400);
+ return @mod(y + leaps + offsets[@intCast(month - 1)] + day, 7);
+}
+
+pub const CalendarMark = struct {
+ /// Day of the month, 1-31.
+ day: i64 = 0,
+ color: ?Color = null,
+ background: ?Color = null,
+ bold: bool = false,
+};
+
+pub const CalendarOptions = struct {
+ year: i64 = 1970,
+ /// 1-12, as people write months.
+ month: i64 = 1,
+ /// Days worth pointing at: holidays, deadlines, days with something on.
+ marks: []const CalendarMark = &.{},
+ /// Drawn in the accent colour, as the day the view is about.
+ selected: ?i64 = null,
+ /// The month and year above the grid.
+ header: bool = true,
+ header_align: unicode.Align = .center,
+ /// The weekday initials above the days.
+ weekdays: bool = true,
+ /// 0 for Sunday, 1 for Monday. Most of the world starts on Monday.
+ week_start: i64 = 1,
+ color: ?Color = null,
+ background: ?Color = null,
+
+ fn valid(self: CalendarOptions) bool {
+ return self.month >= 1 and self.month <= 12;
+ }
+
+ fn weekStart(self: CalendarOptions) i64 {
+ return if (self.week_start == 0) 0 else 1;
+ }
+
+ fn startColumn(self: CalendarOptions) i64 {
+ return @mod(dayOfWeek(self.year, self.month, 1) - self.weekStart() + 7, 7);
+ }
+};
+
+/// How many rows a month needs, so a caller can size the panel around it.
+///
+/// A month spans four, five or six week rows depending on where its first day
+/// falls; guessing five leaves some months a row short and others a blank row
+/// long.
+pub fn calendarHeight(options: CalendarOptions) usize {
+ if (!options.valid()) return 0;
+ const cells = options.startColumn() + daysInMonth(options.year, options.month);
+ const weeks: usize = @intCast(@divFloor(cells + 6, 7));
+ return weeks + @intFromBool(options.header) + @intFromBool(options.weekdays);
+}
+
+pub fn drawCalendar(s: Surface, options: CalendarOptions) void {
+ if (s.isEmpty() or !options.valid()) return;
+ const theme = s.theme;
+ const base = options.color orelse theme.foreground;
+ const bg = options.background;
+ const week_start = options.weekStart();
+
+ var row: isize = 0;
+ if (options.header) {
+ var label_buf: [64]u8 = undefined;
+ const label = std.fmt.bufPrint(&label_buf, "{s} {d}", .{
+ MONTH_NAMES[@intCast(options.month - 1)],
+ options.year,
+ }) catch MONTH_NAMES[@intCast(options.month - 1)];
+ const width = @min(s.width(), CALENDAR_WIDTH);
+ var padded: [128]u8 = undefined;
+ _ = s.text(
+ 0,
+ row,
+ unicode.fit(&padded, label, width, options.header_align),
+ .{ .fg = theme.title, .bg = bg, .attrs = Attrs{ .bold = true } },
+ );
+ row += 1;
+ }
+
+ if (options.weekdays) {
+ for (0..7) |i| {
+ const name = WEEKDAY_NAMES[@intCast(@mod(@as(i64, @intCast(i)) + week_start, 7))];
+ _ = s.text(@intCast(i * DAY_WIDTH), row, name, .{ .fg = theme.muted, .bg = bg });
+ }
+ row += 1;
+ }
+
+ const first = options.startColumn();
+ const total = daysInMonth(options.year, options.month);
+
+ var day: i64 = 1;
+ while (day <= total) : (day += 1) {
+ const cell = first + day - 1;
+ const y = row + @as(isize, @intCast(@divFloor(cell, 7)));
+ if (y >= @as(isize, @intCast(s.height()))) break;
+ const x: isize = @intCast(@as(usize, @intCast(@mod(cell, 7))) * DAY_WIDTH);
+
+ var mark: ?CalendarMark = null;
+ for (options.marks) |m| {
+ if (m.day == day) {
+ mark = m;
+ break;
+ }
+ }
+ const selected = options.selected != null and options.selected.? == day;
+
+ var fg = base;
+ var cell_bg = bg;
+ var attrs = Attrs.none;
+ if (mark) |m| {
+ if (m.color) |c| fg = c;
+ if (m.background) |c| cell_bg = c;
+ if (m.bold) attrs = Attrs{ .bold = true };
+ }
+ if (selected) {
+ fg = theme.background;
+ cell_bg = theme.accent;
+ attrs = Attrs{ .bold = true };
+ }
+
+ var number: [8]u8 = undefined;
+ const text = std.fmt.bufPrint(&number, "{d}", .{day}) catch "?";
+ var padded_day: [16]u8 = undefined;
+ // Right-aligned in two cells, so the units column lines up down the week
+ // and a calendar reads as a table rather than as a paragraph of numbers.
+ _ = s.text(x, y, unicode.fit(&padded_day, text, 2, .right), .{
+ .fg = fg,
+ .bg = cell_bg,
+ .attrs = attrs,
+ });
+ }
+}