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

@@ -29,6 +29,13 @@ year_month_day current_day() {
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) {
const auto last_day = static_cast<unsigned>((month / std::chrono::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;
}
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
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_help = snapshot.show_help;
state_.confirm_delete = false;
state_.search_prompt = false;
state_.show_search_results = false;
}
void TuiApp::push_history(std::vector<HistoryEntry>& 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<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) {
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;