diff --git a/meson.build b/meson.build index 40ad1df..659fe75 100644 --- a/meson.build +++ b/meson.build @@ -46,6 +46,11 @@ nocal_dep = declare_dependency(include_directories: inc, link_with: nocal_lib, nocal_executable = executable('nocal', 'src/main.cpp', dependencies: nocal_dep, install: true) +util = meson.get_compiler('cpp').find_library('util', required: false) +pty_smoke = executable('tui-pty-smoke', 'tests/tui_pty_smoke.cpp', + dependencies: util) +test('tui-pty-smoke', pty_smoke, args: [nocal_executable], timeout: 60) + domain_tests = executable('domain-tests', 'tests/domain_tests.cpp', dependencies: nocal_dep) test('domain', domain_tests) diff --git a/src/tui/EventEditor.cpp b/src/tui/EventEditor.cpp index 33f5cdd..4752de6 100644 --- a/src/tui/EventEditor.cpp +++ b/src/tui/EventEditor.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -253,6 +254,49 @@ std::string horizontal_line(const int width, const std::string_view left, return result; } +struct KeyHint { + std::string_view key; + std::string_view label; +}; + +int hints_width(const KeyHint* const hints, const std::size_t count) +{ + int width = 0; + for (std::size_t index = 0; index < count; ++index) { + if (index != 0) width += 3; + width += static_cast(hints[index].key.size() + 1 + hints[index].label.size()); + } + return width; +} + +// Key bold, label dim, three spaces between hints; the plain layout is +// identical with ANSI disabled. Hints drop from the end to fit. +std::string render_hints(const std::initializer_list hints, const int width, + const bool ansi) +{ + std::size_t count = hints.size(); + while (count > 0 && hints_width(hints.begin(), count) > width) --count; + std::string result; + for (std::size_t index = 0; index < count; ++index) { + if (index != 0) result += " "; + result += style(std::string{hints.begin()[index].key}, "1", ansi); + result += ' '; + result += style(std::string{hints.begin()[index].label}, "2", ansi); + } + const int used = hints_width(hints.begin(), count); + if (used < width) result.append(static_cast(width - used), ' '); + return result; +} + +std::string hints_interior_line(const std::initializer_list hints, const int width, + const bool ansi) +{ + if (width <= 0) return {}; + if (width == 1) return "│"; + if (width <= 3) return interior_line({}, width); + return "│ " + render_hints(hints, width - 4, ansi) + "│"; +} + } // namespace EventEditor::EventEditor(const Date selected_day) @@ -440,8 +484,9 @@ std::string EventEditor::render(const int requested_width, const int requested_h : editing_ ? " NOCAL · EDIT APPOINTMENT" : " NOCAL · NEW APPOINTMENT"; lines.push_back(styled_interior_line(heading, width, "1", ansi)); - lines.push_back(styled_interior_line(" Tab/↓ next Shift-Tab/↑ back Space toggle", - width, "2", ansi)); + lines.push_back(hints_interior_line( + {{"Tab", "next field"}, {"Shift-Tab", "previous field"}, {"Space", "toggle"}}, + width, ansi)); lines.push_back(styled_interior_line( original_.time_basis == TimeBasis::zoned && !original_.time_zone.empty() ? " Times shown in source zone: " + original_.time_zone @@ -470,8 +515,10 @@ std::string EventEditor::render(const int requested_width, const int requested_h } lines.push_back(rule); - lines.push_back(interior_line(error_.empty() ? " Ready" : " ! " + error_, width)); - lines.push_back(styled_interior_line(" Ctrl-S save Esc cancel", width, "1", ansi)); + lines.push_back(error_.empty() + ? styled_interior_line(" Ready", width, "2", ansi) + : styled_interior_line(" ! " + error_, width, "1;31", ansi)); + lines.push_back(hints_interior_line({{"Ctrl-S", "save"}, {"Esc", "cancel"}}, width, ansi)); lines.push_back(bottom); // Keep the active field visible on short terminals by selecting a window diff --git a/src/tui/Screen.cpp b/src/tui/Screen.cpp index 75e55ec..03be898 100644 --- a/src/tui/Screen.cpp +++ b/src/tui/Screen.cpp @@ -17,6 +17,9 @@ using namespace std::chrono; constexpr std::array month_names{ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"}; +constexpr std::array month_short{ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; constexpr std::array weekday_long{ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"}; constexpr std::array weekday_short{ @@ -143,6 +146,62 @@ std::string styled(const std::string_view text, const std::string_view code, con return "\x1b[" + std::string{code} + "m" + std::string{text} + "\x1b[0m"; } +struct KeyHint { + std::string_view key; + std::string_view label; +}; + +int hints_display_width(const std::span hints) { + int total = 0; + for (std::size_t index = 0; index < hints.size(); ++index) { + if (index != 0) total += 3; // three spaces between hints + total += display_width(hints[index].key) + 1 + display_width(hints[index].label); + } + return total; +} + +// Renders hint chips in at most `width` columns, dropping hints from the end +// (lowest priority) until the rest fit, then pads to exactly `width`. +std::string render_hints(const std::span hints, const int width, + const bool ansi) { + const int w = std::max(0, width); + std::size_t shown = hints.size(); + while (shown > 0 && hints_display_width(hints.first(shown)) > w) --shown; + std::string result; + for (std::size_t index = 0; index < shown; ++index) { + if (index != 0) result += " "; + result += styled(hints[index].key, "1", ansi); + result += ' '; + result += styled(hints[index].label, "2", ansi); + } + const int used = hints_display_width(hints.first(shown)); + if (used < w) result.append(static_cast(w - used), ' '); + return result; +} + +// Status left-aligned in its own style, hints right-aligned with at least two +// spaces between. The result is always exactly `width` columns wide. +std::string status_bar(const std::string_view status, const std::string_view status_style, + const std::span hints, const int width, const bool ansi) { + const int w = std::max(0, width); + if (w == 0) return {}; + std::size_t shown = hints.size(); + while (shown > 0 && + display_width(status) + 2 + hints_display_width(hints.first(shown)) > w) { + --shown; + } + const int hints_width = hints_display_width(hints.first(shown)); + const int status_budget = shown > 0 ? std::max(0, w - 2 - hints_width) : w; + const std::string fitted = fit_text(status, status_budget); + std::string result = fitted.empty() ? std::string{} : styled(fitted, status_style, ansi); + if (shown > 0) { + const int gap = std::max(2, w - display_width(fitted) - hints_width); + result.append(static_cast(gap), ' '); + result += render_hints(hints.first(shown), hints_width, ansi); + } + return result; +} + std::string two_digits(const unsigned value) { std::array result{'0', '0', '\0'}; result[0] = static_cast('0' + (value / 10) % 10); @@ -391,15 +450,21 @@ std::string detail_frame(std::span items, const ScreenState& if (!focused.description.empty()) { add(" NOTES", "2;1"); for (const auto& line : wrap_text(printable_text(focused.description, true), - std::max(1, inner - 2))) { + std::max(1, inner - 2))) { add(" " + line); } } else { add(" No notes for this appointment.", "2;3"); } - const auto footer = " ↑↓/Tab browse / search e edit d delete u undo Ctrl-R redo Esc back " + - std::to_string(ordinal) + "/" + std::to_string(total) + " "; + const auto footer_hints = std::array{ + KeyHint{"Tab", "next"}, + KeyHint{"/", "search"}, + KeyHint{"e", "edit"}, + KeyHint{"d", "del"}, + KeyHint{"Esc", "back"}, + }; + const auto ordinal_str = std::to_string(ordinal) + "/" + std::to_string(total); const int body_rows = height - 2; std::ostringstream output; output << border({inner}, "┌", "", "┐") << '\n'; @@ -407,8 +472,24 @@ std::string detail_frame(std::span items, const ScreenState& std::string value(static_cast(inner), ' '); std::string style; if (line == body_rows - 1) { - value = fit_text(footer, inner); - style = "7"; + // Hints left, ordinal right; both drop gracefully on narrow frames. + const int ordinal_width = display_width(ordinal_str); + std::size_t shown = footer_hints.size(); + while (shown > 0 && + hints_display_width(std::span{footer_hints}.first(shown)) + 2 + + ordinal_width > + inner) { + --shown; + } + const int hints_width = + hints_display_width(std::span{footer_hints}.first(shown)); + const bool show_ordinal = + ordinal_width + (shown > 0 ? hints_width + 2 : 0) <= inner; + value = render_hints(std::span{footer_hints}.first(shown), hints_width, + state.ansi); + const int padding = inner - hints_width - (show_ordinal ? ordinal_width : 0); + value.append(static_cast(std::max(0, padding)), ' '); + if (show_ordinal) value += styled(ordinal_str, "2", state.ansi); } else if (line < static_cast(content.size())) { value = content[static_cast(line)].first; style = content[static_cast(line)].second; @@ -429,12 +510,17 @@ std::string delete_confirmation_frame(const ScreenState& state, const CalendarIt const int inner = width - 2; const auto& detail_title = focused.detail_title.empty() ? focused.title : focused.detail_title; const auto title = detail_title.empty() ? std::string{"(untitled)"} - : printable_text(detail_title); + : printable_text(detail_title); const auto question = fit_text(focused.recurring - ? " Delete entire recurring series “" + title + "”?" - : " Delete “" + title + "”?", - inner); - const auto controls = fit_text(" y confirm n/Esc cancel", inner); + ? " Delete entire recurring series “" + title + "”?" + : " Delete “" + title + "”?", + inner); + const std::array confirm_hints{ + KeyHint{"y", "confirm"}, + KeyHint{"n", "cancel"}, + KeyHint{"Esc", "cancel"}, + }; + const auto controls = render_hints(confirm_hints, inner, state.ansi); const int question_line = std::max(1, height / 2 - 1); std::ostringstream output; output << border({inner}, "┌", "", "┐") << '\n'; @@ -446,7 +532,6 @@ std::string delete_confirmation_frame(const ScreenState& state, const CalendarIt style = "1;31"; } else if (line == question_line + 2) { content = controls; - style = "7"; } else if (!state.notification.empty() && line == question_line + 3) { content = fit_text(" " + printable_text(state.notification, true), inner); style = state.notification_is_error ? "1;31" : "1;32"; @@ -531,19 +616,23 @@ std::string search_frame(const ScreenState& state) { if (state.search_prompt) { if (height > 1) lines.push_back(fit_text("/ " + printable_text(state.search_query) + "_", - width, false)); + width, false)); if (height > 2) lines.push_back(styled(fit_text( "Search title, description, location, or calendar ID", width), "2", state.ansi)); - if (height > 3) lines.push_back(styled(fit_text( - "Enter search Backspace edit Esc cancel", width), "2", state.ansi)); + const std::array prompt_hints{ + KeyHint{"Enter", "search"}, + KeyHint{"Backspace", "edit"}, + KeyHint{"Esc", "cancel"}, + }; + if (height > 3) lines.push_back(render_hints(prompt_hints, width, state.ansi)); } else { const auto count = state.search_results.size(); const auto summary = count == 0 - ? "No matches for “" + printable_text(state.search_query) + "”" - : std::to_string(count) + (count == 1 ? " match for “" : " matches for “") + - printable_text(state.search_query) + "”"; + ? "No matches for “" + printable_text(state.search_query) + "”" + : std::to_string(count) + (count == 1 ? " match for “" : " matches for “") + + printable_text(state.search_query) + "”"; if (height > 1) lines.push_back(styled(fit_text(summary, width), count == 0 ? "1" : "1;36", - state.ansi)); + state.ansi)); const auto row_capacity = static_cast(std::max(0, height - 4)); std::size_t first = 0; if (row_capacity > 0 && state.search_result_index >= row_capacity) { @@ -568,8 +657,13 @@ std::string search_frame(const ScreenState& state) { } if (height > 2) lines.push_back(styled(fit_text( "Five years either side of the selected date", width), "2", state.ansi)); - if (height > 1) lines.push_back(styled(fit_text( - "↑/↓ choose Enter jump / new search Esc close", width), "2", state.ansi)); + const std::array results_hints{ + KeyHint{"↑↓", "choose"}, + KeyHint{"Enter", "jump"}, + KeyHint{"/", "new search"}, + KeyHint{"Esc", "close"}, + }; + if (height > 1) lines.push_back(render_hints(results_hints, width, state.ansi)); } while (static_cast(lines.size()) < height) { @@ -602,7 +696,7 @@ std::string agenda_frame(const ScreenState& state) { 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 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}; @@ -611,13 +705,13 @@ std::string agenda_frame(const ScreenState& state) { right.time_of_day.value_or(minutes{-1}); }); - const auto footer_rows = std::min(2, std::max(0, height - 2)); + 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{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; @@ -645,13 +739,15 @@ std::string agenda_frame(const ScreenState& state) { lines.emplace_back(static_cast(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)); + const std::array agenda_hints{ + KeyHint{"↑↓", "choose"}, + KeyHint{"Enter", "month"}, + KeyHint{"n/p", "window"}, + KeyHint{"/", "search"}, + KeyHint{"c", "calendars"}, + KeyHint{"g", "close"}, + }; + lines.push_back(render_hints(agenda_hints, width, state.ansi)); } while (static_cast(lines.size()) < height) { lines.emplace_back(static_cast(width), ' '); @@ -703,8 +799,12 @@ std::string calendar_picker_frame(const std::span calendars, lines.emplace_back(static_cast(width), ' '); } if (height > 1) { - lines.push_back(styled(fit_text( - "↑/↓ choose Enter toggle c/Esc close", width), "2", state.ansi)); + const std::array picker_hints{ + KeyHint{"↑↓", "choose"}, + KeyHint{"Enter", "toggle"}, + KeyHint{"c", "close"}, + }; + lines.push_back(render_hints(picker_hints, width, state.ansi)); } while (static_cast(lines.size()) < height) { lines.emplace_back(static_cast(width), ' '); @@ -719,6 +819,118 @@ std::string calendar_picker_frame(const std::span calendars, return output.str(); } +struct HelpLine { + std::string plain; // exact cell accounting + std::string rich; // styled; identical layout when ANSI is disabled +}; + +std::string help_frame(const ScreenState& state) { + const int width = std::max(1, state.width); + const int height = std::max(1, state.height); + + struct HelpGroup { + std::string_view title; + std::vector> rows; + }; + const std::array groups{ + HelpGroup{"NAVIGATE", + {{"arrows / hjkl", "Move between days"}, + {"PgUp/PgDn or n/p", "Change month"}, + {"t", "Jump to today"}, + {"Tab / Shift-Tab", "Focus appointments"}, + {"Enter", "Read focused appointment"}}}, + HelpGroup{"APPOINTMENTS", + {{"a", "Add appointment"}, + {"e", "Edit focused appointment"}, + {"d", "Delete focused appointment"}, + {"u", "Undo last change"}, + {"Ctrl-R", "Redo last change"}}}, + HelpGroup{"VIEWS", + {{"g", "Agenda (42 days)"}, + {"c", "Calendar visibility"}, + {"/", "Search appointments"}}}, + HelpGroup{"GENERAL", + {{"?", "Toggle this help"}, {"Esc", "Back / close"}, {"q", "Quit"}}}, + }; + + int key_column = 0; + for (const auto& group : groups) { + for (const auto& [key, description] : group.rows) { + key_column = std::max(key_column, display_width(key)); + } + } + + const auto make_column = [&](const std::span column_groups, + const int column_width) { + std::vector column; + for (const auto& group : column_groups) { + const std::string title{group.title}; + column.push_back({title, styled(title, "2;1", state.ansi)}); + for (const auto& [key, description] : group.rows) { + std::string padded_key{key}; + padded_key.append(static_cast(key_column - display_width(key)), + ' '); + const int description_budget = std::max(0, column_width - 3 - key_column); + const std::string fitted = fit_text(description, description_budget); + column.push_back({" " + padded_key + " " + fitted, + " " + styled(padded_key, "1", state.ansi) + " " + fitted}); + } + } + return column; + }; + + std::vector content; + if (width >= 72) { + const int column_width = (width - 4) / 2; + auto left = make_column(std::span{groups}.subspan(0, 2), column_width); + auto right = make_column(std::span{groups}.subspan(2, 2), column_width); + const HelpLine blank{std::string(static_cast(column_width), ' '), + std::string(static_cast(column_width), ' ')}; + const auto rows = std::max(left.size(), right.size()); + left.resize(rows, blank); + right.resize(rows, blank); + content.reserve(rows); + for (std::size_t row = 0; row < rows; ++row) { + content.push_back({left[row].plain + " " + right[row].plain, + left[row].rich + " " + right[row].rich}); + } + } else { + content = make_column(std::span{groups}, width); + } + + std::vector lines; + lines.reserve(static_cast(height)); + lines.push_back(styled(centred("KEYBOARD SHORTCUTS", width), "1", state.ansi)); + if (height > 2) lines.emplace_back(static_cast(width), ' '); + const int content_budget = std::max(0, height - static_cast(lines.size()) - 1); + if (static_cast(content.size()) > content_budget) { + content.resize(static_cast(content_budget)); + } + for (const auto& [plain, rich] : content) { + std::string line = rich; + const int plain_width = display_width(plain); + if (plain_width < width) { + line.append(static_cast(width - plain_width), ' '); + } + lines.push_back(std::move(line)); + } + while (static_cast(lines.size()) < height - 1) { + lines.emplace_back(static_cast(width), ' '); + } + if (height > 1) { + const std::array close_hints{KeyHint{"?", "close"}, KeyHint{"Esc", "close"}}; + lines.push_back(status_bar("", {}, close_hints, width, state.ansi)); + } + 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 compact_frame(std::span items, const ScreenState& state) { const int width = std::max(1, state.width); const int height = std::max(1, state.height); @@ -773,18 +985,25 @@ std::string compact_frame(std::span items, const ScreenState while (static_cast(lines.size()) < height - 1) { lines.emplace_back(static_cast(width), ' '); } - std::string controls; - if (!state.notification.empty()) { - controls = printable_text(state.notification, true); - } else if (focused == nullptr) { - controls = "g agenda 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"; - } if (static_cast(lines.size()) < height) { - const auto style = state.notification.empty() ? "2" - : state.notification_is_error ? "1;31" : "1;32"; - lines.push_back(styled(fit_text(controls, width), style, state.ansi)); + if (!state.notification.empty()) { + const auto text = (state.notification_is_error ? std::string{"! "} : std::string{"✓ "}) + + printable_text(state.notification, true); + lines.push_back(styled(fit_text(text, width), + state.notification_is_error ? "1;31" : "1;32", state.ansi)); + } else if (focused == nullptr) { + const std::array no_focus_hints{ + KeyHint{"g", "agenda"}, KeyHint{"c", "cals"}, KeyHint{"/", "search"}, + KeyHint{"a", "add"}, KeyHint{"?", "help"}, KeyHint{"q", "quit"}, + }; + lines.push_back(render_hints(no_focus_hints, width, state.ansi)); + } else { + const std::array focus_hints{ + KeyHint{"Tab", "appt"}, KeyHint{"Enter", "open"}, + KeyHint{"e", "edit"}, KeyHint{"d", "del"}, + }; + lines.push_back(render_hints(focus_hints, width, state.ansi)); + } } if (static_cast(lines.size()) > height) lines.resize(static_cast(height)); @@ -848,6 +1067,9 @@ std::string render_month(std::span items, const ScreenState& if (state.width < 35 || state.height < 16) { return compact_frame(items, state); } + if (state.show_help) { + return help_frame(state); + } const int width = state.width; const int inner_total = width - 8; // eight vertical border glyphs @@ -875,16 +1097,25 @@ std::string render_month(std::span items, const ScreenState& } const auto month_index = static_cast(state.visible_month.month()) - 1; - const auto title = "‹ " + std::string{month_names[month_index]} + " " + - std::to_string(static_cast(state.visible_month.year())) + " ›"; + const auto month_title = std::string{month_names[month_index]} + " " + + std::to_string(static_cast(state.visible_month.year())); + const auto plain_title = "‹ " + month_title + " ›"; + const int title_width = display_width(plain_title); + const int title_pad = std::max(0, (width - title_width) / 2); std::ostringstream output; - output << styled(centred(title, width), "1", state.ansi) << '\n'; + output << std::string(static_cast(title_pad), ' ') + << styled("‹", "2", state.ansi) << " " << styled(month_title, "1", state.ansi) + << " " << styled("›", "2", state.ansi); + if (title_pad + title_width < width) { + output << std::string(static_cast(width - title_pad - title_width), ' '); + } + output << '\n'; output << ' '; for (std::size_t column = 0; column < widths.size(); ++column) { const auto name_index = weekday_name_index(state.week_start, column); const auto name = widths[column] >= 9 ? weekday_long[name_index] : weekday_short[name_index]; - output << styled(centred(name, widths[column]), "2;1", state.ansi); + output << styled(centred(name, widths[column]), "2", state.ansi); output << (column + 1 == widths.size() ? ' ' : ' '); } output << '\n' << border(widths, "┌", "┬", "┐") << '\n'; @@ -956,26 +1187,54 @@ std::string render_month(std::span items, const ScreenState& const auto selected_count = by_day.contains(sys_days{state.selected_day}) ? 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 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()) { - status = " " + printable_text(state.notification, true); + + // Status left, priority-ordered key hints right. + const auto friendly_day = [](const year_month_day value) { + return std::string{weekday_short[weekday{sys_days{value}}.iso_encoding() - 1]} + ", " + + std::to_string(static_cast(value.day())) + " " + + std::string{month_short[static_cast(value.month()) - 1]} + " " + + std::to_string(static_cast(value.year())); + }; + std::vector hints; + std::string left_status; + std::string status_style; + + if (!state.notification.empty()) { + left_status = (state.notification_is_error ? "! " : "✓ ") + + printable_text(state.notification, true); + status_style = state.notification_is_error ? "1;31" : "1;32"; } else if (focused != nullptr) { const auto focus_title = focused->title.empty() ? std::string{"(untitled)"} : printable_text(focused->title); - status = " " + iso_date(state.selected_day) + " " + - focus_title + " Tab next Enter read / search e edit d delete u undo Ctrl-R redo"; + left_status = friendly_day(state.selected_day) + " · " + focus_title; + hints = {{ + {"Tab", "next"}, + {"Enter", "read"}, + {"e", "edit"}, + {"d", "del"}, + {"u", "undo"}, + {"?", "help"}, + }}; } else { - status = " " + iso_date(state.selected_day) + " " + std::to_string(selected_count) + - (selected_count == 1 ? " appointment" : " appointments") + - (selected_count > 0 ? " Tab focus" : "") + - " g agenda c calendars / search a add u undo Ctrl-R redo ? help q quit"; + left_status = friendly_day(state.selected_day) + " · " + + std::to_string(selected_count) + + (selected_count == 1 ? " appointment" : " appointments"); + if (selected_count > 0) { + hints = {{"Tab", "focus"}}; + } + const std::array right_hints_no_focus{ + KeyHint{"a", "add"}, + KeyHint{"g", "agenda"}, + KeyHint{"c", "calendars"}, + KeyHint{"/", "search"}, + KeyHint{"u", "undo"}, + KeyHint{"?", "help"}, + KeyHint{"q", "quit"}, + }; + hints.insert(hints.end(), right_hints_no_focus.begin(), right_hints_no_focus.end()); } - const auto status_style = !state.notification.empty() - ? (state.notification_is_error ? "1;31" : "1;32") - : focused != nullptr ? "7" : "2"; - output << styled(fit_text(status, width), status_style, state.ansi); + + output << status_bar(left_status, status_style, hints, width, state.ansi); return output.str(); } diff --git a/tests/editor_tests.cpp b/tests/editor_tests.cpp index 1b40576..64d7f4d 100644 --- a/tests/editor_tests.cpp +++ b/tests/editor_tests.cpp @@ -47,6 +47,10 @@ void test_create_and_validation() check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active && editor.focused_field() == nocal::tui::EditorField::title && !editor.error().empty(), "blank title blocks submission and focuses the invalid field"); + check(editor.render(72, 18, true).find("\x1b[1;31m") != std::string::npos, + "validation errors render in the error style"); + check(editor.render(72, 18, false).find("\x1b[") == std::string::npos, + "error frame stays plain with ANSI disabled"); (void)editor.handle_key("Design review"); focus(editor, nocal::tui::EditorField::end_time); @@ -179,6 +183,8 @@ void test_rendering() "render fills requested height exactly"); check(plain.find("NEW APPOINTMENT") != std::string::npos && plain.find("Ctrl-S save") != std::string::npos && + plain.find("Tab next field") != std::string::npos && + plain.find("Esc cancel") != std::string::npos && plain.find("[ ] timed appointment") != std::string::npos, "render exposes form identity and controls"); check(plain.find("\x1b[") == std::string::npos, "ANSI can be disabled"); diff --git a/tests/tui_focus_tests.cpp b/tests/tui_focus_tests.cpp index 9488a47..5bf4b04 100644 --- a/tests/tui_focus_tests.cpp +++ b/tests/tui_focus_tests.cpp @@ -41,6 +41,21 @@ nocal::Event make_event(std::string uid, std::string title, const nocal::Date st return event; } +std::string strip_ansi(const std::string& input) +{ + std::string output; + for (std::size_t index = 0; index < input.size();) { + if (input[index] == '\x1b' && index + 1 < input.size() && input[index + 1] == '[') { + index += 2; + while (index < input.size() && input[index] != 'm') ++index; + if (index < input.size()) ++index; + } else { + output += input[index++]; + } + } + return output; +} + nocal::tui::ScreenState screen_for(const nocal::Date day, const int width = 100, const int height = 32) { @@ -313,9 +328,11 @@ void test_delete_cancel_confirm_and_rollback() app.dispatch(nocal::tui::Action::next_event); app.dispatch(nocal::tui::Action::delete_event); check(app.delete_confirmation_active(), "delete requires an explicit confirmation step"); - const auto confirmation = nocal::tui::render_month(events, app.state()); + const auto confirmation = strip_ansi(nocal::tui::render_month(events, app.state())); check(confirmation.find("Delete target") != std::string::npos && - confirmation.find("y confirm") != std::string::npos, + confirmation.find("y confirm") != std::string::npos && + confirmation.find("n cancel") != std::string::npos && + confirmation.find("Esc cancel") != std::string::npos, "delete confirmation names the appointment and shows its keys"); app.handle_input("n"); check(!app.delete_confirmation_active() && events.size() == 2 && save_calls == 0, @@ -545,8 +562,9 @@ void test_history_is_unavailable_in_modal_states() footer_state.focused_event_id = "modal-history"; const auto month_frame = nocal::tui::render_month(events, footer_state); check(month_frame.find("u undo") != std::string::npos && - month_frame.find("Ctrl-R redo") != std::string::npos, - "the month footer advertises both history shortcuts"); + month_frame.find("Enter read") != std::string::npos && + month_frame.find("Tab next") != std::string::npos, + "the month footer shows focused hints"); app.dispatch(nocal::tui::Action::delete_event); app.handle_input("u"); check(app.delete_confirmation_active() && app.state().notification_is_error && @@ -658,6 +676,10 @@ void test_timed_event_details() std::string::npos, "the reader renders the event description"); check(frame.find("Esc back") != std::string::npos, "the reader footer explains how to close it"); + // Verify the new chip grammar: hints left, ordinal right + check(frame.find("Tab") != std::string::npos, "reader footer contains Tab hint"); + check(frame.find("del") != std::string::npos, "reader footer contains del hint"); + check(frame.find("1/") != std::string::npos, "reader footer contains ordinal"); } void test_all_day_and_multiday_details() diff --git a/tests/tui_pty_smoke.cpp b/tests/tui_pty_smoke.cpp new file mode 100644 index 0000000..68f0b24 --- /dev/null +++ b/tests/tui_pty_smoke.cpp @@ -0,0 +1,203 @@ +// Live PTY smoke test: drives the real nocal binary through interactive +// views and verifies the terminal state (alternate screen, cursor) is +// restored on exit. Usage: tui-pty-smoke /path/to/nocal + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +int failures = 0; + +void check(const bool condition, const std::string_view message) +{ + if (!condition) { + ++failures; + std::cerr << "FAIL: " << message << '\n'; + } +} + +class PtySession { +public: + PtySession(const char* binary, const char* home) + { + struct winsize size {}; + size.ws_col = 100; + size.ws_row = 30; + int master = -1; + const pid_t child = ::forkpty(&master, nullptr, nullptr, &size); + if (child < 0) { + std::cerr << "forkpty failed: " << std::strerror(errno) << '\n'; + std::exit(2); + } + if (child == 0) { + ::setenv("HOME", home, 1); + ::setenv("XDG_DATA_HOME", home, 1); + ::execl(binary, "nocal", "--demo", static_cast(nullptr)); + std::cerr << "exec failed: " << std::strerror(errno) << '\n'; + _exit(127); + } + master_ = master; + child_ = child; + } + + ~PtySession() + { + if (master_ >= 0) ::close(master_); + if (child_ > 0 && !reaped_) { + ::kill(child_, SIGKILL); + ::waitpid(child_, nullptr, 0); + } + } + + [[nodiscard]] bool wait_for(const std::string_view needle, + const std::chrono::milliseconds timeout) + { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (capture_.find(needle, mark_) != std::string::npos) return true; + struct pollfd ready { master_, POLLIN, 0 }; + const int result = ::poll(&ready, 1, 100); + if (result > 0) { + // A read after hangup returns any remaining buffered output, + // then 0/EIO; only then is the stream truly exhausted. + char buffer[4096]; + const auto count = ::read(master_, buffer, sizeof(buffer)); + if (count > 0) { + capture_.append(buffer, static_cast(count)); + continue; + } + return capture_.find(needle, mark_) != std::string::npos; + } + if (result < 0 && errno != EINTR) return false; + } + return capture_.find(needle, mark_) != std::string::npos; + } + + void send(const std::string_view keys) + { + mark_ = capture_.size(); + const auto written = ::write(master_, keys.data(), keys.size()); + check(written == static_cast(keys.size()), "pty write completed"); + } + + [[nodiscard]] bool exited_cleanly(const std::chrono::milliseconds timeout) + { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + int status = 0; + const auto done = ::waitpid(child_, &status, WNOHANG); + if (done == child_) { + reaped_ = true; + exit_status_ = status; + // Drain whatever the child wrote while exiting. + static_cast(wait_for("\x1b[?1049l", std::chrono::milliseconds{500})); + return WIFEXITED(status) && WEXITSTATUS(status) == 0; + } + ::usleep(50'000); + } + return false; + } + + [[nodiscard]] const std::string& capture() const { return capture_; } + +private: + int master_{-1}; + pid_t child_{-1}; + bool reaped_{false}; + int exit_status_{0}; + std::string capture_; + std::size_t mark_{0}; +}; + +void test_interactive_views(const char* binary, const char* home) +{ + PtySession session{binary, home}; + check(session.wait_for("\x1b[?1049h", std::chrono::seconds{5}), + "app enters the alternate screen"); + check(session.wait_for("Monday", std::chrono::seconds{5}), + "month grid renders in the pty"); + + session.send("?"); + check(session.wait_for("KEYBOARD SHORTCUTS", std::chrono::seconds{5}), + "? opens the keyboard shortcut help"); + session.send("\x1b"); + check(session.wait_for("Monday", std::chrono::seconds{5}), + "Esc returns from help to the month grid"); + + session.send("g"); + check(session.wait_for("AGENDA", std::chrono::seconds{5}), "g opens the agenda"); + session.send("\x1b"); + check(session.wait_for("Monday", std::chrono::seconds{5}), "Esc closes the agenda"); + + session.send("/"); + check(session.wait_for("Search title", std::chrono::seconds{5}), + "/ opens the search prompt"); + session.send("\x1b"); + check(session.wait_for("Monday", std::chrono::seconds{5}), "Esc cancels search"); + + session.send("q"); + check(session.exited_cleanly(std::chrono::seconds{5}), "q exits with status zero"); + check(session.capture().find("\x1b[?25h") != std::string::npos, + "cursor visibility is restored after q"); + check(session.capture().find("\x1b[?1049l") != std::string::npos, + "alternate screen is restored after q"); +} + +void test_ctrl_c_unwind(const char* binary, const char* home) +{ + const int before = failures; + PtySession session{binary, home}; + check(session.wait_for("Monday", std::chrono::seconds{5}), + "second session renders the month grid"); + session.send("\x03"); + check(session.exited_cleanly(std::chrono::seconds{5}), + "Ctrl-C unwinds through the normal exit path"); + check(session.capture().find("\x1b[?25h") != std::string::npos, + "cursor visibility is restored after Ctrl-C"); + check(session.capture().find("\x1b[?1049l") != std::string::npos, + "alternate screen is restored after Ctrl-C"); + if (failures != before && std::getenv("NOCAL_PTY_DEBUG") != nullptr) { + const auto& capture = session.capture(); + std::cerr << "--- ctrl-c capture tail ---\n" + << capture.substr(capture.size() > 2000 ? capture.size() - 2000 : 0) + << "\n--- end capture ---\n"; + } +} + +} // namespace + +int main(const int argc, char** argv) +{ + if (argc != 2) { + std::cerr << "usage: tui-pty-smoke /path/to/nocal\n"; + return 2; + } + char home_template[] = "/tmp/nocal-pty-home.XXXXXX"; + const char* home = ::mkdtemp(home_template); + if (home == nullptr) { + std::cerr << "mkdtemp failed: " << std::strerror(errno) << '\n'; + return 2; + } + + test_interactive_views(argv[1], home); + test_ctrl_c_unwind(argv[1], home); + + if (failures != 0) { + std::cerr << failures << " PTY smoke check(s) failed\n"; + return EXIT_FAILURE; + } + std::cout << "PTY smoke passed\n"; + return EXIT_SUCCESS; +} diff --git a/tests/tui_tests.cpp b/tests/tui_tests.cpp index bb36246..9bf503d 100644 --- a/tests/tui_tests.cpp +++ b/tests/tui_tests.cpp @@ -48,6 +48,42 @@ bool ordered(std::string_view line, const std::vector& labels) return true; } +// Codepoint count; every glyph used in these frames is one cell wide. +std::size_t cell_width(std::string_view line) +{ + return static_cast(std::count_if(line.begin(), line.end(), [](const char c) { + return (static_cast(c) & 0xc0U) != 0x80U; + })); +} + +bool frame_lines_exact_width(const std::string& frame, const int width) +{ + std::size_t start = 0; + while (start <= frame.size()) { + const auto end = frame.find('\n', start); + const auto line = std::string_view{frame}.substr( + start, end == std::string::npos ? frame.size() - start : end - start); + if (cell_width(line) != static_cast(width)) return false; + if (end == std::string::npos) break; + start = end + 1; + } + return true; +} + +nocal::tui::ScreenState base_state(const int width, const int height) +{ + 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 = year{2026} / July / day{17}; + state.width = width; + state.height = height; + state.ansi = false; + state.week_start = Monday; + return state; +} + nocal::Event all_day_event(std::string uid, std::string title, nocal::Date start, nocal::Date exclusive_end) { @@ -110,12 +146,15 @@ void test_full_month_render() const auto frame = nocal::tui::render_month(items, state); 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("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, "renderer fills the requested height deterministically"); check(frame.find("\x1b[") == std::string::npos, "ANSI can be disabled"); + check(frame_lines_exact_width(frame, 100), "month frame lines fill the exact width"); + check(frame.find("Monday") != std::string::npos, "weekday header includes Monday"); + check(frame.find("Tuesday") != std::string::npos, "weekday header includes Tuesday"); } void test_compact_and_input() @@ -352,7 +391,7 @@ void test_agenda_empty_frame() { using namespace std::chrono; auto state = nocal::tui::ScreenState{}; - state.width = 64; + state.width = 72; state.height = 9; state.ansi = false; state.show_agenda = true; @@ -365,7 +404,7 @@ void test_agenda_empty_frame() "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, + check(frame.find("g 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"); @@ -403,9 +442,10 @@ void test_agenda_selection_and_scrolling() const auto frame = nocal::tui::render_month( std::span{}, state); - check(frame.find("Holiday") == std::string::npos && + // With 1-row footer, row_capacity = 4, showing indices 0-3 + 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"); + "agenda shows items with the selected item 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, @@ -433,10 +473,86 @@ void test_agenda_input_and_search_precedence() } // namespace +void test_help_frame() +{ + using namespace std::chrono; + auto state = base_state(80, 24); + state.show_help = true; + const auto frame = nocal::tui::render_month(std::span{}, state); + check(frame.find("KEYBOARD SHORTCUTS") != std::string::npos, "help frame has a title"); + check(frame.find("NAVIGATE") != std::string::npos && + frame.find("APPOINTMENTS") != std::string::npos && + frame.find("VIEWS") != std::string::npos && + frame.find("GENERAL") != std::string::npos, + "wide help frame renders every shortcut group"); + check(frame.find("Redo last change") != std::string::npos && + frame.find("Search appointments") != std::string::npos, + "help frame lists both editing and view shortcuts"); + check(frame.find("? close") != std::string::npos && + frame.find("Esc close") != std::string::npos, + "help footer explains how to close it"); + check(std::count(frame.begin(), frame.end(), '\n') == 23, + "help frame fills the requested height"); + check(frame_lines_exact_width(frame, 80), "help frame lines fill the exact width"); + + state.width = 60; + const auto narrow = nocal::tui::render_month(std::span{}, state); + check(narrow.find("NAVIGATE") != std::string::npos && + narrow.find("GENERAL") != std::string::npos, + "single-column help frame keeps every group"); + check(frame_lines_exact_width(narrow, 60), "narrow help lines fill the exact width"); + + state.ansi = true; + const auto styled = nocal::tui::render_month(std::span{}, state); + check(styled.find("\x1b[1m") != std::string::npos, "help keys are bold when ANSI is on"); +} + +void test_status_bar_chrome() +{ + using namespace std::chrono; + const std::vector items{ + {.id = "1", .title = "Design review", .day = year{2026} / July / day{17}, + .time_of_day = hours{9} + minutes{30}, .all_day = false, + .end_time_of_day = std::nullopt, .start_day = {}, .end_day = {}, + .location = {}, .description = {}, .detail_title = {}, + .detail_all_day = false, .detail_start_time_of_day = std::nullopt, + .segment = nocal::tui::ItemSegment::single, .recurring = false, + .recurrence_summary = {}, .source_time_zone = {}}, + }; + + auto state = base_state(60, 20); + const auto frame = nocal::tui::render_month(items, state); + const auto footer = frame_line(frame, 19); + check(cell_width(footer) == 60, "status bar fills a narrowed width exactly"); + check(footer.find("Fr, 17 Jul 2026") != std::string::npos, + "status bar shows a friendly selected date"); + check(footer.find("Tab focus") != std::string::npos && + footer.find("a add") != std::string::npos, + "high-priority hints survive narrowing"); + check(footer.find("q quit") == std::string::npos, + "low-priority hints drop instead of ellipsizing"); + + state.notification = "Appointment saved"; + const auto success = nocal::tui::render_month(items, state); + check(success.find("✓ Appointment saved") != std::string::npos, + "success notifications carry a check mark"); + state.notification_is_error = true; + const auto failure = nocal::tui::render_month(items, state); + check(failure.find("! Appointment saved") != std::string::npos, + "error notifications carry a warning mark"); + + state = base_state(30, 12); + const auto compact = nocal::tui::render_month(items, state); + check(frame_lines_exact_width(compact, 30), + "compact frame hint lines never overflow their width"); +} + int main() { test_full_month_render(); test_compact_and_input(); + test_help_frame(); + test_status_bar_chrome(); test_sunday_start_full_render_and_domain_window(); test_wednesday_start_compact_render(); test_monday_default_and_invalid_week_start_fallback();