diff --git a/include/nocal/tui/Screen.hpp b/include/nocal/tui/Screen.hpp index 03ea906..fde165b 100644 --- a/include/nocal/tui/Screen.hpp +++ b/include/nocal/tui/Screen.hpp @@ -87,6 +87,7 @@ struct ScreenState { bool show_calendar_picker{false}; std::size_t calendar_index{0}; bool show_agenda{false}; + bool show_linear_view{false}; std::chrono::year_month_day agenda_start_day{ std::chrono::year{1970}, std::chrono::month{1}, std::chrono::day{1}}; std::vector agenda_items; @@ -138,6 +139,7 @@ enum class Action { search, agenda, calendars, + linear_view, sync, quit, }; diff --git a/include/nocal/tui/TuiApp.hpp b/include/nocal/tui/TuiApp.hpp index 238f62b..e190032 100644 --- a/include/nocal/tui/TuiApp.hpp +++ b/include/nocal/tui/TuiApp.hpp @@ -121,9 +121,12 @@ private: void toggle_selected_calendar(); void begin_agenda(); void close_agenda(); + void begin_linear_view(); + void close_linear_view(); void refresh_agenda(); void shift_agenda(int direction); void choose_agenda_item(); + void choose_linear_item(); [[nodiscard]] HistorySnapshot capture_history_snapshot() const; void restore_history_snapshot(HistorySnapshot snapshot); void push_history(std::vector& history, HistoryEntry entry); diff --git a/src/tui/Screen.cpp b/src/tui/Screen.cpp index 0f39154..7615544 100644 --- a/src/tui/Screen.cpp +++ b/src/tui/Screen.cpp @@ -667,6 +667,97 @@ std::string agenda_frame(const ScreenState& state) { return output.str(); } +std::string linear_frame(const ScreenState& state) { + const int width = std::max(1, state.width); + const int height = std::max(1, state.height); + std::vector lines; + lines.reserve(static_cast(height)); + + const auto month_index = static_cast(state.visible_month.month()) - 1; + const auto month_name = std::string{month_names[month_index]}; + const auto year_str = std::to_string(static_cast(state.visible_month.year())); + const auto count = state.agenda_items.size(); + const auto header = "LINEAR VIEW \u2014 " + month_name + " " + year_str + + " (" + std::to_string(count) + " event" + (count == 1 ? "" : "s") + ")"; + lines.push_back(styled(centred(header, width), "1", state.ansi)); + + std::vector 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 = 1; + const auto row_capacity = static_cast( + 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(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; + const auto number = position - first + 1; + + 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); + + std::string prefix; + if (focused && !state.ansi) { + prefix = "[*>]"; + } else { + prefix = "[" + std::to_string(number) + "]"; + } + + auto label = prefix + " " + iso_date(item.day) + " " + when + " " + title; + if (item.recurring) label += " (recurring)"; + if (!item.location.empty()) label += " - " + printable_text(item.location); + + lines.push_back(styled(fit_text(label, width), focused ? "1" : "", state.ansi)); + } + if (state.agenda_items.empty() && row_capacity > 0) { + lines.push_back(styled(centred("No appointments.", width), "2", state.ansi)); + } + while (static_cast(lines.size()) < height - footer_rows) { + lines.emplace_back(static_cast(width), ' '); + } + if (footer_rows > 0) { + const std::array linear_hints{ + KeyHint{"\xe2\x86\x91\xe2\x86\x93", "navigate"}, + KeyHint{"Enter", "month"}, + KeyHint{"n/p", "window"}, + KeyHint{"L/Esc", "close"}, + }; + lines.push_back(render_hints(linear_hints, width, state.ansi)); + } + while (static_cast(lines.size()) < height) { + lines.emplace_back(static_cast(width), ' '); + } + if (static_cast(lines.size()) > height) lines.resize(static_cast(height)); + + std::ostringstream output; + for (int line = 0; line < height; ++line) { + if (line != 0) output << '\n'; + output << lines[static_cast(line)]; + } + return output.str(); +} + std::string calendar_picker_frame(const std::span calendars, const ScreenState& state) { const int width = std::max(1, state.width); @@ -752,6 +843,7 @@ std::string help_frame(const ScreenState& state) { {"Ctrl-R", "Redo last change"}}}, HelpGroup{"VIEWS", {{"g", "Agenda (42 days)"}, + {"L", "Linear view (accessible)"}, {"c", "Calendar visibility"}, {"/", "Search appointments"}}}, HelpGroup{"GENERAL", @@ -954,6 +1046,7 @@ Action decode_key(const std::string_view sequence) noexcept { if (sequence == "\x12") return Action::redo; if (sequence == "/") return Action::search; if (sequence == "g") return Action::agenda; + if (sequence == "L") return Action::linear_view; if (sequence == "c") return Action::calendars; if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit; if (sequence == "\x13") return Action::sync; @@ -961,6 +1054,7 @@ Action decode_key(const std::string_view sequence) noexcept { } std::string render_month(std::span items, const ScreenState& state) { + if (state.show_linear_view) return linear_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); diff --git a/src/tui/TuiApp.cpp b/src/tui/TuiApp.cpp index 1bb19dc..96161ee 100644 --- a/src/tui/TuiApp.cpp +++ b/src/tui/TuiApp.cpp @@ -275,6 +275,36 @@ void TuiApp::close_agenda() { state_.show_agenda = false; } +void TuiApp::begin_linear_view() { + state_.show_linear_view = 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; + state_.show_agenda = false; + refresh_agenda(); + // Place cursor on the first item on or after the selected day. + if (!state_.agenda_items.empty()) { + const auto target = sys_days{state_.selected_day}; + for (std::size_t index = 0; index < state_.agenda_items.size(); ++index) { + if (sys_days{state_.agenda_items[index].day} >= target) { + state_.agenda_index = index; + return; + } + } + state_.agenda_index = 0; + } +} + +void TuiApp::close_linear_view() { + state_.show_linear_view = false; +} + void TuiApp::refresh_agenda() { if (!state_.agenda_start_day.ok()) state_.agenda_start_day = state_.selected_day; std::optional selected_identity; @@ -361,6 +391,16 @@ void TuiApp::choose_agenda_item() { close_agenda(); } +void TuiApp::choose_linear_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_linear_view(); +} + void TuiApp::set_notification(std::string message, const bool error) { state_.notification = std::move(message); state_.notification_is_error = error; @@ -808,6 +848,7 @@ void TuiApp::dispatch(const Action action) { case Action::redo: case Action::agenda: case Action::calendars: + case Action::linear_view: case Action::sync: return; } @@ -850,6 +891,61 @@ void TuiApp::dispatch(const Action action) { case Action::redo: case Action::search: case Action::agenda: + case Action::linear_view: + case Action::sync: + return; + } + } + if (state_.show_linear_view) { + 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_linear_item(); + return; + case Action::back: + case Action::linear_view: + close_linear_view(); + 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: + case Action::agenda: case Action::sync: return; } @@ -903,6 +999,7 @@ void TuiApp::dispatch(const Action action) { case Action::delete_event: case Action::undo: case Action::redo: + case Action::linear_view: case Action::sync: return; } @@ -989,6 +1086,7 @@ void TuiApp::dispatch(const Action action) { case Action::next_month: case Action::today: case Action::toggle_help: + case Action::linear_view: case Action::sync: return; } @@ -1075,6 +1173,9 @@ void TuiApp::dispatch(const Action action) { case Action::agenda: begin_agenda(); return; + case Action::linear_view: + begin_linear_view(); + return; case Action::calendars: begin_calendar_picker(); return; diff --git a/tests/tui_tests.cpp b/tests/tui_tests.cpp index e7f1afc..9f3a8d7 100644 --- a/tests/tui_tests.cpp +++ b/tests/tui_tests.cpp @@ -475,6 +475,112 @@ void test_agenda_input_and_search_precedence() "g toggles the agenda view"); } +void test_linear_view_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 = 80; + state.height = 12; + state.ansi = false; + state.show_linear_view = true; + state.agenda_start_day = year{2026} / July / day{1}; + state.agenda_items = { + {.focus_id = "first", .title = "Holiday", .day = year{2026} / July / day{7}, + .time_of_day = std::nullopt, .all_day = true, + .location = {}, .calendar_id = "home", .recurring = false}, + {.focus_id = "second", .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 = "third", .title = "Retrospective", .day = year{2026} / July / day{13}, + .time_of_day = hours{16}, .all_day = false, + .location = "Room 2", .calendar_id = "work", .recurring = false}, + }; + state.agenda_index = 0; + + const auto frame = nocal::tui::render_month( + std::span{}, state); + + check(frame.find("LINEAR VIEW") != std::string::npos, + "linear view header is rendered"); + check(frame.find("July 2026") != std::string::npos, + "linear view shows the month in the header"); + check(frame.find("(3 events)") != std::string::npos, + "linear view header counts items"); + check(frame.find("[*>]") != std::string::npos, + "linear view marks focused item with [*>] in non-ANSI mode"); + check(frame.find("[2]") != std::string::npos && + frame.find("[3]") != std::string::npos, + "linear view uses numbered indicators for non-focused items"); + check(frame.find("2026-07-07") != std::string::npos && + frame.find("all day") != std::string::npos && + frame.find("Holiday") != std::string::npos, + "linear view renders all-day event with correct format"); + check(frame.find("2026-07-12 14:30 local") != std::string::npos && + frame.find("Release") != std::string::npos && + frame.find("(recurring)") != std::string::npos && + frame.find(" - Studio") != std::string::npos, + "linear view renders timed recurring event with location"); + check(frame.find("2026-07-13 16:00 local Retrospective - Room 2") != std::string::npos, + "linear view renders all fields for an item"); + check(frame.find("navigate") != std::string::npos && + frame.find("Enter") != std::string::npos && + frame.find("month") != std::string::npos && + frame.find("close") != std::string::npos, + "linear view footer shows navigation hints"); + check(std::count(frame.begin(), frame.end(), '\n') == 11, + "linear view fills the requested height"); + + // Screen-reader-friendly: no box-drawing chars + check(frame.find("\xe2\x94\x8c") == std::string::npos && + frame.find("\xe2\x94\x82") == std::string::npos && + frame.find("\xe2\x94\x94") == std::string::npos && + frame.find("\xe2\x94\x9c") == std::string::npos && + frame.find("\xe2\x94\xa4") == std::string::npos && + frame.find("\xe2\x94\xac") == std::string::npos && + frame.find("\xe2\x94\xb4") == std::string::npos && + frame.find("\xe2\x94\xbc") == std::string::npos, + "linear view has no box-drawing characters"); + // No bullets or chevrons used in month/agenda views + check(frame.find("\xe2\x80\xa2") == std::string::npos, + "linear view has no bullet character"); + check(frame.find("\xe2\x80\xba") == std::string::npos, + "linear view has no single chevron character"); +} + +void test_linear_view_empty_frame() +{ + using namespace std::chrono; + auto state = nocal::tui::ScreenState{}; + state.width = 72; + state.height = 9; + state.ansi = false; + state.show_linear_view = true; + state.agenda_start_day = year{2026} / July / day{1}; + + const auto frame = nocal::tui::render_month( + std::span{}, state); + check(frame.find("LINEAR VIEW") != std::string::npos, + "empty linear view header is rendered"); + check(frame.find("(0 events)") != std::string::npos || + frame.find("(0 event)") != std::string::npos, + "empty linear view shows zero count"); + check(frame.find("No appointments.") != std::string::npos, + "empty linear view explains no appointments"); + check(std::count(frame.begin(), frame.end(), '\n') == 8, + "empty linear view fills the requested height"); + check(frame.find("\xe2\x94\x8c") == std::string::npos && + frame.find("\xe2\x94\x82") == std::string::npos && + frame.find("\xe2\x94\x94") == std::string::npos, + "empty linear view has no box-drawing characters"); + check(frame.find("\xe2\x80\xa2") == std::string::npos, + "empty linear view has no bullet character"); + check(frame.find("\xe2\x80\xba") == std::string::npos, + "empty linear view has no chevron character"); +} + } // namespace void test_help_frame() @@ -566,6 +672,8 @@ int main() test_agenda_empty_frame(); test_agenda_selection_and_scrolling(); test_agenda_input_and_search_precedence(); + test_linear_view_frame(); + test_linear_view_empty_frame(); if (failures != 0) { std::cerr << failures << " TUI test(s) failed\n"; return EXIT_FAILURE;