diff --git a/README.md b/README.md index 8461e02..73018fe 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,9 @@ Use arrow keys or `h j k l` to move, `PageUp`/`PageDown` or `p`/`n` to change month, and `t` for today. On a day with appointments, `Tab` and `Shift-Tab` move focus through them and `Enter` opens the appointment reader. In the reader, arrows/Vim keys or Tab variants browse the day's appointments and -`Esc` returns to the month. Press `a` to add an appointment. Focus an +`Esc` returns to the month. Press `c` to open the calendar picker; arrows or +`j`/`k` select a calendar and `Enter` toggles its in-session visibility. Press +`a` to add an appointment. Focus an appointment and press `e` to edit it or `d` to delete it. Forms use `Tab` and `Shift-Tab` (or arrows) between fields, `Space` for the all-day toggle, `Ctrl-S` to save, and `Esc` to cancel. Back in the calendar, `u` undoes the @@ -46,7 +48,9 @@ clearly operate on the entire series. Search expands recurring matches within five years on either side of the currently selected date. This finite window keeps unbounded recurrence rules responsive while one-time and recurring results share the same chronological -result list. +result list. Hidden calendars are excluded consistently from the month grid, +appointment focus, and search. Visibility is session-only presentation state; +saves always retain events from hidden calendars. Calendars containing features this version cannot preserve—such as `RDATE`, detached recurrence overrides, ordinal `BYDAY`, embedded `VTIMEZONE` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9c6e1c2..2dc7a3b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,6 +40,13 @@ owns result presentation and occurrence identity. Recurring searches expand a finite window of five years on either side of the selected date so an unbounded rule cannot turn an interactive query into unbounded work. +Calendar visibility is session presentation state. The controller derives its +calendar list from event calendar identifiers and applies the same domain +predicate to month rendering, focus traversal, and search. Hidden events remain +in the canonical collection passed to persistence, and focus identities are +always derived from that complete collection so filtering cannot renumber +events with duplicate or missing UIDs. + Timed events retain their source basis: UTC, floating process-local time, or an IANA `TZID`. C++20 time-zone conversion keeps zoned recurrences at their civil wall time across daylight-saving transitions. The month grid displays instants diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 4ed6593..3bb685c 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -72,6 +72,7 @@ The full terminal is treated as a responsive canvas: | Return to month/unfocus | `Esc` | | Search appointments | `/`, then type and press `Enter` | | Choose search result | arrows, `j` / `k`, then `Enter` | +| Toggle calendar visibility | `c`, arrows or `j` / `k`, then `Enter` | | Help | `?` | | Quit | `q` | @@ -85,8 +86,10 @@ recurring instances appear throughout the month, preserve civil time across daylight-saving changes, and expose their source zone and whole-series mutation semantics in the reader. `/` searches occurrence-aware title, description, location, and calendar-ID matches in a finite ten-year window centered on the -selected date. The next local-data work adds agenda and calendar visibility -views, then advanced recurrence overrides and rule editing. +selected date. `c` toggles session-only calendar visibility consistently across +the grid, appointment focus, and search without filtering the model supplied to +persistence. The next local-data work adds an agenda view, then advanced +recurrence overrides and rule editing. ## Accessibility diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6abd86a..1c31ffc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,13 +10,14 @@ - Bounded session undo/redo, exact external-change detection, and backup recovery - Windowed recurrence expansion, EXDATE, IANA TZID, and multi-day segment cues - Occurrence-aware appointment search with exact result-to-grid navigation +- Session calendar toggles shared by month rendering, focus, and search - Meson build, Nix development shell, desktop entry, and Hyprland launcher - Seeded sample data when explicitly requested, never silent data mutation ## 0.1 — complete local calendar - Advanced recurrence editing, RDATE/overrides, and embedded VTIMEZONE support -- Agenda view, calendar toggles, import/export, configurable week start +- Agenda view, import/export, configurable week start - Screen-reader-friendly linear view and full Unicode display-width handling ## 0.2 — durable cache and provider boundary diff --git a/include/nocal/domain/event_query.hpp b/include/nocal/domain/event_query.hpp index f3d5131..41f8c21 100644 --- a/include/nocal/domain/event_query.hpp +++ b/include/nocal/domain/event_query.hpp @@ -44,6 +44,14 @@ void sort_events(EventRefs& events); [[nodiscard]] bool event_matches_query(const Event& event, std::string_view query); +// Calendar visibility is an application/query concern. An event whose +// calendar is not yet represented remains visible so newly loaded or created +// data cannot disappear merely because metadata has not been synchronized. +[[nodiscard]] bool calendar_is_visible(std::span calendars, + std::string_view calendar_id) noexcept; +[[nodiscard]] bool event_is_visible(const Event& event, + std::span calendars) noexcept; + // Event and query intervals are half-open. A zero-duration event is treated as // an instant and belongs to the interval containing its start time. [[nodiscard]] bool event_overlaps(const Event& event, TimePoint begin, diff --git a/include/nocal/tui/Screen.hpp b/include/nocal/tui/Screen.hpp index 5f32961..a28b648 100644 --- a/include/nocal/tui/Screen.hpp +++ b/include/nocal/tui/Screen.hpp @@ -71,6 +71,8 @@ struct ScreenState { std::size_t search_result_index{0}; std::string notification; bool notification_is_error{false}; + bool show_calendar_picker{false}; + std::size_t calendar_index{0}; }; // Render a complete frame. The result contains no cursor positioning, which @@ -83,6 +85,12 @@ struct ScreenState { [[nodiscard]] std::string render_month(std::span events, const ScreenState& state); +// Calendar visibility filters presentation only. Focus identities are still +// derived from the complete event collection. +[[nodiscard]] std::string render_month(std::span events, + std::span calendars, + const ScreenState& state); + // Stable focus identity shared by the renderer and controller. Non-recurring // events retain their historical UID/synthetic identity; recurring instances // additionally include their concrete start instant. @@ -109,6 +117,7 @@ enum class Action { undo, redo, search, + calendars, quit, }; diff --git a/include/nocal/tui/TuiApp.hpp b/include/nocal/tui/TuiApp.hpp index 1694a21..b7ee364 100644 --- a/include/nocal/tui/TuiApp.hpp +++ b/include/nocal/tui/TuiApp.hpp @@ -45,6 +45,12 @@ public: [[nodiscard]] bool search_active() const noexcept { return state_.search_prompt || state_.show_search_results; } + [[nodiscard]] bool calendar_picker_active() const noexcept { + return state_.show_calendar_picker; + } + [[nodiscard]] const std::vector& calendars() const noexcept { + return calendars_; + } private: struct HistorySnapshot { @@ -64,6 +70,7 @@ private: static constexpr std::size_t history_limit = 100; std::vector& events_; + std::vector calendars_; int input_fd_; int output_fd_; ScreenState state_; @@ -77,6 +84,11 @@ private: [[nodiscard]] std::optional focused_occurrence() const; [[nodiscard]] std::optional focused_event_index() const; + [[nodiscard]] EventOccurrences visible_occurrences_on_selected_day() const; + void synchronize_calendars(); + void begin_calendar_picker(); + void close_calendar_picker(); + void toggle_selected_calendar(); [[nodiscard]] HistorySnapshot capture_history_snapshot() const; void restore_history_snapshot(HistorySnapshot snapshot); void push_history(std::vector& history, HistoryEntry entry); diff --git a/src/domain/event_query.cpp b/src/domain/event_query.cpp index 8ee43f2..82b6bc2 100644 --- a/src/domain/event_query.cpp +++ b/src/domain/event_query.cpp @@ -663,6 +663,21 @@ bool event_matches_query(const Event& event, std::string_view query) contains_ascii_case_insensitive(event.calendar_id, query); } +bool calendar_is_visible(const std::span calendars, + const std::string_view calendar_id) noexcept +{ + const auto calendar = std::find_if( + calendars.begin(), calendars.end(), + [calendar_id](const Calendar& value) { return value.id == calendar_id; }); + return calendar == calendars.end() || calendar->visible; +} + +bool event_is_visible(const Event& event, + const std::span calendars) noexcept +{ + return calendar_is_visible(calendars, event.calendar_id); +} + bool event_overlaps(const Event& event, TimePoint begin, TimePoint end) noexcept { return overlaps(event.start, event.end, begin, end); diff --git a/src/main.cpp b/src/main.cpp index 703cd18..ecedd8c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -57,6 +57,7 @@ void print_help(std::ostream& output) " Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n" " a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n" " u undoes the last change, Ctrl-R redoes it,\n" + " c toggles calendar visibility, / searches appointments,\n" " t jumps to today, ? toggles help, q quits.\n"; } diff --git a/src/tui/Screen.cpp b/src/tui/Screen.cpp index c06245d..749afda 100644 --- a/src/tui/Screen.cpp +++ b/src/tui/Screen.cpp @@ -575,6 +575,59 @@ std::string search_frame(const ScreenState& state) { return output.str(); } +std::string calendar_picker_frame(const std::span calendars, + const ScreenState& state) { + const int width = std::max(1, state.width); + const int height = std::max(1, state.height); + std::vector lines; + lines.reserve(static_cast(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::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(lines.size()) < height - 1) { + lines.emplace_back(static_cast(width), ' '); + } + if (height > 1) { + lines.push_back(styled(fit_text( + "↑/↓ choose Enter toggle c/Esc close", width), "2", state.ansi)); + } + while (static_cast(lines.size()) < height) { + lines.emplace_back(static_cast(width), ' '); + } + if (static_cast(lines.size()) > height) lines.resize(static_cast(height)); + + std::ostringstream output; + for (int line = 0; line < height; ++line) { + if (line != 0) output << '\n'; + output << lines[static_cast(line)]; + } + return output.str(); +} + std::string compact_frame(std::span 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 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 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 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 items, const ScreenState& } std::string render_month(const std::span events, const ScreenState& state) { + return render_month(events, std::span{}, state); +} + +std::string render_month(const std::span events, + const std::span calendars, + const ScreenState& state) { + if (state.show_calendar_picker) return calendar_picker_frame(calendars, state); std::vector 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 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); diff --git a/src/tui/TuiApp.cpp b/src/tui/TuiApp.cpp index 52d2d6a..849133c 100644 --- a/src/tui/TuiApp.cpp +++ b/src/tui/TuiApp.cpp @@ -88,12 +88,13 @@ TuiApp::TuiApp(std::vector& 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 TuiApp::focused_occurrence() const { if (!state_.focused_event_id) return std::nullopt; const auto event_span = std::span{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 TuiApp::focused_event_index() const { : 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; + } +} + 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& 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{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{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); @@ -313,12 +394,14 @@ void TuiApp::confirm_delete() { 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; @@ -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{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{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{events_}, state_); + : 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; diff --git a/tests/domain_tests.cpp b/tests/domain_tests.cpp index abad204..e1017ed 100644 --- a/tests/domain_tests.cpp +++ b/tests/domain_tests.cpp @@ -183,6 +183,34 @@ void test_event_search() "event search does not locale-fold non-ASCII bytes"); } +void test_calendar_visibility() +{ + const std::vector calendars{ + {.id = "", .name = "Default", .color = {}, .visible = true, + .read_only = false}, + {.id = "personal", .name = "Personal", .color = {}, .visible = true, + .read_only = false}, + {.id = "work", .name = "Work", .color = {}, .visible = false, + .read_only = false}, + }; + nocal::Event default_event; + nocal::Event personal_event; + personal_event.calendar_id = "personal"; + nocal::Event work_event; + work_event.calendar_id = "work"; + nocal::Event new_event; + new_event.calendar_id = "new-calendar"; + + check(nocal::event_is_visible(default_event, calendars), + "an empty calendar ID uses the visible default calendar"); + check(nocal::event_is_visible(personal_event, calendars), + "an explicitly visible calendar exposes its events"); + check(!nocal::event_is_visible(work_event, calendars), + "a hidden calendar filters its events"); + check(nocal::event_is_visible(new_event, calendars), + "unknown calendar metadata defaults visible instead of hiding data"); +} + void test_daily_dst_recurrence() { using namespace std::chrono; @@ -407,6 +435,7 @@ int main() test_month_layout(); test_event_queries(); test_event_search(); + test_calendar_visibility(); test_daily_dst_recurrence(); test_floating_dst_and_until(); test_weekly_count_and_exdates(); diff --git a/tests/tui_focus_tests.cpp b/tests/tui_focus_tests.cpp index 6795ac6..47b83a3 100644 --- a/tests/tui_focus_tests.cpp +++ b/tests/tui_focus_tests.cpp @@ -956,6 +956,89 @@ void test_search_prompt_results_and_occurrence_jump() check(!app.search_active(), "Esc/back closes empty search results"); } +void test_calendar_visibility_filters_without_partial_saves() +{ + const auto day = nocal::today(); + auto default_event = make_event("default", "D", day, 8, 0, + day, 9, 0); + auto personal_event = make_event("personal", "P", day, 9, 0, + day, 10, 0); + personal_event.calendar_id = "personal"; + auto work_event = make_event("work", "W", day, 10, 0, + day, 11, 0); + work_event.calendar_id = "work"; + std::vector events{default_event, personal_event, work_event}; + int save_calls = 0; + std::vector saved; + nocal::tui::TuiApp app{ + events, + [&](const std::span values) { + ++save_calls; + saved.assign(values.begin(), values.end()); + }, + -1, + -1, + }; + + check(app.calendars().size() == 3 && app.calendars()[0].id.empty() && + app.calendars()[0].name == "Default" && + app.calendars()[1].id == "personal" && app.calendars()[2].id == "work", + "controller derives a stable default-first calendar list from event metadata"); + app.dispatch(nocal::tui::Action::calendars); + check(app.calendar_picker_active(), "c opens the calendar picker"); + app.dispatch(nocal::tui::Action::down); + app.dispatch(nocal::tui::Action::down); + app.dispatch(nocal::tui::Action::select); + check(!app.calendars()[2].visible && save_calls == 0, + "toggling calendar visibility never crosses the persistence boundary"); + app.dispatch(nocal::tui::Action::back); + + auto render_state = app.state(); + render_state.width = 120; + render_state.height = 36; + auto frame = nocal::tui::render_month(events, app.calendars(), render_state); + check(frame.find("08:00 D") != std::string::npos && + frame.find("09:00 P") != std::string::npos && + frame.find("10:00 W") == std::string::npos, + "month rendering excludes only events from hidden calendars"); + + app.handle_input("/"); + app.handle_input("work"); + app.handle_input("\r"); + check(app.state().search_results.empty(), + "search shares the month view's calendar visibility contract"); + app.dispatch(nocal::tui::Action::back); + + app.dispatch(nocal::tui::Action::next_event); + check(focus_is(app, "default"), "focus starts on the first visible calendar event"); + app.dispatch(nocal::tui::Action::next_event); + check(focus_is(app, "personal"), "focus advances to another visible calendar"); + app.dispatch(nocal::tui::Action::next_event); + check(focus_is(app, "default"), "focus traversal skips hidden-calendar events"); + + app.dispatch(nocal::tui::Action::delete_event); + app.handle_input("y"); + check(save_calls == 1 && saved.size() == 2 && + std::any_of(saved.begin(), saved.end(), [](const nocal::Event& event) { + return event.uid == "work" && event.calendar_id == "work"; + }), + "mutations save the full model, including events in hidden calendars"); + + app.dispatch(nocal::tui::Action::calendars); + app.dispatch(nocal::tui::Action::select); + check(app.calendars()[2].visible && save_calls == 1, + "calendar picker retains its selection and can reveal a calendar without saving"); + app.dispatch(nocal::tui::Action::back); + app.dispatch(nocal::tui::Action::next_event); + app.dispatch(nocal::tui::Action::next_event); + check(focus_is(app, "work"), "revealed calendar events become focusable again"); + app.dispatch(nocal::tui::Action::calendars); + app.dispatch(nocal::tui::Action::select); + check(!app.state().focused_event_id, + "hiding the focused event's calendar clears stale appointment focus"); + check(save_calls == 1, "all visibility-only interactions remain unsaved session state"); +} + } // namespace int main() @@ -983,6 +1066,7 @@ int main() test_recurring_occurrence_navigation_and_whole_series_crud(); test_recurrence_grid_expansion_and_source_zone_display(); test_search_prompt_results_and_occurrence_jump(); + test_calendar_visibility_filters_without_partial_saves(); if (failures != 0) { std::cerr << failures << " TUI focus test(s) failed\n"; return EXIT_FAILURE; diff --git a/tests/tui_tests.cpp b/tests/tui_tests.cpp index b7bf23b..7ea9861 100644 --- a/tests/tui_tests.cpp +++ b/tests/tui_tests.cpp @@ -97,6 +97,40 @@ void test_compact_and_input() check(nocal::tui::decode_key("\x1b[6~") == nocal::tui::Action::next_month, "PageDown is decoded"); check(nocal::tui::decode_key("k") == nocal::tui::Action::up, "Vim movement is decoded"); + check(nocal::tui::decode_key("c") == nocal::tui::Action::calendars, + "c opens the calendar picker"); +} + +void test_calendar_picker_frame() +{ + using namespace std::chrono; + auto state = nocal::tui::ScreenState{}; + state.visible_month = year{2026} / July; + state.selected_day = year{2026} / July / day{17}; + state.today = state.selected_day; + state.width = 52; + state.height = 10; + state.ansi = false; + state.show_calendar_picker = true; + state.calendar_index = 1; + const std::vector calendars{ + {.id = "", .name = "Default", .color = {}, .visible = true, + .read_only = false}, + {.id = "work", .name = "Work", .color = {}, .visible = false, + .read_only = false}, + }; + + const auto frame = nocal::tui::render_month( + std::span{}, calendars, state); + check(frame.find("CALENDARS") != std::string::npos && + frame.find("1 of 2 visible") != std::string::npos && + frame.find("[x] Default") != std::string::npos && + frame.find("[ ] Work") != std::string::npos, + "calendar picker renders visibility and selection context"); + check(std::count(frame.begin(), frame.end(), '\n') == 9, + "calendar picker fills the requested height"); + check(frame.find("\x1b[") == std::string::npos, + "calendar picker remains readable without ANSI styling"); } void test_navigation() @@ -164,6 +198,7 @@ int main() test_compact_and_input(); test_navigation(); test_search_frames(); + test_calendar_picker_frame(); if (failures != 0) { std::cerr << failures << " TUI test(s) failed\n"; return EXIT_FAILURE;