feat: add bounded agenda view
Add a read-only 42-day occurrence agenda with keyboard navigation, six-week paging, exact return-to-grid focus, search and calendar overlays, and bounded recurrence expansion. Keep agenda state outside persistence, document month-only mutations, and cover empty windows, filtering, overlays, navigation, rendering, and terminal restoration.
This commit is contained in:
19
README.md
19
README.md
@@ -6,7 +6,8 @@ inherits its colors from your terminal theme.
|
|||||||
|
|
||||||
This repository currently contains the first local-calendar vertical slice:
|
This repository currently contains the first local-calendar vertical slice:
|
||||||
date/domain logic, guarded atomic iCalendar persistence, add/edit/delete forms,
|
date/domain logic, guarded atomic iCalendar persistence, add/edit/delete forms,
|
||||||
a responsive ANSI terminal UI, tests, and Linux launch integration. See [the product specification](docs/PRODUCT.md),
|
a responsive ANSI terminal UI with month and agenda views, tests, and Linux
|
||||||
|
launch integration. See [the product specification](docs/PRODUCT.md),
|
||||||
[architecture](docs/ARCHITECTURE.md), and [roadmap](docs/ROADMAP.md).
|
[architecture](docs/ARCHITECTURE.md), and [roadmap](docs/ROADMAP.md).
|
||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
@@ -15,7 +16,11 @@ Use arrow keys or `h j k l` to move, `PageUp`/`PageDown` or `p`/`n` to change
|
|||||||
month, and `t` for today. On a day with appointments, `Tab` and `Shift-Tab`
|
month, and `t` for today. On a day with appointments, `Tab` and `Shift-Tab`
|
||||||
move focus through them and `Enter` opens the appointment reader. In the
|
move focus through them and `Enter` opens the appointment reader. In the
|
||||||
reader, arrows/Vim keys or Tab variants browse the day's appointments and
|
reader, arrows/Vim keys or Tab variants browse the day's appointments and
|
||||||
`Esc` returns to the month. Press `c` to open the calendar picker; arrows or
|
`Esc` returns to the month. Press `g` to open a read-only 42-day agenda starting
|
||||||
|
on the selected date. Arrows, `j`/`k`, or Tab variants choose an occurrence;
|
||||||
|
`Enter` returns to the month at that exact occurrence. `PageUp`/`PageDown` or
|
||||||
|
`p`/`n` shift the agenda by 42 days, while `Esc` or `g` returns without changing
|
||||||
|
the month selection. Press `c` to open the calendar picker; arrows or
|
||||||
`j`/`k` select a calendar and `Enter` toggles its in-session visibility. Press
|
`j`/`k` select a calendar and `Enter` toggles its in-session visibility. Press
|
||||||
`a` to add an appointment. Focus an
|
`a` to add an appointment. Focus an
|
||||||
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
|
||||||
@@ -24,6 +29,8 @@ appointment and press `e` to edit it or `d` to delete it. Forms use `Tab` and
|
|||||||
last successful mutation and `Ctrl-R` redoes it. Press `/` to search titles,
|
last successful mutation and `Ctrl-R` redoes it. Press `/` to search titles,
|
||||||
descriptions, locations, and calendar IDs. Search results use arrows or
|
descriptions, locations, and calendar IDs. Search results use arrows or
|
||||||
`j`/`k`; `Enter` jumps to the exact occurrence and `Esc` returns to the month.
|
`j`/`k`; `Enter` jumps to the exact occurrence and `Esc` returns to the month.
|
||||||
|
The calendar picker and search are also available over the agenda with `c` and
|
||||||
|
`/`. Add, edit, and delete remain month-view workflows.
|
||||||
Press `?` for help and `q` to quit.
|
Press `?` for help and `q` to quit.
|
||||||
|
|
||||||
## Build and run
|
## Build and run
|
||||||
@@ -48,9 +55,11 @@ clearly operate on the entire series.
|
|||||||
Search expands recurring matches within five years on either side of the
|
Search expands recurring matches within five years on either side of the
|
||||||
currently selected date. This finite window keeps unbounded recurrence rules
|
currently selected date. This finite window keeps unbounded recurrence rules
|
||||||
responsive while one-time and recurring results share the same chronological
|
responsive while one-time and recurring results share the same chronological
|
||||||
result list. Hidden calendars are excluded consistently from the month grid,
|
result list. Agenda recurrence expansion is bounded to its visible 42-day
|
||||||
appointment focus, and search. Visibility is session-only presentation state;
|
window. Hidden calendars are excluded consistently from the month grid,
|
||||||
saves always retain events from hidden calendars.
|
agenda, appointment focus, and search. Visibility and agenda position are
|
||||||
|
session-only presentation state; saves always retain events from hidden
|
||||||
|
calendars.
|
||||||
|
|
||||||
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`
|
||||||
|
|||||||
@@ -42,10 +42,18 @@ rule cannot turn an interactive query into unbounded work.
|
|||||||
|
|
||||||
Calendar visibility is session presentation state. The controller derives its
|
Calendar visibility is session presentation state. The controller derives its
|
||||||
calendar list from event calendar identifiers and applies the same domain
|
calendar list from event calendar identifiers and applies the same domain
|
||||||
predicate to month rendering, focus traversal, and search. Hidden events remain
|
predicate to month rendering, agenda queries, focus traversal, and search.
|
||||||
in the canonical collection passed to persistence, and focus identities are
|
Hidden events remain in the canonical collection passed to persistence, and
|
||||||
always derived from that complete collection so filtering cannot renumber
|
focus identities are always derived from that complete collection so filtering
|
||||||
events with duplicate or missing UIDs.
|
cannot renumber events with duplicate or missing UIDs.
|
||||||
|
|
||||||
|
The agenda is a TUI projection over domain occurrence queries, not a second
|
||||||
|
calendar model. It requests exactly one 42-day window at a time so recurrence
|
||||||
|
expansion remains finite, preserves occurrence identity when returning to the
|
||||||
|
month grid, and keeps its window and selection as non-persistent controller
|
||||||
|
state. Calendar and search overlays may be opened above it, but mutations stay
|
||||||
|
in the month workflow and continue to operate on the canonical event
|
||||||
|
collection.
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ The full terminal is treated as a responsive canvas:
|
|||||||
| Next/previous appointment | `Tab` / `Shift-Tab` |
|
| Next/previous appointment | `Tab` / `Shift-Tab` |
|
||||||
| Read focused appointment | `Enter` |
|
| Read focused appointment | `Enter` |
|
||||||
| Browse inside reader | arrows, `h j k l`, `Tab` / `Shift-Tab` |
|
| Browse inside reader | arrows, `h j k l`, `Tab` / `Shift-Tab` |
|
||||||
|
| Open/close 42-day agenda | `g`; `Esc` also closes |
|
||||||
|
| Choose agenda occurrence | arrows, `j` / `k`, `Tab` / `Shift-Tab`, then `Enter` |
|
||||||
|
| Shift agenda by 42 days | `PageUp` / `PageDown`, `p` / `n` |
|
||||||
| Add appointment | `a` |
|
| Add appointment | `a` |
|
||||||
| Edit focused appointment | `e` |
|
| Edit focused appointment | `e` |
|
||||||
| Delete focused appointment | `d`, then `y` to confirm |
|
| Delete focused appointment | `d`, then `y` to confirm |
|
||||||
@@ -77,7 +80,8 @@ The full terminal is treated as a responsive canvas:
|
|||||||
| Quit | `q` |
|
| Quit | `q` |
|
||||||
|
|
||||||
The first usable foundation supports day and appointment navigation, a
|
The first usable foundation supports day and appointment navigation, a
|
||||||
full-frame reader, validated add/edit/delete forms, and atomic `.ics` writes.
|
full-frame reader, a read-only 42-day agenda, validated add/edit/delete forms,
|
||||||
|
and atomic `.ics` writes.
|
||||||
Dense days keep every appointment keyboard-reachable even when all lines do
|
Dense days keep every appointment keyboard-reachable even when all lines do
|
||||||
not fit in the cell. Unknown or unsupported iCalendar content makes a source
|
not fit in the cell. Unknown or unsupported iCalendar content makes a source
|
||||||
read-only instead of being discarded. Saves reject external changes, retain a
|
read-only instead of being discarded. Saves reject external changes, retain a
|
||||||
@@ -88,8 +92,14 @@ semantics in the reader. `/` searches occurrence-aware title, description,
|
|||||||
location, and calendar-ID matches in a finite ten-year window centered on the
|
location, and calendar-ID matches in a finite ten-year window centered on the
|
||||||
selected date. `c` toggles session-only calendar visibility consistently across
|
selected date. `c` toggles session-only calendar visibility consistently across
|
||||||
the grid, appointment focus, and search without filtering the model supplied to
|
the grid, appointment focus, and search without filtering the model supplied to
|
||||||
persistence. The next local-data work adds an agenda view, then advanced
|
persistence. `g` opens an occurrence-aware agenda at the selected month date;
|
||||||
recurrence overrides and rule editing.
|
navigation is bounded to consecutive 42-day windows and `Enter` returns to the
|
||||||
|
exact occurrence in the month grid. `Esc` or `g` closes the agenda without
|
||||||
|
changing the month selection unless an occurrence was chosen. Hidden calendars
|
||||||
|
remain filtered there, while calendar selection and search can overlay the
|
||||||
|
agenda. Agenda navigation never persists state, and add/edit/delete remain in
|
||||||
|
the month workflow. The next local-data work adds advanced recurrence overrides
|
||||||
|
and rule editing.
|
||||||
|
|
||||||
## Accessibility
|
## Accessibility
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,14 @@
|
|||||||
- 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
|
- Occurrence-aware appointment search with exact result-to-grid navigation
|
||||||
- Session calendar toggles shared by month rendering, focus, and search
|
- Session calendar toggles shared by month rendering, focus, and search
|
||||||
|
- Read-only 42-day agenda with bounded recurrence and exact return 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
|
||||||
- Agenda view, import/export, configurable week start
|
- Import/export and 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
|
||||||
|
|||||||
@@ -51,6 +51,17 @@ struct SearchResultItem {
|
|||||||
bool recurring{false};
|
bool recurring{false};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct AgendaItem {
|
||||||
|
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;
|
||||||
|
std::string calendar_id;
|
||||||
|
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},
|
||||||
@@ -73,6 +84,11 @@ struct ScreenState {
|
|||||||
bool notification_is_error{false};
|
bool notification_is_error{false};
|
||||||
bool show_calendar_picker{false};
|
bool show_calendar_picker{false};
|
||||||
std::size_t calendar_index{0};
|
std::size_t calendar_index{0};
|
||||||
|
bool show_agenda{false};
|
||||||
|
std::chrono::year_month_day agenda_start_day{
|
||||||
|
std::chrono::year{1970}, std::chrono::month{1}, std::chrono::day{1}};
|
||||||
|
std::vector<AgendaItem> agenda_items;
|
||||||
|
std::size_t agenda_index{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render a complete frame. The result contains no cursor positioning, which
|
// Render a complete frame. The result contains no cursor positioning, which
|
||||||
@@ -117,6 +133,7 @@ enum class Action {
|
|||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
search,
|
search,
|
||||||
|
agenda,
|
||||||
calendars,
|
calendars,
|
||||||
quit,
|
quit,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public:
|
|||||||
[[nodiscard]] bool calendar_picker_active() const noexcept {
|
[[nodiscard]] bool calendar_picker_active() const noexcept {
|
||||||
return state_.show_calendar_picker;
|
return state_.show_calendar_picker;
|
||||||
}
|
}
|
||||||
|
[[nodiscard]] bool agenda_active() const noexcept { return state_.show_agenda; }
|
||||||
[[nodiscard]] const std::vector<Calendar>& calendars() const noexcept {
|
[[nodiscard]] const std::vector<Calendar>& calendars() const noexcept {
|
||||||
return calendars_;
|
return calendars_;
|
||||||
}
|
}
|
||||||
@@ -89,6 +90,11 @@ private:
|
|||||||
void begin_calendar_picker();
|
void begin_calendar_picker();
|
||||||
void close_calendar_picker();
|
void close_calendar_picker();
|
||||||
void toggle_selected_calendar();
|
void toggle_selected_calendar();
|
||||||
|
void begin_agenda();
|
||||||
|
void close_agenda();
|
||||||
|
void refresh_agenda();
|
||||||
|
void shift_agenda(int direction);
|
||||||
|
void choose_agenda_item();
|
||||||
[[nodiscard]] HistorySnapshot capture_history_snapshot() const;
|
[[nodiscard]] HistorySnapshot capture_history_snapshot() const;
|
||||||
void restore_history_snapshot(HistorySnapshot snapshot);
|
void restore_history_snapshot(HistorySnapshot snapshot);
|
||||||
void push_history(std::vector<HistoryEntry>& history, HistoryEntry entry);
|
void push_history(std::vector<HistoryEntry>& history, HistoryEntry entry);
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ void print_help(std::ostream& output)
|
|||||||
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
|
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
|
||||||
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n"
|
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n"
|
||||||
" u undoes the last change, Ctrl-R redoes it,\n"
|
" u undoes the last change, Ctrl-R redoes it,\n"
|
||||||
|
" g opens the 42-day agenda; arrows/jk/Tab choose, Enter jumps,\n"
|
||||||
|
" PageUp/PageDown or p/n shift its window, Esc/g returns,\n"
|
||||||
" c toggles calendar visibility, / searches appointments,\n"
|
" c toggles calendar visibility, / searches appointments,\n"
|
||||||
" t jumps to today, ? toggles help, q quits.\n";
|
" t jumps to today, ? toggles help, q quits.\n";
|
||||||
}
|
}
|
||||||
@@ -159,6 +161,12 @@ int print_frame(std::span<const nocal::Event> events, bool no_color)
|
|||||||
.search_result_index = 0,
|
.search_result_index = 0,
|
||||||
.notification = {},
|
.notification = {},
|
||||||
.notification_is_error = false,
|
.notification_is_error = false,
|
||||||
|
.show_calendar_picker = false,
|
||||||
|
.calendar_index = 0,
|
||||||
|
.show_agenda = false,
|
||||||
|
.agenda_start_day = day,
|
||||||
|
.agenda_items = {},
|
||||||
|
.agenda_index = 0,
|
||||||
};
|
};
|
||||||
std::cout << nocal::tui::render_month(events, state) << '\n';
|
std::cout << nocal::tui::render_month(events, state) << '\n';
|
||||||
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
|
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||||
|
|||||||
@@ -575,6 +575,88 @@ std::string search_frame(const ScreenState& state) {
|
|||||||
return output.str();
|
return output.str();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string agenda_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("AGENDA", width), "1", state.ansi));
|
||||||
|
|
||||||
|
const auto start = sys_days{state.agenda_start_day};
|
||||||
|
const auto final_day = year_month_day{start + days{41}};
|
||||||
|
if (height > 1) {
|
||||||
|
const auto range = iso_date(state.agenda_start_day) + " – " +
|
||||||
|
iso_date(final_day) + " (inclusive)";
|
||||||
|
lines.push_back(styled(centred(range, width), "2", state.ansi));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::size_t> ordered(state.agenda_items.size());
|
||||||
|
for (std::size_t index = 0; index < ordered.size(); ++index) ordered[index] = index;
|
||||||
|
std::stable_sort(ordered.begin(), ordered.end(), [&](const auto left_index,
|
||||||
|
const auto right_index) {
|
||||||
|
const auto& left = state.agenda_items[left_index];
|
||||||
|
const auto& right = state.agenda_items[right_index];
|
||||||
|
if (left.day != right.day) return sys_days{left.day} < sys_days{right.day};
|
||||||
|
if (left.all_day != right.all_day) return left.all_day;
|
||||||
|
return left.time_of_day.value_or(minutes{-1}) <
|
||||||
|
right.time_of_day.value_or(minutes{-1});
|
||||||
|
});
|
||||||
|
|
||||||
|
const auto footer_rows = std::min(2, std::max(0, height - 2));
|
||||||
|
const auto row_capacity = static_cast<std::size_t>(
|
||||||
|
std::max(0, height - 2 - footer_rows));
|
||||||
|
const auto selected = std::find(ordered.begin(), ordered.end(), state.agenda_index);
|
||||||
|
const auto selected_position = selected == ordered.end()
|
||||||
|
? std::size_t{0}
|
||||||
|
: static_cast<std::size_t>(selected - ordered.begin());
|
||||||
|
std::size_t first = 0;
|
||||||
|
if (row_capacity > 0 && selected != ordered.end() && selected_position >= row_capacity) {
|
||||||
|
first = selected_position - row_capacity + 1;
|
||||||
|
}
|
||||||
|
const auto last = std::min(ordered.size(), first + row_capacity);
|
||||||
|
for (auto position = first; position < last; ++position) {
|
||||||
|
const auto index = ordered[position];
|
||||||
|
const auto& item = state.agenda_items[index];
|
||||||
|
const bool focused = index == state.agenda_index;
|
||||||
|
auto when = item.all_day || !item.time_of_day
|
||||||
|
? std::string{"all day"}
|
||||||
|
: clock_time(*item.time_of_day) + " local";
|
||||||
|
auto title = item.title.empty() ? std::string{"(untitled)"}
|
||||||
|
: printable_text(item.title);
|
||||||
|
auto label = std::string{focused ? "› " : " "} + iso_date(item.day) +
|
||||||
|
" " + when + " " + (item.recurring ? "↻ " : "") + title;
|
||||||
|
if (!item.location.empty()) label += " — " + printable_text(item.location);
|
||||||
|
lines.push_back(styled(fit_text(label, width), focused ? "7;1" : "", state.ansi));
|
||||||
|
}
|
||||||
|
if (state.agenda_items.empty() && row_capacity > 0) {
|
||||||
|
lines.push_back(styled(fit_text("No appointments in this 6-week window.", width),
|
||||||
|
"2", state.ansi));
|
||||||
|
}
|
||||||
|
while (static_cast<int>(lines.size()) < height - footer_rows) {
|
||||||
|
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
||||||
|
}
|
||||||
|
if (footer_rows > 0) {
|
||||||
|
lines.push_back(styled(fit_text(
|
||||||
|
"arrows/jk/Tab choose Enter month n/p 6 weeks",
|
||||||
|
width), "2", state.ansi));
|
||||||
|
}
|
||||||
|
if (footer_rows > 1) {
|
||||||
|
lines.push_back(styled(fit_text(
|
||||||
|
"c calendars / search g/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 calendar_picker_frame(const std::span<const Calendar> calendars,
|
std::string calendar_picker_frame(const std::span<const Calendar> calendars,
|
||||||
const ScreenState& state) {
|
const ScreenState& state) {
|
||||||
const int width = std::max(1, state.width);
|
const int width = std::max(1, state.width);
|
||||||
@@ -685,7 +767,7 @@ 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 = "c calendars / search a add u undo Ctrl-R redo ? help n/p month t today q quit";
|
controls = "g agenda c calendars / search a add u undo Ctrl-R redo ? help n/p month t today q quit";
|
||||||
} else {
|
} else {
|
||||||
controls = "Tab appointment Enter open / search e edit d delete u undo Ctrl-R redo";
|
controls = "Tab appointment Enter open / search e edit d delete u undo Ctrl-R redo";
|
||||||
}
|
}
|
||||||
@@ -737,6 +819,7 @@ Action decode_key(const std::string_view sequence) noexcept {
|
|||||||
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 == "/") return Action::search;
|
||||||
|
if (sequence == "g") return Action::agenda;
|
||||||
if (sequence == "c") return Action::calendars;
|
if (sequence == "c") return Action::calendars;
|
||||||
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;
|
||||||
@@ -744,6 +827,7 @@ Action decode_key(const std::string_view sequence) noexcept {
|
|||||||
|
|
||||||
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);
|
if (state.search_prompt || state.show_search_results) return search_frame(state);
|
||||||
|
if (state.show_agenda) return agenda_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);
|
||||||
@@ -862,7 +946,7 @@ 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 c calendars / search 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 g agenda c calendars / 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) {
|
||||||
@@ -874,7 +958,7 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
|
|||||||
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" : "") +
|
||||||
" c calendars / search a add u undo Ctrl-R redo ? help q quit";
|
" g agenda c calendars / 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")
|
||||||
|
|||||||
@@ -169,6 +169,111 @@ void TuiApp::toggle_selected_calendar() {
|
|||||||
state_.focused_event_id.reset();
|
state_.focused_event_id.reset();
|
||||||
state_.show_event_details = false;
|
state_.show_event_details = false;
|
||||||
}
|
}
|
||||||
|
if (state_.show_agenda) refresh_agenda();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::begin_agenda() {
|
||||||
|
state_.show_agenda = true;
|
||||||
|
state_.agenda_start_day = state_.selected_day;
|
||||||
|
state_.agenda_index = 0;
|
||||||
|
state_.agenda_items.clear();
|
||||||
|
state_.show_event_details = false;
|
||||||
|
state_.show_help = false;
|
||||||
|
state_.confirm_delete = false;
|
||||||
|
state_.search_prompt = false;
|
||||||
|
state_.show_search_results = false;
|
||||||
|
state_.show_calendar_picker = false;
|
||||||
|
refresh_agenda();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::close_agenda() {
|
||||||
|
state_.show_agenda = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::refresh_agenda() {
|
||||||
|
if (!state_.agenda_start_day.ok()) state_.agenda_start_day = state_.selected_day;
|
||||||
|
std::optional<std::string> selected_identity;
|
||||||
|
if (state_.agenda_index < state_.agenda_items.size()) {
|
||||||
|
selected_identity = state_.agenda_items[state_.agenda_index].focus_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
state_.agenda_items.clear();
|
||||||
|
const auto event_span = std::span<const Event>{events_};
|
||||||
|
const auto calendar_span = std::span<const Calendar>{calendars_};
|
||||||
|
const auto first_day = sys_days{state_.agenda_start_day};
|
||||||
|
constexpr auto window = days{42};
|
||||||
|
const auto end_day = first_day + window;
|
||||||
|
for (const auto& occurrence : occurrences_overlapping(
|
||||||
|
event_span, local_day_start(year_month_day{first_day}),
|
||||||
|
local_day_start(year_month_day{end_day}))) {
|
||||||
|
if (occurrence.source == nullptr ||
|
||||||
|
!event_is_visible(*occurrence.source, calendar_span)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const auto occurrence_day = local_date(occurrence.start);
|
||||||
|
const auto display_day = std::max(occurrence_day, state_.agenda_start_day);
|
||||||
|
const bool continuation = display_day != occurrence_day;
|
||||||
|
state_.agenda_items.push_back(AgendaItem{
|
||||||
|
.focus_id = occurrence_focus_id(event_span, occurrence),
|
||||||
|
.title = occurrence.source->title,
|
||||||
|
.day = display_day,
|
||||||
|
.time_of_day = occurrence.source->all_day || continuation
|
||||||
|
? std::nullopt
|
||||||
|
: std::optional{local_time_of_day(occurrence.start)},
|
||||||
|
.all_day = occurrence.source->all_day || continuation,
|
||||||
|
.location = occurrence.source->location,
|
||||||
|
.calendar_id = occurrence.source->calendar_id,
|
||||||
|
.recurring = occurrence.source->recurrence.has_value(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
std::stable_sort(state_.agenda_items.begin(), state_.agenda_items.end(),
|
||||||
|
[](const AgendaItem& left, const AgendaItem& 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;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state_.agenda_items.empty()) {
|
||||||
|
state_.agenda_index = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selected_identity) {
|
||||||
|
const auto selected = std::find_if(
|
||||||
|
state_.agenda_items.begin(), state_.agenda_items.end(),
|
||||||
|
[&](const AgendaItem& item) { return item.focus_id == *selected_identity; });
|
||||||
|
if (selected != state_.agenda_items.end()) {
|
||||||
|
state_.agenda_index = static_cast<std::size_t>(selected - state_.agenda_items.begin());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state_.agenda_index = std::min(state_.agenda_index, state_.agenda_items.size() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::shift_agenda(const int direction) {
|
||||||
|
if (direction == 0) return;
|
||||||
|
constexpr auto window = days{42};
|
||||||
|
state_.agenda_start_day = year_month_day{
|
||||||
|
sys_days{state_.agenda_start_day} + window * direction};
|
||||||
|
state_.agenda_items.clear();
|
||||||
|
state_.agenda_index = 0;
|
||||||
|
refresh_agenda();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::choose_agenda_item() {
|
||||||
|
if (state_.agenda_index >= state_.agenda_items.size()) return;
|
||||||
|
const auto& item = state_.agenda_items[state_.agenda_index];
|
||||||
|
state_.selected_day = item.day;
|
||||||
|
state_.visible_month = item.day.year() / item.day.month();
|
||||||
|
state_.focused_event_id = item.focus_id;
|
||||||
|
state_.show_event_details = false;
|
||||||
|
close_agenda();
|
||||||
}
|
}
|
||||||
|
|
||||||
void TuiApp::set_notification(std::string message, const bool error) {
|
void TuiApp::set_notification(std::string message, const bool error) {
|
||||||
@@ -198,6 +303,7 @@ void TuiApp::restore_history_snapshot(HistorySnapshot snapshot) {
|
|||||||
state_.search_prompt = false;
|
state_.search_prompt = false;
|
||||||
state_.show_search_results = false;
|
state_.show_search_results = false;
|
||||||
state_.show_calendar_picker = false;
|
state_.show_calendar_picker = false;
|
||||||
|
state_.show_agenda = false;
|
||||||
synchronize_calendars();
|
synchronize_calendars();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +378,7 @@ void TuiApp::begin_add() {
|
|||||||
state_.show_help = false;
|
state_.show_help = false;
|
||||||
state_.confirm_delete = false;
|
state_.confirm_delete = false;
|
||||||
state_.show_calendar_picker = false;
|
state_.show_calendar_picker = false;
|
||||||
|
state_.show_agenda = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void TuiApp::begin_edit() {
|
void TuiApp::begin_edit() {
|
||||||
@@ -292,6 +399,7 @@ void TuiApp::begin_edit() {
|
|||||||
state_.show_help = false;
|
state_.show_help = false;
|
||||||
state_.confirm_delete = false;
|
state_.confirm_delete = false;
|
||||||
state_.show_calendar_picker = false;
|
state_.show_calendar_picker = false;
|
||||||
|
state_.show_agenda = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void TuiApp::begin_delete() {
|
void TuiApp::begin_delete() {
|
||||||
@@ -304,6 +412,7 @@ void TuiApp::begin_delete() {
|
|||||||
state_.show_event_details = false;
|
state_.show_event_details = false;
|
||||||
state_.show_help = false;
|
state_.show_help = false;
|
||||||
state_.show_calendar_picker = false;
|
state_.show_calendar_picker = false;
|
||||||
|
state_.show_agenda = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void TuiApp::submit_editor() {
|
void TuiApp::submit_editor() {
|
||||||
@@ -491,6 +600,7 @@ void TuiApp::choose_search_result() {
|
|||||||
state_.visible_month = result.day.year() / result.day.month();
|
state_.visible_month = result.day.year() / result.day.month();
|
||||||
state_.focused_event_id = result.focus_id;
|
state_.focused_event_id = result.focus_id;
|
||||||
state_.show_event_details = false;
|
state_.show_event_details = false;
|
||||||
|
state_.show_agenda = false;
|
||||||
close_search();
|
close_search();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,6 +721,7 @@ void TuiApp::dispatch(const Action action) {
|
|||||||
case Action::delete_event:
|
case Action::delete_event:
|
||||||
case Action::undo:
|
case Action::undo:
|
||||||
case Action::redo:
|
case Action::redo:
|
||||||
|
case Action::agenda:
|
||||||
case Action::calendars:
|
case Action::calendars:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -652,6 +763,59 @@ void TuiApp::dispatch(const Action action) {
|
|||||||
case Action::undo:
|
case Action::undo:
|
||||||
case Action::redo:
|
case Action::redo:
|
||||||
case Action::search:
|
case Action::search:
|
||||||
|
case Action::agenda:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state_.show_agenda) {
|
||||||
|
const auto count = state_.agenda_items.size();
|
||||||
|
switch (action) {
|
||||||
|
case Action::up:
|
||||||
|
case Action::left:
|
||||||
|
case Action::previous_event:
|
||||||
|
if (count > 0 && state_.agenda_index > 0) --state_.agenda_index;
|
||||||
|
return;
|
||||||
|
case Action::down:
|
||||||
|
case Action::right:
|
||||||
|
case Action::next_event:
|
||||||
|
if (count > 0 && state_.agenda_index + 1 < count) ++state_.agenda_index;
|
||||||
|
return;
|
||||||
|
case Action::previous_month:
|
||||||
|
shift_agenda(-1);
|
||||||
|
return;
|
||||||
|
case Action::next_month:
|
||||||
|
shift_agenda(1);
|
||||||
|
return;
|
||||||
|
case Action::today:
|
||||||
|
state_.today = current_day();
|
||||||
|
state_.agenda_start_day = state_.today;
|
||||||
|
state_.agenda_items.clear();
|
||||||
|
state_.agenda_index = 0;
|
||||||
|
refresh_agenda();
|
||||||
|
return;
|
||||||
|
case Action::select:
|
||||||
|
choose_agenda_item();
|
||||||
|
return;
|
||||||
|
case Action::back:
|
||||||
|
case Action::agenda:
|
||||||
|
close_agenda();
|
||||||
|
return;
|
||||||
|
case Action::search:
|
||||||
|
begin_search();
|
||||||
|
return;
|
||||||
|
case Action::calendars:
|
||||||
|
begin_calendar_picker();
|
||||||
|
return;
|
||||||
|
case Action::quit:
|
||||||
|
running_ = false;
|
||||||
|
return;
|
||||||
|
case Action::none:
|
||||||
|
case Action::toggle_help:
|
||||||
|
case Action::add_event:
|
||||||
|
case Action::edit_event:
|
||||||
|
case Action::delete_event:
|
||||||
|
case Action::undo:
|
||||||
|
case Action::redo:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -723,6 +887,9 @@ void TuiApp::dispatch(const Action action) {
|
|||||||
case Action::search:
|
case Action::search:
|
||||||
begin_search();
|
begin_search();
|
||||||
return;
|
return;
|
||||||
|
case Action::agenda:
|
||||||
|
begin_agenda();
|
||||||
|
return;
|
||||||
case Action::calendars:
|
case Action::calendars:
|
||||||
begin_calendar_picker();
|
begin_calendar_picker();
|
||||||
return;
|
return;
|
||||||
@@ -816,6 +983,9 @@ void TuiApp::dispatch(const Action action) {
|
|||||||
case Action::search:
|
case Action::search:
|
||||||
begin_search();
|
begin_search();
|
||||||
return;
|
return;
|
||||||
|
case Action::agenda:
|
||||||
|
begin_agenda();
|
||||||
|
return;
|
||||||
case Action::calendars:
|
case Action::calendars:
|
||||||
begin_calendar_picker();
|
begin_calendar_picker();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ void test_key_decoding()
|
|||||||
check(decode_key("d") == Action::delete_event, "d decodes as delete appointment");
|
check(decode_key("d") == Action::delete_event, "d decodes as delete appointment");
|
||||||
check(decode_key("u") == Action::undo, "u decodes as undo");
|
check(decode_key("u") == Action::undo, "u decodes as undo");
|
||||||
check(decode_key("\x12") == Action::redo, "Ctrl-R decodes as redo");
|
check(decode_key("\x12") == Action::redo, "Ctrl-R decodes as redo");
|
||||||
|
check(decode_key("g") == Action::agenda, "g decodes as agenda");
|
||||||
}
|
}
|
||||||
|
|
||||||
void test_crud_preconditions_and_add()
|
void test_crud_preconditions_and_add()
|
||||||
@@ -1039,6 +1040,182 @@ void test_calendar_visibility_filters_without_partial_saves()
|
|||||||
check(save_calls == 1, "all visibility-only interactions remain unsaved session state");
|
check(save_calls == 1, "all visibility-only interactions remain unsaved session state");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void test_agenda_window_order_visibility_and_bounds()
|
||||||
|
{
|
||||||
|
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 after_window = nocal::from_sys_days(nocal::to_sys_days(first_day) + days{42});
|
||||||
|
|
||||||
|
auto series = make_event("agenda-series", "Daily standup", first_day, 9, 0,
|
||||||
|
first_day, 9, 30);
|
||||||
|
series.recurrence = nocal::RecurrenceRule{
|
||||||
|
.frequency = nocal::RecurrenceFrequency::daily,
|
||||||
|
.interval = 1,
|
||||||
|
.count = 100,
|
||||||
|
.until = std::nullopt,
|
||||||
|
.by_weekday = {},
|
||||||
|
.by_month_day = {},
|
||||||
|
.by_month = {},
|
||||||
|
};
|
||||||
|
auto hidden = make_event("hidden-agenda", "Hidden work", first_day, 7, 0,
|
||||||
|
first_day, 8, 0);
|
||||||
|
hidden.calendar_id = "work";
|
||||||
|
std::vector<nocal::Event> events{
|
||||||
|
make_event("timed", "Morning review", first_day, 8, 0, first_day, 8, 30),
|
||||||
|
make_event("all-day", "Release day", first_day, 0, 0, second_day, 0, 0, true),
|
||||||
|
series,
|
||||||
|
hidden,
|
||||||
|
make_event("outside", "Outside window", after_window, 6, 0,
|
||||||
|
after_window, 7, 0),
|
||||||
|
};
|
||||||
|
int save_calls = 0;
|
||||||
|
nocal::tui::TuiApp app{
|
||||||
|
events, [&](std::span<const nocal::Event>) { ++save_calls; }, -1, -1};
|
||||||
|
|
||||||
|
app.dispatch(nocal::tui::Action::calendars);
|
||||||
|
app.dispatch(nocal::tui::Action::down);
|
||||||
|
app.dispatch(nocal::tui::Action::select);
|
||||||
|
app.dispatch(nocal::tui::Action::back);
|
||||||
|
app.handle_input("g");
|
||||||
|
|
||||||
|
check(app.agenda_active() && app.state().agenda_start_day == first_day,
|
||||||
|
"g opens a 42-day agenda anchored at the selected day");
|
||||||
|
check(app.state().agenda_items.size() == 44,
|
||||||
|
"agenda recurrence expansion is bounded to its 42-day window");
|
||||||
|
if (app.state().agenda_items.size() == 44) {
|
||||||
|
check(app.state().agenda_items[0].focus_id == "all-day" &&
|
||||||
|
app.state().agenda_items[1].focus_id == "timed" &&
|
||||||
|
app.state().agenda_items[2].focus_id.starts_with("agenda-series@occ:"),
|
||||||
|
"agenda retains domain occurrence order with all-day items first");
|
||||||
|
check(app.state().agenda_items.back().day ==
|
||||||
|
nocal::from_sys_days(nocal::to_sys_days(first_day) + days{41}),
|
||||||
|
"agenda includes the final day inside the half-open 42-day window");
|
||||||
|
}
|
||||||
|
check(std::none_of(app.state().agenda_items.begin(), app.state().agenda_items.end(),
|
||||||
|
[&](const nocal::tui::AgendaItem& item) {
|
||||||
|
return item.focus_id == "hidden-agenda" ||
|
||||||
|
item.focus_id == "outside" ||
|
||||||
|
nocal::to_sys_days(item.day) >= nocal::to_sys_days(after_window);
|
||||||
|
}),
|
||||||
|
"agenda excludes hidden calendars and occurrences outside its window");
|
||||||
|
check(save_calls == 0, "opening and filtering agenda never saves events");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_agenda_navigation_shift_choice_and_close()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const auto first_day = nocal::today();
|
||||||
|
auto series = make_event("choose-series", "Choose exact occurrence", first_day, 9, 0,
|
||||||
|
first_day, 10, 0);
|
||||||
|
series.recurrence = nocal::RecurrenceRule{
|
||||||
|
.frequency = nocal::RecurrenceFrequency::daily,
|
||||||
|
.interval = 1,
|
||||||
|
.count = 100,
|
||||||
|
.until = std::nullopt,
|
||||||
|
.by_weekday = {},
|
||||||
|
.by_month_day = {},
|
||||||
|
.by_month = {},
|
||||||
|
};
|
||||||
|
std::vector<nocal::Event> events{series};
|
||||||
|
int save_calls = 0;
|
||||||
|
nocal::tui::TuiApp app{
|
||||||
|
events, [&](std::span<const nocal::Event>) { ++save_calls; }, -1, -1};
|
||||||
|
const auto month_day = app.state().selected_day;
|
||||||
|
const auto month = app.state().visible_month;
|
||||||
|
|
||||||
|
app.dispatch(nocal::tui::Action::agenda);
|
||||||
|
app.handle_input("k");
|
||||||
|
check(app.state().agenda_index == 0,
|
||||||
|
"agenda navigation clamps at the first item instead of wrapping");
|
||||||
|
app.handle_input("j");
|
||||||
|
check(app.state().agenda_index == 1, "j advances agenda selection");
|
||||||
|
for (std::size_t index = 0; index < app.state().agenda_items.size() + 2; ++index) {
|
||||||
|
app.dispatch(nocal::tui::Action::down);
|
||||||
|
}
|
||||||
|
check(app.state().agenda_index + 1 == app.state().agenda_items.size(),
|
||||||
|
"agenda navigation clamps at the final item instead of wrapping");
|
||||||
|
|
||||||
|
app.handle_input("n");
|
||||||
|
check(app.state().agenda_start_day ==
|
||||||
|
nocal::from_sys_days(nocal::to_sys_days(first_day) + days{42}) &&
|
||||||
|
app.state().agenda_index == 0 && !app.state().agenda_items.empty() &&
|
||||||
|
app.state().agenda_items.front().day == app.state().agenda_start_day,
|
||||||
|
"n shifts exactly 42 days and refreshes agenda from its new start");
|
||||||
|
app.handle_input("\x1b[5~");
|
||||||
|
check(app.state().agenda_start_day == first_day && app.state().agenda_index == 0,
|
||||||
|
"Page Up shifts agenda exactly 42 days back and resets selection safely");
|
||||||
|
|
||||||
|
app.dispatch(nocal::tui::Action::down);
|
||||||
|
const auto chosen = app.state().agenda_items[app.state().agenda_index];
|
||||||
|
app.dispatch(nocal::tui::Action::select);
|
||||||
|
check(!app.agenda_active() && app.state().selected_day == chosen.day &&
|
||||||
|
app.state().visible_month == chosen.day.year() / chosen.day.month() &&
|
||||||
|
app.state().focused_event_id == std::optional{chosen.focus_id},
|
||||||
|
"Enter returns to month and focuses the exact recurring occurrence");
|
||||||
|
|
||||||
|
const auto chosen_day = app.state().selected_day;
|
||||||
|
const auto chosen_month = app.state().visible_month;
|
||||||
|
app.dispatch(nocal::tui::Action::agenda);
|
||||||
|
app.dispatch(nocal::tui::Action::next_month);
|
||||||
|
app.dispatch(nocal::tui::Action::agenda);
|
||||||
|
check(!app.agenda_active() && app.state().selected_day == chosen_day &&
|
||||||
|
app.state().visible_month == chosen_month,
|
||||||
|
"g closes agenda without changing the prior month selection");
|
||||||
|
app.dispatch(nocal::tui::Action::agenda);
|
||||||
|
app.dispatch(nocal::tui::Action::previous_month);
|
||||||
|
app.dispatch(nocal::tui::Action::back);
|
||||||
|
check(!app.agenda_active() && app.state().selected_day == chosen_day &&
|
||||||
|
app.state().visible_month == chosen_month,
|
||||||
|
"Escape closes agenda without applying its shifted window to month selection");
|
||||||
|
check(month_day == first_day && month == first_day.year() / first_day.month(),
|
||||||
|
"agenda navigation fixture starts on the controller's month selection");
|
||||||
|
check(save_calls == 0, "all agenda navigation and selection remains read-only");
|
||||||
|
|
||||||
|
std::vector<nocal::Event> empty_events;
|
||||||
|
nocal::tui::TuiApp empty{empty_events, -1, -1};
|
||||||
|
empty.dispatch(nocal::tui::Action::agenda);
|
||||||
|
empty.dispatch(nocal::tui::Action::up);
|
||||||
|
empty.dispatch(nocal::tui::Action::down);
|
||||||
|
empty.dispatch(nocal::tui::Action::select);
|
||||||
|
check(empty.agenda_active() && empty.state().agenda_items.empty() &&
|
||||||
|
empty.state().agenda_index == 0,
|
||||||
|
"empty agenda navigation and Enter are safe no-ops");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_agenda_search_and_calendar_overlays()
|
||||||
|
{
|
||||||
|
const auto day = nocal::today();
|
||||||
|
auto personal = make_event("agenda-personal", "Personal", day, 8, 0, day, 9, 0);
|
||||||
|
personal.calendar_id = "personal";
|
||||||
|
std::vector<nocal::Event> events{
|
||||||
|
make_event("agenda-default", "Default", day, 7, 0, day, 8, 0), personal};
|
||||||
|
int save_calls = 0;
|
||||||
|
nocal::tui::TuiApp app{
|
||||||
|
events, [&](std::span<const nocal::Event>) { ++save_calls; }, -1, -1};
|
||||||
|
|
||||||
|
app.dispatch(nocal::tui::Action::agenda);
|
||||||
|
app.handle_input("/");
|
||||||
|
check(app.agenda_active() && app.search_active(),
|
||||||
|
"search prompt can overlay an active agenda");
|
||||||
|
app.handle_input("\x1b");
|
||||||
|
check(app.agenda_active() && !app.search_active(),
|
||||||
|
"Escape closes search and returns to the underlying agenda");
|
||||||
|
|
||||||
|
app.handle_input("c");
|
||||||
|
check(app.agenda_active() && app.calendar_picker_active(),
|
||||||
|
"calendar picker can overlay an active agenda");
|
||||||
|
app.dispatch(nocal::tui::Action::down);
|
||||||
|
app.dispatch(nocal::tui::Action::select);
|
||||||
|
check(!app.calendars()[1].visible && app.state().agenda_items.size() == 1 &&
|
||||||
|
app.state().agenda_items.front().focus_id == "agenda-default",
|
||||||
|
"hiding a calendar immediately refreshes the agenda beneath the picker");
|
||||||
|
app.dispatch(nocal::tui::Action::back);
|
||||||
|
check(app.agenda_active() && !app.calendar_picker_active(),
|
||||||
|
"closing the picker returns to the agenda");
|
||||||
|
check(save_calls == 0, "agenda overlays and visibility changes never save events");
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main()
|
int main()
|
||||||
@@ -1067,6 +1244,9 @@ int main()
|
|||||||
test_recurrence_grid_expansion_and_source_zone_display();
|
test_recurrence_grid_expansion_and_source_zone_display();
|
||||||
test_search_prompt_results_and_occurrence_jump();
|
test_search_prompt_results_and_occurrence_jump();
|
||||||
test_calendar_visibility_filters_without_partial_saves();
|
test_calendar_visibility_filters_without_partial_saves();
|
||||||
|
test_agenda_window_order_visibility_and_bounds();
|
||||||
|
test_agenda_navigation_shift_choice_and_close();
|
||||||
|
test_agenda_search_and_calendar_overlays();
|
||||||
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;
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ void test_full_month_render()
|
|||||||
.search_result_index = 0,
|
.search_result_index = 0,
|
||||||
.notification = {},
|
.notification = {},
|
||||||
.notification_is_error = false,
|
.notification_is_error = false,
|
||||||
|
.show_calendar_picker = false,
|
||||||
|
.calendar_index = 0,
|
||||||
|
.show_agenda = false,
|
||||||
|
.agenda_start_day = year{1970} / January / day{1},
|
||||||
|
.agenda_items = {},
|
||||||
|
.agenda_index = 0,
|
||||||
};
|
};
|
||||||
const std::vector<nocal::tui::CalendarItem> items{
|
const std::vector<nocal::tui::CalendarItem> items{
|
||||||
{.id = "1", .title = "Design review", .day = year{2026} / July / day{17},
|
{.id = "1", .title = "Design review", .day = year{2026} / July / day{17},
|
||||||
@@ -65,6 +71,8 @@ void test_full_month_render()
|
|||||||
check(frame.find("Design") != std::string::npos, "appointment appears in its day cell");
|
check(frame.find("Design") != std::string::npos, "appointment appears in its day cell");
|
||||||
check(frame.find("Release") != std::string::npos, "all-day appointment appears in its day cell");
|
check(frame.find("Release") != std::string::npos, "all-day appointment appears in its day cell");
|
||||||
check(frame.find("July 2026") != std::string::npos, "month title is rendered");
|
check(frame.find("July 2026") != std::string::npos, "month title is rendered");
|
||||||
|
check(frame.find("g agenda") != std::string::npos,
|
||||||
|
"month footer advertises the agenda view");
|
||||||
check(std::count(frame.begin(), frame.end(), '\n') == 29,
|
check(std::count(frame.begin(), frame.end(), '\n') == 29,
|
||||||
"renderer fills the requested height deterministically");
|
"renderer fills the requested height deterministically");
|
||||||
check(frame.find("\x1b[") == std::string::npos, "ANSI can be disabled");
|
check(frame.find("\x1b[") == std::string::npos, "ANSI can be disabled");
|
||||||
@@ -91,6 +99,12 @@ void test_compact_and_input()
|
|||||||
.search_result_index = 0,
|
.search_result_index = 0,
|
||||||
.notification = {},
|
.notification = {},
|
||||||
.notification_is_error = false,
|
.notification_is_error = false,
|
||||||
|
.show_calendar_picker = false,
|
||||||
|
.calendar_index = 0,
|
||||||
|
.show_agenda = false,
|
||||||
|
.agenda_start_day = year{1970} / January / day{1},
|
||||||
|
.agenda_items = {},
|
||||||
|
.agenda_index = 0,
|
||||||
};
|
};
|
||||||
const auto frame = nocal::tui::render_month(std::span<const nocal::tui::CalendarItem>{}, state);
|
const auto frame = nocal::tui::render_month(std::span<const nocal::tui::CalendarItem>{}, state);
|
||||||
check(frame.find("July 2026") != std::string::npos, "compact mode keeps month context");
|
check(frame.find("July 2026") != std::string::npos, "compact mode keeps month context");
|
||||||
@@ -190,6 +204,89 @@ void test_search_frames()
|
|||||||
"slash opens appointment search");
|
"slash opens appointment search");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void test_agenda_empty_frame()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
auto state = nocal::tui::ScreenState{};
|
||||||
|
state.width = 64;
|
||||||
|
state.height = 9;
|
||||||
|
state.ansi = false;
|
||||||
|
state.show_agenda = true;
|
||||||
|
state.agenda_start_day = year{2026} / July / day{6};
|
||||||
|
|
||||||
|
const auto frame = nocal::tui::render_month(
|
||||||
|
std::span<const nocal::tui::CalendarItem>{}, state);
|
||||||
|
check(frame.find("AGENDA") != std::string::npos &&
|
||||||
|
frame.find("2026-07-06 – 2026-08-16 (inclusive)") != std::string::npos,
|
||||||
|
"empty agenda renders its inclusive 42-day range");
|
||||||
|
check(frame.find("No appointments in this 6-week window.") != std::string::npos,
|
||||||
|
"empty agenda explains that its window has no appointments");
|
||||||
|
check(frame.find("g/Esc close") != std::string::npos,
|
||||||
|
"agenda footer explains how to close the view");
|
||||||
|
check(std::count(frame.begin(), frame.end(), '\n') == 8,
|
||||||
|
"empty agenda fills the requested height");
|
||||||
|
check(frame.find("\x1b[") == std::string::npos,
|
||||||
|
"agenda remains readable with ANSI disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_agenda_selection_and_scrolling()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
auto state = nocal::tui::ScreenState{};
|
||||||
|
state.width = 88;
|
||||||
|
state.height = 7;
|
||||||
|
state.ansi = true;
|
||||||
|
state.show_agenda = true;
|
||||||
|
state.agenda_start_day = year{2026} / July / day{6};
|
||||||
|
state.agenda_items = {
|
||||||
|
{.focus_id = "late", .title = "Release", .day = year{2026} / July / day{12},
|
||||||
|
.time_of_day = hours{14} + minutes{30}, .all_day = false,
|
||||||
|
.location = "Studio", .calendar_id = "work", .recurring = true},
|
||||||
|
{.focus_id = "early", .title = "Holiday", .day = year{2026} / July / day{7},
|
||||||
|
.time_of_day = std::nullopt, .all_day = true,
|
||||||
|
.location = {}, .calendar_id = "home", .recurring = false},
|
||||||
|
{.focus_id = "middle", .title = "Planning", .day = year{2026} / July / day{9},
|
||||||
|
.time_of_day = hours{9}, .all_day = false,
|
||||||
|
.location = {}, .calendar_id = "work", .recurring = false},
|
||||||
|
{.focus_id = "selected", .title = "Retrospective", .day = year{2026} / July / day{13},
|
||||||
|
.time_of_day = hours{16}, .all_day = false,
|
||||||
|
.location = "Room 2", .calendar_id = "work", .recurring = false},
|
||||||
|
{.focus_id = "last", .title = "Dinner", .day = year{2026} / July / day{14},
|
||||||
|
.time_of_day = hours{19}, .all_day = false,
|
||||||
|
.location = {}, .calendar_id = "home", .recurring = false},
|
||||||
|
};
|
||||||
|
state.agenda_index = 3;
|
||||||
|
|
||||||
|
const auto frame = nocal::tui::render_month(
|
||||||
|
std::span<const nocal::tui::CalendarItem>{}, state);
|
||||||
|
check(frame.find("Holiday") == std::string::npos &&
|
||||||
|
frame.find("2026-07-12 14:30 local ↻ Release — Studio") != std::string::npos,
|
||||||
|
"agenda scrolls chronologically to keep a late selection visible");
|
||||||
|
check(frame.find("› 2026-07-13 16:00 local Retrospective — Room 2") != std::string::npos,
|
||||||
|
"agenda marks the selected row and includes optional location");
|
||||||
|
check(frame.find("\x1b[7;1m") != std::string::npos,
|
||||||
|
"ANSI agenda gives the selected row visible focus");
|
||||||
|
check(std::count(frame.begin(), frame.end(), '\n') == 6,
|
||||||
|
"scrolling agenda fills the requested height");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_agenda_input_and_search_precedence()
|
||||||
|
{
|
||||||
|
auto state = nocal::tui::ScreenState{};
|
||||||
|
state.width = 50;
|
||||||
|
state.height = 6;
|
||||||
|
state.ansi = false;
|
||||||
|
state.show_agenda = true;
|
||||||
|
state.search_prompt = true;
|
||||||
|
const auto frame = nocal::tui::render_month(
|
||||||
|
std::span<const nocal::tui::CalendarItem>{}, state);
|
||||||
|
check(frame.find("SEARCH APPOINTMENTS") != std::string::npos &&
|
||||||
|
frame.find("AGENDA") == std::string::npos,
|
||||||
|
"search rendering takes precedence over agenda rendering");
|
||||||
|
check(nocal::tui::decode_key("g") == nocal::tui::Action::agenda,
|
||||||
|
"g toggles the agenda view");
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main()
|
int main()
|
||||||
@@ -199,6 +296,9 @@ int main()
|
|||||||
test_navigation();
|
test_navigation();
|
||||||
test_search_frames();
|
test_search_frames();
|
||||||
test_calendar_picker_frame();
|
test_calendar_picker_frame();
|
||||||
|
test_agenda_empty_frame();
|
||||||
|
test_agenda_selection_and_scrolling();
|
||||||
|
test_agenda_input_and_search_precedence();
|
||||||
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