Skip to content

Commit 2f5e1ab

Browse files
committed
refactor(ui): extract stack view render pipeline
1 parent 88a9ab1 commit 2f5e1ab

6 files changed

Lines changed: 1709 additions & 643 deletions

File tree

lua/peekstack/types.lua

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,32 @@
161161
---@field help_augroup integer?
162162
---@field autoclose_group integer?
163163
---@field autoclose_suspended integer
164+
---@field preview_ts_cache table<string, PeekstackStackViewPreviewTsHighlight[]|false>
165+
166+
---@class PeekstackStackViewHighlight
167+
---@field col_start integer
168+
---@field col_end integer
169+
---@field hl_group string
170+
171+
---@class PeekstackStackViewPreviewLine
172+
---@field line string
173+
---@field source_bufnr integer
174+
---@field source_line integer
175+
---@field source_col_start integer
176+
---@field source_col_end integer
177+
---@field preview_col_start integer
178+
179+
---@class PeekstackStackViewPreviewTsHighlight
180+
---@field start_offset integer
181+
---@field end_offset integer
182+
---@field hl_group string
183+
184+
---@class PeekstackStackViewRenderModel
185+
---@field lines string[]
186+
---@field highlights PeekstackStackViewHighlight[][]
187+
---@field preview_lines table<integer, PeekstackStackViewPreviewLine>
188+
---@field line_to_id table<integer, integer>
189+
---@field header_lines integer
164190

165191
-- Config type definitions
166192

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
local preview = require("peekstack.ui.stack_view.preview")
2+
3+
local M = {}
4+
5+
local NS = vim.api.nvim_create_namespace("PeekstackStackView")
6+
local PREVIEW_BASE_HL_PRIORITY = 10
7+
8+
---@param line string
9+
---@param line_hls PeekstackStackViewHighlight[]
10+
---@param preview_line PeekstackStackViewPreviewLine?
11+
---@return string
12+
local function line_render_key(line, line_hls, preview_line)
13+
local parts = { line }
14+
for _, hl in ipairs(line_hls) do
15+
parts[#parts + 1] = string.format("%d:%d:%s", hl.col_start, hl.col_end, hl.hl_group)
16+
end
17+
if preview_line then
18+
parts[#parts + 1] = string.format(
19+
"preview:%d:%d:%d:%d:%d",
20+
preview_line.source_bufnr,
21+
preview_line.source_line,
22+
preview_line.source_col_start,
23+
preview_line.source_col_end,
24+
preview_line.preview_col_start
25+
)
26+
end
27+
return table.concat(parts, "|")
28+
end
29+
30+
---@param items string[]
31+
---@param start_idx integer
32+
---@param end_idx integer
33+
---@return string[]
34+
local function slice_lines(items, start_idx, end_idx)
35+
if end_idx < start_idx then
36+
return {}
37+
end
38+
39+
---@type string[]
40+
local slice = {}
41+
for idx = start_idx, end_idx do
42+
slice[#slice + 1] = items[idx]
43+
end
44+
return slice
45+
end
46+
47+
---@param old_keys string[]
48+
---@param new_keys string[]
49+
---@return integer?, integer?, integer?
50+
local function diff_range(old_keys, new_keys)
51+
local old_count = #old_keys
52+
local new_count = #new_keys
53+
local start_idx = 1
54+
55+
while start_idx <= old_count and start_idx <= new_count and old_keys[start_idx] == new_keys[start_idx] do
56+
start_idx = start_idx + 1
57+
end
58+
59+
if start_idx > old_count and start_idx > new_count then
60+
return nil, nil, nil
61+
end
62+
63+
local old_end = old_count
64+
local new_end = new_count
65+
while old_end >= start_idx and new_end >= start_idx and old_keys[old_end] == new_keys[new_end] do
66+
old_end = old_end - 1
67+
new_end = new_end - 1
68+
end
69+
70+
return start_idx, old_end, new_end
71+
end
72+
73+
---@param bufnr integer
74+
---@param highlights PeekstackStackViewHighlight[][]
75+
---@param preview_lines table<integer, PeekstackStackViewPreviewLine>
76+
---@param start_idx integer
77+
---@param end_idx integer
78+
---@param preview_cache table<string, PeekstackStackViewPreviewTsHighlight[]|false>
79+
local function apply_highlights_in_range(bufnr, highlights, preview_lines, start_idx, end_idx, preview_cache)
80+
if end_idx < start_idx then
81+
return
82+
end
83+
84+
for line_idx = start_idx, end_idx do
85+
for _, hl in ipairs(highlights[line_idx] or {}) do
86+
local opts = {
87+
end_col = hl.col_end,
88+
hl_group = hl.hl_group,
89+
}
90+
if hl.hl_group == "PeekstackStackViewPreview" then
91+
opts.priority = PREVIEW_BASE_HL_PRIORITY
92+
end
93+
vim.api.nvim_buf_set_extmark(bufnr, NS, line_idx - 1, hl.col_start, {
94+
end_col = opts.end_col,
95+
hl_group = opts.hl_group,
96+
priority = opts.priority,
97+
})
98+
end
99+
end
100+
101+
---@type table<integer, PeekstackStackViewPreviewLine>
102+
local changed_previews = {}
103+
for line_idx = start_idx, end_idx do
104+
if preview_lines[line_idx] then
105+
changed_previews[line_idx] = preview_lines[line_idx]
106+
end
107+
end
108+
if next(changed_previews) then
109+
preview.apply_treesitter_highlights(bufnr, changed_previews, preview_cache)
110+
end
111+
end
112+
113+
---@param model PeekstackStackViewRenderModel
114+
---@return string[]
115+
local function line_keys(model)
116+
---@type string[]
117+
local keys = {}
118+
for line_idx, line in ipairs(model.lines) do
119+
keys[line_idx] = line_render_key(line, model.highlights[line_idx] or {}, model.preview_lines[line_idx])
120+
end
121+
return keys
122+
end
123+
124+
---@param bufnr integer
125+
---@param old_keys string[]
126+
---@param model PeekstackStackViewRenderModel
127+
---@param preview_cache table<string, PeekstackStackViewPreviewTsHighlight[]|false>
128+
---@return string[]
129+
function M.apply(bufnr, old_keys, model, preview_cache)
130+
local new_keys = line_keys(model)
131+
local start_idx, old_end, new_end = diff_range(old_keys, new_keys)
132+
if start_idx then
133+
local replace = slice_lines(model.lines, start_idx, new_end)
134+
135+
vim.bo[bufnr].modifiable = true
136+
vim.api.nvim_buf_set_lines(bufnr, start_idx - 1, old_end, false, replace)
137+
vim.bo[bufnr].modifiable = false
138+
139+
vim.api.nvim_buf_clear_namespace(bufnr, NS, start_idx - 1, old_end)
140+
apply_highlights_in_range(bufnr, model.highlights, model.preview_lines, start_idx, new_end, preview_cache)
141+
end
142+
143+
return new_keys
144+
end
145+
146+
return M
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
local render_mod = require("peekstack.ui.render")
2+
local preview = require("peekstack.ui.stack_view.preview")
3+
local tree = require("peekstack.ui.stack_view.tree")
4+
local str = require("peekstack.util.str")
5+
6+
local M = {}
7+
8+
local PREVIEW_LINE_MARKER = ""
9+
10+
---@type table<string, string>
11+
local TITLE_HL_TO_SV = {
12+
PeekstackTitleProvider = "PeekstackStackViewProvider",
13+
PeekstackTitlePath = "PeekstackStackViewPath",
14+
PeekstackTitleIcon = "PeekstackStackViewIcon",
15+
PeekstackTitleLine = "PeekstackStackViewLine",
16+
}
17+
18+
---@class PeekstackStackViewPipelineOpts
19+
---@field items PeekstackPopupModel[]
20+
---@field focused_id integer?
21+
---@field filter string?
22+
---@field win_width integer
23+
---@field ui_path PeekstackConfigPath?
24+
---@field location_text fun(popup: PeekstackPopupModel, max_width: integer?): string
25+
---@field preview_line? fun(source_bufnr: integer?, line: integer, max_width: integer, preview_prefix: string): PeekstackStackViewPreviewLine?
26+
27+
---@param text string
28+
---@param query string?
29+
---@return boolean
30+
local function matches_filter(text, query)
31+
if not query or query == "" then
32+
return true
33+
end
34+
return text:lower():find(query:lower(), 1, true) ~= nil
35+
end
36+
37+
---@param popup PeekstackPopupModel
38+
---@param opts PeekstackStackViewPipelineOpts
39+
---@return string
40+
local function filter_label(popup, opts)
41+
if popup.title then
42+
return popup.title
43+
end
44+
return opts.location_text(popup, nil)
45+
end
46+
47+
---@param popup PeekstackPopupModel
48+
---@param prefix string
49+
---@param idx integer
50+
---@param is_focused boolean
51+
---@param ui_path PeekstackConfigPath
52+
---@param win_width integer
53+
---@param location_text fun(popup: PeekstackPopupModel, max_width: integer?): string
54+
---@return string, PeekstackStackViewHighlight[]
55+
local function build_entry_line(popup, prefix, idx, is_focused, ui_path, win_width, location_text)
56+
local max_label_width = math.max(win_width - vim.fn.strdisplaywidth(prefix), 0)
57+
if ui_path.max_width and ui_path.max_width > 0 then
58+
max_label_width = math.min(max_label_width, ui_path.max_width)
59+
end
60+
61+
local label = nil
62+
local label_chunks = nil
63+
if popup.title_chunks then
64+
label_chunks = render_mod.truncate_chunks(popup.title_chunks, max_label_width)
65+
label = render_mod.title_text(label_chunks)
66+
elseif popup.title then
67+
label = str.truncate_middle(popup.title, max_label_width)
68+
else
69+
label = location_text(popup, max_label_width)
70+
end
71+
72+
---@type PeekstackStackViewHighlight[]
73+
local line_hls = {}
74+
local focus_marker = is_focused and "" or " "
75+
local pinned = popup.pinned and "" or ""
76+
local index_str = string.format("%d. ", idx)
77+
local tree_guide = prefix:sub(#focus_marker + #index_str + #pinned + 1)
78+
79+
if is_focused then
80+
table.insert(line_hls, { col_start = 0, col_end = #focus_marker, hl_group = "PeekstackStackViewFocused" })
81+
end
82+
83+
local idx_start = #focus_marker
84+
table.insert(line_hls, {
85+
col_start = idx_start,
86+
col_end = idx_start + #index_str,
87+
hl_group = "PeekstackStackViewIndex",
88+
})
89+
90+
if popup.pinned then
91+
local pin_start = idx_start + #index_str
92+
table.insert(line_hls, {
93+
col_start = pin_start,
94+
col_end = pin_start + #pinned,
95+
hl_group = "PeekstackStackViewPinned",
96+
})
97+
end
98+
99+
if tree_guide ~= "" then
100+
local tree_start = idx_start + #index_str + #pinned
101+
table.insert(line_hls, {
102+
col_start = tree_start,
103+
col_end = tree_start + #tree_guide,
104+
hl_group = "PeekstackStackViewTree",
105+
})
106+
end
107+
108+
if label_chunks then
109+
local pos = #prefix
110+
for _, chunk in ipairs(label_chunks) do
111+
local text = chunk[1] or ""
112+
local hl = chunk[2]
113+
if hl and #text > 0 then
114+
table.insert(line_hls, {
115+
col_start = pos,
116+
col_end = pos + #text,
117+
hl_group = TITLE_HL_TO_SV[hl] or hl,
118+
})
119+
end
120+
pos = pos + #text
121+
end
122+
end
123+
124+
return prefix .. label, line_hls
125+
end
126+
127+
---@param opts PeekstackStackViewPipelineOpts
128+
---@return PeekstackStackViewRenderModel
129+
function M.build(opts)
130+
local ui_path = opts.ui_path or {}
131+
local preview_line = opts.preview_line or preview.preview_line
132+
133+
---@type string[]
134+
local lines = {}
135+
---@type PeekstackStackViewHighlight[][]
136+
local highlights = {}
137+
---@type table<integer, PeekstackStackViewPreviewLine>
138+
local preview_lines = {}
139+
---@type table<integer, integer>
140+
local line_to_id = {}
141+
142+
---@type PeekstackPopupModel[]
143+
local visible = {}
144+
for _, popup in ipairs(opts.items) do
145+
if matches_filter(filter_label(popup, opts), opts.filter) then
146+
table.insert(visible, popup)
147+
end
148+
end
149+
150+
visible = tree.sort(visible)
151+
local tree_guides = tree.guide_by_id(visible)
152+
153+
local header = nil
154+
local header_hl = nil
155+
if opts.filter and opts.filter ~= "" then
156+
header = string.format("Filter: %s (%d/%d)", opts.filter, #visible, #opts.items)
157+
header_hl = "PeekstackStackViewFilter"
158+
else
159+
header = string.format("Stack: %d", #visible)
160+
header_hl = "PeekstackStackViewHeader"
161+
end
162+
163+
table.insert(lines, header)
164+
table.insert(highlights, { { col_start = 0, col_end = #header, hl_group = header_hl } })
165+
166+
if #visible == 0 then
167+
local empty = opts.filter and opts.filter ~= "" and "No matches" or "No stack entries"
168+
table.insert(lines, empty)
169+
table.insert(highlights, { { col_start = 0, col_end = #empty, hl_group = "PeekstackStackViewEmpty" } })
170+
end
171+
172+
for idx, popup in ipairs(visible) do
173+
local is_focused = popup.id == opts.focused_id
174+
local focus_marker = is_focused and "" or " "
175+
local pinned = popup.pinned and "" or ""
176+
local index_str = string.format("%d. ", idx)
177+
local tree_guide = tree_guides[popup.id] or ""
178+
local prefix = focus_marker .. index_str .. pinned .. tree_guide
179+
180+
local line, line_hls = build_entry_line(popup, prefix, idx, is_focused, ui_path, opts.win_width, opts.location_text)
181+
182+
table.insert(lines, line)
183+
table.insert(highlights, line_hls)
184+
185+
local entry_line_nr = #lines
186+
line_to_id[entry_line_nr] = popup.id
187+
188+
local source_line = popup.location
189+
and popup.location.range
190+
and popup.location.range.start
191+
and popup.location.range.start.line
192+
if source_line then
193+
local preview_prefix = string.rep(" ", vim.fn.strdisplaywidth(prefix)) .. PREVIEW_LINE_MARKER
194+
local preview_item = preview_line(popup.source_bufnr or popup.bufnr, source_line, opts.win_width, preview_prefix)
195+
if preview_item then
196+
table.insert(lines, preview_item.line)
197+
table.insert(highlights, {
198+
{ col_start = 0, col_end = #preview_item.line, hl_group = "PeekstackStackViewPreview" },
199+
})
200+
201+
local preview_line_nr = #lines
202+
preview_lines[preview_line_nr] = preview_item
203+
line_to_id[preview_line_nr] = popup.id
204+
end
205+
end
206+
end
207+
208+
return {
209+
lines = lines,
210+
highlights = highlights,
211+
preview_lines = preview_lines,
212+
line_to_id = line_to_id,
213+
header_lines = 1,
214+
}
215+
end
216+
217+
return M

0 commit comments

Comments
 (0)