feat: add occurrence-aware appointment search
This commit is contained in:
@@ -389,7 +389,7 @@ std::string detail_frame(std::span<const CalendarItem> 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<const Event> 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<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) {
|
||||
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<const CalendarItem> 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<int>(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<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);
|
||||
if (state.confirm_delete && focused != nullptr) {
|
||||
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;
|
||||
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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user