| name | doc-views |
| description | Comprehensive guide for the view system in magenta.nvim, including template literal syntax, component composition, interactive bindings, and TUI-specific rendering patterns |
View System in magenta.nvim
THIS IS NOT REACT. DO NOT USE REACT VIEWS OR DOM. This uses a templating library for a TUI running inside a neovim buffer.
Views in magenta.nvim are built using a declarative templating approach with the d template literal tag and view functions that render controller state to neovim buffers.
Core Concepts
The view system is based on several key principles:
- Declarative rendering: Views describe what should be displayed, not how to update the buffer
- Template composition: Small view functions combine to build complex UIs
- Interactive bindings: Attach keybindings to specific regions of rendered text
- Automatic updates: Views re-render on state changes triggered by dispatched messages
Template Literal Syntax (d)
The d tag function is the foundation of the view system:
d`This is some text`;
d`User: ${username}`;
d`${isLoading ? d`Loading...` : d`Content loaded!`}`;
d`${items.map((item) => d`- ${item.name}\n`)}`;
d`Header: ${headerView({ title })}\nBody: ${bodyView({ content })}`;
Interpolation Rules
- String values are inserted as-is
- Other
d templates can be nested
- Arrays of
d templates are joined together
- Undefined/null values render as empty strings
- Numbers are converted to strings
Component Composition
Break down complex views into smaller, reusable functions:
function headerView(title: string) {
return d`
===================
${title}
===================
`;
}
function itemView(item: Item) {
return d`
- ${item.name}: ${item.description}
Status: ${item.status}
`;
}
function listView(items: Item[]) {
return d`
${headerView("My Items")}
${items.map((item) => itemView(item))}
`;
}
Adding Interactivity with withBindings
Attach keybindings to sections of text using withBindings:
withBindings(d`Press Enter to continue`, {
"<CR>": () => dispatch({ type: "continue" }),
q: () => dispatch({ type: "quit" }),
});
Multiple Bindings
You can attach multiple keybindings to the same text region:
withBindings(d`[Submit]`, {
"<CR>": () => dispatch({ type: "submit" }),
"<Space>": () => dispatch({ type: "submit" }),
s: () => dispatch({ type: "submit" }),
});
Bindings on Lists
Each item in a list can have its own bindings:
d`
${items.map((item, index) =>
withBindings(d`[${index + 1}] ${item.name}\n`, {
"<CR>": () => dispatch({ type: "select-item", index }),
d: () => dispatch({ type: "delete-item", index }),
}),
)}
`;
Controller View Methods
Controllers implement a view() method that returns their rendered state:
class MyController {
state: {
count: number;
items: string[];
};
view() {
return d`
Counter: ${this.state.count}
${withBindings(d`[Increment]`, {
"<CR>": () => this.myDispatch({ type: "increment" }),
})}
Items:
${this.state.items.map((item) => d`- ${item}\n`)}
`;
}
}
Complete Controller Example
A controller owns its state, wraps its local messages into a RootMsg variant
via myDispatch, filters incoming RootMsgs in update, and renders state in
view(). Async work dispatches a follow-up message rather than mutating state
directly:
export type Msg = { type: "toggle" } | { type: "request-finished" };
export type ToggleRootMsg = {
type: "toggle-msg";
id: ToggleId;
msg: Msg;
};
export type ToggleId = number & { __toggleId: true };
export class Toggle {
public state: {
isOn: boolean;
};
private myDispatch: Dispatch<Msg>;
constructor(
public id: ToggleId,
private context: { dispatch: Dispatch<RootMsg>; nvim: Nvim },
) {
this.myDispatch = (msg) =>
this.context.dispatch({
type: "toggle-msg",
id: this.id,
msg,
});
this.state = {
isOn: false,
};
}
update(msg: RootMsg): void {
if (msg.type === "toggle-msg" && msg.id === this.id) {
this.myUpdate(msg.msg);
}
}
private myUpdate(msg: Msg): void {
switch (msg.type) {
case "toggle":
this.state.isOn = !this.state.isOn;
if (this.state.isOn) {
this.notifyServer().catch((error) => {
this.context.nvim.logger.error("Failed to notify server:", error);
});
}
return;
case "request-finished":
this.context.nvim.logger.info("Server notification completed");
return;
default:
assertUnreachable(msg);
}
}
private async notifyServer(): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(() => resolve(), 500);
});
this.myDispatch({ type: "request-finished" });
}
view() {
return d`
Current state: ${this.state.isOn ? "ON" : "OFF"}
${withBindings(d`[Toggle]`, {
"<CR>": () => this.myDispatch({ type: "toggle" }),
})}`;
}
}
Rendering Cycle
- User action triggers a keybinding or command
- Binding dispatches a message
- Message flows through the dispatch system to the appropriate controller
- Controller updates its state
- View is re-rendered based on new state
- Buffer content is updated with new view
TUI-Specific Considerations
Text Alignment and Spacing
Unlike web UIs, TUI rendering requires careful attention to spacing:
d`
Line 1
Line 2
Line 4 (with gap above)
`;
d`Line 1${"\n"}Line 2`;
Buffer Width
Be aware that text may wrap based on terminal/buffer width:
d`
This is a very long line that might wrap in narrow terminals
Consider breaking long text into multiple lines
`;
Visual Separators
Use ASCII art for visual structure:
d`
===================
Section Header
===================
Content goes here
-------------------
Footer
`;
Common Patterns
Loading States
view() {
if (this.state.loading) {
return d`Loading...`;
}
return d`
${this.state.data}
${withBindings(d`[Refresh]`, {
"<CR>": () => this.myDispatch({ type: "refresh" }),
})}
`;
}
Error Display
view() {
if (this.state.error) {
return d`
ERROR: ${this.state.error.message}
${withBindings(d`[Retry]`, {
"<CR>": () => this.myDispatch({ type: "retry" }),
})}
`;
}
}
Conditional Sections
view() {
return d`
Main content
${this.state.showDetails ? d`
Details:
${this.state.details}
` : d``}
${withBindings(d`[${this.state.showDetails ? "Hide" : "Show"} Details]`, {
"<CR>": () => this.myDispatch({ type: "toggle-details" }),
})}
`;
}
Lists with Actions
view() {
return d`
Tasks:
${this.state.tasks.map((task, idx) => d`
${withBindings(d`[${task.done ? "✓" : " "}] ${task.name}`, {
"<CR>": () => this.myDispatch({ type: "toggle-task", index: idx }),
d: () => this.myDispatch({ type: "delete-task", index: idx }),
})}
`)}
${withBindings(d`[Add Task]`, {
"<CR>": () => this.myDispatch({ type: "add-task" }),
})}
`;
}
Multi-Column Layouts
Use spacing to create columns:
function formatRow(name: string, value: string) {
const nameWidth = 20;
const paddedName = name.padEnd(nameWidth);
return d`${paddedName} ${value}`;
}
view() {
return d`
${formatRow("Name:", this.state.name)}
${formatRow("Status:", this.state.status)}
${formatRow("Created:", this.state.created)}
`;
}
Best Practices
Keep Views Pure
Views should be pure functions of state - no side effects:
view() {
return d`Count: ${this.state.count}`;
}
view() {
this.logCount();
return d`Count: ${this.state.count}`;
}
Extract Complex Logic
Don't put complex logic in templates:
private formatItem(item: Item): string {
return `${item.name} (${item.status})`;
}
view() {
return d`${this.state.items.map((item) => d`${this.formatItem(item)}\n`)}`;
}
view() {
return d`${this.state.items.map((item) => d`${item.name} (${item.done ? "✓" : item.pending ? "..." : "✗"})\n`)}`;
}
Use Descriptive Binding Labels
Make interactive elements obvious:
withBindings(d`[ Submit ]`, { "<CR>": handler });
withBindings(d`Press Enter to continue`, { "<CR>": handler });
withBindings(d`Submit`, { "<CR>": handler });
withBindings(d`>`, { "<CR>": handler });
Handle Empty States
Always consider what happens with empty data:
view() {
if (this.state.items.length === 0) {
return d`
No items found.
${withBindings(d`[Add Item]`, {
"<CR>": () => this.myDispatch({ type: "add" }),
})}
`;
}
return d`${this.state.items.map(/* ... */)}`;
}
Avoid Deep Nesting
Keep template nesting shallow for readability:
view() {
return d`
${this.renderHeader()}
${this.renderContent()}
${this.renderFooter()}
`;
}
view() {
return d`
${this.state.show ? d`
${this.state.loading ? d`
Loading...
` : d`
${this.state.items.map((item) => d`
${item.visible ? d`${item.name}` : d``}
`)}
`}
` : d``}
`;
}
Debugging Views
Check Rendered Output
The view is rendered to a neovim buffer - you can inspect the actual buffer content to debug rendering issues:
const bufferContent = await driver.getDisplayBuffer();
console.log(bufferContent);
Validate Bindings
Ensure bindings are attached to the correct regions:
const pos = await driver.assertDisplayBufferContains("[Submit]");
await driver.triggerDisplayBufferKey(pos, "<CR>");
Log State
Add temporary logging to see state during rendering:
view() {
this.context.nvim.logger.debug("Rendering with state:", this.state);
return d`...`;
}
Performance Considerations
Minimize Re-renders
Only dispatch messages when state actually changes:
myUpdate(msg: Msg) {
if (msg.type === "set-filter") {
if (this.state.filter !== msg.filter) {
this.state.filter = msg.filter;
}
}
}
myUpdate(msg: Msg) {
if (msg.type === "set-filter") {
this.state.filter = msg.filter;
}
}
Avoid Expensive Computations in Views
Compute derived data in update methods, not in views:
myUpdate(msg: Msg) {
this.state.items = msg.items;
this.state.filteredItems = this.state.items.filter();
}
view() {
return d`${this.state.filteredItems.map(/* ... */)}`;
}
view() {
const filtered = this.state.items.filter();
return d`${filtered.map(/* ... */)}`;
}
Common Pitfalls
Don't Mix String Concatenation with d
d`Hello ` + userName;
d`Hello ${userName}`;
Don't Forget Newlines in Lists
d`${items.map((item) => d`${item.name}`)}`;
d`${items.map((item) => d`${item.name}\n`)}`;
Don't Return Plain Strings
view() {
return "Hello";
}
view() {
return d`Hello`;
}
Don't Mutate State in View
view() {
this.state.viewCount++;
return d`Viewed ${this.state.viewCount} times`;
}
view() {
return d`Viewed ${this.state.viewCount} times`;
}