Skill v1.0.1
currentAutomated scan96/1002 files
version: "1.0.1" 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:
// Basic text renderingd`This is some text`;// Dynamic content interpolationd`User: ${username}`;// Conditional renderingd`${isLoading ? d`Loading...` : d`Content loaded!`}`;// Rendering listsd`${items.map((item) => d`- ${item.name}\n`)}`;// Component compositiond`Header: ${headerView({ title })}\nBody: ${bodyView({ content })}`;
Interpolation Rules
- String values are inserted as-is
- Other
dtemplates can be nested - Arrays of
dtemplates 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:
// make sure to grab appropriate imports relative to the file path// Define a simple message type for togglingexport type Msg = { type: "toggle" } | { type: "request-finished" };// this should be imported from node/root-msg.tsexport 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);});// Dispatch the request-finished message when donethis.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:
// Good: Explicit newlines for vertical spacingd`Line 1Line 2Line 4 (with gap above)`;// Bad: Implicit spacing assumptionsd`Line 1${"\n"}Line 2`; // Hard to read
Buffer Width
Be aware that text may wrap based on terminal/buffer width:
// Consider line length when formattingd`This is a very long line that might wrap in narrow terminalsConsider 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" }),})}`;}// Normal view...}
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:
// Good: Pure view functionview() {return d`Count: ${this.state.count}`;}// Bad: Side effects in viewview() {this.logCount(); // Don't do this!return d`Count: ${this.state.count}`;}
Extract Complex Logic
Don't put complex logic in templates:
// Good: Extract to helper methodprivate 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 templateview() {return d`${this.state.items.map((item) => d`${item.name} (${item.done ? "✓" : item.pending ? "..." : "✗"})\n`)}`;}
Use Descriptive Binding Labels
Make interactive elements obvious:
// Good: Clear interactive elementswithBindings(d`[ Submit ]`, { "<CR>": handler });withBindings(d`Press Enter to continue`, { "<CR>": handler });// Bad: Unclear what's interactivewithBindings(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:
// Good: Flat structure with helper functionsview() {return d`${this.renderHeader()}${this.renderContent()}${this.renderFooter()}`;}// Bad: Deep nestingview() {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:
// In testsconst bufferContent = await driver.getDisplayBuffer();console.log(bufferContent);
Validate Bindings
Ensure bindings are attached to the correct regions:
// In testsconst 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:
// Good: Check before updatingmyUpdate(msg: Msg) {if (msg.type === "set-filter") {if (this.state.filter !== msg.filter) {this.state.filter = msg.filter;// View will re-render}}}// Bad: Always updatemyUpdate(msg: Msg) {if (msg.type === "set-filter") {this.state.filter = msg.filter; // Re-renders even if same value}}
Avoid Expensive Computations in Views
Compute derived data in update methods, not in views:
// Good: Pre-compute in updatemyUpdate(msg: Msg) {this.state.items = msg.items;this.state.filteredItems = this.state.items.filter(/* ... */);}view() {return d`${this.state.filteredItems.map(/* ... */)}`;}// Bad: Compute in viewview() {const filtered = this.state.items.filter(/* expensive filter */);return d`${filtered.map(/* ... */)}`;}
Common Pitfalls
Don't Mix String Concatenation with d
// Bad: Mixing stringsd`Hello ` + userName; // Wrong!// Good: Use interpolationd`Hello ${userName}`;
Don't Forget Newlines in Lists
// Bad: Items will run togetherd`${items.map((item) => d`${item.name}`)}`;// Good: Explicit newlinesd`${items.map((item) => d`${item.name}\n`)}`;
Don't Return Plain Strings
// Bad: Plain stringview() {return "Hello"; // Won't work!}// Good: Use d templateview() {return d`Hello`;}
Don't Mutate State in View
// Bad: Side effectsview() {this.state.viewCount++; // Never do this!return d`Viewed ${this.state.viewCount} times`;}// Good: Pure viewview() {return d`Viewed ${this.state.viewCount} times`;}