with one click
doc-views
// Comprehensive guide for the view system in magenta.nvim, including template literal syntax, component composition, interactive bindings, and TUI-specific rendering patterns
// Comprehensive guide for the view system in magenta.nvim, including template literal syntax, component composition, interactive bindings, and TUI-specific rendering patterns
| 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 |
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.
The view system is based on several key principles:
d)The d tag function is the foundation of the view system:
// Basic text rendering
d`This is some text`;
// Dynamic content interpolation
d`User: ${username}`;
// Conditional rendering
d`${isLoading ? d`Loading...` : d`Content loaded!`}`;
// Rendering lists
d`${items.map((item) => d`- ${item.name}\n`)}`;
// Component composition
d`Header: ${headerView({ title })}\nBody: ${bodyView({ content })}`;
d templates can be nestedd templates are joined togetherBreak 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))}
`;
}
withBindingsAttach keybindings to sections of text using withBindings:
withBindings(d`Press Enter to continue`, {
"<CR>": () => dispatch({ type: "continue" }),
q: () => dispatch({ type: "quit" }),
});
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" }),
});
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 }),
}),
)}
`;
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`)}
`;
}
}
Unlike web UIs, TUI rendering requires careful attention to spacing:
// Good: Explicit newlines for vertical spacing
d`
Line 1
Line 2
Line 4 (with gap above)
`;
// Bad: Implicit spacing assumptions
d`Line 1${"\n"}Line 2`; // Hard to read
Be aware that text may wrap based on terminal/buffer width:
// Consider line length when formatting
d`
This is a very long line that might wrap in narrow terminals
Consider breaking long text into multiple lines
`;
Use ASCII art for visual structure:
d`
===================
Section Header
===================
Content goes here
-------------------
Footer
`;
view() {
if (this.state.loading) {
return d`Loading...`;
}
return d`
${this.state.data}
${withBindings(d`[Refresh]`, {
"<CR>": () => this.myDispatch({ type: "refresh" }),
})}
`;
}
view() {
if (this.state.error) {
return d`
ERROR: ${this.state.error.message}
${withBindings(d`[Retry]`, {
"<CR>": () => this.myDispatch({ type: "retry" }),
})}
`;
}
// Normal view...
}
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" }),
})}
`;
}
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" }),
})}
`;
}
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)}
`;
}
Views should be pure functions of state - no side effects:
// Good: Pure view function
view() {
return d`Count: ${this.state.count}`;
}
// Bad: Side effects in view
view() {
this.logCount(); // Don't do this!
return d`Count: ${this.state.count}`;
}
Don't put complex logic in templates:
// Good: Extract to helper method
private formatItem(item: Item): string {
return `${item.name} (${item.status})`;
}
view() {
return d`${this.state.items.map((item) => d`${this.formatItem(item)}\n`)}`;
}
// Bad: Complex logic in template
view() {
return d`${this.state.items.map((item) => d`${item.name} (${item.done ? "✓" : item.pending ? "..." : "✗"})\n`)}`;
}
Make interactive elements obvious:
// Good: Clear interactive elements
withBindings(d`[ Submit ]`, { "<CR>": handler });
withBindings(d`Press Enter to continue`, { "<CR>": handler });
// Bad: Unclear what's interactive
withBindings(d`Submit`, { "<CR>": handler });
withBindings(d`>`, { "<CR>": handler });
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(/* ... */)}`;
}
Keep template nesting shallow for readability:
// Good: Flat structure with helper functions
view() {
return d`
${this.renderHeader()}
${this.renderContent()}
${this.renderFooter()}
`;
}
// Bad: Deep nesting
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``}
`;
}
The view is rendered to a neovim buffer - you can inspect the actual buffer content to debug rendering issues:
// In tests
const bufferContent = await driver.getDisplayBuffer();
console.log(bufferContent);
Ensure bindings are attached to the correct regions:
// In tests
const pos = await driver.assertDisplayBufferContains("[Submit]");
await driver.triggerDisplayBufferKey(pos, "<CR>");
Add temporary logging to see state during rendering:
view() {
this.context.nvim.logger.debug("Rendering with state:", this.state);
return d`...`;
}
Only dispatch messages when state actually changes:
// Good: Check before updating
myUpdate(msg: Msg) {
if (msg.type === "set-filter") {
if (this.state.filter !== msg.filter) {
this.state.filter = msg.filter;
// View will re-render
}
}
}
// Bad: Always update
myUpdate(msg: Msg) {
if (msg.type === "set-filter") {
this.state.filter = msg.filter; // Re-renders even if same value
}
}
Compute derived data in update methods, not in views:
// Good: Pre-compute in update
myUpdate(msg: Msg) {
this.state.items = msg.items;
this.state.filteredItems = this.state.items.filter(/* ... */);
}
view() {
return d`${this.state.filteredItems.map(/* ... */)}`;
}
// Bad: Compute in view
view() {
const filtered = this.state.items.filter(/* expensive filter */);
return d`${filtered.map(/* ... */)}`;
}
d// Bad: Mixing strings
d`Hello ` + userName; // Wrong!
// Good: Use interpolation
d`Hello ${userName}`;
// Bad: Items will run together
d`${items.map((item) => d`${item.name}`)}`;
// Good: Explicit newlines
d`${items.map((item) => d`${item.name}\n`)}`;
// Bad: Plain string
view() {
return "Hello"; // Won't work!
}
// Good: Use d template
view() {
return d`Hello`;
}
// Bad: Side effects
view() {
this.state.viewCount++; // Never do this!
return d`Viewed ${this.state.viewCount} times`;
}
// Good: Pure view
view() {
return d`Viewed ${this.state.viewCount} times`;
}