feat(tui): redesign chrome with unified key hints and help overlay
Introduce one key-hint grammar across every view: bold key, dim label, priority-ordered hints that drop from the end instead of ellipsizing. The month footer becomes a single split status bar with a friendly selected date, the '?' mega-line becomes a real keyboard shortcut panel, and the editor, agenda, search, picker, detail, and delete frames share the same chips. Notifications carry check/warning marks. Adds a live PTY smoke test covering view interaction and terminal state restoration on q and Ctrl-C exits.
This commit is contained in:
@@ -46,6 +46,11 @@ nocal_dep = declare_dependency(include_directories: inc, link_with: nocal_lib,
|
|||||||
nocal_executable = executable('nocal', 'src/main.cpp',
|
nocal_executable = executable('nocal', 'src/main.cpp',
|
||||||
dependencies: nocal_dep, install: true)
|
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',
|
domain_tests = executable('domain-tests', 'tests/domain_tests.cpp',
|
||||||
dependencies: nocal_dep)
|
dependencies: nocal_dep)
|
||||||
test('domain', domain_tests)
|
test('domain', domain_tests)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
|
#include <initializer_list>
|
||||||
#include <random>
|
#include <random>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
@@ -253,6 +254,49 @@ std::string horizontal_line(const int width, const std::string_view left,
|
|||||||
return result;
|
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<int>(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<KeyHint> 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<std::size_t>(width - used), ' ');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string hints_interior_line(const std::initializer_list<KeyHint> 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
|
} // namespace
|
||||||
|
|
||||||
EventEditor::EventEditor(const Date selected_day)
|
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"
|
: editing_ ? " NOCAL · EDIT APPOINTMENT"
|
||||||
: " NOCAL · NEW APPOINTMENT";
|
: " NOCAL · NEW APPOINTMENT";
|
||||||
lines.push_back(styled_interior_line(heading, width, "1", ansi));
|
lines.push_back(styled_interior_line(heading, width, "1", ansi));
|
||||||
lines.push_back(styled_interior_line(" Tab/↓ next Shift-Tab/↑ back Space toggle",
|
lines.push_back(hints_interior_line(
|
||||||
width, "2", ansi));
|
{{"Tab", "next field"}, {"Shift-Tab", "previous field"}, {"Space", "toggle"}},
|
||||||
|
width, ansi));
|
||||||
lines.push_back(styled_interior_line(
|
lines.push_back(styled_interior_line(
|
||||||
original_.time_basis == TimeBasis::zoned && !original_.time_zone.empty()
|
original_.time_basis == TimeBasis::zoned && !original_.time_zone.empty()
|
||||||
? " Times shown in source zone: " + original_.time_zone
|
? " 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(rule);
|
||||||
lines.push_back(interior_line(error_.empty() ? " Ready" : " ! " + error_, width));
|
lines.push_back(error_.empty()
|
||||||
lines.push_back(styled_interior_line(" Ctrl-S save Esc cancel", width, "1", ansi));
|
? 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);
|
lines.push_back(bottom);
|
||||||
|
|
||||||
// Keep the active field visible on short terminals by selecting a window
|
// Keep the active field visible on short terminals by selecting a window
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ using namespace std::chrono;
|
|||||||
constexpr std::array<std::string_view, 12> month_names{
|
constexpr std::array<std::string_view, 12> month_names{
|
||||||
"January", "February", "March", "April", "May", "June",
|
"January", "February", "March", "April", "May", "June",
|
||||||
"July", "August", "September", "October", "November", "December"};
|
"July", "August", "September", "October", "November", "December"};
|
||||||
|
constexpr std::array<std::string_view, 12> month_short{
|
||||||
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
|
||||||
constexpr std::array<std::string_view, 7> weekday_long{
|
constexpr std::array<std::string_view, 7> weekday_long{
|
||||||
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"};
|
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"};
|
||||||
constexpr std::array<std::string_view, 7> weekday_short{
|
constexpr std::array<std::string_view, 7> 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";
|
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<const KeyHint> 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<const KeyHint> 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<std::size_t>(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<const KeyHint> 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<std::size_t>(gap), ' ');
|
||||||
|
result += render_hints(hints.first(shown), hints_width, ansi);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
std::string two_digits(const unsigned value) {
|
std::string two_digits(const unsigned value) {
|
||||||
std::array<char, 3> result{'0', '0', '\0'};
|
std::array<char, 3> result{'0', '0', '\0'};
|
||||||
result[0] = static_cast<char>('0' + (value / 10) % 10);
|
result[0] = static_cast<char>('0' + (value / 10) % 10);
|
||||||
@@ -398,8 +457,14 @@ std::string detail_frame(std::span<const CalendarItem> items, const ScreenState&
|
|||||||
add(" No notes for this appointment.", "2;3");
|
add(" No notes for this appointment.", "2;3");
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto footer = " ↑↓/Tab browse / search e edit d delete u undo Ctrl-R redo Esc back " +
|
const auto footer_hints = std::array{
|
||||||
std::to_string(ordinal) + "/" + std::to_string(total) + " ";
|
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;
|
const int body_rows = height - 2;
|
||||||
std::ostringstream output;
|
std::ostringstream output;
|
||||||
output << border({inner}, "┌", "", "┐") << '\n';
|
output << border({inner}, "┌", "", "┐") << '\n';
|
||||||
@@ -407,8 +472,24 @@ std::string detail_frame(std::span<const CalendarItem> items, const ScreenState&
|
|||||||
std::string value(static_cast<std::size_t>(inner), ' ');
|
std::string value(static_cast<std::size_t>(inner), ' ');
|
||||||
std::string style;
|
std::string style;
|
||||||
if (line == body_rows - 1) {
|
if (line == body_rows - 1) {
|
||||||
value = fit_text(footer, inner);
|
// Hints left, ordinal right; both drop gracefully on narrow frames.
|
||||||
style = "7";
|
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::size_t>(std::max(0, padding)), ' ');
|
||||||
|
if (show_ordinal) value += styled(ordinal_str, "2", state.ansi);
|
||||||
} else if (line < static_cast<int>(content.size())) {
|
} else if (line < static_cast<int>(content.size())) {
|
||||||
value = content[static_cast<std::size_t>(line)].first;
|
value = content[static_cast<std::size_t>(line)].first;
|
||||||
style = content[static_cast<std::size_t>(line)].second;
|
style = content[static_cast<std::size_t>(line)].second;
|
||||||
@@ -434,7 +515,12 @@ std::string delete_confirmation_frame(const ScreenState& state, const CalendarIt
|
|||||||
? " Delete entire recurring series “" + title + "”?"
|
? " Delete entire recurring series “" + title + "”?"
|
||||||
: " Delete “" + title + "”?",
|
: " Delete “" + title + "”?",
|
||||||
inner);
|
inner);
|
||||||
const auto controls = fit_text(" y confirm n/Esc cancel", 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);
|
const int question_line = std::max(1, height / 2 - 1);
|
||||||
std::ostringstream output;
|
std::ostringstream output;
|
||||||
output << border({inner}, "┌", "", "┐") << '\n';
|
output << border({inner}, "┌", "", "┐") << '\n';
|
||||||
@@ -446,7 +532,6 @@ std::string delete_confirmation_frame(const ScreenState& state, const CalendarIt
|
|||||||
style = "1;31";
|
style = "1;31";
|
||||||
} else if (line == question_line + 2) {
|
} else if (line == question_line + 2) {
|
||||||
content = controls;
|
content = controls;
|
||||||
style = "7";
|
|
||||||
} else if (!state.notification.empty() && line == question_line + 3) {
|
} else if (!state.notification.empty() && line == question_line + 3) {
|
||||||
content = fit_text(" " + printable_text(state.notification, true), inner);
|
content = fit_text(" " + printable_text(state.notification, true), inner);
|
||||||
style = state.notification_is_error ? "1;31" : "1;32";
|
style = state.notification_is_error ? "1;31" : "1;32";
|
||||||
@@ -534,8 +619,12 @@ std::string search_frame(const ScreenState& state) {
|
|||||||
width, false));
|
width, false));
|
||||||
if (height > 2) lines.push_back(styled(fit_text(
|
if (height > 2) lines.push_back(styled(fit_text(
|
||||||
"Search title, description, location, or calendar ID", width), "2", state.ansi));
|
"Search title, description, location, or calendar ID", width), "2", state.ansi));
|
||||||
if (height > 3) lines.push_back(styled(fit_text(
|
const std::array prompt_hints{
|
||||||
"Enter search Backspace edit Esc cancel", width), "2", state.ansi));
|
KeyHint{"Enter", "search"},
|
||||||
|
KeyHint{"Backspace", "edit"},
|
||||||
|
KeyHint{"Esc", "cancel"},
|
||||||
|
};
|
||||||
|
if (height > 3) lines.push_back(render_hints(prompt_hints, width, state.ansi));
|
||||||
} else {
|
} else {
|
||||||
const auto count = state.search_results.size();
|
const auto count = state.search_results.size();
|
||||||
const auto summary = count == 0
|
const auto summary = count == 0
|
||||||
@@ -568,8 +657,13 @@ std::string search_frame(const ScreenState& state) {
|
|||||||
}
|
}
|
||||||
if (height > 2) lines.push_back(styled(fit_text(
|
if (height > 2) lines.push_back(styled(fit_text(
|
||||||
"Five years either side of the selected date", width), "2", state.ansi));
|
"Five years either side of the selected date", width), "2", state.ansi));
|
||||||
if (height > 1) lines.push_back(styled(fit_text(
|
const std::array results_hints{
|
||||||
"↑/↓ choose Enter jump / new search Esc close", width), "2", state.ansi));
|
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<int>(lines.size()) < height) {
|
while (static_cast<int>(lines.size()) < height) {
|
||||||
@@ -611,7 +705,7 @@ std::string agenda_frame(const ScreenState& state) {
|
|||||||
right.time_of_day.value_or(minutes{-1});
|
right.time_of_day.value_or(minutes{-1});
|
||||||
});
|
});
|
||||||
|
|
||||||
const auto footer_rows = std::min(2, std::max(0, height - 2));
|
const auto footer_rows = 1;
|
||||||
const auto row_capacity = static_cast<std::size_t>(
|
const auto row_capacity = static_cast<std::size_t>(
|
||||||
std::max(0, height - 2 - footer_rows));
|
std::max(0, height - 2 - footer_rows));
|
||||||
const auto selected = std::find(ordered.begin(), ordered.end(), state.agenda_index);
|
const auto selected = std::find(ordered.begin(), ordered.end(), state.agenda_index);
|
||||||
@@ -645,13 +739,15 @@ std::string agenda_frame(const ScreenState& state) {
|
|||||||
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
||||||
}
|
}
|
||||||
if (footer_rows > 0) {
|
if (footer_rows > 0) {
|
||||||
lines.push_back(styled(fit_text(
|
const std::array agenda_hints{
|
||||||
"arrows/jk/Tab choose Enter month n/p 6 weeks",
|
KeyHint{"↑↓", "choose"},
|
||||||
width), "2", state.ansi));
|
KeyHint{"Enter", "month"},
|
||||||
}
|
KeyHint{"n/p", "window"},
|
||||||
if (footer_rows > 1) {
|
KeyHint{"/", "search"},
|
||||||
lines.push_back(styled(fit_text(
|
KeyHint{"c", "calendars"},
|
||||||
"c calendars / search g/Esc close", width), "2", state.ansi));
|
KeyHint{"g", "close"},
|
||||||
|
};
|
||||||
|
lines.push_back(render_hints(agenda_hints, width, state.ansi));
|
||||||
}
|
}
|
||||||
while (static_cast<int>(lines.size()) < height) {
|
while (static_cast<int>(lines.size()) < height) {
|
||||||
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
||||||
@@ -703,8 +799,12 @@ std::string calendar_picker_frame(const std::span<const Calendar> calendars,
|
|||||||
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
||||||
}
|
}
|
||||||
if (height > 1) {
|
if (height > 1) {
|
||||||
lines.push_back(styled(fit_text(
|
const std::array picker_hints{
|
||||||
"↑/↓ choose Enter toggle c/Esc close", width), "2", state.ansi));
|
KeyHint{"↑↓", "choose"},
|
||||||
|
KeyHint{"Enter", "toggle"},
|
||||||
|
KeyHint{"c", "close"},
|
||||||
|
};
|
||||||
|
lines.push_back(render_hints(picker_hints, width, state.ansi));
|
||||||
}
|
}
|
||||||
while (static_cast<int>(lines.size()) < height) {
|
while (static_cast<int>(lines.size()) < height) {
|
||||||
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
||||||
@@ -719,6 +819,118 @@ std::string calendar_picker_frame(const std::span<const Calendar> calendars,
|
|||||||
return output.str();
|
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<std::pair<std::string_view, std::string_view>> 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<const HelpGroup> column_groups,
|
||||||
|
const int column_width) {
|
||||||
|
std::vector<HelpLine> 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<std::size_t>(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<HelpLine> 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<std::size_t>(column_width), ' '),
|
||||||
|
std::string(static_cast<std::size_t>(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<std::string> lines;
|
||||||
|
lines.reserve(static_cast<std::size_t>(height));
|
||||||
|
lines.push_back(styled(centred("KEYBOARD SHORTCUTS", width), "1", state.ansi));
|
||||||
|
if (height > 2) lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
||||||
|
const int content_budget = std::max(0, height - static_cast<int>(lines.size()) - 1);
|
||||||
|
if (static_cast<int>(content.size()) > content_budget) {
|
||||||
|
content.resize(static_cast<std::size_t>(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<std::size_t>(width - plain_width), ' ');
|
||||||
|
}
|
||||||
|
lines.push_back(std::move(line));
|
||||||
|
}
|
||||||
|
while (static_cast<int>(lines.size()) < height - 1) {
|
||||||
|
lines.emplace_back(static_cast<std::size_t>(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<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height));
|
||||||
|
|
||||||
|
std::ostringstream output;
|
||||||
|
for (int line = 0; line < height; ++line) {
|
||||||
|
if (line != 0) output << '\n';
|
||||||
|
output << lines[static_cast<std::size_t>(line)];
|
||||||
|
}
|
||||||
|
return output.str();
|
||||||
|
}
|
||||||
|
|
||||||
std::string compact_frame(std::span<const CalendarItem> items, const ScreenState& state) {
|
std::string compact_frame(std::span<const CalendarItem> items, const ScreenState& state) {
|
||||||
const int width = std::max(1, state.width);
|
const int width = std::max(1, state.width);
|
||||||
const int height = std::max(1, state.height);
|
const int height = std::max(1, state.height);
|
||||||
@@ -773,18 +985,25 @@ std::string compact_frame(std::span<const CalendarItem> items, const ScreenState
|
|||||||
while (static_cast<int>(lines.size()) < height - 1) {
|
while (static_cast<int>(lines.size()) < height - 1) {
|
||||||
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
lines.emplace_back(static_cast<std::size_t>(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<int>(lines.size()) < height) {
|
if (static_cast<int>(lines.size()) < height) {
|
||||||
const auto style = state.notification.empty() ? "2"
|
if (!state.notification.empty()) {
|
||||||
: state.notification_is_error ? "1;31" : "1;32";
|
const auto text = (state.notification_is_error ? std::string{"! "} : std::string{"✓ "}) +
|
||||||
lines.push_back(styled(fit_text(controls, width), style, state.ansi));
|
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<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height));
|
if (static_cast<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height));
|
||||||
|
|
||||||
@@ -848,6 +1067,9 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
|
|||||||
if (state.width < 35 || state.height < 16) {
|
if (state.width < 35 || state.height < 16) {
|
||||||
return compact_frame(items, state);
|
return compact_frame(items, state);
|
||||||
}
|
}
|
||||||
|
if (state.show_help) {
|
||||||
|
return help_frame(state);
|
||||||
|
}
|
||||||
|
|
||||||
const int width = state.width;
|
const int width = state.width;
|
||||||
const int inner_total = width - 8; // eight vertical border glyphs
|
const int inner_total = width - 8; // eight vertical border glyphs
|
||||||
@@ -875,16 +1097,25 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
|
|||||||
}
|
}
|
||||||
|
|
||||||
const auto month_index = static_cast<unsigned>(state.visible_month.month()) - 1;
|
const auto month_index = static_cast<unsigned>(state.visible_month.month()) - 1;
|
||||||
const auto title = "‹ " + std::string{month_names[month_index]} + " " +
|
const auto month_title = std::string{month_names[month_index]} + " " +
|
||||||
std::to_string(static_cast<int>(state.visible_month.year())) + " ›";
|
std::to_string(static_cast<int>(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;
|
std::ostringstream output;
|
||||||
output << styled(centred(title, width), "1", state.ansi) << '\n';
|
output << std::string(static_cast<std::size_t>(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<std::size_t>(width - title_pad - title_width), ' ');
|
||||||
|
}
|
||||||
|
output << '\n';
|
||||||
output << ' ';
|
output << ' ';
|
||||||
for (std::size_t column = 0; column < widths.size(); ++column) {
|
for (std::size_t column = 0; column < widths.size(); ++column) {
|
||||||
const auto name_index = weekday_name_index(state.week_start, column);
|
const auto name_index = weekday_name_index(state.week_start, column);
|
||||||
const auto name = widths[column] >= 9 ? weekday_long[name_index]
|
const auto name = widths[column] >= 9 ? weekday_long[name_index]
|
||||||
: weekday_short[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 << (column + 1 == widths.size() ? ' ' : ' ');
|
||||||
}
|
}
|
||||||
output << '\n' << border(widths, "┌", "┬", "┐") << '\n';
|
output << '\n' << border(widths, "┌", "┬", "┐") << '\n';
|
||||||
@@ -956,26 +1187,54 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
|
|||||||
|
|
||||||
const auto selected_count = by_day.contains(sys_days{state.selected_day})
|
const auto selected_count = by_day.contains(sys_days{state.selected_day})
|
||||||
? by_day.at(sys_days{state.selected_day}).size() : 0U;
|
? by_day.at(sys_days{state.selected_day}).size() : 0U;
|
||||||
std::string status;
|
|
||||||
if (state.show_help) {
|
// Status left, priority-ordered key hints right.
|
||||||
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";
|
const auto friendly_day = [](const year_month_day value) {
|
||||||
} else if (!state.notification.empty()) {
|
return std::string{weekday_short[weekday{sys_days{value}}.iso_encoding() - 1]} + ", " +
|
||||||
status = " " + printable_text(state.notification, true);
|
std::to_string(static_cast<unsigned>(value.day())) + " " +
|
||||||
|
std::string{month_short[static_cast<unsigned>(value.month()) - 1]} + " " +
|
||||||
|
std::to_string(static_cast<int>(value.year()));
|
||||||
|
};
|
||||||
|
std::vector<KeyHint> 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) {
|
} else if (focused != nullptr) {
|
||||||
const auto focus_title = focused->title.empty() ? std::string{"(untitled)"}
|
const auto focus_title = focused->title.empty() ? std::string{"(untitled)"}
|
||||||
: printable_text(focused->title);
|
: printable_text(focused->title);
|
||||||
status = " " + iso_date(state.selected_day) + " " +
|
left_status = friendly_day(state.selected_day) + " · " + focus_title;
|
||||||
focus_title + " Tab next Enter read / search e edit d delete u undo Ctrl-R redo";
|
hints = {{
|
||||||
|
{"Tab", "next"},
|
||||||
|
{"Enter", "read"},
|
||||||
|
{"e", "edit"},
|
||||||
|
{"d", "del"},
|
||||||
|
{"u", "undo"},
|
||||||
|
{"?", "help"},
|
||||||
|
}};
|
||||||
} else {
|
} else {
|
||||||
status = " " + iso_date(state.selected_day) + " " + std::to_string(selected_count) +
|
left_status = friendly_day(state.selected_day) + " · " +
|
||||||
(selected_count == 1 ? " appointment" : " appointments") +
|
std::to_string(selected_count) +
|
||||||
(selected_count > 0 ? " Tab focus" : "") +
|
(selected_count == 1 ? " appointment" : " appointments");
|
||||||
" g agenda c calendars / search a add u undo Ctrl-R redo ? help q quit";
|
if (selected_count > 0) {
|
||||||
|
hints = {{"Tab", "focus"}};
|
||||||
}
|
}
|
||||||
const auto status_style = !state.notification.empty()
|
const std::array right_hints_no_focus{
|
||||||
? (state.notification_is_error ? "1;31" : "1;32")
|
KeyHint{"a", "add"},
|
||||||
: focused != nullptr ? "7" : "2";
|
KeyHint{"g", "agenda"},
|
||||||
output << styled(fit_text(status, width), status_style, state.ansi);
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
output << status_bar(left_status, status_style, hints, width, state.ansi);
|
||||||
return output.str();
|
return output.str();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ void test_create_and_validation()
|
|||||||
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
|
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
|
||||||
editor.focused_field() == nocal::tui::EditorField::title && !editor.error().empty(),
|
editor.focused_field() == nocal::tui::EditorField::title && !editor.error().empty(),
|
||||||
"blank title blocks submission and focuses the invalid field");
|
"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");
|
(void)editor.handle_key("Design review");
|
||||||
focus(editor, nocal::tui::EditorField::end_time);
|
focus(editor, nocal::tui::EditorField::end_time);
|
||||||
@@ -179,6 +183,8 @@ void test_rendering()
|
|||||||
"render fills requested height exactly");
|
"render fills requested height exactly");
|
||||||
check(plain.find("NEW APPOINTMENT") != std::string::npos &&
|
check(plain.find("NEW APPOINTMENT") != std::string::npos &&
|
||||||
plain.find("Ctrl-S save") != 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,
|
plain.find("[ ] timed appointment") != std::string::npos,
|
||||||
"render exposes form identity and controls");
|
"render exposes form identity and controls");
|
||||||
check(plain.find("\x1b[") == std::string::npos, "ANSI can be disabled");
|
check(plain.find("\x1b[") == std::string::npos, "ANSI can be disabled");
|
||||||
|
|||||||
@@ -41,6 +41,21 @@ nocal::Event make_event(std::string uid, std::string title, const nocal::Date st
|
|||||||
return event;
|
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,
|
nocal::tui::ScreenState screen_for(const nocal::Date day, const int width = 100,
|
||||||
const int height = 32)
|
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::next_event);
|
||||||
app.dispatch(nocal::tui::Action::delete_event);
|
app.dispatch(nocal::tui::Action::delete_event);
|
||||||
check(app.delete_confirmation_active(), "delete requires an explicit confirmation step");
|
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 &&
|
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");
|
"delete confirmation names the appointment and shows its keys");
|
||||||
app.handle_input("n");
|
app.handle_input("n");
|
||||||
check(!app.delete_confirmation_active() && events.size() == 2 && save_calls == 0,
|
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";
|
footer_state.focused_event_id = "modal-history";
|
||||||
const auto month_frame = nocal::tui::render_month(events, footer_state);
|
const auto month_frame = nocal::tui::render_month(events, footer_state);
|
||||||
check(month_frame.find("u undo") != std::string::npos &&
|
check(month_frame.find("u undo") != std::string::npos &&
|
||||||
month_frame.find("Ctrl-R redo") != std::string::npos,
|
month_frame.find("Enter read") != std::string::npos &&
|
||||||
"the month footer advertises both history shortcuts");
|
month_frame.find("Tab next") != std::string::npos,
|
||||||
|
"the month footer shows focused hints");
|
||||||
app.dispatch(nocal::tui::Action::delete_event);
|
app.dispatch(nocal::tui::Action::delete_event);
|
||||||
app.handle_input("u");
|
app.handle_input("u");
|
||||||
check(app.delete_confirmation_active() && app.state().notification_is_error &&
|
check(app.delete_confirmation_active() && app.state().notification_is_error &&
|
||||||
@@ -658,6 +676,10 @@ void test_timed_event_details()
|
|||||||
std::string::npos,
|
std::string::npos,
|
||||||
"the reader renders the event description");
|
"the reader renders the event description");
|
||||||
check(frame.find("Esc back") != std::string::npos, "the reader footer explains how to close it");
|
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()
|
void test_all_day_and_multiday_details()
|
||||||
|
|||||||
203
tests/tui_pty_smoke.cpp
Normal file
203
tests/tui_pty_smoke.cpp
Normal file
@@ -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 <pty.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
|
||||||
|
#include <poll.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
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<char*>(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<std::size_t>(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<ssize_t>(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<void>(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;
|
||||||
|
}
|
||||||
@@ -48,6 +48,42 @@ bool ordered(std::string_view line, const std::vector<std::string_view>& labels)
|
|||||||
return true;
|
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::size_t>(std::count_if(line.begin(), line.end(), [](const char c) {
|
||||||
|
return (static_cast<unsigned char>(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<std::size_t>(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::Event all_day_event(std::string uid, std::string title,
|
||||||
nocal::Date start, nocal::Date exclusive_end)
|
nocal::Date start, nocal::Date exclusive_end)
|
||||||
{
|
{
|
||||||
@@ -116,6 +152,9 @@ void test_full_month_render()
|
|||||||
check(std::count(frame.begin(), frame.end(), '\n') == 29,
|
check(std::count(frame.begin(), frame.end(), '\n') == 29,
|
||||||
"renderer fills the requested height deterministically");
|
"renderer fills the requested height deterministically");
|
||||||
check(frame.find("\x1b[") == std::string::npos, "ANSI can be disabled");
|
check(frame.find("\x1b[") == std::string::npos, "ANSI can be disabled");
|
||||||
|
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()
|
void test_compact_and_input()
|
||||||
@@ -352,7 +391,7 @@ void test_agenda_empty_frame()
|
|||||||
{
|
{
|
||||||
using namespace std::chrono;
|
using namespace std::chrono;
|
||||||
auto state = nocal::tui::ScreenState{};
|
auto state = nocal::tui::ScreenState{};
|
||||||
state.width = 64;
|
state.width = 72;
|
||||||
state.height = 9;
|
state.height = 9;
|
||||||
state.ansi = false;
|
state.ansi = false;
|
||||||
state.show_agenda = true;
|
state.show_agenda = true;
|
||||||
@@ -365,7 +404,7 @@ void test_agenda_empty_frame()
|
|||||||
"empty agenda renders its inclusive 42-day range");
|
"empty agenda renders its inclusive 42-day range");
|
||||||
check(frame.find("No appointments in this 6-week window.") != std::string::npos,
|
check(frame.find("No appointments in this 6-week window.") != std::string::npos,
|
||||||
"empty agenda explains that its window has no appointments");
|
"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");
|
"agenda footer explains how to close the view");
|
||||||
check(std::count(frame.begin(), frame.end(), '\n') == 8,
|
check(std::count(frame.begin(), frame.end(), '\n') == 8,
|
||||||
"empty agenda fills the requested height");
|
"empty agenda fills the requested height");
|
||||||
@@ -403,9 +442,10 @@ void test_agenda_selection_and_scrolling()
|
|||||||
|
|
||||||
const auto frame = nocal::tui::render_month(
|
const auto frame = nocal::tui::render_month(
|
||||||
std::span<const nocal::tui::CalendarItem>{}, state);
|
std::span<const nocal::tui::CalendarItem>{}, 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,
|
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,
|
check(frame.find("› 2026-07-13 16:00 local Retrospective — Room 2") != std::string::npos,
|
||||||
"agenda marks the selected row and includes optional location");
|
"agenda marks the selected row and includes optional location");
|
||||||
check(frame.find("\x1b[7;1m") != std::string::npos,
|
check(frame.find("\x1b[7;1m") != std::string::npos,
|
||||||
@@ -433,10 +473,86 @@ void test_agenda_input_and_search_precedence()
|
|||||||
|
|
||||||
} // namespace
|
} // 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<const nocal::tui::CalendarItem>{}, 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<const nocal::tui::CalendarItem>{}, 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<const nocal::tui::CalendarItem>{}, 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<nocal::tui::CalendarItem> 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()
|
int main()
|
||||||
{
|
{
|
||||||
test_full_month_render();
|
test_full_month_render();
|
||||||
test_compact_and_input();
|
test_compact_and_input();
|
||||||
|
test_help_frame();
|
||||||
|
test_status_bar_chrome();
|
||||||
test_sunday_start_full_render_and_domain_window();
|
test_sunday_start_full_render_and_domain_window();
|
||||||
test_wednesday_start_compact_render();
|
test_wednesday_start_compact_render();
|
||||||
test_monday_default_and_invalid_week_start_fallback();
|
test_monday_default_and_invalid_week_start_fallback();
|
||||||
|
|||||||
Reference in New Issue
Block a user