-
Notifications
You must be signed in to change notification settings - Fork 6
/
Copy pathsearchreplacelist.lua
607 lines (554 loc) · 17.3 KB
/
searchreplacelist.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
--
-- 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