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

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

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