feat: initialize Nocal terminal calendar
This commit is contained in:
36
include/nocal/domain/date.hpp
Normal file
36
include/nocal/domain/date.hpp
Normal 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
|
||||
6
include/nocal/domain/domain.hpp
Normal file
6
include/nocal/domain/domain.hpp
Normal 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"
|
||||
36
include/nocal/domain/event.hpp
Normal file
36
include/nocal/domain/event.hpp
Normal 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
|
||||
29
include/nocal/domain/event_query.hpp
Normal file
29
include/nocal/domain/event_query.hpp
Normal 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
|
||||
33
include/nocal/domain/month_layout.hpp
Normal file
33
include/nocal/domain/month_layout.hpp
Normal 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
|
||||
62
include/nocal/storage/ics_store.hpp
Normal file
62
include/nocal/storage/ics_store.hpp
Normal 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
|
||||
64
include/nocal/tui/EventEditor.hpp
Normal file
64
include/nocal/tui/EventEditor.hpp
Normal 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
|
||||
83
include/nocal/tui/Screen.hpp
Normal file
83
include/nocal/tui/Screen.hpp
Normal 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
|
||||
65
include/nocal/tui/Terminal.hpp
Normal file
65
include/nocal/tui/Terminal.hpp
Normal 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
|
||||
92
include/nocal/tui/TuiApp.hpp
Normal file
92
include/nocal/tui/TuiApp.hpp
Normal 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
|
||||
6
include/nocal/tui/tui.hpp
Normal file
6
include/nocal/tui/tui.hpp
Normal 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"
|
||||
Reference in New Issue
Block a user