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, + }); + } +}