From 8d8dd23b3abdd09c7b1f0880cd224aa0fc98daeb Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sun, 12 Apr 2026 12:22:00 -0600 Subject: [PATCH] feat: floating control bar, throttled console, wrapped values, Codicon icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major v3 fixes driven by live-debugging feedback: - Control bar: replace vim.o.statusline override (lualine fights it) with a 1-row floating window pinned above the statusline. Shows state chip (READY/RUNNING/PAUSED) + seven clickable Codicon controls + dap.status(). Survives resize via VimResized autocmd. Fully independent of lualine. - Console refresh: trailing-edge throttle at config.console_refresh_ms (default 50ms = 20 Hz ceiling). Targets console windows via nvim_win_call + redraw! to avoid screen-cache miss. Chatty programs can't DOS the editor. - Modal keys in dapui panes: install synchronously on FileType (drop inner vim.schedule); all modal keys get nowait=true (was continue-only) so single-key presses aren't swallowed by d* prefix matching. - Wrap in dapui panes: wrap=true + breakindent + showbreak=" โ†ณ " so long register values stay visible inside the pane without bleeding into the source window. Safety valve at config.max_value_width (default 2000, set 0 to disable) truncates only pathological multi-KB dumps with "โ€ฆ". - Smart q: context-aware terminate. If a session is running, q calls dap.terminate() and stays in debug mode (so user can read the console postmortem). If no session, q exits debug mode. Removes quit_exits_mode. - Icons: full Nerd Font Codicon set for signs, control bar buttons, pane titles, and debug badge. ๐Ÿ‘ˆ emoji in the paused-line vtext pairs with ๐Ÿ‘‰ in the gutter โ€” fingers literally point at the paused line from both sides. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 67 +++++-- lua/turbo-debug/breakpoints.lua | 28 +-- lua/turbo-debug/config.lua | 12 +- lua/turbo-debug/mode.lua | 304 +++++++++++++++++++++++--------- tests/test_config.lua | 10 +- 5 files changed, 306 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 805a5c6..8d7a134 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,13 @@ During debug mode, single-key controls: | `d` | Step into | `D` | Clear all breakpoints | | `r` | Step out | `W` | Watch expression | | `C` | Run to cursor | `K` | Hover / inspect | -| `q` | Terminate session | `E` | Eval in REPL | +| `q` | Smart quit (terminate โ†’ exit) | `E` | Eval in REPL | | `R` | Restart session | `?` | Help popup | +`q` is context-aware: if a session is running, it terminates the session +and leaves you in debug mode (so you can read the console and inspect +the exit state). If no session is running, `q` exits debug mode entirely. + Press `?` during debug mode for a quick reference that also lists dap-ui's native in-pane bindings (``, `e`, `o`, `d`, `r`, `t`). @@ -86,23 +90,64 @@ Every key is user-overridable. Remap, disable, or add bindings via ```lua require("turbo-debug").setup({ keys = { - step_over = "n", -- remap a modal key - watch = false, -- disable a modal key + step_over = "n", -- remap a modal key + watch = false, -- disable a modal key }, global_keys = { - toggle = "D", -- change the toggle chord + toggle = "D", -- change the toggle chord }, - sidebar = "right", -- "left" (default) or "right" - help_on_enter = false, -- disable the help splash - quit_exits_mode = false, -- q terminates but stays in debug mode + sidebar = "right", -- "left" (default) or "right" + help_on_enter = false, -- disable the help splash + + -- Auto stop-on-entry when no breakpoints are set (Turbo Pascal model): + -- press `c` on an empty-BP project and the debugger pauses at main(). + -- Set false to always run until a breakpoint instead. + stop_on_entry_when_no_breakpoints = true, + + -- Cap per-line value length in Scopes/Watches. Values longer than this + -- get truncated with `โ€ฆ`. Default 2000 only catches pathological register + -- dumps. Set 0 to disable truncation entirely. + max_value_width = 2000, + + -- Console repaint throttle (ms). Max 1000/console_refresh_ms redraws per + -- second when the debugged program spams stdout. Prevents editor DOS. + console_refresh_ms = 50, }) ``` -### Statusline integration +### Control bar -If you want a live debug indicator in your statusline, add -`require("dap").status()` as a lualine/heirline component โ€” it returns a -progress message while debugging and an empty string when idle. +When debug mode is active a 1-row floating bar appears directly above your +statusline, showing: + +- `โ— READY` / `โ— RUNNING` / `โ— PAUSED` session state chip (color-coded) +- Clickable controls: continue `โ–ถ`, step over, step into, step out, + restart, terminate, exit debug mode +- `dap.status()` progress text (right-aligned) +- Cursor position + +The bar is completely independent of lualine, heirline, or any other +statusline plugin โ€” it opens in its own floating window and closes cleanly +when debug mode exits. + +### Design notes + +- **Rounded borders** are on every *floating* window this plugin opens: + the help popup (`?`), the hover popup (`K`), and the debug-eval popup + (`E`). Floating windows are the only windows Neovim draws borders around + in the terminal grid. Splits (the Scopes/Watches/Stacks/Breakpoints/ + REPL/Console panes) use the user's `WinSeparator` character โ€” that's a + terminal-rendering limit, not a plugin choice. + +- **No hardcoded colors.** Every highlight links to a standard group + (`DiagnosticError`, `Function`, `Comment`, `CursorLine`, `Visual`, + `Search`, `Title`, `Special`, etc.) so the plugin automatically inherits + your colorscheme on the fly. Switch themes mid-session and everything + re-paints. + +- **Nerd Font Codicons** for all functional iconography (gutter signs, + control buttons, pane titles). Emoji (`๐Ÿ‘ˆ` / `๐Ÿ‘‰`) for the paused-line + arrows, because fingers-pointing-at-the-line is unmissable. ## Supported Languages diff --git a/lua/turbo-debug/breakpoints.lua b/lua/turbo-debug/breakpoints.lua index f14ba50..d7adb72 100644 --- a/lua/turbo-debug/breakpoints.lua +++ b/lua/turbo-debug/breakpoints.lua @@ -21,19 +21,21 @@ function M.setup() vim.api.nvim_set_hl(0, "DapStoppedLine", { default = true, link = "Search" }) vim.api.nvim_set_hl(0, "DapLogPoint", { default = true, link = "DiagnosticInfo" }) - -- gutter signs: Unicode geometric symbols via byte escapes so they survive - -- any tool-pipeline that strips PUA/Nerd-Font codepoints. They render in - -- any monospace font, Nerd Font required. - -- โ— U+25CF (solid circle) = breakpoint - -- โ—† U+25C6 (solid diamond) = conditional breakpoint - -- โ—‹ U+25CB (hollow circle) = rejected breakpoint - -- โ–ถ U+25B6 (right triangle) = current execution line (IP) - -- โœŽ U+270E (pencil) = log breakpoint - vim.fn.sign_define("DapBreakpoint", { text = "\xe2\x97\x8f", texthl = "DapBreakpoint", numhl = "DapBreakpoint" }) - vim.fn.sign_define("DapBreakpointCondition", { text = "\xe2\x97\x86", texthl = "DapBreakpointCondition", numhl = "DapBreakpointCondition" }) - vim.fn.sign_define("DapBreakpointRejected", { text = "\xe2\x97\x8b", texthl = "DapBreakpointRejected", numhl = "DapBreakpointRejected" }) - vim.fn.sign_define("DapStopped", { text = "\xe2\x96\xb6", texthl = "DapStopped", linehl = "DapStoppedLine", numhl = "DapStopped" }) - vim.fn.sign_define("DapLogPoint", { text = "\xe2\x9c\x8e", texthl = "DapLogPoint", numhl = "DapLogPoint" }) + -- Gutter signs: Nerd Font Codicons (byte-escaped for tool-pipeline safety). + -- The DapStopped IP sign is an emoji ๐Ÿ‘‰ (backhand index pointing right, + -- U+1F449) that literally points AT the paused line from the left. Pairs + -- with the ๐Ÿ‘ˆ emoji in the EOL virtual text (mode.lua) โ€” both fingers + -- point at the current line from opposite sides, unmistakable. + -- + -- nf-cod-debug-breakpoint = U+EA71 "" + -- nf-cod-debug-breakpoint-conditional = U+EA9F "" + -- nf-cod-circle-slash = U+EABE "" + -- nf-cod-debug-breakpoint-log = U+EBE4 "" + vim.fn.sign_define("DapBreakpoint", { text = "\xee\xa9\xb1", texthl = "DapBreakpoint", numhl = "DapBreakpoint" }) + vim.fn.sign_define("DapBreakpointCondition", { text = "\xee\xaa\x9f", texthl = "DapBreakpointCondition", numhl = "DapBreakpointCondition" }) + vim.fn.sign_define("DapBreakpointRejected", { text = "\xee\xaa\xbe", texthl = "DapBreakpointRejected", numhl = "DapBreakpointRejected" }) + vim.fn.sign_define("DapStopped", { text = "\xf0\x9f\x91\x89", texthl = "DapStopped", linehl = "DapStoppedLine", numhl = "DapStopped" }) + vim.fn.sign_define("DapLogPoint", { text = "\xee\xaf\xa4", texthl = "DapLogPoint", numhl = "DapLogPoint" }) -- unobtrusive inline virtual text: italic, comment-colored vim.api.nvim_set_hl(0, "NvimDapVirtualText", { default = true, link = "Comment", italic = true }) diff --git a/lua/turbo-debug/config.lua b/lua/turbo-debug/config.lua index 7b42753..cef70e3 100644 --- a/lua/turbo-debug/config.lua +++ b/lua/turbo-debug/config.lua @@ -3,13 +3,23 @@ local M = {} M.defaults = { help_on_enter = true, builtin_adapters = true, - quit_exits_mode = true, -- q terminates AND exits debug mode (false = terminate only) + -- When `c` is pressed with no breakpoints set, launch the first adapter -- configuration for the filetype with stopOnEntry so the debugger pauses -- at the program's entry point. Matches the Turbo Pascal "press key to -- start debugging" model. Set false to always run until a breakpoint. stop_on_entry_when_no_breakpoints = true, + -- Maximum per-line character width for variable values rendered in dapui + -- panes. Anything over this gets truncated with `โ€ฆ`. Set to 0 to disable. + -- Default 2000 only catches pathological multi-KB register/memory dumps; + -- normal values pass through and wrap naturally inside the pane. + max_value_width = 2000, + + -- Throttle window (ms) for console repaints when the debugged program + -- outputs. Trailing-edge โ€” max 1000/console_refresh_ms redraws per second. + console_refresh_ms = 50, + -- modal keys (active only during debug mode, single-key) keys = { continue = "c", diff --git a/lua/turbo-debug/mode.lua b/lua/turbo-debug/mode.lua index 20a58bf..97d6ff3 100644 --- a/lua/turbo-debug/mode.lua +++ b/lua/turbo-debug/mode.lua @@ -6,8 +6,9 @@ local M = {} local active = false local active_win = nil local saved_winbar = nil -local saved_statusline = nil -local saved_laststatus = nil + +-- floating control-bar window (independent of lualine) +local bar_win, bar_buf = nil, nil -- installed[buf][name] = { key = lhs, prev_n = , prev_v = } local installed = {} @@ -15,6 +16,47 @@ local installed = {} 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 = { + -- controls (match dapui's own controls-panel defaults) + play = "\xee\xab\x98", -- U+EAD8 debug-start + pause = "\xee\xab\x99", -- U+EAD9 debug-pause + step_over = "\xee\xab\x96", -- U+EAD6 debug-step-over + step_into = "\xee\xab\x95", -- U+EAD5 debug-step-into + step_out = "\xee\xab\x94", -- U+EAD4 debug-step-out + step_back = "\xee\xac\xb4", -- U+EB34 debug-step-back + restart = "\xee\xad\x84", -- U+EB44 debug-restart + stop = "\xee\xab\x97", -- U+EAD7 debug-stop + disconnect = "\xee\xab\xb7", -- U+EAF7 debug-disconnect + close = "\xee\xa9\xb6", -- U+EA76 close + + -- signs + breakpoint = "\xee\xa9\xb1", -- U+EA71 debug-breakpoint + bp_cond = "\xee\xaa\x9f", -- U+EA9F debug-breakpoint-conditional + bp_rej = "\xee\xaa\xbe", -- U+EABE circle-slash + stackframe = "\xee\xae\x8b", -- U+EB8B debug-stackframe + logpoint = "\xee\xaf\xa4", -- U+EBE4 debug-breakpoint-log + + -- pane titles + scopes = "\xee\xaa\x8f", -- U+EA8F variable-group + watches = "\xee\xa9\xb0", -- U+EA70 eye + stacks = "\xee\xae\x83", -- U+EB83 list-tree + terminal = "\xee\xaa\x85", -- U+EA85 terminal + chevron = "\xee\xaa\xb6", -- U+EAB6 chevron-right + + -- debug badge (bug-in-circle) + debug_alt = "\xee\xae\x91", -- U+EB91 debug-alt + + -- misc + ip_left = "\xf0\x9f\x91\x88", -- ๐Ÿ‘ˆ backhand index pointing left (U+1F448) + divider = "\xc2\xb7", -- ยท middle dot + ellipsis = "\xe2\x80\xa6", -- โ€ฆ horizontal ellipsis + wrap_mark = "\xe2\x86\xb3", -- โ†ณ downward-arrow-with-tip-rightwards +} + -- โ”€โ”€โ”€ layout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ local function build_default_layouts() @@ -48,9 +90,12 @@ local function define_highlights() hl(0, "TurboDebugBorderActive", { default = true, link = "Function" }) hl(0, "TurboDebugBorderInactive", { default = true, link = "Comment" }) hl(0, "TurboDebugWinbar", { default = true, link = "DiagnosticError" }) - hl(0, "TurboDebugSLState", { default = true, link = "DiagnosticError" }) - hl(0, "TurboDebugSLCtrl", { default = true, link = "Function" }) - hl(0, "TurboDebugSLStatus", { default = true, link = "Comment" }) + hl(0, "TurboDebugBar", { default = true, link = "StatusLine" }) + hl(0, "TurboDebugBarCtrl", { default = true, link = "Function" }) + hl(0, "TurboDebugBarStatus", { default = true, link = "Comment" }) + hl(0, "TurboDebugBarReady", { default = true, link = "DiagnosticHint" }) + hl(0, "TurboDebugBarRunning", { default = true, link = "DiagnosticInfo" }) + hl(0, "TurboDebugBarPaused", { default = true, link = "DiagnosticError" }) end local function setup_active_win_highlights() @@ -76,79 +121,168 @@ local function setup_active_win_highlights() }) end --- โ”€โ”€โ”€ global control buttons (used by the statusline) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +-- โ”€โ”€โ”€ global control buttons (referenced by bar winbar expression) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ _G.turbo_debug_continue = function() M.actions().continue() end _G.turbo_debug_step_over = function() require("dap").step_over() end _G.turbo_debug_step_into = function() require("dap").step_into() end _G.turbo_debug_step_out = function() require("dap").step_out() end _G.turbo_debug_restart = function() require("dap").restart() end -_G.turbo_debug_terminate = function() require("dap").terminate() end +_G.turbo_debug_terminate = function() M.actions().terminate() end _G.turbo_debug_toggle = function() M.toggle() end -function _G.turbo_debug_statusline() +function _G.turbo_debug_bar() local dap_ok, dap = pcall(require, "dap") local state, state_hl if not dap_ok then - state, state_hl = "READY", "TurboDebugSLStatus" + state, state_hl = "READY", "TurboDebugBarReady" else local s = dap.session() if not s then - state, state_hl = "READY", "TurboDebugSLStatus" + state, state_hl = "READY", "TurboDebugBarReady" elseif s.stopped_thread_id then - state, state_hl = "PAUSED", "TurboDebugSLState" + state, state_hl = "PAUSED", "TurboDebugBarPaused" else - state, state_hl = "RUNNING", "TurboDebugSLCtrl" + state, state_hl = "RUNNING", "TurboDebugBarRunning" end end - -- โ— state, then 6 clickable controls, then dap progress text (right-aligned) local parts = { - "%#" .. state_hl .. "# \xe2\x97\x8f " .. state .. " %*", + "%#" .. state_hl .. "# " .. ICON.debug_alt .. " " .. state .. " %*", " ", - "%#TurboDebugSLCtrl#", - "%@v:lua.turbo_debug_continue@ \xe2\x96\xb6 %X", -- โ–ถ continue - "%@v:lua.turbo_debug_step_over@ \xe2\x87\x92 %X", -- โ‡’ step over - "%@v:lua.turbo_debug_step_into@ \xe2\xa4\x93 %X", -- โค“ step into - "%@v:lua.turbo_debug_step_out@ \xe2\xa4\x92 %X", -- โค’ step out - "%@v:lua.turbo_debug_restart@ \xe2\x86\xbb %X", -- โ†ป restart - "%@v:lua.turbo_debug_terminate@ \xe2\x96\xa0 %X", -- โ–  terminate - "%@v:lua.turbo_debug_toggle@ \xe2\x8a\x97 %X", -- โŠ— exit debug mode + "%#TurboDebugBarCtrl#", + "%@v:lua.turbo_debug_continue@ " .. ICON.play .. " %X", + "%@v:lua.turbo_debug_step_over@ " .. ICON.step_over .. " %X", + "%@v:lua.turbo_debug_step_into@ " .. ICON.step_into .. " %X", + "%@v:lua.turbo_debug_step_out@ " .. ICON.step_out .. " %X", + "%@v:lua.turbo_debug_restart@ " .. ICON.restart .. " %X", + "%@v:lua.turbo_debug_terminate@ " .. ICON.stop .. " %X", + "%@v:lua.turbo_debug_toggle@ " .. ICON.close .. " %X", "%*", " %=", - "%#TurboDebugSLStatus#%{v:lua.require'dap'.status()}%*", + "%#TurboDebugBarStatus#%{v:lua.require'dap'.status()}%*", " %l:%c ", } return table.concat(parts, "") end +-- โ”€โ”€โ”€ floating control bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +local function bar_position() + -- sit one row above the statusline; statusline is at (lines - cmdheight - 1). + -- Our bar goes at (lines - cmdheight - 2). If laststatus == 0 there's no + -- statusline, so sit at the bottom (lines - cmdheight - 1). + local row + if vim.o.laststatus > 0 then + row = vim.o.lines - vim.o.cmdheight - 2 + else + row = vim.o.lines - vim.o.cmdheight - 1 + end + if row < 0 then row = 0 end + return { + relative = "editor", + row = row, + col = 0, + width = vim.o.columns, + height = 1, + style = "minimal", + border = "none", + focusable = false, + noautocmd = true, + zindex = 50, + } +end + +local function open_bar() + if bar_win and vim.api.nvim_win_is_valid(bar_win) then return end + bar_buf = vim.api.nvim_create_buf(false, true) + vim.bo[bar_buf].bufhidden = "wipe" + bar_win = vim.api.nvim_open_win(bar_buf, false, bar_position()) + vim.wo[bar_win].winbar = "%!v:lua.turbo_debug_bar()" + vim.wo[bar_win].winhighlight = "Normal:TurboDebugBar,WinBar:TurboDebugBar,WinBarNC:TurboDebugBar" + vim.wo[bar_win].winfixheight = true + vim.wo[bar_win].list = false + vim.wo[bar_win].number = false + vim.wo[bar_win].relativenumber = false + vim.wo[bar_win].signcolumn = "no" +end + +local function close_bar() + if bar_win and vim.api.nvim_win_is_valid(bar_win) then + pcall(vim.api.nvim_win_close, bar_win, true) + end + bar_win, bar_buf = nil, nil +end + +local function reposition_bar() + if bar_win and vim.api.nvim_win_is_valid(bar_win) then + pcall(vim.api.nvim_win_set_config, bar_win, bar_position()) + end +end + -- โ”€โ”€โ”€ dap-ui setup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ local function set_dapui_window_opts(win) - -- dapui elements render long single-line values (e.g. register dumps) that - -- bleed into adjacent windows when wrap is on. Force-disable wrap and - -- horizontal-scroll padding so values stay inside the pane. + -- 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. if vim.api.nvim_win_is_valid(win) then - vim.wo[win].wrap = false + 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].linebreak = false - vim.wo[win].cursorline = true + vim.wo[win].cursorline = true 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() 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 (works globally, not just modally) + -- 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" }) @@ -158,18 +292,11 @@ local function ensure_dapui() pcall(vim.keymap.del, "n", "K") end - -- Console redraws: dapui's console buffer updates on event_output but - -- nvim doesn't always repaint the window until focus changes. Force a - -- schedule-wrapped redraw so output appears live. - dap.listeners.after.event_output["turbo-debug-redraw"] = vim.schedule_wrap(function() - pcall(vim.cmd.redraw) - 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-arrow design. - -- gutter: โ–ถ (points RIGHT at the line from the sign column) - -- eol: โ—€ (points LEFT at the line from the end of content) - -- The uppercase reason (STEP / BREAKPOINT / EXCEPTION / PAUSE / ENTRY) - -- renders in bold TurboDebugIPReason so it jumps off the screen. + -- IP marker: paired arrows. Gutter sign โ–ถ + EOL vtext โ—€ REASON flanking the + -- 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 @@ -189,8 +316,8 @@ local function ensure_dapui() local reason = ((body and body.reason) or "stopped"):upper() pcall(vim.api.nvim_buf_set_extmark, buf, ip_ns, line, 0, { virt_text = { - { " \xe2\x97\x80 ", "DapStopped" }, -- โ—€ - { reason, "TurboDebugIPReason" }, + { " " .. ICON.ip_left .. " ", "DapStopped" }, + { reason, "TurboDebugIPReason" }, }, virt_text_pos = "eol", hl_mode = "combine", @@ -202,19 +329,22 @@ local function ensure_dapui() dap.listeners.before.event_terminated["turbo-debug-ip"] = function() clear_ip() end dap.listeners.before.event_exited["turbo-debug-ip"] = function() clear_ip() end - -- dapui winbar titles + window options (wrap=false to stop the register - -- overflow bug). Apply on FileType so we catch every pane dapui creates. + -- winbar titles on dapui panes (Codicons, byte-escaped) local titles = { - dapui_scopes = " \xe2\x97\x86 Scopes", - dapui_watches = " \xe2\x97\x89 Watches", - dapui_stacks = " \xe2\x89\xa1 Call Stack", - dapui_breakpoints = " \xe2\x97\x8f Breakpoints", - dapui_console = " \xe2\x9d\xaf Console", - ["dap-repl"] = " \xe2\x9d\xaf_ REPL", + dapui_scopes = " " .. ICON.scopes .. " Scopes", + dapui_watches = " " .. ICON.watches .. " Watches", + dapui_stacks = " " .. ICON.stacks .. " Call Stack", + dapui_breakpoints = " " .. ICON.breakpoint .. " Breakpoints", + dapui_console = " " .. ICON.terminal .. " Console", + ["dap-repl"] = " " .. ICON.chevron .. " 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 + -- /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() @@ -223,10 +353,6 @@ local function ensure_dapui() if title then vim.wo[win].winbar = title end set_dapui_window_opts(win) end - -- also (re-)install modal keys on this dapui buffer โ€” BufEnter may - -- not fire at create time and we want the keys ready the moment - -- the user clicks in. - if active then M._install_for_buf(args.buf) end end) end, }) @@ -260,7 +386,6 @@ local function launch_with_stop_on_entry() local ft = vim.bo.filetype or "" local cfgs = dap.configurations[ft] if not (cfgs and cfgs[1]) then - -- no config โ†’ fall back to normal continue (which will prompt / error) dap.continue() return end @@ -281,8 +406,6 @@ local function smart_continue() end local function step_or_launch(step_fn) - -- if no session yet, a step key starts the session with stop-on-entry โ€” - -- matches the Turbo Pascal "just press a key to start stepping" vibe. if require("dap").session() then step_fn() else @@ -290,6 +413,18 @@ local function step_or_launch(step_fn) 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") @@ -302,10 +437,7 @@ local function setup_actions() 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 = function() - dap.terminate() - if config.opts.quit_exits_mode then M.exit() end - end, + terminate = smart_terminate, help = function() if help.is_open() then help.close() else help.open() end end, @@ -356,18 +488,22 @@ end function M._install_for_buf(buf) if not vim.api.nvim_buf_is_valid(buf) 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] and not installed[buf][name] 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 - local opts = { buffer = buf, silent = true, desc = "turbo-debug: " .. name } - if name == "continue" then opts.nowait = true end - pcall(vim.keymap.set, "n", key, acts[name], opts) - if visual_actions[name] then - pcall(vim.keymap.set, "v", key, acts[name], opts) + -- 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 + local prev_n = find_buf_mapping(buf, key, "n") + local prev_v = visual_actions[name] and find_buf_mapping(buf, key, "v") or nil + 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 + installed[buf][name] = { key = key, prev_n = prev_n, prev_v = prev_v } end - installed[buf][name] = { key = key, prev_n = prev_n, prev_v = prev_v } end end end @@ -388,17 +524,12 @@ local function clear_all_modal_keys() installed = {} end --- โ”€โ”€โ”€ chrome: per-source-window winbar badge + global statusline controls โ”€โ”€ +-- โ”€โ”€โ”€ source-window debug chrome โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ local function set_debug_chrome() active_win = vim.api.nvim_get_current_win() saved_winbar = vim.wo[active_win].winbar - vim.wo[active_win].winbar = "%#TurboDebugWinbar# \xe2\x97\x8f DEBUG %* %f" - - saved_statusline = vim.o.statusline - saved_laststatus = vim.o.laststatus - vim.o.laststatus = 3 -- one global statusline - vim.o.statusline = "%!v:lua.turbo_debug_statusline()" + vim.wo[active_win].winbar = "%#TurboDebugWinbar# " .. ICON.debug_alt .. " DEBUG %* " .. ICON.divider .. " %f" end local function clear_debug_chrome() @@ -407,11 +538,6 @@ local function clear_debug_chrome() end active_win = nil saved_winbar = nil - - if saved_statusline ~= nil then vim.o.statusline = saved_statusline end - if saved_laststatus ~= nil then vim.o.laststatus = saved_laststatus end - saved_statusline = nil - saved_laststatus = nil end -- โ”€โ”€โ”€ public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -422,13 +548,17 @@ function M.enter() define_highlights() setup_actions() - vim.api.nvim_create_augroup("TurboDebugModalKeys", { clear = true }) + local group = vim.api.nvim_create_augroup("TurboDebugModalKeys", { clear = true }) vim.api.nvim_create_autocmd("BufEnter", { - group = "TurboDebugModalKeys", + group = group, callback = function(args) if active then M._install_for_buf(args.buf) end end, }) + vim.api.nvim_create_autocmd("VimResized", { + group = group, + callback = reposition_bar, + }) 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 @@ -436,14 +566,15 @@ function M.enter() set_debug_chrome() ensure_dapui() require("dapui").open() + open_bar() - -- second install pass after dapui has created its buffers, since BufEnter - -- doesn't always fire for windows that were opened without focus-change. + -- 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 + reposition_bar() end) ensure_vt() @@ -461,6 +592,7 @@ function M.exit() pcall(vim.api.nvim_del_augroup_by_name, "TurboDebugModalKeys") clear_all_modal_keys() clear_debug_chrome() + close_bar() help.close() if dapui_initialized then require("dapui").close() end diff --git a/tests/test_config.lua b/tests/test_config.lua index cc3626a..47ba192 100644 --- a/tests/test_config.lua +++ b/tests/test_config.lua @@ -8,7 +8,9 @@ assert_eq(opts.keys.breakpoint, "x", "default modal breakpoint key") assert_eq(opts.global_keys.toggle, "dd", "default toggle key") assert_eq(opts.global_keys.breakpoint, "dx", "default global breakpoint key") assert_eq(opts.help_on_enter, true, "default help_on_enter") -assert_eq(opts.quit_exits_mode, true, "default quit_exits_mode") +assert_eq(opts.stop_on_entry_when_no_breakpoints, true, "default stop_on_entry_when_no_breakpoints") +assert_eq(opts.max_value_width, 2000, "default max_value_width") +assert_eq(opts.console_refresh_ms, 50, "default console_refresh_ms") -- partial key override opts = config.merge({ keys = { continue = "g" } }) @@ -24,9 +26,9 @@ opts = config.merge({ global_keys = { toggle = "D" } }) assert_eq(opts.global_keys.toggle, "D", "overridden toggle key") assert_eq(opts.global_keys.breakpoint, "dx", "preserved breakpoint key") --- quit_exits_mode override -opts = config.merge({ quit_exits_mode = false }) -assert_eq(opts.quit_exits_mode, false, "overridden quit_exits_mode") +-- max_value_width override +opts = config.merge({ max_value_width = 0 }) +assert_eq(opts.max_value_width, 0, "overridden max_value_width") -- merge doesn't mutate defaults config.merge({ keys = { continue = "z" } })