@teaui/core
Version:
A high-level terminal UI library for Node
966 lines • 36.1 kB
JavaScript
import { View } from '../View.js';
import { Point, Rect, Size } from '../geometry.js';
import { isMouseClicked, } from '../events/index.js';
import { Style } from '../Style.js';
import { lineWidth } from '@teaui/term';
const MONTH_NAMES = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
const MONTH_SHORT = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
const DAY_HEADERS_SUN = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
const DAY_HEADERS_MON = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
const CLOSE = '×';
const ARROW_UP = ' [ ↑ ] ';
const ARROW_DOWN = ' [ ↓ ] ';
// Width: " Su Mo Tu We Th Fr Sa " = 1 + 7*3 = 22
const CALENDAR_WIDTH = 22;
const NAV_BUTTON_WIDTH = 3;
const CLOSE_BUTTON_WIDTH = 3;
const MONTH_LABEL_X = 3;
// Height: header + weekday row + 6 week rows = 8
const CALENDAR_HEIGHT = 8;
export class Calendar extends View {
// configurable so that tests can test 'today' rendering
#today;
#date;
#cursorDate;
#visibleDate;
#onChangeVisible;
#onChange;
#selection;
#firstDayOfWeek;
#displayMode = 'days';
#hasFocus = false;
// Range selection state
#rangeStart;
#rangeEnd;
#rangeNextSelection = 'start';
#shiftSelecting = false;
// Year picker state
#yearScrollOffset = 0;
#yearSearch = '';
// Mouse drag tracking
#dragStartDate;
// Mouse hover tracking
#hoverClose = false;
#hoverPrevButton = false;
#hoverNextButton = false;
#hoverMonthLabel;
#hoverYearLabel;
#hoverDate;
constructor(props = {}) {
super(props);
const now = props.now ?? new Date();
this.#today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
const date = props.date ?? this.#today;
this.#date = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
this.#cursorDate = this.#date;
this.#visibleDate =
props.visibleDate ??
new Date(this.#today.getFullYear(), this.#today.getMonth(), 1);
this.#onChangeVisible = props.onChangeVisible;
this.#onChange = props.onChange;
this.#selection = props.selection ?? 'single';
this.#firstDayOfWeek = props.firstDayOfWeek ?? 0;
}
update(props) {
if (props.date !== undefined) {
this.#date = new Date(props.date.getFullYear(), props.date.getMonth(), props.date.getDate(), 0, 0, 0, 0);
}
if (props.visibleDate !== undefined)
this.#visibleDate = props.visibleDate;
if (props.onChangeVisible !== undefined)
this.#onChangeVisible = props.onChangeVisible;
if (props.onChange !== undefined)
this.#onChange = props.onChange;
if (props.selection !== undefined)
this.#selection = props.selection;
if (props.firstDayOfWeek !== undefined)
this.#firstDayOfWeek = props.firstDayOfWeek;
super.update(props);
}
get date() {
return this.#date;
}
set date(value) {
this.#date = value;
this.invalidateRender();
}
get cursorDate() {
return this.#cursorDate;
}
set cursorDate(value) {
this.#cursorDate = value;
this.invalidateRender();
}
get visibleDate() {
return this.#visibleDate;
}
set visibleDate(value) {
this.#visibleDate = value;
this.invalidateRender();
}
get displayMode() {
return this.#displayMode;
}
naturalSize(_available) {
return new Size(CALENDAR_WIDTH, CALENDAR_HEIGHT);
}
get #hoverStyle() {
return new Style({
foreground: this.purpose.text().foreground,
background: this.purpose.darkenColor,
});
}
get #buttonHoverStyle() {
return this.purpose.ui({ isHover: true });
}
get #rangeGapStyle() {
return new Style({ background: this.purpose.darkenColor });
}
get #weekdayStyle() {
return new Style({
bold: true,
foreground: this.purpose.highlightColor,
background: this.purpose.text().background,
});
}
get #rangeEndpointStyle() {
return new Style({
bold: true,
foreground: this.purpose.textColor,
background: this.purpose.darkenColor,
});
}
get #inRangeStyle() {
return new Style({
foreground: this.purpose.highlightColor,
background: this.purpose.darkenColor,
});
}
get #todayStyle() {
return new Style({
bold: true,
foreground: this.purpose.contrastTextColor,
background: this.purpose.text().background,
});
}
get #cursorStyle() {
return new Style({
bold: true,
foreground: this.purpose.text().foreground,
background: this.purpose.darkenColor,
});
}
get #selectedStyle() {
return new Style({
bold: true,
foreground: this.purpose.textColor,
background: this.purpose.highlightColor,
});
}
#widgetRect(viewport) {
return new Rect(Point.zero, [
Math.min(CALENDAR_WIDTH, viewport.contentSize.width),
Math.min(CALENDAR_HEIGHT, viewport.contentSize.height),
]);
}
#isSameDay(a, b) {
return (a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate());
}
#isInRange(date) {
if (!this.#rangeStart || !this.#rangeEnd)
return false;
const time = date.getTime();
const start = Math.min(this.#rangeStart.getTime(), this.#rangeEnd.getTime());
const end = Math.max(this.#rangeStart.getTime(), this.#rangeEnd.getTime());
return time >= start && time <= end;
}
#navigateMonth(delta) {
const d = new Date(this.#visibleDate.getFullYear(), this.#visibleDate.getMonth() + delta, 1);
this.#visibleDate = d;
this.#onChangeVisible?.(d);
this.invalidateRender();
}
#syncVisibleDate(date) {
if (date.getMonth() !== this.#visibleDate.getMonth() ||
date.getFullYear() !== this.#visibleDate.getFullYear()) {
const d = new Date(date.getFullYear(), date.getMonth(), 1);
this.#visibleDate = d;
this.#onChangeVisible?.(d);
}
}
#startRangeSelection(date, syncVisibleDate = true) {
this.#rangeStart = date;
this.#rangeEnd = date;
this.#rangeNextSelection = 'end';
this.#date = date;
this.#cursorDate = date;
if (syncVisibleDate) {
this.#syncVisibleDate(date);
}
this.invalidateRender();
}
#finishRangeSelection(date, syncVisibleDate = true) {
if (!this.#rangeStart) {
this.#startRangeSelection(date, syncVisibleDate);
return;
}
this.#rangeEnd = date;
this.#rangeNextSelection = 'start';
this.#date = date;
this.#cursorDate = date;
if (syncVisibleDate) {
this.#syncVisibleDate(date);
}
const start = this.#rangeStart.getTime() <= date.getTime() ? this.#rangeStart : date;
const end = this.#rangeStart.getTime() <= date.getTime() ? date : this.#rangeStart;
this.#onChange?.(start, end);
this.invalidateRender();
}
#selectDate(date) {
if (this.#selection === 'range') {
if (this.#rangeNextSelection === 'start') {
this.#startRangeSelection(date);
}
else {
this.#finishRangeSelection(date);
}
return;
}
this.#date = date;
this.#cursorDate = date;
this.#onChange?.(date, date);
this.#syncVisibleDate(date);
this.invalidateRender();
}
#selectCursorDate(date) {
this.#cursorDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
this.#syncVisibleDate(date);
this.invalidateRender();
}
#moveCursorDateBy(days) {
this.#shiftSelecting = false;
const nextDate = new Date(this.#cursorDate);
nextDate.setDate(nextDate.getDate() + days);
this.#cursorDate = nextDate;
// Navigate months if needed
if (nextDate.getMonth() !== this.#visibleDate.getMonth() ||
nextDate.getFullYear() !== this.#visibleDate.getFullYear()) {
const vis = new Date(nextDate.getFullYear(), nextDate.getMonth(), 1);
this.#visibleDate = vis;
this.#onChangeVisible?.(vis);
}
this.invalidateRender();
}
#shiftSelectBy(days) {
if (!this.#shiftSelecting || !this.#rangeStart) {
this.#rangeStart = new Date(this.#cursorDate);
}
this.#moveCursorDateBy(days);
this.#shiftSelecting = true;
this.#rangeEnd = new Date(this.#cursorDate);
this.#rangeNextSelection = 'start';
const start = this.#rangeStart.getTime() <= this.#rangeEnd.getTime()
? this.#rangeStart
: this.#rangeEnd;
const end = this.#rangeStart.getTime() <= this.#rangeEnd.getTime()
? this.#rangeEnd
: this.#rangeStart;
this.#onChange?.(start, end);
}
#selectMonth(month) {
const d = new Date(this.#visibleDate.getFullYear(), month, 1);
this.#visibleDate = d;
this.#selectDisplayMode('days');
this.#onChangeVisible?.(d);
this.invalidateRender();
}
#selectYear(year) {
const d = new Date(year, this.#visibleDate.getMonth(), 1);
this.#visibleDate = d;
this.#selectDisplayMode('days');
this.#onChangeVisible?.(d);
this.invalidateRender();
}
#selectDisplayMode(mode) {
this.#displayMode = mode;
this.#hoverClose = false;
this.#hoverMonthLabel = undefined;
this.#hoverYearLabel = undefined;
}
#getDateAtPosition(x, y) {
if (y < 2 || y > 7)
return undefined;
const col = Math.floor(x / 3);
const weekRow = y - 2;
const grid = this.#getDayGrid();
if (col < 0 || col >= 7 || weekRow < 0 || weekRow >= grid.length) {
return undefined;
}
return grid[weekRow][col];
}
/** Get all day cells for the current visible month grid */
#getDayGrid() {
const year = this.#visibleDate.getFullYear();
const month = this.#visibleDate.getMonth();
const firstOfMonth = new Date(year, month, 1);
let startDow = firstOfMonth.getDay(); // 0=Sun
if (this.#firstDayOfWeek === 1) {
startDow = (startDow + 6) % 7; // Convert to Mon=0
}
const weeks = [];
// Start from the first day shown in the grid
const startDate = new Date(year, month, 1 - startDow);
for (let week = 0; week < 6; week++) {
const row = [];
for (let day = 0; day < 7; day++) {
const d = new Date(startDate);
d.setDate(startDate.getDate() + week * 7 + day);
row.push(d);
}
weeks.push(row);
}
return weeks;
}
receiveMouse(event, system) {
super.receiveMouse(event, system);
if (event.name === 'mouse.button.down') {
system.requestFocus();
}
if (this.#displayMode === 'days') {
this.#receiveMouseDays(event);
}
else if (this.#displayMode === 'months') {
this.#receiveMouseMonths(event);
}
else {
this.#receiveMouseYears(event);
}
}
#formatMonthTitle(date) {
const totalWidth = 10;
const textWidth = lineWidth(MONTH_NAMES[date.getMonth()]);
const pad = Math.max(0, totalWidth - textWidth);
return (' '.repeat(Math.floor(pad / 2)) +
MONTH_NAMES[date.getMonth()] +
' '.repeat(Math.ceil(pad / 2)));
}
#formatYearTitle(date) {
return ` ${String(date.getFullYear())} `;
}
#yearLabelX(yearStr) {
return CALENDAR_WIDTH - NAV_BUTTON_WIDTH - lineWidth(yearStr);
}
#receiveMouseDays(event) {
const x = event.position.x;
const y = event.position.y;
// Reset hover states
this.#hoverPrevButton = false;
this.#hoverNextButton = false;
this.#hoverMonthLabel = undefined;
this.#hoverYearLabel = undefined;
if (y === 0) {
const monthName = this.#formatMonthTitle(this.#visibleDate);
const yearStr = this.#formatYearTitle(this.#visibleDate);
const yearStart = this.#yearLabelX(yearStr);
if (x < NAV_BUTTON_WIDTH) {
this.#hoverPrevButton = true;
if (isMouseClicked(event)) {
this.#navigateMonth(-1);
}
}
else if (x >= CALENDAR_WIDTH - NAV_BUTTON_WIDTH) {
this.#hoverNextButton = true;
if (isMouseClicked(event)) {
this.#navigateMonth(1);
}
}
else if (x >= MONTH_LABEL_X &&
x < MONTH_LABEL_X + lineWidth(monthName)) {
this.#hoverMonthLabel = 'true';
if (isMouseClicked(event)) {
this.#selectDisplayMode('months');
}
}
else if (x >= yearStart && x < yearStart + lineWidth(yearStr)) {
this.#hoverYearLabel = 'true';
if (isMouseClicked(event)) {
this.#selectDisplayMode('years');
this.#yearScrollOffset = 0;
this.#yearSearch = '';
}
}
return;
}
const date = this.#getDateAtPosition(x, y);
this.#hoverDate = date;
if (!date) {
if (event.name === 'mouse.button.cancel' ||
event.name === 'mouse.button.up') {
this.#dragStartDate = undefined;
}
return;
}
if (this.#selection === 'range') {
switch (event.name) {
case 'mouse.button.down':
if (this.#rangeNextSelection === 'end' && this.#rangeStart) {
this.#dragStartDate = undefined;
this.#finishRangeSelection(date);
}
else {
this.#dragStartDate = date;
this.#startRangeSelection(date, false);
}
break;
case 'mouse.button.dragInside':
case 'mouse.button.enter':
if (this.#dragStartDate) {
this.#rangeStart = this.#dragStartDate;
this.#rangeEnd = date;
this.#date = date;
this.#cursorDate = date;
this.invalidateRender();
}
break;
case 'mouse.button.up':
if (this.#dragStartDate &&
!this.#isSameDay(this.#dragStartDate, date)) {
this.#rangeStart = this.#dragStartDate;
this.#finishRangeSelection(date);
}
this.#dragStartDate = undefined;
break;
case 'mouse.button.cancel':
this.#dragStartDate = undefined;
this.invalidateRender();
break;
}
return;
}
if (event.name === 'mouse.button.down') {
this.#selectDate(date);
}
}
#receiveMouseMonths(event) {
const x = event.position.x;
const y = event.position.y;
// Reset hover states
this.#hoverClose = false;
this.#hoverMonthLabel = undefined;
if (y === 0 && x >= CALENDAR_WIDTH - CLOSE_BUTTON_WIDTH) {
this.#hoverClose = true;
if (isMouseClicked(event)) {
this.#selectDisplayMode('days');
this.invalidateRender();
return;
}
}
// Month grid: 4 rows × 3 columns, starting at y=2
if (y >= 2 && y <= 5) {
const col = Math.floor(x / 7);
const row = y - 2;
if (col >= 0 && col < 3 && row >= 0 && row < 4) {
const month = row * 3 + col;
this.#hoverMonthLabel = MONTH_SHORT[month];
if (isMouseClicked(event)) {
this.#selectMonth(month);
}
}
}
this.invalidateRender();
}
#receiveMouseYears(event) {
const x = event.position.x;
const y = event.position.y;
// Reset hover states
this.#hoverClose = false;
this.#hoverPrevButton = false;
this.#hoverNextButton = false;
this.#hoverYearLabel = undefined;
if (y === 0 && x >= CALENDAR_WIDTH - CLOSE_BUTTON_WIDTH) {
this.#hoverClose = true;
if (isMouseClicked(event)) {
this.#selectDisplayMode('days');
this.invalidateRender();
return;
}
}
// Scroll arrows
if (y === 1) {
this.#hoverPrevButton = true;
if (isMouseClicked(event)) {
this.#yearScrollOffset -= 5;
this.invalidateRender();
}
}
else if (y === 7) {
this.#hoverNextButton = true;
if (isMouseClicked(event)) {
this.#yearScrollOffset += 5;
this.invalidateRender();
}
}
// Year rows at y=2..6 (5 visible years)
if (y >= 2 && y <= 6) {
const yearIndex = y - 2;
const baseYear = this.#visibleDate.getFullYear() - 2 + this.#yearScrollOffset;
const year = baseYear + yearIndex;
this.#hoverYearLabel = String(year);
if (isMouseClicked(event)) {
this.#selectYear(year);
}
}
// Scroll wheel
if (event.name === 'mouse.wheel.up') {
this.#yearScrollOffset -= 1;
this.invalidateRender();
}
else if (event.name === 'mouse.wheel.down') {
this.#yearScrollOffset += 1;
this.invalidateRender();
}
this.invalidateRender();
}
legendItems() {
return [
{ key: ['left', 'right', 'up', 'down'], label: 'Navigate' },
{ key: 'return', label: 'Select' },
{ key: 't', label: 'Today' },
{ key: ['pageup', 'pagedown'], label: 'Month' },
{ key: 'home', label: 'First Day' },
{ key: 'end', label: 'Last Day' },
];
}
receiveKey(event) {
if (this.#displayMode === 'days') {
this.#receiveKeyDays(event);
}
else if (this.#displayMode === 'months') {
this.#receiveKeyMonths(event);
}
else {
this.#receiveKeyYears(event);
}
}
#receiveKeyDays(event) {
switch (event.name) {
case 't':
this.#selectCursorDate(new Date());
break;
case 'left':
if (event.shift) {
this.#shiftSelectBy(-1);
}
else {
this.#moveCursorDateBy(-1);
}
break;
case 'right':
if (event.shift) {
this.#shiftSelectBy(1);
}
else {
this.#moveCursorDateBy(1);
}
break;
case 'up':
if (event.shift) {
this.#shiftSelectBy(-7);
}
else {
this.#moveCursorDateBy(-7);
}
break;
case 'down':
if (event.shift) {
this.#shiftSelectBy(7);
}
else {
this.#moveCursorDateBy(7);
}
break;
case 'return':
this.#selectDate(this.#cursorDate);
break;
case 'escape':
// Do nothing in day view
break;
case 'pageup':
this.#navigateMonth(-1);
break;
case 'pagedown':
this.#navigateMonth(1);
break;
case 'home': {
const nextDate = new Date(this.#visibleDate.getFullYear(), this.#visibleDate.getMonth(), 1);
this.#cursorDate = nextDate;
this.invalidateRender();
break;
}
case 'end': {
const nextDate = new Date(this.#visibleDate.getFullYear(), this.#visibleDate.getMonth() + 1, 0);
this.#cursorDate = nextDate;
this.invalidateRender();
break;
}
}
}
#receiveKeyMonths(event) {
switch (event.name) {
case 'escape':
this.#selectDisplayMode('days');
this.invalidateRender();
break;
case 'return':
this.#selectMonth(this.#visibleDate.getMonth());
break;
case 'left': {
const m = this.#visibleDate.getMonth() - 1;
if (m >= 0) {
this.#visibleDate = new Date(this.#visibleDate.getFullYear(), m, 1);
this.invalidateRender();
}
break;
}
case 'right': {
const m = this.#visibleDate.getMonth() + 1;
if (m <= 11) {
this.#visibleDate = new Date(this.#visibleDate.getFullYear(), m, 1);
this.invalidateRender();
}
break;
}
case 'up': {
const m = this.#visibleDate.getMonth() - 3;
if (m >= 0) {
this.#visibleDate = new Date(this.#visibleDate.getFullYear(), m, 1);
this.invalidateRender();
}
break;
}
case 'down': {
const m = this.#visibleDate.getMonth() + 3;
if (m <= 11) {
this.#visibleDate = new Date(this.#visibleDate.getFullYear(), m, 1);
this.invalidateRender();
}
break;
}
}
}
#receiveKeyYears(event) {
switch (event.name) {
case 'escape':
this.#selectDisplayMode('days');
this.#yearSearch = '';
this.invalidateRender();
break;
case 'return': {
if (this.#yearSearch) {
const year = parseInt(this.#yearSearch, 10);
if (!isNaN(year)) {
this.#selectYear(year);
this.#yearSearch = '';
}
}
else {
this.#selectYear(this.#visibleDate.getFullYear());
}
break;
}
case 'up':
this.#yearScrollOffset -= 1;
this.invalidateRender();
break;
case 'down':
this.#yearScrollOffset += 1;
this.invalidateRender();
break;
case 'backspace':
this.#yearSearch = this.#yearSearch.slice(0, -1);
this.#updateYearFromSearch();
break;
default: {
// Accept digit input for year search
const char = event.char;
if (char && char >= '0' && char <= '9') {
this.#yearSearch += char;
this.#updateYearFromSearch();
}
break;
}
}
}
#updateYearFromSearch() {
if (this.#yearSearch) {
const year = parseInt(this.#yearSearch, 10);
if (!isNaN(year)) {
this.#yearScrollOffset = year - this.#visibleDate.getFullYear();
}
}
this.invalidateRender();
}
render(viewport) {
const hasFocus = viewport.registerFocus();
this.#hasFocus = hasFocus;
if (viewport.isEmpty) {
return;
}
viewport.registerMouse(['mouse.button.left', 'mouse.move', 'mouse.wheel'], this.#widgetRect(viewport));
switch (this.#displayMode) {
case 'days':
this.#renderDays(viewport);
break;
case 'months':
this.#renderMonths(viewport);
break;
case 'years':
this.#renderYears(viewport);
break;
}
}
#renderDays(viewport) {
const today = this.#today;
const month = this.#visibleDate.getMonth();
const monthName = this.#formatMonthTitle(this.#visibleDate);
const yearStr = this.#formatYearTitle(this.#visibleDate);
const textStyle = this.purpose.text();
const dimStyle = this.purpose.text({ isPlaceholder: true });
const hoverStyle = this.#hoverStyle;
const headerStyle = this.purpose.ui({ isHover: false });
const selectedStyle = this.#selectedStyle;
const todayStyle = this.#todayStyle;
const inRangeStyle = this.#inRangeStyle;
const rangeEndpointStyle = this.#rangeEndpointStyle;
viewport.paint(textStyle, this.#widgetRect(viewport));
// Header: "◃ June 2026 ▹"
const prevArrow = this.#hoverPrevButton ? ' ◂ ' : ' ◃ ';
const nextArrow = this.#hoverNextButton ? ' ▸ ' : ' ▹ ';
const monthLabelStyle = this.#hoverMonthLabel
? this.#buttonHoverStyle
: headerStyle;
const yearLabelStyle = this.#hoverYearLabel
? this.#buttonHoverStyle
: headerStyle;
const yearStart = this.#yearLabelX(yearStr);
viewport.write(prevArrow, new Point(0, 0), this.#hoverPrevButton ? this.#buttonHoverStyle : headerStyle);
viewport.write(monthName, new Point(MONTH_LABEL_X, 0), monthLabelStyle);
viewport.write(yearStr, new Point(yearStart, 0), yearLabelStyle);
viewport.write(nextArrow, new Point(CALENDAR_WIDTH - NAV_BUTTON_WIDTH, 0), this.#hoverNextButton ? this.#buttonHoverStyle : headerStyle);
// Weekday headers
const dayHeaders = this.#firstDayOfWeek === 1 ? DAY_HEADERS_MON : DAY_HEADERS_SUN;
const weekdayStyle = this.#weekdayStyle;
for (let i = 0; i < 7; i++) {
viewport.write(dayHeaders[i], new Point(1 + i * 3, 1), weekdayStyle);
}
// Day grid
const grid = this.#getDayGrid();
for (let week = 0; week < 6; week++) {
for (let day = 0; day < 7; day++) {
const date = grid[week][day];
const isHover = this.#hoverDate && this.#isSameDay(date, this.#hoverDate);
const isCurrentMonth = date.getMonth() === month;
const isToday = this.#isSameDay(date, today);
const isSelected = this.#isSameDay(date, this.#date) && this.#selection === 'single';
const isCursor = !this.#isSameDay(this.#cursorDate, this.#date) &&
this.#isSameDay(date, this.#cursorDate);
const isInRange = this.#isInRange(date);
const dayNum = String(date.getDate()).padStart(2, ' ');
const isRangeEndpoint = this.#rangeStart &&
this.#rangeEnd &&
(this.#isSameDay(date, this.#rangeStart) ||
this.#isSameDay(date, this.#rangeEnd));
let style;
if (isRangeEndpoint) {
style = rangeEndpointStyle;
}
else if (isSelected) {
style = selectedStyle;
}
else if (isCursor) {
style = this.#cursorStyle;
}
else if (isInRange) {
style = inRangeStyle;
}
else if (isToday) {
style = todayStyle;
}
else if (!isCurrentMonth) {
style = dimStyle;
}
else if (isHover) {
style = hoverStyle;
}
else {
style = textStyle;
}
viewport.write(dayNum, new Point(1 + day * 3, 2 + week), style);
// Paint the gap between consecutive in-range days
if (day < 6) {
const nextDate = grid[week][day + 1];
const thisInRange = isSelected || isInRange;
const nextInRange = this.#isSameDay(nextDate, this.#date) || this.#isInRange(nextDate);
if (thisInRange && nextInRange) {
const gapStyle = this.#rangeGapStyle;
viewport.write(' ', new Point(1 + day * 3 + 2, 2 + week), gapStyle);
}
}
}
if (this.#selection === 'range' &&
this.#rangeStart &&
this.#rangeEnd &&
!this.#isSameDay(this.#rangeStart, this.#rangeEnd)) {
// Paint the leading gap before the first in-range day of each row
// (the space at position 0 on the row, before the first day column)
const firstDate = grid[week][0];
const firstInRange = this.#isSameDay(firstDate, this.#date) || this.#isInRange(firstDate);
if (firstInRange) {
const gapStyle = this.#rangeGapStyle;
viewport.write(' ', new Point(0, 2 + week), gapStyle);
}
// Paint the trailing gap after the last in-range day of each row
const lastDate = grid[week][6];
const lastInRange = this.#isSameDay(lastDate, this.#date) || this.#isInRange(lastDate);
if (lastInRange) {
const gapStyle = this.#rangeGapStyle;
viewport.write(' ', new Point(1 + 6 * 3 + 2, 2 + week), gapStyle);
}
}
}
// Focus border
if (this.#hasFocus) {
const borderStyle = weekdayStyle;
// Top row (weekday header row): ╭...╮ with ─ between headers
viewport.write('╭', new Point(0, 1), borderStyle);
viewport.write('╮', new Point(CALENDAR_WIDTH - 1, 1), borderStyle);
for (let i = 0; i < 6; i++) {
viewport.write('─', new Point(3 + i * 3, 1), borderStyle);
}
// Middle rows: │...│
for (let week = 0; week < 5; week++) {
viewport.write('│', new Point(0, 2 + week), borderStyle);
viewport.write('│', new Point(CALENDAR_WIDTH - 1, 2 + week), borderStyle);
}
// Bottom row (last week row): ╰...╯ with ─ between days
viewport.write('╰', new Point(0, 7), borderStyle);
viewport.write('╯', new Point(CALENDAR_WIDTH - 1, 7), borderStyle);
for (let i = 0; i < 6; i++) {
viewport.write('─', new Point(3 + i * 3, 7), borderStyle);
}
}
}
#renderMonths(viewport) {
const textStyle = this.purpose.text();
const headerStyle = this.purpose.ui({ isHover: false });
const currentMonth = this.#visibleDate.getMonth();
viewport.paint(textStyle, this.#widgetRect(viewport));
// Header
const monthName = this.#formatMonthTitle(this.#visibleDate);
const headerText = monthName;
const headerX = Math.max(0, Math.floor((CALENDAR_WIDTH - CLOSE_BUTTON_WIDTH - lineWidth(headerText)) / 2) + 1);
viewport.write(headerText, new Point(headerX, 0), headerStyle);
const closeStyle = this.#hoverClose ? this.#buttonHoverStyle : headerStyle;
viewport.write(` ${CLOSE} `, new Point(CALENDAR_WIDTH - CLOSE_BUTTON_WIDTH, 0), closeStyle);
// Month grid: 4 rows × 3 columns
for (let row = 0; row < 4; row++) {
for (let col = 0; col < 3; col++) {
const m = row * 3 + col;
const name = MONTH_SHORT[m];
const isSelected = m === currentMonth;
const isHover = this.#hoverMonthLabel === name;
let style;
if (isSelected) {
style = this.#selectedStyle;
}
else if (isHover) {
style = this.#hoverStyle;
}
else {
style = textStyle;
}
// Each column is ~7 chars wide (fits in 22 width: 7*3=21 + 1 padding)
const x = 1 + col * 7;
const y = 2 + row;
const label = ` ${name} `;
viewport.write(label, new Point(x, y), style);
}
}
}
#renderYears(viewport) {
const textStyle = this.purpose.text();
const headerStyle = this.purpose.ui({ isHover: false });
const currentYear = this.#visibleDate.getFullYear();
viewport.paint(textStyle, this.#widgetRect(viewport));
// Header
const headerText = 'Year';
const headerX = Math.max(0, Math.floor((CALENDAR_WIDTH - CLOSE_BUTTON_WIDTH - lineWidth(headerText)) / 2) + 1);
viewport.write(headerText, new Point(headerX, 0), headerStyle);
const closeStyle = this.#hoverClose ? this.#buttonHoverStyle : headerStyle;
viewport.write(` ${CLOSE} `, new Point(CALENDAR_WIDTH - CLOSE_BUTTON_WIDTH, 0), closeStyle);
// Scroll indicator
const prevStyle = this.#hoverPrevButton
? this.#buttonHoverStyle
: headerStyle;
viewport.write(ARROW_UP, new Point(Math.floor((CALENDAR_WIDTH - lineWidth(ARROW_UP)) / 2), 1), prevStyle);
// 5 visible years
const baseYear = currentYear - 2 + this.#yearScrollOffset;
for (let i = 0; i < 5; i++) {
const year = baseYear + i;
const yearStr = String(year);
const isSelected = year === currentYear;
const isHover = this.#hoverYearLabel === yearStr;
let style;
if (isSelected) {
style = this.#selectedStyle;
}
else if (isHover) {
style = this.#hoverStyle;
}
else {
style = textStyle;
}
const x = Math.floor((CALENDAR_WIDTH - lineWidth(yearStr)) / 2);
viewport.write(yearStr, new Point(x, 2 + i), style);
}
// Down arrow
const nextStyle = this.#hoverNextButton
? this.#buttonHoverStyle
: headerStyle;
viewport.write(ARROW_DOWN, new Point(Math.floor((CALENDAR_WIDTH - lineWidth(ARROW_DOWN)) / 2), 7), nextStyle);
// Search line (if searching)
if (this.#yearSearch) {
const searchLabel = `Search: ${this.#yearSearch}`;
viewport.write(searchLabel, new Point(0, 7), textStyle.merge({ dim: true }));
}
}
}
//# sourceMappingURL=Calendar.js.map