Files
deb-lite-xl/debian/extra/libraries/widget/searchreplacelist.lua
T
vhaudiquet 3bae6fd448 Bundle curated community plugins (2.1.8-4)
Vendored from upstream, pinned by commit (see debian/extra/README.source):
- gitstatus, gitopen, fontconfig, open_ext, select_colorscheme,
  smoothcaret, nerdicons (font mapping patched to DATADIR, Ships the
  Symbols Nerd Font Mono TTF, SIL OFL 1.1)
- lite-xl-lsp (complete LSP client; idle until a language server is
  configured) plus the lite-xl-widgets library it requires
- 104 language syntax plugins (none duplicate the nine bundled in core)

settings GUI left out upstream is broken: it requires a
libraries.widget.fonts widget that does not exist in
lite-xl-widgets at any revision.

Co-developed-with: ZCode (Z.ai)
2026-09-16 22:45:02 +02:00

608 lines
17 KiB
Lua

--
-- SearchReplaceList Widget.
-- @copyright Jefferson Gonzalez
-- @license MIT
--
local core = require "core"
local common = require "core.common"
local style = require "core.style"
local Widget = require "libraries.widget"
---@class widget.searchreplacelist.lineposition
---@field col1 integer
---@field col2 integer
---@field checked boolean?
---@class widget.searchreplacelist.line
---@field line integer
---@field text string
---@field positions widget.searchreplacelist.lineposition[]
---@class widget.searchreplacelist.file
---@field path string
---@field lines widget.searchreplacelist.line[]
---@field expanded boolean
---@class widget.searchreplacelist.item
---@field checked boolean
---@field file widget.searchreplacelist.file?
---@field parent widget.searchreplacelist.item?
---@field line widget.searchreplacelist.line?
---@field position widget.searchreplacelist.lineposition?
---@class widget.searchreplacelist : widget
---@field replacement string?
---@field selected integer
---@field hovered integer
---@field items widget.searchreplacelist.item[]
---@field total_files integer
---@field total_results integer
---@field base_dir string
---@overload fun(parent:widget, find:string, replacement:string?):widget.searchreplacelist
local SearchReplaceList = Widget:extend()
---Colors to use when performing a replacement.
local DIFF = {
ADD = { common.color "#72b886" },
DEL = { common.color "#F36161" },
TEXT = { common.color "#000000" }
}
---Constructor
---@param parent widget
---@param replacement string?
function SearchReplaceList:new(parent, replacement)
SearchReplaceList.super.new(self, parent)
self.type_name = "widget.searchreplacelist"
self.replacement = replacement or nil
self.selected = 0
self.hovered = 0
self.items = {}
self.max_width = 0
self.hovered_checkbox = false
self.hovered_expander = false
self.scrollable = true
self.total_files = 0
self.total_results = 0
self.base_dir = ""
end
---Overridable event triggered when an item is clicked.
---@param item widget.searchreplacelist.item
---@param clicks integer
function SearchReplaceList:on_item_click(item, clicks) end
---Add a new file with all the matching lines and positions.
---@param path string
---@param lines widget.searchreplacelist.line[]
---@param expanded boolean?
function SearchReplaceList:add_file(path, lines, expanded)
if type(expanded) == "nil" then expanded = true end
table.insert(self.items, {
checked = true,
file = {
path = path,
lines = lines,
expanded = false
}
})
self.total_files = self.total_files + 1
-- expand results and count them
if expanded and #lines > 0 then
self:expand(#self.items, true)
-- count the results only
else
for _, line in ipairs(self.items[#self.items]) do
self.total_results = self.total_results + #line.positions
end
end
end
---Removes all elements from the list and reset it.
function SearchReplaceList:clear()
self.selected = 0
self.hovered = 0
self.items = {}
self.max_width = 0
self.hovered_checkbox = false
self.hovered_expander = false
self.total_files = 0
self.total_results = 0
end
---Uncollapse a file line results.
---@param position integer
function SearchReplaceList:expand(position, count_results)
local parent = self.items[position]
if
parent and parent.file
and
not parent.file.expanded
then
local insert_pos = position+1
local items = {}
for _, line in ipairs(parent.file.lines) do
for _, line_pos in ipairs(line.positions) do
table.insert(items, {
parent = parent,
line = line,
position = line_pos
})
if count_results then
self.total_results = self.total_results + 1
end
end
end
common.splice(self.items, insert_pos, 0, items)
parent.file.expanded = true
end
end
---Collapse a file line results.
---@param position integer
function SearchReplaceList:contract(position)
local parent = self.items[position]
if
parent and parent.file
and
parent.file.expanded
then
local start_pos = position+1
local end_pos = 0
for _, line in ipairs(parent.file.lines) do
for _, _ in ipairs(line.positions) do
end_pos = end_pos + 1
end
end
common.splice(self.items, start_pos, end_pos)
parent.file.expanded = false
end
end
---Collapse/uncollapse a file line results.
---@param position integer
function SearchReplaceList:toggle_expand(position)
local parent = self.items[position]
if parent and parent.file then
if parent.file.expanded then
self:contract(position)
else
self:expand(position)
end
end
end
---Toggle the checkbox of the given item position.
---@param pos integer
function SearchReplaceList:toggle_check(pos)
local item = self.items[pos]
if not item or self.replacement == nil then return end
if item.file then
item.checked = not item.checked
for _, line in ipairs(item.file.lines) do
for _, position in ipairs(line.positions) do
position.checked = item.checked
end
end
else
if type(item.position.checked) == "nil" then
item.position.checked = false
else
item.position.checked = not item.position.checked
end
local parent_checked = true
for _, line in ipairs(item.parent.file.lines) do
for _, position in ipairs(line.positions) do
if position.checked == false then
parent_checked = false
break
end
end
end
item.parent.checked = parent_checked
end
end
---Replace a position on a string with a given replacement.
---@param str string
---@param s integer
---@param e integer
---@param rep string
local function replace_substring(str, s, e, rep)
local head = s <= 1 and "" or string.sub(str, 1, s - 1)
local tail = e >= #str and "" or string.sub(str, e + 1)
return head .. rep .. tail
end
---Applies the replacement on the given item position but not on the real file.
---
---The purpose of this function is to reflect the changes on the listed items.
---@param position integer
function SearchReplaceList:apply_replacement(position)
local item = self.items[position]
if item and item.file and self.replacement then
local replacement = self.replacement
local replacement_len = #self.replacement
for _, line in ipairs(item.file.lines) do
local offset = 0
for _, pos in ipairs(line.positions) do
local col1 = pos.col1 + offset
local col2 = pos.col2 + offset
if pos.checked or type(pos.checked) == "nil" then
line.text = replace_substring(line.text, col1, col2, replacement)
local current_len = col2 - col1 + 1
local len_diff = 0
if current_len > replacement_len then
len_diff = current_len - replacement_len
offset = offset - len_diff
col2 = col2 - len_diff
elseif current_len < replacement_len then
len_diff = replacement_len - current_len
offset = offset + len_diff
col2 = col2 + len_diff
end
end
pos.col1, pos.col2 = col1, col2
end
end
end
end
---Select the item that follows currently selected item.
---@return widget.searchreplacelist.item?
function SearchReplaceList:select_next()
local items_count = #self.items
if items_count <= 0 then return nil end
local selected = self.selected+1
if selected > items_count then selected = 1 end
self.selected = common.clamp(selected, 1, items_count)
self:scroll_to_selected()
return self.items[self.selected]
end
---Select the item that precedes currently selected item.
---@return widget.searchreplacelist.item?
function SearchReplaceList:select_prev()
local items_count = #self.items
if items_count <= 0 then return nil end
local selected = self.selected-1
if selected < 1 then selected = items_count end
self.selected = common.clamp(selected, 1, items_count)
self:scroll_to_selected()
return self.items[self.selected]
end
---Get the currently selected item.
---@return widget.searchreplacelist.item?
function SearchReplaceList:get_selected()
if self.selected > 0 and self.selected <= #self.items then
return self.items[self.selected]
end
return nil
end
---Get the line height used when drawing each item row.
---@return number height
function SearchReplaceList:get_line_height()
return style.padding.y + style.font:get_height()
end
---Used when calculating if vertical scrolling is needed.
---@return number size
function SearchReplaceList:get_scrollable_size()
return #self.items * self:get_line_height() + style.padding.y
end
---Used when calculating if horizontal scrolling is needed.
---@return number size
function SearchReplaceList:get_h_scrollable_size()
local width = style.padding.x / 2
+ style.icon_font:get_width("-")
+ style.padding.x / 2
+ self.max_width
if self.replacement then
local cb_w = self:get_checkbox_size()
width = width
+ cb_w + style.padding.x / 2
+ style.font:get_width(self.replacement)
end
return width
end
---Get the checkbox size based on the line height.
---@param y? number
---@return number w
---@return number h
---@return number y Vertically center align coord based on given y param
function SearchReplaceList:get_checkbox_size(y)
if not y then y = 0 end
local lh = self:get_line_height()
local w = lh * 0.6
local h = w
y = y + ((lh / 2) - (h / 2))
return w, h, y
end
---Get the position of first and last visible items.
---@return integer first
---@return integer last
function SearchReplaceList:get_visible_items_range()
local lh = self:get_line_height()
local min = math.max(1, math.floor(self.scroll.y / lh))
return min, min + math.floor(self.size.y / lh) + 1
end
---Allows iterating the currently visible items only.
---@return fun():integer,widget.searchreplacelist.item,number,number,number,number
function SearchReplaceList:each_visible_item()
return coroutine.wrap(function()
local lh = self:get_line_height()
local x, y = self:get_content_offset()
local min, max = self:get_visible_items_range()
y = y + lh * (min - 1)
for i = min, max do
local item = self.items[i]
if not item then break end
coroutine.yield(i, item, x, y, self.size.x, lh)
y = y + lh
end
end)
end
---Iterates over all files, only those that have a position checked on replacement mode.
---@return fun():integer,widget.searchreplacelist.file
function SearchReplaceList:each_file()
return coroutine.wrap(function()
local replacement = self.replacement
for i, item in ipairs(self.items) do
if item.file then
if replacement then
local none_checked = true
for _, line in ipairs(item.file.lines) do
for _, pos in ipairs(line.positions) do
if type(pos.checked) == "nil" or pos.checked then
none_checked = false
break
end
end
end
if none_checked then
goto continue
end
end
coroutine.yield(i, item.file)
end
::continue::
end
end)
end
---Scroll to currently selected item only if not already visible.
function SearchReplaceList:scroll_to_selected()
if self.selected == 0 then return end
local h = self:get_line_height()
local y = h * (self.selected - 1)
self.scroll.to.y = math.min(self.scroll.to.y, y)
self.scroll.to.y = math.max(self.scroll.to.y, y + h - self.size.y)
end
function SearchReplaceList:on_mouse_moved(mx, my, ...)
if not SearchReplaceList.super.on_mouse_moved(self, mx, my, ...) then
return false
end
self.hovered = 0
for i, item, x,y,w,h in self:each_visible_item() do
w = self.size.x
if mx >= x and my >= y and mx < x + w and my < y + h then
self.hovered = i
x = x + style.padding.x / 2
w = style.icon_font:get_width('-')
if item.file and mx >= x and my >= y and mx < x + w and my < y + h then
self.hovered_expander = true
self.hovered_checkbox = false
else
self.hovered_expander = false
end
if not self.hovered_expander and self.replacement then
x = x + w + style.padding.x / 2
w, h, y = self:get_checkbox_size(y)
if mx >= x and my >= y and mx < x + w and my < y + h then
self.hovered_checkbox = true
self.hovered_expander = false
else
self.hovered_checkbox = false
end
end
break
end
end
return true
end
function SearchReplaceList:on_mouse_pressed(button, x, y, clicks)
if
not SearchReplaceList.super.on_mouse_pressed(self, button, x, y, clicks)
then
return false
end
if self:scrollbar_hovering() then return true end
local item = self.items[self.hovered]
if not item then return true end
self.selected = self.hovered
if self.hovered_checkbox then
self:toggle_check(self.selected)
elseif item.file and (clicks > 1 or self.hovered_expander) then
if not item.file.expanded then
self:expand(self.hovered)
else
self:contract(self.hovered)
end
else
self:on_item_click(item, clicks)
end
return true
end
function SearchReplaceList:draw_checkbox(checked, hovered, x, y, lh)
local w, h, cy = self:get_checkbox_size(y)
renderer.draw_rect(x, cy, w, h, style.text)
renderer.draw_rect(
x + 2, cy + 2, w-4, h-4,
hovered and style.dim or style.background
)
if checked then
renderer.draw_rect(x + 5, cy + 5, w-10, h-10, style.caret)
end
return x + w
end
function SearchReplaceList:draw()
if not SearchReplaceList.super.draw(self) then return false end
core.push_clip_rect(
self.position.x,
self.position.y,
self.size.x,
self.size.y
)
local font = self:get_font()
local replacement = self.replacement
local replacement_width = font:get_width(replacement or "")
local file_path
self.max_width = 0
for i, item, x,y,w,h in self:each_visible_item() do
if item.file then
file_path = common.relative_path(self.base_dir, item.file.path)
end
-- add left padding
x = x + style.padding.x / 2
local text_color = style.text
if i == self.selected then
renderer.draw_rect(self.position.x, y, self.size.x, h, style.selection)
elseif i == self.hovered then
renderer.draw_rect(self.position.x, y, self.size.x, h, style.line_highlight)
text_color = style.accent
end
-- draw collapse/contract symbol
if item.file then
common.draw_text(
style.icon_font,
(self.hovered == i and self.hovered_expander) and style.accent or style.text,
item.file.expanded and "-" or "+",
"left",
x, y, w, h
)
x = x + style.icon_font:get_width("-")
else
x = x + style.icon_font:get_width("-")
end
x = x + style.padding.x / 2
-- draw checkbox
if replacement then
local checked = false
if item.file then
checked = item.checked
else
if type(item.position.checked) == "nil" then
checked = true
else
checked = item.position.checked
end
end
x = style.padding.x / 2 + self:draw_checkbox(
checked,
i == self.hovered and self.hovered_checkbox or false,
x, y, h
)
end
-- draw text
local text = item.file and file_path or item.line.text
local all_text = ""
if item.line then
local start_pos, end_pos = 1, #text
local prefix, postfix = "", ""
-- truncate long lines to keep good rendering performance
if #text > 120 then
start_pos = math.max(item.position.col1 - 50, 1)
end_pos = math.min(item.position.col2 + 50, end_pos)
if start_pos ~= 1 then prefix = "..." end
if end_pos ~= #text then postfix = "..." end
end
x = common.draw_text(
font,
style.syntax["number"],
tostring(item.line.line) .. ": ",
"left",
x, y, w, h
)
local start_text = item.position.col1 ~= 1 and
prefix .. text:sub(start_pos, item.position.col1-1)
or
""
local end_text = item.position.col2 ~= #text and
text:sub(item.position.col2+1, end_pos) .. postfix
or
""
local found_text = text:sub(item.position.col1, item.position.col2)
local found_width = style.font:get_width(found_text)
if start_text ~= "" then
x = common.draw_text(
font,
text_color,
start_text,
"left",
x, y, w, h
)
end
local found_color = not replacement and style.dim or DIFF.DEL
local found_text_color = not replacement and text_color or DIFF.TEXT
renderer.draw_rect(x, y, found_width, h, found_color)
x = common.draw_text(font, found_text_color, found_text, "left", x, y, w, h)
if replacement then
renderer.draw_rect(x, y, replacement_width, h, DIFF.ADD)
x = common.draw_text(font, DIFF.TEXT, replacement, "left", x, y, w, h)
end
if end_text ~= "" then
x = common.draw_text(
font,
text_color,
end_text,
"left",
x, y, w, h
)
end
all_text = item.line.line .. ": " .. start_text .. found_text .. end_text
else
x = common.draw_text(font, text_color, text, "left", x, y, w, h)
all_text = file_path
end
-- recalc max_width for horizontal scrollbar
self.max_width = math.max(self.max_width, style.font:get_width(all_text))
end
core.pop_clip_rect()
self:draw_scrollbar()
return true
end
return SearchReplaceList