From 2774087d14a7361576c969d2cfcfb9ef2a1773c2 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sat, 18 Jul 2026 08:31:09 +0100 Subject: [PATCH] feat: add occurrence-aware appointment search --- README.md | 11 +- docs/ARCHITECTURE.md | 5 + docs/PRODUCT.md | 8 +- docs/ROADMAP.md | 3 +- include/nocal/domain/event_query.hpp | 4 + include/nocal/tui/Screen.hpp | 17 +++ include/nocal/tui/TuiApp.hpp | 7 ++ src/domain/event_query.cpp | 30 +++++ src/main.cpp | 5 + src/tui/Screen.cpp | 76 +++++++++++- src/tui/TuiApp.cpp | 165 +++++++++++++++++++++++++++ tests/domain_tests.cpp | 29 +++++ tests/tui_focus_tests.cpp | 82 +++++++++++++ tests/tui_tests.cpp | 54 +++++++++ 14 files changed, 485 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 1248503..8461e02 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,10 @@ reader, arrows/Vim keys or Tab variants browse the day's appointments and 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 -last successful mutation and `Ctrl-R` redoes it. Press `?` for help and `q` to -quit. +last successful mutation and `Ctrl-R` redoes it. Press `/` to search titles, +descriptions, locations, and calendar IDs. Search results use arrows or +`j`/`k`; `Enter` jumps to the exact occurrence and `Esc` returns to the month. +Press `?` for help and `q` to quit. ## Build and run @@ -41,6 +43,11 @@ weekly/monthly/yearly selectors, `EXDATE`, floating times, UTC, and system IANA `TZID` names. Recurring instances are individually navigable; edit and delete 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. + Calendars containing features this version cannot preserve—such as `RDATE`, detached recurrence overrides, ordinal `BYDAY`, embedded `VTIMEZONE` definitions, alarms, or attendees—remain browsable but read-only. This guard diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 011af29..9c6e1c2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,6 +35,11 @@ interval, count or until, weekly `BYDAY`, monthly `BYMONTHDAY`, yearly `BYMONTH`, and `EXDATE`. Invalid metadata retains at most the base event rather than crashing a render. +Search matching is a pure domain predicate over normalized event text; the TUI +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. + 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 5e736dd..4ed6593 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -70,6 +70,8 @@ The full terminal is treated as a responsive canvas: | Move between editor fields | `Tab` / `Shift-Tab`, arrows | | Save/cancel editor | `Ctrl-S` / `Esc` | | Return to month/unfocus | `Esc` | +| Search appointments | `/`, then type and press `Enter` | +| Choose search result | arrows, `j` / `k`, then `Enter` | | Help | `?` | | Quit | `q` | @@ -81,8 +83,10 @@ read-only instead of being discarded. Saves reject external changes, retain a last-known-good backup, and feed bounded session undo/redo history. Supported 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. The next local-data work adds `/` search, agenda and -calendar visibility views, then advanced recurrence overrides and rule editing. +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. ## Accessibility diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b539495..6abd86a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,13 +9,14 @@ - Guarded atomic `.ics` persistence, advisory locking, and round-trip tests - 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 - 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 -- Search, agenda view, calendar toggles, import/export, configurable week start +- Agenda view, calendar toggles, 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 f287576..f3d5131 100644 --- a/include/nocal/domain/event_query.hpp +++ b/include/nocal/domain/event_query.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include namespace nocal { @@ -40,6 +41,9 @@ void sort_occurrences(EventOccurrences& occurrences); [[nodiscard]] bool event_less(const Event& lhs, const Event& rhs) noexcept; void sort_events(EventRefs& events); +[[nodiscard]] bool event_matches_query(const Event& event, + std::string_view query); + // 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 20b03dd..5f32961 100644 --- a/include/nocal/tui/Screen.hpp +++ b/include/nocal/tui/Screen.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include "nocal/domain/event.hpp" #include "nocal/domain/event_query.hpp" @@ -40,6 +41,16 @@ struct CalendarItem { std::string source_time_zone; }; +struct SearchResultItem { + std::string focus_id; + std::string title; + std::chrono::year_month_day day; + std::optional time_of_day; + bool all_day{false}; + std::string location; + bool recurring{false}; +}; + struct ScreenState { std::chrono::year_month visible_month{std::chrono::year{1970}, std::chrono::month{1}}; std::chrono::year_month_day selected_day{std::chrono::year{1970}, std::chrono::month{1}, @@ -53,6 +64,11 @@ struct ScreenState { std::optional focused_event_id; bool show_event_details{false}; bool confirm_delete{false}; + bool search_prompt{false}; + bool show_search_results{false}; + std::string search_query; + std::vector search_results; + std::size_t search_result_index{0}; std::string notification; bool notification_is_error{false}; }; @@ -92,6 +108,7 @@ enum class Action { delete_event, undo, redo, + search, quit, }; diff --git a/include/nocal/tui/TuiApp.hpp b/include/nocal/tui/TuiApp.hpp index 80b976b..1694a21 100644 --- a/include/nocal/tui/TuiApp.hpp +++ b/include/nocal/tui/TuiApp.hpp @@ -42,6 +42,9 @@ public: [[nodiscard]] bool delete_confirmation_active() const noexcept { return state_.confirm_delete; } + [[nodiscard]] bool search_active() const noexcept { + return state_.search_prompt || state_.show_search_results; + } private: struct HistorySnapshot { @@ -87,6 +90,10 @@ private: void submit_editor(); void confirm_delete(); void cancel_delete(); + void begin_search(); + void submit_search(); + void close_search(); + void choose_search_result(); void set_notification(std::string message, bool error = false); }; diff --git a/src/domain/event_query.cpp b/src/domain/event_query.cpp index fb911a4..8ee43f2 100644 --- a/src/domain/event_query.cpp +++ b/src/domain/event_query.cpp @@ -11,6 +11,25 @@ namespace { using CivilTime = std::chrono::local_time; +[[nodiscard]] constexpr unsigned char ascii_lower(unsigned char byte) noexcept +{ + if (byte >= static_cast('A') && + byte <= static_cast('Z')) { + return static_cast(byte + ('a' - 'A')); + } + return byte; +} + +[[nodiscard]] bool contains_ascii_case_insensitive(std::string_view text, + std::string_view query) +{ + return std::search(text.begin(), text.end(), query.begin(), query.end(), + [](char lhs, char rhs) { + return ascii_lower(static_cast(lhs)) == + ascii_lower(static_cast(rhs)); + }) != text.end(); +} + struct Projection { CivilTime start; CivilTime end; @@ -633,6 +652,17 @@ void sort_occurrences(EventOccurrences& occurrences) std::stable_sort(occurrences.begin(), occurrences.end(), occurrence_less); } +bool event_matches_query(const Event& event, std::string_view query) +{ + if (query.empty()) { + return false; + } + return contains_ascii_case_insensitive(event.title, query) || + contains_ascii_case_insensitive(event.description, query) || + contains_ascii_case_insensitive(event.location, query) || + contains_ascii_case_insensitive(event.calendar_id, query); +} + 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 d932008..703cd18 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -151,6 +151,11 @@ int print_frame(std::span events, bool no_color) .focused_event_id = std::nullopt, .show_event_details = false, .confirm_delete = false, + .search_prompt = false, + .show_search_results = false, + .search_query = {}, + .search_results = {}, + .search_result_index = 0, .notification = {}, .notification_is_error = false, }; diff --git a/src/tui/Screen.cpp b/src/tui/Screen.cpp index 1335934..c06245d 100644 --- a/src/tui/Screen.cpp +++ b/src/tui/Screen.cpp @@ -389,7 +389,7 @@ std::string detail_frame(std::span items, const ScreenState& add(" No notes for this appointment.", "2;3"); } - const auto footer = " ↑↓/Tab browse e edit d delete u undo Ctrl-R redo Esc back " + + const auto footer = " ↑↓/Tab browse / search e edit d delete u undo Ctrl-R redo Esc back " + std::to_string(ordinal) + "/" + std::to_string(total) + " "; const int body_rows = height - 2; std::ostringstream output; @@ -513,6 +513,68 @@ std::string source_focus_id(const std::span events, const std::size return unique ? uid : "@nocal:" + std::to_string(index); } +std::string search_frame(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("SEARCH APPOINTMENTS", width), "1", state.ansi)); + + if (state.search_prompt) { + if (height > 1) lines.push_back(fit_text("/ " + printable_text(state.search_query) + "_", + width, false)); + if (height > 2) lines.push_back(styled(fit_text( + "Search title, description, location, or calendar ID", width), "2", state.ansi)); + if (height > 3) lines.push_back(styled(fit_text( + "Enter search Backspace edit Esc cancel", width), "2", state.ansi)); + } else { + const auto count = state.search_results.size(); + const auto summary = count == 0 + ? "No matches for “" + printable_text(state.search_query) + "”" + : std::to_string(count) + (count == 1 ? " match for “" : " matches for “") + + printable_text(state.search_query) + "”"; + if (height > 1) lines.push_back(styled(fit_text(summary, width), count == 0 ? "1" : "1;36", + state.ansi)); + const auto row_capacity = static_cast(std::max(0, height - 4)); + std::size_t first = 0; + if (row_capacity > 0 && state.search_result_index >= row_capacity) { + first = state.search_result_index - row_capacity + 1; + } + const auto last = std::min(count, first + row_capacity); + for (std::size_t index = first; index < last; ++index) { + const auto& result = state.search_results[index]; + std::string when = iso_date(result.day) + " "; + if (result.all_day || !result.time_of_day) when += "all day"; + else when += clock_time(*result.time_of_day); + auto title = result.title.empty() ? std::string{"(untitled)"} + : printable_text(result.title); + std::string label = (index == state.search_result_index ? "› " : " ") + when + + " " + (result.recurring ? "↻ " : "") + title; + if (!result.location.empty()) label += " — " + printable_text(result.location); + lines.push_back(styled(fit_text(label, width), + index == state.search_result_index ? "7;1" : "", state.ansi)); + } + while (static_cast(lines.size()) < height - 2) { + lines.emplace_back(static_cast(width), ' '); + } + if (height > 2) lines.push_back(styled(fit_text( + "Five years either side of the selected date", width), "2", state.ansi)); + if (height > 1) lines.push_back(styled(fit_text( + "↑/↓ choose Enter jump / new search 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); @@ -570,9 +632,9 @@ std::string compact_frame(std::span items, const ScreenState if (!state.notification.empty()) { controls = printable_text(state.notification, true); } else if (focused == nullptr) { - controls = "a add u undo Ctrl-R redo ? help n/p month t today q quit"; + controls = "/ search a add u undo Ctrl-R redo ? help n/p month t today q quit"; } else { - controls = "Tab appointment Enter open e edit d delete u undo Ctrl-R redo"; + controls = "Tab appointment Enter open / search e edit d delete u undo Ctrl-R redo"; } if (static_cast(lines.size()) < height) { const auto style = state.notification.empty() ? "2" @@ -621,11 +683,13 @@ Action decode_key(const std::string_view sequence) noexcept { if (sequence == "d") return Action::delete_event; if (sequence == "u") return Action::undo; if (sequence == "\x12") return Action::redo; + if (sequence == "/") return Action::search; if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit; return Action::none; } std::string render_month(std::span items, const ScreenState& state) { + if (state.search_prompt || state.show_search_results) return search_frame(state); const auto focused = focused_item(items, state); if (state.confirm_delete && focused != nullptr) { return delete_confirmation_frame(state, *focused); @@ -744,19 +808,19 @@ 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 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 / 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) { const auto focus_title = focused->title.empty() ? std::string{"(untitled)"} : printable_text(focused->title); status = " " + iso_date(state.selected_day) + " " + - focus_title + " Tab next Enter read e edit d delete u undo Ctrl-R redo"; + focus_title + " Tab next Enter read / search e edit d delete u undo Ctrl-R redo"; } else { status = " " + iso_date(state.selected_day) + " " + std::to_string(selected_count) + (selected_count == 1 ? " appointment" : " appointments") + (selected_count > 0 ? " Tab focus" : "") + - " a add u undo Ctrl-R redo ? help q quit"; + " / 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") diff --git a/src/tui/TuiApp.cpp b/src/tui/TuiApp.cpp index 5e56d1a..52d2d6a 100644 --- a/src/tui/TuiApp.cpp +++ b/src/tui/TuiApp.cpp @@ -29,6 +29,13 @@ year_month_day current_day() { 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)}; @@ -54,6 +61,20 @@ std::optional source_index(const std::span events, 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) @@ -110,6 +131,8 @@ void TuiApp::restore_history_snapshot(HistorySnapshot snapshot) { 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; } void TuiApp::push_history(std::vector& history, HistoryEntry entry) { @@ -309,6 +332,82 @@ void TuiApp::cancel_delete() { 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; +} + +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_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; + close_search(); +} + void TuiApp::handle_input(const std::string_view input) { if (input == "\x03") { dispatch(Action::quit); @@ -340,6 +439,18 @@ void TuiApp::handle_input(const std::string_view input) { } 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)); } @@ -369,6 +480,54 @@ void TuiApp::dispatch(const Action action) { 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: + return; + } + } const auto clear_event_focus = [this] { state_.focused_event_id.reset(); state_.show_event_details = false; @@ -434,6 +593,9 @@ void TuiApp::dispatch(const Action action) { case Action::redo: redo(); return; + case Action::search: + begin_search(); + return; case Action::quit: running_ = false; return; @@ -521,6 +683,9 @@ void TuiApp::dispatch(const Action action) { case Action::redo: redo(); return; + case Action::search: + begin_search(); + return; case Action::quit: running_ = false; return; diff --git a/tests/domain_tests.cpp b/tests/domain_tests.cpp index d13ea2e..abad204 100644 --- a/tests/domain_tests.cpp +++ b/tests/domain_tests.cpp @@ -155,6 +155,34 @@ void test_event_queries() "invalid backwards event never overlaps"); } +void test_event_search() +{ + nocal::Event event; + event.title = "Quarterly Planning"; + event.description = "Review the launch checklist"; + event.location = "North Conference Room"; + event.calendar_id = "TEAM-OPERATIONS"; + + check(nocal::event_matches_query(event, "quarterly"), + "event search matches the title case-insensitively"); + check(nocal::event_matches_query(event, "launch check"), + "event search matches a description substring"); + check(nocal::event_matches_query(event, "conference"), + "event search matches a location substring"); + check(nocal::event_matches_query(event, "team-operations"), + "event search matches the calendar ID case-insensitively"); + check(!nocal::event_matches_query(event, ""), + "an empty event search query matches nothing"); + check(!nocal::event_matches_query(event, "budget"), + "event search rejects text absent from every searchable field"); + + event.title = "Caf\xC3\xA9 REVIEW"; + check(nocal::event_matches_query(event, "Caf\xC3\xA9 review"), + "event search preserves non-ASCII bytes while folding ASCII"); + check(!nocal::event_matches_query(event, "CAF\xC3\x89"), + "event search does not locale-fold non-ASCII bytes"); +} + void test_daily_dst_recurrence() { using namespace std::chrono; @@ -378,6 +406,7 @@ int main() test_dates(); test_month_layout(); test_event_queries(); + test_event_search(); 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 553f834..6795ac6 100644 --- a/tests/tui_focus_tests.cpp +++ b/tests/tui_focus_tests.cpp @@ -875,6 +875,87 @@ void test_recurrence_grid_expansion_and_source_zone_display() ::tzset(); } +void test_search_prompt_results_and_occurrence_jump() +{ + using namespace std::chrono; + const auto first_day = nocal::today(); + const auto second_day = nocal::from_sys_days(nocal::to_sys_days(first_day) + days{1}); + const auto third_day = nocal::from_sys_days(nocal::to_sys_days(first_day) + days{2}); + const auto fourth_day = nocal::from_sys_days(nocal::to_sys_days(first_day) + days{3}); + auto recurring = make_event("search-series", "Quarter Sync", first_day, 9, 0, + first_day, 10, 0); + recurring.description = "Planning notes"; + recurring.recurrence = nocal::RecurrenceRule{ + .frequency = nocal::RecurrenceFrequency::daily, + .interval = 1, + .count = 3, + .until = std::nullopt, + .by_weekday = {}, + .by_month_day = {}, + .by_month = {}, + }; + auto other = make_event("other", "Unrelated", second_day, 12, 0, + second_day, 13, 0); + other.location = "Quarter room"; + auto holiday = make_event("holiday", "Quarter holiday", third_day, 0, 0, + fourth_day, 0, 0, true); + std::vector events{recurring, other, holiday}; + int saves = 0; + nocal::tui::TuiApp app{ + events, [&](std::span) { ++saves; }, -1, -1}; + + app.handle_input("/"); + check(app.search_active() && app.state().search_prompt, + "slash opens the search prompt"); + app.handle_input("QUARTER SYNX"); + app.handle_input("\x7f"); + app.handle_input("C"); + check(app.state().search_query == "QUARTER SYNC", + "search query accepts text and backspace editing"); + app.handle_input("\r"); + check(app.state().show_search_results && + app.state().search_results.size() == 3, + "search expands every matching recurrence in the bounded window"); + check(app.state().search_results.front().day == first_day && + app.state().search_results.back().day == third_day, + "search results retain chronological occurrence dates"); + + app.dispatch(nocal::tui::Action::down); + app.dispatch(nocal::tui::Action::down); + app.dispatch(nocal::tui::Action::select); + check(!app.search_active() && app.state().selected_day == third_day && + app.state().visible_month == third_day.year() / third_day.month() && + focus_starts_with(app, "search-series@occ:"), + "choosing a recurring result jumps to and focuses that exact instance"); + check(saves == 0, "search and result navigation never cross the persistence boundary"); + + const auto selected_after_jump = app.state().selected_day; + app.handle_input("/"); + app.handle_input("\x1b"); + check(!app.search_active() && app.state().selected_day == selected_after_jump, + "cancelling search preserves the calendar selection"); + + app.handle_input("/"); + app.handle_input("quarter"); + app.handle_input("\r"); + check(app.state().search_results.size() == 5 && + app.state().search_results[0].day == first_day && + app.state().search_results[1].day == second_day && + app.state().search_results[2].day == second_day && + app.state().search_results[3].title == "Quarter holiday" && + app.state().search_results[4].title == "Quarter Sync", + "search results sort by date with all-day events first within each day"); + app.dispatch(nocal::tui::Action::back); + + app.handle_input("/"); + app.handle_input("missing"); + app.handle_input("\r"); + check(app.state().show_search_results && app.state().search_results.empty(), + "a query with no matches produces a safe empty results view"); + app.dispatch(nocal::tui::Action::back); + check(!app.search_active(), "Esc/back closes empty search results"); +} + } // namespace int main() @@ -901,6 +982,7 @@ int main() test_multiday_segment_labels_and_series_messaging(); test_recurring_occurrence_navigation_and_whole_series_crud(); test_recurrence_grid_expansion_and_source_zone_display(); + test_search_prompt_results_and_occurrence_jump(); 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 7518723..b7bf23b 100644 --- a/tests/tui_tests.cpp +++ b/tests/tui_tests.cpp @@ -36,6 +36,11 @@ void test_full_month_render() .focused_event_id = std::nullopt, .show_event_details = false, .confirm_delete = false, + .search_prompt = false, + .show_search_results = false, + .search_query = {}, + .search_results = {}, + .search_result_index = 0, .notification = {}, .notification_is_error = false, }; @@ -79,6 +84,11 @@ void test_compact_and_input() .focused_event_id = std::nullopt, .show_event_details = false, .confirm_delete = false, + .search_prompt = false, + .show_search_results = false, + .search_query = {}, + .search_results = {}, + .search_result_index = 0, .notification = {}, .notification_is_error = false, }; @@ -103,6 +113,49 @@ void test_navigation() "down moves by one week"); } +void test_search_frames() +{ + 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 = 72; + state.height = 12; + state.ansi = false; + state.search_prompt = true; + state.search_query = "team"; + auto frame = nocal::tui::render_month( + std::span{}, state); + check(frame.find("SEARCH APPOINTMENTS") != std::string::npos && + frame.find("/ team_") != std::string::npos, + "search prompt renders its query and context"); + check(std::count(frame.begin(), frame.end(), '\n') == 11, + "search prompt fills the requested height"); + + state.search_prompt = false; + state.show_search_results = true; + state.search_results = { + {.focus_id = "one", .title = "Team planning", + .day = year{2026} / July / day{18}, .time_of_day = hours{9}, + .all_day = false, .location = "Studio", .recurring = false}, + {.focus_id = "two", .title = "Team holiday", + .day = year{2026} / July / day{19}, .time_of_day = std::nullopt, + .all_day = true, .location = {}, .recurring = true}, + }; + state.search_result_index = 1; + frame = nocal::tui::render_month( + std::span{}, state); + check(frame.find("2 matches") != std::string::npos && + frame.find("2026-07-18 09:00") != std::string::npos && + frame.find("Team holiday") != std::string::npos, + "search results render counts, dates, times, and titles"); + check(frame.find("\x1b[") == std::string::npos, + "search remains readable with ANSI disabled"); + check(nocal::tui::decode_key("/") == nocal::tui::Action::search, + "slash opens appointment search"); +} + } // namespace int main() @@ -110,6 +163,7 @@ int main() test_full_month_render(); test_compact_and_input(); test_navigation(); + test_search_frames(); if (failures != 0) { std::cerr << failures << " TUI test(s) failed\n"; return EXIT_FAILURE;