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_v = } 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. -- stylua: ignore start 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 } -- stylua: ignore end -- ─── 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 local function install_cs_listener(name, fn) fn() vim.api.nvim_create_autocmd("ColorScheme", { group = vim.api.nvim_create_augroup(name, { clear = true }), callback = fn, }) end install_cs_listener("TurboDebugBarItalicHL", 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 install_cs_listener("TurboDebugBarKeyHL", 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 install_cs_listener("TurboDebugBarSepHL", 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 keymap + -- per-zone display-column lookup. local bar_ns = vim.api.nvim_create_namespace("turbo-debug.bar") -- ─── small helpers ────────────────────────────────────────────────────────── local function win_ok(w) return w and vim.api.nvim_win_is_valid(w) end local function buf_ok(b) return b and vim.api.nvim_buf_is_valid(b) end -- Exact dapui pane filetypes. Different from the pattern `dapui_*` used in -- setup_active_win_highlights / the FileType autocmd, and different from -- schedule_console_redraw's `dapui_console` single-check — don't unify. local DAPUI_PANE_FTS = { dapui_stacks = true, dapui_scopes = true, dapui_watches = true, dapui_breakpoints = true, dapui_console = true, ["dap-repl"] = true, } local function is_dapui_pane_ft(ft) return ft ~= nil and DAPUI_PANE_FTS[ft] == true end -- INTENTIONAL ASYMMETRY: callbacks do their own per-window filtering. -- compute_dapui_total_height filters floats (relative==""); pin_dapui_sizes -- does not. Don't hoist the filter into this iterator. local function for_each_dapui_pane(cb) 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 is_dapui_pane_ft(ft) then cb(buf, ft) end end end end -- Bar extmarks go through bar_ns + pcall. IP marker uses ip_ns separately. local function safe_extmark(buf, row, col, opts) pcall(vim.api.nvim_buf_set_extmark, buf, bar_ns, row, col, opts) end local function bar_set_lines(buf, lines) vim.bo[buf].modifiable = true vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) vim.bo[buf].modifiable = false end local function safe_set_height(w, h) pcall(vim.api.nvim_win_set_height, w, h) end -- Defer fn via vim.schedule; re-check `active` at fire time so the body -- doesn't run after M.exit cleared state. local function if_still_active(fn) vim.schedule(function() if active then fn() end end) end local function invalidate_dead_bars() if not win_ok(sbar_win) then sbar_win, sbar_buf = nil, nil end if not win_ok(cbar_win) then cbar_win, cbar_buf = nil, nil end end -- click zones for the control bar. Each entry: { line_1based, dcol_start, dcol_end, fn } -- Status bar has no clickable zones (control bar does), so no 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: " ()[ ]" e.g. "(s)tep over" -- prefix: " ()