feat: add occurrence-aware appointment search

This commit is contained in:
2026-07-18 08:31:09 +01:00
parent c19098004e
commit 2774087d14
14 changed files with 485 additions and 11 deletions

View File

@@ -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 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, `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 `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 last successful mutation and `Ctrl-R` redoes it. Press `/` to search titles,
quit. 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 ## 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 `TZID` names. Recurring instances are individually navigable; edit and delete
clearly operate on the entire series. 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`, Calendars containing features this version cannot preserve—such as `RDATE`,
detached recurrence overrides, ordinal `BYDAY`, embedded `VTIMEZONE` detached recurrence overrides, ordinal `BYDAY`, embedded `VTIMEZONE`
definitions, alarms, or attendees—remain browsable but read-only. This guard definitions, alarms, or attendees—remain browsable but read-only. This guard

View File

@@ -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 `BYMONTH`, and `EXDATE`. Invalid metadata retains at most the base event rather
than crashing a render. 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 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 IANA `TZID`. C++20 time-zone conversion keeps zoned recurrences at their civil
wall time across daylight-saving transitions. The month grid displays instants wall time across daylight-saving transitions. The month grid displays instants

View File

@@ -70,6 +70,8 @@ The full terminal is treated as a responsive canvas:
| Move between editor fields | `Tab` / `Shift-Tab`, arrows | | Move between editor fields | `Tab` / `Shift-Tab`, arrows |
| Save/cancel editor | `Ctrl-S` / `Esc` | | Save/cancel editor | `Ctrl-S` / `Esc` |
| Return to month/unfocus | `Esc` | | Return to month/unfocus | `Esc` |
| Search appointments | `/`, then type and press `Enter` |
| Choose search result | arrows, `j` / `k`, then `Enter` |
| Help | `?` | | Help | `?` |
| Quit | `q` | | 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 last-known-good backup, and feed bounded session undo/redo history. Supported
recurring instances appear throughout the month, preserve civil time across recurring instances appear throughout the month, preserve civil time across
daylight-saving changes, and expose their source zone and whole-series mutation 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 semantics in the reader. `/` searches occurrence-aware title, description,
calendar visibility views, then advanced recurrence overrides and rule editing. 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 ## Accessibility

View File

@@ -9,13 +9,14 @@
- Guarded atomic `.ics` persistence, advisory locking, and round-trip tests - Guarded atomic `.ics` persistence, advisory locking, and round-trip tests
- Bounded session undo/redo, exact external-change detection, and backup recovery - Bounded session undo/redo, exact external-change detection, and backup recovery
- Windowed recurrence expansion, EXDATE, IANA TZID, and multi-day segment cues - 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 - Meson build, Nix development shell, desktop entry, and Hyprland launcher
- Seeded sample data when explicitly requested, never silent data mutation - Seeded sample data when explicitly requested, never silent data mutation
## 0.1 — complete local calendar ## 0.1 — complete local calendar
- Advanced recurrence editing, RDATE/overrides, and embedded VTIMEZONE support - 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 - Screen-reader-friendly linear view and full Unicode display-width handling
## 0.2 — durable cache and provider boundary ## 0.2 — durable cache and provider boundary

View File

@@ -6,6 +6,7 @@
#include <cstddef> #include <cstddef>
#include <functional> #include <functional>
#include <span> #include <span>
#include <string_view>
#include <vector> #include <vector>
namespace nocal { namespace nocal {
@@ -40,6 +41,9 @@ void sort_occurrences(EventOccurrences& occurrences);
[[nodiscard]] bool event_less(const Event& lhs, const Event& rhs) noexcept; [[nodiscard]] bool event_less(const Event& lhs, const Event& rhs) noexcept;
void sort_events(EventRefs& events); 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 // 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. // an instant and belongs to the interval containing its start time.
[[nodiscard]] bool event_overlaps(const Event& event, TimePoint begin, [[nodiscard]] bool event_overlaps(const Event& event, TimePoint begin,

View File

@@ -5,6 +5,7 @@
#include <span> #include <span>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <vector>
#include "nocal/domain/event.hpp" #include "nocal/domain/event.hpp"
#include "nocal/domain/event_query.hpp" #include "nocal/domain/event_query.hpp"
@@ -40,6 +41,16 @@ struct CalendarItem {
std::string source_time_zone; std::string source_time_zone;
}; };
struct SearchResultItem {
std::string focus_id;
std::string title;
std::chrono::year_month_day day;
std::optional<std::chrono::minutes> time_of_day;
bool all_day{false};
std::string location;
bool recurring{false};
};
struct ScreenState { struct ScreenState {
std::chrono::year_month visible_month{std::chrono::year{1970}, std::chrono::month{1}}; 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}, std::chrono::year_month_day selected_day{std::chrono::year{1970}, std::chrono::month{1},
@@ -53,6 +64,11 @@ struct ScreenState {
std::optional<std::string> focused_event_id; std::optional<std::string> focused_event_id;
bool show_event_details{false}; bool show_event_details{false};
bool confirm_delete{false}; bool confirm_delete{false};
bool search_prompt{false};
bool show_search_results{false};
std::string search_query;
std::vector<SearchResultItem> search_results;
std::size_t search_result_index{0};
std::string notification; std::string notification;
bool notification_is_error{false}; bool notification_is_error{false};
}; };
@@ -92,6 +108,7 @@ enum class Action {
delete_event, delete_event,
undo, undo,
redo, redo,
search,
quit, quit,
}; };

View File

@@ -42,6 +42,9 @@ public:
[[nodiscard]] bool delete_confirmation_active() const noexcept { [[nodiscard]] bool delete_confirmation_active() const noexcept {
return state_.confirm_delete; return state_.confirm_delete;
} }
[[nodiscard]] bool search_active() const noexcept {
return state_.search_prompt || state_.show_search_results;
}
private: private:
struct HistorySnapshot { struct HistorySnapshot {
@@ -87,6 +90,10 @@ private:
void submit_editor(); void submit_editor();
void confirm_delete(); void confirm_delete();
void cancel_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); void set_notification(std::string message, bool error = false);
}; };

View File

@@ -11,6 +11,25 @@ namespace {
using CivilTime = std::chrono::local_time<Clock::duration>; using CivilTime = std::chrono::local_time<Clock::duration>;
[[nodiscard]] constexpr unsigned char ascii_lower(unsigned char byte) noexcept
{
if (byte >= static_cast<unsigned char>('A') &&
byte <= static_cast<unsigned char>('Z')) {
return static_cast<unsigned char>(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<unsigned char>(lhs)) ==
ascii_lower(static_cast<unsigned char>(rhs));
}) != text.end();
}
struct Projection { struct Projection {
CivilTime start; CivilTime start;
CivilTime end; CivilTime end;
@@ -633,6 +652,17 @@ void sort_occurrences(EventOccurrences& occurrences)
std::stable_sort(occurrences.begin(), occurrences.end(), occurrence_less); 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 bool event_overlaps(const Event& event, TimePoint begin, TimePoint end) noexcept
{ {
return overlaps(event.start, event.end, begin, end); return overlaps(event.start, event.end, begin, end);

View File

@@ -151,6 +151,11 @@ int print_frame(std::span<const nocal::Event> events, bool no_color)
.focused_event_id = std::nullopt, .focused_event_id = std::nullopt,
.show_event_details = false, .show_event_details = false,
.confirm_delete = false, .confirm_delete = false,
.search_prompt = false,
.show_search_results = false,
.search_query = {},
.search_results = {},
.search_result_index = 0,
.notification = {}, .notification = {},
.notification_is_error = false, .notification_is_error = false,
}; };

View File

@@ -389,7 +389,7 @@ std::string detail_frame(std::span<const CalendarItem> items, const ScreenState&
add(" No notes for this appointment.", "2;3"); 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) + " "; std::to_string(ordinal) + "/" + std::to_string(total) + " ";
const int body_rows = height - 2; const int body_rows = height - 2;
std::ostringstream output; std::ostringstream output;
@@ -513,6 +513,68 @@ std::string source_focus_id(const std::span<const Event> events, const std::size
return unique ? uid : "@nocal:" + std::to_string(index); 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<std::string> lines;
lines.reserve(static_cast<std::size_t>(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::size_t>(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<int>(lines.size()) < height - 2) {
lines.emplace_back(static_cast<std::size_t>(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<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) { std::string compact_frame(std::span<const CalendarItem> items, const ScreenState& state) {
const int width = std::max(1, state.width); const int width = std::max(1, state.width);
const int height = std::max(1, state.height); const int height = std::max(1, state.height);
@@ -570,9 +632,9 @@ std::string compact_frame(std::span<const CalendarItem> items, const ScreenState
if (!state.notification.empty()) { if (!state.notification.empty()) {
controls = printable_text(state.notification, true); controls = printable_text(state.notification, true);
} else if (focused == nullptr) { } 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 { } 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<int>(lines.size()) < height) { if (static_cast<int>(lines.size()) < height) {
const auto style = state.notification.empty() ? "2" 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 == "d") return Action::delete_event;
if (sequence == "u") return Action::undo; if (sequence == "u") return Action::undo;
if (sequence == "\x12") return Action::redo; if (sequence == "\x12") return Action::redo;
if (sequence == "/") return Action::search;
if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit; if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit;
return Action::none; return Action::none;
} }
std::string render_month(std::span<const CalendarItem> items, const ScreenState& state) { std::string render_month(std::span<const CalendarItem> items, const ScreenState& state) {
if (state.search_prompt || state.show_search_results) return search_frame(state);
const auto focused = focused_item(items, state); const auto focused = focused_item(items, state);
if (state.confirm_delete && focused != nullptr) { if (state.confirm_delete && focused != nullptr) {
return delete_confirmation_frame(state, *focused); return delete_confirmation_frame(state, *focused);
@@ -744,19 +808,19 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
? by_day.at(sys_days{state.selected_day}).size() : 0U; ? by_day.at(sys_days{state.selected_day}).size() : 0U;
std::string status; std::string status;
if (state.show_help) { 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()) { } else if (!state.notification.empty()) {
status = " " + printable_text(state.notification, true); status = " " + printable_text(state.notification, true);
} else if (focused != nullptr) { } else if (focused != nullptr) {
const auto focus_title = focused->title.empty() ? std::string{"(untitled)"} const auto focus_title = focused->title.empty() ? std::string{"(untitled)"}
: printable_text(focused->title); : printable_text(focused->title);
status = " " + iso_date(state.selected_day) + " " + 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 { } else {
status = " " + iso_date(state.selected_day) + " " + std::to_string(selected_count) + status = " " + iso_date(state.selected_day) + " " + std::to_string(selected_count) +
(selected_count == 1 ? " appointment" : " appointments") + (selected_count == 1 ? " appointment" : " appointments") +
(selected_count > 0 ? " Tab focus" : "") + (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() const auto status_style = !state.notification.empty()
? (state.notification_is_error ? "1;31" : "1;32") ? (state.notification_is_error ? "1;31" : "1;32")

View File

@@ -29,6 +29,13 @@ year_month_day current_day() {
day{static_cast<unsigned>(local.tm_mday)}; day{static_cast<unsigned>(local.tm_mday)};
} }
minutes local_time_of_day(const TimePoint point) {
const auto instant = Clock::to_time_t(point);
std::tm local{};
::localtime_r(&instant, &local);
return hours{local.tm_hour} + minutes{local.tm_min};
}
year_month_day clamp_to_month(const year_month month, const unsigned requested_day) { year_month_day clamp_to_month(const year_month month, const unsigned requested_day) {
const auto last_day = static_cast<unsigned>((month / std::chrono::last).day()); const auto last_day = static_cast<unsigned>((month / std::chrono::last).day());
return month / day{std::min(requested_day, last_day)}; return month / day{std::min(requested_day, last_day)};
@@ -54,6 +61,20 @@ std::optional<std::size_t> source_index(const std::span<const Event> events,
return std::nullopt; return std::nullopt;
} }
void erase_last_codepoint(std::string& value) {
if (value.empty()) return;
auto at = value.size() - 1;
while (at > 0 && (static_cast<unsigned char>(value[at]) & 0xc0U) == 0x80U) --at;
value.erase(at);
}
bool is_search_text(const std::string_view input) {
return std::all_of(input.begin(), input.end(), [](const char character) {
const auto byte = static_cast<unsigned char>(character);
return byte >= 0x20U && byte != 0x7fU;
});
}
} // namespace } // namespace
TuiApp::TuiApp(std::vector<Event>& events, const int input_fd, const int output_fd) TuiApp::TuiApp(std::vector<Event>& 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_event_details = snapshot.show_event_details;
state_.show_help = snapshot.show_help; state_.show_help = snapshot.show_help;
state_.confirm_delete = false; state_.confirm_delete = false;
state_.search_prompt = false;
state_.show_search_results = false;
} }
void TuiApp::push_history(std::vector<HistoryEntry>& history, HistoryEntry entry) { void TuiApp::push_history(std::vector<HistoryEntry>& history, HistoryEntry entry) {
@@ -309,6 +332,82 @@ void TuiApp::cancel_delete() {
set_notification("Deletion cancelled."); 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<const Event>{events_};
for (const auto& occurrence : occurrences_overlapping(
event_span, local_day_start(first_day), local_day_end(last_day))) {
if (occurrence.source == nullptr ||
!event_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) { void TuiApp::handle_input(const std::string_view input) {
if (input == "\x03") { if (input == "\x03") {
dispatch(Action::quit); dispatch(Action::quit);
@@ -340,6 +439,18 @@ void TuiApp::handle_input(const std::string_view input) {
} }
return; 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)); dispatch(decode_key(input));
} }
@@ -369,6 +480,54 @@ void TuiApp::dispatch(const Action action) {
else if (action == Action::quit) running_ = false; else if (action == Action::quit) running_ = false;
return; 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] { const auto clear_event_focus = [this] {
state_.focused_event_id.reset(); state_.focused_event_id.reset();
state_.show_event_details = false; state_.show_event_details = false;
@@ -434,6 +593,9 @@ void TuiApp::dispatch(const Action action) {
case Action::redo: case Action::redo:
redo(); redo();
return; return;
case Action::search:
begin_search();
return;
case Action::quit: case Action::quit:
running_ = false; running_ = false;
return; return;
@@ -521,6 +683,9 @@ void TuiApp::dispatch(const Action action) {
case Action::redo: case Action::redo:
redo(); redo();
return; return;
case Action::search:
begin_search();
return;
case Action::quit: case Action::quit:
running_ = false; running_ = false;
return; return;

View File

@@ -155,6 +155,34 @@ void test_event_queries()
"invalid backwards event never overlaps"); "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() void test_daily_dst_recurrence()
{ {
using namespace std::chrono; using namespace std::chrono;
@@ -378,6 +406,7 @@ int main()
test_dates(); test_dates();
test_month_layout(); test_month_layout();
test_event_queries(); test_event_queries();
test_event_search();
test_daily_dst_recurrence(); test_daily_dst_recurrence();
test_floating_dst_and_until(); test_floating_dst_and_until();
test_weekly_count_and_exdates(); test_weekly_count_and_exdates();

View File

@@ -875,6 +875,87 @@ void test_recurrence_grid_expansion_and_source_zone_display()
::tzset(); ::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<nocal::Event> events{recurring, other, holiday};
int saves = 0;
nocal::tui::TuiApp app{
events, [&](std::span<const nocal::Event>) { ++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 } // namespace
int main() int main()
@@ -901,6 +982,7 @@ int main()
test_multiday_segment_labels_and_series_messaging(); test_multiday_segment_labels_and_series_messaging();
test_recurring_occurrence_navigation_and_whole_series_crud(); test_recurring_occurrence_navigation_and_whole_series_crud();
test_recurrence_grid_expansion_and_source_zone_display(); test_recurrence_grid_expansion_and_source_zone_display();
test_search_prompt_results_and_occurrence_jump();
if (failures != 0) { if (failures != 0) {
std::cerr << failures << " TUI focus test(s) failed\n"; std::cerr << failures << " TUI focus test(s) failed\n";
return EXIT_FAILURE; return EXIT_FAILURE;

View File

@@ -36,6 +36,11 @@ void test_full_month_render()
.focused_event_id = std::nullopt, .focused_event_id = std::nullopt,
.show_event_details = false, .show_event_details = false,
.confirm_delete = false, .confirm_delete = false,
.search_prompt = false,
.show_search_results = false,
.search_query = {},
.search_results = {},
.search_result_index = 0,
.notification = {}, .notification = {},
.notification_is_error = false, .notification_is_error = false,
}; };
@@ -79,6 +84,11 @@ void test_compact_and_input()
.focused_event_id = std::nullopt, .focused_event_id = std::nullopt,
.show_event_details = false, .show_event_details = false,
.confirm_delete = false, .confirm_delete = false,
.search_prompt = false,
.show_search_results = false,
.search_query = {},
.search_results = {},
.search_result_index = 0,
.notification = {}, .notification = {},
.notification_is_error = false, .notification_is_error = false,
}; };
@@ -103,6 +113,49 @@ void test_navigation()
"down moves by one week"); "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<const nocal::tui::CalendarItem>{}, 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<const nocal::tui::CalendarItem>{}, 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 } // namespace
int main() int main()
@@ -110,6 +163,7 @@ int main()
test_full_month_render(); test_full_month_render();
test_compact_and_input(); test_compact_and_input();
test_navigation(); test_navigation();
test_search_frames();
if (failures != 0) { if (failures != 0) {
std::cerr << failures << " TUI test(s) failed\n"; std::cerr << failures << " TUI test(s) failed\n";
return EXIT_FAILURE; return EXIT_FAILURE;