Project 2: Build a Neovim Focus Mode Plugin (Lua)

Build a real Neovim plugin that dims inactive windows, highlights the current paragraph, and displays a floating status widget.

Quick Reference

Attribute Value
Difficulty Intermediate
Time Estimate 3-5 days
Main Programming Language Lua (Neovim native)
Alternative Programming Languages None (Lua is required for native API)
Coolness Level Level 7 - Real UI plugin with polish
Business Potential Medium-High (plugin ecosystem)
Prerequisites Basic Lua, Neovim usage, event-driven thinking
Key Topics vim.api, autocommands, extmarks, highlights, floats

1. Learning Objectives

By completing this project, you will:

  1. Structure a Neovim plugin with a clean module API.
  2. Use vim.api to create namespaces, extmarks, and floating windows.
  3. Register and manage autocommands without memory leaks.
  4. Render dynamic UI updates without introducing lag.
  5. Implement configuration and state management for a plugin.
  6. Debug plugin behavior using Neovim’s built-in tools.

2. All Theory Needed (Per-Concept Breakdown)

2.1 Neovim Lua API (vim.api) Basics

Description

The Lua API is the stable way to manipulate buffers, windows, and editor state from plugins.

Definitions & Key Terms
  • API handle: Integer ID for buffers, windows, or namespaces.
  • nvim_* function: A stable API function exposed to Lua.
  • vim.api: Lua module wrapping the Neovim API.
Mental Model Diagram (ASCII)
[Lua plugin] -> vim.api -> Neovim core
How It Works (Step-by-Step)
  1. Use vim.api.nvim_create_namespace to create an isolated space.
  2. Use nvim_buf_set_extmark to attach highlights to text.
  3. Use nvim_open_win to create floating windows.
  4. Use nvim_create_user_command for user-facing commands.
Minimal Concrete Example
local ns = vim.api.nvim_create_namespace("focus")
vim.api.nvim_buf_set_extmark(0, ns, 10, 0, { end_row = 12, hl_group = "FocusParagraph" })
Common Misconceptions
  • “vim.fn is the same as vim.api” -> vim.fn wraps Vimscript, not core API.
  • “API handles are stable forever” -> Handles can become invalid when buffers close.
Check-Your-Understanding Questions
  1. Explain why extmarks need a namespace.
  2. Predict what happens if you call nvim_open_win with an invalid buffer.
  3. Explain why vim.api is preferred over vim.fn for new plugins.
Where You’ll Apply It

2.2 Autocommands and Event-Driven Updates

Description

Autocommands let your plugin react to editor events such as cursor movement and window focus changes.

Definitions & Key Terms
  • Autocommand: A callback triggered by an event.
  • Augroup: Named group for managing autocommands.
  • Event: CursorMoved, WinEnter, BufLeave, etc.
Mental Model Diagram (ASCII)
[Editor event] -> [Autocmd] -> [Plugin handler] -> [UI update]
How It Works (Step-by-Step)
  1. Create an augroup to own your plugin autocommands.
  2. Register handlers for specific events.
  3. In the handler, update highlights and floating window.
  4. Clear autocommands when disabling the plugin.
Minimal Concrete Example
local group = vim.api.nvim_create_augroup("FocusMode", { clear = true })
vim.api.nvim_create_autocmd({"CursorMoved", "WinEnter"}, {
  group = group,
  callback = function() require("focus").refresh() end,
})
Common Misconceptions
  • “Autocommands are free” -> Too many can cause lag.
  • “All events are equal” -> Some fire very frequently.
Check-Your-Understanding Questions
  1. Explain why CursorMoved needs debouncing.
  2. Predict what happens if you forget to clear the augroup.
  3. Explain when you would use BufEnter instead of WinEnter.
Where You’ll Apply It

2.3 Extmarks and Namespaces

Description

Extmarks are persistent markers in a buffer that move as text changes, perfect for highlights.

Definitions & Key Terms
  • Extmark: A marker that tracks text edits.
  • Namespace: Isolation for extmarks and highlights.
  • Virtual text: Additional text displayed without modifying the buffer.
Mental Model Diagram (ASCII)
Buffer text
  [line 10] ---- extmark ---- [line 12]
Namespace controls ownership

Extmark across lines

How It Works (Step-by-Step)
  1. Create a namespace for your plugin.
  2. Clear old extmarks before setting new ones.
  3. Set an extmark that spans the current paragraph.
  4. Use highlight groups to style the paragraph.
Minimal Concrete Example
vim.api.nvim_buf_clear_namespace(0, ns, 0, -1)
vim.api.nvim_buf_set_extmark(0, ns, start_row, 0, {
  end_row = end_row,
  hl_group = "FocusParagraph",
})
Common Misconceptions
  • “Extmarks are just for highlights” -> They can anchor virtual text too.
  • “Clearing all namespaces is fine” -> It can break other plugins.
Check-Your-Understanding Questions
  1. Explain why namespaces prevent conflicts.
  2. Predict what happens if you set overlapping extmarks.
  3. Explain how extmarks survive edits.
Where You’ll Apply It

2.4 Highlight Groups and Window-Local UI

Description

Highlight groups define styles, and window-local options allow dimming inactive windows.

Definitions & Key Terms
  • Highlight group: Named style (fg, bg, bold, etc).
  • NormalNC: Highlight for inactive windows.
  • Window-local option: Setting that applies per window.
Mental Model Diagram (ASCII)
Window A (active) -> Normal
Window B (inactive) -> NormalNC (dimmed)
How It Works (Step-by-Step)
  1. Define custom highlight groups for focus mode.
  2. Override NormalNC to dim inactive windows.
  3. Apply extmark highlights for the current paragraph.
Minimal Concrete Example
vim.api.nvim_set_hl(0, "FocusDim", { fg = "#666666" })
vim.api.nvim_set_hl(0, "NormalNC", { link = "FocusDim" })
Common Misconceptions
  • “Changing NormalNC is global” -> It applies to all windows unless restored.
  • “Highlights are per-buffer” -> They are global by default.
Check-Your-Understanding Questions
  1. Explain why you should restore highlights on disable.
  2. Predict what happens if two plugins both change NormalNC.
  3. Explain how to scope highlights to a namespace.
Where You’ll Apply It

2.5 Floating Windows and Buffer Lifetimes

Description

Floating windows are independent UI elements anchored to the editor grid.

Definitions & Key Terms
  • Floating window: A window with relative='editor' or relative='win'.
  • Scratch buffer: A temporary buffer not backed by a file.
  • Window config: Position, size, and style options.
Mental Model Diagram (ASCII)
+---------------------------+
| editor grid               |
|                    +----+ |
|                    |HUD | |
|                    +----+ |
+---------------------------+

HUD overlay in editor grid

How It Works (Step-by-Step)
  1. Create a scratch buffer for the status widget.
  2. Open a floating window with fixed row/col.
  3. Update buffer content as session data changes.
  4. Reposition on VimResized.
Minimal Concrete Example
local buf = vim.api.nvim_create_buf(false, true)
local win = vim.api.nvim_open_win(buf, false, {
  relative = "editor",
  width = 20,
  height = 2,
  row = 1,
  col = 1,
  style = "minimal",
  border = "single",
})
Common Misconceptions
  • “Floating windows are permanent” -> They disappear if buffer is wiped.
  • “You can reuse a closed window handle” -> You must recreate it.
Check-Your-Understanding Questions
  1. Explain why a scratch buffer should be listed=false.
  2. Predict what happens if you open a float with width=0.
  3. Explain how to update float content without flicker.
Where You’ll Apply It

2.6 Debouncing, Scheduling, and Performance

Description

Frequent events like cursor moves require debouncing to avoid UI lag.

Definitions & Key Terms
  • Debounce: Delay action until events stop for a time window.
  • Throttle: Limit execution to at most once per interval.
  • vim.defer_fn: Schedule a Lua function after a delay.
Mental Model Diagram (ASCII)
CursorMoved events: | | | | | | | |
Debounced update:             [update]

Debounced CursorMoved timeline

How It Works (Step-by-Step)
  1. Record the last update time or pending timer.
  2. Cancel any existing timer when a new event arrives.
  3. Schedule a new update after a small delay (e.g., 50ms).
  4. Perform UI updates only in scheduled callbacks.
Minimal Concrete Example
local timer
local function debounce(fn, delay)
  return function()
    if timer then timer:stop() end
    timer = vim.defer_fn(fn, delay)
  end
end
Common Misconceptions
  • “Debouncing makes UI sluggish” -> Use small delays (20-50ms).
  • “Lua timers run in parallel” -> They run on the main loop.
Check-Your-Understanding Questions
  1. Explain why debouncing reduces lag on CursorMoved.
  2. Predict what happens if you update extmarks on every keystroke.
  3. Explain the difference between throttle and debounce in this plugin.
Where You’ll Apply It

2.7 Plugin State and Configuration

Description

A good plugin exposes configuration while keeping internal state consistent.

Definitions & Key Terms
  • Module table: Lua table that holds state and functions.
  • Setup function: Entry point that merges defaults and user options.
  • Toggle: Command to enable/disable behavior.
Mental Model Diagram (ASCII)
User config -> setup() -> merged options -> state
state + events -> UI updates
How It Works (Step-by-Step)
  1. Define a defaults table.
  2. Merge user config with defaults in setup.
  3. Store state in a module-local table.
  4. On toggle, enable/disable autocommands and UI elements.
Minimal Concrete Example
local M = { enabled = false, opts = { dim = true } }
function M.setup(opts) M.opts = vim.tbl_extend("force", M.opts, opts or {}) end
Common Misconceptions
  • “Globals are fine” -> They create conflicts and are hard to test.
  • “Config can be changed anywhere” -> Keep a single source of truth.
Check-Your-Understanding Questions
  1. Explain why setup should be idempotent.
  2. Predict what happens if two plugins share a global variable.
  3. Explain how you would allow runtime config changes.
Where You’ll Apply It

3. Project Specification

3.1 What You Will Build

A Neovim plugin named focus that:

  • Dims inactive windows
  • Highlights the current paragraph
  • Shows a floating status widget (timer + WPM)

Included: toggle command, autocommands, extmarks, float. Excluded: external dependencies, complex UI dashboards.

3.2 Functional Requirements

  1. Toggle command: :FocusMode enables/disables the plugin.
  2. Window dimming: Inactive windows use a dim highlight.
  3. Paragraph highlight: Current paragraph is highlighted with extmarks.
  4. Floating widget: Shows elapsed time and current WPM.
  5. Config: setup({ dim = true, wpm = true }) overrides defaults.
  6. Debounce: Cursor-driven updates are debounced.
  7. Cleanup: All highlights and floats are removed on disable.

3.3 Non-Functional Requirements

  • Performance: No noticeable lag on CursorMoved.
  • Reliability: No crashes when buffers close.
  • Usability: Clear on/off messages and safe defaults.

3.4 Example Usage / Output

-- init.lua
require("focus").setup({ dim = true, wpm = true })
vim.cmd("FocusMode")

3.5 Data Formats / Schemas / Protocols

  • Config table:
    {
      dim = true,
      highlight_group = "FocusParagraph",
      wpm = true,
      debounce_ms = 50,
      float = { row = 1, col = 1, width = 20, height = 2 }
    }
    
  • Autocommands: CursorMoved, WinEnter, WinLeave, VimResized.
  • Namespace: focus used for all extmarks.

3.6 Edge Cases

  • No file open (empty buffer).
  • Multiple splits and tab pages.
  • Very large file with rapid cursor movement.
  • Window resized smaller than the float size.

3.7 Real World Outcome

You install the plugin and run :FocusMode.

3.7.1 How to Run (Copy/Paste)

mkdir -p ~/.config/nvim/lua/focus
cp -r focus/* ~/.config/nvim/lua/focus/

In Neovim:

:lua require("focus").setup({ dim = true })
:FocusMode

3.7.2 Golden Path Demo (Deterministic)

  • Open a fixed file fixtures/notes.md with 3 paragraphs.
  • Run :FocusMode.
  • Move cursor to paragraph 2.

Expected:

  • Paragraph 2 is highlighted with FocusParagraph.
  • Inactive windows use the dimmed NormalNC style.
  • Float shows Focus: 00:05 and WPM: 42 (with FOCUS_TEST_MODE=1).

3.7.3 Failure Demo

  • Disable the namespace or clear it manually.
  • Run :FocusMode.

Expected:

  • Notification: FocusMode: namespace not created.
  • Plugin disables itself and cleans up.

3.7.8 TUI Layout (Inside Neovim)

+--------------------------------------------------+
| notes.md                                         |
| Paragraph 1 (dimmed if inactive)                 |
|                                                  |
| [Paragraph 2 highlighted]                         |
|                                                  |
|             +------------------+                 |
|             | Focus 00:05      |                 |
|             | WPM 42           |                 |
|             +------------------+                 |
+--------------------------------------------------+

4. Solution Architecture

4.1 High-Level Design

[Events] -> [Autocmd handlers] -> [State update]
                                     |-> [Extmarks]
                                     |-> [Highlight groups]
                                     |-> [Floating window]

4.2 Key Components

Component Responsibility Key Decisions
Config Defaults + user overrides Merge strategy
State Enabled flag, timers, handles Module-local table
Autocommands Event hooks Debounce policy
Extmark Engine Paragraph highlight Namespace usage
Float HUD Status widget Position strategy

4.3 Data Structures (No Full Code)

local M = {
  enabled = false,
  ns = nil,
  float = { buf = nil, win = nil },
  opts = {},
  timers = { wpm = nil, debounce = nil },
}

4.4 Algorithm Overview

Key Algorithm: Paragraph Highlight

  1. On cursor move, find paragraph start/end by scanning blank lines.
  2. Clear previous extmarks in the namespace.
  3. Set an extmark spanning the paragraph.

Complexity Analysis:

  • Time: O(L) where L is lines scanned around cursor
  • Space: O(1) additional

5. Implementation Guide

5.1 Development Environment Setup

nvim --version

5.2 Project Structure

focus/
├── init.lua
├── config.lua
├── state.lua
├── ui.lua
└── events.lua

5.3 The Core Question You’re Answering

“How do I hook into Neovim’s event loop and update UI safely without rewriting the core?”

5.4 Concepts You Must Understand First

Stop and research these before coding:

  1. Autocommands and events
    • Which events are too frequent?
    • Neovim help: :help autocmd
  2. Extmarks
    • How they track edits
    • Neovim help: :help extmarks
  3. Floating windows
    • nvim_open_win and buffer lifetime
    • Neovim help: :help nvim_open_win

5.5 Questions to Guide Your Design

  1. What events are minimal to keep the UI correct?
  2. How will you avoid clearing extmarks too often?
  3. What should happen if the float goes off-screen?

5.6 Thinking Exercise

Manually list the sequence of events when you:

  1. Open a file.
  2. Split the window.
  3. Move the cursor quickly.

Which events should update paragraph highlight? Which should update the float?

5.7 The Interview Questions They’ll Ask

  1. What is the difference between vim.api and vim.fn?
  2. Why can autocommands create lag?
  3. How do extmarks stay attached during edits?

5.8 Hints in Layers

Hint 1: Start with a toggle Implement :FocusMode first and log a message.

Hint 2: Add dimming Override NormalNC when enabled, restore when disabled.

Hint 3: Add paragraph highlight Use extmarks with hl_group spanning a line range.

Hint 4: Add floating HUD Start with a static float, then update its contents.

5.9 Books That Will Help

Topic Book Chapter
Lua language Programming in Lua Ch. 1-4
Vim workflow Practical Vim Ch. 12
UI design Refactoring UI Ch. 4

5.10 Implementation Phases

Phase 1: Skeleton (1 day)

Goals:

  • Basic module structure
  • Toggle command works

Tasks:

  1. Create init.lua and setup.
  2. Register :FocusMode command.
  3. Store enabled state.

Checkpoint: Command toggles state and logs messages.

Phase 2: UI Features (2-3 days)

Goals:

  • Dimming + paragraph highlight + float

Tasks:

  1. Create namespace and highlight groups.
  2. Implement paragraph highlight via extmarks.
  3. Add float and update WPM.

Checkpoint: Visual effects work without errors.

Phase 3: Performance and Polish (1-2 days)

Goals:

  • Debouncing and cleanup

Tasks:

  1. Debounce CursorMoved updates.
  2. Handle window resize.
  3. Restore highlights on disable.

Checkpoint: No flicker and no leftover UI artifacts.

5.11 Key Implementation Decisions

Decision Options Recommendation Rationale
Update frequency every event, debounced debounced smoother UI
Highlight strategy line highlights, extmarks extmarks survives edits
Float placement fixed corner, cursor relative fixed corner stable and simple

6. Testing Strategy

6.1 Test Categories

Category Purpose Examples
Unit Tests Config/state helpers merge config
Integration Tests Autocmd flow CursorMoved updates
Visual Tests UI correctness manual screenshots

6.2 Critical Test Cases

  1. Toggle on/off 10 times without leaks.
  2. Move cursor rapidly in a 10k-line file.
  3. Resize window smaller than float size.

6.3 Test Data

fixtures/
  notes.md (3 paragraphs)
  large.txt (10k lines)

7. Common Pitfalls & Debugging

7.1 Frequent Mistakes

Pitfall Symptom Solution
Unscoped highlights Other plugins break Use namespaces
Too many redraws Lag on CursorMoved Debounce updates
Floating window closed HUD disappears Recreate on demand

7.2 Debugging Strategies

  • Use :messages and vim.notify for logging.
  • Add a debug flag to print state to the command line.
  • Use :Inspect to verify highlight groups.

7.3 Performance Traps

  • Doing heavy work on every keystroke without debounce.
  • Clearing and re-adding highlights for the entire buffer.

8. Extensions & Challenges

8.1 Beginner Extensions

  • Add a :FocusStats command to show session stats.
  • Add per-filetype enable/disable rules.

8.2 Intermediate Extensions

  • Add a distraction-free mode that hides line numbers.
  • Persist focus sessions across restarts.

8.3 Advanced Extensions

  • Add focus “zones” and break reminders.
  • Export focus metrics to a JSON file.

9. Real-World Connections

9.1 Industry Applications

  • Editor tooling: Many Neovim plugins use the same API surface.
  • Productivity tools: Focus-mode features are common in IDEs.
  • folke/zen-mode.nvim: Focus UI patterns.
  • lukas-reineke/indent-blankline.nvim: Heavy extmark usage.

9.3 Interview Relevance

  • Event-driven UI: Autocommands and debouncing.
  • Editor APIs: Managing state and rendering safely.

10. Resources

10.1 Essential Reading

  • Neovim :help lua-guide
  • Neovim :help api

10.2 Video Resources

  • Neovim Lua plugin basics (search: “neovim lua plugin tutorial”)

10.3 Tools & Documentation

  • :help extmarks
  • :help nvim_open_win
  • :help autocmd

11. Self-Assessment Checklist

11.1 Understanding

  • I can explain how extmarks track edits.
  • I can explain why autocommands can cause lag.
  • I can describe how floating windows are positioned.

11.2 Implementation

  • :FocusMode toggles cleanly.
  • Highlights and floats are cleaned up on disable.
  • No visible lag on CursorMoved.

11.3 Growth

  • I can add a new UI feature using the same API.
  • I can explain this plugin in an interview.

12. Submission / Completion Criteria

Minimum Viable Completion:

  • Plugin loads and toggles.
  • Inactive windows are dimmed.
  • Current paragraph is highlighted.

Full Completion:

  • All minimum criteria plus:
  • Floating widget shows session time and WPM.
  • Debounced updates prevent lag.

Excellence (Going Above & Beyond):

  • Configurable focus rules per filetype.
  • Persistent focus sessions across restarts.

13. Determinism and Reproducibility Notes

  • Use FOCUS_TEST_MODE=1 to freeze the timer and WPM for screenshots.
  • Keep a fixed fixtures/notes.md file for the golden demo.
  • Failure demo uses a missing namespace to produce a stable error.