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:
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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::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<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,
|
||||
"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<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()
|
||||
{
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user