diff --git a/.opencode/agent/prose-coder.md b/.opencode/agent/prose-coder.md
new file mode 100644
index 0000000..fdb4437
--- /dev/null
+++ b/.opencode/agent/prose-coder.md
@@ -0,0 +1,59 @@
+---
+description: >-
+ Use this agent when the user specifically requests code simplification,
+ improved readability, or refactoring to make code more 'literate' or
+ narrative-driven. It is ideal for critiques focusing on cognitive load and
+ semantic clarity rather than strict architectural patterns or performance
+ optimization.
+
+
+
+ Context: The user has written a complex algorithm and wants it to be easier for humans to understand.
+ user: "Can you critique this function? I want it to read like a story, not just a list of operations."
+ assistant: "I will use the prose-coder agent to analyze the narrative flow of your code."
+
+
+
+
+ Context: The user feels their code is becoming too fragmented by 'clean code' rules and wants a more cohesive structure.
+ user: "This code is too abstract. Simplify it so I can read it top-to-bottom."
+ assistant: "I will engage the prose-coder agent to refactor for better locality and narrative structure."
+
+mode: subagent
+---
+You are an expert Senior Software Architect specializing in Literate Programming and Cognitive Load Optimization. Your goal is to critique and refactor code so that it reads as much like natural prose as possible.
+
+### Core Philosophy
+You believe that code is written primarily for humans to read, and incidentally for machines to execute. You explicitly REJECT the dogmatic extremes of 'Clean Code' (e.g., excessive fragmentation, single-line functions, over-abstraction) if they increase cognitive load or force the reader to jump around constantly to understand the narrative. Code is clean when code does not inter-mix different layers of abstraction in the same block. Colocation of concerns is a good thing.
+
+**Important Caveat**: Don't be SO over-eager to simplify that edge-cases or details are missed.
+
+### Your Objectives
+1. **Maximize Readability**: Code should flow logically from top to bottom. The reader should not have to hold a deep stack of context to understand the current line.
+2. **Prose-Like Quality**: Variable and function names should form sentences or clear phrases. Logic should unfold like a paragraph.
+3. **Simplification**: Remove unnecessary abstractions, wrappers, and boilerplate that obscure the intent.
+
+### Guidelines for Critique & Refactoring
+
+#### 1. Naming as Narrative
+- **Variables**: Use descriptive, contextual names. Avoid generic terms like `data`, `item`, or `obj`. Instead of `if (x.status == 1)`, prefer `if (order.isReadyForShipment)`.
+- **Functions**: Function names should describe the *effect* or *intent* clearly.
+
+#### 2. Structure and Flow
+- **Locality**: Keep related logic together. Do not extract code into a private helper function just to reduce line count if it breaks the reading flow. Only extract if the chunk represents a distinct, reusable concept.
+- **Linearity**: Prefer linear logic over deep nesting. Use guard clauses to handle edge cases early, leaving the 'happy path' as the main narrative body.
+
+#### 3. Comments
+- Use comments to explain the *why* and the *narrative arc*, not the *how*.
+- Comments should serve as chapter headings or explanatory asides that bridge the gap between business intent and implementation details.
+
+#### 4. Anti-Patterns to Avoid
+- **The 'Clean Code' Fetish**: Do not suggest breaking a clear 20-line function into four 5-line functions scattered across the file unless it genuinely clarifies the logic.
+- **Yoda Conditions**: Avoid `if (null == value)`. Write how you speak: `if (value is null)`.
+- **Unnecessary Interfaces**: Do not suggest interfaces or dependency injection where a simple direct instantiation suffices for the narrative.
+
+### Output Format
+When critiquing code:
+1. **High-Level Assessment**: Briefly describe the current 'readability score' and narrative flow.
+2. **Specific Critiques**: Point out specific lines or blocks that break the reader's concentration.
+3. **Refactoring**: Provide a rewritten version of the code that embodies your philosophy. Explain *why* the changes make it read more like prose.
diff --git a/.opencode/agent/tui-automator.md b/.opencode/agent/tui-automator.md
new file mode 100644
index 0000000..63d9109
--- /dev/null
+++ b/.opencode/agent/tui-automator.md
@@ -0,0 +1,79 @@
+---
+description: >-
+ Use this agent when you need to automate interactions with Terminal User
+ Interfaces (TUIs), interactive CLI tools, or long-running terminal processes
+ that require state management. It is ideal for tasks requiring keystroke
+ simulation (like navigating menus, using text editors like Vim/Nano, or
+ controlling curses-based applications) and reading screen output to verify
+ state changes.
+
+
+
+
+ Context: The user wants to automate a text edit in Vim within a headless
+ environment.
+
+ User: "Open vim, insert the text 'Hello World', and save the file as
+ test.txt."
+
+ Assistant: "I will use the tui-automator to handle the interactive Vim
+ session."
+
+
+
+ Since this requires interacting with a TUI (Vim) by sending specific
+ keystrokes and verifying the screen state, the tui-automator is the correct
+ tool.
+
+
+
+
+
+
+
+
+ Context: The user needs to navigate an interactive installation wizard.
+
+ User: "Run the ./install.sh script, wait for the license agreement, scroll
+ down, and select 'Accept'."
+
+ Assistant: "I'll launch the installer in a tmux session and navigate the menu
+ using tui-automator."
+
+
+
+ The task involves reacting to screen output (waiting for the license) and
+ sending navigation keys, which fits the tui-automator's capabilities.
+
+
+
+
+mode: all
+---
+You are an expert TUI Automation Specialist, effectively acting as a 'Puppeteer for the Terminal.' Your primary function is to interact with terminal applications running inside background `tmux` sessions. You achieve this by sending keystrokes and commands via the tmux CLI and verifying the results by capturing and analyzing the pane content.
+
+### Operational Context
+You operate 'blindly' by default and must actively 'look' at the screen to understand the state of the application. You do not have direct access to the TTY's standard output stream in real-time; you must snapshot the pane.
+
+### Core Capabilities & Tools
+1. **Session Management**: Ensure a target tmux session/window exists. If not specified, create or identify a dedicated session (e.g., 'automation-session').
+2. **Input Simulation**: Use `tmux send-keys -t ` to simulate user input. Support special keys (Enter, C-c, Up, Down, F1-F12).
+3. **Visual Verification**: Use `tmux capture-pane -p -t ` to read the screen content.
+ * Use plain text capture for logic checks (reading menus, prompts).
+ * Use `-e` (ANSI escape codes) if color or formatting is critical for distinguishing state (e.g., red error text vs. green success text).
+
+### Workflow Protocol
+1. **Initialize**: Verify the tmux session is active. If starting a new process, launch it within the session.
+2. **Action**: Send the required keystrokes (e.g., `tmux send-keys -t 0 'ls -la' C-m`).
+3. **Wait**: Allow a brief moment for the application to render (latency management).
+4. **Observe**: Capture the pane content to verify the action had the intended effect.
+5. **Analyze**: Parse the captured text to decide the next step or confirm success.
+
+### Best Practices
+* **Idempotency**: Check the screen state *before* sending keys to ensure you aren't typing into the wrong context.
+* **Error Handling**: If the screen output indicates a crash or unexpected prompt, stop and report the state to the user.
+* **Cleanup**: When a task is complete, decide whether to kill the session or leave it running based on user intent.
+* **Complex Keys**: When sending control characters, ensure correct tmux syntax (e.g., `C-c` for Ctrl+C, `Escape` for Esc).
+
+### Output Format
+When reporting back to the user, summarize the actions taken and the final state of the terminal screen. If an error occurs, provide the raw text captured from the pane for debugging.
diff --git a/.opencode/command/prose-code-diff.md b/.opencode/command/prose-code-diff.md
new file mode 100644
index 0000000..7f5132a
--- /dev/null
+++ b/.opencode/command/prose-code-diff.md
@@ -0,0 +1,6 @@
+---
+description: Clean-up code that changed
+agent: build
+---
+
+Use @prose-coder to review your CHANGE (not the rest of the code, JUST your change), and whittle it down (IF APPLICABLE) to a clean diff. While you are reviewing your change ONLY, review it in _context_ of whole (applicable) files.
diff --git a/.opencode/command/prose-code.md b/.opencode/command/prose-code.md
new file mode 100644
index 0000000..993558b
--- /dev/null
+++ b/.opencode/command/prose-code.md
@@ -0,0 +1,6 @@
+---
+description: Clean-up code
+agent: build
+---
+
+Use @prose-coder to help cleanup $ARGUMENTS. THE PUBLIC API CANNOT CHANGE.
diff --git a/AGENTS.md b/AGENTS.md
index d2fd486..968ef0b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,5 +1,9 @@
# morph.nvim - Agent Development Guide
+## General Methodology
+
+When asked to add a feature, start with adding a failing test, then the feature.
+
## Build/Lint/Test Commands
- `mise run ci` - Run lint, format check, and tests
@@ -22,7 +26,7 @@
This is why tests require manual `r.buf_watcher.fire()` calls - the current implementation uses `TextChanged` autocmd to batch `on_bytes` events, but this autocmd never fires in headless test environments.
-**Current workaround in tests**: Call `r.buf_watcher.fire()` manually after programmatic buffer changes to simulate the autocmd that would fire with real user input.
+**Current workaround in tests**: Call `vim.cmd.doautocmd 'TextChanged'` manually after programmatic buffer changes to simulate the autocmd that would fire with real user input.
### Buffer Event Testing
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 0000000..9a3ac84
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,312 @@
+# morph.nvim Architecture
+
+This document describes the internal architecture of morph.nvim, a React-like component library for Neovim buffers.
+
+## Overview
+
+morph.nvim is implemented as a single file (`lua/morph.lua`, < 1000 SLoC) for easy vendoring by plugin authors. It provides a declarative, component-based API for building interactive text UIs in Neovim buffers.
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ User Code │
+│ h(Component, props, children) → Tree │
+└─────────────────────────────────┬───────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│ Morph:mount(tree) │
+│ ┌─────────────────────────────────────────────────────────────┐ │
+│ │ Reconciliation (Side-by-Side Visitor) │ │
+│ │ reconcile_tree() <--> reconcile_component() │ │
+│ │ │ │ │ │
+│ │ ▼ ▼ │ │
+│ │ reconcile_array() Component(ctx) → Tree │ │
+│ └─────────────────────────────────────────────────────────────┘ │
+│ │ │
+│ ▼ │
+│ Simplified Tree (no components) │
+└─────────────────────────────────┬───────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│ Morph:render(tree) │
+│ markup_to_lines() → lines[] + pending extmarks │
+│ patch_lines() → minimal buffer edits (Levenshtein) │
+│ create extmarks → buffer with highlights + interactivity │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+## Module Organization
+
+The single file is organized into logical sections:
+
+| Section | Responsibility |
+| --------------------- | --------------------------------------------------------------- |
+| Type Definitions | EmmyLua annotations for Tag, Element, Node, Tree, Component |
+| Tree Utilities | `tree_type()` and `tree_identity_key()` for node classification |
+| Levenshtein Algorithm | Generic diff algorithm for minimal edit sequences |
+| Textlock Detection | Detects when buffer modifications are blocked |
+| Buffer Watcher | Batches `on_bytes` events via TextChanged autocmd |
+| `h()` Hyperscript | Creates virtual DOM tags |
+| `Pos00` | 0-based position class with comparison operators |
+| `Extmark` | Wrapper around Neovim's extmark API |
+| `Ctx` | Component context (props, state, lifecycle) |
+| `Morph` | Main renderer class |
+
+## Core Concepts
+
+### Type Hierarchy
+
+```
+Tree (abstract, recursive)
+├── nil / boolean (produce no output)
+├── string / number (text content)
+├── Tag (created by h())
+│ ├── name: 'text' | Component function
+│ ├── attributes: { hl, key, id, nmap, imap, on_change, extmark, ... }
+│ └── children: Tree
+└── Array of Trees (flattened during rendering)
+
+Element = Tag + Extmark (instantiated, has buffer position)
+```
+
+### Key Abstractions
+
+1. **Tag**: A "recipe" for creating an element. Result of calling `h(name, attrs, children)`. Tags are declarative descriptions that don't yet have physical presence in the buffer.
+
+2. **Element**: An instantiated Tag that has been rendered to the buffer. Elements have an associated `extmark` that tracks their actual position and bounds in buffer text.
+
+3. **Component**: A function `(ctx: Ctx) -> Tree` that can have state and lifecycle. Components are called during reconciliation to produce their rendered output.
+
+4. **Ctx (Context)**: Persistent object passed to components containing:
+ - `props`: Immutable data from parent
+ - `state`: Mutable component-owned state
+ - `phase`: `'mount'` | `'update'` | `'unmount'`
+ - `children`: Child elements passed to component
+ - `update(newState)`: Trigger re-render
+ - `refresh()`: Re-render with current state
+ - `do_after_render(fn)`: Schedule post-render callback
+
+5. **Morph**: The main renderer class bound to a single buffer.
+
+## Rendering Pipeline
+
+### Static Rendering (`Morph:render()`)
+
+For trees without components (or after components are expanded):
+
+```
+Tree
+ ↓ markup_to_lines()
+lines[] + pending_extmarks[]
+ ↓ Levenshtein diff against old lines
+minimal buffer edits (nvim_buf_set_lines/set_text)
+ ↓ create extmarks at computed positions
+Buffer with styled/interactive regions
+```
+
+The `markup_to_lines()` function:
+
+- Visits the tree depth-first
+- Tracks current position (line, column)
+- For strings: splits on `\n`, emits text
+- For numbers: converts to string, emits
+- For tags: records start position, visits children, records stop position
+- Accumulates text content per tag for `on_change` detection
+
+### Component Rendering (`Morph:mount()`)
+
+For trees with components:
+
+```
+Tree (with components)
+ ↓ reconcile_tree()
+ ↓ reconcile_component() for each component
+ ↓ Component(ctx) called → returns Tree
+ ↓ reconcile children recursively
+Simplified Tree (components expanded to tags/text)
+ ↓ Morph:render()
+Buffer output
+```
+
+## Reconciliation Algorithm: Side-by-Side Correlated Visitor
+
+The reconciliation algorithm is the heart of morph.nvim's efficient updates. It uses a **side-by-side (correlated) visitor pattern** - walking the old and new trees together, correlating nodes by identity to determine the minimal set of mount/update/unmount operations.
+
+### The Pattern
+
+Rather than diffing trees independently and then computing changes, morph.nvim walks both trees simultaneously, making decisions at each node about whether to:
+
+- **Update**: Same identity → reuse existing component context
+- **Mount**: New node with no corresponding old node
+- **Unmount**: Old node with no corresponding new node
+
+This is implemented through three mutually recursive functions:
+
+### `reconcile_tree(old_tree, new_tree)`
+
+The entry point that dispatches based on node types:
+
+- reconcile_tree
+- reconcile_array
+- reconcile_component
+
+This is where the **side-by-side correlated visitor** pattern is most visible. It walks old and new arrays together, correlating nodes by identity:
+
+**Step 1: Compute identity keys for each node**
+
+```lua
+-- tree_identity_key() combines:
+-- - node type ('tag', 'component', 'string', etc.)
+-- - for components: the function reference
+-- - explicit `key` attribute (or array index as fallback)
+
+key = 'component-' .. tostring(tag.name) .. '-' .. tostring(tag.attributes.key or index)
+```
+
+**Step 2: Use Levenshtein with custom cost function**
+
+The key insight: nodes with matching identity keys should be updated (cheaper), while different keys require unmount + mount (more expensive):
+
+```lua
+local changes = levenshtein {
+ from = old_nodes,
+ to = new_nodes,
+ are_any_equal = false, -- all nodes need reconciliation
+ cost = {
+ of_change = function(_, _, old_idx, new_idx)
+ -- Matching keys = cheaper (update existing)
+ -- Different keys = more expensive (unmount + mount)
+ return old_keys[old_idx] == new_keys[new_idx] and 1 or 2
+ end,
+ },
+}
+```
+
+**Step 3: Apply changes**
+
+(Code elided)
+
+### Why This Pattern Works
+
+The side-by-side visitor pattern provides several benefits:
+
+1. **State Preservation**: By correlating nodes via identity keys, component state is preserved across re-renders when the same component appears in the same logical position.
+
+2. **Minimal Operations**: Levenshtein finds the minimum edit distance, naturally preferring updates over destroy/recreate.
+
+3. **Depth-First Processing**: Children are fully reconciled before their parent's reconciliation completes, ensuring proper unmount order (children before parents).
+
+4. **Single Pass**: Both trees are walked once together, rather than walking each separately and then diffing.
+
+## Text Diffing (Levenshtein)
+
+The `levenshtein()` function is used for:
+
+1. **Line-level diffing**: Transform old buffer lines to new lines
+2. **Character-level diffing**: Within changed lines, find minimal character edits
+3. **Array reconciliation**: Match old/new component arrays
+
+The algorithm:
+
+1. Build DP table where `dp[i][j]` = cost to transform `from[1..i]` to `to[1..j]`
+2. Backtrack to extract the actual edit sequence
+3. Priority when costs tie: delete > add > change (produces more intuitive results for keyed lists)
+
+`Morph.patch_lines()` applies the edits:
+
+- Computes line-level Levenshtein diff
+- For each changed line, computes character-level diff
+- Applies minimal edits via `nvim_buf_set_lines` and `nvim_buf_set_text`
+
+## Event Handling
+
+### Keymap Handling
+
+1. During `render()`, keymaps from `nmap`, `imap`, `vmap`, `xmap`, `omap` attributes are registered as buffer-local keymaps.
+
+2. **Original keymaps are snapshotted** in `Morph.new()` and restored before each render.
+
+3. On keypress, `_dispatch_keypress(mode, lhs)`:
+ - Gets cursor position
+ - Calls `get_elements_at(pos)` to find overlapping elements (innermost first)
+ - Iterates through handlers, allowing **event bubbling** via `e.bubble_up`
+ - Returns the key to execute (or `''` to swallow the keypress)
+
+4. **Swallowing keypresses in normal mode**: Uses `g@` operator with no-op `operatorfunc` (`MorphOpFuncNoop`)
+
+### Text Change Detection
+
+1. `nvim_buf_attach` with `on_bytes` callback fires during buffer changes
+2. **Problem**: Buffer is in inconsistent state during `on_bytes`
+3. **Solution**: `create_buf_watcher()` batches `on_bytes` events and fires callback on `TextChanged` autocmd (when buffer is stable)
+4. `_on_bytes_after_autocmd()`:
+ - Finds extmarks overlapping the changed region
+ - Compares cached `tag.curr_text` with current extmark text
+ - Fires `on_change` handlers from innermost to outermost with bubbling
+
+## Buffer Management
+
+### Per-Buffer State
+
+- Each `Morph` instance is bound to one buffer
+- Namespace created per buffer: `vim.b[bufnr]._renderer_ns`
+- Tracks:
+ - `changedtick`: Detects external buffer changes
+ - `changing`: Flag to ignore self-inflicted changes
+ - `textlock`: Prevents immediate re-renders during callbacks
+ - `text_content.old/curr`: Lines, extmarks, tag <-> extmark mappings
+
+### Cleanup
+
+- `BufDelete`/`BufUnload`/`BufWipeout` autocmds trigger:
+ - Full tree unmount (depth-first)
+ - Buffer watcher cleanup
+
+### Textlock Handling
+
+- `is_textlock()` probes by trying to modify a hidden scratch buffer
+- When textlocked (e.g., during `on_bytes`), `ctx:update()` schedules the re-render via `vim.schedule()`
+
+## Design Decisions
+
+### Single-File Architecture
+
+The entire framework lives in one file for easy vendoring. Plugin authors can add morph.nvim as a git submodule and require it directly without dependency management concerns.
+
+### No Virtual DOM Diffing
+
+Unlike React, morph.nvim doesn't diff the virtual tree structure. Instead:
+
+- Reconciliation tracks component identity via keys
+- Text diffing happens at the buffer level via Levenshtein
+- Extmarks track element positions automatically (Neovim handles the bookkeeping)
+
+### Context Object vs Hooks
+
+Uses a persistent `ctx` object instead of React-style hooks:
+
+- Same `ctx` instance across renders
+- No hook dependency arrays
+- No stale closure issues
+- Simpler mental model
+
+### Hyperscript Shorthand
+
+`h.Comment({}, 'text')` automatically sets `hl = 'Comment'` via `__index` metamethod, providing a concise syntax for styled text.
+
+### Event Bubbling
+
+Both keypress and `on_change` events bubble from innermost to outermost elements. Handlers can stop propagation by setting `e.bubble_up = false`.
+
+### Levenshtein Priority
+
+When multiple operations have equal cost, priority is: delete > add > change. This produces more intuitive results when removing items from keyed lists (e.g., removing 'b' from ['a','b'] deletes 'b' rather than substituting 'b' for 'a' and deleting 'a').
+
+### Extmark Gravity
+
+Uses left gravity for start (stays put when text inserted before) and right gravity for end (expands when text inserted at end). This matches the intuitive behavior for text regions.
+
+### TextChanged Batching
+
+`on_bytes` fires during buffer modification when the buffer is in an inconsistent state. Callbacks are deferred to `TextChanged` autocmd when the buffer is stable.
diff --git a/examples/big_data_set.lua b/examples/big_data_set.lua
index 4586b15..1c30eb1 100644
--- a/examples/big_data_set.lua
+++ b/examples/big_data_set.lua
@@ -113,8 +113,8 @@ end
-- /_/ \_\ .__/| .__/
-- |_| |_|
---- @param ctx morph.Ctx
-local function App(ctx)
+--- @param _ctx morph.Ctx
+local function App(_ctx)
return h('text', {}, {
--
-- List of items
diff --git a/examples/buffer_portal.lua b/examples/buffer_portal.lua
index 19056eb..2862297 100644
--- a/examples/buffer_portal.lua
+++ b/examples/buffer_portal.lua
@@ -70,10 +70,13 @@ local function BufferPortal(ctx)
end
local state = assert(ctx.state)
+ local portal_update = state.portal_update
-- When this component updates, update the portal content
- if ctx.phase == 'update' then assert(state.portal_update)(ctx.children) end
+ --- @diagnostic disable: unnecessary-assert, need-check-nil
+ if ctx.phase == 'update' then assert(portal_update)(ctx.children) end
-- When unmounting, clear the portal content
- if ctx.phase == 'unmount' then assert(state.portal_update)(nil) end
+ if ctx.phase == 'unmount' then assert(portal_update)(nil) end
+ --- @diagnostic enable: unnecessary-assert, need-check-nil
return nil -- This component renders nothing in its own buffer
end
@@ -101,21 +104,22 @@ end
local function Counter(ctx)
if ctx.phase == 'mount' then ctx.state = { count = 1 } end
local state = assert(ctx.state)
+ local count = state.count or 0
return {
'Value: ',
- h.Number({}, tostring(state.count)), -- Display current count
+ h.Number({}, tostring(count)), -- Display current count
' ',
h(Button, { -- Decrement button
text = ' - ',
hl = 'DiffDelete', -- Red highlight
- on_click = function() ctx:update { count = state.count - 1 } end,
+ on_click = function() ctx:update { count = count - 1 } end,
}),
' / ',
h(Button, { -- Increment button
text = ' + ',
hl = 'DiffAdd', -- Green highlight
- on_click = function() ctx:update { count = state.count + 1 } end,
+ on_click = function() ctx:update { count = count + 1 } end,
}),
}
end
diff --git a/examples/counter.lua b/examples/counter.lua
index 6b9fe7e..19d7665 100644
--- a/examples/counter.lua
+++ b/examples/counter.lua
@@ -39,17 +39,18 @@ local function Counter(ctx)
-- Initialize state only on first render (mount phase)
if ctx.phase == 'mount' then ctx.state = { count = 1 } end
local state = assert(ctx.state)
+ local count = state.count or 0
return {
'Value: ',
- h.Number({}, tostring(state.count)), -- Display current count
+ h.Number({}, tostring(count)), -- Display current count
' ',
h(Button, { -- Decrement button
text = ' - ',
hl = 'DiffDelete', -- Red highlight
on_click = function()
-- Update state and trigger re-render
- ctx:update { count = state.count - 1 }
+ ctx:update { count = count - 1 }
end,
}),
' / ',
@@ -58,7 +59,7 @@ local function Counter(ctx)
hl = 'DiffAdd', -- Green highlight
on_click = function()
-- Update state and trigger re-render
- ctx:update { count = state.count + 1 }
+ ctx:update { count = count + 1 }
end,
}),
}
diff --git a/examples/filetree.lua b/examples/filetree.lua
index 32a9c1a..673246b 100644
--- a/examples/filetree.lua
+++ b/examples/filetree.lua
@@ -241,6 +241,7 @@ local function FsNode(ctx)
--- @type morph.examples.Path | nil
local to_focus
+ --- @diagnostic disable-next-line: unnecessary-if
if kind == 'dir' then
if is_expanded then
-- If the directory itself is expanded, then collapse it:
@@ -263,6 +264,7 @@ local function FsNode(ctx)
l = function(e)
e.bubble_up = false
local to_focus
+ --- @diagnostic disable-next-line: unnecessary-if
if kind == 'dir' then
tree:set_expanded(path, true)
to_focus = tree:children(path)[1]
@@ -314,6 +316,7 @@ local function App(ctx)
end
if ctx.phase ~= 'unmount' then
+ --- @diagnostic disable-next-line: unnecessary-if
if state.focused then
local to_focus = state.focused._path
-- Nil out the focused state, but don't re-render:
@@ -330,9 +333,10 @@ local function App(ctx)
end
end
+ local tree = assert(state.tree)
return h(FsNode, {
- path = state.tree._root,
- tree = state.tree,
+ path = tree._root,
+ tree = tree,
level = 0,
refresh = refresh,
})
diff --git a/lua/morph.lua b/lua/morph.lua
index 1bd344b..d8b0add 100644
--- a/lua/morph.lua
+++ b/lua/morph.lua
@@ -43,13 +43,28 @@
-- +=====: =:
-- :=::::==.
--
+-- A React-like component library for Neovim buffers.
+--
+-- This module provides:
+-- - h() : hyperscript for creating virtual DOM tags
+-- - Pos00 : 0-based position class for buffer coordinates
+-- - Extmark : wrapper around Neovim's extmark API
+-- - Ctx : component context (props, state, lifecycle)
+-- - Morph : the main class that renders components to buffers
+--
+-- The core idea: describe your UI as a tree of tags (like HTML), and Morph
+-- will efficiently update the buffer to match using Levenshtein diffing.
+-- Used by expr-mappings to swallow key-presses without executing anything
function _G.MorphOpFuncNoop() end
-local H = {}
-
--------------------------------------------------------------------------------
-- Type Definitions
+--
+-- The type hierarchy flows from abstract to concrete:
+-- Tag (recipe) -> Element (instantiated tag with extmark)
+-- Node -> Tree (composable structures)
+-- Component (function that produces Trees)
--------------------------------------------------------------------------------
--- @alias morph.TagEventHandler fun(e: { tag: morph.Element, mode: string, lhs: string, bubble_up: boolean }): string
@@ -80,43 +95,292 @@ local H = {}
--- @class morph.Element : morph.Tag
--- @field extmark morph.Extmark
---- @alias morph.Node nil | boolean | string | morph.Tag
+--- @alias morph.Node nil | boolean | string | number | morph.Tag
--- @alias morph.Tree morph.Node | morph.Node[]
--- @alias morph.Component fun(ctx: morph.Ctx): morph.Tree
--------------------------------------------------------------------------------
--- h: Hyper-script Utility
+-- Tree Utilities
+--
+-- Helper functions for working with the tree structure. These are used
+-- throughout the codebase to identify node types and compute diffs.
--------------------------------------------------------------------------------
-H.h = setmetatable({}, {
- --- @param name 'text' | morph.Component
- --- @param attributes? morph.TagAttributes
- --- @param children? morph.Tree
- __call = function(_, name, attributes, children)
- return {
- kind = 'tag',
- name = name,
- attributes = attributes or {},
- children = children or {},
+--- Determine the type of a tree node.
+--- @param node morph.Tree
+--- @return 'nil'|'boolean'|'string'|'number'|'array'|'tag'|'component'
+local function tree_type(node)
+ if node == nil or node == vim.NIL then return 'nil' end
+ if type(node) == 'boolean' then return 'boolean' end
+ if type(node) == 'string' then return 'string' end
+ if type(node) == 'number' then return 'number' end
+ if type(node) == 'table' then
+ if node.kind == 'tag' then
+ return vim.is_callable(node.name) and 'component' or 'tag'
+ else
+ return 'array'
+ end
+ end
+ error('unknown tree node type: ' .. type(node))
+end
+
+--- Compute an identity key for a node, used to match old/new nodes during reconciliation.
+--- Includes the node type, component function (if any), and explicit key attribute.
+--- @param node morph.Node
+--- @param index integer fallback key if no explicit key
+--- @return string
+local function tree_identity_key(node, index)
+ local t = tree_type(node)
+ if t == 'nil' or t == 'boolean' or t == 'string' or t == 'number' or t == 'array' then
+ return t
+ elseif t == 'tag' then
+ local tag = node --[[@as morph.Tag]]
+ return 'tag-' .. tag.name .. '-' .. tostring(tag.attributes.key or index)
+ elseif t == 'component' then
+ local tag = node --[[@as morph.Tag]]
+ return 'component-' .. tostring(tag.name) .. '-' .. tostring(tag.attributes.key or index)
+ end
+ error 'unreachable'
+end
+
+--------------------------------------------------------------------------------
+-- Levenshtein Diff Algorithm
+--
+-- Used to compute the minimal set of changes needed to transform one list
+-- into another. We use this both for text diffing (lines, characters) and
+-- for component reconciliation (matching old/new nodes).
+--------------------------------------------------------------------------------
+
+--- @alias morph.LevenshteinChange { kind: 'add', item: T, index: integer } | { kind: 'delete', item: T, index: integer } | { kind: 'change', from: T, to: T, index: integer }
+
+--- @class morph.LevenshteinOpts
+--- @field from any[]
+--- @field to any[]
+--- @field are_any_equal? boolean
+--- @field cost? morph.LevenshteinCost
+
+--- @class morph.LevenshteinCost
+--- @field of_add? integer
+--- @field of_delete? integer
+--- @field of_change? fun(a: any, b: any, ai: integer, bi: integer): integer
+
+--- Compute the minimal edit sequence to transform `from` into `to`.
+--- @param opts morph.LevenshteinOpts
+--- @return morph.LevenshteinChange[]
+local function levenshtein(opts)
+ local are_any_equal = opts.are_any_equal == nil and true or opts.are_any_equal
+ local cost_of_add = opts.cost and opts.cost.of_add or 1
+ local cost_of_delete = opts.cost and opts.cost.of_delete or 1
+ local cost_of_change = opts.cost and opts.cost.of_change or function() return 1 end
+
+ local from, to = opts.from, opts.to
+ local m, n = #from, #to
+
+ -- Build the DP table. Each cell dp[i][j] represents the minimum cost to
+ -- transform from[1..i] into to[1..j].
+ --- @diagnostic disable-next-line: assign-type-mismatch
+ local dp = {} --- @type integer[][]
+ for i = 0, m do
+ --- @diagnostic disable-next-line: assign-type-mismatch
+ dp[i] = { [0] = i * cost_of_delete }
+ end
+ for j = 1, n do
+ --- @diagnostic disable-next-line: need-check-nil
+ dp[0][j] = j * cost_of_add
+ end
+
+ --- @diagnostic disable: need-check-nil
+ for i = 1, m do
+ for j = 1, n do
+ if are_any_equal and from[i] == to[j] then
+ dp[i][j] = dp[i - 1][j - 1]
+ else
+ dp[i][j] = math.min(
+ dp[i - 1][j] + cost_of_delete,
+ dp[i][j - 1] + cost_of_add,
+ dp[i - 1][j - 1] + cost_of_change(from[i], to[j], i, j)
+ )
+ end
+ end
+ end
+ --- @diagnostic enable: need-check-nil
+
+ -- Backtrack to extract the changes.
+ --
+ -- IMPORTANT: We must check which operation was *actually* used to reach the
+ -- current cell, not just compare previous cell values. When costs are
+ -- variable (e.g., key-based reconciliation where matching keys cost less),
+ -- the previous cell values don't tell us which path was taken - we need to
+ -- verify that prev_cell + operation_cost == current_cell.
+ --
+ -- Priority when multiple operations tie: delete > add > change.
+ -- This prefers removing items over substituting them, which produces more
+ -- intuitive results for keyed list reconciliation (e.g., removing 'b' from
+ -- ['a','b'] should delete 'b', not substitute 'b' for 'a' and delete 'a').
+ local changes = {} --- @type morph.LevenshteinChange[]
+ local i, j = m, n
+
+ while i > 0 or j > 0 do
+ --- @diagnostic disable-next-line: need-check-nil
+ local current = dp[i][j]
+
+ -- Check if delete was the operation used (move up: dp[i-1][j] + delete_cost == current)
+ --- @diagnostic disable-next-line: need-check-nil
+ local can_delete = i > 0 and dp[i - 1][j] + cost_of_delete == current
+
+ -- Check if add was the operation used (move left: dp[i][j-1] + add_cost == current)
+ --- @diagnostic disable-next-line: need-check-nil
+ local can_add = j > 0 and dp[i][j - 1] + cost_of_add == current
+
+ -- Check if change/keep was the operation used (move diagonal)
+ local can_diag = false
+ if i > 0 and j > 0 then
+ if are_any_equal and from[i] == to[j] then
+ --- @diagnostic disable-next-line: need-check-nil
+ can_diag = dp[i - 1][j - 1] == current
+ else
+ --- @diagnostic disable-next-line: need-check-nil
+ can_diag = dp[i - 1][j - 1] + cost_of_change(from[i], to[j], i, j) == current
+ end
+ end
+
+ -- Choose operation with priority: delete > add > diagonal (change/keep)
+ if can_delete then
+ table.insert(changes, { kind = 'delete', item = from[i], index = i })
+ i = i - 1
+ elseif can_add then
+ table.insert(changes, { kind = 'add', item = to[j], index = i + 1 })
+ j = j - 1
+ elseif can_diag then
+ if not are_any_equal or from[i] ~= to[j] then
+ table.insert(changes, { kind = 'change', from = from[i], to = to[j], index = i })
+ end
+ i, j = i - 1, j - 1
+ else
+ -- This should never happen with a valid DP table
+ error('levenshtein backtrack: no valid operation found at (' .. i .. ',' .. j .. ')')
+ end
+ end
+
+ return changes
+end
+
+--------------------------------------------------------------------------------
+-- Textlock Detection
+--
+-- Neovim has a "textlock" that prevents buffer/window changes during certain
+-- operations (like autocmd callbacks). We need to detect this so we can
+-- schedule state updates for later instead of applying them immediately.
+--------------------------------------------------------------------------------
+
+--- A lazily-created unlisted scratch buffer used to probe for textlock.
+--- We reuse a single buffer to avoid creating/destroying buffers on every check.
+--- @type integer?
+local textlock_probe_buf = nil
+
+--- Check if we're currently in a textlock (can't modify buffers).
+--- Uses nvim_buf_set_lines on a hidden probe buffer.
+--- @return boolean
+local function is_textlock()
+ --- @diagnostic disable-next-line: unnecessary-if
+ if vim.in_fast_event() then return true end
+
+ -- Lazily create the probe buffer. We can't create it during textlock,
+ -- but that's fine - if we're in textlock, this pcall will fail and we'll
+ -- know we're in textlock. The buffer persists for future checks.
+ if not textlock_probe_buf or not vim.api.nvim_buf_is_valid(textlock_probe_buf) then
+ local ok, buf = pcall(vim.api.nvim_create_buf, false, true)
+ if not ok then
+ -- Buffer creation failed - we're definitely in textlock
+ return true
+ end
+ textlock_probe_buf = buf --[[@as integer]]
+ end
+
+ -- Try to set lines - this will fail with E565 if textlock is active.
+ -- Setting the same content is a no-op in terms of buffer state.
+ --- @diagnostic disable-next-line: param-type-mismatch
+ local ok, err = pcall(vim.api.nvim_buf_set_lines, textlock_probe_buf, 0, -1, false, { '' })
+
+ if not ok and type(err) == 'string' and err:find 'E565' then return true end
+
+ return false
+end
+
+--------------------------------------------------------------------------------
+-- Buffer Watcher
+--
+-- Neovim's nvim_buf_attach on_bytes callback fires *during* the change,
+-- when the buffer is in an inconsistent state. We use TextChanged autocmd
+-- to delay our callback until after the change is complete.
+--------------------------------------------------------------------------------
+
+--- @class morph.BufWatcher
+--- @field last_on_bytes_args unknown[]
+--- @field text_changed_autocmd_id integer
+--- @field cleanup fun() Remove the watcher
+
+--- Create a buffer watcher that calls `callback` after text changes.
+--- @param bufnr integer
+--- @param callback function Called with on_bytes args after TextChanged fires
+--- @return morph.BufWatcher
+local function create_buf_watcher(bufnr, callback)
+ local watcher = {
+ last_on_bytes_args = {},
+ }
+
+ -- Capture on_bytes args but don't call callback yet
+ vim.api.nvim_buf_attach(bufnr, false, {
+ on_bytes = function(...) watcher.last_on_bytes_args = { ... } end,
+ })
+
+ -- Fire callback when TextChanged fires (buffer is now stable)
+ watcher.text_changed_autocmd_id = vim.api.nvim_create_autocmd(
+ { 'TextChanged', 'TextChangedI', 'TextChangedP' },
+ {
+ buffer = bufnr,
+ callback = function() callback(unpack(watcher.last_on_bytes_args)) end,
}
+ )
+
+ function watcher.cleanup() vim.api.nvim_del_autocmd(watcher.text_changed_autocmd_id) end
+
+ return watcher
+end
+
+--------------------------------------------------------------------------------
+-- h(): Hyperscript - Creating Virtual DOM Tags
+--
+-- Usage:
+-- h('text', { hl = 'Comment' }, { 'Hello' }) -- explicit text tag
+-- h.Comment({}, { 'Hello' }) -- shorthand: h.
+-- h(MyComponent, { prop = 1 }, { ... }) -- component tag
+--
+-- This is the primary way to construct your UI tree.
+--------------------------------------------------------------------------------
+
+--- @type table & fun(name: string | morph.Component, attributes?: morph.TagAttributes, children?: morph.Tree): morph.Tag>
+--- @diagnostic disable-next-line: assign-type-mismatch
+local h = setmetatable({}, {
+ -- h('text', attrs, children) - create a tag directly
+ __call = function(_, name, attributes, children)
+ return { kind = 'tag', name = name, attributes = attributes or {}, children = children or {} }
end,
- --- @param hl string
- __index = function(_, hl)
- --- @param attributes? morph.TagAttributes
- --- @param children? morph.Tree
+ -- h.Comment(attrs, children) - shorthand for h('text', { hl = 'Comment', ...attrs }, children)
+ __index = function(self, highlight_group)
return function(attributes, children)
- return H.h(
- 'text',
- vim.tbl_deep_extend('force', { hl = hl }, attributes or {}),
- children or {}
- )
+ local merged_attrs = vim.tbl_deep_extend('force', { hl = highlight_group }, attributes or {})
+ return self('text', merged_attrs, children or {})
end
end,
-}) --[[@as table & fun(name: string | morph.Component, attributes?: morph.TagAttributes, children?: morph.Tree): morph.Tag>]]
+})
--------------------------------------------------------------------------------
--- class Pos00
+-- Pos00: Zero-Based Buffer Positions
+--
+-- Neovim's API is inconsistent about 0-based vs 1-based indexing.
+-- This class provides a consistent 0-based position type with comparison ops.
--------------------------------------------------------------------------------
--- @class morph.Pos00
@@ -125,18 +389,28 @@ H.h = setmetatable({}, {
local Pos00 = {}
Pos00.__index = Pos00
---- @param row integer
---- @param col integer
+--- @param row integer 0-based row
+--- @param col integer 0-based column
function Pos00.new(row, col) return setmetatable({ row, col }, Pos00) end
---- @param other morph.Pos00
+
function Pos00:__eq(other) return self[1] == other[1] and self[2] == other[2] end
---- @param other morph.Pos00
-function Pos00:__lt(other) return self[1] < other[1] or (self[1] == other[1] and self[2] < other[2]) end
---- @param other morph.Pos00
-function Pos00:__gt(other) return self[1] > other[1] or (self[1] == other[1] and self[2] > other[2]) end
+
+function Pos00:__lt(other)
+ if self[1] ~= other[1] then return self[1] < other[1] end
+ return self[2] < other[2]
+end
+
+function Pos00:__gt(other)
+ if self[1] ~= other[1] then return self[1] > other[1] end
+ return self[2] > other[2]
+end
--------------------------------------------------------------------------------
--- class Extmark
+-- Extmark: Wrapper Around Neovim's Extmark API
+--
+-- Extmarks track regions of text that move as the buffer is edited.
+-- This wrapper provides a cleaner interface and handles edge cases like
+-- extmarks that extend past the end of the buffer.
--------------------------------------------------------------------------------
--- @class morph.Extmark
@@ -149,6 +423,9 @@ function Pos00:__gt(other) return self[1] > other[1] or (self[1] == other[1] and
local Extmark = {}
Extmark.__index = Extmark
+--- Create a new extmark in the buffer.
+--- Uses left gravity for start (stays put when text inserted before) and
+--- right gravity for end (expands when text inserted at end).
--- @param bufnr integer
--- @param ns integer
--- @param start morph.Pos00
@@ -156,87 +433,74 @@ Extmark.__index = Extmark
--- @param opts vim.api.keyset.set_extmark
--- @return morph.Extmark
function Extmark.new(bufnr, ns, start, stop, opts)
- local id = vim.api.nvim_buf_set_extmark(
- bufnr,
- ns,
- start[1],
- start[2],
- vim.tbl_extend('force', {
- end_row = stop[1],
- end_col = stop[2],
- right_gravity = false,
- end_right_gravity = true,
- }, opts)
- )
+ local extmark_opts = vim.tbl_extend('force', {
+ end_row = stop[1],
+ end_col = stop[2],
+ right_gravity = false,
+ end_right_gravity = true,
+ }, opts)
+
+ local id = vim.api.nvim_buf_set_extmark(bufnr, ns, start[1], start[2], extmark_opts)
return setmetatable(
{ id = id, start = start, stop = stop, raw = opts, ns = ns, bufnr = bufnr },
Extmark
)
end
---- @private
+--- Retrieve an existing extmark by its ID.
--- @param bufnr integer
--- @param ns integer
--- @param id integer
---- @param start_row0 integer
---- @param start_col0 integer
---- @param details vim.api.keyset.extmark_details
---- @return morph.Extmark
+--- @return morph.Extmark?
+function Extmark.by_id(bufnr, ns, id)
+ local raw = vim.api.nvim_buf_get_extmark_by_id(bufnr, ns, id, { details = true })
+ if not raw then return nil end
+
+ local start_row0, start_col0, details = unpack(raw)
+ return Extmark._from_raw(bufnr, ns, id, start_row0, start_col0, assert(details))
+end
+
+--- @private
+--- Construct an Extmark from raw API data, normalizing bounds that extend past buffer end.
function Extmark._from_raw(bufnr, ns, id, start_row0, start_col0, details)
local start = Pos00.new(start_row0, start_col0)
local stop = Pos00.new(start_row0, start_col0)
+
if details and details.end_row ~= nil and details.end_col ~= nil then
stop = Pos00.new(details.end_row --[[@as integer]], details.end_col --[[@as integer]])
end
- local extmark = setmetatable({
- id = id,
- start = start,
- stop = stop,
- raw = details,
- ns = ns,
- bufnr = bufnr,
- }, Extmark)
+ local extmark = setmetatable(
+ { id = id, start = start, stop = stop, raw = details, ns = ns, bufnr = bufnr },
+ Extmark
+ )
- -- Normalize extmark ending-bounds:
- local buf_max_line0 = math.max(0, vim.api.nvim_buf_line_count(bufnr) - 1)
- if extmark.stop[1] > buf_max_line0 then
- local last_line = vim.api.nvim_buf_get_lines(bufnr, buf_max_line0, buf_max_line0 + 1, true)[1]
+ -- Clamp extmark bounds to actual buffer size (extmarks can overshoot after deletions)
+ local last_line_idx = math.max(0, vim.api.nvim_buf_line_count(bufnr) - 1)
+ if extmark.stop[1] > last_line_idx then
+ local last_line = vim.api.nvim_buf_get_lines(bufnr, last_line_idx, last_line_idx + 1, true)[1]
or ''
- extmark.stop = Pos00.new(buf_max_line0, last_line:len())
+ extmark.stop = Pos00.new(last_line_idx, #last_line)
end
- return extmark
-end
---- @param bufnr integer
---- @param ns integer
---- @param id integer
-function Extmark.by_id(bufnr, ns, id)
- local raw_extmark = vim.api.nvim_buf_get_extmark_by_id(bufnr, ns, id, { details = true })
- if not raw_extmark then return nil end
- local start_row0, start_col0, details = unpack(raw_extmark)
- return Extmark._from_raw(bufnr, ns, id, start_row0, start_col0, assert(details))
+ return extmark
end
--- @private
---- @param bufnr integer
---- @param ns integer
---- @param start morph.Pos00
---- @param stop morph.Pos00
+--- Find all extmarks that overlap with the given region.
--- @return morph.Extmark[]
-function Extmark._get_near_overshoot(bufnr, ns, start, stop)
+function Extmark._get_in_range(bufnr, ns, start, stop)
+ local raw_extmarks = vim.api.nvim_buf_get_extmarks(
+ bufnr,
+ ns,
+ { start[1], start[2] },
+ { stop[1], stop[2] },
+ { details = true, overlap = true }
+ )
+
return vim
- .iter(
- vim.api.nvim_buf_get_extmarks(
- bufnr,
- ns,
- { start[1], start[2] },
- { stop[1], stop[2] },
- { details = true, overlap = true }
- )
- )
+ .iter(raw_extmarks)
:map(function(ext)
- --- @type integer, integer, integer, any|nil
local id, line0, col0, details = unpack(ext)
return Extmark._from_raw(bufnr, ns, id, line0, col0, assert(details))
end)
@@ -244,42 +508,49 @@ function Extmark._get_near_overshoot(bufnr, ns, start, stop)
end
--- @private
+--- Extract the text content covered by this extmark.
function Extmark:_text()
- local start = self.start
- local stop = self.stop
+ local start, stop = self.start, self.stop
if start == stop then return '' end
- local insert_blank = false
- if stop[2] == 0 then
- -- set stop to end of previous line (stop[1] - 1)
- if stop[1] > 0 then
- insert_blank = true
- local prev_line = vim.api.nvim_buf_get_lines(self.bufnr, stop[1] - 1, stop[1], true)[1] or ''
- stop = Pos00.new(stop[1] - 1, #prev_line)
- end
+ -- Handle edge case: if stop is at column 0, we need to include the newline
+ -- from the previous line, which getregion doesn't handle well
+ local needs_trailing_newline = false
+ if stop[2] == 0 and stop[1] > 0 then
+ needs_trailing_newline = true
+ local prev_line = vim.api.nvim_buf_get_lines(self.bufnr, stop[1] - 1, stop[1], true)[1] or ''
+ stop = Pos00.new(stop[1] - 1, #prev_line)
end
+ -- Convert to 1-based positions for getregion (Neovim's API inconsistency strikes again)
local pos1 = { self.bufnr, start[1] + 1, start[2] + 1 }
local pos2 = { self.bufnr, stop[1] + 1, stop[2] == 0 and 1 or stop[2] }
+
local ok, lines = pcall(vim.fn.getregion, pos1, pos2, { type = 'v' })
if not ok then
vim.api.nvim_echo({
{ '(morph.nvim:getregion:invalid-pos) ', 'ErrorMsg' },
- {
- '{ start, end } = ' .. vim.inspect({ pos1, pos2 }, { newline = ' ', indent = '' }),
- },
+ { '{ start, end } = ' .. vim.inspect({ pos1, pos2 }, { newline = ' ', indent = '' }) },
}, true, {})
error(lines)
end
- if insert_blank then
- table.insert(lines --[[@as (string[])]], '')
+ if needs_trailing_newline then
+ table.insert(lines --[[@as string[] ]], '')
end
- return vim.iter(lines):join '\n'
+ return table.concat(lines --[[@as string[] ]], '\n')
end
--------------------------------------------------------------------------------
--- class Ctx
+-- Ctx: Component Context (Props, State, Lifecycle)
+--
+-- Every component receives a Ctx that provides:
+-- - props: immutable data passed from parent
+-- - state: mutable data owned by this component
+-- - phase: 'mount' | 'update' | 'unmount' lifecycle stage
+-- - update(newState): trigger a re-render with new state
+-- - refresh(): re-render with current state
+-- - do_after_render(fn): schedule work after the render completes
--------------------------------------------------------------------------------
--- @generic TProps
@@ -313,29 +584,44 @@ function Ctx.new(bufnr, document, props, state, children)
}, Ctx)
end
+--- Update state and trigger a re-render.
+--- During 'mount' phase, this only updates state (no re-render, to avoid infinite loops).
+--- If we're in a textlock (e.g., during an on_bytes callback), the re-render is scheduled.
--- @param new_state TState
function Ctx:update(new_state)
- local noop = function() end
-
self.state = new_state
- if self.phase ~= 'mount' then
- if (self.document and self.document.textlock) or H.is_textlock() then
- vim.schedule(function() (self.on_change or noop)() end)
- else
- (self.on_change or noop)()
- end
+
+ -- Don't trigger re-render during mount (component is still being set up)
+ if self.phase == 'mount' then return end
+ if not self.on_change then return end
+
+ -- Textlock means we can't modify the buffer right now - schedule for later
+ local is_textlocked = (self.document and self.document.textlock) or is_textlock()
+ if is_textlocked then
+ vim.schedule(self.on_change)
+ else
+ self.on_change()
end
end
-function Ctx:refresh() return self:update(self.state) end
+--- Re-render with current state (convenience wrapper around update).
+function Ctx:refresh() self:update(self.state) end
+--- Schedule a callback to run after the current render completes.
+--- Useful for focus management, scrolling, etc.
--- @param fn function
function Ctx:do_after_render(fn)
if self._register_after_render_callback then self._register_after_render_callback(fn) end
end
--------------------------------------------------------------------------------
--- class Morph
+-- Morph: The Main Renderer Class
+--
+-- A Morph instance is bound to a single buffer. It provides:
+-- - render(tree): render static markup to the buffer
+-- - mount(tree): render a component tree with lifecycle management
+-- - get_elements_at(pos): find elements at a cursor position
+-- - get_element_by_id(id): find an element by its id attribute
--------------------------------------------------------------------------------
--- @alias morph.MorphTextState {
@@ -351,7 +637,7 @@ end
--- @field private changedtick integer
--- @field private changing boolean
--- @field private textlock boolean
---- @field private orig_kmaps table?>
+--- @field private original_keymaps table>
--- @field private text_content { old: morph.MorphTextState, curr: morph.MorphTextState }
--- @field private component_tree { old: morph.Tree }
--- @field private cleanup_hooks function[]
@@ -360,165 +646,152 @@ local Morph = {}
Morph.__index = Morph
--------------------------------------------------------------------------------
--- class Morph: Static functions
+-- Static Utilities
+--
+-- These functions work on trees without needing a Morph instance.
+-- Useful for testing or converting markup to strings.
--------------------------------------------------------------------------------
--- TODO: public API Pos00
---- @param opts {
---- tree: morph.Tree,
---- on_tag?: fun(tag: morph.Tag, start0: morph.Pos00, stop0: morph.Pos00): any
---- }
+--- Convert a tree to an array of lines, optionally calling on_tag for each tag.
+--- This is the core "rendering" logic that flattens the tree into text.
+--- @param opts { tree: morph.Tree, on_tag?: fun(tag: morph.Tag, start0: morph.Pos00, stop0: morph.Pos00): any }
+--- @return string[]
function Morph.markup_to_lines(opts)
- -- As we visit tags (specifically) we want to keep track of the text of that tag
- -- and put it on the tag object, so that the text is cached.
- --- @type { text: string[] }[]
- local text_accumulators = {}
+ local lines = {} --- @type string[]
+ local curr_line1, curr_col1 = 1, 1 -- 1-based position tracking
- --- @type string[]
- local lines = {}
+ -- Stack of text accumulators - each tag tracks its own text content
+ -- so we can cache it for on_change handlers later
+ local text_accumulators = {} --- @type { text: string[] }[]
- local curr_line1 = 1
- local curr_col1 = 1 -- exclusive: sits one position **beyond** the last inserted text
-
- --- @param s string
- local function put(s)
+ local function emit_text(s)
lines[curr_line1] = (lines[curr_line1] or '') .. s
curr_col1 = #lines[curr_line1] + 1
- for i = 1, #text_accumulators do
- local acc = text_accumulators[i]
+ -- Append to all active accumulators (for nested tags)
+ for _, acc in ipairs(text_accumulators) do
table.insert(acc.text, s)
end
end
- local function put_line()
+
+ local function emit_newline()
table.insert(lines, '')
curr_line1 = curr_line1 + 1
curr_col1 = 1
- for i = 1, #text_accumulators do
- local acc = text_accumulators[i]
+ for _, acc in ipairs(text_accumulators) do
table.insert(acc.text, '\n')
end
end
- --- @param node morph.Tree
local function visit(node)
- H.tree_match(node, {
- string = function(s_node)
- local node_lines = vim.split(s_node, '\n')
- for line_num, s in ipairs(node_lines) do
- if line_num > 1 then put_line() end
- put(s)
- end
- end,
- array = function(ts)
- for _, child in ipairs(ts) do
- visit(child)
- end
- end,
- tag = function(t)
- table.insert(text_accumulators, { text = {} })
+ local node_type = tree_type(node)
+
+ if node_type == 'string' then
+ -- Split on newlines and emit each part
+ local parts = vim.split(node --[[@as string]], '\n')
+ for i, part in ipairs(parts) do
+ if i > 1 then emit_newline() end
+ emit_text(part)
+ end
+ elseif node_type == 'number' then
+ -- Convert number to string and emit
+ emit_text(tostring(node --[[@as number]]))
+ elseif node_type == 'array' then
+ for _, child in
+ ipairs(node --[[@as morph.Node[] ]])
+ do
+ visit(child)
+ end
+ elseif node_type == 'tag' then
+ local tag = node --[[@as morph.Tag]]
+ table.insert(text_accumulators, { text = {} })
- local start0 = Pos00.new(curr_line1 - 1, curr_col1 - 1)
- visit(t.children)
- local stop0 = Pos00.new(curr_line1 - 1, curr_col1 - 1)
+ local start0 = Pos00.new(curr_line1 - 1, curr_col1 - 1)
+ visit(tag.children)
+ local stop0 = Pos00.new(curr_line1 - 1, curr_col1 - 1)
- t.curr_text = table.concat(text_accumulators[#text_accumulators].text)
- table.remove(text_accumulators, #text_accumulators)
+ -- Cache the rendered text on the tag
+ local acc = table.remove(text_accumulators)
+ tag.curr_text = table.concat(acc.text)
- if opts.on_tag then opts.on_tag(t, start0, stop0) end
- end,
- component = function(Component, t)
- local ctx = Ctx.new(nil, nil, t.attributes, nil, t.children)
+ if opts.on_tag then opts.on_tag(tag, start0, stop0) end
+ elseif node_type == 'component' then
+ local tag = node --[[@as morph.Tag]]
+ local Component = tag.name --[[@as morph.Component]]
+ local ctx = Ctx.new(nil, nil, tag.attributes, nil, tag.children)
- local start = Pos00.new(curr_line1 - 1, curr_col1 - 1)
- visit(Component(ctx))
- local stop = Pos00.new(curr_line1 - 1, curr_col1 - 1)
+ local start0 = Pos00.new(curr_line1 - 1, curr_col1 - 1)
+ visit(Component(ctx))
+ local stop0 = Pos00.new(curr_line1 - 1, curr_col1 - 1)
- ctx.phase = 'unmount'
- Component(ctx)
+ -- Immediately unmount (this is stateless rendering)
+ ctx.phase = 'unmount'
+ Component(ctx)
- if opts.on_tag then opts.on_tag(t, start, stop) end
- end,
- })
+ if opts.on_tag then opts.on_tag(tag, start0, stop0) end
+ end
+ -- nil/boolean nodes produce no output
end
- visit(opts.tree)
+ visit(opts.tree)
return lines
end
+--- Convert a tree to a single string (convenience wrapper).
--- @param opts { tree: morph.Tree }
+--- @return string
function Morph.markup_to_string(opts) return table.concat(Morph.markup_to_lines(opts), '\n') end
+--- Apply minimal edits to transform buffer content from old_lines to new_lines.
+--- Uses Levenshtein distance to find the shortest edit sequence.
--- @param bufnr integer
---- @param old_lines string[] | nil
+--- @param old_lines string[]?
--- @param new_lines string[]
function Morph.patch_lines(bufnr, old_lines, new_lines)
- --
- -- Helpers:
- --
-
- --- @param start integer
- --- @param end_ integer
- --- @param strict_indexing boolean
- --- @param replacement string[]
- local function _set_lines(start, end_, strict_indexing, replacement)
- vim.api.nvim_buf_set_lines(bufnr, start, end_, strict_indexing, replacement)
- end
-
- --- @param start_row integer
- --- @param start_col integer
- --- @param end_row integer
- --- @param end_col integer
- --- @param replacement string[]
- local function _set_text(start_row, start_col, end_row, end_col, replacement)
- vim.api.nvim_buf_set_text(bufnr, start_row, start_col, end_row, end_col, replacement)
- end
+ old_lines = old_lines or vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
+
+ local line_changes = levenshtein { from = old_lines, to = new_lines }
+
+ for _, change in ipairs(line_changes) do
+ local line0 = change.index - 1
+
+ if change.kind == 'add' then
+ vim.api.nvim_buf_set_lines(bufnr, line0, line0, true, { change.item })
+ elseif change.kind == 'delete' then
+ vim.api.nvim_buf_set_lines(bufnr, line0, line0 + 1, true, {})
+ elseif change.kind == 'change' then
+ -- For changed lines, do character-level diffing for minimal edits
+ local char_changes = levenshtein {
+ --- @diagnostic disable-next-line: param-type-mismatch
+ from = vim.split(change.from, ''),
+ --- @diagnostic disable-next-line: param-type-mismatch
+ to = vim.split(change.to, ''),
+ }
- -- Morph the text to the desired state:
- local line_changes = (
- H.levenshtein {
- from = old_lines or vim.api.nvim_buf_get_lines(bufnr, 0, -1, false),
- to = new_lines,
- }
- ) --[[@as (morph.LevenshteinChange[])]]
-
- for _, line_change in ipairs(line_changes) do
- local line_num0 = line_change.index - 1
-
- if line_change.kind == 'add' then
- _set_lines(line_num0, line_num0, true, { line_change.item })
- elseif line_change.kind == 'change' then
- -- Compute inter-line diff, and apply:
- local col_changes = (
- H.levenshtein {
- from = vim.split(line_change.from, ''),
- to = vim.split(line_change.to, ''),
- }
- ) --[[@as (morph.LevenshteinChange[])]]
-
- for _, col_change in ipairs(col_changes) do
- local col_num0 = col_change.index - 1
- if col_change.kind == 'add' then
- _set_text(line_num0, col_num0, line_num0, col_num0, { col_change.item })
- elseif col_change.kind == 'change' then
- _set_text(line_num0, col_num0, line_num0, col_num0 + 1, { col_change.to })
- elseif col_change.kind == 'delete' then
- _set_text(line_num0, col_num0, line_num0, col_num0 + 1, {})
- else
- -- No change
+ for _, char_change in ipairs(char_changes) do
+ local col0 = char_change.index - 1
+ if char_change.kind == 'add' then
+ vim.api.nvim_buf_set_text(bufnr, line0, col0, line0, col0, { char_change.item })
+ elseif char_change.kind == 'delete' then
+ vim.api.nvim_buf_set_text(bufnr, line0, col0, line0, col0 + 1, {})
+ elseif char_change.kind == 'change' then
+ vim.api.nvim_buf_set_text(bufnr, line0, col0, line0, col0 + 1, { char_change.to })
end
end
- elseif line_change.kind == 'delete' then
- _set_lines(line_num0, line_num0 + 1, true, {})
- else
- -- No change
end
end
end
---- @param bufnr integer|nil
+--------------------------------------------------------------------------------
+-- Constructor
+--------------------------------------------------------------------------------
+
+--- Create a new Morph instance bound to a buffer.
+--- @param bufnr integer? Buffer number (nil or 0 means current buffer)
+--- @return morph.Morph
function Morph.new(bufnr)
- if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
+ bufnr = (bufnr == nil or bufnr == 0) and vim.api.nvim_get_current_buf() or bufnr
+ -- Each buffer gets its own namespace for extmarks
if vim.b[bufnr]._renderer_ns == nil then
vim.b[bufnr]._renderer_ns = vim.api.nvim_create_namespace('morph:' .. tostring(bufnr))
end
@@ -529,7 +802,7 @@ function Morph.new(bufnr)
changedtick = 0,
changing = false,
textlock = false,
- orig_kmaps = {},
+ original_keymaps = {},
text_content = {
old = { lines = {}, extmarks = {}, tags_to_extmark_ids = {}, extmark_ids_to_tag = {} },
curr = { lines = {}, extmarks = {}, tags_to_extmark_ids = {}, extmark_ids_to_tag = {} },
@@ -538,17 +811,26 @@ function Morph.new(bufnr)
cleanup_hooks = {},
}, Morph)
- self.buf_watcher = H.buf_attach_after_autocmd(
- bufnr,
- function(...) self:_on_bytes_after_autocmd(...) end
- )
+ -- Snapshot all buffer-local keymaps so we can restore them before each render
+ for _, mode in ipairs { 'i', 'n', 'v', 'x', 'o' } do
+ self.original_keymaps[mode] = {}
+ --- @diagnostic disable-next-line: param-type-mismatch
+ for _, map in ipairs(vim.api.nvim_buf_get_keymap(bufnr, mode)) do
+ self.original_keymaps[mode][map.lhs] = map
+ end
+ end
+
+ -- Watch for text changes so we can fire on_change handlers
+ --- @diagnostic disable-next-line: param-type-mismatch
+ self.buf_watcher = create_buf_watcher(bufnr, function(...) self:_on_bytes_after_autocmd(...) end)
table.insert(self.cleanup_hooks, self.buf_watcher.cleanup)
+ -- Clean up when buffer is deleted
local cleanup_autocmd = vim.api.nvim_create_autocmd({ 'BufDelete', 'BufUnload', 'BufWipeout' }, {
buffer = self.bufnr,
callback = function()
- for _, cleanup_hook in ipairs(self.cleanup_hooks) do
- cleanup_hook()
+ for _, cleanup in ipairs(self.cleanup_hooks) do
+ cleanup()
end
end,
})
@@ -558,12 +840,14 @@ function Morph.new(bufnr)
end
--------------------------------------------------------------------------------
--- class Morph: Instance methods
+-- Instance Methods
--------------------------------------------------------------------------------
---- Render static markup
+--- Render static markup to the buffer.
+--- This is a "one-shot" render - no lifecycle, no state, just text + extmarks.
--- @param tree morph.Tree
function Morph:render(tree)
+ -- Detect if buffer changed externally since our last render
local changedtick = vim.b[self.bufnr].changedtick
if changedtick ~= self.changedtick then
self.text_content.curr = {
@@ -571,294 +855,294 @@ function Morph:render(tree)
lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false),
tags_to_extmark_ids = {},
extmark_ids_to_tag = {},
- } --[[@as morph.MorphTextState]]
+ }
self.changedtick = changedtick
end
- -- Extmarks have to correlate to actual text, so we have to accumulate which
- -- ones we want to set, morph the buffer, then set the extmarks:
- --- @type { tag: morph.Tag, start: morph.Pos00, stop: morph.Pos00, opts: any }[]
- local extmarks_to_set = {}
+ -- We need to collect extmarks during tree traversal, but can't create them
+ -- until after the buffer text is updated (extmarks need valid positions)
+ local pending_extmarks = {} --- @type { tag: morph.Tag, start: morph.Pos00, stop: morph.Pos00, opts: any }[]
- -- Unmap all existing mappings so that new mappings are fresh:
- for mode, maps in pairs(self.orig_kmaps) do
- for lhs, _ in pairs(maps) do
- self:_kunmap(mode, lhs, { buffer = self.bufnr })
+ -- Clear all buffer-local keymaps, then restore originals
+ for _, mode in ipairs { 'i', 'n', 'v', 'x', 'o' } do
+ for _, map in ipairs(vim.api.nvim_buf_get_keymap(self.bufnr, mode)) do
+ --- @diagnostic disable-next-line: param-type-mismatch
+ pcall(vim.keymap.del, mode, map.lhs, { buffer = self.bufnr })
+ end
+ for _, map in pairs(self.original_keymaps[mode] or {}) do
+ vim.fn.mapset(map)
end
end
- --- @type string[]
+ -- Traverse the tree, collecting text lines and extmark info
local lines = Morph.markup_to_lines {
tree = tree,
-
on_tag = function(tag, start, stop)
- if tag.name == 'text' then
- local hl = tag.attributes.hl
- if type(hl) == 'string' then
- tag.attributes.extmark = tag.attributes.extmark or {}
- tag.attributes.extmark.hl_group = tag.attributes.extmark.hl_group or hl
- end
+ if tag.name ~= 'text' then return end
- table.insert(extmarks_to_set, {
- tag = tag,
- start = start,
- stop = stop,
- opts = tag.attributes.extmark or {},
- })
-
- -- Set any necessary key-maps:
- for _, mode in ipairs { 'i', 'n', 'v', 'x', 'o' } do
- for lhs, _ in pairs(tag.attributes[mode .. 'map'] or {}) do
- -- Force creating an extmark if there are key handlers. To accurately
- -- sense the bounds of the text, we need an extmark:
- self:_kmap(mode, lhs, function()
- local result = self:_expr_map_callback(mode, lhs)
- -- If the handler indicates that it wants to swallow the event,
- -- we have to convert that intention into something compatible
- -- with expr-mappings, which don't support '' (they try to
- -- execute the literal characters). We'll use the 'g@' operator
- -- to do that, forwarding the event to an operatorfunc that does
- -- nothing:
- if result == '' then
- if mode == 'i' then
- return ''
- else
- vim.go.operatorfunc = 'v:lua.MorphOpFuncNoop'
- return 'g@ '
- end
- end
- return result
- end, { buffer = self.bufnr, expr = true, replace_keycodes = true })
- end
+ -- Convert hl attribute to extmark highlight
+ if type(tag.attributes.hl) == 'string' then
+ tag.attributes.extmark = tag.attributes.extmark or {}
+ tag.attributes.extmark.hl_group = tag.attributes.extmark.hl_group or tag.attributes.hl
+ end
+
+ table.insert(pending_extmarks, {
+ tag = tag,
+ start = start,
+ stop = stop,
+ opts = tag.attributes.extmark or {},
+ })
+
+ -- Register keymaps for any mode handlers (nmap, imap, vmap, xmap, omap)
+ for _, mode in ipairs { 'i', 'n', 'v', 'x', 'o' } do
+ local handlers = tag.attributes[mode .. 'map']
+ for lhs, _ in pairs(handlers or {}) do
+ vim.keymap.set(mode, lhs, function()
+ local result = self:_dispatch_keypress(mode, lhs)
+
+ -- Empty string means "swallow this keypress". In insert mode that's
+ -- easy, but in normal mode we need a trick: use g@ with a no-op
+ -- operator function.
+ if result == '' and mode ~= 'i' then
+ vim.go.operatorfunc = 'v:lua.MorphOpFuncNoop'
+ return 'g@ '
+ end
+ return result
+ end, { buffer = self.bufnr, expr = true, replace_keycodes = true })
end
end
end,
}
+ -- Update buffer text with minimal edits
+ --- @diagnostic disable-next-line: assign-type-mismatch
self.text_content.old = self.text_content.curr
- self.text_content.curr = {
- lines = lines,
- extmarks = {},
- tags_to_extmark_ids = {},
- extmark_ids_to_tag = {},
- }
+ self.text_content.curr =
+ { lines = lines, extmarks = {}, tags_to_extmark_ids = {}, extmark_ids_to_tag = {} }
- -- Step 1: morph the buffer content:
self.changing = true
- Morph.patch_lines(self.bufnr, self.text_content.old.lines, self.text_content.curr.lines)
+ Morph.patch_lines(self.bufnr, self.text_content.old.lines, lines)
self.changing = false
self.changedtick = vim.b[self.bufnr].changedtick
- -- Step 1: apply the new extmarks:
+ -- Now that text is in place, create the extmarks
vim.api.nvim_buf_clear_namespace(self.bufnr, self.ns, 0, -1)
- local bookkeeping = self.text_content.curr
- for _, extmark_to_set in ipairs(extmarks_to_set) do
- local tag = extmark_to_set.tag
- local extmark = Extmark.new(
- self.bufnr,
- self.ns,
- extmark_to_set.start,
- extmark_to_set.stop,
- extmark_to_set.opts
- )
- bookkeeping.extmark_ids_to_tag[extmark.id] = tag
- bookkeeping.tags_to_extmark_ids[tag] = extmark.id
- table.insert(bookkeeping.extmarks, extmark)
+ for _, pending in ipairs(pending_extmarks) do
+ local extmark = Extmark.new(self.bufnr, self.ns, pending.start, pending.stop, pending.opts)
+ self.text_content.curr.extmark_ids_to_tag[extmark.id] = pending.tag
+ self.text_content.curr.tags_to_extmark_ids[pending.tag] = extmark.id
+ table.insert(self.text_content.curr.extmarks, extmark)
end
end
---- Render a component tree
+--- Mount a component tree with full lifecycle management.
+--- Components can have state, respond to updates, and run cleanup on unmount.
--- @param tree morph.Tree
function Morph:mount(tree)
- local H2 = {}
-
- --- @type function[]
- local render_effects = {}
- --- @param cb function
- local function register_after_render_callback(cb) table.insert(render_effects, cb) end
-
- --- @param tree morph.Tree
- function H2.unmount(tree)
- --- @param tree morph.Tree
- local function visit(tree)
- H.tree_match(tree, {
- array = function(tags) H2.visit_array(tags, {}) end,
- tag = function(tag) H2.visit_array(tag.children, {}) end,
- component = function(Component, tag)
- --- @type morph.Ctx
- local ctx = assert(tag.ctx, 'could not find context for node')
-
- -- depth-first:
- H2.visit_array(ctx.prev_rendered_children, {})
-
- -- now unmount current:
- ctx.phase = 'unmount'
- Component(ctx)
- ctx.on_change = nil
- ctx._register_after_render_callback = nil
- end,
+ -- Callbacks scheduled via ctx:do_after_render() - run after each render
+ local after_render_callbacks = {} --- @type function[]
- default = function(tree) H2.visit_tree(tree, nil) end,
- })
- end
+ local function schedule_after_render(cb) table.insert(after_render_callbacks, cb) end
+
+ -- Forward declarations for mutual recursion
+ --- @diagnostic disable: unused
+ local reconcile_tree, reconcile_array, reconcile_component, unmount_tree, rerender
+ --- @diagnostic enable: unused
- visit(tree)
+ --- Unmount a tree, calling unmount lifecycle on all components (depth-first).
+ --- @param old_tree morph.Tree
+ unmount_tree = function(old_tree)
+ local node_type = tree_type(old_tree)
+
+ if node_type == 'array' then
+ --- @diagnostic disable-next-line: need-check-nil
+ reconcile_array(old_tree --[[@as morph.Node[] ]], {})
+ elseif node_type == 'tag' then
+ -- Tag children can be any tree type, so recurse with reconcile_tree
+ --- @diagnostic disable-next-line: need-check-nil
+ reconcile_tree((old_tree --[[@as morph.Tag]]).children, nil)
+ elseif node_type == 'component' then
+ local tag = old_tree --[[@as morph.Tag]]
+ local Component = tag.name --[[@as morph.Component]]
+ local ctx = assert(tag.ctx, 'component missing context during unmount')
+
+ -- Unmount children first (depth-first) - use reconcile_tree since
+ -- prev_rendered_children can be any tree type, not just an array
+ --- @diagnostic disable-next-line: need-check-nil
+ reconcile_tree(ctx.prev_rendered_children, nil)
+
+ -- Then unmount this component
+ ctx.phase = 'unmount'
+ Component(ctx)
+ ctx.on_change = nil
+ ctx._register_after_render_callback = nil
+ end
+ --- @diagnostic enable: need-check-nil
end
+ --- Reconcile old and new trees, handling mount/update/unmount.
+ --- Returns the rendered (simplified) tree.
--- @param old_tree morph.Tree
--- @param new_tree morph.Tree
--- @return morph.Tree
- function H2.visit_tree(old_tree, new_tree)
- local old_tree_kind = H.tree_kind(old_tree)
- local new_tree_kind = H.tree_kind(new_tree)
-
- local new_tree_rendered = H.tree_match(new_tree, {
- string = function(s) return s end,
- boolean = function(b) return b end,
- nil_ = function() return nil end,
-
- array = function(new_arr)
- return H2.visit_array(old_tree --[[@as any]], new_arr)
- end,
- tag = function(new_tag)
- local old_children = old_tree_kind == new_tree_kind and old_tree.children or nil
- return H.h(
- new_tag.name,
- new_tag.attributes,
- H2.visit_tree(old_children --[[@as any]], new_tag.children --[[@as any]])
- )
- end,
-
- component = function(NewC, new_tag)
- --- @type { tag: morph.Tag, ctx?: morph.Ctx } | nil
- local old_component_info =
- H.tree_match(old_tree, { component = function(_, t) return { tag = t, ctx = t.ctx } end })
- local ctx = old_component_info and old_component_info.ctx or nil
-
- if not ctx then
- --- @type morph.Ctx
- ctx = Ctx.new(self.bufnr, self, new_tag.attributes, nil, new_tag.children)
- else
- ctx.phase = 'update'
- end
- ctx.props = new_tag.attributes
- ctx.children = new_tag.children
- ctx.on_change = H2.rerender
- ctx._register_after_render_callback = register_after_render_callback
-
- new_tag.ctx = ctx
- local NewC_rendered_children = NewC(ctx)
- local result = H2.visit_tree(ctx.prev_rendered_children, NewC_rendered_children)
- ctx.prev_rendered_children = NewC_rendered_children
- -- As soon as we've mounted, move past the 'mount' state. This is
- -- because Ctx will not fire `on_update` if it is still in the
- -- 'mount' state (to avoid stack overflows).
- ctx.phase = 'update'
-
- return result
- end,
- })
-
- if old_tree_kind ~= new_tree_kind then H2.unmount(old_tree) end
+ reconcile_tree = function(old_tree, new_tree)
+ local old_type = tree_type(old_tree)
+ local new_type = tree_type(new_tree)
+
+ -- If type changed, unmount old tree first
+ if old_type ~= new_type then unmount_tree(old_tree) end
+
+ -- Handle each node type
+ local rendered
+
+ if new_type == 'nil' or new_type == 'boolean' then
+ rendered = new_tree
+ elseif new_type == 'string' or new_type == 'number' then
+ rendered = new_tree
+ elseif new_type == 'array' then
+ local old_array = (old_type == 'array') and old_tree --[[@as morph.Node[]?]]
+ or nil
+ --- @diagnostic disable-next-line: need-check-nil
+ rendered = reconcile_array(old_array, new_tree --[[@as morph.Node[] ]])
+ elseif new_type == 'tag' then
+ local new_tag = new_tree --[[@as morph.Tag]]
+ local old_children = (old_type == new_type) and (old_tree --[[@as morph.Tag]]).children
+ or nil
+ --- @diagnostic disable-next-line: need-check-nil
+ rendered = h(new_tag.name, new_tag.attributes, reconcile_tree(old_children, new_tag.children))
+ elseif new_type == 'component' then
+ --- @diagnostic disable-next-line: need-check-nil
+ rendered = reconcile_component(old_tree, new_tree --[[@as morph.Tag]])
+ end
- return new_tree_rendered
+ return rendered
end
- --- @param old_arr? morph.Node[]
- --- @param new_arr? morph.Node[]
+ --- Reconcile arrays of nodes using Levenshtein to match up old/new nodes.
+ --- This is where the "diffing" magic happens for lists.
+ --- @param old_nodes morph.Node[]?
+ --- @param new_nodes morph.Node[]?
--- @return morph.Node[]
- function H2.visit_array(old_arr, new_arr)
- -- We are going to hijack levenshtein in order to compute the
- -- difference between elements/components. In this model, we need to
- -- "update" all the nodes, so no nodes are equal. We will rely on
- -- levenshtein to find the "shortest path" to conforming old => new via
- -- the cost.of_change function. That will provide the meat of modeling
- -- what effort it will take to morph one element into the new form.
- -- What levenshtein gives us for free in this model is also informing
- -- us what needs to be added (i.e., "mounted"), what needs to be
- -- deleted ("unmounted") and what needs to be changed ("updated").
-
- -- Pre-compute H.verbose_tree_kind for all the old/new nodes (performance
- -- optimization):
- local old_kinds = {}
- local new_kinds = {}
- local node_to_kind = {}
- if old_arr then
- for i = 1, #old_arr do
- local node = old_arr[i]
- if node ~= nil then
- local kind = H.verbose_tree_kind(node, i)
- old_kinds[i] = kind
- node_to_kind[node] = kind
- end
+ reconcile_array = function(old_nodes, new_nodes)
+ old_nodes = old_nodes or {}
+ new_nodes = new_nodes or {}
+
+ -- Pre-compute "identity keys" for each node so we can match them up
+ -- A key combines: type + component function (if any) + explicit key attribute
+ local old_keys, new_keys = {}, {}
+ local node_key_cache = {}
+
+ for i, node in ipairs(old_nodes) do
+ if node ~= nil then
+ local key = tree_identity_key(node, i)
+ old_keys[i] = key
+ node_key_cache[node] = key
end
end
- if new_arr then
- for i = 1, #new_arr do
- local node = new_arr[i]
- if node ~= nil then
- local kind = H.verbose_tree_kind(node, i)
- new_kinds[i] = kind
- node_to_kind[node] = kind
- end
+ for i, node in ipairs(new_nodes) do
+ if node ~= nil then
+ local key = tree_identity_key(node, i)
+ new_keys[i] = key
+ node_key_cache[node] = key
end
end
- local changes = (
- H.levenshtein {
- --- @diagnostic disable-next-line: assign-type-mismatch
- from = old_arr or {},
- to = new_arr or {},
- are_equal = function() return false end,
- cost = {
- of_change = function(_node1, _node2, node1_idx, node2_idx)
- return old_kinds[node1_idx] == new_kinds[node2_idx] and 1 or 2
- end,
- },
- }
- ) --[[@as (morph.LevenshteinChange[])]]
+ -- Use Levenshtein to find optimal mapping from old -> new nodes.
+ -- We say no nodes are "equal" (all need reconciliation), but nodes with
+ -- matching keys have lower change cost (prefer updating over mount/unmount).
+ local changes = levenshtein {
+ from = old_nodes,
+ to = new_nodes,
+ are_any_equal = false,
+ cost = {
+ of_change = function(_, _, old_idx, new_idx)
+ return old_keys[old_idx] == new_keys[new_idx] and 1 or 2
+ end,
+ },
+ }
- --- @type morph.Node[]
- local resulting_nodes = {}
+ local result = {} --- @type morph.Node[]
for _, change in ipairs(changes) do
- local resulting_node
+ local rendered_node
+
if change.kind == 'add' then
- -- add => mount
- resulting_node = H2.visit_tree(nil, change.item)
+ -- New node: mount it
+ rendered_node = reconcile_tree(nil, change.item)
elseif change.kind == 'delete' then
- -- delete => unmount
- H2.visit_tree(change.item, nil)
+ -- Removed node: unmount it
+ reconcile_tree(change.item, nil)
elseif change.kind == 'change' then
- -- change is either:
- -- - unmount, then mount
- -- - update
- local from_kind = node_to_kind[change.from]
- local to_kind = node_to_kind[change.to]
- if from_kind == to_kind then
- resulting_node = H2.visit_tree(change.from, change.to)
+ -- Changed node: update if same type, otherwise unmount + mount
+ local from_key = node_key_cache[change.from]
+ local to_key = node_key_cache[change.to]
+
+ if from_key == to_key then
+ rendered_node = reconcile_tree(change.from, change.to)
else
- -- from_kind ~= to_kind: unmount/mount
- H2.visit_tree(change.from, nil)
- resulting_node = H2.visit_tree(nil, change.to)
+ reconcile_tree(change.from, nil) -- unmount old
+ rendered_node = reconcile_tree(nil, change.to) -- mount new
end
end
- if resulting_node then table.insert(resulting_nodes, 1, resulting_node) end
+ if rendered_node then table.insert(result, 1, rendered_node) end
+ end
+
+ return result
+ end
+
+ --- Reconcile a component node (mount, update, or reuse existing context).
+ --- @param old_tree morph.Node[]
+ --- @param new_tag morph.Tag
+ reconcile_component = function(old_tree, new_tag)
+ local Component = new_tag.name --[[@as morph.Component]]
+
+ -- Try to reuse existing context from old tree
+ local ctx
+ local old_type = tree_type(old_tree)
+ if old_type == 'component' then
+ local old_tag = old_tree --[[@as morph.Tag]]
+ ctx = old_tag.ctx
end
- return resulting_nodes
+ if ctx then
+ ctx.phase = 'update'
+ else
+ ctx = Ctx.new(self.bufnr, self, new_tag.attributes, nil, new_tag.children)
+ end
+
+ -- Update context with new props/children and wire up callbacks
+ ctx.props = new_tag.attributes
+ ctx.children = new_tag.children
+ ctx.on_change = rerender
+ ctx._register_after_render_callback = schedule_after_render
+
+ -- Render the component
+ new_tag.ctx = ctx
+ --- @diagnostic disable-next-line: param-type-mismatch
+ local rendered_children = Component(ctx)
+ local result = reconcile_tree(ctx.prev_rendered_children, rendered_children)
+ ctx.prev_rendered_children = rendered_children
+
+ -- As soon as we've mounted, move past the 'mount' state. This is
+ -- because Ctx will not fire `on_update` if it is still in the
+ -- 'mount' state (to avoid stack overflows).
+ ctx.phase = 'update'
+
+ return result
end
- function H2.rerender()
- render_effects = {}
+ --- Perform a full re-render of the component tree.
+ rerender = function()
+ after_render_callbacks = {}
- local simplified_tree = H2.visit_tree(self.component_tree.old, tree)
+ local simplified_tree = reconcile_tree(self.component_tree.old, tree)
self.component_tree.old = tree
self:render(simplified_tree)
- for _, eff in ipairs(render_effects) do
- eff()
+ -- Run any scheduled after-render callbacks
+ for _, callback in ipairs(after_render_callbacks) do
+ callback()
end
end
@@ -869,173 +1153,138 @@ function Morph:mount(tree)
unmount_autocmd_id = vim.api.nvim_create_autocmd({ 'BufDelete', 'BufUnload', 'BufWipeout' }, {
buffer = self.bufnr,
callback = function()
- -- Effectively unmount everything:
- H2.visit_tree(self.component_tree.old, nil)
+ reconcile_tree(self.component_tree.old, nil)
+ --- @diagnostic disable-next-line: param-type-mismatch
vim.api.nvim_del_autocmd(unmount_autocmd_id)
end,
})
- -- Kick off initial render:
- H2.rerender()
+ -- Kick off initial render
+ rerender()
end
---- @param pos [integer, integer]|morph.Pos00
---- @param mode string?
+--- Find all elements that contain the given position, sorted innermost to outermost.
+--- @param pos [integer, integer]|morph.Pos00 0-based position
+--- @param mode string? Vim mode ('i', 'n', etc.) - affects cursor width semantics
--- @return morph.Element[]
function Morph:get_elements_at(pos, mode)
pos = Pos00.new(pos[1], pos[2])
- if not mode then mode = vim.api.nvim_get_mode().mode end
- mode = mode:sub(1, 1) -- we don't care about sub-modes
-
- --- @type morph.Element[]
- local intersecting_elements = vim
- --
- -- The cursor (block) occupies **two** extmark spaces: one for it's left
- -- edge, and one for it's right. We need to do our own intersection test,
- -- because the Neovim API is over-inclusive in what it returns:
- .iter(Extmark._get_near_overshoot(self.bufnr, self.ns, pos, pos))
- --
- -- First, convert the list of extmarks to Elements:
- :map(
- --- @param ext morph.Extmark
- function(extmark)
- local tag = assert(self.text_content.curr.extmark_ids_to_tag[extmark.id])
- return vim.tbl_extend('force', {}, tag, { extmark = extmark })
- end
- )
- --
- -- Now do our own custom intersection test:
- :filter(
- --- @param elem morph.Element
- function(elem)
- local ext = elem.extmark
- if ext.stop[1] ~= nil and ext.stop[2] ~= nil then
- -- If we've "ciw" and "collapsed" an extmark onto the cursor,
- -- the cursor pos will equal the extmark's start AND end. In this
- -- case, we want to include the extmark.
- if pos == ext.start and pos == ext.stop then return true end
-
- return
- -- START: line check
- pos[1] >= ext.start[1]
- -- START: column check
- and (pos[1] ~= ext.start[1] or pos[2] >= ext.start[2])
- -- STOP: line check
- and pos[1] <= ext.stop[1]
- -- STOP: column check
- and (
- pos[1] ~= ext.stop[1]
- or (
- mode == 'i'
- -- In insert mode, the cursor is "thin", so <= to compensate:
- and pos[2] <= ext.stop[2]
- -- In normal mode, the cursor is "wide", so < to compensate:
- or pos[2] < ext.stop[2]
- )
- )
- else
- return true
- end
- end
- )
- :totable()
+ mode = (mode or vim.api.nvim_get_mode().mode):sub(1, 1)
+
+ -- Get candidate extmarks and convert to elements
+ local candidates = Extmark._get_in_range(self.bufnr, self.ns, pos, pos)
- -- Sort the tags into smallest (inner) to largest (outer):
- table.sort(intersecting_elements, function(e1, e2)
- local x1, x2 = e1.extmark, e2.extmark
- if x1.start == x2.start and x1.stop == x2.stop then return x1.id < x2.id end
- return x1.start >= x2.start and x1.stop <= x2.stop
+ local elements = {} --- @type morph.Element[]
+ for _, extmark in ipairs(candidates) do
+ local tag = self.text_content.curr.extmark_ids_to_tag[extmark.id]
+ if tag and self:_position_intersects_extmark(pos, extmark, mode) then
+ table.insert(elements, vim.tbl_extend('force', {}, tag, { extmark = extmark }))
+ end
+ end
+
+ -- Sort innermost (smallest) to outermost (largest)
+ table.sort(elements, function(a, b)
+ local ea, eb = a.extmark, b.extmark
+ if ea.start == eb.start and ea.stop == eb.stop then return ea.id < eb.id end
+ return ea.start >= eb.start and ea.stop <= eb.stop
end)
- return intersecting_elements
+ return elements
+end
+
+--- @private
+--- Check if a position truly intersects an extmark (Neovim's API is over-inclusive).
+--- @diagnostic disable-next-line: unused
+function Morph:_position_intersects_extmark(pos, extmark, mode)
+ local start, stop = extmark.start, extmark.stop
+
+ -- Zero-width extmarks at cursor position are considered intersecting
+ if pos == start and pos == stop then return true end
+
+ -- Check row bounds
+ if pos[1] < start[1] or pos[1] > stop[1] then return false end
+
+ -- Check column bounds on start row
+ if pos[1] == start[1] and pos[2] < start[2] then return false end
+
+ -- Check column bounds on stop row
+ if pos[1] == stop[1] then
+ -- In insert mode the cursor is "thin" (between characters), so we include
+ -- the position if it's <= stop (cursor can sit "on" the boundary)
+ -- In normal mode the cursor is "wide" (occupies a character), so we only
+ -- include if strictly < stop
+ if mode == 'i' then
+ if pos[2] > stop[2] then return false end
+ else
+ if pos[2] >= stop[2] then return false end
+ end
+ end
+
+ return true
end
+--- Find an element by its id attribute.
--- @param id string
--- @return morph.Element?
function Morph:get_element_by_id(id)
- for tag, _ in pairs(self.text_content.curr.tags_to_extmark_ids) do
- local extmark_id = assert(self.text_content.curr.tags_to_extmark_ids[tag])
- local extmark = assert(Extmark.by_id(self.bufnr, self.ns, extmark_id))
+ for tag, extmark_id in pairs(self.text_content.curr.tags_to_extmark_ids) do
if tag.attributes.id == id then
+ local extmark = assert(Extmark.by_id(self.bufnr, self.ns, extmark_id))
return vim.tbl_extend('force', {}, tag, { extmark = extmark }) --[[@as morph.Element]]
end
end
end
---- @private
---- @param mode string
---- @param lhs string
---- @param rhs string|function
---- @param opts vim.keymap.set.Opts
-function Morph:_kmap(mode, lhs, rhs, opts)
- local orig = vim.fn.maparg(lhs, mode, false, true)
- if vim.tbl_isempty(orig) then orig = nil end
- self.orig_kmaps[mode] = self.orig_kmaps[mode] or {}
- self.orig_kmaps[mode][lhs] = orig
- vim.keymap.set(mode, lhs, rhs, opts)
-end
+--------------------------------------------------------------------------------
+-- Keymap Management
+--
+-- We intercept keypresses to dispatch them to element handlers.
+-- Original keymaps are snapshotted in Morph.new() and restored before each render.
+--------------------------------------------------------------------------------
--- @private
---- @param mode string
---- @param lhs string
---- @param opts vim.keymap.del.Opts
-function Morph:_kunmap(mode, lhs, opts)
- self.orig_kmaps[mode] = self.orig_kmaps[mode] or {}
- local orig = self.orig_kmaps[mode][lhs]
- if not orig then return end
- self.orig_kmaps[mode][lhs] = nil
-
- vim.keymap.del(mode, lhs, opts)
- vim.api.nvim_buf_call(self.bufnr, function()
- -- mapset has to manually be called in the context of the correct
- -- buffer:
- vim.fn.mapset(mode, false, orig)
- end)
-end
+--- Handle a keypress by dispatching to element handlers (innermost first).
+--- Returns the key to execute, or '' to swallow the keypress.
+function Morph:_dispatch_keypress(mode, lhs)
+ local cursor = vim.api.nvim_win_get_cursor(0)
+ --- @diagnostic disable-next-line: need-check-nil, assign-type-mismatch
+ local pos0 = { cursor[1] - 1, cursor[2] } --- @type [integer, integer]
---- @private
---- @param mode string
---- @param lhs string
-function Morph:_expr_map_callback(mode, lhs)
- -- find the tag with the smallest intersection that contains the cursor:
- local pos0 = vim.api.nvim_win_get_cursor(0)
- pos0[1] = (
- pos0[1]--[[@cast -?]]
- - 1
- ) -- make it actually 0-based
local elements = self:get_elements_at(pos0)
-
if #elements == 0 then return lhs end
- -- Find the first tag that is listening for this event:
- local keypress_cancel = false
- --- @type { bubble_up: boolean }
- local loop_control = { bubble_up = true }
+ -- Dispatch to handlers, bubbling up until one handles it
+ local should_cancel = false
for _, elem in ipairs(elements) do
- if loop_control.bubble_up then
- -- is the tag listening?
- --- @type morph.TagEventHandler?
- local f = vim.tbl_get(elem.attributes, mode .. 'map', lhs)
- if vim.is_callable(f) then
- local e = { tag = elem, mode = mode, lhs = lhs, bubble_up = true }
- --- @diagnostic disable-next-line: need-check-nil
- local result = f(e)
- loop_control.bubble_up = e.bubble_up
- if result == '' then
- -- bubble-up to the next tag, but set cancel to true, in case there are
- -- no more tags to bubble up to:
- keypress_cancel = true
- else
- return result
- end
+ local handler = vim.tbl_get(elem.attributes, mode .. 'map', lhs)
+ if vim.is_callable(handler) then
+ local event = { tag = elem, mode = mode, lhs = lhs, bubble_up = true }
+ local result = handler(event)
+
+ if result == '' then
+ -- Handler wants to cancel, but let event bubble in case parent handles it
+ should_cancel = true
+ --- @diagnostic disable-next-line: unnecessary-if
+ if not event.bubble_up then break end
+ else
+ return result
end
end
end
- -- Resort to default behavior:
- return keypress_cancel and '' or lhs
+ return should_cancel and '' or lhs
end
+--------------------------------------------------------------------------------
+-- Text Change Handling
+--
+-- When the user edits text inside an element, we detect which elements changed
+-- and fire their on_change handlers. This enables controlled input behavior.
+--------------------------------------------------------------------------------
+
+--- @private
+--- Called after TextChanged autocmd fires, with the on_bytes info.
+--- Detects which elements have changed text and fires their on_change handlers.
function Morph:_on_bytes_after_autocmd(
_,
_,
@@ -1050,46 +1299,46 @@ function Morph:_on_bytes_after_autocmd(
new_end_col_off,
_
)
+ -- Ignore changes we're making ourselves during render
if self.changing then return end
- local end_row0 = start_row0 + new_end_row_off
+ -- Clamp the changed region to buffer bounds
+ local end_row0 =
+ math.min(start_row0 + new_end_row_off, vim.api.nvim_buf_line_count(self.bufnr) - 1)
local end_col0 = start_col0 + new_end_col_off
+ local last_line = vim.api.nvim_buf_get_lines(self.bufnr, -2, -1, true)[1] or ''
+ if end_col0 > #last_line then end_col0 = #last_line end
- local max_end_row0 = vim.api.nvim_buf_line_count(self.bufnr) - 1
- if end_row0 > max_end_row0 then end_row0 = max_end_row0 end
-
- local max_end_col0 = #(vim.api.nvim_buf_get_lines(self.bufnr, -2, -1, true)[1] or '')
- if end_col0 > max_end_col0 then end_col0 = max_end_col0 end
-
- local live_extmarks = Extmark._get_near_overshoot(
+ -- Find extmarks that overlap the changed region
+ local affected_extmarks = Extmark._get_in_range(
self.bufnr,
self.ns,
Pos00.new(start_row0, start_col0),
+ --- @diagnostic disable-next-line: param-type-mismatch
Pos00.new(end_row0, end_col0)
)
- --- @type { extmark: morph.Extmark, text: string }[]
- local changed = {}
- for _, live_extmark in ipairs(live_extmarks) do
- local curr_text = live_extmark:_text()
- local cached_tag = self.text_content.curr.extmark_ids_to_tag[live_extmark.id]
- if cached_tag and cached_tag.curr_text ~= curr_text then
- cached_tag.curr_text = curr_text
- table.insert(changed, { extmark = live_extmark, text = curr_text })
+ -- Check which ones actually have different text now
+ local changed_elements = {} --- @type { extmark: morph.Extmark, text: string }[]
+ for _, extmark in ipairs(affected_extmarks) do
+ local tag = self.text_content.curr.extmark_ids_to_tag[extmark.id]
+ if tag then
+ local new_text = extmark:_text()
+ if tag.curr_text ~= new_text then
+ tag.curr_text = new_text
+ table.insert(changed_elements, { extmark = extmark, text = new_text })
+ end
end
end
- -- Sort the tags into smallest (inner) to largest (outer):
- table.sort(changed, function(a, b)
- local x1, x2 = a.extmark, b.extmark
- if x1.start == x2.start and x1.stop == x2.stop then return x1.id < x2.id end
- return x1.start >= x2.start and x1.stop <= x2.stop
+ -- Sort innermost first (same as get_elements_at)
+ table.sort(changed_elements, function(a, b)
+ local ea, eb = a.extmark, b.extmark
+ if ea.start == eb.start and ea.stop == eb.stop then return ea.id < eb.id end
+ return ea.start >= eb.start and ea.stop <= eb.stop
end)
- local loop_control = {
- bubble_up = true --[[@as boolean]],
- }
-
+ -- Fire on_change handlers with bubbling.
-- NOTE: Sometimes we can lose the correlation of tag <=> extmark. Don't we
-- track all extmarks/tags in our bookkeeping? Yes: yes we do. However, we
-- operate on the assumption that the buffer could have changed outside of
@@ -1102,277 +1351,34 @@ function Morph:_on_bytes_after_autocmd(
-- maintaining whatever tag <=> extmark correlations exist at the beginning
-- of this loop, and we can maintain that all the correct handlers are
-- called (at lease, the ones we CAN guarantee).
- local old_textlock = self.textlock
+ local prev_textlock = self.textlock
self.textlock = true
- for _, extmark_and_text in ipairs(changed) do
- if loop_control.bubble_up then
- local tag = self.text_content.curr.extmark_ids_to_tag[extmark_and_text.extmark.id]
- local on_change = tag and tag.attributes.on_change
- if vim.is_callable(on_change) then
- local e = { text = extmark_and_text.text, bubble_up = true }
- --- @diagnostic disable-next-line: need-check-nil
- on_change(e)
- loop_control.bubble_up = e.bubble_up
- end
- end
- end
- self.textlock = old_textlock
-end
---------------------------------------------------------------------------------
--- Utilities
---------------------------------------------------------------------------------
-
---- @param x morph.Tree
-function H.tree_is_tag(x) return type(x) == 'table' and x.kind == 'tag' end
---- @param x morph.Tree
-function H.tree_is_tag_arr(x) return type(x) == 'table' and not H.tree_is_tag(x) end
-
---- @param tree morph.Tree
---- @param visitors {
---- nil_?: (fun(): any),
---- boolean?: (fun(b: boolean): any),
---- string?: (fun(s: string): any),
---- array?: (fun(tags: morph.Node[]): any),
---- tag?: (fun(tag: morph.Tag): any),
---- component?: (fun(component: morph.Component, tag: morph.Tag): any),
---- unknown?: fun(tag: any): any
---- }
-function H.tree_match(tree, visitors)
- if tree == nil or tree == vim.NIL then
- return visitors.nil_ and visitors.nil_() or nil
- elseif type(tree) == 'boolean' then
- return visitors.boolean and visitors.boolean(tree) or nil
- elseif type(tree) == 'string' then
- return visitors.string and visitors.string(tree) or nil
- elseif H.tree_is_tag_arr(tree) then
- return visitors.array and visitors.array(tree --[[@as any]]) or nil
- elseif H.tree_is_tag(tree) then
- local tag = tree --[[@as morph.Tag]]
- if vim.is_callable(tag.name) then
- return visitors.component and visitors.component(tag.name --[[@as function]], tag) or nil
- else
- return visitors.tag and visitors.tag(tree --[[@as any]]) or nil
- end
- else
- return visitors.unknown and visitors.unknown(tree) or error 'unknown value: not a tag'
- end
-end
-
---- @param tree morph.Tree
---- @return 'nil' | 'boolean' | 'string' | 'array' | 'tag' | morph.Component | 'unknown'
-function H.tree_kind(tree)
- if tree == nil or tree == vim.NIL then return 'nil' end
- if type(tree) == 'string' then return 'string' end
- if type(tree) == 'boolean' then return 'boolean' end
- if H.tree_is_tag_arr(tree) then return 'array' end
- if H.tree_is_tag(tree) then
- if vim.is_callable(tree.name) then
- return tree.name --[[@as morph.Component]]
- else
- return 'tag'
- end
- end
- return 'unknown'
-end
+ for _, changed in ipairs(changed_elements) do
+ local tag = self.text_content.curr.extmark_ids_to_tag[changed.extmark.id]
+ local on_change = tag and tag.attributes.on_change
---- @return string
-function H.verbose_tree_kind(tree, idx)
- local tree_type = type(tree)
- if tree == nil or tree == vim.NIL then
- return 'nil'
- elseif tree_type == 'string' then
- return 'string'
- elseif tree_type == 'boolean' then
- return 'boolean'
- elseif H.tree_is_tag_arr(tree) then
- return 'array'
- elseif H.tree_is_tag(tree) then
- if vim.is_callable(tree.name) then
- return 'component-' .. tostring(tree.name) .. '-' .. tostring(tree.attributes.key or idx)
- else
- return 'tag-' .. tree.name .. '-' .. tostring(tree.attributes.key or idx)
+ if vim.is_callable(on_change) then
+ local event = { text = changed.text, bubble_up = true }
+ --- @diagnostic disable-next-line: need-check-nil
+ on_change(event)
+ --- @diagnostic disable-next-line: unnecessary-if
+ if not event.bubble_up then break end
end
- else
- error 'unknown'
end
-end
-function H.is_textlock()
- if vim.in_fast_event() then return true end
-
- local curr_win = vim.api.nvim_get_current_win()
- local curr_mode = vim.api.nvim_get_mode().mode:sub(1, 1)
-
- -- Try to change the window: if textlock is active, an error will be raised:
- local tmp_buf = vim.api.nvim_create_buf(false, true)
- local ok, tmp_win = pcall(
- vim.api.nvim_open_win,
- tmp_buf,
- true,
- { relative = 'editor', width = 1, height = 1, row = 1, col = 1 }
- )
- if
- not ok
- and type(tmp_win) == 'string'
- and tmp_win:find 'E565: Not allowed to change text or change window'
- then
- pcall(vim.api.nvim_buf_delete, tmp_buf, { force = true })
- return true
- end
-
- pcall(vim.api.nvim_win_close, tmp_win --[[@as integer]], true)
- pcall(vim.api.nvim_buf_delete, tmp_buf, { force = true })
-
- vim.api.nvim_set_current_win(curr_win)
- if curr_mode == 'i' or curr_mode == 't' then
- vim.cmd.startinsert()
- elseif curr_mode ~= 'n' then
- vim.cmd.normal { args = { 'gv' }, bang = true }
- end
-
- return false
-end
-
---- @alias morph.LevenshteinChange ({ kind: 'add', item: T, index: integer } | { kind: 'delete', item: T, index: integer } | { kind: 'change', from: T, to: T, index: integer })
-
---- @private
---- @generic T
---- @param opts {
---- from: `T`[],
---- to: T[],
---- are_equal?: (fun(x: T, y: T, x_idx: integer, y_idx: integer): boolean),
---- cost?: {
---- of_delete?: (fun(x: T, idx: integer): integer),
---- of_add?: (fun(x: T, idx: integer): integer),
---- of_change?: (fun(x: T, y: T, x_idx: integer, y_idx: integer): integer)
---- }
---- }
---- @return morph.LevenshteinChange[]
-function H.levenshtein(opts)
- if not opts.are_equal then opts.are_equal = function(x, y) return x == y end end
- if not opts.cost then opts.cost = {} end
- if not opts.cost.of_add then opts.cost.of_add = function() return 1 end end
- if not opts.cost.of_change then opts.cost.of_change = function() return 1 end end
- if not opts.cost.of_delete then opts.cost.of_delete = function() return 1 end end
-
- local m, n = #opts.from, #opts.to
- -- Initialize the distance matrix
- --- @type integer[][]
- local dp = {}
- for i = 0, m do
- dp[i] = {}
- end
-
- -- Fill the base cases
- for i = 0, m do
- assert(dp[i])[0] = i
- end
- for j = 0, n do
- assert(dp[0])[j] = j
- end
-
- -- Compute the Levenshtein distance dynamically
- for i = 1, m do
- for j = 1, n do
- if opts.are_equal(opts.from[i], opts.to[j], i, j) then
- dp[i][j] = dp[i - 1][j - 1] -- no cost if items are the same
- else
- local cost_delete = dp[i - 1][j] + opts.cost.of_delete(opts.from[i], i)
- local cost_add = dp[i][j - 1] + opts.cost.of_add(opts.to[j], j)
- local cost_change = dp[i - 1][j - 1] + opts.cost.of_change(opts.from[i], opts.to[j], i, j)
- dp[i][j] = math.min(cost_delete, cost_add, cost_change)
- end
- end
- end
-
- -- Backtrack to find the changes
- local i = m
- local j = n
- --- @type morph.LevenshteinChange[]
- local changes = {}
-
- while i > 0 or j > 0 do
- local default_cost = dp[i][j]
- local cost_of_change = (i > 0 and j > 0) and dp[i - 1][j - 1] or default_cost
- local cost_of_add = j > 0 and dp[i][j - 1] or default_cost
- local cost_of_delete = i > 0 and dp[i - 1][j] or default_cost
-
- --- @param u integer
- --- @param v integer
- --- @param w integer
- local function is_first_min(u, v, w) return u <= v and u <= w end
-
- if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then
- -- potential change
- if not opts.are_equal(opts.from[i], opts.to[j]) then
- --- @type morph.LevenshteinChange
- local change = { kind = 'change', from = opts.from[i], index = i, to = opts.to[j] }
- table.insert(changes, change)
- end
- i = i - 1
- j = j - 1
- elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then
- -- addition
- --- @type morph.LevenshteinChange
- local change = { kind = 'add', item = opts.to[j], index = i + 1 }
- table.insert(changes, change)
- j = j - 1
- elseif is_first_min(cost_of_delete, cost_of_change, cost_of_add) then
- -- deletion
- --- @type morph.LevenshteinChange
- local change = { kind = 'delete', item = opts.from[i], index = i }
- table.insert(changes, change)
- i = i - 1
- else
- error 'unreachable'
- end
- end
-
- return changes
-end
-
---- @class morph.BufWatcher
---- @field last_on_bytes_args unknown
---- @field text_changed_autocmd_id integer
---- @field fire function
---- @field cleanup function
-
---- This is a helper that waits to notify about changes until _after_ the
---- TextChanged{,I,P} autocmd has fired. This is because, at the time
---- nvim_buf_attach notifies the callback of changes, the buffer seems to be in
---- a strange state. In my testing I saw extra blank lines during the on_bytes
---- callback, as opposed to when the autocmd fires.
----
---- @param bufnr integer
---- @param callback function
---- @return morph.BufWatcher
-function H.buf_attach_after_autocmd(bufnr, callback)
- local state = {}
-
- vim.api.nvim_buf_attach(
- bufnr,
- false,
- { on_bytes = function(...) state.last_on_bytes_args = { ... } end }
- )
-
- state.text_changed_autocmd_id = vim.api.nvim_create_autocmd(
- { 'TextChanged', 'TextChangedI', 'TextChangedP' },
- { buffer = bufnr, callback = function() state.fire() end }
- )
-
- function state.fire() callback(unpack(state.last_on_bytes_args)) end
- function state.cleanup() vim.api.nvim_del_autocmd(state.text_changed_autocmd_id) end
-
- return state
+ self.textlock = prev_textlock
end
--------------------------------------------------------------------------------
-- Exports
--------------------------------------------------------------------------------
-local M = Morph
-Morph.h = H.h
+Morph.h = h
Morph.Pos00 = Pos00
-return M
+-- Export internal functions for testing when NVIM_TEST=true
+--- @diagnostic disable-next-line: unnecessary-if
+if vim.env.NVIM_TEST then Morph._is_textlock = is_textlock end
+
+return Morph
diff --git a/mise.toml b/mise.toml
index af76345..b8bb7fa 100644
--- a/mise.toml
+++ b/mise.toml
@@ -85,7 +85,9 @@ done
[tasks."test:coverage"]
depends = ["test:prepare"]
run = '''
+rm -f ./luacov.*.*
busted --coverage --verbose
+luacov
awk '/^Summary$/{flag=1;next} flag{print}' luacov.report.out
'''
diff --git a/spec/morph_spec.lua b/spec/morph_spec.lua
index 70d216d..fff8535 100644
--- a/spec/morph_spec.lua
+++ b/spec/morph_spec.lua
@@ -1,13 +1,22 @@
---- @diagnostic disable: need-check-nil, undefined-field, missing-fields
+--- @diagnostic disable: need-check-nil, undefined-field, missing-fields, param-type-mismatch
vim.print(tostring(vim.version()))
+-- Set NVIM_TEST to enable testing of internal functions
+vim.env.NVIM_TEST = 'true'
+
local Morph = require 'morph'
local h = Morph.h
local Pos00 = Morph.Pos00
+--------------------------------------------------------------------------------
+-- TEST HELPERS
+--------------------------------------------------------------------------------
+
local function get_lines() return vim.api.nvim_buf_get_lines(0, 0, -1, true) end
local function get_text() return vim.iter(vim.api.nvim_buf_get_lines(0, 0, -1, true)):join '\n' end
+local function line_count() return vim.api.nvim_buf_line_count(0) end
+
--- @param start_row integer
--- @param start_col integer
--- @param end_row integer
@@ -16,12 +25,13 @@ local function get_text() return vim.iter(vim.api.nvim_buf_get_lines(0, 0, -1, t
local function set_text(start_row, start_col, end_row, end_col, repl)
vim.api.nvim_buf_set_text(0, start_row, start_col, end_row, end_col, repl)
end
-local function line_count() return vim.api.nvim_buf_line_count(0) end
+
--- @param pos [integer, integer]
local function set_cursor(pos) vim.api.nvim_win_set_cursor(0, pos) end
+
+--- Execute a test within a temporary buffer that is cleaned up afterward.
local function with_buf(lines, f)
vim.go.swapfile = false
-
vim.cmd.new()
vim.api.nvim_buf_set_lines(0, 0, -1, false, lines)
local ok, result = pcall(f)
@@ -29,586 +39,493 @@ local function with_buf(lines, f)
if not ok then error(result) end
end
+--------------------------------------------------------------------------------
+-- ASSERTION HELPERS
+--------------------------------------------------------------------------------
+
+--- Assert that get_elements_at returns elements with the expected IDs (in order).
+--- @param renderer morph.Morph
+--- @param pos [integer, integer]
+--- @param expected_ids string[]
+local function assert_elements_at(renderer, pos, expected_ids)
+ local elements = renderer:get_elements_at(pos)
+ local actual_ids = vim.tbl_map(function(e) return e.attributes.id end, elements)
+ assert.are.same(expected_ids, actual_ids)
+end
+
+--- Assert that an element has the expected start and stop positions.
+--- @param renderer morph.Morph
+--- @param id string
+--- @param expected_start table
+--- @param expected_stop table
+local function assert_element_bounds(renderer, id, expected_start, expected_stop)
+ local elem = renderer:get_element_by_id(id)
+ assert.is_not_nil(elem, 'Element with id "' .. id .. '" not found')
+ assert.are.same(expected_start, elem.extmark.start)
+ assert.are.same(expected_stop, elem.extmark.stop)
+end
+
+--------------------------------------------------------------------------------
+-- REUSABLE COMPONENT FIXTURES
+--------------------------------------------------------------------------------
+
+--- Creates a component that captures its context for later inspection.
+--- @param captured_contexts table Table to store contexts by id
+--- @return fun(ctx: morph.Ctx<{id: string}, {count: integer}>): morph.Tree
+local function make_counter(captured_contexts)
+ return function(ctx)
+ if ctx.phase == 'mount' then ctx.state = { phase = ctx.phase, count = 1 } end
+ local state = assert(ctx.state)
+ state.phase = ctx.phase
+ captured_contexts[ctx.props.id] = ctx
+ return { { 'Value: ', h.Number({}, tostring(state.count)) } }
+ end
+end
+
+--------------------------------------------------------------------------------
+-- TESTS
+--------------------------------------------------------------------------------
+
describe('Morph', function()
- it('should render text in an empty buffer', function()
- with_buf({}, function()
- local r = Morph.new(0)
- r:render { 'hello', ' ', 'world' }
- assert.are.same(get_lines(), { 'hello world' })
+ ------------------------------------------------------------------------------
+ -- BASIC RENDERING
+ ------------------------------------------------------------------------------
+
+ describe('basic rendering', function()
+ it('renders text in an empty buffer', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render { 'hello', ' ', 'world' }
+ assert.are.same({ 'hello world' }, get_lines())
+ end)
end)
- end)
- it('should result in the correct text after repeated renders', function()
- with_buf({}, function()
- local r = Morph.new(0)
- r:render { 'hello', ' ', 'world' }
- assert.are.same(get_lines(), { 'hello world' })
+ it('reconciles correctly across multiple renders', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render { 'hello', ' ', 'world' }
+ assert.are.same({ 'hello world' }, get_lines())
- r:render { 'goodbye', ' ', 'world' }
- assert.are.same(get_lines(), { 'goodbye world' })
+ r:render { 'goodbye', ' ', 'world' }
+ assert.are.same({ 'goodbye world' }, get_lines())
- r:render { 'hello', ' ', 'universe' }
- assert.are.same(get_lines(), { 'hello universe' })
+ r:render { 'hello', ' ', 'universe' }
+ assert.are.same({ 'hello universe' }, get_lines())
+ end)
end)
- end)
- it('should handle tags correctly', function()
- with_buf({}, function()
- local r = Morph.new(0)
- r:render {
- h('text', { hl = 'HighlightGroup' }, 'hello '),
- h('text', { hl = 'HighlightGroup' }, 'world'),
- }
- assert.are.same(get_lines(), { 'hello world' })
+ it('renders h() tags with highlight groups', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render {
+ h('text', { hl = 'HighlightGroup' }, 'hello '),
+ h('text', { hl = 'HighlightGroup' }, 'world'),
+ }
+ assert.are.same({ 'hello world' }, get_lines())
+ end)
end)
- end)
- it('should reconcile added lines', function()
- with_buf({}, function()
- local r = Morph.new(0)
- r:render { 'line 1', '\n', 'line 2' }
- assert.are.same(get_lines(), { 'line 1', 'line 2' })
+ it('renders numbers as strings', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render { 'count: ', 42 }
+ assert.are.same({ 'count: 42' }, get_lines())
+ end)
+ end)
- -- Add a new line:
- r:render { 'line 1', '\n', 'line 2\n', 'line 3' }
- assert.are.same(get_lines(), { 'line 1', 'line 2', 'line 3' })
+ it('renders negative and decimal numbers', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render { 'negative: ', -5, ', decimal: ', 3.14 }
+ assert.are.same({ 'negative: -5, decimal: 3.14' }, get_lines())
+ end)
end)
- end)
- it('should reconcile deleted lines', function()
- with_buf({}, function()
- local r = Morph.new(0)
- r:render { 'line 1', '\nline 2', '\nline 3' }
- assert.are.same(get_lines(), { 'line 1', 'line 2', 'line 3' })
+ it('renders numbers within tags', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render {
+ h('text', { hl = 'Number' }, { 'Value: ', 123 }),
+ }
+ assert.are.same({ 'Value: 123' }, get_lines())
+ end)
+ end)
- -- Remove a line:
- r:render { 'line 1', '\nline 3' }
- assert.are.same(get_lines(), { 'line 1', 'line 3' })
+ it('treats vim.NIL as nil (produces no output)', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render { 'before', vim.NIL, 'after' }
+ assert.are.same({ 'beforeafter' }, get_lines())
+ end)
end)
- end)
- it('should handle multiple nested elements', function()
- with_buf({}, function()
- local r = Morph.new(0)
- r:render {
- h('text', {}, {
- 'first line',
- }),
- '\n',
- h('text', {}, 'second line'),
- }
- assert.are.same(get_lines(), { 'first line', 'second line' })
+ it('treats boolean false and true as nil (produces no output)', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render { 'before', false, 'after' }
+ assert.are.same({ 'beforeafter' }, get_lines())
- r:render {
- h('text', {}, 'updated first line'),
- '\n',
- h('text', {}, 'third line'),
- }
- assert.are.same(get_lines(), { 'updated first line', 'third line' })
+ r:render { 'start', true, 'end' }
+ assert.are.same({ 'startend' }, get_lines())
+ end)
end)
- end)
- --
- -- get_elements_at
- --
-
- it('should return no extmarks for an empty buffer', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local pos_infos = r:get_elements_at { 0, 0 }
- assert.are.same(pos_infos, {})
+ it('flattens deeply nested arrays', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render {
+ { { { 'deep' } } },
+ { { 'medium' } },
+ { 'shallow' },
+ }
+ assert.are.same({ 'deepmediumshallow' }, get_lines())
+ end)
end)
- end)
- it('should return correct tags for a given position', function()
- with_buf({}, function()
- local r = Morph.new(0)
- -- Text:
- -- 00000000001
- -- 01234567890
- -- Hello World
- r:render {
- h('text', { hl = 'HighlightGroup1' }, 'Hello'),
- h('text', { hl = 'HighlightGroup2' }, ' World'),
- }
+ it('handles empty content gracefully', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render {}
+ assert.are.same({ '' }, get_lines())
+ end)
+ end)
- local tags = r:get_elements_at { 0, 2 }
- assert.are.same(#tags, 1)
- assert.are.same(tags[1].attributes.hl, 'HighlightGroup1')
- assert.are.same(tags[1].extmark.start, Pos00.new(0, 0))
- assert.are.same(tags[1].extmark.stop, Pos00.new(0, 5))
- tags = r:get_elements_at { 0, 4 }
- assert.are.same(#tags, 1)
- assert.are.same(tags[1].attributes.hl, 'HighlightGroup1')
- assert.are.same(tags[1].extmark.start, Pos00.new(0, 0))
- assert.are.same(tags[1].extmark.stop, Pos00.new(0, 5))
-
- tags = r:get_elements_at { 0, 5 }
- assert.are.same(#tags, 1)
- assert.are.same(tags[1].attributes.hl, 'HighlightGroup2')
- assert.are.same(tags[1].extmark.start, Pos00.new(0, 5))
- assert.are.same(tags[1].extmark.stop, Pos00.new(0, 11))
-
- -- In insert mode, bounds are eagerly included:
- tags = r:get_elements_at({ 0, 5 }, 'i')
- assert.are.same(#tags, 2)
- assert.are.same(tags[1].attributes.hl, 'HighlightGroup1')
- assert.are.same(tags[2].attributes.hl, 'HighlightGroup2')
+ it('handles tree with only newlines', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render { '\n\n\n' }
+ assert.are.same({ '', '', '', '' }, get_lines())
+ end)
end)
end)
- it('should return correct tags for elements enclosing empty lines', function()
- with_buf({}, function()
- local r = Morph.new(0)
- -- Text:
- -- 012345
- -- 0 Header
- -- 1
- -- 2
-
- local tag, start0, stop0
- local lines = Morph.markup_to_lines {
- on_tag = function(_tag, _start0, _stop0)
- tag = _tag
- start0 = _start0
- stop0 = _stop0
- end,
- tree = h('text', {}, { 'Header\n\n' }),
- }
- assert.are.same(lines, { 'Header', '', '' })
+ ------------------------------------------------------------------------------
+ -- LINE MANAGEMENT
+ ------------------------------------------------------------------------------
- r:render(h('text', {}, { 'Header\n\n' }))
+ describe('line management', function()
+ it('adds lines when content grows', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render { 'line 1', '\n', 'line 2' }
+ assert.are.same({ 'line 1', 'line 2' }, get_lines())
- local tags = r:get_elements_at { 0, 2 }
- assert.are.same(#tags, 1)
- assert.are.same(tags[1].extmark.start, Pos00.new(0, 0))
- assert.are.same(tags[1].extmark.stop, Pos00.new(2, 0))
+ r:render { 'line 1', '\n', 'line 2\n', 'line 3' }
+ assert.are.same({ 'line 1', 'line 2', 'line 3' }, get_lines())
+ end)
end)
- end)
-
- it('should return multiple extmarks for overlapping text', function()
- with_buf({}, function()
- local r = Morph.new(0)
- r:render {
- h('text', { hl = 'HighlightGroup1' }, {
- 'Hello',
- h(
- 'text',
- { hl = 'HighlightGroup2', extmark = { hl_group = 'HighlightGroup2' } },
- ' World'
- ),
- }),
- }
- local tags = r:get_elements_at { 0, 5 }
+ it('removes lines when content shrinks', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render { 'line 1', '\nline 2', '\nline 3' }
+ assert.are.same({ 'line 1', 'line 2', 'line 3' }, get_lines())
- assert.are.same(#tags, 2)
- assert.are.same(tags[1].attributes.hl, 'HighlightGroup2')
- assert.are.same(tags[2].attributes.hl, 'HighlightGroup1')
+ r:render { 'line 1', '\nline 3' }
+ assert.are.same({ 'line 1', 'line 3' }, get_lines())
+ end)
end)
- end)
- it('repeated patch_lines calls should not change the buffer content', function()
- local lines = {
- [[{ {]],
- [[ bounds = {]],
- [[ start1 = { 1, 1 },]],
- [[ stop1 = { 4, 1 }]],
- [[ },]],
- [[ end_right_gravity = true,]],
- [[ id = 1,]],
- [[ ns_id = 623,]],
- [[ ns_name = "morph:91",]],
- [[ right_gravity = false]],
- [[ } }]],
- [[]],
- }
- with_buf(lines, function()
- Morph.patch_lines(0, nil, lines)
- assert.are.same(get_lines(), lines)
-
- Morph.patch_lines(0, lines, lines)
- assert.are.same(get_lines(), lines)
-
- Morph.patch_lines(0, lines, lines)
- assert.are.same(get_lines(), lines)
+ it('handles multiple consecutive newlines', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render { 'line1\n\n\nline4' }
+ assert.are.same({ 'line1', '', '', 'line4' }, get_lines())
+ end)
end)
end)
- it('should fire text-changed events', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local captured_changed_text = ''
-
- -- Text:
- -- 01234
- -- 0 one
- -- 1 two
- -- 2 three
- -- 3
- r:render {
- h('text', {
- on_change = function(e) captured_changed_text = e.text end,
- }, {
- 'one\n',
- 'two\n',
- 'three\n',
- }),
- }
-
- local elems = r:get_elements_at { 0, 1 }
- assert.are.same(#elems, 1)
- assert.are.same(elems[1].extmark.start, Pos00.new(0, 0))
- assert.are.same(elems[1].extmark.stop, Pos00.new(3, 0))
-
- -- New text:
- -- 1234
- -- 0 bleh
- vim.fn.setreg('"', 'bleh')
- vim.cmd [[normal! ggVGp]]
-
- assert.are.same(line_count(), 1)
- elems = r:get_elements_at { 0, 1 }
- assert.are.same(#elems, 1)
-
- -- For some reason, the autocmd does not fire in the busted environment.
- -- We'll call the handler ourselves:
- r.buf_watcher.fire()
-
- assert.are.same(get_text(), 'bleh')
- assert.are.same(captured_changed_text, 'bleh')
-
- vim.fn.setreg('"', '')
- vim.cmd [[normal! ggdG]]
- -- We'll call the handler ourselves:
- r.buf_watcher.fire()
-
- assert.are.same(get_text(), '')
- assert.are.same(captured_changed_text, '')
- end)
-
- with_buf({}, function()
- local r = Morph.new(0)
- --- @type string?
- local captured_changed_text = nil
- r:render {
- 'prefix:',
- h('text', {
- on_change = function(e) captured_changed_text = e.text end,
- }, {
- 'one',
- }),
- 'suffix',
- }
+ ------------------------------------------------------------------------------
+ -- NESTED ELEMENTS
+ ------------------------------------------------------------------------------
- vim.fn.setreg('"', 'bleh')
- set_cursor { 1, 9 }
- vim.cmd [[normal! vhhd]]
- -- For some reason, the autocmd does not fire in the busted environment.
- -- We'll call the handler ourselves:
- r.buf_watcher.fire()
+ describe('nested elements', function()
+ it('renders and updates nested h() tags', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render {
+ h('text', {}, { 'first line' }),
+ '\n',
+ h('text', {}, 'second line'),
+ }
+ assert.are.same({ 'first line', 'second line' }, get_lines())
- assert.are.same(get_text(), 'prefix:suffix')
- assert.are.same(captured_changed_text, '')
+ r:render {
+ h('text', {}, 'updated first line'),
+ '\n',
+ h('text', {}, 'third line'),
+ }
+ assert.are.same({ 'updated first line', 'third line' }, get_lines())
+ end)
end)
- end)
- it('should find tags by position', function()
- with_buf({}, function()
- local r = Morph.new(0)
- r:render {
- 'pre',
- h('text', {
- id = 'outer',
- }, {
- 'inner-pre',
- h('text', {
- id = 'inner',
- }, {
- 'inner-text',
+ it('renders h.HighlightGroup shorthand with nested children', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render {
+ h.Comment({}, {
+ 'comment start ',
+ h.String({}, 'string'),
+ ' comment end',
}),
- 'inner-post',
- }),
- 'post',
- }
+ }
+ assert.are.same('comment start string comment end', get_text())
- local tags = r:get_elements_at { 0, 11 }
- assert.are.same(#tags, 1)
- assert.are.same(tags[1].attributes.id, 'outer')
+ local comment_elem = r:get_elements_at { 0, 0 }
+ assert.are.same(1, #comment_elem)
+ assert.are.same('Comment', comment_elem[1].attributes.hl)
- tags = r:get_elements_at { 0, 12 }
- assert.are.same(#tags, 2)
- assert.are.same(tags[1].attributes.id, 'inner')
- assert.are.same(tags[2].attributes.id, 'outer')
+ local string_elem = r:get_elements_at { 0, 14 }
+ assert.are.same(2, #string_elem)
+ assert.are.same('String', string_elem[1].attributes.hl)
+ assert.are.same('Comment', string_elem[2].attributes.hl)
+ end)
end)
end)
- it('should return tags sorted from innermost to outermost', function()
- with_buf({}, function()
- local r = Morph.new(0)
- -- Text:
- -- startmiddleinnermostafterend
- r:render {
- h('text', { id = 'level1', hl = 'Level1' }, {
- 'start',
- h('text', { id = 'level2', hl = 'Level2' }, {
- 'middle',
- h('text', { id = 'level3', hl = 'Level3' }, {
- 'innermost',
- }),
- 'after',
- }),
- 'end',
- }),
+ ------------------------------------------------------------------------------
+ -- PATCH_LINES
+ ------------------------------------------------------------------------------
+
+ describe('patch_lines', function()
+ it('is idempotent when content unchanged', function()
+ local lines = {
+ [[{ {]],
+ [[ bounds = {]],
+ [[ start1 = { 1, 1 },]],
+ [[ stop1 = { 4, 1 }]],
+ [[ },]],
+ [[ end_right_gravity = true,]],
+ [[ id = 1,]],
+ [[ ns_id = 623,]],
+ [[ ns_name = "morph:91",]],
+ [[ right_gravity = false]],
+ [[ } }]],
+ [[]],
}
+ with_buf(lines, function()
+ Morph.patch_lines(0, nil, lines)
+ assert.are.same(lines, get_lines())
- -- Test position in the innermost tag
- local tags = r:get_elements_at { 0, 11 } -- position in 'innermost'
- assert.are.same(#tags, 3)
- assert.are.same(tags[1].attributes.id, 'level3') -- innermost first
- assert.are.same(tags[2].attributes.id, 'level2')
- assert.are.same(tags[3].attributes.id, 'level1') -- outermost last
+ Morph.patch_lines(0, lines, lines)
+ assert.are.same(lines, get_lines())
+
+ Morph.patch_lines(0, lines, lines)
+ assert.are.same(lines, get_lines())
+ end)
+ end)
- -- Test position in middle level
- local tags = r:get_elements_at { 0, 7 } -- position in 'middle'
- assert.are.same(#tags, 2)
- assert.are.same(tags[1].attributes.id, 'level2') -- innermost first
- assert.are.same(tags[2].attributes.id, 'level1') -- outermost last
+ it('handles complete content replacement', function()
+ with_buf({ 'old line 1', 'old line 2', 'old line 3' }, function()
+ Morph.patch_lines(0, { 'old line 1', 'old line 2', 'old line 3' }, { 'new content' })
+ assert.are.same({ 'new content' }, get_lines())
+ end)
+ end)
- -- Test position in outermost level only
- local tags = r:get_elements_at { 0, 2 } -- position in 'start'
- assert.are.same(#tags, 1)
- assert.are.same(tags[1].attributes.id, 'level1')
+ it('handles expanding content', function()
+ with_buf({ 'single' }, function()
+ Morph.patch_lines(0, { 'single' }, { 'line 1', 'line 2', 'line 3', 'line 4' })
+ assert.are.same({ 'line 1', 'line 2', 'line 3', 'line 4' }, get_lines())
+ end)
end)
end)
- it('should return correct extmark positions for complex nested structures', function()
- with_buf({}, function()
- local r = Morph.new(0)
- -- Text:
- -- 00000000001111111111
- -- 0123456789_123456789
- -- 0: Section 1: important
- -- 1: Section 2: critical
- r:render {
- h('text', { id = 'container', hl = 'Container' }, {
- h('text', { id = 'section1', hl = 'Section' }, {
- 'Section 1: ',
- h('text', { id = 'highlight1', hl = 'Highlight' }, 'important'),
- }),
- '\n',
- h('text', { id = 'section2', hl = 'Section' }, {
- 'Section 2: ',
- h('text', { id = 'highlight2', hl = 'Highlight' }, 'critical'),
- }),
- }),
- }
+ ------------------------------------------------------------------------------
+ -- GET_ELEMENTS_AT
+ --
+ -- Tests for querying elements at cursor positions. Elements are returned
+ -- sorted from innermost to outermost.
+ ------------------------------------------------------------------------------
+
+ describe('get_elements_at', function()
+ it('returns empty array for empty buffer', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local pos_infos = r:get_elements_at { 0, 0 }
+ assert.are.same({}, pos_infos)
+ end)
+ end)
+
+ it('returns empty array for position beyond buffer end', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render { h('text', { id = 'tag' }, 'short') }
- -- Test extmark positions:
- local extmarks = r:get_elements_at { 0, 15 } -- position in 'important'
- assert.are.same(#extmarks, 3)
- assert.are.same(extmarks[1].attributes.id, 'highlight1') -- innermost first
- assert.are.same(extmarks[2].attributes.id, 'section1')
- assert.are.same(extmarks[3].attributes.id, 'container') -- outermost last
-
- -- Verify extmark bounds
- assert.are.same(extmarks[1].extmark.start, Pos00.new(0, 11)) -- highlight1 start
- assert.are.same(extmarks[1].extmark.stop, Pos00.new(0, 20)) -- highlight1 stop
- assert.are.same(extmarks[2].extmark.start, Pos00.new(0, 0)) -- section1 start
- assert.are.same(extmarks[2].extmark.stop, Pos00.new(0, 20)) -- section1 stop
- assert.are.same(extmarks[3].extmark.start, Pos00.new(0, 0)) -- container start
- assert.are.same(extmarks[3].extmark.stop, Pos00.new(1, 19)) -- container stop
-
- -- Test position in second highlight (after newline)
- extmarks = r:get_elements_at { 1, 15 } -- position in 'critical'
- assert.are.same(#extmarks, 3)
- assert.are.same(extmarks[1].attributes.id, 'highlight2')
- assert.are.same(extmarks[2].attributes.id, 'section2')
- assert.are.same(extmarks[3].attributes.id, 'container')
-
- -- Test position in section text but not in highlight
- extmarks = r:get_elements_at { 0, 5 } -- position in 'Section 1: '
- assert.are.same(#extmarks, 2)
- assert.are.same(extmarks[1].attributes.id, 'section1')
- assert.are.same(extmarks[2].attributes.id, 'container')
+ local elems = r:get_elements_at { 0, 100 }
+ assert.are.same(0, #elems)
+
+ elems = r:get_elements_at { 10, 0 }
+ assert.are.same(0, #elems)
+ end)
end)
- end)
- it('should handle complex nested structures with multiple siblings', function()
- with_buf({}, function()
- local r = Morph.new(0)
- -- Text:
- -- 00000000001111111111
- -- 0123456789_123456789
- -- 0: Section 1: important
- -- 1: Section 2: critical
- r:render {
- h('text', { id = 'container', hl = 'Container' }, {
- h('text', { id = 'section1', hl = 'Section' }, {
- 'Section 1: ',
- h('text', { id = 'highlight1', hl = 'Highlight' }, 'important'),
- }),
- '\n',
- h('text', { id = 'section2', hl = 'Section' }, {
- 'Section 2: ',
- h('text', { id = 'highlight2', hl = 'Highlight' }, 'critical'),
- }),
- }),
- }
+ it('returns correct element for position within bounds', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ -- Text:
+ -- 00000000001
+ -- 01234567890
+ -- Hello World
+ r:render {
+ h('text', { hl = 'HighlightGroup1' }, 'Hello'),
+ h('text', { hl = 'HighlightGroup2' }, ' World'),
+ }
- --- @param id string
- local function get_tag_bounds(id)
- local tag = r:get_element_by_id(id)
- return tag and { start = tag.extmark.start, stop = tag.extmark.stop }
- end
+ local tags = r:get_elements_at { 0, 2 }
+ assert.are.same(1, #tags)
+ assert.are.same('HighlightGroup1', tags[1].attributes.hl)
+ assert.are.same(Pos00.new(0, 0), tags[1].extmark.start)
+ assert.are.same(Pos00.new(0, 5), tags[1].extmark.stop)
+
+ tags = r:get_elements_at { 0, 4 }
+ assert.are.same(1, #tags)
+ assert.are.same('HighlightGroup1', tags[1].attributes.hl)
+ assert.are.same(Pos00.new(0, 0), tags[1].extmark.start)
+ assert.are.same(Pos00.new(0, 5), tags[1].extmark.stop)
+
+ tags = r:get_elements_at { 0, 5 }
+ assert.are.same(1, #tags)
+ assert.are.same('HighlightGroup2', tags[1].attributes.hl)
+ assert.are.same(Pos00.new(0, 5), tags[1].extmark.start)
+ assert.are.same(Pos00.new(0, 11), tags[1].extmark.stop)
+ end)
+ end)
+
+ it('includes both adjacent elements in insert mode', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ -- Text:
+ -- 00000000001
+ -- 01234567890
+ -- Hello World
+ r:render {
+ h('text', { hl = 'HighlightGroup1' }, 'Hello'),
+ h('text', { hl = 'HighlightGroup2' }, ' World'),
+ }
- -- Test positions:
- assert.are.same(get_tag_bounds 'container', {
- start = Pos00.new(0, 0),
- stop = Pos00.new(1, 19),
- })
- assert.are.same(get_tag_bounds 'section1', {
- start = Pos00.new(0, 0),
- stop = Pos00.new(0, 20),
- })
- assert.are.same(get_tag_bounds 'highlight1', {
- start = Pos00.new(0, 11),
- stop = Pos00.new(0, 20),
- })
- assert.are.same(get_tag_bounds 'section2', {
- start = Pos00.new(1, 0),
- stop = Pos00.new(1, 19),
- })
- assert.are.same(get_tag_bounds 'highlight2', {
- start = Pos00.new(1, 11),
- stop = Pos00.new(1, 19),
- })
-
- local tags = r:get_elements_at { 0, 15 } -- position in 'important'
- assert.are.same(#tags, 3)
- assert.are.same(tags[1].attributes.id, 'highlight1')
- assert.are.same(tags[2].attributes.id, 'section1')
- assert.are.same(tags[3].attributes.id, 'container')
-
- -- Test position in second highlight (after newline)
- local tags = r:get_elements_at { 1, 15 } -- position in 'critical'
- assert.are.same(#tags, 3)
- assert.are.same(tags[1].attributes.id, 'highlight2')
- assert.are.same(tags[2].attributes.id, 'section2')
- assert.are.same(tags[3].attributes.id, 'container')
-
- -- Test position in section text but not in highlight
- local tags = r:get_elements_at { 0, 5 } -- position in 'Section 1: '
- assert.are.same(#tags, 2)
- assert.are.same(tags[1].attributes.id, 'section1')
- assert.are.same(tags[2].attributes.id, 'container')
+ local tags = r:get_elements_at({ 0, 5 }, 'i')
+ assert.are.same(2, #tags)
+ assert.are.same('HighlightGroup1', tags[1].attributes.hl)
+ assert.are.same('HighlightGroup2', tags[2].attributes.hl)
+ end)
+ end)
+
+ it('returns elements enclosing empty lines', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ -- Text:
+ -- 012345
+ -- 0 Header
+ -- 1
+ -- 2
+
+ local tag, start0, stop0
+ local lines = Morph.markup_to_lines {
+ on_tag = function(_tag, _start0, _stop0)
+ tag = _tag
+ start0 = _start0
+ stop0 = _stop0
+ end,
+ tree = h('text', {}, { 'Header\n\n' }),
+ }
+ assert.are.same({ 'Header', '', '' }, lines)
+
+ r:render(h('text', {}, { 'Header\n\n' }))
+
+ local tags = r:get_elements_at { 0, 2 }
+ assert.are.same(1, #tags)
+ assert.are.same(Pos00.new(0, 0), tags[1].extmark.start)
+ assert.are.same(Pos00.new(2, 0), tags[1].extmark.stop)
+ end)
end)
- end)
- it('should handle tags with same boundaries correctly', function()
- with_buf({}, function()
- local r = Morph.new(0)
- r:render {
- h('text', { id = 'outer', hl = 'Outer' }, {
- h('text', { id = 'inner', hl = 'Inner' }, {
- 'same-bounds',
+ it('returns multiple elements for overlapping text (innermost first)', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render {
+ h('text', { hl = 'HighlightGroup1' }, {
+ 'Hello',
+ h(
+ 'text',
+ { hl = 'HighlightGroup2', extmark = { hl_group = 'HighlightGroup2' } },
+ ' World'
+ ),
}),
- }),
- }
+ }
- -- Both tags have the same text bounds, should return both sorted by nesting
- local tags = r:get_elements_at { 0, 5 } -- position in 'same-bounds'
- assert.are.same(#tags, 2)
- assert.are.same(tags[1].attributes.id, 'inner') -- innermost first
- assert.are.same(tags[2].attributes.id, 'outer') -- outermost last
+ local tags = r:get_elements_at { 0, 5 }
+ assert.are.same(2, #tags)
+ assert.are.same('HighlightGroup2', tags[1].attributes.hl)
+ assert.are.same('HighlightGroup1', tags[2].attributes.hl)
+ end)
end)
- end)
- it('should handle empty tags and edge cases', function()
- with_buf({}, function()
- local r = Morph.new(0)
- r:render {
- 'prefix',
- h('text', { id = 'empty', hl = 'Empty' }, {}),
- h('text', { id = 'normal', hl = 'Normal' }, 'content'),
- 'suffix',
- }
+ it('returns elements sorted innermost to outermost for nested tags', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ -- Text:
+ -- 00000000001111111111222222222
+ -- 0123456789012345678901234567890
+ -- startmiddleinnermostafterend
+ r:render {
+ h('text', { id = 'level1', hl = 'Level1' }, {
+ 'start',
+ h('text', { id = 'level2', hl = 'Level2' }, {
+ 'middle',
+ h('text', { id = 'level3', hl = 'Level3' }, { 'innermost' }),
+ 'after',
+ }),
+ 'end',
+ }),
+ }
+
+ -- Position in 'innermost' -> all three levels
+ assert_elements_at(r, { 0, 11 }, { 'level3', 'level2', 'level1' })
- -- Test position in normal tag
- local tags = r:get_elements_at { 0, 8 } -- position in 'content'
- assert.are.same(#tags, 1)
- assert.are.same(tags[1].attributes.id, 'normal')
+ -- Position in 'middle' -> two levels
+ assert_elements_at(r, { 0, 7 }, { 'level2', 'level1' })
- -- Test position at boundary between prefix and empty tag
- local tags = r:get_elements_at { 0, 6 } -- position at start of empty tag
- -- Empty tags might not create extmarks, so this tests the boundary behavior
- assert.is_true(#tags >= 0) -- Should not error, may return 0 or more tags
+ -- Position in 'start' -> outermost only
+ assert_elements_at(r, { 0, 2 }, { 'level1' })
+ end)
end)
- end)
- it('get_elements_at should not get siblings', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local captured_events = {}
-
- -- Text structure:
- -- 1111111111222222
- -- 01234567890123456789012345
- -- sibling outer middle inner
- r:render {
- h('text', {
- id = 'sibling',
- on_change = function(e) table.insert(captured_events, { id = 'sibling', text = e.text }) end,
- }, 'sibling'),
- ' ',
- h('text', {
- id = 'outer',
- on_change = function(e) table.insert(captured_events, { id = 'outer', text = e.text }) end,
- }, {
- 'outer ',
- h('text', {
- id = 'middle',
- }, {
- 'middle ',
- h('text', {
- id = 'inner',
- on_change = function(e)
- table.insert(captured_events, { id = 'inner', text = e.text })
- end,
- }, 'inner'),
+ it('excludes sibling elements (only returns ancestors)', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ -- Text:
+ -- 0123456789012345678901234567890
+ -- preinner-preinner-textinner-postpost
+ r:render {
+ 'pre',
+ h('text', { id = 'outer' }, {
+ 'inner-pre',
+ h('text', { id = 'inner' }, { 'inner-text' }),
+ 'inner-post',
}),
- }),
- }
-
- local elems = r:get_elements_at { 0, 23 }
- assert.are.same(#elems, 3)
- -- Should return inner, middle, and outer (innermost to outermost)
- assert.are.same(elems[1].attributes.id, 'inner')
- assert.are.same(elems[2].attributes.id, 'middle')
- assert.are.same(elems[3].attributes.id, 'outer')
+ 'post',
+ }
- -- Test position in sibling - should only return sibling
- local sibling_elems = r:get_elements_at { 0, 3 }
- assert.are.same(#sibling_elems, 1)
- assert.are.same(sibling_elems[1].attributes.id, 'sibling')
+ local tags = r:get_elements_at { 0, 11 }
+ assert.are.same(1, #tags)
+ assert.are.same('outer', tags[1].attributes.id)
- -- Test position in outer but not in nested elements
- local outer_elems = r:get_elements_at { 0, 9 }
- assert.are.same(#outer_elems, 1)
- assert.are.same(outer_elems[1].attributes.id, 'outer')
+ tags = r:get_elements_at { 0, 12 }
+ assert.are.same(2, #tags)
+ assert.are.same('inner', tags[1].attributes.id)
+ assert.are.same('outer', tags[2].attributes.id)
+ end)
end)
- end)
- it('should fire on_change handlers from inner to outer and not affect siblings', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local captured_events = {}
+ it('does not return sibling elements in complex nested structures', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local captured_events = {}
- -- Text structure:
- -- 1111111111222222
- -- 01234567890123456789012345
- -- sibling outer middle inner
- local function reset_render()
+ -- Text:
+ -- 0 1 2
+ -- 0123456789012345678901234567890
+ -- sibling outer middle inner
r:render {
h('text', {
id = 'sibling',
@@ -622,9 +539,7 @@ describe('Morph', function()
on_change = function(e) table.insert(captured_events, { id = 'outer', text = e.text }) end,
}, {
'outer ',
- h('text', {
- id = 'middle',
- }, {
+ h('text', { id = 'middle' }, {
'middle ',
h('text', {
id = 'inner',
@@ -635,1342 +550,2221 @@ describe('Morph', function()
}),
}),
}
- end
- reset_render()
-
- -- Test 1: Change text in the innermost element
- -- This should fire inner handler first, then outer handler
- captured_events = {}
-
- -- Replace "inner" with "changed"
- set_text(0, 21, 0, 26, { 'changed' })
- assert.are.same(get_lines(), { 'sibling outer middle changed' })
- -- Text structure:
- -- 111111111122222222
- -- 0123456789012345678901234567
- -- sibling outer middle changed
- r.buf_watcher.fire()
-
- assert.are.same(#captured_events, 2)
- assert.are.same(captured_events[1].id, 'inner')
- assert.are.same(captured_events[1].text, 'changed')
- assert.are.same(captured_events[2].id, 'outer')
- assert.are.same(captured_events[2].text, 'outer middle changed')
-
- -- Test 2: Change text in the sibling element
- -- This should only fire the sibling handler, not any of the nested ones
- reset_render()
- captured_events = {}
-
- -- Replace "sibling" with "modified"
- set_text(0, 0, 0, 7, { 'modified' })
- assert.are.same(get_lines(), { 'modified outer middle inner' })
- -- Text structure:
- -- 1111111111222222222
- -- 01234567890123456789012345678
- -- modified outer middle inner
- r.buf_watcher.fire()
-
- assert.are.same(#captured_events, 1)
- assert.are.same(captured_events[1].id, 'sibling')
- assert.are.same(captured_events[1].text, 'modified')
-
- -- Test 3: Change text in the middle element (which has no handler)
- -- This should only fire the outer handler
- reset_render()
- captured_events = {}
-
- -- Replace "middle" with "center"
- set_text(0, 14, 0, 20, { 'center' })
- assert.are.same(get_lines(), { 'sibling outer center inner' })
- r.buf_watcher.fire()
-
- assert.are.same(#captured_events, 1)
- assert.are.same(captured_events[1].id, 'outer')
- assert.are.same(captured_events[1].text, 'outer center inner')
- end)
- end)
- it('should find tags by id', function()
- with_buf({}, function()
- local r = Morph.new(0)
- r:render {
- h('text', {
- id = 'outer',
- }, {
- 'inner-pre',
- h('text', {
- id = 'inner',
- }, {
- 'inner-text',
- }),
- 'inner-post',
- }),
- 'post',
- }
+ -- Position in 'inner' -> inner, middle, outer (not sibling)
+ assert_elements_at(r, { 0, 23 }, { 'inner', 'middle', 'outer' })
- --- @param id string
- local function get_tag_bounds(id)
- local tag = r:get_element_by_id(id)
- return tag and { start = tag.extmark.start, stop = tag.extmark.stop }
- end
-
- local bounds = get_tag_bounds 'outer'
- assert.are.same(bounds, { start = Pos00.new(0, 0), stop = Pos00.new(0, 29) })
+ -- Position in 'sibling' -> sibling only
+ assert_elements_at(r, { 0, 3 }, { 'sibling' })
- bounds = get_tag_bounds 'inner'
- assert.are.same(bounds, { start = Pos00.new(0, 9), stop = Pos00.new(0, 19) })
+ -- Position in 'outer ' text -> outer only
+ assert_elements_at(r, { 0, 9 }, { 'outer' })
+ end)
end)
- end)
-
- it('should handle components in markup_to_lines', function()
- local mount_calls = {}
- local unmount_calls = {}
-
- --- @param ctx morph.Ctx<{ name: string }, { value: string }>
- local function TestComponent(ctx)
- if ctx.phase == 'mount' then
- ctx.state = { value = 'Hello ' .. ctx.props.name }
- table.insert(mount_calls, ctx.props.name)
- elseif ctx.phase == 'unmount' then
- table.insert(unmount_calls, ctx.props.name)
- end
-
- return {
- h('text', { hl = 'TestHL' }, ctx.state.value),
- '!',
- }
- end
- local tree = {
- 'Prefix: ',
- h(TestComponent, { name = 'World' }, {}),
- ' Suffix',
- }
-
- local lines = Morph.markup_to_lines { tree = tree }
+ it('handles tags with same boundaries (both returned, inner first)', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render {
+ h('text', { id = 'outer', hl = 'Outer' }, {
+ h('text', { id = 'inner', hl = 'Inner' }, { 'same-bounds' }),
+ }),
+ }
- -- Check that the text is correct
- assert.are.same(lines, { 'Prefix: Hello World! Suffix' })
+ local tags = r:get_elements_at { 0, 5 }
+ assert.are.same(2, #tags)
+ assert.are.same('inner', tags[1].attributes.id)
+ assert.are.same('outer', tags[2].attributes.id)
+ end)
+ end)
- -- Check that component was mounted and then unmounted
- assert.are.same(mount_calls, { 'World' })
- assert.are.same(unmount_calls, { 'World' })
- end)
+ it('handles empty tags at boundaries', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render {
+ 'prefix',
+ h('text', { id = 'empty', hl = 'Empty' }, {}),
+ h('text', { id = 'normal', hl = 'Normal' }, 'content'),
+ 'suffix',
+ }
- it('should mount and rerender components', function()
- with_buf({}, function()
- --- @type any
- local leaked_ctx = { app = {}, c1 = {}, c2 = {} }
+ local tags = r:get_elements_at { 0, 8 }
+ assert.are.same(1, #tags)
+ assert.are.same('normal', tags[1].attributes.id)
- --- @param ctx morph.Ctx<{ id: string }, { phase: string, count: integer }>
- local function Counter(ctx)
- if ctx.phase == 'mount' then ctx.state = { phase = ctx.phase, count = 1 } end
- local state = assert(ctx.state)
- state.phase = ctx.phase
- leaked_ctx[ctx.props.id] = ctx
+ tags = r:get_elements_at { 0, 6 }
+ assert.is_true(#tags >= 0) -- Should not error
+ end)
+ end)
- return {
- { 'Value: ', h.Number({}, tostring(state.count)) },
+ it('returns correct extmark positions for complex nested structures', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ -- Text:
+ -- 00000000001111111111
+ -- 0123456789_123456789
+ -- 0: Section 1: important
+ -- 1: Section 2: critical
+ r:render {
+ h('text', { id = 'container', hl = 'Container' }, {
+ h('text', { id = 'section1', hl = 'Section' }, {
+ 'Section 1: ',
+ h('text', { id = 'highlight1', hl = 'Highlight' }, 'important'),
+ }),
+ '\n',
+ h('text', { id = 'section2', hl = 'Section' }, {
+ 'Section 2: ',
+ h('text', { id = 'highlight2', hl = 'Highlight' }, 'critical'),
+ }),
+ }),
}
- end
-
- --- @param ctx morph.Ctx<{}, { toggle1: boolean, show2: boolean }>
- function App(ctx)
- if ctx.phase == 'mount' then ctx.state = { toggle1 = false, show2 = true } end
- local state = assert(ctx.state)
- leaked_ctx.app = ctx
- return {
- state.toggle1 and 'Toggle1' or h(Counter, { id = 'c1' }, {}),
- '\n',
+ -- Test extmark positions:
+ local extmarks = r:get_elements_at { 0, 15 } -- position in 'important'
+ assert.are.same(3, #extmarks)
+ assert.are.same('highlight1', extmarks[1].attributes.id) -- innermost first
+ assert.are.same('section1', extmarks[2].attributes.id)
+ assert.are.same('container', extmarks[3].attributes.id) -- outermost last
+
+ -- Verify extmark bounds
+ assert.are.same(Pos00.new(0, 11), extmarks[1].extmark.start) -- highlight1 start
+ assert.are.same(Pos00.new(0, 20), extmarks[1].extmark.stop) -- highlight1 stop
+ assert.are.same(Pos00.new(0, 0), extmarks[2].extmark.start) -- section1 start
+ assert.are.same(Pos00.new(0, 20), extmarks[2].extmark.stop) -- section1 stop
+ assert.are.same(Pos00.new(0, 0), extmarks[3].extmark.start) -- container start
+ assert.are.same(Pos00.new(1, 19), extmarks[3].extmark.stop) -- container stop
+
+ -- Test position in second highlight (after newline)
+ extmarks = r:get_elements_at { 1, 15 } -- position in 'critical'
+ assert.are.same(3, #extmarks)
+ assert.are.same('highlight2', extmarks[1].attributes.id)
+ assert.are.same('section2', extmarks[2].attributes.id)
+ assert.are.same('container', extmarks[3].attributes.id)
+
+ -- Test position in section text but not in highlight
+ extmarks = r:get_elements_at { 0, 5 } -- position in 'Section 1: '
+ assert.are.same(2, #extmarks)
+ assert.are.same('section1', extmarks[1].attributes.id)
+ assert.are.same('container', extmarks[2].attributes.id)
+ end)
+ end)
- state.show2 and {
+ it('handles complex nested structures with multiple siblings', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ -- Text:
+ -- 00000000001111111111
+ -- 0123456789_123456789
+ -- 0: Section 1: important
+ -- 1: Section 2: critical
+ r:render {
+ h('text', { id = 'container', hl = 'Container' }, {
+ h('text', { id = 'section1', hl = 'Section' }, {
+ 'Section 1: ',
+ h('text', { id = 'highlight1', hl = 'Highlight' }, 'important'),
+ }),
'\n',
- h(Counter, { id = 'c2' }, {}),
- },
+ h('text', { id = 'section2', hl = 'Section' }, {
+ 'Section 2: ',
+ h('text', { id = 'highlight2', hl = 'Highlight' }, 'critical'),
+ }),
+ }),
}
- end
- local renderer = Morph.new()
- renderer:mount(h(App, {}, {}))
-
- assert.are.same(get_lines(), {
- 'Value: 1',
- '',
- 'Value: 1',
- })
- assert.are.same(leaked_ctx.c1.state.phase, 'mount')
- assert.are.same(leaked_ctx.c2.state.phase, 'mount')
- leaked_ctx.app:update { toggle1 = true, show2 = true }
- assert.are.same(get_lines(), {
- 'Toggle1',
- '',
- 'Value: 1',
- })
- assert.are.same(leaked_ctx.c1.state.phase, 'unmount')
- assert.are.same(leaked_ctx.c2.state.phase, 'update')
-
- leaked_ctx.app:update { toggle1 = true, show2 = false }
- assert.are.same(get_lines(), {
- 'Toggle1',
- '',
- })
- assert.are.same(leaked_ctx.c1.state.phase, 'unmount')
- assert.are.same(leaked_ctx.c2.state.phase, 'unmount')
-
- leaked_ctx.app:update { toggle1 = false, show2 = true }
- assert.are.same(get_lines(), {
- 'Value: 1',
- '',
- 'Value: 1',
- })
- assert.are.same(leaked_ctx.c1.state.phase, 'mount')
- assert.are.same(leaked_ctx.c2.state.phase, 'mount')
-
- leaked_ctx.c1:update { count = 2 }
- assert.are.same(get_lines(), {
- 'Value: 2',
- '',
- 'Value: 1',
- })
- assert.are.same(leaked_ctx.c1.state.phase, 'update')
- assert.are.same(leaked_ctx.c2.state.phase, 'update')
-
- leaked_ctx.c2:update { count = 3 }
- assert.are.same(get_lines(), {
- 'Value: 2',
- '',
- 'Value: 3',
- })
- assert.are.same(leaked_ctx.c1.state.phase, 'update')
- assert.are.same(leaked_ctx.c2.state.phase, 'update')
+ --- @param id string
+ local function get_tag_bounds(id)
+ local tag = r:get_element_by_id(id)
+ return tag and { start = tag.extmark.start, stop = tag.extmark.stop }
+ end
+
+ -- Test positions via get_element_by_id:
+ assert.are.same(
+ { start = Pos00.new(0, 0), stop = Pos00.new(1, 19) },
+ get_tag_bounds 'container'
+ )
+ assert.are.same(
+ { start = Pos00.new(0, 0), stop = Pos00.new(0, 20) },
+ get_tag_bounds 'section1'
+ )
+ assert.are.same(
+ { start = Pos00.new(0, 11), stop = Pos00.new(0, 20) },
+ get_tag_bounds 'highlight1'
+ )
+ assert.are.same(
+ { start = Pos00.new(1, 0), stop = Pos00.new(1, 19) },
+ get_tag_bounds 'section2'
+ )
+ assert.are.same(
+ { start = Pos00.new(1, 11), stop = Pos00.new(1, 19) },
+ get_tag_bounds 'highlight2'
+ )
+
+ local tags = r:get_elements_at { 0, 15 } -- position in 'important'
+ assert.are.same(3, #tags)
+ assert.are.same('highlight1', tags[1].attributes.id)
+ assert.are.same('section1', tags[2].attributes.id)
+ assert.are.same('container', tags[3].attributes.id)
+
+ -- Test position in second highlight (after newline)
+ tags = r:get_elements_at { 1, 15 } -- position in 'critical'
+ assert.are.same(3, #tags)
+ assert.are.same('highlight2', tags[1].attributes.id)
+ assert.are.same('section2', tags[2].attributes.id)
+ assert.are.same('container', tags[3].attributes.id)
+
+ -- Test position in section text but not in highlight
+ tags = r:get_elements_at { 0, 5 } -- position in 'Section 1: '
+ assert.are.same(2, #tags)
+ assert.are.same('section1', tags[1].attributes.id)
+ assert.are.same('container', tags[2].attributes.id)
+ end)
end)
end)
- --
- -- _expr_map_callback tests
- --
+ ------------------------------------------------------------------------------
+ -- GET_ELEMENT_BY_ID
+ ------------------------------------------------------------------------------
- it('should handle key-presses only in defined regions', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local captured_events = {}
-
- -- Text
- -- 00000000011111111112222
- -- 01345678901234567890123
- -- prefix clickable suffix
- r:render {
- 'prefix ',
- h('text', {
- id = 'clickable',
- nmap = {
- [''] = function(e)
- table.insert(captured_events, { tag_id = e.tag.attributes.id, key = e.lhs })
- return ''
- end,
- },
- }, 'clickable'),
- ' suffix',
- }
+ describe('get_element_by_id', function()
+ it('finds element by id', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render {
+ h('text', { id = 'outer' }, {
+ 'inner-pre',
+ h('text', { id = 'inner' }, { 'inner-text' }),
+ 'inner-post',
+ }),
+ 'post',
+ }
- -- Test keypress inside the clickable region
- set_cursor { 1, 12 } -- position in 'clickable'
- local result = r:_expr_map_callback('n', '')
- assert.are.same(result, '')
- assert.are.same(#captured_events, 1)
- assert.are.same(captured_events[1].tag_id, 'clickable')
- assert.are.same(captured_events[1].key, '')
-
- -- Test keypress outside the clickable region (in prefix)
- captured_events = {}
- set_cursor { 1, 2 } -- position in 'prefix'
- local result = r:_expr_map_callback('n', '')
- assert.are.same(result, '') -- should return the original key
- assert.are.same(#captured_events, 0) -- no events captured
-
- -- Test keypress outside the clickable region (in suffix)
- captured_events = {}
- set_cursor { 1, 17 } -- position in 'suffix'
- local result = r:_expr_map_callback('n', '')
- assert.are.same(result, '') -- should return the original key
- assert.are.same(#captured_events, 0) -- no events captured
+ assert_element_bounds(r, 'outer', Pos00.new(0, 0), Pos00.new(0, 29))
+ assert_element_bounds(r, 'inner', Pos00.new(0, 9), Pos00.new(0, 19))
+ end)
end)
- end)
- it('should handle multiple overlapping regions with different key-maps', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local captured_events = {}
-
- -- Text
- -- 00000000001111111111
- -- 01234567890123456789
- -- outer inner text end
- r:render {
- h('text', {
- id = 'outer',
- nmap = {
- ['x'] = function(e)
- table.insert(
- captured_events,
- { tag_id = e.tag.attributes.id, key = e.lhs, bubble = e.bubble_up }
- )
- return 'outer-x'
- end,
- },
- }, {
- 'outer ',
- h('text', {
- id = 'inner',
- nmap = {
- ['x'] = function(e)
- table.insert(
- captured_events,
- { tag_id = e.tag.attributes.id, key = e.lhs, bubble = e.bubble_up }
- )
- return 'inner-x'
- end,
- },
- }, 'inner'),
- ' text',
- }),
- ' end',
- }
+ it('returns nil for non-existent id', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render { h('text', { id = 'exists' }, 'content') }
- -- Test keypress in inner region - should trigger inner handler first
- set_cursor { 1, 8 } -- position in 'inner'
- local result = r:_expr_map_callback('n', 'x')
- assert.are.same(result, 'inner-x')
- assert.are.same(#captured_events, 1)
- assert.are.same(captured_events[1].tag_id, 'inner')
-
- -- Test keypress in outer region but not inner
- captured_events = {}
- set_cursor { 1, 2 } -- position in 'outer '
- local result = r:_expr_map_callback('n', 'x')
- assert.are.same(result, 'outer-x')
- assert.are.same(#captured_events, 1)
- assert.are.same(captured_events[1].tag_id, 'outer')
-
- -- Test keypress outside both regions
- captured_events = {}
- set_cursor { 1, 18 } -- position in ' end'
- local result = r:_expr_map_callback('n', 'x')
- assert.are.same(result, 'x') -- should return original key
- assert.are.same(#captured_events, 0)
+ assert.is_not_nil(r:get_element_by_id 'exists')
+ assert.is_nil(r:get_element_by_id 'does-not-exist')
+ end)
end)
end)
- it('should handle bubble_up behavior correctly', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local captured_events = {}
-
- -- Text:
- -- 000000000011111
- -- 012345678901234
- -- start inner end
- -- --------------- outer
- -- ----- inner
- r:render {
- h('text', {
- id = 'outer',
- nmap = {
- ['b'] = function(e)
- table.insert(captured_events, { tag_id = e.tag.attributes.id, key = e.lhs })
- return 'b'
- end,
- },
- }, {
- 'start ',
- h('text', {
- id = 'inner',
- nmap = {
- ['b'] = function(e)
- table.insert(captured_events, { tag_id = e.tag.attributes.id, key = e.lhs })
- e.bubble_up = true -- allow bubbling to outer
- return ''
- end,
- },
- }, 'inner'),
- ' end',
- }),
- }
+ ------------------------------------------------------------------------------
+ -- EXTMARK EDGE CASES
+ ------------------------------------------------------------------------------
- -- Test bubble up behavior
- set_cursor { 1, 8 } -- position in 'inner'
- local result = r:_expr_map_callback('n', 'b')
- assert.are.same(result, 'b')
- assert.are.same(#captured_events, 2)
- assert.are.same(captured_events[1].tag_id, 'inner')
- assert.are.same(captured_events[2].tag_id, 'outer')
- end)
- end)
+ describe('extmark edge cases', function()
+ it('handles extmarks at buffer boundaries', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render {
+ h('text', { id = 'start-boundary' }, 'start'),
+ ' middle ',
+ h('text', { id = 'end-boundary' }, 'end'),
+ }
- it('should handle no bubble_up behavior correctly', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local captured_events = {}
-
- -- Text:
- -- 000000000011111
- -- 012345678901234
- -- start inner end
- -- --------------- outer
- -- ----- inner
- r:render {
- h('text', {
- id = 'outer',
- nmap = {
- ['c'] = function(e)
- table.insert(captured_events, { tag_id = e.tag.attributes.id, key = e.lhs })
- return 'outer-handled'
- end,
- },
- }, {
- 'start ',
- h('text', {
- id = 'inner',
- nmap = {
- ['c'] = function(e)
- table.insert(captured_events, { tag_id = e.tag.attributes.id, key = e.lhs })
- e.bubble_up = false -- prevent bubbling
- return 'inner-handled'
- end,
- },
- }, 'inner'),
- ' end',
- }),
- }
+ assert.are.same({ 'start middle end' }, get_lines())
+ assert_element_bounds(r, 'start-boundary', Pos00.new(0, 0), Pos00.new(0, 5))
+ assert_element_bounds(r, 'end-boundary', Pos00.new(0, 13), Pos00.new(0, 16))
+
+ r:render {
+ 'prefix ',
+ h('text', { id = 'buffer-end' }, 'at-end'),
+ }
- -- Test no bubble up behavior
- set_cursor { 1, 8 } -- position in 'inner'
- local result = r:_expr_map_callback('n', 'c')
- assert.are.same(result, 'inner-handled')
- assert.are.same(#captured_events, 1) -- only inner should be called
- assert.are.same(captured_events[1].tag_id, 'inner')
+ assert_element_bounds(r, 'buffer-end', Pos00.new(0, 7), Pos00.new(0, 13))
+ end)
end)
- end)
- it('should handle different modes correctly', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local captured_events = {}
-
- -- Text:
- -- 0123
- -- text
- r:render {
- h('text', {
- id = 'multi-mode',
- nmap = {
- ['m'] = function(e)
- table.insert(captured_events, { tag_id = e.tag.attributes.id, mode = e.mode })
- return 'normal-mode'
- end,
- },
- imap = {
- ['m'] = function(e)
- table.insert(captured_events, { tag_id = e.tag.attributes.id, mode = e.mode })
- return 'insert-mode'
+ it('handles zero-width extmarks', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local captured_events = {}
+
+ r:render {
+ 'before',
+ h('text', {
+ id = 'zero-width',
+ on_change = function(e)
+ table.insert(captured_events, { id = 'zero-width', text = e.text })
end,
- },
- }, 'text'),
- }
+ }, ''),
+ 'after',
+ }
+
+ assert.are.same({ 'beforeafter' }, get_lines())
- set_cursor { 1, 2 } -- position in 'text'
+ local zero_elem = r:get_element_by_id 'zero-width'
+ assert.is_not_nil(zero_elem)
+ assert.are.same(Pos00.new(0, 6), zero_elem.extmark.start)
+ assert.are.same(Pos00.new(0, 6), zero_elem.extmark.stop)
- -- Test normal mode
- local result = r:_expr_map_callback('n', 'm')
- assert.are.same(result, 'normal-mode')
- assert.are.same(#captured_events, 1)
- assert.are.same(captured_events[1].mode, 'n')
+ -- Can detect zero-width extmark at its position
+ local elements = r:get_elements_at { 0, 6 }
+ assert.are.same(1, #elements)
+ assert.are.same('zero-width', elements[1].attributes.id)
- -- Test insert mode
- captured_events = {}
- local result = r:_expr_map_callback('i', 'm')
- assert.are.same(result, 'insert-mode')
- assert.are.same(#captured_events, 1)
- assert.are.same(captured_events[1].mode, 'i')
+ -- Inserting text triggers on_change
+ set_text(0, 6, 0, 6, { 'inserted' })
+ vim.cmd.doautocmd 'TextChanged'
- -- Test mode that has no handler
- captured_events = {}
- local result = r:_expr_map_callback('v', 'm')
- assert.are.same(result, 'm') -- should return original key
- assert.are.same(#captured_events, 0)
+ assert.are.same('beforeinsertedafter', get_text())
+ assert.are.same(1, #captured_events)
+ assert.are.same('inserted', captured_events[1].text)
+ end)
end)
- end)
- it('should handle empty keypress cancellation', function()
- with_buf({}, function()
- local r = Morph.new(0)
-
- -- Text:
- -- 00000000001111111
- -- 01234567890123456
- -- cancelable normal
- -- ---------- #cancel-key
- r:render {
- h('text', {
- id = 'cancel-key',
- nmap = {
- ['z'] = function()
- return '' -- cancel the keypress
- end,
- },
- }, 'cancelable'),
- ' normal',
- }
+ it('handles extmark spanning multiple lines', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render {
+ h('text', { id = 'multiline' }, {
+ 'line 1\n',
+ 'line 2\n',
+ 'line 3',
+ }),
+ }
- -- Test keypress cancellation in the region
- set_cursor { 1, 3 } -- position in 'cancelable'
- local result = r:_expr_map_callback('n', 'z')
- assert.are.same(result, '')
+ assert.are.same({ 'line 1', 'line 2', 'line 3' }, get_lines())
- -- Test normal keypress outside the region
- set_cursor { 1, 12 } -- position in ' normal'
- local result = r:_expr_map_callback('n', 'z')
- assert.are.same(result, 'z') -- should return original key
+ local elem = r:get_element_by_id 'multiline'
+ assert.is_not_nil(elem)
+ assert.are.same(Pos00.new(0, 0), elem.extmark.start)
+ assert.are.same(Pos00.new(2, 6), elem.extmark.stop)
+ assert.are.same('line 1\nline 2\nline 3', elem.extmark:_text())
+ end)
end)
- end)
- it('should handle blank lines at the end of buffer text', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local captured_changed_text = ''
+ it('handles blank lines at end of buffer text', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local captured_changed_text = ''
- -- Render content with blank lines at the end
- r:render {
- h('text', {
- on_change = function(e) captured_changed_text = e.text end,
- }, {
- 'line 1\n',
- 'line 2\n',
- '\n',
- }),
- }
+ r:render {
+ h('text', {
+ on_change = function(e) captured_changed_text = e.text end,
+ }, {
+ 'line 1\n',
+ 'line 2\n',
+ '\n',
+ }),
+ }
- assert.are.same(get_lines(), { 'line 1', 'line 2', '', '' })
+ assert.are.same({ 'line 1', 'line 2', '', '' }, get_lines())
- -- Test that getregion works correctly with blank lines at end
- local elems = r:get_elements_at { 0, 1 }
- assert.are.same(#elems, 1)
- assert.are.same(elems[1].extmark.start, Pos00.new(0, 0))
- assert.are.same(elems[1].extmark.stop, Pos00.new(3, 0))
+ local elems = r:get_elements_at { 0, 1 }
+ assert.are.same(1, #elems)
+ assert.are.same(Pos00.new(0, 0), elems[1].extmark.start)
+ assert.are.same(Pos00.new(3, 0), elems[1].extmark.stop)
- -- The extmark ends on a blank line, at column 0, which has tripped up
- -- the translated call to getregion in Extmark:_text() in the past. This
- -- is the main reason for this test: to check this case:
- assert.are.same(elems[1].extmark:_text(), 'line 1\nline 2\n\n')
+ -- extmark:_text() handles blank lines at end correctly
+ assert.are.same('line 1\nline 2\n\n', elems[1].extmark:_text())
- -- Modify the text and ensure on_change fires correctly
- set_text(0, 0, 3, 0, { 'modified content' })
- r.buf_watcher.fire()
+ set_text(0, 0, 3, 0, { 'modified content' })
+ vim.cmd.doautocmd 'TextChanged'
- assert.are.same(get_text(), 'modified content')
- assert.are.same(captured_changed_text, 'modified content')
+ assert.are.same('modified content', get_text())
+ assert.are.same('modified content', captured_changed_text)
+ end)
end)
end)
- it('should handle 0-column position in extmark when text follows', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local captured_changed_text = nil
-
- -- Text structure: "Search [filter]"
- r:render {
- 'Search [',
- h('text', {
- on_change = function(e) captured_changed_text = e.text end,
- }, 'filter'),
- ']',
- }
+ ------------------------------------------------------------------------------
+ -- MULTI-BYTE CHARACTER HANDLING
+ ------------------------------------------------------------------------------
- assert.are.same(get_lines(), { 'Search [filter]' })
+ describe('multi-byte characters', function()
+ it('calculates correct extmark boundaries for emojis, CJK, and combining chars', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
- -- Insert a newline at the end of 'filter'
- set_text(0, 14, 0, 14, { '', '' })
- r.buf_watcher.fire()
+ local emoji_text = '🚀🌟'
+ local cjk_text = '你好世界'
+ local combining_text = 'é' -- e + combining acute accent
- assert.are.same(get_lines(), { 'Search [filter', ']' })
- assert.are.same(captured_changed_text, 'filter\n')
- end)
- end)
+ r:render {
+ h('text', { id = 'emoji-tag' }, emoji_text),
+ ' ',
+ h('text', { id = 'cjk-tag' }, cjk_text),
+ ' ',
+ h('text', { id = 'combining-tag' }, combining_text),
+ }
- it('should handle on_change events for tags at end of line', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local captured_changed_text = nil
-
- -- Text structure: "Search: input_text"
- -- The input tag is at the end of the line
- r:render {
- 'Search: ',
- h('text', {
- on_change = function(e) captured_changed_text = e.text end,
- }, 'input_text'),
- }
+ local expected_text = emoji_text .. ' ' .. cjk_text .. ' ' .. combining_text
+ assert.are.same(expected_text, get_text())
+
+ local emoji_elem = r:get_element_by_id 'emoji-tag'
+ local cjk_elem = r:get_element_by_id 'cjk-tag'
+ local combining_elem = r:get_element_by_id 'combining-tag'
- assert.are.same(get_lines(), { 'Search: input_text' })
+ assert.is_not_nil(emoji_elem)
+ assert.is_not_nil(cjk_elem)
+ assert.is_not_nil(combining_elem)
- -- Delete the input text at the end of the line
- -- This should trigger on_change with an empty string
- set_text(0, 8, 0, 18, {})
- r.buf_watcher.fire()
+ assert.are.same(emoji_text, emoji_elem.extmark:_text())
+ assert.are.same(cjk_text, cjk_elem.extmark:_text())
+ assert.are.same(combining_text, combining_elem.extmark:_text())
+ end)
+ end)
- assert.are.same(get_text(), 'Search: ')
- assert.are.same(captured_changed_text, '')
+ it('handles cursor positioning in multi-byte sequences', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
- -- Test with different end position
- r:render {
- 'Filter: ',
- h('text', {
- on_change = function(e) captured_changed_text = e.text end,
- }, 'query'),
- }
+ r:render {
+ 'ASCII',
+ h('text', { id = 'mixed', hl = 'Test' }, '🎯中文'),
+ 'more',
+ }
- captured_changed_text = nil
- assert.are.same(get_lines(), { 'Filter: query' })
+ assert.are.same('ASCII🎯中文more', get_text())
- -- Delete just the query part
- set_text(0, 8, 0, 13, {})
- r.buf_watcher.fire()
+ local mixed_elem = r:get_element_by_id 'mixed'
+ assert.is_not_nil(mixed_elem)
- assert.are.same(get_text(), 'Filter: ')
- assert.are.same(captured_changed_text, '')
+ local elements_at_start = r:get_elements_at { 0, 5 }
+ assert.are.same(1, #elements_at_start)
+ assert.are.same('mixed', elements_at_start[1].attributes.id)
+
+ local elements_in_middle = r:get_elements_at { 0, 7 }
+ if #elements_in_middle > 0 then
+ assert.are.same('mixed', elements_in_middle[1].attributes.id)
+ end
+ end)
end)
end)
- it('should recognize a text-change when length of changed text == length of original', function()
- with_buf({}, function()
- local captured_changed_text = ''
-
- -- Text:
- -- 01234
- -- hello
- --- @param ctx morph.Ctx
- local function App(ctx)
- return {
+ ------------------------------------------------------------------------------
+ -- ON_CHANGE EVENTS
+ --
+ -- Tests for text change detection and event bubbling.
+ ------------------------------------------------------------------------------
+
+ describe('on_change events', function()
+ it('fires when text is replaced', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local captured_changed_text = ''
+
+ -- Text:
+ -- 01234
+ -- 0 one
+ -- 1 two
+ -- 2 three
+ -- 3
+ r:render {
h('text', {
- id = 'the-id',
- on_change = function(e)
- e.bubble_up = false
- captured_changed_text = e.text
- end,
- }, { 'hello' }),
+ on_change = function(e) captured_changed_text = e.text end,
+ }, {
+ 'one\n',
+ 'two\n',
+ 'three\n',
+ }),
}
- end
- local r = Morph.new()
- r:mount(h(App))
- assert.are.same(get_text(), 'hello')
+ local elems = r:get_elements_at { 0, 1 }
+ assert.are.same(1, #elems)
+ assert.are.same(Pos00.new(0, 0), elems[1].extmark.start)
+ assert.are.same(Pos00.new(3, 0), elems[1].extmark.stop)
- -- Replace the trailing 'o' with 'p':
- set_text(0, 4, 0, 5, { 'p' })
- r.buf_watcher.fire()
- assert.are.same(get_text(), 'hellp')
- assert.are.same(captured_changed_text, 'hellp')
- end)
- end)
+ vim.fn.setreg('"', 'bleh')
+ vim.cmd [[normal! ggVGp]]
- it('should recognize a text-change when text changes back to original content', function()
- with_buf({}, function()
- local captured_changed_text = ''
+ assert.are.same(1, line_count())
+ elems = r:get_elements_at { 0, 1 }
+ assert.are.same(1, #elems)
- -- Text:
- -- 01234
- -- hello
- --- @param ctx morph.Ctx
- local function App(ctx)
- return {
- h('text', {
- id = 'the-id',
- on_change = function(e)
- e.bubble_up = false
- captured_changed_text = e.text
- end,
- }, { 'hello' }),
- }
- end
- local r = Morph.new()
- r:mount(h(App))
+ vim.cmd.doautocmd 'TextChanged'
- assert.are.same(get_text(), 'hello')
+ assert.are.same('bleh', get_text())
+ assert.are.same('bleh', captured_changed_text)
- -- Delete the trailing 'o':
- set_text(0, 4, 0, 5, {})
- assert.are.same(get_text(), 'hell')
- r.buf_watcher.fire()
- assert.are.same(captured_changed_text, 'hell')
+ vim.fn.setreg('"', '')
+ vim.cmd [[normal! ggdG]]
+ vim.cmd.doautocmd 'TextChanged'
- -- Reinsert the trailing 'o':
- set_text(0, 4, 0, 4, { 'o' })
- assert.are.same(get_text(), 'hello')
- r.buf_watcher.fire()
- assert.are.same(captured_changed_text, 'hello')
+ assert.are.same('', get_text())
+ assert.are.same('', captured_changed_text)
+ end)
end)
- end)
-
- -- Edge case tests for extmarks at buffer boundaries
- it('should handle extmarks at buffer boundaries correctly', function()
- with_buf({}, function()
- local r = Morph.new(0)
- -- Test extmark starting at (0,0)
- r:render {
- h('text', { id = 'start-boundary' }, 'start'),
- ' middle ',
- h('text', { id = 'end-boundary' }, 'end'),
- }
+ it('fires when text is deleted', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ --- @type string?
+ local captured_changed_text = nil
+ r:render {
+ 'prefix:',
+ h('text', {
+ on_change = function(e) captured_changed_text = e.text end,
+ }, { 'one' }),
+ 'suffix',
+ }
- assert.are.same(get_lines(), { 'start middle end' })
+ vim.fn.setreg('"', 'bleh')
+ set_cursor { 1, 9 }
+ vim.cmd [[normal! vhhd]]
+ vim.cmd.doautocmd 'TextChanged'
- local start_elem = r:get_element_by_id 'start-boundary'
- assert.is_not_nil(start_elem)
- assert.are.same(start_elem.extmark.start, Pos00.new(0, 0))
- assert.are.same(start_elem.extmark.stop, Pos00.new(0, 5))
+ assert.are.same('prefix:suffix', get_text())
+ assert.are.same('', captured_changed_text)
+ end)
+ end)
- local end_elem = r:get_element_by_id 'end-boundary'
- assert.is_not_nil(end_elem)
- assert.are.same(end_elem.extmark.start, Pos00.new(0, 13))
- assert.are.same(end_elem.extmark.stop, Pos00.new(0, 16))
+ it('fires when newline is inserted', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local captured_changed_text = nil
- -- Test extmark at very end of buffer
- r:render {
- 'prefix ',
- h('text', { id = 'buffer-end' }, 'at-end'),
- }
+ r:render {
+ 'Search [',
+ h('text', {
+ on_change = function(e) captured_changed_text = e.text end,
+ }, 'filter'),
+ ']',
+ }
+
+ assert.are.same({ 'Search [filter]' }, get_lines())
- local end_elem = r:get_element_by_id 'buffer-end'
- assert.is_not_nil(end_elem)
- assert.are.same(end_elem.extmark.start, Pos00.new(0, 7))
- assert.are.same(end_elem.extmark.stop, Pos00.new(0, 13))
+ set_text(0, 14, 0, 14, { '', '' })
+ vim.cmd.doautocmd 'TextChanged'
+
+ assert.are.same({ 'Search [filter', ']' }, get_lines())
+ assert.are.same('filter\n', captured_changed_text)
+ end)
end)
- end)
- -- Edge case tests for zero-width extmarks
- it('should handle zero-width extmarks correctly', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local captured_events = {}
-
- -- Create a zero-width extmark by rendering empty content
- r:render {
- 'before',
- h('text', {
- id = 'zero-width',
- on_change = function(e)
- table.insert(captured_events, { id = 'zero-width', text = e.text })
- end,
- }, ''),
- 'after',
- }
+ it('fires with empty string when tag text is deleted entirely', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local captured_changed_text = nil
- assert.are.same(get_lines(), { 'beforeafter' })
+ -- Text structure: "Search: input_text"
+ -- The input tag is at the end of the line
+ r:render {
+ 'Search: ',
+ h('text', {
+ on_change = function(e) captured_changed_text = e.text end,
+ }, 'input_text'),
+ }
+
+ assert.are.same({ 'Search: input_text' }, get_lines())
+
+ -- Delete the input text at the end of the line
+ -- This should trigger on_change with an empty string
+ set_text(0, 8, 0, 18, {})
+ vim.cmd.doautocmd 'TextChanged'
+
+ assert.are.same('Search: ', get_text())
+ assert.are.same('', captured_changed_text)
- local zero_elem = r:get_element_by_id 'zero-width'
- assert.is_not_nil(zero_elem)
- assert.are.same(zero_elem.extmark.start, Pos00.new(0, 6))
- assert.are.same(zero_elem.extmark.stop, Pos00.new(0, 6))
+ -- Test with different end position
+ r:render {
+ 'Filter: ',
+ h('text', {
+ on_change = function(e) captured_changed_text = e.text end,
+ }, 'query'),
+ }
- -- Test that we can still detect the zero-width extmark at its position
- local elements = r:get_elements_at { 0, 6 }
- assert.are.same(#elements, 1)
- assert.are.same(elements[1].attributes.id, 'zero-width')
+ captured_changed_text = nil
+ assert.are.same({ 'Filter: query' }, get_lines())
- -- Test inserting text at the zero-width position
- set_text(0, 6, 0, 6, { 'inserted' })
- r.buf_watcher.fire()
+ -- Delete just the query part
+ set_text(0, 8, 0, 13, {})
+ vim.cmd.doautocmd 'TextChanged'
- assert.are.same(get_text(), 'beforeinsertedafter')
- assert.are.same(#captured_events, 1)
- assert.are.same(captured_events[1].text, 'inserted')
+ assert.are.same('Filter: ', get_text())
+ assert.are.same('', captured_changed_text)
+ end)
end)
- end)
- -- Edge case tests for multi-byte characters
- it('should handle multi-byte characters correctly', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local captured_events = {}
-
- -- Test with emojis, CJK characters, and combining characters
- local emoji_text = '🚀🌟'
- local cjk_text = '你好世界'
- local combining_text = 'é' -- e + combining acute accent
-
- r:render {
- h('text', {
- id = 'emoji-tag',
- on_change = function(e)
- table.insert(captured_events, { id = 'emoji-tag', text = e.text })
- end,
- }, emoji_text),
- ' ',
- h('text', {
- id = 'cjk-tag',
- on_change = function(e) table.insert(captured_events, { id = 'cjk-tag', text = e.text }) end,
- }, cjk_text),
- ' ',
- h('text', {
- id = 'combining-tag',
- on_change = function(e)
- table.insert(captured_events, { id = 'combining-tag', text = e.text })
- end,
- }, combining_text),
- }
+ it('detects change when new text has same length as original', function()
+ with_buf({}, function()
+ local captured_changed_text = ''
- local expected_text = emoji_text .. ' ' .. cjk_text .. ' ' .. combining_text
- assert.are.same(get_text(), expected_text)
+ --- @param _ctx morph.Ctx
+ local function App(_ctx)
+ return {
+ h('text', {
+ id = 'the-id',
+ on_change = function(e)
+ e.bubble_up = false
+ captured_changed_text = e.text
+ end,
+ }, { 'hello' }),
+ }
+ end
+ local r = Morph.new()
+ r:mount(h(App))
- -- Test that extmark boundaries are calculated correctly for multi-byte chars
- local emoji_elem = r:get_element_by_id 'emoji-tag'
- local cjk_elem = r:get_element_by_id 'cjk-tag'
- local combining_elem = r:get_element_by_id 'combining-tag'
+ assert.are.same('hello', get_text())
- assert.is_not_nil(emoji_elem)
- assert.is_not_nil(cjk_elem)
- assert.is_not_nil(combining_elem)
+ set_text(0, 4, 0, 5, { 'p' })
+ vim.cmd.doautocmd 'TextChanged'
+ assert.are.same('hellp', get_text())
+ assert.are.same('hellp', captured_changed_text)
+ end)
+ end)
- assert.are.same(emoji_elem.extmark:_text(), emoji_text)
- assert.are.same(cjk_elem.extmark:_text(), cjk_text)
- assert.are.same(combining_elem.extmark:_text(), combining_text)
+ it('detects change when text changes back to original content', function()
+ with_buf({}, function()
+ local captured_changed_text = ''
+
+ --- @param _ctx morph.Ctx
+ local function App2(_ctx)
+ return {
+ h('text', {
+ id = 'the-id',
+ on_change = function(e)
+ e.bubble_up = false
+ captured_changed_text = e.text
+ end,
+ }, { 'hello' }),
+ }
+ end
+ local r = Morph.new()
+ r:mount(h(App2))
+
+ assert.are.same('hello', get_text())
+
+ set_text(0, 4, 0, 5, {})
+ assert.are.same('hell', get_text())
+ vim.cmd.doautocmd 'TextChanged'
+ assert.are.same('hell', captured_changed_text)
+
+ set_text(0, 4, 0, 4, { 'o' })
+ assert.are.same('hello', get_text())
+ vim.cmd.doautocmd 'TextChanged'
+ assert.are.same('hello', captured_changed_text)
+ end)
end)
- end)
- -- Additional test for multi-byte character edge cases
- it('should handle multi-byte character boundaries and cursor positioning', function()
- with_buf({}, function()
- local r = Morph.new(0)
+ describe('event bubbling', function()
+ it('fires handlers from inner to outer, not affecting siblings', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local captured_events = {}
+
+ -- Text:
+ -- 0 1 2
+ -- 0123456789012345678901234567890
+ -- sibling outer middle inner
+ local function reset_render()
+ r:render {
+ h('text', {
+ id = 'sibling',
+ on_change = function(e)
+ table.insert(captured_events, { id = 'sibling', text = e.text })
+ end,
+ }, 'sibling'),
+ ' ',
+ h('text', {
+ id = 'outer',
+ on_change = function(e)
+ table.insert(captured_events, { id = 'outer', text = e.text })
+ end,
+ }, {
+ 'outer ',
+ h('text', { id = 'middle' }, {
+ 'middle ',
+ h('text', {
+ id = 'inner',
+ on_change = function(e)
+ table.insert(captured_events, { id = 'inner', text = e.text })
+ end,
+ }, 'inner'),
+ }),
+ }),
+ }
+ end
+ reset_render()
+
+ -- Change innermost element -> fires inner then outer
+ captured_events = {}
+ set_text(0, 21, 0, 26, { 'changed' })
+ assert.are.same({ 'sibling outer middle changed' }, get_lines())
+ vim.cmd.doautocmd 'TextChanged'
+
+ assert.are.same(2, #captured_events)
+ assert.are.same('inner', captured_events[1].id)
+ assert.are.same('changed', captured_events[1].text)
+ assert.are.same('outer', captured_events[2].id)
+ assert.are.same('outer middle changed', captured_events[2].text)
+
+ -- Change sibling -> only sibling handler fires
+ reset_render()
+ captured_events = {}
+ set_text(0, 0, 0, 7, { 'modified' })
+ assert.are.same({ 'modified outer middle inner' }, get_lines())
+ vim.cmd.doautocmd 'TextChanged'
+
+ assert.are.same(1, #captured_events)
+ assert.are.same('sibling', captured_events[1].id)
+ assert.are.same('modified', captured_events[1].text)
+
+ -- Change middle (no handler) -> only outer handler fires
+ reset_render()
+ captured_events = {}
+ set_text(0, 14, 0, 20, { 'center' })
+ assert.are.same({ 'sibling outer center inner' }, get_lines())
+ vim.cmd.doautocmd 'TextChanged'
+
+ assert.are.same(1, #captured_events)
+ assert.are.same('outer', captured_events[1].id)
+ assert.are.same('outer center inner', captured_events[1].text)
+ end)
+ end)
+
+ it('bubbles through multiple nested levels', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local events = {}
+
+ r:render {
+ h('text', {
+ id = 'level1',
+ on_change = function(e) table.insert(events, { level = 1, text = e.text }) end,
+ }, {
+ h('text', {
+ id = 'level2',
+ on_change = function(e) table.insert(events, { level = 2, text = e.text }) end,
+ }, {
+ h('text', {
+ id = 'level3',
+ on_change = function(e) table.insert(events, { level = 3, text = e.text }) end,
+ }, 'inner'),
+ }),
+ }),
+ }
+
+ set_text(0, 0, 0, 5, { 'changed' })
+ vim.cmd.doautocmd 'TextChanged'
- -- Test with a mix of ASCII and multi-byte characters
- r:render {
- 'ASCII',
- h('text', { id = 'mixed', hl = 'Test' }, '🎯中文'),
- 'more',
- }
+ assert.are.same(3, #events)
+ assert.are.same(3, events[1].level)
+ assert.are.same(2, events[2].level)
+ assert.are.same(1, events[3].level)
+ end)
+ end)
- assert.are.same(get_text(), 'ASCII🎯中文more')
+ it('stops bubbling when bubble_up is set to false', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local events = {}
- local mixed_elem = r:get_element_by_id 'mixed'
- assert.is_not_nil(mixed_elem)
+ r:render {
+ h('text', {
+ id = 'outer',
+ on_change = function(e) table.insert(events, { id = 'outer', text = e.text }) end,
+ }, {
+ h('text', {
+ id = 'inner',
+ on_change = function(e)
+ table.insert(events, { id = 'inner', text = e.text })
+ e.bubble_up = false
+ end,
+ }, 'text'),
+ }),
+ }
- -- Test cursor positioning at various points in the multi-byte sequence
- local elements_at_start = r:get_elements_at { 0, 5 } -- Start of emoji
- assert.are.same(#elements_at_start, 1)
- assert.are.same(elements_at_start[1].attributes.id, 'mixed')
+ set_text(0, 0, 0, 4, { 'new' })
+ vim.cmd.doautocmd 'TextChanged'
- -- Test that we can handle positions that might fall in the middle of multi-byte chars
- -- (though Neovim should handle this gracefully)
- local elements_in_middle = r:get_elements_at { 0, 7 } -- Somewhere in the multi-byte sequence
- if #elements_in_middle > 0 then
- assert.are.same(elements_in_middle[1].attributes.id, 'mixed')
- end
+ assert.are.same(1, #events)
+ assert.are.same('inner', events[1].id)
+ end)
+ end)
end)
end)
- it('should handle undo/redo', function()
- with_buf({}, function()
- local r = Morph.new(0)
+ ------------------------------------------------------------------------------
+ -- KEYPRESS DISPATCH
+ --
+ -- Tests for _dispatch_keypress routing keypresses to element handlers.
+ ------------------------------------------------------------------------------
+
+ describe('keypress dispatch', function()
+ it('returns original key when no elements at cursor', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render { 'plain text without handlers' }
+
+ set_cursor { 1, 5 }
+ local result = r:_dispatch_keypress('n', '')
+ assert.are.same('', result)
+ end)
+ end)
- local captured_changed_text = ''
+ it('only triggers handlers in defined regions', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local captured_events = {}
- --- @param ctx morph.Ctx
- local function App(ctx)
- return {
- 'Search: [',
+ -- Text:
+ -- 0 1 2
+ -- 01345678901234567890123
+ -- prefix clickable suffix
+ r:render {
+ 'prefix ',
h('text', {
- id = 'filter',
- on_change = function(e) captured_changed_text = e.text end,
- }, ''),
- ']',
+ id = 'clickable',
+ nmap = {
+ [''] = function(e)
+ table.insert(captured_events, { tag_id = e.tag.attributes.id, key = e.lhs })
+ return ''
+ end,
+ },
+ }, 'clickable'),
+ ' suffix',
}
- end
- r:mount(h(App))
- local filter_elem = assert(r:get_element_by_id 'filter')
- assert.are.same(get_text(), 'Search: []')
- assert.are.same(filter_elem.extmark:_text(), '')
- set_cursor { filter_elem.extmark.start[1] + 1, filter_elem.extmark.start[2] }
+ -- Inside clickable region
+ set_cursor { 1, 12 }
+ local result = r:_dispatch_keypress('n', '')
+ assert.are.same('', result)
+ assert.are.same(1, #captured_events)
+ assert.are.same('clickable', captured_events[1].tag_id)
+ assert.are.same('', captured_events[1].key)
+
+ -- In prefix (outside)
+ captured_events = {}
+ set_cursor { 1, 2 }
+ result = r:_dispatch_keypress('n', '')
+ assert.are.same('', result)
+ assert.are.same(0, #captured_events)
+
+ -- In suffix (outside)
+ captured_events = {}
+ set_cursor { 1, 17 }
+ result = r:_dispatch_keypress('n', '')
+ assert.are.same('', result)
+ assert.are.same(0, #captured_events)
+ end)
+ end)
- -- Insert text:
- vim.api.nvim_feedkeys('ifilter', 'ntx', false)
- r.buf_watcher.fire()
- assert.are.same(captured_changed_text, 'filter')
+ it('triggers innermost handler first for overlapping regions', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local captured_events = {}
- filter_elem = assert(r:get_element_by_id 'filter')
- assert.are.same(filter_elem.extmark:_text(), 'filter')
- assert.are.same(get_text(), 'Search: [filter]')
+ -- Text:
+ -- 0 1
+ -- 01234567890123456789
+ -- outer inner text end
+ r:render {
+ h('text', {
+ id = 'outer',
+ nmap = {
+ ['x'] = function(e)
+ table.insert(captured_events, { tag_id = e.tag.attributes.id })
+ return 'outer-x'
+ end,
+ },
+ }, {
+ 'outer ',
+ h('text', {
+ id = 'inner',
+ nmap = {
+ ['x'] = function(e)
+ table.insert(captured_events, { tag_id = e.tag.attributes.id })
+ return 'inner-x'
+ end,
+ },
+ }, 'inner'),
+ ' text',
+ }),
+ ' end',
+ }
- -- Undo change:
- vim.cmd.undo()
- assert.are.same(get_text(), 'Search: []')
- r.buf_watcher.fire()
- assert.are.same(captured_changed_text, '')
+ -- In inner region
+ set_cursor { 1, 8 }
+ local result = r:_dispatch_keypress('n', 'x')
+ assert.are.same('inner-x', result)
+ assert.are.same(1, #captured_events)
+ assert.are.same('inner', captured_events[1].tag_id)
+
+ -- In outer region but not inner
+ captured_events = {}
+ set_cursor { 1, 2 }
+ result = r:_dispatch_keypress('n', 'x')
+ assert.are.same('outer-x', result)
+ assert.are.same(1, #captured_events)
+ assert.are.same('outer', captured_events[1].tag_id)
+
+ -- Outside both regions
+ captured_events = {}
+ set_cursor { 1, 18 }
+ result = r:_dispatch_keypress('n', 'x')
+ assert.are.same('x', result)
+ assert.are.same(0, #captured_events)
+ end)
+ end)
- filter_elem = assert(r:get_element_by_id 'filter')
- assert.are.same(filter_elem.extmark:_text(), '')
+ describe('event bubbling', function()
+ it('bubbles to outer when bubble_up is true', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local captured_events = {}
+
+ -- Text:
+ -- 0 1
+ -- 012345678901234
+ -- start inner end
+ r:render {
+ h('text', {
+ id = 'outer',
+ nmap = {
+ ['b'] = function(e)
+ table.insert(captured_events, { tag_id = e.tag.attributes.id })
+ return 'b'
+ end,
+ },
+ }, {
+ 'start ',
+ h('text', {
+ id = 'inner',
+ nmap = {
+ ['b'] = function(e)
+ table.insert(captured_events, { tag_id = e.tag.attributes.id })
+ e.bubble_up = true
+ return ''
+ end,
+ },
+ }, 'inner'),
+ ' end',
+ }),
+ }
+
+ set_cursor { 1, 8 }
+ local result = r:_dispatch_keypress('n', 'b')
+ assert.are.same('b', result)
+ assert.are.same(2, #captured_events)
+ assert.are.same('inner', captured_events[1].tag_id)
+ assert.are.same('outer', captured_events[2].tag_id)
+ end)
+ end)
+
+ it('stops at inner when bubble_up is false', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local captured_events = {}
+
+ r:render {
+ h('text', {
+ id = 'outer',
+ nmap = {
+ ['c'] = function(e)
+ table.insert(captured_events, { tag_id = e.tag.attributes.id })
+ return 'outer-handled'
+ end,
+ },
+ }, {
+ 'start ',
+ h('text', {
+ id = 'inner',
+ nmap = {
+ ['c'] = function(e)
+ table.insert(captured_events, { tag_id = e.tag.attributes.id })
+ e.bubble_up = false
+ return 'inner-handled'
+ end,
+ },
+ }, 'inner'),
+ ' end',
+ }),
+ }
+
+ set_cursor { 1, 8 }
+ local result = r:_dispatch_keypress('n', 'c')
+ assert.are.same('inner-handled', result)
+ assert.are.same(1, #captured_events)
+ assert.are.same('inner', captured_events[1].tag_id)
+ end)
+ end)
+ end)
+
+ describe('mode-specific handlers', function()
+ it('uses nmap for normal mode and imap for insert mode', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local captured_events = {}
+
+ r:render {
+ h('text', {
+ id = 'multi-mode',
+ nmap = {
+ ['m'] = function(e)
+ table.insert(captured_events, { mode = e.mode })
+ return 'normal-mode'
+ end,
+ },
+ imap = {
+ ['m'] = function(e)
+ table.insert(captured_events, { mode = e.mode })
+ return 'insert-mode'
+ end,
+ },
+ }, 'text'),
+ }
+
+ set_cursor { 1, 2 }
+
+ local result = r:_dispatch_keypress('n', 'm')
+ assert.are.same('normal-mode', result)
+ assert.are.same(1, #captured_events)
+ assert.are.same('n', captured_events[1].mode)
+
+ captured_events = {}
+ result = r:_dispatch_keypress('i', 'm')
+ assert.are.same('insert-mode', result)
+ assert.are.same(1, #captured_events)
+ assert.are.same('i', captured_events[1].mode)
+
+ -- Mode with no handler returns original key
+ captured_events = {}
+ result = r:_dispatch_keypress('v', 'm')
+ assert.are.same('m', result)
+ assert.are.same(0, #captured_events)
+ end)
+ end)
+ end)
- -- Redo change:
- vim.cmd.redo()
- r.buf_watcher.fire()
- assert.are.same(captured_changed_text, 'filter')
+ it('cancels keypress when handler returns empty string', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
- filter_elem = assert(r:get_element_by_id 'filter')
- assert.are.same(filter_elem.extmark:_text(), 'filter')
- assert.are.same(get_text(), 'Search: [filter]')
+ -- Text:
+ -- 0 1
+ -- 01234567890123456
+ -- cancelable normal
+ r:render {
+ h('text', {
+ id = 'cancel-key',
+ nmap = { ['z'] = function() return '' end },
+ }, 'cancelable'),
+ ' normal',
+ }
+
+ set_cursor { 1, 3 }
+ local result = r:_dispatch_keypress('n', 'z')
+ assert.are.same('', result)
+
+ set_cursor { 1, 12 }
+ result = r:_dispatch_keypress('n', 'z')
+ assert.are.same('z', result)
+ end)
end)
end)
- it('should execute do_after_render callbacks immediately after mount', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local callback_executed = false
- local callback_execution_order = {}
-
- --- @param ctx morph.Ctx
- local function TestComponent(ctx)
- if ctx.phase == 'mount' then
- ctx:do_after_render(function()
- callback_executed = true
- table.insert(callback_execution_order, 'callback')
- end)
- table.insert(callback_execution_order, 'component-render')
+ ------------------------------------------------------------------------------
+ -- KEYMAP MANAGEMENT
+ ------------------------------------------------------------------------------
+
+ describe('keymap management', function()
+ it('cleans up keymaps without error when no original mapping existed', function()
+ with_buf({}, function()
+ local leaked_context
+ local function TestComponent(ctx)
+ leaked_context = ctx
+ if ctx.phase == 'mount' then ctx.state = 1 end
+
+ if ctx.state == 1 then
+ return h('text', {
+ nmap = { ['nonexistent'] = function() return '' end },
+ }, { 'with keymap' })
+ else
+ return h('text', {}, { 'without keymap' })
+ end
end
- return {
- h('text', { id = 'test-text' }, 'Hello World'),
- }
- end
+ local r = Morph.new(0)
+ r:mount(h(TestComponent))
+ assert.are.same('with keymap', get_text())
- -- Mount the component
- r:mount(h(TestComponent))
- table.insert(callback_execution_order, 'after-mount')
+ local mapping = vim.fn.maparg('nonexistent', 'n', false, true)
+ assert.is_false(vim.tbl_isempty(mapping))
- -- Verify the callback was executed
- assert.is_true(callback_executed)
+ assert.has_no.errors(function() leaked_context:update(2) end)
+ assert.are.same('without keymap', get_text())
+
+ mapping = vim.fn.maparg('nonexistent', 'n', false, true)
+ assert.is_true(vim.tbl_isempty(mapping))
+ end)
+ end)
- -- Verify the execution order: component render, then callback, then after mount
- assert.are.same(callback_execution_order, {
- 'component-render',
- 'callback',
- 'after-mount',
- })
+ it('restores original keymaps when component unmounts', function()
+ with_buf({}, function()
+ local my_orig_callback = function() end
+ local my_component_callback_count = 0
+ local my_component_callback = function()
+ my_component_callback_count = my_component_callback_count + 1
+ end
+ vim.keymap.set('n', 'abc', my_orig_callback, { buffer = true })
+
+ local leaked_context
+ local function TestComponent(ctx)
+ leaked_context = ctx
+ if ctx.phase == 'mount' then ctx.state = 1 end
+
+ if ctx.state == 1 then
+ return h('text', {
+ nmap = { ['abc'] = my_component_callback },
+ }, { 'Hello World!' })
+ else
+ return h('text', {}, { 'Hello World (II)!' })
+ end
+ end
- -- Verify the component was actually rendered
- assert.are.same(get_text(), 'Hello World')
- local test_elem = r:get_element_by_id 'test-text'
- assert.is_not_nil(test_elem)
+ local r = Morph.new(0)
+ local result = r:_dispatch_keypress('n', 'abc')
+ assert.are.same('abc', result)
+ assert.are.same(0, my_component_callback_count)
+
+ r:mount(h(TestComponent))
+ assert.are.same('Hello World!', get_text())
+ assert.are_not.same(
+ my_orig_callback,
+ vim.fn.maparg('abc', 'n', false, true).callback
+ )
+
+ result = r:_dispatch_keypress('n', 'abc')
+ assert.are.same(nil, result)
+ assert.are.same(1, my_component_callback_count)
+
+ leaked_context:update(2)
+ assert.are.same('Hello World (II)!', get_text())
+
+ result = r:_dispatch_keypress('n', 'abc')
+ assert.are.same('abc', result)
+ assert.are.same(my_orig_callback, vim.fn.maparg('abc', 'n', false, true).callback)
+ end)
end)
end)
- it('should execute multiple do_after_render callbacks in registration order', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local execution_order = {}
+ ------------------------------------------------------------------------------
+ -- UNDO/REDO
+ ------------------------------------------------------------------------------
- --- @param ctx morph.Ctx
- local function TestComponent(ctx)
- if ctx.phase == 'mount' then
- -- Register multiple callbacks
- ctx:do_after_render(function() table.insert(execution_order, 'first-callback') end)
+ describe('undo/redo', function()
+ it('tracks extmarks correctly through undo/redo', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local captured_changed_text = ''
- ctx:do_after_render(function() table.insert(execution_order, 'second-callback') end)
+ --- @param _ctx morph.Ctx
+ local function App(_ctx)
+ return {
+ 'Search: [',
+ h('text', {
+ id = 'filter',
+ on_change = function(e) captured_changed_text = e.text end,
+ }, ''),
+ ']',
+ }
+ end
+
+ r:mount(h(App))
+ local filter_elem = assert(r:get_element_by_id 'filter')
+ assert.are.same('Search: []', get_text())
+ assert.are.same('', filter_elem.extmark:_text())
+ set_cursor { filter_elem.extmark.start[1] + 1, filter_elem.extmark.start[2] }
+
+ vim.api.nvim_feedkeys('ifilter', 'ntx', false)
+ vim.cmd.doautocmd 'TextChanged'
+ assert.are.same('filter', captured_changed_text)
+
+ filter_elem = assert(r:get_element_by_id 'filter')
+ assert.are.same('filter', filter_elem.extmark:_text())
+ assert.are.same('Search: [filter]', get_text())
- ctx:do_after_render(function() table.insert(execution_order, 'third-callback') end)
+ vim.cmd.undo()
+ assert.are.same('Search: []', get_text())
+ vim.cmd.doautocmd 'TextChanged'
+ assert.are.same('', captured_changed_text)
+
+ filter_elem = assert(r:get_element_by_id 'filter')
+ assert.are.same('', filter_elem.extmark:_text())
+
+ vim.cmd.redo()
+ vim.cmd.doautocmd 'TextChanged'
+ assert.are.same('filter', captured_changed_text)
+
+ filter_elem = assert(r:get_element_by_id 'filter')
+ assert.are.same('filter', filter_elem.extmark:_text())
+ assert.are.same('Search: [filter]', get_text())
+ end)
+ end)
+ end)
+
+ ------------------------------------------------------------------------------
+ -- COMPONENT LIFECYCLE
+ ------------------------------------------------------------------------------
+
+ describe('component lifecycle', function()
+ describe('mount phase', function()
+ it('calls component with phase=mount on first render', function()
+ local mount_calls = {}
+ local unmount_calls = {}
+
+ --- @param ctx morph.Ctx<{ name: string }, { value: string }>
+ local function TestComponent(ctx)
+ if ctx.phase == 'mount' then
+ ctx.state = { value = 'Hello ' .. ctx.props.name }
+ table.insert(mount_calls, ctx.props.name)
+ elseif ctx.phase == 'unmount' then
+ table.insert(unmount_calls, ctx.props.name)
+ end
+ return {
+ h('text', { hl = 'TestHL' }, ctx.state.value),
+ '!',
+ }
end
- return {
- h('text', { id = 'test' }, 'Multiple callbacks'),
+ local tree = {
+ 'Prefix: ',
+ h(TestComponent, { name = 'World' }, {}),
+ ' Suffix',
}
- end
- -- Mount the component
- r:mount(h(TestComponent))
+ local lines = Morph.markup_to_lines { tree = tree }
+ assert.are.same({ 'Prefix: Hello World! Suffix' }, lines)
+ assert.are.same({ 'World' }, mount_calls)
+ assert.are.same({ 'World' }, unmount_calls)
+ end)
+
+ it('executes do_after_render callbacks immediately after mount', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local callback_executed = false
+ local callback_execution_order = {}
+
+ --- @param ctx morph.Ctx
+ local function TestComponent(ctx)
+ if ctx.phase == 'mount' then
+ ctx:do_after_render(function()
+ callback_executed = true
+ table.insert(callback_execution_order, 'callback')
+ end)
+ table.insert(callback_execution_order, 'component-render')
+ end
+ return { h('text', { id = 'test-text' }, 'Hello World') }
+ end
+
+ r:mount(h(TestComponent))
+ table.insert(callback_execution_order, 'after-mount')
+
+ assert.is_true(callback_executed)
+ assert.are.same({
+ 'component-render',
+ 'callback',
+ 'after-mount',
+ }, callback_execution_order)
+ assert.are.same('Hello World', get_text())
+ end)
+ end)
+
+ it('executes multiple do_after_render callbacks in registration order', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local execution_order = {}
+
+ --- @param ctx morph.Ctx
+ local function TestComponent(ctx)
+ if ctx.phase == 'mount' then
+ ctx:do_after_render(function() table.insert(execution_order, 'first') end)
+ ctx:do_after_render(function() table.insert(execution_order, 'second') end)
+ ctx:do_after_render(function() table.insert(execution_order, 'third') end)
+ end
+ return { h('text', { id = 'test' }, 'Multiple callbacks') }
+ end
+
+ r:mount(h(TestComponent))
+
+ assert.are.same({ 'first', 'second', 'third' }, execution_order)
+ end)
+ end)
+
+ it('does not re-render when update called during mount phase', function()
+ with_buf({}, function()
+ local render_count = 0
+
+ --- @param ctx morph.Ctx<{}, { value: number }>
+ local function TestComponent(ctx)
+ render_count = render_count + 1
+ if ctx.phase == 'mount' then
+ ctx.state = { value = 1 }
+ ctx:update { value = 2 }
+ end
+ return { 'Value: ' .. ctx.state.value }
+ end
+
+ local r = Morph.new(0)
+ r:mount(h(TestComponent))
+
+ assert.are.same(1, render_count)
+ assert.are.same('Value: 2', get_text())
+ end)
+ end)
+ end)
- -- Verify all callbacks were executed in the correct order
- assert.are.same(execution_order, {
- 'first-callback',
- 'second-callback',
- 'third-callback',
- })
- assert.are.same(get_text(), 'Multiple callbacks')
+ describe('update phase', function()
+ it('re-renders components when state changes', function()
+ with_buf({}, function()
+ --- @diagnostic disable-next-line: assign-type-mismatch
+ local leaked_ctx = { app = {}, c1 = {}, c2 = {} } --- @type table
+ local Counter = make_counter(leaked_ctx)
+
+ --- @param ctx morph.Ctx<{}, { toggle1: boolean, show2: boolean }>
+ local function App(ctx)
+ if ctx.phase == 'mount' then ctx.state = { toggle1 = false, show2 = true } end
+ leaked_ctx.app = ctx
+ return {
+ ctx.state.toggle1 and 'Toggle1' or h(Counter, { id = 'c1' }, {}),
+ '\n',
+ ctx.state.show2 and { '\n', h(Counter, { id = 'c2' }, {}) },
+ }
+ end
+
+ local renderer = Morph.new()
+ renderer:mount(h(App, {}, {}))
+
+ assert.are.same({ 'Value: 1', '', 'Value: 1' }, get_lines())
+ assert.are.same('mount', leaked_ctx.c1.state.phase)
+ assert.are.same('mount', leaked_ctx.c2.state.phase)
+
+ leaked_ctx.app:update { toggle1 = true, show2 = true }
+ assert.are.same({ 'Toggle1', '', 'Value: 1' }, get_lines())
+ assert.are.same('unmount', leaked_ctx.c1.state.phase)
+ assert.are.same('update', leaked_ctx.c2.state.phase)
+
+ leaked_ctx.app:update { toggle1 = true, show2 = false }
+ assert.are.same({ 'Toggle1', '' }, get_lines())
+ assert.are.same('unmount', leaked_ctx.c1.state.phase)
+ assert.are.same('unmount', leaked_ctx.c2.state.phase)
+
+ leaked_ctx.app:update { toggle1 = false, show2 = true }
+ assert.are.same({ 'Value: 1', '', 'Value: 1' }, get_lines())
+ assert.are.same('mount', leaked_ctx.c1.state.phase)
+ assert.are.same('mount', leaked_ctx.c2.state.phase)
+
+ leaked_ctx.c1:update { count = 2 }
+ assert.are.same({ 'Value: 2', '', 'Value: 1' }, get_lines())
+ assert.are.same('update', leaked_ctx.c1.state.phase)
+ assert.are.same('update', leaked_ctx.c2.state.phase)
+
+ leaked_ctx.c2:update { count = 3 }
+ assert.are.same({ 'Value: 2', '', 'Value: 3' }, get_lines())
+ assert.are.same('update', leaked_ctx.c1.state.phase)
+ assert.are.same('update', leaked_ctx.c2.state.phase)
+ end)
+ end)
+
+ it('persists child state across parent re-renders', function()
+ with_buf({}, function()
+ local child_ctx_ref
+
+ --- @param ctx morph.Ctx<{}, { count: number }>
+ local function Child(ctx)
+ if ctx.phase == 'mount' then ctx.state = { count = 0 } end
+ child_ctx_ref = ctx
+ return { 'Count: ' .. ctx.state.count }
+ end
+
+ local parent_ctx_ref
+ --- @param ctx morph.Ctx<{}, { label: string }>
+ local function Parent(ctx)
+ if ctx.phase == 'mount' then ctx.state = { label = 'A' } end
+ parent_ctx_ref = ctx
+ return {
+ 'Label: ' .. ctx.state.label .. '\n',
+ h(Child),
+ }
+ end
+
+ local r = Morph.new(0)
+ r:mount(h(Parent))
+
+ assert.are.same('Label: A\nCount: 0', get_text())
+
+ child_ctx_ref:update { count = 5 }
+ assert.are.same('Label: A\nCount: 5', get_text())
+
+ parent_ctx_ref:update { label = 'B' }
+ assert.are.same('Label: B\nCount: 5', get_text())
+ end)
+ end)
+
+ it('handles Ctx:update when on_change is nil without errors', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local leaked_context = nil
+ local called = 0
+
+ --- @param ctx morph.Ctx<{}, { count: integer }>
+ local function TestComponent(ctx)
+ called = called + 1
+ if ctx.phase == 'mount' then
+ ctx.state = { count = 1 }
+ leaked_context = ctx
+ ctx.on_change = nil
+ end
+ return { h('text', { id = 'test-component' }, 'Count: ' .. ctx.state.count) }
+ end
+
+ r:mount(h(TestComponent))
+ assert.are.same('Count: 1', get_text())
+ assert.are.same(1, called)
+
+ local orig_schedule = vim.schedule
+ vim.schedule = function(f) return f() end
+ assert.has_no.errors(function() leaked_context:update { count = 2 } end)
+ vim.schedule = orig_schedule
+ end)
+ end)
+
+ it('handles state update via do_after_render', function()
+ with_buf({}, function()
+ local render_count = 0
+
+ --- @param ctx morph.Ctx<{}, { initialized: boolean }>
+ local function TestComponent(ctx)
+ render_count = render_count + 1
+ if ctx.phase == 'mount' then
+ ctx.state = { initialized = false }
+ ctx:do_after_render(function()
+ --- @diagnostic disable-next-line: unnecessary-if
+ if not ctx.state.initialized then ctx:update { initialized = true } end
+ end)
+ end
+ return { ctx.state.initialized and 'ready' or 'loading' }
+ end
+
+ local r = Morph.new(0)
+ local orig_schedule = vim.schedule
+ vim.schedule = function(f) f() end
+
+ r:mount(h(TestComponent))
+
+ vim.schedule = orig_schedule
+
+ assert.are.same(2, render_count)
+ assert.are.same('ready', get_text())
+ end)
+ end)
+ end)
+
+ describe('unmount phase', function()
+ it('unmounts components when buffer is deleted', function()
+ local unmount_calls = {}
+ local mount_calls = {}
+
+ --- @param ctx morph.Ctx
+ local function TestComponent(ctx)
+ if ctx.phase == 'mount' then
+ table.insert(mount_calls, ctx.props.name)
+ elseif ctx.phase == 'unmount' then
+ table.insert(unmount_calls, ctx.props.name)
+ end
+ return { h('text', { id = ctx.props.name }, 'Component ' .. ctx.props.name) }
+ end
+
+ --- @param ctx morph.Ctx<{}, { show_second: boolean }>
+ local function App(ctx)
+ if ctx.phase == 'mount' then
+ ctx.state = { show_second = true }
+ elseif ctx.phase == 'unmount' then
+ table.insert(unmount_calls, 'app')
+ end
+ return {
+ h(TestComponent, { name = 'first' }),
+ '\n',
+ ctx.state.show_second and h(TestComponent, { name = 'second' }) or nil,
+ }
+ end
+
+ vim.cmd.new()
+ local bufnr = vim.api.nvim_get_current_buf()
+ local r = Morph.new(bufnr)
+ r:mount(h(App))
+
+ assert.are.same({ 'Component first', 'Component second' }, get_lines())
+ assert.is_true(vim.tbl_contains(mount_calls, 'first'))
+ assert.is_true(vim.tbl_contains(mount_calls, 'second'))
+ assert.are.same({}, unmount_calls)
+
+ vim.api.nvim_buf_delete(bufnr, { force = true })
+
+ assert.is_true(vim.tbl_contains(unmount_calls, 'first'))
+ assert.is_true(vim.tbl_contains(unmount_calls, 'second'))
+ assert.is_true(vim.tbl_contains(unmount_calls, 'app'))
+ end)
+
+ it('unmounts deeply nested components on state change', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local unmount_calls = {}
+ local leaked_contexts = {}
+
+ --- @param ctx morph.Ctx<{ name: string }, {}>
+ local function Level3Component(ctx)
+ if ctx.phase == 'unmount' then
+ table.insert(unmount_calls, 'level3-' .. ctx.props.name)
+ end
+ return { h('text', { id = 'level3-' .. ctx.props.name }, 'Level3: ' .. ctx.props.name) }
+ end
+
+ --- @param ctx morph.Ctx<{ name: string }, {}>
+ local function Level2Component(ctx)
+ if ctx.phase == 'unmount' then
+ table.insert(unmount_calls, 'level2-' .. ctx.props.name)
+ end
+ return {
+ h('text', {}, {
+ 'Level2: ' .. ctx.props.name,
+ '\n',
+ {
+ h('text', {}, 'Container: '),
+ {
+ h(Level3Component, { name = ctx.props.name .. '-child1' }),
+ '\n',
+ h(Level3Component, { name = ctx.props.name .. '-child2' }),
+ },
+ },
+ }),
+ }
+ end
+
+ --- @param ctx morph.Ctx<{ name: string }, {}>
+ local function Level1Component(ctx)
+ if ctx.phase == 'unmount' then
+ table.insert(unmount_calls, 'level1-' .. ctx.props.name)
+ end
+ return {
+ h('text', {}, 'Level1: ' .. ctx.props.name),
+ '\n',
+ { h(Level2Component, { name = ctx.props.name .. '-sub' }) },
+ }
+ end
+
+ --- @param ctx morph.Ctx<{}, { show_nested: boolean }>
+ local function App(ctx)
+ if ctx.phase == 'mount' then
+ ctx.state = { show_nested = true }
+ leaked_contexts.app = ctx
+ elseif ctx.phase == 'unmount' then
+ table.insert(unmount_calls, 'app')
+ end
+ return {
+ 'App Root',
+ '\n',
+ ctx.state.show_nested and {
+ h(Level1Component, { name = 'main' }),
+ '\n',
+ h(Level1Component, { name = 'secondary' }),
+ } or 'No nested components',
+ }
+ end
+
+ r:mount(h(App))
+
+ assert.are.same({
+ 'App Root',
+ 'Level1: main',
+ 'Level2: main-sub',
+ 'Container: Level3: main-sub-child1',
+ 'Level3: main-sub-child2',
+ 'Level1: secondary',
+ 'Level2: secondary-sub',
+ 'Container: Level3: secondary-sub-child1',
+ 'Level3: secondary-sub-child2',
+ }, get_lines())
+
+ leaked_contexts.app:update { show_nested = false }
+
+ assert.is_true(vim.tbl_contains(unmount_calls, 'level3-main-sub-child1'))
+ assert.is_true(vim.tbl_contains(unmount_calls, 'level3-main-sub-child2'))
+ assert.is_true(vim.tbl_contains(unmount_calls, 'level2-main-sub'))
+ assert.is_true(vim.tbl_contains(unmount_calls, 'level1-main'))
+ assert.is_true(vim.tbl_contains(unmount_calls, 'level3-secondary-sub-child1'))
+ assert.is_true(vim.tbl_contains(unmount_calls, 'level3-secondary-sub-child2'))
+ assert.is_true(vim.tbl_contains(unmount_calls, 'level2-secondary-sub'))
+ assert.is_true(vim.tbl_contains(unmount_calls, 'level1-secondary'))
+
+ assert.are.same('App Root\nNo nested components', get_text())
+
+ unmount_calls = {}
+ leaked_contexts.app:update { show_nested = true }
+ assert.are.same({}, unmount_calls)
+ end)
+ end)
+ end)
+
+ describe('lifecycle transitions', function()
+ it('never has illegal transitions (mount->update->unmount only)', function()
+ with_buf({}, function()
+ local lifecycle_history = {}
+ local illegal_transitions = {}
+
+ local legal_transitions = {
+ mount = { update = true, unmount = true },
+ update = { update = true, unmount = true },
+ }
+
+ local TrackedComponent = function(ctx)
+ local id = ctx.props.id
+ lifecycle_history[id] = lifecycle_history[id] or {}
+ local history = lifecycle_history[id]
+
+ if #history > 0 then
+ local prev = history[#history]
+ local curr = ctx.phase
+ if not (legal_transitions[prev] and legal_transitions[prev][curr]) then
+ table.insert(illegal_transitions, string.format('%s: %s -> %s', id, prev, curr))
+ end
+ end
+
+ table.insert(history, ctx.phase)
+ return { id }
+ end
+
+ local leaked_ctx
+ --- @param ctx morph.Ctx<{}, { items: string[] }>
+ local function App(ctx)
+ if ctx.phase == 'mount' then
+ ctx.state = { items = { 'a', 'b' } }
+ leaked_ctx = ctx
+ end
+ return vim.tbl_map(
+ function(item) return h(TrackedComponent, { id = item, key = item }) end,
+ ctx.state.items
+ )
+ end
+
+ local r = Morph.new(0)
+ r:mount(h(App))
+
+ assert.are.same('ab', get_text())
+ assert.are.same({}, illegal_transitions)
+
+ leaked_ctx:update { items = { 'a' } }
+
+ assert.are.same('a', get_text())
+ assert.are.same({}, illegal_transitions)
+
+ local a_has_unmount = vim.tbl_contains(lifecycle_history['a'] or {}, 'unmount')
+ assert.is_false(a_has_unmount)
+
+ local b_unmounts = vim.tbl_filter(
+ function(phase) return phase == 'unmount' end,
+ lifecycle_history['b'] or {}
+ )
+ assert.are.same(1, #b_unmounts)
+ end)
+ end)
end)
end)
- it('should automatically unmount components when buffer is deleted', function()
- local unmount_calls = {}
- local mount_calls = {}
+ ------------------------------------------------------------------------------
+ -- COMPONENT CHILDREN
+ ------------------------------------------------------------------------------
- --- @param ctx morph.Ctx
- local function TestComponent(ctx)
- if ctx.phase == 'mount' then
- table.insert(mount_calls, ctx.props.name)
- elseif ctx.phase == 'unmount' then
- table.insert(unmount_calls, ctx.props.name)
- end
+ describe('component children', function()
+ it('passes children via ctx.children', function()
+ with_buf({}, function()
+ --- @param ctx morph.Ctx
+ local function Wrapper(ctx) return { '[', ctx.children, ']' } end
- return {
- h('text', { id = ctx.props.name }, 'Component ' .. ctx.props.name),
- }
- end
+ local r = Morph.new(0)
+ r:mount(h(Wrapper, {}, { 'child content' }))
+ assert.are.same('[child content]', get_text())
+ end)
+ end)
- --- @param ctx morph.Ctx<{}, { show_second: boolean }>
- local function App(ctx)
- if ctx.phase == 'mount' then
- ctx.state = { show_second = true }
- elseif ctx.phase == 'unmount' then
- table.insert(unmount_calls, 'app')
- end
+ it('handles component returning empty table', function()
+ with_buf({}, function()
+ --- @param _ctx morph.Ctx
+ local function EmptyComponent(_ctx) return {} end
- return {
- h(TestComponent, { name = 'first' }),
- '\n',
- ctx.state.show_second and h(TestComponent, { name = 'second' }) or nil,
- }
- end
+ local r = Morph.new(0)
+ r:mount { 'before', h(EmptyComponent), 'after' }
+ assert.are.same('beforeafter', get_text())
+ end)
+ end)
+
+ it('handles component returning nil', function()
+ with_buf({}, function()
+ --- @param _ctx morph.Ctx
+ local function NilComponent(_ctx) return nil end
+
+ local r = Morph.new(0)
+ r:mount { 'before', h(NilComponent), 'after' }
+ assert.are.same('beforeafter', get_text())
+ end)
+ end)
+
+ it('renders numbers in component children', function()
+ --- @param ctx morph.Ctx<{ value: number }, {}>
+ local function NumberDisplay(ctx)
+ return { h('text', { hl = 'Number' }, { 'The value is: ', ctx.props.value }) }
+ end
- -- Create a new buffer and mount components
- vim.cmd.new()
- local bufnr = vim.api.nvim_get_current_buf()
- local r = Morph.new(bufnr)
- r:mount(h(App))
-
- assert.are.same(get_lines(), { 'Component first', 'Component second' })
- -- Verify components were mounted
- assert.is_true(vim.tbl_contains(mount_calls, 'first'))
- assert.is_true(vim.tbl_contains(mount_calls, 'second'))
- assert.are.same(unmount_calls, {})
-
- -- Delete the buffer - this should trigger automatic unmounting
- vim.api.nvim_buf_delete(bufnr, { force = true })
-
- -- Verify all components were unmounted
- assert.is_true(vim.tbl_contains(unmount_calls, 'first'))
- assert.is_true(vim.tbl_contains(unmount_calls, 'second'))
- assert.is_true(vim.tbl_contains(unmount_calls, 'app'))
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:mount(h(NumberDisplay, { value = 99 }))
+ assert.are.same({ 'The value is: 99' }, get_lines())
+ end)
+ end)
end)
- it('should handle nested component unmounts triggered by state changes', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local unmount_calls = {}
- local leaked_contexts = {}
+ ------------------------------------------------------------------------------
+ -- KEYED RECONCILIATION
+ ------------------------------------------------------------------------------
+
+ describe('keyed reconciliation', function()
+ it('uses key attribute for component identity', function()
+ with_buf({}, function()
+ local mount_count = 0
+ local unmount_count = 0
+
+ --- @param ctx morph.Ctx<{ id: string }, {}>
+ local function KeyedComponent(ctx)
+ if ctx.phase == 'mount' then
+ mount_count = mount_count + 1
+ elseif ctx.phase == 'unmount' then
+ unmount_count = unmount_count + 1
+ end
+ return { ctx.props.id }
+ end
- --- @param ctx morph.Ctx<{ name: string }, {}>
- local function Level3Component(ctx)
- if ctx.phase == 'unmount' then table.insert(unmount_calls, 'level3-' .. ctx.props.name) end
+ local leaked_ctx
+ --- @param ctx morph.Ctx<{}, { items: string[] }>
+ local function App(ctx)
+ if ctx.phase == 'mount' then
+ ctx.state = { items = { 'a', 'b', 'c' } }
+ leaked_ctx = ctx
+ end
+ return vim.tbl_map(
+ function(item) return h(KeyedComponent, { id = item, key = item }) end,
+ ctx.state.items
+ )
+ end
- return {
- h('text', { id = 'level3-' .. ctx.props.name }, 'Level3: ' .. ctx.props.name),
- }
- end
+ local r = Morph.new(0)
+ r:mount(h(App))
- --- @param ctx morph.Ctx<{ name: string }, {}>
- local function Level2Component(ctx)
- if ctx.phase == 'unmount' then table.insert(unmount_calls, 'level2-' .. ctx.props.name) end
+ assert.are.same('abc', get_text())
+ assert.are.same(3, mount_count)
+ assert.are.same(0, unmount_count)
- return {
- h('text', {}, {
- 'Level2: ' .. ctx.props.name,
- '\n',
- -- Nested within tags and arrays
- {
- h('text', {}, 'Container: '),
- {
- h(Level3Component, { name = ctx.props.name .. '-child1' }),
- '\n',
- h(Level3Component, { name = ctx.props.name .. '-child2' }),
- },
- },
- }),
- }
- end
+ -- Same items - no mounts/unmounts
+ mount_count = 0
+ unmount_count = 0
+ leaked_ctx:update { items = { 'a', 'b', 'c' } }
- --- @param ctx morph.Ctx<{ name: string }, {}>
- local function Level1Component(ctx)
- if ctx.phase == 'unmount' then table.insert(unmount_calls, 'level1-' .. ctx.props.name) end
+ assert.are.same('abc', get_text())
+ assert.are.same(0, mount_count)
+ assert.are.same(0, unmount_count)
- return {
- h('text', {}, 'Level1: ' .. ctx.props.name),
- '\n',
- -- Nested within arrays and tags
- {
- h(Level2Component, { name = ctx.props.name .. '-sub' }),
- },
- }
- end
+ -- Different items - remounting occurs
+ mount_count = 0
+ unmount_count = 0
+ leaked_ctx:update { items = { 'x', 'y' } }
+
+ assert.are.same('xy', get_text())
+ assert.is_true(mount_count > 0)
+ assert.is_true(unmount_count > 0)
+ end)
+ end)
+
+ it('optimally reconciles when removing items from end', function()
+ with_buf({}, function()
+ local events = {} --- @type { id: string, phase: string }[]
- --- @param ctx morph.Ctx<{}, { show_nested: boolean }>
- local function App(ctx)
- if ctx.phase == 'mount' then
- ctx.state = { show_nested = true }
- leaked_contexts.app = ctx
- elseif ctx.phase == 'unmount' then
- table.insert(unmount_calls, 'app')
+ --- @param ctx morph.Ctx<{ id: string }, {}>
+ local function TrackedComponent(ctx)
+ table.insert(events, { id = ctx.props.id, phase = ctx.phase })
+ return { ctx.props.id }
end
- return {
- 'App Root',
- '\n',
- ctx.state.show_nested and {
- h(Level1Component, { name = 'main' }),
- '\n',
- h(Level1Component, { name = 'secondary' }),
- } or 'No nested components',
- }
- end
+ local leaked_ctx
+ --- @param ctx morph.Ctx<{}, { items: string[] }>
+ local function App(ctx)
+ if ctx.phase == 'mount' then
+ ctx.state = { items = { 'a', 'b', 'c' } }
+ leaked_ctx = ctx
+ end
+ return vim.tbl_map(
+ function(item) return h(TrackedComponent, { id = item, key = item }) end,
+ ctx.state.items
+ )
+ end
+
+ local r = Morph.new(0)
+ r:mount(h(App))
+ assert.are.same('abc', get_text())
+
+ -- Clear events from initial mount
+ events = {}
+ leaked_ctx:update { items = { 'a', 'b' } }
+
+ assert.are.same('ab', get_text())
+
+ local mounts = vim.tbl_filter(function(e) return e.phase == 'mount' end, events)
+ local unmounts = vim.tbl_filter(function(e) return e.phase == 'unmount' end, events)
- -- Mount the nested component hierarchy
- r:mount(h(App))
-
- -- Verify the rendered content includes all levels
- assert.are.same(get_lines(), {
- 'App Root',
- 'Level1: main',
- 'Level2: main-sub',
- 'Container: Level3: main-sub-child1',
- 'Level3: main-sub-child2',
- 'Level1: secondary',
- 'Level2: secondary-sub',
- 'Container: Level3: secondary-sub-child1',
- 'Level3: secondary-sub-child2',
- })
-
- -- Trigger state change to hide nested components
- leaked_contexts.app:update { show_nested = false }
-
- -- Verify all nested components were unmounted
- assert.is_true(vim.tbl_contains(unmount_calls, 'level3-main-sub-child1'))
- assert.is_true(vim.tbl_contains(unmount_calls, 'level3-main-sub-child2'))
- assert.is_true(vim.tbl_contains(unmount_calls, 'level2-main-sub'))
- assert.is_true(vim.tbl_contains(unmount_calls, 'level1-main'))
- assert.is_true(vim.tbl_contains(unmount_calls, 'level3-secondary-sub-child1'))
- assert.is_true(vim.tbl_contains(unmount_calls, 'level3-secondary-sub-child2'))
- assert.is_true(vim.tbl_contains(unmount_calls, 'level2-secondary-sub'))
- assert.is_true(vim.tbl_contains(unmount_calls, 'level1-secondary'))
-
- -- Verify the rendered content no longer includes nested components
- assert.are.same(get_text(), 'App Root\nNo nested components')
-
- -- Re-enable nested components to test remounting
- unmount_calls = {}
- leaked_contexts.app:update { show_nested = true }
-
- -- Verify no unmounts occurred during remounting
- assert.are.same(unmount_calls, {})
+ assert.are.same(0, #mounts)
+ assert.are.same(1, #unmounts)
+ assert.are.same('c', unmounts[1].id)
+ end)
end)
- end)
- it('should handle Ctx:update when component on_change is nil without errors', function()
- with_buf({}, function()
- local r = Morph.new(0)
- local leaked_context = nil
- local called = 0
-
- --- @param ctx morph.Ctx<{}, { count: integer }>
- local function TestComponent(ctx)
- called = called + 1
- if ctx.phase == 'mount' then
- ctx.state = { count = 1 }
- leaked_context = ctx
- -- Explicitly set the component's on_change to nil to trigger the bug
- ctx.on_change = nil
+ it('optimally reconciles when removing items from middle', function()
+ with_buf({}, function()
+ local events = {} --- @type { id: string, phase: string }[]
+
+ --- @param ctx morph.Ctx<{ id: string }, {}>
+ local function TrackedComponent(ctx)
+ table.insert(events, { id = ctx.props.id, phase = ctx.phase })
+ return { ctx.props.id }
end
- return {
- h('text', { id = 'test-component' }, 'Count: ' .. ctx.state.count),
- }
- end
+ local leaked_ctx
+ --- @param ctx morph.Ctx<{}, { items: string[] }>
+ local function App(ctx)
+ if ctx.phase == 'mount' then
+ ctx.state = { items = { 'a', 'b', 'c' } }
+ leaked_ctx = ctx
+ end
+ return vim.tbl_map(
+ function(item) return h(TrackedComponent, { id = item, key = item }) end,
+ ctx.state.items
+ )
+ end
+
+ local r = Morph.new(0)
+ r:mount(h(App))
+ assert.are.same('abc', get_text())
+
+ -- Clear events from initial mount
+ events = {}
+ leaked_ctx:update { items = { 'a', 'c' } }
+
+ assert.are.same('ac', get_text())
- -- Mount component
- r:mount(h(TestComponent))
+ local mounts = vim.tbl_filter(function(e) return e.phase == 'mount' end, events)
+ local unmounts = vim.tbl_filter(function(e) return e.phase == 'unmount' end, events)
- assert.are.same(get_text(), 'Count: 1')
- assert.are.same(called, 1)
+ assert.are.same(0, #mounts)
+ assert.are.same(1, #unmounts)
+ assert.are.same('b', unmounts[1].id)
+ end)
+ end)
+
+ it('treats same component with different keys as different components', function()
+ with_buf({}, function()
+ local mount_ids = {}
+ local unmount_ids = {}
+
+ --- @param ctx morph.Ctx<{ id: string }, {}>
+ local function Item(ctx)
+ if ctx.phase == 'mount' then
+ table.insert(mount_ids, ctx.props.id)
+ elseif ctx.phase == 'unmount' then
+ table.insert(unmount_ids, ctx.props.id)
+ end
+ return { ctx.props.id }
+ end
+
+ local leaked_ctx
+ --- @param ctx morph.Ctx<{}, { show_first: boolean }>
+ local function App(ctx)
+ if ctx.phase == 'mount' then ctx.state = { show_first = true } end
+ leaked_ctx = ctx
+ return {
+ ctx.state.show_first and h(Item, { id = 'first', key = 'first' })
+ or h(Item, { id = 'second', key = 'second' }),
+ }
+ end
- -- This should not error even though ctx.on_change is nil
- -- Before the fix, this would throw an error when trying to call self.on_change()
- local orig_schedule = vim.schedule
- vim.schedule = function(f) return f() end
- assert.has_no.errors(function() leaked_context:update { count = 2 } end)
- vim.schedule = orig_schedule
+ local r = Morph.new(0)
+ r:mount(h(App))
+
+ assert.are.same('first', get_text())
+ assert.are.same({ 'first' }, mount_ids)
+
+ leaked_ctx:update { show_first = false }
+
+ assert.are.same('second', get_text())
+ assert.are.same({ 'first' }, unmount_ids)
+ assert.are.same({ 'first', 'second' }, mount_ids)
+ end)
end)
end)
- it('should cleanup bindings between renders', function()
- with_buf({}, function()
- local my_orig_callback = function() end
- local my_component_callback_count = 0
- local my_component_callback = function()
- my_component_callback_count = my_component_callback_count + 1
- end
- vim.keymap.set('n', 'abc', my_orig_callback, { buffer = true })
+ ------------------------------------------------------------------------------
+ -- TYPE TRANSITIONS IN RECONCILIATION
+ ------------------------------------------------------------------------------
+
+ describe('type transitions in reconciliation', function()
+ it('handles transitioning from string to array', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local leaked_ctx
+
+ --- @param ctx morph.Ctx<{show_array: boolean}>
+ local function Root(ctx)
+ if ctx.phase == 'mount' then
+ leaked_ctx = ctx
+ ctx.state = { show_array = false }
+ end
+ --- @diagnostic disable-next-line: unnecessary-if
+ if ctx.state.show_array then
+ return { 'item1', ' ', 'item2' }
+ else
+ return 'single string'
+ end
+ end
- local leaked_context
- local function TestComponent(ctx)
- leaked_context = ctx
- if ctx.phase == 'mount' then ctx.state = 1 end
+ r:mount { h(Root) }
+ assert.are.same('single string', get_text())
- if ctx.state == 1 then
- -- On first render, set a local callback:
- return h('text', {
- nmap = {
- ['abc'] = my_component_callback,
- },
- }, { 'Hello World!' })
- else
- -- On second render, do NOT set a local callback:
- return h('text', {}, { 'Hello World (II)!' })
+ leaked_ctx:update { show_array = true }
+ assert.are.same('item1 item2', get_text())
+ end)
+ end)
+
+ it('handles transitioning from array to string', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local leaked_ctx
+
+ --- @param ctx morph.Ctx<{show_array: boolean}>
+ local function Root(ctx)
+ if ctx.phase == 'mount' then
+ leaked_ctx = ctx
+ ctx.state = { show_array = true }
+ end
+ --- @diagnostic disable-next-line: unnecessary-if
+ if ctx.state.show_array then
+ return { 'item1', ' ', 'item2' }
+ else
+ return 'single string'
+ end
end
- end
- local r = Morph.new(0)
- local result = r:_expr_map_callback('n', 'abc')
- assert.are.same(result, 'abc')
- assert.are.same(my_component_callback_count, 0)
+ r:mount { h(Root) }
+ assert.are.same('item1 item2', get_text())
+
+ leaked_ctx:update { show_array = false }
+ assert.are.same('single string', get_text())
+ end)
+ end)
+
+ it('unmounts component when transitioning from component to array', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local leaked_ctx
+ local child_unmounted = false
- r:mount(h(TestComponent))
- assert.are.same(get_text(), 'Hello World!')
- vim.print(tostring(my_orig_callback))
- assert.are_not.same(vim.fn.maparg('abc', 'n', false, true).callback, my_orig_callback)
+ --- @param ctx morph.Ctx
+ local function Child(ctx)
+ if ctx.phase == 'unmount' then child_unmounted = true end
+ return 'child component'
+ end
- result = r:_expr_map_callback('n', 'abc')
- assert.are.same(result, nil)
- assert.are.same(my_component_callback_count, 1)
+ --- @param ctx morph.Ctx<{}, {show_array: boolean}>
+ local function Root(ctx)
+ if ctx.phase == 'mount' then
+ leaked_ctx = ctx
+ ctx.state = { show_array = false }
+ end
+ --- @diagnostic disable-next-line: unnecessary-if
+ if ctx.state.show_array then
+ return { 'item1', ' ', 'item2' }
+ else
+ return h(Child)
+ end
+ end
- -- Now update the state (this effectively removes the local keymap):
- leaked_context:update(2)
- assert.are.same(get_text(), 'Hello World (II)!')
+ r:mount { h(Root) }
+ assert.are.same('child component', get_text())
+ assert.is_false(child_unmounted)
- result = r:_expr_map_callback('n', 'abc')
- assert.are.same(result, 'abc')
- assert.are.same(vim.fn.maparg('abc', 'n', false, true).callback, my_orig_callback)
+ leaked_ctx:update { show_array = true }
+ assert.are.same('item1 item2', get_text())
+ assert.is_true(child_unmounted)
+ end)
end)
- end)
- --
- -- Mode restoration during rendering (tests is_textlock mode restoration)
- --
+ it('unmounts old tree BEFORE reconciling new array', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ local leaked_ctx
+ local events = {}
+
+ --- @param ctx morph.Ctx
+ local function Child(ctx)
+ if ctx.phase == 'mount' then
+ table.insert(events, 'child:mount')
+ elseif ctx.phase == 'unmount' then
+ table.insert(events, 'child:unmount')
+ end
+ return 'child'
+ end
+
+ --- @param ctx morph.Ctx
+ local function ArrayItem(ctx)
+ if ctx.phase == 'mount' then table.insert(events, 'array-item:mount') end
+ return 'array item'
+ end
- it('should preserve normal mode during rendering when textlock check occurs', function()
- with_buf({}, function()
- -- Start in normal mode
- vim.cmd.stopinsert()
- local initial_mode = vim.api.nvim_get_mode().mode
- assert.are.same(initial_mode:sub(1, 1), 'n')
+ --- @param ctx morph.Ctx<{}, {show_array: boolean}>
+ local function Root(ctx)
+ if ctx.phase == 'mount' then
+ leaked_ctx = ctx
+ ctx.state = { show_array = false }
+ end
+ --- @diagnostic disable-next-line: unnecessary-if
+ if ctx.state.show_array then
+ return { h(ArrayItem) }
+ else
+ return h(Child)
+ end
+ end
- -- Trigger a render which will call is_textlock internally
- local r = Morph.new(0)
- r:render { h('text', {}, 'normal mode test') }
+ r:mount { h(Root) }
+ assert.are.same({ 'child:mount' }, events)
- -- Verify mode is still normal after rendering
- local final_mode = vim.api.nvim_get_mode().mode
- assert.are.same(final_mode:sub(1, 1), 'n')
- assert.are.same(get_text(), 'normal mode test')
+ leaked_ctx:update { show_array = true }
+ assert.are.same({ 'child:mount', 'child:unmount', 'array-item:mount' }, events)
+ end)
end)
end)
- it('should preserve visual mode during rendering when textlock check occurs', function()
- with_buf({ 'visual mode test' }, function()
- -- Render first so we have content
- local r = Morph.new(0)
- r:render { h('text', {}, 'visual mode test content') }
+ ------------------------------------------------------------------------------
+ -- MARKUP UTILITIES
+ ------------------------------------------------------------------------------
- -- Enter visual mode
- set_cursor { 1, 0 }
- vim.cmd.normal { args = { 'v' }, bang = true }
- local initial_mode = vim.api.nvim_get_mode().mode
- assert.are.same(initial_mode:sub(1, 1), 'v')
+ describe('markup utilities', function()
+ it('converts tree to string via markup_to_string', function()
+ local result = Morph.markup_to_string {
+ tree = {
+ 'line 1\n',
+ h('text', { hl = 'Comment' }, 'styled'),
+ '\nline 3',
+ },
+ }
+ assert.are.same('line 1\nstyled\nline 3', result)
+ end)
- -- Trigger another render which will call is_textlock internally
- r:render { h('text', {}, 'updated visual test') }
+ it('detects external buffer changes and resyncs', function()
+ with_buf({}, function()
+ local r = Morph.new(0)
+ r:render { 'initial content' }
+ assert.are.same('initial content', get_text())
- -- Verify mode is restored to visual after rendering
- local final_mode = vim.api.nvim_get_mode().mode
- assert.are.same(final_mode:sub(1, 1), 'v')
- assert.are.same(get_text(), 'updated visual test')
+ vim.api.nvim_buf_set_lines(0, 0, -1, false, { 'external change' })
- -- Exit visual mode for cleanup
- vim.cmd.normal { args = { '' }, bang = true }
+ r:render { 'new morph content' }
+ assert.are.same('new morph content', get_text())
+ end)
end)
end)
- it('should preserve visual line mode during rendering when textlock check occurs', function()
- with_buf({ 'line 1', 'line 2' }, function()
- -- Render first so we have content
- local r = Morph.new(0)
- r:render { h('text', {}, 'line 1\nline 2') }
+ ------------------------------------------------------------------------------
+ -- MODE PRESERVATION
+ --
+ -- Tests that rendering doesn't disrupt the current Vim mode.
+ ------------------------------------------------------------------------------
- -- Enter visual line mode
- set_cursor { 1, 0 }
- vim.cmd.normal { args = { 'V' }, bang = true }
- local initial_mode = vim.api.nvim_get_mode().mode
- assert.are.same(initial_mode:sub(1, 1), 'V')
+ describe('mode preservation during rendering', function()
+ local function test_mode_preservation(mode_char, enter_mode_fn, exit_mode_fn)
+ with_buf({ 'test content' }, function()
+ local r = Morph.new(0)
+ r:render { h('text', {}, 'initial content') }
- -- Trigger another render which will call is_textlock internally
- r:render { h('text', {}, 'updated line 1\nupdated line 2') }
+ set_cursor { 1, 0 }
+ enter_mode_fn()
- -- Verify mode is restored to visual line after rendering
- local final_mode = vim.api.nvim_get_mode().mode
- assert.are.same(final_mode:sub(1, 1), 'V')
- assert.are.same(get_text(), 'updated line 1\nupdated line 2')
+ local initial_mode = vim.api.nvim_get_mode().mode:sub(1, 1)
+ assert.are.same(mode_char, initial_mode)
- -- Exit visual mode for cleanup
- vim.cmd.normal { args = { '' }, bang = true }
+ r:render { h('text', {}, 'updated content') }
+
+ local final_mode = vim.api.nvim_get_mode().mode:sub(1, 1)
+ assert.are.same(mode_char, final_mode)
+
+ exit_mode_fn()
+ end)
+ end
+
+ it('preserves normal mode', function()
+ test_mode_preservation('n', vim.cmd.stopinsert, function() end)
end)
- end)
- it(
- 'should preserve visual mode in focused split during render in unfocused morph buffer',
- function()
- -- Create first buffer with morph content
+ it('preserves visual mode', function()
+ test_mode_preservation(
+ 'v',
+ function() vim.cmd.normal { args = { 'v' }, bang = true } end,
+ function() vim.cmd.normal { args = { '' }, bang = true } end
+ )
+ end)
+
+ it('preserves visual line mode', function()
+ with_buf({ 'line 1', 'line 2' }, function()
+ local r = Morph.new(0)
+ r:render { h('text', {}, 'line 1\nline 2') }
+
+ set_cursor { 1, 0 }
+ vim.cmd.normal { args = { 'V' }, bang = true }
+ local initial_mode = vim.api.nvim_get_mode().mode:sub(1, 1)
+ assert.are.same('V', initial_mode)
+
+ r:render { h('text', {}, 'updated line 1\nupdated line 2') }
+
+ local final_mode = vim.api.nvim_get_mode().mode:sub(1, 1)
+ assert.are.same('V', final_mode)
+
+ vim.cmd.normal { args = { '' }, bang = true }
+ end)
+ end)
+
+ it('preserves visual mode in focused split during render in unfocused buffer', function()
vim.cmd.new()
local morph_buf = vim.api.nvim_get_current_buf()
vim.api.nvim_buf_set_lines(morph_buf, 0, -1, false, { '' })
local r = Morph.new(morph_buf)
r:render { h('text', {}, 'morph buffer content') }
- -- Create a second split with different content
vim.cmd.vsplit()
local focus_buf = vim.api.nvim_get_current_buf()
local focus_win = vim.api.nvim_get_current_win()
vim.api.nvim_buf_set_lines(focus_buf, 0, -1, false, { 'line 1', 'line 2', 'line 3' })
- -- Enter visual mode in the focused split
set_cursor { 1, 0 }
vim.cmd.normal { args = { 'v' }, bang = true }
- local initial_mode = vim.api.nvim_get_mode().mode
- assert.are.same(initial_mode:sub(1, 1), 'v')
+ local initial_mode = vim.api.nvim_get_mode().mode:sub(1, 1)
+ assert.are.same('v', initial_mode)
- -- Verify we're in the focus window, not the morph window
- assert.are.same(vim.api.nvim_get_current_win(), focus_win)
- assert.are.same(vim.api.nvim_get_current_buf(), focus_buf)
+ assert.are.same(focus_win, vim.api.nvim_get_current_win())
+ assert.are.same(focus_buf, vim.api.nvim_get_current_buf())
- -- Trigger a render in the unfocused morph buffer
- -- This will call is_textlock which should preserve the visual mode in focus_win
r:render { h('text', {}, 'updated morph content') }
- -- Verify visual mode is still active in the focused split
- local final_mode = vim.api.nvim_get_mode().mode
- assert.are.same(final_mode:sub(1, 1), 'v')
+ local final_mode = vim.api.nvim_get_mode().mode:sub(1, 1)
+ assert.are.same('v', final_mode)
- -- Verify we're still in the same window
- assert.are.same(vim.api.nvim_get_current_win(), focus_win)
- assert.are.same(vim.api.nvim_get_current_buf(), focus_buf)
+ assert.are.same(focus_win, vim.api.nvim_get_current_win())
+ assert.are.same(focus_buf, vim.api.nvim_get_current_buf())
- -- Verify the morph buffer was updated correctly
local morph_lines = vim.api.nvim_buf_get_lines(morph_buf, 0, -1, false)
- assert.are.same(morph_lines, { 'updated morph content' })
+ assert.are.same({ 'updated morph content' }, morph_lines)
- -- Cleanup: exit visual mode and close all windows
vim.cmd.normal { args = { '' }, bang = true }
- -- Close all windows and buffers created in this test
pcall(vim.api.nvim_win_close, focus_win, true)
pcall(vim.api.nvim_buf_delete, morph_buf, { force = true })
pcall(vim.api.nvim_buf_delete, focus_buf, { force = true })
- end
- )
+ end)
+ end)
+
+ ------------------------------------------------------------------------------
+ -- POS00 COMPARISONS
+ ------------------------------------------------------------------------------
+
+ describe('Pos00 comparisons', function()
+ it('compares with __lt correctly', function()
+ assert.is_true(Pos00.new(0, 0) < Pos00.new(0, 1))
+ assert.is_true(Pos00.new(0, 0) < Pos00.new(1, 0))
+ assert.is_true(Pos00.new(0, 5) < Pos00.new(1, 0))
+ assert.is_false(Pos00.new(1, 0) < Pos00.new(0, 5))
+ assert.is_false(Pos00.new(0, 0) < Pos00.new(0, 0))
+ end)
+
+ it('compares with __gt correctly', function()
+ assert.is_true(Pos00.new(0, 1) > Pos00.new(0, 0))
+ assert.is_true(Pos00.new(1, 0) > Pos00.new(0, 0))
+ assert.is_true(Pos00.new(1, 0) > Pos00.new(0, 5))
+ assert.is_false(Pos00.new(0, 5) > Pos00.new(1, 0))
+ assert.is_false(Pos00.new(0, 0) > Pos00.new(0, 0))
+ end)
+
+ it('compares with __eq correctly', function()
+ assert.is_true(Pos00.new(0, 0) == Pos00.new(0, 0))
+ assert.is_true(Pos00.new(5, 10) == Pos00.new(5, 10))
+ assert.is_false(Pos00.new(0, 0) == Pos00.new(0, 1))
+ assert.is_false(Pos00.new(0, 0) == Pos00.new(1, 0))
+ end)
+ end)
+
+ ------------------------------------------------------------------------------
+ -- IS_TEXTLOCK
+ ------------------------------------------------------------------------------
+
+ describe('is_textlock', function()
+ local is_textlock = Morph._is_textlock
+
+ it('returns false when not in textlock', function()
+ with_buf({}, function() assert.is_false(is_textlock()) end)
+ end)
+
+ it('returns true during expression mapping', function()
+ local result = nil
+ vim.keymap.set('n', '', function()
+ result = is_textlock()
+ return ''
+ end, { expr = true })
+
+ vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'x', false)
+
+ vim.keymap.del('n', '')
+ assert.is_true(result)
+ end)
+
+ it('returns true during foldexpr evaluation', function()
+ vim.cmd.new()
+ local result = nil
+ --- @diagnostic disable-next-line: global-in-non-module
+ _G._test_foldexpr_textlock = function()
+ result = is_textlock()
+ return '0'
+ end
+
+ vim.api.nvim_buf_set_lines(0, 0, -1, false, { 'line1', 'line2' })
+ vim.wo.foldmethod = 'expr'
+ vim.wo.foldexpr = 'v:lua._test_foldexpr_textlock()'
+ vim.cmd.normal { args = { 'zx' }, bang = true }
+
+ _G._test_foldexpr_textlock = nil
+ vim.cmd.bdelete { bang = true }
+
+ assert.is_true(result)
+ end)
+
+ it('returns true during vim.in_fast_event (luv callback)', function()
+ local result = nil
+ local timer = vim.uv.new_timer()
+ timer:start(0, 0, function()
+ result = is_textlock()
+ timer:close()
+ end)
+
+ vim.wait(100, function() return result ~= nil end)
+
+ assert.is_true(result)
+ end)
+
+ it('preserves window after check', function()
+ with_buf({}, function()
+ local win_before = vim.api.nvim_get_current_win()
+ is_textlock()
+ local win_after = vim.api.nvim_get_current_win()
+ assert.are.same(win_before, win_after)
+ end)
+ end)
+ end)
end)