feat: add calendar visibility picker

Derive session calendars from event metadata and apply one visibility contract to month rendering, appointment focus, and search without filtering persistence writes.

Add keyboard picker coverage, full-model save regression tests, documentation, warning-clean builds, sanitizer verification, and live PTY terminal restoration checks.
This commit is contained in:
2026-07-18 09:01:27 +01:00
parent 2774087d14
commit 88d370bf99
14 changed files with 416 additions and 12 deletions

View File

@@ -575,6 +575,59 @@ std::string search_frame(const ScreenState& state) {
return output.str();
}
std::string calendar_picker_frame(const std::span<const Calendar> calendars,
const ScreenState& state) {
const int width = std::max(1, state.width);
const int height = std::max(1, state.height);
std::vector<std::string> lines;
lines.reserve(static_cast<std::size_t>(height));
lines.push_back(styled(centred("CALENDARS", width), "1", state.ansi));
const auto visible = std::count_if(calendars.begin(), calendars.end(),
[](const Calendar& value) { return value.visible; });
if (height > 1) {
const auto summary = std::to_string(visible) + " of " +
std::to_string(calendars.size()) + " visible";
lines.push_back(styled(fit_text(summary, width), "2", state.ansi));
}
const auto row_capacity = static_cast<std::size_t>(std::max(0, height - 3));
std::size_t first = 0;
if (row_capacity > 0 && state.calendar_index >= row_capacity) {
first = state.calendar_index - row_capacity + 1;
}
const auto last = std::min(calendars.size(), first + row_capacity);
for (std::size_t index = first; index < last; ++index) {
const auto& calendar = calendars[index];
const auto name = calendar.name.empty()
? (calendar.id.empty() ? std::string{"Default"}
: printable_text(calendar.id))
: printable_text(calendar.name);
const auto label = std::string{index == state.calendar_index ? " " : " "} +
(calendar.visible ? "[x] " : "[ ] ") + name;
lines.push_back(styled(fit_text(label, width),
index == state.calendar_index ? "7;1" : "", state.ansi));
}
while (static_cast<int>(lines.size()) < height - 1) {
lines.emplace_back(static_cast<std::size_t>(width), ' ');
}
if (height > 1) {
lines.push_back(styled(fit_text(
"↑/↓ choose Enter toggle c/Esc close", width), "2", state.ansi));
}
while (static_cast<int>(lines.size()) < height) {
lines.emplace_back(static_cast<std::size_t>(width), ' ');
}
if (static_cast<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height));
std::ostringstream output;
for (int line = 0; line < height; ++line) {
if (line != 0) output << '\n';
output << lines[static_cast<std::size_t>(line)];
}
return output.str();
}
std::string compact_frame(std::span<const CalendarItem> items, const ScreenState& state) {
const int width = std::max(1, state.width);
const int height = std::max(1, state.height);
@@ -632,7 +685,7 @@ std::string compact_frame(std::span<const CalendarItem> items, const ScreenState
if (!state.notification.empty()) {
controls = printable_text(state.notification, true);
} else if (focused == nullptr) {
controls = "/ search a add u undo Ctrl-R redo ? help n/p month t today q quit";
controls = "c calendars / search a add u undo Ctrl-R redo ? help n/p month t today q quit";
} else {
controls = "Tab appointment Enter open / search e edit d delete u undo Ctrl-R redo";
}
@@ -684,6 +737,7 @@ Action decode_key(const std::string_view sequence) noexcept {
if (sequence == "u") return Action::undo;
if (sequence == "\x12") return Action::redo;
if (sequence == "/") return Action::search;
if (sequence == "c") return Action::calendars;
if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit;
return Action::none;
}
@@ -808,7 +862,7 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
? by_day.at(sys_days{state.selected_day}).size() : 0U;
std::string status;
if (state.show_help) {
status = " arrows/hjkl day Tab/⇧Tab appointment Enter read / search a add e edit d delete u undo Ctrl-R redo n/p month t today ? close q quit";
status = " arrows/hjkl day Tab/⇧Tab appointment Enter read c calendars / search a add e edit d delete u undo Ctrl-R redo n/p month t today ? close q quit";
} else if (!state.notification.empty()) {
status = " " + printable_text(state.notification, true);
} else if (focused != nullptr) {
@@ -820,7 +874,7 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
status = " " + iso_date(state.selected_day) + " " + std::to_string(selected_count) +
(selected_count == 1 ? " appointment" : " appointments") +
(selected_count > 0 ? " Tab focus" : "") +
" / search a add u undo Ctrl-R redo ? help q quit";
" c calendars / search a add u undo Ctrl-R redo ? help q quit";
}
const auto status_style = !state.notification.empty()
? (state.notification_is_error ? "1;31" : "1;32")
@@ -830,6 +884,13 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
}
std::string render_month(const std::span<const Event> events, const ScreenState& state) {
return render_month(events, std::span<const Calendar>{}, state);
}
std::string render_month(const std::span<const Event> events,
const std::span<const Calendar> calendars,
const ScreenState& state) {
if (state.show_calendar_picker) return calendar_picker_frame(calendars, state);
std::vector<CalendarItem> items;
items.reserve(events.size() * 2);
const auto visible_start = monday_on_or_before(sys_days{state.visible_month / day{1}});
@@ -838,7 +899,8 @@ std::string render_month(const std::span<const Event> events, const ScreenState&
events, local_day_start(year_month_day{visible_start}),
local_day_start(year_month_day{visible_end + days{1}}));
for (const auto& occurrence : occurrences) {
if (occurrence.source == nullptr) continue;
if (occurrence.source == nullptr ||
!event_is_visible(*occurrence.source, calendars)) continue;
const auto& event = *occurrence.source;
const auto identity = occurrence_focus_id(events, occurrence);
const auto local_start = to_local(occurrence.start);

View File

@@ -88,12 +88,13 @@ TuiApp::TuiApp(std::vector<Event>& events, SaveCallback saver, const int input_f
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 : occurrences_on_day(event_span, state_.selected_day)) {
for (const auto& occurrence : visible_occurrences_on_selected_day()) {
if (occurrence_focus_id(event_span, occurrence) == *state_.focused_event_id) {
return occurrence;
}
@@ -107,6 +108,69 @@ std::optional<std::size_t> TuiApp::focused_event_index() const {
: 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;
}
}
void TuiApp::set_notification(std::string message, const bool error) {
state_.notification = std::move(message);
state_.notification_is_error = error;
@@ -133,6 +197,8 @@ void TuiApp::restore_history_snapshot(HistorySnapshot snapshot) {
state_.confirm_delete = false;
state_.search_prompt = false;
state_.show_search_results = false;
state_.show_calendar_picker = false;
synchronize_calendars();
}
void TuiApp::push_history(std::vector<HistoryEntry>& history, HistoryEntry entry) {
@@ -205,6 +271,7 @@ void TuiApp::begin_add() {
state_.show_event_details = false;
state_.show_help = false;
state_.confirm_delete = false;
state_.show_calendar_picker = false;
}
void TuiApp::begin_edit() {
@@ -224,6 +291,7 @@ void TuiApp::begin_edit() {
state_.show_event_details = false;
state_.show_help = false;
state_.confirm_delete = false;
state_.show_calendar_picker = false;
}
void TuiApp::begin_delete() {
@@ -235,6 +303,7 @@ void TuiApp::begin_delete() {
state_.confirm_delete = true;
state_.show_event_details = false;
state_.show_help = false;
state_.show_calendar_picker = false;
}
void TuiApp::submit_editor() {
@@ -259,6 +328,7 @@ void TuiApp::submit_editor() {
if (!persist(std::span<const Event>{events_})) {
events_.swap(original);
synchronize_calendars();
state_.focused_event_id = original_focus;
editor_.reset();
editing_index_.reset();
@@ -266,9 +336,20 @@ void TuiApp::submit_editor() {
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);
@@ -313,12 +394,14 @@ void TuiApp::confirm_delete() {
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;
@@ -341,6 +424,7 @@ void TuiApp::begin_search() {
state_.show_event_details = false;
state_.show_help = false;
state_.confirm_delete = false;
state_.show_calendar_picker = false;
}
void TuiApp::submit_search() {
@@ -356,6 +440,8 @@ void TuiApp::submit_search() {
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;
}
@@ -525,6 +611,47 @@ void TuiApp::dispatch(const Action action) {
case Action::delete_event:
case Action::undo:
case Action::redo:
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:
return;
}
}
@@ -534,7 +661,7 @@ void TuiApp::dispatch(const Action action) {
};
const auto cycle_event = [this](const int direction) {
const auto event_span = std::span<const Event>{events_};
const auto day_events = occurrences_on_day(event_span, state_.selected_day);
const auto day_events = visible_occurrences_on_selected_day();
if (day_events.empty()) {
state_.focused_event_id.reset();
return;
@@ -596,6 +723,9 @@ void TuiApp::dispatch(const Action action) {
case Action::search:
begin_search();
return;
case Action::calendars:
begin_calendar_picker();
return;
case Action::quit:
running_ = false;
return;
@@ -686,6 +816,9 @@ void TuiApp::dispatch(const Action action) {
case Action::search:
begin_search();
return;
case Action::calendars:
begin_calendar_picker();
return;
case Action::quit:
running_ = false;
return;
@@ -716,7 +849,8 @@ ExitReason TuiApp::run() {
if (redraw) {
const auto content = editor_
? editor_->render(state_.width, state_.height, state_.ansi)
: render_month(std::span<const Event>{events_}, state_);
: 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;