feat: add occurrence-aware appointment search
This commit is contained in:
11
README.md
11
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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user