Add a read-only 42-day occurrence agenda with keyboard navigation, six-week paging, exact return-to-grid focus, search and calendar overlays, and bounded recurrence expansion. Keep agenda state outside persistence, document month-only mutations, and cover empty windows, filtering, overlays, navigation, rendering, and terminal restoration.
1070 lines
37 KiB
C++
1070 lines
37 KiB
C++
#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)};
|
|
}
|
|
|
|
minutes local_time_of_day(const TimePoint point) {
|
|
const auto instant = Clock::to_time_t(point);
|
|
std::tm local{};
|
|
::localtime_r(&instant, &local);
|
|
return hours{local.tm_hour} + minutes{local.tm_min};
|
|
}
|
|
|
|
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::optional<std::size_t> source_index(const std::span<const Event> events,
|
|
const Event* source) {
|
|
for (std::size_t index = 0; index < events.size(); ++index) {
|
|
if (&events[index] == source) return index;
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
void erase_last_codepoint(std::string& value) {
|
|
if (value.empty()) return;
|
|
auto at = value.size() - 1;
|
|
while (at > 0 && (static_cast<unsigned char>(value[at]) & 0xc0U) == 0x80U) --at;
|
|
value.erase(at);
|
|
}
|
|
|
|
bool is_search_text(const std::string_view input) {
|
|
return std::all_of(input.begin(), input.end(), [](const char character) {
|
|
const auto byte = static_cast<unsigned char>(character);
|
|
return byte >= 0x20U && byte != 0x7fU;
|
|
});
|
|
}
|
|
|
|
} // 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();
|
|
synchronize_calendars();
|
|
}
|
|
|
|
std::optional<EventOccurrence> TuiApp::focused_occurrence() const {
|
|
if (!state_.focused_event_id) return std::nullopt;
|
|
const auto event_span = std::span<const Event>{events_};
|
|
for (const auto& occurrence : visible_occurrences_on_selected_day()) {
|
|
if (occurrence_focus_id(event_span, occurrence) == *state_.focused_event_id) {
|
|
return occurrence;
|
|
}
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
std::optional<std::size_t> TuiApp::focused_event_index() const {
|
|
const auto occurrence = focused_occurrence();
|
|
return occurrence ? source_index(std::span<const Event>{events_}, occurrence->source)
|
|
: std::nullopt;
|
|
}
|
|
|
|
EventOccurrences TuiApp::visible_occurrences_on_selected_day() const {
|
|
auto occurrences = occurrences_on_day(std::span<const Event>{events_}, state_.selected_day);
|
|
std::erase_if(occurrences, [this](const EventOccurrence& occurrence) {
|
|
return occurrence.source == nullptr ||
|
|
!event_is_visible(*occurrence.source,
|
|
std::span<const Calendar>{calendars_});
|
|
});
|
|
return occurrences;
|
|
}
|
|
|
|
void TuiApp::synchronize_calendars() {
|
|
std::vector<std::string> ids{""};
|
|
ids.reserve(events_.size() + 1);
|
|
for (const auto& event : events_) {
|
|
if (!event.calendar_id.empty()) ids.push_back(event.calendar_id);
|
|
}
|
|
std::sort(ids.begin() + 1, ids.end());
|
|
ids.erase(std::unique(ids.begin(), ids.end()), ids.end());
|
|
|
|
std::vector<Calendar> synchronized;
|
|
synchronized.reserve(ids.size());
|
|
for (const auto& id : ids) {
|
|
const auto existing = std::find_if(
|
|
calendars_.begin(), calendars_.end(),
|
|
[&id](const Calendar& calendar) { return calendar.id == id; });
|
|
if (existing != calendars_.end()) {
|
|
synchronized.push_back(*existing);
|
|
} else {
|
|
synchronized.push_back(Calendar{
|
|
.id = id,
|
|
.name = id.empty() ? "Default" : id,
|
|
.color = {},
|
|
.visible = true,
|
|
.read_only = false,
|
|
});
|
|
}
|
|
}
|
|
calendars_ = std::move(synchronized);
|
|
if (state_.calendar_index >= calendars_.size()) state_.calendar_index = 0;
|
|
}
|
|
|
|
void TuiApp::begin_calendar_picker() {
|
|
synchronize_calendars();
|
|
state_.show_calendar_picker = true;
|
|
state_.show_event_details = false;
|
|
state_.show_help = false;
|
|
state_.confirm_delete = false;
|
|
}
|
|
|
|
void TuiApp::close_calendar_picker() {
|
|
state_.show_calendar_picker = false;
|
|
}
|
|
|
|
void TuiApp::toggle_selected_calendar() {
|
|
if (calendars_.empty() || state_.calendar_index >= calendars_.size()) return;
|
|
auto& calendar = calendars_[state_.calendar_index];
|
|
calendar.visible = !calendar.visible;
|
|
if (!focused_occurrence()) {
|
|
state_.focused_event_id.reset();
|
|
state_.show_event_details = false;
|
|
}
|
|
if (state_.show_agenda) refresh_agenda();
|
|
}
|
|
|
|
void TuiApp::begin_agenda() {
|
|
state_.show_agenda = true;
|
|
state_.agenda_start_day = state_.selected_day;
|
|
state_.agenda_index = 0;
|
|
state_.agenda_items.clear();
|
|
state_.show_event_details = false;
|
|
state_.show_help = false;
|
|
state_.confirm_delete = false;
|
|
state_.search_prompt = false;
|
|
state_.show_search_results = false;
|
|
state_.show_calendar_picker = false;
|
|
refresh_agenda();
|
|
}
|
|
|
|
void TuiApp::close_agenda() {
|
|
state_.show_agenda = false;
|
|
}
|
|
|
|
void TuiApp::refresh_agenda() {
|
|
if (!state_.agenda_start_day.ok()) state_.agenda_start_day = state_.selected_day;
|
|
std::optional<std::string> selected_identity;
|
|
if (state_.agenda_index < state_.agenda_items.size()) {
|
|
selected_identity = state_.agenda_items[state_.agenda_index].focus_id;
|
|
}
|
|
|
|
state_.agenda_items.clear();
|
|
const auto event_span = std::span<const Event>{events_};
|
|
const auto calendar_span = std::span<const Calendar>{calendars_};
|
|
const auto first_day = sys_days{state_.agenda_start_day};
|
|
constexpr auto window = days{42};
|
|
const auto end_day = first_day + window;
|
|
for (const auto& occurrence : occurrences_overlapping(
|
|
event_span, local_day_start(year_month_day{first_day}),
|
|
local_day_start(year_month_day{end_day}))) {
|
|
if (occurrence.source == nullptr ||
|
|
!event_is_visible(*occurrence.source, calendar_span)) {
|
|
continue;
|
|
}
|
|
const auto occurrence_day = local_date(occurrence.start);
|
|
const auto display_day = std::max(occurrence_day, state_.agenda_start_day);
|
|
const bool continuation = display_day != occurrence_day;
|
|
state_.agenda_items.push_back(AgendaItem{
|
|
.focus_id = occurrence_focus_id(event_span, occurrence),
|
|
.title = occurrence.source->title,
|
|
.day = display_day,
|
|
.time_of_day = occurrence.source->all_day || continuation
|
|
? std::nullopt
|
|
: std::optional{local_time_of_day(occurrence.start)},
|
|
.all_day = occurrence.source->all_day || continuation,
|
|
.location = occurrence.source->location,
|
|
.calendar_id = occurrence.source->calendar_id,
|
|
.recurring = occurrence.source->recurrence.has_value(),
|
|
});
|
|
}
|
|
std::stable_sort(state_.agenda_items.begin(), state_.agenda_items.end(),
|
|
[](const AgendaItem& left, const AgendaItem& right) {
|
|
const auto left_day = sys_days{left.day};
|
|
const auto right_day = sys_days{right.day};
|
|
if (left_day != right_day) return left_day < right_day;
|
|
if (left.all_day != right.all_day) return left.all_day;
|
|
if (left.time_of_day != right.time_of_day) {
|
|
return left.time_of_day.value_or(minutes{-1}) <
|
|
right.time_of_day.value_or(minutes{-1});
|
|
}
|
|
if (left.title != right.title) return left.title < right.title;
|
|
return left.focus_id < right.focus_id;
|
|
});
|
|
|
|
if (state_.agenda_items.empty()) {
|
|
state_.agenda_index = 0;
|
|
return;
|
|
}
|
|
if (selected_identity) {
|
|
const auto selected = std::find_if(
|
|
state_.agenda_items.begin(), state_.agenda_items.end(),
|
|
[&](const AgendaItem& item) { return item.focus_id == *selected_identity; });
|
|
if (selected != state_.agenda_items.end()) {
|
|
state_.agenda_index = static_cast<std::size_t>(selected - state_.agenda_items.begin());
|
|
return;
|
|
}
|
|
}
|
|
state_.agenda_index = std::min(state_.agenda_index, state_.agenda_items.size() - 1);
|
|
}
|
|
|
|
void TuiApp::shift_agenda(const int direction) {
|
|
if (direction == 0) return;
|
|
constexpr auto window = days{42};
|
|
state_.agenda_start_day = year_month_day{
|
|
sys_days{state_.agenda_start_day} + window * direction};
|
|
state_.agenda_items.clear();
|
|
state_.agenda_index = 0;
|
|
refresh_agenda();
|
|
}
|
|
|
|
void TuiApp::choose_agenda_item() {
|
|
if (state_.agenda_index >= state_.agenda_items.size()) return;
|
|
const auto& item = state_.agenda_items[state_.agenda_index];
|
|
state_.selected_day = item.day;
|
|
state_.visible_month = item.day.year() / item.day.month();
|
|
state_.focused_event_id = item.focus_id;
|
|
state_.show_event_details = false;
|
|
close_agenda();
|
|
}
|
|
|
|
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;
|
|
state_.search_prompt = false;
|
|
state_.show_search_results = false;
|
|
state_.show_calendar_picker = false;
|
|
state_.show_agenda = false;
|
|
synchronize_calendars();
|
|
}
|
|
|
|
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;
|
|
state_.show_calendar_picker = false;
|
|
state_.show_agenda = false;
|
|
}
|
|
|
|
void TuiApp::begin_edit() {
|
|
const auto index = focused_event_index();
|
|
if (!index) {
|
|
set_notification("Focus an appointment before editing.", true);
|
|
return;
|
|
}
|
|
try {
|
|
editor_.emplace(events_[*index]);
|
|
} catch (const std::exception& error) {
|
|
set_notification("Cannot edit this appointment: " + std::string{error.what()}, true);
|
|
return;
|
|
}
|
|
pending_mutation_before_ = capture_history_snapshot();
|
|
editing_index_ = index;
|
|
state_.show_event_details = false;
|
|
state_.show_help = false;
|
|
state_.confirm_delete = false;
|
|
state_.show_calendar_picker = false;
|
|
state_.show_agenda = 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;
|
|
state_.show_calendar_picker = false;
|
|
state_.show_agenda = 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);
|
|
synchronize_calendars();
|
|
state_.focused_event_id = original_focus;
|
|
editor_.reset();
|
|
editing_index_.reset();
|
|
pending_mutation_before_.reset();
|
|
return;
|
|
}
|
|
|
|
synchronize_calendars();
|
|
editor_.reset();
|
|
editing_index_.reset();
|
|
const auto event_span = std::span<const Event>{events_};
|
|
if (!event_is_visible(events_[changed_index],
|
|
std::span<const Calendar>{calendars_})) {
|
|
state_.focused_event_id.reset();
|
|
state_.show_event_details = false;
|
|
record_mutation(editing && events_[changed_index].recurrence
|
|
? "edit recurring series"
|
|
: editing ? "edit appointment" : "add appointment");
|
|
set_notification(std::move(success));
|
|
return;
|
|
}
|
|
auto preferred = occurrences_on_day(event_span, state_.selected_day);
|
|
const auto matching = std::find_if(preferred.begin(), preferred.end(), [&](const auto& value) {
|
|
const auto index = source_index(event_span, value.source);
|
|
return index && *index == changed_index;
|
|
});
|
|
if (matching != preferred.end()) {
|
|
state_.focused_event_id = occurrence_focus_id(event_span, *matching);
|
|
} else {
|
|
state_.selected_day = local_date(events_[changed_index].start);
|
|
state_.visible_month = state_.selected_day.year() / state_.selected_day.month();
|
|
preferred = occurrences_on_day(event_span, state_.selected_day);
|
|
const auto first = std::find_if(preferred.begin(), preferred.end(), [&](const auto& value) {
|
|
const auto index = source_index(event_span, value.source);
|
|
return index && *index == changed_index;
|
|
});
|
|
state_.focused_event_id = first == preferred.end()
|
|
? std::nullopt
|
|
: std::optional{occurrence_focus_id(event_span, *first)};
|
|
}
|
|
state_.show_event_details = false;
|
|
record_mutation(editing && events_[changed_index].recurrence
|
|
? "edit recurring series"
|
|
: editing ? "edit appointment" : "add appointment");
|
|
if (editing && events_[changed_index].recurrence) {
|
|
success = "Recurring series updated.";
|
|
}
|
|
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;
|
|
const bool recurring = events_[*index].recurrence.has_value();
|
|
events_.erase(events_.begin() + static_cast<std::ptrdiff_t>(*index));
|
|
if (!persist(std::span<const Event>{events_})) {
|
|
events_.swap(original);
|
|
synchronize_calendars();
|
|
state_.focused_event_id = original_focus;
|
|
state_.confirm_delete = false;
|
|
pending_mutation_before_.reset();
|
|
return;
|
|
}
|
|
|
|
synchronize_calendars();
|
|
state_.focused_event_id.reset();
|
|
state_.show_event_details = false;
|
|
state_.confirm_delete = false;
|
|
record_mutation(recurring ? "delete recurring series" : "delete appointment");
|
|
set_notification(recurring ? "Recurring series deleted." : "Appointment deleted.");
|
|
}
|
|
|
|
void TuiApp::cancel_delete() {
|
|
state_.confirm_delete = false;
|
|
pending_mutation_before_.reset();
|
|
set_notification("Deletion cancelled.");
|
|
}
|
|
|
|
void TuiApp::begin_search() {
|
|
state_.search_prompt = true;
|
|
state_.show_search_results = false;
|
|
state_.search_query.clear();
|
|
state_.search_results.clear();
|
|
state_.search_result_index = 0;
|
|
state_.show_event_details = false;
|
|
state_.show_help = false;
|
|
state_.confirm_delete = false;
|
|
state_.show_calendar_picker = false;
|
|
}
|
|
|
|
void TuiApp::submit_search() {
|
|
state_.search_results.clear();
|
|
state_.search_result_index = 0;
|
|
if (!state_.search_query.empty()) {
|
|
using namespace std::chrono;
|
|
constexpr auto search_radius = days{366 * 5};
|
|
const auto anchor = sys_days{state_.selected_day};
|
|
const auto first_day = year_month_day{anchor - search_radius};
|
|
const auto last_day = year_month_day{anchor + search_radius};
|
|
const auto event_span = std::span<const Event>{events_};
|
|
for (const auto& occurrence : occurrences_overlapping(
|
|
event_span, local_day_start(first_day), local_day_end(last_day))) {
|
|
if (occurrence.source == nullptr ||
|
|
!event_is_visible(*occurrence.source,
|
|
std::span<const Calendar>{calendars_}) ||
|
|
!event_matches_query(*occurrence.source, state_.search_query)) {
|
|
continue;
|
|
}
|
|
const auto occurrence_day = local_date(occurrence.start);
|
|
state_.search_results.push_back(SearchResultItem{
|
|
.focus_id = occurrence_focus_id(event_span, occurrence),
|
|
.title = occurrence.source->title,
|
|
.day = occurrence_day,
|
|
.time_of_day = occurrence.source->all_day
|
|
? std::nullopt
|
|
: std::optional{local_time_of_day(occurrence.start)},
|
|
.all_day = occurrence.source->all_day,
|
|
.location = occurrence.source->location,
|
|
.recurring = occurrence.source->recurrence.has_value(),
|
|
});
|
|
}
|
|
std::stable_sort(state_.search_results.begin(), state_.search_results.end(),
|
|
[](const SearchResultItem& left, const SearchResultItem& right) {
|
|
const auto left_day = sys_days{left.day};
|
|
const auto right_day = sys_days{right.day};
|
|
if (left_day != right_day) return left_day < right_day;
|
|
if (left.all_day != right.all_day) return left.all_day;
|
|
if (left.time_of_day != right.time_of_day) {
|
|
return left.time_of_day.value_or(minutes{-1}) <
|
|
right.time_of_day.value_or(minutes{-1});
|
|
}
|
|
if (left.title != right.title) return left.title < right.title;
|
|
return left.focus_id < right.focus_id;
|
|
});
|
|
}
|
|
state_.search_prompt = false;
|
|
state_.show_search_results = true;
|
|
}
|
|
|
|
void TuiApp::close_search() {
|
|
state_.search_prompt = false;
|
|
state_.show_search_results = false;
|
|
}
|
|
|
|
void TuiApp::choose_search_result() {
|
|
if (state_.search_results.empty() ||
|
|
state_.search_result_index >= state_.search_results.size()) {
|
|
return;
|
|
}
|
|
const auto& result = state_.search_results[state_.search_result_index];
|
|
state_.selected_day = result.day;
|
|
state_.visible_month = result.day.year() / result.day.month();
|
|
state_.focused_event_id = result.focus_id;
|
|
state_.show_event_details = false;
|
|
state_.show_agenda = false;
|
|
close_search();
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (state_.search_prompt) {
|
|
if (input == "\x1b") {
|
|
close_search();
|
|
} else if (input == "\r" || input == "\n") {
|
|
submit_search();
|
|
} else if (input == "\x7f" || input == "\x08") {
|
|
erase_last_codepoint(state_.search_query);
|
|
} else if (state_.search_query.size() < 256 && is_search_text(input)) {
|
|
state_.search_query.append(input);
|
|
}
|
|
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;
|
|
}
|
|
if (state_.search_prompt) {
|
|
if (action == Action::back) close_search();
|
|
else if (action == Action::select) submit_search();
|
|
else if (action == Action::quit) running_ = false;
|
|
return;
|
|
}
|
|
if (state_.show_search_results) {
|
|
const auto count = state_.search_results.size();
|
|
switch (action) {
|
|
case Action::up:
|
|
case Action::left:
|
|
case Action::previous_event:
|
|
if (count > 0) {
|
|
state_.search_result_index = state_.search_result_index == 0
|
|
? count - 1
|
|
: state_.search_result_index - 1;
|
|
}
|
|
return;
|
|
case Action::down:
|
|
case Action::right:
|
|
case Action::next_event:
|
|
if (count > 0) state_.search_result_index = (state_.search_result_index + 1) % count;
|
|
return;
|
|
case Action::select:
|
|
choose_search_result();
|
|
return;
|
|
case Action::search:
|
|
begin_search();
|
|
return;
|
|
case Action::back:
|
|
close_search();
|
|
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:
|
|
case Action::add_event:
|
|
case Action::edit_event:
|
|
case Action::delete_event:
|
|
case Action::undo:
|
|
case Action::redo:
|
|
case Action::agenda:
|
|
case Action::calendars:
|
|
return;
|
|
}
|
|
}
|
|
if (state_.show_calendar_picker) {
|
|
const auto count = calendars_.size();
|
|
switch (action) {
|
|
case Action::up:
|
|
case Action::left:
|
|
case Action::previous_event:
|
|
if (count > 0) {
|
|
state_.calendar_index = state_.calendar_index == 0
|
|
? count - 1 : state_.calendar_index - 1;
|
|
}
|
|
return;
|
|
case Action::down:
|
|
case Action::right:
|
|
case Action::next_event:
|
|
if (count > 0) state_.calendar_index = (state_.calendar_index + 1) % count;
|
|
return;
|
|
case Action::select:
|
|
toggle_selected_calendar();
|
|
return;
|
|
case Action::calendars:
|
|
case Action::back:
|
|
close_calendar_picker();
|
|
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:
|
|
case Action::add_event:
|
|
case Action::edit_event:
|
|
case Action::delete_event:
|
|
case Action::undo:
|
|
case Action::redo:
|
|
case Action::search:
|
|
case Action::agenda:
|
|
return;
|
|
}
|
|
}
|
|
if (state_.show_agenda) {
|
|
const auto count = state_.agenda_items.size();
|
|
switch (action) {
|
|
case Action::up:
|
|
case Action::left:
|
|
case Action::previous_event:
|
|
if (count > 0 && state_.agenda_index > 0) --state_.agenda_index;
|
|
return;
|
|
case Action::down:
|
|
case Action::right:
|
|
case Action::next_event:
|
|
if (count > 0 && state_.agenda_index + 1 < count) ++state_.agenda_index;
|
|
return;
|
|
case Action::previous_month:
|
|
shift_agenda(-1);
|
|
return;
|
|
case Action::next_month:
|
|
shift_agenda(1);
|
|
return;
|
|
case Action::today:
|
|
state_.today = current_day();
|
|
state_.agenda_start_day = state_.today;
|
|
state_.agenda_items.clear();
|
|
state_.agenda_index = 0;
|
|
refresh_agenda();
|
|
return;
|
|
case Action::select:
|
|
choose_agenda_item();
|
|
return;
|
|
case Action::back:
|
|
case Action::agenda:
|
|
close_agenda();
|
|
return;
|
|
case Action::search:
|
|
begin_search();
|
|
return;
|
|
case Action::calendars:
|
|
begin_calendar_picker();
|
|
return;
|
|
case Action::quit:
|
|
running_ = false;
|
|
return;
|
|
case Action::none:
|
|
case Action::toggle_help:
|
|
case Action::add_event:
|
|
case Action::edit_event:
|
|
case Action::delete_event:
|
|
case Action::undo:
|
|
case Action::redo:
|
|
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 = visible_occurrences_on_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) {
|
|
if (occurrence_focus_id(event_span, day_events[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();
|
|
}
|
|
state_.focused_event_id = occurrence_focus_id(event_span, day_events[target]);
|
|
};
|
|
|
|
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::search:
|
|
begin_search();
|
|
return;
|
|
case Action::agenda:
|
|
begin_agenda();
|
|
return;
|
|
case Action::calendars:
|
|
begin_calendar_picker();
|
|
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::search:
|
|
begin_search();
|
|
return;
|
|
case Action::agenda:
|
|
begin_agenda();
|
|
return;
|
|
case Action::calendars:
|
|
begin_calendar_picker();
|
|
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_},
|
|
std::span<const Calendar>{calendars_}, 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
|