Skip to content

Commit 460980a

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 460980a

1 file changed

Lines changed: 166 additions & 40 deletions

File tree

lua/fzf-lua/win.lua

Lines changed: 166 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,27 @@ local api = vim.api
77
local fn = vim.fn
88

99
---@class fzf-lua.PathShortener
10-
---@field _ns integer?
11-
---@field _wins table<integer, { bufnr: integer, shorten_len: integer }>
10+
---@field _ns integer? namespace for ephemeral conceal extmarks
11+
---@field _ns_padding integer? namespace for persistent inline VT padding
12+
---@field _wins table<integer, fzf-lua.PathShortener.WinData>
13+
---@field _bufs table<integer, boolean> buffers with active on_lines attachment
1214
local PathShortener = {}
1315

1416
PathShortener._wins = {}
17+
PathShortener._bufs = {}
1518

1619
-- Hoist constant byte values for performance
1720
local DOT_BYTE = path.dot_byte
1821

1922
function PathShortener.setup()
2023
if PathShortener._ns then return end
2124
PathShortener._ns = api.nvim_create_namespace("fzf-lua.win.path_shorten")
25+
PathShortener._ns_padding = api.nvim_create_namespace("fzf-lua.win.path_shorten.padding")
2226

23-
-- Register decoration provider with ephemeral extmarks
27+
-- Register decoration provider with ephemeral conceal extmarks.
28+
-- Conceal preserves all terminal highlights (fzf match highlighting,
29+
-- cursor line bg, formatter ANSI colors) unlike overlay VT which
30+
-- replaces the underlying terminal cell content entirely.
2431
api.nvim_set_decoration_provider(PathShortener._ns, {
2532
on_win = function(_, winid, bufnr, _topline, _botline)
2633
-- Only process registered fzf windows
@@ -33,25 +40,24 @@ function PathShortener.setup()
3340
on_line = function(_, winid, bufnr, row)
3441
local win_data = PathShortener._wins[winid]
3542
if not win_data then return end
36-
PathShortener._apply_line(bufnr, row, win_data.shorten_len)
43+
PathShortener._apply_conceal(bufnr, row, win_data.shorten_len)
3744
end,
3845
})
3946
end
4047

41-
---Apply path shortening to a single line using ephemeral extmarks
42-
---@param buf integer buffer number
43-
---@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)
46-
local lines = api.nvim_buf_get_lines(buf, row, row + 1, false)
47-
local line = lines[1]
48-
if not line or #line == 0 then return end
48+
---Parse a terminal buffer line and return conceal/padding information.
49+
---Identifies directory components in the path that can be shortened and
50+
---calculates both the conceal byte ranges and the total display width
51+
---that will be lost to concealing (for compensating inline VT padding).
52+
---@param line string the terminal buffer line text
53+
---@param shorten_len integer number of characters to keep per component
54+
---@return table? result with conceal_ranges, total_concealed_width, padding_col
55+
function PathShortener._parse_line(line, shorten_len)
56+
if #line == 0 then return nil end
4957

5058
-- Find the path portion of the line
5159
-- Lines may have prefixes like icons separated by nbsp (U+2002)
5260
-- 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 " ")
5561
local path_start = 1
5662
local last_nbsp = line:find(utils.nbsp, 1, true)
5763
if last_nbsp then
@@ -61,21 +67,27 @@ function PathShortener._apply_line(buf, row, shorten_len)
6167
last_nbsp = line:find(utils.nbsp, path_start, true)
6268
until not last_nbsp
6369
else
64-
-- No nbsp means no icons - skip fzf's pointer/marker prefix
65-
-- Paths start with: alphanumeric, `/`, `~`, `.`, or drive letter (Windows)
70+
-- No nbsp: either file_icons=false entry or a non-entry line (prompt/header)
71+
-- Filter out fzf's prompt/info line which contains the match counter
72+
if line:find("%d+/%d+") then return nil end
6673
-- Skip leading whitespace and pointer characters until we hit a path char
6774
local first_path_char = line:find("[%w/~%.]")
68-
if first_path_char then
69-
path_start = first_path_char
70-
end
75+
if not first_path_char then return nil end
76+
path_start = first_path_char
77+
-- Reject lines where space appears before the first path separator
78+
-- This filters out header lines like "cwd: /path/to/dir"
79+
local sub = line:sub(path_start)
80+
local first_sep = path.find_next_separator(sub, 1)
81+
if not first_sep then return nil end
82+
local first_space = sub:find(" ")
83+
if first_space and first_space < first_sep then return nil end
7184
end
7285

7386
-- Find where the path ends (at first colon after path_start, if any)
7487
-- But be careful with Windows paths like C:\...
7588
local path_end = #line
7689
local colon_search_start = path_start
7790
-- 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
7991
if string.byte(line, path_start + 1) == path.colon_byte
8092
and path.byte_is_separator(string.byte(line, path_start + 2)) then
8193
colon_search_start = path_start + 2
@@ -85,57 +97,130 @@ function PathShortener._apply_line(buf, row, shorten_len)
8597
path_end = colon_pos - 1
8698
end
8799

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

92-
-- Use path.find_next_separator to iterate through directory components
102+
-- Iterate directory components and calculate conceal ranges
103+
local conceal_ranges = {}
104+
local total_concealed_width = 0
93105
local prev_sep = 0
94106
local sep_pos = path.find_next_separator(path_portion, 1)
107+
95108
while sep_pos do
96109
local component_start = prev_sep + 1
97110
local component = path_portion:sub(component_start, sep_pos - 1)
111+
112+
-- Stop processing if component contains whitespace - this means we've
113+
-- passed the actual path into fzf UI text (e.g., trailing spaces before border)
114+
if component:find("%s") then
115+
break
116+
end
117+
98118
local component_len = #component
99119

100120
-- 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.
103121
local _, component_charlen = vim.str_utfindex(component, "utf-32")
104-
component_charlen = component_charlen or component_len -- fallback to byte length
122+
component_charlen = component_charlen or component_len
105123

106-
-- Only conceal if the component has more characters than shorten_len
107124
if component_charlen > shorten_len then
108125
-- Handle special case: component starts with '.' (hidden files/dirs)
109126
local keep_chars = shorten_len
110127
if string.byte(component, 1) == DOT_BYTE and component_charlen > shorten_len + 1 then
111-
-- Keep the dot plus shorten_len characters
112128
keep_chars = shorten_len + 1
113129
end
114-
115-
-- Bounds check to prevent errors
116130
keep_chars = math.min(keep_chars, component_charlen)
117131

118-
-- Convert character count to byte offset using vim.str_byteindex
119132
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)
133+
keep_bytes = keep_bytes or keep_chars
121134

122135
-- 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
124136
local line_offset = path_start - 1 + component_start - 1
125137
local conceal_start = line_offset + keep_bytes
126-
local conceal_end = line_offset + component_len -- end of component (before separator)
138+
local conceal_end = line_offset + component_len
127139

128140
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-
})
141+
conceal_ranges[#conceal_ranges + 1] = { conceal_start, conceal_end }
142+
local concealed_text = component:sub(keep_bytes + 1)
143+
total_concealed_width = total_concealed_width + fn.strdisplaywidth(concealed_text)
134144
end
135145
end
146+
136147
prev_sep = sep_pos
137148
sep_pos = path.find_next_separator(path_portion, sep_pos + 1)
138149
end
150+
151+
if #conceal_ranges == 0 then return nil end
152+
153+
-- Determine padding placement for inline VT that compensates concealed width.
154+
-- Search for fzf's border/scrollbar character │ (U+2502) in the line.
155+
-- For native fzf previewer (bat), this is the border between list and preview panes.
156+
-- For builtin previewer, this is the scrollbar at the end of some lines.
157+
-- Place padding right before the border to push it back to its original
158+
-- display column, compensating for the concealed width.
159+
local padding_col
160+
local border_char = "\xe2\x94\x82" -- │ U+2502 BOX DRAWINGS LIGHT VERTICAL
161+
local border_pos = line:find(border_char, path_start, true)
162+
if border_pos then
163+
padding_col = border_pos - 1 -- 0-indexed, right before │
164+
else
165+
padding_col = #line -- 0-indexed, after last byte
166+
end
167+
168+
return {
169+
conceal_ranges = conceal_ranges,
170+
total_concealed_width = total_concealed_width,
171+
padding_col = padding_col,
172+
}
173+
end
174+
175+
---Apply ephemeral conceal extmarks for a single line (called from decoration provider).
176+
---Concealing preserves all terminal highlights on non-concealed characters.
177+
---@param buf integer buffer number
178+
---@param row integer 0-indexed line number
179+
---@param shorten_len integer number of characters to keep
180+
function PathShortener._apply_conceal(buf, row, shorten_len)
181+
local lines = api.nvim_buf_get_lines(buf, row, row + 1, false)
182+
local line = lines[1]
183+
if not line or #line == 0 then return end
184+
185+
local result = PathShortener._parse_line(line, shorten_len)
186+
if not result then return end
187+
188+
for _, range in ipairs(result.conceal_ranges) do
189+
pcall(api.nvim_buf_set_extmark, buf, PathShortener._ns, row, range[1], {
190+
end_col = range[2],
191+
conceal = "",
192+
ephemeral = true,
193+
})
194+
end
195+
end
196+
197+
---Update non-ephemeral inline VT padding for changed lines.
198+
---Called from nvim_buf_attach on_lines callback (outside rendering phase),
199+
---which is safe for setting non-ephemeral extmarks. Ephemeral inline VT
200+
---does not render in terminal buffers, so we must use non-ephemeral.
201+
---The padding compensates for the display width lost to concealing,
202+
---preventing fzf's scrollbar and preview border from shifting left.
203+
---@param buf integer buffer number
204+
---@param firstline integer first changed line (0-indexed)
205+
---@param new_lastline integer end of changed range (0-indexed, exclusive)
206+
---@param shorten_len integer number of characters to keep
207+
function PathShortener._update_padding(buf, firstline, new_lastline, shorten_len)
208+
-- Clear existing padding extmarks in the changed range
209+
pcall(api.nvim_buf_clear_namespace, buf, PathShortener._ns_padding, firstline, new_lastline)
210+
211+
local lines = api.nvim_buf_get_lines(buf, firstline, new_lastline, false)
212+
for i, line in ipairs(lines) do
213+
if line and #line > 0 then
214+
local result = PathShortener._parse_line(line, shorten_len)
215+
if result and result.total_concealed_width > 0 then
216+
local row = firstline + i - 1
217+
pcall(api.nvim_buf_set_extmark, buf, PathShortener._ns_padding, row, result.padding_col, {
218+
virt_text = { { string.rep(" ", result.total_concealed_width) } },
219+
virt_text_pos = "inline",
220+
})
221+
end
222+
end
223+
end
139224
end
140225

141226
---Register a window for path shortening
@@ -146,14 +231,55 @@ function PathShortener.attach(winid, bufnr, shorten_len)
146231
if not winid or not bufnr then return end
147232
PathShortener.setup()
148233
shorten_len = (shorten_len == true) and 1 or tonumber(shorten_len) or 1
149-
PathShortener._wins[winid] = { bufnr = bufnr, shorten_len = shorten_len }
234+
PathShortener._wins[winid] = {
235+
bufnr = bufnr,
236+
shorten_len = shorten_len,
237+
}
238+
239+
-- Attach to buffer for content changes (to manage non-ephemeral padding extmarks).
240+
-- on_lines fires outside the rendering phase, making it safe to set
241+
-- non-ephemeral extmarks without causing infinite redraw loops.
242+
if not PathShortener._bufs[bufnr] then
243+
PathShortener._bufs[bufnr] = true
244+
api.nvim_buf_attach(bufnr, false, {
245+
on_lines = function(_, buf, _, firstline, _lastline, new_lastline)
246+
-- Find shorten_len for this buffer
247+
local sl
248+
for _, wd in pairs(PathShortener._wins) do
249+
if wd.bufnr == buf then
250+
sl = wd.shorten_len
251+
break
252+
end
253+
end
254+
if not sl then
255+
PathShortener._bufs[buf] = nil
256+
return true -- detach
257+
end
258+
-- Defer padding update to the next event loop tick.
259+
-- Setting non-ephemeral extmarks directly within on_lines during
260+
-- terminal I/O can cause the terminal grid to miscalculate line
261+
-- widths, breaking the floating window layout. vim.schedule moves
262+
-- the extmark update out of the terminal I/O handler.
263+
vim.schedule(function()
264+
if not api.nvim_buf_is_valid(buf) then return end
265+
PathShortener._update_padding(buf, firstline, new_lastline, sl)
266+
end)
267+
end,
268+
})
269+
end
150270
end
151271

152272
---Unregister a window from path shortening
153273
---@param winid integer window ID
154274
function PathShortener.detach(winid)
155275
if not winid then return end
276+
local win_data = PathShortener._wins[winid]
156277
PathShortener._wins[winid] = nil
278+
-- Clear padding extmarks and mark buffer for detach
279+
if win_data and win_data.bufnr then
280+
pcall(api.nvim_buf_clear_namespace, win_data.bufnr, PathShortener._ns_padding, 0, -1)
281+
PathShortener._bufs[win_data.bufnr] = nil
282+
end
157283
end
158284

159285
---@alias fzf-lua.win.previewPos "up"|"down"|"left"|"right"

0 commit comments

Comments
 (0)