feat: add calendar visibility picker
Derive session calendars from event metadata and apply one visibility contract to month rendering, appointment focus, and search without filtering persistence writes. Add keyboard picker coverage, full-model save regression tests, documentation, warning-clean builds, sanitizer verification, and live PTY terminal restoration checks.
This commit is contained in:
@@ -15,7 +15,9 @@ 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`
|
||||
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
|
||||
`Esc` returns to the month. Press `a` to add an appointment. Focus an
|
||||
`Esc` returns to the month. Press `c` to open the calendar picker; arrows or
|
||||
`j`/`k` select a calendar and `Enter` toggles its in-session visibility. Press
|
||||
`a` to add an appointment. Focus an
|
||||
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,
|
||||
`Ctrl-S` to save, and `Esc` to cancel. Back in the calendar, `u` undoes the
|
||||
@@ -46,7 +48,9 @@ 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.
|
||||
result list. Hidden calendars are excluded consistently from the month grid,
|
||||
appointment focus, and search. Visibility is session-only presentation state;
|
||||
saves always retain events from hidden calendars.
|
||||
|
||||
Calendars containing features this version cannot preserve—such as `RDATE`,
|
||||
detached recurrence overrides, ordinal `BYDAY`, embedded `VTIMEZONE`
|
||||
|
||||
@@ -40,6 +40,13 @@ 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.
|
||||
|
||||
Calendar visibility is session presentation state. The controller derives its
|
||||
calendar list from event calendar identifiers and applies the same domain
|
||||
predicate to month rendering, focus traversal, and search. Hidden events remain
|
||||
in the canonical collection passed to persistence, and focus identities are
|
||||
always derived from that complete collection so filtering cannot renumber
|
||||
events with duplicate or missing UIDs.
|
||||
|
||||
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
|
||||
wall time across daylight-saving transitions. The month grid displays instants
|
||||
|
||||
@@ -72,6 +72,7 @@ The full terminal is treated as a responsive canvas:
|
||||
| Return to month/unfocus | `Esc` |
|
||||
| Search appointments | `/`, then type and press `Enter` |
|
||||
| Choose search result | arrows, `j` / `k`, then `Enter` |
|
||||
| Toggle calendar visibility | `c`, arrows or `j` / `k`, then `Enter` |
|
||||
| Help | `?` |
|
||||
| Quit | `q` |
|
||||
|
||||
@@ -85,8 +86,10 @@ recurring instances appear throughout the month, preserve civil time across
|
||||
daylight-saving changes, and expose their source zone and whole-series mutation
|
||||
semantics in the reader. `/` searches occurrence-aware title, description,
|
||||
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.
|
||||
selected date. `c` toggles session-only calendar visibility consistently across
|
||||
the grid, appointment focus, and search without filtering the model supplied to
|
||||
persistence. The next local-data work adds an agenda view, then advanced
|
||||
recurrence overrides and rule editing.
|
||||
|
||||
## Accessibility
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@
|
||||
- Bounded session undo/redo, exact external-change detection, and backup recovery
|
||||
- Windowed recurrence expansion, EXDATE, IANA TZID, and multi-day segment cues
|
||||
- Occurrence-aware appointment search with exact result-to-grid navigation
|
||||
- Session calendar toggles shared by month rendering, focus, and search
|
||||
- Meson build, Nix development shell, desktop entry, and Hyprland launcher
|
||||
- Seeded sample data when explicitly requested, never silent data mutation
|
||||
|
||||
## 0.1 — complete local calendar
|
||||
|
||||
- Advanced recurrence editing, RDATE/overrides, and embedded VTIMEZONE support
|
||||
- Agenda view, calendar toggles, import/export, configurable week start
|
||||
- Agenda view, import/export, configurable week start
|
||||
- Screen-reader-friendly linear view and full Unicode display-width handling
|
||||
|
||||
## 0.2 — durable cache and provider boundary
|
||||
|
||||
@@ -44,6 +44,14 @@ void sort_events(EventRefs& events);
|
||||
[[nodiscard]] bool event_matches_query(const Event& event,
|
||||
std::string_view query);
|
||||
|
||||
// Calendar visibility is an application/query concern. An event whose
|
||||
// calendar is not yet represented remains visible so newly loaded or created
|
||||
// data cannot disappear merely because metadata has not been synchronized.
|
||||
[[nodiscard]] bool calendar_is_visible(std::span<const Calendar> calendars,
|
||||
std::string_view calendar_id) noexcept;
|
||||
[[nodiscard]] bool event_is_visible(const Event& event,
|
||||
std::span<const Calendar> calendars) noexcept;
|
||||
|
||||
// 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.
|
||||
[[nodiscard]] bool event_overlaps(const Event& event, TimePoint begin,
|
||||
|
||||
@@ -71,6 +71,8 @@ struct ScreenState {
|
||||
std::size_t search_result_index{0};
|
||||
std::string notification;
|
||||
bool notification_is_error{false};
|
||||
bool show_calendar_picker{false};
|
||||
std::size_t calendar_index{0};
|
||||
};
|
||||
|
||||
// Render a complete frame. The result contains no cursor positioning, which
|
||||
@@ -83,6 +85,12 @@ struct ScreenState {
|
||||
[[nodiscard]] std::string render_month(std::span<const Event> events,
|
||||
const ScreenState& state);
|
||||
|
||||
// Calendar visibility filters presentation only. Focus identities are still
|
||||
// derived from the complete event collection.
|
||||
[[nodiscard]] std::string render_month(std::span<const Event> events,
|
||||
std::span<const Calendar> calendars,
|
||||
const ScreenState& state);
|
||||
|
||||
// Stable focus identity shared by the renderer and controller. Non-recurring
|
||||
// events retain their historical UID/synthetic identity; recurring instances
|
||||
// additionally include their concrete start instant.
|
||||
@@ -109,6 +117,7 @@ enum class Action {
|
||||
undo,
|
||||
redo,
|
||||
search,
|
||||
calendars,
|
||||
quit,
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ public:
|
||||
[[nodiscard]] bool search_active() const noexcept {
|
||||
return state_.search_prompt || state_.show_search_results;
|
||||
}
|
||||
[[nodiscard]] bool calendar_picker_active() const noexcept {
|
||||
return state_.show_calendar_picker;
|
||||
}
|
||||
[[nodiscard]] const std::vector<Calendar>& calendars() const noexcept {
|
||||
return calendars_;
|
||||
}
|
||||
|
||||
private:
|
||||
struct HistorySnapshot {
|
||||
@@ -64,6 +70,7 @@ private:
|
||||
static constexpr std::size_t history_limit = 100;
|
||||
|
||||
std::vector<Event>& events_;
|
||||
std::vector<Calendar> calendars_;
|
||||
int input_fd_;
|
||||
int output_fd_;
|
||||
ScreenState state_;
|
||||
@@ -77,6 +84,11 @@ private:
|
||||
|
||||
[[nodiscard]] std::optional<EventOccurrence> focused_occurrence() const;
|
||||
[[nodiscard]] std::optional<std::size_t> focused_event_index() const;
|
||||
[[nodiscard]] EventOccurrences visible_occurrences_on_selected_day() const;
|
||||
void synchronize_calendars();
|
||||
void begin_calendar_picker();
|
||||
void close_calendar_picker();
|
||||
void toggle_selected_calendar();
|
||||
[[nodiscard]] HistorySnapshot capture_history_snapshot() const;
|
||||
void restore_history_snapshot(HistorySnapshot snapshot);
|
||||
void push_history(std::vector<HistoryEntry>& history, HistoryEntry entry);
|
||||
|
||||
@@ -663,6 +663,21 @@ bool event_matches_query(const Event& event, std::string_view query)
|
||||
contains_ascii_case_insensitive(event.calendar_id, query);
|
||||
}
|
||||
|
||||
bool calendar_is_visible(const std::span<const Calendar> calendars,
|
||||
const std::string_view calendar_id) noexcept
|
||||
{
|
||||
const auto calendar = std::find_if(
|
||||
calendars.begin(), calendars.end(),
|
||||
[calendar_id](const Calendar& value) { return value.id == calendar_id; });
|
||||
return calendar == calendars.end() || calendar->visible;
|
||||
}
|
||||
|
||||
bool event_is_visible(const Event& event,
|
||||
const std::span<const Calendar> calendars) noexcept
|
||||
{
|
||||
return calendar_is_visible(calendars, event.calendar_id);
|
||||
}
|
||||
|
||||
bool event_overlaps(const Event& event, TimePoint begin, TimePoint end) noexcept
|
||||
{
|
||||
return overlaps(event.start, event.end, begin, end);
|
||||
|
||||
@@ -57,6 +57,7 @@ void print_help(std::ostream& output)
|
||||
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
|
||||
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n"
|
||||
" u undoes the last change, Ctrl-R redoes it,\n"
|
||||
" c toggles calendar visibility, / searches appointments,\n"
|
||||
" t jumps to today, ? toggles help, q quits.\n";
|
||||
}
|
||||
|
||||
|
||||
@@ -575,6 +575,59 @@ std::string search_frame(const ScreenState& state) {
|
||||
return output.str();
|
||||
}
|
||||
|
||||
std::string calendar_picker_frame(const std::span<const Calendar> calendars,
|
||||
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("CALENDARS", width), "1", state.ansi));
|
||||
|
||||
const auto visible = std::count_if(calendars.begin(), calendars.end(),
|
||||
[](const Calendar& value) { return value.visible; });
|
||||
if (height > 1) {
|
||||
const auto summary = std::to_string(visible) + " of " +
|
||||
std::to_string(calendars.size()) + " visible";
|
||||
lines.push_back(styled(fit_text(summary, width), "2", state.ansi));
|
||||
}
|
||||
|
||||
const auto row_capacity = static_cast<std::size_t>(std::max(0, height - 3));
|
||||
std::size_t first = 0;
|
||||
if (row_capacity > 0 && state.calendar_index >= row_capacity) {
|
||||
first = state.calendar_index - row_capacity + 1;
|
||||
}
|
||||
const auto last = std::min(calendars.size(), first + row_capacity);
|
||||
for (std::size_t index = first; index < last; ++index) {
|
||||
const auto& calendar = calendars[index];
|
||||
const auto name = calendar.name.empty()
|
||||
? (calendar.id.empty() ? std::string{"Default"}
|
||||
: printable_text(calendar.id))
|
||||
: printable_text(calendar.name);
|
||||
const auto label = std::string{index == state.calendar_index ? "› " : " "} +
|
||||
(calendar.visible ? "[x] " : "[ ] ") + name;
|
||||
lines.push_back(styled(fit_text(label, width),
|
||||
index == state.calendar_index ? "7;1" : "", state.ansi));
|
||||
}
|
||||
while (static_cast<int>(lines.size()) < height - 1) {
|
||||
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
||||
}
|
||||
if (height > 1) {
|
||||
lines.push_back(styled(fit_text(
|
||||
"↑/↓ choose Enter toggle c/Esc close", width), "2", state.ansi));
|
||||
}
|
||||
while (static_cast<int>(lines.size()) < height) {
|
||||
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
||||
}
|
||||
if (static_cast<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height));
|
||||
|
||||
std::ostringstream output;
|
||||
for (int line = 0; line < height; ++line) {
|
||||
if (line != 0) output << '\n';
|
||||
output << lines[static_cast<std::size_t>(line)];
|
||||
}
|
||||
return output.str();
|
||||
}
|
||||
|
||||
std::string compact_frame(std::span<const CalendarItem> items, const ScreenState& state) {
|
||||
const int width = std::max(1, state.width);
|
||||
const int height = std::max(1, state.height);
|
||||
@@ -632,7 +685,7 @@ std::string compact_frame(std::span<const CalendarItem> items, const ScreenState
|
||||
if (!state.notification.empty()) {
|
||||
controls = printable_text(state.notification, true);
|
||||
} else if (focused == nullptr) {
|
||||
controls = "/ search a add u undo Ctrl-R redo ? help n/p month t today q quit";
|
||||
controls = "c calendars / search a add u undo Ctrl-R redo ? help n/p month t today q quit";
|
||||
} else {
|
||||
controls = "Tab appointment Enter open / search e edit d delete u undo Ctrl-R redo";
|
||||
}
|
||||
@@ -684,6 +737,7 @@ Action decode_key(const std::string_view sequence) noexcept {
|
||||
if (sequence == "u") return Action::undo;
|
||||
if (sequence == "\x12") return Action::redo;
|
||||
if (sequence == "/") return Action::search;
|
||||
if (sequence == "c") return Action::calendars;
|
||||
if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit;
|
||||
return Action::none;
|
||||
}
|
||||
@@ -808,7 +862,7 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
|
||||
? by_day.at(sys_days{state.selected_day}).size() : 0U;
|
||||
std::string status;
|
||||
if (state.show_help) {
|
||||
status = " arrows/hjkl day Tab/⇧Tab appointment Enter read / 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 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()) {
|
||||
status = " " + printable_text(state.notification, true);
|
||||
} else if (focused != nullptr) {
|
||||
@@ -820,7 +874,7 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
|
||||
status = " " + iso_date(state.selected_day) + " " + std::to_string(selected_count) +
|
||||
(selected_count == 1 ? " appointment" : " appointments") +
|
||||
(selected_count > 0 ? " Tab focus" : "") +
|
||||
" / search a add u undo Ctrl-R redo ? help q quit";
|
||||
" c calendars / search a add u undo Ctrl-R redo ? help q quit";
|
||||
}
|
||||
const auto status_style = !state.notification.empty()
|
||||
? (state.notification_is_error ? "1;31" : "1;32")
|
||||
@@ -830,6 +884,13 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
|
||||
}
|
||||
|
||||
std::string render_month(const std::span<const Event> events, const ScreenState& state) {
|
||||
return render_month(events, std::span<const Calendar>{}, state);
|
||||
}
|
||||
|
||||
std::string render_month(const std::span<const Event> events,
|
||||
const std::span<const Calendar> calendars,
|
||||
const ScreenState& state) {
|
||||
if (state.show_calendar_picker) return calendar_picker_frame(calendars, state);
|
||||
std::vector<CalendarItem> items;
|
||||
items.reserve(events.size() * 2);
|
||||
const auto visible_start = monday_on_or_before(sys_days{state.visible_month / day{1}});
|
||||
@@ -838,7 +899,8 @@ std::string render_month(const std::span<const Event> events, const ScreenState&
|
||||
events, local_day_start(year_month_day{visible_start}),
|
||||
local_day_start(year_month_day{visible_end + days{1}}));
|
||||
for (const auto& occurrence : occurrences) {
|
||||
if (occurrence.source == nullptr) continue;
|
||||
if (occurrence.source == nullptr ||
|
||||
!event_is_visible(*occurrence.source, calendars)) continue;
|
||||
const auto& event = *occurrence.source;
|
||||
const auto identity = occurrence_focus_id(events, occurrence);
|
||||
const auto local_start = to_local(occurrence.start);
|
||||
|
||||
@@ -88,12 +88,13 @@ TuiApp::TuiApp(std::vector<Event>& events, SaveCallback saver, const int input_f
|
||||
state_.today = current_day();
|
||||
state_.selected_day = state_.today;
|
||||
state_.visible_month = state_.today.year() / state_.today.month();
|
||||
synchronize_calendars();
|
||||
}
|
||||
|
||||
std::optional<EventOccurrence> TuiApp::focused_occurrence() const {
|
||||
if (!state_.focused_event_id) return std::nullopt;
|
||||
const auto event_span = std::span<const Event>{events_};
|
||||
for (const auto& occurrence : occurrences_on_day(event_span, state_.selected_day)) {
|
||||
for (const auto& occurrence : visible_occurrences_on_selected_day()) {
|
||||
if (occurrence_focus_id(event_span, occurrence) == *state_.focused_event_id) {
|
||||
return occurrence;
|
||||
}
|
||||
@@ -107,6 +108,69 @@ std::optional<std::size_t> TuiApp::focused_event_index() const {
|
||||
: std::nullopt;
|
||||
}
|
||||
|
||||
EventOccurrences TuiApp::visible_occurrences_on_selected_day() const {
|
||||
auto occurrences = occurrences_on_day(std::span<const Event>{events_}, state_.selected_day);
|
||||
std::erase_if(occurrences, [this](const EventOccurrence& occurrence) {
|
||||
return occurrence.source == nullptr ||
|
||||
!event_is_visible(*occurrence.source,
|
||||
std::span<const Calendar>{calendars_});
|
||||
});
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
void TuiApp::synchronize_calendars() {
|
||||
std::vector<std::string> ids{""};
|
||||
ids.reserve(events_.size() + 1);
|
||||
for (const auto& event : events_) {
|
||||
if (!event.calendar_id.empty()) ids.push_back(event.calendar_id);
|
||||
}
|
||||
std::sort(ids.begin() + 1, ids.end());
|
||||
ids.erase(std::unique(ids.begin(), ids.end()), ids.end());
|
||||
|
||||
std::vector<Calendar> synchronized;
|
||||
synchronized.reserve(ids.size());
|
||||
for (const auto& id : ids) {
|
||||
const auto existing = std::find_if(
|
||||
calendars_.begin(), calendars_.end(),
|
||||
[&id](const Calendar& calendar) { return calendar.id == id; });
|
||||
if (existing != calendars_.end()) {
|
||||
synchronized.push_back(*existing);
|
||||
} else {
|
||||
synchronized.push_back(Calendar{
|
||||
.id = id,
|
||||
.name = id.empty() ? "Default" : id,
|
||||
.color = {},
|
||||
.visible = true,
|
||||
.read_only = false,
|
||||
});
|
||||
}
|
||||
}
|
||||
calendars_ = std::move(synchronized);
|
||||
if (state_.calendar_index >= calendars_.size()) state_.calendar_index = 0;
|
||||
}
|
||||
|
||||
void TuiApp::begin_calendar_picker() {
|
||||
synchronize_calendars();
|
||||
state_.show_calendar_picker = true;
|
||||
state_.show_event_details = false;
|
||||
state_.show_help = false;
|
||||
state_.confirm_delete = false;
|
||||
}
|
||||
|
||||
void TuiApp::close_calendar_picker() {
|
||||
state_.show_calendar_picker = false;
|
||||
}
|
||||
|
||||
void TuiApp::toggle_selected_calendar() {
|
||||
if (calendars_.empty() || state_.calendar_index >= calendars_.size()) return;
|
||||
auto& calendar = calendars_[state_.calendar_index];
|
||||
calendar.visible = !calendar.visible;
|
||||
if (!focused_occurrence()) {
|
||||
state_.focused_event_id.reset();
|
||||
state_.show_event_details = false;
|
||||
}
|
||||
}
|
||||
|
||||
void TuiApp::set_notification(std::string message, const bool error) {
|
||||
state_.notification = std::move(message);
|
||||
state_.notification_is_error = error;
|
||||
@@ -133,6 +197,8 @@ void TuiApp::restore_history_snapshot(HistorySnapshot snapshot) {
|
||||
state_.confirm_delete = false;
|
||||
state_.search_prompt = false;
|
||||
state_.show_search_results = false;
|
||||
state_.show_calendar_picker = false;
|
||||
synchronize_calendars();
|
||||
}
|
||||
|
||||
void TuiApp::push_history(std::vector<HistoryEntry>& history, HistoryEntry entry) {
|
||||
@@ -205,6 +271,7 @@ void TuiApp::begin_add() {
|
||||
state_.show_event_details = false;
|
||||
state_.show_help = false;
|
||||
state_.confirm_delete = false;
|
||||
state_.show_calendar_picker = false;
|
||||
}
|
||||
|
||||
void TuiApp::begin_edit() {
|
||||
@@ -224,6 +291,7 @@ void TuiApp::begin_edit() {
|
||||
state_.show_event_details = false;
|
||||
state_.show_help = false;
|
||||
state_.confirm_delete = false;
|
||||
state_.show_calendar_picker = false;
|
||||
}
|
||||
|
||||
void TuiApp::begin_delete() {
|
||||
@@ -235,6 +303,7 @@ void TuiApp::begin_delete() {
|
||||
state_.confirm_delete = true;
|
||||
state_.show_event_details = false;
|
||||
state_.show_help = false;
|
||||
state_.show_calendar_picker = false;
|
||||
}
|
||||
|
||||
void TuiApp::submit_editor() {
|
||||
@@ -259,6 +328,7 @@ void TuiApp::submit_editor() {
|
||||
|
||||
if (!persist(std::span<const Event>{events_})) {
|
||||
events_.swap(original);
|
||||
synchronize_calendars();
|
||||
state_.focused_event_id = original_focus;
|
||||
editor_.reset();
|
||||
editing_index_.reset();
|
||||
@@ -266,9 +336,20 @@ void TuiApp::submit_editor() {
|
||||
return;
|
||||
}
|
||||
|
||||
synchronize_calendars();
|
||||
editor_.reset();
|
||||
editing_index_.reset();
|
||||
const auto event_span = std::span<const Event>{events_};
|
||||
if (!event_is_visible(events_[changed_index],
|
||||
std::span<const Calendar>{calendars_})) {
|
||||
state_.focused_event_id.reset();
|
||||
state_.show_event_details = false;
|
||||
record_mutation(editing && events_[changed_index].recurrence
|
||||
? "edit recurring series"
|
||||
: editing ? "edit appointment" : "add appointment");
|
||||
set_notification(std::move(success));
|
||||
return;
|
||||
}
|
||||
auto preferred = occurrences_on_day(event_span, state_.selected_day);
|
||||
const auto matching = std::find_if(preferred.begin(), preferred.end(), [&](const auto& value) {
|
||||
const auto index = source_index(event_span, value.source);
|
||||
@@ -313,12 +394,14 @@ void TuiApp::confirm_delete() {
|
||||
events_.erase(events_.begin() + static_cast<std::ptrdiff_t>(*index));
|
||||
if (!persist(std::span<const Event>{events_})) {
|
||||
events_.swap(original);
|
||||
synchronize_calendars();
|
||||
state_.focused_event_id = original_focus;
|
||||
state_.confirm_delete = false;
|
||||
pending_mutation_before_.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
synchronize_calendars();
|
||||
state_.focused_event_id.reset();
|
||||
state_.show_event_details = false;
|
||||
state_.confirm_delete = false;
|
||||
@@ -341,6 +424,7 @@ void TuiApp::begin_search() {
|
||||
state_.show_event_details = false;
|
||||
state_.show_help = false;
|
||||
state_.confirm_delete = false;
|
||||
state_.show_calendar_picker = false;
|
||||
}
|
||||
|
||||
void TuiApp::submit_search() {
|
||||
@@ -356,6 +440,8 @@ void TuiApp::submit_search() {
|
||||
for (const auto& occurrence : occurrences_overlapping(
|
||||
event_span, local_day_start(first_day), local_day_end(last_day))) {
|
||||
if (occurrence.source == nullptr ||
|
||||
!event_is_visible(*occurrence.source,
|
||||
std::span<const Calendar>{calendars_}) ||
|
||||
!event_matches_query(*occurrence.source, state_.search_query)) {
|
||||
continue;
|
||||
}
|
||||
@@ -525,6 +611,47 @@ void TuiApp::dispatch(const Action action) {
|
||||
case Action::delete_event:
|
||||
case Action::undo:
|
||||
case Action::redo:
|
||||
case Action::calendars:
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (state_.show_calendar_picker) {
|
||||
const auto count = calendars_.size();
|
||||
switch (action) {
|
||||
case Action::up:
|
||||
case Action::left:
|
||||
case Action::previous_event:
|
||||
if (count > 0) {
|
||||
state_.calendar_index = state_.calendar_index == 0
|
||||
? count - 1 : state_.calendar_index - 1;
|
||||
}
|
||||
return;
|
||||
case Action::down:
|
||||
case Action::right:
|
||||
case Action::next_event:
|
||||
if (count > 0) state_.calendar_index = (state_.calendar_index + 1) % count;
|
||||
return;
|
||||
case Action::select:
|
||||
toggle_selected_calendar();
|
||||
return;
|
||||
case Action::calendars:
|
||||
case Action::back:
|
||||
close_calendar_picker();
|
||||
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:
|
||||
case Action::search:
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -534,7 +661,7 @@ void TuiApp::dispatch(const Action action) {
|
||||
};
|
||||
const auto cycle_event = [this](const int direction) {
|
||||
const auto event_span = std::span<const Event>{events_};
|
||||
const auto day_events = occurrences_on_day(event_span, state_.selected_day);
|
||||
const auto day_events = visible_occurrences_on_selected_day();
|
||||
if (day_events.empty()) {
|
||||
state_.focused_event_id.reset();
|
||||
return;
|
||||
@@ -596,6 +723,9 @@ void TuiApp::dispatch(const Action action) {
|
||||
case Action::search:
|
||||
begin_search();
|
||||
return;
|
||||
case Action::calendars:
|
||||
begin_calendar_picker();
|
||||
return;
|
||||
case Action::quit:
|
||||
running_ = false;
|
||||
return;
|
||||
@@ -686,6 +816,9 @@ void TuiApp::dispatch(const Action action) {
|
||||
case Action::search:
|
||||
begin_search();
|
||||
return;
|
||||
case Action::calendars:
|
||||
begin_calendar_picker();
|
||||
return;
|
||||
case Action::quit:
|
||||
running_ = false;
|
||||
return;
|
||||
@@ -716,7 +849,8 @@ ExitReason TuiApp::run() {
|
||||
if (redraw) {
|
||||
const auto content = editor_
|
||||
? editor_->render(state_.width, state_.height, state_.ansi)
|
||||
: render_month(std::span<const Event>{events_}, state_);
|
||||
: render_month(std::span<const Event>{events_},
|
||||
std::span<const Calendar>{calendars_}, state_);
|
||||
// Cursor control is independent of color styling: NO_COLOR still
|
||||
// needs in-place redraws rather than appending a new month per key.
|
||||
const auto frame = terminal_output ? "\x1b[H" + content + "\x1b[J" : content;
|
||||
|
||||
@@ -183,6 +183,34 @@ void test_event_search()
|
||||
"event search does not locale-fold non-ASCII bytes");
|
||||
}
|
||||
|
||||
void test_calendar_visibility()
|
||||
{
|
||||
const std::vector<nocal::Calendar> calendars{
|
||||
{.id = "", .name = "Default", .color = {}, .visible = true,
|
||||
.read_only = false},
|
||||
{.id = "personal", .name = "Personal", .color = {}, .visible = true,
|
||||
.read_only = false},
|
||||
{.id = "work", .name = "Work", .color = {}, .visible = false,
|
||||
.read_only = false},
|
||||
};
|
||||
nocal::Event default_event;
|
||||
nocal::Event personal_event;
|
||||
personal_event.calendar_id = "personal";
|
||||
nocal::Event work_event;
|
||||
work_event.calendar_id = "work";
|
||||
nocal::Event new_event;
|
||||
new_event.calendar_id = "new-calendar";
|
||||
|
||||
check(nocal::event_is_visible(default_event, calendars),
|
||||
"an empty calendar ID uses the visible default calendar");
|
||||
check(nocal::event_is_visible(personal_event, calendars),
|
||||
"an explicitly visible calendar exposes its events");
|
||||
check(!nocal::event_is_visible(work_event, calendars),
|
||||
"a hidden calendar filters its events");
|
||||
check(nocal::event_is_visible(new_event, calendars),
|
||||
"unknown calendar metadata defaults visible instead of hiding data");
|
||||
}
|
||||
|
||||
void test_daily_dst_recurrence()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
@@ -407,6 +435,7 @@ int main()
|
||||
test_month_layout();
|
||||
test_event_queries();
|
||||
test_event_search();
|
||||
test_calendar_visibility();
|
||||
test_daily_dst_recurrence();
|
||||
test_floating_dst_and_until();
|
||||
test_weekly_count_and_exdates();
|
||||
|
||||
@@ -956,6 +956,89 @@ void test_search_prompt_results_and_occurrence_jump()
|
||||
check(!app.search_active(), "Esc/back closes empty search results");
|
||||
}
|
||||
|
||||
void test_calendar_visibility_filters_without_partial_saves()
|
||||
{
|
||||
const auto day = nocal::today();
|
||||
auto default_event = make_event("default", "D", day, 8, 0,
|
||||
day, 9, 0);
|
||||
auto personal_event = make_event("personal", "P", day, 9, 0,
|
||||
day, 10, 0);
|
||||
personal_event.calendar_id = "personal";
|
||||
auto work_event = make_event("work", "W", day, 10, 0,
|
||||
day, 11, 0);
|
||||
work_event.calendar_id = "work";
|
||||
std::vector<nocal::Event> events{default_event, personal_event, work_event};
|
||||
int save_calls = 0;
|
||||
std::vector<nocal::Event> saved;
|
||||
nocal::tui::TuiApp app{
|
||||
events,
|
||||
[&](const std::span<const nocal::Event> values) {
|
||||
++save_calls;
|
||||
saved.assign(values.begin(), values.end());
|
||||
},
|
||||
-1,
|
||||
-1,
|
||||
};
|
||||
|
||||
check(app.calendars().size() == 3 && app.calendars()[0].id.empty() &&
|
||||
app.calendars()[0].name == "Default" &&
|
||||
app.calendars()[1].id == "personal" && app.calendars()[2].id == "work",
|
||||
"controller derives a stable default-first calendar list from event metadata");
|
||||
app.dispatch(nocal::tui::Action::calendars);
|
||||
check(app.calendar_picker_active(), "c opens the calendar picker");
|
||||
app.dispatch(nocal::tui::Action::down);
|
||||
app.dispatch(nocal::tui::Action::down);
|
||||
app.dispatch(nocal::tui::Action::select);
|
||||
check(!app.calendars()[2].visible && save_calls == 0,
|
||||
"toggling calendar visibility never crosses the persistence boundary");
|
||||
app.dispatch(nocal::tui::Action::back);
|
||||
|
||||
auto render_state = app.state();
|
||||
render_state.width = 120;
|
||||
render_state.height = 36;
|
||||
auto frame = nocal::tui::render_month(events, app.calendars(), render_state);
|
||||
check(frame.find("08:00 D") != std::string::npos &&
|
||||
frame.find("09:00 P") != std::string::npos &&
|
||||
frame.find("10:00 W") == std::string::npos,
|
||||
"month rendering excludes only events from hidden calendars");
|
||||
|
||||
app.handle_input("/");
|
||||
app.handle_input("work");
|
||||
app.handle_input("\r");
|
||||
check(app.state().search_results.empty(),
|
||||
"search shares the month view's calendar visibility contract");
|
||||
app.dispatch(nocal::tui::Action::back);
|
||||
|
||||
app.dispatch(nocal::tui::Action::next_event);
|
||||
check(focus_is(app, "default"), "focus starts on the first visible calendar event");
|
||||
app.dispatch(nocal::tui::Action::next_event);
|
||||
check(focus_is(app, "personal"), "focus advances to another visible calendar");
|
||||
app.dispatch(nocal::tui::Action::next_event);
|
||||
check(focus_is(app, "default"), "focus traversal skips hidden-calendar events");
|
||||
|
||||
app.dispatch(nocal::tui::Action::delete_event);
|
||||
app.handle_input("y");
|
||||
check(save_calls == 1 && saved.size() == 2 &&
|
||||
std::any_of(saved.begin(), saved.end(), [](const nocal::Event& event) {
|
||||
return event.uid == "work" && event.calendar_id == "work";
|
||||
}),
|
||||
"mutations save the full model, including events in hidden calendars");
|
||||
|
||||
app.dispatch(nocal::tui::Action::calendars);
|
||||
app.dispatch(nocal::tui::Action::select);
|
||||
check(app.calendars()[2].visible && save_calls == 1,
|
||||
"calendar picker retains its selection and can reveal a calendar without saving");
|
||||
app.dispatch(nocal::tui::Action::back);
|
||||
app.dispatch(nocal::tui::Action::next_event);
|
||||
app.dispatch(nocal::tui::Action::next_event);
|
||||
check(focus_is(app, "work"), "revealed calendar events become focusable again");
|
||||
app.dispatch(nocal::tui::Action::calendars);
|
||||
app.dispatch(nocal::tui::Action::select);
|
||||
check(!app.state().focused_event_id,
|
||||
"hiding the focused event's calendar clears stale appointment focus");
|
||||
check(save_calls == 1, "all visibility-only interactions remain unsaved session state");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
@@ -983,6 +1066,7 @@ int main()
|
||||
test_recurring_occurrence_navigation_and_whole_series_crud();
|
||||
test_recurrence_grid_expansion_and_source_zone_display();
|
||||
test_search_prompt_results_and_occurrence_jump();
|
||||
test_calendar_visibility_filters_without_partial_saves();
|
||||
if (failures != 0) {
|
||||
std::cerr << failures << " TUI focus test(s) failed\n";
|
||||
return EXIT_FAILURE;
|
||||
|
||||
@@ -97,6 +97,40 @@ void test_compact_and_input()
|
||||
check(nocal::tui::decode_key("\x1b[6~") == nocal::tui::Action::next_month,
|
||||
"PageDown is decoded");
|
||||
check(nocal::tui::decode_key("k") == nocal::tui::Action::up, "Vim movement is decoded");
|
||||
check(nocal::tui::decode_key("c") == nocal::tui::Action::calendars,
|
||||
"c opens the calendar picker");
|
||||
}
|
||||
|
||||
void test_calendar_picker_frame()
|
||||
{
|
||||
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 = 52;
|
||||
state.height = 10;
|
||||
state.ansi = false;
|
||||
state.show_calendar_picker = true;
|
||||
state.calendar_index = 1;
|
||||
const std::vector<nocal::Calendar> calendars{
|
||||
{.id = "", .name = "Default", .color = {}, .visible = true,
|
||||
.read_only = false},
|
||||
{.id = "work", .name = "Work", .color = {}, .visible = false,
|
||||
.read_only = false},
|
||||
};
|
||||
|
||||
const auto frame = nocal::tui::render_month(
|
||||
std::span<const nocal::Event>{}, calendars, state);
|
||||
check(frame.find("CALENDARS") != std::string::npos &&
|
||||
frame.find("1 of 2 visible") != std::string::npos &&
|
||||
frame.find("[x] Default") != std::string::npos &&
|
||||
frame.find("[ ] Work") != std::string::npos,
|
||||
"calendar picker renders visibility and selection context");
|
||||
check(std::count(frame.begin(), frame.end(), '\n') == 9,
|
||||
"calendar picker fills the requested height");
|
||||
check(frame.find("\x1b[") == std::string::npos,
|
||||
"calendar picker remains readable without ANSI styling");
|
||||
}
|
||||
|
||||
void test_navigation()
|
||||
@@ -164,6 +198,7 @@ int main()
|
||||
test_compact_and_input();
|
||||
test_navigation();
|
||||
test_search_frames();
|
||||
test_calendar_picker_frame();
|
||||
if (failures != 0) {
|
||||
std::cerr << failures << " TUI test(s) failed\n";
|
||||
return EXIT_FAILURE;
|
||||
|
||||
Reference in New Issue
Block a user