From 22c6399056c5a5b2500573122461654f68934c01 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Fri, 17 Jul 2026 21:24:06 +0100 Subject: [PATCH] feat: initialize Nocal terminal calendar --- .gitignore | 7 + AGENTS.md | 82 ++ README.md | 52 + docs/ARCHITECTURE.md | 89 ++ docs/HYPRLAND.md | 43 + docs/PRODUCT.md | 92 ++ docs/ROADMAP.md | 50 + include/nocal/domain/date.hpp | 36 + include/nocal/domain/domain.hpp | 6 + include/nocal/domain/event.hpp | 36 + include/nocal/domain/event_query.hpp | 29 + include/nocal/domain/month_layout.hpp | 33 + include/nocal/storage/ics_store.hpp | 62 ++ include/nocal/tui/EventEditor.hpp | 64 ++ include/nocal/tui/Screen.hpp | 83 ++ include/nocal/tui/Terminal.hpp | 65 ++ include/nocal/tui/TuiApp.hpp | 92 ++ include/nocal/tui/tui.hpp | 6 + meson.build | 53 + scripts/nocal-hyprland | 25 + share/applications/dev.nomarchy.nocal.desktop | 13 + shell.nix | 13 + src/domain/date.cpp | 172 +++ src/domain/event_query.cpp | 78 ++ src/domain/month_layout.cpp | 56 + src/main.cpp | 213 ++++ src/storage/ics_store.cpp | 976 ++++++++++++++++++ src/tui/EventEditor.cpp | 459 ++++++++ src/tui/Screen.cpp | 732 +++++++++++++ src/tui/Terminal.cpp | 112 ++ src/tui/TuiApp.cpp | 573 ++++++++++ tests/cli_recovery_test.sh | 83 ++ tests/domain_tests.cpp | 134 +++ tests/editor_tests.cpp | 234 +++++ tests/ics_tests.cpp | 448 ++++++++ tests/tui_focus_tests.cpp | 730 +++++++++++++ tests/tui_tests.cpp | 115 +++ 37 files changed, 6146 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/HYPRLAND.md create mode 100644 docs/PRODUCT.md create mode 100644 docs/ROADMAP.md create mode 100644 include/nocal/domain/date.hpp create mode 100644 include/nocal/domain/domain.hpp create mode 100644 include/nocal/domain/event.hpp create mode 100644 include/nocal/domain/event_query.hpp create mode 100644 include/nocal/domain/month_layout.hpp create mode 100644 include/nocal/storage/ics_store.hpp create mode 100644 include/nocal/tui/EventEditor.hpp create mode 100644 include/nocal/tui/Screen.hpp create mode 100644 include/nocal/tui/Terminal.hpp create mode 100644 include/nocal/tui/TuiApp.hpp create mode 100644 include/nocal/tui/tui.hpp create mode 100644 meson.build create mode 100644 scripts/nocal-hyprland create mode 100644 share/applications/dev.nomarchy.nocal.desktop create mode 100644 shell.nix create mode 100644 src/domain/date.cpp create mode 100644 src/domain/event_query.cpp create mode 100644 src/domain/month_layout.cpp create mode 100644 src/main.cpp create mode 100644 src/storage/ics_store.cpp create mode 100644 src/tui/EventEditor.cpp create mode 100644 src/tui/Screen.cpp create mode 100644 src/tui/Terminal.cpp create mode 100644 src/tui/TuiApp.cpp create mode 100644 tests/cli_recovery_test.sh create mode 100644 tests/domain_tests.cpp create mode 100644 tests/editor_tests.cpp create mode 100644 tests/ics_tests.cpp create mode 100644 tests/tui_focus_tests.cpp create mode 100644 tests/tui_tests.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06d73c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/build/ +/.cache/ +compile_commands.json +calendar.ics +*.ics.lock +*.ics.bak +.*.ics.tmp.* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..aeec80c --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2f06e7 --- /dev/null +++ b/README.md @@ -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). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..ff5acd4 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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 `` 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. diff --git a/docs/HYPRLAND.md b/docs/HYPRLAND.md new file mode 100644 index 0000000..e1bc020 --- /dev/null +++ b/docs/HYPRLAND.md @@ -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/). diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md new file mode 100644 index 0000000..d0bb0f1 --- /dev/null +++ b/docs/PRODUCT.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..5037e5b --- /dev/null +++ b/docs/ROADMAP.md @@ -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. diff --git a/include/nocal/domain/date.hpp b/include/nocal/domain/date.hpp new file mode 100644 index 0000000..9c05b8e --- /dev/null +++ b/include/nocal/domain/date.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "nocal/domain/event.hpp" + +#include +#include +#include + +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 diff --git a/include/nocal/domain/domain.hpp b/include/nocal/domain/domain.hpp new file mode 100644 index 0000000..ce7eb44 --- /dev/null +++ b/include/nocal/domain/domain.hpp @@ -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" diff --git a/include/nocal/domain/event.hpp b/include/nocal/domain/event.hpp new file mode 100644 index 0000000..b2f6900 --- /dev/null +++ b/include/nocal/domain/event.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +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 diff --git a/include/nocal/domain/event_query.hpp b/include/nocal/domain/event_query.hpp new file mode 100644 index 0000000..cf97c02 --- /dev/null +++ b/include/nocal/domain/event_query.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include "nocal/domain/date.hpp" +#include "nocal/domain/event.hpp" + +#include +#include +#include + +namespace nocal { + +using EventRef = std::reference_wrapper; +using EventRefs = std::vector; + +[[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 events, + TimePoint begin, TimePoint end); +[[nodiscard]] EventRefs events_on_day(std::span events, Date date); +[[nodiscard]] EventRefs events_in_month(std::span events, + YearMonth month); + +} // namespace nocal diff --git a/include/nocal/domain/month_layout.hpp b/include/nocal/domain/month_layout.hpp new file mode 100644 index 0000000..ed48a33 --- /dev/null +++ b/include/nocal/domain/month_layout.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include "nocal/domain/date.hpp" + +#include +#include + +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 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 diff --git a/include/nocal/storage/ics_store.hpp b/include/nocal/storage/ics_store.hpp new file mode 100644 index 0000000..636a4c6 --- /dev/null +++ b/include/nocal/storage/ics_store.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include "nocal/domain/event.hpp" + +#include +#include +#include +#include +#include +#include + +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 contents_; + + friend class IcsStore; +}; + +struct LoadResult { + std::vector events; + std::vector 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 events, + std::optional expected = std::nullopt); + + // Restores the exact bytes in .bak without changing the backup. + static FileRevision restore_backup( + const std::filesystem::path& path, + std::optional expected = std::nullopt); +}; + +} // namespace nocal::storage diff --git a/include/nocal/tui/EventEditor.hpp b/include/nocal/tui/EventEditor.hpp new file mode 100644 index 0000000..80bee5f --- /dev/null +++ b/include/nocal/tui/EventEditor.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include "nocal/domain/date.hpp" +#include "nocal/domain/event.hpp" + +#include +#include +#include +#include + +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(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 diff --git a/include/nocal/tui/Screen.hpp b/include/nocal/tui/Screen.hpp new file mode 100644 index 0000000..0c36539 --- /dev/null +++ b/include/nocal/tui/Screen.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include + +#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 time_of_day; + bool all_day{false}; + std::optional 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 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 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 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 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 diff --git a/include/nocal/tui/Terminal.hpp b/include/nocal/tui/Terminal.hpp new file mode 100644 index 0000000..1507e14 --- /dev/null +++ b/include/nocal/tui/Terminal.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include +#include + +#include + +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 diff --git a/include/nocal/tui/TuiApp.hpp b/include/nocal/tui/TuiApp.hpp new file mode 100644 index 0000000..29189eb --- /dev/null +++ b/include/nocal/tui/TuiApp.hpp @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#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)>; + + explicit TuiApp(std::vector& events, int input_fd = 0, int output_fd = 1); + TuiApp(std::vector& 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& 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 events; + std::chrono::year_month visible_month; + std::chrono::year_month_day selected_day; + std::optional 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& events_; + int input_fd_; + int output_fd_; + ScreenState state_; + SaveCallback saver_; + std::optional editor_; + std::optional editing_index_; + std::optional pending_mutation_before_; + std::vector undo_history_; + std::vector redo_history_; + bool running_{true}; + + [[nodiscard]] std::optional focused_event_index() const; + [[nodiscard]] HistorySnapshot capture_history_snapshot() const; + void restore_history_snapshot(HistorySnapshot snapshot); + void push_history(std::vector& history, HistoryEntry entry); + [[nodiscard]] bool persist(std::span 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 diff --git a/include/nocal/tui/tui.hpp b/include/nocal/tui/tui.hpp new file mode 100644 index 0000000..b58782e --- /dev/null +++ b/include/nocal/tui/tui.hpp @@ -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" diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..0dd5cae --- /dev/null +++ b/meson.build @@ -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') diff --git a/scripts/nocal-hyprland b/scripts/nocal-hyprland new file mode 100644 index 0000000..b831946 --- /dev/null +++ b/scripts/nocal-hyprland @@ -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 diff --git a/share/applications/dev.nomarchy.nocal.desktop b/share/applications/dev.nomarchy.nocal.desktop new file mode 100644 index 0000000..652e978 --- /dev/null +++ b/share/applications/dev.nomarchy.nocal.desktop @@ -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 diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..bab2e4d --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + nativeBuildInputs = with pkgs; [ + meson + ninja + pkg-config + ]; + + buildInputs = with pkgs; [ + gcc + ]; +} diff --git a/src/domain/date.cpp b/src/domain/date.cpp new file mode 100644 index 0000000..efc43da --- /dev/null +++ b/src/domain/date.cpp @@ -0,0 +1,172 @@ +#include "nocal/domain/date.hpp" + +#include +#include +#include +#include + +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(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(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 buffer{}; + const int count = std::snprintf(buffer.data(), buffer.size(), "%04d-%02u-%02u", + static_cast(date.year()), + static_cast(date.month()), + static_cast(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(tm.tm_mon + 1)}, + std::chrono::day{static_cast(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(date.year()) - 1900; + tm.tm_mon = static_cast(static_cast(date.month())) - 1; + tm.tm_mday = static_cast(static_cast(date.day())); + tm.tm_hour = static_cast(hour); + tm.tm_min = static_cast(minute); + tm.tm_sec = static_cast(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 diff --git a/src/domain/event_query.cpp b/src/domain/event_query.cpp new file mode 100644 index 0000000..5878f0f --- /dev/null +++ b/src/domain/event_query.cpp @@ -0,0 +1,78 @@ +#include "nocal/domain/event_query.hpp" + +#include +#include + +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 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 events, Date date) +{ + return events_overlapping(events, local_day_start(date), local_day_end(date)); +} + +EventRefs events_in_month(std::span 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 diff --git a/src/domain/month_layout.cpp b/src/domain/month_layout.cpp new file mode 100644 index 0000000..2c339eb --- /dev/null +++ b/src/domain/month_layout.cpp @@ -0,0 +1,56 @@ +#include "nocal/domain/month_layout.hpp" + +#include + +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(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 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..d932008 --- /dev/null +++ b/src/main.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +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 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 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 events) mutable { + revision = nocal::storage::IcsStore::save( + calendar_path, events, revision); + }; + } else { + saver = [](std::span) { + 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; + } +} diff --git a/src/storage/ics_store.cpp b/src/storage/ics_store.cpp new file mode 100644 index 0000000..fcb0d52 --- /dev/null +++ b/src/storage/ics_store.cpp @@ -0,0 +1,976 @@ +#include "nocal/storage/ics_store.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#include +#include +#include +#include +#endif + +namespace nocal::storage { +namespace { + +using namespace std::chrono; + +struct ParsedTime { + TimePoint value; + bool is_date = false; + std::optional date; +}; + +struct PendingEvent { + std::optional uid; + std::string title; + std::string location; + std::string description; + std::string calendar_id; + std::string color; + std::optional start; + std::optional 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(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 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(month_value)}, day{static_cast(day_value)}}; + return date.ok() ? std::optional{date} : std::nullopt; +} + +[[nodiscard]] std::optional make_local( + year_month_day date, int hour_value, int minute_value, int second_value) { + std::tm local{}; + local.tm_year = static_cast(date.year()) - 1900; + local.tm_mon = static_cast(static_cast(date.month())) - 1; + local.tm_mday = static_cast(static_cast(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(date.year()) - 1900 + || local.tm_mon != static_cast(static_cast(date.month())) - 1 + || local.tm_mday != static_cast(static_cast(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 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 unfold(std::istream& input) { + std::vector 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('0' + (value / 10) % 10); + result[1] = static_cast('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(index)] = static_cast('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(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(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(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 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(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(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 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(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{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(snapshot.bytes); + + std::istringstream input(snapshot.bytes, std::ios::in | std::ios::binary); + const auto lines = unfold(input); + std::optional 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 events, + std::optional 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 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, ¤t.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(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(std::move(bytes)); + return revision; +} + +FileRevision IcsStore::restore_backup( + const std::filesystem::path& path, std::optional 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, ¤t.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(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(saved.bytes); + return revision; +} + +} // namespace nocal::storage diff --git a/src/tui/EventEditor.cpp b/src/tui/EventEditor.cpp new file mode 100644 index 0000000..e49cc9d --- /dev/null +++ b/src/tui/EventEditor.cpp @@ -0,0 +1,459 @@ +#include "nocal/tui/EventEditor.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace nocal::tui { +namespace { + +constexpr std::size_t field_count = static_cast(EditorField::count); +constexpr std::array 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(field); +} + +std::string make_uid() +{ + static std::atomic sequence{0}; + static const std::uint64_t seed = [] { + std::random_device random; + const auto high = static_cast(random()) << 32U; + return high ^ static_cast(random()); + }(); + const auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + std::ostringstream stream; + stream << "nocal-" << std::hex << static_cast(now) << '-' + << (seed ^ sequence.fetch_add(1, std::memory_order_relaxed)) << "@local"; + return stream.str(); +} + +std::string two_digits(const unsigned value) +{ + std::array buffer{}; + std::snprintf(buffer.data(), buffer.size(), "%02u", value); + return {buffer.data(), 2}; +} + +std::pair 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(value.tm_hour), static_cast(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((text[0] - '0') * 10 + text[1] - '0'); + minute = static_cast((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(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(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(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(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(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(index_of(focused_)); + const auto count = static_cast(field_count); + focused_ = static_cast((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 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(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(lines.size()) > height) { + std::vector compact; + compact.reserve(static_cast(height)); + compact.push_back(top); + if (height > 2) { + const int available = height - 2; + const int selected_line = 4 + static_cast(index_of(focused_)); + const int maximum_start = static_cast(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(start + offset)]); + } + } + if (height > 1) compact.push_back(bottom); + lines = std::move(compact); + } + while (static_cast(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(row)]; + } + return frame; +} + +} // namespace nocal::tui diff --git a/src/tui/Screen.cpp b/src/tui/Screen.cpp new file mode 100644 index 0000000..5c6119a --- /dev/null +++ b/src/tui/Screen.cpp @@ -0,0 +1,732 @@ +#include "nocal/tui/Screen.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace nocal::tui { +namespace { + +using namespace std::chrono; + +constexpr std::array month_names{ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"}; +constexpr std::array weekday_long{ + "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"}; +constexpr std::array 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(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(continuation) >= text.size()) { + length = 1; + return 0xfffd; + } + for (int offset = 1; offset <= continuation; ++offset) { + const auto byte = static_cast(text[at + static_cast(offset)]); + if ((byte & 0xc0) != 0x80) { + length = 1; + return 0xfffd; + } + result = (result << 6) | (byte & 0x3f); + } + length = static_cast(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(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(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::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(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 result{'0', '0', '\0'}; + result[0] = static_cast('0' + (value / 10) % 10); + result[1] = static_cast('0' + value % 10); + return std::string{result.data(), 2}; +} + +std::string iso_date(const year_month_day day) { + return std::to_string(static_cast(day.year())) + "-" + + two_digits(static_cast(day.month())) + "-" + + two_digits(static_cast(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((total / 60 + 24) % 24); + const auto minute = static_cast((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& 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((total / 60 + 24) % 24); + const auto minute = static_cast((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(value.day())) + " " + + std::string{month_names[static_cast(value.month()) - 1]} + " " + + std::to_string(static_cast(value.year())); +} + +std::vector wrap_text(const std::string_view text, const int requested_width) { + const int width = std::max(1, requested_width); + std::vector 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 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 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 tiny(static_cast(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(line)]; + } + return output.str(); + } + + std::vector 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(current - selected_items.begin() + 1); + const auto total = std::max(1, selected_items.size()); + + const int inner = width - 2; + std::vector> 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(inner), ' '); + std::string style; + if (line == body_rows - 1) { + value = fit_text(footer, inner); + style = "7"; + } else if (line < static_cast(content.size())) { + value = content[static_cast(line)].first; + style = content[static_cast(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(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(local.tm_mon + 1)} / + day{static_cast(local.tm_mday)}, + hours{local.tm_hour} + minutes{local.tm_min}, + }; +} + +std::string compact_frame(std::span 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 lines; + const auto title = std::string{month_names[static_cast(state.visible_month.month()) - 1]} + + " " + std::to_string(static_cast(state.visible_month.year())); + lines.push_back(styled(centred(title, width), "1", state.ansi)); + std::array tokens{}; + for (int column = 0; column < 7; ++column) { + tokens[static_cast(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(date.day())); + const auto token = tokens[static_cast(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(lines.size()) < height - 1) { + lines.emplace_back(static_cast(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(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(lines.size()) > height) lines.resize(static_cast(height)); + + std::ostringstream output; + for (int line = 0; line < height; ++line) { + if (line != 0) output << '\n'; + output << lines[static_cast(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 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 widths(7, inner_total / 7); + for (int column = 0; column < inner_total % 7; ++column) { + ++widths[static_cast(column)]; + } + + const int content_rows = std::max(6, state.height - 10); + std::vector row_heights(6, content_rows / 6); + for (int row = 0; row < content_rows % 6; ++row) { + ++row_heights[static_cast(row)]; + } + + const auto first = sys_days{state.visible_month / day{1}}; + const auto grid_start = monday_on_or_before(first); + std::map> 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(state.visible_month.month()) - 1; + const auto title = "‹ " + std::string{month_names[month_index]} + " " + + std::to_string(static_cast(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(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(column)]; + std::string cell; + std::string style; + if (line == 0) { + cell = " " + std::to_string(static_cast(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(line - 1); + const auto slots = static_cast(row_heights[static_cast(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(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(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 events, const ScreenState& state) { + std::vector items; + items.reserve(events.size() * 2); + std::map 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(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{items}, state); +} + +} // namespace nocal::tui diff --git a/src/tui/Terminal.cpp b/src/tui/Terminal.cpp new file mode 100644 index 0000000..a3b31ef --- /dev/null +++ b/src/tui/Terminal.cpp @@ -0,0 +1,112 @@ +#include "nocal/tui/Terminal.hpp" + +#include +#include +#include + +#include +#include + +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(size.ws_col), static_cast(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(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(~(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(~(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 diff --git a/src/tui/TuiApp.cpp b/src/tui/TuiApp.cpp new file mode 100644 index 0000000..ac56eb9 --- /dev/null +++ b/src/tui/TuiApp.cpp @@ -0,0 +1,573 @@ +#include "nocal/tui/TuiApp.hpp" + +#include "nocal/domain/event_query.hpp" +#include "nocal/tui/Terminal.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +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(local.tm_mon + 1)} / + day{static_cast(local.tm_mday)}; +} + +year_month_day clamp_to_month(const year_month month, const unsigned requested_day) { + const auto last_day = static_cast((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 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 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::distance(events.begin(), found)); +} + +} // namespace + +TuiApp::TuiApp(std::vector& events, const int input_fd, const int output_fd) + : TuiApp(events, SaveCallback{}, input_fd, output_fd) {} + +TuiApp::TuiApp(std::vector& 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 TuiApp::focused_event_index() const { + if (!state_.focused_event_id) return std::nullopt; + const auto event_span = std::span{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& history, HistoryEntry entry) { + if (history.size() == history_limit) history.erase(history.begin()); + history.push_back(std::move(entry)); +} + +bool TuiApp::persist(const std::span 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{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{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{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{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(*index)); + if (!persist(std::span{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{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(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(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{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 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(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 diff --git a/tests/cli_recovery_test.sh b/tests/cli_recovery_test.sh new file mode 100644 index 0000000..2a1c65a --- /dev/null +++ b/tests/cli_recovery_test.sh @@ -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" diff --git a/tests/domain_tests.cpp b/tests/domain_tests.cpp new file mode 100644 index 0000000..077ea15 --- /dev/null +++ b/tests/domain_tests.cpp @@ -0,0 +1,134 @@ +#include "nocal/domain/domain.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int failures = 0; + +void check(bool condition, std::string_view message) +{ + if (!condition) { + ++failures; + std::cerr << "FAIL: " << message << '\n'; + } +} + +template +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 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; +} diff --git a/tests/editor_tests.cpp b/tests/editor_tests.cpp new file mode 100644 index 0000000..8dfbbb5 --- /dev/null +++ b/tests/editor_tests.cpp @@ -0,0 +1,234 @@ +#include "nocal/domain/date.hpp" +#include "nocal/tui/EventEditor.hpp" + +#include +#include +#include +#include +#include +#include + +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(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; +} diff --git a/tests/ics_tests.cpp b/tests/ics_tests.cpp new file mode 100644 index 0000000..0e42dc6 --- /dev/null +++ b/tests/ics_tests.cpp @@ -0,0 +1,448 @@ +#include "nocal/storage/ics_store.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#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(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(input), "failed to open test fixture"); + return {std::istreambuf_iterator{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 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{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 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 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 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 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 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 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 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 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 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; + } +} diff --git a/tests/tui_focus_tests.cpp b/tests/tui_focus_tests.cpp new file mode 100644 index 0000000..027e4c0 --- /dev/null +++ b/tests/tui_focus_tests.cpp @@ -0,0 +1,730 @@ +#include "nocal/domain/date.hpp" +#include "nocal/tui/Screen.hpp" +#include "nocal/tui/TuiApp.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 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 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 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 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 events; + int save_calls = 0; + std::vector saved; + nocal::tui::TuiApp app{ + events, + [&](const std::span 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 events{original}; + int save_calls = 0; + nocal::tui::TuiApp app{ + events, + [&](std::span) { + ++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) { ++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 events{target, keep}; + int save_calls = 0; + nocal::tui::TuiApp app{ + events, + [&](std::span) { ++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 rollback_events{target}; + nocal::tui::TuiApp rollback_app{ + rollback_events, + [](std::span) { 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 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 events; + int save_calls = 0; + nocal::tui::TuiApp app{ + events, + [&](std::span) { ++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 events{original}; + bool fail = false; + int save_calls = 0; + nocal::tui::TuiApp app{ + events, + [&](std::span) { + ++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 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 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 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 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 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 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 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 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 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; +} diff --git a/tests/tui_tests.cpp b/tests/tui_tests.cpp new file mode 100644 index 0000000..73709d7 --- /dev/null +++ b/tests/tui_tests.cpp @@ -0,0 +1,115 @@ +#include "nocal/domain/date.hpp" +#include "nocal/tui/Screen.hpp" +#include "nocal/tui/TuiApp.hpp" + +#include +#include +#include +#include +#include +#include +#include + +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 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{}, 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 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; +}