feat: initialize Nocal terminal calendar

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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