Files
turbo-debug.nvim/lua/turbo-debug/mode.lua
T
swaits 98c943885a feat: keymap-aware legend, live dap.status, debug-mode colorscheme
Three things the user caught in testing:

1. **Legend keymap-aware.** The cbar labels used hardcoded tails
   ("tep", "escend", "eturn") that assumed the default first-letter
   keys. If a user configured `keys.step_over = "n"` it would render
   as "(n)tep" — garbage. And disabled keys (`false` in config) still
   showed up because `keyof` fell back to the default letter.

   Now `keyof` returns nil for disabled actions (caller SKIPS rendering
   the entry) and `build_control_text` auto-picks between inline form
   "(s)tep" when the actual key matches the label's first letter and
   prefix form "(n) step" when it doesn't. Controls carry their full
   label ("step") plus the canonical default key — the render figures
   out which form fits the user's current keymap.

2. **dap.status() lag.** `dap.status()` polls a message queue one at
   a time per call (nvim-dap/lua/dap/progress.lua:50). We were only
   polling on event_stopped/continued/terminated/etc., so queued
   state strings drained behind reality — user saw "Running" when
   already paused, "All threads stopped" one step late, etc. Now we
   also subscribe to the `User DapProgressUpdate` autocmd that dap
   fires on EVERY status update, keeping the bar in sync.

3. **Debug-mode colorscheme.** New `config.opts.colorscheme` option
   (default nil). If set to a string, M.enter switches to it BEFORE
   define_highlights (so our TurboDebug* groups derive from the new
   theme's colors); M.exit restores the previous `vim.g.colors_name`.
2026-04-12 17:38:20 -06:00

1484 lines
62 KiB
Lua

local config = require("turbo-debug.config")
local help = require("turbo-debug.help")
local M = {}
local active = false
local active_win = nil
local saved_winbar = nil
-- Forward declaration so listeners registered in ensure_dapui (which is
-- defined earlier in the file than source_window) can still reference it
-- as an upvalue rather than a nil global.
local source_window
-- two floating chrome bars (both independent of lualine):
-- sbar = status bar pinned at the TOP of the editor.
-- Row 0: turbo-debug · {dap status} · DEBUG · STATE
-- Row 1: ─── separator
-- cbar = control bar pinned just above the statusline.
-- Row 0: ─── separator
-- Row 1: {controls} ❓ help
local sbar_win, sbar_buf = nil, nil
local cbar_win, cbar_buf = nil, nil
-- installed[buf][name] = { key = lhs, prev_n = <prev n-mode map>, prev_v = <prev v-mode map> }
local installed = {}
-- Tracks the total vertical rows currently available to the dapui layout
-- (sum of all open dapui pane heights). When this changes — e.g. bufferline
-- tabline appears or disappears, reclaiming/returning a row — dapui's
-- `win_states.size` proportions go stale (dapui has no WinResized listener
-- of its own), and nvim arbitrarily gives the delta row to one pane, which
-- then renders EOB `~` below its content. On detected change we re-pin to
-- our initial proportions.
local last_dapui_total_height = nil
-- The user's theme's WinSeparator highlight, captured on debug-mode enter
-- before we override it to the bright TurboDebugBarSeparator color. We
-- want EVERY horizontal/vertical split divider in the editor to match our
-- bar separator style during a debug session — the dim default makes
-- dapui's pane-to-pane boundaries look like a visual seam, while the
-- bright override gives the whole debug UI a single consistent chrome
-- color. Restored byte-for-byte on M.exit.
local saved_winsep_hl = nil
-- The user's colorscheme at M.enter time, saved only if `config.opts.
-- colorscheme` is set and the current colorscheme differs. Restored on
-- M.exit. Lets the user pin a distinct theme for debug sessions (e.g. a
-- high-contrast theme for reading register dumps) without permanently
-- switching their editor.
local saved_colorscheme = nil
local dapui_initialized = false
local vt_initialized = false
-- ─── icon constants (Nerd Font Codicons, byte-escaped) ───────────────────────
-- All in the PUA range U+EA60..U+EC1E. Encoded as UTF-8 bytes so the source
-- file stays ASCII through any tool pipeline that strips PUA codepoints.
local ICON = {
-- Control-bar emoji — strictly supplementary-plane (U+1F000+) or
-- standalone-emoji-default (like 🛑) codepoints. No VS16-dependent
-- glyphs: those render as text-mode in half of modern terminals, which
-- is exactly the "some icons are blocks, some are chars" inconsistency
-- the last iteration had. Every icon here is guaranteed to render as a
-- colored emoji in Ghostty + any modern terminal with emoji support.
go = "\xf0\x9f\x9f\xa2", -- 🟢 green circle (U+1F7E2) — go / continue / running
start_rkt = "\xf0\x9f\x9a\x80", -- 🚀 rocket (U+1F680) — launch a new session
step_over = "\xf0\x9f\x91\xa3", -- 👣 footprints (U+1F463) — step
step_into = "\xf0\x9f\x94\xbd", -- 🔽 down-pointing red triangle (U+1F53D) — into
step_out = "\xf0\x9f\x94\xbc", -- 🔼 up-pointing red triangle (U+1F53C) — out
restart = "\xf0\x9f\x94\x84", -- 🔄 counterclockwise arrows (U+1F504). ♻️
-- (U+267B + VS16) doesn't render in the
-- user's font — VS16 forces emoji only
-- when the font has an emoji glyph, and
-- many don't. Supplementary plane is
-- guaranteed color.
stop = "\xf0\x9f\x9b\x91", -- 🛑 octagonal stop sign (U+1F6D1)
-- pane-title emoji (supplementary plane — reliably colorful)
scopes = "\xf0\x9f\x94\x8e", -- 🔎 magnifier (U+1F50E)
watches = "\xf0\x9f\x91\x80", -- 👀 eyes (U+1F440)
stacks = "\xf0\x9f\x93\x9a", -- 📚 books (U+1F4DA)
breakpoints = "\xf0\x9f\x94\xb4", -- 🔴 red circle (U+1F534)
terminal = "\xf0\x9f\x92\xbb", -- 💻 laptop (U+1F4BB)
repl = "\xf0\x9f\x92\xac", -- 💬 speech balloon (U+1F4AC)
-- brand / state emoji
bug = "\xf0\x9f\x90\x9b", -- 🐛 bug (U+1F41B)
help = "\xe2\x9d\x93", -- ❓ black question mark ornament
-- (U+2753). It's BMP but is
-- Emoji_Presentation by default,
-- so modern terminals render it
-- as color. The previous attempt
-- appeared broken only because
-- of a misaligned extmark; with
-- that gone, ❓ renders fine.
-- IP arrows
ip_left = "\xf0\x9f\x91\x88", -- 👈 backhand pointing left (U+1F448)
-- misc
divider = "\xc2\xb7", -- · middle dot
ellipsis = "\xe2\x80\xa6", -- … horizontal ellipsis
wrap_mark = "\xe2\x86\xb3", -- ↳ downward arrow w/ tip right
hline = "\xe2\x94\x80", -- ─ light horizontal
}
-- ─── layout ──────────────────────────────────────────────────────────────────
local function build_default_layouts()
local pos = config.opts.sidebar == "right" and "right" or "left"
-- Console height: max(8 rows, 20% of total editor lines). More
-- generous than the previous 15% — user reported the Console kept
-- feeling "laughably small" so we're allocating real estate.
local console_size = config.opts.console_height
or math.max(8, math.floor(vim.o.lines * 0.2))
-- Sidebar width: ideal 40 cols, never less than 25, never more than 1/3
-- of tty width. Scales down gracefully on narrow terminals, stops at 40
-- on wide ones so the source area stays the star.
local sidebar_size = math.max(25, math.min(40, math.floor(vim.o.columns / 3)))
return {
{
position = pos,
size = sidebar_size,
-- Order: Call Stack → Scopes → Watches → Breakpoints.
-- Equal 25% share per user preference.
elements = {
{ id = "stacks", size = 0.25 },
{ id = "scopes", size = 0.25 },
{ id = "watches", size = 0.25 },
{ id = "breakpoints", size = 0.25 },
},
},
{
-- Console only. REPL removed — hit E in debug mode to open it
-- as a floating widget when needed.
position = "bottom",
size = console_size,
elements = {
{ id = "console", size = 1.0 },
},
},
}
end
-- ─── highlights ──────────────────────────────────────────────────────────────
local function define_highlights()
local hl = vim.api.nvim_set_hl
hl(0, "TurboDebugBorderActive", { default = true, link = "Function" })
hl(0, "TurboDebugBorderInactive", { default = true, link = "Comment" })
hl(0, "TurboDebugWinbar", { default = true, link = "DiagnosticError" })
-- Bar background: link to Normal so the bar blends with dapui's own
-- pane borders (which use Normal or WinBar — both share the editor's
-- default background). StatusLine was too dark and created a visible
-- seam between the bar and the surrounding panes.
hl(0, "TurboDebugBar", { default = true, link = "Normal" })
-- Bar separator: fg derived from WinBar.fg at runtime so it matches
-- whatever color the user's theme uses for pane-title chrome
-- (dapui renders each pane's title in its winbar, so this is
-- THE visible "chrome" color). Theme-agnostic — works with any
-- colorscheme. Falls back to WinSeparator if WinBar has no fg.
-- Re-applied on ColorScheme so theme swaps keep it in sync.
--
-- Placeholder link so the group exists as a sane default even
-- before the derive runs.
hl(0, "TurboDebugBarSeparator", { default = true, link = "WinSeparator" })
-- Control labels: link to Normal so the text blends with the bar's
-- Normal-bg surface. StatusLine bg was creating a visible color patch
-- behind each label. The key letter still pops via TurboDebugBarKey
-- (Special fg, bold), and icons carry their own emoji color.
hl(0, "TurboDebugBarCtrl", { default = true, link = "Normal" })
hl(0, "TurboDebugBarStatus", { default = true, link = "Comment" })
hl(0, "TurboDebugBarBrand", { default = true, link = "Title" })
hl(0, "TurboDebugBarReady", { default = true, link = "DiagnosticHint" })
hl(0, "TurboDebugBarRunning", { default = true, link = "DiagnosticInfo" })
hl(0, "TurboDebugBarPaused", { default = true, link = "DiagnosticError" })
-- Italic qualifier words ("over", "into", "out") shown after the
-- step/descend/return labels. Subordinate styling: Comment fg + italic,
-- so the key label ("(s)tep") reads primary and the qualifier ("over")
-- reads secondary. Can't combine link+italic via nvim_set_hl; derive
-- fg from Comment and set italic explicitly.
local function apply_italic_hl()
local ok, src = pcall(vim.api.nvim_get_hl, 0, { name = "Comment", link = false })
if not ok or not src or not src.fg then
vim.api.nvim_set_hl(0, "TurboDebugBarItalic", { default = true, link = "Comment" })
return
end
vim.api.nvim_set_hl(0, "TurboDebugBarItalic", {
fg = src.fg, italic = true, default = true,
})
end
apply_italic_hl()
vim.api.nvim_create_autocmd("ColorScheme", {
group = vim.api.nvim_create_augroup("TurboDebugBarItalicHL", { clear = true }),
callback = apply_italic_hl,
})
-- The key letter inside (c)ontinue etc. gets bold + Special's fg.
-- No underline — the descender on lowercase q/g/p/y overlaps the
-- underline and makes the glyph unreadable (q reads as g, etc.).
-- nvim_set_hl can't combine link + bold, so copy Special's fg explicitly.
-- Re-applied on ColorScheme so theme swaps don't blank it out.
local function apply_key_hl()
local ok, src = pcall(vim.api.nvim_get_hl, 0, { name = "Special", link = false })
if not ok or not src or not src.fg then
vim.api.nvim_set_hl(0, "TurboDebugBarKey", { default = true, link = "Special" })
return
end
vim.api.nvim_set_hl(0, "TurboDebugBarKey", {
fg = src.fg, bg = src.bg, bold = true, default = true,
})
end
apply_key_hl()
vim.api.nvim_create_autocmd("ColorScheme", {
group = vim.api.nvim_create_augroup("TurboDebugBarKeyHL", { clear = true }),
callback = apply_key_hl,
})
-- Separator color: derive from WinBar.fg at runtime. Every theme
-- stylistically renders WinBar (pane-title chrome) in a color
-- that's intentionally visible against Normal — which is exactly
-- the semantic we want for our bar edges. Theme-agnostic. If
-- WinBar has no fg (very rare), fall back to WinSeparator so
-- we at least match the theme's split-divider convention.
--
-- Also override the global WinSeparator to the same fg during
-- debug mode, so every split divider in the editor matches our
-- bar chrome (dapui pane-to-pane, source-to-console, our bars to
-- their neighbors — all one consistent color). Original captured
-- on first call, restored in M.exit.
local function apply_sep_hl()
local ok, src = pcall(vim.api.nvim_get_hl, 0, { name = "WinBar", link = false })
if ok and src and src.fg then
-- Not `default = true` — we MUST override the placeholder link
-- we set in the table-of-defaults above. default semantics
-- would leave the placeholder in place.
vim.api.nvim_set_hl(0, "TurboDebugBarSeparator", { fg = src.fg })
if saved_winsep_hl == nil then
local wok, ws = pcall(vim.api.nvim_get_hl, 0, { name = "WinSeparator", link = false })
if wok then saved_winsep_hl = ws or {} end
end
vim.api.nvim_set_hl(0, "WinSeparator", { fg = src.fg })
return
end
vim.api.nvim_set_hl(0, "TurboDebugBarSeparator", { link = "WinSeparator" })
end
apply_sep_hl()
vim.api.nvim_create_autocmd("ColorScheme", {
group = vim.api.nvim_create_augroup("TurboDebugBarSepHL", { clear = true }),
callback = apply_sep_hl,
})
end
-- winhighlight strings applied to dapui panes. `WinBar:WinBar,WinBarNC:WinBar`
-- is defensive — if the user's theme makes WinBarNC dimmer than WinBar, this
-- forces the pane's winbar row to stay bright even when the pane is
-- unfocused (nvim renders unfocused wins with WinBarNC). Nordfox happens to
-- make them identical; other themes don't.
local DAPUI_WINHL_ACTIVE = "WinBar:WinBar,WinBarNC:WinBar,WinSeparator:TurboDebugBorderActive,FloatBorder:TurboDebugBorderActive"
local DAPUI_WINHL_INACTIVE = "WinBar:WinBar,WinBarNC:WinBar,WinSeparator:TurboDebugBorderInactive,FloatBorder:TurboDebugBorderInactive"
local function setup_active_win_highlights()
local active_hl = DAPUI_WINHL_ACTIVE
local inactive_hl = DAPUI_WINHL_INACTIVE
local group = vim.api.nvim_create_augroup("TurboDebugActiveWin", { clear = true })
local function apply(win, hl)
if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_config(win).relative == "" then
local ft = vim.bo[vim.api.nvim_win_get_buf(win)].filetype or ""
if ft:match("^dapui_") or ft == "dap-repl" then
vim.wo[win].winhighlight = hl
end
end
end
vim.api.nvim_create_autocmd("WinEnter", {
group = group,
callback = function() apply(vim.api.nvim_get_current_win(), active_hl) end,
})
vim.api.nvim_create_autocmd("WinLeave", {
group = group,
callback = function() apply(vim.api.nvim_get_current_win(), inactive_hl) end,
})
end
-- ─── chrome bars (top = status, bottom = controls) ─────────────────────────
--
-- Both bars are floating windows with buffer-rendered content (winbar
-- doesn't render reliably in minimal-style floats). Highlights via
-- extmarks; mouse click dispatch via buffer-local <LeftMouse> keymap +
-- per-zone display-column lookup.
local bar_ns = vim.api.nvim_create_namespace("turbo-debug.bar")
-- click zones, one table per bar. Each entry: { line_1based, dcol_start, dcol_end, fn }
local sbar_zones = {}
local cbar_zones = {}
local function dap_state()
local ok, dap = pcall(require, "dap")
if not ok then return "READY", "TurboDebugBarReady" end
local s = dap.session()
if not s then return "READY", "TurboDebugBarReady" end
if s.stopped_thread_id then return "PAUSED", "TurboDebugBarPaused" end
return "RUNNING", "TurboDebugBarRunning"
end
local function action(name)
return function() local a = M.actions(); if a[name] then a[name]() end end
end
-- Returns the user's configured key for an action, or the fallback default
-- when the user hasn't overridden it. Returns nil when the action is
-- explicitly disabled (set to `false` in config.opts.keys) — callers should
-- SKIP rendering a legend entry for that action entirely, since the modal
-- keymap install path at M._install_for_buf also skips it.
local function keyof(name, fallback)
local k = config.opts.keys[name]
if k == false then return nil end
if type(k) == "string" and k ~= "" then return k end
return fallback
end
-- Build a control label's byte-level representation.
--
-- Picks between two forms based on whether the user's actual key matches
-- the first letter of the label (case-insensitive):
-- inline: "<icon> (<k>)<rest>[ <italic>]" e.g. "(s)tep over"
-- prefix: "<icon> (<k>) <label>[ <italic>]" e.g. "(n) step over"
--
-- The inline form is the clean compact presentation for the common case
-- (default keys match the canonical first letters). The prefix form is
-- the fallback for users who remap to non-first-letter keys — it still
-- shows the mnemonic clearly instead of rendering nonsense like "(n)tep".
--
-- Returns: text, key_col, key_end_col, italic_col, italic_end_col
-- (italic_col/italic_end_col are nil when italic is not set).
local function build_control_text(icon, actual_key, label, italic)
local parts = {}
local function add(s) parts[#parts + 1] = s end
local function col() return #table.concat(parts) end
local inline = actual_key and label and #actual_key == 1
and #label >= 1
and label:sub(1, 1):lower() == actual_key:lower()
add(icon); add(" ")
add("(")
local key_col = col()
add(actual_key)
local key_end_col = col()
add(")")
if inline then
add(label:sub(2))
else
add(" ")
add(label)
end
local italic_col, italic_end_col
if italic and italic ~= "" then
add(" ")
italic_col = col()
add(italic)
italic_end_col = col()
end
return table.concat(parts), key_col, key_end_col, italic_col, italic_end_col
end
local function render_sbar()
if not (sbar_buf and vim.api.nvim_buf_is_valid(sbar_buf)) then return end
if not (sbar_win and vim.api.nvim_win_is_valid(sbar_win)) then return end
local width = vim.api.nvim_win_get_width(sbar_win)
if width < 1 then width = vim.o.columns end
local sep = string.rep(ICON.hline, width)
local state, state_hl = dap_state()
-- layout on content row: [brand] [status msg] [state chip]
local brand_text = " turbo-debug "
local status_ok, status_msg = pcall(function() return require("dap").status() end)
if not status_ok then status_msg = "" end
local state_chip = " DEBUG " .. ICON.divider .. " " .. state .. " "
local brand_dw = vim.fn.strdisplaywidth(brand_text)
local chip_dw = vim.fn.strdisplaywidth(state_chip)
local status_dw = vim.fn.strdisplaywidth(status_msg)
local available = width - brand_dw - chip_dw - 4
if status_dw > available and available > 5 then
status_msg = status_msg:sub(1, available - 1) .. ICON.ellipsis
status_dw = vim.fn.strdisplaywidth(status_msg)
elseif available <= 5 then
status_msg = ""
status_dw = 0
end
local remain = width - brand_dw - status_dw - chip_dw
if remain < 2 then remain = 2 end
local pad_left = math.floor(remain / 2)
local pad_right = remain - pad_left
local content_line = brand_text .. string.rep(" ", pad_left) .. status_msg .. string.rep(" ", pad_right) .. state_chip
-- 2 rows: top sep / content. Only the outward (top) edge gets a bright
-- separator; the inward edge abuts dapui's top pane (whose winbar and
-- nvim's own horiz separator serve as the visual boundary there). Keeps
-- the bar's vertical footprint minimal.
vim.bo[sbar_buf].modifiable = true
vim.api.nvim_buf_set_lines(sbar_buf, 0, -1, false, { sep, content_line })
vim.bo[sbar_buf].modifiable = false
vim.api.nvim_buf_clear_namespace(sbar_buf, bar_ns, 0, -1)
pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 0, 0, {
end_row = 0, end_col = #sep, hl_group = "TurboDebugBarSeparator",
})
-- content row
pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 1, 0, {
end_row = 1, end_col = #brand_text, hl_group = "TurboDebugBarBrand",
})
if status_dw > 0 then
local status_byte_start = #brand_text + pad_left
pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 1, status_byte_start, {
end_row = 1, end_col = status_byte_start + #status_msg, hl_group = "TurboDebugBarStatus",
})
end
local chip_byte_start = #content_line - #state_chip
pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 1, chip_byte_start, {
end_row = 1, end_col = #content_line, hl_group = state_hl,
})
sbar_zones = {}
end
local function render_cbar()
if not (cbar_buf and vim.api.nvim_buf_is_valid(cbar_buf)) then return end
if not (cbar_win and vim.api.nvim_win_is_valid(cbar_win)) then return end
local width = vim.api.nvim_win_get_width(cbar_win)
if width < 1 then width = vim.o.columns end
local sep = string.rep(ICON.hline, width)
local state, _ = dap_state()
local help_text = ICON.help .. " help "
local help_dw = vim.fn.strdisplaywidth(help_text)
-- Modal control labels. State-dependent LABEL so the legend shows what
-- the key will ACTUALLY do right now:
-- no session: (c) start (q)uit (Restart hidden)
-- active: (c)ontinue (q) terminate (R)estart shown
--
-- The KEY shown in parens comes from the user's config (keyof returns
-- their override, the default, or nil when explicitly disabled). When
-- the key matches the label's first letter, build_control_text renders
-- the compact inline form `(s)tep`; when it doesn't, prefix form
-- `(n) step`. Disabled actions (keyof returns nil) are omitted entirely.
local has_session = state ~= "READY"
local continue_icon = has_session and ICON.go or ICON.start_rkt
local continue_label = has_session and "continue" or "start"
local terminate_label = has_session and "terminate" or "quit"
local controls = {
{ id = "continue", icon = continue_icon, default = "c", label = continue_label, italic = nil, fn = action("continue") },
{ id = "step_over", icon = ICON.step_over, default = "s", label = "step", italic = "over", fn = action("step_over") },
{ id = "step_into", icon = ICON.step_into, default = "d", label = "descend", italic = "into", fn = action("step_into") },
{ id = "step_out", icon = ICON.step_out, default = "r", label = "return", italic = "out", fn = action("step_out") },
}
-- (R)estart is only meaningful during an active session
if has_session then
controls[#controls + 1] = { id = "restart", icon = ICON.restart, default = "R", label = "restart", italic = nil, fn = action("restart") }
end
controls[#controls + 1] = { id = "terminate", icon = ICON.stop, default = "q", label = terminate_label, italic = nil, fn = action("terminate") }
local gap = " "
local function build_controls(include_italic)
local parts, spans, key_spans, italic_spans, zones = {}, {}, {}, {}, {}
local emitted = 0
for _, c in ipairs(controls) do
local actual_key = keyof(c.id, c.default)
if actual_key then
local prefix_len = #table.concat(parts)
if emitted > 0 then
parts[#parts + 1] = gap
prefix_len = prefix_len + #gap
end
emitted = emitted + 1
local italic_text = include_italic and c.italic or nil
local txt, kcol, kend, icol, iend = build_control_text(c.icon, actual_key, c.label, italic_text)
parts[#parts + 1] = txt
spans[#spans + 1] = { prefix_len, prefix_len + #txt, "TurboDebugBarCtrl" }
key_spans[#key_spans + 1] = { prefix_len + kcol, prefix_len + kend }
if icol then
italic_spans[#italic_spans + 1] = { prefix_len + icol, prefix_len + iend }
end
zones[#zones + 1] = { prefix_len, prefix_len + #txt, c.fn }
end
end
return table.concat(parts), spans, key_spans, italic_spans, zones
end
-- Try with italic qualifiers first; fall back to the compact form if
-- the label row would overflow the available width. Min 1-char padding
-- on each side of the controls-plus-help block.
local controls_text, ctrl_spans, ctrl_key_spans, ctrl_italic_spans, ctrl_zones_bytes =
build_controls(true)
local controls_dw = vim.fn.strdisplaywidth(controls_text)
if controls_dw + help_dw + 2 > width then
controls_text, ctrl_spans, ctrl_key_spans, ctrl_italic_spans, ctrl_zones_bytes =
build_controls(false)
controls_dw = vim.fn.strdisplaywidth(controls_text)
end
-- controls centered between left edge and help (right anchor)
local ctrl_zone_width = width - help_dw
local ctrl_pad_left = math.floor((ctrl_zone_width - controls_dw) / 2)
if ctrl_pad_left < 1 then ctrl_pad_left = 1 end
local ctrl_pad_right = ctrl_zone_width - controls_dw - ctrl_pad_left
if ctrl_pad_right < 1 then ctrl_pad_right = 1 end
local content_line = string.rep(" ", ctrl_pad_left) .. controls_text .. string.rep(" ", ctrl_pad_right) .. help_text
-- 2 rows: content / bottom sep. Only the outward (bottom) edge gets a
-- bright separator; the inward edge abuts dapui Console (whose winbar
-- + nvim's own horiz separator serve as the visual boundary there).
vim.bo[cbar_buf].modifiable = true
vim.api.nvim_buf_set_lines(cbar_buf, 0, -1, false, { content_line, sep })
vim.bo[cbar_buf].modifiable = false
vim.api.nvim_buf_clear_namespace(cbar_buf, bar_ns, 0, -1)
pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 1, 0, {
end_row = 1, end_col = #sep, hl_group = "TurboDebugBarSeparator",
})
-- control labels on row 0 (content row)
local ctrl_byte_offset = ctrl_pad_left
for _, span in ipairs(ctrl_spans) do
pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 0, ctrl_byte_offset + span[1], {
end_row = 0, end_col = ctrl_byte_offset + span[2], hl_group = span[3],
})
end
for _, span in ipairs(ctrl_key_spans) do
pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 0, ctrl_byte_offset + span[1], {
end_row = 0, end_col = ctrl_byte_offset + span[2], hl_group = "TurboDebugBarKey",
})
end
for _, span in ipairs(ctrl_italic_spans) do
pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 0, ctrl_byte_offset + span[1], {
end_row = 0, end_col = ctrl_byte_offset + span[2], hl_group = "TurboDebugBarItalic",
})
end
local help_byte_start = #content_line - #help_text
pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 0, help_byte_start, {
end_row = 0, end_col = #content_line, hl_group = "TurboDebugBarCtrl",
})
cbar_zones = {}
for _, z in ipairs(ctrl_zones_bytes) do
local byte_start = ctrl_byte_offset + z[1]
local byte_end = ctrl_byte_offset + z[2]
local dcol_start = vim.fn.strdisplaywidth(content_line:sub(1, byte_start))
local dcol_end = vim.fn.strdisplaywidth(content_line:sub(1, byte_end))
-- content is on buffer row 0 = window line 1
cbar_zones[#cbar_zones + 1] = { 1, dcol_start, dcol_end, z[3] }
end
local help_dcol_start = vim.fn.strdisplaywidth(content_line:sub(1, help_byte_start))
local help_dcol_end = vim.fn.strdisplaywidth(content_line)
cbar_zones[#cbar_zones + 1] = { 1, help_dcol_start, help_dcol_end,
function() require("turbo-debug.help").open() end }
end
local function render_bars()
render_sbar()
render_cbar()
end
-- The bars are SPLITS, not floats. Splits consume real layout rows so
-- dapui and source windows tuck underneath them naturally — no overlap.
local function handle_sbar_click()
local pos = vim.fn.getmousepos()
if not pos or pos.winid ~= sbar_win then return end
for _, zone in ipairs(sbar_zones) do
if zone[1] == pos.line and pos.wincol - 1 >= zone[2] and pos.wincol - 1 < zone[3] then
zone[4]()
return
end
end
end
local function handle_cbar_click()
local pos = vim.fn.getmousepos()
if not pos or pos.winid ~= cbar_win then return end
for _, zone in ipairs(cbar_zones) do
if zone[1] == pos.line and pos.wincol - 1 >= zone[2] and pos.wincol - 1 < zone[3] then
zone[4]()
return
end
end
end
local function setup_bar_buf(buf)
vim.bo[buf].bufhidden = "wipe"
vim.bo[buf].buftype = "nofile"
vim.bo[buf].swapfile = false
vim.bo[buf].buflisted = false
vim.bo[buf].filetype = "TurboDebugBar"
end
local function setup_bar_win(win)
-- CRITICAL: clear winbar first. `:topleft split` / `:botright split`
-- inherits window-local options from the window we split off of. The
-- caller is the source window, which has `winbar = " %f"` set by
-- `set_debug_chrome`. Inherited onto the bar, that renders as an
-- extra chrome row ABOVE our own separator (since winbar sits above
-- the buffer rows), showing the scratch buffer's empty filename —
-- the phantom "second separator" the user sees stacked above the
-- legend bar. Explicit empty string disables the winbar entirely.
vim.wo[win].winbar = ""
-- Include StatusLine/StatusLineNC so any statusline row the bar
-- might pick up (with laststatus=2 every window gets one) blends
-- into the bar's Normal bg instead of rendering in the dark default
-- StatusLine color — which was the "wrong color border below top bar".
vim.wo[win].winhighlight = "Normal:TurboDebugBar,EndOfBuffer:TurboDebugBar,StatusLine:TurboDebugBar,StatusLineNC:TurboDebugBar"
vim.wo[win].statusline = " " -- empty content; hl makes it invisible
vim.wo[win].winfixheight = true
vim.wo[win].list = false
vim.wo[win].cursorline = false
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].signcolumn = "no"
vim.wo[win].statuscolumn = ""
vim.wo[win].wrap = false
end
local function bounce_out(buf)
-- if focus lands in a bar buffer (e.g. mouse drag), kick it back out
-- so the user can't accidentally type into a non-modifiable buffer.
vim.api.nvim_create_autocmd("BufEnter", {
buffer = buf,
callback = function()
vim.schedule(function()
if vim.api.nvim_get_current_buf() == buf then
pcall(vim.cmd, "wincmd p")
end
end)
end,
})
end
local function open_bars()
-- Both bars are SPLITS, not floats. Splits consume real layout rows
-- so dapui windows tuck between them. `wincmd K` / `wincmd J` is
-- applied after creation to ensure the bars span the FULL editor
-- width (across the sidebar column too) — otherwise dapui's later
-- vertical splits would constrain the bars to just the main column.
local caller_win = vim.api.nvim_get_current_win()
-- status bar (top)
if not (sbar_win and vim.api.nvim_win_is_valid(sbar_win)) then
vim.cmd("noautocmd keepalt topleft split")
sbar_win = vim.api.nvim_get_current_win()
sbar_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_win_set_buf(sbar_win, sbar_buf)
vim.api.nvim_win_set_height(sbar_win, 2)
setup_bar_buf(sbar_buf)
setup_bar_win(sbar_win)
vim.wo[sbar_win].winfixheight = true
vim.keymap.set("n", "<LeftMouse>", handle_sbar_click, { buffer = sbar_buf, silent = true, nowait = true })
vim.keymap.set("n", "<LeftRelease>", "<Nop>", { buffer = sbar_buf, silent = true, nowait = true })
bounce_out(sbar_buf)
end
if vim.api.nvim_win_is_valid(caller_win) then
pcall(vim.api.nvim_set_current_win, caller_win)
end
-- control bar (bottom)
if not (cbar_win and vim.api.nvim_win_is_valid(cbar_win)) then
vim.cmd("noautocmd keepalt botright split")
cbar_win = vim.api.nvim_get_current_win()
cbar_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_win_set_buf(cbar_win, cbar_buf)
vim.api.nvim_win_set_height(cbar_win, 2)
setup_bar_buf(cbar_buf)
setup_bar_win(cbar_win)
vim.wo[cbar_win].winfixheight = true
vim.keymap.set("n", "<LeftMouse>", handle_cbar_click, { buffer = cbar_buf, silent = true, nowait = true })
vim.keymap.set("n", "<LeftRelease>", "<Nop>", { buffer = cbar_buf, silent = true, nowait = true })
bounce_out(cbar_buf)
end
if vim.api.nvim_win_is_valid(caller_win) then
pcall(vim.api.nvim_set_current_win, caller_win)
end
render_bars()
end
-- Set dapui pane heights to their initial proportions. Called ONCE
-- per M.enter, after dapui.open(). No winfixheight — the user is free
-- to manually resize panes (bigger Breakpoints list, taller Console to
-- tail long output, etc.) and their resize sticks because we don't
-- fight subsequent WinResized events. Initial sizing only.
local function pin_dapui_sizes()
local pane_wins = {}
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_valid(buf) then
local ft = vim.bo[buf].filetype
if ft == "dapui_stacks" or ft == "dapui_scopes" or ft == "dapui_watches"
or ft == "dapui_breakpoints" or ft == "dapui_console" or ft == "dap-repl" then
local wins = vim.fn.win_findbuf(buf)
if wins and wins[1] and vim.api.nvim_win_is_valid(wins[1]) then
pane_wins[ft] = wins[1]
end
end
end
end
-- Console to configured height
if pane_wins.dapui_console then
local target = config.opts.console_height
or math.max(8, math.floor(vim.o.lines * 0.2))
pcall(vim.api.nvim_win_set_height, pane_wins.dapui_console, target)
end
-- Sidebar panes distributed equally (residual to Breakpoints)
local order = { "dapui_stacks", "dapui_scopes", "dapui_watches", "dapui_breakpoints" }
local present = {}
local total = 0
for _, ft in ipairs(order) do
local w = pane_wins[ft]
if w then
present[#present + 1] = w
total = total + vim.api.nvim_win_get_height(w)
end
end
if #present > 1 and total > 0 then
local each = math.floor(total / #present)
local residual = total - (each * #present)
for i, w in ipairs(present) do
local h = each + (i == #present and residual or 0)
pcall(vim.api.nvim_win_set_height, w, h)
end
end
end
local function close_bars()
if sbar_win and vim.api.nvim_win_is_valid(sbar_win) then
pcall(vim.api.nvim_win_close, sbar_win, true)
end
if cbar_win and vim.api.nvim_win_is_valid(cbar_win) then
pcall(vim.api.nvim_win_close, cbar_win, true)
end
sbar_win, sbar_buf, cbar_win, cbar_buf = nil, nil, nil, nil
sbar_zones, cbar_zones = {}, {}
end
-- `winfixheight` is a preference, not a guarantee. Under grid pressure
-- (bufferline tabline appearing/disappearing, `wincmd =`, etc.) nvim will
-- shrink OR grow a bar against its fixed-height mark:
--
-- shrinking below 2 rows → bar content is clipped; first/second row
-- invisible, the bar looks "squished".
-- growing above 2 rows → the extra row sits past the end of the 2-line
-- buffer and renders as a stray `~` EOB marker (between our sep
-- and the next window).
--
-- Clamp EXACTLY to 2 on every resize event. When we shrink an oversized
-- bar, the row nvim gave us has to go somewhere else; on WinResized the
-- subsequent pin_dapui_sizes() call redistributes it across the dapui
-- panes according to our initial proportions.
local function ensure_bar_heights()
if sbar_win and vim.api.nvim_win_is_valid(sbar_win) then
if vim.api.nvim_win_get_height(sbar_win) ~= 2 then
pcall(vim.api.nvim_win_set_height, sbar_win, 2)
end
end
if cbar_win and vim.api.nvim_win_is_valid(cbar_win) then
if vim.api.nvim_win_get_height(cbar_win) ~= 2 then
pcall(vim.api.nvim_win_set_height, cbar_win, 2)
end
end
end
-- Sum the heights of all currently-open dapui pane windows. Used to detect
-- external geometry changes (tabline toggle) without false-positive firing
-- on user-internal resizes (which redistribute within the total area
-- without changing it).
local function compute_dapui_total_height()
local total = 0
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_valid(buf) then
local ft = vim.bo[buf].filetype
if ft == "dapui_stacks" or ft == "dapui_scopes" or ft == "dapui_watches"
or ft == "dapui_breakpoints" or ft == "dapui_console" or ft == "dap-repl" then
for _, win in ipairs(vim.fn.win_findbuf(buf)) do
if vim.api.nvim_win_is_valid(win)
and vim.api.nvim_win_get_config(win).relative == "" then
total = total + vim.api.nvim_win_get_height(win)
end
end
end
end
end
return total
end
local function reposition_bars()
-- Just re-render bar content. We intentionally DON'T re-pin dapui
-- sizes here — the user may have manually resized panes, and
-- fighting their resize on every event is a worse UX than occasional
-- layout drift.
render_bars()
end
-- ─── dap-ui setup ────────────────────────────────────────────────────────────
local function set_dapui_window_opts(win)
-- Wrap long values inside the pane so register dumps etc. don't bleed into
-- adjacent windows. breakindent + showbreak make continuation lines visually
-- attached to their parent. The `format_value` wrap below truncates only
-- pathologically-long values.
--
-- Also kill line numbers, the statuscolumn (which snacks overrides with a
-- rendered gutter), and the `~` end-of-buffer markers — these are pure
-- noise in debug-info panes where there's no file to navigate.
if vim.api.nvim_win_is_valid(win) then
vim.wo[win].wrap = true
vim.wo[win].breakindent = true
vim.wo[win].linebreak = true
vim.wo[win].showbreak = " " .. ICON.wrap_mark .. " "
vim.wo[win].sidescrolloff = 0
vim.wo[win].cursorline = true
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].signcolumn = "no"
vim.wo[win].statuscolumn = ""
vim.wo[win].foldcolumn = "0"
-- make end-of-buffer `~` invisible by setting EOB fillchar to space
vim.wo[win].fillchars = "eob: "
-- Baseline winhighlight so WinBarNC:WinBar is in effect from the
-- first render (not just after the first WinEnter/WinLeave cycle).
-- setup_active_win_highlights overwrites on focus change.
vim.wo[win].winhighlight = DAPUI_WINHL_INACTIVE
end
end
local function patch_format_value()
local ok, util = pcall(require, "dapui.util")
if not ok or not util or type(util.format_value) ~= "function" then return end
if util._turbo_debug_wrapped then return end
local orig = util.format_value
util.format_value = function(value_start, value)
if type(value) ~= "string" then return orig(value_start, value) end
local cap = config.opts.max_value_width or 2000
if cap <= 0 or #value <= cap then return orig(value_start, value) end
return orig(value_start, value:sub(1, cap - 1) .. ICON.ellipsis)
end
util._turbo_debug_wrapped = true
end
local redraw_pending = false
local function schedule_console_redraw()
if redraw_pending then return end
redraw_pending = true
vim.defer_fn(function()
redraw_pending = false
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_valid(buf) and vim.bo[buf].filetype == "dapui_console" then
for _, win in ipairs(vim.fn.win_findbuf(buf)) do
if vim.api.nvim_win_is_valid(win) then
pcall(vim.api.nvim_win_call, win, function() vim.cmd("redraw!") end)
end
end
end
end
end, config.opts.console_refresh_ms or 50)
end
local function ensure_dapui()
-- One-time setup. Calling dapui.setup on every enter was tearing down
-- internal state (windows, event listeners, buffer refs) while the
-- adapter handshake was in flight — "Debug adapter didn't respond"
-- timeouts and overall slowness. dapui's layouts use the absolute
-- sizes computed once here; if the terminal resizes significantly
-- between toggles, proportions can drift slightly, but that's
-- acceptable compared to breaking the adapter.
if dapui_initialized then return end
dapui_initialized = true
local dapui_opts = vim.deepcopy(config.opts.dapui)
if not dapui_opts.layouts then dapui_opts.layouts = build_default_layouts() end
require("dapui").setup(dapui_opts)
patch_format_value()
local dap = require("dap")
dap.listeners.after.event_initialized["turbo-debug"] = function()
require("dapui").open()
end
-- K = hover during a live dap session (global, not per-buffer)
dap.listeners.after.event_initialized["turbo-debug-hover"] = function()
vim.keymap.set("n", "K", function()
require("dap.ui.widgets").hover(nil, { border = "rounded" })
end, { silent = true, desc = "DAP hover" })
end
dap.listeners.after.event_terminated["turbo-debug-hover"] = function()
pcall(vim.keymap.del, "n", "K")
end
-- console refresh: trailing-edge throttled so chatty programs don't DOS us
dap.listeners.after.event_output["turbo-debug-redraw"] = schedule_console_redraw
-- IP marker: paired arrows. Gutter sign 👉 + EOL vtext 👈 REASON flanking
-- the paused line. clear_ip() wipes both our own extmarks AND nvim-dap's
-- DapStopped gutter signs, so a restart (or a natural session end) doesn't
-- leave a stale 👉 behind on the last-paused line.
local ip_ns = vim.api.nvim_create_namespace("turbo-debug.ip")
local function clear_ip()
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_valid(buf) then
vim.api.nvim_buf_clear_namespace(buf, ip_ns, 0, -1)
-- unplace any lingering DapStopped signs (nvim-dap doesn't always
-- clear these on restart — it's the exact bug the user hit).
local ok, placed = pcall(vim.fn.sign_getplaced, buf, { group = "*" })
if ok and placed and placed[1] then
for _, s in ipairs(placed[1].signs or {}) do
if s.name == "DapStopped" then
pcall(vim.fn.sign_unplace, s.group or "", { buffer = buf, id = s.id })
end
end
end
end
end
end
-- re-render the bars on any dap state change so the READY/RUNNING/
-- PAUSED chip and dap.status() text stay live.
--
-- `dap.status()` is a queue-backed message poller (see
-- nvim-dap/lua/dap/progress.lua): each call drains ONE message from
-- the circular buffer, or returns the last-drained message if the
-- queue is empty. To keep the status string current, we must poll
-- on every `User DapProgressUpdate` autocmd — nvim-dap fires that
-- every time it reports a new state string. Listening only to
-- event_stopped/event_continued leaves queued messages behind and
-- the bar shows stale state until the next render.
local function refresh_bars() vim.schedule(render_bars) end
dap.listeners.after.event_initialized["turbo-debug-bar"] = refresh_bars
dap.listeners.after.event_stopped["turbo-debug-bar"] = refresh_bars
dap.listeners.after.event_continued["turbo-debug-bar"] = refresh_bars
dap.listeners.after.event_terminated["turbo-debug-bar"] = refresh_bars
dap.listeners.after.event_exited["turbo-debug-bar"] = refresh_bars
vim.api.nvim_create_autocmd("User", {
pattern = "DapProgressUpdate",
group = vim.api.nvim_create_augroup("TurboDebugDapProgress", { clear = true }),
callback = refresh_bars,
})
-- When a stopped event fires, dap will jump to the source frame using
-- its switchbuf logic. `uselast` (the common default) targets the
-- CURRENT window — but if focus happens to be on a dapui pane or one
-- of our bars, dap falls back to `winnr('#')` which can land anywhere
-- (including bufferline's tabline area or a wrong split). By shifting
-- focus to a real source window BEFORE dap processes the jump, we
-- guarantee new source files (stepped-into library/stdlib files) open
-- in the intended source window and the chrome stays intact.
dap.listeners.before.event_stopped["turbo-debug-focus-source"] = function()
local w = source_window()
if w and w ~= vim.api.nvim_get_current_win() then
pcall(vim.api.nvim_set_current_win, w)
end
end
dap.listeners.after.event_stopped["turbo-debug-ip"] = function(session, body)
vim.defer_fn(function()
clear_ip()
local frame = session and session.current_frame
if not (frame and frame.source and frame.source.path) then return end
local buf = vim.fn.bufnr(frame.source.path)
if buf == -1 or not vim.api.nvim_buf_is_valid(buf) then return end
local line = math.max(0, (frame.line or 1) - 1)
local reason = ((body and body.reason) or "stopped"):upper()
-- Extmark carries BOTH the virt_text (👈 REASON at EOL) AND a
-- sign_text (👉 in the gutter) with high priority. nvim-dap also
-- places its own DapStopped sign, but at the same priority as the
-- breakpoint sign — which meant whichever was placed later won.
-- Our extmark sign at priority 500 always wins, guaranteeing the
-- paused line has the finger-pointing icon in the gutter.
pcall(vim.api.nvim_buf_set_extmark, buf, ip_ns, line, 0, {
sign_text = "\xf0\x9f\x91\x89", -- 👉 U+1F449
sign_hl_group = "DapStopped",
virt_text = {
{ " " .. ICON.ip_left .. " ", "DapStopped" },
{ reason, "TurboDebugIPReason" },
},
virt_text_pos = "eol",
hl_mode = "combine",
priority = 500,
})
end, 50)
end
dap.listeners.after.event_continued["turbo-debug-ip"] = function() clear_ip() end
dap.listeners.before.event_terminated["turbo-debug-ip"] = function() clear_ip() end
dap.listeners.before.event_exited["turbo-debug-ip"] = function() clear_ip() end
-- Clear the Console pane on session start/restart.
--
-- Strategy: keep the buffer (crucially, keep the attached terminal
-- channel intact so dap's output stream isn't broken), briefly flip
-- `modifiable` on and call nvim_buf_set_lines to wipe the scrollback.
-- The terminal's PTY state and cursor position are preserved — only
-- the displayed lines are removed. Next output from the program
-- appends from the top of the empty buffer.
--
-- The previous "delete the whole buffer" approach only worked on
-- fresh launches (new terminal opens anyway) and broke on Restart
-- because the adapter reuses the existing terminal channel — deleting
-- the buffer orphaned the channel and left the Console unresponsive.
local function clear_console()
if not config.opts.clear_console_on_start then return end
local ok, dapui = pcall(require, "dapui")
if not (ok and dapui.elements and dapui.elements.console) then return end
local bok, buf = pcall(dapui.elements.console.buffer)
if not bok or not buf or not vim.api.nvim_buf_is_valid(buf) then return end
pcall(function()
local was_mod = vim.bo[buf].modifiable
vim.bo[buf].modifiable = true
vim.api.nvim_buf_set_lines(buf, 0, -1, false, {})
vim.bo[buf].modifiable = was_mod
end)
end
-- Fire before every session-starting verb so old scrollback is gone
-- before the new session's output begins.
dap.listeners.before.launch["turbo-debug-clear-console"] = clear_console
dap.listeners.before.attach["turbo-debug-clear-console"] = clear_console
dap.listeners.before.restart["turbo-debug-clear-console"] = clear_console
-- Default-collapse scopes the user considers noisy (Registers on
-- codelldb, for example). We intercept the scopes response and set
-- `expensive = true` on any matching scope — dapui's own component
-- auto-collapses anything marked expensive (see dapui/components/
-- scopes.lua line 22-24), so this reuses their existing mechanism.
dap.listeners.before.scopes["turbo-debug-collapse"] = function(_, _, response, _)
local collapse = config.opts.collapsed_scopes
if not (collapse and #collapse > 0) then return end
if not (response and response.scopes) then return end
for _, scope in ipairs(response.scopes) do
for _, name in ipairs(collapse) do
if scope.name == name then
scope.expensive = true
break
end
end
end
end
-- winbar titles on dapui panes — colorful emoji for instant pane
-- recognition. REPL is iconless per user preference (its prompt is
-- already visually distinct enough).
local titles = {
dapui_scopes = " " .. ICON.scopes .. " Scopes",
dapui_watches = " " .. ICON.watches .. " Watches",
dapui_stacks = " " .. ICON.stacks .. " Call Stack",
dapui_breakpoints = " " .. ICON.breakpoints .. " Breakpoints",
dapui_console = " " .. ICON.terminal .. " Console",
["dap-repl"] = " REPL",
}
vim.api.nvim_create_autocmd("FileType", {
pattern = { "dapui_*", "dap-repl" },
callback = function(args)
-- install keys synchronously (don't defer) so they're ready the moment
-- the buffer is usable. dapui's own `apply_mapping` pass only touches
-- <CR>/o/d/e/r/t; we don't conflict except on d and r which we skip.
if active then M._install_for_buf(args.buf) end
local ft = vim.bo[args.buf].filetype
local title = titles[ft]
vim.schedule(function()
local win = vim.fn.bufwinid(args.buf)
if win ~= -1 then
if title then vim.wo[win].winbar = title end
set_dapui_window_opts(win)
end
end)
end,
})
if config.opts.active_window_highlight then setup_active_win_highlights() end
end
local function ensure_vt()
if vt_initialized then return end
vt_initialized = true
require("nvim-dap-virtual-text").setup(config.opts.virtual_text)
end
-- ─── actions ────────────────────────────────────────────────────────────────
local actions_cache
local visual_actions = { watch = true, hover = true }
local function has_any_breakpoint()
local ok, bps = pcall(require, "dap.breakpoints")
if not ok then return false end
local all = bps.get()
for _, lines in pairs(all or {}) do
if #lines > 0 then return true end
end
return false
end
-- Find a source window (non-dapui, non-repl, non-bar). nvim-dap reads the
-- current buffer's filetype to pick a launch configuration, so when the user
-- presses `c` while focused in a dapui pane (filetype=dapui_scopes, etc.),
-- we need to temporarily borrow a source window's context.
-- NOTE: assigned (not declared) — forward-declared at top of file.
source_window = function()
-- prefer the current window if it's a source
local cur = vim.api.nvim_get_current_win()
local function is_source(w)
if not vim.api.nvim_win_is_valid(w) then return false end
if vim.api.nvim_win_get_config(w).relative ~= "" then return false end
local ft = vim.bo[vim.api.nvim_win_get_buf(w)].filetype or ""
return ft ~= ""
and not ft:match("^dapui_")
and ft ~= "dap-repl"
and ft ~= "TurboDebugBar"
end
if is_source(cur) then return cur end
for _, w in ipairs(vim.api.nvim_list_wins()) do
if is_source(w) then return w end
end
return nil
end
local function run_in_source(fn)
-- We can't use nvim_win_call here: dap.continue() may open a vim.ui.select
-- picker asynchronously, and by the time the picker opens, nvim_win_call
-- has already returned focus to the original window — the picker opens
-- but nothing has focus on it, forcing the user to click in to interact.
-- Actually shift focus to the source window so the picker takes focus
-- properly.
local w = source_window()
if w and w ~= vim.api.nvim_get_current_win() then
pcall(vim.api.nvim_set_current_win, w)
end
return fn()
end
local function launch_with_stop_on_entry()
local dap = require("dap")
local ft = vim.bo.filetype or ""
local cfgs = dap.configurations[ft]
if not (cfgs and cfgs[1]) then
dap.continue()
return
end
local cfg = vim.deepcopy(cfgs[1])
cfg.stopOnEntry = true
cfg.name = (cfg.name or "debug") .. " (stop on entry)"
dap.run(cfg)
end
local function smart_continue()
local dap = require("dap")
if dap.session() then dap.continue(); return end
run_in_source(function()
if config.opts.stop_on_entry_when_no_breakpoints ~= false and not has_any_breakpoint() then
launch_with_stop_on_entry()
else
dap.continue()
end
end)
end
local function step_or_launch(step_fn)
if require("dap").session() then
step_fn()
else
smart_continue()
end
end
local function smart_terminate()
-- q is context-aware: if a session is running, kill it and stay in debug
-- mode so the user can read the console / post-mortem. If no session is
-- running, q exits debug mode entirely.
local dap = require("dap")
if dap.session() then
pcall(dap.terminate)
else
M.exit()
end
end
local function setup_actions()
if actions_cache then return actions_cache end
local dap = require("dap")
local pb = require("persistent-breakpoints.api")
actions_cache = {
continue = smart_continue,
step_over = function() step_or_launch(dap.step_over) end,
step_into = function() step_or_launch(dap.step_into) end,
step_out = function() step_or_launch(dap.step_out) end,
run_to_cursor = function() dap.run_to_cursor() end,
restart = function() dap.restart() end,
terminate = smart_terminate,
help = function()
if help.is_open() then help.close() else help.open() end
end,
breakpoint = function() pb.toggle_breakpoint() end,
cond_breakpoint = function() pb.set_conditional_breakpoint() end,
clear_breakpoints = function() pb.clear_all_breakpoints() end,
watch = function()
local expr
if vim.fn.mode() == "v" or vim.fn.mode() == "V" then
vim.cmd('noautocmd normal! "vy')
expr = vim.fn.getreg("v")
else
expr = vim.fn.expand("<cexpr>")
end
if expr and expr ~= "" then
require("dapui").elements.watches.add(expr)
end
end,
hover = function() require("dapui").eval() end,
eval = function() require("dapui").float_element("repl") end,
}
return actions_cache
end
function M.actions() return actions_cache or setup_actions() end
-- ─── keymap install / clear ─────────────────────────────────────────────────
local function find_buf_mapping(buf, lhs, mode)
for _, m in ipairs(vim.api.nvim_buf_get_keymap(buf, mode)) do
if m.lhs == lhs then return m end
end
return nil
end
local function restore_mapping(buf, mode, map)
local rhs = map.rhs or map.callback
if not rhs then return end
pcall(vim.keymap.set, mode, map.lhs, rhs, {
buffer = buf,
silent = map.silent == 1,
noremap = map.noremap == 1,
expr = map.expr == 1,
desc = map.desc,
})
end
function M._install_for_buf(buf)
if not vim.api.nvim_buf_is_valid(buf) then return end
-- Skip the control-bar buffer entirely — its own <LeftMouse> keymap handles
-- button clicks, and debug keys like `c` would fire accidentally on click.
if vim.bo[buf].filetype == "TurboDebugBar" then return end
local acts = setup_actions()
local ft = vim.bo[buf].filetype or ""
local is_dapui = ft:match("^dapui_") or ft == "dap-repl"
installed[buf] = installed[buf] or {}
for name, key in pairs(config.opts.keys) do
if key and acts[name] then
-- yield d/r to dapui's native remove/repl inside its own panes
if not (is_dapui and (name == "step_into" or name == "step_out")) then
-- Only record prev-mapping on first install; subsequent installs
-- (force-reinstall on focus change) keep the original stash so
-- exit-time restoration still works.
local entry = installed[buf][name]
if not entry then
local prev_n = find_buf_mapping(buf, key, "n")
local prev_v = visual_actions[name] and find_buf_mapping(buf, key, "v") or nil
entry = { key = key, prev_n = prev_n, prev_v = prev_v }
installed[buf][name] = entry
end
local opts = { buffer = buf, silent = true, nowait = true, desc = "turbo-debug: " .. name }
pcall(vim.keymap.set, "n", key, acts[name], opts)
if visual_actions[name] then
pcall(vim.keymap.set, "v", key, acts[name], opts)
end
end
end
end
end
local function clear_all_modal_keys()
for buf, entries in pairs(installed) do
if vim.api.nvim_buf_is_valid(buf) then
for name, entry in pairs(entries) do
pcall(vim.keymap.del, "n", entry.key, { buffer = buf })
if visual_actions[name] then
pcall(vim.keymap.del, "v", entry.key, { buffer = buf })
end
if entry.prev_n then restore_mapping(buf, "n", entry.prev_n) end
if entry.prev_v then restore_mapping(buf, "v", entry.prev_v) end
end
end
end
installed = {}
end
-- ─── source-window debug chrome ─────────────────────────────────────────────
local function set_debug_chrome()
active_win = vim.api.nvim_get_current_win()
saved_winbar = vim.wo[active_win].winbar
-- Source file's winbar is just the filename now — the "DEBUG · STATE"
-- chip lives in the top status bar (sbar). Keeps the source context
-- minimal and leaves more room for filename paths.
vim.wo[active_win].winbar = " %f"
end
local function clear_debug_chrome()
if active_win and vim.api.nvim_win_is_valid(active_win) then
vim.wo[active_win].winbar = saved_winbar or ""
end
active_win = nil
saved_winbar = nil
end
-- ─── public API ─────────────────────────────────────────────────────────────
function M.enter()
if active then return end
active = true
-- Switch colorscheme BEFORE define_highlights so the TurboDebug*
-- highlights derive their colors from the debug-mode theme.
if type(config.opts.colorscheme) == "string" and config.opts.colorscheme ~= ""
and vim.g.colors_name ~= config.opts.colorscheme then
saved_colorscheme = vim.g.colors_name
pcall(vim.cmd.colorscheme, config.opts.colorscheme)
end
define_highlights()
setup_actions()
local group = vim.api.nvim_create_augroup("TurboDebugModalKeys", { clear = true })
vim.api.nvim_create_autocmd("BufEnter", {
group = group,
callback = function(args)
if active then M._install_for_buf(args.buf) end
end,
})
-- Editor resize: recreate any bar that got killed, un-squish any that
-- got shrunk below 2 rows, then re-render. VimResized always changes
-- the dapui area total (since vim.o.lines changed), so we re-pin
-- proportions and update the cached total.
vim.api.nvim_create_autocmd("VimResized", {
group = group,
callback = function()
vim.schedule(function()
if not active then return end
if not (sbar_win and vim.api.nvim_win_is_valid(sbar_win)) then
sbar_win, sbar_buf = nil, nil
end
if not (cbar_win and vim.api.nvim_win_is_valid(cbar_win)) then
cbar_win, cbar_buf = nil, nil
end
open_bars()
ensure_bar_heights()
pin_dapui_sizes()
last_dapui_total_height = compute_dapui_total_height()
render_bars()
end)
end,
})
-- WinResized fires on any geometry change: mouse-drags on split
-- borders, bufferline's tabline appearing/disappearing (which
-- reclaims/returns a row and can squish our bars), `wincmd =`, etc.
-- Two separate concerns handled here:
-- 1. Bars squished / killed → recreate + force heights.
-- 2. Total dapui area changed (tabline toggle) → re-pin proportions
-- so no pane ends up taller than its buffer (EOB `~` bug).
-- We distinguish case 2 from a user-internal resize (dragging the
-- Scopes/Watches border) by comparing the TOTAL dapui height against
-- last_dapui_total_height. User-internal resizes redistribute within
-- the total; they don't change it — so their customization survives.
vim.api.nvim_create_autocmd("WinResized", {
group = group,
callback = function()
if not active then return end
vim.schedule(function()
if not (sbar_win and vim.api.nvim_win_is_valid(sbar_win)) then
sbar_win, sbar_buf = nil, nil
end
if not (cbar_win and vim.api.nvim_win_is_valid(cbar_win)) then
cbar_win, cbar_buf = nil, nil
end
if not (sbar_win and cbar_win) then
open_bars()
end
ensure_bar_heights()
local dapui_total = compute_dapui_total_height()
if last_dapui_total_height ~= nil
and dapui_total ~= last_dapui_total_height then
pin_dapui_sizes()
dapui_total = compute_dapui_total_height()
end
last_dapui_total_height = dapui_total
render_bars()
pcall(vim.cmd, "redraw!")
end)
end,
})
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buf) then M._install_for_buf(buf) end
end
set_debug_chrome()
ensure_dapui()
-- { reset = true } is critical: without it, dapui's WindowLayout:resize
-- uses whatever `win_state.size` drifted to last time (see
-- dapui/windows/layout.lua:82-116 — `update_sizes` writes current
-- ratios back to state, so sizes accumulate drift across close/open
-- cycles). `reset` tells it to ignore cached state and use init_size
-- (our original 0.25 per pane).
require("dapui").open({ reset = true })
open_bars()
pin_dapui_sizes()
-- second install pass after dapui has created its buffers
vim.schedule(function()
if not active then return end
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buf) then M._install_for_buf(buf) end
end
pin_dapui_sizes()
render_bars()
-- Seed the dapui-area baseline AFTER the final layout settles, so
-- the first WinResized (e.g. bufferline tabline appearing) sees a
-- valid reference point to compare against.
last_dapui_total_height = compute_dapui_total_height()
end)
ensure_vt()
require("nvim-dap-virtual-text").enable()
if config.opts.help_on_enter then help.open() end
end
function M.exit()
if not active then return end
active = false
if require("dap").session() then pcall(require("dap").terminate) end
pcall(vim.api.nvim_del_augroup_by_name, "TurboDebugModalKeys")
clear_all_modal_keys()
clear_debug_chrome()
help.close()
-- Close dapui FIRST so dapui's windows tear down into a state we
-- control. Closing our bars first would let dapui's remaining panes
-- expand into the freed rows, which dapui then "remembers" and
-- breaks the next open's layout.
if dapui_initialized then require("dapui").close() end
close_bars()
if vt_initialized then require("nvim-dap-virtual-text").disable() end
last_dapui_total_height = nil
if saved_winsep_hl ~= nil then
pcall(vim.api.nvim_set_hl, 0, "WinSeparator", saved_winsep_hl)
saved_winsep_hl = nil
end
if saved_colorscheme ~= nil and saved_colorscheme ~= "" then
pcall(vim.cmd.colorscheme, saved_colorscheme)
saved_colorscheme = nil
end
end
function M.toggle()
if active then M.exit() else M.enter() end
end
function M.is_active() return active end
return M