From 5477df4578585dbbf9ed1a76b58eda77abe5d634 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sun, 12 Apr 2026 13:33:53 -0600 Subject: [PATCH] =?UTF-8?q?feat:=204-row=20bar=20(state+help=20row=20above?= =?UTF-8?q?=20controls=20row),=20=E2=9D=93=20help,=20no=20underlines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout restructure per UX feedback — the single-row-crammed layout couldn't fit state + status + controls + help on narrow windows, and byte-based truncation was clipping emoji at the right edge. New shape: ┌────────────────── width ──────────────────┐ ├ ─ ─ ─ ─ top separator ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤ │ 🐛 DEBUG · PAUSED · status… ❓ help │ │ 🟢 (c)ontinue … 🛑 (q) terminate│ ├ ─ ─ ─ ─ bottom separator ─ ─ ─ ─ ─ ─ ─ ─ ─┤ └────────────────────────────────────────────┘ - Help has its own right-anchored slot on row 1 — it can never be squeezed off by long status messages or wide control labels. Solves the "no help visible on READY state" regression where dap's "All threads stopped" message was pushing it past the byte-truncation. - Controls get the full width of row 2 without fighting status text, centered horizontally. - If status message is too long for row 1, we truncate status first with an ellipsis — help stays put. Other fixes: - TurboDebugBarKey no longer applies underline. The descender of lowercase q crossed the underline and read as a lowercase g. Kept bold; dropped underline. - Help emoji switched back to ❓ (U+2753). It's BMP but has Emoji_Presentation=Yes by default, so Ghostty renders it as color. The previous "❓ invisible" symptom was caused by the broken-extmark underline leak; fixed in the prior commit. - Bottom tray shrunk 9 → 8 rows to preserve the 12-row total footprint of the original dapui default (8 tray + 4 bar = 12). Click zones now include a line number so we can dispatch correctly between the state row (help click) and the controls row (control clicks). Co-Authored-By: Claude Opus 4.6 (1M context) --- lua/turbo-debug/mode.lua | 268 ++++++++++++++++++--------------------- 1 file changed, 120 insertions(+), 148 deletions(-) diff --git a/lua/turbo-debug/mode.lua b/lua/turbo-debug/mode.lua index eeab20d..dc4bd3a 100644 --- a/lua/turbo-debug/mode.lua +++ b/lua/turbo-debug/mode.lua @@ -50,7 +50,14 @@ local ICON = { -- brand / state emoji bug = "\xf0\x9f\x90\x9b", -- 🐛 bug (U+1F41B) - help = "\xf0\x9f\x92\xa1", -- 💡 lightbulb (U+1F4A1) — insight/help + 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) @@ -84,9 +91,10 @@ local function build_default_layouts() }, }, { - -- 9 rows (was 12) to leave room for the 3-row control bar below + -- 8 rows — the control bar below takes 4 rows (sep + state + controls + -- + sep). 8 + 4 = 12, matching the dapui default tray size. position = "bottom", - size = 9, + size = 8, elements = { { id = "repl", size = 0.50 }, { id = "console", size = 0.50 }, @@ -110,19 +118,19 @@ local function define_highlights() hl(0, "TurboDebugBarRunning", { default = true, link = "DiagnosticInfo" }) hl(0, "TurboDebugBarPaused", { default = true, link = "DiagnosticError" }) - -- The key letter inside (c)ontinue etc. gets bold + underline + Special's - -- fg. nvim_set_hl can't combine link + bold, so we copy Special's fg - -- explicitly and add bold/underline. Re-applied on ColorScheme so theme - -- swaps don't blank it out. + -- 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 - -- fallback: at least make it bold via linking to something bold-ish 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, underline = true, default = true, + fg = src.fg, bg = src.bg, bold = true, default = true, }) end apply_key_hl() @@ -207,6 +215,13 @@ local function build_control_text(icon, key, tail) return table.concat(parts), key_col, key_end_col end +-- 4-row bar layout: +-- row 0 (line 1): ─ top separator +-- row 1 (line 2): state chip + dap.status() ❓ help +-- row 2 (line 3): controls (centered) +-- row 3 (line 4): ─ bottom separator +-- Help is on its own anchored right-of-row-1 so it can NEVER be pushed +-- off by long status text or wide control labels. local function render_bar() if not (bar_buf and vim.api.nvim_buf_is_valid(bar_buf)) then return end if not (bar_win and vim.api.nvim_win_is_valid(bar_win)) then return end @@ -217,28 +232,43 @@ local function render_bar() local state, state_hl = dap_state() - -- ─── LEFT ZONE ─── state chip (always shown) + dap.status() (optional) + -- ─── ROW 1: state info + help ─────────────────────────── local chip_text = " " .. ICON.bug .. " DEBUG " .. ICON.divider .. " " .. state .. " " local status_ok, status_msg = pcall(function() return require("dap").status() end) if not status_ok then status_msg = "" end - local status_text = (status_msg ~= "" and (" " .. status_msg) or "") + local status_text = status_msg ~= "" and (" " .. status_msg) or "" - -- ─── CENTER ZONE ─── control labels, each clickable. Two labels have - -- modal variants that track the dap state so the text reflects what the - -- key will ACTUALLY do right now: - -- (c) start when no session is running - -- (c)ontinue when a session is paused or running - -- (q)uit when no session is running (exits debug mode) - -- (q) terminate when a session is running (kills the process) - local has_session = state ~= "READY" - local continue_icon, continue_tail - if has_session then - continue_icon = ICON.go - continue_tail = "ontinue" - else - continue_icon = ICON.start_rkt - continue_tail = " start" + local help_text = ICON.help .. " help " + + -- fit-check: if status is too long, truncate it so help stays anchored + local chip_dw = vim.fn.strdisplaywidth(chip_text) + local help_dw = vim.fn.strdisplaywidth(help_text) + local status_dw = vim.fn.strdisplaywidth(status_text) + local needed = chip_dw + status_dw + help_dw + 2 -- at least 2 cells of spacer + if needed > width and status_dw > 0 then + local max_status = width - chip_dw - help_dw - 5 + if max_status > 5 then + status_text = " " .. status_msg:sub(1, max_status - 3) .. ICON.ellipsis + status_dw = vim.fn.strdisplaywidth(status_text) + else + status_text = "" + status_dw = 0 + end end + + local left_state = chip_text .. status_text + local left_state_dw = chip_dw + status_dw + local state_pad = width - left_state_dw - help_dw + if state_pad < 1 then state_pad = 1 end + local state_line = left_state .. string.rep(" ", state_pad) .. help_text + + -- ─── ROW 2: controls (centered) ───────────────────────── + -- Modal variants so the text reflects what the key will ACTUALLY do: + -- no session: (c) start (q)uit + -- active: (c)ontinue (q) terminate + 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 controls = { @@ -250,153 +280,97 @@ local function render_bar() { icon = ICON.stop, key = keyof("terminate", "q"), tail = quit_tail, fn = action("terminate") }, } - -- ─── RIGHT ZONE ─── help (no "(?)" — the ❓ emoji is self-documenting) - local help_icon = ICON.help - local help_label_text = help_icon .. " help " - local help_fn = function() require("turbo-debug.help").open() end - - -- compose the center text first so we can measure it - local center_parts = {} - local center_spans = {} -- { byte_start, byte_end, hl } - local center_key_spans = {} -- { byte_start, byte_end } for TurboDebugBarKey - local center_zones = {} -- { byte_start, byte_end, fn } pre-display-column + local ctrl_parts = {} + local ctrl_spans = {} + local ctrl_key_spans = {} + local ctrl_zones_bytes = {} local gap = " " for i, c in ipairs(controls) do - local prefix_len = #table.concat(center_parts) + local prefix_len = #table.concat(ctrl_parts) if i > 1 then - center_parts[#center_parts + 1] = gap + ctrl_parts[#ctrl_parts + 1] = gap prefix_len = prefix_len + #gap end local txt, kcol, kend = build_control_text(c.icon, c.key, c.tail) - local label_start = prefix_len - local label_end = prefix_len + #txt - center_parts[#center_parts + 1] = txt - center_spans[#center_spans + 1] = { label_start, label_end, "TurboDebugBarCtrl" } - center_key_spans[#center_key_spans + 1] = { label_start + kcol, label_start + kend } - center_zones[#center_zones + 1] = { label_start, label_end, c.fn } - end - local center_text = table.concat(center_parts) - - -- widths in display columns (not bytes — emoji are multi-byte and multi-cell) - local left_dw = vim.fn.strdisplaywidth(chip_text) + vim.fn.strdisplaywidth(status_text) - local center_dw = vim.fn.strdisplaywidth(center_text) - local right_dw = vim.fn.strdisplaywidth(help_label_text) - - -- Graceful degradation when the window narrows. Priority (most important last): - -- 1. dap.status() — drop first - -- 2. center controls — truncate from the right - -- 3. state chip — collapse to just "🐛" - -- 4. help — NEVER drop - local show_status = true - local show_controls = true - local show_chip = true - - if left_dw + center_dw + right_dw + 4 > width then - show_status = false -- drop status first - left_dw = vim.fn.strdisplaywidth(chip_text) - end - if left_dw + center_dw + right_dw + 4 > width then - show_controls = false -- drop center next - center_dw = 0 - end - if left_dw + right_dw + 2 > width then - show_chip = false -- collapse chip to just the bug icon - chip_text = " " .. ICON.bug .. " " - left_dw = vim.fn.strdisplaywidth(chip_text) - end - - -- compose: [left][pad1][center][pad2][right] - local left_text = chip_text .. (show_status and status_text or "") - local right_text = help_label_text - local center_to_use = show_controls and center_text or "" - local center_dw_used = show_controls and center_dw or 0 - - local remain = width - left_dw - center_dw_used - right_dw - if remain < 0 then remain = 0 end - -- split remaining space symmetrically on either side of the center - local pad1 = math.floor(remain / 2) - local pad2 = remain - pad1 - local line = left_text .. string.rep(" ", pad1) .. center_to_use .. string.rep(" ", pad2) .. right_text - - -- safety truncate - if vim.fn.strdisplaywidth(line) > width then - -- keep right-anchored help visible by truncating from the middle if possible - line = line:sub(1, width) + ctrl_parts[#ctrl_parts + 1] = txt + ctrl_spans[#ctrl_spans + 1] = { prefix_len, prefix_len + #txt, "TurboDebugBarCtrl" } + ctrl_key_spans[#ctrl_key_spans + 1] = { prefix_len + kcol, prefix_len + kend } + ctrl_zones_bytes[#ctrl_zones_bytes + 1] = { prefix_len, prefix_len + #txt, c.fn } end + local controls_text = table.concat(ctrl_parts) + local controls_dw = vim.fn.strdisplaywidth(controls_text) + local ctrl_pad_left = math.floor((width - controls_dw) / 2) + if ctrl_pad_left < 0 then ctrl_pad_left = 0 end + local ctrl_pad_right = width - controls_dw - ctrl_pad_left + if ctrl_pad_right < 0 then ctrl_pad_right = 0 end + local controls_line = string.rep(" ", ctrl_pad_left) .. controls_text .. string.rep(" ", ctrl_pad_right) + -- ─── commit buffer + extmarks ────────────────────────── vim.bo[bar_buf].modifiable = true - vim.api.nvim_buf_set_lines(bar_buf, 0, -1, false, { sep, line, sep }) + vim.api.nvim_buf_set_lines(bar_buf, 0, -1, false, { sep, state_line, controls_line, sep }) vim.bo[bar_buf].modifiable = false vim.api.nvim_buf_clear_namespace(bar_buf, bar_ns, 0, -1) -- top + bottom separator highlights - vim.api.nvim_buf_set_extmark(bar_buf, bar_ns, 0, 0, { + pcall(vim.api.nvim_buf_set_extmark, bar_buf, bar_ns, 0, 0, { end_row = 0, end_col = #sep, hl_group = "TurboDebugBarSeparator", }) - vim.api.nvim_buf_set_extmark(bar_buf, bar_ns, 2, 0, { - end_row = 2, end_col = #sep, hl_group = "TurboDebugBarSeparator", + pcall(vim.api.nvim_buf_set_extmark, bar_buf, bar_ns, 3, 0, { + end_row = 3, end_col = #sep, hl_group = "TurboDebugBarSeparator", }) - -- state chip highlight (at the start of the line) - local chip_end = #chip_text + -- row 1: state chip color + status + help pcall(vim.api.nvim_buf_set_extmark, bar_buf, bar_ns, 1, 0, { - end_row = 1, end_col = chip_end, hl_group = state_hl, + end_row = 1, end_col = #chip_text, hl_group = state_hl, }) - if show_status and #status_text > 0 then - local s = chip_end - local e = chip_end + #status_text - pcall(vim.api.nvim_buf_set_extmark, bar_buf, bar_ns, 1, s, { - end_row = 1, end_col = e, hl_group = "TurboDebugBarStatus", + if #status_text > 0 then + pcall(vim.api.nvim_buf_set_extmark, bar_buf, bar_ns, 1, #chip_text, { + end_row = 1, end_col = #chip_text + #status_text, hl_group = "TurboDebugBarStatus", + }) + end + local help_byte_start = #state_line - #help_text + pcall(vim.api.nvim_buf_set_extmark, bar_buf, bar_ns, 1, help_byte_start, { + end_row = 1, end_col = #state_line, hl_group = "TurboDebugBarCtrl", + }) + + -- row 2: controls (offset by ctrl_pad_left bytes, which is all ASCII spaces) + local ctrl_byte_offset = ctrl_pad_left -- spaces are 1 byte each + click_zones = {} + for _, span in ipairs(ctrl_spans) do + pcall(vim.api.nvim_buf_set_extmark, bar_buf, bar_ns, 2, ctrl_byte_offset + span[1], { + end_row = 2, 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, bar_buf, bar_ns, 2, ctrl_byte_offset + span[1], { + end_row = 2, end_col = ctrl_byte_offset + span[2], hl_group = "TurboDebugBarKey", }) end - -- center highlights (offset by left_text + pad1 bytes) - local center_byte_offset = #left_text + pad1 - click_zones = {} - if show_controls then - for _, span in ipairs(center_spans) do - pcall(vim.api.nvim_buf_set_extmark, bar_buf, bar_ns, 1, center_byte_offset + span[1], { - end_row = 1, end_col = center_byte_offset + span[2], hl_group = span[3], - }) - end - for _, span in ipairs(center_key_spans) do - pcall(vim.api.nvim_buf_set_extmark, bar_buf, bar_ns, 1, center_byte_offset + span[1], { - end_row = 1, end_col = center_byte_offset + span[2], hl_group = "TurboDebugBarKey", - }) - end - for _, zone in ipairs(center_zones) do - local byte_start = center_byte_offset + zone[1] - local byte_end = center_byte_offset + zone[2] - local dcol_start = vim.fn.strdisplaywidth(line:sub(1, byte_start)) - local dcol_end = vim.fn.strdisplaywidth(line:sub(1, byte_end)) - click_zones[#click_zones + 1] = { dcol_start, dcol_end, zone[3] } - end + -- click zones — stored as { line_number_1_based, dcol_start, dcol_end, fn } + -- help click lives on row 1 (line 2) + local help_dcol_start = vim.fn.strdisplaywidth(state_line:sub(1, help_byte_start)) + local help_dcol_end = vim.fn.strdisplaywidth(state_line) + click_zones[#click_zones + 1] = { 2, help_dcol_start, help_dcol_end, + function() require("turbo-debug.help").open() end } + -- control clicks live on row 2 (line 3) + 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(controls_line:sub(1, byte_start)) + local dcol_end = vim.fn.strdisplaywidth(controls_line:sub(1, byte_end)) + click_zones[#click_zones + 1] = { 3, dcol_start, dcol_end, z[3] } end - - -- right zone (help) — single-group highlight across the whole label. - -- No special key-hint styling here: the earlier attempt put a misaligned - -- TurboDebugBarKey (bold+underline) extmark starting 1 byte INTO the - -- emoji's 4 UTF-8 bytes, so when the emoji didn't render at all the - -- underline showed up as "_____" after "terminate". Just use the - -- standard control highlight on the whole help region — emoji speaks - -- for itself. - local right_byte_start = #line - #right_text - pcall(vim.api.nvim_buf_set_extmark, bar_buf, bar_ns, 1, right_byte_start, { - end_row = 1, end_col = #line, hl_group = "TurboDebugBarCtrl", - }) - local help_dcol_start = vim.fn.strdisplaywidth(line:sub(1, right_byte_start)) - local help_dcol_end = vim.fn.strdisplaywidth(line) - click_zones[#click_zones + 1] = { help_dcol_start, help_dcol_end, help_fn } end local function bar_position() - -- 3 rows (─ top, content, ─ bottom). Sits above the statusline. + -- 4 rows: ─ top sep / state+help / controls / ─ bottom sep local row if vim.o.laststatus > 0 then - row = vim.o.lines - vim.o.cmdheight - 4 -- 3 (bar) + 1 (statusline) + row = vim.o.lines - vim.o.cmdheight - 5 -- 4 (bar) + 1 (statusline) else - row = vim.o.lines - vim.o.cmdheight - 3 -- 3 (bar) + row = vim.o.lines - vim.o.cmdheight - 4 -- 4 (bar) end if row < 0 then row = 0 end return { @@ -404,7 +378,7 @@ local function bar_position() row = row, col = 0, width = vim.o.columns, - height = 3, + height = 4, style = "minimal", border = "none", focusable = false, @@ -416,12 +390,10 @@ end local function handle_bar_click() local pos = vim.fn.getmousepos() if not pos or pos.winid ~= bar_win then return end - -- only the middle (content) row is clickable; top/bottom are separators - if pos.line ~= 2 then return end local col = pos.wincol - 1 for _, zone in ipairs(click_zones) do - if col >= zone[1] and col < zone[2] then - zone[3]() + if zone[1] == pos.line and col >= zone[2] and col < zone[3] then + zone[4]() return end end