Intro
Motivation
As a university student with examinations around the corner, one grows tired of the same Flashcards and Quizlet decks.For this reason, I wanted something that I could use freely as a Terminal User Interface that paired well with the rest of my dotfiles and respective setup. At a later point, this turned to some rivalry based on points scored on Who knows the most Haskell? (I do!)
After finding and contributing to FTXUI1 on Github, I decided to develop Certamen (latin: contest)2.
Scope of This Document
This post traces the full chronological development of Certamen across 105 commits in roughly 5 weeks of development which sprouts off of the initial CLI prototype made back in September 2025 to the current state of the project as of 3rd of April, 2026. This is designed to cover and explain my personal architectural choices, the TUI migration, the SSH server implementation, packaging for multiple platforms, and the refactoring passes that followed.
---://-
I: The CLI Prototype, "Quizzer"
The Initial Version
The project began back in 29th September 2025 under the name Quizzer as a single-file CLI application. With a main.cpp worth ca. 300 lines; it used yaml-cpp3 for quiz serialisation from a top-down mapping sequence with the standard std::cin/std::cout user interaction.
The original data structure worked like so:
struct Question
{
std::string question;
std::vector<std::string> choices;
int answer; // 'answer' is an index onto 'choices'
std::optional<std::string> code;
std::optional<std::string> explain;
};
Quiz files at this stage were flat YAML sequences with question, choices and answer as mandatory fields:
- question: What is 2+2?
choices: [3, 4, 5, 6]
answer: 1
The main menu was rendered trivially:
static int menu_choice(bool randomise)
{
std::cout << "\nQuizzer (Randomise: " << (randomise ? "ON" : "OFF") << ")\n"
"1. Take Quiz\n"
"2. Add Question\n"
"3. Remove Question\n"
"4. Change Answer\n"
"5. List Questions\n"
"6. Save and Exit\n"
"7. Toggle Randomise\n";
return read_int_in_range(1, 7, "Choose (1-7): ");
}
All input was blocking, validated through helper functions such as read_int_in_range, read_line, and read_yes_no to reduce duplications of code. The build system was a straightforward CMakeLists.txt linking yaml-cpp:
cmake_minimum_required(VERSION 3.12)
project(quizzer LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
find_package(yaml-cpp REQUIRED)
add_executable(quizzer main.cpp)
target_link_libraries(quizzer PRIVATE yaml-cpp)
While functional, this is not the end product I wanted for this passion project; I decided it would be more of use to others and myself if there were more highlights, options, and a more entertaining interface in comparison to the traditional black and white CLI. Thus, one decides to write a Terminal UI for this, not Graphics!
Pre-Fixes
The idea of making it a TUI was 5 months after the Quizzer app was built in September. Due to this, a few critical bugs were addressed and a few features were implemented by 2026-03-06 to ease the transition:
-
Ctrl+D hang: The program would enter an infinite loop on
EOFbecausestd::cinstream state was not being checked. - Score display: The quiz result formatting was corrected, alongside which many optimisations were made.
-
Diff before quit: A feature was added to show unsaved changes before exiting, which now developed into a full system to depict differences between
originalandmodifiedstates! -
Explanations and code in list view:
List Questionswas debugged to properly print code blocks and explanations inline, while earlier it had issues with sequencing and showing them.
---://-
II: The TUI Migration
Decomposition
On 2026-03-06, the centralised main.cpp was split into a header/source structure under src/. This was the first step towards at least some modularity:
src/
main.cpp # screen routing
app.hpp # AppState struct, screen enum
model.hpp/.cpp # higher level data structures
syntax.hpp/.cpp
screens/
menu.cpp/.hpp
quiz.cpp/.hpp
quiz_result.cpp/.hpp
add_question.cpp/.hpp
...
Every screen reads/writes to a single mutable state object; that being AppState. The reasoning behind the use of a flat state struct in place of a class hierarchy (OOP) was mainly due to the fact that FTXUI components are closures that capture references, and a single owning struct simplifies lifetime management.
enum class AppScreen
{
MENU,
QUIZ,
QUIZ_RESULT,
ADD_QUESTION,
// ... rest of the main menu options
};
struct AppState
{
std::vector<Question> questions;
bool randomise = false;
std::string status_message;
AppScreen current_screen = AppScreen::MENU;
// ... rest fields for screen-specific state
};
Screen "routing" is implemented through a Container::Tab indexed by the enumeration:
auto tab = Container::Tab(std::move(screens), &screen_index);
auto main_renderer = Renderer(tab, [&] {
screen_index = static_cast<int>(state.current_screen);
return tab->Render() | size(WIDTH, LESS_THAN, 120) | center;
});
This is a state machine! My favourite! where AppScreen is the state and user input events are the transitions. Each screen is a self-contained FTXUI component produced by a factory function e.g. make_menu_screen(AppState&), and screen transitions are performed by mutating state.current_screen.
FTXUI: The Terminal UI Framework
FTXUI1 is the rendering engine used for this project. It is useful in many ways:
-
Declarative rendering: the
Render()lambda returns an element tree each frame; meaning that FTXUI handles diff-ing against the terminal (emulator). -
Component model:
CatchEventwrappers allow composing input handling without subclassing or further abstraction. - Platform compatibility: Linux, and Homebrew (macOS/Windows).
I did not want to move this to more popular alternatives such as
BubbleTeaorLipGloss, etc. since (1) I did not want to switch to a different language and (2) I wanted my code to be (less) indexed by LLMs.
The CMake integration changed from a single-target build to a multi-library link, FTXUI facilitates Make processes by allowing FetchContent:
include(FetchContent)
FetchContent_Declare(ftxui
GIT_REPOSITORY https://github.com/ArthurSonzogni/FTXUI
GIT_TAG v6.1.9
)
FetchContent_MakeAvailable(ftxui)
find_package(yaml-cpp REQUIRED)
find_package(libssh REQUIRED) # ssh to come
file(GLOB_RECURSE SOURCES src/*.cpp)
add_executable(certamen ${SOURCES})
target_link_libraries(certamen
PRIVATE ftxui::screen ftxui::dom ftxui::component
PRIVATE yaml-cpp
PRIVATE ssh # ssh to come
PRIVATE util
)
FTXUI provides three libraries: ftxui::screen, ftxui::dom for the element tree (layout, borders, colours), and ftxui::component for widgets and event handling.
Screen Implementation
Each screen follows the same pattern. Here is the quiz screen as an example, which demonstrates the rendering of questions with code blocks, choice selection, and answer feedback after which a Renderer is returned to spit it out:
ftxui::Component make_quiz_screen(AppState& state)
{
auto focusable = Renderer([](bool) { return text(""); });
auto component = CatchEvent(focusable, [&](Event event) {
// for example if I want to navigate using arrowkeys
if (nav_up_down(event, state.quiz_selected, num_choices)) return true;
// or if I want to use numbers, 0 -> 9.
if (nav_numeric(event, state.quiz_selected, num_choices)) return true;
if (event == Event::Return)
{
state.quiz_answered = true;
state.quiz_was_correct = (state.quiz_selected == q.answer);
if (state.quiz_was_correct) ++state.quiz_score;
return true;
}
// ...
});
return Renderer(component, [&] {
// build element *tree* i.e. header, progress gauge, question, choices, explaination
Elements body;
body.push_back(gauge(progress) | color(Color::Cyan));
body.push_back(paragraph(" " + q.question) | bold);
if (q.code && !q.code->empty())
body.push_back(render_code_block(*q.code, q.language));
for (int i = 0; i < num_choices; ++i)
{
// colour based on selection state and correctness (so if I chose wrong, _RED_; else _GREEN_)
auto choice_el = hbox({
text(marker), text(std::to_string(i + 1) + ". "), text(q.choices[i]),
});
if (state.quiz_answered && is_correct)
choice_el = choice_el | color(Color::Green) | bold;
// ...
}
return vbox(std::move(body)) | borderRounded;
});
}
The Renderer wraps a CatchEvent that processes input and mutates AppState. The rendering lambda reads state each frame to produce the element tree (as shown in Line 21 above). FTXUI diffentiates this against the previous frame and emits only the necessary terminal escape sequences.
Now, in what I said above, I used a lot of jargon; what really happens is:
FTXUI sees "Hey! this has changed!" and it goes "Let's update it and show the user what the program resolved to!"
That is all.
Regardless, this separation of input handling from rendering is what permits the TUI to remain responsive, due to the continous "diffing"4.
Syntax Highlighting Engine
A bespoke highlighter was implemented in syntax.cpp to render code blocks within quiz questions themselves, this way the user has an easier time parsing the code with their eyes. It supports four language families (Haskell, C-family, Python, Rust) with keyword colouring, string literals, numeric literals, comments (line/block), and operators.
The design is a hand-written lexer that tokenises each line:
enum class Lang { Haskell, CFam, Python, Rust, Unknown };
static const std::unordered_set<std::string> haskell_kw = { // for example, for haskell:
"module","where","let","in","if","then","else","case","of","do",
// etc...
};
static Element highlight_line(const std::string& line, Lang lang,
const std::unordered_set<std::string>& kw)
{
Elements parts;
// detect comments, strings, numbers, keywords, operators
// emit coloured text spans for any given token
while (i < len)
{
if (/* line comment */) flush_text(line.substr(i), Color::GrayDark);
if (/* string literal */) flush_text(s, Color::Yellow);
if (/* number */) flush_text(num, Color::Magenta);
if (kw.count(word)) flush_text(word, Color::Cyan);
// ...
}
return hbox(std::move(parts));
}
As you must be already familiar by now,
Color::namesignifies a base set colour which most Terminal Emulators support the rendering of! =)
The code block is then rendered with a language label and border:
ftxui::Element render_code_block(
const std::string& code,
const std::optional<std::string>& language)
{
Lang lang = detect_lang(language);
const auto& kw = keywords_for(lang);
Elements lines;
std::istringstream stream(code);
std::string line;
while (std::getline(stream, line))
lines.push_back(hbox({ text(" "), highlight_line(line, lang, kw) }));
std::string label = " Code";
if (language && !language->empty())
label += " [" + *language + "]";
return vbox({
text(label) | bold | color(Color::Cyan),
vbox(std::move(lines)),
}) | borderRounded | color(Color::GrayLight);
}
This is NOT a production-grade syntax highlighter. It does not handle multi-line strings, heredocs, or nested block comments spanning lines. It handles the common cases sufficient for quiz code snippets, and I did not want to add a linter dependency!
---://-
III: Name Change
Quizzer to Certamen
On 2026-03-26, the project was faithfully renamed from Quizzer to Certamen. The word is Latin for contest or quiz contest, found via Wikipedia2.
This was executed across two commits (09ce38b, d4eb31c). One wonders, what is one of the easiest ways to replace this string accross all these files? With sed:
find . -type f -exec sed -i 's/quizzer/certamen/g' {} +
To explain this, find initially finds all files from the ./ directory i.e. current working directory.
After which, -exec sed -i 's/quizzer/certamen/g' {} + executes sed, which edits each file in place with the aforementioned regex. {} signifies the current file being processed kept in memory, and + groups multiple files together in find.
---://-
IV: The New Quiz Wrapper
YAML Schema Evolution
The original Quizzer used a flat YAML sequence as shown earlier, however, when the TUI was built, the schema was wrapped with some metadata. That being name and author; to accomodate for this, previous question were nested inside questions:
name: Certamen DEMO
author: trintlermint
questions:
- question: Which Haskell functions can calculate the Euclidean norm?
code: |
nrm1 :: Double -> Double -> Double
nrm1 a b = sqrt (a^2 + b^2)
explain: |
Both definitions compute sqrt(a^2 + b^2).
language: haskell
choices:
- Only nrm1 is correct.
- Only nrm2 is correct.
- Both are correct.
- Neither is correct.
answer: 2
The language field was added alongside the syntax highlighter. The Question struct gained it as well:
struct Question
{
// ... rest
std::optional<std::string> language;
};
The QuizFile struct holds the new top-level metadata:
struct QuizFile
{
std::string name;
std::string author;
std::vector<Question> questions;
};
Serialisation uses yaml-cpp's emitter API with YAML::Literal for multi-line code and explanation fields shown with how | is used earlier along with this new emitter:
void save_quiz(const QuizFile& quiz, const std::string& filename)
{
YAML::Emitter out;
out << YAML::BeginMap;
out << YAML::Key << "name" << YAML::Value << quiz.name;
out << YAML::Key << "author" << YAML::Value << quiz.author;
out << YAML::Key << "questions" << YAML::Value;
// emit_questions handles the sequence
out << YAML::EndMap;
std::ofstream file_out(filename);
file_out << out.c_str();
}
---://-
V: Server Shell
Architecture
The Server Shell (SSH) server mode under option certamen serve was the most architecturally ambitious feature. The design forks a child process per client, connected via a PTY (pseudo-terminal), so that the full TUI renders identically over SSH as it does locally.
The current flow behind-the-scenes:
-
serve_mainbinds an SSH socket usinglibssh, this is the listener for connections to the server. - connection =>
fork()creates a handler for the client. -
handle_clientperforms a SSH key exchange, authenticates the user, channel negotiation, and shell requests. -
forkptycreates a PTY pair and forks again; the child then runscertamen --session(itself) viaexecvpwithout asking client. -
bridge_ioshuttles data between the SSH channel and the PTY master usingpoll().
// The core I/O bridge goes as SSH channel and PTY master bidirectionally
static void bridge_io(ssh_channel channel, int master_fd, ssh_session session)
{
char buf[8192];
int ssh_fd = ssh_get_fd(session);
int flags = fcntl(master_fd, F_GETFL, 0);
if (flags >= 0) fcntl(master_fd, F_SETFL, flags | O_NONBLOCK);
while (ssh_channel_is_open(channel) && !ssh_channel_is_eof(channel))
{
struct pollfd fds[2];
fds[0].fd = ssh_fd; fds[0].events = POLLIN;
fds[1].fd = master_fd; fds[1].events = POLLIN;
int ret = poll(fds, 2, 200);
if (ret < 0) { if (errno == EINTR) continue; break; }
// SSH to PTY
if (fds[0].revents & (POLLIN | POLLHUP | POLLERR))
{
ssh_execute_message_callbacks(session);
while (true)
{
int n = ssh_channel_read_nonblocking(channel, buf, sizeof(buf), 0);
if (n > 0) write(master_fd, buf, n);
else break;
}
}
// PTY to SSH
if (fds[1].revents & POLLIN)
{
while (true)
{
ssize_t n = read(master_fd, buf, sizeof(buf));
if (n > 0) ssh_channel_write(channel, buf, n);
else break;
}
}
}
}
The Session Mode
When the server forks a child, it executes certamen --session --metrics /tmp/certamen_metrics_XXXXXX <quiz files>. The --session flag triggers session_main that presents a stripped-down experience, which asks:
- Name: The client enters their display name.
- Picker: If multiple files are loaded, a menu to select one only.
- Quiz: You're in! The standard quiz screen (mid-game).
- Result: Score display, then return to the picker OR disconnect.
int session_main(const std::vector<std::string>& quiz_files,
const std::string& metrics_file)
{
auto screen = ScreenInteractive::Fullscreen();
std::string player_name;
run_name_prompt(player_name, screen);
if (player_name.empty()) return 0;
if (quiz_files.size() == 1)
{
auto [score, total] = run_quiz(quiz_files[0], screen);
write_metrics(metrics_file, player_name, quiz_files[0], score, total);
return 0;
}
while (true)
{
int chosen = run_quiz_picker(quiz_files, player_name, screen);
if (chosen < 0) break;
auto [score, total] = run_quiz(quiz_files[chosen], screen);
write_metrics(metrics_file, player_name, quiz_files[chosen], score, total);
}
return 0;
}
The metrics are written to a temporary file in tmp/ as illustrated earlier. Then read by the parent server process after the child exits, logged to stdout of the actual server, and then cleaned up:
[2026-03-26 20:18:45] METRICS [vanilla]: player=vanilla
[2026-03-26 20:18:45] METRICS [vanilla]: quiz=algebra.yaml
[2026-03-26 20:18:45] METRICS [vanilla]: score=8/10
Terminal Resize Handling
Window resize events from the SSH client are sent through a message callback that updates the PTY dimensions:
static int message_callback(ssh_session, ssh_message msg, void* userdata)
{
int master_fd = *static_cast<int*>(userdata);
if (ssh_message_type(msg) == SSH_REQUEST_CHANNEL &&
ssh_message_subtype(msg) == SSH_CHANNEL_REQUEST_WINDOW_CHANGE)
{
int cols = ssh_message_channel_request_pty_width(msg);
int rows = ssh_message_channel_request_pty_height(msg);
struct winsize ws{};
ws.ws_col = static_cast<unsigned short>(cols);
ws.ws_row = static_cast<unsigned short>(rows);
ioctl(master_fd, TIOCSWINSZ, &ws);
ssh_message_channel_request_reply_success(msg);
return 0;
}
return 1;
}
Platform Concerns
The SSH server uses forkpty()5 which is POSIX-specific. The header inclusion is branched:
#if defined(__linux__)
#include <pty.h>
#elif defined(__APPLE__)
#include <util.h>
#else
// windows: no pty.h/openpty path in this implementation yet
#endif
This is the primary reason Windows builds are marked as continue-on-error: true in the CI pipeline. The local TUI mode works on Windows; the server does not however.
Authentication
The server supports two authentication modes:
- Open (default): Any SSH client connects without a password. The SSH username becomes the player name.
-
Password: Pass
--password <pw>and clients must authenticate, shown below:
# Open
certamen serve quiz.yaml
# Password
certamen serve --password quiznight --port 3000 quiz.yaml
# Client
ssh -p 3000 alice@192.168.1.10
The host RSA key is auto-generated on first run using ssh_pki_generate and stored as certamen_host_rsa with permissions 0600, that is, sudo (root) can read/write, other users cannot however.
---://-
VI: Multi-File Support
The Problem
Initially, Certamen loaded a single YAML file. For a quiz night with multiple topics (say, algebra, history, and most importantly: Haskell), one would need to merge them manually or run separate instances. On 2026-03-28, multi-file support was implemented.
Data Architecture
Each loaded file is tracked by a LoadedFile struct:
struct LoadedFile
{
std::string filename;
std::string name;
std::string author;
std::vector<Question> saved_questions;
std::string saved_name;
std::string saved_author;
};
Questions carry a source_file index (-1 for unassigned). The AppState then holds a flat std::vector<Question> with all questions from all files chosen, along with std::vector<LoadedFile> for per-file metadata. This allows source_file to serve as an index to enable per-file saving and editing for each screen to use.
Screen Routing with File Picker
When multiple files are loaded, editing operations (add, remove, etc.) must target a specific file. The route_to method intercepts navigation:
AppScreen route_to(AppScreen dest)
{
if (loaded_files.size() > 1)
{
pick_file_cursor = 0;
pick_file_then = dest;
return AppScreen::PICK_FILE;
}
target_file = loaded_files.empty() ? -1 : 0;
build_target_indices();
return dest;
}
If only one file is loaded it implies that routing proceeds directly. Else, the user is first sent to a file picker screen, then forwarded to the intended destination. The pick_file_then field stores the deferred target.
Quiz Setup Screen
When taking a quiz with multiple files, the user selects which files to include and in what order in local mode. The QUIZ_SETUP screen has two parts:
- State 0: Toggle inclusion of each file (checkbox-style).
- State 1: Reorder the included files.
The selected questions are then concatenated and fed to start_quiz_from().
One trivially sees that since
Randomisealready juggles around ALL loaded questions and choices, ifRandomiseis enabled, there is no file picker. Perhaps this is unintuitive, and the order of files should still be present? Do let me know!
Unsaved Change Tracking
The diff system compares the current in-memory state against the last-saved snapshot per file:
bool has_unsaved_changes() const
{
for (int i = 0; i < static_cast<int>(loaded_files.size()); ++i)
{
const auto& lf = loaded_files[i];
if (i == 0 && (quiz_name != lf.saved_name || quiz_author != lf.saved_author))
return true;
std::vector<Question> current;
for (const auto& q : questions)
if (q.source_file == i)
current.push_back(q);
if (current != lf.saved_questions)
return true;
}
for (const auto& q : questions)
if (q.source_file < 0) return true;
return false;
}
We do not use the original file due to the fact that the original file may already have been edited mid-session or outside session. As the program has no way to know if this is the case, it saves snapshots instead.
Diff lines are generated with prefix markers [+] for additions, [-] for deletions, [~] for changes in answer/choices, and [0] for no changes, then rendered with colour coding via the shared diff.hpp utility:
inline ftxui::Elements render_diff_lines(const std::vector<std::string>& diff_lines)
{
Elements entries;
for (const auto& line : diff_lines)
{
Color line_color = Color::Default;
if (line.size() > 1 && line[0] == '[')
{
if (line[1] == '+') line_color = Color::Green;
else if (line[1] == '-') line_color = Color::RedLight;
else if (line[1] == '~') line_color = Color::Yellow;
}
entries.push_back(text(" " + line) | color(line_color));
}
return entries;
}
The menu screen displays a modified indicator in yellow when unsaved changes are present.
VII: CI/CD and Packaging
GitHub Actions
On 2026-03-27, a multi-platform CI/CD pipeline was established using GitHub Actions. The workflow triggers on tag pushes (git tag v* && git push origin v*) and builds on three platforms:
jobs:
build-linux:
runs-on: ubuntu-latest
steps:
- name: Install dependencies
run: sudo apt-get install cmake ninja-build libyaml-cpp-dev libssh-dev
- name: Configure
run: cmake --preset release
- name: Build
run: cmake --build --preset release --config Release
- name: Package
run: |
mkdir certamen-linux-x64
cp build/bin/certamen certamen-linux-x64/
tar -czf certamen-linux-x64.tar.gz certamen-linux-x64/
build-macos:
runs-on: macos-latest
# similar, using brew for dependencies
build-windows:
runs-on: windows-latest
continue-on-error: true # SSH server incompatible so far
# uses vcpkg for yaml-cpp and libssh
The release job depends only on Linux and macOS; Windows is optional because the forkpty-based SSH server cannot compile there as mentioned previously. Releases are created automatically via softprops/action-gh-release.
Few _hot_fixes followed the initial CI setup:
-
26bd229: Platform-specific headers inserve.cppwere branched (#if defined(__linux__)/#elif defined(__APPLE__)) to fix macOS builds as was shown earlier in Platform Concers -
cd197e6: CMake presets were added for consistent cross-platform configuration.
Nix Packaging
A Nix flake was contributed by @valyntyler on 2026-03-28, providing reproducible builds:
# flake.nix
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
};
outputs = inputs @ {flake-parts, ...}:
flake-parts.lib.mkFlake {inherit inputs;} {
systems = ["x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin"];
perSystem = {pkgs, ...}: rec {
devShells.default = pkgs.callPackage ./nix/shell.nix {};
packages.certamen = pkgs.callPackage ./nix/package.nix {};
packages.default = packages.certamen;
};
};
}
The package derivation:
# nix/package.nix
{ cmake, stdenv, ftxui, libssh, yaml-cpp }:
let inherit (stdenv) mkDerivation;
in mkDerivation {
pname = "certamen";
version = "1.0.3";
src = ../.;
buildInputs = [ cmake ftxui libssh yaml-cpp ]; # deps
configurePhase = ''cmake -B build -DCMAKE_BUILD_TYPE=Release'';
buildPhase = ''cmake --build build'';
installPhase = ''
mkdir -p $out/bin
cp ./build/bin/certamen $out/bin/certamen
chmod +x $out/bin/certamen
''; # execution
meta.mainProgram = "certamen";
}
This enables imperative installation trivially via:
nix profile add github:trintlermint/certamen#certamen
Several CMake-related fixes followed to ensure the Nix build found ftxui as a system package rather than fetching it via FetchContent. The CMakeLists.txt was updated to attempt find_package(ftxui QUIET) first:
find_package(ftxui QUIET)
if (NOT ftxui_FOUND)
include(FetchContent)
FetchContent_Declare(ftxui
GIT_REPOSITORY https://github.com/ArthurSonzogni/FTXUI
GIT_TAG v6.1.9
)
FetchContent_MakeAvailable(ftxui)
endif()
This is done due to the fact that many consumers of
NixPkgsdo not allow webFetchaccess to programs such asCMakedue to vulnerability concerns.
AUR Packaging
Certamen was published to the Arch Linux User Repository, installable via:
sudo pacman -S certamen
Or through any other AUR helper/wrapper.
This was natural considering I use Arch Linux, btw!
VIII: Manual and QOL Features
In-Game Manual
On 2026-03-31, a built-in manual screen was implemented. Accessible via option 10 on the menu or by pressing 0, it provides a reference for all features, keybindings, and quiz format documentation without leaving the TUI. Which is seemingly rather helpful for new users! Less README!
Codeberg Migration
The repository has also been mirrored to Codeberg due to concerns about licensing, code ownership, and platform ethics. The README is now embossed with dual badges:
Stance on AI
CONTRIBUTING.md was updated with the project's stance on AI-assisted contributions, as bearing the brainmade.org mark, this project accepts NO purely "vibe-coded" contributions.
As shown by this "vibe-coded" pull request rejection.
IX: Code Quality and Refactoring
Shared Utility Headers
After the feature set stabilised, duplicated code patterns were extracted into shared headers:
Navigation (nav.hpp)
Six screen files contained near-identical j/k/arrow-binds/1-9 navigation logic. This was pushed into two inline functions:
inline bool nav_up_down(const ftxui::Event& event, int& selected, int count)
{
if (event == ftxui::Event::ArrowUp || event == ftxui::Event::Character('k'))
{
if (selected > 0) selected--;
return true;
}
if (event == ftxui::Event::ArrowDown || event == ftxui::Event::Character('j'))
{
if (selected < count - 1) selected++;
return true;
}
return false;
}
inline bool nav_numeric(const ftxui::Event& event, int& selected, int count)
{
if (!event.is_character()) return false;
char ch = event.character()[0];
int num = ch - '1';
if (num >= 0 && num < count)
{
selected = num;
return true;
}
return false;
}
This replaced approximately 100 lines of duplicated code across quiz.cpp, change_answer.cpp, remove_question.cpp, edit_choice.cpp, and list_questions.cpp.
Diff Rendering (diff.hpp)
The diff colouring logic was duplicated between save_confirm.cpp and quit_confirm.cpp. The shared render_diff_lines function shown earlier eliminated this.
AppState Helper
The pattern state.current_screen = AppScreen::MENU; state.status_message.clear(); appeared in 15 locations. It was also lovingly compressed:
void return_to_menu()
{
current_screen = AppScreen::MENU;
status_message.clear();
}
Along with this, several other dead code and typos were fixed which appeared throughout the development process
Reflections
The Flat State Decision
The decision to use a single AppState struct with 40+ fields rather than per-screen state objects or a more structured hierarchy was thoughtful. In FTXUI, components are typically closures capturing references. A single struct means that any given screen can read any state it needs without ownership loop-de-loops! The tradeoff is that AppState is large and its fields are loosely grouped by comments rather than enforced by rigor in types.
For a project of this scale (ca. 4200 lines across 30 source files), this is acceptable. A larger project would benefit from per-screen state structs composed within AppState, or message-passing architecture. The current design was chosen because it permitted rapid iteration during the intensive short term development sprint due to my current examinations.
The PTY Fork Design
The SSH server's design of forking the entire binary with --session and bridging via PTY is rather unconventional, however effective. The alternative would be to run the TUI rendering in-process and translate FTXUI's output to SSH channel writes. This would require either:
- A custom
Screenimplementation that writes to an SSH channel instead of stdout, or - Intercepting FTXUI's terminal output at the file descriptor level.
Both approaches are brittle and couple the server tightly to FTXUI's internals, making it difficult for someone to contribute openly, or for a system administrator to understand. The forkpty + execvp approach treats the TUI as a "black box":
Does it work locally? then it must it over SSH. (hmmm . . . .)
The cost is a process per client, which is negligible for the expected concurrency (I dont imagine having a gigantic quiz night with 200 people, yet).
Specifically, there is absolutely no way I am convincing 200 people to play quizzes with me over the terminal.
Conclusion
Certamen began as a 300-line CLI tool for quizzing myself on Haskell functions on my initial quartile 1 exams, and within a few weeks after months, it grew into a pile of 4200-line TUI application with syntax highlighting, multi-file quiz sessions, SSH multiplayer, cross-platform CI/CD, and packaging for AUR and Nix.
The codebase is full of spiky edges. There are NO automated tests which makes Quality Assessment a nightmare; the state struct is more centralised than I enjoy myself; the Windows build is perpetually broken. These are understandable costs for a project whose primary purpose is serving as a personal Free and Open Source utility.
Certamen means contest. The real contest, as it seems to turn out, was finishing this project before the next exam season!
For information see my blog post on Certamen!
-
FTXUI: Functional Terminal (X) User Interface. See ArthurSonzogni/FTXUI on GitHub, version 6.1.9 used. ↩
-
See the Wikipedia article on Certamen, Latin for contest or quiz competition. ↩
-
yaml-cpp: A YAML parser and emitter for C++. See jbeder/yaml-cpp on GitHub. ↩
-
FTXUI uses a declarative rendering model: the component returns an
Elementtree each frame, and the framework computes the minimal set of terminal escape sequences to update the display. This is conceptually similar to React's virtual DOM diffing. ↩ -
The
forkptyapproach was inspired by howttydand similar web-terminal tools expose TUI applications over HTTP/WebSocket by bridging PTY I/O. ↩
Top comments (0)