Help Center

PsCalendar

An owner-drawn month calendar that lives inside your window — a normal child control, not a dropdown. It shows one month, or a grid of up to twelve, with a shared navigation band across the top, a selectable day, an optional min/max range, and a MonthCal-style drill-down from days to months to years.

It is a date surface, not a date field. There is no text box, no popup, and no parsing: it selects a single SYSTEMTIME and tells you when the user moves or chooses one. If you want an editable date field with a dropdown calendar, that is a different control.

Everything it draws is replaceable. You can restyle it field by field through a colour struct, take over individual day cells with a callback (availability shading, appointment dots), or replace the entire renderer.

Repository: <https://github.com/PaulSquires/PsCalendar>


What it looks like

PsCalendar

A single month with a border, a two-month sliding range picker with a min/max range and tooltips, and a six-month grid using a host day painter and localized month names.


Requirements

Copy these files into your project:

FileWhat it is
PsCalendar.biPublic header — types, enums, callback typedefs, declarations
PsCalendar.incImplementation
PsBufferPaint.biFlicker-free drawing surface (header)
PsBufferPaint.incDrawing surface (implementation)
PsTipHost.bi / PsTipHost.incThe tooltip backend switch — see Tooltips: two backends
PsTooltip.bi / PsTooltip.incThe owner-drawn tooltip. Required even if you never switch to it: PsTipHost.inc includes it.

It also needs the AfxNova framework on the include path (fbc -i "C:\dev" if AfxNova lives at C:\dev\AfxNova).

Include order

PsBufferPaint.inc must be included before PsCalendar.inc:

#include once "windows.bi"
#include once "AfxNova\CWindow.inc"
#include once "AfxNova\AfxStr.inc"
#include once "AfxNova\AfxGdiplus.inc"
using AfxNova

#include once "PsBufferPaint.inc"
#include once "PsCalendar.inc"

That is the whole list of include lines. PsCalendar.inc includes PsTipHost.inc itself, which in turn includes PsTooltip.inc, so the four tooltip files only have to be present — you never name them. PsCalendar wraps no child control and owns no popup window of its own.

Initialise GDI+

The control renders through GDI+. Bracket your message loop with AfxGdipInit and AfxGdipShutdown, or it draws nothing at all:

dim as ULONG_PTR gdipToken = AfxGdipInit()
' ... create windows, run the message loop ...
AfxGdipShutdown( gdipToken )

AfxGdipShutdown must come after every window is destroyed.

Do not name anything ok

GDI+ defines Ok = 0 as a Status enum value in namespace AfxNova, and your host almost certainly says using AfxNova. Any identifier of your own called ok becomes a duplicate definition the moment you adopt this control. Use bOK.

Message pump

There is no PsCalendar_FilterMessage, and the control needs nothing added to your message loop for its own sake. If you are coming from a sibling control that requires a pump filter, there is none to call here.

Because the control takes keyboard focus, you do need the two things any focusable control needs:

dim uMsg as MSG
do while GetMessage(@uMsg, null, 0, 0)
    if uMsg.message = WM_QUIT then exit do
    if IsDialogMessage( hMainWindow, @uMsg ) then continue do
    TranslateMessage @uMsg
    DispatchMessage @uMsg
loop

IsDialogMessage is what makes Tab move between controls. And because it ignores Tab until some child already has focus, SetFocus one of your controls at startup or the first Tab press does nothing.


Quick start

ghCal = PsCalendar_Create( hMainWindow, IDC_CALENDAR )
PsCalendar_SetFont( ghCal, hFont )
PsCalendar_SetSelChangeCallback( ghCal, @OnSelChange )
PsCalendar_SetDateActivatedCallback( ghCal, @OnActivated )

dim as SYSTEMTIME stNow
GetLocalTime( @stNow )
PsCalendar_SetSelDate( ghCal, PsCalendar_MakeDate( stNow.wYear, stNow.wMonth, stNow.wDay ) )

' Size it to whatever it asks for, then show it.
dim as long w, h
PsCalendar_GetIdealSize( ghCal, w, h )
SetWindowPos( ghCal, NULL, 20, 20, w, h, SWP_NOZORDER or SWP_NOACTIVATE )
ShowWindow( ghCal, SW_SHOW )

And the two callbacks it wires:

sub OnSelChange( byval hCtrl as HWND, byval pDate as SYSTEMTIME ptr, byval bHasDate as boolean )
    if bHasDate = false then exit sub          ' the selection was cleared
    ' pDate is the newly selected day
end sub

sub OnActivated( byval hCtrl as HWND, byval pDate as SYSTEMTIME ptr )
    ' the user chose this date: double-click, Enter or Space
end sub

Concepts

The handle is a real HWND

PsCalendar_Create returns an ordinary child window handle. You position it with SetWindowPos, show it with ShowWindow, and it takes part in Tab navigation like any other control. There is no opaque handle type.

Selection, anchor, and today are three different things

  • Selection — the highlighted day. There may be none; that is a legal state (PsCalendar_HasSelDate returns FALSE).
  • Anchor — the first displayed month. Its day component is always 1. Navigation moves the anchor; it does not move the selection.
  • Today — read from the system clock at creation, and re-read by PsCalendar_Refresh so a calendar left on screen across midnight can correct itself.

The panel grid

In CAL_VIEW_DAYS the control draws nCols × nRows month panels, row-major — panel p sits at column p mod nCols, row p \ nCols — showing consecutive months starting at the anchor. The product may not exceed 12.

client
├─ optional border (default off)
├─ padding on all four sides
├─ nav band:  [ prev ]      title      [ next ]
├─ nav gap
└─ panel grid, row-major, with horizontal and vertical gutters
      each panel: month caption / weekday row / 6 x 7 day cells

Two rules fall out of that and are worth knowing before the geometry surprises you:

  • The per-panel month caption is suppressed when there is only one panel. At one panel the nav band's title already reads "January 2026", so the caption would just repeat it. Above one panel the title becomes a range ("Jan – Dec 2026") and each panel names itself. This means a one-panel calendar is exactly one caption shorter than a two-panel one.
  • In CAL_VIEW_MONTHS and CAL_VIEW_YEARS the panel grid does not exist. The 3 × 4 drill-down grid takes the whole area below the nav band, at every panel count.

Cells stretch; ideal size is advice

The panel width is floor-divided by 7 and the body height by 6, so cells grow and shrink with the control. Left-over pixels become a right and bottom margin.

PsCalendar_GetIdealSize computes the natural size from the minimum cell size and the layout metrics. It is pure arithmetic — it takes no device context and measures no text — so it answers correctly before the control has ever been sized, which is exactly when you need it. Sizing below the ideal shrinks the cells; nothing clips and no scrollbar appears.

panelIdealW = 7 * cellMinW
panelIdealH = captionH + weekdayH + 6 * cellMinH
idealW = 2*(pad + border) + nCols*panelIdealW + (nCols-1)*gutterX
idealH = 2*(pad + border) + navBandH + navGap + nRows*panelIdealH + (nRows-1)*gutterY

Rects are derived, never set

Every rectangle the control exposes — nav band, chevrons, title, panels, weekday cells, day cells, drill-down cells — is computed from the client rect and the layout metrics. You change the metrics; the control recomputes the rects. Layout is lazy: a setter marks it dirty and the next paint does one pass, so there is no begin/end update pair to bracket a batch of changes.

Programmatic setters are silent

PsCalendar_SetSelDate, ClearSelDate, SetAnchorDate, SetView, SetRange, SetPanelGrid and every other setter change state without firing a callback. Only user interaction — a click, a double-click, or a key — notifies. This lets you call a setter from inside your own handler without re-entering it.

Two entry points are actions, not setters, and they do fire: PsCalendar_SelectDate and PsCalendar_Activate. Their names say so.

Focus and the keyboard

The control is a tab stop and paints a focus ring on the current cell. In CAL_VIEW_DAYS the arrow keys move the selection directly, scrolling the page when the selection leaves it — there is no separate focus cursor to keep track of.

KeyEffect
← →selection ∓ 1 day
↑ ↓selection ∓ 1 week
PgUp / PgDn∓ 1 month
Ctrl+PgUp / Ctrl+PgDn∓ 1 year
Home / Endfirst / last day of the selection's month
Ctrl+Hometoday
Space / Enterfire DateActivated
Esczoom out one level — only while drilled down

Every move clamps to the min/max range. In the drill-down views the arrows move a cell cursor instead, Space and Enter zoom in, and Esc zooms back out.

Esc is deliberately left alone in CAL_VIEW_DAYS so a host dialog's Cancel keeps working.

When nothing is selected, the focus ring falls back to today if it is on the page, and otherwise to the first day of the first panel. PsCalendar_GetFocusCell reports where it is.


Behaviour and limits

  • Single selection only. One date or none. There is no multi-select and no contiguous range selection; stMin/stMax restrict what is selectable, they are not a selected range.
  • At most 12 panels. PsCalendar_SetPanelGrid refuses — and leaves the grid untouched — when nCols * nRows exceeds 12 or either dimension is below 1. It does not silently clamp.
  • Six week rows, always. Each month is a fixed 6 × 7 grid, so the control's natural height never changes as you navigate. Months needing only four or five weeks show more adjacent days.
  • Out-of-range days are inert, not just dim. They do not highlight on hover and clicking them does nothing.
  • PsCalendar_SetSelDate refuses a date outside the range, and narrowing the range drops a selection that no longer fits. The setter cannot reach anywhere the user could not.
  • Drilling in never selects. Picking a month or a year navigates; it does not change the selection or fire a callback.
  • No week-number column, no "Today" footer button, and no variable row count.
  • Adjacent and out-of-range days must stay far apart in colour. An adjacent day (a leading or trailing day from a neighbouring month) is dimmed but still clickable; an out-of-range day is inert. If you restyle DayColorAdjacent and DayColorDisabled to similar values, a restricted range reads as a broken calendar.
  • The colour defaults are dark. For a light theme, read the struct, assign every field, and write it back.

API reference

Creation

FunctionBehaviour
PsCalendar_Create( hWndParent as HWND, CtrlID as long ) as HWNDCreates the control as a child of hWndParent at zero size. Position it yourself. Returns the HWND, or 0 on failure.
GetCalendarPointer( hCtrl as HWND ) as PSCALENDAR ptrThe per-instance state block. Reading laid-out geometry through the accessors below is safer — they force a pending layout first; the raw struct does not.

Selection

All silent unless marked otherwise.

FunctionBehaviour
PsCalendar_GetSelDate( hCtrl, byref st as SYSTEMTIME ) as booleanFALSE when nothing is selected; st untouched.
PsCalendar_SetSelDate( hCtrl, byref st as SYSTEMTIME )Selects st and brings its month into view. Refuses a date outside the range.
PsCalendar_ClearSelDate( hCtrl )Returns to the no-selection state.
PsCalendar_HasSelDate( hCtrl ) as booleanWhether anything is selected.
PsCalendar_SelectDate( hCtrl, byref st as SYSTEMTIME )Action — fires SelChange. Selects as if the user had clicked. Ignored when out of range.
PsCalendar_Activate( hCtrl )Action — fires DateActivated for the current selection. Does nothing when there is no selection. The door for a host accelerator.
PsCalendar_GetRange( hCtrl, byref stMin, byref stMax, byref bHasMin as boolean, byref bHasMax as boolean )Current min/max and whether each is set.
PsCalendar_SetRange( hCtrl, byref stMin, byref stMax, bHasMin as boolean, bHasMax as boolean )Sets the selectable range. An inverted pair is swapped, not rejected. A selection now outside the range is dropped.
PsCalendar_GetToday( hCtrl, byref st as SYSTEMTIME ) as booleanToday's date as the control sees it.

Displayed page

FunctionBehaviour
PsCalendar_GetAnchorDate( hCtrl, byref st as SYSTEMTIME ) as booleanThe first displayed month; day component is always 1.
PsCalendar_SetAnchorDate( hCtrl, byref st as SYSTEMTIME )Scrolls to that month. The day component is discarded.
PsCalendar_GetView( hCtrl ) as longCAL_VIEW_DAYS, CAL_VIEW_MONTHS or CAL_VIEW_YEARS.
PsCalendar_SetView( hCtrl, eView as long )Switches view and seeds the drill-down cursor. Ignores an out-of-range value.
PsCalendar_GetTitleText( hCtrl ) as DWSTRINGThe nav band's caption for the current view and page — "January 2026", "Jan - Dec 2026", "2026", "2020 - 2029". Honours your localized names.

Behaviour

FunctionBehaviour
PsCalendar_GetWeekStart( hCtrl ) as longCAL_SUNDAY or CAL_MONDAY.
PsCalendar_SetWeekStart( hCtrl, nWeekStart as long )Anything other than CAL_MONDAY is treated as CAL_SUNDAY. Rotates the whole grid.
PsCalendar_GetPanelGrid( hCtrl, byref nCols as long, byref nRows as long )Current grid shape.
PsCalendar_SetPanelGrid( hCtrl, nCols as long, nRows as long )Refuses and changes nothing when either dimension is below 1 or the product exceeds 12.
PsCalendar_GetPanelCount( hCtrl ) as longnCols * nRows, the number of months displayed.
PsCalendar_GetNavStep( hCtrl ) as longThe configured step. 0 means "one page".
PsCalendar_SetNavStep( hCtrl, nStep as long )0 (the default) = a chevron advances by the whole page, so a one-panel calendar steps a month and a twelve-panel grid steps a year. A positive value steps that many months regardless of panel count — SetNavStep(1) on a two-panel calendar gives the sliding range-picker idiom. Negatives are clamped to 0.
PsCalendar_GetShowTodayRing( hCtrl ) as booleanWhether today's cell gets an outline.
PsCalendar_SetShowTodayRing( hCtrl, bShow as boolean )Default TRUE.
PsCalendar_IsPrevDisabled( hCtrl ) as booleanTRUE when the previous chevron would reveal nothing selectable.
PsCalendar_IsNextDisabled( hCtrl ) as booleanLikewise for next. Both account for the panel count: with two panels stepping one month, "next" only dies when the genuinely new month falls outside the range, one month later than a single-panel calendar would.

Localization

The control ships English names. Any entry you set overrides that one; setting an entry to "" reverts it. Weekday entries are indexed by day of week (0 = Sunday), never by column, so an override follows the day through a week-start change.

FunctionBehaviour
PsCalendar_GetMonthName( hCtrl, m as long ) as DWSTRINGFull month name, m = 1..12. Empty string for a bad index.
PsCalendar_SetMonthName( hCtrl, m as long, sName as DWSTRING )Used for panel captions and the single-panel title.
PsCalendar_GetMonthAbbrev( hCtrl, m as long ) as DWSTRINGShort month name, m = 1..12.
PsCalendar_SetMonthAbbrev( hCtrl, m as long, sName as DWSTRING )Used for multi-panel range titles and the CAL_VIEW_MONTHS cells.
PsCalendar_GetWeekdayAbbrev( hCtrl, i as long ) as DWSTRINGi = 0 (Sunday) .. 6 (Saturday).
PsCalendar_SetWeekdayAbbrev( hCtrl, i as long, sName as DWSTRING )The weekday header row.
PsCalendar_SetMonthNames( hCtrl, sNames() as DWSTRING )Bulk form. Reads from lbound upward, mapping the first element to January; a short array leaves the tail alone.
PsCalendar_SetMonthAbbrevs( hCtrl, sNames() as DWSTRING )Bulk form, first element = January.
PsCalendar_SetWeekdayAbbrevs( hCtrl, sNames() as DWSTRING )Bulk form, first element = Sunday.

Layout and appearance

Sizes are DPI-scaled once at creation. Every setter afterwards takes raw pixels — scale them yourself if you need to. The two thicknesses are the exception: they are never scaled here, because the drawing primitives scale the pen they are handed.

FunctionBehaviour
PsCalendar_GetFont( hCtrl ) as HFONTThe main font.
PsCalendar_SetFont( hCtrl, hFont as HFONT )Sets all three fonts at once and clears any title and weekday overrides. Call this first, then the overrides. The font is caller-owned; the control never deletes it.
PsCalendar_GetTitleFont( hCtrl ) as HFONTThe title/caption font, resolved — returns the main font when no override is set.
PsCalendar_SetTitleFont( hCtrl, hFont as HFONT )Nav band title and panel captions. 0 reverts to the main font.
PsCalendar_GetWeekdayFont( hCtrl ) as HFONTThe weekday-row font, resolved.
PsCalendar_SetWeekdayFont( hCtrl, hFont as HFONT )The S M T W T F S row. 0 reverts to the main font.
PsCalendar_GetBorderThickness( hCtrl ) as longFrame thickness in pixels.
PsCalendar_SetBorderThickness( hCtrl, nThickness as long )Default 0 — no frame. Non-zero strokes a rectangle just inside the client and shrinks the drawable area by that much on every side. Negatives are clamped to 0.
PsCalendar_GetChevronThickness( hCtrl ) as longNav glyph stroke weight.
PsCalendar_SetChevronThickness( hCtrl, nThickness as long )Default 2, clamped to a minimum of 1. Appearance only — the chevron cell does not move.
PsCalendar_GetPadding( hCtrl ) as longInset on all four sides, inside any border.
PsCalendar_SetPadding( hCtrl, nPad as long )Negatives clamped to 0. Changes the ideal size.
PsCalendar_GetGutters( hCtrl, byref nGutterX as long, byref nGutterY as long )Spacing between panels.
PsCalendar_SetGutters( hCtrl, nGutterX as long, nGutterY as long )Negatives clamped to 0. Only visible above one panel.
PsCalendar_GetCellMinSize( hCtrl, byref nW as long, byref nH as long )The day-cell size used for the ideal-size calculation.
PsCalendar_SetCellMinSize( hCtrl, nW as long, nH as long )Feeds GetIdealSize only. Cells stretch to the control's actual size, so raising this does not enlarge a control that is already sized — it changes what "ideal" means. Clamped to a minimum of 1.
PsCalendar_GetIdealSize( hCtrl, byref nWidth as long, byref nHeight as long )The natural size. Pure arithmetic, no device context, valid before the control has ever been sized.
PsCalendar_GetEnabled( hCtrl ) as booleanEnabled state.
PsCalendar_SetEnabled( hCtrl, isEnabled as boolean )Goes through EnableWindow, so the disable is enforced by the system rather than being cosmetic.
PsCalendar_HasFocus( hCtrl ) as booleanWhether the control currently has keyboard focus.
PsCalendar_Refresh( hCtrl )Re-reads today's date and repaints.

Geometry

Each of these forces any pending layout before answering, and returns FALSE for a bad handle or an out-of-range index (leaving the output untouched).

FunctionBehaviour
PsCalendar_HitTest( hCtrl, pt as POINT ) as CAL_HITRESULTResolves a client-coordinate point to a part. Identity only — it reports a day cell even when that day is out of range.
PsCalendar_GetInnerRect( hCtrl, byref rc as RECT ) as booleanClient less border and padding.
PsCalendar_GetNavBandRect( hCtrl, byref rc as RECT ) as booleanThe whole prev/title/next band.
PsCalendar_GetPrevRect( hCtrl, byref rc as RECT ) as booleanThe previous chevron's cell.
PsCalendar_GetNextRect( hCtrl, byref rc as RECT ) as booleanThe next chevron's cell.
PsCalendar_GetTitleRect( hCtrl, byref rc as RECT ) as booleanThe clickable title, between the chevrons.
PsCalendar_GetGridRect( hCtrl, byref rc as RECT ) as booleanEverything below the nav band.
PsCalendar_GetPanelRect( hCtrl, panel as long, byref rc as RECT ) as booleanOne month panel, panel = 0..GetPanelCount()-1.
PsCalendar_GetCaptionRect( hCtrl, panel as long, byref rc as RECT ) as booleanThat panel's month caption. Zero height when there is only one panel.
PsCalendar_GetWeekdayRect( hCtrl, panel as long, i as long, byref rc as RECT ) as booleanWeekday header cell, i = 0..6 by column.
PsCalendar_GetDayCellRect( hCtrl, panel as long, k as long, byref rc as RECT ) as booleanDay cell, k = 0..41, row-major within the 6 × 7 grid.
PsCalendar_GetMyCellRect( hCtrl, k as long, byref rc as RECT ) as booleanDrill-down cell, k = 0..11. Zeroed while in CAL_VIEW_DAYS.
PsCalendar_GetCellDate( hCtrl, panel as long, k as long, byref st as SYSTEMTIME ) as booleanThe date a day cell represents, including leading and trailing days from neighbouring months.
PsCalendar_GetPanelMonth( hCtrl, panel as long, byref nYear as long, byref nMonth as long ) as booleanWhich month a panel is showing.
PsCalendar_GetFocusCell( hCtrl, byref panel as long, byref index as long ) as booleanWhere the focus ring sits. FALSE in the drill-down views. Falls back through selection → today → first day of panel 0.

Callback registration

FunctionBehaviour
PsCalendar_SetSelChangeCallback( hCtrl, usersub as CAL_SelChangeCallbackSub )User-driven selection changes.
PsCalendar_SetDateActivatedCallback( hCtrl, usersub as CAL_DateActivatedCallbackSub )Double-click, Enter, Space.
PsCalendar_SetPaintCallback( hCtrl, usersub as CAL_PaintCallbackSub )Replaces the whole renderer.
PsCalendar_SetDayPaintCallback( hCtrl, usersub as CAL_DayPaintCallbackSub )Replaces one day cell's painting.
PsCalendar_SetMonthYearPaintCallback( hCtrl, usersub as CAL_MonthYearPaintCallbackSub )Replaces one drill-down cell's painting.
PsCalendar_SetMessageCallback( hCtrl, userfunc as CAL_MessageCallbackFunc )Observe or suppress messages.
PsCalendar_SetTooltipCallback( hCtrl, userfunc as CAL_TooltipCallbackFunc )Supply tooltip text for day cells. Setting it is what lets a tip exist at all.

Pass 0 to any of these to remove the callback.

Tooltips

FunctionBehaviour
PsCalendar_SetTooltipMode( hCalendar, nMode as long ) as booleanPSTIP_MODE_SYSTEM (the default) or PSTIP_MODE_PS. Returns TRUE when the requested mode is live. Safe to call before any tip exists — it simply decides which kind gets built.
PsCalendar_GetTooltipMode( hCalendar ) as longWhich backend this calendar is on.
PsCalendar_GetTooltipHandle( hCalendar ) as HWNDThe comctl32 tooltip window, for any TTM_* message you want to send it yourself. 0 while on the PsTooltip backend, and 0 until a tip has actually been built — see below.
PsCalendar_GetPsTooltipHandle( hCalendar ) as HWNDThe PsTooltip window, or 0 on the system backend. The door to PsTooltip_SetColors / SetFonts / SetStyle / SetMaxWidth / SetTitle / SetGlyph — none of which is mirrored here. Same lazy caveat.
Tooltips: two backends

The calendar ships on the system (comctl32) tooltip it has always used. PSTIP_MODE_PS switches this instance to PsTooltip: owner-drawn, themeable, word-wrapping. The mode changes how a tip is drawn, never what it says — both backends are fed by the same TooltipCallback, asked once per day cell.

Either way the calendar adds no pump obligation: PsTooltip has no FilterMessage, and this control has none of its own.

The default is deliberate. PsTooltip's colour defaults are dark, so a calendar that silently switched would put a dark tip on a light form. Theme the tips first, then opt in.

This calendar's tip is a tracked one. The control resolves the text for the cell under the cursor, positions the tip and shows it itself — there is no dwell. That is why there are no delay setters here: SetHoverTime, SetAutoPopTime and SetReshowTime do not exist on this control, and the hover delays would not apply on either backend if they did.

The tip is also lazy. It is built on the first day cell that actually wants one, and never at all for a calendar whose host set no TooltipCallback. So both handle getters return 0 on a fresh control — that is correct, not a failure.

Which means the handle is the wrong door for theming: it does not exist yet at the point you would want to use it. Theme every tip in the process at once instead, at startup, before any control is created:

dim as PSTOOLTIP_COLORS tipColors
tipColors.BackColor = BGR(250,250,250)
tipColors.ForeColor = BGR( 20, 20, 20)
PsTooltip_SetDefaultColors( @tipColors )       ' once, at startup, for every tip in the process
PsTooltip_SetDefaultFonts( ghFontUI )
PsTooltip_SetDefaultMaxWidth( 320 )
PsTooltip_SetDefaultStyle( TIP_STYLE_RECT )

' ... then, per calendar:
PsCalendar_SetTooltipCallback( ghCal, @OnTooltip )
PsCalendar_SetTooltipMode( ghCal, PSTIP_MODE_PS )

Colours

FunctionBehaviour
PsCalendar_GetColors( hCtrl, pColors as PSCALENDAR_COLORS ptr )Copies the current palette out.
PsCalendar_SetColors( hCtrl, pColors as PSCALENDAR_COLORS ptr )Copies a palette in and repaints. Read-modify-write: get, assign the fields you care about, set.

Date helpers

Pure functions of their inputs — no control handle, usable anywhere. Serials are days since 1970‑01‑01, proleptic Gregorian, and may be negative.

FunctionBehaviour
PsCalendar_IsLeapYear( y as long ) as booleanGregorian leap-year rule.
PsCalendar_DaysInMonth( y as long, m as long ) as long28–31; returns 30 for an out-of-range month.
PsCalendar_DateToSerial( y as long, m as long, d as long ) as longintDay serial.
PsCalendar_SerialToDate( serial as longint, byref y as long, byref m as long, byref d as long )The inverse.
PsCalendar_DowSunday( y as long, m as long, d as long ) as longDay of week, 0 = Sunday .. 6 = Saturday.
PsCalendar_LeadingCells( y as long, m as long, nWeekStart as long ) as longGrid column of the 1st of that month.
PsCalendar_MakeDate( y as long, m as long, d as long ) as SYSTEMTIMEBuilds a SYSTEMTIME with wDayOfWeek filled in.
PsCalendar_AddMonths( byref st as SYSTEMTIME, delta as long ) as SYSTEMTIMEClamps the day to the target month's length: 31 Jan + 1 month = 28 Feb.
PsCalendar_AddDays( byref st as SYSTEMTIME, delta as long ) as SYSTEMTIMEStraight serial arithmetic, so every rollover is handled.
PsCalendar_DecadeBase( y as long ) as longThe decade's first year: 2026 → 2020.

Render probes

For asserting that a paint callback of your own is drawing rather than flooding. Each renders the control offscreen through the same path WM_PAINT uses. nPart is a CAL_PART_* value, or CAL_PART_MYCELL_BASE + k for drill-down cell k, or CAL_PART_DAYCELL_BASE + panel*100 + k for day cell k of a panel.

FunctionBehaviour
PsCalendar_TestPartTones( hCtrl, nPart as long ) as longDistinct colours in a part, capped at 256. A flooded part reports 1 or 2.
PsCalendar_TestPartBrightness( hCtrl, nPart as long ) as longBrightest pixel, 0..255. Compare two parts as a ratio, not a difference.
PsCalendar_TestHashPart( hCtrl, nPart as long ) as ulongintFNV-1a over a part's pixels, for checking that a state change reached the pixels. A changed hash only proves something moved — a destroyed render changes it too, so pair it with a tone count.

Colors

PSCALENDAR_COLORS is a flat struct of COLORREF fields with dark defaults. Read it, assign, and write it back.

dim as PSCALENDAR_COLORS colors
PsCalendar_GetColors( ghCal, @colors )
colors.DayBackColorSelected = BGR(  0,120,215)
colors.DayForeColorSelected = BGR(255,255,255)
PsCalendar_SetColors( ghCal, @colors )

Chrome

FieldPaintsWhen
BackColorThe whole client, before anything elseAlways
BorderColorThe frameOnly when SetBorderThickness is non-zero
FocusRingColorA ring around the current cellOnly while the control has keyboard focus
TitleColorThe nav band titleIdle
TitleColorHotThe nav band titleCursor over the title
CaptionColorA panel's own month captionTwo or more panels
NavGlyphColorBoth chevronsIdle
NavGlyphColorHotOne chevronCursor over it, and it is not disabled
NavGlyphColorDisabledOne chevronThat direction would reveal nothing selectable
WeekdayColorThe S M T W T F S rowAlways

Day cells

FieldPaintsWhen
DayColorThe day numberOrdinary weekday of the panel's own month
DayColorWeekendThe day numberSaturday or Sunday of the panel's own month
DayColorAdjacentThe day numberA leading or trailing day from a neighbouring month — dimmed but still clickable
DayColorDisabledThe day numberOutside the min/max range — inert
DayBackColorHotA rounded chip behind the numberCursor over an in-range cell
DayForeColorHotThe day numberSame
DayBackColorSelectedA rounded chip behind the numberThe selected day
DayForeColorSelectedThe day numberSame
TodayRingColorAn outline on today's cellToday, not selected, and SetShowTodayRing is on

Day-number colour precedence, highest first:

disabled  >  selected  >  hot  >  adjacent  >  weekend  >  ordinary

The background chip has its own, shorter chain: selected > hot > none. A disabled cell never gets a chip, so the range reads as unavailable rather than merely unselected.

DayColorAdjacent and DayColorDisabled carry a deliberate luminance gap. Adjacent days are clickable and out-of-range days are not; if you restyle these two to similar values, a restricted range looks broken.

The drill-down cells reuse the day colours: DayColor for a normal cell, DayColorAdjacent for the two decade-overhang years, and the selected and hot pairs as above.


Callbacks

Selection changed

type CAL_SelChangeCallbackSub as sub( byval hCtrl as HWND, byval pDate as SYSTEMTIME ptr, byval bHasDate as boolean )

Fires when the user moves the selection — a click on a day, or any arrow / PgUp / PgDn / Home / End keystroke that lands somewhere new. Silent for every programmatic setter, and silent when a key is pressed but the selection clamps to where it already was. bHasDate is FALSE when the selection was cleared.

Date activated

type CAL_DateActivatedCallbackSub as sub( byval hCtrl as HWND, byval pDate as SYSTEMTIME ptr )

The user chose a date: double-click, Enter, or Space. This is the "act on it" signal — close the dialog, open the record. On a double-click it fires after SelChange, so the selection you read inside it is already the new one. Pressing Enter with nothing selected commits the focus cell first, then activates.

Day painting

type CAL_DayPaintCallbackSub as sub( byval p as PSCALENDAR_DAYPAINTINFO ptr )

Draws one day cell instead of the built-in painter. The control has already filled the whole client with BackColor and set the day font, so you start from a known state.

Draw your additions, not a background. A callback that fills a rectangle covering more than its own cell erases what the control already drew.

The focus ring is drawn by the control after this callback returns, so ignoring isFocused cannot make keyboard focus invisible. The flag is there so you can style around the ring.

isSelected and isFocused are each true on exactly one cell per render, and always the same cell. This matters when more than one month is displayed: a date on a month boundary appears twice — as a real day in its own panel, and as a dimmed leading or trailing day in the neighbouring one — so testing stDate against the selected date yourself would highlight both copies and leave the user unable to tell which cell the keyboard is on. The control resolves it to the panel that owns the date, falling back to a neighbour that merely displays it, so the selection is never shown twice and never disappears. Use the flag rather than comparing dates.

FieldMeaning
hCtrlThis calendar's HWND
bThe buffer to draw into
rcCellThe cell's rectangle
stDateThe date this cell represents
dayNumber1..31, the number to draw
panelIndexWhich month panel, 0-based, row-major
cellIndex0..41 within that panel
isAdjacentA leading or trailing day from a neighbouring month
isWeekendSaturday or Sunday, from the cell's own date
isToday
isSelectedTrue on exactly one cell, never two — see below
isHotThe cursor is over this cell
isFocusedThis is the focus cell and the control has focus
isDisabledOutside the min/max range, so unclickable
isEnabledThe control as a whole

Drill-down cell painting

type CAL_MonthYearPaintCallbackSub as sub( byval p as PSCALENDAR_MYPAINTINFO ptr )

Draws one month or year cell in the drill-down views.

FieldMeaning
hCtrlThis calendar's HWND
bThe buffer to draw into
rcCellThe cell's rectangle
eViewCAL_VIEW_MONTHS or CAL_VIEW_YEARS
cellIndex0..11
nValueMonth 1..12, or the four-digit year
wszLabelWhat the built-in painter would draw
isSelected
isHot
isFocused
isDimmedCAL_VIEW_YEARS only: the leading and trailing decade overhang cells
isEnabled

Whole-control painting

type CAL_PaintCallbackSub as sub( byval p as PSCALENDAR_PAINTINFO ptr )

Replaces the entire renderer. The control still fills the background before calling and still strokes the frame afterwards, so those two cannot be lost — everything between is yours. Every rectangle is already laid out.

FieldMeaning
hCtrlThis calendar's HWND
bThe buffer to draw into
rcClientThe whole client area
rcInnerClient less border and padding
rcNavBandThe prev/title/next band
rcPrevPrevious chevron cell
rcTitleTitle cell
rcNextNext chevron cell
rcGridEverything below the nav band
eViewCurrent view
nCols, nRowsPanel grid shape
panelCountnCols * nRows
isEnabled
isFocused
isPrevDisabledPrevious chevron would reveal nothing selectable
isNextDisabledLikewise for next
hotPartCAL_HIT_* under the cursor
hotPanel, hotIndexWhich cell, when hotPart is a cell

Messages

type CAL_MessageCallbackFunc as function( byval m as PSCALENDAR_MESSAGEINFO ptr ) as boolean

Sees each message before the control handles it. Return TRUE to suppress the default handling — which for WM_KEYDOWN means you can veto a navigation key.

The return value is ignored for WM_KILLFOCUS, WM_DESTROY and WM_NCDESTROY: a callback must not be able to leave the control painted as focused when it is not, or half-destroyed.

FieldMeaning
hCtrlThis calendar's HWND
uMsgThe message
wParam, lParamIts parameters

Tooltips

type CAL_TooltipCallbackFunc as function( byval t as PSCALENDAR_TOOLTIPINFO ptr ) as boolean

Asked for text as the cursor moves onto a new day cell. Return TRUE having filled outText; an empty string shows no tip. Day cells only — the chevrons and the title never ask.

The tooltip window is created lazily — on the first day cell that actually wants a tip, and never at all if this callback is unset — so a calendar without one costs nothing. The tip is tracked: the control positions and shows it itself as the cursor moves, with no dwell. SetTooltipMode chooses which backend draws it; see Tooltips: two backends.

FieldMeaning
hCtrlThis calendar's HWND
stDateThe day under the cursor
isValidFALSE when the cursor is not over a day cell
outTextFill this with the tip text

Constants

Week start

ValueMeaning
CAL_SUNDAY0 — the default
CAL_MONDAY1

View

ValueMeaning
CAL_VIEW_DAYSThe month grid — the default
CAL_VIEW_MONTHS3 × 4 month picker
CAL_VIEW_YEARS3 × 4 year picker, showing a decade plus one overhang year at each end

Hit-test parts

PsCalendar_HitTest returns a CAL_HITRESULT of part, panel and index.

Valuepanel / index
CAL_HIT_NONEboth −1
CAL_HIT_PREV
CAL_HIT_NEXT
CAL_HIT_TITLE
CAL_HIT_DAYCELLpanel 0..11, index 0..41
CAL_HIT_MONTHCELLindex 0..11
CAL_HIT_YEARCELLindex 0..11

Render-probe parts

ValuePart
CAL_PART_CLIENTThe whole client
CAL_PART_NAVBANDThe nav band
CAL_PART_PREVPrevious chevron cell
CAL_PART_NEXTNext chevron cell
CAL_PART_TITLETitle cell
CAL_PART_GRIDEverything below the nav band
CAL_PART_MYCELL_BASE + kDrill-down cell k (0..11)
CAL_PART_DAYCELL_BASE + panel*100 + kDay cell k (0..41) of panel

Layout defaults

Logical pixels, DPI-scaled once at creation.

ConstantDefaultWhat it is
CCAL_PAD8Padding on all four sides, inside any border
CCAL_NAVBANDH34Height of the prev/title/next band
CCAL_NAVGAP4Gap between the nav band and the panel grid
CCAL_CHEVW28Width of each chevron cell
CCAL_GUTTERX12Horizontal spacing between panels
CCAL_GUTTERY10Vertical spacing between panel rows
CCAL_CAPTIONH24A panel's own month caption
CCAL_WEEKDAYH22The weekday header row
CCAL_CELLMINW34Day cell width, for the ideal size only
CCAL_CELLMINH30Day cell height, for the ideal size only
CCAL_MY_COLS3Drill-down grid columns
CCAL_MY_ROWS4Drill-down grid rows
CCAL_MAX_PANELS12Maximum nCols * nRows

Not DPI-scaled — the drawing primitives scale the pen themselves:

ConstantDefaultWhat it is
CCAL_DEFAULT_BORDERTHICK0Frame thickness; 0 means no frame
CCAL_DEFAULT_CHEVTHICK2Chevron stroke weight
CCAL_DEFAULT_FOCUSTHICK1Focus ring stroke weight

Mouse wheel

A wheel notch away from the user shows the previous page; toward the user shows the next. Each notch moves one navigation step (see PsCalendar_SetNavStep), and stops at a disabled chevron. Sub-notch deltas from a precision touchpad accumulate rather than being dropped.

Licence

Mozilla Public License 2.0.

MPL-2.0 is file-level copyleft, chosen deliberately for a drop-in control:

  • You may use this in closed-source software, commercial or otherwise. §3.2 permits static linking with no additional conditions.
  • If you modify these files, publish those files' changes. The obligation is per-file — your own sources are unaffected however tightly they are combined with these.
  • The Exhibit B "Incompatible With Secondary Licenses" notice is not applied, which keeps this GPL-compatible.