feat: 4-row bar (state+help row above controls row), ❓ help, no underlines

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) <noreply@anthropic.com>
This commit is contained in:
swaitsandClaude Opus 4.6 committed 2026-04-12 13:33:53 -06:00
1 parent be8a3ac93f
commit 5477df4578
1 file changed
+120 -148
+120 -148
View File
@@ -50,7 +50,14 @@ local ICON = {
-- brand / state emoji -- brand / state emoji
bug = "\xf0\x9f\x90\x9b", -- 🐛 bug (U+1F41B) 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 arrows
ip_left = "\xf0\x9f\x91\x88", -- 👈 backhand pointing left (U+1F448) 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", position = "bottom",
size = 9, size = 8,
elements = { elements = {
{ id = "repl", size = 0.50 }, { id = "repl", size = 0.50 },
{ id = "console", 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, "TurboDebugBarRunning", { default = true, link = "DiagnosticInfo" })
hl(0, "TurboDebugBarPaused", { default = true, link = "DiagnosticError" }) hl(0, "TurboDebugBarPaused", { default = true, link = "DiagnosticError" })
-- The key letter inside (c)ontinue etc. gets bold + underline + Special's -- The key letter inside (c)ontinue etc. gets bold + Special's fg.
-- fg. nvim_set_hl can't combine link + bold, so we copy Special's fg -- No underline — the descender on lowercase q/g/p/y overlaps the
-- explicitly and add bold/underline. Re-applied on ColorScheme so theme -- underline and makes the glyph unreadable (q reads as g, etc.).
-- swaps don't blank it out. -- 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 function apply_key_hl()
local ok, src = pcall(vim.api.nvim_get_hl, 0, { name = "Special", link = false }) 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 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" }) vim.api.nvim_set_hl(0, "TurboDebugBarKey", { default = true, link = "Special" })
return return
end end
vim.api.nvim_set_hl(0, "TurboDebugBarKey", { 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 end
apply_key_hl() apply_key_hl()
@@ -207,6 +215,13 @@ local function build_control_text(icon, key, tail)
return table.concat(parts), key_col, key_end_col return table.concat(parts), key_col, key_end_col
end 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() local function render_bar()
if not (bar_buf and vim.api.nvim_buf_is_valid(bar_buf)) then return end 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 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() 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 chip_text = " " .. ICON.bug .. " DEBUG " .. ICON.divider .. " " .. state .. " "
local status_ok, status_msg = pcall(function() return require("dap").status() end) local status_ok, status_msg = pcall(function() return require("dap").status() end)
if not status_ok then status_msg = "" 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 local help_text = ICON.help .. " help "
-- modal variants that track the dap state so the text reflects what the
-- key will ACTUALLY do right now: -- fit-check: if status is too long, truncate it so help stays anchored
-- (c) start when no session is running local chip_dw = vim.fn.strdisplaywidth(chip_text)
-- (c)ontinue when a session is paused or running local help_dw = vim.fn.strdisplaywidth(help_text)
-- (q)uit when no session is running (exits debug mode) local status_dw = vim.fn.strdisplaywidth(status_text)
-- (q) terminate when a session is running (kills the process) local needed = chip_dw + status_dw + help_dw + 2 -- at least 2 cells of spacer
local has_session = state ~= "READY" if needed > width and status_dw > 0 then
local continue_icon, continue_tail local max_status = width - chip_dw - help_dw - 5
if has_session then if max_status > 5 then
continue_icon = ICON.go status_text = " " .. status_msg:sub(1, max_status - 3) .. ICON.ellipsis
continue_tail = "ontinue" status_dw = vim.fn.strdisplaywidth(status_text)
else else
continue_icon = ICON.start_rkt status_text = ""
continue_tail = " start" status_dw = 0
end
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 quit_tail = has_session and " terminate" or "uit"
local controls = { local controls = {
@@ -250,153 +280,97 @@ local function render_bar()
{ icon = ICON.stop, key = keyof("terminate", "q"), tail = quit_tail, fn = action("terminate") }, { icon = ICON.stop, key = keyof("terminate", "q"), tail = quit_tail, fn = action("terminate") },
} }
-- ─── RIGHT ZONE ─── help (no "(?)" — the ❓ emoji is self-documenting) local ctrl_parts = {}
local help_icon = ICON.help local ctrl_spans = {}
local help_label_text = help_icon .. " help " local ctrl_key_spans = {}
local help_fn = function() require("turbo-debug.help").open() end local ctrl_zones_bytes = {}
-- 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 gap = " " local gap = " "
for i, c in ipairs(controls) do 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 if i > 1 then
center_parts[#center_parts + 1] = gap ctrl_parts[#ctrl_parts + 1] = gap
prefix_len = prefix_len + #gap prefix_len = prefix_len + #gap
end end
local txt, kcol, kend = build_control_text(c.icon, c.key, c.tail) local txt, kcol, kend = build_control_text(c.icon, c.key, c.tail)
local label_start = prefix_len ctrl_parts[#ctrl_parts + 1] = txt
local label_end = prefix_len + #txt ctrl_spans[#ctrl_spans + 1] = { prefix_len, prefix_len + #txt, "TurboDebugBarCtrl" }
center_parts[#center_parts + 1] = txt ctrl_key_spans[#ctrl_key_spans + 1] = { prefix_len + kcol, prefix_len + kend }
center_spans[#center_spans + 1] = { label_start, label_end, "TurboDebugBarCtrl" } ctrl_zones_bytes[#ctrl_zones_bytes + 1] = { prefix_len, prefix_len + #txt, c.fn }
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)
end 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.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.bo[bar_buf].modifiable = false
vim.api.nvim_buf_clear_namespace(bar_buf, bar_ns, 0, -1) vim.api.nvim_buf_clear_namespace(bar_buf, bar_ns, 0, -1)
-- top + bottom separator highlights -- 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", end_row = 0, end_col = #sep, hl_group = "TurboDebugBarSeparator",
}) })
vim.api.nvim_buf_set_extmark(bar_buf, bar_ns, 2, 0, { pcall(vim.api.nvim_buf_set_extmark, bar_buf, bar_ns, 3, 0, {
end_row = 2, end_col = #sep, hl_group = "TurboDebugBarSeparator", end_row = 3, end_col = #sep, hl_group = "TurboDebugBarSeparator",
}) })
-- state chip highlight (at the start of the line) -- row 1: state chip color + status + help
local chip_end = #chip_text
pcall(vim.api.nvim_buf_set_extmark, bar_buf, bar_ns, 1, 0, { 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 if #status_text > 0 then
local s = chip_end pcall(vim.api.nvim_buf_set_extmark, bar_buf, bar_ns, 1, #chip_text, {
local e = chip_end + #status_text end_row = 1, end_col = #chip_text + #status_text, hl_group = "TurboDebugBarStatus",
pcall(vim.api.nvim_buf_set_extmark, bar_buf, bar_ns, 1, s, { })
end_row = 1, end_col = e, 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 end
-- center highlights (offset by left_text + pad1 bytes) -- click zones — stored as { line_number_1_based, dcol_start, dcol_end, fn }
local center_byte_offset = #left_text + pad1 -- help click lives on row 1 (line 2)
click_zones = {} local help_dcol_start = vim.fn.strdisplaywidth(state_line:sub(1, help_byte_start))
if show_controls then local help_dcol_end = vim.fn.strdisplaywidth(state_line)
for _, span in ipairs(center_spans) do click_zones[#click_zones + 1] = { 2, help_dcol_start, help_dcol_end,
pcall(vim.api.nvim_buf_set_extmark, bar_buf, bar_ns, 1, center_byte_offset + span[1], { function() require("turbo-debug.help").open() end }
end_row = 1, end_col = center_byte_offset + span[2], hl_group = span[3], -- control clicks live on row 2 (line 3)
}) for _, z in ipairs(ctrl_zones_bytes) do
end local byte_start = ctrl_byte_offset + z[1]
for _, span in ipairs(center_key_spans) do local byte_end = ctrl_byte_offset + z[2]
pcall(vim.api.nvim_buf_set_extmark, bar_buf, bar_ns, 1, center_byte_offset + span[1], { local dcol_start = vim.fn.strdisplaywidth(controls_line:sub(1, byte_start))
end_row = 1, end_col = center_byte_offset + span[2], hl_group = "TurboDebugBarKey", 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
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
end 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 end
local function bar_position() local function bar_position()
-- 3 rows (─ top, content, ─ bottom). Sits above the statusline. -- 4 rows: ─ top sep / state+help / controls / ─ bottom sep
local row local row
if vim.o.laststatus > 0 then 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 else
row = vim.o.lines - vim.o.cmdheight - 3 -- 3 (bar) row = vim.o.lines - vim.o.cmdheight - 4 -- 4 (bar)
end end
if row < 0 then row = 0 end if row < 0 then row = 0 end
return { return {
@@ -404,7 +378,7 @@ local function bar_position()
row = row, row = row,
col = 0, col = 0,
width = vim.o.columns, width = vim.o.columns,
height = 3, height = 4,
style = "minimal", style = "minimal",
border = "none", border = "none",
focusable = false, focusable = false,
@@ -416,12 +390,10 @@ end
local function handle_bar_click() local function handle_bar_click()
local pos = vim.fn.getmousepos() local pos = vim.fn.getmousepos()
if not pos or pos.winid ~= bar_win then return end 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 local col = pos.wincol - 1
for _, zone in ipairs(click_zones) do for _, zone in ipairs(click_zones) do
if col >= zone[1] and col < zone[2] then if zone[1] == pos.line and col >= zone[2] and col < zone[3] then
zone[3]() zone[4]()
return return
end end
end end