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`.
This commit is contained in:
swaits committed 2026-04-12 17:38:20 -06:00
1 parent acc698ad80
commit 98c943885a
2 files changed
+106 -33

No files matched your search

+6
View File
@@ -62,6 +62,12 @@ M.defaults = {
-- sidebar position: "left" (default) or "right"
sidebar = "left",
-- Optional colorscheme to switch to while debug mode is active.
-- `nil` (default) keeps the user's current colorscheme. Any other
-- string value is passed directly to `:colorscheme`. On M.exit the
-- previous colorscheme is restored.
colorscheme = nil,
-- highlight the active dapui pane's border/separator vs. dim inactive panes
active_window_highlight = true,
+100 -33
View File
@@ -43,6 +43,13 @@ local last_dapui_total_height = nil
-- 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
@@ -306,29 +313,53 @@ 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 type(k) == "string" then return k end
if k == false then return nil end
if type(k) == "string" and k ~= "" then return k end
return fallback
end
-- Build a label's byte-level representation.
-- Format: "<icon> (<key>)<tail>[ <italic>]"
-- e.g. ` (s)tep over` with `over` styled italic.
-- 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, key, tail, italic)
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(key)
add(actual_key)
local key_end_col = col()
add(")")
add(tail or "")
if inline then
add(label:sub(2))
else
add(" ")
add(label)
end
local italic_col, italic_end_col
if italic and italic ~= "" then
add(" ")
@@ -418,45 +449,56 @@ local function render_cbar()
local help_text = ICON.help .. " help "
local help_dw = vim.fn.strdisplaywidth(help_text)
-- Modal control labels. State-dependent text so the label shows what
-- 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
-- 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_tail = has_session and "ontinue" or " start"
local quit_tail = has_session and " terminate" or "uit"
local continue_label = has_session and "continue" or "start"
local terminate_label = has_session and "terminate" or "quit"
local controls = {
{ icon = continue_icon, key = keyof("continue", "c"), tail = continue_tail, italic = nil, fn = action("continue") },
{ icon = ICON.step_over, key = keyof("step_over", "s"), tail = "tep", italic = "over", fn = action("step_over") },
{ icon = ICON.step_into, key = keyof("step_into", "d"), tail = "escend", italic = "into", fn = action("step_into") },
{ icon = ICON.step_out, key = keyof("step_out", "r"), tail = "eturn", italic = "out", fn = action("step_out") },
{ 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] = { icon = ICON.restart, key = keyof("restart", "R"), tail = "estart", italic = nil, fn = action("restart") }
controls[#controls + 1] = { id = "restart", icon = ICON.restart, default = "R", label = "restart", italic = nil, fn = action("restart") }
end
controls[#controls + 1] = { icon = ICON.stop, key = keyof("terminate", "q"), tail = quit_tail, italic = nil, fn = action("terminate") }
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 = {}, {}, {}, {}, {}
for i, c in ipairs(controls) do
local prefix_len = #table.concat(parts)
if i > 1 then
parts[#parts + 1] = gap
prefix_len = prefix_len + #gap
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
local italic_text = include_italic and c.italic or nil
local txt, kcol, kend, icol, iend = build_control_text(c.icon, c.key, c.tail, 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
return table.concat(parts), spans, key_spans, italic_spans, zones
end
@@ -896,14 +938,28 @@ local function ensure_dapui()
end
end
-- re-render the control bar on any dap state change so the READY/RUNNING/
-- 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
@@ -1275,6 +1331,13 @@ end
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()
@@ -1405,6 +1468,10 @@ function M.exit()
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()