Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions apps/web/content/widgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<pre class=\"hqtui-preview\" style=\"background:#05070a;color:#c6d0db;padding:14px;font-size:13px;line-height:15.34px;font-family:ui-monospace,SFMono-Regular,Menlo,&#39;DejaVu Sans Mono&#39;,&#39;Liberation Mono&#39;,Consolas,&#39;Segoe UI Symbol&#39;,monospace;margin:0;overflow-x:auto;border-radius:8px;white-space:pre;font-variant-ligatures:none;-webkit-font-smoothing:antialiased\"><span style=\"color:#7ee2ff;background:#05070a;font-weight:700\"> September 2026 </span>\n<span style=\"color:#5a6b7d;background:#05070a\">Mo</span><span style=\"color:#c6d0db;background:#05070a\"> </span><span style=\"color:#5a6b7d;background:#05070a\">Tu</span><span style=\"color:#c6d0db;background:#05070a\"> </span><span style=\"color:#5a6b7d;background:#05070a\">We</span><span style=\"color:#c6d0db;background:#05070a\"> </span><span style=\"color:#5a6b7d;background:#05070a\">Th</span><span style=\"color:#c6d0db;background:#05070a\"> </span><span style=\"color:#5a6b7d;background:#05070a\">Fr</span><span style=\"color:#c6d0db;background:#05070a\"> </span><span style=\"color:#5a6b7d;background:#05070a\">Sa</span><span style=\"color:#c6d0db;background:#05070a\"> </span><span style=\"color:#5a6b7d;background:#05070a\">Su</span>\n<span style=\"color:#c6d0db;background:#05070a\"> 1 2 3 4 5 6</span>\n<span style=\"color:#c6d0db;background:#05070a\"> 7 </span><span style=\"color:#05070a;background:#56d4dd;font-weight:700\"> 8</span><span style=\"color:#c6d0db;background:#05070a\"> 9 10 11 12 13</span>\n<span style=\"color:#c6d0db;background:#05070a\">14 </span><span style=\"color:#ffd75f;background:#05070a\">15</span><span style=\"color:#c6d0db;background:#05070a\"> 16 17 18 19 20</span>\n<span style=\"color:#c6d0db;background:#05070a\">21 </span><span style=\"color:#5fff87;background:#05070a;font-weight:700\">22</span><span style=\"color:#c6d0db;background:#05070a\"> 23 24 25 26 27</span>\n<span style=\"color:#c6d0db;background:#05070a\">28 29 30 </span>\n<span style=\"color:#c6d0db;background:#05070a\"> </span></pre>",
"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",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/test/widgets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
});
2 changes: 2 additions & 0 deletions examples/widgets/build-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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." },
Expand Down
13 changes: 13 additions & 0 deletions examples/widgets/gallery.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions examples/widgets/gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions packages/hqtui/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
164 changes: 164 additions & 0 deletions packages/hqtui/src/widgets/calendar.ts
Original file line number Diff line number Diff line change
@@ -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<number, CalendarMark>();
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;
1 change: 1 addition & 0 deletions packages/hqtui/src/widgets/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./text.ts";
export * from "./calendar.ts";
export * from "./chart.ts";
export * from "./surface.ts";
export * from "./scrollbar.ts";
Expand Down
Loading