Build a terminal-based Markdown editor and live viewer using FTXUI, intended for plain-text planning documents (notes, TODOs, design plans).
The project must be:
- Implemented from scratch
- Written in modern C++ (see §5 for standard details)
- Structured as a reusable, open-source library
- Cross-platform (Windows, macOS, Linux)
- Backed by tests for every feature
- Provide two UI components:
- A lightweight Markdown Editor
- A semantic Markdown Viewer
- Render both components side by side in a demo application
- Viewer must update immediately on editor changes (with debounce — see §10)
- Focus on readability, not full Markdown compliance
The following are intentionally excluded:
- Tables
- Images
- HTML blocks
- Footnotes
- Math / LaTeX
- Syntax highlighting for programming languages
- Bidirectional editor ↔ viewer mapping
- Cursor-to-AST synchronization
- Ordered lists (numbered
1. 2. 3.— only unordered bullet lists are supported) - Fenced code blocks (
```) — only inline code is supported - Horizontal rules (
---,***) - Nested lists (only single-level bullet lists)
- File I/O (load/save) — the demo works with in-memory text only
- Undo/redo beyond what FTXUI's Input provides natively
| Platform | Toolchain | Minimum version |
|---|---|---|
| Windows | MSVC + CMake | Visual Studio 2022 17.8 |
| macOS | Clang + CMake + make | Clang 17 |
| Linux | GCC or Clang + CMake | GCC 13 / Clang 17 |
- Minimum CMake 3.25
cmake -S . -B build -DCMAKE_CXX_STANDARD=23
cmake --build build
ctest --test-dir build
- No platform-specific UI code
- No POSIX-only APIs
- No compiler extensions
The following C++23 features are expected to be used. The project does not depend on features with spotty compiler support beyond these:
std::expected(error handling in parser)std::string_view(pervasive)std::optional,std::variant(AST node types)- Deducing
this(if useful for CRTP-free patterns, optional) constexprstandard library improvements
If a C++23 feature is unavailable on a target compiler, fall back to C++20 equivalents (e.g., use a Result<T,E> alias over tl::expected as a polyfill for std::expected).
- Raw owning pointers
- Manual memory management (
new/delete) - C-style casts
- Preprocessor macros for logic
- Global mutable state
std::string_viewfor non-owning string parametersstd::optional,std::variant,std::expected- RAII everywhere
- Value types over pointer indirection where practical
- Explicit ownership via
std::unique_ptronly when polymorphism requires it constexprwhere appropriate
- FTXUI — DOM-based rendering only (no Canvas)
- Integrated via CMake FetchContent
- cmark-gfm — https://github.com/github/cmark-gfm
- Integrated via CMake FetchContent
- Must be fully abstracted behind
MarkdownParser(§8.3) — no cmark types leak outsideparser_cmark.cpp
- CTest only — no external test framework
- Each test is a standalone executable that returns 0 on success, non-zero on failure
- A minimal
test_helper.hppprovidesASSERT_EQ,ASSERT_TRUE, etc. as simple macros - Registered via
add_test()in CMake
markdown-ui/
├── CMakeLists.txt
├── markdown/
│ ├── CMakeLists.txt
│ ├── include/markdown/
│ │ ├── editor.hpp
│ │ ├── viewer.hpp
│ │ ├── parser.hpp # MarkdownParser interface
│ │ ├── ast.hpp # MarkdownAST and node types
│ │ ├── dom_builder.hpp # AST → FTXUI DOM
│ │ └── highlight.hpp # Lexical syntax highlighting
│ ├── src/
│ │ ├── editor.cpp
│ │ ├── viewer.cpp
│ │ ├── parser_cmark.cpp # cmark-gfm implementation of MarkdownParser
│ │ ├── dom_builder.cpp
│ │ └── highlight.cpp
│ └── tests/
│ ├── CMakeLists.txt
│ ├── test_helper.hpp # Minimal assert macros for CTest
│ ├── test_parser.cpp # Parser → AST correctness
│ ├── test_dom_builder.cpp# AST → FTXUI DOM correctness
│ ├── test_highlight.cpp # Editor lexical highlighting
│ ├── test_headings.cpp
│ ├── test_bold.cpp
│ ├── test_italic.cpp
│ ├── test_bold_italic.cpp
│ ├── test_links.cpp
│ ├── test_lists.cpp
│ ├── test_code.cpp
│ ├── test_quotes.cpp
│ ├── test_mixed.cpp
│ ├── test_unicode.cpp
│ └── test_edge_cases.cpp # Empty, whitespace, malformed input
├── demo/
│ ├── CMakeLists.txt
│ └── main.cpp
└── .github/
└── workflows/
└── ci.yml # GitHub Actions: Windows, macOS, Linux
Responsibilities
- Wrap
ftxui::Inputas the text entry mechanism - Apply lexical (non-semantic) syntax highlighting
- Expose the current text content as
std::string const& - Support standard FTXUI Input behavior: cursor movement, line wrapping, multi-line editing
What the editor does NOT do
- Parse Markdown
- Validate Markdown correctness
- Provide undo/redo beyond FTXUI's built-in behavior
- Provide custom key bindings
Highlighting rules (lexical only)
Highlight the following characters with a distinct color/dim style when they appear in raw text:
*,_(emphasis markers)`(code markers)[,],(,)(link markers)#at the start of a line (heading markers)>at the start of a line (blockquote markers)-at the start of a line followed by a space (list markers)
Highlighting is implemented in highlight.hpp/.cpp as a pure function:
// Takes raw text, returns FTXUI Elements with highlighted characters
ftxui::Element highlight_markdown_syntax(std::string_view text);Interface sketch
class Editor {
public:
explicit Editor();
// Returns the FTXUI component for embedding in a layout
ftxui::Component component();
// Current document text (read-only reference)
std::string const& content() const;
// Set content programmatically (e.g., for testing)
void set_content(std::string text);
};Responsibilities
- Accept Markdown text
- Parse it into a
MarkdownAST - Convert the AST into FTXUI DOM nodes via
DomBuilder - Render the result in a scrollable container
Rendering rules
| Markdown construct | FTXUI rendering |
|---|---|
# Heading 1 |
ftxui::bold, large-style (all caps or ═ underline) |
## Heading 2 |
ftxui::bold |
### Heading 3–6 |
ftxui::bold | ftxui::dim |
**bold** |
ftxui::bold |
*italic* / _italic_ |
ftxui::italic |
***bold italic*** |
ftxui::bold | ftxui::italic |
[text](url) |
ftxui::underlined (URL discarded in display) |
- item |
• prefix + indentation |
`code` |
ftxui::inverted (reversed foreground/background) |
> quote |
ftxui::borderLeft or │ prefix + ftxui::dim |
| Plain text | ftxui::paragraph |
Interface sketch
class Viewer {
public:
explicit Viewer(std::unique_ptr<MarkdownParser> parser);
// Update the displayed content. Triggers re-parse + re-render.
void set_content(std::string_view markdown);
// Returns the FTXUI component for embedding in a layout
ftxui::Component component();
};// ast.hpp
enum class NodeType {
Document,
Heading, // level: 1–6
Paragraph,
Text,
Emphasis, // italic
Strong, // bold
StrongEmphasis,// bold + italic
Link, // url stored in node
ListItem,
BulletList,
CodeInline,
BlockQuote,
SoftBreak,
HardBreak,
};
struct ASTNode {
NodeType type;
std::string text; // leaf text content
std::string url; // for Link nodes
int level = 0; // for Heading nodes (1–6)
std::vector<ASTNode> children; // child nodes
};
// The full AST is simply the root Document node
using MarkdownAST = ASTNode;// parser.hpp
class MarkdownParser {
public:
virtual ~MarkdownParser() = default;
virtual MarkdownAST parse(std::string_view input) = 0;
};MarkdownASTis a library-defined tree ofASTNodevalues- cmark-gfm types are completely hidden inside
parser_cmark.cpp - Enables future parser replacement by swapping the implementation
Converts a MarkdownAST into an ftxui::Element tree.
// dom_builder.hpp
class DomBuilder {
public:
// Convert an AST into an FTXUI element tree ready for rendering
ftxui::Element build(MarkdownAST const& ast);
};Responsibilities
- Walk the AST recursively
- Map each
NodeTypeto the corresponding FTXUI DOM construction (per the table in §8.2) - Handle nesting (e.g., bold text inside a list item inside a blockquote)
- Produce a single
ftxui::Elementsuitable for embedding in avbox/ scrollable container
This is a pure transformation: AST in, Element out. No state, no side effects.
Editor and Viewer are intentionally decoupled.
- Editor works on raw UTF-8 text (FTXUI handles rendering)
- Viewer parses the same raw text via cmark-gfm (which operates on UTF-8)
- No shared character index mapping between editor and viewer
- cmark-gfm reports byte offsets
- UTF-8 characters may span multiple bytes
- Terminal rendering uses grapheme width (wcwidth)
- Mapping bytes → terminal columns is complex and fragile
- Unicode-safe rendering in both panes
- No offset synchronization bugs
- Simpler design
- Two panes: Editor (left), Viewer (right)
- Shared document state: a single
std::stringowned by the demo's main function - Viewer updates when editor content changes
- Debounce: viewer re-parses at most once per 50ms to avoid flicker during fast typing
- Focus: editor pane is focused by default; viewer is display-only (not focusable)
- Exit:
Ctrl+CorCtrl+Qexits the application
// In main.cpp
std::string document_text;
Editor editor;
Viewer viewer(std::make_unique<CmarkParser>());
// On each FTXUI render cycle:
// 1. Read editor.content()
// 2. If changed since last viewer update AND debounce elapsed:
// viewer.set_content(editor.content());
// 3. Render both side by side┌─ Markdown Editor ────────┐┌─ Markdown Viewer ─────────┐
│ # Plan ││ ═══════════════ │
│ ││ PLAN │
│ > Focus on **important** ││ ═══════════════ │
│ tasks ││ │
│ ││ │ Focus on important tasks │
│ - Write *code* ││ │
│ - Review `tests` ││ • Write code │
│ - Read [docs](url) ││ • Review tests │
│ ││ • Read docs │
└──────────────────────────┘└────────────────────────────┘
Each phase builds on the previous, adds exactly one capability, and includes tests.
Scope
- Top-level
CMakeLists.txtwith FetchContent for FTXUI and cmark-gfm - Library target
markdown-uiundermarkdown/ - Test target under
markdown/tests/ - Demo target under
demo/ - GitHub Actions CI config for Windows (MSVC), macOS (Clang), Linux (GCC)
Deliverable
cmake --build buildsucceeds on all three platformsctest --test-dir buildruns and passes (a single trivial test)
Test
#include "test_helper.hpp"
int main() {
ASSERT_TRUE(true);
return 0;
}Scope
- Define
ASTNode,NodeType,MarkdownASTinast.hpp - Define
MarkdownParserinterface inparser.hpp - Implement
CmarkParserinparser_cmark.cpp - For this phase, only handle
Document,Paragraph,Textnode types - Implement
DomBuilder::build()— for now, onlyparagraph()output
Tests (test_parser.cpp, test_dom_builder.cpp)
Input: "Hello world"
AST: Document → Paragraph → Text("Hello world")
DOM: paragraph("Hello world")
Input: "Line one\n\nLine two"
AST: Document → [Paragraph → Text("Line one"), Paragraph → Text("Line two")]
Input: ""
AST: Document (no children)
Input: " \n\n "
AST: Document (no children or empty paragraph)
Scope
- Parse
# H1through###### H6 ASTNodewithtype = Heading,level = 1..6DomBuilder: H1 → bold + all caps or separator line, H2 → bold, H3–H6 → bold + dim
Tests (test_headings.cpp)
Input: "# Title"
AST: Document → Heading(level=1) → Text("Title")
Input: "## Section\n\nBody text"
AST: Document → [Heading(level=2) → Text("Section"), Paragraph → Text("Body text")]
Input: "###### Deep"
AST: Document → Heading(level=6) → Text("Deep")
Scope
- Parse strong emphasis
ASTNodewithtype = StrongDomBuilder: wrap children inftxui::bold
Tests (test_bold.cpp)
Input: "This is **important**"
AST: Document → Paragraph → [Text("This is "), Strong → Text("important")]
DOM: Contains bold("important")
Input: "**full bold**"
AST: Document → Paragraph → Strong → Text("full bold")
Scope
- Parse emphasis
ASTNodewithtype = EmphasisDomBuilder: wrap children inftxui::italic
Tests (test_italic.cpp)
Input: "*this matters*"
AST: Document → Paragraph → Emphasis → Text("this matters")
Input: "A _subtle_ point"
AST: Document → Paragraph → [Text("A "), Emphasis → Text("subtle"), Text(" point")]
Scope
- Handle
***bold and italic*** - Handle
**bold with *italic* inside** - Handle
*italic with **bold** inside*
Tests (test_bold_italic.cpp)
Input: "***both***"
AST: Document → Paragraph → Strong → Emphasis → Text("both")
DOM: bold(italic("both"))
Input: "**bold and *italic* here**"
AST: Document → Paragraph → Strong → [Text("bold and "), Emphasis → Text("italic"), Text(" here")]
Scope
- Parse
[text](url) ASTNodewithtype = Link,urlfield populatedDomBuilder: render link text asftxui::underlined, URL is not displayed
Tests (test_links.cpp)
Input: "[Docs](https://example.com)"
AST: Document → Paragraph → Link(url="https://example.com") → Text("Docs")
DOM: underlined("Docs")
Input: "See [**bold link**](url)"
AST: Document → Paragraph → [Text("See "), Link → Strong → Text("bold link")]
DOM: underlined(bold("bold link"))
Scope
- Parse
- itemand* item ASTNodetypes:BulletListcontainingListItemchildrenDomBuilder:•prefix per item
Tests (test_lists.cpp)
Input: "- one\n- two\n- three"
AST: Document → BulletList → [ListItem → Paragraph → Text("one"), ...]
DOM: vbox([" • one", " • two", " • three"])
Input: "- **bold** item"
AST: Document → BulletList → ListItem → Paragraph → [Strong → Text("bold"), Text(" item")]
Edge case: nested list input (not supported) should render flat or degrade gracefully without crashing.
Scope
- Parse
`code` ASTNodewithtype = CodeInlineDomBuilder:ftxui::inverted
Tests (test_code.cpp)
Input: "Use `ls -la`"
AST: Document → Paragraph → [Text("Use "), CodeInline("ls -la")]
DOM: Contains inverted("ls -la")
Input: "`single`"
AST: Document → Paragraph → CodeInline("single")
Scope
- Parse
> text ASTNodewithtype = BlockQuoteDomBuilder: left border or│prefix + dim styling
Tests (test_quotes.cpp)
Input: "> note"
AST: Document → BlockQuote → Paragraph → Text("note")
DOM: borderLeft + dim("note")
Input: "> **important** note"
AST: Document → BlockQuote → Paragraph → [Strong → Text("important"), Text(" note")]
Scope
- Implement
highlight_markdown_syntax()inhighlight.cpp - Integrate highlighting into the
Editorcomponent - Wrap
ftxui::Inputso that displayed text has syntax characters colored differently
Tests (test_highlight.cpp)
Input: "**bold** and *italic*"
Result: ** are highlighted, bold is normal, * are highlighted, italic is normal
Input: "[link](url)"
Result: [, ], (, ) are highlighted
Input: "# Heading"
Result: # is highlighted
Scope
- Wire Editor + Viewer in
demo/main.cpp - Side-by-side layout with
ftxui::hbox - Shared
std::stringstate with debounced viewer update Ctrl+C/Ctrl+Qto quit
Manual verification (not automated)
- Type Markdown in the editor, see formatted output update live in the viewer
- Unicode input renders correctly in both panes
- Rapid typing does not cause visible flicker
Scope
- Integration tests combining all features
- Edge case tests for robustness
Tests (test_mixed.cpp)
Input:
# Plan
> Focus on **important** tasks
- Write *code*
- Review `tests`
- Read [docs](url)
Expected: All features render correctly, correct nesting, no crashes
Tests (test_edge_cases.cpp)
Input: "" → Empty document, no crash
Input: " \n\n\n " → Whitespace only, no crash
Input: "**unclosed bold" → Best-effort render, no crash
Input: "* * * *" → Graceful handling (not a horizontal rule since it's a non-goal)
Input: "1. ordered" → Rendered as plain text or best-effort (not supported)
Input: "```\ncode block\n```" → Rendered as plain text or best-effort (not supported)
Input: "- nested\n - list" → Flat rendering, no crash
Unicode tests (test_unicode.cpp)
Input: "**重要** task" → Bold applied to 重要, "task" is plain
Input: "*émphasis*" → Italic applied correctly
Input: "- café\n- naïve" → List renders with correct characters
Input: "# Ünïcödé" → Heading renders correctly
- CTest with standalone test executables — no external test framework
- All tests run via
ctest --test-dir build - A minimal
test_helper.hppprovides assert macros:
// test_helper.hpp
#include <cstdlib>
#include <iostream>
#include <string>
#define ASSERT_TRUE(expr) \
if (!(expr)) { \
std::cerr << "FAIL: " #expr " at " << __FILE__ << ":" << __LINE__ << "\n"; \
return 1; \
}
#define ASSERT_EQ(a, b) \
if ((a) != (b)) { \
std::cerr << "FAIL: " #a " != " #b " at " << __FILE__ << ":" << __LINE__ << "\n"; \
return 1; \
}
#define ASSERT_CONTAINS(haystack, needle) \
if (std::string(haystack).find(needle) == std::string::npos) { \
std::cerr << "FAIL: \"" << needle << "\" not found at " << __FILE__ << ":" << __LINE__ << "\n"; \
return 1; \
}Each test file is a standalone int main() that returns 0 on success. Registered in CMake:
add_executable(test_bold tests/test_bold.cpp)
target_link_libraries(test_bold PRIVATE markdown-ui)
add_test(NAME test_bold COMMAND test_bold)Tests are organized into two layers:
-
Parser tests (
test_parser.cpp+ per-feature files): Parse Markdown string → assert AST structure. Validates that the parser produces the expected tree ofASTNodevalues. -
DOM builder tests (
test_dom_builder.cpp+ per-feature files): Construct an AST manually → callDomBuilder::build()→ render to anftxui::Screen→ inspect screen content for expected text and style markers.
// Render an Element to a Screen and inspect the output
ftxui::Element element = dom_builder.build(ast);
ftxui::Screen screen(80, 24);
ftxui::Render(screen, element);
std::string output = screen.ToString();
// Assert content presence
ASSERT_CONTAINS(output, "important");Style verification (bold, italic, etc.) can be checked by inspecting individual Pixel values on the Screen object, which carry style metadata.
- Demo application UI layout (manual verification only)
- FTXUI's own rendering correctness (trusted dependency)
- Performance benchmarks (not required for this scope)
- Invalid or malformed Markdown must never crash the application
- The parser should return a best-effort AST (cmark-gfm is lenient by default)
- If parsing fails entirely, return a
Documentnode containing a singleParagraphwith the raw input text
- Unknown
NodeTypevalues should be rendered as plain text (defensive) - Null/empty children should be handled gracefully
- The editor does not validate content — it accepts any text
- Target document size: up to ~5,000 lines
- Full re-parse + re-render on each edit is acceptable for this size
- No incremental parsing required
- Debounce (50ms) in the demo prevents excessive re-renders during fast typing
- Single-threaded only — no async rendering, no background parsing
- Matrix: Windows (MSVC 2022), macOS (latest Xcode/Clang), Ubuntu (GCC 13)
- Steps: configure → build → test
- Triggered on push to
mainand on pull requests
# .github/workflows/ci.yml (sketch)
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- name: Configure
run: cmake -S . -B build -DCMAKE_CXX_STANDARD=23
- name: Build
run: cmake --build build
- name: Test
run: ctest --test-dir build --output-on-failure- Builds on Windows (MSVC), macOS (Clang), Linux (GCC) via CI
- Uses C++23 with documented fallback strategy
- Editor and Viewer are clearly separated components
- cmark-gfm is fully abstracted behind
MarkdownParserinterface - AST type system (
ASTNode,NodeType) is fully defined and documented DomBuilderconverts AST to FTXUI DOM with documented mapping- Tests exist for every phase, including edge cases and Unicode
- Editor has lexical highlighting with its own tests
- Demo application runs with side-by-side layout and debounced updates
- Invalid Markdown never crashes the application
- Unicode renders correctly in both editor and viewer