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:
2026-07-20 22:51:28 +01:00
parent 23bcb20ab5
commit 6d9e7b0e3e
7 changed files with 734 additions and 76 deletions

203
tests/tui_pty_smoke.cpp Normal file
View 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;
}