feat: bars become real splits (no overlap), gutter IP sign, responsive layout

Critical fix — the top status bar was overlapping the first rows of the
source window and Call Stack pane. Root cause: both bars were floating
windows positioned at row 0 and just-above-statusline. dapui's layout
algorithm doesn't know about floats, so its windows extend into the
rows our floats were covering. Overlap = content hidden.

Fix: convert both bars from floating windows to real splits.
  :topleft split  → top status bar (3 rows, winfixheight)
  :botright split → bottom control bar (3 rows, winfixheight)
Splits consume real layout space so dapui's panes lay out below/above
them naturally. No more overlap.

Also:
- Bars are now 3 rows each (sep / content / sep) for clear visual
  separation from the main content area, per user design preference.
- IP gutter sign (👉) now placed via extmark with sign_text at
  priority 500, overriding nvim-dap's DapStopped sign which was
  losing the priority tie with the breakpoint sign on the paused line.
  Both the gutter finger and the EOL-vtext finger now show.
- Sidebar width: max(25, min(40, columns/3)). Scales down on narrow
  terminals, caps at 40 on wide ones so the source window stays the
  star. Minimum 25 keeps the panes readable.
- Console (bottom tray) height: max(5, 15% of available vertical
  space) — floors at 5 rows so very tall terminals don't blow it up
  while small terminals still get a usable Console.
- Sidebar panes: equal 25% each (Stack / Scopes / Watches /
  Breakpoints). User preference — lets Breakpoints be visibly taller
  than the 5-row minimum Console so they read as distinct panes.

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:59:06 -06:00
1 parent 869a4a36d9
commit fb695ce359
1 file changed
+101 -92
+101 -92
View File
@@ -80,30 +80,42 @@ local ICON = {
local function build_default_layouts() local function build_default_layouts()
local pos = config.opts.sidebar == "right" and "right" or "left" local pos = config.opts.sidebar == "right" and "right" or "left"
-- Console height: 15% of the available vertical space, floored at 5 rows.
-- "Available" = screen rows minus our two 3-row bars, the statusline,
-- and the cmdline. With Breakpoints at 25% of a much-taller sidebar,
-- Bp visibly exceeds Console so the user can see they're separate panes.
local top_bar_rows = 3
local bottom_bar_rows = 3
local statusline_rows = vim.o.laststatus > 0 and 1 or 0
local cmdline_rows = math.max(1, vim.o.cmdheight)
local avail = vim.o.lines - top_bar_rows - bottom_bar_rows - statusline_rows - cmdline_rows
if avail < 10 then avail = 10 end
local console_size = math.max(5, math.floor(avail * 0.15))
-- 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 { return {
{ {
position = pos, position = pos,
size = 40, size = sidebar_size,
-- Order is deliberate: -- Order: Call Stack → Scopes → Watches → Breakpoints.
-- 1. Call Stack — answers "where am I?" first when paused -- Equal 25% share per user preference.
-- 2. Scopes — "what's the state here?" (updates when you click a
-- frame above, so adjacency is ergonomic)
-- 3. Watches — user-curated expressions, less frequently consulted
-- 4. Breakpoints — maintenance view, accessed rarely during a pause
elements = { elements = {
{ id = "stacks", size = 0.25 }, { id = "stacks", size = 0.25 },
{ id = "scopes", size = 0.40 }, { id = "scopes", size = 0.25 },
{ id = "watches", size = 0.20 }, { id = "watches", size = 0.25 },
{ id = "breakpoints", size = 0.15 }, { id = "breakpoints", size = 0.25 },
}, },
}, },
{ {
-- Console only; REPL removed because it duplicates Console for most -- Console only. REPL removed — hit E in debug mode to open it
-- workflows and the horizontal space is better spent on program -- as a floating widget when needed.
-- output. Users who want a REPL can hit `E` in debug mode to open
-- it as a floating widget.
position = "bottom", position = "bottom",
size = 10, size = console_size,
elements = { elements = {
{ id = "console", size = 1.0 }, { id = "console", size = 1.0 },
}, },
@@ -232,7 +244,7 @@ local function render_sbar()
local state, state_hl = dap_state() local state, state_hl = dap_state()
-- layout: [brand] [status msg] [state chip] -- layout on content row: [brand] [status msg] [state chip]
local brand_text = " turbo-debug " local brand_text = " turbo-debug "
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
@@ -242,7 +254,6 @@ local function render_sbar()
local brand_dw = vim.fn.strdisplaywidth(brand_text) local brand_dw = vim.fn.strdisplaywidth(brand_text)
local chip_dw = vim.fn.strdisplaywidth(state_chip) local chip_dw = vim.fn.strdisplaywidth(state_chip)
-- fit status msg if too long
local status_dw = vim.fn.strdisplaywidth(status_msg) local status_dw = vim.fn.strdisplaywidth(status_msg)
local available = width - brand_dw - chip_dw - 4 local available = width - brand_dw - chip_dw - 4
if status_dw > available and available > 5 then if status_dw > available and available > 5 then
@@ -255,35 +266,38 @@ local function render_sbar()
local remain = width - brand_dw - status_dw - chip_dw local remain = width - brand_dw - status_dw - chip_dw
if remain < 2 then remain = 2 end if remain < 2 then remain = 2 end
-- split evenly on either side of the status msg
local pad_left = math.floor(remain / 2) local pad_left = math.floor(remain / 2)
local pad_right = remain - pad_left local pad_right = remain - pad_left
local top_line = brand_text .. string.rep(" ", pad_left) .. status_msg .. string.rep(" ", pad_right) .. state_chip local content_line = brand_text .. string.rep(" ", pad_left) .. status_msg .. string.rep(" ", pad_right) .. state_chip
-- 3 rows: sep / content / sep
vim.bo[sbar_buf].modifiable = true vim.bo[sbar_buf].modifiable = true
vim.api.nvim_buf_set_lines(sbar_buf, 0, -1, false, { top_line, sep }) vim.api.nvim_buf_set_lines(sbar_buf, 0, -1, false, { sep, content_line, sep })
vim.bo[sbar_buf].modifiable = false vim.bo[sbar_buf].modifiable = false
vim.api.nvim_buf_clear_namespace(sbar_buf, bar_ns, 0, -1) vim.api.nvim_buf_clear_namespace(sbar_buf, bar_ns, 0, -1)
-- brand -- separators
pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 0, 0, { pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 0, 0, {
end_row = 0, end_col = #brand_text, hl_group = "TurboDebugBarBrand", end_row = 0, end_col = #sep, hl_group = "TurboDebugBarSeparator",
}) })
-- status (middle) pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 2, 0, {
end_row = 2, end_col = #sep, hl_group = "TurboDebugBarSeparator",
})
-- content: brand on left
pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 1, 0, {
end_row = 1, end_col = #brand_text, hl_group = "TurboDebugBarBrand",
})
-- status message in the middle
if status_dw > 0 then if status_dw > 0 then
local status_byte_start = #brand_text + pad_left local status_byte_start = #brand_text + pad_left
pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 0, status_byte_start, { pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 1, status_byte_start, {
end_row = 0, end_col = status_byte_start + #status_msg, hl_group = "TurboDebugBarStatus", end_row = 1, end_col = status_byte_start + #status_msg, hl_group = "TurboDebugBarStatus",
}) })
end end
-- state chip (right) -- state chip on right
local chip_byte_start = #top_line - #state_chip local chip_byte_start = #content_line - #state_chip
pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 0, chip_byte_start, { pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 1, chip_byte_start, {
end_row = 0, end_col = #top_line, hl_group = state_hl, end_row = 1, end_col = #content_line, hl_group = state_hl,
})
-- separator
pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 1, 0, {
end_row = 1, end_col = #sep, hl_group = "TurboDebugBarSeparator",
}) })
sbar_zones = {} sbar_zones = {}
@@ -345,19 +359,23 @@ local function render_cbar()
if ctrl_pad_left < 1 then ctrl_pad_left = 1 end if ctrl_pad_left < 1 then ctrl_pad_left = 1 end
local ctrl_pad_right = ctrl_zone_width - controls_dw - ctrl_pad_left local ctrl_pad_right = ctrl_zone_width - controls_dw - ctrl_pad_left
if ctrl_pad_right < 1 then ctrl_pad_right = 1 end if ctrl_pad_right < 1 then ctrl_pad_right = 1 end
local bottom_line = string.rep(" ", ctrl_pad_left) .. controls_text .. string.rep(" ", ctrl_pad_right) .. help_text local content_line = string.rep(" ", ctrl_pad_left) .. controls_text .. string.rep(" ", ctrl_pad_right) .. help_text
-- 3 rows: sep / content / sep
vim.bo[cbar_buf].modifiable = true vim.bo[cbar_buf].modifiable = true
vim.api.nvim_buf_set_lines(cbar_buf, 0, -1, false, { sep, bottom_line }) vim.api.nvim_buf_set_lines(cbar_buf, 0, -1, false, { sep, content_line, sep })
vim.bo[cbar_buf].modifiable = false vim.bo[cbar_buf].modifiable = false
vim.api.nvim_buf_clear_namespace(cbar_buf, bar_ns, 0, -1) vim.api.nvim_buf_clear_namespace(cbar_buf, bar_ns, 0, -1)
pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 0, 0, { pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 0, 0, {
end_row = 0, end_col = #sep, hl_group = "TurboDebugBarSeparator", end_row = 0, end_col = #sep, hl_group = "TurboDebugBarSeparator",
}) })
pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 2, 0, {
end_row = 2, end_col = #sep, hl_group = "TurboDebugBarSeparator",
})
-- control labels -- control labels (content is on row 1, index 1)
local ctrl_byte_offset = ctrl_pad_left -- spaces are 1 byte each local ctrl_byte_offset = ctrl_pad_left
for _, span in ipairs(ctrl_spans) do for _, span in ipairs(ctrl_spans) do
pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 1, ctrl_byte_offset + span[1], { pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 1, ctrl_byte_offset + span[1], {
end_row = 1, end_col = ctrl_byte_offset + span[2], hl_group = span[3], end_row = 1, end_col = ctrl_byte_offset + span[2], hl_group = span[3],
@@ -370,22 +388,22 @@ local function render_cbar()
end end
-- help (right-anchored) -- help (right-anchored)
local help_byte_start = #bottom_line - #help_text local help_byte_start = #content_line - #help_text
pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 1, help_byte_start, { pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 1, help_byte_start, {
end_row = 1, end_col = #bottom_line, hl_group = "TurboDebugBarCtrl", end_row = 1, end_col = #content_line, hl_group = "TurboDebugBarCtrl",
}) })
cbar_zones = {} cbar_zones = {}
for _, z in ipairs(ctrl_zones_bytes) do for _, z in ipairs(ctrl_zones_bytes) do
local byte_start = ctrl_byte_offset + z[1] local byte_start = ctrl_byte_offset + z[1]
local byte_end = ctrl_byte_offset + z[2] local byte_end = ctrl_byte_offset + z[2]
local dcol_start = vim.fn.strdisplaywidth(bottom_line:sub(1, byte_start)) local dcol_start = vim.fn.strdisplaywidth(content_line:sub(1, byte_start))
local dcol_end = vim.fn.strdisplaywidth(bottom_line:sub(1, byte_end)) local dcol_end = vim.fn.strdisplaywidth(content_line:sub(1, byte_end))
cbar_zones[#cbar_zones + 1] = { 2, dcol_start, dcol_end, z[3] } cbar_zones[#cbar_zones + 1] = { 2, dcol_start, dcol_end, z[3] }
end end
-- help click zone -- help click zone (also on row 2 since the content is the middle row of 3)
local help_dcol_start = vim.fn.strdisplaywidth(bottom_line:sub(1, help_byte_start)) local help_dcol_start = vim.fn.strdisplaywidth(content_line:sub(1, help_byte_start))
local help_dcol_end = vim.fn.strdisplaywidth(bottom_line) local help_dcol_end = vim.fn.strdisplaywidth(content_line)
cbar_zones[#cbar_zones + 1] = { 2, help_dcol_start, help_dcol_end, cbar_zones[#cbar_zones + 1] = { 2, help_dcol_start, help_dcol_end,
function() require("turbo-debug.help").open() end } function() require("turbo-debug.help").open() end }
end end
@@ -395,45 +413,8 @@ local function render_bars()
render_cbar() render_cbar()
end end
local function sbar_position() -- The bars are SPLITS, not floats. Splits consume real layout rows so
-- 2 rows at the top of the editor, row 0 (below any tabline if present). -- dapui and source windows tuck underneath them naturally — no overlap.
local top_row = vim.o.showtabline >= 2 and 1 or 0
return {
relative = "editor",
row = top_row,
col = 0,
width = vim.o.columns,
height = 2,
style = "minimal",
border = "none",
focusable = false,
noautocmd = true,
zindex = 50,
}
end
local function cbar_position()
-- 2 rows just above the statusline.
local row
if vim.o.laststatus > 0 then
row = vim.o.lines - vim.o.cmdheight - 3 -- 2 (bar) + 1 (statusline)
else
row = vim.o.lines - vim.o.cmdheight - 2 -- 2 (bar)
end
if row < 0 then row = 0 end
return {
relative = "editor",
row = row,
col = 0,
width = vim.o.columns,
height = 2,
style = "minimal",
border = "none",
focusable = false,
noautocmd = true,
zindex = 50,
}
end
local function handle_sbar_click() local function handle_sbar_click()
local pos = vim.fn.getmousepos() local pos = vim.fn.getmousepos()
@@ -493,26 +474,51 @@ local function bounce_out(buf)
end end
local function open_bars() local function open_bars()
-- Both bars are SPLITS, not floats. Splits consume real layout rows
-- so dapui and source windows tuck underneath them rather than
-- getting overlaid. The caller's original focus is preserved.
local caller_win = vim.api.nvim_get_current_win()
-- status bar (top) -- status bar (top)
if not (sbar_win and vim.api.nvim_win_is_valid(sbar_win)) then if not (sbar_win and vim.api.nvim_win_is_valid(sbar_win)) then
vim.cmd("noautocmd keepalt topleft split")
sbar_win = vim.api.nvim_get_current_win()
sbar_buf = vim.api.nvim_create_buf(false, true) sbar_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_win_set_buf(sbar_win, sbar_buf)
vim.api.nvim_win_set_height(sbar_win, 3)
setup_bar_buf(sbar_buf) setup_bar_buf(sbar_buf)
sbar_win = vim.api.nvim_open_win(sbar_buf, false, sbar_position())
setup_bar_win(sbar_win) setup_bar_win(sbar_win)
vim.wo[sbar_win].winfixheight = true
vim.keymap.set("n", "<LeftMouse>", handle_sbar_click, { buffer = sbar_buf, silent = true, nowait = true }) vim.keymap.set("n", "<LeftMouse>", handle_sbar_click, { buffer = sbar_buf, silent = true, nowait = true })
vim.keymap.set("n", "<LeftRelease>", "<Nop>", { buffer = sbar_buf, silent = true, nowait = true }) vim.keymap.set("n", "<LeftRelease>", "<Nop>", { buffer = sbar_buf, silent = true, nowait = true })
bounce_out(sbar_buf) bounce_out(sbar_buf)
end end
-- back to caller before creating bottom so topology is predictable
if vim.api.nvim_win_is_valid(caller_win) then
pcall(vim.api.nvim_set_current_win, caller_win)
end
-- control bar (bottom) -- control bar (bottom)
if not (cbar_win and vim.api.nvim_win_is_valid(cbar_win)) then if not (cbar_win and vim.api.nvim_win_is_valid(cbar_win)) then
vim.cmd("noautocmd keepalt botright split")
cbar_win = vim.api.nvim_get_current_win()
cbar_buf = vim.api.nvim_create_buf(false, true) cbar_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_win_set_buf(cbar_win, cbar_buf)
vim.api.nvim_win_set_height(cbar_win, 3)
setup_bar_buf(cbar_buf) setup_bar_buf(cbar_buf)
cbar_win = vim.api.nvim_open_win(cbar_buf, false, cbar_position())
setup_bar_win(cbar_win) setup_bar_win(cbar_win)
vim.wo[cbar_win].winfixheight = true
vim.keymap.set("n", "<LeftMouse>", handle_cbar_click, { buffer = cbar_buf, silent = true, nowait = true }) vim.keymap.set("n", "<LeftMouse>", handle_cbar_click, { buffer = cbar_buf, silent = true, nowait = true })
vim.keymap.set("n", "<LeftRelease>", "<Nop>", { buffer = cbar_buf, silent = true, nowait = true }) vim.keymap.set("n", "<LeftRelease>", "<Nop>", { buffer = cbar_buf, silent = true, nowait = true })
bounce_out(cbar_buf) bounce_out(cbar_buf)
end end
-- restore caller focus
if vim.api.nvim_win_is_valid(caller_win) then
pcall(vim.api.nvim_set_current_win, caller_win)
end
render_bars() render_bars()
end end
@@ -528,12 +534,7 @@ local function close_bars()
end end
local function reposition_bars() local function reposition_bars()
if sbar_win and vim.api.nvim_win_is_valid(sbar_win) then -- Splits auto-track VimResized via winfixheight; just re-render content.
pcall(vim.api.nvim_win_set_config, sbar_win, sbar_position())
end
if cbar_win and vim.api.nvim_win_is_valid(cbar_win) then
pcall(vim.api.nvim_win_set_config, cbar_win, cbar_position())
end
render_bars() render_bars()
end end
@@ -664,14 +665,22 @@ local function ensure_dapui()
if buf == -1 or not vim.api.nvim_buf_is_valid(buf) then return end if buf == -1 or not vim.api.nvim_buf_is_valid(buf) then return end
local line = math.max(0, (frame.line or 1) - 1) local line = math.max(0, (frame.line or 1) - 1)
local reason = ((body and body.reason) or "stopped"):upper() local reason = ((body and body.reason) or "stopped"):upper()
-- Extmark carries BOTH the virt_text (👈 REASON at EOL) AND a
-- sign_text (👉 in the gutter) with high priority. nvim-dap also
-- places its own DapStopped sign, but at the same priority as the
-- breakpoint sign — which meant whichever was placed later won.
-- Our extmark sign at priority 500 always wins, guaranteeing the
-- paused line has the finger-pointing icon in the gutter.
pcall(vim.api.nvim_buf_set_extmark, buf, ip_ns, line, 0, { pcall(vim.api.nvim_buf_set_extmark, buf, ip_ns, line, 0, {
sign_text = "\xf0\x9f\x91\x89", -- 👉 U+1F449
sign_hl_group = "DapStopped",
virt_text = { virt_text = {
{ " " .. ICON.ip_left .. " ", "DapStopped" }, { " " .. ICON.ip_left .. " ", "DapStopped" },
{ reason, "TurboDebugIPReason" }, { reason, "TurboDebugIPReason" },
}, },
virt_text_pos = "eol", virt_text_pos = "eol",
hl_mode = "combine", hl_mode = "combine",
priority = 200, priority = 500,
}) })
end, 50) end, 50)
end end