Skip to content

A gallery of interface patterns a terminal application needs ​

A gallery of interface patterns a terminal application needs.

A command palette, notifications, a table with a details drawer, a form with validation, a task runner and a confirmation dialog. Every motion has a job: it shows where something came from, what changed, or how long is left. Nothing moves only to move.

Try it: press Ctrl+K anywhere and type. Sort and filter the table, then press Enter on a row. Submit the form empty. Run the tasks.

Fullscreen Demo
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 gallery of interface patterns a terminal application needs.
//
// A command palette, notifications, a table with a details drawer, a form
// with validation, a task runner and a confirmation dialog. Every motion has
// a job: it shows where something came from, what changed, or how long is
// left. Nothing moves only to move.
//
// Try it: press Ctrl+K anywhere and type. Sort and filter the table, then
// press Enter on a row. Submit the form empty. Run the tasks.
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstddef>
#include <cstdio>
#include <rtxui/rtxui.hpp>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

using namespace rtxui;

namespace {

// Whether `pattern`'s letters appear in `text` in order, ignoring case.
bool FuzzyMatch(std::string_view pattern, std::string_view text) {
  const auto lower = [](char c) {
    return std::tolower(static_cast<unsigned char>(c));
  };
  size_t at = 0;
  for (const char c : pattern) {
    while (at < text.size() && lower(text[at]) != lower(c)) {
      ++at;
    }
    if (at == text.size()) {
      return false;
    }
    ++at;
  }
  return true;
}

bool Contains(std::string_view text, std::string_view part) {
  return std::search(text.begin(), text.end(), part.begin(), part.end(),
                     [](char a, char b) {
                       return std::tolower(static_cast<unsigned char>(a)) ==
                              std::tolower(static_cast<unsigned char>(b));
                     }) != text.end();
}

std::string Fixed(double value, int decimals) {
  char text[32];
  std::snprintf(text, sizeof(text), "%.*f", decimals, value);
  return text;
}

}  // namespace

// ---------------------------------------------------------------------------
// Table and drawer: sort, filter, move with the arrows, open a row.

struct Process {
  int pid = 0;
  std::string name;
  std::string user;
  double cpu = 0;
  double memory = 0;  // MB
  std::string started;
  std::string command;
};

class TablePage : public Component<TablePage> {
 public:
  std::vector<Process> processes = {
      {4121, "clang++", "ada", 98.2, 1240, "12:04",
       "clang++ -O2 -c layout.cpp"},
      {2210, "chrome", "ada", 12.5, 3120, "09:12", "/opt/google/chrome/chrome"},
      {3301, "postgres", "postgres", 6.1, 512, "08:00",
       "postgres -D /var/lib/pg"},
      {1874, "node", "ada", 4.8, 680, "10:41", "node server.js --port 8080"},
      {1022, "dockerd", "root", 2.2, 210, "08:00", "/usr/bin/dockerd"},
      {2950, "nvim", "ada", 0.9, 96, "11:30", "nvim src/screen.cpp"},
      {812, "sshd", "root", 0.1, 8, "08:00", "sshd: /usr/sbin/sshd -D"},
      {1, "systemd", "root", 0.0, 12, "08:00", "/sbin/init splash"},
  };

  std::string filter;
  std::string sort = "cpu";
  bool descending = true;
  int selected = 0;
  bool drawer_open = false;
  std::vector<Process> rows;

  void SortBy(const std::string& column) {
    descending = column == sort ? !descending : column != "name";
    sort = column;
  }
  void Open(const std::string& index) {
    selected = std::stoi(index);
    drawer_open = true;
  }
  void Close() { drawer_open = false; }

  std::string arrow(std::string_view column) const {
    return sort != column ? "" : descending ? " ▾" : " ▴";
  }
  std::string name_arrow() const { return arrow("name"); }
  std::string cpu_arrow() const { return arrow("cpu"); }
  std::string mem_arrow() const { return arrow("mem"); }
  std::string cursor_offset() const { return std::to_string(selected); }
  std::string drawer_class() const {
    return drawer_open && !rows.empty() ? "drawer open" : "drawer";
  }
  std::string count() const {
    return std::to_string(rows.size()) + " of " +
           std::to_string(processes.size()) + " processes";
  }

  // The selected process, for the drawer.
  const Process& current() const {
    static const Process kNone;
    return rows.empty() ? kNone : rows[selected];
  }
  std::string d_name() const { return current().name; }
  std::string d_pid() const { return std::to_string(current().pid); }
  std::string d_user() const { return current().user; }
  std::string d_cpu() const { return Fixed(current().cpu, 1) + "%"; }
  std::string d_cpu_value() const {
    return std::to_string(static_cast<int>(current().cpu));
  }
  std::string d_memory() const { return Fixed(current().memory, 0) + " MB"; }
  std::string d_started() const { return current().started; }
  std::string d_command() const { return current().command; }

  TablePage() {
    Bind(rows, &TablePage::RowFields);
    Bind(filter);
    Bind(drawer_open);
    Bind(name_arrow);
    Bind(cpu_arrow);
    Bind(mem_arrow);
    Bind(cursor_offset);
    Bind(drawer_class);
    Bind(count);
    Bind(d_name);
    Bind(d_pid);
    Bind(d_user);
    Bind(d_cpu);
    Bind(d_cpu_value);
    Bind(d_memory);
    Bind(d_started);
    Bind(d_command);
    Bind(Close);
    Bind(SortBy);
    Bind(Open);
  }

  // The visible rows follow the filter and the sort before every render.
  bool Digest() override {
    rows.clear();
    for (const Process& process : processes) {
      if (Contains(process.name, filter) || Contains(process.user, filter)) {
        rows.push_back(process);
      }
    }
    std::stable_sort(rows.begin(), rows.end(),
                     [&](const Process& a, const Process& b) {
                       if (sort == "name") {
                         return descending ? a.name > b.name : a.name < b.name;
                       }
                       const double x = sort == "cpu" ? a.cpu : a.memory;
                       const double y = sort == "cpu" ? b.cpu : b.memory;
                       return descending ? x > y : x < y;
                     });
    const int count = static_cast<int>(rows.size());
    selected = count == 0 ? 0 : std::clamp(selected, 0, count - 1);
    return Component<TablePage>::Digest();
  }

  bool OnEvent(Event event) override {
    if (event == Event::ArrowDown()) {
      selected++;
      return true;
    }
    if (event == Event::ArrowUp()) {
      selected = std::max(0, selected - 1);
      return true;
    }
    if (event == Event::Return()) {
      drawer_open = true;
      return true;
    }
    if (event == Event::Escape() && drawer_open) {
      drawer_open = false;
      return true;
    }
    return Component<TablePage>::OnEvent(event);
  }

  void RowFields(const Process& process, FieldSink& out) const {
    out("pid", std::to_string(process.pid));
    out("name", process.name);
    out("user", process.user);
    out("cpu", Fixed(process.cpu, 1));
    out("mem", Fixed(process.memory, 0));
    out("started", process.started);
  }

  std::string_view view = R"html(
      <div class="page">
        <div class="toolbar">
          <input value="{filter}" placeholder="Filter by name or user" />
          <span class="count">{count}</span>
        </div>
        <div class="table" tabindex="0">
          <div class="row head">
            <span class="pid">PID</span>
            <span class="name sortable" onclick="SortBy(name)">Name{name_arrow}</span>
            <span class="user">User</span>
            <span class="num sortable" onclick="SortBy(cpu)">CPU %{cpu_arrow}</span>
            <span class="num sortable" onclick="SortBy(mem)">Mem MB{mem_arrow}</span>
            <span class="started">Started</span>
          </div>
          <div class="body">
            <div class="cursor" style="translate: 0 {cursor_offset};"></div>
            <for each="{rows}" as="p" key="{p.pid}">
              <div class="row" onclick="Open({$index})">
                <span class="pid">{p.pid}</span>
                <span class="name">{p.name}</span>
                <span class="user">{p.user}</span>
                <span class="num">{p.cpu}</span>
                <span class="num">{p.mem}</span>
                <span class="started">{p.started}</span>
              </div>
            </for>
          </div>
        </div>
        <div class="hint">↑ ↓ to move · Enter to open · Esc to close · click a header to sort</div>

        <div class="{drawer_class}">
          <div class="drawer-head">
            <span class="drawer-title">{d_name}</span>
            <span class="close" onclick="Close">esc ✕</span>
          </div>
          <div class="kv"><span>PID</span><span>{d_pid}</span></div>
          <div class="kv"><span>User</span><span>{d_user}</span></div>
          <div class="kv"><span>Started</span><span>{d_started}</span></div>
          <div class="kv"><span>Memory</span><span>{d_memory}</span></div>
          <div class="kv"><span>CPU</span><span>{d_cpu}</span></div>
          <progress value="{d_cpu_value}" max="100" width="30" />
          <div class="label">Command</div>
          <div class="command">{d_command}</div>
        </div>
      </div>
      <style>
        self {
          display: block;
          height: 100%;
        }
        .page {
          position: relative;
          display: flex;
          flex-direction: column;
          gap: 1;
          height: 100%;
        }
        .toolbar {
          display: flex;
          align-items: center;
          gap: 2;
        }
        input {
          width: 34;
          border: tall;
          border-color: var(--border);
          background-color: var(--surface);
          transition: border-color 150ms;
        }
        input:focus {
          border-color: var(--accent);
        }
        .count {
          color: var(--muted);
        }
        .table {
          border: tall;
          border-color: var(--border);
          background-color: var(--surface);
          transition: border-color 150ms;
        }
        .table:focus-within {
          border-color: var(--accent);
        }
        /* Above the highlight, which is drawn first. */
        .row {
          position: relative;
          z-index: 1;
          display: flex;
          gap: 2;
          padding: 0 1;
        }
        .row.head {
          color: var(--muted);
          border-bottom: solid;
          border-color: var(--border);
        }
        .sortable:hover {
          color: var(--text);
        }
        .body {
          position: relative;
        }
        /* One highlight for the whole list, moved rather than redrawn: the
           eye follows it from the old row to the new one. */
        .cursor {
          position: absolute;
          top: 0;
          left: 0;
          width: 100%;
          height: 1;
          background-color: var(--raised);
          border-left: tall;
          border-color: var(--accent);
          transition: translate 120ms ease-out;
        }
        .pid { width: 6; color: var(--muted); }
        .name { flex-grow: 1; }
        .user { width: 9; color: var(--muted); }
        .num { width: 9; text-align: right; }
        .started { width: 8; color: var(--muted); }
        .hint {
          color: var(--muted);
        }

        /* The drawer slides over the table's edge instead of replacing the
           page, so the list it came from stays in view. */
        .drawer {
          position: absolute;
          z-index: 2;
          top: 0;
          right: 0;
          width: 38;
          height: 100%;
          display: flex;
          flex-direction: column;
          border: tall;
          border-color: var(--accent);
          background-color: var(--raised);
          padding: 1 2;
          translate: calc(100% + 4);
          transition: translate 220ms ease-out;
        }
        .drawer.open {
          translate: 0;
        }
        .drawer-head {
          display: flex;
          justify-content: space-between;
          margin-bottom: 1;
        }
        .drawer-title {
          color: var(--accent);
          font-weight: bold;
        }
        .close {
          color: var(--muted);
        }
        .close:hover {
          color: var(--text);
        }
        .kv {
          display: flex;
          justify-content: space-between;
        }
        .kv span:first-child {
          color: var(--muted);
        }
        progress {
          color: var(--accent);
          background-color: var(--border);
          margin: 1 0;
        }
        .label {
          color: var(--muted);
        }
        .command {
          color: var(--code);
        }

        @media (max-width: 99) {
          .user, .started {
            display: none;
          }
        }
      </style>
    )html";
};

// ---------------------------------------------------------------------------
// Form: errors appear under the field they are about, once it matters.

class FormPage : public Component<FormPage> {
 public:
  std::string name;
  std::string email;
  std::string role = "developer";
  bool updates = true;
  bool submitted = false;  // Errors show from the first attempt on.
  bool saved = false;

  bool name_error() const { return submitted && name.empty(); }
  bool email_error() const {
    return submitted && email.find('@') == std::string::npos;
  }
  std::string name_class() const { return name_error() ? "invalid" : ""; }
  std::string email_class() const { return email_error() ? "invalid" : ""; }
  std::string summary() const {
    return name + " <" + email + ">, " + role +
           (updates ? ", gets updates" : "");
  }

  void Submit() {
    submitted = true;
    saved = !name_error() && !email_error();
    saved_name_ = name;
    saved_email_ = email;
  }

  FormPage() {
    Bind(name);
    Bind(email);
    Bind(role);
    Bind(updates);
    Bind(submitted);
    Bind(saved);
    Bind(name_error);
    Bind(email_error);
    Bind(name_class);
    Bind(email_class);
    Bind(summary);
    Bind(Submit);
  }

  // Editing a saved form makes it unsaved again.
  bool Digest() override {
    if (saved && (name != saved_name_ || email != saved_email_)) {
      saved = false;
    }
    return Component<FormPage>::Digest();
  }

  std::string_view view = R"html(
      <div class="form">
        <div class="field">
          <span class="label">Name</span>
          <div class="control">
            <input class="{name_class}" value="{name}" placeholder="Ada Lovelace" />
            <if condition="{name_error}"><span class="error">Enter a name.</span></if>
          </div>
        </div>
        <div class="field">
          <span class="label">Email</span>
          <div class="control">
            <input class="{email_class}" value="{email}" placeholder="ada@example.com" />
            <if condition="{email_error}"><span class="error">An email address has an @.</span></if>
          </div>
        </div>
        <div class="field">
          <span class="label">Role</span>
          <select value="{role}">
            <option value="developer">Developer</option>
            <option value="designer">Designer</option>
            <option value="manager">Manager</option>
          </select>
        </div>
        <div class="field">
          <span class="label"></span>
          <checkbox checked="{updates}">Send me release notes</checkbox>
        </div>
        <div class="field">
          <span class="label"></span>
          <button onclick="Submit">Create account</button>
        </div>
        <if condition="{saved}">
          <div class="saved">✓ Created {summary}.</div>
        </if>
      </div>
      <style>
        @keyframes appear {
          from { opacity: 0; }
          to { opacity: 1; }
        }
        .form {
          display: flex;
          flex-direction: column;
          gap: 1;
          width: 70;
        }
        .field {
          display: flex;
          gap: 2;
        }
        .label {
          width: 8;
          color: var(--muted);
          padding-top: 1;
        }
        .control {
          display: flex;
          flex-direction: column;
        }
        input {
          width: 40;
          border: tall;
          border-color: var(--border);
          background-color: var(--surface);
          transition: border-color 150ms;
        }
        input:focus {
          border-color: var(--accent);
        }
        input.invalid {
          border-color: var(--danger);
        }
        .error {
          color: var(--danger);
          animation: appear 150ms ease-out;
        }
        select {
          width: 40;
          margin-top: 1;
        }
        checkbox {
          margin-top: 1;
        }
        button {
          border: tall;
          border-color: var(--accent);
          background-color: var(--bg);
          color: var(--accent);
          padding: 0 2;
          transition: background-color 150ms, color 150ms;
        }
        button:hover, button:focus {
          background-color: var(--accent);
          color: var(--bg);
        }
        .saved {
          color: var(--success);
          animation: appear 200ms ease-out;
        }
      </style>
    )html";

 private:
  std::string saved_name_;
  std::string saved_email_;
};

// ---------------------------------------------------------------------------
// Tasks: what runs now, what is done, and how far along the whole is.

struct Step {
  std::string name;
  double seconds = 0;             // How long it pretends to take.
  std::string state = "pending";  // "pending" | "running" | "done"
};

class TasksPage : public Component<TasksPage> {
 public:
  std::vector<Step> steps = {
      {"Resolve dependencies", 0.6}, {"Download 14 packages", 1.4},
      {"Compile 212 files", 2.0},    {"Run 818 tests", 1.2},
      {"Package release", 0.6},
  };
  bool running = false;
  bool finished = false;

  std::string progress() const {
    double done = 0;
    double total = 0;
    for (const Step& step : steps) {
      total += step.seconds;
      done += step.state == "done" ? step.seconds : 0;
    }
    return std::to_string(static_cast<int>(100 * done / total));
  }
  std::string run_label() const { return finished ? "Run again" : "Run"; }
  std::string total() const {
    double seconds = 0;
    for (const Step& step : steps) {
      seconds += step.seconds;
    }
    return Fixed(seconds, 1) + " s";
  }

  void Run() {
    if (running) {
      return;
    }
    for (Step& step : steps) {
      step.state = "pending";
    }
    running = true;
    finished = false;
    Next(Ref<TasksPage>(this), 0);
  }

  TasksPage() {
    Bind(steps, &TasksPage::StepFields);
    Bind(running);
    Bind(finished);
    Bind(progress);
    Bind(run_label);
    Bind(total);
    Bind(Run);
  }

  void StepFields(const Step& step, FieldSink& out) const {
    out("name", step.name);
    out("state", step.state);
    out("icon", step.state == "done"      ? "✓"
                : step.state == "running" ? "●"
                                          : "○");
    out("time", step.state == "done" ? Fixed(step.seconds, 1) + " s" : "");
  }

  std::string_view view = R"html(
      <div class="tasks">
        <div class="steps">
          <for each="{steps}" as="s">
            <div class="step {s.state}">
              <span class="icon">{s.icon}</span>
              <span class="step-name">{s.name}</span>
              <span class="time">{s.time}</span>
            </div>
          </for>
        </div>
        <div class="track"><div class="bar" style="width: {progress}%;"></div></div>
        <div class="footer">
          <button onclick="Run">{run_label}</button>
          <if condition="{finished}"><span class="done">✓ Done in {total}.</span></if>
        </div>
      </div>
      <style>
        @keyframes appear {
          from { opacity: 0; }
          to { opacity: 1; }
        }
        @keyframes pulse {
          from { opacity: 1; }
          to { opacity: 0.3; }
        }
        .tasks {
          display: flex;
          flex-direction: column;
          gap: 1;
          width: 56;
        }
        .steps {
          border: tall;
          border-color: var(--border);
          background-color: var(--surface);
          padding: 1 2;
        }
        .step {
          display: flex;
          gap: 1;
          color: var(--muted);
          transition: color 200ms;
        }
        .step-name {
          flex-grow: 1;
        }
        .step.running {
          color: var(--text);
        }
        /* A pulse says "busy" without a spinner's noise. */
        .step.running .icon {
          color: var(--accent);
          animation: pulse 500ms ease-in-out infinite alternate;
        }
        .step.done {
          color: var(--text);
        }
        .step.done .icon {
          color: var(--success);
        }
        .time {
          color: var(--muted);
        }
        .track {
          height: 1;
          background-color: var(--border);
        }
        /* The bar eases to each new value instead of jumping. */
        .bar {
          height: 1;
          background-color: var(--accent);
          transition: width 400ms ease-out;
        }
        .footer {
          display: flex;
          align-items: center;
          gap: 2;
        }
        button {
          border: tall;
          border-color: var(--accent);
          background-color: var(--bg);
          color: var(--accent);
          padding: 0 2;
          transition: background-color 150ms, color 150ms;
        }
        button:hover, button:focus {
          background-color: var(--accent);
          color: var(--bg);
        }
        .done {
          color: var(--success);
          animation: appear 200ms ease-out;
        }
      </style>
    )html";

 private:
  // Starts step `index`, and schedules the one after. `self` keeps the page
  // alive while a step runs, even if the user moved to another page.
  static void Next(Ref<TasksPage> self, size_t index) {
    if (index == self->steps.size()) {
      self->running = false;
      self->finished = true;
      return;
    }
    self->steps[index].state = "running";
    PostDelayedTask(
        [self, index] {
          self->steps[index].state = "done";
          Next(self, index + 1);
        },
        std::chrono::milliseconds(
            static_cast<int>(self->steps[index].seconds * 1000)));
  }
};

// ---------------------------------------------------------------------------
// Confirmation: a destructive action asks first, and the safe answer is the
// default.

struct Branch {
  std::string name;
  std::string age;
  bool leaving = false;
};

class DialogPage : public Component<DialogPage> {
 public:
  std::vector<Branch> branches = {
      {"feature/inline-mode", "merged 2 days ago"},
      {"fix/resize-race", "merged 5 days ago"},
      {"experiment/old-ui", "merged 3 weeks ago"},
  };
  bool confirm_open = false;
  std::string pending;
  std::string status = "Delete a merged branch.";

  void Ask(const std::string& index) {
    pending = branches[std::stoul(index)].name;
    confirm_open = true;
  }
  void Cancel() {
    confirm_open = false;
    status = "Kept " + pending + ".";
  }
  void Delete() {
    confirm_open = false;
    status = "Deleted " + pending + ".";
    for (Branch& branch : branches) {
      branch.leaving = branch.leaving || branch.name == pending;
    }
    // The row fades out, then leaves the list.
    Ref<DialogPage> self(this);
    PostDelayedTask(
        [self] {
          std::erase_if(self->branches,
                        [](const Branch& branch) { return branch.leaving; });
        },
        std::chrono::milliseconds(250));
  }
  std::string title() const { return "Delete " + pending + "?"; }

  DialogPage() {
    Bind(branches, &DialogPage::BranchFields);
    Bind(confirm_open);
    Bind(pending);
    Bind(status);
    Bind(title);
    Bind(Cancel);
    Bind(Delete);
    Bind(Ask);
  }

  void BranchFields(const Branch& branch, FieldSink& out) const {
    out("name", branch.name);
    out("age", branch.age);
    out("class", branch.leaving ? "branch leaving" : "branch");
  }

  std::string_view view = R"html(
      <div class="branches">
        <div class="list">
          <for each="{branches}" as="b" key="{b.name}">
            <div class="{b.class}">
              <span class="branch-name">{b.name}</span>
              <span class="age">{b.age}</span>
              <span class="delete" onclick="Ask({$index})">Delete</span>
            </div>
          </for>
        </div>
        <div class="status">{status}</div>
      </div>
      <dialog open="{confirm_open}" title="{title}">
        <p class="warning">Commits that are on no other branch are gone for good.</p>
        <if condition="{confirm_open}">
          <div class="actions">
            <button onclick="Cancel" autofocus="">Cancel</button>
            <button class="danger" onclick="Delete">Delete branch</button>
          </div>
        </if>
      </dialog>
      <style>
        @keyframes fade-in {
          from { opacity: 0; }
          to { opacity: 1; }
        }
        .branches {
          display: flex;
          flex-direction: column;
          gap: 1;
          width: 70;
        }
        .list {
          border: tall;
          border-color: var(--border);
          background-color: var(--surface);
          padding: 1 1;
        }
        .branch {
          display: flex;
          gap: 2;
          padding: 0 1;
          transition: opacity 250ms, background-color 150ms;
        }
        .branch:hover {
          background-color: var(--raised);
        }
        .branch.leaving {
          opacity: 0;
        }
        .branch-name {
          flex-grow: 1;
        }
        .age {
          color: var(--muted);
        }
        .status {
          color: var(--muted);
        }
        .delete {
          color: var(--danger);
          padding: 0 1;
          transition: background-color 150ms, color 150ms;
        }
        .delete:hover {
          background-color: var(--danger);
          color: var(--bg);
        }
        button {
          border: tall;
          border-color: var(--border);
          background-color: var(--bg);
          color: var(--text);
          padding: 0 2;
          transition: background-color 150ms, color 150ms, border-color 150ms;
        }
        button:hover, button:focus {
          border-color: var(--accent);
          color: var(--accent);
        }
        button.danger {
          color: var(--danger);
        }
        button.danger:hover, button.danger:focus {
          border-color: var(--danger);
          background-color: var(--danger);
          color: var(--bg);
        }
        dialog::part(dialog-box) {
          width: 52;
          max-width: 52;
          border: tall;
          border-color: var(--danger);
          background-color: var(--raised);
          padding: 1 2;
          animation: fade-in 100ms ease-out;
        }
        dialog::part(dialog-header) {
          color: var(--text);
          border-color: var(--border);
        }
        .warning {
          color: var(--muted);
          margin-bottom: 1;
        }
        .actions {
          display: flex;
          gap: 2;
          justify-content: flex-end;
          width: 100%;
        }
        .actions button {
          flex-shrink: 0;
        }
      </style>
    )html";
};

// ---------------------------------------------------------------------------
// The gallery: navigation, plus the two patterns every page shares -- the
// command palette and the notifications.

struct Page {
  std::string icon;
  std::string name;
  std::string title;
  std::string description;
};

struct Command {
  std::string title;
  std::string hint;
};

struct Toast {
  int id = 0;
  std::string kind;  // "success" | "warning" | "error"
  std::string title;
  std::string body;
  bool leaving = false;
};

class Showcase : public Component<Showcase> {
 public:
  std::vector<Page> pages = {
      {"❯", "Palette", "Command palette",
       "Every action, one keystroke away. Press Ctrl+K on any page."},
      {"●", "Notifications", "Notifications",
       "Feedback that comes and goes on its own. The bar shows the time left."},
      {"▤", "Table", "Table and drawer",
       "Sort, filter, move with the arrows. A row opens beside the list."},
      {"✎", "Form", "Form",
       "Errors appear under the field they are about, once it matters."},
      {"◷", "Tasks", "Tasks",
       "What runs now, what is done, and how far along the whole is."},
      {"◇", "Dialog", "Confirmation",
       "A destructive action asks first. The safe answer is the default."},
  };
  int page = 0;

  // Command palette.
  std::vector<Command> commands;
  bool palette_open = false;
  std::string query;
  std::vector<Command> matches;
  int selected = 0;
  std::string last_command = "None yet";

  // Notifications.
  std::vector<Toast> toasts;

  bool on_palette() const { return page == 0; }
  bool on_notifications() const { return page == 1; }
  bool on_table() const { return page == 2; }
  bool on_form() const { return page == 3; }
  bool on_tasks() const { return page == 4; }
  bool on_dialog() const { return page == 5; }
  std::string title() const { return pages[page].title; }
  std::string description() const { return pages[page].description; }
  std::string cursor_offset() const { return std::to_string(selected); }

  void Go(const std::string& index) { page = std::stoi(index); }
  void OpenPalette() {
    palette_open = true;
    query.clear();
    selected = 0;
  }
  void RunCommand(const std::string& index) {
    const size_t i = std::stoul(index);
    if (i < matches.size()) {
      Execute(matches[i].title);
    }
    palette_open = false;
  }
  void Notify(const std::string& kind) {
    if (kind == "success") {
      Push("success", "Saved", "Your changes are on disk.");
    } else if (kind == "warning") {
      Push("warning", "Disk almost full", "12 GB left on /home.");
    } else {
      Push("error", "Build failed", "layout.cpp:42: expected ';'.");
    }
  }
  void Dismiss(const std::string& id) { Leave(std::stoi(id)); }

  Showcase() {
    for (const Page& p : pages) {
      commands.push_back({"Go to " + p.title, "page"});
    }
    commands.push_back({"Notify: saved", "toast"});
    commands.push_back({"Notify: disk almost full", "toast"});
    commands.push_back({"Notify: build failed", "toast"});
    commands.push_back({"Clear notifications", "toast"});

    Bind(pages, &Showcase::PageFields);
    Bind(matches, &Showcase::CommandFields);
    Bind(toasts, &Showcase::ToastFields);
    Bind(page);
    Bind(palette_open);
    Bind(query);
    Bind(last_command);
    Bind(on_palette);
    Bind(on_notifications);
    Bind(on_table);
    Bind(on_form);
    Bind(on_tasks);
    Bind(on_dialog);
    Bind(title);
    Bind(description);
    Bind(cursor_offset);
    Bind(OpenPalette);
    Bind(Go);
    Bind(RunCommand);
    Bind(Notify);
    Bind(Dismiss);
    Import<TablePage>();
    Import<FormPage>();
    Import<TasksPage>();
    Import<DialogPage>();
    // Save this file with a different template or style, and the running
    // application picks it up without a rebuild.
    EnableHotReload();
  }

  // The palette's matches follow the query before every render.
  bool Digest() override {
    matches.clear();
    for (const Command& command : commands) {
      if (FuzzyMatch(query, command.title)) {
        matches.push_back(command);
      }
    }
    const int count = static_cast<int>(matches.size());
    selected = count == 0 ? 0 : std::clamp(selected, 0, count - 1);
    return Component<Showcase>::Digest();
  }

  // Keys nothing focused used: the palette's shortcut and its navigation.
  bool OnEvent(Event event) override {
    if (event == Event::CtrlK()) {
      OpenPalette();
      return true;
    }
    if (palette_open) {
      if (event == Event::ArrowDown()) {
        selected++;
        return true;
      }
      if (event == Event::ArrowUp()) {
        selected = std::max(0, selected - 1);
        return true;
      }
      if (event == Event::Return()) {
        RunCommand(std::to_string(selected));
        return true;
      }
      if (event == Event::Escape()) {
        palette_open = false;
        return true;
      }
    }
    return Component<Showcase>::OnEvent(event);
  }

  void PageFields(const Page& item, FieldSink& out) const {
    out("icon", item.icon);
    out("name", item.name);
    out("class", &item == &pages[page] ? "nav active" : "nav");
  }
  void CommandFields(const Command& command, FieldSink& out) const {
    out("title", command.title);
    out("hint", command.hint);
  }
  void ToastFields(const Toast& toast, FieldSink& out) const {
    out("id", std::to_string(toast.id));
    out("class", "toast " + toast.kind + (toast.leaving ? " leaving" : ""));
    out("icon", toast.kind == "success"   ? "✓"
                : toast.kind == "warning" ? "!"
                                          : "✕");
    out("title", toast.title);
    out("body", toast.body);
  }

  std::string_view view = R"html(
      <div class="app">
        <div class="sidebar">
          <div class="brand"><span class="logo">◆</span><span class="label"> RTXUI</span></div>
          <for each="{pages}" as="item">
            <div class="{item.class}" onclick="Go({$index})">
              <span>{item.icon}</span><span class="label">{item.name}</span>
            </div>
          </for>
          <div class="grow"></div>
          <div class="shortcut label">Commands <kbd>Ctrl K</kbd></div>
        </div>

        <div class="main">
          <h1>{title}</h1>
          <p class="description">{description}</p>

          <if condition="{on_palette}">
            <div class="page">
              <button onclick="OpenPalette">Open the command palette</button>
              <div class="last"><span class="muted">Last command</span> {last_command}</div>
            </div>
          </if>
          <elif condition="{on_notifications}">
            <div class="page buttons">
              <button onclick="Notify(success)">Save</button>
              <button onclick="Notify(warning)">Check disk</button>
              <button onclick="Notify(error)">Build</button>
            </div>
          </elif>
          <elif condition="{on_table}">
            <div class="page grow"><TablePage /></div>
          </elif>
          <elif condition="{on_form}">
            <div class="page"><FormPage /></div>
          </elif>
          <elif condition="{on_tasks}">
            <div class="page"><TasksPage /></div>
          </elif>
          <else>
            <div class="page"><DialogPage /></div>
          </else>
        </div>
      </div>

      <dialog class="palette" open="{palette_open}" title="">
        <if condition="{palette_open}">
          <input value="{query}" placeholder="Type a command" autofocus="" />
        </if>
        <div class="results">
          <div class="cursor" style="translate: 0 {cursor_offset};"></div>
          <for each="{matches}" as="m" key="{m.title}">
            <div class="result" onclick="RunCommand({$index})">
              <span>{m.title}</span><span class="muted">{m.hint}</span>
            </div>
          </for>
        </div>
      </dialog>

      <div class="toasts">
        <for each="{toasts}" as="t" key="{t.id}">
          <div class="{t.class}" onclick="Dismiss({t.id})">
            <div class="toast-head"><span class="icon">{t.icon}</span><span class="toast-title">{t.title}</span></div>
            <div class="toast-body">{t.body}</div>
            <div class="timer"></div>
          </div>
        </for>
      </div>

      <style>
        @keyframes fade-in {
          from { opacity: 0; }
          to { opacity: 1; }
        }
        @keyframes slide-in {
          from { opacity: 0; translate: calc(100% + 2); }
          to { opacity: 1; translate: 0; }
        }
        @keyframes drain {
          from { width: 100%; }
          to { width: 0; }
        }

        self {
          --bg: rgb(13, 17, 23);
          --surface: rgb(22, 27, 34);
          --raised: rgb(31, 38, 47);
          --border: rgb(48, 54, 61);
          --muted: rgb(139, 148, 158);
          --text: rgb(230, 237, 243);
          --accent: rgb(88, 166, 255);
          --success: rgb(63, 185, 80);
          --warning: rgb(210, 153, 34);
          --danger: rgb(248, 81, 73);
          --code: rgb(255, 166, 87);

          display: block;
          width: 100%;
          height: 100%;
          background-color: var(--bg);
          color: var(--text);
        }
        .app {
          display: flex;
          height: 100%;
        }
        .muted {
          color: var(--muted);
        }

        .sidebar {
          display: flex;
          flex-direction: column;
          width: 22;
          background-color: var(--surface);
          border-right: tall;
          border-color: var(--border);
          padding: 1 1;
        }
        .brand {
          font-weight: bold;
          margin: 0 1 1 1;
        }
        .logo {
          color: var(--accent);
        }
        .nav {
          display: flex;
          gap: 1;
          padding: 0 1;
          color: var(--muted);
          border-left: tall;
          border-color: var(--surface);
          transition: background-color 150ms, color 150ms, border-color 150ms;
        }
        .nav:hover {
          background-color: var(--raised);
          color: var(--text);
        }
        .nav.active {
          color: var(--text);
          border-color: var(--accent);
          background-color: var(--raised);
        }
        .grow {
          flex-grow: 1;
        }
        .shortcut {
          color: var(--muted);
          padding: 0 1;
        }
        kbd {
          color: var(--text);
          background-color: var(--raised);
        }

        .main {
          display: flex;
          flex-direction: column;
          flex-grow: 1;
          padding: 1 3;
        }
        h1 {
          color: var(--text);
          font-weight: bold;
          text-decoration: none;
        }
        .description {
          color: var(--muted);
          margin-bottom: 1;
        }
        .buttons {
          display: flex;
          gap: 2;
        }
        .last {
          margin-top: 1;
        }
        button {
          border: tall;
          border-color: var(--border);
          background-color: var(--bg);
          color: var(--text);
          padding: 0 2;
          transition: border-color 150ms, color 150ms;
        }
        button:hover, button:focus {
          border-color: var(--accent);
          color: var(--accent);
        }

        dialog.palette::part(dialog-overlay) {
          align-items: flex-start;
          padding-top: 4;
        }
        dialog.palette::part(dialog-header) {
          display: none;
        }
        dialog.palette::part(dialog-box) {
          width: 60;
          max-width: 60;
          border: tall;
          border-color: var(--accent);
          background-color: var(--raised);
          padding: 0 1 1 1;
          animation: fade-in 100ms ease-out;
        }
        .palette input {
          width: 100%;
          background-color: var(--raised);
          border-bottom: solid;
          border-color: var(--border);
          margin-bottom: 1;
        }
        .results {
          position: relative;
          height: 10;
        }
        /* One highlight, moved rather than redrawn, so the eye can follow
           it from one result to the next. */
        .cursor {
          position: absolute;
          top: 0;
          left: 0;
          width: 100%;
          height: 1;
          background-color: rgb(40, 52, 68);
          border-left: tall;
          border-color: var(--accent);
          transition: translate 120ms ease-out;
        }
        /* Above the highlight, which is drawn first. */
        .result {
          position: relative;
          z-index: 1;
          display: flex;
          justify-content: space-between;
          padding: 0 1;
        }

        .toasts {
          position: fixed;
          right: 2;
          bottom: 1;
          width: 38;
          display: flex;
          flex-direction: column;
          gap: 1;
        }
        /* Toasts slide in from the edge they live on, and fade when they
           go, so their coming and going never jolts the page. */
        .toast {
          border: tall;
          border-color: var(--border);
          background-color: var(--raised);
          padding: 0 2;
          animation: slide-in 220ms ease-out;
          transition: opacity 250ms;
        }
        .toast.leaving {
          opacity: 0;
        }
        .toast-head {
          display: flex;
          gap: 1;
        }
        .toast-title {
          font-weight: bold;
        }
        .toast-body {
          color: var(--muted);
        }
        /* The time left before it closes on its own. */
        .timer {
          margin-top: 1;
          border-top: tall;
          width: 100%;
          animation: drain 4s linear forwards;
        }
        .toast.success .icon, .toast.success .timer { color: var(--success); border-color: var(--success); }
        .toast.warning .icon, .toast.warning .timer { color: var(--warning); border-color: var(--warning); }
        .toast.error .icon, .toast.error .timer { color: var(--danger); border-color: var(--danger); }

        @media (max-width: 99) {
          .sidebar {
            width: 8;
          }
          .label {
            display: none;
          }
        }
      </style>
    )html";

 private:
  void Execute(const std::string& command) {
    last_command = command;
    for (size_t i = 0; i < pages.size(); ++i) {
      if (command == "Go to " + pages[i].title) {
        page = static_cast<int>(i);
      }
    }
    if (command == "Notify: saved") {
      Notify("success");
    } else if (command == "Notify: disk almost full") {
      Notify("warning");
    } else if (command == "Notify: build failed") {
      Notify("error");
    } else if (command == "Clear notifications") {
      for (const Toast& toast : toasts) {
        Leave(toast.id);
      }
    }
  }

  void Push(std::string kind, std::string title, std::string body) {
    const int id = ++last_toast_id_;
    toasts.push_back({id, std::move(kind), std::move(title), std::move(body)});
    PostDelayedTask([this, id] { Leave(id); }, std::chrono::seconds(4));
  }

  // Fades the toast out, then removes it.
  void Leave(int id) {
    for (Toast& toast : toasts) {
      if (toast.id == id) {
        toast.leaving = true;
      }
    }
    PostDelayedTask(
        [this, id] {
          std::erase_if(toasts, [id](const Toast& t) { return t.id == id; });
        },
        std::chrono::milliseconds(250));
  }

  int last_toast_id_ = 0;
};

int main() {
  auto app = Ref<Showcase>::New();
  Screen screen(app);
  screen.SetInline(false);
  screen.Loop();
  return 0;
}