#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)}; } 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((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 source_index(const std::span 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(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(character); return byte >= 0x20U && byte != 0x7fU; }); } } // 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(); synchronize_calendars(); } std::optional TuiApp::focused_occurrence() const { if (!state_.focused_event_id) return std::nullopt; const auto event_span = std::span{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 TuiApp::focused_event_index() const { const auto occurrence = focused_occurrence(); return occurrence ? source_index(std::span{events_}, occurrence->source) : std::nullopt; } EventOccurrences TuiApp::visible_occurrences_on_selected_day() const { auto occurrences = occurrences_on_day(std::span{events_}, state_.selected_day); std::erase_if(occurrences, [this](const EventOccurrence& occurrence) { return occurrence.source == nullptr || !event_is_visible(*occurrence.source, std::span{calendars_}); }); return occurrences; } void TuiApp::synchronize_calendars() { std::vector 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 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 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{events_}; const auto calendar_span = std::span{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(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& 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; 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{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{events_}; if (!event_is_visible(events_[changed_index], std::span{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(*index)); if (!persist(std::span{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{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{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{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(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::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{events_}, std::span{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 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