Skip to content

Commit acddebf

Browse files
committed
fix(win): replace conceal with overlay for winopts.path_shorten
When winopts.path_shorten uses conceal extmarks to visually shorten directory components, the reduced display width causes fzf's right-side UI elements (preview border, scrollbar) to shift left. This happens because terminal buffers calculate layout based on actual character cells, and concealing text makes the terminal see fewer characters. Replace the conceal-based approach with virt_text_pos="overlay" which renders shortened path text on top of the original, preserving the underlying line's full character cell count and keeping fzf's layout intact. Implementation details: - Build shortened path string (e.g. "l/f/cmd.lua" from "lua/fzf-lua/cmd.lua"), pad with trailing spaces to match original display width, apply single overlay extmark per line - Use vim.fn.strdisplaywidth for width calculation to correctly handle CJK wide characters, multi-byte UTF-8, and icons - Detect actual path boundaries by stopping at components containing whitespace, preventing fzf status text (e.g. "76/281") from being treated as path separators - Skip non-path lines (fzf prompt, action headers) via early validation: reject lines where a space appears before the first path separator - Handle directory paths with trailing "/" (cwd in fzf header) by checking for whitespace after the last separator - Remove conceallevel/concealcursor window option management since conceal is no longer used - Remove dead _calculate_concealed_width() function and abandoned on_lines callback infrastructure Refs #2607
1 parent 8342463 commit acddebf

1 file changed

Lines changed: 239 additions & 61 deletions

File tree

lua/fzf-lua/win.lua

Lines changed: 239 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ local fn = vim.fn
88

99
---@class fzf-lua.PathShortener
1010
---@field _ns integer?
11-
---@field _wins table<integer, { bufnr: integer, shorten_len: integer }>
11+
---@field _wins table<integer, fzf-lua.PathShortener.WinData>
1212
local PathShortener = {}
1313

1414
PathShortener._wins = {}
@@ -22,36 +22,38 @@ function PathShortener.setup()
2222

2323
-- Register decoration provider with ephemeral extmarks
2424
api.nvim_set_decoration_provider(PathShortener._ns, {
25-
on_win = function(_, winid, bufnr, _topline, _botline)
25+
on_win = function(_, winid, bufnr, topline, _botline)
2626
-- Only process registered fzf windows
2727
local win_data = PathShortener._wins[winid]
2828
if not win_data or win_data.bufnr ~= bufnr then
2929
return false
3030
end
31+
win_data.topline = topline
32+
win_data.winid = winid
3133
return true -- Continue to on_line callbacks
3234
end,
3335
on_line = function(_, winid, bufnr, row)
3436
local win_data = PathShortener._wins[winid]
3537
if not win_data then return end
36-
PathShortener._apply_line(bufnr, row, win_data.shorten_len)
38+
PathShortener._apply_line(bufnr, row, win_data)
3739
end,
3840
})
3941
end
4042

41-
---Apply path shortening to a single line using ephemeral extmarks
43+
---Apply path shortening to a single line using overlay virtual text
44+
---Uses virt_text_pos="overlay" instead of conceal to preserve line width
45+
---in terminal buffers, preventing fzf's preview border from shifting
4246
---@param buf integer buffer number
4347
---@param row integer 0-indexed line number
44-
---@param shorten_len integer number of characters to keep
45-
function PathShortener._apply_line(buf, row, shorten_len)
48+
---@param win_data table window data from PathShortener._wins
49+
function PathShortener._apply_line(buf, row, win_data)
50+
local shorten_len = win_data.shorten_len
4651
local lines = api.nvim_buf_get_lines(buf, row, row + 1, false)
4752
local line = lines[1]
4853
if not line or #line == 0 then return end
4954

50-
-- Find the path portion of the line
51-
-- Lines may have prefixes like icons separated by nbsp (U+2002)
52-
-- Format: [fzf_pointer] [git_icon nbsp] [file_icon nbsp] path[:line:col:text]
53-
-- When file_icons=false, there's no nbsp separator before the path
54-
-- The fzf terminal also adds pointer/marker prefix (e.g., "> " or " ")
55+
-- Only process selectable entry lines (lines with icon nbsp separator)
56+
-- This skips fzf's prompt, header, and info lines which don't have nbsp
5557
local path_start = 1
5658
local last_nbsp = line:find(utils.nbsp, 1, true)
5759
if last_nbsp then
@@ -61,21 +63,27 @@ function PathShortener._apply_line(buf, row, shorten_len)
6163
last_nbsp = line:find(utils.nbsp, path_start, true)
6264
until not last_nbsp
6365
else
64-
-- No nbsp means no icons - skip fzf's pointer/marker prefix
65-
-- Paths start with: alphanumeric, `/`, `~`, `.`, or drive letter (Windows)
66+
-- No nbsp: either file_icons=false entry or a non-entry line (prompt/header)
67+
-- Filter out fzf's prompt/info line which contains the match counter (e.g. "281/281 (0)")
68+
if line:find("%d+/%d+") then return end
6669
-- Skip leading whitespace and pointer characters until we hit a path char
6770
local first_path_char = line:find("[%w/~%.]")
68-
if first_path_char then
69-
path_start = first_path_char
70-
end
71+
if not first_path_char then return end
72+
path_start = first_path_char
73+
-- Reject lines where space appears before the first path separator
74+
-- This filters out header lines like "cwd: /path/to/dir"
75+
local sub = line:sub(path_start)
76+
local first_sep = path.find_next_separator(sub, 1)
77+
if not first_sep then return end
78+
local first_space = sub:find(" ")
79+
if first_space and first_space < first_sep then return end
7180
end
7281

73-
-- Find where the path ends (at first colon after path_start, if any)
74-
-- But be careful with Windows paths like C:\...
82+
-- Find where the path ends
83+
-- For grep-like results: path ends at first colon (file:line:col:text)
7584
local path_end = #line
7685
local colon_search_start = path_start
7786
-- On Windows, skip the drive letter colon (e.g., C:)
78-
-- Check if char at path_start+1 is colon AND char at path_start+2 is a path separator
7987
if string.byte(line, path_start + 1) == path.colon_byte
8088
and path.byte_is_separator(string.byte(line, path_start + 2)) then
8189
colon_search_start = path_start + 2
@@ -85,68 +93,242 @@ function PathShortener._apply_line(buf, row, shorten_len)
8593
path_end = colon_pos - 1
8694
end
8795

88-
-- Now process the path portion for shortening
89-
-- We need to conceal directory components, keeping only `shorten_len` chars
9096
local path_portion = line:sub(path_start, path_end)
9197

92-
-- Use path.find_next_separator to iterate through directory components
98+
-- Formatters add ANSI escape sequences for coloring (e.g., dirname_first, filename_first)
99+
-- We need to strip these for accurate shortening calculations, then let hl_mode="combine"
100+
-- inherit the original coloring from the underlying line
101+
local fmt_opts = win_data.fmt_opts
102+
local has_fmt_hls = fmt_opts and fmt_opts.formatter
103+
and fmt_opts.hl_dir and fmt_opts.hl_file
104+
105+
-- Save the original path portion for width calculation
106+
local original_path_portion = path_portion
107+
108+
-- Strip ANSI codes for shortening calculations
109+
path_portion = utils.strip_ansi_coloring(path_portion)
110+
111+
-- For filename_first formatter, the entry is "filename<sep>parent/dir"
112+
-- With --tabstop=1, the tab is rendered as a space in the terminal buffer
113+
-- Only shorten the directory part after the separator
114+
local filename_first_prefix
115+
local filename_first_prefix_width = 0
116+
if fmt_opts and fmt_opts.formatter and fmt_opts.formatter:find("filename_first") then
117+
-- Try tab first, then fall back to space (fzf renders tab as space with --tabstop=1)
118+
local sep_pos = path_portion:find("\t") or path_portion:find(" ")
119+
if sep_pos then
120+
filename_first_prefix = path_portion:sub(1, sep_pos) -- includes the separator
121+
filename_first_prefix_width = fn.strdisplaywidth(filename_first_prefix)
122+
path_portion = path_portion:sub(sep_pos + 1)
123+
-- Adjust path_start for the extmark col position (stays the same,
124+
-- but we'll prepend the prefix to the overlay)
125+
end
126+
end
127+
128+
-- Find the last path separator to determine the directory/filename boundary
129+
local first_sep = path.find_next_separator(path_portion, 1)
130+
if not first_sep then return end
131+
132+
local last_sep_in_path = nil
133+
do
134+
local prev_sep = 0
135+
local s = first_sep
136+
while s do
137+
-- Check if the component between prev_sep and s contains whitespace
138+
-- (indicates we've left the path and entered fzf status text)
139+
if s > prev_sep + 1 then
140+
local component = path_portion:sub(prev_sep + 1, s - 1)
141+
if component:find("%s") then
142+
break
143+
end
144+
end
145+
last_sep_in_path = s
146+
prev_sep = s
147+
s = path.find_next_separator(path_portion, s + 1)
148+
end
149+
end
150+
151+
if not last_sep_in_path then return end
152+
153+
-- Determine the actual path boundary
154+
local actual_path_len
155+
if last_sep_in_path == #path_portion
156+
or path_portion:find("^%s", last_sep_in_path + 1) then
157+
actual_path_len = last_sep_in_path
158+
else
159+
local filename_start = last_sep_in_path + 1
160+
local filename = path_portion:sub(filename_start)
161+
local filename_trimmed = filename:match("^(%S+)") or filename
162+
actual_path_len = last_sep_in_path + #filename_trimmed
163+
end
164+
path_portion = path_portion:sub(1, actual_path_len)
165+
166+
-- Build shortened path: shorten each directory component, keep filename intact
167+
local shortened_parts = {}
168+
local any_shortened = false
169+
-- Track the byte offset of the last separator in the shortened string
170+
-- to split dir/file parts for highlight groups
171+
local shortened_last_sep_byte = 0
172+
local shortened_byte_len = 0
173+
93174
local prev_sep = 0
94175
local sep_pos = path.find_next_separator(path_portion, 1)
95176
while sep_pos do
96177
local component_start = prev_sep + 1
97178
local component = path_portion:sub(component_start, sep_pos - 1)
98-
local component_len = #component
179+
local sep_char = path_portion:sub(sep_pos, sep_pos)
99180

100-
-- Count UTF-8 characters using vim.str_utfindex
101-
-- Use "utf-32" to count code points (actual characters), not bytes.
102-
-- "utf-8" would give byte positions, "utf-16" gives UTF-16 code units.
181+
-- Count UTF-8 characters
103182
local _, component_charlen = vim.str_utfindex(component, "utf-32")
104-
component_charlen = component_charlen or component_len -- fallback to byte length
183+
component_charlen = component_charlen or #component
105184

106-
-- Only conceal if the component has more characters than shorten_len
107185
if component_charlen > shorten_len then
108-
-- Handle special case: component starts with '.' (hidden files/dirs)
109186
local keep_chars = shorten_len
110187
if string.byte(component, 1) == DOT_BYTE and component_charlen > shorten_len + 1 then
111-
-- Keep the dot plus shorten_len characters
112188
keep_chars = shorten_len + 1
113189
end
114-
115-
-- Bounds check to prevent errors
116190
keep_chars = math.min(keep_chars, component_charlen)
117191

118-
-- Convert character count to byte offset using vim.str_byteindex
119192
local keep_bytes = vim.str_byteindex(component, "utf-32", keep_chars, false)
120-
keep_bytes = keep_bytes or keep_chars -- fallback to character count (ASCII approximation)
121-
122-
-- Calculate 0-indexed byte positions in the full line for extmark
123-
-- path_start is 1-indexed, component_start is 1-indexed within path_portion
124-
local line_offset = path_start - 1 + component_start - 1
125-
local conceal_start = line_offset + keep_bytes
126-
local conceal_end = line_offset + component_len -- end of component (before separator)
127-
128-
if conceal_end > conceal_start then
129-
pcall(api.nvim_buf_set_extmark, buf, PathShortener._ns, row, conceal_start, {
130-
end_col = conceal_end,
131-
conceal = "",
132-
ephemeral = true,
133-
})
134-
end
193+
keep_bytes = keep_bytes or keep_chars
194+
195+
local short_comp = component:sub(1, keep_bytes)
196+
shortened_parts[#shortened_parts + 1] = short_comp
197+
shortened_byte_len = shortened_byte_len + #short_comp
198+
any_shortened = true
199+
else
200+
shortened_parts[#shortened_parts + 1] = component
201+
shortened_byte_len = shortened_byte_len + #component
135202
end
203+
shortened_parts[#shortened_parts + 1] = sep_char
204+
shortened_byte_len = shortened_byte_len + #sep_char
205+
shortened_last_sep_byte = shortened_byte_len
206+
136207
prev_sep = sep_pos
137208
sep_pos = path.find_next_separator(path_portion, sep_pos + 1)
138209
end
210+
211+
-- Add the final component
212+
-- For filename_first, the part after tab is all directories, so shorten it too
213+
-- For dirname_first/normal, this is the filename - never shorten
214+
local has_final_component = prev_sep < #path_portion
215+
if has_final_component then
216+
local final_component = path_portion:sub(prev_sep + 1)
217+
if filename_first_prefix then
218+
-- In filename_first mode, final component is a directory - shorten it
219+
local _, final_charlen = vim.str_utfindex(final_component, "utf-32")
220+
final_charlen = final_charlen or #final_component
221+
if final_charlen > shorten_len then
222+
local keep_chars = shorten_len
223+
if string.byte(final_component, 1) == DOT_BYTE and final_charlen > shorten_len + 1 then
224+
keep_chars = shorten_len + 1
225+
end
226+
keep_chars = math.min(keep_chars, final_charlen)
227+
local keep_bytes = vim.str_byteindex(final_component, "utf-32", keep_chars, false)
228+
keep_bytes = keep_bytes or keep_chars
229+
local short_final = final_component:sub(1, keep_bytes)
230+
shortened_parts[#shortened_parts + 1] = short_final
231+
shortened_byte_len = shortened_byte_len + #short_final
232+
any_shortened = true
233+
-- Update last_sep_byte to include this shortened component
234+
-- so the entire path uses dir highlight in filename_first mode
235+
shortened_last_sep_byte = shortened_byte_len
236+
else
237+
shortened_parts[#shortened_parts + 1] = final_component
238+
shortened_byte_len = shortened_byte_len + #final_component
239+
shortened_last_sep_byte = shortened_byte_len
240+
end
241+
else
242+
-- Normal mode: filename - don't shorten
243+
shortened_parts[#shortened_parts + 1] = final_component
244+
end
245+
end
246+
247+
if not any_shortened then return end
248+
249+
local shortened_path = table.concat(shortened_parts)
250+
-- Use original path (with ANSI) for width calculation since that's what we overlay
251+
local original_width = fn.strdisplaywidth(utils.strip_ansi_coloring(original_path_portion)) + filename_first_prefix_width
252+
local shortened_width = fn.strdisplaywidth(shortened_path) + filename_first_prefix_width
253+
local padding = original_width - shortened_width
254+
255+
-- Build the virt_text tuples with appropriate highlights
256+
-- hl_mode="combine" on the extmark merges our fg with the terminal's bg,
257+
-- so cursor line bg from fzf is automatically preserved without detection
258+
local virt_text = {}
259+
260+
-- Build virt_text with shortened path and explicit highlights
261+
if has_fmt_hls then
262+
-- Apply explicit highlights based on path structure
263+
-- For filename_first: everything is directories (use dir_part)
264+
-- For dirname_first: split at last separator
265+
if filename_first_prefix then
266+
-- filename_first: filename prefix uses file_part, directories use dir_part
267+
virt_text[#virt_text + 1] = { filename_first_prefix, win_data.fmt_opts.hl_file }
268+
virt_text[#virt_text + 1] = { shortened_path, win_data.fmt_opts.hl_dir }
269+
else
270+
-- dirname_first: split at last separator
271+
local dir_str = shortened_path:sub(1, shortened_last_sep_byte)
272+
local file_str = shortened_path:sub(shortened_last_sep_byte + 1)
273+
virt_text[#virt_text + 1] = { dir_str, win_data.fmt_opts.hl_dir }
274+
if #file_str > 0 then
275+
virt_text[#virt_text + 1] = { file_str, win_data.fmt_opts.hl_file }
276+
end
277+
end
278+
else
279+
-- No formatter: single unstyled string
280+
virt_text[#virt_text + 1] = { shortened_path }
281+
end
282+
283+
-- Build virt_text with shortened path and explicit highlights
284+
-- Use a single virt_text entry to ensure proper overlay behavior
285+
local virt_text_str
286+
local virt_text_hl
287+
288+
if has_fmt_hls then
289+
if filename_first_prefix then
290+
-- filename_first: combine prefix and path, apply dir highlight to all
291+
-- (the filename in the prefix will keep its original ANSI color via combine)
292+
virt_text_str = filename_first_prefix .. shortened_path .. (padding > 0 and string.rep(" ", padding) or "")
293+
virt_text_hl = win_data.fmt_opts.hl_dir
294+
else
295+
-- dirname_first: split at last separator for different highlights
296+
local dir_str = shortened_path:sub(1, shortened_last_sep_byte)
297+
local file_str = shortened_path:sub(shortened_last_sep_byte + 1)
298+
virt_text_str = dir_str .. file_str .. (padding > 0 and string.rep(" ", padding) or "")
299+
-- Apply dir highlight to the whole string - file part will get its coloring
300+
-- from the underlying ANSI codes via hl_mode="combine"
301+
virt_text_hl = win_data.fmt_opts.hl_dir
302+
end
303+
else
304+
-- No formatter: plain text with padding
305+
virt_text_str = shortened_path .. (padding > 0 and string.rep(" ", padding) or "")
306+
virt_text_hl = nil
307+
end
308+
309+
-- Overlay the shortened+padded path on top of the original
310+
pcall(api.nvim_buf_set_extmark, buf, PathShortener._ns, row, path_start - 1, {
311+
virt_text = { { virt_text_str, virt_text_hl } },
312+
virt_text_pos = "overlay",
313+
hl_mode = "combine",
314+
ephemeral = true,
315+
})
139316
end
140317

141318
---Register a window for path shortening
142319
---@param winid integer window ID
143320
---@param bufnr integer buffer number
144321
---@param shorten_len integer|boolean number of characters to keep
145-
function PathShortener.attach(winid, bufnr, shorten_len)
322+
---@param fmt_opts? { hl_dir?: string, hl_file?: string, formatter?: string }
323+
function PathShortener.attach(winid, bufnr, shorten_len, fmt_opts)
146324
if not winid or not bufnr then return end
147325
PathShortener.setup()
148326
shorten_len = (shorten_len == true) and 1 or tonumber(shorten_len) or 1
149-
PathShortener._wins[winid] = { bufnr = bufnr, shorten_len = shorten_len }
327+
PathShortener._wins[winid] = {
328+
bufnr = bufnr,
329+
shorten_len = shorten_len,
330+
fmt_opts = fmt_opts,
331+
}
150332
end
151333

152334
---Unregister a window from path shortening
@@ -927,22 +1109,18 @@ end
9271109

9281110
function FzfWin:path_shorten_detach()
9291111
PathShortener.detach(self.fzf_winid)
930-
-- Reset conceallevel when path shortening is disabled (e.g., on window reuse)
931-
if api.nvim_win_is_valid(self.fzf_winid) then
932-
vim.wo[self.fzf_winid].conceallevel = 0
933-
vim.wo[self.fzf_winid].concealcursor = ""
934-
end
9351112
end
9361113

9371114
function FzfWin:path_shorten_attach()
9381115
local path_shorten = self._o.winopts.path_shorten
9391116
if not path_shorten then return end
940-
-- Enable conceallevel for the fzf window to show concealed text
941-
if api.nvim_win_is_valid(self.fzf_winid) then
942-
vim.wo[self.fzf_winid].conceallevel = 2
943-
vim.wo[self.fzf_winid].concealcursor = "nvic"
944-
end
945-
PathShortener.attach(self.fzf_winid, self.fzf_bufnr, path_shorten)
1117+
PathShortener.attach(self.fzf_winid, self.fzf_bufnr, path_shorten, {
1118+
hl_dir = self._o.hls and self._o.hls.dir_part,
1119+
hl_file = self._o.hls and self._o.hls.file_part,
1120+
formatter = self._o.formatter,
1121+
fmt_to = self._o._fmt and self._o._fmt.to,
1122+
opts = self._o,
1123+
})
9461124
end
9471125

9481126
---@param buf integer

0 commit comments

Comments
 (0)