A complete application driven by the keyboard
A complete application driven by the keyboard.
Where app_dashboard.cpp is about composing layout and state, this one is about the two things a terminal UI lives or dies by: scrolling a list that is longer than the viewport, and moving a selection through it without a mouse. Rows carry tabindex, so RTXUI's built-in focus navigation walks them and scrolls the focused one into view; the preview pane re-renders from computed values as the selection moves.
Try it: move with the arrow keys (or j/k via the buttons), Enter to open a directory, Backspace to go up. The list scrolls to follow the selection.
cpp
// Copyright 2026 Arthur Sonzogni. All rights reserved.
// Use of this source code is governed by the MIT license that can be found in
// the LICENSE file.
//
// A complete application driven by the keyboard.
//
// Where app_dashboard.cpp is about composing layout and state, this one is
// about the two things a terminal UI lives or dies by: scrolling a list that
// is longer than the viewport, and moving a selection through it without a
// mouse. Rows carry `tabindex`, so RTXUI's built-in focus navigation walks
// them and scrolls the focused one into view; the preview pane re-renders
// from computed values as the selection moves.
//
// Try it: move with the arrow keys (or j/k via the buttons), Enter to open a
// directory, Backspace to go up. The list scrolls to follow the selection.
#include <rtxui/rtxui.hpp>
#include <string>
#include <vector>
using namespace rtxui;
namespace {
struct FileEntry {
std::string name;
std::string kind; // "dir" | "file"
std::string size;
std::string modified;
bool selected = false;
bool operator==(const FileEntry& other) const = default;
};
// A small fixed tree, so the example stays self-contained and deterministic
// rather than depending on whatever happens to be on disk.
struct Directory {
std::string path;
std::vector<FileEntry> entries;
};
const std::vector<Directory>& Tree() {
static const std::vector<Directory> tree = {
{"/",
{
{"src", "dir", "-", "2026-08-01"},
{"docs", "dir", "-", "2026-07-28"},
{"example", "dir", "-", "2026-08-09"},
{"CMakeLists.txt", "file", "14 kB", "2026-08-09"},
{"README.md", "file", "6.2 kB", "2026-08-09"},
{"LICENSE", "file", "1.1 kB", "2026-01-12"},
}},
{"/src",
{
{"rtxui", "dir", "-", "2026-08-09"},
{"dummy.cpp", "file", "42 B", "2026-03-04"},
}},
{"/docs",
{
{"guide", "dir", "-", "2026-08-09"},
{"index.md", "file", "2.4 kB", "2026-08-09"},
{"css_reference.md", "file", "31 kB", "2026-08-09"},
{"html_reference.md", "file", "18 kB", "2026-08-09"},
{"reactivity.md", "file", "7.8 kB", "2026-08-09"},
}},
{"/example",
{
{"app_dashboard.cpp", "file", "9.1 kB", "2026-08-09"},
{"app_filebrowser.cpp", "file", "8.4 kB", "2026-08-09"},
{"counter.cpp", "file", "2.8 kB", "2026-08-09"},
{"helloworld.cpp", "file", "1.4 kB", "2026-08-09"},
{"playground.cpp", "file", "7.0 kB", "2026-08-09"},
{"transitions.cpp", "file", "2.6 kB", "2026-08-09"},
{"grid.cpp", "file", "2.2 kB", "2026-08-09"},
{"lists.cpp", "file", "3.9 kB", "2026-08-09"},
{"table.cpp", "file", "6.1 kB", "2026-08-09"},
{"tooltip.cpp", "file", "4.3 kB", "2026-08-09"},
{"sticky.cpp", "file", "4.6 kB", "2026-08-09"},
{"markdown.cpp", "file", "6.8 kB", "2026-08-09"},
}},
};
return tree;
}
} // namespace
class FileBrowser : public Component<FileBrowser> {
public:
std::string path = "/";
int cursor = 0;
std::vector<FileEntry> entries = Tree().front().entries;
// --- Computed values -----------------------------------------------------
std::string current_path() const { return path; }
std::string entry_count() const {
return std::to_string(entries.size()) + " items";
}
std::string selected_name() const {
const FileEntry* entry = Selected();
return entry ? entry->name : "-";
}
std::string selected_kind() const {
const FileEntry* entry = Selected();
return entry ? (entry->kind == "dir" ? "Directory" : "File") : "-";
}
std::string selected_size() const {
const FileEntry* entry = Selected();
return entry ? entry->size : "-";
}
std::string selected_modified() const {
const FileEntry* entry = Selected();
return entry ? entry->modified : "-";
}
bool selected_is_dir() const {
const FileEntry* entry = Selected();
return entry && entry->kind == "dir";
}
bool can_go_up() const { return path != "/"; }
// --- Handlers ------------------------------------------------------------
void MoveDown() { SetCursor(cursor + 1); }
void MoveUp() { SetCursor(cursor - 1); }
void Open() {
const FileEntry* entry = Selected();
if (!entry || entry->kind != "dir") {
return;
}
std::string next =
path == "/" ? "/" + entry->name : path + "/" + entry->name;
Navigate(next);
}
void GoUp() {
if (path == "/") {
return;
}
size_t slash = path.find_last_of('/');
Navigate(slash == 0 ? "/" : path.substr(0, slash));
}
std::string_view view = R"html(
<div class="app">
<div class="header">
<span class="brand">FILES</span>
<span class="path">{current_path}</span>
<span class="spacer"></span>
<span class="count">{entry_count}</span>
</div>
<div class="body">
<div class="listing" tabindex="0">
<for each="{entries}" as="entry">
<div class="row {entry.row_class}" tabindex="0" onclick="Select({$index})">
<span class="glyph {entry.kind}">{entry.glyph}</span>
<span class="name">{entry.name}</span>
<span class="size">{entry.size}</span>
<span class="modified">{entry.modified}</span>
</div>
</for>
</div>
<div class="preview">
<span class="preview-name">{selected_name}</span>
<span class="preview-kind">{selected_kind}</span>
<div class="kv"><span class="k">Size</span><span class="v">{selected_size}</span></div>
<div class="kv"><span class="k">Modified</span><span class="v">{selected_modified}</span></div>
<if condition="{selected_is_dir}">
<button class="primary" onclick="Open">Open directory</button>
</if>
<if condition="{can_go_up}">
<button onclick="GoUp">Go up</button>
</if>
</div>
</div>
<div class="footer">
<button class="key" onclick="MoveUp">k / up</button>
<button class="key" onclick="MoveDown">j / down</button>
<span class="spacer"></span>
<span class="hint">the list scrolls to keep the selection visible</span>
</div>
</div>
<style>
self {
--bg: rgb(13, 17, 23);
--surface: rgb(22, 27, 34);
--raised: rgb(31, 38, 47);
--border: rgb(48, 54, 61);
--text: rgb(230, 237, 243);
--muted: rgb(139, 148, 158);
--accent: rgb(88, 166, 255);
display: block;
width: 100%;
height: 100%;
background-color: var(--bg);
color: var(--text);
}
.app {
display: flex;
flex-direction: column;
height: 100%;
}
.header {
display: flex;
align-items: center;
gap: 2;
background-color: var(--surface);
border-bottom: solid;
border-color: var(--border);
padding: 0 2;
}
.brand {
color: var(--accent);
font-weight: bold;
}
.path {
color: var(--text);
}
.count {
color: var(--muted);
}
.spacer {
flex-grow: 1;
}
.body {
display: flex;
flex-grow: 1;
gap: 1;
padding: 1 2;
}
/* The list is deliberately shorter than its content: scroll-behavior
smooth plus the browser-style scroll-into-view on focus is what
keeps the selected row visible as the cursor moves. */
.listing {
display: flex;
flex-direction: column;
flex-grow: 1;
border: tall;
border-color: var(--border);
background-color: var(--surface);
padding: 0 1;
overflow-y: scroll;
scroll-behavior: smooth;
}
.row {
display: flex;
gap: 1;
align-items: center;
padding: 0 1;
transition: background-color 0.15s ease;
}
.row:hover {
background-color: var(--raised);
}
.row.selected, .row:focus {
background-color: var(--raised);
border-left: tall;
border-color: var(--accent);
}
.glyph.dir {
color: rgb(210, 153, 34);
}
.glyph.file {
color: var(--muted);
}
.name {
flex-grow: 1;
}
.size {
width: 10;
color: var(--muted);
text-align: right;
}
.modified {
width: 12;
color: var(--muted);
text-align: right;
}
.preview {
display: flex;
flex-direction: column;
border: tall;
border-color: var(--border);
background-color: var(--surface);
padding: 1 2;
width: 30;
}
.preview-name {
color: var(--accent);
font-weight: bold;
}
.preview-kind {
color: var(--muted);
margin-bottom: 1;
}
.kv {
display: flex;
justify-content: space-between;
width: 100%;
}
.k { color: var(--muted); }
.v { font-weight: bold; }
button {
border: tall;
border-color: var(--border);
background-color: var(--bg);
color: var(--text);
padding: 0 1;
margin-top: 1;
text-align: center;
transition: background-color 0.15s ease, border-color 0.15s ease,
color 0.15s ease;
}
button:hover, button:focus {
border-color: var(--accent);
color: var(--accent);
}
button:active {
background-color: var(--accent);
color: var(--bg);
}
button.primary {
border-color: var(--accent);
color: var(--accent);
}
.footer {
display: flex;
align-items: center;
gap: 2;
border-top: solid;
border-color: var(--border);
background-color: var(--surface);
color: var(--muted);
padding: 0 2;
}
.footer button.key {
margin-top: 0;
}
.hint {
color: var(--border);
}
</style>
)html";
FileBrowser() {
BindCollection("entries", &entries, [](const FileEntry& entry) {
return std::make_shared<ManualStructVisitor>(
std::map<std::string, std::string, std::less<>>{
{"name", entry.name},
{"kind", entry.kind},
{"size", entry.size},
{"modified", entry.modified},
{"glyph", entry.kind == "dir" ? "/" : "."},
{"row_class", entry.selected ? "selected" : ""},
});
});
Bind(path);
Bind(cursor);
Bind(current_path);
Bind(entry_count);
Bind(selected_name);
Bind(selected_kind);
Bind(selected_size);
Bind(selected_modified);
Bind(selected_is_dir);
Bind(can_go_up);
Bind(MoveDown);
Bind(MoveUp);
Bind(Open);
Bind(GoUp);
Import("Select",
[this](std::string index_str) { SetCursor(std::stoi(index_str)); });
SetCursor(0);
}
// Arrow keys drive the cursor; Enter opens; Backspace goes up. Returning
// true marks the event handled so it doesn't also reach the focus system.
bool OnEvent(Event event) override {
if (!event.is<Event::Keyboard>()) {
return Component<FileBrowser>::OnEvent(event);
}
auto key = event.get<Event::Keyboard>();
if (key.motion != Event::Keyboard::Motion::Pressed &&
key.motion != Event::Keyboard::Motion::Repeat) {
return Component<FileBrowser>::OnEvent(event);
}
switch (key.special) {
case Event::Keyboard::Special::ArrowDown:
MoveDown();
return false; // let focus move too, so the list scrolls to follow
case Event::Keyboard::Special::ArrowUp:
MoveUp();
return false;
case Event::Keyboard::Special::Return:
Open();
return true;
case Event::Keyboard::Special::Backspace:
GoUp();
return true;
default:
break;
}
if (key.codepoint == 'j') {
MoveDown();
return true;
}
if (key.codepoint == 'k') {
MoveUp();
return true;
}
return Component<FileBrowser>::OnEvent(event);
}
private:
const FileEntry* Selected() const {
if (cursor < 0 || cursor >= static_cast<int>(entries.size())) {
return nullptr;
}
return &entries[cursor];
}
void SetCursor(int index) {
if (index < 0 || index >= static_cast<int>(entries.size())) {
return;
}
for (FileEntry& entry : entries) {
entry.selected = false;
}
entries[index].selected = true;
cursor = index;
}
void Navigate(const std::string& next) {
for (const Directory& directory : Tree()) {
if (directory.path == next) {
path = next;
entries = directory.entries;
SetCursor(0);
return;
}
}
}
};
int main() {
auto app = Ref<FileBrowser>::New();
Screen screen(app);
screen.Loop();
return 0;
}- Source file:
example/app_filebrowser.cpp - Standalone terminal: ⛶ Open Fullscreen
- Guide: Relevant Documentation
- ← Back to Examples Index

