feat: initialize Nocal terminal calendar

This commit is contained in:
2026-07-17 21:24:06 +01:00
commit 22c6399056
37 changed files with 6146 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
/build/
/.cache/
compile_commands.json
calendar.ics
*.ics.lock
*.ics.bak
.*.ics.tmp.*

82
AGENTS.md Normal file
View File

@@ -0,0 +1,82 @@
# Nocal agent instructions
These instructions apply to the entire repository.
## Operating model
Use a frontier-capability model as the lead agent. The lead owns product and
technical judgment: it inspects the repository, defines the architecture,
creates the implementation plan, decomposes work, assigns non-overlapping file
ownership, reviews every delivered change, integrates the result, and performs
the final verification.
Delegate bounded coding tasks to cheaper/lower-cost agents whenever the
orchestration environment supports model selection. Coding agents should
implement clearly specified slices; they should not independently redefine the
architecture or expand product scope. Reserve the frontier model's context for
planning, difficult cross-cutting decisions, code review, debugging integration
failures, security/data-loss analysis, and final validation.
Delegation never transfers accountability. The lead must inspect the actual
diffs and rerun relevant checks; an agent's claim that code compiles or tests
pass is not sufficient evidence. Small, tightly coupled fixes may be completed
directly by the lead when delegation would create more coordination cost than
implementation value.
## Delegation protocol
Before dispatching implementation work, the lead must:
1. Read this file and inspect the current workspace, including uncommitted work.
2. State the intended behavior, interfaces, invariants, and failure semantics.
3. Give each agent exclusive ownership of an explicit file or directory set.
4. Identify shared API contracts up front and prevent concurrent edits to the
same files.
5. Keep build files, integration, roadmap decisions, and final review under the
lead unless explicitly delegated.
Each coding agent must:
- stay inside its assigned scope and preserve unrelated user changes;
- use C++20 and avoid new runtime dependencies unless the lead approves them;
- use `apply_patch` for source edits;
- add or update deterministic tests for its behavior;
- compile with strict warnings and report the exact checks run;
- document limitations, unhandled edge cases, and any shared-contract mismatch;
- never mark a task complete based only on visual inspection.
## Product and architecture constraints
- Nocal is a local-first, keyboard-driven terminal calendar for Linux and
Hyprland.
- The month grid remains the primary view and appointments remain navigable
inside day cells.
- Styling must use the terminal's default background and semantic ANSI palette.
Do not introduce hard-coded RGB themes. `NO_COLOR` must remain usable.
- Keep domain, storage, synchronization, and TUI concerns separated. Provider
APIs and iCalendar details must not leak into rendering code.
- Treat calendar writes as data-loss-sensitive. Mutations need validation,
atomic persistence, rollback on failure, and tests for failure paths.
- Keep terminal lifecycle code exception-safe: raw mode, cursor visibility, and
the alternate screen must always be restored.
- Remote synchronization is out of scope until the local mutation and durable
cache boundaries are reliable.
## Verification requirements
The lead's final review for a change should be proportional to its risk and
normally include:
```sh
nix-shell --run 'meson compile -C build'
nix-shell --run 'meson test -C build --print-errorlogs'
```
Also run strict warning-as-error compilation for changed translation units.
For storage, parsing, terminal lifecycle, or mutation work, run the Address and
Undefined Behavior sanitizer build and exercise relevant failure paths. TUI
changes require a live PTY smoke test covering interaction and terminal-state
restoration.
Do not report completion while required tests fail, while delegated changes are
unreviewed, or while user data can be lost on an ordinary error path.

52
README.md Normal file
View File

@@ -0,0 +1,52 @@
# Nocal
Nocal is a fast, local-first C++20 month calendar designed for the Linux
terminal and Hyprland. It presents appointments inside a six-week month grid and
inherits its colors from your terminal theme.
This repository currently contains the first local-calendar vertical slice:
date/domain logic, guarded atomic iCalendar persistence, add/edit/delete forms,
a responsive ANSI terminal UI, tests, and Linux launch integration. See [the product specification](docs/PRODUCT.md),
[architecture](docs/ARCHITECTURE.md), and [roadmap](docs/ROADMAP.md).
## Controls
Use arrow keys or `h j k l` to move, `PageUp`/`PageDown` or `p`/`n` to change
month, and `t` for today. On a day with appointments, `Tab` and `Shift-Tab`
move focus through them and `Enter` opens the appointment reader. In the
reader, arrows/Vim keys or Tab variants browse the day's appointments and
`Esc` returns to the month. Press `a` to add an appointment. Focus an
appointment and press `e` to edit it or `d` to delete it. Forms use `Tab` and
`Shift-Tab` (or arrows) between fields, `Space` for the all-day toggle,
`Ctrl-S` to save, and `Esc` to cancel. Back in the calendar, `u` undoes the
last successful mutation and `Ctrl-R` redoes it. Press `?` for help and `q` to
quit.
## Build and run
```sh
nix-shell --run 'meson setup build && meson compile -C build'
./build/nocal --demo
```
Run the tests with `meson test -C build --print-errorlogs`. Nocal reads
`$XDG_DATA_HOME/nocal/calendar.ics` by default (falling back to
`~/.local/share/nocal/calendar.ics`), or accepts another `.ics` path as its
positional argument. `--demo` adds a few unsaved sample appointments and
`--print` emits a non-interactive frame.
Nocal will not rewrite a calendar containing iCalendar data this version
cannot preserve, such as recurrence rules, alarms, attendees, or `TZID`
parameters. The calendar remains browsable, while mutation attempts explain
that it is read-only. This guard prevents a local edit from silently discarding
unknown data until those features are represented by the domain model.
Every replacement checks that the source has not changed since it was loaded.
If another Nocal instance or an external editor changes the file, the mutation
is rejected and the in-memory change is rolled back. Successful replacements
retain the immediately previous bytes as `calendar.ics.bak`. Run
`nocal --restore-backup [CALENDAR.ics]` for an explicit, atomic recovery; the
backup itself is kept so recovery can be repeated.
For a popup calendar binding and version-specific Hyprland rules, see
[Hyprland integration](docs/HYPRLAND.md).

89
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,89 @@
# Architecture
Nocal is split into four layers. Dependencies point inward, keeping remote sync
and terminal rendering replaceable.
```text
src/main.cpp
├── tui/ ANSI rendering, input decoding, terminal lifecycle
├── storage/ iCalendar file adapter (later: SQLite cache)
└── domain/ Event, Calendar, civil dates, month layout and queries
future sync workers
├── caldav/ Nextcloud and generic CalDAV
├── google/ Google Calendar REST/OAuth
└── graph/ Microsoft Graph/OAuth
└──────────> domain/ through a SyncProvider interface
```
## Domain
The domain layer uses C++20 `<chrono>` civil dates and time points. It knows
nothing about escape sequences, files, OAuth, or HTTP. Month layout always
produces 42 cells, starting on Monday, which keeps redraw geometry stable.
Events retain a stable UID, title, half-open start/end interval, all-day flag,
optional descriptive fields, and calendar identity. Queries define overlap as
`event.start < range.end && event.end > range.start`; this matters for events
crossing midnight.
## Storage
Version 0.1 uses a user-owned iCalendar file, defaulting to
`$XDG_DATA_HOME/nocal/calendar.ics` or `~/.local/share/nocal/calendar.ics`.
The adapter unfolds content lines before parsing and escapes text on output.
On POSIX systems, writes are serialized with an advisory lock, staged in a
same-directory private temporary file, flushed, and atomically renamed over the
destination. A load retains an immutable snapshot of the exact source bytes.
Before saving, the writer compares that snapshot with the destination while it
holds the same lock used for replacement, then checks again after staging and
immediately before commit. This catches same-size and same-timestamp changes
and fully serializes cooperating Nocal writers. The lock is advisory: an
unrelated program can ignore it, so no portable filesystem API can provide a
true compare-and-swap against every possible external writer.
Before an existing destination is replaced, its exact bytes are atomically
written to the adjacent `.bak` file. Explicit backup restoration uses the same
lock, source-revision check, private staging, and atomic replacement path; it
does not consume the backup. The TUI mutates a copyable in-memory model and
rolls it back if persistence fails. Its bounded undo/redo history contains only
mutations that crossed the persistence boundary successfully.
The current writer deliberately refuses to mutate an existing file when the
loader encounters information it cannot round-trip, including recurrence,
alarms, attendees, time-zone identifiers, unknown properties, or malformed
components. Browsing remains available. This conservative boundary is more
important than partial editing because a successful-looking edit must not
erase unrelated calendar data.
Provider synchronization will not write directly into this UI file. It will use
a transaction-capable local cache and preserve remote ETags/sync tokens in a
provider metadata table.
The planned cache schema has `calendars`, `events`, `event_instances`, and
`sync_state`. Raw provider payloads are retained alongside normalized fields so
an older Nocal cannot destroy fields it does not understand.
## Terminal backend
The initial backend depends only on POSIX `termios`, `poll`, and ANSI/ECMA-48.
It uses the alternate screen, hides the cursor while rendering, restores all
terminal state through RAII, and repaints from a complete frame buffer. A resize
signal only sets a flag; terminal size and rendering are handled safely in the
main loop.
Colors are semantic ANSI slots and default background, intentionally delegating
actual RGB values to the terminal theme. This is both simpler and more native
than shipping an application theme that fights the desktop palette.
## Sync roadmap
All providers implement the same conceptual operations: authenticate, list
calendars, perform an incremental pull, push a local mutation, and resolve a
conflict. CalDAV uses sync tokens/ETags, Google uses page/sync tokens, and
Microsoft uses Graph delta links. Secrets belong in the Freedesktop Secret
Service, never in the calendar database or command line.
Conflict resolution is deterministic: unchanged side wins; otherwise retain
both versions and mark a conflict for the user. Network work happens outside the
render loop and publishes immutable snapshots to it.

43
docs/HYPRLAND.md Normal file
View File

@@ -0,0 +1,43 @@
# Hyprland integration
The installed `nocal-hyprland` launcher chooses Kitty, foot, WezTerm, or
Alacritty and gives its Wayland window the dedicated `nocal` class/app-id. Set
`NOCAL_TERMINAL` to an executable path to override that order.
Hyprland 0.55 moved its configuration from hyprlang to Lua. Add this to
`~/.config/hypr/hyprland.lua` for a large, centered calendar on `SUPER+C`:
```lua
hl.bind("SUPER + C", hl.dsp.exec_cmd("nocal-hyprland"), {
description = "Open calendar"
})
hl.window_rule({
name = "nocal-calendar",
match = { class = "nocal" },
float = true,
center = true,
size = { "monitor_w*0.82", "monitor_h*0.82" },
persistent_size = true,
no_dim = true,
})
```
For Hyprland 0.54 and older hyprlang configurations, the equivalent is:
```ini
bind = SUPER, C, exec, nocal-hyprland
windowrule = float, class:^(nocal)$
windowrule = center, class:^(nocal)$
windowrule = size 82% 82%, class:^(nocal)$
windowrule = persistentsize, class:^(nocal)$
windowrule = nodim, class:^(nocal)$
```
Nocal deliberately does not request a particular opacity or color. Configure
those on the terminal and Nocal will inherit them; the application uses default
foreground/background plus the ANSI semantic palette.
The syntax above follows the current official [Hyprland bind documentation]
(https://wiki.hypr.land/Configuring/Basics/Binds/) and [window-rule
documentation](https://wiki.hypr.land/Configuring/Basics/Window-Rules/).

92
docs/PRODUCT.md Normal file
View File

@@ -0,0 +1,92 @@
# Nocal product and interaction specification
Nocal is a local-first, keyboard-driven month calendar for Linux terminals. Its
visual target is the information density of a desktop month view without
pretending that a terminal is a pixel canvas. The terminal emulator owns the
typeface and palette; Nocal owns hierarchy, spacing, and restrained emphasis.
## Product principles
1. **The month is the home screen.** Launching Nocal immediately shows six
complete Monday-first weeks. There is no dashboard or splash screen.
2. **Useful at a glance.** Every visible day shows as many appointments as fit,
ordered as all-day first and then by start time. A final `+N more` line is
used instead of clipping silently.
3. **Keyboard-native.** Arrow keys and Vim keys move by day or week; month and
today jumps are single keystrokes. Every action remains discoverable in the
footer and help overlay.
4. **Terminal-native aesthetics.** Nocal uses default foreground/background and
the terminal's ANSI semantic colors. It never paints a fixed RGB theme over
the user's Kitty, foot, Alacritty, or WezTerm theme.
5. **Local-first, sync-ready.** The canonical domain model is independent of
iCalendar files and of any future remote API. Sync engines translate into
domain objects and never leak provider-specific types into the UI.
6. **Fast enough to feel instant.** Startup should remain below 50 ms for a
normal local calendar and redraw should be flicker-free at interactive
resize rates.
## Month view
The full terminal is treated as a responsive canvas:
```text
JULY 2026 Today Fri 17 Jul
Mon Tue Wed Thu Fri Sat Sun
┌────────────┬────────────┬────────────┬────────────┬────────────┬────────────┬────────────┐
│ 29 │ 30 │ 1 │ 2 │ 3 │ 4 │ 5 │
│ │ │ 09:30 Team │ │ All day … │ │ │
├────────────┼────────────┼────────────┼────────────┼────────────┼────────────┼────────────┤
│ … │
└─────────────────────────────────────────────────────────────────────────────────────────┘
←↓↑→ move PgUp/PgDn month t today ? help q quit 2 appointments
```
- At 100 columns and above, Nocal draws the full bordered grid.
- Narrow cells abbreviate times and ellipsize summaries by display width.
- When height is constrained, appointment lines are reduced before structural
chrome. At extremely small sizes a clear minimum-size message replaces a
broken grid.
- Days outside the focused month remain visible but dim.
- The selected date is reverse-video, today is bold/underlined, and collisions
use semantic ANSI accents. These attributes work on monochrome terminals.
- The footer reports the selected day's appointment count and the most useful
keys for the available width.
## Interaction map
| Action | Keys |
| --- | --- |
| Previous/next day | `Left` / `Right`, `h` / `l` |
| Previous/next week | `Up` / `Down`, `k` / `j` |
| Previous/next month | `PageUp` / `PageDown`, `p` / `n` |
| Jump to today | `t` |
| Next/previous appointment | `Tab` / `Shift-Tab` |
| Read focused appointment | `Enter` |
| Browse inside reader | arrows, `h j k l`, `Tab` / `Shift-Tab` |
| Add appointment | `a` |
| Edit focused appointment | `e` |
| Delete focused appointment | `d`, then `y` to confirm |
| Undo/redo successful mutation | `u` / `Ctrl-R` |
| Move between editor fields | `Tab` / `Shift-Tab`, arrows |
| Save/cancel editor | `Ctrl-S` / `Esc` |
| Return to month/unfocus | `Esc` |
| Help | `?` |
| Quit | `q` |
The first usable foundation supports day and appointment navigation, a
full-frame reader, validated add/edit/delete forms, and atomic `.ics` writes.
Dense days keep every appointment keyboard-reachable even when all lines do
not fit in the cell. Unknown or unsupported iCalendar content makes a source
read-only instead of being discarded. Saves reject external changes, retain a
last-known-good backup, and feed bounded session undo/redo history. The next
local-data work adds `/` search, calendar visibility toggles, and recurring-event
and time-zone expansion.
## Accessibility
- Information is never encoded by color alone.
- `NO_COLOR` and terminals without color remain fully usable.
- Borders and icons have ASCII fallbacks; Unicode width is calculated rather
than assumed.
- Focus is always visible and keyboard operation is complete.
- Motion is limited to direct redraws; there are no decorative animations.

50
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,50 @@
# Roadmap
## Foundation — current milestone
- C++20 domain model and deterministic six-week month layout
- Responsive, theme-native ANSI month view with keyboard navigation
- Appointment focus, dense-day traversal, and full-frame detail reader
- Validated add/edit/delete forms with confirmation and failure rollback
- Guarded atomic `.ics` persistence, advisory locking, and round-trip tests
- Bounded session undo/redo, exact external-change detection, and backup recovery
- Meson build, Nix development shell, desktop entry, and Hyprland launcher
- Seeded sample data when explicitly requested, never silent data mutation
## 0.1 — complete local calendar
- Recurrence rules, exclusions, time zones, multi-day presentation
- Search, agenda view, calendar toggles, import/export, configurable week start
- Screen-reader-friendly linear view and full Unicode display-width handling
## 0.2 — durable cache and provider boundary
- SQLite WAL cache and migration framework
- Background job queue, immutable UI snapshots, observable sync status
- Secret Service credentials and OAuth callback helper
- Generic `SyncProvider` contract plus a fake provider test suite
## 0.3 — CalDAV
- Discovery and incremental sync for Nextcloud and standards-compliant servers
- ETag-aware writes, offline mutation queue, conflict UI
- TLS and failure-path integration tests
## 0.4 — hosted providers
- Google Calendar adapter with incremental synchronization
- Microsoft 365 adapter over Microsoft Graph delta queries
- Account/calendar management UI and per-calendar ANSI identity
## 1.0 — distribution quality
- Stable configuration/data format and documented recovery procedures
- Nomarchy package, Hyprland defaults, man page, shell completions
- Performance budgets exercised against large and adversarial calendars
- Fuzzed iCalendar parser, accessibility review, translations
## Quality gates
Every milestone requires clean warning-enabled builds, unit and integration
tests, sanitizer runs, terminal-state recovery after signals/errors, no network
access without explicit account setup, and no loss of unknown calendar data.

View File

@@ -0,0 +1,36 @@
#pragma once
#include "nocal/domain/event.hpp"
#include <chrono>
#include <string>
#include <string_view>
namespace nocal {
using Date = std::chrono::year_month_day;
using YearMonth = std::chrono::year_month;
[[nodiscard]] constexpr bool valid_date(Date date) noexcept { return date.ok(); }
[[nodiscard]] std::chrono::sys_days to_sys_days(Date date);
[[nodiscard]] Date from_sys_days(std::chrono::sys_days days) noexcept;
// ISO 8601 calendar dates in YYYY-MM-DD form.
[[nodiscard]] Date parse_date(std::string_view text);
[[nodiscard]] std::string format_date(Date date);
// These helpers use the process' local time zone, including daylight-saving rules.
[[nodiscard]] Date local_date(TimePoint point);
[[nodiscard]] Date today();
[[nodiscard]] TimePoint local_day_start(Date date);
[[nodiscard]] TimePoint local_day_end(Date date);
[[nodiscard]] TimePoint make_local_time(Date date, unsigned hour = 0,
unsigned minute = 0, unsigned second = 0);
[[nodiscard]] bool same_local_day(TimePoint lhs, TimePoint rhs);
[[nodiscard]] bool is_today(Date date);
[[nodiscard]] Date first_day_of_month(YearMonth month);
[[nodiscard]] YearMonth following_month(YearMonth month);
[[nodiscard]] unsigned monday_first_weekday(Date date);
} // namespace nocal

View File

@@ -0,0 +1,6 @@
#pragma once
#include "nocal/domain/date.hpp"
#include "nocal/domain/event.hpp"
#include "nocal/domain/event_query.hpp"
#include "nocal/domain/month_layout.hpp"

View File

@@ -0,0 +1,36 @@
#pragma once
#include <chrono>
#include <string>
namespace nocal {
using Clock = std::chrono::system_clock;
using TimePoint = Clock::time_point;
struct Event {
std::string uid;
std::string title;
TimePoint start{};
TimePoint end{};
bool all_day{false};
std::string location;
std::string description;
std::string calendar_id;
std::string color;
};
struct Calendar {
std::string id;
std::string name;
std::string color;
bool visible{true};
bool read_only{false};
};
[[nodiscard]] constexpr bool has_valid_interval(const Event& event) noexcept
{
return event.end >= event.start;
}
} // namespace nocal

View File

@@ -0,0 +1,29 @@
#pragma once
#include "nocal/domain/date.hpp"
#include "nocal/domain/event.hpp"
#include <functional>
#include <span>
#include <vector>
namespace nocal {
using EventRef = std::reference_wrapper<const Event>;
using EventRefs = std::vector<EventRef>;
[[nodiscard]] bool event_less(const Event& lhs, const Event& rhs) noexcept;
void sort_events(EventRefs& events);
// Event and query intervals are half-open. A zero-duration event is treated as
// an instant and belongs to the interval containing its start time.
[[nodiscard]] bool event_overlaps(const Event& event, TimePoint begin,
TimePoint end) noexcept;
[[nodiscard]] bool event_occurs_on(const Event& event, Date date);
[[nodiscard]] EventRefs events_overlapping(std::span<const Event> events,
TimePoint begin, TimePoint end);
[[nodiscard]] EventRefs events_on_day(std::span<const Event> events, Date date);
[[nodiscard]] EventRefs events_in_month(std::span<const Event> events,
YearMonth month);
} // namespace nocal

View File

@@ -0,0 +1,33 @@
#pragma once
#include "nocal/domain/date.hpp"
#include <array>
#include <cstddef>
namespace nocal {
struct MonthCell {
Date date{std::chrono::year{1970} / std::chrono::January / 1};
bool in_month{false};
std::size_t row{0};
std::size_t column{0};
};
struct MonthLayout {
static constexpr std::size_t rows = 6;
static constexpr std::size_t columns = 7;
static constexpr std::size_t cell_count = rows * columns;
YearMonth month{std::chrono::year{1970} / std::chrono::January};
std::array<MonthCell, cell_count> cells{};
[[nodiscard]] const MonthCell& at(std::size_t row, std::size_t column) const;
[[nodiscard]] const MonthCell* find(Date date) const noexcept;
[[nodiscard]] Date first_visible_date() const noexcept;
[[nodiscard]] Date last_visible_date() const noexcept;
};
[[nodiscard]] MonthLayout make_month_layout(YearMonth month);
} // namespace nocal

View File

@@ -0,0 +1,62 @@
#pragma once
#include "nocal/domain/event.hpp"
#include <filesystem>
#include <memory>
#include <optional>
#include <span>
#include <string>
#include <vector>
namespace nocal::storage {
class FileRevision {
public:
FileRevision() = default;
[[nodiscard]] bool existed() const noexcept { return existed_; }
[[nodiscard]] std::size_t size() const noexcept {
return contents_ ? contents_->size() : 0;
}
private:
bool existed_ = false;
std::shared_ptr<const std::string> contents_;
friend class IcsStore;
};
struct LoadResult {
std::vector<Event> events;
std::vector<std::string> warnings;
// False when saving the parsed events would discard source data that this
// version of nocal cannot represent faithfully.
bool safe_to_rewrite = true;
// Exact bytes that produced this result. Copies share immutable storage and
// can be supplied to save/restore as an optimistic concurrency token.
FileRevision revision;
};
class IcsStore {
public:
[[nodiscard]] static LoadResult load(const std::filesystem::path& path);
[[nodiscard]] static std::filesystem::path backup_path(
const std::filesystem::path& path);
// Writes a complete VCALENDAR. On POSIX systems replacement is atomic and
// requests durable filesystem flushes. I/O failures before replacement are
// reported as std::runtime_error. If expected is present, the destination
// must still contain the exact bytes represented by that revision.
static FileRevision save(
const std::filesystem::path& path, std::span<const Event> events,
std::optional<FileRevision> expected = std::nullopt);
// Restores the exact bytes in <path>.bak without changing the backup.
static FileRevision restore_backup(
const std::filesystem::path& path,
std::optional<FileRevision> expected = std::nullopt);
};
} // namespace nocal::storage

View File

@@ -0,0 +1,64 @@
#pragma once
#include "nocal/domain/date.hpp"
#include "nocal/domain/event.hpp"
#include <array>
#include <cstddef>
#include <string>
#include <string_view>
namespace nocal::tui {
enum class EditorOutcome {
active,
submit,
cancel,
};
enum class EditorField : std::size_t {
title,
start_date,
start_time,
end_date,
end_time,
all_day,
location,
notes,
count,
};
// A small, stateful form controller. It owns no terminal or storage resources,
// so callers can feed it decoded key sequences and redraw whenever convenient.
class EventEditor {
public:
explicit EventEditor(Date selected_day);
explicit EventEditor(const Event& event);
[[nodiscard]] EditorOutcome handle_key(std::string_view key);
[[nodiscard]] std::string render(int width, int height, bool ansi = true) const;
// result() contains the validated event after handle_key() returns submit.
[[nodiscard]] const Event& result() const noexcept { return result_; }
[[nodiscard]] EditorField focused_field() const noexcept { return focused_; }
[[nodiscard]] std::string_view field_value(EditorField field) const noexcept;
[[nodiscard]] std::string_view error() const noexcept { return error_; }
[[nodiscard]] bool all_day() const noexcept { return all_day_; }
[[nodiscard]] bool editing() const noexcept { return editing_; }
private:
using Values = std::array<std::string, static_cast<std::size_t>(EditorField::count)>;
Event original_;
Event result_;
Values values_{};
EditorField focused_{EditorField::title};
std::string error_;
bool all_day_{false};
bool editing_{false};
void move_focus(int direction) noexcept;
[[nodiscard]] bool validate();
};
} // namespace nocal::tui

View File

@@ -0,0 +1,83 @@
#pragma once
#include <chrono>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include "nocal/domain/event.hpp"
namespace nocal::tui {
// A deliberately small view-model keeps the renderer independent of storage and
// makes it straightforward to unit test. TuiApp adapts domain Events to this.
struct CalendarItem {
std::string id;
std::string title;
std::chrono::year_month_day day;
std::optional<std::chrono::minutes> time_of_day;
bool all_day{false};
std::optional<std::chrono::minutes> end_time_of_day;
std::chrono::year_month_day start_day{};
std::chrono::year_month_day end_day{};
std::string location;
std::string description;
std::string detail_title;
bool detail_all_day{false};
std::optional<std::chrono::minutes> detail_start_time_of_day;
};
struct ScreenState {
std::chrono::year_month visible_month{std::chrono::year{1970}, std::chrono::month{1}};
std::chrono::year_month_day selected_day{std::chrono::year{1970}, std::chrono::month{1},
std::chrono::day{1}};
std::chrono::year_month_day today{std::chrono::year{1970}, std::chrono::month{1},
std::chrono::day{1}};
int width{80};
int height{24};
bool show_help{false};
bool ansi{true};
std::optional<std::string> focused_event_id;
bool show_event_details{false};
bool confirm_delete{false};
std::string notification;
bool notification_is_error{false};
};
// Render a complete frame. The result contains no cursor positioning, which
// makes it useful both for snapshots and for non-interactive output.
[[nodiscard]] std::string render_month(std::span<const CalendarItem> items,
const ScreenState& state);
// Convenience adapter for the domain model. Event timestamps are projected to
// the process's local civil time until explicit calendar time zones are added.
[[nodiscard]] std::string render_month(std::span<const Event> events,
const ScreenState& state);
enum class Action {
none,
left,
right,
up,
down,
previous_month,
next_month,
today,
toggle_help,
previous_event,
next_event,
select,
back,
add_event,
edit_event,
delete_event,
undo,
redo,
quit,
};
// Decodes a complete key sequence, including common terminal arrow/Page keys.
[[nodiscard]] Action decode_key(std::string_view sequence) noexcept;
} // namespace nocal::tui

View File

@@ -0,0 +1,65 @@
#pragma once
#include <string>
#include <string_view>
#include <termios.h>
namespace nocal::tui {
struct TerminalSize {
int columns{80};
int rows{24};
};
[[nodiscard]] TerminalSize terminal_size(int fd) noexcept;
class AlternateScreen {
public:
explicit AlternateScreen(int output_fd);
~AlternateScreen();
AlternateScreen(const AlternateScreen&) = delete;
AlternateScreen& operator=(const AlternateScreen&) = delete;
private:
int output_fd_;
bool active_;
};
class RawInput {
public:
explicit RawInput(int input_fd);
~RawInput();
RawInput(const RawInput&) = delete;
RawInput& operator=(const RawInput&) = delete;
[[nodiscard]] bool active() const noexcept { return active_; }
private:
int input_fd_;
termios original_{};
bool active_{false};
};
// Installs a minimal SIGWINCH handler and restores the previous one on exit.
class ResizeWatcher {
public:
ResizeWatcher();
~ResizeWatcher();
ResizeWatcher(const ResizeWatcher&) = delete;
ResizeWatcher& operator=(const ResizeWatcher&) = delete;
[[nodiscard]] bool consume() noexcept;
private:
struct sigaction_storage;
sigaction_storage* storage_;
};
// Writes all bytes unless an unrecoverable error occurs.
[[nodiscard]] bool write_terminal(int fd, std::string_view bytes) noexcept;
} // namespace nocal::tui

View File

@@ -0,0 +1,92 @@
#pragma once
#include <functional>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <vector>
#include "nocal/domain/event.hpp"
#include "nocal/tui/EventEditor.hpp"
#include "nocal/tui/Screen.hpp"
namespace nocal::tui {
enum class ExitReason {
quit,
input_closed,
io_error,
};
// The Event vector is retained by reference so future editor commands can
// update the caller's model without changing the application boundary.
class TuiApp {
public:
using SaveCallback = std::function<void(std::span<const Event>)>;
explicit TuiApp(std::vector<Event>& events, int input_fd = 0, int output_fd = 1);
TuiApp(std::vector<Event>& events, SaveCallback saver, int input_fd = 0,
int output_fd = 1);
[[nodiscard]] ExitReason run();
void dispatch(Action action);
void handle_input(std::string_view input);
[[nodiscard]] const ScreenState& state() const noexcept { return state_; }
[[nodiscard]] std::vector<Event>& events() noexcept { return events_; }
[[nodiscard]] bool editor_active() const noexcept { return editor_.has_value(); }
[[nodiscard]] const EventEditor* editor() const noexcept {
return editor_ ? &*editor_ : nullptr;
}
[[nodiscard]] bool delete_confirmation_active() const noexcept {
return state_.confirm_delete;
}
private:
struct HistorySnapshot {
std::vector<Event> events;
std::chrono::year_month visible_month;
std::chrono::year_month_day selected_day;
std::optional<std::string> focused_event_id;
bool show_event_details{false};
bool show_help{false};
};
struct HistoryEntry {
HistorySnapshot snapshot;
std::string operation;
};
static constexpr std::size_t history_limit = 100;
std::vector<Event>& events_;
int input_fd_;
int output_fd_;
ScreenState state_;
SaveCallback saver_;
std::optional<EventEditor> editor_;
std::optional<std::size_t> editing_index_;
std::optional<HistorySnapshot> pending_mutation_before_;
std::vector<HistoryEntry> undo_history_;
std::vector<HistoryEntry> redo_history_;
bool running_{true};
[[nodiscard]] std::optional<std::size_t> focused_event_index() const;
[[nodiscard]] HistorySnapshot capture_history_snapshot() const;
void restore_history_snapshot(HistorySnapshot snapshot);
void push_history(std::vector<HistoryEntry>& history, HistoryEntry entry);
[[nodiscard]] bool persist(std::span<const Event> events);
void record_mutation(std::string operation);
void undo();
void redo();
void begin_add();
void begin_edit();
void begin_delete();
void submit_editor();
void confirm_delete();
void cancel_delete();
void set_notification(std::string message, bool error = false);
};
} // namespace nocal::tui

View File

@@ -0,0 +1,6 @@
#pragma once
#include "nocal/tui/EventEditor.hpp"
#include "nocal/tui/Screen.hpp"
#include "nocal/tui/Terminal.hpp"
#include "nocal/tui/TuiApp.hpp"

53
meson.build Normal file
View File

@@ -0,0 +1,53 @@
project(
'nocal',
'cpp',
version: '0.1.0',
meson_version: '>=1.1.0',
default_options: [
'cpp_std=c++20',
'warning_level=3',
'werror=false',
'buildtype=debugoptimized',
],
)
inc = include_directories('include')
nocal_sources = files(
'src/domain/date.cpp',
'src/domain/event_query.cpp',
'src/domain/month_layout.cpp',
'src/storage/ics_store.cpp',
'src/tui/Screen.cpp',
'src/tui/EventEditor.cpp',
'src/tui/Terminal.cpp',
'src/tui/TuiApp.cpp',
)
nocal_lib = static_library('nocal-core', nocal_sources, include_directories: inc)
nocal_executable = executable('nocal', 'src/main.cpp', include_directories: inc,
link_with: nocal_lib, install: true)
domain_tests = executable('domain-tests', 'tests/domain_tests.cpp',
include_directories: inc, link_with: nocal_lib)
test('domain', domain_tests)
ics_tests = executable('ics-tests', 'tests/ics_tests.cpp',
include_directories: inc, link_with: nocal_lib)
test('ics-storage', ics_tests)
tui_tests = executable('tui-tests', 'tests/tui_tests.cpp',
include_directories: inc, link_with: nocal_lib)
test('tui', tui_tests)
tui_focus_tests = executable('tui-focus-tests', 'tests/tui_focus_tests.cpp',
include_directories: inc, link_with: nocal_lib)
test('tui-focus', tui_focus_tests)
editor_tests = executable('editor-tests', 'tests/editor_tests.cpp',
include_directories: inc, link_with: nocal_lib)
test('editor', editor_tests)
shell = find_program('sh')
test('cli-recovery', shell,
args: [files('tests/cli_recovery_test.sh'), nocal_executable])
install_data('scripts/nocal-hyprland', install_dir: get_option('bindir'),
install_mode: 'rwxr-xr-x')
install_data('share/applications/dev.nomarchy.nocal.desktop',
install_dir: get_option('datadir') / 'applications')

25
scripts/nocal-hyprland Normal file
View File

@@ -0,0 +1,25 @@
#!/bin/sh
set -eu
# A dedicated app-id lets Hyprland distinguish Nocal from ordinary terminal
# windows. NOCAL_TERMINAL may be set to one executable path (without arguments).
if [ "${NOCAL_TERMINAL:-}" != "" ]; then
exec "$NOCAL_TERMINAL" -e nocal "$@"
fi
if command -v kitty >/dev/null 2>&1; then
exec kitty --class nocal --name nocal --title Nocal nocal "$@"
elif command -v foot >/dev/null 2>&1; then
exec foot --app-id=nocal --title=Nocal nocal "$@"
elif command -v wezterm >/dev/null 2>&1; then
exec wezterm start --class nocal -- nocal "$@"
elif command -v alacritty >/dev/null 2>&1; then
exec alacritty --class nocal,Nocal --title Nocal -e nocal "$@"
elif command -v xdg-terminal-exec >/dev/null 2>&1; then
exec xdg-terminal-exec nocal "$@"
elif [ "${TERMINAL:-}" != "" ]; then
exec "$TERMINAL" -e nocal "$@"
fi
printf '%s\n' 'nocal: no supported terminal emulator found' >&2
exit 127

View File

@@ -0,0 +1,13 @@
[Desktop Entry]
Type=Application
Name=Nocal
GenericName=Calendar
Comment=Local-first terminal month calendar
Exec=nocal-hyprland
Icon=x-office-calendar
Terminal=false
Categories=Office;Calendar;
Keywords=calendar;appointment;schedule;terminal;
MimeType=text/calendar;
StartupNotify=true
StartupWMClass=nocal

13
shell.nix Normal file
View File

@@ -0,0 +1,13 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
nativeBuildInputs = with pkgs; [
meson
ninja
pkg-config
];
buildInputs = with pkgs; [
gcc
];
}

172
src/domain/date.cpp Normal file
View File

@@ -0,0 +1,172 @@
#include "nocal/domain/date.hpp"
#include <array>
#include <cstdio>
#include <ctime>
#include <stdexcept>
namespace nocal {
namespace {
[[nodiscard]] unsigned parse_component(std::string_view text)
{
unsigned value = 0;
for (const char ch : text) {
if (ch < '0' || ch > '9') {
throw std::invalid_argument("date contains a non-digit");
}
value = value * 10U + static_cast<unsigned>(ch - '0');
}
return value;
}
[[nodiscard]] std::tm local_tm(std::time_t time)
{
std::tm result{};
#if defined(_WIN32)
if (::localtime_s(&result, &time) != 0) {
throw std::runtime_error("could not convert timestamp to local time");
}
#else
if (::localtime_r(&time, &result) == nullptr) {
throw std::runtime_error("could not convert timestamp to local time");
}
#endif
return result;
}
} // namespace
std::chrono::sys_days to_sys_days(Date date)
{
if (!date.ok()) {
throw std::invalid_argument("invalid Gregorian date");
}
return std::chrono::sys_days{date};
}
Date from_sys_days(std::chrono::sys_days days) noexcept
{
return Date{days};
}
Date parse_date(std::string_view text)
{
if (text.size() != 10 || text[4] != '-' || text[7] != '-') {
throw std::invalid_argument("date must have YYYY-MM-DD form");
}
const auto year = static_cast<int>(parse_component(text.substr(0, 4)));
const auto month = parse_component(text.substr(5, 2));
const auto day = parse_component(text.substr(8, 2));
const Date result{std::chrono::year{year}, std::chrono::month{month},
std::chrono::day{day}};
if (!result.ok()) {
throw std::invalid_argument("invalid Gregorian date");
}
return result;
}
std::string format_date(Date date)
{
if (!date.ok()) {
throw std::invalid_argument("invalid Gregorian date");
}
std::array<char, 11> buffer{};
const int count = std::snprintf(buffer.data(), buffer.size(), "%04d-%02u-%02u",
static_cast<int>(date.year()),
static_cast<unsigned>(date.month()),
static_cast<unsigned>(date.day()));
if (count != 10) {
throw std::out_of_range("date cannot be represented as YYYY-MM-DD");
}
return {buffer.data(), 10};
}
Date local_date(TimePoint point)
{
const auto time = Clock::to_time_t(point);
const auto tm = local_tm(time);
return Date{std::chrono::year{tm.tm_year + 1900},
std::chrono::month{static_cast<unsigned>(tm.tm_mon + 1)},
std::chrono::day{static_cast<unsigned>(tm.tm_mday)}};
}
Date today()
{
return local_date(Clock::now());
}
TimePoint make_local_time(Date date, unsigned hour, unsigned minute, unsigned second)
{
if (!date.ok()) {
throw std::invalid_argument("invalid Gregorian date");
}
if (hour > 23 || minute > 59 || second > 59) {
throw std::invalid_argument("invalid local time of day");
}
std::tm tm{};
tm.tm_year = static_cast<int>(date.year()) - 1900;
tm.tm_mon = static_cast<int>(static_cast<unsigned>(date.month())) - 1;
tm.tm_mday = static_cast<int>(static_cast<unsigned>(date.day()));
tm.tm_hour = static_cast<int>(hour);
tm.tm_min = static_cast<int>(minute);
tm.tm_sec = static_cast<int>(second);
tm.tm_isdst = -1;
const auto time = std::mktime(&tm);
const auto checked = local_tm(time);
if (checked.tm_year != tm.tm_year || checked.tm_mon != tm.tm_mon ||
checked.tm_mday != tm.tm_mday || checked.tm_hour != tm.tm_hour ||
checked.tm_min != tm.tm_min || checked.tm_sec != tm.tm_sec) {
throw std::runtime_error("local date-time is not representable");
}
return Clock::from_time_t(time);
}
TimePoint local_day_start(Date date)
{
return make_local_time(date);
}
TimePoint local_day_end(Date date)
{
const auto next = from_sys_days(to_sys_days(date) + std::chrono::days{1});
return local_day_start(next);
}
bool same_local_day(TimePoint lhs, TimePoint rhs)
{
return local_date(lhs) == local_date(rhs);
}
bool is_today(Date date)
{
return date.ok() && date == today();
}
Date first_day_of_month(YearMonth month)
{
if (!month.ok()) {
throw std::invalid_argument("invalid Gregorian month");
}
return Date{month / std::chrono::day{1}};
}
YearMonth following_month(YearMonth month)
{
if (!month.ok()) {
throw std::invalid_argument("invalid Gregorian month");
}
return month + std::chrono::months{1};
}
unsigned monday_first_weekday(Date date)
{
const auto weekday = std::chrono::weekday{to_sys_days(date)}.c_encoding();
return (weekday + 6U) % 7U;
}
} // namespace nocal

View File

@@ -0,0 +1,78 @@
#include "nocal/domain/event_query.hpp"
#include <algorithm>
#include <stdexcept>
namespace nocal {
bool event_less(const Event& lhs, const Event& rhs) noexcept
{
if (lhs.all_day != rhs.all_day) {
return lhs.all_day;
}
if (lhs.start != rhs.start) {
return lhs.start < rhs.start;
}
if (lhs.end != rhs.end) {
return lhs.end < rhs.end;
}
if (lhs.title != rhs.title) {
return lhs.title < rhs.title;
}
return lhs.uid < rhs.uid;
}
void sort_events(EventRefs& events)
{
std::stable_sort(events.begin(), events.end(), [](EventRef lhs, EventRef rhs) {
return event_less(lhs.get(), rhs.get());
});
}
bool event_overlaps(const Event& event, TimePoint begin, TimePoint end) noexcept
{
if (begin >= end || !has_valid_interval(event)) {
return false;
}
if (event.start == event.end) {
return event.start >= begin && event.start < end;
}
return event.start < end && event.end > begin;
}
bool event_occurs_on(const Event& event, Date date)
{
return event_overlaps(event, local_day_start(date), local_day_end(date));
}
EventRefs events_overlapping(std::span<const Event> events, TimePoint begin,
TimePoint end)
{
if (begin > end) {
throw std::invalid_argument("event query begins after it ends");
}
EventRefs result;
result.reserve(events.size());
for (const auto& event : events) {
if (event_overlaps(event, begin, end)) {
result.emplace_back(event);
}
}
sort_events(result);
return result;
}
EventRefs events_on_day(std::span<const Event> events, Date date)
{
return events_overlapping(events, local_day_start(date), local_day_end(date));
}
EventRefs events_in_month(std::span<const Event> events, YearMonth month)
{
const auto begin = local_day_start(first_day_of_month(month));
const auto end = local_day_start(first_day_of_month(following_month(month)));
return events_overlapping(events, begin, end);
}
} // namespace nocal

View File

@@ -0,0 +1,56 @@
#include "nocal/domain/month_layout.hpp"
#include <stdexcept>
namespace nocal {
const MonthCell& MonthLayout::at(std::size_t row, std::size_t column) const
{
if (row >= rows || column >= columns) {
throw std::out_of_range("month cell is outside the 6x7 grid");
}
return cells[row * columns + column];
}
const MonthCell* MonthLayout::find(Date date) const noexcept
{
for (const auto& cell : cells) {
if (cell.date == date) {
return &cell;
}
}
return nullptr;
}
Date MonthLayout::first_visible_date() const noexcept
{
return cells.front().date;
}
Date MonthLayout::last_visible_date() const noexcept
{
return cells.back().date;
}
MonthLayout make_month_layout(YearMonth month)
{
const auto first = first_day_of_month(month);
const auto grid_start = to_sys_days(first) -
std::chrono::days{monday_first_weekday(first)};
MonthLayout result;
result.month = month;
for (std::size_t index = 0; index < result.cells.size(); ++index) {
const auto date = from_sys_days(grid_start +
std::chrono::days{static_cast<long long>(index)});
result.cells[index] = MonthCell{
.date = date,
.in_month = date.year() == month.year() && date.month() == month.month(),
.row = index / MonthLayout::columns,
.column = index % MonthLayout::columns,
};
}
return result;
}
} // namespace nocal

213
src/main.cpp Normal file
View File

@@ -0,0 +1,213 @@
#include "nocal/domain/date.hpp"
#include "nocal/storage/ics_store.hpp"
#include "nocal/tui/Screen.hpp"
#include "nocal/tui/TuiApp.hpp"
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <iostream>
#include <optional>
#include <span>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include <unistd.h>
namespace {
constexpr std::string_view version = "0.1.0";
struct Options {
std::filesystem::path calendar_path;
bool demo{false};
bool print{false};
bool restore_backup{false};
bool no_color{false};
};
std::filesystem::path default_calendar_path()
{
if (const char* data_home = std::getenv("XDG_DATA_HOME");
data_home != nullptr && *data_home != '\0') {
return std::filesystem::path{data_home} / "nocal" / "calendar.ics";
}
if (const char* home = std::getenv("HOME"); home != nullptr && *home != '\0') {
return std::filesystem::path{home} / ".local" / "share" / "nocal" / "calendar.ics";
}
return "calendar.ics";
}
void print_help(std::ostream& output)
{
output <<
"Usage: nocal [OPTIONS] [CALENDAR.ics]\n\n"
"A theme-native terminal month calendar.\n\n"
" -c, --calendar PATH read events from PATH\n"
" --demo add sample appointments without saving them\n"
" --print print one plain-text frame and exit\n"
" --restore-backup restore PATH.bak over PATH and exit\n"
" --no-color disable ANSI styling\n"
" -h, --help show this help\n"
" -V, --version show the version\n\n"
"Keys: arrows/hjkl move days, PageUp/PageDown or p/n change month,\n"
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n"
" u undoes the last change, Ctrl-R redoes it,\n"
" t jumps to today, ? toggles help, q quits.\n";
}
Options parse_options(int argc, char** argv)
{
Options options{.calendar_path = default_calendar_path()};
bool positional_seen = false;
for (int index = 1; index < argc; ++index) {
const std::string_view argument{argv[index]};
if (argument == "-h" || argument == "--help") {
print_help(std::cout);
std::exit(EXIT_SUCCESS);
}
if (argument == "-V" || argument == "--version") {
std::cout << "nocal " << version << '\n';
std::exit(EXIT_SUCCESS);
}
if (argument == "--demo") {
options.demo = true;
} else if (argument == "--print") {
options.print = true;
} else if (argument == "--restore-backup") {
options.restore_backup = true;
} else if (argument == "--no-color") {
options.no_color = true;
} else if (argument == "-c" || argument == "--calendar") {
if (++index >= argc) {
throw std::invalid_argument(std::string{argument} + " requires a path");
}
options.calendar_path = argv[index];
positional_seen = true;
} else if (!argument.empty() && argument.front() == '-') {
throw std::invalid_argument("unknown option: " + std::string{argument});
} else if (positional_seen) {
throw std::invalid_argument("only one calendar path may be supplied");
} else {
options.calendar_path = argument;
positional_seen = true;
}
}
if (options.restore_backup && options.demo) {
throw std::invalid_argument("--restore-backup cannot be combined with --demo");
}
if (options.restore_backup && options.print) {
throw std::invalid_argument("--restore-backup cannot be combined with --print");
}
return options;
}
nocal::Date shifted(nocal::Date date, int offset)
{
return nocal::from_sys_days(nocal::to_sys_days(date) + std::chrono::days{offset});
}
std::vector<nocal::Event> demo_events()
{
const auto day = nocal::today();
nocal::Event planning;
planning.uid = "demo-planning@nocal";
planning.title = "Nomarchy planning";
planning.start = nocal::make_local_time(day, 9, 30);
planning.end = nocal::make_local_time(day, 10, 15);
planning.location = "Terminal 1";
planning.calendar_id = "work";
nocal::Event release;
release.uid = "demo-release@nocal";
release.title = "Release window";
release.start = nocal::local_day_start(shifted(day, 2));
release.end = nocal::local_day_start(shifted(day, 3));
release.all_day = true;
release.calendar_id = "work";
nocal::Event coffee;
coffee.uid = "demo-coffee@nocal";
coffee.title = "Coffee with Luna";
coffee.start = nocal::make_local_time(shifted(day, -3), 14, 0);
coffee.end = nocal::make_local_time(shifted(day, -3), 14, 45);
coffee.location = "Nomarchy café";
coffee.calendar_id = "personal";
return {std::move(planning), std::move(release), std::move(coffee)};
}
int print_frame(std::span<const nocal::Event> events, bool no_color)
{
const auto day = nocal::today();
const nocal::tui::ScreenState state{
.visible_month = day.year() / day.month(), .selected_day = day, .today = day,
.width = 120, .height = 36, .show_help = false,
.ansi = !no_color && ::isatty(STDOUT_FILENO) != 0,
.focused_event_id = std::nullopt,
.show_event_details = false,
.confirm_delete = false,
.notification = {},
.notification_is_error = false,
};
std::cout << nocal::tui::render_month(events, state) << '\n';
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
}
} // namespace
int main(int argc, char** argv)
{
try {
const auto options = parse_options(argc, argv);
auto loaded = nocal::storage::IcsStore::load(options.calendar_path);
for (const auto& warning : loaded.warnings) {
std::cerr << "nocal: " << warning << '\n';
}
if (options.restore_backup) {
const auto backup = nocal::storage::IcsStore::backup_path(options.calendar_path);
nocal::storage::IcsStore::restore_backup(
options.calendar_path, loaded.revision);
std::cout << "Restored backup " << backup.string() << " to "
<< options.calendar_path.string() << '\n';
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
}
if (options.demo) {
auto samples = demo_events();
loaded.events.insert(loaded.events.end(), samples.begin(), samples.end());
}
const bool non_interactive = ::isatty(STDIN_FILENO) == 0 ||
::isatty(STDOUT_FILENO) == 0;
if (options.print || non_interactive) {
return print_frame(loaded.events, true);
}
if (options.no_color) {
::setenv("NO_COLOR", "1", 1);
}
nocal::tui::TuiApp::SaveCallback saver;
if (!options.demo) {
if (loaded.safe_to_rewrite) {
const auto calendar_path = options.calendar_path;
saver = [calendar_path, revision = loaded.revision](
const std::span<const nocal::Event> events) mutable {
revision = nocal::storage::IcsStore::save(
calendar_path, events, revision);
};
} else {
saver = [](std::span<const nocal::Event>) {
throw std::runtime_error(
"calendar is read-only because it contains unsupported iCalendar data");
};
}
}
nocal::tui::TuiApp app{loaded.events, std::move(saver)};
return app.run() == nocal::tui::ExitReason::io_error ? EXIT_FAILURE : EXIT_SUCCESS;
} catch (const std::exception& error) {
std::cerr << "nocal: " << error.what() << '\n';
return EXIT_FAILURE;
}
}

976
src/storage/ics_store.cpp Normal file
View File

@@ -0,0 +1,976 @@
#include "nocal/storage/ics_store.hpp"
#include <algorithm>
#include <charconv>
#include <chrono>
#include <cctype>
#include <ctime>
#include <fstream>
#include <iterator>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string_view>
#include <system_error>
#if !defined(_WIN32)
#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <unistd.h>
#endif
namespace nocal::storage {
namespace {
using namespace std::chrono;
struct ParsedTime {
TimePoint value;
bool is_date = false;
std::optional<year_month_day> date;
};
struct PendingEvent {
std::optional<std::string> uid;
std::string title;
std::string location;
std::string description;
std::string calendar_id;
std::string color;
std::optional<ParsedTime> start;
std::optional<ParsedTime> end;
};
[[nodiscard]] std::string upper(std::string_view value) {
std::string result(value);
std::transform(result.begin(), result.end(), result.begin(), [](unsigned char ch) {
return static_cast<char>(std::toupper(ch));
});
return result;
}
[[nodiscard]] std::string unescape_text(std::string_view value) {
std::string result;
result.reserve(value.size());
for (std::size_t i = 0; i < value.size(); ++i) {
if (value[i] != '\\' || i + 1 == value.size()) {
result.push_back(value[i]);
continue;
}
const char escaped = value[++i];
if (escaped == 'n' || escaped == 'N') {
result.push_back('\n');
} else {
// RFC 5545 TEXT escapes '\\', ';', and ','. Keeping the escaped
// character is the least surprising recovery for unknown escapes.
result.push_back(escaped);
}
}
return result;
}
[[nodiscard]] std::string escape_text(std::string_view value) {
std::string result;
result.reserve(value.size());
for (std::size_t i = 0; i < value.size(); ++i) {
switch (value[i]) {
case '\\': result += "\\\\"; break;
case ';': result += "\\;"; break;
case ',': result += "\\,"; break;
case '\r':
if (i + 1 < value.size() && value[i + 1] == '\n') {
++i;
}
result += "\\n";
break;
case '\n': result += "\\n"; break;
default: result.push_back(value[i]); break;
}
}
return result;
}
[[nodiscard]] bool parse_fixed_int(
std::string_view value, std::size_t offset, std::size_t count, int& destination) {
if (offset + count > value.size()) {
return false;
}
const char* first = value.data() + offset;
const char* last = first + count;
if (!std::all_of(first, last, [](unsigned char ch) { return std::isdigit(ch); })) {
return false;
}
const auto [ptr, ec] = std::from_chars(first, last, destination);
return ec == std::errc{} && ptr == last;
}
[[nodiscard]] std::optional<year_month_day> parse_ymd(std::string_view value) {
if (value.size() != 8) {
return std::nullopt;
}
int year_value = 0;
int month_value = 0;
int day_value = 0;
if (!parse_fixed_int(value, 0, 4, year_value)
|| !parse_fixed_int(value, 4, 2, month_value)
|| !parse_fixed_int(value, 6, 2, day_value)) {
return std::nullopt;
}
const year_month_day date{
year{year_value}, month{static_cast<unsigned>(month_value)}, day{static_cast<unsigned>(day_value)}};
return date.ok() ? std::optional{date} : std::nullopt;
}
[[nodiscard]] std::optional<TimePoint> make_local(
year_month_day date, int hour_value, int minute_value, int second_value) {
std::tm local{};
local.tm_year = static_cast<int>(date.year()) - 1900;
local.tm_mon = static_cast<int>(static_cast<unsigned>(date.month())) - 1;
local.tm_mday = static_cast<int>(static_cast<unsigned>(date.day()));
local.tm_hour = hour_value;
local.tm_min = minute_value;
local.tm_sec = second_value;
local.tm_isdst = -1;
const std::time_t converted = std::mktime(&local);
// mktime may normalize invalid or nonexistent local times. Accept timezone
// normalization only when the requested civil fields survive unchanged.
if (local.tm_year != static_cast<int>(date.year()) - 1900
|| local.tm_mon != static_cast<int>(static_cast<unsigned>(date.month())) - 1
|| local.tm_mday != static_cast<int>(static_cast<unsigned>(date.day()))
|| local.tm_hour != hour_value || local.tm_min != minute_value
|| local.tm_sec != second_value) {
return std::nullopt;
}
return system_clock::from_time_t(converted);
}
[[nodiscard]] std::optional<ParsedTime> parse_time(
std::string_view parameters, std::string_view value, std::string& warning) {
const std::string normalized_parameters = upper(parameters);
bool value_is_date = false;
bool has_tzid = false;
std::size_t parameter_start = 0;
while (parameter_start <= normalized_parameters.size()) {
const std::size_t parameter_end = normalized_parameters.find(';', parameter_start);
const std::string_view parameter = std::string_view(normalized_parameters).substr(
parameter_start, parameter_end == std::string::npos
? std::string::npos : parameter_end - parameter_start);
value_is_date = value_is_date || parameter == "VALUE=DATE";
has_tzid = has_tzid || parameter.starts_with("TZID=");
if (parameter_end == std::string::npos) {
break;
}
parameter_start = parameter_end + 1;
}
if (value_is_date || (parameters.empty() && value.size() == 8)) {
const auto date = parse_ymd(value);
if (!date) {
warning = "invalid DATE value '" + std::string(value) + "'";
return std::nullopt;
}
const auto local = make_local(*date, 0, 0, 0);
if (!local) {
warning = "DATE does not map to a local midnight: '" + std::string(value) + "'";
return std::nullopt;
}
return ParsedTime{*local, true, *date};
}
if (has_tzid) {
warning = "TZID is not supported; DATE-TIME was interpreted in the process local timezone";
}
const bool utc = !value.empty() && value.back() == 'Z';
const std::size_t expected_size = utc ? 16 : 15;
if (value.size() != expected_size || value[8] != 'T') {
warning = "invalid DATE-TIME value '" + std::string(value) + "'";
return std::nullopt;
}
const auto date = parse_ymd(value.substr(0, 8));
int hour_value = 0;
int minute_value = 0;
int second_value = 0;
if (!date || !parse_fixed_int(value, 9, 2, hour_value)
|| !parse_fixed_int(value, 11, 2, minute_value)
|| !parse_fixed_int(value, 13, 2, second_value)
|| hour_value > 23 || minute_value > 59 || second_value > 60) {
warning = "invalid DATE-TIME value '" + std::string(value) + "'";
return std::nullopt;
}
// POSIX time has no representation for a leap second. Normalize it to the
// first instant of the next minute.
const int represented_second = std::min(second_value, 59);
TimePoint result;
if (utc) {
result = sys_days{*date} + hours{hour_value} + minutes{minute_value}
+ seconds{represented_second};
} else {
const auto local = make_local(*date, hour_value, minute_value, represented_second);
if (!local) {
warning = "DATE-TIME does not exist in the local timezone: '" + std::string(value) + "'";
return std::nullopt;
}
result = *local;
}
if (second_value == 60) {
result += seconds{1};
}
return ParsedTime{result, false, std::nullopt};
}
[[nodiscard]] std::vector<std::string> unfold(std::istream& input) {
std::vector<std::string> lines;
std::string line;
while (std::getline(input, line)) {
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
if (!line.empty() && (line.front() == ' ' || line.front() == '\t')) {
if (!lines.empty()) {
lines.back().append(line.begin() + 1, line.end());
}
} else {
lines.push_back(std::move(line));
}
}
return lines;
}
[[nodiscard]] std::size_t content_separator(std::string_view line) {
bool quoted = false;
for (std::size_t i = 0; i < line.size(); ++i) {
if (line[i] == '"') {
quoted = !quoted;
} else if (line[i] == ':' && !quoted) {
return i;
}
}
return std::string_view::npos;
}
void add_warning(LoadResult& result, std::size_t line, std::string message) {
result.warnings.push_back(
"line " + std::to_string(line) + ": " + std::move(message));
}
void mark_unsafe_to_rewrite(LoadResult& result) {
if (result.safe_to_rewrite) {
result.safe_to_rewrite = false;
result.warnings.emplace_back(
"calendar contains data nocal cannot preserve; editing is disabled to prevent data loss");
}
}
[[nodiscard]] bool supported_time_parameters(std::string_view parameters) {
const std::string normalized = upper(parameters);
return normalized.empty() || normalized == "VALUE=DATE";
}
[[nodiscard]] std::tm utc_tm(std::time_t time) {
std::tm result{};
#if defined(_WIN32)
gmtime_s(&result, &time);
#else
gmtime_r(&time, &result);
#endif
return result;
}
[[nodiscard]] std::tm local_tm(std::time_t time) {
std::tm result{};
#if defined(_WIN32)
localtime_s(&result, &time);
#else
localtime_r(&time, &result);
#endif
return result;
}
[[nodiscard]] std::string two_digits(int value) {
std::string result(2, '0');
result[0] = static_cast<char>('0' + (value / 10) % 10);
result[1] = static_cast<char>('0' + value % 10);
return result;
}
[[nodiscard]] std::string four_digits(int value) {
std::string result(4, '0');
for (int index = 3; index >= 0; --index) {
result[static_cast<std::size_t>(index)] = static_cast<char>('0' + value % 10);
value /= 10;
}
return result;
}
[[nodiscard]] std::string format_date(TimePoint value) {
const std::tm local = local_tm(system_clock::to_time_t(value));
return four_digits(local.tm_year + 1900) + two_digits(local.tm_mon + 1)
+ two_digits(local.tm_mday);
}
[[nodiscard]] std::string format_utc_time(TimePoint value) {
const auto whole_seconds = floor<seconds>(value);
const std::tm utc = utc_tm(system_clock::to_time_t(whole_seconds));
return four_digits(utc.tm_year + 1900) + two_digits(utc.tm_mon + 1)
+ two_digits(utc.tm_mday) + "T" + two_digits(utc.tm_hour)
+ two_digits(utc.tm_min) + two_digits(utc.tm_sec) + "Z";
}
[[nodiscard]] std::size_t utf8_boundary(std::string_view value, std::size_t begin, std::size_t limit) {
std::size_t end = std::min(value.size(), begin + limit);
while (end > begin && end < value.size()
&& (static_cast<unsigned char>(value[end]) & 0xC0U) == 0x80U) {
--end;
}
return end == begin ? std::min(value.size(), begin + limit) : end;
}
void write_folded(std::ostream& output, std::string_view content_line) {
std::size_t offset = 0;
bool first = true;
do {
const std::size_t available = first ? 75 : 74;
const std::size_t end = utf8_boundary(content_line, offset, available);
if (!first) {
output.put(' ');
}
output.write(content_line.data() + offset, static_cast<std::streamsize>(end - offset));
output << "\r\n";
offset = end;
first = false;
} while (offset < content_line.size());
}
void write_text_property(std::ostream& output, std::string_view name, std::string_view value) {
if (!value.empty()) {
write_folded(output, std::string(name) + ":" + escape_text(value));
}
}
void serialize_calendar(std::ostream& output, std::span<const Event> events) {
write_folded(output, "BEGIN:VCALENDAR");
write_folded(output, "VERSION:2.0");
write_folded(output, "PRODID:-//Nomarchy Linux//nocal 1.0//EN");
write_folded(output, "CALSCALE:GREGORIAN");
for (const Event& event : events) {
write_folded(output, "BEGIN:VEVENT");
write_folded(output, "UID:" + escape_text(event.uid));
if (event.all_day) {
write_folded(output, "DTSTART;VALUE=DATE:" + format_date(event.start));
write_folded(output, "DTEND;VALUE=DATE:" + format_date(event.end));
} else {
write_folded(output, "DTSTART:" + format_utc_time(event.start));
write_folded(output, "DTEND:" + format_utc_time(event.end));
}
write_text_property(output, "SUMMARY", event.title);
write_text_property(output, "LOCATION", event.location);
write_text_property(output, "DESCRIPTION", event.description);
write_text_property(output, "X-NOCAL-CALENDAR-ID", event.calendar_id);
write_text_property(output, "X-NOCAL-COLOR", event.color);
write_folded(output, "END:VEVENT");
}
write_folded(output, "END:VCALENDAR");
}
struct FileSnapshot {
bool existed = false;
std::string bytes;
};
[[nodiscard]] bool same_file_state(
bool expected_existed, const std::string* expected_bytes,
const FileSnapshot& current) noexcept {
if (expected_existed != current.existed) {
return false;
}
if (!expected_existed) {
return true;
}
return expected_bytes != nullptr && *expected_bytes == current.bytes;
}
#if !defined(_WIN32)
[[noreturn]] void throw_io_error(std::string_view operation, const std::filesystem::path& path) {
const int error = errno;
throw std::runtime_error(
std::string(operation) + " '" + path.string() + "': " + std::strerror(error));
}
class FileDescriptor {
public:
explicit FileDescriptor(int descriptor = -1) noexcept : descriptor_(descriptor) {}
FileDescriptor(const FileDescriptor&) = delete;
FileDescriptor& operator=(const FileDescriptor&) = delete;
FileDescriptor(FileDescriptor&& other) noexcept : descriptor_(other.release()) {}
~FileDescriptor() {
if (descriptor_ >= 0) {
::close(descriptor_);
}
}
[[nodiscard]] int get() const noexcept { return descriptor_; }
int release() noexcept {
const int result = descriptor_;
descriptor_ = -1;
return result;
}
private:
int descriptor_;
};
class TemporaryFile {
public:
TemporaryFile(std::filesystem::path path, int descriptor)
: path_(std::move(path)), descriptor_(descriptor) {}
TemporaryFile(const TemporaryFile&) = delete;
TemporaryFile& operator=(const TemporaryFile&) = delete;
TemporaryFile(TemporaryFile&& other) noexcept
: path_(std::move(other.path_)), descriptor_(std::move(other.descriptor_)),
remove_on_destroy_(other.remove_on_destroy_) {
other.remove_on_destroy_ = false;
}
~TemporaryFile() {
if (descriptor_.get() >= 0) {
::close(descriptor_.release());
}
if (remove_on_destroy_) {
std::error_code ignored;
std::filesystem::remove(path_, ignored);
}
}
[[nodiscard]] int descriptor() const noexcept { return descriptor_.get(); }
[[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; }
void close_checked() {
const int descriptor = descriptor_.release();
if (::close(descriptor) != 0) {
throw_io_error("unable to close temporary calendar", path_);
}
}
void committed() noexcept { remove_on_destroy_ = false; }
private:
std::filesystem::path path_;
FileDescriptor descriptor_;
bool remove_on_destroy_ = true;
};
[[nodiscard]] FileSnapshot read_regular_file(
const std::filesystem::path& path, std::string_view purpose) {
const int descriptor = ::open(path.c_str(), O_RDONLY | O_CLOEXEC);
if (descriptor < 0) {
if (errno == ENOENT) {
return {};
}
throw_io_error(purpose, path);
}
const FileDescriptor file(descriptor);
struct stat metadata {};
if (::fstat(file.get(), &metadata) != 0) {
throw_io_error(purpose, path);
}
if (!S_ISREG(metadata.st_mode)) {
throw std::runtime_error(
std::string(purpose) + " '" + path.string() + "': not a regular file");
}
FileSnapshot snapshot;
snapshot.existed = true;
if (metadata.st_size > 0) {
snapshot.bytes.reserve(static_cast<std::size_t>(metadata.st_size));
}
char buffer[16 * 1024];
while (true) {
const ssize_t count = ::read(file.get(), buffer, sizeof(buffer));
if (count < 0) {
if (errno == EINTR) {
continue;
}
throw_io_error(purpose, path);
}
if (count == 0) {
break;
}
snapshot.bytes.append(buffer, static_cast<std::size_t>(count));
}
return snapshot;
}
[[nodiscard]] FileDescriptor lock_destination(const std::filesystem::path& path) {
std::filesystem::path lock_path = path;
lock_path += ".lock";
const int descriptor = ::open(lock_path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, S_IRUSR | S_IWUSR);
if (descriptor < 0) {
throw_io_error("unable to open calendar lock", lock_path);
}
FileDescriptor lock(descriptor);
while (::flock(lock.get(), LOCK_EX) != 0) {
if (errno != EINTR) {
throw_io_error("unable to lock calendar", lock_path);
}
}
return lock;
}
[[nodiscard]] TemporaryFile create_temporary_file(
const std::filesystem::path& directory, const std::filesystem::path& destination) {
const std::string pattern_string =
(directory / ("." + destination.filename().string() + ".tmp.XXXXXX")).string();
std::vector<char> pattern(pattern_string.begin(), pattern_string.end());
pattern.push_back('\0');
const int descriptor = ::mkstemp(pattern.data());
if (descriptor < 0) {
throw_io_error("unable to create temporary calendar", destination);
}
TemporaryFile temporary{std::filesystem::path{pattern.data()}, descriptor};
const int flags = ::fcntl(descriptor, F_GETFD);
if (flags < 0 || ::fcntl(descriptor, F_SETFD, flags | FD_CLOEXEC) != 0) {
throw_io_error("unable to secure temporary calendar", temporary.path());
}
if (::fchmod(descriptor, S_IRUSR | S_IWUSR) != 0) {
throw_io_error("unable to set temporary calendar permissions", temporary.path());
}
return temporary;
}
void write_all(int descriptor, std::string_view bytes, const std::filesystem::path& path) {
std::size_t offset = 0;
while (offset < bytes.size()) {
const ssize_t written = ::write(descriptor, bytes.data() + offset, bytes.size() - offset);
if (written < 0) {
if (errno == EINTR) {
continue;
}
throw_io_error("unable to write temporary calendar", path);
}
if (written == 0) {
throw std::runtime_error("unable to write temporary calendar '" + path.string()
+ "': write made no progress");
}
offset += static_cast<std::size_t>(written);
}
}
[[nodiscard]] TemporaryFile stage_file(
const std::filesystem::path& directory, const std::filesystem::path& destination,
std::string_view bytes) {
TemporaryFile temporary = create_temporary_file(directory, destination);
write_all(temporary.descriptor(), bytes, temporary.path());
if (::fsync(temporary.descriptor()) != 0) {
throw_io_error("unable to flush temporary calendar", temporary.path());
}
temporary.close_checked();
return temporary;
}
[[nodiscard]] FileDescriptor open_directory(const std::filesystem::path& directory) {
const int directory_descriptor = ::open(directory.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (directory_descriptor < 0) {
throw_io_error("unable to open calendar directory", directory);
}
return FileDescriptor(directory_descriptor);
}
void replace_with_temporary(
TemporaryFile& temporary, const std::filesystem::path& destination,
std::string_view operation) {
if (::rename(temporary.path().c_str(), destination.c_str()) != 0) {
throw_io_error(operation, destination);
}
temporary.committed();
}
#else
[[nodiscard]] FileSnapshot read_regular_file(
const std::filesystem::path& path, std::string_view purpose) {
std::error_code error;
if (!std::filesystem::exists(path, error)) {
if (!error) {
return {};
}
throw std::runtime_error(
std::string(purpose) + " '" + path.string() + "': " + error.message());
}
if (!std::filesystem::is_regular_file(path, error) || error) {
throw std::runtime_error(
std::string(purpose) + " '" + path.string() + "': not a regular file");
}
std::ifstream input(path, std::ios::binary);
if (!input) {
throw std::runtime_error(std::string(purpose) + " '" + path.string() + "'");
}
FileSnapshot snapshot;
snapshot.existed = true;
snapshot.bytes.assign(std::istreambuf_iterator<char>{input}, {});
if (!input.eof()) {
throw std::runtime_error(std::string(purpose) + " '" + path.string() + "'");
}
return snapshot;
}
#endif
[[noreturn]] void throw_external_change(const std::filesystem::path& path) {
throw std::runtime_error(
"calendar changed externally; reload before saving '" + path.string() + "'");
}
[[nodiscard]] FileSnapshot read_current_file(
const std::filesystem::path& path, bool has_expected_revision) {
try {
return read_regular_file(path, "unable to read current calendar");
} catch (const std::runtime_error&) {
// A file that became unreadable or changed type cannot match an exact
// revision. Keep the optimistic-concurrency diagnostic actionable.
if (has_expected_revision) {
throw_external_change(path);
}
throw;
}
}
} // namespace
LoadResult IcsStore::load(const std::filesystem::path& path) {
LoadResult result;
FileSnapshot snapshot;
try {
snapshot = read_regular_file(path, "unable to read calendar");
} catch (const std::runtime_error&) {
std::error_code error;
result.revision.existed_ = std::filesystem::exists(path, error) && !error;
result.warnings.push_back("unable to open '" + path.string() + "' for reading");
result.safe_to_rewrite = false;
return result;
}
result.revision.existed_ = snapshot.existed;
if (!snapshot.existed) {
return result;
}
result.revision.contents_ = std::make_shared<const std::string>(snapshot.bytes);
std::istringstream input(snapshot.bytes, std::ios::in | std::ios::binary);
const auto lines = unfold(input);
std::optional<PendingEvent> pending;
std::size_t event_start_line = 0;
for (std::size_t index = 0; index < lines.size(); ++index) {
const std::string_view line = lines[index];
const std::size_t separator = content_separator(line);
if (separator == std::string_view::npos) {
if (pending) {
add_warning(result, index + 1, "ignored malformed content line");
}
if (!line.empty()) {
mark_unsafe_to_rewrite(result);
}
continue;
}
const std::string_view head = line.substr(0, separator);
const std::string_view value = line.substr(separator + 1);
const std::size_t semicolon = head.find(';');
const std::string name = upper(head.substr(0, semicolon));
const std::string_view parameters = semicolon == std::string_view::npos
? std::string_view{} : head.substr(semicolon + 1);
if (name == "BEGIN" && upper(value) == "VEVENT") {
if (pending) {
add_warning(result, event_start_line, "nested VEVENT; discarded incomplete event");
mark_unsafe_to_rewrite(result);
}
pending.emplace();
event_start_line = index + 1;
continue;
}
if (name == "END" && upper(value) == "VEVENT") {
if (!pending) {
add_warning(result, index + 1, "END:VEVENT without BEGIN:VEVENT");
mark_unsafe_to_rewrite(result);
continue;
}
if (!pending->uid || pending->uid->empty()) {
add_warning(result, event_start_line, "VEVENT has no UID and was skipped");
mark_unsafe_to_rewrite(result);
} else if (!pending->start) {
add_warning(result, event_start_line, "VEVENT has no valid DTSTART and was skipped");
mark_unsafe_to_rewrite(result);
} else if (pending->end && pending->start->is_date != pending->end->is_date) {
add_warning(result, event_start_line, "DTSTART and DTEND value types differ; event was skipped");
mark_unsafe_to_rewrite(result);
} else {
const bool all_day = pending->start->is_date;
TimePoint end = pending->start->value;
if (pending->end) {
end = pending->end->value;
} else if (all_day) {
const auto next_date = year_month_day{
sys_days{*pending->start->date} + days{1}};
const auto next_midnight = make_local(next_date, 0, 0, 0);
if (!next_midnight) {
add_warning(result, event_start_line,
"could not determine implicit all-day DTEND; event was skipped");
mark_unsafe_to_rewrite(result);
pending.reset();
continue;
}
end = *next_midnight;
}
if (end < pending->start->value || (all_day && end == pending->start->value)) {
add_warning(result, event_start_line, "DTEND is before DTSTART; event was skipped");
mark_unsafe_to_rewrite(result);
} else {
Event event;
event.uid = std::move(*pending->uid);
event.title = std::move(pending->title);
event.start = pending->start->value;
event.end = end;
event.all_day = all_day;
event.location = std::move(pending->location);
event.description = std::move(pending->description);
event.calendar_id = std::move(pending->calendar_id);
event.color = std::move(pending->color);
result.events.push_back(std::move(event));
}
}
pending.reset();
continue;
}
if (!pending) {
const std::string normalized_value = upper(value);
const bool structural = (name == "BEGIN" && normalized_value == "VCALENDAR")
|| (name == "END" && normalized_value == "VCALENDAR");
const bool supported_property = name == "VERSION" || name == "PRODID"
|| name == "CALSCALE";
if ((!structural && !supported_property)
|| (supported_property && !parameters.empty())) {
mark_unsafe_to_rewrite(result);
}
continue;
}
const bool time_property = name == "DTSTART" || name == "DTEND";
const bool text_property = name == "UID" || name == "SUMMARY" || name == "LOCATION"
|| name == "DESCRIPTION" || name == "X-NOCAL-CALENDAR-ID"
|| name == "X-NOCAL-COLOR";
if ((!time_property && !text_property)
|| (time_property && !supported_time_parameters(parameters))
|| (text_property && !parameters.empty())) {
mark_unsafe_to_rewrite(result);
}
if (name == "UID") {
pending->uid = unescape_text(value);
} else if (name == "SUMMARY") {
pending->title = unescape_text(value);
} else if (name == "LOCATION") {
pending->location = unescape_text(value);
} else if (name == "DESCRIPTION") {
pending->description = unescape_text(value);
} else if (name == "X-NOCAL-CALENDAR-ID") {
pending->calendar_id = unescape_text(value);
} else if (name == "X-NOCAL-COLOR") {
pending->color = unescape_text(value);
} else if (name == "DTSTART" || name == "DTEND") {
std::string warning;
auto parsed = parse_time(parameters, value, warning);
if (!parsed) {
add_warning(result, index + 1, std::move(warning));
mark_unsafe_to_rewrite(result);
} else {
if (!warning.empty()) {
add_warning(result, index + 1, std::move(warning));
}
if (name == "DTSTART") {
pending->start = *parsed;
} else {
pending->end = *parsed;
}
}
}
}
if (pending) {
add_warning(result, event_start_line, "unterminated VEVENT was skipped");
mark_unsafe_to_rewrite(result);
}
return result;
}
std::filesystem::path IcsStore::backup_path(const std::filesystem::path& path) {
std::filesystem::path backup = path;
backup += ".bak";
return backup;
}
FileRevision IcsStore::save(
const std::filesystem::path& path, std::span<const Event> events,
std::optional<FileRevision> expected) {
if (path.empty() || path.filename().empty()) {
throw std::invalid_argument("calendar path must name a file");
}
for (const Event& event : events) {
if (event.uid.empty()) {
throw std::invalid_argument("cannot save an event with an empty UID");
}
if (event.end < event.start || (event.all_day && event.end == event.start)) {
throw std::invalid_argument("cannot save event '" + event.uid + "' with an invalid interval");
}
}
std::ostringstream serialized(std::ios::out | std::ios::binary);
serialize_calendar(serialized, events);
if (!serialized) {
throw std::runtime_error("unable to serialize calendar '" + path.string() + "'");
}
std::string bytes = serialized.str();
const std::filesystem::path directory =
path.parent_path().empty() ? std::filesystem::path{"."} : path.parent_path();
std::error_code directory_error;
std::filesystem::create_directories(directory, directory_error);
if (directory_error) {
throw std::runtime_error("unable to create calendar directory '" + directory.string()
+ "': " + directory_error.message());
}
#if !defined(_WIN32)
const FileDescriptor lock = lock_destination(path);
const FileSnapshot current = read_current_file(path, expected.has_value());
if (expected && !same_file_state(
expected->existed_, expected->contents_.get(), current)) {
throw_external_change(path);
}
// Prepare and flush every byte before changing either destination.
TemporaryFile calendar_temporary = stage_file(directory, path, bytes);
std::optional<TemporaryFile> backup_temporary;
const auto backup = backup_path(path);
if (current.existed) {
backup_temporary.emplace(stage_file(directory, backup, current.bytes));
}
const FileDescriptor directory_handle = open_directory(directory);
const FileSnapshot rechecked = read_current_file(path, true);
if (!same_file_state(current.existed, &current.bytes, rechecked)) {
throw_external_change(path);
}
// The advisory lock fully serializes Nocal writers. A program that ignores
// it can still replace the path after this comparison and before rename;
// portable POSIX rename does not provide a true compare-and-swap primitive.
if (backup_temporary) {
replace_with_temporary(*backup_temporary, backup, "unable to replace calendar backup");
(void)::fsync(directory_handle.get());
}
replace_with_temporary(calendar_temporary, path, "unable to replace calendar");
// The calendar rename is the commit point. Reporting a later directory
// fsync failure as an unchanged save would make caller rollback incorrect.
(void)::fsync(directory_handle.get());
#else
const FileSnapshot current = read_current_file(path, expected.has_value());
if (expected && !same_file_state(
expected->existed_, expected->contents_.get(), current)) {
throw_external_change(path);
}
const auto write_replacement = [](const std::filesystem::path& destination,
std::string_view replacement) {
std::filesystem::path temporary = destination;
temporary += ".tmp";
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
if (!output) {
throw std::runtime_error("unable to open temporary file '" + temporary.string() + "'");
}
output.write(replacement.data(), static_cast<std::streamsize>(replacement.size()));
output.flush();
if (!output) {
throw std::runtime_error("failed while writing temporary file '" + temporary.string() + "'");
}
output.close();
std::error_code ignored;
std::filesystem::remove(destination, ignored);
std::filesystem::rename(temporary, destination);
};
if (current.existed) {
write_replacement(backup_path(path), current.bytes);
}
write_replacement(path, bytes);
#endif
FileRevision revision;
revision.existed_ = true;
revision.contents_ = std::make_shared<const std::string>(std::move(bytes));
return revision;
}
FileRevision IcsStore::restore_backup(
const std::filesystem::path& path, std::optional<FileRevision> expected) {
if (path.empty() || path.filename().empty()) {
throw std::invalid_argument("calendar path must name a file");
}
const std::filesystem::path directory =
path.parent_path().empty() ? std::filesystem::path{"."} : path.parent_path();
const auto backup = backup_path(path);
#if !defined(_WIN32)
const FileDescriptor lock = lock_destination(path);
const FileSnapshot current = read_current_file(path, expected.has_value());
if (expected && !same_file_state(
expected->existed_, expected->contents_.get(), current)) {
throw_external_change(path);
}
const FileSnapshot saved = read_regular_file(backup, "unable to read calendar backup");
if (!saved.existed) {
throw std::runtime_error("calendar backup does not exist '" + backup.string() + "'");
}
TemporaryFile temporary = stage_file(directory, path, saved.bytes);
const FileDescriptor directory_handle = open_directory(directory);
const FileSnapshot rechecked = read_current_file(path, true);
if (!same_file_state(current.existed, &current.bytes, rechecked)) {
throw_external_change(path);
}
replace_with_temporary(temporary, path, "unable to restore calendar backup");
(void)::fsync(directory_handle.get());
#else
const FileSnapshot current = read_current_file(path, expected.has_value());
if (expected && !same_file_state(
expected->existed_, expected->contents_.get(), current)) {
throw_external_change(path);
}
const FileSnapshot saved = read_regular_file(backup, "unable to read calendar backup");
if (!saved.existed) {
throw std::runtime_error("calendar backup does not exist '" + backup.string() + "'");
}
std::ofstream output(path, std::ios::binary | std::ios::trunc);
if (!output) {
throw std::runtime_error("unable to open '" + path.string() + "' for restoring");
}
output.write(saved.bytes.data(), static_cast<std::streamsize>(saved.bytes.size()));
output.flush();
if (!output) {
throw std::runtime_error("failed while restoring '" + path.string() + "'");
}
#endif
FileRevision revision;
revision.existed_ = true;
revision.contents_ = std::make_shared<const std::string>(saved.bytes);
return revision;
}
} // namespace nocal::storage

459
src/tui/EventEditor.cpp Normal file
View File

@@ -0,0 +1,459 @@
#include "nocal/tui/EventEditor.hpp"
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <ctime>
#include <random>
#include <sstream>
#include <stdexcept>
#include <utility>
#include <vector>
namespace nocal::tui {
namespace {
constexpr std::size_t field_count = static_cast<std::size_t>(EditorField::count);
constexpr std::array<std::string_view, field_count> labels{
"Title", "Start date", "Start time", "End date", "End time", "All day", "Location", "Notes",
};
std::size_t index_of(const EditorField field) noexcept
{
return static_cast<std::size_t>(field);
}
std::string make_uid()
{
static std::atomic<std::uint64_t> sequence{0};
static const std::uint64_t seed = [] {
std::random_device random;
const auto high = static_cast<std::uint64_t>(random()) << 32U;
return high ^ static_cast<std::uint64_t>(random());
}();
const auto now = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
std::ostringstream stream;
stream << "nocal-" << std::hex << static_cast<std::uint64_t>(now) << '-'
<< (seed ^ sequence.fetch_add(1, std::memory_order_relaxed)) << "@local";
return stream.str();
}
std::string two_digits(const unsigned value)
{
std::array<char, 3> buffer{};
std::snprintf(buffer.data(), buffer.size(), "%02u", value);
return {buffer.data(), 2};
}
std::pair<unsigned, unsigned> local_hour_minute(const TimePoint point)
{
const auto time = Clock::to_time_t(point);
std::tm value{};
#if defined(_WIN32)
if (::localtime_s(&value, &time) != 0) {
throw std::runtime_error("could not convert event time");
}
#else
if (::localtime_r(&time, &value) == nullptr) {
throw std::runtime_error("could not convert event time");
}
#endif
return {static_cast<unsigned>(value.tm_hour), static_cast<unsigned>(value.tm_min)};
}
std::string format_time(const TimePoint point)
{
const auto [hour, minute] = local_hour_minute(point);
return two_digits(hour) + ':' + two_digits(minute);
}
bool parse_time(const std::string_view text, unsigned& hour, unsigned& minute) noexcept
{
if (text.size() != 5 || text[2] != ':' || text[0] < '0' || text[0] > '9' ||
text[1] < '0' || text[1] > '9' || text[3] < '0' || text[3] > '9' ||
text[4] < '0' || text[4] > '9') {
return false;
}
hour = static_cast<unsigned>((text[0] - '0') * 10 + text[1] - '0');
minute = static_cast<unsigned>((text[3] - '0') * 10 + text[4] - '0');
return hour < 24 && minute < 60;
}
void erase_last_codepoint(std::string& text)
{
if (text.empty()) {
return;
}
auto at = text.size() - 1;
while (at > 0 && (static_cast<unsigned char>(text[at]) & 0xc0U) == 0x80U) {
--at;
}
text.erase(at);
}
bool printable_input(const std::string_view key) noexcept
{
if (key.empty() || key.front() == '\x1b') {
return false;
}
return std::all_of(key.begin(), key.end(), [](const char character) {
const auto byte = static_cast<unsigned char>(character);
return byte >= 0x20U && byte != 0x7fU;
});
}
std::string single_line(std::string_view value)
{
std::string result{value};
std::replace(result.begin(), result.end(), '\n', ' ');
std::replace(result.begin(), result.end(), '\r', ' ');
return result;
}
struct DecodedCodepoint {
std::uint32_t value;
std::size_t length;
};
DecodedCodepoint decode_codepoint(const std::string_view text, const std::size_t at) noexcept
{
const auto first = static_cast<unsigned char>(text[at]);
if (first < 0x80U) return {first, 1};
std::uint32_t value = 0;
std::size_t length = 1;
if ((first & 0xe0U) == 0xc0U) {
value = first & 0x1fU;
length = 2;
} else if ((first & 0xf0U) == 0xe0U) {
value = first & 0x0fU;
length = 3;
} else if ((first & 0xf8U) == 0xf0U) {
value = first & 0x07U;
length = 4;
} else {
return {0xfffdU, 1};
}
if (at + length > text.size()) return {0xfffdU, 1};
for (std::size_t offset = 1; offset < length; ++offset) {
const auto byte = static_cast<unsigned char>(text[at + offset]);
if ((byte & 0xc0U) != 0x80U) return {0xfffdU, 1};
value = (value << 6U) | (byte & 0x3fU);
}
return {value, length};
}
int codepoint_width(const std::uint32_t value) noexcept
{
if (value == 0 || (value >= 0x0300U && value <= 0x036fU) ||
(value >= 0x1ab0U && value <= 0x1affU) ||
(value >= 0x1dc0U && value <= 0x1dffU) ||
(value >= 0x20d0U && value <= 0x20ffU) ||
(value >= 0xfe00U && value <= 0xfe0fU) ||
(value >= 0xfe20U && value <= 0xfe2fU)) {
return 0;
}
if ((value >= 0x1100U && value <= 0x115fU) || value == 0x2329U || value == 0x232aU ||
(value >= 0x2e80U && value <= 0xa4cfU) ||
(value >= 0xac00U && value <= 0xd7a3U) ||
(value >= 0xf900U && value <= 0xfaffU) ||
(value >= 0xfe10U && value <= 0xfe19U) ||
(value >= 0xfe30U && value <= 0xfe6fU) ||
(value >= 0xff00U && value <= 0xff60U) ||
(value >= 0xffe0U && value <= 0xffe6U) ||
(value >= 0x2600U && value <= 0x27bfU) ||
(value >= 0x1f300U && value <= 0x1faffU) ||
(value >= 0x20000U && value <= 0x3fffdU)) {
return 2;
}
return 1;
}
std::string fit(std::string_view text, const int width)
{
if (width <= 0) return {};
std::string result;
int used = 0;
for (std::size_t at = 0; at < text.size();) {
const auto decoded = decode_codepoint(text, at);
const auto cell_width = codepoint_width(decoded.value);
if (used + cell_width > width) break;
result.append(text.substr(at, decoded.length));
at += decoded.length;
used += cell_width;
}
result.append(static_cast<std::size_t>(width - used), ' ');
return result;
}
std::string style(std::string text, const std::string_view code, const bool ansi)
{
if (!ansi || text.empty()) return text;
return "\x1b[" + std::string{code} + 'm' + std::move(text) + "\x1b[0m";
}
std::string interior_line(std::string content, const int width)
{
if (width <= 0) return {};
if (width == 1) return "";
return "" + fit(content, width - 2) + "";
}
std::string styled_interior_line(std::string content, const int width,
const std::string_view code, const bool ansi)
{
if (width <= 0) return {};
if (width == 1) return "";
return "" + style(fit(content, width - 2), code, ansi) + "";
}
std::string horizontal_line(const int width, const std::string_view left,
const std::string_view fill, const std::string_view right)
{
if (width <= 0) return {};
if (width == 1) return std::string{left};
std::string result{left};
for (int column = 0; column < width - 2; ++column) result += fill;
result += right;
return result;
}
} // namespace
EventEditor::EventEditor(const Date selected_day)
{
if (!selected_day.ok()) {
throw std::invalid_argument("event editor requires a valid selected date");
}
original_.uid = make_uid();
result_ = original_;
values_[index_of(EditorField::start_date)] = format_date(selected_day);
values_[index_of(EditorField::start_time)] = "09:00";
values_[index_of(EditorField::end_date)] = format_date(selected_day);
values_[index_of(EditorField::end_time)] = "10:00";
}
EventEditor::EventEditor(const Event& event)
: original_{event}, result_{event}, all_day_{event.all_day}, editing_{true}
{
values_[index_of(EditorField::title)] = event.title;
values_[index_of(EditorField::start_date)] = format_date(local_date(event.start));
values_[index_of(EditorField::start_time)] = format_time(event.start);
values_[index_of(EditorField::end_date)] = format_date(local_date(event.end));
values_[index_of(EditorField::end_time)] = format_time(event.end);
values_[index_of(EditorField::location)] = event.location;
values_[index_of(EditorField::notes)] = event.description;
}
std::string_view EventEditor::field_value(const EditorField field) const noexcept
{
if (field == EditorField::all_day || field == EditorField::count) return {};
return values_[index_of(field)];
}
void EventEditor::move_focus(const int direction) noexcept
{
const auto current = static_cast<int>(index_of(focused_));
const auto count = static_cast<int>(field_count);
focused_ = static_cast<EditorField>((current + direction + count) % count);
}
EditorOutcome EventEditor::handle_key(const std::string_view key)
{
if (key == "\x1b") return EditorOutcome::cancel;
if (key == "\x13") return validate() ? EditorOutcome::submit : EditorOutcome::active;
if (key == "\t" || key == "\x1b[B" || key == "\x1b[C") {
move_focus(1);
return EditorOutcome::active;
}
if (key == "\x1b[Z" || key == "\x1b[A" || key == "\x1b[D") {
move_focus(-1);
return EditorOutcome::active;
}
if (key == "\r" || key == "\n") {
if (focused_ == EditorField::notes) {
values_[index_of(focused_)] += '\n';
} else {
move_focus(1);
}
return EditorOutcome::active;
}
if (focused_ == EditorField::all_day) {
if (key == " ") {
all_day_ = !all_day_;
if (all_day_) {
try {
const auto start = parse_date(values_[index_of(EditorField::start_date)]);
const auto end = parse_date(values_[index_of(EditorField::end_date)]);
if (start == end) {
values_[index_of(EditorField::end_date)] =
format_date(from_sys_days(to_sys_days(start) + std::chrono::days{1}));
}
} catch (const std::exception&) {
// Leave malformed input untouched; submit validation will
// focus it and provide the specific correction message.
}
}
}
return EditorOutcome::active;
}
auto& value = values_[index_of(focused_)];
if (key == "\x7f" || key == "\b") {
erase_last_codepoint(value);
error_.clear();
} else if (printable_input(key)) {
value.append(key);
error_.clear();
}
return EditorOutcome::active;
}
bool EventEditor::validate()
{
const auto fail = [this](const EditorField field, std::string message) {
focused_ = field;
error_ = std::move(message);
return false;
};
const auto& title = values_[index_of(EditorField::title)];
if (title.find_first_not_of(" \t\r\n") == std::string::npos) {
return fail(EditorField::title, "Title is required.");
}
Date start_day;
Date end_day;
try {
start_day = parse_date(values_[index_of(EditorField::start_date)]);
} catch (const std::exception&) {
return fail(EditorField::start_date, "Start date must be a valid YYYY-MM-DD date.");
}
try {
end_day = parse_date(values_[index_of(EditorField::end_date)]);
} catch (const std::exception&) {
return fail(EditorField::end_date, "End date must be a valid YYYY-MM-DD date.");
}
Event candidate = original_;
if (candidate.uid.empty()) candidate.uid = make_uid();
candidate.title = title;
candidate.location = values_[index_of(EditorField::location)];
candidate.description = values_[index_of(EditorField::notes)];
candidate.all_day = all_day_;
try {
if (all_day_) {
if (to_sys_days(end_day) <= to_sys_days(start_day)) {
return fail(EditorField::end_date,
"All-day end is exclusive and must be at least the next day.");
}
candidate.start = local_day_start(start_day);
candidate.end = local_day_start(end_day);
} else {
unsigned start_hour = 0;
unsigned start_minute = 0;
unsigned end_hour = 0;
unsigned end_minute = 0;
if (!parse_time(values_[index_of(EditorField::start_time)], start_hour, start_minute)) {
return fail(EditorField::start_time, "Start time must be a valid HH:MM time.");
}
if (!parse_time(values_[index_of(EditorField::end_time)], end_hour, end_minute)) {
return fail(EditorField::end_time, "End time must be a valid HH:MM time.");
}
candidate.start = make_local_time(start_day, start_hour, start_minute);
candidate.end = make_local_time(end_day, end_hour, end_minute);
if (candidate.end <= candidate.start) {
return fail(EditorField::end_time, "End must be after start.");
}
}
} catch (const std::exception&) {
return fail(EditorField::start_date,
"That local date and time cannot be represented in this time zone.");
}
result_ = std::move(candidate);
error_.clear();
return true;
}
std::string EventEditor::render(const int requested_width, const int requested_height,
const bool ansi) const
{
const int width = std::max(0, requested_width);
const int height = std::max(0, requested_height);
if (height == 0) return {};
const auto top = horizontal_line(width, "", "", "");
const auto rule = horizontal_line(width, "", "", "");
const auto bottom = horizontal_line(width, "", "", "");
std::vector<std::string> lines;
lines.reserve(16);
lines.push_back(top);
lines.push_back(styled_interior_line(editing_ ? " NOCAL · EDIT APPOINTMENT"
: " NOCAL · NEW APPOINTMENT",
width, "1", ansi));
lines.push_back(styled_interior_line(" Tab/↓ next Shift-Tab/↑ back Space toggle",
width, "2", ansi));
lines.push_back(rule);
for (std::size_t index = 0; index < field_count; ++index) {
const auto field = static_cast<EditorField>(index);
const bool selected = focused_ == field;
std::string value;
if (field == EditorField::all_day) {
value = all_day_ ? "[x] spans whole days (end date is exclusive)"
: "[ ] timed appointment";
} else if (all_day_ && (field == EditorField::start_time || field == EditorField::end_time)) {
value = "— not used for all-day appointments —";
} else {
value = single_line(values_[index]);
if (selected) value += "_";
}
std::string row = selected ? " " : " ";
row += fit(labels[index], 11);
row += " ";
row += value;
lines.push_back(styled_interior_line(std::move(row), width, selected ? "7" : "", ansi));
}
lines.push_back(rule);
lines.push_back(interior_line(error_.empty() ? " Ready" : " ! " + error_, width));
lines.push_back(styled_interior_line(" Ctrl-S save Esc cancel", width, "1", ansi));
lines.push_back(bottom);
// Keep the active field visible on short terminals by selecting a window
// from the complete form, while retaining a top and bottom frame.
if (static_cast<int>(lines.size()) > height) {
std::vector<std::string> compact;
compact.reserve(static_cast<std::size_t>(height));
compact.push_back(top);
if (height > 2) {
const int available = height - 2;
const int selected_line = 4 + static_cast<int>(index_of(focused_));
const int maximum_start = static_cast<int>(lines.size()) - 1 - available;
const int start = std::clamp(selected_line - available / 2, 1,
std::max(1, maximum_start));
for (int offset = 0; offset < available; ++offset) {
compact.push_back(lines[static_cast<std::size_t>(start + offset)]);
}
}
if (height > 1) compact.push_back(bottom);
lines = std::move(compact);
}
while (static_cast<int>(lines.size()) < height) {
lines.insert(lines.end() - 1, interior_line({}, width));
}
std::string frame;
for (int row = 0; row < height; ++row) {
if (row != 0) frame += '\n';
frame += lines[static_cast<std::size_t>(row)];
}
return frame;
}
} // namespace nocal::tui

732
src/tui/Screen.cpp Normal file
View File

@@ -0,0 +1,732 @@
#include "nocal/tui/Screen.hpp"
#include <algorithm>
#include <array>
#include <cstdint>
#include <ctime>
#include <map>
#include <sstream>
#include <vector>
namespace nocal::tui {
namespace {
using namespace std::chrono;
constexpr std::array<std::string_view, 12> month_names{
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"};
constexpr std::array<std::string_view, 7> weekday_long{
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"};
constexpr std::array<std::string_view, 7> weekday_short{
"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"};
std::uint32_t decode_codepoint(const std::string_view text, const std::size_t at,
std::size_t& length) {
const auto first = static_cast<unsigned char>(text[at]);
if (first < 0x80) {
length = 1;
return first;
}
int continuation = 0;
std::uint32_t result = 0;
if ((first & 0xe0) == 0xc0) {
continuation = 1;
result = first & 0x1f;
} else if ((first & 0xf0) == 0xe0) {
continuation = 2;
result = first & 0x0f;
} else if ((first & 0xf8) == 0xf0) {
continuation = 3;
result = first & 0x07;
} else {
length = 1;
return 0xfffd;
}
if (at + static_cast<std::size_t>(continuation) >= text.size()) {
length = 1;
return 0xfffd;
}
for (int offset = 1; offset <= continuation; ++offset) {
const auto byte = static_cast<unsigned char>(text[at + static_cast<std::size_t>(offset)]);
if ((byte & 0xc0) != 0x80) {
length = 1;
return 0xfffd;
}
result = (result << 6) | (byte & 0x3f);
}
length = static_cast<std::size_t>(continuation + 1);
return result;
}
int codepoint_width(const std::uint32_t value) {
if ((value >= 0x0300 && value <= 0x036f) || (value >= 0xfe00 && value <= 0xfe0f)) {
return 0;
}
if ((value >= 0x1100 && value <= 0x115f) || (value >= 0x2e80 && value <= 0xa4cf) ||
(value >= 0xac00 && value <= 0xd7a3) || (value >= 0xf900 && value <= 0xfaff) ||
(value >= 0x1f300 && value <= 0x1faff)) {
return 2;
}
return 1;
}
int display_width(const std::string_view text) {
int width = 0;
for (std::size_t at = 0; at < text.size();) {
std::size_t length = 1;
const auto cp = decode_codepoint(text, at, length);
width += codepoint_width(cp);
at += length;
}
return width;
}
std::string printable_text(const std::string_view text, const bool preserve_newlines = false) {
std::string result{text};
for (auto& character : result) {
const auto byte = static_cast<unsigned char>(character);
if ((byte < 0x20 && !(preserve_newlines && character == '\n')) || byte == 0x7f) {
character = ' ';
}
}
return result;
}
std::string fit_text(const std::string_view text, const int requested_width,
const bool ellipsis = true) {
const int width = std::max(0, requested_width);
if (width == 0) {
return {};
}
if (display_width(text) <= width) {
std::string result{text};
result.append(static_cast<std::size_t>(width - display_width(text)), ' ');
return result;
}
const int content_limit = std::max(0, width - (ellipsis && width > 1 ? 1 : 0));
std::string result;
int used = 0;
for (std::size_t at = 0; at < text.size();) {
std::size_t length = 1;
const auto cp = decode_codepoint(text, at, length);
const int cp_width = codepoint_width(cp);
if (used + cp_width > content_limit) {
break;
}
result.append(text.substr(at, length));
used += cp_width;
at += length;
}
if (ellipsis && width > 1) {
result += "";
++used;
}
result.append(static_cast<std::size_t>(std::max(0, width - used)), ' ');
return result;
}
std::string centred(const std::string_view text, const int width) {
const int visible = std::min(display_width(text), width);
const int left = std::max(0, (width - visible) / 2);
std::string result(static_cast<std::size_t>(left), ' ');
result += fit_text(text, width - left);
return fit_text(result, width, false);
}
std::string styled(const std::string_view text, const std::string_view code, const bool ansi) {
if (!ansi || code.empty()) {
return std::string{text};
}
return "\x1b[" + std::string{code} + "m" + std::string{text} + "\x1b[0m";
}
std::string two_digits(const unsigned value) {
std::array<char, 3> result{'0', '0', '\0'};
result[0] = static_cast<char>('0' + (value / 10) % 10);
result[1] = static_cast<char>('0' + value % 10);
return std::string{result.data(), 2};
}
std::string iso_date(const year_month_day day) {
return std::to_string(static_cast<int>(day.year())) + "-" +
two_digits(static_cast<unsigned>(day.month())) + "-" +
two_digits(static_cast<unsigned>(day.day()));
}
std::string event_label(const CalendarItem& item, const int width) {
const auto title = item.title.empty() ? std::string{"(untitled)"} : printable_text(item.title);
if (item.all_day || !item.time_of_day) {
return fit_text("" + title, width);
}
const auto total = item.time_of_day->count();
const auto hour = static_cast<unsigned>((total / 60 + 24) % 24);
const auto minute = static_cast<unsigned>((total % 60 + 60) % 60);
if (width < 8) {
return fit_text(title, width);
}
return fit_text(two_digits(hour) + ":" + two_digits(minute) + " " + title, width);
}
std::string event_label(const CalendarItem& item, const int width, const bool focused) {
if (!focused) return event_label(item, width);
if (width <= 2) return fit_text("", width);
auto label = event_label(item, width - 2);
return fit_text(" " + label, width, false);
}
std::string border(const std::vector<int>& widths, const char* left,
const char* join, const char* right) {
std::string result{left};
for (std::size_t column = 0; column < widths.size(); ++column) {
for (int position = 0; position < widths[column]; ++position) {
result += "";
}
result += column + 1 == widths.size() ? right : join;
}
return result;
}
sys_days monday_on_or_before(const sys_days date) {
const weekday day{date};
const auto offset = (day.iso_encoding() + 6) % 7;
return date - days{offset};
}
struct LocalMoment {
year_month_day date;
minutes time_of_day;
};
std::string clock_time(const minutes value) {
const auto total = value.count();
const auto hour = static_cast<unsigned>((total / 60 + 24) % 24);
const auto minute = static_cast<unsigned>((total % 60 + 60) % 60);
return two_digits(hour) + ":" + two_digits(minute);
}
std::string friendly_date(const year_month_day value) {
if (!value.ok()) return {};
const auto weekday_index = weekday{sys_days{value}}.iso_encoding() - 1;
return std::string{weekday_long[weekday_index]} + ", " +
std::to_string(static_cast<unsigned>(value.day())) + " " +
std::string{month_names[static_cast<unsigned>(value.month()) - 1]} + " " +
std::to_string(static_cast<int>(value.year()));
}
std::vector<std::string> wrap_text(const std::string_view text, const int requested_width) {
const int width = std::max(1, requested_width);
std::vector<std::string> result;
std::size_t paragraph_start = 0;
while (paragraph_start <= text.size()) {
const auto paragraph_end = text.find('\n', paragraph_start);
const auto paragraph = text.substr(paragraph_start,
paragraph_end == std::string_view::npos ? text.size() - paragraph_start
: paragraph_end - paragraph_start);
if (paragraph.empty()) {
result.emplace_back();
} else {
std::string line;
std::size_t at = 0;
while (at < paragraph.size()) {
while (at < paragraph.size() && paragraph[at] == ' ') ++at;
const auto word_end = paragraph.find(' ', at);
const auto word = paragraph.substr(at,
word_end == std::string_view::npos ? paragraph.size() - at : word_end - at);
if (!word.empty() && display_width(word) > width) {
if (!line.empty()) {
result.push_back(line);
line.clear();
}
std::size_t word_at = 0;
while (word_at < word.size()) {
std::string part;
int used = 0;
while (word_at < word.size()) {
std::size_t length = 1;
const auto cp = decode_codepoint(word, word_at, length);
const auto cp_width = codepoint_width(cp);
if (used + cp_width > width) {
if (used == 0) {
part.append(word.substr(word_at, length));
word_at += length;
}
break;
}
part.append(word.substr(word_at, length));
word_at += length;
used += cp_width;
}
if (word_at < word.size() || !part.empty()) result.push_back(part);
}
} else if (!word.empty() &&
display_width(line) + (line.empty() ? 0 : 1) + display_width(word) <= width) {
if (!line.empty()) line += ' ';
line += word;
} else if (!word.empty()) {
result.push_back(line);
line = std::string{word};
}
if (word_end == std::string_view::npos) break;
at = word_end + 1;
}
if (!line.empty()) result.push_back(line);
}
if (paragraph_end == std::string_view::npos) break;
paragraph_start = paragraph_end + 1;
}
if (result.empty()) result.emplace_back();
return result;
}
const CalendarItem* focused_item(std::span<const CalendarItem> items, const ScreenState& state) {
if (!state.focused_event_id) return nullptr;
const auto on_selected_day = std::find_if(items.begin(), items.end(), [&](const auto& item) {
return item.id == *state.focused_event_id && item.day == state.selected_day;
});
if (on_selected_day != items.end()) return &*on_selected_day;
const auto anywhere = std::find_if(items.begin(), items.end(), [&](const auto& item) {
return item.id == *state.focused_event_id;
});
return anywhere == items.end() ? nullptr : &*anywhere;
}
bool calendar_item_less(const CalendarItem* left, const CalendarItem* right) {
const auto true_all_day = [](const CalendarItem& item) {
return item.detail_all_day || (item.detail_title.empty() && item.all_day);
};
const bool left_all_day = true_all_day(*left);
const bool right_all_day = true_all_day(*right);
if (left_all_day != right_all_day) return left_all_day;
const auto left_time = left->detail_start_time_of_day
? left->detail_start_time_of_day : left->time_of_day;
const auto right_time = right->detail_start_time_of_day
? right->detail_start_time_of_day : right->time_of_day;
return left_time.value_or(minutes{-1}) < right_time.value_or(minutes{-1});
}
std::string detail_frame(std::span<const CalendarItem> items, const ScreenState& state,
const CalendarItem& focused) {
const int width = std::max(1, state.width);
const int height = std::max(1, state.height);
if (width < 4 || height < 4) {
std::vector<std::string> tiny(static_cast<std::size_t>(height), std::string{});
const auto& tiny_title = focused.detail_title.empty() ? focused.title : focused.detail_title;
tiny[0] = fit_text(tiny_title.empty() ? "(untitled)" : printable_text(tiny_title), width);
std::ostringstream output;
for (int line = 0; line < height; ++line) {
if (line != 0) output << '\n';
output << tiny[static_cast<std::size_t>(line)];
}
return output.str();
}
std::vector<const CalendarItem*> selected_items;
for (const auto& item : items) {
if (item.day == state.selected_day) selected_items.push_back(&item);
}
std::stable_sort(selected_items.begin(), selected_items.end(), calendar_item_less);
const auto current = std::find_if(selected_items.begin(), selected_items.end(),
[&](const auto* item) { return item->id == focused.id; });
const auto ordinal = current == selected_items.end()
? 1U : static_cast<unsigned>(current - selected_items.begin() + 1);
const auto total = std::max<std::size_t>(1, selected_items.size());
const int inner = width - 2;
std::vector<std::pair<std::string, std::string>> content;
const auto add = [&](std::string text, std::string style = {}) {
content.emplace_back(fit_text(text, inner, false), std::move(style));
};
add(" APPOINTMENT", "2;1");
const auto& title = focused.detail_title.empty() ? focused.title : focused.detail_title;
add(" " + (title.empty() ? std::string{"(untitled)"} : printable_text(title)), "1");
add("");
const auto start_day = focused.start_day.ok() ? focused.start_day : focused.day;
const auto end_day = focused.end_day.ok() ? focused.end_day : start_day;
add(" Date " + friendly_date(start_day));
if (end_day != start_day) add(" Ends " + friendly_date(end_day));
const bool event_all_day = focused.detail_all_day ||
(focused.detail_title.empty() && focused.all_day);
const auto start_time = focused.detail_start_time_of_day
? focused.detail_start_time_of_day : focused.time_of_day;
if (event_all_day || !start_time) {
add(" Time All day");
} else {
auto value = clock_time(*start_time);
if (focused.end_time_of_day) value += " " + clock_time(*focused.end_time_of_day);
add(" Time " + value + " local");
}
if (!focused.location.empty()) add(" Location " + printable_text(focused.location));
add("");
if (!focused.description.empty()) {
add(" NOTES", "2;1");
for (const auto& line : wrap_text(printable_text(focused.description, true),
std::max(1, inner - 2))) {
add(" " + line);
}
} else {
add(" No notes for this appointment.", "2;3");
}
const auto footer = " ↑↓/Tab browse e edit d delete u undo Ctrl-R redo Esc back " +
std::to_string(ordinal) + "/" + std::to_string(total) + " ";
const int body_rows = height - 2;
std::ostringstream output;
output << border({inner}, "", "", "") << '\n';
for (int line = 0; line < body_rows; ++line) {
std::string value(static_cast<std::size_t>(inner), ' ');
std::string style;
if (line == body_rows - 1) {
value = fit_text(footer, inner);
style = "7";
} else if (line < static_cast<int>(content.size())) {
value = content[static_cast<std::size_t>(line)].first;
style = content[static_cast<std::size_t>(line)].second;
}
output << "" << styled(value, style, state.ansi) << "\n";
}
output << border({inner}, "", "", "");
return output.str();
}
std::string delete_confirmation_frame(const ScreenState& state, const CalendarItem& focused) {
const int width = std::max(1, state.width);
const int height = std::max(1, state.height);
if (width < 4 || height < 4) {
return fit_text("Delete? y/n", width);
}
const int inner = width - 2;
const auto& detail_title = focused.detail_title.empty() ? focused.title : focused.detail_title;
const auto title = detail_title.empty() ? std::string{"(untitled)"}
: printable_text(detail_title);
const auto question = fit_text(" Delete “" + title + "”?", inner);
const auto controls = fit_text(" y confirm n/Esc cancel", inner);
const int question_line = std::max(1, height / 2 - 1);
std::ostringstream output;
output << border({inner}, "", "", "") << '\n';
for (int line = 0; line < height - 2; ++line) {
std::string content(static_cast<std::size_t>(inner), ' ');
std::string style;
if (line == question_line) {
content = question;
style = "1;31";
} else if (line == question_line + 2) {
content = controls;
style = "7";
} else if (!state.notification.empty() && line == question_line + 3) {
content = fit_text(" " + printable_text(state.notification, true), inner);
style = state.notification_is_error ? "1;31" : "1;32";
}
output << "" << styled(content, style, state.ansi) << "\n";
}
output << border({inner}, "", "", "");
return output.str();
}
LocalMoment to_local(const system_clock::time_point point) {
const auto value = system_clock::to_time_t(point);
std::tm local{};
::localtime_r(&value, &local);
return {
year{local.tm_year + 1900} / month{static_cast<unsigned>(local.tm_mon + 1)} /
day{static_cast<unsigned>(local.tm_mday)},
hours{local.tm_hour} + minutes{local.tm_min},
};
}
std::string compact_frame(std::span<const CalendarItem> items, const ScreenState& state) {
const int width = std::max(1, state.width);
const int height = std::max(1, state.height);
const auto first = sys_days{state.visible_month / day{1}};
const auto grid_start = monday_on_or_before(first);
std::vector<std::string> lines;
const auto title = std::string{month_names[static_cast<unsigned>(state.visible_month.month()) - 1]} +
" " + std::to_string(static_cast<int>(state.visible_month.year()));
lines.push_back(styled(centred(title, width), "1", state.ansi));
std::array<int, 7> tokens{};
for (int column = 0; column < 7; ++column) {
tokens[static_cast<std::size_t>(column)] =
width / 7 + (column < width % 7 ? 1 : 0);
}
std::string weekdays;
for (std::size_t column = 0; column < weekday_short.size(); ++column) {
weekdays += centred(weekday_short[column], tokens[column]);
}
lines.push_back(styled(weekdays, "2;1", state.ansi));
for (int week = 0; week < 6; ++week) {
std::string week_line;
for (int column = 0; column < 7; ++column) {
const auto date = year_month_day{grid_start + days{week * 7 + column}};
const auto count = std::count_if(items.begin(), items.end(),
[date](const auto& item) { return item.day == date; });
std::string label = std::to_string(static_cast<unsigned>(date.day()));
const auto token = tokens[static_cast<std::size_t>(column)];
if (count > 0 && token >= 3) {
label += "";
}
auto text = centred(label, token);
if (date == state.selected_day) {
text = styled(text, date == state.today ? "7;1;36" : "7;1", state.ansi);
} else if (date == state.today) {
text = styled(text, "1;36", state.ansi);
} else if (date.month() != state.visible_month.month()) {
text = styled(text, "2", state.ansi);
}
week_line += text;
}
lines.push_back(std::move(week_line));
}
const auto focused = focused_item(items, state);
if (height > 9 && focused != nullptr) {
const auto focus_title = focused->title.empty() ? std::string{"(untitled)"}
: printable_text(focused->title);
lines.push_back(styled(fit_text(" " + focus_title, width),
"7;1", state.ansi));
}
while (static_cast<int>(lines.size()) < height - 1) {
lines.emplace_back(static_cast<std::size_t>(width), ' ');
}
std::string controls;
if (!state.notification.empty()) {
controls = printable_text(state.notification, true);
} else if (focused == nullptr) {
controls = "a add u undo Ctrl-R redo ? help n/p month t today q quit";
} else {
controls = "Tab appointment Enter open e edit d delete u undo Ctrl-R redo";
}
if (static_cast<int>(lines.size()) < height) {
const auto style = state.notification.empty() ? "2"
: state.notification_is_error ? "1;31" : "1;32";
lines.push_back(styled(fit_text(controls, width), style, state.ansi));
}
if (static_cast<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height));
std::ostringstream output;
for (int line = 0; line < height; ++line) {
if (line != 0) output << '\n';
output << lines[static_cast<std::size_t>(line)];
}
return output.str();
}
} // namespace
Action decode_key(const std::string_view sequence) noexcept {
if (sequence == "h" || sequence == "\x1b[D" || sequence == "\x1bOD") return Action::left;
if (sequence == "l" || sequence == "\x1b[C" || sequence == "\x1bOC") return Action::right;
if (sequence == "k" || sequence == "\x1b[A" || sequence == "\x1bOA") return Action::up;
if (sequence == "j" || sequence == "\x1b[B" || sequence == "\x1bOB") return Action::down;
if (sequence == "n" || sequence == "\x1b[6~") return Action::next_month;
if (sequence == "p" || sequence == "\x1b[5~") return Action::previous_month;
if (sequence == "t") return Action::today;
if (sequence == "?") return Action::toggle_help;
if (sequence == "\x1b[Z") return Action::previous_event;
if (sequence == "\t" || sequence == "]") return Action::next_event;
if (sequence == "[") return Action::previous_event;
if (sequence == "\r" || sequence == "\n") return Action::select;
if (sequence == "\x1b" || sequence == "\x7f") return Action::back;
if (sequence == "a") return Action::add_event;
if (sequence == "e") return Action::edit_event;
if (sequence == "d") return Action::delete_event;
if (sequence == "u") return Action::undo;
if (sequence == "\x12") return Action::redo;
if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit;
return Action::none;
}
std::string render_month(std::span<const CalendarItem> items, const ScreenState& state) {
const auto focused = focused_item(items, state);
if (state.confirm_delete && focused != nullptr) {
return delete_confirmation_frame(state, *focused);
}
if (state.show_event_details && focused != nullptr) {
return detail_frame(items, state, *focused);
}
if (state.width < 35 || state.height < 16) {
return compact_frame(items, state);
}
const int width = state.width;
const int inner_total = width - 8; // eight vertical border glyphs
std::vector<int> widths(7, inner_total / 7);
for (int column = 0; column < inner_total % 7; ++column) {
++widths[static_cast<std::size_t>(column)];
}
const int content_rows = std::max(6, state.height - 10);
std::vector<int> row_heights(6, content_rows / 6);
for (int row = 0; row < content_rows % 6; ++row) {
++row_heights[static_cast<std::size_t>(row)];
}
const auto first = sys_days{state.visible_month / day{1}};
const auto grid_start = monday_on_or_before(first);
std::map<sys_days, std::vector<const CalendarItem*>> by_day;
for (const auto& item : items) {
if (item.day.ok()) {
by_day[sys_days{item.day}].push_back(&item);
}
}
for (auto& [_, day_items] : by_day) {
std::stable_sort(day_items.begin(), day_items.end(), calendar_item_less);
}
const auto month_index = static_cast<unsigned>(state.visible_month.month()) - 1;
const auto title = " " + std::string{month_names[month_index]} + " " +
std::to_string(static_cast<int>(state.visible_month.year())) + " ";
std::ostringstream output;
output << styled(centred(title, width), "1", state.ansi) << '\n';
output << ' ';
for (std::size_t column = 0; column < widths.size(); ++column) {
const auto name = widths[column] >= 9 ? weekday_long[column] : weekday_short[column];
output << styled(centred(name, widths[column]), "2;1", state.ansi);
output << (column + 1 == widths.size() ? ' ' : ' ');
}
output << '\n' << border(widths, "", "", "") << '\n';
for (int week = 0; week < 6; ++week) {
for (int line = 0; line < row_heights[static_cast<std::size_t>(week)]; ++line) {
output << "";
for (int column = 0; column < 7; ++column) {
const auto date_days = grid_start + days{week * 7 + column};
const auto date = year_month_day{date_days};
const auto cell_width = widths[static_cast<std::size_t>(column)];
std::string cell;
std::string style;
if (line == 0) {
cell = " " + std::to_string(static_cast<unsigned>(date.day()));
cell = fit_text(cell, cell_width, false);
if (date == state.selected_day) style = date == state.today ? "7;1;36" : "7;1";
else if (date == state.today) style = "1;36";
else if (date.month() != state.visible_month.month()) style = "2";
} else {
const auto found = by_day.find(date_days);
const auto count = found == by_day.end() ? 0U : found->second.size();
const auto appointment_line = static_cast<std::size_t>(line - 1);
const auto slots = static_cast<std::size_t>(row_heights[static_cast<std::size_t>(week)] - 1);
const bool focused_on_day = found != by_day.end() && date == state.selected_day &&
state.focused_event_id &&
std::any_of(found->second.begin(), found->second.end(),
[&](const auto* item) {
return item->id == *state.focused_event_id;
});
// With only one appointment row, focus is more useful than
// an overflow counter. Taller cells retain both.
const bool show_overflow = count > slots &&
!(slots == 1 && focused_on_day);
const auto event_capacity = show_overflow && slots > 0 ? slots - 1 : slots;
std::size_t window_start = 0;
if (found != by_day.end() && event_capacity > 0 && date == state.selected_day &&
state.focused_event_id) {
const auto focus = std::find_if(found->second.begin(), found->second.end(),
[&](const auto* item) { return item->id == *state.focused_event_id; });
if (focus != found->second.end()) {
const auto focus_index = static_cast<std::size_t>(focus - found->second.begin());
if (focus_index >= event_capacity) window_start = focus_index - event_capacity + 1;
}
}
if (show_overflow && appointment_line + 1 == slots) {
cell = fit_text(" +" + std::to_string(count - event_capacity) + " hidden", cell_width);
style = "2";
} else if (appointment_line < event_capacity &&
window_start + appointment_line < count) {
const auto* item = found->second[window_start + appointment_line];
const bool is_focused = date == state.selected_day && state.focused_event_id &&
item->id == *state.focused_event_id;
cell = event_label(*item, cell_width, is_focused);
if (is_focused) style = "7;1";
} else {
cell.assign(static_cast<std::size_t>(cell_width), ' ');
}
if (date.month() != state.visible_month.month() && style.empty()) style = "2";
}
output << styled(cell, style, state.ansi) << "";
}
output << '\n';
}
output << (week == 5 ? border(widths, "", "", "")
: border(widths, "", "", ""))
<< '\n';
}
const auto selected_count = by_day.contains(sys_days{state.selected_day})
? by_day.at(sys_days{state.selected_day}).size() : 0U;
std::string status;
if (state.show_help) {
status = " arrows/hjkl day Tab/⇧Tab appointment Enter read a add e edit d delete u undo Ctrl-R redo n/p month t today ? close q quit";
} else if (!state.notification.empty()) {
status = " " + printable_text(state.notification, true);
} else if (focused != nullptr) {
const auto focus_title = focused->title.empty() ? std::string{"(untitled)"}
: printable_text(focused->title);
status = " " + iso_date(state.selected_day) + " " +
focus_title + " Tab next Enter read e edit d delete u undo Ctrl-R redo";
} else {
status = " " + iso_date(state.selected_day) + " " + std::to_string(selected_count) +
(selected_count == 1 ? " appointment" : " appointments") +
(selected_count > 0 ? " Tab focus" : "") +
" a add u undo Ctrl-R redo ? help q quit";
}
const auto status_style = !state.notification.empty()
? (state.notification_is_error ? "1;31" : "1;32")
: focused != nullptr ? "7" : "2";
output << styled(fit_text(status, width), status_style, state.ansi);
return output.str();
}
std::string render_month(const std::span<const Event> events, const ScreenState& state) {
std::vector<CalendarItem> items;
items.reserve(events.size() * 2);
std::map<std::string, std::size_t> uid_counts;
for (const auto& event : events) {
if (!event.uid.empty()) ++uid_counts[event.uid];
}
const auto visible_start = monday_on_or_before(sys_days{state.visible_month / day{1}});
const auto visible_end = visible_start + days{41};
for (std::size_t index = 0; index < events.size(); ++index) {
const auto& event = events[index];
const auto identity = !event.uid.empty() && uid_counts[event.uid] == 1
? event.uid : "@nocal:" + std::to_string(index);
const auto local_start = to_local(event.start);
const auto local_finish = to_local(event.end);
const auto one_second = duration_cast<system_clock::duration>(seconds{1});
const auto final_instant = event.end > event.start
? event.end - std::min(event.end - event.start, one_second)
: event.start;
const auto local_end = to_local(final_instant);
const auto event_start = sys_days{local_start.date};
const auto event_last = sys_days{local_end.date};
const auto first_day = std::max(event_start, visible_start);
const auto last_day = std::min(event_last, visible_end);
for (auto date = first_day; date <= last_day; date += days{1}) {
const bool continuation = date != event_start;
items.push_back(CalendarItem{
.id = identity,
.title = continuation ? "" + event.title : event.title,
.day = year_month_day{date},
.time_of_day = event.all_day || continuation
? std::nullopt
: std::optional{local_start.time_of_day},
.all_day = event.all_day || continuation,
.end_time_of_day = event.all_day ? std::nullopt
: std::optional{local_finish.time_of_day},
.start_day = local_start.date,
.end_day = event.all_day ? local_end.date : local_finish.date,
.location = event.location,
.description = event.description,
.detail_title = event.title,
.detail_all_day = event.all_day,
.detail_start_time_of_day = event.all_day
? std::nullopt
: std::optional{local_start.time_of_day},
});
}
}
return render_month(std::span<const CalendarItem>{items}, state);
}
} // namespace nocal::tui

112
src/tui/Terminal.cpp Normal file
View File

@@ -0,0 +1,112 @@
#include "nocal/tui/Terminal.hpp"
#include <cerrno>
#include <csignal>
#include <cstddef>
#include <sys/ioctl.h>
#include <unistd.h>
namespace nocal::tui {
namespace {
volatile std::sig_atomic_t resize_pending = 0;
extern "C" void mark_resize(int) noexcept {
resize_pending = 1;
}
} // namespace
struct ResizeWatcher::sigaction_storage {
struct sigaction previous {};
bool installed{false};
};
TerminalSize terminal_size(const int fd) noexcept {
struct winsize size {};
if (::ioctl(fd, TIOCGWINSZ, &size) == 0 && size.ws_col > 0 && size.ws_row > 0) {
return {static_cast<int>(size.ws_col), static_cast<int>(size.ws_row)};
}
return {};
}
bool write_terminal(const int fd, const std::string_view bytes) noexcept {
std::size_t written = 0;
while (written < bytes.size()) {
const auto result = ::write(fd, bytes.data() + written, bytes.size() - written);
if (result > 0) {
written += static_cast<std::size_t>(result);
continue;
}
if (result < 0 && errno == EINTR) {
continue;
}
return false;
}
return true;
}
AlternateScreen::AlternateScreen(const int output_fd)
: output_fd_(output_fd), active_(::isatty(output_fd) != 0) {
if (active_) {
// Save/restore the title too: popup terminals in Hyprland often retain it.
(void)write_terminal(output_fd_, "\x1b[?1049h\x1b[?25l\x1b[0m");
}
}
AlternateScreen::~AlternateScreen() {
if (active_) {
(void)write_terminal(output_fd_, "\x1b[0m\x1b[?25h\x1b[?1049l");
}
}
RawInput::RawInput(const int input_fd) : input_fd_(input_fd) {
if (::isatty(input_fd_) == 0 || ::tcgetattr(input_fd_, &original_) != 0) {
return;
}
auto raw = original_;
raw.c_iflag &= static_cast<tcflag_t>(~(BRKINT | ICRNL | INPCK | ISTRIP | IXON));
// Leave output processing enabled: frames use '\n' and benefit from the
// terminal's normal newline mapping.
raw.c_cflag |= CS8;
// Deliver Ctrl-C as input so TuiApp can unwind and reliably restore the
// alternate screen and cursor before exiting.
raw.c_lflag &= static_cast<tcflag_t>(~(ECHO | ICANON | IEXTEN | ISIG));
raw.c_cc[VMIN] = 0;
raw.c_cc[VTIME] = 0;
active_ = ::tcsetattr(input_fd_, TCSAFLUSH, &raw) == 0;
}
RawInput::~RawInput() {
if (active_) {
::tcsetattr(input_fd_, TCSAFLUSH, &original_);
}
}
ResizeWatcher::ResizeWatcher() : storage_(new sigaction_storage) {
resize_pending = 1;
struct sigaction action {};
action.sa_handler = mark_resize;
::sigemptyset(&action.sa_mask);
action.sa_flags = 0; // Ensure a blocked poll wakes up for resize.
storage_->installed = ::sigaction(SIGWINCH, &action, &storage_->previous) == 0;
}
ResizeWatcher::~ResizeWatcher() {
if (storage_->installed) {
::sigaction(SIGWINCH, &storage_->previous, nullptr);
}
delete storage_;
}
bool ResizeWatcher::consume() noexcept {
if (resize_pending == 0) {
return false;
}
resize_pending = 0;
return true;
}
} // namespace nocal::tui

573
src/tui/TuiApp.cpp Normal file
View File

@@ -0,0 +1,573 @@
#include "nocal/tui/TuiApp.hpp"
#include "nocal/domain/event_query.hpp"
#include "nocal/tui/Terminal.hpp"
#include <algorithm>
#include <array>
#include <cerrno>
#include <chrono>
#include <cstdlib>
#include <ctime>
#include <exception>
#include <string>
#include <utility>
#include <poll.h>
#include <unistd.h>
namespace nocal::tui {
namespace {
using namespace std::chrono;
year_month_day current_day() {
const auto now = system_clock::to_time_t(system_clock::now());
std::tm local{};
::localtime_r(&now, &local);
return year{local.tm_year + 1900} / month{static_cast<unsigned>(local.tm_mon + 1)} /
day{static_cast<unsigned>(local.tm_mday)};
}
year_month_day clamp_to_month(const year_month month, const unsigned requested_day) {
const auto last_day = static_cast<unsigned>((month / std::chrono::last).day());
return month / day{std::min(requested_day, last_day)};
}
std::size_t complete_sequence_length(const std::string& input) {
if (input.empty()) return 0;
if (input.front() != '\x1b') return 1;
if (input.size() == 1) return 0;
if (input[1] == 'O') return input.size() >= 3 ? 3 : 0;
if (input[1] != '[') return 1;
if (input.size() < 3) return 0;
if ((input[2] >= 'A' && input[2] <= 'D') || input[2] == 'Z') return 3;
if ((input[2] == '5' || input[2] == '6') && input.size() >= 4 && input[3] == '~') return 4;
return 1;
}
std::string event_identity(const std::span<const Event> events, const std::size_t index) {
const auto& uid = events[index].uid;
const bool unique = !uid.empty() &&
std::count_if(events.begin(), events.end(), [&uid](const Event& event) {
return event.uid == uid;
}) == 1;
return unique ? uid : "@nocal:" + std::to_string(index);
}
std::size_t source_index(const std::span<const Event> events, const Event& event) {
const auto found = std::find_if(events.begin(), events.end(), [&event](const Event& candidate) {
return &candidate == &event;
});
return static_cast<std::size_t>(std::distance(events.begin(), found));
}
} // namespace
TuiApp::TuiApp(std::vector<Event>& events, const int input_fd, const int output_fd)
: TuiApp(events, SaveCallback{}, input_fd, output_fd) {}
TuiApp::TuiApp(std::vector<Event>& events, SaveCallback saver, const int input_fd,
const int output_fd)
: events_(events), input_fd_(input_fd), output_fd_(output_fd), saver_(std::move(saver)) {
undo_history_.reserve(history_limit);
redo_history_.reserve(history_limit);
state_.today = current_day();
state_.selected_day = state_.today;
state_.visible_month = state_.today.year() / state_.today.month();
}
std::optional<std::size_t> TuiApp::focused_event_index() const {
if (!state_.focused_event_id) return std::nullopt;
const auto event_span = std::span<const Event>{events_};
for (std::size_t index = 0; index < events_.size(); ++index) {
if (event_identity(event_span, index) == *state_.focused_event_id) return index;
}
return std::nullopt;
}
void TuiApp::set_notification(std::string message, const bool error) {
state_.notification = std::move(message);
state_.notification_is_error = error;
}
TuiApp::HistorySnapshot TuiApp::capture_history_snapshot() const {
return {
.events = events_,
.visible_month = state_.visible_month,
.selected_day = state_.selected_day,
.focused_event_id = state_.focused_event_id,
.show_event_details = state_.show_event_details,
.show_help = state_.show_help,
};
}
void TuiApp::restore_history_snapshot(HistorySnapshot snapshot) {
events_ = std::move(snapshot.events);
state_.visible_month = snapshot.visible_month;
state_.selected_day = snapshot.selected_day;
state_.focused_event_id = std::move(snapshot.focused_event_id);
state_.show_event_details = snapshot.show_event_details;
state_.show_help = snapshot.show_help;
state_.confirm_delete = false;
}
void TuiApp::push_history(std::vector<HistoryEntry>& history, HistoryEntry entry) {
if (history.size() == history_limit) history.erase(history.begin());
history.push_back(std::move(entry));
}
bool TuiApp::persist(const std::span<const Event> events) {
try {
if (saver_) saver_(events);
return true;
} catch (const std::exception& error) {
set_notification("Could not save changes: " + std::string{error.what()}, true);
} catch (...) {
set_notification("Could not save changes: unknown error", true);
}
return false;
}
void TuiApp::record_mutation(std::string operation) {
if (pending_mutation_before_) {
push_history(undo_history_,
{.snapshot = std::move(*pending_mutation_before_),
.operation = std::move(operation)});
}
pending_mutation_before_.reset();
redo_history_.clear();
}
void TuiApp::undo() {
if (undo_history_.empty()) {
set_notification("Nothing to undo.", true);
return;
}
auto current = capture_history_snapshot();
const auto operation = undo_history_.back().operation;
if (!persist(std::span<const Event>{undo_history_.back().snapshot.events})) return;
auto target = std::move(undo_history_.back());
undo_history_.pop_back();
push_history(redo_history_,
{.snapshot = std::move(current), .operation = target.operation});
restore_history_snapshot(std::move(target.snapshot));
set_notification("Undid " + operation + ".");
}
void TuiApp::redo() {
if (redo_history_.empty()) {
set_notification("Nothing to redo.", true);
return;
}
auto current = capture_history_snapshot();
const auto operation = redo_history_.back().operation;
if (!persist(std::span<const Event>{redo_history_.back().snapshot.events})) return;
auto target = std::move(redo_history_.back());
redo_history_.pop_back();
push_history(undo_history_,
{.snapshot = std::move(current), .operation = target.operation});
restore_history_snapshot(std::move(target.snapshot));
set_notification("Redid " + operation + ".");
}
void TuiApp::begin_add() {
pending_mutation_before_ = capture_history_snapshot();
editor_.emplace(state_.selected_day);
editing_index_.reset();
state_.show_event_details = false;
state_.show_help = false;
state_.confirm_delete = false;
}
void TuiApp::begin_edit() {
const auto index = focused_event_index();
if (!index) {
set_notification("Focus an appointment before editing.", true);
return;
}
pending_mutation_before_ = capture_history_snapshot();
editor_.emplace(events_[*index]);
editing_index_ = index;
state_.show_event_details = false;
state_.show_help = false;
state_.confirm_delete = false;
}
void TuiApp::begin_delete() {
if (!focused_event_index()) {
set_notification("Focus an appointment before deleting.", true);
return;
}
pending_mutation_before_ = capture_history_snapshot();
state_.confirm_delete = true;
state_.show_event_details = false;
state_.show_help = false;
}
void TuiApp::submit_editor() {
if (!editor_) return;
auto original = events_;
const auto original_focus = state_.focused_event_id;
std::size_t changed_index = events_.size();
std::string success;
const bool editing = editing_index_ && *editing_index_ < events_.size();
if (editing) {
changed_index = *editing_index_;
auto replacement = editor_->result();
replacement.uid = events_[changed_index].uid;
events_[changed_index] = std::move(replacement);
success = "Appointment updated.";
} else {
events_.push_back(editor_->result());
changed_index = events_.size() - 1;
success = "Appointment added.";
}
if (!persist(std::span<const Event>{events_})) {
events_.swap(original);
state_.focused_event_id = original_focus;
editor_.reset();
editing_index_.reset();
pending_mutation_before_.reset();
return;
}
editor_.reset();
editing_index_.reset();
state_.selected_day = local_date(events_[changed_index].start);
state_.visible_month = state_.selected_day.year() / state_.selected_day.month();
state_.focused_event_id = event_identity(std::span<const Event>{events_}, changed_index);
state_.show_event_details = false;
record_mutation(editing ? "edit appointment" : "add appointment");
set_notification(std::move(success));
}
void TuiApp::confirm_delete() {
const auto index = focused_event_index();
if (!index) {
state_.confirm_delete = false;
pending_mutation_before_.reset();
set_notification("The focused appointment no longer exists.", true);
return;
}
auto original = events_;
const auto original_focus = state_.focused_event_id;
events_.erase(events_.begin() + static_cast<std::ptrdiff_t>(*index));
if (!persist(std::span<const Event>{events_})) {
events_.swap(original);
state_.focused_event_id = original_focus;
state_.confirm_delete = false;
pending_mutation_before_.reset();
return;
}
state_.focused_event_id.reset();
state_.show_event_details = false;
state_.confirm_delete = false;
record_mutation("delete appointment");
set_notification("Appointment deleted.");
}
void TuiApp::cancel_delete() {
state_.confirm_delete = false;
pending_mutation_before_.reset();
set_notification("Deletion cancelled.");
}
void TuiApp::handle_input(const std::string_view input) {
if (input == "\x03") {
dispatch(Action::quit);
return;
}
if (editor_) {
if (input == "\x12") {
set_notification("Finish or cancel editing before redoing.", true);
return;
}
const auto outcome = editor_->handle_key(input);
if (outcome == EditorOutcome::submit) {
submit_editor();
} else if (outcome == EditorOutcome::cancel) {
editor_.reset();
editing_index_.reset();
pending_mutation_before_.reset();
set_notification("Edit cancelled.");
}
return;
}
if (state_.confirm_delete) {
if (input == "y" || input == "Y") {
confirm_delete();
} else if (input == "n" || input == "N" || input == "\x1b" || input == "\x7f") {
cancel_delete();
} else if (input == "u" || input == "\x12") {
set_notification("Finish or cancel deletion before using history.", true);
}
return;
}
dispatch(decode_key(input));
}
void TuiApp::dispatch(const Action action) {
if (action != Action::none) {
state_.notification.clear();
state_.notification_is_error = false;
}
if (editor_) {
if (action == Action::back) {
editor_.reset();
editing_index_.reset();
pending_mutation_before_.reset();
set_notification("Edit cancelled.");
} else if (action == Action::undo || action == Action::redo) {
set_notification("Finish or cancel editing before using history.", true);
} else if (action == Action::quit) {
running_ = false;
}
return;
}
if (state_.confirm_delete) {
if (action == Action::back) cancel_delete();
else if (action == Action::undo || action == Action::redo) {
set_notification("Finish or cancel deletion before using history.", true);
}
else if (action == Action::quit) running_ = false;
return;
}
const auto clear_event_focus = [this] {
state_.focused_event_id.reset();
state_.show_event_details = false;
};
const auto cycle_event = [this](const int direction) {
const auto event_span = std::span<const Event>{events_};
const auto day_events = events_on_day(event_span, state_.selected_day);
if (day_events.empty()) {
state_.focused_event_id.reset();
return;
}
std::size_t current = day_events.size();
if (state_.focused_event_id) {
for (std::size_t index = 0; index < day_events.size(); ++index) {
const auto event_index = source_index(event_span, day_events[index].get());
if (event_identity(event_span, event_index) == *state_.focused_event_id) {
current = index;
break;
}
}
}
std::size_t target = 0;
if (current == day_events.size()) {
target = direction < 0 ? day_events.size() - 1 : 0;
} else if (direction < 0) {
target = current == 0 ? day_events.size() - 1 : current - 1;
} else {
target = (current + 1) % day_events.size();
}
const auto event_index = source_index(event_span, day_events[target].get());
state_.focused_event_id = event_identity(event_span, event_index);
};
if (state_.show_event_details) {
switch (action) {
case Action::next_event:
case Action::right:
case Action::down:
cycle_event(1);
return;
case Action::previous_event:
case Action::left:
case Action::up:
cycle_event(-1);
return;
case Action::select:
case Action::back:
state_.show_event_details = false;
return;
case Action::add_event:
begin_add();
return;
case Action::edit_event:
begin_edit();
return;
case Action::delete_event:
begin_delete();
return;
case Action::undo:
undo();
return;
case Action::redo:
redo();
return;
case Action::quit:
running_ = false;
return;
case Action::none:
case Action::previous_month:
case Action::next_month:
case Action::today:
case Action::toggle_help:
return;
}
}
auto selected = sys_days{state_.selected_day};
switch (action) {
case Action::left:
clear_event_focus();
selected -= days{1};
break;
case Action::right:
clear_event_focus();
selected += days{1};
break;
case Action::up:
clear_event_focus();
selected -= days{7};
break;
case Action::down:
clear_event_focus();
selected += days{7};
break;
case Action::previous_month: {
clear_event_focus();
const auto target = state_.visible_month - months{1};
state_.selected_day = clamp_to_month(target, static_cast<unsigned>(state_.selected_day.day()));
state_.visible_month = target;
return;
}
case Action::next_month: {
clear_event_focus();
const auto target = state_.visible_month + months{1};
state_.selected_day = clamp_to_month(target, static_cast<unsigned>(state_.selected_day.day()));
state_.visible_month = target;
return;
}
case Action::today:
clear_event_focus();
state_.today = current_day();
state_.selected_day = state_.today;
state_.visible_month = state_.today.year() / state_.today.month();
return;
case Action::toggle_help:
state_.show_help = !state_.show_help;
return;
case Action::previous_event:
cycle_event(-1);
return;
case Action::next_event:
cycle_event(1);
return;
case Action::select:
if (!state_.focused_event_id) {
cycle_event(1);
}
if (state_.focused_event_id) {
state_.show_event_details = true;
state_.show_help = false;
}
return;
case Action::back:
clear_event_focus();
state_.show_help = false;
return;
case Action::add_event:
begin_add();
return;
case Action::edit_event:
begin_edit();
return;
case Action::delete_event:
begin_delete();
return;
case Action::undo:
undo();
return;
case Action::redo:
redo();
return;
case Action::quit:
running_ = false;
return;
case Action::none:
return;
}
state_.selected_day = year_month_day{selected};
state_.visible_month = state_.selected_day.year() / state_.selected_day.month();
}
ExitReason TuiApp::run() {
RawInput raw{input_fd_};
AlternateScreen screen{output_fd_};
ResizeWatcher resize;
const bool terminal_output = ::isatty(output_fd_) != 0;
state_.ansi = terminal_output && std::getenv("NO_COLOR") == nullptr;
running_ = true;
std::string pending;
bool redraw = true;
while (running_) {
if (resize.consume()) {
const auto size = terminal_size(output_fd_);
state_.width = size.columns;
state_.height = size.rows;
redraw = true;
}
if (redraw) {
const auto content = editor_
? editor_->render(state_.width, state_.height, state_.ansi)
: render_month(std::span<const Event>{events_}, state_);
// Cursor control is independent of color styling: NO_COLOR still
// needs in-place redraws rather than appending a new month per key.
const auto frame = terminal_output ? "\x1b[H" + content + "\x1b[J" : content;
if (!write_terminal(output_fd_, frame)) return ExitReason::io_error;
redraw = false;
}
struct pollfd descriptor { input_fd_, POLLIN, 0 };
const auto result = ::poll(&descriptor, 1, pending.empty() ? -1 : 30);
if (result < 0) {
if (errno == EINTR) continue;
return ExitReason::io_error;
}
if (result == 0) {
// Escape is ambiguous with the prefix of a terminal control sequence.
// Once the short sequence window expires, treat it as a key in its own
// right so it can close the appointment reader.
handle_input(pending);
pending.clear();
redraw = true;
continue;
}
if ((descriptor.revents & (POLLERR | POLLNVAL)) != 0) return ExitReason::io_error;
if ((descriptor.revents & POLLHUP) != 0 && (descriptor.revents & POLLIN) == 0) {
return ExitReason::input_closed;
}
if ((descriptor.revents & POLLIN) == 0) continue;
std::array<char, 64> buffer{};
const auto count = ::read(input_fd_, buffer.data(), buffer.size());
if (count == 0) return ExitReason::input_closed;
if (count < 0) {
if (errno == EINTR || errno == EAGAIN) continue;
return ExitReason::io_error;
}
pending.append(buffer.data(), static_cast<std::size_t>(count));
while (const auto length = complete_sequence_length(pending)) {
handle_input(std::string_view{pending}.substr(0, length));
pending.erase(0, length);
redraw = true;
}
}
return ExitReason::quit;
}
} // namespace nocal::tui

View File

@@ -0,0 +1,83 @@
#!/bin/sh
set -eu
if [ "$#" -ne 1 ]; then
echo "usage: cli_recovery_test.sh /path/to/nocal" >&2
exit 2
fi
nocal=$1
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/nocal-cli-recovery.XXXXXX")
trap 'rm -rf "$tmpdir"' EXIT HUP INT TERM
calendar=$tmpdir/calendar.ics
backup=$calendar.bak
expected=$tmpdir/expected.ics
cat >"$calendar" <<'EOF'
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nocal CLI test//EN
BEGIN:VEVENT
UID:current@nocal.test
DTSTART:20260717T090000
DTEND:20260717T100000
SUMMARY:Current appointment
END:VEVENT
END:VCALENDAR
EOF
cat >"$backup" <<'EOF'
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nocal CLI test//EN
BEGIN:VEVENT
UID:backup@nocal.test
DTSTART:20260718T140000
DTEND:20260718T150000
SUMMARY:Recovered appointment
END:VEVENT
END:VCALENDAR
EOF
cp "$backup" "$expected"
help=$("$nocal" --help)
case $help in
*--restore-backup*"u undoes the last change, Ctrl-R redoes it"*) ;;
*)
echo "help does not document recovery and undo/redo" >&2
exit 1
;;
esac
if "$nocal" --restore-backup --demo --calendar "$calendar" >"$tmpdir/out" 2>"$tmpdir/err"; then
echo "--restore-backup with --demo unexpectedly succeeded" >&2
exit 1
fi
grep -F -- "--restore-backup cannot be combined with --demo" "$tmpdir/err" >/dev/null
if "$nocal" --restore-backup --print --calendar "$calendar" >"$tmpdir/out" 2>"$tmpdir/err"; then
echo "--restore-backup with --print unexpectedly succeeded" >&2
exit 1
fi
grep -F -- "--restore-backup cannot be combined with --print" "$tmpdir/err" >/dev/null
missing_calendar=$tmpdir/missing-backup.ics
cp "$calendar" "$missing_calendar"
cp "$missing_calendar" "$tmpdir/missing-before.ics"
if "$nocal" --restore-backup --calendar "$missing_calendar" \
>"$tmpdir/out" 2>"$tmpdir/err"; then
echo "restore without an adjacent backup unexpectedly succeeded" >&2
exit 1
fi
cmp "$missing_calendar" "$tmpdir/missing-before.ics"
actual=$("$nocal" --restore-backup --calendar "$calendar")
expected_summary="Restored backup $backup to $calendar"
if [ "$actual" != "$expected_summary" ]; then
echo "unexpected restore summary: $actual" >&2
exit 1
fi
cmp "$calendar" "$expected"
cmp "$backup" "$expected"

134
tests/domain_tests.cpp Normal file
View File

@@ -0,0 +1,134 @@
#include "nocal/domain/domain.hpp"
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <stdexcept>
#include <string_view>
#include <utility>
#include <vector>
namespace {
int failures = 0;
void check(bool condition, std::string_view message)
{
if (!condition) {
++failures;
std::cerr << "FAIL: " << message << '\n';
}
}
template <class Function>
void check_throws(Function&& function, std::string_view message)
{
try {
function();
check(false, message);
} catch (const std::exception&) {
}
}
void test_dates()
{
using namespace std::chrono;
const nocal::Date leap{2024y / February / 29d};
check(nocal::valid_date(leap), "leap day is valid");
check(nocal::format_date(leap) == "2024-02-29", "date formats as ISO 8601");
check(nocal::parse_date("2024-02-29") == leap, "date parses from ISO 8601");
check(nocal::from_sys_days(nocal::to_sys_days(leap) + days{1}) ==
nocal::Date{2024y / March / 1d},
"civil date arithmetic crosses a leap month");
check(nocal::monday_first_weekday(nocal::Date{2026y / July / 6d}) == 0,
"Monday has index zero");
check(nocal::monday_first_weekday(nocal::Date{2026y / July / 12d}) == 6,
"Sunday has index six");
check_throws([] { (void)nocal::parse_date("2023-02-29"); },
"invalid date is rejected");
}
void test_month_layout()
{
using namespace std::chrono;
const auto layout = nocal::make_month_layout(2026y / July);
check(layout.cells.size() == 42, "month layout always has 42 cells");
check(layout.first_visible_date() == nocal::Date{2026y / June / 29d},
"July 2026 begins after Monday-leading spill cells");
check(layout.last_visible_date() == nocal::Date{2026y / August / 9d},
"six-week grid has the expected final date");
check(layout.at(0, 0).column == 0 && layout.at(5, 6).row == 5,
"cell coordinates match their positions");
check(!layout.at(0, 0).in_month && layout.at(0, 2).in_month,
"spill cells are distinguished from current-month cells");
const auto* july_17 = layout.find(nocal::Date{2026y / July / 17d});
check(july_17 != nullptr && july_17->row == 2 && july_17->column == 4,
"date lookup finds the correct Friday cell");
check_throws([&] { (void)layout.at(6, 0); }, "out-of-grid access is rejected");
}
nocal::Event event(std::string uid, nocal::Date start_date, unsigned start_hour,
nocal::Date end_date, unsigned end_hour, bool all_day = false)
{
nocal::Event result;
result.uid = std::move(uid);
result.title = result.uid;
result.start = nocal::make_local_time(start_date, start_hour);
result.end = nocal::make_local_time(end_date, end_hour);
result.all_day = all_day;
return result;
}
void test_event_queries()
{
using namespace std::chrono;
const nocal::Date previous{2026y / July / 16d};
const nocal::Date day{2026y / July / 17d};
const nocal::Date next{2026y / July / 18d};
std::vector<nocal::Event> events;
events.push_back(event("late", day, 15, day, 16));
events.push_back(event("all-day", day, 0, next, 0, true));
events.push_back(event("early", day, 9, day, 10));
events.push_back(event("carry", previous, 23, day, 1));
events.push_back(event("ends-at-boundary", previous, 22, day, 0));
events.push_back(event("next-day", next, 0, next, 1));
const nocal::Date august_first{2026y / August / 1d};
events.push_back(event("next-month", august_first, 0, august_first, 1));
nocal::Event instant = event("instant", day, 12, day, 12);
events.push_back(instant);
const auto on_day = nocal::events_on_day(events, day);
check(on_day.size() == 5, "day query uses overlap and half-open boundaries");
check(on_day[0].get().uid == "all-day", "all-day events sort before timed events");
check(on_day[1].get().uid == "carry", "overlapping prior-day event is included");
check(on_day[2].get().uid == "early" && on_day[3].get().uid == "instant" &&
on_day[4].get().uid == "late",
"timed events sort by start time");
check(nocal::event_occurs_on(instant, day),
"zero-duration event occurs on the day containing its instant");
const auto in_july = nocal::events_in_month(events, 2026y / July);
check(in_july.size() == 7, "month query excludes an event beginning August 1");
nocal::Event invalid = event("invalid", day, 12, day, 11);
check(!nocal::event_overlaps(invalid, nocal::local_day_start(day),
nocal::local_day_end(day)),
"invalid backwards event never overlaps");
}
} // namespace
int main()
{
test_dates();
test_month_layout();
test_event_queries();
if (failures != 0) {
std::cerr << failures << " domain test(s) failed\n";
return EXIT_FAILURE;
}
std::cout << "domain tests passed\n";
return EXIT_SUCCESS;
}

234
tests/editor_tests.cpp Normal file
View File

@@ -0,0 +1,234 @@
#include "nocal/domain/date.hpp"
#include "nocal/tui/EventEditor.hpp"
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <string>
#include <string_view>
namespace {
int failures = 0;
void check(const bool condition, const std::string_view message)
{
if (!condition) {
++failures;
std::cerr << "FAIL: " << message << '\n';
}
}
void replace_field(nocal::tui::EventEditor& editor, const std::string_view value)
{
while (!editor.field_value(editor.focused_field()).empty()) {
(void)editor.handle_key("\x7f");
}
(void)editor.handle_key(value);
}
void focus(nocal::tui::EventEditor& editor, const nocal::tui::EditorField target)
{
while (editor.focused_field() != target) (void)editor.handle_key("\t");
}
void test_create_and_validation()
{
using namespace std::chrono;
const nocal::Date day{2026y / July / 17d};
nocal::tui::EventEditor editor{day};
check(editor.field_value(nocal::tui::EditorField::start_date) == "2026-07-17",
"new editor starts on selected date");
check(editor.field_value(nocal::tui::EditorField::start_time) == "09:00" &&
editor.field_value(nocal::tui::EditorField::end_time) == "10:00",
"new editor supplies useful default times");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.focused_field() == nocal::tui::EditorField::title && !editor.error().empty(),
"blank title blocks submission and focuses the invalid field");
(void)editor.handle_key("Design review");
focus(editor, nocal::tui::EditorField::end_time);
replace_field(editor, "08:00");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.error().find("after") != std::string_view::npos,
"backwards timed interval is rejected");
replace_field(editor, "10:30");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"valid timed event submits");
check(editor.result().title == "Design review" && !editor.result().uid.empty(),
"submitted event contains title and generated UID");
check(editor.result().end > editor.result().start && !editor.result().all_day,
"submitted timed event has a strict interval");
nocal::tui::EventEditor second{day};
check(second.result().uid != editor.result().uid, "locally generated UIDs do not collide");
}
void test_invalid_civil_inputs()
{
using namespace std::chrono;
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
(void)editor.handle_key("Planning");
focus(editor, nocal::tui::EditorField::start_date);
replace_field(editor, "2026-02-30");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.focused_field() == nocal::tui::EditorField::start_date,
"invalid civil date is rejected");
replace_field(editor, "2026-07-17");
focus(editor, nocal::tui::EditorField::start_time);
replace_field(editor, "25:10");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.focused_field() == nocal::tui::EditorField::start_time,
"out-of-range clock time is rejected");
check(editor.render(64, 18, false).find("Start time must be") != std::string::npos,
"validation error is visible in the rendered form");
}
void test_edit_preserves_identity_and_metadata()
{
using namespace std::chrono;
const nocal::Date day{2026y / July / 17d};
nocal::Event existing;
existing.uid = "stable-id";
existing.title = "Old title";
existing.start = nocal::make_local_time(day, 14, 0);
existing.end = nocal::make_local_time(day, 15, 0);
existing.location = "Studio";
existing.description = "Original notes";
existing.calendar_id = "work";
existing.color = "blue";
nocal::tui::EventEditor editor{existing};
replace_field(editor, "New title");
focus(editor, nocal::tui::EditorField::notes);
check(editor.field_value(nocal::tui::EditorField::notes) == "Original notes",
"edit initializes notes from description");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"edited event submits");
check(editor.result().uid == "stable-id" && editor.result().calendar_id == "work" &&
editor.result().color == "blue" && editor.result().description == "Original notes",
"editing preserves identity, calendar, color, and unchanged description");
check(editor.result().title == "New title", "editing updates changed fields");
}
void test_all_day_exclusive_end()
{
using namespace std::chrono;
const nocal::Date day{2026y / July / 17d};
nocal::tui::EventEditor editor{day};
(void)editor.handle_key("Conference");
focus(editor, nocal::tui::EditorField::all_day);
(void)editor.handle_key(" ");
check(editor.all_day(), "Space toggles all-day on its control");
check(editor.field_value(nocal::tui::EditorField::end_date) == "2026-07-18",
"all-day toggle supplies the normal exclusive next-day end");
focus(editor, nocal::tui::EditorField::end_date);
replace_field(editor, "2026-07-17");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.focused_field() == nocal::tui::EditorField::end_date,
"same-day exclusive all-day end is rejected");
replace_field(editor, "2026-07-18");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"next-day exclusive all-day end submits");
check(editor.result().all_day && nocal::local_date(editor.result().start) == day &&
nocal::local_date(editor.result().end) == nocal::Date{2026y / July / 18d},
"all-day result uses exclusive civil-day bounds");
}
void test_unicode_backspace_navigation_and_cancel()
{
using namespace std::chrono;
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
(void)editor.handle_key("Café ☕");
(void)editor.handle_key("\x7f");
check(editor.field_value(nocal::tui::EditorField::title) == "Café ",
"Backspace removes one complete UTF-8 code point");
(void)editor.handle_key("\x1b[B");
check(editor.focused_field() == nocal::tui::EditorField::start_date,
"Down arrow advances field focus");
(void)editor.handle_key("\x1b[A");
check(editor.focused_field() == nocal::tui::EditorField::title,
"Up arrow reverses field focus");
check(editor.handle_key("\x1b") == nocal::tui::EditorOutcome::cancel,
"Escape cancels without submission");
}
std::string strip_ansi(const std::string_view input)
{
std::string output;
for (std::size_t index = 0; index < input.size();) {
if (input[index] == '\x1b' && index + 1 < input.size() && input[index + 1] == '[') {
index += 2;
while (index < input.size() && input[index] != 'm') ++index;
if (index < input.size()) ++index;
} else {
output += input[index++];
}
}
return output;
}
void test_rendering()
{
using namespace std::chrono;
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
const auto plain = editor.render(72, 18, false);
check(std::count(plain.begin(), plain.end(), '\n') == 17,
"render fills requested height exactly");
check(plain.find("NEW APPOINTMENT") != std::string::npos &&
plain.find("Ctrl-S save") != std::string::npos &&
plain.find("[ ] timed appointment") != std::string::npos,
"render exposes form identity and controls");
check(plain.find("\x1b[") == std::string::npos, "ANSI can be disabled");
const auto styled = editor.render(72, 18, true);
check(styled.find("\x1b[") != std::string::npos && strip_ansi(styled) == plain,
"semantic ANSI attributes do not alter frame geometry");
const auto compact = editor.render(24, 6, false);
check(std::count(compact.begin(), compact.end(), '\n') == 5,
"compact render also fills requested height");
(void)editor.handle_key("会議 ☕");
const auto wide = editor.render(40, 18, false);
std::size_t start = 0;
bool exact_width = true;
while (start <= wide.size()) {
const auto end = wide.find('\n', start);
const auto line = wide.substr(start, end == std::string::npos ? wide.size() - start
: end - start);
int cells = 0;
for (std::size_t at = 0; at < line.size();) {
const auto first = static_cast<unsigned char>(line[at]);
std::size_t length = 1;
if ((first & 0xe0U) == 0xc0U) length = 2;
else if ((first & 0xf0U) == 0xe0U) length = 3;
else if ((first & 0xf8U) == 0xf0U) length = 4;
const auto fragment = std::string_view{line}.substr(at, length);
if (fragment == "" || fragment == "" || fragment == "") cells += 2;
else ++cells;
at += length;
}
exact_width = exact_width && cells == 40;
if (end == std::string::npos) break;
start = end + 1;
}
check(exact_width, "wide Unicode input retains exact terminal-cell width");
}
} // namespace
int main()
{
test_create_and_validation();
test_invalid_civil_inputs();
test_edit_preserves_identity_and_metadata();
test_all_day_exclusive_end();
test_unicode_backspace_navigation_and_cancel();
test_rendering();
if (failures != 0) {
std::cerr << failures << " editor test(s) failed\n";
return EXIT_FAILURE;
}
std::cout << "editor tests passed\n";
return EXIT_SUCCESS;
}

448
tests/ics_tests.cpp Normal file
View File

@@ -0,0 +1,448 @@
#include "nocal/storage/ics_store.hpp"
#include <chrono>
#include <cstdlib>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <stdexcept>
#include <string>
#include <vector>
#if !defined(_WIN32)
#include <sys/stat.h>
#endif
namespace {
using namespace std::chrono;
void require(bool condition, const char* message) {
if (!condition) {
throw std::runtime_error(message);
}
}
void write_fixture(const std::filesystem::path& path, const std::string& contents) {
std::ofstream output(path, std::ios::binary);
output << contents;
require(static_cast<bool>(output), "failed to write test fixture");
}
std::string read_fixture(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
require(static_cast<bool>(input), "failed to open test fixture");
return {std::istreambuf_iterator<char>{input}, {}};
}
void require_no_temporary_files(const std::filesystem::path& path) {
const std::string prefix = "." + path.filename().string() + ".tmp.";
for (const auto& entry : std::filesystem::directory_iterator(path.parent_path())) {
require(!entry.path().filename().string().starts_with(prefix),
"atomic save left a temporary file behind");
}
}
nocal::Event event_named(std::string uid, std::string title, int day_offset = 0) {
nocal::Event event;
event.uid = std::move(uid);
event.title = std::move(title);
event.start = sys_days{year{2026}/July/17} + days{day_offset} + 9h;
event.end = event.start + 1h;
return event;
}
void test_round_trip_and_folding(const std::filesystem::path& path) {
nocal::Event timed;
timed.uid = "meeting-1@example.test";
timed.title = "Planning, review; and \\ decisions";
timed.start = sys_days{year{2026}/July/17} + 9h + 30min + 15s;
timed.end = timed.start + 45min;
timed.location = "Nomarchy HQ";
timed.description = "First line\nA long UTF-8 line: café café café café café café café café café café café café";
timed.calendar_id = "work";
timed.color = "#89b4fa";
nocal::Event all_day;
all_day.uid = "holiday-1@example.test";
all_day.title = "Release day";
all_day.start = sys_days{year{2026}/July/20};
all_day.end = sys_days{year{2026}/July/22};
all_day.all_day = true;
const std::vector<nocal::Event> source{timed, all_day};
const auto saved_revision = nocal::storage::IcsStore::save(path, source);
const auto loaded = nocal::storage::IcsStore::load(path);
require(saved_revision.existed() && saved_revision.size() > 0,
"save did not return an existing non-empty revision");
require(loaded.revision.existed() && loaded.revision.size() == saved_revision.size(),
"load did not retain the exact saved revision");
require(loaded.warnings.empty(), "saved calendar produced load warnings");
require(loaded.safe_to_rewrite, "nocal-generated calendar was not safe to rewrite");
require(loaded.events.size() == 2, "round-trip event count differs");
const auto& loaded_timed = loaded.events[0];
require(loaded_timed.uid == timed.uid, "UID did not round-trip");
require(loaded_timed.title == timed.title, "escaped SUMMARY did not round-trip");
require(loaded_timed.start == timed.start && loaded_timed.end == timed.end,
"timed interval did not round-trip");
require(loaded_timed.description == timed.description, "DESCRIPTION did not round-trip");
require(loaded_timed.calendar_id == timed.calendar_id && loaded_timed.color == timed.color,
"nocal extension properties did not round-trip");
require(loaded.events[1].all_day, "all-day flag did not round-trip");
require(loaded.events[1].start == all_day.start && loaded.events[1].end == all_day.end,
"all-day interval did not round-trip");
std::ifstream input(path, std::ios::binary);
const std::string bytes{std::istreambuf_iterator<char>{input}, {}};
require(bytes.find("\r\n ") != std::string::npos, "long property was not folded");
require(bytes.find("Planning\\, review\\; and \\\\ decisions") != std::string::npos,
"TEXT escaping was not written");
std::size_t offset = 0;
while (offset < bytes.size()) {
const std::size_t end = bytes.find("\r\n", offset);
require(end != std::string::npos, "physical line is not CRLF terminated");
require(end - offset <= 75, "folded physical line exceeds 75 octets");
offset = end + 2;
}
}
void test_local_utc_and_recovery(const std::filesystem::path& path) {
write_fixture(path,
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\n"
"UID:local\r\n"
"DTSTART:20260717T101500\r\n"
"DTEND:20260717T111500\r\n"
"SUMMARY:Local time\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\n"
"UID:utc\r\n"
"DTSTART:20260717T101500Z\r\n"
"DTEND:20260717T111500Z\r\n"
"DESCRIPTION:folded text begins here and continues over a deliberately long value \r\n"
" with the rest\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\n"
"UID:one-day\r\n"
"DTSTART;VALUE=DATE:20260718\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\n"
"UID:broken\r\n"
"DTSTART:not-a-date\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.events.size() == 3, "malformed VEVENT was not isolated");
require(!loaded.warnings.empty(), "malformed VEVENT was not diagnosed");
require(!loaded.safe_to_rewrite, "malformed source was considered safe to rewrite");
require(loaded.events[0].start == loaded.events[1].start,
"local and UTC DATE-TIME should agree while tests run in UTC");
require(loaded.events[2].all_day, "DATE DTSTART was not recognized");
require(loaded.events[2].end - loaded.events[2].start == 24h,
"implicit all-day DTEND should be the following day in UTC");
require(loaded.events[1].description.find("value with the rest") != std::string::npos,
"folded content line was not unfolded");
}
void test_missing_file(const std::filesystem::path& path) {
std::error_code ignored;
std::filesystem::remove(path, ignored);
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.events.empty() && loaded.warnings.empty(),
"missing calendar should load as empty without warnings");
require(loaded.safe_to_rewrite, "missing calendar should be safe to create");
require(!loaded.revision.existed() && loaded.revision.size() == 0,
"missing calendar produced an existing revision");
}
void test_nested_creation_and_permissions(const std::filesystem::path& root) {
const auto path = root / "new" / "nested" / "calendar.ics";
nocal::storage::IcsStore::save(path, {});
require(std::filesystem::is_regular_file(path), "save did not create a nested calendar path");
require(read_fixture(path).starts_with("BEGIN:VCALENDAR\r\n"),
"new calendar does not contain a VCALENDAR");
require_no_temporary_files(path);
#if !defined(_WIN32)
struct stat metadata {};
require(::stat(path.c_str(), &metadata) == 0, "could not stat saved calendar");
require((metadata.st_mode & S_IRUSR) != 0 && (metadata.st_mode & S_IWUSR) != 0,
"saved calendar is not readable and writable by its owner");
require((metadata.st_mode & (S_IRWXG | S_IRWXO)) == 0,
"saved calendar grants group or other permissions");
#endif
}
void test_atomic_replacement(const std::filesystem::path& root) {
const auto path = root / "replace.ics";
write_fixture(path, "old calendar bytes");
nocal::Event replacement;
replacement.uid = "replacement@example.test";
replacement.title = "Replacement";
replacement.start = sys_days{year{2026}/July/23} + 10h;
replacement.end = replacement.start + 30min;
const std::vector<nocal::Event> events{replacement};
nocal::storage::IcsStore::save(path, events);
const std::string bytes = read_fixture(path);
require(bytes.find("old calendar bytes") == std::string::npos,
"save did not replace the existing calendar");
require(bytes.find("UID:replacement@example.test") != std::string::npos,
"replacement calendar bytes are incomplete");
require_no_temporary_files(path);
}
void test_revisions_reject_external_changes(const std::filesystem::path& root) {
const auto path = root / "revisions.ics";
const auto backup = nocal::storage::IcsStore::backup_path(path);
const std::vector<nocal::Event> original_events{
event_named("revision-1@example.test", "Original")};
const auto missing = nocal::storage::IcsStore::load(path);
const auto created = nocal::storage::IcsStore::save(path, original_events, missing.revision);
require(created.existed(), "created revision says the calendar does not exist");
require(!std::filesystem::exists(backup), "initial creation unexpectedly made a backup");
const auto loaded = nocal::storage::IcsStore::load(path);
const std::string original = read_fixture(path);
std::string externally_changed = original;
const auto title = externally_changed.find("SUMMARY:Original");
require(title != std::string::npos, "test fixture lacks the expected title");
externally_changed[title + std::string("SUMMARY:").size()] = 'X';
require(externally_changed.size() == original.size(), "external edit changed fixture size");
write_fixture(path, externally_changed);
write_fixture(backup, "existing backup must remain");
bool failed = false;
std::string message;
try {
const std::vector<nocal::Event> replacement{
event_named("revision-2@example.test", "Replacement")};
(void)nocal::storage::IcsStore::save(path, replacement, loaded.revision);
} catch (const std::runtime_error& error) {
failed = true;
message = error.what();
}
require(failed, "same-size external change was not rejected");
require(message.find("reload") != std::string::npos,
"external-change error did not tell the caller to reload");
require(read_fixture(path) == externally_changed,
"conflicting save changed the externally modified calendar");
require(read_fixture(backup) == "existing backup must remain",
"conflicting save changed the existing backup");
require_no_temporary_files(path);
}
void test_backup_replacement_failure_preserves_calendar(const std::filesystem::path& root) {
#if !defined(_WIN32)
const auto path = root / "backup-replacement-failure.ics";
const std::vector<nocal::Event> original_events{
event_named("survivor@example.test", "Must survive")};
const auto revision = nocal::storage::IcsStore::save(path, original_events);
const std::string before = read_fixture(path);
const auto backup = nocal::storage::IcsStore::backup_path(path);
std::filesystem::create_directory(backup);
write_fixture(backup / "sentinel", "backup destination must survive");
bool failed = false;
try {
const std::vector<nocal::Event> replacement{
event_named("replacement@example.test", "Replacement")};
(void)nocal::storage::IcsStore::save(path, replacement, revision);
} catch (const std::runtime_error&) {
failed = true;
}
require(failed, "save unexpectedly replaced a non-empty backup directory");
require(read_fixture(path) == before, "backup failure changed the calendar");
require(read_fixture(backup / "sentinel") == "backup destination must survive",
"backup failure damaged the existing backup destination");
require_no_temporary_files(path);
require_no_temporary_files(backup);
#else
(void)root;
#endif
}
void test_backup_rotation_permissions_and_restore(const std::filesystem::path& root) {
const auto path = root / "backup.ics";
const auto backup = nocal::storage::IcsStore::backup_path(path);
const std::vector<nocal::Event> first_events{event_named("first@example.test", "First")};
const auto first_revision = nocal::storage::IcsStore::save(path, first_events);
const std::string first_bytes = read_fixture(path);
require(!std::filesystem::exists(backup), "initial save created a backup");
const std::vector<nocal::Event> second_events{event_named("second@example.test", "Second", 1)};
const auto second_revision = nocal::storage::IcsStore::save(path, second_events, first_revision);
const std::string second_bytes = read_fixture(path);
require(read_fixture(backup) == first_bytes, "backup does not contain exact previous bytes");
#if !defined(_WIN32)
struct stat metadata {};
require(::stat(backup.c_str(), &metadata) == 0, "could not stat calendar backup");
require((metadata.st_mode & (S_IRWXG | S_IRWXO)) == 0,
"calendar backup grants group or other permissions");
require((metadata.st_mode & S_IRUSR) != 0 && (metadata.st_mode & S_IWUSR) != 0,
"calendar backup is not owner-readable and owner-writable");
#endif
const std::vector<nocal::Event> third_events{event_named("third@example.test", "Third", 2)};
const auto third_revision = nocal::storage::IcsStore::save(path, third_events, second_revision);
require(read_fixture(backup) == second_bytes, "backup did not rotate to the previous revision");
const std::string backup_before_restore = read_fixture(backup);
const auto restored = nocal::storage::IcsStore::restore_backup(path, third_revision);
require(restored.existed() && restored.size() == backup_before_restore.size(),
"restore returned an incorrect revision");
require(read_fixture(path) == second_bytes, "restore did not install exact backup bytes");
require(read_fixture(backup) == backup_before_restore, "restore changed the backup");
std::string external = read_fixture(path);
external[0] = external[0] == 'B' ? 'X' : 'B';
write_fixture(path, external);
bool conflict = false;
try {
(void)nocal::storage::IcsStore::restore_backup(path, restored);
} catch (const std::runtime_error&) {
conflict = true;
}
require(conflict, "restore ignored an external calendar change");
require(read_fixture(path) == external, "conflicting restore changed the calendar");
require(read_fixture(backup) == backup_before_restore,
"conflicting restore changed the backup");
require_no_temporary_files(path);
}
void test_restore_requires_backup(const std::filesystem::path& root) {
const auto path = root / "missing-backup.ics";
const std::vector<nocal::Event> events{event_named("only@example.test", "Only")};
const auto revision = nocal::storage::IcsStore::save(path, events);
const std::string before = read_fixture(path);
bool failed = false;
try {
(void)nocal::storage::IcsStore::restore_backup(path, revision);
} catch (const std::runtime_error&) {
failed = true;
}
require(failed, "restore unexpectedly succeeded without a backup");
require(read_fixture(path) == before, "failed restore changed the calendar");
require(!std::filesystem::exists(nocal::storage::IcsStore::backup_path(path)),
"failed restore created a backup");
require_no_temporary_files(path);
}
void test_lock_failure_preserves_existing_bytes(const std::filesystem::path& root) {
#if !defined(_WIN32)
const auto path = root / "must-survive.ics";
const std::string original = "existing bytes must survive";
write_fixture(path, original);
const auto backup = nocal::storage::IcsStore::backup_path(path);
write_fixture(backup, "existing backup must survive");
std::filesystem::path lock_path = path;
lock_path += ".lock";
std::filesystem::create_directory(lock_path);
bool failed = false;
try {
nocal::storage::IcsStore::save(path, {});
} catch (const std::runtime_error&) {
failed = true;
}
require(failed, "save unexpectedly succeeded without acquiring its destination lock");
require(read_fixture(path) == original, "failed save modified the existing calendar");
require(read_fixture(backup) == "existing backup must survive",
"failed save modified the existing backup");
require_no_temporary_files(path);
std::filesystem::remove(lock_path);
#else
(void)root;
#endif
}
void test_rename_failure_cleans_temporary_file(const std::filesystem::path& root) {
#if !defined(_WIN32)
const auto path = root / "destination-is-a-directory.ics";
std::filesystem::create_directory(path);
const auto sentinel = path / "existing-data";
write_fixture(sentinel, "must remain");
bool failed = false;
try {
nocal::storage::IcsStore::save(path, {});
} catch (const std::runtime_error&) {
failed = true;
}
require(failed, "save unexpectedly replaced a non-empty directory");
require(read_fixture(sentinel) == "must remain", "failed replacement damaged existing data");
require_no_temporary_files(path);
#else
(void)root;
#endif
}
void test_unsupported_data_is_not_rewrite_safe(const std::filesystem::path& root) {
const auto path = root / "unsupported.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"BEGIN:VEVENT\r\n"
"UID:recurring\r\n"
"DTSTART;TZID=Europe/London:20260717T101500\r\n"
"DTEND;TZID=Europe/London:20260717T111500\r\n"
"SUMMARY;LANGUAGE=en:Recurring appointment\r\n"
"RRULE:FREQ=WEEKLY\r\n"
"ATTENDEE:mailto:person@example.test\r\n"
"BEGIN:VALARM\r\n"
"ACTION:DISPLAY\r\n"
"TRIGGER:-PT10M\r\n"
"END:VALARM\r\n"
"END:VEVENT\r\n"
"X-UNKNOWN-CALENDAR-PROPERTY:keep me\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(!loaded.safe_to_rewrite,
"unsupported recurrence, alarm, attendee, parameters, or properties were considered safe");
require(!loaded.warnings.empty(), "unsafe calendar did not explain why editing is disabled");
}
} // namespace
int main() {
#if !defined(_WIN32)
setenv("TZ", "UTC", 1);
tzset();
#endif
const auto root = std::filesystem::temp_directory_path() / "nocal-ics-tests";
const auto path = root / "calendar.ics";
try {
std::error_code ignored;
std::filesystem::remove_all(root, ignored);
std::filesystem::create_directories(root);
test_round_trip_and_folding(path);
test_local_utc_and_recovery(path);
test_missing_file(path);
test_nested_creation_and_permissions(root);
test_atomic_replacement(root);
test_revisions_reject_external_changes(root);
test_backup_replacement_failure_preserves_calendar(root);
test_backup_rotation_permissions_and_restore(root);
test_restore_requires_backup(root);
test_lock_failure_preserves_existing_bytes(root);
test_rename_failure_cleans_temporary_file(root);
test_unsupported_data_is_not_rewrite_safe(root);
std::filesystem::remove_all(root, ignored);
std::cout << "ics_tests: ok\n";
return 0;
} catch (const std::exception& error) {
std::error_code ignored;
std::filesystem::remove_all(root, ignored);
std::cerr << "ics_tests: " << error.what() << '\n';
return 1;
}
}

730
tests/tui_focus_tests.cpp Normal file
View File

@@ -0,0 +1,730 @@
#include "nocal/domain/date.hpp"
#include "nocal/tui/Screen.hpp"
#include "nocal/tui/TuiApp.hpp"
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <span>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace {
int failures = 0;
void check(const bool condition, const std::string_view message)
{
if (!condition) {
++failures;
std::cerr << "FAIL: " << message << '\n';
}
}
nocal::Event make_event(std::string uid, std::string title, const nocal::Date start_day,
const unsigned start_hour, const unsigned start_minute,
const nocal::Date end_day, const unsigned end_hour,
const unsigned end_minute, const bool all_day = false)
{
nocal::Event event;
event.uid = std::move(uid);
event.title = std::move(title);
event.start = nocal::make_local_time(start_day, start_hour, start_minute);
event.end = nocal::make_local_time(end_day, end_hour, end_minute);
event.all_day = all_day;
return event;
}
nocal::tui::ScreenState screen_for(const nocal::Date day, const int width = 100,
const int height = 32)
{
nocal::tui::ScreenState state;
state.visible_month = day.year() / day.month();
state.selected_day = day;
state.today = day;
state.width = width;
state.height = height;
state.ansi = false;
return state;
}
bool focus_is(const nocal::tui::TuiApp& app, const std::string_view uid)
{
return app.state().focused_event_id && *app.state().focused_event_id == uid;
}
bool same_event(const nocal::Event& left, const nocal::Event& right)
{
return left.uid == right.uid && left.title == right.title && left.start == right.start &&
left.end == right.end && left.all_day == right.all_day &&
left.location == right.location && left.description == right.description &&
left.calendar_id == right.calendar_id && left.color == right.color;
}
void replace_editor_title(nocal::tui::TuiApp& app, const std::string_view title)
{
const auto* editor = app.editor();
check(editor != nullptr, "title replacement requires an active editor");
if (editor == nullptr) return;
const auto length = editor->field_value(nocal::tui::EditorField::title).size();
for (std::size_t index = 0; index < length; ++index) app.handle_input("\x7f");
app.handle_input(title);
}
void test_event_focus_cycles_in_display_order()
{
using namespace std::chrono;
const auto day = nocal::today();
const auto tomorrow = nocal::from_sys_days(nocal::to_sys_days(day) + days{1});
std::vector<nocal::Event> events;
events.push_back(make_event("late", "Late review", day, 14, 30, day, 15, 0));
events.push_back(make_event("all-day", "Release day", day, 0, 0, tomorrow, 0, 0, true));
events.push_back(make_event("early", "Morning review", day, 9, 15, day, 10, 0));
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "all-day"), "Tab initially focuses the all-day event shown first");
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "early"), "Tab advances to the earliest timed event");
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "late"), "Tab advances through timed events chronologically");
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "all-day"), "Tab wraps from the final event to the first");
app.dispatch(nocal::tui::Action::previous_event);
check(focus_is(app, "late"), "Shift-Tab moves backwards and wraps");
std::vector<nocal::Event> reverse_events = events;
nocal::tui::TuiApp reverse_app{reverse_events, -1, -1};
reverse_app.dispatch(nocal::tui::Action::previous_event);
check(focus_is(reverse_app, "late"),
"Shift-Tab with no focus starts at the final event of the day");
}
void test_open_close_and_day_movement()
{
using namespace std::chrono;
const auto day = nocal::today();
std::vector<nocal::Event> events{
make_event("reader", "Reader event", day, 11, 0, day, 12, 0),
};
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::select);
check(focus_is(app, "reader"), "Enter focuses the first event when none is focused");
check(app.state().show_event_details, "Enter opens the focused event reader");
app.dispatch(nocal::tui::Action::back);
check(!app.state().show_event_details, "Escape closes the event reader");
check(focus_is(app, "reader"), "closing the reader keeps the event focused");
const auto selected = nocal::to_sys_days(app.state().selected_day);
app.dispatch(nocal::tui::Action::right);
check(nocal::to_sys_days(app.state().selected_day) == selected + days{1},
"day navigation still works after closing the reader");
check(!app.state().focused_event_id, "moving to another day clears event focus");
check(!app.state().show_event_details, "moving to another day leaves the reader closed");
}
void test_empty_day_is_safe()
{
std::vector<nocal::Event> events;
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::next_event);
app.dispatch(nocal::tui::Action::previous_event);
app.dispatch(nocal::tui::Action::select);
app.dispatch(nocal::tui::Action::back);
check(!app.state().focused_event_id, "event navigation on an empty day keeps focus empty");
check(!app.state().show_event_details, "Enter on an empty day does not open the reader");
}
void test_reader_navigation_and_synthetic_ids()
{
const auto day = nocal::today();
std::vector<nocal::Event> events;
events.push_back(make_event("duplicate", "First duplicate", day, 8, 0, day, 9, 0));
events.push_back(make_event("duplicate", "Second duplicate", day, 10, 0, day, 11, 0));
events.push_back(make_event("", "No UID", day, 12, 0, day, 13, 0));
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::select);
check(focus_is(app, "@nocal:0"), "duplicate UIDs receive a focusable source identity");
check(app.state().show_event_details, "synthetic identities resolve in the reader");
app.dispatch(nocal::tui::Action::down);
check(focus_is(app, "@nocal:1"), "reader Down advances to the next appointment");
app.dispatch(nocal::tui::Action::up);
check(focus_is(app, "@nocal:0"), "reader Up returns to the previous appointment");
app.dispatch(nocal::tui::Action::next_event);
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "@nocal:2"), "an empty UID remains focusable and readable");
const auto frame = nocal::tui::render_month(events, app.state());
check(frame.find("No UID") != std::string::npos,
"renderer shares the controller's synthetic identity scheme");
}
void test_key_decoding()
{
using nocal::tui::Action;
using nocal::tui::decode_key;
check(decode_key("\t") == Action::next_event, "Tab decodes as next event");
check(decode_key("\x1b[Z") == Action::previous_event,
"terminal Shift-Tab decodes as previous event");
check(decode_key("\r") == Action::select, "carriage-return Enter decodes as select");
check(decode_key("\n") == Action::select, "newline Enter decodes as select");
check(decode_key("\x1b") == Action::back, "a lone Escape decodes as back");
check(decode_key("\x1b[A") == Action::up, "arrow decoding remains intact");
check(decode_key("a") == Action::add_event, "a decodes as add appointment");
check(decode_key("e") == Action::edit_event, "e decodes as edit appointment");
check(decode_key("d") == Action::delete_event, "d decodes as delete appointment");
check(decode_key("u") == Action::undo, "u decodes as undo");
check(decode_key("\x12") == Action::redo, "Ctrl-R decodes as redo");
}
void test_crud_preconditions_and_add()
{
std::vector<nocal::Event> events;
int save_calls = 0;
std::vector<nocal::Event> saved;
nocal::tui::TuiApp app{
events,
[&](const std::span<const nocal::Event> values) {
++save_calls;
saved.assign(values.begin(), values.end());
},
-1,
-1,
};
app.dispatch(nocal::tui::Action::edit_event);
check(!app.editor_active(), "edit without a focused appointment does not open an editor");
check(app.state().notification_is_error,
"edit without focus explains its appointment precondition");
app.dispatch(nocal::tui::Action::delete_event);
check(!app.delete_confirmation_active(),
"delete without a focused appointment does not open confirmation");
check(app.state().notification_is_error,
"delete without focus explains its appointment precondition");
const auto selected_day = app.state().selected_day;
app.dispatch(nocal::tui::Action::add_event);
check(app.editor_active(), "add opens the event editor on an empty day");
check(app.editor() != nullptr &&
app.editor()->field_value(nocal::tui::EditorField::start_date) ==
nocal::format_date(selected_day),
"add initializes the editor from the selected date");
check(app.editor() != nullptr &&
app.editor()->render(60, 18, false).find("NEW APPOINTMENT") != std::string::npos,
"the active add editor can be rendered by the controller");
app.handle_input("Created locally");
app.handle_input("\x13");
check(!app.editor_active(), "a valid Ctrl-S submit closes the add editor");
check(events.size() == 1 && events.front().title == "Created locally",
"add commits the validated editor result to the local model");
check(save_calls == 1 && saved.size() == 1 && saved.front().title == "Created locally",
"add calls the persistence boundary with the mutated model");
check(!events.front().uid.empty() && focus_is(app, events.front().uid),
"a successful add focuses the new appointment");
check(app.state().notification == "Appointment added." &&
!app.state().notification_is_error,
"a successful add reports a non-error status");
}
void test_edit_preserves_uid_and_rolls_back_on_save_failure()
{
const auto day = nocal::today();
auto original = make_event("stable-uid", "Original title", day, 9, 0, day, 10, 0);
original.location = "Desk";
original.description = "Original notes";
original.calendar_id = "personal";
original.color = "blue";
std::vector<nocal::Event> events{original};
int save_calls = 0;
nocal::tui::TuiApp app{
events,
[&](std::span<const nocal::Event>) {
++save_calls;
throw std::runtime_error{"disk full"};
},
-1,
-1,
};
app.dispatch(nocal::tui::Action::next_event);
app.dispatch(nocal::tui::Action::edit_event);
check(app.editor_active() && app.editor() != nullptr && app.editor()->editing(),
"edit opens a populated editing form for the focused appointment");
replace_editor_title(app, "Changed title");
app.handle_input("\x13");
check(save_calls == 1, "edit attempts persistence exactly once");
check(events.size() == 1 && same_event(events.front(), original),
"a throwing saver restores the exact event vector after edit");
check(focus_is(app, "stable-uid"), "edit rollback restores the original focus identity");
check(!app.editor_active(), "save failure returns to the month so its error is visible");
check(app.state().notification_is_error &&
app.state().notification.find("disk full") != std::string::npos,
"save failure is shown to the user with the persistence error");
int successful_saves = 0;
nocal::tui::TuiApp successful_app{
events,
[&](std::span<const nocal::Event>) { ++successful_saves; },
-1,
-1,
};
successful_app.dispatch(nocal::tui::Action::next_event);
successful_app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(successful_app, "Changed title");
successful_app.handle_input("\x13");
check(successful_saves == 1 && events.front().title == "Changed title",
"a successful edit commits and persists its changed fields");
check(events.front().uid == "stable-uid",
"editing preserves the appointment UID used by storage and focus");
}
void test_delete_cancel_confirm_and_rollback()
{
const auto day = nocal::today();
const auto target = make_event("delete-me", "Delete target", day, 9, 0, day, 10, 0);
const auto keep = make_event("keep-me", "Keep target", day, 11, 0, day, 12, 0);
std::vector<nocal::Event> events{target, keep};
int save_calls = 0;
nocal::tui::TuiApp app{
events,
[&](std::span<const nocal::Event>) { ++save_calls; },
-1,
-1,
};
app.dispatch(nocal::tui::Action::next_event);
app.dispatch(nocal::tui::Action::delete_event);
check(app.delete_confirmation_active(), "delete requires an explicit confirmation step");
const auto confirmation = nocal::tui::render_month(events, app.state());
check(confirmation.find("Delete target") != std::string::npos &&
confirmation.find("y confirm") != std::string::npos,
"delete confirmation names the appointment and shows its keys");
app.handle_input("n");
check(!app.delete_confirmation_active() && events.size() == 2 && save_calls == 0,
"n cancels deletion without mutating or saving");
check(focus_is(app, "delete-me"), "cancelled deletion retains appointment focus");
app.dispatch(nocal::tui::Action::delete_event);
app.handle_input("y");
check(events.size() == 1 && events.front().uid == "keep-me" && save_calls == 1,
"y deletes the focused appointment and persists once");
check(!app.state().focused_event_id &&
app.state().notification == "Appointment deleted.",
"successful deletion clears stale focus and reports success");
std::vector<nocal::Event> rollback_events{target};
nocal::tui::TuiApp rollback_app{
rollback_events,
[](std::span<const nocal::Event>) { throw std::runtime_error{"read only"}; },
-1,
-1,
};
rollback_app.dispatch(nocal::tui::Action::next_event);
rollback_app.dispatch(nocal::tui::Action::delete_event);
rollback_app.handle_input("y");
check(rollback_events.size() == 1 && same_event(rollback_events.front(), target),
"a throwing saver restores the exact vector after delete");
check(focus_is(rollback_app, "delete-me"),
"delete rollback restores the focused appointment identity");
check(rollback_app.state().notification_is_error,
"delete rollback displays a persistence error");
}
void test_crud_targets_synthetic_identities()
{
const auto day = nocal::today();
std::vector<nocal::Event> events{
make_event("duplicate", "First duplicate", day, 8, 0, day, 9, 0),
make_event("duplicate", "Second duplicate", day, 10, 0, day, 11, 0),
make_event("", "Empty UID", day, 12, 0, day, 13, 0),
};
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "@nocal:0"), "the first duplicate starts with its synthetic identity");
app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(app, "Edited first duplicate");
app.handle_input("\x13");
check(events[0].title == "Edited first duplicate" &&
events[1].title == "Second duplicate" && events[0].uid == "duplicate",
"edit resolves a synthetic identity to exactly the intended source event");
app.dispatch(nocal::tui::Action::next_event);
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "@nocal:2"), "empty UID remains targetable after editing a duplicate");
app.dispatch(nocal::tui::Action::delete_event);
app.handle_input("y");
check(events.size() == 2 && events[0].title == "Edited first duplicate" &&
events[1].title == "Second duplicate",
"delete resolves an empty-UID synthetic identity to the intended source event");
}
void test_add_edit_delete_undo_redo()
{
std::vector<nocal::Event> events;
int save_calls = 0;
nocal::tui::TuiApp app{
events,
[&](std::span<const nocal::Event>) { ++save_calls; },
-1,
-1,
};
const auto original_day = app.state().selected_day;
app.dispatch(nocal::tui::Action::add_event);
app.handle_input("History event");
app.handle_input("\x13");
check(events.size() == 1, "history test creates an appointment");
const auto added = events.front();
check(focus_is(app, added.uid), "successful add establishes the post-mutation focus");
app.dispatch(nocal::tui::Action::undo);
check(events.empty() && !app.state().focused_event_id &&
app.state().selected_day == original_day,
"undo add restores the exact pre-add model and selection state");
check(app.state().notification == "Undid add appointment.",
"undo add names the reversed operation");
app.dispatch(nocal::tui::Action::redo);
check(events.size() == 1 && same_event(events.front(), added) && focus_is(app, added.uid),
"redo add restores the exact event metadata and post-add focus");
app.dispatch(nocal::tui::Action::select);
check(app.state().show_event_details, "reader is open before starting the edit");
app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(app, "Edited through history");
app.handle_input("\x13");
const auto edited = events.front();
check(edited.title == "Edited through history", "history test commits an edit");
app.dispatch(nocal::tui::Action::undo);
check(events.size() == 1 && same_event(events.front(), added) && focus_is(app, added.uid) &&
app.state().show_event_details,
"undo edit restores metadata, focus, and the pre-editor reader state");
app.dispatch(nocal::tui::Action::redo);
check(events.size() == 1 && same_event(events.front(), edited) && focus_is(app, edited.uid) &&
!app.state().show_event_details,
"redo edit restores the exact edited model and post-editor view state");
app.dispatch(nocal::tui::Action::delete_event);
app.handle_input("y");
check(events.empty(), "history test commits a deletion");
app.dispatch(nocal::tui::Action::undo);
check(events.size() == 1 && same_event(events.front(), edited) && focus_is(app, edited.uid),
"undo delete restores the exact deleted event and its focus");
app.dispatch(nocal::tui::Action::redo);
check(events.empty() && !app.state().focused_event_id,
"redo delete restores the empty model and cleared focus");
check(save_calls == 9,
"three mutations and their undo/redo operations each persist exactly once");
}
void test_history_persistence_failure_is_transactional()
{
const auto day = nocal::today();
const auto original = make_event("history-failure", "Original", day, 9, 0, day, 10, 0);
std::vector<nocal::Event> events{original};
bool fail = false;
int save_calls = 0;
nocal::tui::TuiApp app{
events,
[&](std::span<const nocal::Event>) {
++save_calls;
if (fail) throw std::runtime_error{"history disk failure"};
},
-1,
-1,
};
app.dispatch(nocal::tui::Action::next_event);
app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(app, "Changed");
app.handle_input("\x13");
const auto changed = events.front();
fail = true;
app.dispatch(nocal::tui::Action::undo);
check(events.size() == 1 && same_event(events.front(), changed) &&
focus_is(app, "history-failure"),
"failed undo persistence leaves the current model and focus untouched");
check(app.state().notification_is_error &&
app.state().notification.find("history disk failure") != std::string::npos,
"failed undo reports the persistence error");
fail = false;
app.dispatch(nocal::tui::Action::undo);
check(events.size() == 1 && same_event(events.front(), original),
"undo remains available after a failed persistence attempt");
fail = true;
app.dispatch(nocal::tui::Action::redo);
check(events.size() == 1 && same_event(events.front(), original) &&
focus_is(app, "history-failure"),
"failed redo persistence leaves the restored model and focus untouched");
fail = false;
app.dispatch(nocal::tui::Action::redo);
check(events.size() == 1 && same_event(events.front(), changed),
"redo remains available after a failed persistence attempt");
check(save_calls == 5, "successful mutation plus four history attempts call the saver");
}
void test_new_mutation_invalidates_redo()
{
const auto day = nocal::today();
std::vector<nocal::Event> events{
make_event("redo-invalidation", "Original", day, 9, 0, day, 10, 0),
};
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::next_event);
app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(app, "First branch");
app.handle_input("\x13");
app.dispatch(nocal::tui::Action::undo);
app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(app, "Second branch");
app.handle_input("\x13");
app.dispatch(nocal::tui::Action::redo);
check(events.front().title == "Second branch",
"a new successful mutation after undo invalidates the redo branch");
check(app.state().notification_is_error && app.state().notification == "Nothing to redo.",
"an invalidated redo branch gives contextual feedback");
}
void test_history_is_bounded_to_one_hundred_mutations()
{
const auto day = nocal::today();
std::vector<nocal::Event> events{
make_event("bounded-history", "Version 0", day, 9, 0, day, 10, 0),
};
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::next_event);
for (int version = 1; version <= 101; ++version) {
app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(app, "Version " + std::to_string(version));
app.handle_input("\x13");
}
for (int count = 0; count < 100; ++count) app.dispatch(nocal::tui::Action::undo);
check(events.front().title == "Version 1",
"the bounded history retains the most recent one hundred mutations");
app.dispatch(nocal::tui::Action::undo);
check(events.front().title == "Version 1" &&
app.state().notification == "Nothing to undo.",
"the oldest mutation is discarded once the one-hundred entry bound is reached");
}
void test_history_is_unavailable_in_modal_states()
{
const auto day = nocal::today();
std::vector<nocal::Event> events{
make_event("modal-history", "Modal event", day, 9, 0, day, 10, 0),
};
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::add_event);
app.dispatch(nocal::tui::Action::undo);
check(app.editor_active() && app.state().notification_is_error,
"undo is unavailable while the editor owns input");
app.dispatch(nocal::tui::Action::back);
app.dispatch(nocal::tui::Action::next_event);
auto footer_state = screen_for(day, 180, 32);
footer_state.focused_event_id = "modal-history";
const auto month_frame = nocal::tui::render_month(events, footer_state);
check(month_frame.find("u undo") != std::string::npos &&
month_frame.find("Ctrl-R redo") != std::string::npos,
"the month footer advertises both history shortcuts");
app.dispatch(nocal::tui::Action::delete_event);
app.handle_input("u");
check(app.delete_confirmation_active() && app.state().notification_is_error &&
app.state().notification.find("deletion") != std::string::npos,
"history shortcuts are unavailable during delete confirmation");
const auto confirmation = nocal::tui::render_month(events, app.state());
check(confirmation.find("Finish or cancel deletion") != std::string::npos,
"delete confirmation renders its contextual history error");
app.handle_input("n");
}
void test_focused_line_rendering()
{
using namespace std::chrono;
const auto day = year{2026} / July / std::chrono::day{17};
auto state = screen_for(day, 112, 38);
state.focused_event_id = "focused";
const std::vector<nocal::tui::CalendarItem> items{
{.id = "plain",
.title = "Plain item",
.day = day,
.time_of_day = hours{8},
.all_day = false,
.end_time_of_day = hours{8} + minutes{30},
.start_day = day,
.end_day = day,
.location = {},
.description = {},
.detail_title = "Plain item",
.detail_all_day = false,
.detail_start_time_of_day = hours{8}},
{.id = "focused",
.title = "Focused item",
.day = day,
.time_of_day = hours{9} + minutes{15},
.all_day = false,
.end_time_of_day = hours{10},
.start_day = day,
.end_day = day,
.location = {},
.description = {},
.detail_title = "Focused item",
.detail_all_day = false,
.detail_start_time_of_day = hours{9} + minutes{15}},
};
const auto frame = nocal::tui::render_month(items, state);
check(frame.find(" 09:15 Focu") != std::string::npos,
"the focused appointment line has a visible cursor when ANSI is disabled");
check(frame.find(" 08:00 Plain") == std::string::npos,
"the focus cursor is only rendered on the focused appointment");
check(frame.find("\x1b[") == std::string::npos,
"focused line rendering respects disabled ANSI styling");
}
void test_focus_brings_hidden_event_into_view()
{
using namespace std::chrono;
const auto day = year{2026} / July / std::chrono::day{17};
auto state = screen_for(day, 100, 30);
state.focused_event_id = "last";
std::vector<nocal::tui::CalendarItem> items;
for (unsigned index = 0; index < 6; ++index) {
nocal::tui::CalendarItem item;
item.id = index == 5 ? "last" : "event-" + std::to_string(index);
item.title = index == 5 ? "Hidden focus target" : "Earlier event";
item.day = day;
item.time_of_day = hours{8 + index};
items.push_back(std::move(item));
}
const auto frame = nocal::tui::render_month(items, state);
check(frame.find(" 13:00") != std::string::npos,
"focused overflow event is windowed into the visible cell");
check(frame.find("hidden") != std::string::npos,
"dense cells retain an overflow indicator while focus is windowed");
}
void test_timed_event_details()
{
using namespace std::chrono;
const auto day = year{2026} / July / std::chrono::day{17};
auto event = make_event("detail", "Architecture review", day, 9, 15, day, 10, 45);
event.location = "Studio 4B";
event.description = "Review keyboard navigation and approve the terminal layout.";
const std::vector<nocal::Event> events{event};
auto state = screen_for(day, 80, 24);
state.focused_event_id = "detail";
state.show_event_details = true;
const auto frame = nocal::tui::render_month(events, state);
check(frame.find("APPOINTMENT") != std::string::npos, "the reader has a clear heading");
check(frame.find("Architecture review") != std::string::npos,
"the reader renders the full appointment title");
check(frame.find("Friday, 17 July 2026") != std::string::npos,
"the reader renders the full friendly date");
check(frame.find("09:15 10:45") != std::string::npos,
"the reader renders the complete local time range");
check(frame.find("Studio 4B") != std::string::npos,
"the reader renders the event location");
check(frame.find("Review keyboard navigation and approve the terminal layout.") !=
std::string::npos,
"the reader renders the event description");
check(frame.find("Esc back") != std::string::npos, "the reader footer explains how to close it");
}
void test_all_day_and_multiday_details()
{
using namespace std::chrono;
const auto first = year{2026} / July / std::chrono::day{16};
const auto middle = year{2026} / July / std::chrono::day{17};
const auto last = year{2026} / July / std::chrono::day{18};
const auto after = year{2026} / July / std::chrono::day{19};
const std::vector<nocal::Event> all_day{
make_event("festival", "Terminal festival", first, 0, 0, after, 0, 0, true),
};
auto all_day_state = screen_for(middle, 80, 24);
all_day_state.focused_event_id = "festival";
all_day_state.show_event_details = true;
const auto all_day_frame = nocal::tui::render_month(all_day, all_day_state);
check(all_day_frame.find("Time All day") != std::string::npos,
"all-day events are described as all day");
check(all_day_frame.find("Ends Saturday, 18 July 2026") != std::string::npos,
"exclusive all-day ends are presented as the last occupied day");
const std::vector<nocal::Event> timed{
make_event("overnight", "Long migration", first, 20, 0, last, 11, 30),
};
auto timed_state = screen_for(middle, 80, 24);
timed_state.focused_event_id = "overnight";
timed_state.show_event_details = true;
const auto timed_frame = nocal::tui::render_month(timed, timed_state);
check(timed_frame.find("Thursday, 16 July 2026") != std::string::npos,
"a multi-day reader retains the event's actual start date");
check(timed_frame.find("Ends Saturday, 18 July 2026") != std::string::npos,
"a timed multi-day reader renders its end date");
check(timed_frame.find("20:00 11:30") != std::string::npos,
"a timed multi-day continuation retains its real times");
check(timed_frame.find("Time All day") == std::string::npos,
"a timed multi-day continuation is not mislabeled all day");
}
void test_narrow_detail_wrapping_without_ansi()
{
using namespace std::chrono;
const auto day = year{2026} / July / std::chrono::day{17};
auto event = make_event("wrap", "Wrap check", day, 13, 0, day, 14, 0);
event.description = "Alpha beta gamma delta epsilon zeta eta theta.";
const std::vector<nocal::Event> events{event};
auto state = screen_for(day, 32, 20);
state.focused_event_id = "wrap";
state.show_event_details = true;
const auto frame = nocal::tui::render_month(events, state);
check(frame.find("Alpha beta gamma delta epsilon zeta eta theta.") == std::string::npos,
"narrow readers wrap descriptions instead of overflowing a line");
check(frame.find("Alpha beta gamma delta") != std::string::npos,
"the first wrapped description line is retained");
check(frame.find("epsilon zeta eta theta.") != std::string::npos,
"the remaining wrapped description text is retained");
check(frame.find("\x1b[") == std::string::npos,
"narrow detail rendering emits no ANSI when styling is disabled");
}
} // namespace
int main()
{
test_event_focus_cycles_in_display_order();
test_open_close_and_day_movement();
test_empty_day_is_safe();
test_reader_navigation_and_synthetic_ids();
test_key_decoding();
test_crud_preconditions_and_add();
test_edit_preserves_uid_and_rolls_back_on_save_failure();
test_delete_cancel_confirm_and_rollback();
test_crud_targets_synthetic_identities();
test_add_edit_delete_undo_redo();
test_history_persistence_failure_is_transactional();
test_new_mutation_invalidates_redo();
test_history_is_bounded_to_one_hundred_mutations();
test_history_is_unavailable_in_modal_states();
test_focused_line_rendering();
test_focus_brings_hidden_event_into_view();
test_timed_event_details();
test_all_day_and_multiday_details();
test_narrow_detail_wrapping_without_ansi();
if (failures != 0) {
std::cerr << failures << " TUI focus test(s) failed\n";
return EXIT_FAILURE;
}
std::cout << "TUI focus tests passed\n";
return EXIT_SUCCESS;
}

115
tests/tui_tests.cpp Normal file
View File

@@ -0,0 +1,115 @@
#include "nocal/domain/date.hpp"
#include "nocal/tui/Screen.hpp"
#include "nocal/tui/TuiApp.hpp"
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <optional>
#include <string>
#include <vector>
namespace {
int failures = 0;
void check(bool condition, const char* message)
{
if (!condition) {
++failures;
std::cerr << "FAIL: " << message << '\n';
}
}
void test_full_month_render()
{
using namespace std::chrono;
const nocal::tui::ScreenState state{
.visible_month = year{2026} / July,
.selected_day = year{2026} / July / day{17},
.today = year{2026} / July / day{17},
.width = 100,
.height = 30,
.show_help = false,
.ansi = false,
.focused_event_id = std::nullopt,
.show_event_details = false,
.confirm_delete = false,
.notification = {},
.notification_is_error = false,
};
const std::vector<nocal::tui::CalendarItem> items{
{.id = "1", .title = "Design review", .day = year{2026} / July / day{17},
.time_of_day = hours{9} + minutes{30}, .all_day = false,
.end_time_of_day = std::nullopt, .start_day = {}, .end_day = {},
.location = {}, .description = {}, .detail_title = {},
.detail_all_day = false, .detail_start_time_of_day = std::nullopt},
{.id = "2", .title = "Release day", .day = year{2026} / July / day{17},
.time_of_day = std::nullopt, .all_day = true,
.end_time_of_day = std::nullopt, .start_day = {}, .end_day = {},
.location = {}, .description = {}, .detail_title = {},
.detail_all_day = true, .detail_start_time_of_day = std::nullopt},
};
const auto frame = nocal::tui::render_month(items, state);
check(frame.find("Design") != std::string::npos, "appointment appears in its day cell");
check(frame.find("Release") != std::string::npos, "all-day appointment appears in its day cell");
check(frame.find("July 2026") != std::string::npos, "month title is rendered");
check(std::count(frame.begin(), frame.end(), '\n') == 29,
"renderer fills the requested height deterministically");
check(frame.find("\x1b[") == std::string::npos, "ANSI can be disabled");
}
void test_compact_and_input()
{
using namespace std::chrono;
const nocal::tui::ScreenState state{
.visible_month = year{2026} / July,
.selected_day = year{2026} / July / day{17},
.today = year{2026} / July / day{17},
.width = 30,
.height = 12,
.show_help = false,
.ansi = false,
.focused_event_id = std::nullopt,
.show_event_details = false,
.confirm_delete = false,
.notification = {},
.notification_is_error = false,
};
const auto frame = nocal::tui::render_month(std::span<const nocal::tui::CalendarItem>{}, state);
check(frame.find("July 2026") != std::string::npos, "compact mode keeps month context");
check(nocal::tui::decode_key("\x1b[6~") == nocal::tui::Action::next_month,
"PageDown is decoded");
check(nocal::tui::decode_key("k") == nocal::tui::Action::up, "Vim movement is decoded");
}
void test_navigation()
{
using namespace std::chrono;
std::vector<nocal::Event> events;
nocal::tui::TuiApp app{events, -1, -1};
const auto initial = app.state().selected_day;
app.dispatch(nocal::tui::Action::right);
check(sys_days{app.state().selected_day} == sys_days{initial} + days{1},
"right moves by one civil day");
app.dispatch(nocal::tui::Action::down);
check(sys_days{app.state().selected_day} == sys_days{initial} + days{8},
"down moves by one week");
}
} // namespace
int main()
{
test_full_month_render();
test_compact_and_input();
test_navigation();
if (failures != 0) {
std::cerr << failures << " TUI test(s) failed\n";
return EXIT_FAILURE;
}
std::cout << "TUI tests passed\n";
return EXIT_SUCCESS;
}